explorbot 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/bin/explorbot-cli.ts +70 -8
  2. package/boat/api-tester/src/ai/curler-tools.ts +3 -3
  3. package/boat/api-tester/src/ai/curler.ts +1 -1
  4. package/boat/api-tester/src/apibot.ts +2 -2
  5. package/boat/api-tester/src/config.ts +1 -1
  6. package/dist/bin/explorbot-cli.js +70 -7
  7. package/dist/boat/api-tester/src/ai/curler-tools.js +2 -2
  8. package/dist/boat/api-tester/src/apibot.js +2 -2
  9. package/dist/package.json +1 -1
  10. package/dist/src/ai/bosun.js +5 -1
  11. package/dist/src/ai/experience-compactor.js +235 -50
  12. package/dist/src/ai/historian.js +13 -6
  13. package/dist/src/ai/navigator.js +62 -62
  14. package/dist/src/ai/pilot.js +22 -0
  15. package/dist/src/ai/planner/subpages.js +1 -30
  16. package/dist/src/ai/planner.js +4 -4
  17. package/dist/src/ai/provider.js +1 -1
  18. package/dist/src/ai/rerunner.js +3 -3
  19. package/dist/src/ai/researcher/deep-analysis.js +1 -1
  20. package/dist/src/ai/researcher/fingerprint-worker.js +1 -1
  21. package/dist/src/ai/researcher/locators.js +1 -1
  22. package/dist/src/ai/researcher/sections.js +8 -1
  23. package/dist/src/ai/researcher.js +4 -11
  24. package/dist/src/ai/tools.js +5 -3
  25. package/dist/src/api/request-store.js +20 -0
  26. package/dist/src/api/xhr-capture.js +19 -3
  27. package/dist/src/command-handler.js +1 -1
  28. package/dist/src/commands/add-rule-command.js +1 -1
  29. package/dist/src/commands/base-command.js +20 -0
  30. package/dist/src/commands/clean-command.js +1 -1
  31. package/dist/src/commands/compact-command.js +138 -0
  32. package/dist/src/commands/context-command.js +7 -1
  33. package/dist/src/commands/drill-command.js +4 -1
  34. package/dist/src/commands/experience-command.js +104 -0
  35. package/dist/src/commands/explore-command.js +33 -7
  36. package/dist/src/commands/freesail-command.js +2 -0
  37. package/dist/src/commands/index.js +7 -3
  38. package/dist/src/commands/init-command.js +2 -2
  39. package/dist/src/commands/learn-command.js +1 -1
  40. package/dist/src/commands/navigate-command.js +4 -1
  41. package/dist/src/commands/plan-clear-command.js +4 -1
  42. package/dist/src/commands/plan-command.js +11 -4
  43. package/dist/src/commands/plan-edit-command.js +1 -1
  44. package/dist/src/commands/plan-load-command.js +4 -1
  45. package/dist/src/commands/plan-reload-command.js +4 -1
  46. package/dist/src/commands/plan-save-command.js +1 -1
  47. package/dist/src/commands/research-command.js +5 -2
  48. package/dist/src/commands/start-command.js +5 -1
  49. package/dist/src/commands/test-command.js +7 -1
  50. package/dist/src/experience-tracker.js +191 -56
  51. package/dist/src/explorbot.js +26 -14
  52. package/dist/src/explorer.js +3 -3
  53. package/dist/src/reporter.js +17 -2
  54. package/dist/src/stats.js +2 -0
  55. package/dist/src/suite.js +1 -1
  56. package/dist/src/utils/error-page.js +10 -0
  57. package/dist/src/utils/logger.js +1 -1
  58. package/dist/src/utils/rules-loader.js +1 -1
  59. package/dist/src/utils/test-files.js +1 -1
  60. package/dist/src/utils/url-matcher.js +50 -0
  61. package/package.json +1 -1
  62. package/src/ai/bosun.ts +5 -1
  63. package/src/ai/experience-compactor.ts +270 -63
  64. package/src/ai/historian.ts +12 -7
  65. package/src/ai/navigator.ts +68 -66
  66. package/src/ai/pilot.ts +22 -0
  67. package/src/ai/planner/subpages.ts +1 -24
  68. package/src/ai/planner.ts +5 -5
  69. package/src/ai/provider.ts +1 -1
  70. package/src/ai/rerunner.ts +3 -3
  71. package/src/ai/researcher/deep-analysis.ts +1 -1
  72. package/src/ai/researcher/fingerprint-worker.ts +1 -1
  73. package/src/ai/researcher/locators.ts +2 -2
  74. package/src/ai/researcher/sections.ts +7 -1
  75. package/src/ai/researcher.ts +4 -11
  76. package/src/ai/task-agent.ts +1 -1
  77. package/src/ai/tools.ts +6 -4
  78. package/src/api/request-store.ts +22 -0
  79. package/src/api/xhr-capture.ts +21 -3
  80. package/src/command-handler.ts +1 -1
  81. package/src/commands/add-rule-command.ts +2 -2
  82. package/src/commands/base-command.ts +26 -1
  83. package/src/commands/clean-command.ts +2 -2
  84. package/src/commands/compact-command.ts +156 -0
  85. package/src/commands/context-command.ts +8 -2
  86. package/src/commands/drill-command.ts +5 -2
  87. package/src/commands/experience-command.ts +125 -0
  88. package/src/commands/explore-command.ts +35 -9
  89. package/src/commands/freesail-command.ts +2 -0
  90. package/src/commands/index.ts +7 -3
  91. package/src/commands/init-command.ts +2 -2
  92. package/src/commands/learn-command.ts +2 -2
  93. package/src/commands/navigate-command.ts +5 -2
  94. package/src/commands/plan-clear-command.ts +5 -2
  95. package/src/commands/plan-command.ts +12 -5
  96. package/src/commands/plan-edit-command.ts +2 -2
  97. package/src/commands/plan-load-command.ts +5 -2
  98. package/src/commands/plan-reload-command.ts +5 -2
  99. package/src/commands/plan-save-command.ts +2 -2
  100. package/src/commands/research-command.ts +6 -3
  101. package/src/commands/start-command.ts +6 -2
  102. package/src/commands/test-command.ts +8 -2
  103. package/src/experience-tracker.ts +220 -71
  104. package/src/explorbot.ts +28 -15
  105. package/src/explorer.ts +3 -3
  106. package/src/reporter.ts +17 -3
  107. package/src/stats.ts +4 -0
  108. package/src/suite.ts +1 -1
  109. package/src/utils/error-page.ts +10 -0
  110. package/src/utils/logger.ts +1 -1
  111. package/src/utils/rules-loader.ts +1 -1
  112. package/src/utils/test-files.ts +1 -1
  113. package/src/utils/url-matcher.ts +43 -0
@@ -9,6 +9,22 @@ import { mdq } from './utils/markdown-query.js';
9
9
  import { extractStatePath } from './utils/url-matcher.js';
10
10
  const debugLog = createDebug('explorbot:experience');
11
11
  const DEFAULT_MAX_EXPERIENCE_LINES = 100;
12
+ export const RECENT_WINDOW_DAYS = 30;
13
+ /**
14
+ * Stores and reads per-page experience files (`./experience/<stateHash>.md`).
15
+ *
16
+ * Format rules (enforced by writeFlow/writeAction — the only supported writers):
17
+ *
18
+ * ## FLOW: <imperative title> multi-step, `*` bullets + optional ```js``` + `>` discovery, ends with `---`
19
+ * ## ACTION: <imperative title> single-step, optional `Solution:` line + one ```js``` code block
20
+ *
21
+ * - Always h2. Never h3 for FLOW/ACTION.
22
+ * - Title is an imperative verb phrase, lowercase-first, no trailing punctuation.
23
+ * Writers normalize automatically (strip own `FLOW:` / `ACTION:` prefix if the caller included it,
24
+ * lowercase first char, trim trailing `.!?,;:`).
25
+ * - On read (getSuccessfulExperience), headings are rendered as
26
+ * `## HOW to <title> (multi-step|single-step)` so prompts get natural phrasing.
27
+ */
12
28
  export class ExperienceTracker {
13
29
  experienceDir;
14
30
  disabled;
@@ -122,28 +138,50 @@ export class ExperienceTracker {
122
138
  isWritingDisabled(state) {
123
139
  return this.knowledgeTracker.getRelevantKnowledge(state).some((k) => k.noExperienceWriting === true || k.noExperienceWriting === 'true');
124
140
  }
125
- async saveSuccessfulResolution(state, originalMessage, code, explanation) {
141
+ writeAction(state, action) {
126
142
  if (this.disabled || this.isWritingDisabled(state))
127
143
  return;
144
+ if (!action.code?.trim())
145
+ return;
128
146
  this.ensureExperienceFile(state);
129
147
  const stateHash = state.getStateHash();
130
148
  const { content, data } = this.readExperienceFile(stateHash);
131
- if (content.includes(code)) {
132
- debugLog('Skipping duplicate successful resolution', code);
149
+ if (content.includes(action.code)) {
150
+ debugLog('Skipping duplicate action', action.code);
151
+ return;
152
+ }
153
+ const title = normalizeTitle(action.title.split('\n')[0]);
154
+ if (!title)
155
+ return;
156
+ const filteredCode = action.code.replace(/I\.amOnPage\s*\([^)]*\)/gs, '');
157
+ const newEntry = generateActionContent(title, filteredCode, action.explanation);
158
+ const updatedContent = `${newEntry}\n\n${content}`;
159
+ this.writeExperienceFile(stateHash, updatedContent, data);
160
+ tag('substep').log(` Added ACTION to: ${stateHash}.md`);
161
+ }
162
+ writeFlow(state, flow) {
163
+ if (this.disabled || this.isWritingDisabled(state))
164
+ return;
165
+ if (!flow.steps?.length)
133
166
  return;
167
+ this.ensureExperienceFile(state);
168
+ const stateHash = state.getStateHash();
169
+ const { content, data } = this.readExperienceFile(stateHash);
170
+ if (flow.relatedUrls?.length) {
171
+ const currentPath = extractStatePath(state.url || '');
172
+ const existingRelated = Array.isArray(data.related) ? data.related : [];
173
+ const allRelated = [...new Set([...existingRelated, ...flow.relatedUrls])];
174
+ data.related = allRelated.filter((url) => url !== currentPath);
134
175
  }
135
- const filteredCode = code.replace(/I\.amOnPage\s*\([^)]*\)/gs, '');
136
- const newEntryContent = `### SUCCEEDED: ${originalMessage.split('\n')[0]}
137
-
138
- ${explanation ? `Solution: ${explanation}` : ''}
139
-
140
- \`\`\`javascript
141
- ${filteredCode}
142
- \`\`\`
143
- `;
144
- const updatedContent = `${newEntryContent}\n\n${content}`;
176
+ const title = normalizeTitle(flow.scenario);
177
+ if (!title)
178
+ return;
179
+ const sessionContent = this.trimSessionContent(generateFlowContent(title, flow.steps));
180
+ if (!sessionContent)
181
+ return;
182
+ const updatedContent = `${sessionContent}\n${content}`;
145
183
  this.writeExperienceFile(stateHash, updatedContent, data);
146
- tag('substep').log(` Added successful resolution to: ${stateHash}.md`);
184
+ tag('substep').log(`Added FLOW to: ${stateHash}.md`);
147
185
  }
148
186
  getAllExperience() {
149
187
  const allFiles = [];
@@ -159,10 +197,12 @@ ${filteredCode}
159
197
  try {
160
198
  const content = readFileSync(file, 'utf8');
161
199
  const parsed = matter(content);
200
+ const mtime = statSync(file).mtime;
162
201
  allFiles.push({
163
202
  filePath: file,
164
203
  data: parsed.data,
165
204
  content: parsed.content,
205
+ mtime,
166
206
  });
167
207
  }
168
208
  catch (error) {
@@ -205,44 +245,6 @@ ${filteredCode}
205
245
  // Clear any in-memory state if needed
206
246
  // The actual files will be cleaned up by test cleanup
207
247
  }
208
- saveSessionExperience(state, entry) {
209
- if (this.disabled || this.isWritingDisabled(state))
210
- return;
211
- this.ensureExperienceFile(state);
212
- const stateHash = state.getStateHash();
213
- const { content, data } = this.readExperienceFile(stateHash);
214
- if (entry.relatedUrls?.length) {
215
- const currentPath = extractStatePath(state.url || '');
216
- const existingRelated = Array.isArray(data.related) ? data.related : [];
217
- const allRelated = [...new Set([...existingRelated, ...entry.relatedUrls])];
218
- data.related = allRelated.filter((url) => url !== currentPath);
219
- }
220
- const sessionContent = this.trimSessionContent(this.generateSessionContent(entry));
221
- if (!sessionContent)
222
- return;
223
- const updatedContent = `${sessionContent}\n${content}`;
224
- this.writeExperienceFile(stateHash, updatedContent, data);
225
- tag('substep').log(`Added session experience to: ${stateHash}.md`);
226
- }
227
- generateSessionContent(entry) {
228
- let content = `## Successful Flow: ${entry.scenario}\n\n`;
229
- for (const step of entry.steps) {
230
- content += `* ${step.message}\n\n`;
231
- if (step.code) {
232
- content += '```js\n';
233
- content += `${step.code}\n`;
234
- content += '```\n\n';
235
- }
236
- if (step.discovery) {
237
- const discoveries = step.discovery.split('\n').filter((d) => d.trim());
238
- for (const discovery of discoveries) {
239
- content += `> ${discovery.trim()}\n\n`;
240
- }
241
- }
242
- }
243
- content += '---\n';
244
- return content;
245
- }
246
248
  trimSessionContent(content) {
247
249
  const q = mdq(content);
248
250
  if (q.query('heading').count() === 0)
@@ -278,11 +280,12 @@ ${filteredCode}
278
280
  for (const record of records) {
279
281
  if (!record.content)
280
282
  continue;
281
- const successFlows = mdq(record.content).query('section(~"Successful Flow")').text();
282
- const succeeded = mdq(record.content).query('section(~"SUCCEEDED")').text();
283
- let combined = [successFlows, succeeded].filter(Boolean).join('\n\n');
283
+ const flows = mdq(record.content).query('section(~"FLOW:")').text();
284
+ const actions = mdq(record.content).query('section(~"ACTION:")').text();
285
+ let combined = [flows, actions].filter(Boolean).join('\n\n');
284
286
  if (!combined.trim())
285
287
  continue;
288
+ combined = renderAsHowTo(combined);
286
289
  if (options?.stripCode) {
287
290
  combined = mdq(combined).query('code').replace('');
288
291
  }
@@ -339,6 +342,69 @@ ${filteredCode}
339
342
  }
340
343
  return null;
341
344
  }
345
+ listAllExperienceToc(filter, options) {
346
+ const records = this.getAllExperience();
347
+ if (records.length === 0)
348
+ return [];
349
+ const trimmed = filter?.trim();
350
+ let matching = records;
351
+ if (trimmed) {
352
+ if (trimmed.endsWith('.md')) {
353
+ const bare = trimmed.slice(0, -3);
354
+ const byFilename = records.find((record) => basename(record.filePath, '.md') === bare);
355
+ matching = byFilename ? [byFilename] : [];
356
+ }
357
+ else {
358
+ const lower = trimmed.toLowerCase();
359
+ matching = records.filter((record) => {
360
+ const url = (record.data?.url || '').toLowerCase();
361
+ if (!url)
362
+ return false;
363
+ return url.includes(lower);
364
+ });
365
+ }
366
+ }
367
+ if (options?.recency) {
368
+ const cutoff = Date.now() - RECENT_WINDOW_DAYS * 24 * 60 * 60 * 1000;
369
+ matching = matching.filter((record) => {
370
+ const isRecent = record.mtime.getTime() >= cutoff;
371
+ return options.recency === 'recent' ? isRecent : !isRecent;
372
+ });
373
+ }
374
+ const sorted = matching.sort((a, b) => {
375
+ const aUrl = a.data?.url || '';
376
+ const bUrl = b.data?.url || '';
377
+ return aUrl.localeCompare(bUrl);
378
+ });
379
+ const toc = [];
380
+ for (let i = 0; i < sorted.length; i++) {
381
+ const record = sorted[i];
382
+ const sections = listTocHeadings(record.content);
383
+ if (sections.length === 0)
384
+ continue;
385
+ toc.push({
386
+ fileTag: indexToLetters(toc.length),
387
+ fileHash: basename(record.filePath, '.md'),
388
+ url: record.data?.url || '',
389
+ sections,
390
+ });
391
+ }
392
+ return toc;
393
+ }
394
+ getExperienceSectionByTag(fileTag, sectionIndex, filter) {
395
+ const toc = this.listAllExperienceToc(filter);
396
+ const entry = toc.find((e) => e.fileTag === fileTag);
397
+ if (!entry)
398
+ return null;
399
+ const filePath = this.findExperienceFileByHash(entry.fileHash);
400
+ if (!filePath)
401
+ return null;
402
+ const { content } = this.readExperienceFile(entry.fileHash);
403
+ const extracted = extractHeadingSection(content, sectionIndex);
404
+ if (!extracted)
405
+ return null;
406
+ return { title: extracted.title, url: entry.url, content: extracted.body, fileHash: entry.fileHash };
407
+ }
342
408
  }
343
409
  function listTocHeadings(content) {
344
410
  const tokens = marked.lexer(content);
@@ -403,7 +469,9 @@ export function renderExperienceToc(toc) {
403
469
  return '';
404
470
  const lines = [];
405
471
  lines.push('<experience>');
406
- lines.push('Past experience for this page. Call learn_experience({ fileTag, sectionIndex }) to read a section.');
472
+ lines.push('Past experience for this page reusable recipes recorded from prior successful runs.');
473
+ lines.push('FLOW: = multi-step recipe (bullets + code + discovery). ACTION: = single-step snippet (one code block).');
474
+ lines.push('Call learn_experience({ fileTag, sectionIndex }) to read a section when it looks relevant to the current step.');
407
475
  lines.push('');
408
476
  for (const entry of toc) {
409
477
  lines.push(`File ${entry.fileTag} ${entry.url}:`);
@@ -416,3 +484,70 @@ export function renderExperienceToc(toc) {
416
484
  lines.push('</experience>');
417
485
  return lines.join('\n');
418
486
  }
487
+ function normalizeTitle(raw) {
488
+ let t = (raw || '').trim();
489
+ for (const p of ['FLOW:', 'ACTION:']) {
490
+ if (t.toLowerCase().startsWith(p.toLowerCase())) {
491
+ t = t.slice(p.length).trim();
492
+ break;
493
+ }
494
+ }
495
+ while (t.length > 0 && '.!?,;:'.includes(t[t.length - 1])) {
496
+ t = t.slice(0, -1);
497
+ }
498
+ if (t.length > 0)
499
+ t = t[0].toLowerCase() + t.slice(1);
500
+ return t;
501
+ }
502
+ function generateActionContent(title, code, explanation) {
503
+ const lines = [];
504
+ lines.push(`## ACTION: ${title}`);
505
+ lines.push('');
506
+ if (explanation) {
507
+ lines.push(`Solution: ${explanation}`);
508
+ lines.push('');
509
+ }
510
+ lines.push('```javascript');
511
+ lines.push(code);
512
+ lines.push('```');
513
+ lines.push('');
514
+ return lines.join('\n');
515
+ }
516
+ function generateFlowContent(title, steps) {
517
+ let content = `## FLOW: ${title}\n\n`;
518
+ for (const step of steps) {
519
+ content += `* ${step.message}\n\n`;
520
+ if (step.code) {
521
+ content += '```js\n';
522
+ content += `${step.code}\n`;
523
+ content += '```\n\n';
524
+ }
525
+ if (step.discovery) {
526
+ const discoveries = step.discovery.split('\n').filter((d) => d.trim());
527
+ for (const discovery of discoveries) {
528
+ content += `> ${discovery.trim()}\n\n`;
529
+ }
530
+ }
531
+ }
532
+ content += '---\n';
533
+ return content;
534
+ }
535
+ function renderAsHowTo(content) {
536
+ const tokens = marked.lexer(content);
537
+ let result = '';
538
+ for (const token of tokens) {
539
+ if (token.type === 'heading' && token.depth === 2) {
540
+ const text = token.text.trim();
541
+ if (text.startsWith('FLOW:')) {
542
+ result += `## HOW to ${text.slice(5).trim()} (multi-step)\n\n`;
543
+ continue;
544
+ }
545
+ if (text.startsWith('ACTION:')) {
546
+ result += `## HOW to ${text.slice(7).trim()} (single-step)\n\n`;
547
+ continue;
548
+ }
549
+ }
550
+ result += token.raw || '';
551
+ }
552
+ return result;
553
+ }
@@ -1,9 +1,6 @@
1
1
  import { existsSync, mkdirSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { ActionResult } from "./action-result.js";
4
- import { ApiClient } from "./api/api-client.js";
5
- import { RequestStore } from "./api/request-store.js";
6
- import { loadSpec } from "./api/spec-reader.js";
7
4
  import { Bosun } from "./ai/bosun.js";
8
5
  import { Captain } from "./ai/captain.js";
9
6
  import { ExperienceCompactor } from "./ai/experience-compactor.js";
@@ -14,11 +11,15 @@ import { Pilot } from "./ai/pilot.js";
14
11
  import { Planner } from "./ai/planner.js";
15
12
  import { AIProvider } from "./ai/provider.js";
16
13
  import { Quartermaster } from "./ai/quartermaster.js";
17
- import { Researcher } from "./ai/researcher.js";
18
14
  import { Rerunner } from "./ai/rerunner.js";
15
+ import { Researcher } from "./ai/researcher.js";
19
16
  import { Tester } from "./ai/tester.js";
20
17
  import { createAgentTools } from "./ai/tools.js";
18
+ import { ApiClient } from "./api/api-client.js";
19
+ import { RequestStore } from "./api/request-store.js";
20
+ import { loadSpec } from "./api/spec-reader.js";
21
21
  import { ConfigParser } from "./config.js";
22
+ import { ExperienceTracker } from "./experience-tracker.js";
22
23
  import Explorer from "./explorer.js";
23
24
  import { KnowledgeTracker } from "./knowledge-tracker.js";
24
25
  import { Plan } from "./test-plan.js";
@@ -34,6 +35,7 @@ export class ExplorBot {
34
35
  needsInput = false;
35
36
  currentPlan;
36
37
  planFeature;
38
+ lastPlanError = null;
37
39
  agents = {};
38
40
  constructor(options = {}) {
39
41
  this.options = options;
@@ -54,13 +56,11 @@ export class ExplorBot {
54
56
  return;
55
57
  }
56
58
  try {
57
- this.config = await this.configParser.loadConfig(this.options);
58
- this.provider = new AIProvider(this.config.ai);
59
- await this.provider.validateConnection();
59
+ await this.startProviderOnly();
60
60
  this.explorer = new Explorer(this.config, this.provider, this.options);
61
61
  await this.explorer.start();
62
62
  if (!this.options.incognito) {
63
- await this.agentExperienceCompactor().compactAllExperiences();
63
+ await this.agentExperienceCompactor().autocompact();
64
64
  }
65
65
  if (this.userResolveFn)
66
66
  this.explorer.setUserResolve(this.userResolveFn);
@@ -71,9 +71,16 @@ export class ExplorBot {
71
71
  process.exit(1);
72
72
  }
73
73
  }
74
+ async startProviderOnly() {
75
+ if (this.provider)
76
+ return;
77
+ this.config = await this.configParser.loadConfig(this.options);
78
+ this.provider = new AIProvider(this.config.ai);
79
+ await this.provider.validateConnection();
80
+ }
74
81
  async stop() {
75
82
  this.agents.quartermaster?.stop();
76
- await this.explorer.stop();
83
+ await this.explorer?.stop();
77
84
  }
78
85
  async visitInitialState() {
79
86
  const url = this.options.from || '/';
@@ -97,6 +104,12 @@ export class ExplorBot {
97
104
  }
98
105
  return new KnowledgeTracker();
99
106
  }
107
+ getExperienceTracker() {
108
+ if (this.explorer) {
109
+ return this.explorer.getStateManager().getExperienceTracker();
110
+ }
111
+ return new ExperienceTracker();
112
+ }
100
113
  getConfig() {
101
114
  return this.config;
102
115
  }
@@ -186,10 +199,7 @@ export class ExplorBot {
186
199
  return this.agents.captain;
187
200
  }
188
201
  agentExperienceCompactor() {
189
- return (this.agents.experienceCompactor ||= this.createAgent(({ ai, explorer }) => {
190
- const experienceTracker = explorer.getStateManager().getExperienceTracker();
191
- return new ExperienceCompactor(ai, experienceTracker);
192
- }));
202
+ return (this.agents.experienceCompactor ||= new ExperienceCompactor(this.provider, this.getExperienceTracker()));
193
203
  }
194
204
  agentQuartermaster() {
195
205
  const config = this.config.ai?.agents?.quartermaster;
@@ -293,11 +303,13 @@ export class ExplorBot {
293
303
  if (this.currentPlan) {
294
304
  planner.setPlan(this.currentPlan);
295
305
  }
306
+ this.lastPlanError = null;
296
307
  try {
297
308
  this.currentPlan = await planner.plan(feature, opts.style, opts.extend, opts.completedPlans);
298
309
  }
299
310
  catch (err) {
300
- tag('warning').log(`Planning failed: ${err instanceof Error ? err.message : err}`);
311
+ this.lastPlanError = err instanceof Error ? err : new Error(String(err));
312
+ tag('warning').log(`Planning failed: ${this.lastPlanError.message}`);
301
313
  if (!this.currentPlan)
302
314
  return undefined;
303
315
  return this.currentPlan;
@@ -8,12 +8,12 @@ import { createTest } from 'codeceptjs/lib/mocha/test';
8
8
  import { ActionResult } from "./action-result.js";
9
9
  import Action from './action.js';
10
10
  import { visuallyAnnotateContainers } from "./ai/researcher/coordinates.js";
11
+ import { RequestStore } from "./api/request-store.js";
12
+ import { XhrCapture } from "./api/xhr-capture.js";
11
13
  import { ConfigParser, outputPath } from './config.js';
12
14
  import { KnowledgeTracker } from './knowledge-tracker.js';
13
15
  import { Reporter } from "./reporter.js";
14
16
  import { StateManager } from './state-manager.js';
15
- import { RequestStore } from "./api/request-store.js";
16
- import { XhrCapture } from "./api/xhr-capture.js";
17
17
  import { createDebug, log, tag } from './utils/logger.js';
18
18
  import { WebElement, extractElementData } from "./utils/web-element.js";
19
19
  const debugLog = createDebug('explorbot:explorer');
@@ -42,7 +42,7 @@ class Explorer {
42
42
  this.initializeContainer();
43
43
  this.stateManager = new StateManager({ incognito: this.options?.incognito });
44
44
  this.knowledgeTracker = new KnowledgeTracker();
45
- this.reporter = new Reporter(config.reporter);
45
+ this.reporter = new Reporter(config.reporter, this.stateManager);
46
46
  }
47
47
  initializeContainer() {
48
48
  try {
@@ -1,20 +1,34 @@
1
1
  import { Client } from '@testomatio/reporter';
2
2
  import { outputPath } from './config.js';
3
+ import { Stats } from './stats.js';
3
4
  import { createDebug } from './utils/logger.js';
4
5
  const debugLog = createDebug('explorbot:reporter');
5
6
  export class Reporter {
6
7
  client;
7
8
  isRunStarted = false;
8
9
  reporterEnabled;
9
- constructor(config) {
10
+ stateManager;
11
+ constructor(config, stateManager) {
10
12
  this.reporterEnabled = Reporter.resolveEnabled(config);
13
+ this.stateManager = stateManager;
11
14
  if (this.reporterEnabled && (!process.env.TESTOMATIO || config?.html)) {
12
15
  this.configureHtmlPipe();
13
16
  }
14
- this.client = new Client({ apiKey: process.env.TESTOMATIO || '' });
15
17
  const pipe = process.env.TESTOMATIO && config?.html ? 'both' : process.env.TESTOMATIO ? 'testomatio' : 'html';
16
18
  debugLog('Reporter initialized', { enabled: this.reporterEnabled, pipe });
17
19
  }
20
+ buildTitle() {
21
+ if (process.env.TESTOMATIO_TITLE)
22
+ return process.env.TESTOMATIO_TITLE;
23
+ const url = this.stateManager?.getCurrentState()?.url;
24
+ const parts = ['Explorbot session'];
25
+ if (url)
26
+ parts.push(url);
27
+ if (Stats.focus)
28
+ parts.push(`focus: "${Stats.focus}"`);
29
+ parts.push(`at ${new Date().toISOString().slice(0, 16)}`);
30
+ return parts.join(' ');
31
+ }
18
32
  static resolveEnabled(config) {
19
33
  if (config?.enabled === true)
20
34
  return true;
@@ -35,6 +49,7 @@ export class Reporter {
35
49
  return;
36
50
  }
37
51
  try {
52
+ this.client = new Client({ apiKey: process.env.TESTOMATIO || '', title: this.buildTitle() });
38
53
  const timeoutMs = Number(process.env.TESTOMATIO_TIMEOUT_MS || '15000');
39
54
  const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve('timeout'), timeoutMs));
40
55
  const result = await Promise.race([this.client.createRun().then(() => 'success'), timeoutPromise]);
package/dist/src/stats.js CHANGED
@@ -3,6 +3,8 @@ export class Stats {
3
3
  static researches = 0;
4
4
  static tests = 0;
5
5
  static plans = 0;
6
+ static mode;
7
+ static focus;
6
8
  static models = {};
7
9
  static recordTokens(_agent, model, usage) {
8
10
  if (!Stats.models[model]) {
package/dist/src/suite.js CHANGED
@@ -3,8 +3,8 @@ import path from 'node:path';
3
3
  import { Reflection } from '@codeceptjs/reflection';
4
4
  import { ConfigParser } from "./config.js";
5
5
  import { normalizeUrl } from "./state-manager.js";
6
- import { parsePlanFromMarkdown } from "./utils/test-plan-markdown.js";
7
6
  import { createDebug } from "./utils/logger.js";
7
+ import { parsePlanFromMarkdown } from "./utils/test-plan-markdown.js";
8
8
  const debugLog = createDebug('explorbot:suite');
9
9
  export class Suite {
10
10
  url;
@@ -16,3 +16,13 @@ export function isErrorPage(actionResult) {
16
16
  return true;
17
17
  return false;
18
18
  }
19
+ export class ErrorPageError extends Error {
20
+ url;
21
+ title;
22
+ constructor(url, title) {
23
+ super(`Error page detected at ${url}${title ? ` (${title})` : ''}`);
24
+ this.url = url;
25
+ this.title = title;
26
+ this.name = 'ErrorPageError';
27
+ }
28
+ }
@@ -4,9 +4,9 @@ import { context, trace } from '@opentelemetry/api';
4
4
  import chalk from 'chalk';
5
5
  import debug from 'debug';
6
6
  import dedent from 'dedent';
7
+ import stripAnsi from 'strip-ansi';
7
8
  import { ConfigParser } from '../config.js';
8
9
  import { Observability } from "../observability.js";
9
- import stripAnsi from 'strip-ansi';
10
10
  import { parseMarkdownToTerminal } from "./markdown-terminal.js";
11
11
  class DebugFilter {
12
12
  patterns = [];
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
2
2
  import { basename, dirname, join, resolve } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { tag } from "./logger.js";
@@ -3,9 +3,9 @@ import path from 'node:path';
3
3
  import chalk from 'chalk';
4
4
  import { highlight } from 'cli-highlight';
5
5
  import * as codeceptjs from 'codeceptjs';
6
- import store from 'codeceptjs/lib/store';
7
6
  import stepsListener from 'codeceptjs/lib/listener/steps';
8
7
  import storeListener from 'codeceptjs/lib/listener/store';
8
+ import store from 'codeceptjs/lib/store';
9
9
  import figureSet from 'figures';
10
10
  import { ConfigParser } from "../config.js";
11
11
  export function loadTestSuites(testsDir) {
@@ -1,4 +1,54 @@
1
1
  import micromatch from 'micromatch';
2
+ import { ConfigParser } from '../config.js';
3
+ export function isDynamicSegment(segment) {
4
+ try {
5
+ const configRegex = ConfigParser.getInstance().getConfig().dynamicPageRegex;
6
+ if (configRegex)
7
+ return new RegExp(configRegex, 'i').test(segment);
8
+ }
9
+ catch {
10
+ /* config not loaded yet */
11
+ }
12
+ // numeric: /users/123
13
+ if (/^\d+$/.test(segment))
14
+ return true;
15
+ // UUID: /items/550e8400-e29b-41d4-a716-446655440000
16
+ if (/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(segment))
17
+ return true;
18
+ // ULID: /items/01ARZ3NDEKTSV4RRFFQ69G5FAV
19
+ if (/^[0-9A-HJKMNP-TV-Z]{26}$/.test(segment))
20
+ return true;
21
+ // hex ID (4+ chars): /suite/70dae98a
22
+ if (/^[a-f0-9]{4,}$/i.test(segment))
23
+ return true;
24
+ // hex-prefixed slug (8+ hex before dash): /suite/95ef0c94-mobile
25
+ if (/^[a-f0-9]{8,}-/i.test(segment))
26
+ return true;
27
+ // short mixed alphanumeric (digits + letters, ≤8 chars, no dash): /item/x7f2
28
+ if (segment.length <= 8 && !segment.includes('-') && /\d/.test(segment) && /[a-z]/i.test(segment))
29
+ return true;
30
+ return false;
31
+ }
32
+ export function hasDynamicUrlSegment(url) {
33
+ return url.split('/').some((seg) => seg.length > 0 && isDynamicSegment(seg));
34
+ }
35
+ export function generalizeSegment(segment) {
36
+ if (/^\d+$/.test(segment))
37
+ return '\\d+';
38
+ if (/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(segment))
39
+ return '[a-f0-9-]+';
40
+ if (/^[0-9A-HJKMNP-TV-Z]{26}$/.test(segment))
41
+ return '[0-9A-HJKMNP-TV-Z]+';
42
+ if (/^[a-f0-9]+$/i.test(segment))
43
+ return '[a-f0-9]+';
44
+ return '[^/]+';
45
+ }
46
+ export function generalizeUrl(url) {
47
+ return url
48
+ .split('/')
49
+ .map((seg) => (seg.length > 0 && isDynamicSegment(seg) ? generalizeSegment(seg) : seg))
50
+ .join('/');
51
+ }
2
52
  export function matchesUrl(pattern, path) {
3
53
  if (pattern === '*')
4
54
  return true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
package/src/ai/bosun.ts CHANGED
@@ -473,7 +473,11 @@ export class Bosun extends TaskAgent implements Agent {
473
473
  const successfulInteractions = results.filter((r) => r.result === 'success' && r.code);
474
474
 
475
475
  for (const interaction of successfulInteractions) {
476
- await experienceTracker.saveSuccessfulResolution(actionResult, `Drill ${interaction.action}: ${interaction.component}`, interaction.code!, interaction.description);
476
+ experienceTracker.writeAction(actionResult, {
477
+ title: `Drill ${interaction.action}: ${interaction.component}`,
478
+ code: interaction.code!,
479
+ explanation: interaction.description,
480
+ });
477
481
  }
478
482
 
479
483
  if (successfulInteractions.length > 0) {