explorbot 0.3.4 → 0.4.0

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 (104) hide show
  1. package/bin/explorbot-cli.ts +18 -13
  2. package/boat/doc-collector/src/cli.ts +3 -0
  3. package/boat/doc-collector/src/docbot.ts +3 -1
  4. package/boat/prima/src/cli.ts +21 -8
  5. package/boat/prima/src/envelope.ts +35 -9
  6. package/boat/prima/src/prima.ts +23 -10
  7. package/dist/bin/explorbot-cli.js +19 -13
  8. package/dist/boat/doc-collector/src/cli.js +3 -0
  9. package/dist/boat/doc-collector/src/docbot.js +3 -1
  10. package/dist/boat/prima/src/cli.js +19 -8
  11. package/dist/boat/prima/src/envelope.js +24 -6
  12. package/dist/boat/prima/src/prima.js +23 -11
  13. package/dist/package.json +2 -2
  14. package/dist/src/action-result.d.ts +9 -1
  15. package/dist/src/action-result.js +57 -18
  16. package/dist/src/action.d.ts +1 -1
  17. package/dist/src/action.js +87 -12
  18. package/dist/src/ai/driller.d.ts +0 -1
  19. package/dist/src/ai/driller.js +8 -20
  20. package/dist/src/ai/fisherman-tools.d.ts +9 -0
  21. package/dist/src/ai/fisherman-tools.js +52 -6
  22. package/dist/src/ai/fisherman.d.ts +4 -2
  23. package/dist/src/ai/fisherman.js +48 -27
  24. package/dist/src/ai/historian/codeceptjs.js +1 -1
  25. package/dist/src/ai/historian/playwright.js +1 -1
  26. package/dist/src/ai/pilot.d.ts +1 -0
  27. package/dist/src/ai/pilot.js +15 -1
  28. package/dist/src/ai/planner.js +1 -1
  29. package/dist/src/ai/provider.js +47 -4
  30. package/dist/src/ai/researcher/deep-analysis.js +1 -3
  31. package/dist/src/ai/researcher.js +3 -3
  32. package/dist/src/ai/tester.d.ts +3 -0
  33. package/dist/src/ai/tester.js +40 -3
  34. package/dist/src/ai/tools.d.ts +1 -0
  35. package/dist/src/ai/tools.js +13 -6
  36. package/dist/src/api/request-result.d.ts +2 -0
  37. package/dist/src/api/request-result.js +8 -2
  38. package/dist/src/api/request-store.d.ts +3 -2
  39. package/dist/src/api/request-store.js +66 -14
  40. package/dist/src/commands/explore-command.d.ts +6 -0
  41. package/dist/src/commands/explore-command.js +27 -2
  42. package/dist/src/commands/freesail-command.js +10 -1
  43. package/dist/src/commands/plans-command.js +6 -6
  44. package/dist/src/config.js +1 -0
  45. package/dist/src/experience-tracker.js +5 -0
  46. package/dist/src/explorbot.d.ts +0 -1
  47. package/dist/src/explorbot.js +23 -36
  48. package/dist/src/state-manager.d.ts +5 -1
  49. package/dist/src/state-manager.js +10 -7
  50. package/dist/src/test-plan.d.ts +3 -0
  51. package/dist/src/test-plan.js +27 -0
  52. package/dist/src/utils/aria.d.ts +1 -1
  53. package/dist/src/utils/aria.js +6 -42
  54. package/dist/src/utils/html-diff.d.ts +4 -0
  55. package/dist/src/utils/html-diff.js +62 -7
  56. package/dist/src/utils/html.d.ts +5 -15
  57. package/dist/src/utils/html.js +14 -85
  58. package/dist/src/utils/overlay.d.ts +56 -11
  59. package/dist/src/utils/overlay.js +191 -21
  60. package/dist/src/utils/request-map.d.ts +7 -0
  61. package/dist/src/utils/request-map.js +16 -0
  62. package/dist/src/utils/url-matcher.js +4 -2
  63. package/docs/reference/commands.md +8 -1
  64. package/docs/reference/websocket.md +1 -0
  65. package/docs/superpowers/plans/2026-08-29-fisherman-reliability.md +953 -0
  66. package/docs/superpowers/plans/2026-08-29-region-states.md +1292 -0
  67. package/docs/superpowers/plans/2026-08-30-fisherman-live-session-auth.md +457 -0
  68. package/docs/superpowers/specs/2026-08-29-fisherman-reliability-design.md +45 -0
  69. package/docs/superpowers/specs/2026-08-29-region-states-design.md +262 -0
  70. package/docs/superpowers/specs/2026-08-29-region-states-fixes-design.md +269 -0
  71. package/docs/superpowers/specs/2026-08-30-fisherman-live-session-auth-design.md +37 -0
  72. package/docs/workflow/agentic-usage.md +1 -0
  73. package/docs/workflow/ci.md +1 -0
  74. package/package.json +2 -2
  75. package/src/action-result.ts +61 -22
  76. package/src/action.ts +87 -14
  77. package/src/ai/driller.ts +7 -39
  78. package/src/ai/fisherman-tools.ts +56 -7
  79. package/src/ai/fisherman.ts +48 -28
  80. package/src/ai/historian/codeceptjs.ts +1 -1
  81. package/src/ai/historian/playwright.ts +1 -1
  82. package/src/ai/pilot.ts +11 -1
  83. package/src/ai/planner.ts +1 -1
  84. package/src/ai/provider.ts +48 -4
  85. package/src/ai/researcher/deep-analysis.ts +1 -2
  86. package/src/ai/researcher.ts +3 -3
  87. package/src/ai/tester.ts +40 -3
  88. package/src/ai/tools.ts +17 -9
  89. package/src/api/request-result.ts +10 -2
  90. package/src/api/request-store.ts +60 -13
  91. package/src/commands/explore-command.ts +25 -2
  92. package/src/commands/freesail-command.ts +7 -1
  93. package/src/commands/plans-command.ts +6 -6
  94. package/src/config.ts +1 -0
  95. package/src/experience-tracker.ts +5 -1
  96. package/src/explorbot.ts +20 -36
  97. package/src/state-manager.ts +13 -7
  98. package/src/test-plan.ts +29 -0
  99. package/src/utils/aria.ts +7 -44
  100. package/src/utils/html-diff.ts +62 -7
  101. package/src/utils/html.ts +14 -91
  102. package/src/utils/overlay.ts +226 -23
  103. package/src/utils/request-map.ts +19 -0
  104. package/src/utils/url-matcher.ts +3 -2
