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,47 +1,93 @@
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 { ActionResult } from "../../../src/action-result.js";
6
10
  import { actionRule, locatorRule } from "../../../src/ai/rules.js";
7
- import { createCodeceptJSTools } from "../../../src/ai/tools.js";
11
+ import { createAgentTools, createCodeceptJSTools } from "../../../src/ai/tools.js";
8
12
  import { getAliveEndpoint, launchServer, listInstances, stopServer } from "../../../src/browser-server.js";
13
+ import { listSites } from "../../../src/global-config.js";
9
14
  import { ConfigMissingError, ConfigParser, outputPath } from "../../../src/config.js";
10
15
  import { ExplorBot } from "../../../src/explorbot.js";
11
- import { Task } from "../../../src/test-plan.js";
16
+ import { Reporter } from "../../../src/reporter.js";
17
+ import { Stats } from "../../../src/stats.js";
18
+ import { Task, Test, TestResult } from "../../../src/test-plan.js";
19
+ import { getPreviousResearch } from "../../../src/ai/researcher/cache.js";
12
20
  import { compactAriaSnapshot } from "../../../src/utils/aria.js";
21
+ import { mdq } from "../../../src/utils/markdown-query.js";
13
22
  import { browserErrorMessage } from "../../../src/utils/browser-errors.js";
14
23
  import { pluralize } from "../../../src/utils/logger.js";
24
+ import { safeFilename } from "../../../src/utils/strings.js";
15
25
  import { writeArtifacts } from "./envelope.js";
16
- import { isFunctionExpression, toCodeceptWrapper } from "./pw-parser.js";
26
+ import { isFunctionExpression, takePwValue, toCodeceptWrapper } from "./pw-parser.js";
27
+ import { latestSessionFile, readSession, recordCommand, sessionFile, sessionsDir } from "./session-log.js";
17
28
  import { readDescriptors, selectDescriptor } from "./pw-registry.js";
18
- const MAX_INSTRUCTION_ITERATIONS = 6;
29
+ const TESTER_ONLY_TOOLS = ['learnExperience', 'askUser'];
30
+ const ITERATIONS_PER_INSTRUCTION = 2;
31
+ const MAX_INSTRUCTION_ITERATIONS = 24;
32
+ const DEFAULT_RESEARCH_AFTER_VISITS = 3;
33
+ const CONTEXT_HTML_CAP = 6000;
19
34
  const MAX_TOOL_ROUNDTRIPS = 5;
20
35
  const AI_AGENT_NAME = 'prima';
21
36
  const CONNECT_TIMEOUT = 3000;
22
37
  const requireLib = createRequire(import.meta.url);
