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,8 +1,9 @@
1
1
  import { existsSync, readdirSync } from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { isDynamicSegment } from '../utils/url-matcher.ts';
3
4
  import { RequestResult } from './request-result.ts';
4
5
 
5
- const AUTH_HEADERS = ['authorization', 'cookie', 'x-api-key', 'x-csrf-token'];
6
+ const AUTH_HEADERS = ['authorization', 'x-api-key', 'x-csrf-token'];
6
7
 
7
8
  export class RequestStore {
8
9
  private capturedRequests: RequestResult[] = [];
@@ -10,6 +11,7 @@ export class RequestStore {
10
11
  private failedRequests: RequestResult[] = [];
11
12
  private onFailedListeners: Array<(r: RequestResult) => void> = [];
12
13
  private outputDir: string;
14
+ private sessionStartedAt = new Date();
13
15
 
14
16
  constructor(outputDir: string) {
15
17
  this.outputDir = outputDir;
@@ -77,13 +79,15 @@ export class RequestStore {
77
79
  return this.madeRequests.filter((r) => r.status === status);
78
80
  }
79
81
 
80
- toEndpointList(): string {
82
+ toEndpointList(scopePath?: string): string {
83
+ let requests = this.capturedRequests;
84
+ if (scopePath) requests = this.getWriteRequestsForScope(scopePath);
85
+
81
86
  const seen = new Set<string>();
82
87
  const lines: string[] = [];
83
88
 
84
- for (const req of this.capturedRequests) {
85
- const normalized = normalizePathPattern(req.path);
86
- const key = `${req.method} ${normalized}`;
89
+ for (const req of requests) {
90
+ const key = `${req.method} ${normalizePathPattern(req.path)}`;
87
91
  if (seen.has(key)) continue;
88
92
  seen.add(key);
89
93
  lines.push(key);
@@ -94,23 +98,42 @@ export class RequestStore {
94
98
 
95
99
  extractAuthHeaders(): Record<string, string> {
96
100
  const headers: Record<string, string> = {};
101
+ const sessionCaptures = this.capturedRequests.filter((r) => r.timestamp >= this.sessionStartedAt).sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
97
102
 
98
- for (let i = this.capturedRequests.length - 1; i >= 0; i--) {
99
- const req = this.capturedRequests[i];
103
+ for (const req of sessionCaptures) {
100
104
  for (const [key, value] of Object.entries(req.requestHeaders)) {
101
105
  if (AUTH_HEADERS.includes(key.toLowerCase()) && !headers[key]) {
102
106
  headers[key] = value;
103
107
  }
104
108
  }
105
- if (AUTH_HEADERS.every((h) => Object.keys(headers).some((k) => k.toLowerCase() === h))) break;
106
109
  }
107
110
 
108
111
  return headers;
109
112
  }
110
113
 
111
- findCapturedRequest(method: string, pathPrefix: string): RequestResult | undefined {
114
+ findCapturedRequest(method: string, searchPath: string): RequestResult | undefined {
112
115
  const upper = method.toUpperCase();
113
- return this.capturedRequests.find((r) => r.method === upper && r.path.startsWith(pathPrefix));
116
+ const search = normalizePathPattern(searchPath).split('/').filter(Boolean);
117
+
118
+ let best: RequestResult | undefined;
119
+ let bestScore = -1;
120
+
121
+ for (const req of this.capturedRequests) {
122
+ if (req.method !== upper) continue;
123
+ const segments = normalizePathPattern(req.path).split('/').filter(Boolean);
124
+ if (segments.length < search.length) continue;
125
+ if (!search.every((segment, i) => segment === segments[i])) continue;
126
+
127
+ let score = 0;
128
+ if (segments.length === search.length) score += 4;
129
+ if (req.status < 400) score += 2;
130
+ if (score < bestScore) continue;
131
+ if (score === bestScore && best && req.timestamp <= best.timestamp) continue;
132
+ best = req;
133
+ bestScore = score;
134
+ }
135
+
136
+ return best;
114
137
  }
115
138
 
116
139
  toLog(): string {
@@ -122,7 +145,7 @@ export class RequestStore {
122
145
  if (!existsSync(requestsDir)) return;
123
146
 
124
147
  const existingIds = new Set(this.capturedRequests.map((r) => r.id));
125
- const files = readdirSync(requestsDir).filter((f) => f.endsWith('.request.yaml'));
148
+ const files = readdirSync(requestsDir).filter((f) => f.startsWith('xhr_') && f.endsWith('.request.yaml'));
126
149
 
127
150
  for (const file of files) {
128
151
  try {
@@ -137,7 +160,28 @@ export class RequestStore {
137
160
 
138
161
  getWriteRequestsForScope(scopePath: string): RequestResult[] {
139
162
  const writeMethods = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
140
- return this.capturedRequests.filter((r) => writeMethods.has(r.method) && r.path.startsWith(scopePath));
163
+ const writes = this.capturedRequests.filter((r) => writeMethods.has(r.method));
164
+ const scopeSegments = scopePath.split('/').filter(Boolean);
165
+ if (scopeSegments.length === 0) return writes;
166
+
167
+ let scoped: RequestResult[] = [];
168
+ let fewest = Number.POSITIVE_INFINITY;
169
+ let ambiguous = false;
170
+ for (const segment of scopeSegments) {
171
+ if (isDynamicSegment(segment)) continue;
172
+ const matches = writes.filter((r) => r.path.split('/').includes(segment));
173
+ if (matches.length === 0 || matches.length > fewest) continue;
174
+ if (matches.length === fewest) {
175
+ if (!scoped.every((r, i) => r.id === matches[i].id)) ambiguous = true;
176
+ continue;
177
+ }
178
+ scoped = matches;
179
+ fewest = matches.length;
180
+ ambiguous = false;
181
+ }
182
+ if (ambiguous) return [];
183
+
184
+ return scoped;
141
185
  }
142
186
 
143
187
  clear(): void {
@@ -148,5 +192,8 @@ export class RequestStore {
148
192
  }
149
193
 
150
194
  function normalizePathPattern(urlPath: string): string {
151
- return urlPath.replace(/\/[0-9a-f]{24}\b/g, '/{id}').replace(/\/\d+\b/g, '/{id}');
195
+ return urlPath
196
+ .split('/')
197
+ .map((segment) => (segment && isDynamicSegment(segment) ? '{id}' : segment))
198
+ .join('/');
152
199
  }
@@ -6,7 +6,7 @@ import { tag } from '../utils/logger.ts';
6
6
 
7
7
  export function validateSpecs(specs?: string[]): void {
8
8
  if (!specs?.length) {
9
- throw new Error('API spec is required. Set api.spec in your config file.');
9
+ throw new Error('API spec is required. Pass --spec, set EXPLORBOT_API_SPEC, or set api.spec in your config file.');
10
10
  }
11
11
  }
12
12
 
@@ -37,7 +37,7 @@ export class ConfigCommand extends BaseCommand {
37
37
  const dirs: Record<string, string> = {};
38
38
  if (options.root) {
39
39
  for (const [name, dir] of Object.entries({ output: 'output', ...config.dirs })) {
40
- dirs[name] = path.join(options.root, dir);
40
+ dirs[name] = path.resolve(options.root, dir);
41
41
  }
42
42
  }
43
43
 
@@ -26,7 +26,7 @@ export class DrillCommand extends BaseCommand {
26
26
  }
27
27
 
28
28
  private parseKnowledgeArg(args: string): string | undefined {
29
- const match = args.match(/--knowledge\s+(\S+)/);
29
+ const match = args.match(/--save-knowledge\s+(\S+)/);
30
30
  return match ? match[1] : undefined;
31
31
  }
32
32
 
@@ -14,12 +14,15 @@ import { BaseCommand, type Suggestion } from './base-command.js';
14
14
 
15
15
  const MAX_SUB_PAGE_ATTEMPTS = 30;
16
16
  const PRIORITY_ORDER: Record<string, number> = { critical: 0, important: 1, high: 2, normal: 3, low: 4 };
17
+ export const DEADLINE_RESERVE_MS = 3 * 60_000;
18
+ export const DEADLINE_TEST_ALLOWANCE_MS = 5 * 60_000;
17
19
 
18
20
  export class ExploreCommand extends BaseCommand {
19
21
  name = 'explore';
20
22
  description = 'Start web exploration';
21
23
  options = [
22
24
  { flags: '--max-tests <number>', description: 'Maximum number of tests to run' },
25
+ { flags: '--max-duration <minutes>', description: 'Wall-clock budget in minutes; wraps up the session before the limit is hit' },
23
26
  { flags: '--focus <feature>', description: 'Focus area for exploration' },
24
27
  { flags: '--configure <spec>', description: 'Reuse spec: keys new|from|style|subpages|pick_by|priority, e.g. "new:25%;pick_by=random;priority=critical,high"' },
25
28
  { flags: '--dry-run', description: 'Mark picked tests as skipped without executing or generating new ones' },
@@ -31,8 +34,11 @@ export class ExploreCommand extends BaseCommand {
31
34
  ];
32
35
 
33
36
  maxTests?: number;
37
+ maxDurationMinutes?: number;
38
+ hardDeadlineAt?: number;
34
39
  dryRun = false;
35
40
  private testsRun = 0;
41
+ private deadlineLogged = false;
36
42
  private completedPlans: Plan[] = [];
37
43
  private failedSubPages = new Set<string>();
38
44
  private oldTestRefs = new Set<Test>();
@@ -48,6 +54,12 @@ export class ExploreCommand extends BaseCommand {
48
54
  if (opts.maxTests) {
49
55
  this.maxTests = Number.parseInt(opts.maxTests as string, 10);
50
56
  }
57
+ if (opts.maxDuration) {
58
+ this.maxDurationMinutes = Number.parseInt(opts.maxDuration as string, 10);
59
+ }
60
+ if (this.hardDeadlineAt == null && this.maxDurationMinutes != null) {
61
+ this.hardDeadlineAt = Date.now() + this.maxDurationMinutes * 60_000 - DEADLINE_RESERVE_MS;
62
+ }
51
63
 
52
64
  const feature = (opts.focus as string) || remaining.join(' ') || undefined;
53
65
  const cfg = this.parseConfigure(opts.configure as string | undefined);
@@ -249,9 +261,15 @@ export class ExploreCommand extends BaseCommand {
249
261
  tag('info').log(`Exploring sub-page: ${pick.url} (${pick.reason})`);
250
262
  try {
251
263
  await this.explorBot.visit(pick.url);
264
+ const errorPage = getStateErrorPageError(this.explorBot.stateManager().getCurrentState());
265
+ if (errorPage) {
266
+ tag('warning').log(`Skipping sub-page: ${errorPage.message}`);
267
+ this.failedSubPages.add(normalizeUrl(pick.url));
268
+ continue;
269
+ }
252
270
  await this.runAllStyles(pick.url, undefined, mainPlan, this.completedPlans, styles);
253
271
  const subPlan = this.explorBot.getCurrentPlan();
254
- if (subPlan && !this.completedPlans.includes(subPlan)) {
272
+ if (subPlan?.tests.length && !this.completedPlans.includes(subPlan)) {
255
273
  this.completedPlans.push(subPlan);
256
274
  }
257
275
  knownUrls.add(normalizeUrl(pick.url));
@@ -286,6 +304,11 @@ export class ExploreCommand extends BaseCommand {
286
304
  if (fresh && parentPlan) opts.extend = parentPlan;
287
305
  if (this.dryRun) opts.noSave = true;
288
306
  await this.planWithRetry(feature, opts, pageUrl);
307
+ const plan = this.explorBot.getCurrentPlan();
308
+ if (plan && plan.tests.length === 0) {
309
+ tag('warning').log('Nothing to test on this page, moving on');
310
+ return;
311
+ }
289
312
  await this.runPendingTests();
290
313
  this.rememberCurrentPlan();
291
314
  fresh = false;
@@ -526,7 +549,18 @@ export class ExploreCommand extends BaseCommand {
526
549
  }
527
550
 
528
551
  private isLimitReached(): boolean {
529
- return this.maxTests != null && this.testsRun >= this.maxTests;
552
+ if (this.maxTests != null && this.testsRun >= this.maxTests) return true;
553
+ return this.isDeadlineReached();
554
+ }
555
+
556
+ private isDeadlineReached(): boolean {
557
+ if (this.hardDeadlineAt == null) return false;
558
+ if (Date.now() < this.hardDeadlineAt - DEADLINE_TEST_ALLOWANCE_MS) return false;
559
+ if (!this.deadlineLogged) {
560
+ this.deadlineLogged = true;
561
+ tag('info').log(`Time budget reached after ${this.testsRun} test(s): stopping new work and finishing the session`);
562
+ }
563
+ return true;
530
564
  }
531
565
 
532
566
  private async runPendingTests(): Promise<void> {
@@ -548,7 +582,7 @@ export class ExploreCommand extends BaseCommand {
548
582
  test.start();
549
583
  test.finish(TestResult.SKIPPED);
550
584
  } else {
551
- await this.explorBot.agentTester().test(test);
585
+ await this.explorBot.agentTester().test(test, { deadline: this.hardDeadlineAt });
552
586
  }
553
587
  this.testsRun++;
554
588
  }
@@ -4,7 +4,7 @@ import { Stats } from '../stats.js';
4
4
  import { tag } from '../utils/logger.js';
5
5
  import { loop } from '../utils/loop.js';
6
6
  import { BaseCommand } from './base-command.js';
7
- import { ExploreCommand } from './explore-command.js';
7
+ import { DEADLINE_RESERVE_MS, DEADLINE_TEST_ALLOWANCE_MS, ExploreCommand } from './explore-command.js';
8
8
 
9
9
  export class FreesailCommand extends BaseCommand {
10
10
  name = 'freesail';
@@ -16,6 +16,7 @@ export class FreesailCommand extends BaseCommand {
16
16
  { flags: '--shallow', description: 'Use shallow navigation strategy' },
17
17
  { flags: '--scope <url>', description: 'Limit navigation to URLs starting with this prefix' },
18
18
  { flags: '--max-tests <number>', description: 'Maximum number of tests to run' },
19
+ { flags: '--max-duration <number>', description: 'Wall-clock budget in minutes for the whole run' },
19
20
  ];
20
21
 
21
22
  async execute(args: string): Promise<void> {
@@ -26,6 +27,9 @@ export class FreesailCommand extends BaseCommand {
26
27
  if (opts.shallow) strategy = 'shallow';
27
28
  const scope = opts.scope as string | undefined;
28
29
  const maxTests = opts.maxTests ? Number.parseInt(opts.maxTests as string, 10) : undefined;
30
+ const maxDuration = opts.maxDuration ? Number.parseInt(opts.maxDuration as string, 10) : undefined;
31
+ let hardDeadlineAt: number | undefined;
32
+ if (maxDuration != null) hardDeadlineAt = Date.now() + maxDuration * 60_000 - DEADLINE_RESERVE_MS;
29
33
 
30
34
  await this.explorBot.visitInitialState();
31
35
 
@@ -34,6 +38,7 @@ export class FreesailCommand extends BaseCommand {
34
38
  await loop(
35
39
  async (ctx) => {
36
40
  if (maxTests != null && testsRun >= maxTests) ctx.stop();
41
+ if (hardDeadlineAt != null && Date.now() >= hardDeadlineAt - DEADLINE_TEST_ALLOWANCE_MS) ctx.stop();
37
42
 
38
43
  const stateManager = this.explorBot.stateManager();
39
44
  const state = stateManager.getCurrentState();
@@ -48,6 +53,7 @@ export class FreesailCommand extends BaseCommand {
48
53
  } else {
49
54
  const exploreCmd = new ExploreCommand(this.explorBot);
50
55
  if (maxTests != null) exploreCmd.maxTests = maxTests - testsRun;
56
+ if (hardDeadlineAt != null) exploreCmd.hardDeadlineAt = hardDeadlineAt;
51
57
  await exploreCmd.execute('');
52
58
 
53
59
  const plan = this.explorBot.getCurrentPlan();
@@ -0,0 +1,18 @@
1
+ import type { Command } from 'commander';
2
+
3
+ export abstract class BaseOption {
4
+ abstract flags: string;
5
+ abstract description: string;
6
+ collect?: (value: string, previous: any) => any;
7
+
8
+ register(program: Command): void {
9
+ if (this.collect) program.option(this.flags, this.description, this.collect);
10
+ if (!this.collect) program.option(this.flags, this.description);
11
+
12
+ program.hook('preAction', (_thisCommand, actionCommand) => {
13
+ this.apply(actionCommand.optsWithGlobals(), actionCommand);
14
+ });
15
+ }
16
+
17
+ protected abstract apply(options: Record<string, any>, command: Command): void;
18
+ }
@@ -0,0 +1,7 @@
1
+ import { KnowledgeOption } from './knowledge-option.js';
2
+ import { WsOption } from './ws-option.js';
3
+
4
+ export { BaseOption } from './base-option.js';
5
+
6
+ export const knowledgeOption = new KnowledgeOption();
7
+ export const wsOption = new WsOption();
@@ -0,0 +1,14 @@
1
+ import { KnowledgeTracker } from '../../knowledge-tracker.js';
2
+ import { BaseOption } from './base-option.js';
3
+
4
+ export class KnowledgeOption extends BaseOption {
5
+ flags = '--knowledge <text>';
6
+ description = 'Knowledge for this run only, not saved to disk. Markdown text; add url: or endpoint: frontmatter to scope it, otherwise it applies everywhere. Repeatable';
7
+ collect = (value: string, previous: string[] = []) => [...previous, value];
8
+
9
+ protected apply(options: Record<string, any>): void {
10
+ for (const text of options.knowledge || []) {
11
+ KnowledgeTracker.appendSessionKnowledge(text);
12
+ }
13
+ }
14
+ }
@@ -0,0 +1,24 @@
1
+ import type { Command } from 'commander';
2
+ import { remote } from '../../remote.js';
3
+ import { BaseOption } from './base-option.js';
4
+
5
+ export class WsOption extends BaseOption {
6
+ flags = '--ws <url>';
7
+ description = 'Stream this run to a remote UI over WebSocket';
8
+
9
+ protected apply(options: Record<string, any>, command: Command): void {
10
+ const url = options.ws || process.env.EXPLORBOT_WS_URL;
11
+ if (!url) return;
12
+ remote.attach(String(url), commandPath(command));
13
+ }
14
+ }
15
+
16
+ function commandPath(command: Command): string {
17
+ const parts: string[] = [];
18
+ let node: Command | null = command;
19
+ while (node) {
20
+ parts.unshift(node.name());
21
+ node = node.parent;
22
+ }
23
+ return parts.slice(1).join(' ') || parts.join(' ');
24
+ }
@@ -79,15 +79,15 @@ export class PlansCommand extends BaseCommand {
79
79
  return file;
80
80
  }
81
81
 
82
- const resolved = this.explorBot.resolvePlanPath(target);
83
- if (!existsSync(resolved)) {
84
- throw new Error(`Plan file not found: ${resolved}`);
82
+ const plan = Plan.loadFromFile(target, this.explorBot.getPlansDir());
83
+ if (!plan?.filePath) {
84
+ throw new Error(`Plan file not found: ${target}`);
85
85
  }
86
86
 
87
87
  return {
88
- name: path.basename(resolved),
89
- path: resolved,
90
- modifiedAt: statSync(resolved).mtimeMs,
88
+ name: path.basename(plan.filePath),
89
+ path: plan.filePath,
90
+ modifiedAt: statSync(plan.filePath).mtimeMs,
91
91
  };
92
92
  }
93
93
  }
package/src/config.ts CHANGED
@@ -271,8 +271,10 @@ export const EXPLORBOT_ENV_VARS: EnvVar[] = [
271
271
  { name: 'EXPLORBOT_EPHEMERAL', description: 'Keep no state between runs — output goes to a fresh temp directory instead of the site dir' },
272
272
  { name: 'EXPLORBOT_KNOWLEDGE', description: 'Inline knowledge text, applied to every page' },
273
273
  { name: 'EXPLORBOT_KNOWLEDGE_FILE', description: 'Path to a knowledge markdown file' },
274
+ { name: 'EXPLORBOT_SPEC', description: 'Docbot application spec directory or index.md, used as page knowledge' },
274
275
  { name: 'EXPLORBOT_API_SPEC', description: 'OpenAPI spec path for the API boat' },
275
276
  { name: 'EXPLORBOT_NO_BANNER', description: 'Suppress the startup banner, for machine-readable output' },
277
+ { name: 'EXPLORBOT_MAX_DURATION', description: 'Wall-clock budget in minutes for an explore run; same as --max-duration' },
276
278
  ];
277
279
 
278
280
  export type {
@@ -404,6 +406,8 @@ export class ConfigParser {
404
406
  this.enterGlobalMode(this.config, target);
405
407
  }
406
408
 
409
+ this.applyEnvSpec(this.config);
410
+
407
411
  // Restore original directory after successful config load
408
412
  if (options?.path && originalCwd !== process.cwd()) {
409
413
  process.chdir(originalCwd);
@@ -554,10 +558,18 @@ export class ConfigParser {
554
558
 
555
559
  config.dirs = { knowledge: 'knowledge', experience: 'experience', output: 'output' };
556
560
  config.playwright = { ...config.playwright, browser: config.playwright?.browser || 'chromium', url: site.baseUrl };
561
+ materializeKnowledge(this.site.dir);
557
562
 
558
563
  log(`Global mode: ${site.baseUrl} stored in ${this.site.dir}`);
559
564
  }
560
565
 
566
+ private applyEnvSpec(config: ExplorbotConfig): void {
567
+ const spec = process.env.EXPLORBOT_SPEC;
568
+ if (!spec) return;
569
+ if (!config.dirs) config.dirs = { knowledge: 'knowledge', experience: 'experience', output: 'output' };
570
+ config.dirs.spec = spec;
571
+ }
572
+
561
573
  private async buildEnvConfig(baseUrl: string | undefined, outputRoot: string): Promise<ExplorbotConfig> {
562
574
  const provider = process.env.EXPLORBOT_AI_PROVIDER;
563
575
  const modelSpec = process.env.EXPLORBOT_AI_MODEL;
@@ -123,10 +123,14 @@ export class ExperienceTracker {
123
123
  const filePath = this.getExperienceFilePath(stateHash);
124
124
 
125
125
  if (!existsSync(filePath)) {
126
- const frontmatter = {
126
+ const frontmatter: Record<string, unknown> = {
127
127
  url: state.url ? extractStatePath(state.url) : '',
128
128
  title: state.title,
129
129
  };
130
+ if (state.overlay.present && state.overlay.name) {
131
+ frontmatter.region = state.overlay.name;
132
+ if (state.overlay.root) frontmatter.root = state.overlay.root;
133
+ }
130
134
  this.writeExperienceFile(stateHash, '', frontmatter);
131
135
  }
132
136
 
package/src/explorbot.ts CHANGED
@@ -166,7 +166,7 @@ export class ExplorBot {
166
166
  }
167
167
 
168
168
  knowledgeTracker(): KnowledgeTracker {
169
- return (this._knowledgeTracker ||= new KnowledgeTracker(this.options.applicationSpec));
169
+ return (this._knowledgeTracker ||= new KnowledgeTracker({ applicationSpec: this.options.applicationSpec }));
170
170
  }
171
171
 
172
172
  experienceTracker(): ExperienceTracker {
@@ -346,14 +346,22 @@ export class ExplorBot {
346
346
  }
347
347
  };
348
348
 
349
- const cookieProvider = async (): Promise<Record<string, string>> => {
350
- const cookies = await this.explorer.withPage((page) => page.context().cookies()).catch(() => []);
351
- if (!cookies.length) return {};
352
- return { Cookie: cookies.map((c: any) => `${c.name}=${c.value}`).join('; ') };
349
+ const browserHeaderProvider = async (): Promise<Record<string, string>> => {
350
+ const session = await this.explorer
351
+ .withPage(async (page) => ({
352
+ cookies: await page.context().cookies(baseEndpoint),
353
+ csrf: await page.evaluate(() => document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '').catch(() => ''),
354
+ }))
355
+ .catch(() => ({ cookies: [] as any[], csrf: '' }));
356
+
357
+ const headers: Record<string, string> = {};
358
+ if (session.cookies.length) headers.Cookie = session.cookies.map((c: any) => `${c.name}=${c.value}`).join('; ');
359
+ if (session.csrf) headers['x-csrf-token'] = session.csrf;
360
+ return headers;
353
361
  };
354
362
 
355
363
  this.agents.fisherman = this.createAgent(({ ai }) => {
356
- return new Fisherman(ai, apiClient, requestStore, specLoader, baseEndpoint, cookieProvider, configHeaders, hasApiConfig);
364
+ return new Fisherman(ai, apiClient, requestStore, specLoader, baseEndpoint, browserHeaderProvider, configHeaders, hasApiConfig);
357
365
  });
358
366
  }
359
367
  return this.agents.fisherman;
@@ -449,41 +457,17 @@ export class ExplorBot {
449
457
  return urlPart + featurePart.slice(0, maxFeatureLen) + suffix;
450
458
  }
451
459
 
452
- resolvePlanPath(filename: string): string {
453
- let planPath = filename;
454
-
455
- if (path.isAbsolute(filename)) {
456
- if (!existsSync(planPath) && !filename.endsWith('.md')) {
457
- planPath = `${filename}.md`;
458
- }
459
- } else if (existsSync(filename) || existsSync(`${filename}.md`)) {
460
- planPath = existsSync(filename) ? filename : `${filename}.md`;
461
- } else {
462
- const plansDir = this.getPlansDir();
463
- planPath = path.join(plansDir, filename);
464
- if (!existsSync(planPath) && !filename.endsWith('.md')) {
465
- planPath = path.join(plansDir, `${filename}.md`);
466
- }
467
- }
468
-
469
- return planPath;
470
- }
471
-
472
460
  loadPlan(filename: string): Plan {
473
- const planPath = this.resolvePlanPath(filename);
474
- if (!existsSync(planPath)) {
475
- throw new Error(`Plan file not found: ${planPath}`);
476
- }
477
- this.setCurrentPlan(Plan.fromMarkdown(planPath));
478
- return this.currentPlan!;
461
+ const plan = Plan.loadFromFile(filename, this.getPlansDir());
462
+ if (!plan) throw new Error(`Plan file not found: ${filename}`);
463
+ this.setCurrentPlan(plan);
464
+ return plan;
479
465
  }
480
466
 
481
467
  loadPlans(filename: string): Plan[] {
482
- const planPath = this.resolvePlanPath(filename);
483
- if (!existsSync(planPath)) {
484
- throw new Error(`Plan file not found: ${planPath}`);
485
- }
486
- return parsePlansFromMarkdown(planPath);
468
+ const plan = Plan.loadFromFile(filename, this.getPlansDir());
469
+ if (!plan?.filePath) throw new Error(`Plan file not found: ${filename}`);
470
+ return parsePlansFromMarkdown(plan.filePath);
487
471
  }
488
472
 
489
473
  setCurrentPlan(plan?: Plan): void {