explorbot 0.4.6 → 0.4.8

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 (74) hide show
  1. package/boat/api-tester/src/ai/chief.ts +3 -1
  2. package/boat/api-tester/src/ai/curler.ts +74 -66
  3. package/boat/api-tester/src/apibot.ts +1 -0
  4. package/boat/api-tester/src/cli.ts +2 -0
  5. package/boat/api-tester/src/config.ts +18 -1
  6. package/dist/boat/api-tester/src/ai/chief.js +3 -1
  7. package/dist/boat/api-tester/src/ai/curler.js +59 -56
  8. package/dist/boat/api-tester/src/apibot.js +1 -0
  9. package/dist/boat/api-tester/src/cli.js +2 -0
  10. package/dist/boat/api-tester/src/config.js +3 -1
  11. package/dist/package.json +2 -2
  12. package/dist/rules/chief/general.md +2 -0
  13. package/dist/rules/researcher/pagination.md +7 -0
  14. package/dist/src/action-result.d.ts +6 -0
  15. package/dist/src/action-result.js +12 -0
  16. package/dist/src/action.js +3 -2
  17. package/dist/src/ai/navigator.js +1 -4
  18. package/dist/src/ai/pilot.js +8 -12
  19. package/dist/src/ai/planner/session-dedup.d.ts +2 -1
  20. package/dist/src/ai/planner/session-dedup.js +18 -1
  21. package/dist/src/ai/planner.js +12 -4
  22. package/dist/src/ai/provider.js +18 -4
  23. package/dist/src/ai/researcher/locators.js +1 -1
  24. package/dist/src/ai/researcher/pagination.d.ts +16 -0
  25. package/dist/src/ai/researcher/pagination.js +62 -0
  26. package/dist/src/ai/researcher/parser.d.ts +3 -0
  27. package/dist/src/ai/researcher/parser.js +22 -6
  28. package/dist/src/ai/researcher/sections.js +1 -1
  29. package/dist/src/ai/researcher.js +7 -2
  30. package/dist/src/ai/rules.js +17 -0
  31. package/dist/src/ai/scout.js +8 -2
  32. package/dist/src/ai/tester.js +1 -1
  33. package/dist/src/ai/tools.js +12 -4
  34. package/dist/src/commands/options/ws-option.d.ts +7 -0
  35. package/dist/src/commands/options/ws-option.js +14 -0
  36. package/dist/src/config.d.ts +1 -0
  37. package/dist/src/config.js +14 -11
  38. package/dist/src/remote.d.ts +2 -0
  39. package/dist/src/remote.js +23 -16
  40. package/dist/src/utils/aria.d.ts +2 -0
  41. package/dist/src/utils/aria.js +6 -1
  42. package/dist/src/utils/code-extractor.js +6 -2
  43. package/dist/src/utils/markdown-query.d.ts +2 -0
  44. package/dist/src/utils/markdown-query.js +39 -0
  45. package/dist/src/utils/pagination.d.ts +16 -0
  46. package/dist/src/utils/pagination.js +20 -0
  47. package/docs/superpowers/plans/2026-09-10-pagination.md +1420 -0
  48. package/docs/superpowers/specs/2026-09-09-pagination-rule-design.md +125 -97
  49. package/package.json +2 -2
  50. package/rules/chief/general.md +2 -0
  51. package/rules/researcher/pagination.md +7 -0
  52. package/src/action-result.ts +16 -0
  53. package/src/action.ts +3 -2
  54. package/src/ai/navigator.ts +1 -4
  55. package/src/ai/pilot.ts +8 -12
  56. package/src/ai/planner/session-dedup.ts +16 -2
  57. package/src/ai/planner.ts +12 -4
  58. package/src/ai/provider.ts +18 -3
  59. package/src/ai/researcher/locators.ts +1 -1
  60. package/src/ai/researcher/pagination.ts +68 -0
  61. package/src/ai/researcher/parser.ts +23 -5
  62. package/src/ai/researcher/sections.ts +1 -1
  63. package/src/ai/researcher.ts +9 -3
  64. package/src/ai/rules.ts +17 -0
  65. package/src/ai/scout.ts +9 -2
  66. package/src/ai/tester.ts +1 -1
  67. package/src/ai/tools.ts +9 -4
  68. package/src/commands/options/ws-option.ts +14 -0
  69. package/src/config.ts +15 -11
  70. package/src/remote.ts +22 -15
  71. package/src/utils/aria.ts +8 -1
  72. package/src/utils/code-extractor.ts +6 -2
  73. package/src/utils/markdown-query.ts +39 -0
  74. package/src/utils/pagination.ts +36 -0
