explorbot 0.2.2 → 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 (214) hide show
  1. package/README.md +1 -1
  2. package/bin/explorbot-cli.ts +59 -38
  3. package/boat/api-tester/src/apibot.ts +4 -2
  4. package/boat/api-tester/src/cli.ts +2 -2
  5. package/boat/api-tester/src/config.ts +43 -10
  6. package/boat/doc-collector/bin/doc-collector-cli.ts +2 -0
  7. package/boat/doc-collector/src/ai/documentarian.ts +61 -31
  8. package/boat/doc-collector/src/cli.ts +1 -0
  9. package/boat/doc-collector/src/config.ts +4 -2
  10. package/boat/doc-collector/src/docs-renderer.ts +18 -4
  11. package/boat/doc-collector/src/state-diagram.ts +61 -14
  12. package/boat/prima/bin/prima-cli.ts +5 -0
  13. package/boat/prima/package.json +16 -0
  14. package/boat/prima/src/activity-line.ts +33 -0
  15. package/boat/prima/src/cli.ts +251 -0
  16. package/boat/prima/src/envelope.ts +169 -0
  17. package/boat/prima/src/prima.ts +1124 -0
  18. package/boat/prima/src/pw-parser.ts +27 -0
  19. package/boat/prima/src/pw-registry.ts +74 -0
  20. package/boat/prima/src/session-log.ts +126 -0
  21. package/dist/bin/explorbot-cli.js +51 -32
  22. package/dist/boat/api-tester/bin/apibot-cli.js +2 -0
  23. package/dist/boat/api-tester/src/apibot.js +3 -2
  24. package/dist/boat/api-tester/src/cli.js +2 -2
  25. package/dist/boat/api-tester/src/config.js +40 -10
  26. package/dist/boat/doc-collector/bin/doc-collector-cli.js +2 -0
  27. package/dist/boat/doc-collector/src/ai/documentarian.js +44 -19
  28. package/dist/boat/doc-collector/src/cli.js +1 -0
  29. package/dist/boat/doc-collector/src/config.js +4 -2
  30. package/dist/boat/doc-collector/src/docs-renderer.js +17 -3
  31. package/dist/boat/doc-collector/src/state-diagram.js +57 -13
  32. package/dist/boat/prima/bin/prima-cli.js +4 -0
  33. package/dist/boat/prima/src/activity-line.js +30 -0
  34. package/dist/boat/prima/src/cli.js +220 -0
  35. package/dist/boat/prima/src/envelope.js +142 -0
  36. package/dist/boat/prima/src/prima.js +1031 -0
  37. package/dist/boat/prima/src/pw-parser.js +30 -0
  38. package/dist/boat/prima/src/pw-registry.js +65 -0
  39. package/dist/boat/prima/src/session-log.js +108 -0
  40. package/dist/models.json +3 -0
  41. package/dist/package.json +8 -3
  42. package/dist/rules/navigator/verification-actions.md +20 -0
  43. package/dist/src/action-result.d.ts +7 -0
  44. package/dist/src/action-result.js +4 -0
  45. package/dist/src/action.d.ts +7 -2
  46. package/dist/src/action.js +41 -6
  47. package/dist/src/ai/captain/mixin.js +3 -4
  48. package/dist/src/ai/captain/web-mode.js +7 -4
  49. package/dist/src/ai/captain.js +2 -0
  50. package/dist/src/ai/navigator.d.ts +11 -0
  51. package/dist/src/ai/navigator.js +21 -12
  52. package/dist/src/ai/pilot.d.ts +4 -0
  53. package/dist/src/ai/pilot.js +48 -2
  54. package/dist/src/ai/planner.d.ts +1 -0
  55. package/dist/src/ai/planner.js +6 -0
  56. package/dist/src/ai/provider.js +2 -2
  57. package/dist/src/ai/rerunner.js +2 -1
  58. package/dist/src/ai/researcher.js +2 -2
  59. package/dist/src/ai/rules.js +17 -10
  60. package/dist/src/ai/task-agent.js +5 -2
  61. package/dist/src/ai/tester.d.ts +1 -0
  62. package/dist/src/ai/tester.js +33 -20
  63. package/dist/src/ai/tools.d.ts +14 -0
  64. package/dist/src/ai/tools.js +113 -36
  65. package/dist/src/application-spec-contract.d.ts +8 -0
  66. package/dist/src/application-spec-contract.js +8 -0
  67. package/dist/src/application-spec.d.ts +15 -0
  68. package/dist/src/application-spec.js +71 -0
  69. package/dist/src/browser-server.d.ts +12 -6
  70. package/dist/src/browser-server.js +74 -19
  71. package/dist/src/commands/clean-command.js +2 -7
  72. package/dist/src/commands/init-command.d.ts +5 -0
  73. package/dist/src/commands/init-command.js +119 -1
  74. package/dist/src/commands/navigate-command.js +1 -1
  75. package/dist/src/commands/research-command.js +1 -1
  76. package/dist/src/commands/sites-command.d.ts +6 -0
  77. package/dist/src/commands/sites-command.js +23 -0
  78. package/dist/src/components/InitWizard.d.ts +10 -0
  79. package/dist/src/components/InitWizard.js +133 -0
  80. package/dist/src/components/InputReadline.d.ts +1 -0
  81. package/dist/src/components/InputReadline.js +7 -4
  82. package/dist/src/config.d.ts +25 -5
  83. package/dist/src/config.js +158 -40
  84. package/dist/src/execution-controller.d.ts +2 -0
  85. package/dist/src/execution-controller.js +6 -0
  86. package/dist/src/explorbot.d.ts +11 -1
  87. package/dist/src/explorbot.js +27 -6
  88. package/dist/src/explorer.d.ts +4 -1
  89. package/dist/src/explorer.js +42 -9
  90. package/dist/src/global-config.d.ts +22 -0
  91. package/dist/src/global-config.js +117 -0
  92. package/dist/src/knowledge-tracker.d.ts +5 -1
  93. package/dist/src/knowledge-tracker.js +14 -1
  94. package/dist/src/playwright-recorder.js +30 -0
  95. package/dist/src/remote.d.ts +54 -0
  96. package/dist/src/remote.js +229 -0
  97. package/dist/src/reporter.d.ts +1 -0
  98. package/dist/src/reporter.js +7 -1
  99. package/dist/src/state-manager.d.ts +2 -1
  100. package/dist/src/stats.d.ts +1 -0
  101. package/dist/src/stats.js +1 -0
  102. package/dist/src/test-plan.d.ts +2 -0
  103. package/dist/src/test-plan.js +7 -0
  104. package/dist/src/utils/aria.d.ts +2 -8
  105. package/dist/src/utils/aria.js +69 -40
  106. package/dist/src/utils/cli-name.js +6 -2
  107. package/dist/src/utils/html.js +1 -0
  108. package/dist/src/utils/logger.d.ts +6 -0
  109. package/dist/src/utils/logger.js +24 -0
  110. package/dist/src/utils/page-readiness.js +18 -1
  111. package/dist/src/utils/test-files.js +1 -2
  112. package/dist/src/utils/url-matcher.d.ts +1 -0
  113. package/dist/src/utils/url-matcher.js +12 -0
  114. package/dist/src/utils/web-element.d.ts +2 -0
  115. package/dist/src/utils/web-element.js +8 -0
  116. package/dist/src/utils/web-sandbox.d.ts +1 -1
  117. package/dist/src/utils/web-sandbox.js +2 -3
  118. package/docs/api-testing/basics.md +90 -0
  119. package/docs/api-testing/planning.md +57 -0
  120. package/docs/api-testing/running-tests.md +55 -0
  121. package/docs/assets/cloud-report.png +0 -0
  122. package/docs/assets/html-report.png +0 -0
  123. package/docs/assets/langfuse-trace.png +0 -0
  124. package/docs/assets/successful-explore-run.png +0 -0
  125. package/docs/basics/getting-started.md +140 -0
  126. package/docs/basics/prerequisites.md +63 -0
  127. package/docs/basics/providers.md +362 -0
  128. package/docs/basics/running.md +78 -0
  129. package/docs/contributing/ai-integration-tests.md +57 -0
  130. package/docs/contributing/contributing.md +90 -0
  131. package/docs/contributing/demo-videos.md +36 -0
  132. package/docs/contributing/npm-package.md +138 -0
  133. package/docs/contributing/observability.md +227 -0
  134. package/docs/contributing/regression-tests.md +103 -0
  135. package/docs/contributing/testing.md +95 -0
  136. package/docs/doc-collection/basics.md +128 -0
  137. package/docs/doc-collection/crawling.md +67 -0
  138. package/docs/doc-collection/interactive-mode.md +99 -0
  139. package/docs/index.json +86 -0
  140. package/docs/reference/commands.md +994 -0
  141. package/docs/reference/configuration.md +569 -0
  142. package/docs/reference/scripting.md +303 -0
  143. package/docs/superpowers/plans/2026-08-01-actor-boat.md +925 -0
  144. package/docs/superpowers/plans/2026-08-01-prima-boat.md +1120 -0
  145. package/docs/superpowers/reviews/2026-08-06-prima-vs-playwright-cli.md +268 -0
  146. package/docs/superpowers/specs/2026-08-01-actor-boat-design.md +204 -0
  147. package/docs/superpowers/specs/2026-08-01-prima-boat-design.md +242 -0
  148. package/docs/superpowers/specs/2026-08-03-global-config-design.md +138 -0
  149. package/docs/superpowers/specs/2026-08-07-prima-fixes-design.md +394 -0
  150. package/docs/web-testing/agents.md +158 -0
  151. package/docs/web-testing/automated-tests.md +134 -0
  152. package/docs/web-testing/basics.md +91 -0
  153. package/docs/web-testing/customization.md +131 -0
  154. package/docs/web-testing/hooks.md +238 -0
  155. package/docs/web-testing/page-interaction.md +84 -0
  156. package/docs/web-testing/planner.md +122 -0
  157. package/docs/web-testing/rerun.md +164 -0
  158. package/docs/web-testing/researcher.md +380 -0
  159. package/docs/workflow/agentic-usage.md +233 -0
  160. package/docs/workflow/application-spec.md +73 -0
  161. package/docs/workflow/ci.md +202 -0
  162. package/docs/workflow/knowledge.md +310 -0
  163. package/docs/workflow/planning-styles.md +67 -0
  164. package/docs/workflow/reporting.md +133 -0
  165. package/docs/workflow/test-plans.md +90 -0
  166. package/models.json +3 -0
  167. package/package.json +8 -3
  168. package/rules/navigator/verification-actions.md +20 -0
  169. package/src/action-result.ts +11 -0
  170. package/src/action.ts +47 -7
  171. package/src/ai/captain/mixin.ts +3 -3
  172. package/src/ai/captain/web-mode.ts +7 -4
  173. package/src/ai/captain.ts +3 -0
  174. package/src/ai/navigator.ts +26 -15
  175. package/src/ai/pilot.ts +53 -2
  176. package/src/ai/planner.ts +7 -0
  177. package/src/ai/provider.ts +2 -4
  178. package/src/ai/rerunner.ts +2 -1
  179. package/src/ai/researcher.ts +2 -2
  180. package/src/ai/rules.ts +17 -10
  181. package/src/ai/task-agent.ts +4 -2
  182. package/src/ai/tester.ts +32 -18
  183. package/src/ai/tools.ts +132 -36
  184. package/src/application-spec-contract.ts +10 -0
  185. package/src/application-spec.ts +87 -0
  186. package/src/browser-server.ts +74 -19
  187. package/src/commands/clean-command.ts +1 -6
  188. package/src/commands/init-command.ts +146 -1
  189. package/src/commands/navigate-command.ts +1 -1
  190. package/src/commands/research-command.ts +1 -1
  191. package/src/commands/sites-command.ts +27 -0
  192. package/src/components/InitWizard.tsx +166 -0
  193. package/src/components/InputReadline.tsx +8 -4
  194. package/src/config.ts +177 -43
  195. package/src/execution-controller.ts +8 -0
  196. package/src/explorbot.ts +34 -7
  197. package/src/explorer.ts +47 -9
  198. package/src/global-config.ts +148 -0
  199. package/src/knowledge-tracker.ts +17 -1
  200. package/src/playwright-recorder.ts +23 -0
  201. package/src/remote.ts +238 -0
  202. package/src/reporter.ts +7 -1
  203. package/src/state-manager.ts +2 -1
  204. package/src/stats.ts +1 -0
  205. package/src/test-plan.ts +9 -0
  206. package/src/utils/aria.ts +65 -45
  207. package/src/utils/cli-name.ts +5 -2
  208. package/src/utils/html.ts +1 -0
  209. package/src/utils/logger.ts +24 -1
  210. package/src/utils/page-readiness.ts +24 -1
  211. package/src/utils/test-files.ts +1 -2
  212. package/src/utils/url-matcher.ts +13 -0
  213. package/src/utils/web-element.ts +9 -0
  214. package/src/utils/web-sandbox.ts +3 -4
