explorbot 0.3.5 → 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 (155) hide show
  1. package/bin/explorbot-cli.ts +23 -16
  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 +6 -1
  9. package/boat/doc-collector/src/docbot.ts +4 -2
  10. package/boat/prima/bin/prima-cli.ts +2 -0
  11. package/boat/prima/src/cli.ts +23 -8
  12. package/boat/prima/src/envelope.ts +35 -9
  13. package/boat/prima/src/prima.ts +23 -10
  14. package/dist/bin/explorbot-cli.js +24 -16
  15. package/dist/boat/api-tester/bin/apibot-cli.js +3 -2
  16. package/dist/boat/api-tester/src/ai/chief.js +6 -1
  17. package/dist/boat/api-tester/src/ai/curler.js +6 -1
  18. package/dist/boat/api-tester/src/apibot.js +7 -3
  19. package/dist/boat/api-tester/src/cli.js +12 -2
  20. package/dist/boat/api-tester/src/config.js +31 -8
  21. package/dist/boat/doc-collector/bin/doc-collector-cli.js +3 -2
  22. package/dist/boat/doc-collector/src/cli.js +6 -1
  23. package/dist/boat/doc-collector/src/docbot.js +4 -2
  24. package/dist/boat/prima/bin/prima-cli.js +2 -0
  25. package/dist/boat/prima/src/cli.js +22 -8
  26. package/dist/boat/prima/src/envelope.js +24 -6
  27. package/dist/boat/prima/src/prima.js +23 -11
  28. package/dist/package.json +2 -2
  29. package/dist/rules/planner/styles/normal.md +1 -1
  30. package/dist/src/action-result.d.ts +9 -1
  31. package/dist/src/action-result.js +57 -18
  32. package/dist/src/action.d.ts +1 -1
  33. package/dist/src/action.js +87 -12
  34. package/dist/src/ai/captain.js +1 -1
  35. package/dist/src/ai/driller.d.ts +0 -1
  36. package/dist/src/ai/driller.js +8 -20
  37. package/dist/src/ai/fisherman-tools.d.ts +9 -0
  38. package/dist/src/ai/fisherman-tools.js +52 -6
  39. package/dist/src/ai/fisherman.d.ts +4 -2
  40. package/dist/src/ai/fisherman.js +48 -27
  41. package/dist/src/ai/historian/codeceptjs.js +1 -1
  42. package/dist/src/ai/historian/playwright.js +1 -1
  43. package/dist/src/ai/navigator.js +1 -1
  44. package/dist/src/ai/pilot.d.ts +1 -0
  45. package/dist/src/ai/pilot.js +13 -1
  46. package/dist/src/ai/planner.js +9 -7
  47. package/dist/src/ai/provider.js +20 -3
  48. package/dist/src/ai/researcher/deep-analysis.js +1 -3
  49. package/dist/src/ai/researcher.js +5 -3
  50. package/dist/src/ai/rules.js +3 -3
  51. package/dist/src/ai/tester.d.ts +3 -0
  52. package/dist/src/ai/tester.js +40 -3
  53. package/dist/src/ai/tools.d.ts +1 -0
  54. package/dist/src/ai/tools.js +13 -6
  55. package/dist/src/api/request-result.d.ts +2 -0
  56. package/dist/src/api/request-result.js +8 -2
  57. package/dist/src/api/request-store.d.ts +3 -2
  58. package/dist/src/api/request-store.js +66 -14
  59. package/dist/src/api/spec-reader.js +1 -1
  60. package/dist/src/commands/config-command.js +1 -1
  61. package/dist/src/commands/drill-command.js +1 -1
  62. package/dist/src/commands/explore-command.d.ts +6 -0
  63. package/dist/src/commands/explore-command.js +39 -3
  64. package/dist/src/commands/freesail-command.js +10 -1
  65. package/dist/src/commands/options/base-option.d.ts +8 -0
  66. package/dist/src/commands/options/base-option.js +12 -0
  67. package/dist/src/commands/options/index.d.ts +5 -0
  68. package/dist/src/commands/options/index.js +5 -0
  69. package/dist/src/commands/options/knowledge-option.d.ts +7 -0
  70. package/dist/src/commands/options/knowledge-option.js +12 -0
  71. package/dist/src/commands/options/ws-option.d.ts +7 -0
  72. package/dist/src/commands/options/ws-option.js +21 -0
  73. package/dist/src/commands/plans-command.js +6 -6
  74. package/dist/src/config.d.ts +1 -0
  75. package/dist/src/config.js +12 -0
  76. package/dist/src/experience-tracker.js +5 -0
  77. package/dist/src/explorbot.d.ts +0 -1
  78. package/dist/src/explorbot.js +24 -37
  79. package/dist/src/knowledge-tracker.d.ts +20 -7
  80. package/dist/src/knowledge-tracker.js +69 -31
  81. package/dist/src/remote.d.ts +0 -3
  82. package/dist/src/remote.js +0 -18
  83. package/dist/src/state-manager.d.ts +5 -1
  84. package/dist/src/state-manager.js +10 -7
  85. package/dist/src/test-plan.d.ts +3 -0
  86. package/dist/src/test-plan.js +27 -0
  87. package/dist/src/utils/aria.d.ts +1 -1
  88. package/dist/src/utils/aria.js +6 -42
  89. package/dist/src/utils/html-diff.d.ts +4 -0
  90. package/dist/src/utils/html-diff.js +62 -7
  91. package/dist/src/utils/html.d.ts +5 -15
  92. package/dist/src/utils/html.js +14 -85
  93. package/dist/src/utils/overlay.d.ts +56 -11
  94. package/dist/src/utils/overlay.js +191 -21
  95. package/dist/src/utils/request-map.d.ts +7 -0
  96. package/dist/src/utils/request-map.js +16 -0
  97. package/dist/src/utils/url-matcher.js +4 -2
  98. package/docs/api-testing/basics.md +15 -0
  99. package/docs/api-testing/planning.md +10 -1
  100. package/docs/reference/commands.md +32 -5
  101. package/docs/reference/websocket.md +1 -0
  102. package/docs/superpowers/plans/2026-08-29-fisherman-reliability.md +953 -0
  103. package/docs/superpowers/plans/2026-08-29-region-states.md +1292 -0
  104. package/docs/superpowers/plans/2026-08-30-fisherman-live-session-auth.md +457 -0
  105. package/docs/superpowers/specs/2026-08-29-fisherman-reliability-design.md +45 -0
  106. package/docs/superpowers/specs/2026-08-29-region-states-design.md +262 -0
  107. package/docs/superpowers/specs/2026-08-29-region-states-fixes-design.md +269 -0
  108. package/docs/superpowers/specs/2026-08-30-fisherman-live-session-auth-design.md +37 -0
  109. package/docs/workflow/agentic-usage.md +12 -2
  110. package/docs/workflow/ci.md +1 -0
  111. package/docs/workflow/knowledge.md +46 -2
  112. package/package.json +2 -2
  113. package/rules/planner/styles/normal.md +1 -1
  114. package/src/action-result.ts +61 -22
  115. package/src/action.ts +87 -14
  116. package/src/ai/captain.ts +1 -1
  117. package/src/ai/driller.ts +7 -39
  118. package/src/ai/fisherman-tools.ts +56 -7
  119. package/src/ai/fisherman.ts +48 -28
  120. package/src/ai/historian/codeceptjs.ts +1 -1
  121. package/src/ai/historian/playwright.ts +1 -1
  122. package/src/ai/navigator.ts +1 -1
  123. package/src/ai/pilot.ts +9 -1
  124. package/src/ai/planner.ts +9 -8
  125. package/src/ai/provider.ts +21 -3
  126. package/src/ai/researcher/deep-analysis.ts +1 -2
  127. package/src/ai/researcher.ts +4 -3
  128. package/src/ai/rules.ts +3 -3
  129. package/src/ai/tester.ts +40 -3
  130. package/src/ai/tools.ts +17 -9
  131. package/src/api/request-result.ts +10 -2
  132. package/src/api/request-store.ts +60 -13
  133. package/src/api/spec-reader.ts +1 -1
  134. package/src/commands/config-command.ts +1 -1
  135. package/src/commands/drill-command.ts +1 -1
  136. package/src/commands/explore-command.ts +37 -3
  137. package/src/commands/freesail-command.ts +7 -1
  138. package/src/commands/options/base-option.ts +18 -0
  139. package/src/commands/options/index.ts +7 -0
  140. package/src/commands/options/knowledge-option.ts +14 -0
  141. package/src/commands/options/ws-option.ts +24 -0
  142. package/src/commands/plans-command.ts +6 -6
  143. package/src/config.ts +12 -0
  144. package/src/experience-tracker.ts +5 -1
  145. package/src/explorbot.ts +21 -37
  146. package/src/knowledge-tracker.ts +94 -36
  147. package/src/remote.ts +0 -20
  148. package/src/state-manager.ts +13 -7
  149. package/src/test-plan.ts +29 -0
  150. package/src/utils/aria.ts +7 -44
  151. package/src/utils/html-diff.ts +62 -7
  152. package/src/utils/html.ts +14 -91
  153. package/src/utils/overlay.ts +226 -23
  154. package/src/utils/request-map.ts +19 -0
  155. package/src/utils/url-matcher.ts +3 -2
