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
@@ -2,8 +2,9 @@ import { tool } from 'ai';
2
2
  import dedent from 'dedent';
3
3
  import { z } from 'zod';
4
4
  import { ActionResult } from "../action-result.js";
5
+ import { Stats } from "../stats.js";
5
6
  import { TestResult } from '../test-plan.js';
6
- import { LARGE_ARIA_CHANGE_THRESHOLD, extractFocusedElement } from "../utils/aria.js";
7
+ import { LARGE_ARIA_CHANGE_THRESHOLD } from "../utils/aria.js";
7
8
  import { isFatalBrowserError } from "../utils/browser-errors.js";
8
9
  import { createDebug, tag } from '../utils/logger.js';
9
10
  import { pause } from '../utils/loop.js';
@@ -18,6 +19,10 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
18
19
  description: dedent `
19
20
  Click an element by trying multiple CodeceptJS commands in order until one succeeds.
20
21
 
22
+ Use this only for elements the page context gives you no ref for. When the element shows a ref such as [ref=e14],
23
+ call clickRef with that ref instead — composing a locator for an element that already has a ref is wasted work,
24
+ and a locator can match several elements where a ref cannot.
25
+
21
26
  Follow <locator_priority> from system prompt for locator selection.
22
27
 
23
28
  I.click(locator) - click element matching locator
@@ -36,14 +41,13 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
36
41
  commands: z.array(z.string()).describe(dedent `
37
42
  FALLBACK LOCATORS for ONE element. All commands must click the SAME element.
38
43
  Never mix different elements — use separate click() calls instead.
44
+ REQUIRED: include at least one command WITHOUT a container — a wrong or stale container always fails.
39
45
  Order by reliability:
40
- 1. I.click(text, container) - PREFERRED when container is known - e.g. I.click("Save", ".modal")
46
+ 1. I.click(text, container) - when the container is verified - e.g. I.click("Save", ".modal")
41
47
  2. I.click(ARIA, container) - e.g. I.click({"role":"button","text":"Save"}, ".modal")
42
48
  3. I.click(CSS, container) - e.g. I.click("#btn", ".modal")
43
49
  4. I.click(CSS) or I.click(XPath) - when locator already includes context (ID, XPath)
44
50
  5. I.clickXY(x, y) - coordinates fallback
45
- IMPORTANT: Always include at least one command WITHOUT a container as fallback,
46
- in case the element moved to a different section (e.g. I.click("Save") without container).
47
51
  `),
48
52
  explanation: z.string().describe('Why you are clicking this element'),
49
53
  }),
@@ -106,14 +110,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
106
110
  }
107
111
  const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, commands[0]);
108
112
  await commitNote(activeNote, TestResult.FAILED, toolResult, action);
109
- let suggestion = "Try xpathCheck() to find the element's actual position, see() for visual analysis, or visualClick() to click by visual appearance.";
110
- const lastError = attempts[attempts.length - 1]?.error || '';
111
- if (lastError.includes('was not found') || lastError.includes('not found by text')) {
112
- suggestion = 'Element was not found in the DOM. Use xpathCheck() to locate it, context() to refresh snapshot, or visualClick() to click by visual appearance.';
113
- }
114
- else if (lastError.includes('Timeout') || lastError.includes('intercept')) {
115
- suggestion = 'Element exists but could not be clicked (possibly covered by overlay or not interactable). Try closing overlapping panels first, or use visualClick().';
116
- }
113
+ const suggestion = clickFailureSuggestion(attempts);
117
114
  return failedToolResult('click', 'All click commands failed', {
118
115
  ...toolResult,
119
116
  attempts,
@@ -121,6 +118,38 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
121
118
  }, action.lastError);
122
119
  },
123
120
  }),
121
+ clickRef: tool({
122
+ description: dedent `
123
+ Click an element by the ref the page context gave it, e.g. [ref=e14].
124
+
125
+ Prefer this over click() whenever the element you want carries a ref. A ref names one exact element, so it
126
+ cannot match several by mistake and never needs disambiguating — it is the fastest way to click.
127
+ Only pass a ref that appears in the page context you were given. Never invent or guess one.
128
+ If it reports the ref is gone, the page has been rebuilt: get fresh context and use the new ref.
129
+ `,
130
+ inputSchema: z.object({
131
+ ref: z.string().describe('The ref exactly as it appears in the page context, e.g. "e14"'),
132
+ element: z.string().describe('Role and name of the element you are clicking, for the record'),
133
+ }),
134
+ execute: async ({ ref, element }) => {
135
+ const activeNote = task.startNote(`Click ${element}`);
136
+ const previousState = ActionResult.fromState(stateManager.getCurrentState());
137
+ const action = explorer.action();
138
+ const named = await describeRef(explorer, ref);
139
+ const run = `I.usePlaywrightTo(${JSON.stringify(`click ${element}`)}, async ({ page }) => page.locator(${JSON.stringify(`aria-ref=${ref}`)}).click())`;
140
+ if (!(await action.attempt(run, `Click ${element}`))) {
141
+ activeNote.commit(TestResult.FAILED);
142
+ return failedToolResult('clickRef', `Ref ${ref} could not be clicked: ${errorText(action.lastError)}`, {
143
+ suggestion: 'The ref may belong to an older version of the page. Get fresh context and use the ref it gives, or fall back to click() with a locator.',
144
+ });
145
+ }
146
+ // a ref belongs to this session only, so the run is reported as the locator a later test can replay
147
+ const code = named ? `I.click(${JSON.stringify(named)})` : run;
148
+ const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, code);
149
+ await commitNote(activeNote, TestResult.PASSED, toolResult, action);
150
+ return successToolResult('clickRef', { ...toolResult, code }, action);
151
+ },
152
+ }),
124
153
  hover: tool({
125
154
  description: dedent `
