explorbot 0.2.1 → 0.2.3

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 (115) hide show
  1. package/README.md +1 -1
  2. package/bin/explorbot-cli.ts +52 -37
  3. package/boat/api-tester/src/apibot.ts +4 -2
  4. package/boat/api-tester/src/cli.ts +2 -2
  5. package/boat/api-tester/src/config.ts +39 -8
  6. package/boat/doc-collector/src/ai/documentarian.ts +3 -0
  7. package/boat/doc-collector/src/ai/tools.ts +17 -5
  8. package/boat/doc-collector/src/cli.ts +2 -0
  9. package/boat/doc-collector/src/config.ts +2 -0
  10. package/boat/doc-collector/src/docbot.ts +39 -11
  11. package/boat/doc-collector/src/docs-renderer.ts +18 -4
  12. package/boat/doc-collector/src/interaction-screenshots.ts +160 -0
  13. package/boat/doc-collector/src/screenshots.ts +22 -14
  14. package/boat/doc-collector/src/state-diagram.ts +61 -14
  15. package/boat/prima/bin/prima-cli.ts +5 -0
  16. package/boat/prima/package.json +16 -0
  17. package/boat/prima/src/cli.ts +222 -0
  18. package/boat/prima/src/envelope.ts +141 -0
  19. package/boat/prima/src/prima.ts +705 -0
  20. package/boat/prima/src/pw-parser.ts +17 -0
  21. package/boat/prima/src/pw-registry.ts +75 -0
  22. package/dist/bin/explorbot-cli.js +44 -31
  23. package/dist/boat/api-tester/src/apibot.js +3 -2
  24. package/dist/boat/api-tester/src/cli.js +2 -2
  25. package/dist/boat/api-tester/src/config.js +36 -8
  26. package/dist/boat/doc-collector/src/ai/documentarian.js +3 -0
  27. package/dist/boat/doc-collector/src/ai/tools.js +10 -4
  28. package/dist/boat/doc-collector/src/cli.js +2 -0
  29. package/dist/boat/doc-collector/src/config.js +1 -0
  30. package/dist/boat/doc-collector/src/docbot.js +36 -10
  31. package/dist/boat/doc-collector/src/docs-renderer.js +17 -3
  32. package/dist/boat/doc-collector/src/interaction-screenshots.js +156 -0
  33. package/dist/boat/doc-collector/src/screenshots.js +23 -14
  34. package/dist/boat/doc-collector/src/state-diagram.js +57 -13
  35. package/dist/boat/prima/bin/prima-cli.js +4 -0
  36. package/dist/boat/prima/src/cli.js +200 -0
  37. package/dist/boat/prima/src/envelope.js +116 -0
  38. package/dist/boat/prima/src/prima.js +635 -0
  39. package/dist/boat/prima/src/pw-parser.js +18 -0
  40. package/dist/boat/prima/src/pw-registry.js +66 -0
  41. package/dist/models.json +3 -0
  42. package/dist/package.json +9 -2
  43. package/dist/src/action.d.ts +5 -2
  44. package/dist/src/action.js +5 -5
  45. package/dist/src/ai/captain/mixin.js +3 -4
  46. package/dist/src/ai/captain/web-mode.js +1 -1
  47. package/dist/src/ai/navigator.d.ts +4 -0
  48. package/dist/src/ai/navigator.js +11 -6
  49. package/dist/src/ai/planner.d.ts +1 -0
  50. package/dist/src/ai/planner.js +6 -0
  51. package/dist/src/ai/researcher/locators.js +1 -1
  52. package/dist/src/ai/researcher.js +1 -1
  53. package/dist/src/ai/task-agent.js +1 -1
  54. package/dist/src/ai/tester.d.ts +1 -0
  55. package/dist/src/ai/tester.js +13 -0
  56. package/dist/src/application-spec-contract.d.ts +8 -0
  57. package/dist/src/application-spec-contract.js +8 -0
  58. package/dist/src/application-spec.d.ts +15 -0
  59. package/dist/src/application-spec.js +71 -0
  60. package/dist/src/browser-server.d.ts +12 -6
  61. package/dist/src/browser-server.js +74 -19
  62. package/dist/src/commands/clean-command.js +2 -7
  63. package/dist/src/commands/init-command.d.ts +5 -0
  64. package/dist/src/commands/init-command.js +119 -1
  65. package/dist/src/commands/navigate-command.js +1 -1
  66. package/dist/src/commands/research-command.js +1 -1
  67. package/dist/src/commands/sites-command.d.ts +6 -0
  68. package/dist/src/commands/sites-command.js +23 -0
  69. package/dist/src/components/InitWizard.d.ts +10 -0
  70. package/dist/src/components/InitWizard.js +133 -0
  71. package/dist/src/components/InputReadline.d.ts +1 -0
  72. package/dist/src/components/InputReadline.js +7 -4
  73. package/dist/src/config.d.ts +24 -5
  74. package/dist/src/config.js +146 -37
  75. package/dist/src/explorbot.d.ts +9 -0
  76. package/dist/src/explorbot.js +24 -5
  77. package/dist/src/explorer.d.ts +5 -1
  78. package/dist/src/explorer.js +54 -19
  79. package/dist/src/global-config.d.ts +22 -0
  80. package/dist/src/global-config.js +117 -0
  81. package/dist/src/knowledge-tracker.d.ts +5 -1
  82. package/dist/src/knowledge-tracker.js +14 -1
  83. package/dist/src/utils/cli-name.js +6 -2
  84. package/dist/src/utils/test-files.js +1 -2
  85. package/dist/src/utils/url-matcher.d.ts +1 -0
  86. package/dist/src/utils/url-matcher.js +9 -0
  87. package/models.json +3 -0
  88. package/package.json +9 -2
  89. package/src/action.ts +9 -5
  90. package/src/ai/captain/mixin.ts +3 -3
  91. package/src/ai/captain/web-mode.ts +1 -1
  92. package/src/ai/navigator.ts +12 -7
  93. package/src/ai/planner.ts +7 -0
  94. package/src/ai/researcher/locators.ts +1 -1
  95. package/src/ai/researcher.ts +1 -1
  96. package/src/ai/task-agent.ts +1 -1
  97. package/src/ai/tester.ts +15 -0
  98. package/src/application-spec-contract.ts +10 -0
  99. package/src/application-spec.ts +87 -0
  100. package/src/browser-server.ts +74 -19
  101. package/src/commands/clean-command.ts +1 -6
  102. package/src/commands/init-command.ts +146 -1
  103. package/src/commands/navigate-command.ts +1 -1
  104. package/src/commands/research-command.ts +1 -1
  105. package/src/commands/sites-command.ts +27 -0
  106. package/src/components/InitWizard.tsx +166 -0
  107. package/src/components/InputReadline.tsx +8 -4
  108. package/src/config.ts +162 -39
  109. package/src/explorbot.ts +30 -5
  110. package/src/explorer.ts +59 -20
  111. package/src/global-config.ts +148 -0
  112. package/src/knowledge-tracker.ts +17 -1
  113. package/src/utils/cli-name.ts +5 -2
  114. package/src/utils/test-files.ts +1 -2
  115. package/src/utils/url-matcher.ts +10 -0