@@ -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)}`);
@@ -146,6 +148,7 @@ addCommonOptions(
146
148
  .command('explore <path>')
147
149
  .description('Explore a page autonomously and run invented scenarios')
148
150
  .option('--max-tests <count>', 'Maximum number of tests to run')
151
+ .option('--max-duration <minutes>', 'Wall-clock budget in minutes for the whole run; wraps up before the limit is hit')
149
152
  .option('--focus <feature>', 'Focus area for exploration')
150
153
  .option('--configure <spec>', 'Reuse spec: keys new|from|style|subpages|pick_by|priority, e.g. "new:25%;pick_by=random;priority=critical,high"')
151
154
  .option('--dry-run', 'Mark picked tests as skipped without executing or generating new ones')
@@ -157,6 +160,8 @@ addCommonOptions(
157
160
  const { ExploreCommand } = await import('../src/commands/explore-command.js');
158
161
  const cmd = new ExploreCommand(explorBot);
159
162
  if (options.maxTests) cmd.maxTests = Number.parseInt(options.maxTests, 10);
163
+ if (options.maxDuration) cmd.maxDurationMinutes = Number.parseInt(options.maxDuration, 10);
164
+ else if (process.env.EXPLORBOT_MAX_DURATION) cmd.maxDurationMinutes = Number.parseInt(process.env.EXPLORBOT_MAX_DURATION, 10);
160
165
  if (options.dryRun) cmd.dryRun = true;
161
166
  const execArgs: string[] = [];
162
167
  if (options.focus) execArgs.push('--focus', `"${options.focus}"`);
@@ -257,14 +262,13 @@ addCommonOptions(program.command('plan <path>').description('Generate test plan
257
262
 
258
263
  addCommonOptions(program.command('plan:load <planfile> [index]').description('Load a plan file and display its tests. Pass index to see test details.')).action(async (planfile: string, index: string | undefined) => {
259
264
  try {
260
- const resolvedPath = path.resolve(planfile);
261
- if (!fs.existsSync(resolvedPath)) {
262
- console.error(`Plan file not found: ${resolvedPath}`);
265
+ const plan = Plan.loadFromFile(planfile);
266
+ if (!plan?.filePath) {
267
+ console.error(`Plan file not found: ${planfile}`);
263
268
  process.exit(1);
264
269
  }
265
270
 
266
- const plan = Plan.fromMarkdown(resolvedPath);
267
- const planFile = path.basename(resolvedPath);
271
+ const planFile = path.basename(plan.filePath);
268
272
 
269
273
  if (index) {
270
274
  const idx = Number.parseInt(index, 10);
@@ -276,7 +280,7 @@ addCommonOptions(program.command('plan:load <planfile> [index]').description('Lo
276
280
  const lines: string[] = [];
277
281
  lines.push(`## #${idx} ${test.scenario}\n`);
