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
@@ -22,12 +22,12 @@ untouched.
22
22
 
23
23
  ## Approach
24
24
 
25
- Detection finds out which strategy a list uses; a rule tells the tester what to do about it,
26
- and is injected only when there is something to say. No new tool: the gesture already exists in
27
- CodeceptJS and is reachable through `form`.
25
+ Research finds out which strategy each list uses and records it in the UI map, beside that
26
+ list's own container. The tester already reads the UI map, so the fact arrives attached to the
27
+ list it describes. No new tool: the gesture already exists in CodeceptJS and is reachable
28
+ through `form`.
28
29
 
29
- Splitting it that way is what keeps the rule short. The tester never has to discover anything
30
- at run time, and never carries guidance for a strategy this page does not use.
30
+ Nothing is injected per page see section B for why the first attempt at that was wrong.
31
31
 
32
32
  ### Why `I.scrollTo` is sufficient
33
33
 
@@ -67,41 +67,10 @@ Four steps, cheapest first, stopping as soon as one answers. This is the escalat
67
67
  CLAUDE.md end to end: a table lookup, then AI judgment, then a probe whose result converts
68
68
  judgment back into a recorded fact.
69
69
 
70
- **0. Do the ARIA/HTML conventions name it? (deterministic, no research, no AI)**
71
-
72
- Some markup states the answer outright. These are spec-defined attributes and values, so this
73
- tier is a lookup, not a guess — and it is the only step that works when research has not run.
74
-
75
- | Marker | Means | Available in |
76
- |---|---|---|
77
- | `[aria-current="page"]` | current page of a pagination set | HTML only |
78
- | `a[rel="next"]`, `a[rel="prev"]` | sequential document relations | HTML only |
79
- | `[role="feed"]` | scrollable list that grows as it is scrolled | HTML and ARIA snapshot |
80
- | `[aria-setsize="-1"]` | total count unknown, so the set loads lazily | HTML only |
81
-
82
- The first two mean `controls`, the last two mean `infinite`.
83
-
84
- Verified against Chromium: `ariaSnapshot()` does **not** emit `aria-current`, so a link marked
85
- as the current page is indistinguishable from its neighbours in the ARIA path. Explorbot also
86
- dissolves `navigation` wrappers (`src/utils/aria.ts:46`, `:147`) and treats the role as
87
- template chrome (`src/utils/aria.ts:543`), so a `nav` labelled "Pagination" never reaches the
88
- model either. **These markers must be read from HTML.** `role="feed"` is the one exception —
89
- it survives as `- feed "…"` in the snapshot.
90
-
91
- Two constraints that keep this a lookup rather than a heuristic:
92
-
93
- - The value must be `aria-current="page"` exactly. `aria-current="true"` is what tabs and
94
- breadcrumbs use and would over-match — confirmed in the same probe.
95
- - A `nav` whose `aria-label` reads "Pagination" is author prose, not closed grammar. It is not
96
- part of this tier.
97
-
98
- **Absence proves nothing.** A pager built from plain buttons, and an infinite feed built from
99
- plain divs, carry none of these. That is what steps 1–3 are for.
100
-
101
70
  **1. Are there pagination controls? (AI, free)**
102
71
 
103
- Only asked when step 0 found nothing. Controls are named in open-ended ways — words, arrows,
104
- bare numbers — so this is AI judgment, not a pattern match. Researcher is already describing
72
+ Controls are named in open-ended ways — words, arrows, bare numbers — so this is AI judgment,
73
+ not a pattern match. Researcher is already describing
105
74
  the section, so it costs nothing extra:
106
75
  a new `rules/researcher/pagination.md`, loaded alongside the existing three at
107
76
  `src/ai/researcher/sections.ts:81`, asks it to note when a section contains controls that move
@@ -109,26 +78,69 @@ between pages of the same collection.
109
78
 
110
79
  If found, the section records `> Pagination: controls` and the remaining steps are skipped.
111
80
 
112
- **2. Can the section scroll at all? (deterministic gate)**
81
+ **2. Markers and the scroll gate one `page.evaluate` per container (`inspectList`)**
113
82
 
114
- Only asked when no controls were found. One `page.evaluate` per container:
83
+ Only reached when research recorded nothing. Everything the probe needs comes back in one call,
84
+ **scoped to the container**, never to the page:
115
85
 
