explorbot 0.1.5 → 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.
@@ -0,0 +1,122 @@
1
+ import dedent from 'dedent';
2
+ import type { ActionResult } from '../../action-result.js';
3
+ import { executionController } from '../../execution-controller.ts';
4
+ import type Explorer from '../../explorer.ts';
5
+ import type { StateManager } from '../../state-manager.js';
6
+ import { tag } from '../../utils/logger.js';
7
+ import { RulesLoader } from '../../utils/rules-loader.ts';
8
+ import type { Provider } from '../provider.js';
9
+ import { locatorRule as generalLocatorRuleText } from '../rules.js';
10
+ import type { Constructor } from './mixin.ts';
11
+
12
+ export interface SectionMethods {
13
+ researchBySections(): Promise<string>;
14
+ }
15
+
16
+ export function WithSections<T extends Constructor>(Base: T) {
17
+ return class extends Base {
18
+ declare explorer: Explorer;
19
+ declare provider: Provider;
20
+ declare stateManager: StateManager;
21
+ declare actionResult: ActionResult | undefined;
22
+
23
+ async researchBySections(): Promise<string> {
24
+ const ariaSnapshot = this.actionResult?.getCompactARIA() || '';
25
+ const configured = (this as any).getConfiguredSections() as Record<string, string>;
26
+ const focusCss = await this._detectFocusCss();
27
+
28
+ let targets: Array<[string, string]>;
29
+ if (focusCss) {
30
+ targets = [['Focus', `element bounded by CSS container '${focusCss}'`]];
31
+ tag('info').log(`Focus element detected via selector '${focusCss}', researching focused area only`);
32
+ } else {
33
+ targets = Object.entries(configured);
34
+ tag('info').log(`Splitting research into ${targets.length} per-section requests`);
35
+ }
36
+
37
+ const parts: string[] = [];
38
+ for (const [name, description] of targets) {
39
+ if (executionController.isInterrupted()) break;
40
+ const text = await this._researchSingleSection(name, description, ariaSnapshot, focusCss);
41
+ if (!text) continue;
42
+ const trimmed = text.trim();
43
+ if (trimmed === 'NOT_PRESENT' || trimmed.startsWith('NOT_PRESENT')) continue;
44
+ parts.push(trimmed);
45
+ }
46
+
47
+ if (parts.length === 0) {
48
+ throw new Error('Per-section research produced no sections — AI responses all empty or NOT_PRESENT');
49
+ }
50
+
51
+ let merged = parts.join('\n\n');
52
+ if (focusCss) merged += '\n\n> Focused: Focus';
53
+ return merged;
54
+ }
55
+
56
+ private async _detectFocusCss(): Promise<string | null> {
57
+ const focusSections = (this.explorer.getConfig().ai?.agents?.researcher as any)?.focusSections as string[] | undefined;
58
+ if (!focusSections?.length) return null;
59
+
60
+ for (const css of focusSections) {
61
+ const count = await this.explorer.playwrightLocatorCount((page: any) => page.locator(css)).catch(() => 0);
62
+ if (count > 0) return css;
63
+ }
64
+ return null;
65
+ }
66
+
67
+ private async _researchSingleSection(name: string, description: string, ariaSnapshot: string, focusCss: string | null): Promise<string> {
68
+ const currentUrl = this.stateManager.getCurrentState()?.url || '';
69
+ const rules = RulesLoader.loadRules('researcher', ['ui-map-table', 'list-element', 'container-rules'], currentUrl);
70
+ const url = this.actionResult?.url || 'Unknown';
71
+ const title = this.actionResult?.title || 'Unknown';
72
+
73
+ let focusHint = '';
74
+ if (focusCss) {
75
+ focusHint = dedent`
76
+ The user's focus is the element matching CSS '${focusCss}'.
77
+ Use that CSS as the Container for this section.
78
+ `;
79
+ }
80
+
81
+ const prompt = dedent`
82
+ <task>
83
+ Identify the "${name}" section on this page: ${description}
84
+ If this section is NOT present on the page, respond with ONLY: NOT_PRESENT
85
+ Otherwise output only this single section in the format below.
86
+ ${focusHint}
87
+ </task>
88
+
89
+ <section_format>
90
+ ## ${name}
91
+
92
+ > Container: '.semantic-container-selector'
93
+
94
+ | Element | ARIA | CSS | eidx |
95
+ </section_format>
96
+
97
+ <rules>
98
+ - Every element with eidx MUST appear in the table.
99
+ - Every row needs CSS; ARIA may be "-" for icon-only buttons.
100
+ - ARIA locator JSON uses keys "role" and "text" (NOT "name").
101
+ </rules>
102
+
103
+ ${generalLocatorRuleText}
104
+
105
+ ${rules}
106
+
107
+ URL: ${url}
108
+ Title: ${title}
109
+
110
+ <aria>
111
+ ${ariaSnapshot}
112
+ </aria>
113
+ `;
114
+
115
+ const conversation = this.provider.startConversation((this as any).getSystemMessage(), 'researcher');
116
+ conversation.addUserText(prompt);
117
+
118
+ const result = await this.provider.invokeConversation(conversation, undefined, { agentName: 'researcher' });
119
+ return result?.response.text || '';
120
+ }
121
+ };
122
+ }
@@ -28,6 +28,7 @@ import { detectFocusFromAria, hasFocusedSection, markSectionAsFocused, pickDefau
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';
31
+ import { type SectionMethods, WithSections } from './researcher/sections.ts';
31
32
  import { locatorRule as generalLocatorRuleText } from './rules.js';
32
33
  import { RulesLoader } from '../utils/rules-loader.ts';
33
34
  import { TaskAgent } from './task-agent.ts';
@@ -46,9 +47,9 @@ export const POSSIBLE_SECTIONS = {
46
47
  navigation: 'main navigation (top bar, sidebar, breadcrumbs)',
47
48
  };
48
49
 
49
- const ResearcherBase = WithDeepAnalysis(WithCoordinates(WithLocators(TaskAgent as unknown as new (...args: any[]) => TaskAgent)));
50
+ const ResearcherBase = WithSections(WithDeepAnalysis(WithCoordinates(WithLocators(TaskAgent as unknown as new (...args: any[]) => TaskAgent))));
50
51
 
51
- export interface Researcher extends LocatorMethods, CoordinateMethods, DeepAnalysisMethods {}
52
+ export interface Researcher extends LocatorMethods, CoordinateMethods, DeepAnalysisMethods, SectionMethods {}
52
53
 
53
54
  export class Researcher extends ResearcherBase implements Agent {
54
55
  protected readonly ACTION_TOOLS = ['click'];
@@ -170,10 +171,12 @@ export class Researcher extends ResearcherBase implements Agent {
170
171
  const prompt = await this.buildResearchPrompt();
171
172
  conversation.addUserText(prompt);
172
173
 
173
- let invocationResult: Awaited<ReturnType<typeof this.provider.invokeConversation>>;
174
+ let researchText: string;
174
175
  let activeConversation = conversation;
175
176
  try {
176
- invocationResult = await this.provider.invokeConversation(conversation, undefined, { agentName: 'researcher' });
177
+ const invocationResult = await this.provider.invokeConversation(conversation, undefined, { agentName: 'researcher' });
178
+ if (!invocationResult) throw new Error('Failed to get response from provider');
179
+ researchText = invocationResult.response.text;
177
180
  } catch (error) {
178
181
  if (!(error instanceof ContextLengthError) || retriesLeft <= 0) {
179
182
  if (error instanceof ContextLengthError) {
@@ -181,15 +184,12 @@ export class Researcher extends ResearcherBase implements Agent {
181
184
  }
182
185
  throw error;
183
186
  }
184
- tag('warning').log('Output truncated, retrying with fresh focused conversation (ARIA only)...');
185
187
  retriesLeft = 0;
188
+ researchText = await this.researchBySections();
186
189
  activeConversation = this.provider.startConversation(this.getSystemMessage(), 'researcher');
187
- activeConversation.addUserText(this.buildFocusedRetryPrompt());
188
- invocationResult = await this.provider.invokeConversation(activeConversation, undefined, { agentName: 'researcher' });
189
190
  }
190
- if (!invocationResult) throw new Error('Failed to get response from provider');
191
191
 
192
- const result = new ResearchResult(invocationResult.response.text, state.url);
192
+ const result = new ResearchResult(researchText, state.url);
193
193
  debugLog(`Original research response length: ${result.text.length} chars`);
194
194
 
195
195
  const interrupted = () => executionController.isInterrupted();
@@ -537,44 +537,6 @@ export class Researcher extends ResearcherBase implements Agent {
537
537
  `;
538
538
  }
539
539
 
540
- private buildFocusedRetryPrompt(): string {
541
- const currentUrl = this.stateManager.getCurrentState()?.url || '';
542
- const example = RulesLoader.loadRules('researcher', ['section-example'], currentUrl);
543
- const uiMapTable = RulesLoader.loadRules('researcher', ['ui-map-table'], currentUrl);
544
- const url = this.actionResult?.url || 'Unknown';
545
- const title = this.actionResult?.title || 'Unknown';
546
- const aria = this.actionResult?.getCompactARIA() || '';
547
- return dedent`
548
- Previous response was truncated. Restart with a minimal output.
549
-
550
- <task>
551
- Output a UI map for ONE section only — the main interactive area of this page.
552
- Skip navigation, sidebar, and footer. Max 15 elements.
553
- Every element with an eidx MUST appear. Every row needs CSS; ARIA may be "-" for icon-only.
554
- End with a single line: \`> Focused: <section name>\`.
555
- </task>
556
-
557
- <section_format>
558
- ## Section Name
559
-
560
- > Container: '.container-css-selector'
561
-
562
- | Element | ARIA | CSS | eidx |
563
- </section_format>
564
-
565
- ${example}
566
-
567
- ${uiMapTable}
568
-
569
- URL: ${url}
570
- Title: ${title}
571
-
572
- <aria>
573
- ${aria}
574
- </aria>
575
- `;
576
- }
577
-
578
540
  async textContent(state: WebPageState): Promise<string> {
579
541
  const actionResult = ActionResult.fromState(state);
580
542
  const html = await actionResult.combinedHtml();
@@ -1,6 +1,6 @@
1
1
  import dedent from 'dedent';
2
2
  import type { ActionResult } from '../action-result.js';
3
- import type { ExperienceTracker } from '../experience-tracker.js';
3
+ import { renderExperienceToc, type ExperienceTracker } from '../experience-tracker.js';
4
4
  import type { KnowledgeTracker } from '../knowledge-tracker.js';
5
5
  import { createDebug, pluralize, tag } from '../utils/logger.js';
6
6
 
@@ -56,37 +56,13 @@ export abstract class TaskAgent {
56
56
 
57
57
  protected getExperience(actionResult: ActionResult): string {
58
58
  const tracker = this.getExperienceTracker();
59
- const relevantExperience = tracker.getRelevantExperience(actionResult);
59
+ const toc = tracker.getExperienceTableOfContents(actionResult);
60
+ if (toc.length === 0) return '';
60
61
 
61
- if (relevantExperience.length === 0) return '';
62
-
63
- const allContent = relevantExperience
64
- .map((e) => e.content)
65
- .filter((e) => !!e)
66
- .join('\n\n---\n\n');
67
-
68
- const totalChars = allContent.length;
69
- let experienceContent: string;
70
-
71
- if (totalChars <= 10_000) {
72
- debugLog(`injecting all experience (${Math.round(totalChars / 1000)}k chars)`);
73
- experienceContent = allContent;
74
- } else {
75
- experienceContent = tracker.getSuccessfulExperience(actionResult).join('\n\n---\n\n');
76
- debugLog(`injecting success-only experience (${Math.round(experienceContent.length / 1000)}k chars, filtered from ${Math.round(totalChars / 1000)}k)`);
77
- }
78
-
79
- if (!experienceContent) return '';
80
-
81
- tag('substep').log(`Found ${relevantExperience.length} experience ${pluralize(relevantExperience.length, 'file')}`);
82
- return dedent`
83
- <experience>
84
- Here is past experience of interacting with this page.
85
- Use successful solutions first. Avoid repeating failed actions.
86
-
87
- ${experienceContent}
88
- </experience>
89
- `;
62
+ const totalSections = toc.reduce((sum, entry) => sum + entry.sections.length, 0);
63
+ debugLog(`injecting experience TOC (${toc.length} files, ${totalSections} sections)`);
64
+ tag('substep').log(`Found ${toc.length} experience ${pluralize(toc.length, 'file')} (${totalSections} sections)`);
65
+ return renderExperienceToc(toc);
90
66
  }
91
67
 
92
68
  setHistorian(historian: Historian): void {
package/src/ai/tester.ts CHANGED
@@ -11,7 +11,7 @@ import type Explorer from '../explorer.ts';
11
11
  import type { StateTransition, WebPageState } from '../state-manager.ts';
12
12
  import { Stats } from '../stats.ts';
13
13
  import { type Note, type Test, TestResult, type TestResultType } from '../test-plan.ts';
14
- import { extractFocusedElement } from '../utils/aria.ts';
14
+ import { detectFocusArea, extractFocusedElement } from '../utils/aria.ts';
15
15
  import { HooksRunner } from '../utils/hooks-runner.ts';
16
16
  import { codeToMarkdown } from '../utils/html.ts';
17
17
  import { createDebug, tag } from '../utils/logger.ts';
@@ -59,6 +59,8 @@ export class Tester extends TaskAgent implements Agent {
59
59
  executionLogFile: string | null = null;
60
60
  private previousUrl: string | null = null;
61
61
  private previousStateHash: string | null = null;
62
+ private pageStateHash: string | null = null;
63
+ private pageActionResult: ActionResult | null = null;
62
64
  private hooksRunner: HooksRunner;
63
65
 
64
66
  constructor(explorer: Explorer, provider: Provider, researcher: Researcher, navigator: Navigator, agentTools?: any) {
@@ -117,6 +119,8 @@ export class Tester extends TaskAgent implements Agent {
117
119
 
118
120
  this.previousUrl = null;
119
121
  this.previousStateHash = null;
122
+ this.pageStateHash = null;
123
+ this.pageActionResult = null;
120
124
  this.explorer.getStateManager().clearHistory();
121
125
  this.resetFailureCount();
122
126
  this.pilot?.reset();
@@ -441,7 +445,8 @@ export class Tester extends TaskAgent implements Agent {
441
445
 
442
446
  if (isNewUrl) {
443
447
  const research = await this.researcher.research(currentState);
444
- const experience = this.getExperience(currentState);
448
+ this.pageStateHash = currentStateHash;
449
+ this.pageActionResult = currentState;
445
450
  let uiMapSection = '';
446
451
  if (research) {
447
452
  uiMapSection = dedent`
@@ -467,8 +472,6 @@ export class Tester extends TaskAgent implements Agent {
467
472
  </page_aria>
468
473
  ${uiMapSection}
469
474
 
470
- ${experience}
471
-
472
475
  Use <page_ui_map> to understand the page structure and its main elements.
473
476
  However, <page_ui_map> is not always up to date, use <page_aria> and <page_html> to understand the ACTUAL state of the page
474
477
  Do not interact with elements that are not listed in <page_aria> and <page_html>
@@ -477,6 +480,19 @@ export class Tester extends TaskAgent implements Agent {
477
480
  return context;
478
481
  }
479
482
 
483
+ const focusArea = detectFocusArea(currentState.ariaSnapshot);
484
+ if (focusArea.detected && focusArea.name && this.pageStateHash && this.pageActionResult) {
485
+ const overlaySection = await this.researcher.researchOverlay(currentState, this.pageActionResult, this.pageStateHash);
486
+ if (overlaySection) {
487
+ context += dedent`
488
+
489
+ <page_ui_map_overlay>
490
+ ${overlaySection}
491
+ </page_ui_map_overlay>
492
+ `;
493
+ }
494
+ }
495
+
480
496
  // if (isStateChanged) {
481
497
  // const combinedHtml = await currentState.combinedHtml();
482
498
  // context += dedent`
package/src/ai/tools.ts CHANGED
@@ -2,6 +2,7 @@ import { tool } from 'ai';
2
2
  import dedent from 'dedent';
3
3
  import { z } from 'zod';
4
4
  import { ActionResult, type ToolResultMetadata } from '../action-result.ts';
5
+ import type { ExperienceTracker } from '../experience-tracker.ts';
5
6
  import type Explorer from '../explorer.ts';
6
7
  import { type Task, TestResult } from '../test-plan.js';
7
8
  import { extractFocusedElement } from '../utils/aria.ts';
@@ -449,14 +450,18 @@ export function createAgentTools({
449
450
  explorer,
450
451
  researcher,
451
452
  navigator,
453
+ experienceTracker,
454
+ getState,
452
455
  }: {
453
456
  explorer: Explorer;
454
457
  researcher: Researcher;
455
458
  navigator: Navigator;
459
+ experienceTracker?: ExperienceTracker;
460
+ getState?: () => ActionResult | null;
456
461
  }): any {
457
462
  let visionDisabled = false;
458
463
 
459
- return {
464
+ const tools: Record<string, any> = {
460
465
  see: tool({
461
466
  description: dedent`
462
467
  Check the page contents based on current page state and screenshot.
@@ -947,6 +952,33 @@ export function createAgentTools({
947
952
  },
948
953
  }),
949
954
  };
955
+
956
+ if (experienceTracker && getState) {
957
+ tools.learn_experience = tool({
958
+ description: dedent`
959
+ Read the full body of a specific experience section listed in <experience>.
960
+ The TOC shows entries like "A.1 ## Successful Flow: ...". Pass the fileTag and sectionIndex.
961
+ Only call when a TOC entry looks directly relevant to the current step.
962
+ `,
963
+ inputSchema: z.object({
964
+ fileTag: z.string().describe('File tag from the TOC, e.g. "A", "B", "AA"'),
965
+ sectionIndex: z.number().int().positive().describe('1-based section index within that file'),
966
+ }),
967
+ execute: async ({ fileTag, sectionIndex }) => {
968
+ const state = getState();
969
+ if (!state) {
970
+ return { error: 'No current page state available.' };
971
+ }
972
+ const section = experienceTracker.getExperienceSection(fileTag, sectionIndex, state);
973
+ if (!section) {
974
+ return { error: 'Section not found. Experience may have been updated; re-read the latest TOC.' };
975
+ }
976
+ return section;
977
+ },
978
+ });
979
+ }
980
+
981
+ return tools;
950
982
  }
951
983
 
952
984
  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.';
@@ -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.ts';
6
4
  import { getCliName } from '../utils/cli-name.ts';
7
5
  import type { Plan } from '../test-plan.js';
8
6
  import { jsonToTable } from '../utils/markdown-parser.js';
@@ -116,14 +114,11 @@ export class ExploreCommand extends BaseCommand {
116
114
  }
117
115
 
118
116
  private printRerunSuggestions(): void {
119
- const testsDir = ConfigParser.getInstance().getTestsDir();
120
- if (!existsSync(testsDir)) return;
117
+ const savedFiles = this.explorBot.agentHistorian().getSavedFiles();
118
+ if (savedFiles.length === 0) return;
121
119
 
122
- const testFiles = readdirSync(testsDir).filter((f) => f.endsWith('.js'));
123
- if (testFiles.length === 0) return;
124
-
125
- for (const file of testFiles) {
126
- tag('info').log(`Generated: ${file}`);
120
+ for (const filePath of savedFiles) {
121
+ tag('info').log(`Generated: ${path.basename(filePath)}`);
127
122
  }
128
123
  tag('info').log(`List tests: ${getCliName()} runs`);
129
124
  tag('info').log(`Re-run with healing: ${getCliName()} rerun <filename> [index]`);
package/src/config.ts CHANGED
@@ -69,6 +69,7 @@ interface ResearcherAgentConfig extends AgentConfig {
69
69
  maxExpandableClicks?: number;
70
70
  retries?: number;
71
71
  sections?: string[];
72
+ focusSections?: string[];
72
73
  errorPageTimeout?: number;
73
74
  }
74
75
 
@@ -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, type Tokens } from 'marked';
4
5
  import type { ActionResult } from './action-result.js';
5
6
  import { ConfigParser } from './config.js';
6
7
  import { KnowledgeTracker } from './knowledge-tracker.js';
@@ -332,6 +333,133 @@ ${filteredCode}
332
333
 
333
334
  return results;
334
335
  }
336
+
337
+ getExperienceTableOfContents(state: ActionResult, options?: { includeDescendantExperience?: boolean }): ExperienceTocEntry[] {
338
+ const records = this.getRelevantExperience(state, options);
339
+ if (records.length === 0) return [];
340
+
341
+ const sorted = [...records].sort((a, b) => {
342
+ const aHash = basename(a.filePath, '.md');
343
+ const bHash = basename(b.filePath, '.md');
344
+ return aHash.localeCompare(bHash);
345
+ });
346
+
347
+ const toc: ExperienceTocEntry[] = [];
348
+ for (let i = 0; i < sorted.length; i++) {
349
+ const record = sorted[i];
350
+ const fileHash = basename(record.filePath, '.md');
351
+ const url = (record.data as WebPageState)?.url || '';
352
+ const sections = listTocHeadings(record.content);
353
+ if (sections.length === 0) continue;
354
+ toc.push({
355
+ fileTag: indexToLetters(i),
356
+ fileHash,
357
+ url,
358
+ sections,
359
+ });
360
+ }
361
+ return toc;
362
+ }
363
+
364
+ getExperienceSection(fileTag: string, sectionIndex: number, state: ActionResult, options?: { includeDescendantExperience?: boolean }): { title: string; url: string; content: string } | null {
365
+ const toc = this.getExperienceTableOfContents(state, options);
366
+ const entry = toc.find((e) => e.fileTag === fileTag);
367
+ if (!entry) return null;
368
+
369
+ const filePath = this.findExperienceFileByHash(entry.fileHash);
370
+ if (!filePath) return null;
371
+
372
+ const { content } = this.readExperienceFile(entry.fileHash);
373
+ const extracted = extractHeadingSection(content, sectionIndex);
374
+ if (!extracted) return null;
375
+
376
+ return { title: extracted.title, url: entry.url, content: extracted.body };
377
+ }
378
+
379
+ private findExperienceFileByHash(fileHash: string): string | null {
380
+ for (const dir of this.getExperienceDirectories()) {
381
+ const candidate = join(dir, `${fileHash}.md`);
382
+ if (existsSync(candidate)) return candidate;
383
+ }
384
+ return null;
385
+ }
386
+ }
387
+
388
+ function listTocHeadings(content: string): { index: number; level: 2 | 3; title: string }[] {
389
+ const tokens = marked.lexer(content);
390
+ const result: { index: number; level: 2 | 3; title: string }[] = [];
391
+ let index = 0;
392
+ for (const token of tokens) {
393
+ if (token.type !== 'heading') continue;
394
+ const heading = token as Tokens.Heading;
395
+ if (heading.depth !== 2 && heading.depth !== 3) continue;
396
+ index++;
397
+ result.push({ index, level: heading.depth as 2 | 3, title: heading.text });
398
+ }
399
+ return result;
400
+ }
401
+
402
+ function extractHeadingSection(content: string, sectionIndex: number): { title: string; body: string } | null {
403
+ const tokens = marked.lexer(content);
404
+ const matching: { tokenIdx: number; depth: number; text: string }[] = [];
405
+
406
+ for (let i = 0; i < tokens.length; i++) {
407
+ const token = tokens[i];
408
+ if (token.type !== 'heading') continue;
409
+ const heading = token as Tokens.Heading;
410
+ if (heading.depth !== 2 && heading.depth !== 3) continue;
411
+ matching.push({ tokenIdx: i, depth: heading.depth, text: heading.text });
412
+ }
413
+
414
+ if (sectionIndex < 1 || sectionIndex > matching.length) return null;
415
+
416
+ const target = matching[sectionIndex - 1];
417
+ let endTokenIdx = tokens.length;
418
+ for (let j = target.tokenIdx + 1; j < tokens.length; j++) {
419
+ const token = tokens[j];
420
+ if (token.type !== 'heading') continue;
421
+ if ((token as Tokens.Heading).depth <= target.depth) {
422
+ endTokenIdx = j;
423
+ break;
424
+ }
425
+ }
426
+
427
+ const body = tokens
428
+ .slice(target.tokenIdx, endTokenIdx)
429
+ .map((t) => (t as any).raw || '')
430
+ .join('');
431
+ return { title: target.text, body };
432
+ }
433
+
434
+ function indexToLetters(index: number): string {
435
+ let n = index;
436
+ let result = '';
437
+ while (true) {
438
+ result = String.fromCharCode(65 + (n % 26)) + result;
439
+ n = Math.floor(n / 26);
440
+ if (n === 0) break;
441
+ n -= 1;
442
+ }
443
+ return result;
444
+ }
445
+
446
+ export function renderExperienceToc(toc: ExperienceTocEntry[]): string {
447
+ if (toc.length === 0) return '';
448
+
449
+ const lines: string[] = [];
450
+ lines.push('<experience>');
451
+ lines.push('Past experience for this page. Call learn_experience({ fileTag, sectionIndex }) to read a section.');
452
+ lines.push('');
453
+ for (const entry of toc) {
454
+ lines.push(`File ${entry.fileTag} ${entry.url}:`);
455
+ for (const section of entry.sections) {
456
+ const prefix = '#'.repeat(section.level);
457
+ lines.push(` ${entry.fileTag}.${section.index} ${prefix} ${section.title}`);
458
+ }
459
+ lines.push('');
460
+ }
461
+ lines.push('</experience>');
462
+ return lines.join('\n');
335
463
  }
336
464
 
337
465
  export interface SessionStep {
@@ -348,3 +476,10 @@ export interface SessionExperienceEntry {
348
476
  steps: SessionStep[];
349
477
  relatedUrls?: string[];
350
478
  }
479
+
480
+ export interface ExperienceTocEntry {
481
+ fileTag: string;
482
+ fileHash: string;
483
+ url: string;
484
+ sections: { index: number; level: 2 | 3; title: string }[];
485
+ }
package/src/explorbot.ts CHANGED
@@ -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.ts';
3
4
  import { ApiClient } from './api/api-client.ts';
4
5
  import { RequestStore } from './api/request-store.ts';
5
6
  import { loadSpec } from './api/spec-reader.ts';
@@ -182,8 +183,14 @@ export class ExplorBot {
182
183
  return (this.agents.pilot ||= this.createAgent(({ ai, explorer }) => {
183
184
  const researcher = this.agentResearcher();
184
185
  const navigator = this.agentNavigator();
185
- const tools = createAgentTools({ explorer, researcher, navigator });
186
- return new Pilot(ai, tools, researcher, explorer);
186
+ const stateManager = explorer.getStateManager();
187
+ const experienceTracker = stateManager.getExperienceTracker();
188
+ const getState = () => {
189
+ const state = stateManager.getCurrentState();
190
+ return state ? ActionResult.fromState(state) : null;
191
+ };
192
+ const tools = createAgentTools({ explorer, researcher, navigator, experienceTracker, getState });
193
+ return new Pilot(ai, tools, researcher, explorer, experienceTracker);
187
194
  }));
188
195
  }
189
196
 
package/src/utils/aria.ts CHANGED
@@ -355,8 +355,41 @@ export interface FocusAreaResult {
355
355
  name: string | null;
356
356
  }
357
357
 
358
+ const CLOSE_OVERLAY_BUTTON_RE = /^close\s+(modal|dialog|popup|drawer|panel|sheet)\b/i;
359
+
360
+ const findOverlayByCloseButton = (nodeList: AriaNode[]): FocusAreaResult | null => {
361
+ const closeIdx = nodeList.findIndex((n) => n.role === 'button' && CLOSE_OVERLAY_BUTTON_RE.test(n.name || ''));
362
+ if (closeIdx !== -1) {
363
+ let heading: AriaNode | undefined;
364
+ for (let i = closeIdx - 1; i >= 0; i--) {
365
+ if (nodeList[i].role === 'heading' && nodeList[i].name) {
366
+ heading = nodeList[i];
367
+ break;
368
+ }
369
+ }
370
+ if (!heading) {
371
+ for (let i = closeIdx + 1; i < nodeList.length; i++) {
372
+ if (nodeList[i].role === 'heading' && nodeList[i].name) {
373
+ heading = nodeList[i];
374
+ break;
375
+ }
376
+ }
377
+ }
378
+ return {
379
+ detected: true,
380
+ type: 'dialog',
381
+ name: heading?.name || null,
382
+ };
383
+ }
384
+ for (const node of nodeList) {
385
+ const inner = findOverlayByCloseButton(node.children);
386
+ if (inner) return inner;
387
+ }
388
+ return null;
389
+ };
390
+
358
391
  export const detectFocusArea = (snapshot: string | null): FocusAreaResult => {
359
- const nodes = parseAriaSnapshot(snapshot);
392
+ const nodes = parseAriaSnapshot(snapshot, true);
360
393
 
361
394
  const findFocusArea = (nodeList: AriaNode[]): FocusAreaResult | null => {
362
395
  for (const node of nodeList) {
@@ -385,7 +418,12 @@ export const detectFocusArea = (snapshot: string | null): FocusAreaResult => {
385
418
  };
386
419
 
387
420
  const result = findFocusArea(nodes);
388
- return result || { detected: false, type: null, name: null };
421
+ if (result) return result;
422
+
423
+ const fallback = findOverlayByCloseButton(nodes);
424
+ if (fallback && fallback.name) return fallback;
425
+
426
+ return { detected: false, type: null, name: null };
389
427
  };
390
428
 
391
429
  export const collectInteractiveNodes = (snapshot: string | null): Array<Record<string, unknown>> => {