explorbot 0.2.5 → 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 (82) hide show
  1. package/boat/prima/README.md +96 -0
  2. package/boat/prima/package.json +14 -10
  3. package/boat/prima/src/cli.ts +5 -0
  4. package/boat/prima/src/prima.ts +17 -4
  5. package/dist/boat/prima/src/cli.js +7 -0
  6. package/dist/boat/prima/src/prima.js +18 -4
  7. package/dist/models.json +4 -4
  8. package/dist/package.json +6 -2
  9. package/dist/src/action-result.d.ts +13 -0
  10. package/dist/src/action-result.js +46 -15
  11. package/dist/src/action.d.ts +5 -2
  12. package/dist/src/action.js +48 -17
  13. package/dist/src/ai/captain/web-mode.js +1 -2
  14. package/dist/src/ai/captain.d.ts +20 -0
  15. package/dist/src/ai/captain.js +10 -1
  16. package/dist/src/ai/driller.js +6 -2
  17. package/dist/src/ai/fisherman-tools.d.ts +40 -1
  18. package/dist/src/ai/fisherman-tools.js +39 -0
  19. package/dist/src/ai/fisherman.js +2 -1
  20. package/dist/src/ai/navigator.d.ts +2 -1
  21. package/dist/src/ai/navigator.js +5 -9
  22. package/dist/src/ai/pilot.js +39 -22
  23. package/dist/src/ai/planner/subpages.js +2 -16
  24. package/dist/src/ai/planner.js +1 -1
  25. package/dist/src/ai/provider.js +16 -1
  26. package/dist/src/ai/researcher/cache.d.ts +8 -3
  27. package/dist/src/ai/researcher/cache.js +13 -8
  28. package/dist/src/ai/researcher/deep-analysis.js +1 -1
  29. package/dist/src/ai/researcher/fingerprint-worker.js +21 -4
  30. package/dist/src/ai/researcher.js +4 -3
  31. package/dist/src/ai/rules.js +1 -5
  32. package/dist/src/ai/tester.d.ts +1 -1
  33. package/dist/src/ai/tester.js +13 -22
  34. package/dist/src/ai/tools.d.ts +8 -5
  35. package/dist/src/ai/tools.js +79 -56
  36. package/dist/src/commands/init-command.js +13 -20
  37. package/dist/src/config.js +3 -1
  38. package/dist/src/experience-tracker.d.ts +2 -0
  39. package/dist/src/experience-tracker.js +12 -0
  40. package/dist/src/explorbot.js +1 -1
  41. package/dist/src/playwright-recorder.js +6 -12
  42. package/dist/src/test-plan.d.ts +8 -0
  43. package/dist/src/test-plan.js +11 -0
  44. package/dist/src/utils/html-diff.d.ts +5 -0
  45. package/dist/src/utils/html-diff.js +65 -6
  46. package/dist/src/utils/strings.d.ts +2 -0
  47. package/dist/src/utils/strings.js +32 -0
  48. package/dist/src/utils/url-matcher.d.ts +1 -0
  49. package/dist/src/utils/url-matcher.js +31 -2
  50. package/docs/basics/getting-started.md +33 -10
  51. package/docs/basics/providers.md +6 -4
  52. package/docs/contributing/npm-package.md +73 -4
  53. package/models.json +4 -4
  54. package/package.json +6 -2
  55. package/src/action-result.ts +61 -16
  56. package/src/action.ts +51 -17
  57. package/src/ai/captain/web-mode.ts +1 -2
  58. package/src/ai/captain.ts +9 -1
  59. package/src/ai/driller.ts +6 -2
  60. package/src/ai/fisherman-tools.ts +35 -0
  61. package/src/ai/fisherman.ts +2 -1
  62. package/src/ai/navigator.ts +6 -10
  63. package/src/ai/pilot.ts +41 -24
  64. package/src/ai/planner/subpages.ts +2 -13
  65. package/src/ai/planner.ts +1 -1
  66. package/src/ai/provider.ts +17 -1
  67. package/src/ai/researcher/cache.ts +17 -9
  68. package/src/ai/researcher/deep-analysis.ts +1 -1
  69. package/src/ai/researcher/fingerprint-worker.ts +23 -5
  70. package/src/ai/researcher.ts +4 -3
  71. package/src/ai/rules.ts +1 -5
  72. package/src/ai/tester.ts +13 -22
  73. package/src/ai/tools.ts +84 -60
  74. package/src/commands/init-command.ts +14 -20
  75. package/src/config.ts +2 -1
  76. package/src/experience-tracker.ts +13 -0
  77. package/src/explorbot.ts +1 -1
  78. package/src/playwright-recorder.ts +6 -11
  79. package/src/test-plan.ts +18 -0
  80. package/src/utils/html-diff.ts +72 -7
  81. package/src/utils/strings.ts +36 -0
  82. package/src/utils/url-matcher.ts +27 -2
package/src/action.ts CHANGED
@@ -3,7 +3,7 @@ import { join } from 'node:path';
3
3
  import { context, trace } from '@opentelemetry/api';
4
4
  import { container, recorder } from 'codeceptjs';
