explorbot 0.3.1 → 0.3.4

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 (65) hide show
  1. package/bin/explorbot-cli.ts +16 -5
  2. package/dist/bin/explorbot-cli.js +14 -4
  3. package/dist/models.json +2 -0
  4. package/dist/package.json +1 -1
  5. package/dist/src/action-result.d.ts +4 -0
  6. package/dist/src/action-result.js +20 -12
  7. package/dist/src/action.d.ts +11 -0
  8. package/dist/src/action.js +28 -11
  9. package/dist/src/ai/conversation.d.ts +2 -1
  10. package/dist/src/ai/conversation.js +9 -4
  11. package/dist/src/ai/driller.js +3 -1
  12. package/dist/src/ai/navigator.d.ts +3 -0
  13. package/dist/src/ai/navigator.js +20 -4
  14. package/dist/src/ai/pilot.js +9 -2
  15. package/dist/src/ai/provider.d.ts +2 -0
  16. package/dist/src/ai/provider.js +18 -1
  17. package/dist/src/ai/researcher/deep-analysis.js +7 -3
  18. package/dist/src/ai/researcher/sections.js +0 -1
  19. package/dist/src/ai/researcher.js +0 -1
  20. package/dist/src/ai/rules.js +0 -1
  21. package/dist/src/ai/tester.d.ts +1 -0
  22. package/dist/src/ai/tester.js +9 -11
  23. package/dist/src/ai/tools.d.ts +2 -0
  24. package/dist/src/ai/tools.js +21 -4
  25. package/dist/src/commands/exit-command.js +1 -1
  26. package/dist/src/commands/init-command.js +74 -24
  27. package/dist/src/components/InitWizard.d.ts +2 -1
  28. package/dist/src/components/InitWizard.js +8 -4
  29. package/dist/src/explorbot.js +1 -0
  30. package/dist/src/explorer.js +1 -0
  31. package/dist/src/knowledge-tracker.d.ts +3 -1
  32. package/dist/src/knowledge-tracker.js +4 -4
  33. package/dist/src/state-manager.d.ts +2 -0
  34. package/dist/src/state-manager.js +3 -3
  35. package/dist/src/utils/aria.js +1 -1
  36. package/dist/src/utils/html.d.ts +2 -1
  37. package/dist/src/utils/html.js +10 -4
  38. package/dist/src/utils/overlay.d.ts +24 -0
  39. package/dist/src/utils/overlay.js +43 -0
  40. package/docs/basics/providers.md +2 -4
  41. package/models.json +2 -0
  42. package/package.json +1 -1
  43. package/src/action-result.ts +25 -15
  44. package/src/action.ts +36 -13
  45. package/src/ai/conversation.ts +11 -5
  46. package/src/ai/driller.ts +3 -1
  47. package/src/ai/navigator.ts +22 -4
  48. package/src/ai/pilot.ts +9 -2
  49. package/src/ai/provider.ts +19 -1
  50. package/src/ai/researcher/deep-analysis.ts +8 -3
  51. package/src/ai/researcher/sections.ts +0 -1
  52. package/src/ai/researcher.ts +0 -1
  53. package/src/ai/rules.ts +0 -1
  54. package/src/ai/tester.ts +9 -9
  55. package/src/ai/tools.ts +20 -4
  56. package/src/commands/exit-command.ts +1 -1
  57. package/src/commands/init-command.ts +81 -22
  58. package/src/components/InitWizard.tsx +8 -4
  59. package/src/explorbot.ts +1 -0
  60. package/src/explorer.ts +1 -0
  61. package/src/knowledge-tracker.ts +4 -4
  62. package/src/state-manager.ts +4 -3
  63. package/src/utils/aria.ts +1 -1
  64. package/src/utils/html.ts +13 -4
  65. package/src/utils/overlay.ts +51 -0
@@ -31,6 +31,15 @@ function createHarmonyChannelFallbackTool() {
31
31
  });
32
32
  }
33
33
  let telemetryRegistered = false;
34
+ let beforeExitFlushHooked = false;
35
+ let activeOtelSdk = null;
36
+ export async function flushTelemetry() {
37
+ const sdk = activeOtelSdk;
38
+ activeOtelSdk = null;
39
+ if (!sdk)
40
+ return;
41
+ await sdk.shutdown().catch((error) => debugLog(`Telemetry flush failed: ${error instanceof Error ? error.message : error}`));
42
+ }
34
43
  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'];
35
44
  function extractCachedTokens(usage) {
36
45
  if (!usage)
@@ -105,6 +114,9 @@ export class Provider {
105
114
  throw new AiError(`AI connection failed: ${error.message}`);
106
115
  }
107
116
  }