@@ -0,0 +1,1124 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ import path from 'node:path';
5
+ import { tool } from 'ai';
6
+ import dedent from 'dedent';
7
+ import { z } from 'zod';
8
+ import * as playwright from 'playwright';
9
+ import type { Browser } from 'playwright';
10
+ import { ActionResult } from '../../../src/action-result.ts';
11
+ import { actionRule, locatorRule } from '../../../src/ai/rules.ts';
12
+ import { createAgentTools, createCodeceptJSTools } from '../../../src/ai/tools.ts';
13
+ import { getAliveEndpoint, launchServer, listInstances, stopServer } from '../../../src/browser-server.ts';
14
+ import { listSites } from '../../../src/global-config.ts';
15
+ import { ConfigMissingError, ConfigParser, type ExplorbotConfig, outputPath } from '../../../src/config.ts';
16
+ import { ExplorBot } from '../../../src/explorbot.ts';
17
+ import { Reporter } from '../../../src/reporter.ts';
18
+ import { Stats } from '../../../src/stats.ts';
19
+ import type { WebPageState } from '../../../src/state-manager.ts';
20
+ import { Task, Test, TestResult } from '../../../src/test-plan.ts';
21
+ import { getPreviousResearch } from '../../../src/ai/researcher/cache.ts';
22
+ import { compactAriaSnapshot } from '../../../src/utils/aria.ts';
23
+ import { mdq } from '../../../src/utils/markdown-query.ts';
24
+ import { browserErrorMessage } from '../../../src/utils/browser-errors.ts';
25
+ import { pluralize } from '../../../src/utils/logger.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';
30
+ import { type PwServerDescriptor, readDescriptors, selectDescriptor } from './pw-registry.ts';
31
+
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;
37
+ const MAX_TOOL_ROUNDTRIPS = 5;
38
+ const AI_AGENT_NAME = 'prima';
39
+ const CONNECT_TIMEOUT = 3000;
40
+ const requireLib = createRequire(import.meta.url);
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
+
67
+ export class Prima {
68
+ private options: PrimaOptions;
69
+ private bot: ExplorBot;
70
+ private artifactsDir?: string;
71
+ private hash?: string;
72
+ private sessionUrl?: string;
73
+ private server: { close: () => Promise<void> } | null = null;
74
+ private attached: string | null = null;
75
+ private session: SessionRun | null = null;
76
+
77
+ constructor(options: PrimaOptions = {}) {
78
+ this.options = options;
79
+ this.bot = new ExplorBot({
80
+ config: options.config,
81
+ path: options.path,
82
+ baseUrl: this.configBaseUrl(),
83
+ session: options.session,
84
+ instance: options.instance,
85
+ headless: true,
86
+ optionalAi: true,
87
+ reporter: { enabled: false },
88
+ });
89
+ }
90
+
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
+
98
+ const config = await this.loadConfig();
99
+ await this.resolveBrowser(config, discovery);
100
+ await this.bot.start();
101
+
102
+ if (!this.options.url) return;
103
+ if (this.bot.getCurrentState()) return;
104
+ await this.bot.visit(this.options.url);
105
+ }
106
+
107
+ async stop(): Promise<void> {
108
+ await this.bot.stop();
109
+ }
110
+
111
+ async pw(expression: string): Promise<EnvelopeData> {
112
+ const command = `pw ${expression}`;
113
+ const validation = isFunctionExpression(expression);
114
+ if (!validation.valid) return this.toolFailureEnvelope(command, validation.error!);
115
+
116
+ const previousState = await this.baselineState();
117
+ let result: ActionResult | null = null;
118
+ let returnedValue: unknown;
119
+ let executionError: unknown = null;
120
+
121
+ try {
122
+ const executed = await this.bot.getExplorer().action().execute(toCodeceptWrapper(expression), { verbatim: true });
123
+ result = executed.actionResult;
124
+ returnedValue = executed.lastValue;
125
+ } catch (error) {
126
+ executionError = error;
127
+ }
128
+
129
+ if (executionError) return this.failureEnvelope(command, executionError, previousState);
130
+
131
+ result ||= await this.capturedResult(previousState);
132
+ const envelope = await this.successEnvelope(command, [expression], result, previousState);
133
+ envelope.value = takePwValue(returnedValue);
134
+ return envelope;
135
+ }
136
+
137
+ async do(instructions: string[], label?: string): Promise<EnvelopeData> {
138
+ const command = label || `do ${instructions.map((instruction) => `"${instruction}"`).join(' ')}`;
139
+ const guard = await this.aiGuard(command);
140
+ if (guard) return guard;
141
+
142
+ const provider = this.bot.getProvider();
143
+ const previousState = await this.baselineState();
144
+ const conversation = provider.startConversation(this.instructionSystemPrompt(), AI_AGENT_NAME);
145
+ const task = new Task(instructions.join('; '), previousState?.url || '');
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)));
151
+
152
+ const used: string[] = [];
153
+ const trace: Array<{ label: string; ok: boolean; proof: string }> = [];
154
+ let failure: { code: string; message: string } | null = null;
155
+ let aiError: unknown = null;
156
+ let narration = '';
157
+ let nudged = false;
158
+ let contextHash = this.bot.stateManager().getCurrentState()?.hash;
159
+
160
+ for (let iteration = 1; iteration <= Math.min(instructions.length * ITERATIONS_PER_INSTRUCTION + 2, MAX_INSTRUCTION_ITERATIONS); iteration++) {
161
+ const state = this.bot.stateManager().getCurrentState();
162
+ if (iteration > 1 && state && state.hash !== contextHash) {
163
+ contextHash = state.hash;
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
+ `);
175
+ }
176
+
177
+ const invoked = await provider.invokeConversation(conversation, tools, { maxToolRoundtrips: MAX_TOOL_ROUNDTRIPS, agentName: AI_AGENT_NAME }).catch((error: unknown) => {
178
+ aiError = error;
179
+ return null;
180
+ });
181
+ if (!invoked) break;
182
+
183
+ const executions = invoked.toolExecutions || [];
184
+ if (!executions.length) {
185
+ narration = invoked.response?.text?.trim() || '';
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;
196
+ }
197
+
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
+
211
+ if (!execution.wasSuccessful) {
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', '');
215
+ continue;
216
+ }
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 || '');
222
+ failure = null;
223
+ }
224
+
225
+ if (ledger.every((entry) => entry.status !== 'open')) break;
226
+ }
227
+
228
+ if (aiError) return this.failureEnvelope(command, aiError, previousState);
229
+
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();
241
+ return envelope;
242
+ }
243
+
244
+ if (!used.length && unfinished.length === ledger.length) {
245
+ const reason = ['No action was performed for these instructions on the current page.', narration].filter(Boolean).join(' ');
246
+ return this.failureEnvelope(command, reason, previousState);
247
+ }
248
+
249
+ const result = await this.capturedResult(this.bot.stateManager().getCurrentState());
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;
263
+ }
264
+
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');
271
+ }
272
+
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;
356
+ }
357
+
358
+ async ask(question: string): Promise<EnvelopeData> {
359
+ const command = `ask ${question}`;
360
+ const guard = await this.aiGuard(command);
361
+ if (guard) return guard;
362
+
363
+ const previousState = this.bot.stateManager().getCurrentState();
364
+ const result = await this.capturedResult(previousState, { screenshot: this.visionEnabled() });
365
+ return this.reportEnvelope(command, result, previousState, { answer: await this.answer(question, result) });
366
+ }
367
+
368
+ async verify(assertion: string): Promise<EnvelopeData> {
369
+ const command = `verify ${assertion}`;
370
+ const guard = await this.aiGuard(command);
371
+ if (guard) return guard;
372
+
373
+ const previousState = this.bot.stateManager().getCurrentState();
374
+ const result = await this.capturedResult(previousState);
375
+ const verification = await this.bot.agentNavigator().verifyState(assertion, result);
376
+ return this.reportEnvelope(command, result, previousState, { assertions: verification.results || [] });
377
+ }
378
+
379
+ async research(opts: { data?: boolean; deep?: boolean; fresh?: boolean } = {}): Promise<EnvelopeData> {
380
+ const flags = [opts.data && '--data', opts.deep && '--deep', opts.fresh && '--fresh'].filter(Boolean);
381
+ const command = ['research', ...flags].join(' ');
382
+ const guard = await this.aiGuard(command);
383
+ if (guard) return guard;
384
+
385
+ const previousState = this.bot.stateManager().getCurrentState();
386
+ const result = await this.capturedResult(previousState);
387
+ const uiMap = await this.bot.agentResearcher().research(result, { screenshot: true, data: opts.data, deep: opts.deep, force: opts.fresh });
388
+ return this.reportEnvelope(command, result, previousState, { research: dropVolatileColumns(uiMap) });
389
+ }
390
+
391
+ async go(target: string): Promise<EnvelopeData> {
392
+ const command = `go ${target}`;
393
+ const code = `I.amOnPage('${target}')`;
394
+ const isUrl = this.isUrlTarget(target);
395
+
396
+ if (!isUrl) {
397
+ const guard = await this.aiGuard(command);
398
+ if (guard) return guard;
399
+ }
400
+
401
+ const previousState = this.bot.stateManager().getCurrentState();
402
+ let navigationError: unknown = null;
403
+
404
+ try {
405
+ await this.bot.agentNavigator().visit(target);
406
+ } catch (error) {
407
+ navigationError = error;
408
+ }
409
+
410
+ if (navigationError) return this.failureEnvelope(command, navigationError, previousState);
411
+
412
+ const used: string[] = [];
413
+ if (isUrl) used.push(code);
414
+ const result = await this.capturedResult(this.bot.stateManager().getCurrentState());
415
+ return this.successEnvelope(command, used, result, previousState);
416
+ }
417
+
418
+ async browserStart(): Promise<void> {
419
+ const config = await this.loadConfig();
420
+ let show = config.playwright.show || false;
421
+ if (this.options.show) show = true;
422
+ if (this.options.headless) show = false;
423
+ this.server = await this.launchOwnServer({ browser: config.playwright.browser, show }, this.instanceName());
424
+ }
425
+
426
+ async browserStop(all = false): Promise<boolean> {
427
+ await this.loadConfig();
428
+ if (this.attached) return false;
429
+
430
+ if (!all) return this.stopInstance(this.instanceName());
431
+
432
+ let stopped = false;
433
+ for (const instance of listInstances()) {
434
+ if (await this.stopInstance(instance.name)) stopped = true;
435
+ }
436
+ return stopped;
437
+ }
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
+
501
+ async browserStatus(): Promise<string> {
502
+ await this.loadConfig();
503
+ const info = await this.instanceInfo();
504
+ const endpoint = await getAliveEndpoint(info.name);
505
+ const others = info.others.map((other) => other.name).join(', ') || 'none';
506
+ const lines = [`instance: ${info.name} (${info.tabs} ${pluralize(info.tabs, 'tab')}) | other instances: ${others}`];
507
+ if (!endpoint) lines.push('browser: not running');
508
+ if (endpoint) lines.push(`browser: running at ${endpoint}`);
509
+ return lines.join('\n');
510
+ }
511
+
512
+ async browserList(): Promise<string> {
513
+ await this.loadConfig();
514
+
515
+ const lines: string[] = [];
516
+ for (const instance of listInstances()) {
517
+ const endpoint = await getAliveEndpoint(instance.name);
518
+ if (!endpoint) continue;
519
+ lines.push(`prima --instance ${instance.name} ${endpoint}`);
520
+ }
521
+
522
+ const discovery = await this.discover();
523
+ await discovery.browser?.close().catch(() => {});
524
+ for (const descriptor of discovery.candidates) {
525
+ lines.push(`playwright-cli --pw-session ${descriptor.title} ${descriptor.endpoint}`);
526
+ }
527
+
528
+ if (!lines.length) return 'no browser instances running';
529
+ return lines.join('\n');
530
+ }
531
+
532
+ async instanceInfo(): Promise<InstanceInfo> {
533
+ const name = this.instanceName();
534
+ const others = listInstances()
535
+ .filter((instance) => instance.name !== name)
536
+ .map((instance) => ({ name: instance.name, tabs: 0 }));
537
+
538
+ const info: InstanceInfo = { name, tabs: this.tabCount(), others };
539
+ if (this.attached) info.attached = this.attached;
540
+ return info;
541
+ }
542
+
543
+ async toolFailureEnvelope(command: string, error: unknown): Promise<EnvelopeData> {
544
+ const state = this.bot.getCurrentState();
545
+ const instance = await this.instanceInfo().catch(() => ({ name: this.instanceName(), tabs: 0, others: [] }));
546
+ const failure: EnvelopeData['failure'] = { error: `tool: ${browserErrorMessage(error)}` };
547
+ if (error instanceof ConfigMissingError) failure.error = browserErrorMessage(error);
548
+ if (state?.ariaSnapshot) failure.compactAria = compactAriaSnapshot(state.ariaSnapshot, true);
549
+
550
+ return {
551
+ ok: false,
552
+ command,
553
+ page: { url: state?.url || '', title: state?.title || '', state: state?.hash || '', visits: 0 },
554
+ failure,
555
+ instance,
556
+ };
557
+ }
558
+
559
+ private async loadConfig(): Promise<ExplorbotConfig> {
560
+ return ConfigParser.getInstance().loadConfig({ config: this.options.config, path: this.options.path, baseUrl: this.configBaseUrl() });
561
+ }
562
+
563
+ private configBaseUrl(): string | undefined {
564
+ const url = this.options.baseUrl || this.options.url || this.sessionUrl;
565
+ if (!url) return undefined;
566
+ if (!URL.canParse(url)) return undefined;
567
+ return url;
568
+ }
569
+
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> {
580
+ if (this.options.endpoint) {
581
+ const endpoint = this.options.endpoint;
582
+ const browserName = config.playwright.browser || 'chromium';
583
+ const known = readDescriptors().find((descriptor) => descriptor.endpoint === endpoint);
584
+ if (await this.attachToEndpoint({ file: '', title: '', endpoint, workspaceDir: '', browserName, playwrightLib: known?.playwrightLib || '' })) return;
585
+ throw new Error(dedent`
586
+ No browser answered at ${endpoint}.
587
+ Check the endpoint of the running session, or drop --endpoint to let prima pick
588
+ the playwright-cli session itself.
589
+ `);
590
+ }
591
+
592
+ const { match, candidates, browser } = discovered || (await this.discover());
593
+ if (match && (await this.attachToEndpoint(match, browser))) return;
594
+
595
+ if (!match && candidates.length) {
596
+ const titles = candidates.map((candidate) => candidate.title).join(', ');
597
+ throw new Error(dedent`
598
+ Several playwright-cli sessions are open: ${titles}
599
+ Pick one with --pw-session <title>.
600
+ `);
601
+ }
602
+
603
+ if (await this.connectOwnInstance()) return;
604
+
605
+ throw new Error(dedent`
606
+ No browser to drive for instance "${this.instanceName()}".
607
+ Open one first:
608
+ playwright-cli open <url> prima attaches to this workspace session by default
609
+ prima browser start starts a prima-owned browser instead
610
+ Prima never launches a browser implicitly.
611
+ `);
612
+ }
613
+
614
+ private async discover(descriptors = readDescriptors()): Promise<Discovery> {
615
+ const title = this.options.pwSession ?? process.env.PLAYWRIGHT_CLI_SESSION;
616
+ const opts = { title };
617
+
618
+ const alive = new Map<PwServerDescriptor, Browser>();
619
+ for (const candidate of selectDescriptor(descriptors, opts).candidates) {
620
+ const browser = await this.connectDescriptor(candidate);
621
+ if (browser) alive.set(candidate, browser);
622
+ }
623
+
624
+ const selected = selectDescriptor([...alive.keys()], opts);
625
+ const discovery: Discovery = { match: selected.match, candidates: selected.candidates };
626
+ if (selected.match) discovery.browser = alive.get(selected.match);
627
+
628
+ for (const [descriptor, browser] of alive) {
629
+ if (descriptor === selected.match) continue;
630
+ await browser.close().catch(() => {});
631
+ }
632
+
633
+ return discovery;
634
+ }
635
+
636
+ private async attachToEndpoint(descriptor: PwServerDescriptor, probed?: Browser): Promise<boolean> {
637
+ const browser = probed || (await this.connectDescriptor(descriptor));
638
+ if (!browser) return false;
639
+
640
+ this.bot.attachBrowser(browser);
641
+ this.attached = this.attachmentLabel(descriptor);
642
+ this.session = { key: descriptor.title || this.instanceName(), endpoint: descriptor.endpoint, title: `prima session "${descriptor.title || this.instanceName()}"` };
643
+ return true;
644
+ }
645
+
646
+ private async connectDescriptor(descriptor: PwServerDescriptor): Promise<Browser | null> {
647
+ const connected = await this.connectWith(this.descriptorLib(descriptor), descriptor);
648
+ if (connected) return connected;
649
+ return this.connectWith(playwright, descriptor);
650
+ }
651
+
652
+ private async connectWith(lib: any, descriptor: PwServerDescriptor): Promise<Browser | null> {
653
+ const launcher = lib?.[descriptor.browserName];
654
+ if (!launcher?.connect) return null;
655
+ return launcher.connect(descriptor.endpoint, { timeout: CONNECT_TIMEOUT }).catch(() => null);
656
+ }
657
+
658
+ private descriptorLib(descriptor: PwServerDescriptor): any {
659
+ if (!descriptor.playwrightLib) return null;
660
+ try {
661
+ return requireLib(descriptor.playwrightLib);
662
+ } catch {
663
+ return null;
664
+ }
665
+ }
666
+
667
+ private attachmentLabel(descriptor: PwServerDescriptor): string {
668
+ if (!descriptor.title) return `endpoint ${descriptor.endpoint}`;
669
+ if (!descriptor.workspaceDir) return `playwright-cli session "${descriptor.title}"`;
670
+ return `playwright-cli session "${descriptor.title}", workspace ${descriptor.workspaceDir}`;
671
+ }
672
+
673
+ private async connectOwnInstance(): Promise<boolean> {
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;
678
+ }
679
+
680
+ private async launchOwnServer(opts: { browser?: string; show?: boolean }, instance: string): Promise<{ close: () => Promise<void> }> {
681
+ return launchServer(opts, instance);
682
+ }
683
+
684
+ private async stopInstance(instance: string): Promise<boolean> {
685
+ if (instance === this.instanceName()) {
686
+ const server = this.server;
687
+ this.server = null;
688
+ await server?.close();
689
+ }
690
+
691
+ return stopServer(instance);
692
+ }
693
+
694
+ private isUrlTarget(target: string): boolean {
695
+ const value = target.trim();
696
+ if (value.startsWith('/')) return true;
697
+ return URL.canParse(value);
698
+ }
699
+
700
+ private aiUnavailable(): string | null {
701
+ try {
702
+ if (this.bot.getProvider?.()) return null;
703
+ } catch (error) {
704
+ return browserErrorMessage(error);
705
+ }
706
+ return this.bot.aiFailureReason?.() || 'no AI model is configured';
707
+ }
708
+
709
+ private async aiGuard(command: string): Promise<EnvelopeData | null> {
710
+ const reason = this.aiUnavailable();
711
+ if (!reason) return null;
712
+ const message = `this command needs an AI model and none is usable: ${reason}. Drive the browser directly with playwright-cli or prima pw, or fix the AI config in ~/.explorbot/config.js or EXPLORBOT_AI_PROVIDER.`;
713
+ return this.toolFailureEnvelope(command, message);
714
+ }
715
+
716
+ private instructionSystemPrompt(): string {
717
+ return dedent`
718
+ <role>
719
+ You are a web automation engineer performing high-level instructions on the page that is already open in the browser.
720
+ </role>
721
+
722
+ <approach>
723
+ 1. Read the page context and carry out the instructions in the order they are listed.
724
+ 2. Interact with the page only through the provided tools.
725
+ 3. Pick the smallest interaction that fulfills an instruction, then move to the next one.
726
+ 4. After the page changes, work from the updated context you are given, not from the earlier one.
727
+ 5. Account for every instruction: completed() as soon as one is satisfied, blocked() when the page cannot do what it asks.
728
+ </approach>
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
+
767
+ ${locatorRule}
768
+
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>
776
+ `;
777
+ }
778
+
779
+ private async instructionPrompt(instructions: string[], result: ActionResult): Promise<string> {
780
+ const list = instructions.map((instruction, index) => `${index + 1}. ${instruction}`).join('\n');
781
+ return dedent`
782
+ <instructions>
783
+ ${list}
784
+ </instructions>
785
+
786
+ ${await this.pageContext(result)}
787
+ `;
788
+ }
789
+
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> {
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
+
865
+ return dedent`
866
+ <page url="${result.url}" title="${result.title}">
867
+ ${compactAriaSnapshot(await this.refAriaSnapshot(result), true, (value) => this.offloadValue(value))}
868
+ </page>
869
+
870
+ ${experience}
871
+ `;
872
+ }
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
+
902
+ private executedCodes(code: unknown): string[] {
903
+ if (typeof code !== 'string') return [];
904
+ return code
905
+ .split('\n')
906
+ .map((line) => line.trim())
907
+ .filter((line) => line && !line.startsWith('//'));
908
+ }
909
+
910
+ private visionEnabled(): boolean {
911
+ if (this.options.noVision) return false;
912
+ return this.bot.getProvider().hasVision?.() === true;
913
+ }
914
+
915
+ private async answer(question: string, result: ActionResult): Promise<string> {
916
+ const seen = await this.visionAnswer(question, result);
917
+ if (seen) return seen;
918
+
919
+ const described = await this.textAnswer(question, result);
920
+ if (this.options.noVision) return described;
921
+ return `${described}\n\n(vision was unavailable, answered from page structure)`;
922
+ }
923
+
924
+ private async visionAnswer(question: string, result: ActionResult): Promise<string | null> {
925
+ if (!this.visionEnabled()) return null;
926
+ return this.bot.agentResearcher().answerQuestionAboutScreenshot(result, question);
927
+ }
928
+
929
+ private async textAnswer(question: string, result: ActionResult): Promise<string> {
930
+ const provider = this.bot.getProvider();
931
+ const summary = await this.bot.agentResearcher().summary(result);
932
+ const prompt = dedent`
933
+ Answer the question about the web page described below.
934
+ Rely only on the page summary and the accessibility tree given here.
935
+ When the answer is not present in them, say which part of the page would be needed instead of guessing.
936
+ Keep the answer under five sentences.
937
+
938
+ <question>
939
+ ${question}
940
+ </question>
941
+
942
+ <page_summary>
943
+ ${summary}
944
+ </page_summary>
945
+
946
+ <page_aria>
947
+ ${compactAriaSnapshot(result.ariaSnapshot, true)}
948
+ </page_aria>
949
+ `;
950
+
951
+ const response = await provider.chat([{ role: 'user', content: prompt }], provider.getModelForAgent?.(AI_AGENT_NAME), { agentName: AI_AGENT_NAME });
952
+ return response?.text || '';
953
+ }
954
+
955
+ private async successEnvelope(command: string, used: string[], result: ActionResult, previousState: WebPageState | null): Promise<EnvelopeData> {
956
+ return {
957
+ ok: true,
958
+ command,
959
+ used,
960
+ page: this.pageBlock(result, previousState),
961
+ changes: await this.pageChanges(result, previousState, used[0]),
962
+ instance: await this.instanceInfo(),
963
+ status: await this.saveStatus(result),
964
+ };
965
+ }
966
+
967
+ private async failureEnvelope(command: string, error: unknown, previousState: WebPageState | null): Promise<EnvelopeData> {
968
+ const result = await this.capturedResult(previousState);
969
+ const failure: EnvelopeData['failure'] = { error: browserErrorMessage(error) };
970
+ if (result.ariaSnapshot) failure.compactAria = compactAriaSnapshot(result.ariaSnapshot, true);
971
+
972
+ return {
973
+ ok: false,
974
+ command,
975
+ page: this.pageBlock(result, previousState),
976
+ failure,
977
+ instance: await this.instanceInfo(),
978
+ status: await this.saveStatus(result),
979
+ };
980
+ }
981
+
982
+ private async reportEnvelope(command: string, result: ActionResult, previousState: WebPageState | null, outcome: Partial<EnvelopeData>): Promise<EnvelopeData> {
983
+ return {
984
+ ok: true,
985
+ command,
986
+ page: this.pageBlock(result, previousState),
987
+ ...outcome,
988
+ instance: await this.instanceInfo(),
989
+ status: await this.saveStatus(result),
990
+ };
991
+ }
992
+
993
+ private async capturedResult(previousState: WebPageState | null, opts: { screenshot?: boolean } = {}): Promise<ActionResult> {
994
+ const captured = await this.bot
995
+ .getExplorer()
996
+ ?.capture(opts)
997
+ .catch(() => null);
998
+ if (captured) return captured;
999
+ if (previousState) return ActionResult.fromState(previousState);
1000
+ return new ActionResult({ url: '' });
1001
+ }
1002
+
1003
+ private pageBlock(result: ActionResult, previousState: WebPageState | null): EnvelopeData['page'] {
1004
+ return {
1005
+ url: result.url,
1006
+ previousUrl: previousState?.url,
1007
+ title: result.title,
1008
+ state: result.getStateHash(),
1009
+ visits: this.bot.stateManager().getVisitCount(result.url),
1010
+ };
1011
+ }
1012
+
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';
1024
+ const toolResult = await result.toToolResult(ActionResult.fromState(previousState), code);
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
+ };
1042
+ }
1043
+
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(), {
1067
+ aria: result.ariaSnapshot,
1068
+ html: await result.combinedHtml(),
1069
+ requests: this.bot.requestStore().getRequests(),
1070
+ });
1071
+ return undefined;
1072
+ }
1073
+
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 {
1083
+ this.artifactsDir ||= outputPath('prima');
1084
+ return path.join(this.artifactsDir, hash);
1085
+ }
1086
+
1087
+ private tabCount(): number {
1088
+ const page = this.bot.getExplorer()?.page;
1089
+ if (!page) return 0;
1090
+ return page.context().pages().length;
1091
+ }
1092
+
1093
+ private instanceName(): string {
1094
+ return this.options.instance || 'default';
1095
+ }
1096
+ }
1097
+
1098
+ interface Discovery {
1099
+ match?: PwServerDescriptor;
1100
+ candidates: PwServerDescriptor[];
1101
+ browser?: Browser;
1102
+ }
1103
+
1104
+ interface LedgerEntry {
1105
+ text: string;
1106
+ status: 'open' | 'done' | 'blocked';
1107
+ proof: string;
1108
+ }
1109
+
1110
+ export interface PrimaOptions {
1111
+ config?: string;
1112
+ path?: string;
1113
+ instance?: string;
1114
+ session?: string | boolean;
1115
+ ephemeral?: boolean;
1116
+ framework?: 'codeceptjs' | 'playwright';
1117
+ noVision?: boolean;
1118
+ url?: string;
1119
+ baseUrl?: string;
1120
+ show?: boolean;
1121
+ headless?: boolean;
1122
+ endpoint?: string;
1123
+ pwSession?: string;
1124
+ }