@@ -98,7 +98,7 @@ export function WithPlaywright<T extends Constructor>(Base: T) {
98
98
  lines.push('');
99
99
  lines.push(`test.describe('${escapeString(plan.title)}', () => {`);
100
100
 
101
- const startUrl = plan.url || plan.tests[0]?.startUrl;
101
+ const startUrl = plan.startUrl;
102
102
  if (startUrl) {
103
103
  lines.push(' test.beforeEach(async ({ page }) => {');
104
104
  lines.push(` await page.goto('${escapeString(startUrl)}');`);
package/src/ai/pilot.ts CHANGED
@@ -765,6 +765,7 @@ export class Pilot implements Agent {
765
765
  const parts = [c.type];
766
766
  if (c.title) parts.push(`"${c.title}"`);
767
767
  if (c.id) parts.push(`(id: ${c.id})`);
768
+ if (c.request) parts.push(`via ${c.request}`);
768
769
  return parts.join(' ');
769
770
  });
770
771
  const stepText = `Precondition: created ${items.join(', ')}`;
@@ -827,7 +828,13 @@ export class Pilot implements Agent {
827
828
 
828
829
  const focusArea = state.overlay;
829
830
  if (focusArea.detected) {
830
- lines.push(`modal: ${focusArea.name || focusArea.type}`);
831
+ let line = `modal: ${focusArea.name || focusArea.type}`;
832
+ if (focusArea.root) line += ` (root: ${focusArea.root})`;
833
+ lines.push(line);
834
+ } else if (focusArea.present) {
835
+ let line = `region: ${focusArea.name || 'unnamed'} (inline`;
836
+ if (focusArea.root) line += `, root: ${focusArea.root}`;
837
+ lines.push(`${line})`);
831
838
  } else {
832
839
  lines.push('modal: none');
833
840
  }
@@ -1114,6 +1121,8 @@ export class Pilot implements Agent {
1114
1121
  state), instruct Tester to verify() and finish(). If goal was already true at the start, propose
1115
1122
  different input data so the test is meaningful. If Tester repeats the same successful action, STOP.
1116
1123
 
1124
+ If needed you should pick the exact item the scenario should act on (from the page, or precondition() one) and pass it to tester
1125
+
1117
1126
  Action classification: GOAL-ADVANCING actions mutate the scenario's subject data (create/edit/delete/submit/verify).
1118
1127
  VIEW-ONLY actions toggle filters/tabs/sort/collapse without changing data. One VIEW-ONLY to reveal a
1119
1128
  target is fine; ≥2 consecutive VIEW-ONLY actions with no GOAL-ADVANCING action in between is thrashing
@@ -1131,6 +1140,7 @@ export class Pilot implements Agent {
1131
1140
  Diagnostic patterns (use <state>, executed/element/skipped fields, ariaDiff):
1132
1141
  - Click failed + button in "disabled buttons" → required field missing. Instruct fill first.
1133
1142
  - "modal: none" but Tester targets a modal → modal closed; re-trigger.
1143
+ - "region:" in <state> → a large area appeared in place without navigation (subview, wizard step, panel). Direct Tester to act inside it; the rest of the page is still usable.
1134
1144
  - Action SUCCESS but ariaDiff empty → may have worked without visible DOM change; check result message.
1135
1145
  - MultipleElementsFound → xpathCheck() to identify the right one, then precise locator or visualClick().
1136
1146
  - Wrong page (settings vs feature) → getVisitedStates() then back() or reset(). Don't try breadcrumbs (SPA back-nav is unreliable).
package/src/ai/planner.ts CHANGED
@@ -33,7 +33,7 @@ const TasksSchema = z.object({
33
33
  scenarios: z
34
34
  .array(
35
35
  z.object({
36
- scenario: z.string().describe('A single sentence describing what to test'),
36
+ scenario: z.string().describe('A single sentence describing the behavior to test.'),
37
37
  priority: z.enum(['critical', 'important', 'high', 'normal', 'low']).describe('Priority of the task based on business importance'),
38
38
  startUrl: z.string().nullable().describe('Start URL for the test if different from plan URL. Use only stable feature/list/detail pages, not transient create/edit/modal URLs unless the scenario specifically starts inside that form.'),
39
39
  steps: z.array(z.string()).describe('List of steps to perform for this scenario. Each step should be a specific action (e.g., "Open the form", "Enter required data", "Submit the form"). Keep steps atomic and actionable.'),
@@ -2,7 +2,8 @@ import { OpenTelemetry } from '@ai-sdk/otel';
2
2
  import { LangfuseSpanProcessor } from '@langfuse/otel';
3
3
  import { NodeSDK } from '@opentelemetry/sdk-node';
4
4
  import { AsyncLocalStorage } from 'node:async_hooks';
5
- import { generateObject, generateText, isStepCount, registerTelemetry, tool } from 'ai';
5
+ import dedent from 'dedent';
6
+ import { APICallError, generateObject, generateText, isStepCount, registerTelemetry, tool } from 'ai';
6
7
  import type { ModelMessage } from 'ai';
7
8
  import { z } from 'zod';
8
9
  import { clearActivity, setActivity } from '../activity.ts';
@@ -423,10 +424,29 @@ export class Provider {
423
424
  const stopConditions: any[] = [isStepCount(maxRoundtrips)];
424
425
  if (extraStop) stopConditions.push(extraStop);
425
426
  const config = this.buildGenerateConfig({ tools: toolsWithCommentary, maxOutputTokens: 16384, toolChoice: 'auto', experimental_repairToolCall: repairToolCall }, { stopWhen: stopConditions, model }, options);
427
+ let attemptMessages = messages;
428
+ let invalidRequestFeedbackAdded = false;
429
+ const executedStepMessages: ModelMessage[] = [];
426
430
  try {
427
431
  const response = await this.withModelRequestSlot(() =>
428
432
  withRetry(async () => {
429
- const result = (await this.raceWithIdleTimeout((signal) => generateText({ messages, ...config, abortSignal: signal }), config.timeout || 30000)) as any;
433
+ const stepMessages: ModelMessage[] = [];
434
+ const onStepEnd = (step: any) => {
435
+ stepMessages.push(...(step.response?.messages || []));
436
+ };
437
+ const result = (await this.raceWithIdleTimeout((signal) => generateText({ messages: attemptMessages, ...config, abortSignal: signal, onStepEnd }), config.timeout || 30000).catch((error) => {
438
+ if (stepMessages.length > 0) {
439
+ tag('warning').log(`Keeping ${stepMessages.length} messages from tool steps that already ran before the failure`);
440
+ executedStepMessages.push(...stepMessages);
441
+ attemptMessages = [...attemptMessages, ...stepMessages];
442
+ }
443
+ if (!invalidRequestFeedbackAdded) {
444
+ const amended = withInvalidRequestFeedback(attemptMessages, error);
445
+ invalidRequestFeedbackAdded = amended !== attemptMessages;
446
+ attemptMessages = amended;
447
+ }
448
+ throw error;
449
+ })) as any;
430
450
  this.recordUsage(options.agentName || 'unknown', modelName, result.usage);
431
451
  const hasToolCall = (result.toolCalls?.length || 0) > 0;
432
452
  if (!result.text && !hasToolCall && result.finishReason === 'length') {
@@ -438,6 +458,8 @@ export class Provider {
438
458
 
439
459
  clearActivity();
440
460
 
461
+ withExecutedSteps(response, executedStepMessages);
462
+
441
463
  // Log tool usage summary
442
464
  if (response.toolCalls && response.toolCalls.length > 0) {
443
465
  responseLog(response.toolCalls);
@@ -452,12 +474,13 @@ export class Provider {
452
474
  } catch (error: any) {
453
475
  clearActivity();
454
476
  if (error?.message?.includes('Tool choice is required')) {
455
- return { text: '', toolCalls: [], toolResults: [], response: { messages: [] }, usage: null };
477
+ return { text: '', toolCalls: [], toolResults: [], responseMessages: executedStepMessages, usage: null };
456
478
  }
457
479
  if (error?.name === 'AbortError') throw error;
458
480
  if (error instanceof ContextLengthError) throw error;
459
481
  if (Provider.isContextLengthError(error)) {
460
- return this.recoverFromContextLength(error, messages, options, (m, o) => this.generateWithTools(m, model, tools, o));
482
+ const recovered = await this.recoverFromContextLength(error, attemptMessages, options, (m, o) => this.generateWithTools(m, model, tools, o));
483
+ return withExecutedSteps(recovered, executedStepMessages);
461
484
  }
462
485
  if (error.constructor?.name === 'AI_APICallError') {
463
486
  responseLog(error.message);
@@ -693,6 +716,27 @@ function repairToolCall(options: ToolCallRepairOptions): any | null {
693
716
  return repairHarmonyChannel(options);
694
717
  }
695
718
 
719
+ function withExecutedSteps(result: any, executed: ModelMessage[]): any {
720
+ if (executed.length === 0) return result;
721
+ return Object.defineProperty(result, 'responseMessages', { value: [...executed, ...(result.responseMessages || [])], configurable: true, enumerable: true });
722
+ }
723
+
724
+ function withInvalidRequestFeedback(messages: ModelMessage[], error: unknown): ModelMessage[] {
725
+ if (!(error instanceof APICallError) || error.statusCode !== 400) return messages;
726
+ tag('warning').log('Provider rejected the request as invalid — relaying its reason before the retry');
727
+ return [
728
+ ...messages,
729
+ {
730
+ role: 'user',
731
+ content: dedent`
732
+ The previous request was rejected by the provider as invalid:
733
+ "${error.message}"
734
+ Fix what it describes and re-issue the request.
735
+ `,
736
+ },
737
+ ];
738
+ }
739
+
696
740
  function repairChannelMarker({ toolCall, tools }: ToolCallRepairOptions): any | null {
697
741
  const markerIndex = toolCall.toolName.indexOf('<|channel|>');
698
742
  if (markerIndex <= 0) return null;
@@ -89,8 +89,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
89
89
 
90
90
  async researchOverlay(current: ActionResult, previous: ActionResult, pageStateHash: string): Promise<string | null> {
91
91
  const focusArea = current.overlay;
92
- if (!focusArea.detected || !focusArea.name) return null;
93
- if (focusArea.type !== 'dialog' && focusArea.type !== 'modal') return null;
92
+ if (!focusArea.present || !focusArea.name) return null;
94
93
 
95
94
  const cached = getCachedResearch(pageStateHash);
96
95
  if (!cached) return null;
@@ -76,7 +76,7 @@ export class Researcher extends ResearcherBase implements Agent {
76
76
  }
77
77
 
78
78
  static getCachedResearch(state: WebPageState): string {
79
- return getCachedResearch(state.hash || '');
79
+ return getCachedResearch(ActionResult.fromState(state).baseHash);
80
80
  }
81
81
 
82
82
  getSystemMessage(): string {
@@ -96,7 +96,7 @@ export class Researcher extends ResearcherBase implements Agent {
96
96
  const maxRetries = (this.config.ai?.agents?.researcher as any)?.retries ?? 2;
97
97
  let retriesLeft = opts._retriesLeft ?? maxRetries;
98
98
  this.actionResult = ActionResult.fromState(state);
99
- const stateHash = state.hash || this.actionResult.getStateHash();
99
+ const stateHash = this.actionResult.baseHash;
100
100
  const researchState = { ...state, hash: stateHash };
101
101
 
102
102
  if (!force && stateHash) {
@@ -268,7 +268,7 @@ export class Researcher extends ResearcherBase implements Agent {
268
268
 
269
269
  if (!interrupted() && deep) {
270
270
  try {
271
- await this.performDeepAnalysis(state, result);
271
+ await this.performDeepAnalysis(researchState, result);
272
272
  } catch (err) {
273
273
  tag('warning').log(`Deep analysis failed, continuing with best-effort research: ${err instanceof Error ? err.message : err}`);
274
274
  }
package/src/ai/tester.ts CHANGED
@@ -63,6 +63,8 @@ export class Tester extends TaskAgent implements Agent {
63
63
  private seenUiMapUrls = new Set<string>();
64
64
  private lastAnalyzedStateHash: string | null = null;
65
65
  private stalledIterations = 0;
66
+ private previousRegionPresent: boolean | null = null;
67
+ private regionTransitioned = false;
66
68
  private readonly MAX_STALLED_ITERATIONS = 3;
67
69
 
68
70
  private skipResearch = (err: Error): string => {
@@ -117,6 +119,8 @@ export class Tester extends TaskAgent implements Agent {
117
119
  this.seenUiMapUrls.clear();
118
120
  this.lastAnalyzedStateHash = null;
119
121
  this.stalledIterations = 0;
122
+ this.previousRegionPresent = null;
123
+ this.regionTransitioned = false;
120
124
  this.stateManager.clearHistory();
121
125
  this.resetFailureCount();
122
126
  this.pilot?.reset();
@@ -246,6 +250,7 @@ export class Tester extends TaskAgent implements Agent {
246
250
  const codeceptjsTools = createCodeceptJSTools(this.toolDeps, task);
247
251
  let assertionPerformed = false;
248
252
  let extensions = 0;
253
+ let deadlineReached = false;
249
254
  let shouldContinue = true;
250
255
 
251
256
  while (shouldContinue) {
@@ -253,6 +258,12 @@ export class Tester extends TaskAgent implements Agent {
253
258
 
254
259
  await loop(
255
260
  async ({ stop, pause, iteration, userInput }) => {
261
+ if (opts.deadline != null && Date.now() >= opts.deadline) {
262
+ deadlineReached = true;
263
+ task.addNote('Time budget reached. Stopped');
264
+ stop();
265
+ return;
266
+ }
256
267
  debugLog('iteration', iteration);
257
268
  if (!(await this.explorer.recover()).ok) {
258
269
  task.addNote('Browser page is unavailable');
@@ -420,6 +431,7 @@ export class Tester extends TaskAgent implements Agent {
420
431
  );
421
432
 
422
433
  if (task.hasFinished) break;
434
+ if (deadlineReached) break;
423
435
 
424
436
  if (!(await this.explorer.recover()).ok) break;
425
437
 
@@ -460,6 +472,10 @@ export class Tester extends TaskAgent implements Agent {
460
472
  }
461
473
 
462
474
  private shouldAnalyzeProgress(iteration: number, currentState: ActionResult): boolean {
475
+ if (this.regionTransitioned) {
476
+ this.regionTransitioned = false;
477
+ return true;
478
+ }
463
479
  if (this.consecutiveFailures >= 3) return true;
464
480
  if (this.consecutiveEmptyResults >= 2) return true;
465
481
  if (iteration % this.progressCheckInterval !== 0) return false;
@@ -530,6 +546,12 @@ export class Tester extends TaskAgent implements Agent {
530
546
  const currentStateHash = currentState.hash;
531
547
 
532
548
  const isNewUrl = this.previousUrl !== currentUrl;
549
+ const isNewState = !isNewUrl && this.previousStateHash !== null && this.previousStateHash !== currentStateHash;
550
+
551
+ if (this.previousRegionPresent !== null && this.previousRegionPresent !== currentState.overlay.present) {
552
+ this.regionTransitioned = true;
553
+ }
554
+ this.previousRegionPresent = currentState.overlay.present;
533
555
 
534
556
  this.previousUrl = currentUrl;
535
557
  this.previousStateHash = currentStateHash;
@@ -557,9 +579,11 @@ export class Tester extends TaskAgent implements Agent {
557
579
 
558
580
  if (focusArea.detected) {
559
581
  const areaName = focusArea.name ? ` "${focusArea.name}"` : '';
582
+ let rootHint = '';
583
+ if (focusArea.root) rootHint = `\nIts content lives inside \`${focusArea.root}\` — scope locators to it.`;
560
584
  context += dedent`
561
585
  <focus_scope>
562
- A ${focusArea.type}${areaName} is currently open above the page.
586
+ A ${focusArea.type}${areaName} is currently open above the page.${rootHint}
563
587
  Scope all interactions to elements inside this ${focusArea.type}.
564
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 ${focusArea.type}.
565
589
  Use <page_aria> to confirm the element you target is actually inside the ${focusArea.type}.
@@ -567,6 +591,18 @@ export class Tester extends TaskAgent implements Agent {
567
591
  `;
568
592
  }
569
593
 
594
+ if (!focusArea.detected && focusArea.present && isNewState) {
595
+ let rootHint = '';
596
+ if (focusArea.root) rootHint = `\nIt lives inside \`${focusArea.root}\`.`;
597
+ context += dedent`
598
+ <area_of_interest>
599
+ A large new area "${focusArea.name || 'unnamed area'}" appeared on this page without navigation.${rootHint}
600
+ The scenario most likely continues inside this area — prefer its elements for your next actions.
601
+ The rest of the page (navigation, menus, filters) is still interactive and remains available.
602
+ </area_of_interest>
603
+ `;
604
+ }
605
+
570
606
  if (currentState.isInsideIframe) {
571
607
  const iframeInfo = currentState.iframeURL || 'iframe context active';
572
608
  context += dedent`
@@ -589,7 +625,7 @@ export class Tester extends TaskAgent implements Agent {
589
625
  if (!alreadySeenUiMap) {
590
626
  research = await this.researcher.research(currentState).catch(this.skipResearch);
591
627
  }
592
- this.pageStateHash = currentStateHash;
628
+ this.pageStateHash = currentState.baseHash;
593
629
  this.pageActionResult = currentState;
594
630
  let uiMapSection = '';
595
631
  if (research) {
@@ -627,7 +663,7 @@ export class Tester extends TaskAgent implements Agent {
627
663
  return context;
628
664
  }
629
665
 
630
- if (focusArea.detected && focusArea.name && this.pageStateHash && this.pageActionResult) {
666
+ if (focusArea.present && focusArea.name && this.pageStateHash && this.pageActionResult) {
631
667
  const overlaySection = await this.researcher.researchOverlay(currentState, this.pageActionResult, this.pageStateHash).catch(this.skipResearch);
632
668
  if (overlaySection) {
633
669
  context += dedent`
@@ -1172,4 +1208,5 @@ interface TestSessionHandlers {
1172
1208
 
1173
1209
  export interface TestOptions {
1174
1210
  startOnCurrentPage?: boolean;
1211
+ deadline?: number;
1175
1212
  }
package/src/ai/tools.ts CHANGED
@@ -8,8 +8,9 @@ import { Stats } from '../stats.ts';
8
8
  import { type Task, TestResult } from '../test-plan.js';
9
9
  import { LARGE_ARIA_CHANGE_THRESHOLD } from '../utils/aria.ts';
10
10
  import { isFatalBrowserError } from '../utils/browser-errors.ts';
11
+ import { cleanHtmlSnippet } from '../utils/html.ts';
11
12
  import { createDebug, tag } from '../utils/logger.js';
12
- import { compactErrorMessage } from '../utils/strings.ts';
13
+ import { compactErrorMessage, normalizeInlineText, truncate } from '../utils/strings.ts';
13
14
  import { pause } from '../utils/loop.js';
14
15
  import { WebElement } from '../utils/web-element.ts';
15
16
  import type { ToolDeps } from './agent.ts';
@@ -1332,20 +1333,23 @@ export function clickFailureSuggestion(attempts: Array<{ error?: string }>): str
1332
1333
  }
1333
1334
 
1334
1335
  const MAX_DISAMBIGUATE_ELEMENTS = 10;
1336
+ const MAX_DISAMBIGUATE_TEXT = 80;
1337
+ const MAX_DISAMBIGUATE_HTML = 300;
1335
1338
  const MULTIPLE_ELEMENTS_PATTERN = 'multiple elements';
1336
1339
 
1337
- async function extractWebElements(error: Error | null | undefined): Promise<Array<{ xpath: string; html: string }> | null> {
1340
+ async function extractWebElements(error: Error | null | undefined): Promise<Array<{ xpath: string; html: string; text: string }> | null> {
1338
1341
  if (!error || error.name !== 'MultipleElementsFound') return null;
1339
1342
 
1340
- const elements = (error as any).webElements as Array<{ toAbsoluteXPath: () => Promise<string>; toSimplifiedHTML: () => Promise<string> }> | undefined;
1343
+ const elements = (error as any).webElements as Array<{ toAbsoluteXPath: () => Promise<string>; toOuterHTML: () => Promise<string>; getText: () => Promise<string | null> }> | undefined;
1341
1344
  if (!elements?.length) return null;
1342
1345
 
1343
- const result: Array<{ xpath: string; html: string }> = [];
1346
+ const result: Array<{ xpath: string; html: string; text: string }> = [];
1344
1347
  for (let i = 0; i < Math.min(elements.length, MAX_DISAMBIGUATE_ELEMENTS); i++) {
1345
1348
  try {
1346
1349
  const xpath = await elements[i].toAbsoluteXPath();
1347
- const html = await elements[i].toSimplifiedHTML();
1348
- result.push({ xpath, html });
1350
+ const html = truncate(cleanHtmlSnippet(await elements[i].toOuterHTML()), MAX_DISAMBIGUATE_HTML);
1351
+ const text = truncate(normalizeInlineText((await elements[i].getText()) || ''), MAX_DISAMBIGUATE_TEXT);
1352
+ result.push({ xpath, html, text });
1349
1353
  } catch (e) {
1350
1354
  debugLog('Failed to get details for element %d: %s', i, e);
1351
1355
  }
@@ -1353,17 +1357,21 @@ async function extractWebElements(error: Error | null | undefined): Promise<Arra
1353
1357
  return result.length > 0 ? result : null;
1354
1358
  }
1355
1359
 
1356
- async function formatMatchedElements(error: Error | null | undefined): Promise<string | null> {
1360
+ function formatElementList(details: Array<{ xpath: string; html: string; text: string }>): string {
1361
+ return details.map((el, i) => `Element ${i + 1}:\nText: "${el.text}"\nXPath: ${el.xpath}\nHTML: ${el.html}`).join('\n\n');
1362
+ }
1363
+
1364
+ export async function formatMatchedElements(error: Error | null | undefined): Promise<string | null> {
1357
1365
  const details = await extractWebElements(error);
1358
1366
  if (!details) return 'Could not fetch element details. Repeat the action to get better info.';
1359
- return details.map((el, i) => `Element ${i + 1}\nXPath: ${el.xpath}\nHTML: ${el.html}`).join('\n\n');
1367
+ return formatElementList(details);
1360
1368
  }
1361
1369
 
1362
1370
  async function disambiguateElements(error: Error | null | undefined, explanation: string, provider: AIProvider): Promise<{ position: number; xpath: string } | null> {
1363
1371
  const elementDetails = await extractWebElements(error);
1364
1372
  if (!elementDetails) return null;
1365
1373
 
1366
- const elementList = elementDetails.map((el, i) => `Element ${i + 1}:\nXPath: ${el.xpath}\nHTML: ${el.html}`).join('\n\n');
1374
+ const elementList = formatElementList(elementDetails);
1367
1375
 
1368
1376
  const schema = z.object({
1369
1377
  position: z.number().nullable().describe('1-based position of the correct element, or null if none match'),
@@ -85,6 +85,10 @@ export class RequestResult {
85
85
  }
86
86
  }
87
87
 
88
+ get isWrite(): boolean {
89
+ return ['POST', 'PUT', 'PATCH', 'DELETE'].includes(this.method);
90
+ }
91
+
88
92
  save(outputDir: string): void {
89
93
  const requestsDir = path.join(outputDir, 'requests');
90
94
  if (!existsSync(requestsDir)) {
@@ -169,7 +173,7 @@ export class RequestResult {
169
173
  statusText: meta.statusText || '',
170
174
  responseHeaders: meta.responseHeaders || {},
171
175
  timing: Number.parseInt(meta.timing) || 0,
172
- timestamp: new Date(meta.timestamp || Date.now()),
176
+ timestamp: new Date(meta.timestamp || 0),
173
177
  });
174
178
 
175
179
  result.requestFile = requestFile;
@@ -178,8 +182,12 @@ export class RequestResult {
178
182
  return result;
179
183
  }
180
184
 
185
+ toEndpoint(): string {
186
+ return `${this.method} ${this.path}`;
187
+ }
188
+
181
189
  toSummary(): string {
182
- return `${this.method} ${this.path} → ${this.status} (${this.timing}ms)`;
190
+ return `${this.toEndpoint()} → ${this.status} (${this.timing}ms)`;
183
191
  }
184
192
 
185
193
  extractIdAndTitle(): { id?: string | number; title?: string } {
@@ -1,8 +1,9 @@
1
1
  import { existsSync, readdirSync } from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { isDynamicSegment } from '../utils/url-matcher.ts';
3
4
  import { RequestResult } from './request-result.ts';
4
5
 
5
- const AUTH_HEADERS = ['authorization', 'cookie', 'x-api-key', 'x-csrf-token'];
6
+ const AUTH_HEADERS = ['authorization', 'x-api-key', 'x-csrf-token'];
6
7
 
7
8
  export class RequestStore {
8
9
  private capturedRequests: RequestResult[] = [];
@@ -10,6 +11,7 @@ export class RequestStore {
10
11
  private failedRequests: RequestResult[] = [];
11
12
  private onFailedListeners: Array<(r: RequestResult) => void> = [];
12
13
  private outputDir: string;
14
+ private sessionStartedAt = new Date();
13
15
 
14
16
  constructor(outputDir: string) {
15
17
  this.outputDir = outputDir;
@@ -77,13 +79,15 @@ export class RequestStore {
77
79
  return this.madeRequests.filter((r) => r.status === status);
78
80
  }
79
81
 
80
- toEndpointList(): string {
82
+ toEndpointList(scopePath?: string): string {
83
+ let requests = this.capturedRequests;
84
+ if (scopePath) requests = this.getWriteRequestsForScope(scopePath);
85
+
81
86
  const seen = new Set<string>();
82
87
  const lines: string[] = [];
83
88
 
84
- for (const req of this.capturedRequests) {
85
- const normalized = normalizePathPattern(req.path);
86
- const key = `${req.method} ${normalized}`;
89
+ for (const req of requests) {
90
+ const key = `${req.method} ${normalizePathPattern(req.path)}`;
87
91
  if (seen.has(key)) continue;
88
92
  seen.add(key);
89
93
  lines.push(key);
@@ -94,23 +98,42 @@ export class RequestStore {
94
98
 
95
99
  extractAuthHeaders(): Record<string, string> {
96
100
  const headers: Record<string, string> = {};
101
+ const sessionCaptures = this.capturedRequests.filter((r) => r.timestamp >= this.sessionStartedAt).sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
97
102
 
98
- for (let i = this.capturedRequests.length - 1; i >= 0; i--) {
99
- const req = this.capturedRequests[i];
103
+ for (const req of sessionCaptures) {
100
104
  for (const [key, value] of Object.entries(req.requestHeaders)) {
101
105
  if (AUTH_HEADERS.includes(key.toLowerCase()) && !headers[key]) {
102
106
  headers[key] = value;
103
107
  }
104
108
  }
105
- if (AUTH_HEADERS.every((h) => Object.keys(headers).some((k) => k.toLowerCase() === h))) break;
106
109
  }
107
110
 
108
111
  return headers;
109
112
  }
110
113
 
111
- findCapturedRequest(method: string, pathPrefix: string): RequestResult | undefined {
114
+ findCapturedRequest(method: string, searchPath: string): RequestResult | undefined {
112
115
  const upper = method.toUpperCase();
113
- return this.capturedRequests.find((r) => r.method === upper && r.path.startsWith(pathPrefix));
116
+ const search = normalizePathPattern(searchPath).split('/').filter(Boolean);
117
+
118
+ let best: RequestResult | undefined;
119
+ let bestScore = -1;
120
+
121
+ for (const req of this.capturedRequests) {
122
+ if (req.method !== upper) continue;
123
+ const segments = normalizePathPattern(req.path).split('/').filter(Boolean);
124
+ if (segments.length < search.length) continue;
125
+ if (!search.every((segment, i) => segment === segments[i])) continue;
126
+
127
+ let score = 0;
128
+ if (segments.length === search.length) score += 4;
129
+ if (req.status < 400) score += 2;
130
+ if (score < bestScore) continue;
131
+ if (score === bestScore && best && req.timestamp <= best.timestamp) continue;
132
+ best = req;
133
+ bestScore = score;
134
+ }
135
+
136
+ return best;
114
137
  }
115
138
 
116
139
  toLog(): string {
@@ -122,7 +145,7 @@ export class RequestStore {
122
145
  if (!existsSync(requestsDir)) return;
123
146
 
124
147
  const existingIds = new Set(this.capturedRequests.map((r) => r.id));
125
- const files = readdirSync(requestsDir).filter((f) => f.endsWith('.request.yaml'));
148
+ const files = readdirSync(requestsDir).filter((f) => f.startsWith('xhr_') && f.endsWith('.request.yaml'));
126
149
 
127
150
  for (const file of files) {
128
151
  try {
@@ -137,7 +160,28 @@ export class RequestStore {
137
160
 
138
161
  getWriteRequestsForScope(scopePath: string): RequestResult[] {
139
162
  const writeMethods = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
140
- return this.capturedRequests.filter((r) => writeMethods.has(r.method) && r.path.startsWith(scopePath));
163
+ const writes = this.capturedRequests.filter((r) => writeMethods.has(r.method));
164
+ const scopeSegments = scopePath.split('/').filter(Boolean);
165
+ if (scopeSegments.length === 0) return writes;
166
+
167
+ let scoped: RequestResult[] = [];
168
+ let fewest = Number.POSITIVE_INFINITY;
169
+ let ambiguous = false;
170
+ for (const segment of scopeSegments) {
171
+ if (isDynamicSegment(segment)) continue;
172
+ const matches = writes.filter((r) => r.path.split('/').includes(segment));
173
+ if (matches.length === 0 || matches.length > fewest) continue;
174
+ if (matches.length === fewest) {
175
+ if (!scoped.every((r, i) => r.id === matches[i].id)) ambiguous = true;
176
+ continue;
177
+ }
178
+ scoped = matches;
179
+ fewest = matches.length;
180
+ ambiguous = false;
181
+ }
182
+ if (ambiguous) return [];
183
+
184
+ return scoped;
141
185
  }
142
186
 
143
187
  clear(): void {
@@ -148,5 +192,8 @@ export class RequestStore {
148
192
  }
149
193
 
150
194
  function normalizePathPattern(urlPath: string): string {
151
- return urlPath.replace(/\/[0-9a-f]{24}\b/g, '/{id}').replace(/\/\d+\b/g, '/{id}');
195
+ return urlPath
196
+ .split('/')
197
+ .map((segment) => (segment && isDynamicSegment(segment) ? '{id}' : segment))
198
+ .join('/');
152
199
  }
@@ -14,12 +14,15 @@ import { BaseCommand, type Suggestion } from './base-command.js';
14
14
 
15
15
  const MAX_SUB_PAGE_ATTEMPTS = 30;
16
16
  const PRIORITY_ORDER: Record<string, number> = { critical: 0, important: 1, high: 2, normal: 3, low: 4 };
17
+ export const DEADLINE_RESERVE_MS = 3 * 60_000;
18
+ export const DEADLINE_TEST_ALLOWANCE_MS = 5 * 60_000;
17
19
 
18
20
  export class ExploreCommand extends BaseCommand {
19
21
  name = 'explore';
20
22
  description = 'Start web exploration';
21
23
  options = [
22
24
  { flags: '--max-tests <number>', description: 'Maximum number of tests to run' },
25
+ { flags: '--max-duration <minutes>', description: 'Wall-clock budget in minutes; wraps up the session before the limit is hit' },
23
26
  { flags: '--focus <feature>', description: 'Focus area for exploration' },
24
27
  { flags: '--configure <spec>', description: 'Reuse spec: keys new|from|style|subpages|pick_by|priority, e.g. "new:25%;pick_by=random;priority=critical,high"' },
25
28
  { flags: '--dry-run', description: 'Mark picked tests as skipped without executing or generating new ones' },
@@ -31,8 +34,11 @@ export class ExploreCommand extends BaseCommand {
31
34
  ];
32
35
 
33
36
  maxTests?: number;
37
+ maxDurationMinutes?: number;
38
+ hardDeadlineAt?: number;
34
39
  dryRun = false;
35
40
  private testsRun = 0;
41
+ private deadlineLogged = false;
36
42
  private completedPlans: Plan[] = [];
37
43
  private failedSubPages = new Set<string>();
38
44
  private oldTestRefs = new Set<Test>();
@@ -48,6 +54,12 @@ export class ExploreCommand extends BaseCommand {
48
54
  if (opts.maxTests) {
49
55
  this.maxTests = Number.parseInt(opts.maxTests as string, 10);
50
56
  }
57
+ if (opts.maxDuration) {
58
+ this.maxDurationMinutes = Number.parseInt(opts.maxDuration as string, 10);
59
+ }
60
+ if (this.hardDeadlineAt == null && this.maxDurationMinutes != null) {
61
+ this.hardDeadlineAt = Date.now() + this.maxDurationMinutes * 60_000 - DEADLINE_RESERVE_MS;
62
+ }
51
63
 
52
64
  const feature = (opts.focus as string) || remaining.join(' ') || undefined;
53
65
  const cfg = this.parseConfigure(opts.configure as string | undefined);
@@ -526,7 +538,18 @@ export class ExploreCommand extends BaseCommand {
526
538
  }
527
539
 
528
540
  private isLimitReached(): boolean {
529
- return this.maxTests != null && this.testsRun >= this.maxTests;
541
+ if (this.maxTests != null && this.testsRun >= this.maxTests) return true;
542
+ return this.isDeadlineReached();
543
+ }
544
+
545
+ private isDeadlineReached(): boolean {
546
+ if (this.hardDeadlineAt == null) return false;
547
+ if (Date.now() < this.hardDeadlineAt - DEADLINE_TEST_ALLOWANCE_MS) return false;
548
+ if (!this.deadlineLogged) {
549
+ this.deadlineLogged = true;
550
+ tag('info').log(`Time budget reached after ${this.testsRun} test(s): stopping new work and finishing the session`);
551
+ }
552
+ return true;
530
553
  }
531
554
 
532
555
  private async runPendingTests(): Promise<void> {
@@ -548,7 +571,7 @@ export class ExploreCommand extends BaseCommand {
548
571
  test.start();
549
572
  test.finish(TestResult.SKIPPED);
550
573
  } else {
551
- await this.explorBot.agentTester().test(test);
574
+ await this.explorBot.agentTester().test(test, { deadline: this.hardDeadlineAt });
552
575
  }
553
576
  this.testsRun++;
554
577
  }
@@ -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
 
9
9
  export class FreesailCommand extends BaseCommand {
10
10
  name = 'freesail';
@@ -16,6 +16,7 @@ export class FreesailCommand extends BaseCommand {
16
16
  { flags: '--shallow', description: 'Use shallow navigation strategy' },
17
17
  { flags: '--scope <url>', description: 'Limit navigation to URLs starting with this prefix' },
18
18
  { flags: '--max-tests <number>', description: 'Maximum number of tests to run' },
19
+ { flags: '--max-duration <number>', description: 'Wall-clock budget in minutes for the whole run' },
19
20
  ];
20
21
 
21
22
  async execute(args: string): Promise<void> {
@@ -26,6 +27,9 @@ export class FreesailCommand extends BaseCommand {
26
27
  if (opts.shallow) strategy = 'shallow';
27
28
  const scope = opts.scope as string | undefined;
28
29
  const maxTests = opts.maxTests ? Number.parseInt(opts.maxTests as string, 10) : undefined;
30
+ const maxDuration = opts.maxDuration ? Number.parseInt(opts.maxDuration as string, 10) : undefined;
31
+ let hardDeadlineAt: number | undefined;
32
+ if (maxDuration != null) hardDeadlineAt = Date.now() + maxDuration * 60_000 - DEADLINE_RESERVE_MS;
29
33
 
30
34
  await this.explorBot.visitInitialState();
31
35
 
@@ -34,6 +38,7 @@ export class FreesailCommand extends BaseCommand {
34
38
  await loop(
35
39
  async (ctx) => {
36
40
  if (maxTests != null && testsRun >= maxTests) ctx.stop();
41
+ if (hardDeadlineAt != null && Date.now() >= hardDeadlineAt - DEADLINE_TEST_ALLOWANCE_MS) ctx.stop();
37
42
 
38
43
  const stateManager = this.explorBot.stateManager();
39
44
  const state = stateManager.getCurrentState();
@@ -48,6 +53,7 @@ export class FreesailCommand extends BaseCommand {
48
53
  } else {
49
54
  const exploreCmd = new ExploreCommand(this.explorBot);
50
55
  if (maxTests != null) exploreCmd.maxTests = maxTests - testsRun;
56
+ if (hardDeadlineAt != null) exploreCmd.hardDeadlineAt = hardDeadlineAt;
51
57
  await exploreCmd.execute('');
52
58
 
53
59
  const plan = this.explorBot.getCurrentPlan();