117
+ async stop() {
118
+ await flushTelemetry();
119
+ }
108
120
  getModelForAgent(agentName) {
109
121
  if (!agentName) {
110
122
  return this.config.model;
@@ -251,7 +263,12 @@ export class Provider {
251
263
  spanProcessors: [processor],
252
264
  instrumentations: [],
253
265
  });
266
+ activeOtelSdk = this.otelSdk;
254
267
  void this.otelSdk.start();
268
+ if (!beforeExitFlushHooked) {
269
+ process.on('beforeExit', () => void flushTelemetry());
270
+ beforeExitFlushHooked = true;
271
+ }
255
272
  if (!telemetryRegistered) {
256
273
  registerTelemetry(new OpenTelemetry());
257
274
  telemetryRegistered = true;
@@ -293,7 +310,7 @@ export class Provider {
293
310
  }
294
311
  async invokeConversation(conversation, tools, options = {}) {
295
312
  const response = tools ? await this.generateWithTools(conversation.messages, conversation.model, tools, options) : await this.chat(conversation.messages, conversation.model, options);
296
- const responseMessages = response.response?.messages || [];
313
+ const responseMessages = response.responseMessages || [];
297
314
  if (responseMessages.length > 0) {
298
315
  conversation.messages.push(...responseMessages);
299
316
  tag('debug').log('Added', responseMessages.length, 'messages from response');
@@ -1,14 +1,16 @@
1
1
  import dedent from 'dedent';
2
2
  import { ActionResult } from '../../action-result.js';
3
3
  import { executionController } from "../../execution-controller.js";
4
- import { detectFocusArea, diffAriaSnapshots } from "../../utils/aria.js";
4
+ import { diffAriaSnapshots } from "../../utils/aria.js";
5
5
  import { extractCodeBlocks } from "../../utils/code-extractor.js";
6
6
  import { tag } from '../../utils/logger.js';
7
7
  import { mdq } from "../../utils/markdown-query.js";
8
+ import { truncate } from "../../utils/strings.js";
8
9
  import { getCachedResearch, getPreviousResearch, saveResearch } from "./cache.js";
9
10
  import { debugLog } from "./mixin.js";
10
11
  import { parseResearchSections } from "./parser.js";
11
12
  const DEFAULT_MAX_EXPANDABLE_CLICKS = 10;
13
+ const MAX_HTML_DIFF_CHARS = 20_000;
12
14
  export function WithDeepAnalysis(Base) {
13
15
  return class extends Base {
14
16
  async performDeepAnalysis(state, result) {
@@ -60,7 +62,7 @@ export function WithDeepAnalysis(Base) {
60
62
  this._appendExtendedResearch(result, expandedSections, navigationLinks);
61
63
  }
62
64
  async researchOverlay(current, previous, pageStateHash) {
63
- const focusArea = detectFocusArea(current.ariaSnapshot);
65
+ const focusArea = current.overlay;
64
66
  if (!focusArea.detected || !focusArea.name)
65
67
  return null;
66
68
  if (focusArea.type !== 'dialog' && focusArea.type !== 'modal')
@@ -430,6 +432,8 @@ export function WithDeepAnalysis(Base) {
430
432
 
431
433
  `;
432
434
  }
435
+ const cleanedParts = await diff.cleanedHtmlParts();
436
+ const htmlChanges = truncate(cleanedParts.map((p) => `[Container: ${p.container}]\n${p.subtree}`).join('\n\n'), MAX_HTML_DIFF_CHARS);
433
437
  const prompt = dedent `
434
438
  ${intro}
435
439
  Analyze the changes and produce a UI map section.
@@ -438,7 +442,7 @@ export function WithDeepAnalysis(Base) {
438
442
  ${diff.ariaChanged || 'none'}
439
443
 
440
444
  HTML changes:
441
- ${diff.htmlParts.map((p) => `[Container: ${p.container}]\n${p.subtree}`).join('\n\n') || 'none'}
445
+ ${htmlChanges || 'none'}
442
446
  ${alreadyHint}
443
447
 
444
448
  Respond with a SINGLE section in this format:
@@ -93,7 +93,6 @@ export function WithSections(Base) {
93
93
  - Do not copy global toolbar, navigation, list, or detail elements into this section unless they are descendants of this section container.
94
94
  - Every element with eidx inside this section's container MUST appear in the table.
95
95
  - Every row needs CSS; ARIA may be "-" for icon-only buttons.
96
- - ARIA locator JSON uses keys "role" and "text" (NOT "name").
97
96
  - Elements marked data-explorbot-hit="covered" or "offscreen" are not directly actionable; describe the covering or focused UI first.
98
97
  - In split-pane pages, entity detail panels are active detail context; include close/back/pin controls in the detail panel section when present.
99
98
  </rules>
@@ -345,7 +345,6 @@ export class Researcher extends ResearcherBase {
345
345
  - If an element has data-explorbot-hit="covered" or "offscreen", do not present it as directly actionable. Prefer the overlay, drawer, dialog, or focused section covering it, and mention what must be dismissed or revealed first.
346
346
  - Every element with an eidx attribute MUST appear in exactly one matching UI map section — describe icon-only buttons by their visual role.
347
347
  - Every UI map row needs a CSS selector; ARIA may be "-" for icon-only buttons, CSS must never be "-".
348
- - ARIA locator JSON uses keys "role" and "text" (NOT "name").
349
348
  - Mark elements with likely hover interactions (title, aria-describedby, menu items with submenus) as "(hover)".
350
349
  </rules>
351
350
 
@@ -67,7 +67,6 @@ const locatorStrategyRule = dedent `
67
67
 
68
68
  <bad_aria_locator_example>
69
69
  { "role": "button", "text": "" } // INVALID - empty text is useless, use "-" instead
70
- { "role": "button", "name": "Save" } // WRONG key - use "text", not "name"
71
70
  </bad_aria_locator_example>
72
71
 
73
72
  NEVER include \`eidx\` attribute in any locator (ARIA, CSS, XPath). It is an internal annotation.
@@ -33,6 +33,7 @@ export declare class Tester extends TaskAgent implements Agent {
33
33
  lastAnalyzedStateHash: string | null;
34
34
  stalledIterations: number;
35
35
  readonly MAX_STALLED_ITERATIONS = 3;
36
+ skipResearch: (err: Error) => string;
36
37
  constructor(deps: AgentDeps, researcher: Researcher, navigator: Navigator, agentTools?: any);
37
38
  getNavigator(): Navigator;
38
39
  setPilot(pilot: Pilot): void;
@@ -9,7 +9,6 @@ import { Observability } from "../observability.js";
9
9
  import { normalizeUrl } from "../state-manager.js";
10
10
  import { Stats } from "../stats.js";
11
11
  import { TestResult } from "../test-plan.js";
12
- import { detectFocusArea } from "../utils/aria.js";
13
12
  import { ErrorPageError, isErrorPage } from "../utils/error-page.js";
14
13
  import { createDebug, tag } from "../utils/logger.js";
15
14
  import { loop } from "../utils/loop.js";
@@ -52,6 +51,12 @@ export class Tester extends TaskAgent {
52
51
  lastAnalyzedStateHash = null;
53
52
  stalledIterations = 0;
54
53
  MAX_STALLED_ITERATIONS = 3;
54
+ skipResearch = (err) => {
55
+ if (err.name === 'AbortError')
56
+ throw err;
57
+ tag('warning').log(`Research skipped: ${err.message}`);
58
+ return '';
59
+ };
55
60
  constructor(deps, researcher, navigator, agentTools) {
56
61
  super(deps);
57
62
  this.requestStore = deps.requestStore;
@@ -466,7 +471,7 @@ export class Tester extends TaskAgent {
466
471
  this.previousUrl = currentUrl;
467
472
  this.previousStateHash = currentStateHash;
468
473
  let context = '';
469
- const focusArea = detectFocusArea(currentState.ariaSnapshot);
474
+ const focusArea = currentState.overlay;
470
475
  const focusedElement = currentState.focusedElement;
471
476
  if (focusedElement) {
472
477
  const isTextInput = ['textbox', 'combobox', 'searchbox'].includes(focusedElement.role);
@@ -513,14 +518,7 @@ export class Tester extends TaskAgent {
513
518
  const alreadySeenUiMap = this.seenUiMapUrls.has(currentUrl);
514
519
  let research = '';
515
520
  if (!alreadySeenUiMap) {
516
- try {
517
- research = await this.researcher.research(currentState);
518
- }
519
- catch (err) {
520
- if (!(err instanceof ErrorPageError))
521
- throw err;
522
- tag('warning').log(`Research skipped: ${err.message}`);
523
- }
521
+ research = await this.researcher.research(currentState).catch(this.skipResearch);
524
522
  }
525
523
  this.pageStateHash = currentStateHash;
526
524
  this.pageActionResult = currentState;
@@ -560,7 +558,7 @@ export class Tester extends TaskAgent {
560
558
  return context;
561
559
  }
562
560
  if (focusArea.detected && focusArea.name && this.pageStateHash && this.pageActionResult) {
563
- const overlaySection = await this.researcher.researchOverlay(currentState, this.pageActionResult, this.pageStateHash);
561
+ const overlaySection = await this.researcher.researchOverlay(currentState, this.pageActionResult, this.pageStateHash).catch(this.skipResearch);
564
562
  if (overlaySection) {
565
563
  context += dedent `
566
564
 
@@ -1,3 +1,4 @@
1
+ import type { ExecutedStep } from '../action.js';
1
2
  import { ActionResult, type PageDiff } from '../action-result.js';
2
3
  import { type ExperienceTracker } from '../experience-tracker.js';
3
4
  import { type Task } from '../test-plan.js';
@@ -62,6 +63,7 @@ export declare function successToolResult(action: string, data?: Record<string,
62
63
  }): Record<string, any>;
63
64
  export declare function isMajorPageChange(pageDiff: PageDiff): boolean;
64
65
  export declare function hasFailedRequest(pageDiff: PageDiff): boolean;
66
+ export declare function formatExecutedSteps(steps: ExecutedStep[], requestedCount?: number): string;
65
67
  export declare function failedToolResult(action: string, message: string, data?: Record<string, any>, error?: Error | null): Promise<Record<string, any>>;
66
68
  export declare function withdrawVisionTools(tools: Record<string, any>): void;
67
69
  export declare function clickFailureSuggestion(attempts: Array<{
@@ -343,16 +343,17 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
343
343
  if (action.lastError) {
344
344
  const message = errorText(action.lastError);
345
345
  await commitNote(activeNote, TestResult.FAILED, toolResult, action);
346
- let formSuggestion = 'Look into error message and identify which commands passed and which failed. Continue execution using step-by-step approach using click() and form() tools.';
346
+ let formSuggestion = 'Commands after the failing one never ran. Retry only those, using click() or form().';
347
347
  if (message.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN)) {
348
348
  const disambiguated = await disambiguateElements(action.lastError, explanation, ai);
349
349
  if (disambiguated) {
350
350
  formSuggestion = `Multiple elements matched. Add step.opts({ elementIndex: ${disambiguated.position} }) to the failing command. Fallback locator: ${disambiguated.xpath}`;
351
351
  }
352
352
  }
353
- return failedToolResult('form', `Form execution FAILED! ${message}`, {
353
+ return failedToolResult('form', `Form execution FAILED! ${message}\n${formatExecutedSteps(action.executedSteps, codeLines.length)}`, {
354
354
  ...toolResult,
355
355
  code: codeBlock,
356
+ attempts: action.executedSteps,
356
357
  suggestion: formSuggestion,
357
358
  }, action.lastError);
358
359
  }
@@ -369,6 +370,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }, task) {
369
370
  ...toolResult,
370
371
  message: `Form completed successfully with ${lines.length} commands.`,
371
372
  commandsExecuted: lines.length,
373
+ attempts: action.executedSteps,
372
374
  code: codeBlock,
373
375
  suggestion: 'Verify the form was filled in correctly using see() tool. If needed to submit: try click() tool or form() with I.pressKey("Enter").',
374
376
  }, action);
@@ -698,18 +700,24 @@ export function createAgentTools({ explorer, stateManager, ai, researcher, navig
698
700
  const actionResult = ActionResult.fromState(currentState);
699
701
  const experience = renderExperienceRecipes(explorer.activeTest?.getAppliedExperience(actionResult) ?? []);
700
702
  const success = await navigator.resolveState(instruction, actionResult, { experience });
703
+ const attempts = navigator.executedSteps;
704
+ let stepReport = '';
705
+ if (attempts.length)
706
+ stepReport = `\n${formatExecutedSteps(attempts)}`;
701
707
  const toolResult = await ActionResult.fromState(stateManager.getCurrentState()).toToolResult(previousState, instruction);
702
708
  if (success) {
703
709
  return successToolResult('interact', {
704
710
  ...toolResult,
705
- message: `Successfully executed: ${instruction}`,
711
+ message: `Successfully executed: ${instruction}${stepReport}`,
712
+ attempts,
706
713
  });
707
714
  }
708
715
  let reason = '';
709
716
  if (navigator.lastFailureReason)
710
717
  reason = `: ${navigator.lastFailureReason}`;
711
- return failedToolResult('interact', `Failed to execute: ${instruction}${reason}`, {
718
+ return failedToolResult('interact', `Failed to execute: ${instruction}${reason}${stepReport}`, {
712
719
  ...toolResult,
720
+ attempts,
713
721
  suggestion: 'The action could not be completed. Try a different instruction or use more specific element descriptions.',
714
722
  });
715
723
  }
@@ -1071,6 +1079,15 @@ export function isMajorPageChange(pageDiff) {
1071
1079
  export function hasFailedRequest(pageDiff) {
1072
1080
  return (pageDiff.requests ?? []).some((request) => request.status >= 400);
1073
1081
  }
1082
+ export function formatExecutedSteps(steps, requestedCount = steps.length) {
1083
+ if (!steps.length)
1084
+ return `No command ran of ${requestedCount} requested.`;
1085
+ const lines = steps.map((step) => ` ${step.success ? 'OK' : 'FAILED'} ${step.command}`);
1086
+ const notRun = requestedCount - steps.length;
1087
+ if (notRun > 0)
1088
+ lines.push(` NOT RUN ${notRun} more`);
1089
+ return lines.join('\n');
1090
+ }
1074
1091
  function hasObservablePageChange(data) {
1075
1092
  if (!data?.pageDiff)
1076
1093
  return false;
@@ -9,7 +9,7 @@ export class ExitCommand extends BaseCommand {
9
9
  aliases = ['quit'];
10
10
  async execute(_args) {
11
11
  await this.explorBot.printSessionAnalysis();
12
- await this.explorBot.getExplorer().stop();
12
+ await this.explorBot.stop();
13
13
  if (Stats.hasActivity()) {
14
14
  await new Promise((resolve) => {
15
15
  const { unmount } = render(React.createElement(StatusPane, {
@@ -1,15 +1,17 @@
1
1
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, extname, join, resolve } from 'node:path';
3
3
  import chalk from 'chalk';
4
- import dedent from 'dedent';
5
4
  import { ConfigParser, PROVIDERS } from "../config.js";
6
5
  import { findGlobalConfig, globalConfigPath, globalDir, globalEnvPath } from "../global-config.js";
7
6
  import { getCliName } from "../utils/cli-name.js";
8
7
  import { log, tag } from '../utils/logger.js';
9
8
  import { relativeToCwd } from "../utils/next-steps.js";
10
- function defaultConfigTemplate() {
9
+ function defaultConfigTemplate(provider, esm) {
10
+ let moduleExport = 'module.exports = config;';
11
+ if (esm)
12
+ moduleExport = 'export default config;';
11
13
  return `// 'provider/model-id' uses a bundled provider.
12
- // It is also possible to import provider as a module from Vercel AI SDK.
14
+ // It is also possible to import provider as a module from Vercel AI SDK.
13
15
  // https://github.com/testomatio/explorbot/blob/main/docs/basics/providers.md
14
16
 
15
17
  const config = {
@@ -19,7 +21,7 @@ const config = {
19
21
  },
20
22
 
21
23
  ai: {
22
- ${modelLines('openrouter')}
24
+ ${modelLines(provider)}
23
25
  },
24
26
 
25
27
  reporter: {
@@ -32,16 +34,17 @@ ${modelLines('openrouter')}
32
34
  },
33
35
  };
34
36
 
35
- export default config;
37
+ ${moduleExport}
36
38
  `;
37
39
  }
38
- const DEFAULT_ENV_TEMPLATE = dedent `
39
- # AI provider API keys
40
- OPENROUTER_API_KEY=
41
-
42
- # OPENAI_API_KEY=
43
- # ANTHROPIC_API_KEY=
44
- # GROQ_API_KEY=
40
+ function envTemplate(provider) {
41
+ const keyLines = Object.entries(PROVIDERS).map(([name, { envKey }]) => {
42
+ if (name === provider)
43
+ return `${envKey}=`;
44
+ return `# ${envKey}=`;
45
+ });
46
+ return `# AI provider API keys
47
+ ${keyLines.join('\n')}
45
48
 
46
49
  # Langfuse Tracing
47
50
  LANGFUSE_SECRET_KEY=
@@ -49,10 +52,11 @@ LANGFUSE_PUBLIC_KEY=
49
52
  LANGFUSE_BASE_URL=
50
53
 
51
54
  # Testomat.io API key to publish run results
52
- TESTOMATIO=
53
- `;
55
+ TESTOMATIO=`;
56
+ }
54
57
  export async function runInit(options) {
55
- if (options.global || options.provider) {
58
+ const localRequested = !!(options.configPath || options.path);
59
+ if (options.global || (options.provider && !localRequested)) {
56
60
  await runGlobalInit(options);
57
61
  return;
58
62
  }
@@ -61,8 +65,12 @@ export async function runInit(options) {
61
65
  return;
62
66
  }
63
67
  const choice = await renderInitWizard('choose');
64
- if (choice === 'local')
65
- runInitCommand(options);
68
+ if (choice !== 'local')
69
+ return;
70
+ const provider = await renderLocalProviderWizard();
71
+ if (!provider)
72
+ return;
73
+ runInitCommand({ ...options, provider });
66
74
  }
67
75
  export function writeGlobalConfig(provider, apiKey) {
68
76
  if (!PROVIDERS[provider]) {
@@ -87,7 +95,7 @@ export function writeGlobalConfig(provider, apiKey) {
87
95
  tag('substep').log(chalk.yellow(`${getCliName()} sites`));
88
96
  }
89
97
  export function runInitCommand(options) {
90
- const configPath = options.configPath ?? './explorbot.config.js';
98
+ const provider = options.provider || 'openrouter';
91
99
  const force = options.force ?? false;
92
100
  const customPath = options.path;
93
101
  const originalCwd = process.cwd();
@@ -100,13 +108,15 @@ export function runInitCommand(options) {
100
108
  process.chdir(dir);
101
109
  log(`Working in directory: ${relativeToCwd(dir)}`);
102
110
  }
111
+ const configName = 'explorbot.config.js';
112
+ const configPath = options.configPath ?? `./${configName}`;
103
113
  try {
104
114
  let outPath = resolve(configPath);
105
115
  if (existsSync(outPath) && statSync(outPath).isDirectory()) {
106
- outPath = join(outPath, 'explorbot.config.js');
116
+ outPath = join(outPath, configName);
107
117
  }
108
118
  else if (!extname(outPath)) {
109
- outPath = join(outPath, 'explorbot.config.js');
119
+ outPath = join(outPath, configName);
110
120
  }
111
121
  const dir = dirname(outPath);
112
122
  if (!existsSync(dir)) {
@@ -118,23 +128,28 @@ export function runInitCommand(options) {
118
128
  log('Use --force to overwrite existing file');
119
129
  process.exit(1);
120
130
  }
121
- writeFileSync(outPath, defaultConfigTemplate(), 'utf8');
131
+ const esm = extname(outPath) !== '.js' || isModuleProject(dirname(outPath));
132
+ writeFileSync(outPath, defaultConfigTemplate(provider, esm), 'utf8');
122
133
  log(`Created config file: ${relativeToCwd(outPath)}`);
123
134
  const envPath = resolve(process.cwd(), '.env');
124
135
  if (!existsSync(envPath)) {
125
- writeFileSync(envPath, `${DEFAULT_ENV_TEMPLATE}\n`, 'utf8');
136
+ writeFileSync(envPath, `${envTemplate(provider)}\n`, 'utf8');
126
137
  log(`Created env file: ${relativeToCwd(envPath)}`);
127
138
  }
128
139
  else {
129
140
  log(`Env file already exists: ${relativeToCwd(envPath)}`);
130
141
  }
142
+ const missing = missingRoles(provider);
143
+ if (missing.length) {
144
+ tag('warning').log(`No recommended ${missing.join(' and ')} for ${provider} — set the model ids in ${relativeToCwd(outPath)}`);
145
+ }
131
146
  log('');
132
147
  log('Next steps:');
133
148
  log('1. Configure AI provider in .env');
134
149
  log('2. Set AI models config file');
135
150
  log('3. Set web application URL in the config file');
136
151
  log('4. Add initial knowledge (how to authorize to the application, etc.)');
137
- tag('substep').log(chalk.yellow(`${getCliName()} learn * 'to aurhorize use these credentials: admin@example.com / secret123'`));
152
+ tag('substep').log(chalk.yellow(`${getCliName()} learn * 'to authorize use these credentials: admin@example.com / secret123'`));
138
153
  tag('substep').log('You can use ${env.LOGIN} and ${env.PASSWORD} to reference environment variables.');
139
154
  log('5. Launch application on a relative URL');
140
155
  tag('substep').log(chalk.yellow(`${getCliName()} start /dashboard`));
@@ -187,6 +202,23 @@ async function renderInitWizard(mode) {
187
202
  }), { exitOnCtrlC: false, patchConsole: false });
188
203
  });
189
204
  }
205
+ async function renderLocalProviderWizard() {
206
+ const [{ render }, React, InitWizard] = await Promise.all([import('ink'), import('react'), import('../components/InitWizard.js').then((m) => m.default)]);
207
+ return new Promise((resolve) => {
208
+ const finish = (provider) => {
209
+ unmount();
210
+ resolve(provider);
211
+ };
212
+ const { unmount } = render(React.createElement(InitWizard, {
213
+ mode: 'local',
214
+ globalConfigExists: !!findGlobalConfig(),
215
+ onLocal: () => finish(null),
216
+ onComplete: () => finish(null),
217
+ onCancel: () => finish(null),
218
+ onLocalProvider: (provider) => finish(provider),
219
+ }), { exitOnCtrlC: false, patchConsole: false });
220
+ });
221
+ }
190
222
  function modelLines(provider) {
191
223
  const recommended = ConfigParser.recommendedModels()[provider] || {};
192
224
  const roles = [
@@ -216,9 +248,27 @@ ${modelLines(provider)}
216
248
  },
217
249
  };
218
250
 
219
- export default config;
251
+ module.exports = config;
220
252
  `;
221
253
  }
254
+ function isModuleProject(configDir) {
255
+ let currentDir = resolve(configDir);
256
+ while (true) {
257
+ const packagePath = join(currentDir, 'package.json');
258
+ if (existsSync(packagePath)) {
259
+ try {
260
+ return JSON.parse(readFileSync(packagePath, 'utf8')).type === 'module';
261
+ }
262
+ catch {
263
+ return false;
264
+ }
265
+ }
266
+ const parentDir = dirname(currentDir);
267
+ if (parentDir === currentDir)
268
+ return false;
269
+ currentDir = parentDir;
270
+ }
271
+ }
222
272
  function missingRoles(provider) {
223
273
  const recommended = ConfigParser.recommendedModels()[provider] || {};
224
274
  return ['model', 'visionModel', 'agenticModel'].filter((role) => !recommended[role]);
@@ -1,10 +1,11 @@
1
1
  import React from 'react';
2
2
  interface InitWizardProps {
3
- mode: 'choose' | 'global';
3
+ mode: 'choose' | 'global' | 'local';
4
4
  globalConfigExists: boolean;
5
5
  onLocal: () => void;
6
6
  onComplete: () => void;
7
7
  onCancel: () => void;
8
+ onLocalProvider?: (provider: string) => void;
8
9
  }
9
10
  declare const InitWizard: React.FC<InitWizardProps>;
10
11
  export default InitWizard;
@@ -7,7 +7,7 @@ import { ConfigParser, PROVIDERS, createModel } from '../config.js';
7
7
  import { globalDir } from '../global-config.js';
8
8
  import InputReadline from './InputReadline.js';
9
9
  const PROVIDER_NAMES = Object.keys(PROVIDERS);
10
- const InitWizard = ({ mode, globalConfigExists, onLocal, onComplete, onCancel }) => {
10
+ const InitWizard = ({ mode, globalConfigExists, onLocal, onComplete, onCancel, onLocalProvider }) => {
11
11
  const [step, setStep] = useState(mode === 'choose' ? 'target' : 'provider');
12
12
  const [targetIndex, setTargetIndex] = useState(0);
13
13
  const [providerIndex, setProviderIndex] = useState(0);
@@ -67,8 +67,12 @@ const InitWizard = ({ mode, globalConfigExists, onLocal, onComplete, onCancel })
67
67
  setProviderIndex((index) => Math.max(0, index - 1));
68
68
  if (key.downArrow)
69
69
  setProviderIndex((index) => Math.min(PROVIDER_NAMES.length - 1, index + 1));
70
- if (key.return)
71
- setStep('key');
70
+ if (key.return) {
71
+ if (mode === 'local')
72
+ onLocalProvider?.(provider);
73
+ else
74
+ setStep('key');
75
+ }
72
76
  return;
73
77
  }
74
78
  if (status)
@@ -121,7 +125,7 @@ const InitWizard = ({ mode, globalConfigExists, onLocal, onComplete, onCancel })
121
125
  React.createElement(Box, { marginTop: 1 },
122
126
  React.createElement(Text, { dimColor: true },
123
127
  "Config goes to ",
124
- globalDir(),
128
+ mode === 'local' ? 'the current directory' : globalDir(),
125
129
  " | ",
126
130
  step === 'key' ? 'Enter: continue' : '↑↓: select | Enter: confirm',
127
131
  " | Ctrl+C: exit"))));
@@ -114,6 +114,7 @@ export class ExplorBot {
114
114
  async stop() {
115
115
  this.agents.quartermaster?.stop();
116
116
  await this.explorer?.stop();
117
+ await this.provider?.stop();
117
118
  }
118
119
  async visitInitialState() {
119
120
  await this.visit(this.options.from || '/');
@@ -203,6 +203,7 @@ class Explorer {
203
203
  const projectRoot = configParser.getProjectRoot();
204
204
  global.output_dir = configParser.getStatesDir();
205
205
  global.codecept_dir = projectRoot;
206
+ global.codeceptjs = codeceptjs;
206
207
  configParser.validateConfig(this.config);
207
208
  codeceptjs.container.create(this.convertToCodeceptConfig(this.config), {});
208
209
  }
@@ -17,7 +17,9 @@ export declare class KnowledgeTracker {
17
17
  renderRelevantKnowledge(state: ActionResult): string;
18
18
  renderRelevantContext(state: ActionResult): string;
19
19
  renderApplicationSpec(state: ActionResult): string;
20
- addKnowledge(urlPattern: string, description: string): {
20
+ addKnowledge(urlPattern: string, description: string, opts?: {
21
+ replace?: boolean;
22
+ }): {
21
23
  filename: string;
22
24
  filePath: string;
23
25
  isNewFile: boolean;
@@ -73,7 +73,7 @@ export class KnowledgeTracker {
73
73
  renderApplicationSpec(state) {
74
74
  return this.applicationSpec?.renderFor(state) || '';
75
75
  }
76
- addKnowledge(urlPattern, description) {
76
+ addKnowledge(urlPattern, description, opts) {
77
77
  const configParser = ConfigParser.getInstance();
78
78
  const configPath = configParser.getConfigPath();
79
79
  if (!configPath) {
@@ -102,11 +102,11 @@ export class KnowledgeTracker {
102
102
  const existingDescription = parsed.content.trim();
103
103
  // Append new knowledge with separator
104
104
  let newContent;
105
- if (existingDescription) {
106
- newContent = `${existingDescription}\n\n---\n\n${description}`;
105
+ if (opts?.replace || !existingDescription) {
106
+ newContent = description;
107
107
  }
108
108
  else {
109
- newContent = description;
109
+ newContent = `${existingDescription}\n\n---\n\n${description}`;
110
110
  }
111
111
  const fileContent = matter.stringify(newContent, frontmatter);
112
112
  writeFileSync(filePath, fileContent, 'utf8');
@@ -1,6 +1,7 @@
1
1
  import { ActionResult, type FocusedElement } from './action-result.js';
2
2
  import type { ExperienceTracker } from './experience-tracker.js';
3
3
  import type { Knowledge, KnowledgeTracker } from './knowledge-tracker.js';
4
+ import { Overlay } from './utils/overlay.js';
4
5
  export interface Link {
5
6
  title: string;
6
7
  url: string;
@@ -40,6 +41,7 @@ export interface WebPageState {
40
41
  focusedElement?: FocusedElement | null;
41
42
  links?: Link[];
42
43
  verifications?: Record<string, boolean>;
44
+ overlay?: Overlay;
43
45
  }
44
46
  export interface StateTransition {
45
47
  /** Previous state (null if this is the first state) */
@@ -1,6 +1,6 @@
1
1
  import { ActionResult } from './action-result.js';
2
- import { detectFocusArea } from './utils/aria.js';
3
2
  import { createDebug, tag } from './utils/logger.js';
3
+ import { Overlay } from './utils/overlay.js';
4
4
  import { slugify } from './utils/strings.js';
5
5
  import { extractStatePath } from './utils/url-matcher.js';
6
6
  const debugLog = createDebug('explorbot:state');
@@ -114,8 +114,8 @@ export class StateManager {
114
114
  return newState;
115
115
  }
116
116
  hasDialogAppeared(previousState, newState) {
117
- const prevFocus = detectFocusArea(previousState?.ariaSnapshot ?? null);
118
- const newFocus = detectFocusArea(newState.ariaSnapshot ?? null);
117
+ const prevFocus = previousState?.overlay ?? Overlay.fromAria(previousState?.ariaSnapshot ?? null);
118
+ const newFocus = newState.overlay ?? Overlay.fromAria(newState.ariaSnapshot ?? null);
119
119
  return !prevFocus.detected && newFocus.detected;
120
120
  }
121
121
  /**
@@ -569,7 +569,7 @@ export function parseAriaLocator(ariaStr) {
569
569
  const trimmed = ariaStr.trim();
570
570
  if (trimmed === '-' || trimmed === '' || trimmed === '"-"')
571
571
  return null;
572
- const match = trimmed.match(/\{\s*["']?role["']?\s*:\s*['"]([^'"]+)['"]\s*,\s*["']?text["']?\s*:\s*['"]([^'"]*)['"]\s*\}/);
572
+ const match = trimmed.match(/\{\s*["']?role["']?\s*:\s*['"]([^'"]+)['"]\s*,\s*["']?(?:text|name)["']?\s*:\s*['"]([^'"]*)['"]\s*\}/);
573
573
  if (!match)
574
574
  return null;
575
575
  return { role: match[1], text: match[2] };
@@ -14,7 +14,6 @@ export declare const HTML_SELECTORS: {
14
14
  readonly interactiveControl: "button, a[href], input, select, textarea, [role=\"button\"], [role=\"link\"], [role=\"checkbox\"], [role=\"radio\"], [role=\"switch\"], [role=\"tab\"], [role=\"menuitem\"]";
15
15
  readonly labelLike: "h1, h2, h3, h4, h5, h6, legend, caption, label, [role=\"heading\"], [class*=\"title\"], [class*=\"label\"], [class*=\"header\"], [class*=\"name\"]";
16
16
  readonly semanticContextContainer: "section, article, form, fieldset, li, tr, td, th, [role=\"group\"], [role=\"tabpanel\"], [role=\"region\"], [class*=\"card\"], [class*=\"panel\"], [class*=\"item\"], [class*=\"usage\"], [class*=\"group\"]";
17
- readonly semanticOverlays: readonly ["[role=\"dialog\"]", "[role=\"listbox\"]", "[role=\"menu\"]", "[role=\"tooltip\"]:not([style*=\"display: none\"]):not([style*=\"visibility: hidden\"])"];
18
17
  };
19
18
  export declare const HTML_VISIBILITY_LIMITS: {
20
19
  readonly maxViewportOverlayRatio: 0.95;
@@ -67,7 +66,9 @@ export type VisibleOverlayExtractionConfig = {
67
66
  interactiveContentSelector: string;
68
67
  limits: typeof HTML_EXTRACTION_LIMITS;
69
68
  overlaySelectors: readonly string[];
69
+ overlaySemanticSelector: string;
70
70
  visibilityLimits: typeof HTML_VISIBILITY_LIMITS;
71
+ geometryFallback?: boolean;
71
72
  };
72
73
  export type ComponentScopeExtractionConfig = {
73
74
  eidxAttr: string;