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.
Files changed (73) hide show
  1. package/bin/explorbot-cli.ts +5 -3
  2. package/boat/api-tester/src/ai/chief.ts +7 -1
  3. package/boat/api-tester/src/ai/curler.ts +7 -1
  4. package/boat/api-tester/src/apibot.ts +10 -4
  5. package/boat/api-tester/src/cli.ts +12 -2
  6. package/boat/api-tester/src/config.ts +28 -8
  7. package/boat/doc-collector/bin/doc-collector-cli.ts +3 -2
  8. package/boat/doc-collector/src/cli.ts +3 -1
  9. package/boat/doc-collector/src/docbot.ts +1 -1
  10. package/boat/prima/bin/prima-cli.ts +2 -0
  11. package/boat/prima/src/cli.ts +2 -0
  12. package/dist/bin/explorbot-cli.js +5 -3
  13. package/dist/boat/api-tester/bin/apibot-cli.js +3 -2
  14. package/dist/boat/api-tester/src/ai/chief.js +6 -1
  15. package/dist/boat/api-tester/src/ai/curler.js +6 -1
  16. package/dist/boat/api-tester/src/apibot.js +7 -3
  17. package/dist/boat/api-tester/src/cli.js +12 -2
  18. package/dist/boat/api-tester/src/config.js +31 -8
  19. package/dist/boat/doc-collector/bin/doc-collector-cli.js +3 -2
  20. package/dist/boat/doc-collector/src/cli.js +3 -1
  21. package/dist/boat/doc-collector/src/docbot.js +1 -1
  22. package/dist/boat/prima/bin/prima-cli.js +2 -0
  23. package/dist/boat/prima/src/cli.js +3 -0
  24. package/dist/package.json +1 -1
  25. package/dist/rules/planner/styles/normal.md +1 -1
  26. package/dist/src/ai/captain.js +1 -1
  27. package/dist/src/ai/navigator.js +1 -1
  28. package/dist/src/ai/planner.js +9 -7
  29. package/dist/src/ai/researcher.js +2 -0
  30. package/dist/src/ai/rules.js +3 -3
  31. package/dist/src/api/spec-reader.js +1 -1
  32. package/dist/src/commands/config-command.js +1 -1
  33. package/dist/src/commands/drill-command.js +1 -1
  34. package/dist/src/commands/explore-command.js +12 -1
  35. package/dist/src/commands/options/base-option.d.ts +8 -0
  36. package/dist/src/commands/options/base-option.js +12 -0
  37. package/dist/src/commands/options/index.d.ts +5 -0
  38. package/dist/src/commands/options/index.js +5 -0
  39. package/dist/src/commands/options/knowledge-option.d.ts +7 -0
  40. package/dist/src/commands/options/knowledge-option.js +12 -0
  41. package/dist/src/commands/options/ws-option.d.ts +7 -0
  42. package/dist/src/commands/options/ws-option.js +21 -0
  43. package/dist/src/config.d.ts +1 -0
  44. package/dist/src/config.js +11 -0
  45. package/dist/src/explorbot.js +1 -1
  46. package/dist/src/knowledge-tracker.d.ts +20 -7
  47. package/dist/src/knowledge-tracker.js +69 -31
  48. package/dist/src/remote.d.ts +0 -3
  49. package/dist/src/remote.js +0 -18
  50. package/docs/api-testing/basics.md +15 -0
  51. package/docs/api-testing/planning.md +10 -1
  52. package/docs/reference/commands.md +24 -4
  53. package/docs/workflow/agentic-usage.md +11 -2
  54. package/docs/workflow/knowledge.md +46 -2
  55. package/package.json +1 -1
  56. package/rules/planner/styles/normal.md +1 -1
  57. package/src/ai/captain.ts +1 -1
  58. package/src/ai/navigator.ts +1 -1
  59. package/src/ai/planner.ts +9 -8
  60. package/src/ai/researcher.ts +1 -0
  61. package/src/ai/rules.ts +3 -3
  62. package/src/api/spec-reader.ts +1 -1
  63. package/src/commands/config-command.ts +1 -1
  64. package/src/commands/drill-command.ts +1 -1
  65. package/src/commands/explore-command.ts +12 -1
  66. package/src/commands/options/base-option.ts +18 -0
  67. package/src/commands/options/index.ts +7 -0
  68. package/src/commands/options/knowledge-option.ts +14 -0
  69. package/src/commands/options/ws-option.ts +24 -0
  70. package/src/config.ts +11 -0
  71. package/src/explorbot.ts +1 -1
  72. package/src/knowledge-tracker.ts +94 -36
  73. package/src/remote.ts +0 -20
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env bun
2
- import { remote } from "../../../src/remote.js";
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
- remote.registerOption(program);
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,
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env node
2
+ import { knowledgeOption } from "../../../src/commands/options/index.js";
2
3
  import { createPrimaCommands } from "../src/cli.js";
