explorbot 0.1.26 → 0.1.27

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/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.1.26",
3
+ "version": "0.1.27",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -46,6 +46,14 @@ export function getCachedResearch(hash) {
46
46
  memoryCacheTimestamps[hash] = now;
47
47
  return cached;
48
48
  }
49
+ export function getPreviousResearch(hash) {
50
+ if (!hash)
51
+ return '';
52
+ const researchFile = outputPath('research', `${hash}.md`);
53
+ if (!existsSync(researchFile))
54
+ return '';
55
+ return readFileSync(researchFile, 'utf8');
56
+ }
49
57
  export function saveResearch(hash, text, combinedHtml) {
50
58
  const researchDir = outputPath('research');
51
59
  const researchFile = join(researchDir, `${hash}.md`);
@@ -2,9 +2,10 @@ import dedent from 'dedent';
2
2
  import { ActionResult } from '../../action-result.js';
3
3
  import { executionController } from "../../execution-controller.js";
4
4
  import { detectFocusArea, diffAriaSnapshots } from "../../utils/aria.js";
5
+ import { extractCodeBlocks } from "../../utils/code-extractor.js";
5
6
  import { tag } from '../../utils/logger.js';
6
7
  import { mdq } from "../../utils/markdown-query.js";
7
- import { getCachedResearch, saveResearch } from "./cache.js";
8
+ import { getCachedResearch, getPreviousResearch, saveResearch } from "./cache.js";
8
9
  import { debugLog } from "./mixin.js";
9
10
  import { parseResearchSections } from "./parser.js";
10
11
  const DEFAULT_MAX_EXPANDABLE_CLICKS = 10;
@@ -13,13 +14,26 @@ export function WithDeepAnalysis(Base) {
13
14
  async performDeepAnalysis(state, result) {
14
15
  tag('info').log('Starting deep analysis of expandable elements');
15
16
  await this.navigateTo(state.fullUrl || state.url);
16
- let expandables = await this._discoverExpandables(result.text);
17
- if (expandables.length === 0) {
18
- tag('info').log('No expandable elements identified by AI');
19
- return;
20
- }
21
- tag('substep').log(`Identified ${expandables.length} expandable elements`);
22
17
  const maxClicks = this.explorer.getConfig().ai?.agents?.researcher?.maxExpandableClicks ?? DEFAULT_MAX_EXPANDABLE_CLICKS;
18
+ const expandedSections = [];
19
+ const navigationLinks = [];
20
+ let verifiedCodes = [];
21
+ let missing = [];
22
+ const previousSections = this._loadPreviousExtendedSections(state.hash || '');
23
+ if (previousSections.length > 0) {
24
+ tag('substep').log(`Replaying ${previousSections.length} previously discovered sections`);
25
+ const replay = await this._replayPreviousSections(state, previousSections, maxClicks);
26
+ expandedSections.push(...replay.verified);
27
+ verifiedCodes = replay.verifiedCodes;
28
+ missing = replay.missing;
29
+ tag('info').log(`Reused ${replay.verified.length}/${previousSections.length} previous sections, ${missing.length} to re-discover`);
30
+ if (missing.length === 0 && replay.verified.length >= maxClicks) {
31
+ tag('info').log('Page appears unchanged, reusing previous sections and skipping discovery');
32
+ this._appendExtendedResearch(result, expandedSections, navigationLinks);
33
+ return;
34
+ }
35
+ }
36
+ let expandables = await this._discoverExpandables(result.text, missing, verifiedCodes);
23
37
  if (expandables.length > maxClicks) {
24
38
  expandables = await this._selectExpandables(expandables, state.fullUrl || state.url, maxClicks);
25
39
  tag('substep').log(`Selected ${expandables.length} expandables to click (max: ${maxClicks})`);
@@ -29,9 +43,11 @@ export function WithDeepAnalysis(Base) {
29
43
  commands: this._buildClickCommands(el),
30
44
  description: el.name,
31
45
  }))
32
- .filter((el) => el.commands.length > 0);
46
+ .filter((el) => el.commands.length > 0)
47
+ .filter((el) => !el.commands.some((cmd) => verifiedCodes.includes(cmd)));
33
48
  if (elements.length === 0) {
34
- tag('info').log('No expandables with valid locators');
49
+ tag('info').log('No new expandable elements to click');
50
+ this._appendExtendedResearch(result, expandedSections, navigationLinks);
35
51
  return;
36
52
  }
37
53
  const expandableRows = elements.map((el) => `| ${el.description} | \`${el.commands[0]}\` |`).join('\n');
@@ -39,21 +55,9 @@ export function WithDeepAnalysis(Base) {
39
55
  for (const el of elements)
40
56
  debugLog(`Expandable: ${el.description} → ${el.commands[0]}`);
41
57
  tag('substep').log(`Clicking ${elements.length} expandable elements`);
42
- const expandedSections = [];
43
- const navigationLinks = [];
44
58
  await this._clickExpandableElements(elements, state, expandedSections, navigationLinks);
45
59
  tag('info').log(`Deep analysis complete. Sections: ${expandedSections.length}, navigation links: ${navigationLinks.length}`);
46
- const dedupedSections = this._deduplicateExpandedSections(expandedSections);
47
- if (dedupedSections.length !== expandedSections.length) {
48
- tag('substep').log(`Deduplicated ${expandedSections.length} → ${dedupedSections.length} extended sections`);
49
- }
50
- if (dedupedSections.length > 0) {
51
- result.text += `\n\n# Extended Research\n\n${dedupedSections.join('\n\n---\n\n')}`;
52
- }
53
- if (navigationLinks.length > 0) {
54
- const links = navigationLinks.map((l) => `- \`${l.code}\` opens ${l.url}`).join('\n');
55
- result.text += `\n\n## Navigation Links\n\n${links}`;
56
- }
60
+ this._appendExtendedResearch(result, expandedSections, navigationLinks);
57
61
  }
58
62
  async researchOverlay(current, previous, pageStateHash) {
59
63
  const focusArea = detectFocusArea(current.ariaSnapshot);
@@ -97,7 +101,66 @@ export function WithDeepAnalysis(Base) {
97
101
  tag('substep').log(`Overlay research appended: ${focusArea.name}`);
98
102
  return sectionMarkdown;
99
103
  }
100
- async _discoverExpandables(researchText) {
104
+ _loadPreviousExtendedSections(hash) {
105
+ if (!hash)
106
+ return [];
107
+ const previous = getPreviousResearch(hash);
108
+ if (!previous)
109
+ return [];
110
+ const sections = [];
111
+ for (const section of parseResearchSections(previous)) {
112
+ if (!section.isExtended)
113
+ continue;
114
+ const code = extractCodeBlocks(section.rawMarkdown)[0];
115
+ if (!code)
116
+ continue;
117
+ sections.push({ name: section.name, code });
118
+ }
119
+ return sections;
120
+ }
121
+ async _replayPreviousSections(state, prevSections, maxClicks) {
122
+ const originalAria = state.ariaSnapshot || '';
123
+ const verified = [];
124
+ const verifiedCodes = [];
125
+ const missing = [];
126
+ for (const section of prevSections.slice(0, maxClicks)) {
127
+ if (executionController.isInterrupted())
128
+ break;
129
+ let outcome;
130
+ try {
131
+ outcome = await this._executeAndAnalyze([section.code], section.name, state, originalAria, this._summarizeExpanded(verified));
132
+ }
133
+ catch (err) {
134
+ tag('warning').log(`Replay failed for "${section.name}": ${err instanceof Error ? err.message : err}`);
135
+ await this._restorePageState(state.url, originalAria).catch(() => { });
136
+ missing.push(section);
137
+ continue;
138
+ }
139
+ if (outcome.status === 'revealed') {
140
+ verified.push(outcome.sectionMarkdown);
141
+ verifiedCodes.push(section.code);
142
+ debugLog(`Replayed and verified section: ${section.name}`);
143
+ continue;
144
+ }
145
+ debugLog(`Could not replay previous section: ${section.name}`);
146
+ missing.push(section);
147
+ }
148
+ return { verified, verifiedCodes, missing };
149
+ }
150
+ _appendExtendedResearch(result, expandedSections, navigationLinks) {
151
+ const dedupedSections = this._deduplicateExpandedSections(expandedSections);
152
+ if (dedupedSections.length !== expandedSections.length) {
153
+ tag('substep').log(`Deduplicated ${expandedSections.length} → ${dedupedSections.length} extended sections`);
154
+ }
155
+ if (dedupedSections.length > 0) {
156
+ result.text += `\n\n# Extended Research\n\n${dedupedSections.join('\n\n---\n\n')}`;
157
+ }
158
+ if (navigationLinks.length > 0) {
159
+ const links = navigationLinks.map((l) => `- \`${l.code}\` opens ${l.url}`).join('\n');
160
+ result.text += `\n\n## Navigation Links\n\n${links}`;
161
+ }
162
+ }
163
+ async _discoverExpandables(researchText, missing = [], verifiedCodes = []) {
101
164
  const allElements = new Map();
102
165
  for (const section of parseResearchSections(researchText)) {
103
166
  for (const el of section.elements) {
@@ -108,6 +171,15 @@ export function WithDeepAnalysis(Base) {
108
171
  if (allElements.size === 0)
109
172
  return [];
110
173
  const eidxList = [...allElements.keys()].join(', ');
174
+ let missingHint = '';
175
+ if (missing.length > 0) {
176
+ const list = missing.map((s) => `- "${s.name}" (previously revealed via ${s.code})`).join('\n');
177
+ missingHint = dedent `
178
+
179
+ These sections were present on a previous visit but their trigger could not be replayed now — the element may have moved or been renamed. Prioritize finding the element that now reveals each:
180
+ ${list}
181
+ `;
182
+ }
111
183
  const textPrompt = dedent `
112
184
  From this UI research, identify elements that could reveal hidden UI when clicked
113
185
  (dropdown menus, popups, expandable panels, accordion sections, overflow menus, tab switches).
@@ -115,6 +187,7 @@ export function WithDeepAnalysis(Base) {
115
187
  Available eidx refs: ${eidxList}
116
188
 
117
189
  ${researchText}
190
+ ${missingHint}
118
191
 
119
192
  Rules:
120
193
  - Only pick elements that HIDE content until clicked (menus, dropdowns, accordions, tabs)
@@ -136,6 +209,7 @@ export function WithDeepAnalysis(Base) {
136
209
 
137
210
  Look for: overflow/ellipsis menus, chevron dropdowns, hamburger menus,
138
211
  gear/settings buttons, accordion toggles, tab switches, filter buttons.
212
+ ${missingHint}
139
213
 
140
214
  Rules:
141
215
  - For repeated icons (same icon on every list row), pick only the FIRST one
@@ -258,48 +332,15 @@ export function WithDeepAnalysis(Base) {
258
332
  await this._restorePageState(state.url, originalAria);
259
333
  }
260
334
  }
261
- let clickCode = null;
262
- const action = this.explorer.createAction();
263
- for (const cmd of el.commands) {
264
- if (await action.attempt(cmd, undefined, false)) {
265
- clickCode = cmd;
266
- break;
267
- }
268
- }
269
- if (!clickCode) {
270
- debugLog(`Click failed: ${el.description.slice(0, 80)}`);
335
+ const outcome = await this._executeAndAnalyze(el.commands, el.description, state, originalAria, this._summarizeExpanded(expandedSections));
336
+ if (outcome.status === 'navigated') {
337
+ navigationLinks.push({ code: outcome.code, url: outcome.url });
271
338
  continue;
272
339
  }
273
- await new Promise((r) => setTimeout(r, 500));
274
- let diff;
275
- try {
276
- await this.explorer.createAction().capturePageState();
277
- const currAR = ActionResult.fromState(this.stateManager.getCurrentState());
278
- diff = await currAR.diff(previousState);
279
- await diff.calculate();
280
- }
281
- catch (err) {
282
- tag('warning').log(`State capture failed after click: ${err instanceof Error ? err.message : err}`);
283
- await this._restorePageState(state.url, originalAria);
284
- continue;
285
- }
286
- if (diff.urlHasChanged()) {
287
- debugLog(`Click navigated to ${this.stateManager.getCurrentState()?.url}`);
288
- navigationLinks.push({ code: clickCode, url: this.stateManager.getCurrentState()?.url || '' });
289
- await this.navigateTo(state.url);
290
- continue;
291
- }
292
- const clickHtmlSize = diff.htmlParts.reduce((sum, p) => sum + p.subtree.length, 0);
293
- if (!diff.ariaChanged && clickHtmlSize <= 150) {
294
- debugLog(`No changes from: ${el.description.slice(0, 80)}`);
295
- continue;
296
- }
297
- const sectionMarkdown = await this._analyzeExpandedAction(clickCode, el.description, diff, this._summarizeExpanded(expandedSections));
298
- if (sectionMarkdown) {
299
- expandedSections.push(sectionMarkdown);
340
+ if (outcome.status === 'revealed') {
341
+ expandedSections.push(outcome.sectionMarkdown);
300
342
  debugLog(`Captured section from: ${el.description.slice(0, 80)}`);
301
343
  }
302
- await this._restorePageState(state.url, originalAria);
303
344
  }
304
345
  catch (err) {
305
346
  tag('warning').log(`Expandable click failed for "${el.description.slice(0, 80)}": ${err instanceof Error ? err.message : err}`);
@@ -310,6 +351,50 @@ export function WithDeepAnalysis(Base) {
310
351
  }
311
352
  }
312
353
  }
354
+ async _executeAndAnalyze(commands, description, state, originalAria, alreadyExpanded) {
355
+ const previousState = ActionResult.fromState(this.stateManager.getCurrentState());
356
+ let clickCode = null;
357
+ const action = this.explorer.createAction();
358
+ for (const cmd of commands) {
359
+ if (await action.attempt(cmd, undefined, false)) {
360
+ clickCode = cmd;
361
+ break;
362
+ }
363
+ }
364
+ if (!clickCode) {
365
+ debugLog(`Click failed: ${description.slice(0, 80)}`);
366
+ return { status: 'failed' };
367
+ }
368
+ await new Promise((r) => setTimeout(r, 500));
369
+ let diff;
370
+ try {
371
+ await this.explorer.createAction().capturePageState();
372
+ const currAR = ActionResult.fromState(this.stateManager.getCurrentState());
373
+ diff = await currAR.diff(previousState);
374
+ await diff.calculate();
375
+ }
376
+ catch (err) {
377
+ tag('warning').log(`State capture failed after click: ${err instanceof Error ? err.message : err}`);
378
+ await this._restorePageState(state.url, originalAria);
379
+ return { status: 'failed' };
380
+ }
381
+ if (diff.urlHasChanged()) {
382
+ const url = this.stateManager.getCurrentState()?.url || '';
383
+ debugLog(`Click navigated to ${url}`);
384
+ await this.navigateTo(state.url);
385
+ return { status: 'navigated', code: clickCode, url };
386
+ }
387
+ const clickHtmlSize = diff.htmlParts.reduce((sum, p) => sum + p.subtree.length, 0);
388
+ if (!diff.ariaChanged && clickHtmlSize <= 150) {
389
+ debugLog(`No changes from: ${description.slice(0, 80)}`);
390
+ return { status: 'none', code: clickCode };
391
+ }
392
+ const sectionMarkdown = await this._analyzeExpandedAction(clickCode, description, diff, alreadyExpanded);
393
+ await this._restorePageState(state.url, originalAria);
394
+ if (!sectionMarkdown)
395
+ return { status: 'none', code: clickCode };
396
+ return { status: 'revealed', code: clickCode, sectionMarkdown };
397
+ }
313
398
  async _restorePageState(url, originalAria) {
314
399
  try {
315
400
  await this.cancelInUi();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.1.26",
3
+ "version": "0.1.27",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -49,6 +49,13 @@ export function getCachedResearch(hash: string): string {
49
49
  return cached;
50
50
  }
51
51
 
52
+ export function getPreviousResearch(hash: string): string {
53
+ if (!hash) return '';
54
+ const researchFile = outputPath('research', `${hash}.md`);
55
+ if (!existsSync(researchFile)) return '';
56
+ return readFileSync(researchFile, 'utf8');
57
+ }
58
+
52
59
  export function saveResearch(hash: string, text: string, combinedHtml?: string): string {
53
60
  const researchDir = outputPath('research');
54
61
  const researchFile = join(researchDir, `${hash}.md`);
@@ -5,10 +5,11 @@ import type Explorer from '../../explorer.ts';
5
5
  import type { StateManager } from '../../state-manager.js';
6
6
  import { WebPageState } from '../../state-manager.js';
7
7
  import { detectFocusArea, diffAriaSnapshots } from '../../utils/aria.ts';
8
+ import { extractCodeBlocks } from '../../utils/code-extractor.ts';
8
9
  import { tag } from '../../utils/logger.js';
9
10
  import { mdq } from '../../utils/markdown-query.ts';
10
11
  import type { Provider } from '../provider.js';
11
- import { getCachedResearch, saveResearch } from './cache.ts';
12
+ import { getCachedResearch, getPreviousResearch, saveResearch } from './cache.ts';
12
13
  import { type Constructor, debugLog } from './mixin.ts';
13
14
  import { type ResearchElement, parseResearchSections } from './parser.ts';
14
15
  import type { ResearchResult } from './research-result.ts';
@@ -26,14 +27,30 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
26
27
  tag('info').log('Starting deep analysis of expandable elements');
27
28
  await (this as any).navigateTo(state.fullUrl || state.url);
28
29
 
29
- let expandables = await this._discoverExpandables(result.text);
30
- if (expandables.length === 0) {
31
- tag('info').log('No expandable elements identified by AI');
32
- return;
30
+ const maxClicks = (this.explorer.getConfig().ai?.agents?.researcher as any)?.maxExpandableClicks ?? DEFAULT_MAX_EXPANDABLE_CLICKS;
31
+
32
+ const expandedSections: string[] = [];
33
+ const navigationLinks: Array<{ code: string; url: string }> = [];
34
+ let verifiedCodes: string[] = [];
35
+ let missing: PreviousSection[] = [];
36
+
37
+ const previousSections = this._loadPreviousExtendedSections(state.hash || '');
38
+ if (previousSections.length > 0) {
39
+ tag('substep').log(`Replaying ${previousSections.length} previously discovered sections`);
40
+ const replay = await this._replayPreviousSections(state, previousSections, maxClicks);
41
+ expandedSections.push(...replay.verified);
42
+ verifiedCodes = replay.verifiedCodes;
43
+ missing = replay.missing;
44
+ tag('info').log(`Reused ${replay.verified.length}/${previousSections.length} previous sections, ${missing.length} to re-discover`);
45
+
46
+ if (missing.length === 0 && replay.verified.length >= maxClicks) {
47
+ tag('info').log('Page appears unchanged, reusing previous sections and skipping discovery');
48
+ this._appendExtendedResearch(result, expandedSections, navigationLinks);
49
+ return;
50
+ }
33
51
  }
34
- tag('substep').log(`Identified ${expandables.length} expandable elements`);
35
52
 
36
- const maxClicks = (this.explorer.getConfig().ai?.agents?.researcher as any)?.maxExpandableClicks ?? DEFAULT_MAX_EXPANDABLE_CLICKS;
53
+ let expandables = await this._discoverExpandables(result.text, missing, verifiedCodes);
37
54
  if (expandables.length > maxClicks) {
38
55
  expandables = await this._selectExpandables(expandables, state.fullUrl || state.url, maxClicks);
39
56
  tag('substep').log(`Selected ${expandables.length} expandables to click (max: ${maxClicks})`);
@@ -44,10 +61,12 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
44
61
  commands: this._buildClickCommands(el),
45
62
  description: el.name,
46
63
  }))
47
- .filter((el) => el.commands.length > 0);
64
+ .filter((el) => el.commands.length > 0)
65
+ .filter((el) => !el.commands.some((cmd) => verifiedCodes.includes(cmd)));
48
66
 
49
67
  if (elements.length === 0) {
50
- tag('info').log('No expandables with valid locators');
68
+ tag('info').log('No new expandable elements to click');
69
+ this._appendExtendedResearch(result, expandedSections, navigationLinks);
51
70
  return;
52
71
  }
53
72
 
@@ -57,25 +76,11 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
57
76
  for (const el of elements) debugLog(`Expandable: ${el.description} → ${el.commands[0]}`);
58
77
  tag('substep').log(`Clicking ${elements.length} expandable elements`);
59
78
 
60
- const expandedSections: string[] = [];
61
- const navigationLinks: Array<{ code: string; url: string }> = [];
62
-
63
79
  await this._clickExpandableElements(elements, state, expandedSections, navigationLinks);
64
80
 
65
81
  tag('info').log(`Deep analysis complete. Sections: ${expandedSections.length}, navigation links: ${navigationLinks.length}`);
66
82
 
67
- const dedupedSections = this._deduplicateExpandedSections(expandedSections);
68
- if (dedupedSections.length !== expandedSections.length) {
69
- tag('substep').log(`Deduplicated ${expandedSections.length} → ${dedupedSections.length} extended sections`);
70
- }
71
-
72
- if (dedupedSections.length > 0) {
73
- result.text += `\n\n# Extended Research\n\n${dedupedSections.join('\n\n---\n\n')}`;
74
- }
75
- if (navigationLinks.length > 0) {
76
- const links = navigationLinks.map((l) => `- \`${l.code}\` opens ${l.url}`).join('\n');
77
- result.text += `\n\n## Navigation Links\n\n${links}`;
78
- }
83
+ this._appendExtendedResearch(result, expandedSections, navigationLinks);
79
84
  }
80
85
 
81
86
  async researchOverlay(current: ActionResult, previous: ActionResult, pageStateHash: string): Promise<string | null> {
@@ -127,7 +132,70 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
127
132
  return sectionMarkdown;
128
133
  }
129
134
 
130
- private async _discoverExpandables(researchText: string): Promise<ExpandableElement[]> {
135
+ private _loadPreviousExtendedSections(hash: string): PreviousSection[] {
136
+ if (!hash) return [];
137
+ const previous = getPreviousResearch(hash);
138
+ if (!previous) return [];
139
+
140
+ const sections: PreviousSection[] = [];
141
+ for (const section of parseResearchSections(previous)) {
142
+ if (!section.isExtended) continue;
143
+ const code = extractCodeBlocks(section.rawMarkdown)[0];
144
+ if (!code) continue;
145
+ sections.push({ name: section.name, code });
146
+ }
147
+ return sections;
148
+ }
149
+
150
+ private async _replayPreviousSections(state: WebPageState, prevSections: PreviousSection[], maxClicks: number): Promise<{ verified: string[]; verifiedCodes: string[]; missing: PreviousSection[] }> {
151
+ const originalAria = state.ariaSnapshot || '';
152
+ const verified: string[] = [];
153
+ const verifiedCodes: string[] = [];
154
+ const missing: PreviousSection[] = [];
155
+
156
+ for (const section of prevSections.slice(0, maxClicks)) {
157
+ if (executionController.isInterrupted()) break;
158
+
159
+ let outcome: ExpansionOutcome;
160
+ try {
161
+ outcome = await this._executeAndAnalyze([section.code], section.name, state, originalAria, this._summarizeExpanded(verified));
162
+ } catch (err) {
163
+ tag('warning').log(`Replay failed for "${section.name}": ${err instanceof Error ? err.message : err}`);
164
+ await this._restorePageState(state.url, originalAria).catch(() => {});
165
+ missing.push(section);
166
+ continue;
167
+ }
168
+
169
+ if (outcome.status === 'revealed') {
170
+ verified.push(outcome.sectionMarkdown);
171
+ verifiedCodes.push(section.code);
172
+ debugLog(`Replayed and verified section: ${section.name}`);
173
+ continue;
174
+ }
175
+
176
+ debugLog(`Could not replay previous section: ${section.name}`);
177
+ missing.push(section);
178
+ }
179
+
180
+ return { verified, verifiedCodes, missing };
181
+ }
182
+
183
+ private _appendExtendedResearch(result: ResearchResult, expandedSections: string[], navigationLinks: Array<{ code: string; url: string }>): void {
184
+ const dedupedSections = this._deduplicateExpandedSections(expandedSections);
185
+ if (dedupedSections.length !== expandedSections.length) {
186
+ tag('substep').log(`Deduplicated ${expandedSections.length} → ${dedupedSections.length} extended sections`);
187
+ }
188
+
189
+ if (dedupedSections.length > 0) {
190
+ result.text += `\n\n# Extended Research\n\n${dedupedSections.join('\n\n---\n\n')}`;
191
+ }
192
+ if (navigationLinks.length > 0) {
193
+ const links = navigationLinks.map((l) => `- \`${l.code}\` opens ${l.url}`).join('\n');
194
+ result.text += `\n\n## Navigation Links\n\n${links}`;
195
+ }
196
+ }
197
+
198
+ private async _discoverExpandables(researchText: string, missing: PreviousSection[] = [], verifiedCodes: string[] = []): Promise<ExpandableElement[]> {
131
199
  const allElements = new Map<string, ExpandableElement>();
132
200
  for (const section of parseResearchSections(researchText)) {
133
201
  for (const el of section.elements) {
@@ -138,6 +206,16 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
138
206
 
139
207
  const eidxList = [...allElements.keys()].join(', ');
140
208
 
209
+ let missingHint = '';
210
+ if (missing.length > 0) {
211
+ const list = missing.map((s) => `- "${s.name}" (previously revealed via ${s.code})`).join('\n');
212
+ missingHint = dedent`
213
+
214
+ These sections were present on a previous visit but their trigger could not be replayed now — the element may have moved or been renamed. Prioritize finding the element that now reveals each:
215
+ ${list}
216
+ `;
217
+ }
218
+
141
219
  const textPrompt = dedent`
142
220
  From this UI research, identify elements that could reveal hidden UI when clicked
143
221
  (dropdown menus, popups, expandable panels, accordion sections, overflow menus, tab switches).
@@ -145,6 +223,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
145
223
  Available eidx refs: ${eidxList}
146
224
 
147
225
  ${researchText}
226
+ ${missingHint}
148
227
 
149
228
  Rules:
150
229
  - Only pick elements that HIDE content until clicked (menus, dropdowns, accordions, tabs)
@@ -168,6 +247,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
168
247
 
169
248
  Look for: overflow/ellipsis menus, chevron dropdowns, hamburger menus,
170
249
  gear/settings buttons, accordion toggles, tab switches, filter buttons.
250
+ ${missingHint}
171
251
 
172
252
  Rules:
173
253
  - For repeated icons (same icon on every list row), pick only the FIRST one
@@ -302,53 +382,15 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
302
382
  }
303
383
  }
304
384
 
305
- let clickCode: string | null = null;
306
- const action = this.explorer.createAction();
307
- for (const cmd of el.commands) {
308
- if (await action.attempt(cmd, undefined, false)) {
309
- clickCode = cmd;
310
- break;
311
- }
312
- }
313
- if (!clickCode) {
314
- debugLog(`Click failed: ${el.description.slice(0, 80)}`);
315
- continue;
316
- }
317
-
318
- await new Promise((r) => setTimeout(r, 500));
319
-
320
- let diff: Diff;
321
- try {
322
- await this.explorer.createAction().capturePageState();
323
- const currAR = ActionResult.fromState(this.stateManager.getCurrentState()!);
324
- diff = await currAR.diff(previousState);
325
- await diff.calculate();
326
- } catch (err) {
327
- tag('warning').log(`State capture failed after click: ${err instanceof Error ? err.message : err}`);
328
- await this._restorePageState(state.url, originalAria);
329
- continue;
330
- }
331
-
332
- if (diff.urlHasChanged()) {
333
- debugLog(`Click navigated to ${this.stateManager.getCurrentState()?.url}`);
334
- navigationLinks.push({ code: clickCode, url: this.stateManager.getCurrentState()?.url || '' });
335
- await (this as any).navigateTo(state.url);
336
- continue;
337
- }
338
-
339
- const clickHtmlSize = diff.htmlParts.reduce((sum, p) => sum + p.subtree.length, 0);
340
- if (!diff.ariaChanged && clickHtmlSize <= 150) {
341
- debugLog(`No changes from: ${el.description.slice(0, 80)}`);
385
+ const outcome = await this._executeAndAnalyze(el.commands, el.description, state, originalAria, this._summarizeExpanded(expandedSections));
386
+ if (outcome.status === 'navigated') {
387
+ navigationLinks.push({ code: outcome.code, url: outcome.url });
342
388
  continue;
343
389
  }
344
-
345
- const sectionMarkdown = await this._analyzeExpandedAction(clickCode, el.description, diff, this._summarizeExpanded(expandedSections));
346
- if (sectionMarkdown) {
347
- expandedSections.push(sectionMarkdown);
390
+ if (outcome.status === 'revealed') {
391
+ expandedSections.push(outcome.sectionMarkdown);
348
392
  debugLog(`Captured section from: ${el.description.slice(0, 80)}`);
349
393
  }
350
-
351
- await this._restorePageState(state.url, originalAria);
352
394
  } catch (err) {
353
395
  tag('warning').log(`Expandable click failed for "${el.description.slice(0, 80)}": ${err instanceof Error ? err.message : err}`);
354
396
  try {
@@ -358,6 +400,55 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
358
400
  }
359
401
  }
360
402
 
403
+ private async _executeAndAnalyze(commands: string[], description: string, state: WebPageState, originalAria: string, alreadyExpanded: string[]): Promise<ExpansionOutcome> {
404
+ const previousState = ActionResult.fromState(this.stateManager.getCurrentState()!);
405
+
406
+ let clickCode: string | null = null;
407
+ const action = this.explorer.createAction();
408
+ for (const cmd of commands) {
409
+ if (await action.attempt(cmd, undefined, false)) {
410
+ clickCode = cmd;
411
+ break;
412
+ }
413
+ }
414
+ if (!clickCode) {
415
+ debugLog(`Click failed: ${description.slice(0, 80)}`);
416
+ return { status: 'failed' };
417
+ }
418
+
419
+ await new Promise((r) => setTimeout(r, 500));
420
+
421
+ let diff: Diff;
422
+ try {
423
+ await this.explorer.createAction().capturePageState();
424
+ const currAR = ActionResult.fromState(this.stateManager.getCurrentState()!);
425
+ diff = await currAR.diff(previousState);
426
+ await diff.calculate();
427
+ } catch (err) {
428
+ tag('warning').log(`State capture failed after click: ${err instanceof Error ? err.message : err}`);
429
+ await this._restorePageState(state.url, originalAria);
430
+ return { status: 'failed' };
431
+ }
432
+
433
+ if (diff.urlHasChanged()) {
434
+ const url = this.stateManager.getCurrentState()?.url || '';
435
+ debugLog(`Click navigated to ${url}`);
436
+ await (this as any).navigateTo(state.url);
437
+ return { status: 'navigated', code: clickCode, url };
438
+ }
439
+
440
+ const clickHtmlSize = diff.htmlParts.reduce((sum, p) => sum + p.subtree.length, 0);
441
+ if (!diff.ariaChanged && clickHtmlSize <= 150) {
442
+ debugLog(`No changes from: ${description.slice(0, 80)}`);
443
+ return { status: 'none', code: clickCode };
444
+ }
445
+
446
+ const sectionMarkdown = await this._analyzeExpandedAction(clickCode, description, diff, alreadyExpanded);
447
+ await this._restorePageState(state.url, originalAria);
448
+ if (!sectionMarkdown) return { status: 'none', code: clickCode };
449
+ return { status: 'revealed', code: clickCode, sectionMarkdown };
450
+ }
451
+
361
452
  private async _restorePageState(url: string, originalAria: string): Promise<void> {
362
453
  try {
363
454
  await (this as any).cancelInUi();
@@ -484,6 +575,13 @@ interface ExpandableElement extends ResearchElement {
484
575
  container: string | null;
485
576
  }
486
577
 
578
+ interface PreviousSection {
579
+ name: string;
580
+ code: string;
581
+ }
582
+
583
+ type ExpansionOutcome = { status: 'revealed'; code: string; sectionMarkdown: string } | { status: 'navigated'; code: string; url: string } | { status: 'none'; code: string } | { status: 'failed' };
584
+
487
585
  export interface DeepAnalysisMethods {
488
586
  performDeepAnalysis(state: WebPageState, result: ResearchResult): Promise<void>;
489
587
  researchOverlay(current: ActionResult, previous: ActionResult, pageStateHash: string): Promise<string | null>;