explorbot 0.1.9 → 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 +28 -5
  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 +30 -7
  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,9 +1,12 @@
1
- import { readFileSync, unlinkSync } from 'node:fs';
1
+ import { unlinkSync } from 'node:fs';
2
2
  import dedent from 'dedent';
3
- import matter from 'gray-matter';
3
+ import { marked } from 'marked';
4
4
  import { z } from 'zod';
5
+ import { RECENT_WINDOW_DAYS } from '../experience-tracker.js';
5
6
  import { Observability } from '../observability.js';
6
7
  import { createDebug, log, tag } from '../utils/logger.js';
8
+ import { mdq } from '../utils/markdown-query.js';
9
+ import { generalizeUrl, hasDynamicUrlSegment } from '../utils/url-matcher.js';
7
10
  const debugLog = createDebug('explorbot:experience-compactor');
8
11
  export class ExperienceCompactor {
9
12
  emoji = '🗜️';
@@ -15,10 +18,11 @@ export class ExperienceCompactor {
15
18
  this.experienceTracker = experienceTracker;
16
19
  }
17
20
  async compactExperience(experience) {
18
- if (experience.length < this.MAX_LENGTH) {
19
- return experience;
21
+ const stripped = this.stripNonUsefulEntries(experience);
22
+ if (stripped.length < this.MAX_LENGTH) {
23
+ return stripped;
20
24
  }
21
- const prompt = this.buildCompactionPrompt(experience);
25
+ const prompt = this.buildCompactionPrompt(stripped);
22
26
  const model = this.provider.getModelForAgent('experience-compactor');
23
27
  const response = await this.provider.chat([
24
28
  { role: 'user', content: this.getSystemPrompt() },
@@ -26,32 +30,145 @@ export class ExperienceCompactor {
26
30
  ], model, { telemetryFunctionId: 'experience.compact' });
27
31
  return response.text;
28
32
  }
33
+ stripNonUsefulEntries(content) {
34
+ let result = dropNonReusableSections(content);
35
+ result = dropEmptySections(result);
36
+ return result;
37
+ }
38
+ isRecent(file) {
39
+ const ageDays = (Date.now() - file.mtime.getTime()) / (1000 * 60 * 60 * 24);
40
+ return ageDays <= RECENT_WINDOW_DAYS;
41
+ }
42
+ async reviewExperienceQuality(content, context) {
43
+ const sections = listSections(content);
44
+ if (sections.length === 0)
45
+ return content;
46
+ const prompt = this.buildReviewPrompt(sections, context);
47
+ const schema = z.object({
48
+ sections: z.array(z.object({
49
+ index: z.number().int(),
50
+ keep: z.boolean(),
51
+ reason: z.string(),
52
+ })),
53
+ });
54
+ try {
55
+ const model = this.provider.getModelForAgent('experience-compactor');
56
+ const response = await this.provider.generateObject([
57
+ { role: 'user', content: this.getSystemPrompt() },
58
+ { role: 'user', content: prompt },
59
+ ], schema, model, { telemetryFunctionId: 'experience.reviewQuality' });
60
+ const decisions = response?.object?.sections || [];
61
+ let result = content;
62
+ for (const decision of decisions) {
63
+ if (decision.keep)
64
+ continue;
65
+ const target = sections[decision.index];
66
+ if (!target)
67
+ continue;
68
+ debugLog('Dropping section: %s (reason: %s)', target.title, decision.reason);
69
+ result = result.replace(target.raw, '');
70
+ }
71
+ return result;
72
+ }
73
+ catch (error) {
74
+ debugLog('AI quality review failed, keeping content unchanged: %s', error);
75
+ return content;
76
+ }
77
+ }
29
78
  async compactAllExperiences() {
30
79
  return Observability.run('experience-compactor.compactAll', { tags: ['experience-compactor'] }, async () => {
31
- await this.mergeSimilarExperiences();
32
- const experienceFiles = this.experienceTracker.getAllExperience();
33
- let compactedCount = 0;
34
- for (const experience of experienceFiles) {
35
- const prevContent = experience.content;
36
- const frontmatter = experience.data;
37
- const compactedContent = await this.compactExperienceFile(experience.filePath);
38
- if (prevContent !== compactedContent) {
39
- const stateHash = experience.filePath.split('/').pop()?.replace('.md', '') || '';
40
- this.experienceTracker.writeExperienceFile(stateHash, compactedContent, frontmatter);
41
- debugLog('Experience file compacted:', experience.filePath);
42
- compactedCount++;
43
- }
44
- }
45
- return compactedCount;
80
+ const aiMerged = await this.mergeSimilarExperiences();
81
+ const generalized = this.generalizeDynamicUrls();
82
+ const compacted = await this.compactFiles(this.experienceTracker.getAllExperience());
83
+ return { merged: aiMerged + generalized, compacted };
84
+ });
85
+ }
86
+ async autocompact() {
87
+ return Observability.run('experience-compactor.autocompact', { tags: ['experience-compactor'] }, async () => {
88
+ const aiMerged = await this.mergeSimilarExperiences({ onlyDynamic: true });
89
+ const generalized = this.generalizeDynamicUrls();
90
+ const compacted = await this.compactFiles(this.experienceTracker.getAllExperience(), { skipSmall: true });
91
+ return { merged: aiMerged + generalized, compacted };
46
92
  });
47
93
  }
48
- async mergeSimilarExperiences() {
94
+ generalizeDynamicUrls() {
95
+ const files = this.experienceTracker.getAllExperience();
96
+ let count = 0;
97
+ for (const file of files) {
98
+ const url = file.data.url;
99
+ if (!url || url.startsWith('~'))
100
+ continue;
101
+ const generalized = generalizeUrl(url);
102
+ if (generalized === url)
103
+ continue;
104
+ const stateHash = file.filePath.split('/').pop()?.replace('.md', '') || '';
105
+ const newData = { ...file.data, url: `~${generalized}~`, mergedFrom: [url] };
106
+ this.experienceTracker.writeExperienceFile(stateHash, file.content, newData);
107
+ tag('substep').log(`Generalized URL: ${url} → ~${generalized}~`);
108
+ count++;
109
+ }
110
+ return count;
111
+ }
112
+ async compactFiles(files, options) {
113
+ const workingSet = options?.skipSmall ? files.filter((f) => f.content.length >= this.MAX_LENGTH) : files;
114
+ let compactedCount = 0;
115
+ const total = workingSet.length;
116
+ const aiReviewCount = workingSet.filter((f) => this.isRecent(f)).length;
117
+ if (total > 1) {
118
+ tag('info').log(`Processing ${total} experience file${total === 1 ? '' : 's'} (${aiReviewCount} will get AI review — ~${aiReviewCount * 3}s minimum)…`);
119
+ }
120
+ for (let i = 0; i < workingSet.length; i++) {
121
+ const experience = workingSet[i];
122
+ const shortName = experience.filePath.split('/').pop() || experience.filePath;
123
+ const willReview = this.isRecent(experience);
124
+ if (total > 1 && willReview) {
125
+ tag('substep').log(`[${i + 1}/${total}] reviewing ${shortName}`);
126
+ }
127
+ let content = this.stripNonUsefulEntries(experience.content);
128
+ if (willReview) {
129
+ content = await this.reviewExperienceQuality(content, experience.data);
130
+ }
131
+ if (content.length >= this.MAX_LENGTH) {
132
+ if (total > 1)
133
+ tag('substep').log(`[${i + 1}/${total}] compacting ${shortName} (over ${this.MAX_LENGTH} chars)`);
134
+ const prompt = this.buildCompactionPrompt(content);
135
+ const model = this.provider.getModelForAgent('experience-compactor');
136
+ const response = await this.provider.chat([
137
+ { role: 'user', content: this.getSystemPrompt() },
138
+ { role: 'user', content: prompt },
139
+ ], model, { telemetryFunctionId: 'experience.compact' });
140
+ content = response.text;
141
+ }
142
+ if (content === experience.content)
143
+ continue;
144
+ const stateHash = experience.filePath.split('/').pop()?.replace('.md', '') || '';
145
+ this.experienceTracker.writeExperienceFile(stateHash, content, experience.data);
146
+ debugLog('Experience file compacted:', experience.filePath);
147
+ compactedCount++;
148
+ }
149
+ return compactedCount;
150
+ }
151
+ async mergeSimilarExperiences(options) {
49
152
  return Observability.run('experience-compactor.merge', { tags: ['experience-compactor'] }, async () => {
50
153
  const experienceFiles = this.experienceTracker.getAllExperience();
51
154
  if (experienceFiles.length < 2) {
52
155
  return 0;
53
156
  }
54
- const mergeGroups = await this.identifyMergeGroups(experienceFiles);
157
+ let candidates = experienceFiles.filter((f) => f.data.url && !f.data.url.startsWith('~'));
158
+ if (options?.onlyDynamic) {
159
+ candidates = candidates.filter((f) => hasDynamicUrlSegment(f.data.url));
160
+ }
161
+ if (candidates.length < 2) {
162
+ debugLog('No mergeable URL patterns — skipping merge.');
163
+ return 0;
164
+ }
165
+ tag('info').log(`Experience compaction: checking ${candidates.length} file${candidates.length === 1 ? '' : 's'} for mergeable URL patterns…`);
166
+ const mergeGroups = await this.identifyMergeGroups(candidates);
167
+ if (mergeGroups.length === 0) {
168
+ tag('info').log('No URL groups to merge.');
169
+ return 0;
170
+ }
171
+ tag('info').log(`Merging ${mergeGroups.length} URL group${mergeGroups.length === 1 ? '' : 's'}…`);
55
172
  let mergedCount = 0;
56
173
  for (const group of mergeGroups) {
57
174
  if (group.files.length < 2) {
@@ -73,7 +190,7 @@ export class ExperienceCompactor {
73
190
  const groups = [];
74
191
  const usedFiles = new Set();
75
192
  for (const decision of mergeDecisions) {
76
- const matchingFiles = filesWithUrl.filter((f) => decision.urls.includes(f.data.url) && !usedFiles.has(f.filePath));
193
+ const matchingFiles = decision.urls.map((url) => filesWithUrl.find((f) => f.data.url === url && !usedFiles.has(f.filePath))).filter((f) => Boolean(f));
77
194
  if (matchingFiles.length >= 2) {
78
195
  groups.push({
79
196
  pattern: decision.pattern,
@@ -152,24 +269,6 @@ export class ExperienceCompactor {
152
269
  }
153
270
  }
154
271
  }
155
- async compactExperienceFile(filePath) {
156
- try {
157
- const fileContent = readFileSync(filePath, 'utf8');
158
- const parsed = matter(fileContent);
159
- if (parsed.content.length < this.MAX_LENGTH) {
160
- return parsed.content;
161
- }
162
- debugLog('Experience file to compact:', filePath);
163
- const text = await this.compactExperience(parsed.content);
164
- tag('substep').log('Experience file compacted:', filePath);
165
- debugLog('Experience file compacted:', text);
166
- return text;
167
- }
168
- catch (error) {
169
- debugLog('Error compacting experience file:', error);
170
- return '';
171
- }
172
- }
173
272
  getSystemPrompt() {
174
273
  const customPrompt = this.provider.getSystemPromptForAgent('experience-compactor', '*');
175
274
  return dedent `
@@ -182,7 +281,7 @@ export class ExperienceCompactor {
182
281
  buildCompactionPrompt(content) {
183
282
  return dedent `
184
283
  <rules>
185
- - Use markdown headers only (##, ###) - NO XML tags or wrappers in output
284
+ - Use markdown h2 headers only (##) - NO XML tags or wrappers in output
186
285
  - Merge similar flows to remove duplicates
187
286
  - Keep output under ${this.MAX_LENGTH} characters
188
287
  - Be explicit and short - no proposals or explanations
@@ -202,20 +301,18 @@ export class ExperienceCompactor {
202
301
  </rules>
203
302
 
204
303
  <output_format>
205
- Use this markdown structure:
304
+ Use this markdown structure. Titles must be imperative verb phrases, lowercase-first (e.g. "create a new user"):
206
305
 
207
- ## Flows
306
+ ## FLOW: <multi-step imperative title>
208
307
 
209
- For each unique positive flow (merge duplicates):
210
- - Purpose: what was accomplished
308
+ * <step message>
211
309
  \`\`\`js
212
310
  // working code
213
311
  \`\`\`
312
+ ---
214
313
 
215
- ## Reusable Actions
314
+ ## ACTION: <single-step imperative title>
216
315
 
217
- For each reusable interaction pattern not covered by flows:
218
- - Purpose: what was accomplished
219
316
  \`\`\`js
220
317
  // working code
221
318
  \`\`\`
@@ -225,7 +322,95 @@ export class ExperienceCompactor {
225
322
  ${content}
226
323
  </context>
227
324
 
228
- Compact this experience data following the format above.
325
+ Compact this experience data following the format above. Every section must be either a multi-step FLOW or a single-step ACTION.
326
+ `;
327
+ }
328
+ buildReviewPrompt(sections, context) {
329
+ const url = context.url || 'unknown';
330
+ const title = context.title || 'unknown';
331
+ const sectionsList = sections
332
+ .map((s, i) => {
333
+ return `Section ${i}: ${s.title}\n${s.raw.trim()}`;
334
+ })
335
+ .join('\n\n---\n\n');
336
+ return dedent `
337
+ Review each experience section stored for the page below. For each, decide whether to keep it for future test automation use.
338
+
339
+ <page>
340
+ url: ${url}
341
+ title: ${title}
342
+ </page>
343
+
344
+ <mental_model>
345
+ Every section answers the question "HOW to <title>?". If the title does not complete that question naturally, the section has no value. A FLOW teaches a multi-step procedure; an ACTION teaches a single atomic step. Verifications and one-off recoveries are out of scope.
346
+ </mental_model>
347
+
348
+ <drop_if>
349
+ - Title does not read as a reusable instruction — it describes a transient recovery, retry, or navigation step. Any title starting with "attempt", "try", "retry", "need to", "ensure", "go back", "return (to)", or ending with "(RESET)" is not a teaching.
350
+ - Title is a verification / assertion rather than an action. Any "verify", "verification", "see that", "check that", "expect", "assert", or "and verify" phrasing means the teaching is about asserting state, not doing something — drop.
351
+ - ACTION describes more than one atomic step. Multiple verb phrases, "and", "then", or comma-joined actions mean it is actually a FLOW, not an ACTION — drop so the flow gets re-captured correctly.
352
+ - Locator is dynamic / brittle: ember IDs, random UUIDs, numeric data-testid, positional XPaths like div[3].
353
+ - Too generic to reuse: "click button", "fill input" with no specific target.
354
+ - Duplicates another section on this same page.
355
+ - Body has no executable CodeceptJS code block.
356
+ </drop_if>
357
+
358
+ <keep_if>
359
+ Everything else. Prefer keeping sections — only drop when clearly low value.
360
+ </keep_if>
361
+
362
+ <sections>
363
+ ${sectionsList}
364
+ </sections>
365
+
366
+ Return a decision per section: { index, keep (boolean), reason (short) }.
229
367
  `;
230
368
  }
231
369
  }
370
+ function listSections(content) {
371
+ const tokens = marked.lexer(content);
372
+ const sections = [];
373
+ let currentHeading = null;
374
+ let currentRaw = '';
375
+ const flush = () => {
376
+ if (currentHeading !== null)
377
+ sections.push({ title: currentHeading, raw: currentRaw });
378
+ };
379
+ for (const token of tokens) {
380
+ const raw = token.raw || '';
381
+ if (token.type === 'heading' && token.depth === 2) {
382
+ flush();
383
+ currentHeading = token.text.trim();
384
+ currentRaw = raw;
385
+ continue;
386
+ }
387
+ if (currentHeading !== null)
388
+ currentRaw += raw;
389
+ }
390
+ flush();
391
+ return sections;
392
+ }
393
+ function dropEmptySections(content) {
394
+ let result = content;
395
+ const sections = [...mdq(result).query('section2(~"FLOW:")').each(), ...mdq(result).query('section2(~"ACTION:")').each()];
396
+ for (const section of sections) {
397
+ const raw = section.text();
398
+ const hasCode = mdq(raw).query('code').count() > 0;
399
+ const hasList = mdq(raw).query('list').count() > 0;
400
+ if (hasCode || hasList)
401
+ continue;
402
+ result = result.replace(raw, '');
403
+ }
404
+ return result;
405
+ }
406
+ function dropNonReusableSections(content) {
407
+ let result = content;
408
+ const sections = [...mdq(result).query('section2(~"FLOW:")').each(), ...mdq(result).query('section2(~"ACTION:")').each()];
409
+ for (const section of sections) {
410
+ const raw = section.text();
411
+ if (!/\bI\.clickXY\s*\(/.test(raw))
412
+ continue;
413
+ result = result.replace(raw, '');
414
+ }
415
+ return result;
416
+ }
@@ -4,8 +4,8 @@ import dedent from 'dedent';
4
4
  import { z } from 'zod';
5
5
  import { ActionResult } from "../action-result.js";
6
6
  import { ConfigParser } from "../config.js";
7
- import { KnowledgeTracker } from "../knowledge-tracker.js";
8
7
  import { ExperienceTracker } from "../experience-tracker.js";
8
+ import { KnowledgeTracker } from "../knowledge-tracker.js";
9
9
  import { Test } from "../test-plan.js";
10
10
  import { createDebug, tag } from "../utils/logger.js";
11
11
  import { extractStatePath } from "../utils/url-matcher.js";
@@ -38,13 +38,11 @@ export class Historian {
38
38
  const verifiedSteps = await this.verifySteps(steps, initialState);
39
39
  if (verifiedSteps.length > 0) {
40
40
  const relatedUrls = this.extractVisitedUrls(toolExecutions, initialState.url || '');
41
- const entry = {
41
+ this.experienceTracker.writeFlow(initialState, {
42
42
  scenario: task.description,
43
- result,
44
43
  steps: verifiedSteps,
45
44
  relatedUrls,
46
- };
47
- this.experienceTracker.saveSessionExperience(initialState, entry);
45
+ });
48
46
  }
49
47
  if (task instanceof Test && result !== 'failed') {
50
48
  await this.reportSession(task, steps);
@@ -71,6 +69,8 @@ export class Historian {
71
69
  continue;
72
70
  if (!exec.wasSuccessful)
73
71
  continue;
72
+ if (isNonReusableCode(exec.output.code))
73
+ continue;
74
74
  const message = this.getExecutionLabel(exec, `Executed ${exec.toolName}`);
75
75
  const ariaDiff = exec.output?.pageDiff?.ariaChanges || null;
76
76
  const urlChanged = exec.output?.pageDiff?.urlChanged || false;
@@ -203,7 +203,9 @@ export class Historian {
203
203
  state = ActionResult.fromState(transition.toState);
204
204
  }
205
205
  }
206
- await this.experienceTracker.saveSuccessfulResolution(state, pattern.intent, candidate.success.output.code, pattern.explanation);
206
+ if (isNonReusableCode(candidate.success.output.code))
207
+ continue;
208
+ this.experienceTracker.writeAction(state, { title: pattern.intent, code: candidate.success.output.code, explanation: pattern.explanation });
207
209
  }
208
210
  debugLog('Detected %d retry patterns', response?.object?.retryPatterns?.length || 0);
209
211
  }
@@ -307,6 +309,8 @@ export class Historian {
307
309
  const lines = [];
308
310
  lines.push(`Scenario('${this.escapeString(scenario)}', ({ I }) => {`);
309
311
  for (const exec of successfulSteps) {
312
+ if (isNonReusableCode(exec.output.code))
313
+ continue;
310
314
  const explanation = this.getExecutionLabel(exec);
311
315
  if (explanation) {
312
316
  lines.push('');
@@ -418,3 +422,6 @@ export class Historian {
418
422
  .join('\n');
419
423
  }
420
424
  }
425
+ export function isNonReusableCode(code) {
426
+ return /\bI\.clickXY\s*\(/.test(code);
427
+ }