explorbot 0.4.9 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/README.md +4 -1
  2. package/bin/mdq.ts +18 -0
  3. package/boat/api-tester/src/ai/chief.ts +72 -0
  4. package/boat/api-tester/src/api-client.ts +37 -0
  5. package/boat/prima/src/prima.ts +41 -2
  6. package/dist/bin/mdq.js +19 -0
  7. package/dist/boat/api-tester/src/ai/chief.js +69 -0
  8. package/dist/boat/api-tester/src/api-client.js +26 -0
  9. package/dist/boat/prima/src/prima.js +42 -2
  10. package/dist/package.json +3 -2
  11. package/dist/src/action.js +4 -2
  12. package/dist/src/ai/agent.d.ts +3 -1
  13. package/dist/src/ai/judge-provider.d.ts +17 -0
  14. package/dist/src/ai/judge-provider.js +56 -0
  15. package/dist/src/ai/judge-tool.d.ts +2 -0
  16. package/dist/src/ai/judge-tool.js +33 -0
  17. package/dist/src/ai/judge.d.ts +28 -0
  18. package/dist/src/ai/judge.js +71 -0
  19. package/dist/src/ai/navigator.d.ts +3 -1
  20. package/dist/src/ai/navigator.js +24 -28
  21. package/dist/src/ai/pilot.d.ts +4 -0
  22. package/dist/src/ai/pilot.js +67 -9
  23. package/dist/src/ai/planner.js +9 -6
  24. package/dist/src/ai/provider.d.ts +4 -1
  25. package/dist/src/ai/provider.js +52 -7
  26. package/dist/src/ai/rerunner.js +7 -0
  27. package/dist/src/ai/researcher/deep-analysis.js +2 -2
  28. package/dist/src/ai/researcher/locators.js +2 -2
  29. package/dist/src/ai/researcher/pagination.js +1 -1
  30. package/dist/src/ai/researcher/research-result.js +2 -2
  31. package/dist/src/ai/researcher.js +1 -1
  32. package/dist/src/ai/task-agent.d.ts +2 -0
  33. package/dist/src/ai/task-agent.js +3 -1
  34. package/dist/src/ai/tester.js +19 -15
  35. package/dist/src/ai/tools.d.ts +4 -3
  36. package/dist/src/ai/tools.js +35 -7
  37. package/dist/src/api/request-result.js +2 -1
  38. package/dist/src/api/xhr-capture.js +2 -1
  39. package/dist/src/command-handler.d.ts +1 -0
  40. package/dist/src/command-handler.js +24 -3
  41. package/dist/src/commands/base-command.d.ts +5 -0
  42. package/dist/src/commands/base-command.js +3 -0
  43. package/dist/src/commands/explore-command.d.ts +2 -1
  44. package/dist/src/commands/explore-command.js +12 -1
  45. package/dist/src/commands/freesail-command.js +8 -2
  46. package/dist/src/commands/init-command.js +1 -1
  47. package/dist/src/commands/navigate-command.d.ts +2 -1
  48. package/dist/src/commands/navigate-command.js +6 -0
  49. package/dist/src/commands/plan-load-command.d.ts +2 -1
  50. package/dist/src/commands/plan-load-command.js +4 -0
  51. package/dist/src/commands/plans-command.d.ts +3 -9
  52. package/dist/src/commands/plans-command.js +11 -21
  53. package/dist/src/commands/rerun-command.d.ts +2 -1
  54. package/dist/src/commands/rerun-command.js +5 -1
  55. package/dist/src/commands/research-command.d.ts +2 -1
  56. package/dist/src/commands/research-command.js +6 -0
  57. package/dist/src/commands/test-command.d.ts +2 -1
  58. package/dist/src/commands/test-command.js +4 -1
  59. package/dist/src/components/Autocomplete.js +26 -12
  60. package/dist/src/components/InputReadline.js +10 -1
  61. package/dist/src/config.d.ts +6 -0
  62. package/dist/src/experience-tracker.js +4 -3
  63. package/dist/src/explorbot.d.ts +3 -0
  64. package/dist/src/explorbot.js +8 -0
  65. package/dist/src/explorer.js +2 -3
  66. package/dist/src/knowledge-tracker.js +1 -1
  67. package/dist/src/reporter.js +8 -4
  68. package/dist/src/state-manager.d.ts +2 -0
  69. package/dist/src/state-manager.js +16 -0
  70. package/dist/src/test-plan.d.ts +11 -0
  71. package/dist/src/test-plan.js +54 -2
  72. package/dist/src/utils/aria-ref.js +1 -1
  73. package/dist/src/utils/logger.js +9 -3
  74. package/dist/src/utils/markdown-query.d.ts +1 -48
  75. package/dist/src/utils/markdown-query.js +1 -444
  76. package/dist/src/utils/mdq/cli.d.ts +6 -0
  77. package/dist/src/utils/mdq/cli.js +122 -0
  78. package/dist/src/utils/mdq/edit.d.ts +24 -0
  79. package/dist/src/utils/mdq/edit.js +147 -0
  80. package/dist/src/utils/mdq/query.d.ts +118 -0
  81. package/dist/src/utils/mdq/query.js +451 -0
  82. package/dist/src/utils/step-analyzer.d.ts +3 -0
  83. package/dist/src/utils/step-analyzer.js +7 -0
  84. package/dist/src/utils/strings.d.ts +1 -0
  85. package/dist/src/utils/strings.js +7 -0
  86. package/dist/src/utils/test-files.d.ts +1 -0
  87. package/dist/src/utils/test-files.js +5 -2
  88. package/dist/src/utils/url-matcher.d.ts +1 -0
  89. package/dist/src/utils/url-matcher.js +7 -0
  90. package/docs/api-testing/planning.md +1 -1
  91. package/docs/superpowers/plans/2026-09-15-mdq-package.md +130 -94
  92. package/docs/superpowers/specs/2026-09-18-judge-decision-model-design.md +79 -0
  93. package/package.json +3 -2
  94. package/src/action.ts +4 -2
  95. package/src/ai/agent.ts +3 -1
  96. package/src/ai/judge-provider.ts +62 -0
  97. package/src/ai/judge-tool.ts +35 -0
  98. package/src/ai/judge.ts +75 -0
  99. package/src/ai/navigator.ts +24 -27
  100. package/src/ai/pilot.ts +68 -9
  101. package/src/ai/planner.ts +9 -6
  102. package/src/ai/provider.ts +51 -7
  103. package/src/ai/rerunner.ts +4 -0
  104. package/src/ai/researcher/deep-analysis.ts +2 -2
  105. package/src/ai/researcher/locators.ts +2 -2
  106. package/src/ai/researcher/pagination.ts +1 -1
  107. package/src/ai/researcher/research-result.ts +2 -2
  108. package/src/ai/researcher.ts +1 -1
  109. package/src/ai/task-agent.ts +4 -1
  110. package/src/ai/tester.ts +19 -16
  111. package/src/ai/tools.ts +42 -7
  112. package/src/api/request-result.ts +2 -1
  113. package/src/api/xhr-capture.ts +2 -1
  114. package/src/command-handler.ts +28 -3
  115. package/src/commands/base-command.ts +9 -0
  116. package/src/commands/explore-command.ts +15 -2
  117. package/src/commands/freesail-command.ts +9 -2
  118. package/src/commands/init-command.ts +1 -1
  119. package/src/commands/navigate-command.ts +8 -1
  120. package/src/commands/plan-load-command.ts +6 -1
  121. package/src/commands/plans-command.ts +13 -29
  122. package/src/commands/rerun-command.ts +7 -2
  123. package/src/commands/research-command.ts +8 -1
  124. package/src/commands/test-command.ts +6 -2
  125. package/src/components/Autocomplete.tsx +39 -10
  126. package/src/components/InputReadline.tsx +10 -1
  127. package/src/config.ts +1 -0
  128. package/src/experience-tracker.ts +4 -3
  129. package/src/explorbot.ts +8 -0
  130. package/src/explorer.ts +2 -2
  131. package/src/knowledge-tracker.ts +1 -1
  132. package/src/reporter.ts +8 -4
  133. package/src/state-manager.ts +16 -0
  134. package/src/test-plan.ts +67 -2
  135. package/src/utils/aria-ref.ts +1 -1
  136. package/src/utils/logger.ts +7 -2
  137. package/src/utils/markdown-query.ts +1 -519
  138. package/src/utils/mdq/cli.ts +118 -0
  139. package/src/utils/mdq/edit.ts +158 -0
  140. package/src/utils/mdq/query.ts +556 -0
  141. package/src/utils/step-analyzer.ts +8 -0
  142. package/src/utils/strings.ts +7 -0
  143. package/src/utils/test-files.ts +5 -2
  144. package/src/utils/url-matcher.ts +7 -0
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>
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
  }
