explorbot 0.1.27 → 0.1.29
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 +83 -245
- package/bin/explorbot-cli.ts +1 -0
- package/dist/bin/explorbot-cli.js +1 -0
- package/dist/package.json +8 -6
- package/dist/rules/navigator/verification-actions.md +2 -0
- package/dist/src/action-result.js +2 -0
- package/dist/src/action.js +88 -7
- package/dist/src/ai/captain/file-tools.js +100 -0
- package/dist/src/ai/captain/idle-mode.js +70 -6
- package/dist/src/ai/captain/web-mode.js +36 -6
- package/dist/src/ai/captain.js +87 -19
- package/dist/src/ai/fisherman.js +14 -3
- package/dist/src/ai/historian/screencast.js +11 -2
- package/dist/src/ai/navigator.js +5 -2
- package/dist/src/ai/pilot.js +52 -9
- package/dist/src/ai/planner.js +16 -5
- package/dist/src/ai/provider.js +53 -18
- package/dist/src/ai/researcher/coordinates.js +2 -3
- package/dist/src/ai/researcher/deep-analysis.js +3 -4
- package/dist/src/ai/researcher/locators.js +1 -2
- package/dist/src/ai/researcher.js +24 -19
- package/dist/src/ai/rules.js +44 -0
- package/dist/src/ai/task-agent.js +1 -0
- package/dist/src/ai/tester.js +161 -46
- package/dist/src/ai/tools.js +84 -8
- package/dist/src/commands/explore-command.js +6 -1
- package/dist/src/components/LogPane.js +4 -3
- package/dist/src/explorbot.js +7 -2
- package/dist/src/explorer.js +270 -35
- package/dist/src/stats.js +16 -0
- package/dist/src/utils/aria.js +66 -6
- package/dist/src/utils/browser-errors.js +23 -0
- package/dist/src/utils/error-page.js +17 -2
- package/dist/src/utils/logger.js +2 -2
- package/package.json +8 -6
- package/rules/navigator/verification-actions.md +2 -0
- package/src/action-result.ts +2 -0
- package/src/action.ts +83 -7
- package/src/ai/captain/file-tools.ts +126 -0
- package/src/ai/captain/idle-mode.ts +72 -6
- package/src/ai/captain/mixin.ts +1 -1
- package/src/ai/captain/web-mode.ts +40 -5
- package/src/ai/captain.ts +94 -20
- package/src/ai/fisherman.ts +14 -3
- package/src/ai/historian/screencast.ts +11 -2
- package/src/ai/navigator.ts +6 -2
- package/src/ai/pilot.ts +53 -9
- package/src/ai/planner.ts +16 -5
- package/src/ai/provider.ts +51 -19
- package/src/ai/researcher/coordinates.ts +2 -3
- package/src/ai/researcher/deep-analysis.ts +3 -4
- package/src/ai/researcher/locators.ts +1 -2
- package/src/ai/researcher.ts +25 -19
- package/src/ai/rules.ts +46 -0
- package/src/ai/task-agent.ts +1 -1
- package/src/ai/tester.ts +175 -48
- package/src/ai/tools.ts +97 -8
- package/src/commands/explore-command.ts +6 -1
- package/src/components/LogPane.tsx +4 -3
- package/src/config.ts +1 -0
- package/src/explorbot.ts +6 -2
- package/src/explorer.ts +295 -38
- package/src/state-manager.ts +2 -0
- package/src/stats.ts +18 -0
- package/src/utils/aria.ts +63 -6
- package/src/utils/browser-errors.ts +25 -0
- package/src/utils/error-page.ts +16 -3
- package/src/utils/logger.ts +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "explorbot",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.29",
|
|
4
4
|
"description": "CLI app built with React Ink, CodeceptJS, and Playwright",
|
|
5
5
|
"license": "Elastic-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -65,9 +65,10 @@
|
|
|
65
65
|
},
|
|
66
66
|
"author": "",
|
|
67
67
|
"dependencies": {
|
|
68
|
-
"@ai-sdk/anthropic": "^
|
|
69
|
-
"@ai-sdk/groq": "^
|
|
70
|
-
"@ai-sdk/openai": "^
|
|
68
|
+
"@ai-sdk/anthropic": "^4.0",
|
|
69
|
+
"@ai-sdk/groq": "^4.0",
|
|
70
|
+
"@ai-sdk/openai": "^4.0",
|
|
71
|
+
"@ai-sdk/otel": "^1.0.2",
|
|
71
72
|
"@axe-core/playwright": "^4.11.0",
|
|
72
73
|
"@codeceptjs/reflection": "^0.5.2",
|
|
73
74
|
"@faker-js/faker": "^10.4.0",
|
|
@@ -82,8 +83,8 @@
|
|
|
82
83
|
"@opentelemetry/sdk-trace-base": "^2.2.0",
|
|
83
84
|
"@opentelemetry/semantic-conventions": "^1.38.0",
|
|
84
85
|
"@scalar/openapi-parser": "^0.25.6",
|
|
85
|
-
"@testomatio/reporter": "^2.
|
|
86
|
-
"ai": "^
|
|
86
|
+
"@testomatio/reporter": "^2.9.1",
|
|
87
|
+
"ai": "^7.0.2",
|
|
87
88
|
"axe-core": "^4.11.1",
|
|
88
89
|
"bash-tool": "^1.3.15",
|
|
89
90
|
"cli-highlight": "^2.1.11",
|
|
@@ -110,6 +111,7 @@
|
|
|
110
111
|
"parse5": "^8.0.0",
|
|
111
112
|
"playwright": "^1.60",
|
|
112
113
|
"react": "^19.1.1",
|
|
114
|
+
"sambanova-ai-provider": "^1.2.2",
|
|
113
115
|
"strip-ansi": "^7.1.2",
|
|
114
116
|
"turndown": "^7.2.1",
|
|
115
117
|
"unique-names-generator": "^4.7.1",
|
|
@@ -113,6 +113,8 @@ For input field values, ALWAYS use I.seeInField() — never check value via CSS
|
|
|
113
113
|
Prefer text locators (label, name, placeholder) for form fields: I.seeInField('Search', 'value') over I.seeInField('input[name="search"]', 'value').
|
|
114
114
|
Only use locators that exist in the provided HTML or ARIA snapshot.
|
|
115
115
|
Verify exact conditions, not approximate matches.
|
|
116
|
+
When the claim contains a concrete quoted value, generated assertion code MUST include that whole value. Do not shorten names, IDs, titles, emails, URLs, or other user-created values.
|
|
117
|
+
For exact visible text, prefer a text assertion scoped to a specific container or an ARIA locator with the complete text. Partial text is not valid evidence for a claim about the full value.
|
|
116
118
|
NEVER use `:has-text(...)` inside a seeElement/dontSeeElement locator. Checking text inside an element is the job of I.see(text, context) — the `:has-text()` form duplicates that capability with a fragile selector.
|
|
117
119
|
NEVER emit two assertions that check the same fact with different shapes. `I.see(text, locator)` and `I.seeElement("<locator>:has-text('text')")` verify the same thing — pick one (prefer I.see). One claim, one assertion.
|
|
118
120
|
</verification_rules>
|
package/src/action-result.ts
CHANGED
|
@@ -52,6 +52,7 @@ export interface ToolResultMetadata {
|
|
|
52
52
|
export class ActionResult implements ActionResultData {
|
|
53
53
|
public id?: number;
|
|
54
54
|
public title = '';
|
|
55
|
+
public httpStatus: number | undefined = undefined;
|
|
55
56
|
public error: string | null = null;
|
|
56
57
|
public timestamp: Date = new Date();
|
|
57
58
|
public h1: string | undefined = undefined;
|
|
@@ -82,6 +83,7 @@ export class ActionResult implements ActionResultData {
|
|
|
82
83
|
this.url = data.url ?? '';
|
|
83
84
|
this.fullUrl = data.fullUrl;
|
|
84
85
|
this.title = data.title ?? '';
|
|
86
|
+
this.httpStatus = data.httpStatus;
|
|
85
87
|
this.error = data.error ?? null;
|
|
86
88
|
this.browserLogs = data.browserLogs ?? [];
|
|
87
89
|
this.iframeSnapshots = data.iframeSnapshots ?? [];
|
package/src/action.ts
CHANGED
|
@@ -23,9 +23,9 @@ import { htmlCombinedSnapshot, minifyHtml } from './utils/html.js';
|
|
|
23
23
|
import { createDebug, setStepSpanParent, tag } from './utils/logger.js';
|
|
24
24
|
import { safeFilename } from './utils/strings.ts';
|
|
25
25
|
import { throttle } from './utils/throttle.ts';
|
|
26
|
+
import { isFatalBrowserError } from './utils/browser-errors.ts';
|
|
26
27
|
|
|
27
28
|
const debugLog = createDebug('explorbot:action');
|
|
28
|
-
const FATAL_BROWSER_ERRORS = /Frame was detached|Target closed|Execution context was destroyed|Protocol error|Session closed/i;
|
|
29
29
|
|
|
30
30
|
class Action {
|
|
31
31
|
private actor: CodeceptJS.I;
|
|
@@ -41,6 +41,7 @@ class Action {
|
|
|
41
41
|
public playwrightGroupId: string | null = null;
|
|
42
42
|
public assertionSteps: Array<{ name: string; args: any[] }> = [];
|
|
43
43
|
private recorder?: PlaywrightRecorder;
|
|
44
|
+
private mainDocumentStatus: number | undefined = undefined;
|
|
44
45
|
|
|
45
46
|
constructor(actor: CodeceptJS.I, stateManager: StateManager, recorder?: PlaywrightRecorder) {
|
|
46
47
|
this.actor = actor;
|
|
@@ -78,21 +79,26 @@ class Action {
|
|
|
78
79
|
const page = this.playwrightHelper.page;
|
|
79
80
|
const frame = this.playwrightHelper.frame;
|
|
80
81
|
await page?.waitForLoadState('domcontentloaded', { timeout: 10000 })?.catch(() => {});
|
|
81
|
-
|
|
82
|
+
await waitForUsablePageDom(page);
|
|
83
|
+
const grabAll = () => Promise.all([captureHtml(page, frame, this.actor), captureTitle(page, this.actor), this.captureBrowserLogs()]);
|
|
82
84
|
const [html, title, browserLogs] = await grabAll().catch(async (err: Error) => {
|
|
83
85
|
const msg = err instanceof Error ? err.message : String(err);
|
|
84
86
|
if (!/navigating and changing the content/i.test(msg)) throw err;
|
|
85
87
|
await page?.waitForLoadState('domcontentloaded', { timeout: 10000 })?.catch(() => {});
|
|
88
|
+
await waitForUsablePageDom(page);
|
|
86
89
|
return grabAll();
|
|
87
90
|
});
|
|
88
91
|
const url = page?.url() || (await (this.actor as any).grabCurrentUrl?.());
|
|
89
92
|
|
|
90
93
|
let screenshotFile: string | undefined = undefined;
|
|
94
|
+
const statesDir = outputPath('states');
|
|
95
|
+
fs.mkdirSync(statesDir, { recursive: true });
|
|
91
96
|
|
|
92
97
|
if (includeScreenshot) {
|
|
93
98
|
const filename = safeFilename(`${stateHash}_${timestamp}`, '.png');
|
|
94
|
-
|
|
95
|
-
|
|
99
|
+
const screenshotPath = join(statesDir, filename);
|
|
100
|
+
screenshotFile = await page
|
|
101
|
+
?.screenshot({ path: screenshotPath, fullPage: true })
|
|
96
102
|
.then(() => filename)
|
|
97
103
|
.catch((err: Error) => {
|
|
98
104
|
debugLog('Screenshot failed, continuing without it:', err);
|
|
@@ -101,8 +107,6 @@ class Action {
|
|
|
101
107
|
}
|
|
102
108
|
|
|
103
109
|
// Save HTML to file
|
|
104
|
-
const statesDir = outputPath('states');
|
|
105
|
-
fs.mkdirSync(statesDir, { recursive: true });
|
|
106
110
|
const htmlFile = safeFilename(`${stateHash}_${timestamp}`, '.html');
|
|
107
111
|
const htmlPath = join(statesDir, htmlFile);
|
|
108
112
|
fs.writeFileSync(htmlPath, html, 'utf8');
|
|
@@ -144,6 +148,7 @@ class Action {
|
|
|
144
148
|
const result = new ActionResult({
|
|
145
149
|
html,
|
|
146
150
|
title,
|
|
151
|
+
httpStatus: await this.captureMainDocumentStatus(),
|
|
147
152
|
url,
|
|
148
153
|
browserLogs,
|
|
149
154
|
htmlFile,
|
|
@@ -158,13 +163,52 @@ class Action {
|
|
|
158
163
|
return result;
|
|
159
164
|
} catch (err) {
|
|
160
165
|
const msg = err instanceof Error ? err.message : String(err);
|
|
161
|
-
if (
|
|
166
|
+
if (isFatalBrowserError(err)) throw err;
|
|
162
167
|
debugLog('capturePageState failed with non-fatal error:', msg);
|
|
163
168
|
const url = this.playwrightHelper.page?.url?.() || '';
|
|
164
169
|
return new ActionResult({ url, error: msg });
|
|
165
170
|
}
|
|
166
171
|
}
|
|
167
172
|
|
|
173
|
+
private async captureMainDocumentStatus(): Promise<number | undefined> {
|
|
174
|
+
if (this.mainDocumentStatus) return this.mainDocumentStatus;
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const page = this.playwrightHelper.page;
|
|
178
|
+
const status = await page.evaluate(() => {
|
|
179
|
+
const navigation = performance.getEntriesByType('navigation').at(-1) as PerformanceNavigationTiming & { responseStatus?: number };
|
|
180
|
+
if (!navigation) return undefined;
|
|
181
|
+
if (new URL(navigation.name).href !== window.location.href) return undefined;
|
|
182
|
+
return navigation.responseStatus;
|
|
183
|
+
});
|
|
184
|
+
if (typeof status !== 'number') return undefined;
|
|
185
|
+
if (status <= 0) return undefined;
|
|
186
|
+
return status;
|
|
187
|
+
} catch {
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private captureMainDocumentResponse(): () => void {
|
|
193
|
+
const page = this.playwrightHelper.page;
|
|
194
|
+
if (!page?.on || !page?.off) return () => {};
|
|
195
|
+
|
|
196
|
+
this.mainDocumentStatus = undefined;
|
|
197
|
+
|
|
198
|
+
const handler = (response: any) => {
|
|
199
|
+
const request = response.request();
|
|
200
|
+
if (request.resourceType() !== 'document') return;
|
|
201
|
+
if (response.frame() !== page.mainFrame()) return;
|
|
202
|
+
const status = response.status();
|
|
203
|
+
if (typeof status !== 'number') return;
|
|
204
|
+
if (status <= 0) return;
|
|
205
|
+
this.mainDocumentStatus = status;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
page.on('response', handler);
|
|
209
|
+
return () => page.off('response', handler);
|
|
210
|
+
}
|
|
211
|
+
|
|
168
212
|
/**
|
|
169
213
|
* Capture HTML snapshots of all iframes on the page
|
|
170
214
|
*/
|
|
@@ -235,6 +279,7 @@ class Action {
|
|
|
235
279
|
const stepListener = attachStepLogger(executedSteps, assertionSteps);
|
|
236
280
|
const groupId = this.recorder ? await this.recorder.beginAction(codeString) : null;
|
|
237
281
|
this.playwrightGroupId = groupId;
|
|
282
|
+
const detachMainDocumentResponse = this.captureMainDocumentResponse();
|
|
238
283
|
const activeSpan = Observability.getSpan();
|
|
239
284
|
const tracer = trace.getTracer('ai');
|
|
240
285
|
const stepSpan = activeSpan ? tracer.startSpan('codeceptjs.step', undefined, trace.setSpan(context.active(), activeSpan)) : null;
|
|
@@ -280,6 +325,7 @@ class Action {
|
|
|
280
325
|
this.assertionSteps = [];
|
|
281
326
|
throw err;
|
|
282
327
|
} finally {
|
|
328
|
+
detachMainDocumentResponse();
|
|
283
329
|
if (groupId) await this.recorder!.endAction();
|
|
284
330
|
detachStepLogger(stepListener);
|
|
285
331
|
if (stepSpan) {
|
|
@@ -375,6 +421,7 @@ class Action {
|
|
|
375
421
|
return true;
|
|
376
422
|
} catch (error) {
|
|
377
423
|
this.lastError = error as Error;
|
|
424
|
+
if (isFatalBrowserError(error)) throw error;
|
|
378
425
|
debugLog(`Attempt failed: ${codeBlock}: ${errorToString(error) || this.lastError?.toString()}`);
|
|
379
426
|
return false;
|
|
380
427
|
}
|
|
@@ -406,6 +453,35 @@ function errorToString(error: any): string {
|
|
|
406
453
|
return error.message || error.toString();
|
|
407
454
|
}
|
|
408
455
|
|
|
456
|
+
async function waitForUsablePageDom(page: any): Promise<void> {
|
|
457
|
+
if (!page?.waitForFunction) return;
|
|
458
|
+
|
|
459
|
+
await page
|
|
460
|
+
.waitForFunction(
|
|
461
|
+
() => {
|
|
462
|
+
const body = document.body;
|
|
463
|
+
if (!body) return false;
|
|
464
|
+
return body.children.length > 0 || body.textContent?.trim().length > 0;
|
|
465
|
+
},
|
|
466
|
+
undefined,
|
|
467
|
+
{ timeout: 5000 }
|
|
468
|
+
)
|
|
469
|
+
.catch(() => {});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function captureHtml(page: any, frame: any, actor: any): Promise<string> {
|
|
473
|
+
if (frame?.content) return frame.content();
|
|
474
|
+
if (page?.content) return page.content();
|
|
475
|
+
if (actor?.grabSource) return actor.grabSource();
|
|
476
|
+
throw new Error('Playwright page is unavailable for HTML capture');
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async function captureTitle(page: any, actor: any): Promise<string> {
|
|
480
|
+
if (page?.title) return page.title();
|
|
481
|
+
if (actor?.grabTitle) return actor.grabTitle();
|
|
482
|
+
return '';
|
|
483
|
+
}
|
|
484
|
+
|
|
409
485
|
function sanitizeCodeBlock(code: string): string {
|
|
410
486
|
return code
|
|
411
487
|
.split('\n')
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { basename, isAbsolute, join, relative, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const CAPTAIN_ARTIFACT_DIRS = ['reports', 'plans', 'tests', 'states'] as const;
|
|
5
|
+
export const CAPTAIN_ALLOWED_READ_DIRS = ['output', 'knowledge', 'experience'] as const;
|
|
6
|
+
export const CAPTAIN_ARTIFACT_SCAN_LIMIT = 200;
|
|
7
|
+
export const CAPTAIN_ARTIFACT_LIST_LIMIT = 20;
|
|
8
|
+
export const CAPTAIN_READ_FILE_DEFAULT_LIMIT = 12000;
|
|
9
|
+
export const CAPTAIN_READ_FILE_MAX_LIMIT = 50000;
|
|
10
|
+
export const CAPTAIN_READ_FILE_MIN_LIMIT = 1000;
|
|
11
|
+
|
|
12
|
+
export function listRecentArtifacts(outputDir: string): Array<{ path: string; size: number; modifiedAt: string }> {
|
|
13
|
+
const artifacts: Array<{ path: string; size: number; modifiedAt: string; timestamp: number }> = [];
|
|
14
|
+
|
|
15
|
+
for (const dir of CAPTAIN_ARTIFACT_DIRS) {
|
|
16
|
+
if (artifacts.length >= CAPTAIN_ARTIFACT_SCAN_LIMIT) break;
|
|
17
|
+
const targetDir = join(outputDir, dir);
|
|
18
|
+
if (!existsSync(targetDir)) continue;
|
|
19
|
+
collectArtifacts(outputDir, targetDir, artifacts);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return artifacts
|
|
23
|
+
.sort((a, b) => b.timestamp - a.timestamp)
|
|
24
|
+
.slice(0, CAPTAIN_ARTIFACT_LIST_LIMIT)
|
|
25
|
+
.map(({ timestamp, ...artifact }) => artifact);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function readCaptainFile(projectRoot: string | null, input: ReadCaptainFileInput, allowedDirs: readonly string[] = CAPTAIN_ALLOWED_READ_DIRS): ReadCaptainFileResult {
|
|
29
|
+
const resolved = resolveReadableFile(projectRoot, input.path, allowedDirs);
|
|
30
|
+
if (!resolved) {
|
|
31
|
+
return { success: false, message: 'File is outside allowed directories' };
|
|
32
|
+
}
|
|
33
|
+
if (!existsSync(resolved)) {
|
|
34
|
+
return { success: false, message: `File not found: ${input.path}` };
|
|
35
|
+
}
|
|
36
|
+
if (!statSync(resolved).isFile()) {
|
|
37
|
+
return { success: false, message: `Not a file: ${input.path}` };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const maxChars = normalizeMaxChars(input.maxChars);
|
|
41
|
+
const fullContent = readFileSync(resolved, 'utf8');
|
|
42
|
+
const content = selectContent(fullContent, input);
|
|
43
|
+
return {
|
|
44
|
+
success: true,
|
|
45
|
+
path: relative(projectRoot || process.cwd(), resolved),
|
|
46
|
+
truncated: content.length > maxChars,
|
|
47
|
+
content: content.slice(0, maxChars),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function collectArtifacts(outputDir: string, targetDir: string, artifacts: Array<{ path: string; size: number; modifiedAt: string; timestamp: number }>): void {
|
|
52
|
+
for (const entry of readdirSync(targetDir, { withFileTypes: true })) {
|
|
53
|
+
if (artifacts.length >= CAPTAIN_ARTIFACT_SCAN_LIMIT) return;
|
|
54
|
+
const entryPath = join(targetDir, entry.name);
|
|
55
|
+
if (entry.isDirectory()) {
|
|
56
|
+
collectArtifacts(outputDir, entryPath, artifacts);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const stats = statSync(entryPath);
|
|
61
|
+
artifacts.push({
|
|
62
|
+
path: relative(outputDir, entryPath),
|
|
63
|
+
size: stats.size,
|
|
64
|
+
modifiedAt: stats.mtime.toISOString(),
|
|
65
|
+
timestamp: stats.mtimeMs,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function resolveReadableFile(projectRoot: string | null, requestedPath: string, allowedDirs: readonly string[]): string | null {
|
|
71
|
+
if (!projectRoot) return null;
|
|
72
|
+
|
|
73
|
+
let cleanPath = requestedPath.trim();
|
|
74
|
+
const projectName = basename(projectRoot);
|
|
75
|
+
if (cleanPath.startsWith(`${projectName}/`) || cleanPath.startsWith(`${projectName}\\`)) {
|
|
76
|
+
cleanPath = cleanPath.slice(projectName.length + 1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const resolved = isAbsolute(cleanPath) ? resolve(cleanPath) : resolve(projectRoot, cleanPath);
|
|
80
|
+
const allowedRoots = allowedDirs.map((dir) => resolve(projectRoot, dir));
|
|
81
|
+
for (const root of allowedRoots) {
|
|
82
|
+
const rel = relative(root, resolved);
|
|
83
|
+
if (!rel || (!rel.startsWith('..') && !isAbsolute(rel))) return resolved;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function selectContent(content: string, input: ReadCaptainFileInput): string {
|
|
90
|
+
if (!input.startLine && !input.endLine) return content;
|
|
91
|
+
|
|
92
|
+
const lines = content.split(/\r?\n/);
|
|
93
|
+
const startIndex = resolveLineIndex(input.startLine, lines.length, 1);
|
|
94
|
+
const endIndex = resolveLineIndex(input.endLine, lines.length, lines.length);
|
|
95
|
+
if (endIndex < startIndex) return '';
|
|
96
|
+
return lines.slice(startIndex - 1, endIndex).join('\n');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function resolveLineIndex(line: number | undefined, totalLines: number, fallback: number): number {
|
|
100
|
+
if (!line) return fallback;
|
|
101
|
+
if (line < 0) return Math.max(1, totalLines + line + 1);
|
|
102
|
+
return Math.min(Math.max(1, line), totalLines);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function normalizeMaxChars(maxChars?: number): number {
|
|
106
|
+
return Math.max(CAPTAIN_READ_FILE_MIN_LIMIT, Math.min(maxChars || CAPTAIN_READ_FILE_DEFAULT_LIMIT, CAPTAIN_READ_FILE_MAX_LIMIT));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface ReadCaptainFileInput {
|
|
110
|
+
path: string;
|
|
111
|
+
startLine?: number;
|
|
112
|
+
endLine?: number;
|
|
113
|
+
maxChars?: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export type ReadCaptainFileResult =
|
|
117
|
+
| {
|
|
118
|
+
success: true;
|
|
119
|
+
path: string;
|
|
120
|
+
truncated: boolean;
|
|
121
|
+
content: string;
|
|
122
|
+
}
|
|
123
|
+
| {
|
|
124
|
+
success: false;
|
|
125
|
+
message: string;
|
|
126
|
+
};
|
|
@@ -4,6 +4,7 @@ import dedent from 'dedent';
|
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { ConfigParser } from '../../config.ts';
|
|
6
6
|
import { Test } from '../../test-plan.ts';
|
|
7
|
+
import { listRecentArtifacts, readCaptainFile } from './file-tools.ts';
|
|
7
8
|
import { type Constructor, type ModeContext, resolveProjectRoot } from './mixin.ts';
|
|
8
9
|
|
|
9
10
|
let cachedBashTool: Awaited<ReturnType<typeof createBashTool>> | null = null;
|
|
@@ -15,6 +16,8 @@ export function WithIdleMode<T extends Constructor>(Base: T) {
|
|
|
15
16
|
const config = ConfigParser.getInstance().getConfig();
|
|
16
17
|
const knowledgeDir = config.dirs?.knowledge || 'knowledge';
|
|
17
18
|
const experienceDir = config.dirs?.experience || 'experience';
|
|
19
|
+
const outputDir = config.dirs?.output || 'output';
|
|
20
|
+
const readableDirs = [outputDir, knowledgeDir, experienceDir];
|
|
18
21
|
|
|
19
22
|
if (!cachedBashTool && projectRoot) {
|
|
20
23
|
cachedBashTool = await createBashTool({
|
|
@@ -75,6 +78,55 @@ export function WithIdleMode<T extends Constructor>(Base: T) {
|
|
|
75
78
|
return { success: true, tests: plan.tests.length };
|
|
76
79
|
},
|
|
77
80
|
}),
|
|
81
|
+
project: tool({
|
|
82
|
+
description: dedent`
|
|
83
|
+
Inspect Explorbot project configuration and recent generated artifacts.
|
|
84
|
+
Use this before answering questions about setup, previous sessions, reports, saved plans, or output files.
|
|
85
|
+
`,
|
|
86
|
+
inputSchema: z.object({
|
|
87
|
+
view: z.enum(['config', 'artifacts']).optional().describe('config shows setup summary; artifacts lists recent generated files'),
|
|
88
|
+
}),
|
|
89
|
+
execute: async ({ view }) => {
|
|
90
|
+
const parser = ConfigParser.getInstance();
|
|
91
|
+
const config = parser.getConfig();
|
|
92
|
+
const outputDir = parser.getOutputDir();
|
|
93
|
+
|
|
94
|
+
if (view === 'artifacts') {
|
|
95
|
+
return {
|
|
96
|
+
success: true,
|
|
97
|
+
outputDir,
|
|
98
|
+
artifacts: listRecentArtifacts(outputDir),
|
|
99
|
+
suggestion: 'Use readFile to inspect specific reports, plans, logs, generated tests, knowledge, or experience files.',
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
success: true,
|
|
105
|
+
configPath: parser.getConfigPath(),
|
|
106
|
+
baseUrl: config.playwright?.url,
|
|
107
|
+
browser: config.playwright?.browser,
|
|
108
|
+
headed: config.playwright?.show === true,
|
|
109
|
+
dirs: config.dirs,
|
|
110
|
+
agents: Object.fromEntries(Object.entries(config.ai?.agents || {}).map(([name, agentConfig]: [string, any]) => [name, { enabled: agentConfig?.enabled !== false, hasModelOverride: !!agentConfig?.model }])),
|
|
111
|
+
reporterEnabled: config.reporter?.enabled === true,
|
|
112
|
+
apiEnabled: !!config.api,
|
|
113
|
+
};
|
|
114
|
+
},
|
|
115
|
+
}),
|
|
116
|
+
readFile: tool({
|
|
117
|
+
description: dedent`
|
|
118
|
+
Read a specific Explorbot project file for analysis.
|
|
119
|
+
Use this for explicit user questions about reports, plans, logs, generated tests, knowledge, or experience files.
|
|
120
|
+
Prefer this over bash() for reading file contents after bash has found the file.
|
|
121
|
+
`,
|
|
122
|
+
inputSchema: z.object({
|
|
123
|
+
path: z.string().describe('Path inside output, knowledge, or experience directories'),
|
|
124
|
+
startLine: z.number().optional().describe('First line to read, 1-based. Negative values count from the end of the file'),
|
|
125
|
+
endLine: z.number().optional().describe('Last line to read, 1-based and inclusive. Negative values count from the end of the file'),
|
|
126
|
+
maxChars: z.number().optional().describe('Maximum characters to return, default 12000'),
|
|
127
|
+
}),
|
|
128
|
+
execute: async (input) => readCaptainFile(projectRoot, input, readableDirs),
|
|
129
|
+
}),
|
|
78
130
|
};
|
|
79
131
|
|
|
80
132
|
if (cachedBashTool) {
|
|
@@ -88,18 +140,32 @@ export function WithIdleMode<T extends Constructor>(Base: T) {
|
|
|
88
140
|
const config = ConfigParser.getInstance().getConfig();
|
|
89
141
|
const knowledgeDir = config.dirs?.knowledge || 'knowledge';
|
|
90
142
|
const experienceDir = config.dirs?.experience || 'experience';
|
|
143
|
+
const outputDir = config.dirs?.output || 'output';
|
|
91
144
|
|
|
92
145
|
return dedent`
|
|
93
146
|
<idle_capabilities>
|
|
94
147
|
- Plan management: updatePlan() — replace or append tests in the current plan
|
|
95
|
-
-
|
|
96
|
-
|
|
97
|
-
-
|
|
98
|
-
-
|
|
99
|
-
- Use
|
|
100
|
-
- Use
|
|
148
|
+
- readFile() — read specific report, plan, log, generated test, knowledge, or experience file content
|
|
149
|
+
- bash() — discover files and inspect file metadata
|
|
150
|
+
- READ from: ${knowledgeDir}/, ${experienceDir}/, ${outputDir}/
|
|
151
|
+
- WRITE to: ${knowledgeDir}/, ${experienceDir}/ only (NOT ${outputDir}/)
|
|
152
|
+
- Use wc -l -c file.txt to inspect size
|
|
153
|
+
- Use file file.txt to inspect type
|
|
154
|
+
- Use find . -name "*.md" to discover files
|
|
155
|
+
- Use grep -n "keyword" file.txt to find matching lines
|
|
156
|
+
- Use ls -lh to list files
|
|
101
157
|
</idle_capabilities>
|
|
102
158
|
|
|
159
|
+
<file_reading>
|
|
160
|
+
Use bash() for file discovery and search. Once the needed file and line range are known,
|
|
161
|
+
use readFile() to read its contents. Do not use bash() to print file contents.
|
|
162
|
+
</file_reading>
|
|
163
|
+
|
|
164
|
+
<project_inspection>
|
|
165
|
+
Use project({ view: "config" }) before explaining Explorbot setup or suggesting config improvements.
|
|
166
|
+
Use project({ view: "artifacts" }) before answering questions about previous sessions, reports, plans, generated tests, or logs.
|
|
167
|
+
</project_inspection>
|
|
168
|
+
|
|
103
169
|
<knowledge_saving>
|
|
104
170
|
When user shares credentials, selectors, or important domain info during conversation,
|
|
105
171
|
suggest saving it to a knowledge file using bash tool.
|
package/src/ai/captain/mixin.ts
CHANGED
|
@@ -8,7 +8,7 @@ export type Constructor<T = object> = new (...args: any[]) => T;
|
|
|
8
8
|
|
|
9
9
|
export const debugLog = createDebug('explorbot:captain');
|
|
10
10
|
|
|
11
|
-
export type CaptainMode = 'idle' | 'web' | 'test';
|
|
11
|
+
export type CaptainMode = 'idle' | 'web' | 'test' | 'heal';
|
|
12
12
|
|
|
13
13
|
export interface ModeContext {
|
|
14
14
|
explorBot: ExplorBot;
|
|
@@ -47,18 +47,53 @@ export function WithWebMode<T extends Constructor>(Base: T) {
|
|
|
47
47
|
description: dedent`
|
|
48
48
|
Direct browser access via Playwright. Use for diagnostics and browser management.
|
|
49
49
|
Actions:
|
|
50
|
+
- status: Inspect browser/page availability, URL, title, tab count
|
|
50
51
|
- evaluate: Run JavaScript in browser context (localStorage, cookies, DOM, console)
|
|
51
52
|
- closeTabs: Close all browser tabs except the current one
|
|
52
|
-
- screenshot: Take a screenshot of current page
|
|
53
53
|
- reload: Reload the current page
|
|
54
|
+
- screenshot: Take a screenshot of current page
|
|
55
|
+
- recover: Recover from a closed/crashed page using Explorer recovery
|
|
56
|
+
- restart: Restart the browser when page/context recovery is not enough
|
|
57
|
+
- openFreshTab: Open a fresh tab in the current browser context
|
|
54
58
|
`,
|
|
55
59
|
inputSchema: z.object({
|
|
56
|
-
action: z.enum(['evaluate', 'closeTabs', 'screenshot', '
|
|
60
|
+
action: z.enum(['status', 'evaluate', 'closeTabs', 'reload', 'screenshot', 'recover', 'restart', 'openFreshTab']).describe('Browser action to perform'),
|
|
57
61
|
code: z.string().optional().describe('JavaScript code for evaluate action'),
|
|
58
62
|
}),
|
|
59
63
|
execute: async ({ action, code }) => {
|
|
60
|
-
const
|
|
61
|
-
|
|
64
|
+
const explorer = ctx.explorBot.getExplorer();
|
|
65
|
+
|
|
66
|
+
if (action === 'status') {
|
|
67
|
+
const page = explorer.playwrightHelper?.page;
|
|
68
|
+
const pages = page?.context?.().pages?.() || [];
|
|
69
|
+
return {
|
|
70
|
+
success: true,
|
|
71
|
+
hasPage: !!page,
|
|
72
|
+
isClosed: page?.isClosed?.() || false,
|
|
73
|
+
url: page && !page.isClosed?.() ? await page.url() : null,
|
|
74
|
+
title: page && !page.isClosed?.() ? await page.title().catch(() => null) : null,
|
|
75
|
+
tabs: pages.length,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (action === 'recover') {
|
|
80
|
+
const recovered = await explorer.recoverFromBrowserError();
|
|
81
|
+
return { success: recovered, message: recovered ? 'Browser page recovered' : 'Browser recovery failed' };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (action === 'restart') {
|
|
85
|
+
const restarted = await explorer.restartBrowser();
|
|
86
|
+
return { success: restarted, message: restarted ? 'Browser restarted' : 'Browser restart failed' };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (action === 'openFreshTab') {
|
|
90
|
+
await ctx.explorBot.openFreshTab();
|
|
91
|
+
const state = explorer.getStateManager().getCurrentState();
|
|
92
|
+
return { success: true, url: state?.url, title: state?.title };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const page = explorer.playwrightHelper?.page;
|
|
96
|
+
if (!page || page.isClosed?.()) return { success: false, message: 'No browser page available. Try browser({ action: "recover" }) first.' };
|
|
62
97
|
|
|
63
98
|
if (action === 'evaluate') {
|
|
64
99
|
if (!code) return { success: false, message: 'Code required for evaluate action' };
|
|
@@ -112,7 +147,7 @@ export function WithWebMode<T extends Constructor>(Base: T) {
|
|
|
112
147
|
<web_capabilities>
|
|
113
148
|
- Page actions: click, pressKey, form (CodeceptJS tools)
|
|
114
149
|
- Navigation: navigate() — AI-powered navigation to URLs or page descriptions
|
|
115
|
-
- Browser diagnostics: browser() — evaluate JS, close tabs, screenshot, reload
|
|
150
|
+
- Browser diagnostics: browser() — inspect status, evaluate JS, close tabs, screenshot, reload, recover closed/crashed pages, restart browser, open a fresh tab
|
|
116
151
|
- Visual analysis: see() — screenshot-based page verification
|
|
117
152
|
- Context refresh: context() — get fresh HTML/ARIA snapshot
|
|
118
153
|
- Visual fallback: visualClick() — coordinate-based click when locators fail
|