explorbot 0.1.13 → 0.1.16

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 (42) hide show
  1. package/dist/package.json +3 -2
  2. package/dist/src/action.js +3 -2
  3. package/dist/src/ai/conversation.js +20 -4
  4. package/dist/src/ai/historian/utils.js +8 -1
  5. package/dist/src/ai/pilot.js +198 -260
  6. package/dist/src/ai/provider.js +25 -12
  7. package/dist/src/ai/quartermaster.js +2 -2
  8. package/dist/src/ai/researcher/focus.js +51 -10
  9. package/dist/src/ai/researcher/sections.js +8 -4
  10. package/dist/src/ai/researcher.js +9 -24
  11. package/dist/src/ai/rules.js +2 -0
  12. package/dist/src/ai/session-analyst.js +46 -41
  13. package/dist/src/ai/tester.js +63 -22
  14. package/dist/src/ai/tools.js +19 -4
  15. package/dist/src/commands/explore-command.js +8 -2
  16. package/dist/src/components/StatusPane.js +6 -1
  17. package/dist/src/experience-tracker.js +9 -0
  18. package/dist/src/explorer.js +2 -5
  19. package/dist/src/reporter.js +41 -1
  20. package/dist/src/stats.js +2 -1
  21. package/dist/src/test-plan.js +47 -3
  22. package/package.json +3 -2
  23. package/src/action.ts +3 -2
  24. package/src/ai/conversation.ts +21 -4
  25. package/src/ai/historian/utils.ts +8 -1
  26. package/src/ai/pilot.ts +199 -259
  27. package/src/ai/provider.ts +24 -12
  28. package/src/ai/quartermaster.ts +2 -2
  29. package/src/ai/researcher/focus.ts +57 -8
  30. package/src/ai/researcher/sections.ts +7 -3
  31. package/src/ai/researcher.ts +8 -23
  32. package/src/ai/rules.ts +2 -0
  33. package/src/ai/session-analyst.ts +47 -41
  34. package/src/ai/tester.ts +55 -20
  35. package/src/ai/tools.ts +18 -4
  36. package/src/commands/explore-command.ts +9 -2
  37. package/src/components/StatusPane.tsx +6 -3
  38. package/src/experience-tracker.ts +9 -0
  39. package/src/explorer.ts +1 -4
  40. package/src/reporter.ts +44 -1
  41. package/src/stats.ts +3 -1
  42. package/src/test-plan.ts +62 -3
@@ -1,6 +1,7 @@
1
1
  import figureSet from 'figures';
2
2
  import { getStyles } from '../ai/planner/styles.js';
3
3
  import { outputPath } from '../config.js';
4
+ import { normalizeUrl } from '../state-manager.js';
4
5
  import { Stats } from '../stats.js';
5
6
  import type { Plan } from '../test-plan.js';
6
7
  import { getCliName } from '../utils/cli-name.ts';
@@ -11,6 +12,8 @@ import { type NextStepSection, printNextSteps, relativeToCwd } from '../utils/ne
11
12
  import { safeFilename } from '../utils/strings.ts';
12
13
  import { BaseCommand, type Suggestion } from './base-command.js';
13
14
 
