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
@@ -10,6 +10,7 @@ import React from 'react';
10
10
  import { flushTelemetry } from '../src/ai/provider.js';
11
11
  import { App } from '../src/components/App.js';
12
12
  import { StatusPane } from '../src/components/StatusPane.js';
13
+ import { knowledgeOption, wsOption } from '../src/commands/options/index.js';
13
14
  import { ConfigParser, EXPLORBOT_ENV_VARS, PROVIDERS } from '../src/config.js';
14
15
  import { ExplorBot, type ExplorBotOptions } from '../src/explorbot.js';
15
16
  import { remote } from '../src/remote.js';
@@ -28,7 +29,8 @@ const pkgPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../p
28
29
  const pkgVersion = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version as string;
29
30
 
30
31
  program.name(cli).description('AI-powered web exploration tool').version(pkgVersion, '-V, --version');
31
- remote.registerOption(program);
32
+ wsOption.register(program);
33
+ knowledgeOption.register(program);
32
34
 
33
35
  process.on('uncaughtException', async (error) => {
34
36
  tag('error').log(`Uncaught exception: ${error instanceof Error ? `${error.message}\n${error.stack}` : String(error)}`);
@@ -706,7 +708,7 @@ addCommonOptions(program.command('navigate <url>').description('Navigate to a UR
706
708
  });
707
709
 
708
710
  addCommonOptions(
709
- program.command('drill <url>').alias('driller').description('Drill all components on a page to learn interactions').option('--knowledge <path>', 'Save learned interactions to knowledge file at this URL path').option('--max-components <count>', 'Maximum number of components to drill')
711
+ program.command('drill <url>').alias('driller').description('Drill all components on a page to learn interactions').option('--save-knowledge <path>', 'Save learned interactions to knowledge file at this URL path').option('--max-components <count>', 'Maximum number of components to drill')
710
712
  ).action(async (url, options) => {
711
713
  try {
712
714
  const explorBot = new ExplorBot(buildExplorBotOptions(url, options));
@@ -715,7 +717,7 @@ addCommonOptions(
715
717
  await explorBot.visit(url);
716
718
 
717
719
  const plan = await explorBot.agentDriller().drill({
718
- knowledgePath: options.knowledge,
720
+ knowledgePath: options.saveKnowledge,
719
721
  maxComponents: Number.parseInt(options.maxComponents || '30', 10),
720
722
  interactive: false,
721
723
  });
@@ -3,6 +3,7 @@ import { z } from 'zod';
3
3
  import { Conversation } from '../../../../src/ai/conversation.ts';
4
4
  import { WithSessionDedup } from '../../../../src/ai/planner/session-dedup.ts';
5
5
  import type { AIProvider } from '../../../../src/ai/provider.ts';
6
+ import type { KnowledgeTracker } from '../../../../src/knowledge-tracker.ts';
6
7
  import { Observability } from '../../../../src/observability.ts';
7
8
  import { Plan, Test } from '../../../../src/test-plan.ts';
8
9
  import { createDebug, tag } from '../../../../src/utils/logger.ts';
@@ -33,17 +34,19 @@ export class Chief extends ChiefBase {
33
34
  private provider: AIProvider;
34
35
  private config: ApibotConfig;
35
36
  private apiClient: ApiClient | null;
37
+ private knowledgeTracker?: KnowledgeTracker;
36
38
  currentPlan: Plan | null = null;
37
39
  private lastStyleName = '';
38
40
 
39
41
  MIN_TASKS = 3;
40
42
  MAX_TASKS = 10;
41
43
 
42
- constructor(provider: AIProvider, config: ApibotConfig, apiClient?: ApiClient | null) {
44
+ constructor(provider: AIProvider, config: ApibotConfig, apiClient?: ApiClient | null, knowledgeTracker?: KnowledgeTracker) {
43
45
  super();
44
46
  this.provider = provider;
45
47
  this.config = config;
46
48
  this.apiClient = apiClient || null;
49
+ this.knowledgeTracker = knowledgeTracker;
47
50
  }
48
51
 
49
52
  async plan(endpoint: string, opts?: { style?: string; specDefinition?: string }): Promise<Plan> {
@@ -56,6 +59,9 @@ export class Chief extends ChiefBase {
56
59
  const sampleData = await this.collectSampleData(endpoint);
57
60
  const conversation = this.buildConversation(endpoint, opts?.style, sampleData);
58
61
 
62
+ const knowledge = this.knowledgeTracker?.renderEndpointKnowledge(endpoint);
63
+ if (knowledge) conversation.addUserText(knowledge);
64
+
59
65
  if (opts?.specDefinition) {
60
66
  conversation.addUserText(dedent`
61
67
  <api_spec>
@@ -2,6 +2,7 @@ import dedent from 'dedent';
2
2
  import { z } from 'zod';
3
3
  import type { AIProvider } from '../../../../src/ai/provider.ts';
4
4
  import type { RequestStore } from '../../../../src/api/request-store.ts';
5
+ import type { KnowledgeTracker } from '../../../../src/knowledge-tracker.ts';
5
6
  import type { Reporter } from '../../../../src/reporter.ts';
6
7
  import { type Test, TestResult } from '../../../../src/test-plan.ts';
7
8
  import { createDebug, tag } from '../../../../src/utils/logger.ts';
@@ -18,12 +19,14 @@ export class Curler {
18
19
  private apiClient: ApiClient;
19
20
  private requestState: RequestStore;
20
21
  private reporter: Reporter;
22
+ private knowledgeTracker?: KnowledgeTracker;
21
23
 
22
- constructor(provider: AIProvider, apiClient: ApiClient, requestState: RequestStore, reporter: Reporter) {
24
+ constructor(provider: AIProvider, apiClient: ApiClient, requestState: RequestStore, reporter: Reporter, knowledgeTracker?: KnowledgeTracker) {
23
25
  this.provider = provider;
24
26
  this.apiClient = apiClient;
25
27
  this.requestState = requestState;
26
28
  this.reporter = reporter;
29
+ this.knowledgeTracker = knowledgeTracker;
27
30
  }
28
31
 
29
32
  async test(test: Test, opts?: { specDefinition?: string; baseEndpoint?: string; searchSpec?: (query: string) => string }): Promise<{ success: boolean }> {
@@ -37,6 +40,9 @@ export class Curler {
37
40
  const conversation = this.provider.startConversation(this.buildSystemPrompt(), 'curler', this.provider.getAgenticModel('curler'));
38
41
  const tools = createCurlerTools(this.apiClient, this.requestState, test, opts?.searchSpec);
39
42
 
43
+ const knowledge = test.startUrl && this.knowledgeTracker?.renderEndpointKnowledge(test.startUrl);
44
+ if (knowledge) conversation.addUserText(knowledge);
45
+
40
46
  const initialPrompt = this.buildTestPrompt(test, opts?.specDefinition, opts?.baseEndpoint);
41
47
  conversation.addUserText(initialPrompt);
42
48
 
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { AIProvider } from '../../../src/ai/provider.ts';
4
4
  import { RequestStore } from '../../../src/api/request-store.ts';
5
5
  import { extractEndpointDefinition, loadSpec, searchEndpoints, validateSpecs } from '../../../src/api/spec-reader.ts';
6
+ import { KnowledgeTracker } from '../../../src/knowledge-tracker.ts';
6
7
  import { Reporter } from '../../../src/reporter.ts';
7
8
  import { Plan } from '../../../src/test-plan.ts';
8
9
  import { setVerboseMode, tag } from '../../../src/utils/logger.ts';
@@ -20,6 +21,7 @@ export class ApiBot {
20
21
  private apiClient!: ApiClient;
21
22
  private requestState!: RequestStore;
22
23
  private reporter!: Reporter;
24
+ private knowledgeTracker!: KnowledgeTracker;
23
25
  private options: ApibotOptions;
24
26
  private apiSpec: any;
25
27
 
@@ -33,7 +35,7 @@ export class ApiBot {
33
35
  }
34
36
 
35
37
  async start(): Promise<void> {
36
- this.config = await this.configParser.loadConfig({ config: this.options.config, path: this.options.path, endpoint: this.options.endpoint });
38
+ this.config = await this.configParser.loadConfig(this.options);
37
39
  this.provider = new AIProvider(this.config.ai);
38
40
  await this.provider.validateConnection();
39
41
 
@@ -47,6 +49,7 @@ export class ApiBot {
47
49
  this.configParser.ensureDirectory(outputDir);
48
50
  this.requestState = new RequestStore(outputDir);
49
51
  this.reporter = new Reporter(this.config.reporter);
52
+ this.knowledgeTracker = new KnowledgeTracker({ knowledgeDir: this.configParser.getKnowledgeDir() });
50
53
 
51
54
  validateSpecs(this.config.api.spec);
52
55
  this.apiSpec = await loadSpec(this.config.api.spec!, outputDir);
@@ -84,21 +87,22 @@ export class ApiBot {
84
87
  await this.apiClient?.teardown();
85
88
  }
86
89
 
87
- createAgent<T>(factory: (deps: { ai: AIProvider; config: ApibotConfig; apiClient: ApiClient; requestState: RequestStore }) => T): T {
90
+ createAgent<T>(factory: (deps: { ai: AIProvider; config: ApibotConfig; apiClient: ApiClient; requestState: RequestStore; knowledge: KnowledgeTracker }) => T): T {
88
91
  return factory({
89
92
  ai: this.provider,
90
93
  config: this.config,
91
94
  apiClient: this.apiClient,
92
95
  requestState: this.requestState,
96
+ knowledge: this.knowledgeTracker,
93
97
  });
94
98
  }
95
99
 
96
100
  agentChief(): Chief {
97
- return (this.agents.chief ||= this.createAgent(({ ai, config, apiClient }) => new Chief(ai, config, apiClient)));
101
+ return (this.agents.chief ||= this.createAgent(({ ai, config, apiClient, knowledge }) => new Chief(ai, config, apiClient, knowledge)));
98
102
  }
99
103
 
100
104
  agentCurler(): Curler {
101
- return (this.agents.curler ||= this.createAgent(({ ai, apiClient, requestState }) => new Curler(ai, apiClient, requestState, this.reporter)));
105
+ return (this.agents.curler ||= this.createAgent(({ ai, apiClient, requestState, knowledge }) => new Curler(ai, apiClient, requestState, this.reporter, knowledge)));
102
106
  }
103
107
 
104
108
  async plan(target: string, opts: { style?: string; fresh?: boolean } = {}): Promise<Plan> {
@@ -200,6 +204,8 @@ interface ApibotOptions {
200
204
  config?: string;
201
205
  path?: string;
202
206
  endpoint?: string;
207
+ baseEndpoint?: string;
208
+ spec?: string;
203
209
  }
204
210
 
205
211
  export type { ApibotOptions };
@@ -13,11 +13,19 @@ function buildOptions(options: any): ApibotOptions {
13
13
  verbose: options.verbose || options.debug,
14
14
  config: options.config,
15
15
  path: options.path,
16
+ baseEndpoint: options.endpoint,
17
+ spec: options.spec,
16
18
  };
17
19
  }
18
20
 
19
21
  function addCommonOptions(cmd: Command): Command {
20
- return cmd.option('-v, --verbose', 'Enable verbose logging').option('--debug', 'Enable debug logging').option('-c, --config <path>', 'Path to configuration file').option('-p, --path <path>', 'Working directory path');
22
+ return cmd
23
+ .option('-v, --verbose', 'Enable verbose logging')
24
+ .option('--debug', 'Enable debug logging')
25
+ .option('-c, --config <path>', 'Path to configuration file')
26
+ .option('-p, --path <path>', 'Working directory path')
27
+ .option('--endpoint <url>', 'Base API endpoint to test (env: EXPLORBOT_URL)')
28
+ .option('--spec <path>', 'OpenAPI spec file or URL (env: EXPLORBOT_API_SPEC)');
21
29
  }
22
30
 
23
31
  function selectTests(tests: any[], index?: string): any[] {
@@ -90,8 +98,10 @@ export function createApiCommands(name = 'api'): Command {
90
98
  .action(async (endpoint, options) => {
91
99
  const parser = ApibotConfigParser.getInstance();
92
100
  const [site] = listSites();
101
+ const runOptions = buildOptions(options);
102
+ runOptions.endpoint = endpoint || site?.url;
93
103
  try {
94
- const config = await parser.loadConfig({ config: options.config, path: options.path, endpoint: endpoint || site?.url });
104
+ const config = await parser.loadConfig(runOptions);
95
105
  console.log(ConfigCommand.render(config, { configPath: parser.getConfigPath(), root: parser.getProjectRoot(), json: options.json }));
96
106
  } catch (error) {
97
107
  console.error(error instanceof Error ? error.message : 'Unknown error');
@@ -45,7 +45,7 @@ export class ApibotConfigParser {
45
45
  Object.assign(process.env, parseEnv(readFileSync(resolved, 'utf8')));
46
46
  }
47
47
 
48
- async loadConfig(options?: { config?: string; path?: string; endpoint?: string }): Promise<ApibotConfig> {
48
+ async loadConfig(options?: { config?: string; path?: string; endpoint?: string; baseEndpoint?: string; spec?: string }): Promise<ApibotConfig> {
49
49
  if (this.config && !options?.config && !options?.path) return this.config;
50
50
 
51
51
  const originalCwd = process.cwd();
@@ -55,6 +55,7 @@ export class ApibotConfigParser {
55
55
 
56
56
  ApibotConfigParser.loadEnv(globalEnvPath());
57
57
  ApibotConfigParser.loadEnv('.env');
58
+ this.applyRunOptions(options);
58
59
 
59
60
  const resolvedPath = options?.config || this.findConfigFile();
60
61
  if (!resolvedPath) {
@@ -82,6 +83,8 @@ export class ApibotConfigParser {
82
83
  }
83
84
 
84
85
  this.config = this.mergeWithDefaults(loadedConfig);
86
+ this.applyEnvSpec(this.config.api);
87
+ if (options?.baseEndpoint) this.config.api.baseEndpoint = options.baseEndpoint.replace(/\/$/, '');
85
88
  await resolveConfigModels(this.config.ai);
86
89
  this.configPath = resolvedPath;
87
90
  this.site = null;
@@ -126,6 +129,11 @@ export class ApibotConfigParser {
126
129
 
127
130
  const resolved = resolveSiteTarget(endpoint, this.site.url);
128
131
  if (resolved.baseUrl !== this.site.url) return endpoint;
132
+
133
+ const basePath = new URL(this.getConfig().api.baseEndpoint).pathname.replace(/\/$/, '');
134
+ if (!basePath) return resolved.path;
135
+ if (resolved.path === basePath) return '/';
136
+ if (resolved.path.startsWith(`${basePath}/`)) return resolved.path.slice(basePath.length);
129
137
  return resolved.path;
130
138
  }
131
139
 
@@ -148,13 +156,27 @@ export class ApibotConfigParser {
148
156
  }
149
157
  }
150
158
 
159
+ private applyRunOptions(options?: { baseEndpoint?: string; spec?: string }): void {
160
+ if (options?.baseEndpoint) process.env.EXPLORBOT_URL = options.baseEndpoint;
161
+ if (options?.spec) process.env.EXPLORBOT_API_SPEC = options.spec;
162
+ }
163
+
164
+ private applyEnvSpec(api: ApiConfig): void {
165
+ if (!process.env.EXPLORBOT_API_SPEC) return;
166
+ api.spec = [process.env.EXPLORBOT_API_SPEC];
167
+ }
168
+
151
169
  private enterGlobalMode(config: ApibotConfig, endpoint?: string): void {
152
170
  const site = resolveSiteTarget(endpoint);
153
171
  this.site = registerSite(site.baseUrl);
154
172
 
173
+ let baseEndpoint = site.baseUrl;
174
+ const envUrl = process.env.EXPLORBOT_URL;
175
+ if (envUrl && URL.parse(envUrl)?.origin === site.baseUrl) baseEndpoint = envUrl.replace(/\/$/, '');
176
+
155
177
  config.dirs = { output: 'output', knowledge: 'knowledge' };
156
- config.api = { ...config.api, baseEndpoint: site.baseUrl };
157
- if (process.env.EXPLORBOT_API_SPEC) config.api.spec = [process.env.EXPLORBOT_API_SPEC];
178
+ config.api = { ...config.api, baseEndpoint };
179
+ materializeKnowledge(this.site.dir);
158
180
  }
159
181
 
160
182
  private async loadEnvConfig(): Promise<ApibotConfig> {
@@ -169,16 +191,14 @@ export class ApibotConfigParser {
169
191
 
170
192
  const baseEndpoint = process.env.EXPLORBOT_URL;
171
193
  if (!baseEndpoint) {
172
- throw new Error('No API endpoint to test. Set EXPLORBOT_URL to the API base endpoint');
194
+ throw new Error('No API endpoint to test. Pass --endpoint or set EXPLORBOT_URL to the API base endpoint');
173
195
  }
174
196
 
175
197
  const outputRoot = resolveOutputRoot();
176
198
  materializeKnowledge(outputRoot);
177
199
 
178
200
  const api: ApiConfig = { baseEndpoint };
179
- if (process.env.EXPLORBOT_API_SPEC) {
180
- api.spec = [process.env.EXPLORBOT_API_SPEC];
181
- }
201
+ this.applyEnvSpec(api);
182
202
 
183
203
  let model: any;
184
204
  if (provider && modelSpec) model = await createModel(provider, modelSpec);
@@ -246,7 +266,7 @@ export class ApibotConfigParser {
246
266
  }
247
267
 
248
268
  private mergeWithDefaults(config: Partial<ApibotConfig>): ApibotConfig {
249
- return this.deepMerge({ dirs: { output: 'output' } }, config);
269
+ return this.deepMerge({ dirs: { output: 'output' }, api: {} }, config);
250
270
  }
251
271
 
252
272
  private deepMerge(target: any, source: any): any {
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env bun
2
- import { remote } from '../../../src/remote.ts';
2
+ import { knowledgeOption, wsOption } from '../../../src/commands/options/index.ts';
3
3
  import { createDocsCommands } from '../src/cli.ts';
4
4
 
5
5
  const program = createDocsCommands('doc-collector');
6
- remote.registerOption(program);
6
+ wsOption.register(program);
7
+ knowledgeOption.register(program);
7
8
  program.parse();
@@ -16,6 +16,7 @@ function buildOptions(options: any): DocbotOptions {
16
16
  incognito: options.incognito,
17
17
  session: options.session,
18
18
  docsConfig: options.docsConfig,
19
+ baseUrl: options.url,
19
20
  };
20
21
  }
21
22
 
@@ -26,6 +27,7 @@ function addCommonOptions(cmd: Command): Command {
26
27
  .option('-c, --config <path>', 'Path to explorbot configuration file')
27
28
  .option('--docs-config <path>', 'Path to doc collector configuration file')
28
29
  .option('-p, --path <path>', 'Working directory path')
30
+ .option('--url <url>', 'Base URL of the site, when the path argument is relative (env: EXPLORBOT_URL)')
29
31
  .option('-s, --show', 'Show browser window')
30
32
  .option('--headless', 'Run browser in headless mode')
31
33
  .option('--incognito', 'Run without recording experiences')
@@ -74,7 +76,7 @@ export function createDocsCommands(name = 'docs'): Command {
74
76
  .action(async (url, options) => {
75
77
  setQuietMode(!isVerboseMode());
76
78
  try {
77
- console.log(await ConfigCommand.summary({ config: options.config, path: options.path, url, json: options.json }));
79
+ console.log(await ConfigCommand.summary({ config: options.config, path: options.path, url: url || options.url, json: options.json }));
78
80
  } catch (error) {
79
81
  console.error(error instanceof Error ? error.message : 'Unknown error');
80
82
  process.exit(1);
@@ -24,7 +24,7 @@ class DocBot {
24
24
 
25
25
  constructor(options: DocbotOptions = {}) {
26
26
  this.options = options;
27
- const baseUrl = this.extractAbsoluteBaseUrl(options.startUrl || '/');
27
+ const baseUrl = this.extractAbsoluteBaseUrl(options.startUrl || '/') || options.baseUrl;
28
28
  this.explorBot = new ExplorBot({
29
29
  baseUrl,
30
30
  verbose: options.verbose,
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env bun
2
+ import { knowledgeOption } from '../../../src/commands/options/index.ts';
2
3
  import { createPrimaCommands } from '../src/cli.ts';
3
4
 
4
5
  const program = createPrimaCommands('prima');
6
+ knowledgeOption.register(program);
5
7
  program.parse();
@@ -116,6 +116,7 @@ function addCommonOptions(cmd: Command): Command {
116
116
  .option('--ephemeral', 'Keep no state between runs; applies to config-free runs, where output goes to a temp directory')
117
117
  .option('--framework <name>', 'Not active yet: framework the reported code targets, codeceptjs or playwright')
118
118
  .option('--url <url>', 'Page to open when the session has no page yet')
119
+ .option('--spec <path>', 'Docbot application spec directory or index.md to read as page knowledge')
119
120
  .option('--endpoint <ep>', 'Websocket endpoint of a browser server to attach to, skipping discovery')
120
121
  .option('--pw-session <title>', 'Title of the playwright-cli session to attach to')
121
122
  .addHelpText('after', `\n${sessionHelp}`);
@@ -126,6 +127,7 @@ function primaFor(options: any): Prima {
126
127
  if (options.ephemeral) process.env.EXPLORBOT_EPHEMERAL = '1';
127
128
  if (options.model) process.env.EXPLORBOT_AI_MODEL = options.model;
128
129
  if (options.visionModel) process.env.EXPLORBOT_VISION_MODEL = options.visionModel;
130
+ if (options.spec) process.env.EXPLORBOT_SPEC = options.spec;
129
131
  return new Prima(buildOptions(options));
130
132
  }
131
133
 
@@ -10,6 +10,7 @@ import React from 'react';
10
10
  import { flushTelemetry } from '../src/ai/provider.js';
11
11
  import { App } from '../src/components/App.js';
12
12
  import { StatusPane } from '../src/components/StatusPane.js';
13
+ import { knowledgeOption, wsOption } from '../src/commands/options/index.js';
13
14
  import { ConfigParser, EXPLORBOT_ENV_VARS, PROVIDERS } from '../src/config.js';
14
15
  import { ExplorBot } from '../src/explorbot.js';
15
16
  import { remote } from '../src/remote.js';
@@ -25,7 +26,8 @@ const cli = getCliName();
25
26
  const pkgPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../package.json');
26
27
  const pkgVersion = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version;
27
28
  program.name(cli).description('AI-powered web exploration tool').version(pkgVersion, '-V, --version');
28
- remote.registerOption(program);
29
+ wsOption.register(program);
30
+ knowledgeOption.register(program);
29
31
  process.on('uncaughtException', async (error) => {
30
32
  tag('error').log(`Uncaught exception: ${error instanceof Error ? `${error.message}\n${error.stack}` : String(error)}`);
31
33
  await flushTelemetry();
@@ -644,13 +646,13 @@ addCommonOptions(program.command('navigate <url>').description('Navigate to a UR
644
646
  await showStatsAndExit(1);
645
647
  }
646
648
  });
647
- addCommonOptions(program.command('drill <url>').alias('driller').description('Drill all components on a page to learn interactions').option('--knowledge <path>', 'Save learned interactions to knowledge file at this URL path').option('--max-components <count>', 'Maximum number of components to drill')).action(async (url, options) => {
649
+ addCommonOptions(program.command('drill <url>').alias('driller').description('Drill all components on a page to learn interactions').option('--save-knowledge <path>', 'Save learned interactions to knowledge file at this URL path').option('--max-components <count>', 'Maximum number of components to drill')).action(async (url, options) => {
648
650
  try {
649
651
  const explorBot = new ExplorBot(buildExplorBotOptions(url, options));
650
652
  await explorBot.start();
651
653
  await explorBot.visit(url);
652
654
  const plan = await explorBot.agentDriller().drill({
653
- knowledgePath: options.knowledge,
655
+ knowledgePath: options.saveKnowledge,
654
656
  maxComponents: Number.parseInt(options.maxComponents || '30', 10),
655
657
  interactive: false,
656
658
  });
@@ -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 { createApiCommands } from "../src/cli.js";
4
4
  const program = createApiCommands('apibot');
5
- remote.registerOption(program);
5
+ wsOption.register(program);
6
+ knowledgeOption.register(program);
6
7
  program.parse();
@@ -24,15 +24,17 @@ export class Chief extends ChiefBase {
24
24
  provider;
25
25
  config;
26
26
  apiClient;
27
+ knowledgeTracker;
27
28
  currentPlan = null;
28
29
  lastStyleName = '';
29
30
  MIN_TASKS = 3;
30
31
  MAX_TASKS = 10;
31
- constructor(provider, config, apiClient) {
32
+ constructor(provider, config, apiClient, knowledgeTracker) {
32
33
  super();
33
34
  this.provider = provider;
34
35
  this.config = config;
35
36
  this.apiClient = apiClient || null;
37
+ this.knowledgeTracker = knowledgeTracker;
36
38
  }
37
39
  async plan(endpoint, opts) {
38
40
  tag('info').log(`Planning API tests for ${endpoint}`);
@@ -42,6 +44,9 @@ export class Chief extends ChiefBase {
42
44
  await Observability.run(`chief: ${endpoint}`, { tags: ['chief'], sessionId: endpoint }, async () => {
43
45
  const sampleData = await this.collectSampleData(endpoint);
44
46
  const conversation = this.buildConversation(endpoint, opts?.style, sampleData);
47
+ const knowledge = this.knowledgeTracker?.renderEndpointKnowledge(endpoint);
48
+ if (knowledge)
49
+ conversation.addUserText(knowledge);
45
50
  if (opts?.specDefinition) {
46
51
  conversation.addUserText(dedent `
47
52
  <api_spec>
@@ -11,11 +11,13 @@ export class Curler {
11
11
  apiClient;
12
12
  requestState;
13
13
  reporter;
14
- constructor(provider, apiClient, requestState, reporter) {
14
+ knowledgeTracker;
15
+ constructor(provider, apiClient, requestState, reporter, knowledgeTracker) {
15
16
  this.provider = provider;
16
17
  this.apiClient = apiClient;
17
18
  this.requestState = requestState;
18
19
  this.reporter = reporter;
20
+ this.knowledgeTracker = knowledgeTracker;
19
21
  }
20
22
  async test(test, opts) {
21
23
  tag('info').log(`Testing: ${test.scenario}`);
@@ -25,6 +27,9 @@ export class Curler {
25
27
  await this.reporter.reportTestStart(test);
26
28
  const conversation = this.provider.startConversation(this.buildSystemPrompt(), 'curler', this.provider.getAgenticModel('curler'));
27
29
  const tools = createCurlerTools(this.apiClient, this.requestState, test, opts?.searchSpec);
30
+ const knowledge = test.startUrl && this.knowledgeTracker?.renderEndpointKnowledge(test.startUrl);
31
+ if (knowledge)
32
+ conversation.addUserText(knowledge);
28
33
  const initialPrompt = this.buildTestPrompt(test, opts?.specDefinition, opts?.baseEndpoint);
29
34
  conversation.addUserText(initialPrompt);
30
35
  await loop(async ({ stop, iteration }) => {
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { AIProvider } from "../../../src/ai/provider.js";
4
4
  import { RequestStore } from "../../../src/api/request-store.js";
5
5
  import { extractEndpointDefinition, loadSpec, searchEndpoints, validateSpecs } from "../../../src/api/spec-reader.js";
6
+ import { KnowledgeTracker } from "../../../src/knowledge-tracker.js";
6
7
  import { Reporter } from "../../../src/reporter.js";
7
8
  import { Plan } from "../../../src/test-plan.js";
8
9
  import { setVerboseMode, tag } from "../../../src/utils/logger.js";
@@ -19,6 +20,7 @@ export class ApiBot {
19
20
  apiClient;
20
21
  requestState;
21
22
  reporter;
23
+ knowledgeTracker;
22
24
  options;
23
25
  apiSpec;
24
26
  constructor(options = {}) {
@@ -30,7 +32,7 @@ export class ApiBot {
30
32
  }
31
33
  }
32
34
  async start() {
33
- this.config = await this.configParser.loadConfig({ config: this.options.config, path: this.options.path, endpoint: this.options.endpoint });
35
+ this.config = await this.configParser.loadConfig(this.options);
34
36
  this.provider = new AIProvider(this.config.ai);
35
37
  await this.provider.validateConnection();
36
38
  this.apiClient = new ApiClient(this.config.api.baseEndpoint, this.config.api.headers || {}, {
@@ -42,6 +44,7 @@ export class ApiBot {
42
44
  this.configParser.ensureDirectory(outputDir);
43
45
  this.requestState = new RequestStore(outputDir);
44
46
  this.reporter = new Reporter(this.config.reporter);
47
+ this.knowledgeTracker = new KnowledgeTracker({ knowledgeDir: this.configParser.getKnowledgeDir() });
45
48
  validateSpecs(this.config.api.spec);
46
49
  this.apiSpec = await loadSpec(this.config.api.spec, outputDir);
47
50
  tag('info').log('OpenAPI spec loaded');
@@ -78,13 +81,14 @@ export class ApiBot {
78
81
  config: this.config,
79
82
  apiClient: this.apiClient,
80
83
  requestState: this.requestState,
84
+ knowledge: this.knowledgeTracker,
81
85
  });
82
86
  }
83
87
  agentChief() {
84
- return (this.agents.chief ||= this.createAgent(({ ai, config, apiClient }) => new Chief(ai, config, apiClient)));
88
+ return (this.agents.chief ||= this.createAgent(({ ai, config, apiClient, knowledge }) => new Chief(ai, config, apiClient, knowledge)));
85
89
  }
86
90
  agentCurler() {
87
- return (this.agents.curler ||= this.createAgent(({ ai, apiClient, requestState }) => new Curler(ai, apiClient, requestState, this.reporter)));
91
+ return (this.agents.curler ||= this.createAgent(({ ai, apiClient, requestState, knowledge }) => new Curler(ai, apiClient, requestState, this.reporter, knowledge)));
88
92
  }
89
93
  async plan(target, opts = {}) {
90
94
  if (opts.fresh) {
@@ -12,10 +12,18 @@ function buildOptions(options) {
12
12
  verbose: options.verbose || options.debug,
13
13
  config: options.config,
14
14
  path: options.path,
15
+ baseEndpoint: options.endpoint,
16
+ spec: options.spec,
15
17
  };
16
18
  }
17
19
  function addCommonOptions(cmd) {
18
- return cmd.option('-v, --verbose', 'Enable verbose logging').option('--debug', 'Enable debug logging').option('-c, --config <path>', 'Path to configuration file').option('-p, --path <path>', 'Working directory path');
20
+ return cmd
21
+ .option('-v, --verbose', 'Enable verbose logging')
22
+ .option('--debug', 'Enable debug logging')
23
+ .option('-c, --config <path>', 'Path to configuration file')
24
+ .option('-p, --path <path>', 'Working directory path')
25
+ .option('--endpoint <url>', 'Base API endpoint to test (env: EXPLORBOT_URL)')
26
+ .option('--spec <path>', 'OpenAPI spec file or URL (env: EXPLORBOT_API_SPEC)');
19
27
  }
20
28
  function selectTests(tests, index) {
21
29
  if (!index || index === '*' || index === 'all') {
@@ -76,8 +84,10 @@ export function createApiCommands(name = 'api') {
76
84
  .action(async (endpoint, options) => {
77
85
  const parser = ApibotConfigParser.getInstance();
78
86
  const [site] = listSites();
87
+ const runOptions = buildOptions(options);
88
+ runOptions.endpoint = endpoint || site?.url;
79
89
  try {
80
- const config = await parser.loadConfig({ config: options.config, path: options.path, endpoint: endpoint || site?.url });
90
+ const config = await parser.loadConfig(runOptions);
81
91
  console.log(ConfigCommand.render(config, { configPath: parser.getConfigPath(), root: parser.getProjectRoot(), json: options.json }));
82
92
  }
83
93
  catch (error) {
@@ -39,6 +39,7 @@ export class ApibotConfigParser {
39
39
  }
40
40
  ApibotConfigParser.loadEnv(globalEnvPath());
41
41
  ApibotConfigParser.loadEnv('.env');
42
+ this.applyRunOptions(options);
42
43
  const resolvedPath = options?.config || this.findConfigFile();
43
44
  if (!resolvedPath) {
44
45
  try {
@@ -63,6 +64,9 @@ export class ApibotConfigParser {
63
64
  };
64
65
  }
65
66
  this.config = this.mergeWithDefaults(loadedConfig);
67
+ this.applyEnvSpec(this.config.api);
68
+ if (options?.baseEndpoint)
69
+ this.config.api.baseEndpoint = options.baseEndpoint.replace(/\/$/, '');
66
70
  await resolveConfigModels(this.config.ai);
67
71
  this.configPath = resolvedPath;
68
72
  this.site = null;
@@ -104,6 +108,13 @@ export class ApibotConfigParser {
104
108
  const resolved = resolveSiteTarget(endpoint, this.site.url);
105
109
  if (resolved.baseUrl !== this.site.url)
106
110
  return endpoint;
111
+ const basePath = new URL(this.getConfig().api.baseEndpoint).pathname.replace(/\/$/, '');
112
+ if (!basePath)
113
+ return resolved.path;
114
+ if (resolved.path === basePath)
115
+ return '/';
116
+ if (resolved.path.startsWith(`${basePath}/`))
117
+ return resolved.path.slice(basePath.length);
107
118
  return resolved.path;
108
119
  }
109
120
  getPlansDir() {
@@ -121,13 +132,27 @@ export class ApibotConfigParser {
121
132
  mkdirSync(dirPath, { recursive: true });
122
133
  }
123
134
  }
135
+ applyRunOptions(options) {
136
+ if (options?.baseEndpoint)
137
+ process.env.EXPLORBOT_URL = options.baseEndpoint;
138
+ if (options?.spec)
139
+ process.env.EXPLORBOT_API_SPEC = options.spec;
140
+ }
141
+ applyEnvSpec(api) {
142
+ if (!process.env.EXPLORBOT_API_SPEC)
143
+ return;
144
+ api.spec = [process.env.EXPLORBOT_API_SPEC];
145
+ }
124
146
  enterGlobalMode(config, endpoint) {
125
147
  const site = resolveSiteTarget(endpoint);
126
148
  this.site = registerSite(site.baseUrl);
149
+ let baseEndpoint = site.baseUrl;
150
+ const envUrl = process.env.EXPLORBOT_URL;
151
+ if (envUrl && URL.parse(envUrl)?.origin === site.baseUrl)
152
+ baseEndpoint = envUrl.replace(/\/$/, '');
127
153
  config.dirs = { output: 'output', knowledge: 'knowledge' };
128
- config.api = { ...config.api, baseEndpoint: site.baseUrl };
129
- if (process.env.EXPLORBOT_API_SPEC)
130
- config.api.spec = [process.env.EXPLORBOT_API_SPEC];
154
+ config.api = { ...config.api, baseEndpoint };
155
+ materializeKnowledge(this.site.dir);
131
156
  }
132
157
  async loadEnvConfig() {
133
158
  const provider = process.env.EXPLORBOT_AI_PROVIDER;
@@ -140,14 +165,12 @@ export class ApibotConfigParser {
140
165
  }
141
166
  const baseEndpoint = process.env.EXPLORBOT_URL;
142
167
  if (!baseEndpoint) {
143
- throw new Error('No API endpoint to test. Set EXPLORBOT_URL to the API base endpoint');
168
+ throw new Error('No API endpoint to test. Pass --endpoint or set EXPLORBOT_URL to the API base endpoint');
144
169
  }
145
170
  const outputRoot = resolveOutputRoot();
146
171
  materializeKnowledge(outputRoot);
147
172
  const api = { baseEndpoint };
148
- if (process.env.EXPLORBOT_API_SPEC) {
149
- api.spec = [process.env.EXPLORBOT_API_SPEC];
150
- }
173
+ this.applyEnvSpec(api);
151
174
  let model;
152
175
  if (provider && modelSpec)
153
176
  model = await createModel(provider, modelSpec);
@@ -210,7 +233,7 @@ export class ApibotConfigParser {
210
233
  }
211
234
  }
212
235
  mergeWithDefaults(config) {
213
- return this.deepMerge({ dirs: { output: 'output' } }, config);
236
+ return this.deepMerge({ dirs: { output: 'output' }, api: {} }, config);
214
237
  }
215
238
  deepMerge(target, source) {
216
239
  const result = { ...target };