5
5
  import * as codeceptjs from 'codeceptjs';
6
- import { ActionResult, type FocusedElement } from './action-result.js';
6
+ import { ActionResult, type FocusedElement, type NetworkCall } from './action-result.js';
7
7
  import { clearActivity, setActivity } from './activity.ts';
8
8
  import { ConfigParser, outputPath } from './config.js';
9
9
  import type { ExplorbotConfig } from './config.js';
@@ -21,6 +21,8 @@ const debugLog = createDebug('explorbot:action');
21
21
  const CAPTURE_NAVIGATION_TRANSITION_ATTEMPTS = 3;
22
22
  const DEFAULT_ACTION_TIMEOUT = 3000;
23
23
  const DEFAULT_PAGE_TIMEOUT = 3000;
24
+ const MAX_NETWORK_CALLS = 10;
25
+ const IMPORTANT_LOG_LEVELS = new Set(['info', 'error', 'warning', 'warn']);
24
26
 
25
27
  class Action {
26
28
  private actor: CodeceptJS.I;
@@ -38,6 +40,8 @@ class Action {
38
40
  private recorder?: PlaywrightRecorder;
39
41
  private recovery: RecoveryRunner;
40
42
  private mainDocumentStatus: number | undefined = undefined;
43
+ private networkRequests: NetworkCall[] = [];
44
+ private baseOrigin: string;
41
45
 
42
46
  constructor(actor: CodeceptJS.I, stateManager: StateManager, recorder?: PlaywrightRecorder, recovery?: RecoveryRunner) {
43
47
  this.actor = actor;
@@ -46,6 +50,7 @@ class Action {
46
50
  this.playwrightHelper = container.helpers('Playwright');
47
51
  this.recorder = recorder;
48
52
  this.recovery = recovery || ((fn) => fn());
53
+ this.baseOrigin = URL.parse(this.config.playwright?.url || '')?.origin || '';
49
54
  }
50
55
 
51
56
  async saveScreenshot(): Promise<string | undefined> {
@@ -127,9 +132,7 @@ class Action {
127
132
  const logPath = join(statesDir, logFile);
128
133
  const formattedLogs = browserLogs.map((log: any) => {
129
134
  const logTimestamp = new Date().toISOString();
130
- const level = (log.type || log.level || 'LOG').toUpperCase();
131
- const message = log.text || log.message || String(log);
132
- return `[${logTimestamp}] ${level}: ${message}`;
135
+ return `[${logTimestamp}] ${log.type.toUpperCase()}: ${log.text}`;
133
136
  });
134
137
  fs.writeFileSync(logPath, `${formattedLogs.join('\n')}\n`, 'utf8');
135
138
 
@@ -157,12 +160,16 @@ class Action {
157
160
  ariaSnapshotFile = ariaFileName;
158
161
  }
159
162
 
163
+ const networkRequests = this.networkRequests;
164
+ this.networkRequests = [];
165
+
160
166
  const result = new ActionResult({
161
167
  html,
162
168
  title,
163
169
  httpStatus: await this.captureMainDocumentStatus(),
164
170
  url,
165
171
  browserLogs,
172
+ networkRequests,
166
173
  htmlFile,
167
174
  logFile,
168
175
  screenshotFile,
@@ -202,26 +209,53 @@ class Action {
202
209
  }
203
210
  }
204
211
 
205
- private captureMainDocumentResponse(): () => void {
212
+ private captureResponses(): () => void {
206
213
  const page = this.playwrightHelper.page;
207
214
  if (!page?.on || !page?.off) return () => {};
208
215
 
209
216
  this.mainDocumentStatus = undefined;
217
+ this.networkRequests = [];
210
218
 
211
219
  const handler = (response: any) => {
212
220
  const request = response.request();
213
- if (request.resourceType() !== 'document') return;
214
- if (response.frame() !== page.mainFrame()) return;
215
221
  const status = response.status();
216
222
  if (typeof status !== 'number') return;
217
223
  if (status <= 0) return;
218
- this.mainDocumentStatus = status;
224
+
225
+ if (request.resourceType() === 'document') {
226
+ if (response.frame() !== page.mainFrame()) return;
227
+ this.mainDocumentStatus = status;
228
+ return;
229
+ }
230
+
231
+ this.recordNetworkCall(request, status);
219
232
  };
220
233
 
221
234
  page.on('response', handler);
222
235
  return () => page.off('response', handler);
223
236
  }
224
237
 
238
+ private recordNetworkCall(request: any, status: number): void {
239
+ const resourceType = request.resourceType();
240
+ if (resourceType !== 'xhr' && resourceType !== 'fetch') return;
241
+
242
+ const url = URL.parse(request.url());
243
+ if (!url) return;
244
+ if (url.origin !== this.baseOrigin) return;
245
+
246
+ const call: NetworkCall = { method: request.method(), path: url.pathname, status };
247
+ if (this.networkRequests.some((r) => r.method === call.method && r.path === call.path && r.status === call.status)) return;
248
+
249
+ if (this.networkRequests.length >= MAX_NETWORK_CALLS) {
250
+ if (status < 400) return;
251
+ const succeeded = this.networkRequests.findIndex((r) => r.status < 400);
252
+ if (succeeded === -1) return;
253
+ this.networkRequests.splice(succeeded, 1);
254
+ }
255
+
256
+ this.networkRequests.push(call);
257
+ }
258
+
225
259
  /**
226
260
  * Capture HTML snapshots of all iframes on the page
227
261
  */
@@ -267,13 +301,7 @@ class Action {
267
301
  try {
268
302
  const logs = await (this.actor as any).grabBrowserLogs();
269
303
 
270
- // Filter for important logs (info, error, warning)
271
- const importantLogs = logs.filter((log: any) => {
272
- const level = log.type || log.level;
273
- return ['info', 'error', 'warning', 'warn'].includes(level);
274
- });
275
-
276
- return importantLogs;
304
+ return logs.map(toBrowserLog).filter((log: any) => IMPORTANT_LOG_LEVELS.has(log.type));
277
305
  } catch (error) {
278
306
  debugLog('Failed to capture browser logs:', error);
279
307
  return [];
@@ -292,7 +320,7 @@ class Action {
292
320
  const stepListener = attachStepLogger(executedSteps, assertionSteps);
293
321
  const groupId = this.recorder ? await this.recorder.beginAction(codeString) : null;
294
322
  this.playwrightGroupId = groupId;
295
- const detachMainDocumentResponse = this.captureMainDocumentResponse();
323
+ const detachResponses = this.captureResponses();
296
324
  const activeSpan = Observability.getSpan();
297
325
  const tracer = trace.getTracer('ai');
298
326
  const stepSpan = activeSpan ? tracer.startSpan('codeceptjs.step', undefined, trace.setSpan(context.active(), activeSpan)) : null;
@@ -341,7 +369,7 @@ class Action {
341
369
  throw err;
342
370
  } finally {
343
371
  this.restorePageTimeout();
344
- detachMainDocumentResponse();
372
+ detachResponses();
345
373
  if (groupId) await this.recorder!.endAction();
346
374
  detachStepLogger(stepListener);
347
375
  if (stepSpan) {
@@ -429,6 +457,12 @@ async function captureHtml(page: any, frame: any, actor: any): Promise<string> {
429
457
  throw new Error('Playwright page is unavailable for HTML capture');
430
458
  }
431
459
 
460
+ function toBrowserLog(log: any): { type: string; text: string } {
461
+ const type = typeof log.type === 'function' ? log.type() : log.type || log.level || 'log';
462
+ const text = typeof log.text === 'function' ? log.text() : log.text || log.message || String(log);
463
+ return { type, text: text.replace(/\s+/g, ' ').trim() };
464
+ }
465
+
432
466
  async function captureTitle(page: any, actor: any): Promise<string> {
433
467
  if (page?.title) return page.title();
434
468
  if (actor?.grabTitle) return actor.grabTitle();
@@ -16,7 +16,7 @@ export function WithWebMode<T extends Constructor>(Base: T) {
16
16
  researcher: ctx.explorBot.agentResearcher(),
17
17
  navigator: ctx.explorBot.agentNavigator(),
18
18
  });
19
- const { see, context, visualClick, learnExperience } = agentTools;
19
+ const { see, context, visualClick } = agentTools;
20
20
 
21
21
  const tools: Record<string, any> = {
22
22
  navigate: tool({
@@ -124,7 +124,6 @@ export function WithWebMode<T extends Constructor>(Base: T) {
124
124
 
125
125
  ...codeceptTools,
126
126
  context,
127
- learnExperience,
128
127
  };
129
128
 
130
129
  if (see) tools.see = see;
package/src/ai/captain.ts CHANGED
@@ -21,7 +21,7 @@ import type { Navigator } from './navigator.ts';
21
21
  import type { Provider } from './provider.ts';
22
22
  import { Researcher } from './researcher.ts';
23
23
  import { TaskAgent } from './task-agent.ts';
24
- import { withdrawVisionTools } from './tools.ts';
24
+ import { createLearnExperienceTool, withdrawVisionTools } from './tools.ts';
25
25
 
26
26
  const MAX_STEPS = 15;
27
27
 
@@ -241,6 +241,14 @@ export class Captain extends CaptainBase implements Agent {
241
241
 
242
242
  private coreTools(task: Task, onDone: (summary: string) => void) {
243
243
  return {
244
+ learnExperience: createLearnExperienceTool({
245
+ getExperienceTracker: () => this.getExperienceTracker(),
246
+ getState: () => {
247
+ const state = this.explorBot.stateManager().getCurrentState();
248
+ if (!state) return null;
249
+ return ActionResult.fromState(state);
250
+ },
251
+ }),
244
252
  done: tool({
245
253
  description: 'Call when the user request is fulfilled.',
246
254
  inputSchema: z.object({
package/src/ai/driller.ts CHANGED
@@ -30,7 +30,7 @@ import type { Navigator } from './navigator.ts';
30
30
  import type { Provider } from './provider.ts';
31
31
  import { drillLocatorRule } from './rules.ts';
32
32
  import { TaskAgent, isInteractive } from './task-agent.ts';
33
- import { createCodeceptJSTools } from './tools.ts';
33
+ import { createCodeceptJSTools, createLearnExperienceTool } from './tools.ts';
34
34
 
35
35
  const debugLog = createDebug('explorbot:driller');
36
36
 
@@ -306,7 +306,11 @@ export class Driller extends TaskAgent implements Agent {
306
306
 
307
307
  let finished = false;
308
308
  const actionTools = this.createVerifiedActionTools(createCodeceptJSTools(this.toolDeps, test), component);
309
- const tools = { ...actionTools, ...this.createDrillFlowTools(originalState, test, interactive) };
309
+ const learnExperience = createLearnExperienceTool({
310
+ getExperienceTracker: () => this.getExperienceTracker(),
311
+ getState: () => ActionResult.fromState(this.stateManager.getCurrentState() || originalState),
312
+ });
313
+ const tools = { ...actionTools, learnExperience, ...this.createDrillFlowTools(originalState, test, interactive) };
310
314
 
311
315
  await loop(
312
316
  async ({ stop, iteration }) => {
@@ -29,6 +29,28 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
29
29
 
30
30
  const captured = requestStore.findCapturedRequest(method, path);
31
31
  if (captured) {
32
+ if (captured.status >= 400) {
33
+ const rejectedCapture = {
34
+ status: captured.status,
35
+ requestBody: captured.requestBody || 'no body',
36
+ };
37
+ if (opts.spec) {
38
+ try {
39
+ const definition = extractEndpointDefinition(opts.spec, path, opts.baseEndpoint);
40
+ return { source: 'spec', method, path, definition, rejectedCapture };
41
+ } catch {
42
+ return { source: 'captured', method, path, usable: false, rejectedRequestBody: captured.requestBody || 'no body', status: captured.status };
43
+ }
44
+ }
45
+ return {
46
+ source: 'captured',
47
+ method: captured.method,
48
+ path: captured.path,
49
+ status: captured.status,
50
+ usable: false,
51
+ rejectedRequestBody: captured.requestBody || 'no body',
52
+ };
53
+ }
32
54
  return {
33
55
  source: 'captured',
34
56
  method: captured.method,
@@ -87,6 +109,7 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
87
109
  success: false,
88
110
  status: reqResult.status,
89
111
  statusText: reqResult.statusText,
112
+ category: responseCategory(reqResult.status),
90
113
  errorPreview: reqResult.rawResponseBody.substring(0, 300),
91
114
  };
92
115
  }
@@ -149,6 +172,16 @@ export function createFishermanTools(apiClient: ApiClient, requestStore: Request
149
172
  return { tools, getResult, isFinished };
150
173
  }
151
174
 
175
+ function responseCategory(status: number): ResponseCategory {
176
+ if (status === 400 || status === 422) return 'validation';
177
+ if (status === 401 || status === 403) return 'authorization';
178
+ if (status === 404) return 'not_found';
179
+ if (status === 409) return 'conflict';
180
+ if (status === 408 || status === 425 || status === 429) return 'temporary';
181
+ if (status >= 500) return 'server';
182
+ return 'client';
183
+ }
184
+
152
185
  function extractKeyFields(body: any, result: Record<string, any> = {}, depth = 0): Record<string, any> {
153
186
  if (!body || typeof body !== 'object' || depth > 5) return result;
154
187
 
@@ -179,3 +212,5 @@ export interface FishermanResult {
179
212
  created: Array<{ type: string; id?: string | number; title?: string }>;
180
213
  failed: Array<{ type: string; reason: string }>;
181
214
  }
215
+
216
+ type ResponseCategory = 'validation' | 'authorization' | 'not_found' | 'conflict' | 'temporary' | 'server' | 'client';
@@ -208,7 +208,8 @@ export class Fisherman implements Agent {
208
208
  RULES:
209
209
  - Always call getEndpointSpec before your first request to an unfamiliar endpoint
210
210
  - Chain requests logically — create parent resources before children
211
- - If a request fails, try once more with adjusted data before reporting failure
211
+ - Use the response category and error text to decide what failed: validation requires corrected data, authorization requires valid access, not_found requires a valid path or parent, and conflict requires resolving the conflicting state
212
+ - Retry temporary or server failures once. Retry other failures only when the specification or error text gives a concrete correction
212
213
  - Use realistic but unique data for each item (vary names, titles)
213
214
 
214
215
  ${dataProtectionRules}
@@ -213,7 +213,7 @@ class Navigator implements Agent {
213
213
  return reasons.join('; ') || null;
214
214
  }
215
215
 
216
- async resolveState(message: string, actionResult: ActionResult, opts?: { action?: Action; expectedUrl?: string; onAttempt?: (attempt: { code: string; error?: string }) => void }): Promise<boolean> {
216
+ async resolveState(message: string, actionResult: ActionResult, opts?: { action?: Action; expectedUrl?: string; experience?: string; onAttempt?: (attempt: { code: string; error?: string }) => void }): Promise<boolean> {
217
217
  if (!this.provider) throw new Error('AI-assisted recovery is unavailable: no AI model is configured.');
218
218
 
219
219
  this.lastFailureReason = null;
@@ -226,7 +226,7 @@ class Navigator implements Agent {
226
226
  const knowledge = this.knowledgeTracker.renderRelevantContext(actionResult);
227
227
 
228
228
  const conversation = this.provider.startConversation(this.systemPrompt, 'navigator');
229
- conversation.addUserText(await this.buildResolutionPrompt(message, actionResult));
229
+ conversation.addUserText(await this.buildResolutionPrompt(message, actionResult, opts?.experience));
230
230
 
231
231
  let stopReason: string | null = null;
232
232
  const tools = {
@@ -369,14 +369,10 @@ class Navigator implements Agent {
369
369
  return resolved;
370
370
  }
371
371
 
372
- private async buildResolutionPrompt(message: string, actionResult: ActionResult): Promise<string> {
373
- let experience = '';
374
- if (!actionResult.isInsideIframe) {
375
- const successful = this.experienceTracker.getSuccessfulExperience(actionResult);
376
- if (successful.length > 0) {
377
- tag('operation').log(`Found ${successful.length} experience ${pluralize(successful.length, 'file')} for: ${actionResult.url}`);
378
- experience = `<experience>\nPast successful recipes recorded from prior runs for this page. Prefer these solutions first if they match the goal.\n\n${successful.join('\n\n')}\n</experience>`;
379
- }
372
+ private async buildResolutionPrompt(message: string, actionResult: ActionResult, injectedExperience?: string): Promise<string> {
373
+ let experience = injectedExperience || '';
374
+ if (!experience && !actionResult.isInsideIframe) {
375
+ experience = this.experienceTracker.renderExperienceFor(actionResult);
380
376
  }
381
377
 
382
378
  return dedent`
package/src/ai/pilot.ts CHANGED
@@ -27,6 +27,8 @@ import { withdrawVisionTools } from './tools.ts';
27
27
 
28
28
  const CHECK_TOOLS = ['verify', 'see', 'research', 'context'];
29
29
  const META_TOOLS = ['record', 'reset', 'stop', 'finish'];
30
+ const PILOT_MESSAGE_LIMIT = 2;
31
+ const PILOT_MESSAGE_MAX_LENGTH = 160;
30
32
 
31
33
  export class Pilot implements Agent {
32
34
  emoji = '🧭';
@@ -465,9 +467,13 @@ export class Pilot implements Agent {
465
467
  the elements needed for the scenario. The page summary does not list every element.
466
468
  Prefer interacting with the current page over navigating away.
467
469
 
468
- If you load a recipe via learnExperience, do NOT rewrite its code in your plan — the
469
- raw recipe is forwarded to Tester automatically. Reference it by step ("apply recipe
470
- steps 1–3, then…") and call out anywhere your scenario diverges from it.
470
+ Tester never sees <experience> a recorded recipe reaches it only when you open one.
471
+ The entries listed are what was recorded on the page you are on now; recipes for the
472
+ pages this test moves to are listed when it gets there. Open the ones whose titles fit a
473
+ step taken from here, and say so in the plan when none of them fit.
474
+ Do NOT rewrite a loaded recipe's code — the raw recipe is forwarded to Tester
475
+ automatically. Reference it by step ("apply recipe steps 1–3, then…") and call out
476
+ anywhere your scenario diverges from it.
471
477
 
472
478
  Be concise and specific. Tester will follow your plan.
473
479
  `,
@@ -513,9 +519,11 @@ export class Pilot implements Agent {
513
519
  ${this.formatExpectations(task)}
514
520
 
515
521
  First: evaluate whether this navigation makes sense for the scenario goal. If the page is unrelated, instruct Tester to back() or reset(). Then plan next steps.
522
+
523
+ Tester holds no recipe for this page until you load one — open the <experience> entries whose titles fit a step you are about to instruct.
516
524
  `,
517
525
  'pilot.reviewNewPage',
518
- { task }
526
+ { tools: true, maxToolRoundtrips: 2, task }
519
527
  );
520
528
  }
521
529
 
@@ -549,6 +557,8 @@ export class Pilot implements Agent {
549
557
  </recent_actions>
550
558
 
551
559
  What should Tester do next?
560
+
561
+ Before proposing new locators for a step that keeps failing, check <experience> for a recorded recipe covering it and load it.
552
562
  `,
553
563
  'pilot.analyze',
554
564
  { tools: hasFailures, maxToolRoundtrips: hasFailures ? 2 : 0, task }
@@ -665,10 +675,9 @@ export class Pilot implements Agent {
665
675
 
666
676
  let finalUserText = userText;
667
677
  if (opts.tools) {
678
+ this.conversation!.cleanupTag('experience', '...cleaned experience index...');
668
679
  const tocBlock = this.getExperienceToc();
669
- if (tocBlock) {
670
- finalUserText = `${tocBlock}\n\n${userText}`;
671
- }
680
+ if (tocBlock) finalUserText = `${tocBlock}\n\n${userText}`;
672
681
  }
673
682
  this.conversation!.addUserText(finalUserText);
674
683
 
@@ -682,8 +691,9 @@ export class Pilot implements Agent {
682
691
  telemetry: { functionId },
683
692
  });
684
693
  const text = result?.response?.text || '';
685
- const learned = (result?.toolExecutions || []).filter((e: any) => e.toolName === 'learnExperience' && e.output?.content).map((e: any) => e.output.content);
694
+ const learned = (result?.toolExecutions || []).filter((e: any) => e.toolName === 'learnExperience' && e.output?.content).map((e: any) => ({ url: e.output.url, content: e.output.content }));
686
695
  if (learned.length === 0) return text;
696
+ opts.task.applyExperience(learned);
687
697
  return dedent`
688
698
  ${text}
689
699
 
@@ -691,7 +701,7 @@ export class Pilot implements Agent {
691
701
  Recipes from prior successful runs that Pilot judged relevant. Locators worked then; the page may have changed since.
692
702
  Treat code blocks below as a starting hypothesis. If a locator misses, fall back to ARIA/UI-map.
693
703
 
694
- ${learned.join('\n\n')}
704
+ ${learned.map((recipe) => recipe.content).join('\n\n')}
695
705
  </applied_experience>
696
706
  `;
697
707
  }
@@ -718,6 +728,7 @@ export class Pilot implements Agent {
718
728
  }
719
729
 
720
730
  private buildPreconditionTool(task: Test) {
731
+ const unavailable = 'Data was not created and cannot be created automatically. Do not call precondition again for this test — continue with what the page already shows.';
721
732
  return {
722
733
  precondition: tool({
723
734
  description: 'Create fresh disposable data that the test will act on (edit, delete, filter). Describe WHAT to create, not what exists. Do NOT request users. Examples: "1 post", "1 comment", "1 label named Bug".',
@@ -732,7 +743,7 @@ export class Pilot implements Agent {
732
743
  if (!this.fisherman || !this.fisherman.isAvailable()) {
733
744
  const skipReason = await this.checkDataAvailability(task, description, 'Fisherman not available');
734
745
  if (skipReason) return { noted: true, prepared: false, skipped: true, reason: skipReason };
735
- return { noted: true, prepared: false, reason: 'Fisherman not available' };
746
+ return { noted: true, prepared: false, reason: unavailable };
736
747
  }
737
748
 
738
749
  const result = await this.fisherman.prepareData(description, task.startUrl, task.sessionName);
@@ -741,7 +752,7 @@ export class Pilot implements Agent {
741
752
  if (result.summary) tag('warning').log(`Precondition failed: ${result.summary}`);
742
753
  const skipReason = await this.checkDataAvailability(task, description, result.summary);
743
754
  if (skipReason) return { noted: true, prepared: false, skipped: true, reason: skipReason };
744
- return { noted: true, prepared: false, reason: result.summary };
755
+ return { noted: true, prepared: false, reason: `${result.summary || 'Data preparation failed'}. ${unavailable}` };
745
756
  }
746
757
 
747
758
  const items = result.created.map((c) => {
@@ -778,7 +789,7 @@ export class Pilot implements Agent {
778
789
  Reply with YES or NO on the first line, then a one-sentence reason on the second line.
779
790
  `;
780
791
 
781
- const answer = await this.researcher.answerQuestionAboutScreenshot(screenshotState, question);
792
+ const answer = await this.researcher.answerQuestionAboutScreenshot(screenshotState, question).catch(() => null);
782
793
  if (!answer) return null;
783
794
 
784
795
  const firstLine = answer.split('\n')[0]?.trim().toUpperCase() ?? '';
@@ -870,15 +881,6 @@ export class Pilot implements Agent {
870
881
  private async fetchRequestedContext(text: string, currentState: ActionResult): Promise<string> {
871
882
  const parts: string[] = [];
872
883
 
873
- if (text.includes('ATTACH_HTML')) {
874
- const html = await currentState.simplifiedHtml();
875
- parts.push(dedent`
876
- <page_html>
877
- ${html}
878
- </page_html>
879
- `);
880
- }
881
-
882
884
  if (text.includes('ATTACH_ARIA')) {
883
885
  parts.push(dedent`
884
886
  <page_aria>
@@ -1048,6 +1050,21 @@ export class Pilot implements Agent {
1048
1050
  const ariaDiff = t.output?.pageDiff?.ariaChanges;
1049
1051
  if (ariaDiff) line += `\n ${ariaDiff}`;
1050
1052
 
1053
+ if (t.output?.pageDiff?.urlChanged) line += `\n moved: ${t.output.pageDiff.previousUrl} → ${t.output.pageDiff.currentUrl}`;
1054
+
1055
+ const failedRequests = (t.output?.pageDiff?.requests ?? []).filter((r: any) => r.status >= 400);
1056
+ if (failedRequests.length > 0) {
1057
+ line += `\n requests: ${failedRequests.map((r: any) => `${r.method} ${r.path} → ${r.status}`).join(', ')}`;
1058
+ }
1059
+
1060
+ const messages = (t.output?.pageDiff?.messages ?? []).slice(0, PILOT_MESSAGE_LIMIT);
1061
+ if (messages.length > 0) {
1062
+ line += `\n messages: ${messages.map((m: string) => m.slice(0, PILOT_MESSAGE_MAX_LENGTH)).join(' | ')}`;
1063
+ }
1064
+
1065
+ const consoleError = t.output?.pageDiff?.consoleErrors?.[0];
1066
+ if (consoleError) line += `\n console: ${consoleError.slice(0, PILOT_MESSAGE_MAX_LENGTH)}`;
1067
+
1051
1068
  return line;
1052
1069
  })
1053
1070
  .join('\n\n');
@@ -1116,10 +1133,10 @@ export class Pilot implements Agent {
1116
1133
  role, icon classes with "or" in one XPath. If empty, broaden (drop role filter). Pass discovered
1117
1134
  XPath into NEXT instruction.
1118
1135
 
1119
- To request more context, mention ATTACH_HTML, ATTACH_ARIA, or ATTACH_UI_MAP — only when recent actions show failures.
1136
+ To request more context, mention ATTACH_ARIA, ATTACH_SUMMARY, or ATTACH_UI_MAP — only when recent actions show failures.
1120
1137
 
1121
- Tester tools: click, pressKey, form, see, verify, context, research, xpathCheck, visualClick,
1122
- back, getVisitedStates, reset, stop, finish, record.
1138
+ Tester tools: click, pressKey, form, see, verify, interact, context, research, xpathCheck,
1139
+ visualClick, back, getVisitedStates, reset, stop, finish, record.
1123
1140
  Use tool names exactly as listed. Do not invent combined names, aliases, or names with channel markers such as "commentary".
1124
1141
 
1125
1142
  ${capabilityGroundingRule}
@@ -4,7 +4,7 @@ import { normalizeUrl } from '../../state-manager.ts';
4
4
  import type { StateManager } from '../../state-manager.ts';
5
5
  import type { Plan } from '../../test-plan.ts';
6
6
  import { tag } from '../../utils/logger.ts';
7
- import { isDynamicSegment } from '../../utils/url-matcher.ts';
7
+ import { isSamePageFamily } from '../../utils/url-matcher.ts';
8
8
  import type { Provider } from '../provider.ts';
9
9
  import type { Constructor } from '../researcher/mixin.ts';
10
10
 
@@ -39,18 +39,7 @@ function buildKey(url: string, feature?: string): string {
39
39
  }
40
40
 
41
41
  export function isTemplateMatch(urlA: string, urlB: string): boolean {
42
- const partsA = normalizeUrl(urlA).split('/');
43
- const partsB = normalizeUrl(urlB).split('/');
44
- if (partsA.length !== partsB.length) return false;
45
-
46
- let diffCount = 0;
47
- for (let i = 0; i < partsA.length; i++) {
48
- if (partsA[i] === partsB[i]) continue;
49
- diffCount++;
50
- if (diffCount > 1) return false;
51
- if (!isDynamicSegment(partsA[i]) && !isDynamicSegment(partsB[i])) return false;
52
- }
53
- return diffCount === 1;
42
+ return isSamePageFamily(urlA, urlB);
54
43
  }
55
44
 
56
45
  export function getPlannedByStateHash(hash: string): PlanRecord | null {
package/src/ai/planner.ts CHANGED
@@ -149,7 +149,7 @@ export class Planner extends PlannerBase implements Agent {
149
149
 
150
150
  const actionResult = ActionResult.fromState(state);
151
151
  const combinedHtml = await actionResult.combinedHtml();
152
- const similarHash = await findSimilarStateHash(combinedHtml);
152
+ const similarHash = await findSimilarStateHash(combinedHtml, state.url);
153
153
  if (similarHash) {
154
154
  const planned = getPlannedByStateHash(similarHash);
155
155
  if (planned) {
@@ -365,7 +365,7 @@ export class Provider {
365
365
  const extraStop = options.stopWhen;
366
366
  const stopConditions: any[] = [isStepCount(maxRoundtrips)];
367
367
  if (extraStop) stopConditions.push(extraStop);
368
- const config = this.buildGenerateConfig({ tools, maxOutputTokens: 16384, toolChoice: 'auto' }, { stopWhen: stopConditions, model }, options);
368
+ const config = this.buildGenerateConfig({ tools, maxOutputTokens: 16384, toolChoice: 'auto', experimental_repairToolCall: repairToolCall }, { stopWhen: stopConditions, model }, options);
369
369
  try {
370
370
  const response = await withRetry(async () => {
371
371
  const result = (await this.raceWithIdleTimeout((signal) => generateText({ messages, ...config, abortSignal: signal }), config.timeout || 30000)) as any;
@@ -627,4 +627,20 @@ export class Provider {
627
627
  }
628
628
  }
629
629
 
630
+ function repairToolCall(options: ToolCallRepairOptions): any | null {
631
+ if (options.toolCall.toolName.includes('<|channel|>')) return repairChannelMarker(options);
632
+ return null;
633
+ }
634
+
635
+ function repairChannelMarker({ toolCall, tools }: ToolCallRepairOptions): any | null {
636
+ const markerIndex = toolCall.toolName.indexOf('<|channel|>');
637
+ if (markerIndex <= 0) return null;
638
+ const toolName = toolCall.toolName.slice(0, markerIndex);
639
+ if (!tools[toolName]) return null;
640
+ tag('warning').log(`Repaired tool name '${toolCall.toolName}' → '${toolName}'`);
641
+ return { ...toolCall, toolName };
642
+ }
643
+
630
644
  export { AiError, Provider as AIProvider };
645
+
646
+ type ToolCallRepairOptions = { toolCall: any; tools: any };
@@ -60,7 +60,8 @@ export function getPreviousResearch(hash: string): string {
60
60
  return readFileSync(researchFile, 'utf8');
61
61
  }
62
62
 
63
- export function saveResearch(hash: string, text: string, combinedHtml?: string): string {
63
+ export function saveResearch(state: ResearchState, text: string, combinedHtml?: string): string {
64
+ const { hash, url } = state;
64
65
  const researchDir = outputPath('research');
65
66
  const researchFile = join(researchDir, `${hash}.md`);
66
67
  if (!existsSync(researchDir)) mkdirSync(researchDir, { recursive: true });
@@ -74,14 +75,16 @@ export function saveResearch(hash: string, text: string, combinedHtml?: string):
74
75
  if (!existsSync(statesDir)) mkdirSync(statesDir, { recursive: true });
75
76
  const fingerprint = computeHtmlFingerprint(combinedHtml);
76
77
  const fingerprintFile = join(statesDir, `${hash}.fingerprint`);
77
- writeFileSync(fingerprintFile, fingerprint.join('\n'));
78
+ const record: FingerprintRecord = { entries: fingerprint };
79
+ if (url) record.url = url;
80
+ writeFileSync(fingerprintFile, JSON.stringify(record));
78
81
  debugLog(`Fingerprint saved to ${fingerprintFile}`);
79
82
  }
80
83
 
81
84
  return researchFile;
82
85
  }
83
86
 
84
- function findSimilarMatch(combinedHtml: string): Promise<{ hash: string; similarity: number } | null> {
87
+ function findSimilarMatch(combinedHtml: string, url?: string): Promise<FingerprintMatch | null> {
85
88
  const statesDir = getStatesDir();
86
89
  if (!existsSync(statesDir)) return Promise.resolve(null);
87
90
 
@@ -93,7 +96,7 @@ function findSimilarMatch(combinedHtml: string): Promise<{ hash: string; similar
93
96
  resolve(null);
94
97
  }, FINGERPRINT_WORKER_TIMEOUT_MS);
95
98
 
96
- worker.on('message', (data: { matchHash: string | null; similarity: number }) => {
99
+ worker.on('message', (data: { matchHash: string | null; similarity: number; url?: string }) => {
97
100
  clearTimeout(timeout);
98
101
  const { matchHash, similarity } = data;
99
102
  if (!matchHash) {
@@ -102,7 +105,7 @@ function findSimilarMatch(combinedHtml: string): Promise<{ hash: string; similar
102
105
  }
103
106
 
104
107
  debugLog(`Similar fingerprint found: ${matchHash} (${similarity}% similar)`);
105
- resolve({ hash: matchHash, similarity });
108
+ resolve({ hash: matchHash, similarity, url: data.url });
106
109
  });
107
110
 
108
111
  worker.postMessage({
@@ -110,17 +113,22 @@ function findSimilarMatch(combinedHtml: string): Promise<{ hash: string; similar
110
113
  statesDir,
111
114
  maxAgeMs: FINGERPRINT_MAX_AGE_MS,
112
115
  threshold: SIMILARITY_THRESHOLD,
116
+ url,
113
117
  });
114
118
  });
115
119
  }
116
120
 
117
- export async function findSimilarResearch(combinedHtml: string): Promise<string | null> {
118
- const match = await findSimilarMatch(combinedHtml);
121
+ export async function findSimilarResearch(combinedHtml: string, url?: string): Promise<string | null> {
122
+ const match = await findSimilarMatch(combinedHtml, url);
119
123
  if (!match) return null;
120
124
  return getCachedResearch(match.hash) || null;
121
125
  }
122
126
 
123
- export async function findSimilarStateHash(combinedHtml: string): Promise<string | null> {
124
- const match = await findSimilarMatch(combinedHtml);
127
+ export async function findSimilarStateHash(combinedHtml: string, url?: string): Promise<string | null> {
128
+ const match = await findSimilarMatch(combinedHtml, url);
125
129
  return match?.hash || null;
126
130
  }
131
+
132
+ type FingerprintRecord = { entries: string[]; url?: string };
133
+ type FingerprintMatch = { hash: string; similarity: number; url?: string };
134
+ type ResearchState = { hash: string; url?: string };
@@ -128,7 +128,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
128
128
  updated = `${cached.trimEnd()}\n\n# Extended Research\n\n${sectionMarkdown}\n`;
129
129
  }
130
130
 
131
- saveResearch(pageStateHash, updated);
131
+ saveResearch({ hash: pageStateHash }, updated);
132
132
  tag('substep').log(`Overlay research appended: ${focusArea.name}`);
133
133
  return sectionMarkdown;
134
134
  }