explorbot 0.1.32-beta.1 → 0.2.0

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 (168) hide show
  1. package/README.md +14 -14
  2. package/bin/explorbot-cli.ts +4 -3
  3. package/boat/doc-collector/src/ai/documentarian.ts +22 -27
  4. package/boat/doc-collector/src/ai/tools.ts +67 -10
  5. package/boat/doc-collector/src/docbot.ts +28 -7
  6. package/boat/doc-collector/src/docs-renderer.ts +19 -24
  7. package/boat/doc-collector/src/screenshots.ts +33 -1
  8. package/boat/doc-collector/src/state-diagram.ts +281 -0
  9. package/dist/bin/explorbot-cli.js +3 -3
  10. package/dist/boat/doc-collector/src/ai/documentarian.js +16 -6
  11. package/dist/boat/doc-collector/src/ai/tools.js +47 -10
  12. package/dist/boat/doc-collector/src/docbot.js +25 -6
  13. package/dist/boat/doc-collector/src/docs-renderer.js +18 -3
  14. package/dist/boat/doc-collector/src/screenshots.js +29 -0
  15. package/dist/boat/doc-collector/src/state-diagram.js +200 -0
  16. package/dist/package.json +20 -3
  17. package/dist/src/action-result.d.ts +8 -10
  18. package/dist/src/action-result.js +72 -164
  19. package/dist/src/action.d.ts +3 -10
  20. package/dist/src/action.js +18 -111
  21. package/dist/src/ai/captain/web-mode.js +0 -7
  22. package/dist/src/ai/captain.d.ts +1 -3
  23. package/dist/src/ai/captain.js +4 -19
  24. package/dist/src/ai/conversation.d.ts +2 -5
  25. package/dist/src/ai/conversation.js +9 -28
  26. package/dist/src/ai/driller.d.ts +0 -4
  27. package/dist/src/ai/driller.js +3 -12
  28. package/dist/src/ai/experience-compactor.d.ts +2 -2
  29. package/dist/src/ai/experience-compactor.js +16 -33
  30. package/dist/src/ai/fisherman-tools.js +1 -1
  31. package/dist/src/ai/fisherman.d.ts +0 -1
  32. package/dist/src/ai/fisherman.js +0 -3
  33. package/dist/src/ai/historian/codeceptjs.d.ts +2 -0
  34. package/dist/src/ai/historian/codeceptjs.js +3 -2
  35. package/dist/src/ai/historian/playwright.d.ts +2 -0
  36. package/dist/src/ai/historian/playwright.js +3 -2
  37. package/dist/src/ai/historian/utils.d.ts +0 -1
  38. package/dist/src/ai/historian/utils.js +0 -1
  39. package/dist/src/ai/historian.d.ts +5 -4
  40. package/dist/src/ai/historian.js +1 -5
  41. package/dist/src/ai/navigator.d.ts +3 -5
  42. package/dist/src/ai/navigator.js +11 -46
  43. package/dist/src/ai/pilot.d.ts +1 -3
  44. package/dist/src/ai/pilot.js +6 -17
  45. package/dist/src/ai/planner.d.ts +3 -3
  46. package/dist/src/ai/planner.js +49 -37
  47. package/dist/src/ai/provider.d.ts +4 -1
  48. package/dist/src/ai/provider.js +77 -136
  49. package/dist/src/ai/quartermaster.d.ts +21 -22
  50. package/dist/src/ai/quartermaster.js +4 -10
  51. package/dist/src/ai/rerunner.d.ts +1 -2
  52. package/dist/src/ai/rerunner.js +14 -22
  53. package/dist/src/ai/researcher/cache.js +11 -18
  54. package/dist/src/ai/researcher/deep-analysis.js +3 -6
  55. package/dist/src/ai/researcher/parser.js +1 -1
  56. package/dist/src/ai/researcher/research-result.d.ts +0 -1
  57. package/dist/src/ai/researcher/research-result.js +0 -19
  58. package/dist/src/ai/researcher/sections.js +5 -1
  59. package/dist/src/ai/researcher.js +12 -53
  60. package/dist/src/ai/rules.d.ts +1 -0
  61. package/dist/src/ai/rules.js +22 -15
  62. package/dist/src/ai/session-analyst.js +8 -5
  63. package/dist/src/ai/task-agent.d.ts +1 -1
  64. package/dist/src/ai/task-agent.js +2 -27
  65. package/dist/src/ai/tester.d.ts +0 -2
  66. package/dist/src/ai/tester.js +10 -61
  67. package/dist/src/ai/tools.d.ts +18 -7
  68. package/dist/src/ai/tools.js +119 -101
  69. package/dist/src/commands/compact-command.d.ts +1 -1
  70. package/dist/src/commands/compact-command.js +6 -6
  71. package/dist/src/commands/context-command.js +1 -1
  72. package/dist/src/commands/context-knowledge-command.js +1 -1
  73. package/dist/src/commands/experience-command.js +1 -1
  74. package/dist/src/commands/knows-command.js +1 -1
  75. package/dist/src/commands/learn-command.js +3 -1
  76. package/dist/src/components/AddKnowledge.d.ts +2 -0
  77. package/dist/src/components/AddKnowledge.js +3 -7
  78. package/dist/src/components/App.js +2 -2
  79. package/dist/src/config.d.ts +2 -0
  80. package/dist/src/config.js +12 -0
  81. package/dist/src/experience-tracker.d.ts +11 -6
  82. package/dist/src/experience-tracker.js +81 -154
  83. package/dist/src/explorbot.d.ts +4 -5
  84. package/dist/src/explorbot.js +17 -51
  85. package/dist/src/explorer.d.ts +4 -15
  86. package/dist/src/explorer.js +18 -68
  87. package/dist/src/knowledge-tracker.d.ts +2 -2
  88. package/dist/src/knowledge-tracker.js +38 -45
  89. package/dist/src/state-manager.d.ts +6 -31
  90. package/dist/src/state-manager.js +10 -129
  91. package/dist/src/utils/aria.d.ts +6 -1
  92. package/dist/src/utils/aria.js +4 -1
  93. package/dist/src/utils/cache.d.ts +15 -0
  94. package/dist/src/utils/cache.js +34 -0
  95. package/dist/src/utils/context-formatter.d.ts +6 -0
  96. package/dist/src/utils/context-formatter.js +15 -27
  97. package/dist/src/utils/html.d.ts +5 -0
  98. package/dist/src/utils/html.js +71 -0
  99. package/dist/src/utils/markdown-files.d.ts +10 -0
  100. package/dist/src/utils/markdown-files.js +21 -0
  101. package/dist/src/utils/markdown-query.d.ts +6 -0
  102. package/dist/src/utils/markdown-query.js +14 -1
  103. package/dist/src/utils/page-readiness.d.ts +1 -0
  104. package/dist/src/utils/page-readiness.js +1 -1
  105. package/dist/src/utils/secrets.js +2 -2
  106. package/dist/src/utils/strings.d.ts +2 -0
  107. package/dist/src/utils/strings.js +10 -0
  108. package/dist/src/utils/web-element.d.ts +2 -1
  109. package/dist/src/utils/web-element.js +3 -0
  110. package/dist/src/utils/web-sandbox.d.ts +4 -0
  111. package/dist/src/utils/web-sandbox.js +37 -0
  112. package/package.json +20 -3
  113. package/src/action-result.ts +70 -173
  114. package/src/action.ts +16 -130
  115. package/src/ai/captain/web-mode.ts +0 -7
  116. package/src/ai/captain.ts +5 -17
  117. package/src/ai/conversation.ts +9 -32
  118. package/src/ai/driller.ts +3 -16
  119. package/src/ai/experience-compactor.ts +16 -35
  120. package/src/ai/fisherman-tools.ts +1 -1
  121. package/src/ai/fisherman.ts +0 -4
  122. package/src/ai/historian/codeceptjs.ts +4 -2
  123. package/src/ai/historian/playwright.ts +4 -2
  124. package/src/ai/historian/utils.ts +0 -1
  125. package/src/ai/historian.ts +4 -7
  126. package/src/ai/navigator.ts +13 -49
  127. package/src/ai/pilot.ts +6 -16
  128. package/src/ai/planner.ts +56 -44
  129. package/src/ai/provider.ts +85 -145
  130. package/src/ai/quartermaster.ts +34 -41
  131. package/src/ai/rerunner.ts +17 -21
  132. package/src/ai/researcher/cache.ts +10 -16
  133. package/src/ai/researcher/deep-analysis.ts +3 -6
  134. package/src/ai/researcher/parser.ts +1 -1
  135. package/src/ai/researcher/research-result.ts +0 -18
  136. package/src/ai/researcher/sections.ts +5 -1
  137. package/src/ai/researcher.ts +11 -59
  138. package/src/ai/rules.ts +24 -13
  139. package/src/ai/session-analyst.ts +8 -5
  140. package/src/ai/task-agent.ts +3 -30
  141. package/src/ai/tester.ts +12 -73
  142. package/src/ai/tools.ts +119 -106
  143. package/src/commands/compact-command.ts +7 -8
  144. package/src/commands/context-command.ts +1 -1
  145. package/src/commands/context-knowledge-command.ts +1 -1
  146. package/src/commands/experience-command.ts +1 -1
  147. package/src/commands/knows-command.ts +1 -1
  148. package/src/commands/learn-command.ts +3 -1
  149. package/src/components/AddKnowledge.tsx +5 -7
  150. package/src/components/App.tsx +2 -2
  151. package/src/config.ts +12 -0
  152. package/src/experience-tracker.ts +88 -151
  153. package/src/explorbot.ts +17 -51
  154. package/src/explorer.ts +20 -72
  155. package/src/knowledge-tracker.ts +42 -49
  156. package/src/state-manager.ts +15 -156
  157. package/src/utils/aria.ts +11 -2
  158. package/src/utils/cache.ts +40 -0
  159. package/src/utils/context-formatter.ts +12 -15
  160. package/src/utils/html.ts +79 -0
  161. package/src/utils/markdown-files.ts +30 -0
  162. package/src/utils/markdown-query.ts +15 -1
  163. package/src/utils/page-readiness.ts +1 -1
  164. package/src/utils/secrets.ts +2 -3
  165. package/src/utils/strings.ts +12 -0
  166. package/src/utils/web-element.ts +4 -0
  167. package/src/utils/web-sandbox.ts +43 -0
  168. package/dist/boat/api-tester/example/apibot.config.js +0 -30
