explorbot 0.1.28 → 0.1.30

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 (68) hide show
  1. package/README.md +83 -245
  2. package/bin/explorbot-cli.ts +1 -0
  3. package/boat/doc-collector/src/ai/documentarian.ts +37 -13
  4. package/boat/doc-collector/src/ai/tools.ts +60 -20
  5. package/boat/doc-collector/src/cli.ts +3 -0
  6. package/boat/doc-collector/src/config.ts +7 -0
  7. package/boat/doc-collector/src/docbot.ts +23 -5
  8. package/boat/doc-collector/src/docs-renderer.ts +14 -1
  9. package/boat/doc-collector/src/screenshots.ts +126 -0
  10. package/dist/bin/explorbot-cli.js +1 -0
  11. package/dist/boat/doc-collector/src/ai/documentarian.js +15 -11
  12. package/dist/boat/doc-collector/src/ai/tools.js +53 -20
  13. package/dist/boat/doc-collector/src/cli.js +3 -0
  14. package/dist/boat/doc-collector/src/config.js +3 -0
  15. package/dist/boat/doc-collector/src/docbot.js +19 -4
  16. package/dist/boat/doc-collector/src/docs-renderer.js +12 -1
  17. package/dist/boat/doc-collector/src/screenshots.js +90 -0
  18. package/dist/package.json +8 -6
  19. package/dist/rules/navigator/verification-actions.md +2 -0
  20. package/dist/src/action.js +26 -23
  21. package/dist/src/ai/fisherman.js +14 -3
  22. package/dist/src/ai/historian/codeceptjs.js +3 -2
  23. package/dist/src/ai/historian/experience.js +48 -6
  24. package/dist/src/ai/historian/playwright.js +2 -1
  25. package/dist/src/ai/historian/utils.js +1 -19
  26. package/dist/src/ai/historian.js +1 -1
  27. package/dist/src/ai/pilot.js +19 -4
  28. package/dist/src/ai/planner.js +16 -5
  29. package/dist/src/ai/provider.js +53 -18
  30. package/dist/src/ai/quartermaster.js +2 -2
  31. package/dist/src/ai/researcher.js +7 -1
  32. package/dist/src/ai/rules.js +44 -0
  33. package/dist/src/ai/tester.js +73 -7
  34. package/dist/src/ai/tools.js +66 -1
  35. package/dist/src/experience-tracker.js +1 -1
  36. package/dist/src/explorbot.js +14 -3
  37. package/dist/src/explorer.js +30 -27
  38. package/dist/src/stats.js +16 -0
  39. package/dist/src/utils/aria.js +66 -6
  40. package/dist/src/utils/browser-errors.js +5 -0
  41. package/dist/src/utils/page-readiness.js +48 -0
  42. package/dist/src/utils/step-analyzer.js +68 -0
  43. package/package.json +8 -6
  44. package/rules/navigator/verification-actions.md +2 -0
  45. package/src/action.ts +24 -26
  46. package/src/ai/fisherman.ts +14 -3
  47. package/src/ai/historian/codeceptjs.ts +3 -2
  48. package/src/ai/historian/experience.ts +51 -6
  49. package/src/ai/historian/playwright.ts +2 -1
  50. package/src/ai/historian/utils.ts +1 -21
  51. package/src/ai/historian.ts +1 -1
  52. package/src/ai/pilot.ts +19 -4
  53. package/src/ai/planner.ts +16 -5
  54. package/src/ai/provider.ts +51 -19
  55. package/src/ai/quartermaster.ts +2 -2
  56. package/src/ai/researcher.ts +8 -1
  57. package/src/ai/rules.ts +46 -0
  58. package/src/ai/tester.ts +77 -7
  59. package/src/ai/tools.ts +79 -1
  60. package/src/config.ts +2 -0
  61. package/src/experience-tracker.ts +1 -1
  62. package/src/explorbot.ts +13 -3
  63. package/src/explorer.ts +28 -27
  64. package/src/stats.ts +18 -0
  65. package/src/utils/aria.ts +63 -6
  66. package/src/utils/browser-errors.ts +6 -0
  67. package/src/utils/page-readiness.ts +59 -0
  68. package/src/utils/step-analyzer.ts +73 -0
