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.
- package/README.md +27 -1
- package/bin/explorbot-cli.ts +86 -15
- package/boat/api-tester/src/ai/curler-tools.ts +3 -3
- package/boat/api-tester/src/ai/curler.ts +1 -1
- package/boat/api-tester/src/apibot.ts +2 -2
- package/boat/api-tester/src/config.ts +1 -1
- package/dist/bin/explorbot-cli.js +85 -14
- package/dist/boat/api-tester/src/ai/curler-tools.js +2 -2
- package/dist/boat/api-tester/src/apibot.js +2 -2
- package/dist/package.json +2 -2
- package/dist/rules/navigator/output.md +9 -0
- package/dist/rules/navigator/verification-actions.md +2 -0
- package/dist/src/action-result.js +23 -1
- package/dist/src/action.js +46 -38
- package/dist/src/ai/bosun.js +16 -2
- package/dist/src/ai/conversation.js +39 -0
- package/dist/src/ai/experience-compactor.js +235 -50
- package/dist/src/ai/historian/codeceptjs.js +109 -0
- package/dist/src/ai/historian/experience.js +320 -0
- package/dist/src/ai/historian/mixin.js +2 -0
- package/dist/src/ai/historian/playwright.js +145 -0
- package/dist/src/ai/historian/utils.js +18 -0
- package/dist/src/ai/historian.js +19 -398
- package/dist/src/ai/navigator.js +133 -80
- package/dist/src/ai/pilot.js +254 -13
- package/dist/src/ai/planner/subpages.js +1 -30
- package/dist/src/ai/planner.js +33 -13
- package/dist/src/ai/provider.js +55 -18
- package/dist/src/ai/rerunner.js +3 -3
- package/dist/src/ai/researcher/deep-analysis.js +1 -1
- package/dist/src/ai/researcher/fingerprint-worker.js +1 -1
- package/dist/src/ai/researcher/locators.js +1 -1
- package/dist/src/ai/researcher/sections.js +8 -1
- package/dist/src/ai/researcher.js +43 -41
- package/dist/src/ai/rules.js +26 -14
- package/dist/src/ai/tester.js +90 -26
- package/dist/src/ai/tools.js +18 -10
- package/dist/src/api/request-store.js +20 -0
- package/dist/src/api/xhr-capture.js +19 -3
- package/dist/src/browser-server.js +16 -3
- package/dist/src/command-handler.js +1 -1
- package/dist/src/commands/add-rule-command.js +12 -9
- package/dist/src/commands/base-command.js +20 -0
- package/dist/src/commands/clean-command.js +3 -2
- package/dist/src/commands/compact-command.js +138 -0
- package/dist/src/commands/context-command.js +7 -1
- package/dist/src/commands/drill-command.js +4 -1
- package/dist/src/commands/experience-command.js +104 -0
- package/dist/src/commands/explore-command.js +54 -19
- package/dist/src/commands/freesail-command.js +2 -0
- package/dist/src/commands/index.js +7 -3
- package/dist/src/commands/init-command.js +11 -10
- package/dist/src/commands/learn-command.js +1 -1
- package/dist/src/commands/navigate-command.js +4 -1
- package/dist/src/commands/plan-clear-command.js +4 -1
- package/dist/src/commands/plan-command.js +43 -4
- package/dist/src/commands/plan-edit-command.js +1 -1
- package/dist/src/commands/plan-load-command.js +4 -1
- package/dist/src/commands/plan-reload-command.js +4 -1
- package/dist/src/commands/plan-save-command.js +20 -8
- package/dist/src/commands/rerun-command.js +4 -0
- package/dist/src/commands/research-command.js +5 -2
- package/dist/src/commands/start-command.js +5 -1
- package/dist/src/commands/test-command.js +7 -1
- package/dist/src/components/App.js +15 -5
- package/dist/src/execution-controller.js +13 -2
- package/dist/src/experience-tracker.js +174 -83
- package/dist/src/explorbot.js +31 -22
- package/dist/src/explorer.js +12 -5
- package/dist/src/observability.js +50 -99
- package/dist/src/playwright-recorder.js +309 -0
- package/dist/src/reporter.js +17 -2
- package/dist/src/stats.js +2 -0
- package/dist/src/suite.js +1 -1
- package/dist/src/test-plan.js +12 -0
- package/dist/src/utils/aria.js +37 -1
- package/dist/src/utils/error-page.js +30 -7
- package/dist/src/utils/logger.js +1 -1
- package/dist/src/utils/next-steps.js +37 -0
- package/dist/src/utils/rules-loader.js +1 -1
- package/dist/src/utils/test-files.js +1 -1
- package/dist/src/utils/url-matcher.js +50 -0
- package/package.json +2 -2
- package/rules/navigator/output.md +9 -0
- package/rules/navigator/verification-actions.md +2 -0
- package/src/action-result.ts +26 -1
- package/src/action.ts +44 -37
- package/src/ai/bosun.ts +16 -2
- package/src/ai/conversation.ts +37 -0
- package/src/ai/experience-compactor.ts +270 -63
- package/src/ai/historian/codeceptjs.ts +130 -0
- package/src/ai/historian/experience.ts +383 -0
- package/src/ai/historian/mixin.ts +4 -0
- package/src/ai/historian/playwright.ts +169 -0
- package/src/ai/historian/utils.ts +23 -0
- package/src/ai/historian.ts +35 -468
- package/src/ai/navigator.ts +140 -85
- package/src/ai/pilot.ts +259 -14
- package/src/ai/planner/subpages.ts +1 -24
- package/src/ai/planner.ts +34 -14
- package/src/ai/provider.ts +52 -18
- package/src/ai/rerunner.ts +3 -3
- package/src/ai/researcher/deep-analysis.ts +1 -1
- package/src/ai/researcher/fingerprint-worker.ts +1 -1
- package/src/ai/researcher/locators.ts +2 -2
- package/src/ai/researcher/sections.ts +7 -1
- package/src/ai/researcher.ts +47 -42
- package/src/ai/rules.ts +27 -14
- package/src/ai/task-agent.ts +1 -1
- package/src/ai/tester.ts +94 -26
- package/src/ai/tools.ts +53 -29
- package/src/api/request-store.ts +22 -0
- package/src/api/xhr-capture.ts +21 -3
- package/src/browser-server.ts +17 -3
- package/src/command-handler.ts +1 -1
- package/src/commands/add-rule-command.ts +13 -9
- package/src/commands/base-command.ts +26 -1
- package/src/commands/clean-command.ts +4 -3
- package/src/commands/compact-command.ts +156 -0
- package/src/commands/context-command.ts +8 -2
- package/src/commands/drill-command.ts +5 -2
- package/src/commands/experience-command.ts +125 -0
- package/src/commands/explore-command.ts +58 -21
- package/src/commands/freesail-command.ts +2 -0
- package/src/commands/index.ts +7 -3
- package/src/commands/init-command.ts +11 -10
- package/src/commands/learn-command.ts +2 -2
- package/src/commands/navigate-command.ts +5 -2
- package/src/commands/plan-clear-command.ts +5 -2
- package/src/commands/plan-command.ts +47 -5
- package/src/commands/plan-edit-command.ts +2 -2
- package/src/commands/plan-load-command.ts +5 -2
- package/src/commands/plan-reload-command.ts +5 -2
- package/src/commands/plan-save-command.ts +20 -9
- package/src/commands/rerun-command.ts +5 -0
- package/src/commands/research-command.ts +6 -3
- package/src/commands/start-command.ts +6 -2
- package/src/commands/test-command.ts +8 -2
- package/src/components/App.tsx +16 -5
- package/src/config.ts +6 -1
- package/src/execution-controller.ts +14 -3
- package/src/experience-tracker.ts +198 -100
- package/src/explorbot.ts +33 -23
- package/src/explorer.ts +14 -5
- package/src/observability.ts +50 -109
- package/src/playwright-recorder.ts +305 -0
- package/src/reporter.ts +17 -3
- package/src/stats.ts +4 -0
- package/src/suite.ts +1 -1
- package/src/test-plan.ts +12 -0
- package/src/utils/aria.ts +38 -1
- package/src/utils/error-page.ts +32 -7
- package/src/utils/logger.ts +1 -1
- package/src/utils/next-steps.ts +51 -0
- package/src/utils/rules-loader.ts +1 -1
- package/src/utils/test-files.ts +1 -1
- package/src/utils/url-matcher.ts +43 -0
package/dist/src/ai/bosun.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
|
1
|
+
import { unlinkSync } from 'node:fs';
|
|
2
2
|
import dedent from 'dedent';
|
|
3
|
-
import
|
|
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
|
-
|
|
19
|
-
|
|
21
|
+
const stripped = this.stripNonUsefulEntries(experience);
|
|
22
|
+
if (stripped.length < this.MAX_LENGTH) {
|
|
23
|
+
return stripped;
|
|
20
24
|
}
|
|
21
|
-
const prompt = this.buildCompactionPrompt(
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 (
|
|
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
|
-
##
|
|
306
|
+
## FLOW: <multi-step imperative title>
|
|
208
307
|
|
|
209
|
-
|
|
210
|
-
- Purpose: what was accomplished
|
|
308
|
+
* <step message>
|
|
211
309
|
\`\`\`js
|
|
212
310
|
// working code
|
|
213
311
|
\`\`\`
|
|
312
|
+
---
|
|
214
313
|
|
|
215
|
-
##
|
|
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
|
+
}
|