explorbot 0.1.32-beta.1 → 0.2.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 (222) hide show
  1. package/README.md +35 -19
  2. package/bin/explorbot-cli.ts +33 -10
  3. package/boat/api-tester/src/config.ts +45 -3
  4. package/boat/doc-collector/src/ai/documentarian.ts +26 -28
  5. package/boat/doc-collector/src/ai/tools.ts +74 -16
  6. package/boat/doc-collector/src/cli.ts +1 -6
  7. package/boat/doc-collector/src/docbot.ts +30 -9
  8. package/boat/doc-collector/src/docs-renderer.ts +19 -24
  9. package/boat/doc-collector/src/screenshots.ts +34 -2
  10. package/boat/doc-collector/src/state-diagram.ts +281 -0
  11. package/dist/bin/explorbot-cli.js +29 -9
  12. package/dist/boat/api-tester/src/config.js +43 -4
  13. package/dist/boat/doc-collector/src/ai/documentarian.js +19 -7
  14. package/dist/boat/doc-collector/src/ai/tools.js +53 -16
  15. package/dist/boat/doc-collector/src/cli.js +1 -5
  16. package/dist/boat/doc-collector/src/docbot.js +27 -8
  17. package/dist/boat/doc-collector/src/docs-renderer.js +18 -3
  18. package/dist/boat/doc-collector/src/screenshots.js +30 -1
  19. package/dist/boat/doc-collector/src/state-diagram.js +200 -0
  20. package/dist/models.json +30 -0
  21. package/dist/package.json +23 -3
  22. package/dist/src/action-result.d.ts +8 -10
  23. package/dist/src/action-result.js +72 -164
  24. package/dist/src/action.d.ts +13 -12
  25. package/dist/src/action.js +37 -113
  26. package/dist/src/ai/agent.d.ts +17 -0
  27. package/dist/src/ai/captain/idle-mode.js +1 -1
  28. package/dist/src/ai/captain/test-mode.js +1 -1
  29. package/dist/src/ai/captain/web-mode.js +18 -29
  30. package/dist/src/ai/captain.d.ts +1 -3
  31. package/dist/src/ai/captain.js +17 -45
  32. package/dist/src/ai/conversation.d.ts +2 -5
  33. package/dist/src/ai/conversation.js +9 -28
  34. package/dist/src/ai/driller.d.ts +2 -17
  35. package/dist/src/ai/driller.js +26 -50
  36. package/dist/src/ai/experience-compactor.d.ts +2 -2
  37. package/dist/src/ai/experience-compactor.js +16 -33
  38. package/dist/src/ai/fisherman-tools.js +1 -1
  39. package/dist/src/ai/fisherman.d.ts +0 -1
  40. package/dist/src/ai/fisherman.js +0 -3
  41. package/dist/src/ai/historian/codeceptjs.d.ts +2 -0
  42. package/dist/src/ai/historian/codeceptjs.js +4 -3
  43. package/dist/src/ai/historian/playwright.d.ts +4 -1
  44. package/dist/src/ai/historian/playwright.js +4 -3
  45. package/dist/src/ai/historian/screencast.d.ts +2 -1
  46. package/dist/src/ai/historian/screencast.js +2 -2
  47. package/dist/src/ai/historian/utils.d.ts +0 -1
  48. package/dist/src/ai/historian/utils.js +0 -1
  49. package/dist/src/ai/historian.d.ts +10 -8
  50. package/dist/src/ai/historian.js +1 -5
  51. package/dist/src/ai/navigator.d.ts +8 -6
  52. package/dist/src/ai/navigator.js +34 -65
  53. package/dist/src/ai/pilot.d.ts +13 -8
  54. package/dist/src/ai/pilot.js +53 -59
  55. package/dist/src/ai/planner.d.ts +5 -4
  56. package/dist/src/ai/planner.js +66 -52
  57. package/dist/src/ai/provider.d.ts +4 -1
  58. package/dist/src/ai/provider.js +77 -137
  59. package/dist/src/ai/quartermaster.d.ts +22 -22
  60. package/dist/src/ai/quartermaster.js +8 -14
  61. package/dist/src/ai/rerunner.d.ts +3 -13
  62. package/dist/src/ai/rerunner.js +23 -44
  63. package/dist/src/ai/researcher/cache.js +11 -18
  64. package/dist/src/ai/researcher/coordinates.js +4 -3
  65. package/dist/src/ai/researcher/deep-analysis.d.ts +2 -0
  66. package/dist/src/ai/researcher/deep-analysis.js +8 -11
  67. package/dist/src/ai/researcher/locators.d.ts +9 -2
  68. package/dist/src/ai/researcher/locators.js +65 -8
  69. package/dist/src/ai/researcher/parser.js +1 -1
  70. package/dist/src/ai/researcher/research-result.d.ts +0 -1
  71. package/dist/src/ai/researcher/research-result.js +0 -19
  72. package/dist/src/ai/researcher/sections.d.ts +2 -0
  73. package/dist/src/ai/researcher/sections.js +7 -3
  74. package/dist/src/ai/researcher.js +40 -106
  75. package/dist/src/ai/rules.d.ts +1 -0
  76. package/dist/src/ai/rules.js +22 -15
  77. package/dist/src/ai/session-analyst.js +8 -5
  78. package/dist/src/ai/task-agent.d.ts +19 -6
  79. package/dist/src/ai/task-agent.js +37 -33
  80. package/dist/src/ai/tester.d.ts +6 -14
  81. package/dist/src/ai/tester.js +46 -111
  82. package/dist/src/ai/tools.d.ts +26 -14
  83. package/dist/src/ai/tools.js +138 -132
  84. package/dist/src/command-handler.js +1 -1
  85. package/dist/src/commands/compact-command.d.ts +1 -1
  86. package/dist/src/commands/compact-command.js +6 -6
  87. package/dist/src/commands/context-aria-command.js +1 -1
  88. package/dist/src/commands/context-command.js +8 -6
  89. package/dist/src/commands/context-data-command.js +2 -2
  90. package/dist/src/commands/context-experience-command.js +2 -2
  91. package/dist/src/commands/context-html-command.js +2 -2
  92. package/dist/src/commands/context-knowledge-command.js +2 -2
  93. package/dist/src/commands/drill-command.js +1 -1
  94. package/dist/src/commands/experience-command.js +1 -1
  95. package/dist/src/commands/explore-command.js +4 -3
  96. package/dist/src/commands/freesail-command.js +2 -2
  97. package/dist/src/commands/knows-command.js +1 -1
  98. package/dist/src/commands/learn-command.js +5 -3
  99. package/dist/src/commands/path-command.js +1 -1
  100. package/dist/src/commands/research-command.js +1 -1
  101. package/dist/src/commands/test-command.js +1 -1
  102. package/dist/src/components/AddKnowledge.d.ts +2 -0
  103. package/dist/src/components/AddKnowledge.js +3 -7
  104. package/dist/src/components/App.js +3 -3
  105. package/dist/src/config.d.ts +19 -0
  106. package/dist/src/config.js +156 -10
  107. package/dist/src/experience-tracker.d.ts +11 -6
  108. package/dist/src/experience-tracker.js +82 -155
  109. package/dist/src/explorbot.d.ts +21 -13
  110. package/dist/src/explorbot.js +84 -80
  111. package/dist/src/explorer.d.ts +66 -102
  112. package/dist/src/explorer.js +332 -623
  113. package/dist/src/knowledge-tracker.d.ts +2 -2
  114. package/dist/src/knowledge-tracker.js +38 -45
  115. package/dist/src/state-manager.d.ts +10 -30
  116. package/dist/src/state-manager.js +11 -129
  117. package/dist/src/utils/aria.d.ts +6 -1
  118. package/dist/src/utils/aria.js +4 -1
  119. package/dist/src/utils/cache.d.ts +15 -0
  120. package/dist/src/utils/cache.js +34 -0
  121. package/dist/src/utils/context-formatter.d.ts +6 -0
  122. package/dist/src/utils/context-formatter.js +15 -27
  123. package/dist/src/utils/hooks-runner.js +2 -4
  124. package/dist/src/utils/html.d.ts +5 -0
  125. package/dist/src/utils/html.js +71 -0
  126. package/dist/src/utils/markdown-files.d.ts +10 -0
  127. package/dist/src/utils/markdown-files.js +21 -0
  128. package/dist/src/utils/markdown-query.d.ts +6 -0
  129. package/dist/src/utils/markdown-query.js +14 -1
  130. package/dist/src/utils/page-readiness.d.ts +1 -0
  131. package/dist/src/utils/page-readiness.js +1 -1
  132. package/dist/src/utils/secrets.js +2 -2
  133. package/dist/src/utils/strings.d.ts +2 -0
  134. package/dist/src/utils/strings.js +10 -0
  135. package/dist/src/utils/test-files.js +1 -1
  136. package/dist/src/utils/web-annotate.d.ts +5 -0
  137. package/dist/src/utils/web-annotate.js +58 -0
  138. package/dist/src/utils/web-eidx.d.ts +2 -0
  139. package/dist/src/utils/web-eidx.js +20 -0
  140. package/dist/src/utils/web-element.d.ts +3 -1
  141. package/dist/src/utils/web-element.js +32 -0
  142. package/dist/src/utils/web-sandbox.d.ts +4 -0
  143. package/dist/src/utils/web-sandbox.js +37 -0
  144. package/models.json +30 -0
  145. package/package.json +23 -3
  146. package/src/action-result.ts +70 -173
  147. package/src/action.ts +38 -132
  148. package/src/ai/agent.ts +20 -0
  149. package/src/ai/captain/idle-mode.ts +1 -1
  150. package/src/ai/captain/test-mode.ts +1 -1
  151. package/src/ai/captain/web-mode.ts +18 -30
  152. package/src/ai/captain.ts +17 -42
  153. package/src/ai/conversation.ts +9 -32
  154. package/src/ai/driller.ts +53 -83
  155. package/src/ai/experience-compactor.ts +16 -35
  156. package/src/ai/fisherman-tools.ts +1 -1
  157. package/src/ai/fisherman.ts +0 -4
  158. package/src/ai/historian/codeceptjs.ts +5 -3
  159. package/src/ai/historian/playwright.ts +7 -4
  160. package/src/ai/historian/screencast.ts +4 -3
  161. package/src/ai/historian/utils.ts +0 -1
  162. package/src/ai/historian.ts +6 -8
  163. package/src/ai/navigator.ts +39 -70
  164. package/src/ai/pilot.ts +59 -61
  165. package/src/ai/planner.ts +76 -62
  166. package/src/ai/provider.ts +85 -146
  167. package/src/ai/quartermaster.ts +39 -45
  168. package/src/ai/rerunner.ts +27 -50
  169. package/src/ai/researcher/cache.ts +10 -16
  170. package/src/ai/researcher/coordinates.ts +4 -3
  171. package/src/ai/researcher/deep-analysis.ts +10 -11
  172. package/src/ai/researcher/locators.ts +66 -9
  173. package/src/ai/researcher/parser.ts +1 -1
  174. package/src/ai/researcher/research-result.ts +0 -18
  175. package/src/ai/researcher/sections.ts +9 -3
  176. package/src/ai/researcher.ts +42 -119
  177. package/src/ai/rules.ts +24 -13
  178. package/src/ai/session-analyst.ts +8 -5
  179. package/src/ai/task-agent.ts +45 -38
  180. package/src/ai/tester.ts +51 -129
  181. package/src/ai/tools.ts +145 -153
  182. package/src/command-handler.ts +1 -1
  183. package/src/commands/compact-command.ts +7 -8
  184. package/src/commands/context-aria-command.ts +1 -1
  185. package/src/commands/context-command.ts +8 -6
  186. package/src/commands/context-data-command.ts +2 -2
  187. package/src/commands/context-experience-command.ts +2 -2
  188. package/src/commands/context-html-command.ts +2 -2
  189. package/src/commands/context-knowledge-command.ts +2 -2
  190. package/src/commands/drill-command.ts +1 -1
  191. package/src/commands/experience-command.ts +1 -1
  192. package/src/commands/explore-command.ts +4 -3
  193. package/src/commands/freesail-command.ts +2 -2
  194. package/src/commands/knows-command.ts +1 -1
  195. package/src/commands/learn-command.ts +5 -3
  196. package/src/commands/path-command.ts +1 -1
  197. package/src/commands/research-command.ts +1 -1
  198. package/src/commands/test-command.ts +1 -1
  199. package/src/components/AddKnowledge.tsx +5 -7
  200. package/src/components/App.tsx +3 -3
  201. package/src/config.ts +185 -11
  202. package/src/experience-tracker.ts +89 -152
  203. package/src/explorbot.ts +88 -83
  204. package/src/explorer.ts +402 -693
  205. package/src/knowledge-tracker.ts +42 -49
  206. package/src/state-manager.ts +20 -155
  207. package/src/utils/aria.ts +11 -2
  208. package/src/utils/cache.ts +40 -0
  209. package/src/utils/context-formatter.ts +12 -15
  210. package/src/utils/hooks-runner.ts +2 -4
  211. package/src/utils/html.ts +79 -0
  212. package/src/utils/markdown-files.ts +30 -0
  213. package/src/utils/markdown-query.ts +15 -1
  214. package/src/utils/page-readiness.ts +1 -1
  215. package/src/utils/secrets.ts +2 -3
  216. package/src/utils/strings.ts +12 -0
  217. package/src/utils/test-files.ts +1 -1
  218. package/src/utils/web-annotate.ts +64 -0
  219. package/src/utils/web-eidx.ts +21 -0
  220. package/src/utils/web-element.ts +34 -0
  221. package/src/utils/web-sandbox.ts +43 -0
  222. package/dist/boat/api-tester/example/apibot.config.js +0 -30
