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.
- package/dist/rules/planner/styles/curious.md +18 -5
- package/dist/rules/planner/styles/normal.md +4 -4
- package/dist/rules/planner/styles/psycho.md +14 -11
- package/dist/src/ai/captain/web-mode.js +9 -1
- package/dist/src/ai/historian.js +6 -0
- package/dist/src/ai/pilot.js +23 -2
- package/dist/src/ai/researcher/deep-analysis.js +65 -9
- package/dist/src/ai/researcher/sections.js +103 -0
- package/dist/src/ai/researcher.js +9 -46
- package/dist/src/ai/task-agent.js +7 -27
- package/dist/src/ai/tester.js +19 -4
- package/dist/src/ai/tools.js +27 -2
- package/dist/src/commands/explore-command.js +4 -9
- package/dist/src/experience-tracker.js +126 -1
- package/dist/src/explorbot.js +9 -2
- package/dist/src/utils/aria.js +39 -2
- package/package.json +1 -1
- package/rules/planner/styles/curious.md +18 -5
- package/rules/planner/styles/normal.md +4 -4
- package/rules/planner/styles/psycho.md +14 -11
- package/src/ai/captain/web-mode.ts +9 -1
- package/src/ai/historian.ts +7 -0
- package/src/ai/pilot.ts +23 -3
- package/src/ai/researcher/deep-analysis.ts +74 -9
- package/src/ai/researcher/sections.ts +122 -0
- package/src/ai/researcher.ts +9 -47
- package/src/ai/task-agent.ts +7 -31
- package/src/ai/tester.ts +20 -4
- package/src/ai/tools.ts +33 -1
- package/src/commands/explore-command.ts +4 -9
- package/src/config.ts +1 -0
- package/src/experience-tracker.ts +136 -1
- package/src/explorbot.ts +9 -2
- package/src/utils/aria.ts +40 -2
|
@@ -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
|
+
}
|
package/src/ai/researcher.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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();
|
package/src/ai/task-agent.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import dedent from 'dedent';
|
|
2
2
|
import type { ActionResult } from '../action-result.js';
|
|
3
|
-
import type
|
|
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
|
|
59
|
+
const toc = tracker.getExperienceTableOfContents(actionResult);
|
|
60
|
+
if (toc.length === 0) return '';
|
|
60
61
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
120
|
-
if (
|
|
117
|
+
const savedFiles = this.explorBot.agentHistorian().getSavedFiles();
|
|
118
|
+
if (savedFiles.length === 0) return;
|
|
121
119
|
|
|
122
|
-
const
|
|
123
|
-
|
|
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
|
@@ -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
|
|
186
|
-
|
|
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
|
-
|
|
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>> => {
|