explorbot 0.2.3 → 0.2.4

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 (156) hide show
  1. package/README.md +1 -1
  2. package/bin/explorbot-cli.ts +7 -1
  3. package/boat/api-tester/src/config.ts +4 -2
  4. package/boat/doc-collector/bin/doc-collector-cli.ts +2 -0
  5. package/boat/doc-collector/src/ai/documentarian.ts +61 -31
  6. package/boat/doc-collector/src/config.ts +4 -2
  7. package/boat/prima/bin/prima-cli.ts +0 -0
  8. package/boat/prima/src/activity-line.ts +33 -0
  9. package/boat/prima/src/cli.ts +115 -86
  10. package/boat/prima/src/envelope.ts +80 -52
  11. package/boat/prima/src/prima.ts +547 -128
  12. package/boat/prima/src/pw-parser.ts +11 -1
  13. package/boat/prima/src/pw-registry.ts +4 -5
  14. package/boat/prima/src/session-log.ts +126 -0
  15. package/dist/bin/explorbot-cli.js +7 -1
  16. package/dist/boat/api-tester/bin/apibot-cli.js +2 -0
  17. package/dist/boat/api-tester/src/config.js +4 -2
  18. package/dist/boat/doc-collector/bin/doc-collector-cli.js +2 -0
  19. package/dist/boat/doc-collector/src/ai/documentarian.js +44 -19
  20. package/dist/boat/doc-collector/src/config.js +4 -2
  21. package/dist/boat/prima/src/activity-line.js +30 -0
  22. package/dist/boat/prima/src/cli.js +97 -77
  23. package/dist/boat/prima/src/envelope.js +70 -44
  24. package/dist/boat/prima/src/prima.js +515 -119
  25. package/dist/boat/prima/src/pw-parser.js +13 -1
  26. package/dist/boat/prima/src/pw-registry.js +4 -5
  27. package/dist/boat/prima/src/session-log.js +108 -0
  28. package/dist/package.json +3 -2
  29. package/dist/rules/navigator/verification-actions.md +20 -0
  30. package/dist/src/action-result.d.ts +7 -0
  31. package/dist/src/action-result.js +4 -0
  32. package/dist/src/action.d.ts +2 -0
  33. package/dist/src/action.js +36 -1
  34. package/dist/src/ai/captain/web-mode.js +6 -3
  35. package/dist/src/ai/captain.js +2 -0
  36. package/dist/src/ai/navigator.d.ts +7 -0
  37. package/dist/src/ai/navigator.js +10 -6
  38. package/dist/src/ai/pilot.d.ts +4 -0
  39. package/dist/src/ai/pilot.js +48 -2
  40. package/dist/src/ai/provider.js +2 -2
  41. package/dist/src/ai/rerunner.js +2 -1
  42. package/dist/src/ai/researcher.js +1 -1
  43. package/dist/src/ai/rules.js +17 -10
  44. package/dist/src/ai/task-agent.js +4 -1
  45. package/dist/src/ai/tester.d.ts +1 -1
  46. package/dist/src/ai/tester.js +33 -33
  47. package/dist/src/ai/tools.d.ts +14 -0
  48. package/dist/src/ai/tools.js +113 -36
  49. package/dist/src/config.d.ts +1 -0
  50. package/dist/src/config.js +13 -4
  51. package/dist/src/execution-controller.d.ts +2 -0
  52. package/dist/src/execution-controller.js +6 -0
  53. package/dist/src/explorbot.d.ts +2 -1
  54. package/dist/src/explorbot.js +3 -1
  55. package/dist/src/explorer.js +2 -3
  56. package/dist/src/playwright-recorder.js +30 -0
  57. package/dist/src/remote.d.ts +54 -0
  58. package/dist/src/remote.js +229 -0
  59. package/dist/src/reporter.d.ts +1 -0
  60. package/dist/src/reporter.js +7 -1
  61. package/dist/src/state-manager.d.ts +2 -1
  62. package/dist/src/stats.d.ts +1 -0
  63. package/dist/src/stats.js +1 -0
  64. package/dist/src/test-plan.d.ts +2 -0
  65. package/dist/src/test-plan.js +7 -0
  66. package/dist/src/utils/aria.d.ts +2 -8
  67. package/dist/src/utils/aria.js +69 -40
  68. package/dist/src/utils/html.js +1 -0
  69. package/dist/src/utils/logger.d.ts +6 -0
  70. package/dist/src/utils/logger.js +24 -0
  71. package/dist/src/utils/page-readiness.js +18 -1
  72. package/dist/src/utils/url-matcher.js +3 -0
  73. package/dist/src/utils/web-element.d.ts +2 -0
  74. package/dist/src/utils/web-element.js +8 -0
  75. package/dist/src/utils/web-sandbox.d.ts +1 -1
  76. package/dist/src/utils/web-sandbox.js +2 -3
  77. package/docs/api-testing/basics.md +90 -0
  78. package/docs/api-testing/planning.md +57 -0
  79. package/docs/api-testing/running-tests.md +55 -0
  80. package/docs/assets/cloud-report.png +0 -0
  81. package/docs/assets/html-report.png +0 -0
  82. package/docs/assets/langfuse-trace.png +0 -0
  83. package/docs/assets/successful-explore-run.png +0 -0
  84. package/docs/basics/getting-started.md +140 -0
  85. package/docs/basics/prerequisites.md +63 -0
  86. package/docs/basics/providers.md +362 -0
  87. package/docs/basics/running.md +78 -0
  88. package/docs/contributing/ai-integration-tests.md +57 -0
  89. package/docs/contributing/contributing.md +90 -0
  90. package/docs/contributing/demo-videos.md +36 -0
  91. package/docs/contributing/npm-package.md +138 -0
  92. package/docs/contributing/observability.md +227 -0
  93. package/docs/contributing/regression-tests.md +103 -0
  94. package/docs/contributing/testing.md +95 -0
  95. package/docs/doc-collection/basics.md +128 -0
  96. package/docs/doc-collection/crawling.md +67 -0
  97. package/docs/doc-collection/interactive-mode.md +99 -0
  98. package/docs/index.json +86 -0
  99. package/docs/reference/commands.md +994 -0
  100. package/docs/reference/configuration.md +569 -0
  101. package/docs/reference/scripting.md +303 -0
  102. package/docs/superpowers/plans/2026-08-01-actor-boat.md +925 -0
  103. package/docs/superpowers/plans/2026-08-01-prima-boat.md +1120 -0
  104. package/docs/superpowers/reviews/2026-08-06-prima-vs-playwright-cli.md +268 -0
  105. package/docs/superpowers/specs/2026-08-01-actor-boat-design.md +204 -0
  106. package/docs/superpowers/specs/2026-08-01-prima-boat-design.md +242 -0
  107. package/docs/superpowers/specs/2026-08-03-global-config-design.md +138 -0
  108. package/docs/superpowers/specs/2026-08-07-prima-fixes-design.md +394 -0
  109. package/docs/web-testing/agents.md +158 -0
  110. package/docs/web-testing/automated-tests.md +134 -0
  111. package/docs/web-testing/basics.md +91 -0
  112. package/docs/web-testing/customization.md +131 -0
  113. package/docs/web-testing/hooks.md +238 -0
  114. package/docs/web-testing/page-interaction.md +84 -0
  115. package/docs/web-testing/planner.md +122 -0
  116. package/docs/web-testing/rerun.md +164 -0
  117. package/docs/web-testing/researcher.md +380 -0
  118. package/docs/workflow/agentic-usage.md +233 -0
  119. package/docs/workflow/application-spec.md +73 -0
  120. package/docs/workflow/ci.md +202 -0
  121. package/docs/workflow/knowledge.md +310 -0
  122. package/docs/workflow/planning-styles.md +67 -0
  123. package/docs/workflow/reporting.md +133 -0
  124. package/docs/workflow/test-plans.md +90 -0
  125. package/package.json +3 -2
  126. package/rules/navigator/verification-actions.md +20 -0
  127. package/src/action-result.ts +11 -0
  128. package/src/action.ts +38 -2
  129. package/src/ai/captain/web-mode.ts +6 -3
  130. package/src/ai/captain.ts +3 -0
  131. package/src/ai/navigator.ts +14 -8
  132. package/src/ai/pilot.ts +53 -2
  133. package/src/ai/provider.ts +2 -4
  134. package/src/ai/rerunner.ts +2 -1
  135. package/src/ai/researcher.ts +1 -1
  136. package/src/ai/rules.ts +17 -10
  137. package/src/ai/task-agent.ts +3 -1
  138. package/src/ai/tester.ts +32 -33
  139. package/src/ai/tools.ts +132 -36
  140. package/src/config.ts +15 -4
  141. package/src/execution-controller.ts +8 -0
  142. package/src/explorbot.ts +4 -2
  143. package/src/explorer.ts +2 -2
  144. package/src/playwright-recorder.ts +23 -0
  145. package/src/remote.ts +238 -0
  146. package/src/reporter.ts +7 -1
  147. package/src/state-manager.ts +2 -1
  148. package/src/stats.ts +1 -0
  149. package/src/test-plan.ts +9 -0
  150. package/src/utils/aria.ts +65 -45
  151. package/src/utils/html.ts +1 -0
  152. package/src/utils/logger.ts +24 -1
  153. package/src/utils/page-readiness.ts +24 -1
  154. package/src/utils/url-matcher.ts +3 -0
  155. package/src/utils/web-element.ts +9 -0
  156. package/src/utils/web-sandbox.ts +3 -4
