explorbot 0.4.10 → 0.5.1

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 (148) hide show
  1. package/README.md +4 -1
  2. package/bin/explorbot-cli.ts +2 -1
  3. package/bin/mdq.ts +18 -0
  4. package/boat/api-tester/src/ai/chief.ts +72 -0
  5. package/boat/api-tester/src/api-client.ts +37 -0
  6. package/boat/prima/README.md +31 -4
  7. package/boat/prima/bin/prima-cli.ts +2 -1
  8. package/boat/prima/src/prima.ts +41 -2
  9. package/dist/bin/explorbot-cli.js +2 -1
  10. package/dist/bin/mdq.js +19 -0
  11. package/dist/boat/api-tester/src/ai/chief.js +69 -0
  12. package/dist/boat/api-tester/src/api-client.js +26 -0
  13. package/dist/boat/prima/bin/prima-cli.js +2 -1
  14. package/dist/boat/prima/src/prima.js +42 -2
  15. package/dist/package.json +4 -3
  16. package/dist/src/ai/agent.d.ts +3 -1
  17. package/dist/src/ai/judge-provider.d.ts +17 -0
  18. package/dist/src/ai/judge-provider.js +56 -0
  19. package/dist/src/ai/judge-tool.d.ts +2 -0
  20. package/dist/src/ai/judge-tool.js +33 -0
  21. package/dist/src/ai/judge.d.ts +28 -0
  22. package/dist/src/ai/judge.js +71 -0
  23. package/dist/src/ai/navigator.d.ts +3 -0
  24. package/dist/src/ai/navigator.js +13 -4
  25. package/dist/src/ai/pilot.d.ts +4 -0
  26. package/dist/src/ai/pilot.js +57 -5
  27. package/dist/src/ai/planner.js +9 -6
  28. package/dist/src/ai/provider.d.ts +4 -1
  29. package/dist/src/ai/provider.js +53 -8
  30. package/dist/src/ai/researcher/deep-analysis.js +2 -2
  31. package/dist/src/ai/researcher/locators.js +2 -2
  32. package/dist/src/ai/researcher/pagination.js +1 -1
  33. package/dist/src/ai/researcher/research-result.js +2 -2
  34. package/dist/src/ai/researcher.js +1 -1
  35. package/dist/src/ai/rules.js +13 -0
  36. package/dist/src/ai/task-agent.d.ts +2 -0
  37. package/dist/src/ai/task-agent.js +3 -1
  38. package/dist/src/ai/tester.js +19 -15
  39. package/dist/src/ai/tools.d.ts +4 -3
  40. package/dist/src/ai/tools.js +35 -7
  41. package/dist/src/api/request-result.js +2 -1
  42. package/dist/src/command-handler.d.ts +1 -0
  43. package/dist/src/command-handler.js +24 -3
  44. package/dist/src/commands/base-command.d.ts +5 -0
  45. package/dist/src/commands/base-command.js +3 -0
  46. package/dist/src/commands/explore-command.d.ts +2 -1
  47. package/dist/src/commands/explore-command.js +12 -1
  48. package/dist/src/commands/freesail-command.js +8 -2
  49. package/dist/src/commands/init-command.js +1 -1
  50. package/dist/src/commands/navigate-command.d.ts +2 -1
  51. package/dist/src/commands/navigate-command.js +6 -0
  52. package/dist/src/commands/options/decision-model-option.d.ts +6 -0
  53. package/dist/src/commands/options/decision-model-option.js +10 -0
  54. package/dist/src/commands/options/index.d.ts +2 -0
  55. package/dist/src/commands/options/index.js +2 -0
  56. package/dist/src/commands/plan-load-command.d.ts +2 -1
  57. package/dist/src/commands/plan-load-command.js +4 -0
  58. package/dist/src/commands/plans-command.d.ts +3 -9
  59. package/dist/src/commands/plans-command.js +11 -21
  60. package/dist/src/commands/rerun-command.d.ts +2 -1
  61. package/dist/src/commands/rerun-command.js +5 -1
  62. package/dist/src/commands/research-command.d.ts +2 -1
  63. package/dist/src/commands/research-command.js +6 -0
  64. package/dist/src/commands/test-command.d.ts +2 -1
  65. package/dist/src/commands/test-command.js +4 -1
  66. package/dist/src/components/Autocomplete.js +26 -12
  67. package/dist/src/components/InputReadline.js +10 -1
  68. package/dist/src/config.d.ts +7 -0
  69. package/dist/src/config.js +11 -0
  70. package/dist/src/experience-tracker.js +4 -3
  71. package/dist/src/explorbot.d.ts +3 -0
  72. package/dist/src/explorbot.js +8 -0
  73. package/dist/src/knowledge-tracker.js +1 -1
  74. package/dist/src/state-manager.d.ts +2 -0
  75. package/dist/src/state-manager.js +16 -0
  76. package/dist/src/test-plan.d.ts +11 -0
  77. package/dist/src/test-plan.js +54 -2
  78. package/dist/src/utils/aria-ref.js +1 -1
  79. package/dist/src/utils/logger.js +8 -2
  80. package/dist/src/utils/markdown-query.d.ts +1 -48
  81. package/dist/src/utils/markdown-query.js +1 -444
  82. package/dist/src/utils/mdq/cli.d.ts +6 -0
  83. package/dist/src/utils/mdq/cli.js +122 -0
  84. package/dist/src/utils/mdq/edit.d.ts +24 -0
  85. package/dist/src/utils/mdq/edit.js +147 -0
  86. package/dist/src/utils/mdq/query.d.ts +118 -0
  87. package/dist/src/utils/mdq/query.js +451 -0
  88. package/dist/src/utils/strings.d.ts +1 -0
  89. package/dist/src/utils/strings.js +7 -0
  90. package/dist/src/utils/test-files.d.ts +1 -0
  91. package/dist/src/utils/test-files.js +5 -2
  92. package/docs/api-testing/planning.md +1 -1
  93. package/docs/basics/providers.md +37 -0
  94. package/docs/index.json +1 -0
  95. package/docs/reference/commands.md +1 -0
  96. package/docs/reference/configuration.md +2 -0
  97. package/docs/superpowers/plans/2026-09-15-mdq-package.md +130 -94
  98. package/docs/superpowers/specs/2026-09-18-judge-decision-model-design.md +79 -0
  99. package/docs/web-testing/decisions.md +108 -0
  100. package/docs/workflow/agentic-usage.md +1 -0
  101. package/package.json +4 -3
  102. package/src/ai/agent.ts +3 -1
  103. package/src/ai/judge-provider.ts +62 -0
  104. package/src/ai/judge-tool.ts +35 -0
  105. package/src/ai/judge.ts +75 -0
  106. package/src/ai/navigator.ts +15 -4
  107. package/src/ai/pilot.ts +58 -5
  108. package/src/ai/planner.ts +9 -6
  109. package/src/ai/provider.ts +52 -8
  110. package/src/ai/researcher/deep-analysis.ts +2 -2
  111. package/src/ai/researcher/locators.ts +2 -2
  112. package/src/ai/researcher/pagination.ts +1 -1
  113. package/src/ai/researcher/research-result.ts +2 -2
  114. package/src/ai/researcher.ts +1 -1
  115. package/src/ai/rules.ts +13 -0
  116. package/src/ai/task-agent.ts +4 -1
  117. package/src/ai/tester.ts +19 -16
  118. package/src/ai/tools.ts +42 -7
  119. package/src/api/request-result.ts +2 -1
  120. package/src/command-handler.ts +28 -3
  121. package/src/commands/base-command.ts +9 -0
  122. package/src/commands/explore-command.ts +15 -2
  123. package/src/commands/freesail-command.ts +9 -2
  124. package/src/commands/init-command.ts +1 -1
  125. package/src/commands/navigate-command.ts +8 -1
  126. package/src/commands/options/decision-model-option.ts +11 -0
  127. package/src/commands/options/index.ts +2 -0
  128. package/src/commands/plan-load-command.ts +6 -1
  129. package/src/commands/plans-command.ts +13 -29
  130. package/src/commands/rerun-command.ts +7 -2
  131. package/src/commands/research-command.ts +8 -1
  132. package/src/commands/test-command.ts +6 -2
  133. package/src/components/Autocomplete.tsx +39 -10
  134. package/src/components/InputReadline.tsx +10 -1
  135. package/src/config.ts +11 -0
  136. package/src/experience-tracker.ts +4 -3
  137. package/src/explorbot.ts +8 -0
  138. package/src/knowledge-tracker.ts +1 -1
  139. package/src/state-manager.ts +16 -0
  140. package/src/test-plan.ts +67 -2
  141. package/src/utils/aria-ref.ts +1 -1
  142. package/src/utils/logger.ts +6 -1
  143. package/src/utils/markdown-query.ts +1 -519
  144. package/src/utils/mdq/cli.ts +118 -0
  145. package/src/utils/mdq/edit.ts +158 -0
  146. package/src/utils/mdq/query.ts +556 -0
  147. package/src/utils/strings.ts +7 -0
  148. package/src/utils/test-files.ts +5 -2
