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,66 @@
1
+ import { readFileSync, readdirSync } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ const DEFAULT_TITLE = 'default';
5
+ const DEFAULT_BROWSER = 'chromium';
6
+ export function registryDir() {
7
+ if (process.platform === 'darwin')
8
+ return path.join(os.homedir(), 'Library', 'Caches', 'ms-playwright', 'b');
9
+ if (process.platform === 'win32')
10
+ return path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'ms-playwright', 'b');
11
+ return path.join(process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'), 'ms-playwright', 'b');
12
+ }
13
+ export function readDescriptors(dir = registryDir()) {
14
+ const descriptors = [];
15
+ for (const fileName of listFiles(dir)) {
16
+ const descriptor = parseDescriptor(path.join(dir, fileName));
17
+ if (descriptor)
18
+ descriptors.push(descriptor);
19
+ }
20
+ return descriptors;
21
+ }
22
+ export function selectDescriptor(descriptors, opts) {
23
+ const workspaceDir = path.resolve(opts.workspaceDir);
24
+ const candidates = descriptors.filter((descriptor) => path.resolve(descriptor.workspaceDir) === workspaceDir);
25
+ if (!candidates.length)
26
+ return { candidates };
27
+ if (opts.title) {
28
+ const titled = candidates.find((descriptor) => descriptor.title === opts.title);
29
+ if (titled)
30
+ return { match: titled, candidates };
31
+ return { candidates };
32
+ }
33
+ const preferred = candidates.find((descriptor) => descriptor.title === DEFAULT_TITLE);
34
+ if (preferred)
35
+ return { match: preferred, candidates };
36
+ if (candidates.length === 1)
37
+ return { match: candidates[0], candidates };
38
+ return { candidates };
39
+ }
40
+ function listFiles(dir) {
41
+ try {
42
+ return readdirSync(dir);
43
+ }
44
+ catch {
45
+ return [];
46
+ }
47
+ }
48
+ function parseDescriptor(file) {
49
+ let data;
50
+ try {
51
+ data = JSON.parse(readFileSync(file, 'utf8'));
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ if (!data?.endpoint || !data?.title || !data?.workspaceDir)
57
+ return null;
58
+ return {
59
+ file,
60
+ title: data.title,
61
+ endpoint: data.endpoint,
62
+ workspaceDir: data.workspaceDir,
63
+ browserName: data.browser?.browserName || DEFAULT_BROWSER,
64
+ playwrightLib: data.playwrightLib || '',
65
+ };
66
+ }
package/dist/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/dist/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"
@@ -24,7 +24,7 @@ declare class Action {
24
24
  includeScreenshot?: boolean;
25
25
  codeBlock?: string;
26
26
  }): Promise<ActionResult>;
