explorbot 0.2.4 → 0.2.5

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 (63) hide show
  1. package/bin/explorbot-cli.ts +19 -7
  2. package/boat/api-tester/src/cli.ts +17 -0
  3. package/boat/doc-collector/src/cli.ts +14 -1
  4. package/boat/prima/src/cli.ts +24 -12
  5. package/boat/prima/src/envelope.ts +35 -13
  6. package/boat/prima/src/prima.ts +61 -41
  7. package/dist/bin/explorbot-cli.js +19 -7
  8. package/dist/boat/api-tester/src/cli.js +17 -0
  9. package/dist/boat/doc-collector/src/cli.js +14 -1
  10. package/dist/boat/prima/src/cli.js +19 -7
  11. package/dist/boat/prima/src/envelope.js +32 -8
  12. package/dist/boat/prima/src/prima.js +57 -39
  13. package/dist/package.json +1 -1
  14. package/dist/src/action.js +5 -1
  15. package/dist/src/ai/navigator.d.ts +27 -0
  16. package/dist/src/ai/navigator.js +227 -175
  17. package/dist/src/ai/pilot.d.ts +7 -4
  18. package/dist/src/ai/pilot.js +50 -8
  19. package/dist/src/ai/provider.d.ts +2 -2
  20. package/dist/src/ai/provider.js +12 -21
  21. package/dist/src/ai/researcher/cache.d.ts +2 -0
  22. package/dist/src/ai/researcher/cache.js +10 -2
  23. package/dist/src/ai/researcher.js +2 -1
  24. package/dist/src/ai/session-analyst.js +2 -0
  25. package/dist/src/ai/tester.d.ts +5 -2
  26. package/dist/src/ai/tester.js +17 -13
  27. package/dist/src/ai/tools.js +4 -1
  28. package/dist/src/commands/config-command.d.ts +51 -0
  29. package/dist/src/commands/config-command.js +117 -0
  30. package/dist/src/commands/index.js +2 -0
  31. package/dist/src/config.d.ts +8 -1
  32. package/dist/src/config.js +40 -0
  33. package/dist/src/explorbot.js +4 -1
  34. package/dist/src/remote.d.ts +3 -2
  35. package/dist/src/remote.js +8 -2
  36. package/dist/src/state-manager.d.ts +1 -1
  37. package/dist/src/state-manager.js +3 -1
  38. package/dist/src/test-plan.d.ts +1 -0
  39. package/dist/src/test-plan.js +19 -0
  40. package/dist/src/utils/logger.d.ts +1 -1
  41. package/dist/src/utils/logger.js +8 -0
  42. package/docs/index.json +2 -1
  43. package/docs/reference/commands.md +3 -0
  44. package/docs/reference/websocket.md +50 -0
  45. package/docs/superpowers/specs/2026-08-18-prima-false-verdicts.md +159 -0
  46. package/package.json +1 -1
  47. package/src/action.ts +5 -1
  48. package/src/ai/navigator.ts +241 -178
  49. package/src/ai/pilot.ts +63 -12
  50. package/src/ai/provider.ts +12 -20
  51. package/src/ai/researcher/cache.ts +12 -2
  52. package/src/ai/researcher.ts +2 -1
  53. package/src/ai/session-analyst.ts +2 -0
  54. package/src/ai/tester.ts +20 -12
  55. package/src/ai/tools.ts +4 -1
  56. package/src/commands/config-command.ts +146 -0
  57. package/src/commands/index.ts +2 -0
  58. package/src/config.ts +45 -1
  59. package/src/explorbot.ts +3 -1
  60. package/src/remote.ts +8 -2
  61. package/src/state-manager.ts +5 -2
  62. package/src/test-plan.ts +20 -0
  63. package/src/utils/logger.ts +9 -1
@@ -3,6 +3,7 @@ import { LangfuseSpanProcessor } from '@langfuse/otel';
3
3
  import { NodeSDK } from '@opentelemetry/sdk-node';
4
4
  import { generateObject, generateText, isStepCount, registerTelemetry } from 'ai';
5
5
  import { clearActivity, setActivity } from "../activity.js";
