explorbot 0.4.6 → 0.4.7

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 (55) hide show
  1. package/boat/api-tester/src/ai/curler.ts +70 -66
  2. package/boat/api-tester/src/apibot.ts +1 -0
  3. package/boat/api-tester/src/cli.ts +2 -0
  4. package/boat/api-tester/src/config.ts +18 -1
  5. package/dist/boat/api-tester/src/ai/curler.js +55 -56
  6. package/dist/boat/api-tester/src/apibot.js +1 -0
  7. package/dist/boat/api-tester/src/cli.js +2 -0
  8. package/dist/boat/api-tester/src/config.js +3 -1
  9. package/dist/package.json +2 -2
  10. package/dist/rules/researcher/pagination.md +6 -0
  11. package/dist/src/action-result.d.ts +6 -0
  12. package/dist/src/action-result.js +12 -0
  13. package/dist/src/ai/planner.js +4 -0
  14. package/dist/src/ai/researcher/locators.js +1 -1
  15. package/dist/src/ai/researcher/pagination.d.ts +16 -0
  16. package/dist/src/ai/researcher/pagination.js +62 -0
  17. package/dist/src/ai/researcher/parser.d.ts +3 -0
  18. package/dist/src/ai/researcher/parser.js +22 -6
  19. package/dist/src/ai/researcher/sections.js +1 -1
  20. package/dist/src/ai/researcher.js +7 -2
  21. package/dist/src/ai/rules.js +16 -0
  22. package/dist/src/ai/scout.js +8 -2
  23. package/dist/src/ai/tools.js +10 -3
  24. package/dist/src/commands/options/ws-option.d.ts +7 -0
  25. package/dist/src/commands/options/ws-option.js +14 -0
  26. package/dist/src/config.d.ts +1 -0
  27. package/dist/src/config.js +14 -11
  28. package/dist/src/remote.d.ts +2 -0
  29. package/dist/src/remote.js +23 -16
  30. package/dist/src/utils/aria.d.ts +2 -0
  31. package/dist/src/utils/aria.js +6 -1
  32. package/dist/src/utils/markdown-query.d.ts +2 -0
  33. package/dist/src/utils/markdown-query.js +39 -0
  34. package/dist/src/utils/pagination.d.ts +16 -0
  35. package/dist/src/utils/pagination.js +20 -0
  36. package/docs/superpowers/plans/2026-09-10-pagination.md +1420 -0
  37. package/docs/superpowers/specs/2026-09-09-pagination-rule-design.md +125 -97
  38. package/package.json +2 -2
  39. package/rules/researcher/pagination.md +6 -0
  40. package/src/action-result.ts +16 -0
  41. package/src/ai/planner.ts +4 -0
  42. package/src/ai/researcher/locators.ts +1 -1
  43. package/src/ai/researcher/pagination.ts +68 -0
  44. package/src/ai/researcher/parser.ts +23 -5
  45. package/src/ai/researcher/sections.ts +1 -1
  46. package/src/ai/researcher.ts +9 -3
  47. package/src/ai/rules.ts +16 -0
  48. package/src/ai/scout.ts +9 -2
  49. package/src/ai/tools.ts +7 -3
  50. package/src/commands/options/ws-option.ts +14 -0
  51. package/src/config.ts +15 -11
  52. package/src/remote.ts +22 -15
  53. package/src/utils/aria.ts +8 -1
  54. package/src/utils/markdown-query.ts +39 -0
  55. package/src/utils/pagination.ts +36 -0
@@ -63,7 +63,7 @@ export function WithSections(Base) {
63
63
  }