package/README.md CHANGED
@@ -1,5 +1,8 @@
1
1
  <p align="center">
2
- <img src="assets/explorbot-logo.png" alt="Explorbot" width="560">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="assets/logos/explorbot/explorbot-for-dark-theme.svg">
4
+ <img src="assets/logos/explorbot/explorbot-for-light-theme.svg" alt="Explorbot" width="560">
5
+ </picture>
3
6
  </p>
4
7
 
5
8
  <p align="center"><b>The vibe-testing agent for web applications.</b></p>
@@ -12,7 +12,7 @@ import { HelpJsonCommand } from '../src/commands/help-json-command.js';
12
12
  import { RecommendedModelsCommand } from '../src/commands/recommended-models-command.js';
13
13
  import { App } from '../src/components/App.js';
14
14
  import { StatusPane } from '../src/components/StatusPane.js';
15
- import { knowledgeOption, wsOption } from '../src/commands/options/index.js';
15
+ import { decisionModelOption, knowledgeOption, wsOption } from '../src/commands/options/index.js';
16
16
  import { ConfigParser, EXPLORBOT_ENV_VARS, PROVIDERS } from '../src/config.js';
17
17
  import { ExplorBot, type ExplorBotOptions } from '../src/explorbot.js';
18
18
  import { remote } from '../src/remote.js';
@@ -33,6 +33,7 @@ const pkgVersion = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version as stri
33
33
  program.name(cli).description('AI-powered web exploration tool').version(pkgVersion, '-V, --version');
34
34
  wsOption.register(program);
35
35
  knowledgeOption.register(program);
36
+ decisionModelOption.register(program);
36
37
 
