explorbot 0.2.4 → 0.3.0

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 (113) 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/README.md +96 -0
  5. package/boat/prima/package.json +14 -10
  6. package/boat/prima/src/cli.ts +29 -12
  7. package/boat/prima/src/envelope.ts +35 -13
  8. package/boat/prima/src/prima.ts +78 -45
  9. package/dist/bin/explorbot-cli.js +19 -7
  10. package/dist/boat/api-tester/src/cli.js +17 -0
  11. package/dist/boat/doc-collector/src/cli.js +14 -1
  12. package/dist/boat/prima/src/cli.js +26 -7
  13. package/dist/boat/prima/src/envelope.js +32 -8
  14. package/dist/boat/prima/src/prima.js +75 -43
  15. package/dist/models.json +4 -4
  16. package/dist/package.json +6 -2
  17. package/dist/src/action-result.d.ts +13 -0
  18. package/dist/src/action-result.js +46 -15
  19. package/dist/src/action.d.ts +5 -2
  20. package/dist/src/action.js +53 -18
  21. package/dist/src/ai/captain/web-mode.js +1 -2
  22. package/dist/src/ai/captain.d.ts +20 -0
  23. package/dist/src/ai/captain.js +10 -1
  24. package/dist/src/ai/driller.js +6 -2
  25. package/dist/src/ai/fisherman-tools.d.ts +40 -1
  26. package/dist/src/ai/fisherman-tools.js +39 -0
  27. package/dist/src/ai/fisherman.js +2 -1
  28. package/dist/src/ai/navigator.d.ts +28 -0
  29. package/dist/src/ai/navigator.js +223 -175
  30. package/dist/src/ai/pilot.d.ts +7 -4
  31. package/dist/src/ai/pilot.js +89 -30
  32. package/dist/src/ai/planner/subpages.js +2 -16
  33. package/dist/src/ai/planner.js +1 -1
  34. package/dist/src/ai/provider.d.ts +2 -2
  35. package/dist/src/ai/provider.js +28 -22
  36. package/dist/src/ai/researcher/cache.d.ts +10 -3
  37. package/dist/src/ai/researcher/cache.js +23 -10
  38. package/dist/src/ai/researcher/deep-analysis.js +1 -1
  39. package/dist/src/ai/researcher/fingerprint-worker.js +21 -4
  40. package/dist/src/ai/researcher.js +6 -4
  41. package/dist/src/ai/rules.js +1 -5
  42. package/dist/src/ai/session-analyst.js +2 -0
  43. package/dist/src/ai/tester.d.ts +6 -3
  44. package/dist/src/ai/tester.js +30 -35
  45. package/dist/src/ai/tools.d.ts +8 -5
  46. package/dist/src/ai/tools.js +83 -57
  47. package/dist/src/commands/config-command.d.ts +51 -0
  48. package/dist/src/commands/config-command.js +117 -0
  49. package/dist/src/commands/index.js +2 -0
  50. package/dist/src/commands/init-command.js +13 -20
  51. package/dist/src/config.d.ts +8 -1
  52. package/dist/src/config.js +43 -1
  53. package/dist/src/experience-tracker.d.ts +2 -0
  54. package/dist/src/experience-tracker.js +12 -0
  55. package/dist/src/explorbot.js +5 -2
  56. package/dist/src/playwright-recorder.js +6 -12
  57. package/dist/src/remote.d.ts +3 -2
  58. package/dist/src/remote.js +8 -2
  59. package/dist/src/state-manager.d.ts +1 -1
  60. package/dist/src/state-manager.js +3 -1
  61. package/dist/src/test-plan.d.ts +9 -0
  62. package/dist/src/test-plan.js +30 -0
  63. package/dist/src/utils/html-diff.d.ts +5 -0
  64. package/dist/src/utils/html-diff.js +65 -6
  65. package/dist/src/utils/logger.d.ts +1 -1
  66. package/dist/src/utils/logger.js +8 -0
  67. package/dist/src/utils/strings.d.ts +2 -0
  68. package/dist/src/utils/strings.js +32 -0
  69. package/dist/src/utils/url-matcher.d.ts +1 -0
  70. package/dist/src/utils/url-matcher.js +31 -2
  71. package/docs/basics/getting-started.md +33 -10
  72. package/docs/basics/providers.md +6 -4
  73. package/docs/contributing/npm-package.md +73 -4
  74. package/docs/index.json +2 -1
  75. package/docs/reference/commands.md +3 -0
  76. package/docs/reference/websocket.md +50 -0
  77. package/docs/superpowers/specs/2026-08-18-prima-false-verdicts.md +159 -0
  78. package/models.json +4 -4
  79. package/package.json +6 -2
  80. package/src/action-result.ts +61 -16
  81. package/src/action.ts +56 -18
  82. package/src/ai/captain/web-mode.ts +1 -2
  83. package/src/ai/captain.ts +9 -1
  84. package/src/ai/driller.ts +6 -2
  85. package/src/ai/fisherman-tools.ts +35 -0
  86. package/src/ai/fisherman.ts +2 -1
  87. package/src/ai/navigator.ts +238 -179
  88. package/src/ai/pilot.ts +104 -36
  89. package/src/ai/planner/subpages.ts +2 -13
  90. package/src/ai/planner.ts +1 -1
  91. package/src/ai/provider.ts +29 -21
  92. package/src/ai/researcher/cache.ts +29 -11
  93. package/src/ai/researcher/deep-analysis.ts +1 -1
  94. package/src/ai/researcher/fingerprint-worker.ts +23 -5
  95. package/src/ai/researcher.ts +6 -4
  96. package/src/ai/rules.ts +1 -5
  97. package/src/ai/session-analyst.ts +2 -0
  98. package/src/ai/tester.ts +33 -34
  99. package/src/ai/tools.ts +88 -61
  100. package/src/commands/config-command.ts +146 -0
  101. package/src/commands/index.ts +2 -0
  102. package/src/commands/init-command.ts +14 -20
  103. package/src/config.ts +47 -2
  104. package/src/experience-tracker.ts +13 -0
  105. package/src/explorbot.ts +4 -2
  106. package/src/playwright-recorder.ts +6 -11
  107. package/src/remote.ts +8 -2
  108. package/src/state-manager.ts +5 -2
  109. package/src/test-plan.ts +38 -0
  110. package/src/utils/html-diff.ts +72 -7
  111. package/src/utils/logger.ts +9 -1
  112. package/src/utils/strings.ts +36 -0
  113. package/src/utils/url-matcher.ts +27 -2
