explorbot 0.4.1 → 0.4.3

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 (141) hide show
  1. package/bin/explorbot-cli.ts +14 -4
  2. package/boat/api-tester/src/ai/curler-tools.ts +1 -1
  3. package/boat/api-tester/src/cli.ts +3 -0
  4. package/boat/doc-collector/src/ai/documentarian.ts +57 -5
  5. package/boat/doc-collector/src/ai/tools.ts +1 -1
  6. package/boat/doc-collector/src/cli.ts +18 -2
  7. package/boat/doc-collector/src/config.ts +3 -0
  8. package/boat/doc-collector/src/docbot.ts +56 -11
  9. package/boat/doc-collector/src/docs-renderer.ts +79 -55
  10. package/boat/doc-collector/src/interaction-screenshots.ts +19 -10
  11. package/boat/doc-collector/src/screenshots.ts +106 -3
  12. package/boat/doc-collector/src/template-dedup.ts +31 -0
  13. package/boat/prima/src/cli.ts +3 -0
  14. package/boat/prima/src/prima.ts +3 -4
  15. package/dist/bin/explorbot-cli.js +14 -4
  16. package/dist/boat/api-tester/src/ai/curler-tools.js +1 -1
  17. package/dist/boat/api-tester/src/cli.js +2 -0
  18. package/dist/boat/doc-collector/src/ai/documentarian.js +62 -4
  19. package/dist/boat/doc-collector/src/ai/tools.js +1 -1
  20. package/dist/boat/doc-collector/src/cli.js +15 -2
  21. package/dist/boat/doc-collector/src/config.js +1 -0
  22. package/dist/boat/doc-collector/src/docbot.js +52 -10
  23. package/dist/boat/doc-collector/src/docs-renderer.js +69 -46
  24. package/dist/boat/doc-collector/src/interaction-screenshots.js +18 -11
  25. package/dist/boat/doc-collector/src/screenshots.js +104 -1
  26. package/dist/boat/doc-collector/src/template-dedup.js +27 -0
  27. package/dist/boat/prima/src/cli.js +2 -0
  28. package/dist/boat/prima/src/prima.js +3 -4
  29. package/dist/package.json +1 -1
  30. package/dist/src/action-result.d.ts +2 -2
  31. package/dist/src/action-result.js +15 -17
  32. package/dist/src/action.d.ts +1 -1
  33. package/dist/src/action.js +15 -15
  34. package/dist/src/ai/fisherman/request-haul.d.ts +12 -0
  35. package/dist/src/ai/fisherman/request-haul.js +31 -0
  36. package/dist/src/ai/fisherman/tools.d.ts +50 -0
  37. package/dist/src/ai/{fisherman-tools.js → fisherman/tools.js} +109 -40
  38. package/dist/src/ai/fisherman.d.ts +14 -4
  39. package/dist/src/ai/fisherman.js +96 -18
  40. package/dist/src/ai/pilot.d.ts +13 -1
  41. package/dist/src/ai/pilot.js +40 -22
  42. package/dist/src/ai/provider.js +8 -14
  43. package/dist/src/ai/researcher/coordinates.d.ts +2 -0
  44. package/dist/src/ai/researcher/coordinates.js +13 -1
  45. package/dist/src/ai/researcher/deep-analysis.js +9 -9
  46. package/dist/src/ai/researcher/fingerprint-worker.js +1 -11
  47. package/dist/src/ai/researcher.js +1 -1
  48. package/dist/src/ai/rules.js +10 -0
  49. package/dist/src/ai/tester.js +20 -20
  50. package/dist/src/ai/tools.d.ts +1 -1
  51. package/dist/src/ai/tools.js +18 -74
  52. package/dist/src/api/request-result.js +3 -1
  53. package/dist/src/api/request-store.d.ts +7 -6
  54. package/dist/src/api/request-store.js +65 -37
  55. package/dist/src/api/xhr-capture.d.ts +2 -0
  56. package/dist/src/api/xhr-capture.js +35 -10
  57. package/dist/src/commands/config-command.js +2 -9
  58. package/dist/src/commands/help-json-command.d.ts +31 -0
  59. package/dist/src/commands/help-json-command.js +58 -0
  60. package/dist/src/commands/index.js +2 -0
  61. package/dist/src/commands/init-command.js +3 -7
  62. package/dist/src/commands/plans-command.d.ts +4 -1
  63. package/dist/src/commands/plans-command.js +4 -4
  64. package/dist/src/commands/recommended-models-command.d.ts +9 -0
  65. package/dist/src/commands/recommended-models-command.js +85 -0
  66. package/dist/src/config.d.ts +2 -0
  67. package/dist/src/config.js +7 -2
  68. package/dist/src/experience-tracker.js +1 -1
  69. package/dist/src/explorbot.js +3 -3
  70. package/dist/src/global-config.d.ts +2 -0
  71. package/dist/src/global-config.js +6 -0
  72. package/dist/src/state-manager.d.ts +3 -3
  73. package/dist/src/state-manager.js +8 -8
  74. package/dist/src/test-plan.d.ts +1 -0
  75. package/dist/src/test-plan.js +13 -9
  76. package/dist/src/utils/aria.d.ts +1 -1
  77. package/dist/src/utils/aria.js +23 -2
  78. package/dist/src/utils/cli-section.d.ts +1 -0
  79. package/dist/src/utils/cli-section.js +12 -0
  80. package/dist/src/utils/html-diff.d.ts +1 -0
  81. package/dist/src/utils/html-diff.js +5 -5
  82. package/dist/src/utils/html.d.ts +2 -0
  83. package/dist/src/utils/html.js +5 -4
  84. package/dist/src/utils/overlay.d.ts +19 -55
  85. package/dist/src/utils/overlay.js +94 -148
  86. package/dist/src/utils/region.d.ts +41 -0
  87. package/dist/src/utils/region.js +99 -0
  88. package/dist/src/utils/similarity.d.ts +1 -0
  89. package/dist/src/utils/similarity.js +11 -0
  90. package/dist/src/utils/url-matcher.d.ts +1 -1
  91. package/dist/src/utils/url-matcher.js +4 -4
  92. package/docs/doc-collection/basics.md +11 -5
  93. package/docs/doc-collection/crawling.md +21 -1
  94. package/docs/reference/commands.md +4 -0
  95. package/docs/reference/configuration.md +4 -0
  96. package/docs/superpowers/plans/2026-09-03-fisherman-query-api.md +1361 -0
  97. package/docs/superpowers/plans/2026-09-03-model-resolves-click-ambiguity.md +509 -0
  98. package/docs/workflow/agentic-usage.md +12 -0
  99. package/docs/workflow/application-spec.md +4 -0
  100. package/package.json +1 -1
  101. package/src/action-result.ts +15 -16
  102. package/src/action.ts +16 -15
  103. package/src/ai/fisherman/request-haul.ts +36 -0
  104. package/src/ai/{fisherman-tools.ts → fisherman/tools.ts} +125 -46
  105. package/src/ai/fisherman.ts +110 -20
  106. package/src/ai/pilot.ts +38 -20
  107. package/src/ai/provider.ts +8 -12
  108. package/src/ai/researcher/coordinates.ts +13 -1
  109. package/src/ai/researcher/deep-analysis.ts +9 -9
  110. package/src/ai/researcher/fingerprint-worker.ts +1 -10
  111. package/src/ai/researcher.ts +1 -1
  112. package/src/ai/rules.ts +10 -0
  113. package/src/ai/tester.ts +18 -18
  114. package/src/ai/tools.ts +18 -86
  115. package/src/api/request-result.ts +2 -1
  116. package/src/api/request-store.ts +68 -42
  117. package/src/api/xhr-capture.ts +39 -11
  118. package/src/commands/config-command.ts +2 -7
  119. package/src/commands/help-json-command.ts +74 -0
  120. package/src/commands/index.ts +2 -0
  121. package/src/commands/init-command.ts +4 -11
  122. package/src/commands/plans-command.ts +5 -5
  123. package/src/commands/recommended-models-command.ts +92 -0
  124. package/src/config.ts +9 -2
  125. package/src/experience-tracker.ts +1 -1
  126. package/src/explorbot.ts +3 -3
  127. package/src/global-config.ts +8 -0
  128. package/src/state-manager.ts +10 -9
  129. package/src/test-plan.ts +12 -10
  130. package/src/utils/aria.ts +26 -3
  131. package/src/utils/cli-section.ts +12 -0
  132. package/src/utils/html-diff.ts +6 -6
  133. package/src/utils/html.ts +6 -4
  134. package/src/utils/overlay.ts +105 -192
  135. package/src/utils/region.ts +132 -0
  136. package/src/utils/similarity.ts +9 -0
  137. package/src/utils/url-matcher.ts +3 -3
  138. package/dist/src/ai/fisherman-tools.d.ts +0 -138
  139. package/dist/src/utils/request-map.d.ts +0 -7
  140. package/dist/src/utils/request-map.js +0 -16
  141. package/src/utils/request-map.ts +0 -19
