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.
- package/boat/api-tester/src/ai/curler.ts +70 -66
- package/boat/api-tester/src/apibot.ts +1 -0
- package/boat/api-tester/src/cli.ts +2 -0
- package/boat/api-tester/src/config.ts +18 -1
- package/dist/boat/api-tester/src/ai/curler.js +55 -56
- package/dist/boat/api-tester/src/apibot.js +1 -0
- package/dist/boat/api-tester/src/cli.js +2 -0
- package/dist/boat/api-tester/src/config.js +3 -1
- package/dist/package.json +2 -2
- package/dist/rules/researcher/pagination.md +6 -0
- package/dist/src/action-result.d.ts +6 -0
- package/dist/src/action-result.js +12 -0
- package/dist/src/ai/planner.js +4 -0
- package/dist/src/ai/researcher/locators.js +1 -1
- package/dist/src/ai/researcher/pagination.d.ts +16 -0
- package/dist/src/ai/researcher/pagination.js +62 -0
- package/dist/src/ai/researcher/parser.d.ts +3 -0
- package/dist/src/ai/researcher/parser.js +22 -6
- package/dist/src/ai/researcher/sections.js +1 -1
- package/dist/src/ai/researcher.js +7 -2
- package/dist/src/ai/rules.js +16 -0
- package/dist/src/ai/scout.js +8 -2
- package/dist/src/ai/tools.js +10 -3
- package/dist/src/commands/options/ws-option.d.ts +7 -0
- package/dist/src/commands/options/ws-option.js +14 -0
- package/dist/src/config.d.ts +1 -0
- package/dist/src/config.js +14 -11
- package/dist/src/remote.d.ts +2 -0
- package/dist/src/remote.js +23 -16
- package/dist/src/utils/aria.d.ts +2 -0
- package/dist/src/utils/aria.js +6 -1
- package/dist/src/utils/markdown-query.d.ts +2 -0
- package/dist/src/utils/markdown-query.js +39 -0
- package/dist/src/utils/pagination.d.ts +16 -0
- package/dist/src/utils/pagination.js +20 -0
- package/docs/superpowers/plans/2026-09-10-pagination.md +1420 -0
- package/docs/superpowers/specs/2026-09-09-pagination-rule-design.md +125 -97
- package/package.json +2 -2
- package/rules/researcher/pagination.md +6 -0
- package/src/action-result.ts +16 -0
- package/src/ai/planner.ts +4 -0
- package/src/ai/researcher/locators.ts +1 -1
- package/src/ai/researcher/pagination.ts +68 -0
- package/src/ai/researcher/parser.ts +23 -5
- package/src/ai/researcher/sections.ts +1 -1
- package/src/ai/researcher.ts +9 -3
- package/src/ai/rules.ts +16 -0
- package/src/ai/scout.ts +9 -2
- package/src/ai/tools.ts +7 -3
- package/src/commands/options/ws-option.ts +14 -0
- package/src/config.ts +15 -11
- package/src/remote.ts +22 -15
- package/src/utils/aria.ts +8 -1
- package/src/utils/markdown-query.ts +39 -0
- package/src/utils/pagination.ts +36 -0
|
@@ -22,12 +22,12 @@ untouched.
|
|
|
22
22
|
|
|
23
23
|
## Approach
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
CodeceptJS and is reachable
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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.
|
|
81
|
+
**2. Markers and the scroll gate — one `page.evaluate` per container (`inspectList`)**
|
|
113
82
|
|
|
114
|
-
Only
|
|
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
|
-
- `
|
|
117
|
-
- `
|
|
118
|
-
|
|
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
|
-
|
|
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
|
|
126
|
-
|
|
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
|
|
144
|
-
|
|
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
|
|
188
|
+
### B. The UI map carries it — nothing is injected per page
|
|
177
189
|
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
203
|
+
Consequences:
|
|
191
204
|
|
|
192
|
-
`
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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: `
|
|
296
|
-
`
|
|
297
|
-
|
|
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.
|
|
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.
|
|
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>
|
package/src/action-result.ts
CHANGED
|
@@ -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]').
|
|
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
|
|
88
|
-
if (!
|
|
89
|
-
const
|
|
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
|
|
package/src/ai/researcher.ts
CHANGED
|
@@ -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();
|