@@ -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.28",
3
+ "version": "0.1.30",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -65,9 +65,10 @@
65
65
  },
66
66
  "author": "",
67
67
  "dependencies": {
68
- "@ai-sdk/anthropic": "^3.0",
69
- "@ai-sdk/groq": "^3.0",
70
- "@ai-sdk/openai": "^3.0",
68
+ "@ai-sdk/anthropic": "^4.0",
69
+ "@ai-sdk/groq": "^4.0",
70
+ "@ai-sdk/openai": "^4.0",
71
+ "@ai-sdk/otel": "^1.0.2",
71
72
  "@axe-core/playwright": "^4.11.0",
72
73
  "@codeceptjs/reflection": "^0.5.2",
73
74
  "@faker-js/faker": "^10.4.0",
@@ -82,8 +83,8 @@
82
83
  "@opentelemetry/sdk-trace-base": "^2.2.0",
83
84
  "@opentelemetry/semantic-conventions": "^1.38.0",
84
85
  "@scalar/openapi-parser": "^0.25.6",
85
- "@testomatio/reporter": "^2.8.4",
86
- "ai": "^6.0.6",
86
+ "@testomatio/reporter": "^2.9.1",
87
+ "ai": "^7.0.2",
87
88
  "axe-core": "^4.11.1",
88
89
  "bash-tool": "^1.3.15",
89
90
  "cli-highlight": "^2.1.11",
@@ -110,6 +111,7 @@
110
111
  "parse5": "^8.0.0",
111
112
  "playwright": "^1.60",
112
113
  "react": "^19.1.1",
114
+ "sambanova-ai-provider": "^1.2.2",
113
115
  "strip-ansi": "^7.1.2",
114
116
  "turndown": "^7.2.1",
115
117
  "unique-names-generator": "^4.7.1",
@@ -113,6 +113,8 @@ For input field values, ALWAYS use I.seeInField() — never check value via CSS
113
113
  Prefer text locators (label, name, placeholder) for form fields: I.seeInField('Search', 'value') over I.seeInField('input[name="search"]', 'value').
114
114
  Only use locators that exist in the provided HTML or ARIA snapshot.
115
115
  Verify exact conditions, not approximate matches.
116
+ When the claim contains a concrete quoted value, generated assertion code MUST include that whole value. Do not shorten names, IDs, titles, emails, URLs, or other user-created values.
117
+ For exact visible text, prefer a text assertion scoped to a specific container or an ARIA locator with the complete text. Partial text is not valid evidence for a claim about the full value.
116
118
  NEVER use `:has-text(...)` inside a seeElement/dontSeeElement locator. Checking text inside an element is the job of I.see(text, context) — the `:has-text()` form duplicates that capability with a fragile selector.
117
119
  NEVER emit two assertions that check the same fact with different shapes. `I.see(text, locator)` and `I.seeElement("<locator>:has-text('text')")` verify the same thing — pick one (prefer I.see). One claim, one assertion.
118
120
  </verification_rules>
@@ -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();
@@ -4,6 +4,7 @@ import { createDebug, tag } from "../utils/logger.js";
4
4
  const debugLog = createDebug('explorbot:fisherman');
5
5
  import { loop } from "../utils/loop.js";
6
6
  import { createFishermanTools } from "./fisherman-tools.js";
7
+ import { dataProtectionRules } from "./rules.js";
7
8
  const MAX_ITERATIONS = 15;
8
9
  const MAX_TOOL_ROUNDTRIPS = 5;
9
10
  export class Fisherman {
@@ -65,7 +66,7 @@ export class Fisherman {
65
66
  spec: this.spec,
66
67
  baseEndpoint: this.baseEndpoint,
67
68
  });
68
- const conversation = this.provider.startConversation(this.buildSystemPrompt(endpointList, scopeUrl), 'fisherman');
69
+ const conversation = this.provider.startConversation(this.buildSystemPrompt(endpointList, Object.keys(tools), scopeUrl), 'fisherman');
69
70
  conversation.addUserText(this.buildTaskPrompt(instructions));
70
71
  await loop(async ({ stop, iteration }) => {
71
72
  debugLog(`iteration ${iteration}`);
@@ -149,7 +150,7 @@ export class Fisherman {
149
150
  }
150
151
  return lines.join('\n');
151
152
  }
152
- buildSystemPrompt(endpointList, scopeUrl) {
153
+ buildSystemPrompt(endpointList, toolNames, scopeUrl) {
153
154
  const scopeBlock = scopeUrl ? `\n\nSCOPE: You are operating within ${scopeUrl}.\nAll created items must belong to this scope.` : '';
154
155
  return dedent `
155
156
  You are Fisherman — a data preparation agent. You create test data by making API requests.
@@ -158,6 +159,11 @@ export class Fisherman {
158
159
  ${endpointList}
159
160
  ${scopeBlock}
160
161
 
162
+ AVAILABLE TOOLS:
163
+ ${toolNames.join(', ')}.
164
+ Use tool names exactly as listed. Do not invent aliases, combined names, or names with channel markers such as "commentary".
165
+ Match each tool input schema exactly. Do not invent parameter names or pass extra fields.
166
+
161
167
  WORKFLOW:
162
168
  1. Call getEndpointSpec to see the request body example for the endpoint
163
169
  2. Make requests — the response automatically extracts IDs, names, and status fields
@@ -169,6 +175,8 @@ export class Fisherman {
169
175
  - Chain requests logically — create parent resources before children
170
176
  - If a request fails, try once more with adjusted data before reporting failure
171
177
  - Use realistic but unique data for each item (vary names, titles)
178
+
179
+ ${dataProtectionRules}
172
180
  `;
173
181
  }
174
182
  buildTaskPrompt(instructions) {
@@ -177,7 +185,10 @@ export class Fisherman {
177
185
 
178
186
  ${instructions}
179
187
 
180
- Execute the necessary API requests to create this data. When done, call finish with the summary.
188
+ ${dataProtectionRules}
189
+
190
+ If data preparation is allowed by these rules, execute the necessary API requests to create this data.
191
+ When done, call finish with the summary. If data preparation is forbidden, call stop with the reason.
181
192
  `;
182
193
  }
183
194
  }
@@ -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) {
@@ -10,6 +10,7 @@ import { ErrorPageError } from "../utils/error-page.js";
10
10
  import { createDebug, tag } from "../utils/logger.js";
11
11
  const debugLog = createDebug('explorbot:pilot');
12
12
  import { truncateJson } from "../utils/strings.js";
13
+ import { capabilityGroundingRule, dataProtectionRules } from "./rules.js";
13
14
  import { isInteractive } from "./task-agent.js";
14
15
  const CHECK_TOOLS = ['verify', 'see', 'research', 'context'];
15
16
  const META_TOOLS = ['record', 'reset', 'stop', 'finish'];
@@ -71,7 +72,7 @@ export class Pilot {
71
72
  const notes = task.notesToString() || 'No notes recorded.';
72
73
  let visualAnalysis = '';
73
74
  let screenshotState = null;
74
- if (this.provider.hasVision()) {
75
+ if (type === 'finish' && this.provider.hasVision()) {
75
76
  try {
76
77
  screenshotState = await this.explorer.capturePageWithScreenshot();
77
78
  if (screenshotState.screenshot) {
@@ -139,7 +140,7 @@ export class Pilot {
139
140
  try {
140
141
  const response = await this.provider.generateObject(messages, schema, this.provider.getAgenticModel('pilot'), {
141
142
  agentName: 'pilot',
142
- experimental_telemetry: { functionId: 'pilot.reviewVerdict' },
143
+ telemetry: { functionId: 'pilot.reviewVerdict' },
143
144
  });
144
145
  const result = response?.object;
145
146
  if (!result) {
@@ -229,7 +230,7 @@ export class Pilot {
229
230
  try {
230
231
  const response = await this.provider.generateObject(messages, schema, this.provider.getAgenticModel('pilot'), {
231
232
  agentName: 'pilot',
232
- experimental_telemetry: { functionId: 'pilot.reviewReset' },
233
+ telemetry: { functionId: 'pilot.reviewReset' },
233
234
  });
234
235
  const result = response?.object;
235
236
  if (!result) {
@@ -333,6 +334,8 @@ export class Pilot {
333
334
  You are Pilot — final decision maker for test pass/fail. Tester requested ${type}. Review the
334
335
  evidence and commit to a verdict; "continue" only when evidence is genuinely insufficient.
335
336
 
337
+ ${capabilityGroundingRule}
338
+
336
339
  ${this.buildSharedEvidenceRules(task)}
337
340
 
338
341
  DECISION:
@@ -341,6 +344,8 @@ export class Pilot {
341
344
  Pick assertions DOM can express; for non-DOM regions (iframes, canvas, Monaco/CodeMirror), target a
342
345
  stable landmark (container, ARIA role) instead of literal inner text. Your "pass" stands even if the
343
346
  DOM assertion can't be made.
347
+ Do not pass when Tester achieved only a related navigation/filter/tab/status outcome instead of the
348
+ requested action, workflow, or entity detail goal.
344
349
  - "fail": scenario was attempted but the goal was not achieved.
345
350
  - "skipped": scenario is irrelevant to the app, OR systematic infrastructure failures (LLM errors,
346
351
  crashes) prevented testing. NOT for "test failed to interact" — that's "fail" or "continue".
@@ -374,6 +379,10 @@ export class Pilot {
374
379
 
375
380
  FIRST: Decide if precondition() is needed.
376
381
 
382
+ ${capabilityGroundingRule}
383
+
384
+ ${dataProtectionRules}
385
+
377
386
  Call precondition() WHEN:
378
387
  - The scenario edits/deletes/modifies an item, and you want a DISPOSABLE item to act on safely
379
388
  - The scenario needs specific data clearly NOT on the current page (e.g., items with specific statuses for filtering)
@@ -498,7 +507,7 @@ export class Pilot {
498
507
  maxToolRoundtrips: opts.maxToolRoundtrips ?? 0,
499
508
  agentName: 'pilot',
500
509
  stopWhen: opts.task ? () => opts.task.hasFinished : undefined,
501
- experimental_telemetry: { functionId },
510
+ telemetry: { functionId },
502
511
  });
503
512
  const text = result?.response?.text || '';
504
513
  const learned = (result?.toolExecutions || []).filter((e) => e.toolName === 'learnExperience' && e.output?.content).map((e) => e.output.content);
@@ -930,9 +939,13 @@ export class Pilot {
930
939
 
931
940
  Tester tools: click, pressKey, form, see, verify, context, research, xpathCheck, visualClick,
932
941
  back, getVisitedStates, reset, stop, finish, record.
942
+ Use tool names exactly as listed. Do not invent combined names, aliases, or names with channel markers such as "commentary".
943
+
944
+ ${capabilityGroundingRule}
933
945
 
934
946
  YOUR Pilot-only tool: precondition(description) — create FRESH disposable test data via API. Never
935
947
  request users. Use when:
948
+
936
949
  - Scenario edits/deletes/modifies an item → create a disposable target ("1 post").
937
950
  - Scenario needs auxiliary data (labels, categories, statuses for filtering).
938
951
  - Tester failed because required data is missing (empty dropdown, empty list).
@@ -942,6 +955,8 @@ export class Pilot {
942
955
  - Current page already shows the exact data needed.
943
956
  - Scenario tests navigation, search UI, or viewing.
944
957
 
958
+ ${dataProtectionRules}
959
+
945
960
  Describe WHAT to create, not what exists. RIGHT: precondition("1 test"). WRONG:
946
961
  precondition("1 test suite named Updated Suite with existing tests"). Keep descriptions short.
947
962