explorbot 0.3.1 → 0.3.2

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.
@@ -9,6 +9,7 @@ import { render } from 'ink';
9
9
  import React from 'react';
10
10
  import { App } from '../src/components/App.js';
11
11
  import { StatusPane } from '../src/components/StatusPane.js';
12
+ import { flushTelemetry } from '../src/ai/provider.js';
12
13
  import { ConfigParser, EXPLORBOT_ENV_VARS, PROVIDERS } from '../src/config.js';
13
14
  import { ExplorBot, type ExplorBotOptions } from '../src/explorbot.js';
14
15
  import { remote } from '../src/remote.js';
@@ -103,9 +104,7 @@ async function startTUI(explorBot: ExplorBot): Promise<void> {
103
104
  async function showStatsAndExit(code: number): Promise<never> {
104
105
  if (remote.isAttached()) {
105
106
  await remote.close(code);
106
- process.exit(code);
107
- }
108
- if (Stats.hasActivity()) {
107
+ } else if (Stats.hasActivity()) {
109
108
  await new Promise<void>((resolve) => {
110
109
  const { unmount } = render(
111
110
  React.createElement(StatusPane, {
@@ -121,6 +120,7 @@ async function showStatsAndExit(code: number): Promise<never> {
121
120
  );
122
121
  });
123
122
  }
123
+ await flushTelemetry();
124
124
  process.exit(code);
125
125
  }
126
126
 
@@ -9,6 +9,7 @@ import { render } from 'ink';
9
9
  import React from 'react';
10
10
  import { App } from '../src/components/App.js';
11
11
  import { StatusPane } from '../src/components/StatusPane.js';
12
+ import { flushTelemetry } from '../src/ai/provider.js';
12
13
  import { ConfigParser, EXPLORBOT_ENV_VARS, PROVIDERS } from '../src/config.js';
13
14
  import { ExplorBot } from '../src/explorbot.js';
14
15
  import { remote } from '../src/remote.js';
@@ -78,9 +79,8 @@ async function startTUI(explorBot) {
78
79
  async function showStatsAndExit(code) {
79
80
  if (remote.isAttached()) {
80
81
  await remote.close(code);
81
- process.exit(code);
82
82
  }
83
- if (Stats.hasActivity()) {
83
+ else if (Stats.hasActivity()) {
84
84
  await new Promise((resolve) => {
85
85
  const { unmount } = render(React.createElement(StatusPane, {
86
86
  onComplete: () => {
@@ -93,6 +93,7 @@ async function showStatsAndExit(code) {
93
93
  });
94
94
  });
95
95
  }
96
+ await flushTelemetry();
96
97
  process.exit(code);
97
98
  }
98
99
  addCommonOptions(program.command('start [path]').description('Start web exploration')).action(async (startPath, options) => {
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -2,6 +2,7 @@ import { type HtmlConfig } from './config.js';
2
2
  import type { Link, WebPageState } from './state-manager.js';
3
3
  import { TTLCache } from './utils/cache.js';
4
4
  import { type HtmlDiffPart, type HtmlDiffResult } from './utils/html-diff.js';
5
+ import { Overlay } from './utils/overlay.js';
5
6
  interface ActionResultData extends WebPageState {
6
7
  html?: string;
7
8
  fullUrl?: string | undefined;
@@ -28,6 +29,7 @@ interface ActionResultData extends WebPageState {
28
29
  focusedElement?: FocusedElement | null;
29
30
  iframeURL?: string;
30
31
  links?: Link[];
32
+ overlayHtml?: string;
31
33
  }
32
34
  export interface PageDiff {
33
35
  urlChanged: boolean;
@@ -81,6 +83,7 @@ export declare class ActionResult implements ActionResultData {
81
83
  notes: string[];
82
84
  links: Link[];
83
85
  verifications?: Record<string, boolean>;
86
+ overlay: Overlay;
84
87
  constructor(data: ActionResultData);
85
88
  get hash(): string;
86
89
  get html(): string;
@@ -5,6 +5,7 @@ import { TTLCache } from "./utils/cache.js";
5
5
  import { htmlDiff, liveRegionMessages } from "./utils/html-diff.js";
6
6
  import { extractHeadings, extractLinks, extractTargetedHtml, htmlCombinedSnapshot, htmlMinimalUISnapshot, htmlTextSnapshot, minifyHtml } from "./utils/html.js";
7
7
  import { createDebug } from "./utils/logger.js";
8
+ import { Overlay } from "./utils/overlay.js";
8
9
  import { slugify } from "./utils/strings.js";
9
10
  import { extractStatePath, matchesUrl } from "./utils/url-matcher.js";
10
11
  const debugLog = createDebug('explorbot:state');
@@ -37,6 +38,7 @@ export class ActionResult {
37
38
  notes = [];
38
39
  links = [];
39
40
  verifications;
41
+ overlay = new Overlay();
40
42
  constructor(data) {
41
43
  this.id = data.id;
42
44
  this.timestamp = data.timestamp ?? new Date();
@@ -77,6 +79,7 @@ export class ActionResult {
77
79
  if (data.ariaSnapshot !== undefined) {
78
80
  this._ariaSnapshot = data.ariaSnapshot;
79
81
  }
82
+ this.overlay = Overlay.resolve(data);
80
83
  if (!this.fullUrl && this.url) {
81
84
  this.fullUrl = this.url;
82
85
  }
@@ -32,6 +32,7 @@ declare class Action {
32
32
  includeScreenshot?: boolean;
33
33
  codeBlock?: string;
34
34
  }): Promise<ActionResult>;
35
+ captureOverlayHtml(): Promise<string>;
35
36
  captureMainDocumentStatus(): Promise<number | undefined>;
36
37
  captureResponses(): () => void;
37
38
  recordNetworkCall(request: any, status: number): void;
@@ -8,8 +8,9 @@ import { clearActivity, setActivity } from "./activity.js";
8
8
  import { ConfigParser, outputPath } from './config.js';
9
9
  import { Observability } from "./observability.js";
10
10
  import { browserErrorMessage, isFatalBrowserError, isNavigationTransitionError } from "./utils/browser-errors.js";
11
- import { captureHtmlForSnapshot, htmlCombinedSnapshot, minifyHtml } from './utils/html.js';
11
+ import { captureHtmlForSnapshot, getVisibleOverlayHtmlExtractorSource, htmlCombinedSnapshot, minifyHtml } from './utils/html.js';
12
12
  import { createDebug, setStepSpanParent, tag } from './utils/logger.js';
13
+ import { Overlay } from './utils/overlay.js';
13
14
  import { sleep, waitForPageReadiness } from "./utils/page-readiness.js";
14
15
  import { safeFilename } from "./utils/strings.js";
15
16
  import { codeceptJSSandbox, hasPlaywrightCommands, playwrightSandbox, sanitizeCodeBlock } from "./utils/web-sandbox.js";
@@ -131,10 +132,13 @@ class Action {
131
132
  let ariaSnapshot = null;
132
133
  let ariaSnapshotFile = undefined;
133
134
  let focusedElement = null;
135
+ let overlayHtml = '';
134
136
  try {
135
137
  const page = this.playwrightHelper.page;
136
138
  ariaSnapshot = await page.locator('body').ariaSnapshot();
137
139
  focusedElement = await page.evaluate(readFocusedElement);
140
+ if (!frame)
141
+ overlayHtml = await this.captureOverlayHtml();
138
142
  }
139
143
  catch (err) {
140
144
  debugLog('ARIA snapshot failed:', err instanceof Error ? `${err.message}\n${err.stack}` : err);
@@ -161,6 +165,7 @@ class Action {
161
165
  ariaSnapshot,
162
166
  ariaSnapshotFile,
163
167
  focusedElement,
168
+ overlayHtml: overlayHtml || undefined,
164
169
  iframeURL: frame ? frame.url?.() || 'iframe' : undefined,
165
170
  });
166
171
  this.stateManager.updateState(result, codeBlock);
@@ -175,6 +180,12 @@ class Action {
175
180
  return new ActionResult({ url, error: msg });
176
181
  }
177
182
  }
183
+ async captureOverlayHtml() {
184
+ return this.playwrightHelper.page.evaluate(({ extractorSource, config }) => {
185
+ const extract = new Function(`return ${extractorSource}`)();
186
+ return extract(config);
187
+ }, { extractorSource: getVisibleOverlayHtmlExtractorSource(), config: Overlay.captureConfig() });
188
+ }
178
189
  async captureMainDocumentStatus() {
179
190
  if (this.mainDocumentStatus)
180
191
  return this.mainDocumentStatus;
@@ -9,6 +9,7 @@ import { collectInteractiveNodes } from "../utils/aria.js";
9
9
  import { EXPLORBOT_ATTRS, HTML_COMPOSITE_AREA_HINTS, HTML_COMPOSITE_TARGET_ROLES, HTML_EXTRACTION_LIMITS, HTML_FORM_CONTROL_ROLES, HTML_FORM_CONTROL_TAGS, HTML_INTERACTIVE_ROLES, HTML_SELECTORS, HTML_VISIBILITY_LIMITS, getComponentScopeHtmlExtractorSource, getVisibleOverlayHtmlExtractorSource, inferHtmlRole, } from "../utils/html.js";
10
10
  import { createDebug, tag } from "../utils/logger.js";
11
11
  import { loop, pause } from "../utils/loop.js";
12
+ import { OVERLAY_SELECTORS } from "../utils/overlay.js";
12
13
  import { annotatePageElements } from "../utils/web-annotate.js";
13
14
  import { eidxInContainer } from "../utils/web-eidx.js";
14
15
  import { WebElement } from "../utils/web-element.js";
@@ -578,7 +579,8 @@ export class Driller extends TaskAgent {
578
579
  config: {
579
580
  interactiveContentSelector: HTML_SELECTORS.interactiveContent,
580
581
  limits: HTML_EXTRACTION_LIMITS,
581
- overlaySelectors: HTML_SELECTORS.semanticOverlays,
582
+ overlaySelectors: OVERLAY_SELECTORS.semanticOverlays,
583
+ overlaySemanticSelector: OVERLAY_SELECTORS.overlaySemanticSelector,
582
584
  visibilityLimits: HTML_VISIBILITY_LIMITS,
583
585
  },
584
586
  }));
@@ -5,7 +5,7 @@ import { ActionResult } from "../action-result.js";
5
5
  import { ConfigParser } from "../config.js";
6
6
  import { Stats } from "../stats.js";
7
7
  import { TestResult } from "../test-plan.js";
8
- import { collectInteractiveNodes, detectFocusArea } from "../utils/aria.js";
8
+ import { collectInteractiveNodes } from "../utils/aria.js";
9
9
  import { ErrorPageError } from "../utils/error-page.js";
10
10
  import { createDebug, tag } from "../utils/logger.js";
11
11
  const debugLog = createDebug('explorbot:pilot');
@@ -738,7 +738,7 @@ export class Pilot {
738
738
  lines.push(`h2: ${state.h2 || ''}`);
739
739
  lines.push(`h3: ${state.h3 || ''}`);
740
740
  lines.push(`h4: ${state.h4 || ''}`);
741
- const focusArea = detectFocusArea(state.ariaSnapshot);
741
+ const focusArea = state.overlay;
742
742
  if (focusArea.detected) {
743
743
  lines.push(`modal: ${focusArea.name || focusArea.type}`);
744
744
  }
@@ -7,6 +7,7 @@ declare class AiError extends Error {
7
7
  }
8
8
  export declare class ContextLengthError extends Error {
9
9
  }
10
+ export declare function flushTelemetry(): Promise<void>;
10
11
  export declare class Provider {
11
12
  config: AIConfig;
12
13
  telemetryEnabled: boolean;
@@ -17,6 +18,7 @@ export declare class Provider {
17
18
  modelCallWaiters: (() => void)[];
18
19
  constructor(config: AIConfig);
19
20
  validateConnection(): Promise<void>;
21
+ stop(): Promise<void>;
20
22
  getModelForAgent(agentName?: string): any;
21
23
  getAgenticModel(agentName?: string): any;
22
24
  getVisionModel(): any;
@@ -31,6 +31,15 @@ function createHarmonyChannelFallbackTool() {
31
31
  });
32
32
  }
33
33
  let telemetryRegistered = false;
34
+ let beforeExitFlushHooked = false;
35
+ let activeOtelSdk = null;
36
+ export async function flushTelemetry() {
37
+ const sdk = activeOtelSdk;
38
+ activeOtelSdk = null;
39
+ if (!sdk)
40
+ return;
41
+ await sdk.shutdown().catch((error) => debugLog(`Telemetry flush failed: ${error instanceof Error ? error.message : error}`));
42
+ }
34
43
  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'];
35
44
  function extractCachedTokens(usage) {
36
45
  if (!usage)
@@ -105,6 +114,9 @@ export class Provider {
105
114
  throw new AiError(`AI connection failed: ${error.message}`);
106
115
  }
107
116
  }
117
+ async stop() {
118
+ await flushTelemetry();
119
+ }
108
120
  getModelForAgent(agentName) {
109
121
  if (!agentName) {
110
122
  return this.config.model;
@@ -251,7 +263,12 @@ export class Provider {
251
263
  spanProcessors: [processor],
252
264
  instrumentations: [],
253
265
  });
266
+ activeOtelSdk = this.otelSdk;
254
267
  void this.otelSdk.start();
268
+ if (!beforeExitFlushHooked) {
269
+ process.on('beforeExit', () => void flushTelemetry());
270
+ beforeExitFlushHooked = true;
271
+ }
255
272
  if (!telemetryRegistered) {
256
273
  registerTelemetry(new OpenTelemetry());
257
274
  telemetryRegistered = true;
@@ -293,7 +310,7 @@ export class Provider {
293
310
  }
294
311
  async invokeConversation(conversation, tools, options = {}) {
295
312
  const response = tools ? await this.generateWithTools(conversation.messages, conversation.model, tools, options) : await this.chat(conversation.messages, conversation.model, options);
296
- const responseMessages = response.response?.messages || [];
313
+ const responseMessages = response.responseMessages || [];
297
314
  if (responseMessages.length > 0) {
298
315
  conversation.messages.push(...responseMessages);
299
316
  tag('debug').log('Added', responseMessages.length, 'messages from response');
@@ -1,7 +1,7 @@
1
1
  import dedent from 'dedent';
2
2
  import { ActionResult } from '../../action-result.js';
3
3
  import { executionController } from "../../execution-controller.js";
4
- import { detectFocusArea, diffAriaSnapshots } from "../../utils/aria.js";
4
+ import { diffAriaSnapshots } from "../../utils/aria.js";
5
5
  import { extractCodeBlocks } from "../../utils/code-extractor.js";
6
6
  import { tag } from '../../utils/logger.js';
7
7
  import { mdq } from "../../utils/markdown-query.js";
@@ -60,7 +60,7 @@ export function WithDeepAnalysis(Base) {
60
60
  this._appendExtendedResearch(result, expandedSections, navigationLinks);
61
61
  }
62
62
  async researchOverlay(current, previous, pageStateHash) {
63
- const focusArea = detectFocusArea(current.ariaSnapshot);
63
+ const focusArea = current.overlay;
64
64
  if (!focusArea.detected || !focusArea.name)
65
65
  return null;
66
66
  if (focusArea.type !== 'dialog' && focusArea.type !== 'modal')
@@ -9,7 +9,6 @@ import { Observability } from "../observability.js";
9
9
  import { normalizeUrl } from "../state-manager.js";
10
10
  import { Stats } from "../stats.js";
11
11
  import { TestResult } from "../test-plan.js";
12
- import { detectFocusArea } from "../utils/aria.js";
13
12
  import { ErrorPageError, isErrorPage } from "../utils/error-page.js";
14
13
  import { createDebug, tag } from "../utils/logger.js";
15
14
  import { loop } from "../utils/loop.js";
@@ -466,7 +465,7 @@ export class Tester extends TaskAgent {
466
465
  this.previousUrl = currentUrl;
467
466
  this.previousStateHash = currentStateHash;
468
467
  let context = '';
469
- const focusArea = detectFocusArea(currentState.ariaSnapshot);
468
+ const focusArea = currentState.overlay;
470
469
  const focusedElement = currentState.focusedElement;
471
470
  if (focusedElement) {
472
471
  const isTextInput = ['textbox', 'combobox', 'searchbox'].includes(focusedElement.role);
@@ -9,7 +9,7 @@ export class ExitCommand extends BaseCommand {
9
9
  aliases = ['quit'];
10
10
  async execute(_args) {
11
11
  await this.explorBot.printSessionAnalysis();
12
- await this.explorBot.getExplorer().stop();
12
+ await this.explorBot.stop();
13
13
  if (Stats.hasActivity()) {
14
14
  await new Promise((resolve) => {
15
15
  const { unmount } = render(React.createElement(StatusPane, {
@@ -114,6 +114,7 @@ export class ExplorBot {
114
114
  async stop() {
115
115
  this.agents.quartermaster?.stop();
116
116
  await this.explorer?.stop();
117
+ await this.provider?.stop();
117
118
  }
118
119
  async visitInitialState() {
119
120
  await this.visit(this.options.from || '/');
@@ -1,6 +1,7 @@
1
1
  import { ActionResult, type FocusedElement } from './action-result.js';
2
2
  import type { ExperienceTracker } from './experience-tracker.js';
3
3
  import type { Knowledge, KnowledgeTracker } from './knowledge-tracker.js';
4
+ import { Overlay } from './utils/overlay.js';
4
5
  export interface Link {
5
6
  title: string;
6
7
  url: string;
@@ -40,6 +41,7 @@ export interface WebPageState {
40
41
  focusedElement?: FocusedElement | null;
41
42
  links?: Link[];
42
43
  verifications?: Record<string, boolean>;
44
+ overlay?: Overlay;
43
45
  }
44
46
  export interface StateTransition {
45
47
  /** Previous state (null if this is the first state) */
@@ -1,6 +1,6 @@
1
1
  import { ActionResult } from './action-result.js';
2
- import { detectFocusArea } from './utils/aria.js';
3
2
  import { createDebug, tag } from './utils/logger.js';
3
+ import { Overlay } from './utils/overlay.js';
4
4
  import { slugify } from './utils/strings.js';
5
5
  import { extractStatePath } from './utils/url-matcher.js';
6
6
  const debugLog = createDebug('explorbot:state');
@@ -114,8 +114,8 @@ export class StateManager {
114
114
  return newState;
115
115
  }
116
116
  hasDialogAppeared(previousState, newState) {
117
- const prevFocus = detectFocusArea(previousState?.ariaSnapshot ?? null);
118
- const newFocus = detectFocusArea(newState.ariaSnapshot ?? null);
117
+ const prevFocus = previousState?.overlay ?? Overlay.fromAria(previousState?.ariaSnapshot ?? null);
118
+ const newFocus = newState.overlay ?? Overlay.fromAria(newState.ariaSnapshot ?? null);
119
119
  return !prevFocus.detected && newFocus.detected;
120
120
  }
121
121
  /**
@@ -14,7 +14,6 @@ export declare const HTML_SELECTORS: {
14
14
  readonly interactiveControl: "button, a[href], input, select, textarea, [role=\"button\"], [role=\"link\"], [role=\"checkbox\"], [role=\"radio\"], [role=\"switch\"], [role=\"tab\"], [role=\"menuitem\"]";
15
15
  readonly labelLike: "h1, h2, h3, h4, h5, h6, legend, caption, label, [role=\"heading\"], [class*=\"title\"], [class*=\"label\"], [class*=\"header\"], [class*=\"name\"]";
16
16
  readonly 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\"]";
17
- readonly semanticOverlays: readonly ["[role=\"dialog\"]", "[role=\"listbox\"]", "[role=\"menu\"]", "[role=\"tooltip\"]:not([style*=\"display: none\"]):not([style*=\"visibility: hidden\"])"];
18
17
  };
19
18
  export declare const HTML_VISIBILITY_LIMITS: {
20
19
  readonly maxViewportOverlayRatio: 0.95;
@@ -67,7 +66,9 @@ export type VisibleOverlayExtractionConfig = {
67
66
  interactiveContentSelector: string;
68
67
  limits: typeof HTML_EXTRACTION_LIMITS;
69
68
  overlaySelectors: readonly string[];
69
+ overlaySemanticSelector: string;
70
70
  visibilityLimits: typeof HTML_VISIBILITY_LIMITS;
71
+ geometryFallback?: boolean;
71
72
  };
72
73
  export type ComponentScopeExtractionConfig = {
73
74
  eidxAttr: string;
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
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
  }
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';
@@ -144,11 +145,13 @@ class Action {
144
145
  let ariaSnapshot: string | null = null;
145
146
  let ariaSnapshotFile: string | undefined = undefined;
146
147
  let focusedElement: FocusedElement | null = null;
148
+ let overlayHtml = '';
147
149
 
148
150
  try {
149
151
  const page = this.playwrightHelper.page;
150
152
  ariaSnapshot = await page.locator('body').ariaSnapshot();
151
153
  focusedElement = await page.evaluate(readFocusedElement);
154
+ if (!frame) overlayHtml = await this.captureOverlayHtml();
152
155
  } catch (err) {
153
156
  debugLog('ARIA snapshot failed:', err instanceof Error ? `${err.message}\n${err.stack}` : err);
154
157
  }
@@ -177,6 +180,7 @@ class Action {
177
180
  ariaSnapshot,
178
181
  ariaSnapshotFile,
179
182
  focusedElement,
183
+ overlayHtml: overlayHtml || undefined,
180
184
  iframeURL: frame ? frame.url?.() || 'iframe' : undefined,
181
185
  });
182
186
  this.stateManager.updateState(result, codeBlock);
@@ -190,6 +194,16 @@ class Action {
190
194
  }
191
195
  }
192
196
 
197
+ private async captureOverlayHtml(): Promise<string> {
198
+ return this.playwrightHelper.page.evaluate(
199
+ ({ extractorSource, config }: { extractorSource: string; config: any }) => {
200
+ const extract = new Function(`return ${extractorSource}`)() as (config: any) => string;
201
+ return extract(config);
202
+ },
203
+ { extractorSource: getVisibleOverlayHtmlExtractorSource(), config: Overlay.captureConfig() }
204
+ );
205
+ }
206
+
193
207
  private async captureMainDocumentStatus(): Promise<number | undefined> {
194
208
  if (this.mainDocumentStatus) return this.mainDocumentStatus;
195
209
 
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
  }
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
 
@@ -824,7 +824,7 @@ export class Pilot implements Agent {
824
824
  lines.push(`h3: ${state.h3 || ''}`);
825
825
  lines.push(`h4: ${state.h4 || ''}`);
826
826
 
827
- const focusArea = detectFocusArea(state.ariaSnapshot);
827
+ const focusArea = state.overlay;
828
828
  if (focusArea.detected) {
829
829
  lines.push(`modal: ${focusArea.name || focusArea.type}`);
830
830
  } else {
@@ -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,7 +5,7 @@ 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';
@@ -86,7 +86,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
86
86
  }
87
87
 
88
88
  async researchOverlay(current: ActionResult, previous: ActionResult, pageStateHash: string): Promise<string | null> {
89
- const focusArea = detectFocusArea(current.ariaSnapshot);
89
+ const focusArea = current.overlay;
90
90
  if (!focusArea.detected || !focusArea.name) return null;
91
91
  if (focusArea.type !== 'dialog' && focusArea.type !== 'modal') return null;
92
92
 
package/src/ai/tester.ts CHANGED
@@ -11,7 +11,6 @@ import { Observability } from '../observability.ts';
11
11
  import { type StateTransition, normalizeUrl } from '../state-manager.ts';
12
12
  import { Stats } from '../stats.ts';
13
13
  import { type Test, TestResult, type TestResultType } from '../test-plan.ts';
14
- import { detectFocusArea } from '../utils/aria.ts';
15
14
  import { ErrorPageError, isErrorPage } from '../utils/error-page.ts';
16
15
  import { createDebug, tag } from '../utils/logger.ts';
17
16
  import { loop } from '../utils/loop.ts';
@@ -531,7 +530,7 @@ export class Tester extends TaskAgent implements Agent {
531
530
 
532
531
  let context = '';
533
532
 
534
- const focusArea = detectFocusArea(currentState.ariaSnapshot);
533
+ const focusArea = currentState.overlay;
535
534
 
536
535
  const focusedElement = currentState.focusedElement;
537
536
  if (focusedElement) {
@@ -11,7 +11,7 @@ export class ExitCommand extends BaseCommand {
11
11
 
12
12
  async execute(_args: string): Promise<void> {
13
13
  await this.explorBot.printSessionAnalysis();
14
- await this.explorBot.getExplorer().stop();
14
+ await this.explorBot.stop();
15
15
 
16
16
  if (Stats.hasActivity()) {
17
17
  await new Promise<void>((resolve) => {
package/src/explorbot.ts CHANGED
@@ -142,6 +142,7 @@ export class ExplorBot {
142
142
  async stop(): Promise<void> {
143
143
  this.agents.quartermaster?.stop();
144
144
  await this.explorer?.stop();
145
+ await this.provider?.stop();
145
146
  }
146
147
 
147
148
  async visitInitialState(): Promise<void> {
@@ -1,8 +1,8 @@
1
1
  import { ActionResult, type FocusedElement } from './action-result.js';
2
2
  import type { ExperienceTracker } from './experience-tracker.js';
3
3
  import type { Knowledge, KnowledgeTracker } from './knowledge-tracker.js';
4
- import { detectFocusArea } from './utils/aria.js';
5
4
  import { createDebug, tag } from './utils/logger.js';
5
+ import { Overlay } from './utils/overlay.js';
6
6
  import { slugify } from './utils/strings.js';
7
7
  import { extractStatePath } from './utils/url-matcher.js';
8
8
 
@@ -49,6 +49,7 @@ export interface WebPageState {
49
49
  focusedElement?: FocusedElement | null;
50
50
  links?: Link[];
51
51
  verifications?: Record<string, boolean>;
52
+ overlay?: Overlay;
52
53
  }
53
54
 
54
55
  export interface StateTransition {
@@ -206,8 +207,8 @@ export class StateManager {
206
207
  }
207
208
 
208
209
  private hasDialogAppeared(previousState: WebPageState | null, newState: WebPageState): boolean {
209
- const prevFocus = detectFocusArea(previousState?.ariaSnapshot ?? null);
210
- const newFocus = detectFocusArea(newState.ariaSnapshot ?? null);
210
+ const prevFocus = previousState?.overlay ?? Overlay.fromAria(previousState?.ariaSnapshot ?? null);
211
+ const newFocus = newState.overlay ?? Overlay.fromAria(newState.ariaSnapshot ?? null);
211
212
  return !prevFocus.detected && newFocus.detected;
212
213
  }
213
214
 
package/src/utils/html.ts CHANGED
@@ -98,7 +98,6 @@ export const HTML_SELECTORS = {
98
98
  interactiveControl: 'button, a[href], input, select, textarea, [role="button"], [role="link"], [role="checkbox"], [role="radio"], [role="switch"], [role="tab"], [role="menuitem"]',
99
99
  labelLike: 'h1, h2, h3, h4, h5, h6, legend, caption, label, [role="heading"], [class*="title"], [class*="label"], [class*="header"], [class*="name"]',
100
100
  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"]',
101
- semanticOverlays: ['[role="dialog"]', '[role="listbox"]', '[role="menu"]', '[role="tooltip"]:not([style*="display: none"]):not([style*="visibility: hidden"])'],
102
101
  } as const;
103
102
 
104
103
  export const HTML_VISIBILITY_LIMITS = {
@@ -163,7 +162,9 @@ export type VisibleOverlayExtractionConfig = {
163
162
  interactiveContentSelector: string;
164
163
  limits: typeof HTML_EXTRACTION_LIMITS;
165
164
  overlaySelectors: readonly string[];
165
+ overlaySemanticSelector: string;
166
166
  visibilityLimits: typeof HTML_VISIBILITY_LIMITS;
167
+ geometryFallback?: boolean;
167
168
  };
168
169
  export type ComponentScopeExtractionConfig = {
169
170
  eidxAttr: string;
@@ -479,20 +480,28 @@ export function extractVisibleOverlayHtml(config: VisibleOverlayExtractionConfig
479
480
  return interactiveCount > 0 || text.length > 0;
480
481
  }
481
482
 
482
- const overlays: string[] = [];
483
+ function isFloatingOverlay(element: Element): boolean {
484
+ const style = window.getComputedStyle(element as HTMLElement);
485
+ return style.position === 'fixed' || style.position === 'absolute' || Number.parseInt(style.zIndex || '0', 10) > 0;
486
+ }
487
+
483
488
  const seen = new Set<Element>();
489
+ const collected: Element[] = [];
484
490
  for (const selector of config.overlaySelectors) {
485
491
  for (const element of Array.from(document.querySelectorAll(selector))) {
486
492
  if (seen.has(element)) continue;
487
493
  seen.add(element);
488
494
  if (!isVisible(element)) continue;
495
+ if (!element.matches(config.overlaySemanticSelector) && !isFloatingOverlay(element)) continue;
489
496
  const { interactiveCount, text } = getUsefulContent(element);
490
497
  if (interactiveCount === 0 && text.length === 0) continue;
491
- overlays.push((element as HTMLElement).outerHTML.slice(0, config.limits.overlayHtmlLength));
498
+ collected.push(element);
492
499
  }
493
500
  }
494
501
 
495
- if (overlays.length === 0) {
502
+ const overlays = collected.filter((element) => !collected.some((other) => other !== element && element.contains(other))).map((element) => (element as HTMLElement).outerHTML.slice(0, config.limits.overlayHtmlLength));
503
+
504
+ if (overlays.length === 0 && config.geometryFallback !== false) {
496
505
  const floatingCandidates = Array.from(document.body.querySelectorAll('*'))
497
506
  .filter((element) => !seen.has(element) && isVisible(element) && isLikelyFloatingOverlay(element))
498
507
  .sort((left, right) => {
@@ -0,0 +1,51 @@
1
+ import { detectFocusArea } from './aria.js';
2
+ import { HTML_EXTRACTION_LIMITS, HTML_SELECTORS, HTML_VISIBILITY_LIMITS, type VisibleOverlayExtractionConfig, extractHeadings } from './html.js';
3
+
4
+ export const OVERLAY_SELECTORS = {
5
+ 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"]'],
6
+ modalOverlays: ['[role="dialog"]', '[role="alertdialog"]', '[aria-modal="true"]', '[class*="modal"]', '[class*="dialog"]', '[class*="overlay"]', '[class*="popup"]', '[class*="drawer"]', '[class*="lightbox"]'],
7
+ overlaySemanticSelector: '[role="dialog"], [role="alertdialog"], [aria-modal="true"], [role="listbox"], [role="menu"], [role="tooltip"]',
8
+ } as const;
9
+
10
+ export type OverlayData = { type?: 'dialog' | 'modal' | null; name?: string | null };
11
+
12
+ export class Overlay {
13
+ readonly type: 'dialog' | 'modal' | null;
14
+ readonly name: string | null;
15
+
16
+ constructor(data: OverlayData = {}) {
17
+ this.type = data.type ?? null;
18
+ this.name = data.name ?? null;
19
+ }
20
+
21
+ get detected(): boolean {
22
+ return this.type !== null;
23
+ }
24
+
25
+ static fromHtml(html: string): Overlay {
26
+ const headings = extractHeadings(html);
27
+ const name = [headings.h1, headings.h2, headings.h3, headings.h4].filter(Boolean).join(' ');
28
+ return new Overlay({ type: 'modal', name: name || null });
29
+ }
30
+
31
+ static fromAria(snapshot: string | null): Overlay {
32
+ return new Overlay(detectFocusArea(snapshot));
33
+ }
34
+
35
+ static resolve(data: { overlayHtml?: string; overlay?: OverlayData | null; ariaSnapshot?: string | null }): Overlay {
36
+ if (data.overlayHtml) return Overlay.fromHtml(data.overlayHtml);
37
+ if (data.overlay) return new Overlay(data.overlay);
38
+ return Overlay.fromAria(data.ariaSnapshot ?? null);
39
+ }
40
+
41
+ static captureConfig(): VisibleOverlayExtractionConfig {
42
+ return {
43
+ interactiveContentSelector: HTML_SELECTORS.interactiveContent,
44
+ limits: HTML_EXTRACTION_LIMITS,
45
+ overlaySelectors: OVERLAY_SELECTORS.modalOverlays,
46
+ overlaySemanticSelector: OVERLAY_SELECTORS.overlaySemanticSelector,
47
+ visibilityLimits: HTML_VISIBILITY_LIMITS,
48
+ geometryFallback: false,
49
+ };
50
+ }
51
+ }