explorbot 0.1.32-beta.1 → 0.2.1

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 (222) hide show
  1. package/README.md +35 -19
  2. package/bin/explorbot-cli.ts +33 -10
  3. package/boat/api-tester/src/config.ts +45 -3
  4. package/boat/doc-collector/src/ai/documentarian.ts +26 -28
  5. package/boat/doc-collector/src/ai/tools.ts +74 -16
  6. package/boat/doc-collector/src/cli.ts +1 -6
  7. package/boat/doc-collector/src/docbot.ts +30 -9
  8. package/boat/doc-collector/src/docs-renderer.ts +19 -24
  9. package/boat/doc-collector/src/screenshots.ts +34 -2
  10. package/boat/doc-collector/src/state-diagram.ts +281 -0
  11. package/dist/bin/explorbot-cli.js +29 -9
  12. package/dist/boat/api-tester/src/config.js +43 -4
  13. package/dist/boat/doc-collector/src/ai/documentarian.js +19 -7
  14. package/dist/boat/doc-collector/src/ai/tools.js +53 -16
  15. package/dist/boat/doc-collector/src/cli.js +1 -5
  16. package/dist/boat/doc-collector/src/docbot.js +27 -8
  17. package/dist/boat/doc-collector/src/docs-renderer.js +18 -3
  18. package/dist/boat/doc-collector/src/screenshots.js +30 -1
  19. package/dist/boat/doc-collector/src/state-diagram.js +200 -0
  20. package/dist/models.json +30 -0
  21. package/dist/package.json +23 -3
  22. package/dist/src/action-result.d.ts +8 -10
  23. package/dist/src/action-result.js +72 -164
  24. package/dist/src/action.d.ts +13 -12
  25. package/dist/src/action.js +37 -113
  26. package/dist/src/ai/agent.d.ts +17 -0
  27. package/dist/src/ai/captain/idle-mode.js +1 -1
  28. package/dist/src/ai/captain/test-mode.js +1 -1
  29. package/dist/src/ai/captain/web-mode.js +18 -29
  30. package/dist/src/ai/captain.d.ts +1 -3
  31. package/dist/src/ai/captain.js +17 -45
  32. package/dist/src/ai/conversation.d.ts +2 -5
  33. package/dist/src/ai/conversation.js +9 -28
  34. package/dist/src/ai/driller.d.ts +2 -17
  35. package/dist/src/ai/driller.js +26 -50
  36. package/dist/src/ai/experience-compactor.d.ts +2 -2
  37. package/dist/src/ai/experience-compactor.js +16 -33
  38. package/dist/src/ai/fisherman-tools.js +1 -1
  39. package/dist/src/ai/fisherman.d.ts +0 -1
  40. package/dist/src/ai/fisherman.js +0 -3
  41. package/dist/src/ai/historian/codeceptjs.d.ts +2 -0
  42. package/dist/src/ai/historian/codeceptjs.js +4 -3
  43. package/dist/src/ai/historian/playwright.d.ts +4 -1
  44. package/dist/src/ai/historian/playwright.js +4 -3
  45. package/dist/src/ai/historian/screencast.d.ts +2 -1
  46. package/dist/src/ai/historian/screencast.js +2 -2
  47. package/dist/src/ai/historian/utils.d.ts +0 -1
  48. package/dist/src/ai/historian/utils.js +0 -1
  49. package/dist/src/ai/historian.d.ts +10 -8
  50. package/dist/src/ai/historian.js +1 -5
  51. package/dist/src/ai/navigator.d.ts +8 -6
  52. package/dist/src/ai/navigator.js +34 -65
  53. package/dist/src/ai/pilot.d.ts +13 -8
  54. package/dist/src/ai/pilot.js +53 -59
  55. package/dist/src/ai/planner.d.ts +5 -4
  56. package/dist/src/ai/planner.js +66 -52
  57. package/dist/src/ai/provider.d.ts +4 -1
  58. package/dist/src/ai/provider.js +77 -137
  59. package/dist/src/ai/quartermaster.d.ts +22 -22
  60. package/dist/src/ai/quartermaster.js +8 -14
  61. package/dist/src/ai/rerunner.d.ts +3 -13
  62. package/dist/src/ai/rerunner.js +23 -44
  63. package/dist/src/ai/researcher/cache.js +11 -18
  64. package/dist/src/ai/researcher/coordinates.js +4 -3
  65. package/dist/src/ai/researcher/deep-analysis.d.ts +2 -0
  66. package/dist/src/ai/researcher/deep-analysis.js +8 -11
  67. package/dist/src/ai/researcher/locators.d.ts +9 -2
  68. package/dist/src/ai/researcher/locators.js +65 -8
  69. package/dist/src/ai/researcher/parser.js +1 -1
  70. package/dist/src/ai/researcher/research-result.d.ts +0 -1
  71. package/dist/src/ai/researcher/research-result.js +0 -19
  72. package/dist/src/ai/researcher/sections.d.ts +2 -0
  73. package/dist/src/ai/researcher/sections.js +7 -3
  74. package/dist/src/ai/researcher.js +40 -106
  75. package/dist/src/ai/rules.d.ts +1 -0
  76. package/dist/src/ai/rules.js +22 -15
  77. package/dist/src/ai/session-analyst.js +8 -5
  78. package/dist/src/ai/task-agent.d.ts +19 -6
  79. package/dist/src/ai/task-agent.js +37 -33
  80. package/dist/src/ai/tester.d.ts +6 -14
  81. package/dist/src/ai/tester.js +46 -111
  82. package/dist/src/ai/tools.d.ts +26 -14
  83. package/dist/src/ai/tools.js +138 -132
  84. package/dist/src/command-handler.js +1 -1
  85. package/dist/src/commands/compact-command.d.ts +1 -1
  86. package/dist/src/commands/compact-command.js +6 -6
  87. package/dist/src/commands/context-aria-command.js +1 -1
  88. package/dist/src/commands/context-command.js +8 -6
  89. package/dist/src/commands/context-data-command.js +2 -2
  90. package/dist/src/commands/context-experience-command.js +2 -2
  91. package/dist/src/commands/context-html-command.js +2 -2
  92. package/dist/src/commands/context-knowledge-command.js +2 -2
  93. package/dist/src/commands/drill-command.js +1 -1
  94. package/dist/src/commands/experience-command.js +1 -1
  95. package/dist/src/commands/explore-command.js +4 -3
  96. package/dist/src/commands/freesail-command.js +2 -2
  97. package/dist/src/commands/knows-command.js +1 -1
  98. package/dist/src/commands/learn-command.js +5 -3
  99. package/dist/src/commands/path-command.js +1 -1
  100. package/dist/src/commands/research-command.js +1 -1
  101. package/dist/src/commands/test-command.js +1 -1
  102. package/dist/src/components/AddKnowledge.d.ts +2 -0
  103. package/dist/src/components/AddKnowledge.js +3 -7
  104. package/dist/src/components/App.js +3 -3
  105. package/dist/src/config.d.ts +19 -0
  106. package/dist/src/config.js +156 -10
  107. package/dist/src/experience-tracker.d.ts +11 -6
  108. package/dist/src/experience-tracker.js +82 -155
  109. package/dist/src/explorbot.d.ts +21 -13
  110. package/dist/src/explorbot.js +84 -80
  111. package/dist/src/explorer.d.ts +66 -102
  112. package/dist/src/explorer.js +332 -623
  113. package/dist/src/knowledge-tracker.d.ts +2 -2
  114. package/dist/src/knowledge-tracker.js +38 -45
  115. package/dist/src/state-manager.d.ts +10 -30
  116. package/dist/src/state-manager.js +11 -129
  117. package/dist/src/utils/aria.d.ts +6 -1
  118. package/dist/src/utils/aria.js +4 -1
  119. package/dist/src/utils/cache.d.ts +15 -0
  120. package/dist/src/utils/cache.js +34 -0
  121. package/dist/src/utils/context-formatter.d.ts +6 -0
  122. package/dist/src/utils/context-formatter.js +15 -27
  123. package/dist/src/utils/hooks-runner.js +2 -4
  124. package/dist/src/utils/html.d.ts +5 -0
  125. package/dist/src/utils/html.js +71 -0
  126. package/dist/src/utils/markdown-files.d.ts +10 -0
  127. package/dist/src/utils/markdown-files.js +21 -0
  128. package/dist/src/utils/markdown-query.d.ts +6 -0
  129. package/dist/src/utils/markdown-query.js +14 -1
  130. package/dist/src/utils/page-readiness.d.ts +1 -0
  131. package/dist/src/utils/page-readiness.js +1 -1
  132. package/dist/src/utils/secrets.js +2 -2
  133. package/dist/src/utils/strings.d.ts +2 -0
  134. package/dist/src/utils/strings.js +10 -0
  135. package/dist/src/utils/test-files.js +1 -1
  136. package/dist/src/utils/web-annotate.d.ts +5 -0
  137. package/dist/src/utils/web-annotate.js +58 -0
  138. package/dist/src/utils/web-eidx.d.ts +2 -0
  139. package/dist/src/utils/web-eidx.js +20 -0
  140. package/dist/src/utils/web-element.d.ts +3 -1
  141. package/dist/src/utils/web-element.js +32 -0
  142. package/dist/src/utils/web-sandbox.d.ts +4 -0
  143. package/dist/src/utils/web-sandbox.js +37 -0
  144. package/models.json +30 -0
  145. package/package.json +23 -3
  146. package/src/action-result.ts +70 -173
  147. package/src/action.ts +38 -132
  148. package/src/ai/agent.ts +20 -0
  149. package/src/ai/captain/idle-mode.ts +1 -1
  150. package/src/ai/captain/test-mode.ts +1 -1
  151. package/src/ai/captain/web-mode.ts +18 -30
  152. package/src/ai/captain.ts +17 -42
  153. package/src/ai/conversation.ts +9 -32
  154. package/src/ai/driller.ts +53 -83
  155. package/src/ai/experience-compactor.ts +16 -35
  156. package/src/ai/fisherman-tools.ts +1 -1
  157. package/src/ai/fisherman.ts +0 -4
  158. package/src/ai/historian/codeceptjs.ts +5 -3
  159. package/src/ai/historian/playwright.ts +7 -4
  160. package/src/ai/historian/screencast.ts +4 -3
  161. package/src/ai/historian/utils.ts +0 -1
  162. package/src/ai/historian.ts +6 -8
  163. package/src/ai/navigator.ts +39 -70
  164. package/src/ai/pilot.ts +59 -61
  165. package/src/ai/planner.ts +76 -62
  166. package/src/ai/provider.ts +85 -146
  167. package/src/ai/quartermaster.ts +39 -45
  168. package/src/ai/rerunner.ts +27 -50
  169. package/src/ai/researcher/cache.ts +10 -16
  170. package/src/ai/researcher/coordinates.ts +4 -3
  171. package/src/ai/researcher/deep-analysis.ts +10 -11
  172. package/src/ai/researcher/locators.ts +66 -9
  173. package/src/ai/researcher/parser.ts +1 -1
  174. package/src/ai/researcher/research-result.ts +0 -18
  175. package/src/ai/researcher/sections.ts +9 -3
  176. package/src/ai/researcher.ts +42 -119
  177. package/src/ai/rules.ts +24 -13
  178. package/src/ai/session-analyst.ts +8 -5
  179. package/src/ai/task-agent.ts +45 -38
  180. package/src/ai/tester.ts +51 -129
  181. package/src/ai/tools.ts +145 -153
  182. package/src/command-handler.ts +1 -1
  183. package/src/commands/compact-command.ts +7 -8
  184. package/src/commands/context-aria-command.ts +1 -1
  185. package/src/commands/context-command.ts +8 -6
  186. package/src/commands/context-data-command.ts +2 -2
  187. package/src/commands/context-experience-command.ts +2 -2
  188. package/src/commands/context-html-command.ts +2 -2
  189. package/src/commands/context-knowledge-command.ts +2 -2
  190. package/src/commands/drill-command.ts +1 -1
  191. package/src/commands/experience-command.ts +1 -1
  192. package/src/commands/explore-command.ts +4 -3
  193. package/src/commands/freesail-command.ts +2 -2
  194. package/src/commands/knows-command.ts +1 -1
  195. package/src/commands/learn-command.ts +5 -3
  196. package/src/commands/path-command.ts +1 -1
  197. package/src/commands/research-command.ts +1 -1
  198. package/src/commands/test-command.ts +1 -1
  199. package/src/components/AddKnowledge.tsx +5 -7
  200. package/src/components/App.tsx +3 -3
  201. package/src/config.ts +185 -11
  202. package/src/experience-tracker.ts +89 -152
  203. package/src/explorbot.ts +88 -83
  204. package/src/explorer.ts +402 -693
  205. package/src/knowledge-tracker.ts +42 -49
  206. package/src/state-manager.ts +20 -155
  207. package/src/utils/aria.ts +11 -2
  208. package/src/utils/cache.ts +40 -0
  209. package/src/utils/context-formatter.ts +12 -15
  210. package/src/utils/hooks-runner.ts +2 -4
  211. package/src/utils/html.ts +79 -0
  212. package/src/utils/markdown-files.ts +30 -0
  213. package/src/utils/markdown-query.ts +15 -1
  214. package/src/utils/page-readiness.ts +1 -1
  215. package/src/utils/secrets.ts +2 -3
  216. package/src/utils/strings.ts +12 -0
  217. package/src/utils/test-files.ts +1 -1
  218. package/src/utils/web-annotate.ts +64 -0
  219. package/src/utils/web-eidx.ts +21 -0
  220. package/src/utils/web-element.ts +34 -0
  221. package/src/utils/web-sandbox.ts +43 -0
  222. package/dist/boat/api-tester/example/apibot.config.js +0 -30
