explorbot 0.1.4 → 0.1.6

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 (42) hide show
  1. package/dist/rules/planner/styles/curious.md +18 -5
  2. package/dist/rules/planner/styles/normal.md +4 -4
  3. package/dist/rules/planner/styles/psycho.md +14 -11
  4. package/dist/rules/researcher/container-rules.md +15 -0
  5. package/dist/rules/researcher/section-example.md +12 -0
  6. package/dist/src/ai/captain/web-mode.js +9 -1
  7. package/dist/src/ai/historian.js +6 -0
  8. package/dist/src/ai/pilot.js +23 -2
  9. package/dist/src/ai/researcher/cache.js +6 -0
  10. package/dist/src/ai/researcher/deep-analysis.js +65 -9
  11. package/dist/src/ai/researcher/sections.js +103 -0
  12. package/dist/src/ai/researcher.js +30 -103
  13. package/dist/src/ai/task-agent.js +7 -27
  14. package/dist/src/ai/tester.js +19 -4
  15. package/dist/src/ai/tools.js +27 -2
  16. package/dist/src/commands/explore-command.js +4 -9
  17. package/dist/src/experience-tracker.js +126 -1
  18. package/dist/src/explorbot.js +9 -2
  19. package/dist/src/explorer.js +12 -1
  20. package/dist/src/utils/aria.js +39 -2
  21. package/package.json +1 -1
  22. package/rules/planner/styles/curious.md +18 -5
  23. package/rules/planner/styles/normal.md +4 -4
  24. package/rules/planner/styles/psycho.md +14 -11
  25. package/rules/researcher/container-rules.md +15 -0
  26. package/rules/researcher/section-example.md +12 -0
  27. package/src/ai/captain/web-mode.ts +9 -1
  28. package/src/ai/historian.ts +7 -0
  29. package/src/ai/pilot.ts +23 -3
  30. package/src/ai/researcher/cache.ts +5 -0
  31. package/src/ai/researcher/deep-analysis.ts +74 -9
  32. package/src/ai/researcher/sections.ts +122 -0
  33. package/src/ai/researcher.ts +31 -105
  34. package/src/ai/task-agent.ts +7 -31
  35. package/src/ai/tester.ts +20 -4
  36. package/src/ai/tools.ts +33 -1
  37. package/src/commands/explore-command.ts +4 -9
  38. package/src/config.ts +1 -0
  39. package/src/experience-tracker.ts +136 -1
  40. package/src/explorbot.ts +9 -2
  41. package/src/explorer.ts +10 -1
  42. package/src/utils/aria.ts +40 -2
