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,117 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
const GLOBAL_CONFIG_NAMES = ['config.js', 'config.mjs', 'config.ts'];
|
|
5
|
+
const SITE_DIRS = ['knowledge', 'experience', 'output'];
|
|
6
|
+
export function globalDir() {
|
|
7
|
+
return join(os.homedir(), '.explorbot');
|
|
8
|
+
}
|
|
9
|
+
export function globalEnvPath() {
|
|
10
|
+
return join(globalDir(), '.env');
|
|
11
|
+
}
|
|
12
|
+
export function globalConfigPath() {
|
|
13
|
+
return join(globalDir(), 'config.js');
|
|
14
|
+
}
|
|
15
|
+
export function findGlobalConfig() {
|
|
16
|
+
for (const name of GLOBAL_CONFIG_NAMES) {
|
|
17
|
+
const fullPath = join(globalDir(), name);
|
|
18
|
+
if (existsSync(fullPath))
|
|
19
|
+
return fullPath;
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
export function isGlobalConfigPath(configPath) {
|
|
24
|
+
return GLOBAL_CONFIG_NAMES.some((name) => join(globalDir(), name) === configPath);
|
|
25
|
+
}
|
|
26
|
+
export function sitesDir() {
|
|
27
|
+
return join(globalDir(), 'sites');
|
|
28
|
+
}
|
|
29
|
+
export function siteFolderName(url) {
|
|
30
|
+
return new URL(url).host.toLowerCase().replace(/[^a-z0-9._-]/g, '_');
|
|
31
|
+
}
|
|
32
|
+
export function listSites() {
|
|
33
|
+
if (!existsSync(sitesDir()))
|
|
34
|
+
return [];
|
|
35
|
+
return readdirSync(sitesDir(), { withFileTypes: true })
|
|
36
|
+
.filter((entry) => entry.isDirectory())
|
|
37
|
+
.map((entry) => readSite(entry.name))
|
|
38
|
+
.filter((site) => !!site)
|
|
39
|
+
.sort((a, b) => b.lastRunAt.localeCompare(a.lastRunAt));
|
|
40
|
+
}
|
|
41
|
+
export function registerSite(baseUrl) {
|
|
42
|
+
const folder = siteFolderName(baseUrl);
|
|
43
|
+
const dir = join(sitesDir(), folder);
|
|
44
|
+
for (const subDir of SITE_DIRS) {
|
|
45
|
+
mkdirSync(join(dir, subDir), { recursive: true, mode: 0o700 });
|
|
46
|
+
}
|
|
47
|
+
const now = new Date().toISOString();
|
|
48
|
+
const meta = {
|
|
49
|
+
url: baseUrl,
|
|
50
|
+
createdAt: readSite(folder)?.createdAt || now,
|
|
51
|
+
lastRunAt: now,
|
|
52
|
+
};
|
|
53
|
+
writeFileSync(join(dir, 'site.json'), `${JSON.stringify(meta, null, 2)}\n`, 'utf8');
|
|
54
|
+
return { folder, dir, ...meta };
|
|
55
|
+
}
|
|
56
|
+
export function resolveSiteTarget(target, defaultBaseUrl) {
|
|
57
|
+
const raw = (target || process.env.EXPLORBOT_URL || defaultBaseUrl || '').trim();
|
|
58
|
+
if (!raw) {
|
|
59
|
+
throw new Error(withSites('No site to explore. Pass a URL to the command or set EXPLORBOT_URL.'));
|
|
60
|
+
}
|
|
61
|
+
if (raw.startsWith('http://') || raw.startsWith('https://')) {
|
|
62
|
+
const url = new URL(raw);
|
|
63
|
+
return { baseUrl: url.origin, path: `${url.pathname}${url.search}${url.hash}` };
|
|
64
|
+
}
|
|
65
|
+
if (raw.startsWith('/')) {
|
|
66
|
+
const base = defaultBaseUrl || process.env.EXPLORBOT_URL;
|
|
67
|
+
if (!base) {
|
|
68
|
+
throw new Error(withSites(`Cannot resolve path "${raw}" without a site.`));
|
|
69
|
+
}
|
|
70
|
+
return { baseUrl: new URL(base).origin, path: raw };
|
|
71
|
+
}
|
|
72
|
+
let reference = raw;
|
|
73
|
+
let path = '/';
|
|
74
|
+
const separator = raw.indexOf('/');
|
|
75
|
+
if (separator > -1) {
|
|
76
|
+
reference = raw.slice(0, separator);
|
|
77
|
+
path = raw.slice(separator);
|
|
78
|
+
}
|
|
79
|
+
const site = findSite(reference);
|
|
80
|
+
if (!site) {
|
|
81
|
+
throw new Error(withSites(`Unknown site "${reference}".`));
|
|
82
|
+
}
|
|
83
|
+
return { baseUrl: site.url, path };
|
|
84
|
+
}
|
|
85
|
+
function readSite(folder) {
|
|
86
|
+
const dir = join(sitesDir(), folder);
|
|
87
|
+
const metaPath = join(dir, 'site.json');
|
|
88
|
+
if (!existsSync(metaPath))
|
|
89
|
+
return null;
|
|
90
|
+
try {
|
|
91
|
+
const meta = JSON.parse(readFileSync(metaPath, 'utf8'));
|
|
92
|
+
new URL(meta.url);
|
|
93
|
+
return { folder, dir, url: meta.url, createdAt: meta.createdAt, lastRunAt: meta.lastRunAt };
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function findSite(reference) {
|
|
100
|
+
const normalized = reference.toLowerCase().replace(/[^a-z0-9._-]/g, '_');
|
|
101
|
+
const sites = listSites();
|
|
102
|
+
const byFolder = sites.find((site) => site.folder === normalized);
|
|
103
|
+
if (byFolder)
|
|
104
|
+
return byFolder;
|
|
105
|
+
return sites.find((site) => new URL(site.url).host.toLowerCase() === reference.toLowerCase()) || null;
|
|
106
|
+
}
|
|
107
|
+
function withSites(message) {
|
|
108
|
+
const lines = [message];
|
|
109
|
+
const sites = listSites();
|
|
110
|
+
if (sites.length) {
|
|
111
|
+
lines.push('Registered sites:');
|
|
112
|
+
for (const site of sites)
|
|
113
|
+
lines.push(` ${site.folder} → ${site.url}`);
|
|
114
|
+
}
|
|
115
|
+
lines.push('Explore a new site by passing its full URL, e.g. https://app.example.com/login');
|
|
116
|
+
return lines.join('\n');
|
|
117
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ActionResult } from './action-result.js';
|
|
2
|
+
import { ApplicationSpec } from './application-spec.js';
|
|
2
3
|
export interface Knowledge {
|
|
3
4
|
filePath: string;
|
|
4
5
|
url: string;
|
|
@@ -9,10 +10,13 @@ export declare class KnowledgeTracker {
|
|
|
9
10
|
knowledgeDir: string;
|
|
10
11
|
knowledgeFiles: Knowledge[];
|
|
11
12
|
isLoaded: boolean;
|
|
12
|
-
|
|
13
|
+
applicationSpec?: ApplicationSpec;
|
|
14
|
+
constructor(applicationSpecPath?: string);
|
|
13
15
|
loadKnowledgeFiles(): void;
|
|
14
16
|
getRelevantKnowledge(state: ActionResult): Knowledge[];
|
|
15
17
|
renderRelevantKnowledge(state: ActionResult): string;
|
|
18
|
+
renderRelevantContext(state: ActionResult): string;
|
|
19
|
+
renderApplicationSpec(state: ActionResult): string;
|
|
16
20
|
addKnowledge(urlPattern: string, description: string): {
|
|
17
21
|
filename: string;
|
|
18
22
|
filePath: string;
|
|
@@ -3,6 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import dedent from 'dedent';
|
|
4
4
|
import matter from 'gray-matter';
|
|
5
5
|
import { ActionResult } from './action-result.js';
|
|
6
|
+
import { ApplicationSpec } from "./application-spec.js";
|
|
6
7
|
import { ConfigParser } from './config.js';
|
|
7
8
|
import { getCliName } from "./utils/cli-name.js";
|
|
8
9
|
import { createDebug, pluralize, tag } from './utils/logger.js';
|
|
@@ -15,13 +16,19 @@ export class KnowledgeTracker {
|
|
|
15
16
|
knowledgeDir;
|
|
16
17
|
knowledgeFiles = [];
|
|
17
18
|
isLoaded = false;
|
|
18
|
-
|
|
19
|
+
applicationSpec;
|
|
20
|
+
constructor(applicationSpecPath) {
|
|
19
21
|
const configParser = ConfigParser.getInstance();
|
|
20
22
|
const config = configParser.getConfig();
|
|
21
23
|
this.knowledgeDir = configParser.resolveProjectDir(config.dirs?.knowledge || 'knowledge');
|
|
22
24
|
if (!existsSync(this.knowledgeDir)) {
|
|
23
25
|
mkdirSync(this.knowledgeDir, { recursive: true });
|
|
24
26
|
}
|
|
27
|
+
const specPath = applicationSpecPath || config.dirs?.spec;
|
|
28
|
+
if (specPath) {
|
|
29
|
+
this.applicationSpec = new ApplicationSpec(specPath);
|
|
30
|
+
tag('info').log(`Loaded application spec with ${this.applicationSpec.pageCount} documented pages`);
|
|
31
|
+
}
|
|
25
32
|
}
|
|
26
33
|
loadKnowledgeFiles() {
|
|
27
34
|
if (this.isLoaded)
|
|
@@ -60,6 +67,12 @@ export class KnowledgeTracker {
|
|
|
60
67
|
</knowledge>
|
|
61
68
|
`;
|
|
62
69
|
}
|
|
70
|
+
renderRelevantContext(state) {
|
|
71
|
+
return [this.renderRelevantKnowledge(state), this.renderApplicationSpec(state)].filter(Boolean).join('\n\n');
|
|
72
|
+
}
|
|
73
|
+
renderApplicationSpec(state) {
|
|
74
|
+
return this.applicationSpec?.renderFor(state) || '';
|
|
75
|
+
}
|
|
63
76
|
addKnowledge(urlPattern, description) {
|
|
64
77
|
const configParser = ConfigParser.getInstance();
|
|
65
78
|
const configPath = configParser.getConfigPath();
|
|
@@ -8,8 +8,12 @@ export function getCliName() {
|
|
|
8
8
|
cached = 'bunx explorbot';
|
|
9
9
|
else if (ua.includes('npm'))
|
|
10
10
|
cached = 'npx explorbot';
|
|
11
|
-
else if (process.argv[1]?.endsWith('.ts'))
|
|
12
|
-
|
|
11
|
+
else if (process.argv[1]?.endsWith('.ts')) {
|
|
12
|
+
let script = path.relative(process.cwd(), process.argv[1]);
|
|
13
|
+
if (script.startsWith('..'))
|
|
14
|
+
script = process.argv[1];
|
|
15
|
+
cached = `bun ${script}`;
|
|
16
|
+
}
|
|
13
17
|
else
|
|
14
18
|
cached = 'explorbot';
|
|
15
19
|
return cached;
|
|
@@ -57,8 +57,7 @@ export async function dryRunTestFile(filePath) {
|
|
|
57
57
|
return;
|
|
58
58
|
}
|
|
59
59
|
const config = ConfigParser.getInstance().getConfig();
|
|
60
|
-
const
|
|
61
|
-
const projectRoot = configPath ? path.dirname(configPath) : process.cwd();
|
|
60
|
+
const projectRoot = ConfigParser.getInstance().getProjectRoot();
|
|
62
61
|
const codeceptConfig = {
|
|
63
62
|
helpers: {
|
|
64
63
|
Playwright: { browser: config.playwright.browser, url: config.playwright.url },
|
|
@@ -4,3 +4,4 @@ export declare function generalizeSegment(segment: string): string;
|
|
|
4
4
|
export declare function generalizeUrl(url: string): string;
|
|
5
5
|
export declare function matchesUrl(pattern: string, path: string): boolean;
|
|
6
6
|
export declare function extractStatePath(url: string): string;
|
|
7
|
+
export declare function matchesNavigationUrl(expected: string, current: string): boolean;
|
|
@@ -102,3 +102,12 @@ export function extractStatePath(url) {
|
|
|
102
102
|
return url;
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
|
+
export function matchesNavigationUrl(expected, current) {
|
|
106
|
+
const expectedPath = extractStatePath(expected);
|
|
107
|
+
let currentPath = extractStatePath(current);
|
|
108
|
+
if (!expectedPath.includes('#')) {
|
|
109
|
+
currentPath = currentPath.split('#')[0];
|
|
110
|
+
}
|
|
111
|
+
const normalize = (value) => value.replace(/^\/+|\/+$/g, '').toLowerCase();
|
|
112
|
+
return normalize(expectedPath) === normalize(currentPath);
|
|
113
|
+
}
|
package/models.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "explorbot",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "CLI app built with React Ink, CodeceptJS, and Playwright",
|
|
5
5
|
"license": "Elastic-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
}
|
|
15
15
|
},
|
|
16
16
|
"bin": {
|
|
17
|
-
"explorbot": "./dist/bin/explorbot-cli.js"
|
|
17
|
+
"explorbot": "./dist/bin/explorbot-cli.js",
|
|
18
|
+
"prima": "./dist/boat/prima/bin/prima-cli.js"
|
|
18
19
|
},
|
|
19
20
|
"files": [
|
|
20
21
|
"dist/",
|
|
@@ -25,6 +26,9 @@
|
|
|
25
26
|
"boat/doc-collector/src/**/*.ts",
|
|
26
27
|
"boat/doc-collector/bin/**/*.ts",
|
|
27
28
|
"boat/doc-collector/package.json",
|
|
29
|
+
"boat/prima/src/**/*.ts",
|
|
30
|
+
"boat/prima/bin/**/*.ts",
|
|
31
|
+
"boat/prima/package.json",
|
|
28
32
|
"rules/",
|
|
29
33
|
"assets/sample-files/",
|
|
30
34
|
"models.json"
|
|
@@ -114,7 +118,9 @@
|
|
|
114
118
|
"micromatch": "^4.0.8",
|
|
115
119
|
"ora-classic": "^5.4.2",
|
|
116
120
|
"parse5": "^8.0.0",
|
|
121
|
+
"pixelmatch": "^7.2.0",
|
|
117
122
|
"playwright": "^1.60",
|
|
123
|
+
"pngjs": "^7.0.0",
|
|
118
124
|
"react": "^19.1.1",
|
|
119
125
|
"sambanova-ai-provider": "^1.2.2",
|
|
120
126
|
"strip-ansi": "^7.1.2",
|
|
@@ -131,6 +137,7 @@
|
|
|
131
137
|
"@types/debug": "^4.1.12",
|
|
132
138
|
"@types/jsdom": "^27.0.0",
|
|
133
139
|
"@types/micromatch": "^4.0.9",
|
|
140
|
+
"@types/pngjs": "^6.0.5",
|
|
134
141
|
"@types/react": "^18.2.0",
|
|
135
142
|
"@types/yargs": "^17.0.24",
|
|
136
143
|
"bunosh": "^0.4.0",
|
package/src/action.ts
CHANGED
|
@@ -65,8 +65,8 @@ class Action {
|
|
|
65
65
|
return this.recovery(() => this.captureOnce(opts));
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
async execute(code: string): Promise<Action> {
|
|
69
|
-
return this.recovery(() => this.executeOnce(code));
|
|
68
|
+
async execute(code: string, opts: ExecuteOptions = {}): Promise<Action> {
|
|
69
|
+
return this.recovery(() => this.executeOnce(code, opts.verbatim));
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
private async captureOnce({ includeScreenshot = false, codeBlock }: { includeScreenshot?: boolean; codeBlock?: string } = {}): Promise<ActionResult> {
|
|
@@ -270,7 +270,7 @@ class Action {
|
|
|
270
270
|
}
|
|
271
271
|
}
|
|
272
272
|
|
|
273
|
-
private async executeOnce(code: string): Promise<Action> {
|
|
273
|
+
private async executeOnce(code: string, verbatim = false): Promise<Action> {
|
|
274
274
|
let error: Error | null = null;
|
|
275
275
|
|
|
276
276
|
setActivity('🔎 Browsing...', 'action');
|
|
@@ -287,8 +287,8 @@ class Action {
|
|
|
287
287
|
const tracer = trace.getTracer('ai');
|
|
288
288
|
const stepSpan = activeSpan ? tracer.startSpan('codeceptjs.step', undefined, trace.setSpan(context.active(), activeSpan)) : null;
|
|
289
289
|
setStepSpanParent(stepSpan);
|
|
290
|
-
const sanitizedCode = sanitizeCodeBlock(codeString);
|
|
291
|
-
const isPlaywright = hasPlaywrightCommands(sanitizedCode);
|
|
290
|
+
const sanitizedCode = verbatim ? codeString : sanitizeCodeBlock(codeString);
|
|
291
|
+
const isPlaywright = !verbatim && hasPlaywrightCommands(sanitizedCode);
|
|
292
292
|
|
|
293
293
|
try {
|
|
294
294
|
debugLog('Executing action:', codeString);
|
|
@@ -386,6 +386,10 @@ export default Action;
|
|
|
386
386
|
|
|
387
387
|
export type RecoveryRunner = <T>(fn: () => Promise<T>) => Promise<T>;
|
|
388
388
|
|
|
389
|
+
export interface ExecuteOptions {
|
|
390
|
+
verbatim?: boolean;
|
|
391
|
+
}
|
|
392
|
+
|
|
389
393
|
function errorToString(error: any): string {
|
|
390
394
|
if (error.cliMessage) {
|
|
391
395
|
return error.cliMessage();
|
package/src/ai/captain/mixin.ts
CHANGED
|
@@ -16,7 +16,7 @@ export interface ModeContext {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
export function resolveProjectRoot(): string | null {
|
|
19
|
-
const
|
|
20
|
-
if (!
|
|
21
|
-
return
|
|
19
|
+
const configParser = ConfigParser.getInstance();
|
|
20
|
+
if (!configParser.getConfigPath()) return null;
|
|
21
|
+
return configParser.getProjectRoot();
|
|
22
22
|
}
|
|
@@ -27,7 +27,7 @@ export function WithWebMode<T extends Constructor>(Base: T) {
|
|
|
27
27
|
execute: async ({ destination }) => {
|
|
28
28
|
try {
|
|
29
29
|
debugLog('navigate', destination);
|
|
30
|
-
await ctx.explorBot.
|
|
30
|
+
await ctx.explorBot.visit(destination);
|
|
31
31
|
const stateManager = ctx.explorBot.stateManager();
|
|
32
32
|
const state = stateManager.getCurrentState();
|
|
33
33
|
return { success: true, url: state?.url, title: state?.title };
|
package/src/ai/navigator.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { HooksRunner } from '../utils/hooks-runner.ts';
|
|
|
13
13
|
import { createDebug, pluralize, tag } from '../utils/logger.js';
|
|
14
14
|
import { loop, pause } from '../utils/loop.js';
|
|
15
15
|
import { RulesLoader } from '../utils/rules-loader.ts';
|
|
16
|
-
import { extractStatePath } from '../utils/url-matcher.js';
|
|
16
|
+
import { extractStatePath, matchesNavigationUrl } from '../utils/url-matcher.js';
|
|
17
17
|
import type { Agent, AgentDeps } from './agent.js';
|
|
18
18
|
import type { Conversation } from './conversation.js';
|
|
19
19
|
import type { Provider } from './provider.js';
|
|
@@ -134,7 +134,7 @@ class Navigator implements Agent {
|
|
|
134
134
|
return false;
|
|
135
135
|
}
|
|
136
136
|
const currentUrl = this.getComparableCurrentUrl(stateManager, expectedUrl);
|
|
137
|
-
return
|
|
137
|
+
return matchesNavigationUrl(expectedUrl, currentUrl);
|
|
138
138
|
}
|
|
139
139
|
|
|
140
140
|
async visit(url: string): Promise<void> {
|
|
@@ -189,14 +189,16 @@ class Navigator implements Agent {
|
|
|
189
189
|
}
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
-
async resolveState(message: string, actionResult: ActionResult, opts?: { action?: Action; expectedUrl?: string }): Promise<boolean> {
|
|
192
|
+
async resolveState(message: string, actionResult: ActionResult, opts?: { action?: Action; expectedUrl?: string; onAttempt?: (attempt: { code: string; error?: string }) => void }): Promise<boolean> {
|
|
193
|
+
if (!this.provider) throw new Error('AI-assisted recovery is unavailable: no AI model is configured.');
|
|
194
|
+
|
|
193
195
|
tag('info').log('AI Navigator resolving state at', actionResult.url);
|
|
194
196
|
debugLog('Resolution message:', message);
|
|
195
197
|
|
|
196
198
|
const action = opts?.action ?? this.explorer.action();
|
|
197
199
|
const expectedUrl = opts?.expectedUrl;
|
|
198
200
|
|
|
199
|
-
const knowledge = this.knowledgeTracker.
|
|
201
|
+
const knowledge = this.knowledgeTracker.renderRelevantContext(actionResult);
|
|
200
202
|
let experience = '';
|
|
201
203
|
|
|
202
204
|
if (!actionResult.isInsideIframe) {
|
|
@@ -363,24 +365,27 @@ class Navigator implements Agent {
|
|
|
363
365
|
}
|
|
364
366
|
}
|
|
365
367
|
|
|
368
|
+
if (attemptOk) opts?.onAttempt?.({ code: codeBlock });
|
|
369
|
+
|
|
366
370
|
if (!attemptOk) {
|
|
367
371
|
const raw = action.lastError?.message || 'attempt failed';
|
|
368
372
|
const firstMeaningful = raw.split('\n').find((l) => l.trim() && !l.trim().startsWith('at ')) || raw;
|
|
369
373
|
const shortErr = firstMeaningful.replace(/\s+/g, ' ').trim().slice(0, 220);
|
|
370
374
|
batchFailures.push({ code: codeBlock, error: shortErr });
|
|
375
|
+
opts?.onAttempt?.({ code: codeBlock, error: shortErr });
|
|
371
376
|
}
|
|
372
377
|
|
|
373
378
|
if (expectedUrl) {
|
|
374
379
|
if (page) {
|
|
375
380
|
try {
|
|
376
|
-
await page.waitForURL((url: URL) =>
|
|
381
|
+
await page.waitForURL((url: URL) => matchesNavigationUrl(expectedUrl, `${url.pathname}${url.search}${url.hash}`), { timeout: 5000 });
|
|
377
382
|
} catch {
|
|
378
383
|
// URL did not transition to expectedUrl within timeout
|
|
379
384
|
}
|
|
380
385
|
}
|
|
381
386
|
const freshState = await this.explorer.capture();
|
|
382
387
|
const currentUrl = /^https?:\/\//i.test(expectedUrl) ? freshState.fullUrl || freshState.url || '' : freshState.url || '';
|
|
383
|
-
const urlMatches = this.isSameExpectedOrigin(expectedUrl, action.stateManager) &&
|
|
388
|
+
const urlMatches = this.isSameExpectedOrigin(expectedUrl, action.stateManager) && matchesNavigationUrl(expectedUrl, currentUrl);
|
|
384
389
|
const stateChanged = freshState.getStateHash() !== actionResult.getStateHash();
|
|
385
390
|
resolved = urlMatches && stateChanged;
|
|
386
391
|
|
|
@@ -625,7 +630,7 @@ class Navigator implements Agent {
|
|
|
625
630
|
return { verified: cachedVerification, successfulCodes: [], assertionSteps: [], totalAttempted: 0 };
|
|
626
631
|
}
|
|
627
632
|
|
|
628
|
-
const knowledge = this.knowledgeTracker.
|
|
633
|
+
const knowledge = this.knowledgeTracker.renderRelevantContext(actionResult);
|
|
629
634
|
let experience = '';
|
|
630
635
|
|
|
631
636
|
if (!actionResult.isInsideIframe) {
|
package/src/ai/planner.ts
CHANGED
|
@@ -53,6 +53,7 @@ export class Planner extends PlannerBase implements Agent {
|
|
|
53
53
|
provider: Provider;
|
|
54
54
|
stateManager: StateManager;
|
|
55
55
|
private experienceTracker: ExperienceTracker;
|
|
56
|
+
private knowledgeTracker: AgentDeps['knowledgeTracker'];
|
|
56
57
|
|
|
57
58
|
MIN_TASKS = 3;
|
|
58
59
|
MAX_TASKS = 12;
|
|
@@ -70,6 +71,7 @@ export class Planner extends PlannerBase implements Agent {
|
|
|
70
71
|
this.researcher = researcher;
|
|
71
72
|
this.stateManager = deps.stateManager;
|
|
72
73
|
this.experienceTracker = deps.stateManager.getExperienceTracker();
|
|
74
|
+
this.knowledgeTracker = deps.knowledgeTracker;
|
|
73
75
|
}
|
|
74
76
|
|
|
75
77
|
setFisherman(fisherman: Fisherman): void {
|
|
@@ -411,6 +413,11 @@ export class Planner extends PlannerBase implements Agent {
|
|
|
411
413
|
</page_research>
|
|
412
414
|
`);
|
|
413
415
|
|
|
416
|
+
const applicationContext = this.knowledgeTracker.renderApplicationSpec(state);
|
|
417
|
+
if (applicationContext) {
|
|
418
|
+
conversation.addUserText(applicationContext);
|
|
419
|
+
}
|
|
420
|
+
|
|
414
421
|
conversation.addUserText(dedent`
|
|
415
422
|
${this.buildApproach(style)}
|
|
416
423
|
|
|
@@ -228,7 +228,7 @@ export function WithLocators<T extends Constructor>(Base: T) {
|
|
|
228
228
|
const eidxList = section.elements.map((el) => el.eidx).filter(Boolean) as string[];
|
|
229
229
|
if (eidxList.length < 2) continue;
|
|
230
230
|
|
|
231
|
-
const ancestor = await this.explorer.
|
|
231
|
+
const ancestor = await this.explorer.withPage((page) => WebElement.commonAncestor(page, eidxList));
|
|
232
232
|
if (!ancestor) continue;
|
|
233
233
|
|
|
234
234
|
const candidates: string[] = [];
|
package/src/ai/researcher.ts
CHANGED
|
@@ -438,7 +438,7 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
438
438
|
if (!this.actionResult) throw new Error('actionResult is not set');
|
|
439
439
|
|
|
440
440
|
const html = await this.actionResult.combinedHtml();
|
|
441
|
-
const knowledge = this.knowledgeTracker.
|
|
441
|
+
const knowledge = this.knowledgeTracker.renderRelevantContext(this.actionResult);
|
|
442
442
|
|
|
443
443
|
const ariaSnapshot = this.actionResult.getCompactARIA();
|
|
444
444
|
|
package/src/ai/task-agent.ts
CHANGED
|
@@ -73,7 +73,7 @@ export abstract class TaskAgent {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
protected getKnowledge(actionResult: ActionResult): string {
|
|
76
|
-
return this.getKnowledgeTracker().
|
|
76
|
+
return this.getKnowledgeTracker().renderRelevantContext(actionResult);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
protected getExperience(actionResult: ActionResult): string {
|
package/src/ai/tester.ts
CHANGED
|
@@ -62,6 +62,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
62
62
|
private seenUiMapUrls = new Set<string>();
|
|
63
63
|
private lastAnalyzedStateHash: string | null = null;
|
|
64
64
|
private stalledIterations = 0;
|
|
65
|
+
private hasSuccessfulAssertion = false;
|
|
65
66
|
private readonly MAX_STALLED_ITERATIONS = 3;
|
|
66
67
|
|
|
67
68
|
constructor(deps: AgentDeps, researcher: Researcher, navigator: Navigator, agentTools?: any) {
|
|
@@ -110,6 +111,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
110
111
|
this.seenUiMapUrls.clear();
|
|
111
112
|
this.lastAnalyzedStateHash = null;
|
|
112
113
|
this.stalledIterations = 0;
|
|
114
|
+
this.hasSuccessfulAssertion = false;
|
|
113
115
|
this.stateManager.clearHistory();
|
|
114
116
|
this.resetFailureCount();
|
|
115
117
|
this.pilot?.reset();
|
|
@@ -312,9 +314,17 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
312
314
|
const allToolNames = result?.toolExecutions?.map((execution: any) => execution.toolName) || [];
|
|
313
315
|
const successfulToolNames = result?.toolExecutions?.filter((execution: any) => execution.wasSuccessful)?.map((execution: any) => execution.toolName) || [];
|
|
314
316
|
const actionPerformed = !!allToolNames.find((toolName: string) => this.ACTION_TOOLS.includes(toolName));
|
|
317
|
+
const successfulActionPerformed = !!successfulToolNames.find((toolName: string) => this.ACTION_TOOLS.includes(toolName));
|
|
315
318
|
assertionPerformed = !!successfulToolNames.find((toolName: string) => this.ASSERTION_TOOLS.includes(toolName));
|
|
316
319
|
const wasSuccessful = result?.toolExecutions?.every((execution: any) => execution.wasSuccessful);
|
|
317
320
|
|
|
321
|
+
if (successfulActionPerformed) {
|
|
322
|
+
this.hasSuccessfulAssertion = false;
|
|
323
|
+
}
|
|
324
|
+
if (assertionPerformed) {
|
|
325
|
+
this.hasSuccessfulAssertion = true;
|
|
326
|
+
}
|
|
327
|
+
|
|
318
328
|
this.trackToolExecutions(result?.toolExecutions || []);
|
|
319
329
|
|
|
320
330
|
if (this.consecutiveEmptyResults >= 5) {
|
|
@@ -464,6 +474,11 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
464
474
|
this.stalledIterations++;
|
|
465
475
|
if (this.stalledIterations < this.MAX_STALLED_ITERATIONS) return false;
|
|
466
476
|
|
|
477
|
+
if (this.hasSuccessfulAssertion) {
|
|
478
|
+
task.addNote('No further browser progress after successful verification; requesting final review');
|
|
479
|
+
return true;
|
|
480
|
+
}
|
|
481
|
+
|
|
467
482
|
task.addNote('No browser progress after repeated attempts on unchanged page', TestResult.FAILED);
|
|
468
483
|
task.finish(TestResult.FAILED);
|
|
469
484
|
return true;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const APPLICATION_SPEC_FORMAT = 'explorbot-application-spec';
|
|
4
|
+
export const APPLICATION_SPEC_VERSION = 1;
|
|
5
|
+
|
|
6
|
+
export const APPLICATION_SPEC_PAGE_SCHEMA = z.object({
|
|
7
|
+
format: z.literal(APPLICATION_SPEC_FORMAT),
|
|
8
|
+
version: z.literal(APPLICATION_SPEC_VERSION),
|
|
9
|
+
url: z.string().trim().min(1),
|
|
10
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { existsSync, statSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import dedent from 'dedent';
|
|
4
|
+
import { ActionResult } from './action-result.ts';
|
|
5
|
+
import { APPLICATION_SPEC_PAGE_SCHEMA } from './application-spec-contract.ts';
|
|
6
|
+
import { ConfigParser } from './config.ts';
|
|
7
|
+
import { tag } from './utils/logger.ts';
|
|
8
|
+
import { loadMarkdownFiles } from './utils/markdown-files.ts';
|
|
9
|
+
|
|
10
|
+
export class ApplicationSpec {
|
|
11
|
+
private pages: ApplicationSpecPage[] = [];
|
|
12
|
+
readonly sourcePath: string;
|
|
13
|
+
|
|
14
|
+
constructor(sourcePath: string) {
|
|
15
|
+
this.sourcePath = this.resolveSourcePath(sourcePath);
|
|
16
|
+
this.load();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
renderFor(state: ActionResult): string {
|
|
20
|
+
const relevant = this.pages.filter((page) => state.isMatchedBy({ url: page.url }));
|
|
21
|
+
if (relevant.length === 0) return '';
|
|
22
|
+
|
|
23
|
+
tag('operation').log(`Found application specification for ${state.url}`);
|
|
24
|
+
return dedent`
|
|
25
|
+
<application_spec>
|
|
26
|
+
This is previously collected application documentation. Treat User Can and observed state transitions as supporting context, not as a replacement for the current page state. Treat User Might as unverified possibilities that must be confirmed before use.
|
|
27
|
+
|
|
28
|
+
${relevant.map((page) => page.content).join('\n\n')}
|
|
29
|
+
</application_spec>
|
|
30
|
+
`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
get pageCount(): number {
|
|
34
|
+
return this.pages.length;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private load(): void {
|
|
38
|
+
if (!existsSync(this.sourcePath)) {
|
|
39
|
+
throw new Error(`Application spec not found: ${this.sourcePath}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const isDirectory = statSync(this.sourcePath).isDirectory();
|
|
43
|
+
if (!isDirectory && path.basename(this.sourcePath).toLowerCase() !== 'index.md') {
|
|
44
|
+
throw new Error(`Application spec file must be index.md: ${this.sourcePath}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const bundlePath = isDirectory ? this.sourcePath : path.dirname(this.sourcePath);
|
|
48
|
+
const indexPath = path.join(bundlePath, 'index.md');
|
|
49
|
+
if (!existsSync(indexPath)) {
|
|
50
|
+
throw new Error(`Application spec index not found: ${indexPath}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const pagesPath = path.join(bundlePath, 'pages');
|
|
54
|
+
if (!existsSync(pagesPath)) {
|
|
55
|
+
throw new Error(`Application spec pages directory not found: ${pagesPath}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for (const file of loadMarkdownFiles(pagesPath, { recursive: true })) {
|
|
59
|
+
const parsed = APPLICATION_SPEC_PAGE_SCHEMA.safeParse(file.data);
|
|
60
|
+
if (!parsed.success && parsed.error.issues.some((issue) => issue.path[0] === 'format')) {
|
|
61
|
+
throw new Error(`Invalid application spec format in ${file.filePath}`);
|
|
62
|
+
}
|
|
63
|
+
if (!parsed.success && parsed.error.issues.some((issue) => issue.path[0] === 'version')) {
|
|
64
|
+
throw new Error(`Unsupported application spec version in ${file.filePath}: ${String(file.data.version)}`);
|
|
65
|
+
}
|
|
66
|
+
if (!parsed.success) {
|
|
67
|
+
throw new Error(`Application spec page URL is missing in ${file.filePath}`);
|
|
68
|
+
}
|
|
69
|
+
this.pages.push({ url: parsed.data.url, content: file.content.trim() });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (this.pages.length === 0) {
|
|
73
|
+
throw new Error(`Application spec contains no documented pages: ${pagesPath}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private resolveSourcePath(sourcePath: string): string {
|
|
78
|
+
if (path.isAbsolute(sourcePath)) return path.resolve(sourcePath);
|
|
79
|
+
const configParser = ConfigParser.getInstance();
|
|
80
|
+
return path.resolve(configParser.resolveProjectDir(sourcePath));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface ApplicationSpecPage {
|
|
85
|
+
url: string;
|
|
86
|
+
content: string;
|
|
87
|
+
}
|