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
package/src/ai/pilot.ts CHANGED
@@ -7,6 +7,7 @@ import { ConfigParser } from '../config.ts';
7
7
  import type Explorer from '../explorer.ts';
8
8
  import type { PlaywrightRecorder } from '../playwright-recorder.ts';
9
9
  import type { StateManager } from '../state-manager.ts';
10
+ import { Stats } from '../stats.ts';
10
11
  import { type Test, TestResult } from '../test-plan.ts';
11
12
  import { collectInteractiveNodes, detectFocusArea } from '../utils/aria.ts';
12
13
  import { ErrorPageError } from '../utils/error-page.ts';
@@ -562,24 +563,46 @@ export class Pilot implements Agent {
562
563
  return text;
563
564
  }
564
565
 
565
- async settleExpectations(task: Test): Promise<Array<{ text: string; status: 'passed' | 'failed' | 'unverified' }>> {
566
- const undecided = task.expected.filter((text) => !task.getCheckedExpectations().includes(text));
566
+ async settleExpectations(task: Test, finalState?: ActionResult): Promise<SettledExpectation[]> {
567
+ let image: string | null = null;
568
+ if (finalState?.screenshot && this.provider.hasVision()) image = `data:image/png;base64,${finalState.screenshot.toString('base64')}`;
569
+
567
570
  const decided = (text: string): 'passed' | 'failed' => {
568
571
  if (task.hasAchievedAny() && !task.getRemainingExpectations().includes(text)) return 'passed';
569
572
  return 'failed';
570
573
  };
571
574
 
575
+ let undecided = task.expected.filter((text) => !task.getCheckedExpectations().includes(text));
576
+ if (image) undecided = task.expected;
572
577
  if (!undecided.length) return task.expected.map((text) => ({ text, status: decided(text) }));
573
578
 
574
579
  const schema = z.object({
575
580
  outcomes: z.array(
576
581
  z.object({
577
582
  expectation: z.string().describe('The expected outcome, repeated exactly as it was given'),
578
- status: z.enum(['passed', 'failed', 'unverified']).describe('passed = the log shows it happened, failed = the log shows it did not, unverified = the run never established either way'),
583
+ status: z.enum(['passed', 'failed', 'unverified', 'contradiction']).describe('passed = the evidence shows it happened, failed = the evidence shows it did not, unverified = the run never established either way, contradiction = the picture and the run disagree'),
584
+ evidence: z.string().nullable().describe('What settled it. For a contradiction, what each side shows. Null when there is nothing to add'),
579
585
  })
580
586
  ),
581
587
  });
582
588
 
589
+ let pageEvidence = '';
590
+ if (image) {
591
+ pageEvidence = dedent`
592
+ A screenshot of the whole page as the run left it is attached. It is the proof: an outcome is satisfied
593
+ when the page shows it to somebody looking at it. The log only says what the run did.
594
+
595
+ Not finding something in the picture is not by itself a disagreement. Report "contradiction" only when
596
+ the picture shows something incompatible with what the run claims — a list visibly empty, an error where
597
+ a result was expected, the old value still displayed, a control visibly disabled. When you simply cannot
598
+ make it out, say "unverified" and name what you could not find.
599
+
600
+ The picture covers the full page, but not the inside of a region that scrolls on its own, and not the
601
+ state of the page before the run ended. An outcome established earlier stays established even when the
602
+ page has moved past it, and that is not a contradiction.
603
+ `;
604
+ }
605
+
583
606
  const userContent = dedent`
584
607
  A test run has finished. Decide, for each expected outcome, what the run established about it.
585
608
 
@@ -591,23 +614,43 @@ export class Pilot implements Agent {
591
614
  ${task.notesToString() || 'No steps recorded.'}
592
615
  </run_log>
593
616
 
617
+ ${pageEvidence}
618
+
594
619
  The log is written in the tester's own words, so an outcome can be satisfied by a step that describes it
595
620
  differently. Judge by what the steps show happened, not by whether the wording matches.
596
- Choose "unverified" only when the log neither shows the outcome happening nor shows it failing —
621
+ Choose "unverified" only when the evidence neither shows the outcome happening nor shows it failing —
597
622
  that is a statement about the run, not about the application.
598
623
  `;
599
624
 
600
- const response = await this.provider
601
- .generateObject([{ role: 'user' as const, content: userContent }], schema, this.provider.getAgenticModel('pilot'), {
602
- agentName: 'pilot',
603
- telemetry: { functionId: 'pilot.settleExpectations' },
604
- })
605
- .catch(() => null);
625
+ const settle = (content: any, model: any) =>
626
+ this.provider
627
+ .generateObject([{ role: 'user' as const, content }], schema, model, {
628
+ agentName: 'pilot',
629
+ telemetry: { functionId: 'pilot.settleExpectations' },
630
+ })
631
+ .catch(() => null);
632
+
633
+ let response = null;
634
+ if (image) {
635
+ const seen = [
636
+ { type: 'text', text: userContent },
637
+ { type: 'file', mediaType: 'image/png', data: image },
638
+ ];
639
+ response = await settle(seen, this.provider.getVisionModel());
640
+ if (!response) {
641
+ Stats.visionDisabled = true;
642
+ tag('warning').log('⚠️ Vision model could not judge the outcomes. Settling them from the run log instead.');
643
+ }
644
+ }
645
+
646
+ if (!response) response = await settle(userContent, this.provider.getAgenticModel('pilot'));
606
647
 
607
- const judged = new Map((response?.object?.outcomes || []).map((outcome: any) => [outcome.expectation, outcome.status]));
648
+ const judged = new Map((response?.object?.outcomes || []).map((outcome: any) => [outcome.expectation, outcome]));
608
649
  return task.expected.map((text) => {
609
650
  if (!undecided.includes(text)) return { text, status: decided(text) };
610
- return { text, status: (judged.get(text) as 'passed' | 'failed' | 'unverified') || 'unverified' };
651
+ const outcome = judged.get(text) as { status: SettledStatus; evidence?: string } | undefined;
652
+ if (!outcome) return { text, status: 'unverified' as SettledStatus };
653
+ return { text, status: outcome.status || 'unverified', evidence: outcome.evidence };
611
654
  });
612
655
  }
613
656
 
@@ -1116,3 +1159,11 @@ export class Pilot implements Agent {
1116
1159
  `;
1117
1160
  }
1118
1161
  }
1162
+
1163
+ export type SettledStatus = 'passed' | 'failed' | 'unverified' | 'contradiction';
1164
+
1165
+ export interface SettledExpectation {
1166
+ text: string;
1167
+ status: SettledStatus;
1168
+ evidence?: string;
1169
+ }
@@ -4,7 +4,7 @@ import { NodeSDK } from '@opentelemetry/sdk-node';
4
4
  import { generateObject, generateText, isStepCount, registerTelemetry } from 'ai';
5
5
  import type { ModelMessage } from 'ai';
6
6
  import { clearActivity, setActivity } from '../activity.ts';
7
- import type { AIConfig } from '../config.js';
7
+ import { type AIConfig, configuredModels, modelName as getModelName } from '../config.js';
8
8
  import { executionController } from '../execution-controller.ts';
9
9
  import { Observability } from '../observability.ts';
10
10
  import { Stats } from '../stats.ts';
@@ -88,10 +88,6 @@ export class Provider {
88
88
  this.initLangfuse();
89
89
  }
90
90
 
91
- private getModelName(model: any): string {
92
- return model?.modelId || model?.model || 'unknown';
93
- }
94
-
95
91
  async validateConnection(): Promise<void> {
96
92
  try {
97
93
  await generateText({
@@ -120,13 +116,13 @@ export class Provider {
120
116
  return this.config.agenticModel || this.config.model;
121
117
  }
122
118
 
119
+ getVisionModel(): any {
120
+ return this.config.visionModel;
121
+ }
122
+
123
123
  getConfiguredModels(): Record<string, string> {
124
- const models: Record<string, string> = { model: this.getModelName(this.config.model) };
125
- if (this.config.agenticModel) models.agenticModel = this.getModelName(this.config.agenticModel);
126
- if (this.config.visionModel) models.visionModel = this.getModelName(this.config.visionModel);
127
- for (const [agent, agentConfig] of Object.entries(this.config.agents || {})) {
128
- if (agentConfig?.model) models[agent] = this.getModelName(agentConfig.model);
129
- }
124
+ const models: Record<string, string> = {};
125
+ for (const [role, model] of Object.entries(configuredModels(this.config))) models[role] = model.name;
130
126
  return models;
131
127
  }
132
128
 
@@ -223,11 +219,7 @@ export class Provider {
223
219
  }
224
220
 
225
221
  private initLangfuse() {
226
- const langfuseConfig = this.config.langfuse;
227
- const publicKey = langfuseConfig?.publicKey || process.env.LANGFUSE_PUBLIC_KEY;
228
- const secretKey = langfuseConfig?.secretKey || process.env.LANGFUSE_SECRET_KEY;
229
- const baseUrl = langfuseConfig?.baseUrl || process.env.LANGFUSE_BASE_URL || process.env.LANGFUSE_HOST;
230
- const enabled = langfuseConfig?.enabled ?? Boolean(publicKey && secretKey);
222
+ const { enabled, publicKey, secretKey, baseUrl } = this.config.langfuse || {};
231
223
 
232
224
  if (!enabled || !publicKey || !secretKey) {
233
225
  return;
@@ -316,7 +308,7 @@ export class Provider {
316
308
  }
317
309
 
318
310
  async chat(messages: ModelMessage[], model: any, options: any = {}): Promise<any> {
319
- const modelName = this.getModelName(model);
311
+ const modelName = getModelName(model);
320
312
  setActivity(`🤖 Asking ${modelName}`, 'ai');
321
313
  promptLog(`Using model: ${modelName}`);
322
314
 
@@ -360,7 +352,7 @@ export class Provider {
360
352
  }
361
353
 
362
354
  async generateWithTools(messages: ModelMessage[], model: any, tools: any, options: any = {}): Promise<any> {
363
- const modelName = this.getModelName(model);
355
+ const modelName = getModelName(model);
364
356
  setActivity(`🤖 Asking ${modelName} with dynamic tools`, 'ai');
365
357
  promptLog(`Using model: ${modelName}`);
366
358
 
@@ -418,7 +410,7 @@ export class Provider {
418
410
 
419
411
  async generateObject(messages: ModelMessage[], schema: any, model?: any, options: any = {}): Promise<any> {
420
412
  const modelToUse = model || this.config.model;
421
- const modelName = this.getModelName(modelToUse);
413
+ const modelName = getModelName(modelToUse);
422
414
  setActivity(`🤖 Asking ${modelName} for structured output`, 'ai');
423
415
  promptLog(`Using model: ${modelName}`);
424
416
 
@@ -620,7 +612,7 @@ export class Provider {
620
612
  clearActivity();
621
613
  responseLog(response.text);
622
614
 
623
- this.recordUsage('vision', this.getModelName(this.config.visionModel), response.usage);
615
+ this.recordUsage('vision', getModelName(this.config.visionModel), response.usage);
624
616
 
625
617
  return response;
626
618
  } catch (error: any) {
@@ -4,6 +4,7 @@ import { Worker } from 'node:worker_threads';
4
4
  import { outputPath } from '../../config.ts';
5
5
  import { TTLCache } from '../../utils/cache.ts';
6
6
  import { computeHtmlFingerprint } from '../../utils/html-diff.ts';
7
+ import { tag } from '../../utils/logger.ts';
7
8
  import { debugLog } from './mixin.ts';
8
9
 
9
10
  const CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 6 hours
@@ -15,6 +16,14 @@ const memoryCache = new TTLCache<string>(CACHE_TTL_MS);
15
16
 
16
17
  let fingerprintWorker: Worker | null = null;
17
18
 
19
+ export function researchPath(hash: string): string {
20
+ return outputPath('research', `${hash}.md`);
21
+ }
22
+
23
+ export function reportResearch(hash: string, text: string): void {
24
+ tag('data').log('research', { path: researchPath(hash), hash, content: text });
25
+ }
26
+
18
27
  function getStatesDir(): string {
19
28
  return outputPath('states');
20
29
  }
@@ -35,7 +44,7 @@ export function getCachedResearch(hash: string): string {
35
44
  if (!hash) return '';
36
45
  const cached = memoryCache.get(hash);
37
46
  if (cached !== undefined) return cached;
38
- const researchFile = outputPath('research', `${hash}.md`);
47
+ const researchFile = researchPath(hash);
39
48
  if (!existsSync(researchFile)) return '';
40
49
  const stats = statSync(researchFile);
41
50
  if (Date.now() - stats.mtimeMs > CACHE_TTL_MS) return '';
@@ -46,7 +55,7 @@ export function getCachedResearch(hash: string): string {
46
55
 
47
56
  export function getPreviousResearch(hash: string): string {
48
57
  if (!hash) return '';
49
- const researchFile = outputPath('research', `${hash}.md`);
58
+ const researchFile = researchPath(hash);
50
59
  if (!existsSync(researchFile)) return '';
51
60
  return readFileSync(researchFile, 'utf8');
52
61
  }
@@ -57,6 +66,7 @@ export function saveResearch(hash: string, text: string, combinedHtml?: string):
57
66
  if (!existsSync(researchDir)) mkdirSync(researchDir, { recursive: true });
58
67
  writeFileSync(researchFile, text);
59
68
  memoryCache.set(hash, text);
69
+ reportResearch(hash, text);
60
70
  debugLog(`Research saved to ${researchFile}`);
61
71
 
62
72
  if (combinedHtml) {
@@ -19,7 +19,7 @@ import { annotatePageElements } from '../utils/web-annotate.ts';
19
19
  import type { Agent, AgentDeps } from './agent.js';
20
20
  import type { Navigator } from './navigator.ts';
21
21
  import { ContextLengthError, type Provider } from './provider.js';
22
- import { findSimilarResearch, getCachedResearch, saveResearch } from './researcher/cache.ts';
22
+ import { findSimilarResearch, getCachedResearch, reportResearch, saveResearch } from './researcher/cache.ts';
23
23
  import { type CoordinateMethods, WithCoordinates } from './researcher/coordinates.ts';
24
24
  import { type DeepAnalysisMethods, WithDeepAnalysis } from './researcher/deep-analysis.ts';
25
25
  import { detectFocusedSection, hasFocusedSection, markSectionAsFocused, pickDefaultFocusedSection } from './researcher/focus.ts';
@@ -102,6 +102,7 @@ export class Researcher extends ResearcherBase implements Agent {
102
102
  const cached = getCachedResearch(stateHash);
103
103
  if (cached) {
104
104
  debugLog('Previous research result found');
105
+ reportResearch(stateHash, cached);
105
106
  return cached;
106
107
  }
107
108
  }
@@ -4,6 +4,7 @@ import dedent from 'dedent';
4
4
  import { outputPath } from '../config.ts';
5
5
  import { Stats } from '../stats.ts';
6
6
  import type { Test } from '../test-plan.ts';
7
+ import { tag } from '../utils/logger.ts';
7
8
  import type { Agent } from './agent.ts';
8
9
  import type { Provider } from './provider.ts';
9
10
 
@@ -117,6 +118,7 @@ export class SessionAnalyst implements Agent {
117
118
  const dir = path.dirname(filePath);
118
119
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
119
120
  writeFileSync(filePath, markdown);
121
+ tag('data').log('report', { path: filePath, content: markdown });
120
122
  return filePath;
121
123
  }
122
124
 
package/src/ai/tester.ts CHANGED
@@ -96,7 +96,7 @@ export class Tester extends TaskAgent implements Agent {
96
96
  return this.currentConversation;
97
97
  }
98
98
 
99
- async test(task: Test): Promise<{ success: boolean }> {
99
+ async test(task: Test, opts: TestOptions = {}): Promise<{ success: boolean }> {
100
100
  Stats.tests++;
101
101
  const state = this.stateManager.getCurrentState();
102
102
  if (!state) throw new Error('No state found');
@@ -151,11 +151,11 @@ export class Tester extends TaskAgent implements Agent {
151
151
  expected: task.expected,
152
152
  },
153
153
  },
154
- async () => this.runTestSession(task, initialState, conversation, { offFailedRequest })
154
+ async () => this.runTestSession(task, initialState, conversation, { offFailedRequest }, opts)
155
155
  );
156
156
  }
157
157
 
158
- private async runTestSession(task: Test, initialState: ActionResult, conversation: Conversation, handlers: TestSessionHandlers): Promise<{ success: boolean }> {
158
+ private async runTestSession(task: Test, initialState: ActionResult, conversation: Conversation, handlers: TestSessionHandlers, opts: TestOptions): Promise<{ success: boolean }> {
159
159
  const { offFailedRequest } = handlers;
160
160
 
161
161
  if (this.pilot) {
@@ -187,15 +187,19 @@ export class Tester extends TaskAgent implements Agent {
187
187
  return { success: task.isSuccessful };
188
188
  }
189
189
 
190
- debugLog(`Navigating to ${task.startUrl}`);
191
- try {
192
- await this.explorer.visit(task.startUrl!);
193
- } catch (error) {
194
- const result = await this.handleLoopError(task, error);
195
- if (result === 'stop') {
196
- offFailedRequest?.();
197
- await this.cleanupStartedTest(task);
198
- return { success: task.isSuccessful };
190
+ if (opts.startOnCurrentPage) debugLog(`Starting on the page already open at ${task.startUrl}`);
191
+
192
+ if (!opts.startOnCurrentPage) {
193
+ debugLog(`Navigating to ${task.startUrl}`);
194
+ try {
195
+ await this.explorer.visit(task.startUrl!);
196
+ } catch (error) {
197
+ const result = await this.handleLoopError(task, error);
198
+ if (result === 'stop') {
199
+ offFailedRequest?.();
200
+ await this.cleanupStartedTest(task);
201
+ return { success: task.isSuccessful };
202
+ }
199
203
  }
200
204
  }
201
205
 
@@ -1160,3 +1164,7 @@ export class Tester extends TaskAgent implements Agent {
1160
1164
  interface TestSessionHandlers {
1161
1165
  offFailedRequest?: () => void;
1162
1166
  }
1167
+
1168
+ export interface TestOptions {
1169
+ startOnCurrentPage?: boolean;
1170
+ }
package/src/ai/tools.ts CHANGED
@@ -810,7 +810,10 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
810
810
  });
811
811
  }
812
812
 
813
- return failedToolResult('interact', `Failed to execute: ${instruction}`, {
813
+ let reason = '';
814
+ if (navigator.lastFailureReason) reason = `: ${navigator.lastFailureReason}`;
815
+
816
+ return failedToolResult('interact', `Failed to execute: ${instruction}${reason}`, {
814
817
  ...toolResult,
815
818
  suggestion: 'The action could not be completed. Try a different instruction or use more specific element descriptions.',
816
819
  });
@@ -0,0 +1,146 @@
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import chalk from 'chalk';
4
+ import { type AIConfig, ConfigParser, EXPLORBOT_ENV_VARS, type ReporterConfig, 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
+
11
+ export class ConfigCommand extends BaseCommand {
12
+ name = 'config';
13
+ description = 'Show models, config file and paths used by this run';
14
+
15
+ async execute(): Promise<void> {
16
+ const parser = ConfigParser.getInstance();
17
+ tag('info').log(ConfigCommand.render(this.explorBot.getConfig(), { configPath: parser.getConfigPath(), root: parser.getProjectRoot() }));
18
+ }
19
+
20
+ static async summary(options: { config?: string; path?: string; url?: string; json?: boolean } = {}): Promise<string> {
21
+ const parser = ConfigParser.getInstance();
22
+ const [site] = listSites();
23
+ const load = (baseUrl?: string) => parser.loadConfig({ config: options.config, path: options.path, baseUrl });
24
+
25
+ const config = await load(options.url).catch((error) => {
26
+ if (!site) throw error;
27
+ return load(site.url);
28
+ });
29
+
30
+ return ConfigCommand.render(config, { configPath: parser.getConfigPath(), root: parser.getProjectRoot(), json: options.json });
31
+ }
32
+
33
+ static data(config: SummarizedConfig, options: ConfigSummaryOptions = {}): ConfigData {
34
+ let configPath = '';
35
+ if (options.configPath && existsSync(options.configPath)) configPath = options.configPath;
36
+
37
+ const dirs: Record<string, string> = {};
38
+ if (options.root) {
39
+ for (const [name, dir] of Object.entries({ output: 'output', ...config.dirs })) {
40
+ dirs[name] = path.join(options.root, dir);
41
+ }
42
+ }
43
+
44
+ const env: Record<string, string> = {};
45
+ for (const variable of EXPLORBOT_ENV_VARS) {
46
+ const value = process.env[variable.name];
47
+ if (value) env[variable.name] = value;
48
+ }
49
+
50
+ const models: Record<string, string> = {};
51
+ const providers: Record<string, string> = {};
52
+ for (const [role, model] of Object.entries(configuredModels(config.ai))) {
53
+ models[role] = model.name;
54
+ if (model.provider) providers[role] = model.provider;
55
+ }
56
+
57
+ return {
58
+ config: configPath,
59
+ url: config.playwright?.url || config.web?.url || config.api?.baseEndpoint || '',
60
+ browser: config.playwright?.browser || '',
61
+ headless: !config.playwright?.show,
62
+ dirs,
63
+ models,
64
+ providers,
65
+ integrations: {
66
+ langfuse: !!config.ai?.langfuse?.enabled,
67
+ testomatio: Reporter.resolveEnabled(config.reporter),
68
+ },
69
+ env,
70
+ };
71
+ }
72
+
73
+ static render(config: SummarizedConfig, options: ConfigSummaryOptions = {}): string {
74
+ const data = ConfigCommand.data(config, options);
75
+ if (options.json) return JSON.stringify(data, null, 2);
76
+
77
+ const lines: string[] = [];
78
+ const section = (title: string, entries: [string, string][]) => {
79
+ if (!entries.length) return;
80
+ const width = Math.max(...entries.map(([label]) => label.length));
81
+ lines.push(chalk.bold(title));
82
+ for (const [label, value] of entries) lines.push(` ${chalk.dim(label.padEnd(width))} ${value}`);
83
+ lines.push('');
84
+ };
85
+
86
+ const general: [string, string][] = [['config', data.config || 'EXPLORBOT_* environment variables']];
87
+ if (data.url) general.push(['url', data.url]);
88
+ if (data.browser) {
89
+ let window = 'visible';
90
+ if (data.headless) window = 'headless';
91
+ general.push(['browser', `${data.browser}, ${window}`]);
92
+ }
93
+ for (const [name, dir] of Object.entries(data.dirs)) general.push([name, dir]);
94
+ section('Config', general);
95
+
96
+ const providerWidth = Math.max(0, ...Object.values(data.providers).map((provider) => provider.length));
97
+ const models: [string, string][] = Object.entries(data.models).map(([role, model]) => {
98
+ if (!providerWidth) return [role, model];
99
+ return [role, `${chalk.dim((data.providers[role] || '').padEnd(providerWidth))} ${model}`];
100
+ });
101
+ if (!models.length) models.push(['model', chalk.red(`not configured — run ${getCliName()} init`)]);
102
+ section('Models', models);
103
+
104
+ const integrations: [string, string][] = [];
105
+ if (data.integrations.langfuse) integrations.push(['langfuse', 'traces sent']);
106
+ if (data.integrations.testomatio) integrations.push(['testomatio', 'runs reported']);
107
+ section('Integrations', integrations);
108
+
109
+ const env: [string, string][] = Object.entries(data.env).map(([name, value]) => {
110
+ let shown = value;
111
+ if (shown.length > 60) shown = `${shown.slice(0, 57)}...`;
112
+ return [name, shown];
113
+ });
114
+ section('Environment', env);
115
+
116
+ lines.push(chalk.dim(`Every EXPLORBOT_* variable: ${getCliName()} --help`));
117
+ return lines.join('\n');
118
+ }
119
+ }
120
+
121
+ interface ConfigSummaryOptions {
122
+ configPath?: string | null;
123
+ root?: string;
124
+ json?: boolean;
125
+ }
126
+
127
+ export interface ConfigData {
128
+ config: string;
129
+ url: string;
130
+ browser: string;
131
+ headless: boolean;
132
+ dirs: Record<string, string>;
133
+ models: Record<string, string>;
134
+ providers: Record<string, string>;
135
+ integrations: { langfuse: boolean; testomatio: boolean };
136
+ env: Record<string, string>;
137
+ }
138
+
139
+ interface SummarizedConfig {
140
+ ai?: AIConfig;
141
+ playwright?: { url?: string; browser?: string; show?: boolean };
142
+ web?: { url?: string };
143
+ api?: { baseEndpoint?: string };
144
+ dirs?: Record<string, string>;
145
+ reporter?: ReporterConfig;
146
+ }
@@ -3,6 +3,7 @@ import { AddRuleCommand } from './add-rule-command.js';
3
3
  import type { BaseCommand } from './base-command.js';
4
4
  import { CleanCommand } from './clean-command.js';
5
5
  import { CompactCommand } from './compact-command.js';
6
+ import { ConfigCommand } from './config-command.js';
6
7
  import { ContextAriaCommand } from './context-aria-command.js';
7
8
  import { ContextCommand } from './context-command.js';
8
9
  import { ContextDataCommand } from './context-data-command.js';
@@ -70,6 +71,7 @@ const commandClasses: CommandClass[] = [
70
71
  RunsCommand,
71
72
  RerunCommand,
72
73
  StatusCommand,
74
+ ConfigCommand,
73
75
  DebugCommand,
74
76
  ExitCommand,
75
77
  ];
package/src/config.ts CHANGED
@@ -514,6 +514,7 @@ export class ConfigParser {
514
514
  model: { modelId: 'test-model', provider: 'test' },
515
515
  config: {},
516
516
  vision: false,
517
+ langfuse: { enabled: false },
517
518
  },
518
519
  dirs: {
519
520
  knowledge: join(testBaseDir, 'knowledge'),
@@ -650,6 +651,18 @@ export class ConfigParser {
650
651
  config.playwright.url = options.baseUrl;
651
652
  }
652
653
 
654
+ if (config.ai) {
655
+ const langfuse = config.ai.langfuse;
656
+ const publicKey = langfuse?.publicKey || process.env.LANGFUSE_PUBLIC_KEY;
657
+ const secretKey = langfuse?.secretKey || process.env.LANGFUSE_SECRET_KEY;
658
+ config.ai.langfuse = {
659
+ enabled: langfuse?.enabled ?? Boolean(publicKey && secretKey),
660
+ publicKey,
661
+ secretKey,
662
+ baseUrl: langfuse?.baseUrl || process.env.LANGFUSE_BASE_URL || process.env.LANGFUSE_HOST,
663
+ };
664
+ }
665
+
653
666
  return config;
654
667
  }
655
668
 
@@ -762,6 +775,32 @@ export function missingConfigMessage(configFile = 'explorbot.config.js'): string
762
775
  `;
763
776
  }
764
777
 
778
+ export function modelName(model: unknown): string {
779
+ if (typeof model === 'string') return model;
780
+ return (model as any)?.modelId || (model as any)?.model || 'unknown';
781
+ }
782
+
783
+ export function modelProvider(model: unknown): string {
784
+ const provider = (model as any)?.provider;
785
+ if (typeof provider === 'string') return provider.split('.')[0];
786
+ if (typeof model === 'string') return model.split('/')[0];
787
+ return '';
788
+ }
789
+
790
+ export function configuredModels(ai?: AIConfig): Record<string, ConfiguredModel> {
791
+ if (!ai?.model) return {};
792
+
793
+ const describe = (model: unknown): ConfiguredModel => ({ name: modelName(model), provider: modelProvider(model) });
794
+
795
+ const models: Record<string, ConfiguredModel> = { model: describe(ai.model) };
796
+ if (ai.agenticModel) models.agenticModel = describe(ai.agenticModel);
797
+ if (ai.visionModel) models.visionModel = describe(ai.visionModel);
798
+ for (const [agent, agentConfig] of Object.entries(ai.agents || {})) {
799
+ if (agentConfig?.model) models[agent] = describe(agentConfig.model);
800
+ }
801
+ return models;
802
+ }
803
+
765
804
  export async function resolveConfigModels(ai?: AIConfig): Promise<void> {
766
805
  if (!ai) return;
767
806
 
@@ -838,6 +877,11 @@ export async function createModel(provider: string, modelId: string): Promise<an
838
877
 
839
878
  type ModelRole = 'model' | 'visionModel' | 'agenticModel';
840
879
 
880
+ interface ConfiguredModel {
881
+ name: string;
882
+ provider: string;
883
+ }
884
+
841
885
  interface ProviderInfo {
842
886
  envKey: string;
843
887
  load: () => Promise<(modelId: string) => any>;
@@ -849,4 +893,4 @@ interface EnvVar {
849
893
  required?: boolean;
850
894
  }
851
895
 
852
- export type { ModelRole, EnvVar, ProviderInfo };
896
+ export type { ModelRole, EnvVar, ProviderInfo, ConfiguredModel };
package/src/explorbot.ts CHANGED
@@ -487,9 +487,11 @@ export class ExplorBot {
487
487
 
488
488
  setCurrentPlan(plan?: Plan): void {
489
489
  this.currentPlan = plan;
490
- if (plan && !this.sessionPlans.includes(plan)) {
490
+ if (!plan) return;
491
+ if (!this.sessionPlans.includes(plan)) {
491
492
  this.sessionPlans.push(plan);
492
493
  }
494
+ plan.notifyChange();
493
495
  }
494
496
 
495
497
  getSessionTests(): Test[] {
package/src/remote.ts CHANGED
@@ -16,8 +16,9 @@ const FLUSH_TIMEOUT_MS = 3000;
16
16
  * process and a CI bot are the same case.
17
17
  *
18
18
  * It **is** a LogDestination — that is the whole integration on the logger's
19
- * side — and it answers asks by installing itself as the execution
20
- * controller's input callback. Nothing else in explorbot knows it exists.
19
+ * side, messages and `data` alike — and it answers asks by installing itself
20
+ * as the execution controller's input callback. Nothing else in explorbot
21
+ * knows it exists.
21
22
  */
22
23
  export class Remote implements LogDestination {
23
24
  private url: string | null = null;
@@ -110,6 +111,11 @@ export class Remote implements LogDestination {
110
111
  */
111
112
  write(entry: TaggedLogEntry): void {
112
113
  if (entry.type === 'html') return;
114
+ if (entry.type === 'data') {
115
+ const [kind, payload] = entry.originalArgs || [];
116
+ this.send(String(kind), payload);
117
+ return;
118
+ }
113
119
  let content = stripAnsi(entry.content ?? '');
114
120
  if (content.length > CONTENT_CAP) content = `${content.slice(0, CONTENT_CAP)}… (${content.length} chars)`;
115
121
  this.send('log', {
@@ -1,8 +1,8 @@
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
  import { detectFocusArea } from './utils/aria.js';
5
- import { createDebug } from './utils/logger.js';
5
+ import { createDebug, tag } from './utils/logger.js';
6
6
  import { slugify } from './utils/strings.js';
7
7
  import { extractStatePath } from './utils/url-matcher.js';
8
8
 
@@ -117,6 +117,9 @@ export class StateManager {
117
117
  * Emit state change event to all listeners
118
118
  */
119
119
  private emitStateChange(event: StateTransition): void {
120
+ const state = event.toState;
121
+ tag('data').log('state', { url: state.fullUrl || state.url, path: state.url, title: state.title, h1: state.h1 });
122
+
120
123
  this.stateChangeListeners.forEach((listener) => {
121
124
  try {
122
125
  listener(event);