explorbot 0.4.10 → 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 (128) 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/ai/agent.d.ts +3 -1
  12. package/dist/src/ai/judge-provider.d.ts +17 -0
  13. package/dist/src/ai/judge-provider.js +56 -0
  14. package/dist/src/ai/judge-tool.d.ts +2 -0
  15. package/dist/src/ai/judge-tool.js +33 -0
  16. package/dist/src/ai/judge.d.ts +28 -0
  17. package/dist/src/ai/judge.js +71 -0
  18. package/dist/src/ai/navigator.d.ts +3 -0
  19. package/dist/src/ai/navigator.js +13 -4
  20. package/dist/src/ai/pilot.d.ts +4 -0
  21. package/dist/src/ai/pilot.js +57 -5
  22. package/dist/src/ai/planner.js +9 -6
  23. package/dist/src/ai/provider.d.ts +4 -1
  24. package/dist/src/ai/provider.js +52 -7
  25. package/dist/src/ai/researcher/deep-analysis.js +2 -2
  26. package/dist/src/ai/researcher/locators.js +2 -2
  27. package/dist/src/ai/researcher/pagination.js +1 -1
  28. package/dist/src/ai/researcher/research-result.js +2 -2
  29. package/dist/src/ai/researcher.js +1 -1
  30. package/dist/src/ai/task-agent.d.ts +2 -0
  31. package/dist/src/ai/task-agent.js +3 -1
  32. package/dist/src/ai/tester.js +19 -15
  33. package/dist/src/ai/tools.d.ts +4 -3
  34. package/dist/src/ai/tools.js +35 -7
  35. package/dist/src/api/request-result.js +2 -1
  36. package/dist/src/command-handler.d.ts +1 -0
  37. package/dist/src/command-handler.js +24 -3
  38. package/dist/src/commands/base-command.d.ts +5 -0
  39. package/dist/src/commands/base-command.js +3 -0
  40. package/dist/src/commands/explore-command.d.ts +2 -1
  41. package/dist/src/commands/explore-command.js +12 -1
  42. package/dist/src/commands/freesail-command.js +8 -2
  43. package/dist/src/commands/init-command.js +1 -1
  44. package/dist/src/commands/navigate-command.d.ts +2 -1
  45. package/dist/src/commands/navigate-command.js +6 -0
  46. package/dist/src/commands/plan-load-command.d.ts +2 -1
  47. package/dist/src/commands/plan-load-command.js +4 -0
  48. package/dist/src/commands/plans-command.d.ts +3 -9
  49. package/dist/src/commands/plans-command.js +11 -21
  50. package/dist/src/commands/rerun-command.d.ts +2 -1
  51. package/dist/src/commands/rerun-command.js +5 -1
  52. package/dist/src/commands/research-command.d.ts +2 -1
  53. package/dist/src/commands/research-command.js +6 -0
  54. package/dist/src/commands/test-command.d.ts +2 -1
  55. package/dist/src/commands/test-command.js +4 -1
  56. package/dist/src/components/Autocomplete.js +26 -12
  57. package/dist/src/components/InputReadline.js +10 -1
  58. package/dist/src/config.d.ts +6 -0
  59. package/dist/src/experience-tracker.js +4 -3
  60. package/dist/src/explorbot.d.ts +3 -0
  61. package/dist/src/explorbot.js +8 -0
  62. package/dist/src/knowledge-tracker.js +1 -1
  63. package/dist/src/state-manager.d.ts +2 -0
  64. package/dist/src/state-manager.js +16 -0
  65. package/dist/src/test-plan.d.ts +11 -0
  66. package/dist/src/test-plan.js +54 -2
  67. package/dist/src/utils/aria-ref.js +1 -1
  68. package/dist/src/utils/logger.js +8 -2
  69. package/dist/src/utils/markdown-query.d.ts +1 -48
  70. package/dist/src/utils/markdown-query.js +1 -444
  71. package/dist/src/utils/mdq/cli.d.ts +6 -0
  72. package/dist/src/utils/mdq/cli.js +122 -0
  73. package/dist/src/utils/mdq/edit.d.ts +24 -0
  74. package/dist/src/utils/mdq/edit.js +147 -0
  75. package/dist/src/utils/mdq/query.d.ts +118 -0
  76. package/dist/src/utils/mdq/query.js +451 -0
  77. package/dist/src/utils/strings.d.ts +1 -0
  78. package/dist/src/utils/strings.js +7 -0
  79. package/dist/src/utils/test-files.d.ts +1 -0
  80. package/dist/src/utils/test-files.js +5 -2
  81. package/docs/api-testing/planning.md +1 -1
  82. package/docs/superpowers/plans/2026-09-15-mdq-package.md +130 -94
  83. package/docs/superpowers/specs/2026-09-18-judge-decision-model-design.md +79 -0
  84. package/package.json +3 -2
  85. package/src/ai/agent.ts +3 -1
  86. package/src/ai/judge-provider.ts +62 -0
  87. package/src/ai/judge-tool.ts +35 -0
  88. package/src/ai/judge.ts +75 -0
  89. package/src/ai/navigator.ts +15 -4
  90. package/src/ai/pilot.ts +58 -5
  91. package/src/ai/planner.ts +9 -6
  92. package/src/ai/provider.ts +51 -7
  93. package/src/ai/researcher/deep-analysis.ts +2 -2
  94. package/src/ai/researcher/locators.ts +2 -2
  95. package/src/ai/researcher/pagination.ts +1 -1
  96. package/src/ai/researcher/research-result.ts +2 -2
  97. package/src/ai/researcher.ts +1 -1
  98. package/src/ai/task-agent.ts +4 -1
  99. package/src/ai/tester.ts +19 -16
  100. package/src/ai/tools.ts +42 -7
  101. package/src/api/request-result.ts +2 -1
  102. package/src/command-handler.ts +28 -3
  103. package/src/commands/base-command.ts +9 -0
  104. package/src/commands/explore-command.ts +15 -2
  105. package/src/commands/freesail-command.ts +9 -2
  106. package/src/commands/init-command.ts +1 -1
  107. package/src/commands/navigate-command.ts +8 -1
  108. package/src/commands/plan-load-command.ts +6 -1
  109. package/src/commands/plans-command.ts +13 -29
  110. package/src/commands/rerun-command.ts +7 -2
  111. package/src/commands/research-command.ts +8 -1
  112. package/src/commands/test-command.ts +6 -2
  113. package/src/components/Autocomplete.tsx +39 -10
  114. package/src/components/InputReadline.tsx +10 -1
  115. package/src/config.ts +1 -0
  116. package/src/experience-tracker.ts +4 -3
  117. package/src/explorbot.ts +8 -0
  118. package/src/knowledge-tracker.ts +1 -1
  119. package/src/state-manager.ts +16 -0
  120. package/src/test-plan.ts +67 -2
  121. package/src/utils/aria-ref.ts +1 -1
  122. package/src/utils/logger.ts +6 -1
  123. package/src/utils/markdown-query.ts +1 -519
  124. package/src/utils/mdq/cli.ts +118 -0
  125. package/src/utils/mdq/edit.ts +158 -0
  126. package/src/utils/mdq/query.ts +556 -0
  127. package/src/utils/strings.ts +7 -0
  128. package/src/utils/test-files.ts +5 -2
