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