@@ -0,0 +1,156 @@
1
+ import { writeFileSync } from 'node:fs';
2
+ import pixelmatch from 'pixelmatch';
3
+ import { PNG } from 'pngjs';
4
+ const REGION_PADDING = 30;
5
+ const SCREENSHOT_OPTIONS = { animations: 'disabled', caret: 'hide' };
6
+ export async function captureInteractionBefore(page) {
7
+ await removeVisualAnnotations(page);
8
+ try {
9
+ return await page.screenshot(SCREENSHOT_OPTIONS);
10
+ }
11
+ catch {
12
+ return null;
13
+ }
14
+ }
15
+ export async function captureInteractionAfter(page, beforeScreenshot, filePath, detectUnmarkedOverlay = false) {
16
+ if (!beforeScreenshot)
17
+ return 'failed';
18
+ await removeVisualAnnotations(page);
19
+ try {
20
+ const afterScreenshot = await page.screenshot(SCREENSHOT_OPTIONS);
21
+ const before = PNG.sync.read(beforeScreenshot);
22
+ const after = PNG.sync.read(afterScreenshot);
23
+ if (before.width !== after.width || before.height !== after.height)
24
+ return 'failed';
25
+ const changedPixels = findChangedPixelBounds(before, after);
26
+ if (!changedPixels)
27
+ return 'unchanged';
28
+ const fullViewportChanged = changedPixels.x === 0 && changedPixels.y === 0 && changedPixels.width === after.width && changedPixels.height === after.height;
29
+ const changedRegion = addPadding(changedPixels, after.width, after.height);
30
+ const overlayRegion = fullViewportChanged ? await findOverlayRegion(page, after, detectUnmarkedOverlay) : null;
31
+ saveRegion(after, overlayRegion || changedRegion, filePath);
32
+ return 'captured';
33
+ }
34
+ catch {
35
+ return 'failed';
36
+ }
37
+ }
38
+ export function findChangedRegion(beforeScreenshot, afterScreenshot, padding = REGION_PADDING) {
39
+ const before = PNG.sync.read(beforeScreenshot);
40
+ const after = PNG.sync.read(afterScreenshot);
41
+ if (before.width !== after.width || before.height !== after.height)
42
+ return null;
43
+ const changedPixels = findChangedPixelBounds(before, after);
44
+ return changedPixels ? addPadding(changedPixels, before.width, before.height, padding) : null;
45
+ }
46
+ function saveRegion(after, region, filePath) {
47
+ const cropped = new PNG({ width: region.width, height: region.height });
48
+ PNG.bitblt(after, cropped, region.x, region.y, region.width, region.height, 0, 0);
49
+ writeFileSync(filePath, PNG.sync.write(cropped));
50
+ }
51
+ function findChangedPixelBounds(before, after) {
52
+ const diff = Buffer.alloc(before.width * before.height * 4);
53
+ const changedPixels = pixelmatch(before.data, after.data, diff, before.width, before.height, { diffMask: true });
54
+ if (changedPixels === 0)
55
+ return null;
56
+ let left = before.width;
57
+ let top = before.height;
58
+ let right = 0;
59
+ let bottom = 0;
60
+ for (let y = 0; y < before.height; y++) {
61
+ for (let x = 0; x < before.width; x++) {
62
+ if (diff[(y * before.width + x) * 4 + 3] === 0)
63
+ continue;
64
+ left = Math.min(left, x);
65
+ top = Math.min(top, y);
66
+ right = Math.max(right, x);
67
+ bottom = Math.max(bottom, y);
68
+ }
69
+ }
70
+ return { x: left, y: top, width: right - left + 1, height: bottom - top + 1 };
71
+ }
72
+ function addPadding(region, imageWidth, imageHeight, padding = REGION_PADDING) {
73
+ const x = Math.max(0, region.x - padding);
74
+ const y = Math.max(0, region.y - padding);
75
+ const maxX = Math.min(imageWidth, region.x + region.width + padding);
76
+ const maxY = Math.min(imageHeight, region.y + region.height + padding);
77
+ return { x, y, width: maxX - x, height: maxY - y };
78
+ }
79
+ async function removeVisualAnnotations(page) {
80
+ try {
81
+ await page.locator('[data-explorbot-annotation]').evaluateAll((elements) => {
82
+ for (const element of elements)
83
+ element.remove();
84
+ });
85
+ }
86
+ catch { }
87
+ }
88
+ async function findOverlayRegion(page, image, detectUnmarkedOverlay) {
89
+ let box = null;
90
+ try {
91
+ const dialogs = page.locator('[role="dialog"]:visible, [role="alertdialog"]:visible, [aria-modal="true"]:visible');
92
+ if ((await dialogs.count()) > 0)
93
+ box = await dialogs.last().boundingBox();
94
+ }
95
+ catch { }
96
+ if (!box && detectUnmarkedOverlay) {
97
+ try {
98
+ box = await findUnmarkedOverlay(page);
99
+ }
100
+ catch { }
101
+ }
102
+ try {
103
+ const viewport = page.viewportSize();
104
+ if (!box || !viewport)
105
+ return null;
106
+ const scaleX = image.width / viewport.width;
107
+ const scaleY = image.height / viewport.height;
108
+ const x = Math.max(0, Math.floor(box.x * scaleX) - REGION_PADDING);
109
+ const y = Math.max(0, Math.floor(box.y * scaleY) - REGION_PADDING);
110
+ const maxX = Math.min(image.width, Math.ceil((box.x + box.width) * scaleX) + REGION_PADDING);
111
+ const maxY = Math.min(image.height, Math.ceil((box.y + box.height) * scaleY) + REGION_PADDING);
112
+ if (maxX <= x || maxY <= y)
113
+ return null;
114
+ return { x, y, width: maxX - x, height: maxY - y };
115
+ }
116
+ catch {
117
+ return null;
118
+ }
119
+ }
120
+ async function findUnmarkedOverlay(page) {
121
+ return page.evaluate(() => {
122
+ const elements = [...document.body.querySelectorAll('*')].map((element) => {
123
+ const style = getComputedStyle(element);
124
+ const box = element.getBoundingClientRect();
125
+ const zIndex = Number.parseInt(style.zIndex, 10);
126
+ return { element, style, box, zIndex, area: box.width * box.height };
127
+ });
128
+ const isVisibleLayer = ({ style, box, zIndex }) => {
129
+ if (style.visibility === 'hidden' || style.display === 'none' || Number(style.opacity) === 0)
130
+ return false;
131
+ if (box.width <= 0 || box.height <= 0)
132
+ return false;
133
+ if (style.position !== 'fixed' && style.position !== 'absolute')
134
+ return false;
135
+ return Number.isFinite(zIndex);
136
+ };
137
+ const backdropZIndex = elements.filter((item) => isVisibleLayer(item) && item.box.width >= window.innerWidth && item.box.height >= window.innerHeight).reduce((highest, item) => Math.max(highest, item.zIndex), Number.NEGATIVE_INFINITY);
138
+ if (!Number.isFinite(backdropZIndex))
139
+ return null;
140
+ const candidates = elements
141
+ .filter((item) => {
142
+ if (!isVisibleLayer(item))
143
+ return false;
144
+ if (item.box.width >= window.innerWidth && item.box.height >= window.innerHeight)
145
+ return false;
146
+ if (item.zIndex < backdropZIndex)
147
+ return false;
148
+ return item.element.matches('button, input, select, textarea, a[href]') || !!item.element.querySelector('button, input, select, textarea, a[href]');
149
+ })
150
+ .sort((left, right) => right.zIndex - left.zIndex || left.area - right.area);
151
+ const box = candidates[0]?.box;
152
+ if (!box)
153
+ return null;
154
+ return { x: box.x, y: box.y, width: box.width, height: box.height };
155
+ });
156
+ }
@@ -1,8 +1,8 @@
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";
5
4
  import { safeFilename, sanitizeFilename } from "../../../src/utils/strings.js";
