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,20 +1,18 @@
1
- import { readFileSync, unlinkSync, writeFileSync } from 'node:fs';
1
+ import { unlinkSync } from 'node:fs';
2
2
  import dedent from 'dedent';
3
- import matter from 'gray-matter';
3
+ import { type Tokens, marked } from 'marked';
4
4
  import { z } from 'zod';
5
- import type { ExperienceTracker } from '../experience-tracker.js';
5
+ import { type ExperienceFile, type ExperienceTracker, RECENT_WINDOW_DAYS } from '../experience-tracker.js';
6
6
  import { Observability } from '../observability.js';
7
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';
8
10
  import type { Agent } from './agent.js';
9
11
  import type { Provider } from './provider.js';
10
12
 
11
13
  const debugLog = createDebug('explorbot:experience-compactor');
12
14
 
13
- interface ExperienceFile {
14
- filePath: string;
15
- data: { url?: string; title?: string; [key: string]: any };
16
- content: string;
17
- }
15
+ export type { ExperienceFile };
18
16
 
19
17
  interface MergeGroup {
20
18
  pattern: string;
@@ -33,11 +31,12 @@ export class ExperienceCompactor implements Agent {
33
31
  }
34
32
 
35
33
  async compactExperience(experience: string): Promise<string> {
36
- if (experience.length < this.MAX_LENGTH) {
37
- return experience;
34
+ const stripped = this.stripNonUsefulEntries(experience);
35
+ if (stripped.length < this.MAX_LENGTH) {
36
+ return stripped;
38
37
  }
39
38
 
40
- const prompt = this.buildCompactionPrompt(experience);
39
+ const prompt = this.buildCompactionPrompt(stripped);
41
40
  const model = this.provider.getModelForAgent('experience-compactor');
42
41
  const response = await this.provider.chat(
43
42
  [
@@ -50,38 +49,174 @@ export class ExperienceCompactor implements Agent {
50
49
  return response.text;
51
50
  }
52
51
 
53
- async compactAllExperiences(): Promise<number> {
54
- return Observability.run('experience-compactor.compactAll', { tags: ['experience-compactor'] }, async () => {
55
- await this.mergeSimilarExperiences();
52
+ stripNonUsefulEntries(content: string): string {
53
+ let result = dropNonReusableSections(content);
54
+ result = dropEmptySections(result);
55
+ return result;
56
+ }
56
57
 
57
- const experienceFiles = this.experienceTracker.getAllExperience();
58
- let compactedCount = 0;
59
-
60
- for (const experience of experienceFiles) {
61
- const prevContent = experience.content;
62
- const frontmatter = experience.data;
63
- const compactedContent = await this.compactExperienceFile(experience.filePath);
64
-
65
- if (prevContent !== compactedContent) {
66
- const stateHash = experience.filePath.split('/').pop()?.replace('.md', '') || '';
67
- this.experienceTracker.writeExperienceFile(stateHash, compactedContent, frontmatter);
68
- debugLog('Experience file compacted:', experience.filePath);
69
- compactedCount++;
70
- }
58
+ isRecent(file: ExperienceFile): boolean {
59
+ const ageDays = (Date.now() - file.mtime.getTime()) / (1000 * 60 * 60 * 24);
60
+ return ageDays <= RECENT_WINDOW_DAYS;
61
+ }
62
+
63
+ async reviewExperienceQuality(content: string, context: { url?: string; title?: string }): Promise<string> {
64
+ const sections = listSections(content);
65
+ if (sections.length === 0) return content;
66
+
67
+ const prompt = this.buildReviewPrompt(sections, context);
68
+ const schema = z.object({
69
+ sections: z.array(
70
+ z.object({
71
+ index: z.number().int(),
72
+ keep: z.boolean(),
73
+ reason: z.string(),
74
+ })
75
+ ),
76
+ });
77
+
78
+ try {
79
+ const model = this.provider.getModelForAgent('experience-compactor');
80
+ const response = await this.provider.generateObject(
81
+ [
82
+ { role: 'user', content: this.getSystemPrompt() },
83
+ { role: 'user', content: prompt },
84
+ ],
85
+ schema,
86
+ model,
87
+ { telemetryFunctionId: 'experience.reviewQuality' }
88
+ );
89
+
90
+ const decisions = response?.object?.sections || [];
91
+ let result = content;
92
+ for (const decision of decisions) {
93
+ if (decision.keep) continue;
94
+ const target = sections[decision.index];
95
+ if (!target) continue;
96
+ debugLog('Dropping section: %s (reason: %s)', target.title, decision.reason);
97
+ result = result.replace(target.raw, '');
71
98
  }
99
+ return result;
100
+ } catch (error) {
101
+ debugLog('AI quality review failed, keeping content unchanged: %s', error);
102
+ return content;
103
+ }
104
+ }
105
+
106
+ async compactAllExperiences(): Promise<{ merged: number; compacted: number }> {
107
+ return Observability.run('experience-compactor.compactAll', { tags: ['experience-compactor'] }, async () => {
108
+ const aiMerged = await this.mergeSimilarExperiences();
109
+ const generalized = this.generalizeDynamicUrls();
110
+ const compacted = await this.compactFiles(this.experienceTracker.getAllExperience());
111
+ return { merged: aiMerged + generalized, compacted };
112
+ });
113
+ }
72
114
 
73
- return compactedCount;
115
+ async autocompact(): Promise<{ merged: number; compacted: number }> {
116
+ return Observability.run('experience-compactor.autocompact', { tags: ['experience-compactor'] }, async () => {
117
+ const aiMerged = await this.mergeSimilarExperiences({ onlyDynamic: true });
118
+ const generalized = this.generalizeDynamicUrls();
119
+ const compacted = await this.compactFiles(this.experienceTracker.getAllExperience(), { skipSmall: true });
120
+ return { merged: aiMerged + generalized, compacted };
74
121
  });
75
122
  }
76
123
 
77
- async mergeSimilarExperiences(): Promise<number> {
124
+ generalizeDynamicUrls(): number {
125
+ const files = this.experienceTracker.getAllExperience();
126
+ let count = 0;
127
+ for (const file of files) {
128
+ const url = file.data.url as string | undefined;
129
+ if (!url || url.startsWith('~')) continue;
130
+ const generalized = generalizeUrl(url);
131
+ if (generalized === url) continue;
132
+ const stateHash = file.filePath.split('/').pop()?.replace('.md', '') || '';
133
+ const newData = { ...file.data, url: `~${generalized}~`, mergedFrom: [url] };
134
+ this.experienceTracker.writeExperienceFile(stateHash, file.content, newData);
135
+ tag('substep').log(`Generalized URL: ${url} → ~${generalized}~`);
136
+ count++;
137
+ }
138
+ return count;
139
+ }
140
+
141
+ async compactFiles(files: ExperienceFile[], options?: { skipSmall?: boolean }): Promise<number> {
142
+ const workingSet = options?.skipSmall ? files.filter((f) => f.content.length >= this.MAX_LENGTH) : files;
143
+
144
+ let compactedCount = 0;
145
+ const total = workingSet.length;
146
+ const aiReviewCount = workingSet.filter((f) => this.isRecent(f)).length;
147
+
148
+ if (total > 1) {
149
+ tag('info').log(`Processing ${total} experience file${total === 1 ? '' : 's'} (${aiReviewCount} will get AI review — ~${aiReviewCount * 3}s minimum)…`);
150
+ }
151
+
152
+ for (let i = 0; i < workingSet.length; i++) {
153
+ const experience = workingSet[i];
154
+ const shortName = experience.filePath.split('/').pop() || experience.filePath;
155
+ const willReview = this.isRecent(experience);
156
+
157
+ if (total > 1 && willReview) {
158
+ tag('substep').log(`[${i + 1}/${total}] reviewing ${shortName}`);
159
+ }
160
+
161
+ let content = this.stripNonUsefulEntries(experience.content);
162
+
163
+ if (willReview) {
164
+ content = await this.reviewExperienceQuality(content, experience.data);
165
+ }
166
+
167
+ if (content.length >= this.MAX_LENGTH) {
168
+ if (total > 1) tag('substep').log(`[${i + 1}/${total}] compacting ${shortName} (over ${this.MAX_LENGTH} chars)`);
169
+ const prompt = this.buildCompactionPrompt(content);
170
+ const model = this.provider.getModelForAgent('experience-compactor');
171
+ const response = await this.provider.chat(
172
+ [
173
+ { role: 'user', content: this.getSystemPrompt() },
174
+ { role: 'user', content: prompt },
175
+ ],
176
+ model,
177
+ { telemetryFunctionId: 'experience.compact' }
178
+ );
179
+ content = response.text;
180
+ }
181
+
182
+ if (content === experience.content) continue;
183
+
184
+ const stateHash = experience.filePath.split('/').pop()?.replace('.md', '') || '';
185
+ this.experienceTracker.writeExperienceFile(stateHash, content, experience.data);
186
+ debugLog('Experience file compacted:', experience.filePath);
187
+ compactedCount++;
188
+ }
189
+
190
+ return compactedCount;
191
+ }
192
+
193
+ async mergeSimilarExperiences(options?: { onlyDynamic?: boolean }): Promise<number> {
78
194
  return Observability.run('experience-compactor.merge', { tags: ['experience-compactor'] }, async () => {
79
195
  const experienceFiles = this.experienceTracker.getAllExperience();
80
196
  if (experienceFiles.length < 2) {
81
197
  return 0;
82
198
  }
83
199
 
84
- const mergeGroups = await this.identifyMergeGroups(experienceFiles);
200
+ let candidates = experienceFiles.filter((f) => f.data.url && !f.data.url.startsWith('~'));
201
+ if (options?.onlyDynamic) {
202
+ candidates = candidates.filter((f) => hasDynamicUrlSegment(f.data.url as string));
203
+ }
204
+
205
+ if (candidates.length < 2) {
206
+ debugLog('No mergeable URL patterns — skipping merge.');
207
+ return 0;
208
+ }
209
+
210
+ tag('info').log(`Experience compaction: checking ${candidates.length} file${candidates.length === 1 ? '' : 's'} for mergeable URL patterns…`);
211
+
212
+ const mergeGroups = await this.identifyMergeGroups(candidates);
213
+
214
+ if (mergeGroups.length === 0) {
215
+ tag('info').log('No URL groups to merge.');
216
+ return 0;
217
+ }
218
+
219
+ tag('info').log(`Merging ${mergeGroups.length} URL group${mergeGroups.length === 1 ? '' : 's'}…`);
85
220
  let mergedCount = 0;
86
221
 
87
222
  for (const group of mergeGroups) {
@@ -110,7 +245,7 @@ export class ExperienceCompactor implements Agent {
110
245
  const usedFiles = new Set<string>();
111
246
 
112
247
  for (const decision of mergeDecisions) {
113
- const matchingFiles = filesWithUrl.filter((f) => decision.urls.includes(f.data.url as string) && !usedFiles.has(f.filePath));
248
+ const matchingFiles = decision.urls.map((url) => filesWithUrl.find((f) => f.data.url === url && !usedFiles.has(f.filePath))).filter((f): f is ExperienceFile => Boolean(f));
114
249
 
115
250
  if (matchingFiles.length >= 2) {
116
251
  groups.push({
@@ -200,28 +335,6 @@ export class ExperienceCompactor implements Agent {
200
335
  }
201
336
  }
202
337
 
203
- async compactExperienceFile(filePath: string): Promise<string> {
204
- try {
205
- const fileContent = readFileSync(filePath, 'utf8');
206
- const parsed = matter(fileContent);
207
-
208
- if (parsed.content.length < this.MAX_LENGTH) {
209
- return parsed.content;
210
- }
211
- debugLog('Experience file to compact:', filePath);
212
-
213
- const text = await this.compactExperience(parsed.content);
214
-
215
- tag('substep').log('Experience file compacted:', filePath);
216
- debugLog('Experience file compacted:', text);
217
-
218
- return text;
219
- } catch (error) {
220
- debugLog('Error compacting experience file:', error);
221
- return '';
222
- }
223
- }
224
-
225
338
  private getSystemPrompt(): string {
226
339
  const customPrompt = this.provider.getSystemPromptForAgent('experience-compactor', '*');
227
340
  return dedent`
@@ -235,7 +348,7 @@ export class ExperienceCompactor implements Agent {
235
348
  private buildCompactionPrompt(content: string): string {
236
349
  return dedent`
237
350
  <rules>
238
- - Use markdown headers only (##, ###) - NO XML tags or wrappers in output
351
+ - Use markdown h2 headers only (##) - NO XML tags or wrappers in output
239
352
  - Merge similar flows to remove duplicates
240
353
  - Keep output under ${this.MAX_LENGTH} characters
241
354
  - Be explicit and short - no proposals or explanations
@@ -255,20 +368,18 @@ export class ExperienceCompactor implements Agent {
255
368
  </rules>
256
369
 
257
370
  <output_format>
258
- Use this markdown structure:
371
+ Use this markdown structure. Titles must be imperative verb phrases, lowercase-first (e.g. "create a new user"):
259
372
 
260
- ## Flows
373
+ ## FLOW: <multi-step imperative title>
261
374
 
262
- For each unique positive flow (merge duplicates):
263
- - Purpose: what was accomplished
375
+ * <step message>
264
376
  \`\`\`js
265
377
  // working code
266
378
  \`\`\`
379
+ ---
267
380
 
268
- ## Reusable Actions
381
+ ## ACTION: <single-step imperative title>
269
382
 
270
- For each reusable interaction pattern not covered by flows:
271
- - Purpose: what was accomplished
272
383
  \`\`\`js
273
384
  // working code
274
385
  \`\`\`
@@ -278,7 +389,103 @@ export class ExperienceCompactor implements Agent {
278
389
  ${content}
279
390
  </context>
280
391
 
281
- Compact this experience data following the format above.
392
+ Compact this experience data following the format above. Every section must be either a multi-step FLOW or a single-step ACTION.
393
+ `;
394
+ }
395
+
396
+ private buildReviewPrompt(sections: Array<{ title: string; raw: string }>, context: { url?: string; title?: string }): string {
397
+ const url = context.url || 'unknown';
398
+ const title = context.title || 'unknown';
399
+ const sectionsList = sections
400
+ .map((s, i) => {
401
+ return `Section ${i}: ${s.title}\n${s.raw.trim()}`;
402
+ })
403
+ .join('\n\n---\n\n');
404
+
405
+ return dedent`
406
+ Review each experience section stored for the page below. For each, decide whether to keep it for future test automation use.
407
+
408
+ <page>
409
+ url: ${url}
410
+ title: ${title}
411
+ </page>
412
+
413
+ <mental_model>
414
+ 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.
415
+ </mental_model>
416
+
417
+ <drop_if>
418
+ - 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.
419
+ - 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.
420
+ - 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.
421
+ - Locator is dynamic / brittle: ember IDs, random UUIDs, numeric data-testid, positional XPaths like div[3].
422
+ - Too generic to reuse: "click button", "fill input" with no specific target.
423
+ - Duplicates another section on this same page.
424
+ - Body has no executable CodeceptJS code block.
425
+ </drop_if>
426
+
427
+ <keep_if>
428
+ Everything else. Prefer keeping sections — only drop when clearly low value.
429
+ </keep_if>
430
+
431
+ <sections>
432
+ ${sectionsList}
433
+ </sections>
434
+
435
+ Return a decision per section: { index, keep (boolean), reason (short) }.
282
436
  `;
283
437
  }
284
438
  }
439
+
440
+ function listSections(content: string): { title: string; raw: string }[] {
441
+ const tokens = marked.lexer(content);
442
+ const sections: { title: string; raw: string }[] = [];
443
+
444
+ let currentHeading: string | null = null;
445
+ let currentRaw = '';
446
+
447
+ const flush = () => {
448
+ if (currentHeading !== null) sections.push({ title: currentHeading, raw: currentRaw });
449
+ };
450
+
451
+ for (const token of tokens) {
452
+ const raw = (token as any).raw || '';
453
+ if (token.type === 'heading' && (token as Tokens.Heading).depth === 2) {
454
+ flush();
455
+ currentHeading = (token as Tokens.Heading).text.trim();
456
+ currentRaw = raw;
457
+ continue;
458
+ }
459
+ if (currentHeading !== null) currentRaw += raw;
460
+ }
461
+ flush();
462
+ return sections;
463
+ }
464
+
465
+ function dropEmptySections(content: string): string {
466
+ let result = content;
467
+ const sections = [...mdq(result).query('section2(~"FLOW:")').each(), ...mdq(result).query('section2(~"ACTION:")').each()];
468
+
469
+ for (const section of sections) {
470
+ const raw = section.text();
471
+ const hasCode = mdq(raw).query('code').count() > 0;
472
+ const hasList = mdq(raw).query('list').count() > 0;
473
+ if (hasCode || hasList) continue;
474
+ result = result.replace(raw, '');
475
+ }
476
+
477
+ return result;
478
+ }
479
+
480
+ function dropNonReusableSections(content: string): string {
481
+ let result = content;
482
+ const sections = [...mdq(result).query('section2(~"FLOW:")').each(), ...mdq(result).query('section2(~"ACTION:")').each()];
483
+
484
+ for (const section of sections) {
485
+ const raw = section.text();
486
+ if (!/\bI\.clickXY\s*\(/.test(raw)) continue;
487
+ result = result.replace(raw, '');
488
+ }
489
+
490
+ return result;
491
+ }
@@ -4,15 +4,15 @@ import dedent from 'dedent';
4
4
  import { z } from 'zod';
5
5
  import { ActionResult } from '../action-result.ts';
6
6
  import { ConfigParser } from '../config.ts';
7
+ import { ExperienceTracker, type SessionStep } from '../experience-tracker.ts';
7
8
  import { KnowledgeTracker } from '../knowledge-tracker.ts';
8
- import { ExperienceTracker, type SessionExperienceEntry, type SessionStep } from '../experience-tracker.ts';
9
9
  import { type Reporter, type ReporterStep } from '../reporter.ts';
10
10
  import type { StateManager } from '../state-manager.ts';
11
11
  import { type Plan, type Task, Test } from '../test-plan.ts';
12
12
  import { createDebug, tag } from '../utils/logger.ts';
13
+ import { extractStatePath } from '../utils/url-matcher.ts';
13
14
  import type { Conversation, ToolExecution } from './conversation.ts';
14
15
  import type { Provider } from './provider.ts';
15
- import { extractStatePath } from '../utils/url-matcher.ts';
16
16
  import { ASSERTION_TOOLS, CODECEPT_TOOLS } from './tools.ts';
17
17
 
18
18
  const debugLog = createDebug('explorbot:historian');
@@ -51,13 +51,11 @@ export class Historian {
51
51
 
52
52
  if (verifiedSteps.length > 0) {
53
53
  const relatedUrls = this.extractVisitedUrls(toolExecutions, initialState.url || '');
54
- const entry: SessionExperienceEntry = {
54
+ this.experienceTracker.writeFlow(initialState, {
55
55
  scenario: task.description,
56
- result,
57
56
  steps: verifiedSteps,
58
57
  relatedUrls,
59
- };
60
- this.experienceTracker.saveSessionExperience(initialState, entry);
58
+ });
61
59
  }
62
60
 
63
61
  if (task instanceof Test && result !== 'failed') {
@@ -87,6 +85,7 @@ export class Historian {
87
85
  if (!CODECEPT_TOOLS.includes(exec.toolName as any)) continue;
88
86
  if (!exec.output?.code) continue;
89
87
  if (!exec.wasSuccessful) continue;
88
+ if (isNonReusableCode(exec.output.code)) continue;
90
89
 
91
90
  const message = this.getExecutionLabel(exec, `Executed ${exec.toolName}`);
92
91
  const ariaDiff = exec.output?.pageDiff?.ariaChanges || null;
@@ -252,7 +251,8 @@ export class Historian {
252
251
  }
253
252
  }
254
253
 
255
- await this.experienceTracker.saveSuccessfulResolution(state, pattern.intent, candidate.success.output.code, pattern.explanation);
254
+ if (isNonReusableCode(candidate.success.output.code)) continue;
255
+ this.experienceTracker.writeAction(state, { title: pattern.intent, code: candidate.success.output.code, explanation: pattern.explanation });
256
256
  }
257
257
 
258
258
  debugLog('Detected %d retry patterns', response?.object?.retryPatterns?.length || 0);
@@ -372,6 +372,7 @@ export class Historian {
372
372
  lines.push(`Scenario('${this.escapeString(scenario)}', ({ I }) => {`);
373
373
 
374
374
  for (const exec of successfulSteps) {
375
+ if (isNonReusableCode(exec.output.code)) continue;
375
376
  const explanation = this.getExecutionLabel(exec);
376
377
  if (explanation) {
377
378
  lines.push('');
@@ -496,3 +497,7 @@ export class Historian {
496
497
  .join('\n');
497
498
  }
498
499
  }
500
+
501
+ export function isNonReusableCode(code: string): boolean {
502
+ return /\bI\.clickXY\s*\(/.test(code);
503
+ }