@@ -10,7 +10,8 @@ import { type DocbotConfig, DocbotConfigParser } from './config.ts';
10
10
  import { type DocumentedPage, type SkippedPage, renderPageDocumentation, renderSpecIndex } from './docs-renderer.ts';
11
11
  import { getDocPageKey, shouldCrawlDocPath } from './path-filter.ts';
12
12
  import { extractResearchNavigationTargets } from './research-navigation.ts';
13
- import { type DocumentationScreenshot, captureDocumentationScreenshots } from './screenshots.ts';
13
+ import { type DocumentationScreenshot, captureDocumentationScreenshots, captureInteractionScreenshot } from './screenshots.ts';
14
+ import { renderMermaidBody } from './state-diagram.ts';
14
15
 
15
16
  class DocBot {
16
17
  private explorBot: ExplorBot;
@@ -42,7 +43,7 @@ class DocBot {
42
43
  config: this.options.docsConfig,
43
44
  path: this.options.path,
44
45
  });
45
- this.documentarian = new Documentarian(this.explorBot.getProvider(), this.config, this.explorBot.getExplorer());
46
+ this.documentarian = new Documentarian(this.explorBot.getProvider(), this.config, this.explorBot.getExplorer(), this.explorBot.stateManager());
46
47
  this.ensureDirectory(this.configParser.getOutputDir());
