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
@@ -1,6 +1,10 @@
1
- import { mkdirSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
 
4
+ export const STATUS_FILE = 'status.json';
5
+
6
+ const ARTIFACT_FILES = { aria: 'aria.yml', html: 'page.html', screenshot: 'page.png', network: 'network.jsonl' };
7
+
4
8
  const EXPECTATION_LABELS = {
5
9
  passed: 'PASSED ',
6
10
  failed: 'FAILED ',
@@ -33,7 +37,7 @@ export interface EnvelopeData {
33
37
  failure?: { error: string; compactAria?: string };
34
38
  instance: InstanceInfo;
35
39
  status?: string;
36
- artifacts?: { aria: string; html: string; screenshot?: string; network?: string };
40
+ artifacts?: ArtifactPaths;
37
41
  }
38
42
 
39
43
  export function renderEnvelope(data: EnvelopeData): string {
@@ -41,27 +45,40 @@ export function renderEnvelope(data: EnvelopeData): string {
41
45
  return sections.filter((section) => section).join('\n\n');
42
46
  }
43
47
 
44
- export function writeArtifacts(dir: string, snapshot: { aria: string | null; html: string | null; screenshot?: Buffer; requests: unknown[] }): { aria: string; html: string; screenshot?: string; network?: string } {
48
+ export function writeArtifacts(dir: string, snapshot: { aria: string | null; html: string | null; screenshot?: Buffer; requests: unknown[] }): ArtifactPaths {
45
49
  mkdirSync(dir, { recursive: true });
46
- const paths: { aria: string; html: string; screenshot?: string; network?: string } = {
47
- aria: path.resolve(dir, 'aria.yml'),
48
- html: path.resolve(dir, 'page.html'),
50
+ const paths: ArtifactPaths = {
51
+ aria: path.resolve(dir, ARTIFACT_FILES.aria),
52
+ html: path.resolve(dir, ARTIFACT_FILES.html),
49
53
  };
50
54
  writeFileSync(paths.aria, snapshot.aria ?? '', 'utf-8');
51
55
  writeFileSync(paths.html, snapshot.html ?? '', 'utf-8');
52
56
 
53
57
  if (snapshot.screenshot) {
54
- paths.screenshot = path.resolve(dir, 'page.png');
58
+ paths.screenshot = path.resolve(dir, ARTIFACT_FILES.screenshot);
55
59
  writeFileSync(paths.screenshot, snapshot.screenshot);
56
60
  }
57
61
 
58
62
  if (!snapshot.requests.length) return paths;
59
63
 
60
- paths.network = path.resolve(dir, 'network.jsonl');
64
+ paths.network = path.resolve(dir, ARTIFACT_FILES.network);
61
65
  writeFileSync(paths.network, snapshot.requests.map((request) => `${JSON.stringify(request)}\n`).join(''), 'utf-8');
62
66
  return paths;
63
67
  }
64
68
 
69
+ export function readArtifacts(dir: string): ArtifactPaths {
70
+ const paths: ArtifactPaths = { aria: path.resolve(dir, ARTIFACT_FILES.aria), html: path.resolve(dir, ARTIFACT_FILES.html) };
71
+ if (existsSync(path.resolve(dir, ARTIFACT_FILES.screenshot))) paths.screenshot = path.resolve(dir, ARTIFACT_FILES.screenshot);
72
+ if (existsSync(path.resolve(dir, ARTIFACT_FILES.network))) paths.network = path.resolve(dir, ARTIFACT_FILES.network);
73
+
74
+ const recorded = [...Object.values(ARTIFACT_FILES), STATUS_FILE];
75
+ const files = readdirSync(dir)
76
+ .filter((entry) => !recorded.includes(entry))
77
+ .sort();
78
+ if (files.length) paths.files = files;
79
+ return paths;
80
+ }
81
+
65
82
  function renderResult(data: EnvelopeData): string {
66
83
  const lines = [`ok: ${data.ok}`, `command: ${data.command}`];
67
84
  if (data.used?.length) lines.push(`used: ${data.used.join('; ')}`);
@@ -100,7 +117,7 @@ function renderSteps(data: EnvelopeData): string | null {
100
117
  lines.push(`${index + 1}. ${mark} ${step.label}`);
101
118
  for (const line of (step.proof || '').split('\n').filter(Boolean)) lines.push(` ${line}`);
102
119
  });
103
- if (data.stepFiles) lines.push('', `page after each step: ${data.stepFiles}`);
120
+ if (data.stepFiles) lines.push('', `page after each step: ${data.stepFiles}/<n>-<step>.{aria.yaml,html,diff.yaml}`);
104
121
  return section('Steps', lines.join('\n'));
105
122
  }
106
123
 
@@ -178,6 +195,7 @@ export function renderArtifacts(data: EnvelopeData): string | null {
178
195
  const lines = [`aria: ${data.artifacts.aria}`, `html: ${data.artifacts.html}`];
179
196
  if (data.artifacts.screenshot) lines.push(`screenshot: ${data.artifacts.screenshot}`);
180
197
  if (data.artifacts.network) lines.push(`network: ${data.artifacts.network}`);
198
+ if (data.artifacts.files?.length) lines.push(`also here: ${data.artifacts.files.join(', ')}`);
181
199
  return section('Artifacts', lines.join('\n'));
182
200
  }
183
201
 
@@ -189,3 +207,11 @@ function align(label: string, marker: string, width: number): string {
189
207
  function section(title: string, body: string): string {
190
208
  return `### ${title}\n${body}`;
191
209
  }
210
+
211
+ export interface ArtifactPaths {
212
+ aria: string;
213
+ html: string;
214
+ screenshot?: string;
215
+ network?: string;
216
+ files?: string[];
217
+ }
@@ -25,7 +25,7 @@ import { browserErrorMessage } from '../../../src/utils/browser-errors.ts';
25
25
  import { pluralize } from '../../../src/utils/logger.ts';
26
26
  import { mdq } from '../../../src/utils/markdown-query.ts';
27
27
  import { safeFilename } from '../../../src/utils/strings.ts';
28
- import { type EnvelopeData, type InstanceInfo, writeArtifacts } from './envelope.ts';
28
+ import { type EnvelopeData, type InstanceInfo, STATUS_FILE, readArtifacts, writeArtifacts } from './envelope.ts';
29
29
  import { isFunctionExpression, takePwValue, toCodeceptWrapper } from './pw-parser.ts';
30
30
  import { type PwServerDescriptor, readDescriptors, selectDescriptor } from './pw-registry.ts';
31
31
  import { type SessionRun, latestSessionFile, readSession, recordCommand, sessionFile, sessionsDir } from './session-log.ts';
@@ -376,7 +376,6 @@ export class Prima {
376
376
 
377
377
  const problems = [...unreached.map((expectation) => `not reached: ${expectation.text}`), ...contradicted.map((expectation) => `the picture and the run disagree about: ${expectation.text}`)];
378
378
  if (problems.length) envelope.failure = { error: problems.join('\n') };
379
- if (contradicted.length) envelope.artifacts = this.artifacts;
380
379
 
381
380
  if (!test.hasFinished || test.isSkipped) {
382
381
  envelope.ok = false;
@@ -982,14 +981,17 @@ export class Prima {
982
981
  }
983
982
 
984
983
  private async successEnvelope(command: string, used: string[], result: ActionResult, previousState: WebPageState | null): Promise<EnvelopeData> {
984
+ const changes = await this.pageChanges(result, previousState, used[0]);
985
+ const status = await this.saveStatus(result);
985
986
  return {
986
987
  ok: true,
987
988
  command,
988
989
  used,
989
990
  page: this.pageBlock(result, previousState),
990
- changes: await this.pageChanges(result, previousState, used[0]),
991
+ changes,
991
992
  instance: await this.instanceInfo(),
992
- status: await this.saveStatus(result),
993
+ status,
994
+ artifacts: this.artifacts,
993
995
  };
994
996
  }
995
997
 
@@ -998,24 +1000,28 @@ export class Prima {
998
1000
  const failure: EnvelopeData['failure'] = { error: browserErrorMessage(error) };
999
1001
  if (result.ariaSnapshot) failure.compactAria = compactAriaSnapshot(result.ariaSnapshot, true);
1000
1002
 
1003
+ const status = await this.saveStatus(result);
1001
1004
  return {
1002
1005
  ok: false,
1003
1006
  command,
1004
1007
  page: this.pageBlock(result, previousState),
1005
1008
  failure,
1006
1009
  instance: await this.instanceInfo(),
1007
- status: await this.saveStatus(result),
1010
+ status,
1011
+ artifacts: this.artifacts,
1008
1012
  };
1009
1013
  }
1010
1014
 
1011
1015
  private async reportEnvelope(command: string, result: ActionResult, previousState: WebPageState | null, outcome: Partial<EnvelopeData>): Promise<EnvelopeData> {
1016
+ const status = await this.saveStatus(result);
1012
1017
  return {
1013
1018
  ok: true,
1014
1019
  command,
1015
1020
  page: this.pageBlock(result, previousState),
1016
1021
  ...outcome,
1017
1022
  instance: await this.instanceInfo(),
1018
- status: await this.saveStatus(result),
1023
+ status,
1024
+ artifacts: this.artifacts,
1019
1025
  };
1020
1026
  }
1021
1027
 
@@ -1060,9 +1066,16 @@ export class Prima {
1060
1066
  }
1061
1067
 
1062
1068
  async status(hash: string): Promise<EnvelopeData> {
1069
+ if (!this.artifactsDir) {
1070
+ const sites = listSites();
1071
+ const site = sites.find((candidate) => existsSync(path.join(candidate.dir, 'output', 'prima', hash))) || sites[0];
1072
+ if (site && !this.configBaseUrl()) this.sessionUrl = site.url;
1073
+ await this.loadConfig();
1074
+ }
1075
+
1063
1076
  const dir = this.statusDir(hash);
1064
- const statusFile = path.join(dir, 'status.json');
1065
- if (!existsSync(statusFile)) return this.toolFailureEnvelope(`status ${hash}`, `No command was recorded under ${hash}. Every envelope prints its own hash on the Instance line.`);
1077
+ const statusFile = path.join(dir, STATUS_FILE);
1078
+ if (!existsSync(statusFile)) return this.toolFailureEnvelope(`status ${hash}`, `No command was recorded under ${dir}. Every envelope prints its own hash on the Instance line.`);
1066
1079
 
1067
1080
  const saved = JSON.parse(readFileSync(statusFile, 'utf-8'));
1068
1081
  return {
@@ -1070,14 +1083,14 @@ export class Prima {
1070
1083
  command: `status ${hash}`,
1071
1084
  page: saved.page,
1072
1085
  instance: await this.instanceInfo(),
1073
- artifacts: { aria: path.join(dir, 'aria.yml'), html: path.join(dir, 'page.html') },
1086
+ artifacts: readArtifacts(dir),
1074
1087
  };
1075
1088
  }
1076
1089
 
1077
1090
  private async saveStatus(result: ActionResult): Promise<string> {
1078
1091
  const hash = this.statusHash();
1079
1092
  await this.writeSnapshot(result);
1080
- writeFileSync(path.join(this.statusDir(hash), 'status.json'), JSON.stringify({ page: this.pageBlock(result, null) }), 'utf-8');
1093
+ writeFileSync(path.join(this.statusDir(hash), STATUS_FILE), JSON.stringify({ page: this.pageBlock(result, null) }), 'utf-8');
1081
1094
  return hash;
1082
1095
  }
1083
1096
 
@@ -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();
@@ -114,6 +116,7 @@ addCommonOptions(program
114
116
  .command('explore <path>')
115
117
  .description('Explore a page autonomously and run invented scenarios')
116
118
  .option('--max-tests <count>', 'Maximum number of tests to run')
119
+ .option('--max-duration <minutes>', 'Wall-clock budget in minutes for the whole run; wraps up before the limit is hit')
117
120
  .option('--focus <feature>', 'Focus area for exploration')
118
121
  .option('--configure <spec>', 'Reuse spec: keys new|from|style|subpages|pick_by|priority, e.g. "new:25%;pick_by=random;priority=critical,high"')
119
122
  .option('--dry-run', 'Mark picked tests as skipped without executing or generating new ones')).action(async (explorePath, options) => {
@@ -125,6 +128,10 @@ addCommonOptions(program
125
128
  const cmd = new ExploreCommand(explorBot);
126
129
  if (options.maxTests)
127
130
  cmd.maxTests = Number.parseInt(options.maxTests, 10);
131
+ if (options.maxDuration)
132
+ cmd.maxDurationMinutes = Number.parseInt(options.maxDuration, 10);
133
+ else if (process.env.EXPLORBOT_MAX_DURATION)
134
+ cmd.maxDurationMinutes = Number.parseInt(process.env.EXPLORBOT_MAX_DURATION, 10);
128
135
  if (options.dryRun)
129
136
  cmd.dryRun = true;
130
137
  const execArgs = [];
@@ -219,13 +226,12 @@ addCommonOptions(program.command('plan <path>').description('Generate test plan
219
226
  });
220
227
  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, index) => {
221
228
  try {
222
- const resolvedPath = path.resolve(planfile);
223
- if (!fs.existsSync(resolvedPath)) {
224
- console.error(`Plan file not found: ${resolvedPath}`);
229
+ const plan = Plan.loadFromFile(planfile);
230
+ if (!plan?.filePath) {
231
+ console.error(`Plan file not found: ${planfile}`);
225
232
  process.exit(1);
226
233
  }
227
- const plan = Plan.fromMarkdown(resolvedPath);
228
- const planFile = path.basename(resolvedPath);
234
+ const planFile = path.basename(plan.filePath);
229
235
  if (index) {
230
236
  const idx = Number.parseInt(index, 10);
231
237
  if (Number.isNaN(idx) || idx < 1 || idx > plan.tests.length) {
@@ -236,7 +242,7 @@ addCommonOptions(program.command('plan:load <planfile> [index]').description('Lo
236
242
  const lines = [];
237
243
  lines.push(`## #${idx} ${test.scenario}\n`);
238
244
  lines.push(`**Priority:** ${test.priority}`);
239
- const planUrl = plan.url || plan.tests[0]?.startUrl;
245
+ const planUrl = plan.startUrl;
240
246
  if (planUrl)
241
247
  lines.push(`**Plan URL:** ${planUrl}`);
242
248
  if (test.startUrl && test.startUrl !== planUrl)
@@ -256,7 +262,7 @@ addCommonOptions(program.command('plan:load <planfile> [index]').description('Lo
256
262
  console.log(parseMarkdownToTerminal(lines.join('\n')));
257
263
  return;
258
264
  }
259
- const planUrl = plan.url || plan.tests[0]?.startUrl;
265
+ const planUrl = plan.startUrl;
260
266
  const lines = [`**${plan.title}** (${plan.tests.length} tests)\n`];
261
267
  if (planUrl) {
262
268
  lines.push(`URL: ${planUrl}\n`);
@@ -284,24 +290,25 @@ addCommonOptions(program.command('plan:load <planfile> [index]').description('Lo
284
290
  });
285
291
  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(async (planfile, index, options) => {
286
292
  try {
287
- const explorBot = new ExplorBot(buildExplorBotOptions(undefined, options));
288
- await explorBot.start();
289
293
  let planfileArg = planfile;
290
294
  let indexArg = index;
291
295
  if (options.fromPlan) {
292
296
  planfileArg = options.fromPlan;
293
297
  indexArg = planfile;
294
298
  }
299
+ const planTarget = Plan.loadFromFile(planfileArg)?.startUrl;
300
+ const explorBot = new ExplorBot(buildExplorBotOptions(planTarget, options));
301
+ await explorBot.start();
295
302
  const plan = explorBot.loadPlan(planfileArg);
296
303
  const pending = plan.getPendingTests();
297
304
  log(`Plan loaded: "${plan.title}" (${plan.tests.length} tests, ${pending.length} pending)`);
298
- const startUrl = plan.url || pending[0]?.startUrl;
305
+ const startUrl = plan.startUrl;
299
306
  if (!startUrl) {
300
307
  throw new Error('No URL found in plan or tests. Cannot determine where to navigate.');
301
308
  }
302
309
  log(`Navigating to ${startUrl}`);
303
310
  await explorBot.visit(startUrl);
304
- let args = '';
311
+ let args = '*';
305
312
  if (indexArg)
306
313
  args = indexArg;
307
314
  else if (options.grep)
@@ -387,10 +394,11 @@ addCommonOptions(program
387
394
  .option('--deep', 'Depth-first: prioritize newly discovered pages')
388
395
  .option('--shallow', 'Breadth-first: pick globally least-visited page')
389
396
  .option('--scope <prefix>', 'Restrict navigation to URL prefix')
390
- .option('--max-tests <count>', 'Maximum number of tests to run')).action(async (startUrl, options) => {
397
+ .option('--max-tests <count>', 'Maximum number of tests to run')
398
+ .option('--max-duration <minutes>', 'Wall-clock budget in minutes for the whole run')).action(async (startUrl, options) => {
391
399
  const explorBot = new ExplorBot(buildExplorBotOptions(startUrl || '/', options));
392
400
  await explorBot.start();
393
- const args = [options.deep && '--deep', options.shallow && '--shallow', options.scope && `--scope ${options.scope}`, options.maxTests && `--max-tests ${options.maxTests}`].filter(Boolean).join(' ');
401
+ 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(' ');
394
402
  const { FreesailCommand } = await import('../src/commands/freesail-command.js');
395
403
  const cmd = new FreesailCommand(explorBot);
396
404
  await cmd.execute(args);
@@ -638,13 +646,13 @@ addCommonOptions(program.command('navigate <url>').description('Navigate to a UR
638
646
  await showStatsAndExit(1);
639
647
  }
640
648
  });
641
- 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) => {
642
650
  try {
643
651
  const explorBot = new ExplorBot(buildExplorBotOptions(url, options));
644
652
  await explorBot.start();
645
653
  await explorBot.visit(url);
646
654
  const plan = await explorBot.agentDriller().drill({
647
- knowledgePath: options.knowledge,
655
+ knowledgePath: options.saveKnowledge,
648
656
  maxComponents: Number.parseInt(options.maxComponents || '30', 10),
649
657
  interactive: false,
650
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 };
@@ -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();
@@ -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.js";
5
+ import { remote } from "../../../src/remote.js";
5
6
  import { isVerboseMode, setPreserveConsoleLogs, setQuietMode } from "../../../src/utils/logger.js";
6
7
  import { DocBot } from "./docbot.js";
7
8
  function buildOptions(options) {
@@ -14,6 +15,7 @@ function buildOptions(options) {
14
15
  incognito: options.incognito,
15
16
  session: options.session,
16
17
  docsConfig: options.docsConfig,
18
+ baseUrl: options.url,
17
19
  };
18
20
  }
19
21
  function addCommonOptions(cmd) {
@@ -23,6 +25,7 @@ function addCommonOptions(cmd) {
23
25
  .option('-c, --config <path>', 'Path to explorbot configuration file')
24
26
  .option('--docs-config <path>', 'Path to doc collector configuration file')
25
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)')
26
29
  .option('-s, --show', 'Show browser window')
27
30
  .option('--headless', 'Run browser in headless mode')
28
31
  .option('--incognito', 'Run without recording experiences')
@@ -50,10 +53,12 @@ export function createDocsCommands(name = 'docs') {
50
53
  console.log(`Pages dir: ${path.join(result.outputDir, 'pages')}`);
51
54
  console.log(`Use in Explorbot: npx explorbot start ${startPath} --spec "${result.outputDir}"`);
52
55
  await bot.stop();
56
+ await remote.close(0);
53
57
  process.exit(0);
54
58
  }
55
59
  catch (error) {
56
60
  console.error('Failed:', error instanceof Error ? error.message : 'Unknown error');
61
+ await remote.close(1);
57
62
  process.exit(1);
58
63
  }
59
64
  });
@@ -62,7 +67,7 @@ export function createDocsCommands(name = 'docs') {
62
67
  .action(async (url, options) => {
63
68
  setQuietMode(!isVerboseMode());
64
69
  try {
65
- 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 }));
66
71
  }
67
72
  catch (error) {
68
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,
@@ -409,7 +409,9 @@ class DocBot {
409
409
  saveIndex(startPath, pages, skipped, maxPages) {
410
410
  const outputDir = this.configParser.getOutputDir();
411
411
  const indexPath = path.join(outputDir, 'index.md');
412
- writeFileSync(indexPath, renderSpecIndex(outputDir, startPath, pages, skipped, maxPages), 'utf8');
412
+ const index = renderSpecIndex(outputDir, startPath, pages, skipped, maxPages);
413
+ writeFileSync(indexPath, index, 'utf8');
414
+ tag('data').log('docs', { path: indexPath, content: index });
413
415
  const diagramPath = path.join(outputDir, 'state-diagram.mmd');
414
416
  writeFileSync(diagramPath, renderMermaidBody(outputDir, pages), 'utf8');
415
417
  return { indexPath, diagramPath };
@@ -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();