explorbot 0.2.4 → 0.3.0

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 (113) hide show
  1. package/bin/explorbot-cli.ts +19 -7
  2. package/boat/api-tester/src/cli.ts +17 -0
  3. package/boat/doc-collector/src/cli.ts +14 -1
  4. package/boat/prima/README.md +96 -0
  5. package/boat/prima/package.json +14 -10
  6. package/boat/prima/src/cli.ts +29 -12
  7. package/boat/prima/src/envelope.ts +35 -13
  8. package/boat/prima/src/prima.ts +78 -45
  9. package/dist/bin/explorbot-cli.js +19 -7
  10. package/dist/boat/api-tester/src/cli.js +17 -0
  11. package/dist/boat/doc-collector/src/cli.js +14 -1
  12. package/dist/boat/prima/src/cli.js +26 -7
  13. package/dist/boat/prima/src/envelope.js +32 -8
  14. package/dist/boat/prima/src/prima.js +75 -43
  15. package/dist/models.json +4 -4
  16. package/dist/package.json +6 -2
  17. package/dist/src/action-result.d.ts +13 -0
  18. package/dist/src/action-result.js +46 -15
  19. package/dist/src/action.d.ts +5 -2
  20. package/dist/src/action.js +53 -18
  21. package/dist/src/ai/captain/web-mode.js +1 -2
  22. package/dist/src/ai/captain.d.ts +20 -0
  23. package/dist/src/ai/captain.js +10 -1
  24. package/dist/src/ai/driller.js +6 -2
  25. package/dist/src/ai/fisherman-tools.d.ts +40 -1
  26. package/dist/src/ai/fisherman-tools.js +39 -0
  27. package/dist/src/ai/fisherman.js +2 -1
  28. package/dist/src/ai/navigator.d.ts +28 -0
  29. package/dist/src/ai/navigator.js +223 -175
  30. package/dist/src/ai/pilot.d.ts +7 -4
  31. package/dist/src/ai/pilot.js +89 -30
  32. package/dist/src/ai/planner/subpages.js +2 -16
  33. package/dist/src/ai/planner.js +1 -1
  34. package/dist/src/ai/provider.d.ts +2 -2
  35. package/dist/src/ai/provider.js +28 -22
  36. package/dist/src/ai/researcher/cache.d.ts +10 -3
  37. package/dist/src/ai/researcher/cache.js +23 -10
  38. package/dist/src/ai/researcher/deep-analysis.js +1 -1
  39. package/dist/src/ai/researcher/fingerprint-worker.js +21 -4
  40. package/dist/src/ai/researcher.js +6 -4
  41. package/dist/src/ai/rules.js +1 -5
  42. package/dist/src/ai/session-analyst.js +2 -0
  43. package/dist/src/ai/tester.d.ts +6 -3
  44. package/dist/src/ai/tester.js +30 -35
  45. package/dist/src/ai/tools.d.ts +8 -5
  46. package/dist/src/ai/tools.js +83 -57
  47. package/dist/src/commands/config-command.d.ts +51 -0
  48. package/dist/src/commands/config-command.js +117 -0
  49. package/dist/src/commands/index.js +2 -0
  50. package/dist/src/commands/init-command.js +13 -20
  51. package/dist/src/config.d.ts +8 -1
  52. package/dist/src/config.js +43 -1
  53. package/dist/src/experience-tracker.d.ts +2 -0
  54. package/dist/src/experience-tracker.js +12 -0
  55. package/dist/src/explorbot.js +5 -2
  56. package/dist/src/playwright-recorder.js +6 -12
  57. package/dist/src/remote.d.ts +3 -2
  58. package/dist/src/remote.js +8 -2
  59. package/dist/src/state-manager.d.ts +1 -1
  60. package/dist/src/state-manager.js +3 -1
  61. package/dist/src/test-plan.d.ts +9 -0
  62. package/dist/src/test-plan.js +30 -0
  63. package/dist/src/utils/html-diff.d.ts +5 -0
  64. package/dist/src/utils/html-diff.js +65 -6
  65. package/dist/src/utils/logger.d.ts +1 -1
  66. package/dist/src/utils/logger.js +8 -0
  67. package/dist/src/utils/strings.d.ts +2 -0
  68. package/dist/src/utils/strings.js +32 -0
  69. package/dist/src/utils/url-matcher.d.ts +1 -0
  70. package/dist/src/utils/url-matcher.js +31 -2
  71. package/docs/basics/getting-started.md +33 -10
  72. package/docs/basics/providers.md +6 -4
  73. package/docs/contributing/npm-package.md +73 -4
  74. package/docs/index.json +2 -1
  75. package/docs/reference/commands.md +3 -0
  76. package/docs/reference/websocket.md +50 -0
  77. package/docs/superpowers/specs/2026-08-18-prima-false-verdicts.md +159 -0
  78. package/models.json +4 -4
  79. package/package.json +6 -2
  80. package/src/action-result.ts +61 -16
  81. package/src/action.ts +56 -18
  82. package/src/ai/captain/web-mode.ts +1 -2
  83. package/src/ai/captain.ts +9 -1
  84. package/src/ai/driller.ts +6 -2
  85. package/src/ai/fisherman-tools.ts +35 -0
  86. package/src/ai/fisherman.ts +2 -1
  87. package/src/ai/navigator.ts +238 -179
  88. package/src/ai/pilot.ts +104 -36
  89. package/src/ai/planner/subpages.ts +2 -13
  90. package/src/ai/planner.ts +1 -1
  91. package/src/ai/provider.ts +29 -21
  92. package/src/ai/researcher/cache.ts +29 -11
  93. package/src/ai/researcher/deep-analysis.ts +1 -1
  94. package/src/ai/researcher/fingerprint-worker.ts +23 -5
  95. package/src/ai/researcher.ts +6 -4
  96. package/src/ai/rules.ts +1 -5
  97. package/src/ai/session-analyst.ts +2 -0
  98. package/src/ai/tester.ts +33 -34
  99. package/src/ai/tools.ts +88 -61
  100. package/src/commands/config-command.ts +146 -0
  101. package/src/commands/index.ts +2 -0
  102. package/src/commands/init-command.ts +14 -20
  103. package/src/config.ts +47 -2
  104. package/src/experience-tracker.ts +13 -0
  105. package/src/explorbot.ts +4 -2
  106. package/src/playwright-recorder.ts +6 -11
  107. package/src/remote.ts +8 -2
  108. package/src/state-manager.ts +5 -2
  109. package/src/test-plan.ts +38 -0
  110. package/src/utils/html-diff.ts +72 -7
  111. package/src/utils/logger.ts +9 -1
  112. package/src/utils/strings.ts +36 -0
  113. package/src/utils/url-matcher.ts +27 -2
