explorbot 0.5.7 → 0.5.9

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 (77) hide show
  1. package/bin/explorbot-cli.ts +25 -3
  2. package/boat/prima/README.md +3 -0
  3. package/boat/prima/src/cli.ts +7 -1
  4. package/boat/prima/src/envelope.ts +18 -4
  5. package/boat/prima/src/help.ts +15 -4
  6. package/boat/prima/src/prima.ts +181 -25
  7. package/dist/bin/explorbot-cli.js +31 -3
  8. package/dist/boat/prima/src/cli.js +6 -1
  9. package/dist/boat/prima/src/envelope.js +16 -3
  10. package/dist/boat/prima/src/help.js +14 -4
  11. package/dist/boat/prima/src/prima.js +191 -27
  12. package/dist/package.json +1 -1
  13. package/dist/rules/navigator/verification-actions.md +5 -3
  14. package/dist/rules/planner/ignored-pages.md +6 -0
  15. package/dist/rules/quartermaster/ux/accessibility.md +8 -0
  16. package/dist/rules/quartermaster/ux/charts.md +6 -0
  17. package/dist/rules/quartermaster/ux/copy.md +9 -0
  18. package/dist/rules/quartermaster/ux/forms.md +7 -0
  19. package/dist/rules/quartermaster/ux/interaction.md +7 -0
  20. package/dist/rules/quartermaster/ux/motion.md +5 -0
  21. package/dist/rules/quartermaster/ux/navigation.md +7 -0
  22. package/dist/rules/quartermaster/ux/readability.md +6 -0
  23. package/dist/rules/quartermaster/ux/responsive.md +7 -0
  24. package/dist/src/ai/judge.d.ts +3 -1
  25. package/dist/src/ai/judge.js +13 -3
  26. package/dist/src/ai/navigator.js +3 -2
  27. package/dist/src/ai/pilot.js +10 -4
  28. package/dist/src/ai/planner.js +9 -1
  29. package/dist/src/ai/provider.d.ts +4 -0
  30. package/dist/src/ai/provider.js +27 -5
  31. package/dist/src/ai/quartermaster.d.ts +31 -3
  32. package/dist/src/ai/quartermaster.js +262 -17
  33. package/dist/src/ai/tester.js +6 -3
  34. package/dist/src/commands/explore-command.js +7 -0
  35. package/dist/src/commands/init-command.d.ts +1 -0
  36. package/dist/src/commands/init-command.js +87 -36
  37. package/dist/src/components/InitWizard.d.ts +5 -1
  38. package/dist/src/components/InitWizard.js +29 -11
  39. package/dist/src/config.d.ts +2 -0
  40. package/dist/src/config.js +29 -7
  41. package/dist/src/explorbot.d.ts +1 -1
  42. package/dist/src/explorbot.js +8 -13
  43. package/dist/src/explorer.js +1 -0
  44. package/dist/src/global-config.js +6 -2
  45. package/dist/src/utils/ux-probe.d.ts +25 -0
  46. package/dist/src/utils/ux-probe.js +155 -0
  47. package/docs/basics/getting-started.md +23 -10
  48. package/docs/basics/providers.md +2 -0
  49. package/docs/reference/commands.md +5 -2
  50. package/docs/reference/configuration.md +1 -0
  51. package/package.json +1 -1
  52. package/rules/navigator/verification-actions.md +5 -3
  53. package/rules/planner/ignored-pages.md +6 -0
  54. package/rules/quartermaster/ux/accessibility.md +8 -0
  55. package/rules/quartermaster/ux/charts.md +6 -0
  56. package/rules/quartermaster/ux/copy.md +9 -0
  57. package/rules/quartermaster/ux/forms.md +7 -0
  58. package/rules/quartermaster/ux/interaction.md +7 -0
  59. package/rules/quartermaster/ux/motion.md +5 -0
  60. package/rules/quartermaster/ux/navigation.md +7 -0
  61. package/rules/quartermaster/ux/readability.md +6 -0
  62. package/rules/quartermaster/ux/responsive.md +7 -0
  63. package/src/ai/judge.ts +11 -3
  64. package/src/ai/navigator.ts +2 -2
  65. package/src/ai/pilot.ts +10 -4
  66. package/src/ai/planner.ts +10 -1
  67. package/src/ai/provider.ts +29 -6
  68. package/src/ai/quartermaster.ts +300 -18
  69. package/src/ai/tester.ts +5 -3
  70. package/src/commands/explore-command.ts +8 -0
  71. package/src/commands/init-command.ts +105 -38
  72. package/src/components/InitWizard.tsx +43 -11
  73. package/src/config.ts +32 -7
  74. package/src/explorbot.ts +9 -13
  75. package/src/explorer.ts +1 -0
  76. package/src/global-config.ts +5 -2
  77. package/src/utils/ux-probe.ts +163 -0