@@ -1,10 +1,10 @@
1
- import { existsSync, readdirSync, statSync } from 'node:fs';
1
+ import { statSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { Plan } from '../test-plan.js';
3
+ import { Plan, type PlanFile } from '../test-plan.js';
4
4
  import { getCliName } from '../utils/cli-name.js';
5
5
  import { tag } from '../utils/logger.js';
6
6
  import { relativeToCwd } from '../utils/next-steps.js';
7
- import { BaseCommand } from './base-command.js';
7
+ import { type ArgumentCompletion, BaseCommand } from './base-command.js';
8
8
 
9
9
  export class PlansCommand extends BaseCommand {
10
10
  name = 'plans';
@@ -13,7 +13,7 @@ export class PlansCommand extends BaseCommand {
13
13
 
14
14
  async execute(args: string): Promise<void> {
15
15
  const { opts, args: remaining } = this.parseArgs(args);
16
- const files = this.getPlanFiles();
16
+ const files = Plan.listFiles(this.explorBot.getPlansDir());
17
17
  const target = String(opts.fromPlan || remaining[0] || '').trim();
18
18
 
19
19
  if (!target) {
@@ -25,22 +25,8 @@ export class PlansCommand extends BaseCommand {
25
25
  this.printPlanDetails(plan, file);
26
26
  }
27
27
 
28
- private getPlanFiles(): PlanFile[] {
29
- const plansDir = this.explorBot.getPlansDir();
30
- if (!existsSync(plansDir)) return [];
31
-
32
- return readdirSync(plansDir)
33
- .filter((file) => file.endsWith('.md'))
34
- .map((file) => {
35
- const filePath = path.join(plansDir, file);
36
- const stat = statSync(filePath);
37
- return {
38
- name: file,
39
- path: filePath,
40
- modifiedAt: stat.mtimeMs,
41
- };
42
- })
43
- .sort((left, right) => right.modifiedAt - left.modifiedAt);
28
+ completeArguments(): ArgumentCompletion[] {
29
+ return Plan.listFiles(this.explorBot.getPlansDir()).map((file) => ({ value: file.name, display: file.label }));
44
30
  }
45
31
 
46
32
  private printPlans(files: PlanFile[]): void {
@@ -52,8 +38,7 @@ export class PlansCommand extends BaseCommand {
52
38
  tag('info').log('Saved plans:');
53
39
  for (let i = 0; i < files.length; i++) {
54
40
  const file = files[i];
55
- const plan = Plan.fromMarkdown(file.path);
56
- tag('info').log(`${i + 1}. ${plan.title} (${plan.tests.length} tests) - ${file.name}`);
41
+ tag('info').log(`${i + 1}. ${file.title} (${file.testCount} tests) - ${file.name}`);
57
42
  }
58
43
  tag('info').log('');
59
44
  tag('info').log(`View plan tests: ${getCliName()} plans <number>`);
@@ -83,17 +68,16 @@ export class PlansCommand extends BaseCommand {
83
68
  throw new Error(`Plan file not found: ${target}`);
84
69
  }
85
70
 
71
+ const name = path.basename(plan.filePath);
86
72
  const file = {
87
- name: path.basename(plan.filePath),
73
+ name,
88
74
  path: plan.filePath,
89
75
  modifiedAt: statSync(plan.filePath).mtimeMs,
76
+ title: plan.title,
77
+ url: plan.startUrl || '',
78
+ testCount: plan.tests.length,
79
+ label: name,
90
80
  };
91
81
  return { plan, file };
92
82
  }
93
83
  }
94
-
95
- interface PlanFile {
96
- name: string;
97
- path: string;
98
- modifiedAt: number;
99
- }
@@ -1,8 +1,9 @@
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.ts';
4
4
  import { tag } from '../utils/logger.ts';
5
- import { BaseCommand } from './base-command.js';
5
+ import { listTestFiles } from '../utils/test-files.ts';
6
+ import { type ArgumentCompletion, BaseCommand } from './base-command.js';
6
7
 
7
8
  export class RerunCommand extends BaseCommand {
8
9
  name = 'rerun';
@@ -32,6 +33,10 @@ export class RerunCommand extends BaseCommand {
32
33
  const testIndices = indexArg ? parseTestIndices(indexArg) : undefined;
33
34
  await this.explorBot.agentRerunner().rerun(filePath, { testIndices });
34
35
  }
36
+
37
+ completeArguments(): ArgumentCompletion[] {
38
+ return listTestFiles(ConfigParser.getInstance().getTestsDir()).map((file) => ({ value: basename(file) }));
39
+ }
35
40
  }
36
41
 
37
42
  function parseTestIndices(input: string): number[] {
@@ -1,7 +1,7 @@
1
1
  import { join } from 'node:path';
2
2
  import { ConfigParser } from '../config.ts';
3
3
  import { tag } from '../utils/logger.ts';
4
- import { BaseCommand, type Suggestion } from './base-command.js';
4
+ import { type ArgumentCompletion, BaseCommand, type Suggestion } from './base-command.js';
5
5
 
6
6
  export class ResearchCommand extends BaseCommand {
7
7
  name = 'research';
@@ -51,4 +51,11 @@ export class ResearchCommand extends BaseCommand {
51
51
  this.suggestions = [{ command: 'research <page> --deep', hint: 'analyze page for all expandable elements and interactions' }];
52
52
  }
53
53
  }
54
+
55
+ completeArguments(): ArgumentCompletion[] {
56
+ return this.explorBot
57
+ .stateManager()
58
+ .getKnownUrls()
59
+ .map((url) => ({ value: url }));
60
+ }
54
61
  }
@@ -1,7 +1,7 @@
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
- import { BaseCommand, type Suggestion } from './base-command.js';
4
+ import { type ArgumentCompletion, BaseCommand, type Suggestion } from './base-command.js';
5
5
 
6
6
  export class TestCommand extends BaseCommand {
7
7
  name = 'test';
@@ -75,6 +75,10 @@ export class TestCommand extends BaseCommand {
75
75
  }
76
76
  tag('success').log('Test execution finished');
77
77
  }
78
+
79
+ completeArguments(): ArgumentCompletion[] {
80
+ return Plan.listFiles(this.explorBot.getPlansDir()).map((file) => ({ value: `--from-plan ${file.name}`, display: file.label }));
81
+ }
78
82
  }
79
83
 
80
84
  function parseTestIndices(input: string, total: number): number[] {
@@ -1,9 +1,14 @@
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';
4
5
 
6
+ const MAX_HEIGHT = 7;
7
+ const COLUMN_WIDTH = 24;
8
+
5
9
  const Autocomplete: React.FC = () => {
6
10
  const { suggestions, selectedIndex, visible, argumentHint } = useAutocompleteState();
11
+ const { stdout } = useStdout();
7
12
 
8
13
  if (!visible) {
9
14
  if (!argumentHint) {
@@ -20,24 +25,49 @@ const Autocomplete: React.FC = () => {
20
25
  }
21
26
 
22
27
  const effectiveSelectedIndex = Math.min(selectedIndex, suggestions.length - 1);
23
- const maxHeight = 7;
24
- const rowsPerColumn = Math.min(Math.max(1, maxHeight - 2), suggestions.length);
28
+ const rowsPerColumn = Math.min(Math.max(1, MAX_HEIGHT - 2), suggestions.length);
29
+ const widest = Math.max(...suggestions.map((suggestion) => suggestion.display.length));
30
+
31
+ if (widest > COLUMN_WIDTH) {
32
+ const width = Math.max(COLUMN_WIDTH, (stdout?.columns || 80) - 4);
33
+ let firstRow = 0;
34
+ if (effectiveSelectedIndex >= rowsPerColumn) {
35
+ firstRow = effectiveSelectedIndex - rowsPerColumn + 1;
36
+ }
37
+
38
+ return (
39
+ <Box position="absolute" top={0} left={0} width="100%" maxHeight={MAX_HEIGHT} overflow="hidden" paddingX={1} paddingY={1} backgroundColor="#2a2a2a" flexDirection="column">
40
+ {suggestions.slice(firstRow, firstRow + rowsPerColumn).map((suggestion, rowIndex) => {
41
+ const isSelected = firstRow + rowIndex === effectiveSelectedIndex;
42
+ let color = 'white';
43
+ let backgroundColor = '#2a2a2a';
44
+ if (isSelected) {
45
+ color = 'black';
46
+ backgroundColor = '#e6e6e6';
47
+ }
48
+
49
+ return (
50
+ <Text key={suggestion.value} color={color} backgroundColor={backgroundColor}>
51
+ {` ${truncate(suggestion.display, width).padEnd(width)} `}
52
+ </Text>
53
+ );
54
+ })}
55
+ </Box>
56
+ );
57
+ }
58
+
25
59
  const columns: (typeof suggestions)[] = [];
26
60
  for (let index = 0; index < suggestions.length; index += rowsPerColumn) {
27
61
  columns.push(suggestions.slice(index, index + rowsPerColumn));
28
62
  }
29
63
 
30
64
  return (
31
- <Box position="absolute" top={0} left={0} width="100%" maxHeight={maxHeight} overflow="hidden" paddingX={1} paddingY={1} backgroundColor="#2a2a2a" flexDirection="row" columnGap={2}>
65
+ <Box position="absolute" top={0} left={0} width="100%" maxHeight={MAX_HEIGHT} overflow="hidden" paddingX={1} paddingY={1} backgroundColor="#2a2a2a" flexDirection="row" columnGap={2}>
32
66
  {columns.map((column, columnIndex) => (
33
67
  <Box key={columnIndex} flexDirection="column">
34
68
  {column.map((suggestion, rowIndex) => {
35
69
  const index = columnIndex * rowsPerColumn + rowIndex;
36
70
  const isSelected = index === effectiveSelectedIndex;
37
- let display = suggestion.display;
38
- if (display.length > 24) {
39
- display = `${display.slice(0, 21)}...`;
40
- }
41
71
  let color = 'white';
42
72
  let backgroundColor = '#2a2a2a';
43
73
  if (isSelected) {
@@ -48,8 +78,7 @@ const Autocomplete: React.FC = () => {
48
78
  return (
49
79
  <Box key={suggestion.display} marginBottom={1}>
50
80
  <Text color={color} backgroundColor={backgroundColor}>
51
- {' '}
52
- {display}{' '}
81
+ {` ${suggestion.display} `}
53
82
  </Text>
54
83
  </Box>
55
84
  );
@@ -43,6 +43,7 @@ const InputReadline: React.FC<InputReadlineProps> = React.memo(({ commandHandler
43
43
  const cursorRef = useRef(cursorPosition);
44
44
  const showAutocompleteRef = useRef(showAutocomplete);
45
45
  const selectedIndexRef = useRef(selectedIndex);
46
+ const navigatedRef = useRef(false);
46
47
  const historyRef = useRef<string[]>([]);
47
48
  const historyIndexRef = useRef(-1);
48
49
  const historyDraftRef = useRef('');
@@ -85,6 +86,7 @@ const InputReadline: React.FC<InputReadlineProps> = React.memo(({ commandHandler
85
86
  cursorRef.current = 0;
86
87
  showAutocompleteRef.current = false;
87
88
  selectedIndexRef.current = 0;
89
+ navigatedRef.current = false;
88
90
  historyIndexRef.current = -1;
89
91
  historyDraftRef.current = '';
90
92
  setInputState({ value: '', cursor: 0, showAutocomplete: false, selectedIndex: 0 });
@@ -141,6 +143,7 @@ const InputReadline: React.FC<InputReadlineProps> = React.memo(({ commandHandler
141
143
  cursorRef.current = safeCursor;
142
144
  showAutocompleteRef.current = nextShowAutocomplete;
143
145
  selectedIndexRef.current = 0;
146
+ navigatedRef.current = false;
144
147
  historyIndexRef.current = -1;
145
148
  historyDraftRef.current = '';
146
149
 
@@ -235,6 +238,7 @@ const InputReadline: React.FC<InputReadlineProps> = React.memo(({ commandHandler
235
238
  if (showAutocompleteRef.current) {
236
239
  showAutocompleteRef.current = false;
237
240
  selectedIndexRef.current = 0;
241
+ navigatedRef.current = false;
238
242
  setInputState((prev) => ({ ...prev, showAutocomplete: false, selectedIndex: 0 }));
239
243
  }
240
244
  return;
@@ -247,7 +251,7 @@ const InputReadline: React.FC<InputReadlineProps> = React.memo(({ commandHandler
247
251
  }
248
252
  if (commandHandler && showAutocompleteRef.current) {
249
253
  const autocomplete = getAutocomplete(inputRef.current, cursorRef.current);
250
- if (autocomplete.visible && autocomplete.suggestions.length > 0) {
254
+ if (autocomplete.visible && autocomplete.suggestions.length > 0 && (!autocomplete.completesArgument || navigatedRef.current)) {
251
255
  const chosen = applyAutocomplete(selectedIndexRef.current);
252
256
  if (chosen) {
253
257
  handleSubmit(chosen);
@@ -381,6 +385,7 @@ const InputReadline: React.FC<InputReadlineProps> = React.memo(({ commandHandler
381
385
  if (autocomplete.visible && autocomplete.suggestions.length > 0) {
382
386
  const nextIndex = selectedIndexRef.current > 0 ? selectedIndexRef.current - 1 : autocomplete.suggestions.length - 1;
383
387
  selectedIndexRef.current = nextIndex;
388
+ navigatedRef.current = true;
384
389
  setInputState((prev) => ({ ...prev, selectedIndex: nextIndex }));
385
390
  return;
386
391
  }
@@ -391,6 +396,7 @@ const InputReadline: React.FC<InputReadlineProps> = React.memo(({ commandHandler
391
396
  if (autocomplete.visible && autocomplete.suggestions.length > 0) {
392
397
  const nextIndex = selectedIndexRef.current < autocomplete.suggestions.length - 1 ? selectedIndexRef.current + 1 : 0;
393
398
  selectedIndexRef.current = nextIndex;
399
+ navigatedRef.current = true;
394
400
  setInputState((prev) => ({ ...prev, selectedIndex: nextIndex }));
395
401
  return;
396
402
  }
@@ -449,6 +455,7 @@ const InputReadline: React.FC<InputReadlineProps> = React.memo(({ commandHandler
449
455
  inputRef.current = nextValue;
450
456
  cursorRef.current = nextValue.length;
451
457
  selectedIndexRef.current = 0;
458
+ navigatedRef.current = false;
452
459
  showAutocompleteRef.current = nextShowAutocomplete;
453
460
  setInputState({ value: nextValue, cursor: nextValue.length, selectedIndex: 0, showAutocomplete: nextShowAutocomplete });
454
461
  return;
@@ -470,6 +477,7 @@ const InputReadline: React.FC<InputReadlineProps> = React.memo(({ commandHandler
470
477
  inputRef.current = nextValue;
471
478
  cursorRef.current = nextValue.length;
472
479
  selectedIndexRef.current = 0;
480
+ navigatedRef.current = false;
473
481
  showAutocompleteRef.current = nextShowAutocomplete;
474
482
  setInputState({ value: nextValue, cursor: nextValue.length, selectedIndex: 0, showAutocomplete: nextShowAutocomplete });
475
483
  return;
@@ -481,6 +489,7 @@ const InputReadline: React.FC<InputReadlineProps> = React.memo(({ commandHandler
481
489
  inputRef.current = nextValue;
482
490
  cursorRef.current = nextValue.length;
483
491
  selectedIndexRef.current = 0;
492
+ navigatedRef.current = false;
484
493
  showAutocompleteRef.current = nextShowAutocomplete;
485
494
  setInputState({ value: nextValue, cursor: nextValue.length, selectedIndex: 0, showAutocomplete: nextShowAutocomplete });
486
495
  return;
package/src/config.ts CHANGED
@@ -185,6 +185,7 @@ interface AIConfig {
185
185
  vision?: boolean;
186
186
  visionModel?: any;
187
187
  agenticModel?: any;
188
+ decisionModel?: { provider: string; model: string; tool?: boolean; direct?: boolean };
188
189
  retryAttempts?: number;
189
190
  retryDelay?: number;
190
191
  maxParallelRequests?: number;
@@ -262,7 +262,7 @@ export class ExperienceTracker {
262
262
  while (content.split('\n').length > maxLines) {
263
263
  const sections = mdq(content).query('section2').each();
264
264
  if (sections.length <= 1) break;
265
- content = sections[sections.length - 1].replace('');
265
+ content = sections[sections.length - 1].replace('').toString();
266
266
  }
267
267
  return { ...experience, content };
268
268
  });
@@ -286,7 +286,7 @@ export class ExperienceTracker {
286
286
  combined = renderAsHowTo(combined);
287
287
 
288
288
  if (options?.stripCode) {
289
- combined = mdq(combined).query('code').replace('');
289
+ combined = mdq(combined).query('code').replace('').toString();
290
290
  }
291
291
 
292
292
  if (combined.trim()) results.push(combined.trim());
@@ -515,7 +515,8 @@ function renderAsHowTo(content: string): string {
515
515
  if (text.startsWith('FLOW:')) return `## HOW to ${text.slice(5).trim()} (multi-step)\n\n`;
516
516
  if (text.startsWith('ACTION:')) return `## HOW to ${text.slice(7).trim()} (single-step)\n\n`;
517
517
  return heading.text();
518
- });
518
+ })
519
+ .toString();
519
520
  }
520
521
 
521
522
  export interface ExperienceFile {
package/src/explorbot.ts CHANGED
@@ -7,6 +7,7 @@ import { Driller } from './ai/driller.ts';
7
7
  import { ExperienceCompactor } from './ai/experience-compactor.ts';
8
8
  import { Fisherman } from './ai/fisherman.ts';
9
9
  import { Historian } from './ai/historian.ts';
10
+ import { Judge } from './ai/judge.ts';
10
11
  import { Navigator } from './ai/navigator.ts';
11
12
  import { Pilot } from './ai/pilot.ts';
12
13
  import { Planner } from './ai/planner.ts';
@@ -80,6 +81,7 @@ export class ExplorBot {
80
81
  private _reporter?: Reporter;
81
82
  private _requestStore?: RequestStore;
82
83
  private _playwrightRecorder?: PlaywrightRecorder;
84
+ private judgeInstance: Judge | null | undefined;
83
85
 
84
86
  constructor(options: ExplorBotOptions = {}) {
85
87
  this.options = options;
@@ -192,6 +194,11 @@ export class ExplorBot {
192
194
  return (this._playwrightRecorder ||= new PlaywrightRecorder());
193
195
  }
194
196
 
197
+ judge(): Judge | null {
198
+ if (this.judgeInstance === undefined) this.judgeInstance = Judge.fromConfig(this.config.ai?.decisionModel);
199
+ return this.judgeInstance;
200
+ }
201
+
195
202
  getConfig(): ExplorbotConfig {
196
203
  return this.config;
197
204
  }
@@ -213,6 +220,7 @@ export class ExplorBot {
213
220
  knowledgeTracker: this.knowledgeTracker(),
214
221
  requestStore: this.requestStore(),
215
222
  playwrightRecorder: this.playwrightRecorder(),
223
+ judge: this.judge() || undefined,
216
224
  });
217
225
 
218
226
  const agentName = (agent as any).constructor.name.toLowerCase();
@@ -214,7 +214,7 @@ export class KnowledgeTracker {
214
214
 
215
215
  return this.knowledgeFiles.map((knowledge) => {
216
216
  const content = knowledge.content.trim();
217
- const firstLine = mdq(content).meta()[0]?.text.split('\n')[0]?.trim() || '';
217
+ const firstLine = mdq(content).nodes()[0]?.text.split('\n')[0]?.trim() || '';
218
218
  return {
219
219
  url: knowledge.url || knowledge.endpoint || '',
220
220
  firstLine,
@@ -86,6 +86,7 @@ export class StateManager {
86
86
  private currentState: WebPageState | null = null;
87
87
  private stateHistory: StateTransition[] = [];
88
88
  private allVisitedUrls: Set<string> = new Set();
89
+ private experienceUrls?: string[];
89
90
  private stateChangeListeners: StateChangeListener[] = [];
90
91
  private experienceTracker!: ExperienceTracker;
91
92
  private knowledgeTracker: KnowledgeTracker;
@@ -359,6 +360,21 @@ export class StateManager {
359
360
  return this.allVisitedUrls;
360
361
  }
361
362
 
363
+ getKnownUrls(): string[] {
364
+ this.experienceUrls ||= this.experienceTracker
365
+ .getAllExperience()
366
+ .sort((left, right) => right.mtime.getTime() - left.mtime.getTime())
367
+ .flatMap((experience) => experience.data.url || []);
368
+ const sessionUrls = this.stateHistory.map((transition) => transition.toState.url).reverse();
369
+ const urls = new Map<string, string>();
370
+ for (const url of [...sessionUrls, ...this.experienceUrls]) {
371
+ const key = normalizeUrl(url);
372
+ if (urls.has(key)) continue;
373
+ urls.set(key, url);
374
+ }
375
+ return [...urls.values()];
376
+ }
377
+
362
378
  /**
363
379
  * Get how many times we've visited a specific path
364
380
  */
package/src/test-plan.ts CHANGED
@@ -1,13 +1,15 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { existsSync } from 'node:fs';
2
+ import { existsSync, readdirSync, statSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import figures from 'figures';
5
5
  import type { ActionResult } from './action-result.ts';
6
6
  import { listSitePlanDirs } from './global-config.ts';
7
7
  import { WebPageState } from './state-manager.ts';
8
- import { tag } from './utils/logger.ts';
8
+ import { createDebug, pluralize, tag } from './utils/logger.ts';
9
+ import { truncateMiddle } from './utils/strings.ts';
9
10
  import { parsePlanFromMarkdown, planToAiContext, savePlanToMarkdown, savePlansToMarkdown } from './utils/test-plan-markdown.ts';
10
11
  import { uniqSessionName } from './utils/unique-names.ts';
12
+ import { extractStatePath } from './utils/url-matcher.ts';
11
13
 
12
14
  export const TestResult = {
13
15
  PASSED: 'passed',
@@ -241,6 +243,7 @@ export class Test extends Task {
241
243
  endTime?: number;
242
244
  resetCount = 0;
243
245
  appliedExperience: AppliedExperience[] = [];
246
+ preparedData: string[] = [];
244
247
 
245
248
  constructor(scenario: string, priority: 'critical' | 'important' | 'high' | 'normal' | 'low', expectedOutcome: string | string[], startUrl: string, plannedSteps: string[] = []) {
246
249
  super(scenario, startUrl);
@@ -477,6 +480,39 @@ export class Plan {
477
480
 
478
481
  updateStatus(): void {}
479
482
 
483
+ static listFiles(plansDir: string): PlanFile[] {
484
+ if (!existsSync(plansDir)) return [];
485
+
486
+ const files = readdirSync(plansDir)
487
+ .filter((file) => file.endsWith('.md'))
488
+ .map((file) => {
489
+ const filePath = path.join(plansDir, file);
490
+ const modifiedAt = statSync(filePath).mtimeMs;
491
+ const plan = readPlanFile(filePath, modifiedAt);
492
+ return {
493
+ name: file,
494
+ path: filePath,
495
+ modifiedAt,
496
+ title: plan.title,
497
+ url: extractStatePath(plan.startUrl || ''),
498
+ testCount: plan.tests.length,
499
+ label: '',
500
+ };
501
+ })
502
+ .sort((left, right) => right.modifiedAt - left.modifiedAt);
503
+
504
+ if (files.length === 0) return files;
505
+
506
+ const nameWidth = Math.min(MAX_PLAN_NAME_WIDTH, Math.max(...files.map((file) => file.name.length)));
507
+ const urlWidth = Math.min(MAX_PLAN_URL_WIDTH, Math.max(...files.map((file) => file.url.length)));
508
+ for (const file of files) {
509
+ const name = truncateMiddle(file.name, nameWidth).padEnd(nameWidth);
510
+ const url = truncateMiddle(file.url, urlWidth).padEnd(urlWidth);
511
+ file.label = `${name} ${url} ${file.testCount} ${pluralize(file.testCount, 'test')}`;
512
+ }
513
+ return files;
514
+ }
515
+
480
516
  static resolveFile(file: string, plansDir?: string): string | null {
481
517
  const names = [file];
482
518
  if (!file.endsWith('.md')) names.push(`${file}.md`);
@@ -579,3 +615,32 @@ interface AppliedExperience {
579
615
  url: string;
580
616
  content: string;
581
617
  }
618
+
619
+ export interface PlanFile {
620
+ name: string;
621
+ path: string;
622
+ modifiedAt: number;
623
+ title: string;
624
+ url: string;
625
+ testCount: number;
626
+ label: string;
627
+ }
628
+
629
+ const debugLog = createDebug('explorbot:test-plan');
630
+ const MAX_PLAN_NAME_WIDTH = 38;
631
+ const MAX_PLAN_URL_WIDTH = 26;
632
+ const planFileCache = new Map<string, { modifiedAt: number; plan: Plan }>();
633
+
634
+ function readPlanFile(filePath: string, modifiedAt: number): Plan {
635
+ const cached = planFileCache.get(filePath);
636
+ if (cached?.modifiedAt === modifiedAt) return cached.plan;
637
+
638
+ let plan = new Plan(path.basename(filePath));
639
+ try {
640
+ plan = parsePlanFromMarkdown(filePath);
641
+ } catch (error) {
642
+ debugLog(`Failed to read plan file ${filePath}:`, error);
643
+ }
644
+ planFileCache.set(filePath, { modifiedAt, plan });
645
+ return plan;
646
+ }
@@ -1,6 +1,6 @@
1
1
  import { WebElement } from './web-element.ts';
2
2
 
3
- const REF_LINE_PATTERN = /^(\s*)-\s+(\w+)\s*(?:"([^"]*)")?.*?\[ref=(e\d+)\]/;
3
+ const REF_LINE_PATTERN = /^(\s*)-\s+(\w+)\s*(?:"([^"]*)")?.*?\[ref=((?:f\d+)?e\d+)\]/;
4
4
  const ARIA_REF_PATTERN = /^(f\d+)?e\d+$/i;
5
5
 
6
6
  const REF_ROLES: Record<string, string> = { a: 'link', button: 'button', select: 'combobox', textarea: 'textbox' };
@@ -103,7 +103,12 @@ class ConsoleDestination implements LogDestination {
103
103
  if (entry.type === 'debug') return;
104
104
  if (entry.type === 'html') return;
105
105
  if (entry.type === 'operation' && !this.verboseMode) return;
106
- if (entry.type === 'step' && !this.verboseMode && this.recentSteps.shouldSuppress(entry.content)) return;
106
+ if (entry.type === 'step' && !this.verboseMode) {
107
+ const step = entry.originalArgs?.[0];
108
+ const error = entry.originalArgs?.[1];
109
+ if (error || step?.failed === true || step?.status === 'failed') return;
110
+ if (this.recentSteps.shouldSuppress(entry.content)) return;
111
+ }
107
112
  let content = entry.content;
108
113
  if (entry.type === 'multiline' || entry.type === 'details') {
109
114
  const cleaned = stripAnsi(dedent(entry.content));