explorbot 0.4.8 → 0.4.10

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/cli.ts +1 -1
  2. package/boat/api-tester/src/config.ts +34 -26
  3. package/dist/boat/api-tester/src/cli.js +1 -1
  4. package/dist/boat/api-tester/src/config.js +32 -26
  5. package/dist/package.json +1 -1
  6. package/dist/src/action.js +4 -2
  7. package/dist/src/ai/fisherman/tools.js +10 -4
  8. package/dist/src/ai/navigator.d.ts +0 -1
  9. package/dist/src/ai/navigator.js +11 -24
  10. package/dist/src/ai/pilot.js +21 -10
  11. package/dist/src/ai/rerunner.js +7 -0
  12. package/dist/src/ai/tools.js +8 -1
  13. package/dist/src/api/xhr-capture.js +2 -1
  14. package/dist/src/commands/config-command.d.ts +2 -0
  15. package/dist/src/commands/config-command.js +8 -2
  16. package/dist/src/commands/init-command.js +6 -26
  17. package/dist/src/commands/sites-command.js +6 -1
  18. package/dist/src/config.d.ts +5 -4
  19. package/dist/src/config.js +25 -23
  20. package/dist/src/explorer.js +2 -3
  21. package/dist/src/global-config.d.ts +6 -0
  22. package/dist/src/global-config.js +82 -7
  23. package/dist/src/reporter.js +8 -4
  24. package/dist/src/utils/html.js +6 -0
  25. package/dist/src/utils/logger.js +1 -1
  26. package/dist/src/utils/merge.d.ts +1 -0
  27. package/dist/src/utils/merge.js +11 -0
  28. package/dist/src/utils/step-analyzer.d.ts +3 -0
  29. package/dist/src/utils/step-analyzer.js +7 -0
  30. package/dist/src/utils/url-matcher.d.ts +1 -0
  31. package/dist/src/utils/url-matcher.js +7 -0
  32. package/docs/reference/commands.md +10 -2
  33. package/docs/reference/configuration.md +37 -4
  34. package/docs/superpowers/plans/2026-09-15-mdq-package.md +2029 -0
  35. package/docs/superpowers/specs/2026-09-14-mdq-package-design.md +397 -0
  36. package/package.json +1 -1
  37. package/src/action.ts +4 -2
  38. package/src/ai/fisherman/tools.ts +10 -4
  39. package/src/ai/navigator.ts +9 -23
  40. package/src/ai/pilot.ts +21 -10
  41. package/src/ai/rerunner.ts +4 -0
  42. package/src/ai/tools.ts +7 -1
  43. package/src/api/xhr-capture.ts +2 -1
  44. package/src/commands/config-command.ts +9 -2
  45. package/src/commands/init-command.ts +6 -27
  46. package/src/commands/sites-command.ts +6 -1
  47. package/src/config.ts +28 -25
  48. package/src/explorer.ts +2 -2
  49. package/src/global-config.ts +81 -6
  50. package/src/reporter.ts +8 -4
  51. package/src/utils/html.ts +6 -0
  52. package/src/utils/logger.ts +1 -1
  53. package/src/utils/merge.ts +13 -0
  54. package/src/utils/step-analyzer.ts +8 -0
  55. package/src/utils/url-matcher.ts +7 -0