47
48
  this.ensureDirectory(this.getPagesDir());
48
49
  }
@@ -75,7 +76,7 @@ class DocBot {
75
76
  continue;
76
77
  }
77
78
 
78
- const stateManager = this.explorBot.getExplorer().getStateManager();
79
+ const stateManager = this.explorBot.stateManager();
79
80
  if (stateManager.hasVisitedState(target)) {
80
81
  continue;
81
82
  }
@@ -111,7 +112,17 @@ class DocBot {
111
112
  screenshot: this.shouldUseScreenshots(),
112
113
  force: true,
113
114
  });
114
- const documentation = await this.documentarian.document(state, research);
115
+ const pagePath = this.getPageFilePath(state.url);
116
+ const documentation = await this.documentarian.document(state, research, async (interactionState, transition) => {
117
+ if (!this.shouldUseScreenshots()) {
118
+ return null;
119
+ }
120
+ return captureInteractionScreenshot(this.explorBot.getExplorer(), interactionState, transition, {
121
+ pageFilePath: pagePath,
122
+ screenshotsDir: this.getScreenshotsDir(),
123
+ config: this.config,
124
+ });
125
+ });
115
126
  const lowSignalReason = this.getLowSignalReason(documentation, research);
116
127
  if (lowSignalReason) {
117
128
  skipped.push({
@@ -134,6 +145,7 @@ class DocBot {
134
145
  mightActions: documentation.might.map((item) => item.action),
135
146
  interactionActions: (documentation.interactions || []).map((item) => item.action),
136
147
  qualityNotes: documentation.qualityNotes || [],
148
+ interactions: documentation.interactions || [],
137
149
  filePath,
138
150
  });
139
151
  documented.add(pageKey);
@@ -159,12 +171,13 @@ class DocBot {
159
171
  }
160
172
  }
161
173
 
162
- const indexPath = this.saveIndex(effectiveStartPath, pages, skipped, effectiveMaxPages);
174
+ const { indexPath, diagramPath } = this.saveIndex(effectiveStartPath, pages, skipped, effectiveMaxPages);
163
175
 
164
176
  return {
165
177
  pages,
166
178
  skipped,
167
179
  indexPath,
180
+ diagramPath,
168
181
  outputDir: this.configParser.getOutputDir(),
169
182
  };
170
183
  }