@@ -1,36 +1,78 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
1
3
  import { createRequire } from 'node:module';
2
4
  import path from 'node:path';
5
+ import { tool } from 'ai';
3
6
  import dedent from 'dedent';
7
+ import { z } from 'zod';
4
8
  import * as playwright from 'playwright';
5
9
  import type { Browser } from 'playwright';
6
10
  import { ActionResult } from '../../../src/action-result.ts';
7
- import type { Navigator } from '../../../src/ai/navigator.ts';
8
11
  import { actionRule, locatorRule } from '../../../src/ai/rules.ts';
9
- import { createCodeceptJSTools } from '../../../src/ai/tools.ts';
12
+ import { createAgentTools, createCodeceptJSTools } from '../../../src/ai/tools.ts';
10
13
  import { getAliveEndpoint, launchServer, listInstances, stopServer } from '../../../src/browser-server.ts';
14
+ import { listSites } from '../../../src/global-config.ts';
11
15
  import { ConfigMissingError, ConfigParser, type ExplorbotConfig, outputPath } from '../../../src/config.ts';
12
16
  import { ExplorBot } from '../../../src/explorbot.ts';
17
+ import { Reporter } from '../../../src/reporter.ts';
18
+ import { Stats } from '../../../src/stats.ts';
13
19
  import type { WebPageState } from '../../../src/state-manager.ts';
14
- import { Task } from '../../../src/test-plan.ts';
20
+ import { Task, Test, TestResult } from '../../../src/test-plan.ts';
21
+ import { getPreviousResearch } from '../../../src/ai/researcher/cache.ts';
15
22
  import { compactAriaSnapshot } from '../../../src/utils/aria.ts';
23
+ import { mdq } from '../../../src/utils/markdown-query.ts';
16
24
  import { browserErrorMessage } from '../../../src/utils/browser-errors.ts';
17
25
  import { pluralize } from '../../../src/utils/logger.ts';
18
- import { type EnvelopeData, type HealAttempt, type InstanceInfo, writeArtifacts } from './envelope.ts';
19
- import { isFunctionExpression, toCodeceptWrapper } from './pw-parser.ts';
26
+ import { safeFilename } from '../../../src/utils/strings.ts';
27
+ import { type EnvelopeData, type InstanceInfo, writeArtifacts } from './envelope.ts';
28
+ import { isFunctionExpression, takePwValue, toCodeceptWrapper } from './pw-parser.ts';
29
+ import { type SessionRun, latestSessionFile, readSession, recordCommand, sessionFile, sessionsDir } from './session-log.ts';
20
30
  import { type PwServerDescriptor, readDescriptors, selectDescriptor } from './pw-registry.ts';
21
31
 
22
- const MAX_INSTRUCTION_ITERATIONS = 6;
32
+ const TESTER_ONLY_TOOLS = ['learnExperience', 'askUser'];
33
+ const ITERATIONS_PER_INSTRUCTION = 2;
34
+ const MAX_INSTRUCTION_ITERATIONS = 24;
35
+ const DEFAULT_RESEARCH_AFTER_VISITS = 3;
36
+ const CONTEXT_HTML_CAP = 6000;
23
37
  const MAX_TOOL_ROUNDTRIPS = 5;
24
38
  const AI_AGENT_NAME = 'prima';
25
39
  const CONNECT_TIMEOUT = 3000;
26
40
  const requireLib = createRequire(import.meta.url);
27
41
 
42
+ 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
+
45
+ function dropVolatileColumns(markdown: string): string {
46
+ return mdq(markdown)
47
+ .query('table')
48
+ .replaceEach((table) => {
49
+ const rows = table.toJson();
50
+ if (!rows.length) return table.text();
51
+
52
+ const columns = Object.keys(rows[0]).filter((name) => !VOLATILE_COLUMNS.includes(name));
53
+ if (!columns.length) return table.text();
54
+
55
+ const header = `| ${columns.join(' | ')} |`;
56
+ const divider = `|${columns.map(() => '------').join('|')}|`;
57
+ const body = rows.map((row) => `| ${columns.map((name) => row[name] || '-').join(' | ')} |`);
58
+ return [header, divider, ...body, ''].join('\n');
59
+ });
60
+ }
61
+
62
+ function cap(text: string, max: number): string {
63
+ if (text.length <= max) return text;
64
+ return `${text.slice(0, max)}\n[...truncated; ${text.length - max} chars omitted...]`;
65
+ }
66
+
28
67
  export class Prima {
29
68
  private options: PrimaOptions;
30
69
  private bot: ExplorBot;
31
70
  private artifactsDir?: string;
71
+ private hash?: string;
72
+ private sessionUrl?: string;
32
73
  private server: { close: () => Promise<void> } | null = null;
33
74
  private attached: string | null = null;
75
+ private session: SessionRun | null = null;
34
76
 
35
77
  constructor(options: PrimaOptions = {}) {
36
78
  this.options = options;
@@ -38,17 +80,23 @@ export class Prima {
38
80
  config: options.config,
39
81
  path: options.path,
40
82
  baseUrl: this.configBaseUrl(),
41
- verbose: options.verbose,
42
83
  session: options.session,
43
84
  instance: options.instance,
44
85
  headless: true,
45
86
  optionalAi: true,
87
+ reporter: { enabled: false },
46
88
  });
47
89
  }
