explorbot 0.1.8 → 0.1.10

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 +70 -8
  2. package/boat/api-tester/src/ai/curler-tools.ts +3 -3
  3. package/boat/api-tester/src/ai/curler.ts +1 -1
  4. package/boat/api-tester/src/apibot.ts +2 -2
  5. package/boat/api-tester/src/config.ts +1 -1
  6. package/dist/bin/explorbot-cli.js +70 -7
  7. package/dist/boat/api-tester/src/ai/curler-tools.js +2 -2
  8. package/dist/boat/api-tester/src/apibot.js +2 -2
  9. package/dist/package.json +1 -1
  10. package/dist/src/ai/bosun.js +5 -1
  11. package/dist/src/ai/experience-compactor.js +235 -50
  12. package/dist/src/ai/historian.js +13 -6
  13. package/dist/src/ai/navigator.js +62 -62
  14. package/dist/src/ai/pilot.js +22 -0
  15. package/dist/src/ai/planner/subpages.js +1 -30
  16. package/dist/src/ai/planner.js +4 -4
  17. package/dist/src/ai/provider.js +1 -1
  18. package/dist/src/ai/rerunner.js +3 -3
  19. package/dist/src/ai/researcher/deep-analysis.js +1 -1
  20. package/dist/src/ai/researcher/fingerprint-worker.js +1 -1
  21. package/dist/src/ai/researcher/locators.js +1 -1
  22. package/dist/src/ai/researcher/sections.js +8 -1
  23. package/dist/src/ai/researcher.js +4 -11
  24. package/dist/src/ai/tools.js +5 -3
  25. package/dist/src/api/request-store.js +20 -0
  26. package/dist/src/api/xhr-capture.js +19 -3
  27. package/dist/src/command-handler.js +1 -1
  28. package/dist/src/commands/add-rule-command.js +1 -1
  29. package/dist/src/commands/base-command.js +20 -0
  30. package/dist/src/commands/clean-command.js +1 -1
  31. package/dist/src/commands/compact-command.js +138 -0
  32. package/dist/src/commands/context-command.js +7 -1
  33. package/dist/src/commands/drill-command.js +4 -1
  34. package/dist/src/commands/experience-command.js +104 -0
  35. package/dist/src/commands/explore-command.js +33 -7
  36. package/dist/src/commands/freesail-command.js +2 -0
  37. package/dist/src/commands/index.js +7 -3
  38. package/dist/src/commands/init-command.js +2 -2
  39. package/dist/src/commands/learn-command.js +1 -1
  40. package/dist/src/commands/navigate-command.js +4 -1
  41. package/dist/src/commands/plan-clear-command.js +4 -1
  42. package/dist/src/commands/plan-command.js +11 -4
  43. package/dist/src/commands/plan-edit-command.js +1 -1
  44. package/dist/src/commands/plan-load-command.js +4 -1
  45. package/dist/src/commands/plan-reload-command.js +4 -1
  46. package/dist/src/commands/plan-save-command.js +1 -1
  47. package/dist/src/commands/research-command.js +5 -2
  48. package/dist/src/commands/start-command.js +5 -1
  49. package/dist/src/commands/test-command.js +7 -1
  50. package/dist/src/experience-tracker.js +191 -56
  51. package/dist/src/explorbot.js +26 -14
  52. package/dist/src/explorer.js +3 -3
  53. package/dist/src/reporter.js +17 -2
  54. package/dist/src/stats.js +2 -0
  55. package/dist/src/suite.js +1 -1
  56. package/dist/src/utils/error-page.js +10 -0
  57. package/dist/src/utils/logger.js +1 -1
  58. package/dist/src/utils/rules-loader.js +1 -1
  59. package/dist/src/utils/test-files.js +1 -1
  60. package/dist/src/utils/url-matcher.js +50 -0
  61. package/package.json +1 -1
  62. package/src/ai/bosun.ts +5 -1
  63. package/src/ai/experience-compactor.ts +270 -63
  64. package/src/ai/historian.ts +12 -7
  65. package/src/ai/navigator.ts +68 -66
  66. package/src/ai/pilot.ts +22 -0
  67. package/src/ai/planner/subpages.ts +1 -24
  68. package/src/ai/planner.ts +5 -5
  69. package/src/ai/provider.ts +1 -1
  70. package/src/ai/rerunner.ts +3 -3
  71. package/src/ai/researcher/deep-analysis.ts +1 -1
  72. package/src/ai/researcher/fingerprint-worker.ts +1 -1
  73. package/src/ai/researcher/locators.ts +2 -2
  74. package/src/ai/researcher/sections.ts +7 -1
  75. package/src/ai/researcher.ts +4 -11
  76. package/src/ai/task-agent.ts +1 -1
  77. package/src/ai/tools.ts +6 -4
  78. package/src/api/request-store.ts +22 -0
  79. package/src/api/xhr-capture.ts +21 -3
  80. package/src/command-handler.ts +1 -1
  81. package/src/commands/add-rule-command.ts +2 -2
  82. package/src/commands/base-command.ts +26 -1
  83. package/src/commands/clean-command.ts +2 -2
  84. package/src/commands/compact-command.ts +156 -0
  85. package/src/commands/context-command.ts +8 -2
  86. package/src/commands/drill-command.ts +5 -2
  87. package/src/commands/experience-command.ts +125 -0
  88. package/src/commands/explore-command.ts +35 -9
  89. package/src/commands/freesail-command.ts +2 -0
  90. package/src/commands/index.ts +7 -3
  91. package/src/commands/init-command.ts +2 -2
  92. package/src/commands/learn-command.ts +2 -2
  93. package/src/commands/navigate-command.ts +5 -2
  94. package/src/commands/plan-clear-command.ts +5 -2
  95. package/src/commands/plan-command.ts +12 -5
  96. package/src/commands/plan-edit-command.ts +2 -2
  97. package/src/commands/plan-load-command.ts +5 -2
  98. package/src/commands/plan-reload-command.ts +5 -2
  99. package/src/commands/plan-save-command.ts +2 -2
  100. package/src/commands/research-command.ts +6 -3
  101. package/src/commands/start-command.ts +6 -2
  102. package/src/commands/test-command.ts +8 -2
  103. package/src/experience-tracker.ts +220 -71
  104. package/src/explorbot.ts +28 -15
  105. package/src/explorer.ts +3 -3
  106. package/src/reporter.ts +17 -3
  107. package/src/stats.ts +4 -0
  108. package/src/suite.ts +1 -1
  109. package/src/utils/error-page.ts +10 -0
  110. package/src/utils/logger.ts +1 -1
  111. package/src/utils/rules-loader.ts +1 -1
  112. package/src/utils/test-files.ts +1 -1
  113. package/src/utils/url-matcher.ts +43 -0
