explorbot 0.4.1 → 0.4.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/bin/explorbot-cli.ts +14 -4
- package/boat/api-tester/src/ai/curler-tools.ts +1 -1
- package/boat/api-tester/src/cli.ts +3 -0
- package/boat/doc-collector/src/ai/documentarian.ts +57 -5
- package/boat/doc-collector/src/ai/tools.ts +1 -1
- package/boat/doc-collector/src/cli.ts +18 -2
- package/boat/doc-collector/src/config.ts +3 -0
- package/boat/doc-collector/src/docbot.ts +56 -11
- package/boat/doc-collector/src/docs-renderer.ts +79 -55
- package/boat/doc-collector/src/interaction-screenshots.ts +19 -10
- package/boat/doc-collector/src/screenshots.ts +106 -3
- package/boat/doc-collector/src/template-dedup.ts +31 -0
- package/boat/prima/src/cli.ts +3 -0
- package/boat/prima/src/prima.ts +3 -4
- package/dist/bin/explorbot-cli.js +14 -4
- package/dist/boat/api-tester/src/ai/curler-tools.js +1 -1
- package/dist/boat/api-tester/src/cli.js +2 -0
- package/dist/boat/doc-collector/src/ai/documentarian.js +62 -4
- package/dist/boat/doc-collector/src/ai/tools.js +1 -1
- package/dist/boat/doc-collector/src/cli.js +15 -2
- package/dist/boat/doc-collector/src/config.js +1 -0
- package/dist/boat/doc-collector/src/docbot.js +52 -10
- package/dist/boat/doc-collector/src/docs-renderer.js +69 -46
- package/dist/boat/doc-collector/src/interaction-screenshots.js +18 -11
- package/dist/boat/doc-collector/src/screenshots.js +104 -1
- package/dist/boat/doc-collector/src/template-dedup.js +27 -0
- package/dist/boat/prima/src/cli.js +2 -0
- package/dist/boat/prima/src/prima.js +3 -4
- package/dist/package.json +1 -1
- package/dist/src/action-result.d.ts +2 -2
- package/dist/src/action-result.js +15 -17
- package/dist/src/action.d.ts +1 -1
- package/dist/src/action.js +15 -15
- package/dist/src/ai/fisherman/request-haul.d.ts +12 -0
- package/dist/src/ai/fisherman/request-haul.js +31 -0
- package/dist/src/ai/fisherman/tools.d.ts +50 -0
- package/dist/src/ai/{fisherman-tools.js → fisherman/tools.js} +109 -40
- package/dist/src/ai/fisherman.d.ts +14 -4
- package/dist/src/ai/fisherman.js +96 -18
- package/dist/src/ai/pilot.d.ts +13 -1
- package/dist/src/ai/pilot.js +40 -22
- package/dist/src/ai/provider.js +8 -14
- package/dist/src/ai/researcher/coordinates.d.ts +2 -0
- package/dist/src/ai/researcher/coordinates.js +13 -1
- package/dist/src/ai/researcher/deep-analysis.js +9 -9
- package/dist/src/ai/researcher/fingerprint-worker.js +1 -11
- package/dist/src/ai/researcher.js +1 -1
- package/dist/src/ai/rules.js +10 -0
- package/dist/src/ai/tester.js +20 -20
- package/dist/src/ai/tools.d.ts +1 -1
- package/dist/src/ai/tools.js +18 -74
- package/dist/src/api/request-result.js +3 -1
- package/dist/src/api/request-store.d.ts +7 -6
- package/dist/src/api/request-store.js +65 -37
- package/dist/src/api/xhr-capture.d.ts +2 -0
- package/dist/src/api/xhr-capture.js +35 -10
- package/dist/src/commands/config-command.js +2 -9
- package/dist/src/commands/help-json-command.d.ts +31 -0
- package/dist/src/commands/help-json-command.js +58 -0
- package/dist/src/commands/index.js +2 -0
- package/dist/src/commands/init-command.js +3 -7
- package/dist/src/commands/plans-command.d.ts +4 -1
- package/dist/src/commands/plans-command.js +4 -4
- package/dist/src/commands/recommended-models-command.d.ts +9 -0
- package/dist/src/commands/recommended-models-command.js +85 -0
- package/dist/src/config.d.ts +2 -0
- package/dist/src/config.js +7 -2
- package/dist/src/experience-tracker.js +1 -1
- package/dist/src/explorbot.js +3 -3
- package/dist/src/global-config.d.ts +2 -0
- package/dist/src/global-config.js +6 -0
- package/dist/src/state-manager.d.ts +3 -3
- package/dist/src/state-manager.js +8 -8
- package/dist/src/test-plan.d.ts +1 -0
- package/dist/src/test-plan.js +13 -9
- package/dist/src/utils/aria.d.ts +1 -1
- package/dist/src/utils/aria.js +23 -2
- package/dist/src/utils/cli-section.d.ts +1 -0
- package/dist/src/utils/cli-section.js +12 -0
- package/dist/src/utils/html-diff.d.ts +1 -0
- package/dist/src/utils/html-diff.js +5 -5
- package/dist/src/utils/html.d.ts +2 -0
- package/dist/src/utils/html.js +5 -4
- package/dist/src/utils/overlay.d.ts +19 -55
- package/dist/src/utils/overlay.js +94 -148
- package/dist/src/utils/region.d.ts +41 -0
- package/dist/src/utils/region.js +99 -0
- package/dist/src/utils/similarity.d.ts +1 -0
- package/dist/src/utils/similarity.js +11 -0
- package/dist/src/utils/url-matcher.d.ts +1 -1
- package/dist/src/utils/url-matcher.js +4 -4
- package/docs/doc-collection/basics.md +11 -5
- package/docs/doc-collection/crawling.md +21 -1
- package/docs/reference/commands.md +4 -0
- package/docs/reference/configuration.md +4 -0
- package/docs/superpowers/plans/2026-09-03-fisherman-query-api.md +1361 -0
- package/docs/superpowers/plans/2026-09-03-model-resolves-click-ambiguity.md +509 -0
- package/docs/workflow/agentic-usage.md +12 -0
- package/docs/workflow/application-spec.md +4 -0
- package/package.json +1 -1
- package/src/action-result.ts +15 -16
- package/src/action.ts +16 -15
- package/src/ai/fisherman/request-haul.ts +36 -0
- package/src/ai/{fisherman-tools.ts → fisherman/tools.ts} +125 -46
- package/src/ai/fisherman.ts +110 -20
- package/src/ai/pilot.ts +38 -20
- package/src/ai/provider.ts +8 -12
- package/src/ai/researcher/coordinates.ts +13 -1
- package/src/ai/researcher/deep-analysis.ts +9 -9
- package/src/ai/researcher/fingerprint-worker.ts +1 -10
- package/src/ai/researcher.ts +1 -1
- package/src/ai/rules.ts +10 -0
- package/src/ai/tester.ts +18 -18
- package/src/ai/tools.ts +18 -86
- package/src/api/request-result.ts +2 -1
- package/src/api/request-store.ts +68 -42
- package/src/api/xhr-capture.ts +39 -11
- package/src/commands/config-command.ts +2 -7
- package/src/commands/help-json-command.ts +74 -0
- package/src/commands/index.ts +2 -0
- package/src/commands/init-command.ts +4 -11
- package/src/commands/plans-command.ts +5 -5
- package/src/commands/recommended-models-command.ts +92 -0
- package/src/config.ts +9 -2
- package/src/experience-tracker.ts +1 -1
- package/src/explorbot.ts +3 -3
- package/src/global-config.ts +8 -0
- package/src/state-manager.ts +10 -9
- package/src/test-plan.ts +12 -10
- package/src/utils/aria.ts +26 -3
- package/src/utils/cli-section.ts +12 -0
- package/src/utils/html-diff.ts +6 -6
- package/src/utils/html.ts +6 -4
- package/src/utils/overlay.ts +105 -192
- package/src/utils/region.ts +132 -0
- package/src/utils/similarity.ts +9 -0
- package/src/utils/url-matcher.ts +3 -3
- package/dist/src/ai/fisherman-tools.d.ts +0 -138
- package/dist/src/utils/request-map.d.ts +0 -7
- package/dist/src/utils/request-map.js +0 -16
- package/src/utils/request-map.ts +0 -19
package/src/api/request-store.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readdirSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { isDynamicSegment } from '../utils/url-matcher.ts';
|
|
3
|
+
import { generalizeUrl, isDynamicSegment } from '../utils/url-matcher.ts';
|
|
4
4
|
import { RequestResult } from './request-result.ts';
|
|
5
5
|
|
|
6
6
|
const AUTH_HEADERS = ['authorization', 'x-api-key', 'x-csrf-token'];
|
|
@@ -12,6 +12,7 @@ export class RequestStore {
|
|
|
12
12
|
private onFailedListeners: Array<(r: RequestResult) => void> = [];
|
|
13
13
|
private outputDir: string;
|
|
14
14
|
private sessionStartedAt = new Date();
|
|
15
|
+
private readEndpointKeys = new Set<string>();
|
|
15
16
|
|
|
16
17
|
constructor(outputDir: string) {
|
|
17
18
|
this.outputDir = outputDir;
|
|
@@ -22,6 +23,14 @@ export class RequestStore {
|
|
|
22
23
|
result.save(this.outputDir);
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
addReadRequest(result: RequestResult): void {
|
|
27
|
+
const key = readEndpointKey(result);
|
|
28
|
+
if (this.readEndpointKeys.has(key)) return;
|
|
29
|
+
this.readEndpointKeys.add(key);
|
|
30
|
+
this.capturedRequests.push(result);
|
|
31
|
+
result.save(this.outputDir);
|
|
32
|
+
}
|
|
33
|
+
|
|
25
34
|
addFailedRequest(result: RequestResult): void {
|
|
26
35
|
this.failedRequests.push(result);
|
|
27
36
|
for (const cb of this.onFailedListeners) {
|
|
@@ -46,10 +55,6 @@ export class RequestStore {
|
|
|
46
55
|
result.save(this.outputDir);
|
|
47
56
|
}
|
|
48
57
|
|
|
49
|
-
addRequest(result: RequestResult): void {
|
|
50
|
-
this.addMadeRequest(result);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
58
|
getCapturedRequests(): RequestResult[] {
|
|
54
59
|
return this.capturedRequests;
|
|
55
60
|
}
|
|
@@ -58,36 +63,19 @@ export class RequestStore {
|
|
|
58
63
|
return this.madeRequests;
|
|
59
64
|
}
|
|
60
65
|
|
|
61
|
-
getRequests(): RequestResult[] {
|
|
62
|
-
return this.madeRequests;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
66
|
getLastRequest(): RequestResult | undefined {
|
|
66
67
|
return this.madeRequests[this.madeRequests.length - 1];
|
|
67
68
|
}
|
|
68
69
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
getRequestsByMethod(method: string): RequestResult[] {
|
|
74
|
-
const upper = method.toUpperCase();
|
|
75
|
-
return this.madeRequests.filter((r) => r.method === upper);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
getRequestsByStatus(status: number): RequestResult[] {
|
|
79
|
-
return this.madeRequests.filter((r) => r.status === status);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
toEndpointList(scopePath?: string): string {
|
|
83
|
-
let requests = this.capturedRequests;
|
|
84
|
-
if (scopePath) requests = this.getWriteRequestsForScope(scopePath);
|
|
70
|
+
toEndpointList(scopePath?: string, methods: EndpointFamily = 'write'): string {
|
|
71
|
+
let requests = this.capturedRequests.filter((r) => matchesFamily(r, methods));
|
|
72
|
+
if (scopePath) requests = this.getRequestsForScope(scopePath, methods);
|
|
85
73
|
|
|
86
74
|
const seen = new Set<string>();
|
|
87
75
|
const lines: string[] = [];
|
|
88
76
|
|
|
89
77
|
for (const req of requests) {
|
|
90
|
-
const key = `${req.method} ${
|
|
78
|
+
const key = `${req.method} ${generalizeUrl(req.path, () => '{id}')}${queryParamHint(req)}`;
|
|
91
79
|
if (seen.has(key)) continue;
|
|
92
80
|
seen.add(key);
|
|
93
81
|
lines.push(key);
|
|
@@ -113,14 +101,18 @@ export class RequestStore {
|
|
|
113
101
|
|
|
114
102
|
findCapturedRequest(method: string, searchPath: string): RequestResult | undefined {
|
|
115
103
|
const upper = method.toUpperCase();
|
|
116
|
-
const search =
|
|
104
|
+
const search = generalizeUrl(searchPath, () => '{id}')
|
|
105
|
+
.split('/')
|
|
106
|
+
.filter(Boolean);
|
|
117
107
|
|
|
118
108
|
let best: RequestResult | undefined;
|
|
119
109
|
let bestScore = -1;
|
|
120
110
|
|
|
121
111
|
for (const req of this.capturedRequests) {
|
|
122
112
|
if (req.method !== upper) continue;
|
|
123
|
-
const segments =
|
|
113
|
+
const segments = generalizeUrl(req.path, () => '{id}')
|
|
114
|
+
.split('/')
|
|
115
|
+
.filter(Boolean);
|
|
124
116
|
if (segments.length < search.length) continue;
|
|
125
117
|
if (!search.every((segment, i) => segment === segments[i])) continue;
|
|
126
118
|
|
|
@@ -151,6 +143,11 @@ export class RequestStore {
|
|
|
151
143
|
try {
|
|
152
144
|
const result = RequestResult.load(path.join(requestsDir, file));
|
|
153
145
|
if (existingIds.has(result.id)) continue;
|
|
146
|
+
if (!result.isWrite) {
|
|
147
|
+
const key = readEndpointKey(result);
|
|
148
|
+
if (this.readEndpointKeys.has(key)) continue;
|
|
149
|
+
this.readEndpointKeys.add(key);
|
|
150
|
+
}
|
|
154
151
|
this.capturedRequests.push(result);
|
|
155
152
|
} catch {
|
|
156
153
|
// skip invalid files
|
|
@@ -159,17 +156,31 @@ export class RequestStore {
|
|
|
159
156
|
}
|
|
160
157
|
|
|
161
158
|
getWriteRequestsForScope(scopePath: string): RequestResult[] {
|
|
162
|
-
|
|
163
|
-
|
|
159
|
+
return this.getRequestsForScope(scopePath, 'write');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
getReadRequestsForScope(scopePath: string): RequestResult[] {
|
|
163
|
+
return this.getRequestsForScope(scopePath, 'read');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
clear(): void {
|
|
167
|
+
this.capturedRequests = [];
|
|
168
|
+
this.madeRequests = [];
|
|
169
|
+
this.failedRequests = [];
|
|
170
|
+
this.readEndpointKeys.clear();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private getRequestsForScope(scopePath: string, methods: EndpointFamily): RequestResult[] {
|
|
174
|
+
const candidates = this.capturedRequests.filter((r) => matchesFamily(r, methods));
|
|
164
175
|
const scopeSegments = scopePath.split('/').filter(Boolean);
|
|
165
|
-
if (scopeSegments.length === 0) return
|
|
176
|
+
if (scopeSegments.length === 0) return candidates;
|
|
166
177
|
|
|
167
178
|
let scoped: RequestResult[] = [];
|
|
168
179
|
let fewest = Number.POSITIVE_INFINITY;
|
|
169
180
|
let ambiguous = false;
|
|
170
181
|
for (const segment of scopeSegments) {
|
|
171
182
|
if (isDynamicSegment(segment)) continue;
|
|
172
|
-
const matches =
|
|
183
|
+
const matches = candidates.filter((r) => r.path.split('/').includes(segment));
|
|
173
184
|
if (matches.length === 0 || matches.length > fewest) continue;
|
|
174
185
|
if (matches.length === fewest) {
|
|
175
186
|
if (!scoped.every((r, i) => r.id === matches[i].id)) ambiguous = true;
|
|
@@ -183,17 +194,32 @@ export class RequestStore {
|
|
|
183
194
|
|
|
184
195
|
return scoped;
|
|
185
196
|
}
|
|
197
|
+
}
|
|
186
198
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
199
|
+
export function isFailedRequest(request: RequestResult): boolean {
|
|
200
|
+
return request.status >= 400 || Boolean(request.error);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function readEndpointKey(result: RequestResult): string {
|
|
204
|
+
return `${result.method} ${generalizeUrl(result.path, () => '{id}')}?${queryParamNames(result).join(',')}`;
|
|
192
205
|
}
|
|
193
206
|
|
|
194
|
-
function
|
|
195
|
-
return
|
|
196
|
-
|
|
197
|
-
.map((segment) => (segment && isDynamicSegment(segment) ? '{id}' : segment))
|
|
198
|
-
.join('/');
|
|
207
|
+
function matchesFamily(result: RequestResult, methods: EndpointFamily): boolean {
|
|
208
|
+
if (methods === 'write') return result.isWrite;
|
|
209
|
+
return result.method === 'GET';
|
|
199
210
|
}
|
|
211
|
+
|
|
212
|
+
function queryParamHint(result: RequestResult): string {
|
|
213
|
+
if (result.isWrite) return '';
|
|
214
|
+
const names = queryParamNames(result);
|
|
215
|
+
if (names.length === 0) return '';
|
|
216
|
+
return ` ?${names.join(',')}`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function queryParamNames(result: RequestResult): string[] {
|
|
220
|
+
const query = result.fullUrl.split('?')[1];
|
|
221
|
+
if (!query) return [];
|
|
222
|
+
return [...new Set(new URLSearchParams(query).keys())].sort();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export type EndpointFamily = 'read' | 'write';
|
package/src/api/xhr-capture.ts
CHANGED
|
@@ -60,26 +60,25 @@ export class XhrCapture {
|
|
|
60
60
|
this.store.addFailedRequest(failure);
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
if (!WRITE_METHODS.has(method)) return;
|
|
64
|
-
|
|
65
63
|
const contentType = response.headers()['content-type'] || '';
|
|
66
64
|
if (!JSON_CONTENT_TYPES.test(contentType)) return;
|
|
67
65
|
|
|
66
|
+
if (method === 'GET') {
|
|
67
|
+
if (status !== 200) return;
|
|
68
|
+
this.captureReadEndpoint(request, response);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!WRITE_METHODS.has(method)) return;
|
|
73
|
+
|
|
68
74
|
if (status === 304) return;
|
|
69
75
|
|
|
70
76
|
const parsedUrl = new URL(url);
|
|
71
77
|
const origin = parsedUrl.pathname + parsedUrl.search;
|
|
72
78
|
const id = generateRequestId(method, parsedUrl.pathname, 'xhr_');
|
|
73
79
|
|
|
74
|
-
const requestHeaders
|
|
75
|
-
|
|
76
|
-
requestHeaders[k] = String(v);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const responseHeaders: Record<string, string> = {};
|
|
80
|
-
for (const [k, v] of Object.entries(response.headers())) {
|
|
81
|
-
responseHeaders[k] = String(v);
|
|
82
|
-
}
|
|
80
|
+
const requestHeaders = this.toHeaderMap(request.headers());
|
|
81
|
+
const responseHeaders = this.toHeaderMap(response.headers());
|
|
83
82
|
|
|
84
83
|
let rawBody = '';
|
|
85
84
|
try {
|
|
@@ -115,4 +114,33 @@ export class XhrCapture {
|
|
|
115
114
|
|
|
116
115
|
this.store.addCapturedRequest(result);
|
|
117
116
|
}
|
|
117
|
+
|
|
118
|
+
private captureReadEndpoint(request: any, response: any): void {
|
|
119
|
+
const parsedUrl = new URL(request.url());
|
|
120
|
+
const requestHeaders = this.toHeaderMap(request.headers());
|
|
121
|
+
|
|
122
|
+
const result = new RequestResult({
|
|
123
|
+
id: generateRequestId('GET', parsedUrl.pathname, 'xhr_'),
|
|
124
|
+
method: 'GET',
|
|
125
|
+
path: parsedUrl.pathname,
|
|
126
|
+
fullUrl: parsedUrl.pathname + parsedUrl.search,
|
|
127
|
+
requestHeaders,
|
|
128
|
+
status: response.status(),
|
|
129
|
+
statusText: response.statusText(),
|
|
130
|
+
responseHeaders: {},
|
|
131
|
+
timing: 0,
|
|
132
|
+
timestamp: new Date(),
|
|
133
|
+
});
|
|
134
|
+
result.rawResponseBodyValue = '';
|
|
135
|
+
|
|
136
|
+
this.store.addReadRequest(result);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private toHeaderMap(headers: Record<string, unknown>): Record<string, string> {
|
|
140
|
+
const map: Record<string, string> = {};
|
|
141
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
142
|
+
map[k] = String(v);
|
|
143
|
+
}
|
|
144
|
+
return map;
|
|
145
|
+
}
|
|
118
146
|
}
|
|
@@ -5,6 +5,7 @@ import { type AIConfig, ConfigParser, EXPLORBOT_ENV_VARS, type ReporterConfig, c
|
|
|
5
5
|
import { listSites } from '../global-config.js';
|
|
6
6
|
import { Reporter } from '../reporter.js';
|
|
7
7
|
import { getCliName } from '../utils/cli-name.js';
|
|
8
|
+
import { renderSection } from '../utils/cli-section.js';
|
|
8
9
|
import { tag } from '../utils/logger.js';
|
|
9
10
|
import { BaseCommand } from './base-command.js';
|
|
10
11
|
|
|
@@ -75,13 +76,7 @@ export class ConfigCommand extends BaseCommand {
|
|
|
75
76
|
if (options.json) return JSON.stringify(data, null, 2);
|
|
76
77
|
|
|
77
78
|
const lines: string[] = [];
|
|
78
|
-
const section = (title: string, entries: [string, string][]) =>
|
|
79
|
-
if (!entries.length) return;
|
|
80
|
-
const width = Math.max(...entries.map(([label]) => label.length));
|
|
81
|
-
lines.push(chalk.bold(title));
|
|
82
|
-
for (const [label, value] of entries) lines.push(` ${chalk.dim(label.padEnd(width))} ${value}`);
|
|
83
|
-
lines.push('');
|
|
84
|
-
};
|
|
79
|
+
const section = (title: string, entries: [string, string][]) => lines.push(...renderSection(title, entries));
|
|
85
80
|
|
|
86
81
|
const general: [string, string][] = [['config', data.config || 'EXPLORBOT_* environment variables']];
|
|
87
82
|
if (data.url) general.push(['url', data.url]);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import { EXPLORBOT_ENV_VARS } from '../config.js';
|
|
3
|
+
|
|
4
|
+
const DESCRIPTION = 'Print command definitions as JSON for agents and tools';
|
|
5
|
+
|
|
6
|
+
export class HelpJsonCommand {
|
|
7
|
+
static register(program: Command): void {
|
|
8
|
+
program
|
|
9
|
+
.command('help-json [command...]')
|
|
10
|
+
.description(DESCRIPTION)
|
|
11
|
+
.action(async (names: string[]) => {
|
|
12
|
+
const target = HelpJsonCommand.find(program, names);
|
|
13
|
+
if (!target) {
|
|
14
|
+
console.error(`Unknown command: ${names.join(' ')}`);
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
const json = JSON.stringify(HelpJsonCommand.data(target, target === program), null, 2);
|
|
18
|
+
await new Promise<void>((resolve) => process.stdout.write(`${json}\n`, () => resolve()));
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
static data(cmd: Command, root = false): CommandDefinition {
|
|
23
|
+
const helper = cmd.createHelp();
|
|
24
|
+
const definition: CommandDefinition = {
|
|
25
|
+
name: cmd.name(),
|
|
26
|
+
description: cmd.description(),
|
|
27
|
+
aliases: cmd.aliases(),
|
|
28
|
+
arguments: cmd.registeredArguments.map((argument) => ({
|
|
29
|
+
name: argument.name(),
|
|
30
|
+
description: argument.description,
|
|
31
|
+
required: argument.required,
|
|
32
|
+
variadic: argument.variadic,
|
|
33
|
+
})),
|
|
34
|
+
options: helper.visibleOptions(cmd).map((option) => ({
|
|
35
|
+
flags: option.flags,
|
|
36
|
+
description: option.description,
|
|
37
|
+
default: option.defaultValue,
|
|
38
|
+
choices: option.argChoices,
|
|
39
|
+
})),
|
|
40
|
+
commands: helper.visibleCommands(cmd).map((sub) => HelpJsonCommand.data(sub)),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
if (!root) return definition;
|
|
44
|
+
|
|
45
|
+
definition.version = cmd.version();
|
|
46
|
+
definition.env = EXPLORBOT_ENV_VARS.map((variable) => ({
|
|
47
|
+
name: variable.name,
|
|
48
|
+
description: variable.description,
|
|
49
|
+
required: !!variable.required,
|
|
50
|
+
}));
|
|
51
|
+
return definition;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private static find(cmd: Command, names: string[]): Command | undefined {
|
|
55
|
+
let target = cmd;
|
|
56
|
+
for (const name of names) {
|
|
57
|
+
const sub = target.commands.find((candidate) => candidate.name() === name || candidate.aliases().includes(name));
|
|
58
|
+
if (!sub) return undefined;
|
|
59
|
+
target = sub;
|
|
60
|
+
}
|
|
61
|
+
return target;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface CommandDefinition {
|
|
66
|
+
name: string;
|
|
67
|
+
description: string;
|
|
68
|
+
aliases: string[];
|
|
69
|
+
version?: string;
|
|
70
|
+
arguments: { name: string; description: string; required: boolean; variadic: boolean }[];
|
|
71
|
+
options: { flags: string; description: string; default?: unknown; choices?: string[] }[];
|
|
72
|
+
commands: CommandDefinition[];
|
|
73
|
+
env?: { name: string; description: string; required: boolean }[];
|
|
74
|
+
}
|
package/src/commands/index.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { PlanLoadCommand } from './plan-load-command.js';
|
|
|
28
28
|
import { PlanReloadCommand } from './plan-reload-command.js';
|
|
29
29
|
import { PlanSaveCommand } from './plan-save-command.js';
|
|
30
30
|
import { PlansCommand } from './plans-command.js';
|
|
31
|
+
import { RecommendedModelsCommand } from './recommended-models-command.js';
|
|
31
32
|
import { RerunCommand } from './rerun-command.js';
|
|
32
33
|
import { ResearchCommand } from './research-command.js';
|
|
33
34
|
import { RunsCommand } from './runs-command.js';
|
|
@@ -72,6 +73,7 @@ const commandClasses: CommandClass[] = [
|
|
|
72
73
|
RerunCommand,
|
|
73
74
|
StatusCommand,
|
|
74
75
|
ConfigCommand,
|
|
76
|
+
RecommendedModelsCommand,
|
|
75
77
|
DebugCommand,
|
|
76
78
|
ExitCommand,
|
|
77
79
|
];
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, extname, join, resolve } from 'node:path';
|
|
3
3
|
import chalk from 'chalk';
|
|
4
|
-
import { ConfigParser, PROVIDERS } from '../config.ts';
|
|
4
|
+
import { ConfigParser, type ModelRole, PROVIDERS, missingModelRoles } from '../config.ts';
|
|
5
5
|
import { findGlobalConfig, globalConfigPath, globalDir, globalEnvPath } from '../global-config.ts';
|
|
6
6
|
import { getCliName } from '../utils/cli-name.ts';
|
|
7
7
|
import { log, tag } from '../utils/logger.js';
|
|
@@ -91,7 +91,7 @@ export function writeGlobalConfig(provider: string, apiKey?: string): void {
|
|
|
91
91
|
writeEnvKey(envKey, apiKey || '');
|
|
92
92
|
log(`Stored ${envKey} in ${globalEnvPath()}`);
|
|
93
93
|
|
|
94
|
-
const missing =
|
|
94
|
+
const missing = missingModelRoles(provider);
|
|
95
95
|
if (missing.length) {
|
|
96
96
|
tag('warning').log(`No recommended ${missing.join(' and ')} for ${provider} — set the model ids in ${globalConfigPath()}`);
|
|
97
97
|
}
|
|
@@ -156,7 +156,7 @@ export function runInitCommand(options: InitCommandOptions): void {
|
|
|
156
156
|
log(`Env file already exists: ${relativeToCwd(envPath)}`);
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
const missing =
|
|
159
|
+
const missing = missingModelRoles(provider);
|
|
160
160
|
if (missing.length) {
|
|
161
161
|
tag('warning').log(`No recommended ${missing.join(' and ')} for ${provider} — set the model ids in ${relativeToCwd(outPath)}`);
|
|
162
162
|
}
|
|
@@ -255,7 +255,7 @@ async function renderLocalProviderWizard(): Promise<string | null> {
|
|
|
255
255
|
|
|
256
256
|
function modelLines(provider: string): string {
|
|
257
257
|
const recommended = ConfigParser.recommendedModels()[provider] || {};
|
|
258
|
-
const roles: Array<[
|
|
258
|
+
const roles: Array<[ModelRole, string]> = [
|
|
259
259
|
['model', 'fast model with tool calling capabilities'],
|
|
260
260
|
['visionModel', 'vision model for screenshot analysis'],
|
|
261
261
|
['agenticModel', 'agentic model for decision making'],
|
|
@@ -308,11 +308,6 @@ function isModuleProject(configDir: string): boolean {
|
|
|
308
308
|
}
|
|
309
309
|
}
|
|
310
310
|
|
|
311
|
-
function missingRoles(provider: string): string[] {
|
|
312
|
-
const recommended = ConfigParser.recommendedModels()[provider] || {};
|
|
313
|
-
return ['model', 'visionModel', 'agenticModel'].filter((role) => !recommended[role]);
|
|
314
|
-
}
|
|
315
|
-
|
|
316
311
|
function writeEnvKey(key: string, value: string): void {
|
|
317
312
|
const envPath = globalEnvPath();
|
|
318
313
|
let content = '# AI provider API keys';
|
|
@@ -327,8 +322,6 @@ function writeEnvKey(key: string, value: string): void {
|
|
|
327
322
|
writeFileSync(envPath, `${lines.join('\n').trimEnd()}\n`, 'utf8');
|
|
328
323
|
}
|
|
329
324
|
|
|
330
|
-
type ModelRoleName = 'model' | 'visionModel' | 'agenticModel';
|
|
331
|
-
|
|
332
325
|
type InitCommandOptions = {
|
|
333
326
|
configPath?: string;
|
|
334
327
|
force?: boolean;
|
|
@@ -21,8 +21,7 @@ export class PlansCommand extends BaseCommand {
|
|
|
21
21
|
return;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
const file = this.resolvePlanFile(target, files);
|
|
25
|
-
const plan = Plan.fromMarkdown(file.path);
|
|
24
|
+
const { plan, file } = this.resolvePlanFile(target, files);
|
|
26
25
|
this.printPlanDetails(plan, file);
|
|
27
26
|
}
|
|
28
27
|
|
|
@@ -71,12 +70,12 @@ export class PlansCommand extends BaseCommand {
|
|
|
71
70
|
tag('info').log(`${getCliName()} test 1 --from-plan ${file.name}`);
|
|
72
71
|
}
|
|
73
72
|
|
|
74
|
-
private resolvePlanFile(target: string, files: PlanFile[]): PlanFile {
|
|
73
|
+
private resolvePlanFile(target: string, files: PlanFile[]): { plan: Plan; file: PlanFile } {
|
|
75
74
|
const index = Number.parseInt(target, 10);
|
|
76
75
|
if (!Number.isNaN(index) && String(index) === target) {
|
|
77
76
|
const file = files[index - 1];
|
|
78
77
|
if (!file) throw new Error(`Plan #${target} not found. Available: 1-${files.length}`);
|
|
79
|
-
return file;
|
|
78
|
+
return { plan: Plan.fromMarkdown(file.path), file };
|
|
80
79
|
}
|
|
81
80
|
|
|
82
81
|
const plan = Plan.loadFromFile(target, this.explorBot.getPlansDir());
|
|
@@ -84,11 +83,12 @@ export class PlansCommand extends BaseCommand {
|
|
|
84
83
|
throw new Error(`Plan file not found: ${target}`);
|
|
85
84
|
}
|
|
86
85
|
|
|
87
|
-
|
|
86
|
+
const file = {
|
|
88
87
|
name: path.basename(plan.filePath),
|
|
89
88
|
path: plan.filePath,
|
|
90
89
|
modifiedAt: statSync(plan.filePath).mtimeMs,
|
|
91
90
|
};
|
|
91
|
+
return { plan, file };
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import type { Command } from 'commander';
|
|
3
|
+
import { ConfigParser, EXPLORBOT_ENV_VARS, MODEL_ROLES, PROVIDERS } from '../config.js';
|
|
4
|
+
import { getCliName } from '../utils/cli-name.js';
|
|
5
|
+
import { renderSection } from '../utils/cli-section.js';
|
|
6
|
+
import { tag } from '../utils/logger.js';
|
|
7
|
+
import { BaseCommand } from './base-command.js';
|
|
8
|
+
|
|
9
|
+
const DESCRIPTION = 'Show the models recommended for every AI provider';
|
|
10
|
+
|
|
11
|
+
const ROLES: Record<string, { hint: string; env: string }> = {
|
|
12
|
+
model: { hint: 'fast', env: 'EXPLORBOT_AI_MODEL' },
|
|
13
|
+
visionModel: { hint: 'screenshots', env: 'EXPLORBOT_VISION_MODEL' },
|
|
14
|
+
agenticModel: { hint: 'smart', env: 'EXPLORBOT_AGENTIC_MODEL' },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const MODEL_ENV_VARS = MODEL_ROLES.map((role) => ROLES[role].env);
|
|
18
|
+
|
|
19
|
+
export class RecommendedModelsCommand extends BaseCommand {
|
|
20
|
+
name = 'recommended-models';
|
|
21
|
+
description = DESCRIPTION;
|
|
22
|
+
|
|
23
|
+
async execute(): Promise<void> {
|
|
24
|
+
tag('info').log(RecommendedModelsCommand.render());
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
static register(program: Command): void {
|
|
28
|
+
program
|
|
29
|
+
.command('recommended-models')
|
|
30
|
+
.description(DESCRIPTION)
|
|
31
|
+
.option('--json', 'Print the recommendations as JSON')
|
|
32
|
+
.action((options) => console.log(RecommendedModelsCommand.render(options.json)));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
static render(json = false): string {
|
|
36
|
+
const recommended = ConfigParser.recommendedModels();
|
|
37
|
+
if (json) return JSON.stringify(recommended, null, 2);
|
|
38
|
+
|
|
39
|
+
const lines = [
|
|
40
|
+
chalk.bold('Export the provider key, then pick one of two ways to select models:'),
|
|
41
|
+
` ${chalk.bold('EXPLORBOT_AI_PROVIDER')} — every role takes that provider's recommendation below`,
|
|
42
|
+
` ${chalk.bold(MODEL_ENV_VARS.join(' + '))} — no provider variable, pin every role yourself as ${chalk.dim('provider/model-id')}`,
|
|
43
|
+
'',
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
for (const [provider, roles] of Object.entries(recommended)) {
|
|
47
|
+
lines.push(chalk.bold.yellow(`${provider}:`));
|
|
48
|
+
|
|
49
|
+
const models: [string, string][] = [];
|
|
50
|
+
for (const role of MODEL_ROLES) {
|
|
51
|
+
const label = `${role} (${ROLES[role].hint})`;
|
|
52
|
+
if (roles[role]) models.push([label, roles[role]]);
|
|
53
|
+
if (!roles[role]) models.push([label, chalk.dim('not served, pair with another provider')]);
|
|
54
|
+
}
|
|
55
|
+
lines.push(...renderSection('models', models, 2));
|
|
56
|
+
|
|
57
|
+
const envKey = PROVIDERS[provider]?.envKey;
|
|
58
|
+
if (!envKey) {
|
|
59
|
+
lines.push(` ${chalk.dim('not bundled — import the provider in your config')}`, '');
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const env: [string, string][] = [
|
|
64
|
+
['key', envKey],
|
|
65
|
+
['provider', `EXPLORBOT_AI_PROVIDER=${provider}`],
|
|
66
|
+
];
|
|
67
|
+
let label = 'or models';
|
|
68
|
+
for (const role of MODEL_ROLES) {
|
|
69
|
+
if (!roles[role]) continue;
|
|
70
|
+
env.push([label, `${ROLES[role].env}=${provider}/${roles[role]}`]);
|
|
71
|
+
label = '';
|
|
72
|
+
}
|
|
73
|
+
lines.push(...renderSection('env variables', env, 2));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const modelVars = ['EXPLORBOT_AI_PROVIDER', ...MODEL_ENV_VARS];
|
|
77
|
+
const current: [string, string][] = [];
|
|
78
|
+
for (const variable of EXPLORBOT_ENV_VARS) {
|
|
79
|
+
if (!modelVars.includes(variable.name)) continue;
|
|
80
|
+
if (process.env[variable.name]) current.push([variable.name, process.env[variable.name]!]);
|
|
81
|
+
}
|
|
82
|
+
for (const [provider, { envKey }] of Object.entries(PROVIDERS)) {
|
|
83
|
+
if (process.env[envKey]) current.push([envKey, chalk.dim(`set, ${provider} usable`)]);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!current.length) lines.push(chalk.dim('Nothing set — export one of the variables above, or write models into your config'), '');
|
|
87
|
+
if (current.length) lines.push(...renderSection('Currently set', current));
|
|
88
|
+
|
|
89
|
+
lines.push(chalk.bold('Example using OpenRouter:'), ` OPENROUTER_API_KEY=sk-... EXPLORBOT_AI_PROVIDER=openrouter ${getCliName()} <command>`);
|
|
90
|
+
return lines.join('\n');
|
|
91
|
+
}
|
|
92
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -19,6 +19,8 @@ export const PROVIDERS: Record<string, ProviderInfo> = {
|
|
|
19
19
|
sambanova: { envKey: 'SAMBANOVA_API_KEY', load: async () => (await import('sambanova-ai-provider')).createSambaNova() },
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
+
export const MODEL_ROLES: ModelRole[] = ['model', 'visionModel', 'agenticModel'];
|
|
23
|
+
|
|
22
24
|
let cachedOutputRoot: string | null = null;
|
|
23
25
|
|
|
24
26
|
interface PlaywrightConfig {
|
|
@@ -765,6 +767,11 @@ export async function resolveModel(spec: string, role: ModelRole = 'model'): Pro
|
|
|
765
767
|
return createModel(spec, modelId);
|
|
766
768
|
}
|
|
767
769
|
|
|
770
|
+
export function missingModelRoles(provider: string): ModelRole[] {
|
|
771
|
+
const recommended = ConfigParser.recommendedModels()[provider] || {};
|
|
772
|
+
return MODEL_ROLES.filter((role) => !recommended[role]);
|
|
773
|
+
}
|
|
774
|
+
|
|
768
775
|
export class ConfigMissingError extends Error {}
|
|
769
776
|
|
|
770
777
|
export function envConfigRequested(): boolean {
|
|
@@ -786,6 +793,7 @@ export function missingConfigMessage(configFile = 'explorbot.config.js'): string
|
|
|
786
793
|
EXPLORBOT_AI_PROVIDER=openrouter EXPLORBOT_URL=https://your-app.example.com ${cli} ...
|
|
787
794
|
|
|
788
795
|
Providers: ${Object.keys(PROVIDERS).join(', ')}
|
|
796
|
+
See the models each one recommends: ${cli} recommended-models
|
|
789
797
|
`;
|
|
790
798
|
}
|
|
791
799
|
|
|
@@ -818,8 +826,7 @@ export function configuredModels(ai?: AIConfig): Record<string, ConfiguredModel>
|
|
|
818
826
|
export async function resolveConfigModels(ai?: AIConfig): Promise<void> {
|
|
819
827
|
if (!ai) return;
|
|
820
828
|
|
|
821
|
-
const
|
|
822
|
-
for (const role of roles) {
|
|
829
|
+
for (const role of MODEL_ROLES) {
|
|
823
830
|
if (typeof ai[role] === 'string') ai[role] = await resolveModel(ai[role], role);
|
|
824
831
|
}
|
|
825
832
|
|
|
@@ -127,7 +127,7 @@ export class ExperienceTracker {
|
|
|
127
127
|
url: state.url ? extractStatePath(state.url) : '',
|
|
128
128
|
title: state.title,
|
|
129
129
|
};
|
|
130
|
-
if (state.overlay.
|
|
130
|
+
if (state.overlay.isOpen && state.overlay.name) {
|
|
131
131
|
frontmatter.region = state.overlay.name;
|
|
132
132
|
if (state.overlay.root) frontmatter.root = state.overlay.root;
|
|
133
133
|
}
|
package/src/explorbot.ts
CHANGED
|
@@ -465,9 +465,9 @@ export class ExplorBot {
|
|
|
465
465
|
}
|
|
466
466
|
|
|
467
467
|
loadPlans(filename: string): Plan[] {
|
|
468
|
-
const
|
|
469
|
-
if (!
|
|
470
|
-
return parsePlansFromMarkdown(
|
|
468
|
+
const filePath = Plan.resolveFile(filename, this.getPlansDir());
|
|
469
|
+
if (!filePath) throw new Error(`Plan file not found: ${filename}`);
|
|
470
|
+
return parsePlansFromMarkdown(filePath);
|
|
471
471
|
}
|
|
472
472
|
|
|
473
473
|
setCurrentPlan(plan?: Plan): void {
|
package/src/global-config.ts
CHANGED
|
@@ -47,6 +47,14 @@ export function listSites(): SiteRecord[] {
|
|
|
47
47
|
.sort((a, b) => b.lastRunAt.localeCompare(a.lastRunAt));
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
export function findSiteWith(subpath: string): SiteRecord | undefined {
|
|
51
|
+
return listSites().find((site) => existsSync(join(site.dir, subpath)));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function listSitePlanDirs(): string[] {
|
|
55
|
+
return listSites().map((site) => join(site.dir, 'output', 'plans'));
|
|
56
|
+
}
|
|
57
|
+
|
|
50
58
|
export function registerSite(baseUrl: string): SiteRecord {
|
|
51
59
|
const folder = siteFolderName(baseUrl);
|
|
52
60
|
const dir = join(sitesDir(), folder);
|