explorbot 0.4.5 → 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 (85) hide show
  1. package/boat/api-tester/src/ai/curler.ts +70 -66
  2. package/boat/api-tester/src/apibot.ts +19 -2
  3. package/boat/api-tester/src/cli.ts +87 -274
  4. package/boat/api-tester/src/commands/api-command.ts +10 -0
  5. package/boat/api-tester/src/commands/explore-command.ts +52 -0
  6. package/boat/api-tester/src/commands/init-command.ts +119 -0
  7. package/boat/api-tester/src/commands/know-command.ts +44 -0
  8. package/boat/api-tester/src/commands/plan-command.ts +42 -0
  9. package/boat/api-tester/src/commands/test-command.ts +54 -0
  10. package/boat/api-tester/src/config.ts +18 -1
  11. package/dist/boat/api-tester/src/ai/curler.js +55 -56
  12. package/dist/boat/api-tester/src/apibot.js +15 -1
  13. package/dist/boat/api-tester/src/cli.js +89 -243
  14. package/dist/boat/api-tester/src/commands/api-command.js +7 -0
  15. package/dist/boat/api-tester/src/commands/explore-command.js +41 -0
  16. package/dist/boat/api-tester/src/commands/init-command.js +88 -0
  17. package/dist/boat/api-tester/src/commands/know-command.js +39 -0
  18. package/dist/boat/api-tester/src/commands/plan-command.js +37 -0
  19. package/dist/boat/api-tester/src/commands/test-command.js +45 -0
  20. package/dist/boat/api-tester/src/config.js +3 -1
  21. package/dist/package.json +4 -4
  22. package/dist/rules/researcher/pagination.md +6 -0
  23. package/dist/src/action-result.d.ts +6 -0
  24. package/dist/src/action-result.js +12 -0
  25. package/dist/src/ai/planner.js +4 -0
  26. package/dist/src/ai/researcher/deep-analysis.d.ts +1 -1
  27. package/dist/src/ai/researcher/deep-analysis.js +14 -6
  28. package/dist/src/ai/researcher/locators.js +1 -1
  29. package/dist/src/ai/researcher/pagination.d.ts +16 -0
  30. package/dist/src/ai/researcher/pagination.js +62 -0
  31. package/dist/src/ai/researcher/parser.d.ts +3 -0
  32. package/dist/src/ai/researcher/parser.js +22 -6
  33. package/dist/src/ai/researcher/sections.js +1 -1
  34. package/dist/src/ai/researcher.js +7 -2
  35. package/dist/src/ai/rules.js +16 -0
  36. package/dist/src/ai/scout.js +8 -2
  37. package/dist/src/ai/tools.d.ts +1 -1
  38. package/dist/src/ai/tools.js +25 -13
  39. package/dist/src/api/spec-reader.d.ts +1 -0
  40. package/dist/src/api/spec-reader.js +93 -1
  41. package/dist/src/commands/base-command.d.ts +3 -3
  42. package/dist/src/commands/init-command.d.ts +3 -0
  43. package/dist/src/commands/init-command.js +6 -3
  44. package/dist/src/commands/options/ws-option.d.ts +7 -0
  45. package/dist/src/commands/options/ws-option.js +14 -0
  46. package/dist/src/config.d.ts +1 -0
  47. package/dist/src/config.js +14 -11
  48. package/dist/src/explorer.d.ts +1 -1
  49. package/dist/src/explorer.js +1 -1
  50. package/dist/src/remote.d.ts +2 -0
  51. package/dist/src/remote.js +23 -16
  52. package/dist/src/utils/aria.d.ts +2 -0
  53. package/dist/src/utils/aria.js +6 -1
  54. package/dist/src/utils/html-diff.js +4 -1
  55. package/dist/src/utils/markdown-query.d.ts +2 -0
  56. package/dist/src/utils/markdown-query.js +39 -0
  57. package/dist/src/utils/pagination.d.ts +16 -0
  58. package/dist/src/utils/pagination.js +20 -0
  59. package/docs/api-testing/basics.md +26 -2
  60. package/docs/superpowers/plans/2026-09-10-pagination.md +1420 -0
  61. package/docs/superpowers/specs/2026-09-09-pagination-rule-design.md +345 -0
  62. package/package.json +4 -4
  63. package/rules/researcher/pagination.md +6 -0
  64. package/src/action-result.ts +16 -0
  65. package/src/ai/planner.ts +4 -0
  66. package/src/ai/researcher/deep-analysis.ts +13 -6
  67. package/src/ai/researcher/locators.ts +1 -1
  68. package/src/ai/researcher/pagination.ts +68 -0
  69. package/src/ai/researcher/parser.ts +23 -5
  70. package/src/ai/researcher/sections.ts +1 -1
  71. package/src/ai/researcher.ts +9 -3
  72. package/src/ai/rules.ts +16 -0
  73. package/src/ai/scout.ts +9 -2
  74. package/src/ai/tools.ts +22 -14
  75. package/src/api/spec-reader.ts +106 -1
  76. package/src/commands/base-command.ts +3 -3
  77. package/src/commands/init-command.ts +6 -3
  78. package/src/commands/options/ws-option.ts +14 -0
  79. package/src/config.ts +15 -11
  80. package/src/explorer.ts +1 -1
  81. package/src/remote.ts +22 -15
  82. package/src/utils/aria.ts +8 -1
  83. package/src/utils/html-diff.ts +3 -1
  84. package/src/utils/markdown-query.ts +39 -0
  85. package/src/utils/pagination.ts +36 -0
