explorbot 0.4.2 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/explorbot-cli.ts +6 -1
- package/boat/api-tester/src/apibot.ts +8 -13
- package/boat/api-tester/src/cli.ts +7 -3
- package/boat/api-tester/src/config.ts +45 -9
- package/boat/prima/src/cli.ts +33 -99
- package/boat/prima/src/envelope.ts +3 -1
- package/boat/prima/src/help.ts +72 -0
- package/boat/prima/src/prima.ts +33 -43
- package/dist/bin/explorbot-cli.js +5 -1
- package/dist/boat/api-tester/src/apibot.js +7 -6
- package/dist/boat/api-tester/src/cli.js +9 -3
- package/dist/boat/api-tester/src/config.js +32 -6
- package/dist/boat/prima/src/cli.js +30 -86
- package/dist/boat/prima/src/envelope.js +2 -1
- package/dist/boat/prima/src/help.js +63 -0
- package/dist/boat/prima/src/prima.js +29 -41
- package/dist/package.json +1 -1
- package/dist/src/action-result.d.ts +3 -0
- package/dist/src/action-result.js +5 -0
- package/dist/src/action.js +12 -1
- package/dist/src/ai/fisherman/request-haul.d.ts +1 -0
- package/dist/src/ai/fisherman/request-haul.js +3 -0
- package/dist/src/ai/fisherman/tools.d.ts +50 -0
- package/dist/src/ai/{fisherman-tools.js → fisherman/tools.js} +78 -13
- package/dist/src/ai/fisherman.d.ts +12 -3
- package/dist/src/ai/fisherman.js +89 -13
- package/dist/src/ai/pilot.d.ts +13 -1
- package/dist/src/ai/pilot.js +20 -7
- package/dist/src/ai/researcher/deep-analysis.d.ts +1 -1
- package/dist/src/ai/researcher/deep-analysis.js +4 -1
- package/dist/src/ai/researcher/sections.d.ts +1 -1
- package/dist/src/ai/researcher/sections.js +2 -1
- package/dist/src/ai/researcher.js +25 -11
- package/dist/src/ai/rules.js +2 -0
- package/dist/src/ai/tester.d.ts +1 -0
- package/dist/src/ai/tester.js +27 -33
- package/dist/src/ai/tools.js +5 -0
- package/dist/src/api/request-result.js +3 -1
- package/dist/src/api/request-store.d.ts +6 -1
- package/dist/src/api/request-store.js +55 -17
- 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 +6 -2
- package/dist/src/commands/help-json-command.d.ts +31 -0
- package/dist/src/commands/help-json-command.js +58 -0
- package/dist/src/config.d.ts +3 -0
- package/dist/src/config.js +14 -0
- package/dist/src/state-manager.js +5 -1
- package/docs/api-testing/basics.md +12 -4
- package/docs/reference/commands.md +2 -0
- package/docs/reference/configuration.md +4 -0
- package/docs/superpowers/plans/2026-09-03-fisherman-query-api.md +1361 -0
- package/docs/workflow/agentic-usage.md +15 -1
- package/package.json +1 -1
- package/src/action-result.ts +7 -0
- package/src/action.ts +14 -2
- package/src/ai/fisherman/request-haul.ts +4 -0
- package/src/ai/{fisherman-tools.ts → fisherman/tools.ts} +93 -20
- package/src/ai/fisherman.ts +104 -15
- package/src/ai/pilot.ts +20 -7
- package/src/ai/researcher/deep-analysis.ts +4 -2
- package/src/ai/researcher/sections.ts +2 -2
- package/src/ai/researcher.ts +28 -11
- package/src/ai/rules.ts +2 -0
- package/src/ai/tester.ts +25 -30
- package/src/ai/tools.ts +6 -0
- package/src/api/request-result.ts +2 -1
- package/src/api/request-store.ts +58 -18
- package/src/api/xhr-capture.ts +39 -11
- package/src/commands/config-command.ts +4 -1
- package/src/commands/help-json-command.ts +74 -0
- package/src/config.ts +16 -0
- package/src/state-manager.ts +6 -1
- package/dist/src/ai/fisherman-tools.d.ts +0 -147
package/src/ai/researcher.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import dedent from 'dedent';
|
|
2
2
|
import { ActionResult } from '../action-result.js';
|
|
3
3
|
import { setActivity } from '../activity.ts';
|
|
4
|
-
import { ConfigParser, type ExplorbotConfig, outputPath } from '../config.ts';
|
|
4
|
+
import { ConfigParser, type ExplorbotConfig, type ResearcherAgentConfig, agentSettings, outputPath } from '../config.ts';
|
|
5
5
|
import { executionController } from '../execution-controller.ts';
|
|
6
6
|
import type { ExperienceTracker } from '../experience-tracker.ts';
|
|
7
7
|
import type Explorer from '../explorer.ts';
|
|
@@ -19,7 +19,7 @@ import { annotatePageElements } from '../utils/web-annotate.ts';
|
|
|
19
19
|
import type { Agent, AgentDeps } from './agent.js';
|
|
20
20
|
import type { Navigator } from './navigator.ts';
|
|
21
21
|
import { ContextLengthError, type Provider } from './provider.js';
|
|
22
|
-
import { findSimilarResearch, getCachedResearch, reportResearch, saveResearch } from './researcher/cache.ts';
|
|
22
|
+
import { findSimilarResearch, getCachedResearch, getPreviousResearch, reportResearch, saveResearch } from './researcher/cache.ts';
|
|
23
23
|
import { type CoordinateMethods, WithCoordinates } from './researcher/coordinates.ts';
|
|
24
24
|
import { type DeepAnalysisMethods, WithDeepAnalysis } from './researcher/deep-analysis.ts';
|
|
25
25
|
import { detectFocusedSection, hasFocusedSection, markSectionAsFocused, pickDefaultFocusedSection } from './researcher/focus.ts';
|
|
@@ -62,13 +62,23 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
62
62
|
constructor(deps: AgentDeps) {
|
|
63
63
|
super(deps);
|
|
64
64
|
this.experienceTracker = deps.stateManager.getExperienceTracker();
|
|
65
|
+
this.settings.reasoning ??= 'low';
|
|
66
|
+
}
|
|
65
67
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
68
|
+
get settings(): ResearcherAgentConfig {
|
|
69
|
+
return agentSettings(this.config, 'researcher');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
isEnabled(): boolean {
|
|
73
|
+
return this.settings.enabled !== false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
enable(): void {
|
|
77
|
+
this.settings.enabled = true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
disable(): void {
|
|
81
|
+
this.settings.enabled = false;
|
|
72
82
|
}
|
|
73
83
|
|
|
74
84
|
protected getNavigator(): Navigator {
|
|
@@ -94,7 +104,7 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
94
104
|
|
|
95
105
|
async research(state: WebPageState, opts: { screenshot?: boolean; force?: boolean; deep?: boolean; data?: boolean; fix?: boolean; _retriesLeft?: number } = {}): Promise<string> {
|
|
96
106
|
const { screenshot = false, force = false, deep = false, data = false, fix = true } = opts;
|
|
97
|
-
const maxRetries =
|
|
107
|
+
const maxRetries = this.settings.retries ?? 2;
|
|
98
108
|
let retriesLeft = opts._retriesLeft ?? maxRetries;
|
|
99
109
|
this.actionResult = ActionResult.fromState(state);
|
|
100
110
|
const stateHash = this.actionResult.baseHash;
|
|
@@ -109,6 +119,13 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
109
119
|
}
|
|
110
120
|
}
|
|
111
121
|
|
|
122
|
+
if (!this.isEnabled()) {
|
|
123
|
+
debugLog('Researcher is disabled, answering with the recorded map');
|
|
124
|
+
const recorded = getPreviousResearch(stateHash);
|
|
125
|
+
if (recorded) reportResearch(stateHash, recorded);
|
|
126
|
+
return recorded;
|
|
127
|
+
}
|
|
128
|
+
|
|
112
129
|
Stats.researches++;
|
|
113
130
|
|
|
114
131
|
const sessionName = `researcher: ${state.url}`;
|
|
@@ -341,7 +358,7 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
341
358
|
}
|
|
342
359
|
|
|
343
360
|
private async waitUntilSettled(screenshot: boolean): Promise<boolean> {
|
|
344
|
-
const errorPageTimeout =
|
|
361
|
+
const errorPageTimeout = this.settings.errorPageTimeout ?? 10;
|
|
345
362
|
if (errorPageTimeout <= 0) return false;
|
|
346
363
|
|
|
347
364
|
const includeScreenshot = screenshot && this.provider.hasVision();
|
|
@@ -374,7 +391,7 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
374
391
|
}
|
|
375
392
|
|
|
376
393
|
private getConfiguredSections(): Record<string, string> {
|
|
377
|
-
const configSections =
|
|
394
|
+
const configSections = this.settings.sections;
|
|
378
395
|
if (!configSections?.length) return POSSIBLE_SECTIONS;
|
|
379
396
|
const filtered: Record<string, string> = {};
|
|
380
397
|
for (const key of configSections) {
|
package/src/ai/rules.ts
CHANGED
|
@@ -173,6 +173,8 @@ export const dataProtectionRules = dedent`
|
|
|
173
173
|
Do not use Fisherman or API data preparation to bypass a no-mutation, read-only, search,
|
|
174
174
|
filter, tab, or list-inspection constraint. Use visible existing data when it is available.
|
|
175
175
|
If no suitable data exists, report the missing precondition instead of creating data.
|
|
176
|
+
Reading through the API to establish what already exists is not a mutation and stays allowed
|
|
177
|
+
under a read-only constraint.
|
|
176
178
|
|
|
177
179
|
Destructive actions are allowed only against data created by the current scenario
|
|
178
180
|
or prepared for that scenario by Fisherman/API preconditions. Existing application data must
|
package/src/ai/tester.ts
CHANGED
|
@@ -53,6 +53,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
53
53
|
MAX_ITERATIONS = 30;
|
|
54
54
|
MAX_EXTENSIONS = 2;
|
|
55
55
|
ASSERTION_TOOLS = ['verify'];
|
|
56
|
+
private pendingReview = '';
|
|
56
57
|
researcher: Researcher;
|
|
57
58
|
navigator: Navigator;
|
|
58
59
|
agentTools: any;
|
|
@@ -119,6 +120,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
119
120
|
this.seenUiMapUrls.clear();
|
|
120
121
|
this.lastAnalyzedStateHash = null;
|
|
121
122
|
this.stalledIterations = 0;
|
|
123
|
+
this.pendingReview = '';
|
|
122
124
|
this.previousRegionPresent = null;
|
|
123
125
|
this.regionTransitioned = false;
|
|
124
126
|
this.stateManager.clearHistory();
|
|
@@ -333,7 +335,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
333
335
|
const result = await this.provider.invokeConversation(conversation, tools, {
|
|
334
336
|
maxToolRoundtrips: 3,
|
|
335
337
|
toolChoice: 'required',
|
|
336
|
-
stopWhen: () => task.hasFinished,
|
|
338
|
+
stopWhen: () => task.hasFinished || !!this.pendingReview,
|
|
337
339
|
});
|
|
338
340
|
|
|
339
341
|
if (!result) throw new Error('Failed to get response from provider');
|
|
@@ -388,6 +390,14 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
388
390
|
}
|
|
389
391
|
}
|
|
390
392
|
|
|
393
|
+
if (this.pendingReview && this.pilot) {
|
|
394
|
+
const reviewed = this.pendingReview;
|
|
395
|
+
this.pendingReview = '';
|
|
396
|
+
const reviewState = this.getCurrentState();
|
|
397
|
+
if (reviewed === 'finish') await this.pilot.reviewFinish(task, reviewState, conversation, this.navigator);
|
|
398
|
+
if (reviewed === 'stop') await this.pilot.reviewStop(task, reviewState, conversation);
|
|
399
|
+
}
|
|
400
|
+
|
|
391
401
|
if (task.hasFinished) {
|
|
392
402
|
stop();
|
|
393
403
|
return;
|
|
@@ -579,16 +589,19 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
579
589
|
|
|
580
590
|
if (region.isModal) {
|
|
581
591
|
const areaName = region.name ? ` "${region.name}"` : '';
|
|
582
|
-
let
|
|
583
|
-
if (region.root)
|
|
592
|
+
let scoping = 'Use <page_aria> to confirm the element you target is actually inside the overlay.';
|
|
593
|
+
if (region.root) {
|
|
594
|
+
scoping = `Its root is \`${region.root}\` — build every locator as ARIA scoped to that root, e.g. I.click({ role: 'button', text: 'Continue' }, '${region.root}')`;
|
|
595
|
+
}
|
|
584
596
|
context += dedent`
|
|
585
597
|
<overlay>
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
Use <page_aria> to confirm the element you target is actually inside the overlay.
|
|
590
|
-
</overlay>
|
|
598
|
+
You are inside an overlay${areaName} opened above the page.
|
|
599
|
+
${scoping}
|
|
600
|
+
Elements outside the overlay are behind it and not actionable while it is open — they may share names or roles with the ones inside, so never target them by bare text.
|
|
591
601
|
`;
|
|
602
|
+
const regionAria = currentState.getRegionARIA();
|
|
603
|
+
if (regionAria) context += `\nIt holds exactly these elements:\n<overlay_aria>\n${regionAria}\n</overlay_aria>`;
|
|
604
|
+
context += '\n</overlay>\n';
|
|
592
605
|
}
|
|
593
606
|
|
|
594
607
|
if (!region.isModal && region.isOpen && isNewState) {
|
|
@@ -1006,18 +1019,9 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
1006
1019
|
}),
|
|
1007
1020
|
execute: async ({ reason }) => {
|
|
1008
1021
|
task.addNote(`Stop requested: ${reason}`);
|
|
1022
|
+
this.pendingReview = 'stop';
|
|
1009
1023
|
|
|
1010
|
-
if (this.pilot) {
|
|
1011
|
-
const currentState = this.getCurrentState();
|
|
1012
|
-
await this.pilot.reviewStop(task, currentState, conversation);
|
|
1013
|
-
if (!task.hasFinished) {
|
|
1014
|
-
return {
|
|
1015
|
-
success: false,
|
|
1016
|
-
action: 'stop',
|
|
1017
|
-
message: 'Stop rejected; Continue execution',
|
|
1018
|
-
};
|
|
1019
|
-
}
|
|
1020
|
-
} else {
|
|
1024
|
+
if (!this.pilot) {
|
|
1021
1025
|
task.addNote(reason, TestResult.FAILED);
|
|
1022
1026
|
task.finish(TestResult.FAILED);
|
|
1023
1027
|
}
|
|
@@ -1053,18 +1057,9 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
1053
1057
|
return { success: true, action: 'finish', message: 'already finished' };
|
|
1054
1058
|
}
|
|
1055
1059
|
task.addNote(`Finish requested: ${verify}`);
|
|
1060
|
+
this.pendingReview = 'finish';
|
|
1056
1061
|
|
|
1057
|
-
if (this.pilot) {
|
|
1058
|
-
const currentState = this.getCurrentState();
|
|
1059
|
-
await this.pilot.reviewFinish(task, currentState, conversation, this.navigator);
|
|
1060
|
-
if (!task.hasFinished) {
|
|
1061
|
-
return {
|
|
1062
|
-
success: false,
|
|
1063
|
-
action: 'finish',
|
|
1064
|
-
message: 'Finishing rejected; Continue execution',
|
|
1065
|
-
};
|
|
1066
|
-
}
|
|
1067
|
-
} else {
|
|
1062
|
+
if (!this.pilot) {
|
|
1068
1063
|
task.addNote('Test finished successfully', TestResult.PASSED);
|
|
1069
1064
|
task.finish(TestResult.PASSED);
|
|
1070
1065
|
}
|
package/src/ai/tools.ts
CHANGED
|
@@ -737,6 +737,12 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
|
|
|
737
737
|
|
|
738
738
|
const researchResult = await researcher.research(currentState, { screenshot: true, data: true });
|
|
739
739
|
|
|
740
|
+
if (!researchResult) {
|
|
741
|
+
return failedToolResult('research', 'No UI map is available for this page.', {
|
|
742
|
+
suggestion: 'Use context() to read the page structure and act on the elements it lists.',
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
|
|
740
746
|
return successToolResult('research', {
|
|
741
747
|
analysis: researchResult,
|
|
742
748
|
aria: cap(ActionResult.fromState(currentState).getInteractiveARIA(), ARIA_OUTPUT_CAP),
|
|
@@ -124,7 +124,8 @@ export class RequestResult {
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
writeFileSync(this.requestFile, yaml, 'utf8');
|
|
127
|
-
|
|
127
|
+
if (!this._rawResponseBody) return;
|
|
128
|
+
writeFileSync(this.responseFile, this._rawResponseBody, 'utf8');
|
|
128
129
|
}
|
|
129
130
|
|
|
130
131
|
static load(requestFile: string): RequestResult {
|
package/src/api/request-store.ts
CHANGED
|
@@ -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) {
|
|
@@ -58,15 +67,15 @@ export class RequestStore {
|
|
|
58
67
|
return this.madeRequests[this.madeRequests.length - 1];
|
|
59
68
|
}
|
|
60
69
|
|
|
61
|
-
toEndpointList(scopePath?: string): string {
|
|
62
|
-
let requests = this.capturedRequests;
|
|
63
|
-
if (scopePath) requests = this.
|
|
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);
|
|
64
73
|
|
|
65
74
|
const seen = new Set<string>();
|
|
66
75
|
const lines: string[] = [];
|
|
67
76
|
|
|
68
77
|
for (const req of requests) {
|
|
69
|
-
const key = `${req.method} ${generalizeUrl(req.path, () => '{id}')}`;
|
|
78
|
+
const key = `${req.method} ${generalizeUrl(req.path, () => '{id}')}${queryParamHint(req)}`;
|
|
70
79
|
if (seen.has(key)) continue;
|
|
71
80
|
seen.add(key);
|
|
72
81
|
lines.push(key);
|
|
@@ -134,6 +143,11 @@ export class RequestStore {
|
|
|
134
143
|
try {
|
|
135
144
|
const result = RequestResult.load(path.join(requestsDir, file));
|
|
136
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
|
+
}
|
|
137
151
|
this.capturedRequests.push(result);
|
|
138
152
|
} catch {
|
|
139
153
|
// skip invalid files
|
|
@@ -142,16 +156,31 @@ export class RequestStore {
|
|
|
142
156
|
}
|
|
143
157
|
|
|
144
158
|
getWriteRequestsForScope(scopePath: string): RequestResult[] {
|
|
145
|
-
|
|
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));
|
|
146
175
|
const scopeSegments = scopePath.split('/').filter(Boolean);
|
|
147
|
-
if (scopeSegments.length === 0) return
|
|
176
|
+
if (scopeSegments.length === 0) return candidates;
|
|
148
177
|
|
|
149
178
|
let scoped: RequestResult[] = [];
|
|
150
179
|
let fewest = Number.POSITIVE_INFINITY;
|
|
151
180
|
let ambiguous = false;
|
|
152
181
|
for (const segment of scopeSegments) {
|
|
153
182
|
if (isDynamicSegment(segment)) continue;
|
|
154
|
-
const matches =
|
|
183
|
+
const matches = candidates.filter((r) => r.path.split('/').includes(segment));
|
|
155
184
|
if (matches.length === 0 || matches.length > fewest) continue;
|
|
156
185
|
if (matches.length === fewest) {
|
|
157
186
|
if (!scoped.every((r, i) => r.id === matches[i].id)) ambiguous = true;
|
|
@@ -165,21 +194,32 @@ export class RequestStore {
|
|
|
165
194
|
|
|
166
195
|
return scoped;
|
|
167
196
|
}
|
|
168
|
-
|
|
169
|
-
clear(): void {
|
|
170
|
-
this.capturedRequests = [];
|
|
171
|
-
this.madeRequests = [];
|
|
172
|
-
this.failedRequests = [];
|
|
173
|
-
}
|
|
174
197
|
}
|
|
175
198
|
|
|
176
199
|
export function isFailedRequest(request: RequestResult): boolean {
|
|
177
200
|
return request.status >= 400 || Boolean(request.error);
|
|
178
201
|
}
|
|
179
202
|
|
|
180
|
-
function
|
|
181
|
-
return
|
|
182
|
-
.split('/')
|
|
183
|
-
.map((segment) => (segment && isDynamicSegment(segment) ? '{id}' : segment))
|
|
184
|
-
.join('/');
|
|
203
|
+
function readEndpointKey(result: RequestResult): string {
|
|
204
|
+
return `${result.method} ${generalizeUrl(result.path, () => '{id}')}?${queryParamNames(result).join(',')}`;
|
|
185
205
|
}
|
|
206
|
+
|
|
207
|
+
function matchesFamily(result: RequestResult, methods: EndpointFamily): boolean {
|
|
208
|
+
if (methods === 'write') return result.isWrite;
|
|
209
|
+
return result.method === 'GET';
|
|
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
|
}
|
|
@@ -45,7 +45,10 @@ export class ConfigCommand extends BaseCommand {
|
|
|
45
45
|
const env: Record<string, string> = {};
|
|
46
46
|
for (const variable of EXPLORBOT_ENV_VARS) {
|
|
47
47
|
const value = process.env[variable.name];
|
|
48
|
-
if (value)
|
|
48
|
+
if (!value) continue;
|
|
49
|
+
let shown = value;
|
|
50
|
+
if (variable.secret) shown = 'set';
|
|
51
|
+
env[variable.name] = shown;
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
const models: Record<string, string> = {};
|
|
@@ -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/config.ts
CHANGED
|
@@ -22,6 +22,7 @@ export const PROVIDERS: Record<string, ProviderInfo> = {
|
|
|
22
22
|
export const MODEL_ROLES: ModelRole[] = ['model', 'visionModel', 'agenticModel'];
|
|
23
23
|
|
|
24
24
|
let cachedOutputRoot: string | null = null;
|
|
25
|
+
let runOutputDir: string | null = null;
|
|
25
26
|
|
|
26
27
|
interface PlaywrightConfig {
|
|
27
28
|
browser: 'chromium' | 'firefox' | 'webkit';
|
|
@@ -275,6 +276,7 @@ export const EXPLORBOT_ENV_VARS: EnvVar[] = [
|
|
|
275
276
|
{ name: 'EXPLORBOT_KNOWLEDGE_FILE', description: 'Path to a knowledge markdown file' },
|
|
276
277
|
{ name: 'EXPLORBOT_SPEC', description: 'Docbot application spec directory or index.md, used as page knowledge' },
|
|
277
278
|
{ name: 'EXPLORBOT_API_SPEC', description: 'OpenAPI spec path for the API boat' },
|
|
279
|
+
{ name: 'EXPLORBOT_API_HEADERS', description: 'Headers sent with every API request, one "Name: value" per line', secret: true },
|
|
278
280
|
{ name: 'EXPLORBOT_NO_BANNER', description: 'Suppress the startup banner, for machine-readable output' },
|
|
279
281
|
{ name: 'EXPLORBOT_MAX_DURATION', description: 'Wall-clock budget in minutes for an explore run; same as --max-duration' },
|
|
280
282
|
];
|
|
@@ -496,6 +498,7 @@ export class ConfigParser {
|
|
|
496
498
|
// For testing purposes only
|
|
497
499
|
public static resetForTesting(): void {
|
|
498
500
|
cachedOutputRoot = null;
|
|
501
|
+
runOutputDir = null;
|
|
499
502
|
if (ConfigParser.instance) {
|
|
500
503
|
ConfigParser.instance.config = null;
|
|
501
504
|
ConfigParser.instance.configPath = null;
|
|
@@ -744,10 +747,22 @@ export class ConfigParser {
|
|
|
744
747
|
}
|
|
745
748
|
}
|
|
746
749
|
|
|
750
|
+
export function setOutputDir(dir: string): void {
|
|
751
|
+
runOutputDir = dir;
|
|
752
|
+
}
|
|
753
|
+
|
|
747
754
|
export function outputPath(...segments: string[]): string {
|
|
755
|
+
if (runOutputDir) return path.join(runOutputDir, ...segments);
|
|
748
756
|
return path.join(ConfigParser.getInstance().getOutputDir(), ...segments);
|
|
749
757
|
}
|
|
750
758
|
|
|
759
|
+
export function agentSettings<K extends keyof AgentsConfig>(config: ExplorbotConfig, agent: K): NonNullable<AgentsConfig[K]> {
|
|
760
|
+
const ai = (config.ai ??= { model: null });
|
|
761
|
+
const agents = (ai.agents ??= {}) as Record<K, NonNullable<AgentsConfig[K]>>;
|
|
762
|
+
agents[agent] ??= {} as NonNullable<AgentsConfig[K]>;
|
|
763
|
+
return agents[agent];
|
|
764
|
+
}
|
|
765
|
+
|
|
751
766
|
export async function resolveModel(spec: string, role: ModelRole = 'model'): Promise<any> {
|
|
752
767
|
const separator = spec.indexOf('/');
|
|
753
768
|
if (separator > 0) {
|
|
@@ -912,6 +927,7 @@ interface EnvVar {
|
|
|
912
927
|
name: string;
|
|
913
928
|
description: string;
|
|
914
929
|
required?: boolean;
|
|
930
|
+
secret?: boolean;
|
|
915
931
|
}
|
|
916
932
|
|
|
917
933
|
export type { ModelRole, EnvVar, ProviderInfo, ConfiguredModel };
|
package/src/state-manager.ts
CHANGED
|
@@ -143,13 +143,18 @@ export class StateManager {
|
|
|
143
143
|
updateState(actionResult: ActionResult, codeBlock?: string, trigger: 'manual' | 'navigation' | 'automatic' = 'manual'): WebPageState {
|
|
144
144
|
const previousState = this.currentState;
|
|
145
145
|
const previousHash = previousState?.hash;
|
|
146
|
+
const hashChanged = actionResult.hash !== previousHash;
|
|
147
|
+
|
|
148
|
+
if (!hashChanged && previousState?.verifications) {
|
|
149
|
+
const stillTrue = Object.entries(previousState.verifications).filter(([, passed]) => passed);
|
|
150
|
+
actionResult.verifications = { ...Object.fromEntries(stillTrue), ...actionResult.verifications };
|
|
151
|
+
}
|
|
146
152
|
|
|
147
153
|
const newState = actionResult;
|
|
148
154
|
this.currentState = newState;
|
|
149
155
|
this.currentState.id = this.nextStateId++;
|
|
150
156
|
if (newState.url) this.allVisitedUrls.add(normalizeUrl(newState.url));
|
|
151
157
|
|
|
152
|
-
const hashChanged = actionResult.hash !== previousHash;
|
|
153
158
|
const regionOpened = !hashChanged && this.regionOpened(previousState, newState);
|
|
154
159
|
|
|
155
160
|
if (hashChanged || regionOpened) {
|