package/src/config.ts CHANGED
@@ -1,8 +1,22 @@
1
- import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
2
- import path, { dirname, join, resolve } from 'node:path';
1
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import path, { basename, dirname, join, resolve } from 'node:path';
3
4
  import { parseEnv } from 'node:util';
5
+ import matter from 'gray-matter';
4
6
  import { log } from './utils/logger.js';
5
7
 
8
+ export const PROVIDERS: Record<string, () => Promise<(modelId: string) => any>> = {
9
+ openai: async () => (await import('@ai-sdk/openai')).createOpenAI(),
10
+ anthropic: async () => (await import('@ai-sdk/anthropic')).createAnthropic(),
11
+ google: async () => (await import('@ai-sdk/google')).createGoogleGenerativeAI(),
12
+ groq: async () => (await import('@ai-sdk/groq')).createGroq(),
13
+ mistral: async () => (await import('@ai-sdk/mistral')).createMistral(),
14
+ openrouter: async () => (await import('@openrouter/ai-sdk-provider')).createOpenRouter(),
15
+ sambanova: async () => (await import('sambanova-ai-provider')).createSambaNova(),
16
+ };
17
+
18
+ let cachedOutputRoot: string | null = null;
19
+
6
20
  interface PlaywrightConfig {
7
21
  browser: 'chromium' | 'firefox' | 'webkit';
8
22
  url: string;
@@ -216,6 +230,7 @@ interface ExplorbotConfig {
216
230
  };
217
231
  experience?: {
218
232
  maxReadLines?: number;
233
+ disabled?: boolean;
219
234
  };
220
235
  reporter?: ReporterConfig;
221
236
  api?: ApiConfig;
@@ -239,6 +254,19 @@ type RuleEntry = string | Record<string, string>;
239
254
 
240
255
  export const EXPLORBOT_CONFIG_PATHS = ['explorbot.config.js', 'explorbot.config.mjs', 'explorbot.config.ts'];
241
256
 
257
+ export const EXPLORBOT_ENV_VARS: EnvVar[] = [
258
+ { name: 'EXPLORBOT_AI_PROVIDER', required: true, description: 'Provider name; fills every model role from its recommended models. Turns on config-free mode' },
259
+ { name: 'EXPLORBOT_AI_MODEL', description: 'Pins the main model — a model id for the provider, or a standalone provider/model-id' },
260
+ { name: 'EXPLORBOT_URL', required: true, description: 'Base URL to test; the API boat reads it as the base endpoint' },
261
+ { name: 'EXPLORBOT_VISION_MODEL', description: 'Screenshot analysis; overrides the provider recommendation' },
262
+ { name: 'EXPLORBOT_AGENTIC_MODEL', description: 'Captain and Pilot decisions; overrides the provider recommendation' },
263
+ { name: 'EXPLORBOT_OUTPUT', description: 'Output root for states, plans, research, and reports. Defaults to a fresh temp directory' },
264
+ { name: 'EXPLORBOT_KNOWLEDGE', description: 'Inline knowledge text, applied to every page' },
265
+ { name: 'EXPLORBOT_KNOWLEDGE_FILE', description: 'Path to a knowledge markdown file' },
266
+ { name: 'EXPLORBOT_API_SPEC', description: 'OpenAPI spec path for the API boat' },
267
+ { name: 'EXPLORBOT_NO_BANNER', description: 'Suppress the startup banner, for machine-readable output' },
268
+ ];
269
+
242
270
  export type {
243
271
  ExplorbotConfig,
244
272
  PlaywrightConfig,
@@ -268,6 +296,7 @@ export type {
268
296
 
269
297
  export class ConfigParser {
270
298
  private static instance: ConfigParser;
299
+ private static recommended: Record<string, Record<string, string>> | null = null;
271
300
  private config: ExplorbotConfig | null = null;
272
301
  private configPath: string | null = null;
273
302
  private runtimeBaseUrlOverride: string | null = null;
@@ -280,6 +309,11 @@ export class ConfigParser {
280
309
  Object.assign(process.env, parseEnv(readFileSync(resolved, 'utf8')));
281
310
  }
282
311
 
312
+ public static recommendedModels(): Record<string, Record<string, string>> {
313
+ ConfigParser.recommended ||= JSON.parse(readFileSync(new URL('../models.json', import.meta.url), 'utf8'));
314
+ return ConfigParser.recommended!;
315
+ }
316
+
283
317
  public static getInstance(): ConfigParser {
284
318
  if (!ConfigParser.instance) {
285
319
  ConfigParser.instance = new ConfigParser();
@@ -312,22 +346,31 @@ export class ConfigParser {
312
346
  try {
313
347
  const resolvedPath = options?.config || this.findConfigFile();
314
348
 
315
- if (!resolvedPath) {
316
- throw new Error('No configuration file found. Please create explorbot.config.js or explorbot.config.ts');
349
+ let loadedConfig: ExplorbotConfig | null = null;
350
+ let sourcePath = resolvedPath;
351
+
352
+ if (resolvedPath) {
353
+ const configModule = await this.loadConfigModule(resolvedPath);
354
+ loadedConfig = configModule.default || configModule;
355
+
356
+ if (!loadedConfig) {
357
+ throw new Error('Configuration file is empty or invalid');
358
+ }
359
+
360
+ log(`Configuration loaded from: ${resolvedPath}`);
317
361
  }
318
362
 
319
- const configModule = await this.loadConfigModule(resolvedPath);
320
- const loadedConfig = configModule.default || configModule;
363
+ if (!resolvedPath) {
364
+ const outputRoot = resolveOutputRoot();
365
+ loadedConfig = await this.buildEnvConfig(options?.baseUrl, outputRoot);
366
+ sourcePath = join(outputRoot, 'explorbot.config.js');
321
367
 
322
- if (!loadedConfig) {
323
- throw new Error('Configuration file is empty or invalid');
368
+ log(`Configuration built from EXPLORBOT_* environment variables. Output: ${outputRoot}`);
324
369
  }
325
370
 
326
371
  this.config = this.resolveConfig(loadedConfig as ExplorbotConfig, options);
327
372
  this.runtimeBaseUrlOverride = options?.baseUrl || null;
328
- this.configPath = resolvedPath;
329
-
330
- log(`Configuration loaded from: ${resolvedPath}`);
373
+ this.configPath = sourcePath;
331
374
 
332
375
  // Restore original directory after successful config load
333
376
  if (options?.path && originalCwd !== process.cwd()) {
@@ -362,6 +405,18 @@ export class ConfigParser {
362
405
  return path.join(path.dirname(configPath), config.dirs?.output || 'output');
363
406
  }
364
407
 
408
+ public getProjectRoot(): string {
409
+ const configPath = this.getConfigPath();
410
+ if (configPath) return path.dirname(configPath);
411
+ return process.cwd();
412
+ }
413
+
414
+ public resolveProjectDir(relativeDir: string): string {
415
+ const configPath = this.getConfigPath();
416
+ if (!configPath) return relativeDir;
417
+ return path.join(path.dirname(configPath), relativeDir);
418
+ }
419
+
365
420
  public getStatesDir(): string {
366
421
  return outputPath('states');
367
422
  }
@@ -376,6 +431,7 @@ export class ConfigParser {
376
431
 
377
432
  // For testing purposes only
378
433
  public static resetForTesting(): void {
434
+ cachedOutputRoot = null;
379
435
  if (ConfigParser.instance) {
380
436
  ConfigParser.instance.config = null;
381
437
  ConfigParser.instance.configPath = null;
@@ -430,6 +486,52 @@ export class ConfigParser {
430
486
  }
431
487
  }
432
488
 
489
+ private async buildEnvConfig(baseUrl: string | undefined, outputRoot: string): Promise<ExplorbotConfig> {
490
+ const provider = process.env.EXPLORBOT_AI_PROVIDER;
491
+ const modelSpec = process.env.EXPLORBOT_AI_MODEL;
492
+ if (!provider && !modelSpec) {
493
+ throw new Error('No configuration file found. Please create explorbot.config.js or set EXPLORBOT_URL and EXPLORBOT_AI_PROVIDER environment variables');
494
+ }
495
+ if (modelSpec && !provider && !modelSpec.includes('/')) {
496
+ throw new Error('EXPLORBOT_AI_MODEL needs a provider — set EXPLORBOT_AI_PROVIDER, or write it as "provider/model-id"');
497
+ }
498
+
499
+ const url = process.env.EXPLORBOT_URL || baseUrl;
500
+ if (!url) {
501
+ throw new Error('No URL to explore. Set EXPLORBOT_URL or pass a URL to the command');
502
+ }
503
+
504
+ materializeKnowledge(outputRoot);
505
+
506
+ let model: any;
507
+ if (provider && modelSpec) model = await createModel(provider, modelSpec);
508
+ if (provider && !modelSpec) model = await resolveModel(provider, 'model');
509
+ if (!provider) model = await resolveModel(modelSpec!, 'model');
510
+
511
+ const ai: AIConfig = {
512
+ model,
513
+ agents: { historian: { enabled: false } },
514
+ };
515
+
516
+ let recommended: Record<string, string> = {};
517
+ if (provider) recommended = ConfigParser.recommendedModels()[provider] || {};
518
+
519
+ const visionSpec = process.env.EXPLORBOT_VISION_MODEL;
520
+ if (visionSpec) ai.visionModel = await resolveModel(visionSpec, 'visionModel');
521
+ if (!visionSpec && recommended.visionModel) ai.visionModel = await resolveModel(provider!, 'visionModel');
522
+
523
+ const agenticSpec = process.env.EXPLORBOT_AGENTIC_MODEL;
524
+ if (agenticSpec) ai.agenticModel = await resolveModel(agenticSpec, 'agenticModel');
525
+ if (!agenticSpec && recommended.agenticModel) ai.agenticModel = await resolveModel(provider!, 'agenticModel');
526
+
527
+ return {
528
+ playwright: { browser: 'chromium', url, show: false },
529
+ ai,
530
+ dirs: { knowledge: 'knowledge', experience: 'experience', output: '.' },
531
+ experience: { disabled: true },
532
+ };
533
+ }
534
+
433
535
  private findConfigFile(): string | null {
434
536
  const possiblePaths = [...EXPLORBOT_CONFIG_PATHS, 'config/explorbot.config.js', 'config/explorbot.config.mjs', 'config/explorbot.config.ts', 'src/config/explorbot.config.js', 'src/config/explorbot.config.mjs', 'src/config/explorbot.config.ts'];
435
537
 
@@ -542,3 +644,75 @@ export class ConfigParser {
542
644
  export function outputPath(...segments: string[]): string {
543
645
  return path.join(ConfigParser.getInstance().getOutputDir(), ...segments);
544
646
  }
647
+
648
+ export async function resolveModel(spec: string, role: ModelRole = 'model'): Promise<any> {
649
+ const separator = spec.indexOf('/');
650
+ if (separator > 0) {
651
+ return createModel(spec.slice(0, separator), spec.slice(separator + 1));
652
+ }
653
+
654
+ const recommended = ConfigParser.recommendedModels()[spec];
655
+ if (!recommended) {
656
+ throw new Error(`No recommended models for "${spec}". Write it as "provider/model-id", or use a provider with recommendations: ${Object.keys(ConfigParser.recommendedModels()).join(', ')}`);
657
+ }
658
+
659
+ const modelId = recommended[role];
660
+ if (!modelId) {
661
+ throw new Error(`Provider "${spec}" has no recommended ${role}. Set it explicitly as "provider/model-id".`);
662
+ }
663
+
664
+ return createModel(spec, modelId);
665
+ }
666
+
667
+ export function resolveOutputRoot(): string {
668
+ if (cachedOutputRoot) return cachedOutputRoot;
669
+
670
+ const configured = process.env.EXPLORBOT_OUTPUT;
671
+ if (!configured) {
672
+ cachedOutputRoot = mkdtempSync(join(tmpdir(), 'explorbot-'));
673
+ return cachedOutputRoot;
674
+ }
675
+
676
+ cachedOutputRoot = resolve(configured);
677
+ mkdirSync(cachedOutputRoot, { recursive: true });
678
+ return cachedOutputRoot;
679
+ }
680
+
681
+ export function materializeKnowledge(outputRoot: string): void {
682
+ const inline = process.env.EXPLORBOT_KNOWLEDGE;
683
+ const knowledgeFile = process.env.EXPLORBOT_KNOWLEDGE_FILE;
684
+ if (!inline && !knowledgeFile) return;
685
+
686
+ const knowledgeDir = join(outputRoot, 'knowledge');
687
+ mkdirSync(knowledgeDir, { recursive: true });
688
+
689
+ if (inline) {
690
+ writeFileSync(join(knowledgeDir, 'global.md'), matter.stringify(inline, { url: '*', endpoint: '*' }));
691
+ }
692
+
693
+ if (!knowledgeFile) return;
694
+
695
+ const source = resolve(knowledgeFile);
696
+ if (!existsSync(source)) {
697
+ throw new Error(`Knowledge file from EXPLORBOT_KNOWLEDGE_FILE not found: ${source}`);
698
+ }
699
+ copyFileSync(source, join(knowledgeDir, basename(source)));
700
+ }
701
+
702
+ export async function createModel(provider: string, modelId: string): Promise<any> {
703
+ const factory = PROVIDERS[provider];
704
+ if (!factory) {
705
+ throw new Error(`Unknown AI provider "${provider}". Supported providers: ${Object.keys(PROVIDERS).join(', ')}`);
706
+ }
707
+ return (await factory())(modelId);
708
+ }
709
+
710
+ type ModelRole = 'model' | 'visionModel' | 'agenticModel';
711
+
712
+ interface EnvVar {
713
+ name: string;
714
+ description: string;
715
+ required?: boolean;
716
+ }
717
+
718
+ export type { ModelRole, EnvVar };
@@ -1,12 +1,12 @@
1
- import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
2
- import { basename, dirname, join } from 'node:path';
1
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
3
  import matter from 'gray-matter';
4
- import { type Tokens, marked } from 'marked';
5
4
  import type { ActionResult } from './action-result.js';
6
5
  import { ConfigParser } from './config.js';
7
6
  import { KnowledgeTracker } from './knowledge-tracker.js';
8
7
  import type { WebPageState } from './state-manager.js';
9
- import { createDebug, tag } from './utils/logger.js';
8
+ import { createDebug, pluralize, tag } from './utils/logger.js';
9
+ import { loadMarkdownFiles } from './utils/markdown-files.js';
10
10
  import { mdq } from './utils/markdown-query.js';
11
11
  import { redactSecrets } from './utils/secrets.js';
12
12
  import { isNonReusableCode } from './utils/step-analyzer.ts';
@@ -39,20 +39,14 @@ export class ExperienceTracker {
39
39
  private disabled: boolean;
40
40
  private knowledgeTracker: KnowledgeTracker;
41
41
 
42
- constructor(options: { disabled?: boolean } = {}) {
42
+ constructor(knowledgeTracker: KnowledgeTracker, options: { disabled?: boolean } = {}) {
43
43
  const configParser = ConfigParser.getInstance();
44
44
  const config = configParser.getConfig();
45
- const configPath = configParser.getConfigPath();
46
- this.disabled = options.disabled ?? false;
47
- this.knowledgeTracker = new KnowledgeTracker();
45
+ this.disabled = options.disabled ?? config.experience?.disabled ?? false;
46
+ this.knowledgeTracker = knowledgeTracker;
48
47
 
49
48
  // Resolve experience directory relative to the config file location (project root)
50
- if (configPath) {
51
- const projectRoot = dirname(configPath);
52
- this.experienceDir = join(projectRoot, config.dirs?.experience || 'experience');
53
- } else {
54
- this.experienceDir = config.dirs?.experience || 'experience';
55
- }
49
+ this.experienceDir = configParser.resolveProjectDir(config.dirs?.experience || 'experience');
56
50
 
57
51
  if (!this.disabled) {
58
52
  this.ensureDirectory(this.experienceDir);
@@ -99,7 +93,10 @@ export class ExperienceTracker {
99
93
  }
100
94
 
101
95
  readExperienceFile(stateHash: string): { content: string; data: any } {
102
- const filePath = this.getExperienceFilePath(stateHash);
96
+ return this.readExperienceFileAt(this.getExperienceFilePath(stateHash));
97
+ }
98
+
99
+ private readExperienceFileAt(filePath: string): { content: string; data: any } {
103
100
  const fileContent = readFileSync(filePath, 'utf8');
104
101
  const { content, data } = matter(fileContent);
105
102
  return { content, data };
@@ -114,21 +111,6 @@ export class ExperienceTracker {
114
111
  writeFileSync(filePath, fileContent, 'utf8');
115
112
  }
116
113
 
117
- hasRecentExperience(stateHash: string, prefix = ''): boolean {
118
- if (this.disabled) {
119
- return false;
120
- }
121
- if (prefix) {
122
- stateHash = `${prefix}_${stateHash}`;
123
- }
124
- const filePath = this.getExperienceFilePath(stateHash);
125
- if (!existsSync(filePath)) {
126
- return false;
127
- }
128
- const stats = statSync(filePath);
129
- return stats.mtime.getTime() > Date.now() - 1000 * 60 * 60 * 24;
130
- }
131
-
132
114
  private getExperienceFilePath(stateHash: string): string {
133
115
  return join(this.experienceDir, `${stateHash}.md`);
134
116
  }
@@ -176,7 +158,12 @@ export class ExperienceTracker {
176
158
  this.ensureExperienceFile(state);
177
159
  const stateHash = state.getStateHash();
178
160
  const { content, data } = this.readExperienceFile(stateHash);
179
- if (content.includes(action.code)) {
161
+ if (
162
+ mdq(content)
163
+ .query('code')
164
+ .meta()
165
+ .some((m) => m.text.trim() === action.code.trim())
166
+ ) {
180
167
  debugLog('Skipping duplicate action', action.code);
181
168
  return;
182
169
  }
@@ -204,7 +191,12 @@ export class ExperienceTracker {
204
191
  const stateHash = state.getStateHash();
205
192
  const { content, data } = this.readExperienceFile(stateHash);
206
193
 
207
- if (content.includes(body)) {
194
+ if (
195
+ mdq(content)
196
+ .query('section2')
197
+ .each()
198
+ .some((s) => s.text().trim() === body.trim())
199
+ ) {
208
200
  debugLog('Skipping duplicate flow body');
209
201
  return;
210
202
  }
@@ -231,25 +223,11 @@ export class ExperienceTracker {
231
223
  }
232
224
 
233
225
  try {
234
- const files = readdirSync(experienceDir)
235
- .filter((file: string) => file.endsWith('.md'))
236
- .map((file: string) => join(experienceDir, file));
237
-
238
- for (const file of files) {
239
- try {
240
- const content = readFileSync(file, 'utf8');
241
- const parsed = matter(content);
242
- const mtime = statSync(file).mtime;
243
- allFiles.push({
244
- filePath: file,
245
- data: parsed.data,
246
- content: parsed.content,
247
- mtime,
248
- });
249
- } catch (error) {
250
- debugLog(`Failed to read experience file ${file}:`, error);
251
- }
252
- }
226
+ allFiles.push(
227
+ ...loadMarkdownFiles(experienceDir, {
228
+ onError: (filePath, error) => debugLog(`Failed to read experience file ${filePath}:`, error),
229
+ })
230
+ );
253
231
  } catch (error) {
254
232
  debugLog(`Failed to read experience directory ${experienceDir}:`, error);
255
233
  }
@@ -275,20 +253,17 @@ export class ExperienceTracker {
275
253
  });
276
254
  })
277
255
  .map((experience) => {
278
- const lines = experience.content.split('\n');
279
- if (lines.length <= maxLines) return experience;
280
- return { ...experience, content: lines.slice(0, maxLines).join('\n') };
256
+ if (experience.content.split('\n').length <= maxLines) return experience;
257
+ let content = experience.content;
258
+ while (content.split('\n').length > maxLines) {
259
+ const sections = mdq(content).query('section2').each();
260
+ if (sections.length <= 1) break;
261
+ content = sections[sections.length - 1].replace('');
262
+ }
263
+ return { ...experience, content };
281
264
  });
282
265
  }
283
266
 
284
- /**
285
- * Clean up experience tracker (for testing)
286
- */
287
- cleanup(): void {
288
- // Clear any in-memory state if needed
289
- // The actual files will be cleaned up by test cleanup
290
- }
291
-
292
267
  getSuccessfulExperience(state: ActionResult, options?: { includeDescendants?: boolean; stripCode?: boolean }): string[] {
293
268
  const records = this.getRelevantExperience(state, {
294
269
  includeDescendantExperience: options?.includeDescendants,
@@ -326,36 +301,50 @@ export class ExperienceTracker {
326
301
  return aHash.localeCompare(bHash);
327
302
  });
328
303
 
304
+ return this.buildToc(sorted);
305
+ }
306
+
307
+ renderExperienceTocFor(state: ActionResult): string {
308
+ const toc = this.getExperienceTableOfContents(state);
309
+ if (toc.length === 0) return '';
310
+
311
+ const totalSections = toc.reduce((sum, entry) => sum + entry.sections.length, 0);
312
+ debugLog(`injecting experience TOC (${toc.length} files, ${totalSections} sections)`);
313
+ tag('operation').log(`Found ${toc.length} experience ${pluralize(toc.length, 'file')} (${totalSections} sections)`);
314
+ return renderExperienceToc(toc);
315
+ }
316
+
317
+ getExperienceSection(fileTag: string, sectionIndex: number, state: ActionResult, options?: { includeDescendantExperience?: boolean }): { title: string; url: string; content: string } | null {
318
+ const toc = this.getExperienceTableOfContents(state, options);
319
+ const entry = toc.find((e) => e.fileTag === fileTag);
320
+ if (!entry) return null;
321
+
322
+ const extracted = this.readSectionByHash(entry.fileHash, sectionIndex);
323
+ if (!extracted) return null;
324
+
325
+ return { title: extracted.title, url: entry.url, content: extracted.body };
326
+ }
327
+
328
+ private buildToc(records: ExperienceFile[]): ExperienceTocEntry[] {
329
329
  const toc: ExperienceTocEntry[] = [];
330
- for (let i = 0; i < sorted.length; i++) {
331
- const record = sorted[i];
332
- const fileHash = basename(record.filePath, '.md');
333
- const url = (record.data as WebPageState)?.url || '';
330
+ for (const record of records) {
334
331
  const sections = listTocHeadings(record.content);
335
332
  if (sections.length === 0) continue;
336
333
  toc.push({
337
- fileTag: indexToLetters(i),
338
- fileHash,
339
- url,
334
+ fileTag: indexToLetters(toc.length),
335
+ fileHash: basename(record.filePath, '.md'),
336
+ url: (record.data as WebPageState)?.url || '',
340
337
  sections,
341
338
  });
342
339
  }
343
340
  return toc;
344
341
  }
345
342
 
346
- getExperienceSection(fileTag: string, sectionIndex: number, state: ActionResult, options?: { includeDescendantExperience?: boolean }): { title: string; url: string; content: string } | null {
347
- const toc = this.getExperienceTableOfContents(state, options);
348
- const entry = toc.find((e) => e.fileTag === fileTag);
349
- if (!entry) return null;
350
-
351
- const filePath = this.findExperienceFileByHash(entry.fileHash);
343
+ private readSectionByHash(fileHash: string, sectionIndex: number): { title: string; body: string } | null {
344
+ const filePath = this.findExperienceFileByHash(fileHash);
352
345
  if (!filePath) return null;
353
-
354
- const { content } = this.readExperienceFile(entry.fileHash);
355
- const extracted = extractHeadingSection(content, sectionIndex);
356
- if (!extracted) return null;
357
-
358
- return { title: extracted.title, url: entry.url, content: extracted.body };
346
+ const { content } = this.readExperienceFileAt(filePath);
347
+ return extractHeadingSection(content, sectionIndex);
359
348
  }
360
349
 
361
350
  private findExperienceFileByHash(fileHash: string): string | null {
@@ -402,19 +391,7 @@ export class ExperienceTracker {
402
391
  return aUrl.localeCompare(bUrl);
403
392
  });
404
393
 
405
- const toc: ExperienceTocEntry[] = [];
406
- for (let i = 0; i < sorted.length; i++) {
407
- const record = sorted[i];
408
- const sections = listTocHeadings(record.content);
409
- if (sections.length === 0) continue;
410
- toc.push({
411
- fileTag: indexToLetters(toc.length),
412
- fileHash: basename(record.filePath, '.md'),
413
- url: (record.data as WebPageState)?.url || '',
414
- sections,
415
- });
416
- }
417
- return toc;
394
+ return this.buildToc(sorted);
418
395
  }
419
396
 
420
397
  getExperienceSectionByTag(fileTag: string, sectionIndex: number, filter?: string): { title: string; url: string; content: string; fileHash: string } | null {
@@ -422,11 +399,7 @@ export class ExperienceTracker {
422
399
  const entry = toc.find((e) => e.fileTag === fileTag);
423
400
  if (!entry) return null;
424
401
 
425
- const filePath = this.findExperienceFileByHash(entry.fileHash);
426
- if (!filePath) return null;
427
-
428
- const { content } = this.readExperienceFile(entry.fileHash);
429
- const extracted = extractHeadingSection(content, sectionIndex);
402
+ const extracted = this.readSectionByHash(entry.fileHash, sectionIndex);
430
403
  if (!extracted) return null;
431
404
 
432
405
  return { title: extracted.title, url: entry.url, content: extracted.body, fileHash: entry.fileHash };
@@ -434,49 +407,22 @@ export class ExperienceTracker {
434
407
  }
435
408
 
436
409
  function listTocHeadings(content: string): { index: number; level: 2 | 3; title: string }[] {
437
- const tokens = marked.lexer(content);
438
- const result: { index: number; level: 2 | 3; title: string }[] = [];
439
- let index = 0;
440
- for (const token of tokens) {
441
- if (token.type !== 'heading') continue;
442
- const heading = token as Tokens.Heading;
443
- if (heading.depth !== 2 && heading.depth !== 3) continue;
444
- index++;
445
- result.push({ index, level: heading.depth as 2 | 3, title: heading.text });
446
- }
447
- return result;
410
+ return mdq(content)
411
+ .query('heading')
412
+ .meta()
413
+ .filter((m) => m.depth === 2 || m.depth === 3)
414
+ .map((m, i) => ({ index: i + 1, level: m.depth as 2 | 3, title: m.text }));
448
415
  }
449
416
 
450
417
  function extractHeadingSection(content: string, sectionIndex: number): { title: string; body: string } | null {
451
- const tokens = marked.lexer(content);
452
- const matching: { tokenIdx: number; depth: number; text: string }[] = [];
453
-
454
- for (let i = 0; i < tokens.length; i++) {
455
- const token = tokens[i];
456
- if (token.type !== 'heading') continue;
457
- const heading = token as Tokens.Heading;
458
- if (heading.depth !== 2 && heading.depth !== 3) continue;
459
- matching.push({ tokenIdx: i, depth: heading.depth, text: heading.text });
460
- }
418
+ const sections = mdq(content).query('section').each();
419
+ const metas = mdq(content).query('section').meta();
420
+ const matching = metas.map((meta, i) => ({ meta, section: sections[i] })).filter((entry) => entry.meta.depth === 2 || entry.meta.depth === 3);
461
421
 
462
422
  if (sectionIndex < 1 || sectionIndex > matching.length) return null;
463
423
 
464
424
  const target = matching[sectionIndex - 1];
465
- let endTokenIdx = tokens.length;
466
- for (let j = target.tokenIdx + 1; j < tokens.length; j++) {
467
- const token = tokens[j];
468
- if (token.type !== 'heading') continue;
469
- if ((token as Tokens.Heading).depth <= target.depth) {
470
- endTokenIdx = j;
471
- break;
472
- }
473
- }
474
-
475
- const body = tokens
476
- .slice(target.tokenIdx, endTokenIdx)
477
- .map((t) => (t as any).raw || '')
478
- .join('');
479
- return { title: target.text, body };
425
+ return { title: target.meta.text, body: target.section.text() };
480
426
  }
481
427
 
482
428
  function indexToLetters(index: number): string {
@@ -545,23 +491,14 @@ function generateActionContent(title: string, code: string, explanation?: string
545
491
  }
546
492
 
547
493
  function renderAsHowTo(content: string): string {
548
- const tokens = marked.lexer(content);
549
- let result = '';
550
- for (const token of tokens) {
551
- if (token.type === 'heading' && (token as Tokens.Heading).depth === 2) {
552
- const text = (token as Tokens.Heading).text.trim();
553
- if (text.startsWith('FLOW:')) {
554
- result += `## HOW to ${text.slice(5).trim()} (multi-step)\n\n`;
555
- continue;
556
- }
557
- if (text.startsWith('ACTION:')) {
558
- result += `## HOW to ${text.slice(7).trim()} (single-step)\n\n`;
559
- continue;
560
- }
561
- }
562
- result += (token as any).raw || '';
563
- }
564
- return result;
494
+ return mdq(content)
495
+ .query('h2')
496
+ .replaceEach((heading) => {
497
+ const text = heading.meta()[0]?.text.trim() || '';
498
+ if (text.startsWith('FLOW:')) return `## HOW to ${text.slice(5).trim()} (multi-step)\n\n`;
499
+ if (text.startsWith('ACTION:')) return `## HOW to ${text.slice(7).trim()} (single-step)\n\n`;
500
+ return heading.text();
501
+ });
565
502
  }
566
503
 
567
504
  export interface ExperienceFile {