48
90
 
49
91
  async start(): Promise<void> {
92
+ let discovery: Discovery | undefined;
93
+ if (!this.options.endpoint) {
94
+ discovery = await this.discover();
95
+ this.adoptSessionUrl(discovery);
96
+ }
97
+
50
98
  const config = await this.loadConfig();
51
- await this.resolveBrowser(config);
99
+ await this.resolveBrowser(config, discovery);
52
100
  await this.bot.start();
53
101
 
54
102
  if (!this.options.url) return;
@@ -65,46 +113,65 @@ export class Prima {
65
113
  const validation = isFunctionExpression(expression);
66
114
  if (!validation.valid) return this.toolFailureEnvelope(command, validation.error!);
67
115
 
68
- const previousState = this.bot.stateManager().getCurrentState();
116
+ const previousState = await this.baselineState();
69
117
  let result: ActionResult | null = null;
118
+ let returnedValue: unknown;
70
119
  let executionError: unknown = null;
71
120
 
72
121
  try {
73
122
  const executed = await this.bot.getExplorer().action().execute(toCodeceptWrapper(expression), { verbatim: true });
74
123
  result = executed.actionResult;
124
+ returnedValue = executed.lastValue;
75
125
  } catch (error) {
76
126
  executionError = error;
77
127
  }
78
128
 
79
- if (executionError) return this.heal(command, expression, executionError, previousState);
129
+ if (executionError) return this.failureEnvelope(command, executionError, previousState);
80
130
 
81
131
  result ||= await this.capturedResult(previousState);
82
- return this.successEnvelope(command, [expression], result, previousState);
132
+ const envelope = await this.successEnvelope(command, [expression], result, previousState);
133
+ envelope.value = takePwValue(returnedValue);
134
+ return envelope;
83
135
  }
84
136
 
85
- async do(instructions: string[]): Promise<EnvelopeData> {
86
- const command = `do ${instructions.map((instruction) => `"${instruction}"`).join(' ')}`;
137
+ async do(instructions: string[], label?: string): Promise<EnvelopeData> {
138
+ const command = label || `do ${instructions.map((instruction) => `"${instruction}"`).join(' ')}`;
87
139
  const guard = await this.aiGuard(command);
88
140
  if (guard) return guard;
89
141
 
90
142
  const provider = this.bot.getProvider();
91
- const previousState = this.bot.stateManager().getCurrentState();
143
+ const previousState = await this.baselineState();
92
144
  const conversation = provider.startConversation(this.instructionSystemPrompt(), AI_AGENT_NAME);
93
145
  const task = new Task(instructions.join('; '), previousState?.url || '');
94
- const tools = createCodeceptJSTools({ explorer: this.bot.getExplorer(), stateManager: this.bot.stateManager(), ai: provider }, task);
95
- conversation.addUserText(this.instructionPrompt(instructions, await this.capturedResult(previousState)));
146
+ const deps = { explorer: this.bot.getExplorer(), stateManager: this.bot.stateManager(), ai: provider };
147
+ const ledger: LedgerEntry[] = instructions.map((text) => ({ text, status: 'open', proof: '' }));
148
+ const descent = { markup: false };
149
+ const tools = { ...createCodeceptJSTools(deps, task), ...this.testerTools(deps), context: this.contextTool(descent), completed: this.completedTool(), blocked: this.blockedTool() };
150
+ conversation.addUserText(await this.instructionPrompt(instructions, await this.capturedResult(previousState)));
96
151
 
97
152
  const used: string[] = [];
153
+ const trace: Array<{ label: string; ok: boolean; proof: string }> = [];
98
154
  let failure: { code: string; message: string } | null = null;
99
155
  let aiError: unknown = null;
100
156
  let narration = '';
157
+ let nudged = false;
101
158
  let contextHash = this.bot.stateManager().getCurrentState()?.hash;
102
159
 
103
- for (let iteration = 1; iteration <= Math.min(instructions.length + 2, MAX_INSTRUCTION_ITERATIONS); iteration++) {
160
+ for (let iteration = 1; iteration <= Math.min(instructions.length * ITERATIONS_PER_INSTRUCTION + 2, MAX_INSTRUCTION_ITERATIONS); iteration++) {
104
161
  const state = this.bot.stateManager().getCurrentState();
105
162
  if (iteration > 1 && state && state.hash !== contextHash) {
106
163
  contextHash = state.hash;
107
- conversation.addUserText(this.pageContext(ActionResult.fromState(state)));
164
+ conversation.addUserText(await this.pageContext(ActionResult.fromState(state)));
165
+ }
166
+
167
+ if (iteration > 1) {
168
+ conversation.addUserText(dedent`
169
+ <progress>
170
+ ${this.ledgerProgress(ledger)}
171
+ </progress>
172
+
173
+ Call completed() now for every open instruction the page already shows is satisfied, before you act again.
174
+ `);
108
175
  }
109
176
 
110
177
  const invoked = await provider.invokeConversation(conversation, tools, { maxToolRoundtrips: MAX_TOOL_ROUNDTRIPS, agentName: AI_AGENT_NAME }).catch((error: unknown) => {
@@ -116,42 +183,176 @@ export class Prima {
116
183
  const executions = invoked.toolExecutions || [];
117
184
  if (!executions.length) {
118
185
  narration = invoked.response?.text?.trim() || '';
119
- break;
186
+ const unreported = this.openInstructions(ledger);
187
+ if (!unreported || nudged) break;
188
+ nudged = true;
189
+ conversation.addUserText(dedent`
190
+ These instructions are still unreported:
191
+ ${unreported}
192
+
193
+ Report each one with completed() or blocked(). Do not act again on anything you have already carried out.
194
+ `);
195
+ continue;
120
196
  }
121
197
 
122
198
  for (const execution of executions) {
199
+ const output = execution.output || {};
200
+
201
+ if (this.applyLedgerReport(execution, ledger, trace)) continue;
202
+
203
+ if (output.action === 'verify' && !output.inexpressible) {
204
+ const claim = execution.input?.assertion || 'verification';
205
+ let passed = execution.wasSuccessful;
206
+ if (output.alreadyVerified) passed = output.verifications?.[claim] === true;
207
+ trace.push({ label: `verify: ${claim}`, ok: passed, proof: output.code || '' });
208
+ continue;
209
+ }
210
+
123
211
  if (!execution.wasSuccessful) {
124
- failure = { code: execution.output?.code || '', message: execution.output?.message || 'action failed' };
212
+ failure = { code: output.code || '', message: output.message || 'action failed' };
213
+ trace.push({ label: output.code || execution.toolName || 'action', ok: false, proof: output.message || '' });
214
+ await this.writeStepFiles(trace.length, output.code || execution.toolName || 'action', '');
125
215
  continue;
126
216
  }
127
- used.push(...this.executedCodes(execution.output?.code));
217
+
218
+ const codes = this.executedCodes(output.code);
219
+ used.push(...codes);
220
+ trace.push({ label: codes.join('; ') || execution.toolName || 'action', ok: true, proof: '' });
221
+ await this.writeStepFiles(trace.length, codes.join(' ') || execution.toolName || 'action', output.pageDiff?.ariaChanges || '');
128
222
  failure = null;
129
223
  }
224
+
225
+ if (ledger.every((entry) => entry.status !== 'open')) break;
130
226
  }
131
227
 
132
228
  if (aiError) return this.failureEnvelope(command, aiError, previousState);
133
229
 
134
- if (failure) {
135
- const envelope = await this.heal(command, failure.code || instructions.join('; '), failure.message, previousState);
136
- envelope.used = [...used, ...(envelope.used || [])];
230
+ if (used.length && ledger.some((entry) => entry.status === 'open')) {
231
+ await this.settleLedger(conversation, provider, ledger, trace);
232
+ }
233
+
234
+ 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 }))];
236
+
237
+ if (failure && unfinished.length) {
238
+ const envelope = await this.failureEnvelope(command, failure.message, previousState);
239
+ envelope.steps = steps;
240
+ envelope.stepFiles = this.statusDir();
137
241
  return envelope;
138
242
  }
139
243
 
140
- if (!used.length) {
244
+ if (!used.length && unfinished.length === ledger.length) {
141
245
  const reason = ['No action was performed for these instructions on the current page.', narration].filter(Boolean).join(' ');
142
246
  return this.failureEnvelope(command, reason, previousState);
143
247
  }
144
248
 
145
249
  const result = await this.capturedResult(this.bot.stateManager().getCurrentState());
146
- return this.successEnvelope(command, used, result, previousState);
250
+ const envelope = await this.successEnvelope(command, used, result, previousState);
251
+ envelope.steps = steps;
252
+ envelope.stepFiles = this.statusDir();
253
+ // the step log already reports every action and what it changed
254
+ envelope.used = undefined;
255
+ envelope.changes = undefined;
256
+
257
+ const unmet = unfinished.map((entry) => `${entry.status}: ${entry.text}${entry.proof ? ` — ${entry.proof}` : ''}`);
258
+ if (unmet.length) {
259
+ envelope.ok = false;
260
+ envelope.failure = { error: unmet.join('\n') };
261
+ }
262
+ return envelope;
147
263
  }
148
264
 
149
- async click(target: string): Promise<EnvelopeData> {
150
- return this.do([`click ${target}`]);
265
+ private openInstructions(ledger: LedgerEntry[]): string {
266
+ return ledger
267
+ .map((entry, index) => ({ entry, number: index + 1 }))
268
+ .filter(({ entry }) => entry.status === 'open')
269
+ .map(({ entry, number }) => `${number}. ${entry.text}`)
270
+ .join('\n');
151
271
  }
152
272
 
153
- async fill(field: string, value: string): Promise<EnvelopeData> {
154
- return this.do([`fill ${field} with value: ${value}`]);
273
+ private applyLedgerReport(execution: any, ledger: LedgerEntry[], trace: Array<{ label: string; ok: boolean; proof: string }>): boolean {
274
+ const action = execution.output?.action;
275
+
276
+ if (action === 'completed') {
277
+ const closed: string[] = [];
278
+ for (const number of execution.input?.numbers || []) {
279
+ const entry = ledger[number - 1];
280
+ if (entry?.status !== 'open') continue;
281
+ entry.status = 'done';
282
+ entry.proof = execution.input?.proof || '';
283
+ closed.push(entry.text);
284
+ }
285
+ // one report carries one proof, however many instructions it closed
286
+ if (closed.length) trace.push({ label: `done: ${closed.join('; ')}`, ok: true, proof: execution.input?.proof || '' });
287
+ return true;
288
+ }
289
+
290
+ if (action !== 'blocked') return false;
291
+
292
+ const entry = ledger[(execution.input?.instruction || 0) - 1];
293
+ if (entry?.status === 'open') {
294
+ entry.status = 'blocked';
295
+ entry.proof = execution.input?.reason || '';
296
+ trace.push({ label: `blocked: ${entry.text}`, ok: false, proof: entry.proof });
297
+ }
298
+ return true;
299
+ }
300
+
301
+ private async settleLedger(conversation: any, provider: any, ledger: LedgerEntry[], trace: Array<{ label: string; ok: boolean; proof: string }>): Promise<void> {
302
+ conversation.addUserText(dedent`
303
+ The run is over and these instructions were never reported:
304
+
305
+ ${this.openInstructions(ledger)}
306
+
307
+ Judge each one against what you saw at the time it was due, not against the page as it stands now — later
308
+ instructions have moved it on, and something you confirmed earlier stays confirmed even if it is gone.
309
+ completed() for those, blocked() for the ones the page could not do. Report every one — nothing else runs after this.
310
+ `);
311
+
312
+ const invoked = await provider.invokeConversation(conversation, { completed: this.completedTool(), blocked: this.blockedTool() }, { maxToolRoundtrips: 2, toolChoice: 'required', agentName: AI_AGENT_NAME }).catch(() => null);
313
+
314
+ for (const execution of invoked?.toolExecutions || []) {
315
+ this.applyLedgerReport(execution, ledger, trace);
316
+ }
317
+ }
318
+
319
+ private ledgerProgress(ledger: LedgerEntry[]): string {
320
+ return ledger
321
+ .map((entry, index) => {
322
+ const head = `${index + 1}. ${entry.status} — ${entry.text}`;
323
+ if (entry.status === 'open') return head;
324
+ return `${head} (${entry.proof})`;
325
+ })
326
+ .join('\n');
327
+ }
328
+
329
+ async check(scenario: string, expected: string[] = []): Promise<EnvelopeData> {
330
+ const command = `check ${scenario}`;
331
+ const guard = await this.aiGuard(command);
332
+ if (guard) return guard;
333
+
334
+ const previousState = await this.baselineState();
335
+ const outcomes = expected.length ? expected : [scenario];
336
+ const test = new Test(scenario, 'normal', outcomes, previousState?.url || this.options.url || '');
337
+ const tester = this.bot.agentTester();
338
+
339
+ const outcome = await tester.test(test);
340
+
341
+ 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 });
344
+ const recorded = notes.filter((note) => !note.observation && !outcomes.includes(note.message));
345
+ const failed = recorded.filter((note) => note.status === TestResult.FAILED);
346
+ envelope.steps = failed.map((note) => ({ label: note.message, ok: false, proof: note.log || '' }));
347
+
348
+ const routine = recorded.length - failed.length;
349
+ 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
+
351
+ envelope.expectations = await this.bot.agentPilot().settleExpectations(test);
352
+
353
+ const observations = notes.filter((note) => note.observation).map((note) => note.message);
354
+ if (observations.length) envelope.answer = ['Page problems noticed while running, not step failures:', ...observations.map((line) => `- ${line}`)].join('\n');
355
+ return envelope;
155
356
  }
156
357
 
157
358
  async ask(question: string): Promise<EnvelopeData> {
@@ -172,9 +373,7 @@ export class Prima {
172
373
  const previousState = this.bot.stateManager().getCurrentState();
173
374
  const result = await this.capturedResult(previousState);
174
375
  const verification = await this.bot.agentNavigator().verifyState(assertion, result);
175
- const codes = verification.successfulCodes || [];
176
- const verdict = { passed: verification.verified, evidence: this.verdictEvidence(verification.verified, codes), code: codes.join('\n') };
177
- return this.reportEnvelope(command, result, previousState, { ok: verification.verified, verdict });
376
+ return this.reportEnvelope(command, result, previousState, { assertions: verification.results || [] });
178
377
  }
179
378
 
180
379
  async research(opts: { data?: boolean; deep?: boolean; fresh?: boolean } = {}): Promise<EnvelopeData> {
@@ -186,7 +385,7 @@ export class Prima {
186
385
  const previousState = this.bot.stateManager().getCurrentState();
187
386
  const result = await this.capturedResult(previousState);
188
387
  const uiMap = await this.bot.agentResearcher().research(result, { screenshot: true, data: opts.data, deep: opts.deep, force: opts.fresh });
189
- return this.reportEnvelope(command, result, previousState, { research: uiMap });
388
+ return this.reportEnvelope(command, result, previousState, { research: dropVolatileColumns(uiMap) });
190
389
  }
191
390
 
192
391
  async go(target: string): Promise<EnvelopeData> {
@@ -208,7 +407,7 @@ export class Prima {
208
407
  navigationError = error;
209
408
  }
210
409
 
211
- if (navigationError) return this.heal(command, code, navigationError, previousState);
410
+ if (navigationError) return this.failureEnvelope(command, navigationError, previousState);
212
411
 
213
412
  const used: string[] = [];
214
413
  if (isUrl) used.push(code);
@@ -237,6 +436,68 @@ export class Prima {
237
436
  return stopped;
238
437
  }
239
438
 
439
+ async config(): Promise<string> {
440
+ const [site] = listSites();
441
+ if (site && !this.configBaseUrl()) this.sessionUrl = site.url;
442
+ const config = await this.loadConfig();
443
+
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');
460
+ }
461
+
462
+ record(envelope: EnvelopeData, durationMs: number): void {
463
+ if (!this.session) return;
464
+ recordCommand(sessionFile(this.session.key), this.session, envelope, durationMs);
465
+ }
466
+
467
+ async report(): Promise<string> {
468
+ const [site] = listSites();
469
+ if (site && !this.configBaseUrl()) this.sessionUrl = site.url;
470
+ await this.loadConfig();
471
+
472
+ let file = latestSessionFile();
473
+ if (this.options.pwSession) file = sessionFile(this.options.pwSession);
474
+ if (!file || !existsSync(file)) return `No prima session was recorded under ${sessionsDir()}. Commands are recorded as they run.`;
475
+
476
+ const session = readSession(file);
477
+ if (!session.tests.length) return `No commands are recorded in ${file}`;
478
+
479
+ Stats.sessionName = path.basename(file, '.jsonl');
480
+ process.env.TESTOMATIO_TITLE = session.title;
481
+ const reporter = new Reporter({ html: true, markdown: true });
482
+
483
+ // the report pipes narrate themselves on console.log; prima prints the paths itself
484
+ const speak = console.log;
485
+ console.log = () => {};
486
+ try {
487
+ for (const test of session.tests) await reporter.reportTestData(test.status, test);
488
+ await reporter.finishRun();
489
+ } finally {
490
+ console.log = speak;
491
+ }
492
+
493
+ return [
494
+ `${session.tests.length} ${pluralize(session.tests.length, 'command')} from ${file}`,
495
+ `html: ${outputPath('reports', `${Stats.sessionLabel()}.html`)}`,
496
+ `markdown: ${outputPath('reports', `${Stats.sessionLabel()}-tests.md`)}`,
497
+ `upload: TESTOMATIO=<apiKey> npx @testomatio/reporter replay ${file}`,
498
+ ].join('\n');
499
+ }
500
+
240
501
  async browserStatus(): Promise<string> {
241
502
  await this.loadConfig();
242
503
  const info = await this.instanceInfo();
@@ -282,7 +543,7 @@ export class Prima {
282
543
  async toolFailureEnvelope(command: string, error: unknown): Promise<EnvelopeData> {
283
544
  const state = this.bot.getCurrentState();
284
545
  const instance = await this.instanceInfo().catch(() => ({ name: this.instanceName(), tabs: 0, others: [] }));
285
- const failure: EnvelopeData['failure'] = { error: `tool: ${browserErrorMessage(error)}`, attempts: [] };
546
+ const failure: EnvelopeData['failure'] = { error: `tool: ${browserErrorMessage(error)}` };
286
547
  if (error instanceof ConfigMissingError) failure.error = browserErrorMessage(error);
287
548
  if (state?.ariaSnapshot) failure.compactAria = compactAriaSnapshot(state.ariaSnapshot, true);
288
549
 
@@ -300,31 +561,41 @@ export class Prima {
300
561
  }
301
562
 
302
563
  private configBaseUrl(): string | undefined {
303
- const url = this.options.baseUrl || this.options.url;
564
+ const url = this.options.baseUrl || this.options.url || this.sessionUrl;
304
565
  if (!url) return undefined;
305
566
  if (!URL.canParse(url)) return undefined;
306
567
  return url;
307
568
  }
308
569
 
309
- private async resolveBrowser(config: ExplorbotConfig): Promise<void> {
570
+ private adoptSessionUrl(discovery: Discovery): void {
571
+ if (this.options.baseUrl || this.options.url) return;
572
+
573
+ const url = discovery.browser?.contexts()[0]?.pages()[0]?.url();
574
+ if (!url?.startsWith('http')) return;
575
+ this.sessionUrl = new URL(url).origin;
576
+ this.bot.getOptions().baseUrl = this.sessionUrl;
577
+ }
578
+
579
+ private async resolveBrowser(config: ExplorbotConfig, discovered?: Discovery): Promise<void> {
310
580
  if (this.options.endpoint) {
311
581
  const endpoint = this.options.endpoint;
312
582
  const browserName = config.playwright.browser || 'chromium';
313
- if (await this.attachToEndpoint({ file: '', title: '', endpoint, workspaceDir: '', browserName, playwrightLib: '' })) return;
583
+ const known = readDescriptors().find((descriptor) => descriptor.endpoint === endpoint);
584
+ if (await this.attachToEndpoint({ file: '', title: '', endpoint, workspaceDir: '', browserName, playwrightLib: known?.playwrightLib || '' })) return;
314
585
  throw new Error(dedent`
315
586
  No browser answered at ${endpoint}.
316
- Check the endpoint of the running session, or drop --endpoint to attach to the
317
- playwright-cli browser of this workspace.
587
+ Check the endpoint of the running session, or drop --endpoint to let prima pick
588
+ the playwright-cli session itself.
318
589
  `);
319
590
  }
320
591
 
321
- const { match, candidates, browser } = await this.discover();
592
+ const { match, candidates, browser } = discovered || (await this.discover());
322
593
  if (match && (await this.attachToEndpoint(match, browser))) return;
323
594
 
324
595
  if (!match && candidates.length) {
325
596
  const titles = candidates.map((candidate) => candidate.title).join(', ');
326
597
  throw new Error(dedent`
327
- Several playwright-cli sessions are open for this workspace: ${titles}
598
+ Several playwright-cli sessions are open: ${titles}
328
599
  Pick one with --pw-session <title>.
329
600
  `);
330
601
  }
@@ -342,7 +613,7 @@ export class Prima {
342
613
 
343
614
  private async discover(descriptors = readDescriptors()): Promise<Discovery> {
344
615
  const title = this.options.pwSession ?? process.env.PLAYWRIGHT_CLI_SESSION;
345
- const opts = { workspaceDir: this.workspaceDir(), title };
616
+ const opts = { title };
346
617
 
347
618
  const alive = new Map<PwServerDescriptor, Browser>();
348
619
  for (const candidate of selectDescriptor(descriptors, opts).candidates) {
@@ -368,13 +639,14 @@ export class Prima {
368
639
 
369
640
  this.bot.attachBrowser(browser);
370
641
  this.attached = this.attachmentLabel(descriptor);
642
+ this.session = { key: descriptor.title || this.instanceName(), endpoint: descriptor.endpoint, title: `prima session "${descriptor.title || this.instanceName()}"` };
371
643
  return true;
372
644
  }
373
645
 
374
646
  private async connectDescriptor(descriptor: PwServerDescriptor): Promise<Browser | null> {
375
- const connected = await this.connectWith(playwright, descriptor);
647
+ const connected = await this.connectWith(this.descriptorLib(descriptor), descriptor);
376
648
  if (connected) return connected;
377
- return this.connectWith(this.descriptorLib(descriptor), descriptor);
649
+ return this.connectWith(playwright, descriptor);
378
650
  }
379
651
 
380
652
  private async connectWith(lib: any, descriptor: PwServerDescriptor): Promise<Browser | null> {
@@ -394,15 +666,15 @@ export class Prima {
394
666
 
395
667
  private attachmentLabel(descriptor: PwServerDescriptor): string {
396
668
  if (!descriptor.title) return `endpoint ${descriptor.endpoint}`;
669
+ if (!descriptor.workspaceDir) return `playwright-cli session "${descriptor.title}"`;
397
670
  return `playwright-cli session "${descriptor.title}", workspace ${descriptor.workspaceDir}`;
398
671
  }
399
672
 
400
- private workspaceDir(): string {
401
- return path.resolve(this.options.path || process.cwd());
402
- }
403
-
404
673
  private async connectOwnInstance(): Promise<boolean> {
405
- return !!(await getAliveEndpoint(this.instanceName()));
674
+ const endpoint = await getAliveEndpoint(this.instanceName());
675
+ if (!endpoint) return false;
676
+ this.session = { key: this.instanceName(), endpoint, title: `prima instance "${this.instanceName()}"` };
677
+ return true;
406
678
  }
407
679
 
408
680
  private async launchOwnServer(opts: { browser?: string; show?: boolean }, instance: string): Promise<{ close: () => Promise<void> }> {
@@ -425,49 +697,6 @@ export class Prima {
425
697
  return URL.canParse(value);
426
698
  }
427
699
 
428
- private async heal(command: string, expression: string, error: unknown, previousState: WebPageState | null): Promise<EnvelopeData> {
429
- if (this.options.heal === false) return this.failureEnvelope(command, error, previousState);
430
-
431
- const navigator = this.healNavigator();
432
- if (!navigator) {
433
- const envelope = await this.failureEnvelope(command, error, previousState);
434
- envelope.healed = false;
435
- envelope.healNote = this.aiUnavailableNote();
436
- return envelope;
437
- }
438
-
439
- const message = dedent`
440
- I tried to run this command on the page: ${expression}
441
- But it failed with: ${browserErrorMessage(error)}
442
- Reach the same outcome on the current page in a different way.
443
- `;
444
-
445
- const attempts: Array<{ code: string; error?: string }> = [];
446
- const failedResult = await this.capturedResult(previousState);
447
- const resolved = await navigator.resolveState(message, failedResult, { onAttempt: (attempt) => attempts.push(attempt) }).catch(() => false);
448
-
449
- if (!resolved) {
450
- const healAttempts = attempts.map((attempt) => ({ code: attempt.code, outcome: attempt.error || 'ok' }));
451
- return this.failureEnvelope(command, error, previousState, healAttempts);
452
- }
453
-
454
- const used = attempts.filter((attempt) => !attempt.error).map((attempt) => attempt.code);
455
- const result = await this.capturedResult(this.bot.stateManager().getCurrentState());
456
- const envelope = await this.successEnvelope(command, used, result, previousState);
457
- envelope.healed = true;
458
- envelope.healNote = `recovered after ${attempts.length} ${pluralize(attempts.length, 'attempt')}`;
459
- return envelope;
460
- }
461
-
462
- private healNavigator(): Navigator | null {
463
- if (this.aiUnavailable()) return null;
464
- try {
465
- return this.bot.agentNavigator?.() ?? null;
466
- } catch {
467
- return null;
468
- }
469
- }
470
-
471
700
  private aiUnavailable(): string | null {
472
701
  try {
473
702
  if (this.bot.getProvider?.()) return null;
@@ -477,12 +706,6 @@ export class Prima {
477
706
  return this.bot.aiFailureReason?.() || 'no AI model is configured';
478
707
  }
479
708
 
480
- private aiUnavailableNote(): string {
481
- const reason = this.aiUnavailable();
482
- if (!reason) return 'ai unavailable';
483
- return `ai unavailable: ${reason}`;
484
- }
485
-
486
709
  private async aiGuard(command: string): Promise<EnvelopeData | null> {
487
710
  const reason = this.aiUnavailable();
488
711
  if (!reason) return null;
@@ -497,47 +720,191 @@ export class Prima {
497
720
  </role>
498
721
 
499
722
  <approach>
500
- 1. Read the page context and perform the instructions in the order they are listed.
723
+ 1. Read the page context and carry out the instructions in the order they are listed.
501
724
  2. Interact with the page only through the provided tools.
502
725
  3. Pick the smallest interaction that fulfills an instruction, then move to the next one.
503
726
  4. After the page changes, work from the updated context you are given, not from the earlier one.
504
- 5. Stop calling tools when every instruction is done, or when an instruction cannot be performed on this page say what is missing instead.
727
+ 5. Account for every instruction: completed() as soon as one is satisfied, blocked() when the page cannot do what it asks.
505
728
  </approach>
506
729
 
730
+ <ledger>
731
+ Instructions are numbered and those numbers never change. Report by number.
732
+ Saying in your reply that something is done does not report it — only completed() does. Nothing you write is read as a report.
733
+ Report an instruction the moment the page shows it is satisfied, before moving on. Waiting until later is how work gets repeated.
734
+ An instruction you have reported is finished. Never act on it again, and never report it twice.
735
+ After each turn you are shown every instruction with its state. Act only on the ones still open — repeating an action that already
736
+ landed can undo it, since a control that opened something will close it again.
737
+ Reaching for blocked() after a couple of honest attempts costs less than a third attempt that fails the same way.
738
+ </ledger>
739
+
740
+ <scope>
741
+ Do only what the instructions ask. An action that looks helpful but was not asked for is out of scope — report it as something you noticed, never perform it.
742
+ Continuing past the last instruction is a failure, even when the next step seems obvious.
743
+ An instruction worded as a condition — do X if Y appears — is satisfied the moment you can see Y is absent. Say so and move on. Never search for something the page does not show.
744
+ </scope>
745
+
746
+ <pace>
747
+ Work in as few turns as you can. When the next actions are already determined by what you can see, ask for them together in one turn rather than one at a time — each turn costs a full round trip.
748
+ Only stop to look again when what you find changes what you would do next.
749
+ A batch may not run past an instruction that inspects the page — settle that one first, because the actions after it destroy the state it would have read.
750
+ </pace>
751
+
752
+ <proof>
753
+ 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
+ That part is what completed() takes as its proof. Do not restate the action as if it were the outcome.
755
+ 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
+ </proof>
757
+
758
+ <targets>
759
+ The page context lists every element with a ref, like [ref=e14]. To click one, pass that ref to clickRef — a ref names one
760
+ exact element, so it cannot match several by mistake and costs nothing to resolve. This is the cheapest way to act.
761
+ Use click() with a role and name for anything clickRef cannot take, and narrow with the container it sits in when a name
762
+ appears more than once, rather than guessing at an id or a class.
763
+ Refs belong to the context you were given. Use the ones in your newest context, never one you invented or remembered from
764
+ an older page. When the element an instruction needs is missing from that context, call context() and act on what it returns.
765
+ </targets>
766
+
507
767
  ${locatorRule}
508
768
 
509
769
  ${actionRule}
770
+
771
+ <targets_first>
772
+ Everything above about composing locators applies to click() and the other locator tools. It does not apply when the
773
+ element carries a ref: pass that ref to clickRef instead and compose nothing. Reach for a locator only for elements
774
+ that have no ref, or when a ref has stopped resolving.
775
+ </targets_first>
510
776
  `;
511
777
  }
512
778
 
513
- private instructionPrompt(instructions: string[], result: ActionResult): string {
779
+ private async instructionPrompt(instructions: string[], result: ActionResult): Promise<string> {
514
780
  const list = instructions.map((instruction, index) => `${index + 1}. ${instruction}`).join('\n');
515
781
  return dedent`
516
782
  <instructions>
517
783
  ${list}
518
784
  </instructions>
519
785
 
520
- ${this.pageContext(result)}
786
+ ${await this.pageContext(result)}
521
787
  `;
522
788
  }
523
789
 
524
- private pageContext(result: ActionResult): string {
790
+ private testerTools(deps: any): any {
791
+ const researcher = this.bot.agentResearcher?.();
792
+ const navigator = this.bot.agentNavigator?.();
793
+ if (!researcher || !navigator) return {};
794
+
795
+ const tools = createAgentTools({ ...deps, researcher, navigator, withExperience: false });
796
+ for (const name of TESTER_ONLY_TOOLS) delete tools[name];
797
+ return tools;
798
+ }
799
+
800
+ private completedTool(): any {
801
+ return tool({
802
+ description: dedent`
803
+ Report the instructions you have just satisfied, by their number. Report several together when one turn satisfied several.
804
+ A reported instruction is finished — you will not be asked for it again and must not act on it again.
805
+ `,
806
+ inputSchema: z.object({
807
+ numbers: z.array(z.number()).describe('Numbers of the instructions now satisfied, as they are numbered in the instruction list'),
808
+ proof: z.string().describe('What on the page shows they are satisfied'),
809
+ }),
810
+ execute: async () => ({ success: true, action: 'completed' }),
811
+ });
812
+ }
813
+
814
+ private blockedTool(): any {
815
+ return tool({
816
+ description: dedent`
817
+ Report one instruction that cannot be carried out on this page, by its number. Reach for this instead of trying the same thing again.
818
+ The rest of the sequence continues without it.
819
+ `,
820
+ inputSchema: z.object({
821
+ instruction: z.number().describe('Number of the instruction that cannot be carried out'),
822
+ reason: z.string().describe('What stopped it — what you looked for and what the page showed instead'),
823
+ }),
824
+ execute: async () => ({ success: true, action: 'blocked' }),
825
+ });
826
+ }
827
+
828
+ private contextTool(descent: { markup: boolean }): any {
829
+ let refreshed = false;
830
+ return tool({
831
+ description: dedent`
832
+ Look at the page again when the refs you hold no longer resolve, or when the element an instruction needs is not in the context you were given.
833
+ The first call returns the page as it is now, with fresh refs that replace every ref you were holding.
834
+ A later call on the same page drops to the raw markup, for elements the accessibility tree does not describe.
835
+ Do not call it to confirm an action worked — the change is already reported back to you.
836
+ `,
837
+ inputSchema: z.object({
838
+ reason: z.string().describe('Which element you cannot reach and what you already tried'),
839
+ }),
840
+ execute: async () => {
841
+ const result = await this.capturedResult(this.bot.stateManager().getCurrentState());
842
+ if (!refreshed) {
843
+ refreshed = true;
844
+ return { success: true, context: await this.pageContext(result) };
845
+ }
846
+ descent.markup = true;
847
+ return { success: true, context: cap(await result.simplifiedHtml(), CONTEXT_HTML_CAP) };
848
+ },
849
+ });
850
+ }
851
+
852
+ private async pageContext(result: ActionResult): Promise<string> {
525
853
  const experience = this.bot.experienceTracker?.()?.renderExperienceTocFor?.(result) || '';
854
+ const map = this.researchMap(result);
855
+ if (map) {
856
+ return dedent`
857
+ <page_ui_map url="${result.url}" title="${result.title}">
858
+ ${map}
859
+ </page_ui_map>
860
+
861
+ ${experience}
862
+ `;
863
+ }
864
+
526
865
  return dedent`
527
866
  <page url="${result.url}" title="${result.title}">
528
- ${compactAriaSnapshot(result.ariaSnapshot, true)}
867
+ ${compactAriaSnapshot(await this.refAriaSnapshot(result), true, (value) => this.offloadValue(value))}
529
868
  </page>
530
869
 
531
870
  ${experience}
532
871
  `;
533
872
  }
534
873
 
874
+ private researchMap(result: ActionResult): string {
875
+ if (this.bot.stateManager().getVisitCount(result.url) < this.researchAfterVisits()) return '';
876
+ return getPreviousResearch(result.getStateHash());
877
+ }
878
+
879
+ private researchAfterVisits(): number {
880
+ const configured = this.bot.getConfig?.()?.ai?.agents?.prima?.researchAfterVisits;
881
+ if (typeof configured === 'number') return configured;
882
+ return DEFAULT_RESEARCH_AFTER_VISITS;
883
+ }
884
+
885
+ private offloadValue(value: string): string | undefined {
886
+ const dir = this.statusDir();
887
+ const name = `value-${createHash('sha1').update(value).digest('hex').slice(0, 8)}.txt`;
888
+ try {
889
+ mkdirSync(dir, { recursive: true });
890
+ writeFileSync(path.join(dir, name), value, 'utf-8');
891
+ } catch {
892
+ return undefined;
893
+ }
894
+ return path.join(path.basename(dir), name);
895
+ }
896
+
897
+ private async refAriaSnapshot(result: ActionResult): Promise<string | null> {
898
+ const snapshot = await Promise.resolve(this.bot.getExplorer()?.withPage?.((page: any) => page.locator('body').ariaSnapshot({ mode: 'ai' }))).catch(() => null);
899
+ return snapshot || result.ariaSnapshot;
900
+ }
901
+
535
902
  private executedCodes(code: unknown): string[] {
536
903
  if (typeof code !== 'string') return [];
537
904
  return code
538
905
  .split('\n')
539
906
  .map((line) => line.trim())
540
- .filter((line) => line);
907
+ .filter((line) => line && !line.startsWith('//'));
541
908
  }
542
909
 
543
910
  private visionEnabled(): boolean {
@@ -585,12 +952,6 @@ export class Prima {
585
952
  return response?.text || '';
586
953
  }
587
954
 
588
- private verdictEvidence(verified: boolean, codes: string[]): string {
589
- if (!verified) return 'no assertion held on the current page';
590
- if (!codes.length) return 'already verified on this page';
591
- return `${codes[0]} passed`;
592
- }
593
-
594
955
  private async successEnvelope(command: string, used: string[], result: ActionResult, previousState: WebPageState | null): Promise<EnvelopeData> {
595
956
  return {
596
957
  ok: true,
@@ -599,15 +960,14 @@ export class Prima {
599
960
  page: this.pageBlock(result, previousState),
600
961
  changes: await this.pageChanges(result, previousState, used[0]),
601
962
  instance: await this.instanceInfo(),
602
- artifacts: await this.writeSnapshot(result),
963
+ status: await this.saveStatus(result),
603
964
  };
604
965
  }
605
966
 
606
- private async failureEnvelope(command: string, error: unknown, previousState: WebPageState | null, attempts: HealAttempt[] = []): Promise<EnvelopeData> {
967
+ private async failureEnvelope(command: string, error: unknown, previousState: WebPageState | null): Promise<EnvelopeData> {
607
968
  const result = await this.capturedResult(previousState);
608
- const failure: EnvelopeData['failure'] = { error: browserErrorMessage(error), attempts };
969
+ const failure: EnvelopeData['failure'] = { error: browserErrorMessage(error) };
609
970
  if (result.ariaSnapshot) failure.compactAria = compactAriaSnapshot(result.ariaSnapshot, true);
610
- if (attempts.length) failure.reasoning = [...new Set(attempts.map((attempt) => attempt.outcome))].join('; ');
611
971
 
612
972
  return {
613
973
  ok: false,
@@ -615,7 +975,7 @@ export class Prima {
615
975
  page: this.pageBlock(result, previousState),
616
976
  failure,
617
977
  instance: await this.instanceInfo(),
618
- artifacts: await this.writeSnapshot(result),
978
+ status: await this.saveStatus(result),
619
979
  };
620
980
  }
621
981
 
@@ -626,7 +986,7 @@ export class Prima {
626
986
  page: this.pageBlock(result, previousState),
627
987
  ...outcome,
628
988
  instance: await this.instanceInfo(),
629
- artifacts: await this.writeSnapshot(result),
989
+ status: await this.saveStatus(result),
630
990
  };
631
991
  }
632
992
 
@@ -650,23 +1010,78 @@ export class Prima {
650
1010
  };
651
1011
  }
652
1012
 
653
- private async pageChanges(result: ActionResult, previousState: WebPageState | null, code: string): Promise<string | null> {
654
- if (!previousState) return null;
1013
+ private async baselineState(): Promise<WebPageState | null> {
1014
+ const existing = this.bot.stateManager?.()?.getCurrentState();
1015
+ if (existing) return existing;
1016
+
1017
+ const result = await Promise.resolve(this.bot.getExplorer?.()?.capture?.()).catch(() => null);
1018
+ if (!result) return null;
1019
+ return this.bot.stateManager?.()?.updateState(result) ?? null;
1020
+ }
1021
+
1022
+ private async pageChanges(result: ActionResult, previousState: WebPageState | null, code: string): Promise<string> {
1023
+ if (!previousState) return 'no snapshot was captured before this command, so nothing could be compared';
655
1024
  const toolResult = await result.toToolResult(ActionResult.fromState(previousState), code);
656
- return toolResult.pageDiff?.ariaChanges ?? null;
1025
+ return toolResult.pageDiff?.ariaChanges || 'no change';
1026
+ }
1027
+
1028
+ async status(hash: string): Promise<EnvelopeData> {
1029
+ const dir = this.statusDir(hash);
1030
+ const statusFile = path.join(dir, 'status.json');
1031
+ if (!existsSync(statusFile)) return this.toolFailureEnvelope(`status ${hash}`, `No command was recorded under ${hash}. Every envelope prints its own hash on the Instance line.`);
1032
+
1033
+ const saved = JSON.parse(readFileSync(statusFile, 'utf-8'));
1034
+ return {
1035
+ ok: true,
1036
+ command: `status ${hash}`,
1037
+ page: saved.page,
1038
+ changes: saved.changes,
1039
+ instance: await this.instanceInfo(),
1040
+ artifacts: { aria: path.join(dir, 'aria.yml'), html: path.join(dir, 'page.html') },
1041
+ };
657
1042
  }
658
1043
 
659
- private async writeSnapshot(result: ActionResult): Promise<EnvelopeData['artifacts']> {
660
- return writeArtifacts(this.nextArtifactDir(), {
1044
+ private async saveStatus(result: ActionResult): Promise<string> {
1045
+ const hash = this.statusHash();
1046
+ 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');
1048
+ return hash;
1049
+ }
1050
+
1051
+ private async writeStepFiles(index: number, label: string, diff: string): Promise<void> {
1052
+ const state = this.bot.stateManager().getCurrentState();
1053
+ if (!state) return;
1054
+
1055
+ const dir = this.statusDir();
1056
+ mkdirSync(dir, { recursive: true });
1057
+ const stem = path.join(dir, `${index}-${safeFilename(label.slice(0, 60))}`);
1058
+ const result = ActionResult.fromState(state);
1059
+
1060
+ writeFileSync(`${stem}.aria.yaml`, result.ariaSnapshot ?? '', 'utf-8');
1061
+ writeFileSync(`${stem}.html`, await result.combinedHtml(), 'utf-8');
1062
+ if (diff) writeFileSync(`${stem}.diff.yaml`, diff, 'utf-8');
1063
+ }
1064
+
1065
+ private async writeSnapshot(result: ActionResult): Promise<undefined> {
1066
+ writeArtifacts(this.statusDir(), {
661
1067
  aria: result.ariaSnapshot,
662
1068
  html: await result.combinedHtml(),
663
1069
  requests: this.bot.requestStore().getRequests(),
664
1070
  });
1071
+ return undefined;
665
1072
  }
666
1073
 
667
- private nextArtifactDir(): string {
1074
+ private statusHash(): string {
1075
+ this.hash ||= createHash('sha1')
1076
+ .update(`${this.options.path || process.cwd()}-${Date.now()}`)
1077
+ .digest('hex')
1078
+ .slice(0, 15);
1079
+ return this.hash;
1080
+ }
1081
+
1082
+ private statusDir(hash = this.statusHash()): string {
668
1083
  this.artifactsDir ||= outputPath('prima');
669
- return path.join(this.artifactsDir, new Date().toISOString().replace(/[:.]/g, '-'));
1084
+ return path.join(this.artifactsDir, hash);
670
1085
  }
671
1086
 
672
1087
  private tabCount(): number {
@@ -686,13 +1101,17 @@ interface Discovery {
686
1101
  browser?: Browser;
687
1102
  }
688
1103
 
1104
+ interface LedgerEntry {
1105
+ text: string;
1106
+ status: 'open' | 'done' | 'blocked';
1107
+ proof: string;
1108
+ }
1109
+
689
1110
  export interface PrimaOptions {
690
- verbose?: boolean;
691
1111
  config?: string;
692
1112
  path?: string;
693
1113
  instance?: string;
694
1114
  session?: string | boolean;
695
- heal?: boolean;
696
1115
  ephemeral?: boolean;
697
1116
  framework?: 'codeceptjs' | 'playwright';
698
1117
  noVision?: boolean;