64
64
  async _researchSingleSection(name, description, ariaSnapshot, focusCss) {
65
65
  const currentUrl = this.stateManager.getCurrentState()?.url || '';
66
- const rules = RulesLoader.loadRules('researcher', ['ui-map-table', 'list-element', 'container-rules'], currentUrl);
66
+ const rules = RulesLoader.loadRules('researcher', ['ui-map-table', 'list-element', 'container-rules', 'pagination'], currentUrl);
67
67
  const url = this.actionResult?.url || 'Unknown';
68
68
  const title = this.actionResult?.title || 'Unknown';
69
69
  let focusHint = '';
@@ -18,6 +18,7 @@ import { WithCoordinates } from "./researcher/coordinates.js";
18
18
  import { WithDeepAnalysis } from "./researcher/deep-analysis.js";
19
19
  import { detectFocusedSection, hasFocusedSection, markSectionAsFocused, pickDefaultFocusedSection } from "./researcher/focus.js";
20
20
  import { WithLocators } from "./researcher/locators.js";
21
+ import { WithPagination } from "./researcher/pagination.js";
21
22
  import { extractValidContainers, formatResearchSummary, parseResearchSections } from "./researcher/parser.js";
22
23
  import { ResearchResult } from "./researcher/research-result.js";
23
24
  import { WithSections } from "./researcher/sections.js";
@@ -33,7 +34,7 @@ export const POSSIBLE_SECTIONS = {
33
34
  menu: 'page menu (toolbar, context actions, filters, dropdowns)',
34
35
  navigation: 'main navigation (top bar, sidebar, breadcrumbs)',
35
36
  };
36
- const ResearcherBase = WithSections(WithDeepAnalysis(WithCoordinates(WithLocators(TaskAgent))));
37
+ const ResearcherBase = WithSections(WithPagination(WithDeepAnalysis(WithCoordinates(WithLocators(TaskAgent)))));
37
38
  export class Researcher extends ResearcherBase {
38
39
  ACTION_TOOLS = ['click'];
39
40
  emoji = '🔍';
@@ -230,6 +231,9 @@ export class Researcher extends ResearcherBase {
230
231
  await this.backfillCoordinates(result);
231
232
  await this.backfillBrokenLocators(result);
232
233
  }
234
+ if (!interrupted()) {
235
+ await this.detectPagination(result);
236
+ }
233
237
  // Focused section: final fallback (vision-only — without a screenshot we don't infer focus)
234
238
  if (this.hasScreenshotToAnalyze && !hasFocusedSection(result.text)) {
235
239
  const sections = parseResearchSections(result.text);
@@ -366,7 +370,7 @@ export class Researcher extends ResearcherBase {
366
370
 
367
371
  ${generalLocatorRuleText}
368
372
 
369
- ${RulesLoader.loadRules('researcher', ['ui-map-table', 'list-element', 'container-rules'], currentUrl)}
373
+ ${RulesLoader.loadRules('researcher', ['ui-map-table', 'list-element', 'container-rules', 'pagination'], currentUrl)}
370
374
 
371
375
  <section_identification>
372
376
  Identify page sections in this priority order:
@@ -439,6 +443,7 @@ export class Researcher extends ResearcherBase {
439
443
  - When a section contains a list of similar data items (records, entities, rows — content that varies by data, not by app UI), output it as a Data section with NO table.
440
444
  - Data section heading MUST be a level-2 heading (##) that starts exactly with "Data:" — for example: "## Data: Suites List". Do NOT use ### or add section numbers.
441
445
  - Data sections must NOT include a UI map table. Only include the container and a brief summary line.
446
+ - When the data list has controls that move between pages of the collection, add "> Pagination: controls" under its container.
442
447
  - Example data section:
443
448
 
444
449
  ## Data: Suites List
@@ -332,6 +332,22 @@ export const actionRule = dedent `
332
332
  For checkboxes, prefer I.checkOption/I.uncheckOption over I.click.
333
333
 
334
334
 
335
+ ### I.scrollTo
336
+
337
+ scrolls until the element is in view
338
+
339
+ I.scrollTo(<locator>)
340
+
341
+ Scrolls every scrollable ancestor of the target, so it reaches an element inside a container
342
+ that has its own scrollbar. I.scrollPageToBottom() moves only the page itself.
343
+
344
+ <example>
345
+ I.scrollTo('.rows > *:last-child');
346
+ I.scrollTo({ role: 'listitem', text: 'Last entry' });
347
+ I.scrollPageToBottom();
348
+ </example>
349
+
350
+
335
351
  ### I.fillField
336
352
 
337
353
  fills the field with the given value
@@ -47,12 +47,18 @@ export class Scout {
47
47
  maxToolRoundtrips: MAX_TOOL_ROUNDTRIPS,
48
48
  agentName: 'scout',
49
49
  });
50
+ const responseText = invokeResult?.response?.text;
51
+ if (responseText?.trim()) {
52
+ finishFromText(responseText);
53
+ stop();
54
+ return;
55
+ }
50
56
  if (!invokeResult?.toolExecutions?.length) {
51
- finishFromText(invokeResult?.response?.text);
52
57
  stop();
53
58
  return;
54
59
  }
55
- if (iteration >= MAX_ITERATIONS) {
60
+ if (iteration >= MAX_ITERATIONS - 1) {
61
+ conversation.addUserText('Exploration time is over. Report your findings now as your final message.');
56
62
  const final = await this.provider.invokeConversation(conversation, undefined, { agentName: 'scout' });
57
63
  finishFromText(final?.response?.text);
58
64
  stop();
@@ -311,6 +311,7 @@ export function createCodeceptJSTools({ explorer, stateManager }, task) {
311
311
  - Working with iframes (switch context with I.switchTo)
312
312
  - Performing multiple form actions in a single batch
313
313
  - Complex interactions requiring sequential commands
314
+ - Reaching items further down a list (I.scrollTo)
314
315
 
315
316
  Example - filling a form with context (PREFERRED):
316
317
  I.fillField('Username', 'John', '.login-form')
@@ -365,10 +366,10 @@ export function createCodeceptJSTools({ explorer, stateManager }, task) {
365
366
  }
366
367
  if (!hasObservablePageChange(toolResult)) {
367
368
  activeNote.commit(TestResult.FAILED);
368
- return failedToolResult('form', 'Form command executed, but no observable page or form-state change was captured.', {
369
+ return failedToolResult('form', 'Command executed, but nothing on the page changed: no navigation, no ARIA change, no HTML change and no request.', {
369
370
  ...toolResult,
370
371
  code: codeBlock,
371
- suggestion: 'Treat the field/form action as not completed. Re-locate the editable control, check whether another UI layer is active, then retry and verify the field value before submitting.',
372
+ suggestion: 'The command ran without reaching anything. Re-locate the target, check whether another UI layer is active, then retry. If the goal was to load more of a list, no change means the collection has ended.',
372
373
  });
373
374
  }
374
375
  await commitNote(activeNote, TestResult.PASSED, toolResult, action);
@@ -1080,7 +1081,13 @@ export function successToolResult(action, data, source) {
1080
1081
  return result;
1081
1082
  }
1082
1083
  export function isMajorPageChange(pageDiff) {
1083
- return pageDiff.urlChanged !== true && (pageDiff.ariaChangeCount ?? 0) >= LARGE_ARIA_CHANGE_THRESHOLD;
1084
+ if (pageDiff.urlChanged === true)
1085
+ return false;
1086
+ if ((pageDiff.ariaChangeCount ?? 0) < LARGE_ARIA_CHANGE_THRESHOLD)
1087
+ return false;
1088
+ if (pageDiff.ariaRemoved === 0 && (pageDiff.ariaAdded ?? 0) > 0)
1089
+ return false;
1090
+ return true;
1084
1091
  }
1085
1092
  export function hasFailedRequest(pageDiff) {
1086
1093
  return (pageDiff.requests ?? []).some((request) => request.status >= 400);
@@ -3,5 +3,12 @@ import { BaseOption } from './base-option.js';
3
3
  export declare class WsOption extends BaseOption {
4
4
  flags: string;
5
5
  description: string;
6
+ /**
7
+ * An open socket holds the event loop, and `WebSocket` has no `unref` on
8
+ * either runtime — so a command that ends by returning rather than through
9
+ * `showStatsAndExit` would never exit once it is attached. The option that
10
+ * opened the connection is what closes it.
11
+ */
12
+ register(command: Command): void;
6
13
  apply(options: Record<string, any>, command: Command): void;
7
14
  }
@@ -3,6 +3,20 @@ import { BaseOption } from './base-option.js';
3
3
  export class WsOption extends BaseOption {
4
4
  flags = '--ws <url>';
5
5
  description = 'Stream this run to a remote UI over WebSocket';
6
+ /**
7
+ * An open socket holds the event loop, and `WebSocket` has no `unref` on
8
+ * either runtime — so a command that ends by returning rather than through
9
+ * `showStatsAndExit` would never exit once it is attached. The option that
10
+ * opened the connection is what closes it.
11
+ */
12
+ register(command) {
13
+ super.register(command);
14
+ command.hook('postAction', async () => {
15
+ if (!remote.isAttached())
16
+ return;
17
+ await remote.close(0);
18
+ });
19
+ }
6
20
  apply(options, command) {
7
21
  const url = options.ws || process.env.EXPLORBOT_WS_URL;
8
22
  if (!url)
@@ -287,6 +287,7 @@ export declare function modelName(model: unknown): string;
287
287
  export declare function modelProvider(model: unknown): string;
288
288
  export declare function configuredModels(ai?: AIConfig): Record<string, ConfiguredModel>;
289
289
  export declare function resolveConfigModels(ai?: AIConfig): Promise<void>;
290
+ export declare function resolveLangfuse(ai?: AIConfig): void;
290
291
  export declare function resolveOutputRoot(baseUrl?: string): string;
291
292
  export declare function resolveStateRoot(baseUrl: string, ephemeral?: boolean): string;
292
293
  export declare function materializeKnowledge(outputRoot: string): void;
@@ -377,17 +377,7 @@ export class ConfigParser {
377
377
  config.playwright = config.playwright || { browser: 'chromium', url: '' };
378
378
  config.playwright.url = options.baseUrl;
379
379
  }
380
- if (config.ai) {
381
- const langfuse = config.ai.langfuse;
382
- const publicKey = langfuse?.publicKey || process.env.LANGFUSE_PUBLIC_KEY;
383
- const secretKey = langfuse?.secretKey || process.env.LANGFUSE_SECRET_KEY;
384
- config.ai.langfuse = {
385
- enabled: langfuse?.enabled ?? Boolean(publicKey && secretKey),
386
- publicKey,
387
- secretKey,
388
- baseUrl: langfuse?.baseUrl || process.env.LANGFUSE_BASE_URL || process.env.LANGFUSE_HOST,
389
- };
390
- }
380
+ resolveLangfuse(config.ai);
391
381
  return config;
392
382
  }
393
383
  validateConfig(config) {
@@ -540,6 +530,19 @@ export async function resolveConfigModels(ai) {
540
530
  agent.model = await resolveModel(agent.model);
541
531
  }
542
532
  }
533
+ export function resolveLangfuse(ai) {
534
+ if (!ai)
535
+ return;
536
+ const langfuse = ai.langfuse;
537
+ const publicKey = langfuse?.publicKey || process.env.LANGFUSE_PUBLIC_KEY;
538
+ const secretKey = langfuse?.secretKey || process.env.LANGFUSE_SECRET_KEY;
539
+ ai.langfuse = {
540
+ enabled: langfuse?.enabled ?? Boolean(publicKey && secretKey),
541
+ publicKey,
542
+ secretKey,
543
+ baseUrl: langfuse?.baseUrl || process.env.LANGFUSE_BASE_URL || process.env.LANGFUSE_HOST,
544
+ };
545
+ }
543
546
  export function resolveOutputRoot(baseUrl) {
544
547
  if (cachedOutputRoot)
545
548
  return cachedOutputRoot;
@@ -18,6 +18,7 @@ export declare class Remote implements LogDestination {
18
18
  asks: Map<string, (value: string | null) => void>;
19
19
  askCounter: number;
20
20
  lastActivity: string | null;
21
+ closing: Promise<void> | null;
21
22
  attach(url: string, command: string): void;
22
23
  isAttached(): boolean;
23
24
  send(type: string, data?: Record<string, unknown>): void;
@@ -30,6 +31,7 @@ export declare class Remote implements LogDestination {
30
31
  * back out of flattened text.
31
32
  */
32
33
  write(entry: TaggedLogEntry): void;
34
+ shutdown(exitCode: number): Promise<void>;
33
35
  connect(): void;
34
36
  reconnect(): void;
35
37
  flush(): Promise<void>;
@@ -26,6 +26,7 @@ export class Remote {
26
26
  asks = new Map();
27
27
  askCounter = 0;
28
28
  lastActivity = null;
29
+ closing = null;
29
30
  attach(url, command) {
30
31
  if (this.url)
31
32
  return;
@@ -68,23 +69,12 @@ export class Remote {
68
69
  });
69
70
  });
70
71
  }
71
- async close(exitCode) {
72
+ close(exitCode) {
72
73
  if (!this.url)
73
- return;
74
- this.send('result', { ok: exitCode === 0, exitCode });
75
- await this.flush();
76
- this.url = null;
77
- if (this.reconnectTimer)
78
- clearTimeout(this.reconnectTimer);
79
- // Whoever asks next has nobody to ask — leaving the callback installed would
80
- // route them into a closed socket and park them until the ask times out.
81
- executionController.clearInputCallback();
82
- for (const resolve of this.asks.values())
83
- resolve(null);
84
- this.asks.clear();
85
- this.queue = [];
86
- this.socket?.close();
87
- this.socket = null;
74
+ return Promise.resolve();
75
+ if (!this.closing)
76
+ this.closing = this.shutdown(exitCode);
77
+ return this.closing;
88
78
  }
89
79
  isEnabled() {
90
80
  return this.isAttached();
@@ -112,6 +102,23 @@ export class Remote {
112
102
  error: this.errorOf(entry.originalArgs),
113
103
  });
114
104
  }
105
+ async shutdown(exitCode) {
106
+ this.send('result', { ok: exitCode === 0, exitCode });
107
+ await this.flush();
108
+ this.url = null;
109
+ if (this.reconnectTimer)
110
+ clearTimeout(this.reconnectTimer);
111
+ // Whoever asks next has nobody to ask — leaving the callback installed would
112
+ // route them into a closed socket and park them until the ask times out.
113
+ executionController.clearInputCallback();
114
+ for (const resolve of this.asks.values())
115
+ resolve(null);
116
+ this.asks.clear();
117
+ this.queue = [];
118
+ this.socket?.close();
119
+ this.socket = null;
120
+ this.closing = null;
121
+ }
115
122
  connect() {
116
123
  if (!this.url)
117
124
  return;
@@ -16,4 +16,6 @@ export declare const LARGE_ARIA_CHANGE_THRESHOLD = 50;
16
16
  export interface AriaDiff {
17
17
  text: string | null;
18
18
  count: number;
19
+ added: number;
20
+ removed: number;
19
21
  }
@@ -511,7 +511,12 @@ export const diffAriaSnapshots = (previous, current) => {
511
511
  const renames = detectRenames(prev, curr, prevTotals, currTotals);
512
512
  const added = [...byCount.added, ...renames.added];
513
513
  const removed = [...byCount.removed, ...renames.removed];
514
- return { text: formatDiff(added, removed, toggled, typed), count: added.length + removed.length + toggled.length + typed.length };
514
+ return {
515
+ text: formatDiff(added, removed, toggled, typed),
516
+ count: added.length + removed.length + toggled.length + typed.length,
517
+ added: added.length,
518
+ removed: removed.length,
519
+ };
515
520
  };
516
521
  export const detectFocusArea = (snapshot) => {
517
522
  let tree = parseSnapshot(snapshot);
@@ -29,6 +29,8 @@ export declare class MarkdownQuery {
29
29
  text(): string;
30
30
  get(): string;
31
31
  toJson(): Record<string, string>[];
32
+ keyValue(): Record<string, string>;
33
+ setKeyValue(key: string, value: string | null): string;
32
34
  replace(content: string): string;
33
35
  replaceEach(replacer: (match: MarkdownQuery, index: number) => string): string;
34
36
  count(): number;
@@ -129,6 +129,12 @@ function matchText(text, matcher) {
129
129
  }
130
130
  return matcher.negated ? !result : result;
131
131
  }
132
+ function entryKey(line) {
133
+ const separator = line.indexOf(':');
134
+ if (separator < 1)
135
+ return null;
136
+ return line.slice(0, separator).trim().toLowerCase();
137
+ }
132
138
  function getTokenText(token) {
133
139
  const t = token;
134
140
  switch (token.type) {
@@ -340,6 +346,39 @@ export class MarkdownQuery {
340
346
  }
341
347
  return results;
342
348
  }
349
+ keyValue() {
350
+ const entries = {};
351
+ for (const range of this.matches) {
352
+ for (const line of getTokenText(range.token).split('\n')) {
353
+ const key = entryKey(line);
354
+ if (!key)
355
+ continue;
356
+ const value = line.slice(line.indexOf(':') + 1).trim();
357
+ if (value)
358
+ entries[key] = value;
359
+ }
360
+ }
361
+ return entries;
362
+ }
363
+ setKeyValue(key, value) {
364
+ return this.replaceEach((match) => {
365
+ const token = match.matches[0].token;
366
+ const lines = getTokenText(token)
367
+ .split('\n')
368
+ .map((line) => line.trim())
369
+ .filter(Boolean);
370
+ const index = lines.findIndex((line) => entryKey(line) === key.toLowerCase());
371
+ if (index < 0 && value)
372
+ lines.push(`${key}: ${value}`);
373
+ if (index >= 0 && value)
374
+ lines[index] = `${key}: ${value}`;
375
+ if (index >= 0 && !value)
376
+ lines.splice(index, 1);
377
+ if (token.type !== 'blockquote')
378
+ return lines.join('\n');
379
+ return lines.map((line) => `> ${line}`).join('\n');
380
+ });
381
+ }
343
382
  replace(content) {
344
383
  return this.replaceEach(() => content);
345
384
  }
@@ -0,0 +1,16 @@
1
+ export declare function inspectList(css: string): ListMeasure | null;
2
+ export declare function restoreScroll({ css, scrollTop, pageScrollY }: ScrollPosition): void;
3
+ export type PaginationStrategy = 'controls' | 'infinite';
4
+ export interface ListMeasure {
5
+ hasPagingControls: boolean;
6
+ isFeed: boolean;
7
+ scrolls: boolean;
8
+ items: number;
9
+ scrollTop: number;
10
+ pageScrollY: number;
11
+ }
12
+ export interface ScrollPosition {
13
+ css: string;
14
+ scrollTop: number;
15
+ pageScrollY: number;
16
+ }
@@ -0,0 +1,20 @@
1
+ export function inspectList(css) {
2
+ const element = document.querySelector(css);
3
+ if (!element)
4
+ return null;
5
+ const rect = element.getBoundingClientRect();
6
+ return {
7
+ hasPagingControls: !!element.querySelector('a[rel="next"], a[rel="prev"]'),
8
+ isFeed: element.matches('[role="feed"]') || !!element.querySelector('[role="feed"]'),
9
+ scrolls: element.scrollHeight > element.clientHeight + 1 || rect.bottom > window.innerHeight,
10
+ items: element.querySelectorAll(':scope > *').length,
11
+ scrollTop: element.scrollTop,
12
+ pageScrollY: window.scrollY,
13
+ };
14
+ }
15
+ export function restoreScroll({ css, scrollTop, pageScrollY }) {
16
+ const element = document.querySelector(css);
17
+ if (element)
18
+ element.scrollTop = scrollTop;
19
+ window.scrollTo(0, pageScrollY);
20
+ }