@@ -0,0 +1,397 @@
1
+ # mdq — Markdown Query & Edit Package
2
+
3
+ Date: 2026-09-14
4
+ Status: Approved design, pending implementation
5
+
6
+ ## Goal
7
+
8
+ Extract `src/utils/markdown-query.ts` into `src/utils/mdq/`, designed as a publishable
9
+ standalone package: query markdown *and* update it, with a jq-like CLI planned as a
10
+ second phase.
11
+
12
+ The name `mdq` is unclaimed on npm (verified 404). Publishing is deferred; this change
13
+ makes the package publish-ready but adds no `package.json` or build script.
14
+
15
+ ## Constraints
16
+
17
+ - **Zero explorbot imports.** Two dependencies only: `marked` for markdown, `yaml` for
18
+ frontmatter. Both are already repo deps (`marked` ^16.2.0, `yaml` ^2.8.3).
19
+ - **Two files**, per the module split below.
20
+ - 54 in-repo call sites must keep working; a re-export shim carries them.
21
+
22
+ ## Architecture
23
+
24
+ ```
25
+ src/utils/mdq/
26
+ query.ts selector grammar - token index - MarkdownDoc - Selection (reads)
27
+ edit.ts pure edits over (source, ranges): splicing - whitespace - renderers
28
+ README.md public documentation
29
+ src/utils/markdown-query.ts re-export shim
30
+ tests/unit/mdq/*.test.ts
31
+ ```
32
+
33
+ `edit.ts` exports pure functions taking source text plus ranges or tokens, and returning
34
+ new source text. It imports types from `query.ts` type-only and never references a class
35
+ value. `query.ts` owns both classes and delegates each write verb to exactly one `edit.ts`
36
+ call. This keeps the split acyclic by construction.
37
+
38
+ Two types:
39
+
40
+ - **`MarkdownDoc`** — a whole document. Returned by `mdq()` and by every write.
41
+ - **`Selection`** — a set of matched ranges. Returned by `query()` and the sugar methods.
42
+
43
+ One rule, stated in the README: **reads narrow, writes return the document.**
44
+
45
+ ## API
46
+
47
+ ### `mdq(source)`
48
+
49
+ `mdq(source: string | MarkdownDoc): MarkdownDoc`
50
+
51
+ Accepting a `MarkdownDoc` makes re-wrapping free.
52
+
53
+ ### `MarkdownDoc`
54
+
55
+ | Method | Returns | Notes |
56
+ |---|---|---|
57
+ | `query(selector, matcher?)` | `Selection` | |
58
+ | `frontmatter()` | `Record<string, unknown>` | `{}` when absent |
59
+ | `setFrontmatter(key, value)` | `MarkdownDoc` | `null` value deletes the key |
60
+ | `append(md)` | `MarkdownDoc` | add a block at end of document |
61
+ | `prepend(md)` | `MarkdownDoc` | add a block at start of body, after frontmatter |
62
+ | `toString()` / `valueOf()` | `string` | full document, frontmatter included |
63
+
64
+ Plus the shared sugar layer.
65
+
66
+ `append`/`prepend` exist because "add a section to the end of the document" otherwise has
67
+ no clean path — only the `section().last().insertAfter(...)` workaround. There is a real
68
+ call site: `deep-analysis.ts:131` builds it by hand today as
69
+ `` `${cached.trimEnd()}\n\n# Extended Research\n\n...` ``.
70
+
71
+ ### `Selection` — reads
72
+
73
+ Narrow or extract; never mutate.
74
+
75
+ | Method | Returns | Replaces |
76
+ |---|---|---|
77
+ | `query(selector, matcher?)` | `Selection` | sub-query, unchanged |
78
+ | `text()` | `string` | — (`get()` deprecated) |
79
+ | `count()` | `number` | |
80
+ | `exists()` | `boolean` | the `.count() > 0` idiom, 3 in-repo uses |
81
+ | `first()` / `last()` | `Selection` | |
82
+ | `at(n)` | `Selection` | new; sugar-path equivalent of DSL `[n]` |
83
+ | `slice(from?, to?)` | `Selection` | new; sugar-path equivalent of DSL `[a:b]` |
84
+ | `each()` | `Selection[]` | |
85
+ | `nodes()` | `NodeInfo[]` | `meta()` |
86
+ | `rows()` | `Record<string,string>[]` | `toJson()` — it only ever handled tables |
87
+ | `entries()` | `Record<string,string>` | `keyValue()` |
88
+ | `preceding()` / `following()` | `Selection` | `before()` / `after()` |
89
+
90
+ `before`/`after` are renamed specifically to free those names from colliding with
91
+ `insertBefore`/`insertAfter`.
92
+
93
+ ### `Selection` — writes
94
+
95
+ Every write returns `MarkdownDoc`, so edits chain in one expression.
96
+
97
+ | Method | Signature | Notes |
98
+ |---|---|---|
99
+ | `replace(md)` | `(Markdown) => MarkdownDoc` | |
100
+ | `replaceEach(fn)` | `((Selection, number) => Markdown) => MarkdownDoc` | |
101
+ | `remove()` | `() => MarkdownDoc` | node **plus its adjacent `space` token** |
102
+ | `insertBefore(md)` | `(Markdown) => MarkdownDoc` | sibling |
103
+ | `insertAfter(md)` | `(Markdown) => MarkdownDoc` | sibling |
104
+ | `prepend(md)` | `(Markdown) => MarkdownDoc` | inside a section or list; `MdqOperationError` on a leaf node |
105
+ | `append(md)` | `(Markdown) => MarkdownDoc` | inside a section or list; `MdqOperationError` on a leaf node |
106
+ | `addRow(row)` | `(Record<string,string>) => MarkdownDoc` | table only; re-aligns columns |
107
+ | `addItem(text)` | `(string) => MarkdownDoc` | list only; matches marker + indent |
108
+ | `setEntry(key, value)` | `(string, string \| null) => MarkdownDoc` | `null` deletes |
109
+
110
+ Naming now pairs: `rows()`/`addRow()`, `entries()`/`setEntry()`, `nodes()`.
111
+
112
+ Every verb that *takes* markdown accepts `Markdown = string | MarkdownDoc`, mirroring
113
+ `mdq()` itself. A `replaceEach` callback may therefore return a `MarkdownDoc` built by a
114
+ nested edit, without a `.toString()` hop.
115
+
116
+ ### Sugar layer
117
+
118
+ Eleven methods on both classes: `section` `heading` `paragraph` `table` `list` `item`
119
+ `code` `blockquote` `comment` `html` `hr`.
120
+
121
+ Each is `(matcher?, opts?) => Selection` and is *defined as* `query(sel, matcher)` —
122
+ documented as sugar, not a parallel implementation. Defined once on a shared base that
123
+ implements them in terms of an abstract `query()`, so the two classes do not duplicate it.
124
+
125
+ Depth is an option rather than 12 near-duplicate methods:
126
+
127
+ ```js
128
+ mdq(src).section('API', { depth: 2 }) // DSL: query('section2("API")')
129
+ mdq(src).heading(/^f/i).at(0) // DSL: query('heading(/^f/i)[0]')
130
+ mdq(src).comment(/^test/) // DSL: query('comment(/^test/)')
131
+ ```
132
+
133
+ ### Exported types
134
+
135
+ Declared at the end of their file, per repo convention.
136
+
137
+ ```ts
138
+ type Markdown = string | MarkdownDoc;
139
+ type Matcher = string | RegExp | ((text: string) => boolean);
140
+
141
+ interface NodeInfo {
142
+ type: string; // 'heading' | 'paragraph' | 'table' | 'comment' | ...
143
+ depth: number | null; // heading level, else null
144
+ text: string; // unwrapped text; comment bodies without <!-- -->
145
+ }
146
+
147
+ interface SelectorOptions {
148
+ depth?: 1 | 2 | 3 | 4 | 5 | 6;
149
+ }
150
+ ```
151
+
152
+ `Matcher` semantics:
153
+
154
+ - `string` — exact match (mirrors DSL `"x"`)
155
+ - `RegExp` — pattern, honoring its own flags
156
+ - function — predicate; needs no escaping at all
157
+
158
+ Note the consequence for `comment`: a `string` matcher is **exact**, and this repo's own
159
+ test-plan comments are multi-line (`<!-- test\n priority=critical\n-->`). So
160
+ `comment('test')` matches only a bare `<!-- test -->`; reaching the multi-line ones needs
161
+ `comment(/^test/)` or a predicate. Exactness is the consistent rule and is kept, but it is
162
+ the one place the sugar is likely to surprise.
163
+
164
+ This removes an existing wart. Today the repo hand-escapes to build selector strings:
165
+
166
+ ```js
167
+ const escaped = section.name.replace(/"/g, '\\"'); // researcher/focus.ts:77
168
+ mdq(result.text).query(`section2(~"${escaped}")`);
169
+ ```
170
+
171
+ ### Deprecated aliases
172
+
173
+ `get` `toJson` `keyValue` `setKeyValue` `meta` `before` `after` are kept, marked
174
+ `@deprecated`, and omitted from the README so the published surface reads clean.
175
+
176
+ Aliases cover the read renames completely. They cannot shield the write return-type
177
+ change — see Migration.
178
+
179
+ ## Selector grammar
180
+
181
+ Unchanged, plus one addition and three fixes.
182
+
183
+ ### `comment` (new)
184
+
185
+ `html` tokens filtered to those that are comments. Not an alias for `html`: `<div>x</div>`
186
+ lexes as `html` too.
187
+
188
+ - `comment` matches on the **inner** body, trimmed. `html` matches on raw.
189
+ This is required for anchored patterns — `/^test/` against `<!-- test id=1 -->` only
190
+ works if the text is `test id=1`.
191
+ - Multi-line comments are a single token and keep their newlines in the matched text.
192
+ - **Inline comments are out of scope for 1.0.** `para with <!-- x --> comment` lexes the
193
+ comment inside the paragraph token; it is not reachable as a block. Documented, not faked.
194
+
195
+ `comment` and `html` together finish the `test-plan-markdown.ts` story: its hand-rolled
196
+ line parser (`src/utils/test-plan-markdown.ts:122+`) exists only because mdq could not
197
+ see `<!-- suite -->` and `<!-- test ... -->`.
198
+
199
+ ### Fixes
200
+
201
+ 1. **Regex flags are honored.** Today flags are parsed then discarded
202
+ (`markdown-query.ts:90`) and `'i'` is hardcoded (`markdown-query.ts:156`), so `/x/` is
203
+ case-insensitive while `"x"` and `~"x"` are case-sensitive. After the fix `/x/i` is
204
+ insensitive and `/x/` is not. One production call site relies on the old behavior:
205
+ `researcher.ts:316` `section2(/^summary/)` becomes `/^summary/i`. Tests already write
206
+ flags explicitly.
207
+ 2. **Unknown selectors throw.** Today `query('secton("A")')` silently matches nothing
208
+ (`markdown-query.ts:103-106`, `:356`). Unacceptable for a CLI.
209
+ 3. **Table text-match widens to headers plus cells.** Today `getTokenText` returns headers
210
+ only (`markdown-query.ts:181`), so `table(~"GET")` can never match a cell. No call site
211
+ uses table text-matching, so this is safe.
212
+
213
+ ## Update semantics
214
+
215
+ `marked` separators are uneven, and every write rule follows from this:
216
+
217
+ | Token | `raw` |
218
+ |---|---|
219
+ | `heading` | `"# A\n\n"` — separators baked in |
220
+ | `paragraph` | `"para"` — no trailing newline |
221
+ | `space` | `"\n\n"` — a separate token |
222
+
223
+ The token index therefore records each node's range **and its adjacent `space` range**.
224
+
225
+ > **Invariant: mdq never leaves zero blank lines between blocks, and never more than one.**
226
+
227
+ - `remove()` takes the node plus its trailing `space` — or its leading `space` when it is
228
+ the last block. Without this, removing a paragraph leaves a four-newline crater. This is
229
+ the most likely bug in the feature and gets dedicated tests.
230
+ - `insertAfter` / `append` normalize inserted markdown to one trailing `\n` and splice at
231
+ the boundary, never inside a space token.
232
+ - `append` on a section inserts before the next same-or-shallower heading, reusing the
233
+ existing `computeSections` end boundary.
234
+ - `addRow` re-renders the whole table so column pipes stay aligned.
235
+ - `addItem` copies the list's existing marker (`-`, `*`, `1.`) and indent.
236
+
237
+ ## Frontmatter
238
+
239
+ Every `knowledge/` and `experience/` file opens with `---\nurl: /login\n---`, which
240
+ `marked` lexes as a setext h2 titled `url: /login`.
241
+
242
+ mdq detects leading frontmatter, excludes it from the token index with offsets preserved
243
+ so edits splice correctly, and exposes it as data.
244
+
245
+ Reading and writing both go through `yaml`'s **Document API** (`YAML.parseDocument`), not
246
+ `parse`/`stringify`. That buys two things a hand-rolled parser cannot: correctness on
247
+ nested maps, lists and block scalars — the Jekyll/Astro/Obsidian files that justify the
248
+ feature — and **comment preservation through a write**, verified:
249
+
250
+ ```yaml
251
+ # a leading comment <- survives setFrontmatter('wait', 2000)
252
+ url: /login
253
+ wait: 2000
254
+ tags:
255
+ - auth
256
+ - smoke
257
+ nested:
258
+ key: value # trailing note <- also survives
259
+ ```
260
+
261
+ `gray-matter` is deliberately not used: `knowledge-tracker.ts` keeps it for its own
262
+ purposes, but a published package should not carry it to do what `yaml` already does.
263
+
264
+ ```js
265
+ const doc = mdq(knowledgeFile);
266
+ doc.frontmatter(); // { url: '/login', wait: 1000, tags: ['auth'] }
267
+ doc.query('h2').count(); // 0 — the --- block is not a heading
268
+ doc.setFrontmatter('wait', 2000).toString();
269
+ ```
270
+
271
+ ## Errors
272
+
273
+ `MdqError` base, with:
274
+
275
+ - `MdqSelectorError` — malformed or unknown selector, carrying the offending index.
276
+ - `MdqOperationError` — a verb applied to the wrong node type, e.g. `addRow` on a paragraph.
277
+
278
+ An **empty selection is a safe no-op**: reads return `''` / `[]`, writes return the
279
+ document unchanged. This preserves the existing "returns source unchanged when no matches"
280
+ test.
281
+
282
+ ## CLI (phase 2)
283
+
284
+ The selector is the program, the file or stdin is the input, markdown is the default output.
285
+
286
+ A leading `.` is accepted and ignored, so muscle memory from jq (`mdq '.h2'`) works. It is
287
+ sugar in the grammar, not a separate syntax — without it the new "unknown selectors throw"
288
+ rule would reject the most natural thing a jq user types first.
289
+
290
+ ```bash
291
+ mdq 'h2' README.md # raw markdown of matches
292
+ cat plan.md | mdq 'section("API") table' -j # rows() as JSON
293
+ mdq 'comment(~"test")' plan.md --count
294
+ mdq 'section("FAQ")' doc.md --remove -i # edit in place
295
+ mdq 'table[0]' api.md --add-row '{"Method":"GET","Path":"/users"}' -i
296
+ ```
297
+
298
+ Flags mirror library verbs exactly: `--remove` `--replace` `--insert-before`
299
+ `--insert-after` `--prepend` `--append` `--add-row` `--add-item` `--set k=v`, plus
300
+ `-i/--in-place`, `-j/--json`, `-c/--count`, `-t/--text`, `--frontmatter`.
301
+
302
+ Built with Commander, per repo convention. Exit codes compose like grep: **0** match,
303
+ **1** no match, **2** usage or selector error.
304
+
305
+ 1.0 reads one file or stdin. Multi-file input is out of scope.
306
+
307
+ ## Testing
308
+
309
+ Port the existing 801-line suite first — it is the regression net for all 54 call sites.
310
+ Then add coverage for what is new or newly specified:
311
+
312
+ - whitespace craters on `remove` (the invariant above)
313
+ - chained multi-edits through `MarkdownDoc`
314
+ - `addRow` column alignment; `addItem` marker and indent matching
315
+ - frontmatter round-trip, including a file whose body has its own `---`
316
+ - `comment` inner-text matching, multi-line comments, and `html` versus `comment`
317
+ - `MdqSelectorError` on unknown selectors and malformed input
318
+ - sugar equivalence: every sugar call equals its `query()` form
319
+
320
+ ## Migration
321
+
322
+ Two steps. Only the second carries risk.
323
+
324
+ 1. `src/utils/markdown-query.ts` becomes a re-export shim. **All 54 call sites keep
325
+ working untouched.**
326
+ 2. A sweep updates imports, then fixes the call sites the return-type change breaks.
327
+
328
+ Deprecated read aliases mean **no read call site changes**. Writes are not shielded: a
329
+ verb that returned `string` now returns `MarkdownDoc`. That breaks four classes of site,
330
+ at least eleven in total.
331
+
332
+ **(a) Assignment into a `string`-typed target** — 7 sites, each needs `.toString()`:
333
+
334
+ | Site | Target |
335
+ |---|---|
336
+ | `experience-tracker.ts:265` | `content` (inferred `string`) |
337
+ | `experience-tracker.ts:289` | `combined` (inferred `string`) |
338
+ | `researcher/deep-analysis.ts:129` | `let updated: string` |
339
+ | `researcher/locators.ts:307` | `result.text` |
340
+ | `researcher/locators.ts:309` | `result.text` |
341
+ | `researcher/pagination.ts:61` | `result.text` |
342
+ | `researcher/research-result.ts:57` | `section.rawMarkdown` |
343
+ | `researcher/research-result.ts:58` | `this.text` |
344
+
345
+ **(b) A string method called on the result** — 2 sites:
346
+
347
+ - `planner.ts:304` — `.replace('').trim()`
348
+ - `planner.ts:322` — `const kept = ...replace('')`, then `kept.trimEnd()` on line 324
349
+
350
+ **(c) Returned from a `replaceEach` callback** — `deep-analysis.ts:542`. **Resolved by
351
+ design**, not by migration: callbacks accept `Markdown`, so returning a `MarkdownDoc` is
352
+ valid. No edit needed.
353
+
354
+ **(d) Compared against a string — the dangerous one.** `research-result.ts:56`:
355
+
356
+ ```js
357
+ const updated = sectionQuery.query('table').replace(`${newTable.trimEnd()}\n`);
358
+ if (updated === this.text) return; // MarkdownDoc === string is always false
359
+ ```
360
+
361
+ This does not crash. The guard silently stops firing and the method starts doing work it
362
+ used to skip. `tsc` does flag it — comparing types with no overlap is an error — which is
363
+ precisely why the manual type-check below is not optional. Every `replace`/`setEntry`
364
+ result used in an equality or truthiness test must be audited, not just the ones that fail
365
+ to compile.
366
+
367
+ Sites that flow the result straight back into `mdq()` — `planner.ts:303`, `planner.ts:405`
368
+ — keep working unchanged, because `mdq()` accepts a `MarkdownDoc`.
369
+
370
+ `tsc` is the complete detector for these breaks: every one surfaces as a type error. The migration step is therefore *run `tsc` over the changed files and fix
371
+ what it reports*, with the table above as the expected result rather than the whole story.
372
+
373
+ Also in the sweep: `researcher.ts:316` gains its `i` flag (`section2(/^summary/i)`).
374
+
375
+ ### Hazards
376
+
377
+ - **`.claude/worktrees/**` holds four stale copies** of `markdown-query.ts` and its
378
+ consumers. Every grep or sed sweep must exclude that path.
379
+ - **`tsc` runs with `--noCheck` in CI.** The nine breaks above are *exactly* the errors a
380
+ type-check would raise, and CI raises none of them — a fully green build proves nothing
381
+ here. Run `tsc` manually over the changed files before considering the sweep done.
382
+
383
+ ## Style compliance
384
+
385
+ The current file violates several repo rules that the rewrite fixes: types belong at the
386
+ end of the file, ternaries are banned (`markdown-query.ts:30`, `:34`, `:129-130`, `:295`),
387
+ and the `switch` in `matchText` should be early returns.
388
+
389
+ ## Deliberately out of scope
390
+
391
+ - Publishing: no `package.json`, no build script, no npm release in this change.
392
+ - Frontmatter formats other than YAML (TOML `+++`, JSON) — detected and skipped from the
393
+ token index, but not parsed.
394
+ - Row-level and item-level *selectors* (`addRow` has no `removeRow` partner). A future
395
+ `row(...)` selector is the right shape for that; guessing at it now is premature.
396
+ - Inline HTML comments.
397
+ - Multi-file CLI input.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.4.8",
3
+ "version": "0.4.10",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
package/src/action.ts CHANGED
@@ -16,7 +16,9 @@ import { createDebug, setStepSpanParent, tag } from './utils/logger.js';
16
16
  import { Overlay, OverlayPage } from './utils/overlay.js';