38
+ const VOLATILE_COLUMNS = ['CSS', 'XPath', 'Coordinates', 'eidx'];
39
+ const UNACCOUNTED = { open: 'never reported — the run ended with this instruction still open' };
40
+ function dropVolatileColumns(markdown) {
41
+ return mdq(markdown)
42
+ .query('table')
43
+ .replaceEach((table) => {
44
+ const rows = table.toJson();
45
+ if (!rows.length)
46
+ return table.text();
47
+ const columns = Object.keys(rows[0]).filter((name) => !VOLATILE_COLUMNS.includes(name));
48
+ if (!columns.length)
49
+ return table.text();
50
+ const header = `| ${columns.join(' | ')} |`;
51
+ const divider = `|${columns.map(() => '------').join('|')}|`;
52
+ const body = rows.map((row) => `| ${columns.map((name) => row[name] || '-').join(' | ')} |`);
53
+ return [header, divider, ...body, ''].join('\n');
54
+ });
55
+ }
56
+ function cap(text, max) {
57
+ if (text.length <= max)
58
+ return text;
59
+ return `${text.slice(0, max)}\n[...truncated; ${text.length - max} chars omitted...]`;
60
+ }
23
61
  export class Prima {
24
62
  options;
25
63
  bot;
26
64
  artifactsDir;
65
+ hash;
66
+ sessionUrl;
27
67
  server = null;
28
68
  attached = null;
69
+ session = null;
29
70
  constructor(options = {}) {
30
71
  this.options = options;
31
72
  this.bot = new ExplorBot({
32
73
  config: options.config,
33
74
  path: options.path,
34
75
  baseUrl: this.configBaseUrl(),
35
- verbose: options.verbose,
36
76
  session: options.session,
37
77
  instance: options.instance,
38
78
  headless: true,
39
79
  optionalAi: true,
80
+ reporter: { enabled: false },
40
81
  });
41
82
  }
42
83
  async start() {
84
+ let discovery;
85
+ if (!this.options.endpoint) {
86
+ discovery = await this.discover();
87
+ this.adoptSessionUrl(discovery);
88
+ }
43
89
  const config = await this.loadConfig();
44
- await this.resolveBrowser(config);
90
+ await this.resolveBrowser(config, discovery);
45
91
  await this.bot.start();
46
92
  if (!this.options.url)
47
93
  return;
@@ -57,42 +103,60 @@ export class Prima {
57
103
  const validation = isFunctionExpression(expression);
58
104
  if (!validation.valid)
59
105
  return this.toolFailureEnvelope(command, validation.error);
60
- const previousState = this.bot.stateManager().getCurrentState();
106
+ const previousState = await this.baselineState();
61
107
  let result = null;
108
+ let returnedValue;
62
109
  let executionError = null;
63
110
  try {
64
111
  const executed = await this.bot.getExplorer().action().execute(toCodeceptWrapper(expression), { verbatim: true });
65
112
  result = executed.actionResult;
113
+ returnedValue = executed.lastValue;
66
114
  }
67
115
  catch (error) {
68
116
  executionError = error;
69
117
  }
70
118
  if (executionError)
71
- return this.heal(command, expression, executionError, previousState);
119
+ return this.failureEnvelope(command, executionError, previousState);
72
120
  result ||= await this.capturedResult(previousState);
73
- return this.successEnvelope(command, [expression], result, previousState);
121
+ const envelope = await this.successEnvelope(command, [expression], result, previousState);
122
+ envelope.value = takePwValue(returnedValue);
123
+ return envelope;
74
124
  }
75
- async do(instructions) {
76
- const command = `do ${instructions.map((instruction) => `"${instruction}"`).join(' ')}`;
125
+ async do(instructions, label) {
126
+ const command = label || `do ${instructions.map((instruction) => `"${instruction}"`).join(' ')}`;
77
127
  const guard = await this.aiGuard(command);
78
128
  if (guard)
79
129
  return guard;
80
130
  const provider = this.bot.getProvider();
81
- const previousState = this.bot.stateManager().getCurrentState();
131
+ const previousState = await this.baselineState();
82
132
  const conversation = provider.startConversation(this.instructionSystemPrompt(), AI_AGENT_NAME);
83
133
  const task = new Task(instructions.join('; '), previousState?.url || '');
84
- const tools = createCodeceptJSTools({ explorer: this.bot.getExplorer(), stateManager: this.bot.stateManager(), ai: provider }, task);
85
- conversation.addUserText(this.instructionPrompt(instructions, await this.capturedResult(previousState)));
134
+ const deps = { explorer: this.bot.getExplorer(), stateManager: this.bot.stateManager(), ai: provider };
135
+ const ledger = instructions.map((text) => ({ text, status: 'open', proof: '' }));
136
+ const descent = { markup: false };
137
+ const tools = { ...createCodeceptJSTools(deps, task), ...this.testerTools(deps), context: this.contextTool(descent), completed: this.completedTool(), blocked: this.blockedTool() };
138
+ conversation.addUserText(await this.instructionPrompt(instructions, await this.capturedResult(previousState)));
86
139
  const used = [];
140
+ const trace = [];
87
141
  let failure = null;
88
142
  let aiError = null;
89
143
  let narration = '';
144
+ let nudged = false;
90
145
  let contextHash = this.bot.stateManager().getCurrentState()?.hash;
91
- for (let iteration = 1; iteration <= Math.min(instructions.length + 2, MAX_INSTRUCTION_ITERATIONS); iteration++) {
146
+ for (let iteration = 1; iteration <= Math.min(instructions.length * ITERATIONS_PER_INSTRUCTION + 2, MAX_INSTRUCTION_ITERATIONS); iteration++) {
92
147
  const state = this.bot.stateManager().getCurrentState();
93
148
  if (iteration > 1 && state && state.hash !== contextHash) {
94
149
  contextHash = state.hash;
95
- conversation.addUserText(this.pageContext(ActionResult.fromState(state)));
150
+ conversation.addUserText(await this.pageContext(ActionResult.fromState(state)));
151
+ }
152
+ if (iteration > 1) {
153
+ conversation.addUserText(dedent `
154
+ <progress>
155
+ ${this.ledgerProgress(ledger)}
156
+ </progress>
157
+
158
+ Call completed() now for every open instruction the page already shows is satisfied, before you act again.
159
+ `);
96
160
  }
97
161
  const invoked = await provider.invokeConversation(conversation, tools, { maxToolRoundtrips: MAX_TOOL_ROUNDTRIPS, agentName: AI_AGENT_NAME }).catch((error) => {
98
162
  aiError = error;
@@ -103,36 +167,159 @@ export class Prima {
103
167
  const executions = invoked.toolExecutions || [];
104
168
  if (!executions.length) {
105
169
  narration = invoked.response?.text?.trim() || '';
106
- break;
170
+ const unreported = this.openInstructions(ledger);
171
+ if (!unreported || nudged)
172
+ break;
173
+ nudged = true;
174
+ conversation.addUserText(dedent `
175
+ These instructions are still unreported:
176
+ ${unreported}
177
+
178
+ Report each one with completed() or blocked(). Do not act again on anything you have already carried out.
179
+ `);
180
+ continue;
107
181
  }
108
182
  for (const execution of executions) {
183
+ const output = execution.output || {};
184
+ if (this.applyLedgerReport(execution, ledger, trace))
185
+ continue;
186
+ if (output.action === 'verify' && !output.inexpressible) {
187
+ const claim = execution.input?.assertion || 'verification';
188
+ let passed = execution.wasSuccessful;
189
+ if (output.alreadyVerified)
190
+ passed = output.verifications?.[claim] === true;
191
+ trace.push({ label: `verify: ${claim}`, ok: passed, proof: output.code || '' });
192
+ continue;
193
+ }
109
194
  if (!execution.wasSuccessful) {
110
- failure = { code: execution.output?.code || '', message: execution.output?.message || 'action failed' };
195
+ failure = { code: output.code || '', message: output.message || 'action failed' };
196
+ trace.push({ label: output.code || execution.toolName || 'action', ok: false, proof: output.message || '' });
197
+ await this.writeStepFiles(trace.length, output.code || execution.toolName || 'action', '');
111
198
  continue;
112
199
  }
113
- used.push(...this.executedCodes(execution.output?.code));
200
+ const codes = this.executedCodes(output.code);
201
+ used.push(...codes);
202
+ trace.push({ label: codes.join('; ') || execution.toolName || 'action', ok: true, proof: '' });
203
+ await this.writeStepFiles(trace.length, codes.join(' ') || execution.toolName || 'action', output.pageDiff?.ariaChanges || '');
114
204
  failure = null;
115
205
  }
206
+ if (ledger.every((entry) => entry.status !== 'open'))
207
+ break;
116
208
  }
117
209
  if (aiError)
118
210
  return this.failureEnvelope(command, aiError, previousState);
119
- if (failure) {
120
- const envelope = await this.heal(command, failure.code || instructions.join('; '), failure.message, previousState);
121
- envelope.used = [...used, ...(envelope.used || [])];
211
+ if (used.length && ledger.some((entry) => entry.status === 'open')) {
212
+ await this.settleLedger(conversation, provider, ledger, trace);
213
+ }
214
+ const unfinished = ledger.filter((entry) => entry.status !== 'done');
215
+ const steps = [...trace, ...ledger.filter((entry) => entry.status === 'open').map((entry) => ({ label: `unreported: ${entry.text}`, ok: false, proof: UNACCOUNTED.open }))];
216
+ if (failure && unfinished.length) {
217
+ const envelope = await this.failureEnvelope(command, failure.message, previousState);
218
+ envelope.steps = steps;
219
+ envelope.stepFiles = this.statusDir();
122
220
  return envelope;
123
221
  }
124
- if (!used.length) {
222
+ if (!used.length && unfinished.length === ledger.length) {
125
223
  const reason = ['No action was performed for these instructions on the current page.', narration].filter(Boolean).join(' ');
126
224
  return this.failureEnvelope(command, reason, previousState);
127
225
  }
128
226
  const result = await this.capturedResult(this.bot.stateManager().getCurrentState());
129
- return this.successEnvelope(command, used, result, previousState);
227
+ const envelope = await this.successEnvelope(command, used, result, previousState);
228
+ envelope.steps = steps;
229
+ envelope.stepFiles = this.statusDir();
230
+ // the step log already reports every action and what it changed
231
+ envelope.used = undefined;
232
+ envelope.changes = undefined;
233
+ const unmet = unfinished.map((entry) => `${entry.status}: ${entry.text}${entry.proof ? ` — ${entry.proof}` : ''}`);
234
+ if (unmet.length) {
235
+ envelope.ok = false;
236
+ envelope.failure = { error: unmet.join('\n') };
237
+ }
238
+ return envelope;
130
239
  }
131
- async click(target) {
132
- return this.do([`click ${target}`]);
240
+ openInstructions(ledger) {
241
+ return ledger
242
+ .map((entry, index) => ({ entry, number: index + 1 }))
243
+ .filter(({ entry }) => entry.status === 'open')
244
+ .map(({ entry, number }) => `${number}. ${entry.text}`)
245
+ .join('\n');
133
246
  }
134
- async fill(field, value) {
135
- return this.do([`fill ${field} with value: ${value}`]);
247
+ applyLedgerReport(execution, ledger, trace) {
248
+ const action = execution.output?.action;
249
+ if (action === 'completed') {
250
+ const closed = [];
251
+ for (const number of execution.input?.numbers || []) {
252
+ const entry = ledger[number - 1];
253
+ if (entry?.status !== 'open')
254
+ continue;
255
+ entry.status = 'done';
256
+ entry.proof = execution.input?.proof || '';
257
+ closed.push(entry.text);
258
+ }
259
+ // one report carries one proof, however many instructions it closed
260
+ if (closed.length)
261
+ trace.push({ label: `done: ${closed.join('; ')}`, ok: true, proof: execution.input?.proof || '' });
262
+ return true;
263
+ }
264
+ if (action !== 'blocked')
265
+ return false;
266
+ const entry = ledger[(execution.input?.instruction || 0) - 1];
267
+ if (entry?.status === 'open') {
268
+ entry.status = 'blocked';
269
+ entry.proof = execution.input?.reason || '';
270
+ trace.push({ label: `blocked: ${entry.text}`, ok: false, proof: entry.proof });
271
+ }
272
+ return true;
273
+ }
274
+ async settleLedger(conversation, provider, ledger, trace) {
275
+ conversation.addUserText(dedent `
276
+ The run is over and these instructions were never reported:
277
+
278
+ ${this.openInstructions(ledger)}
279
+
280
+ Judge each one against what you saw at the time it was due, not against the page as it stands now — later
281
+ instructions have moved it on, and something you confirmed earlier stays confirmed even if it is gone.
282
+ completed() for those, blocked() for the ones the page could not do. Report every one — nothing else runs after this.
283
+ `);
284
+ const invoked = await provider.invokeConversation(conversation, { completed: this.completedTool(), blocked: this.blockedTool() }, { maxToolRoundtrips: 2, toolChoice: 'required', agentName: AI_AGENT_NAME }).catch(() => null);
285
+ for (const execution of invoked?.toolExecutions || []) {
286
+ this.applyLedgerReport(execution, ledger, trace);
287
+ }
288
+ }
289
+ ledgerProgress(ledger) {
290
+ return ledger
291
+ .map((entry, index) => {
292
+ const head = `${index + 1}. ${entry.status} — ${entry.text}`;
293
+ if (entry.status === 'open')
294
+ return head;
295
+ return `${head} (${entry.proof})`;
296
+ })
297
+ .join('\n');
298
+ }
299
+ async check(scenario, expected = []) {
300
+ const command = `check ${scenario}`;
301
+ const guard = await this.aiGuard(command);
302
+ if (guard)
303
+ return guard;
304
+ const previousState = await this.baselineState();
305
+ const outcomes = expected.length ? expected : [scenario];
306
+ const test = new Test(scenario, 'normal', outcomes, previousState?.url || this.options.url || '');
307
+ const tester = this.bot.agentTester();
308
+ const outcome = await tester.test(test);
309
+ const notes = Object.values(test.notes || {});
310
+ const result = await this.capturedResult(this.bot.stateManager().getCurrentState());
311
+ const envelope = await this.reportEnvelope(command, result, previousState, { ok: outcome.success });
312
+ const recorded = notes.filter((note) => !note.observation && !outcomes.includes(note.message));
313
+ const failed = recorded.filter((note) => note.status === TestResult.FAILED);
314
+ envelope.steps = failed.map((note) => ({ label: note.message, ok: false, proof: note.log || '' }));
315
+ const routine = recorded.length - failed.length;
316
+ if (routine)
317
+ envelope.steps.push({ label: `${routine} further ${pluralize(routine, 'step')} ran without failing — prima status ${envelope.status} for the full log`, ok: true, proof: '' });
318
+ envelope.expectations = await this.bot.agentPilot().settleExpectations(test);
319
+ const observations = notes.filter((note) => note.observation).map((note) => note.message);
320
+ if (observations.length)
321
+ envelope.answer = ['Page problems noticed while running, not step failures:', ...observations.map((line) => `- ${line}`)].join('\n');
322
+ return envelope;
136
323
  }
137
324
  async ask(question) {
138
325
  const command = `ask ${question}`;
@@ -151,9 +338,7 @@ export class Prima {
151
338
  const previousState = this.bot.stateManager().getCurrentState();
152
339
  const result = await this.capturedResult(previousState);
153
340
  const verification = await this.bot.agentNavigator().verifyState(assertion, result);
154
- const codes = verification.successfulCodes || [];
155
- const verdict = { passed: verification.verified, evidence: this.verdictEvidence(verification.verified, codes), code: codes.join('\n') };
156
- return this.reportEnvelope(command, result, previousState, { ok: verification.verified, verdict });
341
+ return this.reportEnvelope(command, result, previousState, { assertions: verification.results || [] });
157
342
  }
158
343
  async research(opts = {}) {
159
344
  const flags = [opts.data && '--data', opts.deep && '--deep', opts.fresh && '--fresh'].filter(Boolean);
@@ -164,7 +349,7 @@ export class Prima {
164
349
  const previousState = this.bot.stateManager().getCurrentState();
165
350
  const result = await this.capturedResult(previousState);
166
351
  const uiMap = await this.bot.agentResearcher().research(result, { screenshot: true, data: opts.data, deep: opts.deep, force: opts.fresh });
167
- return this.reportEnvelope(command, result, previousState, { research: uiMap });
352
+ return this.reportEnvelope(command, result, previousState, { research: dropVolatileColumns(uiMap) });
168
353
  }
169
354
  async go(target) {
170
355
  const command = `go ${target}`;
@@ -184,7 +369,7 @@ export class Prima {
184
369
  navigationError = error;
185
370
  }
186
371
  if (navigationError)
187
- return this.heal(command, code, navigationError, previousState);
372
+ return this.failureEnvelope(command, navigationError, previousState);
188
373
  const used = [];
189
374
  if (isUrl)
190
375
  used.push(code);
@@ -213,6 +398,67 @@ export class Prima {
213
398
  }
214
399
  return stopped;
215
400
  }
401
+ async config() {
402
+ const [site] = listSites();
403
+ if (site && !this.configBaseUrl())
404
+ this.sessionUrl = site.url;
405
+ const config = await this.loadConfig();
406
+ const named = (model) => {
407
+ if (typeof model === 'string')
408
+ return model;
409
+ return model?.modelId || model?.model || 'unknown';
410
+ };
411
+ const ai = config.ai || {};
412
+ const roles = [
413
+ ['model', ai.model],
414
+ ['agenticModel', ai.agenticModel],
415
+ ['visionModel', ai.visionModel],
416
+ ];
417
+ const lines = roles.filter(([, model]) => model).map(([role, model]) => `${role.padEnd(14)} ${named(model)}`);
418
+ lines.push(`config ${ConfigParser.getInstance().getConfigPath() || 'built-in defaults'}`);
419
+ if (ai.langfuse?.enabled)
420
+ lines.push('telemetry langfuse');
421
+ return lines.join('\n');
422
+ }
423
+ record(envelope, durationMs) {
424
+ if (!this.session)
425
+ return;
426
+ recordCommand(sessionFile(this.session.key), this.session, envelope, durationMs);
427
+ }
428
+ async report() {
429
+ const [site] = listSites();
430
+ if (site && !this.configBaseUrl())
431
+ this.sessionUrl = site.url;
432
+ await this.loadConfig();
433
+ let file = latestSessionFile();
434
+ if (this.options.pwSession)
435
+ file = sessionFile(this.options.pwSession);
436
+ if (!file || !existsSync(file))
437
+ return `No prima session was recorded under ${sessionsDir()}. Commands are recorded as they run.`;
438
+ const session = readSession(file);
439
+ if (!session.tests.length)
440
+ return `No commands are recorded in ${file}`;
441
+ Stats.sessionName = path.basename(file, '.jsonl');
442
+ process.env.TESTOMATIO_TITLE = session.title;
443
+ const reporter = new Reporter({ html: true, markdown: true });
444
+ // the report pipes narrate themselves on console.log; prima prints the paths itself
445
+ const speak = console.log;
446
+ console.log = () => { };
447
+ try {
448
+ for (const test of session.tests)
449
+ await reporter.reportTestData(test.status, test);
450
+ await reporter.finishRun();
451
+ }
452
+ finally {
453
+ console.log = speak;
454
+ }
455
+ return [
456
+ `${session.tests.length} ${pluralize(session.tests.length, 'command')} from ${file}`,
457
+ `html: ${outputPath('reports', `${Stats.sessionLabel()}.html`)}`,
458
+ `markdown: ${outputPath('reports', `${Stats.sessionLabel()}-tests.md`)}`,
459
+ `upload: TESTOMATIO=<apiKey> npx @testomatio/reporter replay ${file}`,
460
+ ].join('\n');
461
+ }
216
462
  async browserStatus() {
217
463
  await this.loadConfig();
218
464
  const info = await this.instanceInfo();
@@ -256,7 +502,7 @@ export class Prima {
256
502
  async toolFailureEnvelope(command, error) {
257
503
  const state = this.bot.getCurrentState();
258
504
  const instance = await this.instanceInfo().catch(() => ({ name: this.instanceName(), tabs: 0, others: [] }));
259
- const failure = { error: `tool: ${browserErrorMessage(error)}`, attempts: [] };
505
+ const failure = { error: `tool: ${browserErrorMessage(error)}` };
260
506
  if (error instanceof ConfigMissingError)
261
507
  failure.error = browserErrorMessage(error);
262
508
  if (state?.ariaSnapshot)
@@ -273,32 +519,42 @@ export class Prima {
273
519
  return ConfigParser.getInstance().loadConfig({ config: this.options.config, path: this.options.path, baseUrl: this.configBaseUrl() });
274
520
  }
275
521
  configBaseUrl() {
276
- const url = this.options.baseUrl || this.options.url;
522
+ const url = this.options.baseUrl || this.options.url || this.sessionUrl;
277
523
  if (!url)
278
524
  return undefined;
279
525
  if (!URL.canParse(url))
280
526
  return undefined;
281
527
  return url;
282
528
  }
283
- async resolveBrowser(config) {
529
+ adoptSessionUrl(discovery) {
530
+ if (this.options.baseUrl || this.options.url)
531
+ return;
532
+ const url = discovery.browser?.contexts()[0]?.pages()[0]?.url();
533
+ if (!url?.startsWith('http'))
534
+ return;
535
+ this.sessionUrl = new URL(url).origin;
536
+ this.bot.getOptions().baseUrl = this.sessionUrl;
537
+ }
538
+ async resolveBrowser(config, discovered) {
284
539
  if (this.options.endpoint) {
285
540
  const endpoint = this.options.endpoint;
286
541
  const browserName = config.playwright.browser || 'chromium';
287
- if (await this.attachToEndpoint({ file: '', title: '', endpoint, workspaceDir: '', browserName, playwrightLib: '' }))
542
+ const known = readDescriptors().find((descriptor) => descriptor.endpoint === endpoint);
543
+ if (await this.attachToEndpoint({ file: '', title: '', endpoint, workspaceDir: '', browserName, playwrightLib: known?.playwrightLib || '' }))
288
544
  return;
289
545
  throw new Error(dedent `
290
546
  No browser answered at ${endpoint}.
291
- Check the endpoint of the running session, or drop --endpoint to attach to the
292
- playwright-cli browser of this workspace.
547
+ Check the endpoint of the running session, or drop --endpoint to let prima pick
548
+ the playwright-cli session itself.
293
549
  `);
294
550
  }
295
- const { match, candidates, browser } = await this.discover();
551
+ const { match, candidates, browser } = discovered || (await this.discover());
296
552
  if (match && (await this.attachToEndpoint(match, browser)))
297
553
  return;
298
554
  if (!match && candidates.length) {
299
555
  const titles = candidates.map((candidate) => candidate.title).join(', ');
300
556
  throw new Error(dedent `
301
- Several playwright-cli sessions are open for this workspace: ${titles}
557
+ Several playwright-cli sessions are open: ${titles}
302
558
  Pick one with --pw-session <title>.
303
559
  `);
304
560
  }
@@ -314,7 +570,7 @@ export class Prima {
314
570
  }
315
571
  async discover(descriptors = readDescriptors()) {
316
572
  const title = this.options.pwSession ?? process.env.PLAYWRIGHT_CLI_SESSION;
317
- const opts = { workspaceDir: this.workspaceDir(), title };
573
+ const opts = { title };
318
574
  const alive = new Map();
319
575
  for (const candidate of selectDescriptor(descriptors, opts).candidates) {
320
576
  const browser = await this.connectDescriptor(candidate);
@@ -338,13 +594,14 @@ export class Prima {
338
594
  return false;
339
595
  this.bot.attachBrowser(browser);
340
596
  this.attached = this.attachmentLabel(descriptor);
597
+ this.session = { key: descriptor.title || this.instanceName(), endpoint: descriptor.endpoint, title: `prima session "${descriptor.title || this.instanceName()}"` };
341
598
  return true;
342
599
  }
343
600
  async connectDescriptor(descriptor) {
344
- const connected = await this.connectWith(playwright, descriptor);
601
+ const connected = await this.connectWith(this.descriptorLib(descriptor), descriptor);
345
602
  if (connected)
346
603
  return connected;
347
- return this.connectWith(this.descriptorLib(descriptor), descriptor);
604
+ return this.connectWith(playwright, descriptor);
348
605
  }
349
606
  async connectWith(lib, descriptor) {
350
607
  const launcher = lib?.[descriptor.browserName];
@@ -365,13 +622,16 @@ export class Prima {
365
622
  attachmentLabel(descriptor) {
366
623
  if (!descriptor.title)
367
624
  return `endpoint ${descriptor.endpoint}`;
625
+ if (!descriptor.workspaceDir)
626
+ return `playwright-cli session "${descriptor.title}"`;
368
627
  return `playwright-cli session "${descriptor.title}", workspace ${descriptor.workspaceDir}`;
369
628
  }
370
- workspaceDir() {
371
- return path.resolve(this.options.path || process.cwd());
372
- }
373
629
  async connectOwnInstance() {
374
- return !!(await getAliveEndpoint(this.instanceName()));
630
+ const endpoint = await getAliveEndpoint(this.instanceName());
631
+ if (!endpoint)
632
+ return false;
633
+ this.session = { key: this.instanceName(), endpoint, title: `prima instance "${this.instanceName()}"` };
634
+ return true;
375
635
  }
376
636
  async launchOwnServer(opts, instance) {
377
637
  return launchServer(opts, instance);
@@ -390,45 +650,6 @@ export class Prima {
390
650
  return true;
391
651
  return URL.canParse(value);
392
652
  }
393
- async heal(command, expression, error, previousState) {
394
- if (this.options.heal === false)
395
- return this.failureEnvelope(command, error, previousState);
396
- const navigator = this.healNavigator();
397
- if (!navigator) {
398
- const envelope = await this.failureEnvelope(command, error, previousState);
399
- envelope.healed = false;
400
- envelope.healNote = this.aiUnavailableNote();
401
- return envelope;
402
- }
403
- const message = dedent `
404
- I tried to run this command on the page: ${expression}
405
- But it failed with: ${browserErrorMessage(error)}
406
- Reach the same outcome on the current page in a different way.
407
- `;
408
- const attempts = [];
409
- const failedResult = await this.capturedResult(previousState);
410
- const resolved = await navigator.resolveState(message, failedResult, { onAttempt: (attempt) => attempts.push(attempt) }).catch(() => false);
411
- if (!resolved) {
412
- const healAttempts = attempts.map((attempt) => ({ code: attempt.code, outcome: attempt.error || 'ok' }));
413
- return this.failureEnvelope(command, error, previousState, healAttempts);
414
- }
415
- const used = attempts.filter((attempt) => !attempt.error).map((attempt) => attempt.code);
416
- const result = await this.capturedResult(this.bot.stateManager().getCurrentState());
417
- const envelope = await this.successEnvelope(command, used, result, previousState);
418
- envelope.healed = true;
419
- envelope.healNote = `recovered after ${attempts.length} ${pluralize(attempts.length, 'attempt')}`;
420
- return envelope;
421
- }
422
- healNavigator() {
423
- if (this.aiUnavailable())
424
- return null;
425
- try {
426
- return this.bot.agentNavigator?.() ?? null;
427
- }
428
- catch {
429
- return null;
430
- }
431
- }
432
653
  aiUnavailable() {
433
654
  try {
434
655
  if (this.bot.getProvider?.())
@@ -439,12 +660,6 @@ export class Prima {
439
660
  }
440
661
  return this.bot.aiFailureReason?.() || 'no AI model is configured';
441
662
  }
442
- aiUnavailableNote() {
443
- const reason = this.aiUnavailable();
444
- if (!reason)
445
- return 'ai unavailable';
446
- return `ai unavailable: ${reason}`;
447
- }
448
663
  async aiGuard(command) {
449
664
  const reason = this.aiUnavailable();
450
665
  if (!reason)
@@ -459,45 +674,184 @@ export class Prima {
459
674
  </role>
460
675
 
461
676
  <approach>
462
- 1. Read the page context and perform the instructions in the order they are listed.
677
+ 1. Read the page context and carry out the instructions in the order they are listed.
463
678
  2. Interact with the page only through the provided tools.
464
679
  3. Pick the smallest interaction that fulfills an instruction, then move to the next one.
465
680
  4. After the page changes, work from the updated context you are given, not from the earlier one.
466
- 5. Stop calling tools when every instruction is done, or when an instruction cannot be performed on this page say what is missing instead.
681
+ 5. Account for every instruction: completed() as soon as one is satisfied, blocked() when the page cannot do what it asks.
467
682
  </approach>
468
683
 
684
+ <ledger>
685
+ Instructions are numbered and those numbers never change. Report by number.
686
+ Saying in your reply that something is done does not report it — only completed() does. Nothing you write is read as a report.
687
+ Report an instruction the moment the page shows it is satisfied, before moving on. Waiting until later is how work gets repeated.
688
+ An instruction you have reported is finished. Never act on it again, and never report it twice.
689
+ After each turn you are shown every instruction with its state. Act only on the ones still open — repeating an action that already
690
+ landed can undo it, since a control that opened something will close it again.
691
+ Reaching for blocked() after a couple of honest attempts costs less than a third attempt that fails the same way.
692
+ </ledger>
693
+
694
+ <scope>
695
+ 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.
696
+ Continuing past the last instruction is a failure, even when the next step seems obvious.
697
+ 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.
698
+ </scope>
699
+
700
+ <pace>
701
+ 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.
702
+ Only stop to look again when what you find changes what you would do next.
703
+ 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.
704
+ </pace>
705
+
706
+ <proof>
707
+ 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.
708
+ That part is what completed() takes as its proof. Do not restate the action as if it were the outcome.
709
+ 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.
710
+ </proof>
711
+
712
+ <targets>
713
+ The page context lists every element with a ref, like [ref=e14]. To click one, pass that ref to clickRef — a ref names one
714
+ exact element, so it cannot match several by mistake and costs nothing to resolve. This is the cheapest way to act.
715
+ Use click() with a role and name for anything clickRef cannot take, and narrow with the container it sits in when a name
716
+ appears more than once, rather than guessing at an id or a class.
717
+ Refs belong to the context you were given. Use the ones in your newest context, never one you invented or remembered from
718
+ an older page. When the element an instruction needs is missing from that context, call context() and act on what it returns.
719
+ </targets>
720
+
469
721
  ${locatorRule}
470
722
 
471
723
  ${actionRule}
724
+
725
+ <targets_first>
726
+ Everything above about composing locators applies to click() and the other locator tools. It does not apply when the
727
+ element carries a ref: pass that ref to clickRef instead and compose nothing. Reach for a locator only for elements
728
+ that have no ref, or when a ref has stopped resolving.
729
+ </targets_first>
472
730
  `;
473
731
  }
474
- instructionPrompt(instructions, result) {
732
+ async instructionPrompt(instructions, result) {
475
733
  const list = instructions.map((instruction, index) => `${index + 1}. ${instruction}`).join('\n');
476
734
  return dedent `
477
735
  <instructions>
478
736
  ${list}
479
737
  </instructions>
480
738
 
481
- ${this.pageContext(result)}
739
+ ${await this.pageContext(result)}
482
740
  `;
483
741
  }
484
- pageContext(result) {
742
+ testerTools(deps) {
743
+ const researcher = this.bot.agentResearcher?.();
744
+ const navigator = this.bot.agentNavigator?.();
745
+ if (!researcher || !navigator)
746
+ return {};
747
+ const tools = createAgentTools({ ...deps, researcher, navigator, withExperience: false });
748
+ for (const name of TESTER_ONLY_TOOLS)
749
+ delete tools[name];
750
+ return tools;
751
+ }
752
+ completedTool() {
753
+ return tool({
754
+ description: dedent `
755
+ Report the instructions you have just satisfied, by their number. Report several together when one turn satisfied several.
756
+ A reported instruction is finished — you will not be asked for it again and must not act on it again.
757
+ `,
758
+ inputSchema: z.object({
759
+ numbers: z.array(z.number()).describe('Numbers of the instructions now satisfied, as they are numbered in the instruction list'),
760
+ proof: z.string().describe('What on the page shows they are satisfied'),
761
+ }),
762
+ execute: async () => ({ success: true, action: 'completed' }),
763
+ });
764
+ }
765
+ blockedTool() {
766
+ return tool({
767
+ description: dedent `
768
+ Report one instruction that cannot be carried out on this page, by its number. Reach for this instead of trying the same thing again.
769
+ The rest of the sequence continues without it.
770
+ `,
771
+ inputSchema: z.object({
772
+ instruction: z.number().describe('Number of the instruction that cannot be carried out'),
773
+ reason: z.string().describe('What stopped it — what you looked for and what the page showed instead'),
774
+ }),
775
+ execute: async () => ({ success: true, action: 'blocked' }),
776
+ });
777
+ }
778
+ contextTool(descent) {
779
+ let refreshed = false;
780
+ return tool({
781
+ description: dedent `
782
+ 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.
783
+ The first call returns the page as it is now, with fresh refs that replace every ref you were holding.
784
+ A later call on the same page drops to the raw markup, for elements the accessibility tree does not describe.
785
+ Do not call it to confirm an action worked — the change is already reported back to you.
786
+ `,
787
+ inputSchema: z.object({
788
+ reason: z.string().describe('Which element you cannot reach and what you already tried'),
789
+ }),
790
+ execute: async () => {
791
+ const result = await this.capturedResult(this.bot.stateManager().getCurrentState());
792
+ if (!refreshed) {
793
+ refreshed = true;
794
+ return { success: true, context: await this.pageContext(result) };
795
+ }
796
+ descent.markup = true;
797
+ return { success: true, context: cap(await result.simplifiedHtml(), CONTEXT_HTML_CAP) };
798
+ },
799
+ });
800
+ }
801
+ async pageContext(result) {
485
802
  const experience = this.bot.experienceTracker?.()?.renderExperienceTocFor?.(result) || '';
803
+ const map = this.researchMap(result);
804
+ if (map) {
805
+ return dedent `
806
+ <page_ui_map url="${result.url}" title="${result.title}">
807
+ ${map}
808
+ </page_ui_map>
809
+
810
+ ${experience}
811
+ `;
812
+ }
486
813
  return dedent `
487
814
  <page url="${result.url}" title="${result.title}">
488
- ${compactAriaSnapshot(result.ariaSnapshot, true)}
815
+ ${compactAriaSnapshot(await this.refAriaSnapshot(result), true, (value) => this.offloadValue(value))}
489
816
  </page>
490
817
 
491
818
  ${experience}
492
819
  `;
493
820
  }
821
+ researchMap(result) {
822
+ if (this.bot.stateManager().getVisitCount(result.url) < this.researchAfterVisits())
823
+ return '';
824
+ return getPreviousResearch(result.getStateHash());
825
+ }
826
+ researchAfterVisits() {
827
+ const configured = this.bot.getConfig?.()?.ai?.agents?.prima?.researchAfterVisits;
828
+ if (typeof configured === 'number')
829
+ return configured;
830
+ return DEFAULT_RESEARCH_AFTER_VISITS;
831
+ }
832
+ offloadValue(value) {
833
+ const dir = this.statusDir();
834
+ const name = `value-${createHash('sha1').update(value).digest('hex').slice(0, 8)}.txt`;
835
+ try {
836
+ mkdirSync(dir, { recursive: true });
837
+ writeFileSync(path.join(dir, name), value, 'utf-8');
838
+ }
839
+ catch {
840
+ return undefined;
841
+ }
842
+ return path.join(path.basename(dir), name);
843
+ }
844
+ async refAriaSnapshot(result) {
845
+ const snapshot = await Promise.resolve(this.bot.getExplorer()?.withPage?.((page) => page.locator('body').ariaSnapshot({ mode: 'ai' }))).catch(() => null);
846
+ return snapshot || result.ariaSnapshot;
847
+ }
494
848
  executedCodes(code) {
495
849
  if (typeof code !== 'string')
496
850
  return [];
497
851
  return code
498
852
  .split('\n')
499
853
  .map((line) => line.trim())
500
- .filter((line) => line);
854
+ .filter((line) => line && !line.startsWith('//'));
501
855
  }
502
856
  visionEnabled() {
503
857
  if (this.options.noVision)
@@ -542,13 +896,6 @@ export class Prima {
542
896
  const response = await provider.chat([{ role: 'user', content: prompt }], provider.getModelForAgent?.(AI_AGENT_NAME), { agentName: AI_AGENT_NAME });
543
897
  return response?.text || '';
544
898
  }
545
- verdictEvidence(verified, codes) {
546
- if (!verified)
547
- return 'no assertion held on the current page';
548
- if (!codes.length)
549
- return 'already verified on this page';
550
- return `${codes[0]} passed`;
551
- }
552
899
  async successEnvelope(command, used, result, previousState) {
553
900
  return {
554
901
  ok: true,
@@ -557,23 +904,21 @@ export class Prima {
557
904
  page: this.pageBlock(result, previousState),
558
905
  changes: await this.pageChanges(result, previousState, used[0]),
559
906
  instance: await this.instanceInfo(),
560
- artifacts: await this.writeSnapshot(result),
907
+ status: await this.saveStatus(result),
561
908
  };
562
909
  }
563
- async failureEnvelope(command, error, previousState, attempts = []) {
910
+ async failureEnvelope(command, error, previousState) {
564
911
  const result = await this.capturedResult(previousState);
565
- const failure = { error: browserErrorMessage(error), attempts };
912
+ const failure = { error: browserErrorMessage(error) };
566
913
  if (result.ariaSnapshot)
567
914
  failure.compactAria = compactAriaSnapshot(result.ariaSnapshot, true);
568
- if (attempts.length)
569
- failure.reasoning = [...new Set(attempts.map((attempt) => attempt.outcome))].join('; ');
570
915
  return {
571
916
  ok: false,
572
917
  command,
573
918
  page: this.pageBlock(result, previousState),
574
919
  failure,
575
920
  instance: await this.instanceInfo(),
576
- artifacts: await this.writeSnapshot(result),
921
+ status: await this.saveStatus(result),
577
922
  };
578
923
  }
579
924
  async reportEnvelope(command, result, previousState, outcome) {
@@ -583,7 +928,7 @@ export class Prima {
583
928
  page: this.pageBlock(result, previousState),
584
929
  ...outcome,
585
930
  instance: await this.instanceInfo(),
586
- artifacts: await this.writeSnapshot(result),
931
+ status: await this.saveStatus(result),
587
932
  };
588
933
  }
589
934
  async capturedResult(previousState, opts = {}) {
@@ -606,22 +951,73 @@ export class Prima {
606
951
  visits: this.bot.stateManager().getVisitCount(result.url),
607
952
  };
608
953
  }
954
+ async baselineState() {
955
+ const existing = this.bot.stateManager?.()?.getCurrentState();
956
+ if (existing)
957
+ return existing;
958
+ const result = await Promise.resolve(this.bot.getExplorer?.()?.capture?.()).catch(() => null);
959
+ if (!result)
960
+ return null;
961
+ return this.bot.stateManager?.()?.updateState(result) ?? null;
962
+ }
609
963
  async pageChanges(result, previousState, code) {
610
964
  if (!previousState)
611
- return null;
965
+ return 'no snapshot was captured before this command, so nothing could be compared';
612
966
  const toolResult = await result.toToolResult(ActionResult.fromState(previousState), code);
613
- return toolResult.pageDiff?.ariaChanges ?? null;
967
+ return toolResult.pageDiff?.ariaChanges || 'no change';
968
+ }
969
+ async status(hash) {
970
+ const dir = this.statusDir(hash);
971
+ const statusFile = path.join(dir, 'status.json');
972
+ if (!existsSync(statusFile))
973
+ return this.toolFailureEnvelope(`status ${hash}`, `No command was recorded under ${hash}. Every envelope prints its own hash on the Instance line.`);
974
+ const saved = JSON.parse(readFileSync(statusFile, 'utf-8'));
975
+ return {
976
+ ok: true,
977
+ command: `status ${hash}`,
978
+ page: saved.page,
979
+ changes: saved.changes,
980
+ instance: await this.instanceInfo(),
981
+ artifacts: { aria: path.join(dir, 'aria.yml'), html: path.join(dir, 'page.html') },
982
+ };
983
+ }
984
+ async saveStatus(result) {
985
+ const hash = this.statusHash();
986
+ await this.writeSnapshot(result);
987
+ writeFileSync(path.join(this.statusDir(hash), 'status.json'), JSON.stringify({ page: this.pageBlock(result, null), changes: compactAriaSnapshot(result.ariaSnapshot, true) }), 'utf-8');
988
+ return hash;
989
+ }
990
+ async writeStepFiles(index, label, diff) {
991
+ const state = this.bot.stateManager().getCurrentState();
992
+ if (!state)
993
+ return;
994
+ const dir = this.statusDir();
995
+ mkdirSync(dir, { recursive: true });
996
+ const stem = path.join(dir, `${index}-${safeFilename(label.slice(0, 60))}`);
997
+ const result = ActionResult.fromState(state);
998
+ writeFileSync(`${stem}.aria.yaml`, result.ariaSnapshot ?? '', 'utf-8');
999
+ writeFileSync(`${stem}.html`, await result.combinedHtml(), 'utf-8');
1000
+ if (diff)
1001
+ writeFileSync(`${stem}.diff.yaml`, diff, 'utf-8');
614
1002
  }
615
1003
  async writeSnapshot(result) {
616
- return writeArtifacts(this.nextArtifactDir(), {
1004
+ writeArtifacts(this.statusDir(), {
617
1005
  aria: result.ariaSnapshot,
618
1006
  html: await result.combinedHtml(),
619
1007
  requests: this.bot.requestStore().getRequests(),
620
1008
  });
1009
+ return undefined;
1010
+ }
1011
+ statusHash() {
1012
+ this.hash ||= createHash('sha1')
1013
+ .update(`${this.options.path || process.cwd()}-${Date.now()}`)
1014
+ .digest('hex')
1015
+ .slice(0, 15);
1016
+ return this.hash;
621
1017
  }
622
- nextArtifactDir() {
1018
+ statusDir(hash = this.statusHash()) {
623
1019
  this.artifactsDir ||= outputPath('prima');
624
- return path.join(this.artifactsDir, new Date().toISOString().replace(/[:.]/g, '-'));
1020
+ return path.join(this.artifactsDir, hash);
625
1021
  }
626
1022
  tabCount() {
627
1023
  const page = this.bot.getExplorer()?.page;