explorbot 0.1.32-beta.1 → 0.2.0

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 (168) hide show
  1. package/README.md +14 -14
  2. package/bin/explorbot-cli.ts +4 -3
  3. package/boat/doc-collector/src/ai/documentarian.ts +22 -27
  4. package/boat/doc-collector/src/ai/tools.ts +67 -10
  5. package/boat/doc-collector/src/docbot.ts +28 -7
  6. package/boat/doc-collector/src/docs-renderer.ts +19 -24
  7. package/boat/doc-collector/src/screenshots.ts +33 -1
  8. package/boat/doc-collector/src/state-diagram.ts +281 -0
  9. package/dist/bin/explorbot-cli.js +3 -3
  10. package/dist/boat/doc-collector/src/ai/documentarian.js +16 -6
  11. package/dist/boat/doc-collector/src/ai/tools.js +47 -10
  12. package/dist/boat/doc-collector/src/docbot.js +25 -6
  13. package/dist/boat/doc-collector/src/docs-renderer.js +18 -3
  14. package/dist/boat/doc-collector/src/screenshots.js +29 -0
  15. package/dist/boat/doc-collector/src/state-diagram.js +200 -0
  16. package/dist/package.json +20 -3
  17. package/dist/src/action-result.d.ts +8 -10
  18. package/dist/src/action-result.js +72 -164
  19. package/dist/src/action.d.ts +3 -10
  20. package/dist/src/action.js +18 -111
  21. package/dist/src/ai/captain/web-mode.js +0 -7
  22. package/dist/src/ai/captain.d.ts +1 -3
  23. package/dist/src/ai/captain.js +4 -19
  24. package/dist/src/ai/conversation.d.ts +2 -5
  25. package/dist/src/ai/conversation.js +9 -28
  26. package/dist/src/ai/driller.d.ts +0 -4
  27. package/dist/src/ai/driller.js +3 -12
  28. package/dist/src/ai/experience-compactor.d.ts +2 -2
  29. package/dist/src/ai/experience-compactor.js +16 -33
  30. package/dist/src/ai/fisherman-tools.js +1 -1
  31. package/dist/src/ai/fisherman.d.ts +0 -1
  32. package/dist/src/ai/fisherman.js +0 -3
  33. package/dist/src/ai/historian/codeceptjs.d.ts +2 -0
  34. package/dist/src/ai/historian/codeceptjs.js +3 -2
  35. package/dist/src/ai/historian/playwright.d.ts +2 -0
  36. package/dist/src/ai/historian/playwright.js +3 -2
  37. package/dist/src/ai/historian/utils.d.ts +0 -1
  38. package/dist/src/ai/historian/utils.js +0 -1
  39. package/dist/src/ai/historian.d.ts +5 -4
  40. package/dist/src/ai/historian.js +1 -5
  41. package/dist/src/ai/navigator.d.ts +3 -5
  42. package/dist/src/ai/navigator.js +11 -46
  43. package/dist/src/ai/pilot.d.ts +1 -3
  44. package/dist/src/ai/pilot.js +6 -17
  45. package/dist/src/ai/planner.d.ts +3 -3
  46. package/dist/src/ai/planner.js +49 -37
  47. package/dist/src/ai/provider.d.ts +4 -1
  48. package/dist/src/ai/provider.js +77 -136
  49. package/dist/src/ai/quartermaster.d.ts +21 -22
  50. package/dist/src/ai/quartermaster.js +4 -10
  51. package/dist/src/ai/rerunner.d.ts +1 -2
  52. package/dist/src/ai/rerunner.js +14 -22
  53. package/dist/src/ai/researcher/cache.js +11 -18
  54. package/dist/src/ai/researcher/deep-analysis.js +3 -6
  55. package/dist/src/ai/researcher/parser.js +1 -1
  56. package/dist/src/ai/researcher/research-result.d.ts +0 -1
  57. package/dist/src/ai/researcher/research-result.js +0 -19
  58. package/dist/src/ai/researcher/sections.js +5 -1
  59. package/dist/src/ai/researcher.js +12 -53
  60. package/dist/src/ai/rules.d.ts +1 -0
  61. package/dist/src/ai/rules.js +22 -15
  62. package/dist/src/ai/session-analyst.js +8 -5
  63. package/dist/src/ai/task-agent.d.ts +1 -1
  64. package/dist/src/ai/task-agent.js +2 -27
  65. package/dist/src/ai/tester.d.ts +0 -2
  66. package/dist/src/ai/tester.js +10 -61
  67. package/dist/src/ai/tools.d.ts +18 -7
  68. package/dist/src/ai/tools.js +119 -101
  69. package/dist/src/commands/compact-command.d.ts +1 -1
  70. package/dist/src/commands/compact-command.js +6 -6
  71. package/dist/src/commands/context-command.js +1 -1
  72. package/dist/src/commands/context-knowledge-command.js +1 -1
  73. package/dist/src/commands/experience-command.js +1 -1
  74. package/dist/src/commands/knows-command.js +1 -1
  75. package/dist/src/commands/learn-command.js +3 -1
  76. package/dist/src/components/AddKnowledge.d.ts +2 -0
  77. package/dist/src/components/AddKnowledge.js +3 -7
  78. package/dist/src/components/App.js +2 -2
  79. package/dist/src/config.d.ts +2 -0
  80. package/dist/src/config.js +12 -0
  81. package/dist/src/experience-tracker.d.ts +11 -6
  82. package/dist/src/experience-tracker.js +81 -154
  83. package/dist/src/explorbot.d.ts +4 -5
  84. package/dist/src/explorbot.js +17 -51
  85. package/dist/src/explorer.d.ts +4 -15
  86. package/dist/src/explorer.js +18 -68
  87. package/dist/src/knowledge-tracker.d.ts +2 -2
  88. package/dist/src/knowledge-tracker.js +38 -45
  89. package/dist/src/state-manager.d.ts +6 -31
  90. package/dist/src/state-manager.js +10 -129
  91. package/dist/src/utils/aria.d.ts +6 -1
  92. package/dist/src/utils/aria.js +4 -1
  93. package/dist/src/utils/cache.d.ts +15 -0
  94. package/dist/src/utils/cache.js +34 -0
  95. package/dist/src/utils/context-formatter.d.ts +6 -0
  96. package/dist/src/utils/context-formatter.js +15 -27
  97. package/dist/src/utils/html.d.ts +5 -0
  98. package/dist/src/utils/html.js +71 -0
  99. package/dist/src/utils/markdown-files.d.ts +10 -0
  100. package/dist/src/utils/markdown-files.js +21 -0
  101. package/dist/src/utils/markdown-query.d.ts +6 -0
  102. package/dist/src/utils/markdown-query.js +14 -1
  103. package/dist/src/utils/page-readiness.d.ts +1 -0
  104. package/dist/src/utils/page-readiness.js +1 -1
  105. package/dist/src/utils/secrets.js +2 -2
  106. package/dist/src/utils/strings.d.ts +2 -0
  107. package/dist/src/utils/strings.js +10 -0
  108. package/dist/src/utils/web-element.d.ts +2 -1
  109. package/dist/src/utils/web-element.js +3 -0
  110. package/dist/src/utils/web-sandbox.d.ts +4 -0
  111. package/dist/src/utils/web-sandbox.js +37 -0
  112. package/package.json +20 -3
  113. package/src/action-result.ts +70 -173
  114. package/src/action.ts +16 -130
  115. package/src/ai/captain/web-mode.ts +0 -7
  116. package/src/ai/captain.ts +5 -17
  117. package/src/ai/conversation.ts +9 -32
  118. package/src/ai/driller.ts +3 -16
  119. package/src/ai/experience-compactor.ts +16 -35
  120. package/src/ai/fisherman-tools.ts +1 -1
  121. package/src/ai/fisherman.ts +0 -4
  122. package/src/ai/historian/codeceptjs.ts +4 -2
  123. package/src/ai/historian/playwright.ts +4 -2
  124. package/src/ai/historian/utils.ts +0 -1
  125. package/src/ai/historian.ts +4 -7
  126. package/src/ai/navigator.ts +13 -49
  127. package/src/ai/pilot.ts +6 -16
  128. package/src/ai/planner.ts +56 -44
  129. package/src/ai/provider.ts +85 -145
  130. package/src/ai/quartermaster.ts +34 -41
  131. package/src/ai/rerunner.ts +17 -21
  132. package/src/ai/researcher/cache.ts +10 -16
  133. package/src/ai/researcher/deep-analysis.ts +3 -6
  134. package/src/ai/researcher/parser.ts +1 -1
  135. package/src/ai/researcher/research-result.ts +0 -18
  136. package/src/ai/researcher/sections.ts +5 -1
  137. package/src/ai/researcher.ts +11 -59
  138. package/src/ai/rules.ts +24 -13
  139. package/src/ai/session-analyst.ts +8 -5
  140. package/src/ai/task-agent.ts +3 -30
  141. package/src/ai/tester.ts +12 -73
  142. package/src/ai/tools.ts +119 -106
  143. package/src/commands/compact-command.ts +7 -8
  144. package/src/commands/context-command.ts +1 -1
  145. package/src/commands/context-knowledge-command.ts +1 -1
  146. package/src/commands/experience-command.ts +1 -1
  147. package/src/commands/knows-command.ts +1 -1
  148. package/src/commands/learn-command.ts +3 -1
  149. package/src/components/AddKnowledge.tsx +5 -7
  150. package/src/components/App.tsx +2 -2
  151. package/src/config.ts +12 -0
  152. package/src/experience-tracker.ts +88 -151
  153. package/src/explorbot.ts +17 -51
  154. package/src/explorer.ts +20 -72
  155. package/src/knowledge-tracker.ts +42 -49
  156. package/src/state-manager.ts +15 -156
  157. package/src/utils/aria.ts +11 -2
  158. package/src/utils/cache.ts +40 -0
  159. package/src/utils/context-formatter.ts +12 -15
  160. package/src/utils/html.ts +79 -0
  161. package/src/utils/markdown-files.ts +30 -0
  162. package/src/utils/markdown-query.ts +15 -1
  163. package/src/utils/page-readiness.ts +1 -1
  164. package/src/utils/secrets.ts +2 -3
  165. package/src/utils/strings.ts +12 -0
  166. package/src/utils/web-element.ts +4 -0
  167. package/src/utils/web-sandbox.ts +43 -0
  168. package/dist/boat/api-tester/example/apibot.config.js +0 -30
