explorbot 0.3.5 → 0.4.0
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/bin/explorbot-cli.ts +18 -13
- package/boat/doc-collector/src/cli.ts +3 -0
- package/boat/doc-collector/src/docbot.ts +3 -1
- package/boat/prima/src/cli.ts +21 -8
- package/boat/prima/src/envelope.ts +35 -9
- package/boat/prima/src/prima.ts +23 -10
- package/dist/bin/explorbot-cli.js +19 -13
- package/dist/boat/doc-collector/src/cli.js +3 -0
- package/dist/boat/doc-collector/src/docbot.js +3 -1
- package/dist/boat/prima/src/cli.js +19 -8
- package/dist/boat/prima/src/envelope.js +24 -6
- package/dist/boat/prima/src/prima.js +23 -11
- package/dist/package.json +2 -2
- package/dist/src/action-result.d.ts +9 -1
- package/dist/src/action-result.js +57 -18
- package/dist/src/action.d.ts +1 -1
- package/dist/src/action.js +87 -12
- package/dist/src/ai/driller.d.ts +0 -1
- package/dist/src/ai/driller.js +8 -20
- package/dist/src/ai/fisherman-tools.d.ts +9 -0
- package/dist/src/ai/fisherman-tools.js +52 -6
- package/dist/src/ai/fisherman.d.ts +4 -2
- package/dist/src/ai/fisherman.js +48 -27
- package/dist/src/ai/historian/codeceptjs.js +1 -1
- package/dist/src/ai/historian/playwright.js +1 -1
- package/dist/src/ai/pilot.d.ts +1 -0
- package/dist/src/ai/pilot.js +13 -1
- package/dist/src/ai/provider.js +20 -3
- package/dist/src/ai/researcher/deep-analysis.js +1 -3
- package/dist/src/ai/researcher.js +3 -3
- package/dist/src/ai/tester.d.ts +3 -0
- package/dist/src/ai/tester.js +40 -3
- package/dist/src/ai/tools.d.ts +1 -0
- package/dist/src/ai/tools.js +13 -6
- package/dist/src/api/request-result.d.ts +2 -0
- package/dist/src/api/request-result.js +8 -2
- package/dist/src/api/request-store.d.ts +3 -2
- package/dist/src/api/request-store.js +66 -14
- package/dist/src/commands/explore-command.d.ts +6 -0
- package/dist/src/commands/explore-command.js +27 -2
- package/dist/src/commands/freesail-command.js +10 -1
- package/dist/src/commands/plans-command.js +6 -6
- package/dist/src/config.js +1 -0
- package/dist/src/experience-tracker.js +5 -0
- package/dist/src/explorbot.d.ts +0 -1
- package/dist/src/explorbot.js +23 -36
- package/dist/src/state-manager.d.ts +5 -1
- package/dist/src/state-manager.js +10 -7
- package/dist/src/test-plan.d.ts +3 -0
- package/dist/src/test-plan.js +27 -0
- package/dist/src/utils/aria.d.ts +1 -1
- package/dist/src/utils/aria.js +6 -42
- package/dist/src/utils/html-diff.d.ts +4 -0
- package/dist/src/utils/html-diff.js +62 -7
- package/dist/src/utils/html.d.ts +5 -15
- package/dist/src/utils/html.js +14 -85
- package/dist/src/utils/overlay.d.ts +56 -11
- package/dist/src/utils/overlay.js +191 -21
- package/dist/src/utils/request-map.d.ts +7 -0
- package/dist/src/utils/request-map.js +16 -0
- package/dist/src/utils/url-matcher.js +4 -2
- package/docs/reference/commands.md +8 -1
- package/docs/reference/websocket.md +1 -0
- package/docs/superpowers/plans/2026-08-29-fisherman-reliability.md +953 -0
- package/docs/superpowers/plans/2026-08-29-region-states.md +1292 -0
- package/docs/superpowers/plans/2026-08-30-fisherman-live-session-auth.md +457 -0
- package/docs/superpowers/specs/2026-08-29-fisherman-reliability-design.md +45 -0
- package/docs/superpowers/specs/2026-08-29-region-states-design.md +262 -0
- package/docs/superpowers/specs/2026-08-29-region-states-fixes-design.md +269 -0
- package/docs/superpowers/specs/2026-08-30-fisherman-live-session-auth-design.md +37 -0
- package/docs/workflow/agentic-usage.md +1 -0
- package/docs/workflow/ci.md +1 -0
- package/package.json +2 -2
- package/src/action-result.ts +61 -22
- package/src/action.ts +87 -14
- package/src/ai/driller.ts +7 -39
- package/src/ai/fisherman-tools.ts +56 -7
- package/src/ai/fisherman.ts +48 -28
- package/src/ai/historian/codeceptjs.ts +1 -1
- package/src/ai/historian/playwright.ts +1 -1
- package/src/ai/pilot.ts +9 -1
- package/src/ai/provider.ts +21 -3
- package/src/ai/researcher/deep-analysis.ts +1 -2
- package/src/ai/researcher.ts +3 -3
- package/src/ai/tester.ts +40 -3
- package/src/ai/tools.ts +17 -9
- package/src/api/request-result.ts +10 -2
- package/src/api/request-store.ts +60 -13
- package/src/commands/explore-command.ts +25 -2
- package/src/commands/freesail-command.ts +7 -1
- package/src/commands/plans-command.ts +6 -6
- package/src/config.ts +1 -0
- package/src/experience-tracker.ts +5 -1
- package/src/explorbot.ts +20 -36
- package/src/state-manager.ts +13 -7
- package/src/test-plan.ts +29 -0
- package/src/utils/aria.ts +7 -44
- package/src/utils/html-diff.ts +62 -7
- package/src/utils/html.ts +14 -91
- package/src/utils/overlay.ts +226 -23
- package/src/utils/request-map.ts +19 -0
- package/src/utils/url-matcher.ts +3 -2
|
@@ -98,7 +98,7 @@ export function WithPlaywright<T extends Constructor>(Base: T) {
|
|
|
98
98
|
lines.push('');
|
|
99
99
|
lines.push(`test.describe('${escapeString(plan.title)}', () => {`);
|
|
100
100
|
|
|
101
|
-
const startUrl = plan.
|
|
101
|
+
const startUrl = plan.startUrl;
|
|
102
102
|
if (startUrl) {
|
|
103
103
|
lines.push(' test.beforeEach(async ({ page }) => {');
|
|
104
104
|
lines.push(` await page.goto('${escapeString(startUrl)}');`);
|
package/src/ai/pilot.ts
CHANGED
|
@@ -765,6 +765,7 @@ export class Pilot implements Agent {
|
|
|
765
765
|
const parts = [c.type];
|
|
766
766
|
if (c.title) parts.push(`"${c.title}"`);
|
|
767
767
|
if (c.id) parts.push(`(id: ${c.id})`);
|
|
768
|
+
if (c.request) parts.push(`via ${c.request}`);
|
|
768
769
|
return parts.join(' ');
|
|
769
770
|
});
|
|
770
771
|
const stepText = `Precondition: created ${items.join(', ')}`;
|
|
@@ -827,7 +828,13 @@ export class Pilot implements Agent {
|
|
|
827
828
|
|
|
828
829
|
const focusArea = state.overlay;
|
|
829
830
|
if (focusArea.detected) {
|
|
830
|
-
|
|
831
|
+
let line = `modal: ${focusArea.name || focusArea.type}`;
|
|
832
|
+
if (focusArea.root) line += ` (root: ${focusArea.root})`;
|
|
833
|
+
lines.push(line);
|
|
834
|
+
} else if (focusArea.present) {
|
|
835
|
+
let line = `region: ${focusArea.name || 'unnamed'} (inline`;
|
|
836
|
+
if (focusArea.root) line += `, root: ${focusArea.root}`;
|
|
837
|
+
lines.push(`${line})`);
|
|
831
838
|
} else {
|
|
832
839
|
lines.push('modal: none');
|
|
833
840
|
}
|
|
@@ -1133,6 +1140,7 @@ export class Pilot implements Agent {
|
|
|
1133
1140
|
Diagnostic patterns (use <state>, executed/element/skipped fields, ariaDiff):
|
|
1134
1141
|
- Click failed + button in "disabled buttons" → required field missing. Instruct fill first.
|
|
1135
1142
|
- "modal: none" but Tester targets a modal → modal closed; re-trigger.
|
|
1143
|
+
- "region:" in <state> → a large area appeared in place without navigation (subview, wizard step, panel). Direct Tester to act inside it; the rest of the page is still usable.
|
|
1136
1144
|
- Action SUCCESS but ariaDiff empty → may have worked without visible DOM change; check result message.
|
|
1137
1145
|
- MultipleElementsFound → xpathCheck() to identify the right one, then precise locator or visualClick().
|
|
1138
1146
|
- Wrong page (settings vs feature) → getVisitedStates() then back() or reset(). Don't try breadcrumbs (SPA back-nav is unreliable).
|
package/src/ai/provider.ts
CHANGED
|
@@ -426,10 +426,20 @@ export class Provider {
|
|
|
426
426
|
const config = this.buildGenerateConfig({ tools: toolsWithCommentary, maxOutputTokens: 16384, toolChoice: 'auto', experimental_repairToolCall: repairToolCall }, { stopWhen: stopConditions, model }, options);
|
|
427
427
|
let attemptMessages = messages;
|
|
428
428
|
let invalidRequestFeedbackAdded = false;
|
|
429
|
+
const executedStepMessages: ModelMessage[] = [];
|
|
429
430
|
try {
|
|
430
431
|
const response = await this.withModelRequestSlot(() =>
|
|
431
432
|
withRetry(async () => {
|
|
432
|
-
const
|
|
433
|
+
const stepMessages: ModelMessage[] = [];
|
|
434
|
+
const onStepEnd = (step: any) => {
|
|
435
|
+
stepMessages.push(...(step.response?.messages || []));
|
|
436
|
+
};
|
|
437
|
+
const result = (await this.raceWithIdleTimeout((signal) => generateText({ messages: attemptMessages, ...config, abortSignal: signal, onStepEnd }), config.timeout || 30000).catch((error) => {
|
|
438
|
+
if (stepMessages.length > 0) {
|
|
439
|
+
tag('warning').log(`Keeping ${stepMessages.length} messages from tool steps that already ran before the failure`);
|
|
440
|
+
executedStepMessages.push(...stepMessages);
|
|
441
|
+
attemptMessages = [...attemptMessages, ...stepMessages];
|
|
442
|
+
}
|
|
433
443
|
if (!invalidRequestFeedbackAdded) {
|
|
434
444
|
const amended = withInvalidRequestFeedback(attemptMessages, error);
|
|
435
445
|
invalidRequestFeedbackAdded = amended !== attemptMessages;
|
|
@@ -448,6 +458,8 @@ export class Provider {
|
|
|
448
458
|
|
|
449
459
|
clearActivity();
|
|
450
460
|
|
|
461
|
+
withExecutedSteps(response, executedStepMessages);
|
|
462
|
+
|
|
451
463
|
// Log tool usage summary
|
|
452
464
|
if (response.toolCalls && response.toolCalls.length > 0) {
|
|
453
465
|
responseLog(response.toolCalls);
|
|
@@ -462,12 +474,13 @@ export class Provider {
|
|
|
462
474
|
} catch (error: any) {
|
|
463
475
|
clearActivity();
|
|
464
476
|
if (error?.message?.includes('Tool choice is required')) {
|
|
465
|
-
return { text: '', toolCalls: [], toolResults: [],
|
|
477
|
+
return { text: '', toolCalls: [], toolResults: [], responseMessages: executedStepMessages, usage: null };
|
|
466
478
|
}
|
|
467
479
|
if (error?.name === 'AbortError') throw error;
|
|
468
480
|
if (error instanceof ContextLengthError) throw error;
|
|
469
481
|
if (Provider.isContextLengthError(error)) {
|
|
470
|
-
|
|
482
|
+
const recovered = await this.recoverFromContextLength(error, attemptMessages, options, (m, o) => this.generateWithTools(m, model, tools, o));
|
|
483
|
+
return withExecutedSteps(recovered, executedStepMessages);
|
|
471
484
|
}
|
|
472
485
|
if (error.constructor?.name === 'AI_APICallError') {
|
|
473
486
|
responseLog(error.message);
|
|
@@ -703,6 +716,11 @@ function repairToolCall(options: ToolCallRepairOptions): any | null {
|
|
|
703
716
|
return repairHarmonyChannel(options);
|
|
704
717
|
}
|
|
705
718
|
|
|
719
|
+
function withExecutedSteps(result: any, executed: ModelMessage[]): any {
|
|
720
|
+
if (executed.length === 0) return result;
|
|
721
|
+
return Object.defineProperty(result, 'responseMessages', { value: [...executed, ...(result.responseMessages || [])], configurable: true, enumerable: true });
|
|
722
|
+
}
|
|
723
|
+
|
|
706
724
|
function withInvalidRequestFeedback(messages: ModelMessage[], error: unknown): ModelMessage[] {
|
|
707
725
|
if (!(error instanceof APICallError) || error.statusCode !== 400) return messages;
|
|
708
726
|
tag('warning').log('Provider rejected the request as invalid — relaying its reason before the retry');
|
|
@@ -89,8 +89,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
|
|
|
89
89
|
|
|
90
90
|
async researchOverlay(current: ActionResult, previous: ActionResult, pageStateHash: string): Promise<string | null> {
|
|
91
91
|
const focusArea = current.overlay;
|
|
92
|
-
if (!focusArea.
|
|
93
|
-
if (focusArea.type !== 'dialog' && focusArea.type !== 'modal') return null;
|
|
92
|
+
if (!focusArea.present || !focusArea.name) return null;
|
|
94
93
|
|
|
95
94
|
const cached = getCachedResearch(pageStateHash);
|
|
96
95
|
if (!cached) return null;
|
package/src/ai/researcher.ts
CHANGED
|
@@ -76,7 +76,7 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
static getCachedResearch(state: WebPageState): string {
|
|
79
|
-
return getCachedResearch(state.
|
|
79
|
+
return getCachedResearch(ActionResult.fromState(state).baseHash);
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
getSystemMessage(): string {
|
|
@@ -96,7 +96,7 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
96
96
|
const maxRetries = (this.config.ai?.agents?.researcher as any)?.retries ?? 2;
|
|
97
97
|
let retriesLeft = opts._retriesLeft ?? maxRetries;
|
|
98
98
|
this.actionResult = ActionResult.fromState(state);
|
|
99
|
-
const stateHash =
|
|
99
|
+
const stateHash = this.actionResult.baseHash;
|
|
100
100
|
const researchState = { ...state, hash: stateHash };
|
|
101
101
|
|
|
102
102
|
if (!force && stateHash) {
|
|
@@ -268,7 +268,7 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
268
268
|
|
|
269
269
|
if (!interrupted() && deep) {
|
|
270
270
|
try {
|
|
271
|
-
await this.performDeepAnalysis(
|
|
271
|
+
await this.performDeepAnalysis(researchState, result);
|
|
272
272
|
} catch (err) {
|
|
273
273
|
tag('warning').log(`Deep analysis failed, continuing with best-effort research: ${err instanceof Error ? err.message : err}`);
|
|
274
274
|
}
|
package/src/ai/tester.ts
CHANGED
|
@@ -63,6 +63,8 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
63
63
|
private seenUiMapUrls = new Set<string>();
|
|
64
64
|
private lastAnalyzedStateHash: string | null = null;
|
|
65
65
|
private stalledIterations = 0;
|
|
66
|
+
private previousRegionPresent: boolean | null = null;
|
|
67
|
+
private regionTransitioned = false;
|
|
66
68
|
private readonly MAX_STALLED_ITERATIONS = 3;
|
|
67
69
|
|
|
68
70
|
private skipResearch = (err: Error): string => {
|
|
@@ -117,6 +119,8 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
117
119
|
this.seenUiMapUrls.clear();
|
|
118
120
|
this.lastAnalyzedStateHash = null;
|
|
119
121
|
this.stalledIterations = 0;
|
|
122
|
+
this.previousRegionPresent = null;
|
|
123
|
+
this.regionTransitioned = false;
|
|
120
124
|
this.stateManager.clearHistory();
|
|
121
125
|
this.resetFailureCount();
|
|
122
126
|
this.pilot?.reset();
|
|
@@ -246,6 +250,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
246
250
|
const codeceptjsTools = createCodeceptJSTools(this.toolDeps, task);
|
|
247
251
|
let assertionPerformed = false;
|
|
248
252
|
let extensions = 0;
|
|
253
|
+
let deadlineReached = false;
|
|
249
254
|
let shouldContinue = true;
|
|
250
255
|
|
|
251
256
|
while (shouldContinue) {
|
|
@@ -253,6 +258,12 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
253
258
|
|
|
254
259
|
await loop(
|
|
255
260
|
async ({ stop, pause, iteration, userInput }) => {
|
|
261
|
+
if (opts.deadline != null && Date.now() >= opts.deadline) {
|
|
262
|
+
deadlineReached = true;
|
|
263
|
+
task.addNote('Time budget reached. Stopped');
|
|
264
|
+
stop();
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
256
267
|
debugLog('iteration', iteration);
|
|
257
268
|
if (!(await this.explorer.recover()).ok) {
|
|
258
269
|
task.addNote('Browser page is unavailable');
|
|
@@ -420,6 +431,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
420
431
|
);
|
|
421
432
|
|
|
422
433
|
if (task.hasFinished) break;
|
|
434
|
+
if (deadlineReached) break;
|
|
423
435
|
|
|
424
436
|
if (!(await this.explorer.recover()).ok) break;
|
|
425
437
|
|
|
@@ -460,6 +472,10 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
460
472
|
}
|
|
461
473
|
|
|
462
474
|
private shouldAnalyzeProgress(iteration: number, currentState: ActionResult): boolean {
|
|
475
|
+
if (this.regionTransitioned) {
|
|
476
|
+
this.regionTransitioned = false;
|
|
477
|
+
return true;
|
|
478
|
+
}
|
|
463
479
|
if (this.consecutiveFailures >= 3) return true;
|
|
464
480
|
if (this.consecutiveEmptyResults >= 2) return true;
|
|
465
481
|
if (iteration % this.progressCheckInterval !== 0) return false;
|
|
@@ -530,6 +546,12 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
530
546
|
const currentStateHash = currentState.hash;
|
|
531
547
|
|
|
532
548
|
const isNewUrl = this.previousUrl !== currentUrl;
|
|
549
|
+
const isNewState = !isNewUrl && this.previousStateHash !== null && this.previousStateHash !== currentStateHash;
|
|
550
|
+
|
|
551
|
+
if (this.previousRegionPresent !== null && this.previousRegionPresent !== currentState.overlay.present) {
|
|
552
|
+
this.regionTransitioned = true;
|
|
553
|
+
}
|
|
554
|
+
this.previousRegionPresent = currentState.overlay.present;
|
|
533
555
|
|
|
534
556
|
this.previousUrl = currentUrl;
|
|
535
557
|
this.previousStateHash = currentStateHash;
|
|
@@ -557,9 +579,11 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
557
579
|
|
|
558
580
|
if (focusArea.detected) {
|
|
559
581
|
const areaName = focusArea.name ? ` "${focusArea.name}"` : '';
|
|
582
|
+
let rootHint = '';
|
|
583
|
+
if (focusArea.root) rootHint = `\nIts content lives inside \`${focusArea.root}\` — scope locators to it.`;
|
|
560
584
|
context += dedent`
|
|
561
585
|
<focus_scope>
|
|
562
|
-
A ${focusArea.type}${areaName} is currently open above the page
|
|
586
|
+
A ${focusArea.type}${areaName} is currently open above the page.${rootHint}
|
|
563
587
|
Scope all interactions to elements inside this ${focusArea.type}.
|
|
564
588
|
Page navigation, filters, and tabs that exist outside it are not actionable while it is open and may share names or roles with elements inside it — prefer the locator inside the ${focusArea.type}.
|
|
565
589
|
Use <page_aria> to confirm the element you target is actually inside the ${focusArea.type}.
|
|
@@ -567,6 +591,18 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
567
591
|
`;
|
|
568
592
|
}
|
|
569
593
|
|
|
594
|
+
if (!focusArea.detected && focusArea.present && isNewState) {
|
|
595
|
+
let rootHint = '';
|
|
596
|
+
if (focusArea.root) rootHint = `\nIt lives inside \`${focusArea.root}\`.`;
|
|
597
|
+
context += dedent`
|
|
598
|
+
<area_of_interest>
|
|
599
|
+
A large new area "${focusArea.name || 'unnamed area'}" appeared on this page without navigation.${rootHint}
|
|
600
|
+
The scenario most likely continues inside this area — prefer its elements for your next actions.
|
|
601
|
+
The rest of the page (navigation, menus, filters) is still interactive and remains available.
|
|
602
|
+
</area_of_interest>
|
|
603
|
+
`;
|
|
604
|
+
}
|
|
605
|
+
|
|
570
606
|
if (currentState.isInsideIframe) {
|
|
571
607
|
const iframeInfo = currentState.iframeURL || 'iframe context active';
|
|
572
608
|
context += dedent`
|
|
@@ -589,7 +625,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
589
625
|
if (!alreadySeenUiMap) {
|
|
590
626
|
research = await this.researcher.research(currentState).catch(this.skipResearch);
|
|
591
627
|
}
|
|
592
|
-
this.pageStateHash =
|
|
628
|
+
this.pageStateHash = currentState.baseHash;
|
|
593
629
|
this.pageActionResult = currentState;
|
|
594
630
|
let uiMapSection = '';
|
|
595
631
|
if (research) {
|
|
@@ -627,7 +663,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
627
663
|
return context;
|
|
628
664
|
}
|
|
629
665
|
|
|
630
|
-
if (focusArea.
|
|
666
|
+
if (focusArea.present && focusArea.name && this.pageStateHash && this.pageActionResult) {
|
|
631
667
|
const overlaySection = await this.researcher.researchOverlay(currentState, this.pageActionResult, this.pageStateHash).catch(this.skipResearch);
|
|
632
668
|
if (overlaySection) {
|
|
633
669
|
context += dedent`
|
|
@@ -1172,4 +1208,5 @@ interface TestSessionHandlers {
|
|
|
1172
1208
|
|
|
1173
1209
|
export interface TestOptions {
|
|
1174
1210
|
startOnCurrentPage?: boolean;
|
|
1211
|
+
deadline?: number;
|
|
1175
1212
|
}
|
package/src/ai/tools.ts
CHANGED
|
@@ -8,8 +8,9 @@ import { Stats } from '../stats.ts';
|
|
|
8
8
|
import { type Task, TestResult } from '../test-plan.js';
|
|
9
9
|
import { LARGE_ARIA_CHANGE_THRESHOLD } from '../utils/aria.ts';
|
|
10
10
|
import { isFatalBrowserError } from '../utils/browser-errors.ts';
|
|
11
|
+
import { cleanHtmlSnippet } from '../utils/html.ts';
|
|
11
12
|
import { createDebug, tag } from '../utils/logger.js';
|
|
12
|
-
import { compactErrorMessage } from '../utils/strings.ts';
|
|
13
|
+
import { compactErrorMessage, normalizeInlineText, truncate } from '../utils/strings.ts';
|
|
13
14
|
import { pause } from '../utils/loop.js';
|
|
14
15
|
import { WebElement } from '../utils/web-element.ts';
|
|
15
16
|
import type { ToolDeps } from './agent.ts';
|
|
@@ -1332,20 +1333,23 @@ export function clickFailureSuggestion(attempts: Array<{ error?: string }>): str
|
|
|
1332
1333
|
}
|
|
1333
1334
|
|
|
1334
1335
|
const MAX_DISAMBIGUATE_ELEMENTS = 10;
|
|
1336
|
+
const MAX_DISAMBIGUATE_TEXT = 80;
|
|
1337
|
+
const MAX_DISAMBIGUATE_HTML = 300;
|
|
1335
1338
|
const MULTIPLE_ELEMENTS_PATTERN = 'multiple elements';
|
|
1336
1339
|
|
|
1337
|
-
async function extractWebElements(error: Error | null | undefined): Promise<Array<{ xpath: string; html: string }> | null> {
|
|
1340
|
+
async function extractWebElements(error: Error | null | undefined): Promise<Array<{ xpath: string; html: string; text: string }> | null> {
|
|
1338
1341
|
if (!error || error.name !== 'MultipleElementsFound') return null;
|
|
1339
1342
|
|
|
1340
|
-
const elements = (error as any).webElements as Array<{ toAbsoluteXPath: () => Promise<string>;
|
|
1343
|
+
const elements = (error as any).webElements as Array<{ toAbsoluteXPath: () => Promise<string>; toOuterHTML: () => Promise<string>; getText: () => Promise<string | null> }> | undefined;
|
|
1341
1344
|
if (!elements?.length) return null;
|
|
1342
1345
|
|
|
1343
|
-
const result: Array<{ xpath: string; html: string }> = [];
|
|
1346
|
+
const result: Array<{ xpath: string; html: string; text: string }> = [];
|
|
1344
1347
|
for (let i = 0; i < Math.min(elements.length, MAX_DISAMBIGUATE_ELEMENTS); i++) {
|
|
1345
1348
|
try {
|
|
1346
1349
|
const xpath = await elements[i].toAbsoluteXPath();
|
|
1347
|
-
const html = await elements[i].
|
|
1348
|
-
|
|
1350
|
+
const html = truncate(cleanHtmlSnippet(await elements[i].toOuterHTML()), MAX_DISAMBIGUATE_HTML);
|
|
1351
|
+
const text = truncate(normalizeInlineText((await elements[i].getText()) || ''), MAX_DISAMBIGUATE_TEXT);
|
|
1352
|
+
result.push({ xpath, html, text });
|
|
1349
1353
|
} catch (e) {
|
|
1350
1354
|
debugLog('Failed to get details for element %d: %s', i, e);
|
|
1351
1355
|
}
|
|
@@ -1353,17 +1357,21 @@ async function extractWebElements(error: Error | null | undefined): Promise<Arra
|
|
|
1353
1357
|
return result.length > 0 ? result : null;
|
|
1354
1358
|
}
|
|
1355
1359
|
|
|
1356
|
-
|
|
1360
|
+
function formatElementList(details: Array<{ xpath: string; html: string; text: string }>): string {
|
|
1361
|
+
return details.map((el, i) => `Element ${i + 1}:\nText: "${el.text}"\nXPath: ${el.xpath}\nHTML: ${el.html}`).join('\n\n');
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
export async function formatMatchedElements(error: Error | null | undefined): Promise<string | null> {
|
|
1357
1365
|
const details = await extractWebElements(error);
|
|
1358
1366
|
if (!details) return 'Could not fetch element details. Repeat the action to get better info.';
|
|
1359
|
-
return details
|
|
1367
|
+
return formatElementList(details);
|
|
1360
1368
|
}
|
|
1361
1369
|
|
|
1362
1370
|
async function disambiguateElements(error: Error | null | undefined, explanation: string, provider: AIProvider): Promise<{ position: number; xpath: string } | null> {
|
|
1363
1371
|
const elementDetails = await extractWebElements(error);
|
|
1364
1372
|
if (!elementDetails) return null;
|
|
1365
1373
|
|
|
1366
|
-
const elementList = elementDetails
|
|
1374
|
+
const elementList = formatElementList(elementDetails);
|
|
1367
1375
|
|
|
1368
1376
|
const schema = z.object({
|
|
1369
1377
|
position: z.number().nullable().describe('1-based position of the correct element, or null if none match'),
|
|
@@ -85,6 +85,10 @@ export class RequestResult {
|
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
get isWrite(): boolean {
|
|
89
|
+
return ['POST', 'PUT', 'PATCH', 'DELETE'].includes(this.method);
|
|
90
|
+
}
|
|
91
|
+
|
|
88
92
|
save(outputDir: string): void {
|
|
89
93
|
const requestsDir = path.join(outputDir, 'requests');
|
|
90
94
|
if (!existsSync(requestsDir)) {
|
|
@@ -169,7 +173,7 @@ export class RequestResult {
|
|
|
169
173
|
statusText: meta.statusText || '',
|
|
170
174
|
responseHeaders: meta.responseHeaders || {},
|
|
171
175
|
timing: Number.parseInt(meta.timing) || 0,
|
|
172
|
-
timestamp: new Date(meta.timestamp ||
|
|
176
|
+
timestamp: new Date(meta.timestamp || 0),
|
|
173
177
|
});
|
|
174
178
|
|
|
175
179
|
result.requestFile = requestFile;
|
|
@@ -178,8 +182,12 @@ export class RequestResult {
|
|
|
178
182
|
return result;
|
|
179
183
|
}
|
|
180
184
|
|
|
185
|
+
toEndpoint(): string {
|
|
186
|
+
return `${this.method} ${this.path}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
181
189
|
toSummary(): string {
|
|
182
|
-
return `${this.
|
|
190
|
+
return `${this.toEndpoint()} → ${this.status} (${this.timing}ms)`;
|
|
183
191
|
}
|
|
184
192
|
|
|
185
193
|
extractIdAndTitle(): { id?: string | number; title?: string } {
|
package/src/api/request-store.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { existsSync, readdirSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { isDynamicSegment } from '../utils/url-matcher.ts';
|
|
3
4
|
import { RequestResult } from './request-result.ts';
|
|
4
5
|
|
|
5
|
-
const AUTH_HEADERS = ['authorization', '
|
|
6
|
+
const AUTH_HEADERS = ['authorization', 'x-api-key', 'x-csrf-token'];
|
|
6
7
|
|
|
7
8
|
export class RequestStore {
|
|
8
9
|
private capturedRequests: RequestResult[] = [];
|
|
@@ -10,6 +11,7 @@ export class RequestStore {
|
|
|
10
11
|
private failedRequests: RequestResult[] = [];
|
|
11
12
|
private onFailedListeners: Array<(r: RequestResult) => void> = [];
|
|
12
13
|
private outputDir: string;
|
|
14
|
+
private sessionStartedAt = new Date();
|
|
13
15
|
|
|
14
16
|
constructor(outputDir: string) {
|
|
15
17
|
this.outputDir = outputDir;
|
|
@@ -77,13 +79,15 @@ export class RequestStore {
|
|
|
77
79
|
return this.madeRequests.filter((r) => r.status === status);
|
|
78
80
|
}
|
|
79
81
|
|
|
80
|
-
toEndpointList(): string {
|
|
82
|
+
toEndpointList(scopePath?: string): string {
|
|
83
|
+
let requests = this.capturedRequests;
|
|
84
|
+
if (scopePath) requests = this.getWriteRequestsForScope(scopePath);
|
|
85
|
+
|
|
81
86
|
const seen = new Set<string>();
|
|
82
87
|
const lines: string[] = [];
|
|
83
88
|
|
|
84
|
-
for (const req of
|
|
85
|
-
const
|
|
86
|
-
const key = `${req.method} ${normalized}`;
|
|
89
|
+
for (const req of requests) {
|
|
90
|
+
const key = `${req.method} ${normalizePathPattern(req.path)}`;
|
|
87
91
|
if (seen.has(key)) continue;
|
|
88
92
|
seen.add(key);
|
|
89
93
|
lines.push(key);
|
|
@@ -94,23 +98,42 @@ export class RequestStore {
|
|
|
94
98
|
|
|
95
99
|
extractAuthHeaders(): Record<string, string> {
|
|
96
100
|
const headers: Record<string, string> = {};
|
|
101
|
+
const sessionCaptures = this.capturedRequests.filter((r) => r.timestamp >= this.sessionStartedAt).sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
|
97
102
|
|
|
98
|
-
for (
|
|
99
|
-
const req = this.capturedRequests[i];
|
|
103
|
+
for (const req of sessionCaptures) {
|
|
100
104
|
for (const [key, value] of Object.entries(req.requestHeaders)) {
|
|
101
105
|
if (AUTH_HEADERS.includes(key.toLowerCase()) && !headers[key]) {
|
|
102
106
|
headers[key] = value;
|
|
103
107
|
}
|
|
104
108
|
}
|
|
105
|
-
if (AUTH_HEADERS.every((h) => Object.keys(headers).some((k) => k.toLowerCase() === h))) break;
|
|
106
109
|
}
|
|
107
110
|
|
|
108
111
|
return headers;
|
|
109
112
|
}
|
|
110
113
|
|
|
111
|
-
findCapturedRequest(method: string,
|
|
114
|
+
findCapturedRequest(method: string, searchPath: string): RequestResult | undefined {
|
|
112
115
|
const upper = method.toUpperCase();
|
|
113
|
-
|
|
116
|
+
const search = normalizePathPattern(searchPath).split('/').filter(Boolean);
|
|
117
|
+
|
|
118
|
+
let best: RequestResult | undefined;
|
|
119
|
+
let bestScore = -1;
|
|
120
|
+
|
|
121
|
+
for (const req of this.capturedRequests) {
|
|
122
|
+
if (req.method !== upper) continue;
|
|
123
|
+
const segments = normalizePathPattern(req.path).split('/').filter(Boolean);
|
|
124
|
+
if (segments.length < search.length) continue;
|
|
125
|
+
if (!search.every((segment, i) => segment === segments[i])) continue;
|
|
126
|
+
|
|
127
|
+
let score = 0;
|
|
128
|
+
if (segments.length === search.length) score += 4;
|
|
129
|
+
if (req.status < 400) score += 2;
|
|
130
|
+
if (score < bestScore) continue;
|
|
131
|
+
if (score === bestScore && best && req.timestamp <= best.timestamp) continue;
|
|
132
|
+
best = req;
|
|
133
|
+
bestScore = score;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return best;
|
|
114
137
|
}
|
|
115
138
|
|
|
116
139
|
toLog(): string {
|
|
@@ -122,7 +145,7 @@ export class RequestStore {
|
|
|
122
145
|
if (!existsSync(requestsDir)) return;
|
|
123
146
|
|
|
124
147
|
const existingIds = new Set(this.capturedRequests.map((r) => r.id));
|
|
125
|
-
const files = readdirSync(requestsDir).filter((f) => f.endsWith('.request.yaml'));
|
|
148
|
+
const files = readdirSync(requestsDir).filter((f) => f.startsWith('xhr_') && f.endsWith('.request.yaml'));
|
|
126
149
|
|
|
127
150
|
for (const file of files) {
|
|
128
151
|
try {
|
|
@@ -137,7 +160,28 @@ export class RequestStore {
|
|
|
137
160
|
|
|
138
161
|
getWriteRequestsForScope(scopePath: string): RequestResult[] {
|
|
139
162
|
const writeMethods = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
140
|
-
|
|
163
|
+
const writes = this.capturedRequests.filter((r) => writeMethods.has(r.method));
|
|
164
|
+
const scopeSegments = scopePath.split('/').filter(Boolean);
|
|
165
|
+
if (scopeSegments.length === 0) return writes;
|
|
166
|
+
|
|
167
|
+
let scoped: RequestResult[] = [];
|
|
168
|
+
let fewest = Number.POSITIVE_INFINITY;
|
|
169
|
+
let ambiguous = false;
|
|
170
|
+
for (const segment of scopeSegments) {
|
|
171
|
+
if (isDynamicSegment(segment)) continue;
|
|
172
|
+
const matches = writes.filter((r) => r.path.split('/').includes(segment));
|
|
173
|
+
if (matches.length === 0 || matches.length > fewest) continue;
|
|
174
|
+
if (matches.length === fewest) {
|
|
175
|
+
if (!scoped.every((r, i) => r.id === matches[i].id)) ambiguous = true;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
scoped = matches;
|
|
179
|
+
fewest = matches.length;
|
|
180
|
+
ambiguous = false;
|
|
181
|
+
}
|
|
182
|
+
if (ambiguous) return [];
|
|
183
|
+
|
|
184
|
+
return scoped;
|
|
141
185
|
}
|
|
142
186
|
|
|
143
187
|
clear(): void {
|
|
@@ -148,5 +192,8 @@ export class RequestStore {
|
|
|
148
192
|
}
|
|
149
193
|
|
|
150
194
|
function normalizePathPattern(urlPath: string): string {
|
|
151
|
-
return urlPath
|
|
195
|
+
return urlPath
|
|
196
|
+
.split('/')
|
|
197
|
+
.map((segment) => (segment && isDynamicSegment(segment) ? '{id}' : segment))
|
|
198
|
+
.join('/');
|
|
152
199
|
}
|
|
@@ -14,12 +14,15 @@ import { BaseCommand, type Suggestion } from './base-command.js';
|
|
|
14
14
|
|
|
15
15
|
const MAX_SUB_PAGE_ATTEMPTS = 30;
|
|
16
16
|
const PRIORITY_ORDER: Record<string, number> = { critical: 0, important: 1, high: 2, normal: 3, low: 4 };
|
|
17
|
+
export const DEADLINE_RESERVE_MS = 3 * 60_000;
|
|
18
|
+
export const DEADLINE_TEST_ALLOWANCE_MS = 5 * 60_000;
|
|
17
19
|
|
|
18
20
|
export class ExploreCommand extends BaseCommand {
|
|
19
21
|
name = 'explore';
|
|
20
22
|
description = 'Start web exploration';
|
|
21
23
|
options = [
|
|
22
24
|
{ flags: '--max-tests <number>', description: 'Maximum number of tests to run' },
|
|
25
|
+
{ flags: '--max-duration <minutes>', description: 'Wall-clock budget in minutes; wraps up the session before the limit is hit' },
|
|
23
26
|
{ flags: '--focus <feature>', description: 'Focus area for exploration' },
|
|
24
27
|
{ flags: '--configure <spec>', description: 'Reuse spec: keys new|from|style|subpages|pick_by|priority, e.g. "new:25%;pick_by=random;priority=critical,high"' },
|
|
25
28
|
{ flags: '--dry-run', description: 'Mark picked tests as skipped without executing or generating new ones' },
|
|
@@ -31,8 +34,11 @@ export class ExploreCommand extends BaseCommand {
|
|
|
31
34
|
];
|
|
32
35
|
|
|
33
36
|
maxTests?: number;
|
|
37
|
+
maxDurationMinutes?: number;
|
|
38
|
+
hardDeadlineAt?: number;
|
|
34
39
|
dryRun = false;
|
|
35
40
|
private testsRun = 0;
|
|
41
|
+
private deadlineLogged = false;
|
|
36
42
|
private completedPlans: Plan[] = [];
|
|
37
43
|
private failedSubPages = new Set<string>();
|
|
38
44
|
private oldTestRefs = new Set<Test>();
|
|
@@ -48,6 +54,12 @@ export class ExploreCommand extends BaseCommand {
|
|
|
48
54
|
if (opts.maxTests) {
|
|
49
55
|
this.maxTests = Number.parseInt(opts.maxTests as string, 10);
|
|
50
56
|
}
|
|
57
|
+
if (opts.maxDuration) {
|
|
58
|
+
this.maxDurationMinutes = Number.parseInt(opts.maxDuration as string, 10);
|
|
59
|
+
}
|
|
60
|
+
if (this.hardDeadlineAt == null && this.maxDurationMinutes != null) {
|
|
61
|
+
this.hardDeadlineAt = Date.now() + this.maxDurationMinutes * 60_000 - DEADLINE_RESERVE_MS;
|
|
62
|
+
}
|
|
51
63
|
|
|
52
64
|
const feature = (opts.focus as string) || remaining.join(' ') || undefined;
|
|
53
65
|
const cfg = this.parseConfigure(opts.configure as string | undefined);
|
|
@@ -526,7 +538,18 @@ export class ExploreCommand extends BaseCommand {
|
|
|
526
538
|
}
|
|
527
539
|
|
|
528
540
|
private isLimitReached(): boolean {
|
|
529
|
-
|
|
541
|
+
if (this.maxTests != null && this.testsRun >= this.maxTests) return true;
|
|
542
|
+
return this.isDeadlineReached();
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
private isDeadlineReached(): boolean {
|
|
546
|
+
if (this.hardDeadlineAt == null) return false;
|
|
547
|
+
if (Date.now() < this.hardDeadlineAt - DEADLINE_TEST_ALLOWANCE_MS) return false;
|
|
548
|
+
if (!this.deadlineLogged) {
|
|
549
|
+
this.deadlineLogged = true;
|
|
550
|
+
tag('info').log(`Time budget reached after ${this.testsRun} test(s): stopping new work and finishing the session`);
|
|
551
|
+
}
|
|
552
|
+
return true;
|
|
530
553
|
}
|
|
531
554
|
|
|
532
555
|
private async runPendingTests(): Promise<void> {
|
|
@@ -548,7 +571,7 @@ export class ExploreCommand extends BaseCommand {
|
|
|
548
571
|
test.start();
|
|
549
572
|
test.finish(TestResult.SKIPPED);
|
|
550
573
|
} else {
|
|
551
|
-
await this.explorBot.agentTester().test(test);
|
|
574
|
+
await this.explorBot.agentTester().test(test, { deadline: this.hardDeadlineAt });
|
|
552
575
|
}
|
|
553
576
|
this.testsRun++;
|
|
554
577
|
}
|
|
@@ -4,7 +4,7 @@ import { Stats } from '../stats.js';
|
|
|
4
4
|
import { tag } from '../utils/logger.js';
|
|
5
5
|
import { loop } from '../utils/loop.js';
|
|
6
6
|
import { BaseCommand } from './base-command.js';
|
|
7
|
-
import { ExploreCommand } from './explore-command.js';
|
|
7
|
+
import { DEADLINE_RESERVE_MS, DEADLINE_TEST_ALLOWANCE_MS, ExploreCommand } from './explore-command.js';
|
|
8
8
|
|
|
9
9
|
export class FreesailCommand extends BaseCommand {
|
|
10
10
|
name = 'freesail';
|
|
@@ -16,6 +16,7 @@ export class FreesailCommand extends BaseCommand {
|
|
|
16
16
|
{ flags: '--shallow', description: 'Use shallow navigation strategy' },
|
|
17
17
|
{ flags: '--scope <url>', description: 'Limit navigation to URLs starting with this prefix' },
|
|
18
18
|
{ flags: '--max-tests <number>', description: 'Maximum number of tests to run' },
|
|
19
|
+
{ flags: '--max-duration <number>', description: 'Wall-clock budget in minutes for the whole run' },
|
|
19
20
|
];
|
|
20
21
|
|
|
21
22
|
async execute(args: string): Promise<void> {
|
|
@@ -26,6 +27,9 @@ export class FreesailCommand extends BaseCommand {
|
|
|
26
27
|
if (opts.shallow) strategy = 'shallow';
|
|
27
28
|
const scope = opts.scope as string | undefined;
|
|
28
29
|
const maxTests = opts.maxTests ? Number.parseInt(opts.maxTests as string, 10) : undefined;
|
|
30
|
+
const maxDuration = opts.maxDuration ? Number.parseInt(opts.maxDuration as string, 10) : undefined;
|
|
31
|
+
let hardDeadlineAt: number | undefined;
|
|
32
|
+
if (maxDuration != null) hardDeadlineAt = Date.now() + maxDuration * 60_000 - DEADLINE_RESERVE_MS;
|
|
29
33
|
|
|
30
34
|
await this.explorBot.visitInitialState();
|
|
31
35
|
|
|
@@ -34,6 +38,7 @@ export class FreesailCommand extends BaseCommand {
|
|
|
34
38
|
await loop(
|
|
35
39
|
async (ctx) => {
|
|
36
40
|
if (maxTests != null && testsRun >= maxTests) ctx.stop();
|
|
41
|
+
if (hardDeadlineAt != null && Date.now() >= hardDeadlineAt - DEADLINE_TEST_ALLOWANCE_MS) ctx.stop();
|
|
37
42
|
|
|
38
43
|
const stateManager = this.explorBot.stateManager();
|
|
39
44
|
const state = stateManager.getCurrentState();
|
|
@@ -48,6 +53,7 @@ export class FreesailCommand extends BaseCommand {
|
|
|
48
53
|
} else {
|
|
49
54
|
const exploreCmd = new ExploreCommand(this.explorBot);
|
|
50
55
|
if (maxTests != null) exploreCmd.maxTests = maxTests - testsRun;
|
|
56
|
+
if (hardDeadlineAt != null) exploreCmd.hardDeadlineAt = hardDeadlineAt;
|
|
51
57
|
await exploreCmd.execute('');
|
|
52
58
|
|
|
53
59
|
const plan = this.explorBot.getCurrentPlan();
|
|
@@ -79,15 +79,15 @@ export class PlansCommand extends BaseCommand {
|
|
|
79
79
|
return file;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
const
|
|
83
|
-
if (!
|
|
84
|
-
throw new Error(`Plan file not found: ${
|
|
82
|
+
const plan = Plan.loadFromFile(target, this.explorBot.getPlansDir());
|
|
83
|
+
if (!plan?.filePath) {
|
|
84
|
+
throw new Error(`Plan file not found: ${target}`);
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
return {
|
|
88
|
-
name: path.basename(
|
|
89
|
-
path:
|
|
90
|
-
modifiedAt: statSync(
|
|
88
|
+
name: path.basename(plan.filePath),
|
|
89
|
+
path: plan.filePath,
|
|
90
|
+
modifiedAt: statSync(plan.filePath).mtimeMs,
|
|
91
91
|
};
|
|
92
92
|
}
|
|
93
93
|
}
|
package/src/config.ts
CHANGED
|
@@ -273,6 +273,7 @@ export const EXPLORBOT_ENV_VARS: EnvVar[] = [
|
|
|
273
273
|
{ name: 'EXPLORBOT_KNOWLEDGE_FILE', description: 'Path to a knowledge markdown file' },
|
|
274
274
|
{ name: 'EXPLORBOT_API_SPEC', description: 'OpenAPI spec path for the API boat' },
|
|
275
275
|
{ name: 'EXPLORBOT_NO_BANNER', description: 'Suppress the startup banner, for machine-readable output' },
|
|
276
|
+
{ name: 'EXPLORBOT_MAX_DURATION', description: 'Wall-clock budget in minutes for an explore run; same as --max-duration' },
|
|
276
277
|
];
|
|
277
278
|
|
|
278
279
|
export type {
|
|
@@ -123,10 +123,14 @@ export class ExperienceTracker {
|
|
|
123
123
|
const filePath = this.getExperienceFilePath(stateHash);
|
|
124
124
|
|
|
125
125
|
if (!existsSync(filePath)) {
|
|
126
|
-
const frontmatter = {
|
|
126
|
+
const frontmatter: Record<string, unknown> = {
|
|
127
127
|
url: state.url ? extractStatePath(state.url) : '',
|
|
128
128
|
title: state.title,
|
|
129
129
|
};
|
|
130
|
+
if (state.overlay.present && state.overlay.name) {
|
|
131
|
+
frontmatter.region = state.overlay.name;
|
|
132
|
+
if (state.overlay.root) frontmatter.root = state.overlay.root;
|
|
133
|
+
}
|
|
130
134
|
this.writeExperienceFile(stateHash, '', frontmatter);
|
|
131
135
|
}
|
|
132
136
|
|