37
38
  process.on('uncaughtException', async (error) => {
38
39
  tag('error').log(`Uncaught exception: ${error instanceof Error ? `${error.message}\n${error.stack}` : String(error)}`);
package/bin/mdq.ts ADDED
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ import { runMdq } from '../src/utils/mdq/cli.ts';
3
+
4
+ async function readStdin(): Promise<string> {
5
+ if (process.stdin.isTTY) return '';
6
+ process.stdin.setEncoding('utf8');
7
+ let text = '';
8
+ for await (const chunk of process.stdin) text += chunk;
9
+ return text;
10
+ }
11
+
12
+ const result = await runMdq(process.argv.slice(2), readStdin);
13
+ if (result.output) {
14
+ let text = result.output;
15
+ if (!text.endsWith('\n')) text = `${text}\n`;
16
+ process.stdout.write(text);
17
+ }
18
+ process.exit(result.code);
@@ -8,6 +8,7 @@ import { Observability } from '../../../../src/observability.ts';
8
8
  import { Plan, Test } from '../../../../src/test-plan.ts';
9
9
  import { createDebug, tag } from '../../../../src/utils/logger.ts';
10
10
  import { RulesLoader } from '../../../../src/utils/rules-loader.ts';
11
+ import { isSecretName, registerSecret } from '../../../../src/utils/secrets.ts';
11
12
  import type { ApiClient } from '../api-client.ts';
12
13
  import type { ApibotConfig } from '../config.ts';
13
14
  import { getActiveStyle, getStyles } from './chief/styles.ts';
@@ -28,6 +29,21 @@ const ApiTasksSchema = z.object({
28
29
  .describe('List of testing scenarios'),
29
30
  });
30
31
 
32
+ const NamedValueSchema = z.object({ name: z.string(), value: z.string() });
33
+
34
+ const RequestDefaultsSchema = z.object({
35
+ files: z
36
+ .array(
37
+ z.object({
38
+ file: z.number().describe('Number of the knowledge file the values come from'),
39
+ headers: z.array(NamedValueSchema).describe('Headers to send, each value exactly as it goes on the wire'),
40
+ query: z.array(NamedValueSchema).describe('Query parameters to send'),
41
+ body: z.array(NamedValueSchema).describe('Fields a request body must contain, each value as a JSON literal'),
42
+ })
43
+ )
44
+ .describe('One entry per knowledge file that names values to send with requests'),
45
+ });
46
+
31
47
  const ChiefBase = WithSessionDedup(Object as unknown as new (...args: any[]) => object);
32
48
 
33
49
  export class Chief extends ChiefBase {
@@ -37,6 +53,7 @@ export class Chief extends ChiefBase {
37
53
  private knowledgeTracker?: KnowledgeTracker;
38
54
  currentPlan: Plan | null = null;
39
55
  private lastStyleName = '';
56
+ private readKnowledgeFiles = new Set<string>();
40
57
 
41
58
  MIN_TASKS = 3;
42
59
  MAX_TASKS = 10;
@@ -56,6 +73,7 @@ export class Chief extends ChiefBase {
56
73
  debugLog('Sending planning prompt to AI provider');
57
74
 
58
75
  await Observability.run(`chief: ${endpoint}`, { tags: ['chief'], sessionId: endpoint }, async () => {
76
+ await this.prepareRequestDefaults(endpoint);
59
77
  const sampleData = await this.collectSampleData(endpoint);
60
78
  const conversation = this.buildConversation(endpoint, opts?.style, sampleData);
61
79
 
@@ -127,6 +145,52 @@ export class Chief extends ChiefBase {
127
145
  }
128
146
  }
129
147
 
148
+ private async prepareRequestDefaults(endpoint: string): Promise<void> {
149
+ if (!this.apiClient) return;
150
+ const files = (this.knowledgeTracker?.getEndpointKnowledge(endpoint) || []).filter((knowledge) => !this.readKnowledgeFiles.has(knowledge.filePath));
151
+ if (!files.length) return;
152
+
153
+ const knowledgeFiles = files.map((knowledge, index) => `<file number="${index + 1}" endpoint="${knowledge.endpoint}">\n${knowledge.content}\n</file>`).join('\n\n');
154
+ const prompt = dedent`
155
+ <task>
156
+ Extract the values these knowledge files say must be sent with API requests:
157
+ - headers: authentication, API keys and other headers, written exactly as sent (include the auth scheme when one is named)
158
+ - query: query parameters requests need
159
+ - body: fields request bodies must contain, each value written as a JSON literal
160
+ Copy values exactly as written. Never invent a value. Skip facts that are not about what to send.
161
+ </task>
162
+
163
+ <knowledge_files>
164
+ ${knowledgeFiles}
165
+ </knowledge_files>
166
+ `;
167
+
168
+ const result = await this.provider.generateObject([{ role: 'user', content: prompt }], RequestDefaultsSchema, this.provider.getModelForAgent('chief'), { agentName: 'chief' }).catch((error: Error) => {
169
+ tag('warning').log(`Could not read request defaults from knowledge: ${error.message}`);
170
+ return null;
171
+ });
172
+ if (!result) return;
173
+ for (const knowledge of files) this.readKnowledgeFiles.add(knowledge.filePath);
174
+
175
+ const entries: z.infer<typeof RequestDefaultsSchema>['files'] = result.object?.files || [];
176
+ for (const entry of entries) {
177
+ const knowledge = files[entry.file - 1];
178
+ if (!knowledge?.endpoint) continue;
179
+
180
+ const headers = Object.fromEntries(entry.headers.map(({ name, value }) => [name, value]));
181
+ const query = Object.fromEntries(entry.query.map(({ name, value }) => [name, value]));
182
+ const body = Object.fromEntries(entry.body.map(({ name, value }) => [name, parseJsonLiteral(value)]));
183
+ const sent = Object.entries({ headers, query, body }).filter(([, values]) => Object.keys(values).length);
184
+ if (!sent.length) continue;
185
+
186
+ for (const [name, value] of sent.flatMap(([, values]) => Object.entries(values))) {
187
+ if (isSecretName(name)) registerSecret(String(value));
188
+ }
189
+ this.apiClient.addRequestDefaults({ pattern: knowledge.endpoint, headers, query, body });
190
+ tag('info').log(`Request defaults from knowledge for ${knowledge.endpoint}: ${sent.map(([kind, values]) => `${kind} ${Object.keys(values).join(', ')}`).join('; ')}`);
191
+ }
192
+ }
193
+
130
194
  private async collectSampleData(endpoint: string): Promise<string> {
131
195
  if (!this.apiClient) return '';
132
196
 
@@ -341,3 +405,11 @@ export class Chief extends ChiefBase {
341
405
  `;
342
406
  }
343
407
  }
408
+
409
+ function parseJsonLiteral(value: string): unknown {
410
+ try {
411
+ return JSON.parse(value);
412
+ } catch {
413
+ return value;
414
+ }
415
+ }
@@ -1,9 +1,14 @@
1
1
  import { ApiClient as BaseApiClient } from '../../../src/api/api-client.ts';
2
+ import type { RequestResult } from '../../../src/api/request-result.ts';
3
+ import { extractStatePath, matchesUrl } from '../../../src/utils/url-matcher.ts';
2
4
  import type { HookFn } from './config.ts';
3
5
 
6
+ const BODY_METHODS = ['POST', 'PUT', 'PATCH'];
7
+
4
8
  export class ApiClient extends BaseApiClient {
5
9
  private bootstrapHook?: HookFn;
6
10
  private teardownHook?: HookFn;
11
+ private requestDefaults: RequestDefaults[] = [];
7
12
 
8
13
  constructor(baseEndpoint: string, defaultHeaders: Record<string, string> = {}, hooks?: { bootstrap?: HookFn; teardown?: HookFn }) {
9
14
  super(baseEndpoint, defaultHeaders);
@@ -25,4 +30,36 @@ export class ApiClient extends BaseApiClient {
25
30
  const ctx = { headers: this.getHeaders(), baseEndpoint: this.getBaseEndpoint() };
26
31
  await this.teardownHook(ctx);
27
32
  }
33
+
34
+ addRequestDefaults(defaults: RequestDefaults): void {
35
+ this.requestDefaults.push(defaults);
36
+ }
37
+
38
+ override async request(opts: Parameters<BaseApiClient['request']>[0]): Promise<RequestResult> {
39
+ const path = extractStatePath(opts.path);
40
+ const matching = this.requestDefaults.filter((defaults) => matchesUrl(defaults.pattern, path));
41
+ if (!matching.length) return super.request(opts);
42
+
43
+ const knowledgeHeaders: Record<string, string> = {};
44
+ for (const defaults of matching) {
45
+ for (const [name, value] of Object.entries(defaults.headers)) knowledgeHeaders[name.toLowerCase()] = value;
46
+ }
47
+ for (const name of Object.keys({ ...this.getHeaders(), ...opts.headers })) delete knowledgeHeaders[name.toLowerCase()];
48
+
49
+ const headers = { ...knowledgeHeaders, ...this.getHeaders(), ...opts.headers };
50
+ const queryParams = Object.assign({}, ...matching.map((defaults) => defaults.query), opts.queryParams);
51
+ let body = opts.body;
52
+ if (BODY_METHODS.includes(opts.method.toUpperCase()) && body && typeof body === 'object' && !Array.isArray(body)) {
53
+ body = Object.assign({}, ...matching.map((defaults) => defaults.body), body);
54
+ }
55
+
56
+ return super.request({ ...opts, headers, queryParams, body });
57
+ }
58
+ }
59
+
60
+ export interface RequestDefaults {
61
+ pattern: string;
62
+ headers: Record<string, string>;
63
+ query: Record<string, string>;
64
+ body: Record<string, unknown>;
28
65
  }
@@ -61,22 +61,49 @@ prima-cli browser stop
61
61
 
62
62
  Prima needs a model, and takes it from the environment. There is no `init` to run and nothing is written for you.
63
63
 
64
+ The setup we recommend runs everything through OpenRouter with one key: GPT-OSS 120B served by Groq as the main model, and TypeSafe's Jev as the [decision model](https://github.com/testomatio/explorbot/blob/main/docs/web-testing/decisions.md), which answers narrow yes/no and pick-one questions so the main model is called less often.
65
+
64
66
  ```bash
65
- export PRIMA_CLI_AI_MODEL=openrouter/openai/gpt-oss-120b
66
- export OPENROUTER_API_KEY=your-key
67
+ PRIMA_CLI_AI_MODEL=openrouter/openai/gpt-oss-120b:nitro
68
+ PRIMA_CLI_DECISION_MODEL=openrouter/typesafe/jev-1.13
69
+ OPENROUTER_API_KEY=your-key
67
70
  ```
68
71
 
69
- The provider comes from the model name, so that is the whole setup. Fish uses `set -gx` instead of `export`. Since prima is usually run by a coding agent, the better home is the agent's own config — in `~/.claude/settings.json`, an `env` block reaches every command the agent runs:
72
+ The provider comes from each model name, and `:nitro` asks OpenRouter for its fastest host, which is Groq for this model. Jev is optional: leave `PRIMA_CLI_DECISION_MODEL` unset and prima never calls it, or pass `--decision-model` for a single run.
73
+
74
+ Prima is usually run by a coding agent, so the best home for these variables is the agent's own config, where they reach every command the agent runs.
75
+
76
+ Claude Code, in `~/.claude/settings.json`:
70
77
 
71
78
  ```json
72
79
  {
73
80
  "env": {
74
- "PRIMA_CLI_AI_MODEL": "openrouter/openai/gpt-oss-120b",
81
+ "PRIMA_CLI_AI_MODEL": "openrouter/openai/gpt-oss-120b:nitro",
82
+ "PRIMA_CLI_DECISION_MODEL": "openrouter/typesafe/jev-1.13",
75
83
  "OPENROUTER_API_KEY": "your-key"
76
84
  }
77
85
  }
78
86
  ```
79
87
 
88
+ Codex, in `~/.codex/config.toml`:
89
+
90
+ ```toml
91
+ [shell_environment_policy]
92
+ set = { PRIMA_CLI_AI_MODEL = "openrouter/openai/gpt-oss-120b:nitro", PRIMA_CLI_DECISION_MODEL = "openrouter/typesafe/jev-1.13", OPENROUTER_API_KEY = "your-key" }
93
+ ```
94
+
95
+ `set` is applied after Codex filters the inherited environment, so the key survives even where names containing `KEY` are stripped.
96
+
97
+ To run prima yourself, or from any agent that inherits your shell, export them in `~/.bashrc` or `~/.zshrc`:
98
+
99
+ ```bash
100
+ export PRIMA_CLI_AI_MODEL=openrouter/openai/gpt-oss-120b:nitro
101
+ export PRIMA_CLI_DECISION_MODEL=openrouter/typesafe/jev-1.13
102
+ export OPENROUTER_API_KEY=your-key
103
+ ```
104
+
105
+ Fish uses `set -gx` in `~/.config/fish/config.fish` instead of `export`.
106
+
80
107
  Screenshot analysis needs its own model — one is never guessed from the main model. Set `PRIMA_CLI_VISION_MODEL`, or pass `--vision-model` for a single run, as `--model` does for the main one. Without it prima still runs, but settles `check` outcomes from the run log rather than from the page as seen.
81
108
 
82
109
  Every `PRIMA_CLI_*` variable mirrors the `EXPLORBOT_*` one of the same name and wins over it, so prima can be pointed at a different provider, model or URL without disturbing an explorbot setup on the same machine. `PRIMA_CLI_AI_MODEL`, `PRIMA_CLI_URL`, `PRIMA_CLI_VISION_MODEL` and the rest all work this way.
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env bun
2
- import { knowledgeOption } from '../../../src/commands/options/index.ts';
2
+ import { decisionModelOption, knowledgeOption } from '../../../src/commands/options/index.ts';
3
3
  import { createPrimaCommands } from '../src/cli.ts';
4
4
 
5
5
  const program = createPrimaCommands('prima');
6
6
  knowledgeOption.register(program);
7
+ decisionModelOption.register(program);
7
8
  program.parse();
@@ -8,6 +8,7 @@ import * as playwright from 'playwright';
8
8
  import type { Browser } from 'playwright';
9
9
  import { z } from 'zod';
10
10
  import { ActionResult } from '../../../src/action-result.ts';
11
+ import { JUDGE_PAGE_CAP, UNDECIDED } from '../../../src/ai/judge.ts';
11
12
  import { getPreviousResearch } from '../../../src/ai/researcher/cache.ts';
12
13
  import { actionRule, locatorRule } from '../../../src/ai/rules.ts';
13
14
  import { createAgentTools, createCodeceptJSTools, createRefTools } from '../../../src/ai/tools.ts';
@@ -20,7 +21,7 @@ import { Reporter } from '../../../src/reporter.ts';
20
21
  import type { WebPageState } from '../../../src/state-manager.ts';
21
22
  import { Stats } from '../../../src/stats.ts';
22
23
  import { Task, Test, TestResult } from '../../../src/test-plan.ts';
23
- import { ariaRefSnapshot } from '../../../src/utils/aria-ref.ts';
24
+ import { ariaRefSelector, ariaRefSnapshot, describeRef, parseAriaRefs, refIsGone } from '../../../src/utils/aria-ref.ts';
24
25
  import { compactAriaSnapshot } from '../../../src/utils/aria.ts';
25
26
  import { browserErrorMessage } from '../../../src/utils/browser-errors.ts';
26
27
  import { pluralize } from '../../../src/utils/logger.ts';
@@ -57,7 +58,8 @@ function dropVolatileColumns(markdown: string): string {
57
58
  const divider = `|${columns.map(() => '------').join('|')}|`;
58
59
  const body = rows.map((row) => `| ${columns.map((name) => row[name] || '-').join(' | ')} |`);
59
60
  return [header, divider, ...body, ''].join('\n');
60
- });
61
+ })
62
+ .toString();
61
63
  }
62
64
 
63
65
  function cap(text: string, max: number): string {
@@ -446,6 +448,12 @@ export class Prima {
446
448
  }
447
449
 
448
450
  const previousState = this.bot.stateManager().getCurrentState();
451
+
452
+ if (!isUrl) {
453
+ const judged = await this.judgedGo(target, previousState);
454
+ if (judged) return judged;
455
+ }
456
+
449
457
  let navigationError: unknown = null;
450
458
 
451
459
  try {
@@ -925,6 +933,37 @@ export class Prima {
925
933
  return snapshot || result.ariaSnapshot;
926
934
  }
927
935
 
936
+ private async judgedGo(target: string, previousState: WebPageState | null): Promise<EnvelopeData | null> {
937
+ if (!previousState) return null;
938
+ const judge = this.bot.judge?.();
939
+ if (!judge) return null;
940
+
941
+ const refs = parseAriaRefs((await this.refAriaSnapshot(ActionResult.fromState(previousState))) || '').filter((entry) => entry.name);
942
+ const labels = refs.map((entry) => `${entry.role} named "${entry.name}"`);
943
+ const pick = await judge.decide(`Which listed control leads to: ${target}`, [...labels, UNDECIDED], { target });
944
+ if (!pick.value) return null;
945
+
946
+ const ref = refs[labels.indexOf(pick.value)].ref;
947
+ const explorer = this.bot.getExplorer();
948
+ if (await refIsGone(explorer, ref)) return null;
949
+
950
+ const named = await describeRef(explorer, ref);
951
+ const run = `I.usePlaywrightTo(${JSON.stringify(`go to ${target}`)}, async ({ page }) => page.locator(${JSON.stringify(ariaRefSelector(ref))}).click())`;
952
+ if (!(await explorer.action().attempt(run, `go ${target}`))) return null;
953
+
954
+ const newState = this.bot.stateManager().getCurrentState();
955
+ if (!newState) return null;
956
+ if (newState.hash === previousState.hash && newState.url === previousState.url) return null;
957
+
958
+ const page = ((await this.refAriaSnapshot(ActionResult.fromState(newState))) || '').slice(0, JUDGE_PAGE_CAP);
959
+ const arrived = await judge.decide(`The page now shows: ${target}`, null, { target, page });
960
+ if (arrived.rejected) return null;
961
+
962
+ let code = run;
963
+ if (named) code = `I.click(${JSON.stringify(named)})`;
964
+ return this.successEnvelope(`go ${target}`, [code], await this.capturedResult(newState), previousState);
965
+ }
966
+
928
967
  private executedCodes(code: unknown): string[] {
929
968
  if (typeof code !== 'string') return [];
930
969
  return code
@@ -12,7 +12,7 @@ import { HelpJsonCommand } from '../src/commands/help-json-command.js';
12
12
  import { RecommendedModelsCommand } from '../src/commands/recommended-models-command.js';
13
13
  import { App } from '../src/components/App.js';
14
14
  import { StatusPane } from '../src/components/StatusPane.js';
15
- import { knowledgeOption, wsOption } from '../src/commands/options/index.js';
15
+ import { decisionModelOption, knowledgeOption, wsOption } from '../src/commands/options/index.js';
16
16
  import { ConfigParser, EXPLORBOT_ENV_VARS, PROVIDERS } from '../src/config.js';
17
17
  import { ExplorBot } from '../src/explorbot.js';
18
18
  import { remote } from '../src/remote.js';
@@ -30,6 +30,7 @@ const pkgVersion = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version;
30
30
  program.name(cli).description('AI-powered web exploration tool').version(pkgVersion, '-V, --version');
31
31
  wsOption.register(program);
32
32
  knowledgeOption.register(program);
33
+ decisionModelOption.register(program);
33
34
  process.on('uncaughtException', async (error) => {
34
35
  tag('error').log(`Uncaught exception: ${error instanceof Error ? `${error.message}\n${error.stack}` : String(error)}`);
35
36
  await flushTelemetry();
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ import { runMdq } from "../src/utils/mdq/cli.js";
3
+ async function readStdin() {
4
+ if (process.stdin.isTTY)
5
+ return '';
6
+ process.stdin.setEncoding('utf8');
7
+ let text = '';
8
+ for await (const chunk of process.stdin)
9
+ text += chunk;
10
+ return text;
11
+ }
12
+ const result = await runMdq(process.argv.slice(2), readStdin);
13
+ if (result.output) {
14
+ let text = result.output;
15
+ if (!text.endsWith('\n'))
16
+ text = `${text}\n`;
17
+ process.stdout.write(text);
18
+ }
19
+ process.exit(result.code);
@@ -6,6 +6,7 @@ import { Observability } from "../../../../src/observability.js";
6
6
  import { Plan, Test } from "../../../../src/test-plan.js";
7
7
  import { createDebug, tag } from "../../../../src/utils/logger.js";
8
8
  import { RulesLoader } from "../../../../src/utils/rules-loader.js";
9
+ import { isSecretName, registerSecret } from "../../../../src/utils/secrets.js";
9
10
  import { getActiveStyle, getStyles } from "./chief/styles.js";
10
11
  const debugLog = createDebug('explorbot:chief');
11
12
  const ApiTasksSchema = z.object({
@@ -19,6 +20,17 @@ const ApiTasksSchema = z.object({
19
20
  }))
20
21
  .describe('List of testing scenarios'),
21
22
  });
23
+ const NamedValueSchema = z.object({ name: z.string(), value: z.string() });
24
+ const RequestDefaultsSchema = z.object({
25
+ files: z
26
+ .array(z.object({
27
+ file: z.number().describe('Number of the knowledge file the values come from'),
28
+ headers: z.array(NamedValueSchema).describe('Headers to send, each value exactly as it goes on the wire'),
29
+ query: z.array(NamedValueSchema).describe('Query parameters to send'),
30
+ body: z.array(NamedValueSchema).describe('Fields a request body must contain, each value as a JSON literal'),
31
+ }))
32
+ .describe('One entry per knowledge file that names values to send with requests'),
33
+ });
22
34
  const ChiefBase = WithSessionDedup(Object);
23
35
  export class Chief extends ChiefBase {
24
36
  provider;
@@ -27,6 +39,7 @@ export class Chief extends ChiefBase {
27
39
  knowledgeTracker;
28
40
  currentPlan = null;
29
41
  lastStyleName = '';
42
+ readKnowledgeFiles = new Set();
30
43
  MIN_TASKS = 3;
31
44
  MAX_TASKS = 10;
32
45
  constructor(provider, config, apiClient, knowledgeTracker) {
@@ -42,6 +55,7 @@ export class Chief extends ChiefBase {
42
55
  tag('info').log(`Planning style: ${opts.style}`);
43
56
  debugLog('Sending planning prompt to AI provider');
44
57
  await Observability.run(`chief: ${endpoint}`, { tags: ['chief'], sessionId: endpoint }, async () => {
58
+ await this.prepareRequestDefaults(endpoint);
45
59
  const sampleData = await this.collectSampleData(endpoint);
46
60
  const conversation = this.buildConversation(endpoint, opts?.style, sampleData);
47
61
  const knowledge = this.knowledgeTracker?.renderEndpointKnowledge(endpoint);
@@ -105,6 +119,53 @@ export class Chief extends ChiefBase {
105
119
  existing.add(test.scenario.toLowerCase());
106
120
  }
107
121
  }
122
+ async prepareRequestDefaults(endpoint) {
123
+ if (!this.apiClient)
124
+ return;
125
+ const files = (this.knowledgeTracker?.getEndpointKnowledge(endpoint) || []).filter((knowledge) => !this.readKnowledgeFiles.has(knowledge.filePath));
126
+ if (!files.length)
127
+ return;
128
+ const knowledgeFiles = files.map((knowledge, index) => `<file number="${index + 1}" endpoint="${knowledge.endpoint}">\n${knowledge.content}\n</file>`).join('\n\n');
129
+ const prompt = dedent `
130
+ <task>
131
+ Extract the values these knowledge files say must be sent with API requests:
132
+ - headers: authentication, API keys and other headers, written exactly as sent (include the auth scheme when one is named)
133
+ - query: query parameters requests need
134
+ - body: fields request bodies must contain, each value written as a JSON literal
135
+ Copy values exactly as written. Never invent a value. Skip facts that are not about what to send.
136
+ </task>
137
+
138
+ <knowledge_files>
139
+ ${knowledgeFiles}
140
+ </knowledge_files>
141
+ `;
142
+ const result = await this.provider.generateObject([{ role: 'user', content: prompt }], RequestDefaultsSchema, this.provider.getModelForAgent('chief'), { agentName: 'chief' }).catch((error) => {
143
+ tag('warning').log(`Could not read request defaults from knowledge: ${error.message}`);
144
+ return null;
145
+ });
146
+ if (!result)
147
+ return;
148
+ for (const knowledge of files)
149
+ this.readKnowledgeFiles.add(knowledge.filePath);
150
+ const entries = result.object?.files || [];
151
+ for (const entry of entries) {
152
+ const knowledge = files[entry.file - 1];
153
+ if (!knowledge?.endpoint)
154
+ continue;
155
+ const headers = Object.fromEntries(entry.headers.map(({ name, value }) => [name, value]));
156
+ const query = Object.fromEntries(entry.query.map(({ name, value }) => [name, value]));
157
+ const body = Object.fromEntries(entry.body.map(({ name, value }) => [name, parseJsonLiteral(value)]));
158
+ const sent = Object.entries({ headers, query, body }).filter(([, values]) => Object.keys(values).length);
159
+ if (!sent.length)
160
+ continue;
161
+ for (const [name, value] of sent.flatMap(([, values]) => Object.entries(values))) {
162
+ if (isSecretName(name))
163
+ registerSecret(String(value));
164
+ }
165
+ this.apiClient.addRequestDefaults({ pattern: knowledge.endpoint, headers, query, body });
166
+ tag('info').log(`Request defaults from knowledge for ${knowledge.endpoint}: ${sent.map(([kind, values]) => `${kind} ${Object.keys(values).join(', ')}`).join('; ')}`);
167
+ }
168
+ }
108
169
  async collectSampleData(endpoint) {
109
170
  if (!this.apiClient)
110
171
  return '';
@@ -305,3 +366,11 @@ export class Chief extends ChiefBase {
305
366
  `;
306
367
  }
307
368
  }
369
+ function parseJsonLiteral(value) {
370
+ try {
371
+ return JSON.parse(value);
372
+ }
373
+ catch {
374
+ return value;
375
+ }
376
+ }
@@ -1,7 +1,10 @@
1
1
  import { ApiClient as BaseApiClient } from "../../../src/api/api-client.js";
2
+ import { extractStatePath, matchesUrl } from "../../../src/utils/url-matcher.js";
3
+ const BODY_METHODS = ['POST', 'PUT', 'PATCH'];
2
4
  export class ApiClient extends BaseApiClient {
3
5
  bootstrapHook;
4
6
  teardownHook;
7
+ requestDefaults = [];
5
8
  constructor(baseEndpoint, defaultHeaders = {}, hooks) {
6
9
  super(baseEndpoint, defaultHeaders);
7
10
  this.bootstrapHook = hooks?.bootstrap;
@@ -22,4 +25,27 @@ export class ApiClient extends BaseApiClient {
22
25
  const ctx = { headers: this.getHeaders(), baseEndpoint: this.getBaseEndpoint() };
23
26
  await this.teardownHook(ctx);
24
27
  }
28
+ addRequestDefaults(defaults) {
29
+ this.requestDefaults.push(defaults);
30
+ }
31
+ async request(opts) {
32
+ const path = extractStatePath(opts.path);
33
+ const matching = this.requestDefaults.filter((defaults) => matchesUrl(defaults.pattern, path));
34
+ if (!matching.length)
35
+ return super.request(opts);
36
+ const knowledgeHeaders = {};
37
+ for (const defaults of matching) {
38
+ for (const [name, value] of Object.entries(defaults.headers))
39
+ knowledgeHeaders[name.toLowerCase()] = value;
40
+ }
41
+ for (const name of Object.keys({ ...this.getHeaders(), ...opts.headers }))
42
+ delete knowledgeHeaders[name.toLowerCase()];
43
+ const headers = { ...knowledgeHeaders, ...this.getHeaders(), ...opts.headers };
44
+ const queryParams = Object.assign({}, ...matching.map((defaults) => defaults.query), opts.queryParams);
45
+ let body = opts.body;
46
+ if (BODY_METHODS.includes(opts.method.toUpperCase()) && body && typeof body === 'object' && !Array.isArray(body)) {
47
+ body = Object.assign({}, ...matching.map((defaults) => defaults.body), body);
48
+ }
49
+ return super.request({ ...opts, headers, queryParams, body });
50
+ }
25
51
  }
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { knowledgeOption } from "../../../src/commands/options/index.js";
2
+ import { decisionModelOption, knowledgeOption } from "../../../src/commands/options/index.js";
3
3
  import { createPrimaCommands } from "../src/cli.js";
4
4
  const program = createPrimaCommands('prima');
5
5
  knowledgeOption.register(program);
6
+ decisionModelOption.register(program);
6
7
  program.parse();
@@ -7,6 +7,7 @@ import dedent from 'dedent';
7
7
  import * as playwright from 'playwright';
8
8
  import { z } from 'zod';
9
9
  import { ActionResult } from "../../../src/action-result.js";
10
+ import { JUDGE_PAGE_CAP, UNDECIDED } from "../../../src/ai/judge.js";
10
11
  import { getPreviousResearch } from "../../../src/ai/researcher/cache.js";
11
12
  import { actionRule, locatorRule } from "../../../src/ai/rules.js";
12
13
  import { createAgentTools, createCodeceptJSTools, createRefTools } from "../../../src/ai/tools.js";
@@ -18,7 +19,7 @@ import { findSiteWith, listSites } from "../../../src/global-config.js";
18
19
  import { Reporter } from "../../../src/reporter.js";
19
20
  import { Stats } from "../../../src/stats.js";
20
21
  import { Task, Test, TestResult } from "../../../src/test-plan.js";
21
- import { ariaRefSnapshot } from "../../../src/utils/aria-ref.js";
22
+ import { ariaRefSelector, ariaRefSnapshot, describeRef, parseAriaRefs, refIsGone } from "../../../src/utils/aria-ref.js";
22
23
  import { compactAriaSnapshot } from "../../../src/utils/aria.js";
23
24
  import { browserErrorMessage } from "../../../src/utils/browser-errors.js";
24
25
  import { pluralize } from "../../../src/utils/logger.js";
@@ -52,7 +53,8 @@ function dropVolatileColumns(markdown) {
52
53
  const divider = `|${columns.map(() => '------').join('|')}|`;
53
54
  const body = rows.map((row) => `| ${columns.map((name) => row[name] || '-').join(' | ')} |`);
54
55
  return [header, divider, ...body, ''].join('\n');
55
- });
56
+ })
57
+ .toString();
56
58
  }
57
59
  function cap(text, max) {
58
60
  if (text.length <= max)
@@ -405,6 +407,11 @@ export class Prima {
405
407
  return guard;
406
408
  }
407
409
  const previousState = this.bot.stateManager().getCurrentState();
410
+ if (!isUrl) {
411
+ const judged = await this.judgedGo(target, previousState);
412
+ if (judged)
413
+ return judged;
414
+ }
408
415
  let navigationError = null;
409
416
  try {
410
417
  await this.bot.agentNavigator().visit(target);
@@ -868,6 +875,39 @@ export class Prima {
868
875
  const snapshot = await Promise.resolve(this.bot.getExplorer()?.withPage?.(ariaRefSnapshot)).catch(() => null);
869
876
  return snapshot || result.ariaSnapshot;
870
877
  }
878
+ async judgedGo(target, previousState) {
879
+ if (!previousState)
880
+ return null;
881
+ const judge = this.bot.judge?.();
882
+ if (!judge)
883
+ return null;
884
+ const refs = parseAriaRefs((await this.refAriaSnapshot(ActionResult.fromState(previousState))) || '').filter((entry) => entry.name);
885
+ const labels = refs.map((entry) => `${entry.role} named "${entry.name}"`);
886
+ const pick = await judge.decide(`Which listed control leads to: ${target}`, [...labels, UNDECIDED], { target });
887
+ if (!pick.value)
888
+ return null;
889
+ const ref = refs[labels.indexOf(pick.value)].ref;
890
+ const explorer = this.bot.getExplorer();
891
+ if (await refIsGone(explorer, ref))
892
+ return null;
893
+ const named = await describeRef(explorer, ref);
894
+ const run = `I.usePlaywrightTo(${JSON.stringify(`go to ${target}`)}, async ({ page }) => page.locator(${JSON.stringify(ariaRefSelector(ref))}).click())`;
895
+ if (!(await explorer.action().attempt(run, `go ${target}`)))
896
+ return null;
897
+ const newState = this.bot.stateManager().getCurrentState();
898
+ if (!newState)
899
+ return null;
900
+ if (newState.hash === previousState.hash && newState.url === previousState.url)
901
+ return null;
902
+ const page = ((await this.refAriaSnapshot(ActionResult.fromState(newState))) || '').slice(0, JUDGE_PAGE_CAP);
903
+ const arrived = await judge.decide(`The page now shows: ${target}`, null, { target, page });
904
+ if (arrived.rejected)
905
+ return null;
906
+ let code = run;
907
+ if (named)
908
+ code = `I.click(${JSON.stringify(named)})`;
909
+ return this.successEnvelope(`go ${target}`, [code], await this.capturedResult(newState), previousState);
910
+ }
871
911
  executedCodes(code) {
872
912
  if (typeof code !== 'string')
873
913
  return [];
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.4.10",
3
+ "version": "0.5.1",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -60,7 +60,8 @@
60
60
  "check": "biome check .",
61
61
  "check:fix": "biome check --write .",
62
62
  "langfuse:export": "bun run .claude/skills/explorbot-debug/langfuse-export.ts",
63
- "build:prima": "bun run scripts/build-prima-npm.ts"
63
+ "build:prima": "bun run scripts/build-prima-npm.ts",
64
+ "build:mdq": "bun run scripts/build-mdq-npm.ts"
64
65
  },
65
66
  "keywords": [
66
67
  "cli",
@@ -101,7 +102,7 @@
101
102
  "bash-tool": "^1.3.15",
102
103
  "chalk": "^5.6.2",
103
104
  "cli-highlight": "^2.1.11",
104
- "codeceptjs": "^4.2.0-beta.3",
105
+ "codeceptjs": "^4.2.0",
105
106
  "commander": "^14.0.1",
106
107
  "debug": "^4.4.3",
107
108
  "dedent": "^1.6.0",