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
@@ -3,6 +3,7 @@ import { z } from 'zod';
3
3
  import type { AIProvider } from '../../../../src/ai/provider.ts';
4
4
  import type { RequestStore } from '../../../../src/api/request-store.ts';
5
5
  import type { KnowledgeTracker } from '../../../../src/knowledge-tracker.ts';
6
+ import { Observability } from '../../../../src/observability.ts';
6
7
  import type { Reporter } from '../../../../src/reporter.ts';
7
8
  import { type Test, TestResult } from '../../../../src/test-plan.ts';
8
9
  import { createDebug, tag } from '../../../../src/utils/logger.ts';
@@ -46,75 +47,77 @@ export class Curler {
46
47
  const initialPrompt = this.buildTestPrompt(test, opts?.specDefinition, opts?.baseEndpoint);
47
48
  conversation.addUserText(initialPrompt);
48
49
 
49
- await loop(
50
- async ({ stop, iteration }) => {
51
- debugLog(`Iteration ${iteration}`);
52
-
53
- if (iteration > 1) {
54
- const requestLog = this.requestState.toLog();
55
- const nextStep = dedent`
56
- <request_log>
57
- ${requestLog || 'No requests made yet'}
58
- </request_log>
59
-
60
- <task>
61
- Continue testing. Review the request log above and proceed with the next step.
62
- </task>
63
-
64
- <notes>
65
- ${test.notesToString() || 'No notes yet'}
66
- </notes>
67
- `;
68
- conversation.addUserText(nextStep);
69
- }
70
-
71
- const result = await this.provider.invokeConversation(conversation, tools, {
72
- maxToolRoundtrips: 5,
73
- toolChoice: 'required',
74
- agentName: 'curler',
75
- });
76
-
77
- if (!result) throw new Error('Failed to get response from provider');
78
-
79
- const toolNames = result.toolExecutions?.map((e: any) => e.toolName) || [];
80
- debugLog('Tool calls:', toolNames.join(', '));
81
-
82
- if (test.hasFinished) {
83
- stop();
84
- return;
85
- }
86
-
87
- if (iteration >= MAX_ITERATIONS) {
88
- tag('warning').log('Max iterations reached, running final review...');
89
- stop();
90
- }
91
- },
50
+ await Observability.run(
51
+ `curler: ${test.scenario}`,
92
52
  {
93
- maxAttempts: MAX_ITERATIONS,
94
- observability: {
95
- name: `curler: ${test.scenario}`,
96
- agent: 'curler',
97
- sessionId: test.sessionName,
98
- metadata: {
99
- input: {
100
- scenario: test.scenario,
101
- startUrl: test.startUrl,
102
- expected: test.expected,
103
- },
104
- },
105
- },
106
- catch: async ({ error, stop }) => {
107
- tag('error').log(`Test execution error: ${error}`);
108
- stop();
53
+ sessionId: test.sessionName,
54
+ tags: ['curler'],
55
+ input: {
56
+ scenario: test.scenario,
57
+ startUrl: test.startUrl,
58
+ expected: test.expected,
109
59
  },
60
+ },
61
+ async () => {
62
+ await loop(
63
+ async ({ stop, iteration }) => {
64
+ debugLog(`Iteration ${iteration}`);
65
+
66
+ if (iteration > 1) {
67
+ const requestLog = this.requestState.toLog();
68
+ const nextStep = dedent`
69
+ <request_log>
70
+ ${requestLog || 'No requests made yet'}
71
+ </request_log>
72
+
73
+ <task>
74
+ Continue testing. Review the request log above and proceed with the next step.
75
+ </task>
76
+
77
+ <notes>
78
+ ${test.notesToString() || 'No notes yet'}
79
+ </notes>
80
+ `;
81
+ conversation.addUserText(nextStep);
82
+ }
83
+
84
+ const result = await this.provider.invokeConversation(conversation, tools, {
85
+ maxToolRoundtrips: 5,
86
+ toolChoice: 'required',
87
+ agentName: 'curler',
88
+ });
89
+
90
+ if (!result) throw new Error('Failed to get response from provider');
91
+
92
+ const toolNames = result.toolExecutions?.map((e: any) => e.toolName) || [];
93
+ debugLog('Tool calls:', toolNames.join(', '));
94
+
95
+ if (test.hasFinished) {
96
+ stop();
97
+ return;
98
+ }
99
+
100
+ if (iteration >= MAX_ITERATIONS) {
101
+ tag('warning').log('Max iterations reached, running final review...');
102
+ stop();
103
+ }
104
+ },
105
+ {
106
+ maxAttempts: MAX_ITERATIONS,
107
+ catch: async ({ error, stop }) => {
108
+ tag('error').log(`Test execution error: ${error}`);
109
+ stop();
110
+ },
111
+ }
112
+ );
113
+
114
+ try {
115
+ await this.finalReview(test);
116
+ } catch (error) {
117
+ tag('error').log(`Final review failed: ${error}`);
118
+ }
110
119
  }
111
120
  );
112
-
113
- try {
114
- await this.finalReview(test);
115
- } catch (error) {
116
- tag('error').log(`Final review failed: ${error}`);
117
- }
118
121
  this.finishTest(test);
119
122
  const meta: Record<string, string | undefined> = {
120
123
  endpoint: test.startUrl,
@@ -190,7 +193,8 @@ export class Curler {
190
193
  },
191
194
  ],
192
195
  schema,
193
- model
196
+ model,
197
+ { agentName: 'curler', telemetryFunctionId: 'curler.finalReview' }
194
198
  );
195
199
 
196
200
  const result = response?.object;
@@ -85,6 +85,7 @@ export class ApiBot {
85
85
  async stop(): Promise<void> {
86
86
  await this.reporter?.finishRun();
87
87
  await this.apiClient?.teardown();
88
+ await this.provider?.stop();
88
89
  }
89
90
 
90
91
  createAgent<T>(factory: (deps: { ai: AIProvider; config: ApibotConfig; apiClient: ApiClient; requestState: RequestStore; knowledge: KnowledgeTracker }) => T): T {
@@ -1,4 +1,5 @@
1
1
  import { Command } from 'commander';
2
+ import { flushTelemetry } from '../../../src/ai/provider.ts';
2
3
  import { ConfigCommand } from '../../../src/commands/config-command.ts';
3
4
  import { RecommendedModelsCommand } from '../../../src/commands/recommended-models-command.ts';
4
5
  import { listSites } from '../../../src/global-config.ts';
@@ -111,6 +112,7 @@ async function run(name: string, options: any, endpoint: string | undefined, bod
111
112
  process.exit(code);
112
113
  } catch (error) {
113
114
  console.error('Failed:', error instanceof Error ? error.message : 'Unknown error');
115
+ await flushTelemetry();
114
116
  process.exit(1);
115
117
  }
116
118
  }
@@ -2,7 +2,22 @@ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
2
2
  import path, { resolve } from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
4
  import { parseEnv } from 'node:util';
5
- import { type AIConfig, type ApiHookFn, type ApiConfig as BaseApiConfig, ConfigMissingError, EXPLORBOT_CONFIG_PATHS, createModel, envConfigRequested, materializeKnowledge, missingConfigMessage, resolveConfigModels, resolveModel, resolveOutputRoot, setOutputDir } from '../../../src/config.ts';
5
+ import {
6
+ type AIConfig,
7
+ type ApiHookFn,
8
+ type ApiConfig as BaseApiConfig,
9
+ ConfigMissingError,
10
+ EXPLORBOT_CONFIG_PATHS,
11
+ createModel,
12
+ envConfigRequested,
13
+ materializeKnowledge,
14
+ missingConfigMessage,
15
+ resolveConfigModels,
16
+ resolveLangfuse,
17
+ resolveModel,
18
+ resolveOutputRoot,
19
+ setOutputDir,
20
+ } from '../../../src/config.ts';
6
21
  import { type SiteRecord, findGlobalConfig, globalEnvPath, isGlobalConfigPath, registerSite, resolveSiteTarget } from '../../../src/global-config.ts';
7
22
 
8
23
  export type { AIConfig };
@@ -101,6 +116,7 @@ export class ApibotConfigParser {
101
116
  this.applyEnvHeaders(this.config.api);
102
117
  if (options?.baseEndpoint) this.config.api.baseEndpoint = options.baseEndpoint.replace(/\/$/, '');
103
118
  await resolveConfigModels(this.config.ai);
119
+ resolveLangfuse(this.config.ai);
104
120
  this.configPath = resolvedPath;
105
121
  this.site = null;
106
122
 
@@ -236,6 +252,7 @@ export class ApibotConfigParser {
236
252
  api,
237
253
  dirs: { output: '.', knowledge: 'knowledge' },
238
254
  };
255
+ resolveLangfuse(this.config.ai);
239
256
  this.configPath = path.join(outputRoot, 'apibot.config.js');
240
257
  this.validateConfig(this.config);
241
258
  setOutputDir(this.getOutputDir());
@@ -1,5 +1,6 @@
1
1
  import dedent from 'dedent';
2
2
  import { z } from 'zod';
3
+ import { Observability } from "../../../../src/observability.js";
3
4
  import { TestResult } from "../../../../src/test-plan.js";
4
5
  import { createDebug, tag } from "../../../../src/utils/logger.js";
5
6
  import { loop } from "../../../../src/utils/loop.js";
@@ -32,67 +33,65 @@ export class Curler {
32
33
  conversation.addUserText(knowledge);
33
34
  const initialPrompt = this.buildTestPrompt(test, opts?.specDefinition, opts?.baseEndpoint);
34
35
  conversation.addUserText(initialPrompt);
35
- await loop(async ({ stop, iteration }) => {
36
- debugLog(`Iteration ${iteration}`);
37
- if (iteration > 1) {
38
- const requestLog = this.requestState.toLog();
39
- const nextStep = dedent `
40
- <request_log>
41
- ${requestLog || 'No requests made yet'}
42
- </request_log>
36
+ await Observability.run(`curler: ${test.scenario}`, {
37
+ sessionId: test.sessionName,
38
+ tags: ['curler'],
39
+ input: {
40
+ scenario: test.scenario,
41
+ startUrl: test.startUrl,
42
+ expected: test.expected,
43
+ },
44
+ }, async () => {
45
+ await loop(async ({ stop, iteration }) => {
46
+ debugLog(`Iteration ${iteration}`);
47
+ if (iteration > 1) {
48
+ const requestLog = this.requestState.toLog();
49
+ const nextStep = dedent `
50
+ <request_log>
51
+ ${requestLog || 'No requests made yet'}
52
+ </request_log>
43
53
 
44
- <task>
45
- Continue testing. Review the request log above and proceed with the next step.
46
- </task>
54
+ <task>
55
+ Continue testing. Review the request log above and proceed with the next step.
56
+ </task>
47
57
 
48
- <notes>
49
- ${test.notesToString() || 'No notes yet'}
50
- </notes>
51
- `;
52
- conversation.addUserText(nextStep);
53
- }
54
- const result = await this.provider.invokeConversation(conversation, tools, {
55
- maxToolRoundtrips: 5,
56
- toolChoice: 'required',
57
- agentName: 'curler',
58
+ <notes>
59
+ ${test.notesToString() || 'No notes yet'}
60
+ </notes>
61
+ `;
62
+ conversation.addUserText(nextStep);
63
+ }
64
+ const result = await this.provider.invokeConversation(conversation, tools, {
65
+ maxToolRoundtrips: 5,
66
+ toolChoice: 'required',
67
+ agentName: 'curler',
68
+ });
69
+ if (!result)
70
+ throw new Error('Failed to get response from provider');
71
+ const toolNames = result.toolExecutions?.map((e) => e.toolName) || [];
72
+ debugLog('Tool calls:', toolNames.join(', '));
73
+ if (test.hasFinished) {
74
+ stop();
75
+ return;
76
+ }
77
+ if (iteration >= MAX_ITERATIONS) {
78
+ tag('warning').log('Max iterations reached, running final review...');
79
+ stop();
80
+ }
81
+ }, {
82
+ maxAttempts: MAX_ITERATIONS,
83
+ catch: async ({ error, stop }) => {
84
+ tag('error').log(`Test execution error: ${error}`);
85
+ stop();
86
+ },
58
87
  });
59
- if (!result)
60
- throw new Error('Failed to get response from provider');
61
- const toolNames = result.toolExecutions?.map((e) => e.toolName) || [];
62
- debugLog('Tool calls:', toolNames.join(', '));
63
- if (test.hasFinished) {
64
- stop();
65
- return;
88
+ try {
89
+ await this.finalReview(test);
66
90
  }
67
- if (iteration >= MAX_ITERATIONS) {
68
- tag('warning').log('Max iterations reached, running final review...');
69
- stop();
91
+ catch (error) {
92
+ tag('error').log(`Final review failed: ${error}`);
70
93
  }
71
- }, {
72
- maxAttempts: MAX_ITERATIONS,
73
- observability: {
74
- name: `curler: ${test.scenario}`,
75
- agent: 'curler',
76
- sessionId: test.sessionName,
77
- metadata: {
78
- input: {
79
- scenario: test.scenario,
80
- startUrl: test.startUrl,
81
- expected: test.expected,
82
- },
83
- },
84
- },
85
- catch: async ({ error, stop }) => {
86
- tag('error').log(`Test execution error: ${error}`);
87
- stop();
88
- },
89
94
  });
90
- try {
91
- await this.finalReview(test);
92
- }
93
- catch (error) {
94
- tag('error').log(`Final review failed: ${error}`);
95
- }
96
95
  this.finishTest(test);
97
96
  const meta = {
98
97
  endpoint: test.startUrl,
@@ -160,7 +159,7 @@ export class Curler {
160
159
  3. Should the test pass or fail overall?
161
160
  `,
162
161
  },
163
- ], schema, model);
162
+ ], schema, model, { agentName: 'curler', telemetryFunctionId: 'curler.finalReview' });
164
163
  const result = response?.object;
165
164
  if (!result)
166
165
  return;
@@ -74,6 +74,7 @@ export class ApiBot {
74
74
  async stop() {
75
75
  await this.reporter?.finishRun();
76
76
  await this.apiClient?.teardown();
77
+ await this.provider?.stop();
77
78
  }
78
79
  createAgent(factory) {
79
80
  return factory({
@@ -1,4 +1,5 @@
1
1
  import { Command } from 'commander';
2
+ import { flushTelemetry } from "../../../src/ai/provider.js";
2
3
  import { ConfigCommand } from "../../../src/commands/config-command.js";
3
4
  import { RecommendedModelsCommand } from "../../../src/commands/recommended-models-command.js";
4
5
  import { listSites } from "../../../src/global-config.js";
@@ -108,6 +109,7 @@ async function run(name, options, endpoint, body) {
108
109
  }
109
110
  catch (error) {
110
111
  console.error('Failed:', error instanceof Error ? error.message : 'Unknown error');
112
+ await flushTelemetry();
111
113
  process.exit(1);
112
114
  }
113
115
  }
@@ -10,7 +10,7 @@ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
10
10
  import path, { resolve } from 'node:path';
11
11
  import { pathToFileURL } from 'node:url';
12
12
  import { parseEnv } from 'node:util';
13
- import { ConfigMissingError, EXPLORBOT_CONFIG_PATHS, createModel, envConfigRequested, materializeKnowledge, missingConfigMessage, resolveConfigModels, resolveModel, resolveOutputRoot, setOutputDir } from "../../../src/config.js";
13
+ import { ConfigMissingError, EXPLORBOT_CONFIG_PATHS, createModel, envConfigRequested, materializeKnowledge, missingConfigMessage, resolveConfigModels, resolveLangfuse, resolveModel, resolveOutputRoot, setOutputDir, } from "../../../src/config.js";
14
14
  import { findGlobalConfig, globalEnvPath, isGlobalConfigPath, registerSite, resolveSiteTarget } from "../../../src/global-config.js";
15
15
  function isAbsoluteEndpoint(value) {
16
16
  return !!value && (value.startsWith('http://') || value.startsWith('https://'));
@@ -82,6 +82,7 @@ export class ApibotConfigParser {
82
82
  if (options?.baseEndpoint)
83
83
  this.config.api.baseEndpoint = options.baseEndpoint.replace(/\/$/, '');
84
84
  await resolveConfigModels(this.config.ai);
85
+ resolveLangfuse(this.config.ai);
85
86
  this.configPath = resolvedPath;
86
87
  this.site = null;
87
88
  if (isGlobalConfigPath(resolvedPath)) {
@@ -208,6 +209,7 @@ export class ApibotConfigParser {
208
209
  api,
209
210
  dirs: { output: '.', knowledge: 'knowledge' },
210
211
  };
212
+ resolveLangfuse(this.config.ai);
211
213
  this.configPath = path.join(outputRoot, 'apibot.config.js');
212
214
  this.validateConfig(this.config);
213
215
  setOutputDir(this.getOutputDir());
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.4.6",
3
+ "version": "0.4.7",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -101,7 +101,7 @@
101
101
  "bash-tool": "^1.3.15",
102
102
  "chalk": "^5.6.2",
103
103
  "cli-highlight": "^2.1.11",
104
- "codeceptjs": "4.2.0-beta.2",
104
+ "codeceptjs": "^4.2.0-beta.3",
105
105
  "commander": "^14.0.1",
106
106
  "debug": "^4.4.3",
107
107
  "dedent": "^1.6.0",
@@ -0,0 +1,6 @@
1
+ <pagination>
2
+ When a section is a list that continues beyond what is shown, add one line under its `> Container:` line:
3
+ `> Pagination: controls` — it has page numbers (1, 2, 3), prev/next arrows, or a "load more" button.
4
+ `> Pagination: infinite` — it has none of those and loads more as it is scrolled.
5
+ Sorting, filtering and switching tabs are not pagination — omit the line then.
6
+ </pagination>
@@ -37,6 +37,8 @@ export interface PageDiff {
37
37
  currentUrl: string;
38
38
  ariaChanges?: string | null;
39
39
  ariaChangeCount?: number;
40
+ ariaAdded?: number;
41
+ ariaRemoved?: number;
40
42
  messages?: string[];
41
43
  requests?: NetworkCall[];
42
44
  consoleErrors?: string[];
@@ -137,6 +139,8 @@ export declare class Diff {
137
139
  _messages: string[];
138
140
  _ariaDiffResult: string | null;
139
141
  _ariaChangeCount: number;
142
+ _ariaAdded: number;
143
+ _ariaRemoved: number;
140
144
  _isSameUrl: boolean;
141
145
  constructor(current: ActionResult, previous: ActionResult | null);
142
146
  static create(current: ActionResult, previous: ActionResult | null): Promise<Diff>;
@@ -147,6 +151,8 @@ export declare class Diff {
147
151
  cleanedHtmlParts(): Promise<HtmlDiffPart[]>;
148
152
  get ariaChanged(): string | null;
149
153
  get ariaChangeCount(): number;
154
+ get ariaAdded(): number;
155
+ get ariaRemoved(): number;
150
156
  get htmlDiff(): HtmlDiffResult | null;
151
157
  get messages(): string[];
152
158
  get similarity(): number;
@@ -451,6 +451,8 @@ export class ActionResult {
451
451
  if (diff.ariaChanged) {
452
452
  pageDiff.ariaChanges = diff.ariaChanged;
453
453
  pageDiff.ariaChangeCount = diff.ariaChangeCount;
454
+ pageDiff.ariaAdded = diff.ariaAdded;
455
+ pageDiff.ariaRemoved = diff.ariaRemoved;
454
456
  }
455
457
  if (this.overlay.isOpen && (!previousState.overlay.isOpen || previousState.overlay.name !== this.overlay.name)) {
456
458
  pageDiff.areaOfInterest = this.overlay.describe();
@@ -542,6 +544,8 @@ export class Diff {
542
544
  _messages = [];
543
545
  _ariaDiffResult = null;
544
546
  _ariaChangeCount = 0;
547
+ _ariaAdded = 0;
548
+ _ariaRemoved = 0;
545
549
  _isSameUrl;
546
550
  constructor(current, previous) {
547
551
  this.current = current;
@@ -590,6 +594,12 @@ export class Diff {
590
594
  get ariaChangeCount() {
591
595
  return this._ariaChangeCount;
592
596
  }
597
+ get ariaAdded() {
598
+ return this._ariaAdded;
599
+ }
600
+ get ariaRemoved() {
601
+ return this._ariaRemoved;
602
+ }
593
603
  get htmlDiff() {
594
604
  return this._htmlDiffResult;
595
605
  }
@@ -614,5 +624,7 @@ export class Diff {
614
624
  const ariaDiff = diffAriaSnapshots(this.previous.ariaSnapshot, this.current.ariaSnapshot);
615
625
  this._ariaDiffResult = ariaDiff.text;
616
626
  this._ariaChangeCount = ariaDiff.count;
627
+ this._ariaAdded = ariaDiff.added;
628
+ this._ariaRemoved = ariaDiff.removed;
617
629
  }
618
630
  }
@@ -404,12 +404,16 @@ export class Planner extends PlannerBase {
404
404
  `);
405
405
  }
406
406
  }
407
+ let activeRegion = '';
408
+ if (state.overlay.isOpen)
409
+ activeRegion = `Active region: ${state.overlay.describe()} — the user's current focus area. Plan tests for the controls inside it first.`;
407
410
  conversation.addUserText(dedent `
408
411
  ${this.buildApproach(style)}
409
412
 
410
413
  <context>
411
414
  URL: ${state.url || 'Unknown'}
412
415
  Title: ${state.title || 'Unknown'}
416
+ ${activeRegion}
413
417
  </context>
414
418
  `);
415
419
  if (this.fisherman) {
@@ -281,7 +281,7 @@ export function WithLocators(Base) {
281
281
  if (sectionQuery.count() === 0)
282
282
  sectionQuery = mdq(result.text).query(`section3(~"${escaped}")`);
283
283
  if (newCss) {
284
- result.text = sectionQuery.query('blockquote[0]').replace(`Container: '${newCss}'`);
284
+ result.text = sectionQuery.query('blockquote[0]').setKeyValue('Container', `'${newCss}'`);
285
285
  }
286
286
  else {
287
287
  result.text = sectionQuery.query('blockquote[0]').replace('');
@@ -0,0 +1,16 @@
1
+ import type Explorer from '../../explorer.js';
2
+ import { type ListMeasure, type PaginationStrategy } from '../../utils/pagination.js';
3
+ import { type Constructor } from './mixin.js';
4
+ import type { ResearchResult } from './research-result.js';
5
+ export declare function WithPagination<T extends Constructor>(Base: T): {
6
+ new (...args: any[]): {
7
+ explorer: Explorer;
8
+ detectPagination(result: ResearchResult): Promise<void>;
9
+ probeSection(css: string): Promise<PaginationStrategy | null>;
10
+ measure(css: string): Promise<ListMeasure | null>;
11
+ recordPagination(result: ResearchResult, name: string, strategy: PaginationStrategy): void;
12
+ };
13
+ } & T;
14
+ export interface PaginationMethods {
15
+ detectPagination(result: ResearchResult): Promise<void>;
16
+ }
@@ -0,0 +1,62 @@
1
+ import { mdq } from "../../utils/markdown-query.js";
2
+ import { inspectList, restoreScroll } from "../../utils/pagination.js";
3
+ import { debugLog } from "./mixin.js";
4
+ import { extractPaginationFromBlockquote, parseDataSections, parseResearchSections } from "./parser.js";
5
+ export function WithPagination(Base) {
6
+ return class extends Base {
7
+ async detectPagination(result) {
8
+ const sections = [...parseResearchSections(result.text), ...parseDataSections(result.text)];
9
+ for (const section of sections) {
10
+ const css = section.containerCss;
11
+ if (!css)
12
+ continue;
13
+ if (extractPaginationFromBlockquote(section.rawMarkdown))
14
+ continue;
15
+ const strategy = await this.probeSection(css);
16
+ if (!strategy)
17
+ continue;
18
+ this.recordPagination(result, section.name, strategy);
19
+ debugLog(`Pagination in "${section.name}": ${strategy}`);
20
+ }
21
+ }
22
+ async probeSection(css) {
23
+ const before = await this.measure(css);
24
+ if (!before)
25
+ return null;
26
+ if (before.hasPagingControls)
27
+ return 'controls';
28
+ if (before.isFeed)
29
+ return 'infinite';
30
+ if (!before.scrolls)
31
+ return null;
32
+ const action = this.explorer.action();
33
+ const scrolled = await action.attempt(`I.scrollTo('${css} > *:last-child')`).catch(() => false);
34
+ if (!scrolled)
35
+ return null;
36
+ const after = await this.measure(css);
37
+ await this.explorer.withPage((page) => page.evaluate(restoreScroll, { css, scrollTop: before.scrollTop, pageScrollY: before.pageScrollY })).catch(() => { });
38
+ if (!after)
39
+ return null;
40
+ if (after.items > before.items)
41
+ return 'infinite';
42
+ return null;
43
+ }
44
+ measure(css) {
45
+ return this.explorer
46
+ .withPage((page) => page.evaluate(inspectList, css))
47
+ .catch((err) => {
48
+ debugLog(`List measurement failed for '${css}': ${err.message}`);
49
+ return null;
50
+ });
51
+ }
52
+ recordPagination(result, name, strategy) {
53
+ const escaped = name.replace(/"/g, '\\"');
54
+ let sectionQuery = mdq(result.text).query(`section2(~"${escaped}")`);
55
+ if (sectionQuery.count() === 0)
56
+ sectionQuery = mdq(result.text).query(`section3(~"${escaped}")`);
57
+ if (sectionQuery.count() === 0)
58
+ return;
59
+ result.text = sectionQuery.query('blockquote[0]').setKeyValue('Pagination', strategy);
60
+ }
61
+ };
62
+ }
@@ -1,3 +1,4 @@
1
+ import type { PaginationStrategy } from '../../utils/pagination.js';
1
2
  export interface ResearchElement {
2
3
  name: string;
3
4
  type: string | null;
@@ -23,6 +24,8 @@ export declare const RESEARCH_COLUMN_ORDER: string[];
23
24
  export declare function mapRowToElement(row: Record<string, string>): ResearchElement | null;
24
25
  export declare function extractContainerFromBlockquote(sectionMarkdown: string): string | null;
25
26
  export declare function parseResearchSections(markdown: string): ResearchSection[];
27
+ export declare function parseDataSections(markdown: string): ResearchSection[];
28
+ export declare function extractPaginationFromBlockquote(sectionMarkdown: string): PaginationStrategy | null;
26
29
  export declare function extractValidContainers(researchText: string, opts?: {
27
30
  exclude?: string[];
28
31
  }): Array<{
@@ -57,13 +57,10 @@ export function mapRowToElement(row) {
57
57
  };
58
58
  }
59
59
  export function extractContainerFromBlockquote(sectionMarkdown) {
60
- const bq = mdq(sectionMarkdown).query('blockquote[0]').text().trim();
61
- if (!bq)
60
+ const entry = mdq(sectionMarkdown).query('blockquote[0]').keyValue().container;
61
+ if (!entry)
62
62
  return null;
63
- const match = bq.match(/Container:\s*(.+)/i);
64
- if (!match)
65
- return null;
66
- const css = normalizeLocatorValue(match[1]);
63
+ const css = normalizeLocatorValue(entry);
67
64
  if (!css || !/^[.#\[\w]/.test(css))
68
65
  return null;
69
66
  return css;
@@ -80,6 +77,25 @@ export function parseResearchSections(markdown) {
80
77
  return { name: section.name, containerCss, elements, rawMarkdown: section.rawMarkdown, isExtended };
81
78
  });
82
79
  }
80
+ export function parseDataSections(markdown) {
81
+ return parseSections(markdown)
82
+ .filter((s) => s.name.toLowerCase().startsWith('data:'))
83
+ .map((section) => ({
84
+ name: section.name,
85
+ containerCss: extractContainerFromBlockquote(section.rawMarkdown),
86
+ elements: [],
87
+ rawMarkdown: section.rawMarkdown,
88
+ isExtended: false,
89
+ }));
90
+ }
91
+ export function extractPaginationFromBlockquote(sectionMarkdown) {
92
+ const value = mdq(sectionMarkdown).query('blockquote[0]').keyValue().pagination?.toLowerCase();
93
+ if (value === 'controls')
94
+ return 'controls';
95
+ if (value === 'infinite')
96
+ return 'infinite';
97
+ return null;
98
+ }
83
99
  export function extractValidContainers(researchText, opts) {
84
100
  const exclude = opts?.exclude || [];
85
101
  return parseResearchSections(researchText)