5
+ import { captureInteractionAfter, captureInteractionBefore } from "./interaction-screenshots.js";
6
6
  const DEFAULT_MAX_SECTION_SCREENSHOTS = 8;
7
7
  export async function captureDocumentationScreenshots(explorer, state, research, options) {
8
8
  const page = explorer.page;
@@ -47,7 +47,7 @@ export function getScreenshotSections(research) {
47
47
  }
48
48
  return sections;
49
49
  }
50
- export async function captureInteractionScreenshot(explorer, state, transition, options) {
50
+ export async function captureInteractionScreenshot(explorer, beforeScreenshot, state, transition, options) {
51
51
  const page = explorer.page;
52
52
  if (!page) {
53
53
  return null;
@@ -55,19 +55,13 @@ export async function captureInteractionScreenshot(explorer, state, transition,
55
55
  mkdirSync(options.screenshotsDir, { recursive: true });
56
56
  const pageName = sanitizeFilename(state.url || 'page') || 'page';
57
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 {
58
+ const stateId = state.id ? `_${state.id}` : '';
59
+ const filePath = path.join(options.screenshotsDir, safeFilename(`${pageName}_${stateName}${stateId}`, '.png'));
60
+ const result = await captureInteractionAfter(page, beforeScreenshot, filePath, transition.changes?.urlChanged !== true);
61
+ if (result === 'unchanged')
62
+ return null;
63
+ if (result === 'failed' && !(await captureViewport(page, filePath)))
69
64
  return null;
70
- }
71
65
  return {
72
66
  title: transition.targetState?.label || transition.action,
73
67
  path: filePath,
@@ -75,6 +69,21 @@ export async function captureInteractionScreenshot(explorer, state, transition,
75
69
  kind: 'state',
76
70
  };
77
71
  }
72
+ export async function captureBeforeInteraction(explorer) {
73
+ const page = explorer.page;
74
+ if (!page)
75
+ return null;
76
+ return captureInteractionBefore(page);
77
+ }
78
+ async function captureViewport(page, filePath) {
79
+ try {
80
+ await page.screenshot({ path: filePath });
81
+ return true;
82
+ }
83
+ catch {
84
+ return false;
85
+ }
86
+ }
78
87
  async function captureFullPageScreenshot(page, pageName, options) {
79
88
  const filePath = path.join(options.screenshotsDir, safeFilename(`${pageName}_page`, '.png'));
80
89
  try {
@@ -60,8 +60,11 @@ function buildStateGraph(outputDir, pages) {
60
60
  if (!targetId) {
61
61
  continue;
62
62
  }
63
- const pairKey = `${sourceId}>${targetId}`;
64
- if (adjacency.get(targetId)?.has(sourceId)) {
63
+ if (adjacency.get(sourceId)?.has(targetId)) {
64
+ continue;
65
+ }
66
+ if (createsCycle(sourceId, targetId, adjacency)) {
67
+ const pairKey = `${sourceId}>${targetId}`;
65
68
  if (drawnBack.has(pairKey)) {
66
69
  continue;
67
70
  }
@@ -69,9 +72,6 @@ function buildStateGraph(outputDir, pages) {
69
72
  edges.push({ source: sourceId, target: targetId, action: transition.action, isBack: true });
70
73
  continue;
71
74
  }
72
- if (adjacency.get(sourceId)?.has(targetId) || createsCycle(sourceId, targetId, adjacency)) {
73
- continue;
74
- }
75
75
  adjacency.get(sourceId)?.add(targetId);
76
76
  edges.push({ source: sourceId, target: targetId, action: transition.action, isBack: false });
77
77
  }
@@ -90,8 +90,8 @@ function buildStateGraph(outputDir, pages) {
90
90
  function renderMermaidBody(outputDir, pages) {
91
91
  return renderMermaidFromGraph(buildStateGraph(outputDir, pages));
92
92
  }
93
- function renderMermaidFromGraph(graph) {
94
- const lines = ['flowchart TD'];
93
+ function renderMermaidFromGraph(graph, compact = false) {
94
+ const lines = [compact ? 'flowchart LR' : 'flowchart TD'];
95
95
  if (graph.pages.length === 0) {
96
96
  lines.push(' empty["No documented states"]');
97
97
  return lines.join('\n');
@@ -102,6 +102,12 @@ function renderMermaidFromGraph(graph) {
102
102
  if (!children || children.length === 0) {
103
103
  continue;
104
104
  }
105
+ if (compact) {
106
+ for (const child of children) {
107
+ lines.push(` ${renderNodeLine(child)}`);
108
+ }
109
+ continue;
110
+ }
105
111
  lines.push(` subgraph sg_${page.id} ["${escapeMermaidLabel(page.label)} — transient states"]`);
106
112
  for (const child of children) {
107
113
  lines.push(` ${renderNodeLine(child)}`);
@@ -109,11 +115,13 @@ function renderMermaidFromGraph(graph) {
109
115
  lines.push(' end');
110
116
  }
111
117
  for (const edge of graph.edges) {
112
- let arrow = '-->';
113
- if (edge.isBack) {
114
- arrow = '-.->';
118
+ const arrow = edge.isBack ? '-.->' : '-->';
119
+ if (compact) {
120
+ lines.push(` ${edge.source} ${arrow} ${edge.target}`);
121
+ }
122
+ else {
123
+ lines.push(` ${edge.source} ${arrow}|"${escapeMermaidLabel(edge.action)}"| ${edge.target}`);
115
124
  }
116
- lines.push(` ${edge.source} ${arrow}|"${escapeMermaidLabel(edge.action)}"| ${edge.target}`);
117
125
  }
118
126
  lines.push(' classDef page fill:#dbeafe,stroke:#2563eb,color:#0f172a;');
119
127
  lines.push(' classDef dialog fill:#ffedd5,stroke:#ea580c,color:#0f172a;');
@@ -153,6 +161,42 @@ function renderStateMapFromGraph(graph) {
153
161
  }
154
162
  return rows.join('\n');
155
163
  }
164
+ function renderPageStateDiagram(label, url, interactions) {
165
+ const targets = new Map();
166
+ let index = 0;
167
+ for (const interaction of interactions) {
168
+ const targetState = interaction.targetState;
169
+ if (!targetState) {
170
+ continue;
171
+ }
172
+ const key = `${targetState.kind}:${targetState.label}:${normalizeUrl(targetState.url)}`;
173
+ if (targets.has(key)) {
174
+ continue;
175
+ }
176
+ targets.set(key, {
177
+ node: { id: `target${index++}`, kind: targetState.kind, label: targetState.label, subLabel: targetState.kind },
178
+ action: interaction.action,
179
+ screenshot: interaction.screenshot,
180
+ });
181
+ }
182
+ if (targets.size === 0) {
183
+ return '';
184
+ }
185
+ const lines = ['flowchart LR'];
186
+ lines.push(` ${renderNodeLine({ id: 'self', kind: 'page', label, subLabel: url })}`);
187
+ for (const target of targets.values()) {
188
+ lines.push(` ${renderNodeLine(target.node)}`);
189
+ }
190
+ for (const target of targets.values()) {
191
+ lines.push(` self -->|"${escapeMermaidLabel(target.action)}"| ${target.node.id}`);
192
+ }
193
+ for (const target of targets.values()) {
194
+ if (target.screenshot) {
195
+ lines.push(` click ${target.node.id} "${target.screenshot.relativePath}" "${escapeMermaidLabel(target.screenshot.title)}"`);
196
+ }
197
+ }
198
+ return lines.join('\n');
199
+ }
156
200
  function renderNodeLine(node) {
157
201
  const label = `${escapeMermaidLabel(node.label)}<br/>${escapeMermaidLabel(node.subLabel)}`;
158
202
  if (node.kind === 'dialog' || node.kind === 'modal') {
@@ -183,7 +227,7 @@ function createsCycle(sourceId, targetId, adjacency) {
183
227
  return false;
184
228
  }
185
229
  function escapeMermaidLabel(value) {
186
- return normalizeInlineText(value).replaceAll('&', '&amp;').replaceAll('"', '&quot;').replaceAll('|', '&#124;');
230
+ return normalizeInlineText(value).replaceAll('&', '&amp;').replaceAll('"', '&quot;').replaceAll('#', '&#35;').replaceAll('<', '&#60;').replaceAll('>', '&#62;').replaceAll('|', '&#124;');
187
231
  }
188
232
  function escapeTable(value) {
189
233
  return normalizeInlineText(value).replaceAll('|', '\\|');
@@ -197,4 +241,4 @@ function classForKind(kind) {
197
241
  }
198
242
  return 'dialog';
199
243
  }
200
- export { buildStateGraph, renderMermaidBody, renderMermaidFromGraph, renderStateMapFromGraph };
244
+ export { buildStateGraph, renderMermaidBody, renderMermaidFromGraph, renderPageStateDiagram, renderStateMapFromGraph };
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { createPrimaCommands } from "../src/cli.js";
3
+ const program = createPrimaCommands('prima');
4
+ program.parse();
@@ -0,0 +1,200 @@
1
+ import { Command } from 'commander';
2
+ import dedent from 'dedent';
3
+ import { keepServerRunning } from "../../../src/browser-server.js";
4
+ import { browserErrorMessage } from "../../../src/utils/browser-errors.js";
5
+ import { setPreserveConsoleLogs } from "../../../src/utils/logger.js";
6
+ import { renderEnvelope } from "./envelope.js";
7
+ import { Prima } from "./prima.js";
8
+ const helpContract = dedent `
9
+ Prima drives a browser that is already open. One command per process; every command
10
+ prints a plain-text envelope on stdout and exits 0 when ok, 1 when not.
11
+
12
+ TIERS - choose by what you hold, not by how hard the step looks
13
+ pw <fn> Precise. A Playwright function expression built from a locator you
14
+ already verified. No AI on the happy path.
15
+ prima pw "({ page }) => page.click('[data-test=submit]')"
16
+ click / fill One action described in words; AI resolves it on the current page.
17
+ prima click "the primary action button in the header"
18
+ prima fill "the search box" "a search term"
19
+ do <steps...> Several described steps, run tester-style in one process.
20
+ prima do "open the account menu" "choose the settings entry"
21
+ Never pass a locator or a function expression to click/fill/do - describe the target.
22
+ Never pass a description to pw - it takes executable code only.
23
+
24
+ LOOP
25
+ prima go <url|path|words> reach the page you want to work on
26
+ prima research once per new page; returns verified locators
27
+ prima pw "..." drive the page with those locators
28
+ prima verify "..." assert the outcome (prima ask "..." to inspect instead)
29
+ Fall back to click/fill/do whenever research left you no locator to hold.
30
+
31
+ ENVELOPE
32
+ ### Result ok, command, healed, used
33
+ ### Page url, title, state hash, visit count
34
+ ### Changes what the accessibility tree gained or lost
35
+ ### Answer | ### Research | ### Verdict output of ask, research, verify
36
+ ### Failure error, reasoning, healing attempts, compact ARIA of the page
37
+ ### Instance the browser you are on and the other instances running
38
+ ### Artifacts paths to the full aria.yml, page.html and network.jsonl
39
+ used: is code that already executed - CodeceptJS steps to copy as they are, except
40
+ for pw, whose Playwright expression a test needs inside I.usePlaywrightTo(...).
41
+ Log lines can precede the envelope; start parsing at the first ### line.
42
+
43
+ HEALING AND FAILURE
44
+ A failed action is retried by AI along a different route; healed: true means the
45
+ outcome was reached another way and used: holds the code that worked.
46
+ --no-heal skips that and fails fast.
47
+ Failures print compact ARIA inline, so retarget from the envelope itself and open
48
+ the artifact files only when the inline snapshot is not enough.
49
+
50
+ SESSIONS
51
+ By default prima attaches to the playwright-cli browser of this workspace and works
52
+ on the tabs it already has open; driving the same session from both tools is the
53
+ intended usage.
54
+ playwright-cli open <url> the session prima attaches to
55
+ --pw-session <title> which playwright-cli session, when several are open
56
+ --endpoint <ep> attach to a browser server endpoint directly
57
+ prima browser start a prima-owned browser instead, when no session is open
58
+ --instance <name> which prima-owned browser you talk to; parallel work
59
+ needs one each
60
+ --session [file] cookies and storage persisted across processes; ignored
61
+ while attached, the attached session keeps its own
62
+ Prima never launches a browser implicitly and never closes an attached one - it
63
+ disconnects. browser list shows both kinds; ### Instance names the one you are on.
64
+ Every browser is reached over a Playwright browser-server endpoint, which needs the
65
+ Node build - run prima as "npx explorbot prima ..." or through the published prima
66
+ bin; from source under Bun the connection does not open.
67
+ When no AI model is usable pw still works; for everything else drive
68
+ playwright-cli directly.
69
+ Parsed but not active yet: --framework, so reported code is CodeceptJS whatever
70
+ you pass.
71
+ `;
72
+ function buildOptions(options) {
73
+ return {
74
+ verbose: options.verbose || options.debug,
75
+ config: options.config,
76
+ path: options.path,
77
+ instance: options.instance,
78
+ session: options.session,
79
+ heal: options.heal,
80
+ ephemeral: options.ephemeral,
81
+ framework: options.framework,
82
+ noVision: options.vision === false,
83
+ url: options.url,
84
+ baseUrl: options.baseUrl,
85
+ show: options.show,
86
+ headless: options.headless,
87
+ endpoint: options.endpoint,
88
+ pwSession: options.pwSession,
89
+ };
90
+ }
91
+ function addCommonOptions(cmd) {
92
+ return cmd
93
+ .option('-v, --verbose', 'Enable verbose logging')
94
+ .option('--debug', 'Enable debug logging (same as --verbose)')
95
+ .option('-c, --config <path>', 'Path to explorbot configuration file')
96
+ .option('-p, --path <path>', 'Working directory path')
97
+ .option('-i, --instance <name>', 'Browser instance to drive')
98
+ .option('--session [file]', 'Persist cookies and storage to a session file')
99
+ .option('--no-heal', 'Fail immediately instead of letting AI retry a failed action')
100
+ .option('--ephemeral', 'Keep no state between runs; applies to config-free runs, where output goes to a temp directory')
101
+ .option('--framework <name>', 'Not active yet: framework the reported code targets, codeceptjs or playwright')
102
+ .option('--url <url>', 'Page to open when the session has no page yet')
103
+ .option('--endpoint <ep>', 'Websocket endpoint of a browser server to attach to, skipping discovery')
104
+ .option('--pw-session <title>', 'Title of the playwright-cli session to attach to');
105
+ }
106
+ function primaFor(options) {
107
+ setPreserveConsoleLogs(true);
108
+ if (options.ephemeral)
109
+ process.env.EXPLORBOT_EPHEMERAL = '1';
110
+ return new Prima(buildOptions(options));
111
+ }
112
+ async function runPrima(options, command, run) {
113
+ const prima = primaFor(options);
114
+ let envelope;
115
+ try {
116
+ await prima.start();
117
+ envelope = await run(prima);
118
+ }
119
+ catch (error) {
120
+ envelope = await prima.toolFailureEnvelope(command, error);
121
+ }
122
+ console.log(renderEnvelope(envelope));
123
+ await prima.stop().catch(() => { });
124
+ process.exit(envelope.ok ? 0 : 1);
125
+ }
126
+ async function runBrowser(options, run) {
127
+ let ok = false;
128
+ try {
129
+ ok = await run(primaFor(options));
130
+ }
131
+ catch (error) {
132
+ console.error(browserErrorMessage(error));
133
+ process.exit(1);
134
+ }
135
+ process.exit(ok ? 0 : 1);
136
+ }
137
+ export function createPrimaCommands(name = 'prima') {
138
+ const cmd = new Command(name);
139
+ cmd.description('Drive an already-open browser one command at a time and report back in a plain-text envelope');
140
+ cmd.addHelpText('after', `\n${helpContract}`);
141
+ addCommonOptions(cmd.command('pw <fn>').description('Run a Playwright function expression against the open page')).action(async (fn, options) => {
142
+ await runPrima(options, `pw ${fn}`, (prima) => prima.pw(fn));
143
+ });
144
+ addCommonOptions(cmd.command('do <instructions...>').description('Run high-level instructions tester-style, one argument per instruction')).action(async (instructions, options) => {
145
+ await runPrima(options, `do ${instructions.join(' ')}`, (prima) => prima.do(instructions));
146
+ });
147
+ addCommonOptions(cmd.command('click <target>').description('Click an element described in plain words')).action(async (target, options) => {
148
+ await runPrima(options, `click ${target}`, (prima) => prima.click(target));
149
+ });
150
+ addCommonOptions(cmd.command('fill <field> <value>').description('Fill a field described in plain words')).action(async (field, value, options) => {
151
+ await runPrima(options, `fill ${field} ${value}`, (prima) => prima.fill(field, value));
152
+ });
153
+ addCommonOptions(cmd.command('ask <question>').description('Answer a question about the current page').option('--no-vision', 'Answer from page structure only, without a screenshot')).action(async (question, options) => {
154
+ await runPrima(options, `ask ${question}`, (prima) => prima.ask(question));
155
+ });
156
+ addCommonOptions(cmd.command('verify <assertion>').alias('assert').description('Assert a statement about the current page')).action(async (assertion, options) => {
157
+ await runPrima(options, `verify ${assertion}`, (prima) => prima.verify(assertion));
158
+ });
159
+ addCommonOptions(cmd.command('research').description('Map the current page and return verified locators').option('--data', 'Include data extraction in the map').option('--deep', 'Expand hidden elements for a deeper map').option('--fresh', 'Ignore the cached map and research the page again')).action(async (options) => {
160
+ await runPrima(options, 'research', (prima) => prima.research({ data: options.data, deep: options.deep, fresh: options.fresh }));
161
+ });
162
+ addCommonOptions(cmd.command('go <target>').description('Navigate to a url, a path, or a page described in plain words')).action(async (target, options) => {
163
+ if (URL.canParse(target))
164
+ options.baseUrl = target;
165
+ await runPrima(options, `go ${target}`, (prima) => prima.go(target));
166
+ });
167
+ const browser = cmd.command('browser').description('Manage the browsers prima drives');
168
+ addCommonOptions(browser.command('start').description('Start a prima-owned browser and hold it open until Ctrl+C'))
169
+ .option('-s, --show', 'Launch the browser in a visible window')
170
+ .option('--headless', 'Launch the browser without a window')
171
+ .action(async (options) => {
172
+ await runBrowser(options, async (prima) => {
173
+ await prima.browserStart();
174
+ console.log(await prima.browserStatus());
175
+ return keepServerRunning(() => prima.browserStop());
176
+ });
177
+ });
178
+ addCommonOptions(browser.command('stop').description('Stop the browser of this instance'))
179
+ .option('--all', 'Stop every running instance')
180
+ .action(async (options) => {
181
+ await runBrowser(options, async (prima) => {
182
+ const stopped = await prima.browserStop(options.all);
183
+ console.log(await prima.browserStatus());
184
+ return stopped;
185
+ });
186
+ });
187
+ addCommonOptions(browser.command('status').description('Report the browser of this instance')).action(async (options) => {
188
+ await runBrowser(options, async (prima) => {
189
+ console.log(await prima.browserStatus());
190
+ return true;
191
+ });
192
+ });
193
+ addCommonOptions(browser.command('list').description('List every browser instance that is running')).action(async (options) => {
194
+ await runBrowser(options, async (prima) => {
195
+ console.log(await prima.browserList());
196
+ return true;
197
+ });
198
+ });
199
+ return cmd;
200
+ }
@@ -0,0 +1,116 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ export function renderEnvelope(data) {
4
+ const sections = [renderResult(data), renderPage(data), renderOutcome(data), ...renderFailure(data), renderInstance(data.instance), renderArtifacts(data)];
5
+ return sections.filter((section) => section).join('\n\n');
6
+ }
7
+ export function writeArtifacts(dir, snapshot) {
8
+ mkdirSync(dir, { recursive: true });
9
+ const paths = {
10
+ aria: path.resolve(dir, 'aria.yml'),
11
+ html: path.resolve(dir, 'page.html'),
12
+ network: path.resolve(dir, 'network.jsonl'),
13
+ };
14
+ writeFileSync(paths.aria, snapshot.aria ?? '', 'utf-8');
15
+ writeFileSync(paths.html, snapshot.html ?? '', 'utf-8');
16
+ writeFileSync(paths.network, snapshot.requests.map((request) => `${JSON.stringify(request)}\n`).join(''), 'utf-8');
17
+ return paths;
18
+ }
19
+ function renderResult(data) {
20
+ const lines = [`ok: ${data.ok}`, `command: ${data.command}`];
21
+ const healed = renderHealed(data);
22
+ if (healed)
23
+ lines.push(healed);
24
+ if (data.used?.length)
25
+ lines.push(`used: ${data.used.join('; ')}`);
26
+ return section('Result', lines.join('\n'));
27
+ }
28
+ function renderHealed(data) {
29
+ if (data.healed === undefined)
30
+ return null;
31
+ if (data.healNote)
32
+ return `healed: ${data.healed} (${data.healNote})`;
33
+ return `healed: ${data.healed}`;
34
+ }
35
+ function renderPage(data) {
36
+ const { url, previousUrl, title, state, visits } = data.page;
37
+ const urlLabel = `url: ${url}`;
38
+ const stateLabel = `state: ${state}`;
39
+ const width = Math.max(urlLabel.length, stateLabel.length) + 3;
40
+ let changedMarker = '';
41
+ if (previousUrl && previousUrl !== url)
42
+ changedMarker = `(changed: ${previousUrl} → ${url})`;
43
+ const lines = [align(urlLabel, changedMarker, width), `title: ${title}`, align(stateLabel, `(visit #${visits})`, width)];
44
+ return section('Page', lines.join('\n'));
45
+ }
46
+ function renderOutcome(data) {
47
+ if (data.changes)
48
+ return section('Changes', data.changes);
49
+ if (data.answer)
50
+ return section('Answer', data.answer);
51
+ if (data.research)
52
+ return section('Research', data.research);
53
+ if (!data.verdict)
54
+ return null;
55
+ const lines = [`passed: ${data.verdict.passed}`, `evidence: ${data.verdict.evidence}`, `code: ${data.verdict.code}`];
56
+ return section('Verdict', lines.join('\n'));
57
+ }
58
+ function renderFailure(data) {
59
+ if (!data.failure)
60
+ return [];
61
+ const lines = [`error: ${data.failure.error}`];
62
+ if (data.failure.reasoning)
63
+ lines.push(`reasoning: ${data.failure.reasoning}`);
64
+ return [section('Failure', lines.join('\n')), renderAttempts(data.failure.attempts), renderCompactAria(data.failure.compactAria)];
65
+ }
66
+ function renderAttempts(attempts) {
67
+ if (!attempts?.length)
68
+ return null;
69
+ const labels = attempts.map((attempt, index) => `${index + 1}. ${attempt.code}`);
70
+ const width = Math.max(...labels.map((label) => label.length)) + 3;
71
+ const lines = labels.map((label, index) => align(label, `→ ${attempts[index].outcome}`, width));
72
+ return section(`Healing attempts (${attempts.length})`, lines.join('\n'));
73
+ }
74
+ function renderCompactAria(compactAria) {
75
+ if (!compactAria)
76
+ return null;
77
+ return section('Current page (compact ARIA)', compactAria);
78
+ }
79
+ function renderInstance(instance) {
80
+ const others = instance.others.map((other) => `${other.name} (${tabsLabel(other.tabs)})`);
81
+ const lines = [`instance: ${instance.name} (${tabsLabel(instance.tabs)}) | other instances: ${otherInstances(others)}`, browserLine(instance)];
82
+ return section('Instance', lines.join('\n'));
83
+ }
84
+ function otherInstances(others) {
85
+ if (!others.length)
86
+ return 'none';
87
+ return others.join(', ');
88
+ }
89
+ function browserLine(instance) {
90
+ if (instance.attached)
91
+ return `browser: attached (${instance.attached})`;
92
+ if (instance.startedAgo)
93
+ return `browser: running, started ${instance.startedAgo} ago`;
94
+ if (instance.tabs > 0)
95
+ return 'browser: running';
96
+ return 'browser: not running';
97
+ }
98
+ function tabsLabel(tabs) {
99
+ if (tabs === 1)
100
+ return '1 tab';
101
+ return `${tabs} tabs`;
102
+ }
103
+ function renderArtifacts(data) {
104
+ if (!data.artifacts)
105
+ return null;
106
+ const lines = [`aria: ${data.artifacts.aria}`, `html: ${data.artifacts.html}`, `network: ${data.artifacts.network}`];
107
+ return section('Artifacts', lines.join('\n'));
108
+ }
109
+ function align(label, marker, width) {
110
+ if (!marker)
111
+ return label;
112
+ return `${label.padEnd(width)}${marker}`;
113
+ }
114
+ function section(title, body) {
115
+ return `### ${title}\n${body}`;
116
+ }