278
282
  lines.push(`**Priority:** ${test.priority}`);
279
- const planUrl = plan.url || plan.tests[0]?.startUrl;
283
+ const planUrl = plan.startUrl;
280
284
  if (planUrl) lines.push(`**Plan URL:** ${planUrl}`);
281
285
  if (test.startUrl && test.startUrl !== planUrl) lines.push(`**Test URL:** ${test.startUrl}`);
282
286
  if (test.plannedSteps.length) {
@@ -293,7 +297,7 @@ addCommonOptions(program.command('plan:load <planfile> [index]').description('Lo
293
297
  return;
294
298
  }
295
299
 
296
- const planUrl = plan.url || plan.tests[0]?.startUrl;
300
+ const planUrl = plan.startUrl;
297
301
  const lines: string[] = [`**${plan.title}** (${plan.tests.length} tests)\n`];
298
302
  if (planUrl) {
299
303
  lines.push(`URL: ${planUrl}\n`);
@@ -325,9 +329,6 @@ addCommonOptions(program.command('plan:load <planfile> [index]').description('Lo
325
329
  addCommonOptions(program.command('test <planfile> [index]').description('Execute tests from a plan file. Index: 1, 1,3, 1-5, *, all').option('--grep <pattern>', 'Run tests matching pattern').option('--from-plan <file>', 'Load plan file when the first argument is a test index')).action(
326
330
  async (planfile, index, options) => {
327
331
  try {
328
- const explorBot = new ExplorBot(buildExplorBotOptions(undefined, options));
329
- await explorBot.start();
330
-
331
332
  let planfileArg = planfile;
332
333
  let indexArg = index;
333
334
  if (options.fromPlan) {
@@ -335,11 +336,16 @@ addCommonOptions(program.command('test <planfile> [index]').description('Execute
335
336
  indexArg = planfile;
336
337
  }
337
338
 
339
+ const planTarget = Plan.loadFromFile(planfileArg)?.startUrl;
340
+
341
+ const explorBot = new ExplorBot(buildExplorBotOptions(planTarget, options));
342
+ await explorBot.start();
343
+
338
344
  const plan = explorBot.loadPlan(planfileArg);
339
345
  const pending = plan.getPendingTests();
340
346
  log(`Plan loaded: "${plan.title}" (${plan.tests.length} tests, ${pending.length} pending)`);
341
347
 
342
- const startUrl = plan.url || pending[0]?.startUrl;
348
+ const startUrl = plan.startUrl;
343
349
  if (!startUrl) {
344
350
  throw new Error('No URL found in plan or tests. Cannot determine where to navigate.');
345
351
  }
@@ -347,7 +353,7 @@ addCommonOptions(program.command('test <planfile> [index]').description('Execute
347
353
  log(`Navigating to ${startUrl}`);
348
354
  await explorBot.visit(startUrl);
349
355
 
350
- let args = '';
356
+ let args = '*';
351
357
  if (indexArg) args = indexArg;
352
358
  else if (options.grep) args = options.grep;
353
359
 
@@ -437,10 +443,11 @@ addCommonOptions(
437
443
  .option('--shallow', 'Breadth-first: pick globally least-visited page')
438
444
  .option('--scope <prefix>', 'Restrict navigation to URL prefix')
439
445
  .option('--max-tests <count>', 'Maximum number of tests to run')
446
+ .option('--max-duration <minutes>', 'Wall-clock budget in minutes for the whole run')
440
447
  ).action(async (startUrl, options) => {
441
448
  const explorBot = new ExplorBot(buildExplorBotOptions(startUrl || '/', options));
442
449
  await explorBot.start();
443
- const args = [options.deep && '--deep', options.shallow && '--shallow', options.scope && `--scope ${options.scope}`, options.maxTests && `--max-tests ${options.maxTests}`].filter(Boolean).join(' ');
450
+ const args = [options.deep && '--deep', options.shallow && '--shallow', options.scope && `--scope ${options.scope}`, options.maxTests && `--max-tests ${options.maxTests}`, options.maxDuration && `--max-duration ${options.maxDuration}`].filter(Boolean).join(' ');
444
451
  const { FreesailCommand } = await import('../src/commands/freesail-command.js');
445
452
  const cmd = new FreesailCommand(explorBot);
446
453
  await cmd.execute(args);
@@ -701,7 +708,7 @@ addCommonOptions(program.command('navigate <url>').description('Navigate to a UR
701
708
  });
702
709
 
703
710
  addCommonOptions(
704
- 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')
705
712
  ).action(async (url, options) => {
706
713
  try {
707
714
  const explorBot = new ExplorBot(buildExplorBotOptions(url, options));
@@ -710,7 +717,7 @@ addCommonOptions(
710
717
  await explorBot.visit(url);
711
718
 
712
719
  const plan = await explorBot.agentDriller().drill({
713
- knowledgePath: options.knowledge,
720
+ knowledgePath: options.saveKnowledge,
714
721
  maxComponents: Number.parseInt(options.maxComponents || '30', 10),
715
722
  interactive: false,
716
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();
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { Command } from 'commander';
4
4
  import { ConfigCommand } from '../../../src/commands/config-command.ts';
5
+ import { remote } from '../../../src/remote.ts';
5
6
  import { isVerboseMode, setPreserveConsoleLogs, setQuietMode } from '../../../src/utils/logger.ts';
6
7
  import { DocBot, type DocbotOptions } from './docbot.ts';
7
8
 
@@ -15,6 +16,7 @@ function buildOptions(options: any): DocbotOptions {
15
16
  incognito: options.incognito,
16
17
  session: options.session,
17
18
  docsConfig: options.docsConfig,
19
+ baseUrl: options.url,
18
20
  };
19
21
  }
20
22
 
@@ -25,6 +27,7 @@ function addCommonOptions(cmd: Command): Command {
25
27
  .option('-c, --config <path>', 'Path to explorbot configuration file')
26
28
  .option('--docs-config <path>', 'Path to doc collector configuration file')
27
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)')
28
31
  .option('-s, --show', 'Show browser window')
29
32
  .option('--headless', 'Run browser in headless mode')
30
33
  .option('--incognito', 'Run without recording experiences')
@@ -59,9 +62,11 @@ export function createDocsCommands(name = 'docs'): Command {
59
62
  console.log(`Use in Explorbot: npx explorbot start ${startPath} --spec "${result.outputDir}"`);
60
63
 
61
64
  await bot.stop();
65
+ await remote.close(0);
62
66
  process.exit(0);
63
67
  } catch (error) {
64
68
  console.error('Failed:', error instanceof Error ? error.message : 'Unknown error');
69
+ await remote.close(1);
65
70
  process.exit(1);
66
71
  }
67
72
  });
@@ -71,7 +76,7 @@ export function createDocsCommands(name = 'docs'): Command {
71
76
  .action(async (url, options) => {
72
77
  setQuietMode(!isVerboseMode());
73
78
  try {
74
- 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 }));
75
80
  } catch (error) {
76
81
  console.error(error instanceof Error ? error.message : 'Unknown error');
77
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,
@@ -465,7 +465,9 @@ class DocBot {
465
465
  private saveIndex(startPath: string, pages: DocumentedPage[], skipped: SkippedPage[], maxPages: number): { indexPath: string; diagramPath: string } {
466
466
  const outputDir = this.configParser.getOutputDir();
467
467
  const indexPath = path.join(outputDir, 'index.md');
468
- writeFileSync(indexPath, renderSpecIndex(outputDir, startPath, pages, skipped, maxPages), 'utf8');
468
+ const index = renderSpecIndex(outputDir, startPath, pages, skipped, maxPages);
469
+ writeFileSync(indexPath, index, 'utf8');
470
+ tag('data').log('docs', { path: indexPath, content: index });
469
471
  const diagramPath = path.join(outputDir, 'state-diagram.mmd');
470
472
  writeFileSync(diagramPath, renderMermaidBody(outputDir, pages), 'utf8');
471
473
  return { indexPath, diagramPath };
@@ -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();
@@ -31,9 +31,9 @@ const checkHelp = dedent`
31
31
  as false.
32
32
  Outcomes are settled against a screenshot of the whole page: what a user can see is
33
33
  the proof, and the run log only says what was done. CONTRADICTION means the two
34
- disagree - reported with both sides rather than settled one way, and ### Artifacts
35
- then names the html, aria and screenshot on disk so you can judge it yourself. Not
36
- finding something in the picture is not enough on its own; that is "not verified".
34
+ disagree - reported with both sides rather than settled one way, so read the html,
35
+ aria and screenshot named under ### Artifacts and judge it yourself. Not finding
36
+ something in the picture is not enough on its own; that is "not verified".
37
37
  ok: follows those outcomes - false when one FAILED or CONTRADICTED, or when the run
38
38
  could not complete, which is reported as such rather than as an app failure.
39
39
  Page problems seen on the way appear under ### Answer, not as step failures.
@@ -53,6 +53,13 @@ const verifyHelp = dedent`
53
53
  could not be expressed, which is not the same as false.
54
54
  `;
55
55
 
56
+ const statusHelp = dedent`
57
+ Reads the files a command recorded, so it needs no browser and outlives the session.
58
+ The hash is looked up across every recorded site. ### Artifacts names every file kept
59
+ under it: the aria tree, the html, the screenshot and network log when they were
60
+ captured, and the per-step captures of a do run.
61
+ `;
62
+
56
63
  const reportHelp = dedent`
57
64
  Commands are logged as they run, so the report needs no browser and outlives the session.
58
65
  The most recent session is reported unless --pw-session names another.
@@ -109,6 +116,7 @@ function addCommonOptions(cmd: Command): Command {
109
116
  .option('--ephemeral', 'Keep no state between runs; applies to config-free runs, where output goes to a temp directory')
110
117
  .option('--framework <name>', 'Not active yet: framework the reported code targets, codeceptjs or playwright')
111
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')
112
120
  .option('--endpoint <ep>', 'Websocket endpoint of a browser server to attach to, skipping discovery')
113
121
  .option('--pw-session <title>', 'Title of the playwright-cli session to attach to')
114
122
  .addHelpText('after', `\n${sessionHelp}`);
@@ -119,10 +127,11 @@ function primaFor(options: any): Prima {
119
127
  if (options.ephemeral) process.env.EXPLORBOT_EPHEMERAL = '1';
120
128
  if (options.model) process.env.EXPLORBOT_AI_MODEL = options.model;
121
129
  if (options.visionModel) process.env.EXPLORBOT_VISION_MODEL = options.visionModel;
130
+ if (options.spec) process.env.EXPLORBOT_SPEC = options.spec;
122
131
  return new Prima(buildOptions(options));
123
132
  }
124
133
 
125
- async function runPrima(options: any, command: string, run: (prima: Prima) => Promise<EnvelopeData>, record = true): Promise<void> {
134
+ async function runPrima(options: any, command: string, run: (prima: Prima) => Promise<EnvelopeData>): Promise<void> {
126
135
  setQuietMode(!isVerboseMode());
127
136
  trackActivityLine();
128
137
  const prima = primaFor(options);
@@ -136,7 +145,7 @@ async function runPrima(options: any, command: string, run: (prima: Prima) => Pr
136
145
  envelope = await prima.toolFailureEnvelope(command, error);
137
146
  }
138
147
 
139
- if (record) prima.record(envelope, Date.now() - startedAt);
148
+ prima.record(envelope, Date.now() - startedAt);
140
149
  clearActivityLine();
141
150
  console.log(renderEnvelope(envelope));
142
151
  await prima.stop().catch(() => {});
@@ -211,9 +220,15 @@ export function createPrimaCommands(name = 'prima'): Command {
211
220
  process.exit(0);
212
221
  });
213
222
 
214
- addCommonOptions(cmd.command('status <hash>').description('Show the artifacts and page detail recorded for an earlier command')).action(async (hash, options) => {
215
- await runPrima(options, `status ${hash}`, (prima) => prima.status(hash), false);
216
- });
223
+ addCommonOptions(cmd.command('status <hash>').description('Show the artifacts and page detail recorded for an earlier command'))
224
+ .addHelpText('after', `\n${statusHelp}`)
225
+ .action(async (hash, options) => {
226
+ setQuietMode(!isVerboseMode());
227
+ const prima = primaFor(options);
228
+ const envelope = await prima.status(hash).catch((error: unknown) => prima.toolFailureEnvelope(`status ${hash}`, error));
229
+ console.log(renderEnvelope(envelope));
230
+ process.exit(envelope.ok ? 0 : 1);
231
+ });
217
232
 
218
233
  addCommonOptions(cmd.command('report').description('Turn every command of a session into one html and markdown report'))
219
234
  .addHelpText('after', `\n${reportHelp}`)