explorbot 0.1.9 → 0.1.11

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 (157) hide show
  1. package/README.md +27 -1
  2. package/bin/explorbot-cli.ts +86 -15
  3. package/boat/api-tester/src/ai/curler-tools.ts +3 -3
  4. package/boat/api-tester/src/ai/curler.ts +1 -1
  5. package/boat/api-tester/src/apibot.ts +2 -2
  6. package/boat/api-tester/src/config.ts +1 -1
  7. package/dist/bin/explorbot-cli.js +85 -14
  8. package/dist/boat/api-tester/src/ai/curler-tools.js +2 -2
  9. package/dist/boat/api-tester/src/apibot.js +2 -2
  10. package/dist/package.json +2 -2
  11. package/dist/rules/navigator/output.md +9 -0
  12. package/dist/rules/navigator/verification-actions.md +2 -0
  13. package/dist/src/action-result.js +23 -1
  14. package/dist/src/action.js +46 -38
  15. package/dist/src/ai/bosun.js +16 -2
  16. package/dist/src/ai/conversation.js +39 -0
  17. package/dist/src/ai/experience-compactor.js +235 -50
  18. package/dist/src/ai/historian/codeceptjs.js +109 -0
  19. package/dist/src/ai/historian/experience.js +320 -0
  20. package/dist/src/ai/historian/mixin.js +2 -0
  21. package/dist/src/ai/historian/playwright.js +145 -0
  22. package/dist/src/ai/historian/utils.js +18 -0
  23. package/dist/src/ai/historian.js +19 -398
  24. package/dist/src/ai/navigator.js +133 -80
  25. package/dist/src/ai/pilot.js +254 -13
  26. package/dist/src/ai/planner/subpages.js +1 -30
  27. package/dist/src/ai/planner.js +33 -13
  28. package/dist/src/ai/provider.js +55 -18
  29. package/dist/src/ai/rerunner.js +3 -3
  30. package/dist/src/ai/researcher/deep-analysis.js +1 -1
  31. package/dist/src/ai/researcher/fingerprint-worker.js +1 -1
  32. package/dist/src/ai/researcher/locators.js +1 -1
  33. package/dist/src/ai/researcher/sections.js +8 -1
  34. package/dist/src/ai/researcher.js +43 -41
  35. package/dist/src/ai/rules.js +26 -14
  36. package/dist/src/ai/tester.js +90 -26
  37. package/dist/src/ai/tools.js +18 -10
  38. package/dist/src/api/request-store.js +20 -0
  39. package/dist/src/api/xhr-capture.js +19 -3
  40. package/dist/src/browser-server.js +16 -3
  41. package/dist/src/command-handler.js +1 -1
  42. package/dist/src/commands/add-rule-command.js +12 -9
  43. package/dist/src/commands/base-command.js +20 -0
  44. package/dist/src/commands/clean-command.js +3 -2
  45. package/dist/src/commands/compact-command.js +138 -0
  46. package/dist/src/commands/context-command.js +7 -1
  47. package/dist/src/commands/drill-command.js +4 -1
  48. package/dist/src/commands/experience-command.js +104 -0
  49. package/dist/src/commands/explore-command.js +54 -19
  50. package/dist/src/commands/freesail-command.js +2 -0
  51. package/dist/src/commands/index.js +7 -3
  52. package/dist/src/commands/init-command.js +11 -10
  53. package/dist/src/commands/learn-command.js +1 -1
  54. package/dist/src/commands/navigate-command.js +4 -1
  55. package/dist/src/commands/plan-clear-command.js +4 -1
  56. package/dist/src/commands/plan-command.js +43 -4
  57. package/dist/src/commands/plan-edit-command.js +1 -1
  58. package/dist/src/commands/plan-load-command.js +4 -1
  59. package/dist/src/commands/plan-reload-command.js +4 -1
  60. package/dist/src/commands/plan-save-command.js +20 -8
  61. package/dist/src/commands/rerun-command.js +4 -0
  62. package/dist/src/commands/research-command.js +5 -2
  63. package/dist/src/commands/start-command.js +5 -1
  64. package/dist/src/commands/test-command.js +7 -1
  65. package/dist/src/components/App.js +15 -5
  66. package/dist/src/execution-controller.js +13 -2
  67. package/dist/src/experience-tracker.js +174 -83
  68. package/dist/src/explorbot.js +31 -22
  69. package/dist/src/explorer.js +12 -5
  70. package/dist/src/observability.js +50 -99
  71. package/dist/src/playwright-recorder.js +309 -0
  72. package/dist/src/reporter.js +17 -2
  73. package/dist/src/stats.js +2 -0
  74. package/dist/src/suite.js +1 -1
  75. package/dist/src/test-plan.js +12 -0
  76. package/dist/src/utils/aria.js +37 -1
  77. package/dist/src/utils/error-page.js +30 -7
  78. package/dist/src/utils/logger.js +1 -1
  79. package/dist/src/utils/next-steps.js +37 -0
  80. package/dist/src/utils/rules-loader.js +1 -1
  81. package/dist/src/utils/test-files.js +1 -1
  82. package/dist/src/utils/url-matcher.js +50 -0
  83. package/package.json +2 -2
  84. package/rules/navigator/output.md +9 -0
  85. package/rules/navigator/verification-actions.md +2 -0
  86. package/src/action-result.ts +26 -1
  87. package/src/action.ts +44 -37
  88. package/src/ai/bosun.ts +16 -2
  89. package/src/ai/conversation.ts +37 -0
  90. package/src/ai/experience-compactor.ts +270 -63
  91. package/src/ai/historian/codeceptjs.ts +130 -0
  92. package/src/ai/historian/experience.ts +383 -0
  93. package/src/ai/historian/mixin.ts +4 -0
  94. package/src/ai/historian/playwright.ts +169 -0
  95. package/src/ai/historian/utils.ts +23 -0
  96. package/src/ai/historian.ts +35 -468
  97. package/src/ai/navigator.ts +140 -85
  98. package/src/ai/pilot.ts +259 -14
  99. package/src/ai/planner/subpages.ts +1 -24
  100. package/src/ai/planner.ts +34 -14
  101. package/src/ai/provider.ts +52 -18
  102. package/src/ai/rerunner.ts +3 -3
  103. package/src/ai/researcher/deep-analysis.ts +1 -1
  104. package/src/ai/researcher/fingerprint-worker.ts +1 -1
  105. package/src/ai/researcher/locators.ts +2 -2
  106. package/src/ai/researcher/sections.ts +7 -1
  107. package/src/ai/researcher.ts +47 -42
  108. package/src/ai/rules.ts +27 -14
  109. package/src/ai/task-agent.ts +1 -1
  110. package/src/ai/tester.ts +94 -26
  111. package/src/ai/tools.ts +53 -29
  112. package/src/api/request-store.ts +22 -0
  113. package/src/api/xhr-capture.ts +21 -3
  114. package/src/browser-server.ts +17 -3
  115. package/src/command-handler.ts +1 -1
  116. package/src/commands/add-rule-command.ts +13 -9
  117. package/src/commands/base-command.ts +26 -1
  118. package/src/commands/clean-command.ts +4 -3
  119. package/src/commands/compact-command.ts +156 -0
  120. package/src/commands/context-command.ts +8 -2
  121. package/src/commands/drill-command.ts +5 -2
  122. package/src/commands/experience-command.ts +125 -0
  123. package/src/commands/explore-command.ts +58 -21
  124. package/src/commands/freesail-command.ts +2 -0
  125. package/src/commands/index.ts +7 -3
  126. package/src/commands/init-command.ts +11 -10
  127. package/src/commands/learn-command.ts +2 -2
  128. package/src/commands/navigate-command.ts +5 -2
  129. package/src/commands/plan-clear-command.ts +5 -2
  130. package/src/commands/plan-command.ts +47 -5
  131. package/src/commands/plan-edit-command.ts +2 -2
  132. package/src/commands/plan-load-command.ts +5 -2
  133. package/src/commands/plan-reload-command.ts +5 -2
  134. package/src/commands/plan-save-command.ts +20 -9
  135. package/src/commands/rerun-command.ts +5 -0
  136. package/src/commands/research-command.ts +6 -3
  137. package/src/commands/start-command.ts +6 -2
  138. package/src/commands/test-command.ts +8 -2
  139. package/src/components/App.tsx +16 -5
  140. package/src/config.ts +6 -1
  141. package/src/execution-controller.ts +14 -3
  142. package/src/experience-tracker.ts +198 -100
  143. package/src/explorbot.ts +33 -23
  144. package/src/explorer.ts +14 -5
  145. package/src/observability.ts +50 -109
  146. package/src/playwright-recorder.ts +305 -0
  147. package/src/reporter.ts +17 -3
  148. package/src/stats.ts +4 -0
  149. package/src/suite.ts +1 -1
  150. package/src/test-plan.ts +12 -0
  151. package/src/utils/aria.ts +38 -1
  152. package/src/utils/error-page.ts +32 -7
  153. package/src/utils/logger.ts +1 -1
  154. package/src/utils/next-steps.ts +51 -0
  155. package/src/utils/rules-loader.ts +1 -1
  156. package/src/utils/test-files.ts +1 -1
  157. package/src/utils/url-matcher.ts +43 -0
