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
@@ -7,6 +7,8 @@ const AUTH_HEADERS = ['authorization', 'cookie', 'x-api-key', 'x-csrf-token'];
7
7
  export class RequestStore {
8
8
  private capturedRequests: RequestResult[] = [];
9
9
  private madeRequests: RequestResult[] = [];
10
+ private failedRequests: RequestResult[] = [];
11
+ private onFailedListeners: Array<(r: RequestResult) => void> = [];
10
12
  private outputDir: string;
11
13
 
12
14
  constructor(outputDir: string) {
@@ -18,6 +20,25 @@ export class RequestStore {
18
20
  result.save(this.outputDir);
19
21
  }
20
22
 
23
+ addFailedRequest(result: RequestResult): void {
24
+ this.failedRequests.push(result);
25
+ for (const cb of this.onFailedListeners) {
26
+ cb(result);
27
+ }
28
+ }
29
+
30
+ getFailedRequests(): RequestResult[] {
31
+ return this.failedRequests;
32
+ }
33
+
34
+ onFailedRequest(cb: (r: RequestResult) => void): () => void {
35
+ this.onFailedListeners.push(cb);
36
+ return () => {
37
+ const idx = this.onFailedListeners.indexOf(cb);
38
+ if (idx !== -1) this.onFailedListeners.splice(idx, 1);
39
+ };
40
+ }
41
+
21
42
  addMadeRequest(result: RequestResult): void {
22
43
  this.madeRequests.push(result);
23
44
  result.save(this.outputDir);
@@ -122,6 +143,7 @@ export class RequestStore {
122
143
  clear(): void {
123
144
  this.capturedRequests = [];
124
145
  this.madeRequests = [];
146
+ this.failedRequests = [];
125
147
  }
126
148
  }
127
149
 
@@ -38,15 +38,33 @@ export class XhrCapture {
38
38
  if (resourceType !== 'xhr' && resourceType !== 'fetch') return;
39
39
 
40
40
  const method = request.method();
41
- if (!WRITE_METHODS.has(method)) return;
42
-
43
41
  const url = request.url();
44
42
  if (!url.startsWith(this.baseOrigin)) return;
45
43
 
44
+ const status = response.status();
45
+
46
+ if (status >= 400) {
47
+ const failedUrl = new URL(url);
48
+ const failure = new RequestResult({
49
+ id: generateRequestId(method, failedUrl.pathname, 'fail_'),
50
+ method,
51
+ path: failedUrl.pathname,
52
+ fullUrl: failedUrl.pathname + failedUrl.search,
53
+ requestHeaders: {},
54
+ status,
55
+ statusText: response.statusText(),
56
+ responseHeaders: {},
57
+ timing: 0,
58
+ timestamp: new Date(),
59
+ });
60
+ this.store.addFailedRequest(failure);
61
+ }
62
+
63
+ if (!WRITE_METHODS.has(method)) return;
64
+
46
65
  const contentType = response.headers()['content-type'] || '';
47
66
  if (!JSON_CONTENT_TYPES.test(contentType)) return;
48
67
 
49
- const status = response.status();
50
68
  if (status === 304) return;
51
69
 
52
70
  const parsedUrl = new URL(url);
@@ -2,7 +2,9 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from '
2
2
  import path from 'node:path';
3
3
  import { chromium, firefox, webkit } from 'playwright-core';
4
4
  import { ConfigParser } from './config.js';
5
- import { log, tag } from './utils/logger.js';
5
+ import { getCliName } from './utils/cli-name.ts';
6
+ import { log } from './utils/logger.js';
7
+ import { type NextStepSection, printNextSteps } from './utils/next-steps.ts';
6
8
 
7
9
  const ENDPOINT_FILENAME = '.browser-endpoint';
8
10
 
@@ -57,8 +59,20 @@ async function launchServer(opts: { browser?: string; show?: boolean }): Promise
57
59
  writeEndpoint(wsEndpoint);
58
60
 
59
61
  log(`Browser server started: ${browserName} (${opts.show ? 'headed' : 'headless'})`);
60
- tag('info').log(`WebSocket endpoint: ${wsEndpoint}`);
61
- tag('info').log(`Endpoint saved to: ${getEndpointFilePath()}`);
62
+
63
+ const cli = getCliName();
64
+ const sections: NextStepSection[] = [
65
+ {
66
+ label: 'Browser server',
67
+ path: getEndpointFilePath(),
68
+ commands: [
69
+ { label: 'Endpoint', command: wsEndpoint },
70
+ { label: 'Status', command: `${cli} browser status` },
71
+ { label: 'Stop', command: `${cli} browser stop` },
72
+ ],
73
+ },
74
+ ];
75
+ printNextSteps(sections);
62
76
 
63
77
  return server;
64
78
  }
@@ -155,7 +155,7 @@ export class CommandHandler implements InputManager {
155
155
  this.runningCommands.add(command.name);
156
156
  try {
157
157
  await command.execute(argsString);
158
- command.suggestions.forEach((s) => tag('step').log(s));
158
+ command.printSuggestions();
159
159
  } catch (error: any) {
160
160
  if (error?.name === 'AbortError') throw error;
161
161
  tag('error').log(`/${command.name} failed: ${error instanceof Error ? error.message : String(error)}`);
@@ -3,12 +3,13 @@ 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 { BaseCommand } from './base-command.js';
6
+ import { type NextStepSection, printNextSteps, relativeToCwd } from '../utils/next-steps.ts';
7
+ import { BaseCommand, type Suggestion } from './base-command.js';
7
8
 
8
9
  export class AddRuleCommand extends BaseCommand {
9
10
  name = 'add-rule';
10
11
  description = 'Create a rule file for an agent';
11
- suggestions = ['/add-rule researcher check-tooltips'];
12
+ suggestions: Suggestion[] = [{ command: 'add-rule researcher check-tooltips', hint: 'example — add a rule for the researcher agent' }];
12
13
 
13
14
  async execute(args: string): Promise<void> {
14
15
  const parts = args.trim().split(/\s+/);
@@ -43,19 +44,22 @@ export class AddRuleCommand extends BaseCommand {
43
44
 
44
45
  const filePath = join(rulesDir, `${ruleName}.md`);
45
46
  if (existsSync(filePath)) {
46
- tag('warning').log(`Rule file already exists: ${filePath}`);
47
+ tag('warning').log(`Rule file already exists: ${relativeToCwd(filePath)}`);
47
48
  return null;
48
49
  }
49
50
 
50
51
  const content = opts?.content || `Instructions for ${agentName} agent.`;
51
52
  writeFileSync(filePath, `${content.trim()}\n`);
52
- tag('success').log(`Rule created: ${filePath}`);
53
53
 
54
- if (opts?.urlPattern) {
55
- tag('info').log(`Add to config: ai.agents.${agentName}.rules: [{ '${opts.urlPattern}': '${ruleName}' }]`);
56
- } else {
57
- tag('info').log(`Add to config: ai.agents.${agentName}.rules: ['${ruleName}']`);
58
- }
54
+ const configLine = opts?.urlPattern ? `ai.agents.${agentName}.rules: [{ '${opts.urlPattern}': '${ruleName}' }]` : `ai.agents.${agentName}.rules: ['${ruleName}']`;
55
+ const sections: NextStepSection[] = [
56
+ {
57
+ label: 'Rule',
58
+ path: filePath,
59
+ commands: [{ label: 'Add to config', command: configLine }],
60
+ },
61
+ ];
62
+ printNextSteps(sections);
59
63
 
60
64
  return filePath;
61
65
  }
@@ -1,18 +1,27 @@
1
+ import chalk from 'chalk';
1
2
  import { Command } from 'commander';
3
+ import { isInteractive } from '../ai/task-agent.js';
2
4
  import type { ExplorBot } from '../explorbot.js';
5
+ import { getCliName } from '../utils/cli-name.js';
6
+ import { tag } from '../utils/logger.js';
3
7
 
4
8
  export interface CommandOption {
5
9
  flags: string;
6
10
  description: string;
7
11
  }
8
12
 
13
+ export interface Suggestion {
14
+ command?: string;
15
+ hint: string;
16
+ }
17
+
9
18
  export abstract class BaseCommand {
10
19
  abstract name: string;
11
20
  abstract description: string;
12
21
  aliases: string[] = [];
13
22
  options: CommandOption[] = [];
14
23
  tuiEnabled = true;
15
- suggestions: string[] = [];
24
+ suggestions: Suggestion[] = [];
16
25
 
17
26
  protected explorBot: ExplorBot;
18
27
 
@@ -26,6 +35,22 @@ export abstract class BaseCommand {
26
35
  return this.name === commandName || this.aliases.includes(commandName);
27
36
  }
28
37
 
38
+ printSuggestions(): void {
39
+ if (this.suggestions.length === 0) return;
40
+ const prefix = isInteractive() ? '/' : `${getCliName()} `;
41
+ tag('info').log('');
42
+ tag('info').log(chalk.bold('Suggested:'));
43
+ for (const { command, hint } of this.suggestions) {
44
+ tag('info').log('');
45
+ if (!command) {
46
+ tag('info').log(chalk.dim(hint));
47
+ continue;
48
+ }
49
+ tag('info').log(chalk.dim(`${hint}:`));
50
+ tag('info').log(` ${chalk.yellow(`${prefix}${command}`)}`);
51
+ }
52
+ }
53
+
29
54
  protected parseArgs(args: string): { opts: Record<string, string | boolean>; args: string[] } {
30
55
  const cmd = new Command();
31
56
  cmd.exitOverride();
@@ -2,12 +2,13 @@ import { existsSync, readdirSync, rmSync, statSync, unlinkSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { ConfigParser, outputPath } from '../config.js';
4
4
  import { tag } from '../utils/logger.js';
5
- import { BaseCommand } from './base-command.js';
5
+ import { BaseCommand, type Suggestion } from './base-command.js';
6
6
 
7
7
  export const CLEAN_TARGETS: Record<string, { description: string; getDir: () => string }> = {
8
8
  states: { description: 'page states', getDir: () => outputPath('states') },
9
9
  research: { description: 'research cache', getDir: () => outputPath('research') },
10
10
  plans: { description: 'test plans', getDir: () => outputPath('plans') },
11
+ tests: { description: 'generated tests', getDir: () => outputPath('tests') },
11
12
  experiences: { description: 'experience files', getDir: () => getExperienceDir() },
12
13
  output: { description: 'all output files', getDir: () => outputPath() },
13
14
  };
@@ -40,8 +41,8 @@ function cleanDirectoryContents(dirPath: string): number {
40
41
 
41
42
  export class CleanCommand extends BaseCommand {
42
43
  name = 'clean';
43
- description = 'Clean files: clean [states|research|plans|experiences|output]';
44
- suggestions = Object.keys(CLEAN_TARGETS).map((t) => `/clean ${t}`);
44
+ description = 'Clean files: clean [states|research|plans|tests|experiences|output]';
45
+ suggestions: Suggestion[] = Object.entries(CLEAN_TARGETS).map(([name, target]) => ({ command: `clean ${name}`, hint: `clean ${target.description}` }));
45
46
 
46
47
  async execute(args: string): Promise<void> {
47
48
  const target = args.trim().toLowerCase();
@@ -0,0 +1,156 @@
1
+ import { basename } from 'node:path';
2
+ import chalk from 'chalk';
3
+ import type { ExperienceFile } from '../ai/experience-compactor.js';
4
+ import type { ExperienceTracker } from '../experience-tracker.js';
5
+ import { tag } from '../utils/logger.js';
6
+ import { BaseCommand, type Suggestion } from './base-command.js';
7
+
8
+ const COMPACT_THRESHOLD = 5000;
9
+
10
+ export class CompactCommand extends BaseCommand {
11
+ name = 'compact';
12
+ description = 'Compact stored experience files; optionally filtered by filename or URL substring';
13
+ options = [
14
+ { flags: '--dry-run', description: 'Preview without running AI or writing files' },
15
+ { flags: '--no-merge', description: 'Skip the cross-URL merge step when compacting all' },
16
+ ];
17
+ suggestions: Suggestion[] = [];
18
+
19
+ async execute(args: string): Promise<void> {
20
+ const { opts, args: remaining } = this.parseArgs(args);
21
+ const target = remaining[0];
22
+ const dryRun = !!opts.dryRun;
23
+ const merge = opts.merge !== false;
24
+
25
+ const tracker = this.explorBot.getExperienceTracker();
26
+ const files = this.resolveTarget(tracker, target);
27
+
28
+ if (files === null) {
29
+ tag('info').log('No experience files found.');
30
+ return;
31
+ }
32
+ if (files.length === 0) {
33
+ tag('info').log(`No experience files match target "${target}"`);
34
+ return;
35
+ }
36
+
37
+ const compactor = this.explorBot.agentExperienceCompactor();
38
+
39
+ if (dryRun) {
40
+ this.printDryRun(compactor, files, target, !target && merge);
41
+ this.suggestions = this.buildDryRunSuggestions(compactor, files, target);
42
+ return;
43
+ }
44
+
45
+ if (!target) {
46
+ const { merged, compacted } = await this.runFullSweep(compactor, merge);
47
+ tag('success').log(`Done. ${merged} merged, ${compacted} compacted.`);
48
+ this.suggestions = [{ command: 'experience', hint: 'list stored experiences' }];
49
+ return;
50
+ }
51
+
52
+ tag('info').log(`Compacting ${files.length} experience file${files.length === 1 ? '' : 's'}…`);
53
+ const compacted = await compactor.compactFiles(files);
54
+ if (compacted === 0) {
55
+ tag('info').log('No experience changes — nothing to strip and all content under size limit.');
56
+ } else {
57
+ tag('success').log(`Compacted ${compacted}/${files.length} experience file${files.length === 1 ? '' : 's'}.`);
58
+ }
59
+ this.suggestions = [{ command: `experience ${target}`, hint: 'view updated file' }];
60
+ }
61
+
62
+ private buildDryRunSuggestions(compactor: ReturnType<typeof this.explorBot.agentExperienceCompactor>, files: ExperienceFile[], target?: string): Suggestion[] {
63
+ const wouldTouch = files.filter((f) => {
64
+ const stripped = compactor.stripNonUsefulEntries(f.content);
65
+ return stripped !== f.content || stripped.length >= COMPACT_THRESHOLD || compactor.isRecent(f);
66
+ });
67
+ if (wouldTouch.length === 0) return [];
68
+ const argsPart = target ? ` ${target}` : '';
69
+ const count = wouldTouch.length;
70
+ return [{ command: `compact${argsPart}`, hint: `run compaction for real (${count} file${count === 1 ? '' : 's'} will be touched)` }];
71
+ }
72
+
73
+ private async runFullSweep(compactor: ReturnType<typeof this.explorBot.agentExperienceCompactor>, merge: boolean): Promise<{ merged: number; compacted: number }> {
74
+ if (!merge) {
75
+ tag('info').log('Compacting all experience files (merge skipped)…');
76
+ const compacted = await compactor.compactFiles(this.explorBot.getExperienceTracker().getAllExperience());
77
+ return { merged: 0, compacted };
78
+ }
79
+
80
+ tag('info').log('Merging experience files with similar URLs, then compacting all…');
81
+ return compactor.compactAllExperiences();
82
+ }
83
+
84
+ private resolveTarget(tracker: ExperienceTracker, target?: string): ExperienceFile[] | null {
85
+ const all = tracker.getAllExperience();
86
+ if (all.length === 0) return null;
87
+ if (!target) return all;
88
+
89
+ if (target.endsWith('.md')) {
90
+ const bare = target.slice(0, -3);
91
+ const byFilename = all.find((f) => basename(f.filePath, '.md') === bare);
92
+ return byFilename ? [byFilename] : [];
93
+ }
94
+
95
+ const filter = target.toLowerCase();
96
+ return all.filter((f) => (f.data.url || '').toLowerCase().includes(filter));
97
+ }
98
+
99
+ private printDryRun(compactor: ReturnType<typeof this.explorBot.agentExperienceCompactor>, files: ExperienceFile[], target: string | undefined, willMerge: boolean): void {
100
+ const lines: string[] = [];
101
+ const title = target ? `Dry run — target: "${target}"` : 'Dry run — full sweep';
102
+ lines.push(chalk.bold.underline.cyan(title));
103
+
104
+ if (willMerge) {
105
+ lines.push(chalk.dim('Merge step would run across all files before compaction.'));
106
+ }
107
+
108
+ let wouldStrip = 0;
109
+ let wouldAiReview = 0;
110
+ let wouldAiCompact = 0;
111
+ let totalChars = 0;
112
+
113
+ for (const file of files) {
114
+ const chars = file.content.length;
115
+ totalChars += chars;
116
+
117
+ const stripped = compactor.stripNonUsefulEntries(file.content);
118
+ const strippedChars = stripped.length;
119
+ const charsRemoved = chars - strippedChars;
120
+ const willStrip = stripped !== file.content;
121
+ const willReview = compactor.isRecent(file);
122
+ const willAi = strippedChars >= COMPACT_THRESHOLD;
123
+
124
+ if (willStrip) wouldStrip++;
125
+ if (willReview) wouldAiReview++;
126
+ if (willAi) wouldAiCompact++;
127
+
128
+ const name = basename(file.filePath);
129
+ const url = file.data.url || chalk.dim('(no url)');
130
+
131
+ const markers: string[] = [];
132
+ if (willStrip) {
133
+ const label = charsRemoved > 0 ? `✂ strip -${charsRemoved} chars` : '✂ strip (rewrite)';
134
+ markers.push(chalk.yellow(label));
135
+ }
136
+ if (willReview) markers.push(chalk.magenta('✓ ai-review'));
137
+ if (willAi) markers.push(chalk.yellow('✎ ai-compact'));
138
+ if (markers.length === 0) markers.push(chalk.dim('· skip'));
139
+
140
+ lines.push('');
141
+ lines.push(chalk.bold.green(name));
142
+ lines.push(` ${chalk.cyan(url)}`);
143
+ lines.push(` ${chalk.dim('size:')} ${chars} chars ${markers.join(' ')}`);
144
+ }
145
+
146
+ lines.push('');
147
+ lines.push(chalk.bold('Summary'));
148
+ lines.push(` ${chalk.dim('Files matched:')} ${chalk.bold(String(files.length))}`);
149
+ lines.push(` ${chalk.dim('Would strip:')} ${chalk.bold(String(wouldStrip))} ${chalk.dim('(remove legacy / FAILED / Visual click, rename to FLOW/ACTION)')}`);
150
+ lines.push(` ${chalk.dim('Would ai-review:')} ${chalk.bold(String(wouldAiReview))} ${chalk.dim('(recent files ≤30d — quality/reusability check)')}`);
151
+ lines.push(` ${chalk.dim('Would ai-compact:')} ${chalk.bold(String(wouldAiCompact))} ${chalk.dim(`(over ${COMPACT_THRESHOLD} chars after strip)`)}`);
152
+ lines.push(` ${chalk.dim('Total chars:')} ${chalk.bold(String(totalChars))}`);
153
+
154
+ tag('info').log(lines.join('\n'));
155
+ }
156
+ }
@@ -4,12 +4,18 @@ import { outputPath } from '../config.js';
4
4
  import { type ContextData, type ContextMode, formatContextSummary } from '../utils/context-formatter.js';
5
5
  import { tag } from '../utils/logger.js';
6
6
  import { extractValidContainers } from '../utils/research-parser.js';
7
- import { BaseCommand } from './base-command.js';
7
+ import { BaseCommand, type Suggestion } from './base-command.js';
8
8
 
9
9
  export class ContextCommand extends BaseCommand {
10
10
  name = 'context';
11
11
  description = 'Show page context summary (URL, headings, experience, knowledge, ARIA, HTML, research)';
12
- suggestions = ['context:aria', 'context:html', 'context:knowledge', 'context:experience', 'context:data'];
12
+ suggestions: Suggestion[] = [
13
+ { command: 'context:aria', hint: 'show page ARIA snapshot' },
14
+ { command: 'context:html', hint: 'show page HTML' },
15
+ { command: 'context:knowledge', hint: 'show relevant knowledge' },
16
+ { command: 'context:experience', hint: 'show relevant experience' },
17
+ { command: 'context:data', hint: 'show captured page data' },
18
+ ];
13
19
  options = [
14
20
  { flags: '--visual', description: 'Include annotated screenshot' },
15
21
  { flags: '--screenshot', description: 'Include annotated screenshot' },
@@ -1,9 +1,12 @@
1
- import { BaseCommand } from './base-command.js';
1
+ import { BaseCommand, type Suggestion } from './base-command.js';
2
2
 
3
3
  export class DrillCommand extends BaseCommand {
4
4
  name = 'drill';
5
5
  description = 'Drill all components on current page to learn interactions';
6
- suggestions = ['/research - to see UI map first', '/navigate <page> - to go to another page'];
6
+ suggestions: Suggestion[] = [
7
+ { command: 'research', hint: 'see UI map first' },
8
+ { command: 'navigate <page>', hint: 'go to another page' },
9
+ ];
7
10
 
8
11
  async execute(args: string): Promise<void> {
9
12
  const knowledgePath = this.parseKnowledgeArg(args);
@@ -0,0 +1,125 @@
1
+ import chalk from 'chalk';
2
+ import type { ExperienceTocEntry, ExperienceTracker } from '../experience-tracker.js';
3
+ import { tag } from '../utils/logger.js';
4
+ import { BaseCommand, type Suggestion } from './base-command.js';
5
+
6
+ export class ExperienceCommand extends BaseCommand {
7
+ name = 'experience';
8
+ description = 'List stored experiences; filter by filename or URL substring; expand a section by ref (e.g. LW1)';
9
+ options = [
10
+ { flags: '--recent', description: 'Only files modified within the last 30 days' },
11
+ { flags: '--old', description: 'Only files modified more than 30 days ago' },
12
+ ];
13
+ suggestions: Suggestion[] = [];
14
+
15
+ async execute(args: string): Promise<void> {
16
+ const { opts, args: remaining } = this.parseArgs(args);
17
+ if (opts.recent && opts.old) {
18
+ tag('info').log('Flags --recent and --old are mutually exclusive.');
19
+ return;
20
+ }
21
+ const recency = opts.recent ? 'recent' : opts.old ? 'old' : undefined;
22
+
23
+ const tracker = this.explorBot.getExperienceTracker();
24
+ const [first, second] = remaining;
25
+
26
+ const combinedRef = first?.match(/^([A-Z]+)[.\-]?(\d+)$/i);
27
+ if (combinedRef) {
28
+ this.expand(tracker, combinedRef[1].toUpperCase(), Number(combinedRef[2]));
29
+ return;
30
+ }
31
+
32
+ if (first && /^[A-Z]+$/i.test(first) && second && /^\d+$/.test(second)) {
33
+ this.expand(tracker, first.toUpperCase(), Number(second));
34
+ return;
35
+ }
36
+
37
+ const secondRef = second?.match(/^([A-Z]+)[.\-]?(\d+)$/i);
38
+ if (first && secondRef) {
39
+ this.expand(tracker, secondRef[1].toUpperCase(), Number(secondRef[2]), first);
40
+ return;
41
+ }
42
+
43
+ if (first && second && /^\d+$/.test(second)) {
44
+ const toc = tracker.listAllExperienceToc(first, { recency });
45
+ if (toc.length === 0) {
46
+ tag('info').log(`No experience found matching: ${first}`);
47
+ return;
48
+ }
49
+ this.expand(tracker, toc[0].fileTag, Number(second), first);
50
+ return;
51
+ }
52
+
53
+ const toc = tracker.listAllExperienceToc(first, { recency });
54
+ if (toc.length === 0) {
55
+ const scope = recency === 'recent' ? ' (recent only)' : recency === 'old' ? ' (old only)' : '';
56
+ tag('info').log(first ? `No experience found matching: ${first}${scope}` : `No experience files found${scope}. Experience is recorded automatically during test sessions.`);
57
+ return;
58
+ }
59
+
60
+ tag('info').log(this.formatToc(toc, first, recency));
61
+
62
+ const hints: Suggestion[] = [];
63
+ const exampleRef = `${toc[0].fileTag}1`;
64
+ hints.push({ command: `experience ${exampleRef}`, hint: 'read a section' });
65
+
66
+ const overloaded = toc.filter((entry) => entry.sections.length >= 10);
67
+ for (const entry of overloaded) {
68
+ hints.push({ command: `compact ${entry.fileHash}.md`, hint: `compact this file (${entry.sections.length} sections)` });
69
+ }
70
+
71
+ this.suggestions = hints;
72
+ }
73
+
74
+ private expand(tracker: ExperienceTracker, fileTag: string, sectionIndex: number, urlFilter?: string): void {
75
+ const section = tracker.getExperienceSectionByTag(fileTag, sectionIndex, urlFilter);
76
+ if (!section) {
77
+ tag('info').log(`No section ${fileTag}${sectionIndex} found${urlFilter ? ` for URL matching: ${urlFilter}` : ''}`);
78
+ return;
79
+ }
80
+
81
+ tag('info').log(`${chalk.dim('File:')} ${chalk.bold.green(`${section.fileHash}.md`)}`);
82
+ tag('info').log(`${chalk.dim('URL:')} ${chalk.bold.cyan(section.url)}`);
83
+ tag('info').log(`${chalk.green(`${fileTag}${sectionIndex}`)} ${chalk.bold(section.title)}`);
84
+ tag('multiline').log(this.stripLeadingHeading(section.content.trim()));
85
+ }
86
+
87
+ private formatToc(toc: ExperienceTocEntry[], urlFilter?: string, recency?: 'recent' | 'old'): string {
88
+ const totalSections = toc.reduce((sum, entry) => sum + entry.sections.length, 0);
89
+ const lines: string[] = [];
90
+
91
+ const scope = recency === 'recent' ? ' (recent, ≤30d)' : recency === 'old' ? ' (old, >30d)' : '';
92
+ const title = urlFilter ? `Experience matching "${urlFilter}"${scope}` : `Stored experience${scope}`;
93
+ lines.push(chalk.bold.underline.cyan(title));
94
+
95
+ for (const entry of toc) {
96
+ lines.push('');
97
+ lines.push(chalk.bold.green(`${entry.fileHash}.md`));
98
+ lines.push(` ${chalk.cyan(entry.url)}`);
99
+ for (let i = 0; i < entry.sections.length; i++) {
100
+ const section = entry.sections[i];
101
+ const isLast = i === entry.sections.length - 1;
102
+ const branch = isLast ? '└─' : '├─';
103
+ const ref = chalk.yellow(`${entry.fileTag}${section.index}`);
104
+ lines.push(` ${chalk.dim(branch)} ${ref} ${chalk.dim(':')} ${section.title}`);
105
+ }
106
+ }
107
+
108
+ const urls = new Set(toc.map((e) => e.url)).size;
109
+ lines.push('');
110
+ lines.push(chalk.bold('Summary'));
111
+ lines.push(` ${chalk.dim('URLs:')} ${chalk.bold(String(urls))}`);
112
+ lines.push(` ${chalk.dim('Files:')} ${chalk.bold(String(toc.length))}`);
113
+ lines.push(` ${chalk.dim('Entries:')} ${chalk.bold(String(totalSections))}`);
114
+
115
+ return lines.join('\n');
116
+ }
117
+
118
+ private stripLeadingHeading(body: string): string {
119
+ const lines = body.split('\n');
120
+ if (lines[0]?.match(/^#{1,6}\s/)) {
121
+ return lines.slice(1).join('\n').trimStart();
122
+ }
123
+ return body;
124
+ }
125
+ }