explorbot 0.2.2 → 0.2.3

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 (101) hide show
  1. package/README.md +1 -1
  2. package/bin/explorbot-cli.ts +52 -37
  3. package/boat/api-tester/src/apibot.ts +4 -2
  4. package/boat/api-tester/src/cli.ts +2 -2
  5. package/boat/api-tester/src/config.ts +39 -8
  6. package/boat/doc-collector/src/cli.ts +1 -0
  7. package/boat/doc-collector/src/docs-renderer.ts +18 -4
  8. package/boat/doc-collector/src/state-diagram.ts +61 -14
  9. package/boat/prima/bin/prima-cli.ts +5 -0
  10. package/boat/prima/package.json +16 -0
  11. package/boat/prima/src/cli.ts +222 -0
  12. package/boat/prima/src/envelope.ts +141 -0
  13. package/boat/prima/src/prima.ts +705 -0
  14. package/boat/prima/src/pw-parser.ts +17 -0
  15. package/boat/prima/src/pw-registry.ts +75 -0
  16. package/dist/bin/explorbot-cli.js +44 -31
  17. package/dist/boat/api-tester/src/apibot.js +3 -2
  18. package/dist/boat/api-tester/src/cli.js +2 -2
  19. package/dist/boat/api-tester/src/config.js +36 -8
  20. package/dist/boat/doc-collector/src/cli.js +1 -0
  21. package/dist/boat/doc-collector/src/docs-renderer.js +17 -3
  22. package/dist/boat/doc-collector/src/state-diagram.js +57 -13
  23. package/dist/boat/prima/bin/prima-cli.js +4 -0
  24. package/dist/boat/prima/src/cli.js +200 -0
  25. package/dist/boat/prima/src/envelope.js +116 -0
  26. package/dist/boat/prima/src/prima.js +635 -0
  27. package/dist/boat/prima/src/pw-parser.js +18 -0
  28. package/dist/boat/prima/src/pw-registry.js +66 -0
  29. package/dist/models.json +3 -0
  30. package/dist/package.json +6 -2
  31. package/dist/src/action.d.ts +5 -2
  32. package/dist/src/action.js +5 -5
  33. package/dist/src/ai/captain/mixin.js +3 -4
  34. package/dist/src/ai/captain/web-mode.js +1 -1
  35. package/dist/src/ai/navigator.d.ts +4 -0
  36. package/dist/src/ai/navigator.js +11 -6
  37. package/dist/src/ai/planner.d.ts +1 -0
  38. package/dist/src/ai/planner.js +6 -0
  39. package/dist/src/ai/researcher.js +1 -1
  40. package/dist/src/ai/task-agent.js +1 -1
  41. package/dist/src/ai/tester.d.ts +1 -0
  42. package/dist/src/ai/tester.js +13 -0
  43. package/dist/src/application-spec-contract.d.ts +8 -0
  44. package/dist/src/application-spec-contract.js +8 -0
  45. package/dist/src/application-spec.d.ts +15 -0
  46. package/dist/src/application-spec.js +71 -0
  47. package/dist/src/browser-server.d.ts +12 -6
  48. package/dist/src/browser-server.js +74 -19
  49. package/dist/src/commands/clean-command.js +2 -7
  50. package/dist/src/commands/init-command.d.ts +5 -0
  51. package/dist/src/commands/init-command.js +119 -1
  52. package/dist/src/commands/navigate-command.js +1 -1
  53. package/dist/src/commands/research-command.js +1 -1
  54. package/dist/src/commands/sites-command.d.ts +6 -0
  55. package/dist/src/commands/sites-command.js +23 -0
  56. package/dist/src/components/InitWizard.d.ts +10 -0
  57. package/dist/src/components/InitWizard.js +133 -0
  58. package/dist/src/components/InputReadline.d.ts +1 -0
  59. package/dist/src/components/InputReadline.js +7 -4
  60. package/dist/src/config.d.ts +24 -5
  61. package/dist/src/config.js +146 -37
  62. package/dist/src/explorbot.d.ts +9 -0
  63. package/dist/src/explorbot.js +24 -5
  64. package/dist/src/explorer.d.ts +4 -1
  65. package/dist/src/explorer.js +40 -6
  66. package/dist/src/global-config.d.ts +22 -0
  67. package/dist/src/global-config.js +117 -0
  68. package/dist/src/knowledge-tracker.d.ts +5 -1
  69. package/dist/src/knowledge-tracker.js +14 -1
  70. package/dist/src/utils/cli-name.js +6 -2
  71. package/dist/src/utils/test-files.js +1 -2
  72. package/dist/src/utils/url-matcher.d.ts +1 -0
  73. package/dist/src/utils/url-matcher.js +9 -0
  74. package/models.json +3 -0
  75. package/package.json +6 -2
  76. package/src/action.ts +9 -5
  77. package/src/ai/captain/mixin.ts +3 -3
  78. package/src/ai/captain/web-mode.ts +1 -1
  79. package/src/ai/navigator.ts +12 -7
  80. package/src/ai/planner.ts +7 -0
  81. package/src/ai/researcher.ts +1 -1
  82. package/src/ai/task-agent.ts +1 -1
  83. package/src/ai/tester.ts +15 -0
  84. package/src/application-spec-contract.ts +10 -0
  85. package/src/application-spec.ts +87 -0
  86. package/src/browser-server.ts +74 -19
  87. package/src/commands/clean-command.ts +1 -6
  88. package/src/commands/init-command.ts +146 -1
  89. package/src/commands/navigate-command.ts +1 -1
  90. package/src/commands/research-command.ts +1 -1
  91. package/src/commands/sites-command.ts +27 -0
  92. package/src/components/InitWizard.tsx +166 -0
  93. package/src/components/InputReadline.tsx +8 -4
  94. package/src/config.ts +162 -39
  95. package/src/explorbot.ts +30 -5
  96. package/src/explorer.ts +45 -7
  97. package/src/global-config.ts +148 -0
  98. package/src/knowledge-tracker.ts +17 -1
  99. package/src/utils/cli-name.ts +5 -2
  100. package/src/utils/test-files.ts +1 -2
  101. package/src/utils/url-matcher.ts +10 -0