@@ -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 };
@@ -454,16 +454,16 @@ program
454
454
  await ConfigParser.getInstance().loadConfig({
455
455
  path: options.path || process.cwd(),
456
456
  });
457
+ const { KnowledgeTracker } = await import('../src/knowledge-tracker.js');
458
+ const tracker = new KnowledgeTracker();
457
459
  if (url && description) {
458
- const { KnowledgeTracker } = await import('../src/knowledge-tracker.js');
459
- const tracker = new KnowledgeTracker();
460
460
  const result = tracker.addKnowledge(url, description);
461
461
  const action = result.isNewFile ? 'Created' : 'Updated';
462
462
  console.log(`Knowledge ${action} in: ${result.filename}`);
463
463
  return;
464
464
  }
465
465
  const AddKnowledge = (await import('../src/components/AddKnowledge.js')).default;
466
- render(React.createElement(AddKnowledge, { initialUrl: url || '' }), {
466
+ render(React.createElement(AddKnowledge, { initialUrl: url || '', knowledgeTracker: tracker }), {
467
467
  exitOnCtrlC: false,
468
468
  patchConsole: false,
469
469
  });
@@ -11,14 +11,14 @@ class Documentarian {
11
11
  this.config = config;
12
12
  this.explorer = explorer;
13
13
  }
14
- async document(state, research) {
14
+ async document(state, research, captureState) {
15
15
  const interactiveEnabled = this.config.docs?.interactive === true && this.explorer;
16
16
  if (!interactiveEnabled) {
17
17
  tag('info').log('Documentarian: Using static mode (interactive disabled or no explorer)');
18
18
  return this.documentStatic(state, research);
19
19
  }
20
20
  tag('info').log('Documentarian: Using interactive mode with tools');
21
- return this.documentWithInteraction(state, research);
21
+ return this.documentWithInteraction(state, research, captureState);
22
22
  }
23
23
  async documentStatic(state, research) {
24
24
  try {
@@ -31,11 +31,12 @@ class Documentarian {
31
31
  return this.generateDocumentation(state, this.sanitizeResearch(research), true);
32
32
  }
33
33
  }
34
- async documentWithInteraction(state, research) {
34
+ async documentWithInteraction(state, research, captureState) {
35
+ let meaningfulInteractions = [];
35
36
  try {
36
37
  tag('info').log('Starting interactive exploration...');
37
- const deterministicInteractions = await collectDocInteractions(this.explorer, state, research, this.config);
38
- const meaningfulInteractions = this.getMeaningfulInteractions(deterministicInteractions);
38
+ const deterministicInteractions = await collectDocInteractions(this.explorer, state, research, this.config, captureState);
39
+ meaningfulInteractions = this.getMeaningfulInteractions(deterministicInteractions);
39
40
  if (meaningfulInteractions.length > 0) {
40
41
  tag('success').log(`Collected ${meaningfulInteractions.length} deterministic interactions`);
41
42
  return await this.generateDocumentationWithInteractions(state, research, meaningfulInteractions);
@@ -50,7 +51,16 @@ class Documentarian {
50
51
  }
51
52
  catch (error) {
52
53
  const message = error instanceof Error ? error.message : String(error);
53
- tag('warning').log(`Interactive documentation failed: ${message}. Falling back to static.`);
54
+ tag('warning').log(`Interactive documentation failed: ${message}.`);
55
+ if (meaningfulInteractions.length > 0) {
56
+ tag('info').log(`Preserving ${meaningfulInteractions.length} observed interaction(s) without AI summary.`);
57
+ return this.normalizeDocumentation({
58
+ summary: `Observed ${meaningfulInteractions.length} interaction(s); AI-generated summary was unavailable.`,
59
+ can: [],
60
+ might: [],
61
+ interactions: meaningfulInteractions,
62
+ }, state, research);
63
+ }
54
64
  return this.documentStatic(state, research);
55
65
  }
56
66
  }
@@ -1,23 +1,24 @@
1
1
  import { parseResearchSections } from "../../../../src/ai/researcher/parser.js";
2
+ import { detectFocusArea } from "../../../../src/utils/aria.js";
2
3
  const DEFAULT_MAX_PRIMARY_CANDIDATES = 3;
3
4
  const DEFAULT_MAX_INTERACTIONS = 5;
4
5
  const MAX_LINKS = 15;
5
6
  const DEFAULT_WAIT_MS = 700;
6
7
  const TAB_WAIT_MS = 500;
7
8
  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 = {}) {
9
+ export async function collectDocInteractions(explorer, state, research, config = {}, captureState) {
9
10
  const sections = parseResearchSections(research);
10
11
  const transitions = [];
11
12
  const maxInteractions = getPositiveConfigNumber(config.docs?.maxInteractions, DEFAULT_MAX_INTERACTIONS);
12
13
  const tabGroup = findTabGroup(sections);
13
14
  if (tabGroup) {
14
- transitions.push(...(await exploreTabGroup(explorer, tabGroup, state.url, maxInteractions)));
15
+ transitions.push(...(await exploreTabGroup(explorer, tabGroup, state.url, maxInteractions, captureState)));
15
16
  }
16
17
  for (const candidate of findActionCandidates(sections, config)) {
17
18
  if (transitions.length >= maxInteractions) {
18
19
  break;
19
20
  }
20
- const transition = await executeInteraction(explorer, candidate, state.url, DEFAULT_WAIT_MS);
21
+ const transition = await executeInteraction(explorer, candidate, state.url, DEFAULT_WAIT_MS, captureState);
21
22
  if (!transition) {
22
23
  continue;
23
24
  }
@@ -32,7 +33,7 @@ export function pickDocActionCandidates(research, config = {}) {
32
33
  section: candidate.sectionName,
33
34
  }));
34
35
  }
35
- async function exploreTabGroup(explorer, tabGroup, restoreUrl, maxInteractions) {
36
+ async function exploreTabGroup(explorer, tabGroup, restoreUrl, maxInteractions, captureState) {
36
37
  const transitions = [];
37
38
  for (const element of tabGroup.elements) {
38
39
  if (transitions.length >= maxInteractions) {
@@ -43,7 +44,7 @@ async function exploreTabGroup(explorer, tabGroup, restoreUrl, maxInteractions)
43
44
  container: tabGroup.container,
44
45
  role: 'tab',
45
46
  sectionName: tabGroup.sectionName,
46
- }, restoreUrl, TAB_WAIT_MS);
47
+ }, restoreUrl, TAB_WAIT_MS, captureState);
47
48
  if (!transition) {
48
49
  continue;
49
50
  }
@@ -52,7 +53,7 @@ async function exploreTabGroup(explorer, tabGroup, restoreUrl, maxInteractions)
52
53
  await restoreInteractionState(explorer, restoreUrl, buildPrimaryCommand(tabGroup.elements[0], tabGroup.container));
53
54
  return transitions;
54
55
  }
55
- async function executeInteraction(explorer, candidate, restoreUrl, waitMs) {
56
+ async function executeInteraction(explorer, candidate, restoreUrl, waitMs, captureState) {
56
57
  const beforeState = explorer.getStateManager().getCurrentState();
57
58
  if (!beforeState) {
58
59
  return null;
@@ -73,7 +74,13 @@ async function executeInteraction(explorer, candidate, restoreUrl, waitMs) {
73
74
  newElements: ariaChanges.newCount,
74
75
  removedElements: ariaChanges.removedCount,
75
76
  });
76
- if (urlChanged) {
77
+ if (captureState && isMeaningfulStateTransition(transition)) {
78
+ const screenshot = await captureState(afterState, transition);
79
+ if (screenshot) {
80
+ transition.screenshot = screenshot;
81
+ }
82
+ }
83
+ if (urlChanged || ariaChanges.newCount > 0) {
77
84
  await restoreInteractionState(explorer, restoreUrl);
78
85
  }
79
86
  return transition;
@@ -81,7 +88,7 @@ async function executeInteraction(explorer, candidate, restoreUrl, waitMs) {
81
88
  async function attemptInteraction(explorer, candidate) {
82
89
  const action = explorer.createAction();
83
90
  for (const command of buildClickCommands(candidate.element, candidate.container)) {
84
- const success = await action.attempt(command, buildPurpose(candidate), false);
91
+ const success = await action.attempt(command, buildPurpose(candidate));
85
92
  if (success) {
86
93
  return true;
87
94
  }
@@ -91,14 +98,14 @@ async function attemptInteraction(explorer, candidate) {
91
98
  async function restoreInteractionState(explorer, restoreUrl, primaryCommand) {
92
99
  if (primaryCommand) {
93
100
  const action = explorer.createAction();
94
- const restored = await action.attempt(primaryCommand, `Restore initial state on ${restoreUrl}`, false);
101
+ const restored = await action.attempt(primaryCommand, `Restore initial state on ${restoreUrl}`);
95
102
  if (restored) {
96
103
  await wait(TAB_WAIT_MS);
97
104
  return;
98
105
  }
99
106
  }
100
107
  const action = explorer.createAction();
101
- await action.attempt(`I.amOnPage(${JSON.stringify(restoreUrl)})`, `Restore page ${restoreUrl}`, false);
108
+ await action.attempt(`I.amOnPage(${JSON.stringify(restoreUrl)})`, `Restore page ${restoreUrl}`);
102
109
  }
103
110
  function buildTransition(candidate, beforeState, afterState, changes) {
104
111
  const transition = {
@@ -109,12 +116,42 @@ function buildTransition(candidate, beforeState, afterState, changes) {
109
116
  newCapabilities: collectDiscoveryNotes(afterState, changes),
110
117
  element: buildInteractionElement(candidate),
111
118
  changes,
119
+ targetState: describeTargetState(beforeState, afterState, candidate),
112
120
  };
113
121
  if (changes.urlChanged) {
114
122
  transition.targetUrl = afterState.url;
115
123
  }
116
124
  return transition;
117
125
  }
126
+ function describeTargetState(beforeState, afterState, candidate) {
127
+ const beforeFocus = detectFocusArea(beforeState.ariaSnapshot || null);
128
+ const afterFocus = detectFocusArea(afterState.ariaSnapshot || null);
129
+ if (afterFocus.detected && (!beforeFocus.detected || beforeFocus.name !== afterFocus.name)) {
130
+ return {
131
+ kind: afterFocus.type || 'dialog',
132
+ label: afterFocus.name || candidate.element.name.trim(),
133
+ url: afterState.url,
134
+ };
135
+ }
136
+ const beforePath = beforeState.url.split('?')[0].split('#')[0];
137
+ const afterPath = afterState.url.split('?')[0].split('#')[0];
138
+ const headings = collectHeadings(afterState);
139
+ let kind = 'page';
140
+ if (beforePath === afterPath) {
141
+ kind = 'section';
142
+ }
143
+ return {
144
+ kind,
145
+ label: headings[0] || afterState.title || candidate.element.name.trim(),
146
+ url: afterState.url,
147
+ };
148
+ }
149
+ function isMeaningfulStateTransition(transition) {
150
+ if (transition.targetUrl || transition.changes?.urlChanged) {
151
+ return true;
152
+ }
153
+ return (transition.changes?.newElements || 0) > 0;
154
+ }
118
155
  function buildInteractionElement(candidate) {
119
156
  const element = {
120
157
  role: candidate.role,
@@ -7,9 +7,10 @@ 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
+ import { renderMermaidBody } from "./state-diagram.js";
10
11
  import { getDocPageKey, shouldCrawlDocPath } from "./path-filter.js";
11
12
  import { extractResearchNavigationTargets } from "./research-navigation.js";
12
- import { captureDocumentationScreenshots } from "./screenshots.js";
13
+ import { captureDocumentationScreenshots, captureInteractionScreenshot } from "./screenshots.js";
13
14
  class DocBot {
14
15
  explorBot;
15
16
  configParser;
@@ -96,7 +97,17 @@ class DocBot {
96
97
  screenshot: this.shouldUseScreenshots(),
97
98
  force: true,
98
99
  });
99
- const documentation = await this.documentarian.document(state, research);
100
+ const pagePath = this.getPageFilePath(state.url);
101
+ const documentation = await this.documentarian.document(state, research, async (interactionState, transition) => {
102
+ if (!this.shouldUseScreenshots()) {
103
+ return null;
104
+ }
105
+ return captureInteractionScreenshot(this.explorBot.getExplorer(), interactionState, transition, {
106
+ pageFilePath: pagePath,
107
+ screenshotsDir: this.getScreenshotsDir(),
108
+ config: this.config,
109
+ });
110
+ });
100
111
  const lowSignalReason = this.getLowSignalReason(documentation, research);
101
112
  if (lowSignalReason) {
102
113
  skipped.push({
@@ -118,6 +129,7 @@ class DocBot {
118
129
  mightActions: documentation.might.map((item) => item.action),
119
130
  interactionActions: (documentation.interactions || []).map((item) => item.action),
120
131
  qualityNotes: documentation.qualityNotes || [],
132
+ interactions: documentation.interactions || [],
121
133
  filePath,
122
134
  });
123
135
  documented.add(pageKey);
@@ -142,11 +154,12 @@ class DocBot {
142
154
  });
143
155
  }
144
156
  }
145
- const indexPath = this.saveIndex(effectiveStartPath, pages, skipped, effectiveMaxPages);
157
+ const { indexPath, diagramPath } = this.saveIndex(effectiveStartPath, pages, skipped, effectiveMaxPages);
146
158
  return {
147
159
  pages,
148
160
  skipped,
149
161
  indexPath,
162
+ diagramPath,
150
163
  outputDir: this.configParser.getOutputDir(),
151
164
  };
152
165
  }
@@ -338,6 +351,9 @@ class DocBot {
338
351
  if (documentation.can.length >= minCanActions) {
339
352
  return null;
340
353
  }
354
+ if ((documentation.interactions || []).length > 0) {
355
+ return null;
356
+ }
341
357
  const interactiveCount = this.countInteractiveElements(research);
342
358
  if (interactiveCount >= minInteractiveElements) {
343
359
  return null;
@@ -365,9 +381,12 @@ class DocBot {
365
381
  });
366
382
  }
367
383
  saveIndex(startPath, pages, skipped, maxPages) {
368
- const indexPath = path.join(this.configParser.getOutputDir(), 'spec.md');
369
- writeFileSync(indexPath, renderSpecIndex(this.configParser.getOutputDir(), startPath, pages, skipped, maxPages), 'utf8');
370
- return indexPath;
384
+ const outputDir = this.configParser.getOutputDir();
385
+ const indexPath = path.join(outputDir, 'index.md');
386
+ writeFileSync(indexPath, renderSpecIndex(outputDir, startPath, pages, skipped, maxPages), 'utf8');
387
+ const diagramPath = path.join(outputDir, 'state-diagram.mmd');
388
+ writeFileSync(diagramPath, renderMermaidBody(outputDir, pages), 'utf8');
389
+ return { indexPath, diagramPath };
371
390
  }
372
391
  getPagesDir() {
373
392
  return path.join(this.configParser.getOutputDir(), 'pages');
@@ -1,4 +1,6 @@
1
1
  import path from 'node:path';
2
+ import { buildStateGraph, renderMermaidFromGraph, renderStateMapFromGraph } from "./state-diagram.js";
3
+ import { normalizeInlineText } from "../../../src/utils/strings.js";
2
4
  function renderPageDocumentation(state, documentation, screenshots = []) {
3
5
  const lines = [];
4
6
  lines.push(`# ${state.url}`);
@@ -40,6 +42,10 @@ function renderPageDocumentation(state, documentation, screenshots = []) {
40
42
  }
41
43
  lines.push('');
42
44
  }
45
+ if (transition.screenshot) {
46
+ lines.push(`![${normalizeInlineText(transition.screenshot.title)}](${transition.screenshot.relativePath})`);
47
+ lines.push('');
48
+ }
43
49
  }
44
50
  }
45
51
  lines.push('## User Can');
@@ -90,6 +96,18 @@ function renderSpecIndex(outputDir, startPath, pages, skipped, maxPages) {
90
96
  lines.push(`Pages skipped: ${skipped.length}`);
91
97
  lines.push(`Max pages: ${maxPages}`);
92
98
  lines.push('');
99
+ const graph = buildStateGraph(outputDir, pages);
100
+ lines.push('## State Transitions');
101
+ lines.push('');
102
+ lines.push(`\`\`\`mermaid\n${renderMermaidFromGraph(graph)}\n\`\`\``);
103
+ lines.push('');
104
+ const stateMap = renderStateMapFromGraph(graph);
105
+ if (stateMap) {
106
+ lines.push('### State Index');
107
+ lines.push('');
108
+ lines.push(stateMap);
109
+ lines.push('');
110
+ }
93
111
  lines.push('## Pages');
94
112
  lines.push('');
95
113
  if (pages.length === 0) {
@@ -192,7 +210,4 @@ function ensureSentence(text) {
192
210
  }
193
211
  return `${trimmed}.`;
194
212
  }
195
- function normalizeInlineText(text) {
196
- return text.normalize('NFKC').replace(/\s+/g, ' ').trim();
197
- }
198
213
  export { renderPageDocumentation, renderSpecIndex, ensureSentence, normalizeAction };
@@ -1,6 +1,7 @@
1
1
  import { mkdirSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { parseResearchSections } from "../../../src/ai/researcher/parser.js";
4
+ import { detectFocusArea } from "../../../src/utils/aria.js";
4
5
  import { safeFilename, sanitizeFilename } from "../../../src/utils/strings.js";
5
6
  const DEFAULT_MAX_SECTION_SCREENSHOTS = 8;
6
7
  export async function captureDocumentationScreenshots(explorer, state, research, options) {
@@ -46,6 +47,34 @@ export function getScreenshotSections(research) {
46
47
  }
47
48
  return sections;
48
49
  }
50
+ export async function captureInteractionScreenshot(explorer, state, transition, options) {
51
+ const page = explorer.playwrightHelper?.page;
52
+ if (!page) {
53
+ return null;
54
+ }
55
+ mkdirSync(options.screenshotsDir, { recursive: true });
56
+ const pageName = sanitizeFilename(state.url || 'page') || 'page';
57
+ const stateName = sanitizeFilename(transition.targetState?.label || transition.action) || 'state';
58
+ const filePath = path.join(options.screenshotsDir, safeFilename(`${pageName}_${stateName}`, '.png'));
59
+ const focus = detectFocusArea(state.ariaSnapshot || null);
60
+ try {
61
+ if (focus.detected) {
62
+ await page.locator('[role="dialog"], [role="alertdialog"], [aria-modal="true"]').last().screenshot({ path: filePath });
63
+ }
64
+ else {
65
+ await page.screenshot({ path: filePath });
66
+ }
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ return {
72
+ title: transition.targetState?.label || transition.action,
73
+ path: filePath,
74
+ relativePath: toMarkdownPath(options.pageFilePath, filePath),
75
+ kind: 'state',
76
+ };
77
+ }
49
78
  async function captureFullPageScreenshot(page, pageName, options) {
50
79
  const filePath = path.join(options.screenshotsDir, safeFilename(`${pageName}_page`, '.png'));
51
80
  try {