@@ -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
@@ -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
  }
@@ -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.9",
3
+ "version": "0.5.0",
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",
@@ -12,7 +12,9 @@ import { captureHtmlForSnapshot, htmlCombinedSnapshot, minifyHtml } from './util
12
12
  import { createDebug, setStepSpanParent, tag } from './utils/logger.js';
13
13
  import { Overlay, OverlayPage } from './utils/overlay.js';
14
14
  import { sleep, waitForPageReadiness } from "./utils/page-readiness.js";
15
+ import { isInternalStep } from "./utils/step-analyzer.js";
15
16
  import { safeFilename } from "./utils/strings.js";
17
+ import { isSameHostFamily } from './utils/url-matcher.js';
16
18
  import { codeceptJSSandbox, hasPlaywrightCommands, playwrightSandbox, sanitizeCodeBlock } from "./utils/web-sandbox.js";
17
19
  const debugLog = createDebug('explorbot:action');
18
20
  const CAPTURE_NAVIGATION_TRANSITION_ATTEMPTS = 3;
@@ -303,7 +305,7 @@ class Action {
303
305
  const url = URL.parse(request.url());
304
306
  if (!url)
305
307
  return;
306
- if (url.origin !== this.baseOrigin)
308
+ if (!isSameHostFamily(url.href, this.baseOrigin))
307
309
  return;
308
310
  const call = { method: request.method(), path: url.pathname, status };
309
311
  if (this.networkRequests.some((r) => r.method === call.method && r.path === call.path && r.status === call.status))
@@ -523,7 +525,7 @@ export const attachStepLogger = (target, assertionsTarget) => {
523
525
  const listener = (step, error) => {
524
526
  if (!step?.toCode)
525
527
  return;
526
- if (step.name?.startsWith('grab'))
528
+ if (isInternalStep(step))
527
529
  return;
528
530
  const existing = recorded.get(step);
529
531
  if (existing) {
@@ -4,6 +4,7 @@ import type Explorer from '../explorer.js';
4
4
  import type { KnowledgeTracker } from '../knowledge-tracker.js';
5
5
  import type { PlaywrightRecorder } from '../playwright-recorder.js';
6
6
  import type { StateManager } from '../state-manager.js';
7
+ import type { Judge } from './judge.js';
7
8
  import type { AIProvider } from './provider.js';
8
9
  export interface Agent {
9
10
  emoji?: string;
@@ -16,5 +17,6 @@ export interface AgentDeps {
16
17
  knowledgeTracker: KnowledgeTracker;
17
18
  requestStore: RequestStore;
18
19
  playwrightRecorder: PlaywrightRecorder;
20
+ judge?: Judge;
19
21
  }
20
- export type ToolDeps = Pick<AgentDeps, 'explorer' | 'stateManager' | 'ai'>;
22
+ export type ToolDeps = Pick<AgentDeps, 'explorer' | 'stateManager' | 'ai' | 'judge'>;
@@ -0,0 +1,17 @@
1
+ export declare class JudgeProvider {
2
+ readonly model: string;
3
+ fetchImpl: typeof fetch;
4
+ requestTimeoutMs: number;
5
+ url: string;
6
+ apiKey: string;
7
+ constructor(provider: string, model: string);
8
+ decide(state: unknown, question: string, options?: string[]): Promise<ProviderDecision>;
9
+ static endpointFor(provider: string): {
10
+ url: string;
11
+ keyName: string;
12
+ };
13
+ }
14
+ export interface ProviderDecision {
15
+ value: string;
16
+ probability: number;
17
+ }
@@ -0,0 +1,56 @@
1
+ const REQUEST_TIMEOUT_MS = 15000;
2
+ export class JudgeProvider {
3
+ model;
4
+ fetchImpl = fetch;
5
+ requestTimeoutMs = REQUEST_TIMEOUT_MS;
6
+ url;
7
+ apiKey;
8
+ constructor(provider, model) {
9
+ this.model = model;
10
+ const { url, keyName } = JudgeProvider.endpointFor(provider);
11
+ const apiKey = process.env[keyName];
12
+ if (!apiKey)
13
+ throw new Error(`Set ${keyName} to use the decision model`);
14
+ this.url = url;
15
+ this.apiKey = apiKey;
16
+ }
17
+ async decide(state, question, options) {
18
+ let q = { type: 'noul', instructions: question };
19
+ if (options)
20
+ q = { type: 'choice', instructions: question, criteria: Object.fromEntries(options.map((option, index) => [String(index), option])) };
21
+ const body = JSON.stringify({ model: this.model, state, questions: { q } });
22
+ const controller = new AbortController();
23
+ const timer = setTimeout(() => controller.abort(), this.requestTimeoutMs);
24
+ try {
25
+ const response = await this.fetchImpl(this.url, {
26
+ method: 'POST',
27
+ headers: { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
28
+ body,
29
+ signal: controller.signal,
30
+ });
31
+ if (!response.ok)
32
+ throw new Error(`http_${response.status}`);
33
+ const answer = (await response.json())?.answers?.q;
34
+ if (!options && typeof answer?.noul === 'number')
35
+ return { value: 'yes', probability: answer.noul };
36
+ const value = options?.[Number(answer?.choice)];
37
+ const probability = answer?.probabilities?.[answer?.choice];
38
+ if (value === undefined || typeof probability !== 'number')
39
+ throw new Error('malformed_body');
40
+ return { value, probability };
41
+ }
42
+ finally {
43
+ clearTimeout(timer);
44
+ }
45
+ }
46
+ static endpointFor(provider) {
47
+ switch (provider) {
48
+ case 'openrouter':
49
+ return { url: 'https://openrouter.ai/api/alpha/decisions', keyName: 'OPENROUTER_API_KEY' };
50
+ case 'typesafe':
51
+ return { url: 'https://api.typesafe.ai/v1/systemone', keyName: 'TYPESAFE_API_KEY' };
52
+ default:
53
+ throw new Error(`Unknown decision model provider "${provider}" — use "openrouter" or "typesafe"`);
54
+ }
55
+ }
56
+ }
@@ -0,0 +1,2 @@
1
+ import type { ToolDeps } from './agent.js';
2
+ export declare function createJudgeTool(deps: ToolDeps, buildState: () => Promise<Record<string, unknown>>): Record<string, any>;