explorbot 0.4.3 → 0.4.5
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/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 +41 -46
- 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 +39 -44
- 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/tools.js +7 -1
- package/dist/src/ai/fisherman.js +2 -1
- package/dist/src/ai/pilot.d.ts +0 -1
- package/dist/src/ai/pilot.js +8 -24
- package/dist/src/ai/planner.d.ts +4 -0
- package/dist/src/ai/planner.js +28 -0
- package/dist/src/ai/provider.js +3 -1
- 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 +8 -7
- package/dist/src/ai/scout/tools.d.ts +17 -0
- package/dist/src/ai/scout/tools.js +130 -0
- package/dist/src/ai/scout.d.ts +21 -0
- package/dist/src/ai/scout.js +150 -0
- package/dist/src/ai/tester.d.ts +1 -0
- package/dist/src/ai/tester.js +27 -33
- package/dist/src/ai/tools.js +61 -30
- package/dist/src/application-spec.d.ts +3 -0
- package/dist/src/application-spec.js +21 -5
- package/dist/src/commands/config-command.js +6 -2
- package/dist/src/config.d.ts +9 -1
- package/dist/src/config.js +14 -0
- package/dist/src/explorbot.d.ts +3 -0
- package/dist/src/explorbot.js +33 -0
- package/dist/src/knowledge-tracker.d.ts +1 -0
- package/dist/src/knowledge-tracker.js +3 -0
- package/dist/src/state-manager.js +5 -1
- package/dist/src/utils/aria-ref.d.ts +16 -0
- package/dist/src/utils/aria-ref.js +47 -0
- package/dist/src/utils/aria.js +3 -3
- package/dist/src/utils/web-annotate.js +3 -15
- package/dist/src/utils/web-element.d.ts +0 -2
- package/dist/src/utils/web-element.js +0 -8
- package/docs/api-testing/basics.md +12 -4
- package/docs/reference/commands.md +1 -0
- package/docs/reference/configuration.md +28 -1
- package/docs/web-testing/agents.md +9 -1
- package/docs/web-testing/planner.md +5 -0
- package/docs/workflow/agentic-usage.md +3 -1
- package/docs/workflow/application-spec.md +4 -0
- package/package.json +1 -1
- package/src/action-result.ts +7 -0
- package/src/action.ts +14 -2
- package/src/ai/fisherman/tools.ts +8 -1
- package/src/ai/fisherman.ts +2 -1
- package/src/ai/pilot.ts +8 -25
- package/src/ai/planner.ts +33 -0
- package/src/ai/provider.ts +2 -1
- 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 +8 -7
- package/src/ai/scout/tools.ts +150 -0
- package/src/ai/scout.ts +173 -0
- package/src/ai/tester.ts +25 -30
- package/src/ai/tools.ts +75 -36
- package/src/application-spec.ts +22 -4
- package/src/commands/config-command.ts +4 -1
- package/src/config.ts +23 -0
- package/src/explorbot.ts +36 -0
- package/src/knowledge-tracker.ts +4 -0
- package/src/state-manager.ts +6 -1
- package/src/utils/aria-ref.ts +61 -0
- package/src/utils/aria.ts +3 -3
- package/src/utils/web-annotate.ts +3 -15
- package/src/utils/web-element.ts +0 -9
package/dist/src/ai/tools.js
CHANGED
|
@@ -11,6 +11,7 @@ import { cleanHtmlSnippet } from "../utils/html.js";
|
|
|
11
11
|
import { createDebug, tag } from '../utils/logger.js';
|
|
12
12
|
import { compactErrorMessage, normalizeInlineText, truncate } from "../utils/strings.js";
|
|
13
13
|
import { pause } from '../utils/loop.js';
|
|
14
|
+
import { ariaRefSelector, describeRef, refIsGone } from "../utils/aria-ref.js";
|
|
14
15
|
import { WebElement } from "../utils/web-element.js";
|
|
15
16
|
import { sectionContextRule } from "./rules.js";
|
|
16
17
|
import { isInteractive } from "./task-agent.js";
|
|
@@ -52,7 +53,6 @@ export function createCodeceptJSTools({ explorer, stateManager }, task) {
|
|
|
52
53
|
2. I.click(ARIA, container) - e.g. I.click({"role":"button","text":"Save"}, ".modal")
|
|
53
54
|
3. I.click(CSS, container) - e.g. I.click("#btn", ".modal")
|
|
54
55
|
4. I.click(CSS) or I.click(XPath) - when locator already includes context (ID, XPath)
|
|
55
|
-
5. I.clickXY(x, y) - coordinates fallback
|
|
56
56
|
After a result reporting multiple matches, reuse that locator with step.opts({ elementIndex: N }) as the last argument.
|
|
57
57
|
`),
|
|
58
58
|
explanation: z.string().describe('Why you are clicking this element'),
|
|
@@ -63,10 +63,18 @@ export function createCodeceptJSTools({ explorer, stateManager }, task) {
|
|
|
63
63
|
activeNote.commit(TestResult.FAILED);
|
|
64
64
|
return failedToolResult('click', 'No commands provided');
|
|
65
65
|
}
|
|
66
|
-
const
|
|
66
|
+
const trimmedCommands = rawCommands.map((cmd) => cmd.trim());
|
|
67
|
+
const coordinateCommands = trimmedCommands.filter((cmd) => cmd.startsWith('I.clickXY'));
|
|
68
|
+
if (coordinateCommands.length > 0) {
|
|
69
|
+
activeNote.commit(TestResult.FAILED);
|
|
70
|
+
return failedToolResult('click', `Coordinate commands are not locators: ${coordinateCommands.join(', ')}. A coordinate click always runs, so it cannot tell you whether the element was there.`, {
|
|
71
|
+
suggestion: 'Name the element instead. Use visualClick() when you can see the target but cannot address it, or form() for a deliberate coordinate click such as dismissing a layer.',
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const invalidCommands = trimmedCommands.filter((cmd) => cmd.startsWith('I.') && !cmd.startsWith('I.click'));
|
|
67
75
|
if (invalidCommands.length > 0) {
|
|
68
76
|
activeNote.commit(TestResult.FAILED);
|
|
69
|
-
return failedToolResult('click', `Invalid commands: ${invalidCommands.join(', ')}. Click tool only accepts I.click()
|
|
77
|
+
return failedToolResult('click', `Invalid commands: ${invalidCommands.join(', ')}. Click tool only accepts I.click() commands.`, {
|
|
70
78
|
suggestion: 'Use form() tool for typing text or multiple actions, or exitIframe() to leave iframe context.',
|
|
71
79
|
});
|
|
72
80
|
}
|
|
@@ -91,6 +99,15 @@ export function createCodeceptJSTools({ explorer, stateManager }, task) {
|
|
|
91
99
|
ambiguityError = action.lastError;
|
|
92
100
|
if (success) {
|
|
93
101
|
const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, command);
|
|
102
|
+
if (!hasObservablePageChange(toolResult)) {
|
|
103
|
+
activeNote.commit(TestResult.FAILED);
|
|
104
|
+
return failedToolResult('click', 'Click executed, but no observable page change was captured.', {
|
|
105
|
+
...toolResult,
|
|
106
|
+
attempts,
|
|
107
|
+
code: command,
|
|
108
|
+
suggestion: 'Treat the element as not clicked. It may be covered by another layer, disabled, or the locator may have matched a non-interactive ancestor. Re-locate via xpathCheck(), which reports whether the element is covered or offscreen, before retrying.',
|
|
109
|
+
});
|
|
110
|
+
}
|
|
94
111
|
await commitNote(activeNote, TestResult.PASSED, toolResult, action);
|
|
95
112
|
return successToolResult('click', { ...toolResult, attempts, code: command }, action);
|
|
96
113
|
}
|
|
@@ -385,10 +402,16 @@ export function createRefTools({ explorer, stateManager }, task) {
|
|
|
385
402
|
}),
|
|
386
403
|
execute: async ({ ref, element }) => {
|
|
387
404
|
const activeNote = task.startNote(`Click ${element}`);
|
|
405
|
+
if (await refIsGone(explorer, ref)) {
|
|
406
|
+
activeNote.commit(TestResult.FAILED);
|
|
407
|
+
return failedToolResult('clickRef', `Ref ${ref} names no element on the page any more.`, {
|
|
408
|
+
suggestion: 'The page has been rebuilt since you were given that ref. Call context() and use the ref it gives, or fall back to click() with a locator.',
|
|
409
|
+
});
|
|
410
|
+
}
|
|
388
411
|
const previousState = ActionResult.fromState(stateManager.getCurrentState());
|
|
389
412
|
const action = explorer.action();
|
|
390
413
|
const named = await describeRef(explorer, ref);
|
|
391
|
-
const run = `I.usePlaywrightTo(${JSON.stringify(`click ${element}`)}, async ({ page }) => page.locator(${JSON.stringify(
|
|
414
|
+
const run = `I.usePlaywrightTo(${JSON.stringify(`click ${element}`)}, async ({ page }) => page.locator(${JSON.stringify(ariaRefSelector(ref))}).click())`;
|
|
392
415
|
if (!(await action.attempt(run, `Click ${element}`))) {
|
|
393
416
|
activeNote.commit(TestResult.FAILED);
|
|
394
417
|
return failedToolResult('clickRef', `Ref ${ref} could not be clicked: ${errorText(action.lastError)}`, {
|
|
@@ -634,6 +657,11 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
|
|
|
634
657
|
return failedToolResult('research', 'No current page state available. Navigate to a page first.');
|
|
635
658
|
}
|
|
636
659
|
const researchResult = await researcher.research(currentState, { screenshot: true, data: true });
|
|
660
|
+
if (!researchResult) {
|
|
661
|
+
return failedToolResult('research', 'No UI map is available for this page.', {
|
|
662
|
+
suggestion: 'Use context() to read the page structure and act on the elements it lists.',
|
|
663
|
+
});
|
|
664
|
+
}
|
|
637
665
|
return successToolResult('research', {
|
|
638
666
|
analysis: researchResult,
|
|
639
667
|
aria: cap(ActionResult.fromState(currentState).getInteractiveARIA(), ARIA_OUTPUT_CAP),
|
|
@@ -1010,17 +1038,6 @@ export async function commitNote(activeNote, result, toolResult, action) {
|
|
|
1010
1038
|
}
|
|
1011
1039
|
activeNote.commit(result);
|
|
1012
1040
|
}
|
|
1013
|
-
async function describeRef(explorer, ref) {
|
|
1014
|
-
return Promise.resolve(explorer?.withPage?.((page) => page.locator(`aria-ref=${ref}`).evaluate((el) => {
|
|
1015
|
-
const tag = el.tagName.toLowerCase();
|
|
1016
|
-
const roles = { a: 'link', button: 'button', select: 'combobox', textarea: 'textbox' };
|
|
1017
|
-
const role = el.getAttribute('role') || roles[tag] || tag;
|
|
1018
|
-
const text = (el.getAttribute('aria-label') || el.innerText || el.value || '').trim().split('\n')[0];
|
|
1019
|
-
if (!text)
|
|
1020
|
-
return null;
|
|
1021
|
-
return { role, text };
|
|
1022
|
-
}))).catch(() => null);
|
|
1023
|
-
}
|
|
1024
1041
|
async function hasFocusedElement(explorer) {
|
|
1025
1042
|
return explorer.withPage((page) => page.evaluate(() => !!document.activeElement && document.activeElement !== document.body)).catch(() => true);
|
|
1026
1043
|
}
|
|
@@ -1080,6 +1097,8 @@ function hasObservablePageChange(data) {
|
|
|
1080
1097
|
return true;
|
|
1081
1098
|
if (data.pageDiff.messages?.length)
|
|
1082
1099
|
return true;
|
|
1100
|
+
if (data.pageDiff.requests?.length)
|
|
1101
|
+
return true;
|
|
1083
1102
|
return Array.isArray(data.pageDiff.htmlParts) && data.pageDiff.htmlParts.length > 0;
|
|
1084
1103
|
}
|
|
1085
1104
|
export async function failedToolResult(action, message, data, error) {
|
|
@@ -1088,12 +1107,11 @@ export async function failedToolResult(action, message, data, error) {
|
|
|
1088
1107
|
result.suggestion = data.suggestion ? `${data.suggestion} ${PAGE_DIFF_SUGGESTION}` : PAGE_DIFF_SUGGESTION;
|
|
1089
1108
|
}
|
|
1090
1109
|
const errorTexts = [message, ...(data?.attempts?.map((a) => a.error || '') || [])];
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
result.suggestion = multipleElementsSuggestion;
|
|
1110
|
+
if (errorTexts.some((t) => t.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN))) {
|
|
1111
|
+
const matched = await extractWebElements(error);
|
|
1112
|
+
result.suggestion = getMultipleElementsSuggestion(matched);
|
|
1095
1113
|
result.multipleElementsDetected = true;
|
|
1096
|
-
result.elements =
|
|
1114
|
+
result.elements = formatElementList(matched);
|
|
1097
1115
|
return result;
|
|
1098
1116
|
}
|
|
1099
1117
|
const notFoundSuggestion = getNotFoundSuggestion(message);
|
|
@@ -1103,11 +1121,16 @@ export async function failedToolResult(action, message, data, error) {
|
|
|
1103
1121
|
}
|
|
1104
1122
|
return result;
|
|
1105
1123
|
}
|
|
1106
|
-
function getMultipleElementsSuggestion() {
|
|
1124
|
+
function getMultipleElementsSuggestion(matched) {
|
|
1125
|
+
const visible = (matched || []).filter((element) => element.visible !== false);
|
|
1126
|
+
let onlyVisible = '';
|
|
1127
|
+
if (matched && visible.length === 1)
|
|
1128
|
+
onlyVisible = `\nOnly element ${matched.indexOf(visible[0]) + 1} is on screen, so that is the one to act on.`;
|
|
1107
1129
|
return dedent `
|
|
1108
1130
|
Multiple elements matched your locator, so that command did nothing — it selected no element and acted on none.
|
|
1109
|
-
Read the numbered elements list and
|
|
1131
|
+
Read the numbered elements list and act on the one you meant by its number:
|
|
1110
1132
|
reuse the same locator with step.opts({ elementIndex: N }) as the last argument.
|
|
1133
|
+
A match reported as not visible can never be acted on — pick one that is.${onlyVisible}
|
|
1111
1134
|
If none of them is the element you want, narrow the locator with a container or its full unique text.
|
|
1112
1135
|
If the list is missing, call xpathCheck() to see what the locator matches.
|
|
1113
1136
|
`;
|
|
@@ -1127,7 +1150,7 @@ export function clickFailureSuggestion(attempts) {
|
|
|
1127
1150
|
return 'Element exists but another element covers it. Close the overlapping panel or dialog, then retry.';
|
|
1128
1151
|
}
|
|
1129
1152
|
if (errors.some((e) => e.includes('is not visible'))) {
|
|
1130
|
-
return 'Element is in the DOM but not visible. Reveal it
|
|
1153
|
+
return 'Element is in the DOM but not visible. Reveal it — scroll to it, expand its section, open the panel holding it — or, when the page carries several copies of the same control, target the one that is on screen.';
|
|
1131
1154
|
}
|
|
1132
1155
|
if (errors.some((e) => e.includes('SyntaxError'))) {
|
|
1133
1156
|
return 'The command string never parsed as JavaScript — quotes or brackets do not match. No element was looked up, so this tells you nothing about the page. Re-emit the same intent as valid CodeceptJS.';
|
|
@@ -1157,7 +1180,8 @@ async function extractWebElements(error) {
|
|
|
1157
1180
|
const xpath = await elements[i].toAbsoluteXPath();
|
|
1158
1181
|
const html = truncate(cleanHtmlSnippet(await elements[i].toOuterHTML()), MAX_DISAMBIGUATE_HTML);
|
|
1159
1182
|
const text = truncate(normalizeInlineText((await elements[i].getText()) || ''), MAX_DISAMBIGUATE_TEXT);
|
|
1160
|
-
|
|
1183
|
+
const visible = await Promise.resolve(elements[i].isVisible?.()).catch(() => undefined);
|
|
1184
|
+
result.push({ xpath, html, text, visible });
|
|
1161
1185
|
}
|
|
1162
1186
|
catch (e) {
|
|
1163
1187
|
debugLog('Failed to get details for element %d: %s', i, e);
|
|
@@ -1165,14 +1189,21 @@ async function extractWebElements(error) {
|
|
|
1165
1189
|
}
|
|
1166
1190
|
return result.length > 0 ? result : null;
|
|
1167
1191
|
}
|
|
1168
|
-
function formatElementList(
|
|
1169
|
-
|
|
1192
|
+
function formatElementList(matched) {
|
|
1193
|
+
if (!matched)
|
|
1194
|
+
return 'Could not fetch element details. Repeat the action to get better info.';
|
|
1195
|
+
return matched
|
|
1196
|
+
.map((el, i) => {
|
|
1197
|
+
const lines = [`Element ${i + 1}:`, `Text: "${el.text}"`];
|
|
1198
|
+
if (el.visible !== undefined)
|
|
1199
|
+
lines.push(`Visible: ${el.visible}`);
|
|
1200
|
+
lines.push(`XPath: ${el.xpath}`, `HTML: ${el.html}`);
|
|
1201
|
+
return lines.join('\n');
|
|
1202
|
+
})
|
|
1203
|
+
.join('\n\n');
|
|
1170
1204
|
}
|
|
1171
1205
|
export async function formatMatchedElements(error) {
|
|
1172
|
-
|
|
1173
|
-
if (!details)
|
|
1174
|
-
return 'Could not fetch element details. Repeat the action to get better info.';
|
|
1175
|
-
return formatElementList(details);
|
|
1206
|
+
return formatElementList(await extractWebElements(error));
|
|
1176
1207
|
}
|
|
1177
1208
|
function getNotFoundSuggestion(errorMessage) {
|
|
1178
1209
|
if (!errorMessage.includes('not found')) {
|
|
@@ -4,10 +4,13 @@ export declare class ApplicationSpec {
|
|
|
4
4
|
readonly sourcePath: string;
|
|
5
5
|
constructor(sourcePath: string);
|
|
6
6
|
renderFor(state: ActionResult): string;
|
|
7
|
+
matchedUrls(state: ActionResult): string[];
|
|
7
8
|
get pageCount(): number;
|
|
8
9
|
load(): void;
|
|
9
10
|
resolveSourcePath(sourcePath: string): string;
|
|
11
|
+
relevantPages(state: ActionResult): ApplicationSpecPage[];
|
|
10
12
|
}
|
|
13
|
+
export declare function resolveSpecBundlePath(sourcePath: string): string | null;
|
|
11
14
|
interface ApplicationSpecPage {
|
|
12
15
|
url: string;
|
|
13
16
|
content: string;
|
|
@@ -13,7 +13,7 @@ export class ApplicationSpec {
|
|
|
13
13
|
this.load();
|
|
14
14
|
}
|
|
15
15
|
renderFor(state) {
|
|
16
|
-
const relevant = this.
|
|
16
|
+
const relevant = this.relevantPages(state);
|
|
17
17
|
if (relevant.length === 0)
|
|
18
18
|
return '';
|
|
19
19
|
tag('operation').log(`Found application specification for ${state.url}`);
|
|
@@ -25,6 +25,9 @@ export class ApplicationSpec {
|
|
|
25
25
|
</application_spec>
|
|
26
26
|
`;
|
|
27
27
|
}
|
|
28
|
+
matchedUrls(state) {
|
|
29
|
+
return this.relevantPages(state).map((page) => page.url);
|
|
30
|
+
}
|
|
28
31
|
get pageCount() {
|
|
29
32
|
return this.pages.length;
|
|
30
33
|
}
|
|
@@ -63,9 +66,22 @@ export class ApplicationSpec {
|
|
|
63
66
|
}
|
|
64
67
|
}
|
|
65
68
|
resolveSourcePath(sourcePath) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
return
|
|
69
|
+
return resolveSpecSource(sourcePath);
|
|
70
|
+
}
|
|
71
|
+
relevantPages(state) {
|
|
72
|
+
return this.pages.filter((page) => state.isMatchedBy({ url: page.url }));
|
|
70
73
|
}
|
|
71
74
|
}
|
|
75
|
+
export function resolveSpecBundlePath(sourcePath) {
|
|
76
|
+
const resolved = resolveSpecSource(sourcePath);
|
|
77
|
+
if (!existsSync(resolved))
|
|
78
|
+
return null;
|
|
79
|
+
if (statSync(resolved).isDirectory())
|
|
80
|
+
return resolved;
|
|
81
|
+
return path.dirname(resolved);
|
|
82
|
+
}
|
|
83
|
+
function resolveSpecSource(sourcePath) {
|
|
84
|
+
if (path.isAbsolute(sourcePath))
|
|
85
|
+
return path.resolve(sourcePath);
|
|
86
|
+
return path.resolve(ConfigParser.getInstance().resolveProjectDir(sourcePath));
|
|
87
|
+
}
|
|
@@ -39,8 +39,12 @@ export class ConfigCommand extends BaseCommand {
|
|
|
39
39
|
const env = {};
|
|
40
40
|
for (const variable of EXPLORBOT_ENV_VARS) {
|
|
41
41
|
const value = process.env[variable.name];
|
|
42
|
-
if (value)
|
|
43
|
-
|
|
42
|
+
if (!value)
|
|
43
|
+
continue;
|
|
44
|
+
let shown = value;
|
|
45
|
+
if (variable.secret)
|
|
46
|
+
shown = 'set';
|
|
47
|
+
env[variable.name] = shown;
|
|
44
48
|
}
|
|
45
49
|
const models = {};
|
|
46
50
|
const providers = {};
|
package/dist/src/config.d.ts
CHANGED
|
@@ -105,6 +105,10 @@ interface RerunnerAgentConfig extends AgentConfig {
|
|
|
105
105
|
interface PlannerAgentConfig extends AgentConfig {
|
|
106
106
|
styles?: string[];
|
|
107
107
|
stylesDir?: string;
|
|
108
|
+
docsWeight?: number;
|
|
109
|
+
}
|
|
110
|
+
interface ScoutAgentConfig extends AgentConfig {
|
|
111
|
+
dirs?: string[];
|
|
108
112
|
}
|
|
109
113
|
interface ScreencastConfig {
|
|
110
114
|
size?: {
|
|
@@ -129,6 +133,7 @@ interface AgentsConfig {
|
|
|
129
133
|
quartermaster?: AgentConfig;
|
|
130
134
|
historian?: HistorianAgentConfig;
|
|
131
135
|
fisherman?: AgentConfig;
|
|
136
|
+
scout?: ScoutAgentConfig;
|
|
132
137
|
chief?: AgentConfig;
|
|
133
138
|
curler?: AgentConfig;
|
|
134
139
|
rerunner?: RerunnerAgentConfig;
|
|
@@ -221,7 +226,7 @@ interface ExplorbotConfig {
|
|
|
221
226
|
type RuleEntry = string | Record<string, string>;
|
|
222
227
|
export declare const EXPLORBOT_CONFIG_PATHS: string[];
|
|
223
228
|
export declare const EXPLORBOT_ENV_VARS: EnvVar[];
|
|
224
|
-
export type { ExplorbotConfig, PlaywrightConfig, AIConfig, HtmlConfig, ActionConfig, AgentConfig, AgentsConfig, HistorianAgentConfig, ResearcherAgentConfig, NavigatorAgentConfig, PlannerAgentConfig, RerunnerAgentConfig, HealRecipe, Hook, HookConfig, HooksConfig, PlaywrightHook, CodeceptJSHook, HookPatternMap, RuleEntry, ReporterConfig, ApiConfig, WebConfig, ApiHookFn, };
|
|
229
|
+
export type { ExplorbotConfig, PlaywrightConfig, AIConfig, HtmlConfig, ActionConfig, AgentConfig, AgentsConfig, HistorianAgentConfig, ResearcherAgentConfig, NavigatorAgentConfig, PlannerAgentConfig, ScoutAgentConfig, RerunnerAgentConfig, HealRecipe, Hook, HookConfig, HooksConfig, PlaywrightHook, CodeceptJSHook, HookPatternMap, RuleEntry, ReporterConfig, ApiConfig, WebConfig, ApiHookFn, };
|
|
225
230
|
export declare class ConfigParser {
|
|
226
231
|
static instance: ConfigParser;
|
|
227
232
|
static recommended: Record<string, Record<string, string>> | null;
|
|
@@ -269,7 +274,9 @@ export declare class ConfigParser {
|
|
|
269
274
|
deepMerge(target: any, source: any): any;
|
|
270
275
|
ensureDirectory(path: string): void;
|
|
271
276
|
}
|
|
277
|
+
export declare function setOutputDir(dir: string): void;
|
|
272
278
|
export declare function outputPath(...segments: string[]): string;
|
|
279
|
+
export declare function agentSettings<K extends keyof AgentsConfig>(config: ExplorbotConfig, agent: K): NonNullable<AgentsConfig[K]>;
|
|
273
280
|
export declare function resolveModel(spec: string, role?: ModelRole): Promise<any>;
|
|
274
281
|
export declare function missingModelRoles(provider: string): ModelRole[];
|
|
275
282
|
export declare class ConfigMissingError extends Error {
|
|
@@ -297,5 +304,6 @@ interface EnvVar {
|
|
|
297
304
|
name: string;
|
|
298
305
|
description: string;
|
|
299
306
|
required?: boolean;
|
|
307
|
+
secret?: boolean;
|
|
300
308
|
}
|
|
301
309
|
export type { ModelRole, EnvVar, ProviderInfo, ConfiguredModel };
|
package/dist/src/config.js
CHANGED
|
@@ -27,6 +27,7 @@ export const PROVIDERS = {
|
|
|
27
27
|
};
|
|
28
28
|
export const MODEL_ROLES = ['model', 'visionModel', 'agenticModel'];
|
|
29
29
|
let cachedOutputRoot = null;
|
|
30
|
+
let runOutputDir = null;
|
|
30
31
|
const config = {
|
|
31
32
|
playwright: {
|
|
32
33
|
browser: 'chromium',
|
|
@@ -49,6 +50,7 @@ export const EXPLORBOT_ENV_VARS = [
|
|
|
49
50
|
{ name: 'EXPLORBOT_KNOWLEDGE_FILE', description: 'Path to a knowledge markdown file' },
|
|
50
51
|
{ name: 'EXPLORBOT_SPEC', description: 'Docbot application spec directory or index.md, used as page knowledge' },
|
|
51
52
|
{ name: 'EXPLORBOT_API_SPEC', description: 'OpenAPI spec path for the API boat' },
|
|
53
|
+
{ name: 'EXPLORBOT_API_HEADERS', description: 'Headers sent with every API request, one "Name: value" per line', secret: true },
|
|
52
54
|
{ name: 'EXPLORBOT_NO_BANNER', description: 'Suppress the startup banner, for machine-readable output' },
|
|
53
55
|
{ name: 'EXPLORBOT_MAX_DURATION', description: 'Wall-clock budget in minutes for an explore run; same as --max-duration' },
|
|
54
56
|
];
|
|
@@ -213,6 +215,7 @@ export class ConfigParser {
|
|
|
213
215
|
// For testing purposes only
|
|
214
216
|
static resetForTesting() {
|
|
215
217
|
cachedOutputRoot = null;
|
|
218
|
+
runOutputDir = null;
|
|
216
219
|
if (ConfigParser.instance) {
|
|
217
220
|
ConfigParser.instance.config = null;
|
|
218
221
|
ConfigParser.instance.configPath = null;
|
|
@@ -441,9 +444,20 @@ export class ConfigParser {
|
|
|
441
444
|
}
|
|
442
445
|
}
|
|
443
446
|
}
|
|
447
|
+
export function setOutputDir(dir) {
|
|
448
|
+
runOutputDir = dir;
|
|
449
|
+
}
|
|
444
450
|
export function outputPath(...segments) {
|
|
451
|
+
if (runOutputDir)
|
|
452
|
+
return path.join(runOutputDir, ...segments);
|
|
445
453
|
return path.join(ConfigParser.getInstance().getOutputDir(), ...segments);
|
|
446
454
|
}
|
|
455
|
+
export function agentSettings(config, agent) {
|
|
456
|
+
const ai = (config.ai ??= { model: null });
|
|
457
|
+
const agents = (ai.agents ??= {});
|
|
458
|
+
agents[agent] ??= {};
|
|
459
|
+
return agents[agent];
|
|
460
|
+
}
|
|
447
461
|
export async function resolveModel(spec, role = 'model') {
|
|
448
462
|
const separator = spec.indexOf('/');
|
|
449
463
|
if (separator > 0) {
|
package/dist/src/explorbot.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { AIProvider } from './ai/provider.js';
|
|
|
12
12
|
import { Quartermaster } from './ai/quartermaster.js';
|
|
13
13
|
import { Rerunner } from './ai/rerunner.js';
|
|
14
14
|
import { Researcher } from './ai/researcher.js';
|
|
15
|
+
import { Scout } from './ai/scout.js';
|
|
15
16
|
import { SessionAnalyst } from './ai/session-analyst.js';
|
|
16
17
|
import { Tester } from './ai/tester.js';
|
|
17
18
|
import { RequestStore } from './api/request-store.js';
|
|
@@ -99,6 +100,8 @@ export declare class ExplorBot {
|
|
|
99
100
|
agentDriller(): Driller;
|
|
100
101
|
agentSessionAnalyst(): SessionAnalyst;
|
|
101
102
|
agentFisherman(): Fisherman | null;
|
|
103
|
+
agentScout(): Scout | null;
|
|
104
|
+
scoutCorpusDirs(): string[];
|
|
102
105
|
getCurrentPlan(): Plan | undefined;
|
|
103
106
|
getSuite(): Suite | null;
|
|
104
107
|
getPlanFeature(): string | undefined;
|
package/dist/src/explorbot.js
CHANGED
|
@@ -12,12 +12,15 @@ import { AIProvider } from "./ai/provider.js";
|
|
|
12
12
|
import { Quartermaster } from "./ai/quartermaster.js";
|
|
13
13
|
import { Rerunner } from "./ai/rerunner.js";
|
|
14
14
|
import { Researcher } from "./ai/researcher.js";
|
|
15
|
+
import { Scout } from "./ai/scout.js";
|
|
16
|
+
import { loadScoutCorpus } from "./ai/scout/tools.js";
|
|
15
17
|
import { SessionAnalyst } from "./ai/session-analyst.js";
|
|
16
18
|
import { Tester } from "./ai/tester.js";
|
|
17
19
|
import { createAgentTools } from "./ai/tools.js";
|
|
18
20
|
import { ApiClient } from "./api/api-client.js";
|
|
19
21
|
import { RequestStore } from "./api/request-store.js";
|
|
20
22
|
import { loadSpec } from "./api/spec-reader.js";
|
|
23
|
+
import { resolveSpecBundlePath } from "./application-spec.js";
|
|
21
24
|
import { ConfigParser } from "./config.js";
|
|
22
25
|
import { ExperienceTracker } from "./experience-tracker.js";
|
|
23
26
|
import Explorer from "./explorer.js";
|
|
@@ -184,6 +187,9 @@ export class ExplorBot {
|
|
|
184
187
|
const fisherman = this.agentFisherman();
|
|
185
188
|
if (fisherman)
|
|
186
189
|
this.agents.planner.setFisherman(fisherman);
|
|
190
|
+
const scout = this.agentScout();
|
|
191
|
+
if (scout)
|
|
192
|
+
this.agents.planner.setScout(scout);
|
|
187
193
|
}
|
|
188
194
|
return this.agents.planner;
|
|
189
195
|
}
|
|
@@ -310,6 +316,33 @@ export class ExplorBot {
|
|
|
310
316
|
}
|
|
311
317
|
return this.agents.fisherman;
|
|
312
318
|
}
|
|
319
|
+
agentScout() {
|
|
320
|
+
const scoutConfig = this.config.ai?.agents?.scout;
|
|
321
|
+
if (scoutConfig?.enabled !== true)
|
|
322
|
+
return null;
|
|
323
|
+
const dirs = this.scoutCorpusDirs();
|
|
324
|
+
if (dirs.length === 0) {
|
|
325
|
+
tag('warning').log('Scout enabled but no documentation found — set --spec or ai.agents.scout.dirs');
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
return (this.agents.scout ||= this.createAgent(({ ai }) => new Scout(ai, loadScoutCorpus(dirs))));
|
|
329
|
+
}
|
|
330
|
+
scoutCorpusDirs() {
|
|
331
|
+
const dirs = [];
|
|
332
|
+
const specPath = this.options.applicationSpec || this.config.dirs?.spec;
|
|
333
|
+
if (specPath) {
|
|
334
|
+
const bundle = resolveSpecBundlePath(specPath);
|
|
335
|
+
const pagesDir = bundle && path.join(bundle, 'pages');
|
|
336
|
+
if (pagesDir && existsSync(pagesDir))
|
|
337
|
+
dirs.push(pagesDir);
|
|
338
|
+
}
|
|
339
|
+
for (const dir of this.config.ai?.agents?.scout?.dirs || []) {
|
|
340
|
+
const resolved = this.configParser.resolveProjectDir(dir);
|
|
341
|
+
if (existsSync(resolved))
|
|
342
|
+
dirs.push(resolved);
|
|
343
|
+
}
|
|
344
|
+
return [...new Set(dirs)];
|
|
345
|
+
}
|
|
313
346
|
getCurrentPlan() {
|
|
314
347
|
return this.currentPlan;
|
|
315
348
|
}
|
|
@@ -16,6 +16,7 @@ export declare class KnowledgeTracker {
|
|
|
16
16
|
renderEndpointKnowledge(endpoint: string): string;
|
|
17
17
|
renderRelevantContext(state: ActionResult): string;
|
|
18
18
|
renderApplicationSpec(state: ActionResult): string;
|
|
19
|
+
applicationSpecUrls(state: ActionResult): string[];
|
|
19
20
|
addKnowledge(urlPattern: string, description: string, opts?: {
|
|
20
21
|
replace?: boolean;
|
|
21
22
|
}): {
|
|
@@ -79,6 +79,9 @@ export class KnowledgeTracker {
|
|
|
79
79
|
renderApplicationSpec(state) {
|
|
80
80
|
return this.applicationSpec?.renderFor(state) || '';
|
|
81
81
|
}
|
|
82
|
+
applicationSpecUrls(state) {
|
|
83
|
+
return this.applicationSpec?.matchedUrls(state) || [];
|
|
84
|
+
}
|
|
82
85
|
addKnowledge(urlPattern, description, opts) {
|
|
83
86
|
const configParser = ConfigParser.getInstance();
|
|
84
87
|
const configPath = configParser.getConfigPath();
|
|
@@ -60,12 +60,16 @@ export class StateManager {
|
|
|
60
60
|
updateState(actionResult, codeBlock, trigger = 'manual') {
|
|
61
61
|
const previousState = this.currentState;
|
|
62
62
|
const previousHash = previousState?.hash;
|
|
63
|
+
const hashChanged = actionResult.hash !== previousHash;
|
|
64
|
+
if (!hashChanged && previousState?.verifications) {
|
|
65
|
+
const stillTrue = Object.entries(previousState.verifications).filter(([, passed]) => passed);
|
|
66
|
+
actionResult.verifications = { ...Object.fromEntries(stillTrue), ...actionResult.verifications };
|
|
67
|
+
}
|
|
63
68
|
const newState = actionResult;
|
|
64
69
|
this.currentState = newState;
|
|
65
70
|
this.currentState.id = this.nextStateId++;
|
|
66
71
|
if (newState.url)
|
|
67
72
|
this.allVisitedUrls.add(normalizeUrl(newState.url));
|
|
68
|
-
const hashChanged = actionResult.hash !== previousHash;
|
|
69
73
|
const regionOpened = !hashChanged && this.regionOpened(previousState, newState);
|
|
70
74
|
if (hashChanged || regionOpened) {
|
|
71
75
|
const transition = {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { WebElement } from './web-element.js';
|
|
2
|
+
export declare function ariaRefSelector(ref: string): string;
|
|
3
|
+
export declare function isAriaRef(ref: string): boolean;
|
|
4
|
+
export declare function ariaRefSnapshot(page: any): Promise<string>;
|
|
5
|
+
export declare function parseAriaRefs(ariaSnapshot: string): AriaRefEntry[];
|
|
6
|
+
export declare function elementFromAriaRef(page: any, ref: string): Promise<WebElement | null>;
|
|
7
|
+
export declare function refIsGone(explorer: any, ref: string): Promise<boolean>;
|
|
8
|
+
export declare function describeRef(explorer: any, ref: string): Promise<{
|
|
9
|
+
role: string;
|
|
10
|
+
text: string;
|
|
11
|
+
} | null>;
|
|
12
|
+
export interface AriaRefEntry {
|
|
13
|
+
role: string;
|
|
14
|
+
name: string;
|
|
15
|
+
ref: string;
|
|
16
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { WebElement } from "./web-element.js";
|
|
2
|
+
const REF_LINE_PATTERN = /^(\s*)-\s+(\w+)\s*(?:"([^"]*)")?.*?\[ref=(e\d+)\]/;
|
|
3
|
+
const ARIA_REF_PATTERN = /^(f\d+)?e\d+$/i;
|
|
4
|
+
const REF_ROLES = { a: 'link', button: 'button', select: 'combobox', textarea: 'textbox' };
|
|
5
|
+
export function ariaRefSelector(ref) {
|
|
6
|
+
return `aria-ref=${ref}`;
|
|
7
|
+
}
|
|
8
|
+
export function isAriaRef(ref) {
|
|
9
|
+
return ARIA_REF_PATTERN.test(ref);
|
|
10
|
+
}
|
|
11
|
+
export function ariaRefSnapshot(page) {
|
|
12
|
+
return page.locator('body').ariaSnapshot({ mode: 'ai' });
|
|
13
|
+
}
|
|
14
|
+
export function parseAriaRefs(ariaSnapshot) {
|
|
15
|
+
const entries = [];
|
|
16
|
+
for (const line of ariaSnapshot.split('\n')) {
|
|
17
|
+
const match = line.match(REF_LINE_PATTERN);
|
|
18
|
+
if (!match)
|
|
19
|
+
continue;
|
|
20
|
+
entries.push({ role: match[2], name: match[3] || '', ref: match[4] });
|
|
21
|
+
}
|
|
22
|
+
return entries;
|
|
23
|
+
}
|
|
24
|
+
export async function elementFromAriaRef(page, ref) {
|
|
25
|
+
if (!isAriaRef(ref))
|
|
26
|
+
return null;
|
|
27
|
+
return WebElement.fromPlaywrightLocator(page.locator(ariaRefSelector(ref)));
|
|
28
|
+
}
|
|
29
|
+
export async function refIsGone(explorer, ref) {
|
|
30
|
+
const count = () => Promise.resolve(explorer?.withPage?.((page) => page.locator(ariaRefSelector(ref)).count())).catch(() => undefined);
|
|
31
|
+
if ((await count()) !== 0)
|
|
32
|
+
return false;
|
|
33
|
+
await Promise.resolve(explorer?.withPage?.(ariaRefSnapshot)).catch(() => null);
|
|
34
|
+
return (await count()) === 0;
|
|
35
|
+
}
|
|
36
|
+
export async function describeRef(explorer, ref) {
|
|
37
|
+
return Promise.resolve(explorer?.withPage?.((page) => page.locator(ariaRefSelector(ref)).evaluate((el, roles) => {
|
|
38
|
+
const tag = el.tagName.toLowerCase();
|
|
39
|
+
const role = el.getAttribute('role') || roles[tag];
|
|
40
|
+
if (!role)
|
|
41
|
+
return null;
|
|
42
|
+
const text = (el.getAttribute('aria-label') || el.innerText || el.value || '').trim().split('\n')[0];
|
|
43
|
+
if (!text)
|
|
44
|
+
return null;
|
|
45
|
+
return { role, text };
|
|
46
|
+
}, REF_ROLES))).catch(() => null);
|
|
47
|
+
}
|
package/dist/src/utils/aria.js
CHANGED
|
@@ -434,9 +434,9 @@ const formatDiffSection = (label, items) => {
|
|
|
434
434
|
const summary = countBy(items);
|
|
435
435
|
if (summary.size === 0)
|
|
436
436
|
return [` ${label}: []`];
|
|
437
|
-
const
|
|
438
|
-
const top =
|
|
439
|
-
const rest =
|
|
437
|
+
const ordered = Array.from(summary.entries());
|
|
438
|
+
const top = ordered.slice(0, TOP_DIFF_ITEMS);
|
|
439
|
+
const rest = ordered.slice(TOP_DIFF_ITEMS);
|
|
440
440
|
const lines = [` ${label}:`];
|
|
441
441
|
for (const [item, count] of top) {
|
|
442
442
|
let suffix = '';
|
|
@@ -1,24 +1,12 @@
|
|
|
1
|
+
import { ariaRefSnapshot, parseAriaRefs } from "./aria-ref.js";
|
|
1
2
|
import { ELEMENT_EXTRACTION_CONFIG, getElementDataExtractorSource } from "./html.js";
|
|
2
3
|
import { createDebug } from './logger.js';
|
|
3
4
|
import { WebElement } from "./web-element.js";
|
|
4
5
|
const debugLog = createDebug('explorbot:web-annotate');
|
|
5
|
-
const REF_LINE_PATTERN = /^(\s*)-\s+(\w+)\s*(?:"([^"]*)")?.*?\[ref=(e\d+)\]/;
|
|
6
6
|
const ANNOTATABLE_ROLES = new Set(['button', 'link', 'textbox', 'searchbox', 'checkbox', 'radio', 'switch', 'combobox', 'tab', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'slider', 'spinbutton', 'treeitem']);
|
|
7
|
-
function parseAriaRefs(ariaSnapshot) {
|
|
8
|
-
const entries = [];
|
|
9
|
-
for (const line of ariaSnapshot.split('\n')) {
|
|
10
|
-
const match = line.match(REF_LINE_PATTERN);
|
|
11
|
-
if (!match)
|
|
12
|
-
continue;
|
|
13
|
-
if (!ANNOTATABLE_ROLES.has(match[2]))
|
|
14
|
-
continue;
|
|
15
|
-
entries.push({ role: match[2], name: match[3] || '', ref: match[4] });
|
|
16
|
-
}
|
|
17
|
-
return entries;
|
|
18
|
-
}
|
|
19
7
|
export async function annotatePageElements(page) {
|
|
20
|
-
const ariaSnapshot = await page
|
|
21
|
-
const refEntries = parseAriaRefs(ariaSnapshot);
|
|
8
|
+
const ariaSnapshot = await ariaRefSnapshot(page);
|
|
9
|
+
const refEntries = parseAriaRefs(ariaSnapshot).filter((entry) => ANNOTATABLE_ROLES.has(entry.role));
|
|
22
10
|
const byRole = new Map();
|
|
23
11
|
for (const { role, name, ref } of refEntries) {
|
|
24
12
|
let list = byRole.get(role);
|
|
@@ -36,8 +36,6 @@ export declare class WebElement {
|
|
|
36
36
|
static fromXPathMatch(m: XPathMatch): WebElement;
|
|
37
37
|
static fromPlaywrightLocator(locator: any): Promise<WebElement | null>;
|
|
38
38
|
static fromEidx(page: any, eidx: string): Promise<WebElement | null>;
|
|
39
|
-
static isAriaRef(ref: string): boolean;
|
|
40
|
-
static fromAriaRef(page: any, ref: string): Promise<WebElement | null>;
|
|
41
39
|
static fromEidxList(page: any, eidxList: string[]): Promise<WebElement[]>;
|
|
42
40
|
static commonAncestor(page: any, eidxList: string[]): Promise<WebElement | null>;
|
|
43
41
|
static findByXPath(html: string, xpath: string): Promise<{
|
|
@@ -111,14 +111,6 @@ export class WebElement {
|
|
|
111
111
|
static async fromEidx(page, eidx) {
|
|
112
112
|
return WebElement.fromPlaywrightLocator(page.locator(`[${EXPLORBOT_ATTRS.eidx}="${eidx}"]`));
|
|
113
113
|
}
|
|
114
|
-
static isAriaRef(ref) {
|
|
115
|
-
return /^(f\d+)?e\d+$/i.test(ref);
|
|
116
|
-
}
|
|
117
|
-
static async fromAriaRef(page, ref) {
|
|
118
|
-
if (!WebElement.isAriaRef(ref))
|
|
119
|
-
return null;
|
|
120
|
-
return WebElement.fromPlaywrightLocator(page.locator(`aria-ref=${ref}`));
|
|
121
|
-
}
|
|
122
114
|
static async fromEidxList(page, eidxList) {
|
|
123
115
|
const validEidxList = eidxList.filter((eidx) => /^e\d+$/i.test(eidx));
|
|
124
116
|
if (validEidxList.length === 0)
|
|
@@ -30,7 +30,7 @@ export default {
|
|
|
30
30
|
|
|
31
31
|
- **`baseEndpoint`** (required) — the base URL prepended to every request. Test steps use relative paths like `/users`; Curler adds the base for you.
|
|
32
32
|
- **`spec`** (required) — one or more OpenAPI specs, given as HTTP(S) URLs or local file paths, in YAML or JSON. Chief uses the spec to plan; Curler uses it to look up schemas. Both agents refuse to run without one.
|
|
33
|
-
- **`headers`** — sent with every request. This is where API keys and auth tokens go.
|
|
33
|
+
- **`headers`** — sent with every request. This is where API keys and auth tokens go. `-H "Name: value"` on the command line and `EXPLORBOT_API_HEADERS` add to them without a config file.
|
|
34
34
|
|
|
35
35
|
See the [full configuration reference](../reference/configuration.md) for every option and [providers](../basics/providers.md) for choosing an AI model.
|
|
36
36
|
|
|
@@ -60,16 +60,24 @@ A matching `teardown` hook runs after all tests finish — use it to clean up da
|
|
|
60
60
|
|
|
61
61
|
Chief and Curler need three things: where the API is, what its spec says, and how to authenticate. Pass all three on the command line and no config file is needed:
|
|
62
62
|
|
|
63
|
+
```bash
|
|
64
|
+
npx explorbot api explore https://api.example.com/v1 \
|
|
65
|
+
--spec ./openapi.yaml \
|
|
66
|
+
-H "Authorization: Bearer $TOKEN"
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`api explore` takes the base endpoint as its argument, so one line covers the whole run: it plans in every style, executes each plan, and reports the totals. The other commands take a path within the API and read the base from `--endpoint`:
|
|
70
|
+
|
|
63
71
|
```bash
|
|
64
72
|
npx explorbot api plan /users \
|
|
65
73
|
--endpoint https://api.example.com/v1 \
|
|
66
74
|
--spec ./openapi.yaml \
|
|
67
|
-
|
|
75
|
+
-H "Authorization: Bearer $TOKEN"
|
|
68
76
|
```
|
|
69
77
|
|
|
70
|
-
|
|
78
|
+
Each flag has an environment twin — `EXPLORBOT_URL`, `EXPLORBOT_API_SPEC` and `EXPLORBOT_API_HEADERS` — and the flag wins when both are set. `-H` is repeatable and takes one `Name: value` per use; the variable takes one per line. Headers land on every request, the startup health check included, and merge over any `headers` a config file sets. `--knowledge` adds to the facts `EXPLORBOT_KNOWLEDGE` and `EXPLORBOT_KNOWLEDGE_FILE` bring in rather than replacing them. Configure your models once with `npx explorbot init --global` and every run stores its plans and requests per host under `~/.explorbot/sites/<host>/`, so a later `api test` against the same API picks up where the last one left off. Knowledge given on the command line lasts for the run; `api know` is what writes it down.
|
|
71
79
|
|
|
72
|
-
|
|
80
|
+
The base endpoint keeps its path prefix: given `https://api.example.com/v1`, steps stay relative (`/users`) and Curler sends them to `https://api.example.com/v1/users`. `api test`, which takes a plan file rather than an endpoint, reads the base from the flag or the variable.
|
|
73
81
|
|
|
74
82
|
### A dedicated API project
|
|
75
83
|
|