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,635 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import dedent from 'dedent';
|
|
4
|
+
import * as playwright from 'playwright';
|
|
5
|
+
import { ActionResult } from "../../../src/action-result.js";
|
|
6
|
+
import { actionRule, locatorRule } from "../../../src/ai/rules.js";
|
|
7
|
+
import { createCodeceptJSTools } from "../../../src/ai/tools.js";
|
|
8
|
+
import { getAliveEndpoint, launchServer, listInstances, stopServer } from "../../../src/browser-server.js";
|
|
9
|
+
import { ConfigMissingError, ConfigParser, outputPath } from "../../../src/config.js";
|
|
10
|
+
import { ExplorBot } from "../../../src/explorbot.js";
|
|
11
|
+
import { Task } from "../../../src/test-plan.js";
|
|
12
|
+
import { compactAriaSnapshot } from "../../../src/utils/aria.js";
|
|
13
|
+
import { browserErrorMessage } from "../../../src/utils/browser-errors.js";
|
|
14
|
+
import { pluralize } from "../../../src/utils/logger.js";
|
|
15
|
+
import { writeArtifacts } from "./envelope.js";
|
|
16
|
+
import { isFunctionExpression, toCodeceptWrapper } from "./pw-parser.js";
|
|
17
|
+
import { readDescriptors, selectDescriptor } from "./pw-registry.js";
|
|
18
|
+
const MAX_INSTRUCTION_ITERATIONS = 6;
|
|
19
|
+
const MAX_TOOL_ROUNDTRIPS = 5;
|
|
20
|
+
const AI_AGENT_NAME = 'prima';
|
|
21
|
+
const CONNECT_TIMEOUT = 3000;
|
|
22
|
+
const requireLib = createRequire(import.meta.url);
|
|
23
|
+
export class Prima {
|
|
24
|
+
options;
|
|
25
|
+
bot;
|
|
26
|
+
artifactsDir;
|
|
27
|
+
server = null;
|
|
28
|
+
attached = null;
|
|
29
|
+
constructor(options = {}) {
|
|
30
|
+
this.options = options;
|
|
31
|
+
this.bot = new ExplorBot({
|
|
32
|
+
config: options.config,
|
|
33
|
+
path: options.path,
|
|
34
|
+
baseUrl: this.configBaseUrl(),
|
|
35
|
+
verbose: options.verbose,
|
|
36
|
+
session: options.session,
|
|
37
|
+
instance: options.instance,
|
|
38
|
+
headless: true,
|
|
39
|
+
optionalAi: true,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
async start() {
|
|
43
|
+
const config = await this.loadConfig();
|
|
44
|
+
await this.resolveBrowser(config);
|
|
45
|
+
await this.bot.start();
|
|
46
|
+
if (!this.options.url)
|
|
47
|
+
return;
|
|
48
|
+
if (this.bot.getCurrentState())
|
|
49
|
+
return;
|
|
50
|
+
await this.bot.visit(this.options.url);
|
|
51
|
+
}
|
|
52
|
+
async stop() {
|
|
53
|
+
await this.bot.stop();
|
|
54
|
+
}
|
|
55
|
+
async pw(expression) {
|
|
56
|
+
const command = `pw ${expression}`;
|
|
57
|
+
const validation = isFunctionExpression(expression);
|
|
58
|
+
if (!validation.valid)
|
|
59
|
+
return this.toolFailureEnvelope(command, validation.error);
|
|
60
|
+
const previousState = this.bot.stateManager().getCurrentState();
|
|
61
|
+
let result = null;
|
|
62
|
+
let executionError = null;
|
|
63
|
+
try {
|
|
64
|
+
const executed = await this.bot.getExplorer().action().execute(toCodeceptWrapper(expression), { verbatim: true });
|
|
65
|
+
result = executed.actionResult;
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
executionError = error;
|
|
69
|
+
}
|
|
70
|
+
if (executionError)
|
|
71
|
+
return this.heal(command, expression, executionError, previousState);
|
|
72
|
+
result ||= await this.capturedResult(previousState);
|
|
73
|
+
return this.successEnvelope(command, [expression], result, previousState);
|
|
74
|
+
}
|
|
75
|
+
async do(instructions) {
|
|
76
|
+
const command = `do ${instructions.map((instruction) => `"${instruction}"`).join(' ')}`;
|
|
77
|
+
const guard = await this.aiGuard(command);
|
|
78
|
+
if (guard)
|
|
79
|
+
return guard;
|
|
80
|
+
const provider = this.bot.getProvider();
|
|
81
|
+
const previousState = this.bot.stateManager().getCurrentState();
|
|
82
|
+
const conversation = provider.startConversation(this.instructionSystemPrompt(), AI_AGENT_NAME);
|
|
83
|
+
const task = new Task(instructions.join('; '), previousState?.url || '');
|
|
84
|
+
const tools = createCodeceptJSTools({ explorer: this.bot.getExplorer(), stateManager: this.bot.stateManager(), ai: provider }, task);
|
|
85
|
+
conversation.addUserText(this.instructionPrompt(instructions, await this.capturedResult(previousState)));
|
|
86
|
+
const used = [];
|
|
87
|
+
let failure = null;
|
|
88
|
+
let aiError = null;
|
|
89
|
+
let narration = '';
|
|
90
|
+
let contextHash = this.bot.stateManager().getCurrentState()?.hash;
|
|
91
|
+
for (let iteration = 1; iteration <= Math.min(instructions.length + 2, MAX_INSTRUCTION_ITERATIONS); iteration++) {
|
|
92
|
+
const state = this.bot.stateManager().getCurrentState();
|
|
93
|
+
if (iteration > 1 && state && state.hash !== contextHash) {
|
|
94
|
+
contextHash = state.hash;
|
|
95
|
+
conversation.addUserText(this.pageContext(ActionResult.fromState(state)));
|
|
96
|
+
}
|
|
97
|
+
const invoked = await provider.invokeConversation(conversation, tools, { maxToolRoundtrips: MAX_TOOL_ROUNDTRIPS, agentName: AI_AGENT_NAME }).catch((error) => {
|
|
98
|
+
aiError = error;
|
|
99
|
+
return null;
|
|
100
|
+
});
|
|
101
|
+
if (!invoked)
|
|
102
|
+
break;
|
|
103
|
+
const executions = invoked.toolExecutions || [];
|
|
104
|
+
if (!executions.length) {
|
|
105
|
+
narration = invoked.response?.text?.trim() || '';
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
for (const execution of executions) {
|
|
109
|
+
if (!execution.wasSuccessful) {
|
|
110
|
+
failure = { code: execution.output?.code || '', message: execution.output?.message || 'action failed' };
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
used.push(...this.executedCodes(execution.output?.code));
|
|
114
|
+
failure = null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (aiError)
|
|
118
|
+
return this.failureEnvelope(command, aiError, previousState);
|
|
119
|
+
if (failure) {
|
|
120
|
+
const envelope = await this.heal(command, failure.code || instructions.join('; '), failure.message, previousState);
|
|
121
|
+
envelope.used = [...used, ...(envelope.used || [])];
|
|
122
|
+
return envelope;
|
|
123
|
+
}
|
|
124
|
+
if (!used.length) {
|
|
125
|
+
const reason = ['No action was performed for these instructions on the current page.', narration].filter(Boolean).join(' ');
|
|
126
|
+
return this.failureEnvelope(command, reason, previousState);
|
|
127
|
+
}
|
|
128
|
+
const result = await this.capturedResult(this.bot.stateManager().getCurrentState());
|
|
129
|
+
return this.successEnvelope(command, used, result, previousState);
|
|
130
|
+
}
|
|
131
|
+
async click(target) {
|
|
132
|
+
return this.do([`click ${target}`]);
|
|
133
|
+
}
|
|
134
|
+
async fill(field, value) {
|
|
135
|
+
return this.do([`fill ${field} with value: ${value}`]);
|
|
136
|
+
}
|
|
137
|
+
async ask(question) {
|
|
138
|
+
const command = `ask ${question}`;
|
|
139
|
+
const guard = await this.aiGuard(command);
|
|
140
|
+
if (guard)
|
|
141
|
+
return guard;
|
|
142
|
+
const previousState = this.bot.stateManager().getCurrentState();
|
|
143
|
+
const result = await this.capturedResult(previousState, { screenshot: this.visionEnabled() });
|
|
144
|
+
return this.reportEnvelope(command, result, previousState, { answer: await this.answer(question, result) });
|
|
145
|
+
}
|
|
146
|
+
async verify(assertion) {
|
|
147
|
+
const command = `verify ${assertion}`;
|
|
148
|
+
const guard = await this.aiGuard(command);
|
|
149
|
+
if (guard)
|
|
150
|
+
return guard;
|
|
151
|
+
const previousState = this.bot.stateManager().getCurrentState();
|
|
152
|
+
const result = await this.capturedResult(previousState);
|
|
153
|
+
const verification = await this.bot.agentNavigator().verifyState(assertion, result);
|
|
154
|
+
const codes = verification.successfulCodes || [];
|
|
155
|
+
const verdict = { passed: verification.verified, evidence: this.verdictEvidence(verification.verified, codes), code: codes.join('\n') };
|
|
156
|
+
return this.reportEnvelope(command, result, previousState, { ok: verification.verified, verdict });
|
|
157
|
+
}
|
|
158
|
+
async research(opts = {}) {
|
|
159
|
+
const flags = [opts.data && '--data', opts.deep && '--deep', opts.fresh && '--fresh'].filter(Boolean);
|
|
160
|
+
const command = ['research', ...flags].join(' ');
|
|
161
|
+
const guard = await this.aiGuard(command);
|
|
162
|
+
if (guard)
|
|
163
|
+
return guard;
|
|
164
|
+
const previousState = this.bot.stateManager().getCurrentState();
|
|
165
|
+
const result = await this.capturedResult(previousState);
|
|
166
|
+
const uiMap = await this.bot.agentResearcher().research(result, { screenshot: true, data: opts.data, deep: opts.deep, force: opts.fresh });
|
|
167
|
+
return this.reportEnvelope(command, result, previousState, { research: uiMap });
|
|
168
|
+
}
|
|
169
|
+
async go(target) {
|
|
170
|
+
const command = `go ${target}`;
|
|
171
|
+
const code = `I.amOnPage('${target}')`;
|
|
172
|
+
const isUrl = this.isUrlTarget(target);
|
|
173
|
+
if (!isUrl) {
|
|
174
|
+
const guard = await this.aiGuard(command);
|
|
175
|
+
if (guard)
|
|
176
|
+
return guard;
|
|
177
|
+
}
|
|
178
|
+
const previousState = this.bot.stateManager().getCurrentState();
|
|
179
|
+
let navigationError = null;
|
|
180
|
+
try {
|
|
181
|
+
await this.bot.agentNavigator().visit(target);
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
navigationError = error;
|
|
185
|
+
}
|
|
186
|
+
if (navigationError)
|
|
187
|
+
return this.heal(command, code, navigationError, previousState);
|
|
188
|
+
const used = [];
|
|
189
|
+
if (isUrl)
|
|
190
|
+
used.push(code);
|
|
191
|
+
const result = await this.capturedResult(this.bot.stateManager().getCurrentState());
|
|
192
|
+
return this.successEnvelope(command, used, result, previousState);
|
|
193
|
+
}
|
|
194
|
+
async browserStart() {
|
|
195
|
+
const config = await this.loadConfig();
|
|
196
|
+
let show = config.playwright.show || false;
|
|
197
|
+
if (this.options.show)
|
|
198
|
+
show = true;
|
|
199
|
+
if (this.options.headless)
|
|
200
|
+
show = false;
|
|
201
|
+
this.server = await this.launchOwnServer({ browser: config.playwright.browser, show }, this.instanceName());
|
|
202
|
+
}
|
|
203
|
+
async browserStop(all = false) {
|
|
204
|
+
await this.loadConfig();
|
|
205
|
+
if (this.attached)
|
|
206
|
+
return false;
|
|
207
|
+
if (!all)
|
|
208
|
+
return this.stopInstance(this.instanceName());
|
|
209
|
+
let stopped = false;
|
|
210
|
+
for (const instance of listInstances()) {
|
|
211
|
+
if (await this.stopInstance(instance.name))
|
|
212
|
+
stopped = true;
|
|
213
|
+
}
|
|
214
|
+
return stopped;
|
|
215
|
+
}
|
|
216
|
+
async browserStatus() {
|
|
217
|
+
await this.loadConfig();
|
|
218
|
+
const info = await this.instanceInfo();
|
|
219
|
+
const endpoint = await getAliveEndpoint(info.name);
|
|
220
|
+
const others = info.others.map((other) => other.name).join(', ') || 'none';
|
|
221
|
+
const lines = [`instance: ${info.name} (${info.tabs} ${pluralize(info.tabs, 'tab')}) | other instances: ${others}`];
|
|
222
|
+
if (!endpoint)
|
|
223
|
+
lines.push('browser: not running');
|
|
224
|
+
if (endpoint)
|
|
225
|
+
lines.push(`browser: running at ${endpoint}`);
|
|
226
|
+
return lines.join('\n');
|
|
227
|
+
}
|
|
228
|
+
async browserList() {
|
|
229
|
+
await this.loadConfig();
|
|
230
|
+
const lines = [];
|
|
231
|
+
for (const instance of listInstances()) {
|
|
232
|
+
const endpoint = await getAliveEndpoint(instance.name);
|
|
233
|
+
if (!endpoint)
|
|
234
|
+
continue;
|
|
235
|
+
lines.push(`prima --instance ${instance.name} ${endpoint}`);
|
|
236
|
+
}
|
|
237
|
+
const discovery = await this.discover();
|
|
238
|
+
await discovery.browser?.close().catch(() => { });
|
|
239
|
+
for (const descriptor of discovery.candidates) {
|
|
240
|
+
lines.push(`playwright-cli --pw-session ${descriptor.title} ${descriptor.endpoint}`);
|
|
241
|
+
}
|
|
242
|
+
if (!lines.length)
|
|
243
|
+
return 'no browser instances running';
|
|
244
|
+
return lines.join('\n');
|
|
245
|
+
}
|
|
246
|
+
async instanceInfo() {
|
|
247
|
+
const name = this.instanceName();
|
|
248
|
+
const others = listInstances()
|
|
249
|
+
.filter((instance) => instance.name !== name)
|
|
250
|
+
.map((instance) => ({ name: instance.name, tabs: 0 }));
|
|
251
|
+
const info = { name, tabs: this.tabCount(), others };
|
|
252
|
+
if (this.attached)
|
|
253
|
+
info.attached = this.attached;
|
|
254
|
+
return info;
|
|
255
|
+
}
|
|
256
|
+
async toolFailureEnvelope(command, error) {
|
|
257
|
+
const state = this.bot.getCurrentState();
|
|
258
|
+
const instance = await this.instanceInfo().catch(() => ({ name: this.instanceName(), tabs: 0, others: [] }));
|
|
259
|
+
const failure = { error: `tool: ${browserErrorMessage(error)}`, attempts: [] };
|
|
260
|
+
if (error instanceof ConfigMissingError)
|
|
261
|
+
failure.error = browserErrorMessage(error);
|
|
262
|
+
if (state?.ariaSnapshot)
|
|
263
|
+
failure.compactAria = compactAriaSnapshot(state.ariaSnapshot, true);
|
|
264
|
+
return {
|
|
265
|
+
ok: false,
|
|
266
|
+
command,
|
|
267
|
+
page: { url: state?.url || '', title: state?.title || '', state: state?.hash || '', visits: 0 },
|
|
268
|
+
failure,
|
|
269
|
+
instance,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
async loadConfig() {
|
|
273
|
+
return ConfigParser.getInstance().loadConfig({ config: this.options.config, path: this.options.path, baseUrl: this.configBaseUrl() });
|
|
274
|
+
}
|
|
275
|
+
configBaseUrl() {
|
|
276
|
+
const url = this.options.baseUrl || this.options.url;
|
|
277
|
+
if (!url)
|
|
278
|
+
return undefined;
|
|
279
|
+
if (!URL.canParse(url))
|
|
280
|
+
return undefined;
|
|
281
|
+
return url;
|
|
282
|
+
}
|
|
283
|
+
async resolveBrowser(config) {
|
|
284
|
+
if (this.options.endpoint) {
|
|
285
|
+
const endpoint = this.options.endpoint;
|
|
286
|
+
const browserName = config.playwright.browser || 'chromium';
|
|
287
|
+
if (await this.attachToEndpoint({ file: '', title: '', endpoint, workspaceDir: '', browserName, playwrightLib: '' }))
|
|
288
|
+
return;
|
|
289
|
+
throw new Error(dedent `
|
|
290
|
+
No browser answered at ${endpoint}.
|
|
291
|
+
Check the endpoint of the running session, or drop --endpoint to attach to the
|
|
292
|
+
playwright-cli browser of this workspace.
|
|
293
|
+
`);
|
|
294
|
+
}
|
|
295
|
+
const { match, candidates, browser } = await this.discover();
|
|
296
|
+
if (match && (await this.attachToEndpoint(match, browser)))
|
|
297
|
+
return;
|
|
298
|
+
if (!match && candidates.length) {
|
|
299
|
+
const titles = candidates.map((candidate) => candidate.title).join(', ');
|
|
300
|
+
throw new Error(dedent `
|
|
301
|
+
Several playwright-cli sessions are open for this workspace: ${titles}
|
|
302
|
+
Pick one with --pw-session <title>.
|
|
303
|
+
`);
|
|
304
|
+
}
|
|
305
|
+
if (await this.connectOwnInstance())
|
|
306
|
+
return;
|
|
307
|
+
throw new Error(dedent `
|
|
308
|
+
No browser to drive for instance "${this.instanceName()}".
|
|
309
|
+
Open one first:
|
|
310
|
+
playwright-cli open <url> prima attaches to this workspace session by default
|
|
311
|
+
prima browser start starts a prima-owned browser instead
|
|
312
|
+
Prima never launches a browser implicitly.
|
|
313
|
+
`);
|
|
314
|
+
}
|
|
315
|
+
async discover(descriptors = readDescriptors()) {
|
|
316
|
+
const title = this.options.pwSession ?? process.env.PLAYWRIGHT_CLI_SESSION;
|
|
317
|
+
const opts = { workspaceDir: this.workspaceDir(), title };
|
|
318
|
+
const alive = new Map();
|
|
319
|
+
for (const candidate of selectDescriptor(descriptors, opts).candidates) {
|
|
320
|
+
const browser = await this.connectDescriptor(candidate);
|
|
321
|
+
if (browser)
|
|
322
|
+
alive.set(candidate, browser);
|
|
323
|
+
}
|
|
324
|
+
const selected = selectDescriptor([...alive.keys()], opts);
|
|
325
|
+
const discovery = { match: selected.match, candidates: selected.candidates };
|
|
326
|
+
if (selected.match)
|
|
327
|
+
discovery.browser = alive.get(selected.match);
|
|
328
|
+
for (const [descriptor, browser] of alive) {
|
|
329
|
+
if (descriptor === selected.match)
|
|
330
|
+
continue;
|
|
331
|
+
await browser.close().catch(() => { });
|
|
332
|
+
}
|
|
333
|
+
return discovery;
|
|
334
|
+
}
|
|
335
|
+
async attachToEndpoint(descriptor, probed) {
|
|
336
|
+
const browser = probed || (await this.connectDescriptor(descriptor));
|
|
337
|
+
if (!browser)
|
|
338
|
+
return false;
|
|
339
|
+
this.bot.attachBrowser(browser);
|
|
340
|
+
this.attached = this.attachmentLabel(descriptor);
|
|
341
|
+
return true;
|
|
342
|
+
}
|
|
343
|
+
async connectDescriptor(descriptor) {
|
|
344
|
+
const connected = await this.connectWith(playwright, descriptor);
|
|
345
|
+
if (connected)
|
|
346
|
+
return connected;
|
|
347
|
+
return this.connectWith(this.descriptorLib(descriptor), descriptor);
|
|
348
|
+
}
|
|
349
|
+
async connectWith(lib, descriptor) {
|
|
350
|
+
const launcher = lib?.[descriptor.browserName];
|
|
351
|
+
if (!launcher?.connect)
|
|
352
|
+
return null;
|
|
353
|
+
return launcher.connect(descriptor.endpoint, { timeout: CONNECT_TIMEOUT }).catch(() => null);
|
|
354
|
+
}
|
|
355
|
+
descriptorLib(descriptor) {
|
|
356
|
+
if (!descriptor.playwrightLib)
|
|
357
|
+
return null;
|
|
358
|
+
try {
|
|
359
|
+
return requireLib(descriptor.playwrightLib);
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
attachmentLabel(descriptor) {
|
|
366
|
+
if (!descriptor.title)
|
|
367
|
+
return `endpoint ${descriptor.endpoint}`;
|
|
368
|
+
return `playwright-cli session "${descriptor.title}", workspace ${descriptor.workspaceDir}`;
|
|
369
|
+
}
|
|
370
|
+
workspaceDir() {
|
|
371
|
+
return path.resolve(this.options.path || process.cwd());
|
|
372
|
+
}
|
|
373
|
+
async connectOwnInstance() {
|
|
374
|
+
return !!(await getAliveEndpoint(this.instanceName()));
|
|
375
|
+
}
|
|
376
|
+
async launchOwnServer(opts, instance) {
|
|
377
|
+
return launchServer(opts, instance);
|
|
378
|
+
}
|
|
379
|
+
async stopInstance(instance) {
|
|
380
|
+
if (instance === this.instanceName()) {
|
|
381
|
+
const server = this.server;
|
|
382
|
+
this.server = null;
|
|
383
|
+
await server?.close();
|
|
384
|
+
}
|
|
385
|
+
return stopServer(instance);
|
|
386
|
+
}
|
|
387
|
+
isUrlTarget(target) {
|
|
388
|
+
const value = target.trim();
|
|
389
|
+
if (value.startsWith('/'))
|
|
390
|
+
return true;
|
|
391
|
+
return URL.canParse(value);
|
|
392
|
+
}
|
|
393
|
+
async heal(command, expression, error, previousState) {
|
|
394
|
+
if (this.options.heal === false)
|
|
395
|
+
return this.failureEnvelope(command, error, previousState);
|
|
396
|
+
const navigator = this.healNavigator();
|
|
397
|
+
if (!navigator) {
|
|
398
|
+
const envelope = await this.failureEnvelope(command, error, previousState);
|
|
399
|
+
envelope.healed = false;
|
|
400
|
+
envelope.healNote = this.aiUnavailableNote();
|
|
401
|
+
return envelope;
|
|
402
|
+
}
|
|
403
|
+
const message = dedent `
|
|
404
|
+
I tried to run this command on the page: ${expression}
|
|
405
|
+
But it failed with: ${browserErrorMessage(error)}
|
|
406
|
+
Reach the same outcome on the current page in a different way.
|
|
407
|
+
`;
|
|
408
|
+
const attempts = [];
|
|
409
|
+
const failedResult = await this.capturedResult(previousState);
|
|
410
|
+
const resolved = await navigator.resolveState(message, failedResult, { onAttempt: (attempt) => attempts.push(attempt) }).catch(() => false);
|
|
411
|
+
if (!resolved) {
|
|
412
|
+
const healAttempts = attempts.map((attempt) => ({ code: attempt.code, outcome: attempt.error || 'ok' }));
|
|
413
|
+
return this.failureEnvelope(command, error, previousState, healAttempts);
|
|
414
|
+
}
|
|
415
|
+
const used = attempts.filter((attempt) => !attempt.error).map((attempt) => attempt.code);
|
|
416
|
+
const result = await this.capturedResult(this.bot.stateManager().getCurrentState());
|
|
417
|
+
const envelope = await this.successEnvelope(command, used, result, previousState);
|
|
418
|
+
envelope.healed = true;
|
|
419
|
+
envelope.healNote = `recovered after ${attempts.length} ${pluralize(attempts.length, 'attempt')}`;
|
|
420
|
+
return envelope;
|
|
421
|
+
}
|
|
422
|
+
healNavigator() {
|
|
423
|
+
if (this.aiUnavailable())
|
|
424
|
+
return null;
|
|
425
|
+
try {
|
|
426
|
+
return this.bot.agentNavigator?.() ?? null;
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
aiUnavailable() {
|
|
433
|
+
try {
|
|
434
|
+
if (this.bot.getProvider?.())
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
catch (error) {
|
|
438
|
+
return browserErrorMessage(error);
|
|
439
|
+
}
|
|
440
|
+
return this.bot.aiFailureReason?.() || 'no AI model is configured';
|
|
441
|
+
}
|
|
442
|
+
aiUnavailableNote() {
|
|
443
|
+
const reason = this.aiUnavailable();
|
|
444
|
+
if (!reason)
|
|
445
|
+
return 'ai unavailable';
|
|
446
|
+
return `ai unavailable: ${reason}`;
|
|
447
|
+
}
|
|
448
|
+
async aiGuard(command) {
|
|
449
|
+
const reason = this.aiUnavailable();
|
|
450
|
+
if (!reason)
|
|
451
|
+
return null;
|
|
452
|
+
const message = `this command needs an AI model and none is usable: ${reason}. Drive the browser directly with playwright-cli or prima pw, or fix the AI config in ~/.explorbot/config.js or EXPLORBOT_AI_PROVIDER.`;
|
|
453
|
+
return this.toolFailureEnvelope(command, message);
|
|
454
|
+
}
|
|
455
|
+
instructionSystemPrompt() {
|
|
456
|
+
return dedent `
|
|
457
|
+
<role>
|
|
458
|
+
You are a web automation engineer performing high-level instructions on the page that is already open in the browser.
|
|
459
|
+
</role>
|
|
460
|
+
|
|
461
|
+
<approach>
|
|
462
|
+
1. Read the page context and perform the instructions in the order they are listed.
|
|
463
|
+
2. Interact with the page only through the provided tools.
|
|
464
|
+
3. Pick the smallest interaction that fulfills an instruction, then move to the next one.
|
|
465
|
+
4. After the page changes, work from the updated context you are given, not from the earlier one.
|
|
466
|
+
5. Stop calling tools when every instruction is done, or when an instruction cannot be performed on this page — say what is missing instead.
|
|
467
|
+
</approach>
|
|
468
|
+
|
|
469
|
+
${locatorRule}
|
|
470
|
+
|
|
471
|
+
${actionRule}
|
|
472
|
+
`;
|
|
473
|
+
}
|
|
474
|
+
instructionPrompt(instructions, result) {
|
|
475
|
+
const list = instructions.map((instruction, index) => `${index + 1}. ${instruction}`).join('\n');
|
|
476
|
+
return dedent `
|
|
477
|
+
<instructions>
|
|
478
|
+
${list}
|
|
479
|
+
</instructions>
|
|
480
|
+
|
|
481
|
+
${this.pageContext(result)}
|
|
482
|
+
`;
|
|
483
|
+
}
|
|
484
|
+
pageContext(result) {
|
|
485
|
+
const experience = this.bot.experienceTracker?.()?.renderExperienceTocFor?.(result) || '';
|
|
486
|
+
return dedent `
|
|
487
|
+
<page url="${result.url}" title="${result.title}">
|
|
488
|
+
${compactAriaSnapshot(result.ariaSnapshot, true)}
|
|
489
|
+
</page>
|
|
490
|
+
|
|
491
|
+
${experience}
|
|
492
|
+
`;
|
|
493
|
+
}
|
|
494
|
+
executedCodes(code) {
|
|
495
|
+
if (typeof code !== 'string')
|
|
496
|
+
return [];
|
|
497
|
+
return code
|
|
498
|
+
.split('\n')
|
|
499
|
+
.map((line) => line.trim())
|
|
500
|
+
.filter((line) => line);
|
|
501
|
+
}
|
|
502
|
+
visionEnabled() {
|
|
503
|
+
if (this.options.noVision)
|
|
504
|
+
return false;
|
|
505
|
+
return this.bot.getProvider().hasVision?.() === true;
|
|
506
|
+
}
|
|
507
|
+
async answer(question, result) {
|
|
508
|
+
const seen = await this.visionAnswer(question, result);
|
|
509
|
+
if (seen)
|
|
510
|
+
return seen;
|
|
511
|
+
const described = await this.textAnswer(question, result);
|
|
512
|
+
if (this.options.noVision)
|
|
513
|
+
return described;
|
|
514
|
+
return `${described}\n\n(vision was unavailable, answered from page structure)`;
|
|
515
|
+
}
|
|
516
|
+
async visionAnswer(question, result) {
|
|
517
|
+
if (!this.visionEnabled())
|
|
518
|
+
return null;
|
|
519
|
+
return this.bot.agentResearcher().answerQuestionAboutScreenshot(result, question);
|
|
520
|
+
}
|
|
521
|
+
async textAnswer(question, result) {
|
|
522
|
+
const provider = this.bot.getProvider();
|
|
523
|
+
const summary = await this.bot.agentResearcher().summary(result);
|
|
524
|
+
const prompt = dedent `
|
|
525
|
+
Answer the question about the web page described below.
|
|
526
|
+
Rely only on the page summary and the accessibility tree given here.
|
|
527
|
+
When the answer is not present in them, say which part of the page would be needed instead of guessing.
|
|
528
|
+
Keep the answer under five sentences.
|
|
529
|
+
|
|
530
|
+
<question>
|
|
531
|
+
${question}
|
|
532
|
+
</question>
|
|
533
|
+
|
|
534
|
+
<page_summary>
|
|
535
|
+
${summary}
|
|
536
|
+
</page_summary>
|
|
537
|
+
|
|
538
|
+
<page_aria>
|
|
539
|
+
${compactAriaSnapshot(result.ariaSnapshot, true)}
|
|
540
|
+
</page_aria>
|
|
541
|
+
`;
|
|
542
|
+
const response = await provider.chat([{ role: 'user', content: prompt }], provider.getModelForAgent?.(AI_AGENT_NAME), { agentName: AI_AGENT_NAME });
|
|
543
|
+
return response?.text || '';
|
|
544
|
+
}
|
|
545
|
+
verdictEvidence(verified, codes) {
|
|
546
|
+
if (!verified)
|
|
547
|
+
return 'no assertion held on the current page';
|
|
548
|
+
if (!codes.length)
|
|
549
|
+
return 'already verified on this page';
|
|
550
|
+
return `${codes[0]} passed`;
|
|
551
|
+
}
|
|
552
|
+
async successEnvelope(command, used, result, previousState) {
|
|
553
|
+
return {
|
|
554
|
+
ok: true,
|
|
555
|
+
command,
|
|
556
|
+
used,
|
|
557
|
+
page: this.pageBlock(result, previousState),
|
|
558
|
+
changes: await this.pageChanges(result, previousState, used[0]),
|
|
559
|
+
instance: await this.instanceInfo(),
|
|
560
|
+
artifacts: await this.writeSnapshot(result),
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
async failureEnvelope(command, error, previousState, attempts = []) {
|
|
564
|
+
const result = await this.capturedResult(previousState);
|
|
565
|
+
const failure = { error: browserErrorMessage(error), attempts };
|
|
566
|
+
if (result.ariaSnapshot)
|
|
567
|
+
failure.compactAria = compactAriaSnapshot(result.ariaSnapshot, true);
|
|
568
|
+
if (attempts.length)
|
|
569
|
+
failure.reasoning = [...new Set(attempts.map((attempt) => attempt.outcome))].join('; ');
|
|
570
|
+
return {
|
|
571
|
+
ok: false,
|
|
572
|
+
command,
|
|
573
|
+
page: this.pageBlock(result, previousState),
|
|
574
|
+
failure,
|
|
575
|
+
instance: await this.instanceInfo(),
|
|
576
|
+
artifacts: await this.writeSnapshot(result),
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
async reportEnvelope(command, result, previousState, outcome) {
|
|
580
|
+
return {
|
|
581
|
+
ok: true,
|
|
582
|
+
command,
|
|
583
|
+
page: this.pageBlock(result, previousState),
|
|
584
|
+
...outcome,
|
|
585
|
+
instance: await this.instanceInfo(),
|
|
586
|
+
artifacts: await this.writeSnapshot(result),
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
async capturedResult(previousState, opts = {}) {
|
|
590
|
+
const captured = await this.bot
|
|
591
|
+
.getExplorer()
|
|
592
|
+
?.capture(opts)
|
|
593
|
+
.catch(() => null);
|
|
594
|
+
if (captured)
|
|
595
|
+
return captured;
|
|
596
|
+
if (previousState)
|
|
597
|
+
return ActionResult.fromState(previousState);
|
|
598
|
+
return new ActionResult({ url: '' });
|
|
599
|
+
}
|
|
600
|
+
pageBlock(result, previousState) {
|
|
601
|
+
return {
|
|
602
|
+
url: result.url,
|
|
603
|
+
previousUrl: previousState?.url,
|
|
604
|
+
title: result.title,
|
|
605
|
+
state: result.getStateHash(),
|
|
606
|
+
visits: this.bot.stateManager().getVisitCount(result.url),
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
async pageChanges(result, previousState, code) {
|
|
610
|
+
if (!previousState)
|
|
611
|
+
return null;
|
|
612
|
+
const toolResult = await result.toToolResult(ActionResult.fromState(previousState), code);
|
|
613
|
+
return toolResult.pageDiff?.ariaChanges ?? null;
|
|
614
|
+
}
|
|
615
|
+
async writeSnapshot(result) {
|
|
616
|
+
return writeArtifacts(this.nextArtifactDir(), {
|
|
617
|
+
aria: result.ariaSnapshot,
|
|
618
|
+
html: await result.combinedHtml(),
|
|
619
|
+
requests: this.bot.requestStore().getRequests(),
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
nextArtifactDir() {
|
|
623
|
+
this.artifactsDir ||= outputPath('prima');
|
|
624
|
+
return path.join(this.artifactsDir, new Date().toISOString().replace(/[:.]/g, '-'));
|
|
625
|
+
}
|
|
626
|
+
tabCount() {
|
|
627
|
+
const page = this.bot.getExplorer()?.page;
|
|
628
|
+
if (!page)
|
|
629
|
+
return 0;
|
|
630
|
+
return page.context().pages().length;
|
|
631
|
+
}
|
|
632
|
+
instanceName() {
|
|
633
|
+
return this.options.instance || 'default';
|
|
634
|
+
}
|
|
635
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const FUNCTION_SHAPE = /^(async\s+)?(function\b|\()/;
|
|
2
|
+
export function isFunctionExpression(expr) {
|
|
3
|
+
const trimmed = expr.trim();
|
|
4
|
+
if (!trimmed)
|
|
5
|
+
return { valid: false, error: 'empty expression; pass a function like ({ page }) => ...' };
|
|
6
|
+
if (!FUNCTION_SHAPE.test(trimmed))
|
|
7
|
+
return { valid: false, error: 'expression must be a function like ({ page }) => ... destructuring the playwright objects it needs' };
|
|
8
|
+
try {
|
|
9
|
+
new Function(`return (${trimmed})`);
|
|
10
|
+
}
|
|
11
|
+
catch (e) {
|
|
12
|
+
return { valid: false, error: `not a valid function expression: ${e.message}` };
|
|
13
|
+
}
|
|
14
|
+
return { valid: true };
|
|
15
|
+
}
|
|
16
|
+
export function toCodeceptWrapper(expr) {
|
|
17
|
+
return `I.usePlaywrightTo('pw', async (playwright) => (${expr.trim()})(playwright))`;
|
|
18
|
+
}
|