@@ -6,8 +6,10 @@ import { setActivity } from "../activity.js";
6
6
  import { Observability } from "../observability.js";
7
7
  import { Plan, Task, Test, TestResult } from "../test-plan.js";
8
8
  import { HooksRunner } from "../utils/hooks-runner.js";
9
+ import { getCliName } from "../utils/cli-name.js";
9
10
  import { createDebug, tag } from "../utils/logger.js";
10
11
  import { loop, pause } from "../utils/loop.js";
12
+ import { printNextSteps } from "../utils/next-steps.js";
11
13
  import { locatorRule } from "./rules.js";
12
14
  import { TaskAgent, isInteractive } from "./task-agent.js";
13
15
  import { createCodeceptJSTools } from "./tools.js";
@@ -372,7 +374,11 @@ export class Bosun extends TaskAgent {
372
374
  const actionResult = ActionResult.fromState(state);
373
375
  const successfulInteractions = results.filter((r) => r.result === 'success' && r.code);
374
376
  for (const interaction of successfulInteractions) {
375
- await experienceTracker.saveSuccessfulResolution(actionResult, `Drill ${interaction.action}: ${interaction.component}`, interaction.code, interaction.description);
377
+ experienceTracker.writeAction(actionResult, {
378
+ title: `Drill ${interaction.action}: ${interaction.component}`,
379
+ code: interaction.code,
380
+ explanation: interaction.description,
381
+ });
376
382
  }
377
383
  if (successfulInteractions.length > 0) {
378
384
  tag('success').log(`Saved ${successfulInteractions.length} interactions to experience`);
@@ -387,7 +393,15 @@ export class Bosun extends TaskAgent {
387
393
  }
388
394
  const content = this.generateKnowledgeContent(state, successfulInteractions);
389
395
  const result = knowledgeTracker.addKnowledge(knowledgePath, content);
390
- tag('success').log(`Knowledge saved to: ${result.filePath}`);
396
+ const cli = getCliName();
397
+ const sections = [
398
+ {
399
+ label: 'Knowledge',
400
+ path: result.filePath,
401
+ commands: [{ label: 'View matches', command: `${cli} knows ${knowledgePath}` }],
402
+ },
403
+ ];
404
+ printNextSteps(sections);
391
405
  }
392
406
  generateKnowledgeContent(state, interactions) {
393
407
  const lines = [];
@@ -1,6 +1,8 @@
1
1
  export function toolExecutionLabel(input) {
2
2
  return input?.explanation || input?.assertion || input?.reason || input?.request || '';
3
3
  }
4
+ const AUTO_COMPACT_ARIA_CHANGES_CUTOFF = 500;
5
+ const AUTO_COMPACT_TARGETED_HTML_CUTOFF = 500;
4
6
  export class Conversation {
5
7
  id;
6
8
  messages;
@@ -105,6 +107,43 @@ export class Conversation {
105
107
  autoTrimTag(tagName, maxLength) {
106
108
  this.autoTrimRules.set(tagName, maxLength);
107
109
  }
110
+ compactToolResults(keepLastN) {
111
+ const toolMessageIndexes = [];
112
+ for (let i = 0; i < this.messages.length; i++) {
113
+ if (this.messages[i].role === 'tool')
114
+ toolMessageIndexes.push(i);
115
+ }
116
+ const compactUpTo = toolMessageIndexes.length - Math.max(0, keepLastN);
117
+ for (let k = 0; k < compactUpTo; k++) {
118
+ const message = this.messages[toolMessageIndexes[k]];
119
+ if (!Array.isArray(message.content))
120
+ continue;
121
+ for (const part of message.content) {
122
+ if (part.type !== 'tool-result')
123
+ continue;
124
+ const rawOutput = part.output;
125
+ if (!rawOutput || rawOutput.type !== 'json' || !rawOutput.value || typeof rawOutput.value !== 'object')
126
+ continue;
127
+ const value = rawOutput.value;
128
+ if (value.pageDiff && typeof value.pageDiff === 'object') {
129
+ const pageDiff = value.pageDiff;
130
+ if (Array.isArray(pageDiff.htmlParts)) {
131
+ pageDiff.htmlParts = undefined;
132
+ pageDiff.compacted = true;
133
+ }
134
+ if (typeof pageDiff.ariaChanges === 'string' && pageDiff.ariaChanges.length > AUTO_COMPACT_ARIA_CHANGES_CUTOFF) {
135
+ pageDiff.ariaChanges = `${pageDiff.ariaChanges.slice(0, AUTO_COMPACT_ARIA_CHANGES_CUTOFF)}...`;
136
+ }
137
+ if (typeof pageDiff.iframes === 'string') {
138
+ pageDiff.iframes = undefined;
139
+ }
140
+ }
141
+ if (typeof value.targetedHtml === 'string' && value.targetedHtml.length > AUTO_COMPACT_TARGETED_HTML_CUTOFF) {
142
+ value.targetedHtml = `${value.targetedHtml.slice(0, AUTO_COMPACT_TARGETED_HTML_CUTOFF)}...`;
143
+ }
144
+ }
145
+ }
146
+ }
108
147
  hasTag(tagName, lastN) {
109
148
  const escapedTag = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
110
149
  const regex = new RegExp(`<${escapedTag}>`, 'g');
@@ -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
+ }
@@ -0,0 +1,109 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { ActionResult } from "../../action-result.js";
4
+ import { ConfigParser } from "../../config.js";
5
+ import { KnowledgeTracker } from "../../knowledge-tracker.js";
6
+ import { tag } from "../../utils/logger.js";
7
+ import { relativeToCwd } from "../../utils/next-steps.js";
8
+ import { ASSERTION_TOOLS, CODECEPT_TOOLS } from "../tools.js";
9
+ import { escapeString, getExecutionLabel, isNonReusableCode, stripComments } from "./utils.js";
10
+ export function WithCodeceptJS(Base) {
11
+ return class extends Base {
12
+ toCode(conversation, scenario) {
13
+ const toolExecutions = conversation.getToolExecutions();
14
+ const TRACKABLE_TOOLS = [...CODECEPT_TOOLS, ...ASSERTION_TOOLS];
15
+ const successfulSteps = toolExecutions.filter((exec) => exec.wasSuccessful && TRACKABLE_TOOLS.includes(exec.toolName) && exec.output?.code);
16
+ if (successfulSteps.length === 0) {
17
+ return '';
18
+ }
19
+ const lines = [];
20
+ lines.push(`Scenario('${escapeString(scenario)}', ({ I }) => {`);
21
+ for (const exec of successfulSteps) {
22
+ if (isNonReusableCode(exec.output.code))
23
+ continue;
24
+ const explanation = getExecutionLabel(exec);
25
+ if (explanation) {
26
+ lines.push('');
27
+ lines.push(` Section('${escapeString(explanation)}');`);
28
+ }
29
+ const code = stripComments(exec.output.code);
30
+ const codeLines = code.includes('\n') ? code.split('\n') : code.split('; ');
31
+ for (const codeLine of codeLines) {
32
+ const trimmed = codeLine.trim();
33
+ if (trimmed) {
34
+ lines.push(` ${trimmed}`);
35
+ }
36
+ }
37
+ }
38
+ lines.push('});');
39
+ return lines.join('\n');
40
+ }
41
+ saveCodeceptPlanToFile(plan) {
42
+ const lines = [];
43
+ lines.push(`import step, { Section } from 'codeceptjs/steps';`);
44
+ lines.push('');
45
+ lines.push(`Feature('${escapeString(plan.title)}')`);
46
+ lines.push('');
47
+ const startUrl = plan.url || plan.tests[0]?.startUrl;
48
+ if (startUrl) {
49
+ lines.push('Before(({ I }) => {');
50
+ lines.push(` I.amOnPage('${escapeString(startUrl)}');`);
51
+ lines.push(...this.getKnowledgeLines(startUrl));
52
+ lines.push('});');
53
+ lines.push('');
54
+ }
55
+ for (const test of plan.tests) {
56
+ if (test.generatedCode) {
57
+ if (test.isSuccessful) {
58
+ lines.push(test.generatedCode);
59
+ }
60
+ else {
61
+ lines.push(`// FAILED: ${test.scenario}`);
62
+ lines.push(test.generatedCode.replace(/Scenario\(/, 'Scenario.skip('));
63
+ }
64
+ lines.push('');
65
+ continue;
66
+ }
67
+ lines.push(`Scenario.todo('${escapeString(test.scenario)}', ({ I }) => {`);
68
+ if (test.plannedSteps.length > 0) {
69
+ for (const step of test.plannedSteps) {
70
+ lines.push(` // ${step}`);
71
+ }
72
+ }
73
+ else {
74
+ lines.push(` // ${test.scenario}`);
75
+ }
76
+ lines.push('});');
77
+ lines.push('');
78
+ }
79
+ const testsDir = ConfigParser.getInstance().getTestsDir();
80
+ mkdirSync(testsDir, { recursive: true });
81
+ const filename = plan.title.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase();
82
+ const filePath = join(testsDir, `${filename}.js`);
83
+ writeFileSync(filePath, lines.join('\n'));
84
+ this.savedFiles.add(filePath);
85
+ tag('substep').log(`Saved plan tests to: ${relativeToCwd(filePath)}`);
86
+ return filePath;
87
+ }
88
+ getKnowledgeLines(url, indent = ' ') {
89
+ const knowledgeTracker = new KnowledgeTracker();
90
+ const state = new ActionResult({ url });
91
+ const { wait, waitForElement, code } = knowledgeTracker.getStateParameters(state, ['wait', 'waitForElement', 'code']);
92
+ const lines = [];
93
+ if (wait !== undefined) {
94
+ lines.push(`${indent}I.wait(${wait});`);
95
+ }
96
+ if (waitForElement) {
97
+ lines.push(`${indent}I.waitForElement(${JSON.stringify(waitForElement)});`);
98
+ }
99
+ if (code) {
100
+ for (const codeLine of code.split('\n')) {
101
+ const trimmed = codeLine.trim();
102
+ if (trimmed)
103
+ lines.push(`${indent}${trimmed}`);
104
+ }
105
+ }
106
+ return lines;
107
+ }
108
+ };
109
+ }