explorbot 0.4.6 → 0.4.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/boat/api-tester/src/ai/chief.ts +3 -1
- package/boat/api-tester/src/ai/curler.ts +74 -66
- package/boat/api-tester/src/apibot.ts +1 -0
- package/boat/api-tester/src/cli.ts +2 -0
- package/boat/api-tester/src/config.ts +18 -1
- package/dist/boat/api-tester/src/ai/chief.js +3 -1
- package/dist/boat/api-tester/src/ai/curler.js +59 -56
- package/dist/boat/api-tester/src/apibot.js +1 -0
- package/dist/boat/api-tester/src/cli.js +2 -0
- package/dist/boat/api-tester/src/config.js +3 -1
- package/dist/package.json +2 -2
- package/dist/rules/chief/general.md +2 -0
- package/dist/rules/researcher/pagination.md +7 -0
- package/dist/src/action-result.d.ts +6 -0
- package/dist/src/action-result.js +12 -0
- package/dist/src/action.js +3 -2
- package/dist/src/ai/navigator.js +1 -4
- package/dist/src/ai/pilot.js +8 -12
- package/dist/src/ai/planner/session-dedup.d.ts +2 -1
- package/dist/src/ai/planner/session-dedup.js +18 -1
- package/dist/src/ai/planner.js +12 -4
- package/dist/src/ai/provider.js +18 -4
- package/dist/src/ai/researcher/locators.js +1 -1
- package/dist/src/ai/researcher/pagination.d.ts +16 -0
- package/dist/src/ai/researcher/pagination.js +62 -0
- package/dist/src/ai/researcher/parser.d.ts +3 -0
- package/dist/src/ai/researcher/parser.js +22 -6
- package/dist/src/ai/researcher/sections.js +1 -1
- package/dist/src/ai/researcher.js +7 -2
- package/dist/src/ai/rules.js +17 -0
- package/dist/src/ai/scout.js +8 -2
- package/dist/src/ai/tester.js +1 -1
- package/dist/src/ai/tools.js +12 -4
- package/dist/src/commands/options/ws-option.d.ts +7 -0
- package/dist/src/commands/options/ws-option.js +14 -0
- package/dist/src/config.d.ts +1 -0
- package/dist/src/config.js +14 -11
- package/dist/src/remote.d.ts +2 -0
- package/dist/src/remote.js +23 -16
- package/dist/src/utils/aria.d.ts +2 -0
- package/dist/src/utils/aria.js +6 -1
- package/dist/src/utils/code-extractor.js +6 -2
- package/dist/src/utils/markdown-query.d.ts +2 -0
- package/dist/src/utils/markdown-query.js +39 -0
- package/dist/src/utils/pagination.d.ts +16 -0
- package/dist/src/utils/pagination.js +20 -0
- package/docs/superpowers/plans/2026-09-10-pagination.md +1420 -0
- package/docs/superpowers/specs/2026-09-09-pagination-rule-design.md +125 -97
- package/package.json +2 -2
- package/rules/chief/general.md +2 -0
- package/rules/researcher/pagination.md +7 -0
- package/src/action-result.ts +16 -0
- package/src/action.ts +3 -2
- package/src/ai/navigator.ts +1 -4
- package/src/ai/pilot.ts +8 -12
- package/src/ai/planner/session-dedup.ts +16 -2
- package/src/ai/planner.ts +12 -4
- package/src/ai/provider.ts +18 -3
- package/src/ai/researcher/locators.ts +1 -1
- package/src/ai/researcher/pagination.ts +68 -0
- package/src/ai/researcher/parser.ts +23 -5
- package/src/ai/researcher/sections.ts +1 -1
- package/src/ai/researcher.ts +9 -3
- package/src/ai/rules.ts +17 -0
- package/src/ai/scout.ts +9 -2
- package/src/ai/tester.ts +1 -1
- package/src/ai/tools.ts +9 -4
- package/src/commands/options/ws-option.ts +14 -0
- package/src/config.ts +15 -11
- package/src/remote.ts +22 -15
- package/src/utils/aria.ts +8 -1
- package/src/utils/code-extractor.ts +6 -2
- package/src/utils/markdown-query.ts +39 -0
- package/src/utils/pagination.ts +36 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type Explorer from '../../explorer.ts';
|
|
2
|
+
import { mdq } from '../../utils/markdown-query.ts';
|
|
3
|
+
import { type ListMeasure, type PaginationStrategy, inspectList, restoreScroll } from '../../utils/pagination.ts';
|
|
4
|
+
import { type Constructor, debugLog } from './mixin.ts';
|
|
5
|
+
import { extractPaginationFromBlockquote, parseDataSections, parseResearchSections } from './parser.ts';
|
|
6
|
+
import type { ResearchResult } from './research-result.ts';
|
|
7
|
+
|
|
8
|
+
export function WithPagination<T extends Constructor>(Base: T) {
|
|
9
|
+
return class extends Base {
|
|
10
|
+
declare explorer: Explorer;
|
|
11
|
+
|
|
12
|
+
async detectPagination(result: ResearchResult): Promise<void> {
|
|
13
|
+
const sections = [...parseResearchSections(result.text), ...parseDataSections(result.text)];
|
|
14
|
+
|
|
15
|
+
for (const section of sections) {
|
|
16
|
+
const css = section.containerCss;
|
|
17
|
+
if (!css) continue;
|
|
18
|
+
if (extractPaginationFromBlockquote(section.rawMarkdown)) continue;
|
|
19
|
+
|
|
20
|
+
const strategy = await this.probeSection(css);
|
|
21
|
+
if (!strategy) continue;
|
|
22
|
+
|
|
23
|
+
this.recordPagination(result, section.name, strategy);
|
|
24
|
+
debugLog(`Pagination in "${section.name}": ${strategy}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private async probeSection(css: string): Promise<PaginationStrategy | null> {
|
|
29
|
+
const before = await this.measure(css);
|
|
30
|
+
if (!before) return null;
|
|
31
|
+
if (before.hasPagingControls) return 'controls';
|
|
32
|
+
if (before.isFeed) return 'infinite';
|
|
33
|
+
if (!before.scrolls) return null;
|
|
34
|
+
|
|
35
|
+
const action = this.explorer.action();
|
|
36
|
+
const scrolled = await action.attempt(`I.scrollTo('${css} > *:last-child')`).catch(() => false);
|
|
37
|
+
if (!scrolled) return null;
|
|
38
|
+
|
|
39
|
+
const after = await this.measure(css);
|
|
40
|
+
await this.explorer.withPage((page) => page.evaluate(restoreScroll, { css, scrollTop: before.scrollTop, pageScrollY: before.pageScrollY })).catch(() => {});
|
|
41
|
+
|
|
42
|
+
if (!after) return null;
|
|
43
|
+
if (after.items > before.items) return 'infinite';
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private measure(css: string): Promise<ListMeasure | null> {
|
|
48
|
+
return this.explorer
|
|
49
|
+
.withPage((page) => page.evaluate(inspectList, css))
|
|
50
|
+
.catch((err: Error) => {
|
|
51
|
+
debugLog(`List measurement failed for '${css}': ${err.message}`);
|
|
52
|
+
return null;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private recordPagination(result: ResearchResult, name: string, strategy: PaginationStrategy): void {
|
|
57
|
+
const escaped = name.replace(/"/g, '\\"');
|
|
58
|
+
let sectionQuery = mdq(result.text).query(`section2(~"${escaped}")`);
|
|
59
|
+
if (sectionQuery.count() === 0) sectionQuery = mdq(result.text).query(`section3(~"${escaped}")`);
|
|
60
|
+
if (sectionQuery.count() === 0) return;
|
|
61
|
+
result.text = sectionQuery.query('blockquote[0]').setKeyValue('Pagination', strategy);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface PaginationMethods {
|
|
67
|
+
detectPagination(result: ResearchResult): Promise<void>;
|
|
68
|
+
}
|
|
@@ -2,6 +2,7 @@ import { parseAriaLocator } from '../../utils/aria.ts';
|
|
|
2
2
|
import { pluralize } from '../../utils/logger.ts';
|
|
3
3
|
import { jsonToTable, parseSections, tableToJson } from '../../utils/markdown-parser.ts';
|
|
4
4
|
import { mdq } from '../../utils/markdown-query.ts';
|
|
5
|
+
import type { PaginationStrategy } from '../../utils/pagination.ts';
|
|
5
6
|
import { FOCUSED_MARKER } from './focus.ts';
|
|
6
7
|
|
|
7
8
|
export interface ResearchElement {
|
|
@@ -84,11 +85,9 @@ export function mapRowToElement(row: Record<string, string>): ResearchElement |
|
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
export function extractContainerFromBlockquote(sectionMarkdown: string): string | null {
|
|
87
|
-
const
|
|
88
|
-
if (!
|
|
89
|
-
const
|
|
90
|
-
if (!match) return null;
|
|
91
|
-
const css = normalizeLocatorValue(match[1]);
|
|
88
|
+
const entry = mdq(sectionMarkdown).query('blockquote[0]').keyValue().container;
|
|
89
|
+
if (!entry) return null;
|
|
90
|
+
const css = normalizeLocatorValue(entry);
|
|
92
91
|
if (!css || !/^[.#\[\w]/.test(css)) return null;
|
|
93
92
|
return css;
|
|
94
93
|
}
|
|
@@ -108,6 +107,25 @@ export function parseResearchSections(markdown: string): ResearchSection[] {
|
|
|
108
107
|
});
|
|
109
108
|
}
|
|
110
109
|
|
|
110
|
+
export function parseDataSections(markdown: string): ResearchSection[] {
|
|
111
|
+
return parseSections(markdown)
|
|
112
|
+
.filter((s) => s.name.toLowerCase().startsWith('data:'))
|
|
113
|
+
.map((section) => ({
|
|
114
|
+
name: section.name,
|
|
115
|
+
containerCss: extractContainerFromBlockquote(section.rawMarkdown),
|
|
116
|
+
elements: [],
|
|
117
|
+
rawMarkdown: section.rawMarkdown,
|
|
118
|
+
isExtended: false,
|
|
119
|
+
}));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function extractPaginationFromBlockquote(sectionMarkdown: string): PaginationStrategy | null {
|
|
123
|
+
const value = mdq(sectionMarkdown).query('blockquote[0]').keyValue().pagination?.toLowerCase();
|
|
124
|
+
if (value === 'controls') return 'controls';
|
|
125
|
+
if (value === 'infinite') return 'infinite';
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
111
129
|
export function extractValidContainers(researchText: string, opts?: { exclude?: string[] }): Array<{ css: string; label: string }> {
|
|
112
130
|
const exclude = opts?.exclude || [];
|
|
113
131
|
return parseResearchSections(researchText)
|
|
@@ -78,7 +78,7 @@ export function WithSections<T extends Constructor>(Base: T) {
|
|
|
78
78
|
|
|
79
79
|
private async _researchSingleSection(name: string, description: string, ariaSnapshot: string, focusCss: string | null): Promise<string> {
|
|
80
80
|
const currentUrl = this.stateManager.getCurrentState()?.url || '';
|
|
81
|
-
const rules = RulesLoader.loadRules('researcher', ['ui-map-table', 'list-element', 'container-rules'], currentUrl);
|
|
81
|
+
const rules = RulesLoader.loadRules('researcher', ['ui-map-table', 'list-element', 'container-rules', 'pagination'], currentUrl);
|
|
82
82
|
const url = this.actionResult?.url || 'Unknown';
|
|
83
83
|
const title = this.actionResult?.title || 'Unknown';
|
|
84
84
|
|
package/src/ai/researcher.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { type CoordinateMethods, WithCoordinates } from './researcher/coordinate
|
|
|
24
24
|
import { type DeepAnalysisMethods, WithDeepAnalysis } from './researcher/deep-analysis.ts';
|
|
25
25
|
import { detectFocusedSection, hasFocusedSection, markSectionAsFocused, pickDefaultFocusedSection } from './researcher/focus.ts';
|
|
26
26
|
import { type LocatorMethods, WithLocators } from './researcher/locators.ts';
|
|
27
|
+
import { type PaginationMethods, WithPagination } from './researcher/pagination.ts';
|
|
27
28
|
import { extractValidContainers, formatResearchSummary, parseResearchSections } from './researcher/parser.ts';
|
|
28
29
|
import { ResearchResult } from './researcher/research-result.ts';
|
|
29
30
|
import { type SectionMethods, WithSections } from './researcher/sections.ts';
|
|
@@ -44,9 +45,9 @@ export const POSSIBLE_SECTIONS = {
|
|
|
44
45
|
navigation: 'main navigation (top bar, sidebar, breadcrumbs)',
|
|
45
46
|
};
|
|
46
47
|
|
|
47
|
-
const ResearcherBase = WithSections(WithDeepAnalysis(WithCoordinates(WithLocators(TaskAgent as unknown as new (...args: any[]) => TaskAgent))));
|
|
48
|
+
const ResearcherBase = WithSections(WithPagination(WithDeepAnalysis(WithCoordinates(WithLocators(TaskAgent as unknown as new (...args: any[]) => TaskAgent)))));
|
|
48
49
|
|
|
49
|
-
export interface Researcher extends LocatorMethods, CoordinateMethods, DeepAnalysisMethods, SectionMethods {}
|
|
50
|
+
export interface Researcher extends LocatorMethods, CoordinateMethods, DeepAnalysisMethods, SectionMethods, PaginationMethods {}
|
|
50
51
|
|
|
51
52
|
export class Researcher extends ResearcherBase implements Agent {
|
|
52
53
|
protected readonly ACTION_TOOLS = ['click'];
|
|
@@ -277,6 +278,10 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
277
278
|
await this.backfillBrokenLocators(result);
|
|
278
279
|
}
|
|
279
280
|
|
|
281
|
+
if (!interrupted()) {
|
|
282
|
+
await this.detectPagination(result);
|
|
283
|
+
}
|
|
284
|
+
|
|
280
285
|
// Focused section: final fallback (vision-only — without a screenshot we don't infer focus)
|
|
281
286
|
if (this.hasScreenshotToAnalyze && !hasFocusedSection(result.text)) {
|
|
282
287
|
const sections = parseResearchSections(result.text);
|
|
@@ -426,7 +431,7 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
426
431
|
|
|
427
432
|
${generalLocatorRuleText}
|
|
428
433
|
|
|
429
|
-
${RulesLoader.loadRules('researcher', ['ui-map-table', 'list-element', 'container-rules'], currentUrl)}
|
|
434
|
+
${RulesLoader.loadRules('researcher', ['ui-map-table', 'list-element', 'container-rules', 'pagination'], currentUrl)}
|
|
430
435
|
|
|
431
436
|
<section_identification>
|
|
432
437
|
Identify page sections in this priority order:
|
|
@@ -502,6 +507,7 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
502
507
|
- When a section contains a list of similar data items (records, entities, rows — content that varies by data, not by app UI), output it as a Data section with NO table.
|
|
503
508
|
- Data section heading MUST be a level-2 heading (##) that starts exactly with "Data:" — for example: "## Data: Suites List". Do NOT use ### or add section numbers.
|
|
504
509
|
- Data sections must NOT include a UI map table. Only include the container and a brief summary line.
|
|
510
|
+
- When the data list has controls that move between pages of the collection, add "> Pagination: controls" under its container.
|
|
505
511
|
- Example data section:
|
|
506
512
|
|
|
507
513
|
## Data: Suites List
|
package/src/ai/rules.ts
CHANGED
|
@@ -188,6 +188,7 @@ export const capabilityGroundingRule = dedent`
|
|
|
188
188
|
When a scenario depends on a named action, menu item, status, option, workflow, or feature,
|
|
189
189
|
that capability must be visible or explicitly confirmed in the current research/page context
|
|
190
190
|
for the same target entity type.
|
|
191
|
+
Ground on the scenario's outcome, not a planned step's control label — a missing label is not a missing capability.
|
|
191
192
|
|
|
192
193
|
Do not transfer capabilities between similar entities, rows, lists, detail pages, or menus.
|
|
193
194
|
Do not replace a requested action with a synonym or related action unless the UI explicitly
|
|
@@ -349,6 +350,22 @@ export const actionRule = dedent`
|
|
|
349
350
|
For checkboxes, prefer I.checkOption/I.uncheckOption over I.click.
|
|
350
351
|
|
|
351
352
|
|
|
353
|
+
### I.scrollTo
|
|
354
|
+
|
|
355
|
+
scrolls until the element is in view
|
|
356
|
+
|
|
357
|
+
I.scrollTo(<locator>)
|
|
358
|
+
|
|
359
|
+
Scrolls every scrollable ancestor of the target, so it reaches an element inside a container
|
|
360
|
+
that has its own scrollbar. I.scrollPageToBottom() moves only the page itself.
|
|
361
|
+
|
|
362
|
+
<example>
|
|
363
|
+
I.scrollTo('.rows > *:last-child');
|
|
364
|
+
I.scrollTo({ role: 'listitem', text: 'Last entry' });
|
|
365
|
+
I.scrollPageToBottom();
|
|
366
|
+
</example>
|
|
367
|
+
|
|
368
|
+
|
|
352
369
|
### I.fillField
|
|
353
370
|
|
|
354
371
|
fills the field with the given value
|
package/src/ai/scout.ts
CHANGED
|
@@ -56,13 +56,20 @@ export class Scout implements Agent {
|
|
|
56
56
|
agentName: 'scout',
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
+
const responseText = invokeResult?.response?.text;
|
|
60
|
+
if (responseText?.trim()) {
|
|
61
|
+
finishFromText(responseText);
|
|
62
|
+
stop();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
59
66
|
if (!invokeResult?.toolExecutions?.length) {
|
|
60
|
-
finishFromText(invokeResult?.response?.text);
|
|
61
67
|
stop();
|
|
62
68
|
return;
|
|
63
69
|
}
|
|
64
70
|
|
|
65
|
-
if (iteration >= MAX_ITERATIONS) {
|
|
71
|
+
if (iteration >= MAX_ITERATIONS - 1) {
|
|
72
|
+
conversation.addUserText('Exploration time is over. Report your findings now as your final message.');
|
|
66
73
|
const final = await this.provider.invokeConversation(conversation, undefined, { agentName: 'scout' });
|
|
67
74
|
finishFromText(final?.response?.text);
|
|
68
75
|
stop();
|
package/src/ai/tester.ts
CHANGED
|
@@ -41,7 +41,7 @@ const SAMPLE_FILES: Record<string, string> = {
|
|
|
41
41
|
|
|
42
42
|
export class Tester extends TaskAgent implements Agent {
|
|
43
43
|
protected readonly ACTION_TOOLS = ['click', 'hover', 'pressKey', 'form'];
|
|
44
|
-
protected readonly DELEGATED_ACTION_TOOLS = ['interact'];
|
|
44
|
+
protected readonly DELEGATED_ACTION_TOOLS = ['interact', 'visualClick'];
|
|
45
45
|
protected readonly SPECIAL_CONTEXT_ACTION_TOOLS = ['exitIframe'];
|
|
46
46
|
emoji = '🧪';
|
|
47
47
|
private requestStore: RequestStore;
|
package/src/ai/tools.ts
CHANGED
|
@@ -374,6 +374,8 @@ export function createCodeceptJSTools({ explorer, stateManager }: ToolDeps, task
|
|
|
374
374
|
- Working with iframes (switch context with I.switchTo)
|
|
375
375
|
- Performing multiple form actions in a single batch
|
|
376
376
|
- Complex interactions requiring sequential commands
|
|
377
|
+
- Reaching items further down a list (I.scrollTo)
|
|
378
|
+
- Reloading the page to prove a change outlived it (I.reloadPage)
|
|
377
379
|
|
|
378
380
|
Example - filling a form with context (PREFERRED):
|
|
379
381
|
I.fillField('Username', 'John', '.login-form')
|
|
@@ -386,7 +388,7 @@ export function createCodeceptJSTools({ explorer, stateManager }: ToolDeps, task
|
|
|
386
388
|
I.selectOption({"role":"combobox","text":"Category"}, 'Technology')
|
|
387
389
|
|
|
388
390
|
Do not submit form - use verify() first to check fields were filled correctly, then click() to submit.
|
|
389
|
-
Do not use: wait functions, amOnPage,
|
|
391
|
+
Do not use: wait functions, amOnPage, saveScreenshot
|
|
390
392
|
`,
|
|
391
393
|
inputSchema: z.object({
|
|
392
394
|
codeBlock: z.string().describe('Valid CodeceptJS code starting with I. Can contain multiple commands separated by newlines.'),
|
|
@@ -441,10 +443,10 @@ export function createCodeceptJSTools({ explorer, stateManager }: ToolDeps, task
|
|
|
441
443
|
|
|
442
444
|
if (!hasObservablePageChange(toolResult)) {
|
|
443
445
|
activeNote.commit(TestResult.FAILED);
|
|
444
|
-
return failedToolResult('form', '
|
|
446
|
+
return failedToolResult('form', 'Command executed, but nothing on the page changed: no navigation, no ARIA change, no HTML change and no request.', {
|
|
445
447
|
...toolResult,
|
|
446
448
|
code: codeBlock,
|
|
447
|
-
suggestion: '
|
|
449
|
+
suggestion: 'The command ran without reaching anything. Re-locate the target, check whether another UI layer is active, then retry. If the goal was to load more of a list, no change means the collection has ended.',
|
|
448
450
|
});
|
|
449
451
|
}
|
|
450
452
|
await commitNote(activeNote, TestResult.PASSED, toolResult, action);
|
|
@@ -1238,7 +1240,10 @@ export function successToolResult(action: string, data?: Record<string, any>, so
|
|
|
1238
1240
|
}
|
|
1239
1241
|
|
|
1240
1242
|
export function isMajorPageChange(pageDiff: PageDiff): boolean {
|
|
1241
|
-
|
|
1243
|
+
if (pageDiff.urlChanged === true) return false;
|
|
1244
|
+
if ((pageDiff.ariaChangeCount ?? 0) < LARGE_ARIA_CHANGE_THRESHOLD) return false;
|
|
1245
|
+
if (pageDiff.ariaRemoved === 0 && (pageDiff.ariaAdded ?? 0) > 0) return false;
|
|
1246
|
+
return true;
|
|
1242
1247
|
}
|
|
1243
1248
|
|
|
1244
1249
|
export function hasFailedRequest(pageDiff: PageDiff): boolean {
|
|
@@ -6,6 +6,20 @@ export class WsOption extends BaseOption {
|
|
|
6
6
|
flags = '--ws <url>';
|
|
7
7
|
description = 'Stream this run to a remote UI over WebSocket';
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* An open socket holds the event loop, and `WebSocket` has no `unref` on
|
|
11
|
+
* either runtime — so a command that ends by returning rather than through
|
|
12
|
+
* `showStatsAndExit` would never exit once it is attached. The option that
|
|
13
|
+
* opened the connection is what closes it.
|
|
14
|
+
*/
|
|
15
|
+
override register(command: Command): void {
|
|
16
|
+
super.register(command);
|
|
17
|
+
command.hook('postAction', async () => {
|
|
18
|
+
if (!remote.isAttached()) return;
|
|
19
|
+
await remote.close(0);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
9
23
|
protected apply(options: Record<string, any>, command: Command): void {
|
|
10
24
|
const url = options.ws || process.env.EXPLORBOT_WS_URL;
|
|
11
25
|
if (!url) return;
|
package/src/config.ts
CHANGED
|
@@ -677,17 +677,7 @@ export class ConfigParser {
|
|
|
677
677
|
config.playwright.url = options.baseUrl;
|
|
678
678
|
}
|
|
679
679
|
|
|
680
|
-
|
|
681
|
-
const langfuse = config.ai.langfuse;
|
|
682
|
-
const publicKey = langfuse?.publicKey || process.env.LANGFUSE_PUBLIC_KEY;
|
|
683
|
-
const secretKey = langfuse?.secretKey || process.env.LANGFUSE_SECRET_KEY;
|
|
684
|
-
config.ai.langfuse = {
|
|
685
|
-
enabled: langfuse?.enabled ?? Boolean(publicKey && secretKey),
|
|
686
|
-
publicKey,
|
|
687
|
-
secretKey,
|
|
688
|
-
baseUrl: langfuse?.baseUrl || process.env.LANGFUSE_BASE_URL || process.env.LANGFUSE_HOST,
|
|
689
|
-
};
|
|
690
|
-
}
|
|
680
|
+
resolveLangfuse(config.ai);
|
|
691
681
|
|
|
692
682
|
return config;
|
|
693
683
|
}
|
|
@@ -857,6 +847,20 @@ export async function resolveConfigModels(ai?: AIConfig): Promise<void> {
|
|
|
857
847
|
}
|
|
858
848
|
}
|
|
859
849
|
|
|
850
|
+
export function resolveLangfuse(ai?: AIConfig): void {
|
|
851
|
+
if (!ai) return;
|
|
852
|
+
|
|
853
|
+
const langfuse = ai.langfuse;
|
|
854
|
+
const publicKey = langfuse?.publicKey || process.env.LANGFUSE_PUBLIC_KEY;
|
|
855
|
+
const secretKey = langfuse?.secretKey || process.env.LANGFUSE_SECRET_KEY;
|
|
856
|
+
ai.langfuse = {
|
|
857
|
+
enabled: langfuse?.enabled ?? Boolean(publicKey && secretKey),
|
|
858
|
+
publicKey,
|
|
859
|
+
secretKey,
|
|
860
|
+
baseUrl: langfuse?.baseUrl || process.env.LANGFUSE_BASE_URL || process.env.LANGFUSE_HOST,
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
|
|
860
864
|
export function resolveOutputRoot(baseUrl?: string): string {
|
|
861
865
|
if (cachedOutputRoot) return cachedOutputRoot;
|
|
862
866
|
|
package/src/remote.ts
CHANGED
|
@@ -28,6 +28,7 @@ export class Remote implements LogDestination {
|
|
|
28
28
|
private asks = new Map<string, (value: string | null) => void>();
|
|
29
29
|
private askCounter = 0;
|
|
30
30
|
private lastActivity: string | null = null;
|
|
31
|
+
private closing: Promise<void> | null = null;
|
|
31
32
|
|
|
32
33
|
attach(url: string, command: string): void {
|
|
33
34
|
if (this.url) return;
|
|
@@ -73,21 +74,10 @@ export class Remote implements LogDestination {
|
|
|
73
74
|
});
|
|
74
75
|
}
|
|
75
76
|
|
|
76
|
-
|
|
77
|
-
if (!this.url) return;
|
|
78
|
-
this.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
this.url = null;
|
|
82
|
-
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
83
|
-
// Whoever asks next has nobody to ask — leaving the callback installed would
|
|
84
|
-
// route them into a closed socket and park them until the ask times out.
|
|
85
|
-
executionController.clearInputCallback();
|
|
86
|
-
for (const resolve of this.asks.values()) resolve(null);
|
|
87
|
-
this.asks.clear();
|
|
88
|
-
this.queue = [];
|
|
89
|
-
this.socket?.close();
|
|
90
|
-
this.socket = null;
|
|
77
|
+
close(exitCode: number): Promise<void> {
|
|
78
|
+
if (!this.url) return Promise.resolve();
|
|
79
|
+
if (!this.closing) this.closing = this.shutdown(exitCode);
|
|
80
|
+
return this.closing;
|
|
91
81
|
}
|
|
92
82
|
|
|
93
83
|
isEnabled(): boolean {
|
|
@@ -116,6 +106,23 @@ export class Remote implements LogDestination {
|
|
|
116
106
|
});
|
|
117
107
|
}
|
|
118
108
|
|
|
109
|
+
private async shutdown(exitCode: number): Promise<void> {
|
|
110
|
+
this.send('result', { ok: exitCode === 0, exitCode });
|
|
111
|
+
await this.flush();
|
|
112
|
+
|
|
113
|
+
this.url = null;
|
|
114
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
115
|
+
// Whoever asks next has nobody to ask — leaving the callback installed would
|
|
116
|
+
// route them into a closed socket and park them until the ask times out.
|
|
117
|
+
executionController.clearInputCallback();
|
|
118
|
+
for (const resolve of this.asks.values()) resolve(null);
|
|
119
|
+
this.asks.clear();
|
|
120
|
+
this.queue = [];
|
|
121
|
+
this.socket?.close();
|
|
122
|
+
this.socket = null;
|
|
123
|
+
this.closing = null;
|
|
124
|
+
}
|
|
125
|
+
|
|
119
126
|
private connect(): void {
|
|
120
127
|
if (!this.url) return;
|
|
121
128
|
|
package/src/utils/aria.ts
CHANGED
|
@@ -521,7 +521,12 @@ export const diffAriaSnapshots = (previous: string | null, current: string | nul
|
|
|
521
521
|
const renames = detectRenames(prev, curr, prevTotals, currTotals);
|
|
522
522
|
const added = [...byCount.added, ...renames.added];
|
|
523
523
|
const removed = [...byCount.removed, ...renames.removed];
|
|
524
|
-
return {
|
|
524
|
+
return {
|
|
525
|
+
text: formatDiff(added, removed, toggled, typed),
|
|
526
|
+
count: added.length + removed.length + toggled.length + typed.length,
|
|
527
|
+
added: added.length,
|
|
528
|
+
removed: removed.length,
|
|
529
|
+
};
|
|
525
530
|
};
|
|
526
531
|
|
|
527
532
|
export const detectFocusArea = (snapshot: string | null): FocusAreaResult => {
|
|
@@ -587,6 +592,8 @@ export const LARGE_ARIA_CHANGE_THRESHOLD = 50;
|
|
|
587
592
|
export interface AriaDiff {
|
|
588
593
|
text: string | null;
|
|
589
594
|
count: number;
|
|
595
|
+
added: number;
|
|
596
|
+
removed: number;
|
|
590
597
|
}
|
|
591
598
|
|
|
592
599
|
type AriaNode = {
|
|
@@ -2,13 +2,17 @@ import { createDebug } from './logger.js';
|
|
|
2
2
|
|
|
3
3
|
const debugLog = createDebug('explorbot:code-extractor');
|
|
4
4
|
|
|
5
|
+
const JS_LANGUAGES = new Set(['', 'js', 'javascript']);
|
|
6
|
+
|
|
5
7
|
export function extractCodeBlocks(aiResponse: string): string[] {
|
|
6
|
-
const codeBlockRegex = /```(
|
|
8
|
+
const codeBlockRegex = /```([^\n`]*)\n([\s\S]*?)\n```/g;
|
|
7
9
|
const codeBlocks: string[] = [];
|
|
8
10
|
let match: RegExpExecArray | null = null;
|
|
9
11
|
|
|
10
12
|
while ((match = codeBlockRegex.exec(aiResponse))) {
|
|
11
|
-
const
|
|
13
|
+
const language = match[1].trim().toLowerCase();
|
|
14
|
+
if (!JS_LANGUAGES.has(language)) continue;
|
|
15
|
+
const code = match[2].trim();
|
|
12
16
|
if (!code) continue;
|
|
13
17
|
try {
|
|
14
18
|
new Function('I', code);
|
|
@@ -162,6 +162,12 @@ function matchText(text: string, matcher: TextMatcher): boolean {
|
|
|
162
162
|
return matcher.negated ? !result : result;
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
function entryKey(line: string): string | null {
|
|
166
|
+
const separator = line.indexOf(':');
|
|
167
|
+
if (separator < 1) return null;
|
|
168
|
+
return line.slice(0, separator).trim().toLowerCase();
|
|
169
|
+
}
|
|
170
|
+
|
|
165
171
|
function getTokenText(token: Token): string {
|
|
166
172
|
const t = token as any;
|
|
167
173
|
switch (token.type) {
|
|
@@ -407,6 +413,39 @@ export class MarkdownQuery {
|
|
|
407
413
|
return results;
|
|
408
414
|
}
|
|
409
415
|
|
|
416
|
+
keyValue(): Record<string, string> {
|
|
417
|
+
const entries: Record<string, string> = {};
|
|
418
|
+
|
|
419
|
+
for (const range of this.matches) {
|
|
420
|
+
for (const line of getTokenText(range.token).split('\n')) {
|
|
421
|
+
const key = entryKey(line);
|
|
422
|
+
if (!key) continue;
|
|
423
|
+
const value = line.slice(line.indexOf(':') + 1).trim();
|
|
424
|
+
if (value) entries[key] = value;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return entries;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
setKeyValue(key: string, value: string | null): string {
|
|
432
|
+
return this.replaceEach((match) => {
|
|
433
|
+
const token = match.matches[0].token;
|
|
434
|
+
const lines = getTokenText(token)
|
|
435
|
+
.split('\n')
|
|
436
|
+
.map((line) => line.trim())
|
|
437
|
+
.filter(Boolean);
|
|
438
|
+
|
|
439
|
+
const index = lines.findIndex((line) => entryKey(line) === key.toLowerCase());
|
|
440
|
+
if (index < 0 && value) lines.push(`${key}: ${value}`);
|
|
441
|
+
if (index >= 0 && value) lines[index] = `${key}: ${value}`;
|
|
442
|
+
if (index >= 0 && !value) lines.splice(index, 1);
|
|
443
|
+
|
|
444
|
+
if (token.type !== 'blockquote') return lines.join('\n');
|
|
445
|
+
return lines.map((line) => `> ${line}`).join('\n');
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
410
449
|
replace(content: string): string {
|
|
411
450
|
return this.replaceEach(() => content);
|
|
412
451
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export function inspectList(css: string): ListMeasure | null {
|
|
2
|
+
const element = document.querySelector(css);
|
|
3
|
+
if (!element) return null;
|
|
4
|
+
const rect = element.getBoundingClientRect();
|
|
5
|
+
return {
|
|
6
|
+
hasPagingControls: !!element.querySelector('a[rel="next"], a[rel="prev"]'),
|
|
7
|
+
isFeed: element.matches('[role="feed"]') || !!element.querySelector('[role="feed"]'),
|
|
8
|
+
scrolls: element.scrollHeight > element.clientHeight + 1 || rect.bottom > window.innerHeight,
|
|
9
|
+
items: element.querySelectorAll(':scope > *').length,
|
|
10
|
+
scrollTop: element.scrollTop,
|
|
11
|
+
pageScrollY: window.scrollY,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function restoreScroll({ css, scrollTop, pageScrollY }: ScrollPosition): void {
|
|
16
|
+
const element = document.querySelector(css);
|
|
17
|
+
if (element) element.scrollTop = scrollTop;
|
|
18
|
+
window.scrollTo(0, pageScrollY);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type PaginationStrategy = 'controls' | 'infinite';
|
|
22
|
+
|
|
23
|
+
export interface ListMeasure {
|
|
24
|
+
hasPagingControls: boolean;
|
|
25
|
+
isFeed: boolean;
|
|
26
|
+
scrolls: boolean;
|
|
27
|
+
items: number;
|
|
28
|
+
scrollTop: number;
|
|
29
|
+
pageScrollY: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ScrollPosition {
|
|
33
|
+
css: string;
|
|
34
|
+
scrollTop: number;
|
|
35
|
+
pageScrollY: number;
|
|
36
|
+
}
|