15
+ const MAX_SUB_PAGE_ATTEMPTS = 30;
16
+
14
17
  export class ExploreCommand extends BaseCommand {
15
18
  name = 'explore';
16
19
  description = 'Start web exploration';
@@ -27,6 +30,7 @@ export class ExploreCommand extends BaseCommand {
27
30
  maxTests?: number;
28
31
  private testsRun = 0;
29
32
  private completedPlans: Plan[] = [];
33
+ private failedSubPages = new Set<string>();
30
34
 
31
35
  async execute(args: string): Promise<void> {
32
36
  const { opts, args: remaining } = this.parseArgs(args);
@@ -46,10 +50,12 @@ export class ExploreCommand extends BaseCommand {
46
50
 
47
51
  if (!feature && !this.isLimitReached()) {
48
52
  const planner = this.explorBot.agentPlanner();
49
- while (true) {
53
+ let attempts = 0;
54
+ while (attempts < MAX_SUB_PAGE_ATTEMPTS) {
55
+ attempts++;
50
56
  if (this.isLimitReached()) break;
51
57
 
52
- const candidates = planner.collectSubPageCandidates(mainPlan, mainUrl || '/');
58
+ const candidates = planner.collectSubPageCandidates(mainPlan, mainUrl || '/').filter((c) => !this.failedSubPages.has(normalizeUrl(c.url)));
53
59
  if (candidates.length === 0) break;
54
60
 
55
61
  const pick = await planner.pickNextSubPage(candidates);
@@ -64,6 +70,7 @@ export class ExploreCommand extends BaseCommand {
64
70
  this.completedPlans.push(subPlan);
65
71
  }
66
72
  } catch (err) {
73
+ this.failedSubPages.add(normalizeUrl(pick.url));
67
74
  tag('warning').log(`Sub-page exploration failed: ${err instanceof Error ? err.message : err}`);
68
75
  }
69
76
  }
@@ -52,9 +52,12 @@ export const StatusPane: React.FC<{ onComplete?: () => void }> = ({ onComplete }
52
52
  <Text bold>Usage</Text>
53
53
  </Box>
54
54
  <Row label="Time" value={Stats.getElapsedTime()} />
55
- {tokenRows.map(([model, tokens]) => (
56
- <Row key={model} label={model} value={`${Stats.humanizeTokens(tokens.total)} tokens`} />
57
- ))}
55
+ {tokenRows.map(([model, tokens]) => {
56
+ const cached = tokens.cached ?? 0;
57
+ const cachePct = tokens.input > 0 ? Math.round((cached / tokens.input) * 100) : 0;
58
+ const suffix = cached > 0 ? ` (${Stats.humanizeTokens(cached)} cached, ${cachePct}%)` : '';
59
+ return <Row key={model} label={model} value={`${Stats.humanizeTokens(tokens.total)} tokens${suffix}`} />;
60
+ })}
58
61
  </>
59
62
  )}
60
63
  </Box>
@@ -3,6 +3,7 @@ import { basename, dirname, join } from 'node:path';
3
3
  import matter from 'gray-matter';
4
4
  import { type Tokens, marked } from 'marked';
5
5
  import type { ActionResult } from './action-result.js';
6
+ import { isNonReusableCode } from './ai/historian/utils.ts';
6
7
  import { ConfigParser } from './config.js';
7
8
  import { KnowledgeTracker } from './knowledge-tracker.js';
8
9
  import type { WebPageState } from './state-manager.js';
@@ -166,6 +167,10 @@ export class ExperienceTracker {
166
167
  writeAction(state: ActionResult, action: ActionInput): void {
167
168
  if (this.disabled || this.isWritingDisabled(state)) return;
168
169
  if (!action.code?.trim()) return;
170
+ if (isNonReusableCode(action.code)) {
171
+ debugLog('Skipping action with non-reusable code: %s', action.code);
172
+ return;
173
+ }
169
174
 
170
175
  this.ensureExperienceFile(state);
171
176
  const stateHash = state.getStateHash();
@@ -189,6 +194,10 @@ export class ExperienceTracker {
189
194
  writeFlow(state: ActionResult, body: string, relatedUrls?: string[]): void {
190
195
  if (this.disabled || this.isWritingDisabled(state)) return;
191
196
  if (!body?.trim()) return;
197
+ if (isNonReusableCode(body)) {
198
+ debugLog('Skipping flow body with non-reusable code');
199
+ return;
200
+ }
192
201
 
193
202
  this.ensureExperienceFile(state);
194
203
  const stateHash = state.getStateHash();
package/src/explorer.ts CHANGED
@@ -549,10 +549,7 @@ class Explorer {
549
549
  if (!this.stateManager.getCurrentState()) return;
550
550
 
551
551
  const lastScreenshot = ActionResult.fromState(this.stateManager.getCurrentState()!).screenshotFile;
552
- if (!lastScreenshot) return;
553
-
554
- const screenshotPath = outputPath('states', lastScreenshot);
555
- test.addArtifact(screenshotPath);
552
+ test.setActiveNoteScreenshot(lastScreenshot);
556
553
  };
557
554
 
558
555
  const dialogHandler = (dialog: any) => {
package/src/reporter.ts CHANGED
@@ -110,7 +110,7 @@ export class Reporter {
110
110
  const timeoutMs = Number(process.env.TESTOMATIO_TIMEOUT_MS || '15000');
111
111
  const timeoutPromise = new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), timeoutMs));
112
112
 
113
- const result = await Promise.race([this.client.createRun().then(() => 'success' as const), timeoutPromise]);
113
+ const result = await Promise.race([this.client.createRun({ configuration: { exploratory: true } }).then(() => 'success' as const), timeoutPromise]);
114
114
 
115
115
  if (result === 'timeout') {
116
116
  debugLog('Reporter run creation timed out');
@@ -145,6 +145,7 @@ export class Reporter {
145
145
  message: note.message,
146
146
  status: note.status,
147
147
  screenshot: note.screenshot,
148
+ log: note.log,
148
149
  }))
149
150
  .sort((a, b) => a.startTime - b.startTime);
150
151
 
@@ -180,9 +181,18 @@ export class Reporter {
180
181
  if (noteEntry.screenshot) {
181
182
  step.artifacts = [outputPath('states', noteEntry.screenshot)];
182
183
  }
184
+ if (noteEntry.log) {
185
+ step.log = noteEntry.log;
186
+ }
183
187
  steps.push(step);
184
188
  }
185
189
 
190
+ const verificationStep = this.buildVerificationStep(test, lastScreenshotFile);
191
+ if (verificationStep) {
192
+ steps.push(verificationStep);
193
+ return steps;
194
+ }
195
+
186
196
  if (lastScreenshotFile && steps.length > 0) {
187
197
  const lastStep = steps[steps.length - 1];
188
198
  const screenshotPath = outputPath('states', lastScreenshotFile);
@@ -196,6 +206,39 @@ export class Reporter {
196
206
  return steps;
197
207
  }
198
208
 
209
+ private buildVerificationStep(test: Test, lastScreenshotFile?: string): Step | undefined {
210
+ const v = test.verification;
211
+ if (!v) return undefined;
212
+
213
+ const subSteps: Step[] = [];
214
+ if (v.message) subSteps.push({ category: 'framework', title: v.message, duration: 0 });
215
+ if (v.url) {
216
+ subSteps.push({
217
+ category: 'framework',
218
+ title: v.pageLabel ? `Navigated to ${v.pageLabel}` : 'Final page',
219
+ log: v.url,
220
+ duration: 0,
221
+ });
222
+ }
223
+ for (const detail of v.details) {
224
+ subSteps.push({ category: 'framework', title: detail, duration: 0 });
225
+ }
226
+
227
+ const screenshotFile = v.screenshot || lastScreenshotFile;
228
+
229
+ const step: Step = {
230
+ category: 'user',
231
+ title: 'Verification',
232
+ duration: 0,
233
+ status: v.status || 'none',
234
+ steps: subSteps.length > 0 ? subSteps : undefined,
235
+ };
236
+ if (screenshotFile) {
237
+ step.artifacts = [outputPath('states', screenshotFile)];
238
+ }
239
+ return step;
240
+ }
241
+
199
242
  async reportTest(test: Test, meta?: ReporterMeta): Promise<void> {
200
243
  await this.startRun();
201
244
 
package/src/stats.ts CHANGED
@@ -4,6 +4,7 @@ interface TokenUsage {
4
4
  input: number;
5
5
  output: number;
6
6
  total: number;
7
+ cached?: number;
7
8
  }
8
9
 
9
10
  export type ExplorbotMode = 'explore' | 'test' | 'freesail' | 'tui';
@@ -20,11 +21,12 @@ export class Stats {
20
21
 
21
22
  static recordTokens(_agent: string, model: string, usage: TokenUsage): void {
22
23
  if (!Stats.models[model]) {
23
- Stats.models[model] = { input: 0, output: 0, total: 0 };
24
+ Stats.models[model] = { input: 0, output: 0, total: 0, cached: 0 };
24
25
  }
25
26
  Stats.models[model].input += usage.input;
26
27
  Stats.models[model].output += usage.output;
27
28
  Stats.models[model].total += usage.total;
29
+ Stats.models[model].cached = (Stats.models[model].cached ?? 0) + (usage.cached ?? 0);
28
30
  }
29
31
 
30
32
  static getElapsedTime(): string {
package/src/test-plan.ts CHANGED
@@ -26,6 +26,7 @@ export interface Note {
26
26
  startTime: number;
27
27
  endTime: number;
28
28
  screenshot?: string;
29
+ log?: string;
29
30
  }
30
31
 
31
32
  export class ActiveNote {
@@ -34,6 +35,7 @@ export class ActiveNote {
34
35
  message: string;
35
36
  status?: TestResultType;
36
37
  screenshot?: string;
38
+ log?: string;
37
39
 
38
40
  constructor(task: Task, message: string, status?: TestResultType) {
39
41
  this.task = task;
@@ -73,6 +75,7 @@ export class Task {
73
75
  steps: Record<string, StepData>;
74
76
  states: WebPageState[];
75
77
  startUrl: string;
78
+ verification?: Verification;
76
79
  protected timestampCounter = 0;
77
80
  private activeNote?: ActiveNote;
78
81
 
@@ -102,6 +105,7 @@ export class Task {
102
105
  startTime: activeNote.getStartTime(),
103
106
  endTime,
104
107
  screenshot: activeNote.screenshot,
108
+ log: activeNote.log,
105
109
  };
106
110
  this.activeNote = undefined;
107
111
  }
@@ -118,13 +122,28 @@ export class Task {
118
122
  .join('\n');
119
123
  }
120
124
 
121
- addNote(message: string, status: TestResultType = null, screenshot?: string): void {
122
- const isDuplicate = Object.values(this.notes).some((note) => note.message === message && note.status === status);
125
+ addNote(message: string, status: TestResultType = null, screenshot?: string, log?: string): void {
126
+ const isDuplicate = Object.values(this.notes).some((note) => note.message === message && note.status === status && note.log === log);
123
127
  if (isDuplicate) return;
124
128
 
125
129
  const now = performance.now();
126
130
  const timestamp = `${now}_${this.timestampCounter++}`;
127
- this.notes[timestamp] = { message, status, startTime: now, endTime: now, screenshot };
131
+ this.notes[timestamp] = { message, status, startTime: now, endTime: now, screenshot, log };
132
+ }
133
+
134
+ addUrlNote(state: UrlNoteState, prevState?: { title?: string; h1?: string; h2?: string }): void {
135
+ const fullUrl = state.fullUrl || state.url;
136
+ if (!fullUrl) return;
137
+
138
+ let label: string | undefined;
139
+ if (state.title && state.title !== prevState?.title) label = state.title;
140
+ else if (state.h1 && state.h1 !== prevState?.h1) label = state.h1;
141
+ else if (state.h2 && state.h2 !== prevState?.h2) label = state.h2;
142
+ else label = state.title || state.h1 || state.h2;
143
+
144
+ if (!label) return;
145
+
146
+ this.addNote(`Navigated to ${label}`, TestResult.PASSED, state.screenshotFile, fullUrl);
128
147
  }
129
148
 
130
149
  addState(state: WebPageState): void {
@@ -136,6 +155,28 @@ export class Task {
136
155
  this.steps[timestamp] = { text, duration, status, error, log, artifacts, noteStartTime: this.activeNote?.getStartTime() };
137
156
  }
138
157
 
158
+ setActiveNoteScreenshot(screenshotFile?: string): void {
159
+ if (!this.activeNote || !screenshotFile) return;
160
+ this.activeNote.screenshot = screenshotFile;
161
+ }
162
+
163
+ setVerification(message: string, status: TestResultType, state?: UrlNoteState): void {
164
+ this.verification ||= { message: '', status: null, details: [] };
165
+ this.verification.message = message;
166
+ this.verification.status = status;
167
+ if (!state) return;
168
+ if (state.screenshotFile) this.verification.screenshot = state.screenshotFile;
169
+ const fullUrl = state.fullUrl || state.url;
170
+ if (fullUrl) this.verification.url = fullUrl;
171
+ this.verification.pageLabel = state.title || state.h1 || state.h2 || undefined;
172
+ }
173
+
174
+ addVerificationDetail(detail: string): void {
175
+ if (!detail) return;
176
+ this.verification ||= { message: '', status: null, details: [] };
177
+ this.verification.details.push(detail);
178
+ }
179
+
139
180
  getLog(): Array<{ type: 'step' | 'note' | 'artifact'; content: string; timestamp: number }> {
140
181
  const merged: Record<string, { type: 'step' | 'note' | 'artifact'; content: string }> = {};
141
182
 
@@ -442,3 +483,21 @@ export class Plan {
442
483
  return planToAiContext(this, options);
443
484
  }
444
485
  }
486
+
487
+ interface Verification {
488
+ message: string;
489
+ status: TestResultType;
490
+ screenshot?: string;
491
+ url?: string;
492
+ pageLabel?: string;
493
+ details: string[];
494
+ }
495
+
496
+ interface UrlNoteState {
497
+ url?: string;
498
+ fullUrl?: string;
499
+ title?: string;
500
+ h1?: string;
501
+ h2?: string;
502
+ screenshotFile?: string;
503
+ }