@@ -0,0 +1,509 @@
1
+ # Model-Resolved Click Ambiguity Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Make `MultipleElementsFound` a real zero-click failure that hands the model the matched elements and lets it pick one by position, instead of an internal AI call that silently retries and clicks.
6
+
7
+ **Architecture:** Delete the in-tool disambiguation retry (`disambiguateElements`) from both `click()` and `form()`. The existing failure path in `failedToolResult` already returns `multipleElementsDetected`, a numbered `elements` list, and a suggestion — that becomes the only path. Then document CodeceptJS's `step.opts({ elementIndex: N })` in the shared action rules so the model can act on that list; the sandbox already exposes `step`, so no runtime work is needed.
8
+
9
+ **Tech Stack:** Bun, TypeScript, CodeceptJS 4 (Playwright helper), Biome, `bun:test`.
10
+
11
+ **Spec:** This document — see Background. Primary evidence: Langfuse trace `5172fb3e976aa1e3889f149dd25a039b` (session `ThoughtlessActualViolet630`, 2026-09-01).
12
+
13
+ ## Background
14
+
15
+ `src/ai/tools.ts` catches `MultipleElementsFound`, runs a separate AI call to choose a position, appends `step.opts({ elementIndex: N })` to the failing command, retries, and returns `success: true` — while leaving the original `MultipleElementsFound` text in `attempts`.
16
+
17
+ Both readers of that result treat it as a failure. The Tester sees the error in the result it just received; Pilot's `formatActions` (`src/ai/pilot.ts:1038`) lifts the first `attempts[].error` into its evidence line and fires its `MultipleElementsFound → xpathCheck() then precise locator` rule (`src/ai/pilot.ts:1145`). So both send another click. Every one of those calls really clicks, because the internal retry runs inside each call.
18
+
19
+ In the reference trace a `button[role="switch"]` was clicked four times in 26 seconds — twice from the Tester's own locators, twice on Pilot's advice — each call reporting `success: true, disambiguated: true` with `code` ending in `step.opts({ elementIndex: 1 })` and an ariaDiff alternating `added switch [checked]` / `removed switch [checked]`. The plan was saved with the setting off and the test reported PASS.
20
+
21
+ The disambiguator picked position 1 correctly all four times, so it was not buying accuracy — it was buying one round trip and charging a page mutation for it. After this change an ambiguous locator never mutates the page, so a wrong guess is free.
22
+
23
+ `step.opts({ elementIndex })` already works end to end and needs no implementation: `src/utils/web-sandbox.ts:9` puts `step` in the sandbox's argument names, `node_modules/codeceptjs/lib/step/record.js:14` strips a `StepConfig` from the last argument position, and `node_modules/codeceptjs/lib/helper/Playwright.js:4285` honours `elementIndex`. It is 1-based in document order, accepts negatives counting from the end, and accepts `'first'`/`'last'`. It appears in no prompt or rule today, which is why the model could only guess new locators.
24
+
25
+ ## Global Constraints
26
+
27
+ - Runtime is **Bun only**. Never invoke `node`.
28
+ - Prompt text must be **general, never example-driven**: no locator, class name, or scenario taken from the reference trace may appear in any prompt, rule, or tool description. Illustrate the shape of correct usage, not the bug.
29
+ - Prompts stay **concise** — 1-3 lines per bullet, telegraph style.
30
+ - Never hardcode site-specific locators anywhere.
31
+ - Use `dedent` for multi-line prompt blocks.
32
+ - Prefer early exit over `if`/`else`. No ternaries. No `...(cond ? {k:v} : {})` spreads.
33
+ - Run `bun run format` after each code change; `bun run lint` must stay clean.
34
+ - Do **not** add or re-add the `regression` label, and do not trigger `.github/workflows/regression.yml`.
35
+ - End every commit message with:
36
+ `Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>` and
37
+ `Claude-Session: https://claude.ai/code/session_017DuWpxPTjAacCgowWAT631`
38
+
39
+ ## File Structure
40
+
41
+ | File | Responsibility after this plan |
42
+ |---|---|
43
+ | `src/ai/tools.ts` | `click()` and `form()` report ambiguity as a plain failure; keeps `extractWebElements` / `formatElementList` / `formatMatchedElements` for the element list; no longer holds an AI disambiguator |
44
+ | `src/ai/rules.ts` | `actionRule` documents `step.opts({ elementIndex })` under `### I.click`, reaching Tester, Navigator, Captain web-mode and Rerunner through the existing imports |
45
+ | `src/ai/pilot.ts` | Its `MultipleElementsFound` diagnostic line points at `elementIndex` instead of a sharper locator |
46
+ | `tests/unit/click-ambiguity.test.ts` | New. Pins zero clicks on ambiguity, and that the element list survives later fallback failures |
47
+ | `tests/unit/matched-elements.test.ts` | Unchanged. Already covers `formatMatchedElements` |
48
+ | `CHANGELOG.md` | User-facing entry |
49
+
50
+ ---
51
+
52
+ ### Task 1: Ambiguous click fails without clicking
53
+
54
+ **Files:**
55
+ - Modify: `src/ai/tools.ts:95-145` (the `click` tool's command loop, the disambiguation block, the failure return)
56
+ - Test: `tests/unit/click-ambiguity.test.ts` (create)
57
+
58
+ **Interfaces:**
59
+ - Consumes: `failedToolResult(action, message, data, error)` — already async, already sets `multipleElementsDetected: true` and `elements` when any text in `message` or `data.attempts[].error` contains `'multiple elements'`, using the `error` argument to build the list.
60
+ - Produces: the `click` tool result on ambiguity — `{ success: false, action: 'click', message: 'All click commands failed', attempts, suggestion, multipleElementsDetected: true, elements }`. No `disambiguated` key exists any more.
61
+
62
+ - [ ] **Step 1: Write the failing test**
63
+
64
+ Create `tests/unit/click-ambiguity.test.ts`:
65
+
66
+ ```ts
67
+ import { beforeEach, describe, expect, it } from 'bun:test';
68
+ import { createCodeceptJSTools } from '../../src/ai/tools.ts';
69
+ import { ConfigParser } from '../../src/config.ts';
70
+
71
+ function multipleElementsError(): Error {
72
+ const element = (xpath: string, text: string) => ({
73
+ toAbsoluteXPath: async () => xpath,
74
+ toOuterHTML: async () => '<button role="switch" type="button"></button>',
75
+ getText: async () => text,
76
+ });
77
+ return Object.assign(new Error('Multiple elements (2) found for "{role: switch}" in strict mode'), {
78
+ name: 'MultipleElementsFound',
79
+ webElements: [element('/html/body/div/button[1]', 'First control'), element('/html/body/div/button[2]', 'Second control')],
80
+ });
81
+ }
82
+
83
+ function notFoundError(): Error {
84
+ return Object.assign(new Error('element (.missing) was not found by text|CSS|XPath'), { name: 'ElementNotFound' });
85
+ }
86
+
87
+ function fakeDeps(errorFor: (command: string) => Error) {
88
+ const state = { url: '/settings', html: '<html><body></body></html>', ariaSnapshot: '- switch', id: 'unchanged' };
89
+ const action: any = {
90
+ lastError: null,
91
+ executedSteps: [],
92
+ ran: [] as string[],
93
+ saveScreenshot: async () => undefined,
94
+ attempt: async (command: string) => {
95
+ action.ran.push(command);
96
+ action.lastError = errorFor(command);
97
+ return false;
98
+ },
99
+ };
100
+ const deps: any = {
101
+ explorer: { action: () => action },
102
+ stateManager: { getCurrentState: () => state },
103
+ ai: { getModelForAgent: () => ({}), generateObject: async () => ({ object: { position: 1 } }) },
104
+ };
105
+ return { deps, action };
106
+ }
107
+
108
+ function fakeTask(): any {
109
+ return { startNote: () => ({ commit: () => {}, screenshot: undefined }) };
110
+ }
111
+
112
+ describe('click on an ambiguous locator', () => {
113
+ beforeEach(() => {
114
+ ConfigParser.resetForTesting();
115
+ ConfigParser.setupTestConfig();
116
+ });
117
+
118
+ it('clicks nothing and hands the matched elements back to the model', async () => {
119
+ const { deps, action } = fakeDeps(() => multipleElementsError());
120
+ const tools = createCodeceptJSTools(deps, fakeTask());
121
+
122
+ const result = await tools.click.execute({ commands: [`I.click({"role":"switch"})`], explanation: 'Toggle the control' }, {} as any);
123
+
124
+ expect(result.success).toBe(false);
125
+ expect(action.ran).toEqual([`I.click({"role":"switch"})`]);
126
+ expect(result.disambiguated).toBeUndefined();
127
+ expect(result.multipleElementsDetected).toBe(true);
128
+ expect(result.elements).toContain('Element 1:');
129
+ expect(result.elements).toContain('Element 2:');
130
+ });
131
+
132
+ it('keeps the ambiguous match when a later fallback command failed differently', async () => {
133
+ const { deps } = fakeDeps((command) => {
134
+ if (command.includes('role')) return multipleElementsError();
135
+ return notFoundError();
136
+ });
137
+ const tools = createCodeceptJSTools(deps, fakeTask());
138
+
139
+ const result = await tools.click.execute({ commands: [`I.click({"role":"switch"})`, `I.click('.missing')`], explanation: 'Toggle the control' }, {} as any);
140
+
141
+ expect(result.multipleElementsDetected).toBe(true);
142
+ expect(result.elements).toContain('Element 2:');
143
+ });
144
+ });
145
+ ```
146
+
147
+ - [ ] **Step 2: Run the test to verify it fails**
148
+
149
+ Run: `bun test tests/unit/click-ambiguity.test.ts`
150
+
151
+ Expected: both tests FAIL.
152
+
153
+ The first fails on `action.ran`: the old code disambiguates and retries, so `ran` holds three commands — the original, the original plus `step.opts({ elementIndex: 1 })`, and the picked XPath. The fake `ai` must answer `generateObject`; with an empty `ai` object the disambiguator throws, is swallowed by its own `catch`, returns `null`, and the test would pass against unfixed code for the wrong reason.
154
+
155
+ The second fails on `elements`, which reads `Could not fetch element details.` — at return time `action.lastError` is the not-found error from the second command, not the ambiguity error from the first.
156
+
157
+ - [ ] **Step 3: Capture the ambiguity error in the command loop**
158
+
159
+ In `src/ai/tools.ts`, in the `click` tool, declare the holder next to `attempts` and set it inside the loop. Replace:
160
+
161
+ ```ts
162
+ const attempts: Array<{ command: string; success: boolean; error?: string }> = [];
163
+
164
+ for (let i = 0; i < commands.length; i++) {
165
+ const command = transformContainsCommand(commands[i]);
166
+ const success = await action.attempt(command, explanation);
167
+
168
+ const attempt: { command: string; success: boolean; error?: string } = { command, success };
169
+ if (action.lastError) attempt.error = errorText(action.lastError);
170
+ attempts.push(attempt);
171
+ ```
172
+
173
+ with:
174
+
175
+ ```ts
176
+ const attempts: Array<{ command: string; success: boolean; error?: string }> = [];
177
+ let ambiguityError: Error | null = null;
178
+
179
+ for (let i = 0; i < commands.length; i++) {
180
+ const command = transformContainsCommand(commands[i]);
181
+ const success = await action.attempt(command, explanation);
182
+
183
+ const attempt: { command: string; success: boolean; error?: string } = { command, success };
184
+ if (action.lastError) attempt.error = errorText(action.lastError);
185
+ attempts.push(attempt);
186
+
187
+ if (!ambiguityError && action.lastError?.name === 'MultipleElementsFound') ambiguityError = action.lastError;
188
+ ```
189
+
190
+ - [ ] **Step 4: Delete the disambiguation retry block**
191
+
192
+ Still in the `click` tool, delete this entire block (it sits between the command loop and the final `const toolResult = …`):
193
+
194
+ ```ts
195
+ let disambiguated = null;
196
+ if (attempts.some((a) => a.error?.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN))) {
197
+ disambiguated = await disambiguateElements(action.lastError, explanation, ai);
198
+ }
199
+
200
+ if (disambiguated) {
201
+ debugLog('Disambiguation picked element %d', disambiguated.position);
202
+ const failedCommand = attempts.find((a) => a.error?.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN))?.command;
203
+ const retryCommands = [];
204
+ if (failedCommand) {
205
+ retryCommands.push(failedCommand.replace(/\)$/, `, step.opts({ elementIndex: ${disambiguated.position} }))`));
206
+ }
207
+ retryCommands.push(`I.click('${disambiguated.xpath.replace(/'/g, "\\'")}')`);
208
+
209
+ for (const retryCmd of retryCommands) {
210
+ if (!(await action.attempt(retryCmd, explanation))) {
211
+ attempts.push({ command: retryCmd, success: false, error: errorText(action.lastError) });
212
+ continue;
213
+ }
214
+ const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, retryCmd);
215
+ await commitNote(activeNote, TestResult.PASSED, toolResult, action);
216
+ return successToolResult('click', { ...toolResult, attempts, code: retryCmd, disambiguated: true }, action);
217
+ }
218
+ }
219
+ ```
220
+
221
+ - [ ] **Step 5: Pass the ambiguity error to the failure result**
222
+
223
+ In the same tool, change the final return's `error` argument. Replace:
224
+
225
+ ```ts
226
+ return failedToolResult(
227
+ 'click',
228
+ 'All click commands failed',
229
+ {
230
+ ...toolResult,
231
+ attempts,
232
+ suggestion,
233
+ },
234
+ action.lastError
235
+ );
236
+ ```
237
+
238
+ with:
239
+
240
+ ```ts
241
+ return failedToolResult(
242
+ 'click',
243
+ 'All click commands failed',
244
+ {
245
+ ...toolResult,
246
+ attempts,
247
+ suggestion,
248
+ },
249
+ ambiguityError || action.lastError
250
+ );
251
+ ```
252
+
253
+ - [ ] **Step 6: Run the test to verify it passes**
254
+
255
+ Run: `bun test tests/unit/click-ambiguity.test.ts`
256
+
257
+ Expected: 2 pass.
258
+
259
+ - [ ] **Step 7: Commit**
260
+
261
+ ```bash
262
+ git add src/ai/tools.ts tests/unit/click-ambiguity.test.ts
263
+ git commit -m "Let an ambiguous click fail without clicking"
264
+ ```
265
+
266
+ ---
267
+
268
+ ### Task 2: Remove the AI disambiguator
269
+
270
+ **Files:**
271
+ - Modify: `src/ai/tools.ts:34` (drop the now-unused `ai` binding), `src/ai/tools.ts:18` (drop the `AIProvider` import), `src/ai/tools.ts:412-422` (the `form` tool's ambiguity branch), `src/ai/tools.ts:1370-1410` (delete `disambiguateElements`)
272
+ - Test: `tests/unit/tools.test.ts`, `tests/unit/matched-elements.test.ts` (both existing, must keep passing)
273
+
274
+ **Interfaces:**
275
+ - Consumes: nothing new.
276
+ - Produces: `createCodeceptJSTools({ explorer, stateManager }: ToolDeps, task: Task)` — the `ai` member stays on the `ToolDeps` type because `createAgentTools` uses it; only this function stops destructuring it. `disambiguateElements` no longer exists. `extractWebElements`, `formatElementList`, `formatMatchedElements`, `MAX_DISAMBIGUATE_ELEMENTS`, `MAX_DISAMBIGUATE_TEXT`, `MAX_DISAMBIGUATE_HTML` and `MULTIPLE_ELEMENTS_PATTERN` all stay — they build the element list the model now reads.
277
+
278
+ - [ ] **Step 1: Simplify the form tool's failure suggestion**
279
+
280
+ In `src/ai/tools.ts`, in the `form` tool, replace:
281
+
282
+ ```ts
283
+ let formSuggestion = 'Commands after the failing one never ran. Retry only those, using click() or form().';
284
+ if (message.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN)) {
285
+ const disambiguated = await disambiguateElements(action.lastError, explanation, ai);
286
+ if (disambiguated) {
287
+ formSuggestion = `Multiple elements matched. Add step.opts({ elementIndex: ${disambiguated.position} }) to the failing command. Fallback locator: ${disambiguated.xpath}`;
288
+ }
289
+ }
290
+ ```
291
+
292
+ with:
293
+
294
+ ```ts
295
+ const formSuggestion = 'Commands after the failing one never ran. Retry only those, using click() or form().';
296
+ ```
297
+
298
+ `failedToolResult` already receives `action.lastError` here and replaces the suggestion with the ambiguity text plus the `elements` list whenever the message is ambiguous, so nothing is lost.
299
+
300
+ - [ ] **Step 2: Delete the disambiguator function**
301
+
302
+ Delete the whole `disambiguateElements` function from `src/ai/tools.ts` — it starts with:
303
+
304
+ ```ts
305
+ async function disambiguateElements(error: Error | null | undefined, explanation: string, provider: AIProvider): Promise<{ position: number; xpath: string } | null> {
306
+ ```
307
+
308
+ and ends with the closing brace after its `catch` block. Do not touch `extractWebElements`, `formatElementList` or `formatMatchedElements` directly above it.
309
+
310
+ - [ ] **Step 3: Drop the now-unused bindings**
311
+
312
+ At `src/ai/tools.ts:34`, change:
313
+
314
+ ```ts
315
+ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps, task: Task) {
316
+ ```
317
+
318
+ to:
319
+
320
+ ```ts
321
+ export function createCodeceptJSTools({ explorer, stateManager }: ToolDeps, task: Task) {
322
+ ```
323
+
324
+ At `src/ai/tools.ts:18`, delete the import line:
325
+
326
+ ```ts
327
+ import type { AIProvider } from './provider.ts';
328
+ ```
329
+
330
+ - [ ] **Step 4: Verify nothing else referenced them**
331
+
332
+ Run: `grep -rn "disambiguateElements\|AIProvider" src/ai/tools.ts`
333
+
334
+ Expected: no output.
335
+
336
+ Run: `bun run format && bun run lint`
337
+
338
+ Expected: formatting applied, `bun run lint` reports no errors.
339
+
340
+ - [ ] **Step 5: Run the surrounding suites**
341
+
342
+ Run: `bun test tests/unit/tools.test.ts tests/unit/matched-elements.test.ts tests/unit/click-ambiguity.test.ts tests/unit/click-failure-suggestion.test.ts`
343
+
344
+ Expected: all pass.
345
+
346
+ - [ ] **Step 6: Commit**
347
+
348
+ ```bash
349
+ git add src/ai/tools.ts
350
+ git commit -m "Drop the AI disambiguator from the click and form tools"
351
+ ```
352
+
353
+ ---
354
+
355
+ ### Task 3: Teach the model to pick a match by position
356
+
357
+ **Files:**
358
+ - Modify: `src/ai/rules.ts:309-338` (the `### I.click` section of `actionRule`)
359
+ - Modify: `src/ai/tools.ts:55-65` (the `click` tool's `commands` schema description) and `src/ai/tools.ts:1285-1295` (`getMultipleElementsSuggestion`)
360
+ - Modify: `src/ai/pilot.ts:1145` (the `MultipleElementsFound` diagnostic line)
361
+ - Test: `tests/unit/click-ambiguity.test.ts` (existing, from Task 1)
362
+
363
+ **Interfaces:**
364
+ - Consumes: `actionRule` and `locatorRule` are already imported by `src/ai/tester.ts:823-825`, `src/ai/navigator.ts:414`, `src/ai/captain/web-mode.ts:146-148` and `src/ai/rerunner.ts:448-450`. Editing `actionRule` reaches all four; no import changes.
365
+ - Produces: `getMultipleElementsSuggestion(): string` — same signature, new text naming `elementIndex`.
366
+
367
+ - [ ] **Step 1: Document `elementIndex` in the shared action rule**
368
+
369
+ In `src/ai/rules.ts`, inside `actionRule`'s `### I.click` section, insert after the line `If locator doesn't work, try CSS or XPath locators.`:
370
+
371
+ ```
372
+ When one locator matches several elements, select among them by position instead of inventing another locator.
373
+ Pass step.opts({ elementIndex: N }) as the LAST argument. N is 1-based, in document order.
374
+
375
+ <example>
376
+ I.click('Remove', step.opts({ elementIndex: 2 }));
377
+ I.click({ role: 'link', text: 'Details' }, '.panel', step.opts({ elementIndex: 1 }));
378
+ </example>
379
+ ```
380
+
381
+ - [ ] **Step 2: Rewrite the ambiguity suggestion**
382
+
383
+ In `src/ai/tools.ts`, replace the body of `getMultipleElementsSuggestion`:
384
+
385
+ ```ts
386
+ function getMultipleElementsSuggestion(): string {
387
+ return dedent`
388
+ Multiple elements matched your locator, so NOTHING was clicked and the page is unchanged.
389
+ Read the numbered elements list and click the one you meant by its number:
390
+ reuse the same locator with step.opts({ elementIndex: N }) as the last argument.
391
+ If none of them is the element you want, narrow the locator with a container or its full unique text.
392
+ If the list is missing, call xpathCheck() to see what the locator matches.
393
+ `;
394
+ }
395
+ ```
396
+
397
+ - [ ] **Step 3: Point the click tool's schema at the same escape hatch**
398
+
399
+ In `src/ai/tools.ts`, in the `click` tool's `commands` description, append one line after `5. I.clickXY(x, y) - coordinates fallback`:
400
+
401
+ ```
402
+ After a result reporting multiple matches, reuse that locator with step.opts({ elementIndex: N }) as the last argument.
403
+ ```
404
+
405
+ - [ ] **Step 4: Update Pilot's diagnostic line**
406
+
407
+ In `src/ai/pilot.ts`, replace:
408
+
409
+ ```
410
+ - MultipleElementsFound → xpathCheck() to identify the right one, then precise locator or visualClick().
411
+ ```
412
+
413
+ with:
414
+
415
+ ```
416
+ - MultipleElementsFound → nothing was clicked. Tell Tester to reuse the same locator with step.opts({ elementIndex: N }) from the numbered elements list.
417
+ ```
418
+
419
+ - [ ] **Step 5: Assert the model is told how to resolve it**
420
+
421
+ In `tests/unit/click-ambiguity.test.ts`, add one assertion to the end of the first test, after `expect(result.elements).toContain('Element 2:');`:
422
+
423
+ ```ts
424
+ expect(result.suggestion).toContain('elementIndex');
425
+ ```
426
+
427
+ - [ ] **Step 6: Run the tests**
428
+
429
+ Run: `bun test tests/unit/click-ambiguity.test.ts tests/unit/rules.test.ts`
430
+
431
+ Expected: all pass.
432
+
433
+ - [ ] **Step 7: Commit**
434
+
435
+ ```bash
436
+ git add src/ai/rules.ts src/ai/tools.ts src/ai/pilot.ts tests/unit/click-ambiguity.test.ts
437
+ git commit -m "Tell the model to pick an ambiguous match by position"
438
+ ```
439
+
440
+ ---
441
+
442
+ ### Task 4: Full suites, changelog, pull request
443
+
444
+ **Files:**
445
+ - Modify: `CHANGELOG.md`
446
+
447
+ **Interfaces:**
448
+ - Consumes: everything from Tasks 1-3.
449
+ - Produces: nothing consumed by later tasks.
450
+
451
+ - [ ] **Step 1: Run the whole unit suite**
452
+
453
+ Run: `bun test tests/unit/`
454
+
455
+ Expected: all pass. Baseline before this plan was 1247 pass / 0 fail; expect that plus the two new tests, minus none.
456
+
457
+ - [ ] **Step 2: Run the integration suite**
458
+
459
+ Run: `bun test tests/integration/`
460
+
461
+ Expected: all pass. Baseline was 92 pass / 0 fail. These exercise real prompts through the aimock server, so a prompt edit that breaks a journal assertion surfaces here.
462
+
463
+ - [ ] **Step 3: Format and lint**
464
+
465
+ Run: `bun run format && bun run lint`
466
+
467
+ Expected: clean.
468
+
469
+ - [ ] **Step 4: Add the changelog entry**
470
+
471
+ Add to `CHANGELOG.md` under a `## 2026-09-03` heading with a `### Changes` section (create the date heading directly under `# Changelog` if it does not exist yet; keep one blank line between the header and the first entry, and between entries):
472
+
473
+ ```markdown
474
+ - Click tool: A locator that matches several elements is now reported as a failure that clicked nothing,
475
+ together with the numbered list of what matched. Explorbot no longer guesses which one you meant and
476
+ clicks it — a guess used to land a real click, so a control that toggles could be switched back by a
477
+ retry the model thought had failed. The AI now picks a match by its number instead.
478
+ ```
479
+
480
+ - [ ] **Step 5: Commit and push**
481
+
482
+ ```bash
483
+ git add CHANGELOG.md
484
+ git commit -m "Add changelog entry for model-resolved click ambiguity"
485
+ git push -u origin fix/model-resolves-click-ambiguity
486
+ ```
487
+
488
+ - [ ] **Step 6: Open the pull request**
489
+
490
+ Open a PR against `main` titled `Let the model resolve an ambiguous click by position`. The body must state: ambiguity now clicks nothing; the disambiguating AI call is gone; `step.opts({ elementIndex })` is documented for the model; evidence is Langfuse trace `5172fb3e976aa1e3889f149dd25a039b`. End it with the 🤖 Claude Code footer and the session link. Do **not** apply the `regression` label.
491
+
492
+ - [ ] **Step 7: Close the superseded pull request**
493
+
494
+ PR #179 (`fix/click-disambiguation-not-a-failure`) implements the rejected approach — it kept the auto-retry and only made its reporting honest. Ask the user before closing it, then close with a comment naming the replacement PR and remove its worktree:
495
+
496
+ ```bash
497
+ bunosh worktree:delete fix/click-disambiguation-not-a-failure
498
+ ```
499
+
500
+ ---
501
+
502
+ ## Notes for the executor
503
+
504
+ - `MULTIPLE_ELEMENTS_PATTERN` is `'multiple elements'`, matched case-insensitively against error text. Task 1 additionally matches `error.name === 'MultipleElementsFound'` to pick the right error object out of several failed attempts — the string check stays where it is for the suggestion routing, because a failure can reach `failedToolResult` with only the text and no error object.
505
+ - `formatElementList` numbers entries `Element 1:`, `Element 2:` … 1-based, which is exactly the numbering `elementIndex` expects. Do not renumber either side.
506
+ - The `MAX_DISAMBIGUATE_*` constants keep their names. They cap the list the model reads; renaming them is churn with no user-visible effect.
507
+ - `elementIndex` also accepts a negative index counting from the end, and the strings `'first'`/`'last'` (`node_modules/codeceptjs/lib/helper/extras/elementSelection.js`, shared by the Playwright and WebDriver helpers). That is left out of the prompt on purpose: the element list is numbered 1..N, so positive integers are all the model needs. Do not "complete" the rule by adding them.
508
+ - `grep -rn "disambiguat|elementIndex|multiple elements" docs/` was run against this tree. Nothing in `docs/` claims Explorbot picks the matching element itself, so no user documentation needs updating. `docs/superpowers/specs/2026-08-01-actor-boat-design.md:73` mentions cheap-model disambiguation, but that is a historical design doc for the actor boat and no boat implements it — leave it alone.
509
+ - Do not add a knowledge file, a locator table, or any site-specific selector. The reference trace's app has two unlabelled `button[role="switch"]` controls; that is an accessibility gap in the application under test, not something to encode here.
@@ -66,6 +66,18 @@ No `init`, no config file, no project directory, no model IDs to look up. These
66
66
 
67
67
  This table is generated from the registry in `src/config.ts`, which also feeds `explorbot --help` — so `npx explorbot --help` lists the same variables on any command, and an agent can discover them without reading these docs.
68
68
 
69
+ ### Discovering commands
70
+
71
+ An agent that has to build a command line does not have to scrape help text. `help-json` prints the whole command tree — every command with its description, aliases, arguments, options and defaults, plus the version and the variables above:
72
+
73
+ ```bash
74
+ npx explorbot help-json | jq -r '.commands[] | "\(.name): \(.description)"'
75
+ npx explorbot help-json explore | jq '.options' # one command
76
+ npx explorbot help-json api config # nested boat commands
77
+ ```
78
+
79
+ It prints nothing but JSON, so the output pipes straight into `jq`. `npx explorbot config --json` does the same for the resolved configuration.
80
+
69
81
  ### Naming models
70
82
 
71
83
  Set `EXPLORBOT_AI_PROVIDER` to a provider name and Explorbot uses that provider's recommended model for every role — the same IDs listed in [Providers](../basics/providers.md), maintained in [`models.json`](../../models.json):
@@ -56,6 +56,10 @@ Lists the application's users.
56
56
 
57
57
  - user might export the user list
58
58
  Signal: An unlabeled download control is present.
59
+
60
+ ## Navigation
61
+
62
+ - Invite user: /users/invite
59
63
  ```
60
64
 
61
65
  The front matter fields are mandatory:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -8,6 +8,7 @@ import { type HtmlDiffPart, type HtmlDiffResult, htmlDiff, liveRegionMessages }
8
8
  import { extractHeadings, extractLinks, extractTargetedHtml, htmlCombinedSnapshot, htmlMinimalUISnapshot, htmlTextSnapshot, minifyHtml } from './utils/html.ts';
9
9
  import { createDebug } from './utils/logger.ts';
10
10
  import { Overlay } from './utils/overlay.ts';
11
+ import { Region } from './utils/region.ts';
11
12
  import { slugify } from './utils/strings.ts';
12
13
  import { extractStatePath, matchesUrl } from './utils/url-matcher.ts';
13
14
 
@@ -88,7 +89,7 @@ export class ActionResult implements ActionResultData {
88
89
  notes: string[] = [];
89
90
  public links: Link[] = [];
90
91
  public verifications?: Record<string, boolean>;
91
- public overlay: Overlay = new Overlay();
92
+ public overlay: Region = new Region();
92
93
  private _diffCache: { previousId: number | undefined; diff: Diff } | null = null;
93
94
 
94
95
  constructor(data: ActionResultData) {
@@ -264,7 +265,7 @@ export class ActionResult implements ActionResultData {
264
265
  if (!record.url || !this.url) return false;
265
266
  if (record.region && this.overlay.name !== record.region) return false;
266
267
  if (record.root) {
267
- if (!this.overlay.present) return false;
268
+ if (!this.overlay.isOpen) return false;
268
269
  if (this.overlay.root && this.overlay.root !== record.root) return false;
269
270
  }
270
271
  if (this.isMatchedBy(record)) return true;
@@ -545,16 +546,13 @@ export class ActionResult implements ActionResultData {
545
546
  pageDiff.ariaChangeCount = diff.ariaChangeCount;
546
547
  }
547
548
 
548
- if (this.overlay.present && (!previousState.overlay.present || previousState.overlay.name !== this.overlay.name)) {
549
+ if (this.overlay.isOpen && (!previousState.overlay.isOpen || previousState.overlay.name !== this.overlay.name)) {
549
550
  pageDiff.areaOfInterest = this.overlay.describe();
550
551
  }
551
552
 
552
553
  if (pageDiff.areaOfInterest && this.overlay.html && this.overlay.root) {
553
554
  const htmlConfig = ConfigParser.getInstance().getConfig().html;
554
- let subtree = await minifyHtml(htmlCombinedSnapshot(this.overlay.html, htmlConfig?.combined));
555
- if (subtree.length > HTML_PART_SUBTREE_BUDGET) {
556
- subtree = `${subtree.slice(0, HTML_PART_SUBTREE_BUDGET)}...<!-- truncated -->`;
557
- }
555
+ const subtree = trimSubtree(await minifyHtml(htmlCombinedSnapshot(this.overlay.html, htmlConfig?.combined)));
558
556
  pageDiff.htmlParts = [{ container: this.overlay.root, subtree, rawSize: subtree.length, added: [], removed: [] }];
559
557
  } else if (diff.isSameUrl() && diff.htmlParts.length > 0) {
560
558
  const collapsed = collapseHtmlParts(await diff.cleanedHtmlParts());
@@ -581,7 +579,7 @@ export class ActionResult implements ActionResultData {
581
579
 
582
580
  if (this.h1) parts.push(`h1_${this.h1}`);
583
581
  if (this.h2) parts.push(`h2_${this.h2}`);
584
- if (includeRegion && this.overlay.present && this.overlay.name) parts.push(`region_${this.overlay.name}`);
582
+ if (includeRegion && this.overlay.isOpen && this.overlay.name) parts.push(`region_${this.overlay.name}`);
585
583
 
586
584
  let stateString = slugify(parts.map((part) => part.substring(0, 100)).join('_'));
587
585
 
@@ -617,6 +615,7 @@ const CONSOLE_ERROR_LIMIT = 3;
617
615
  const HTML_PARTS_TOTAL_BUDGET = 8000;
618
616
  const HTML_PARTS_COUNT_LIMIT = 8;
619
617
  const HTML_PART_SUBTREE_BUDGET = 2000;
618
+ const SUBTREE_TAIL_BUDGET = 600;
620
619
 
621
620
  function collapseHtmlParts(parts: HtmlDiffPart[]): HtmlDiffPart[] {
622
621
  const total = parts.reduce((sum, p) => sum + p.subtree.length, 0);
@@ -631,14 +630,14 @@ function collapseHtmlParts(parts: HtmlDiffPart[]): HtmlDiffPart[] {
631
630
  }));
632
631
  }
633
632
 
634
- return parts.map((part) => {
635
- if (part.subtree.length <= HTML_PART_SUBTREE_BUDGET) return part;
636
- const head = part.subtree.slice(0, HTML_PART_SUBTREE_BUDGET);
637
- return {
638
- ...part,
639
- subtree: `${head}...<!-- truncated ${part.subtree.length - HTML_PART_SUBTREE_BUDGET} chars -->`,
640
- };
641
- });
633
+ return parts.map((part) => ({ ...part, subtree: trimSubtree(part.subtree) }));
634
+ }
635
+
636
+ function trimSubtree(subtree: string): string {
637
+ if (subtree.length <= HTML_PART_SUBTREE_BUDGET) return subtree;
638
+ const head = subtree.slice(0, HTML_PART_SUBTREE_BUDGET - SUBTREE_TAIL_BUDGET);
639
+ const tail = subtree.slice(-SUBTREE_TAIL_BUDGET);
640
+ return `${head}...<!-- truncated ${subtree.length - HTML_PART_SUBTREE_BUDGET} chars -->${tail}`;
642
641
  }
643
642
 
644
643
  export class Diff {