3
4
  const program = createPrimaCommands('prima');
5
+ knowledgeOption.register(program);
4
6
  program.parse();
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -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 CRUD operations end-to-end (create item → verify in list, edit item verify changes saved, delete item verify removed)
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
@@ -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(state);
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
  }
@@ -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(state) || '';
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();
@@ -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
- NEVER split one workflow into multiple tests. Each test must be a complete end-to-end flow.
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
- If two scenarios cannot run independently (one requires the other to run first), merge them into one.${featureDirective}${focusExistingDataDirective}
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
- If you plan to test CRUD operations, plan them in correct order: create, read, update.
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 or API preconditions can create it.
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() {
@@ -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 a disposable target and then destroy that specific target — never operate on data that was already there when the test started.
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 disposable children created within the scenario itself.
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 disposable data created by the current scenario
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. Set api.spec in your config file.');
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.join(options.root, dir);
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,5 @@
1
+ import { KnowledgeOption } from './knowledge-option.js';
2
+ import { WsOption } from './ws-option.js';
3
+ export { BaseOption } from './base-option.js';
4
+ export declare const knowledgeOption: KnowledgeOption;
5
+ export declare const wsOption: WsOption;
@@ -0,0 +1,5 @@
1
+ import { KnowledgeOption } from './knowledge-option.js';
2
+ import { WsOption } from './ws-option.js';
3
+ export { BaseOption } from './base-option.js';
4
+ export const knowledgeOption = new KnowledgeOption();
5
+ export const wsOption = new WsOption();
@@ -0,0 +1,7 @@
1
+ import { BaseOption } from './base-option.js';
2
+ export declare class KnowledgeOption extends BaseOption {
3
+ flags: string;
4
+ description: string;
5
+ collect: (value: string, previous?: string[]) => string[];
6
+ apply(options: Record<string, any>): void;
7
+ }
@@ -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,7 @@
1
+ import type { Command } from 'commander';
2
+ import { BaseOption } from './base-option.js';
3
+ export declare class WsOption extends BaseOption {
4
+ flags: string;
5
+ description: string;
6
+ apply(options: Record<string, any>, command: Command): void;
7
+ }
@@ -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
+ }
@@ -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>;
@@ -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;
@@ -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
- constructor(applicationSpecPath?: string);
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
- constructor(applicationSpecPath) {
21
- const configParser = ConfigParser.getInstance();
22
- const config = configParser.getConfig();
23
- this.knowledgeDir = configParser.resolveProjectDir(config.dirs?.knowledge || 'knowledge');
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.knowledgeFiles.filter((knowledge) => {
50
- return state.isMatchedBy(knowledge);
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
- const knowledgeFiles = this.getRelevantKnowledge(state);
55
- if (knowledgeFiles.length === 0)
56
- return '';
57
- const knowledgeContent = knowledgeFiles
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
  }
@@ -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