@@ -2,11 +2,13 @@ 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 { renderExperienceRecipes } from "../experience-tracker.js";
5
6
  import { Stats } from "../stats.js";
6
7
  import { TestResult } from '../test-plan.js';
7
8
  import { LARGE_ARIA_CHANGE_THRESHOLD } from "../utils/aria.js";
8
9
  import { isFatalBrowserError } from "../utils/browser-errors.js";
9
10
  import { createDebug, tag } from '../utils/logger.js';
11
+ import { compactErrorMessage } from "../utils/strings.js";
10
12
  import { pause } from '../utils/loop.js';
11
13
  import { WebElement } from "../utils/web-element.js";
12
14
  import { sectionContextRule } from "./rules.js";
@@ -19,10 +21,6 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
19
21
  description: dedent `
20
22
  Click an element by trying multiple CodeceptJS commands in order until one succeeds.
21
23
 
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
-
26
24
  Follow <locator_priority> from system prompt for locator selection.
27
25
 
28
26
  I.click(locator) - click element matching locator
@@ -78,7 +76,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
78
76
  const success = await action.attempt(command, explanation);
79
77
  const attempt = { command, success };
80
78
  if (action.lastError)
81
- attempt.error = action.lastError.toString();
79
+ attempt.error = errorText(action.lastError);
82
80
  attempts.push(attempt);
83
81
  if (success) {
84
82
  const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, command);
@@ -100,7 +98,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
100
98
  retryCommands.push(`I.click('${disambiguated.xpath.replace(/'/g, "\\'")}')`);
101
99
  for (const retryCmd of retryCommands) {
102
100
  if (!(await action.attempt(retryCmd, explanation))) {
103
- attempts.push({ command: retryCmd, success: false, error: action.lastError?.toString() });
101
+ attempts.push({ command: retryCmd, success: false, error: errorText(action.lastError) });
104
102
  continue;
105
103
  }
106
104
  const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, retryCmd);
@@ -118,38 +116,6 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
118
116
  }, action.lastError);
119
117
  },