116
- - `el.scrollHeight > el.clientHeight`the container has its own scroller.
117
- - `el.getBoundingClientRect().bottom > innerHeight` the list continues below the fold.
118
- - Neither nothing more to do; no line recorded.
86
+ - `a[rel="next"]`/`a[rel="prev"]` inside it`controls`, recorded without scrolling.
87
+ - `[role="feed"]` `infinite`, recorded without scrolling. These are spec-defined relations, so
88
+ this tier is a lookup, not a heuristic.
89
+ - `scrollHeight > clientHeight`, or the container's bottom below the fold → it can scroll, so
90
+ step 3 may run. Neither → nothing recorded.
119
91
 
120
- This gate exists to keep step 3 from running on every short list.
92
+ `aria-current` is excluded in every value: its primary spec use is a site-navigation link
93
+ marking the page you are on, and `aria-current="true"` is what tabs and breadcrumbs use.
94
+ `rel="next"`/`rel="prev"` carry the sequential meaning unambiguously.
95
+
96
+ The scoping is the point. Asking the same question of the whole page is what section B removed.
121
97
 
122
98
  **3. Probe: does scrolling load more? (deterministic measurement)**
123
99
 
124
100
  Scroll the container to its end, wait for readiness (`waitForPageReadiness`,
125
- `src/utils/page-readiness.ts`), and compare. More descendant rows than before, or a same-origin
126
- xhr/fetch fired during the scroll, means the list appends. Record `> Pagination: infinite`.
101
+ `src/utils/page-readiness.ts`), and compare descendant counts. More than before means the list
102
+ appends. Record `> Pagination: infinite`.
103
+
104
+ **Rows are the evidence, not requests.** The same rule the tester follows: a request that
105
+ brings no rows tells you nothing arrived. A page also fires telemetry and prefetches while
106
+ scrolling, so a bare request count would report growth where there is none. `networkRequests`
107
+ is private to `Action` (`src/action.ts:46`) and stays that way — nothing here needs widening.
108
+
109
+ **The scroll goes through `Action`, not through `page.evaluate`.** `deep-analysis.ts` sets the
110
+ precedent at `:405` — `this.explorer.action()`, then `action.attempt(cmd)` per command. Action
111
+ is the only thing that moves the browser (CLAUDE.md glue tiers), and going around it would
112
+ bypass the recorder and state updates. Measurement (row counts, scroll offsets) still uses
113
+ `withPage`, which reads without moving.
127
114
 
128
115
  Then restore `scrollTop` to what it was, so screenshots, coordinates and later research see the
129
116
  page as they found it. Scroll position is not app state, so this needs none of the modal
130
117
  cleanup `_restorePageState` does in `deep-analysis.ts:453` — there is nothing to reuse there.
131
118
 
119
+ **Which sections get probed — `Data:` sections are the point.**
120
+
121
+ Researcher is instructed to emit a list of similar data items as a `## Data: <name>` section
122
+ holding a container and a summary line, no table (`src/ai/researcher.ts:502-509`). That is
123
+ precisely where a paginated list lands.
124
+
125
+ But `parseResearchSections` (`src/ai/researcher/parser.ts:100`) filters those out:
126
+
127
+ ```js
128
+ .filter((s) => !SKIP_SECTIONS.has(s.name.toLowerCase()) && !s.name.toLowerCase().includes('data:'))
129
+ ```
130
+
131
+ `SKIP_SECTIONS` (`:27`) also drops a section literally named `data`. So iterating
132
+ `parseResearchSections` — as `validateContainers` does — would probe every section **except**
133
+ the lists. Nothing else in the codebase parses `Data:` sections today.
134
+
135
+ A new `parseDataSections(markdown): ResearchSection[]` in `parser.ts` returns them: the same
136
+ `parseSections` call, filtered to names beginning with `data:`, reusing
137
+ `extractContainerFromBlockquote` and yielding an empty `elements` array (Data sections carry no
138
+ table by construction). Both parsers stay single-purpose.
139
+
140
+ Steps 1–3 then run over `[...parseResearchSections(text), ...parseDataSections(text)]`. A
141
+ non-Data section can hold a list too, and it costs nothing to include it: step 2 gates it out
142
+ when it does not scroll.
143
+
132
144
  **Recorded vocabulary:** `controls` or `infinite`, as a line in the section's container
133
145
  blockquote. Nothing is written when a list neither paginates nor grows, which is the common
134
146
  case and should stay silent.
