explorbot 0.2.2 → 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/cli.ts +1 -0
- package/boat/doc-collector/src/docs-renderer.ts +18 -4
- 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/cli.js +1 -0
- package/dist/boat/doc-collector/src/docs-renderer.js +17 -3
- 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 +6 -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.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 +4 -1
- package/dist/src/explorer.js +40 -6
- 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 +6 -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.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 +45 -7
- 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,66 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
const DEFAULT_TITLE = 'default';
|
|
5
|
+
const DEFAULT_BROWSER = 'chromium';
|
|
6
|
+
export function registryDir() {
|
|
7
|
+
if (process.platform === 'darwin')
|
|
8
|
+
return path.join(os.homedir(), 'Library', 'Caches', 'ms-playwright', 'b');
|
|
9
|
+
if (process.platform === 'win32')
|
|
10
|
+
return path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'ms-playwright', 'b');
|
|
11
|
+
return path.join(process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'), 'ms-playwright', 'b');
|
|
12
|
+
}
|
|
13
|
+
export function readDescriptors(dir = registryDir()) {
|
|
14
|
+
const descriptors = [];
|
|
15
|
+
for (const fileName of listFiles(dir)) {
|
|
16
|
+
const descriptor = parseDescriptor(path.join(dir, fileName));
|
|
17
|
+
if (descriptor)
|
|
18
|
+
descriptors.push(descriptor);
|
|
19
|
+
}
|
|
20
|
+
return descriptors;
|
|
21
|
+
}
|
|
22
|
+
export function selectDescriptor(descriptors, opts) {
|
|
23
|
+
const workspaceDir = path.resolve(opts.workspaceDir);
|
|
24
|
+
const candidates = descriptors.filter((descriptor) => path.resolve(descriptor.workspaceDir) === workspaceDir);
|
|
25
|
+
if (!candidates.length)
|
|
26
|
+
return { candidates };
|
|
27
|
+
if (opts.title) {
|
|
28
|
+
const titled = candidates.find((descriptor) => descriptor.title === opts.title);
|
|
29
|
+
if (titled)
|
|
30
|
+
return { match: titled, candidates };
|
|
31
|
+
return { candidates };
|
|
32
|
+
}
|
|
33
|
+
const preferred = candidates.find((descriptor) => descriptor.title === DEFAULT_TITLE);
|
|
34
|
+
if (preferred)
|
|
35
|
+
return { match: preferred, candidates };
|
|
36
|
+
if (candidates.length === 1)
|
|
37
|
+
return { match: candidates[0], candidates };
|
|
38
|
+
return { candidates };
|
|
39
|
+
}
|
|
40
|
+
function listFiles(dir) {
|
|
41
|
+
try {
|
|
42
|
+
return readdirSync(dir);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function parseDescriptor(file) {
|
|
49
|
+
let data;
|
|
50
|
+
try {
|
|
51
|
+
data = JSON.parse(readFileSync(file, 'utf8'));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
if (!data?.endpoint || !data?.title || !data?.workspaceDir)
|
|
57
|
+
return null;
|
|
58
|
+
return {
|
|
59
|
+
file,
|
|
60
|
+
title: data.title,
|
|
61
|
+
endpoint: data.endpoint,
|
|
62
|
+
workspaceDir: data.workspaceDir,
|
|
63
|
+
browserName: data.browser?.browserName || DEFAULT_BROWSER,
|
|
64
|
+
playwrightLib: data.playwrightLib || '',
|
|
65
|
+
};
|
|
66
|
+
}
|
package/dist/models.json
CHANGED
package/dist/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"
|
package/dist/src/action.d.ts
CHANGED
|
@@ -24,7 +24,7 @@ declare class Action {
|
|
|
24
24
|
includeScreenshot?: boolean;
|
|
25
25
|
codeBlock?: string;
|
|
26
26
|
}): Promise<ActionResult>;
|
|
27
|
-
execute(code: string): Promise<Action>;
|
|
27
|
+
execute(code: string, opts?: ExecuteOptions): Promise<Action>;
|
|
28
28
|
captureOnce({ includeScreenshot, codeBlock }?: {
|
|
29
29
|
includeScreenshot?: boolean;
|
|
30
30
|
codeBlock?: string;
|
|
@@ -40,7 +40,7 @@ declare class Action {
|
|
|
40
40
|
id?: string;
|
|
41
41
|
}>>;
|
|
42
42
|
captureBrowserLogs(): Promise<any[]>;
|
|
43
|
-
executeOnce(code: string): Promise<Action>;
|
|
43
|
+
executeOnce(code: string, verbatim?: boolean): Promise<Action>;
|
|
44
44
|
attempt(codeBlock: string, originalMessage?: string): Promise<boolean>;
|
|
45
45
|
exitIframe(): Promise<void>;
|
|
46
46
|
getActor(): CodeceptJS.I;
|
|
@@ -49,3 +49,6 @@ declare class Action {
|
|
|
49
49
|
}
|
|
50
50
|
export default Action;
|
|
51
51
|
export type RecoveryRunner = <T>(fn: () => Promise<T>) => Promise<T>;
|
|
52
|
+
export interface ExecuteOptions {
|
|
53
|
+
verbatim?: boolean;
|
|
54
|
+
}
|
package/dist/src/action.js
CHANGED
|
@@ -57,8 +57,8 @@ class Action {
|
|
|
57
57
|
async capturePageState(opts = {}) {
|
|
58
58
|
return this.recovery(() => this.captureOnce(opts));
|
|
59
59
|
}
|
|
60
|
-
async execute(code) {
|
|
61
|
-
return this.recovery(() => this.executeOnce(code));
|
|
60
|
+
async execute(code, opts = {}) {
|
|
61
|
+
return this.recovery(() => this.executeOnce(code, opts.verbatim));
|
|
62
62
|
}
|
|
63
63
|
async captureOnce({ includeScreenshot = false, codeBlock } = {}) {
|
|
64
64
|
try {
|
|
@@ -251,7 +251,7 @@ class Action {
|
|
|
251
251
|
return [];
|
|
252
252
|
}
|
|
253
253
|
}
|
|
254
|
-
async executeOnce(code) {
|
|
254
|
+
async executeOnce(code, verbatim = false) {
|
|
255
255
|
let error = null;
|
|
256
256
|
setActivity('🔎 Browsing...', 'action');
|
|
257
257
|
let codeString = code.replace(/^\(I\) => /, '').trim();
|
|
@@ -265,8 +265,8 @@ class Action {
|
|
|
265
265
|
const tracer = trace.getTracer('ai');
|
|
266
266
|
const stepSpan = activeSpan ? tracer.startSpan('codeceptjs.step', undefined, trace.setSpan(context.active(), activeSpan)) : null;
|
|
267
267
|
setStepSpanParent(stepSpan);
|
|
268
|
-
const sanitizedCode = sanitizeCodeBlock(codeString);
|
|
269
|
-
const isPlaywright = hasPlaywrightCommands(sanitizedCode);
|
|
268
|
+
const sanitizedCode = verbatim ? codeString : sanitizeCodeBlock(codeString);
|
|
269
|
+
const isPlaywright = !verbatim && hasPlaywrightCommands(sanitizedCode);
|
|
270
270
|
try {
|
|
271
271
|
debugLog('Executing action:', codeString);
|
|
272
272
|
if (!sanitizedCode) {
|
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import { dirname } from 'node:path';
|
|
2
1
|
import { ConfigParser } from "../../config.js";
|
|
3
2
|
import { createDebug } from '../../utils/logger.js';
|
|
4
3
|
export const debugLog = createDebug('explorbot:captain');
|
|
5
4
|
export function resolveProjectRoot() {
|
|
6
|
-
const
|
|
7
|
-
if (!
|
|
5
|
+
const configParser = ConfigParser.getInstance();
|
|
6
|
+
if (!configParser.getConfigPath())
|
|
8
7
|
return null;
|
|
9
|
-
return
|
|
8
|
+
return configParser.getProjectRoot();
|
|
10
9
|
}
|
|
@@ -25,7 +25,7 @@ export function WithWebMode(Base) {
|
|
|
25
25
|
execute: async ({ destination }) => {
|
|
26
26
|
try {
|
|
27
27
|
debugLog('navigate', destination);
|
|
28
|
-
await ctx.explorBot.
|
|
28
|
+
await ctx.explorBot.visit(destination);
|
|
29
29
|
const stateManager = ctx.explorBot.stateManager();
|
|
30
30
|
const state = stateManager.getCurrentState();
|
|
31
31
|
return { success: true, url: state?.url, title: state?.title };
|
|
@@ -32,6 +32,10 @@ declare class Navigator implements Agent {
|
|
|
32
32
|
resolveState(message: string, actionResult: ActionResult, opts?: {
|
|
33
33
|
action?: Action;
|
|
34
34
|
expectedUrl?: string;
|
|
35
|
+
onAttempt?: (attempt: {
|
|
36
|
+
code: string;
|
|
37
|
+
error?: string;
|
|
38
|
+
}) => void;
|
|
35
39
|
}): Promise<boolean>;
|
|
36
40
|
buildExperienceTools(): {
|
|
37
41
|
learnExperience: unknown;
|
package/dist/src/ai/navigator.js
CHANGED
|
@@ -8,7 +8,7 @@ import { HooksRunner } from "../utils/hooks-runner.js";
|
|
|
8
8
|
import { createDebug, pluralize, tag } from '../utils/logger.js';
|
|
9
9
|
import { loop, pause } from '../utils/loop.js';
|
|
10
10
|
import { RulesLoader } from "../utils/rules-loader.js";
|
|
11
|
-
import { extractStatePath } from '../utils/url-matcher.js';
|
|
11
|
+
import { extractStatePath, matchesNavigationUrl } from '../utils/url-matcher.js';
|
|
12
12
|
import { Researcher } from "./researcher.js";
|
|
13
13
|
import { actionRule, locatorRule, unexpectedPopupRule } from './rules.js';
|
|
14
14
|
import { isInteractive } from './task-agent.js';
|
|
@@ -118,7 +118,7 @@ class Navigator {
|
|
|
118
118
|
return false;
|
|
119
119
|
}
|
|
120
120
|
const currentUrl = this.getComparableCurrentUrl(stateManager, expectedUrl);
|
|
121
|
-
return
|
|
121
|
+
return matchesNavigationUrl(expectedUrl, currentUrl);
|
|
122
122
|
}
|
|
123
123
|
async visit(url) {
|
|
124
124
|
return this.visitOnce(url);
|
|
@@ -167,11 +167,13 @@ class Navigator {
|
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
169
|
async resolveState(message, actionResult, opts) {
|
|
170
|
+
if (!this.provider)
|
|
171
|
+
throw new Error('AI-assisted recovery is unavailable: no AI model is configured.');
|
|
170
172
|
tag('info').log('AI Navigator resolving state at', actionResult.url);
|
|
171
173
|
debugLog('Resolution message:', message);
|
|
172
174
|
const action = opts?.action ?? this.explorer.action();
|
|
173
175
|
const expectedUrl = opts?.expectedUrl;
|
|
174
|
-
const knowledge = this.knowledgeTracker.
|
|
176
|
+
const knowledge = this.knowledgeTracker.renderRelevantContext(actionResult);
|
|
175
177
|
let experience = '';
|
|
176
178
|
if (!actionResult.isInsideIframe) {
|
|
177
179
|
const successful = this.experienceTracker.getSuccessfulExperience(actionResult);
|
|
@@ -328,16 +330,19 @@ class Navigator {
|
|
|
328
330
|
// Navigation did not reach 'load' state within timeout; continue and verify URL
|
|
329
331
|
}
|
|
330
332
|
}
|
|
333
|
+
if (attemptOk)
|
|
334
|
+
opts?.onAttempt?.({ code: codeBlock });
|
|
331
335
|
if (!attemptOk) {
|
|
332
336
|
const raw = action.lastError?.message || 'attempt failed';
|
|
333
337
|
const firstMeaningful = raw.split('\n').find((l) => l.trim() && !l.trim().startsWith('at ')) || raw;
|
|
334
338
|
const shortErr = firstMeaningful.replace(/\s+/g, ' ').trim().slice(0, 220);
|
|
335
339
|
batchFailures.push({ code: codeBlock, error: shortErr });
|
|
340
|
+
opts?.onAttempt?.({ code: codeBlock, error: shortErr });
|
|
336
341
|
}
|
|
337
342
|
if (expectedUrl) {
|
|
338
343
|
if (page) {
|
|
339
344
|
try {
|
|
340
|
-
await page.waitForURL((url) =>
|
|
345
|
+
await page.waitForURL((url) => matchesNavigationUrl(expectedUrl, `${url.pathname}${url.search}${url.hash}`), { timeout: 5000 });
|
|
341
346
|
}
|
|
342
347
|
catch {
|
|
343
348
|
// URL did not transition to expectedUrl within timeout
|
|
@@ -345,7 +350,7 @@ class Navigator {
|
|
|
345
350
|
}
|
|
346
351
|
const freshState = await this.explorer.capture();
|
|
347
352
|
const currentUrl = /^https?:\/\//i.test(expectedUrl) ? freshState.fullUrl || freshState.url || '' : freshState.url || '';
|
|
348
|
-
const urlMatches = this.isSameExpectedOrigin(expectedUrl, action.stateManager) &&
|
|
353
|
+
const urlMatches = this.isSameExpectedOrigin(expectedUrl, action.stateManager) && matchesNavigationUrl(expectedUrl, currentUrl);
|
|
349
354
|
const stateChanged = freshState.getStateHash() !== actionResult.getStateHash();
|
|
350
355
|
resolved = urlMatches && stateChanged;
|
|
351
356
|
if (!resolved && attemptOk) {
|
|
@@ -562,7 +567,7 @@ class Navigator {
|
|
|
562
567
|
tag('operation').log(`Reusing cached verification: ${cachedVerification ? 'PASS' : 'FAIL'}`);
|
|
563
568
|
return { verified: cachedVerification, successfulCodes: [], assertionSteps: [], totalAttempted: 0 };
|
|
564
569
|
}
|
|
565
|
-
const knowledge = this.knowledgeTracker.
|
|
570
|
+
const knowledge = this.knowledgeTracker.renderRelevantContext(actionResult);
|
|
566
571
|
let experience = '';
|
|
567
572
|
if (!actionResult.isInsideIframe) {
|
|
568
573
|
experience = this.experienceTracker.renderExperienceTocFor(actionResult);
|
package/dist/src/ai/planner.d.ts
CHANGED
|
@@ -50,6 +50,7 @@ export declare class Planner extends PlannerBase implements Agent {
|
|
|
50
50
|
provider: Provider;
|
|
51
51
|
stateManager: StateManager;
|
|
52
52
|
experienceTracker: ExperienceTracker;
|
|
53
|
+
knowledgeTracker: AgentDeps['knowledgeTracker'];
|
|
53
54
|
MIN_TASKS: number;
|
|
54
55
|
MAX_TASKS: number;
|
|
55
56
|
currentPlan: Plan | null;
|
package/dist/src/ai/planner.js
CHANGED
|
@@ -41,6 +41,7 @@ export class Planner extends PlannerBase {
|
|
|
41
41
|
provider;
|
|
42
42
|
stateManager;
|
|
43
43
|
experienceTracker;
|
|
44
|
+
knowledgeTracker;
|
|
44
45
|
MIN_TASKS = 3;
|
|
45
46
|
MAX_TASKS = 12;
|
|
46
47
|
currentPlan = null;
|
|
@@ -56,6 +57,7 @@ export class Planner extends PlannerBase {
|
|
|
56
57
|
this.researcher = researcher;
|
|
57
58
|
this.stateManager = deps.stateManager;
|
|
58
59
|
this.experienceTracker = deps.stateManager.getExperienceTracker();
|
|
60
|
+
this.knowledgeTracker = deps.knowledgeTracker;
|
|
59
61
|
}
|
|
60
62
|
setFisherman(fisherman) {
|
|
61
63
|
this.fisherman = fisherman;
|
|
@@ -368,6 +370,10 @@ export class Planner extends PlannerBase {
|
|
|
368
370
|
${plannerResearch}
|
|
369
371
|
</page_research>
|
|
370
372
|
`);
|
|
373
|
+
const applicationContext = this.knowledgeTracker.renderApplicationSpec(state);
|
|
374
|
+
if (applicationContext) {
|
|
375
|
+
conversation.addUserText(applicationContext);
|
|
376
|
+
}
|
|
371
377
|
conversation.addUserText(dedent `
|
|
372
378
|
${this.buildApproach(style)}
|
|
373
379
|
|
|
@@ -379,7 +379,7 @@ export class Researcher extends ResearcherBase {
|
|
|
379
379
|
if (!this.actionResult)
|
|
380
380
|
throw new Error('actionResult is not set');
|
|
381
381
|
const html = await this.actionResult.combinedHtml();
|
|
382
|
-
const knowledge = this.knowledgeTracker.
|
|
382
|
+
const knowledge = this.knowledgeTracker.renderRelevantContext(this.actionResult);
|
|
383
383
|
const ariaSnapshot = this.actionResult.getCompactARIA();
|
|
384
384
|
return dedent `
|
|
385
385
|
Analyze this web page and provide a comprehensive research report in markdown format.
|
|
@@ -49,7 +49,7 @@ export class TaskAgent {
|
|
|
49
49
|
return this.provider;
|
|
50
50
|
}
|
|
51
51
|
getKnowledge(actionResult) {
|
|
52
|
-
return this.getKnowledgeTracker().
|
|
52
|
+
return this.getKnowledgeTracker().renderRelevantContext(actionResult);
|
|
53
53
|
}
|
|
54
54
|
getExperience(actionResult) {
|
|
55
55
|
return this.getExperienceTracker().renderExperienceTocFor(actionResult);
|
package/dist/src/ai/tester.d.ts
CHANGED
|
@@ -31,6 +31,7 @@ export declare class Tester extends TaskAgent implements Agent {
|
|
|
31
31
|
seenUiMapUrls: Set<string>;
|
|
32
32
|
lastAnalyzedStateHash: string | null;
|
|
33
33
|
stalledIterations: number;
|
|
34
|
+
hasSuccessfulAssertion: boolean;
|
|
34
35
|
readonly MAX_STALLED_ITERATIONS = 3;
|
|
35
36
|
constructor(deps: AgentDeps, researcher: Researcher, navigator: Navigator, agentTools?: any);
|
|
36
37
|
getNavigator(): Navigator;
|
package/dist/src/ai/tester.js
CHANGED
|
@@ -48,6 +48,7 @@ export class Tester extends TaskAgent {
|
|
|
48
48
|
seenUiMapUrls = new Set();
|
|
49
49
|
lastAnalyzedStateHash = null;
|
|
50
50
|
stalledIterations = 0;
|
|
51
|
+
hasSuccessfulAssertion = false;
|
|
51
52
|
MAX_STALLED_ITERATIONS = 3;
|
|
52
53
|
constructor(deps, researcher, navigator, agentTools) {
|
|
53
54
|
super(deps);
|
|
@@ -87,6 +88,7 @@ export class Tester extends TaskAgent {
|
|
|
87
88
|
this.seenUiMapUrls.clear();
|
|
88
89
|
this.lastAnalyzedStateHash = null;
|
|
89
90
|
this.stalledIterations = 0;
|
|
91
|
+
this.hasSuccessfulAssertion = false;
|
|
90
92
|
this.stateManager.clearHistory();
|
|
91
93
|
this.resetFailureCount();
|
|
92
94
|
this.pilot?.reset();
|
|
@@ -266,8 +268,15 @@ export class Tester extends TaskAgent {
|
|
|
266
268
|
const allToolNames = result?.toolExecutions?.map((execution) => execution.toolName) || [];
|
|
267
269
|
const successfulToolNames = result?.toolExecutions?.filter((execution) => execution.wasSuccessful)?.map((execution) => execution.toolName) || [];
|
|
268
270
|
const actionPerformed = !!allToolNames.find((toolName) => this.ACTION_TOOLS.includes(toolName));
|
|
271
|
+
const successfulActionPerformed = !!successfulToolNames.find((toolName) => this.ACTION_TOOLS.includes(toolName));
|
|
269
272
|
assertionPerformed = !!successfulToolNames.find((toolName) => this.ASSERTION_TOOLS.includes(toolName));
|
|
270
273
|
const wasSuccessful = result?.toolExecutions?.every((execution) => execution.wasSuccessful);
|
|
274
|
+
if (successfulActionPerformed) {
|
|
275
|
+
this.hasSuccessfulAssertion = false;
|
|
276
|
+
}
|
|
277
|
+
if (assertionPerformed) {
|
|
278
|
+
this.hasSuccessfulAssertion = true;
|
|
279
|
+
}
|
|
271
280
|
this.trackToolExecutions(result?.toolExecutions || []);
|
|
272
281
|
if (this.consecutiveEmptyResults >= 5) {
|
|
273
282
|
task.addNote('AI model is not responding with actions. Stopped');
|
|
@@ -405,6 +414,10 @@ export class Tester extends TaskAgent {
|
|
|
405
414
|
this.stalledIterations++;
|
|
406
415
|
if (this.stalledIterations < this.MAX_STALLED_ITERATIONS)
|
|
407
416
|
return false;
|
|
417
|
+
if (this.hasSuccessfulAssertion) {
|
|
418
|
+
task.addNote('No further browser progress after successful verification; requesting final review');
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
408
421
|
task.addNote('No browser progress after repeated attempts on unchanged page', TestResult.FAILED);
|
|
409
422
|
task.finish(TestResult.FAILED);
|
|
410
423
|
return true;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const APPLICATION_SPEC_FORMAT = "explorbot-application-spec";
|
|
3
|
+
export declare const APPLICATION_SPEC_VERSION = 1;
|
|
4
|
+
export declare const APPLICATION_SPEC_PAGE_SCHEMA: z.ZodObject<{
|
|
5
|
+
format: z.ZodLiteral<"explorbot-application-spec">;
|
|
6
|
+
version: z.ZodLiteral<1>;
|
|
7
|
+
url: z.ZodString;
|
|
8
|
+
}, z.core.$strip>;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export const APPLICATION_SPEC_FORMAT = 'explorbot-application-spec';
|
|
3
|
+
export const APPLICATION_SPEC_VERSION = 1;
|
|
4
|
+
export const APPLICATION_SPEC_PAGE_SCHEMA = z.object({
|
|
5
|
+
format: z.literal(APPLICATION_SPEC_FORMAT),
|
|
6
|
+
version: z.literal(APPLICATION_SPEC_VERSION),
|
|
7
|
+
url: z.string().trim().min(1),
|
|
8
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ActionResult } from './action-result.js';
|
|
2
|
+
export declare class ApplicationSpec {
|
|
3
|
+
pages: ApplicationSpecPage[];
|
|
4
|
+
readonly sourcePath: string;
|
|
5
|
+
constructor(sourcePath: string);
|
|
6
|
+
renderFor(state: ActionResult): string;
|
|
7
|
+
get pageCount(): number;
|
|
8
|
+
load(): void;
|
|
9
|
+
resolveSourcePath(sourcePath: string): string;
|
|
10
|
+
}
|
|
11
|
+
interface ApplicationSpecPage {
|
|
12
|
+
url: string;
|
|
13
|
+
content: string;
|
|
14
|
+
}
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { existsSync, statSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import dedent from 'dedent';
|
|
4
|
+
import { APPLICATION_SPEC_PAGE_SCHEMA } from "./application-spec-contract.js";
|
|
5
|
+
import { ConfigParser } from "./config.js";
|
|
6
|
+
import { tag } from "./utils/logger.js";
|
|
7
|
+
import { loadMarkdownFiles } from "./utils/markdown-files.js";
|
|
8
|
+
export class ApplicationSpec {
|
|
9
|
+
pages = [];
|
|
10
|
+
sourcePath;
|
|
11
|
+
constructor(sourcePath) {
|
|
12
|
+
this.sourcePath = this.resolveSourcePath(sourcePath);
|
|
13
|
+
this.load();
|
|
14
|
+
}
|
|
15
|
+
renderFor(state) {
|
|
16
|
+
const relevant = this.pages.filter((page) => state.isMatchedBy({ url: page.url }));
|
|
17
|
+
if (relevant.length === 0)
|
|
18
|
+
return '';
|
|
19
|
+
tag('operation').log(`Found application specification for ${state.url}`);
|
|
20
|
+
return dedent `
|
|
21
|
+
<application_spec>
|
|
22
|
+
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.
|
|
23
|
+
|
|
24
|
+
${relevant.map((page) => page.content).join('\n\n')}
|
|
25
|
+
</application_spec>
|
|
26
|
+
`;
|
|
27
|
+
}
|
|
28
|
+
get pageCount() {
|
|
29
|
+
return this.pages.length;
|
|
30
|
+
}
|
|
31
|
+
load() {
|
|
32
|
+
if (!existsSync(this.sourcePath)) {
|
|
33
|
+
throw new Error(`Application spec not found: ${this.sourcePath}`);
|
|
34
|
+
}
|
|
35
|
+
const isDirectory = statSync(this.sourcePath).isDirectory();
|
|
36
|
+
if (!isDirectory && path.basename(this.sourcePath).toLowerCase() !== 'index.md') {
|
|
37
|
+
throw new Error(`Application spec file must be index.md: ${this.sourcePath}`);
|
|
38
|
+
}
|
|
39
|
+
const bundlePath = isDirectory ? this.sourcePath : path.dirname(this.sourcePath);
|
|
40
|
+
const indexPath = path.join(bundlePath, 'index.md');
|
|
41
|
+
if (!existsSync(indexPath)) {
|
|
42
|
+
throw new Error(`Application spec index not found: ${indexPath}`);
|
|
43
|
+
}
|
|
44
|
+
const pagesPath = path.join(bundlePath, 'pages');
|
|
45
|
+
if (!existsSync(pagesPath)) {
|
|
46
|
+
throw new Error(`Application spec pages directory not found: ${pagesPath}`);
|
|
47
|
+
}
|
|
48
|
+
for (const file of loadMarkdownFiles(pagesPath, { recursive: true })) {
|
|
49
|
+
const parsed = APPLICATION_SPEC_PAGE_SCHEMA.safeParse(file.data);
|
|
50
|
+
if (!parsed.success && parsed.error.issues.some((issue) => issue.path[0] === 'format')) {
|
|
51
|
+
throw new Error(`Invalid application spec format in ${file.filePath}`);
|
|
52
|
+
}
|
|
53
|
+
if (!parsed.success && parsed.error.issues.some((issue) => issue.path[0] === 'version')) {
|
|
54
|
+
throw new Error(`Unsupported application spec version in ${file.filePath}: ${String(file.data.version)}`);
|
|
55
|
+
}
|
|
56
|
+
if (!parsed.success) {
|
|
57
|
+
throw new Error(`Application spec page URL is missing in ${file.filePath}`);
|
|
58
|
+
}
|
|
59
|
+
this.pages.push({ url: parsed.data.url, content: file.content.trim() });
|
|
60
|
+
}
|
|
61
|
+
if (this.pages.length === 0) {
|
|
62
|
+
throw new Error(`Application spec contains no documented pages: ${pagesPath}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
resolveSourcePath(sourcePath) {
|
|
66
|
+
if (path.isAbsolute(sourcePath))
|
|
67
|
+
return path.resolve(sourcePath);
|
|
68
|
+
const configParser = ConfigParser.getInstance();
|
|
69
|
+
return path.resolve(configParser.resolveProjectDir(sourcePath));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -1,10 +1,16 @@
|
|
|
1
|
-
declare function getEndpointFilePath(): string;
|
|
2
|
-
declare function readEndpoint(): string | null;
|
|
3
|
-
declare function removeEndpointFile(): void;
|
|
1
|
+
declare function getEndpointFilePath(instance?: string): string;
|
|
2
|
+
declare function readEndpoint(instance?: string): string | null;
|
|
3
|
+
declare function removeEndpointFile(instance?: string): void;
|
|
4
|
+
declare function listInstances(): Array<{
|
|
5
|
+
name: string;
|
|
6
|
+
endpoint: string;
|
|
7
|
+
}>;
|
|
4
8
|
declare function isServerRunning(wsEndpoint: string): Promise<boolean>;
|
|
5
9
|
declare function launchServer(opts: {
|
|
6
10
|
browser?: string;
|
|
7
11
|
show?: boolean;
|
|
8
|
-
}): Promise<any>;
|
|
9
|
-
declare function
|
|
10
|
-
|
|
12
|
+
}, instance?: string): Promise<any>;
|
|
13
|
+
declare function stopServer(instance?: string): Promise<boolean>;
|
|
14
|
+
declare function keepServerRunning(stop: () => unknown): Promise<never>;
|
|
15
|
+
declare function getAliveEndpoint(instance?: string): Promise<string | null>;
|
|
16
|
+
export { readEndpoint, removeEndpointFile, isServerRunning, launchServer, stopServer, getEndpointFilePath, getAliveEndpoint, listInstances, keepServerRunning };
|
|
@@ -1,35 +1,62 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { chromium, firefox, webkit } from 'playwright
|
|
3
|
+
import { chromium, firefox, webkit } from 'playwright';
|
|
4
4
|
import { ConfigParser } from './config.js';
|
|
5
5
|
import { getCliName } from "./utils/cli-name.js";
|
|
6
6
|
import { log } from './utils/logger.js';
|
|
7
7
|
import { printNextSteps } from "./utils/next-steps.js";
|
|
8
8
|
const ENDPOINT_FILENAME = '.browser-endpoint';
|
|
9
|
-
|
|
9
|
+
const INSTANCE_NAME_PATTERN = /^[a-z0-9-]+$/;
|
|
10
|
+
const KEEP_ALIVE_INTERVAL = 1 << 30;
|
|
11
|
+
function getEndpointFilePath(instance = 'default') {
|
|
12
|
+
if (!INSTANCE_NAME_PATTERN.test(instance)) {
|
|
13
|
+
throw new Error(`Invalid browser instance name: "${instance}". Use lowercase letters, digits and dashes.`);
|
|
14
|
+
}
|
|
10
15
|
const configParser = ConfigParser.getInstance();
|
|
11
16
|
const outputDir = configParser.getOutputDir();
|
|
12
|
-
|
|
17
|
+
if (instance === 'default')
|
|
18
|
+
return path.join(outputDir, ENDPOINT_FILENAME);
|
|
19
|
+
return path.join(outputDir, `${ENDPOINT_FILENAME}-${instance}`);
|
|
13
20
|
}
|
|
14
|
-
function readEndpoint() {
|
|
15
|
-
const filePath = getEndpointFilePath();
|
|
21
|
+
function readEndpoint(instance = 'default') {
|
|
22
|
+
const filePath = getEndpointFilePath(instance);
|
|
16
23
|
if (!existsSync(filePath))
|
|
17
24
|
return null;
|
|
18
25
|
return readFileSync(filePath, 'utf8').trim();
|
|
19
26
|
}
|
|
20
|
-
function writeEndpoint(wsEndpoint) {
|
|
21
|
-
const filePath = getEndpointFilePath();
|
|
27
|
+
function writeEndpoint(wsEndpoint, instance = 'default') {
|
|
28
|
+
const filePath = getEndpointFilePath(instance);
|
|
22
29
|
const dir = path.dirname(filePath);
|
|
23
30
|
if (!existsSync(dir)) {
|
|
24
31
|
mkdirSync(dir, { recursive: true });
|
|
25
32
|
}
|
|
26
33
|
writeFileSync(filePath, wsEndpoint, 'utf8');
|
|
27
34
|
}
|
|
28
|
-
function removeEndpointFile() {
|
|
29
|
-
const filePath = getEndpointFilePath();
|
|
35
|
+
function removeEndpointFile(instance = 'default') {
|
|
36
|
+
const filePath = getEndpointFilePath(instance);
|
|
30
37
|
if (existsSync(filePath))
|
|
31
38
|
unlinkSync(filePath);
|
|
32
39
|
}
|
|
40
|
+
function listInstances() {
|
|
41
|
+
const dir = path.dirname(getEndpointFilePath());
|
|
42
|
+
if (!existsSync(dir))
|
|
43
|
+
return [];
|
|
44
|
+
const instances = [];
|
|
45
|
+
for (const fileName of readdirSync(dir)) {
|
|
46
|
+
if (!fileName.startsWith(ENDPOINT_FILENAME))
|
|
47
|
+
continue;
|
|
48
|
+
const suffix = fileName.slice(ENDPOINT_FILENAME.length);
|
|
49
|
+
if (suffix && !suffix.startsWith('-'))
|
|
50
|
+
continue;
|
|
51
|
+
if (suffix === '-')
|
|
52
|
+
continue;
|
|
53
|
+
const name = suffix.slice(1) || 'default';
|
|
54
|
+
if (!INSTANCE_NAME_PATTERN.test(name))
|
|
55
|
+
continue;
|
|
56
|
+
instances.push({ name, endpoint: readFileSync(path.join(dir, fileName), 'utf8').trim() });
|
|
57
|
+
}
|
|
58
|
+
return instances;
|
|
59
|
+
}
|
|
33
60
|
async function isServerRunning(wsEndpoint) {
|
|
34
61
|
try {
|
|
35
62
|
const browser = await chromium.connect(wsEndpoint, { timeout: 3000 });
|
|
@@ -41,7 +68,7 @@ async function isServerRunning(wsEndpoint) {
|
|
|
41
68
|
}
|
|
42
69
|
}
|
|
43
70
|
const BROWSER_LAUNCHERS = { chromium, firefox, webkit };
|
|
44
|
-
async function launchServer(opts) {
|
|
71
|
+
async function launchServer(opts, instance = 'default') {
|
|
45
72
|
const browserName = (opts.browser || 'chromium');
|
|
46
73
|
const launcher = BROWSER_LAUNCHERS[browserName];
|
|
47
74
|
if (!launcher)
|
|
@@ -50,30 +77,58 @@ async function launchServer(opts) {
|
|
|
50
77
|
headless: !opts.show,
|
|
51
78
|
});
|
|
52
79
|
const wsEndpoint = server.wsEndpoint();
|
|
53
|
-
writeEndpoint(wsEndpoint);
|
|
80
|
+
writeEndpoint(wsEndpoint, instance);
|
|
54
81
|
log(`Browser server started: ${browserName} (${opts.show ? 'headed' : 'headless'})`);
|
|
55
82
|
const cli = getCliName();
|
|
83
|
+
let instanceFlag = '';
|
|
84
|
+
if (instance !== 'default')
|
|
85
|
+
instanceFlag = ` --instance ${instance}`;
|
|
56
86
|
const sections = [
|
|
57
87
|
{
|
|
58
88
|
label: 'Browser server',
|
|
59
|
-
path: getEndpointFilePath(),
|
|
89
|
+
path: getEndpointFilePath(instance),
|
|
60
90
|
commands: [
|
|
61
91
|
{ label: 'Endpoint', command: wsEndpoint },
|
|
62
|
-
{ label: 'Status', command: `${cli} browser status` },
|
|
63
|
-
{ label: 'Stop', command: `${cli} browser stop` },
|
|
92
|
+
{ label: 'Status', command: `${cli} browser status${instanceFlag}` },
|
|
93
|
+
{ label: 'Stop', command: `${cli} browser stop${instanceFlag}` },
|
|
64
94
|
],
|
|
65
95
|
},
|
|
66
96
|
];
|
|
67
97
|
printNextSteps(sections);
|
|
68
98
|
return server;
|
|
69
99
|
}
|
|
70
|
-
async function
|
|
71
|
-
const endpoint =
|
|
100
|
+
async function stopServer(instance = 'default') {
|
|
101
|
+
const endpoint = await getAliveEndpoint(instance);
|
|
102
|
+
if (!endpoint)
|
|
103
|
+
return false;
|
|
104
|
+
try {
|
|
105
|
+
const browser = await chromium.connect(endpoint, { timeout: 3000 });
|
|
106
|
+
await browser.close();
|
|
107
|
+
}
|
|
108
|
+
catch { }
|
|
109
|
+
removeEndpointFile(instance);
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
function keepServerRunning(stop) {
|
|
113
|
+
console.log('Browser server is running. Press Ctrl+C to stop.');
|
|
114
|
+
const heartbeat = setInterval(() => { }, KEEP_ALIVE_INTERVAL);
|
|
115
|
+
const cleanup = async () => {
|
|
116
|
+
console.log('\nStopping browser server...');
|
|
117
|
+
clearInterval(heartbeat);
|
|
118
|
+
await stop();
|
|
119
|
+
process.exit(0);
|
|
120
|
+
};
|
|
121
|
+
process.on('SIGINT', cleanup);
|
|
122
|
+
process.on('SIGTERM', cleanup);
|
|
123
|
+
return new Promise(() => { });
|
|
124
|
+
}
|
|
125
|
+
async function getAliveEndpoint(instance = 'default') {
|
|
126
|
+
const endpoint = readEndpoint(instance);
|
|
72
127
|
if (!endpoint)
|
|
73
128
|
return null;
|
|
74
129
|
if (await isServerRunning(endpoint))
|
|
75
130
|
return endpoint;
|
|
76
|
-
removeEndpointFile();
|
|
131
|
+
removeEndpointFile(instance);
|
|
77
132
|
return null;
|
|
78
133
|
}
|
|
79
|
-
export { readEndpoint, removeEndpointFile, isServerRunning, launchServer, getEndpointFilePath, getAliveEndpoint };
|
|
134
|
+
export { readEndpoint, removeEndpointFile, isServerRunning, launchServer, stopServer, getEndpointFilePath, getAliveEndpoint, listInstances, keepServerRunning };
|