@@ -0,0 +1,222 @@
1
+ import { Command } from 'commander';
2
+ import dedent from 'dedent';
3
+ import { keepServerRunning } from '../../../src/browser-server.ts';
4
+ import { browserErrorMessage } from '../../../src/utils/browser-errors.ts';
5
+ import { setPreserveConsoleLogs } from '../../../src/utils/logger.ts';
6
+ import { type EnvelopeData, renderEnvelope } from './envelope.ts';
7
+ import { Prima, type PrimaOptions } from './prima.ts';
8
+
9
+ const helpContract = dedent`
10
+ Prima drives a browser that is already open. One command per process; every command
11
+ prints a plain-text envelope on stdout and exits 0 when ok, 1 when not.
12
+
13
+ TIERS - choose by what you hold, not by how hard the step looks
14
+ pw <fn> Precise. A Playwright function expression built from a locator you
15
+ already verified. No AI on the happy path.
16
+ prima pw "({ page }) => page.click('[data-test=submit]')"
17
+ click / fill One action described in words; AI resolves it on the current page.
18
+ prima click "the primary action button in the header"
19
+ prima fill "the search box" "a search term"
20
+ do <steps...> Several described steps, run tester-style in one process.
21
+ prima do "open the account menu" "choose the settings entry"
22
+ Never pass a locator or a function expression to click/fill/do - describe the target.
23
+ Never pass a description to pw - it takes executable code only.
24
+
25
+ LOOP
26
+ prima go <url|path|words> reach the page you want to work on
27
+ prima research once per new page; returns verified locators
28
+ prima pw "..." drive the page with those locators
29
+ prima verify "..." assert the outcome (prima ask "..." to inspect instead)
30
+ Fall back to click/fill/do whenever research left you no locator to hold.
31
+
32
+ ENVELOPE
33
+ ### Result ok, command, healed, used
34
+ ### Page url, title, state hash, visit count
35
+ ### Changes what the accessibility tree gained or lost
36
+ ### Answer | ### Research | ### Verdict output of ask, research, verify
37
+ ### Failure error, reasoning, healing attempts, compact ARIA of the page
38
+ ### Instance the browser you are on and the other instances running
39
+ ### Artifacts paths to the full aria.yml, page.html and network.jsonl
40
+ used: is code that already executed - CodeceptJS steps to copy as they are, except
41
+ for pw, whose Playwright expression a test needs inside I.usePlaywrightTo(...).
42
+ Log lines can precede the envelope; start parsing at the first ### line.
43
+
44
+ HEALING AND FAILURE
45
+ A failed action is retried by AI along a different route; healed: true means the
46
+ outcome was reached another way and used: holds the code that worked.
47
+ --no-heal skips that and fails fast.
48
+ Failures print compact ARIA inline, so retarget from the envelope itself and open
49
+ the artifact files only when the inline snapshot is not enough.
50
+
51
+ SESSIONS
52
+ By default prima attaches to the playwright-cli browser of this workspace and works
53
+ on the tabs it already has open; driving the same session from both tools is the
54
+ intended usage.
55
+ playwright-cli open <url> the session prima attaches to
56
+ --pw-session <title> which playwright-cli session, when several are open
57
+ --endpoint <ep> attach to a browser server endpoint directly
58
+ prima browser start a prima-owned browser instead, when no session is open
59
+ --instance <name> which prima-owned browser you talk to; parallel work
60
+ needs one each
61
+ --session [file] cookies and storage persisted across processes; ignored
62
+ while attached, the attached session keeps its own
63
+ Prima never launches a browser implicitly and never closes an attached one - it
64
+ disconnects. browser list shows both kinds; ### Instance names the one you are on.
65
+ Every browser is reached over a Playwright browser-server endpoint, which needs the
66
+ Node build - run prima as "npx explorbot prima ..." or through the published prima
67
+ bin; from source under Bun the connection does not open.
68
+ When no AI model is usable pw still works; for everything else drive
69
+ playwright-cli directly.
70
+ Parsed but not active yet: --framework, so reported code is CodeceptJS whatever
71
+ you pass.
72
+ `;
73
+
74
+ function buildOptions(options: any): PrimaOptions {
75
+ return {
76
+ verbose: options.verbose || options.debug,
77
+ config: options.config,
78
+ path: options.path,
79
+ instance: options.instance,
80
+ session: options.session,
81
+ heal: options.heal,
82
+ ephemeral: options.ephemeral,
83
+ framework: options.framework,
84
+ noVision: options.vision === false,
85
+ url: options.url,
86
+ baseUrl: options.baseUrl,
87
+ show: options.show,
88
+ headless: options.headless,
89
+ endpoint: options.endpoint,
90
+ pwSession: options.pwSession,
91
+ };
92
+ }
93
+
94
+ function addCommonOptions(cmd: Command): Command {
95
+ return cmd
96
+ .option('-v, --verbose', 'Enable verbose logging')
97
+ .option('--debug', 'Enable debug logging (same as --verbose)')
98
+ .option('-c, --config <path>', 'Path to explorbot configuration file')
99
+ .option('-p, --path <path>', 'Working directory path')
100
+ .option('-i, --instance <name>', 'Browser instance to drive')
101
+ .option('--session [file]', 'Persist cookies and storage to a session file')
102
+ .option('--no-heal', 'Fail immediately instead of letting AI retry a failed action')
103
+ .option('--ephemeral', 'Keep no state between runs; applies to config-free runs, where output goes to a temp directory')
104
+ .option('--framework <name>', 'Not active yet: framework the reported code targets, codeceptjs or playwright')
105
+ .option('--url <url>', 'Page to open when the session has no page yet')
106
+ .option('--endpoint <ep>', 'Websocket endpoint of a browser server to attach to, skipping discovery')
107
+ .option('--pw-session <title>', 'Title of the playwright-cli session to attach to');
108
+ }
109
+
110
+ function primaFor(options: any): Prima {
111
+ setPreserveConsoleLogs(true);
112
+ if (options.ephemeral) process.env.EXPLORBOT_EPHEMERAL = '1';
113
+ return new Prima(buildOptions(options));
114
+ }
115
+
116
+ async function runPrima(options: any, command: string, run: (prima: Prima) => Promise<EnvelopeData>): Promise<void> {
117
+ const prima = primaFor(options);
118
+
119
+ let envelope: EnvelopeData;
120
+ try {
121
+ await prima.start();
122
+ envelope = await run(prima);
123
+ } catch (error) {
124
+ envelope = await prima.toolFailureEnvelope(command, error);
125
+ }
126
+
127
+ console.log(renderEnvelope(envelope));
128
+ await prima.stop().catch(() => {});
129
+ process.exit(envelope.ok ? 0 : 1);
130
+ }
131
+
132
+ async function runBrowser(options: any, run: (prima: Prima) => Promise<boolean>): Promise<void> {
133
+ let ok = false;
134
+ try {
135
+ ok = await run(primaFor(options));
136
+ } catch (error) {
137
+ console.error(browserErrorMessage(error));
138
+ process.exit(1);
139
+ }
140
+
141
+ process.exit(ok ? 0 : 1);
142
+ }
143
+
144
+ export function createPrimaCommands(name = 'prima'): Command {
145
+ const cmd = new Command(name);
146
+ cmd.description('Drive an already-open browser one command at a time and report back in a plain-text envelope');
147
+ cmd.addHelpText('after', `\n${helpContract}`);
148
+
149
+ addCommonOptions(cmd.command('pw <fn>').description('Run a Playwright function expression against the open page')).action(async (fn, options) => {
150
+ await runPrima(options, `pw ${fn}`, (prima) => prima.pw(fn));
151
+ });
152
+
153
+ addCommonOptions(cmd.command('do <instructions...>').description('Run high-level instructions tester-style, one argument per instruction')).action(async (instructions, options) => {
154
+ await runPrima(options, `do ${instructions.join(' ')}`, (prima) => prima.do(instructions));
155
+ });
156
+
157
+ addCommonOptions(cmd.command('click <target>').description('Click an element described in plain words')).action(async (target, options) => {
158
+ await runPrima(options, `click ${target}`, (prima) => prima.click(target));
159
+ });
160
+
161
+ addCommonOptions(cmd.command('fill <field> <value>').description('Fill a field described in plain words')).action(async (field, value, options) => {
162
+ await runPrima(options, `fill ${field} ${value}`, (prima) => prima.fill(field, value));
163
+ });
164
+
165
+ addCommonOptions(cmd.command('ask <question>').description('Answer a question about the current page').option('--no-vision', 'Answer from page structure only, without a screenshot')).action(async (question, options) => {
166
+ await runPrima(options, `ask ${question}`, (prima) => prima.ask(question));
167
+ });
168
+
169
+ addCommonOptions(cmd.command('verify <assertion>').alias('assert').description('Assert a statement about the current page')).action(async (assertion, options) => {
170
+ await runPrima(options, `verify ${assertion}`, (prima) => prima.verify(assertion));
171
+ });
172
+
173
+ addCommonOptions(
174
+ cmd.command('research').description('Map the current page and return verified locators').option('--data', 'Include data extraction in the map').option('--deep', 'Expand hidden elements for a deeper map').option('--fresh', 'Ignore the cached map and research the page again')
175
+ ).action(async (options) => {
176
+ await runPrima(options, 'research', (prima) => prima.research({ data: options.data, deep: options.deep, fresh: options.fresh }));
177
+ });
178
+
179
+ addCommonOptions(cmd.command('go <target>').description('Navigate to a url, a path, or a page described in plain words')).action(async (target, options) => {
180
+ if (URL.canParse(target)) options.baseUrl = target;
181
+ await runPrima(options, `go ${target}`, (prima) => prima.go(target));
182
+ });
183
+
184
+ const browser = cmd.command('browser').description('Manage the browsers prima drives');
185
+
186
+ addCommonOptions(browser.command('start').description('Start a prima-owned browser and hold it open until Ctrl+C'))
187
+ .option('-s, --show', 'Launch the browser in a visible window')
188
+ .option('--headless', 'Launch the browser without a window')
189
+ .action(async (options) => {
190
+ await runBrowser(options, async (prima) => {
191
+ await prima.browserStart();
192
+ console.log(await prima.browserStatus());
193
+ return keepServerRunning(() => prima.browserStop());
194
+ });
195
+ });
196
+
197
+ addCommonOptions(browser.command('stop').description('Stop the browser of this instance'))
198
+ .option('--all', 'Stop every running instance')
199
+ .action(async (options) => {
200
+ await runBrowser(options, async (prima) => {
201
+ const stopped = await prima.browserStop(options.all);
202
+ console.log(await prima.browserStatus());
203
+ return stopped;
204
+ });
205
+ });
206
+
207
+ addCommonOptions(browser.command('status').description('Report the browser of this instance')).action(async (options) => {
208
+ await runBrowser(options, async (prima) => {
209
+ console.log(await prima.browserStatus());
210
+ return true;
211
+ });
212
+ });
213
+
214
+ addCommonOptions(browser.command('list').description('List every browser instance that is running')).action(async (options) => {
215
+ await runBrowser(options, async (prima) => {
216
+ console.log(await prima.browserList());
217
+ return true;
218
+ });
219
+ });
220
+
221
+ return cmd;
222
+ }
@@ -0,0 +1,141 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export interface InstanceInfo {
5
+ name: string;
6
+ tabs: number;
7
+ startedAgo?: string;
8
+ attached?: string;
9
+ others: Array<{ name: string; tabs: number }>;
10
+ }
11
+
12
+ export interface HealAttempt {
13
+ code: string;
14
+ outcome: string;
15
+ }
16
+
17
+ export interface EnvelopeData {
18
+ ok: boolean;
19
+ command: string;
20
+ healed?: boolean;
21
+ healNote?: string;
22
+ used?: string[];
23
+ page: { url: string; previousUrl?: string; title: string; state: string; visits: number };
24
+ changes?: string | null;
25
+ answer?: string;
26
+ research?: string;
27
+ verdict?: { passed: boolean; evidence: string; code: string };
28
+ failure?: { error: string; attempts: HealAttempt[]; reasoning?: string; compactAria?: string };
29
+ instance: InstanceInfo;
30
+ artifacts?: { aria: string; html: string; network: string };
31
+ }
32
+
33
+ export function renderEnvelope(data: EnvelopeData): string {
34
+ const sections = [renderResult(data), renderPage(data), renderOutcome(data), ...renderFailure(data), renderInstance(data.instance), renderArtifacts(data)];
35
+ return sections.filter((section) => section).join('\n\n');
36
+ }
37
+
38
+ export function writeArtifacts(dir: string, snapshot: { aria: string | null; html: string | null; requests: unknown[] }): { aria: string; html: string; network: string } {
39
+ mkdirSync(dir, { recursive: true });
40
+ const paths = {
41
+ aria: path.resolve(dir, 'aria.yml'),
42
+ html: path.resolve(dir, 'page.html'),
43
+ network: path.resolve(dir, 'network.jsonl'),
44
+ };
45
+ writeFileSync(paths.aria, snapshot.aria ?? '', 'utf-8');
46
+ writeFileSync(paths.html, snapshot.html ?? '', 'utf-8');
47
+ writeFileSync(paths.network, snapshot.requests.map((request) => `${JSON.stringify(request)}\n`).join(''), 'utf-8');
48
+ return paths;
49
+ }
50
+
51
+ function renderResult(data: EnvelopeData): string {
52
+ const lines = [`ok: ${data.ok}`, `command: ${data.command}`];
53
+ const healed = renderHealed(data);
54
+ if (healed) lines.push(healed);
55
+ if (data.used?.length) lines.push(`used: ${data.used.join('; ')}`);
56
+ return section('Result', lines.join('\n'));
57
+ }
58
+
59
+ function renderHealed(data: EnvelopeData): string | null {
60
+ if (data.healed === undefined) return null;
61
+ if (data.healNote) return `healed: ${data.healed} (${data.healNote})`;
62
+ return `healed: ${data.healed}`;
63
+ }
64
+
65
+ function renderPage(data: EnvelopeData): string {
66
+ const { url, previousUrl, title, state, visits } = data.page;
67
+ const urlLabel = `url: ${url}`;
68
+ const stateLabel = `state: ${state}`;
69
+ const width = Math.max(urlLabel.length, stateLabel.length) + 3;
70
+ let changedMarker = '';
71
+ if (previousUrl && previousUrl !== url) changedMarker = `(changed: ${previousUrl} → ${url})`;
72
+ const lines = [align(urlLabel, changedMarker, width), `title: ${title}`, align(stateLabel, `(visit #${visits})`, width)];
73
+ return section('Page', lines.join('\n'));
74
+ }
75
+
76
+ function renderOutcome(data: EnvelopeData): string | null {
77
+ if (data.changes) return section('Changes', data.changes);
78
+ if (data.answer) return section('Answer', data.answer);
79
+ if (data.research) return section('Research', data.research);
80
+ if (!data.verdict) return null;
81
+ const lines = [`passed: ${data.verdict.passed}`, `evidence: ${data.verdict.evidence}`, `code: ${data.verdict.code}`];
82
+ return section('Verdict', lines.join('\n'));
83
+ }
84
+
85
+ function renderFailure(data: EnvelopeData): Array<string | null> {
86
+ if (!data.failure) return [];
87
+ const lines = [`error: ${data.failure.error}`];
88
+ if (data.failure.reasoning) lines.push(`reasoning: ${data.failure.reasoning}`);
89
+ return [section('Failure', lines.join('\n')), renderAttempts(data.failure.attempts), renderCompactAria(data.failure.compactAria)];
90
+ }
91
+
92
+ function renderAttempts(attempts: HealAttempt[]): string | null {
93
+ if (!attempts?.length) return null;
94
+ const labels = attempts.map((attempt, index) => `${index + 1}. ${attempt.code}`);
95
+ const width = Math.max(...labels.map((label) => label.length)) + 3;
96
+ const lines = labels.map((label, index) => align(label, `→ ${attempts[index].outcome}`, width));
97
+ return section(`Healing attempts (${attempts.length})`, lines.join('\n'));
98
+ }
99
+
100
+ function renderCompactAria(compactAria?: string): string | null {
101
+ if (!compactAria) return null;
102
+ return section('Current page (compact ARIA)', compactAria);
103
+ }
104
+
105
+ function renderInstance(instance: InstanceInfo): string {
106
+ const others = instance.others.map((other) => `${other.name} (${tabsLabel(other.tabs)})`);
107
+ const lines = [`instance: ${instance.name} (${tabsLabel(instance.tabs)}) | other instances: ${otherInstances(others)}`, browserLine(instance)];
108
+ return section('Instance', lines.join('\n'));
109
+ }
110
+
111
+ function otherInstances(others: string[]): string {
112
+ if (!others.length) return 'none';
113
+ return others.join(', ');
114
+ }
115
+
116
+ function browserLine(instance: InstanceInfo): string {
117
+ if (instance.attached) return `browser: attached (${instance.attached})`;
118
+ if (instance.startedAgo) return `browser: running, started ${instance.startedAgo} ago`;
119
+ if (instance.tabs > 0) return 'browser: running';
120
+ return 'browser: not running';
121
+ }
122
+
123
+ function tabsLabel(tabs: number): string {
124
+ if (tabs === 1) return '1 tab';
125
+ return `${tabs} tabs`;
126
+ }
127
+
128
+ function renderArtifacts(data: EnvelopeData): string | null {
129
+ if (!data.artifacts) return null;
130
+ const lines = [`aria: ${data.artifacts.aria}`, `html: ${data.artifacts.html}`, `network: ${data.artifacts.network}`];
131
+ return section('Artifacts', lines.join('\n'));
132
+ }
133
+
134
+ function align(label: string, marker: string, width: number): string {
135
+ if (!marker) return label;
136
+ return `${label.padEnd(width)}${marker}`;
137
+ }
138
+
139
+ function section(title: string, body: string): string {
140
+ return `### ${title}\n${body}`;
141
+ }