@@ -4,30 +4,31 @@ import { createRequire } from 'node:module';
4
4
  import path from 'node:path';
5
5
  import { tool } from 'ai';
6
6
  import dedent from 'dedent';
7
- import { z } from 'zod';
8
7
  import * as playwright from 'playwright';
9
8
  import type { Browser } from 'playwright';
9
+ import { z } from 'zod';
10
10
  import { ActionResult } from '../../../src/action-result.ts';
11
+ import { getPreviousResearch } from '../../../src/ai/researcher/cache.ts';
11
12
  import { actionRule, locatorRule } from '../../../src/ai/rules.ts';
12
- import { createAgentTools, createCodeceptJSTools } from '../../../src/ai/tools.ts';
13
+ import { createAgentTools, createCodeceptJSTools, createRefTools } from '../../../src/ai/tools.ts';
13
14
  import { getAliveEndpoint, launchServer, listInstances, stopServer } from '../../../src/browser-server.ts';
14
- import { listSites } from '../../../src/global-config.ts';
15
- import { ConfigMissingError, ConfigParser, type ExplorbotConfig, outputPath } from '../../../src/config.ts';
15
+ import { ConfigCommand } from '../../../src/commands/config-command.ts';
16
+ import { ConfigMissingError, ConfigParser, EXPLORBOT_ENV_VARS, type ExplorbotConfig, outputPath } from '../../../src/config.ts';
16
17
  import { ExplorBot } from '../../../src/explorbot.ts';
18
+ import { listSites } from '../../../src/global-config.ts';
17
19
  import { Reporter } from '../../../src/reporter.ts';
18
- import { Stats } from '../../../src/stats.ts';
19
20
  import type { WebPageState } from '../../../src/state-manager.ts';
21
+ import { Stats } from '../../../src/stats.ts';
20
22
  import { Task, Test, TestResult } from '../../../src/test-plan.ts';
21
- import { getPreviousResearch } from '../../../src/ai/researcher/cache.ts';
22
23
  import { compactAriaSnapshot } from '../../../src/utils/aria.ts';
23
- import { mdq } from '../../../src/utils/markdown-query.ts';
24
24
  import { browserErrorMessage } from '../../../src/utils/browser-errors.ts';
25
25
  import { pluralize } from '../../../src/utils/logger.ts';
26
+ import { mdq } from '../../../src/utils/markdown-query.ts';
26
27
  import { safeFilename } from '../../../src/utils/strings.ts';
27
28
  import { type EnvelopeData, type InstanceInfo, writeArtifacts } from './envelope.ts';
28
29
  import { isFunctionExpression, takePwValue, toCodeceptWrapper } from './pw-parser.ts';
29
- import { type SessionRun, latestSessionFile, readSession, recordCommand, sessionFile, sessionsDir } from './session-log.ts';
30
30
  import { type PwServerDescriptor, readDescriptors, selectDescriptor } from './pw-registry.ts';
31
+ import { type SessionRun, latestSessionFile, readSession, recordCommand, sessionFile, sessionsDir } from './session-log.ts';
31
32
 
32
33
  const TESTER_ONLY_TOOLS = ['learnExperience', 'askUser'];
33
34
  const ITERATIONS_PER_INSTRUCTION = 2;
@@ -40,7 +41,7 @@ const CONNECT_TIMEOUT = 3000;
40
41
  const requireLib = createRequire(import.meta.url);
