explorbot 0.4.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/explorbot-cli.ts +5 -3
- package/boat/api-tester/src/ai/chief.ts +7 -1
- package/boat/api-tester/src/ai/curler.ts +7 -1
- package/boat/api-tester/src/apibot.ts +10 -4
- package/boat/api-tester/src/cli.ts +12 -2
- package/boat/api-tester/src/config.ts +28 -8
- package/boat/doc-collector/bin/doc-collector-cli.ts +3 -2
- package/boat/doc-collector/src/cli.ts +3 -1
- package/boat/doc-collector/src/docbot.ts +1 -1
- package/boat/prima/bin/prima-cli.ts +2 -0
- package/boat/prima/src/cli.ts +2 -0
- package/dist/bin/explorbot-cli.js +5 -3
- package/dist/boat/api-tester/bin/apibot-cli.js +3 -2
- package/dist/boat/api-tester/src/ai/chief.js +6 -1
- package/dist/boat/api-tester/src/ai/curler.js +6 -1
- package/dist/boat/api-tester/src/apibot.js +7 -3
- package/dist/boat/api-tester/src/cli.js +12 -2
- package/dist/boat/api-tester/src/config.js +31 -8
- package/dist/boat/doc-collector/bin/doc-collector-cli.js +3 -2
- package/dist/boat/doc-collector/src/cli.js +3 -1
- package/dist/boat/doc-collector/src/docbot.js +1 -1
- package/dist/boat/prima/bin/prima-cli.js +2 -0
- package/dist/boat/prima/src/cli.js +3 -0
- package/dist/package.json +1 -1
- package/dist/rules/planner/styles/normal.md +1 -1
- package/dist/src/ai/captain.js +1 -1
- package/dist/src/ai/navigator.js +1 -1
- package/dist/src/ai/planner.js +9 -7
- package/dist/src/ai/researcher.js +2 -0
- package/dist/src/ai/rules.js +3 -3
- package/dist/src/api/spec-reader.js +1 -1
- package/dist/src/commands/config-command.js +1 -1
- package/dist/src/commands/drill-command.js +1 -1
- package/dist/src/commands/explore-command.js +12 -1
- package/dist/src/commands/options/base-option.d.ts +8 -0
- package/dist/src/commands/options/base-option.js +12 -0
- package/dist/src/commands/options/index.d.ts +5 -0
- package/dist/src/commands/options/index.js +5 -0
- package/dist/src/commands/options/knowledge-option.d.ts +7 -0
- package/dist/src/commands/options/knowledge-option.js +12 -0
- package/dist/src/commands/options/ws-option.d.ts +7 -0
- package/dist/src/commands/options/ws-option.js +21 -0
- package/dist/src/config.d.ts +1 -0
- package/dist/src/config.js +11 -0
- package/dist/src/explorbot.js +1 -1
- package/dist/src/knowledge-tracker.d.ts +20 -7
- package/dist/src/knowledge-tracker.js +69 -31
- package/dist/src/remote.d.ts +0 -3
- package/dist/src/remote.js +0 -18
- package/docs/api-testing/basics.md +15 -0
- package/docs/api-testing/planning.md +10 -1
- package/docs/reference/commands.md +24 -4
- package/docs/workflow/agentic-usage.md +11 -2
- package/docs/workflow/knowledge.md +46 -2
- package/package.json +1 -1
- package/rules/planner/styles/normal.md +1 -1
- package/src/ai/captain.ts +1 -1
- package/src/ai/navigator.ts +1 -1
- package/src/ai/planner.ts +9 -8
- package/src/ai/researcher.ts +1 -0
- package/src/ai/rules.ts +3 -3
- package/src/api/spec-reader.ts +1 -1
- package/src/commands/config-command.ts +1 -1
- package/src/commands/drill-command.ts +1 -1
- package/src/commands/explore-command.ts +12 -1
- package/src/commands/options/base-option.ts +18 -0
- package/src/commands/options/index.ts +7 -0
- package/src/commands/options/knowledge-option.ts +14 -0
- package/src/commands/options/ws-option.ts +24 -0
- package/src/config.ts +11 -0
- package/src/explorbot.ts +1 -1
- package/src/knowledge-tracker.ts +94 -36
- package/src/remote.ts +0 -20
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import {
|
|
2
|
+
import { knowledgeOption, wsOption } from "../../../src/commands/options/index.js";
|
|
3
3
|
import { createDocsCommands } from "../src/cli.js";
|
|
4
4
|
const program = createDocsCommands('doc-collector');
|
|
5
|
-
|
|
5
|
+
wsOption.register(program);
|
|
6
|
+
knowledgeOption.register(program);
|
|
6
7
|
program.parse();
|
|
@@ -15,6 +15,7 @@ function buildOptions(options) {
|
|
|
15
15
|
incognito: options.incognito,
|
|
16
16
|
session: options.session,
|
|
17
17
|
docsConfig: options.docsConfig,
|
|
18
|
+
baseUrl: options.url,
|
|
18
19
|
};
|
|
19
20
|
}
|
|
20
21
|
function addCommonOptions(cmd) {
|
|
@@ -24,6 +25,7 @@ function addCommonOptions(cmd) {
|
|
|
24
25
|
.option('-c, --config <path>', 'Path to explorbot configuration file')
|
|
25
26
|
.option('--docs-config <path>', 'Path to doc collector configuration file')
|
|
26
27
|
.option('-p, --path <path>', 'Working directory path')
|
|
28
|
+
.option('--url <url>', 'Base URL of the site, when the path argument is relative (env: EXPLORBOT_URL)')
|
|
27
29
|
.option('-s, --show', 'Show browser window')
|
|
28
30
|
.option('--headless', 'Run browser in headless mode')
|
|
29
31
|
.option('--incognito', 'Run without recording experiences')
|
|
@@ -65,7 +67,7 @@ export function createDocsCommands(name = 'docs') {
|
|
|
65
67
|
.action(async (url, options) => {
|
|
66
68
|
setQuietMode(!isVerboseMode());
|
|
67
69
|
try {
|
|
68
|
-
console.log(await ConfigCommand.summary({ config: options.config, path: options.path, url, json: options.json }));
|
|
70
|
+
console.log(await ConfigCommand.summary({ config: options.config, path: options.path, url: url || options.url, json: options.json }));
|
|
69
71
|
}
|
|
70
72
|
catch (error) {
|
|
71
73
|
console.error(error instanceof Error ? error.message : 'Unknown error');
|
|
@@ -20,7 +20,7 @@ class DocBot {
|
|
|
20
20
|
scopeRoot = '/';
|
|
21
21
|
constructor(options = {}) {
|
|
22
22
|
this.options = options;
|
|
23
|
-
const baseUrl = this.extractAbsoluteBaseUrl(options.startUrl || '/');
|
|
23
|
+
const baseUrl = this.extractAbsoluteBaseUrl(options.startUrl || '/') || options.baseUrl;
|
|
24
24
|
this.explorBot = new ExplorBot({
|
|
25
25
|
baseUrl,
|
|
26
26
|
verbose: options.verbose,
|
|
@@ -106,6 +106,7 @@ function addCommonOptions(cmd) {
|
|
|
106
106
|
.option('--ephemeral', 'Keep no state between runs; applies to config-free runs, where output goes to a temp directory')
|
|
107
107
|
.option('--framework <name>', 'Not active yet: framework the reported code targets, codeceptjs or playwright')
|
|
108
108
|
.option('--url <url>', 'Page to open when the session has no page yet')
|
|
109
|
+
.option('--spec <path>', 'Docbot application spec directory or index.md to read as page knowledge')
|
|
109
110
|
.option('--endpoint <ep>', 'Websocket endpoint of a browser server to attach to, skipping discovery')
|
|
110
111
|
.option('--pw-session <title>', 'Title of the playwright-cli session to attach to')
|
|
111
112
|
.addHelpText('after', `\n${sessionHelp}`);
|
|
@@ -118,6 +119,8 @@ function primaFor(options) {
|
|
|
118
119
|
process.env.EXPLORBOT_AI_MODEL = options.model;
|
|
119
120
|
if (options.visionModel)
|
|
120
121
|
process.env.EXPLORBOT_VISION_MODEL = options.visionModel;
|
|
122
|
+
if (options.spec)
|
|
123
|
+
process.env.EXPLORBOT_SPEC = options.spec;
|
|
121
124
|
return new Prima(buildOptions(options));
|
|
122
125
|
}
|
|
123
126
|
async function runPrima(options, command, run) {
|
package/dist/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Study the page and figure out its business purpose. What is this page FOR? What would a user come here to do?
|
|
2
2
|
|
|
3
3
|
Based on the page type, propose tests for COMPLETE user workflows:
|
|
4
|
-
- If this is a data page (lists, tables): test
|
|
4
|
+
- If this is a data page (lists, tables): test create, edit and delete as separate tests, each preparing the item it acts on
|
|
5
5
|
- If the page has inputs to fill in: test the full commit flow, not just that the controls render
|
|
6
6
|
- If this has filters and search: test filtering AND verify results change, not just "filter tab clicked"
|
|
7
7
|
- If this has modals/dropdowns: test the ACTION inside them, not just opening/closing them
|
package/dist/src/ai/captain.js
CHANGED
|
@@ -137,7 +137,7 @@ export class Captain extends CaptainBase {
|
|
|
137
137
|
const headingLines = formatHeadings(state);
|
|
138
138
|
const headingsBlock = headingLines.join('\n');
|
|
139
139
|
let pageSummary = '';
|
|
140
|
-
const cachedResearch = Researcher.getCachedResearch(
|
|
140
|
+
const cachedResearch = Researcher.getCachedResearch(actionResult);
|
|
141
141
|
if (cachedResearch) {
|
|
142
142
|
pageSummary = `<page_summary>\n${this.explorBot.agentResearcher().extractBrief(cachedResearch)}\n</page_summary>`;
|
|
143
143
|
}
|
package/dist/src/ai/navigator.js
CHANGED
|
@@ -522,7 +522,7 @@ class Navigator {
|
|
|
522
522
|
return null;
|
|
523
523
|
}
|
|
524
524
|
const currentActionResult = actionResult || ActionResult.fromState(state);
|
|
525
|
-
const research = Researcher.getCachedResearch(
|
|
525
|
+
const research = Researcher.getCachedResearch(currentActionResult) || '';
|
|
526
526
|
const combinedHtml = await currentActionResult.combinedHtml();
|
|
527
527
|
const history = stateManager.getStateHistory();
|
|
528
528
|
const visitCounts = new Map();
|
package/dist/src/ai/planner.js
CHANGED
|
@@ -96,11 +96,15 @@ export class Planner extends PlannerBase {
|
|
|
96
96
|
Tests must be relevant to the page
|
|
97
97
|
Tests must be achievable from UI
|
|
98
98
|
Tests must be verifiable from UI
|
|
99
|
-
|
|
99
|
+
One test verifies ONE business operation: the steps that reach it, the action itself, and its verification.
|
|
100
|
+
Steps that only reach the action — opening a form, expanding a panel, creating or locating the item to act on — belong to that test. A second operation with its own verification does not.
|
|
100
101
|
Bad: "Open delete dropdown" + "Confirm deletion" — these are ONE test, not two.
|
|
101
102
|
Bad: "Search for X" + "Verify search results" — searching and verifying is ONE test.
|
|
102
103
|
Bad: "Leave field empty" + "Click submit" — that's one negative test, not two.
|
|
103
|
-
|
|
104
|
+
Bad: "Create a record, rename it, delete it" — three verified operations, so THREE tests, not one.
|
|
105
|
+
Good: "Rename existing record and verify the new title" — ONE test; creating is skipped, we assume record already exists, only the rename is verified.
|
|
106
|
+
You may rely on another test having run first in case we deal with empty state and no relevant data was created yet and we expect another our test creates it
|
|
107
|
+
When the page reports a record is missing or unavailable, it is not a testable surface — plan list-level or recovery behavior instead of operations on that record.${featureDirective}${focusExistingDataDirective}
|
|
104
108
|
</task>
|
|
105
109
|
|
|
106
110
|
${customPrompt || ''}
|
|
@@ -166,9 +170,6 @@ export class Planner extends PlannerBase {
|
|
|
166
170
|
if (!aiResult?.object?.scenarios) {
|
|
167
171
|
throw new Error('No tasks were created successfully');
|
|
168
172
|
}
|
|
169
|
-
if (aiResult.object.scenarios.length === 0 && !this.currentPlan) {
|
|
170
|
-
throw new Error('No tasks were created successfully');
|
|
171
|
-
}
|
|
172
173
|
const defaultStartUrl = this.getDefaultStartUrl(state);
|
|
173
174
|
const fromPlanning = aiResult.object.scenarios.map((s) => new Test(s.scenario, s.priority, s.expectedOutcomes, s.startUrl || defaultStartUrl, s.steps || []));
|
|
174
175
|
return { tests: fromPlanning, planName: aiResult.object.planName };
|
|
@@ -295,6 +296,7 @@ export class Planner extends PlannerBase {
|
|
|
295
296
|
<task>
|
|
296
297
|
Based on the page research, create ${this.MIN_TASKS}-${this.MAX_TASKS} exploratory testing scenarios.
|
|
297
298
|
For each scenario provide specific steps and expected outcomes.
|
|
299
|
+
Exception: if the page reports the requested resource is missing, shows a failure state, or holds no content and no controls, return an empty scenarios list. Never invent tests for a page with nothing to exercise.
|
|
298
300
|
</task>
|
|
299
301
|
|
|
300
302
|
<rules>
|
|
@@ -308,13 +310,13 @@ export class Planner extends PlannerBase {
|
|
|
308
310
|
Focus on error or success messages as outcome.
|
|
309
311
|
Focus on URL page change or data persistency after page reload.
|
|
310
312
|
If there are subpages (pages with same URL path) plan testing of those subpages as well
|
|
311
|
-
|
|
313
|
+
Plan CRUD operations in order: create, read, update, delete.
|
|
312
314
|
Do not invent specific route names, success messages, validation texts, badge counts, or welcome messages unless they are visible in research, visited pages, or prior observed flows.
|
|
313
315
|
When validation placement or wording was not observed, require feedback associated with the invalid input without inventing a specific location or message.
|
|
314
316
|
If exact wording is unknown, describe the expected result generically, for example "an authentication error is shown" or "the user stays on the login page" instead of guessing the literal text.
|
|
315
317
|
If exact redirect destination is unknown, describe the destination by visible page identity, for example "the dashboard page opens" or "the current workspace home page opens" instead of inventing a URL slug.
|
|
316
318
|
Only propose scenarios whose prerequisites are evident from page research, visited pages, or API data preparation context.
|
|
317
|
-
If a scenario needs existing records, recipients, results, notifications, or other target data, propose it only when that data is visible
|
|
319
|
+
If a scenario needs existing records, recipients, results, notifications, or other target data, propose it only when that data is visible, API preconditions can create it, or the scenario itself creates the record as its setup.
|
|
318
320
|
If the page appears read-only, degraded, demo-limited, maintenance-like, or lacks write controls, prefer read-only scenarios such as opening panels, inspecting visible lists, filtering, searching, or verifying current state.
|
|
319
321
|
Do not assume hidden data exists just because a control is present.
|
|
320
322
|
For scenarios that act on existing items or search/filter by existing values, use only item names or values visible in research, visited pages, or prior observed flows.
|
|
@@ -53,6 +53,8 @@ export class Researcher extends ResearcherBase {
|
|
|
53
53
|
throw new Error('not implemented');
|
|
54
54
|
}
|
|
55
55
|
static getCachedResearch(state) {
|
|
56
|
+
if (state instanceof ActionResult)
|
|
57
|
+
return getCachedResearch(state.baseHash);
|
|
56
58
|
return getCachedResearch(ActionResult.fromState(state).baseHash);
|
|
57
59
|
}
|
|
58
60
|
getSystemMessage() {
|
package/dist/src/ai/rules.js
CHANGED
|
@@ -145,11 +145,11 @@ export const protectionRule = dedent `
|
|
|
145
145
|
|
|
146
146
|
Pre-existing data on the page belongs to the application, not the test.
|
|
147
147
|
Items that were not created inside the current test scenario must not be deleted, removed, emptied, reset, archived, or otherwise destroyed.
|
|
148
|
-
If a scenario needs to verify destructive behaviour, the same scenario must first create
|
|
148
|
+
If a scenario needs to verify destructive behaviour, the same scenario must first create its own target and then destroy that specific target — never operate on data that was already there when the test started.
|
|
149
149
|
|
|
150
150
|
The resource that the current page URL represents is "under test".
|
|
151
151
|
The test must not destroy the resource it is running against — doing so invalidates every subsequent scenario that starts on the same URL.
|
|
152
|
-
Do not propose or perform delete/remove/archive actions on the entity that owns the current URL; propose such actions only on
|
|
152
|
+
Do not propose or perform delete/remove/archive actions on the entity that owns the current URL; propose such actions only on children created within the scenario itself.
|
|
153
153
|
</important>
|
|
154
154
|
`;
|
|
155
155
|
export const dataProtectionRules = dedent `
|
|
@@ -164,7 +164,7 @@ export const dataProtectionRules = dedent `
|
|
|
164
164
|
filter, tab, or list-inspection constraint. Use visible existing data when it is available.
|
|
165
165
|
If no suitable data exists, report the missing precondition instead of creating data.
|
|
166
166
|
|
|
167
|
-
Destructive actions are allowed only against
|
|
167
|
+
Destructive actions are allowed only against data created by the current scenario
|
|
168
168
|
or prepared for that scenario by Fisherman/API preconditions. Existing application data must
|
|
169
169
|
remain unchanged.
|
|
170
170
|
</data_protection_rules>
|
|
@@ -5,7 +5,7 @@ import { dereference } from '@scalar/openapi-parser';
|
|
|
5
5
|
import { tag } from "../utils/logger.js";
|
|
6
6
|
export function validateSpecs(specs) {
|
|
7
7
|
if (!specs?.length) {
|
|
8
|
-
throw new Error('API spec is required.
|
|
8
|
+
throw new Error('API spec is required. Pass --spec, set EXPLORBOT_API_SPEC, or set api.spec in your config file.');
|
|
9
9
|
}
|
|
10
10
|
}
|
|
11
11
|
export async function loadSpec(specPaths, outputDir) {
|
|
@@ -32,7 +32,7 @@ export class ConfigCommand extends BaseCommand {
|
|
|
32
32
|
const dirs = {};
|
|
33
33
|
if (options.root) {
|
|
34
34
|
for (const [name, dir] of Object.entries({ output: 'output', ...config.dirs })) {
|
|
35
|
-
dirs[name] = path.
|
|
35
|
+
dirs[name] = path.resolve(options.root, dir);
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
const env = {};
|
|
@@ -21,7 +21,7 @@ export class DrillCommand extends BaseCommand {
|
|
|
21
21
|
});
|
|
22
22
|
}
|
|
23
23
|
parseKnowledgeArg(args) {
|
|
24
|
-
const match = args.match(/--knowledge\s+(\S+)/);
|
|
24
|
+
const match = args.match(/--save-knowledge\s+(\S+)/);
|
|
25
25
|
return match ? match[1] : undefined;
|
|
26
26
|
}
|
|
27
27
|
parseMaxArg(args) {
|
|
@@ -256,9 +256,15 @@ export class ExploreCommand extends BaseCommand {
|
|
|
256
256
|
tag('info').log(`Exploring sub-page: ${pick.url} (${pick.reason})`);
|
|
257
257
|
try {
|
|
258
258
|
await this.explorBot.visit(pick.url);
|
|
259
|
+
const errorPage = getStateErrorPageError(this.explorBot.stateManager().getCurrentState());
|
|
260
|
+
if (errorPage) {
|
|
261
|
+
tag('warning').log(`Skipping sub-page: ${errorPage.message}`);
|
|
262
|
+
this.failedSubPages.add(normalizeUrl(pick.url));
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
259
265
|
await this.runAllStyles(pick.url, undefined, mainPlan, this.completedPlans, styles);
|
|
260
266
|
const subPlan = this.explorBot.getCurrentPlan();
|
|
261
|
-
if (subPlan && !this.completedPlans.includes(subPlan)) {
|
|
267
|
+
if (subPlan?.tests.length && !this.completedPlans.includes(subPlan)) {
|
|
262
268
|
this.completedPlans.push(subPlan);
|
|
263
269
|
}
|
|
264
270
|
knownUrls.add(normalizeUrl(pick.url));
|
|
@@ -297,6 +303,11 @@ export class ExploreCommand extends BaseCommand {
|
|
|
297
303
|
if (this.dryRun)
|
|
298
304
|
opts.noSave = true;
|
|
299
305
|
await this.planWithRetry(feature, opts, pageUrl);
|
|
306
|
+
const plan = this.explorBot.getCurrentPlan();
|
|
307
|
+
if (plan && plan.tests.length === 0) {
|
|
308
|
+
tag('warning').log('Nothing to test on this page, moving on');
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
300
311
|
await this.runPendingTests();
|
|
301
312
|
this.rememberCurrentPlan();
|
|
302
313
|
fresh = false;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
export declare abstract class BaseOption {
|
|
3
|
+
abstract flags: string;
|
|
4
|
+
abstract description: string;
|
|
5
|
+
collect?: (value: string, previous: any) => any;
|
|
6
|
+
register(program: Command): void;
|
|
7
|
+
abstract apply(options: Record<string, any>, command: Command): void;
|
|
8
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export class BaseOption {
|
|
2
|
+
collect;
|
|
3
|
+
register(program) {
|
|
4
|
+
if (this.collect)
|
|
5
|
+
program.option(this.flags, this.description, this.collect);
|
|
6
|
+
if (!this.collect)
|
|
7
|
+
program.option(this.flags, this.description);
|
|
8
|
+
program.hook('preAction', (_thisCommand, actionCommand) => {
|
|
9
|
+
this.apply(actionCommand.optsWithGlobals(), actionCommand);
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { KnowledgeTracker } from '../../knowledge-tracker.js';
|
|
2
|
+
import { BaseOption } from './base-option.js';
|
|
3
|
+
export class KnowledgeOption extends BaseOption {
|
|
4
|
+
flags = '--knowledge <text>';
|
|
5
|
+
description = 'Knowledge for this run only, not saved to disk. Markdown text; add url: or endpoint: frontmatter to scope it, otherwise it applies everywhere. Repeatable';
|
|
6
|
+
collect = (value, previous = []) => [...previous, value];
|
|
7
|
+
apply(options) {
|
|
8
|
+
for (const text of options.knowledge || []) {
|
|
9
|
+
KnowledgeTracker.appendSessionKnowledge(text);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { remote } from '../../remote.js';
|
|
2
|
+
import { BaseOption } from './base-option.js';
|
|
3
|
+
export class WsOption extends BaseOption {
|
|
4
|
+
flags = '--ws <url>';
|
|
5
|
+
description = 'Stream this run to a remote UI over WebSocket';
|
|
6
|
+
apply(options, command) {
|
|
7
|
+
const url = options.ws || process.env.EXPLORBOT_WS_URL;
|
|
8
|
+
if (!url)
|
|
9
|
+
return;
|
|
10
|
+
remote.attach(String(url), commandPath(command));
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function commandPath(command) {
|
|
14
|
+
const parts = [];
|
|
15
|
+
let node = command;
|
|
16
|
+
while (node) {
|
|
17
|
+
parts.unshift(node.name());
|
|
18
|
+
node = node.parent;
|
|
19
|
+
}
|
|
20
|
+
return parts.slice(1).join(' ') || parts.join(' ');
|
|
21
|
+
}
|
package/dist/src/config.d.ts
CHANGED
|
@@ -255,6 +255,7 @@ export declare class ConfigParser {
|
|
|
255
255
|
static getTestDirectories(): string[];
|
|
256
256
|
static cleanupAllTestDirectories(): void;
|
|
257
257
|
enterGlobalMode(config: ExplorbotConfig, target: string | null): void;
|
|
258
|
+
applyEnvSpec(config: ExplorbotConfig): void;
|
|
258
259
|
buildEnvConfig(baseUrl: string | undefined, outputRoot: string): Promise<ExplorbotConfig>;
|
|
259
260
|
findConfigFile(): string | null;
|
|
260
261
|
loadConfigModule(configPath: string): Promise<any>;
|
package/dist/src/config.js
CHANGED
|
@@ -46,6 +46,7 @@ export const EXPLORBOT_ENV_VARS = [
|
|
|
46
46
|
{ name: 'EXPLORBOT_EPHEMERAL', description: 'Keep no state between runs — output goes to a fresh temp directory instead of the site dir' },
|
|
47
47
|
{ name: 'EXPLORBOT_KNOWLEDGE', description: 'Inline knowledge text, applied to every page' },
|
|
48
48
|
{ name: 'EXPLORBOT_KNOWLEDGE_FILE', description: 'Path to a knowledge markdown file' },
|
|
49
|
+
{ name: 'EXPLORBOT_SPEC', description: 'Docbot application spec directory or index.md, used as page knowledge' },
|
|
49
50
|
{ name: 'EXPLORBOT_API_SPEC', description: 'OpenAPI spec path for the API boat' },
|
|
50
51
|
{ name: 'EXPLORBOT_NO_BANNER', description: 'Suppress the startup banner, for machine-readable output' },
|
|
51
52
|
{ name: 'EXPLORBOT_MAX_DURATION', description: 'Wall-clock budget in minutes for an explore run; same as --max-duration' },
|
|
@@ -129,6 +130,7 @@ export class ConfigParser {
|
|
|
129
130
|
if (resolvedPath && isGlobalConfigPath(resolvedPath)) {
|
|
130
131
|
this.enterGlobalMode(this.config, target);
|
|
131
132
|
}
|
|
133
|
+
this.applyEnvSpec(this.config);
|
|
132
134
|
// Restore original directory after successful config load
|
|
133
135
|
if (options?.path && originalCwd !== process.cwd()) {
|
|
134
136
|
process.chdir(originalCwd);
|
|
@@ -269,8 +271,17 @@ export class ConfigParser {
|
|
|
269
271
|
this.siteStartPath = site.path;
|
|
270
272
|
config.dirs = { knowledge: 'knowledge', experience: 'experience', output: 'output' };
|
|
271
273
|
config.playwright = { ...config.playwright, browser: config.playwright?.browser || 'chromium', url: site.baseUrl };
|
|
274
|
+
materializeKnowledge(this.site.dir);
|
|
272
275
|
log(`Global mode: ${site.baseUrl} stored in ${this.site.dir}`);
|
|
273
276
|
}
|
|
277
|
+
applyEnvSpec(config) {
|
|
278
|
+
const spec = process.env.EXPLORBOT_SPEC;
|
|
279
|
+
if (!spec)
|
|
280
|
+
return;
|
|
281
|
+
if (!config.dirs)
|
|
282
|
+
config.dirs = { knowledge: 'knowledge', experience: 'experience', output: 'output' };
|
|
283
|
+
config.dirs.spec = spec;
|
|
284
|
+
}
|
|
274
285
|
async buildEnvConfig(baseUrl, outputRoot) {
|
|
275
286
|
const provider = process.env.EXPLORBOT_AI_PROVIDER;
|
|
276
287
|
const modelSpec = process.env.EXPLORBOT_AI_MODEL;
|
package/dist/src/explorbot.js
CHANGED
|
@@ -132,7 +132,7 @@ export class ExplorBot {
|
|
|
132
132
|
return this.explorer;
|
|
133
133
|
}
|
|
134
134
|
knowledgeTracker() {
|
|
135
|
-
return (this._knowledgeTracker ||= new KnowledgeTracker(this.options.applicationSpec));
|
|
135
|
+
return (this._knowledgeTracker ||= new KnowledgeTracker({ applicationSpec: this.options.applicationSpec }));
|
|
136
136
|
}
|
|
137
137
|
experienceTracker() {
|
|
138
138
|
return (this._experienceTracker ||= new ExperienceTracker(this.knowledgeTracker()));
|
|
@@ -1,20 +1,19 @@
|
|
|
1
1
|
import { ActionResult } from './action-result.js';
|
|
2
2
|
import { ApplicationSpec } from './application-spec.js';
|
|
3
|
-
export interface Knowledge {
|
|
4
|
-
filePath: string;
|
|
5
|
-
url: string;
|
|
6
|
-
content: string;
|
|
7
|
-
[key: string]: any;
|
|
8
|
-
}
|
|
9
3
|
export declare class KnowledgeTracker {
|
|
10
4
|
knowledgeDir: string;
|
|
11
5
|
knowledgeFiles: Knowledge[];
|
|
6
|
+
sessionKnowledge: Knowledge[];
|
|
12
7
|
isLoaded: boolean;
|
|
13
8
|
applicationSpec?: ApplicationSpec;
|
|
14
|
-
|
|
9
|
+
static appendSessionKnowledge(text: string): void;
|
|
10
|
+
static resetSessionKnowledge(): void;
|
|
11
|
+
constructor(options?: KnowledgeTrackerOptions);
|
|
15
12
|
loadKnowledgeFiles(): void;
|
|
16
13
|
getRelevantKnowledge(state: ActionResult): Knowledge[];
|
|
14
|
+
getEndpointKnowledge(endpoint: string): Knowledge[];
|
|
17
15
|
renderRelevantKnowledge(state: ActionResult): string;
|
|
16
|
+
renderEndpointKnowledge(endpoint: string): string;
|
|
18
17
|
renderRelevantContext(state: ActionResult): string;
|
|
19
18
|
renderApplicationSpec(state: ActionResult): string;
|
|
20
19
|
addKnowledge(urlPattern: string, description: string, opts?: {
|
|
@@ -36,4 +35,18 @@ export declare class KnowledgeTracker {
|
|
|
36
35
|
getMatchingKnowledge(url: string): Knowledge[];
|
|
37
36
|
normalizeUrl(url: string): string;
|
|
38
37
|
getStateParameters(state: ActionResult, keys: string[]): Record<string, any>;
|
|
38
|
+
allKnowledge(): Knowledge[];
|
|
39
|
+
toKnowledge(filePath: string, data: Record<string, any>, content: string): Knowledge;
|
|
40
|
+
renderKnowledge(knowledgeFiles: Knowledge[], scope: string): string;
|
|
41
|
+
}
|
|
42
|
+
export interface Knowledge {
|
|
43
|
+
filePath: string;
|
|
44
|
+
url?: string;
|
|
45
|
+
endpoint?: string;
|
|
46
|
+
content: string;
|
|
47
|
+
[key: string]: any;
|
|
48
|
+
}
|
|
49
|
+
export interface KnowledgeTrackerOptions {
|
|
50
|
+
applicationSpec?: string;
|
|
51
|
+
knowledgeDir?: string;
|
|
39
52
|
}
|
|
@@ -11,61 +11,67 @@ import { loadMarkdownFiles } from './utils/markdown-files.js';
|
|
|
11
11
|
import { mdq } from './utils/markdown-query.js';
|
|
12
12
|
import { isSecretName, registerSecret } from './utils/secrets.js';
|
|
13
13
|
import { slugify } from './utils/strings.js';
|
|
14
|
+
import { extractStatePath, matchesUrl } from './utils/url-matcher.js';
|
|
14
15
|
const debugLog = createDebug('explorbot:knowledge-tracker');
|
|
16
|
+
const sessionEntries = [];
|
|
15
17
|
export class KnowledgeTracker {
|
|
16
18
|
knowledgeDir;
|
|
17
19
|
knowledgeFiles = [];
|
|
20
|
+
sessionKnowledge = [];
|
|
18
21
|
isLoaded = false;
|
|
19
22
|
applicationSpec;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
static appendSessionKnowledge(text) {
|
|
24
|
+
sessionEntries.push(text);
|
|
25
|
+
}
|
|
26
|
+
static resetSessionKnowledge() {
|
|
27
|
+
sessionEntries.length = 0;
|
|
28
|
+
}
|
|
29
|
+
constructor(options = {}) {
|
|
30
|
+
let knowledgeDir = options.knowledgeDir;
|
|
31
|
+
let specPath = options.applicationSpec;
|
|
32
|
+
if (!knowledgeDir) {
|
|
33
|
+
const configParser = ConfigParser.getInstance();
|
|
34
|
+
const config = configParser.getConfig();
|
|
35
|
+
knowledgeDir = configParser.resolveProjectDir(config.dirs?.knowledge || 'knowledge');
|
|
36
|
+
specPath ||= config.dirs?.spec;
|
|
37
|
+
}
|
|
38
|
+
this.knowledgeDir = knowledgeDir;
|
|
24
39
|
if (!existsSync(this.knowledgeDir)) {
|
|
25
40
|
mkdirSync(this.knowledgeDir, { recursive: true });
|
|
26
41
|
}
|
|
27
|
-
const specPath = applicationSpecPath || config.dirs?.spec;
|
|
28
42
|
if (specPath) {
|
|
29
43
|
this.applicationSpec = new ApplicationSpec(specPath);
|
|
30
44
|
tag('info').log(`Loaded application spec with ${this.applicationSpec.pageCount} documented pages`);
|
|
31
45
|
}
|
|
46
|
+
this.sessionKnowledge = sessionEntries.map((entry, index) => {
|
|
47
|
+
const parsed = matter(entry);
|
|
48
|
+
debugLog(`Session knowledge #${index + 1}`);
|
|
49
|
+
return this.toKnowledge(`--knowledge #${index + 1}`, parsed.data, parsed.content.trim());
|
|
50
|
+
});
|
|
32
51
|
}
|
|
33
52
|
loadKnowledgeFiles() {
|
|
34
53
|
if (this.isLoaded)
|
|
35
54
|
return;
|
|
36
55
|
this.knowledgeFiles = [];
|
|
37
56
|
for (const entry of loadMarkdownFiles(this.knowledgeDir, { recursive: true })) {
|
|
38
|
-
this.knowledgeFiles.push(
|
|
39
|
-
filePath: entry.filePath,
|
|
40
|
-
url: entry.data.url || entry.data.path || '*',
|
|
41
|
-
content: this.interpolateVars(entry.content),
|
|
42
|
-
...entry.data,
|
|
43
|
-
});
|
|
57
|
+
this.knowledgeFiles.push(this.toKnowledge(entry.filePath, entry.data, entry.content));
|
|
44
58
|
}
|
|
45
59
|
this.isLoaded = true;
|
|
46
60
|
}
|
|
47
61
|
getRelevantKnowledge(state) {
|
|
48
62
|
this.loadKnowledgeFiles();
|
|
49
|
-
return this.
|
|
50
|
-
|
|
51
|
-
|
|
63
|
+
return this.allKnowledge().filter((knowledge) => knowledge.url && state.isMatchedBy(knowledge));
|
|
64
|
+
}
|
|
65
|
+
getEndpointKnowledge(endpoint) {
|
|
66
|
+
this.loadKnowledgeFiles();
|
|
67
|
+
const path = extractStatePath(endpoint);
|
|
68
|
+
return this.allKnowledge().filter((knowledge) => knowledge.endpoint && matchesUrl(knowledge.endpoint, path));
|
|
52
69
|
}
|
|
53
70
|
renderRelevantKnowledge(state) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
.map((k) => k.content)
|
|
59
|
-
.filter((k) => !!k)
|
|
60
|
-
.join('\n\n');
|
|
61
|
-
tag('operation').log(`Found ${knowledgeFiles.length} relevant knowledge ${pluralize(knowledgeFiles.length, 'file')}`);
|
|
62
|
-
return dedent `
|
|
63
|
-
<knowledge>
|
|
64
|
-
Here is relevant knowledge for this page:
|
|
65
|
-
|
|
66
|
-
${knowledgeContent}
|
|
67
|
-
</knowledge>
|
|
68
|
-
`;
|
|
71
|
+
return this.renderKnowledge(this.getRelevantKnowledge(state), 'page');
|
|
72
|
+
}
|
|
73
|
+
renderEndpointKnowledge(endpoint) {
|
|
74
|
+
return this.renderKnowledge(this.getEndpointKnowledge(endpoint), 'endpoint');
|
|
69
75
|
}
|
|
70
76
|
renderRelevantContext(state) {
|
|
71
77
|
return [this.renderRelevantKnowledge(state), this.renderApplicationSpec(state)].filter(Boolean).join('\n\n');
|
|
@@ -151,7 +157,7 @@ export class KnowledgeTracker {
|
|
|
151
157
|
}
|
|
152
158
|
getExistingUrls() {
|
|
153
159
|
this.loadKnowledgeFiles();
|
|
154
|
-
return this.knowledgeFiles.map((knowledge) => knowledge.url).filter((url) => url && url !== '*');
|
|
160
|
+
return this.knowledgeFiles.map((knowledge) => knowledge.url || '').filter((url) => url && url !== '*');
|
|
155
161
|
}
|
|
156
162
|
getKnowledgeForUrl(urlPattern) {
|
|
157
163
|
this.loadKnowledgeFiles();
|
|
@@ -164,7 +170,7 @@ export class KnowledgeTracker {
|
|
|
164
170
|
const content = knowledge.content.trim();
|
|
165
171
|
const firstLine = mdq(content).meta()[0]?.text.split('\n')[0]?.trim() || '';
|
|
166
172
|
return {
|
|
167
|
-
url: knowledge.url,
|
|
173
|
+
url: knowledge.url || knowledge.endpoint || '',
|
|
168
174
|
firstLine,
|
|
169
175
|
filePath: knowledge.filePath,
|
|
170
176
|
};
|
|
@@ -194,4 +200,36 @@ export class KnowledgeTracker {
|
|
|
194
200
|
}
|
|
195
201
|
return result;
|
|
196
202
|
}
|
|
203
|
+
allKnowledge() {
|
|
204
|
+
return [...this.knowledgeFiles, ...this.sessionKnowledge];
|
|
205
|
+
}
|
|
206
|
+
toKnowledge(filePath, data, content) {
|
|
207
|
+
const knowledge = {
|
|
208
|
+
...data,
|
|
209
|
+
filePath,
|
|
210
|
+
url: data.url || data.path,
|
|
211
|
+
content: this.interpolateVars(content),
|
|
212
|
+
};
|
|
213
|
+
if (!data.url && !data.path && !data.endpoint) {
|
|
214
|
+
knowledge.url = '*';
|
|
215
|
+
knowledge.endpoint = '*';
|
|
216
|
+
}
|
|
217
|
+
return knowledge;
|
|
218
|
+
}
|
|
219
|
+
renderKnowledge(knowledgeFiles, scope) {
|
|
220
|
+
if (knowledgeFiles.length === 0)
|
|
221
|
+
return '';
|
|
222
|
+
const knowledgeContent = knowledgeFiles
|
|
223
|
+
.map((k) => k.content)
|
|
224
|
+
.filter((k) => !!k)
|
|
225
|
+
.join('\n\n');
|
|
226
|
+
tag('operation').log(`Found ${knowledgeFiles.length} relevant knowledge ${pluralize(knowledgeFiles.length, 'file')}`);
|
|
227
|
+
return dedent `
|
|
228
|
+
<knowledge>
|
|
229
|
+
Here is relevant knowledge for this ${scope}:
|
|
230
|
+
|
|
231
|
+
${knowledgeContent}
|
|
232
|
+
</knowledge>
|
|
233
|
+
`;
|
|
234
|
+
}
|
|
197
235
|
}
|
package/dist/src/remote.d.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { Command } from 'commander';
|
|
2
1
|
import { type ActivityEntry } from './activity.js';
|
|
3
2
|
import { type LogDestination, type TaggedLogEntry } from './utils/logger.js';
|
|
4
3
|
/**
|
|
@@ -19,7 +18,6 @@ export declare class Remote implements LogDestination {
|
|
|
19
18
|
asks: Map<string, (value: string | null) => void>;
|
|
20
19
|
askCounter: number;
|
|
21
20
|
lastActivity: string | null;
|
|
22
|
-
registerOption(program: Command): void;
|
|
23
21
|
attach(url: string, command: string): void;
|
|
24
22
|
isAttached(): boolean;
|
|
25
23
|
send(type: string, data?: Record<string, unknown>): void;
|
|
@@ -42,7 +40,6 @@ export declare class Remote implements LogDestination {
|
|
|
42
40
|
* nothing — so skip repeats, the priming null included. */
|
|
43
41
|
reportActivity(activity: ActivityEntry | null): void;
|
|
44
42
|
errorOf(args: any[] | undefined): string | undefined;
|
|
45
|
-
commandPath(command: Command): string;
|
|
46
43
|
}
|
|
47
44
|
export declare const remote: Remote;
|
|
48
45
|
/** Whatever the run wants to say. Not a schema — the UI renders what it knows
|