6
+ import { configuredModels, modelName as getModelName } from '../config.js';
6
7
  import { executionController } from "../execution-controller.js";
7
8
  import { Observability } from "../observability.js";
8
9
  import { Stats } from "../stats.js";
@@ -79,9 +80,6 @@ export class Provider {
79
80
  this.config = config;
80
81
  this.initLangfuse();
81
82
  }
82
- getModelName(model) {
83
- return model?.modelId || model?.model || 'unknown';
84
- }
85
83
  async validateConnection() {
86
84
  try {
87
85
  await generateText({
@@ -108,16 +106,13 @@ export class Provider {
108
106
  }
109
107
  return this.config.agenticModel || this.config.model;
110
108
  }
109
+ getVisionModel() {
110
+ return this.config.visionModel;
111
+ }
111
112
  getConfiguredModels() {
112
- const models = { model: this.getModelName(this.config.model) };
113
- if (this.config.agenticModel)
114
- models.agenticModel = this.getModelName(this.config.agenticModel);
115
- if (this.config.visionModel)
116
- models.visionModel = this.getModelName(this.config.visionModel);
117
- for (const [agent, agentConfig] of Object.entries(this.config.agents || {})) {
118
- if (agentConfig?.model)
119
- models[agent] = this.getModelName(agentConfig.model);
120
- }
113
+ const models = {};
114
+ for (const [role, model] of Object.entries(configuredModels(this.config)))
115
+ models[role] = model.name;
121
116
  return models;
122
117
  }
123
118
  getSystemPromptForAgent(agentName, currentUrl) {
@@ -207,11 +202,7 @@ export class Provider {
207
202
  return retry(reduced.messages, { ...options, _contextRetryLevel: reduced.nextLevel });
208
203
  }
209
204
  initLangfuse() {
210
- const langfuseConfig = this.config.langfuse;
211
- const publicKey = langfuseConfig?.publicKey || process.env.LANGFUSE_PUBLIC_KEY;
212
- const secretKey = langfuseConfig?.secretKey || process.env.LANGFUSE_SECRET_KEY;
213
- const baseUrl = langfuseConfig?.baseUrl || process.env.LANGFUSE_BASE_URL || process.env.LANGFUSE_HOST;
214
- const enabled = langfuseConfig?.enabled ?? Boolean(publicKey && secretKey);
205
+ const { enabled, publicKey, secretKey, baseUrl } = this.config.langfuse || {};
215
206
  if (!enabled || !publicKey || !secretKey) {
216
207
  return;
217
208
  }
@@ -282,7 +273,7 @@ export class Provider {
282
273
  return { conversation, response, toolExecutions };
283
274
  }
284
275
  async chat(messages, model, options = {}) {
285
- const modelName = this.getModelName(model);
276
+ const modelName = getModelName(model);
286
277
  setActivity(`🤖 Asking ${modelName}`, 'ai');
287
278
  promptLog(`Using model: ${modelName}`);
288
279
  const config = this.buildGenerateConfig({ maxOutputTokens: 16384 }, { model, abortSignal: executionController.getAbortSignal() }, options);
@@ -324,7 +315,7 @@ export class Provider {
324
315
  }
325
316
  }
326
317
  async generateWithTools(messages, model, tools, options = {}) {
327
- const modelName = this.getModelName(model);
318
+ const modelName = getModelName(model);
328
319
  setActivity(`🤖 Asking ${modelName} with dynamic tools`, 'ai');
329
320
  promptLog(`Using model: ${modelName}`);
330
321
  const toolNames = Object.keys(tools || {});
@@ -379,7 +370,7 @@ export class Provider {
379
370
  }
380
371
  async generateObject(messages, schema, model, options = {}) {
381
372
  const modelToUse = model || this.config.model;
382
- const modelName = this.getModelName(modelToUse);
373
+ const modelName = getModelName(modelToUse);
383
374
  setActivity(`🤖 Asking ${modelName} for structured output`, 'ai');
384
375
  promptLog(`Using model: ${modelName}`);
385
376
  const config = this.buildGenerateConfig({ schema }, { model: modelToUse }, options);
@@ -557,7 +548,7 @@ export class Provider {
557
548
  }, this.getRetryOptions());
558
549
  clearActivity();
559
550
  responseLog(response.text);
560
- this.recordUsage('vision', this.getModelName(this.config.visionModel), response.usage);
551
+ this.recordUsage('vision', getModelName(this.config.visionModel), response.usage);
561
552
  return response;
562
553
  }
563
554
  catch (error) {
@@ -1,3 +1,5 @@
1
+ export declare function researchPath(hash: string): string;
2
+ export declare function reportResearch(hash: string, text: string): void;
1
3
  export declare function clearResearchCache(): void;
2
4
  export declare function getCachedResearch(hash: string): string;
3
5
  export declare function getPreviousResearch(hash: string): string;
@@ -4,6 +4,7 @@ import { Worker } from 'node:worker_threads';
4
4
  import { outputPath } from "../../config.js";
5
5
  import { TTLCache } from "../../utils/cache.js";
6
6
  import { computeHtmlFingerprint } from "../../utils/html-diff.js";
7
+ import { tag } from "../../utils/logger.js";
7
8
  import { debugLog } from "./mixin.js";
8
9
  const CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 6 hours
9
10
  const FINGERPRINT_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
@@ -11,6 +12,12 @@ const FINGERPRINT_WORKER_TIMEOUT_MS = 10_000;
11
12
  const SIMILARITY_THRESHOLD = 90;
12
13
  const memoryCache = new TTLCache(CACHE_TTL_MS);
13
14
  let fingerprintWorker = null;
15
+ export function researchPath(hash) {
16
+ return outputPath('research', `${hash}.md`);
17
+ }
18
+ export function reportResearch(hash, text) {
19
+ tag('data').log('research', { path: researchPath(hash), hash, content: text });
20
+ }
14
21
  function getStatesDir() {
15
22
  return outputPath('states');
16
23
  }
@@ -30,7 +37,7 @@ export function getCachedResearch(hash) {
30
37
  const cached = memoryCache.get(hash);
31
38
  if (cached !== undefined)
32
39
  return cached;
33
- const researchFile = outputPath('research', `${hash}.md`);
40
+ const researchFile = researchPath(hash);
34
41
  if (!existsSync(researchFile))
35
42
  return '';
36
43
  const stats = statSync(researchFile);
@@ -43,7 +50,7 @@ export function getCachedResearch(hash) {
43
50
  export function getPreviousResearch(hash) {
44
51
  if (!hash)
45
52
  return '';
46
- const researchFile = outputPath('research', `${hash}.md`);
53
+ const researchFile = researchPath(hash);
47
54
  if (!existsSync(researchFile))
48
55
  return '';
49
56
  return readFileSync(researchFile, 'utf8');
@@ -55,6 +62,7 @@ export function saveResearch(hash, text, combinedHtml) {
55
62
  mkdirSync(researchDir, { recursive: true });
56
63
  writeFileSync(researchFile, text);
57
64
  memoryCache.set(hash, text);
65
+ reportResearch(hash, text);
58
66
  debugLog(`Research saved to ${researchFile}`);
59
67
  if (combinedHtml) {
60
68
  const statesDir = getStatesDir();
@@ -13,7 +13,7 @@ import { mdq } from "../utils/markdown-query.js";
13
13
  import { RulesLoader } from "../utils/rules-loader.js";
14
14
  import { annotatePageElements } from "../utils/web-annotate.js";
15
15
  import { ContextLengthError } from './provider.js';
16
- import { findSimilarResearch, getCachedResearch, saveResearch } from "./researcher/cache.js";
16
+ import { findSimilarResearch, getCachedResearch, reportResearch, saveResearch } from "./researcher/cache.js";
17
17
  import { WithCoordinates } from "./researcher/coordinates.js";
18
18
  import { WithDeepAnalysis } from "./researcher/deep-analysis.js";
19
19
  import { detectFocusedSection, hasFocusedSection, markSectionAsFocused, pickDefaultFocusedSection } from "./researcher/focus.js";
@@ -76,6 +76,7 @@ export class Researcher extends ResearcherBase {
76
76
  const cached = getCachedResearch(stateHash);
77
77
  if (cached) {
78
78
  debugLog('Previous research result found');
79
+ reportResearch(stateHash, cached);
79
80
  return cached;
80
81
  }
81
82
  }
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import dedent from 'dedent';
4
4
  import { outputPath } from "../config.js";
5
5
  import { Stats } from "../stats.js";
6
+ import { tag } from "../utils/logger.js";
6
7
  export class SessionAnalyst {
7
8
  emoji = '🧐';
8
9
  provider;
@@ -103,6 +104,7 @@ export class SessionAnalyst {
103
104
  if (!existsSync(dir))
104
105
  mkdirSync(dir, { recursive: true });
105
106
  writeFileSync(filePath, markdown);
107
+ tag('data').log('report', { path: filePath, content: markdown });
106
108
  return filePath;
107
109
  }
108
110
  serializeTest(test, ref) {
@@ -39,10 +39,10 @@ export declare class Tester extends TaskAgent implements Agent {
39
39
  getCurrentState(): ActionResult;
40
40
  get progressCheckInterval(): number;
41
41
  getConversation(): Conversation | null;
42
- test(task: Test): Promise<{
42
+ test(task: Test, opts?: TestOptions): Promise<{
43
43
  success: boolean;
44
44
  }>;
45
- runTestSession(task: Test, initialState: ActionResult, conversation: Conversation, handlers: TestSessionHandlers): Promise<{
45
+ runTestSession(task: Test, initialState: ActionResult, conversation: Conversation, handlers: TestSessionHandlers, opts: TestOptions): Promise<{
46
46
  success: boolean;
47
47
  }>;
48
48
  shouldAnalyzeProgress(iteration: number, currentState: ActionResult): boolean;
@@ -93,4 +93,7 @@ export declare class Tester extends TaskAgent implements Agent {
93
93
  interface TestSessionHandlers {
94
94
  offFailedRequest?: () => void;
95
95
  }
96
+ export interface TestOptions {
97
+ startOnCurrentPage?: boolean;
98
+ }
96
99
  export {};
@@ -74,7 +74,7 @@ export class Tester extends TaskAgent {
74
74
  getConversation() {
75
75
  return this.currentConversation;
76
76
  }
77
- async test(task) {
77
+ async test(task, opts = {}) {
78
78
  Stats.tests++;
79
79
  const state = this.stateManager.getCurrentState();
80
80
  if (!state)
@@ -120,9 +120,9 @@ export class Tester extends TaskAgent {
120
120
  startUrl: task.startUrl,
121
121
  expected: task.expected,
122
122
  },
123
- }, async () => this.runTestSession(task, initialState, conversation, { offFailedRequest }));
123
+ }, async () => this.runTestSession(task, initialState, conversation, { offFailedRequest }, opts));
124
124
  }
125
- async runTestSession(task, initialState, conversation, handlers) {
125
+ async runTestSession(task, initialState, conversation, handlers, opts) {
126
126
  const { offFailedRequest } = handlers;
127
127
  if (this.pilot) {
128
128
  try {
@@ -151,16 +151,20 @@ export class Tester extends TaskAgent {
151
151
  await this.cleanupStartedTest(task);
152
152
  return { success: task.isSuccessful };
153
153
  }
154
- debugLog(`Navigating to ${task.startUrl}`);
155
- try {
156
- await this.explorer.visit(task.startUrl);
157
- }
158
- catch (error) {
159
- const result = await this.handleLoopError(task, error);
160
- if (result === 'stop') {
161
- offFailedRequest?.();
162
- await this.cleanupStartedTest(task);
163
- return { success: task.isSuccessful };
154
+ if (opts.startOnCurrentPage)
155
+ debugLog(`Starting on the page already open at ${task.startUrl}`);
156
+ if (!opts.startOnCurrentPage) {
157
+ debugLog(`Navigating to ${task.startUrl}`);
158
+ try {
159
+ await this.explorer.visit(task.startUrl);
160
+ }
161
+ catch (error) {
162
+ const result = await this.handleLoopError(task, error);
163
+ if (result === 'stop') {
164
+ offFailedRequest?.();
165
+ await this.cleanupStartedTest(task);
166
+ return { success: task.isSuccessful };
167
+ }
164
168
  }
165
169
  }
166
170
  const startState = this.stateManager.getCurrentState();
@@ -698,7 +698,10 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
698
698
  message: `Successfully executed: ${instruction}`,
699
699
  });
700
700
  }
701
- return failedToolResult('interact', `Failed to execute: ${instruction}`, {
701
+ let reason = '';
702
+ if (navigator.lastFailureReason)
703
+ reason = `: ${navigator.lastFailureReason}`;
704
+ return failedToolResult('interact', `Failed to execute: ${instruction}${reason}`, {
702
705
  ...toolResult,
703
706
  suggestion: 'The action could not be completed. Try a different instruction or use more specific element descriptions.',
704
707
  });
@@ -0,0 +1,51 @@
1
+ import { type AIConfig, type ReporterConfig } from '../config.js';
2
+ import { BaseCommand } from './base-command.js';
3
+ export declare class ConfigCommand extends BaseCommand {
4
+ name: string;
5
+ description: string;
6
+ execute(): Promise<void>;
7
+ static summary(options?: {
8
+ config?: string;
9
+ path?: string;
10
+ url?: string;
11
+ json?: boolean;
12
+ }): Promise<string>;
13
+ static data(config: SummarizedConfig, options?: ConfigSummaryOptions): ConfigData;
14
+ static render(config: SummarizedConfig, options?: ConfigSummaryOptions): string;
15
+ }
16
+ interface ConfigSummaryOptions {
17
+ configPath?: string | null;
18
+ root?: string;
19
+ json?: boolean;
20
+ }
21
+ export interface ConfigData {
22
+ config: string;
23
+ url: string;
24
+ browser: string;
25
+ headless: boolean;
26
+ dirs: Record<string, string>;
27
+ models: Record<string, string>;
28
+ providers: Record<string, string>;
29
+ integrations: {
30
+ langfuse: boolean;
31
+ testomatio: boolean;
32
+ };
33
+ env: Record<string, string>;
34
+ }
35
+ interface SummarizedConfig {
36
+ ai?: AIConfig;
37
+ playwright?: {
38
+ url?: string;
39
+ browser?: string;
40
+ show?: boolean;
41
+ };
42
+ web?: {
43
+ url?: string;
44
+ };
45
+ api?: {
46
+ baseEndpoint?: string;
47
+ };
48
+ dirs?: Record<string, string>;
49
+ reporter?: ReporterConfig;
50
+ }
51
+ export {};
@@ -0,0 +1,117 @@
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import chalk from 'chalk';
4
+ import { ConfigParser, EXPLORBOT_ENV_VARS, configuredModels } from '../config.js';
5
+ import { listSites } from '../global-config.js';
6
+ import { Reporter } from '../reporter.js';
7
+ import { getCliName } from '../utils/cli-name.js';
8
+ import { tag } from '../utils/logger.js';
9
+ import { BaseCommand } from './base-command.js';
10
+ export class ConfigCommand extends BaseCommand {
11
+ name = 'config';
12
+ description = 'Show models, config file and paths used by this run';
13
+ async execute() {
14
+ const parser = ConfigParser.getInstance();
15
+ tag('info').log(ConfigCommand.render(this.explorBot.getConfig(), { configPath: parser.getConfigPath(), root: parser.getProjectRoot() }));
16
+ }
17
+ static async summary(options = {}) {
18
+ const parser = ConfigParser.getInstance();
19
+ const [site] = listSites();
20
+ const load = (baseUrl) => parser.loadConfig({ config: options.config, path: options.path, baseUrl });
21
+ const config = await load(options.url).catch((error) => {
22
+ if (!site)
23
+ throw error;
24
+ return load(site.url);
25
+ });
26
+ return ConfigCommand.render(config, { configPath: parser.getConfigPath(), root: parser.getProjectRoot(), json: options.json });
27
+ }
28
+ static data(config, options = {}) {
29
+ let configPath = '';
30
+ if (options.configPath && existsSync(options.configPath))
31
+ configPath = options.configPath;
32
+ const dirs = {};
33
+ if (options.root) {
34
+ for (const [name, dir] of Object.entries({ output: 'output', ...config.dirs })) {
35
+ dirs[name] = path.join(options.root, dir);
36
+ }
37
+ }
38
+ const env = {};
39
+ for (const variable of EXPLORBOT_ENV_VARS) {
40
+ const value = process.env[variable.name];
41
+ if (value)
42
+ env[variable.name] = value;
43
+ }
44
+ const models = {};
45
+ const providers = {};
46
+ for (const [role, model] of Object.entries(configuredModels(config.ai))) {
47
+ models[role] = model.name;
48
+ if (model.provider)
49
+ providers[role] = model.provider;
50
+ }
51
+ return {
52
+ config: configPath,
53
+ url: config.playwright?.url || config.web?.url || config.api?.baseEndpoint || '',
54
+ browser: config.playwright?.browser || '',
55
+ headless: !config.playwright?.show,
56
+ dirs,
57
+ models,
58
+ providers,
59
+ integrations: {
60
+ langfuse: !!config.ai?.langfuse?.enabled,
61
+ testomatio: Reporter.resolveEnabled(config.reporter),
62
+ },
63
+ env,
64
+ };
65
+ }
66
+ static render(config, options = {}) {
67
+ const data = ConfigCommand.data(config, options);
68
+ if (options.json)
69
+ return JSON.stringify(data, null, 2);
70
+ const lines = [];
71
+ const section = (title, entries) => {
72
+ if (!entries.length)
73
+ return;
74
+ const width = Math.max(...entries.map(([label]) => label.length));
75
+ lines.push(chalk.bold(title));
76
+ for (const [label, value] of entries)
77
+ lines.push(` ${chalk.dim(label.padEnd(width))} ${value}`);
78
+ lines.push('');
79
+ };
80
+ const general = [['config', data.config || 'EXPLORBOT_* environment variables']];
81
+ if (data.url)
82
+ general.push(['url', data.url]);
83
+ if (data.browser) {
84
+ let window = 'visible';
85
+ if (data.headless)
86
+ window = 'headless';
87
+ general.push(['browser', `${data.browser}, ${window}`]);
88
+ }
89
+ for (const [name, dir] of Object.entries(data.dirs))
90
+ general.push([name, dir]);
91
+ section('Config', general);
92
+ const providerWidth = Math.max(0, ...Object.values(data.providers).map((provider) => provider.length));
93
+ const models = Object.entries(data.models).map(([role, model]) => {
94
+ if (!providerWidth)
95
+ return [role, model];
96
+ return [role, `${chalk.dim((data.providers[role] || '').padEnd(providerWidth))} ${model}`];
97
+ });
98
+ if (!models.length)
99
+ models.push(['model', chalk.red(`not configured — run ${getCliName()} init`)]);
100
+ section('Models', models);
101
+ const integrations = [];
102
+ if (data.integrations.langfuse)
103
+ integrations.push(['langfuse', 'traces sent']);
104
+ if (data.integrations.testomatio)
105
+ integrations.push(['testomatio', 'runs reported']);
106
+ section('Integrations', integrations);
107
+ const env = Object.entries(data.env).map(([name, value]) => {
108
+ let shown = value;
109
+ if (shown.length > 60)
110
+ shown = `${shown.slice(0, 57)}...`;
111
+ return [name, shown];
112
+ });
113
+ section('Environment', env);
114
+ lines.push(chalk.dim(`Every EXPLORBOT_* variable: ${getCliName()} --help`));
115
+ return lines.join('\n');
116
+ }
117
+ }
@@ -1,6 +1,7 @@
1
1
  import { AddRuleCommand } from './add-rule-command.js';
2
2
  import { CleanCommand } from './clean-command.js';
3
3
  import { CompactCommand } from './compact-command.js';
4
+ import { ConfigCommand } from './config-command.js';
4
5
  import { ContextAriaCommand } from './context-aria-command.js';
5
6
  import { ContextCommand } from './context-command.js';
6
7
  import { ContextDataCommand } from './context-data-command.js';
@@ -64,6 +65,7 @@ const commandClasses = [
64
65
  RunsCommand,
65
66
  RerunCommand,
66
67
  StatusCommand,
68
+ ConfigCommand,
67
69
  DebugCommand,
68
70
  ExitCommand,
69
71
  ];
@@ -272,12 +272,19 @@ export declare class ConfigMissingError extends Error {
272
272
  }
273
273
  export declare function envConfigRequested(): boolean;
274
274
  export declare function missingConfigMessage(configFile?: string): string;
275
+ export declare function modelName(model: unknown): string;
276
+ export declare function modelProvider(model: unknown): string;
277
+ export declare function configuredModels(ai?: AIConfig): Record<string, ConfiguredModel>;
275
278
  export declare function resolveConfigModels(ai?: AIConfig): Promise<void>;
276
279
  export declare function resolveOutputRoot(baseUrl?: string): string;
277
280
  export declare function resolveStateRoot(baseUrl: string, ephemeral?: boolean): string;
278
281
  export declare function materializeKnowledge(outputRoot: string): void;
279
282
  export declare function createModel(provider: string, modelId: string): Promise<any>;
280
283
  type ModelRole = 'model' | 'visionModel' | 'agenticModel';
284
+ interface ConfiguredModel {
285
+ name: string;
286
+ provider: string;
287
+ }
281
288
  interface ProviderInfo {
282
289
  envKey: string;
283
290
  load: () => Promise<(modelId: string) => any>;
@@ -287,4 +294,4 @@ interface EnvVar {
287
294
  description: string;
288
295
  required?: boolean;
289
296
  }
290
- export type { ModelRole, EnvVar, ProviderInfo };
297
+ export type { ModelRole, EnvVar, ProviderInfo, ConfiguredModel };
@@ -231,6 +231,7 @@ export class ConfigParser {
231
231
  model: { modelId: 'test-model', provider: 'test' },
232
232
  config: {},
233
233
  vision: false,
234
+ langfuse: { enabled: false },
234
235
  },
235
236
  dirs: {
236
237
  knowledge: join(testBaseDir, 'knowledge'),
@@ -358,6 +359,17 @@ export class ConfigParser {
358
359
  config.playwright = config.playwright || { browser: 'chromium', url: '' };
359
360
  config.playwright.url = options.baseUrl;
360
361
  }
362
+ if (config.ai) {
363
+ const langfuse = config.ai.langfuse;
364
+ const publicKey = langfuse?.publicKey || process.env.LANGFUSE_PUBLIC_KEY;
365
+ const secretKey = langfuse?.secretKey || process.env.LANGFUSE_SECRET_KEY;
366
+ config.ai.langfuse = {
367
+ enabled: langfuse?.enabled ?? Boolean(publicKey && secretKey),
368
+ publicKey,
369
+ secretKey,
370
+ baseUrl: langfuse?.baseUrl || process.env.LANGFUSE_BASE_URL || process.env.LANGFUSE_HOST,
371
+ };
372
+ }
361
373
  return config;
362
374
  }
363
375
  validateConfig(config) {
@@ -454,6 +466,34 @@ export function missingConfigMessage(configFile = 'explorbot.config.js') {
454
466
  Providers: ${Object.keys(PROVIDERS).join(', ')}
455
467
  `;
456
468
  }
469
+ export function modelName(model) {
470
+ if (typeof model === 'string')
471
+ return model;
472
+ return model?.modelId || model?.model || 'unknown';
473
+ }
474
+ export function modelProvider(model) {
475
+ const provider = model?.provider;
476
+ if (typeof provider === 'string')
477
+ return provider.split('.')[0];
478
+ if (typeof model === 'string')
479
+ return model.split('/')[0];
480
+ return '';
481
+ }
482
+ export function configuredModels(ai) {
483
+ if (!ai?.model)
484
+ return {};
485
+ const describe = (model) => ({ name: modelName(model), provider: modelProvider(model) });
486
+ const models = { model: describe(ai.model) };
487
+ if (ai.agenticModel)
488
+ models.agenticModel = describe(ai.agenticModel);
489
+ if (ai.visionModel)
490
+ models.visionModel = describe(ai.visionModel);
491
+ for (const [agent, agentConfig] of Object.entries(ai.agents || {})) {
492
+ if (agentConfig?.model)
493
+ models[agent] = describe(agentConfig.model);
494
+ }
495
+ return models;
496
+ }
457
497
  export async function resolveConfigModels(ai) {
458
498
  if (!ai)
459
499
  return;
@@ -418,9 +418,12 @@ export class ExplorBot {
418
418
  }
419
419
  setCurrentPlan(plan) {
420
420
  this.currentPlan = plan;
421
- if (plan && !this.sessionPlans.includes(plan)) {
421
+ if (!plan)
422
+ return;
423
+ if (!this.sessionPlans.includes(plan)) {
422
424
  this.sessionPlans.push(plan);
423
425
  }
426
+ plan.notifyChange();
424
427
  }
425
428
  getSessionTests() {
426
429
  return this.sessionPlans.flatMap((p) => p.tests.filter((t) => t.startTime != null));
@@ -6,8 +6,9 @@ import { type LogDestination, type TaggedLogEntry } from './utils/logger.js';
6
6
  * process and a CI bot are the same case.
7
7
  *
8
8
  * It **is** a LogDestination — that is the whole integration on the logger's
9
- * side — and it answers asks by installing itself as the execution
10
- * controller's input callback. Nothing else in explorbot knows it exists.
9
+ * side, messages and `data` alike — and it answers asks by installing itself
10
+ * as the execution controller's input callback. Nothing else in explorbot
11
+ * knows it exists.
11
12
  */
12
13
  export declare class Remote implements LogDestination {
13
14
  url: string | null;
@@ -13,8 +13,9 @@ const FLUSH_TIMEOUT_MS = 3000;
13
13
  * process and a CI bot are the same case.
14
14
  *
15
15
  * It **is** a LogDestination — that is the whole integration on the logger's
16
- * side — and it answers asks by installing itself as the execution
17
- * controller's input callback. Nothing else in explorbot knows it exists.
16
+ * side, messages and `data` alike — and it answers asks by installing itself
17
+ * as the execution controller's input callback. Nothing else in explorbot
18
+ * knows it exists.
18
19
  */
19
20
  export class Remote {
20
21
  url = null;
@@ -105,6 +106,11 @@ export class Remote {
105
106
  write(entry) {
106
107
  if (entry.type === 'html')
107
108
  return;
109
+ if (entry.type === 'data') {
110
+ const [kind, payload] = entry.originalArgs || [];
111
+ this.send(String(kind), payload);
112
+ return;
113
+ }
108
114
  let content = stripAnsi(entry.content ?? '');
109
115
  if (content.length > CONTENT_CAP)
110
116
  content = `${content.slice(0, CONTENT_CAP)}… (${content.length} chars)`;
@@ -1,4 +1,4 @@
1
- import { type FocusedElement, ActionResult } from './action-result.js';
1
+ import { ActionResult, type FocusedElement } from './action-result.js';
2
2
  import type { ExperienceTracker } from './experience-tracker.js';
3
3
  import type { Knowledge, KnowledgeTracker } from './knowledge-tracker.js';
4
4
  export interface Link {
@@ -1,6 +1,6 @@
1
1
  import { ActionResult } from './action-result.js';
2
2
  import { detectFocusArea } from './utils/aria.js';
3
- import { createDebug } from './utils/logger.js';
3
+ import { createDebug, tag } from './utils/logger.js';
4
4
  import { slugify } from './utils/strings.js';
5
5
  import { extractStatePath } from './utils/url-matcher.js';
6
6
  const debugLog = createDebug('explorbot:state');
@@ -40,6 +40,8 @@ export class StateManager {
40
40
  * Emit state change event to all listeners
41
41
  */
42
42
  emitStateChange(event) {
43
+ const state = event.toState;
44
+ tag('data').log('state', { url: state.fullUrl || state.url, path: state.url, title: state.title, h1: state.h1 });
43
45
  this.stateChangeListeners.forEach((listener) => {
44
46
  try {
45
47
  listener(event);
@@ -113,6 +113,7 @@ export declare class Test extends Task {
113
113
  finish(result?: TestResultType): void;
114
114
  getDurationMs(): number | null;
115
115
  getRemainingExpectations(): string[];
116
+ reportStatus(): void;
116
117
  getLog(): Array<{
117
118
  type: 'step' | 'note' | 'artifact';
118
119
  content: string;