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,117 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
2
+ import os from 'node:os';
3
+ import { join } from 'node:path';
4
+ const GLOBAL_CONFIG_NAMES = ['config.js', 'config.mjs', 'config.ts'];
5
+ const SITE_DIRS = ['knowledge', 'experience', 'output'];
6
+ export function globalDir() {
7
+ return join(os.homedir(), '.explorbot');
8
+ }
9
+ export function globalEnvPath() {
10
+ return join(globalDir(), '.env');
11
+ }
12
+ export function globalConfigPath() {
13
+ return join(globalDir(), 'config.js');
14
+ }
15
+ export function findGlobalConfig() {
16
+ for (const name of GLOBAL_CONFIG_NAMES) {
17
+ const fullPath = join(globalDir(), name);
18
+ if (existsSync(fullPath))
19
+ return fullPath;
20
+ }
21
+ return null;
22
+ }
23
+ export function isGlobalConfigPath(configPath) {
24
+ return GLOBAL_CONFIG_NAMES.some((name) => join(globalDir(), name) === configPath);
25
+ }
26
+ export function sitesDir() {
27
+ return join(globalDir(), 'sites');
28
+ }
29
+ export function siteFolderName(url) {
30
+ return new URL(url).host.toLowerCase().replace(/[^a-z0-9._-]/g, '_');
31
+ }
32
+ export function listSites() {
33
+ if (!existsSync(sitesDir()))
34
+ return [];
35
+ return readdirSync(sitesDir(), { withFileTypes: true })
36
+ .filter((entry) => entry.isDirectory())
37
+ .map((entry) => readSite(entry.name))
38
+ .filter((site) => !!site)
39
+ .sort((a, b) => b.lastRunAt.localeCompare(a.lastRunAt));
40
+ }
41
+ export function registerSite(baseUrl) {
42
+ const folder = siteFolderName(baseUrl);
43
+ const dir = join(sitesDir(), folder);
44
+ for (const subDir of SITE_DIRS) {
45
+ mkdirSync(join(dir, subDir), { recursive: true, mode: 0o700 });
46
+ }
47
+ const now = new Date().toISOString();
48
+ const meta = {
49
+ url: baseUrl,
50
+ createdAt: readSite(folder)?.createdAt || now,
51
+ lastRunAt: now,
52
+ };
53
+ writeFileSync(join(dir, 'site.json'), `${JSON.stringify(meta, null, 2)}\n`, 'utf8');
54
+ return { folder, dir, ...meta };
55
+ }
56
+ export function resolveSiteTarget(target, defaultBaseUrl) {
57
+ const raw = (target || process.env.EXPLORBOT_URL || defaultBaseUrl || '').trim();
58
+ if (!raw) {
59
+ throw new Error(withSites('No site to explore. Pass a URL to the command or set EXPLORBOT_URL.'));
60
+ }
61
+ if (raw.startsWith('http://') || raw.startsWith('https://')) {
62
+ const url = new URL(raw);
63
+ return { baseUrl: url.origin, path: `${url.pathname}${url.search}${url.hash}` };
64
+ }
65
+ if (raw.startsWith('/')) {
66
+ const base = defaultBaseUrl || process.env.EXPLORBOT_URL;
67
+ if (!base) {
68
+ throw new Error(withSites(`Cannot resolve path "${raw}" without a site.`));
69
+ }
70
+ return { baseUrl: new URL(base).origin, path: raw };
71
+ }
72
+ let reference = raw;
73
+ let path = '/';
74
+ const separator = raw.indexOf('/');
75
+ if (separator > -1) {
76
+ reference = raw.slice(0, separator);
77
+ path = raw.slice(separator);
78
+ }
79
+ const site = findSite(reference);
80
+ if (!site) {
81
+ throw new Error(withSites(`Unknown site "${reference}".`));
82
+ }
83
+ return { baseUrl: site.url, path };
84
+ }
85
+ function readSite(folder) {
86
+ const dir = join(sitesDir(), folder);
87
+ const metaPath = join(dir, 'site.json');
88
+ if (!existsSync(metaPath))
89
+ return null;
90
+ try {
91
+ const meta = JSON.parse(readFileSync(metaPath, 'utf8'));
92
+ new URL(meta.url);
93
+ return { folder, dir, url: meta.url, createdAt: meta.createdAt, lastRunAt: meta.lastRunAt };
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ }
99
+ function findSite(reference) {
100
+ const normalized = reference.toLowerCase().replace(/[^a-z0-9._-]/g, '_');
101
+ const sites = listSites();
102
+ const byFolder = sites.find((site) => site.folder === normalized);
103
+ if (byFolder)
104
+ return byFolder;
105
+ return sites.find((site) => new URL(site.url).host.toLowerCase() === reference.toLowerCase()) || null;
106
+ }
107
+ function withSites(message) {
108
+ const lines = [message];
109
+ const sites = listSites();
110
+ if (sites.length) {
111
+ lines.push('Registered sites:');
112
+ for (const site of sites)
113
+ lines.push(` ${site.folder} → ${site.url}`);
114
+ }
115
+ lines.push('Explore a new site by passing its full URL, e.g. https://app.example.com/login');
116
+ return lines.join('\n');
117
+ }
@@ -1,4 +1,5 @@
1
1
  import { ActionResult } from './action-result.js';
2
+ import { ApplicationSpec } from './application-spec.js';
2
3
  export interface Knowledge {
3
4
  filePath: string;
4
5
  url: string;
@@ -9,10 +10,13 @@ export declare class KnowledgeTracker {
9
10
  knowledgeDir: string;
10
11
  knowledgeFiles: Knowledge[];
11
12
  isLoaded: boolean;
12
- constructor();
13
+ applicationSpec?: ApplicationSpec;
14
+ constructor(applicationSpecPath?: string);
13
15
  loadKnowledgeFiles(): void;
14
16
  getRelevantKnowledge(state: ActionResult): Knowledge[];
15
17
  renderRelevantKnowledge(state: ActionResult): string;
18
+ renderRelevantContext(state: ActionResult): string;
19
+ renderApplicationSpec(state: ActionResult): string;
16
20
  addKnowledge(urlPattern: string, description: string): {
17
21
  filename: string;
18
22
  filePath: string;
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import dedent from 'dedent';
4
4
  import matter from 'gray-matter';
5
5
  import { ActionResult } from './action-result.js';
6
+ import { ApplicationSpec } from "./application-spec.js";
6
7
  import { ConfigParser } from './config.js';
7
8
  import { getCliName } from "./utils/cli-name.js";
8
9
  import { createDebug, pluralize, tag } from './utils/logger.js';
@@ -15,13 +16,19 @@ export class KnowledgeTracker {
15
16
  knowledgeDir;
16
17
  knowledgeFiles = [];
17
18
  isLoaded = false;
18
- constructor() {
19
+ applicationSpec;
20
+ constructor(applicationSpecPath) {
19
21
  const configParser = ConfigParser.getInstance();
20
22
  const config = configParser.getConfig();
21
23
  this.knowledgeDir = configParser.resolveProjectDir(config.dirs?.knowledge || 'knowledge');
22
24
  if (!existsSync(this.knowledgeDir)) {
23
25
  mkdirSync(this.knowledgeDir, { recursive: true });
24
26
  }
27
+ const specPath = applicationSpecPath || config.dirs?.spec;
28
+ if (specPath) {
29
+ this.applicationSpec = new ApplicationSpec(specPath);
30
+ tag('info').log(`Loaded application spec with ${this.applicationSpec.pageCount} documented pages`);
31
+ }
25
32
  }
26
33
  loadKnowledgeFiles() {
27
34
  if (this.isLoaded)
@@ -60,6 +67,12 @@ export class KnowledgeTracker {
60
67
  </knowledge>
61
68
  `;
62
69
  }
70
+ renderRelevantContext(state) {
71
+ return [this.renderRelevantKnowledge(state), this.renderApplicationSpec(state)].filter(Boolean).join('\n\n');
72
+ }
73
+ renderApplicationSpec(state) {
74
+ return this.applicationSpec?.renderFor(state) || '';
75
+ }
63
76
  addKnowledge(urlPattern, description) {
64
77
  const configParser = ConfigParser.getInstance();
65
78
  const configPath = configParser.getConfigPath();
@@ -8,8 +8,12 @@ export function getCliName() {
8
8
  cached = 'bunx explorbot';
9
9
  else if (ua.includes('npm'))
10
10
  cached = 'npx explorbot';
11
- else if (process.argv[1]?.endsWith('.ts'))
12
- cached = `bun ${path.relative(process.cwd(), process.argv[1])}`;
11
+ else if (process.argv[1]?.endsWith('.ts')) {
12
+ let script = path.relative(process.cwd(), process.argv[1]);
13
+ if (script.startsWith('..'))
14
+ script = process.argv[1];
15
+ cached = `bun ${script}`;
16
+ }
13
17
  else
14
18
  cached = 'explorbot';
15
19
  return cached;
@@ -57,8 +57,7 @@ export async function dryRunTestFile(filePath) {
57
57
  return;
58
58
  }
59
59
  const config = ConfigParser.getInstance().getConfig();
60
- const configPath = ConfigParser.getInstance().getConfigPath();
61
- const projectRoot = configPath ? path.dirname(configPath) : process.cwd();
60
+ const projectRoot = ConfigParser.getInstance().getProjectRoot();
62
61
  const codeceptConfig = {
63
62
  helpers: {
64
63
  Playwright: { browser: config.playwright.browser, url: config.playwright.url },
@@ -4,3 +4,4 @@ export declare function generalizeSegment(segment: string): string;
4
4
  export declare function generalizeUrl(url: string): string;
5
5
  export declare function matchesUrl(pattern: string, path: string): boolean;
6
6
  export declare function extractStatePath(url: string): string;
7
+ export declare function matchesNavigationUrl(expected: string, current: string): boolean;
@@ -102,3 +102,12 @@ export function extractStatePath(url) {
102
102
  return url;
103
103
  }
104
104
  }
105
+ export function matchesNavigationUrl(expected, current) {
106
+ const expectedPath = extractStatePath(expected);
107
+ let currentPath = extractStatePath(current);
108
+ if (!expectedPath.includes('#')) {
109
+ currentPath = currentPath.split('#')[0];
110
+ }
111
+ const normalize = (value) => value.replace(/^\/+|\/+$/g, '').toLowerCase();
112
+ return normalize(expectedPath) === normalize(currentPath);
113
+ }
package/models.json CHANGED
@@ -4,6 +4,9 @@
4
4
  "visionModel": "google/gemma-4-31b-it:nitro",
5
5
  "agenticModel": "google/gemma-4-31b-it:nitro"
6
6
  },
7
+ "poolside": {
8
+ "model": "poolside/laguna-xs-2.1"
9
+ },
7
10
  "groq": {
8
11
  "model": "openai/gpt-oss-20b",
9
12
  "visionModel": "qwen/qwen3.6-27b",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -14,7 +14,8 @@
14
14
  }
15
15
  },
16
16
  "bin": {
17
- "explorbot": "./dist/bin/explorbot-cli.js"
17
+ "explorbot": "./dist/bin/explorbot-cli.js",
18
+ "prima": "./dist/boat/prima/bin/prima-cli.js"
18
19
  },
19
20
  "files": [
20
21
  "dist/",
@@ -25,6 +26,9 @@
25
26
  "boat/doc-collector/src/**/*.ts",
26
27
  "boat/doc-collector/bin/**/*.ts",
27
28
  "boat/doc-collector/package.json",
29
+ "boat/prima/src/**/*.ts",
30
+ "boat/prima/bin/**/*.ts",
31
+ "boat/prima/package.json",
28
32
  "rules/",
29
33
  "assets/sample-files/",
30
34
  "models.json"
package/src/action.ts CHANGED
@@ -65,8 +65,8 @@ class Action {
65
65
  return this.recovery(() => this.captureOnce(opts));
66
66
  }
67
67
 
68
- async execute(code: string): Promise<Action> {
69
- return this.recovery(() => this.executeOnce(code));
68
+ async execute(code: string, opts: ExecuteOptions = {}): Promise<Action> {
69
+ return this.recovery(() => this.executeOnce(code, opts.verbatim));
70
70
  }
71
71
 
72
72
  private async captureOnce({ includeScreenshot = false, codeBlock }: { includeScreenshot?: boolean; codeBlock?: string } = {}): Promise<ActionResult> {
@@ -270,7 +270,7 @@ class Action {
270
270
  }
271
271
  }
272
272
 
273
- private async executeOnce(code: string): Promise<Action> {
273
+ private async executeOnce(code: string, verbatim = false): Promise<Action> {
274
274
  let error: Error | null = null;
275
275
 
276
276
  setActivity('🔎 Browsing...', 'action');
@@ -287,8 +287,8 @@ class Action {
287
287
  const tracer = trace.getTracer('ai');
288
288
  const stepSpan = activeSpan ? tracer.startSpan('codeceptjs.step', undefined, trace.setSpan(context.active(), activeSpan)) : null;
289
289
  setStepSpanParent(stepSpan);
290
- const sanitizedCode = sanitizeCodeBlock(codeString);
291
- const isPlaywright = hasPlaywrightCommands(sanitizedCode);
290
+ const sanitizedCode = verbatim ? codeString : sanitizeCodeBlock(codeString);
291
+ const isPlaywright = !verbatim && hasPlaywrightCommands(sanitizedCode);
292
292
 
293
293
  try {
294
294
  debugLog('Executing action:', codeString);
@@ -386,6 +386,10 @@ export default Action;
386
386
 
387
387
  export type RecoveryRunner = <T>(fn: () => Promise<T>) => Promise<T>;
388
388
 
389
+ export interface ExecuteOptions {
390
+ verbatim?: boolean;
391
+ }
392
+
389
393
  function errorToString(error: any): string {
390
394
  if (error.cliMessage) {
391
395
  return error.cliMessage();
@@ -16,7 +16,7 @@ export interface ModeContext {
16
16
  }
17
17
 
18
18
  export function resolveProjectRoot(): string | null {
19
- const configPath = ConfigParser.getInstance().getConfigPath();
20
- if (!configPath) return null;
21
- return dirname(configPath);
19
+ const configParser = ConfigParser.getInstance();
20
+ if (!configParser.getConfigPath()) return null;
21
+ return configParser.getProjectRoot();
22
22
  }
@@ -27,7 +27,7 @@ export function WithWebMode<T extends Constructor>(Base: T) {
27
27
  execute: async ({ destination }) => {
28
28
  try {
29
29
  debugLog('navigate', destination);
30
- await ctx.explorBot.agentNavigator().visit(destination);
30
+ await ctx.explorBot.visit(destination);
31
31
  const stateManager = ctx.explorBot.stateManager();
32
32
  const state = stateManager.getCurrentState();
33
33
  return { success: true, url: state?.url, title: state?.title };
@@ -13,7 +13,7 @@ import { HooksRunner } from '../utils/hooks-runner.ts';
13
13
  import { createDebug, pluralize, tag } from '../utils/logger.js';
14
14
  import { loop, pause } from '../utils/loop.js';
15
15
  import { RulesLoader } from '../utils/rules-loader.ts';
16
- import { extractStatePath } from '../utils/url-matcher.js';
16
+ import { extractStatePath, matchesNavigationUrl } from '../utils/url-matcher.js';
17
17
  import type { Agent, AgentDeps } from './agent.js';
18
18
  import type { Conversation } from './conversation.js';
19
19
  import type { Provider } from './provider.js';
@@ -134,7 +134,7 @@ class Navigator implements Agent {
134
134
  return false;
135
135
  }
136
136
  const currentUrl = this.getComparableCurrentUrl(stateManager, expectedUrl);
137
- return normalizeUrl(currentUrl) === normalizeUrl(expectedUrl);
137
+ return matchesNavigationUrl(expectedUrl, currentUrl);
138
138
  }
139
139
 
140
140
  async visit(url: string): Promise<void> {
@@ -189,14 +189,16 @@ class Navigator implements Agent {
189
189
  }
190
190
  }
191
191
 
192
- async resolveState(message: string, actionResult: ActionResult, opts?: { action?: Action; expectedUrl?: string }): Promise<boolean> {
192
+ async resolveState(message: string, actionResult: ActionResult, opts?: { action?: Action; expectedUrl?: string; onAttempt?: (attempt: { code: string; error?: string }) => void }): Promise<boolean> {
193
+ if (!this.provider) throw new Error('AI-assisted recovery is unavailable: no AI model is configured.');
194
+
193
195
  tag('info').log('AI Navigator resolving state at', actionResult.url);
194
196
  debugLog('Resolution message:', message);
195
197
 
196
198
  const action = opts?.action ?? this.explorer.action();
197
199
  const expectedUrl = opts?.expectedUrl;
198
200
 
199
- const knowledge = this.knowledgeTracker.renderRelevantKnowledge(actionResult);
201
+ const knowledge = this.knowledgeTracker.renderRelevantContext(actionResult);
200
202
  let experience = '';
201
203
 
202
204
  if (!actionResult.isInsideIframe) {
@@ -363,24 +365,27 @@ class Navigator implements Agent {
363
365
  }
364
366
  }
365
367
 
368
+ if (attemptOk) opts?.onAttempt?.({ code: codeBlock });
369
+
366
370
  if (!attemptOk) {
367
371
  const raw = action.lastError?.message || 'attempt failed';
368
372
  const firstMeaningful = raw.split('\n').find((l) => l.trim() && !l.trim().startsWith('at ')) || raw;
369
373
  const shortErr = firstMeaningful.replace(/\s+/g, ' ').trim().slice(0, 220);
370
374
  batchFailures.push({ code: codeBlock, error: shortErr });
375
+ opts?.onAttempt?.({ code: codeBlock, error: shortErr });
371
376
  }
372
377
 
373
378
  if (expectedUrl) {
374
379
  if (page) {
375
380
  try {
376
- await page.waitForURL((url: URL) => normalizeUrl(url.pathname) === normalizeUrl(expectedUrl), { timeout: 5000 });
381
+ await page.waitForURL((url: URL) => matchesNavigationUrl(expectedUrl, `${url.pathname}${url.search}${url.hash}`), { timeout: 5000 });
377
382
  } catch {
378
383
  // URL did not transition to expectedUrl within timeout
379
384
  }
380
385
  }
381
386
  const freshState = await this.explorer.capture();
382
387
  const currentUrl = /^https?:\/\//i.test(expectedUrl) ? freshState.fullUrl || freshState.url || '' : freshState.url || '';
383
- const urlMatches = this.isSameExpectedOrigin(expectedUrl, action.stateManager) && normalizeUrl(currentUrl) === normalizeUrl(expectedUrl);
388
+ const urlMatches = this.isSameExpectedOrigin(expectedUrl, action.stateManager) && matchesNavigationUrl(expectedUrl, currentUrl);
384
389
  const stateChanged = freshState.getStateHash() !== actionResult.getStateHash();
385
390
  resolved = urlMatches && stateChanged;
386
391
 
@@ -625,7 +630,7 @@ class Navigator implements Agent {
625
630
  return { verified: cachedVerification, successfulCodes: [], assertionSteps: [], totalAttempted: 0 };
626
631
  }
627
632
 
628
- const knowledge = this.knowledgeTracker.renderRelevantKnowledge(actionResult);
633
+ const knowledge = this.knowledgeTracker.renderRelevantContext(actionResult);
629
634
  let experience = '';
630
635
 
631
636
  if (!actionResult.isInsideIframe) {
package/src/ai/planner.ts CHANGED
@@ -53,6 +53,7 @@ export class Planner extends PlannerBase implements Agent {
53
53
  provider: Provider;
54
54
  stateManager: StateManager;
55
55
  private experienceTracker: ExperienceTracker;
56
+ private knowledgeTracker: AgentDeps['knowledgeTracker'];
56
57
 
57
58
  MIN_TASKS = 3;
58
59
  MAX_TASKS = 12;
@@ -70,6 +71,7 @@ export class Planner extends PlannerBase implements Agent {
70
71
  this.researcher = researcher;
71
72
  this.stateManager = deps.stateManager;
72
73
  this.experienceTracker = deps.stateManager.getExperienceTracker();
74
+ this.knowledgeTracker = deps.knowledgeTracker;
73
75
  }
74
76
 
75
77
  setFisherman(fisherman: Fisherman): void {
@@ -411,6 +413,11 @@ export class Planner extends PlannerBase implements Agent {
411
413
  </page_research>
412
414
  `);
413
415
 
416
+ const applicationContext = this.knowledgeTracker.renderApplicationSpec(state);
417
+ if (applicationContext) {
418
+ conversation.addUserText(applicationContext);
419
+ }
420
+
414
421
  conversation.addUserText(dedent`
415
422
  ${this.buildApproach(style)}
416
423
 
@@ -438,7 +438,7 @@ export class Researcher extends ResearcherBase implements Agent {
438
438
  if (!this.actionResult) throw new Error('actionResult is not set');
439
439
 
440
440
  const html = await this.actionResult.combinedHtml();
441
- const knowledge = this.knowledgeTracker.renderRelevantKnowledge(this.actionResult);
441
+ const knowledge = this.knowledgeTracker.renderRelevantContext(this.actionResult);
442
442
 
443
443
  const ariaSnapshot = this.actionResult.getCompactARIA();
444
444
 
@@ -73,7 +73,7 @@ export abstract class TaskAgent {
73
73
  }
74
74
 
75
75
  protected getKnowledge(actionResult: ActionResult): string {
76
- return this.getKnowledgeTracker().renderRelevantKnowledge(actionResult);
76
+ return this.getKnowledgeTracker().renderRelevantContext(actionResult);
77
77
  }
78
78
 
79
79
  protected getExperience(actionResult: ActionResult): string {
package/src/ai/tester.ts CHANGED
@@ -62,6 +62,7 @@ export class Tester extends TaskAgent implements Agent {
62
62
  private seenUiMapUrls = new Set<string>();
63
63
  private lastAnalyzedStateHash: string | null = null;
64
64
  private stalledIterations = 0;
65
+ private hasSuccessfulAssertion = false;
65
66
  private readonly MAX_STALLED_ITERATIONS = 3;
66
67
 
67
68
  constructor(deps: AgentDeps, researcher: Researcher, navigator: Navigator, agentTools?: any) {
@@ -110,6 +111,7 @@ export class Tester extends TaskAgent implements Agent {
110
111
  this.seenUiMapUrls.clear();
111
112
  this.lastAnalyzedStateHash = null;
112
113
  this.stalledIterations = 0;
114
+ this.hasSuccessfulAssertion = false;
113
115
  this.stateManager.clearHistory();
114
116
  this.resetFailureCount();
115
117
  this.pilot?.reset();
@@ -312,9 +314,17 @@ export class Tester extends TaskAgent implements Agent {
312
314
  const allToolNames = result?.toolExecutions?.map((execution: any) => execution.toolName) || [];
313
315
  const successfulToolNames = result?.toolExecutions?.filter((execution: any) => execution.wasSuccessful)?.map((execution: any) => execution.toolName) || [];
314
316
  const actionPerformed = !!allToolNames.find((toolName: string) => this.ACTION_TOOLS.includes(toolName));
317
+ const successfulActionPerformed = !!successfulToolNames.find((toolName: string) => this.ACTION_TOOLS.includes(toolName));
315
318
  assertionPerformed = !!successfulToolNames.find((toolName: string) => this.ASSERTION_TOOLS.includes(toolName));
316
319
  const wasSuccessful = result?.toolExecutions?.every((execution: any) => execution.wasSuccessful);
317
320
 
321
+ if (successfulActionPerformed) {
322
+ this.hasSuccessfulAssertion = false;
323
+ }
324
+ if (assertionPerformed) {
325
+ this.hasSuccessfulAssertion = true;
326
+ }
327
+
318
328
  this.trackToolExecutions(result?.toolExecutions || []);
319
329
 
320
330
  if (this.consecutiveEmptyResults >= 5) {
@@ -464,6 +474,11 @@ export class Tester extends TaskAgent implements Agent {
464
474
  this.stalledIterations++;
465
475
  if (this.stalledIterations < this.MAX_STALLED_ITERATIONS) return false;
466
476
 
477
+ if (this.hasSuccessfulAssertion) {
478
+ task.addNote('No further browser progress after successful verification; requesting final review');
479
+ return true;
480
+ }
481
+
467
482
  task.addNote('No browser progress after repeated attempts on unchanged page', TestResult.FAILED);
468
483
  task.finish(TestResult.FAILED);
469
484
  return true;
@@ -0,0 +1,10 @@
1
+ import { z } from 'zod';
2
+
3
+ export const APPLICATION_SPEC_FORMAT = 'explorbot-application-spec';
4
+ export const APPLICATION_SPEC_VERSION = 1;
5
+
6
+ export const APPLICATION_SPEC_PAGE_SCHEMA = z.object({
7
+ format: z.literal(APPLICATION_SPEC_FORMAT),
8
+ version: z.literal(APPLICATION_SPEC_VERSION),
9
+ url: z.string().trim().min(1),
10
+ });
@@ -0,0 +1,87 @@
1
+ import { existsSync, statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import dedent from 'dedent';
4
+ import { ActionResult } from './action-result.ts';
5
+ import { APPLICATION_SPEC_PAGE_SCHEMA } from './application-spec-contract.ts';
6
+ import { ConfigParser } from './config.ts';
7
+ import { tag } from './utils/logger.ts';
8
+ import { loadMarkdownFiles } from './utils/markdown-files.ts';
9
+
10
+ export class ApplicationSpec {
11
+ private pages: ApplicationSpecPage[] = [];
12
+ readonly sourcePath: string;
13
+
14
+ constructor(sourcePath: string) {
15
+ this.sourcePath = this.resolveSourcePath(sourcePath);
16
+ this.load();
17
+ }
18
+
19
+ renderFor(state: ActionResult): string {
20
+ const relevant = this.pages.filter((page) => state.isMatchedBy({ url: page.url }));
21
+ if (relevant.length === 0) return '';
22
+
23
+ tag('operation').log(`Found application specification for ${state.url}`);
24
+ return dedent`
25
+ <application_spec>
26
+ This is previously collected application documentation. Treat User Can and observed state transitions as supporting context, not as a replacement for the current page state. Treat User Might as unverified possibilities that must be confirmed before use.
27
+
28
+ ${relevant.map((page) => page.content).join('\n\n')}
29
+ </application_spec>
30
+ `;
31
+ }
32
+
33
+ get pageCount(): number {
34
+ return this.pages.length;
35
+ }
36
+
37
+ private load(): void {
38
+ if (!existsSync(this.sourcePath)) {
39
+ throw new Error(`Application spec not found: ${this.sourcePath}`);
40
+ }
41
+
42
+ const isDirectory = statSync(this.sourcePath).isDirectory();
43
+ if (!isDirectory && path.basename(this.sourcePath).toLowerCase() !== 'index.md') {
44
+ throw new Error(`Application spec file must be index.md: ${this.sourcePath}`);
45
+ }
46
+
47
+ const bundlePath = isDirectory ? this.sourcePath : path.dirname(this.sourcePath);
48
+ const indexPath = path.join(bundlePath, 'index.md');
49
+ if (!existsSync(indexPath)) {
50
+ throw new Error(`Application spec index not found: ${indexPath}`);
51
+ }
52
+
53
+ const pagesPath = path.join(bundlePath, 'pages');
54
+ if (!existsSync(pagesPath)) {
55
+ throw new Error(`Application spec pages directory not found: ${pagesPath}`);
56
+ }
57
+
58
+ for (const file of loadMarkdownFiles(pagesPath, { recursive: true })) {
59
+ const parsed = APPLICATION_SPEC_PAGE_SCHEMA.safeParse(file.data);
60
+ if (!parsed.success && parsed.error.issues.some((issue) => issue.path[0] === 'format')) {
61
+ throw new Error(`Invalid application spec format in ${file.filePath}`);
62
+ }
63
+ if (!parsed.success && parsed.error.issues.some((issue) => issue.path[0] === 'version')) {
64
+ throw new Error(`Unsupported application spec version in ${file.filePath}: ${String(file.data.version)}`);
65
+ }
66
+ if (!parsed.success) {
67
+ throw new Error(`Application spec page URL is missing in ${file.filePath}`);
68
+ }
69
+ this.pages.push({ url: parsed.data.url, content: file.content.trim() });
70
+ }
71
+
72
+ if (this.pages.length === 0) {
73
+ throw new Error(`Application spec contains no documented pages: ${pagesPath}`);
74
+ }
75
+ }
76
+
77
+ private resolveSourcePath(sourcePath: string): string {
78
+ if (path.isAbsolute(sourcePath)) return path.resolve(sourcePath);
79
+ const configParser = ConfigParser.getInstance();
80
+ return path.resolve(configParser.resolveProjectDir(sourcePath));
81
+ }
82
+ }
83
+
84
+ interface ApplicationSpecPage {
85
+ url: string;
86
+ content: string;
87
+ }