explorbot 0.3.2 → 0.3.4
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/bin/explorbot-cli.ts +14 -3
- package/dist/bin/explorbot-cli.js +12 -3
- package/dist/models.json +2 -0
- package/dist/package.json +1 -1
- package/dist/src/action-result.d.ts +1 -0
- package/dist/src/action-result.js +17 -12
- package/dist/src/action.d.ts +10 -0
- package/dist/src/action.js +16 -10
- package/dist/src/ai/conversation.d.ts +2 -1
- package/dist/src/ai/conversation.js +9 -4
- package/dist/src/ai/navigator.d.ts +3 -0
- package/dist/src/ai/navigator.js +20 -4
- package/dist/src/ai/pilot.js +7 -0
- package/dist/src/ai/researcher/deep-analysis.js +5 -1
- package/dist/src/ai/researcher/sections.js +0 -1
- package/dist/src/ai/researcher.js +0 -1
- package/dist/src/ai/rules.js +0 -1
- package/dist/src/ai/tester.d.ts +1 -0
- package/dist/src/ai/tester.js +8 -9
- package/dist/src/ai/tools.d.ts +2 -0
- package/dist/src/ai/tools.js +21 -4
- package/dist/src/commands/init-command.js +74 -24
- package/dist/src/components/InitWizard.d.ts +2 -1
- package/dist/src/components/InitWizard.js +8 -4
- package/dist/src/explorer.js +1 -0
- package/dist/src/knowledge-tracker.d.ts +3 -1
- package/dist/src/knowledge-tracker.js +4 -4
- package/dist/src/utils/aria.js +1 -1
- package/docs/basics/providers.md +2 -4
- package/models.json +2 -0
- package/package.json +1 -1
- package/src/action-result.ts +20 -15
- package/src/action.ts +21 -12
- package/src/ai/conversation.ts +11 -5
- package/src/ai/navigator.ts +22 -4
- package/src/ai/pilot.ts +7 -0
- package/src/ai/researcher/deep-analysis.ts +6 -1
- package/src/ai/researcher/sections.ts +0 -1
- package/src/ai/researcher.ts +0 -1
- package/src/ai/rules.ts +0 -1
- package/src/ai/tester.ts +8 -7
- package/src/ai/tools.ts +20 -4
- package/src/commands/init-command.ts +81 -22
- package/src/components/InitWizard.tsx +8 -4
- package/src/explorer.ts +1 -0
- package/src/knowledge-tracker.ts +4 -4
- package/src/utils/aria.ts +1 -1
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, extname, join, resolve } from 'node:path';
|
|
3
3
|
import chalk from 'chalk';
|
|
4
|
-
import dedent from 'dedent';
|
|
5
4
|
import { ConfigParser, PROVIDERS } from "../config.js";
|
|
6
5
|
import { findGlobalConfig, globalConfigPath, globalDir, globalEnvPath } from "../global-config.js";
|
|
7
6
|
import { getCliName } from "../utils/cli-name.js";
|
|
8
7
|
import { log, tag } from '../utils/logger.js';
|
|
9
8
|
import { relativeToCwd } from "../utils/next-steps.js";
|
|
10
|
-
function defaultConfigTemplate() {
|
|
9
|
+
function defaultConfigTemplate(provider, esm) {
|
|
10
|
+
let moduleExport = 'module.exports = config;';
|
|
11
|
+
if (esm)
|
|
12
|
+
moduleExport = 'export default config;';
|
|
11
13
|
return `// 'provider/model-id' uses a bundled provider.
|
|
12
|
-
// It is also possible to import provider as a module from Vercel AI SDK.
|
|
14
|
+
// It is also possible to import provider as a module from Vercel AI SDK.
|
|
13
15
|
// https://github.com/testomatio/explorbot/blob/main/docs/basics/providers.md
|
|
14
16
|
|
|
15
17
|
const config = {
|
|
@@ -19,7 +21,7 @@ const config = {
|
|
|
19
21
|
},
|
|
20
22
|
|
|
21
23
|
ai: {
|
|
22
|
-
${modelLines(
|
|
24
|
+
${modelLines(provider)}
|
|
23
25
|
},
|
|
24
26
|
|
|
25
27
|
reporter: {
|
|
@@ -32,16 +34,17 @@ ${modelLines('openrouter')}
|
|
|
32
34
|
},
|
|
33
35
|
};
|
|
34
36
|
|
|
35
|
-
|
|
37
|
+
${moduleExport}
|
|
36
38
|
`;
|
|
37
39
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
40
|
+
function envTemplate(provider) {
|
|
41
|
+
const keyLines = Object.entries(PROVIDERS).map(([name, { envKey }]) => {
|
|
42
|
+
if (name === provider)
|
|
43
|
+
return `${envKey}=`;
|
|
44
|
+
return `# ${envKey}=`;
|
|
45
|
+
});
|
|
46
|
+
return `# AI provider API keys
|
|
47
|
+
${keyLines.join('\n')}
|
|
45
48
|
|
|
46
49
|
# Langfuse Tracing
|
|
47
50
|
LANGFUSE_SECRET_KEY=
|
|
@@ -49,10 +52,11 @@ LANGFUSE_PUBLIC_KEY=
|
|
|
49
52
|
LANGFUSE_BASE_URL=
|
|
50
53
|
|
|
51
54
|
# Testomat.io API key to publish run results
|
|
52
|
-
TESTOMATIO
|
|
53
|
-
|
|
55
|
+
TESTOMATIO=`;
|
|
56
|
+
}
|
|
54
57
|
export async function runInit(options) {
|
|
55
|
-
|
|
58
|
+
const localRequested = !!(options.configPath || options.path);
|
|
59
|
+
if (options.global || (options.provider && !localRequested)) {
|
|
56
60
|
await runGlobalInit(options);
|
|
57
61
|
return;
|
|
58
62
|
}
|
|
@@ -61,8 +65,12 @@ export async function runInit(options) {
|
|
|
61
65
|
return;
|
|
62
66
|
}
|
|
63
67
|
const choice = await renderInitWizard('choose');
|
|
64
|
-
if (choice
|
|
65
|
-
|
|
68
|
+
if (choice !== 'local')
|
|
69
|
+
return;
|
|
70
|
+
const provider = await renderLocalProviderWizard();
|
|
71
|
+
if (!provider)
|
|
72
|
+
return;
|
|
73
|
+
runInitCommand({ ...options, provider });
|
|
66
74
|
}
|
|
67
75
|
export function writeGlobalConfig(provider, apiKey) {
|
|
68
76
|
if (!PROVIDERS[provider]) {
|
|
@@ -87,7 +95,7 @@ export function writeGlobalConfig(provider, apiKey) {
|
|
|
87
95
|
tag('substep').log(chalk.yellow(`${getCliName()} sites`));
|
|
88
96
|
}
|
|
89
97
|
export function runInitCommand(options) {
|
|
90
|
-
const
|
|
98
|
+
const provider = options.provider || 'openrouter';
|
|
91
99
|
const force = options.force ?? false;
|
|
92
100
|
const customPath = options.path;
|
|
93
101
|
const originalCwd = process.cwd();
|
|
@@ -100,13 +108,15 @@ export function runInitCommand(options) {
|
|
|
100
108
|
process.chdir(dir);
|
|
101
109
|
log(`Working in directory: ${relativeToCwd(dir)}`);
|
|
102
110
|
}
|
|
111
|
+
const configName = 'explorbot.config.js';
|
|
112
|
+
const configPath = options.configPath ?? `./${configName}`;
|
|
103
113
|
try {
|
|
104
114
|
let outPath = resolve(configPath);
|
|
105
115
|
if (existsSync(outPath) && statSync(outPath).isDirectory()) {
|
|
106
|
-
outPath = join(outPath,
|
|
116
|
+
outPath = join(outPath, configName);
|
|
107
117
|
}
|
|
108
118
|
else if (!extname(outPath)) {
|
|
109
|
-
outPath = join(outPath,
|
|
119
|
+
outPath = join(outPath, configName);
|
|
110
120
|
}
|
|
111
121
|
const dir = dirname(outPath);
|
|
112
122
|
if (!existsSync(dir)) {
|
|
@@ -118,23 +128,28 @@ export function runInitCommand(options) {
|
|
|
118
128
|
log('Use --force to overwrite existing file');
|
|
119
129
|
process.exit(1);
|
|
120
130
|
}
|
|
121
|
-
|
|
131
|
+
const esm = extname(outPath) !== '.js' || isModuleProject(dirname(outPath));
|
|
132
|
+
writeFileSync(outPath, defaultConfigTemplate(provider, esm), 'utf8');
|
|
122
133
|
log(`Created config file: ${relativeToCwd(outPath)}`);
|
|
123
134
|
const envPath = resolve(process.cwd(), '.env');
|
|
124
135
|
if (!existsSync(envPath)) {
|
|
125
|
-
writeFileSync(envPath, `${
|
|
136
|
+
writeFileSync(envPath, `${envTemplate(provider)}\n`, 'utf8');
|
|
126
137
|
log(`Created env file: ${relativeToCwd(envPath)}`);
|
|
127
138
|
}
|
|
128
139
|
else {
|
|
129
140
|
log(`Env file already exists: ${relativeToCwd(envPath)}`);
|
|
130
141
|
}
|
|
142
|
+
const missing = missingRoles(provider);
|
|
143
|
+
if (missing.length) {
|
|
144
|
+
tag('warning').log(`No recommended ${missing.join(' and ')} for ${provider} — set the model ids in ${relativeToCwd(outPath)}`);
|
|
145
|
+
}
|
|
131
146
|
log('');
|
|
132
147
|
log('Next steps:');
|
|
133
148
|
log('1. Configure AI provider in .env');
|
|
134
149
|
log('2. Set AI models config file');
|
|
135
150
|
log('3. Set web application URL in the config file');
|
|
136
151
|
log('4. Add initial knowledge (how to authorize to the application, etc.)');
|
|
137
|
-
tag('substep').log(chalk.yellow(`${getCliName()} learn * 'to
|
|
152
|
+
tag('substep').log(chalk.yellow(`${getCliName()} learn * 'to authorize use these credentials: admin@example.com / secret123'`));
|
|
138
153
|
tag('substep').log('You can use ${env.LOGIN} and ${env.PASSWORD} to reference environment variables.');
|
|
139
154
|
log('5. Launch application on a relative URL');
|
|
140
155
|
tag('substep').log(chalk.yellow(`${getCliName()} start /dashboard`));
|
|
@@ -187,6 +202,23 @@ async function renderInitWizard(mode) {
|
|
|
187
202
|
}), { exitOnCtrlC: false, patchConsole: false });
|
|
188
203
|
});
|
|
189
204
|
}
|
|
205
|
+
async function renderLocalProviderWizard() {
|
|
206
|
+
const [{ render }, React, InitWizard] = await Promise.all([import('ink'), import('react'), import('../components/InitWizard.js').then((m) => m.default)]);
|
|
207
|
+
return new Promise((resolve) => {
|
|
208
|
+
const finish = (provider) => {
|
|
209
|
+
unmount();
|
|
210
|
+
resolve(provider);
|
|
211
|
+
};
|
|
212
|
+
const { unmount } = render(React.createElement(InitWizard, {
|
|
213
|
+
mode: 'local',
|
|
214
|
+
globalConfigExists: !!findGlobalConfig(),
|
|
215
|
+
onLocal: () => finish(null),
|
|
216
|
+
onComplete: () => finish(null),
|
|
217
|
+
onCancel: () => finish(null),
|
|
218
|
+
onLocalProvider: (provider) => finish(provider),
|
|
219
|
+
}), { exitOnCtrlC: false, patchConsole: false });
|
|
220
|
+
});
|
|
221
|
+
}
|
|
190
222
|
function modelLines(provider) {
|
|
191
223
|
const recommended = ConfigParser.recommendedModels()[provider] || {};
|
|
192
224
|
const roles = [
|
|
@@ -216,9 +248,27 @@ ${modelLines(provider)}
|
|
|
216
248
|
},
|
|
217
249
|
};
|
|
218
250
|
|
|
219
|
-
|
|
251
|
+
module.exports = config;
|
|
220
252
|
`;
|
|
221
253
|
}
|
|
254
|
+
function isModuleProject(configDir) {
|
|
255
|
+
let currentDir = resolve(configDir);
|
|
256
|
+
while (true) {
|
|
257
|
+
const packagePath = join(currentDir, 'package.json');
|
|
258
|
+
if (existsSync(packagePath)) {
|
|
259
|
+
try {
|
|
260
|
+
return JSON.parse(readFileSync(packagePath, 'utf8')).type === 'module';
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const parentDir = dirname(currentDir);
|
|
267
|
+
if (parentDir === currentDir)
|
|
268
|
+
return false;
|
|
269
|
+
currentDir = parentDir;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
222
272
|
function missingRoles(provider) {
|
|
223
273
|
const recommended = ConfigParser.recommendedModels()[provider] || {};
|
|
224
274
|
return ['model', 'visionModel', 'agenticModel'].filter((role) => !recommended[role]);
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
interface InitWizardProps {
|
|
3
|
-
mode: 'choose' | 'global';
|
|
3
|
+
mode: 'choose' | 'global' | 'local';
|
|
4
4
|
globalConfigExists: boolean;
|
|
5
5
|
onLocal: () => void;
|
|
6
6
|
onComplete: () => void;
|
|
7
7
|
onCancel: () => void;
|
|
8
|
+
onLocalProvider?: (provider: string) => void;
|
|
8
9
|
}
|
|
9
10
|
declare const InitWizard: React.FC<InitWizardProps>;
|
|
10
11
|
export default InitWizard;
|
|
@@ -7,7 +7,7 @@ import { ConfigParser, PROVIDERS, createModel } from '../config.js';
|
|
|
7
7
|
import { globalDir } from '../global-config.js';
|
|
8
8
|
import InputReadline from './InputReadline.js';
|
|
9
9
|
const PROVIDER_NAMES = Object.keys(PROVIDERS);
|
|
10
|
-
const InitWizard = ({ mode, globalConfigExists, onLocal, onComplete, onCancel }) => {
|
|
10
|
+
const InitWizard = ({ mode, globalConfigExists, onLocal, onComplete, onCancel, onLocalProvider }) => {
|
|
11
11
|
const [step, setStep] = useState(mode === 'choose' ? 'target' : 'provider');
|
|
12
12
|
const [targetIndex, setTargetIndex] = useState(0);
|
|
13
13
|
const [providerIndex, setProviderIndex] = useState(0);
|
|
@@ -67,8 +67,12 @@ const InitWizard = ({ mode, globalConfigExists, onLocal, onComplete, onCancel })
|
|
|
67
67
|
setProviderIndex((index) => Math.max(0, index - 1));
|
|
68
68
|
if (key.downArrow)
|
|
69
69
|
setProviderIndex((index) => Math.min(PROVIDER_NAMES.length - 1, index + 1));
|
|
70
|
-
if (key.return)
|
|
71
|
-
|
|
70
|
+
if (key.return) {
|
|
71
|
+
if (mode === 'local')
|
|
72
|
+
onLocalProvider?.(provider);
|
|
73
|
+
else
|
|
74
|
+
setStep('key');
|
|
75
|
+
}
|
|
72
76
|
return;
|
|
73
77
|
}
|
|
74
78
|
if (status)
|
|
@@ -121,7 +125,7 @@ const InitWizard = ({ mode, globalConfigExists, onLocal, onComplete, onCancel })
|
|
|
121
125
|
React.createElement(Box, { marginTop: 1 },
|
|
122
126
|
React.createElement(Text, { dimColor: true },
|
|
123
127
|
"Config goes to ",
|
|
124
|
-
globalDir(),
|
|
128
|
+
mode === 'local' ? 'the current directory' : globalDir(),
|
|
125
129
|
" | ",
|
|
126
130
|
step === 'key' ? 'Enter: continue' : '↑↓: select | Enter: confirm',
|
|
127
131
|
" | Ctrl+C: exit"))));
|
package/dist/src/explorer.js
CHANGED
|
@@ -203,6 +203,7 @@ class Explorer {
|
|
|
203
203
|
const projectRoot = configParser.getProjectRoot();
|
|
204
204
|
global.output_dir = configParser.getStatesDir();
|
|
205
205
|
global.codecept_dir = projectRoot;
|
|
206
|
+
global.codeceptjs = codeceptjs;
|
|
206
207
|
configParser.validateConfig(this.config);
|
|
207
208
|
codeceptjs.container.create(this.convertToCodeceptConfig(this.config), {});
|
|
208
209
|
}
|
|
@@ -17,7 +17,9 @@ export declare class KnowledgeTracker {
|
|
|
17
17
|
renderRelevantKnowledge(state: ActionResult): string;
|
|
18
18
|
renderRelevantContext(state: ActionResult): string;
|
|
19
19
|
renderApplicationSpec(state: ActionResult): string;
|
|
20
|
-
addKnowledge(urlPattern: string, description: string
|
|
20
|
+
addKnowledge(urlPattern: string, description: string, opts?: {
|
|
21
|
+
replace?: boolean;
|
|
22
|
+
}): {
|
|
21
23
|
filename: string;
|
|
22
24
|
filePath: string;
|
|
23
25
|
isNewFile: boolean;
|
|
@@ -73,7 +73,7 @@ export class KnowledgeTracker {
|
|
|
73
73
|
renderApplicationSpec(state) {
|
|
74
74
|
return this.applicationSpec?.renderFor(state) || '';
|
|
75
75
|
}
|
|
76
|
-
addKnowledge(urlPattern, description) {
|
|
76
|
+
addKnowledge(urlPattern, description, opts) {
|
|
77
77
|
const configParser = ConfigParser.getInstance();
|
|
78
78
|
const configPath = configParser.getConfigPath();
|
|
79
79
|
if (!configPath) {
|
|
@@ -102,11 +102,11 @@ export class KnowledgeTracker {
|
|
|
102
102
|
const existingDescription = parsed.content.trim();
|
|
103
103
|
// Append new knowledge with separator
|
|
104
104
|
let newContent;
|
|
105
|
-
if (existingDescription) {
|
|
106
|
-
newContent =
|
|
105
|
+
if (opts?.replace || !existingDescription) {
|
|
106
|
+
newContent = description;
|
|
107
107
|
}
|
|
108
108
|
else {
|
|
109
|
-
newContent = description
|
|
109
|
+
newContent = `${existingDescription}\n\n---\n\n${description}`;
|
|
110
110
|
}
|
|
111
111
|
const fileContent = matter.stringify(newContent, frontmatter);
|
|
112
112
|
writeFileSync(filePath, fileContent, 'utf8');
|
package/dist/src/utils/aria.js
CHANGED
|
@@ -569,7 +569,7 @@ export function parseAriaLocator(ariaStr) {
|
|
|
569
569
|
const trimmed = ariaStr.trim();
|
|
570
570
|
if (trimmed === '-' || trimmed === '' || trimmed === '"-"')
|
|
571
571
|
return null;
|
|
572
|
-
const match = trimmed.match(/\{\s*["']?role["']?\s*:\s*['"]([^'"]+)['"]\s*,\s*["']?text["']?\s*:\s*['"]([^'"]*)['"]\s*\}/);
|
|
572
|
+
const match = trimmed.match(/\{\s*["']?role["']?\s*:\s*['"]([^'"]+)['"]\s*,\s*["']?(?:text|name)["']?\s*:\s*['"]([^'"]*)['"]\s*\}/);
|
|
573
573
|
if (!match)
|
|
574
574
|
return null;
|
|
575
575
|
return { role: match[1], text: match[2] };
|
package/docs/basics/providers.md
CHANGED
|
@@ -154,14 +154,12 @@ Set the recommended model in the exported config:
|
|
|
154
154
|
```javascript
|
|
155
155
|
export default {
|
|
156
156
|
ai: {
|
|
157
|
+
model: anthropic('claude-haiku-4-5-20251001'),
|
|
158
|
+
visionModel: anthropic('claude-haiku-4-5-20251001'),
|
|
157
159
|
agenticModel: anthropic('claude-haiku-4-5-20251001'),
|
|
158
160
|
},
|
|
159
161
|
};
|
|
160
162
|
```
|
|
161
|
-
|
|
162
|
-
> [!NOTE]
|
|
163
|
-
> This provider currently doesn't serve `model` and `visionModel`, which is required for Explorbot to run at optimal cost and speed.
|
|
164
|
-
> It is recommended to pair it with another AI provider.
|
|
165
163
|
<!-- END provider:anthropic -->
|
|
166
164
|
|
|
167
165
|
### Azure OpenAI
|
package/models.json
CHANGED
package/package.json
CHANGED
package/src/action-result.ts
CHANGED
|
@@ -550,17 +550,9 @@ export class ActionResult implements ActionResultData {
|
|
|
550
550
|
}
|
|
551
551
|
|
|
552
552
|
if (diff.htmlParts.length > 0) {
|
|
553
|
-
const
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
const filteredHtml = htmlCombinedSnapshot(part.subtree, htmlConfig?.combined);
|
|
557
|
-
const minified = await minifyHtml(filteredHtml);
|
|
558
|
-
if (minified) {
|
|
559
|
-
processedParts.push({ ...part, subtree: minified });
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
if (processedParts.length > 0) {
|
|
563
|
-
pageDiff.htmlParts = collapseHtmlParts(processedParts);
|
|
553
|
+
const collapsed = collapseHtmlParts(await diff.cleanedHtmlParts());
|
|
554
|
+
if (collapsed.length > 0) {
|
|
555
|
+
pageDiff.htmlParts = collapsed;
|
|
564
556
|
}
|
|
565
557
|
}
|
|
566
558
|
|
|
@@ -601,10 +593,12 @@ function collapseHtmlParts(parts: HtmlDiffPart[]): HtmlDiffPart[] {
|
|
|
601
593
|
const fullPageReRender = total > HTML_PARTS_TOTAL_BUDGET || parts.length > HTML_PARTS_COUNT_LIMIT;
|
|
602
594
|
|
|
603
595
|
if (fullPageReRender) {
|
|
604
|
-
return parts
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
596
|
+
return parts
|
|
597
|
+
.filter((part) => part.added.length > 0 || part.removed.length > 0)
|
|
598
|
+
.map((part) => ({
|
|
599
|
+
...part,
|
|
600
|
+
subtree: `<html><head></head><body>...collapsed (${part.subtree.length} chars, ${part.added.length} added, ${part.removed.length} removed)...</body></html>`,
|
|
601
|
+
}));
|
|
608
602
|
}
|
|
609
603
|
|
|
610
604
|
return parts.map((part) => {
|
|
@@ -660,6 +654,17 @@ export class Diff {
|
|
|
660
654
|
return this._htmlDiffResult.parts;
|
|
661
655
|
}
|
|
662
656
|
|
|
657
|
+
async cleanedHtmlParts(): Promise<HtmlDiffPart[]> {
|
|
658
|
+
const htmlConfig = ConfigParser.getInstance().getConfig().html;
|
|
659
|
+
const cleaned: HtmlDiffPart[] = [];
|
|
660
|
+
for (const part of this.htmlParts) {
|
|
661
|
+
const minified = await minifyHtml(htmlCombinedSnapshot(part.subtree, htmlConfig?.combined));
|
|
662
|
+
if (!minified) continue;
|
|
663
|
+
cleaned.push({ ...part, subtree: minified });
|
|
664
|
+
}
|
|
665
|
+
return cleaned;
|
|
666
|
+
}
|
|
667
|
+
|
|
663
668
|
get ariaChanged(): string | null {
|
|
664
669
|
return this._ariaDiffResult;
|
|
665
670
|
}
|
package/src/action.ts
CHANGED
|
@@ -37,6 +37,7 @@ class Action {
|
|
|
37
37
|
public playwrightHelper: any;
|
|
38
38
|
public playwrightGroupId: string | null = null;
|
|
39
39
|
public assertionSteps: Array<{ name: string; args: any[] }> = [];
|
|
40
|
+
public executedSteps: ExecutedStep[] = [];
|
|
40
41
|
public lastValue: unknown;
|
|
41
42
|
private recorder?: PlaywrightRecorder;
|
|
42
43
|
private recovery: RecoveryRunner;
|
|
@@ -329,9 +330,9 @@ class Action {
|
|
|
329
330
|
|
|
330
331
|
let codeString = code.replace(/^\(I\) => /, '').trim();
|
|
331
332
|
|
|
332
|
-
const executedSteps:
|
|
333
|
+
const executedSteps: ExecutedStep[] = [];
|
|
333
334
|
const assertionSteps: Array<{ name: string; args: any[] }> = [];
|
|
334
|
-
const
|
|
335
|
+
const detachSteps = attachStepLogger(executedSteps, assertionSteps);
|
|
335
336
|
const groupId = this.recorder ? await this.recorder.beginAction(codeString) : null;
|
|
336
337
|
this.playwrightGroupId = groupId;
|
|
337
338
|
const detachResponses = this.captureResponses();
|
|
@@ -360,12 +361,13 @@ class Action {
|
|
|
360
361
|
await recorder.add(() => sleep(this.config.action?.delay || 500));
|
|
361
362
|
await recorder.promise();
|
|
362
363
|
this.lastValue = await returned;
|
|
364
|
+
if (!recorder.isRunning()) throw new Error('CodeceptJS recorder is stopped, commands were skipped and never reached the browser');
|
|
363
365
|
}
|
|
364
366
|
|
|
365
367
|
this.restorePageTimeout();
|
|
366
368
|
|
|
367
369
|
if (executedSteps.length > 0) {
|
|
368
|
-
codeString = executedSteps.join('\n');
|
|
370
|
+
codeString = executedSteps.map((step) => step.command).join('\n');
|
|
369
371
|
}
|
|
370
372
|
|
|
371
373
|
const pageState = await this.captureOnce({ codeBlock: codeString });
|
|
@@ -382,10 +384,11 @@ class Action {
|
|
|
382
384
|
this.assertionSteps = [];
|
|
383
385
|
throw err;
|
|
384
386
|
} finally {
|
|
387
|
+
this.executedSteps = executedSteps;
|
|
385
388
|
this.restorePageTimeout();
|
|
386
389
|
detachResponses();
|
|
387
390
|
if (groupId) await this.recorder!.endAction();
|
|
388
|
-
|
|
391
|
+
detachSteps();
|
|
389
392
|
if (stepSpan) {
|
|
390
393
|
stepSpan.end();
|
|
391
394
|
}
|
|
@@ -487,11 +490,13 @@ const ASSERTION_STEP_NAMES = new Set(['see', 'dontSee', 'seeElement', 'dontSeeEl
|
|
|
487
490
|
|
|
488
491
|
type StepListener = (step: any, error?: any) => void;
|
|
489
492
|
|
|
490
|
-
const attachStepLogger = (target:
|
|
493
|
+
export const attachStepLogger = (target: ExecutedStep[], assertionsTarget?: Array<{ name: string; args: any[] }>): (() => void) => {
|
|
491
494
|
const listener: StepListener = (step, error) => {
|
|
492
495
|
if (!step?.toCode) return;
|
|
493
496
|
if (step.name?.startsWith('grab')) return;
|
|
494
|
-
|
|
497
|
+
const executed: ExecutedStep = { command: step.toCode(), success: !error };
|
|
498
|
+
if (error) executed.error = errorToString(error);
|
|
499
|
+
target.push(executed);
|
|
495
500
|
if (assertionsTarget && ASSERTION_STEP_NAMES.has(step.name)) {
|
|
496
501
|
assertionsTarget.push({ name: step.name, args: step.args || [] });
|
|
497
502
|
}
|
|
@@ -503,12 +508,10 @@ const attachStepLogger = (target: string[], assertionsTarget?: Array<{ name: str
|
|
|
503
508
|
};
|
|
504
509
|
codeceptjs.event.dispatcher.on(codeceptjs.event.step.passed, listener);
|
|
505
510
|
codeceptjs.event.dispatcher.on(codeceptjs.event.step.failed, listener);
|
|
506
|
-
return
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
codeceptjs.event.dispatcher.off(codeceptjs.event.step.passed, listener);
|
|
511
|
-
codeceptjs.event.dispatcher.off(codeceptjs.event.step.failed, listener);
|
|
511
|
+
return () => {
|
|
512
|
+
codeceptjs.event.dispatcher.off(codeceptjs.event.step.passed, listener);
|
|
513
|
+
codeceptjs.event.dispatcher.off(codeceptjs.event.step.failed, listener);
|
|
514
|
+
};
|
|
512
515
|
};
|
|
513
516
|
|
|
514
517
|
const readFocusedElement = () => {
|
|
@@ -530,3 +533,9 @@ const readFocusedElement = () => {
|
|
|
530
533
|
if (typeof value === 'string' && value) focused.value = value.slice(0, 200);
|
|
531
534
|
return focused;
|
|
532
535
|
};
|
|
536
|
+
|
|
537
|
+
export interface ExecutedStep {
|
|
538
|
+
command: string;
|
|
539
|
+
success: boolean;
|
|
540
|
+
error?: string;
|
|
541
|
+
}
|
package/src/ai/conversation.ts
CHANGED
|
@@ -5,12 +5,13 @@ export interface ToolExecution {
|
|
|
5
5
|
input: any;
|
|
6
6
|
output: any;
|
|
7
7
|
wasSuccessful: boolean;
|
|
8
|
+
reasoning?: string;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
|
-
export function toToolExecution(toolName: string, input: any, rawOutput: any): ToolExecution {
|
|
11
|
+
export function toToolExecution(toolName: string, input: any, rawOutput: any, reasoning?: string): ToolExecution {
|
|
11
12
|
let output = rawOutput;
|
|
12
13
|
if (rawOutput?.type === 'json' && rawOutput?.value) output = rawOutput.value;
|
|
13
|
-
return { toolName, input, output, wasSuccessful: output?.success !== false };
|
|
14
|
+
return { toolName, input, output, wasSuccessful: output?.success !== false, reasoning };
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export function toolExecutionLabel(input: Record<string, any> | undefined): string {
|
|
@@ -213,13 +214,17 @@ export class Conversation {
|
|
|
213
214
|
}
|
|
214
215
|
|
|
215
216
|
getToolExecutions(): ToolExecution[] {
|
|
216
|
-
const toolCalls = new Map<string, any>();
|
|
217
|
+
const toolCalls = new Map<string, { input: any; reasoning?: string }>();
|
|
217
218
|
for (const message of this.messages) {
|
|
218
219
|
if (message.role !== 'assistant') continue;
|
|
219
220
|
if (!Array.isArray(message.content)) continue;
|
|
221
|
+
const reasoning = message.content
|
|
222
|
+
.filter((part: any) => part.type === 'reasoning' && part.text?.trim())
|
|
223
|
+
.map((part: any) => part.text.trim())
|
|
224
|
+
.join('\n');
|
|
220
225
|
for (const part of message.content) {
|
|
221
226
|
if (part.type !== 'tool-call') continue;
|
|
222
|
-
toolCalls.set(part.toolCallId, part.input);
|
|
227
|
+
toolCalls.set(part.toolCallId, { input: part.input, reasoning });
|
|
223
228
|
}
|
|
224
229
|
}
|
|
225
230
|
|
|
@@ -230,7 +235,8 @@ export class Conversation {
|
|
|
230
235
|
for (const part of message.content) {
|
|
231
236
|
if (part.type !== 'tool-result') continue;
|
|
232
237
|
if (part.toolName === NARRATION_TOOL) continue;
|
|
233
|
-
|
|
238
|
+
const call = toolCalls.get(part.toolCallId);
|
|
239
|
+
executions.push(toToolExecution(part.toolName, call?.input || {}, part.output, call?.reasoning));
|
|
234
240
|
}
|
|
235
241
|
}
|
|
236
242
|
|
package/src/ai/navigator.ts
CHANGED
|
@@ -3,12 +3,13 @@ import dedent from 'dedent';
|
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { ActionResult } from '../action-result.js';
|
|
5
5
|
import type Action from '../action.ts';
|
|
6
|
+
import type { ExecutedStep } from '../action.ts';
|
|
6
7
|
import type { ExplorbotConfig } from '../config.ts';
|
|
7
8
|
import type { ExperienceTracker } from '../experience-tracker.js';
|
|
8
9
|
import Explorer from '../explorer.ts';
|
|
9
10
|
import type { KnowledgeTracker } from '../knowledge-tracker.js';
|
|
10
|
-
import { type StateManager, normalizeUrl } from '../state-manager.js';
|
|
11
11
|
import { renderAssertion } from '../playwright-recorder.ts';
|
|
12
|
+
import { type StateManager, normalizeUrl } from '../state-manager.js';
|
|
12
13
|
import { isFatalBrowserError } from '../utils/browser-errors.ts';
|
|
13
14
|
import { getCliName } from '../utils/cli-name.ts';
|
|
14
15
|
import { extractCodeBlocks } from '../utils/code-extractor.js';
|
|
@@ -37,6 +38,7 @@ class Navigator implements Agent {
|
|
|
37
38
|
|
|
38
39
|
private MAX_ATTEMPTS = Number.parseInt(process.env.MAX_ATTEMPTS || '5');
|
|
39
40
|
lastFailureReason: string | null = null;
|
|
41
|
+
executedSteps: ExecutedStep[] = [];
|
|
40
42
|
|
|
41
43
|
private systemPrompt = dedent`
|
|
42
44
|
<role>
|
|
@@ -217,12 +219,18 @@ class Navigator implements Agent {
|
|
|
217
219
|
if (!this.provider) throw new Error('AI-assisted recovery is unavailable: no AI model is configured.');
|
|
218
220
|
|
|
219
221
|
this.lastFailureReason = null;
|
|
222
|
+
this.executedSteps = [];
|
|
220
223
|
tag('info').log('AI Navigator resolving state at', actionResult.url);
|
|
221
224
|
debugLog('Resolution message:', message);
|
|
222
225
|
|
|
223
226
|
const action = opts?.action ?? this.explorer.action();
|
|
224
227
|
const expectedUrl = opts?.expectedUrl;
|
|
225
228
|
|
|
229
|
+
if (expectedUrl && this.targetUrlReached(action, expectedUrl, actionResult)) {
|
|
230
|
+
tag('success').log(`Already at ${expectedUrl} — navigation resolved`);
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
|
|
226
234
|
const knowledge = this.knowledgeTracker.renderRelevantContext(actionResult);
|
|
227
235
|
|
|
228
236
|
const conversation = this.provider.startConversation(this.systemPrompt, 'navigator');
|
|
@@ -313,14 +321,19 @@ class Navigator implements Agent {
|
|
|
313
321
|
resolved = check.urlMatches && freshHash !== actionResult.getStateHash();
|
|
314
322
|
|
|
315
323
|
if (!resolved && attempt.ok) {
|
|
316
|
-
|
|
324
|
+
if (check.urlMatches) {
|
|
325
|
+
lastFailure = `Reached ${check.freshState.url} but the page state did not change`;
|
|
326
|
+
tag('warning').log(`Page state did not change at ${check.freshState.url}`);
|
|
327
|
+
} else {
|
|
328
|
+
lastFailure = `Reached ${check.freshState.url}, expected ${expectedUrl}`;
|
|
329
|
+
tag('warning').log(`URL verification failed: expected ${expectedUrl}, got ${check.freshState.url}`);
|
|
330
|
+
}
|
|
317
331
|
batchFailures.push({
|
|
318
332
|
code: codeBlock,
|
|
319
333
|
error: lastFailure,
|
|
320
334
|
ariaChanges: await this.ariaDiff(check.freshState, prevActionResult),
|
|
321
335
|
urlAfter: check.freshState.url,
|
|
322
336
|
});
|
|
323
|
-
tag('warning').log(`URL verification failed: expected ${expectedUrl}, got ${check.freshState.url}`);
|
|
324
337
|
}
|
|
325
338
|
if (freshHash !== prevHash && (attempt.ok || check.urlMatches)) {
|
|
326
339
|
progressBlocks.push(codeBlock);
|
|
@@ -456,6 +469,7 @@ class Navigator implements Agent {
|
|
|
456
469
|
|
|
457
470
|
debugLog(`Attempting resolution: ${codeBlock}`);
|
|
458
471
|
const ok = await action.attempt(codeBlock, message);
|
|
472
|
+
this.executedSteps.push(...action.executedSteps);
|
|
459
473
|
|
|
460
474
|
const page = action.playwrightHelper?.page;
|
|
461
475
|
if (page) {
|
|
@@ -484,11 +498,15 @@ class Navigator implements Agent {
|
|
|
484
498
|
}
|
|
485
499
|
|
|
486
500
|
const freshState = await this.explorer.capture();
|
|
487
|
-
const urlMatches = this.
|
|
501
|
+
const urlMatches = this.targetUrlReached(action, expectedUrl, freshState);
|
|
488
502
|
|
|
489
503
|
return { freshState, urlMatches };
|
|
490
504
|
}
|
|
491
505
|
|
|
506
|
+
private targetUrlReached(action: Action, expectedUrl: string, state: ActionResult): boolean {
|
|
507
|
+
return this.isSameExpectedOrigin(expectedUrl, action.stateManager) && matchesNavigationUrl(expectedUrl, this.comparableUrl(state, expectedUrl));
|
|
508
|
+
}
|
|
509
|
+
|
|
492
510
|
private async ariaDiff(freshState: ActionResult, previous: ActionResult): Promise<string | null> {
|
|
493
511
|
if (freshState.getStateHash() === previous.getStateHash()) return null;
|
|
494
512
|
try {
|
package/src/ai/pilot.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { withdrawVisionTools } from './tools.ts';
|
|
|
28
28
|
const CHECK_TOOLS = ['verify', 'see', 'research'];
|
|
29
29
|
const EVIDENCE_TOOLS = ['verify', 'see'];
|
|
30
30
|
const META_TOOLS = ['record', 'reset', 'stop', 'finish'];
|
|
31
|
+
const PILOT_REASONING_LIMIT = 500;
|
|
31
32
|
const PILOT_MESSAGE_LIMIT = 2;
|
|
32
33
|
const PILOT_MESSAGE_MAX_LENGTH = 160;
|
|
33
34
|
|
|
@@ -1044,6 +1045,12 @@ export class Pilot implements Agent {
|
|
|
1044
1045
|
if (resultMessage) line += `\n result: ${resultMessage}`;
|
|
1045
1046
|
if (errorDetail && errorDetail !== resultMessage) line += `\n error: ${errorDetail}`;
|
|
1046
1047
|
|
|
1048
|
+
if (!t.wasSuccessful && t.reasoning) {
|
|
1049
|
+
let rationale = t.reasoning;
|
|
1050
|
+
if (rationale.length > PILOT_REASONING_LIMIT) rationale = `...${rationale.slice(-PILOT_REASONING_LIMIT)}`;
|
|
1051
|
+
line += `\n tester reasoned: ${rationale.replace(/\n+/g, ' ')}`;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1047
1054
|
const attempts = t.output?.attempts;
|
|
1048
1055
|
if (attempts && attempts.length > 1 && t.wasSuccessful) {
|
|
1049
1056
|
const failedBefore = attempts.filter((a: any) => !a.success);
|