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
@@ -30,6 +30,7 @@ interface ActionResultData extends WebPageState {
30
30
  iframeSnapshots?: Array<{ src: string; html: string; id?: string }>;
31
31
  ariaSnapshot?: string | null;
32
32
  ariaSnapshotFile?: string;
33
+ focusedElement?: FocusedElement | null;
33
34
  iframeURL?: string;
34
35
  links?: Link[];
35
36
  }
@@ -74,6 +75,7 @@ export class ActionResult implements ActionResultData {
74
75
  private snapshotCache = new TTLCache<string>();
75
76
  readonly logFile: string | undefined = undefined;
76
77
  readonly ariaSnapshotFile: string | undefined = undefined;
78
+ readonly focusedElement: FocusedElement | null = null;
77
79
  private _ariaSnapshot: string | null | undefined = undefined;
78
80
  private _lastExtractedHtml: string | undefined = undefined;
79
81
  notes: string[] = [];
@@ -107,6 +109,9 @@ export class ActionResult implements ActionResultData {
107
109
  if (data.ariaSnapshotFile !== undefined) {
108
110
  this.ariaSnapshotFile = data.ariaSnapshotFile;
109
111
  }
112
+ if (data.focusedElement !== undefined) {
113
+ this.focusedElement = data.focusedElement;
114
+ }
110
115
 
111
116
  // Store HTML in a private property if provided
112
117
  if (data.html !== undefined) {
@@ -643,3 +648,9 @@ export class Diff {
643
648
  this._ariaChangeCount = ariaDiff.count;
644
649
  }
645
650
  }
651
+
652
+ export interface FocusedElement {
653
+ role: string;
654
+ name: string;
655
+ value?: string;
656
+ }
package/src/action.ts CHANGED
@@ -3,7 +3,7 @@ import { join } from 'node:path';
3
3
  import { context, trace } from '@opentelemetry/api';
4
4
  import { container, recorder } from 'codeceptjs';
5
5
  import * as codeceptjs from 'codeceptjs';
6
- import { ActionResult } from './action-result.js';
6
+ import { ActionResult, type FocusedElement } from './action-result.js';
7
7
  import { clearActivity, setActivity } from './activity.ts';
8
8
  import { ConfigParser, outputPath } from './config.js';
9
9
  import type { ExplorbotConfig } from './config.js';
@@ -19,6 +19,8 @@ import { codeceptJSSandbox, hasPlaywrightCommands, playwrightSandbox, sanitizeCo
19
19
 
20
20
  const debugLog = createDebug('explorbot:action');
21
21
  const CAPTURE_NAVIGATION_TRANSITION_ATTEMPTS = 3;
22
+ const DEFAULT_ACTION_TIMEOUT = 3000;
23
+ const DEFAULT_PAGE_TIMEOUT = 3000;
22
24
 
23
25
  class Action {
24
26
  private actor: CodeceptJS.I;
@@ -32,6 +34,7 @@ class Action {
32
34
  public playwrightHelper: any;
33
35
  public playwrightGroupId: string | null = null;
34
36
  public assertionSteps: Array<{ name: string; args: any[] }> = [];
37
+ public lastValue: unknown;
35
38
  private recorder?: PlaywrightRecorder;
36
39
  private recovery: RecoveryRunner;
37
40
  private mainDocumentStatus: number | undefined = undefined;
@@ -65,8 +68,8 @@ class Action {
65
68
  return this.recovery(() => this.captureOnce(opts));
66
69
  }
67
70
 
68
- async execute(code: string): Promise<Action> {
69
- return this.recovery(() => this.executeOnce(code));
71
+ async execute(code: string, opts: ExecuteOptions = {}): Promise<Action> {
72
+ return this.recovery(() => this.executeOnce(code, opts.verbatim));
70
73
  }
71
74
 
72
75
  private async captureOnce({ includeScreenshot = false, codeBlock }: { includeScreenshot?: boolean; codeBlock?: string } = {}): Promise<ActionResult> {
@@ -133,10 +136,12 @@ class Action {
133
136
 
134
137
  let ariaSnapshot: string | null = null;
135
138
  let ariaSnapshotFile: string | undefined = undefined;
139
+ let focusedElement: FocusedElement | null = null;
136
140
 
137
141
  try {
138
142
  const page = this.playwrightHelper.page;
139
143
  ariaSnapshot = await page.locator('body').ariaSnapshot();
144
+ focusedElement = await page.evaluate(readFocusedElement);
140
145
  } catch (err) {
141
146
  debugLog('ARIA snapshot failed:', err instanceof Error ? `${err.message}\n${err.stack}` : err);
142
147
  }
@@ -160,6 +165,7 @@ class Action {
160
165
  iframeSnapshots,
161
166
  ariaSnapshot,
162
167
  ariaSnapshotFile,
168
+ focusedElement,
163
169
  iframeURL: frame ? frame.url?.() || 'iframe' : undefined,
164
170
  });
165
171
  this.stateManager.updateState(result, codeBlock);
@@ -270,7 +276,7 @@ class Action {
270
276
  }
271
277
  }
272
278
 
273
- private async executeOnce(code: string): Promise<Action> {
279
+ private async executeOnce(code: string, verbatim = false): Promise<Action> {
274
280
  let error: Error | null = null;
275
281
 
276
282
  setActivity('🔎 Browsing...', 'action');
@@ -287,8 +293,8 @@ class Action {
287
293
  const tracer = trace.getTracer('ai');
288
294
  const stepSpan = activeSpan ? tracer.startSpan('codeceptjs.step', undefined, trace.setSpan(context.active(), activeSpan)) : null;
289
295
  setStepSpanParent(stepSpan);
290
- const sanitizedCode = sanitizeCodeBlock(codeString);
291
- const isPlaywright = hasPlaywrightCommands(sanitizedCode);
296
+ const sanitizedCode = verbatim ? codeString : sanitizeCodeBlock(codeString);
297
+ const isPlaywright = !verbatim && hasPlaywrightCommands(sanitizedCode);
292
298
 
293
299
  try {
294
300
  debugLog('Executing action:', codeString);
@@ -297,16 +303,21 @@ class Action {
297
303
  throw new Error('No valid I.* or page.* commands found in code block');
298
304
  }
299
305
 
306
+ this.playwrightHelper?.page?.setDefaultTimeout(this.config.action?.timeout ?? DEFAULT_ACTION_TIMEOUT);
307
+
300
308
  if (isPlaywright) {
301
309
  const page = this.playwrightHelper.page;
302
310
  await playwrightSandbox(page, sanitizedCode);
303
311
  await sleep(this.config.action?.delay || 500);
304
312
  } else {
305
- codeceptJSSandbox(this.actor, sanitizedCode);
313
+ const returned = codeceptJSSandbox(this.actor, sanitizedCode);
306
314
  await recorder.add(() => sleep(this.config.action?.delay || 500));
307
315
  await recorder.promise();
316
+ this.lastValue = await returned;
308
317
  }
309
318
 
319
+ this.restorePageTimeout();
320
+
310
321
  if (executedSteps.length > 0) {
311
322
  codeString = executedSteps.join('\n');
312
323
  }
@@ -325,6 +336,7 @@ class Action {
325
336
  this.assertionSteps = [];
326
337
  throw err;
327
338
  } finally {
339
+ this.restorePageTimeout();
328
340
  detachMainDocumentResponse();
329
341
  if (groupId) await this.recorder!.endAction();
330
342
  detachStepLogger(stepListener);
@@ -374,6 +386,10 @@ class Action {
374
386
  return this.actionResult;
375
387
  }
376
388
 
389
+ private restorePageTimeout(): void {
390
+ this.playwrightHelper?.page?.setDefaultTimeout(this.config.playwright.timeout ?? DEFAULT_PAGE_TIMEOUT);
391
+ }
392
+
377
393
  private async waitForPageReadiness(page: any): Promise<void> {
378
394
  await waitForPageReadiness(page, {
379
395
  timeout: this.config.playwright.waitForTimeout,
@@ -386,6 +402,10 @@ export default Action;
386
402
 
387
403
  export type RecoveryRunner = <T>(fn: () => Promise<T>) => Promise<T>;
388
404
 
405
+ export interface ExecuteOptions {
406
+ verbatim?: boolean;
407
+ }
408
+
389
409
  function errorToString(error: any): string {
390
410
  if (error.cliMessage) {
391
411
  return error.cliMessage();
@@ -438,3 +458,23 @@ const detachStepLogger = (listener: StepListener) => {
438
458
  codeceptjs.event.dispatcher.off(codeceptjs.event.step.passed, listener);
439
459
  codeceptjs.event.dispatcher.off(codeceptjs.event.step.failed, listener);
440
460
  };
461
+
462
+ const readFocusedElement = () => {
463
+ const el = document.activeElement as any;
464
+ if (!el || el === document.body) return null;
465
+
466
+ const tag = el.tagName.toLowerCase();
467
+ const textish = new Set(['text', 'search', 'email', 'password', 'url', 'tel', 'number']);
468
+ let role = el.getAttribute('role') || tag;
469
+ if (tag === 'textarea' || el.isContentEditable) role = 'textbox';
470
+ if (tag === 'input' && textish.has(el.type)) role = 'textbox';
471
+ if (tag === 'select') role = 'combobox';
472
+ if (tag === 'a') role = 'link';
473
+
474
+ const label = el.getAttribute('aria-label') || el.getAttribute('placeholder') || el.labels?.[0]?.textContent || el.textContent || '';
475
+ const focused: { role: string; name: string; value?: string } = { role, name: label.trim().slice(0, 80) };
476
+
477
+ const value = el.value ?? el.textContent;
478
+ if (typeof value === 'string' && value) focused.value = value.slice(0, 200);
479
+ return focused;
480
+ };
@@ -16,7 +16,7 @@ export interface ModeContext {
16
16
  }
17
17
 
18
18
  export function resolveProjectRoot(): string | null {
19
- const configPath = ConfigParser.getInstance().getConfigPath();
20
- if (!configPath) return null;
21
- return dirname(configPath);
19
+ const configParser = ConfigParser.getInstance();
20
+ if (!configParser.getConfigPath()) return null;
21
+ return configParser.getProjectRoot();
22
22
  }
@@ -18,7 +18,7 @@ export function WithWebMode<T extends Constructor>(Base: T) {
18
18
  });
19
19
  const { see, context, visualClick, learnExperience } = agentTools;
20
20
 
21
- return {
21
+ const tools: Record<string, any> = {
22
22
  navigate: tool({
23
23
  description: 'Navigate to a URL or page description using AI-powered navigation.',
24
24
  inputSchema: z.object({
@@ -27,7 +27,7 @@ export function WithWebMode<T extends Constructor>(Base: T) {
27
27
  execute: async ({ destination }) => {
28
28
  try {
29
29
  debugLog('navigate', destination);
30
- await ctx.explorBot.agentNavigator().visit(destination);
30
+ await ctx.explorBot.visit(destination);
31
31
  const stateManager = ctx.explorBot.stateManager();
32
32
  const state = stateManager.getCurrentState();
33
33
  return { success: true, url: state?.url, title: state?.title };
@@ -123,11 +123,14 @@ export function WithWebMode<T extends Constructor>(Base: T) {
123
123
  }),
124
124
 
125
125
  ...codeceptTools,
126
- see,
127
126
  context,
128
- visualClick,
129
127
  learnExperience,
130
128
  };
129
+
130
+ if (see) tools.see = see;
131
+ if (visualClick) tools.visualClick = visualClick;
132
+
133
+ return tools;
131
134
  }
132
135
 
133
136
  webModePrompt(): string {
package/src/ai/captain.ts CHANGED
@@ -21,6 +21,7 @@ import type { Navigator } from './navigator.ts';
21
21
  import type { Provider } from './provider.ts';
22
22
  import { Researcher } from './researcher.ts';
23
23
  import { TaskAgent } from './task-agent.ts';
24
+ import { withdrawVisionTools } from './tools.ts';
24
25
 
25
26
  const MAX_STEPS = 15;
26
27
 
@@ -437,6 +438,8 @@ export class Captain extends CaptainBase implements Agent {
437
438
  return;
438
439
  }
439
440
 
441
+ withdrawVisionTools(tools);
442
+
440
443
  const currentState = stateManager.getCurrentState();
441
444
  if (!currentState && this.getMode() !== 'idle') {
442
445
  stop();
@@ -8,12 +8,13 @@ import type { ExperienceTracker } from '../experience-tracker.js';
8
8
  import Explorer from '../explorer.ts';
9
9
  import type { KnowledgeTracker } from '../knowledge-tracker.js';
10
10
  import { type StateManager, normalizeUrl } from '../state-manager.js';
11
+ import { renderAssertion } from '../playwright-recorder.ts';
11
12
  import { extractCodeBlocks } from '../utils/code-extractor.js';
12
13
  import { HooksRunner } from '../utils/hooks-runner.ts';
13
14
  import { createDebug, pluralize, tag } from '../utils/logger.js';
14
15
  import { loop, pause } from '../utils/loop.js';
15
16
  import { RulesLoader } from '../utils/rules-loader.ts';
16
- import { extractStatePath } from '../utils/url-matcher.js';
17
+ import { extractStatePath, matchesNavigationUrl } from '../utils/url-matcher.js';
17
18
  import type { Agent, AgentDeps } from './agent.js';
18
19
  import type { Conversation } from './conversation.js';
19
20
  import type { Provider } from './provider.js';
@@ -134,7 +135,7 @@ class Navigator implements Agent {
134
135
  return false;
135
136
  }
136
137
  const currentUrl = this.getComparableCurrentUrl(stateManager, expectedUrl);
137
- return normalizeUrl(currentUrl) === normalizeUrl(expectedUrl);
138
+ return matchesNavigationUrl(expectedUrl, currentUrl);
138
139
  }
139
140
 
140
141
  async visit(url: string): Promise<void> {
@@ -189,14 +190,16 @@ class Navigator implements Agent {
189
190
  }
190
191
  }
191
192
 
192
- async resolveState(message: string, actionResult: ActionResult, opts?: { action?: Action; expectedUrl?: string }): Promise<boolean> {
193
+ async resolveState(message: string, actionResult: ActionResult, opts?: { action?: Action; expectedUrl?: string; onAttempt?: (attempt: { code: string; error?: string }) => void }): Promise<boolean> {
194
+ if (!this.provider) throw new Error('AI-assisted recovery is unavailable: no AI model is configured.');
195
+
193
196
  tag('info').log('AI Navigator resolving state at', actionResult.url);
194
197
  debugLog('Resolution message:', message);
195
198
 
196
199
  const action = opts?.action ?? this.explorer.action();
197
200
  const expectedUrl = opts?.expectedUrl;
198
201
 
199
- const knowledge = this.knowledgeTracker.renderRelevantKnowledge(actionResult);
202
+ const knowledge = this.knowledgeTracker.renderRelevantContext(actionResult);
200
203
  let experience = '';
201
204
 
202
205
  if (!actionResult.isInsideIframe) {
@@ -363,24 +366,27 @@ class Navigator implements Agent {
363
366
  }
364
367
  }
365
368
 
369
+ if (attemptOk) opts?.onAttempt?.({ code: codeBlock });
370
+
366
371
  if (!attemptOk) {
367
372
  const raw = action.lastError?.message || 'attempt failed';
368
373
  const firstMeaningful = raw.split('\n').find((l) => l.trim() && !l.trim().startsWith('at ')) || raw;
369
374
  const shortErr = firstMeaningful.replace(/\s+/g, ' ').trim().slice(0, 220);
370
375
  batchFailures.push({ code: codeBlock, error: shortErr });
376
+ opts?.onAttempt?.({ code: codeBlock, error: shortErr });
371
377
  }
372
378
 
373
379
  if (expectedUrl) {
374
380
  if (page) {
375
381
  try {
376
- await page.waitForURL((url: URL) => normalizeUrl(url.pathname) === normalizeUrl(expectedUrl), { timeout: 5000 });
382
+ await page.waitForURL((url: URL) => matchesNavigationUrl(expectedUrl, `${url.pathname}${url.search}${url.hash}`), { timeout: 5000 });
377
383
  } catch {
378
384
  // URL did not transition to expectedUrl within timeout
379
385
  }
380
386
  }
381
387
  const freshState = await this.explorer.capture();
382
388
  const currentUrl = /^https?:\/\//i.test(expectedUrl) ? freshState.fullUrl || freshState.url || '' : freshState.url || '';
383
- const urlMatches = this.isSameExpectedOrigin(expectedUrl, action.stateManager) && normalizeUrl(currentUrl) === normalizeUrl(expectedUrl);
389
+ const urlMatches = this.isSameExpectedOrigin(expectedUrl, action.stateManager) && matchesNavigationUrl(expectedUrl, currentUrl);
384
390
  const stateChanged = freshState.getStateHash() !== actionResult.getStateHash();
385
391
  resolved = urlMatches && stateChanged;
386
392
 
@@ -615,7 +621,7 @@ class Navigator implements Agent {
615
621
  return suggestion;
616
622
  }
617
623
 
618
- async verifyState(message: string, actionResult: ActionResult): Promise<{ verified: boolean; successfulCodes: string[]; assertionSteps: Array<{ name: string; args: any[] }>; totalAttempted: number }> {
624
+ async verifyState(message: string, actionResult: ActionResult): Promise<{ verified: boolean; inexpressible: boolean; results: AssertionResult[]; successfulCodes: string[]; assertionSteps: Array<{ name: string; args: any[] }>; totalAttempted: number }> {
619
625
  tag('info').log('AI Navigator verifying state at', actionResult.url);
620
626
  debugLog('Verification message:', message);
621
627
 
@@ -625,7 +631,7 @@ class Navigator implements Agent {
625
631
  return { verified: cachedVerification, successfulCodes: [], assertionSteps: [], totalAttempted: 0 };
626
632
  }
627
633
 
628
- const knowledge = this.knowledgeTracker.renderRelevantKnowledge(actionResult);
634
+ const knowledge = this.knowledgeTracker.renderRelevantContext(actionResult);
629
635
  let experience = '';
630
636
 
631
637
  if (!actionResult.isInsideIframe) {
@@ -693,6 +699,7 @@ class Navigator implements Agent {
693
699
 
694
700
  let codeBlocks: string[] = [];
695
701
  const successfulCodes: string[] = [];
702
+ const results: AssertionResult[] = [];
696
703
  const assertionSteps: Array<{ name: string; args: any[] }> = [];
697
704
 
698
705
  const action = this.explorer.action();
@@ -734,6 +741,8 @@ class Navigator implements Agent {
734
741
  await action.exitIframe();
735
742
 
736
743
  const verified = await action.attempt(codeBlock, message);
744
+ const proof = action.assertionSteps.map(renderAssertion).filter(Boolean);
745
+ results.push({ code: codeBlock, passed: verified, proof });
737
746
 
738
747
  if (verified) {
739
748
  tag('success').log('Verification passed');
@@ -742,12 +751,6 @@ class Navigator implements Agent {
742
751
  } else {
743
752
  failures++;
744
753
  }
745
-
746
- const target = Math.min(codeBlocks.length, this.verifyAttempts);
747
- const majorityNeeded = Math.floor(target / 2) + 1;
748
- if (successfulCodes.length >= majorityNeeded || failures > target - majorityNeeded) {
749
- stop();
750
- }
751
754
  },
752
755
  {
753
756
  maxAttempts: this.verifyAttempts,
@@ -768,10 +771,16 @@ class Navigator implements Agent {
768
771
  let verified = successfulCodes.length >= majorityNeeded;
769
772
  if (alreadyVerified) verified = true;
770
773
 
774
+ const inexpressible = !alreadyVerified && totalAttempted === 0;
775
+ if (inexpressible) {
776
+ tag('warning').log('No assertion could express this claim');
777
+ return { verified: false, inexpressible, results, successfulCodes, assertionSteps, totalAttempted };
778
+ }
779
+
771
780
  actionResult.addVerification(message, verified);
772
781
  this.stateManager.updateState(actionResult);
773
782
 
774
- return { verified, successfulCodes, assertionSteps, totalAttempted };
783
+ return { verified, inexpressible, results, successfulCodes, assertionSteps, totalAttempted };
775
784
  }
776
785
 
777
786
  private checkAlreadyVerified(aiResponse: string, actionResult: ActionResult): boolean {
@@ -782,4 +791,6 @@ class Navigator implements Agent {
782
791
  }
783
792
  }
784
793
 
794
+ export type AssertionResult = { code: string; passed: boolean; proof: string[] };
795
+
785
796
  export { Navigator };
package/src/ai/pilot.ts CHANGED
@@ -8,7 +8,7 @@ import type Explorer from '../explorer.ts';
8
8
  import type { PlaywrightRecorder } from '../playwright-recorder.ts';
9
9
  import type { StateManager } from '../state-manager.ts';
10
10
  import { type Test, TestResult } from '../test-plan.ts';
11
- import { collectInteractiveNodes, detectFocusArea, extractFocusedElement } from '../utils/aria.ts';
11
+ import { collectInteractiveNodes, detectFocusArea } from '../utils/aria.ts';
12
12
  import { ErrorPageError } from '../utils/error-page.ts';
13
13
  import { createDebug, tag } from '../utils/logger.ts';
14
14
 
@@ -22,6 +22,7 @@ import type { Provider } from './provider.ts';
22
22
  import type { Researcher } from './researcher.ts';
23
23
  import { capabilityGroundingRule, dataProtectionRules } from './rules.ts';
24
24
  import { isInteractive } from './task-agent.ts';
25
+ import { withdrawVisionTools } from './tools.ts';
25
26
 
26
27
  const CHECK_TOOLS = ['verify', 'see', 'research', 'context'];
27
28
  const META_TOOLS = ['record', 'reset', 'stop', 'finish'];
@@ -561,6 +562,55 @@ export class Pilot implements Agent {
561
562
  return text;
562
563
  }
563
564
 
565
+ async settleExpectations(task: Test): Promise<Array<{ text: string; status: 'passed' | 'failed' | 'unverified' }>> {
566
+ const undecided = task.expected.filter((text) => !task.getCheckedExpectations().includes(text));
567
+ const decided = (text: string): 'passed' | 'failed' => {
568
+ if (task.hasAchievedAny() && !task.getRemainingExpectations().includes(text)) return 'passed';
569
+ return 'failed';
570
+ };
571
+
572
+ if (!undecided.length) return task.expected.map((text) => ({ text, status: decided(text) }));
573
+
574
+ const schema = z.object({
575
+ outcomes: z.array(
576
+ z.object({
577
+ expectation: z.string().describe('The expected outcome, repeated exactly as it was given'),
578
+ status: z.enum(['passed', 'failed', 'unverified']).describe('passed = the log shows it happened, failed = the log shows it did not, unverified = the run never established either way'),
579
+ })
580
+ ),
581
+ });
582
+
583
+ const userContent = dedent`
584
+ A test run has finished. Decide, for each expected outcome, what the run established about it.
585
+
586
+ <expected_outcomes>
587
+ ${undecided.map((text) => `- ${text}`).join('\n')}
588
+ </expected_outcomes>
589
+
590
+ <run_log>
591
+ ${task.notesToString() || 'No steps recorded.'}
592
+ </run_log>
593
+
594
+ The log is written in the tester's own words, so an outcome can be satisfied by a step that describes it
595
+ differently. Judge by what the steps show happened, not by whether the wording matches.
596
+ Choose "unverified" only when the log neither shows the outcome happening nor shows it failing —
597
+ that is a statement about the run, not about the application.
598
+ `;
599
+
600
+ const response = await this.provider
601
+ .generateObject([{ role: 'user' as const, content: userContent }], schema, this.provider.getAgenticModel('pilot'), {
602
+ agentName: 'pilot',
603
+ telemetry: { functionId: 'pilot.settleExpectations' },
604
+ })
605
+ .catch(() => null);
606
+
607
+ const judged = new Map((response?.object?.outcomes || []).map((outcome: any) => [outcome.expectation, outcome.status]));
608
+ return task.expected.map((text) => {
609
+ if (!undecided.includes(text)) return { text, status: decided(text) };
610
+ return { text, status: (judged.get(text) as 'passed' | 'failed' | 'unverified') || 'unverified' };
611
+ });
612
+ }
613
+
564
614
  private formatExpectations(task: Test): string {
565
615
  const checked = task.getCheckedExpectations();
566
616
  const remaining = task.getRemainingExpectations();
@@ -620,6 +670,7 @@ export class Pilot implements Agent {
620
670
  if (xpathCheck) planning.xpathCheck = xpathCheck;
621
671
  if (learnExperience) planning.learnExperience = learnExperience;
622
672
  if (askUser) planning.askUser = askUser;
673
+ withdrawVisionTools(planning);
623
674
  return planning;
624
675
  }
625
676
 
@@ -703,7 +754,7 @@ export class Pilot implements Agent {
703
754
  lines.push(`url: ${state.url}`);
704
755
  lines.push(`title: ${state.title || 'unknown'}`);
705
756
 
706
- const focused = extractFocusedElement(state.ariaSnapshot);
757
+ const focused = state.focusedElement;
707
758
  if (focused) {
708
759
  const valuePart = focused.value ? ` (value: "${focused.value}")` : '';
709
760
  lines.push(`focused: ${focused.role} "${focused.name}"${valuePart}`);
package/src/ai/planner.ts CHANGED
@@ -53,6 +53,7 @@ export class Planner extends PlannerBase implements Agent {
53
53
  provider: Provider;
54
54
  stateManager: StateManager;
55
55
  private experienceTracker: ExperienceTracker;
56
+ private knowledgeTracker: AgentDeps['knowledgeTracker'];
56
57
 
57
58
  MIN_TASKS = 3;
58
59
  MAX_TASKS = 12;
@@ -70,6 +71,7 @@ export class Planner extends PlannerBase implements Agent {
70
71
  this.researcher = researcher;
71
72
  this.stateManager = deps.stateManager;
72
73
  this.experienceTracker = deps.stateManager.getExperienceTracker();
74
+ this.knowledgeTracker = deps.knowledgeTracker;
73
75
  }
74
76
 
75
77
  setFisherman(fisherman: Fisherman): void {
@@ -411,6 +413,11 @@ export class Planner extends PlannerBase implements Agent {
411
413
  </page_research>
412
414
  `);
413
415
 
416
+ const applicationContext = this.knowledgeTracker.renderApplicationSpec(state);
417
+ if (applicationContext) {
418
+ conversation.addUserText(applicationContext);
419
+ }
420
+
414
421
  conversation.addUserText(dedent`
415
422
  ${this.buildApproach(style)}
416
423
 
@@ -326,6 +326,7 @@ export class Provider {
326
326
  try {
327
327
  const response = await withRetry(async () => {
328
328
  const result = await generateText({ messages, ...config });
329
+ this.recordUsage(options.agentName || 'unknown', modelName, result.usage);
329
330
  if (!result.text) {
330
331
  debugLog(result);
331
332
  if (result.finishReason === 'length') {
@@ -342,8 +343,6 @@ export class Provider {
342
343
  clearActivity();
343
344
  responseLog(response.text);
344
345
 
345
- this.recordUsage(options.agentName || 'unknown', modelName, response.usage);
346
-
347
346
  return response;
348
347
  } catch (error: any) {
349
348
  clearActivity();
@@ -378,6 +377,7 @@ export class Provider {
378
377
  try {
379
378
  const response = await withRetry(async () => {
380
379
  const result = (await this.raceWithIdleTimeout((signal) => generateText({ messages, ...config, abortSignal: signal }), config.timeout || 30000)) as any;
380
+ this.recordUsage(options.agentName || 'unknown', modelName, result.usage);
381
381
  const hasToolCall = (result.toolCalls?.length || 0) > 0;
382
382
  if (!result.text && !hasToolCall && result.finishReason === 'length') {
383
383
  throw new ContextLengthError('AI response empty: output truncated at maxTokens. Increase maxOutputTokens in config or use a model with higher output capacity.');
@@ -397,8 +397,6 @@ export class Provider {
397
397
 
398
398
  responseLog(response.text);
399
399
 
400
- this.recordUsage(options.agentName || 'unknown', modelName, response.usage);
401
-
402
400
  return response;
403
401
  } catch (error: any) {
404
402
  clearActivity();
@@ -24,7 +24,7 @@ import type { Navigator } from './navigator.ts';
24
24
  import { Provider } from './provider.ts';
25
25
  import { actionRule, locatorRule, sectionContextRule } from './rules.ts';
26
26
  import { TaskAgent } from './task-agent.ts';
27
- import { createCodeceptJSTools } from './tools.ts';
27
+ import { createCodeceptJSTools, withdrawVisionTools } from './tools.ts';
28
28
 
29
29
  const debugLog = createDebug('explorbot:rerunner');
30
30
 
@@ -361,6 +361,7 @@ export class Rerunner extends TaskAgent implements Agent {
361
361
  },
362
362
  }),
363
363
  };
364
+ withdrawVisionTools(tools);
364
365
 
365
366
  const conversation = this.provider.startConversation(this.getHealSystemPrompt(), 'rerunner');
366
367
  conversation.addUserText(this.getHealUserPrompt(failedCode, error));
@@ -102,7 +102,7 @@ export class Researcher extends ResearcherBase implements Agent {
102
102
  const cached = getCachedResearch(stateHash);
103
103
  if (cached) {
104
104
  debugLog('Previous research result found');
105
- return `!! UI MAP IS CACHED AND MAY NOT REPRESENT CURRENT STATE; REFRESH RESEARCH IF YOU NOTICE ISSUES !!\n\n${cached}`;
105
+ return cached;
106
106
  }
107
107
  }
108
108
 
@@ -438,7 +438,7 @@ export class Researcher extends ResearcherBase implements Agent {
438
438
  if (!this.actionResult) throw new Error('actionResult is not set');
439
439
 
440
440
  const html = await this.actionResult.combinedHtml();
441
- const knowledge = this.knowledgeTracker.renderRelevantKnowledge(this.actionResult);
441
+ const knowledge = this.knowledgeTracker.renderRelevantContext(this.actionResult);
442
442
 
443
443
  const ariaSnapshot = this.actionResult.getCompactARIA();
444
444
 
package/src/ai/rules.ts CHANGED
@@ -4,13 +4,18 @@ export const recommendedCodeceptCommands = ['I.click', 'I.type', 'I.fillField',
4
4
 
5
5
  const locatorPriorityRule = dedent`
6
6
  <locator_priority>
7
+ When the page context shows the element a ref, such as [ref=e14], there is no locator to select: click it with clickRef
8
+ and that ref. A ref names one exact element, so it never matches the wrong one and never has to be narrowed. Everything
9
+ below is for elements the context gives no ref for.
10
+
7
11
  Use the following priority when selecting locators:
8
12
 
9
13
  1. ARIA locators (first choice) - target browser's accessibility tree, most reliable
10
14
  Use JSON format: { "role": "button", "text": "Login" }
11
- Best for: buttons, links, inputs, form controls, dropdowns, checkboxes, radio buttons
15
+ Copy role and text VERBATIM from the ARIA snapshot or UI map — never guess the pair.
16
+ If the element is absent from the snapshot, do not invent one; use text or CSS instead.
12
17
 
13
- 2. Text locators (second choice) - use only when text is unique on the page
18
+ 2. Text locators (second choice) - exact visible text, use only when unique on the page
14
19
  Example: 'Login', 'Submit', 'Username'
15
20
  Skip if the same text appears multiple times on the page
16
21
 
@@ -26,10 +31,10 @@ const locatorPriorityRule = dedent`
26
31
 
27
32
  const contextSimplificationRule = dedent`
28
33
  <context_simplification>
29
- When container is available from UI map sections:
30
- - Text + container is simplest and PREFERRED: I.click('Save', '.modal')
31
- - ARIA + container for disambiguation: I.click({"role":"button","text":"Save"}, '.modal')
32
- - ALWAYS use context parameter unless locator is XPath or unique ID
34
+ - Add a container when the target may match several elements, or the UI map gives a verified
35
+ section container: I.click('Save', '.modal')
36
+ - Skip the container when the locator is already unique (XPath, unique ID, unique text)
37
+ - A wrong or stale container guarantees failure: always add one fallback command WITHOUT a container
33
38
  - No need for complex ARIA when container narrows scope sufficiently
34
39
  </context_simplification>
35
40
  `;
@@ -59,7 +64,7 @@ const locatorStrategyRule = dedent`
59
64
 
60
65
  <good_aria_locator_example>
61
66
  { "role": "button", "text": "Login" },
62
- { "role": "input", "text": "Name" },
67
+ { "role": "textbox", "text": "Name" },
63
68
  { "role": "link", "text": "Forgot your password?" },
64
69
  { "role": "link", "text": "Sign Up" },
65
70
  { "role": "button", "text": "Sign In" },
@@ -105,7 +110,7 @@ const locatorStrategyRule = dedent`
105
110
  </xpath_rules>
106
111
 
107
112
  <good locator example>
108
- 'div[role=input][placeholder="Name"]'
113
+ 'input[placeholder="Name"]'
109
114
  '[aria-label="Name"]'
110
115
  'form#user_form input[name="name"]'
111
116
  '#content-top #user_name'
@@ -245,7 +250,8 @@ export const unexpectedPopupRule = dedent`
245
250
 
246
251
  export const sectionContextRule = dedent`
247
252
  <section_context_rule>
248
- Context parameter is DEFAULT for all interactions. ALWAYS use container from UI map sections unless locator is XPath or unique ID.
253
+ Use a container from UI map sections when the target may match several elements. A container that is
254
+ wrong or stale guarantees failure, so prefer a verified one and keep a containerless fallback.
249
255
 
250
256
  1. Identify which section contains the target element
251
257
  2. Get the Context Locator from that section in the UI map
@@ -263,9 +269,10 @@ export const sectionContextRule = dedent`
263
269
  For CSS locators - prepend section context:
264
270
  - I.click('.main button.submit') // instead of I.click('button.submit')
265
271
 
266
- Only omit context when:
272
+ Omit context when the locator already resolves to one element:
267
273
  - Locator is XPath (already includes path context)
268
274
  - Locator is a unique ID (#specific-element)
275
+ - Text or ARIA that appears only once on the page
269
276
  </section_context_rule>
270
277
 
271
278
  ${unexpectedPopupRule}