explorbot 0.1.29 → 0.1.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/boat/doc-collector/src/ai/documentarian.ts +37 -13
  2. package/boat/doc-collector/src/ai/tools.ts +60 -20
  3. package/boat/doc-collector/src/cli.ts +3 -0
  4. package/boat/doc-collector/src/config.ts +7 -0
  5. package/boat/doc-collector/src/docbot.ts +23 -5
  6. package/boat/doc-collector/src/docs-renderer.ts +14 -1
  7. package/boat/doc-collector/src/screenshots.ts +126 -0
  8. package/dist/boat/doc-collector/src/ai/documentarian.js +15 -11
  9. package/dist/boat/doc-collector/src/ai/tools.js +53 -20
  10. package/dist/boat/doc-collector/src/cli.js +3 -0
  11. package/dist/boat/doc-collector/src/config.js +3 -0
  12. package/dist/boat/doc-collector/src/docbot.js +19 -4
  13. package/dist/boat/doc-collector/src/docs-renderer.js +12 -1
  14. package/dist/boat/doc-collector/src/screenshots.js +90 -0
  15. package/dist/package.json +2 -2
  16. package/dist/src/action.js +26 -23
  17. package/dist/src/ai/conversation.js +1 -1
  18. package/dist/src/ai/historian/codeceptjs.js +3 -2
  19. package/dist/src/ai/historian/experience.js +48 -6
  20. package/dist/src/ai/historian/playwright.js +2 -1
  21. package/dist/src/ai/historian/utils.js +1 -19
  22. package/dist/src/ai/historian.js +1 -1
  23. package/dist/src/ai/provider.js +3 -2
  24. package/dist/src/ai/quartermaster.js +2 -2
  25. package/dist/src/ai/tester.js +3 -0
  26. package/dist/src/ai/tools.js +0 -1
  27. package/dist/src/experience-tracker.js +1 -1
  28. package/dist/src/explorbot.js +7 -1
  29. package/dist/src/explorer.js +30 -27
  30. package/dist/src/utils/browser-errors.js +5 -0
  31. package/dist/src/utils/page-readiness.js +48 -0
  32. package/dist/src/utils/step-analyzer.js +68 -0
  33. package/package.json +2 -2
  34. package/src/action.ts +24 -26
  35. package/src/ai/conversation.ts +1 -1
  36. package/src/ai/historian/codeceptjs.ts +3 -2
  37. package/src/ai/historian/experience.ts +51 -6
  38. package/src/ai/historian/playwright.ts +2 -1
  39. package/src/ai/historian/utils.ts +1 -21
  40. package/src/ai/historian.ts +1 -1
  41. package/src/ai/provider.ts +3 -2
  42. package/src/ai/quartermaster.ts +2 -2
  43. package/src/ai/tester.ts +3 -0
  44. package/src/ai/tools.ts +0 -1
  45. package/src/config.ts +1 -0
  46. package/src/experience-tracker.ts +1 -1
  47. package/src/explorbot.ts +7 -1
  48. package/src/explorer.ts +28 -27
  49. package/src/utils/browser-errors.ts +6 -0
  50. package/src/utils/page-readiness.ts +59 -0
  51. package/src/utils/step-analyzer.ts +73 -0
@@ -1,18 +1,20 @@
1
1
  import { parseResearchSections } from "../../../../src/ai/researcher/parser.js";
2
- const MAX_PRIMARY_CANDIDATES = 3;
3
- const MAX_INTERACTIONS = 5;
2
+ const DEFAULT_MAX_PRIMARY_CANDIDATES = 3;
3
+ const DEFAULT_MAX_INTERACTIONS = 5;
4
4
  const MAX_LINKS = 15;
5
5
  const DEFAULT_WAIT_MS = 700;
6
6
  const TAB_WAIT_MS = 500;
