explorbot 0.4.0 → 0.4.2
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 +13 -6
- package/boat/api-tester/src/ai/chief.ts +7 -1
- package/boat/api-tester/src/ai/curler-tools.ts +1 -1
- package/boat/api-tester/src/ai/curler.ts +7 -1
- package/boat/api-tester/src/apibot.ts +10 -4
- package/boat/api-tester/src/cli.ts +15 -2
- package/boat/api-tester/src/config.ts +28 -8
- package/boat/doc-collector/bin/doc-collector-cli.ts +3 -2
- package/boat/doc-collector/src/ai/documentarian.ts +57 -5
- package/boat/doc-collector/src/ai/tools.ts +1 -1
- package/boat/doc-collector/src/cli.ts +21 -3
- package/boat/doc-collector/src/config.ts +3 -0
- package/boat/doc-collector/src/docbot.ts +57 -12
- package/boat/doc-collector/src/docs-renderer.ts +79 -55
- package/boat/doc-collector/src/interaction-screenshots.ts +19 -10
- package/boat/doc-collector/src/screenshots.ts +106 -3
- package/boat/doc-collector/src/template-dedup.ts +31 -0
- package/boat/prima/bin/prima-cli.ts +2 -0
- package/boat/prima/src/cli.ts +5 -0
- package/boat/prima/src/prima.ts +3 -4
- package/dist/bin/explorbot-cli.js +14 -6
- package/dist/boat/api-tester/bin/apibot-cli.js +3 -2
- package/dist/boat/api-tester/src/ai/chief.js +6 -1
- package/dist/boat/api-tester/src/ai/curler-tools.js +1 -1
- package/dist/boat/api-tester/src/ai/curler.js +6 -1
- package/dist/boat/api-tester/src/apibot.js +7 -3
- package/dist/boat/api-tester/src/cli.js +14 -2
- package/dist/boat/api-tester/src/config.js +31 -8
- package/dist/boat/doc-collector/bin/doc-collector-cli.js +3 -2
- package/dist/boat/doc-collector/src/ai/documentarian.js +62 -4
- package/dist/boat/doc-collector/src/ai/tools.js +1 -1
- package/dist/boat/doc-collector/src/cli.js +18 -3
- package/dist/boat/doc-collector/src/config.js +1 -0
- package/dist/boat/doc-collector/src/docbot.js +53 -11
- package/dist/boat/doc-collector/src/docs-renderer.js +69 -46
- package/dist/boat/doc-collector/src/interaction-screenshots.js +18 -11
- package/dist/boat/doc-collector/src/screenshots.js +104 -1
- package/dist/boat/doc-collector/src/template-dedup.js +27 -0
- package/dist/boat/prima/bin/prima-cli.js +2 -0
- package/dist/boat/prima/src/cli.js +5 -0
- package/dist/boat/prima/src/prima.js +3 -4
- package/dist/package.json +1 -1
- package/dist/rules/planner/styles/normal.md +1 -1
- package/dist/src/action-result.d.ts +2 -2
- package/dist/src/action-result.js +15 -17
- package/dist/src/action.d.ts +1 -1
- package/dist/src/action.js +15 -15
- package/dist/src/ai/captain.js +1 -1
- package/dist/src/ai/fisherman/request-haul.d.ts +11 -0
- package/dist/src/ai/fisherman/request-haul.js +28 -0
- package/dist/src/ai/fisherman-tools.d.ts +10 -1
- package/dist/src/ai/fisherman-tools.js +35 -31
- package/dist/src/ai/fisherman.d.ts +3 -2
- package/dist/src/ai/fisherman.js +8 -6
- package/dist/src/ai/navigator.js +1 -1
- package/dist/src/ai/pilot.js +21 -16
- package/dist/src/ai/planner.js +9 -7
- package/dist/src/ai/provider.js +8 -14
- package/dist/src/ai/researcher/coordinates.d.ts +2 -0
- package/dist/src/ai/researcher/coordinates.js +13 -1
- package/dist/src/ai/researcher/deep-analysis.js +9 -9
- package/dist/src/ai/researcher/fingerprint-worker.js +1 -11
- package/dist/src/ai/researcher.js +3 -1
- package/dist/src/ai/rules.js +11 -3
- package/dist/src/ai/tester.js +20 -20
- package/dist/src/ai/tools.d.ts +1 -1
- package/dist/src/ai/tools.js +18 -74
- package/dist/src/api/request-store.d.ts +1 -5
- package/dist/src/api/request-store.js +12 -22
- package/dist/src/api/spec-reader.js +1 -1
- package/dist/src/commands/config-command.js +3 -10
- package/dist/src/commands/drill-command.js +1 -1
- package/dist/src/commands/explore-command.js +12 -1
- package/dist/src/commands/index.js +2 -0
- package/dist/src/commands/init-command.js +3 -7
- package/dist/src/commands/options/base-option.d.ts +8 -0
- package/dist/src/commands/options/base-option.js +12 -0
- package/dist/src/commands/options/index.d.ts +5 -0
- package/dist/src/commands/options/index.js +5 -0
- package/dist/src/commands/options/knowledge-option.d.ts +7 -0
- package/dist/src/commands/options/knowledge-option.js +12 -0
- package/dist/src/commands/options/ws-option.d.ts +7 -0
- package/dist/src/commands/options/ws-option.js +21 -0
- package/dist/src/commands/plans-command.d.ts +4 -1
- package/dist/src/commands/plans-command.js +4 -4
- package/dist/src/commands/recommended-models-command.d.ts +9 -0
- package/dist/src/commands/recommended-models-command.js +85 -0
- package/dist/src/config.d.ts +3 -0
- package/dist/src/config.js +18 -2
- package/dist/src/experience-tracker.js +1 -1
- package/dist/src/explorbot.js +4 -4
- package/dist/src/global-config.d.ts +2 -0
- package/dist/src/global-config.js +6 -0
- package/dist/src/knowledge-tracker.d.ts +20 -7
- package/dist/src/knowledge-tracker.js +69 -31
- package/dist/src/remote.d.ts +0 -3
- package/dist/src/remote.js +0 -18
- package/dist/src/state-manager.d.ts +3 -3
- package/dist/src/state-manager.js +8 -8
- package/dist/src/test-plan.d.ts +1 -0
- package/dist/src/test-plan.js +13 -9
- package/dist/src/utils/aria.d.ts +1 -1
- package/dist/src/utils/aria.js +23 -2
- package/dist/src/utils/cli-section.d.ts +1 -0
- package/dist/src/utils/cli-section.js +12 -0
- package/dist/src/utils/html-diff.d.ts +1 -0
- package/dist/src/utils/html-diff.js +5 -5
- package/dist/src/utils/html.d.ts +2 -0
- package/dist/src/utils/html.js +5 -4
- package/dist/src/utils/overlay.d.ts +19 -55
- package/dist/src/utils/overlay.js +94 -148
- package/dist/src/utils/region.d.ts +41 -0
- package/dist/src/utils/region.js +99 -0
- package/dist/src/utils/similarity.d.ts +1 -0
- package/dist/src/utils/similarity.js +11 -0
- package/dist/src/utils/url-matcher.d.ts +1 -1
- package/dist/src/utils/url-matcher.js +4 -4
- package/docs/api-testing/basics.md +15 -0
- package/docs/api-testing/planning.md +10 -1
- package/docs/doc-collection/basics.md +11 -5
- package/docs/doc-collection/crawling.md +21 -1
- package/docs/reference/commands.md +27 -4
- package/docs/superpowers/plans/2026-09-03-model-resolves-click-ambiguity.md +509 -0
- package/docs/workflow/agentic-usage.md +11 -2
- package/docs/workflow/application-spec.md +4 -0
- package/docs/workflow/knowledge.md +46 -2
- package/package.json +1 -1
- package/rules/planner/styles/normal.md +1 -1
- package/src/action-result.ts +15 -16
- package/src/action.ts +16 -15
- package/src/ai/captain.ts +1 -1
- package/src/ai/fisherman/request-haul.ts +32 -0
- package/src/ai/fisherman-tools.ts +38 -32
- package/src/ai/fisherman.ts +8 -7
- package/src/ai/navigator.ts +1 -1
- package/src/ai/pilot.ts +19 -14
- package/src/ai/planner.ts +9 -8
- package/src/ai/provider.ts +8 -12
- package/src/ai/researcher/coordinates.ts +13 -1
- package/src/ai/researcher/deep-analysis.ts +9 -9
- package/src/ai/researcher/fingerprint-worker.ts +1 -10
- package/src/ai/researcher.ts +2 -1
- package/src/ai/rules.ts +11 -3
- package/src/ai/tester.ts +18 -18
- package/src/ai/tools.ts +18 -86
- package/src/api/request-store.ts +13 -27
- package/src/api/spec-reader.ts +1 -1
- package/src/commands/config-command.ts +3 -8
- package/src/commands/drill-command.ts +1 -1
- package/src/commands/explore-command.ts +12 -1
- package/src/commands/index.ts +2 -0
- package/src/commands/init-command.ts +4 -11
- package/src/commands/options/base-option.ts +18 -0
- package/src/commands/options/index.ts +7 -0
- package/src/commands/options/knowledge-option.ts +14 -0
- package/src/commands/options/ws-option.ts +24 -0
- package/src/commands/plans-command.ts +5 -5
- package/src/commands/recommended-models-command.ts +92 -0
- package/src/config.ts +20 -2
- package/src/experience-tracker.ts +1 -1
- package/src/explorbot.ts +4 -4
- package/src/global-config.ts +8 -0
- package/src/knowledge-tracker.ts +94 -36
- package/src/remote.ts +0 -20
- package/src/state-manager.ts +10 -9
- package/src/test-plan.ts +12 -10
- package/src/utils/aria.ts +26 -3
- package/src/utils/cli-section.ts +12 -0
- package/src/utils/html-diff.ts +6 -6
- package/src/utils/html.ts +6 -4
- package/src/utils/overlay.ts +105 -192
- package/src/utils/region.ts +132 -0
- package/src/utils/similarity.ts +9 -0
- package/src/utils/url-matcher.ts +3 -3
- package/dist/src/utils/request-map.d.ts +0 -7
- package/dist/src/utils/request-map.js +0 -16
- package/src/utils/request-map.ts +0 -19
package/src/ai/provider.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { type AIConfig, configuredModels, modelName as getModelName } from '../c
|
|
|
11
11
|
import { executionController } from '../execution-controller.ts';
|
|
12
12
|
import { Observability } from '../observability.ts';
|
|
13
13
|
import { Stats } from '../stats.ts';
|
|
14
|
+
import { getCliName } from '../utils/cli-name.js';
|
|
14
15
|
import { createDebug, tag } from '../utils/logger.js';
|
|
15
16
|
import { type RetryOptions, withRetry } from '../utils/retry.js';
|
|
16
17
|
import { RulesLoader } from '../utils/rules-loader.ts';
|
|
@@ -51,12 +52,7 @@ export async function flushTelemetry(): Promise<void> {
|
|
|
51
52
|
const CONTEXT_LENGTH_PATTERNS = ['reduce the length', 'context length', 'maximum context', 'token limit', 'too many tokens', 'max_tokens', 'context_length_exceeded', 'output truncated at maxtokens'];
|
|
52
53
|
|
|
53
54
|
function extractCachedTokens(usage: any): number {
|
|
54
|
-
|
|
55
|
-
const direct = usage.inputTokenDetails?.cacheReadTokens ?? usage.cachedInputTokens;
|
|
56
|
-
if (typeof direct === 'number') return direct;
|
|
57
|
-
const raw = usage.raw;
|
|
58
|
-
const fromRaw = raw?.prompt_tokens_details?.cached_tokens ?? raw?.promptTokensDetails?.cachedTokens;
|
|
59
|
-
return typeof fromRaw === 'number' ? fromRaw : 0;
|
|
55
|
+
return usage?.inputTokenDetails?.cacheReadTokens ?? 0;
|
|
60
56
|
}
|
|
61
57
|
|
|
62
58
|
function abortAfterIdle(ms: number, cancel: { cancelled: boolean }, controller: AbortController): Promise<never> {
|
|
@@ -110,7 +106,7 @@ export class Provider {
|
|
|
110
106
|
|
|
111
107
|
constructor(config: AIConfig) {
|
|
112
108
|
if (!config?.model) {
|
|
113
|
-
throw new AiError(
|
|
109
|
+
throw new AiError(`AI model is not configured. Set ai.model in your config file, or pick one with ${getCliName()} recommended-models`);
|
|
114
110
|
}
|
|
115
111
|
this.config = config;
|
|
116
112
|
this.initLangfuse();
|
|
@@ -243,8 +239,8 @@ export class Provider {
|
|
|
243
239
|
private recordUsage(agentName: string, modelName: string, usage: any): void {
|
|
244
240
|
if (!usage) return;
|
|
245
241
|
Stats.recordTokens(agentName, modelName, {
|
|
246
|
-
input: usage.inputTokens ??
|
|
247
|
-
output: usage.outputTokens ??
|
|
242
|
+
input: usage.inputTokens ?? 0,
|
|
243
|
+
output: usage.outputTokens ?? 0,
|
|
248
244
|
total: usage.totalTokens ?? 0,
|
|
249
245
|
cached: extractCachedTokens(usage),
|
|
250
246
|
});
|
|
@@ -428,7 +424,7 @@ export class Provider {
|
|
|
428
424
|
let invalidRequestFeedbackAdded = false;
|
|
429
425
|
const executedStepMessages: ModelMessage[] = [];
|
|
430
426
|
try {
|
|
431
|
-
|
|
427
|
+
let response = await this.withModelRequestSlot(() =>
|
|
432
428
|
withRetry(async () => {
|
|
433
429
|
const stepMessages: ModelMessage[] = [];
|
|
434
430
|
const onStepEnd = (step: any) => {
|
|
@@ -458,7 +454,7 @@ export class Provider {
|
|
|
458
454
|
|
|
459
455
|
clearActivity();
|
|
460
456
|
|
|
461
|
-
withExecutedSteps(response, executedStepMessages);
|
|
457
|
+
response = withExecutedSteps(response, executedStepMessages);
|
|
462
458
|
|
|
463
459
|
// Log tool usage summary
|
|
464
460
|
if (response.toolCalls && response.toolCalls.length > 0) {
|
|
@@ -651,7 +647,7 @@ export class Provider {
|
|
|
651
647
|
|
|
652
648
|
async processImage(prompt: string, image: string): Promise<any> {
|
|
653
649
|
if (!this.config.visionModel) {
|
|
654
|
-
throw new Error(
|
|
650
|
+
throw new Error(`Vision model not configured. Set ai.visionModel in your config; ${getCliName()} recommended-models lists the providers that serve one`);
|
|
655
651
|
}
|
|
656
652
|
|
|
657
653
|
setActivity(`🤖 Processing image with ${this.config.visionModel}`, 'ai');
|
|
@@ -71,6 +71,14 @@ export async function visuallyAnnotateContainers(page: Page, containers: Array<{
|
|
|
71
71
|
}, containers);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
export async function removeVisualAnnotations(page: Page): Promise<void> {
|
|
75
|
+
try {
|
|
76
|
+
await page.locator('[data-explorbot-annotation]').evaluateAll((elements) => {
|
|
77
|
+
for (const element of elements) element.remove();
|
|
78
|
+
});
|
|
79
|
+
} catch {}
|
|
80
|
+
}
|
|
81
|
+
|
|
74
82
|
export function WithCoordinates<T extends Constructor>(Base: T) {
|
|
75
83
|
return class extends Base {
|
|
76
84
|
declare explorer: Explorer;
|
|
@@ -85,6 +93,10 @@ export function WithCoordinates<T extends Constructor>(Base: T) {
|
|
|
85
93
|
return this.explorer.withPage((page) => visuallyAnnotateContainers(page, opts?.containers || []));
|
|
86
94
|
}
|
|
87
95
|
|
|
96
|
+
async removeVisualAnnotations(): Promise<void> {
|
|
97
|
+
await this.explorer.withPage((page) => removeVisualAnnotations(page));
|
|
98
|
+
}
|
|
99
|
+
|
|
88
100
|
private async _analyzeScreenshotForVisualProps(): Promise<VisualAnalysisResult> {
|
|
89
101
|
const elements = new Map<string, { coordinates: string | null; color: string | null; icon: string | null }>();
|
|
90
102
|
const emptyResult: VisualAnalysisResult = { elements, pagePurpose: null, primaryActions: null, focusedSection: null };
|
|
@@ -96,7 +108,7 @@ export function WithCoordinates<T extends Constructor>(Base: T) {
|
|
|
96
108
|
|
|
97
109
|
const prompt = dedent`
|
|
98
110
|
This screenshot has two types of annotations:
|
|
99
|
-
- **Section containers**: dashed bordered boxes (no labels on them). A legend at the bottom-
|
|
111
|
+
- **Section containers**: dashed bordered boxes (no labels on them). A legend at the bottom-right maps dashed line colors to section names. Ignore containers for this task.
|
|
100
112
|
- **Interactive elements**: solid bordered boxes with eidx numbers in the top-right corner above the box. Adjacent elements use different colors.
|
|
101
113
|
|
|
102
114
|
For each interactive element (solid border, eidx number), report:
|
|
@@ -88,22 +88,22 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
async researchOverlay(current: ActionResult, previous: ActionResult, pageStateHash: string): Promise<string | null> {
|
|
91
|
-
const
|
|
92
|
-
if (!
|
|
91
|
+
const region = current.overlay;
|
|
92
|
+
if (!region.isOpen || !region.name) return null;
|
|
93
93
|
|
|
94
94
|
const cached = getCachedResearch(pageStateHash);
|
|
95
95
|
if (!cached) return null;
|
|
96
96
|
|
|
97
|
-
const escaped =
|
|
97
|
+
const escaped = region.name.replace(/"/g, '\\"');
|
|
98
98
|
if (mdq(cached).query(`section3(~"${escaped}")`).count() > 0) {
|
|
99
|
-
debugLog(`Overlay "${
|
|
99
|
+
debugLog(`Overlay "${region.name}" already in cached research, skipping`);
|
|
100
100
|
return null;
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
const diff = await current.diff(previous);
|
|
104
104
|
|
|
105
105
|
if (!diff.ariaChanged && diff.htmlParts.length === 0) {
|
|
106
|
-
debugLog(`No diff between current and previous state for overlay "${
|
|
106
|
+
debugLog(`No diff between current and previous state for overlay "${region.name}"`);
|
|
107
107
|
return null;
|
|
108
108
|
}
|
|
109
109
|
|
|
@@ -113,10 +113,10 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
|
|
|
113
113
|
.map((s) => s.rawMarkdown)
|
|
114
114
|
);
|
|
115
115
|
|
|
116
|
-
tag('substep').log(`Researching overlay: ${
|
|
117
|
-
const sectionMarkdown = await this._analyzeExpandedAction('',
|
|
116
|
+
tag('substep').log(`Researching overlay: ${region.name}`);
|
|
117
|
+
const sectionMarkdown = await this._analyzeExpandedAction('', region.name, diff, alreadyExpanded);
|
|
118
118
|
if (!sectionMarkdown) {
|
|
119
|
-
debugLog(`Overlay "${
|
|
119
|
+
debugLog(`Overlay "${region.name}" produced no meaningful expansion`);
|
|
120
120
|
return null;
|
|
121
121
|
}
|
|
122
122
|
|
|
@@ -130,7 +130,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
|
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
saveResearch({ hash: pageStateHash }, updated);
|
|
133
|
-
tag('substep').log(`Overlay research appended: ${
|
|
133
|
+
tag('substep').log(`Overlay research appended: ${region.name}`);
|
|
134
134
|
return sectionMarkdown;
|
|
135
135
|
}
|
|
136
136
|
|
|
@@ -3,16 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { parentPort } from 'node:worker_threads';
|
|
4
4
|
import { computeHtmlFingerprint } from '../../utils/html-diff.ts';
|
|
5
5
|
import { isSamePageFamily } from '../../utils/url-matcher.ts';
|
|
6
|
-
|
|
7
|
-
function diceSimilarity(a: Set<string>, b: Set<string>): number {
|
|
8
|
-
let intersection = 0;
|
|
9
|
-
for (const item of a) {
|
|
10
|
-
if (b.has(item)) intersection++;
|
|
11
|
-
}
|
|
12
|
-
const total = a.size + b.size;
|
|
13
|
-
if (total === 0) return 100;
|
|
14
|
-
return Math.round(((2 * intersection) / total) * 100);
|
|
15
|
-
}
|
|
6
|
+
import { diceSimilarity } from '../../utils/similarity.ts';
|
|
16
7
|
|
|
17
8
|
parentPort!.on('message', (data: FingerprintRequest) => {
|
|
18
9
|
const { html, statesDir, maxAgeMs, threshold, url } = data;
|
package/src/ai/researcher.ts
CHANGED
|
@@ -76,6 +76,7 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
static getCachedResearch(state: WebPageState): string {
|
|
79
|
+
if (state instanceof ActionResult) return getCachedResearch(state.baseHash);
|
|
79
80
|
return getCachedResearch(ActionResult.fromState(state).baseHash);
|
|
80
81
|
}
|
|
81
82
|
|
|
@@ -235,7 +236,7 @@ export class Researcher extends ResearcherBase implements Agent {
|
|
|
235
236
|
const containers = validContainers.filter((c) => !freshBroken.includes(c.css));
|
|
236
237
|
await this.visuallyAnnotateElements({ containers });
|
|
237
238
|
this.actionResult = await this.explorer.capture({ screenshot: true });
|
|
238
|
-
const visualResult = await this.analyzeScreenshotForVisualProps();
|
|
239
|
+
const visualResult = await this.analyzeScreenshotForVisualProps().finally(() => this.removeVisualAnnotations());
|
|
239
240
|
if (visualResult.elements.size > 0) {
|
|
240
241
|
await this.mergeVisualData(result, visualResult.elements);
|
|
241
242
|
result.parseLocators();
|
package/src/ai/rules.ts
CHANGED
|
@@ -154,11 +154,11 @@ export const protectionRule = dedent`
|
|
|
154
154
|
|
|
155
155
|
Pre-existing data on the page belongs to the application, not the test.
|
|
156
156
|
Items that were not created inside the current test scenario must not be deleted, removed, emptied, reset, archived, or otherwise destroyed.
|
|
157
|
-
If a scenario needs to verify destructive behaviour, the same scenario must first create
|
|
157
|
+
If a scenario needs to verify destructive behaviour, the same scenario must first create its own target and then destroy that specific target — never operate on data that was already there when the test started.
|
|
158
158
|
|
|
159
159
|
The resource that the current page URL represents is "under test".
|
|
160
160
|
The test must not destroy the resource it is running against — doing so invalidates every subsequent scenario that starts on the same URL.
|
|
161
|
-
Do not propose or perform delete/remove/archive actions on the entity that owns the current URL; propose such actions only on
|
|
161
|
+
Do not propose or perform delete/remove/archive actions on the entity that owns the current URL; propose such actions only on children created within the scenario itself.
|
|
162
162
|
</important>
|
|
163
163
|
`;
|
|
164
164
|
|
|
@@ -174,7 +174,7 @@ export const dataProtectionRules = dedent`
|
|
|
174
174
|
filter, tab, or list-inspection constraint. Use visible existing data when it is available.
|
|
175
175
|
If no suitable data exists, report the missing precondition instead of creating data.
|
|
176
176
|
|
|
177
|
-
Destructive actions are allowed only against
|
|
177
|
+
Destructive actions are allowed only against data created by the current scenario
|
|
178
178
|
or prepared for that scenario by Fisherman/API preconditions. Existing application data must
|
|
179
179
|
remain unchanged.
|
|
180
180
|
</data_protection_rules>
|
|
@@ -335,6 +335,14 @@ export const actionRule = dedent`
|
|
|
335
335
|
If locator doesn't work, try CSS or XPath locators.
|
|
336
336
|
If nothing works, use I.clickXY(x, y) as last resort.
|
|
337
337
|
|
|
338
|
+
When a click result reports several matches, pick one from its numbered list by position rather than guessing a new locator.
|
|
339
|
+
Reuse the same locator with step.opts({ elementIndex: N }) as the LAST argument. N is the "Element N" number.
|
|
340
|
+
|
|
341
|
+
<example>
|
|
342
|
+
I.click('Remove', step.opts({ elementIndex: 2 }));
|
|
343
|
+
I.click({ role: 'link', text: 'Details' }, '.panel', step.opts({ elementIndex: 1 }));
|
|
344
|
+
</example>
|
|
345
|
+
|
|
338
346
|
For checkboxes, prefer I.checkOption/I.uncheckOption over I.click.
|
|
339
347
|
|
|
340
348
|
|
package/src/ai/tester.ts
CHANGED
|
@@ -548,17 +548,17 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
548
548
|
const isNewUrl = this.previousUrl !== currentUrl;
|
|
549
549
|
const isNewState = !isNewUrl && this.previousStateHash !== null && this.previousStateHash !== currentStateHash;
|
|
550
550
|
|
|
551
|
-
if (this.previousRegionPresent !== null && this.previousRegionPresent !== currentState.overlay.
|
|
551
|
+
if (this.previousRegionPresent !== null && this.previousRegionPresent !== currentState.overlay.isOpen) {
|
|
552
552
|
this.regionTransitioned = true;
|
|
553
553
|
}
|
|
554
|
-
this.previousRegionPresent = currentState.overlay.
|
|
554
|
+
this.previousRegionPresent = currentState.overlay.isOpen;
|
|
555
555
|
|
|
556
556
|
this.previousUrl = currentUrl;
|
|
557
557
|
this.previousStateHash = currentStateHash;
|
|
558
558
|
|
|
559
559
|
let context = '';
|
|
560
560
|
|
|
561
|
-
const
|
|
561
|
+
const region = currentState.overlay;
|
|
562
562
|
|
|
563
563
|
const focusedElement = currentState.focusedElement;
|
|
564
564
|
if (focusedElement) {
|
|
@@ -577,29 +577,29 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
577
577
|
`;
|
|
578
578
|
}
|
|
579
579
|
|
|
580
|
-
if (
|
|
581
|
-
const areaName =
|
|
580
|
+
if (region.isModal) {
|
|
581
|
+
const areaName = region.name ? ` "${region.name}"` : '';
|
|
582
582
|
let rootHint = '';
|
|
583
|
-
if (
|
|
583
|
+
if (region.root) rootHint = `\nIts content lives inside \`${region.root}\` — scope locators to it.`;
|
|
584
584
|
context += dedent`
|
|
585
|
-
<
|
|
586
|
-
|
|
587
|
-
Scope all interactions to elements inside this
|
|
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
|
|
589
|
-
Use <page_aria> to confirm the element you target is actually inside the
|
|
590
|
-
</
|
|
585
|
+
<overlay>
|
|
586
|
+
An overlay${areaName} is currently open above the page.${rootHint}
|
|
587
|
+
Scope all interactions to elements inside this overlay.
|
|
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 overlay.
|
|
589
|
+
Use <page_aria> to confirm the element you target is actually inside the overlay.
|
|
590
|
+
</overlay>
|
|
591
591
|
`;
|
|
592
592
|
}
|
|
593
593
|
|
|
594
|
-
if (!
|
|
594
|
+
if (!region.isModal && region.isOpen && isNewState) {
|
|
595
595
|
let rootHint = '';
|
|
596
|
-
if (
|
|
596
|
+
if (region.root) rootHint = `\nIt lives inside \`${region.root}\`.`;
|
|
597
597
|
context += dedent`
|
|
598
|
-
<
|
|
599
|
-
A large new
|
|
598
|
+
<region>
|
|
599
|
+
A large new region "${region.name || 'unnamed region'}" appeared on this page without navigation.${rootHint}
|
|
600
600
|
The scenario most likely continues inside this area — prefer its elements for your next actions.
|
|
601
601
|
The rest of the page (navigation, menus, filters) is still interactive and remains available.
|
|
602
|
-
</
|
|
602
|
+
</region>
|
|
603
603
|
`;
|
|
604
604
|
}
|
|
605
605
|
|
|
@@ -663,7 +663,7 @@ export class Tester extends TaskAgent implements Agent {
|
|
|
663
663
|
return context;
|
|
664
664
|
}
|
|
665
665
|
|
|
666
|
-
if (
|
|
666
|
+
if (region.isOpen && region.name && this.pageStateHash && this.pageActionResult) {
|
|
667
667
|
const overlaySection = await this.researcher.researchOverlay(currentState, this.pageActionResult, this.pageStateHash).catch(this.skipResearch);
|
|
668
668
|
if (overlaySection) {
|
|
669
669
|
context += dedent`
|
package/src/ai/tools.ts
CHANGED
|
@@ -15,7 +15,6 @@ import { pause } from '../utils/loop.js';
|
|
|
15
15
|
import { WebElement } from '../utils/web-element.ts';
|
|
16
16
|
import type { ToolDeps } from './agent.ts';
|
|
17
17
|
import { Navigator } from './navigator.ts';
|
|
18
|
-
import type { AIProvider } from './provider.ts';
|
|
19
18
|
import { Researcher } from './researcher.ts';
|
|
20
19
|
import { sectionContextRule } from './rules.ts';
|
|
21
20
|
import { isInteractive } from './task-agent.ts';
|
|
@@ -31,7 +30,7 @@ interface AgentToolDeps extends ToolDeps {
|
|
|
31
30
|
|
|
32
31
|
export const ASSERTION_TOOLS = ['verify'] as const;
|
|
33
32
|
|
|
34
|
-
export function createCodeceptJSTools({ explorer, stateManager
|
|
33
|
+
export function createCodeceptJSTools({ explorer, stateManager }: ToolDeps, task: Task) {
|
|
35
34
|
return {
|
|
36
35
|
click: tool({
|
|
37
36
|
description: dedent`
|
|
@@ -44,6 +43,12 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
|
|
|
44
43
|
Container narrows search area. Use when page has multiple matching elements.
|
|
45
44
|
Example: Page has 3 "Delete" buttons in different rows:
|
|
46
45
|
I.click("Delete", ".row-1") - clicks Delete inside element with class row-1
|
|
46
|
+
I.click(locator, step.opts({ elementIndex: N })) - click the Nth element the locator matches
|
|
47
|
+
Use after a result reported several matches. N is the "Element N" number from that list.
|
|
48
|
+
step.opts() always goes LAST, after the container when there is one.
|
|
49
|
+
Example: the result listed 3 "Delete" buttons and you want the second:
|
|
50
|
+
I.click("Delete", step.opts({ elementIndex: 2 }))
|
|
51
|
+
I.click("Delete", ".table", step.opts({ elementIndex: 2 })) - same, narrowed to a container
|
|
47
52
|
|
|
48
53
|
IMPORTANT: This tool ONLY accepts click commands. For typing text, use form() tool.
|
|
49
54
|
CRITICAL: All commands MUST target the SAME element using different locators.
|
|
@@ -62,6 +67,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
|
|
|
62
67
|
3. I.click(CSS, container) - e.g. I.click("#btn", ".modal")
|
|
63
68
|
4. I.click(CSS) or I.click(XPath) - when locator already includes context (ID, XPath)
|
|
64
69
|
5. I.clickXY(x, y) - coordinates fallback
|
|
70
|
+
After a result reporting multiple matches, reuse that locator with step.opts({ elementIndex: N }) as the last argument.
|
|
65
71
|
`),
|
|
66
72
|
explanation: z.string().describe('Why you are clicking this element'),
|
|
67
73
|
}),
|
|
@@ -91,6 +97,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
|
|
|
91
97
|
const previousState = ActionResult.fromState(stateManager.getCurrentState()!);
|
|
92
98
|
const action = explorer.action();
|
|
93
99
|
const attempts: Array<{ command: string; success: boolean; error?: string }> = [];
|
|
100
|
+
let ambiguityError: Error | null = null;
|
|
94
101
|
|
|
95
102
|
for (let i = 0; i < commands.length; i++) {
|
|
96
103
|
const command = transformContainsCommand(commands[i]);
|
|
@@ -100,6 +107,8 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
|
|
|
100
107
|
if (action.lastError) attempt.error = errorText(action.lastError);
|
|
101
108
|
attempts.push(attempt);
|
|
102
109
|
|
|
110
|
+
if (!ambiguityError && action.lastError?.name === 'MultipleElementsFound') ambiguityError = action.lastError;
|
|
111
|
+
|
|
103
112
|
if (success) {
|
|
104
113
|
const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, command);
|
|
105
114
|
await commitNote(activeNote, TestResult.PASSED, toolResult, action);
|
|
@@ -107,31 +116,6 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
|
|
|
107
116
|
}
|
|
108
117
|
}
|
|
109
118
|
|
|
110
|
-
let disambiguated = null;
|
|
111
|
-
if (attempts.some((a) => a.error?.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN))) {
|
|
112
|
-
disambiguated = await disambiguateElements(action.lastError, explanation, ai);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
if (disambiguated) {
|
|
116
|
-
debugLog('Disambiguation picked element %d', disambiguated.position);
|
|
117
|
-
const failedCommand = attempts.find((a) => a.error?.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN))?.command;
|
|
118
|
-
const retryCommands = [];
|
|
119
|
-
if (failedCommand) {
|
|
120
|
-
retryCommands.push(failedCommand.replace(/\)$/, `, step.opts({ elementIndex: ${disambiguated.position} }))`));
|
|
121
|
-
}
|
|
122
|
-
retryCommands.push(`I.click('${disambiguated.xpath.replace(/'/g, "\\'")}')`);
|
|
123
|
-
|
|
124
|
-
for (const retryCmd of retryCommands) {
|
|
125
|
-
if (!(await action.attempt(retryCmd, explanation))) {
|
|
126
|
-
attempts.push({ command: retryCmd, success: false, error: errorText(action.lastError) });
|
|
127
|
-
continue;
|
|
128
|
-
}
|
|
129
|
-
const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, retryCmd);
|
|
130
|
-
await commitNote(activeNote, TestResult.PASSED, toolResult, action);
|
|
131
|
-
return successToolResult('click', { ...toolResult, attempts, code: retryCmd, disambiguated: true }, action);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
119
|
const toolResult = await ActionResult.fromState(stateManager.getCurrentState()!).toToolResult(previousState, commands[0]);
|
|
136
120
|
await commitNote(activeNote, TestResult.FAILED, toolResult, action);
|
|
137
121
|
|
|
@@ -145,7 +129,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
|
|
|
145
129
|
attempts,
|
|
146
130
|
suggestion,
|
|
147
131
|
},
|
|
148
|
-
action.lastError
|
|
132
|
+
ambiguityError || action.lastError
|
|
149
133
|
);
|
|
150
134
|
},
|
|
151
135
|
}),
|
|
@@ -413,13 +397,7 @@ export function createCodeceptJSTools({ explorer, stateManager, ai }: ToolDeps,
|
|
|
413
397
|
const message = errorText(action.lastError);
|
|
414
398
|
await commitNote(activeNote, TestResult.FAILED, toolResult, action);
|
|
415
399
|
|
|
416
|
-
|
|
417
|
-
if (message.toLowerCase().includes(MULTIPLE_ELEMENTS_PATTERN)) {
|
|
418
|
-
const disambiguated = await disambiguateElements(action.lastError, explanation, ai);
|
|
419
|
-
if (disambiguated) {
|
|
420
|
-
formSuggestion = `Multiple elements matched. Add step.opts({ elementIndex: ${disambiguated.position} }) to the failing command. Fallback locator: ${disambiguated.xpath}`;
|
|
421
|
-
}
|
|
422
|
-
}
|
|
400
|
+
const formSuggestion = 'Commands after the failing one never ran. Retry only those, using click() or form().';
|
|
423
401
|
|
|
424
402
|
return failedToolResult(
|
|
425
403
|
'form',
|
|
@@ -1284,13 +1262,11 @@ export async function failedToolResult(action: string, message: string, data?: R
|
|
|
1284
1262
|
|
|
1285
1263
|
function getMultipleElementsSuggestion(): string {
|
|
1286
1264
|
return dedent`
|
|
1287
|
-
Multiple elements matched your locator
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
5. Use xpathCheck() to inspect matched elements and pick the correct one
|
|
1293
|
-
6. Use visualClick() to click the right element by visual appearance
|
|
1265
|
+
Multiple elements matched your locator, so that command did nothing — it selected no element and acted on none.
|
|
1266
|
+
Read the numbered elements list and click the one you meant by its number:
|
|
1267
|
+
reuse the same locator with step.opts({ elementIndex: N }) as the last argument.
|
|
1268
|
+
If none of them is the element you want, narrow the locator with a container or its full unique text.
|
|
1269
|
+
If the list is missing, call xpathCheck() to see what the locator matches.
|
|
1294
1270
|
`;
|
|
1295
1271
|
}
|
|
1296
1272
|
|
|
@@ -1367,50 +1343,6 @@ export async function formatMatchedElements(error: Error | null | undefined): Pr
|
|
|
1367
1343
|
return formatElementList(details);
|
|
1368
1344
|
}
|
|
1369
1345
|
|
|
1370
|
-
async function disambiguateElements(error: Error | null | undefined, explanation: string, provider: AIProvider): Promise<{ position: number; xpath: string } | null> {
|
|
1371
|
-
const elementDetails = await extractWebElements(error);
|
|
1372
|
-
if (!elementDetails) return null;
|
|
1373
|
-
|
|
1374
|
-
const elementList = formatElementList(elementDetails);
|
|
1375
|
-
|
|
1376
|
-
const schema = z.object({
|
|
1377
|
-
position: z.number().nullable().describe('1-based position of the correct element, or null if none match'),
|
|
1378
|
-
});
|
|
1379
|
-
|
|
1380
|
-
try {
|
|
1381
|
-
const result = await provider.generateObject(
|
|
1382
|
-
[
|
|
1383
|
-
{
|
|
1384
|
-
role: 'user' as const,
|
|
1385
|
-
content: dedent`
|
|
1386
|
-
A click action failed because multiple elements matched the locator.
|
|
1387
|
-
The intended action was: ${explanation}
|
|
1388
|
-
|
|
1389
|
-
Here are the matched elements:
|
|
1390
|
-
|
|
1391
|
-
${elementList}
|
|
1392
|
-
|
|
1393
|
-
Which element (1-${elementDetails.length}) best matches the intended action?
|
|
1394
|
-
Return the position number, or null if none of them match.
|
|
1395
|
-
`,
|
|
1396
|
-
},
|
|
1397
|
-
],
|
|
1398
|
-
schema,
|
|
1399
|
-
provider.getModelForAgent(),
|
|
1400
|
-
{ agentName: 'disambiguator', timeout: 15000 }
|
|
1401
|
-
);
|
|
1402
|
-
|
|
1403
|
-
const position = result?.object?.position;
|
|
1404
|
-
if (position && position >= 1 && position <= elementDetails.length) {
|
|
1405
|
-
return { position, xpath: elementDetails[position - 1].xpath };
|
|
1406
|
-
}
|
|
1407
|
-
return null;
|
|
1408
|
-
} catch (e) {
|
|
1409
|
-
debugLog('Element disambiguation AI call failed: %s', e);
|
|
1410
|
-
return null;
|
|
1411
|
-
}
|
|
1412
|
-
}
|
|
1413
|
-
|
|
1414
1346
|
function getNotFoundSuggestion(errorMessage: string): string | null {
|
|
1415
1347
|
if (!errorMessage.includes('not found')) {
|
|
1416
1348
|
return null;
|
package/src/api/request-store.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readdirSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { isDynamicSegment } from '../utils/url-matcher.ts';
|
|
3
|
+
import { generalizeUrl, isDynamicSegment } from '../utils/url-matcher.ts';
|
|
4
4
|
import { RequestResult } from './request-result.ts';
|
|
5
5
|
|
|
6
6
|
const AUTH_HEADERS = ['authorization', 'x-api-key', 'x-csrf-token'];
|
|
@@ -46,10 +46,6 @@ export class RequestStore {
|
|
|
46
46
|
result.save(this.outputDir);
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
addRequest(result: RequestResult): void {
|
|
50
|
-
this.addMadeRequest(result);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
49
|
getCapturedRequests(): RequestResult[] {
|
|
54
50
|
return this.capturedRequests;
|
|
55
51
|
}
|
|
@@ -58,27 +54,10 @@ export class RequestStore {
|
|
|
58
54
|
return this.madeRequests;
|
|
59
55
|
}
|
|
60
56
|
|
|
61
|
-
getRequests(): RequestResult[] {
|
|
62
|
-
return this.madeRequests;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
57
|
getLastRequest(): RequestResult | undefined {
|
|
66
58
|
return this.madeRequests[this.madeRequests.length - 1];
|
|
67
59
|
}
|
|
68
60
|
|
|
69
|
-
getRequestsByEndpoint(pathPrefix: string): RequestResult[] {
|
|
70
|
-
return this.madeRequests.filter((r) => r.path.startsWith(pathPrefix));
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
getRequestsByMethod(method: string): RequestResult[] {
|
|
74
|
-
const upper = method.toUpperCase();
|
|
75
|
-
return this.madeRequests.filter((r) => r.method === upper);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
getRequestsByStatus(status: number): RequestResult[] {
|
|
79
|
-
return this.madeRequests.filter((r) => r.status === status);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
61
|
toEndpointList(scopePath?: string): string {
|
|
83
62
|
let requests = this.capturedRequests;
|
|
84
63
|
if (scopePath) requests = this.getWriteRequestsForScope(scopePath);
|
|
@@ -87,7 +66,7 @@ export class RequestStore {
|
|
|
87
66
|
const lines: string[] = [];
|
|
88
67
|
|
|
89
68
|
for (const req of requests) {
|
|
90
|
-
const key = `${req.method} ${
|
|
69
|
+
const key = `${req.method} ${generalizeUrl(req.path, () => '{id}')}`;
|
|
91
70
|
if (seen.has(key)) continue;
|
|
92
71
|
seen.add(key);
|
|
93
72
|
lines.push(key);
|
|
@@ -113,14 +92,18 @@ export class RequestStore {
|
|
|
113
92
|
|
|
114
93
|
findCapturedRequest(method: string, searchPath: string): RequestResult | undefined {
|
|
115
94
|
const upper = method.toUpperCase();
|
|
116
|
-
const search =
|
|
95
|
+
const search = generalizeUrl(searchPath, () => '{id}')
|
|
96
|
+
.split('/')
|
|
97
|
+
.filter(Boolean);
|
|
117
98
|
|
|
118
99
|
let best: RequestResult | undefined;
|
|
119
100
|
let bestScore = -1;
|
|
120
101
|
|
|
121
102
|
for (const req of this.capturedRequests) {
|
|
122
103
|
if (req.method !== upper) continue;
|
|
123
|
-
const segments =
|
|
104
|
+
const segments = generalizeUrl(req.path, () => '{id}')
|
|
105
|
+
.split('/')
|
|
106
|
+
.filter(Boolean);
|
|
124
107
|
if (segments.length < search.length) continue;
|
|
125
108
|
if (!search.every((segment, i) => segment === segments[i])) continue;
|
|
126
109
|
|
|
@@ -159,8 +142,7 @@ export class RequestStore {
|
|
|
159
142
|
}
|
|
160
143
|
|
|
161
144
|
getWriteRequestsForScope(scopePath: string): RequestResult[] {
|
|
162
|
-
const
|
|
163
|
-
const writes = this.capturedRequests.filter((r) => writeMethods.has(r.method));
|
|
145
|
+
const writes = this.capturedRequests.filter((r) => r.isWrite);
|
|
164
146
|
const scopeSegments = scopePath.split('/').filter(Boolean);
|
|
165
147
|
if (scopeSegments.length === 0) return writes;
|
|
166
148
|
|
|
@@ -191,6 +173,10 @@ export class RequestStore {
|
|
|
191
173
|
}
|
|
192
174
|
}
|
|
193
175
|
|
|
176
|
+
export function isFailedRequest(request: RequestResult): boolean {
|
|
177
|
+
return request.status >= 400 || Boolean(request.error);
|
|
178
|
+
}
|
|
179
|
+
|
|
194
180
|
function normalizePathPattern(urlPath: string): string {
|
|
195
181
|
return urlPath
|
|
196
182
|
.split('/')
|
package/src/api/spec-reader.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { tag } from '../utils/logger.ts';
|
|
|
6
6
|
|
|
7
7
|
export function validateSpecs(specs?: string[]): void {
|
|
8
8
|
if (!specs?.length) {
|
|
9
|
-
throw new Error('API spec is required.
|
|
9
|
+
throw new Error('API spec is required. Pass --spec, set EXPLORBOT_API_SPEC, or set api.spec in your config file.');
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
12
|
|
|
@@ -5,6 +5,7 @@ import { type AIConfig, ConfigParser, EXPLORBOT_ENV_VARS, type ReporterConfig, c
|
|
|
5
5
|
import { listSites } from '../global-config.js';
|
|
6
6
|
import { Reporter } from '../reporter.js';
|
|
7
7
|
import { getCliName } from '../utils/cli-name.js';
|
|
8
|
+
import { renderSection } from '../utils/cli-section.js';
|
|
8
9
|
import { tag } from '../utils/logger.js';
|
|
9
10
|
import { BaseCommand } from './base-command.js';
|
|
10
11
|
|
|
@@ -37,7 +38,7 @@ export class ConfigCommand extends BaseCommand {
|
|
|
37
38
|
const dirs: Record<string, string> = {};
|
|
38
39
|
if (options.root) {
|
|
39
40
|
for (const [name, dir] of Object.entries({ output: 'output', ...config.dirs })) {
|
|
40
|
-
dirs[name] = path.
|
|
41
|
+
dirs[name] = path.resolve(options.root, dir);
|
|
41
42
|
}
|
|
42
43
|
}
|
|
43
44
|
|
|
@@ -75,13 +76,7 @@ export class ConfigCommand extends BaseCommand {
|
|
|
75
76
|
if (options.json) return JSON.stringify(data, null, 2);
|
|
76
77
|
|
|
77
78
|
const lines: string[] = [];
|
|
78
|
-
const section = (title: string, entries: [string, string][]) =>
|
|
79
|
-
if (!entries.length) return;
|
|
80
|
-
const width = Math.max(...entries.map(([label]) => label.length));
|
|
81
|
-
lines.push(chalk.bold(title));
|
|
82
|
-
for (const [label, value] of entries) lines.push(` ${chalk.dim(label.padEnd(width))} ${value}`);
|
|
83
|
-
lines.push('');
|
|
84
|
-
};
|
|
79
|
+
const section = (title: string, entries: [string, string][]) => lines.push(...renderSection(title, entries));
|
|
85
80
|
|
|
86
81
|
const general: [string, string][] = [['config', data.config || 'EXPLORBOT_* environment variables']];
|
|
87
82
|
if (data.url) general.push(['url', data.url]);
|
|
@@ -26,7 +26,7 @@ export class DrillCommand extends BaseCommand {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
private parseKnowledgeArg(args: string): string | undefined {
|
|
29
|
-
const match = args.match(/--knowledge\s+(\S+)/);
|
|
29
|
+
const match = args.match(/--save-knowledge\s+(\S+)/);
|
|
30
30
|
return match ? match[1] : undefined;
|
|
31
31
|
}
|
|
32
32
|
|