@@ -1,24 +1,29 @@
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 figureSet from 'figures';
4
5
  import { ConfigParser, PROVIDERS, missingModelRoles } from "../config.js";
5
6
  import { findGlobalConfig, globalConfigPath, globalDir, globalEnvPath } from "../global-config.js";
6
7
  import { getCliName } from "../utils/cli-name.js";
7
8
  import { log, tag } from '../utils/logger.js';
8
- import { relativeToCwd } from "../utils/next-steps.js";
9
- function defaultConfigTemplate(provider) {
9
+ import { printNextSteps, relativeToCwd } from "../utils/next-steps.js";
10
+ function defaultConfigTemplate(provider, url) {
11
+ const appUrl = url || 'http://<your-app-host-here>';
10
12
  return `// 'provider/model-id' uses a bundled provider.
11
13
  // It is also possible to import provider as a module from Vercel AI SDK.
12
- // https://github.com/testomatio/explorbot/blob/main/docs/basics/providers.md
14
+ // https://testomat.ai/docs/explorbot
13
15
 
14
16
  const config = {
15
17
  web: {
16
18
  // use application host without path prefix (e.g., http://localhost:3000)
17
- url: 'http://<your-app-host-here>',
19
+ url: '${appUrl}',
18
20
  },
19
21
 
20
22
  ai: {
21
- ${modelLines(provider)}
23
+ // every role (model, visionModel, agenticModel) takes the recommended ${provider} models bundled with this version
24
+ // override a single role as 'provider/model-id':
25
+ // model: '${provider}/<model-id>',
26
+ recommendedModels: '${provider}',
22
27
  },
23
28
 
24
29
  reporter: {
@@ -35,9 +40,10 @@ export default config;
35
40
  `;
36
41
  }
37
42
  export function envTemplate(provider) {
43
+ const { keyUrl } = PROVIDERS[provider];
38
44
  const keyLines = Object.entries(PROVIDERS).map(([name, { envKey }]) => {
39
45
  if (name === provider)
40
- return `${envKey}=`;
46
+ return `# Create a key at ${keyUrl} and paste it after '='\n${envKey}=`;
41
47
  return `# ${envKey}=`;
42
48
  });
43
49
  return `# AI provider API keys
@@ -64,10 +70,10 @@ export async function runInit(options) {
64
70
  const choice = await renderInitWizard('choose');
65
71
  if (choice !== 'local')
66
72
  return;
67
- const provider = await renderLocalProviderWizard();
68
- if (!provider)
73
+ const setup = await renderLocalSetupWizard();
74
+ if (!setup)
69
75
  return;
70
- runInitCommand({ ...options, provider });
76
+ runInitCommand({ ...options, ...setup });
71
77
  }
72
78
  export function writeGlobalConfig(provider, apiKey) {
73
79
  if (!PROVIDERS[provider]) {
@@ -75,21 +81,25 @@ export function writeGlobalConfig(provider, apiKey) {
75
81
  }
76
82
  mkdirSync(globalDir(), { recursive: true });
77
83
  writeFileSync(globalConfigPath(), globalConfigTemplate(provider), 'utf8');
78
- log(`Created global config: ${globalConfigPath()}`);
84
+ tag('success').log(`${figureSet.tick} Created global config: ${globalConfigPath()}`);
79
85
  const envKey = PROVIDERS[provider].envKey;
80
86
  writeEnvKey(envKey, apiKey || '');
81
- log(`Stored ${envKey} in ${globalEnvPath()}`);
87
+ tag('success').log(`${figureSet.tick} Stored ${envKey} in ${globalEnvPath()}`);
82
88
  const missing = missingModelRoles(provider);
83
89
  if (missing.length) {
84
90
  tag('warning').log(`No recommended ${missing.join(' and ')} for ${provider} — set the model ids in ${globalConfigPath()}`);
85
91
  }
86
92
  if (!apiKey && !process.env[envKey]) {
87
- tag('warning').log(`Add your API key to ${globalEnvPath()}`);
93
+ printApiKeySteps(provider, globalEnvPath());
88
94
  }
89
95
  log('');
90
- log('Explorbot now runs from any directory:');
91
- tag('substep').log(chalk.yellow(`${getCliName()} explore https://your-app.example.com`));
92
- tag('substep').log(chalk.yellow(`${getCliName()} sites`));
96
+ log(`${figureSet.star} What's next:`);
97
+ printInitNextSteps({
98
+ siteCommands: [
99
+ { label: 'Any site', command: `${getCliName()} explore https://your-app.example.com` },
100
+ { label: 'Saved sites', command: `${getCliName()} sites` },
101
+ ],
102
+ });
93
103
  }
94
104
  export function runInitCommand(options) {
95
105
  const provider = options.provider || 'openrouter';
@@ -125,34 +135,38 @@ export function runInitCommand(options) {
125
135
  log('Use --force to overwrite existing file');
126
136
  process.exit(1);
127
137
  }
128
- writeFileSync(outPath, defaultConfigTemplate(provider), 'utf8');
129
- log(`Created config file: ${relativeToCwd(outPath)}`);
138
+ writeFileSync(outPath, defaultConfigTemplate(provider, options.url), 'utf8');
139
+ tag('success').log(`${figureSet.tick} Created config file: ${relativeToCwd(outPath)}`);
130
140
  const envPath = resolve(process.cwd(), '.env');
131
141
  if (!existsSync(envPath)) {
132
142
  writeFileSync(envPath, `${envTemplate(provider)}\n`, 'utf8');
133
- log(`Created env file: ${relativeToCwd(envPath)}`);
143
+ tag('success').log(`${figureSet.tick} Created env file: ${relativeToCwd(envPath)}`);
134
144
  }
135
145
  else {
136
146
  log(`Env file already exists: ${relativeToCwd(envPath)}`);
137
147
  }
148
+ const envKey = PROVIDERS[provider].envKey;
149
+ if (options.apiKey) {
150
+ writeEnvKey(envKey, options.apiKey, envPath);
151
+ tag('success').log(`${figureSet.tick} Stored ${envKey} in ${relativeToCwd(envPath)}`);
152
+ }
153
+ if (!options.apiKey && !process.env[envKey]) {
154
+ printApiKeySteps(provider, relativeToCwd(envPath));
155
+ }
156
+ if (!options.url) {
157
+ tag('warning').log(`Set your application URL in web.url: ${relativeToCwd(outPath)}`);
158
+ }
138
159
  const missing = missingModelRoles(provider);
139
160
  if (missing.length) {
140
161
  tag('warning').log(`No recommended ${missing.join(' and ')} for ${provider} — set the model ids in ${relativeToCwd(outPath)}`);
141
162
  }
142
- log('');
143
- log('Next steps:');
144
- log('1. Configure AI provider in .env');
145
- log('2. Set AI models config file');
146
- log('3. Set web application URL in the config file');
147
- log('4. Add initial knowledge (how to authorize to the application, etc.)');
148
- tag('substep').log(chalk.yellow(`${getCliName()} learn '*' 'to authorize use these credentials: admin@example.com / secret123'`));
149
- tag('substep').log('You can use ${env.LOGIN} and ${env.PASSWORD} to reference environment variables.');
150
- log('5. Launch application on a relative URL');
151
- tag('substep').log(chalk.yellow(`${getCliName()} start /dashboard`));
152
163
  if (!existsSync('./output')) {
153
164
  mkdirSync('./output', { recursive: true });
154
- log('Created directory: output');
165
+ tag('success').log(`${figureSet.tick} Created directory: output`);
155
166
  }
167
+ log('');
168
+ log(`${figureSet.star} What's next:`);
169
+ printInitNextSteps({ url: options.url });
156
170
  }
157
171
  catch (error) {
158
172
  log('Failed to create config file:', error);
@@ -198,12 +212,12 @@ async function renderInitWizard(mode) {
198
212
  }), { exitOnCtrlC: false, patchConsole: false });
