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
@@ -1,5 +1,6 @@
1
1
  import { Planner } from '../ai/planner.js';
2
2
  import { Researcher } from '../ai/researcher.js';
3
+ import { normalizeUrl } from '../state-manager.js';
3
4
  import { Stats } from '../stats.js';
4
5
  import { tag } from '../utils/logger.js';
5
6
  import { loop } from '../utils/loop.js';
@@ -65,11 +66,16 @@ export class FreesailCommand extends BaseCommand {
65
66
  const suggestion = await navigator.freeSail({ strategy, scope, visitedUrls });
66
67
  if (!suggestion) {
67
68
  tag('info').log('No navigation suggestion available');
68
- return;
69
+ ctx.stop();
69
70
  }
70
71
  if (scope && !suggestion.target.startsWith(scope)) {
71
72
  tag('warning').log(`Suggestion ${suggestion.target} is outside scope ${scope}, skipping`);
72
- return;
73
+ ctx.stop();
74
+ }
75
+ const currentUrl = stateManager.getCurrentState()?.url;
76
+ if (currentUrl && normalizeUrl(suggestion.target) === normalizeUrl(currentUrl)) {
77
+ tag('info').log(`No new navigation target available after ${currentUrl}; stopping exploration`);
78
+ ctx.stop();
73
79
  }
74
80
  tag('info').log(`Navigating to: ${suggestion.target} - ${suggestion.reason}`);
75
81
  await this.explorBot.openTab();
@@ -145,7 +145,7 @@ export function runInitCommand(options) {
145
145
  log('2. Set AI models config file');
146
146
  log('3. Set web application URL in the config file');
147
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'`));
148
+ tag('substep').log(chalk.yellow(`${getCliName()} learn '*' 'to authorize use these credentials: admin@example.com / secret123'`));
149
149
  tag('substep').log('You can use ${env.LOGIN} and ${env.PASSWORD} to reference environment variables.');
150
150
  log('5. Launch application on a relative URL');
151
151
  tag('substep').log(chalk.yellow(`${getCliName()} start /dashboard`));
@@ -1,7 +1,8 @@
1
- import { BaseCommand, type Suggestion } from './base-command.js';
1
+ import { type ArgumentCompletion, BaseCommand, type Suggestion } from './base-command.js';
2
2
  export declare class NavigateCommand extends BaseCommand {
3
3
  name: string;
4
4
  description: string;
5
5
  suggestions: Suggestion[];
6
6
  execute(args: string): Promise<void>;
7
+ completeArguments(): ArgumentCompletion[];
7
8
  }
@@ -15,4 +15,10 @@ export class NavigateCommand extends BaseCommand {
15
15
  await this.explorBot.visit(destination);
16
16
  tag('success').log(`Navigation requested: ${destination}`);
17
17
  }
18
+ completeArguments() {
19
+ return this.explorBot
20
+ .stateManager()
21
+ .getKnownUrls()
22
+ .map((url) => ({ value: url }));
23
+ }
18
24
  }
@@ -0,0 +1,6 @@
1
+ import { BaseOption } from './base-option.js';
2
+ export declare class DecisionModelOption extends BaseOption {
3
+ flags: string;
4
+ description: string;
5
+ apply(options: Record<string, any>): void;
6
+ }
@@ -0,0 +1,10 @@
1
+ import { BaseOption } from './base-option.js';
2
+ export class DecisionModelOption extends BaseOption {
3
+ flags = '--decision-model <model>';
4
+ description = 'Turn on the decision model for this run, as provider/model-id (e.g. openrouter/typesafe/jev-1.13)';
5
+ apply(options) {
6
+ if (!options.decisionModel)
7
+ return;
8
+ process.env.EXPLORBOT_DECISION_MODEL = options.decisionModel;
9
+ }
10
+ }
@@ -1,5 +1,7 @@
1
+ import { DecisionModelOption } from './decision-model-option.js';
1
2
  import { KnowledgeOption } from './knowledge-option.js';
2
3
  import { WsOption } from './ws-option.js';
3
4
  export { BaseOption } from './base-option.js';
5
+ export declare const decisionModelOption: DecisionModelOption;
4
6
  export declare const knowledgeOption: KnowledgeOption;
5
7
  export declare const wsOption: WsOption;
@@ -1,5 +1,7 @@
1
+ import { DecisionModelOption } from './decision-model-option.js';
1
2
  import { KnowledgeOption } from './knowledge-option.js';
2
3
  import { WsOption } from './ws-option.js';
3
4
  export { BaseOption } from './base-option.js';
5
+ export const decisionModelOption = new DecisionModelOption();
4
6
  export const knowledgeOption = new KnowledgeOption();
5
7
  export const wsOption = new WsOption();
@@ -1,7 +1,8 @@
1
- import { BaseCommand, type Suggestion } from './base-command.js';
1
+ import { type ArgumentCompletion, BaseCommand, type Suggestion } from './base-command.js';
2
2
  export declare class PlanLoadCommand extends BaseCommand {
3
3
  name: string;
4
4
  description: string;
5
5
  suggestions: Suggestion[];
6
6
  execute(args: string): Promise<void>;
7
+ completeArguments(): ArgumentCompletion[];
7
8
  }
@@ -1,3 +1,4 @@
1
+ import { Plan } from '../test-plan.js';
1
2
  import { tag } from '../utils/logger.js';
2
3
  import { BaseCommand } from './base-command.js';
3
4
  export class PlanLoadCommand extends BaseCommand {
@@ -15,4 +16,7 @@ export class PlanLoadCommand extends BaseCommand {
15
16
  const plan = this.explorBot.loadPlan(filename);
16
17
  tag('success').log(`Plan loaded: ${plan.title} with ${plan.tests.length} tests`);
17
18
  }
19
+ completeArguments() {
20
+ return Plan.listFiles(this.explorBot.getPlansDir()).map((file) => ({ value: file.name, display: file.label }));
21
+ }
18
22
  }
@@ -1,5 +1,5 @@
1
- import { Plan } from '../test-plan.js';
2
- import { BaseCommand } from './base-command.js';
1
+ import { Plan, type PlanFile } from '../test-plan.js';
2
+ import { type ArgumentCompletion, BaseCommand } from './base-command.js';
3
3
  export declare class PlansCommand extends BaseCommand {
4
4
  name: string;
5
5
  description: string;
@@ -8,7 +8,7 @@ export declare class PlansCommand extends BaseCommand {
8
8
  description: string;
9
9
  }[];
10
10
  execute(args: string): Promise<void>;
11
- getPlanFiles(): PlanFile[];
11
+ completeArguments(): ArgumentCompletion[];
12
12
  printPlans(files: PlanFile[]): void;
13
13
  printPlanDetails(plan: Plan, file: PlanFile): void;
14
14
  resolvePlanFile(target: string, files: PlanFile[]): {
@@ -16,9 +16,3 @@ export declare class PlansCommand extends BaseCommand {
16
16
  file: PlanFile;
17
17
  };
18
18
  }
19
- interface PlanFile {
20
- name: string;
21
- path: string;
22
- modifiedAt: number;
23
- }
24
- export {};
@@ -1,4 +1,4 @@
1
- import { existsSync, readdirSync, statSync } from 'node:fs';
1
+ import { statSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { Plan } from '../test-plan.js';
4
4
  import { getCliName } from '../utils/cli-name.js';
@@ -11,7 +11,7 @@ export class PlansCommand extends BaseCommand {
11
11
  options = [{ flags: '--from-plan <file>', description: 'Plan file to show' }];
12
12
  async execute(args) {
13
13
  const { opts, args: remaining } = this.parseArgs(args);
14
- const files = this.getPlanFiles();
14
+ const files = Plan.listFiles(this.explorBot.getPlansDir());
15
15
  const target = String(opts.fromPlan || remaining[0] || '').trim();
16
16
  if (!target) {
17
17
  this.printPlans(files);
@@ -20,22 +20,8 @@ export class PlansCommand extends BaseCommand {
20
20
  const { plan, file } = this.resolvePlanFile(target, files);
21
21
  this.printPlanDetails(plan, file);
22
22
  }
23
- getPlanFiles() {
24
- const plansDir = this.explorBot.getPlansDir();
25
- if (!existsSync(plansDir))
26
- return [];
27
- return readdirSync(plansDir)
28
- .filter((file) => file.endsWith('.md'))
29
- .map((file) => {
30
- const filePath = path.join(plansDir, file);
31
- const stat = statSync(filePath);
32
- return {
33
- name: file,
34
- path: filePath,
35
- modifiedAt: stat.mtimeMs,
36
- };
37
- })
38
- .sort((left, right) => right.modifiedAt - left.modifiedAt);
23
+ completeArguments() {
24
+ return Plan.listFiles(this.explorBot.getPlansDir()).map((file) => ({ value: file.name, display: file.label }));
39
25
  }
40
26
  printPlans(files) {
41
27
  if (files.length === 0) {
@@ -45,8 +31,7 @@ export class PlansCommand extends BaseCommand {
45
31
  tag('info').log('Saved plans:');
46
32
  for (let i = 0; i < files.length; i++) {
47
33
  const file = files[i];
48
- const plan = Plan.fromMarkdown(file.path);
49
- tag('info').log(`${i + 1}. ${plan.title} (${plan.tests.length} tests) - ${file.name}`);
34
+ tag('info').log(`${i + 1}. ${file.title} (${file.testCount} tests) - ${file.name}`);
50
35
  }
51
36
  tag('info').log('');
52
37
  tag('info').log(`View plan tests: ${getCliName()} plans <number>`);
@@ -73,10 +58,15 @@ export class PlansCommand extends BaseCommand {
73
58
  if (!plan?.filePath) {
74
59
  throw new Error(`Plan file not found: ${target}`);
75
60
  }
61
+ const name = path.basename(plan.filePath);
76
62
  const file = {
77
- name: path.basename(plan.filePath),
63
+ name,
78
64
  path: plan.filePath,
79
65
  modifiedAt: statSync(plan.filePath).mtimeMs,
66
+ title: plan.title,
67
+ url: plan.startUrl || '',
68
+ testCount: plan.tests.length,
69
+ label: name,
80
70
  };
81
71
  return { plan, file };
82
72
  }
@@ -1,7 +1,8 @@
1
- import { BaseCommand } from './base-command.js';
1
+ import { type ArgumentCompletion, BaseCommand } from './base-command.js';
2
2
  export declare class RerunCommand extends BaseCommand {
3
3
  name: string;
4
4
  description: string;
5
5
  tuiEnabled: boolean;
6
6
  execute(args: string): Promise<void>;
7
+ completeArguments(): ArgumentCompletion[];
7
8
  }
@@ -1,7 +1,8 @@
1
1
  import { existsSync } from 'node:fs';
2
- import { resolve } from 'node:path';
2
+ import { basename, resolve } from 'node:path';
3
3
  import { ConfigParser } from "../config.js";
4
4
  import { tag } from "../utils/logger.js";
5
+ import { listTestFiles } from "../utils/test-files.js";
5
6
  import { BaseCommand } from './base-command.js';
6
7
  export class RerunCommand extends BaseCommand {
7
8
  name = 'rerun';
@@ -26,6 +27,9 @@ export class RerunCommand extends BaseCommand {
26
27
  const testIndices = indexArg ? parseTestIndices(indexArg) : undefined;
27
28
  await this.explorBot.agentRerunner().rerun(filePath, { testIndices });
28
29
  }
30
+ completeArguments() {
31
+ return listTestFiles(ConfigParser.getInstance().getTestsDir()).map((file) => ({ value: basename(file) }));
32
+ }
29
33
  }
30
34
  function parseTestIndices(input) {
31
35
  if (input === '*' || input === 'all')
@@ -1,4 +1,4 @@
1
- import { BaseCommand, type Suggestion } from './base-command.js';
1
+ import { type ArgumentCompletion, BaseCommand, type Suggestion } from './base-command.js';
2
2
  export declare class ResearchCommand extends BaseCommand {
3
3
  name: string;
4
4
  description: string;
@@ -8,4 +8,5 @@ export declare class ResearchCommand extends BaseCommand {
8
8
  description: string;
9
9
  }[];
10
10
  execute(args: string): Promise<void>;
11
+ completeArguments(): ArgumentCompletion[];
11
12
  }
@@ -43,4 +43,10 @@ export class ResearchCommand extends BaseCommand {
43
43
  this.suggestions = [{ command: 'research <page> --deep', hint: 'analyze page for all expandable elements and interactions' }];
44
44
  }
45
45
  }
46
+ completeArguments() {
47
+ return this.explorBot
48
+ .stateManager()
49
+ .getKnownUrls()
50
+ .map((url) => ({ value: url }));
51
+ }
46
52
  }
@@ -1,4 +1,4 @@
1
- import { BaseCommand, type Suggestion } from './base-command.js';
1
+ import { type ArgumentCompletion, BaseCommand, type Suggestion } from './base-command.js';
2
2
  export declare class TestCommand extends BaseCommand {
3
3
  name: string;
4
4
  description: string;
@@ -8,4 +8,5 @@ export declare class TestCommand extends BaseCommand {
8
8
  }[];
9
9
  suggestions: Suggestion[];
10
10
  execute(args: string): Promise<void>;
11
+ completeArguments(): ArgumentCompletion[];
11
12
  }
@@ -1,5 +1,5 @@
1
1
  import { Stats } from '../stats.js';
2
- import { Test } from '../test-plan.js';
2
+ import { Plan, Test } from '../test-plan.js';
3
3
  import { tag } from '../utils/logger.js';
4
4
  import { BaseCommand } from './base-command.js';
5
5
  export class TestCommand extends BaseCommand {
@@ -74,6 +74,9 @@ export class TestCommand extends BaseCommand {
74
74
  }
75
75
  tag('success').log('Test execution finished');
76
76
  }
77
+ completeArguments() {
78
+ return Plan.listFiles(this.explorBot.getPlansDir()).map((file) => ({ value: `--from-plan ${file.name}`, display: file.label }));
79
+ }
77
80
  }
78
81
  function parseTestIndices(input, total) {
79
82
  const indices = new Set();
@@ -1,8 +1,12 @@
1
- import { Box, Text } from 'ink';
1
+ import { Box, Text, useStdout } from 'ink';
2
2
  import React from 'react';
3
+ import { truncate } from '../utils/strings.js';
3
4
  import { useAutocompleteState } from './autocomplete-store.js';
5
+ const MAX_HEIGHT = 7;
6
+ const COLUMN_WIDTH = 24;
4
7
  const Autocomplete = () => {
5
8
  const { suggestions, selectedIndex, visible, argumentHint } = useAutocompleteState();
9
+ const { stdout } = useStdout();
6
10
  if (!visible) {
7
11
  if (!argumentHint) {
8
12
  return null;
@@ -13,19 +17,32 @@ const Autocomplete = () => {
13
17
  React.createElement(Text, { dimColor: true }, argumentHint)));
14
18
  }
15
19
  const effectiveSelectedIndex = Math.min(selectedIndex, suggestions.length - 1);
16
- const maxHeight = 7;
17
- const rowsPerColumn = Math.min(Math.max(1, maxHeight - 2), suggestions.length);
20
+ const rowsPerColumn = Math.min(Math.max(1, MAX_HEIGHT - 2), suggestions.length);
21
+ const widest = Math.max(...suggestions.map((suggestion) => suggestion.display.length));
22
+ if (widest > COLUMN_WIDTH) {
23
+ const width = Math.max(COLUMN_WIDTH, (stdout?.columns || 80) - 4);
24
+ let firstRow = 0;
25
+ if (effectiveSelectedIndex >= rowsPerColumn) {
26
+ firstRow = effectiveSelectedIndex - rowsPerColumn + 1;
27
+ }
28
+ return (React.createElement(Box, { position: "absolute", top: 0, left: 0, width: "100%", maxHeight: MAX_HEIGHT, overflow: "hidden", paddingX: 1, paddingY: 1, backgroundColor: "#2a2a2a", flexDirection: "column" }, suggestions.slice(firstRow, firstRow + rowsPerColumn).map((suggestion, rowIndex) => {
29
+ const isSelected = firstRow + rowIndex === effectiveSelectedIndex;
30
+ let color = 'white';
31
+ let backgroundColor = '#2a2a2a';
32
+ if (isSelected) {
33
+ color = 'black';
34
+ backgroundColor = '#e6e6e6';
35
+ }
36
+ return (React.createElement(Text, { key: suggestion.value, color: color, backgroundColor: backgroundColor }, ` ${truncate(suggestion.display, width).padEnd(width)} `));
37
+ })));
38
+ }
18
39
  const columns = [];
19
40
  for (let index = 0; index < suggestions.length; index += rowsPerColumn) {
20
41
  columns.push(suggestions.slice(index, index + rowsPerColumn));
21
42
  }
22
- return (React.createElement(Box, { position: "absolute", top: 0, left: 0, width: "100%", maxHeight: maxHeight, overflow: "hidden", paddingX: 1, paddingY: 1, backgroundColor: "#2a2a2a", flexDirection: "row", columnGap: 2 }, columns.map((column, columnIndex) => (React.createElement(Box, { key: columnIndex, flexDirection: "column" }, column.map((suggestion, rowIndex) => {
43
+ return (React.createElement(Box, { position: "absolute", top: 0, left: 0, width: "100%", maxHeight: MAX_HEIGHT, overflow: "hidden", paddingX: 1, paddingY: 1, backgroundColor: "#2a2a2a", flexDirection: "row", columnGap: 2 }, columns.map((column, columnIndex) => (React.createElement(Box, { key: columnIndex, flexDirection: "column" }, column.map((suggestion, rowIndex) => {
23
44
  const index = columnIndex * rowsPerColumn + rowIndex;
24
45
  const isSelected = index === effectiveSelectedIndex;
25
- let display = suggestion.display;
26
- if (display.length > 24) {
27
- display = `${display.slice(0, 21)}...`;
28
- }
29
46
  let color = 'white';
30
47
  let backgroundColor = '#2a2a2a';
31
48
  if (isSelected) {
@@ -33,10 +50,7 @@ const Autocomplete = () => {
33
50
  backgroundColor = '#e6e6e6';
34
51
  }
35
52
  return (React.createElement(Box, { key: suggestion.display, marginBottom: 1 },
36
- React.createElement(Text, { color: color, backgroundColor: backgroundColor },
37
- ' ',
38
- display,
39
- ' ')));
53
+ React.createElement(Text, { color: color, backgroundColor: backgroundColor }, ` ${suggestion.display} `)));
40
54
  }))))));
41
55
  };
42
56
  export default Autocomplete;
@@ -17,6 +17,7 @@ const InputReadline = React.memo(({ commandHandler, exitOnEmptyInput = false, on
17
17
  const cursorRef = useRef(cursorPosition);
18
18
  const showAutocompleteRef = useRef(showAutocomplete);
19
19
  const selectedIndexRef = useRef(selectedIndex);
20
+ const navigatedRef = useRef(false);
20
21
  const historyRef = useRef([]);
21
22
  const historyIndexRef = useRef(-1);
22
23
  const historyDraftRef = useRef('');
@@ -55,6 +56,7 @@ const InputReadline = React.memo(({ commandHandler, exitOnEmptyInput = false, on
55
56
  cursorRef.current = 0;
56
57
  showAutocompleteRef.current = false;
57
58
  selectedIndexRef.current = 0;
59
+ navigatedRef.current = false;
58
60
  historyIndexRef.current = -1;
59
61
  historyDraftRef.current = '';
60
62
  setInputState({ value: '', cursor: 0, showAutocomplete: false, selectedIndex: 0 });
@@ -101,6 +103,7 @@ const InputReadline = React.memo(({ commandHandler, exitOnEmptyInput = false, on
101
103
  cursorRef.current = safeCursor;
102
104
  showAutocompleteRef.current = nextShowAutocomplete;
103
105
  selectedIndexRef.current = 0;
106
+ navigatedRef.current = false;
104
107
  historyIndexRef.current = -1;
105
108
  historyDraftRef.current = '';
106
109
  if (isControlled) {
@@ -173,6 +176,7 @@ const InputReadline = React.memo(({ commandHandler, exitOnEmptyInput = false, on
173
176
  if (showAutocompleteRef.current) {
174
177
  showAutocompleteRef.current = false;
175
178
  selectedIndexRef.current = 0;
179
+ navigatedRef.current = false;
176
180
  setInputState((prev) => ({ ...prev, showAutocomplete: false, selectedIndex: 0 }));
177
181
  }
178
182
  return;
@@ -184,7 +188,7 @@ const InputReadline = React.memo(({ commandHandler, exitOnEmptyInput = false, on
184
188
  }
185
189
  if (commandHandler && showAutocompleteRef.current) {
186
190
  const autocomplete = getAutocomplete(inputRef.current, cursorRef.current);
187
- if (autocomplete.visible && autocomplete.suggestions.length > 0) {
191
+ if (autocomplete.visible && autocomplete.suggestions.length > 0 && (!autocomplete.completesArgument || navigatedRef.current)) {
188
192
  const chosen = applyAutocomplete(selectedIndexRef.current);
189
193
  if (chosen) {
190
194
  handleSubmit(chosen);
@@ -321,6 +325,7 @@ const InputReadline = React.memo(({ commandHandler, exitOnEmptyInput = false, on
321
325
  if (autocomplete.visible && autocomplete.suggestions.length > 0) {
322
326
  const nextIndex = selectedIndexRef.current > 0 ? selectedIndexRef.current - 1 : autocomplete.suggestions.length - 1;
323
327
  selectedIndexRef.current = nextIndex;
328
+ navigatedRef.current = true;
324
329
  setInputState((prev) => ({ ...prev, selectedIndex: nextIndex }));
325
330
  return;
326
331
  }
@@ -330,6 +335,7 @@ const InputReadline = React.memo(({ commandHandler, exitOnEmptyInput = false, on
330
335
  if (autocomplete.visible && autocomplete.suggestions.length > 0) {
331
336
  const nextIndex = selectedIndexRef.current < autocomplete.suggestions.length - 1 ? selectedIndexRef.current + 1 : 0;
332
337
  selectedIndexRef.current = nextIndex;
338
+ navigatedRef.current = true;
333
339
  setInputState((prev) => ({ ...prev, selectedIndex: nextIndex }));
334
340
  return;
335
341
  }
@@ -388,6 +394,7 @@ const InputReadline = React.memo(({ commandHandler, exitOnEmptyInput = false, on
388
394
  inputRef.current = nextValue;
389
395
  cursorRef.current = nextValue.length;
390
396
  selectedIndexRef.current = 0;
397
+ navigatedRef.current = false;
391
398
  showAutocompleteRef.current = nextShowAutocomplete;
392
399
  setInputState({ value: nextValue, cursor: nextValue.length, selectedIndex: 0, showAutocomplete: nextShowAutocomplete });
393
400
  return;
@@ -410,6 +417,7 @@ const InputReadline = React.memo(({ commandHandler, exitOnEmptyInput = false, on
410
417
  inputRef.current = nextValue;
411
418
  cursorRef.current = nextValue.length;
412
419
  selectedIndexRef.current = 0;
420
+ navigatedRef.current = false;
413
421
  showAutocompleteRef.current = nextShowAutocomplete;
414
422
  setInputState({ value: nextValue, cursor: nextValue.length, selectedIndex: 0, showAutocomplete: nextShowAutocomplete });
415
423
  return;
@@ -421,6 +429,7 @@ const InputReadline = React.memo(({ commandHandler, exitOnEmptyInput = false, on
421
429
  inputRef.current = nextValue;
422
430
  cursorRef.current = nextValue.length;
423
431
  selectedIndexRef.current = 0;
432
+ navigatedRef.current = false;
424
433
  showAutocompleteRef.current = nextShowAutocomplete;
425
434
  setInputState({ value: nextValue, cursor: nextValue.length, selectedIndex: 0, showAutocomplete: nextShowAutocomplete });
426
435
  return;
@@ -157,6 +157,12 @@ interface AIConfig {
157
157
  vision?: boolean;
158
158
  visionModel?: any;
159
159
  agenticModel?: any;
160
+ decisionModel?: {
161
+ provider: string;
162
+ model: string;
163
+ tool?: boolean;
164
+ direct?: boolean;
165
+ };
160
166
  retryAttempts?: number;
161
167
  retryDelay?: number;
162
168
  maxParallelRequests?: number;
@@ -264,6 +270,7 @@ export declare class ConfigParser {
264
270
  static cleanupAllTestDirectories(): void;
265
271
  enterGlobalMode(config: ExplorbotConfig, target: string | null): Promise<ExplorbotConfig>;
266
272
  applyEnvSpec(config: ExplorbotConfig): void;
273
+ applyEnvDecisionModel(config: ExplorbotConfig): void;
267
274
  buildEnvConfig(baseUrl: string | undefined, outputRoot: string): Promise<ExplorbotConfig>;
268
275
  findConfigFile(): string | null;
269
276
  loadConfigModule(configPath: string): Promise<any>;
@@ -45,6 +45,7 @@ export const EXPLORBOT_ENV_VARS = [
45
45
  { name: 'EXPLORBOT_URL', required: true, description: 'Base URL to test; the API boat reads it as the base endpoint' },
46
46
  { name: 'EXPLORBOT_VISION_MODEL', description: 'Screenshot analysis; overrides the provider recommendation' },
47
47
  { name: 'EXPLORBOT_AGENTIC_MODEL', description: 'Captain and Pilot decisions; overrides the provider recommendation' },
48
+ { name: 'EXPLORBOT_DECISION_MODEL', description: 'Turns on the decision model, as provider/model-id (openrouter/typesafe/jev-1.13 or typesafe/jev-latest); overrides ai.decisionModel' },
48
49
  { name: 'EXPLORBOT_OUTPUT', description: 'Output root for states, plans, research, and reports. Defaults to the site dir under ~/.explorbot/sites' },
49
50
  { name: 'EXPLORBOT_EPHEMERAL', description: 'Keep no state between runs — output goes to a fresh temp directory instead of the site dir' },
50
51
  { name: 'EXPLORBOT_KNOWLEDGE', description: 'Inline knowledge text, applied to every page' },
@@ -138,6 +139,7 @@ export class ConfigParser {
138
139
  this.runtimeTarget = target;
139
140
  this.configPath = sourcePath;
140
141
  this.applyEnvSpec(this.config);
142
+ this.applyEnvDecisionModel(this.config);
141
143
  // Restore original directory after successful config load
142
144
  if (options?.path && originalCwd !== process.cwd()) {
143
145
  process.chdir(originalCwd);
@@ -300,6 +302,15 @@ export class ConfigParser {
300
302
  config.dirs = { knowledge: 'knowledge', experience: 'experience', output: 'output' };
301
303
  config.dirs.spec = spec;
302
304
  }
305
+ applyEnvDecisionModel(config) {
306
+ const spec = process.env.EXPLORBOT_DECISION_MODEL;
307
+ if (!spec)
308
+ return;
309
+ const separator = spec.indexOf('/');
310
+ if (separator < 1)
311
+ throw new Error('EXPLORBOT_DECISION_MODEL must be written as "provider/model-id", e.g. openrouter/typesafe/jev-1.13');
312
+ config.ai.decisionModel = { ...config.ai.decisionModel, provider: spec.slice(0, separator), model: spec.slice(separator + 1) };
313
+ }
303
314
  async buildEnvConfig(baseUrl, outputRoot) {
304
315
  const provider = process.env.EXPLORBOT_AI_PROVIDER;
305
316
  const modelSpec = process.env.EXPLORBOT_AI_MODEL;
@@ -224,7 +224,7 @@ export class ExperienceTracker {
224
224
  const sections = mdq(content).query('section2').each();
225
225
  if (sections.length <= 1)
226
226
  break;
227
- content = sections[sections.length - 1].replace('');
227
+ content = sections[sections.length - 1].replace('').toString();
228
228
  }
229
229
  return { ...experience, content };
230
230
  });
@@ -244,7 +244,7 @@ export class ExperienceTracker {
244
244
  continue;
245
245
  combined = renderAsHowTo(combined);
246
246
  if (options?.stripCode) {
247
- combined = mdq(combined).query('code').replace('');
247
+ combined = mdq(combined).query('code').replace('').toString();
248
248
  }
249
249
  if (combined.trim())
250
250
  results.push(combined.trim());
@@ -459,5 +459,6 @@ function renderAsHowTo(content) {
459
459
  if (text.startsWith('ACTION:'))
460
460
  return `## HOW to ${text.slice(7).trim()} (single-step)\n\n`;
461
461
  return heading.text();
462
- });
462
+ })
463
+ .toString();
463
464
  }
@@ -5,6 +5,7 @@ import { Driller } from './ai/driller.js';
5
5
  import { ExperienceCompactor } from './ai/experience-compactor.js';
6
6
  import { Fisherman } from './ai/fisherman.js';
7
7
  import { Historian } from './ai/historian.js';
8
+ import { Judge } from './ai/judge.js';
8
9
  import { Navigator } from './ai/navigator.js';
9
10
  import { Pilot } from './ai/pilot.js';
10
11
  import { Planner } from './ai/planner.js';
@@ -64,6 +65,7 @@ export declare class ExplorBot {
64
65
  _reporter?: Reporter;
65
66
  _requestStore?: RequestStore;
66
67
  _playwrightRecorder?: PlaywrightRecorder;
68
+ judgeInstance: Judge | null | undefined;
67
69
  constructor(options?: ExplorBotOptions);
68
70
  get isExploring(): boolean;
69
71
  setUserResolve(fn: UserResolveFunction): void;
@@ -83,6 +85,7 @@ export declare class ExplorBot {
83
85
  reporter(): Reporter;
84
86
  requestStore(): RequestStore;
85
87
  playwrightRecorder(): PlaywrightRecorder;
88
+ judge(): Judge | null;
86
89
  getConfig(): ExplorbotConfig;
87
90
  getOptions(): ExplorBotOptions;
88
91
  getProvider(): AIProvider;
@@ -5,6 +5,7 @@ import { Driller } from "./ai/driller.js";
5
5
  import { ExperienceCompactor } from "./ai/experience-compactor.js";
6
6
  import { Fisherman } from "./ai/fisherman.js";
7
7
  import { Historian } from "./ai/historian.js";
8
+ import { Judge } from "./ai/judge.js";
8
9
  import { Navigator } from "./ai/navigator.js";
9
10
  import { Pilot } from "./ai/pilot.js";
10
11
  import { Planner } from "./ai/planner.js";
@@ -56,6 +57,7 @@ export class ExplorBot {
56
57
  _reporter;
57
58
  _requestStore;
58
59
  _playwrightRecorder;
60
+ judgeInstance;
59
61
  constructor(options = {}) {
60
62
  this.options = options;
61
63
  this.configParser = ConfigParser.getInstance();
@@ -152,6 +154,11 @@ export class ExplorBot {
152
154
  playwrightRecorder() {
153
155
  return (this._playwrightRecorder ||= new PlaywrightRecorder());
154
156
  }
157
+ judge() {
158
+ if (this.judgeInstance === undefined)
159
+ this.judgeInstance = Judge.fromConfig(this.config.ai?.decisionModel);
160
+ return this.judgeInstance;
161
+ }
155
162
  getConfig() {
156
163
  return this.config;
157
164
  }
@@ -170,6 +177,7 @@ export class ExplorBot {
170
177
  knowledgeTracker: this.knowledgeTracker(),
171
178
  requestStore: this.requestStore(),
172
179
  playwrightRecorder: this.playwrightRecorder(),
180
+ judge: this.judge() || undefined,
173
181
  });
174
182
  const agentName = agent.constructor.name.toLowerCase();
175
183
  tag('debug').log(`Created ${agentName} agent`);
@@ -171,7 +171,7 @@ export class KnowledgeTracker {
171
171
  this.loadKnowledgeFiles();
172
172
  return this.knowledgeFiles.map((knowledge) => {
173
173
  const content = knowledge.content.trim();
174
- const firstLine = mdq(content).meta()[0]?.text.split('\n')[0]?.trim() || '';
174
+ const firstLine = mdq(content).nodes()[0]?.text.split('\n')[0]?.trim() || '';
175
175
  return {
176
176
  url: knowledge.url || knowledge.endpoint || '',
177
177
  firstLine,
@@ -72,6 +72,7 @@ export declare class StateManager {
72
72
  currentState: WebPageState | null;
73
73
  stateHistory: StateTransition[];
74
74
  allVisitedUrls: Set<string>;
75
+ experienceUrls?: string[];
75
76
  stateChangeListeners: StateChangeListener[];
76
77
  experienceTracker: ExperienceTracker;
77
78
  knowledgeTracker: KnowledgeTracker;
@@ -137,6 +138,7 @@ export declare class StateManager {
137
138
  */
138
139
  hasVisitedState(path: string): boolean;
139
140
  getAllVisitedUrls(): Set<string>;
141
+ getKnownUrls(): string[];
140
142
  /**
141
143
  * Get how many times we've visited a specific path
142
144
  */
@@ -9,6 +9,7 @@ export class StateManager {
9
9
  currentState = null;
10
10
  stateHistory = [];
11
11
  allVisitedUrls = new Set();
12
+ experienceUrls;
12
13
  stateChangeListeners = [];
13
14
  experienceTracker;
14
15
  knowledgeTracker;
@@ -243,6 +244,21 @@ export class StateManager {
243
244
  getAllVisitedUrls() {
244
245
  return this.allVisitedUrls;
245
246
  }
247
+ getKnownUrls() {
248
+ this.experienceUrls ||= this.experienceTracker
249
+ .getAllExperience()
250
+ .sort((left, right) => right.mtime.getTime() - left.mtime.getTime())
251
+ .flatMap((experience) => experience.data.url || []);
252
+ const sessionUrls = this.stateHistory.map((transition) => transition.toState.url).reverse();
253
+ const urls = new Map();
254
+ for (const url of [...sessionUrls, ...this.experienceUrls]) {
255
+ const key = normalizeUrl(url);
256
+ if (urls.has(key))
257
+ continue;
258
+ urls.set(key, url);
259
+ }
260
+ return [...urls.values()];
261
+ }
246
262
  /**
247
263
  * Get how many times we've visited a specific path
248
264
  */