@@ -385,6 +398,10 @@ class DocBot {
385
398
  return null;
386
399
  }
387
400
 
401
+ if ((documentation.interactions || []).length > 0) {
402
+ return null;
403
+ }
404
+
388
405
  const interactiveCount = this.countInteractiveElements(research);
389
406
  if (interactiveCount >= minInteractiveElements) {
390
407
  return null;
@@ -417,10 +434,13 @@ class DocBot {
417
434
  });
418
435
  }
419
436
 
420
- private saveIndex(startPath: string, pages: DocumentedPage[], skipped: SkippedPage[], maxPages: number): string {
421
- const indexPath = path.join(this.configParser.getOutputDir(), 'spec.md');
422
- writeFileSync(indexPath, renderSpecIndex(this.configParser.getOutputDir(), startPath, pages, skipped, maxPages), 'utf8');
423
- return indexPath;
437
+ private saveIndex(startPath: string, pages: DocumentedPage[], skipped: SkippedPage[], maxPages: number): { indexPath: string; diagramPath: string } {
438
+ const outputDir = this.configParser.getOutputDir();
439
+ const indexPath = path.join(outputDir, 'index.md');
440
+ writeFileSync(indexPath, renderSpecIndex(outputDir, startPath, pages, skipped, maxPages), 'utf8');
441
+ const diagramPath = path.join(outputDir, 'state-diagram.mmd');
442
+ writeFileSync(diagramPath, renderMermaidBody(outputDir, pages), 'utf8');
443
+ return { indexPath, diagramPath };
424
444
  }
425
445
 
426
446
  private getPagesDir(): string {
@@ -461,6 +481,7 @@ interface CollectionResult {
461
481
  pages: DocumentedPage[];
462
482
  skipped: SkippedPage[];
463
483
  indexPath: string;
484
+ diagramPath: string;
464
485
  outputDir: string;
465
486
  }
466
487
 
@@ -1,7 +1,9 @@
1
1
  import path from 'node:path';
2
- import type { WebPageState } from '../../../src/state-manager.ts';
2
+ import { type WebPageState } from '../../../src/state-manager.ts';
3
3
  import type { PageDocumentation, StateTransition } from './ai/documentarian.ts';
4
4
  import type { DocumentationScreenshot } from './screenshots.ts';
5
+ import { buildStateGraph, renderMermaidFromGraph, renderStateMapFromGraph, type DocumentedPage, type SkippedPage } from './state-diagram.ts';
6
+ import { normalizeInlineText } from '../../../src/utils/strings.ts';
5
7
 
6
8
  function renderPageDocumentation(state: WebPageState, documentation: PageDocumentation, screenshots: DocumentationScreenshot[] = []): string {
7
9
  const lines: string[] = [];
@@ -48,6 +50,10 @@ function renderPageDocumentation(state: WebPageState, documentation: PageDocumen
48
50
  }
49
51
  lines.push('');
50
52
  }
53
+ if (transition.screenshot) {
54
+ lines.push(`![${normalizeInlineText(transition.screenshot.title)}](${transition.screenshot.relativePath})`);
55
+ lines.push('');
56
+ }
51
57
  }
52
58
  }
53
59
 