27
- execute(code: string): Promise<Action>;
27
+ execute(code: string, opts?: ExecuteOptions): Promise<Action>;
28
28
  captureOnce({ includeScreenshot, codeBlock }?: {
29
29
  includeScreenshot?: boolean;
30
30
  codeBlock?: string;
@@ -40,7 +40,7 @@ declare class Action {
40
40
  id?: string;
41
41
  }>>;
42
42
  captureBrowserLogs(): Promise<any[]>;
43
- executeOnce(code: string): Promise<Action>;
43
+ executeOnce(code: string, verbatim?: boolean): Promise<Action>;
44
44
  attempt(codeBlock: string, originalMessage?: string): Promise<boolean>;
45
45
  exitIframe(): Promise<void>;
46
46
  getActor(): CodeceptJS.I;
@@ -49,3 +49,6 @@ declare class Action {
49
49
  }
50
50
  export default Action;
51
51
  export type RecoveryRunner = <T>(fn: () => Promise<T>) => Promise<T>;
52
+ export interface ExecuteOptions {
53
+ verbatim?: boolean;
54
+ }
@@ -57,8 +57,8 @@ class Action {
57
57
  async capturePageState(opts = {}) {
58
58
  return this.recovery(() => this.captureOnce(opts));
59
59
  }
60
- async execute(code) {
61
- return this.recovery(() => this.executeOnce(code));
60
+ async execute(code, opts = {}) {
61
+ return this.recovery(() => this.executeOnce(code, opts.verbatim));
62
62
  }
63
63
  async captureOnce({ includeScreenshot = false, codeBlock } = {}) {
64
64
  try {
@@ -251,7 +251,7 @@ class Action {
251
251
  return [];
252
252
  }
253
253
  }
254
- async executeOnce(code) {
254
+ async executeOnce(code, verbatim = false) {
255
255
  let error = null;
256
256
  setActivity('🔎 Browsing...', 'action');
257
257
  let codeString = code.replace(/^\(I\) => /, '').trim();
@@ -265,8 +265,8 @@ class Action {
265
265
  const tracer = trace.getTracer('ai');
266
266
  const stepSpan = activeSpan ? tracer.startSpan('codeceptjs.step', undefined, trace.setSpan(context.active(), activeSpan)) : null;
267
267
  setStepSpanParent(stepSpan);
268
- const sanitizedCode = sanitizeCodeBlock(codeString);
269
- const isPlaywright = hasPlaywrightCommands(sanitizedCode);
268
+ const sanitizedCode = verbatim ? codeString : sanitizeCodeBlock(codeString);
269
+ const isPlaywright = !verbatim && hasPlaywrightCommands(sanitizedCode);
270
270
  try {
271
271
  debugLog('Executing action:', codeString);
272
272
  if (!sanitizedCode) {
@@ -1,10 +1,9 @@
1
- import { dirname } from 'node:path';
2
1
  import { ConfigParser } from "../../config.js";
3
2
  import { createDebug } from '../../utils/logger.js';
4
3
  export const debugLog = createDebug('explorbot:captain');
5
4
  export function resolveProjectRoot() {
6
- const configPath = ConfigParser.getInstance().getConfigPath();
7
- if (!configPath)
5
+ const configParser = ConfigParser.getInstance();
6
+ if (!configParser.getConfigPath())
8
7
  return null;
9
- return dirname(configPath);
8
+ return configParser.getProjectRoot();
10
9
  }
@@ -25,7 +25,7 @@ export function WithWebMode(Base) {
25
25
  execute: async ({ destination }) => {
26
26
  try {
27
27
  debugLog('navigate', destination);
28
- await ctx.explorBot.agentNavigator().visit(destination);
28
+ await ctx.explorBot.visit(destination);
29
29
  const stateManager = ctx.explorBot.stateManager();
30
30
  const state = stateManager.getCurrentState();
31
31
  return { success: true, url: state?.url, title: state?.title };
@@ -32,6 +32,10 @@ declare class Navigator implements Agent {
32
32
  resolveState(message: string, actionResult: ActionResult, opts?: {
33
33
  action?: Action;
34
34
  expectedUrl?: string;
35
+ onAttempt?: (attempt: {
36
+ code: string;
37
+ error?: string;
38
+ }) => void;
35
39
  }): Promise<boolean>;
36
40
  buildExperienceTools(): {
37
41
  learnExperience: unknown;
@@ -8,7 +8,7 @@ import { HooksRunner } from "../utils/hooks-runner.js";
8
8
  import { createDebug, pluralize, tag } from '../utils/logger.js';
9
9
  import { loop, pause } from '../utils/loop.js';
10
10
  import { RulesLoader } from "../utils/rules-loader.js";
11
- import { extractStatePath } from '../utils/url-matcher.js';
11
+ import { extractStatePath, matchesNavigationUrl } from '../utils/url-matcher.js';
12
12
  import { Researcher } from "./researcher.js";
13
13
  import { actionRule, locatorRule, unexpectedPopupRule } from './rules.js';
14
14
  import { isInteractive } from './task-agent.js';
@@ -118,7 +118,7 @@ class Navigator {
118
118
  return false;
119
119
  }
120
120
  const currentUrl = this.getComparableCurrentUrl(stateManager, expectedUrl);
121
- return normalizeUrl(currentUrl) === normalizeUrl(expectedUrl);
121
+ return matchesNavigationUrl(expectedUrl, currentUrl);
122
122
  }
123
123
  async visit(url) {
124
124
  return this.visitOnce(url);
@@ -167,11 +167,13 @@ class Navigator {
167
167
  }
168
168
  }
169
169
  async resolveState(message, actionResult, opts) {
170
+ if (!this.provider)
171
+ throw new Error('AI-assisted recovery is unavailable: no AI model is configured.');
170
172
  tag('info').log('AI Navigator resolving state at', actionResult.url);
171
173
  debugLog('Resolution message:', message);
172
174
  const action = opts?.action ?? this.explorer.action();
173
175
  const expectedUrl = opts?.expectedUrl;
174
- const knowledge = this.knowledgeTracker.renderRelevantKnowledge(actionResult);
176
+ const knowledge = this.knowledgeTracker.renderRelevantContext(actionResult);
175
177
  let experience = '';
176
178
  if (!actionResult.isInsideIframe) {
177
179
  const successful = this.experienceTracker.getSuccessfulExperience(actionResult);
@@ -328,16 +330,19 @@ class Navigator {
328
330
  // Navigation did not reach 'load' state within timeout; continue and verify URL
329
331
  }
330
332
  }
333
+ if (attemptOk)
334
+ opts?.onAttempt?.({ code: codeBlock });
331
335
  if (!attemptOk) {
332
336
  const raw = action.lastError?.message || 'attempt failed';
333
337
  const firstMeaningful = raw.split('\n').find((l) => l.trim() && !l.trim().startsWith('at ')) || raw;
334
338
  const shortErr = firstMeaningful.replace(/\s+/g, ' ').trim().slice(0, 220);
335
339
  batchFailures.push({ code: codeBlock, error: shortErr });
340
+ opts?.onAttempt?.({ code: codeBlock, error: shortErr });
336
341
  }
337
342
  if (expectedUrl) {
338
343
  if (page) {
339
344
  try {
340
- await page.waitForURL((url) => normalizeUrl(url.pathname) === normalizeUrl(expectedUrl), { timeout: 5000 });
345
+ await page.waitForURL((url) => matchesNavigationUrl(expectedUrl, `${url.pathname}${url.search}${url.hash}`), { timeout: 5000 });
341
346
  }
342
347
  catch {
343
348
  // URL did not transition to expectedUrl within timeout
@@ -345,7 +350,7 @@ class Navigator {
345
350
  }
346
351
  const freshState = await this.explorer.capture();
347
352
  const currentUrl = /^https?:\/\//i.test(expectedUrl) ? freshState.fullUrl || freshState.url || '' : freshState.url || '';
348
- const urlMatches = this.isSameExpectedOrigin(expectedUrl, action.stateManager) && normalizeUrl(currentUrl) === normalizeUrl(expectedUrl);
353
+ const urlMatches = this.isSameExpectedOrigin(expectedUrl, action.stateManager) && matchesNavigationUrl(expectedUrl, currentUrl);
349
354
  const stateChanged = freshState.getStateHash() !== actionResult.getStateHash();
350
355
  resolved = urlMatches && stateChanged;
351
356
  if (!resolved && attemptOk) {
@@ -562,7 +567,7 @@ class Navigator {
562
567
  tag('operation').log(`Reusing cached verification: ${cachedVerification ? 'PASS' : 'FAIL'}`);
563
568
  return { verified: cachedVerification, successfulCodes: [], assertionSteps: [], totalAttempted: 0 };
564
569
  }
565
- const knowledge = this.knowledgeTracker.renderRelevantKnowledge(actionResult);
570
+ const knowledge = this.knowledgeTracker.renderRelevantContext(actionResult);
566
571
  let experience = '';
567
572
  if (!actionResult.isInsideIframe) {
568
573
  experience = this.experienceTracker.renderExperienceTocFor(actionResult);
@@ -50,6 +50,7 @@ export declare class Planner extends PlannerBase implements Agent {
50
50
  provider: Provider;
51
51
  stateManager: StateManager;
52
52
  experienceTracker: ExperienceTracker;
53
+ knowledgeTracker: AgentDeps['knowledgeTracker'];
53
54
  MIN_TASKS: number;
54
55
  MAX_TASKS: number;
55
56
  currentPlan: Plan | null;
@@ -41,6 +41,7 @@ export class Planner extends PlannerBase {
41
41
  provider;
42
42
  stateManager;
43
43
  experienceTracker;
44
+ knowledgeTracker;
44
45
  MIN_TASKS = 3;
45
46
  MAX_TASKS = 12;
46
47
  currentPlan = null;
@@ -56,6 +57,7 @@ export class Planner extends PlannerBase {
56
57
  this.researcher = researcher;
57
58
  this.stateManager = deps.stateManager;
58
59
  this.experienceTracker = deps.stateManager.getExperienceTracker();
60
+ this.knowledgeTracker = deps.knowledgeTracker;
59
61
  }
60
62
  setFisherman(fisherman) {
61
63
  this.fisherman = fisherman;
@@ -368,6 +370,10 @@ export class Planner extends PlannerBase {
368
370
  ${plannerResearch}
369
371
  </page_research>
370
372
  `);
373
+ const applicationContext = this.knowledgeTracker.renderApplicationSpec(state);
374
+ if (applicationContext) {
375
+ conversation.addUserText(applicationContext);
376
+ }
371
377
  conversation.addUserText(dedent `
372
378
  ${this.buildApproach(style)}
373
379
 
@@ -379,7 +379,7 @@ export class Researcher extends ResearcherBase {
379
379
  if (!this.actionResult)
380
380
  throw new Error('actionResult is not set');
381
381
  const html = await this.actionResult.combinedHtml();
382
- const knowledge = this.knowledgeTracker.renderRelevantKnowledge(this.actionResult);
382
+ const knowledge = this.knowledgeTracker.renderRelevantContext(this.actionResult);
383
383
  const ariaSnapshot = this.actionResult.getCompactARIA();
384
384
  return dedent `
385
385
  Analyze this web page and provide a comprehensive research report in markdown format.
@@ -49,7 +49,7 @@ export class TaskAgent {
49
49
  return this.provider;
50
50
  }
51
51
  getKnowledge(actionResult) {
52
- return this.getKnowledgeTracker().renderRelevantKnowledge(actionResult);
52
+ return this.getKnowledgeTracker().renderRelevantContext(actionResult);
53
53
  }
54
54
  getExperience(actionResult) {
55
55
  return this.getExperienceTracker().renderExperienceTocFor(actionResult);
@@ -31,6 +31,7 @@ export declare class Tester extends TaskAgent implements Agent {
31
31
  seenUiMapUrls: Set<string>;
32
32
  lastAnalyzedStateHash: string | null;
33
33
  stalledIterations: number;
34
+ hasSuccessfulAssertion: boolean;
34
35
  readonly MAX_STALLED_ITERATIONS = 3;
35
36
  constructor(deps: AgentDeps, researcher: Researcher, navigator: Navigator, agentTools?: any);
36
37
  getNavigator(): Navigator;
@@ -48,6 +48,7 @@ export class Tester extends TaskAgent {
48
48
  seenUiMapUrls = new Set();
49
49
  lastAnalyzedStateHash = null;
50
50
  stalledIterations = 0;
51
+ hasSuccessfulAssertion = false;
51
52
  MAX_STALLED_ITERATIONS = 3;
52
53
  constructor(deps, researcher, navigator, agentTools) {
53
54
  super(deps);
@@ -87,6 +88,7 @@ export class Tester extends TaskAgent {
87
88
  this.seenUiMapUrls.clear();
88
89
  this.lastAnalyzedStateHash = null;
89
90
  this.stalledIterations = 0;
91
+ this.hasSuccessfulAssertion = false;
90
92
  this.stateManager.clearHistory();
91
93
  this.resetFailureCount();
92
94
  this.pilot?.reset();
@@ -266,8 +268,15 @@ export class Tester extends TaskAgent {
266
268
  const allToolNames = result?.toolExecutions?.map((execution) => execution.toolName) || [];
267
269
  const successfulToolNames = result?.toolExecutions?.filter((execution) => execution.wasSuccessful)?.map((execution) => execution.toolName) || [];
268
270
  const actionPerformed = !!allToolNames.find((toolName) => this.ACTION_TOOLS.includes(toolName));
271
+ const successfulActionPerformed = !!successfulToolNames.find((toolName) => this.ACTION_TOOLS.includes(toolName));
269
272
  assertionPerformed = !!successfulToolNames.find((toolName) => this.ASSERTION_TOOLS.includes(toolName));
270
273
  const wasSuccessful = result?.toolExecutions?.every((execution) => execution.wasSuccessful);
274
+ if (successfulActionPerformed) {
275
+ this.hasSuccessfulAssertion = false;
276
+ }
277
+ if (assertionPerformed) {
278
+ this.hasSuccessfulAssertion = true;
279
+ }
271
280
  this.trackToolExecutions(result?.toolExecutions || []);
272
281
  if (this.consecutiveEmptyResults >= 5) {
273
282
  task.addNote('AI model is not responding with actions. Stopped');
@@ -405,6 +414,10 @@ export class Tester extends TaskAgent {
405
414
  this.stalledIterations++;
406
415
  if (this.stalledIterations < this.MAX_STALLED_ITERATIONS)
407
416
  return false;
417
+ if (this.hasSuccessfulAssertion) {
418
+ task.addNote('No further browser progress after successful verification; requesting final review');
419
+ return true;
420
+ }
408
421
  task.addNote('No browser progress after repeated attempts on unchanged page', TestResult.FAILED);
409
422
  task.finish(TestResult.FAILED);
410
423
  return true;
@@ -0,0 +1,8 @@
1
+ import { z } from 'zod';
2
+ export declare const APPLICATION_SPEC_FORMAT = "explorbot-application-spec";
3
+ export declare const APPLICATION_SPEC_VERSION = 1;
4
+ export declare const APPLICATION_SPEC_PAGE_SCHEMA: z.ZodObject<{
5
+ format: z.ZodLiteral<"explorbot-application-spec">;
6
+ version: z.ZodLiteral<1>;
7
+ url: z.ZodString;
8
+ }, z.core.$strip>;
@@ -0,0 +1,8 @@
1
+ import { z } from 'zod';
2
+ export const APPLICATION_SPEC_FORMAT = 'explorbot-application-spec';
3
+ export const APPLICATION_SPEC_VERSION = 1;
4
+ export const APPLICATION_SPEC_PAGE_SCHEMA = z.object({
5
+ format: z.literal(APPLICATION_SPEC_FORMAT),
6
+ version: z.literal(APPLICATION_SPEC_VERSION),
7
+ url: z.string().trim().min(1),
8
+ });
@@ -0,0 +1,15 @@
1
+ import { ActionResult } from './action-result.js';
2
+ export declare class ApplicationSpec {
3
+ pages: ApplicationSpecPage[];
4
+ readonly sourcePath: string;
5
+ constructor(sourcePath: string);
6
+ renderFor(state: ActionResult): string;
7
+ get pageCount(): number;
8
+ load(): void;
9
+ resolveSourcePath(sourcePath: string): string;
10
+ }
11
+ interface ApplicationSpecPage {
12
+ url: string;
13
+ content: string;
14
+ }
15
+ export {};
@@ -0,0 +1,71 @@
1
+ import { existsSync, statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import dedent from 'dedent';
4
+ import { APPLICATION_SPEC_PAGE_SCHEMA } from "./application-spec-contract.js";
5
+ import { ConfigParser } from "./config.js";
6
+ import { tag } from "./utils/logger.js";
7
+ import { loadMarkdownFiles } from "./utils/markdown-files.js";
8
+ export class ApplicationSpec {
9
+ pages = [];
10
+ sourcePath;
11
+ constructor(sourcePath) {
12
+ this.sourcePath = this.resolveSourcePath(sourcePath);
13
+ this.load();
14
+ }
15
+ renderFor(state) {
16
+ const relevant = this.pages.filter((page) => state.isMatchedBy({ url: page.url }));
17
+ if (relevant.length === 0)
18
+ return '';
19
+ tag('operation').log(`Found application specification for ${state.url}`);
20
+ return dedent `
21
+ <application_spec>
22
+ 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.
23
+
24
+ ${relevant.map((page) => page.content).join('\n\n')}
25
+ </application_spec>
26
+ `;
27
+ }
28
+ get pageCount() {
29
+ return this.pages.length;
30
+ }
31
+ load() {
32
+ if (!existsSync(this.sourcePath)) {
33
+ throw new Error(`Application spec not found: ${this.sourcePath}`);
34
+ }
35
+ const isDirectory = statSync(this.sourcePath).isDirectory();
36
+ if (!isDirectory && path.basename(this.sourcePath).toLowerCase() !== 'index.md') {
37
+ throw new Error(`Application spec file must be index.md: ${this.sourcePath}`);
38
+ }
39
+ const bundlePath = isDirectory ? this.sourcePath : path.dirname(this.sourcePath);
40
+ const indexPath = path.join(bundlePath, 'index.md');
41
+ if (!existsSync(indexPath)) {
42
+ throw new Error(`Application spec index not found: ${indexPath}`);
43
+ }
44
+ const pagesPath = path.join(bundlePath, 'pages');
45
+ if (!existsSync(pagesPath)) {
46
+ throw new Error(`Application spec pages directory not found: ${pagesPath}`);
47
+ }
48
+ for (const file of loadMarkdownFiles(pagesPath, { recursive: true })) {
49
+ const parsed = APPLICATION_SPEC_PAGE_SCHEMA.safeParse(file.data);
50
+ if (!parsed.success && parsed.error.issues.some((issue) => issue.path[0] === 'format')) {
51
+ throw new Error(`Invalid application spec format in ${file.filePath}`);
52
+ }
53
+ if (!parsed.success && parsed.error.issues.some((issue) => issue.path[0] === 'version')) {
54
+ throw new Error(`Unsupported application spec version in ${file.filePath}: ${String(file.data.version)}`);
55
+ }
56
+ if (!parsed.success) {
57
+ throw new Error(`Application spec page URL is missing in ${file.filePath}`);
58
+ }
59
+ this.pages.push({ url: parsed.data.url, content: file.content.trim() });
60
+ }
61
+ if (this.pages.length === 0) {
62
+ throw new Error(`Application spec contains no documented pages: ${pagesPath}`);
63
+ }
64
+ }
65
+ resolveSourcePath(sourcePath) {
66
+ if (path.isAbsolute(sourcePath))
67
+ return path.resolve(sourcePath);
68
+ const configParser = ConfigParser.getInstance();
69
+ return path.resolve(configParser.resolveProjectDir(sourcePath));
70
+ }
71
+ }
@@ -1,10 +1,16 @@
1
- declare function getEndpointFilePath(): string;
2
- declare function readEndpoint(): string | null;
3
- declare function removeEndpointFile(): void;
1
+ declare function getEndpointFilePath(instance?: string): string;
2
+ declare function readEndpoint(instance?: string): string | null;
3
+ declare function removeEndpointFile(instance?: string): void;
4
+ declare function listInstances(): Array<{
5
+ name: string;
6
+ endpoint: string;
7
+ }>;
4
8
  declare function isServerRunning(wsEndpoint: string): Promise<boolean>;
5
9
  declare function launchServer(opts: {
6
10
  browser?: string;
7
11
  show?: boolean;
8
- }): Promise<any>;
9
- declare function getAliveEndpoint(): Promise<string | null>;
10
- export { readEndpoint, removeEndpointFile, isServerRunning, launchServer, getEndpointFilePath, getAliveEndpoint };
12
+ }, instance?: string): Promise<any>;
13
+ declare function stopServer(instance?: string): Promise<boolean>;
14
+ declare function keepServerRunning(stop: () => unknown): Promise<never>;
15
+ declare function getAliveEndpoint(instance?: string): Promise<string | null>;
16
+ export { readEndpoint, removeEndpointFile, isServerRunning, launchServer, stopServer, getEndpointFilePath, getAliveEndpoint, listInstances, keepServerRunning };
@@ -1,35 +1,62 @@
1
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { chromium, firefox, webkit } from 'playwright-core';
3
+ import { chromium, firefox, webkit } from 'playwright';
4
4
  import { ConfigParser } from './config.js';
5
5
  import { getCliName } from "./utils/cli-name.js";
6
6
  import { log } from './utils/logger.js';
7
7
  import { printNextSteps } from "./utils/next-steps.js";
8
8
  const ENDPOINT_FILENAME = '.browser-endpoint';
9
- function getEndpointFilePath() {
9
+ const INSTANCE_NAME_PATTERN = /^[a-z0-9-]+$/;
10
+ const KEEP_ALIVE_INTERVAL = 1 << 30;
11
+ function getEndpointFilePath(instance = 'default') {
12
+ if (!INSTANCE_NAME_PATTERN.test(instance)) {
13
+ throw new Error(`Invalid browser instance name: "${instance}". Use lowercase letters, digits and dashes.`);
14
+ }
10
15
  const configParser = ConfigParser.getInstance();
11
16
  const outputDir = configParser.getOutputDir();
12
- return path.join(outputDir, ENDPOINT_FILENAME);
17
+ if (instance === 'default')
18
+ return path.join(outputDir, ENDPOINT_FILENAME);
19
+ return path.join(outputDir, `${ENDPOINT_FILENAME}-${instance}`);
13
20
  }
14
- function readEndpoint() {
15
- const filePath = getEndpointFilePath();
21
+ function readEndpoint(instance = 'default') {
22
+ const filePath = getEndpointFilePath(instance);
16
23
  if (!existsSync(filePath))
17
24
  return null;
18
25
  return readFileSync(filePath, 'utf8').trim();
19
26
  }
20
- function writeEndpoint(wsEndpoint) {
21
- const filePath = getEndpointFilePath();
27
+ function writeEndpoint(wsEndpoint, instance = 'default') {
28
+ const filePath = getEndpointFilePath(instance);
22
29
  const dir = path.dirname(filePath);
23
30
  if (!existsSync(dir)) {
24
31
  mkdirSync(dir, { recursive: true });
25
32
  }
26
33
  writeFileSync(filePath, wsEndpoint, 'utf8');
27
34
  }
28
- function removeEndpointFile() {
29
- const filePath = getEndpointFilePath();
35
+ function removeEndpointFile(instance = 'default') {
36
+ const filePath = getEndpointFilePath(instance);
30
37
  if (existsSync(filePath))
31
38
  unlinkSync(filePath);
32
39
  }
40
+ function listInstances() {
41
+ const dir = path.dirname(getEndpointFilePath());
42
+ if (!existsSync(dir))
43
+ return [];
44
+ const instances = [];
45
+ for (const fileName of readdirSync(dir)) {
46
+ if (!fileName.startsWith(ENDPOINT_FILENAME))
47
+ continue;
48
+ const suffix = fileName.slice(ENDPOINT_FILENAME.length);
49
+ if (suffix && !suffix.startsWith('-'))
50
+ continue;
51
+ if (suffix === '-')
52
+ continue;
53
+ const name = suffix.slice(1) || 'default';
54
+ if (!INSTANCE_NAME_PATTERN.test(name))
55
+ continue;
56
+ instances.push({ name, endpoint: readFileSync(path.join(dir, fileName), 'utf8').trim() });
57
+ }
58
+ return instances;
59
+ }
33
60
  async function isServerRunning(wsEndpoint) {
34
61
  try {
35
62
  const browser = await chromium.connect(wsEndpoint, { timeout: 3000 });
@@ -41,7 +68,7 @@ async function isServerRunning(wsEndpoint) {
41
68
  }
42
69
  }
43
70
  const BROWSER_LAUNCHERS = { chromium, firefox, webkit };
44
- async function launchServer(opts) {
71
+ async function launchServer(opts, instance = 'default') {
45
72
  const browserName = (opts.browser || 'chromium');
46
73
  const launcher = BROWSER_LAUNCHERS[browserName];
47
74
  if (!launcher)
@@ -50,30 +77,58 @@ async function launchServer(opts) {
50
77
  headless: !opts.show,
51
78
  });
52
79
  const wsEndpoint = server.wsEndpoint();
53
- writeEndpoint(wsEndpoint);
80
+ writeEndpoint(wsEndpoint, instance);
54
81
  log(`Browser server started: ${browserName} (${opts.show ? 'headed' : 'headless'})`);
55
82
  const cli = getCliName();
83
+ let instanceFlag = '';
84
+ if (instance !== 'default')
85
+ instanceFlag = ` --instance ${instance}`;
56
86
  const sections = [
57
87
  {
58
88
  label: 'Browser server',
59
- path: getEndpointFilePath(),
89
+ path: getEndpointFilePath(instance),
60
90
  commands: [
61
91
  { label: 'Endpoint', command: wsEndpoint },
62
- { label: 'Status', command: `${cli} browser status` },
63
- { label: 'Stop', command: `${cli} browser stop` },
92
+ { label: 'Status', command: `${cli} browser status${instanceFlag}` },
93
+ { label: 'Stop', command: `${cli} browser stop${instanceFlag}` },
64
94
  ],
65
95
  },
66
96
  ];
67
97
  printNextSteps(sections);
68
98
  return server;
69
99
  }
70
- async function getAliveEndpoint() {
71
- const endpoint = readEndpoint();
100
+ async function stopServer(instance = 'default') {
101
+ const endpoint = await getAliveEndpoint(instance);
102
+ if (!endpoint)
103
+ return false;
104
+ try {
105
+ const browser = await chromium.connect(endpoint, { timeout: 3000 });
106
+ await browser.close();
107
+ }
108
+ catch { }
109
+ removeEndpointFile(instance);
110
+ return true;
111
+ }
112
+ function keepServerRunning(stop) {
113
+ console.log('Browser server is running. Press Ctrl+C to stop.');
114
+ const heartbeat = setInterval(() => { }, KEEP_ALIVE_INTERVAL);
115
+ const cleanup = async () => {
116
+ console.log('\nStopping browser server...');
117
+ clearInterval(heartbeat);
118
+ await stop();
119
+ process.exit(0);
120
+ };
121
+ process.on('SIGINT', cleanup);
122
+ process.on('SIGTERM', cleanup);
123
+ return new Promise(() => { });
124
+ }
125
+ async function getAliveEndpoint(instance = 'default') {
126
+ const endpoint = readEndpoint(instance);
72
127
  if (!endpoint)
73
128
  return null;
74
129
  if (await isServerRunning(endpoint))
75
130
  return endpoint;
76
- removeEndpointFile();
131
+ removeEndpointFile(instance);
77
132
  return null;
78
133
  }
79
- export { readEndpoint, removeEndpointFile, isServerRunning, launchServer, getEndpointFilePath, getAliveEndpoint };
134
+ export { readEndpoint, removeEndpointFile, isServerRunning, launchServer, stopServer, getEndpointFilePath, getAliveEndpoint, listInstances, keepServerRunning };