7
- export async function collectDocInteractions(explorer, state, research) {
7
+ const DEFAULT_DENIED_ACTION_LABELS = ['delete', 'remove', 'destroy', 'archive', 'discard', 'logout', 'sign out', 'signout', 'sign_out', 'erase', 'drop'];
8
+ export async function collectDocInteractions(explorer, state, research, config = {}) {
8
9
  const sections = parseResearchSections(research);
9
10
  const transitions = [];
11
+ const maxInteractions = getPositiveConfigNumber(config.docs?.maxInteractions, DEFAULT_MAX_INTERACTIONS);
10
12
  const tabGroup = findTabGroup(sections);
11
13
  if (tabGroup) {
12
- transitions.push(...(await exploreTabGroup(explorer, tabGroup, state.url)));
14
+ transitions.push(...(await exploreTabGroup(explorer, tabGroup, state.url, maxInteractions)));
13
15
  }
14
- for (const candidate of findActionCandidates(sections)) {
15
- if (transitions.length >= MAX_INTERACTIONS) {
16
+ for (const candidate of findActionCandidates(sections, config)) {
17
+ if (transitions.length >= maxInteractions) {
16
18
  break;
17
19
  }
18
20
  const transition = await executeInteraction(explorer, candidate, state.url, DEFAULT_WAIT_MS);
@@ -23,16 +25,19 @@ export async function collectDocInteractions(explorer, state, research) {
23
25
  }
24
26
  return transitions;
25
27
  }
26
- export function pickDocActionCandidates(research) {
27
- return findActionCandidates(parseResearchSections(research)).map((candidate) => ({
28
+ export function pickDocActionCandidates(research, config = {}) {
29
+ return findActionCandidates(parseResearchSections(research), config).map((candidate) => ({
28
30
  label: candidate.element.name.trim(),
29
31
  role: candidate.role,
30
32
  section: candidate.sectionName,
31
33
  }));
32
34
  }
33
- async function exploreTabGroup(explorer, tabGroup, restoreUrl) {
35
+ async function exploreTabGroup(explorer, tabGroup, restoreUrl, maxInteractions) {
34
36
  const transitions = [];
35
37
  for (const element of tabGroup.elements) {
38
+ if (transitions.length >= maxInteractions) {
39
+ break;
40
+ }
36
41
  const transition = await executeInteraction(explorer, {
37
42
  element,
38
43
  container: tabGroup.container,
@@ -164,21 +169,19 @@ function findTabGroup(sections) {
164
169
  }
165
170
  return null;
166
171
  }
167
- function findActionCandidates(sections) {
172
+ function findActionCandidates(sections, config) {
168
173
  const candidates = [];
169
174
  const seen = new Set();
170
175
  const navigationLabels = collectNavigationLabels(sections);
176
+ const maxPrimaryCandidates = getPositiveConfigNumber(config.docs?.maxPrimaryCandidates, DEFAULT_MAX_PRIMARY_CANDIDATES);
171
177
  for (const section of sections) {
172
178
  const sectionName = section.name.toLowerCase();
173
179
  const container = section.containerCss?.toLowerCase() || '';
174
- if (isOverlaySection(sectionName, container)) {
175
- continue;
176
- }
177
- if (isNavigationSection(sectionName)) {
180
+ if (isIgnoredSection(sectionName, container)) {
178
181
  continue;
179
182
  }
180
183
  for (const element of section.elements) {
181
- const candidate = toInteractionCandidate(element, section.name, section.containerCss, navigationLabels);
184
+ const candidate = toInteractionCandidate(element, section.name, section.containerCss, navigationLabels, config);
182
185
  if (!candidate) {
183
186
  continue;
184
187
  }
@@ -190,9 +193,9 @@ function findActionCandidates(sections) {
190
193
  candidates.push(candidate);
191
194
  }
192
195
  }
193
- return candidates.sort((a, b) => scoreCandidate(b) - scoreCandidate(a)).slice(0, MAX_PRIMARY_CANDIDATES);
196
+ return candidates.sort((a, b) => scoreCandidate(b) - scoreCandidate(a)).slice(0, maxPrimaryCandidates);
194
197
  }
195
- function toInteractionCandidate(element, sectionName, container, navigationLabels) {
198
+ function toInteractionCandidate(element, sectionName, container, navigationLabels, config) {
196
199
  const role = getElementRole(element);
197
200
  if (role !== 'link' && role !== 'button' && role !== 'tab') {
198
201
  return null;
@@ -200,7 +203,10 @@ function toInteractionCandidate(element, sectionName, container, navigationLabel
200
203
  if (!hasUsableName(element)) {
201
204
  return null;
202
205
  }
203
- if (isShellLocator(element.css) || isShellLocator(element.xpath) || isShellLocator(container)) {
206
+ if (isPageShellContainer(element.css) || isPageShellContainer(element.xpath)) {
207
+ return null;
208
+ }
209
+ if (isDestructiveAction(element, config)) {
204
210
  return null;
205
211
  }
206
212
  if (role === 'link' && navigationLabels.has(normalizeCandidateLabel(element.name))) {
@@ -340,6 +346,18 @@ function hasUsableName(element) {
340
346
  function isNavigationSection(sectionName) {
341
347
  return /(navigation|menu|header|footer|breadcrumb)/i.test(sectionName);
342
348
  }
349
+ function isContentControlSection(sectionName) {
350
+ return /(content|control|filter|toolbar|action|list|data)/i.test(sectionName);
351
+ }
352
+ function isIgnoredSection(sectionName, container) {
353
+ if (isOverlaySection(sectionName, container)) {
354
+ return true;
355
+ }
356
+ if (isContentControlSection(sectionName)) {
357
+ return false;
358
+ }
359
+ return isNavigationSection(sectionName) || isPageShellContainer(container);
360
+ }
343
361
  function isOverlaySection(sectionName, container) {
344
362
  return /(overlay|modal|popup|dialog)/i.test(sectionName) || /(overlay|modal|popup|dialog)/i.test(container);
345
363
  }
@@ -365,11 +383,11 @@ function scoreCandidate(candidate) {
365
383
  }
366
384
  return score;
367
385
  }
368
- function isShellLocator(locator) {
386
+ function isPageShellContainer(locator) {
369
387
  if (!locator) {
370
388
  return false;
371
389
  }
372
- return /(nav\[role="navigation"\]|header|menu|breadcrumb|footer)/i.test(locator);
390
+ return /(^|[\s>+~,.#\[])(nav|navigation|mainnav|header|menu|breadcrumb|footer)([\s>+~,.#\]_-]|$)/i.test(locator);
373
391
  }
374
392
  function collectNavigationLabels(sections) {
375
393
  const labels = new Set();
@@ -390,6 +408,21 @@ function collectNavigationLabels(sections) {
390
408
  function normalizeCandidateLabel(label) {
391
409
  return label.trim().toLowerCase();
392
410
  }
411
+ function isDestructiveAction(element, config) {
412
+ const label = normalizeCandidateLabel(element.name);
413
+ const deniedLabels = config.docs?.deniedActionLabels || DEFAULT_DENIED_ACTION_LABELS;
414
+ if (deniedLabels.some((denied) => label.includes(normalizeCandidateLabel(denied)))) {
415
+ return true;
416
+ }
417
+ const locator = `${element.css || ''} ${element.xpath || ''}`.toLowerCase();
418
+ return deniedLabels.some((denied) => locator.includes(normalizeCandidateLabel(denied)));
419
+ }
420
+ function getPositiveConfigNumber(value, fallback) {
421
+ if (!value || value <= 0) {
422
+ return fallback;
423
+ }
424
+ return value;
425
+ }
393
426
  function limitInlineText(text, maxLength) {
394
427
  const normalized = text.replace(/\s+/g, ' ').trim();
395
428
  if (normalized.length <= maxLength) {
@@ -89,6 +89,9 @@ export function createDocsCommands(name = 'docs') {
89
89
  includePaths: [],
90
90
  excludePaths: [],
91
91
  deniedPathSegments: ['callback', 'callbacks', 'logout', 'signout', 'sign_out', 'destroy', 'delete', 'remove'],
92
+ deniedActionLabels: ['delete', 'remove', 'destroy', 'archive', 'discard', 'logout', 'sign out', 'signout', 'sign_out', 'erase', 'drop'],
93
+ maxPrimaryCandidates: 3,
94
+ maxInteractions: 5,
92
95
  minCanActions: 1,
93
96
  minInteractiveElements: 3,
94
97
  // prompt: 'Add domain-specific documentation guidance here',
@@ -110,6 +110,9 @@ class DocbotConfigParser {
110
110
  includePaths: [],
111
111
  excludePaths: [],
112
112
  deniedPathSegments: ['callback', 'callbacks', 'logout', 'signout', 'sign_out', 'destroy', 'delete', 'remove'],
113
+ deniedActionLabels: ['delete', 'remove', 'destroy', 'archive', 'discard', 'logout', 'sign out', 'signout', 'sign_out', 'erase', 'drop'],
114
+ maxPrimaryCandidates: 3,
115
+ maxInteractions: 5,
113
116
  minCanActions: 1,
114
117
  minInteractiveElements: 3,
115
118
  },
@@ -2,13 +2,14 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { ExplorBot } from "../../../src/explorbot.js";
4
4
  import { normalizeUrl } from "../../../src/state-manager.js";
5
- import { sanitizeFilename } from "../../../src/utils/strings.js";
6
5
  import { tag } from "../../../src/utils/logger.js";
6
+ import { sanitizeFilename } from "../../../src/utils/strings.js";
7
7
  import { Documentarian } from "./ai/documentarian.js";
8
8
  import { DocbotConfigParser } from "./config.js";
9
9
  import { renderPageDocumentation, renderSpecIndex } from "./docs-renderer.js";
10
10
  import { getDocPageKey, shouldCrawlDocPath } from "./path-filter.js";
11
11
  import { extractResearchNavigationTargets } from "./research-navigation.js";
12
+ import { captureDocumentationScreenshots } from "./screenshots.js";
12
13
  class DocBot {
13
14
  explorBot;
14
15
  configParser;
@@ -105,7 +106,7 @@ class DocBot {
105
106
  documented.add(pageKey);
106
107
  continue;
107
108
  }
108
- const filePath = this.savePageDocumentation(state, documentation);
109
+ const filePath = await this.savePageDocumentation(state, documentation, research);
109
110
  pages.push({
110
111
  url: state.url,
111
112
  title: state.title || '',
@@ -347,11 +348,22 @@ class DocBot {
347
348
  const matches = [...research.matchAll(/\((\d+) elements?\)/g)];
348
349
  return matches.reduce((sum, match) => sum + Number.parseInt(match[1], 10), 0);
349
350
  }
350
- savePageDocumentation(state, documentation) {
351
+ async savePageDocumentation(state, documentation, research) {
351
352
  const pagePath = this.getPageFilePath(state.url);
352
- writeFileSync(pagePath, renderPageDocumentation(state, documentation), 'utf8');
353
+ const screenshots = await this.captureScreenshots(state, research, pagePath);
354
+ writeFileSync(pagePath, renderPageDocumentation(state, documentation, screenshots), 'utf8');
353
355
  return pagePath;
354
356
  }
357
+ async captureScreenshots(state, research, pagePath) {
358
+ if (!this.shouldUseScreenshots()) {
359
+ return [];
360
+ }
361
+ return captureDocumentationScreenshots(this.explorBot.getExplorer(), state, research, {
362
+ pageFilePath: pagePath,
363
+ screenshotsDir: this.getScreenshotsDir(),
364
+ config: this.config,
365
+ });
366
+ }
355
367
  saveIndex(startPath, pages, skipped, maxPages) {
356
368
  const indexPath = path.join(this.configParser.getOutputDir(), 'spec.md');
357
369
  writeFileSync(indexPath, renderSpecIndex(this.configParser.getOutputDir(), startPath, pages, skipped, maxPages), 'utf8');
@@ -360,6 +372,9 @@ class DocBot {
360
372
  getPagesDir() {
361
373
  return path.join(this.configParser.getOutputDir(), 'pages');
362
374
  }
375
+ getScreenshotsDir() {
376
+ return path.join(this.configParser.getOutputDir(), 'screenshots');
377
+ }
363
378
  getPageFilePath(pageUrl) {
364
379
  const normalized = normalizeUrl(pageUrl || '/');
365
380
  const baseName = sanitizeFilename(normalized || 'root');
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path';
2
- function renderPageDocumentation(state, documentation) {
2
+ function renderPageDocumentation(state, documentation, screenshots = []) {
3
3
  const lines = [];
4
4
  lines.push(`# ${state.url}`);
5
5
  lines.push('');
@@ -11,6 +11,17 @@ function renderPageDocumentation(state, documentation) {
11
11
  lines.push('');
12
12
  lines.push(ensureSentence(documentation.summary));
13
13
  lines.push('');
14
+ if (screenshots.length > 0) {
15
+ lines.push('## Screenshots');
16
+ lines.push('');
17
+ for (const screenshot of screenshots) {
18
+ lines.push(`![${normalizeInlineText(screenshot.title)}](${screenshot.relativePath})`);
19
+ if (screenshot.selector) {
20
+ lines.push(`Section: \`${screenshot.selector}\``);
21
+ }
22
+ lines.push('');
23
+ }
24
+ }
14
25
  const interactions = documentation.interactions;
15
26
  if (interactions && interactions.length > 0) {
16
27
  lines.push('## State Transitions');
@@ -0,0 +1,90 @@
1
+ import { mkdirSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parseResearchSections } from "../../../src/ai/researcher/parser.js";
4
+ import { safeFilename, sanitizeFilename } from "../../../src/utils/strings.js";
5
+ const DEFAULT_MAX_SECTION_SCREENSHOTS = 8;
6
+ export async function captureDocumentationScreenshots(explorer, state, research, options) {
7
+ const page = explorer.playwrightHelper?.page;
8
+ if (!page) {
9
+ return [];
10
+ }
11
+ mkdirSync(options.screenshotsDir, { recursive: true });
12
+ const screenshots = [];
13
+ const pageName = sanitizeFilename(state.url || 'page') || 'page';
14
+ const fullPage = await captureFullPageScreenshot(page, pageName, options);
15
+ if (fullPage) {
16
+ screenshots.push(fullPage);
17
+ }
18
+ const maxSections = getMaxSectionScreenshots(options.config);
19
+ for (const section of getScreenshotSections(research).slice(0, maxSections)) {
20
+ const screenshot = await captureSectionScreenshot(page, pageName, section, options);
21
+ if (!screenshot) {
22
+ continue;
23
+ }
24
+ screenshots.push(screenshot);
25
+ }
26
+ return screenshots;
27
+ }
28
+ export function getScreenshotSections(research) {
29
+ const sections = [];
30
+ const seen = new Set();
31
+ for (const section of parseResearchSections(research)) {
32
+ if (!section.containerCss) {
33
+ continue;
34
+ }
35
+ if (section.elements.length === 0) {
36
+ continue;
37
+ }
38
+ if (seen.has(section.containerCss)) {
39
+ continue;
40
+ }
41
+ seen.add(section.containerCss);
42
+ sections.push({
43
+ title: section.name,
44
+ selector: section.containerCss,
45
+ });
46
+ }
47
+ return sections;
48
+ }
49
+ async function captureFullPageScreenshot(page, pageName, options) {
50
+ const filePath = path.join(options.screenshotsDir, safeFilename(`${pageName}_page`, '.png'));
51
+ try {
52
+ await page.screenshot({ path: filePath, fullPage: true });
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ return {
58
+ title: 'Page screenshot',
59
+ path: filePath,
60
+ relativePath: toMarkdownPath(options.pageFilePath, filePath),
61
+ kind: 'page',
62
+ };
63
+ }
64
+ async function captureSectionScreenshot(page, pageName, section, options) {
65
+ const sectionName = sanitizeFilename(section.title) || 'section';
66
+ const filePath = path.join(options.screenshotsDir, safeFilename(`${pageName}_${sectionName}`, '.png'));
67
+ try {
68
+ await page.locator(section.selector).first().screenshot({ path: filePath });
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ return {
74
+ title: section.title,
75
+ path: filePath,
76
+ relativePath: toMarkdownPath(options.pageFilePath, filePath),
77
+ kind: 'section',
78
+ selector: section.selector,
79
+ };
80
+ }
81
+ function getMaxSectionScreenshots(config) {
82
+ const configured = config.docs?.maxSectionScreenshots;
83
+ if (configured && configured > 0) {
84
+ return configured;
85
+ }
86
+ return DEFAULT_MAX_SECTION_SCREENSHOTS;
87
+ }
88
+ function toMarkdownPath(pageFilePath, assetPath) {
89
+ return path.relative(path.dirname(pageFilePath), assetPath).replaceAll('\\', '/');
90
+ }
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.1.29",
3
+ "version": "0.1.31",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -74,7 +74,7 @@
74
74
  "@faker-js/faker": "^10.4.0",
75
75
  "@inkjs/ui": "^2.0.0",
76
76
  "@langfuse/otel": "^4.5.1",
77
- "@openrouter/ai-sdk-provider": "^2.3.3",
77
+ "@openrouter/ai-sdk-provider": "^3.0.0",
78
78
  "@opentelemetry/api": "^1.9.0",
79
79
  "@opentelemetry/auto-instrumentations-node": "^0.67.3",
80
80
  "@opentelemetry/instrumentation": "^0.208.0",
@@ -10,11 +10,13 @@ import { ActionResult } from './action-result.js';
10
10
  import { clearActivity, setActivity } from "./activity.js";
11
11
  import { ConfigParser, outputPath } from './config.js';
12
12
  import { Observability } from "./observability.js";
13
+ import { isFatalBrowserError, isNavigationTransitionError } from "./utils/browser-errors.js";
13
14
  import { htmlCombinedSnapshot, minifyHtml } from './utils/html.js';
14
15
  import { createDebug, setStepSpanParent, tag } from './utils/logger.js';
16
+ import { waitForPageReadiness } from "./utils/page-readiness.js";
15
17
  import { safeFilename } from "./utils/strings.js";
16
- import { isFatalBrowserError } from "./utils/browser-errors.js";
17
18
  const debugLog = createDebug('explorbot:action');
19
+ const CAPTURE_NAVIGATION_TRANSITION_ATTEMPTS = 3;
18
20
  class Action {
19
21
  actor;
20
22
  stateManager;
@@ -63,17 +65,24 @@ class Action {
63
65
  const timestamp = Date.now();
64
66
  const page = this.playwrightHelper.page;
65
67
  const frame = this.playwrightHelper.frame;
66
- await page?.waitForLoadState('domcontentloaded', { timeout: 10000 })?.catch(() => { });
67
- await waitForUsablePageDom(page);
68
+ await this.waitForPageReadiness(page);
68
69
  const grabAll = () => Promise.all([captureHtml(page, frame, this.actor), captureTitle(page, this.actor), this.captureBrowserLogs()]);
69
- const [html, title, browserLogs] = await grabAll().catch(async (err) => {
70
- const msg = err instanceof Error ? err.message : String(err);
71
- if (!/navigating and changing the content/i.test(msg))
72
- throw err;
73
- await page?.waitForLoadState('domcontentloaded', { timeout: 10000 })?.catch(() => { });
74
- await waitForUsablePageDom(page);
75
- return grabAll();
76
- });
70
+ let html = '';
71
+ let title = '';
72
+ let browserLogs = [];
73
+ for (let attempt = 1; attempt <= CAPTURE_NAVIGATION_TRANSITION_ATTEMPTS; attempt++) {
74
+ try {
75
+ [html, title, browserLogs] = await grabAll();
76
+ break;
77
+ }
78
+ catch (err) {
79
+ if (!isNavigationTransitionError(err))
80
+ throw err;
81
+ if (attempt === CAPTURE_NAVIGATION_TRANSITION_ATTEMPTS)
82
+ throw err;
83
+ await this.waitForPageReadiness(page);
84
+ }
85
+ }
77
86
  const url = page?.url() || (await this.actor.grabCurrentUrl?.());
78
87
  let screenshotFile = undefined;
79
88
  const statesDir = outputPath('states');
@@ -399,6 +408,12 @@ class Action {
399
408
  getActionResult() {
400
409
  return this.actionResult;
401
410
  }
411
+ async waitForPageReadiness(page) {
412
+ await waitForPageReadiness(page, {
413
+ timeout: this.config.playwright.waitForTimeout,
414
+ spinnerSelectors: this.config.playwright.spinnerSelectors,
415
+ });
416
+ }
402
417
  }
403
418
  export default Action;
404
419
  function errorToString(error) {
@@ -407,18 +422,6 @@ function errorToString(error) {
407
422
  }
408
423
  return error.message || error.toString();
409
424
  }
410
- async function waitForUsablePageDom(page) {
411
- if (!page?.waitForFunction)
412
- return;
413
- await page
414
- .waitForFunction(() => {
415
- const body = document.body;
416
- if (!body)
417
- return false;
418
- return body.children.length > 0 || body.textContent?.trim().length > 0;
419
- }, undefined, { timeout: 5000 })
420
- .catch(() => { });
421
- }
422
425
  async function captureHtml(page, frame, actor) {
423
426
  if (frame?.content)
424
427
  return frame.content();
@@ -34,7 +34,7 @@ export class Conversation {
34
34
  const imageData = image.startsWith('data:') ? image : `data:image/png;base64,${image}`;
35
35
  this.messages.push({
36
36
  role: 'user',
37
- content: [{ type: 'image', image: imageData }],
37
+ content: [{ type: 'file', mediaType: 'image/png', data: imageData }],
38
38
  });
39
39
  }
40
40
  addAssistantText(text) {
@@ -6,8 +6,9 @@ import { KnowledgeTracker } from "../../knowledge-tracker.js";
6
6
  import { tag } from "../../utils/logger.js";
7
7
  import { relativeToCwd } from "../../utils/next-steps.js";
8
8
  import { safeFilename } from "../../utils/strings.js";
9
- import { ASSERTION_TOOLS, CODECEPT_TOOLS } from "../tools.js";
10
- import { escapeString, getExecutionLabel, isNonReusableCode, stripComments } from "./utils.js";
9
+ import { CODECEPT_TOOLS, isNonReusableCode, stripComments } from "../../utils/step-analyzer.js";
10
+ import { ASSERTION_TOOLS } from "../tools.js";
11
+ import { escapeString, getExecutionLabel } from "./utils.js";
11
12
  export function WithCodeceptJS(Base) {
12
13
  return class extends Base {
13
14
  toCode(conversation, scenario) {
@@ -3,10 +3,10 @@ import { z } from 'zod';
3
3
  import { ActionResult } from "../../action-result.js";
4
4
  import { Test } from "../../test-plan.js";
5
5
  import { tag } from "../../utils/logger.js";
6
+ import { isCodeceptToolName, isNonReusableCode, mergeUniqueStepsByCode, stripComments, toReusableSessionStep } from "../../utils/step-analyzer.js";
6
7
  import { extractStatePath } from "../../utils/url-matcher.js";
7
- import { CODECEPT_TOOLS } from "../tools.js";
8
8
  import { debugLog } from "./mixin.js";
9
- import { getExecutionLabel, isNonReusableCode, stripComments } from "./utils.js";
9
+ import { getExecutionLabel } from "./utils.js";
10
10
  export function WithExperience(Base) {
11
11
  return class extends Base {
12
12
  async saveSession(task, initialState, conversation) {
@@ -16,11 +16,17 @@ export function WithExperience(Base) {
16
16
  if (task instanceof Test) {
17
17
  task.generatedCode = this.isPlaywrightFramework() ? await this.toPlaywrightCode(conversation, task.description) : this.toCode(conversation, task.description);
18
18
  }
19
- const steps = await this.extractSteps(toolExecutions);
19
+ const conversationSteps = await this.extractSteps(toolExecutions);
20
+ const taskSteps = this.extractPassedCodeceptSteps(task);
21
+ const steps = mergeUniqueStepsByCode(conversationSteps, taskSteps);
20
22
  const skipExperience = result === 'failed' || (task instanceof Test && (task.hasFailed || task.isSkipped));
21
23
  if (!skipExperience) {
24
+ const hasExistingFlow = this.hasRelevantFlowExperience(initialState);
22
25
  await this.detectRetryPatterns(toolExecutions, initialState);
23
- const body = await this.curateFlow(steps, task, initialState);
26
+ let body = await this.curateFlow(steps, task, initialState);
27
+ if (!body.trim() && !hasExistingFlow) {
28
+ body = this.renderFlowFromSuccessfulSteps(steps, task);
29
+ }
24
30
  if (body.trim()) {
25
31
  const relatedUrls = this.extractVisitedUrls(toolExecutions, initialState.url || '');
26
32
  this.experienceTracker.writeFlow(initialState, body, relatedUrls);
@@ -46,7 +52,7 @@ export function WithExperience(Base) {
46
52
  async extractSteps(toolExecutions) {
47
53
  const stepsWithDiffs = [];
48
54
  for (const exec of toolExecutions) {
49
- if (!CODECEPT_TOOLS.includes(exec.toolName))
55
+ if (!isCodeceptToolName(exec.toolName))
50
56
  continue;
51
57
  if (!exec.output?.code)
52
58
  continue;
@@ -65,6 +71,18 @@ export function WithExperience(Base) {
65
71
  await this.analyzeDiscoveries(stepsWithDiffs);
66
72
  return stepsWithDiffs.map((s) => s.step);
67
73
  }
74
+ hasRelevantFlowExperience(state) {
75
+ return this.experienceTracker.getRelevantExperience(state).some((experience) => experience.content.includes('## FLOW:'));
76
+ }
77
+ extractPassedCodeceptSteps(task) {
78
+ const steps = [];
79
+ for (const step of Object.values(task.steps)) {
80
+ const sessionStep = toReusableSessionStep(step);
81
+ if (sessionStep)
82
+ steps.push(sessionStep);
83
+ }
84
+ return steps;
85
+ }
68
86
  async curateFlow(steps, task, initialState) {
69
87
  if (steps.length === 0)
70
88
  return '';
@@ -167,13 +185,37 @@ export function WithExperience(Base) {
167
185
  return '';
168
186
  }
169
187
  }
188
+ renderFlowFromSuccessfulSteps(steps, task) {
189
+ if (steps.length === 0)
190
+ return '';
191
+ const title = task.description.charAt(0).toLowerCase() + task.description.slice(1);
192
+ const blocks = steps
193
+ .filter((step) => step.code)
194
+ .map((step) => {
195
+ const lines = [`* ${step.message}`];
196
+ lines.push('');
197
+ lines.push('```js');
198
+ lines.push(stripComments(step.code || ''));
199
+ lines.push('```');
200
+ if (step.discovery) {
201
+ lines.push('');
202
+ for (const discovery of step.discovery.split('\n').filter((line) => line.trim())) {
203
+ lines.push(`> ${discovery.trim()}`);
204
+ }
205
+ }
206
+ return lines.join('\n');
207
+ });
208
+ if (blocks.length === 0)
209
+ return '';
210
+ return `## FLOW: ${title}\n\n${blocks.join('\n\n')}\n\n---\n`;
211
+ }
170
212
  async detectRetryPatterns(toolExecutions, initialState) {
171
213
  if (!this.experienceTracker || !this.stateManager)
172
214
  return;
173
215
  const failedByTool = new Map();
174
216
  const candidates = [];
175
217
  for (const exec of toolExecutions) {
176
- if (!CODECEPT_TOOLS.includes(exec.toolName))
218
+ if (!isCodeceptToolName(exec.toolName))
177
219
  continue;
178
220
  if (!exec.output?.code)
179
221
  continue;
@@ -7,7 +7,8 @@ import { renderAssertion, renderCall } from "../../playwright-recorder.js";
7
7
  import { tag } from "../../utils/logger.js";
8
8
  import { relativeToCwd } from "../../utils/next-steps.js";
9
9
  import { safeFilename } from "../../utils/strings.js";
10
- import { ASSERTION_TOOLS, CODECEPT_TOOLS } from "../tools.js";
10
+ import { CODECEPT_TOOLS } from "../../utils/step-analyzer.js";
11
+ import { ASSERTION_TOOLS } from "../tools.js";
11
12
  import { escapeString, getExecutionLabel } from "./utils.js";
12
13
  const PLAYWRIGHT_EMITTED_TOOLS = [...CODECEPT_TOOLS, ...ASSERTION_TOOLS];
13
14
  export function WithPlaywright(Base) {
@@ -1,25 +1,7 @@
1
- import { isDynamicId } from "../../utils/xpath.js";
2
- export function isNonReusableCode(code) {
3
- if (/\bI\.clickXY\s*\(/.test(code))
4
- return true;
5
- for (const m of code.matchAll(/#([A-Za-z_][\w-]*)/g)) {
6
- if (isDynamicId(m[1]))
7
- return true;
8
- }
9
- return false;
10
- }
1
+ export { isNonReusableCode, stripComments } from "../../utils/step-analyzer.js";
11
2
  export function escapeString(str) {
12
3
  return str.replace(/'/g, "\\'").replace(/\n/g, ' ');
13
4
  }
14
- export function stripComments(code) {
15
- return code
16
- .split('\n')
17
- .filter((line) => {
18
- const trimmed = line.trim();
19
- return trimmed && !trimmed.startsWith('//') && !trimmed.startsWith('/*') && !trimmed.startsWith('*');
20
- })
21
- .join('\n');
22
- }
23
5
  export function getExecutionLabel(exec, fallback) {
24
6
  return exec.input?.explanation || exec.input?.assertion || exec.input?.note || fallback || '';
25
7
  }
@@ -6,7 +6,7 @@ import { WithCodeceptJS } from "./historian/codeceptjs.js";
6
6
  import { WithExperience } from "./historian/experience.js";
7
7
  import { WithPlaywright } from "./historian/playwright.js";
8
8
  import { WithScreencast } from "./historian/screencast.js";
9
- export { isNonReusableCode } from "./historian/utils.js";
9
+ export { isNonReusableCode } from "../utils/step-analyzer.js";
10
10
  const HistorianBase = WithScreencast(WithPlaywright(WithCodeceptJS(WithExperience(Object))));
11
11
  export class Historian extends HistorianBase {
12
12
  constructor(provider, experienceTracker, reporter, stateManager, config, playwright) {
@@ -578,8 +578,9 @@ export class Provider {
578
578
  text: prompt,
579
579
  },
580
580
  {
581
- type: 'image',
582
- image: imageData,
581
+ type: 'file',
582
+ mediaType: 'image/png',
583
+ data: imageData,
583
584
  },
584
585
  ],
585
586
  },
@@ -3,7 +3,7 @@ import { join } from 'node:path';
3
3
  import { z } from 'zod';
4
4
  import { ConfigParser } from "../config.js";
5
5
  import { createDebug, tag } from "../utils/logger.js";
6
- import { CODECEPT_TOOLS } from "./tools.js";
6
+ import { isCodeceptToolName } from "../utils/step-analyzer.js";
7
7
  const debugLog = createDebug('explorbot:quartermaster');
8
8
  export class Quartermaster {
9
9
  provider;
@@ -88,7 +88,7 @@ export class Quartermaster {
88
88
  const stateHash = initialState.getStateHash();
89
89
  const pageAnalysis = this.pageAnalyses.get(stateHash);
90
90
  const toolExecutions = conversation.getToolExecutions();
91
- const codeceptExecutions = toolExecutions.filter((e) => CODECEPT_TOOLS.includes(e.toolName));
91
+ const codeceptExecutions = toolExecutions.filter((e) => isCodeceptToolName(e.toolName));
92
92
  if (codeceptExecutions.length === 0 && !pageAnalysis?.axeViolations.length) {
93
93
  debugLog('No interactions or violations to analyze');
94
94
  return null;