@@ -109,6 +115,18 @@ function renderSpecIndex(outputDir: string, startPath: string, pages: Documented
109
115
  lines.push(`Pages skipped: ${skipped.length}`);
110
116
  lines.push(`Max pages: ${maxPages}`);
111
117
  lines.push('');
118
+ const graph = buildStateGraph(outputDir, pages);
119
+ lines.push('## State Transitions');
120
+ lines.push('');
121
+ lines.push(`\`\`\`mermaid\n${renderMermaidFromGraph(graph)}\n\`\`\``);
122
+ lines.push('');
123
+ const stateMap = renderStateMapFromGraph(graph);
124
+ if (stateMap) {
125
+ lines.push('### State Index');
126
+ lines.push('');
127
+ lines.push(stateMap);
128
+ lines.push('');
129
+ }
112
130
  lines.push('## Pages');
113
131
  lines.push('');
114
132
 
@@ -227,28 +245,5 @@ function ensureSentence(text: string): string {
227
245
  return `${trimmed}.`;
228
246
  }
229
247
 
230
- function normalizeInlineText(text: string): string {
231
- return text.normalize('NFKC').replace(/\s+/g, ' ').trim();
232
- }
233
-
234
- interface DocumentedPage {
235
- url: string;
236
- title: string;
237
- summary: string;
238
- canCount: number;
239
- mightCount: number;
240
- interactionCount: number;
241
- canActions: string[];
242
- mightActions: string[];
243
- interactionActions: string[];
244
- qualityNotes: string[];
245
- filePath: string;
246
- }
247
-
248
- interface SkippedPage {
249
- url: string;
250
- reason: string;
251
- }
252
-
253
248
  export { renderPageDocumentation, renderSpecIndex, ensureSentence, normalizeAction };
254
249
  export type { DocumentedPage, SkippedPage, StateTransition };
@@ -3,13 +3,15 @@ import path from 'node:path';
3
3
  import { parseResearchSections } from '../../../src/ai/researcher/parser.ts';
4
4
  import type Explorer from '../../../src/explorer.ts';
5
5
  import type { WebPageState } from '../../../src/state-manager.ts';
6
+ import { detectFocusArea } from '../../../src/utils/aria.ts';
6
7
  import { safeFilename, sanitizeFilename } from '../../../src/utils/strings.ts';
8
+ import type { DocStateTransition } from './ai/tools.ts';
7
9
  import type { DocbotConfig } from './config.ts';
8
10
 
9
11
  const DEFAULT_MAX_SECTION_SCREENSHOTS = 8;
10
12
 
11
13
  export async function captureDocumentationScreenshots(explorer: Explorer, state: WebPageState, research: string, options: DocumentationScreenshotOptions): Promise<DocumentationScreenshot[]> {
12
- const page = explorer.playwrightHelper?.page;
14
+ const page = explorer.page;
13
15
  if (!page) {
14
16
  return [];
15
17
  }
@@ -59,6 +61,36 @@ export function getScreenshotSections(research: string): ScreenshotSection[] {
59
61
  return sections;
60
62
  }
61
63
 
64
+ export async function captureInteractionScreenshot(explorer: Explorer, state: WebPageState, transition: DocStateTransition, options: DocumentationScreenshotOptions): Promise<DocumentationScreenshot | null> {
65
+ const page = explorer.page;
66
+ if (!page) {
67
+ return null;
68
+ }
69
+
70
+ mkdirSync(options.screenshotsDir, { recursive: true });
71
+ const pageName = sanitizeFilename(state.url || 'page') || 'page';
72
+ const stateName = sanitizeFilename(transition.targetState?.label || transition.action) || 'state';
73
+ const filePath = path.join(options.screenshotsDir, safeFilename(`${pageName}_${stateName}`, '.png'));
74
+ const focus = detectFocusArea(state.ariaSnapshot || null);
75
+
76
+ try {
77
+ if (focus.detected) {
78
+ await page.locator('[role="dialog"], [role="alertdialog"], [aria-modal="true"]').last().screenshot({ path: filePath });
79
+ } else {
80
+ await page.screenshot({ path: filePath });
81
+ }
82
+ } catch {
83
+ return null;
84
+ }
85
+
86
+ return {
87
+ title: transition.targetState?.label || transition.action,
88
+ path: filePath,
89
+ relativePath: toMarkdownPath(options.pageFilePath, filePath),
90
+ kind: 'state',
91
+ };
92
+ }
93
+
62
94
  async function captureFullPageScreenshot(page: any, pageName: string, options: DocumentationScreenshotOptions): Promise<DocumentationScreenshot | null> {
63
95
  const filePath = path.join(options.screenshotsDir, safeFilename(`${pageName}_page`, '.png'));
64
96
  try {
@@ -110,7 +142,7 @@ export interface DocumentationScreenshot {
110
142
  title: string;
111
143
  path: string;
112
144
  relativePath: string;
113
- kind: 'page' | 'section';
145
+ kind: 'page' | 'section' | 'state';
114
146
  selector?: string;
115
147
  }
116
148
 
@@ -0,0 +1,281 @@
1
+ import path from 'node:path';
2
+ import { normalizeUrl } from '../../../src/state-manager.ts';
3
+ import { normalizeInlineText } from '../../../src/utils/strings.ts';
4
+ import type { StateTransition } from './ai/documentarian.ts';
5
+
6
+ function buildStateGraph(outputDir: string, pages: DocumentedPage[]): StateGraph {
7
+ const pageIds = new Map<string, string>();
8
+ const pageNodes: StateNode[] = [];
9
+ const adjacency = new Map<string, Set<string>>();
10
+ const classAssignment = new Map<StateClass, string[]>([
11
+ ['page', []],
12
+ ['dialog', []],
13
+ ['section', []],
14
+ ]);
15
+
16
+ for (const [index, page] of pages.entries()) {
17
+ const id = `page${index}`;
18
+ pageIds.set(normalizeUrl(page.url), id);
19
+ adjacency.set(id, new Set());
20
+ classAssignment.get('page')?.push(id);
21
+ pageNodes.push({ id, kind: 'page', label: page.title || page.url, subLabel: page.url, filePath: page.filePath });
22
+ }
23
+
24
+ const stateKeys = new Map<string, string>();
25
+ const transientByPage = new Map<string, StateNode[]>();
26
+ const clicks: StateClick[] = [];
27
+ const edges: StateEdge[] = [];
28
+ const drawnBack = new Set<string>();
29
+ let stateIndex = 0;
30
+
31
+ for (const [pageIndex, page] of pages.entries()) {
32
+ const sourceId = `page${pageIndex}`;
33
+ for (const transition of page.interactions || []) {
34
+ const targetState = transition.targetState;
35
+ if (!targetState) {
36
+ continue;
37
+ }
38
+
39
+ const normalizedTarget = normalizeUrl(targetState.url);
40
+ const isPageTarget = targetState.kind === 'page';
41
+ let pageTargetId: string | undefined;
42
+ if (isPageTarget) {
43
+ pageTargetId = pageIds.get(normalizedTarget);
44
+ }
45
+ let targetId: string | undefined;
46
+ if (pageTargetId && pageTargetId !== sourceId) {
47
+ targetId = pageTargetId;
48
+ }
49
+
50
+ if (!targetId) {
51
+ const stateKey = `${sourceId}:${targetState.kind}:${normalizeInlineText(targetState.label)}:${normalizedTarget}`;
52
+ targetId = stateKeys.get(stateKey);
53
+ if (!targetId) {
54
+ targetId = `state${stateIndex++}`;
55
+ stateKeys.set(stateKey, targetId);
56
+ adjacency.set(targetId, new Set());
57
+ classAssignment.get(classForKind(targetState.kind))?.push(targetId);
58
+ const list = transientByPage.get(sourceId) ?? [];
59
+ list.push({ id: targetId, kind: targetState.kind, label: targetState.label, subLabel: targetState.kind, parentPageId: sourceId });
60
+ transientByPage.set(sourceId, list);
61
+ if (transition.screenshot) {
62
+ const screenshotPath = path.resolve(path.dirname(page.filePath), transition.screenshot.relativePath);
63
+ clicks.push({ node: targetId, target: path.relative(outputDir, screenshotPath).replaceAll('\\', '/'), tooltip: 'Open state screenshot' });
64
+ }
65
+ }
66
+ }
67
+
68
+ if (!targetId) {
69
+ continue;
70
+ }
71
+
72
+ const pairKey = `${sourceId}>${targetId}`;
73
+ if (adjacency.get(targetId)?.has(sourceId)) {
74
+ if (drawnBack.has(pairKey)) {
75
+ continue;
76
+ }
77
+ drawnBack.add(pairKey);
78
+ edges.push({ source: sourceId, target: targetId, action: transition.action, isBack: true });
79
+ continue;
80
+ }
81
+
82
+ if (adjacency.get(sourceId)?.has(targetId) || createsCycle(sourceId, targetId, adjacency)) {
83
+ continue;
84
+ }
85
+ adjacency.get(sourceId)?.add(targetId);
86
+ edges.push({ source: sourceId, target: targetId, action: transition.action, isBack: false });
87
+ }
88
+ }
89
+
90
+ const pageClicks: StateClick[] = [];
91
+ for (const pageNode of pageNodes) {
92
+ const filePath = pageNode.filePath;
93
+ if (!filePath) {
94
+ continue;
95
+ }
96
+ const relativeFile = path.relative(outputDir, filePath).replaceAll('\\', '/');
97
+ pageClicks.push({ node: pageNode.id, target: relativeFile, tooltip: `Open ${pageNode.label}` });
98
+ }
99
+
100
+ return { pages: pageNodes, transientByPage, edges, clicks: [...pageClicks, ...clicks], classAssignment };
101
+ }
102
+
103
+ function renderMermaidBody(outputDir: string, pages: DocumentedPage[]): string {
104
+ return renderMermaidFromGraph(buildStateGraph(outputDir, pages));
105
+ }
106
+
107
+ function renderMermaidFromGraph(graph: StateGraph): string {
108
+ const lines: string[] = ['flowchart TD'];
109
+ if (graph.pages.length === 0) {
110
+ lines.push(' empty["No documented states"]');
111
+ return lines.join('\n');
112
+ }
113
+
114
+ for (const page of graph.pages) {
115
+ lines.push(` ${renderNodeLine(page)}`);
116
+ const children = graph.transientByPage.get(page.id);
117
+ if (!children || children.length === 0) {
118
+ continue;
119
+ }
120
+ lines.push(` subgraph sg_${page.id} ["${escapeMermaidLabel(page.label)} — transient states"]`);
121
+ for (const child of children) {
122
+ lines.push(` ${renderNodeLine(child)}`);
123
+ }
124
+ lines.push(' end');
125
+ }
126
+
127
+ for (const edge of graph.edges) {
128
+ let arrow = '-->';
129
+ if (edge.isBack) {
130
+ arrow = '-.->';
131
+ }
132
+ lines.push(` ${edge.source} ${arrow}|"${escapeMermaidLabel(edge.action)}"| ${edge.target}`);
133
+ }
134
+
135
+ lines.push(' classDef page fill:#dbeafe,stroke:#2563eb,color:#0f172a;');
136
+ lines.push(' classDef dialog fill:#ffedd5,stroke:#ea580c,color:#0f172a;');
137
+ lines.push(' classDef section fill:#f3e8ff,stroke:#9333ea,color:#0f172a;');
138
+ for (const [className, ids] of graph.classAssignment) {
139
+ if (ids.length === 0) {
140
+ continue;
141
+ }
142
+ lines.push(` class ${ids.join(',')} ${className};`);
143
+ }
144
+
145
+ for (const click of graph.clicks) {
146
+ lines.push(` click ${click.node} "${click.target}" "${escapeMermaidLabel(click.tooltip)}"`);
147
+ }
148
+
149
+ return lines.join('\n');
150
+ }
151
+
152
+ function renderStateMapFromGraph(graph: StateGraph): string {
153
+ if (graph.pages.length === 0) {
154
+ return '';
155
+ }
156
+ const clickByNode = new Map(graph.clicks.map((click) => [click.node, click]));
157
+ const rows = ['| State | Type | Open |', '| --- | --- | --- |'];
158
+ for (const pageNode of graph.pages) {
159
+ const pageClick = clickByNode.get(pageNode.id);
160
+ let openCell = '—';
161
+ if (pageClick) {
162
+ openCell = `[open page](${pageClick.target})`;
163
+ }
164
+ rows.push(`| ${escapeTable(pageNode.label)} | page | ${openCell} |`);
165
+ for (const child of graph.transientByPage.get(pageNode.id) ?? []) {
166
+ const childClick = clickByNode.get(child.id);
167
+ let childCell = '—';
168
+ if (childClick) {
169
+ childCell = `[view screenshot](${childClick.target})`;
170
+ }
171
+ rows.push(`| ${escapeTable(child.label)} | ${child.kind} | ${childCell} |`);
172
+ }
173
+ }
174
+ return rows.join('\n');
175
+ }
176
+
177
+ function renderNodeLine(node: StateNode): string {
178
+ const label = `${escapeMermaidLabel(node.label)}<br/>${escapeMermaidLabel(node.subLabel)}`;
179
+ if (node.kind === 'dialog' || node.kind === 'modal') {
180
+ return `${node.id}{{"${label}"}}`;
181
+ }
182
+ if (node.kind === 'section') {
183
+ return `${node.id}("${label}")`;
184
+ }
185
+ return `${node.id}["${label}"]`;
186
+ }
187
+
188
+ function createsCycle(sourceId: string, targetId: string, adjacency: Map<string, Set<string>>): boolean {
189
+ if (sourceId === targetId) {
190
+ return true;
191
+ }
192
+
193
+ const pending = [targetId];
194
+ const visited = new Set<string>();
195
+ while (pending.length > 0) {
196
+ const nodeId = pending.pop();
197
+ if (!nodeId || visited.has(nodeId)) {
198
+ continue;
199
+ }
200
+ if (nodeId === sourceId) {
201
+ return true;
202
+ }
203
+ visited.add(nodeId);
204
+ pending.push(...(adjacency.get(nodeId) || []));
205
+ }
206
+ return false;
207
+ }
208
+
209
+ function escapeMermaidLabel(value: string): string {
210
+ return normalizeInlineText(value).replaceAll('&', '&amp;').replaceAll('"', '&quot;').replaceAll('|', '&#124;');
211
+ }
212
+
213
+ function escapeTable(value: string): string {
214
+ return normalizeInlineText(value).replaceAll('|', '\\|');
215
+ }
216
+
217
+ function classForKind(kind: StateKind): StateClass {
218
+ if (kind === 'section') {
219
+ return 'section';
220
+ }
221
+ if (kind === 'page') {
222
+ return 'page';
223
+ }
224
+ return 'dialog';
225
+ }
226
+
227
+ interface DocumentedPage {
228
+ url: string;
229
+ title: string;
230
+ summary: string;
231
+ canCount: number;
232
+ mightCount: number;
233
+ interactionCount: number;
234
+ canActions: string[];
235
+ mightActions: string[];
236
+ interactionActions: string[];
237
+ qualityNotes: string[];
238
+ interactions?: StateTransition[];
239
+ filePath: string;
240
+ }
241
+
242
+ interface SkippedPage {
243
+ url: string;
244
+ reason: string;
245
+ }
246
+
247
+ type StateKind = 'page' | 'dialog' | 'modal' | 'section';
248
+ type StateClass = 'page' | 'dialog' | 'section';
249
+
250
+ interface StateNode {
251
+ id: string;
252
+ kind: StateKind;
253
+ label: string;
254
+ subLabel: string;
255
+ filePath?: string;
256
+ parentPageId?: string;
257
+ }
258
+
259
+ interface StateEdge {
260
+ source: string;
261
+ target: string;
262
+ action: string;
263
+ isBack: boolean;
264
+ }
265
+
266
+ interface StateClick {
267
+ node: string;
268
+ target: string;
269
+ tooltip: string;
270
+ }
271
+
272
+ interface StateGraph {
273
+ pages: StateNode[];
274
+ transientByPage: Map<string, StateNode[]>;
275
+ edges: StateEdge[];
276
+ clicks: StateClick[];
277
+ classAssignment: Map<StateClass, string[]>;
278
+ }
279
+
280
+ export { buildStateGraph, renderMermaidBody, renderMermaidFromGraph, renderStateMapFromGraph };
281
+ export type { DocumentedPage, SkippedPage, StateGraph, StateNode, StateEdge, StateClick };
@@ -9,7 +9,7 @@ import { render } from 'ink';
9
9
  import React from 'react';
10
10
  import { App } from '../src/components/App.js';
11
11
  import { StatusPane } from '../src/components/StatusPane.js';
12
- import { ConfigParser } from '../src/config.js';
12
+ import { ConfigParser, EXPLORBOT_ENV_VARS, PROVIDERS } from '../src/config.js';
13
13
  import { ExplorBot } from '../src/explorbot.js';
14
14
  import { Stats } from '../src/stats.js';
15
15
  import { Plan } from '../src/test-plan.js';
@@ -27,7 +27,6 @@ if (!process.env.EXPLORBOT_NO_BANNER) {
27
27
  console.log(`⛵ ${chalk.yellow.bold(`Explorbot v${pkgVersion}`)} ${chalk.dim('Autonomous Testing Agent')}`);
28
28
  }
29
29
  function buildExplorBotOptions(from, options) {
30
- const sessionFile = options.session === true ? path.join(path.resolve(options.path || process.cwd()), 'output', 'session.json') : options.session;
31
30
  return {
32
31
  from,
33
32
  verbose: options.verbose || options.debug,
@@ -36,7 +35,7 @@ function buildExplorBotOptions(from, options) {
36
35
  show: options.show,
37
36
  headless: options.headless,
38
37
  incognito: options.incognito,
39
- session: sessionFile,
38
+ session: options.session,
40
39
  };
41
40
  }
42
41
  function addCommonOptions(cmd) {
@@ -454,16 +453,16 @@ program
454
453
  await ConfigParser.getInstance().loadConfig({
455
454
  path: options.path || process.cwd(),
456
455
  });
456
+ const { KnowledgeTracker } = await import('../src/knowledge-tracker.js');
457
+ const tracker = new KnowledgeTracker();
457
458
  if (url && description) {
458
- const { KnowledgeTracker } = await import('../src/knowledge-tracker.js');
459
- const tracker = new KnowledgeTracker();
460
459
  const result = tracker.addKnowledge(url, description);
461
460
  const action = result.isNewFile ? 'Created' : 'Updated';
462
461
  console.log(`Knowledge ${action} in: ${result.filename}`);
463
462
  return;
464
463
  }
465
464
  const AddKnowledge = (await import('../src/components/AddKnowledge.js')).default;
466
- render(React.createElement(AddKnowledge, { initialUrl: url || '' }), {
465
+ render(React.createElement(AddKnowledge, { initialUrl: url || '', knowledgeTracker: tracker }), {
467
466
  exitOnCtrlC: false,
468
467
  patchConsole: false,
469
468
  });
@@ -623,7 +622,7 @@ program
623
622
  path: options.path,
624
623
  config: options.config,
625
624
  headless: true,
626
- session: options.session === true ? 'output/session.json' : options.session,
625
+ session: options.session,
627
626
  };
628
627
  const explorBot = new ExplorBot(mainOptions);
629
628
  await explorBot.start();
@@ -652,10 +651,10 @@ addCommonOptions(program.command('shell <url> <command>').description('Execute a
652
651
  const explorBot = new ExplorBot(buildExplorBotOptions(url, options));
653
652
  await explorBot.start();
654
653
  await explorBot.agentNavigator().visit(url);
655
- const action = explorBot.getExplorer().createAction();
654
+ const action = explorBot.getExplorer().action();
656
655
  await action.execute(command);
657
656
  log('Command executed successfully');
658
- const state = explorBot.getExplorer().getStateManager().getCurrentState();
657
+ const state = explorBot.stateManager().getCurrentState();
659
658
  if (state)
660
659
  log(`URL: ${state.url}`);
661
660
  await explorBot.stop();
@@ -793,4 +792,25 @@ import { createApiCommands } from "../boat/api-tester/src/cli.js";
793
792
  import { createDocsCommands } from "../boat/doc-collector/src/cli.js";
794
793
  program.addCommand(createApiCommands('api'));
795
794
  program.addCommand(createDocsCommands('docs'));
795
+ const envHelp = () => {
796
+ const width = Math.max(...EXPLORBOT_ENV_VARS.map((v) => v.name.length));
797
+ const rows = EXPLORBOT_ENV_VARS.map((v) => ` ${v.name.padEnd(width)} ${v.description}`).join('\n');
798
+ return `
799
+ Environment variables (config-free one-liner mode):
800
+ Set EXPLORBOT_AI_PROVIDER to run without an explorbot.config.js. A config file always wins.
801
+
802
+ ${rows}
803
+
804
+ Providers: ${Object.keys(PROVIDERS).join(', ')}
805
+ Example:
806
+ EXPLORBOT_URL=https://app.example.com EXPLORBOT_AI_PROVIDER=openrouter \\
807
+ ${cli} explore /login --max-tests 3
808
+ `;
809
+ };
810
+ const addEnvHelp = (cmd) => {
811
+ cmd.addHelpText('after', envHelp);
812
+ for (const sub of cmd.commands)
813
+ addEnvHelp(sub);
814
+ };
815
+ addEnvHelp(program);
796
816
  program.parse();
@@ -9,7 +9,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
9
9
  import { existsSync, mkdirSync, readFileSync } from 'node:fs';
10
10
  import path, { resolve } from 'node:path';
11
11
  import { parseEnv } from 'node:util';
12
- import { EXPLORBOT_CONFIG_PATHS } from "../../../src/config.js";
12
+ import { EXPLORBOT_CONFIG_PATHS, createModel, materializeKnowledge, resolveModel, resolveOutputRoot } from "../../../src/config.js";
13
13
  export class ApibotConfigParser {
14
14
  static instance;
15
15
  config = null;
@@ -37,9 +37,13 @@ export class ApibotConfigParser {
37
37
  ApibotConfigParser.loadEnv('.env');
38
38
  const resolvedPath = options?.config || this.findConfigFile();
39
39
  if (!resolvedPath) {
40
- if (options?.path)
41
- process.chdir(originalCwd);
42
- throw new Error('No configuration file found. Create apibot.config.js or apibot.config.ts');
40
+ try {
41
+ return await this.loadEnvConfig();
42
+ }
43
+ finally {
44
+ if (options?.path && originalCwd !== process.cwd())
45
+ process.chdir(originalCwd);
46
+ }
43
47
  }
44
48
  try {
45
49
  const configModule = await this.loadConfigModule(resolvedPath);
@@ -98,6 +102,41 @@ export class ApibotConfigParser {
98
102
  mkdirSync(dirPath, { recursive: true });
99
103
  }
100
104
  }
105
+ async loadEnvConfig() {
106
+ const provider = process.env.EXPLORBOT_AI_PROVIDER;
107
+ const modelSpec = process.env.EXPLORBOT_AI_MODEL;
108
+ if (!provider && !modelSpec) {
109
+ throw new Error('No configuration file found. Create apibot.config.js or set EXPLORBOT_URL and EXPLORBOT_AI_PROVIDER environment variables');
110
+ }
111
+ if (modelSpec && !provider && !modelSpec.includes('/')) {
112
+ throw new Error('EXPLORBOT_AI_MODEL needs a provider — set EXPLORBOT_AI_PROVIDER, or write it as "provider/model-id"');
113
+ }
114
+ const baseEndpoint = process.env.EXPLORBOT_URL;
115
+ if (!baseEndpoint) {
116
+ throw new Error('No API endpoint to test. Set EXPLORBOT_URL to the API base endpoint');
117
+ }
118
+ const outputRoot = resolveOutputRoot();
119
+ materializeKnowledge(outputRoot);
120
+ const api = { baseEndpoint };
121
+ if (process.env.EXPLORBOT_API_SPEC) {
122
+ api.spec = [process.env.EXPLORBOT_API_SPEC];
123
+ }
124
+ let model;
125
+ if (provider && modelSpec)
126
+ model = await createModel(provider, modelSpec);
127
+ if (provider && !modelSpec)
128
+ model = await resolveModel(provider, 'model');
129
+ if (!provider)
130
+ model = await resolveModel(modelSpec, 'model');
131
+ this.config = {
132
+ ai: { model },
133
+ api,
134
+ dirs: { output: '.', knowledge: 'knowledge' },
135
+ };
136
+ this.configPath = path.join(outputRoot, 'apibot.config.js');
137
+ this.validateConfig(this.config);
138
+ return this.config;
139
+ }
101
140
  findConfigFile() {
102
141
  const apibotPaths = ['apibot.config.js', 'apibot.config.mjs', 'apibot.config.ts'];
103
142
  for (const p of apibotPaths) {