120
118
  }),
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
- }),
153
119
  hover: tool({
154
120
  description: dedent `
155
121
  Move the mouse cursor to an element to reveal hover-only controls.
@@ -197,7 +163,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
197
163
  const success = await action.attempt(command, explanation);
198
164
  const attempt = { command, success };
199
165
  if (action.lastError)
200
- attempt.error = action.lastError.toString();
166
+ attempt.error = errorText(action.lastError);
201
167
  attempts.push(attempt);
202
168
  if (!success)
203
169
  continue;
@@ -264,7 +230,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
264
230
  fallback: true,
265
231
  }, action);
266
232
  }
267
- const errorMsg = `pressKey fallback to type() failed: ${action.lastError?.toString()}`;
233
+ const errorMsg = `pressKey fallback to type() failed: ${errorText(action.lastError)}`;
268
234
  await commitNote(activeNote, TestResult.FAILED, toolResult, action);
269
235
  return failedToolResult('pressKey', errorMsg, {
270
236
  ...toolResult,
@@ -300,7 +266,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
300
266
  code: pressKeyCommand,
301
267
  }, action);
302
268
  }
303
- const errorMsg = `pressKey() failed: ${action.lastError?.toString()}`;
269
+ const errorMsg = `pressKey() failed: ${errorText(action.lastError)}`;
304
270
  await commitNote(activeNote, TestResult.FAILED, toolResult, action);
305
271
  return failedToolResult('pressKey', errorMsg, {
306
272
  ...toolResult,
@@ -321,8 +287,6 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
321
287
  Execute raw CodeceptJS code block with multiple commands.
322
288
  USE THIS TOOL for typing text into fields: I.fillField, I.type
323
289
 
324
- Do not put a click on a ref-bearing element in here — clickRef with its ref is cheaper and cannot mis-target.
325
-
326
290
  Follow <actions> from system prompt for available commands.
327
291
  Follow <locator_priority> from system prompt for locator selection.
328
292
 
@@ -377,7 +341,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
377
341
  await action.attempt(codeBlock, explanation);
378
342
  const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, formLocator);
379
343
  if (action.lastError) {
380
- const message = action.lastError ? String(action.lastError) : 'Unknown error';
344
+ const message = errorText(action.lastError);
381
345
  await commitNote(activeNote, TestResult.FAILED, toolResult, action);
382
346
  let formSuggestion = 'Look into error message and identify which commands passed and which failed. Continue execution using step-by-step approach using click() and form() tools.';
383
347
  if (message.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN)) {
@@ -419,6 +383,42 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
419
383
  }),
420
384
  };
421
385
  }
386
+ export function createRefTools({ explorer, stateManager }, task) {
387
+ return {
388
+ clickRef: tool({
389
+ description: dedent `
390
+ Click an element by the ref the page context gave it, e.g. [ref=e14].
391
+
392
+ Prefer this over click() whenever the element you want carries a ref. A ref names one exact element, so it
393
+ cannot match several by mistake and never needs disambiguating — it is the fastest way to click.
394
+ Only pass a ref that appears in the page context you were given. Never invent or guess one.
395
+ If it reports the ref is gone, the page has been rebuilt: get fresh context and use the new ref.
396
+ `,
397
+ inputSchema: z.object({
398
+ ref: z.string().describe('The ref exactly as it appears in the page context, e.g. "e14"'),
399
+ element: z.string().describe('Role and name of the element you are clicking, for the record'),
400
+ }),
401
+ execute: async ({ ref, element }) => {
402
+ const activeNote = task.startNote(`Click ${element}`);
403
+ const previousState = ActionResult.fromState(stateManager.getCurrentState());
404
+ const action = explorer.action();
405
+ const named = await describeRef(explorer, ref);
406
+ const run = `I.usePlaywrightTo(${JSON.stringify(`click ${element}`)}, async ({ page }) => page.locator(${JSON.stringify(`aria-ref=${ref}`)}).click())`;
407
+ if (!(await action.attempt(run, `Click ${element}`))) {
408
+ activeNote.commit(TestResult.FAILED);
409
+ return failedToolResult('clickRef', `Ref ${ref} could not be clicked: ${errorText(action.lastError)}`, {
410
+ 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.',
411
+ });
412
+ }
413
+ // a ref belongs to this session only, so the run is reported as the locator a later test can replay
414
+ const code = named ? `I.click(${JSON.stringify(named)})` : run;
415
+ const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, code);
416
+ await commitNote(activeNote, TestResult.PASSED, toolResult, action);
417
+ return successToolResult('clickRef', { ...toolResult, code }, action);
418
+ },
419
+ }),
420
+ };
421
+ }
422
422
  export function createIframeTools({ explorer, stateManager }) {
423
423
  return {
424
424
  exitIframe: tool({
@@ -535,7 +535,7 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
535
535
 
536
536
  DO NOT call this if:
537
537
  - You just performed an action (pageDiff already provided in response)
538
- - You already have recent <page_html>/<page_aria> in context
538
+ - You already have a recent <page_aria> snapshot in context
539
539
  - You're about to perform an action (you'll get pageDiff after)
540
540
 
541
541
  Call ONLY when:
@@ -675,12 +675,18 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
675
675
  }),
676
676
  interact: tool({
677
677
  description: dedent `
678
- Execute an action on the current page using AI-powered interaction.
679
- Use this to perform actions like clicking buttons, selecting options, filling forms, etc.
680
- The AI will generate and try multiple CodeceptJS code strategies to accomplish the instruction.
678
+ Delegate one step to the Navigator, which reads the full page HTML and tries multiple CodeceptJS strategies.
679
+ Slower than the direct action tools use it as a fallback, not as the default.
680
+
681
+ Use when:
682
+ - direct action tools failed and you have no better locator to try
683
+ - the step needs a sequence of actions to complete
684
+ - the element is not in the context you have
685
+
686
+ Describe the outcome to reach, not the locator to use.
681
687
  `,
682
688
  inputSchema: z.object({
683
- instruction: z.string().describe('What action to perform on the page, e.g. "select new suite option", "click the Submit button"'),
689
+ instruction: z.string().describe('The step to perform on the page, described by its intent'),
684
690
  }),
685
691
  execute: async ({ instruction }) => {
686
692
  try {
@@ -690,7 +696,8 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
690
696
  }
691
697
  const previousState = ActionResult.fromState(currentState);
692
698
  const actionResult = ActionResult.fromState(currentState);
693
- const success = await navigator.resolveState(instruction, actionResult);
699
+ const experience = renderExperienceRecipes(explorer.activeTest?.getAppliedExperience(actionResult) ?? []);
700
+ const success = await navigator.resolveState(instruction, actionResult, { experience });
694
701
  const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, instruction);
695
702
  if (success) {
696
703
  return successToolResult('interact', {
@@ -698,7 +705,10 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
698
705
  message: `Successfully executed: ${instruction}`,
699
706
  });
700
707
  }
701
- return failedToolResult('interact', `Failed to execute: ${instruction}`, {
708
+ let reason = '';
709
+ if (navigator.lastFailureReason)
710
+ reason = `: ${navigator.lastFailureReason}`;
711
+ return failedToolResult('interact', `Failed to execute: ${instruction}${reason}`, {
702
712
  ...toolResult,
703
713
  suggestion: 'The action could not be completed. Try a different instruction or use more specific element descriptions.',
704
714
  });
@@ -821,7 +831,7 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
821
831
  }
822
832
  const failData = { suggestion: 'Try reset() to return to the starting page.' };
823
833
  if (action.lastError)
824
- failData.error = action.lastError.toString();
834
+ failData.error = errorText(action.lastError);
825
835
  return failedToolResult('back', `Failed to navigate back to ${targetUrl}`, failData);
826
836
  },
827
837
  }),
@@ -878,7 +888,7 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
878
888
  }
879
889
  if (result.totalFound === 0) {
880
890
  return failedToolResult('xpathCheck', `No elements matched XPath: ${xpath}`, {
881
- suggestion: 'Try a broader expression. Examples: //*[contains(@class, "btn")], //button, //*[contains(text(), "keyword")]',
891
+ suggestion: 'Do not guess another expression. Narrow down from what you know about the target: its role, its visible text, its nearest labelled ancestor. Add one constraint at a time.',
882
892
  });
883
893
  }
884
894
  const action = explorer.action();
@@ -960,7 +970,9 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
960
970
  withdrawVisionTools(tools);
961
971
  return tools;
962
972
  }
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.';
973
+ 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. messages holds text the app showed in response, requests the calls it made and consoleErrors what it logged.';
974
+ const FAILED_REQUEST_SUGGESTION = 'The server rejected a request made by this action (see requests). The UI accepted the interaction but the operation did not complete — read messages and consoleErrors for the reason and report it instead of repeating the action.';
975
+ const NAVIGATED_SUGGESTION = 'The action left the page. Elements are never compared across pages, so this diff carries the move itself and what the app announced in transit — an empty element diff does not mean nothing happened.';
964
976
  const ARIA_OUTPUT_CAP = 4000;
965
977
  const HTML_OUTPUT_CAP = 6000;
966
978
  const ANALYSIS_OUTPUT_CAP = 2000;
@@ -998,7 +1010,7 @@ function transformContainsCommand(command) {
998
1010
  }
999
1011
  function errorText(error) {
1000
1012
  if (error instanceof Error)
1001
- return error.toString();
1013
+ return compactErrorMessage(error);
1002
1014
  return 'Unknown error occurred';
1003
1015
  }
1004
1016
  export async function commitNote(activeNote, result, toolResult, action) {
@@ -1034,7 +1046,13 @@ export function successToolResult(action, data, source) {
1034
1046
  const ariaChanges = data.pageDiff.ariaChanges || '';
1035
1047
  const urlChanged = data.pageDiff.urlChanged === true;
1036
1048
  const hasHtmlParts = Array.isArray(data.pageDiff.htmlParts) && data.pageDiff.htmlParts.length > 0;
1037
- if (isMajorPageChange(data.pageDiff)) {
1049
+ if (hasFailedRequest(data.pageDiff)) {
1050
+ suggestion = `${FAILED_REQUEST_SUGGESTION} ${suggestion}`;
1051
+ }
1052
+ else if (urlChanged) {
1053
+ suggestion = `${NAVIGATED_SUGGESTION} ${suggestion}`;
1054
+ }
1055
+ else if (isMajorPageChange(data.pageDiff)) {
1038
1056
  suggestion = `MAJOR PAGE CHANGE. Page entered a different mode. Check htmlParts and iframes in pageDiff before next action. ${suggestion}`;
1039
1057
  }
1040
1058
  else if (!urlChanged && !ariaChanges && !hasHtmlParts) {
@@ -1050,6 +1068,9 @@ export function successToolResult(action, data, source) {
1050
1068
  export function isMajorPageChange(pageDiff) {
1051
1069
  return pageDiff.urlChanged !== true && (pageDiff.ariaChangeCount ?? 0) >= LARGE_ARIA_CHANGE_THRESHOLD;
1052
1070
  }
1071
+ export function hasFailedRequest(pageDiff) {
1072
+ return (pageDiff.requests ?? []).some((request) => request.status >= 400);
1073
+ }
1053
1074
  function hasObservablePageChange(data) {
1054
1075
  if (!data?.pageDiff)
1055
1076
  return false;
@@ -1057,6 +1078,8 @@ function hasObservablePageChange(data) {
1057
1078
  return true;
1058
1079
  if (data.pageDiff.ariaChanges)
1059
1080
  return true;
1081
+ if (data.pageDiff.messages?.length)
1082
+ return true;
1060
1083
  return Array.isArray(data.pageDiff.htmlParts) && data.pageDiff.htmlParts.length > 0;
1061
1084
  }
1062
1085
  export async function failedToolResult(action, message, data, error) {
@@ -1108,6 +1131,9 @@ export function clickFailureSuggestion(attempts) {
1108
1131
  if (errors.some((e) => e.includes('is not visible'))) {
1109
1132
  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
1133
  }
1134
+ if (errors.some((e) => e.includes('SyntaxError'))) {
1135
+ return 'The command string never parsed as JavaScript — quotes or brackets do not match. No element was looked up, so this tells you nothing about the page. Re-emit the same intent as valid CodeceptJS.';
1136
+ }
1111
1137
  const notFound = errors.filter((e) => e.includes('was not found'));
1112
1138
  if (notFound.length && notFound.every((e) => e.includes('was not found inside element'))) {
1113
1139
  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().';
@@ -1188,7 +1214,7 @@ function getNotFoundSuggestion(errorMessage) {
1188
1214
  Element was not found. The locator does not exist on this page.
1189
1215
  1. Use see() to visually analyze what elements are actually on the page
1190
1216
  2. Use context() to get fresh HTML and ARIA snapshot
1191
- 3. Use ONLY locators from <page_aria> or <page_html>
1217
+ 3. Use ONLY locators from <page_aria> or from HTML returned by context()
1192
1218
  4. Prefer ARIA locators: { "role": "button", "text": "visible text" }
1193
1219
  `;
1194
1220
  }
@@ -0,0 +1,51 @@
1
+ import { type AIConfig, type ReporterConfig } from '../config.js';
2
+ import { BaseCommand } from './base-command.js';
3
+ export declare class ConfigCommand extends BaseCommand {
4
+ name: string;
5
+ description: string;
6
+ execute(): Promise<void>;
7
+ static summary(options?: {
8
+ config?: string;
9
+ path?: string;
10
+ url?: string;
11
+ json?: boolean;
12
+ }): Promise<string>;
13
+ static data(config: SummarizedConfig, options?: ConfigSummaryOptions): ConfigData;
14
+ static render(config: SummarizedConfig, options?: ConfigSummaryOptions): string;
15
+ }
16
+ interface ConfigSummaryOptions {
17
+ configPath?: string | null;
18
+ root?: string;
19
+ json?: boolean;
20
+ }
21
+ export interface ConfigData {
22
+ config: string;
23
+ url: string;
24
+ browser: string;
25
+ headless: boolean;
26
+ dirs: Record<string, string>;
27
+ models: Record<string, string>;
28
+ providers: Record<string, string>;
29
+ integrations: {
30
+ langfuse: boolean;
31
+ testomatio: boolean;
32
+ };
33
+ env: Record<string, string>;
34
+ }
35
+ interface SummarizedConfig {
36
+ ai?: AIConfig;
37
+ playwright?: {
38
+ url?: string;
39
+ browser?: string;
40
+ show?: boolean;
41
+ };
42
+ web?: {
43
+ url?: string;
44
+ };
45
+ api?: {
46
+ baseEndpoint?: string;
47
+ };
48
+ dirs?: Record<string, string>;
49
+ reporter?: ReporterConfig;
50
+ }
51
+ export {};
@@ -0,0 +1,117 @@
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import chalk from 'chalk';
4
+ import { ConfigParser, EXPLORBOT_ENV_VARS, configuredModels } from '../config.js';
5
+ import { listSites } from '../global-config.js';
6
+ import { Reporter } from '../reporter.js';
7
+ import { getCliName } from '../utils/cli-name.js';
8
+ import { tag } from '../utils/logger.js';
9
+ import { BaseCommand } from './base-command.js';
10
+ export class ConfigCommand extends BaseCommand {
11
+ name = 'config';
12
+ description = 'Show models, config file and paths used by this run';
13
+ async execute() {
14
+ const parser = ConfigParser.getInstance();
15
+ tag('info').log(ConfigCommand.render(this.explorBot.getConfig(), { configPath: parser.getConfigPath(), root: parser.getProjectRoot() }));
16
+ }
17
+ static async summary(options = {}) {
18
+ const parser = ConfigParser.getInstance();
19
+ const [site] = listSites();
20
+ const load = (baseUrl) => parser.loadConfig({ config: options.config, path: options.path, baseUrl });
21
+ const config = await load(options.url).catch((error) => {
22
+ if (!site)
23
+ throw error;
24
+ return load(site.url);
25
+ });
26
+ return ConfigCommand.render(config, { configPath: parser.getConfigPath(), root: parser.getProjectRoot(), json: options.json });
27
+ }
28
+ static data(config, options = {}) {
29
+ let configPath = '';
30
+ if (options.configPath && existsSync(options.configPath))
31
+ configPath = options.configPath;
32
+ const dirs = {};
33
+ if (options.root) {
34
+ for (const [name, dir] of Object.entries({ output: 'output', ...config.dirs })) {
35
+ dirs[name] = path.join(options.root, dir);
36
+ }
37
+ }
38
+ const env = {};
39
+ for (const variable of EXPLORBOT_ENV_VARS) {
40
+ const value = process.env[variable.name];
41
+ if (value)
42
+ env[variable.name] = value;
43
+ }
44
+ const models = {};
45
+ const providers = {};
46
+ for (const [role, model] of Object.entries(configuredModels(config.ai))) {
47
+ models[role] = model.name;
48
+ if (model.provider)
49
+ providers[role] = model.provider;
50
+ }
51
+ return {
52
+ config: configPath,
53
+ url: config.playwright?.url || config.web?.url || config.api?.baseEndpoint || '',
54
+ browser: config.playwright?.browser || '',
55
+ headless: !config.playwright?.show,
56
+ dirs,
57
+ models,
58
+ providers,
59
+ integrations: {
60
+ langfuse: !!config.ai?.langfuse?.enabled,
61
+ testomatio: Reporter.resolveEnabled(config.reporter),
62
+ },
63
+ env,
64
+ };
65
+ }
66
+ static render(config, options = {}) {
67
+ const data = ConfigCommand.data(config, options);
68
+ if (options.json)
69
+ return JSON.stringify(data, null, 2);
70
+ const lines = [];
71
+ const section = (title, entries) => {
72
+ if (!entries.length)
73
+ return;
74
+ const width = Math.max(...entries.map(([label]) => label.length));
75
+ lines.push(chalk.bold(title));
76
+ for (const [label, value] of entries)
77
+ lines.push(` ${chalk.dim(label.padEnd(width))} ${value}`);
78
+ lines.push('');
79
+ };
80
+ const general = [['config', data.config || 'EXPLORBOT_* environment variables']];
81
+ if (data.url)
82
+ general.push(['url', data.url]);
83
+ if (data.browser) {
84
+ let window = 'visible';
85
+ if (data.headless)
86
+ window = 'headless';
87
+ general.push(['browser', `${data.browser}, ${window}`]);
88
+ }
89
+ for (const [name, dir] of Object.entries(data.dirs))
90
+ general.push([name, dir]);
91
+ section('Config', general);
92
+ const providerWidth = Math.max(0, ...Object.values(data.providers).map((provider) => provider.length));
93
+ const models = Object.entries(data.models).map(([role, model]) => {
94
+ if (!providerWidth)
95
+ return [role, model];
96
+ return [role, `${chalk.dim((data.providers[role] || '').padEnd(providerWidth))} ${model}`];
97
+ });
98
+ if (!models.length)
99
+ models.push(['model', chalk.red(`not configured — run ${getCliName()} init`)]);
100
+ section('Models', models);
101
+ const integrations = [];
102
+ if (data.integrations.langfuse)
103
+ integrations.push(['langfuse', 'traces sent']);
104
+ if (data.integrations.testomatio)
105
+ integrations.push(['testomatio', 'runs reported']);
106
+ section('Integrations', integrations);
107
+ const env = Object.entries(data.env).map(([name, value]) => {
108
+ let shown = value;
109
+ if (shown.length > 60)
110
+ shown = `${shown.slice(0, 57)}...`;
111
+ return [name, shown];
112
+ });
113
+ section('Environment', env);
114
+ lines.push(chalk.dim(`Every EXPLORBOT_* variable: ${getCliName()} --help`));
115
+ return lines.join('\n');
116
+ }
117
+ }
@@ -1,6 +1,7 @@
1
1
  import { AddRuleCommand } from './add-rule-command.js';
2
2
  import { CleanCommand } from './clean-command.js';
3
3
  import { CompactCommand } from './compact-command.js';
4
+ import { ConfigCommand } from './config-command.js';
4
5
  import { ContextAriaCommand } from './context-aria-command.js';
5
6
  import { ContextCommand } from './context-command.js';
6
7
  import { ContextDataCommand } from './context-data-command.js';
@@ -64,6 +65,7 @@ const commandClasses = [
64
65
  RunsCommand,
65
66
  RerunCommand,
66
67
  StatusCommand,
68
+ ConfigCommand,
67
69
  DebugCommand,
68
70
  ExitCommand,
69
71
  ];
@@ -7,15 +7,10 @@ import { findGlobalConfig, globalConfigPath, globalDir, globalEnvPath } from "..
7
7
  import { getCliName } from "../utils/cli-name.js";
8
8
  import { log, tag } from '../utils/logger.js';
9
9
  import { relativeToCwd } from "../utils/next-steps.js";
10
- const DEFAULT_CONFIG_TEMPLATE = `import { createOpenRouter } from '@openrouter/ai-sdk-provider';
11
- // import { '<your provider here>' } from '<your provider package here>';
12
-
13
- // Vercel AI SDK is used to connect to AI providers.
14
- // Bring your own provider or use OpenRouter (one API key, many providers).
15
- // https://github.com/testomatio/explorbot/blob/main/docs/providers.md
16
- const openrouter = createOpenRouter({
17
- apiKey: process.env.OPENROUTER_API_KEY,
18
- });
10
+ function defaultConfigTemplate() {
11
+ return `// 'provider/model-id' uses a bundled provider.
12
+ // It is also possible to import provider as a module from Vercel AI SDK.
13
+ // https://github.com/testomatio/explorbot/blob/main/docs/basics/providers.md
19
14
 
20
15
  const config = {
21
16
  web: {
@@ -24,12 +19,7 @@ const config = {
24
19
  },
25
20
 
26
21
  ai: {
27
- // fast model with tool calling capabilities
28
- model: openrouter('openai/gpt-oss-20b:nitro'),
29
- // vision model for screenshot analysis
30
- visionModel: openrouter('meta-llama/llama-4-scout-17b-16e-instruct'),
31
- // agentic model for decision making
32
- agenticModel: openrouter('minimax/minimax-m2.5:nitro'),
22
+ ${modelLines('openrouter')}
33
23
  },
34
24
 
35
25
  reporter: {
@@ -44,6 +34,7 @@ const config = {
44
34
 
45
35
  export default config;
46
36
  `;
37
+ }
47
38
  const DEFAULT_ENV_TEMPLATE = dedent `
48
39
  # AI provider API keys
49
40
  OPENROUTER_API_KEY=
@@ -127,7 +118,7 @@ export function runInitCommand(options) {
127
118
  log('Use --force to overwrite existing file');
128
119
  process.exit(1);
129
120
  }
130
- writeFileSync(outPath, DEFAULT_CONFIG_TEMPLATE, 'utf8');
121
+ writeFileSync(outPath, defaultConfigTemplate(), 'utf8');
131
122
  log(`Created config file: ${relativeToCwd(outPath)}`);
132
123
  const envPath = resolve(process.cwd(), '.env');
133
124
  if (!existsSync(envPath)) {
@@ -196,15 +187,17 @@ async function renderInitWizard(mode) {
196
187
  }), { exitOnCtrlC: false, patchConsole: false });
197
188
  });
198
189
  }
199
- function globalConfigTemplate(provider) {
200
- const { envKey } = PROVIDERS[provider];
190
+ function modelLines(provider) {
201
191
  const recommended = ConfigParser.recommendedModels()[provider] || {};
202
192
  const roles = [
203
193
  ['model', 'fast model with tool calling capabilities'],
204
194
  ['visionModel', 'vision model for screenshot analysis'],
205
195
  ['agenticModel', 'agentic model for decision making'],
206
196
  ];
207
- const models = roles.map(([role, comment]) => ` // ${comment}\n ${role}: '${provider}/${recommended[role] || '<model-id>'}',`).join('\n');
197
+ return roles.map(([role, comment]) => ` // ${comment}\n ${role}: '${provider}/${recommended[role] || '<model-id>'}',`).join('\n');
198
+ }
199
+ function globalConfigTemplate(provider) {
200
+ const { envKey } = PROVIDERS[provider];
208
201
  return `// Global Explorbot configuration — used by every directory without its own explorbot.config.js.
209
202
  // Models are written as 'provider/model-id' so they resolve without a local node_modules.
210
203
  // The key is read from ${envKey} in ~/.explorbot/.env
@@ -212,7 +205,7 @@ function globalConfigTemplate(provider) {
212
205
  // https://github.com/testomatio/explorbot/blob/main/docs/basics/providers.md
213
206
  const config = {
214
207
  ai: {
215
- ${models}
208
+ ${modelLines(provider)}
216
209
  },
217
210
 
218
211
  reporter: {
@@ -272,12 +272,19 @@ export declare class ConfigMissingError extends Error {
272
272
  }
273
273
  export declare function envConfigRequested(): boolean;
274
274
  export declare function missingConfigMessage(configFile?: string): string;
275
+ export declare function modelName(model: unknown): string;
276
+ export declare function modelProvider(model: unknown): string;
277
+ export declare function configuredModels(ai?: AIConfig): Record<string, ConfiguredModel>;
275
278
  export declare function resolveConfigModels(ai?: AIConfig): Promise<void>;
276
279
  export declare function resolveOutputRoot(baseUrl?: string): string;
277
280
  export declare function resolveStateRoot(baseUrl: string, ephemeral?: boolean): string;
278
281
  export declare function materializeKnowledge(outputRoot: string): void;
279
282
  export declare function createModel(provider: string, modelId: string): Promise<any>;
280
283
  type ModelRole = 'model' | 'visionModel' | 'agenticModel';
284
+ interface ConfiguredModel {
285
+ name: string;
286
+ provider: string;
287
+ }
281
288
  interface ProviderInfo {
282
289
  envKey: string;
283
290
  load: () => Promise<(modelId: string) => any>;
@@ -287,4 +294,4 @@ interface EnvVar {
287
294
  description: string;
288
295
  required?: boolean;
289
296
  }
290
- export type { ModelRole, EnvVar, ProviderInfo };
297
+ export type { ModelRole, EnvVar, ProviderInfo, ConfiguredModel };