199
213
  });
200
214
  }
201
- async function renderLocalProviderWizard() {
215
+ async function renderLocalSetupWizard() {
202
216
  const [{ render }, React, InitWizard] = await Promise.all([import('ink'), import('react'), import('../components/InitWizard.js').then((m) => m.default)]);
203
217
  return new Promise((resolve) => {
204
- const finish = (provider) => {
218
+ const finish = (setup) => {
205
219
  unmount();
206
- resolve(provider);
220
+ resolve(setup);
207
221
  };
208
222
  const { unmount } = render(React.createElement(InitWizard, {
209
223
  mode: 'local',
@@ -211,7 +225,7 @@ async function renderLocalProviderWizard() {
211
225
  onLocal: () => finish(null),
212
226
  onComplete: () => finish(null),
213
227
  onCancel: () => finish(null),
214
- onLocalProvider: (provider) => finish(provider),
228
+ onLocalSetup: (setup) => finish(setup),
215
229
  }), { exitOnCtrlC: false, patchConsole: false });
216
230
  });
217
231
  }
@@ -238,7 +252,7 @@ function globalConfigTemplate(provider) {
238
252
  // Models are written as 'provider/model-id' so they resolve without a local node_modules.
239
253
  // The key is read from ${envKey} in ~/.explorbot/.env
240
254
  // Model ids are snapshotted from the recommendations of this Explorbot version.
241
- // https://github.com/testomatio/explorbot/blob/main/docs/basics/providers.md
255
+ // https://testomat.ai/docs/explorbot
242
256
  const config = {
243
257
  ai: {
244
258
  ${modelLines(provider)}
@@ -255,8 +269,39 @@ ${modelLines(provider)}
255
269
  export default config;
256
270
  `;
257
271
  }
258
- function writeEnvKey(key, value) {
259
- const envPath = globalEnvPath();
272
+ function printInitNextSteps(options) {
273
+ const cli = getCliName();
274
+ const sections = [
275
+ {
276
+ label: 'Try the demo on a sample app',
277
+ commands: [{ command: `${cli} explore https://todomvc.com/examples/react/dist/` }],
278
+ },
279
+ ];
280
+ if (options.url) {
281
+ sections.push({
282
+ label: 'Explore your app',
283
+ commands: [
284
+ { label: 'Whole site', command: `${cli} explore ${options.url}` },
285
+ { label: 'One page', command: `${cli} explore /dashboard` },
286
+ ],
287
+ });
288
+ }
289
+ if (options.siteCommands)
290
+ sections.push({ label: 'Explore any site', commands: options.siteCommands });
291
+ sections.push({
292
+ label: 'Run it from VS Code',
293
+ commands: [{ command: 'https://testomat.ai/explorbot' }],
294
+ }, {
295
+ label: 'Docs',
296
+ commands: [{ command: 'https://testomat.ai/docs/explorbot' }],
297
+ });
298
+ printNextSteps(sections);
299
+ log('');
300
+ log('Add knowledge (logins, credentials) — created on first use in ./knowledge:');
301
+ tag('substep').log(chalk.yellow(`${cli} learn '*' 'to authorize use these credentials: admin@example.com / secret123'`));
302
+ tag('substep').log('You can use ${env.LOGIN} and ${env.PASSWORD} to reference environment variables.');
303
+ }
304
+ function writeEnvKey(key, value, envPath = globalEnvPath()) {
260
305
  let content = '# AI provider API keys';
261
306
  if (existsSync(envPath))
262
307
  content = readFileSync(envPath, 'utf8').trimEnd();
@@ -268,3 +313,9 @@ function writeEnvKey(key, value) {
268
313
  lines[index] = `${key}=${value}`;
269
314
  writeFileSync(envPath, `${lines.join('\n').trimEnd()}\n`, 'utf8');
270
315
  }
316
+ function printApiKeySteps(provider, envPath) {
317
+ const { envKey, keyUrl } = PROVIDERS[provider];
318
+ tag('warning').log(`No API key yet — explorbot needs one to connect to ${provider}`);
319
+ tag('substep').log(`1. Create a key at ${keyUrl}`);
320
+ tag('substep').log(`2. Paste it into ${envPath}: ${envKey}=<your-key>`);
321
+ }
@@ -5,7 +5,11 @@ interface InitWizardProps {
5
5
  onLocal: () => void;
6
6
  onComplete: () => void;
7
7
  onCancel: () => void;
8
- onLocalProvider?: (provider: string) => void;
8
+ onLocalSetup?: (setup: {
9
+ provider: string;
10
+ apiKey: string;
11
+ url: string;
12
+ }) => void;
9
13
  }
10
14
  declare const InitWizard: React.FC<InitWizardProps>;
11
15
  export default InitWizard;
@@ -7,16 +7,23 @@ 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, onLocalProvider }) => {
10
+ const InitWizard = ({ mode, globalConfigExists, onLocal, onComplete, onCancel, onLocalSetup }) => {
11
11
  const [step, setStep] = useState(mode === 'choose' ? 'target' : 'provider');
12
12
  const [targetIndex, setTargetIndex] = useState(0);
13
13
  const [providerIndex, setProviderIndex] = useState(0);
14
14
  const [apiKey, setApiKey] = useState('');
15
+ const [url, setUrl] = useState('');
16
+ const [urlError, setUrlError] = useState('');
15
17
  const [status, setStatus] = useState('');
16
18
  const [error, setError] = useState('');
17
19
  const provider = PROVIDER_NAMES[providerIndex];
18
20
  const envKey = PROVIDERS[provider].envKey;
19
21
  const save = () => {
22
+ if (mode === 'local') {
23
+ setStatus('');
24
+ setStep('url');
25
+ return;
26
+ }
20
27
  writeGlobalConfig(provider, apiKey.trim());
21
28
  onComplete();
22
29
  };
@@ -45,7 +52,7 @@ const InitWizard = ({ mode, globalConfigExists, onLocal, onComplete, onCancel, o
45
52
  onCancel();
46
53
  return;
47
54
  }
48
- if (step === 'key')
55
+ if (step === 'key' || step === 'url')
49
56
  return;
50
57
  if (key.escape) {
51
58
  onCancel();
@@ -67,12 +74,8 @@ const InitWizard = ({ mode, globalConfigExists, onLocal, onComplete, onCancel, o
67
74
  setProviderIndex((index) => Math.max(0, index - 1));
68
75
  if (key.downArrow)
69
76
  setProviderIndex((index) => Math.min(PROVIDER_NAMES.length - 1, index + 1));
70
- if (key.return) {
71
- if (mode === 'local')
72
- onLocalProvider?.(provider);
73
- else
74
- setStep('key');
75
- }
77
+ if (key.return)
78
+ setStep('key');
76
79
  return;
77
80
  }
78
81
  if (status)
@@ -109,10 +112,12 @@ const InitWizard = ({ mode, globalConfigExists, onLocal, onComplete, onCancel, o
109
112
  React.createElement(Text, null,
110
113
  "Enter the API key for ",
111
114
  provider,
112
- " ",
115
+ ' ',
113
116
  React.createElement(Text, { dimColor: true },
114
117
  "(stored as ",
115
118
  envKey,
119
+ ", create one at ",
120
+ PROVIDERS[provider].keyUrl,
116
121
  ")")),
117
122
  React.createElement(Box, { borderStyle: "single", borderColor: "blue", paddingX: 1 },
118
123
  React.createElement(InputReadline, { value: apiKey, onChange: setApiKey, onSubmit: () => setStep('validate'), placeholder: process.env[envKey] ? 'leave empty to keep the key from your environment' : 'paste the key', isActive: true, showPrompt: false, mask: true })))),
@@ -121,13 +126,26 @@ const InitWizard = ({ mode, globalConfigExists, onLocal, onComplete, onCancel, o
121
126
  status && React.createElement(Text, { color: "yellow" }, status),
122
127
  error && (React.createElement(Box, { flexDirection: "column" },
123
128
  React.createElement(Text, { color: "red" }, error),
124
- React.createElement(Text, { dimColor: true }, "r: re-enter the key | n: save anyway"))))),
129
+ React.createElement(Text, { dimColor: true }, "r: re-enter the key | n: continue anyway"))))),
130
+ step === 'url' && (React.createElement(Box, { flexDirection: "column" },
131
+ React.createElement(Text, null,
132
+ "What is the URL of the web application to test? ",
133
+ React.createElement(Text, { dimColor: true }, "(written to web.url in the config)")),
134
+ React.createElement(Box, { borderStyle: "single", borderColor: "blue", paddingX: 1 },
135
+ React.createElement(InputReadline, { value: url, onChange: setUrl, onSubmit: () => {
136
+ if (!URL.canParse(url.trim())) {
137
+ setUrlError('Enter a full URL, e.g. http://localhost:3000');
138
+ return;
139
+ }
140
+ onLocalSetup?.({ provider, apiKey: apiKey.trim(), url: url.trim() });
141
+ }, placeholder: "http://localhost:3000", isActive: true, showPrompt: false })),
142
+ urlError && React.createElement(Text, { color: "red" }, urlError))),
125
143
  React.createElement(Box, { marginTop: 1 },
126
144
  React.createElement(Text, { dimColor: true },
127
145
  "Config goes to ",
128
146
  mode === 'local' ? 'the current directory' : globalDir(),
129
147
  " | ",
130
- step === 'key' ? 'Enter: continue' : '↑↓: select | Enter: confirm',
148
+ step === 'key' || step === 'url' ? 'Enter: continue' : '↑↓: select | Enter: confirm',
131
149
  " | Ctrl+C: exit"))));
132
150
  };
