explorbot 0.1.15 → 0.1.16

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.
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -1,20 +1,61 @@
1
- import { detectFocusArea } from "../../utils/aria.js";
2
1
  import { mdq } from "../../utils/markdown-query.js";
3
2
  export const FOCUSED_MARKER = '> **Focused**';
4
3
  const FOCUS_SKIP_SECTIONS = new Set(['navigation', 'menu']);
5
4
  export function hasFocusedSection(text) {
6
5
  return text.includes(FOCUSED_MARKER);
7
6
  }
8
- export function detectFocusFromAria(ariaSnapshot, sections) {
9
- const focusArea = detectFocusArea(ariaSnapshot);
10
- if (!focusArea.detected)
11
- return null;
12
- if (focusArea.type === 'dialog' || focusArea.type === 'modal') {
13
- const dialogSection = sections.find((s) => s.containerCss && (s.containerCss.includes('[role="dialog"]') || s.containerCss.includes('[role="alertdialog"]') || s.containerCss.includes('[aria-modal')));
14
- if (dialogSection)
15
- return dialogSection.name;
7
+ export async function detectFocusedSection(page, sections) {
8
+ const candidates = [];
9
+ for (const section of sections) {
10
+ if (!section.containerCss)
11
+ continue;
12
+ const key = section.name.toLowerCase().replace(/^section:\s*/, '');
13
+ if (FOCUS_SKIP_SECTIONS.has(key))
14
+ continue;
15
+ try {
16
+ const locator = page.locator(section.containerCss).first();
17
+ if (!(await locator.isVisible()))
18
+ continue;
19
+ const probe = await locator.evaluate((el) => {
20
+ const dialogSelector = '[role="dialog"], [role="alertdialog"], [aria-modal="true"]';
21
+ const isDialog = el.matches(dialogSelector) || !!el.querySelector(dialogSelector);
22
+ let cur = el;
23
+ let maxZ = 0;
24
+ while (cur && cur !== document.body) {
25
+ const cs = window.getComputedStyle(cur);
26
+ if (cs.position !== 'static') {
27
+ const z = Number.parseInt(cs.zIndex, 10);
28
+ if (!Number.isNaN(z) && z > maxZ)
29
+ maxZ = z;
30
+ }
31
+ cur = cur.parentElement;
32
+ }
33
+ const shadow = window.getComputedStyle(el).boxShadow;
34
+ const hasShadow = !!shadow && shadow !== 'none';
35
+ return { isDialog, zIndex: maxZ, hasShadow };
36
+ });
37
+ candidates.push({ name: section.name, ...probe });
38
+ }
39
+ catch { }
16
40
  }
17
- return null;
41
+ if (candidates.length === 0)
42
+ return null;
43
+ const dialogs = candidates.filter((c) => c.isDialog);
44
+ const pool = dialogs.length > 0 ? dialogs : candidates;
45
+ const winner = pool.reduce((best, c) => {
46
+ if (!best)
47
+ return c;
48
+ if (c.zIndex !== best.zIndex)
49
+ return c.zIndex > best.zIndex ? c : best;
50
+ if (c.hasShadow !== best.hasShadow)
51
+ return c.hasShadow ? c : best;
52
+ return best;
53
+ }, null);
54
+ if (!winner)
55
+ return null;
56
+ if (dialogs.length === 0 && winner.zIndex === 0 && !winner.hasShadow)
57
+ return null;
58
+ return winner.name;
18
59
  }
19
60
  export function markSectionAsFocused(result, sectionName) {
20
61
  if (hasFocusedSection(result.text))
@@ -3,6 +3,8 @@ import { executionController } from "../../execution-controller.js";
3
3
  import { tag } from '../../utils/logger.js';
4
4
  import { RulesLoader } from "../../utils/rules-loader.js";
5
5
  import { locatorRule as generalLocatorRuleText } from '../rules.js';
6
+ import { markSectionAsFocused } from "./focus.js";
7
+ import { ResearchResult } from "./research-result.js";
6
8
  export function WithSections(Base) {
7
9
  return class extends Base {
8
10
  async researchBySections() {
@@ -40,10 +42,12 @@ export function WithSections(Base) {
40
42
  if (parts.length === 0) {
41
43
  throw new Error('Per-section research produced no sections — AI responses all empty or NOT_PRESENT');
42
44
  }
43
- let merged = parts.join('\n\n');
44
- if (focusCss)
45
- merged += '\n\n> Focused: Focus';
46
- return merged;
45
+ const merged = parts.join('\n\n');
46
+ if (!focusCss)
47
+ return merged;
48
+ const focused = new ResearchResult(merged, this.actionResult?.url || '');
49
+ markSectionAsFocused(focused, 'Focus');
50
+ return focused.text;
47
51
  }
48
52
  async _detectFocusCss() {
49
53
  const focusSections = this.explorer.getConfig().ai?.agents?.researcher?.focusSections;
@@ -16,7 +16,7 @@ import { ContextLengthError } from './provider.js';
16
16
  import { findSimilarResearch, getCachedResearch, saveResearch } from "./researcher/cache.js";
17
17
  import { WithCoordinates } from "./researcher/coordinates.js";
18
18
  import { WithDeepAnalysis } from "./researcher/deep-analysis.js";
19
- import { detectFocusFromAria, hasFocusedSection, markSectionAsFocused, pickDefaultFocusedSection } from "./researcher/focus.js";
19
+ import { detectFocusedSection, hasFocusedSection, markSectionAsFocused, pickDefaultFocusedSection } from "./researcher/focus.js";
20
20
  import { WithLocators } from "./researcher/locators.js";
21
21
  import { extractValidContainers, formatResearchSummary, parseResearchSections } from "./researcher/parser.js";
22
22
  import { ResearchResult } from "./researcher/research-result.js";
@@ -186,18 +186,13 @@ export class Researcher extends ResearcherBase {
186
186
  if (!interrupted() && fix && result.locators.some((l) => l.valid === false)) {
187
187
  await this.fixBrokenSections(result, activeConversation);
188
188
  }
189
- // Focused section: parse AI declaration, then ARIA fallback
190
- const focusMatch = result.text.match(/^>\s*Focused:\s*(.+)/m);
191
- if (focusMatch) {
192
- result.text = result.text.replace(focusMatch[0], '');
193
- markSectionAsFocused(result, focusMatch[1].trim());
194
- }
195
- if (!hasFocusedSection(result.text)) {
189
+ // Focused section: unified Playwright probe (HTML+CSS+visibility).
190
+ // Must run BEFORE visuallyAnnotateContainers — annotation overlays inject z-index 99998+ which would pollute the scoring.
191
+ if (!interrupted() && this.hasScreenshotToAnalyze) {
196
192
  const sections = parseResearchSections(result.text);
197
- const ariaSnapshot = this.actionResult?.getCompactARIA() || '';
198
- const focusedName = detectFocusFromAria(ariaSnapshot, sections);
199
- if (focusedName)
200
- markSectionAsFocused(result, focusedName);
193
+ const focused = await detectFocusedSection(this.explorer.playwrightHelper.page, sections);
194
+ if (focused)
195
+ markSectionAsFocused(result, focused);
201
196
  }
202
197
  // Stage 4: Visual analysis
203
198
  if (!interrupted() && this.hasScreenshotToAnalyze) {
@@ -232,8 +227,8 @@ export class Researcher extends ResearcherBase {
232
227
  await this.backfillCoordinates(result);
233
228
  await this.backfillBrokenLocators(result);
234
229
  }
235
- // Focused section: final fallback
236
- if (!hasFocusedSection(result.text)) {
230
+ // Focused section: final fallback (vision-only — without a screenshot we don't infer focus)
231
+ if (this.hasScreenshotToAnalyze && !hasFocusedSection(result.text)) {
237
232
  const sections = parseResearchSections(result.text);
238
233
  const fallback = pickDefaultFocusedSection(sections);
239
234
  if (fallback)
@@ -388,16 +383,6 @@ export class Researcher extends ResearcherBase {
388
383
 
389
384
  | Element | ARIA | CSS | eidx |
390
385
  </section_format>
391
-
392
- <focused_section>
393
- At the end of your output, declare the primary focus area on a single line:
394
-
395
- > Focused: <exact section name>
396
-
397
- - If a dialog/modal/drawer/overlay exists, it is focused.
398
- - Otherwise pick the section where the main business action happens (list for catalog, detail for item page, content for article).
399
- - Navigation and menu/toolbar are never focused.
400
- </focused_section>
401
386
  `;
402
387
  }
403
388
  async buildResearchPrompt() {
@@ -329,10 +329,15 @@ export class Tester extends TaskAgent {
329
329
  : undefined,
330
330
  catch: async ({ error, stop }) => {
331
331
  tag('error').log(`Test execution error: ${error}`);
332
+ const message = error instanceof Error ? error.message : String(error);
332
333
  if (!task.hasFinished) {
333
- task.addNote(`Execution error: ${error instanceof Error ? error.message : String(error)}`);
334
+ task.addNote(`Execution error: ${message}`);
334
335
  }
335
- stop();
336
+ if (error instanceof Error && error.name === 'AbortError') {
337
+ stop();
338
+ return;
339
+ }
340
+ conversation.addUserText(`Previous AI call failed: ${message}. Take a different approach on the next step.`);
336
341
  },
337
342
  });
338
343
  if (task.hasFinished)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -1,4 +1,4 @@
1
- import { detectFocusArea } from '../../utils/aria.ts';
1
+ import type { Page } from 'playwright';
2
2
  import { mdq } from '../../utils/markdown-query.ts';
3
3
  import type { ResearchSection } from './parser.ts';
4
4
  import type { ResearchResult } from './research-result.ts';
@@ -10,16 +10,65 @@ export function hasFocusedSection(text: string): boolean {
10
10
  return text.includes(FOCUSED_MARKER);
11
11
  }
12
12
 
13
- export function detectFocusFromAria(ariaSnapshot: string | null, sections: ResearchSection[]): string | null {
14
- const focusArea = detectFocusArea(ariaSnapshot);
15
- if (!focusArea.detected) return null;
13
+ interface FocusProbe {
14
+ name: string;
15
+ isDialog: boolean;
16
+ zIndex: number;
17
+ hasShadow: boolean;
18
+ }
19
+
20
+ export async function detectFocusedSection(page: Page, sections: ResearchSection[]): Promise<string | null> {
21
+ const candidates: FocusProbe[] = [];
22
+
23
+ for (const section of sections) {
24
+ if (!section.containerCss) continue;
25
+ const key = section.name.toLowerCase().replace(/^section:\s*/, '');
26
+ if (FOCUS_SKIP_SECTIONS.has(key)) continue;
27
+
28
+ try {
29
+ const locator = page.locator(section.containerCss).first();
30
+ if (!(await locator.isVisible())) continue;
31
+
32
+ const probe = await locator.evaluate((el) => {
33
+ const dialogSelector = '[role="dialog"], [role="alertdialog"], [aria-modal="true"]';
34
+ const isDialog = el.matches(dialogSelector) || !!el.querySelector(dialogSelector);
35
+
36
+ let cur: Element | null = el;
37
+ let maxZ = 0;
38
+ while (cur && cur !== document.body) {
39
+ const cs = window.getComputedStyle(cur);
40
+ if (cs.position !== 'static') {
41
+ const z = Number.parseInt(cs.zIndex, 10);
42
+ if (!Number.isNaN(z) && z > maxZ) maxZ = z;
43
+ }
44
+ cur = cur.parentElement;
45
+ }
16
46
 
17
- if (focusArea.type === 'dialog' || focusArea.type === 'modal') {
18
- const dialogSection = sections.find((s) => s.containerCss && (s.containerCss.includes('[role="dialog"]') || s.containerCss.includes('[role="alertdialog"]') || s.containerCss.includes('[aria-modal')));
19
- if (dialogSection) return dialogSection.name;
47
+ const shadow = window.getComputedStyle(el).boxShadow;
48
+ const hasShadow = !!shadow && shadow !== 'none';
49
+
50
+ return { isDialog, zIndex: maxZ, hasShadow };
51
+ });
52
+
53
+ candidates.push({ name: section.name, ...probe });
54
+ } catch {}
20
55
  }
21
56
 
22
- return null;
57
+ if (candidates.length === 0) return null;
58
+
59
+ const dialogs = candidates.filter((c) => c.isDialog);
60
+ const pool = dialogs.length > 0 ? dialogs : candidates;
61
+
62
+ const winner = pool.reduce<FocusProbe | null>((best, c) => {
63
+ if (!best) return c;
64
+ if (c.zIndex !== best.zIndex) return c.zIndex > best.zIndex ? c : best;
65
+ if (c.hasShadow !== best.hasShadow) return c.hasShadow ? c : best;
66
+ return best;
67
+ }, null);
68
+
69
+ if (!winner) return null;
70
+ if (dialogs.length === 0 && winner.zIndex === 0 && !winner.hasShadow) return null;
71
+ return winner.name;
23
72
  }
24
73
 
25
74
  export function markSectionAsFocused(result: ResearchResult, sectionName: string): void {
@@ -7,7 +7,9 @@ import { tag } from '../../utils/logger.js';
7
7
  import { RulesLoader } from '../../utils/rules-loader.ts';
8
8
  import type { Provider } from '../provider.js';
9
9
  import { locatorRule as generalLocatorRuleText } from '../rules.js';
10
+ import { markSectionAsFocused } from './focus.ts';
10
11
  import type { Constructor } from './mixin.ts';
12
+ import { ResearchResult } from './research-result.ts';
11
13
 
12
14
  export interface SectionMethods {
13
15
  researchBySections(): Promise<string>;
@@ -54,9 +56,11 @@ export function WithSections<T extends Constructor>(Base: T) {
54
56
  throw new Error('Per-section research produced no sections — AI responses all empty or NOT_PRESENT');
55
57
  }
56
58
 
57
- let merged = parts.join('\n\n');
58
- if (focusCss) merged += '\n\n> Focused: Focus';
59
- return merged;
59
+ const merged = parts.join('\n\n');
60
+ if (!focusCss) return merged;
61
+ const focused = new ResearchResult(merged, this.actionResult?.url || '');
62
+ markSectionAsFocused(focused, 'Focus');
63
+ return focused.text;
60
64
  }
61
65
 
62
66
  private async _detectFocusCss(): Promise<string | null> {
@@ -24,7 +24,7 @@ import { ContextLengthError, type Provider } from './provider.js';
24
24
  import { findSimilarResearch, getCachedResearch, saveResearch } from './researcher/cache.ts';
25
25
  import { type CoordinateMethods, WithCoordinates } from './researcher/coordinates.ts';
26
26
  import { type DeepAnalysisMethods, WithDeepAnalysis } from './researcher/deep-analysis.ts';
27
- import { detectFocusFromAria, hasFocusedSection, markSectionAsFocused, pickDefaultFocusedSection } from './researcher/focus.ts';
27
+ import { detectFocusedSection, hasFocusedSection, markSectionAsFocused, pickDefaultFocusedSection } from './researcher/focus.ts';
28
28
  import { type LocatorMethods, WithLocators } from './researcher/locators.ts';
29
29
  import { extractValidContainers, formatResearchSummary, parseResearchSections } from './researcher/parser.ts';
30
30
  import { ResearchResult } from './researcher/research-result.ts';
@@ -234,17 +234,12 @@ export class Researcher extends ResearcherBase implements Agent {
234
234
  await this.fixBrokenSections(result, activeConversation);
235
235
  }
236
236
 
237
- // Focused section: parse AI declaration, then ARIA fallback
238
- const focusMatch = result.text.match(/^>\s*Focused:\s*(.+)/m);
239
- if (focusMatch) {
240
- result.text = result.text.replace(focusMatch[0], '');
241
- markSectionAsFocused(result, focusMatch[1].trim());
242
- }
243
- if (!hasFocusedSection(result.text)) {
237
+ // Focused section: unified Playwright probe (HTML+CSS+visibility).
238
+ // Must run BEFORE visuallyAnnotateContainers — annotation overlays inject z-index 99998+ which would pollute the scoring.
239
+ if (!interrupted() && this.hasScreenshotToAnalyze) {
244
240
  const sections = parseResearchSections(result.text);
245
- const ariaSnapshot = this.actionResult?.getCompactARIA() || '';
246
- const focusedName = detectFocusFromAria(ariaSnapshot, sections);
247
- if (focusedName) markSectionAsFocused(result, focusedName);
241
+ const focused = await detectFocusedSection(this.explorer.playwrightHelper.page, sections);
242
+ if (focused) markSectionAsFocused(result, focused);
248
243
  }
249
244
 
250
245
  // Stage 4: Visual analysis
@@ -281,8 +276,8 @@ export class Researcher extends ResearcherBase implements Agent {
281
276
  await this.backfillBrokenLocators(result);
282
277
  }
283
278
 
284
- // Focused section: final fallback
285
- if (!hasFocusedSection(result.text)) {
279
+ // Focused section: final fallback (vision-only — without a screenshot we don't infer focus)
280
+ if (this.hasScreenshotToAnalyze && !hasFocusedSection(result.text)) {
286
281
  const sections = parseResearchSections(result.text);
287
282
  const fallback = pickDefaultFocusedSection(sections);
288
283
  if (fallback) markSectionAsFocused(result, fallback);
@@ -451,16 +446,6 @@ export class Researcher extends ResearcherBase implements Agent {
451
446
 
452
447
  | Element | ARIA | CSS | eidx |
453
448
  </section_format>
454
-
455
- <focused_section>
456
- At the end of your output, declare the primary focus area on a single line:
457
-
458
- > Focused: <exact section name>
459
-
460
- - If a dialog/modal/drawer/overlay exists, it is focused.
461
- - Otherwise pick the section where the main business action happens (list for catalog, detail for item page, content for article).
462
- - Navigation and menu/toolbar are never focused.
463
- </focused_section>
464
449
  `;
465
450
  }
466
451
 
package/src/ai/tester.ts CHANGED
@@ -387,10 +387,15 @@ export class Tester extends TaskAgent implements Agent {
387
387
  : undefined,
388
388
  catch: async ({ error, stop }) => {
389
389
  tag('error').log(`Test execution error: ${error}`);
390
+ const message = error instanceof Error ? error.message : String(error);
390
391
  if (!task.hasFinished) {
391
- task.addNote(`Execution error: ${error instanceof Error ? error.message : String(error)}`);
392
+ task.addNote(`Execution error: ${message}`);
392
393
  }
393
- stop();
394
+ if (error instanceof Error && error.name === 'AbortError') {
395
+ stop();
396
+ return;
397
+ }
398
+ conversation.addUserText(`Previous AI call failed: ${message}. Take a different approach on the next step.`);
394
399
  },
395
400
  }
396
401
  );