126
155
  Move the mouse cursor to an element to reveal hover-only controls.
@@ -245,15 +274,11 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
245
274
  }
246
275
  const focusFreeKeys = new Set(['Escape', 'Esc', 'Tab', 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12']);
247
276
  const needsFocus = !focusFreeKeys.has(keyToUse) && !modifier;
248
- if (needsFocus) {
249
- const currentAriaState = stateManager.getCurrentState()?.ariaSnapshot;
250
- const focused = extractFocusedElement(currentAriaState ?? null);
251
- if (!focused) {
252
- activeNote.commit(TestResult.FAILED);
253
- return failedToolResult('pressKey', `No element is focused. Key '${keyToUse}' requires a focused element.`, {
254
- suggestion: 'Click the target element first, then press the key.',
255
- });
256
- }
277
+ if (needsFocus && !(await hasFocusedElement(explorer))) {
278
+ activeNote.commit(TestResult.FAILED);
279
+ return failedToolResult('pressKey', `No element is focused. Key '${keyToUse}' requires a focused element.`, {
280
+ suggestion: 'Click the target element first, then press the key.',
281
+ });
257
282
  }
258
283
  const previousState = ActionResult.fromState(stateManager.getCurrentState());
259
284
  const action = explorer.action();
@@ -296,6 +321,8 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
296
321
  Execute raw CodeceptJS code block with multiple commands.
297
322
  USE THIS TOOL for typing text into fields: I.fillField, I.type
298
323
 
324
+ Do not put a click on a ref-bearing element in here — clickRef with its ref is cheaper and cannot mis-target.
325
+
299
326
  Follow <actions> from system prompt for available commands.
300
327
  Follow <locator_priority> from system prompt for locator selection.
301
328
 
@@ -456,25 +483,25 @@ export function createLearnExperienceTool({ getExperienceTracker, getState }) {
456
483
  });
457
484
  }
458
485
  export function createAgentTools({ explorer, stateManager, ai, researcher, navigator, supervisor, withExperience }) {
459
- let visionDisabled = false;
460
486
  const tools = {
461
487
  see: tool({
462
488
  description: dedent `
463
- Check the page contents based on current page state and screenshot.
464
- This tool will trigger visual research to check the page contents on request.
465
- Use it to verify the actions were performed correctly and the page is in the expected state.
489
+ Answer a question about the page from a screenshot, for things its structure cannot express:
490
+ layout and position, what an image or canvas depicts, colour, and whether something is covered or cut off.
491
+ This runs a second model and is the slowest tool here, so reach for it only when the question is genuinely visual.
492
+ Do NOT use it to confirm an action landed — every action already reports what changed on the page.
466
493
  Input schema has exactly one field: request. Do not pass text, reason, assertion, or other fields.
467
494
 
468
495
  <example>
469
- request: "Check current state of the Login form"
470
- result: "Login form is visible with username and password fields, username is filled with 'testuser' and password is empty'
496
+ request: "Is the save button covered by anything, and does the chart show any plotted data?"
497
+ result: "The save button is partly behind a cookie banner at the bottom. The chart area is empty apart from its axes."
471
498
  </example>
472
499
  `,
473
500
  inputSchema: z.object({
474
501
  request: z.string().describe('LLM-friendly description of the page contents to look for. 1-3 sentences. No more than 100 words.'),
475
502
  }),
476
503
  execute: async ({ request }) => {
477
- if (visionDisabled) {
504
+ if (Stats.visionDisabled) {
478
505
  return failedToolResult('see', 'Vision tools are disabled for this session. Use context() to get fresh ARIA snapshot and analyze page state from ARIA data.');
479
506
  }
480
507
  try {
@@ -495,8 +522,7 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
495
522
  catch (error) {
496
523
  throwIfFatalBrowserError(error);
497
524
  const errorMessage = errorText(error);
498
- visionDisabled = true;
499
- tag('warning').log('⚠️ Vision model is not available. Visual checks are disabled for this session.');
525
+ disableVision();
500
526
  return failedToolResult('see', `See tool failed: ${errorMessage}`, {
501
527
  suggestion: 'Vision is now disabled. Use context() to get fresh ARIA snapshot and analyze page state from ARIA data.',
502
528
  });
@@ -576,6 +602,12 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
576
602
  code: result.successfulCodes.join('\n'),
577
603
  }, { assertionSteps: result.assertionSteps });
578
604
  }
605
+ if (result.inexpressible) {
606
+ return failedToolResult('verify', `No assertion could express this claim: ${assertion}`, {
607
+ inexpressible: true,
608
+ suggestion: 'This is not evidence the page is wrong — the claim could not be turned into an assertion. Restate it in terms of what is visible or of a control state, or check it with see().',
609
+ });
610
+ }
579
611
  return failedToolResult('verify', `Verification failed: ${assertion}`, {
580
612
  suggestion: 'The assertion could not be verified. Check if the condition is actually present on the page or try a different assertion.',
581
613
  });
@@ -696,7 +728,7 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
696
728
  context: z.string().describe('What you already tried and why it failed - helps with accurate identification'),
697
729
  }),
698
730
  execute: async ({ element, context }) => {
699
- if (visionDisabled) {
731
+ if (Stats.visionDisabled) {
700
732
  return failedToolResult('visualClick', 'Vision tools are disabled for this session. Use xpathCheck() to find the element, then click() with the discovered locator.');
701
733
  }
702
734
  try {
@@ -741,8 +773,7 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
741
773
  catch (error) {
742
774
  throwIfFatalBrowserError(error);
743
775
  const errorMessage = errorText(error);
744
- visionDisabled = true;
745
- tag('warning').log('⚠️ Vision model is not available. Visual clicks are disabled for this session.');
776
+ disableVision();
746
777
  return failedToolResult('visualClick', `visualClick tool failed: ${errorMessage}`, {
747
778
  suggestion: 'Vision is now disabled. Use xpathCheck() to find the element, then click() with the discovered locator.',
748
779
  });
@@ -875,6 +906,11 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
875
906
  },
876
907
  }),
877
908
  };
909
+ const disableVision = () => {
910
+ Stats.visionDisabled = true;
911
+ withdrawVisionTools(tools);
912
+ tag('warning').log('⚠️ Vision model is not available. Visual tools are disabled for this session.');
913
+ };
878
914
  if (withExperience !== false) {
879
915
  tools.learnExperience = createLearnExperienceTool({
880
916
  getExperienceTracker: () => stateManager.getExperienceTracker(),
@@ -921,6 +957,7 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
921
957
  },
922
958
  });
923
959
  }
960
+ withdrawVisionTools(tools);
924
961
  return tools;
925
962
  }
926
963
  const PAGE_DIFF_SUGGESTION = 'Analyze page diff. htmlParts shows what changed and WHERE — each part has a container selector. Use the container as context when clicking elements from the diff.';
@@ -964,13 +1001,27 @@ function errorText(error) {
964
1001
  return error.toString();
965
1002
  return 'Unknown error occurred';
966
1003
  }
967
- async function commitNote(activeNote, result, toolResult, action) {
1004
+ export async function commitNote(activeNote, result, toolResult, action) {
968
1005
  if (toolResult?.pageDiff?.ariaChanges || toolResult?.pageDiff?.urlChanged) {
969
1006
  activeNote.screenshot = await action.saveScreenshot();
970
1007
  }
971
1008
  activeNote.commit(result);
972
1009
  }
973
- function successToolResult(action, data, source) {
1010
+ async function describeRef(explorer, ref) {
1011
+ return Promise.resolve(explorer?.withPage?.((page) => page.locator(`aria-ref=${ref}`).evaluate((el) => {
1012
+ const tag = el.tagName.toLowerCase();
1013
+ const roles = { a: 'link', button: 'button', select: 'combobox', textarea: 'textbox' };
1014
+ const role = el.getAttribute('role') || roles[tag] || tag;
1015
+ const text = (el.getAttribute('aria-label') || el.innerText || el.value || '').trim().split('\n')[0];
1016
+ if (!text)
1017
+ return null;
1018
+ return { role, text };
1019
+ }))).catch(() => null);
1020
+ }
1021
+ async function hasFocusedElement(explorer) {
1022
+ return explorer.withPage((page) => page.evaluate(() => !!document.activeElement && document.activeElement !== document.body)).catch(() => true);
1023
+ }
1024
+ export function successToolResult(action, data, source) {
974
1025
  const result = { success: true, action, ...data };
975
1026
  if (source?.playwrightGroupId) {
976
1027
  result.playwrightGroupId = source.playwrightGroupId;
@@ -1008,7 +1059,7 @@ function hasObservablePageChange(data) {
1008
1059
  return true;
1009
1060
  return Array.isArray(data.pageDiff.htmlParts) && data.pageDiff.htmlParts.length > 0;
1010
1061
  }
1011
- async function failedToolResult(action, message, data, error) {
1062
+ export async function failedToolResult(action, message, data, error) {
1012
1063
  const result = { success: false, action, message, ...data };
1013
1064
  if (data?.pageDiff) {
1014
1065
  result.suggestion = data.suggestion ? `${data.suggestion} ${PAGE_DIFF_SUGGESTION}` : PAGE_DIFF_SUGGESTION;
@@ -1040,6 +1091,32 @@ function getMultipleElementsSuggestion() {
1040
1091
  6. Use visualClick() to click the right element by visual appearance
1041
1092
  `;
1042
1093
  }
1094
+ export function withdrawVisionTools(tools) {
1095
+ if (!Stats.visionDisabled)
1096
+ return;
1097
+ Reflect.deleteProperty(tools, 'see');
1098
+ Reflect.deleteProperty(tools, 'visualClick');
1099
+ }
1100
+ export function clickFailureSuggestion(attempts) {
1101
+ const errors = attempts.map((a) => a.error || '');
1102
+ if (errors.some((e) => e.includes('not enabled'))) {
1103
+ return 'Element exists but is DISABLED — clicking it again cannot work. A precondition is unmet: a required field is empty, nothing is selected, or a dialog is blocking. Satisfy it, then retry.';
1104
+ }
1105
+ if (errors.some((e) => e.includes('intercepts pointer events'))) {
1106
+ return 'Element exists but another element covers it. Close the overlapping panel or dialog, then retry.';
1107
+ }
1108
+ if (errors.some((e) => e.includes('is not visible'))) {
1109
+ return 'Element is in the DOM but not visible. Reveal it first — scroll to it, expand its section, or open the panel holding it.';
1110
+ }
1111
+ const notFound = errors.filter((e) => e.includes('was not found'));
1112
+ if (notFound.length && notFound.every((e) => e.includes('was not found inside element'))) {
1113
+ return 'Element was not found inside that container — the container is wrong or stale, and the element may exist elsewhere on the page. Retry the same locator WITHOUT a container, or verify the container with xpathCheck().';
1114
+ }
1115
+ if (notFound.length) {
1116
+ return 'Element was not found in the DOM. Use xpathCheck() to locate it, context() to refresh snapshot, or visualClick() to click by visual appearance.';
1117
+ }
1118
+ return "Try xpathCheck() to find the element's actual position, see() for visual analysis, or visualClick() to click by visual appearance.";
1119
+ }
1043
1120
  const MAX_DISAMBIGUATE_ELEMENTS = 10;
1044
1121
  const MULTIPLE_ELEMENTS_PATTERN = 'multiple elements';
1045
1122
  async function extractWebElements(error) {
@@ -0,0 +1,8 @@
1
+ import { z } from 'zod';
2
+ export declare const APPLICATION_SPEC_FORMAT = "explorbot-application-spec";
3
+ export declare const APPLICATION_SPEC_VERSION = 1;
4
+ export declare const APPLICATION_SPEC_PAGE_SCHEMA: z.ZodObject<{
5
+ format: z.ZodLiteral<"explorbot-application-spec">;
6
+ version: z.ZodLiteral<1>;
7
+ url: z.ZodString;
8
+ }, z.core.$strip>;
@@ -0,0 +1,8 @@
1
+ import { z } from 'zod';
2
+ export const APPLICATION_SPEC_FORMAT = 'explorbot-application-spec';
3
+ export const APPLICATION_SPEC_VERSION = 1;
4
+ export const APPLICATION_SPEC_PAGE_SCHEMA = z.object({
5
+ format: z.literal(APPLICATION_SPEC_FORMAT),
6
+ version: z.literal(APPLICATION_SPEC_VERSION),
7
+ url: z.string().trim().min(1),
8
+ });
@@ -0,0 +1,15 @@
1
+ import { ActionResult } from './action-result.js';
2
+ export declare class ApplicationSpec {
3
+ pages: ApplicationSpecPage[];
4
+ readonly sourcePath: string;
5
+ constructor(sourcePath: string);
6
+ renderFor(state: ActionResult): string;
7
+ get pageCount(): number;
8
+ load(): void;
9
+ resolveSourcePath(sourcePath: string): string;
10
+ }
11
+ interface ApplicationSpecPage {
12
+ url: string;
13
+ content: string;
14
+ }
15
+ export {};
@@ -0,0 +1,71 @@
1
+ import { existsSync, statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import dedent from 'dedent';
4
+ import { APPLICATION_SPEC_PAGE_SCHEMA } from "./application-spec-contract.js";
5
+ import { ConfigParser } from "./config.js";
6
+ import { tag } from "./utils/logger.js";
7
+ import { loadMarkdownFiles } from "./utils/markdown-files.js";
8
+ export class ApplicationSpec {
9
+ pages = [];
10
+ sourcePath;
11
+ constructor(sourcePath) {
12
+ this.sourcePath = this.resolveSourcePath(sourcePath);
13
+ this.load();
14
+ }
15
+ renderFor(state) {
16
+ const relevant = this.pages.filter((page) => state.isMatchedBy({ url: page.url }));
17
+ if (relevant.length === 0)
18
+ return '';
19
+ tag('operation').log(`Found application specification for ${state.url}`);
20
+ return dedent `
21
+ <application_spec>
22
+ This is previously collected application documentation. Treat User Can and observed state transitions as supporting context, not as a replacement for the current page state. Treat User Might as unverified possibilities that must be confirmed before use.
23
+
24
+ ${relevant.map((page) => page.content).join('\n\n')}
25
+ </application_spec>
26
+ `;
27
+ }
28
+ get pageCount() {
29
+ return this.pages.length;
30
+ }
31
+ load() {
32
+ if (!existsSync(this.sourcePath)) {
33
+ throw new Error(`Application spec not found: ${this.sourcePath}`);
34
+ }
35
+ const isDirectory = statSync(this.sourcePath).isDirectory();
36
+ if (!isDirectory && path.basename(this.sourcePath).toLowerCase() !== 'index.md') {
37
+ throw new Error(`Application spec file must be index.md: ${this.sourcePath}`);
38
+ }
39
+ const bundlePath = isDirectory ? this.sourcePath : path.dirname(this.sourcePath);
40
+ const indexPath = path.join(bundlePath, 'index.md');
41
+ if (!existsSync(indexPath)) {
42
+ throw new Error(`Application spec index not found: ${indexPath}`);
43
+ }
44
+ const pagesPath = path.join(bundlePath, 'pages');
45
+ if (!existsSync(pagesPath)) {
46
+ throw new Error(`Application spec pages directory not found: ${pagesPath}`);
47
+ }
48
+ for (const file of loadMarkdownFiles(pagesPath, { recursive: true })) {
49
+ const parsed = APPLICATION_SPEC_PAGE_SCHEMA.safeParse(file.data);
50
+ if (!parsed.success && parsed.error.issues.some((issue) => issue.path[0] === 'format')) {
51
+ throw new Error(`Invalid application spec format in ${file.filePath}`);
52
+ }
53
+ if (!parsed.success && parsed.error.issues.some((issue) => issue.path[0] === 'version')) {
54
+ throw new Error(`Unsupported application spec version in ${file.filePath}: ${String(file.data.version)}`);
55
+ }
56
+ if (!parsed.success) {
57
+ throw new Error(`Application spec page URL is missing in ${file.filePath}`);
58
+ }
59
+ this.pages.push({ url: parsed.data.url, content: file.content.trim() });
60
+ }
61
+ if (this.pages.length === 0) {
62
+ throw new Error(`Application spec contains no documented pages: ${pagesPath}`);
63
+ }
64
+ }
65
+ resolveSourcePath(sourcePath) {
66
+ if (path.isAbsolute(sourcePath))
67
+ return path.resolve(sourcePath);
68
+ const configParser = ConfigParser.getInstance();
69
+ return path.resolve(configParser.resolveProjectDir(sourcePath));
70
+ }
71
+ }
@@ -1,10 +1,16 @@
1
- declare function getEndpointFilePath(): string;
2
- declare function readEndpoint(): string | null;
3
- declare function removeEndpointFile(): void;
1
+ declare function getEndpointFilePath(instance?: string): string;
2
+ declare function readEndpoint(instance?: string): string | null;
3
+ declare function removeEndpointFile(instance?: string): void;
4
+ declare function listInstances(): Array<{
5
+ name: string;
6
+ endpoint: string;
7
+ }>;
4
8
  declare function isServerRunning(wsEndpoint: string): Promise<boolean>;
5
9
  declare function launchServer(opts: {
6
10
  browser?: string;
7
11
  show?: boolean;
8
- }): Promise<any>;
9
- declare function getAliveEndpoint(): Promise<string | null>;
10
- export { readEndpoint, removeEndpointFile, isServerRunning, launchServer, getEndpointFilePath, getAliveEndpoint };
12
+ }, instance?: string): Promise<any>;
13
+ declare function stopServer(instance?: string): Promise<boolean>;
14
+ declare function keepServerRunning(stop: () => unknown): Promise<never>;
15
+ declare function getAliveEndpoint(instance?: string): Promise<string | null>;
16
+ export { readEndpoint, removeEndpointFile, isServerRunning, launchServer, stopServer, getEndpointFilePath, getAliveEndpoint, listInstances, keepServerRunning };
@@ -1,35 +1,62 @@
1
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { chromium, firefox, webkit } from 'playwright-core';
3
+ import { chromium, firefox, webkit } from 'playwright';
4
4
  import { ConfigParser } from './config.js';
5
5
  import { getCliName } from "./utils/cli-name.js";
6
6
  import { log } from './utils/logger.js';
7
7
  import { printNextSteps } from "./utils/next-steps.js";
8
8
  const ENDPOINT_FILENAME = '.browser-endpoint';
9
- function getEndpointFilePath() {
9
+ const INSTANCE_NAME_PATTERN = /^[a-z0-9-]+$/;
10
+ const KEEP_ALIVE_INTERVAL = 1 << 30;
11
+ function getEndpointFilePath(instance = 'default') {
12
+ if (!INSTANCE_NAME_PATTERN.test(instance)) {
13
+ throw new Error(`Invalid browser instance name: "${instance}". Use lowercase letters, digits and dashes.`);
14
+ }
10
15
  const configParser = ConfigParser.getInstance();
11
16
  const outputDir = configParser.getOutputDir();
12
- return path.join(outputDir, ENDPOINT_FILENAME);
17
+ if (instance === 'default')
18
+ return path.join(outputDir, ENDPOINT_FILENAME);
19
+ return path.join(outputDir, `${ENDPOINT_FILENAME}-${instance}`);
13
20
  }
14
- function readEndpoint() {
15
- const filePath = getEndpointFilePath();
21
+ function readEndpoint(instance = 'default') {
22
+ const filePath = getEndpointFilePath(instance);
16
23
  if (!existsSync(filePath))
17
24
  return null;
18
25
  return readFileSync(filePath, 'utf8').trim();
19
26
  }
20
- function writeEndpoint(wsEndpoint) {
21
- const filePath = getEndpointFilePath();
27
+ function writeEndpoint(wsEndpoint, instance = 'default') {
28
+ const filePath = getEndpointFilePath(instance);
22
29
  const dir = path.dirname(filePath);
23
30
  if (!existsSync(dir)) {
24
31
  mkdirSync(dir, { recursive: true });
25
32
  }
26
33
  writeFileSync(filePath, wsEndpoint, 'utf8');
27
34
  }
28
- function removeEndpointFile() {
29
- const filePath = getEndpointFilePath();
35
+ function removeEndpointFile(instance = 'default') {
36
+ const filePath = getEndpointFilePath(instance);
30
37
  if (existsSync(filePath))
31
38
  unlinkSync(filePath);
32
39
  }
40
+ function listInstances() {
41
+ const dir = path.dirname(getEndpointFilePath());
42
+ if (!existsSync(dir))
43
+ return [];
44
+ const instances = [];
45
+ for (const fileName of readdirSync(dir)) {
46
+ if (!fileName.startsWith(ENDPOINT_FILENAME))
47
+ continue;
48
+ const suffix = fileName.slice(ENDPOINT_FILENAME.length);
49
+ if (suffix && !suffix.startsWith('-'))
50
+ continue;
51
+ if (suffix === '-')
52
+ continue;
53
+ const name = suffix.slice(1) || 'default';
54
+ if (!INSTANCE_NAME_PATTERN.test(name))
55
+ continue;
56
+ instances.push({ name, endpoint: readFileSync(path.join(dir, fileName), 'utf8').trim() });
57
+ }
58
+ return instances;
59
+ }
33
60
  async function isServerRunning(wsEndpoint) {
34
61
  try {
35
62
  const browser = await chromium.connect(wsEndpoint, { timeout: 3000 });
@@ -41,7 +68,7 @@ async function isServerRunning(wsEndpoint) {
41
68
  }
42
69
  }
43
70
  const BROWSER_LAUNCHERS = { chromium, firefox, webkit };
44
- async function launchServer(opts) {
71
+ async function launchServer(opts, instance = 'default') {
45
72
  const browserName = (opts.browser || 'chromium');
46
73
  const launcher = BROWSER_LAUNCHERS[browserName];
47
74
  if (!launcher)
@@ -50,30 +77,58 @@ async function launchServer(opts) {
50
77
  headless: !opts.show,
51
78
  });
52
79
  const wsEndpoint = server.wsEndpoint();
53
- writeEndpoint(wsEndpoint);
80
+ writeEndpoint(wsEndpoint, instance);
54
81
  log(`Browser server started: ${browserName} (${opts.show ? 'headed' : 'headless'})`);
55
82
  const cli = getCliName();
83
+ let instanceFlag = '';
84
+ if (instance !== 'default')
85
+ instanceFlag = ` --instance ${instance}`;
56
86
  const sections = [
57
87
  {
58
88
  label: 'Browser server',
59
- path: getEndpointFilePath(),
89
+ path: getEndpointFilePath(instance),
60
90
  commands: [
61
91
  { label: 'Endpoint', command: wsEndpoint },
62
- { label: 'Status', command: `${cli} browser status` },
63
- { label: 'Stop', command: `${cli} browser stop` },
92
+ { label: 'Status', command: `${cli} browser status${instanceFlag}` },
93
+ { label: 'Stop', command: `${cli} browser stop${instanceFlag}` },
64
94
  ],
65
95
  },
66
96
  ];
67
97
  printNextSteps(sections);
68
98
  return server;
69
99
  }
70
- async function getAliveEndpoint() {
71
- const endpoint = readEndpoint();
100
+ async function stopServer(instance = 'default') {
101
+ const endpoint = await getAliveEndpoint(instance);
102
+ if (!endpoint)
103
+ return false;
104
+ try {
105
+ const browser = await chromium.connect(endpoint, { timeout: 3000 });
106
+ await browser.close();
107
+ }
108
+ catch { }
109
+ removeEndpointFile(instance);
110
+ return true;
111
+ }
112
+ function keepServerRunning(stop) {
113
+ console.log('Browser server is running. Press Ctrl+C to stop.');
114
+ const heartbeat = setInterval(() => { }, KEEP_ALIVE_INTERVAL);
115
+ const cleanup = async () => {
116
+ console.log('\nStopping browser server...');
117
+ clearInterval(heartbeat);
118
+ await stop();
119
+ process.exit(0);
120
+ };
121
+ process.on('SIGINT', cleanup);
122
+ process.on('SIGTERM', cleanup);
123
+ return new Promise(() => { });
124
+ }
125
+ async function getAliveEndpoint(instance = 'default') {
126
+ const endpoint = readEndpoint(instance);
72
127
  if (!endpoint)
73
128
  return null;
74
129
  if (await isServerRunning(endpoint))
75
130
  return endpoint;
76
- removeEndpointFile();
131
+ removeEndpointFile(instance);
77
132
  return null;
78
133
  }
79
- export { readEndpoint, removeEndpointFile, isServerRunning, launchServer, getEndpointFilePath, getAliveEndpoint };
134
+ export { readEndpoint, removeEndpointFile, isServerRunning, launchServer, stopServer, getEndpointFilePath, getAliveEndpoint, listInstances, keepServerRunning };
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readdirSync, rmSync, statSync, unlinkSync } from 'node:fs';
2
- import { dirname, join } from 'node:path';
2
+ import { join } from 'node:path';
3
3
  import { ConfigParser, outputPath } from '../config.js';
4
4
  import { tag } from '../utils/logger.js';
5
5
  import { BaseCommand } from './base-command.js';
@@ -13,12 +13,7 @@ export const CLEAN_TARGETS = {
13
13
  };
14
14
  function getExperienceDir() {
15
15
  const configParser = ConfigParser.getInstance();
16
- const config = configParser.getConfig();
17
- const configPath = configParser.getConfigPath();
18
- if (configPath) {
19
- return join(dirname(configPath), config.dirs?.experience || 'experience');
20
- }
21
- return config.dirs?.experience || 'experience';
16
+ return configParser.resolveProjectDir(configParser.getConfig().dirs?.experience || 'experience');
22
17
  }
23
18
  function cleanDirectoryContents(dirPath) {
24
19
  if (!existsSync(dirPath))
@@ -1,7 +1,12 @@
1
+ export declare function runInit(options: InitCommandOptions): Promise<void>;
2
+ export declare function writeGlobalConfig(provider: string, apiKey?: string): void;
1
3
  export declare function runInitCommand(options: InitCommandOptions): void;
2
4
  type InitCommandOptions = {
3
5
  configPath?: string;
4
6
  force?: boolean;
5
7
  path?: string;
8
+ global?: boolean;
9
+ provider?: string;
10
+ apiKey?: string;
6
11
  };
7
12
  export {};