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.
- 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/rules/researcher/container-rules.md +15 -0
- package/dist/rules/researcher/section-example.md +12 -0
- 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/cache.js +6 -0
- 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 +30 -103
- 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/explorer.js +12 -1
- 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/rules/researcher/container-rules.md +15 -0
- package/rules/researcher/section-example.md +12 -0
- 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/cache.ts +5 -0
- package/src/ai/researcher/deep-analysis.ts +74 -9
- package/src/ai/researcher/sections.ts +122 -0
- package/src/ai/researcher.ts +31 -105
- 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/explorer.ts +10 -1
- 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
|
|
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
|
-
|
|
151
|
-
|
|
150
|
+
researchText = await this.researchBySections();
|
|
151
|
+
activeConversation = this.provider.startConversation(this.getSystemMessage(), 'researcher');
|
|
152
152
|
}
|
|
153
|
-
|
|
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,
|
|
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
|
|
363
|
-
Identify the
|
|
364
|
-
|
|
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
|
|
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
|
-
-
|
|
375
|
-
- UI map
|
|
376
|
-
-
|
|
377
|
-
-
|
|
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'
|
|
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
|
|
394
|
-
- Never name a section "Focus" or "Focused" —
|
|
395
|
-
-
|
|
396
|
-
- Each section
|
|
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
|
|
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
|
-
|
|
438
|
-
-
|
|
439
|
-
-
|
|
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
|
|
38
|
-
if (
|
|
38
|
+
const toc = tracker.getExperienceTableOfContents(actionResult);
|
|
39
|
+
if (toc.length === 0)
|
|
39
40
|
return '';
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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;
|
package/dist/src/ai/tester.js
CHANGED
|
@@ -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
|
-
|
|
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`
|
package/dist/src/ai/tools.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
114
|
-
if (
|
|
111
|
+
const savedFiles = this.explorBot.agentHistorian().getSavedFiles();
|
|
112
|
+
if (savedFiles.length === 0)
|
|
115
113
|
return;
|
|
116
|
-
const
|
|
117
|
-
|
|
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
|
}
|
package/dist/src/explorbot.js
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.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
|
|
148
|
-
|
|
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() {
|
package/dist/src/explorer.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/dist/src/utils/aria.js
CHANGED
|
@@ -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
|
-
|
|
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);
|