@@ -1,16 +1,17 @@
1
1
  import dedent from 'dedent';
2
2
  import { ActionResult } from '../action-result.js';
3
- import { ExperienceTracker } from '../experience-tracker.js';
3
+ import { ExperienceTracker, renderExperienceToc } from '../experience-tracker.js';
4
4
  import { KnowledgeTracker } from '../knowledge-tracker.js';
5
5
  import { normalizeUrl } from '../state-manager.js';
6
6
  import { extractCodeBlocks } from '../utils/code-extractor.js';
7
7
  import { HooksRunner } from "../utils/hooks-runner.js";
8
8
  import { createDebug, pluralize, tag } from '../utils/logger.js';
9
9
  import { loop, pause } from '../utils/loop.js';
10
+ import { RulesLoader } from "../utils/rules-loader.js";
10
11
  import { Researcher } from "./researcher.js";
11
12
  import { actionRule, locatorRule } from './rules.js';
12
- import { RulesLoader } from "../utils/rules-loader.js";
13
13
  import { isInteractive } from './task-agent.js';
14
+ import { createAgentTools } from "./tools.js";
14
15
  const debugLog = createDebug('explorbot:navigator');
15
16
  class Navigator {
16
17
  emoji = '🧭';
@@ -18,8 +19,6 @@ class Navigator {
18
19
  experienceCompactor;
19
20
  knowledgeTracker;
20
21
  experienceTracker;
21
- currentAction = null;
22
- currentUrl = null;
23
22
  hooksRunner;
24
23
  MAX_ATTEMPTS = Number.parseInt(process.env.MAX_ATTEMPTS || '5');
25
24
  systemPrompt = dedent `
@@ -85,9 +84,7 @@ class Navigator {
85
84
  const actualPath = action.stateManager.getCurrentState()?.url || '';
86
85
  const actionResult = action.actionResult || ActionResult.fromState(action.stateManager.getCurrentState());
87
86
  const originalMessage = `Navigate to: ${url}. Current page: ${actualPath}`;
88
- this.currentAction = action;
89
- this.currentUrl = url;
90
- const resolved = await this.resolveState(originalMessage, actionResult);
87
+ const resolved = await this.resolveState(originalMessage, actionResult, { action, expectedUrl: url });
91
88
  if (!resolved) {
92
89
  throw new Error(`Navigation to ${url} failed: redirected to ${actualPath} and could not resolve`);
93
90
  }
@@ -99,9 +96,7 @@ class Navigator {
99
96
  And I expected to see the URL in the browser
100
97
  But I got error: ${action.lastError?.message || 'Navigation failed'}.
101
98
  `.trim();
102
- this.currentAction = action;
103
- this.currentUrl = url;
104
- const resolved = await this.resolveState(originalMessage, actionResult);
99
+ const resolved = await this.resolveState(originalMessage, actionResult, { action, expectedUrl: url });
105
100
  if (!resolved) {
106
101
  throw new Error(`Navigation to ${url} failed: ${action.lastError?.message}`);
107
102
  }
@@ -119,9 +114,11 @@ class Navigator {
119
114
  throw error;
120
115
  }
121
116
  }
122
- async resolveState(message, actionResult) {
117
+ async resolveState(message, actionResult, opts) {
123
118
  tag('info').log('AI Navigator resolving state at', actionResult.url);
124
119
  debugLog('Resolution message:', message);
120
+ const action = opts?.action ?? this.explorer.createAction();
121
+ const expectedUrl = opts?.expectedUrl;
125
122
  let knowledge = '';
126
123
  let experience = '';
127
124
  const relevantKnowledge = this.knowledgeTracker.getRelevantKnowledge(actionResult);
@@ -133,24 +130,13 @@ class Navigator {
133
130
  ${knowledgeContent}
134
131
  </hint>`;
135
132
  }
136
- const relevantExperience = this.experienceTracker.getRelevantExperience(actionResult).map((experience) => experience.content);
137
- if (relevantExperience.length > 0) {
138
- const experienceContent = relevantExperience.join('\n\n---\n\n');
139
- experience = await this.experienceCompactor.compactExperience(experienceContent);
140
- tag('substep').log(`Found ${relevantExperience.length} experience ${pluralize(relevantExperience.length, 'file')} for: ${actionResult.url}`);
141
- experience = dedent `
142
- <experience>
143
- Here is the experience of interacting with the page.
144
- Learn from it AND DO NOT REPEAT THE SAME MISTAKES.
145
- If there was found successful solution to an issue, propose it as a first solution.
146
- If there are no successful solutions, analyze failed intentions and actions to avoid them.
147
- Do not try again same failed solutions
148
-
149
- Focus on successful solutions and avoid actions and locators that caused errors in past.
150
-
151
- ${experienceContent}
152
-
153
- </experience>`;
133
+ if (!actionResult.isInsideIframe) {
134
+ const toc = this.experienceTracker.getExperienceTableOfContents(actionResult);
135
+ if (toc.length > 0) {
136
+ const totalSections = toc.reduce((sum, entry) => sum + entry.sections.length, 0);
137
+ tag('substep').log(`Found ${toc.length} experience ${pluralize(toc.length, 'file')} (${totalSections} sections) for: ${actionResult.url}`);
138
+ experience = renderExperienceToc(toc);
139
+ }
154
140
  }
155
141
  const prompt = dedent `
156
142
  <message>
@@ -185,6 +171,7 @@ class Navigator {
185
171
  `;
186
172
  const conversation = this.provider.startConversation(this.systemPrompt, 'navigator');
187
173
  conversation.addUserText(prompt);
174
+ const tools = this.buildExperienceTools();
188
175
  let codeBlocks = [];
189
176
  let htmlContextAdded = false;
190
177
  let codeBlockIndex = 0;
@@ -192,12 +179,12 @@ class Navigator {
192
179
  let resolved = false;
193
180
  await loop(async ({ stop }) => {
194
181
  if (codeBlocks.length === 0) {
195
- const result = await this.provider.invokeConversation(conversation);
182
+ const result = await this.provider.invokeConversation(conversation, tools);
196
183
  if (!result)
197
184
  return;
198
185
  const aiResponse = result?.response?.text;
199
186
  debugLog('AI:', aiResponse?.split('\n')[0]);
200
- debugLog('Received AI response:', aiResponse.length, 'characters');
187
+ debugLog('Received AI response:', aiResponse?.length ?? 0, 'characters');
201
188
  codeBlocks = extractCodeBlocks(aiResponse ?? '');
202
189
  codeBlockIndex = 0;
203
190
  }
@@ -227,22 +214,23 @@ class Navigator {
227
214
  }
228
215
  codeBlockIndex++;
229
216
  totalAttempts++;
217
+ await this.explorer.switchToMainFrame();
230
218
  debugLog(`Attempting resolution: ${codeBlock}`);
231
- resolved = await this.currentAction.attempt(codeBlock, message);
232
- if (this.currentUrl) {
233
- await this.currentAction.getActor().wait(2);
234
- const freshState = await this.currentAction.capturePageState();
235
- if (normalizeUrl(freshState.url || '') === normalizeUrl(this.currentUrl)) {
219
+ resolved = await action.attempt(codeBlock, message);
220
+ if (expectedUrl) {
221
+ await action.getActor().wait(2);
222
+ const freshState = await action.capturePageState();
223
+ if (normalizeUrl(freshState.url || '') === normalizeUrl(expectedUrl)) {
236
224
  resolved = true;
237
225
  }
238
226
  else if (resolved) {
239
- tag('warning').log(`URL verification failed: expected ${this.currentUrl}, got ${freshState.url}`);
227
+ tag('warning').log(`URL verification failed: expected ${expectedUrl}, got ${freshState.url}`);
240
228
  resolved = false;
241
229
  }
242
230
  }
243
231
  if (resolved) {
244
232
  tag('success').log('Navigation resolved successfully');
245
- await this.experienceTracker.saveSuccessfulResolution(actionResult, message, codeBlock);
233
+ this.experienceTracker.writeAction(actionResult, { title: message, code: codeBlock });
246
234
  stop();
247
235
  return;
248
236
  }
@@ -256,9 +244,9 @@ class Navigator {
256
244
  resolved = false;
257
245
  },
258
246
  });
259
- if (!resolved && this.currentUrl) {
260
- await this.currentAction.getActor().wait(1);
261
- if (this.isOnExpectedPage(this.currentUrl, this.currentAction.stateManager)) {
247
+ if (!resolved && expectedUrl) {
248
+ await action.getActor().wait(1);
249
+ if (this.isOnExpectedPage(expectedUrl, action.stateManager)) {
262
250
  resolved = true;
263
251
  tag('success').log('Navigation resolved after delayed redirect');
264
252
  }
@@ -267,12 +255,12 @@ class Navigator {
267
255
  tag('error').log(`Navigation failed after ${totalAttempts} attempts`);
268
256
  }
269
257
  if (!resolved && isInteractive()) {
270
- const userInput = await pause(`Navigator failed to resolve. Current: ${this.currentAction.stateManager.getCurrentState()?.url}\n` + `Target: ${this.currentUrl}\nEnter CodeceptJS commands (or press Enter to skip):`);
258
+ const userInput = await pause(`Navigator failed to resolve. Current: ${action.stateManager.getCurrentState()?.url}\n` + `Target: ${expectedUrl ?? '(none)'}\nEnter CodeceptJS commands (or press Enter to skip):`);
271
259
  if (userInput?.trim()) {
272
- resolved = await this.currentAction.attempt(userInput, message);
273
- if (resolved && this.currentUrl) {
274
- await this.currentAction.getActor().wait(1);
275
- if (!this.isOnExpectedPage(this.currentUrl, this.currentAction.stateManager)) {
260
+ resolved = await action.attempt(userInput, message);
261
+ if (resolved && expectedUrl) {
262
+ await action.getActor().wait(1);
263
+ if (!this.isOnExpectedPage(expectedUrl, action.stateManager)) {
276
264
  resolved = false;
277
265
  }
278
266
  }
@@ -280,6 +268,23 @@ class Navigator {
280
268
  }
281
269
  return resolved;
282
270
  }
271
+ buildExperienceTools() {
272
+ const stateManager = this.explorer.getStateManager();
273
+ const getState = () => {
274
+ const s = stateManager.getCurrentState();
275
+ return s ? ActionResult.fromState(s) : null;
276
+ };
277
+ const { learn_experience } = createAgentTools({
278
+ explorer: this.explorer,
279
+ researcher: null,
280
+ navigator: this,
281
+ experienceTracker: this.experienceTracker,
282
+ getState,
283
+ });
284
+ if (!learn_experience)
285
+ return undefined;
286
+ return { learn_experience };
287
+ }
283
288
  async freeSail(opts, actionResult) {
284
289
  const stateManager = this.explorer.getStateManager();
285
290
  const state = stateManager.getCurrentState();
@@ -403,20 +408,13 @@ class Navigator {
403
408
  ${knowledgeContent}
404
409
  </hint>`;
405
410
  }
406
- const relevantExperience = this.experienceTracker.getRelevantExperience(actionResult).map((exp) => exp.content);
407
- if (relevantExperience.length > 0) {
408
- const experienceContent = relevantExperience.join('\n\n---\n\n');
409
- experience = await this.experienceCompactor.compactExperience(experienceContent);
410
- tag('substep').log(`Found ${relevantExperience.length} experience ${pluralize(relevantExperience.length, 'file')} for: ${actionResult.url}`);
411
- experience = dedent `
412
- <experience>
413
- Here is the experience of interacting with the page.
414
- Learn from it AND DO NOT REPEAT THE SAME MISTAKES.
415
- If there was found successful solution to an issue, propose it as a first solution.
416
-
417
- ${experienceContent}
418
-
419
- </experience>`;
411
+ if (!actionResult.isInsideIframe) {
412
+ const toc = this.experienceTracker.getExperienceTableOfContents(actionResult);
413
+ if (toc.length > 0) {
414
+ const totalSections = toc.reduce((sum, entry) => sum + entry.sections.length, 0);
415
+ tag('substep').log(`Found ${toc.length} experience ${pluralize(toc.length, 'file')} (${totalSections} sections) for: ${actionResult.url}`);
416
+ experience = renderExperienceToc(toc);
417
+ }
420
418
  }
421
419
  const prompt = dedent `
422
420
  <message>
@@ -454,16 +452,17 @@ class Navigator {
454
452
  tag('debug').log('Prompt:', prompt);
455
453
  const conversation = this.provider.startConversation(this.systemPrompt, 'navigator');
456
454
  conversation.addUserText(prompt);
455
+ const tools = this.buildExperienceTools();
457
456
  let codeBlocks = [];
458
457
  const successfulCodes = [];
459
458
  const action = this.explorer.createAction();
460
459
  await loop(async ({ stop, iteration }) => {
461
460
  if (codeBlocks.length === 0) {
462
- const result = await this.provider.invokeConversation(conversation);
461
+ const result = await this.provider.invokeConversation(conversation, tools);
463
462
  if (!result)
464
463
  return;
465
464
  const aiResponse = result?.response?.text;
466
- debugLog('Received AI response:', aiResponse.length, 'characters');
465
+ debugLog('Received AI response:', aiResponse?.length ?? 0, 'characters');
467
466
  tag('step').log('Verifying assertion...');
468
467
  codeBlocks = extractCodeBlocks(aiResponse ?? '');
469
468
  }
@@ -475,6 +474,7 @@ class Navigator {
475
474
  stop();
476
475
  return;
477
476
  }
477
+ await this.explorer.switchToMainFrame();
478
478
  const verified = await action.attempt(codeBlock, message, false);
479
479
  if (verified) {
480
480
  tag('success').log('Verification passed');
@@ -418,6 +418,28 @@ export class Pilot {
418
418
  const verifyLines = verifications.map(([a, v]) => `${v ? 'PASS' : 'FAIL'}: ${a}`);
419
419
  lines.push(`verifications: ${verifyLines.join(', ')}`);
420
420
  }
421
+ const consoleErrors = (state.browserLogs ?? []).filter((l) => (l.type || l.level) === 'error');
422
+ if (consoleErrors.length > 0) {
423
+ const sample = consoleErrors
424
+ .slice(0, 3)
425
+ .map((e) => e.text || e.message || String(e))
426
+ .join(' | ');
427
+ lines.push(`console errors: ${consoleErrors.length} (${sample})`);
428
+ }
429
+ else {
430
+ lines.push('console errors: none');
431
+ }
432
+ const failedRequests = this.explorer.getRequestStore()?.getFailedRequests() ?? [];
433
+ if (failedRequests.length > 0) {
434
+ const sample = failedRequests
435
+ .slice(-5)
436
+ .map((r) => `${r.method} ${r.path} → ${r.status}`)
437
+ .join(', ');
438
+ lines.push(`network errors: ${sample}`);
439
+ }
440
+ else {
441
+ lines.push('network errors: none');
442
+ }
421
443
  const interactiveNodes = collectInteractiveNodes(state.ariaSnapshot);
422
444
  const disabledButtons = interactiveNodes.filter((n) => n.role === 'button' && n.disabled === true && n.name).map((n) => n.name);
423
445
  lines.push(`disabled buttons: ${disabledButtons.length > 0 ? disabledButtons.join(', ') : 'none'}`);
@@ -1,7 +1,7 @@
1
1
  import dedent from 'dedent';
2
2
  import { z } from 'zod';
3
- import { ConfigParser } from "../../config.js";
4
3
  import { normalizeUrl } from "../../state-manager.js";
4
+ import { isDynamicSegment } from "../../utils/url-matcher.js";
5
5
  const planRegistry = new Map();
6
6
  export function registerPlan(url, plan, feature, stateHash) {
7
7
  const key = buildKey(url, feature);
@@ -27,35 +27,6 @@ function buildKey(url, feature) {
27
27
  return `${normalized}::${feature}`;
28
28
  return normalized;
29
29
  }
30
- export function isDynamicSegment(segment) {
31
- try {
32
- const configRegex = ConfigParser.getInstance().getConfig().dynamicPageRegex;
33
- if (configRegex)
34
- return new RegExp(configRegex, 'i').test(segment);
35
- }
36
- catch {
37
- /* config not loaded yet */
38
- }
39
- // numeric: /users/123
40
- if (/^\d+$/.test(segment))
41
- return true;
42
- // UUID: /items/550e8400-e29b-41d4-a716-446655440000
43
- if (/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(segment))
44
- return true;
45
- // ULID: /items/01ARZ3NDEKTSV4RRFFQ69G5FAV
46
- if (/^[0-9A-HJKMNP-TV-Z]{26}$/.test(segment))
47
- return true;
48
- // hex ID (4+ chars): /suite/70dae98a
49
- if (/^[a-f0-9]{4,}$/i.test(segment))
50
- return true;
51
- // hex-prefixed slug (8+ hex before dash): /suite/95ef0c94-mobile
52
- if (/^[a-f0-9]{8,}-/i.test(segment))
53
- return true;
54
- // short mixed alphanumeric (digits + letters, ≤8 chars, no dash): /item/x7f2
55
- if (segment.length <= 8 && !segment.includes('-') && /\d/.test(segment) && /[a-z]/i.test(segment))
56
- return true;
57
- return false;
58
- }
59
30
  export function isTemplateMatch(urlA, urlB) {
60
31
  const partsA = normalizeUrl(urlA).split('/');
61
32
  const partsB = normalizeUrl(urlB).split('/');
@@ -6,19 +6,19 @@ import { ConfigParser } from "../config.js";
6
6
  import { ExperienceTracker } from "../experience-tracker.js";
7
7
  import { Observability } from "../observability.js";
8
8
  import { Stats } from "../stats.js";
9
+ import { Suite } from "../suite.js";
9
10
  import { Plan, Test } from "../test-plan.js";
10
- import { planToCompactAiContext } from "../utils/test-plan-markdown.js";
11
11
  import { createDebug, tag } from '../utils/logger.js';
12
12
  import { jsonToTable } from "../utils/markdown-parser.js";
13
13
  import { mdq } from '../utils/markdown-query.js';
14
+ import { planToCompactAiContext } from "../utils/test-plan-markdown.js";
14
15
  import { Conversation } from "./conversation.js";
15
- import { getActiveStyle, getStyles } from "./planner/styles.js";
16
16
  import { WithSessionDedup } from "./planner/session-dedup.js";
17
+ import { getActiveStyle, getStyles } from "./planner/styles.js";
17
18
  import { WithSubPages, getPlannedByStateHash, getRegisteredPlan, registerPlan } from "./planner/subpages.js";
19
+ import { POSSIBLE_SECTIONS, Researcher } from "./researcher.js";
18
20
  import { findSimilarStateHash } from "./researcher/cache.js";
19
21
  import { hasFocusedSection } from "./researcher/focus.js";
20
- import { POSSIBLE_SECTIONS, Researcher } from "./researcher.js";
21
- import { Suite } from "../suite.js";
22
22
  import { fileUploadRule, protectionRule } from "./rules.js";
23
23
  const debugLog = createDebug('explorbot:planner');
24
24
  const TasksSchema = z.object({
@@ -2,12 +2,12 @@ import { LangfuseSpanProcessor } from '@langfuse/otel';
2
2
  import { NodeSDK } from '@opentelemetry/sdk-node';
3
3
  import { generateObject, generateText } from 'ai';
4
4
  import { clearActivity, setActivity } from "../activity.js";
5
- import { RulesLoader } from "../utils/rules-loader.js";
6
5
  import { executionController } from "../execution-controller.js";
7
6
  import { Observability } from "../observability.js";
8
7
  import { Stats } from "../stats.js";
9
8
  import { createDebug, tag } from '../utils/logger.js';
10
9
  import { withRetry } from '../utils/retry.js';
10
+ import { RulesLoader } from "../utils/rules-loader.js";
11
11
  import { Conversation } from './conversation.js';
12
12
  const debugLog = createDebug('explorbot:provider');
13
13
  const promptLog = createDebug('explorbot:provider:out');
@@ -7,8 +7,8 @@ import { highlight } from 'cli-highlight';
7
7
  import * as codeceptjs from 'codeceptjs';
8
8
  import heal from 'codeceptjs/lib/heal';
9
9
  import aiTracePlugin from 'codeceptjs/lib/plugin/aiTrace';
10
- import figureSet from 'figures';
11
10
  import dedent from 'dedent';
11
+ import figureSet from 'figures';
12
12
  import { z } from 'zod';
13
13
  import { ActionResult } from "../action-result.js";
14
14
  import { setActivity } from "../activity.js";
@@ -16,11 +16,11 @@ import { Stats } from "../stats.js";
16
16
  import { Task, Test, TestResult } from "../test-plan.js";
17
17
  import { createDebug, tag } from "../utils/logger.js";
18
18
  import { loop } from "../utils/loop.js";
19
+ import { RulesLoader } from "../utils/rules-loader.js";
19
20
  import { loadTestSuites, printTestList } from "../utils/test-files.js";
20
21
  import { toolExecutionLabel } from "./conversation.js";
21
- import { locatorRule, actionRule, sectionContextRule } from "./rules.js";
22
+ import { actionRule, locatorRule, sectionContextRule } from "./rules.js";
22
23
  import { TaskAgent } from "./task-agent.js";
23
- import { RulesLoader } from "../utils/rules-loader.js";
24
24
  import { createCodeceptJSTools } from "./tools.js";
25
25
  const debugLog = createDebug('explorbot:rerunner');
26
26
  export class Rerunner extends TaskAgent {
@@ -1,7 +1,7 @@
1
1
  import dedent from 'dedent';
2
2
  import { ActionResult } from '../../action-result.js';
3
- import { detectFocusArea, diffAriaSnapshots } from "../../utils/aria.js";
4
3
  import { executionController } from "../../execution-controller.js";
4
+ import { detectFocusArea, diffAriaSnapshots } from "../../utils/aria.js";
5
5
  import { tag } from '../../utils/logger.js';
6
6
  import { mdq } from "../../utils/markdown-query.js";
7
7
  import { getCachedResearch, saveResearch } from "./cache.js";
@@ -1,4 +1,4 @@
1
- import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
1
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { parentPort } from 'node:worker_threads';
4
4
  import { computeHtmlFingerprint } from "../../utils/html-diff.js";
@@ -4,8 +4,8 @@ import { parseAriaLocator } from "../../utils/aria.js";
4
4
  import { tag } from '../../utils/logger.js';
5
5
  import { mdq } from "../../utils/markdown-query.js";
6
6
  import { WebElement } from "../../utils/web-element.js";
7
- import { FOCUSED_MARKER } from "./focus.js";
8
7
  import { locatorRule as generalLocatorRuleText } from '../rules.js';
8
+ import { FOCUSED_MARKER } from "./focus.js";
9
9
  import { debugLog } from "./mixin.js";
10
10
  import { parseResearchSections } from "./parser.js";
11
11
  function firstCssSegment(css) {
@@ -22,7 +22,14 @@ export function WithSections(Base) {
22
22
  for (const [name, description] of targets) {
23
23
  if (executionController.isInterrupted())
24
24
  break;
25
- const text = await this._researchSingleSection(name, description, ariaSnapshot, focusCss);
25
+ let text = '';
26
+ try {
27
+ text = await this._researchSingleSection(name, description, ariaSnapshot, focusCss);
28
+ }
29
+ catch (err) {
30
+ tag('warning').log(`Section "${name}" research failed, skipping: ${err instanceof Error ? err.message : err}`);
31
+ continue;
32
+ }
26
33
  if (!text)
27
34
  continue;
28
35
  const trimmed = text.trim();
@@ -2,16 +2,17 @@ import dedent from 'dedent';
2
2
  import { ActionResult } from '../action-result.js';
3
3
  import { setActivity } from "../activity.js";
4
4
  import { outputPath } from "../config.js";
5
+ import { executionController } from "../execution-controller.js";
5
6
  import { Observability } from "../observability.js";
6
7
  import { Stats } from "../stats.js";
7
8
  import { diffAriaSnapshots } from "../utils/aria.js";
8
- import { isErrorPage } from "../utils/error-page.js";
9
+ import { ErrorPageError, isErrorPage } from "../utils/error-page.js";
9
10
  import { HooksRunner } from "../utils/hooks-runner.js";
10
11
  import { isBodyEmpty } from "../utils/html.js";
11
12
  import { createDebug, pluralize, tag } from '../utils/logger.js';
12
13
  import { mdq } from "../utils/markdown-query.js";
13
14
  import { withRetry } from "../utils/retry.js";
14
- import { executionController } from "../execution-controller.js";
15
+ import { RulesLoader } from "../utils/rules-loader.js";
15
16
  import { ContextLengthError } from './provider.js';
16
17
  import { findSimilarResearch, getCachedResearch, saveResearch } from "./researcher/cache.js";
17
18
  import { WithCoordinates } from "./researcher/coordinates.js";
@@ -22,7 +23,6 @@ import { extractValidContainers, formatResearchSummary, parseResearchSections }
22
23
  import { ResearchResult } from "./researcher/research-result.js";
23
24
  import { WithSections } from "./researcher/sections.js";
24
25
  import { locatorRule as generalLocatorRuleText } from './rules.js';
25
- import { RulesLoader } from "../utils/rules-loader.js";
26
26
  import { TaskAgent } from "./task-agent.js";
27
27
  const debugLog = createDebug('explorbot:researcher');
28
28
  export const POSSIBLE_SECTIONS = {
@@ -102,14 +102,7 @@ export class Researcher extends ResearcherBase {
102
102
  const recovered = await this.waitForPageLoad(screenshot);
103
103
  if (!recovered) {
104
104
  tag('warning').log(`Detected error page at ${state.url}`);
105
- return dedent `
106
- ## Error Page Detected
107
-
108
- URL: ${state.url}
109
- Title: ${this.actionResult.title || 'N/A'}
110
-
111
- Research skipped. Navigate to a valid page to continue.
112
- `;
105
+ throw new ErrorPageError(state.url, this.actionResult.title);
113
106
  }
114
107
  }
115
108
  debugLog('Researching web page:', this.actionResult.url);
@@ -252,14 +252,16 @@ export function createCodeceptJSTools(explorer, task) {
252
252
  form: tool({
253
253
  description: dedent `
254
254
  Execute raw CodeceptJS code block with multiple commands.
255
- USE THIS TOOL for all keyboard interactions: I.fillField, I.type, I.pressKey
255
+ USE THIS TOOL for typing text into fields: I.fillField, I.type
256
256
 
257
257
  Follow <actions> from system prompt for available commands.
258
258
  Follow <locator_priority> from system prompt for locator selection.
259
259
 
260
+ I.type(text) types the literal characters of its argument into the focused element.
261
+ To press key combination or special keys (Ctrl, Meta, Esc) use I.pressKey instead.
262
+
260
263
  Use cases:
261
264
  - Typing into input fields (I.fillField, I.type)
262
- - Pressing keyboard keys (I.pressKey)
263
265
  - Working with iframes (switch context with I.switchTo)
264
266
  - Performing multiple form actions in a single batch
265
267
  - Complex interactions requiring sequential commands
@@ -834,7 +836,7 @@ export function createAgentTools({ explorer, researcher, navigator, experienceTr
834
836
  tools.learn_experience = tool({
835
837
  description: dedent `
836
838
  Read the full body of a specific experience section listed in <experience>.
837
- The TOC shows entries like "A.1 ## Successful Flow: ...". Pass the fileTag and sectionIndex.
839
+ The TOC shows entries like "A.1 ## FLOW: ..." or "A.2 ## ACTION: ...". Pass the fileTag and sectionIndex.
838
840
  Only call when a TOC entry looks directly relevant to the current step.
839
841
  `,
840
842
  inputSchema: z.object({
@@ -5,6 +5,8 @@ const AUTH_HEADERS = ['authorization', 'cookie', 'x-api-key', 'x-csrf-token'];
5
5
  export class RequestStore {
6
6
  capturedRequests = [];
7
7
  madeRequests = [];
8
+ failedRequests = [];
9
+ onFailedListeners = [];
8
10
  outputDir;
9
11
  constructor(outputDir) {
10
12
  this.outputDir = outputDir;
@@ -13,6 +15,23 @@ export class RequestStore {
13
15
  this.capturedRequests.push(result);
14
16
  result.save(this.outputDir);
15
17
  }
18
+ addFailedRequest(result) {
19
+ this.failedRequests.push(result);
20
+ for (const cb of this.onFailedListeners) {
21
+ cb(result);
22
+ }
23
+ }
24
+ getFailedRequests() {
25
+ return this.failedRequests;
26
+ }
27
+ onFailedRequest(cb) {
28
+ this.onFailedListeners.push(cb);
29
+ return () => {
30
+ const idx = this.onFailedListeners.indexOf(cb);
31
+ if (idx !== -1)
32
+ this.onFailedListeners.splice(idx, 1);
33
+ };
34
+ }
16
35
  addMadeRequest(result) {
17
36
  this.madeRequests.push(result);
18
37
  result.save(this.outputDir);
@@ -101,6 +120,7 @@ export class RequestStore {
101
120
  clear() {
102
121
  this.capturedRequests = [];
103
122
  this.madeRequests = [];
123
+ this.failedRequests = [];
104
124
  }
105
125
  }
106
126
  function normalizePathPattern(urlPath) {
@@ -32,15 +32,31 @@ export class XhrCapture {
32
32
  if (resourceType !== 'xhr' && resourceType !== 'fetch')
33
33
  return;
34
34
  const method = request.method();
35
- if (!WRITE_METHODS.has(method))
36
- return;
37
35
  const url = request.url();
38
36
  if (!url.startsWith(this.baseOrigin))
39
37
  return;
38
+ const status = response.status();
39
+ if (status >= 400) {
40
+ const failedUrl = new URL(url);
41
+ const failure = new RequestResult({
42
+ id: generateRequestId(method, failedUrl.pathname, 'fail_'),
43
+ method,
44
+ path: failedUrl.pathname,
45
+ fullUrl: failedUrl.pathname + failedUrl.search,
46
+ requestHeaders: {},
47
+ status,
48
+ statusText: response.statusText(),
49
+ responseHeaders: {},
50
+ timing: 0,
51
+ timestamp: new Date(),
52
+ });
53
+ this.store.addFailedRequest(failure);
54
+ }
55
+ if (!WRITE_METHODS.has(method))
56
+ return;
40
57
  const contentType = response.headers()['content-type'] || '';
41
58
  if (!JSON_CONTENT_TYPES.test(contentType))
42
59
  return;
43
- const status = response.status();
44
60
  if (status === 304)
45
61
  return;
46
62
  const parsedUrl = new URL(url);
@@ -105,7 +105,7 @@ export class CommandHandler {
105
105
  this.runningCommands.add(command.name);
106
106
  try {
107
107
  await command.execute(argsString);
108
- command.suggestions.forEach((s) => tag('step').log(s));
108
+ command.printSuggestions();
109
109
  }
110
110
  catch (error) {
111
111
  if (error?.name === 'AbortError')
@@ -7,7 +7,7 @@ import { BaseCommand } from './base-command.js';
7
7
  export class AddRuleCommand extends BaseCommand {
8
8
  name = 'add-rule';
9
9
  description = 'Create a rule file for an agent';
10
- suggestions = ['/add-rule researcher check-tooltips'];
10
+ suggestions = [{ command: 'add-rule researcher check-tooltips', hint: 'example — add a rule for the researcher agent' }];
11
11
  async execute(args) {
12
12
  const parts = args.trim().split(/\s+/);
13
13
  const agentName = parts[0] || '';
@@ -1,4 +1,8 @@
1
+ import chalk from 'chalk';
1
2
  import { Command } from 'commander';
3
+ import { isInteractive } from '../ai/task-agent.js';
4
+ import { getCliName } from '../utils/cli-name.js';
5
+ import { tag } from '../utils/logger.js';
2
6
  export class BaseCommand {
3
7
  aliases = [];
4
8
  options = [];
@@ -11,6 +15,22 @@ export class BaseCommand {
11
15
  matches(commandName) {
12
16
  return this.name === commandName || this.aliases.includes(commandName);
13
17
  }
18
+ printSuggestions() {
19
+ if (this.suggestions.length === 0)
20
+ return;
21
+ const prefix = isInteractive() ? '/' : `${getCliName()} `;
22
+ tag('info').log('');
23
+ tag('info').log(chalk.bold('Suggested:'));
24
+ for (const { command, hint } of this.suggestions) {
25
+ tag('info').log('');
26
+ if (!command) {
27
+ tag('info').log(chalk.dim(hint));
28
+ continue;
29
+ }
30
+ tag('info').log(chalk.dim(`${hint}:`));
31
+ tag('info').log(` ${chalk.yellow(`${prefix}${command}`)}`);
32
+ }
33
+ }
14
34
  parseArgs(args) {
15
35
  const cmd = new Command();
16
36
  cmd.exitOverride();