explorbot 0.3.1 → 0.3.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 (65) hide show
  1. package/bin/explorbot-cli.ts +16 -5
  2. package/dist/bin/explorbot-cli.js +14 -4
  3. package/dist/models.json +2 -0
  4. package/dist/package.json +1 -1
  5. package/dist/src/action-result.d.ts +4 -0
  6. package/dist/src/action-result.js +20 -12
  7. package/dist/src/action.d.ts +11 -0
  8. package/dist/src/action.js +28 -11
  9. package/dist/src/ai/conversation.d.ts +2 -1
  10. package/dist/src/ai/conversation.js +9 -4
  11. package/dist/src/ai/driller.js +3 -1
  12. package/dist/src/ai/navigator.d.ts +3 -0
  13. package/dist/src/ai/navigator.js +20 -4
  14. package/dist/src/ai/pilot.js +9 -2
  15. package/dist/src/ai/provider.d.ts +2 -0
  16. package/dist/src/ai/provider.js +18 -1
  17. package/dist/src/ai/researcher/deep-analysis.js +7 -3
  18. package/dist/src/ai/researcher/sections.js +0 -1
  19. package/dist/src/ai/researcher.js +0 -1
  20. package/dist/src/ai/rules.js +0 -1
  21. package/dist/src/ai/tester.d.ts +1 -0
  22. package/dist/src/ai/tester.js +9 -11
  23. package/dist/src/ai/tools.d.ts +2 -0
  24. package/dist/src/ai/tools.js +21 -4
  25. package/dist/src/commands/exit-command.js +1 -1
  26. package/dist/src/commands/init-command.js +74 -24
  27. package/dist/src/components/InitWizard.d.ts +2 -1
  28. package/dist/src/components/InitWizard.js +8 -4
  29. package/dist/src/explorbot.js +1 -0
  30. package/dist/src/explorer.js +1 -0
  31. package/dist/src/knowledge-tracker.d.ts +3 -1
  32. package/dist/src/knowledge-tracker.js +4 -4
  33. package/dist/src/state-manager.d.ts +2 -0
  34. package/dist/src/state-manager.js +3 -3
  35. package/dist/src/utils/aria.js +1 -1
  36. package/dist/src/utils/html.d.ts +2 -1
  37. package/dist/src/utils/html.js +10 -4
  38. package/dist/src/utils/overlay.d.ts +24 -0
  39. package/dist/src/utils/overlay.js +43 -0
  40. package/docs/basics/providers.md +2 -4
  41. package/models.json +2 -0
  42. package/package.json +1 -1
  43. package/src/action-result.ts +25 -15
  44. package/src/action.ts +36 -13
  45. package/src/ai/conversation.ts +11 -5
  46. package/src/ai/driller.ts +3 -1
  47. package/src/ai/navigator.ts +22 -4
  48. package/src/ai/pilot.ts +9 -2
  49. package/src/ai/provider.ts +19 -1
  50. package/src/ai/researcher/deep-analysis.ts +8 -3
  51. package/src/ai/researcher/sections.ts +0 -1
  52. package/src/ai/researcher.ts +0 -1
  53. package/src/ai/rules.ts +0 -1
  54. package/src/ai/tester.ts +9 -9
  55. package/src/ai/tools.ts +20 -4
  56. package/src/commands/exit-command.ts +1 -1
  57. package/src/commands/init-command.ts +81 -22
  58. package/src/components/InitWizard.tsx +8 -4
  59. package/src/explorbot.ts +1 -0
  60. package/src/explorer.ts +1 -0
  61. package/src/knowledge-tracker.ts +4 -4
  62. package/src/state-manager.ts +4 -3
  63. package/src/utils/aria.ts +1 -1
  64. package/src/utils/html.ts +13 -4
  65. package/src/utils/overlay.ts +51 -0
@@ -79,7 +79,6 @@ export const HTML_SELECTORS = {
79
79
  interactiveControl: 'button, a[href], input, select, textarea, [role="button"], [role="link"], [role="checkbox"], [role="radio"], [role="switch"], [role="tab"], [role="menuitem"]',
80
80
  labelLike: 'h1, h2, h3, h4, h5, h6, legend, caption, label, [role="heading"], [class*="title"], [class*="label"], [class*="header"], [class*="name"]',
81
81
  semanticContextContainer: 'section, article, form, fieldset, li, tr, td, th, [role="group"], [role="tabpanel"], [role="region"], [class*="card"], [class*="panel"], [class*="item"], [class*="usage"], [class*="group"]',
82
- semanticOverlays: ['[role="dialog"]', '[role="listbox"]', '[role="menu"]', '[role="tooltip"]:not([style*="display: none"]):not([style*="visibility: hidden"])'],
83
82
  };