@@ -0,0 +1,68 @@
1
+ import type Explorer from '../../explorer.ts';
2
+ import { mdq } from '../../utils/markdown-query.ts';
3
+ import { type ListMeasure, type PaginationStrategy, inspectList, restoreScroll } from '../../utils/pagination.ts';
4
+ import { type Constructor, debugLog } from './mixin.ts';
5
+ import { extractPaginationFromBlockquote, parseDataSections, parseResearchSections } from './parser.ts';
6
+ import type { ResearchResult } from './research-result.ts';
7
+
8
+ export function WithPagination<T extends Constructor>(Base: T) {
9
+ return class extends Base {
10
+ declare explorer: Explorer;
11
+
12
+ async detectPagination(result: ResearchResult): Promise<void> {
13
+ const sections = [...parseResearchSections(result.text), ...parseDataSections(result.text)];
14
+
15
+ for (const section of sections) {
16
+ const css = section.containerCss;
17
+ if (!css) continue;
18
+ if (extractPaginationFromBlockquote(section.rawMarkdown)) continue;
19
+
20
+ const strategy = await this.probeSection(css);
21
+ if (!strategy) continue;
22
+
23
+ this.recordPagination(result, section.name, strategy);
24
+ debugLog(`Pagination in "${section.name}": ${strategy}`);
25
+ }
26
+ }
27
+
28
+ private async probeSection(css: string): Promise<PaginationStrategy | null> {
29
+ const before = await this.measure(css);
30
+ if (!before) return null;
31
+ if (before.hasPagingControls) return 'controls';
32
+ if (before.isFeed) return 'infinite';
33
+ if (!before.scrolls) return null;
34
+
35
+ const action = this.explorer.action();
36
+ const scrolled = await action.attempt(`I.scrollTo('${css} > *:last-child')`).catch(() => false);
37
+ if (!scrolled) return null;
38
+
39
+ const after = await this.measure(css);
40
+ await this.explorer.withPage((page) => page.evaluate(restoreScroll, { css, scrollTop: before.scrollTop, pageScrollY: before.pageScrollY })).catch(() => {});
41
+
42
+ if (!after) return null;
43
+ if (after.items > before.items) return 'infinite';
44
+ return null;
45
+ }
46
+
47
+ private measure(css: string): Promise<ListMeasure | null> {
48
+ return this.explorer
49
+ .withPage((page) => page.evaluate(inspectList, css))
50
+ .catch((err: Error) => {
51
+ debugLog(`List measurement failed for '${css}': ${err.message}`);
52
+ return null;
53
+ });
54
+ }
55
+
56
+ private recordPagination(result: ResearchResult, name: string, strategy: PaginationStrategy): void {
57
+ const escaped = name.replace(/"/g, '\\"');
58
+ let sectionQuery = mdq(result.text).query(`section2(~"${escaped}")`);
59
+ if (sectionQuery.count() === 0) sectionQuery = mdq(result.text).query(`section3(~"${escaped}")`);
60
+ if (sectionQuery.count() === 0) return;
61
+ result.text = sectionQuery.query('blockquote[0]').setKeyValue('Pagination', strategy);
62
+ }
63
+ };
64
+ }
65
+
66
+ export interface PaginationMethods {
67
+ detectPagination(result: ResearchResult): Promise<void>;
68
+ }
@@ -2,6 +2,7 @@ import { parseAriaLocator } from '../../utils/aria.ts';
2
2
  import { pluralize } from '../../utils/logger.ts';
3
3
  import { jsonToTable, parseSections, tableToJson } from '../../utils/markdown-parser.ts';
4
4
  import { mdq } from '../../utils/markdown-query.ts';
5
+ import type { PaginationStrategy } from '../../utils/pagination.ts';
5
6
  import { FOCUSED_MARKER } from './focus.ts';
6
7
 
7
8
  export interface ResearchElement {
@@ -84,11 +85,9 @@ export function mapRowToElement(row: Record<string, string>): ResearchElement |
84
85
  }
85
86
 
86
87
  export function extractContainerFromBlockquote(sectionMarkdown: string): string | null {
87
- const bq = mdq(sectionMarkdown).query('blockquote[0]').text().trim();
88
- if (!bq) return null;
89
- const match = bq.match(/Container:\s*(.+)/i);
90
- if (!match) return null;
91
- const css = normalizeLocatorValue(match[1]);
88
+ const entry = mdq(sectionMarkdown).query('blockquote[0]').keyValue().container;
89
+ if (!entry) return null;
90
+ const css = normalizeLocatorValue(entry);
92
91
  if (!css || !/^[.#\[\w]/.test(css)) return null;
93
92
  return css;
94
93
  }
@@ -108,6 +107,25 @@ export function parseResearchSections(markdown: string): ResearchSection[] {
108
107
  });
109
108
  }
110
109
 
110
+ export function parseDataSections(markdown: string): ResearchSection[] {
111
+ return parseSections(markdown)
112
+ .filter((s) => s.name.toLowerCase().startsWith('data:'))
113
+ .map((section) => ({
114
+ name: section.name,
115
+ containerCss: extractContainerFromBlockquote(section.rawMarkdown),
116
+ elements: [],
117
+ rawMarkdown: section.rawMarkdown,
118
+ isExtended: false,
119
+ }));
120
+ }
121
+
122
+ export function extractPaginationFromBlockquote(sectionMarkdown: string): PaginationStrategy | null {
123
+ const value = mdq(sectionMarkdown).query('blockquote[0]').keyValue().pagination?.toLowerCase();
124
+ if (value === 'controls') return 'controls';
125
+ if (value === 'infinite') return 'infinite';
126
+ return null;
127
+ }
128
+
111
129
  export function extractValidContainers(researchText: string, opts?: { exclude?: string[] }): Array<{ css: string; label: string }> {
112
130
  const exclude = opts?.exclude || [];
113
131
  return parseResearchSections(researchText)
@@ -78,7 +78,7 @@ export function WithSections<T extends Constructor>(Base: T) {
78
78
 
79
79
  private async _researchSingleSection(name: string, description: string, ariaSnapshot: string, focusCss: string | null): Promise<string> {
80
80
  const currentUrl = this.stateManager.getCurrentState()?.url || '';
81
- const rules = RulesLoader.loadRules('researcher', ['ui-map-table', 'list-element', 'container-rules'], currentUrl);
81
+ const rules = RulesLoader.loadRules('researcher', ['ui-map-table', 'list-element', 'container-rules', 'pagination'], currentUrl);
82
82
  const url = this.actionResult?.url || 'Unknown';
83
83
  const title = this.actionResult?.title || 'Unknown';
84
84
 
@@ -24,6 +24,7 @@ import { type CoordinateMethods, WithCoordinates } from './researcher/coordinate
24
24
  import { type DeepAnalysisMethods, WithDeepAnalysis } from './researcher/deep-analysis.ts';
25
25
  import { detectFocusedSection, hasFocusedSection, markSectionAsFocused, pickDefaultFocusedSection } from './researcher/focus.ts';
26
26
  import { type LocatorMethods, WithLocators } from './researcher/locators.ts';
27
+ import { type PaginationMethods, WithPagination } from './researcher/pagination.ts';
27
28
  import { extractValidContainers, formatResearchSummary, parseResearchSections } from './researcher/parser.ts';
28
29
  import { ResearchResult } from './researcher/research-result.ts';
29
30
  import { type SectionMethods, WithSections } from './researcher/sections.ts';
@@ -44,9 +45,9 @@ export const POSSIBLE_SECTIONS = {
44
45
  navigation: 'main navigation (top bar, sidebar, breadcrumbs)',
45
46
  };
46
47
 
47
- const ResearcherBase = WithSections(WithDeepAnalysis(WithCoordinates(WithLocators(TaskAgent as unknown as new (...args: any[]) => TaskAgent))));
48
+ const ResearcherBase = WithSections(WithPagination(WithDeepAnalysis(WithCoordinates(WithLocators(TaskAgent as unknown as new (...args: any[]) => TaskAgent)))));
48
49
 
49
- export interface Researcher extends LocatorMethods, CoordinateMethods, DeepAnalysisMethods, SectionMethods {}
50
+ export interface Researcher extends LocatorMethods, CoordinateMethods, DeepAnalysisMethods, SectionMethods, PaginationMethods {}
50
51
 
51
52
  export class Researcher extends ResearcherBase implements Agent {
52
53
  protected readonly ACTION_TOOLS = ['click'];
@@ -277,6 +278,10 @@ export class Researcher extends ResearcherBase implements Agent {
277
278
  await this.backfillBrokenLocators(result);
278
279
  }
279
280
 
281
+ if (!interrupted()) {
282
+ await this.detectPagination(result);
283
+ }
284
+
280
285
  // Focused section: final fallback (vision-only — without a screenshot we don't infer focus)
281
286
  if (this.hasScreenshotToAnalyze && !hasFocusedSection(result.text)) {
282
287
  const sections = parseResearchSections(result.text);
@@ -426,7 +431,7 @@ export class Researcher extends ResearcherBase implements Agent {
426
431
 
427
432
  ${generalLocatorRuleText}
428
433
 
429
- ${RulesLoader.loadRules('researcher', ['ui-map-table', 'list-element', 'container-rules'], currentUrl)}
434
+ ${RulesLoader.loadRules('researcher', ['ui-map-table', 'list-element', 'container-rules', 'pagination'], currentUrl)}
430
435
 
431
436
  <section_identification>
432
437
  Identify page sections in this priority order:
@@ -502,6 +507,7 @@ export class Researcher extends ResearcherBase implements Agent {
502
507
  - 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.
503
508
  - 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.
504
509
  - Data sections must NOT include a UI map table. Only include the container and a brief summary line.
510
+ - When the data list has controls that move between pages of the collection, add "> Pagination: controls" under its container.
505
511
  - Example data section:
506
512
 
507
513
  ## Data: Suites List
package/src/ai/rules.ts CHANGED
@@ -188,6 +188,7 @@ export const capabilityGroundingRule = dedent`
188
188
  When a scenario depends on a named action, menu item, status, option, workflow, or feature,
189
189
  that capability must be visible or explicitly confirmed in the current research/page context
190
190
  for the same target entity type.
191
+ Ground on the scenario's outcome, not a planned step's control label — a missing label is not a missing capability.
191
192
 
192
193
  Do not transfer capabilities between similar entities, rows, lists, detail pages, or menus.
193
194
  Do not replace a requested action with a synonym or related action unless the UI explicitly
@@ -349,6 +350,22 @@ export const actionRule = dedent`
349
350
  For checkboxes, prefer I.checkOption/I.uncheckOption over I.click.
350
351
 
351
352
 
353
+ ### I.scrollTo
354
+
355
+ scrolls until the element is in view
356
+
357
+ I.scrollTo(<locator>)
358
+
359
+ Scrolls every scrollable ancestor of the target, so it reaches an element inside a container
360
+ that has its own scrollbar. I.scrollPageToBottom() moves only the page itself.
361
+
362
+ <example>
363
+ I.scrollTo('.rows > *:last-child');
364
+ I.scrollTo({ role: 'listitem', text: 'Last entry' });
365
+ I.scrollPageToBottom();
366
+ </example>
367
+
368
+
352
369
  ### I.fillField
353
370
 
354
371
  fills the field with the given value
package/src/ai/scout.ts CHANGED
@@ -56,13 +56,20 @@ export class Scout implements Agent {
56
56
  agentName: 'scout',
57
57
  });
58
58
 
59
+ const responseText = invokeResult?.response?.text;
60
+ if (responseText?.trim()) {
61
+ finishFromText(responseText);
62
+ stop();
63
+ return;
64
+ }
65
+
59
66
  if (!invokeResult?.toolExecutions?.length) {
60
- finishFromText(invokeResult?.response?.text);
61
67
  stop();
62
68
  return;
63
69
  }
64
70
 
65
- if (iteration >= MAX_ITERATIONS) {
71
+ if (iteration >= MAX_ITERATIONS - 1) {
72
+ conversation.addUserText('Exploration time is over. Report your findings now as your final message.');
66
73
  const final = await this.provider.invokeConversation(conversation, undefined, { agentName: 'scout' });
67
74
  finishFromText(final?.response?.text);
68
75
  stop();
package/src/ai/tester.ts CHANGED
@@ -41,7 +41,7 @@ const SAMPLE_FILES: Record<string, string> = {
41
41
 
42
42
  export class Tester extends TaskAgent implements Agent {
43
43
  protected readonly ACTION_TOOLS = ['click', 'hover', 'pressKey', 'form'];
44
- protected readonly DELEGATED_ACTION_TOOLS = ['interact'];
44
+ protected readonly DELEGATED_ACTION_TOOLS = ['interact', 'visualClick'];
45
45
  protected readonly SPECIAL_CONTEXT_ACTION_TOOLS = ['exitIframe'];
46
46
  emoji = '🧪';
47
47
  private requestStore: RequestStore;
package/src/ai/tools.ts CHANGED
@@ -374,6 +374,8 @@ export function createCodeceptJSTools({ explorer, stateManager }: ToolDeps, task
374
374
  - Working with iframes (switch context with I.switchTo)
375
375
  - Performing multiple form actions in a single batch
376
376
  - Complex interactions requiring sequential commands
377
+ - Reaching items further down a list (I.scrollTo)
378
+ - Reloading the page to prove a change outlived it (I.reloadPage)
377
379
 
378
380
  Example - filling a form with context (PREFERRED):
379
381
  I.fillField('Username', 'John', '.login-form')
@@ -386,7 +388,7 @@ export function createCodeceptJSTools({ explorer, stateManager }: ToolDeps, task
386
388
  I.selectOption({"role":"combobox","text":"Category"}, 'Technology')
387
389
 
388
390
  Do not submit form - use verify() first to check fields were filled correctly, then click() to submit.
389
- Do not use: wait functions, amOnPage, reloadPage, saveScreenshot
391
+ Do not use: wait functions, amOnPage, saveScreenshot
390
392
  `,
391
393
  inputSchema: z.object({
392
394
  codeBlock: z.string().describe('Valid CodeceptJS code starting with I. Can contain multiple commands separated by newlines.'),
@@ -441,10 +443,10 @@ export function createCodeceptJSTools({ explorer, stateManager }: ToolDeps, task
441
443
 
442
444
  if (!hasObservablePageChange(toolResult)) {
443
445
  activeNote.commit(TestResult.FAILED);
444
- return failedToolResult('form', 'Form command executed, but no observable page or form-state change was captured.', {
446
+ return failedToolResult('form', 'Command executed, but nothing on the page changed: no navigation, no ARIA change, no HTML change and no request.', {
445
447
  ...toolResult,
446
448
  code: codeBlock,
447
- 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.',
449
+ 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.',
448
450
  });
449
451
  }
450
452
  await commitNote(activeNote, TestResult.PASSED, toolResult, action);
@@ -1238,7 +1240,10 @@ export function successToolResult(action: string, data?: Record<string, any>, so
1238
1240
  }
1239
1241
 
1240
1242
  export function isMajorPageChange(pageDiff: PageDiff): boolean {
1241
- return pageDiff.urlChanged !== true && (pageDiff.ariaChangeCount ?? 0) >= LARGE_ARIA_CHANGE_THRESHOLD;
1243
+ if (pageDiff.urlChanged === true) return false;
1244
+ if ((pageDiff.ariaChangeCount ?? 0) < LARGE_ARIA_CHANGE_THRESHOLD) return false;
1245
+ if (pageDiff.ariaRemoved === 0 && (pageDiff.ariaAdded ?? 0) > 0) return false;
1246
+ return true;
1242
1247
  }
1243
1248
 
1244
1249
  export function hasFailedRequest(pageDiff: PageDiff): boolean {
@@ -6,6 +6,20 @@ export class WsOption extends BaseOption {
6
6
  flags = '--ws <url>';
7
7
  description = 'Stream this run to a remote UI over WebSocket';
8
8
 
9
+ /**
10
+ * An open socket holds the event loop, and `WebSocket` has no `unref` on
11
+ * either runtime — so a command that ends by returning rather than through
12
+ * `showStatsAndExit` would never exit once it is attached. The option that
13
+ * opened the connection is what closes it.
14
+ */
15
+ override register(command: Command): void {
16
+ super.register(command);
17
+ command.hook('postAction', async () => {
18
+ if (!remote.isAttached()) return;
19
+ await remote.close(0);
20
+ });
21
+ }
22
+
9
23
  protected apply(options: Record<string, any>, command: Command): void {
10
24
  const url = options.ws || process.env.EXPLORBOT_WS_URL;
11
25
  if (!url) return;
package/src/config.ts CHANGED
@@ -677,17 +677,7 @@ export class ConfigParser {
677
677
  config.playwright.url = options.baseUrl;
678
678
  }
679
679
 
680
- if (config.ai) {
681
- const langfuse = config.ai.langfuse;
682
- const publicKey = langfuse?.publicKey || process.env.LANGFUSE_PUBLIC_KEY;
683
- const secretKey = langfuse?.secretKey || process.env.LANGFUSE_SECRET_KEY;
684
- config.ai.langfuse = {
685
- enabled: langfuse?.enabled ?? Boolean(publicKey && secretKey),
686
- publicKey,
687
- secretKey,
688
- baseUrl: langfuse?.baseUrl || process.env.LANGFUSE_BASE_URL || process.env.LANGFUSE_HOST,
689
- };
690
- }
680
+ resolveLangfuse(config.ai);
691
681
 
692
682
  return config;
693
683
  }
@@ -857,6 +847,20 @@ export async function resolveConfigModels(ai?: AIConfig): Promise<void> {
857
847
  }
858
848
  }
859
849
 
850
+ export function resolveLangfuse(ai?: AIConfig): void {
851
+ if (!ai) return;
852
+
853
+ const langfuse = ai.langfuse;
854
+ const publicKey = langfuse?.publicKey || process.env.LANGFUSE_PUBLIC_KEY;
855
+ const secretKey = langfuse?.secretKey || process.env.LANGFUSE_SECRET_KEY;
856
+ ai.langfuse = {
857
+ enabled: langfuse?.enabled ?? Boolean(publicKey && secretKey),
858
+ publicKey,
859
+ secretKey,
860
+ baseUrl: langfuse?.baseUrl || process.env.LANGFUSE_BASE_URL || process.env.LANGFUSE_HOST,
861
+ };
862
+ }
863
+
860
864
  export function resolveOutputRoot(baseUrl?: string): string {
861
865
  if (cachedOutputRoot) return cachedOutputRoot;
862
866
 
package/src/remote.ts CHANGED
@@ -28,6 +28,7 @@ export class Remote implements LogDestination {
28
28
  private asks = new Map<string, (value: string | null) => void>();
29
29
  private askCounter = 0;
30
30
  private lastActivity: string | null = null;
31
+ private closing: Promise<void> | null = null;
31
32
 
32
33
  attach(url: string, command: string): void {
33
34
  if (this.url) return;
@@ -73,21 +74,10 @@ export class Remote implements LogDestination {
73
74
  });
74
75
  }
75
76
 
76
- async close(exitCode: number): Promise<void> {
77
- if (!this.url) return;
78
- this.send('result', { ok: exitCode === 0, exitCode });
79
- await this.flush();
80
-
81
- this.url = null;
82
- if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
83
- // Whoever asks next has nobody to ask — leaving the callback installed would
84
- // route them into a closed socket and park them until the ask times out.
85
- executionController.clearInputCallback();
86
- for (const resolve of this.asks.values()) resolve(null);
87
- this.asks.clear();
88
- this.queue = [];
89
- this.socket?.close();
90
- this.socket = null;
77
+ close(exitCode: number): Promise<void> {
78
+ if (!this.url) return Promise.resolve();
79
+ if (!this.closing) this.closing = this.shutdown(exitCode);
80
+ return this.closing;
91
81
  }
92
82
 
93
83
  isEnabled(): boolean {
@@ -116,6 +106,23 @@ export class Remote implements LogDestination {
116
106
  });
117
107
  }
118
108
 
109
+ private async shutdown(exitCode: number): Promise<void> {
110
+ this.send('result', { ok: exitCode === 0, exitCode });
111
+ await this.flush();
112
+
113
+ this.url = null;
114
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
115
+ // Whoever asks next has nobody to ask — leaving the callback installed would
116
+ // route them into a closed socket and park them until the ask times out.
117
+ executionController.clearInputCallback();
118
+ for (const resolve of this.asks.values()) resolve(null);
119
+ this.asks.clear();
120
+ this.queue = [];
121
+ this.socket?.close();
122
+ this.socket = null;
123
+ this.closing = null;
124
+ }
125
+
119
126
  private connect(): void {
120
127
  if (!this.url) return;
121
128
 
package/src/utils/aria.ts CHANGED
@@ -521,7 +521,12 @@ export const diffAriaSnapshots = (previous: string | null, current: string | nul
521
521
  const renames = detectRenames(prev, curr, prevTotals, currTotals);
522
522
  const added = [...byCount.added, ...renames.added];
523
523
  const removed = [...byCount.removed, ...renames.removed];
524
- return { text: formatDiff(added, removed, toggled, typed), count: added.length + removed.length + toggled.length + typed.length };
524
+ return {
525
+ text: formatDiff(added, removed, toggled, typed),
526
+ count: added.length + removed.length + toggled.length + typed.length,
527
+ added: added.length,
528
+ removed: removed.length,
529
+ };
525
530
  };
526
531
 
527
532
  export const detectFocusArea = (snapshot: string | null): FocusAreaResult => {
@@ -587,6 +592,8 @@ export const LARGE_ARIA_CHANGE_THRESHOLD = 50;
587
592
  export interface AriaDiff {
588
593
  text: string | null;
589
594
  count: number;
595
+ added: number;
596
+ removed: number;
590
597
  }
591
598
 
592
599
  type AriaNode = {
@@ -2,13 +2,17 @@ import { createDebug } from './logger.js';
2
2
 
3
3
  const debugLog = createDebug('explorbot:code-extractor');
4
4
 
5
+ const JS_LANGUAGES = new Set(['', 'js', 'javascript']);
6
+
5
7
  export function extractCodeBlocks(aiResponse: string): string[] {
6
- const codeBlockRegex = /```(?:js|javascript)?\s*\n([\s\S]*?)\n```/g;
8
+ const codeBlockRegex = /```([^\n`]*)\n([\s\S]*?)\n```/g;
7
9
  const codeBlocks: string[] = [];
8
10
  let match: RegExpExecArray | null = null;
9
11
 
10
12
  while ((match = codeBlockRegex.exec(aiResponse))) {
11
- const code = match[1].trim();
13
+ const language = match[1].trim().toLowerCase();
14
+ if (!JS_LANGUAGES.has(language)) continue;
15
+ const code = match[2].trim();
12
16
  if (!code) continue;
13
17
  try {
14
18
  new Function('I', code);
@@ -162,6 +162,12 @@ function matchText(text: string, matcher: TextMatcher): boolean {
162
162
  return matcher.negated ? !result : result;
163
163
  }
164
164
 
165
+ function entryKey(line: string): string | null {
166
+ const separator = line.indexOf(':');
167
+ if (separator < 1) return null;
168
+ return line.slice(0, separator).trim().toLowerCase();
169
+ }
170
+
165
171
  function getTokenText(token: Token): string {
166
172
  const t = token as any;
167
173
  switch (token.type) {
@@ -407,6 +413,39 @@ export class MarkdownQuery {
407
413
  return results;
408
414
  }
409
415
 
416
+ keyValue(): Record<string, string> {
417
+ const entries: Record<string, string> = {};
418
+
419
+ for (const range of this.matches) {
420
+ for (const line of getTokenText(range.token).split('\n')) {
421
+ const key = entryKey(line);
422
+ if (!key) continue;
423
+ const value = line.slice(line.indexOf(':') + 1).trim();
424
+ if (value) entries[key] = value;
425
+ }
426
+ }
427
+
428
+ return entries;
429
+ }
430
+
431
+ setKeyValue(key: string, value: string | null): string {
432
+ return this.replaceEach((match) => {
433
+ const token = match.matches[0].token;
434
+ const lines = getTokenText(token)
435
+ .split('\n')
436
+ .map((line) => line.trim())
437
+ .filter(Boolean);
438
+
439
+ const index = lines.findIndex((line) => entryKey(line) === key.toLowerCase());
440
+ if (index < 0 && value) lines.push(`${key}: ${value}`);
441
+ if (index >= 0 && value) lines[index] = `${key}: ${value}`;
442
+ if (index >= 0 && !value) lines.splice(index, 1);
443
+
444
+ if (token.type !== 'blockquote') return lines.join('\n');
445
+ return lines.map((line) => `> ${line}`).join('\n');
446
+ });
447
+ }
448
+
410
449
  replace(content: string): string {
411
450
  return this.replaceEach(() => content);
412
451
  }
@@ -0,0 +1,36 @@
1
+ export function inspectList(css: string): ListMeasure | null {
2
+ const element = document.querySelector(css);
3
+ if (!element) return null;
4
+ const rect = element.getBoundingClientRect();
5
+ return {
6
+ hasPagingControls: !!element.querySelector('a[rel="next"], a[rel="prev"]'),
7
+ isFeed: element.matches('[role="feed"]') || !!element.querySelector('[role="feed"]'),
8
+ scrolls: element.scrollHeight > element.clientHeight + 1 || rect.bottom > window.innerHeight,
9
+ items: element.querySelectorAll(':scope > *').length,
10
+ scrollTop: element.scrollTop,
11
+ pageScrollY: window.scrollY,
12
+ };
13
+ }
14
+
15
+ export function restoreScroll({ css, scrollTop, pageScrollY }: ScrollPosition): void {
16
+ const element = document.querySelector(css);
17
+ if (element) element.scrollTop = scrollTop;
18
+ window.scrollTo(0, pageScrollY);
19
+ }
20
+
21
+ export type PaginationStrategy = 'controls' | 'infinite';
22
+
23
+ export interface ListMeasure {
24
+ hasPagingControls: boolean;
25
+ isFeed: boolean;
26
+ scrolls: boolean;
27
+ items: number;
28
+ scrollTop: number;
29
+ pageScrollY: number;
30
+ }
31
+
32
+ export interface ScrollPosition {
33
+ css: string;
34
+ scrollTop: number;
35
+ pageScrollY: number;
36
+ }