17
17
  import { sleep, waitForPageReadiness } from './utils/page-readiness.ts';
18
18
  import type { Region } from './utils/region.js';
19
+ import { isInternalStep } from './utils/step-analyzer.ts';
19
20
  import { safeFilename } from './utils/strings.ts';
21
+ import { isSameHostFamily } from './utils/url-matcher.js';
20
22
  import { codeceptJSSandbox, hasPlaywrightCommands, playwrightSandbox, sanitizeCodeBlock } from './utils/web-sandbox.ts';
21
23
 
22
24
  const debugLog = createDebug('explorbot:action');
@@ -317,7 +319,7 @@ class Action {
317
319
 
318
320
  const url = URL.parse(request.url());
319
321
  if (!url) return;
320
- if (url.origin !== this.baseOrigin) return;
322
+ if (!isSameHostFamily(url.href, this.baseOrigin)) return;
321
323
 
322
324
  const call: NetworkCall = { method: request.method(), path: url.pathname, status };
323
325
  if (this.networkRequests.some((r) => r.method === call.method && r.path === call.path && r.status === call.status)) return;
@@ -563,7 +565,7 @@ export const attachStepLogger = (target: ExecutedStep[], assertionsTarget?: Arra
563
565
  let batchFailed = false;
564
566
  const listener: StepListener = (step, error) => {
565
567
  if (!step?.toCode) return;
566
- if (step.name?.startsWith('grab')) return;
568
+ if (isInternalStep(step)) return;
567
569
 
568
570
  const existing = recorded.get(step);
569
571
  if (existing) {
@@ -221,13 +221,19 @@ export function createAskApiTool(fisherman: Fisherman | null, task: Test) {
221
221
  return {
222
222
  askApi: tool({
223
223
  description: dedent`
224
- Ask what data already exists, changing nothing.
225
- Ask a question about existing records: which ones are there, what they are called, whether a particular one exists.
226
- Use it before precondition() to see whether suitable data is already available, and whenever a step needs the exact name or id of a record that is already there.
224
+ Read the app's data over the API, changing nothing.
225
+ Answers questions about records: which ones are there, what they are called, whether a particular one exists.
226
+
227
+ Use it to:
228
+ - check whether suitable data already exists, before precondition() creates any
229
+ - get the exact name or id of a record a step must act on
230
+ - find out whether an action was stored, when the app reported success but the page does not show the result
231
+ - find out whether data exists at all, when a list or dropdown is empty
232
+
227
233
  It never creates, edits or deletes anything — precondition() does that.
228
234
  `,
229
235
  inputSchema: z.object({
230
- question: z.string().describe('What to find out about data that already exists'),
236
+ question: z.string().describe('What to find out about the data'),
231
237
  }),
232
238
  execute: async ({ question }) => {
233
239
  tag('info').log(`Ask API: ${question}`);
@@ -18,7 +18,7 @@ import { createDebug, pluralize, tag } from '../utils/logger.js';
18
18
  import { loop, pause } from '../utils/loop.js';
19
19
  import { RulesLoader } from '../utils/rules-loader.ts';
20
20
  import { normalizeInlineText } from '../utils/strings.ts';
21
- import { extractStatePath, matchesNavigationUrl } from '../utils/url-matcher.js';
21
+ import { extractStatePath, isSameHostFamily, matchesNavigationUrl } from '../utils/url-matcher.js';
22
22
  import type { Agent, AgentDeps } from './agent.js';
23
23
  import type { Conversation } from './conversation.js';
24
24
  import type { Provider } from './provider.js';
@@ -99,15 +99,6 @@ class Navigator implements Agent {
99
99
  return this.config.ai?.agents?.navigator?.verifyTimeout ?? 1500;
100
100
  }
101
101
 
102
- private getBaseOrigin(): string | null {
103
- const baseUrl = this.config.playwright.url;
104
- try {
105
- return new URL(baseUrl).origin;
106
- } catch {
107
- return null;
108
- }
109
- }
110
-
111
102
  private getComparableCurrentUrl(stateManager: any, expectedUrl: string): string {
112
103
  const currentState = stateManager.getCurrentState();
113
104
  if (!currentState) return '';
@@ -126,18 +117,12 @@ class Navigator implements Agent {
126
117
  const currentFullUrl = currentState.fullUrl || currentState.url || '';
127
118
  if (!currentFullUrl) return false;
128
119
 
129
- try {
130
- const currentOrigin = new URL(currentFullUrl).origin;
131
- if (/^https?:\/\//i.test(expectedUrl)) {
132
- return currentOrigin === new URL(expectedUrl).origin;
133
- }
120
+ if (!/^https?:\/\//i.test(currentFullUrl)) return !/^https?:\/\//i.test(expectedUrl);
121
+ if (/^https?:\/\//i.test(expectedUrl)) return isSameHostFamily(currentFullUrl, expectedUrl);
134
122
 
135
- const baseOrigin = this.getBaseOrigin();
136
- if (!baseOrigin) return true;
137
- return currentOrigin === baseOrigin;
138
- } catch {
139
- return !/^https?:\/\//i.test(expectedUrl);
140
- }
123
+ const baseUrl = this.config.playwright.url;
124
+ if (!baseUrl) return true;
125
+ return isSameHostFamily(currentFullUrl, baseUrl);
141
126
  }
142
127
 
143
128
  private isOnExpectedPage(expectedUrl: string, stateManager: any): boolean {
@@ -325,8 +310,9 @@ class Navigator implements Agent {
325
310
  lastFailure = `Reached ${check.freshState.url} but the page state did not change`;
326
311
  tag('warning').log(`Page state did not change at ${check.freshState.url}`);
327
312
  } else {
328
- lastFailure = `Reached ${check.freshState.url}, expected ${expectedUrl}`;
329
- tag('warning').log(`URL verification failed: expected ${expectedUrl}, got ${check.freshState.url}`);
313
+ const reachedUrl = check.freshState.fullUrl || check.freshState.url;
314
+ lastFailure = `Reached ${reachedUrl}, expected ${expectedUrl}`;
315
+ tag('warning').log(`URL verification failed: expected ${expectedUrl}, got ${reachedUrl}`);
330
316
  }
331
317
  batchFailures.push({
332
318
  code: codeBlock,
package/src/ai/pilot.ts CHANGED
@@ -118,7 +118,9 @@ export class Pilot implements Agent {
118
118
  }
119
119
 
120
120
  const schema = z.object({
121
- decision: z.enum(['pass', 'fail', 'continue', 'skipped']).describe('pass = test succeeded, fail = test failed, continue = tester should keep going, skipped = scenario is irrelevant OR systematic execution failures prevented testing'),
121
+ decision: z
122
+ .enum(['pass', 'fail', 'continue', 'skipped'])
123
+ .describe('pass = scenario goal accomplished, fail = the app misbehaved, continue = tester should keep going, skipped = the scenario cannot be judged against this app (its premise does not hold, it is irrelevant, or systematic execution failures prevented testing)'),
122
124
  reason: z.string().describe('Concise user-facing reason, maximum 1 short sentence and 120 characters. Do NOT repeat the decision status; explain only the evidence. For continue: explain why rejected and suggest alternatives.'),
123
125
  guidance: z.string().nullable().describe('Required for "continue": specific actionable instruction for the tester — what exactly to verify, retry differently, or complete next. Be concrete.'),
124
126
  requestVerification: z
@@ -407,9 +409,13 @@ export class Pilot implements Agent {
407
409
  DOM assertion can't be made.
408
410
  Do not pass when Tester achieved only a related navigation/filter/tab/status outcome instead of the
409
411
  requested action, workflow, or entity detail goal.
410
- - "fail": goal not achieved and no further step toward it is available on the current page.
411
- - "skipped": scenario is irrelevant to the app, OR systematic infrastructure failures (LLM errors,
412
- crashes) prevented testing. NOT for "test failed to interact"that's "fail" or "continue".
412
+ - "fail": the app misbehaved the scenario's action ran against the right target and the app
413
+ produced a wrong, broken, or missing outcome. Not reaching the goal is not by itself a fail.
414
+ - "skipped": the scenario cannot be judged against this app the page shows its premise does not
415
+ hold (the assumed constraint, field, or behaviour is designed differently), the target entity or
416
+ feature is not the one here, the scenario is irrelevant, OR systematic infrastructure failures
417
+ (LLM errors, crashes) prevented testing. NOT for "test failed to interact" — that's "fail" or
418
+ "continue".
413
419
  - "continue": goal incomplete but the control for the NEXT step is present on the current page, or a
414
420
  concrete missing check would change your verdict. Guidance must name that step.
415
421
  If a verify() asserted a state that was ALREADY TRUE before the test, it proves nothing — reject.
@@ -1016,14 +1022,14 @@ export class Pilot implements Agent {
1016
1022
  private formatSuccessfulAssertions(currentState: ActionResult, testerConversation: Conversation): string {
1017
1023
  const lines: string[] = [];
1018
1024
  for (const [assertion, passed] of Object.entries(currentState.verifications ?? {})) {
1019
- if (passed) lines.push(`state verification (passed): ${assertion}`);
1025
+ if (passed) lines.push(`verify: ${assertion}`);
1020
1026
  }
1021
1027
 
1022
1028
  for (const exec of testerConversation.getToolExecutions()) {
1023
1029
  if (!EVIDENCE_TOOLS.includes(exec.toolName) || !exec.wasSuccessful) continue;
1024
1030
  const description = exec.input?.assertion || exec.input?.request || truncateJson(exec.input);
1025
- const result = exec.output?.message || exec.output?.analysis || exec.output?.result;
1026
- lines.push(`CHECK ${exec.toolName} (executed successfully): ${description}${result ? ` -> ${result}` : ''}`);
1031
+ const analysis = exec.output?.analysis;
1032
+ lines.push(`${exec.toolName}: ${description}${analysis ? ` -> ${analysis}` : ''}`);
1027
1033
  }
1028
1034
 
1029
1035
  return [...new Set(lines)].join('\n');
@@ -1157,9 +1163,14 @@ export class Pilot implements Agent {
1157
1163
 
1158
1164
  YOUR Pilot-only tools, both over the API:
1159
1165
 
1160
- askApi(question) — ask what data already exists. It changes nothing. Use it to check whether
1161
- suitable data is already there before creating any, and to get the exact name or id of an existing
1162
- record a step must act on.
1166
+ askApi(question) — read the app's data over the API. It changes nothing. Use when:
1167
+
1168
+ - Before precondition() check whether suitable data already exists.
1169
+ - A step needs the exact name or id of an existing record.
1170
+ - The app reported success but the page does not show the result — ask whether it was stored.
1171
+ - A list or dropdown is empty — ask whether the data exists at all.
1172
+
1173
+ The page is not the only witness. A record missing from the page may still exist.
1163
1174
 
1164
1175
  precondition(description) — create FRESH disposable test data. Never request users. Use when:
1165
1176
 
@@ -18,6 +18,7 @@ import { formatHeadings } from '../utils/context-formatter.ts';
18
18
  import { createDebug, tag } from '../utils/logger.ts';
19
19
  import { loop } from '../utils/loop.ts';
20
20
  import { RulesLoader } from '../utils/rules-loader.ts';
21
+ import { isInternalStep } from '../utils/step-analyzer.ts';
21
22
  import type { Agent, AgentDeps } from './agent.ts';
22
23
  import { toolExecutionLabel } from './conversation.ts';
23
24
  import type { Navigator } from './navigator.ts';
@@ -85,6 +86,7 @@ export class Rerunner extends TaskAgent implements Agent {
85
86
 
86
87
  const onStepStarted = (step: any) => {
87
88
  if (!step.toCode) return;
89
+ if (isInternalStep(step)) return;
88
90
  const code = highlight(step.toCode(), { language: 'javascript' });
89
91
  console.log(chalk.dim(` ${code}`));
90
92
  };
@@ -92,12 +94,14 @@ export class Rerunner extends TaskAgent implements Agent {
92
94
  const onStepPassed = (step: any) => {
93
95
  const task = this.getCurrentTask(testMap);
94
96
  if (!task || !step.toCode) return;
97
+ if (isInternalStep(step)) return;
95
98
  task.addStep(step.toCode(), step.duration, 'passed');
96
99
  };
97
100
 
98
101
  const onStepFailed = (step: any, error: any) => {
99
102
  const task = this.getCurrentTask(testMap);
100
103
  if (!task || !step.toCode) return;
104
+ if (isInternalStep(step)) return;
101
105
  task.addStep(step.toCode(), step.duration, 'failed', error?.message);
102
106
  console.log(chalk.red(` ${figureSet.cross} ${step.toCode()} — ${error?.message || 'failed'}`));
103
107
  };
package/src/ai/tools.ts CHANGED
@@ -1368,16 +1368,22 @@ async function extractWebElements(error: Error | null | undefined): Promise<Matc
1368
1368
 
1369
1369
  function formatElementList(matched: MatchedElement[] | null): string {
1370
1370
  if (!matched) return 'Could not fetch element details. Repeat the action to get better info.';
1371
- return matched
1371
+ const keys = matched.map((el) => `${el.text}::${el.visible}::${el.html}`);
1372
+ const list = matched
1372
1373
  .map((el, i) => {
1373
1374
  const lines = [`Element ${i + 1}:`, `Text: "${el.text}"`];
1374
1375
  if (el.visible !== undefined) lines.push(`Visible: ${el.visible}`);
1375
1376
  const wrapped = matched.map((_, j) => j).filter((j) => j !== i && matched[j].xpath.startsWith(`${el.xpath}/`));
1376
1377
  if (wrapped.length) lines.push(`Wraps: element ${wrapped.map((j) => j + 1).join(', ')}`);
1378
+ const same = keys.map((_, j) => j).filter((j) => j !== i && keys[j] === keys[i]);
1379
+ if (same.length) lines.push(`Identical to element ${same.map((j) => j + 1).join(', ')}`);
1377
1380
  lines.push(`XPath: ${el.xpath}`, `HTML: ${el.html}`);
1378
1381
  return lines.join('\n');
1379
1382
  })
1380
1383
  .join('\n\n');
1384
+
1385
+ if (new Set(keys).size === matched.length) return list;
1386
+ return `${list}\n\nIdentical matches are not told apart by their number — picking one is a guess. Click the one you mean by appearance with visualClick().`;
1381
1387
  }
1382
1388
 
1383
1389
  export async function formatMatchedElements(error: Error | null | undefined): Promise<string | null> {
@@ -1,3 +1,4 @@
1
+ import { isSameHostFamily } from '../utils/url-matcher.js';
1
2
  import { RequestResult, generateRequestId } from './request-result.ts';
2
3
  import type { RequestStore } from './request-store.ts';
3
4
 
@@ -39,7 +40,7 @@ export class XhrCapture {
39
40
 
40
41
  const method = request.method();
41
42
  const url = request.url();
42
- if (!url.startsWith(this.baseOrigin)) return;
43
+ if (!isSameHostFamily(url, this.baseOrigin)) return;
43
44
 
44
45
  const status = response.status();
45
46