explorbot 0.4.9 → 0.5.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/README.md +4 -1
- package/bin/mdq.ts +18 -0
- package/boat/api-tester/src/ai/chief.ts +72 -0
- package/boat/api-tester/src/api-client.ts +37 -0
- package/boat/prima/src/prima.ts +41 -2
- package/dist/bin/mdq.js +19 -0
- package/dist/boat/api-tester/src/ai/chief.js +69 -0
- package/dist/boat/api-tester/src/api-client.js +26 -0
- package/dist/boat/prima/src/prima.js +42 -2
- package/dist/package.json +3 -2
- package/dist/src/action.js +4 -2
- package/dist/src/ai/agent.d.ts +3 -1
- package/dist/src/ai/judge-provider.d.ts +17 -0
- package/dist/src/ai/judge-provider.js +56 -0
- package/dist/src/ai/judge-tool.d.ts +2 -0
- package/dist/src/ai/judge-tool.js +33 -0
- package/dist/src/ai/judge.d.ts +28 -0
- package/dist/src/ai/judge.js +71 -0
- package/dist/src/ai/navigator.d.ts +3 -1
- package/dist/src/ai/navigator.js +24 -28
- package/dist/src/ai/pilot.d.ts +4 -0
- package/dist/src/ai/pilot.js +67 -9
- package/dist/src/ai/planner.js +9 -6
- package/dist/src/ai/provider.d.ts +4 -1
- package/dist/src/ai/provider.js +52 -7
- package/dist/src/ai/rerunner.js +7 -0
- package/dist/src/ai/researcher/deep-analysis.js +2 -2
- package/dist/src/ai/researcher/locators.js +2 -2
- package/dist/src/ai/researcher/pagination.js +1 -1
- package/dist/src/ai/researcher/research-result.js +2 -2
- package/dist/src/ai/researcher.js +1 -1
- package/dist/src/ai/task-agent.d.ts +2 -0
- package/dist/src/ai/task-agent.js +3 -1
- package/dist/src/ai/tester.js +19 -15
- package/dist/src/ai/tools.d.ts +4 -3
- package/dist/src/ai/tools.js +35 -7
- package/dist/src/api/request-result.js +2 -1
- package/dist/src/api/xhr-capture.js +2 -1
- package/dist/src/command-handler.d.ts +1 -0
- package/dist/src/command-handler.js +24 -3
- package/dist/src/commands/base-command.d.ts +5 -0
- package/dist/src/commands/base-command.js +3 -0
- package/dist/src/commands/explore-command.d.ts +2 -1
- package/dist/src/commands/explore-command.js +12 -1
- package/dist/src/commands/freesail-command.js +8 -2
- package/dist/src/commands/init-command.js +1 -1
- package/dist/src/commands/navigate-command.d.ts +2 -1
- package/dist/src/commands/navigate-command.js +6 -0
- package/dist/src/commands/plan-load-command.d.ts +2 -1
- package/dist/src/commands/plan-load-command.js +4 -0
- package/dist/src/commands/plans-command.d.ts +3 -9
- package/dist/src/commands/plans-command.js +11 -21
- package/dist/src/commands/rerun-command.d.ts +2 -1
- package/dist/src/commands/rerun-command.js +5 -1
- package/dist/src/commands/research-command.d.ts +2 -1
- package/dist/src/commands/research-command.js +6 -0
- package/dist/src/commands/test-command.d.ts +2 -1
- package/dist/src/commands/test-command.js +4 -1
- package/dist/src/components/Autocomplete.js +26 -12
- package/dist/src/components/InputReadline.js +10 -1
- package/dist/src/config.d.ts +6 -0
- package/dist/src/experience-tracker.js +4 -3
- package/dist/src/explorbot.d.ts +3 -0
- package/dist/src/explorbot.js +8 -0
- package/dist/src/explorer.js +2 -3
- package/dist/src/knowledge-tracker.js +1 -1
- package/dist/src/reporter.js +8 -4
- package/dist/src/state-manager.d.ts +2 -0
- package/dist/src/state-manager.js +16 -0
- package/dist/src/test-plan.d.ts +11 -0
- package/dist/src/test-plan.js +54 -2
- package/dist/src/utils/aria-ref.js +1 -1
- package/dist/src/utils/logger.js +9 -3
- package/dist/src/utils/markdown-query.d.ts +1 -48
- package/dist/src/utils/markdown-query.js +1 -444
- package/dist/src/utils/mdq/cli.d.ts +6 -0
- package/dist/src/utils/mdq/cli.js +122 -0
- package/dist/src/utils/mdq/edit.d.ts +24 -0
- package/dist/src/utils/mdq/edit.js +147 -0
- package/dist/src/utils/mdq/query.d.ts +118 -0
- package/dist/src/utils/mdq/query.js +451 -0
- package/dist/src/utils/step-analyzer.d.ts +3 -0
- package/dist/src/utils/step-analyzer.js +7 -0
- package/dist/src/utils/strings.d.ts +1 -0
- package/dist/src/utils/strings.js +7 -0
- package/dist/src/utils/test-files.d.ts +1 -0
- package/dist/src/utils/test-files.js +5 -2
- package/dist/src/utils/url-matcher.d.ts +1 -0
- package/dist/src/utils/url-matcher.js +7 -0
- package/docs/api-testing/planning.md +1 -1
- package/docs/superpowers/plans/2026-09-15-mdq-package.md +130 -94
- package/docs/superpowers/specs/2026-09-18-judge-decision-model-design.md +79 -0
- package/package.json +3 -2
- package/src/action.ts +4 -2
- package/src/ai/agent.ts +3 -1
- package/src/ai/judge-provider.ts +62 -0
- package/src/ai/judge-tool.ts +35 -0
- package/src/ai/judge.ts +75 -0
- package/src/ai/navigator.ts +24 -27
- package/src/ai/pilot.ts +68 -9
- package/src/ai/planner.ts +9 -6
- package/src/ai/provider.ts +51 -7
- package/src/ai/rerunner.ts +4 -0
- package/src/ai/researcher/deep-analysis.ts +2 -2
- package/src/ai/researcher/locators.ts +2 -2
- package/src/ai/researcher/pagination.ts +1 -1
- package/src/ai/researcher/research-result.ts +2 -2
- package/src/ai/researcher.ts +1 -1
- package/src/ai/task-agent.ts +4 -1
- package/src/ai/tester.ts +19 -16
- package/src/ai/tools.ts +42 -7
- package/src/api/request-result.ts +2 -1
- package/src/api/xhr-capture.ts +2 -1
- package/src/command-handler.ts +28 -3
- package/src/commands/base-command.ts +9 -0
- package/src/commands/explore-command.ts +15 -2
- package/src/commands/freesail-command.ts +9 -2
- package/src/commands/init-command.ts +1 -1
- package/src/commands/navigate-command.ts +8 -1
- package/src/commands/plan-load-command.ts +6 -1
- package/src/commands/plans-command.ts +13 -29
- package/src/commands/rerun-command.ts +7 -2
- package/src/commands/research-command.ts +8 -1
- package/src/commands/test-command.ts +6 -2
- package/src/components/Autocomplete.tsx +39 -10
- package/src/components/InputReadline.tsx +10 -1
- package/src/config.ts +1 -0
- package/src/experience-tracker.ts +4 -3
- package/src/explorbot.ts +8 -0
- package/src/explorer.ts +2 -2
- package/src/knowledge-tracker.ts +1 -1
- package/src/reporter.ts +8 -4
- package/src/state-manager.ts +16 -0
- package/src/test-plan.ts +67 -2
- package/src/utils/aria-ref.ts +1 -1
- package/src/utils/logger.ts +7 -2
- package/src/utils/markdown-query.ts +1 -519
- package/src/utils/mdq/cli.ts +118 -0
- package/src/utils/mdq/edit.ts +158 -0
- package/src/utils/mdq/query.ts +556 -0
- package/src/utils/step-analyzer.ts +8 -0
- package/src/utils/strings.ts +7 -0
- package/src/utils/test-files.ts +5 -2
- package/src/utils/url-matcher.ts +7 -0
package/README.md
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="assets/logos/explorbot/explorbot-for-dark-theme.svg">
|
|
4
|
+
<img src="assets/logos/explorbot/explorbot-for-light-theme.svg" alt="Explorbot" width="560">
|
|
5
|
+
</picture>
|
|
3
6
|
</p>
|
|
4
7
|
|
|
5
8
|
<p align="center"><b>The vibe-testing agent for web applications.</b></p>
|
package/bin/mdq.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { runMdq } from '../src/utils/mdq/cli.ts';
|
|
3
|
+
|
|
4
|
+
async function readStdin(): Promise<string> {
|
|
5
|
+
if (process.stdin.isTTY) return '';
|
|
6
|
+
process.stdin.setEncoding('utf8');
|
|
7
|
+
let text = '';
|
|
8
|
+
for await (const chunk of process.stdin) text += chunk;
|
|
9
|
+
return text;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const result = await runMdq(process.argv.slice(2), readStdin);
|
|
13
|
+
if (result.output) {
|
|
14
|
+
let text = result.output;
|
|
15
|
+
if (!text.endsWith('\n')) text = `${text}\n`;
|
|
16
|
+
process.stdout.write(text);
|
|
17
|
+
}
|
|
18
|
+
process.exit(result.code);
|
|
@@ -8,6 +8,7 @@ import { Observability } from '../../../../src/observability.ts';
|
|
|
8
8
|
import { Plan, Test } from '../../../../src/test-plan.ts';
|
|
9
9
|
import { createDebug, tag } from '../../../../src/utils/logger.ts';
|
|
10
10
|
import { RulesLoader } from '../../../../src/utils/rules-loader.ts';
|
|
11
|
+
import { isSecretName, registerSecret } from '../../../../src/utils/secrets.ts';
|
|
11
12
|
import type { ApiClient } from '../api-client.ts';
|
|
12
13
|
import type { ApibotConfig } from '../config.ts';
|
|
13
14
|
import { getActiveStyle, getStyles } from './chief/styles.ts';
|
|
@@ -28,6 +29,21 @@ const ApiTasksSchema = z.object({
|
|
|
28
29
|
.describe('List of testing scenarios'),
|
|
29
30
|
});
|
|
30
31
|
|
|
32
|
+
const NamedValueSchema = z.object({ name: z.string(), value: z.string() });
|
|
33
|
+
|
|
34
|
+
const RequestDefaultsSchema = z.object({
|
|
35
|
+
files: z
|
|
36
|
+
.array(
|
|
37
|
+
z.object({
|
|
38
|
+
file: z.number().describe('Number of the knowledge file the values come from'),
|
|
39
|
+
headers: z.array(NamedValueSchema).describe('Headers to send, each value exactly as it goes on the wire'),
|
|
40
|
+
query: z.array(NamedValueSchema).describe('Query parameters to send'),
|
|
41
|
+
body: z.array(NamedValueSchema).describe('Fields a request body must contain, each value as a JSON literal'),
|
|
42
|
+
})
|
|
43
|
+
)
|
|
44
|
+
.describe('One entry per knowledge file that names values to send with requests'),
|
|
45
|
+
});
|
|
46
|
+
|
|
31
47
|
const ChiefBase = WithSessionDedup(Object as unknown as new (...args: any[]) => object);
|
|
32
48
|
|
|
33
49
|
export class Chief extends ChiefBase {
|
|
@@ -37,6 +53,7 @@ export class Chief extends ChiefBase {
|
|
|
37
53
|
private knowledgeTracker?: KnowledgeTracker;
|
|
38
54
|
currentPlan: Plan | null = null;
|
|
39
55
|
private lastStyleName = '';
|
|
56
|
+
private readKnowledgeFiles = new Set<string>();
|
|
40
57
|
|
|
41
58
|
MIN_TASKS = 3;
|
|
42
59
|
MAX_TASKS = 10;
|
|
@@ -56,6 +73,7 @@ export class Chief extends ChiefBase {
|
|
|
56
73
|
debugLog('Sending planning prompt to AI provider');
|
|
57
74
|
|
|
58
75
|
await Observability.run(`chief: ${endpoint}`, { tags: ['chief'], sessionId: endpoint }, async () => {
|
|
76
|
+
await this.prepareRequestDefaults(endpoint);
|
|
59
77
|
const sampleData = await this.collectSampleData(endpoint);
|
|
60
78
|
const conversation = this.buildConversation(endpoint, opts?.style, sampleData);
|
|
61
79
|
|
|
@@ -127,6 +145,52 @@ export class Chief extends ChiefBase {
|
|
|
127
145
|
}
|
|
128
146
|
}
|
|
129
147
|
|
|
148
|
+
private async prepareRequestDefaults(endpoint: string): Promise<void> {
|
|
149
|
+
if (!this.apiClient) return;
|
|
150
|
+
const files = (this.knowledgeTracker?.getEndpointKnowledge(endpoint) || []).filter((knowledge) => !this.readKnowledgeFiles.has(knowledge.filePath));
|
|
151
|
+
if (!files.length) return;
|
|
152
|
+
|
|
153
|
+
const knowledgeFiles = files.map((knowledge, index) => `<file number="${index + 1}" endpoint="${knowledge.endpoint}">\n${knowledge.content}\n</file>`).join('\n\n');
|
|
154
|
+
const prompt = dedent`
|
|
155
|
+
<task>
|
|
156
|
+
Extract the values these knowledge files say must be sent with API requests:
|
|
157
|
+
- headers: authentication, API keys and other headers, written exactly as sent (include the auth scheme when one is named)
|
|
158
|
+
- query: query parameters requests need
|
|
159
|
+
- body: fields request bodies must contain, each value written as a JSON literal
|
|
160
|
+
Copy values exactly as written. Never invent a value. Skip facts that are not about what to send.
|
|
161
|
+
</task>
|
|
162
|
+
|
|
163
|
+
<knowledge_files>
|
|
164
|
+
${knowledgeFiles}
|
|
165
|
+
</knowledge_files>
|
|
166
|
+
`;
|
|
167
|
+
|
|
168
|
+
const result = await this.provider.generateObject([{ role: 'user', content: prompt }], RequestDefaultsSchema, this.provider.getModelForAgent('chief'), { agentName: 'chief' }).catch((error: Error) => {
|
|
169
|
+
tag('warning').log(`Could not read request defaults from knowledge: ${error.message}`);
|
|
170
|
+
return null;
|
|
171
|
+
});
|
|
172
|
+
if (!result) return;
|
|
173
|
+
for (const knowledge of files) this.readKnowledgeFiles.add(knowledge.filePath);
|
|
174
|
+
|
|
175
|
+
const entries: z.infer<typeof RequestDefaultsSchema>['files'] = result.object?.files || [];
|
|
176
|
+
for (const entry of entries) {
|
|
177
|
+
const knowledge = files[entry.file - 1];
|
|
178
|
+
if (!knowledge?.endpoint) continue;
|
|
179
|
+
|
|
180
|
+
const headers = Object.fromEntries(entry.headers.map(({ name, value }) => [name, value]));
|
|
181
|
+
const query = Object.fromEntries(entry.query.map(({ name, value }) => [name, value]));
|
|
182
|
+
const body = Object.fromEntries(entry.body.map(({ name, value }) => [name, parseJsonLiteral(value)]));
|
|
183
|
+
const sent = Object.entries({ headers, query, body }).filter(([, values]) => Object.keys(values).length);
|
|
184
|
+
if (!sent.length) continue;
|
|
185
|
+
|
|
186
|
+
for (const [name, value] of sent.flatMap(([, values]) => Object.entries(values))) {
|
|
187
|
+
if (isSecretName(name)) registerSecret(String(value));
|
|
188
|
+
}
|
|
189
|
+
this.apiClient.addRequestDefaults({ pattern: knowledge.endpoint, headers, query, body });
|
|
190
|
+
tag('info').log(`Request defaults from knowledge for ${knowledge.endpoint}: ${sent.map(([kind, values]) => `${kind} ${Object.keys(values).join(', ')}`).join('; ')}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
130
194
|
private async collectSampleData(endpoint: string): Promise<string> {
|
|
131
195
|
if (!this.apiClient) return '';
|
|
132
196
|
|
|
@@ -341,3 +405,11 @@ export class Chief extends ChiefBase {
|
|
|
341
405
|
`;
|
|
342
406
|
}
|
|
343
407
|
}
|
|
408
|
+
|
|
409
|
+
function parseJsonLiteral(value: string): unknown {
|
|
410
|
+
try {
|
|
411
|
+
return JSON.parse(value);
|
|
412
|
+
} catch {
|
|
413
|
+
return value;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { ApiClient as BaseApiClient } from '../../../src/api/api-client.ts';
|
|
2
|
+
import type { RequestResult } from '../../../src/api/request-result.ts';
|
|
3
|
+
import { extractStatePath, matchesUrl } from '../../../src/utils/url-matcher.ts';
|
|
2
4
|
import type { HookFn } from './config.ts';
|
|
3
5
|
|
|
6
|
+
const BODY_METHODS = ['POST', 'PUT', 'PATCH'];
|
|
7
|
+
|
|
4
8
|
export class ApiClient extends BaseApiClient {
|
|
5
9
|
private bootstrapHook?: HookFn;
|
|
6
10
|
private teardownHook?: HookFn;
|
|
11
|
+
private requestDefaults: RequestDefaults[] = [];
|
|
7
12
|
|
|
8
13
|
constructor(baseEndpoint: string, defaultHeaders: Record<string, string> = {}, hooks?: { bootstrap?: HookFn; teardown?: HookFn }) {
|
|
9
14
|
super(baseEndpoint, defaultHeaders);
|
|
@@ -25,4 +30,36 @@ export class ApiClient extends BaseApiClient {
|
|
|
25
30
|
const ctx = { headers: this.getHeaders(), baseEndpoint: this.getBaseEndpoint() };
|
|
26
31
|
await this.teardownHook(ctx);
|
|
27
32
|
}
|
|
33
|
+
|
|
34
|
+
addRequestDefaults(defaults: RequestDefaults): void {
|
|
35
|
+
this.requestDefaults.push(defaults);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
override async request(opts: Parameters<BaseApiClient['request']>[0]): Promise<RequestResult> {
|
|
39
|
+
const path = extractStatePath(opts.path);
|
|
40
|
+
const matching = this.requestDefaults.filter((defaults) => matchesUrl(defaults.pattern, path));
|
|
41
|
+
if (!matching.length) return super.request(opts);
|
|
42
|
+
|
|
43
|
+
const knowledgeHeaders: Record<string, string> = {};
|
|
44
|
+
for (const defaults of matching) {
|
|
45
|
+
for (const [name, value] of Object.entries(defaults.headers)) knowledgeHeaders[name.toLowerCase()] = value;
|
|
46
|
+
}
|
|
47
|
+
for (const name of Object.keys({ ...this.getHeaders(), ...opts.headers })) delete knowledgeHeaders[name.toLowerCase()];
|
|
48
|
+
|
|
49
|
+
const headers = { ...knowledgeHeaders, ...this.getHeaders(), ...opts.headers };
|
|
50
|
+
const queryParams = Object.assign({}, ...matching.map((defaults) => defaults.query), opts.queryParams);
|
|
51
|
+
let body = opts.body;
|
|
52
|
+
if (BODY_METHODS.includes(opts.method.toUpperCase()) && body && typeof body === 'object' && !Array.isArray(body)) {
|
|
53
|
+
body = Object.assign({}, ...matching.map((defaults) => defaults.body), body);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return super.request({ ...opts, headers, queryParams, body });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface RequestDefaults {
|
|
61
|
+
pattern: string;
|
|
62
|
+
headers: Record<string, string>;
|
|
63
|
+
query: Record<string, string>;
|
|
64
|
+
body: Record<string, unknown>;
|
|
28
65
|
}
|
package/boat/prima/src/prima.ts
CHANGED
|
@@ -8,6 +8,7 @@ import * as playwright from 'playwright';
|
|
|
8
8
|
import type { Browser } from 'playwright';
|
|
9
9
|
import { z } from 'zod';
|
|
10
10
|
import { ActionResult } from '../../../src/action-result.ts';
|
|
11
|
+
import { JUDGE_PAGE_CAP, UNDECIDED } from '../../../src/ai/judge.ts';
|
|
11
12
|
import { getPreviousResearch } from '../../../src/ai/researcher/cache.ts';
|
|
12
13
|
import { actionRule, locatorRule } from '../../../src/ai/rules.ts';
|
|
13
14
|
import { createAgentTools, createCodeceptJSTools, createRefTools } from '../../../src/ai/tools.ts';
|
|
@@ -20,7 +21,7 @@ import { Reporter } from '../../../src/reporter.ts';
|
|
|
20
21
|
import type { WebPageState } from '../../../src/state-manager.ts';
|
|
21
22
|
import { Stats } from '../../../src/stats.ts';
|
|
22
23
|
import { Task, Test, TestResult } from '../../../src/test-plan.ts';
|
|
23
|
-
import { ariaRefSnapshot } from '../../../src/utils/aria-ref.ts';
|
|
24
|
+
import { ariaRefSelector, ariaRefSnapshot, describeRef, parseAriaRefs, refIsGone } from '../../../src/utils/aria-ref.ts';
|
|
24
25
|
import { compactAriaSnapshot } from '../../../src/utils/aria.ts';
|
|
25
26
|
import { browserErrorMessage } from '../../../src/utils/browser-errors.ts';
|
|
26
27
|
import { pluralize } from '../../../src/utils/logger.ts';
|
|
@@ -57,7 +58,8 @@ function dropVolatileColumns(markdown: string): string {
|
|
|
57
58
|
const divider = `|${columns.map(() => '------').join('|')}|`;
|
|
58
59
|
const body = rows.map((row) => `| ${columns.map((name) => row[name] || '-').join(' | ')} |`);
|
|
59
60
|
return [header, divider, ...body, ''].join('\n');
|
|
60
|
-
})
|
|
61
|
+
})
|
|
62
|
+
.toString();
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
function cap(text: string, max: number): string {
|
|
@@ -446,6 +448,12 @@ export class Prima {
|
|
|
446
448
|
}
|
|
447
449
|
|
|
448
450
|
const previousState = this.bot.stateManager().getCurrentState();
|
|
451
|
+
|
|
452
|
+
if (!isUrl) {
|
|
453
|
+
const judged = await this.judgedGo(target, previousState);
|
|
454
|
+
if (judged) return judged;
|
|
455
|
+
}
|
|
456
|
+
|
|
449
457
|
let navigationError: unknown = null;
|
|
450
458
|
|
|
451
459
|
try {
|
|
@@ -925,6 +933,37 @@ export class Prima {
|
|
|
925
933
|
return snapshot || result.ariaSnapshot;
|
|
926
934
|
}
|
|
927
935
|
|
|
936
|
+
private async judgedGo(target: string, previousState: WebPageState | null): Promise<EnvelopeData | null> {
|
|
937
|
+
if (!previousState) return null;
|
|
938
|
+
const judge = this.bot.judge?.();
|
|
939
|
+
if (!judge) return null;
|
|
940
|
+
|
|
941
|
+
const refs = parseAriaRefs((await this.refAriaSnapshot(ActionResult.fromState(previousState))) || '').filter((entry) => entry.name);
|
|
942
|
+
const labels = refs.map((entry) => `${entry.role} named "${entry.name}"`);
|
|
943
|
+
const pick = await judge.decide(`Which listed control leads to: ${target}`, [...labels, UNDECIDED], { target });
|
|
944
|
+
if (!pick.value) return null;
|
|
945
|
+
|
|
946
|
+
const ref = refs[labels.indexOf(pick.value)].ref;
|
|
947
|
+
const explorer = this.bot.getExplorer();
|
|
948
|
+
if (await refIsGone(explorer, ref)) return null;
|
|
949
|
+
|
|
950
|
+
const named = await describeRef(explorer, ref);
|
|
951
|
+
const run = `I.usePlaywrightTo(${JSON.stringify(`go to ${target}`)}, async ({ page }) => page.locator(${JSON.stringify(ariaRefSelector(ref))}).click())`;
|
|
952
|
+
if (!(await explorer.action().attempt(run, `go ${target}`))) return null;
|
|
953
|
+
|
|
954
|
+
const newState = this.bot.stateManager().getCurrentState();
|
|
955
|
+
if (!newState) return null;
|
|
956
|
+
if (newState.hash === previousState.hash && newState.url === previousState.url) return null;
|
|
957
|
+
|
|
958
|
+
const page = ((await this.refAriaSnapshot(ActionResult.fromState(newState))) || '').slice(0, JUDGE_PAGE_CAP);
|
|
959
|
+
const arrived = await judge.decide(`The page now shows: ${target}`, null, { target, page });
|
|
960
|
+
if (arrived.rejected) return null;
|
|
961
|
+
|
|
962
|
+
let code = run;
|
|
963
|
+
if (named) code = `I.click(${JSON.stringify(named)})`;
|
|
964
|
+
return this.successEnvelope(`go ${target}`, [code], await this.capturedResult(newState), previousState);
|
|
965
|
+
}
|
|
966
|
+
|
|
928
967
|
private executedCodes(code: unknown): string[] {
|
|
929
968
|
if (typeof code !== 'string') return [];
|
|
930
969
|
return code
|
package/dist/bin/mdq.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { runMdq } from "../src/utils/mdq/cli.js";
|
|
3
|
+
async function readStdin() {
|
|
4
|
+
if (process.stdin.isTTY)
|
|
5
|
+
return '';
|
|
6
|
+
process.stdin.setEncoding('utf8');
|
|
7
|
+
let text = '';
|
|
8
|
+
for await (const chunk of process.stdin)
|
|
9
|
+
text += chunk;
|
|
10
|
+
return text;
|
|
11
|
+
}
|
|
12
|
+
const result = await runMdq(process.argv.slice(2), readStdin);
|
|
13
|
+
if (result.output) {
|
|
14
|
+
let text = result.output;
|
|
15
|
+
if (!text.endsWith('\n'))
|
|
16
|
+
text = `${text}\n`;
|
|
17
|
+
process.stdout.write(text);
|
|
18
|
+
}
|
|
19
|
+
process.exit(result.code);
|
|
@@ -6,6 +6,7 @@ import { Observability } from "../../../../src/observability.js";
|
|
|
6
6
|
import { Plan, Test } from "../../../../src/test-plan.js";
|
|
7
7
|
import { createDebug, tag } from "../../../../src/utils/logger.js";
|
|
8
8
|
import { RulesLoader } from "../../../../src/utils/rules-loader.js";
|
|
9
|
+
import { isSecretName, registerSecret } from "../../../../src/utils/secrets.js";
|
|
9
10
|
import { getActiveStyle, getStyles } from "./chief/styles.js";
|
|
10
11
|
const debugLog = createDebug('explorbot:chief');
|
|
11
12
|
const ApiTasksSchema = z.object({
|
|
@@ -19,6 +20,17 @@ const ApiTasksSchema = z.object({
|
|
|
19
20
|
}))
|
|
20
21
|
.describe('List of testing scenarios'),
|
|
21
22
|
});
|
|
23
|
+
const NamedValueSchema = z.object({ name: z.string(), value: z.string() });
|
|
24
|
+
const RequestDefaultsSchema = z.object({
|
|
25
|
+
files: z
|
|
26
|
+
.array(z.object({
|
|
27
|
+
file: z.number().describe('Number of the knowledge file the values come from'),
|
|
28
|
+
headers: z.array(NamedValueSchema).describe('Headers to send, each value exactly as it goes on the wire'),
|
|
29
|
+
query: z.array(NamedValueSchema).describe('Query parameters to send'),
|
|
30
|
+
body: z.array(NamedValueSchema).describe('Fields a request body must contain, each value as a JSON literal'),
|
|
31
|
+
}))
|
|
32
|
+
.describe('One entry per knowledge file that names values to send with requests'),
|
|
33
|
+
});
|
|
22
34
|
const ChiefBase = WithSessionDedup(Object);
|
|
23
35
|
export class Chief extends ChiefBase {
|
|
24
36
|
provider;
|
|
@@ -27,6 +39,7 @@ export class Chief extends ChiefBase {
|
|
|
27
39
|
knowledgeTracker;
|
|
28
40
|
currentPlan = null;
|
|
29
41
|
lastStyleName = '';
|
|
42
|
+
readKnowledgeFiles = new Set();
|
|
30
43
|
MIN_TASKS = 3;
|
|
31
44
|
MAX_TASKS = 10;
|
|
32
45
|
constructor(provider, config, apiClient, knowledgeTracker) {
|
|
@@ -42,6 +55,7 @@ export class Chief extends ChiefBase {
|
|
|
42
55
|
tag('info').log(`Planning style: ${opts.style}`);
|
|
43
56
|
debugLog('Sending planning prompt to AI provider');
|
|
44
57
|
await Observability.run(`chief: ${endpoint}`, { tags: ['chief'], sessionId: endpoint }, async () => {
|
|
58
|
+
await this.prepareRequestDefaults(endpoint);
|
|
45
59
|
const sampleData = await this.collectSampleData(endpoint);
|
|
46
60
|
const conversation = this.buildConversation(endpoint, opts?.style, sampleData);
|
|
47
61
|
const knowledge = this.knowledgeTracker?.renderEndpointKnowledge(endpoint);
|
|
@@ -105,6 +119,53 @@ export class Chief extends ChiefBase {
|
|
|
105
119
|
existing.add(test.scenario.toLowerCase());
|
|
106
120
|
}
|
|
107
121
|
}
|
|
122
|
+
async prepareRequestDefaults(endpoint) {
|
|
123
|
+
if (!this.apiClient)
|
|
124
|
+
return;
|
|
125
|
+
const files = (this.knowledgeTracker?.getEndpointKnowledge(endpoint) || []).filter((knowledge) => !this.readKnowledgeFiles.has(knowledge.filePath));
|
|
126
|
+
if (!files.length)
|
|
127
|
+
return;
|
|
128
|
+
const knowledgeFiles = files.map((knowledge, index) => `<file number="${index + 1}" endpoint="${knowledge.endpoint}">\n${knowledge.content}\n</file>`).join('\n\n');
|
|
129
|
+
const prompt = dedent `
|
|
130
|
+
<task>
|
|
131
|
+
Extract the values these knowledge files say must be sent with API requests:
|
|
132
|
+
- headers: authentication, API keys and other headers, written exactly as sent (include the auth scheme when one is named)
|
|
133
|
+
- query: query parameters requests need
|
|
134
|
+
- body: fields request bodies must contain, each value written as a JSON literal
|
|
135
|
+
Copy values exactly as written. Never invent a value. Skip facts that are not about what to send.
|
|
136
|
+
</task>
|
|
137
|
+
|
|
138
|
+
<knowledge_files>
|
|
139
|
+
${knowledgeFiles}
|
|
140
|
+
</knowledge_files>
|
|
141
|
+
`;
|
|
142
|
+
const result = await this.provider.generateObject([{ role: 'user', content: prompt }], RequestDefaultsSchema, this.provider.getModelForAgent('chief'), { agentName: 'chief' }).catch((error) => {
|
|
143
|
+
tag('warning').log(`Could not read request defaults from knowledge: ${error.message}`);
|
|
144
|
+
return null;
|
|
145
|
+
});
|
|
146
|
+
if (!result)
|
|
147
|
+
return;
|
|
148
|
+
for (const knowledge of files)
|
|
149
|
+
this.readKnowledgeFiles.add(knowledge.filePath);
|
|
150
|
+
const entries = result.object?.files || [];
|
|
151
|
+
for (const entry of entries) {
|
|
152
|
+
const knowledge = files[entry.file - 1];
|
|
153
|
+
if (!knowledge?.endpoint)
|
|
154
|
+
continue;
|
|
155
|
+
const headers = Object.fromEntries(entry.headers.map(({ name, value }) => [name, value]));
|
|
156
|
+
const query = Object.fromEntries(entry.query.map(({ name, value }) => [name, value]));
|
|
157
|
+
const body = Object.fromEntries(entry.body.map(({ name, value }) => [name, parseJsonLiteral(value)]));
|
|
158
|
+
const sent = Object.entries({ headers, query, body }).filter(([, values]) => Object.keys(values).length);
|
|
159
|
+
if (!sent.length)
|
|
160
|
+
continue;
|
|
161
|
+
for (const [name, value] of sent.flatMap(([, values]) => Object.entries(values))) {
|
|
162
|
+
if (isSecretName(name))
|
|
163
|
+
registerSecret(String(value));
|
|
164
|
+
}
|
|
165
|
+
this.apiClient.addRequestDefaults({ pattern: knowledge.endpoint, headers, query, body });
|
|
166
|
+
tag('info').log(`Request defaults from knowledge for ${knowledge.endpoint}: ${sent.map(([kind, values]) => `${kind} ${Object.keys(values).join(', ')}`).join('; ')}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
108
169
|
async collectSampleData(endpoint) {
|
|
109
170
|
if (!this.apiClient)
|
|
110
171
|
return '';
|
|
@@ -305,3 +366,11 @@ export class Chief extends ChiefBase {
|
|
|
305
366
|
`;
|
|
306
367
|
}
|
|
307
368
|
}
|
|
369
|
+
function parseJsonLiteral(value) {
|
|
370
|
+
try {
|
|
371
|
+
return JSON.parse(value);
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
return value;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { ApiClient as BaseApiClient } from "../../../src/api/api-client.js";
|
|
2
|
+
import { extractStatePath, matchesUrl } from "../../../src/utils/url-matcher.js";
|
|
3
|
+
const BODY_METHODS = ['POST', 'PUT', 'PATCH'];
|
|
2
4
|
export class ApiClient extends BaseApiClient {
|
|
3
5
|
bootstrapHook;
|
|
4
6
|
teardownHook;
|
|
7
|
+
requestDefaults = [];
|
|
5
8
|
constructor(baseEndpoint, defaultHeaders = {}, hooks) {
|
|
6
9
|
super(baseEndpoint, defaultHeaders);
|
|
7
10
|
this.bootstrapHook = hooks?.bootstrap;
|
|
@@ -22,4 +25,27 @@ export class ApiClient extends BaseApiClient {
|
|
|
22
25
|
const ctx = { headers: this.getHeaders(), baseEndpoint: this.getBaseEndpoint() };
|
|
23
26
|
await this.teardownHook(ctx);
|
|
24
27
|
}
|
|
28
|
+
addRequestDefaults(defaults) {
|
|
29
|
+
this.requestDefaults.push(defaults);
|
|
30
|
+
}
|
|
31
|
+
async request(opts) {
|
|
32
|
+
const path = extractStatePath(opts.path);
|
|
33
|
+
const matching = this.requestDefaults.filter((defaults) => matchesUrl(defaults.pattern, path));
|
|
34
|
+
if (!matching.length)
|
|
35
|
+
return super.request(opts);
|
|
36
|
+
const knowledgeHeaders = {};
|
|
37
|
+
for (const defaults of matching) {
|
|
38
|
+
for (const [name, value] of Object.entries(defaults.headers))
|
|
39
|
+
knowledgeHeaders[name.toLowerCase()] = value;
|
|
40
|
+
}
|
|
41
|
+
for (const name of Object.keys({ ...this.getHeaders(), ...opts.headers }))
|
|
42
|
+
delete knowledgeHeaders[name.toLowerCase()];
|
|
43
|
+
const headers = { ...knowledgeHeaders, ...this.getHeaders(), ...opts.headers };
|
|
44
|
+
const queryParams = Object.assign({}, ...matching.map((defaults) => defaults.query), opts.queryParams);
|
|
45
|
+
let body = opts.body;
|
|
46
|
+
if (BODY_METHODS.includes(opts.method.toUpperCase()) && body && typeof body === 'object' && !Array.isArray(body)) {
|
|
47
|
+
body = Object.assign({}, ...matching.map((defaults) => defaults.body), body);
|
|
48
|
+
}
|
|
49
|
+
return super.request({ ...opts, headers, queryParams, body });
|
|
50
|
+
}
|
|
25
51
|
}
|
|
@@ -7,6 +7,7 @@ import dedent from 'dedent';
|
|
|
7
7
|
import * as playwright from 'playwright';
|
|
8
8
|
import { z } from 'zod';
|
|
9
9
|
import { ActionResult } from "../../../src/action-result.js";
|
|
10
|
+
import { JUDGE_PAGE_CAP, UNDECIDED } from "../../../src/ai/judge.js";
|
|
10
11
|
import { getPreviousResearch } from "../../../src/ai/researcher/cache.js";
|
|
11
12
|
import { actionRule, locatorRule } from "../../../src/ai/rules.js";
|
|
12
13
|
import { createAgentTools, createCodeceptJSTools, createRefTools } from "../../../src/ai/tools.js";
|
|
@@ -18,7 +19,7 @@ import { findSiteWith, listSites } from "../../../src/global-config.js";
|
|
|
18
19
|
import { Reporter } from "../../../src/reporter.js";
|
|
19
20
|
import { Stats } from "../../../src/stats.js";
|
|
20
21
|
import { Task, Test, TestResult } from "../../../src/test-plan.js";
|
|
21
|
-
import { ariaRefSnapshot } from "../../../src/utils/aria-ref.js";
|
|
22
|
+
import { ariaRefSelector, ariaRefSnapshot, describeRef, parseAriaRefs, refIsGone } from "../../../src/utils/aria-ref.js";
|
|
22
23
|
import { compactAriaSnapshot } from "../../../src/utils/aria.js";
|
|
23
24
|
import { browserErrorMessage } from "../../../src/utils/browser-errors.js";
|
|
24
25
|
import { pluralize } from "../../../src/utils/logger.js";
|
|
@@ -52,7 +53,8 @@ function dropVolatileColumns(markdown) {
|
|
|
52
53
|
const divider = `|${columns.map(() => '------').join('|')}|`;
|
|
53
54
|
const body = rows.map((row) => `| ${columns.map((name) => row[name] || '-').join(' | ')} |`);
|
|
54
55
|
return [header, divider, ...body, ''].join('\n');
|
|
55
|
-
})
|
|
56
|
+
})
|
|
57
|
+
.toString();
|
|
56
58
|
}
|
|
57
59
|
function cap(text, max) {
|
|
58
60
|
if (text.length <= max)
|
|
@@ -405,6 +407,11 @@ export class Prima {
|
|
|
405
407
|
return guard;
|
|
406
408
|
}
|
|
407
409
|
const previousState = this.bot.stateManager().getCurrentState();
|
|
410
|
+
if (!isUrl) {
|
|
411
|
+
const judged = await this.judgedGo(target, previousState);
|
|
412
|
+
if (judged)
|
|
413
|
+
return judged;
|
|
414
|
+
}
|
|
408
415
|
let navigationError = null;
|
|
409
416
|
try {
|
|
410
417
|
await this.bot.agentNavigator().visit(target);
|
|
@@ -868,6 +875,39 @@ export class Prima {
|
|
|
868
875
|
const snapshot = await Promise.resolve(this.bot.getExplorer()?.withPage?.(ariaRefSnapshot)).catch(() => null);
|
|
869
876
|
return snapshot || result.ariaSnapshot;
|
|
870
877
|
}
|
|
878
|
+
async judgedGo(target, previousState) {
|
|
879
|
+
if (!previousState)
|
|
880
|
+
return null;
|
|
881
|
+
const judge = this.bot.judge?.();
|
|
882
|
+
if (!judge)
|
|
883
|
+
return null;
|
|
884
|
+
const refs = parseAriaRefs((await this.refAriaSnapshot(ActionResult.fromState(previousState))) || '').filter((entry) => entry.name);
|
|
885
|
+
const labels = refs.map((entry) => `${entry.role} named "${entry.name}"`);
|
|
886
|
+
const pick = await judge.decide(`Which listed control leads to: ${target}`, [...labels, UNDECIDED], { target });
|
|
887
|
+
if (!pick.value)
|
|
888
|
+
return null;
|
|
889
|
+
const ref = refs[labels.indexOf(pick.value)].ref;
|
|
890
|
+
const explorer = this.bot.getExplorer();
|
|
891
|
+
if (await refIsGone(explorer, ref))
|
|
892
|
+
return null;
|
|
893
|
+
const named = await describeRef(explorer, ref);
|
|
894
|
+
const run = `I.usePlaywrightTo(${JSON.stringify(`go to ${target}`)}, async ({ page }) => page.locator(${JSON.stringify(ariaRefSelector(ref))}).click())`;
|
|
895
|
+
if (!(await explorer.action().attempt(run, `go ${target}`)))
|
|
896
|
+
return null;
|
|
897
|
+
const newState = this.bot.stateManager().getCurrentState();
|
|
898
|
+
if (!newState)
|
|
899
|
+
return null;
|
|
900
|
+
if (newState.hash === previousState.hash && newState.url === previousState.url)
|
|
901
|
+
return null;
|
|
902
|
+
const page = ((await this.refAriaSnapshot(ActionResult.fromState(newState))) || '').slice(0, JUDGE_PAGE_CAP);
|
|
903
|
+
const arrived = await judge.decide(`The page now shows: ${target}`, null, { target, page });
|
|
904
|
+
if (arrived.rejected)
|
|
905
|
+
return null;
|
|
906
|
+
let code = run;
|
|
907
|
+
if (named)
|
|
908
|
+
code = `I.click(${JSON.stringify(named)})`;
|
|
909
|
+
return this.successEnvelope(`go ${target}`, [code], await this.capturedResult(newState), previousState);
|
|
910
|
+
}
|
|
871
911
|
executedCodes(code) {
|
|
872
912
|
if (typeof code !== 'string')
|
|
873
913
|
return [];
|
package/dist/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "explorbot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "CLI app built with React Ink, CodeceptJS, and Playwright",
|
|
5
5
|
"license": "Elastic-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -60,7 +60,8 @@
|
|
|
60
60
|
"check": "biome check .",
|
|
61
61
|
"check:fix": "biome check --write .",
|
|
62
62
|
"langfuse:export": "bun run .claude/skills/explorbot-debug/langfuse-export.ts",
|
|
63
|
-
"build:prima": "bun run scripts/build-prima-npm.ts"
|
|
63
|
+
"build:prima": "bun run scripts/build-prima-npm.ts",
|
|
64
|
+
"build:mdq": "bun run scripts/build-mdq-npm.ts"
|
|
64
65
|
},
|
|
65
66
|
"keywords": [
|
|
66
67
|
"cli",
|
package/dist/src/action.js
CHANGED
|
@@ -12,7 +12,9 @@ import { captureHtmlForSnapshot, htmlCombinedSnapshot, minifyHtml } from './util
|
|
|
12
12
|
import { createDebug, setStepSpanParent, tag } from './utils/logger.js';
|
|
13
13
|
import { Overlay, OverlayPage } from './utils/overlay.js';
|
|
14
14
|
import { sleep, waitForPageReadiness } from "./utils/page-readiness.js";
|
|
15
|
+
import { isInternalStep } from "./utils/step-analyzer.js";
|
|
15
16
|
import { safeFilename } from "./utils/strings.js";
|
|
17
|
+
import { isSameHostFamily } from './utils/url-matcher.js';
|
|
16
18
|
import { codeceptJSSandbox, hasPlaywrightCommands, playwrightSandbox, sanitizeCodeBlock } from "./utils/web-sandbox.js";
|
|
17
19
|
const debugLog = createDebug('explorbot:action');
|
|
18
20
|
const CAPTURE_NAVIGATION_TRANSITION_ATTEMPTS = 3;
|
|
@@ -303,7 +305,7 @@ class Action {
|
|
|
303
305
|
const url = URL.parse(request.url());
|
|
304
306
|
if (!url)
|
|
305
307
|
return;
|
|
306
|
-
if (url.
|
|
308
|
+
if (!isSameHostFamily(url.href, this.baseOrigin))
|
|
307
309
|
return;
|
|
308
310
|
const call = { method: request.method(), path: url.pathname, status };
|
|
309
311
|
if (this.networkRequests.some((r) => r.method === call.method && r.path === call.path && r.status === call.status))
|
|
@@ -523,7 +525,7 @@ export const attachStepLogger = (target, assertionsTarget) => {
|
|
|
523
525
|
const listener = (step, error) => {
|
|
524
526
|
if (!step?.toCode)
|
|
525
527
|
return;
|
|
526
|
-
if (step
|
|
528
|
+
if (isInternalStep(step))
|
|
527
529
|
return;
|
|
528
530
|
const existing = recorded.get(step);
|
|
529
531
|
if (existing) {
|
package/dist/src/ai/agent.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type Explorer from '../explorer.js';
|
|
|
4
4
|
import type { KnowledgeTracker } from '../knowledge-tracker.js';
|
|
5
5
|
import type { PlaywrightRecorder } from '../playwright-recorder.js';
|
|
6
6
|
import type { StateManager } from '../state-manager.js';
|
|
7
|
+
import type { Judge } from './judge.js';
|
|
7
8
|
import type { AIProvider } from './provider.js';
|
|
8
9
|
export interface Agent {
|
|
9
10
|
emoji?: string;
|
|
@@ -16,5 +17,6 @@ export interface AgentDeps {
|
|
|
16
17
|
knowledgeTracker: KnowledgeTracker;
|
|
17
18
|
requestStore: RequestStore;
|
|
18
19
|
playwrightRecorder: PlaywrightRecorder;
|
|
20
|
+
judge?: Judge;
|
|
19
21
|
}
|
|
20
|
-
export type ToolDeps = Pick<AgentDeps, 'explorer' | 'stateManager' | 'ai'>;
|
|
22
|
+
export type ToolDeps = Pick<AgentDeps, 'explorer' | 'stateManager' | 'ai' | 'judge'>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare class JudgeProvider {
|
|
2
|
+
readonly model: string;
|
|
3
|
+
fetchImpl: typeof fetch;
|
|
4
|
+
requestTimeoutMs: number;
|
|
5
|
+
url: string;
|
|
6
|
+
apiKey: string;
|
|
7
|
+
constructor(provider: string, model: string);
|
|
8
|
+
decide(state: unknown, question: string, options?: string[]): Promise<ProviderDecision>;
|
|
9
|
+
static endpointFor(provider: string): {
|
|
10
|
+
url: string;
|
|
11
|
+
keyName: string;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export interface ProviderDecision {
|
|
15
|
+
value: string;
|
|
16
|
+
probability: number;
|
|
17
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const REQUEST_TIMEOUT_MS = 15000;
|
|
2
|
+
export class JudgeProvider {
|
|
3
|
+
model;
|
|
4
|
+
fetchImpl = fetch;
|
|
5
|
+
requestTimeoutMs = REQUEST_TIMEOUT_MS;
|
|
6
|
+
url;
|
|
7
|
+
apiKey;
|
|
8
|
+
constructor(provider, model) {
|
|
9
|
+
this.model = model;
|
|
10
|
+
const { url, keyName } = JudgeProvider.endpointFor(provider);
|
|
11
|
+
const apiKey = process.env[keyName];
|
|
12
|
+
if (!apiKey)
|
|
13
|
+
throw new Error(`Set ${keyName} to use the decision model`);
|
|
14
|
+
this.url = url;
|
|
15
|
+
this.apiKey = apiKey;
|
|
16
|
+
}
|
|
17
|
+
async decide(state, question, options) {
|
|
18
|
+
let q = { type: 'noul', instructions: question };
|
|
19
|
+
if (options)
|
|
20
|
+
q = { type: 'choice', instructions: question, criteria: Object.fromEntries(options.map((option, index) => [String(index), option])) };
|
|
21
|
+
const body = JSON.stringify({ model: this.model, state, questions: { q } });
|
|
22
|
+
const controller = new AbortController();
|
|
23
|
+
const timer = setTimeout(() => controller.abort(), this.requestTimeoutMs);
|
|
24
|
+
try {
|
|
25
|
+
const response = await this.fetchImpl(this.url, {
|
|
26
|
+
method: 'POST',
|
|
27
|
+
headers: { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
|
|
28
|
+
body,
|
|
29
|
+
signal: controller.signal,
|
|
30
|
+
});
|
|
31
|
+
if (!response.ok)
|
|
32
|
+
throw new Error(`http_${response.status}`);
|
|
33
|
+
const answer = (await response.json())?.answers?.q;
|
|
34
|
+
if (!options && typeof answer?.noul === 'number')
|
|
35
|
+
return { value: 'yes', probability: answer.noul };
|
|
36
|
+
const value = options?.[Number(answer?.choice)];
|
|
37
|
+
const probability = answer?.probabilities?.[answer?.choice];
|
|
38
|
+
if (value === undefined || typeof probability !== 'number')
|
|
39
|
+
throw new Error('malformed_body');
|
|
40
|
+
return { value, probability };
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
static endpointFor(provider) {
|
|
47
|
+
switch (provider) {
|
|
48
|
+
case 'openrouter':
|
|
49
|
+
return { url: 'https://openrouter.ai/api/alpha/decisions', keyName: 'OPENROUTER_API_KEY' };
|
|
50
|
+
case 'typesafe':
|
|
51
|
+
return { url: 'https://api.typesafe.ai/v1/systemone', keyName: 'TYPESAFE_API_KEY' };
|
|
52
|
+
default:
|
|
53
|
+
throw new Error(`Unknown decision model provider "${provider}" — use "openrouter" or "typesafe"`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|