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
|
@@ -3,11 +3,12 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { render } from 'ink';
|
|
4
4
|
import React from 'react';
|
|
5
5
|
import { tag } from '../utils/logger.js';
|
|
6
|
+
import { printNextSteps, relativeToCwd } from "../utils/next-steps.js";
|
|
6
7
|
import { BaseCommand } from './base-command.js';
|
|
7
8
|
export class AddRuleCommand extends BaseCommand {
|
|
8
9
|
name = 'add-rule';
|
|
9
10
|
description = 'Create a rule file for an agent';
|
|
10
|
-
suggestions = ['
|
|
11
|
+
suggestions = [{ command: 'add-rule researcher check-tooltips', hint: 'example — add a rule for the researcher agent' }];
|
|
11
12
|
async execute(args) {
|
|
12
13
|
const parts = args.trim().split(/\s+/);
|
|
13
14
|
const agentName = parts[0] || '';
|
|
@@ -33,18 +34,20 @@ export class AddRuleCommand extends BaseCommand {
|
|
|
33
34
|
mkdirSync(rulesDir, { recursive: true });
|
|
34
35
|
const filePath = join(rulesDir, `${ruleName}.md`);
|
|
35
36
|
if (existsSync(filePath)) {
|
|
36
|
-
tag('warning').log(`Rule file already exists: ${filePath}`);
|
|
37
|
+
tag('warning').log(`Rule file already exists: ${relativeToCwd(filePath)}`);
|
|
37
38
|
return null;
|
|
38
39
|
}
|
|
39
40
|
const content = opts?.content || `Instructions for ${agentName} agent.`;
|
|
40
41
|
writeFileSync(filePath, `${content.trim()}\n`);
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
42
|
+
const configLine = opts?.urlPattern ? `ai.agents.${agentName}.rules: [{ '${opts.urlPattern}': '${ruleName}' }]` : `ai.agents.${agentName}.rules: ['${ruleName}']`;
|
|
43
|
+
const sections = [
|
|
44
|
+
{
|
|
45
|
+
label: 'Rule',
|
|
46
|
+
path: filePath,
|
|
47
|
+
commands: [{ label: 'Add to config', command: configLine }],
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
printNextSteps(sections);
|
|
48
51
|
return filePath;
|
|
49
52
|
}
|
|
50
53
|
}
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
1
2
|
import { Command } from 'commander';
|
|
3
|
+
import { isInteractive } from '../ai/task-agent.js';
|
|
4
|
+
import { getCliName } from '../utils/cli-name.js';
|
|
5
|
+
import { tag } from '../utils/logger.js';
|
|
2
6
|
export class BaseCommand {
|
|
3
7
|
aliases = [];
|
|
4
8
|
options = [];
|
|
@@ -11,6 +15,22 @@ export class BaseCommand {
|
|
|
11
15
|
matches(commandName) {
|
|
12
16
|
return this.name === commandName || this.aliases.includes(commandName);
|
|
13
17
|
}
|
|
18
|
+
printSuggestions() {
|
|
19
|
+
if (this.suggestions.length === 0)
|
|
20
|
+
return;
|
|
21
|
+
const prefix = isInteractive() ? '/' : `${getCliName()} `;
|
|
22
|
+
tag('info').log('');
|
|
23
|
+
tag('info').log(chalk.bold('Suggested:'));
|
|
24
|
+
for (const { command, hint } of this.suggestions) {
|
|
25
|
+
tag('info').log('');
|
|
26
|
+
if (!command) {
|
|
27
|
+
tag('info').log(chalk.dim(hint));
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
tag('info').log(chalk.dim(`${hint}:`));
|
|
31
|
+
tag('info').log(` ${chalk.yellow(`${prefix}${command}`)}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
14
34
|
parseArgs(args) {
|
|
15
35
|
const cmd = new Command();
|
|
16
36
|
cmd.exitOverride();
|
|
@@ -7,6 +7,7 @@ export const CLEAN_TARGETS = {
|
|
|
7
7
|
states: { description: 'page states', getDir: () => outputPath('states') },
|
|
8
8
|
research: { description: 'research cache', getDir: () => outputPath('research') },
|
|
9
9
|
plans: { description: 'test plans', getDir: () => outputPath('plans') },
|
|
10
|
+
tests: { description: 'generated tests', getDir: () => outputPath('tests') },
|
|
10
11
|
experiences: { description: 'experience files', getDir: () => getExperienceDir() },
|
|
11
12
|
output: { description: 'all output files', getDir: () => outputPath() },
|
|
12
13
|
};
|
|
@@ -38,8 +39,8 @@ function cleanDirectoryContents(dirPath) {
|
|
|
38
39
|
}
|
|
39
40
|
export class CleanCommand extends BaseCommand {
|
|
40
41
|
name = 'clean';
|
|
41
|
-
description = 'Clean files: clean [states|research|plans|experiences|output]';
|
|
42
|
-
suggestions = Object.
|
|
42
|
+
description = 'Clean files: clean [states|research|plans|tests|experiences|output]';
|
|
43
|
+
suggestions = Object.entries(CLEAN_TARGETS).map(([name, target]) => ({ command: `clean ${name}`, hint: `clean ${target.description}` }));
|
|
43
44
|
async execute(args) {
|
|
44
45
|
const target = args.trim().toLowerCase();
|
|
45
46
|
if (!target) {
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { tag } from '../utils/logger.js';
|
|
4
|
+
import { BaseCommand } from './base-command.js';
|
|
5
|
+
const COMPACT_THRESHOLD = 5000;
|
|
6
|
+
export class CompactCommand extends BaseCommand {
|
|
7
|
+
name = 'compact';
|
|
8
|
+
description = 'Compact stored experience files; optionally filtered by filename or URL substring';
|
|
9
|
+
options = [
|
|
10
|
+
{ flags: '--dry-run', description: 'Preview without running AI or writing files' },
|
|
11
|
+
{ flags: '--no-merge', description: 'Skip the cross-URL merge step when compacting all' },
|
|
12
|
+
];
|
|
13
|
+
suggestions = [];
|
|
14
|
+
async execute(args) {
|
|
15
|
+
const { opts, args: remaining } = this.parseArgs(args);
|
|
16
|
+
const target = remaining[0];
|
|
17
|
+
const dryRun = !!opts.dryRun;
|
|
18
|
+
const merge = opts.merge !== false;
|
|
19
|
+
const tracker = this.explorBot.getExperienceTracker();
|
|
20
|
+
const files = this.resolveTarget(tracker, target);
|
|
21
|
+
if (files === null) {
|
|
22
|
+
tag('info').log('No experience files found.');
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (files.length === 0) {
|
|
26
|
+
tag('info').log(`No experience files match target "${target}"`);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const compactor = this.explorBot.agentExperienceCompactor();
|
|
30
|
+
if (dryRun) {
|
|
31
|
+
this.printDryRun(compactor, files, target, !target && merge);
|
|
32
|
+
this.suggestions = this.buildDryRunSuggestions(compactor, files, target);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (!target) {
|
|
36
|
+
const { merged, compacted } = await this.runFullSweep(compactor, merge);
|
|
37
|
+
tag('success').log(`Done. ${merged} merged, ${compacted} compacted.`);
|
|
38
|
+
this.suggestions = [{ command: 'experience', hint: 'list stored experiences' }];
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
tag('info').log(`Compacting ${files.length} experience file${files.length === 1 ? '' : 's'}…`);
|
|
42
|
+
const compacted = await compactor.compactFiles(files);
|
|
43
|
+
if (compacted === 0) {
|
|
44
|
+
tag('info').log('No experience changes — nothing to strip and all content under size limit.');
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
tag('success').log(`Compacted ${compacted}/${files.length} experience file${files.length === 1 ? '' : 's'}.`);
|
|
48
|
+
}
|
|
49
|
+
this.suggestions = [{ command: `experience ${target}`, hint: 'view updated file' }];
|
|
50
|
+
}
|
|
51
|
+
buildDryRunSuggestions(compactor, files, target) {
|
|
52
|
+
const wouldTouch = files.filter((f) => {
|
|
53
|
+
const stripped = compactor.stripNonUsefulEntries(f.content);
|
|
54
|
+
return stripped !== f.content || stripped.length >= COMPACT_THRESHOLD || compactor.isRecent(f);
|
|
55
|
+
});
|
|
56
|
+
if (wouldTouch.length === 0)
|
|
57
|
+
return [];
|
|
58
|
+
const argsPart = target ? ` ${target}` : '';
|
|
59
|
+
const count = wouldTouch.length;
|
|
60
|
+
return [{ command: `compact${argsPart}`, hint: `run compaction for real (${count} file${count === 1 ? '' : 's'} will be touched)` }];
|
|
61
|
+
}
|
|
62
|
+
async runFullSweep(compactor, merge) {
|
|
63
|
+
if (!merge) {
|
|
64
|
+
tag('info').log('Compacting all experience files (merge skipped)…');
|
|
65
|
+
const compacted = await compactor.compactFiles(this.explorBot.getExperienceTracker().getAllExperience());
|
|
66
|
+
return { merged: 0, compacted };
|
|
67
|
+
}
|
|
68
|
+
tag('info').log('Merging experience files with similar URLs, then compacting all…');
|
|
69
|
+
return compactor.compactAllExperiences();
|
|
70
|
+
}
|
|
71
|
+
resolveTarget(tracker, target) {
|
|
72
|
+
const all = tracker.getAllExperience();
|
|
73
|
+
if (all.length === 0)
|
|
74
|
+
return null;
|
|
75
|
+
if (!target)
|
|
76
|
+
return all;
|
|
77
|
+
if (target.endsWith('.md')) {
|
|
78
|
+
const bare = target.slice(0, -3);
|
|
79
|
+
const byFilename = all.find((f) => basename(f.filePath, '.md') === bare);
|
|
80
|
+
return byFilename ? [byFilename] : [];
|
|
81
|
+
}
|
|
82
|
+
const filter = target.toLowerCase();
|
|
83
|
+
return all.filter((f) => (f.data.url || '').toLowerCase().includes(filter));
|
|
84
|
+
}
|
|
85
|
+
printDryRun(compactor, files, target, willMerge) {
|
|
86
|
+
const lines = [];
|
|
87
|
+
const title = target ? `Dry run — target: "${target}"` : 'Dry run — full sweep';
|
|
88
|
+
lines.push(chalk.bold.underline.cyan(title));
|
|
89
|
+
if (willMerge) {
|
|
90
|
+
lines.push(chalk.dim('Merge step would run across all files before compaction.'));
|
|
91
|
+
}
|
|
92
|
+
let wouldStrip = 0;
|
|
93
|
+
let wouldAiReview = 0;
|
|
94
|
+
let wouldAiCompact = 0;
|
|
95
|
+
let totalChars = 0;
|
|
96
|
+
for (const file of files) {
|
|
97
|
+
const chars = file.content.length;
|
|
98
|
+
totalChars += chars;
|
|
99
|
+
const stripped = compactor.stripNonUsefulEntries(file.content);
|
|
100
|
+
const strippedChars = stripped.length;
|
|
101
|
+
const charsRemoved = chars - strippedChars;
|
|
102
|
+
const willStrip = stripped !== file.content;
|
|
103
|
+
const willReview = compactor.isRecent(file);
|
|
104
|
+
const willAi = strippedChars >= COMPACT_THRESHOLD;
|
|
105
|
+
if (willStrip)
|
|
106
|
+
wouldStrip++;
|
|
107
|
+
if (willReview)
|
|
108
|
+
wouldAiReview++;
|
|
109
|
+
if (willAi)
|
|
110
|
+
wouldAiCompact++;
|
|
111
|
+
const name = basename(file.filePath);
|
|
112
|
+
const url = file.data.url || chalk.dim('(no url)');
|
|
113
|
+
const markers = [];
|
|
114
|
+
if (willStrip) {
|
|
115
|
+
const label = charsRemoved > 0 ? `✂ strip -${charsRemoved} chars` : '✂ strip (rewrite)';
|
|
116
|
+
markers.push(chalk.yellow(label));
|
|
117
|
+
}
|
|
118
|
+
if (willReview)
|
|
119
|
+
markers.push(chalk.magenta('✓ ai-review'));
|
|
120
|
+
if (willAi)
|
|
121
|
+
markers.push(chalk.yellow('✎ ai-compact'));
|
|
122
|
+
if (markers.length === 0)
|
|
123
|
+
markers.push(chalk.dim('· skip'));
|
|
124
|
+
lines.push('');
|
|
125
|
+
lines.push(chalk.bold.green(name));
|
|
126
|
+
lines.push(` ${chalk.cyan(url)}`);
|
|
127
|
+
lines.push(` ${chalk.dim('size:')} ${chars} chars ${markers.join(' ')}`);
|
|
128
|
+
}
|
|
129
|
+
lines.push('');
|
|
130
|
+
lines.push(chalk.bold('Summary'));
|
|
131
|
+
lines.push(` ${chalk.dim('Files matched:')} ${chalk.bold(String(files.length))}`);
|
|
132
|
+
lines.push(` ${chalk.dim('Would strip:')} ${chalk.bold(String(wouldStrip))} ${chalk.dim('(remove legacy / FAILED / Visual click, rename to FLOW/ACTION)')}`);
|
|
133
|
+
lines.push(` ${chalk.dim('Would ai-review:')} ${chalk.bold(String(wouldAiReview))} ${chalk.dim('(recent files ≤30d — quality/reusability check)')}`);
|
|
134
|
+
lines.push(` ${chalk.dim('Would ai-compact:')} ${chalk.bold(String(wouldAiCompact))} ${chalk.dim(`(over ${COMPACT_THRESHOLD} chars after strip)`)}`);
|
|
135
|
+
lines.push(` ${chalk.dim('Total chars:')} ${chalk.bold(String(totalChars))}`);
|
|
136
|
+
tag('info').log(lines.join('\n'));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -7,7 +7,13 @@ import { BaseCommand } from './base-command.js';
|
|
|
7
7
|
export class ContextCommand extends BaseCommand {
|
|
8
8
|
name = 'context';
|
|
9
9
|
description = 'Show page context summary (URL, headings, experience, knowledge, ARIA, HTML, research)';
|
|
10
|
-
suggestions = [
|
|
10
|
+
suggestions = [
|
|
11
|
+
{ command: 'context:aria', hint: 'show page ARIA snapshot' },
|
|
12
|
+
{ command: 'context:html', hint: 'show page HTML' },
|
|
13
|
+
{ command: 'context:knowledge', hint: 'show relevant knowledge' },
|
|
14
|
+
{ command: 'context:experience', hint: 'show relevant experience' },
|
|
15
|
+
{ command: 'context:data', hint: 'show captured page data' },
|
|
16
|
+
];
|
|
11
17
|
options = [
|
|
12
18
|
{ flags: '--visual', description: 'Include annotated screenshot' },
|
|
13
19
|
{ flags: '--screenshot', description: 'Include annotated screenshot' },
|
|
@@ -2,7 +2,10 @@ import { BaseCommand } from './base-command.js';
|
|
|
2
2
|
export class DrillCommand extends BaseCommand {
|
|
3
3
|
name = 'drill';
|
|
4
4
|
description = 'Drill all components on current page to learn interactions';
|
|
5
|
-
suggestions = [
|
|
5
|
+
suggestions = [
|
|
6
|
+
{ command: 'research', hint: 'see UI map first' },
|
|
7
|
+
{ command: 'navigate <page>', hint: 'go to another page' },
|
|
8
|
+
];
|
|
6
9
|
async execute(args) {
|
|
7
10
|
const knowledgePath = this.parseKnowledgeArg(args);
|
|
8
11
|
const maxComponents = this.parseMaxArg(args);
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { tag } from '../utils/logger.js';
|
|
3
|
+
import { BaseCommand } from './base-command.js';
|
|
4
|
+
export class ExperienceCommand extends BaseCommand {
|
|
5
|
+
name = 'experience';
|
|
6
|
+
description = 'List stored experiences; filter by filename or URL substring; expand a section by ref (e.g. LW1)';
|
|
7
|
+
options = [
|
|
8
|
+
{ flags: '--recent', description: 'Only files modified within the last 30 days' },
|
|
9
|
+
{ flags: '--old', description: 'Only files modified more than 30 days ago' },
|
|
10
|
+
];
|
|
11
|
+
suggestions = [];
|
|
12
|
+
async execute(args) {
|
|
13
|
+
const { opts, args: remaining } = this.parseArgs(args);
|
|
14
|
+
if (opts.recent && opts.old) {
|
|
15
|
+
tag('info').log('Flags --recent and --old are mutually exclusive.');
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const recency = opts.recent ? 'recent' : opts.old ? 'old' : undefined;
|
|
19
|
+
const tracker = this.explorBot.getExperienceTracker();
|
|
20
|
+
const [first, second] = remaining;
|
|
21
|
+
const combinedRef = first?.match(/^([A-Z]+)[.\-]?(\d+)$/i);
|
|
22
|
+
if (combinedRef) {
|
|
23
|
+
this.expand(tracker, combinedRef[1].toUpperCase(), Number(combinedRef[2]));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (first && /^[A-Z]+$/i.test(first) && second && /^\d+$/.test(second)) {
|
|
27
|
+
this.expand(tracker, first.toUpperCase(), Number(second));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const secondRef = second?.match(/^([A-Z]+)[.\-]?(\d+)$/i);
|
|
31
|
+
if (first && secondRef) {
|
|
32
|
+
this.expand(tracker, secondRef[1].toUpperCase(), Number(secondRef[2]), first);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (first && second && /^\d+$/.test(second)) {
|
|
36
|
+
const toc = tracker.listAllExperienceToc(first, { recency });
|
|
37
|
+
if (toc.length === 0) {
|
|
38
|
+
tag('info').log(`No experience found matching: ${first}`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
this.expand(tracker, toc[0].fileTag, Number(second), first);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const toc = tracker.listAllExperienceToc(first, { recency });
|
|
45
|
+
if (toc.length === 0) {
|
|
46
|
+
const scope = recency === 'recent' ? ' (recent only)' : recency === 'old' ? ' (old only)' : '';
|
|
47
|
+
tag('info').log(first ? `No experience found matching: ${first}${scope}` : `No experience files found${scope}. Experience is recorded automatically during test sessions.`);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
tag('info').log(this.formatToc(toc, first, recency));
|
|
51
|
+
const hints = [];
|
|
52
|
+
const exampleRef = `${toc[0].fileTag}1`;
|
|
53
|
+
hints.push({ command: `experience ${exampleRef}`, hint: 'read a section' });
|
|
54
|
+
const overloaded = toc.filter((entry) => entry.sections.length >= 10);
|
|
55
|
+
for (const entry of overloaded) {
|
|
56
|
+
hints.push({ command: `compact ${entry.fileHash}.md`, hint: `compact this file (${entry.sections.length} sections)` });
|
|
57
|
+
}
|
|
58
|
+
this.suggestions = hints;
|
|
59
|
+
}
|
|
60
|
+
expand(tracker, fileTag, sectionIndex, urlFilter) {
|
|
61
|
+
const section = tracker.getExperienceSectionByTag(fileTag, sectionIndex, urlFilter);
|
|
62
|
+
if (!section) {
|
|
63
|
+
tag('info').log(`No section ${fileTag}${sectionIndex} found${urlFilter ? ` for URL matching: ${urlFilter}` : ''}`);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
tag('info').log(`${chalk.dim('File:')} ${chalk.bold.green(`${section.fileHash}.md`)}`);
|
|
67
|
+
tag('info').log(`${chalk.dim('URL:')} ${chalk.bold.cyan(section.url)}`);
|
|
68
|
+
tag('info').log(`${chalk.green(`${fileTag}${sectionIndex}`)} ${chalk.bold(section.title)}`);
|
|
69
|
+
tag('multiline').log(this.stripLeadingHeading(section.content.trim()));
|
|
70
|
+
}
|
|
71
|
+
formatToc(toc, urlFilter, recency) {
|
|
72
|
+
const totalSections = toc.reduce((sum, entry) => sum + entry.sections.length, 0);
|
|
73
|
+
const lines = [];
|
|
74
|
+
const scope = recency === 'recent' ? ' (recent, ≤30d)' : recency === 'old' ? ' (old, >30d)' : '';
|
|
75
|
+
const title = urlFilter ? `Experience matching "${urlFilter}"${scope}` : `Stored experience${scope}`;
|
|
76
|
+
lines.push(chalk.bold.underline.cyan(title));
|
|
77
|
+
for (const entry of toc) {
|
|
78
|
+
lines.push('');
|
|
79
|
+
lines.push(chalk.bold.green(`${entry.fileHash}.md`));
|
|
80
|
+
lines.push(` ${chalk.cyan(entry.url)}`);
|
|
81
|
+
for (let i = 0; i < entry.sections.length; i++) {
|
|
82
|
+
const section = entry.sections[i];
|
|
83
|
+
const isLast = i === entry.sections.length - 1;
|
|
84
|
+
const branch = isLast ? '└─' : '├─';
|
|
85
|
+
const ref = chalk.yellow(`${entry.fileTag}${section.index}`);
|
|
86
|
+
lines.push(` ${chalk.dim(branch)} ${ref} ${chalk.dim(':')} ${section.title}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const urls = new Set(toc.map((e) => e.url)).size;
|
|
90
|
+
lines.push('');
|
|
91
|
+
lines.push(chalk.bold('Summary'));
|
|
92
|
+
lines.push(` ${chalk.dim('URLs:')} ${chalk.bold(String(urls))}`);
|
|
93
|
+
lines.push(` ${chalk.dim('Files:')} ${chalk.bold(String(toc.length))}`);
|
|
94
|
+
lines.push(` ${chalk.dim('Entries:')} ${chalk.bold(String(totalSections))}`);
|
|
95
|
+
return lines.join('\n');
|
|
96
|
+
}
|
|
97
|
+
stripLeadingHeading(body) {
|
|
98
|
+
const lines = body.split('\n');
|
|
99
|
+
if (lines[0]?.match(/^#{1,6}\s/)) {
|
|
100
|
+
return lines.slice(1).join('\n').trimStart();
|
|
101
|
+
}
|
|
102
|
+
return body;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import figureSet from 'figures';
|
|
2
|
-
import path from 'node:path';
|
|
3
2
|
import { getStyles } from '../ai/planner/styles.js';
|
|
3
|
+
import { Stats } from '../stats.js';
|
|
4
4
|
import { getCliName } from "../utils/cli-name.js";
|
|
5
|
-
import {
|
|
5
|
+
import { ErrorPageError } from "../utils/error-page.js";
|
|
6
6
|
import { tag } from '../utils/logger.js';
|
|
7
|
+
import { jsonToTable } from '../utils/markdown-parser.js';
|
|
8
|
+
import { printNextSteps, relativeToCwd } from "../utils/next-steps.js";
|
|
7
9
|
import { BaseCommand } from './base-command.js';
|
|
8
10
|
export class ExploreCommand extends BaseCommand {
|
|
9
11
|
name = 'explore';
|
|
@@ -12,7 +14,11 @@ export class ExploreCommand extends BaseCommand {
|
|
|
12
14
|
{ flags: '--max-tests <number>', description: 'Maximum number of tests to run' },
|
|
13
15
|
{ flags: '--focus <feature>', description: 'Focus area for exploration' },
|
|
14
16
|
];
|
|
15
|
-
suggestions = [
|
|
17
|
+
suggestions = [
|
|
18
|
+
{ command: 'navigate <page>', hint: 'go to another page' },
|
|
19
|
+
{ command: 'research', hint: 'analyze current page' },
|
|
20
|
+
{ command: 'plan <feature>', hint: 'plan testing' },
|
|
21
|
+
];
|
|
16
22
|
maxTests;
|
|
17
23
|
testsRun = 0;
|
|
18
24
|
completedPlans = [];
|
|
@@ -22,13 +28,15 @@ export class ExploreCommand extends BaseCommand {
|
|
|
22
28
|
this.maxTests = Number.parseInt(opts.maxTests, 10);
|
|
23
29
|
}
|
|
24
30
|
const feature = opts.focus || remaining.join(' ') || undefined;
|
|
31
|
+
Stats.mode ??= 'explore';
|
|
32
|
+
Stats.focus ??= feature;
|
|
25
33
|
const mainUrl = this.explorBot.getExplorer().getStateManager().getCurrentState()?.url;
|
|
26
34
|
await this.runAllStyles(mainUrl, feature);
|
|
27
35
|
const mainPlan = this.explorBot.getCurrentPlan();
|
|
28
36
|
if (!mainPlan)
|
|
29
37
|
return;
|
|
30
38
|
this.completedPlans.push(mainPlan);
|
|
31
|
-
if (!this.isLimitReached()) {
|
|
39
|
+
if (!feature && !this.isLimitReached()) {
|
|
32
40
|
const planner = this.explorBot.agentPlanner();
|
|
33
41
|
while (true) {
|
|
34
42
|
if (this.isLimitReached())
|
|
@@ -57,8 +65,8 @@ export class ExploreCommand extends BaseCommand {
|
|
|
57
65
|
if (mainUrl)
|
|
58
66
|
await this.explorBot.visit(mainUrl);
|
|
59
67
|
const savedPath = this.explorBot.savePlans(this.completedPlans);
|
|
60
|
-
this.printResults(
|
|
61
|
-
this.
|
|
68
|
+
this.printResults();
|
|
69
|
+
this.printNextSteps(savedPath);
|
|
62
70
|
}
|
|
63
71
|
async runAllStyles(pageUrl, feature, parentPlan, completedPlans) {
|
|
64
72
|
let fresh = true;
|
|
@@ -69,12 +77,27 @@ export class ExploreCommand extends BaseCommand {
|
|
|
69
77
|
const opts = { fresh, style, completedPlans };
|
|
70
78
|
if (fresh && parentPlan)
|
|
71
79
|
opts.extend = parentPlan;
|
|
72
|
-
await this.
|
|
80
|
+
await this.planWithRetry(feature, opts, pageUrl);
|
|
73
81
|
await this.runPendingTests();
|
|
74
82
|
fresh = false;
|
|
75
83
|
}
|
|
76
84
|
}
|
|
77
|
-
|
|
85
|
+
async planWithRetry(feature, opts, pageUrl) {
|
|
86
|
+
await this.explorBot.plan(feature, opts);
|
|
87
|
+
if (!this.explorBot.lastPlanError)
|
|
88
|
+
return;
|
|
89
|
+
if (this.explorBot.lastPlanError instanceof ErrorPageError) {
|
|
90
|
+
throw this.explorBot.lastPlanError;
|
|
91
|
+
}
|
|
92
|
+
tag('info').log(`Retrying planning style '${opts.style}'...`);
|
|
93
|
+
if (pageUrl)
|
|
94
|
+
await this.explorBot.visit(pageUrl);
|
|
95
|
+
await this.explorBot.plan(feature, opts);
|
|
96
|
+
if (this.explorBot.lastPlanError) {
|
|
97
|
+
tag('warning').log(`Planning style '${opts.style}' failed after retry, skipping`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
printResults() {
|
|
78
101
|
const allTests = this.completedPlans.flatMap((plan) => plan.tests.filter((t) => t.startTime != null).map((test) => ({ test, planTitle: plan.title })));
|
|
79
102
|
if (allTests.length === 0)
|
|
80
103
|
return;
|
|
@@ -105,20 +128,32 @@ export class ExploreCommand extends BaseCommand {
|
|
|
105
128
|
columns.push('Plan');
|
|
106
129
|
tag('multiline').log(jsonToTable(rows, columns));
|
|
107
130
|
tag('info').log(`${figureSet.tick} ${allTests.length} tests completed`);
|
|
108
|
-
if (savedPath) {
|
|
109
|
-
const relativePath = path.relative(process.cwd(), savedPath);
|
|
110
|
-
tag('info').log(`Re-run tests: ${getCliName()} test ${relativePath} <index>`);
|
|
111
|
-
}
|
|
112
131
|
}
|
|
113
|
-
|
|
132
|
+
printNextSteps(savedPlanPath) {
|
|
133
|
+
const cli = getCliName();
|
|
134
|
+
const sections = [];
|
|
135
|
+
if (savedPlanPath) {
|
|
136
|
+
const relPlan = relativeToCwd(savedPlanPath);
|
|
137
|
+
sections.push({
|
|
138
|
+
label: 'Plan',
|
|
139
|
+
path: savedPlanPath,
|
|
140
|
+
commands: [
|
|
141
|
+
{ label: 'Re-run', command: `${cli} test ${relPlan} 1` },
|
|
142
|
+
{ label: 'Run all', command: `${cli} test ${relPlan} *` },
|
|
143
|
+
{ label: 'Run range', command: `${cli} test ${relPlan} 1-3` },
|
|
144
|
+
],
|
|
145
|
+
});
|
|
146
|
+
}
|
|
114
147
|
const savedFiles = this.explorBot.agentHistorian().getSavedFiles();
|
|
115
|
-
if (savedFiles.length
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
148
|
+
if (savedFiles.length > 0) {
|
|
149
|
+
const commands = savedFiles.map((f) => ({ label: '', command: `${cli} rerun ${relativeToCwd(f)}` }));
|
|
150
|
+
commands.push({ label: 'List tests', command: `${cli} runs` });
|
|
151
|
+
sections.push({
|
|
152
|
+
label: `Generated tests (${savedFiles.length})`,
|
|
153
|
+
commands,
|
|
154
|
+
});
|
|
119
155
|
}
|
|
120
|
-
|
|
121
|
-
tag('info').log(`Re-run with healing: ${getCliName()} rerun <filename> [index]`);
|
|
156
|
+
printNextSteps(sections);
|
|
122
157
|
}
|
|
123
158
|
isLimitReached() {
|
|
124
159
|
return this.maxTests != null && this.testsRun >= this.maxTests;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Planner } from '../ai/planner.js';
|
|
2
2
|
import { Researcher } from '../ai/researcher.js';
|
|
3
|
+
import { Stats } from '../stats.js';
|
|
3
4
|
import { tag } from '../utils/logger.js';
|
|
4
5
|
import { loop } from '../utils/loop.js';
|
|
5
6
|
import { BaseCommand } from './base-command.js';
|
|
@@ -16,6 +17,7 @@ export class FreesailCommand extends BaseCommand {
|
|
|
16
17
|
{ flags: '--max-tests <number>', description: 'Maximum number of tests to run' },
|
|
17
18
|
];
|
|
18
19
|
async execute(args) {
|
|
20
|
+
Stats.mode = 'freesail';
|
|
19
21
|
const { opts } = this.parseArgs(args);
|
|
20
22
|
let strategy;
|
|
21
23
|
if (opts.deep)
|
|
@@ -1,19 +1,21 @@
|
|
|
1
|
+
import { AddRuleCommand } from './add-rule-command.js';
|
|
1
2
|
import { CleanCommand } from './clean-command.js';
|
|
2
|
-
import {
|
|
3
|
+
import { CompactCommand } from './compact-command.js';
|
|
3
4
|
import { ContextAriaCommand } from './context-aria-command.js';
|
|
4
5
|
import { ContextCommand } from './context-command.js';
|
|
5
6
|
import { ContextDataCommand } from './context-data-command.js';
|
|
6
7
|
import { ContextExperienceCommand } from './context-experience-command.js';
|
|
7
8
|
import { ContextHtmlCommand } from './context-html-command.js';
|
|
8
9
|
import { ContextKnowledgeCommand } from './context-knowledge-command.js';
|
|
10
|
+
import { DebugCommand } from './debug-command.js';
|
|
9
11
|
import { DrillCommand } from './drill-command.js';
|
|
10
12
|
import { ExitCommand } from './exit-command.js';
|
|
13
|
+
import { ExperienceCommand } from './experience-command.js';
|
|
11
14
|
import { ExploreCommand } from './explore-command.js';
|
|
12
15
|
import { FreesailCommand } from './freesail-command.js';
|
|
13
16
|
import { HelpCommand } from './help-command.js';
|
|
14
|
-
import { AddRuleCommand } from './add-rule-command.js';
|
|
15
|
-
import { LearnCommand } from './learn-command.js';
|
|
16
17
|
import { KnowsCommand } from './knows-command.js';
|
|
18
|
+
import { LearnCommand } from './learn-command.js';
|
|
17
19
|
import { NavigateCommand } from './navigate-command.js';
|
|
18
20
|
import { PathCommand } from './path-command.js';
|
|
19
21
|
import { PlanClearCommand } from './plan-clear-command.js';
|
|
@@ -47,6 +49,8 @@ const commandClasses = [
|
|
|
47
49
|
PathCommand,
|
|
48
50
|
LearnCommand,
|
|
49
51
|
KnowsCommand,
|
|
52
|
+
ExperienceCommand,
|
|
53
|
+
CompactCommand,
|
|
50
54
|
AddRuleCommand,
|
|
51
55
|
ContextCommand,
|
|
52
56
|
ContextAriaCommand,
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, extname, join, resolve } from 'node:path';
|
|
3
|
-
import { log, tag } from '../utils/logger.js';
|
|
4
|
-
import dedent from 'dedent';
|
|
5
3
|
import chalk from 'chalk';
|
|
4
|
+
import dedent from 'dedent';
|
|
6
5
|
import { getCliName } from "../utils/cli-name.js";
|
|
6
|
+
import { log, tag } from '../utils/logger.js';
|
|
7
|
+
import { relativeToCwd } from "../utils/next-steps.js";
|
|
7
8
|
const DEFAULT_CONFIG_TEMPLATE = `import { createOpenRouter } from '@openrouter/ai-sdk-provider';
|
|
8
9
|
// import { '<your provider here>' } from '<your provider package here>';
|
|
9
10
|
|
|
@@ -57,10 +58,10 @@ export function runInitCommand(options) {
|
|
|
57
58
|
const dir = resolve(customPath);
|
|
58
59
|
if (!existsSync(dir)) {
|
|
59
60
|
mkdirSync(dir, { recursive: true });
|
|
60
|
-
log(`Created directory: ${dir}`);
|
|
61
|
+
log(`Created directory: ${relativeToCwd(dir)}`);
|
|
61
62
|
}
|
|
62
63
|
process.chdir(dir);
|
|
63
|
-
log(`Working in directory: ${dir}`);
|
|
64
|
+
log(`Working in directory: ${relativeToCwd(dir)}`);
|
|
64
65
|
}
|
|
65
66
|
try {
|
|
66
67
|
let outPath = resolve(configPath);
|
|
@@ -73,22 +74,22 @@ export function runInitCommand(options) {
|
|
|
73
74
|
const dir = dirname(outPath);
|
|
74
75
|
if (!existsSync(dir)) {
|
|
75
76
|
mkdirSync(dir, { recursive: true });
|
|
76
|
-
log(`Created directory: ${dir}`);
|
|
77
|
+
log(`Created directory: ${relativeToCwd(dir)}`);
|
|
77
78
|
}
|
|
78
79
|
if (existsSync(outPath) && !force) {
|
|
79
|
-
log(`Config file already exists: ${outPath}`);
|
|
80
|
+
log(`Config file already exists: ${relativeToCwd(outPath)}`);
|
|
80
81
|
log('Use --force to overwrite existing file');
|
|
81
82
|
process.exit(1);
|
|
82
83
|
}
|
|
83
84
|
writeFileSync(outPath, DEFAULT_CONFIG_TEMPLATE, 'utf8');
|
|
84
|
-
log(`Created config file: ${outPath}`);
|
|
85
|
+
log(`Created config file: ${relativeToCwd(outPath)}`);
|
|
85
86
|
const envPath = resolve(process.cwd(), '.env');
|
|
86
87
|
if (!existsSync(envPath)) {
|
|
87
88
|
writeFileSync(envPath, `${DEFAULT_ENV_TEMPLATE}\n`, 'utf8');
|
|
88
|
-
log(`Created env file: ${envPath}`);
|
|
89
|
+
log(`Created env file: ${relativeToCwd(envPath)}`);
|
|
89
90
|
}
|
|
90
91
|
else {
|
|
91
|
-
log(`Env file already exists: ${envPath}`);
|
|
92
|
+
log(`Env file already exists: ${relativeToCwd(envPath)}`);
|
|
92
93
|
}
|
|
93
94
|
log('');
|
|
94
95
|
log('Next steps:');
|
|
@@ -102,7 +103,7 @@ export function runInitCommand(options) {
|
|
|
102
103
|
tag('substep').log(chalk.yellow(`${getCliName()} start /dashboard`));
|
|
103
104
|
if (!existsSync('./output')) {
|
|
104
105
|
mkdirSync('./output', { recursive: true });
|
|
105
|
-
log('Created directory:
|
|
106
|
+
log('Created directory: output');
|
|
106
107
|
}
|
|
107
108
|
}
|
|
108
109
|
catch (error) {
|
|
@@ -5,7 +5,7 @@ import { BaseCommand } from './base-command.js';
|
|
|
5
5
|
export class LearnCommand extends BaseCommand {
|
|
6
6
|
name = 'learn';
|
|
7
7
|
description = 'Store knowledge for current page';
|
|
8
|
-
suggestions = ['
|
|
8
|
+
suggestions = [{ command: 'knows', hint: 'view all knowledge' }];
|
|
9
9
|
async execute(args) {
|
|
10
10
|
const note = args.trim();
|
|
11
11
|
if (!note) {
|
|
@@ -3,7 +3,10 @@ import { BaseCommand } from './base-command.js';
|
|
|
3
3
|
export class NavigateCommand extends BaseCommand {
|
|
4
4
|
name = 'navigate';
|
|
5
5
|
description = 'Navigate to URI or state using AI';
|
|
6
|
-
suggestions = [
|
|
6
|
+
suggestions = [
|
|
7
|
+
{ command: 'research', hint: 'analyze current page' },
|
|
8
|
+
{ command: 'plan <feature>', hint: 'plan testing' },
|
|
9
|
+
];
|
|
7
10
|
async execute(args) {
|
|
8
11
|
const destination = args.trim();
|
|
9
12
|
if (!destination) {
|
|
@@ -3,7 +3,10 @@ import { BaseCommand } from './base-command.js';
|
|
|
3
3
|
export class PlanClearCommand extends BaseCommand {
|
|
4
4
|
name = 'plan:clear';
|
|
5
5
|
description = 'Clear current plan and create a new one';
|
|
6
|
-
suggestions = [
|
|
6
|
+
suggestions = [
|
|
7
|
+
{ command: 'test', hint: 'launch first test' },
|
|
8
|
+
{ command: 'test *', hint: 'launch all tests' },
|
|
9
|
+
];
|
|
7
10
|
async execute(args) {
|
|
8
11
|
this.explorBot.clearPlan();
|
|
9
12
|
tag('success').log('Plan cleared');
|