@@ -0,0 +1,345 @@
1
+ # Pagination rule: page numbers and infinite scroll
2
+
3
+ ## Problem
4
+
5
+ Explorbot cannot reach list content that is not already loaded. A list shows a window onto
6
+ a larger collection, and when the item under test is outside that window the tester concludes
7
+ it is absent.
8
+
9
+ Nothing in the repo scrolls anything. `<actions>` (`src/ai/rules.ts:307`) documents no scroll
10
+ command, so the model has no way to know scrolling is available. Where scrolling is mentioned
11
+ at all it is as a passing hint inside an unrelated suggestion string
12
+ (`src/ai/tools.ts:875`, `:989`, `:1297`).
13
+
14
+ Two strategies cover practically every paginated list on the web:
15
+
16
+ - **Controls that replace the window** — next, previous, page numbers, load more.
17
+ - **Appending on scroll** — new items are fetched and appended as the list is scrolled.
18
+
19
+ The second is the hard one, because the scroller is often a container with its own scrollbar
20
+ rather than the page. `I.scrollPageToBottom()` moves the window and leaves such a container
21
+ untouched.
22
+
23
+ ## Approach
24
+
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`.
29
+
30
+ Nothing is injected per page — see section B for why the first attempt at that was wrong.
31
+
32
+ ### Why `I.scrollTo` is sufficient
33
+
34
+ `Playwright.scrollTo(locator)` calls `el.scrollIntoViewIfNeeded()`
35
+ (`node_modules/codeceptjs/lib/helper/Playwright.js:1679`), which scrolls **every scrollable
36
+ ancestor** of the target. Pointing it at the last item currently in a list therefore scrolls
37
+ that list's own scroller.
38
+
39
+ Verified against Chromium on a page with both a scrollable `div` and a scrollable window:
40
+
41
+ ```
42
+ before {"box":0, "win":0}
43
+ scrollIntoView {"box":2200, "win":1821}
44
+ after cjs tail {"box":2200, "win":1821}
45
+ ```
46
+
47
+ Both scrollers moved. The `window.scrollBy` call CodeceptJS runs afterwards is a no-op: it
48
+ passes one object to a two-positional-parameter function, so both deltas coerce to `NaN` and
49
+ normalize to zero. Nothing needs to be worked around.
50
+
51
+ The gesture is a single line beginning with `I.`, so it passes the `form` tool's line check
52
+ (`src/ai/tools.ts:384`) and needs no new tool.
53
+
54
+ ### Signals already on the wire
55
+
56
+ - **`pageDiff.ariaChanges` / `ariaChangeCount`** — `diffAriaSnapshots` (`src/utils/aria.ts:503`)
57
+ counts node summaries, so appended rows surface as counted additions.
58
+ - **`pageDiff.requests`** — `Action.recordNetworkCall` (`src/action.ts:314`) captures same-origin
59
+ xhr/fetch as `{method, path, status}`, deduped. It stores `url.pathname` only, so the query
60
+ string is dropped: presence of a call, never a page number.
61
+
62
+ ## Design
63
+
64
+ ### A. Researcher determines each list's pagination strategy
65
+
66
+ Four steps, cheapest first, stopping as soon as one answers. This is the escalation ladder from
67
+ CLAUDE.md end to end: a table lookup, then AI judgment, then a probe whose result converts
68
+ judgment back into a recorded fact.
69
+
70
+ **1. Are there pagination controls? (AI, free)**
71
+
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
74
+ the section, so it costs nothing extra:
75
+ a new `rules/researcher/pagination.md`, loaded alongside the existing three at
76
+ `src/ai/researcher/sections.ts:81`, asks it to note when a section contains controls that move
77
+ between pages of the same collection.
78
+
79
+ If found, the section records `> Pagination: controls` and the remaining steps are skipped.
80
+
81
+ **2. Markers and the scroll gate — one `page.evaluate` per container (`inspectList`)**
82
+
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:
85
+
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.
91
+
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.
97
+
98
+ **3. Probe: does scrolling load more? (deterministic measurement)**
99
+
100
+ Scroll the container to its end, wait for readiness (`waitForPageReadiness`,
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.
114
+
115
+ Then restore `scrollTop` to what it was, so screenshots, coordinates and later research see the
116
+ page as they found it. Scroll position is not app state, so this needs none of the modal
117
+ cleanup `_restorePageState` does in `deep-analysis.ts:453` — there is nothing to reuse there.
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
+
144
+ **Recorded vocabulary:** `controls` or `infinite`, as a line in the section's container
145
+ blockquote. Nothing is written when a list neither paginates nor grows, which is the common
146
+ case and should stay silent.
147
+
148
+ ```
149
+ > Container: '.semantic-container'
150
+ > Pagination: infinite
151
+ ```
152
+
153
+ **This line has a reader**, because section B injects the rule only when pagination was
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.
157
+
158
+ That makes `Pagination:` a closed vocabulary read deterministically by code, so the envelope
159
+ checklist from CLAUDE.md applies and holds: read by code, scoped to a section of a state,
160
+ optional with "absent" as the default, and written by one module.
161
+
162
+ ### A2. Where the code goes
163
+
164
+ New `src/ai/researcher/pagination.ts` mixin, composed into `ResearcherBase`
165
+ (`src/ai/researcher.ts:47`), owning steps 2 and 3. Step 1 is prompt text in
166
+ `rules/researcher/pagination.md` and needs no code.
167
+
168
+ It runs after `validateContainers` (`src/ai/researcher/locators.ts:268`), on containers that
169
+ survived validation, so a probe never targets a selector already known to be broken.
170
+
171
+ Not `deep-analysis.ts`: that mixin owns the same interact-measure-restore shape, but it is
172
+ gated behind `deep` (`src/ai/researcher.ts:287`), and infinite scroll has to be detected on
173
+ ordinary research runs too. It is also already 26k.
174
+
175
+ Not `locators.ts`: that mixin owns locator validity, not list behaviour.
176
+
177
+ **One writer for the blockquote.** `updateSectionContainer`
178
+ (`src/ai/researcher/locators.ts:300`) currently owns that `blockquote[0]` replace. The
179
+ pagination mixin must not write it independently. Extract the blockquote composition into one
180
+ helper both call, so `Container:` and `Pagination:` are always emitted by the same code.
181
+
182
+ A new `src/utils/pagination.ts` owns the two deterministic halves — the step 0 marker scan over
183
+ HTML, and the in-page evaluate functions for steps 2 and 3, self-contained with no outer-scope
184
+ references. One concern: how a list continues. `measureLayout` in `overlay.ts` is not reused:
185
+ it is xpath-based and returns a modal-scoring `RegionLayout`, while sections carry CSS
186
+ selectors and need neither.
187
+
188
+ ### B. The UI map carries it — nothing is injected per page
189
+
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.
192
+
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.
197
+
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.
202
+
203
+ Consequences:
204
+
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.
212
+
213
+ ### C. `actionRule` documents the gesture
214
+
215
+ `<actions>` (`src/ai/rules.ts:307`) gains a scroll entry covering `I.scrollTo(<locator>)` —
216
+ stating that it scrolls every scrollable ancestor of the target — and
217
+ `I.scrollPageToBottom()` for the window. The `form` tool description
218
+ (`src/ai/tools.ts:354`) gains "reach items further down a list" as a use case, since it
219
+ currently reads as a typing tool.
220
+
221
+ ### D. Tool output that contradicts the rule
222
+
223
+ Both are wrong reporting on existing tools, not new behaviour.
224
+
225
+ **`src/ai/tools.ts:415`** — `hasObservablePageChange` (`src/ai/tools.ts:1236`) ignores
226
+ `pageDiff.requests`. A scroll that fires a fetch which has not yet rendered rows, and a scroll
227
+ at the true end of a list, both return `failedToolResult` carrying the suggestion "Treat the
228
+ field/form action as not completed. Re-locate the editable control…" and commit
229
+ `TestResult.FAILED` into the note. That note is read by final review and by Historian, so a
230
+ correct end-of-list check is recorded as a failed test step.
231
+
232
+ Fix: one line in `hasObservablePageChange` — `if (data.pageDiff.requests?.length) return true;`
233
+ — and a no-change message that states what was observed rather than prescribing a
234
+ form-specific recovery.
235
+
236
+ **`src/ai/tools.ts:1208`** — `isMajorPageChange` (`src/ai/tools.ts:1220`) fires at
237
+ `ariaChangeCount >= 50` with no URL change (`LARGE_ARIA_CHANGE_THRESHOLD`,
238
+ `src/utils/aria.ts:581`), producing "MAJOR PAGE CHANGE. Page entered a different mode."
239
+ `diffByCount` (`src/utils/aria.ts:309`) pushes one entry per surplus occurrence, so a batch of
240
+ appended rows clears 50 easily. The rule says growth is the same state; the tool says the mode
241
+ changed.
242
+
243
+ Fix: a diff consisting of additions with no corresponding removals is growth, not a mode
244
+ change. Mode changes churn — they remove as well as add.
245
+
246
+ The added/removed split exists inside `diffAriaSnapshots` but is flattened into a single
247
+ `count` before it reaches the check, so it has to be threaded through:
248
+
249
+ 1. `AriaDiff` (`src/utils/aria.ts:587`) gains `added: number; removed: number` — both arrays
250
+ are already computed at `src/utils/aria.ts:522`.
251
+ 2. `Diff` (`src/action-result.ts:650`) stores them alongside `_ariaChangeCount`, set where
252
+ `diffAriaSnapshots` is called (`src/action-result.ts:740`), and exposes them.
253
+ 3. `PageDiff` (`src/action-result.ts:46`) gains `ariaAdded` / `ariaRemoved`, populated next to
254
+ `ariaChanges` / `ariaChangeCount` (`src/action-result.ts:552`).
255
+ 4. `isMajorPageChange` then reads the split instead of the total.
256
+
257
+ ## Risks
258
+
259
+ - **The probe costs a scroll per candidate section.** Steps 1 and 2 narrow it to sections that
260
+ have no pagination controls and can actually scroll, which on most pages is zero or one. If
261
+ it still proves too slow, the gate to tighten is step 2, not the probe itself.
262
+ - **Region misclassification.** `OverlayPage.detectRegion` (`src/utils/overlay.ts:41`) accepts
263
+ in-flow added content that is `sizable` (≥5,000 chars, `src/utils/region.ts:99`), dominant
264
+ (≥70% of added raw size) and carries a name or root. A large appended batch in one list
265
+ container fits all three, which would fork the state hash with `region_<name>` and record a
266
+ transition. The probe can trigger this during research as well as the tester during a run.
267
+ Watch for it on the first real run; not changed here.
268
+ - **Virtualized lists.** Recycled nodes keep counts flat, so the probe sees no growth and the
269
+ aria diff shows renames (`src/utils/aria.ts:325`) rather than additions. Such a list records
270
+ nothing and the rule will not know to scroll it. Out of scope.
271
+ - **The stop condition lives only in the prompt.** No tool enforces it, so a model that keeps
272
+ scrolling past an attempt which added no rows will keep scrolling. Accepted: the tester's own
273
+ iteration cap is the only backstop.
274
+ - **A REST call alone no longer stops the loop, by design.** After the D fix an empty-payload
275
+ 200 counts as an observable change, so the tool reports success. The rule makes rows, not
276
+ calls, the evidence that more arrived — otherwise a list that answers every scroll with an
277
+ empty page would loop.
278
+
279
+ ## Testing
280
+
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.
287
+ - `tests/integration/researcher-sections.test.ts` — `> Pagination: controls` appears for a
288
+ section whose UI map holds next/prev controls, and step 1 is not asked when step 0 already
289
+ answered.
290
+ - A browser test for steps 2 and 3, following `tests/integration/overlay-modal-browser.test.ts`:
291
+ a container with its own scroller that appends on scroll (`infinite`), one that does not
292
+ (silent), a list with pagination controls (`controls`, no probe runs), and confirmation that
293
+ `scrollTop` is restored afterwards.
294
+ - An aimock prompt-inspection test that the matching fragment reaches the tester on a paginated
295
+ state, the other fragment does not, and neither appears on a state with no list, per
296
+ `docs/contributing/ai-integration-tests.md`.
297
+ - Unit coverage for the two `tools.ts` corrections: requests-only diff counts as observable;
298
+ an additions-only diff over the threshold is not a major page change.
299
+
300
+ ## Out of scope
301
+
302
+ - Any new tool. The gesture is reachable through `form` today.
303
+ - Persisting the strategy across runs. Research recomputes it, and the research cache is
304
+ session-scoped.
305
+ - Virtualized list support.
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.5",
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.0.0-rc.16",
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",
@@ -123,8 +123,8 @@
123
123
  "ora-classic": "^5.4.2",
124
124
  "parse5": "^8.0.0",
125
125
  "pixelmatch": "^7.2.0",
126
- "playwright": "^1.62",
127
- "playwright-core": "^1.62",
126
+ "playwright": "^1.63",
127
+ "playwright-core": "^1.63",
128
128
  "pngjs": "^7.0.0",
129
129
  "react": "^19.1.1",
130
130
  "sambanova-ai-provider": "^1.2.2",
@@ -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
 
@@ -116,7 +116,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
116
116
  );
117
117
 
118
118
  tag('substep').log(`Researching overlay: ${region.name}`);
119
- const sectionMarkdown = await this._analyzeExpandedAction('', region.name, diff, alreadyExpanded);
119
+ const sectionMarkdown = await this._analyzeExpandedAction('', region.name, diff, alreadyExpanded, region.root);
120
120
  if (!sectionMarkdown) {
121
121
  debugLog(`Overlay "${region.name}" produced no meaningful expansion`);
122
122
  return null;
@@ -421,9 +421,9 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
421
421
  await new Promise((r) => setTimeout(r, 500));
422
422
 
423
423
  let diff: Diff;
424
+ let currAR: ActionResult;
424
425
  try {
425
- await this.explorer.capture();
426
- const currAR = ActionResult.fromState(this.stateManager.getCurrentState()!);
426
+ currAR = await this.explorer.capture();
427
427
  diff = await currAR.diff(previousState);
428
428
  } catch (err) {
429
429
  tag('warning').log(`State capture failed after click: ${err instanceof Error ? err.message : err}`);
@@ -444,7 +444,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
444
444
  return { status: 'none', code: clickCode };
445
445
  }
446
446
 
447
- const sectionMarkdown = await this._analyzeExpandedAction(clickCode, description, diff, alreadyExpanded);
447
+ const sectionMarkdown = await this._analyzeExpandedAction(clickCode, description, diff, alreadyExpanded, currAR.overlay.root);
448
448
  await this._restorePageState(state.url, originalAria);
449
449
  if (!sectionMarkdown) return { status: 'none', code: clickCode };
450
450
  return { status: 'revealed', code: clickCode, sectionMarkdown };
@@ -467,7 +467,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
467
467
  }
468
468
  }
469
469
 
470
- private async _analyzeExpandedAction(code: string, description: string, diff: Diff, alreadyExpanded: string[]): Promise<string | null> {
470
+ private async _analyzeExpandedAction(code: string, description: string, diff: Diff, alreadyExpanded: string[], containerCss: string | null = null): Promise<string | null> {
471
471
  const alreadyHint = alreadyExpanded.length > 0 ? `\nAlready expanded sections:\n${alreadyExpanded.join('\n')}` : '';
472
472
 
473
473
  let intro: string;
@@ -532,7 +532,14 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
532
532
  const sections = parseResearchSections(text);
533
533
  if (sections.length === 0) return null;
534
534
 
535
- return sections[0].rawMarkdown;
535
+ const sectionMarkdown = sections[0].rawMarkdown;
536
+ if (!containerCss) return sectionMarkdown;
537
+
538
+ let heading = mdq(sectionMarkdown).query('h3[0]');
539
+ if (heading.count() === 0) heading = mdq(sectionMarkdown).query('h2[0]');
540
+ if (heading.count() === 0) return sectionMarkdown;
541
+
542
+ return heading.replace(`${heading.text().trimEnd()}\n\n> Container: '${containerCss}'\n\n`);
536
543
  }
537
544
 
538
545
  private _deduplicateExpandedSections(sections: string[]): string[] {
@@ -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