@@ -20,6 +20,7 @@ import { detectFocusFromAria, hasFocusedSection, markSectionAsFocused, pickDefau
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";
23
+ import { WithSections } from "./researcher/sections.js";
23
24
  import { locatorRule as generalLocatorRuleText } from './rules.js';
24
25
  import { RulesLoader } from "../utils/rules-loader.js";
25
26
  import { TaskAgent } from "./task-agent.js";
@@ -33,7 +34,7 @@ export const POSSIBLE_SECTIONS = {
33
34
  menu: 'page menu (toolbar, context actions, filters, dropdowns)',
34
35
  navigation: 'main navigation (top bar, sidebar, breadcrumbs)',
35
36
  };
36
- const ResearcherBase = WithDeepAnalysis(WithCoordinates(WithLocators(TaskAgent)));
37
+ const ResearcherBase = WithSections(WithDeepAnalysis(WithCoordinates(WithLocators(TaskAgent))));
37
38
  export class Researcher extends ResearcherBase {
38
39
  ACTION_TOOLS = ['click'];
39
40
  emoji = '🔍';
@@ -71,10 +72,6 @@ export class Researcher extends ResearcherBase {
71
72
  You are senior QA focused on exploritary testig of web application.
72
73
  </role>
73
74
 
74
- <wording>
75
- In the UI map and all descriptions, name concrete UI parts (visible labels, headings, regions, ARIA roles). Do not use vague placeholders like "the page", "the element", "the button", "the input", "the link", "the form", "the table", "the list", or "the item". Do not use filler such as "comprehensive", "All required", "All elements", or "All necessary".
76
- </wording>
77
-
78
75
  ${customPrompt || ''}
79
76
  `;
80
77
  }
@@ -134,9 +131,13 @@ export class Researcher extends ResearcherBase {
134
131
  const conversation = this.provider.startConversation(this.getSystemMessage(), 'researcher');
135
132
  const prompt = await this.buildResearchPrompt();
136
133
  conversation.addUserText(prompt);
137
- let invocationResult;
134
+ let researchText;
135
+ let activeConversation = conversation;
138
136
  try {
139
- invocationResult = await this.provider.invokeConversation(conversation, undefined, { agentName: 'researcher' });
137
+ const invocationResult = await this.provider.invokeConversation(conversation, undefined, { agentName: 'researcher' });
138
+ if (!invocationResult)
139
+ throw new Error('Failed to get response from provider');
140
+ researchText = invocationResult.response.text;
140
141
  }
141
142
  catch (error) {
142
143
  if (!(error instanceof ContextLengthError) || retriesLeft <= 0) {
@@ -145,29 +146,12 @@ export class Researcher extends ResearcherBase {
145
146
  }
146
147
  throw error;
147
148
  }
148
- tag('warning').log('Output truncated, retrying with focused instructions...');
149
149
  retriesLeft = 0;
150
- conversation.addUserText(this.buildFocusedRetryPrompt());
151
- invocationResult = await this.provider.invokeConversation(conversation, undefined, { agentName: 'researcher' });
150
+ researchText = await this.researchBySections();
151
+ activeConversation = this.provider.startConversation(this.getSystemMessage(), 'researcher');
152
152
  }
153
- if (!invocationResult)
154
- throw new Error('Failed to get response from provider');
155
- const result = new ResearchResult(invocationResult.response.text, state.url);
153
+ const result = new ResearchResult(researchText, state.url);
156
154
  debugLog(`Original research response length: ${result.text.length} chars`);
157
- const errorSection = mdq(result.text).query('section("Error Page Detected")');
158
- if (errorSection.count() > 0) {
159
- if (result.text.length < 500) {
160
- if (!opts._skipErrorPageRetry && (await this.waitForPageLoad(screenshot))) {
161
- return this.research(state, { ...opts, force: true, _skipErrorPageRetry: true });
162
- }
163
- tag('warning').log(`AI detected error page at ${state.url}`);
164
- if (stateHash)
165
- saveResearch(stateHash, result.text);
166
- await this.hooksRunner.runAfterHook('researcher', state.url);
167
- return result.text;
168
- }
169
- result.text = errorSection.replace('');
170
- }
171
155
  const interrupted = () => executionController.isInterrupted();
172
156
  // Stage 2: Test containers + locators
173
157
  result.parseLocators();
@@ -204,7 +188,7 @@ export class Researcher extends ResearcherBase {
204
188
  }
205
189
  // Stage 3: Fix broken sections via AI conversation continuation
206
190
  if (!interrupted() && fix && result.locators.some((l) => l.valid === false)) {
207
- await this.fixBrokenSections(result, conversation);
191
+ await this.fixBrokenSections(result, activeConversation);
208
192
  }
209
193
  // Focused section: parse AI declaration, then ARIA fallback
210
194
  const focusMatch = result.text.match(/^>\s*Focused:\s*(.+)/m);
@@ -357,32 +341,29 @@ export class Researcher extends ResearcherBase {
357
341
  }
358
342
  researchRules() {
359
343
  const sections = this.getConfiguredSections();
344
+ const currentUrl = this.stateManager.getCurrentState()?.url || '';
360
345
  return dedent `
361
346
  <task>
362
- Examine the provided page and explain its main purpose from the user perspective.
363
- Identify the main user actions of this page.
364
- Break down the page by sections and identify structural patterns.
365
- Provide a comprehensive UI map report in markdown format.
347
+ Examine the page and explain its main purpose from the user perspective.
348
+ Identify the primary user actions and break the page into sections.
349
+ Provide a UI map report in markdown.
366
350
  </task>
367
351
 
368
352
  <rules>
369
353
  - Explain what the user can achieve on this page.
370
354
  - Focus on primary user actions and interactive elements only.
371
355
  - Research all menus and navigational areas.
372
- - Ignore purely decorative sidebars, footer-only links, and external links.
356
+ - Ignore decorative sidebars, footer-only links, and external links.
373
357
  - Detect layout patterns: list/detail split, 2-pane, or 3-pane layouts.
374
- - If multiple elements match, pick the element inside the most relevant section and closest to recent UI context.
375
- - UI map table must include ARIA and CSS for every element.
376
- - Every element MUST have a CSS selector. NEVER leave CSS as "-".
377
- - For icon-only buttons with empty aria-label, set ARIA to "-" but ALWAYS provide CSS.
378
- - NEVER skip elements that have an eidx attribute. Every element with eidx MUST appear in the UI map table, even if it has no text or accessible name. Describe icon-only elements using their SVG class (e.g., md-icon-dots-horizontal → "More actions (ellipsis)") or their visual appearance.
379
- - ARIA locator must be JSON with role and text keys (NOT "name").
380
- - Note elements likely to have hover interactions (elements with title attribute, aria-describedby, navigation menu items with submenus) and mark them with "(hover)" in the UI map.
358
+ - Every element with an eidx attribute MUST appear in the UI map describe icon-only buttons by their visual role.
359
+ - Every UI map row needs a CSS selector; ARIA may be "-" for icon-only buttons, CSS must never be "-".
360
+ - ARIA locator JSON uses keys "role" and "text" (NOT "name").
361
+ - Mark elements with likely hover interactions (title, aria-describedby, menu items with submenus) as "(hover)".
381
362
  </rules>
382
363
 
383
364
  ${generalLocatorRuleText}
384
365
 
385
- ${RulesLoader.loadRules('researcher', ['ui-map-table', 'list-element'], this.stateManager.getCurrentState()?.url || '')}
366
+ ${RulesLoader.loadRules('researcher', ['ui-map-table', 'list-element', 'container-rules'], currentUrl)}
386
367
 
387
368
  <section_identification>
388
369
  Identify page sections in this priority order:
@@ -390,23 +371,12 @@ export class Researcher extends ResearcherBase {
390
371
  .map(([name, description]) => `* ${name}: ${description}`)
391
372
  .join('\n')}
392
373
 
393
- - Sections can overlap, prefer more detailed sections over broader ones.
394
- - Never name a section "Focus" or "Focused" — describe what the section actually contains (e.g., "Detail", "Modal", "Form", "Content", "List").
395
- - If a proposed section is not relevant or not detected, do not include it.
396
- - Each section must have a container CSS locator.
397
- - UI map CSS locators must be relative to the section container.
374
+ - Sections can overlap; prefer more detailed sections over broader ones.
375
+ - Never name a section "Focus" or "Focused" — use what it contains (Detail, Modal, Form, Content, List).
376
+ - Omit sections that are not present or not relevant.
377
+ - Each section needs a container CSS locator; UI map CSS locators are relative to it.
398
378
  </section_identification>
399
379
 
400
- <container_rules>
401
- CRITICAL: Container CSS must be a SINGLE selector — one class, one ID, or one attribute.
402
- No spaces, no >, no combinators, no nesting.
403
- - INVALID: '.filterbar-filter-btn-div button', 'div.static nav', 'div > .content'
404
- - INVALID: 'div', 'section', 'nav', 'div:first' (bare tags are not containers)
405
- - INVALID: Tailwind/Bootstrap utility classes that describe layout or styling (e.g. flex-none, d-flex, col-md-6, items-center, mt-4, p-2, bg-white, text-sm, rounded-lg). These are visual, not semantic.
406
- - VALID: Semantic class names that describe WHAT the section IS — e.g. '.product-list', '.sidebar-menu', '.user-profile', '[role="dialog"]', '.search-results'
407
- Container must uniquely identify a semantic wrapper, not a path through the DOM.
408
- </container_rules>
409
-
410
380
  <section_format>
411
381
  ## Section Name
412
382
 
@@ -416,40 +386,16 @@ export class Researcher extends ResearcherBase {
416
386
 
417
387
  | Element | ARIA | CSS | eidx |
418
388
  </section_format>
419
- <section_example>
420
- ## List
421
-
422
- Product catalog showing available items with sorting and filtering.
423
-
424
- > Container: '.product-list'
425
-
426
- | Element | ARIA | CSS | eidx |
427
- | 'Sort by price' | { role: 'button', text: 'Sort by price' } | '.sort-btn' | 3 |
428
- | 'Add to cart' | { role: 'button', text: 'Add to cart' } | '.add-btn' | 4 |
429
- | 'Product name' | { role: 'link', text: 'Widget Pro' } | 'a.product-link' | 5 |
430
- </section_example>
431
389
 
432
390
  <focused_section>
433
- At the very end of your output, add a single line declaring which section is the user's primary focus area:
391
+ At the end of your output, declare the primary focus area on a single line:
434
392
 
435
393
  > Focused: <exact section name>
436
394
 
437
- Rules for determining the focused section:
438
- - If the page has a dialog, modal, drawer, or overlay that section is focused
439
- - If no overlay exists, pick the section where the user performs the main business action of this page (e.g., a list section for a catalog page, a detail section for an item page, a content section for an article page)
440
- - Navigation is NEVER focused — it exists on every page
441
- - Menu/toolbar is NEVER focused — it contains actions, not the main content
442
- - The focused section is the one the user came to this page to interact with
395
+ - If a dialog/modal/drawer/overlay exists, it is focused.
396
+ - Otherwise pick the section where the main business action happens (list for catalog, detail for item page, content for article).
397
+ - Navigation and menu/toolbar are never focused.
443
398
  </focused_section>
444
-
445
- <css_selector_rules>
446
- CSS selectors MUST point to the actual interactive element (input, button, a, select), NOT to container divs.
447
- - If a submit button is inside a wrapper div, target the input/button directly
448
- - Bad: '#submit-wrapper' (div container)
449
- - Good: '#submit-wrapper input[type="submit"]' or 'input[type="submit"][value="Submit"]'
450
- - For buttons with similar text, include distinguishing attributes like type, value, or form context
451
-
452
- </css_selector_rules>
453
399
  `;
454
400
  }
455
401
  async buildResearchPrompt() {
@@ -475,17 +421,6 @@ export class Researcher extends ResearcherBase {
475
421
  return dedent `
476
422
  Analyze this web page and provide a comprehensive research report in markdown format.
477
423
 
478
- <error_detection>
479
- IMPORTANT: First check if this looks like an error page (404, 500, access denied,
480
- not found, server error, forbidden, or similar). If so, respond ONLY with:
481
-
482
- ## Error Page Detected
483
- Type: [error type]
484
- Reason: [what indicates this is an error page]
485
-
486
- Then stop - do not provide normal research output for error pages.
487
- </error_detection>
488
-
489
424
  ${this.researchRules()}
490
425
 
491
426
  URL: ${this.actionResult.url || 'Unknown'}
@@ -534,14 +469,6 @@ export class Researcher extends ResearcherBase {
534
469
  </output_rules>
535
470
 
536
471
 
537
- `;
538
- }
539
- buildFocusedRetryPrompt() {
540
- return dedent `
541
- Your previous response was truncated and could not be parsed.
542
-
543
- Please retry with a shorter output. Focus ONLY on the main interactive section of the page.
544
- Skip navigation, sidebar, and footer sections. Output ONE section only with max 15 elements.
545
472
  `;
546
473
  }
547
474
  async textContent(state) {
@@ -1,4 +1,5 @@
1
1
  import dedent from 'dedent';
2
+ import { renderExperienceToc } from '../experience-tracker.js';
2
3
  import { createDebug, pluralize, tag } from '../utils/logger.js';
3
4
  const debugLog = createDebug('explorbot:task-agent');
4
5
  export function isInteractive() {
@@ -34,34 +35,13 @@ export class TaskAgent {
34
35
  }
35
36
  getExperience(actionResult) {
36
37
  const tracker = this.getExperienceTracker();
37
- const relevantExperience = tracker.getRelevantExperience(actionResult);
38
- if (relevantExperience.length === 0)
38
+ const toc = tracker.getExperienceTableOfContents(actionResult);
39
+ if (toc.length === 0)
39
40
  return '';
40
- const allContent = relevantExperience
41
- .map((e) => e.content)
42
- .filter((e) => !!e)
43
- .join('\n\n---\n\n');
44
- const totalChars = allContent.length;
45
- let experienceContent;
46
- if (totalChars <= 10_000) {
47
- debugLog(`injecting all experience (${Math.round(totalChars / 1000)}k chars)`);
48
- experienceContent = allContent;
49
- }
50
- else {
51
- experienceContent = tracker.getSuccessfulExperience(actionResult).join('\n\n---\n\n');
52
- debugLog(`injecting success-only experience (${Math.round(experienceContent.length / 1000)}k chars, filtered from ${Math.round(totalChars / 1000)}k)`);
53
- }
54
- if (!experienceContent)
55
- return '';
56
- tag('substep').log(`Found ${relevantExperience.length} experience ${pluralize(relevantExperience.length, 'file')}`);
57
- return dedent `
58
- <experience>
59
- Here is past experience of interacting with this page.
60
- Use successful solutions first. Avoid repeating failed actions.
61
-
62
- ${experienceContent}
63
- </experience>
64
- `;
41
+ const totalSections = toc.reduce((sum, entry) => sum + entry.sections.length, 0);
42
+ debugLog(`injecting experience TOC (${toc.length} files, ${totalSections} sections)`);
43
+ tag('substep').log(`Found ${toc.length} experience ${pluralize(toc.length, 'file')} (${totalSections} sections)`);
44
+ return renderExperienceToc(toc);
65
45
  }
66
46
  setHistorian(historian) {
67
47
  this._historian = historian;
@@ -8,7 +8,7 @@ import { setActivity } from "../activity.js";
8
8
  import { ConfigParser } from "../config.js";
9
9
  import { Stats } from "../stats.js";
10
10
  import { TestResult } from "../test-plan.js";
11
- import { extractFocusedElement } from "../utils/aria.js";
11
+ import { detectFocusArea, extractFocusedElement } from "../utils/aria.js";
12
12
  import { HooksRunner } from "../utils/hooks-runner.js";
13
13
  import { createDebug, tag } from "../utils/logger.js";
14
14
  import { loop } from "../utils/loop.js";
@@ -44,6 +44,8 @@ export class Tester extends TaskAgent {
44
44
  executionLogFile = null;
45
45
  previousUrl = null;
46
46
  previousStateHash = null;
47
+ pageStateHash = null;
48
+ pageActionResult = null;
47
49
  hooksRunner;
48
50
  constructor(explorer, provider, researcher, navigator, agentTools) {
49
51
  super();
@@ -90,6 +92,8 @@ export class Tester extends TaskAgent {
90
92
  setActivity(`🧪 Testing: ${task.scenario}`, 'action');
91
93
  this.previousUrl = null;
92
94
  this.previousStateHash = null;
95
+ this.pageStateHash = null;
96
+ this.pageActionResult = null;
93
97
  this.explorer.getStateManager().clearHistory();
94
98
  this.resetFailureCount();
95
99
  this.pilot?.reset();
@@ -369,7 +373,8 @@ export class Tester extends TaskAgent {
369
373
  }
370
374
  if (isNewUrl) {
371
375
  const research = await this.researcher.research(currentState);
372
- const experience = this.getExperience(currentState);
376
+ this.pageStateHash = currentStateHash;
377
+ this.pageActionResult = currentState;
373
378
  let uiMapSection = '';
374
379
  if (research) {
375
380
  uiMapSection = dedent `
@@ -394,8 +399,6 @@ export class Tester extends TaskAgent {
394
399
  </page_aria>
395
400
  ${uiMapSection}
396
401
 
397
- ${experience}
398
-
399
402
  Use <page_ui_map> to understand the page structure and its main elements.
400
403
  However, <page_ui_map> is not always up to date, use <page_aria> and <page_html> to understand the ACTUAL state of the page
401
404
  Do not interact with elements that are not listed in <page_aria> and <page_html>
@@ -403,6 +406,18 @@ export class Tester extends TaskAgent {
403
406
  `;
404
407
  return context;
405
408
  }
409
+ const focusArea = detectFocusArea(currentState.ariaSnapshot);
410
+ if (focusArea.detected && focusArea.name && this.pageStateHash && this.pageActionResult) {
411
+ const overlaySection = await this.researcher.researchOverlay(currentState, this.pageActionResult, this.pageStateHash);
412
+ if (overlaySection) {
413
+ context += dedent `
414
+
415
+ <page_ui_map_overlay>
416
+ ${overlaySection}
417
+ </page_ui_map_overlay>
418
+ `;
419
+ }
420
+ }
406
421
  // if (isStateChanged) {
407
422
  // const combinedHtml = await currentState.combinedHtml();
408
423
  // context += dedent`
@@ -389,9 +389,9 @@ export function createSpecialContextTools(explorer, context) {
389
389
  }),
390
390
  };
391
391
  }
392
- export function createAgentTools({ explorer, researcher, navigator, }) {
392
+ export function createAgentTools({ explorer, researcher, navigator, experienceTracker, getState, }) {
393
393
  let visionDisabled = false;
394
- return {
394
+ const tools = {
395
395
  see: tool({
396
396
  description: dedent `
397
397
  Check the page contents based on current page state and screenshot.
@@ -830,6 +830,31 @@ export function createAgentTools({ explorer, researcher, navigator, }) {
830
830
  },
831
831
  }),
832
832
  };
833
+ if (experienceTracker && getState) {
834
+ tools.learn_experience = tool({
835
+ description: dedent `
836
+ Read the full body of a specific experience section listed in <experience>.
837
+ The TOC shows entries like "A.1 ## Successful Flow: ...". Pass the fileTag and sectionIndex.
838
+ Only call when a TOC entry looks directly relevant to the current step.
839
+ `,
840
+ inputSchema: z.object({
841
+ fileTag: z.string().describe('File tag from the TOC, e.g. "A", "B", "AA"'),
842
+ sectionIndex: z.number().int().positive().describe('1-based section index within that file'),
843
+ }),
844
+ execute: async ({ fileTag, sectionIndex }) => {
845
+ const state = getState();
846
+ if (!state) {
847
+ return { error: 'No current page state available.' };
848
+ }
849
+ const section = experienceTracker.getExperienceSection(fileTag, sectionIndex, state);
850
+ if (!section) {
851
+ return { error: 'Section not found. Experience may have been updated; re-read the latest TOC.' };
852
+ }
853
+ return section;
854
+ },
855
+ });
856
+ }
857
+ return tools;
833
858
  }
834
859
  const PAGE_DIFF_SUGGESTION = 'Analyze page diff. htmlParts shows what changed and WHERE — each part has a container selector. Use the container as context when clicking elements from the diff.';
835
860
  function transformContainsCommand(command) {
@@ -1,8 +1,6 @@
1
- import { existsSync, readdirSync } from 'node:fs';
2
1
  import figureSet from 'figures';
3
2
  import path from 'node:path';
4
3
  import { getStyles } from '../ai/planner/styles.js';
5
- import { ConfigParser } from "../config.js";
6
4
  import { getCliName } from "../utils/cli-name.js";
7
5
  import { jsonToTable } from '../utils/markdown-parser.js';
8
6
  import { tag } from '../utils/logger.js';
@@ -110,14 +108,11 @@ export class ExploreCommand extends BaseCommand {
110
108
  }
111
109
  }
112
110
  printRerunSuggestions() {
113
- const testsDir = ConfigParser.getInstance().getTestsDir();
114
- if (!existsSync(testsDir))
111
+ const savedFiles = this.explorBot.agentHistorian().getSavedFiles();
112
+ if (savedFiles.length === 0)
115
113
  return;
116
- const testFiles = readdirSync(testsDir).filter((f) => f.endsWith('.js'));
117
- if (testFiles.length === 0)
118
- return;
119
- for (const file of testFiles) {
120
- tag('info').log(`Generated: ${file}`);
114
+ for (const filePath of savedFiles) {
115
+ tag('info').log(`Generated: ${path.basename(filePath)}`);
121
116
  }
122
117
  tag('info').log(`List tests: ${getCliName()} runs`);
123
118
  tag('info').log(`Re-run with healing: ${getCliName()} rerun <filename> [index]`);
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
2
- import { dirname, join } from 'node:path';
2
+ import { basename, dirname, join } from 'node:path';
3
3
  import matter from 'gray-matter';
4
+ import { marked } from 'marked';
4
5
  import { ConfigParser } from './config.js';
5
6
  import { KnowledgeTracker } from './knowledge-tracker.js';
6
7
  import { createDebug, tag } from './utils/logger.js';
@@ -290,4 +291,128 @@ ${filteredCode}
290
291
  }
291
292
  return results;
292
293
  }
294
+ getExperienceTableOfContents(state, options) {
295
+ const records = this.getRelevantExperience(state, options);
296
+ if (records.length === 0)
297
+ return [];
298
+ const sorted = [...records].sort((a, b) => {
299
+ const aHash = basename(a.filePath, '.md');
300
+ const bHash = basename(b.filePath, '.md');
301
+ return aHash.localeCompare(bHash);
302
+ });
303
+ const toc = [];
304
+ for (let i = 0; i < sorted.length; i++) {
305
+ const record = sorted[i];
306
+ const fileHash = basename(record.filePath, '.md');
307
+ const url = record.data?.url || '';
308
+ const sections = listTocHeadings(record.content);
309
+ if (sections.length === 0)
310
+ continue;
311
+ toc.push({
312
+ fileTag: indexToLetters(i),
313
+ fileHash,
314
+ url,
315
+ sections,
316
+ });
317
+ }
318
+ return toc;
319
+ }
320
+ getExperienceSection(fileTag, sectionIndex, state, options) {
321
+ const toc = this.getExperienceTableOfContents(state, options);
322
+ const entry = toc.find((e) => e.fileTag === fileTag);
323
+ if (!entry)
324
+ return null;
325
+ const filePath = this.findExperienceFileByHash(entry.fileHash);
326
+ if (!filePath)
327
+ return null;
328
+ const { content } = this.readExperienceFile(entry.fileHash);
329
+ const extracted = extractHeadingSection(content, sectionIndex);
330
+ if (!extracted)
331
+ return null;
332
+ return { title: extracted.title, url: entry.url, content: extracted.body };
333
+ }
334
+ findExperienceFileByHash(fileHash) {
335
+ for (const dir of this.getExperienceDirectories()) {
336
+ const candidate = join(dir, `${fileHash}.md`);
337
+ if (existsSync(candidate))
338
+ return candidate;
339
+ }
340
+ return null;
341
+ }
342
+ }
343
+ function listTocHeadings(content) {
344
+ const tokens = marked.lexer(content);
345
+ const result = [];
346
+ let index = 0;
347
+ for (const token of tokens) {
348
+ if (token.type !== 'heading')
349
+ continue;
350
+ const heading = token;
351
+ if (heading.depth !== 2 && heading.depth !== 3)
352
+ continue;
353
+ index++;
354
+ result.push({ index, level: heading.depth, title: heading.text });
355
+ }
356
+ return result;
357
+ }
358
+ function extractHeadingSection(content, sectionIndex) {
359
+ const tokens = marked.lexer(content);
360
+ const matching = [];
361
+ for (let i = 0; i < tokens.length; i++) {
362
+ const token = tokens[i];
363
+ if (token.type !== 'heading')
364
+ continue;
365
+ const heading = token;
366
+ if (heading.depth !== 2 && heading.depth !== 3)
367
+ continue;
368
+ matching.push({ tokenIdx: i, depth: heading.depth, text: heading.text });
369
+ }
370
+ if (sectionIndex < 1 || sectionIndex > matching.length)
371
+ return null;
372
+ const target = matching[sectionIndex - 1];
373
+ let endTokenIdx = tokens.length;
374
+ for (let j = target.tokenIdx + 1; j < tokens.length; j++) {
375
+ const token = tokens[j];
376
+ if (token.type !== 'heading')
377
+ continue;
378
+ if (token.depth <= target.depth) {
379
+ endTokenIdx = j;
380
+ break;
381
+ }
382
+ }
383
+ const body = tokens
384
+ .slice(target.tokenIdx, endTokenIdx)
385
+ .map((t) => t.raw || '')
386
+ .join('');
387
+ return { title: target.text, body };
388
+ }
389
+ function indexToLetters(index) {
390
+ let n = index;
391
+ let result = '';
392
+ while (true) {
393
+ result = String.fromCharCode(65 + (n % 26)) + result;
394
+ n = Math.floor(n / 26);
395
+ if (n === 0)
396
+ break;
397
+ n -= 1;
398
+ }
399
+ return result;
400
+ }
401
+ export function renderExperienceToc(toc) {
402
+ if (toc.length === 0)
403
+ return '';
404
+ const lines = [];
405
+ lines.push('<experience>');
406
+ lines.push('Past experience for this page. Call learn_experience({ fileTag, sectionIndex }) to read a section.');
407
+ lines.push('');
408
+ for (const entry of toc) {
409
+ lines.push(`File ${entry.fileTag} ${entry.url}:`);
410
+ for (const section of entry.sections) {
411
+ const prefix = '#'.repeat(section.level);
412
+ lines.push(` ${entry.fileTag}.${section.index} ${prefix} ${section.title}`);
413
+ }
414
+ lines.push('');
415
+ }
416
+ lines.push('</experience>');
417
+ return lines.join('\n');
293
418
  }
@@ -1,5 +1,6 @@
1
1
  import { existsSync, mkdirSync } from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { ActionResult } from "./action-result.js";
3
4
  import { ApiClient } from "./api/api-client.js";
4
5
  import { RequestStore } from "./api/request-store.js";
5
6
  import { loadSpec } from "./api/spec-reader.js";
@@ -144,8 +145,14 @@ export class ExplorBot {
144
145
  return (this.agents.pilot ||= this.createAgent(({ ai, explorer }) => {
145
146
  const researcher = this.agentResearcher();
146
147
  const navigator = this.agentNavigator();
147
- const tools = createAgentTools({ explorer, researcher, navigator });
148
- return new Pilot(ai, tools, researcher, explorer);
148
+ const stateManager = explorer.getStateManager();
149
+ const experienceTracker = stateManager.getExperienceTracker();
150
+ const getState = () => {
151
+ const state = stateManager.getCurrentState();
152
+ return state ? ActionResult.fromState(state) : null;
153
+ };
154
+ const tools = createAgentTools({ explorer, researcher, navigator, experienceTracker, getState });
155
+ return new Pilot(ai, tools, researcher, explorer, experienceTracker);
149
156
  }));
150
157
  }
151
158
  agentTester() {
@@ -18,6 +18,7 @@ import { createDebug, log, tag } from './utils/logger.js';
18
18
  import { WebElement, extractElementData } from "./utils/web-element.js";
19
19
  const debugLog = createDebug('explorbot:explorer');
20
20
  const FATAL_BROWSER_ERRORS = /Frame was detached|Target closed|Execution context was destroyed|Protocol error|Session closed/i;
21
+ const RECOVERABLE_NAVIGATION_ERRORS = /net::ERR_ABORTED|page\.screenshot.*Timeout|waiting for fonts to load/i;
21
22
  class Explorer {
22
23
  aiProvider;
23
24
  playwrightHelper;
@@ -228,7 +229,17 @@ class Explorer {
228
229
  await action.execute(`I.executeScript(() => { window.history.pushState({}, '', ${serializedUrl}); window.dispatchEvent(new PopStateEvent('popstate')); })`);
229
230
  }
230
231
  else {
231
- await action.execute(`I.amOnPage(${serializedUrl})`);
232
+ try {
233
+ await action.execute(`I.amOnPage(${serializedUrl})`);
234
+ }
235
+ catch (err) {
236
+ const msg = err instanceof Error ? err.message : String(err);
237
+ if (!RECOVERABLE_NAVIGATION_ERRORS.test(msg))
238
+ throw err;
239
+ tag('warning').log(`Navigation warning (continuing after load): ${msg.split('\n')[0]}`);
240
+ await this.playwrightHelper.page.waitForLoadState('domcontentloaded', { timeout: 10000 }).catch(() => { });
241
+ await action.capturePageState();
242
+ }
232
243
  }
233
244
  if (wait !== undefined) {
234
245
  debugLog('Waiting for', wait);
@@ -333,8 +333,40 @@ const parseAriaSnapshot = (snapshot, keepNamed = false) => {
333
333
  }
334
334
  return pruneNodes(roots, keepNamed);
335
335
  };
336
+ const CLOSE_OVERLAY_BUTTON_RE = /^close\s+(modal|dialog|popup|drawer|panel|sheet)\b/i;
337
+ const findOverlayByCloseButton = (nodeList) => {
338
+ const closeIdx = nodeList.findIndex((n) => n.role === 'button' && CLOSE_OVERLAY_BUTTON_RE.test(n.name || ''));
339
+ if (closeIdx !== -1) {
340
+ let heading;
341
+ for (let i = closeIdx - 1; i >= 0; i--) {
342
+ if (nodeList[i].role === 'heading' && nodeList[i].name) {
343
+ heading = nodeList[i];
344
+ break;
345
+ }
346
+ }
347
+ if (!heading) {
348
+ for (let i = closeIdx + 1; i < nodeList.length; i++) {
349
+ if (nodeList[i].role === 'heading' && nodeList[i].name) {
350
+ heading = nodeList[i];
351
+ break;
352
+ }
353
+ }
354
+ }
355
+ return {
356
+ detected: true,
357
+ type: 'dialog',
358
+ name: heading?.name || null,
359
+ };
360
+ }
361
+ for (const node of nodeList) {
362
+ const inner = findOverlayByCloseButton(node.children);
363
+ if (inner)
364
+ return inner;
365
+ }
366
+ return null;
367
+ };
336
368
  export const detectFocusArea = (snapshot) => {
337
- const nodes = parseAriaSnapshot(snapshot);
369
+ const nodes = parseAriaSnapshot(snapshot, true);
338
370
  const findFocusArea = (nodeList) => {
339
371
  for (const node of nodeList) {
340
372
  if (node.role === 'dialog' || node.role === 'alertdialog') {
@@ -359,7 +391,12 @@ export const detectFocusArea = (snapshot) => {
359
391
  return null;
360
392
  };
361
393
  const result = findFocusArea(nodes);
362
- return result || { detected: false, type: null, name: null };
394
+ if (result)
395
+ return result;
396
+ const fallback = findOverlayByCloseButton(nodes);
397
+ if (fallback && fallback.name)
398
+ return fallback;
399
+ return { detected: false, type: null, name: null };
363
400
  };
364
401
  export const collectInteractiveNodes = (snapshot) => {
365
402
  const nodes = parseAriaSnapshot(snapshot);