explorbot 0.3.5 → 0.4.1
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 +23 -16
- package/boat/api-tester/src/ai/chief.ts +7 -1
- package/boat/api-tester/src/ai/curler.ts +7 -1
- package/boat/api-tester/src/apibot.ts +10 -4
- package/boat/api-tester/src/cli.ts +12 -2
- package/boat/api-tester/src/config.ts +28 -8
- package/boat/doc-collector/bin/doc-collector-cli.ts +3 -2
- package/boat/doc-collector/src/cli.ts +6 -1
- package/boat/doc-collector/src/docbot.ts +4 -2
- package/boat/prima/bin/prima-cli.ts +2 -0
- package/boat/prima/src/cli.ts +23 -8
- package/boat/prima/src/envelope.ts +35 -9
- package/boat/prima/src/prima.ts +23 -10
- package/dist/bin/explorbot-cli.js +24 -16
- package/dist/boat/api-tester/bin/apibot-cli.js +3 -2
- package/dist/boat/api-tester/src/ai/chief.js +6 -1
- package/dist/boat/api-tester/src/ai/curler.js +6 -1
- package/dist/boat/api-tester/src/apibot.js +7 -3
- package/dist/boat/api-tester/src/cli.js +12 -2
- package/dist/boat/api-tester/src/config.js +31 -8
- package/dist/boat/doc-collector/bin/doc-collector-cli.js +3 -2
- package/dist/boat/doc-collector/src/cli.js +6 -1
- package/dist/boat/doc-collector/src/docbot.js +4 -2
- package/dist/boat/prima/bin/prima-cli.js +2 -0
- package/dist/boat/prima/src/cli.js +22 -8
- package/dist/boat/prima/src/envelope.js +24 -6
- package/dist/boat/prima/src/prima.js +23 -11
- package/dist/package.json +2 -2
- package/dist/rules/planner/styles/normal.md +1 -1
- package/dist/src/action-result.d.ts +9 -1
- package/dist/src/action-result.js +57 -18
- package/dist/src/action.d.ts +1 -1
- package/dist/src/action.js +87 -12
- package/dist/src/ai/captain.js +1 -1
- package/dist/src/ai/driller.d.ts +0 -1
- package/dist/src/ai/driller.js +8 -20
- package/dist/src/ai/fisherman-tools.d.ts +9 -0
- package/dist/src/ai/fisherman-tools.js +52 -6
- package/dist/src/ai/fisherman.d.ts +4 -2
- package/dist/src/ai/fisherman.js +48 -27
- package/dist/src/ai/historian/codeceptjs.js +1 -1
- package/dist/src/ai/historian/playwright.js +1 -1
- package/dist/src/ai/navigator.js +1 -1
- package/dist/src/ai/pilot.d.ts +1 -0
- package/dist/src/ai/pilot.js +13 -1
- package/dist/src/ai/planner.js +9 -7
- package/dist/src/ai/provider.js +20 -3
- package/dist/src/ai/researcher/deep-analysis.js +1 -3
- package/dist/src/ai/researcher.js +5 -3
- package/dist/src/ai/rules.js +3 -3
- package/dist/src/ai/tester.d.ts +3 -0
- package/dist/src/ai/tester.js +40 -3
- package/dist/src/ai/tools.d.ts +1 -0
- package/dist/src/ai/tools.js +13 -6
- package/dist/src/api/request-result.d.ts +2 -0
- package/dist/src/api/request-result.js +8 -2
- package/dist/src/api/request-store.d.ts +3 -2
- package/dist/src/api/request-store.js +66 -14
- package/dist/src/api/spec-reader.js +1 -1
- package/dist/src/commands/config-command.js +1 -1
- package/dist/src/commands/drill-command.js +1 -1
- package/dist/src/commands/explore-command.d.ts +6 -0
- package/dist/src/commands/explore-command.js +39 -3
- package/dist/src/commands/freesail-command.js +10 -1
- package/dist/src/commands/options/base-option.d.ts +8 -0
- package/dist/src/commands/options/base-option.js +12 -0
- package/dist/src/commands/options/index.d.ts +5 -0
- package/dist/src/commands/options/index.js +5 -0
- package/dist/src/commands/options/knowledge-option.d.ts +7 -0
- package/dist/src/commands/options/knowledge-option.js +12 -0
- package/dist/src/commands/options/ws-option.d.ts +7 -0
- package/dist/src/commands/options/ws-option.js +21 -0
- package/dist/src/commands/plans-command.js +6 -6
- package/dist/src/config.d.ts +1 -0
- package/dist/src/config.js +12 -0
- package/dist/src/experience-tracker.js +5 -0
- package/dist/src/explorbot.d.ts +0 -1
- package/dist/src/explorbot.js +24 -37
- package/dist/src/knowledge-tracker.d.ts +20 -7
- package/dist/src/knowledge-tracker.js +69 -31
- package/dist/src/remote.d.ts +0 -3
- package/dist/src/remote.js +0 -18
- package/dist/src/state-manager.d.ts +5 -1
- package/dist/src/state-manager.js +10 -7
- package/dist/src/test-plan.d.ts +3 -0
- package/dist/src/test-plan.js +27 -0
- package/dist/src/utils/aria.d.ts +1 -1
- package/dist/src/utils/aria.js +6 -42
- package/dist/src/utils/html-diff.d.ts +4 -0
- package/dist/src/utils/html-diff.js +62 -7
- package/dist/src/utils/html.d.ts +5 -15
- package/dist/src/utils/html.js +14 -85
- package/dist/src/utils/overlay.d.ts +56 -11
- package/dist/src/utils/overlay.js +191 -21
- package/dist/src/utils/request-map.d.ts +7 -0
- package/dist/src/utils/request-map.js +16 -0
- package/dist/src/utils/url-matcher.js +4 -2
- package/docs/api-testing/basics.md +15 -0
- package/docs/api-testing/planning.md +10 -1
- package/docs/reference/commands.md +32 -5
- package/docs/reference/websocket.md +1 -0
- package/docs/superpowers/plans/2026-08-29-fisherman-reliability.md +953 -0
- package/docs/superpowers/plans/2026-08-29-region-states.md +1292 -0
- package/docs/superpowers/plans/2026-08-30-fisherman-live-session-auth.md +457 -0
- package/docs/superpowers/specs/2026-08-29-fisherman-reliability-design.md +45 -0
- package/docs/superpowers/specs/2026-08-29-region-states-design.md +262 -0
- package/docs/superpowers/specs/2026-08-29-region-states-fixes-design.md +269 -0
- package/docs/superpowers/specs/2026-08-30-fisherman-live-session-auth-design.md +37 -0
- package/docs/workflow/agentic-usage.md +12 -2
- package/docs/workflow/ci.md +1 -0
- package/docs/workflow/knowledge.md +46 -2
- package/package.json +2 -2
- package/rules/planner/styles/normal.md +1 -1
- package/src/action-result.ts +61 -22
- package/src/action.ts +87 -14
- package/src/ai/captain.ts +1 -1
- package/src/ai/driller.ts +7 -39
- package/src/ai/fisherman-tools.ts +56 -7
- package/src/ai/fisherman.ts +48 -28
- package/src/ai/historian/codeceptjs.ts +1 -1
- package/src/ai/historian/playwright.ts +1 -1
- package/src/ai/navigator.ts +1 -1
- package/src/ai/pilot.ts +9 -1
- package/src/ai/planner.ts +9 -8
- package/src/ai/provider.ts +21 -3
- package/src/ai/researcher/deep-analysis.ts +1 -2
- package/src/ai/researcher.ts +4 -3
- package/src/ai/rules.ts +3 -3
- package/src/ai/tester.ts +40 -3
- package/src/ai/tools.ts +17 -9
- package/src/api/request-result.ts +10 -2
- package/src/api/request-store.ts +60 -13
- package/src/api/spec-reader.ts +1 -1
- package/src/commands/config-command.ts +1 -1
- package/src/commands/drill-command.ts +1 -1
- package/src/commands/explore-command.ts +37 -3
- package/src/commands/freesail-command.ts +7 -1
- package/src/commands/options/base-option.ts +18 -0
- package/src/commands/options/index.ts +7 -0
- package/src/commands/options/knowledge-option.ts +14 -0
- package/src/commands/options/ws-option.ts +24 -0
- package/src/commands/plans-command.ts +6 -6
- package/src/config.ts +12 -0
- package/src/experience-tracker.ts +5 -1
- package/src/explorbot.ts +21 -37
- package/src/knowledge-tracker.ts +94 -36
- package/src/remote.ts +0 -20
- package/src/state-manager.ts +13 -7
- package/src/test-plan.ts +29 -0
- package/src/utils/aria.ts +7 -44
- package/src/utils/html-diff.ts +62 -7
- package/src/utils/html.ts +14 -91
- package/src/utils/overlay.ts +226 -23
- package/src/utils/request-map.ts +19 -0
- package/src/utils/url-matcher.ts +3 -2
package/src/api/request-store.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { existsSync, readdirSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { isDynamicSegment } from '../utils/url-matcher.ts';
|
|
3
4
|
import { RequestResult } from './request-result.ts';
|
|
4
5
|
|
|
5
|
-
const AUTH_HEADERS = ['authorization', '
|
|
6
|
+
const AUTH_HEADERS = ['authorization', 'x-api-key', 'x-csrf-token'];
|
|
6
7
|
|
|
7
8
|
export class RequestStore {
|
|
8
9
|
private capturedRequests: RequestResult[] = [];
|
|
@@ -10,6 +11,7 @@ export class RequestStore {
|
|
|
10
11
|
private failedRequests: RequestResult[] = [];
|
|
11
12
|
private onFailedListeners: Array<(r: RequestResult) => void> = [];
|
|
12
13
|
private outputDir: string;
|
|
14
|
+
private sessionStartedAt = new Date();
|
|
13
15
|
|
|
14
16
|
constructor(outputDir: string) {
|
|
15
17
|
this.outputDir = outputDir;
|
|
@@ -77,13 +79,15 @@ export class RequestStore {
|
|
|
77
79
|
return this.madeRequests.filter((r) => r.status === status);
|
|
78
80
|
}
|
|
79
81
|
|
|
80
|
-
toEndpointList(): string {
|
|
82
|
+
toEndpointList(scopePath?: string): string {
|
|
83
|
+
let requests = this.capturedRequests;
|
|
84
|
+
if (scopePath) requests = this.getWriteRequestsForScope(scopePath);
|
|
85
|
+
|
|
81
86
|
const seen = new Set<string>();
|
|
82
87
|
const lines: string[] = [];
|
|
83
88
|
|
|
84
|
-
for (const req of
|
|
85
|
-
const
|
|
86
|
-
const key = `${req.method} ${normalized}`;
|
|
89
|
+
for (const req of requests) {
|
|
90
|
+
const key = `${req.method} ${normalizePathPattern(req.path)}`;
|
|
87
91
|
if (seen.has(key)) continue;
|
|
88
92
|
seen.add(key);
|
|
89
93
|
lines.push(key);
|
|
@@ -94,23 +98,42 @@ export class RequestStore {
|
|
|
94
98
|
|
|
95
99
|
extractAuthHeaders(): Record<string, string> {
|
|
96
100
|
const headers: Record<string, string> = {};
|
|
101
|
+
const sessionCaptures = this.capturedRequests.filter((r) => r.timestamp >= this.sessionStartedAt).sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
|
97
102
|
|
|
98
|
-
for (
|
|
99
|
-
const req = this.capturedRequests[i];
|
|
103
|
+
for (const req of sessionCaptures) {
|
|
100
104
|
for (const [key, value] of Object.entries(req.requestHeaders)) {
|
|
101
105
|
if (AUTH_HEADERS.includes(key.toLowerCase()) && !headers[key]) {
|
|
102
106
|
headers[key] = value;
|
|
103
107
|
}
|
|
104
108
|
}
|
|
105
|
-
if (AUTH_HEADERS.every((h) => Object.keys(headers).some((k) => k.toLowerCase() === h))) break;
|
|
106
109
|
}
|
|
107
110
|
|
|
108
111
|
return headers;
|
|
109
112
|
}
|
|
110
113
|
|
|
111
|
-
findCapturedRequest(method: string,
|
|
114
|
+
findCapturedRequest(method: string, searchPath: string): RequestResult | undefined {
|
|
112
115
|
const upper = method.toUpperCase();
|
|
113
|
-
|
|
116
|
+
const search = normalizePathPattern(searchPath).split('/').filter(Boolean);
|
|
117
|
+
|
|
118
|
+
let best: RequestResult | undefined;
|
|
119
|
+
let bestScore = -1;
|
|
120
|
+
|
|
121
|
+
for (const req of this.capturedRequests) {
|
|
122
|
+
if (req.method !== upper) continue;
|
|
123
|
+
const segments = normalizePathPattern(req.path).split('/').filter(Boolean);
|
|
124
|
+
if (segments.length < search.length) continue;
|
|
125
|
+
if (!search.every((segment, i) => segment === segments[i])) continue;
|
|
126
|
+
|
|
127
|
+
let score = 0;
|
|
128
|
+
if (segments.length === search.length) score += 4;
|
|
129
|
+
if (req.status < 400) score += 2;
|
|
130
|
+
if (score < bestScore) continue;
|
|
131
|
+
if (score === bestScore && best && req.timestamp <= best.timestamp) continue;
|
|
132
|
+
best = req;
|
|
133
|
+
bestScore = score;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return best;
|
|
114
137
|
}
|
|
115
138
|
|
|
116
139
|
toLog(): string {
|
|
@@ -122,7 +145,7 @@ export class RequestStore {
|
|
|
122
145
|
if (!existsSync(requestsDir)) return;
|
|
123
146
|
|
|
124
147
|
const existingIds = new Set(this.capturedRequests.map((r) => r.id));
|
|
125
|
-
const files = readdirSync(requestsDir).filter((f) => f.endsWith('.request.yaml'));
|
|
148
|
+
const files = readdirSync(requestsDir).filter((f) => f.startsWith('xhr_') && f.endsWith('.request.yaml'));
|
|
126
149
|
|
|
127
150
|
for (const file of files) {
|
|
128
151
|
try {
|
|
@@ -137,7 +160,28 @@ export class RequestStore {
|
|
|
137
160
|
|
|
138
161
|
getWriteRequestsForScope(scopePath: string): RequestResult[] {
|
|
139
162
|
const writeMethods = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
140
|
-
|
|
163
|
+
const writes = this.capturedRequests.filter((r) => writeMethods.has(r.method));
|
|
164
|
+
const scopeSegments = scopePath.split('/').filter(Boolean);
|
|
165
|
+
if (scopeSegments.length === 0) return writes;
|
|
166
|
+
|
|
167
|
+
let scoped: RequestResult[] = [];
|
|
168
|
+
let fewest = Number.POSITIVE_INFINITY;
|
|
169
|
+
let ambiguous = false;
|
|
170
|
+
for (const segment of scopeSegments) {
|
|
171
|
+
if (isDynamicSegment(segment)) continue;
|
|
172
|
+
const matches = writes.filter((r) => r.path.split('/').includes(segment));
|
|
173
|
+
if (matches.length === 0 || matches.length > fewest) continue;
|
|
174
|
+
if (matches.length === fewest) {
|
|
175
|
+
if (!scoped.every((r, i) => r.id === matches[i].id)) ambiguous = true;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
scoped = matches;
|
|
179
|
+
fewest = matches.length;
|
|
180
|
+
ambiguous = false;
|
|
181
|
+
}
|
|
182
|
+
if (ambiguous) return [];
|
|
183
|
+
|
|
184
|
+
return scoped;
|
|
141
185
|
}
|
|
142
186
|
|
|
143
187
|
clear(): void {
|
|
@@ -148,5 +192,8 @@ export class RequestStore {
|
|
|
148
192
|
}
|
|
149
193
|
|
|
150
194
|
function normalizePathPattern(urlPath: string): string {
|
|
151
|
-
return urlPath
|
|
195
|
+
return urlPath
|
|
196
|
+
.split('/')
|
|
197
|
+
.map((segment) => (segment && isDynamicSegment(segment) ? '{id}' : segment))
|
|
198
|
+
.join('/');
|
|
152
199
|
}
|
package/src/api/spec-reader.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { tag } from '../utils/logger.ts';
|
|
|
6
6
|
|
|
7
7
|
export function validateSpecs(specs?: string[]): void {
|
|
8
8
|
if (!specs?.length) {
|
|
9
|
-
throw new Error('API spec is required.
|
|
9
|
+
throw new Error('API spec is required. Pass --spec, set EXPLORBOT_API_SPEC, or set api.spec in your config file.');
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
12
|
|
|
@@ -37,7 +37,7 @@ export class ConfigCommand extends BaseCommand {
|
|
|
37
37
|
const dirs: Record<string, string> = {};
|
|
38
38
|
if (options.root) {
|
|
39
39
|
for (const [name, dir] of Object.entries({ output: 'output', ...config.dirs })) {
|
|
40
|
-
dirs[name] = path.
|
|
40
|
+
dirs[name] = path.resolve(options.root, dir);
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
@@ -26,7 +26,7 @@ export class DrillCommand extends BaseCommand {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
private parseKnowledgeArg(args: string): string | undefined {
|
|
29
|
-
const match = args.match(/--knowledge\s+(\S+)/);
|
|
29
|
+
const match = args.match(/--save-knowledge\s+(\S+)/);
|
|
30
30
|
return match ? match[1] : undefined;
|
|
31
31
|
}
|
|
32
32
|
|
|
@@ -14,12 +14,15 @@ import { BaseCommand, type Suggestion } from './base-command.js';
|
|
|
14
14
|
|
|
15
15
|
const MAX_SUB_PAGE_ATTEMPTS = 30;
|
|
16
16
|
const PRIORITY_ORDER: Record<string, number> = { critical: 0, important: 1, high: 2, normal: 3, low: 4 };
|
|
17
|
+
export const DEADLINE_RESERVE_MS = 3 * 60_000;
|
|
18
|
+
export const DEADLINE_TEST_ALLOWANCE_MS = 5 * 60_000;
|
|
17
19
|
|
|
18
20
|
export class ExploreCommand extends BaseCommand {
|
|
19
21
|
name = 'explore';
|
|
20
22
|
description = 'Start web exploration';
|
|
21
23
|
options = [
|
|
22
24
|
{ flags: '--max-tests <number>', description: 'Maximum number of tests to run' },
|
|
25
|
+
{ flags: '--max-duration <minutes>', description: 'Wall-clock budget in minutes; wraps up the session before the limit is hit' },
|
|
23
26
|
{ flags: '--focus <feature>', description: 'Focus area for exploration' },
|
|
24
27
|
{ flags: '--configure <spec>', description: 'Reuse spec: keys new|from|style|subpages|pick_by|priority, e.g. "new:25%;pick_by=random;priority=critical,high"' },
|
|
25
28
|
{ flags: '--dry-run', description: 'Mark picked tests as skipped without executing or generating new ones' },
|
|
@@ -31,8 +34,11 @@ export class ExploreCommand extends BaseCommand {
|
|
|
31
34
|
];
|
|
32
35
|
|
|
33
36
|
maxTests?: number;
|
|
37
|
+
maxDurationMinutes?: number;
|
|
38
|
+
hardDeadlineAt?: number;
|
|
34
39
|
dryRun = false;
|
|
35
40
|
private testsRun = 0;
|
|
41
|
+
private deadlineLogged = false;
|
|
36
42
|
private completedPlans: Plan[] = [];
|
|
37
43
|
private failedSubPages = new Set<string>();
|
|
38
44
|
private oldTestRefs = new Set<Test>();
|
|
@@ -48,6 +54,12 @@ export class ExploreCommand extends BaseCommand {
|
|
|
48
54
|
if (opts.maxTests) {
|
|
49
55
|
this.maxTests = Number.parseInt(opts.maxTests as string, 10);
|
|
50
56
|
}
|
|
57
|
+
if (opts.maxDuration) {
|
|
58
|
+
this.maxDurationMinutes = Number.parseInt(opts.maxDuration as string, 10);
|
|
59
|
+
}
|
|
60
|
+
if (this.hardDeadlineAt == null && this.maxDurationMinutes != null) {
|
|
61
|
+
this.hardDeadlineAt = Date.now() + this.maxDurationMinutes * 60_000 - DEADLINE_RESERVE_MS;
|
|
62
|
+
}
|
|
51
63
|
|
|
52
64
|
const feature = (opts.focus as string) || remaining.join(' ') || undefined;
|
|
53
65
|
const cfg = this.parseConfigure(opts.configure as string | undefined);
|
|
@@ -249,9 +261,15 @@ export class ExploreCommand extends BaseCommand {
|
|
|
249
261
|
tag('info').log(`Exploring sub-page: ${pick.url} (${pick.reason})`);
|
|
250
262
|
try {
|
|
251
263
|
await this.explorBot.visit(pick.url);
|
|
264
|
+
const errorPage = getStateErrorPageError(this.explorBot.stateManager().getCurrentState());
|
|
265
|
+
if (errorPage) {
|
|
266
|
+
tag('warning').log(`Skipping sub-page: ${errorPage.message}`);
|
|
267
|
+
this.failedSubPages.add(normalizeUrl(pick.url));
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
252
270
|
await this.runAllStyles(pick.url, undefined, mainPlan, this.completedPlans, styles);
|
|
253
271
|
const subPlan = this.explorBot.getCurrentPlan();
|
|
254
|
-
if (subPlan && !this.completedPlans.includes(subPlan)) {
|
|
272
|
+
if (subPlan?.tests.length && !this.completedPlans.includes(subPlan)) {
|
|
255
273
|
this.completedPlans.push(subPlan);
|
|
256
274
|
}
|
|
257
275
|
knownUrls.add(normalizeUrl(pick.url));
|
|
@@ -286,6 +304,11 @@ export class ExploreCommand extends BaseCommand {
|
|
|
286
304
|
if (fresh && parentPlan) opts.extend = parentPlan;
|
|
287
305
|
if (this.dryRun) opts.noSave = true;
|
|
288
306
|
await this.planWithRetry(feature, opts, pageUrl);
|
|
307
|
+
const plan = this.explorBot.getCurrentPlan();
|
|
308
|
+
if (plan && plan.tests.length === 0) {
|
|
309
|
+
tag('warning').log('Nothing to test on this page, moving on');
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
289
312
|
await this.runPendingTests();
|
|
290
313
|
this.rememberCurrentPlan();
|
|
291
314
|
fresh = false;
|
|
@@ -526,7 +549,18 @@ export class ExploreCommand extends BaseCommand {
|
|
|
526
549
|
}
|
|
527
550
|
|
|
528
551
|
private isLimitReached(): boolean {
|
|
529
|
-
|
|
552
|
+
if (this.maxTests != null && this.testsRun >= this.maxTests) return true;
|
|
553
|
+
return this.isDeadlineReached();
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
private isDeadlineReached(): boolean {
|
|
557
|
+
if (this.hardDeadlineAt == null) return false;
|
|
558
|
+
if (Date.now() < this.hardDeadlineAt - DEADLINE_TEST_ALLOWANCE_MS) return false;
|
|
559
|
+
if (!this.deadlineLogged) {
|
|
560
|
+
this.deadlineLogged = true;
|
|
561
|
+
tag('info').log(`Time budget reached after ${this.testsRun} test(s): stopping new work and finishing the session`);
|
|
562
|
+
}
|
|
563
|
+
return true;
|
|
530
564
|
}
|
|
531
565
|
|
|
532
566
|
private async runPendingTests(): Promise<void> {
|
|
@@ -548,7 +582,7 @@ export class ExploreCommand extends BaseCommand {
|
|
|
548
582
|
test.start();
|
|
549
583
|
test.finish(TestResult.SKIPPED);
|
|
550
584
|
} else {
|
|
551
|
-
await this.explorBot.agentTester().test(test);
|
|
585
|
+
await this.explorBot.agentTester().test(test, { deadline: this.hardDeadlineAt });
|
|
552
586
|
}
|
|
553
587
|
this.testsRun++;
|
|
554
588
|
}
|
|
@@ -4,7 +4,7 @@ import { Stats } from '../stats.js';
|
|
|
4
4
|
import { tag } from '../utils/logger.js';
|
|
5
5
|
import { loop } from '../utils/loop.js';
|
|
6
6
|
import { BaseCommand } from './base-command.js';
|
|
7
|
-
import { ExploreCommand } from './explore-command.js';
|
|
7
|
+
import { DEADLINE_RESERVE_MS, DEADLINE_TEST_ALLOWANCE_MS, ExploreCommand } from './explore-command.js';
|
|
8
8
|
|
|
9
9
|
export class FreesailCommand extends BaseCommand {
|
|
10
10
|
name = 'freesail';
|
|
@@ -16,6 +16,7 @@ export class FreesailCommand extends BaseCommand {
|
|
|
16
16
|
{ flags: '--shallow', description: 'Use shallow navigation strategy' },
|
|
17
17
|
{ flags: '--scope <url>', description: 'Limit navigation to URLs starting with this prefix' },
|
|
18
18
|
{ flags: '--max-tests <number>', description: 'Maximum number of tests to run' },
|
|
19
|
+
{ flags: '--max-duration <number>', description: 'Wall-clock budget in minutes for the whole run' },
|
|
19
20
|
];
|
|
20
21
|
|
|
21
22
|
async execute(args: string): Promise<void> {
|
|
@@ -26,6 +27,9 @@ export class FreesailCommand extends BaseCommand {
|
|
|
26
27
|
if (opts.shallow) strategy = 'shallow';
|
|
27
28
|
const scope = opts.scope as string | undefined;
|
|
28
29
|
const maxTests = opts.maxTests ? Number.parseInt(opts.maxTests as string, 10) : undefined;
|
|
30
|
+
const maxDuration = opts.maxDuration ? Number.parseInt(opts.maxDuration as string, 10) : undefined;
|
|
31
|
+
let hardDeadlineAt: number | undefined;
|
|
32
|
+
if (maxDuration != null) hardDeadlineAt = Date.now() + maxDuration * 60_000 - DEADLINE_RESERVE_MS;
|
|
29
33
|
|
|
30
34
|
await this.explorBot.visitInitialState();
|
|
31
35
|
|
|
@@ -34,6 +38,7 @@ export class FreesailCommand extends BaseCommand {
|
|
|
34
38
|
await loop(
|
|
35
39
|
async (ctx) => {
|
|
36
40
|
if (maxTests != null && testsRun >= maxTests) ctx.stop();
|
|
41
|
+
if (hardDeadlineAt != null && Date.now() >= hardDeadlineAt - DEADLINE_TEST_ALLOWANCE_MS) ctx.stop();
|
|
37
42
|
|
|
38
43
|
const stateManager = this.explorBot.stateManager();
|
|
39
44
|
const state = stateManager.getCurrentState();
|
|
@@ -48,6 +53,7 @@ export class FreesailCommand extends BaseCommand {
|
|
|
48
53
|
} else {
|
|
49
54
|
const exploreCmd = new ExploreCommand(this.explorBot);
|
|
50
55
|
if (maxTests != null) exploreCmd.maxTests = maxTests - testsRun;
|
|
56
|
+
if (hardDeadlineAt != null) exploreCmd.hardDeadlineAt = hardDeadlineAt;
|
|
51
57
|
await exploreCmd.execute('');
|
|
52
58
|
|
|
53
59
|
const plan = this.explorBot.getCurrentPlan();
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
|
|
3
|
+
export abstract class BaseOption {
|
|
4
|
+
abstract flags: string;
|
|
5
|
+
abstract description: string;
|
|
6
|
+
collect?: (value: string, previous: any) => any;
|
|
7
|
+
|
|
8
|
+
register(program: Command): void {
|
|
9
|
+
if (this.collect) program.option(this.flags, this.description, this.collect);
|
|
10
|
+
if (!this.collect) program.option(this.flags, this.description);
|
|
11
|
+
|
|
12
|
+
program.hook('preAction', (_thisCommand, actionCommand) => {
|
|
13
|
+
this.apply(actionCommand.optsWithGlobals(), actionCommand);
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
protected abstract apply(options: Record<string, any>, command: Command): void;
|
|
18
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { KnowledgeTracker } from '../../knowledge-tracker.js';
|
|
2
|
+
import { BaseOption } from './base-option.js';
|
|
3
|
+
|
|
4
|
+
export class KnowledgeOption extends BaseOption {
|
|
5
|
+
flags = '--knowledge <text>';
|
|
6
|
+
description = 'Knowledge for this run only, not saved to disk. Markdown text; add url: or endpoint: frontmatter to scope it, otherwise it applies everywhere. Repeatable';
|
|
7
|
+
collect = (value: string, previous: string[] = []) => [...previous, value];
|
|
8
|
+
|
|
9
|
+
protected apply(options: Record<string, any>): void {
|
|
10
|
+
for (const text of options.knowledge || []) {
|
|
11
|
+
KnowledgeTracker.appendSessionKnowledge(text);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import { remote } from '../../remote.js';
|
|
3
|
+
import { BaseOption } from './base-option.js';
|
|
4
|
+
|
|
5
|
+
export class WsOption extends BaseOption {
|
|
6
|
+
flags = '--ws <url>';
|
|
7
|
+
description = 'Stream this run to a remote UI over WebSocket';
|
|
8
|
+
|
|
9
|
+
protected apply(options: Record<string, any>, command: Command): void {
|
|
10
|
+
const url = options.ws || process.env.EXPLORBOT_WS_URL;
|
|
11
|
+
if (!url) return;
|
|
12
|
+
remote.attach(String(url), commandPath(command));
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function commandPath(command: Command): string {
|
|
17
|
+
const parts: string[] = [];
|
|
18
|
+
let node: Command | null = command;
|
|
19
|
+
while (node) {
|
|
20
|
+
parts.unshift(node.name());
|
|
21
|
+
node = node.parent;
|
|
22
|
+
}
|
|
23
|
+
return parts.slice(1).join(' ') || parts.join(' ');
|
|
24
|
+
}
|
|
@@ -79,15 +79,15 @@ export class PlansCommand extends BaseCommand {
|
|
|
79
79
|
return file;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
const
|
|
83
|
-
if (!
|
|
84
|
-
throw new Error(`Plan file not found: ${
|
|
82
|
+
const plan = Plan.loadFromFile(target, this.explorBot.getPlansDir());
|
|
83
|
+
if (!plan?.filePath) {
|
|
84
|
+
throw new Error(`Plan file not found: ${target}`);
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
return {
|
|
88
|
-
name: path.basename(
|
|
89
|
-
path:
|
|
90
|
-
modifiedAt: statSync(
|
|
88
|
+
name: path.basename(plan.filePath),
|
|
89
|
+
path: plan.filePath,
|
|
90
|
+
modifiedAt: statSync(plan.filePath).mtimeMs,
|
|
91
91
|
};
|
|
92
92
|
}
|
|
93
93
|
}
|
package/src/config.ts
CHANGED
|
@@ -271,8 +271,10 @@ export const EXPLORBOT_ENV_VARS: EnvVar[] = [
|
|
|
271
271
|
{ name: 'EXPLORBOT_EPHEMERAL', description: 'Keep no state between runs — output goes to a fresh temp directory instead of the site dir' },
|
|
272
272
|
{ name: 'EXPLORBOT_KNOWLEDGE', description: 'Inline knowledge text, applied to every page' },
|
|
273
273
|
{ name: 'EXPLORBOT_KNOWLEDGE_FILE', description: 'Path to a knowledge markdown file' },
|
|
274
|
+
{ name: 'EXPLORBOT_SPEC', description: 'Docbot application spec directory or index.md, used as page knowledge' },
|
|
274
275
|
{ name: 'EXPLORBOT_API_SPEC', description: 'OpenAPI spec path for the API boat' },
|
|
275
276
|
{ name: 'EXPLORBOT_NO_BANNER', description: 'Suppress the startup banner, for machine-readable output' },
|
|
277
|
+
{ name: 'EXPLORBOT_MAX_DURATION', description: 'Wall-clock budget in minutes for an explore run; same as --max-duration' },
|
|
276
278
|
];
|
|
277
279
|
|
|
278
280
|
export type {
|
|
@@ -404,6 +406,8 @@ export class ConfigParser {
|
|
|
404
406
|
this.enterGlobalMode(this.config, target);
|
|
405
407
|
}
|
|
406
408
|
|
|
409
|
+
this.applyEnvSpec(this.config);
|
|
410
|
+
|
|
407
411
|
// Restore original directory after successful config load
|
|
408
412
|
if (options?.path && originalCwd !== process.cwd()) {
|
|
409
413
|
process.chdir(originalCwd);
|
|
@@ -554,10 +558,18 @@ export class ConfigParser {
|
|
|
554
558
|
|
|
555
559
|
config.dirs = { knowledge: 'knowledge', experience: 'experience', output: 'output' };
|
|
556
560
|
config.playwright = { ...config.playwright, browser: config.playwright?.browser || 'chromium', url: site.baseUrl };
|
|
561
|
+
materializeKnowledge(this.site.dir);
|
|
557
562
|
|
|
558
563
|
log(`Global mode: ${site.baseUrl} stored in ${this.site.dir}`);
|
|
559
564
|
}
|
|
560
565
|
|
|
566
|
+
private applyEnvSpec(config: ExplorbotConfig): void {
|
|
567
|
+
const spec = process.env.EXPLORBOT_SPEC;
|
|
568
|
+
if (!spec) return;
|
|
569
|
+
if (!config.dirs) config.dirs = { knowledge: 'knowledge', experience: 'experience', output: 'output' };
|
|
570
|
+
config.dirs.spec = spec;
|
|
571
|
+
}
|
|
572
|
+
|
|
561
573
|
private async buildEnvConfig(baseUrl: string | undefined, outputRoot: string): Promise<ExplorbotConfig> {
|
|
562
574
|
const provider = process.env.EXPLORBOT_AI_PROVIDER;
|
|
563
575
|
const modelSpec = process.env.EXPLORBOT_AI_MODEL;
|
|
@@ -123,10 +123,14 @@ export class ExperienceTracker {
|
|
|
123
123
|
const filePath = this.getExperienceFilePath(stateHash);
|
|
124
124
|
|
|
125
125
|
if (!existsSync(filePath)) {
|
|
126
|
-
const frontmatter = {
|
|
126
|
+
const frontmatter: Record<string, unknown> = {
|
|
127
127
|
url: state.url ? extractStatePath(state.url) : '',
|
|
128
128
|
title: state.title,
|
|
129
129
|
};
|
|
130
|
+
if (state.overlay.present && state.overlay.name) {
|
|
131
|
+
frontmatter.region = state.overlay.name;
|
|
132
|
+
if (state.overlay.root) frontmatter.root = state.overlay.root;
|
|
133
|
+
}
|
|
130
134
|
this.writeExperienceFile(stateHash, '', frontmatter);
|
|
131
135
|
}
|
|
132
136
|
|
package/src/explorbot.ts
CHANGED
|
@@ -166,7 +166,7 @@ export class ExplorBot {
|
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
knowledgeTracker(): KnowledgeTracker {
|
|
169
|
-
return (this._knowledgeTracker ||= new KnowledgeTracker(this.options.applicationSpec));
|
|
169
|
+
return (this._knowledgeTracker ||= new KnowledgeTracker({ applicationSpec: this.options.applicationSpec }));
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
experienceTracker(): ExperienceTracker {
|
|
@@ -346,14 +346,22 @@ export class ExplorBot {
|
|
|
346
346
|
}
|
|
347
347
|
};
|
|
348
348
|
|
|
349
|
-
const
|
|
350
|
-
const
|
|
351
|
-
|
|
352
|
-
|
|
349
|
+
const browserHeaderProvider = async (): Promise<Record<string, string>> => {
|
|
350
|
+
const session = await this.explorer
|
|
351
|
+
.withPage(async (page) => ({
|
|
352
|
+
cookies: await page.context().cookies(baseEndpoint),
|
|
353
|
+
csrf: await page.evaluate(() => document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '').catch(() => ''),
|
|
354
|
+
}))
|
|
355
|
+
.catch(() => ({ cookies: [] as any[], csrf: '' }));
|
|
356
|
+
|
|
357
|
+
const headers: Record<string, string> = {};
|
|
358
|
+
if (session.cookies.length) headers.Cookie = session.cookies.map((c: any) => `${c.name}=${c.value}`).join('; ');
|
|
359
|
+
if (session.csrf) headers['x-csrf-token'] = session.csrf;
|
|
360
|
+
return headers;
|
|
353
361
|
};
|
|
354
362
|
|
|
355
363
|
this.agents.fisherman = this.createAgent(({ ai }) => {
|
|
356
|
-
return new Fisherman(ai, apiClient, requestStore, specLoader, baseEndpoint,
|
|
364
|
+
return new Fisherman(ai, apiClient, requestStore, specLoader, baseEndpoint, browserHeaderProvider, configHeaders, hasApiConfig);
|
|
357
365
|
});
|
|
358
366
|
}
|
|
359
367
|
return this.agents.fisherman;
|
|
@@ -449,41 +457,17 @@ export class ExplorBot {
|
|
|
449
457
|
return urlPart + featurePart.slice(0, maxFeatureLen) + suffix;
|
|
450
458
|
}
|
|
451
459
|
|
|
452
|
-
resolvePlanPath(filename: string): string {
|
|
453
|
-
let planPath = filename;
|
|
454
|
-
|
|
455
|
-
if (path.isAbsolute(filename)) {
|
|
456
|
-
if (!existsSync(planPath) && !filename.endsWith('.md')) {
|
|
457
|
-
planPath = `${filename}.md`;
|
|
458
|
-
}
|
|
459
|
-
} else if (existsSync(filename) || existsSync(`${filename}.md`)) {
|
|
460
|
-
planPath = existsSync(filename) ? filename : `${filename}.md`;
|
|
461
|
-
} else {
|
|
462
|
-
const plansDir = this.getPlansDir();
|
|
463
|
-
planPath = path.join(plansDir, filename);
|
|
464
|
-
if (!existsSync(planPath) && !filename.endsWith('.md')) {
|
|
465
|
-
planPath = path.join(plansDir, `${filename}.md`);
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
return planPath;
|
|
470
|
-
}
|
|
471
|
-
|
|
472
460
|
loadPlan(filename: string): Plan {
|
|
473
|
-
const
|
|
474
|
-
if (!
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
this.setCurrentPlan(Plan.fromMarkdown(planPath));
|
|
478
|
-
return this.currentPlan!;
|
|
461
|
+
const plan = Plan.loadFromFile(filename, this.getPlansDir());
|
|
462
|
+
if (!plan) throw new Error(`Plan file not found: ${filename}`);
|
|
463
|
+
this.setCurrentPlan(plan);
|
|
464
|
+
return plan;
|
|
479
465
|
}
|
|
480
466
|
|
|
481
467
|
loadPlans(filename: string): Plan[] {
|
|
482
|
-
const
|
|
483
|
-
if (!
|
|
484
|
-
|
|
485
|
-
}
|
|
486
|
-
return parsePlansFromMarkdown(planPath);
|
|
468
|
+
const plan = Plan.loadFromFile(filename, this.getPlansDir());
|
|
469
|
+
if (!plan?.filePath) throw new Error(`Plan file not found: ${filename}`);
|
|
470
|
+
return parsePlansFromMarkdown(plan.filePath);
|
|
487
471
|
}
|
|
488
472
|
|
|
489
473
|
setCurrentPlan(plan?: Plan): void {
|