@@ -139,9 +151,9 @@ case and should stay silent.
139
151
  ```
140
152
 
141
153
  **This line has a reader**, because section B injects the rule only when pagination was
142
- detected, and that decision is code. `extractPaginationFromBlockquote` joins
143
- `extractContainerFromBlockquote` (`src/ai/researcher/parser.ts:86`) and returns the recorded
144
- value or null.
154
+ detected, and that decision is code. `extractPaginationFromBlockquote(sectionMarkdown)` joins
155
+ `extractContainerFromBlockquote` (`src/ai/researcher/parser.ts:86`) and returns `'controls'`,
156
+ `'infinite'`, or null — anything else in the line is ignored, keeping the vocabulary closed.
145
157
 
146
158
  That makes `Pagination:` a closed vocabulary read deterministically by code, so the envelope
147
159
  checklist from CLAUDE.md applies and holds: read by code, scoped to a section of a state,
@@ -173,56 +185,30 @@ references. One concern: how a list continues. `measureLayout` in `overlay.ts` i
173
185
  it is xpath-based and returns a modal-scoring `RegionLayout`, while sections carry CSS
174
186
  selectors and need neither.
175
187
 
176
- ### B. The pagination rule, injected only when pagination was detected
188
+ ### B. The UI map carries it nothing is injected per page
177
189
 
178
- Not part of the static system message. A page with no list should not carry list guidance, and
179
- a page with page numbers should not be told how to scroll.
190
+ **Superseded during implementation.** The original design injected a `<pagination>` block into
191
+ Tester and Navigator whenever a strategy was detected. That was wrong and is removed.
180
192
 
181
- **Seam:** `reinjectContextIfNeeded` (`src/ai/tester.ts:554`), which already injects per-state
182
- blocks conditionally `focusedElementRule` when something is focused, an `<overlay>` block
183
- when a region is open. A `<pagination>` block joins them. Navigator gets the same block where
184
- it builds its own per-state context (`src/ai/navigator.ts:414`).
193
+ The condition was computed from the whole page's HTML, which carries no context. A pager
194
+ anywhere on the page a sidebar list, a widget behind an open modal, a drawer — told the
195
+ tester "this list pages through a larger collection" whatever it was actually looking at. A
196
+ page-level answer cannot address a question about one list among several.
185
197
 
186
- **Condition:** the state shows pagination if step 0's markers are present in the current HTML,
187
- or `extractPaginationFromBlockquote` finds a recorded value for a section. Markers win when
188
- both are available, since they describe the page as it is now rather than as research left it.
198
+ The UI map already solves this. Research records `> Pagination:` under the section's own
199
+ container, and the tester already reads the UI map. The fact arrives attached to the list it
200
+ describes, and says nothing about any other list on the page. `actionRule` documents
201
+ `I.scrollTo` for the capability itself (section C), which is genuinely page-independent.
189
202
 
190
- **Which text:** the strategy selects the fragment, so the model is never shown the other one.
203
+ Consequences:
191
204
 
192
- `paginationControlsRule`:
193
-
194
- ```
195
- <pagination>
196
- This list pages through a larger collection. If what you need is not on screen,
197
- click next or the page number you need before concluding it is absent.
198
- </pagination>
199
- ```
200
-
201
- `infiniteScrollRule`:
202
-
203
- ```
204
- <pagination>
205
- This list grows as it is scrolled. If what you need is not on screen, scroll to
206
- the last item in the list — every scrollable ancestor of that item scrolls, so
207
- this reaches a list with its own scrollbar.
208
-
209
- New rows in the aria changes mean more arrived; a request with none means nothing
210
- was left. Stop on the first attempt that adds no rows: the end of a collection is
211
- an answer, not a failure.
212
- </pagination>
213
- ```
214
-
215
- Both live in `src/ai/rules.ts` as exports. Not `rules/*.md`: those load per agent, and these
216
- have two consumers.
217
-
218
- **Reach narrows from the earlier draft.** Composing into `actionRule` would have reached
219
- Rerunner (`src/ai/rerunner.ts:450`) and Captain web-mode (`src/ai/captain/web-mode.ts:148`) too;
220
- conditional injection reaches only agents that build per-state context, which is Tester and
221
- Navigator. That is the cost of making it conditional, and it is the right trade: Rerunner heals
222
- known steps rather than traversing lists.
223
-
224
- Constraints both fragments keep: general phrasing, no selectors, no site names, no example
225
- taken from a debug session, one to three lines per bullet.
205
+ - `paginationRuleFor` and `paginationFromResearch` are gone; `src/ai/rules.ts` gains only the
206
+ scroll commands in section C.
207
+ - `Pagination:` has no code reader, so the envelope checklist does not apply to it — it is
208
+ prompt context, like every other line in the UI map.
209
+ - Marker detection moves in-page, scoped to the container, inside `inspectList`. jsdom is no
210
+ longer used: it was pulled in only to parse whole-page HTML for the injection, and eagerly at
211
+ that, while the single existing use in `src/utils/xpath.ts:94` imports it lazily.
226
212
 
227
213
  ### C. `actionRule` documents the gesture
228
214
 
@@ -292,9 +278,12 @@ The added/removed split exists inside `diffAriaSnapshots` but is flattened into
292
278
 
293
279
  ## Testing
294
280
 
295
- - Unit coverage for step 0's marker scan: `aria-current="page"` and `rel=next/prev` yield
296
- `controls`; `role="feed"` and `aria-setsize="-1"` yield `infinite`; `aria-current="true"` on a
297
- tab list yields nothing.
281
+ - Unit coverage for step 0's marker scan: `rel=next/prev` yields `controls`; `role="feed"` and
282
+ `aria-setsize="-1"` yield `infinite`; `aria-current` in any value yields nothing.
283
+ - Unit coverage for `parseDataSections`: a `## Data: Suites List` section with a container is
284
+ returned with its `containerCss`, and `parseResearchSections` still excludes it.
285
+ - Unit coverage for `extractPaginationFromBlockquote`: reads `controls` and `infinite`, returns
286
+ null for an absent line and for any other value.
298
287
  - `tests/integration/researcher-sections.test.ts` — `> Pagination: controls` appears for a
299
288
  section whose UI map holds next/prev controls, and step 1 is not asked when step 0 already
300
289
  answered.
@@ -315,3 +304,42 @@ The added/removed split exists inside `diffAriaSnapshots` but is flattened into
315
304
  session-scoped.
316
305
  - Virtualized list support.
317
306
  - Changing `detectRegion` thresholds.
307
+ - The API signal below — a follow-up branch, not this one.
308
+
309
+ ## Follow-up: pagination from the API
310
+
311
+ The DOM says a list continues; the API says **how far**. That number decides whether paging on
312
+ is worth it or the item is not in the collection at all, and nothing in this design can supply
313
+ it. Deferred to its own branch because it depends on the API boat being configured and lands on
314
+ Pilot rather than on Researcher or Tester.
315
+
316
+ **Already in place, verified:**
317
+
318
+ - `XhrCapture.captureReadEndpoint` (`src/api/xhr-capture.ts`) stores GETs as
319
+ `fullUrl = pathname + search`, so the **query string is already captured**.
320
+ - `queryParamNames()` extracts the names and `queryParamHint()` renders them into
321
+ `toEndpointList()` (`src/api/request-store.ts:78`), so fisherman already sees
322
+ `GET /api/items ?page,per_page` in its endpoint list.
323
+ - `askApi` (`src/ai/fisherman/tools.ts:220`) is wired to **Pilot alone**
324
+ (`src/ai/pilot.ts:786`), and `fisherman.lookupData()` issues live read-only requests, reading
325
+ full responses. Totals are reachable today without touching capture.
326
+
327
+ **The gap:** GET response bodies are deliberately dropped (`rawResponseBodyValue = ''`,
328
+ `responseHeaders: {}`), so `total`, `X-Total-Count` and `Link: rel="next"` are not passively
329
+ available. `askApi` re-requests instead, which is why that gap does not block this.
330
+
331
+ **Shape of the follow-up:**
332
+
333
+ 1. A Pilot rule saying **when** asking is worth it — after repeated paging or scrolling has not
334
+ produced the target — never "ask about every list". Pilot *guides* while Tester *executes*,
335
+ and judging whether to keep paging is guidance; Pilot's conversation is also the light one,
336
+ so API reasoning belongs there rather than in Tester's ARIA-heavy loop.
337
+ 2. A deterministic signal from captured read requests whose query params name a pagination
338
+ scheme (`page`, `offset`, `cursor`, `limit`, `per_page`). Free — the data is already stored,
339
+ and `queryParamNames` already isolates it.
340
+
341
+ **Rejected:** capturing GET response bodies to read totals passively. Bodies are large, reads
342
+ are discarded on purpose, and `askApi` already obtains them on demand.
343
+
344
+ Both must stay optional: `fisherman?.isAvailable()` already returns a clean "no API access,
345
+ judge from the page instead", and a run without an API boat must behave exactly as it does now.
package/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>
@@ -45,6 +45,8 @@ export interface PageDiff {
45
45
  currentUrl: string;
46
46
  ariaChanges?: string | null;
47
47
  ariaChangeCount?: number;
48
+ ariaAdded?: number;
49
+ ariaRemoved?: number;
48
50
  messages?: string[];
49
51
  requests?: NetworkCall[];
50
52
  consoleErrors?: string[];
@@ -551,6 +553,8 @@ export class ActionResult implements ActionResultData {
551
553
  if (diff.ariaChanged) {
552
554
  pageDiff.ariaChanges = diff.ariaChanged;
553
555
  pageDiff.ariaChangeCount = diff.ariaChangeCount;
556
+ pageDiff.ariaAdded = diff.ariaAdded;
557
+ pageDiff.ariaRemoved = diff.ariaRemoved;
554
558
  }
555
559
 
556
560
  if (this.overlay.isOpen && (!previousState.overlay.isOpen || previousState.overlay.name !== this.overlay.name)) {
@@ -652,6 +656,8 @@ export class Diff {
652
656
  private _messages: string[] = [];
653
657
  private _ariaDiffResult: string | null = null;
654
658
  private _ariaChangeCount = 0;
659
+ private _ariaAdded = 0;
660
+ private _ariaRemoved = 0;
655
661
  private _isSameUrl: boolean;
656
662
 
657
663
  constructor(
@@ -709,6 +715,14 @@ export class Diff {
709
715
  return this._ariaChangeCount;
710
716
  }
711
717
 
718
+ get ariaAdded(): number {
719
+ return this._ariaAdded;
720
+ }
721
+
722
+ get ariaRemoved(): number {
723
+ return this._ariaRemoved;
724
+ }
725
+
712
726
  get htmlDiff(): HtmlDiffResult | null {
713
727
  return this._htmlDiffResult;
714
728
  }
@@ -740,6 +754,8 @@ export class Diff {
740
754
  const ariaDiff = diffAriaSnapshots(this.previous.ariaSnapshot, this.current.ariaSnapshot);
741
755
  this._ariaDiffResult = ariaDiff.text;
742
756
  this._ariaChangeCount = ariaDiff.count;
757
+ this._ariaAdded = ariaDiff.added;
758
+ this._ariaRemoved = ariaDiff.removed;
743
759
  }
744
760
  }
745
761
 
package/src/ai/planner.ts CHANGED
@@ -452,12 +452,16 @@ export class Planner extends PlannerBase implements Agent {
452
452
  }
453
453
  }
454
454
 
455
+ let activeRegion = '';
456
+ if (state.overlay.isOpen) activeRegion = `Active region: ${state.overlay.describe()} — the user's current focus area. Plan tests for the controls inside it first.`;
457
+
455
458
  conversation.addUserText(dedent`
456
459
  ${this.buildApproach(style)}
457
460
 
458
461
  <context>
459
462
  URL: ${state.url || 'Unknown'}
460
463
  Title: ${state.title || 'Unknown'}
464
+ ${activeRegion}
461
465
  </context>
462
466
  `);
463
467
 
@@ -304,7 +304,7 @@ export function WithLocators<T extends Constructor>(Base: T) {
304
304
  if (sectionQuery.count() === 0) sectionQuery = mdq(result.text).query(`section3(~"${escaped}")`);
305
305
 
306
306
  if (newCss) {
307
- result.text = sectionQuery.query('blockquote[0]').replace(`Container: '${newCss}'`);
307
+ result.text = sectionQuery.query('blockquote[0]').setKeyValue('Container', `'${newCss}'`);
308
308
  } else {
309
309
  result.text = sectionQuery.query('blockquote[0]').replace('');
310
310
  result.text = result.text.replace(`${FOCUSED_MARKER}\n`, '');
@@ -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
@@ -349,6 +349,22 @@ export const actionRule = dedent`
349
349
  For checkboxes, prefer I.checkOption/I.uncheckOption over I.click.
350
350
 
351
351
 
352
+ ### I.scrollTo
353
+
354
+ scrolls until the element is in view
355
+
356
+ I.scrollTo(<locator>)
357
+
358
+ Scrolls every scrollable ancestor of the target, so it reaches an element inside a container
359
+ that has its own scrollbar. I.scrollPageToBottom() moves only the page itself.
360
+
361
+ <example>
362
+ I.scrollTo('.rows > *:last-child');
363
+ I.scrollTo({ role: 'listitem', text: 'Last entry' });
364
+ I.scrollPageToBottom();
365
+ </example>
366
+
367
+
352
368
  ### I.fillField
353
369
 
354
370
  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();