133
151
  function defaultModelId(provider) {
@@ -141,6 +141,7 @@ interface AgentsConfig {
141
141
  }
142
142
  interface AIConfig {
143
143
  model: any;
144
+ recommendedModels?: string;
144
145
  apiKey?: string;
145
146
  config?: Record<string, any>;
146
147
  langfuse?: {
@@ -315,6 +316,7 @@ interface ConfiguredModel {
315
316
  }
316
317
  interface ProviderInfo {
317
318
  envKey: string;
319
+ keyUrl: string;
318
320
  load: () => Promise<(modelId: string) => any>;
319
321
  }
320
322
  interface EnvVar {
@@ -18,13 +18,13 @@ import { getCliName } from './utils/cli-name.js';
18
18
  import { deepMerge } from './utils/merge.js';
19
19
  import { log, tag } from './utils/logger.js';
20
20
  export const PROVIDERS = {
21
- openai: { envKey: 'OPENAI_API_KEY', load: async () => (await import('@ai-sdk/openai')).createOpenAI() },
22
- anthropic: { envKey: 'ANTHROPIC_API_KEY', load: async () => (await import('@ai-sdk/anthropic')).createAnthropic() },
23
- google: { envKey: 'GOOGLE_GENERATIVE_AI_API_KEY', load: async () => (await import('@ai-sdk/google')).createGoogleGenerativeAI() },
24
- groq: { envKey: 'GROQ_API_KEY', load: async () => (await import('@ai-sdk/groq')).createGroq() },
25
- mistral: { envKey: 'MISTRAL_API_KEY', load: async () => (await import('@ai-sdk/mistral')).createMistral() },
26
- openrouter: { envKey: 'OPENROUTER_API_KEY', load: async () => (await import('@openrouter/ai-sdk-provider')).createOpenRouter() },
27
- sambanova: { envKey: 'SAMBANOVA_API_KEY', load: async () => (await import('sambanova-ai-provider')).createSambaNova() },
21
+ openai: { envKey: 'OPENAI_API_KEY', keyUrl: 'https://platform.openai.com/api-keys', load: async () => (await import('@ai-sdk/openai')).createOpenAI() },
22
+ anthropic: { envKey: 'ANTHROPIC_API_KEY', keyUrl: 'https://console.anthropic.com/settings/keys', load: async () => (await import('@ai-sdk/anthropic')).createAnthropic() },
23
+ google: { envKey: 'GOOGLE_GENERATIVE_AI_API_KEY', keyUrl: 'https://aistudio.google.com/apikey', load: async () => (await import('@ai-sdk/google')).createGoogleGenerativeAI() },
24
+ groq: { envKey: 'GROQ_API_KEY', keyUrl: 'https://console.groq.com/keys', load: async () => (await import('@ai-sdk/groq')).createGroq() },
25
+ mistral: { envKey: 'MISTRAL_API_KEY', keyUrl: 'https://console.mistral.ai/api-keys', load: async () => (await import('@ai-sdk/mistral')).createMistral() },
26
+ openrouter: { envKey: 'OPENROUTER_API_KEY', keyUrl: 'https://openrouter.ai/keys', load: async () => (await import('@openrouter/ai-sdk-provider')).createOpenRouter() },
27
+ sambanova: { envKey: 'SAMBANOVA_API_KEY', keyUrl: 'https://cloud.sambanova.ai/apis', load: async () => (await import('sambanova-ai-provider')).createSambaNova() },
28
28
  };
29
29
  export const MODEL_ROLES = ['model', 'visionModel', 'agenticModel'];
30
30
  let cachedOutputRoot = null;
@@ -572,11 +572,33 @@ export async function resolveConfigModels(ai) {
572
572
  if (typeof ai[role] === 'string')
573
573
  ai[role] = await resolveModel(ai[role], role);
574
574
  }
575
+ await fillRecommendedModels(ai);
575
576
  for (const agent of Object.values(ai.agents || {})) {
576
577
  if (typeof agent?.model === 'string')
577
578
  agent.model = await resolveModel(agent.model);
578
579
  }
579
580
  }
581
+ async function fillRecommendedModels(ai) {
582
+ const provider = ai.recommendedModels;
583
+ if (!provider)
584
+ return;
585
+ const recommended = ConfigParser.recommendedModels()[provider];
586
+ if (!recommended) {
587
+ throw new Error(`No recommended models for "${provider}". Use a provider with recommendations: ${Object.keys(ConfigParser.recommendedModels()).join(', ')}`);
588
+ }
589
+ for (const role of MODEL_ROLES) {
590
+ if (ai[role])
591
+ continue;
592
+ if (!recommended[role])
593
+ continue;
594
+ ai[role] = await resolveModel(provider, role);
595
+ }
596
+ if (!ai.decisionModel && recommended.decisionModel)
597
+ ai.decisionModel = { provider, model: recommended.decisionModel };
598
+ const missing = missingModelRoles(provider);
599
+ if (missing.length)
600
+ tag('warning').log(`No recommended ${missing.join(' and ')} for ${provider} — set them explicitly as "provider/model-id"`);
601
+ }
580
602
  export function resolveLangfuse(ai) {
581
603
  if (!ai)
582
604
  return;
@@ -97,7 +97,7 @@ export declare class ExplorBot {
97
97
  agentTester(): Tester;
98
98
  agentCaptain(): Captain;
99
99
  agentExperienceCompactor(): ExperienceCompactor;
100
- agentQuartermaster(): Quartermaster | null;
100
+ agentQuartermaster(): Quartermaster;
101
101
  agentHistorian(): Historian;
102
102
  agentRerunner(): Rerunner;
103
103
  agentDriller(): Driller;
@@ -111,6 +111,7 @@ export class ExplorBot {
111
111
  if (this.options.optionalAi)
112
112
  return this.bootstrapOptionalProvider();
113
113
  this.provider = new AIProvider(this.config.ai);
114
+ this.provider.setJudge(this.judge());
114
115
  await this.provider.validateConnection();
115
116
  }
116
117
  aiFailureReason() {
@@ -217,9 +218,11 @@ export class ExplorBot {
217
218
  const tools = createAgentTools({ ...deps, researcher, navigator, withExperience: false });
218
219
  return new Tester(deps, researcher, navigator, tools);
219
220
  });
220
- const qm = this.agentQuartermaster();
221
- if (qm)
222
- this.agents.tester.setQuartermaster(qm);
221
+ if (this.config.ai?.agents?.quartermaster?.enabled === true) {
222
+ const quartermaster = this.agentQuartermaster();
223
+ quartermaster.start(this.stateManager());
224
+ this.agents.tester.setQuartermaster(quartermaster);
225
+ }
223
226
  if (this.isHistorianEnabled())
224
227
  this.agents.tester.setHistorian(this.agentHistorian());
225
228
  this.agents.tester.setPilot(this.agentPilot());
@@ -240,16 +243,7 @@ export class ExplorBot {
240
243
  return (this.agents.experienceCompactor ||= new ExperienceCompactor(this.provider, this.experienceTracker()));
241
244
  }
242
245
  agentQuartermaster() {
243
- const config = this.config.ai?.agents?.quartermaster;
244
- if (config?.enabled !== true)
245
- return null;
246
- if (!this.agents.quartermaster) {
247
- this.agents.quartermaster = new Quartermaster(this.provider, {
248
- model: config?.model,
249
- });
250
- this.agents.quartermaster.start(this.explorer, this.stateManager());
251
- }
252
- return this.agents.quartermaster;
246
+ return (this.agents.quartermaster ||= new Quartermaster(this.provider, this.explorer, { model: this.config.ai?.agents?.quartermaster?.model }));
253
247
  }
254
248
  agentHistorian() {
255
249
  return (this.agents.historian ||= this.createAgent(({ ai, explorer, config, stateManager, playwrightRecorder }) => {
@@ -500,6 +494,7 @@ export class ExplorBot {
500
494
  async bootstrapOptionalProvider() {
501
495
  try {
502
496
  const provider = new AIProvider(this.config.ai);
497
+ provider.setJudge(this.judge());
503
498
  await provider.validateConnection();
504
499
  this.provider = provider;
505
500
  }
@@ -74,6 +74,7 @@ class Explorer {
74
74
  throw new Error('Playwright helper not available');
75
75
  }
76
76
  this.applyBasePathToHelper();
77
+ codeceptjs.store.visibleLocator = this.playwrightHelper.options?.visibleLocator;
77
78
  await this.connectOrLaunchBrowser();
78
79
  const hasSession = !this.options?.attachedBrowser && this.options?.session && existsSync(this.options.session);
79
80
  await this.openContextPage();
@@ -13,6 +13,7 @@ import { pathToFileURL } from 'node:url';
13
13
  import dedent from 'dedent';
14
14
  const GLOBAL_CONFIG_NAMES = ['config.js', 'config.mjs', 'config.ts'];
15
15
  const SITE_DIRS = ['knowledge', 'experience', 'output'];
16
+ const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '[::1]'];
16
17
  export const EXPLORBOT_CONFIG_PATHS = ['explorbot.config.js', 'explorbot.config.mjs', 'explorbot.config.ts'];
17
18
  export function globalDir() {
18
19
  return join(os.homedir(), '.explorbot');
@@ -33,7 +34,10 @@ export function sitesDir() {
33
34
  return join(globalDir(), 'sites');
34
35
  }
35
36
  export function siteFolderName(url) {
36
- return new URL(url).host.toLowerCase().replace(/[^a-z0-9._-]/g, '_');
37
+ const parsed = new URL(url);
38
+ if (LOOPBACK_HOSTS.includes(parsed.hostname))
39
+ parsed.hostname = 'localhost';
40
+ return parsed.host.toLowerCase().replace(/[^a-z0-9._-]/g, '_');
37
41
  }
38
42
  export function listSites() {
39
43
  if (!existsSync(sitesDir()))
@@ -74,7 +78,7 @@ function validateSiteConfig(config, configPath, baseUrl) {
74
78
  `);
75
79
  }
76
80
  const declared = URL.parse(url)?.origin;
77
- if (declared === baseUrl)
81
+ if (declared && siteFolderName(declared) === siteFolderName(baseUrl))
78
82
  return;
79
83
  throw new Error(dedent `
80
84
  Site config declares a different site.
@@ -0,0 +1,25 @@
1
+ export declare const UX_FOCUS_ATTR = "data-explorbot-ux";
2
+ declare const UX_LIMITS: {
3
+ touchTarget: number;
4
+ pointerTarget: number;
5
+ touchWidth: number;
6
+ targetGap: number;
7
+ minFontSize: number;
8
+ minLineHeight: number;
9
+ maxEntries: number;
10
+ minOpacity: 0.1;
11
+ };
12
+ export declare function uxProbeArgs(scope: string): UxProbeArgs;
13
+ export declare function renderUxMeasurements(measurements: UxMeasurements): string;
14
+ export declare function measureUx({ scope, controlsSelector, limits }: UxProbeArgs): UxMeasurements;
15
+ export declare function countRunningAnimations(scope: string): number;
16
+ interface UxProbeArgs {
17
+ scope: string;
18
+ controlsSelector: string;
19
+ limits: typeof UX_LIMITS;
20
+ }
21
+ export interface UxMeasurements {
22
+ width: number;
23
+ checks: Record<string, string[]>;
24
+ }
25
+ export {};