84
83
  export const HTML_VISIBILITY_LIMITS = {
85
84
  maxViewportOverlayRatio: 0.95,
@@ -447,8 +446,12 @@ export function extractVisibleOverlayHtml(config) {
447
446
  const { interactiveCount, text } = getUsefulContent(element);
448
447
  return interactiveCount > 0 || text.length > 0;
449
448
  }
450
- const overlays = [];
449
+ function isFloatingOverlay(element) {
450
+ const style = window.getComputedStyle(element);
451
+ return style.position === 'fixed' || style.position === 'absolute' || Number.parseInt(style.zIndex || '0', 10) > 0;
452
+ }
451
453
  const seen = new Set();
454
+ const collected = [];
452
455
  for (const selector of config.overlaySelectors) {
453
456
  for (const element of Array.from(document.querySelectorAll(selector))) {
454
457
  if (seen.has(element))
@@ -456,13 +459,16 @@ export function extractVisibleOverlayHtml(config) {
456
459
  seen.add(element);
457
460
  if (!isVisible(element))
458
461
  continue;
462
+ if (!element.matches(config.overlaySemanticSelector) && !isFloatingOverlay(element))
463
+ continue;
459
464
  const { interactiveCount, text } = getUsefulContent(element);
460
465
  if (interactiveCount === 0 && text.length === 0)
461
466
  continue;
462
- overlays.push(element.outerHTML.slice(0, config.limits.overlayHtmlLength));
467
+ collected.push(element);
463
468
  }
464
469
  }
465
- if (overlays.length === 0) {
470
+ const overlays = collected.filter((element) => !collected.some((other) => other !== element && element.contains(other))).map((element) => element.outerHTML.slice(0, config.limits.overlayHtmlLength));
471
+ if (overlays.length === 0 && config.geometryFallback !== false) {
466
472
  const floatingCandidates = Array.from(document.body.querySelectorAll('*'))
467
473
  .filter((element) => !seen.has(element) && isVisible(element) && isLikelyFloatingOverlay(element))
468
474
  .sort((left, right) => {
@@ -0,0 +1,24 @@
1
+ import { type VisibleOverlayExtractionConfig } from './html.js';
2
+ export declare const OVERLAY_SELECTORS: {
3
+ readonly semanticOverlays: readonly ["[role=\"dialog\"]", "[role=\"listbox\"]", "[role=\"menu\"]", "[role=\"tooltip\"]:not([style*=\"display: none\"]):not([style*=\"visibility: hidden\"])", "[class*=\"modal\"]", "[class*=\"dialog\"]", "[class*=\"overlay\"]", "[class*=\"popup\"]", "[class*=\"drawer\"]", "[class*=\"lightbox\"]"];
4
+ readonly modalOverlays: readonly ["[role=\"dialog\"]", "[role=\"alertdialog\"]", "[aria-modal=\"true\"]", "[class*=\"modal\"]", "[class*=\"dialog\"]", "[class*=\"overlay\"]", "[class*=\"popup\"]", "[class*=\"drawer\"]", "[class*=\"lightbox\"]"];
5
+ readonly overlaySemanticSelector: "[role=\"dialog\"], [role=\"alertdialog\"], [aria-modal=\"true\"], [role=\"listbox\"], [role=\"menu\"], [role=\"tooltip\"]";
6
+ };
7
+ export type OverlayData = {
8
+ type?: 'dialog' | 'modal' | null;
9
+ name?: string | null;
10
+ };
11
+ export declare class Overlay {
12
+ readonly type: 'dialog' | 'modal' | null;
13
+ readonly name: string | null;
14
+ constructor(data?: OverlayData);
15
+ get detected(): boolean;
16
+ static fromHtml(html: string): Overlay;
17
+ static fromAria(snapshot: string | null): Overlay;
18
+ static resolve(data: {
19
+ overlayHtml?: string;
20
+ overlay?: OverlayData | null;
21
+ ariaSnapshot?: string | null;
22
+ }): Overlay;
23
+ static captureConfig(): VisibleOverlayExtractionConfig;
24
+ }
@@ -0,0 +1,43 @@
1
+ import { detectFocusArea } from './aria.js';
2
+ import { HTML_EXTRACTION_LIMITS, HTML_SELECTORS, HTML_VISIBILITY_LIMITS, extractHeadings } from './html.js';
3
+ export const OVERLAY_SELECTORS = {
4
+ semanticOverlays: ['[role="dialog"]', '[role="listbox"]', '[role="menu"]', '[role="tooltip"]:not([style*="display: none"]):not([style*="visibility: hidden"])', '[class*="modal"]', '[class*="dialog"]', '[class*="overlay"]', '[class*="popup"]', '[class*="drawer"]', '[class*="lightbox"]'],
5
+ modalOverlays: ['[role="dialog"]', '[role="alertdialog"]', '[aria-modal="true"]', '[class*="modal"]', '[class*="dialog"]', '[class*="overlay"]', '[class*="popup"]', '[class*="drawer"]', '[class*="lightbox"]'],
6
+ overlaySemanticSelector: '[role="dialog"], [role="alertdialog"], [aria-modal="true"], [role="listbox"], [role="menu"], [role="tooltip"]',
7
+ };
8
+ export class Overlay {
9
+ type;
10
+ name;
11
+ constructor(data = {}) {
12
+ this.type = data.type ?? null;
13
+ this.name = data.name ?? null;
14
+ }
15
+ get detected() {
16
+ return this.type !== null;
17
+ }
18
+ static fromHtml(html) {
19
+ const headings = extractHeadings(html);
20
+ const name = [headings.h1, headings.h2, headings.h3, headings.h4].filter(Boolean).join(' ');
21
+ return new Overlay({ type: 'modal', name: name || null });
22
+ }
23
+ static fromAria(snapshot) {
24
+ return new Overlay(detectFocusArea(snapshot));
25
+ }
26
+ static resolve(data) {
27
+ if (data.overlayHtml)
28
+ return Overlay.fromHtml(data.overlayHtml);
29
+ if (data.overlay)
30
+ return new Overlay(data.overlay);
31
+ return Overlay.fromAria(data.ariaSnapshot ?? null);
32
+ }
33
+ static captureConfig() {
34
+ return {
35
+ interactiveContentSelector: HTML_SELECTORS.interactiveContent,
36
+ limits: HTML_EXTRACTION_LIMITS,
37
+ overlaySelectors: OVERLAY_SELECTORS.modalOverlays,
38
+ overlaySemanticSelector: OVERLAY_SELECTORS.overlaySemanticSelector,
39
+ visibilityLimits: HTML_VISIBILITY_LIMITS,
40
+ geometryFallback: false,
41
+ };
42
+ }
43
+ }
@@ -154,14 +154,12 @@ Set the recommended model in the exported config:
154
154
  ```javascript
155
155
  export default {
156
156
  ai: {
157
+ model: anthropic('claude-haiku-4-5-20251001'),
158
+ visionModel: anthropic('claude-haiku-4-5-20251001'),
157
159
  agenticModel: anthropic('claude-haiku-4-5-20251001'),
158
160
  },
159
161
  };
160
162
  ```
161
-
162
- > [!NOTE]
163
- > This provider currently doesn't serve `model` and `visionModel`, which is required for Explorbot to run at optimal cost and speed.
164
- > It is recommended to pair it with another AI provider.
165
163
  <!-- END provider:anthropic -->
166
164
 
167
165
  ### Azure OpenAI
package/models.json CHANGED
@@ -18,6 +18,8 @@
18
18
  "agenticModel": "gpt-5.6-luna"
19
19
  },
20
20
  "anthropic": {
21
+ "model": "claude-haiku-4-5-20251001",
22
+ "visionModel": "claude-haiku-4-5-20251001",
21
23
  "agenticModel": "claude-haiku-4-5-20251001"
22
24
  },
23
25
  "mistral": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.3.1",
3
+ "version": "0.3.4",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -7,6 +7,7 @@ import { TTLCache } from './utils/cache.ts';
7
7
  import { type HtmlDiffPart, type HtmlDiffResult, htmlDiff, liveRegionMessages } from './utils/html-diff.ts';
8
8
  import { extractHeadings, extractLinks, extractTargetedHtml, htmlCombinedSnapshot, htmlMinimalUISnapshot, htmlTextSnapshot, minifyHtml } from './utils/html.ts';
9
9
  import { createDebug } from './utils/logger.ts';
10
+ import { Overlay } from './utils/overlay.ts';
10
11
  import { slugify } from './utils/strings.ts';
11
12
  import { extractStatePath, matchesUrl } from './utils/url-matcher.ts';
12
13
 
@@ -34,6 +35,7 @@ interface ActionResultData extends WebPageState {
34
35
  focusedElement?: FocusedElement | null;
35
36
  iframeURL?: string;
36
37
  links?: Link[];
38
+ overlayHtml?: string;
37
39
  }
38
40
 
39
41
  export interface PageDiff {
@@ -86,6 +88,7 @@ export class ActionResult implements ActionResultData {
86
88
  notes: string[] = [];
87
89
  public links: Link[] = [];
88
90
  public verifications?: Record<string, boolean>;
91
+ public overlay: Overlay = new Overlay();
89
92
 
90
93
  constructor(data: ActionResultData) {
91
94
  this.id = data.id;
@@ -131,6 +134,8 @@ export class ActionResult implements ActionResultData {
131
134
  this._ariaSnapshot = data.ariaSnapshot;
132
135
  }
133
136
 
137
+ this.overlay = Overlay.resolve(data);
138
+
134
139
  if (!this.fullUrl && this.url) {
135
140
  this.fullUrl = this.url;
136
141
  }
@@ -545,17 +550,9 @@ export class ActionResult implements ActionResultData {
545
550
  }
546
551
 
547
552
  if (diff.htmlParts.length > 0) {
548
- const htmlConfig = this.normalizeHtmlConfig();
549
- const processedParts: HtmlDiffPart[] = [];
550
- for (const part of diff.htmlParts) {
551
- const filteredHtml = htmlCombinedSnapshot(part.subtree, htmlConfig?.combined);
552
- const minified = await minifyHtml(filteredHtml);
553
- if (minified) {
554
- processedParts.push({ ...part, subtree: minified });
555
- }
556
- }
557
- if (processedParts.length > 0) {
558
- pageDiff.htmlParts = collapseHtmlParts(processedParts);
553
+ const collapsed = collapseHtmlParts(await diff.cleanedHtmlParts());
554
+ if (collapsed.length > 0) {
555
+ pageDiff.htmlParts = collapsed;
559
556
  }
560
557
  }
561
558
 
@@ -596,10 +593,12 @@ function collapseHtmlParts(parts: HtmlDiffPart[]): HtmlDiffPart[] {
596
593
  const fullPageReRender = total > HTML_PARTS_TOTAL_BUDGET || parts.length > HTML_PARTS_COUNT_LIMIT;
597
594
 
598
595
  if (fullPageReRender) {
599
- return parts.map((part) => ({
600
- ...part,
601
- subtree: `<html><head></head><body>...collapsed (${part.subtree.length} chars, ${part.added.length} added, ${part.removed.length} removed)...</body></html>`,
602
- }));
596
+ return parts
597
+ .filter((part) => part.added.length > 0 || part.removed.length > 0)
598
+ .map((part) => ({
599
+ ...part,
600
+ subtree: `<html><head></head><body>...collapsed (${part.subtree.length} chars, ${part.added.length} added, ${part.removed.length} removed)...</body></html>`,
601
+ }));
603
602
  }
604
603
 
605
604
  return parts.map((part) => {
@@ -655,6 +654,17 @@ export class Diff {
655
654
  return this._htmlDiffResult.parts;
656
655
  }
657
656
 
657
+ async cleanedHtmlParts(): Promise<HtmlDiffPart[]> {
658
+ const htmlConfig = ConfigParser.getInstance().getConfig().html;
659
+ const cleaned: HtmlDiffPart[] = [];
660
+ for (const part of this.htmlParts) {
661
+ const minified = await minifyHtml(htmlCombinedSnapshot(part.subtree, htmlConfig?.combined));
662
+ if (!minified) continue;
663
+ cleaned.push({ ...part, subtree: minified });
664
+ }
665
+ return cleaned;
666
+ }
667
+
658
668
  get ariaChanged(): string | null {
659
669
  return this._ariaDiffResult;
660
670
  }
package/src/action.ts CHANGED
@@ -11,8 +11,9 @@ import { Observability } from './observability.ts';
11
11
  import type { PlaywrightRecorder } from './playwright-recorder.ts';
12
12
  import type { StateManager } from './state-manager.js';
13
13
  import { browserErrorMessage, isFatalBrowserError, isNavigationTransitionError } from './utils/browser-errors.ts';
14
- import { captureHtmlForSnapshot, htmlCombinedSnapshot, minifyHtml } from './utils/html.js';
14
+ import { captureHtmlForSnapshot, getVisibleOverlayHtmlExtractorSource, htmlCombinedSnapshot, minifyHtml } from './utils/html.js';
15
15
  import { createDebug, setStepSpanParent, tag } from './utils/logger.js';
16
+ import { Overlay } from './utils/overlay.js';
16
17
  import { sleep, waitForPageReadiness } from './utils/page-readiness.ts';
17
18
  import { safeFilename } from './utils/strings.ts';
18
19
  import { codeceptJSSandbox, hasPlaywrightCommands, playwrightSandbox, sanitizeCodeBlock } from './utils/web-sandbox.ts';
@@ -36,6 +37,7 @@ class Action {
36
37
  public playwrightHelper: any;
37
38
  public playwrightGroupId: string | null = null;
38
39
  public assertionSteps: Array<{ name: string; args: any[] }> = [];
40
+ public executedSteps: ExecutedStep[] = [];
39
41
  public lastValue: unknown;
40
42
  private recorder?: PlaywrightRecorder;
41
43
  private recovery: RecoveryRunner;
@@ -144,11 +146,13 @@ class Action {
144
146
  let ariaSnapshot: string | null = null;
145
147
  let ariaSnapshotFile: string | undefined = undefined;
146
148
  let focusedElement: FocusedElement | null = null;
149
+ let overlayHtml = '';
147
150
 
148
151
  try {
149
152
  const page = this.playwrightHelper.page;
150
153
  ariaSnapshot = await page.locator('body').ariaSnapshot();
151
154
  focusedElement = await page.evaluate(readFocusedElement);
155
+ if (!frame) overlayHtml = await this.captureOverlayHtml();
152
156
  } catch (err) {
153
157
  debugLog('ARIA snapshot failed:', err instanceof Error ? `${err.message}\n${err.stack}` : err);
154
158
  }
@@ -177,6 +181,7 @@ class Action {
177
181
  ariaSnapshot,
178
182
  ariaSnapshotFile,
179
183
  focusedElement,
184
+ overlayHtml: overlayHtml || undefined,
180
185
  iframeURL: frame ? frame.url?.() || 'iframe' : undefined,
181
186
  });
182
187
  this.stateManager.updateState(result, codeBlock);
@@ -190,6 +195,16 @@ class Action {
190
195
  }
191
196
  }
192
197
 
198
+ private async captureOverlayHtml(): Promise<string> {
199
+ return this.playwrightHelper.page.evaluate(
200
+ ({ extractorSource, config }: { extractorSource: string; config: any }) => {
201
+ const extract = new Function(`return ${extractorSource}`)() as (config: any) => string;
202
+ return extract(config);
203
+ },
204
+ { extractorSource: getVisibleOverlayHtmlExtractorSource(), config: Overlay.captureConfig() }
205
+ );
206
+ }
207
+
193
208
  private async captureMainDocumentStatus(): Promise<number | undefined> {
194
209
  if (this.mainDocumentStatus) return this.mainDocumentStatus;
195
210
 
@@ -315,9 +330,9 @@ class Action {
315
330
 
316
331
  let codeString = code.replace(/^\(I\) => /, '').trim();
317
332
 
318
- const executedSteps: string[] = [];
333
+ const executedSteps: ExecutedStep[] = [];
319
334
  const assertionSteps: Array<{ name: string; args: any[] }> = [];
320
- const stepListener = attachStepLogger(executedSteps, assertionSteps);
335
+ const detachSteps = attachStepLogger(executedSteps, assertionSteps);
321
336
  const groupId = this.recorder ? await this.recorder.beginAction(codeString) : null;
322
337
  this.playwrightGroupId = groupId;
323
338
  const detachResponses = this.captureResponses();
@@ -346,12 +361,13 @@ class Action {
346
361
  await recorder.add(() => sleep(this.config.action?.delay || 500));
347
362
  await recorder.promise();
348
363
  this.lastValue = await returned;
364
+ if (!recorder.isRunning()) throw new Error('CodeceptJS recorder is stopped, commands were skipped and never reached the browser');
349
365
  }
350
366
 
351
367
  this.restorePageTimeout();
352
368
 
353
369
  if (executedSteps.length > 0) {
354
- codeString = executedSteps.join('\n');
370
+ codeString = executedSteps.map((step) => step.command).join('\n');
355
371
  }
356
372
 
357
373
  const pageState = await this.captureOnce({ codeBlock: codeString });
@@ -368,10 +384,11 @@ class Action {
368
384
  this.assertionSteps = [];
369
385
  throw err;
370
386
  } finally {
387
+ this.executedSteps = executedSteps;
371
388
  this.restorePageTimeout();
372
389
  detachResponses();
373
390
  if (groupId) await this.recorder!.endAction();
374
- detachStepLogger(stepListener);
391
+ detachSteps();
375
392
  if (stepSpan) {
376
393
  stepSpan.end();
377
394
  }
@@ -473,11 +490,13 @@ const ASSERTION_STEP_NAMES = new Set(['see', 'dontSee', 'seeElement', 'dontSeeEl
473
490
 
474
491
  type StepListener = (step: any, error?: any) => void;
475
492
 
476
- const attachStepLogger = (target: string[], assertionsTarget?: Array<{ name: string; args: any[] }>): StepListener => {
493
+ export const attachStepLogger = (target: ExecutedStep[], assertionsTarget?: Array<{ name: string; args: any[] }>): (() => void) => {
477
494
  const listener: StepListener = (step, error) => {
478
495
  if (!step?.toCode) return;
479
496
  if (step.name?.startsWith('grab')) return;
480
- target.push(step.toCode());
497
+ const executed: ExecutedStep = { command: step.toCode(), success: !error };
498
+ if (error) executed.error = errorToString(error);
499
+ target.push(executed);
481
500
  if (assertionsTarget && ASSERTION_STEP_NAMES.has(step.name)) {
482
501
  assertionsTarget.push({ name: step.name, args: step.args || [] });
483
502
  }
@@ -489,12 +508,10 @@ const attachStepLogger = (target: string[], assertionsTarget?: Array<{ name: str
489
508
  };
490
509
  codeceptjs.event.dispatcher.on(codeceptjs.event.step.passed, listener);
491
510
  codeceptjs.event.dispatcher.on(codeceptjs.event.step.failed, listener);
492
- return listener;
493
- };
494
-
495
- const detachStepLogger = (listener: StepListener) => {
496
- codeceptjs.event.dispatcher.off(codeceptjs.event.step.passed, listener);
497
- codeceptjs.event.dispatcher.off(codeceptjs.event.step.failed, listener);
511
+ return () => {
512
+ codeceptjs.event.dispatcher.off(codeceptjs.event.step.passed, listener);
513
+ codeceptjs.event.dispatcher.off(codeceptjs.event.step.failed, listener);
514
+ };
498
515
  };
499
516
 
500
517
  const readFocusedElement = () => {
@@ -516,3 +533,9 @@ const readFocusedElement = () => {
516
533
  if (typeof value === 'string' && value) focused.value = value.slice(0, 200);
517
534
  return focused;
518
535
  };
536
+
537
+ export interface ExecutedStep {
538
+ command: string;
539
+ success: boolean;
540
+ error?: string;
541
+ }
@@ -5,12 +5,13 @@ export interface ToolExecution {
5
5
  input: any;
6
6
  output: any;
7
7
  wasSuccessful: boolean;
8
+ reasoning?: string;
8
9
  }
9
10
 
10
- export function toToolExecution(toolName: string, input: any, rawOutput: any): ToolExecution {
11
+ export function toToolExecution(toolName: string, input: any, rawOutput: any, reasoning?: string): ToolExecution {
11
12
  let output = rawOutput;
12
13
  if (rawOutput?.type === 'json' && rawOutput?.value) output = rawOutput.value;
13
- return { toolName, input, output, wasSuccessful: output?.success !== false };
14
+ return { toolName, input, output, wasSuccessful: output?.success !== false, reasoning };
14
15
  }
15
16
 
16
17
  export function toolExecutionLabel(input: Record<string, any> | undefined): string {
@@ -213,13 +214,17 @@ export class Conversation {
213
214
  }
214
215
 
215
216
  getToolExecutions(): ToolExecution[] {
216
- const toolCalls = new Map<string, any>();
217
+ const toolCalls = new Map<string, { input: any; reasoning?: string }>();
217
218
  for (const message of this.messages) {
218
219
  if (message.role !== 'assistant') continue;
219
220
  if (!Array.isArray(message.content)) continue;
221
+ const reasoning = message.content
222
+ .filter((part: any) => part.type === 'reasoning' && part.text?.trim())
223
+ .map((part: any) => part.text.trim())
224
+ .join('\n');
220
225
  for (const part of message.content) {
221
226
  if (part.type !== 'tool-call') continue;
222
- toolCalls.set(part.toolCallId, part.input);
227
+ toolCalls.set(part.toolCallId, { input: part.input, reasoning });
223
228
  }
224
229
  }
225
230
 
@@ -230,7 +235,8 @@ export class Conversation {
230
235
  for (const part of message.content) {
231
236
  if (part.type !== 'tool-result') continue;
232
237
  if (part.toolName === NARRATION_TOOL) continue;
233
- executions.push(toToolExecution(part.toolName, toolCalls.get(part.toolCallId) || {}, part.output));
238
+ const call = toolCalls.get(part.toolCallId);
239
+ executions.push(toToolExecution(part.toolName, call?.input || {}, part.output, call?.reasoning));
234
240
  }
235
241
  }
236
242
 
package/src/ai/driller.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  } from '../utils/html.ts';
23
23
  import { createDebug, tag } from '../utils/logger.ts';
24
24
  import { loop, pause } from '../utils/loop.ts';
25
+ import { OVERLAY_SELECTORS } from '../utils/overlay.ts';
25
26
  import { annotatePageElements } from '../utils/web-annotate.ts';
26
27
  import { eidxInContainer } from '../utils/web-eidx.ts';
27
28
  import { WebElement } from '../utils/web-element.ts';
@@ -682,7 +683,8 @@ export class Driller extends TaskAgent implements Agent {
682
683
  config: {
683
684
  interactiveContentSelector: HTML_SELECTORS.interactiveContent,
684
685
  limits: HTML_EXTRACTION_LIMITS,
685
- overlaySelectors: HTML_SELECTORS.semanticOverlays,
686
+ overlaySelectors: OVERLAY_SELECTORS.semanticOverlays,
687
+ overlaySemanticSelector: OVERLAY_SELECTORS.overlaySemanticSelector,
686
688
  visibilityLimits: HTML_VISIBILITY_LIMITS,
687
689
  },
688
690
  }
@@ -3,12 +3,13 @@ import dedent from 'dedent';
3
3
  import { z } from 'zod';
4
4
  import { ActionResult } from '../action-result.js';
5
5
  import type Action from '../action.ts';
6
+ import type { ExecutedStep } from '../action.ts';
6
7
  import type { ExplorbotConfig } from '../config.ts';
7
8
  import type { ExperienceTracker } from '../experience-tracker.js';
8
9
  import Explorer from '../explorer.ts';
9
10
  import type { KnowledgeTracker } from '../knowledge-tracker.js';
10
- import { type StateManager, normalizeUrl } from '../state-manager.js';
11
11
  import { renderAssertion } from '../playwright-recorder.ts';
12
+ import { type StateManager, normalizeUrl } from '../state-manager.js';
12
13
  import { isFatalBrowserError } from '../utils/browser-errors.ts';
13
14
  import { getCliName } from '../utils/cli-name.ts';
14
15
  import { extractCodeBlocks } from '../utils/code-extractor.js';
@@ -37,6 +38,7 @@ class Navigator implements Agent {
37
38
 
38
39
  private MAX_ATTEMPTS = Number.parseInt(process.env.MAX_ATTEMPTS || '5');
39
40
  lastFailureReason: string | null = null;
41
+ executedSteps: ExecutedStep[] = [];
40
42
 
41
43
  private systemPrompt = dedent`
42
44
  <role>
@@ -217,12 +219,18 @@ class Navigator implements Agent {
217
219
  if (!this.provider) throw new Error('AI-assisted recovery is unavailable: no AI model is configured.');
218
220
 
219
221
  this.lastFailureReason = null;
222
+ this.executedSteps = [];
220
223
  tag('info').log('AI Navigator resolving state at', actionResult.url);
221
224
  debugLog('Resolution message:', message);
222
225
 
223
226
  const action = opts?.action ?? this.explorer.action();
224
227
  const expectedUrl = opts?.expectedUrl;
225
228
 
229
+ if (expectedUrl && this.targetUrlReached(action, expectedUrl, actionResult)) {
230
+ tag('success').log(`Already at ${expectedUrl} — navigation resolved`);
231
+ return true;
232
+ }
233
+
226
234
  const knowledge = this.knowledgeTracker.renderRelevantContext(actionResult);
227
235
 
228
236
  const conversation = this.provider.startConversation(this.systemPrompt, 'navigator');
@@ -313,14 +321,19 @@ class Navigator implements Agent {
313
321
  resolved = check.urlMatches && freshHash !== actionResult.getStateHash();
314
322
 
315
323
  if (!resolved && attempt.ok) {
316
- lastFailure = `URL did not change (still ${check.freshState.url})`;
324
+ if (check.urlMatches) {
325
+ lastFailure = `Reached ${check.freshState.url} but the page state did not change`;
326
+ tag('warning').log(`Page state did not change at ${check.freshState.url}`);
327
+ } else {
328
+ lastFailure = `Reached ${check.freshState.url}, expected ${expectedUrl}`;
329
+ tag('warning').log(`URL verification failed: expected ${expectedUrl}, got ${check.freshState.url}`);
330
+ }
317
331
  batchFailures.push({
318
332
  code: codeBlock,
319
333
  error: lastFailure,
320
334
  ariaChanges: await this.ariaDiff(check.freshState, prevActionResult),
321
335
  urlAfter: check.freshState.url,
322
336
  });
323
- tag('warning').log(`URL verification failed: expected ${expectedUrl}, got ${check.freshState.url}`);
324
337
  }
325
338
  if (freshHash !== prevHash && (attempt.ok || check.urlMatches)) {
326
339
  progressBlocks.push(codeBlock);
@@ -456,6 +469,7 @@ class Navigator implements Agent {
456
469
 
457
470
  debugLog(`Attempting resolution: ${codeBlock}`);
458
471
  const ok = await action.attempt(codeBlock, message);
472
+ this.executedSteps.push(...action.executedSteps);
459
473
 
460
474
  const page = action.playwrightHelper?.page;
461
475
  if (page) {
@@ -484,11 +498,15 @@ class Navigator implements Agent {
484
498
  }
485
499
 
486
500
  const freshState = await this.explorer.capture();
487
- const urlMatches = this.isSameExpectedOrigin(expectedUrl, action.stateManager) && matchesNavigationUrl(expectedUrl, this.comparableUrl(freshState, expectedUrl));
501
+ const urlMatches = this.targetUrlReached(action, expectedUrl, freshState);
488
502
 
489
503
  return { freshState, urlMatches };
490
504
  }
491
505
 
506
+ private targetUrlReached(action: Action, expectedUrl: string, state: ActionResult): boolean {
507
+ return this.isSameExpectedOrigin(expectedUrl, action.stateManager) && matchesNavigationUrl(expectedUrl, this.comparableUrl(state, expectedUrl));
508
+ }
509
+
492
510
  private async ariaDiff(freshState: ActionResult, previous: ActionResult): Promise<string | null> {
493
511
  if (freshState.getStateHash() === previous.getStateHash()) return null;
494
512
  try {
package/src/ai/pilot.ts CHANGED
@@ -9,7 +9,7 @@ import type { PlaywrightRecorder } from '../playwright-recorder.ts';
9
9
  import type { StateManager } from '../state-manager.ts';
10
10
  import { Stats } from '../stats.ts';
11
11
  import { type Test, TestResult } from '../test-plan.ts';
12
- import { collectInteractiveNodes, detectFocusArea } from '../utils/aria.ts';
12
+ import { collectInteractiveNodes } from '../utils/aria.ts';
13
13
  import { ErrorPageError } from '../utils/error-page.ts';
14
14
  import { createDebug, tag } from '../utils/logger.ts';
15
15
 
@@ -28,6 +28,7 @@ import { withdrawVisionTools } from './tools.ts';
28
28
  const CHECK_TOOLS = ['verify', 'see', 'research'];
29
29
  const EVIDENCE_TOOLS = ['verify', 'see'];
30
30
  const META_TOOLS = ['record', 'reset', 'stop', 'finish'];
31
+ const PILOT_REASONING_LIMIT = 500;
31
32
  const PILOT_MESSAGE_LIMIT = 2;
32
33
  const PILOT_MESSAGE_MAX_LENGTH = 160;
33
34
 
@@ -824,7 +825,7 @@ export class Pilot implements Agent {
824
825
  lines.push(`h3: ${state.h3 || ''}`);
825
826
  lines.push(`h4: ${state.h4 || ''}`);
826
827
 
827
- const focusArea = detectFocusArea(state.ariaSnapshot);
828
+ const focusArea = state.overlay;
828
829
  if (focusArea.detected) {
829
830
  lines.push(`modal: ${focusArea.name || focusArea.type}`);
830
831
  } else {
@@ -1044,6 +1045,12 @@ export class Pilot implements Agent {
1044
1045
  if (resultMessage) line += `\n result: ${resultMessage}`;
1045
1046
  if (errorDetail && errorDetail !== resultMessage) line += `\n error: ${errorDetail}`;
1046
1047
 
1048
+ if (!t.wasSuccessful && t.reasoning) {
1049
+ let rationale = t.reasoning;
1050
+ if (rationale.length > PILOT_REASONING_LIMIT) rationale = `...${rationale.slice(-PILOT_REASONING_LIMIT)}`;
1051
+ line += `\n tester reasoned: ${rationale.replace(/\n+/g, ' ')}`;
1052
+ }
1053
+
1047
1054
  const attempts = t.output?.attempts;
1048
1055
  if (attempts && attempts.length > 1 && t.wasSuccessful) {
1049
1056
  const failedBefore = attempts.filter((a: any) => !a.success);
@@ -37,6 +37,15 @@ function createHarmonyChannelFallbackTool() {
37
37
  }
38
38
 
39
39
  let telemetryRegistered = false;
40
+ let beforeExitFlushHooked = false;
41
+ let activeOtelSdk: NodeSDK | null = null;
42
+
43
+ export async function flushTelemetry(): Promise<void> {
44
+ const sdk = activeOtelSdk;
45
+ activeOtelSdk = null;
46
+ if (!sdk) return;
47
+ await sdk.shutdown().catch((error) => debugLog(`Telemetry flush failed: ${error instanceof Error ? error.message : error}`));
48
+ }
40
49
 
41
50
  const CONTEXT_LENGTH_PATTERNS = ['reduce the length', 'context length', 'maximum context', 'token limit', 'too many tokens', 'max_tokens', 'context_length_exceeded', 'output truncated at maxtokens'];
42
51
 
@@ -117,6 +126,10 @@ export class Provider {
117
126
  }
118
127
  }
119
128
 
129
+ async stop(): Promise<void> {
130
+ await flushTelemetry();
131
+ }
132
+
120
133
  getModelForAgent(agentName?: string): any {
121
134
  if (!agentName) {
122
135
  return this.config.model;
@@ -270,7 +283,12 @@ export class Provider {
270
283
  spanProcessors: [processor],
271
284
  instrumentations: [],
272
285
  });
286
+ activeOtelSdk = this.otelSdk;
273
287
  void this.otelSdk.start();
288
+ if (!beforeExitFlushHooked) {
289
+ process.on('beforeExit', () => void flushTelemetry());
290
+ beforeExitFlushHooked = true;
291
+ }
274
292
  if (!telemetryRegistered) {
275
293
  registerTelemetry(new OpenTelemetry());
276
294
  telemetryRegistered = true;
@@ -324,7 +342,7 @@ export class Provider {
324
342
  async invokeConversation(conversation: Conversation, tools?: any, options: any = {}): Promise<{ conversation: Conversation; response: any; toolExecutions?: any[] } | null> {
325
343
  const response = tools ? await this.generateWithTools(conversation.messages, conversation.model, tools, options) : await this.chat(conversation.messages, conversation.model, options);
326
344
 
327
- const responseMessages = response.response?.messages || [];
345
+ const responseMessages = response.responseMessages || [];
328
346
  if (responseMessages.length > 0) {
329
347
  conversation.messages.push(...responseMessages);
330
348
  tag('debug').log('Added', responseMessages.length, 'messages from response');
@@ -5,10 +5,11 @@ import { executionController } from '../../execution-controller.ts';
5
5
  import type Explorer from '../../explorer.ts';
6
6
  import type { StateManager } from '../../state-manager.js';
7
7
  import { WebPageState } from '../../state-manager.js';
8
- import { detectFocusArea, diffAriaSnapshots } from '../../utils/aria.ts';
8
+ import { diffAriaSnapshots } from '../../utils/aria.ts';
9
9
  import { extractCodeBlocks } from '../../utils/code-extractor.ts';
10
10
  import { tag } from '../../utils/logger.js';
11
11
  import { mdq } from '../../utils/markdown-query.ts';
12
+ import { truncate } from '../../utils/strings.ts';
12
13
  import type { Provider } from '../provider.js';
13
14
  import { getCachedResearch, getPreviousResearch, saveResearch } from './cache.ts';
14
15
  import { type Constructor, debugLog } from './mixin.ts';
@@ -16,6 +17,7 @@ import { type ResearchElement, parseResearchSections } from './parser.ts';
16
17
  import type { ResearchResult } from './research-result.ts';
17
18
 
18
19
  const DEFAULT_MAX_EXPANDABLE_CLICKS = 10;
20
+ const MAX_HTML_DIFF_CHARS = 20_000;
19
21
 
20
22
  export function WithDeepAnalysis<T extends Constructor>(Base: T) {
21
23
  return class extends Base {
@@ -86,7 +88,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
86
88
  }
87
89
 
88
90
  async researchOverlay(current: ActionResult, previous: ActionResult, pageStateHash: string): Promise<string | null> {
89
- const focusArea = detectFocusArea(current.ariaSnapshot);
91
+ const focusArea = current.overlay;
90
92
  if (!focusArea.detected || !focusArea.name) return null;
91
93
  if (focusArea.type !== 'dialog' && focusArea.type !== 'modal') return null;
92
94
 
@@ -486,6 +488,9 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
486
488
  `;
487
489
  }
488
490
 
491
+ const cleanedParts = await diff.cleanedHtmlParts();
492
+ const htmlChanges = truncate(cleanedParts.map((p) => `[Container: ${p.container}]\n${p.subtree}`).join('\n\n'), MAX_HTML_DIFF_CHARS);
493
+
489
494
  const prompt = dedent`
490
495
  ${intro}
491
496
  Analyze the changes and produce a UI map section.
@@ -494,7 +499,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
494
499
  ${diff.ariaChanged || 'none'}
495
500
 
496
501
  HTML changes:
497
- ${diff.htmlParts.map((p) => `[Container: ${p.container}]\n${p.subtree}`).join('\n\n') || 'none'}
502
+ ${htmlChanges || 'none'}
498
503
  ${alreadyHint}
499
504
 
500
505
  Respond with a SINGLE section in this format: