explorbot 0.1.12 → 0.1.15

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 (75) hide show
  1. package/bin/explorbot-cli.ts +21 -21
  2. package/dist/bin/explorbot-cli.js +3 -3
  3. package/dist/package.json +4 -2
  4. package/dist/rules/researcher/container-rules.md +2 -0
  5. package/dist/src/action-result.js +2 -1
  6. package/dist/src/action.js +3 -8
  7. package/dist/src/ai/captain.js +0 -2
  8. package/dist/src/ai/conversation.js +20 -4
  9. package/dist/src/ai/driller.js +1108 -0
  10. package/dist/src/ai/historian/utils.js +8 -1
  11. package/dist/src/ai/pilot.js +214 -267
  12. package/dist/src/ai/provider.js +25 -12
  13. package/dist/src/ai/quartermaster.js +2 -2
  14. package/dist/src/ai/rules.js +5 -5
  15. package/dist/src/ai/session-analyst.js +122 -0
  16. package/dist/src/ai/tester.js +69 -22
  17. package/dist/src/ai/tools.js +19 -4
  18. package/dist/src/commands/base-command.js +6 -6
  19. package/dist/src/commands/drill-command.js +3 -2
  20. package/dist/src/commands/exit-command.js +1 -0
  21. package/dist/src/commands/explore-command.js +9 -2
  22. package/dist/src/components/AddRule.js +1 -1
  23. package/dist/src/components/StatusPane.js +6 -1
  24. package/dist/src/experience-tracker.js +9 -0
  25. package/dist/src/explorbot.js +48 -8
  26. package/dist/src/explorer.js +11 -13
  27. package/dist/src/reporter.js +105 -4
  28. package/dist/src/state-manager.js +4 -3
  29. package/dist/src/stats.js +7 -1
  30. package/dist/src/test-plan.js +47 -3
  31. package/dist/src/utils/aria.js +354 -529
  32. package/dist/src/utils/hooks-runner.js +2 -8
  33. package/dist/src/utils/html.js +371 -0
  34. package/dist/src/utils/unique-names.js +12 -1
  35. package/dist/src/utils/url-matcher.js +6 -1
  36. package/dist/src/utils/web-element.js +27 -24
  37. package/dist/src/utils/xpath.js +1 -1
  38. package/package.json +4 -2
  39. package/rules/researcher/container-rules.md +2 -0
  40. package/src/action-result.ts +2 -1
  41. package/src/action.ts +3 -10
  42. package/src/ai/captain.ts +0 -2
  43. package/src/ai/conversation.ts +21 -4
  44. package/src/ai/driller.ts +1194 -0
  45. package/src/ai/historian/utils.ts +8 -1
  46. package/src/ai/pilot.ts +215 -265
  47. package/src/ai/provider.ts +24 -12
  48. package/src/ai/quartermaster.ts +2 -2
  49. package/src/ai/rules.ts +5 -5
  50. package/src/ai/session-analyst.ts +139 -0
  51. package/src/ai/tester.ts +63 -20
  52. package/src/ai/tools.ts +18 -4
  53. package/src/commands/base-command.ts +6 -6
  54. package/src/commands/drill-command.ts +3 -2
  55. package/src/commands/exit-command.ts +1 -0
  56. package/src/commands/explore-command.ts +10 -2
  57. package/src/components/AddRule.tsx +1 -1
  58. package/src/components/StatusPane.tsx +6 -3
  59. package/src/config.ts +4 -0
  60. package/src/experience-tracker.ts +9 -0
  61. package/src/explorbot.ts +55 -10
  62. package/src/explorer.ts +10 -12
  63. package/src/reporter.ts +108 -4
  64. package/src/state-manager.ts +4 -3
  65. package/src/stats.ts +10 -1
  66. package/src/test-plan.ts +62 -3
  67. package/src/utils/aria.ts +367 -537
  68. package/src/utils/hooks-runner.ts +2 -6
  69. package/src/utils/html.ts +381 -0
  70. package/src/utils/unique-names.ts +13 -0
  71. package/src/utils/url-matcher.ts +5 -1
  72. package/src/utils/web-element.ts +31 -28
  73. package/src/utils/xpath.ts +1 -1
  74. package/dist/src/ai/bosun.js +0 -456
  75. package/src/ai/bosun.ts +0 -571
@@ -32,5 +32,10 @@ export const StatusPane = ({ onComplete }) => {
32
32
  React.createElement(Box, { marginTop: 1, marginBottom: 1 },
33
33
  React.createElement(Text, { bold: true }, "Usage")),
34
34
  React.createElement(Row, { label: "Time", value: Stats.getElapsedTime() }),
35
- tokenRows.map(([model, tokens]) => (React.createElement(Row, { key: model, label: model, value: `${Stats.humanizeTokens(tokens.total)} tokens` })))))));
35
+ tokenRows.map(([model, tokens]) => {
36
+ const cached = tokens.cached ?? 0;
37
+ const cachePct = tokens.input > 0 ? Math.round((cached / tokens.input) * 100) : 0;
38
+ const suffix = cached > 0 ? ` (${Stats.humanizeTokens(cached)} cached, ${cachePct}%)` : '';
39
+ return React.createElement(Row, { key: model, label: model, value: `${Stats.humanizeTokens(tokens.total)} tokens${suffix}` });
40
+ })))));
36
41
  };
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSy
2
2
  import { basename, dirname, join } from 'node:path';
3
3
  import matter from 'gray-matter';
4
4
  import { marked } from 'marked';
5
+ import { isNonReusableCode } from "./ai/historian/utils.js";
5
6
  import { ConfigParser } from './config.js';
6
7
  import { KnowledgeTracker } from './knowledge-tracker.js';
7
8
  import { createDebug, tag } from './utils/logger.js';