41
42
 
42
43
  const VOLATILE_COLUMNS = ['CSS', 'XPath', 'Coordinates', 'eidx'];
43
- const UNACCOUNTED: Record<string, string> = { open: 'never reported — the run ended with this instruction still open' };
44
+ const UNACCOUNTED: Record<string, string> = { open: 'the run ended without confirming this one — the actions above are everything that ran' };
44
45
 
45
46
  function dropVolatileColumns(markdown: string): string {
46
47
  return mdq(markdown)
@@ -73,6 +74,7 @@ export class Prima {
73
74
  private server: { close: () => Promise<void> } | null = null;
74
75
  private attached: string | null = null;
75
76
  private session: SessionRun | null = null;
77
+ private artifacts?: EnvelopeData['artifacts'];
76
78
 
77
79
  constructor(options: PrimaOptions = {}) {
78
80
  this.options = options;
@@ -88,6 +90,14 @@ export class Prima {
88
90
  });
89
91
  }
90
92
 
93
+ static applyEnv(): void {
94
+ for (const { name } of EXPLORBOT_ENV_VARS) {
95
+ const value = process.env[name.replace('EXPLORBOT_', 'PRIMA_CLI_')];
96
+ if (!value) continue;
97
+ process.env[name] = value;
98
+ }
99
+ }
100
+
91
101
  async start(): Promise<void> {
92
102
  let discovery: Discovery | undefined;
93
103
  if (!this.options.endpoint) {
@@ -146,7 +156,7 @@ export class Prima {
146
156
  const deps = { explorer: this.bot.getExplorer(), stateManager: this.bot.stateManager(), ai: provider };
147
157
  const ledger: LedgerEntry[] = instructions.map((text) => ({ text, status: 'open', proof: '' }));
148
158
  const descent = { markup: false };
149
- const tools = { ...createCodeceptJSTools(deps, task), ...this.testerTools(deps), context: this.contextTool(descent), completed: this.completedTool(), blocked: this.blockedTool() };
159
+ const tools = { ...createCodeceptJSTools(deps, task), ...createRefTools(deps, task), ...this.testerTools(deps), context: this.contextTool(descent), completed: this.completedTool(), blocked: this.blockedTool() };
150
160
  conversation.addUserText(await this.instructionPrompt(instructions, await this.capturedResult(previousState)));
151
161
 
152
162
  const used: string[] = [];
@@ -227,12 +237,12 @@ export class Prima {
227
237
 
228
238
  if (aiError) return this.failureEnvelope(command, aiError, previousState);
229
239
 
230
- if (used.length && ledger.some((entry) => entry.status === 'open')) {
240
+ if (trace.length && ledger.some((entry) => entry.status === 'open')) {
231
241
  await this.settleLedger(conversation, provider, ledger, trace);
232
242
  }
233
243
 
234
244
  const unfinished = ledger.filter((entry) => entry.status !== 'done');
235
- const steps = [...trace, ...ledger.filter((entry) => entry.status === 'open').map((entry) => ({ label: `unreported: ${entry.text}`, ok: false, proof: UNACCOUNTED.open }))];
245
+ const steps = [...trace, ...ledger.filter((entry) => entry.status === 'open').map((entry) => ({ label: entry.text, ok: false, unconfirmed: true, proof: UNACCOUNTED.open }))];
236
246
 
237
247
  if (failure && unfinished.length) {
238
248
  const envelope = await this.failureEnvelope(command, failure.message, previousState);
@@ -241,7 +251,7 @@ export class Prima {
241
251
  return envelope;
242
252
  }
243
253
 
244
- if (!used.length && unfinished.length === ledger.length) {
254
+ if (!trace.length && unfinished.length === ledger.length) {
245
255
  const reason = ['No action was performed for these instructions on the current page.', narration].filter(Boolean).join(' ');
246
256
  return this.failureEnvelope(command, reason, previousState);
247
257
  }
@@ -254,10 +264,10 @@ export class Prima {
254
264
  envelope.used = undefined;
255
265
  envelope.changes = undefined;
256
266
 
257
- const unmet = unfinished.map((entry) => `${entry.status}: ${entry.text}${entry.proof ? ` — ${entry.proof}` : ''}`);
258
- if (unmet.length) {
267
+ const blocked = ledger.filter((entry) => entry.status === 'blocked');
268
+ if (blocked.length) {
259
269
  envelope.ok = false;
260
- envelope.failure = { error: unmet.join('\n') };
270
+ envelope.failure = { error: blocked.map((entry) => `blocked: ${entry.text}${entry.proof ? ` — ${entry.proof}` : ''}`).join('\n') };
261
271
  }
262
272
  return envelope;
263
273
  }
@@ -309,7 +319,13 @@ export class Prima {
309
319
  completed() for those, blocked() for the ones the page could not do. Report every one — nothing else runs after this.
310
320
  `);
311
321
 
312
- const invoked = await provider.invokeConversation(conversation, { completed: this.completedTool(), blocked: this.blockedTool() }, { maxToolRoundtrips: 2, toolChoice: 'required', agentName: AI_AGENT_NAME }).catch(() => null);
322
+ let settleError: unknown = null;
323
+ const invoked = await provider.invokeConversation(conversation, { completed: this.completedTool(), blocked: this.blockedTool() }, { maxToolRoundtrips: 2, toolChoice: 'required', agentName: AI_AGENT_NAME }).catch((error: unknown) => {
324
+ settleError = error;
325
+ return null;
326
+ });
327
+
328
+ if (settleError) trace.push({ label: 'settling which instructions were satisfied', ok: false, proof: browserErrorMessage(settleError) });
313
329
 
314
330
  for (const execution of invoked?.toolExecutions || []) {
315
331
  this.applyLedgerReport(execution, ledger, trace);
@@ -336,11 +352,11 @@ export class Prima {
336
352
  const test = new Test(scenario, 'normal', outcomes, previousState?.url || this.options.url || '');
337
353
  const tester = this.bot.agentTester();
338
354
 
339
- const outcome = await tester.test(test);
355
+ await tester.test(test, { startOnCurrentPage: true });
340
356
 
341
357
  const notes = Object.values(test.notes || {}) as Array<{ message: string; status?: string; log?: string; observation?: boolean }>;
342
- const result = await this.capturedResult(this.bot.stateManager().getCurrentState());
343
- const envelope = await this.reportEnvelope(command, result, previousState, { ok: outcome.success });
358
+ const result = await this.capturedResult(this.bot.stateManager().getCurrentState(), { screenshot: this.visionEnabled() });
359
+ const envelope = await this.reportEnvelope(command, result, previousState, {});
344
360
  const recorded = notes.filter((note) => !note.observation && !outcomes.includes(note.message));
345
361
  const failed = recorded.filter((note) => note.status === TestResult.FAILED);
346
362
  envelope.steps = failed.map((note) => ({ label: note.message, ok: false, proof: note.log || '' }));
@@ -348,7 +364,24 @@ export class Prima {
348
364
  const routine = recorded.length - failed.length;
349
365
  if (routine) envelope.steps.push({ label: `${routine} further ${pluralize(routine, 'step')} ran without failing — prima status ${envelope.status} for the full log`, ok: true, proof: '' });
350
366
 
351
- envelope.expectations = await this.bot.agentPilot().settleExpectations(test);
367
+ envelope.expectations = await this.bot.agentPilot().settleExpectations(test, result);
368
+
369
+ if (!result.screenshot || !this.visionEnabled()) {
370
+ envelope.warning = 'These outcomes were settled from the run log alone — no screenshot backed them. Set ai.visionModel, or check anything visual with prima ask.';
371
+ }
372
+
373
+ const unreached = envelope.expectations.filter((expectation) => expectation.status === 'failed');
374
+ const contradicted = envelope.expectations.filter((expectation) => expectation.status === 'contradiction');
375
+ envelope.ok = !unreached.length && !contradicted.length;
376
+
377
+ const problems = [...unreached.map((expectation) => `not reached: ${expectation.text}`), ...contradicted.map((expectation) => `the picture and the run disagree about: ${expectation.text}`)];
378
+ if (problems.length) envelope.failure = { error: problems.join('\n') };
379
+ if (contradicted.length) envelope.artifacts = this.artifacts;
380
+
381
+ if (!test.hasFinished || test.isSkipped) {
382
+ envelope.ok = false;
383
+ envelope.failure = { error: `the run did not complete, so it established nothing about the app: ${notes.at(-1)?.message || 'no steps were recorded'}` };
384
+ }
352
385
 
353
386
  const observations = notes.filter((note) => note.observation).map((note) => note.message);
354
387
  if (observations.length) envelope.answer = ['Page problems noticed while running, not step failures:', ...observations.map((line) => `- ${line}`)].join('\n');
@@ -373,7 +406,15 @@ export class Prima {
373
406
  const previousState = this.bot.stateManager().getCurrentState();
374
407
  const result = await this.capturedResult(previousState);
375
408
  const verification = await this.bot.agentNavigator().verifyState(assertion, result);
376
- return this.reportEnvelope(command, result, previousState, { assertions: verification.results || [] });
409
+ const outcome: Partial<EnvelopeData> = { assertions: verification.results || [] };
410
+
411
+ if (verification.inexpressible) {
412
+ const question = `Judging only from the screenshot, is this true of the page: "${assertion}"? Answer true, false or undetermined, and say what settles it.`;
413
+ const seen = await this.visionAnswer(question, await this.capturedResult(previousState, { screenshot: this.visionEnabled() }));
414
+ if (seen) outcome.answer = `No assertion could express this claim, so it was judged from a screenshot instead.\n\n${seen}`;
415
+ }
416
+
417
+ return this.reportEnvelope(command, result, previousState, outcome);
377
418
  }
378
419
 
379
420
  async research(opts: { data?: boolean; deep?: boolean; fresh?: boolean } = {}): Promise<EnvelopeData> {
@@ -436,27 +477,13 @@ export class Prima {
436
477
  return stopped;
437
478
  }
438
479
 
439
- async config(): Promise<string> {
480
+ async config(json?: boolean): Promise<string> {
440
481
  const [site] = listSites();
441
482
  if (site && !this.configBaseUrl()) this.sessionUrl = site.url;
442
483
  const config = await this.loadConfig();
484
+ const parser = ConfigParser.getInstance();
443
485
 
444
- const named = (model: unknown): string => {
445
- if (typeof model === 'string') return model;
446
- return (model as any)?.modelId || (model as any)?.model || 'unknown';
447
- };
448
-
449
- const ai = config.ai || ({} as any);
450
- const roles: Array<[string, unknown]> = [
451
- ['model', ai.model],
452
- ['agenticModel', ai.agenticModel],
453
- ['visionModel', ai.visionModel],
454
- ];
455
-
456
- const lines = roles.filter(([, model]) => model).map(([role, model]) => `${role.padEnd(14)} ${named(model)}`);
457
- lines.push(`config ${ConfigParser.getInstance().getConfigPath() || 'built-in defaults'}`);
458
- if (ai.langfuse?.enabled) lines.push('telemetry langfuse');
459
- return lines.join('\n');
486
+ return ConfigCommand.render(config, { configPath: parser.getConfigPath(), root: parser.getProjectRoot(), json });
460
487
  }
461
488
 
462
489
  record(envelope: EnvelopeData, durationMs: number): void {
@@ -752,6 +779,7 @@ export class Prima {
752
779
  <proof>
753
780
  An instruction is done only when a change on the page shows it. After each action read the reported change and decide which part of it proves the instruction.
754
781
  That part is what completed() takes as its proof. Do not restate the action as if it were the outcome.
782
+ How much of the page moved is not evidence of whether it happened — a change confined to one region proves an instruction as well as one that redraws everything.
755
783
  An instruction that only inspects the page is satisfied by what you can see, including seeing that something is absent — those need no action at all.
756
784
  </proof>
757
785
 
@@ -909,6 +937,7 @@ export class Prima {
909
937
 
910
938
  private visionEnabled(): boolean {
911
939
  if (this.options.noVision) return false;
940
+ if (Stats.visionDisabled) return false;
912
941
  return this.bot.getProvider().hasVision?.() === true;
913
942
  }
914
943
 
@@ -1022,7 +1051,12 @@ export class Prima {
1022
1051
  private async pageChanges(result: ActionResult, previousState: WebPageState | null, code: string): Promise<string> {
1023
1052
  if (!previousState) return 'no snapshot was captured before this command, so nothing could be compared';
1024
1053
  const toolResult = await result.toToolResult(ActionResult.fromState(previousState), code);
1025
- return toolResult.pageDiff?.ariaChanges || 'no change';
1054
+ const pageDiff = toolResult.pageDiff;
1055
+ if (!pageDiff?.urlChanged) return pageDiff?.ariaChanges || 'no change';
1056
+
1057
+ const lines = [`left ${previousState.url} for ${result.url}`];
1058
+ for (const message of pageDiff.messages ?? []) lines.push(`- ${message}`);
1059
+ return lines.join('\n');
1026
1060
  }
1027
1061
 
1028
1062
  async status(hash: string): Promise<EnvelopeData> {
@@ -1035,7 +1069,6 @@ export class Prima {
1035
1069
  ok: true,
1036
1070
  command: `status ${hash}`,
1037
1071
  page: saved.page,
1038
- changes: saved.changes,
1039
1072
  instance: await this.instanceInfo(),
1040
1073
  artifacts: { aria: path.join(dir, 'aria.yml'), html: path.join(dir, 'page.html') },
1041
1074
  };
@@ -1044,7 +1077,7 @@ export class Prima {
1044
1077
  private async saveStatus(result: ActionResult): Promise<string> {
1045
1078
  const hash = this.statusHash();
1046
1079
  await this.writeSnapshot(result);
1047
- writeFileSync(path.join(this.statusDir(hash), 'status.json'), JSON.stringify({ page: this.pageBlock(result, null), changes: compactAriaSnapshot(result.ariaSnapshot, true) }), 'utf-8');
1080
+ writeFileSync(path.join(this.statusDir(hash), 'status.json'), JSON.stringify({ page: this.pageBlock(result, null) }), 'utf-8');
1048
1081
  return hash;
1049
1082
  }
1050
1083
 
@@ -1062,13 +1095,13 @@ export class Prima {
1062
1095
  if (diff) writeFileSync(`${stem}.diff.yaml`, diff, 'utf-8');
1063
1096
  }
1064
1097
 
1065
- private async writeSnapshot(result: ActionResult): Promise<undefined> {
1066
- writeArtifacts(this.statusDir(), {
1098
+ private async writeSnapshot(result: ActionResult): Promise<void> {
1099
+ this.artifacts = writeArtifacts(this.statusDir(), {
1067
1100
  aria: result.ariaSnapshot,
1068
1101
  html: await result.combinedHtml(),
1102
+ screenshot: result.screenshot,
1069
1103
  requests: this.bot.requestStore().getRequests(),
1070
1104
  });
1071
- return undefined;
1072
1105
  }
1073
1106
 
1074
1107
  private statusHash(): string {
@@ -15,7 +15,7 @@ import { remote } from '../src/remote.js';
15
15
  import { Stats } from '../src/stats.js';
16
16
  import { Plan } from '../src/test-plan.js';
17
17
  import { getCliName } from "../src/utils/cli-name.js";
18
- import { log, setPreserveConsoleLogs } from '../src/utils/logger.js';
18
+ import { isVerboseMode, log, setPreserveConsoleLogs, setQuietMode } from '../src/utils/logger.js';
19
19
  import { jsonToTable } from '../src/utils/markdown-parser.js';
20
20
  import { parseMarkdownToTerminal } from '../src/utils/markdown-terminal.js';
21
21
  import { printNextSteps, relativeToCwd } from "../src/utils/next-steps.js";
@@ -386,6 +386,23 @@ addCommonOptions(program
386
386
  const cmd = new FreesailCommand(explorBot);
387
387
  await cmd.execute(args);
388
388
  });
389
+ program
390
+ .command('config [url]')
391
+ .description('Show models, config file and paths used by this run')
392
+ .option('-c, --config <path>', 'Path to configuration file')
393
+ .option('-p, --path <path>', 'Working directory path')
394
+ .option('--json', 'Print the resolved config as JSON')
395
+ .action(async (url, options) => {
396
+ setQuietMode(!isVerboseMode());
397
+ const { ConfigCommand } = await import('../src/commands/config-command.js');
398
+ try {
399
+ console.log(await ConfigCommand.summary({ config: options.config, path: options.path, url, json: options.json }));
400
+ }
401
+ catch (error) {
402
+ console.error(error instanceof Error ? error.message : 'Unknown error');
403
+ process.exit(1);
404
+ }
405
+ });
389
406
  program
390
407
  .command('init')
391
408
  .description('Initialize configuration for a project or for this machine')
@@ -826,10 +843,5 @@ ${rows}
826
843
  ${cli} explore /login --max-tests 3
827
844
  `;
828
845
  };
829
- const addEnvHelp = (cmd) => {
830
- cmd.addHelpText('after', envHelp);
831
- for (const sub of cmd.commands)
832
- addEnvHelp(sub);
833
- };
834
- addEnvHelp(program);
846
+ program.addHelpText('after', envHelp);
835
847
  program.parse();
@@ -1,9 +1,12 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { Command } from 'commander';
4
+ import { ConfigCommand } from "../../../src/commands/config-command.js";
5
+ import { listSites } from "../../../src/global-config.js";
4
6
  import { setPreserveConsoleLogs } from "../../../src/utils/logger.js";
5
7
  import { getStyles } from "./ai/chief/styles.js";
6
8
  import { ApiBot } from "./apibot.js";
9
+ import { ApibotConfigParser } from "./config.js";
7
10
  function buildOptions(options) {
8
11
  return {
9
12
  verbose: options.verbose || options.debug,
@@ -68,6 +71,20 @@ export function createApiCommands(name = 'api') {
68
71
  process.exit(1);
69
72
  }
70
73
  });
74
+ addCommonOptions(cmd.command('config [endpoint]').description('Show models, config file and paths used by this run'))
75
+ .option('--json', 'Print the resolved config as JSON')
76
+ .action(async (endpoint, options) => {
77
+ const parser = ApibotConfigParser.getInstance();
78
+ const [site] = listSites();
79
+ try {
80
+ const config = await parser.loadConfig({ config: options.config, path: options.path, endpoint: endpoint || site?.url });
81
+ console.log(ConfigCommand.render(config, { configPath: parser.getConfigPath(), root: parser.getProjectRoot(), json: options.json }));
82
+ }
83
+ catch (error) {
84
+ console.error(error instanceof Error ? error.message : 'Unknown error');
85
+ process.exit(1);
86
+ }
87
+ });
71
88
  addCommonOptions(cmd.command('test <planfile> [index]').description('Execute tests from a plan file. Index: 1, 1-3, *')).action(async (planfile, index, options) => {
72
89
  setPreserveConsoleLogs(true);
73
90
  try {
@@ -1,7 +1,8 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { Command } from 'commander';
4
- import { setPreserveConsoleLogs } from "../../../src/utils/logger.js";
4
+ import { ConfigCommand } from "../../../src/commands/config-command.js";
5
+ import { isVerboseMode, setPreserveConsoleLogs, setQuietMode } from "../../../src/utils/logger.js";
5
6
  import { DocBot } from "./docbot.js";
6
7
  function buildOptions(options) {
7
8
  return {
@@ -56,6 +57,18 @@ export function createDocsCommands(name = 'docs') {
56
57
  process.exit(1);
57
58
  }
58
59
  });
60
+ addCommonOptions(cmd.command('config [url]').description('Show models, config file and paths used by this run'))
61
+ .option('--json', 'Print the resolved config as JSON')
62
+ .action(async (url, options) => {
63
+ setQuietMode(!isVerboseMode());
64
+ try {
65
+ console.log(await ConfigCommand.summary({ config: options.config, path: options.path, url, json: options.json }));
66
+ }
67
+ catch (error) {
68
+ console.error(error instanceof Error ? error.message : 'Unknown error');
69
+ process.exit(1);
70
+ }
71
+ });
59
72
  cmd
60
73
  .command('init')
61
74
  .description('Initialize doc collector configuration')
@@ -20,16 +20,26 @@ const helpContract = dedent `
20
20
  prima pw "({ page }) => page.click('[data-test=submit]')"
21
21
  `;
22
22
  const checkHelp = dedent `
23
- check takes an outcome rather than a click path, and works out how to reach it.
23
+ check takes an outcome rather than a click path, and works out how to reach it. It runs
24
+ on the page you are already on and never reloads it, so an open dialog survives the check.
24
25
  --expected one outcome the run must reach, repeatable for several. Without it the
25
26
  scenario text is the single expected outcome. Each comes back under
26
- ### Expected outcomes as PASSED, FAILED or not verified - "not verified"
27
- means the run never checked it, which is not the same as false.
27
+ ### Expected outcomes as PASSED, FAILED, CONTRADICTION or not verified.
28
+ "not verified" means the run never checked it, which is not the same
29
+ as false.
30
+ Outcomes are settled against a screenshot of the whole page: what a user can see is
31
+ the proof, and the run log only says what was done. CONTRADICTION means the two
32
+ disagree - reported with both sides rather than settled one way, and ### Artifacts
33
+ then names the html, aria and screenshot on disk so you can judge it yourself. Not
34
+ finding something in the picture is not enough on its own; that is "not verified".
35
+ ok: follows those outcomes - false when one FAILED or CONTRADICTED, or when the run
36
+ could not complete, which is reported as such rather than as an app failure.
28
37
  Page problems seen on the way appear under ### Answer, not as step failures.
29
38
  `;
30
39
  const doHelp = dedent `
31
- Each instruction is numbered and accounted for: ### Steps reports each as ok or FAIL
32
- with what proved it. One that could not be carried out fails the command and says why.
40
+ Each instruction is numbered and accounted for: ### Steps reports each as ok, FAIL or ??.
41
+ ?? means the action ran but the run ended without confirming that instruction - read the
42
+ steps above it. Only FAIL and an instruction the page could not carry out fail the command.
33
43
  Nothing runs past the last instruction given. A whole remaining sequence in one call is
34
44
  what makes this tier cheap.
35
45
  `;
@@ -85,6 +95,8 @@ function addCommonOptions(cmd) {
85
95
  .option('-p, --path <path>', 'Working directory path')
86
96
  .option('-i, --instance <name>', 'Browser instance to drive')
87
97
  .option('--session [file]', 'Persist cookies and storage to a session file')
98
+ .option('--model <model>', 'Main model, as provider/model-id')
99
+ .option('--vision-model <model>', 'Model for screenshot analysis, as provider/model-id')
88
100
  .option('--ephemeral', 'Keep no state between runs; applies to config-free runs, where output goes to a temp directory')
89
101
  .option('--framework <name>', 'Not active yet: framework the reported code targets, codeceptjs or playwright')
90
102
  .option('--url <url>', 'Page to open when the session has no page yet')
@@ -93,8 +105,13 @@ function addCommonOptions(cmd) {
93
105
  .addHelpText('after', `\n${sessionHelp}`);
94
106
  }
95
107
  function primaFor(options) {
108
+ Prima.applyEnv();
96
109
  if (options.ephemeral)
97
110
  process.env.EXPLORBOT_EPHEMERAL = '1';
111
+ if (options.model)
112
+ process.env.EXPLORBOT_AI_MODEL = options.model;
113
+ if (options.visionModel)
114
+ process.env.EXPLORBOT_VISION_MODEL = options.visionModel;
98
115
  return new Prima(buildOptions(options));
99
116
  }
100
117
  async function runPrima(options, command, run, record = true) {
@@ -165,10 +182,12 @@ export function createPrimaCommands(name = 'prima') {
165
182
  options.baseUrl = target;
166
183
  await runPrima(options, `go ${target}`, (prima) => prima.go(target));
167
184
  });
168
- addCommonOptions(cmd.command('config').description('Show the AI models prima runs on and the config file they come from')).action(async (options) => {
185
+ addCommonOptions(cmd.command('config').description('Show models, config file and paths used by this run'))
186
+ .option('--json', 'Print the resolved config as JSON')
187
+ .action(async (options) => {
169
188
  setQuietMode(!isVerboseMode());
170
189
  const prima = primaFor(options);
171
- console.log(await prima.config().catch((error) => browserErrorMessage(error)));
190
+ console.log(await prima.config(options.json).catch((error) => browserErrorMessage(error)));
172
191
  await prima.stop().catch(() => { });
173
192
  process.exit(0);
174
193
  });
@@ -1,12 +1,13 @@
1
1
  import { mkdirSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  const EXPECTATION_LABELS = {
4
- passed: 'PASSED ',
5
- failed: 'FAILED ',
6
- unverified: 'not verified',
4
+ passed: 'PASSED ',
5
+ failed: 'FAILED ',
6
+ unverified: 'not verified ',
7
+ contradiction: 'CONTRADICTION',
7
8
  };
8
9
  export function renderEnvelope(data) {
9
- const sections = [renderResult(data), renderPage(data), renderValue(data), renderChanges(data), renderSteps(data), renderExpectations(data), renderOutcome(data), ...renderFailure(data), renderInstance(data), renderArtifacts(data)];
10
+ const sections = [renderResult(data), renderPage(data), renderValue(data), renderChanges(data), renderSteps(data), renderExpectations(data), renderWarning(data), renderOutcome(data), ...renderFailure(data), renderInstance(data), renderArtifacts(data)];
10
11
  return sections.filter((section) => section).join('\n\n');
11
12
  }
12
13
  export function writeArtifacts(dir, snapshot) {
@@ -17,6 +18,10 @@ export function writeArtifacts(dir, snapshot) {
17
18
  };
18
19
  writeFileSync(paths.aria, snapshot.aria ?? '', 'utf-8');
19
20
  writeFileSync(paths.html, snapshot.html ?? '', 'utf-8');
21
+ if (snapshot.screenshot) {
22
+ paths.screenshot = path.resolve(dir, 'page.png');
23
+ writeFileSync(paths.screenshot, snapshot.screenshot);
24
+ }
20
25
  if (!snapshot.requests.length)
21
26
  return paths;
22
27
  paths.network = path.resolve(dir, 'network.jsonl');
@@ -55,7 +60,12 @@ function renderSteps(data) {
55
60
  return null;
56
61
  const lines = [];
57
62
  data.steps.forEach((step, index) => {
58
- lines.push(`${index + 1}. ${step.ok ? 'ok ' : 'FAIL'} ${step.label}`);
63
+ let mark = 'FAIL';
64
+ if (step.ok)
65
+ mark = 'ok ';
66
+ if (step.unconfirmed)
67
+ mark = '?? ';
68
+ lines.push(`${index + 1}. ${mark} ${step.label}`);
59
69
  for (const line of (step.proof || '').split('\n').filter(Boolean))
60
70
  lines.push(` ${line}`);
61
71
  });
@@ -66,9 +76,21 @@ function renderSteps(data) {
66
76
  function renderExpectations(data) {
67
77
  if (!data.expectations?.length)
68
78
  return null;
69
- const lines = data.expectations.map((expectation, index) => `${index + 1}. ${EXPECTATION_LABELS[expectation.status]} ${expectation.text}`);
79
+ const lines = [];
80
+ data.expectations.forEach((expectation, index) => {
81
+ lines.push(`${index + 1}. ${EXPECTATION_LABELS[expectation.status]} ${expectation.text}`);
82
+ if (expectation.status !== 'contradiction' && expectation.status !== 'failed')
83
+ return;
84
+ for (const line of (expectation.evidence || '').split('\n').filter(Boolean))
85
+ lines.push(` ${line}`);
86
+ });
70
87
  return section('Expected outcomes', lines.join('\n'));
71
88
  }
89
+ function renderWarning(data) {
90
+ if (!data.warning)
91
+ return null;
92
+ return section('Warning', data.warning);
93
+ }
72
94
  function renderOutcome(data) {
73
95
  if (data.answer)
74
96
  return section('Answer', data.answer);
@@ -127,9 +149,11 @@ function tabsLabel(tabs) {
127
149
  export function renderArtifacts(data) {
128
150
  if (!data.artifacts)
129
151
  return null;
130
- const lines = [`aria: ${data.artifacts.aria}`, `html: ${data.artifacts.html}`];
152
+ const lines = [`aria: ${data.artifacts.aria}`, `html: ${data.artifacts.html}`];
153
+ if (data.artifacts.screenshot)
154
+ lines.push(`screenshot: ${data.artifacts.screenshot}`);
131
155
  if (data.artifacts.network)
132
- lines.push(`network: ${data.artifacts.network}`);
156
+ lines.push(`network: ${data.artifacts.network}`);
133
157
  return section('Artifacts', lines.join('\n'));
134
158
  }
135
159
  function align(label, marker, width) {