@@ -11,7 +11,7 @@ import { Stats } from '../stats.ts';
11
11
  import { createDebug, tag } from '../utils/logger.js';
12
12
  import { type RetryOptions, withRetry } from '../utils/retry.js';
13
13
  import { RulesLoader } from '../utils/rules-loader.ts';
14
- import { Conversation } from './conversation.js';
14
+ import { Conversation, toToolExecution } from './conversation.js';
15
15
 
16
16
  const debugLog = createDebug('explorbot:provider');
17
17
  const promptLog = createDebug('explorbot:provider:out');
@@ -22,6 +22,8 @@ export class ContextLengthError extends Error {}
22
22
 
23
23
  let telemetryRegistered = false;
24
24
 
25
+ const CONTEXT_LENGTH_PATTERNS = ['reduce the length', 'context length', 'maximum context', 'token limit', 'too many tokens', 'max_tokens', 'context_length_exceeded', 'output truncated at maxtokens'];
26
+
25
27
  function extractCachedTokens(usage: any): number {
26
28
  if (!usage) return 0;
27
29
  const direct = usage.inputTokenDetails?.cacheReadTokens ?? usage.cachedInputTokens;
@@ -31,10 +33,10 @@ function extractCachedTokens(usage: any): number {
31
33
  return typeof fromRaw === 'number' ? fromRaw : 0;
32
34
  }
33
35
 
34
- function rejectAfterIdle(ms: number, signal: { cancelled: boolean }, controller: AbortController): Promise<never> {
36
+ function abortAfterIdle(ms: number, cancel: { cancelled: boolean }, controller: AbortController): Promise<never> {
35
37
  return new Promise((_, reject) => {
36
38
  const tick = () => {
37
- if (signal.cancelled) return;
39
+ if (cancel.cancelled) return;
38
40
  if (executionController.isAwaitingInput()) {
39
41
  setTimeout(tick, ms);
40
42
  return;
@@ -46,6 +48,12 @@ function rejectAfterIdle(ms: number, signal: { cancelled: boolean }, controller:
46
48
  });
47
49
  }
48
50
 
51
+ function combinedAbortSignal(controller: AbortController): AbortSignal {
52
+ const executionSignal = executionController.getAbortSignal();
53
+ if (!executionSignal) return controller.signal;
54
+ return AbortSignal.any([controller.signal, executionSignal]);
55
+ }
56
+
49
57
  export class Provider {
50
58
  private config: AIConfig;
51
59
  private telemetryEnabled = false;
@@ -70,7 +78,6 @@ export class Provider {
70
78
  },
71
79
  };
72
80
 
73
- static readonly CONTEXT_LENGTH_PATTERNS = ['reduce the length', 'context length', 'maximum context', 'token limit', 'too many tokens', 'max_tokens', 'context_length_exceeded', 'output truncated at maxtokens'];
74
81
  lastConversation: Conversation | null = null;
75
82
 
76
83
  constructor(config: AIConfig) {
@@ -138,12 +145,12 @@ export class Provider {
138
145
  return parts.length > 0 ? parts.join('\n\n') : undefined;
139
146
  }
140
147
 
141
- getProviderOptionsForAgent(agentName: string): Record<string, any> | undefined {
148
+ private getProviderOptionsForAgent(agentName: string): Record<string, any> | undefined {
142
149
  const agentConfig = this.config.agents?.[agentName as keyof typeof this.config.agents];
143
150
  return agentConfig?.providerOptions;
144
151
  }
145
152
 
146
- getReasoningForAgent(agentName?: string): string | undefined {
153
+ private getReasoningForAgent(agentName?: string): string | undefined {
147
154
  if (!agentName) return undefined;
148
155
  const agentConfig = this.config.agents?.[agentName as keyof typeof this.config.agents];
149
156
  return agentConfig?.reasoning;
@@ -172,6 +179,50 @@ export class Provider {
172
179
  if (reasoning) config.reasoning ??= reasoning;
173
180
  }
174
181
 
182
+ private buildGenerateConfig(defaults: Record<string, any>, overrides: Record<string, any>, options: any): Record<string, any> {
183
+ const telemetry = this.getTelemetry(options);
184
+ const config = this.mergeProviderOptions(
185
+ {
186
+ ...defaults,
187
+ allowSystemInMessages: true,
188
+ ...(this.config.config || {}),
189
+ ...options,
190
+ ...overrides,
191
+ },
192
+ options.agentName
193
+ );
194
+ this.finalizeConfig(config, options, telemetry);
195
+ return config;
196
+ }
197
+
198
+ private recordUsage(agentName: string, modelName: string, usage: any): void {
199
+ if (!usage) return;
200
+ Stats.recordTokens(agentName, modelName, {
201
+ input: usage.inputTokens ?? usage.promptTokens ?? 0,
202
+ output: usage.outputTokens ?? usage.completionTokens ?? 0,
203
+ total: usage.totalTokens ?? 0,
204
+ cached: extractCachedTokens(usage),
205
+ });
206
+ }
207
+
208
+ private async raceWithIdleTimeout<T>(fn: (signal: AbortSignal) => Promise<T>, timeoutMs: number): Promise<T> {
209
+ const cancel = { cancelled: false };
210
+ const controller = new AbortController();
211
+ const combinedSignal = combinedAbortSignal(controller);
212
+ try {
213
+ return await Promise.race([fn(combinedSignal), abortAfterIdle(timeoutMs, cancel, controller)]);
214
+ } finally {
215
+ cancel.cancelled = true;
216
+ }
217
+ }
218
+
219
+ private async recoverFromContextLength(error: any, messages: ModelMessage[], options: any, retry: (messages: ModelMessage[], options: any) => Promise<any>): Promise<any> {
220
+ const reduced = this.tryReduceMessages(messages, options._contextRetryLevel || 0);
221
+ if (!reduced) throw new ContextLengthError(error.message || error.toString());
222
+ tag('warning').log('Context length exceeded, retrying with reduced messages...');
223
+ return retry(reduced.messages, { ...options, _contextRetryLevel: reduced.nextLevel });
224
+ }
225
+
175
226
  private initLangfuse() {
176
227
  const langfuseConfig = this.config.langfuse;
177
228
  const publicKey = langfuseConfig?.publicKey || process.env.LANGFUSE_PUBLIC_KEY;
@@ -207,20 +258,25 @@ export class Provider {
207
258
 
208
259
  const runTelemetry = Observability.getTelemetry();
209
260
 
210
- if (!options.telemetry) {
261
+ let optionTelemetry = options.telemetry;
262
+ if (options.telemetryFunctionId && !optionTelemetry?.functionId) {
263
+ optionTelemetry = { ...optionTelemetry, functionId: options.telemetryFunctionId };
264
+ }
265
+
266
+ if (!optionTelemetry) {
211
267
  return runTelemetry;
212
268
  }
213
269
 
214
270
  if (!runTelemetry) {
215
- return options.telemetry;
271
+ return optionTelemetry;
216
272
  }
217
273
 
218
274
  return {
219
275
  ...runTelemetry,
220
- ...options.telemetry,
276
+ ...optionTelemetry,
221
277
  metadata: {
222
278
  ...runTelemetry.metadata,
223
- ...options.telemetry.metadata,
279
+ ...optionTelemetry.metadata,
224
280
  },
225
281
  };
226
282
  }
@@ -254,12 +310,8 @@ export class Provider {
254
310
  const toolCalls = response.toolCalls || [];
255
311
  const toolResults = response.toolResults || [];
256
312
 
257
- const toolExecutions = toolCalls.map((call: any, index: number) => ({
258
- toolName: call.toolName || '',
259
- input: call.input,
260
- output: toolResults[index]?.output,
261
- wasSuccessful: toolResults[index]?.output?.success || false,
262
- }));
313
+ const resultsById = new Map(toolResults.map((r: any) => [r.toolCallId, r]));
314
+ const toolExecutions = toolCalls.map((call: any) => toToolExecution(call.toolName || '', call.input, resultsById.get(call.toolCallId)?.output));
263
315
 
264
316
  return { conversation, response, toolExecutions };
265
317
  }
@@ -269,19 +321,7 @@ export class Provider {
269
321
  setActivity(`🤖 Asking ${modelName}`, 'ai');
270
322
  promptLog(`Using model: ${modelName}`);
271
323
 
272
- const telemetry = this.getTelemetry(options);
273
- const config = this.mergeProviderOptions(
274
- {
275
- maxOutputTokens: 16384,
276
- allowSystemInMessages: true,
277
- ...(this.config.config || {}),
278
- ...options,
279
- model,
280
- abortSignal: executionController.getAbortSignal(),
281
- },
282
- options.agentName
283
- );
284
- this.finalizeConfig(config, options, telemetry);
324
+ const config = this.buildGenerateConfig({ maxOutputTokens: 16384 }, { model, abortSignal: executionController.getAbortSignal() }, options);
285
325
 
286
326
  promptLog(messages[messages.length - 1].content);
287
327
  try {
@@ -303,14 +343,7 @@ export class Provider {
303
343
  clearActivity();
304
344
  responseLog(response.text);
305
345
 
306
- if (response.usage) {
307
- Stats.recordTokens(options.agentName || 'unknown', modelName, {
308
- input: response.usage.inputTokens ?? response.usage.promptTokens ?? 0,
309
- output: response.usage.outputTokens ?? response.usage.completionTokens ?? 0,
310
- total: response.usage.totalTokens ?? 0,
311
- cached: extractCachedTokens(response.usage),
312
- });
313
- }
346
+ this.recordUsage(options.agentName || 'unknown', modelName, response.usage);
314
347
 
315
348
  return response;
316
349
  } catch (error: any) {
@@ -318,12 +351,7 @@ export class Provider {
318
351
  if (error?.name === 'AbortError') throw error;
319
352
  if (error instanceof ContextLengthError) throw error;
320
353
  if (Provider.isContextLengthError(error)) {
321
- const reduced = this.tryReduceMessages(messages, options._contextRetryLevel || 0);
322
- if (reduced) {
323
- tag('warning').log('Context length exceeded, retrying with reduced messages...');
324
- return this.chat(reduced.messages, model, { ...options, _contextRetryLevel: reduced.nextLevel });
325
- }
326
- throw new ContextLengthError(error.message || error.toString());
354
+ return this.recoverFromContextLength(error, messages, options, (m, o) => this.chat(m, model, o));
327
355
  }
328
356
  const message = error.message || error.toString();
329
357
  if (message !== 'No response text from AI') {
@@ -343,50 +371,19 @@ export class Provider {
343
371
  promptLog('Available tools:', toolNames);
344
372
  promptLog(messages[messages.length - 1].content);
345
373
 
346
- const telemetry = this.getTelemetry(options);
347
374
  const maxRoundtrips = options.maxToolRoundtrips ?? 5;
348
375
  const extraStop = options.stopWhen;
349
376
  const stopConditions: any[] = [isStepCount(maxRoundtrips)];
350
377
  if (extraStop) stopConditions.push(extraStop);
351
- const { stopWhen: _ignoredStopWhen, ...optionsWithoutStop } = options;
352
- const config = this.mergeProviderOptions(
353
- {
354
- tools,
355
- maxOutputTokens: 16384,
356
- toolChoice: 'auto',
357
- allowSystemInMessages: true,
358
- ...(this.config.config || {}),
359
- ...optionsWithoutStop,
360
- stopWhen: stopConditions,
361
- model,
362
- abortSignal: executionController.getAbortSignal(),
363
- },
364
- options.agentName
365
- );
366
- this.finalizeConfig(config, options, telemetry);
378
+ const config = this.buildGenerateConfig({ tools, maxOutputTokens: 16384, toolChoice: 'auto' }, { stopWhen: stopConditions, model }, options);
367
379
  try {
368
380
  const response = await withRetry(async () => {
369
- const timeout = config.timeout || 30000;
370
- const cancel = { cancelled: false };
371
- const controller = new AbortController();
372
- const combinedSignal = AbortSignal.any([controller.signal, executionController.getAbortSignal()].filter(Boolean) as AbortSignal[]);
373
- try {
374
- const result = (await Promise.race([
375
- generateText({
376
- messages,
377
- ...config,
378
- abortSignal: combinedSignal,
379
- }),
380
- rejectAfterIdle(timeout, cancel, controller),
381
- ])) as any;
382
- const hasToolCall = (result.toolCalls?.length || 0) > 0;
383
- if (!result.text && !hasToolCall && result.finishReason === 'length') {
384
- throw new ContextLengthError('AI response empty: output truncated at maxTokens. Increase maxOutputTokens in config or use a model with higher output capacity.');
385
- }
386
- return result;
387
- } finally {
388
- cancel.cancelled = true;
381
+ const result = (await this.raceWithIdleTimeout((signal) => generateText({ messages, ...config, abortSignal: signal }), config.timeout || 30000)) as any;
382
+ const hasToolCall = (result.toolCalls?.length || 0) > 0;
383
+ if (!result.text && !hasToolCall && result.finishReason === 'length') {
384
+ throw new ContextLengthError('AI response empty: output truncated at maxTokens. Increase maxOutputTokens in config or use a model with higher output capacity.');
389
385
  }
386
+ return result;
390
387
  }, this.getRetryOptions(options));
391
388
 
392
389
  clearActivity();
@@ -401,14 +398,7 @@ export class Provider {
401
398
 
402
399
  responseLog(response.text);
403
400
 
404
- if (response.usage) {
405
- Stats.recordTokens(options.agentName || 'unknown', modelName, {
406
- input: response.usage.inputTokens ?? response.usage.promptTokens ?? 0,
407
- output: response.usage.outputTokens ?? response.usage.completionTokens ?? 0,
408
- total: response.usage.totalTokens ?? 0,
409
- cached: extractCachedTokens(response.usage),
410
- });
411
- }
401
+ this.recordUsage(options.agentName || 'unknown', modelName, response.usage);
412
402
 
413
403
  return response;
414
404
  } catch (error: any) {
@@ -419,12 +409,7 @@ export class Provider {
419
409
  if (error?.name === 'AbortError') throw error;
420
410
  if (error instanceof ContextLengthError) throw error;
421
411
  if (Provider.isContextLengthError(error)) {
422
- const reduced = this.tryReduceMessages(messages, options._contextRetryLevel || 0);
423
- if (reduced) {
424
- tag('warning').log('Context length exceeded, retrying with reduced messages...');
425
- return this.generateWithTools(reduced.messages, model, tools, { ...options, _contextRetryLevel: reduced.nextLevel });
426
- }
427
- throw new ContextLengthError(error.message || error.toString());
412
+ return this.recoverFromContextLength(error, messages, options, (m, o) => this.generateWithTools(m, model, tools, o));
428
413
  }
429
414
  if (error.constructor?.name === 'AI_APICallError') {
430
415
  responseLog(error.message);
@@ -440,52 +425,18 @@ export class Provider {
440
425
  setActivity(`🤖 Asking ${modelName} for structured output`, 'ai');
441
426
  promptLog(`Using model: ${modelName}`);
442
427
 
443
- const telemetry = this.getTelemetry(options);
444
- const config = this.mergeProviderOptions(
445
- {
446
- schema,
447
- allowSystemInMessages: true,
448
- ...(this.config.config || {}),
449
- ...options,
450
- model: modelToUse,
451
- abortSignal: executionController.getAbortSignal(),
452
- },
453
- options.agentName
454
- );
455
- this.finalizeConfig(config, options, telemetry);
428
+ const config = this.buildGenerateConfig({ schema }, { model: modelToUse }, options);
456
429
 
457
430
  try {
458
431
  promptLog(messages[messages.length - 1].content);
459
432
  const response = await withRetry(async () => {
460
- const timeout = config.timeout || 30000;
461
- const cancel = { cancelled: false };
462
- const controller = new AbortController();
463
- const combinedSignal = AbortSignal.any([controller.signal, executionController.getAbortSignal()].filter(Boolean) as AbortSignal[]);
464
- try {
465
- return (await Promise.race([
466
- generateObject({
467
- messages,
468
- ...config,
469
- abortSignal: combinedSignal,
470
- }),
471
- rejectAfterIdle(timeout, cancel, controller),
472
- ])) as any;
473
- } finally {
474
- cancel.cancelled = true;
475
- }
433
+ return (await this.raceWithIdleTimeout((signal) => generateObject({ messages, ...config, abortSignal: signal }), config.timeout || 30000)) as any;
476
434
  }, this.getRetryOptions(options));
477
435
 
478
436
  clearActivity();
479
437
  responseLog(response.object);
480
438
 
481
- if (response.usage) {
482
- Stats.recordTokens(options.agentName || 'unknown', modelName, {
483
- input: response.usage.inputTokens ?? response.usage.promptTokens ?? 0,
484
- output: response.usage.outputTokens ?? response.usage.completionTokens ?? 0,
485
- total: response.usage.totalTokens ?? 0,
486
- cached: extractCachedTokens(response.usage),
487
- });
488
- }
439
+ this.recordUsage(options.agentName || 'unknown', modelName, response.usage);
489
440
 
490
441
  return response;
491
442
  } catch (error: any) {
@@ -493,12 +444,7 @@ export class Provider {
493
444
  if (error?.name === 'AbortError') throw error;
494
445
  if (error instanceof ContextLengthError) throw error;
495
446
  if (Provider.isContextLengthError(error)) {
496
- const reduced = this.tryReduceMessages(messages, options._contextRetryLevel || 0);
497
- if (reduced) {
498
- tag('warning').log('Context length exceeded, retrying with reduced messages...');
499
- return this.generateObject(reduced.messages, schema, model, { ...options, _contextRetryLevel: reduced.nextLevel });
500
- }
501
- throw new ContextLengthError(error.message || error.toString());
447
+ return this.recoverFromContextLength(error, messages, options, (m, o) => this.generateObject(m, schema, model, o));
502
448
  }
503
449
  throw new AiError(error.message || error.toString());
504
450
  }
@@ -506,7 +452,7 @@ export class Provider {
506
452
 
507
453
  static isContextLengthError(error: any): boolean {
508
454
  const msg = (error?.message || error?.toString() || '').toLowerCase();
509
- return Provider.CONTEXT_LENGTH_PATTERNS.some((p) => msg.includes(p));
455
+ return CONTEXT_LENGTH_PATTERNS.some((p) => msg.includes(p));
510
456
  }
511
457
 
512
458
  static trimMessagesForRetry(messages: ModelMessage[]): ModelMessage[] | null {
@@ -677,13 +623,7 @@ export class Provider {
677
623
  clearActivity();
678
624
  responseLog(response.text);
679
625
 
680
- if (response.usage) {
681
- Stats.recordTokens('vision', this.getModelName(this.config.visionModel), {
682
- input: response.usage.inputTokens ?? response.usage.promptTokens ?? 0,
683
- output: response.usage.outputTokens ?? response.usage.completionTokens ?? 0,
684
- total: response.usage.totalTokens ?? 0,
685
- });
686
- }
626
+ this.recordUsage('vision', this.getModelName(this.config.visionModel), response.usage);
687
627
 
688
628
  return response;
689
629
  } catch (error: any) {
@@ -1,8 +1,8 @@
1
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { z } from 'zod';
4
4
  import type { ActionResult } from '../action-result.ts';
5
- import { ConfigParser } from '../config.ts';
5
+ import { outputPath } from '../config.ts';
6
6
  import type { StateManager, StateTransition, WebPageState } from '../state-manager.ts';
7
7
  import type { Task } from '../test-plan.ts';
8
8
  import { createDebug, tag } from '../utils/logger.ts';
@@ -12,36 +12,6 @@ import type { Provider } from './provider.ts';
12
12
 
13
13
  const debugLog = createDebug('explorbot:quartermaster');
14
14
 
15
- interface AxeViolation {
16
- id: string;
17
- impact: 'critical' | 'serious' | 'moderate' | 'minor';
18
- description: string;
19
- helpUrl: string;
20
- nodes: Array<{ html: string; target: string[] }>;
21
- }
22
-
23
- interface PageAnalysis {
24
- url: string;
25
- stateHash: string;
26
- timestamp: string;
27
- axeViolations: AxeViolation[];
28
- }
29
-
30
- interface SemanticIssue {
31
- type: 'unclear_intention' | 'confusing_naming' | 'hard_to_interact';
32
- element: string;
33
- issue: string;
34
- suggestion: string;
35
- }
36
-
37
- interface AnalysisReport {
38
- scenario: string;
39
- timestamp: string;
40
- url: string;
41
- axeViolations: AxeViolation[];
42
- semanticIssues: SemanticIssue[];
43
- }
44
-
45
15
  export class Quartermaster {
46
16
  private provider: Provider;
47
17
  private model?: any;
@@ -55,13 +25,12 @@ export class Quartermaster {
55
25
  this.provider = provider;
56
26
  this.model = options?.model;
57
27
 
58
- const configParser = ConfigParser.getInstance();
59
- this.outputDir = join(configParser.getOutputDir(), 'a11y');
28
+ this.outputDir = outputPath('a11y');
60
29
  }
61
30
 
62
31
  start(playwrightHelper: any, stateManager: StateManager): void {
63
32
  this.playwrightHelper = playwrightHelper;
64
- this.ensureDirectory();
33
+ mkdirSync(this.outputDir, { recursive: true });
65
34
 
66
35
  this.unsubscribe = stateManager.onStateChange((event) => {
67
36
  this.onPageChange(event);
@@ -78,12 +47,6 @@ export class Quartermaster {
78
47
  debugLog('Quartermaster stopped');
79
48
  }
80
49
 
81
- private ensureDirectory(): void {
82
- if (!existsSync(this.outputDir)) {
83
- mkdirSync(this.outputDir, { recursive: true });
84
- }
85
- }
86
-
87
50
  private onPageChange(event: StateTransition): void {
88
51
  const state = event.toState;
89
52
  if (!state?.hash) return;
@@ -284,3 +247,33 @@ Focus on what would confuse a real user or caused the agent to make mistakes.`;
284
247
  return content;
285
248
  }
286
249
  }
250
+
251
+ interface AxeViolation {
252
+ id: string;
253
+ impact: 'critical' | 'serious' | 'moderate' | 'minor';
254
+ description: string;
255
+ helpUrl: string;
256
+ nodes: Array<{ html: string; target: string[] }>;
257
+ }
258
+
259
+ interface PageAnalysis {
260
+ url: string;
261
+ stateHash: string;
262
+ timestamp: string;
263
+ axeViolations: AxeViolation[];
264
+ }
265
+
266
+ interface SemanticIssue {
267
+ type: 'unclear_intention' | 'confusing_naming' | 'hard_to_interact';
268
+ element: string;
269
+ issue: string;
270
+ suggestion: string;
271
+ }
272
+
273
+ interface AnalysisReport {
274
+ scenario: string;
275
+ timestamp: string;
276
+ url: string;
277
+ axeViolations: AxeViolation[];
278
+ semanticIssues: SemanticIssue[];
279
+ }
@@ -17,10 +17,10 @@ import type Explorer from '../explorer.ts';
17
17
  import type { KnowledgeTracker } from '../knowledge-tracker.ts';
18
18
  import { Stats } from '../stats.ts';
19
19
  import { Task, Test, TestResult } from '../test-plan.ts';
20
+ import { formatHeadings } from '../utils/context-formatter.ts';
20
21
  import { createDebug, tag } from '../utils/logger.ts';
21
22
  import { loop } from '../utils/loop.ts';
22
23
  import { RulesLoader } from '../utils/rules-loader.ts';
23
- import { loadTestSuites, printTestList } from '../utils/test-files.ts';
24
24
  import type { Agent } from './agent.ts';
25
25
  import { toolExecutionLabel } from './conversation.ts';
26
26
  import type { Navigator } from './navigator.ts';
@@ -38,8 +38,9 @@ export class Rerunner extends TaskAgent implements Agent {
38
38
  private explorer: Explorer;
39
39
  private provider: Provider;
40
40
  private agentTools: any;
41
- private healedSteps: Array<{ test: string; original: string; healed: string }> = [];
41
+ private healedSteps: Array<{ original: string; healed: string }> = [];
42
42
  private traceDir = '';
43
+ private static pluginsWired = false;
43
44
 
44
45
  constructor(explorer: Explorer, provider: Provider, agentTools?: any) {
45
46
  super();
@@ -76,10 +77,6 @@ export class Rerunner extends TaskAgent implements Agent {
76
77
  return this.rerunnerConfig.healMaxIterations ?? 3;
77
78
  }
78
79
 
79
- listTests(testsDir: string): void {
80
- printTestList(loadTestSuites(testsDir));
81
- }
82
-
83
80
  async rerun(filePath: string, options?: { testIndices?: number[] }): Promise<RerunResult> {
84
81
  const absPath = resolve(filePath);
85
82
  if (!existsSync(absPath)) {
@@ -219,7 +216,6 @@ export class Rerunner extends TaskAgent implements Agent {
219
216
 
220
217
  private setupPlugins(): void {
221
218
  const healMod = heal.default || heal;
222
- healMod.connectToEvents();
223
219
 
224
220
  healMod.addRecipe('explorbot-ai-healer', {
225
221
  priority: 10,
@@ -233,21 +229,29 @@ export class Rerunner extends TaskAgent implements Agent {
233
229
  healMod.addRecipe(name, recipe);
234
230
  }
235
231
 
232
+ const outputDir = (global as any).output_dir || 'output';
233
+ this.traceDir = `${outputDir}/rerun-traces`;
234
+
235
+ if (Rerunner.pluginsWired) return;
236
+ Rerunner.pluginsWired = true;
237
+
238
+ healMod.connectToEvents();
239
+
236
240
  let currentTest: any = null;
237
241
  let healTries = 0;
238
242
  let isHealing = false;
239
243
  let caughtError: any = null;
240
- const healLimit = this.healLimit;
241
244
 
242
245
  codeceptjs.event.dispatcher.on('test.before', (test: any) => {
243
246
  currentTest = test;
244
247
  healTries = 0;
245
248
  caughtError = null;
249
+ isHealing = false;
246
250
  });
247
251
 
248
252
  codeceptjs.event.dispatcher.on('step.after', (step: any) => {
249
253
  if (isHealing) return;
250
- if (healTries >= healLimit) return;
254
+ if (healTries >= this.healLimit) return;
251
255
  if (!healMod.hasCorrespondingRecipes(step)) return;
252
256
 
253
257
  codeceptjs.recorder.catchWithoutStop(async (err: any) => {
@@ -286,10 +290,6 @@ export class Rerunner extends TaskAgent implements Agent {
286
290
  factor: 1.5,
287
291
  });
288
292
 
289
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
290
- const outputDir = (global as any).output_dir || 'output';
291
- this.traceDir = `${outputDir}/rerun_${timestamp}`;
292
-
293
293
  const aiTrace = aiTracePlugin.default || aiTracePlugin;
294
294
  aiTrace(this.rerunnerConfig.aiTrace || { output: this.traceDir });
295
295
 
@@ -303,9 +303,9 @@ export class Rerunner extends TaskAgent implements Agent {
303
303
 
304
304
  private teardownHealing(): void {
305
305
  const healMod = heal.default || heal;
306
- healMod.recipes['explorbot-ai-healer'] = undefined;
306
+ Reflect.deleteProperty(healMod.recipes, 'explorbot-ai-healer');
307
307
  for (const name of Object.keys(this.rerunnerConfig.recipes || {})) {
308
- healMod.recipes[name] = undefined;
308
+ Reflect.deleteProperty(healMod.recipes, name);
309
309
  }
310
310
  }
311
311
 
@@ -432,7 +432,7 @@ export class Rerunner extends TaskAgent implements Agent {
432
432
  throw new Error(`Could not heal: ${failedCode}`);
433
433
  }
434
434
 
435
- this.healedSteps.push({ test: '', original: failedCode, healed: healedCommand });
435
+ this.healedSteps.push({ original: failedCode, healed: healedCommand });
436
436
  console.log(chalk.green(` ${figureSet.tick} Healed: ${healedCommand}`));
437
437
  };
438
438
  }
@@ -477,11 +477,7 @@ export class Rerunner extends TaskAgent implements Agent {
477
477
  const state = this.explorer.getStateManager().getCurrentState();
478
478
  const actionResult = state ? ActionResult.fromState(state) : null;
479
479
 
480
- const headings: string[] = [];
481
- if (state?.h1) headings.push(`H1: ${state.h1}`);
482
- if (state?.h2) headings.push(`H2: ${state.h2}`);
483
- if (state?.h3) headings.push(`H3: ${state.h3}`);
484
- if (state?.h4) headings.push(`H4: ${state.h4}`);
480
+ const headings = formatHeadings(state || {});
485
481
 
486
482
  return dedent`
487
483
  A test step failed and needs healing.
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'no
2
2
  import { join } from 'node:path';
3
3
  import { Worker } from 'node:worker_threads';
4
4
  import { outputPath } from '../../config.ts';
5
+ import { TTLCache } from '../../utils/cache.ts';
5
6
  import { computeHtmlFingerprint } from '../../utils/html-diff.ts';
6
7
  import { debugLog } from './mixin.ts';
7
8
 
@@ -10,8 +11,7 @@ const FINGERPRINT_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
10
11
  const FINGERPRINT_WORKER_TIMEOUT_MS = 10_000;
11
12
  const SIMILARITY_THRESHOLD = 90;
12
13
 
13
- const memoryCache: Record<string, string> = {};
14
- const memoryCacheTimestamps: Record<string, number> = {};
14
+ const memoryCache = new TTLCache<string>(CACHE_TTL_MS);
15
15
 
16
16
  let fingerprintWorker: Worker | null = null;
17
17
 
@@ -28,25 +28,20 @@ function getFingerprintWorker(): Worker {
28
28
  }
29
29
 
30
30
  export function clearResearchCache(): void {
31
- for (const key of Object.keys(memoryCache)) delete memoryCache[key];
32
- for (const key of Object.keys(memoryCacheTimestamps)) delete memoryCacheTimestamps[key];
31
+ memoryCache.clear();
33
32
  }
34
33
 
35
34
  export function getCachedResearch(hash: string): string {
36
35
  if (!hash) return '';
37
- const now = Date.now();
38
- const timestamp = memoryCacheTimestamps[hash];
39
- if (timestamp && now - timestamp <= CACHE_TTL_MS) {
40
- return memoryCache[hash] || '';
41
- }
36
+ const cached = memoryCache.get(hash);
37
+ if (cached !== undefined) return cached;
42
38
  const researchFile = outputPath('research', `${hash}.md`);
43
39
  if (!existsSync(researchFile)) return '';
44
40
  const stats = statSync(researchFile);
45
- if (now - stats.mtimeMs > CACHE_TTL_MS) return '';
46
- const cached = readFileSync(researchFile, 'utf8');
47
- memoryCache[hash] = cached;
48
- memoryCacheTimestamps[hash] = now;
49
- return cached;
41
+ if (Date.now() - stats.mtimeMs > CACHE_TTL_MS) return '';
42
+ const fromDisk = readFileSync(researchFile, 'utf8');
43
+ memoryCache.set(hash, fromDisk);
44
+ return fromDisk;
50
45
  }
51
46
 
52
47
  export function getPreviousResearch(hash: string): string {
@@ -61,8 +56,7 @@ export function saveResearch(hash: string, text: string, combinedHtml?: string):
61
56
  const researchFile = join(researchDir, `${hash}.md`);
62
57
  if (!existsSync(researchDir)) mkdirSync(researchDir, { recursive: true });
63
58
  writeFileSync(researchFile, text);
64
- memoryCache[hash] = text;
65
- memoryCacheTimestamps[hash] = Date.now();
59
+ memoryCache.set(hash, text);
66
60
  debugLog(`Research saved to ${researchFile}`);
67
61
 
68
62
  if (combinedHtml) {