@@ -145,6 +146,10 @@ export class ExperienceTracker {
145
146
  return;
146
147
  if (!action.code?.trim())
147
148
  return;
149
+ if (isNonReusableCode(action.code)) {
150
+ debugLog('Skipping action with non-reusable code: %s', action.code);
151
+ return;
152
+ }
148
153
  this.ensureExperienceFile(state);
149
154
  const stateHash = state.getStateHash();
150
155
  const { content, data } = this.readExperienceFile(stateHash);
@@ -166,6 +171,10 @@ export class ExperienceTracker {
166
171
  return;
167
172
  if (!body?.trim())
168
173
  return;
174
+ if (isNonReusableCode(body)) {
175
+ debugLog('Skipping flow body with non-reusable code');
176
+ return;
177
+ }
169
178
  this.ensureExperienceFile(state);
170
179
  const stateHash = state.getStateHash();
171
180
  const { content, data } = this.readExperienceFile(stateHash);
@@ -1,8 +1,8 @@
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 { Bosun } from "./ai/bosun.js";
5
4
  import { Captain } from "./ai/captain.js";
5
+ import { Driller } from "./ai/driller.js";
6
6
  import { ExperienceCompactor } from "./ai/experience-compactor.js";
7
7
  import { Fisherman } from "./ai/fisherman.js";
8
8
  import { Historian } from "./ai/historian.js";
@@ -13,6 +13,7 @@ import { AIProvider } from "./ai/provider.js";
13
13
  import { Quartermaster } from "./ai/quartermaster.js";
14
14
  import { Rerunner } from "./ai/rerunner.js";
15
15
  import { Researcher } from "./ai/researcher.js";
16
+ import { SessionAnalyst } from "./ai/session-analyst.js";
16
17
  import { Tester } from "./ai/tester.js";
17
18
  import { createAgentTools } from "./ai/tools.js";
18
19
  import { ApiClient } from "./api/api-client.js";
@@ -24,6 +25,7 @@ import Explorer from "./explorer.js";
24
25
  import { KnowledgeTracker } from "./knowledge-tracker.js";
25
26
  import { Plan } from "./test-plan.js";
26
27
  import { setVerboseMode, tag } from "./utils/logger.js";
28
+ import { relativeToCwd } from "./utils/next-steps.js";
27
29
  import { sanitizeFilename } from "./utils/strings.js";
28
30
  export class ExplorBot {
29
31
  configParser;
@@ -38,6 +40,8 @@ export class ExplorBot {
38
40
  lastPlanError = null;
39
41
  lastSavedPlanPath = null;
40
42
  agents = {};
43
+ sessionPlans = [];
44
+ lastReportedTestCount = 0;
41
45
  constructor(options = {}) {
42
46
  this.options = options;
43
47
  this.configParser = ConfigParser.getInstance();
@@ -239,14 +243,15 @@ export class ExplorBot {
239
243
  }
240
244
  return this.agents.rerunner;
241
245
  }
242
- agentBosun() {
243
- return (this.agents.bosun ||= this.createAgent(({ ai, explorer }) => {
244
- const researcher = this.agentResearcher();
246
+ agentDriller() {
247
+ return (this.agents.driller ||= this.createAgent(({ ai, explorer }) => {
245
248
  const navigator = this.agentNavigator();
246
- const tools = createAgentTools({ explorer, researcher, navigator });
247
- return new Bosun(explorer, ai, researcher, navigator, tools);
249
+ return new Driller(explorer, ai, navigator);
248
250
  }));
249
251
  }
252
+ agentSessionAnalyst() {
253
+ return (this.agents.sessionAnalyst ||= this.createAgent(({ ai }) => new SessionAnalyst(ai)));
254
+ }
250
255
  agentFisherman() {
251
256
  const fishermanConfig = this.config.ai?.agents?.fisherman;
252
257
  const hasApiConfig = !!this.config.api;
@@ -309,7 +314,7 @@ export class ExplorBot {
309
314
  }
310
315
  this.lastPlanError = null;
311
316
  try {
312
- this.currentPlan = await planner.plan(feature, opts.style, opts.extend, opts.completedPlans);
317
+ this.setCurrentPlan(await planner.plan(feature, opts.style, opts.extend, opts.completedPlans));
313
318
  }
314
319
  catch (err) {
315
320
  this.lastPlanError = err instanceof Error ? err : new Error(String(err));
@@ -376,10 +381,45 @@ export class ExplorBot {
376
381
  if (!existsSync(planPath)) {
377
382
  throw new Error(`Plan file not found: ${planPath}`);
378
383
  }
379
- this.currentPlan = Plan.fromMarkdown(planPath);
384
+ this.setCurrentPlan(Plan.fromMarkdown(planPath));
380
385
  return this.currentPlan;
381
386
  }
382
387
  setCurrentPlan(plan) {
383
388
  this.currentPlan = plan;
389
+ if (plan && !this.sessionPlans.includes(plan)) {
390
+ this.sessionPlans.push(plan);
391
+ }
392
+ }
393
+ getSessionTests() {
394
+ return this.sessionPlans.flatMap((p) => p.tests.filter((t) => t.startTime != null));
395
+ }
396
+ async printSessionAnalysis() {
397
+ const analystConfig = this.config.ai?.agents?.analyst;
398
+ if (analystConfig?.enabled === false)
399
+ return;
400
+ const tests = this.getSessionTests();
401
+ if (tests.length === 0)
402
+ return;
403
+ if (tests.length === this.lastReportedTestCount)
404
+ return;
405
+ try {
406
+ const markdown = await this.agentSessionAnalyst().analyze(tests);
407
+ if (!markdown) {
408
+ this.lastReportedTestCount = tests.length;
409
+ return;
410
+ }
411
+ tag('multiline').log(markdown);
412
+ const filePath = this.agentSessionAnalyst().writeReport(markdown);
413
+ tag('info').log(`Session report saved: ${relativeToCwd(filePath)}`);
414
+ const reporter = this.explorer?.getReporter();
415
+ if (reporter?.isEnabled()) {
416
+ await reporter.setRunDescription(markdown);
417
+ }
418
+ this.lastReportedTestCount = tests.length;
419
+ }
420
+ catch (error) {
421
+ const message = error instanceof Error ? error.message : String(error);
422
+ tag('warning').log(`Session analysis failed: ${message}`);
423
+ }
384
424
  }
385
425
  }
@@ -10,13 +10,14 @@ import Action from './action.js';
10
10
  import { visuallyAnnotateContainers } from "./ai/researcher/coordinates.js";
11
11
  import { RequestStore } from "./api/request-store.js";
12
12
  import { XhrCapture } from "./api/xhr-capture.js";
13
- import { ConfigParser, outputPath } from './config.js';
13
+ import { ConfigParser } from './config.js';
14
14
  import { KnowledgeTracker } from './knowledge-tracker.js';
15
15
  import { PlaywrightRecorder } from "./playwright-recorder.js";
16
16
  import { Reporter } from "./reporter.js";
17
17
  import { StateManager } from './state-manager.js';
18
+ import { ELEMENT_EXTRACTION_CONFIG, getElementDataExtractorSource } from "./utils/html.js";
18
19
  import { createDebug, log, tag } from './utils/logger.js';
19
- import { WebElement, extractElementData } from "./utils/web-element.js";
20
+ import { WebElement } from "./utils/web-element.js";
20
21
  const debugLog = createDebug('explorbot:explorer');
21
22
  const FATAL_BROWSER_ERRORS = /Frame was detached|Target closed|Execution context was destroyed|Protocol error|Session closed/i;
22
23
  const RECOVERABLE_NAVIGATION_ERRORS = /net::ERR_ABORTED|page\.screenshot.*Timeout|waiting for fonts to load/i;
@@ -270,11 +271,11 @@ class Explorer {
270
271
  async getEidxInContainer(containerCss) {
271
272
  const page = this.playwrightHelper.page;
272
273
  try {
273
- const selector = containerCss ? `${containerCss} [data-explorbot-eidx]` : '[data-explorbot-eidx]';
274
+ const selector = containerCss ? `${containerCss} [${ELEMENT_EXTRACTION_CONFIG.attrs.eidx}]` : `[${ELEMENT_EXTRACTION_CONFIG.attrs.eidx}]`;
274
275
  const elements = await page.locator(selector).all();
275
276
  const result = [];
276
277
  for (const el of elements) {
277
- const attr = await el.getAttribute('data-explorbot-eidx');
278
+ const attr = await el.getAttribute(ELEMENT_EXTRACTION_CONFIG.attrs.eidx);
278
279
  if (attr)
279
280
  result.push(attr);
280
281
  }
@@ -293,7 +294,7 @@ class Explorer {
293
294
  const page = this.playwrightHelper.page;
294
295
  const base = container ? page.locator(container) : page;
295
296
  const el = locator.startsWith('//') ? base.locator(`xpath=${locator}`) : base.locator(locator);
296
- return await el.first().getAttribute('data-explorbot-eidx');
297
+ return await el.first().getAttribute(ELEMENT_EXTRACTION_CONFIG.attrs.eidx);
297
298
  }
298
299
  catch (error) {
299
300
  if (this.isFatalBrowserError(error)) {
@@ -466,10 +467,7 @@ class Explorer {
466
467
  if (!this.stateManager.getCurrentState())
467
468
  return;
468
469
  const lastScreenshot = ActionResult.fromState(this.stateManager.getCurrentState()).screenshotFile;
469
- if (!lastScreenshot)
470
- return;
471
- const screenshotPath = outputPath('states', lastScreenshot);
472
- test.addArtifact(screenshotPath);
470
+ test.setActiveNoteScreenshot(lastScreenshot);
473
471
  };
474
472
  const dialogHandler = (dialog) => {
475
473
  const dialogType = dialog.type();
@@ -644,21 +642,21 @@ export async function annotatePageElements(page) {
644
642
  const elements = [];
645
643
  for (const [role, entries] of byRole) {
646
644
  try {
647
- const rawList = await page.getByRole(role).evaluateAll((domElements, [data, extractFnStr]) => {
645
+ const rawList = await page.getByRole(role).evaluateAll((domElements, [data, extractFnStr, config]) => {
648
646
  const extract = new Function(`return ${extractFnStr}`)();
649
647
  const results = [];
650
648
  let ariaIdx = 0;
651
649
  for (const el of domElements) {
652
650
  if (ariaIdx >= data.length)
653
651
  break;
654
- el.setAttribute('data-explorbot-eidx', data[ariaIdx].ref);
655
- const elData = extract(el);
652
+ el.setAttribute(config.attrs.eidx, data[ariaIdx].ref);
653
+ const elData = extract(el, config);
656
654
  if (elData)
657
655
  results.push(elData);
658
656
  ariaIdx++;
659
657
  }
660
658
  return results;
661
- }, [entries, extractElementData.toString()]);
659
+ }, [entries, getElementDataExtractorSource(), ELEMENT_EXTRACTION_CONFIG]);
662
660
  for (const raw of rawList) {
663
661
  elements.push(WebElement.fromRawData(raw, role));
664
662
  }
@@ -14,8 +14,19 @@ export class Reporter {
14
14
  if (this.reporterEnabled && (!process.env.TESTOMATIO || config?.html)) {
15
15
  this.configureHtmlPipe();
16
16
  }
17
- const pipe = process.env.TESTOMATIO && config?.html ? 'both' : process.env.TESTOMATIO ? 'testomatio' : 'html';
18
- debugLog('Reporter initialized', { enabled: this.reporterEnabled, pipe });
17
+ if (this.reporterEnabled && config?.markdown) {
18
+ this.configureMarkdownPipe();
19
+ }
20
+ if (this.reporterEnabled) {
21
+ this.configureRunGroup(config?.runGroup);
22
+ }
23
+ debugLog('Reporter initialized', {
24
+ enabled: this.reporterEnabled,
25
+ testomatio: Boolean(process.env.TESTOMATIO),
26
+ html: Boolean(process.env.TESTOMATIO_HTML_REPORT_SAVE),
27
+ markdown: Boolean(process.env.TESTOMATIO_MARKDOWN_REPORT_SAVE),
28
+ runGroup: process.env.TESTOMATIO_RUNGROUP_TITLE || null,
29
+ });
19
30
  }
20
31
  buildTitle() {
21
32
  if (process.env.TESTOMATIO_TITLE)
@@ -39,7 +50,31 @@ export class Reporter {
39
50
  configureHtmlPipe() {
40
51
  process.env.TESTOMATIO_HTML_REPORT_SAVE = '1';
41
52
  process.env.TESTOMATIO_HTML_REPORT_FOLDER = outputPath('reports');
42
- debugLog('HTML report pipe configured', { folder: process.env.TESTOMATIO_HTML_REPORT_FOLDER });
53
+ process.env.TESTOMATIO_HTML_FILENAME = `${Stats.sessionLabel()}.html`;
54
+ debugLog('HTML report pipe configured', {
55
+ folder: process.env.TESTOMATIO_HTML_REPORT_FOLDER,
56
+ filename: process.env.TESTOMATIO_HTML_FILENAME,
57
+ });
58
+ }
59
+ configureMarkdownPipe() {
60
+ process.env.TESTOMATIO_MARKDOWN_REPORT_SAVE = '1';
61
+ process.env.TESTOMATIO_MARKDOWN_REPORT_FOLDER = outputPath('reports');
62
+ process.env.TESTOMATIO_MARKDOWN_FILENAME = `${Stats.sessionLabel()}-tests.md`;
63
+ debugLog('Markdown report pipe configured', {
64
+ folder: process.env.TESTOMATIO_MARKDOWN_REPORT_FOLDER,
65
+ filename: process.env.TESTOMATIO_MARKDOWN_FILENAME,
66
+ });
67
+ }
68
+ configureRunGroup(runGroup) {
69
+ if (process.env.TESTOMATIO_RUNGROUP_TITLE)
70
+ return;
71
+ if (runGroup === null)
72
+ return;
73
+ if (runGroup) {
74
+ process.env.TESTOMATIO_RUNGROUP_TITLE = runGroup;
75
+ return;
76
+ }
77
+ process.env.TESTOMATIO_RUNGROUP_TITLE = `Explorbot ${new Date().toISOString().slice(0, 10)}`;
43
78
  }
44
79
  async startRun() {
45
80
  if (this.isRunStarted) {
@@ -52,7 +87,7 @@ export class Reporter {
52
87
  this.client = new Client({ apiKey: process.env.TESTOMATIO || '', title: this.buildTitle() });
53
88
  const timeoutMs = Number(process.env.TESTOMATIO_TIMEOUT_MS || '15000');
54
89
  const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve('timeout'), timeoutMs));
55
- const result = await Promise.race([this.client.createRun().then(() => 'success'), timeoutPromise]);
90
+ const result = await Promise.race([this.client.createRun({ configuration: { exploratory: true } }).then(() => 'success'), timeoutPromise]);
56
91
  if (result === 'timeout') {
57
92
  debugLog('Reporter run creation timed out');
58
93
  return;
@@ -82,6 +117,7 @@ export class Reporter {
82
117
  message: note.message,
83
118
  status: note.status,
84
119
  screenshot: note.screenshot,
120
+ log: note.log,
85
121
  }))
86
122
  .sort((a, b) => a.startTime - b.startTime);
87
123
  const stepEntries = Object.entries(test.steps)
@@ -113,8 +149,16 @@ export class Reporter {
113
149
  if (noteEntry.screenshot) {
114
150
  step.artifacts = [outputPath('states', noteEntry.screenshot)];
115
151
  }
152
+ if (noteEntry.log) {
153
+ step.log = noteEntry.log;
154
+ }
116
155
  steps.push(step);
117
156
  }
157
+ const verificationStep = this.buildVerificationStep(test, lastScreenshotFile);
158
+ if (verificationStep) {
159
+ steps.push(verificationStep);
160
+ return steps;
161
+ }
118
162
  if (lastScreenshotFile && steps.length > 0) {
119
163
  const lastStep = steps[steps.length - 1];
120
164
  const screenshotPath = outputPath('states', lastScreenshotFile);
@@ -127,6 +171,37 @@ export class Reporter {
127
171
  }
128
172
  return steps;
129
173
  }
174
+ buildVerificationStep(test, lastScreenshotFile) {
175
+ const v = test.verification;
176
+ if (!v)
177
+ return undefined;
178
+ const subSteps = [];
179
+ if (v.message)
180
+ subSteps.push({ category: 'framework', title: v.message, duration: 0 });
181
+ if (v.url) {
182
+ subSteps.push({
183
+ category: 'framework',
184
+ title: v.pageLabel ? `Navigated to ${v.pageLabel}` : 'Final page',
185
+ log: v.url,
186
+ duration: 0,
187
+ });
188
+ }
189
+ for (const detail of v.details) {
190
+ subSteps.push({ category: 'framework', title: detail, duration: 0 });
191
+ }
192
+ const screenshotFile = v.screenshot || lastScreenshotFile;
193
+ const step = {
194
+ category: 'user',
195
+ title: 'Verification',
196
+ duration: 0,
197
+ status: v.status || 'none',
198
+ steps: subSteps.length > 0 ? subSteps : undefined,
199
+ };
200
+ if (screenshotFile) {
201
+ step.artifacts = [outputPath('states', screenshotFile)];
202
+ }
203
+ return step;
204
+ }
130
205
  async reportTest(test, meta) {
131
206
  await this.startRun();
132
207
  if (!this.isRunStarted) {
@@ -190,6 +265,32 @@ export class Reporter {
190
265
  isEnabled() {
191
266
  return this.isRunStarted;
192
267
  }
268
+ async setRunDescription(text) {
269
+ if (!this.isRunStarted)
270
+ return;
271
+ if (!process.env.TESTOMATIO)
272
+ return;
273
+ const runId = this.client.runId;
274
+ if (!runId)
275
+ return;
276
+ const baseUrl = process.env.TESTOMATIO_URL || 'https://app.testomat.io';
277
+ const url = `${baseUrl}/api/reporter/${runId}`;
278
+ try {
279
+ const response = await fetch(url, {
280
+ method: 'PUT',
281
+ headers: { 'Content-Type': 'application/json' },
282
+ body: JSON.stringify({ api_key: process.env.TESTOMATIO, description: text }),
283
+ });
284
+ if (!response.ok) {
285
+ debugLog('Run description update failed:', response.status, response.statusText);
286
+ return;
287
+ }
288
+ debugLog('Run description updated');
289
+ }
290
+ catch (error) {
291
+ debugLog('Failed to update run description:', error);
292
+ }
293
+ }
193
294
  extractLastNoteMessage(test) {
194
295
  const notes = Object.values(test.notes);
195
296
  if (notes.length === 0)
@@ -70,8 +70,8 @@ export class StateManager {
70
70
  }
71
71
  /**
72
72
  * Extract state path from full URL
73
- * Removes domain, port, protocol, and query params
74
- * Keeps path and hash: /path/to/page#section
73
+ * Removes domain, port, protocol
74
+ * Keeps path, query, and hash: /path/to/page?tab=users#section
75
75
  */
76
76
  /**
77
77
  * Update current state from ActionResult and record transition if state changed
@@ -418,7 +418,8 @@ export class StateManager {
418
418
  export function normalizeUrl(url) {
419
419
  try {
420
420
  const parsed = new URL(url, 'http://localhost');
421
- return parsed.pathname.replace(/^\/+|\/+$/g, '');
421
+ const path = parsed.pathname.replace(/^\/+|\/+$/g, '');
422
+ return `${path}${parsed.search}${parsed.hash}`;
422
423
  }
423
424
  catch {
424
425
  return url.replace(/^\/+|\/+$/g, '');
package/dist/src/stats.js CHANGED
@@ -1,5 +1,7 @@
1
+ import { uniqExplorationName } from "./utils/unique-names.js";
1
2
  export class Stats {
2
3
  static startTime = Date.now();
4
+ static sessionName = uniqExplorationName();
3
5
  static researches = 0;
4
6
  static tests = 0;
5
7
  static plans = 0;
@@ -8,11 +10,12 @@ export class Stats {
8
10
  static models = {};
9
11
  static recordTokens(_agent, model, usage) {
10
12
  if (!Stats.models[model]) {
11
- Stats.models[model] = { input: 0, output: 0, total: 0 };
13
+ Stats.models[model] = { input: 0, output: 0, total: 0, cached: 0 };
12
14
  }
13
15
  Stats.models[model].input += usage.input;
14
16
  Stats.models[model].output += usage.output;
15
17
  Stats.models[model].total += usage.total;
18
+ Stats.models[model].cached = (Stats.models[model].cached ?? 0) + (usage.cached ?? 0);
16
19
  }
17
20
  static getElapsedTime() {
18
21
  const elapsed = Date.now() - Stats.startTime;
@@ -42,4 +45,7 @@ export class Stats {
42
45
  const totalTokens = Object.values(Stats.models).reduce((sum, m) => sum + m.total, 0);
43
46
  return totalTokens > 0;
44
47
  }
48
+ static sessionLabel() {
49
+ return `${Stats.mode || 'session'}-${Stats.sessionName}`;
50
+ }
45
51
  }
@@ -17,6 +17,7 @@ export class ActiveNote {
17
17
  message;
18
18
  status;
19
19
  screenshot;
20
+ log;
20
21
  constructor(task, message, status) {
21
22
  this.task = task;
22
23
  this.startTime = performance.now();
@@ -41,6 +42,7 @@ export class Task {
41
42
  steps;
42
43
  states;
43
44
  startUrl;
45
+ verification;
44
46
  timestampCounter = 0;
45
47
  activeNote;
46
48
  constructor(description, startUrl = '') {
@@ -67,6 +69,7 @@ export class Task {
67
69
  startTime: activeNote.getStartTime(),
68
70
  endTime,
69
71
  screenshot: activeNote.screenshot,
72
+ log: activeNote.log,
70
73
  };
71
74
  this.activeNote = undefined;
72
75
  }
@@ -80,13 +83,30 @@ export class Task {
80
83
  .map((n) => `- ${n}`)
81
84
  .join('\n');
82
85
  }
83
- addNote(message, status = null, screenshot) {
84
- const isDuplicate = Object.values(this.notes).some((note) => note.message === message && note.status === status);
86
+ addNote(message, status = null, screenshot, log) {
87
+ const isDuplicate = Object.values(this.notes).some((note) => note.message === message && note.status === status && note.log === log);
85
88
  if (isDuplicate)
86
89
  return;
87
90
  const now = performance.now();
88
91
  const timestamp = `${now}_${this.timestampCounter++}`;
89
- this.notes[timestamp] = { message, status, startTime: now, endTime: now, screenshot };
92
+ this.notes[timestamp] = { message, status, startTime: now, endTime: now, screenshot, log };
93
+ }
94
+ addUrlNote(state, prevState) {
95
+ const fullUrl = state.fullUrl || state.url;
96
+ if (!fullUrl)
97
+ return;
98
+ let label;
99
+ if (state.title && state.title !== prevState?.title)
100
+ label = state.title;
101
+ else if (state.h1 && state.h1 !== prevState?.h1)
102
+ label = state.h1;
103
+ else if (state.h2 && state.h2 !== prevState?.h2)
104
+ label = state.h2;
105
+ else
106
+ label = state.title || state.h1 || state.h2;
107
+ if (!label)
108
+ return;
109
+ this.addNote(`Navigated to ${label}`, TestResult.PASSED, state.screenshotFile, fullUrl);
90
110
  }
91
111
  addState(state) {
92
112
  this.states.push(state);
@@ -95,6 +115,30 @@ export class Task {
95
115
  const timestamp = `${performance.now()}_${this.timestampCounter++}`;
96
116
  this.steps[timestamp] = { text, duration, status, error, log, artifacts, noteStartTime: this.activeNote?.getStartTime() };
97
117
  }
118
+ setActiveNoteScreenshot(screenshotFile) {
119
+ if (!this.activeNote || !screenshotFile)
120
+ return;
121
+ this.activeNote.screenshot = screenshotFile;
122
+ }
123
+ setVerification(message, status, state) {
124
+ this.verification ||= { message: '', status: null, details: [] };
125
+ this.verification.message = message;
126
+ this.verification.status = status;
127
+ if (!state)
128
+ return;
129
+ if (state.screenshotFile)
130
+ this.verification.screenshot = state.screenshotFile;
131
+ const fullUrl = state.fullUrl || state.url;
132
+ if (fullUrl)
133
+ this.verification.url = fullUrl;
134
+ this.verification.pageLabel = state.title || state.h1 || state.h2 || undefined;
135
+ }
136
+ addVerificationDetail(detail) {
137
+ if (!detail)
138
+ return;
139
+ this.verification ||= { message: '', status: null, details: [] };
140
+ this.verification.details.push(detail);
141
+ }
98
142
  getLog() {
99
143
  const merged = {};
100
144
  for (const [key, stepData] of Object.entries(this.steps)) {