explorbot 0.2.4 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/explorbot-cli.ts +19 -7
- package/boat/api-tester/src/cli.ts +17 -0
- package/boat/doc-collector/src/cli.ts +14 -1
- package/boat/prima/README.md +96 -0
- package/boat/prima/package.json +14 -10
- package/boat/prima/src/cli.ts +29 -12
- package/boat/prima/src/envelope.ts +35 -13
- package/boat/prima/src/prima.ts +78 -45
- package/dist/bin/explorbot-cli.js +19 -7
- package/dist/boat/api-tester/src/cli.js +17 -0
- package/dist/boat/doc-collector/src/cli.js +14 -1
- package/dist/boat/prima/src/cli.js +26 -7
- package/dist/boat/prima/src/envelope.js +32 -8
- package/dist/boat/prima/src/prima.js +75 -43
- package/dist/models.json +4 -4
- package/dist/package.json +6 -2
- package/dist/src/action-result.d.ts +13 -0
- package/dist/src/action-result.js +46 -15
- package/dist/src/action.d.ts +5 -2
- package/dist/src/action.js +53 -18
- package/dist/src/ai/captain/web-mode.js +1 -2
- package/dist/src/ai/captain.d.ts +20 -0
- package/dist/src/ai/captain.js +10 -1
- package/dist/src/ai/driller.js +6 -2
- package/dist/src/ai/fisherman-tools.d.ts +40 -1
- package/dist/src/ai/fisherman-tools.js +39 -0
- package/dist/src/ai/fisherman.js +2 -1
- package/dist/src/ai/navigator.d.ts +28 -0
- package/dist/src/ai/navigator.js +223 -175
- package/dist/src/ai/pilot.d.ts +7 -4
- package/dist/src/ai/pilot.js +89 -30
- package/dist/src/ai/planner/subpages.js +2 -16
- package/dist/src/ai/planner.js +1 -1
- package/dist/src/ai/provider.d.ts +2 -2
- package/dist/src/ai/provider.js +28 -22
- package/dist/src/ai/researcher/cache.d.ts +10 -3
- package/dist/src/ai/researcher/cache.js +23 -10
- package/dist/src/ai/researcher/deep-analysis.js +1 -1
- package/dist/src/ai/researcher/fingerprint-worker.js +21 -4
- package/dist/src/ai/researcher.js +6 -4
- package/dist/src/ai/rules.js +1 -5
- package/dist/src/ai/session-analyst.js +2 -0
- package/dist/src/ai/tester.d.ts +6 -3
- package/dist/src/ai/tester.js +30 -35
- package/dist/src/ai/tools.d.ts +8 -5
- package/dist/src/ai/tools.js +83 -57
- package/dist/src/commands/config-command.d.ts +51 -0
- package/dist/src/commands/config-command.js +117 -0
- package/dist/src/commands/index.js +2 -0
- package/dist/src/commands/init-command.js +13 -20
- package/dist/src/config.d.ts +8 -1
- package/dist/src/config.js +43 -1
- package/dist/src/experience-tracker.d.ts +2 -0
- package/dist/src/experience-tracker.js +12 -0
- package/dist/src/explorbot.js +5 -2
- package/dist/src/playwright-recorder.js +6 -12
- package/dist/src/remote.d.ts +3 -2
- package/dist/src/remote.js +8 -2
- package/dist/src/state-manager.d.ts +1 -1
- package/dist/src/state-manager.js +3 -1
- package/dist/src/test-plan.d.ts +9 -0
- package/dist/src/test-plan.js +30 -0
- package/dist/src/utils/html-diff.d.ts +5 -0
- package/dist/src/utils/html-diff.js +65 -6
- package/dist/src/utils/logger.d.ts +1 -1
- package/dist/src/utils/logger.js +8 -0
- package/dist/src/utils/strings.d.ts +2 -0
- package/dist/src/utils/strings.js +32 -0
- package/dist/src/utils/url-matcher.d.ts +1 -0
- package/dist/src/utils/url-matcher.js +31 -2
- package/docs/basics/getting-started.md +33 -10
- package/docs/basics/providers.md +6 -4
- package/docs/contributing/npm-package.md +73 -4
- package/docs/index.json +2 -1
- package/docs/reference/commands.md +3 -0
- package/docs/reference/websocket.md +50 -0
- package/docs/superpowers/specs/2026-08-18-prima-false-verdicts.md +159 -0
- package/models.json +4 -4
- package/package.json +6 -2
- package/src/action-result.ts +61 -16
- package/src/action.ts +56 -18
- package/src/ai/captain/web-mode.ts +1 -2
- package/src/ai/captain.ts +9 -1
- package/src/ai/driller.ts +6 -2
- package/src/ai/fisherman-tools.ts +35 -0
- package/src/ai/fisherman.ts +2 -1
- package/src/ai/navigator.ts +238 -179
- package/src/ai/pilot.ts +104 -36
- package/src/ai/planner/subpages.ts +2 -13
- package/src/ai/planner.ts +1 -1
- package/src/ai/provider.ts +29 -21
- package/src/ai/researcher/cache.ts +29 -11
- package/src/ai/researcher/deep-analysis.ts +1 -1
- package/src/ai/researcher/fingerprint-worker.ts +23 -5
- package/src/ai/researcher.ts +6 -4
- package/src/ai/rules.ts +1 -5
- package/src/ai/session-analyst.ts +2 -0
- package/src/ai/tester.ts +33 -34
- package/src/ai/tools.ts +88 -61
- package/src/commands/config-command.ts +146 -0
- package/src/commands/index.ts +2 -0
- package/src/commands/init-command.ts +14 -20
- package/src/config.ts +47 -2
- package/src/experience-tracker.ts +13 -0
- package/src/explorbot.ts +4 -2
- package/src/playwright-recorder.ts +6 -11
- package/src/remote.ts +8 -2
- package/src/state-manager.ts +5 -2
- package/src/test-plan.ts +38 -0
- package/src/utils/html-diff.ts +72 -7
- package/src/utils/logger.ts +9 -1
- package/src/utils/strings.ts +36 -0
- package/src/utils/url-matcher.ts +27 -2
package/src/ai/pilot.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { ConfigParser } from '../config.ts';
|
|
|
7
7
|
import type Explorer from '../explorer.ts';
|
|
8
8
|
import type { PlaywrightRecorder } from '../playwright-recorder.ts';
|
|
9
9
|
import type { StateManager } from '../state-manager.ts';
|
|
10
|
+
import { Stats } from '../stats.ts';
|
|
10
11
|
import { type Test, TestResult } from '../test-plan.ts';
|
|
11
12
|
import { collectInteractiveNodes, detectFocusArea } from '../utils/aria.ts';
|
|
12
13
|
import { ErrorPageError } from '../utils/error-page.ts';
|
|
@@ -26,6 +27,8 @@ import { withdrawVisionTools } from './tools.ts';
|
|
|
26
27
|
|
|
27
28
|
const CHECK_TOOLS = ['verify', 'see', 'research', 'context'];
|
|
28
29
|
const META_TOOLS = ['record', 'reset', 'stop', 'finish'];
|
|
30
|
+
const PILOT_MESSAGE_LIMIT = 2;
|
|
31
|
+
const PILOT_MESSAGE_MAX_LENGTH = 160;
|
|
29
32
|
|
|
30
33
|
export class Pilot implements Agent {
|
|
31
34
|
emoji = '🧭';
|
|
@@ -464,9 +467,13 @@ export class Pilot implements Agent {
|
|
|
464
467
|
the elements needed for the scenario. The page summary does not list every element.
|
|
465
468
|
Prefer interacting with the current page over navigating away.
|
|
466
469
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
+
Tester never sees <experience> — a recorded recipe reaches it only when you open one.
|
|
471
|
+
The entries listed are what was recorded on the page you are on now; recipes for the
|
|
472
|
+
pages this test moves to are listed when it gets there. Open the ones whose titles fit a
|
|
473
|
+
step taken from here, and say so in the plan when none of them fit.
|
|
474
|
+
Do NOT rewrite a loaded recipe's code — the raw recipe is forwarded to Tester
|
|
475
|
+
automatically. Reference it by step ("apply recipe steps 1–3, then…") and call out
|
|
476
|
+
anywhere your scenario diverges from it.
|
|
470
477
|
|
|
471
478
|
Be concise and specific. Tester will follow your plan.
|
|
472
479
|
`,
|
|
@@ -512,9 +519,11 @@ export class Pilot implements Agent {
|
|
|
512
519
|
${this.formatExpectations(task)}
|
|
513
520
|
|
|
514
521
|
First: evaluate whether this navigation makes sense for the scenario goal. If the page is unrelated, instruct Tester to back() or reset(). Then plan next steps.
|
|
522
|
+
|
|
523
|
+
Tester holds no recipe for this page until you load one — open the <experience> entries whose titles fit a step you are about to instruct.
|
|
515
524
|
`,
|
|
516
525
|
'pilot.reviewNewPage',
|
|
517
|
-
{ task }
|
|
526
|
+
{ tools: true, maxToolRoundtrips: 2, task }
|
|
518
527
|
);
|
|
519
528
|
}
|
|
520
529
|
|
|
@@ -548,6 +557,8 @@ export class Pilot implements Agent {
|
|
|
548
557
|
</recent_actions>
|
|
549
558
|
|
|
550
559
|
What should Tester do next?
|
|
560
|
+
|
|
561
|
+
Before proposing new locators for a step that keeps failing, check <experience> for a recorded recipe covering it and load it.
|
|
551
562
|
`,
|
|
552
563
|
'pilot.analyze',
|
|
553
564
|
{ tools: hasFailures, maxToolRoundtrips: hasFailures ? 2 : 0, task }
|
|
@@ -562,24 +573,46 @@ export class Pilot implements Agent {
|
|
|
562
573
|
return text;
|
|
563
574
|
}
|
|
564
575
|
|
|
565
|
-
async settleExpectations(task: Test): Promise<
|
|
566
|
-
|
|
576
|
+
async settleExpectations(task: Test, finalState?: ActionResult): Promise<SettledExpectation[]> {
|
|
577
|
+
let image: string | null = null;
|
|
578
|
+
if (finalState?.screenshot && this.provider.hasVision()) image = `data:image/png;base64,${finalState.screenshot.toString('base64')}`;
|
|
579
|
+
|
|
567
580
|
const decided = (text: string): 'passed' | 'failed' => {
|
|
568
581
|
if (task.hasAchievedAny() && !task.getRemainingExpectations().includes(text)) return 'passed';
|
|
569
582
|
return 'failed';
|
|
570
583
|
};
|
|
571
584
|
|
|
585
|
+
let undecided = task.expected.filter((text) => !task.getCheckedExpectations().includes(text));
|
|
586
|
+
if (image) undecided = task.expected;
|
|
572
587
|
if (!undecided.length) return task.expected.map((text) => ({ text, status: decided(text) }));
|
|
573
588
|
|
|
574
589
|
const schema = z.object({
|
|
575
590
|
outcomes: z.array(
|
|
576
591
|
z.object({
|
|
577
592
|
expectation: z.string().describe('The expected outcome, repeated exactly as it was given'),
|
|
578
|
-
status: z.enum(['passed', 'failed', 'unverified']).describe('passed = the
|
|
593
|
+
status: z.enum(['passed', 'failed', 'unverified', 'contradiction']).describe('passed = the evidence shows it happened, failed = the evidence shows it did not, unverified = the run never established either way, contradiction = the picture and the run disagree'),
|
|
594
|
+
evidence: z.string().nullable().describe('What settled it. For a contradiction, what each side shows. Null when there is nothing to add'),
|
|
579
595
|
})
|
|
580
596
|
),
|
|
581
597
|
});
|
|
582
598
|
|
|
599
|
+
let pageEvidence = '';
|
|
600
|
+
if (image) {
|
|
601
|
+
pageEvidence = dedent`
|
|
602
|
+
A screenshot of the whole page as the run left it is attached. It is the proof: an outcome is satisfied
|
|
603
|
+
when the page shows it to somebody looking at it. The log only says what the run did.
|
|
604
|
+
|
|
605
|
+
Not finding something in the picture is not by itself a disagreement. Report "contradiction" only when
|
|
606
|
+
the picture shows something incompatible with what the run claims — a list visibly empty, an error where
|
|
607
|
+
a result was expected, the old value still displayed, a control visibly disabled. When you simply cannot
|
|
608
|
+
make it out, say "unverified" and name what you could not find.
|
|
609
|
+
|
|
610
|
+
The picture covers the full page, but not the inside of a region that scrolls on its own, and not the
|
|
611
|
+
state of the page before the run ended. An outcome established earlier stays established even when the
|
|
612
|
+
page has moved past it, and that is not a contradiction.
|
|
613
|
+
`;
|
|
614
|
+
}
|
|
615
|
+
|
|
583
616
|
const userContent = dedent`
|
|
584
617
|
A test run has finished. Decide, for each expected outcome, what the run established about it.
|
|
585
618
|
|
|
@@ -591,23 +624,43 @@ export class Pilot implements Agent {
|
|
|
591
624
|
${task.notesToString() || 'No steps recorded.'}
|
|
592
625
|
</run_log>
|
|
593
626
|
|
|
627
|
+
${pageEvidence}
|
|
628
|
+
|
|
594
629
|
The log is written in the tester's own words, so an outcome can be satisfied by a step that describes it
|
|
595
630
|
differently. Judge by what the steps show happened, not by whether the wording matches.
|
|
596
|
-
Choose "unverified" only when the
|
|
631
|
+
Choose "unverified" only when the evidence neither shows the outcome happening nor shows it failing —
|
|
597
632
|
that is a statement about the run, not about the application.
|
|
598
633
|
`;
|
|
599
634
|
|
|
600
|
-
const
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
635
|
+
const settle = (content: any, model: any) =>
|
|
636
|
+
this.provider
|
|
637
|
+
.generateObject([{ role: 'user' as const, content }], schema, model, {
|
|
638
|
+
agentName: 'pilot',
|
|
639
|
+
telemetry: { functionId: 'pilot.settleExpectations' },
|
|
640
|
+
})
|
|
641
|
+
.catch(() => null);
|
|
642
|
+
|
|
643
|
+
let response = null;
|
|
644
|
+
if (image) {
|
|
645
|
+
const seen = [
|
|
646
|
+
{ type: 'text', text: userContent },
|
|
647
|
+
{ type: 'file', mediaType: 'image/png', data: image },
|
|
648
|
+
];
|
|
649
|
+
response = await settle(seen, this.provider.getVisionModel());
|
|
650
|
+
if (!response) {
|
|
651
|
+
Stats.visionDisabled = true;
|
|
652
|
+
tag('warning').log('⚠️ Vision model could not judge the outcomes. Settling them from the run log instead.');
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
if (!response) response = await settle(userContent, this.provider.getAgenticModel('pilot'));
|
|
606
657
|
|
|
607
|
-
const judged = new Map((response?.object?.outcomes || []).map((outcome: any) => [outcome.expectation, outcome
|
|
658
|
+
const judged = new Map((response?.object?.outcomes || []).map((outcome: any) => [outcome.expectation, outcome]));
|
|
608
659
|
return task.expected.map((text) => {
|
|
609
660
|
if (!undecided.includes(text)) return { text, status: decided(text) };
|
|
610
|
-
|
|
661
|
+
const outcome = judged.get(text) as { status: SettledStatus; evidence?: string } | undefined;
|
|
662
|
+
if (!outcome) return { text, status: 'unverified' as SettledStatus };
|
|
663
|
+
return { text, status: outcome.status || 'unverified', evidence: outcome.evidence };
|
|
611
664
|
});
|
|
612
665
|
}
|
|
613
666
|
|
|
@@ -622,10 +675,9 @@ export class Pilot implements Agent {
|
|
|
622
675
|
|
|
623
676
|
let finalUserText = userText;
|
|
624
677
|
if (opts.tools) {
|
|
678
|
+
this.conversation!.cleanupTag('experience', '...cleaned experience index...');
|
|
625
679
|
const tocBlock = this.getExperienceToc();
|
|
626
|
-
if (tocBlock) {
|
|
627
|
-
finalUserText = `${tocBlock}\n\n${userText}`;
|
|
628
|
-
}
|
|
680
|
+
if (tocBlock) finalUserText = `${tocBlock}\n\n${userText}`;
|
|
629
681
|
}
|
|
630
682
|
this.conversation!.addUserText(finalUserText);
|
|
631
683
|
|
|
@@ -639,8 +691,9 @@ export class Pilot implements Agent {
|
|
|
639
691
|
telemetry: { functionId },
|
|
640
692
|
});
|
|
641
693
|
const text = result?.response?.text || '';
|
|
642
|
-
const learned = (result?.toolExecutions || []).filter((e: any) => e.toolName === 'learnExperience' && e.output?.content).map((e: any) => e.output.content);
|
|
694
|
+
const learned = (result?.toolExecutions || []).filter((e: any) => e.toolName === 'learnExperience' && e.output?.content).map((e: any) => ({ url: e.output.url, content: e.output.content }));
|
|
643
695
|
if (learned.length === 0) return text;
|
|
696
|
+
opts.task.applyExperience(learned);
|
|
644
697
|
return dedent`
|
|
645
698
|
${text}
|
|
646
699
|
|
|
@@ -648,7 +701,7 @@ export class Pilot implements Agent {
|
|
|
648
701
|
Recipes from prior successful runs that Pilot judged relevant. Locators worked then; the page may have changed since.
|
|
649
702
|
Treat code blocks below as a starting hypothesis. If a locator misses, fall back to ARIA/UI-map.
|
|
650
703
|
|
|
651
|
-
${learned.join('\n\n')}
|
|
704
|
+
${learned.map((recipe) => recipe.content).join('\n\n')}
|
|
652
705
|
</applied_experience>
|
|
653
706
|
`;
|
|
654
707
|
}
|
|
@@ -675,6 +728,7 @@ export class Pilot implements Agent {
|
|
|
675
728
|
}
|
|
676
729
|
|
|
677
730
|
private buildPreconditionTool(task: Test) {
|
|
731
|
+
const unavailable = 'Data was not created and cannot be created automatically. Do not call precondition again for this test — continue with what the page already shows.';
|
|
678
732
|
return {
|
|
679
733
|
precondition: tool({
|
|
680
734
|
description: 'Create fresh disposable data that the test will act on (edit, delete, filter). Describe WHAT to create, not what exists. Do NOT request users. Examples: "1 post", "1 comment", "1 label named Bug".',
|
|
@@ -689,7 +743,7 @@ export class Pilot implements Agent {
|
|
|
689
743
|
if (!this.fisherman || !this.fisherman.isAvailable()) {
|
|
690
744
|
const skipReason = await this.checkDataAvailability(task, description, 'Fisherman not available');
|
|
691
745
|
if (skipReason) return { noted: true, prepared: false, skipped: true, reason: skipReason };
|
|
692
|
-
return { noted: true, prepared: false, reason:
|
|
746
|
+
return { noted: true, prepared: false, reason: unavailable };
|
|
693
747
|
}
|
|
694
748
|
|
|
695
749
|
const result = await this.fisherman.prepareData(description, task.startUrl, task.sessionName);
|
|
@@ -698,7 +752,7 @@ export class Pilot implements Agent {
|
|
|
698
752
|
if (result.summary) tag('warning').log(`Precondition failed: ${result.summary}`);
|
|
699
753
|
const skipReason = await this.checkDataAvailability(task, description, result.summary);
|
|
700
754
|
if (skipReason) return { noted: true, prepared: false, skipped: true, reason: skipReason };
|
|
701
|
-
return { noted: true, prepared: false, reason: result.summary };
|
|
755
|
+
return { noted: true, prepared: false, reason: `${result.summary || 'Data preparation failed'}. ${unavailable}` };
|
|
702
756
|
}
|
|
703
757
|
|
|
704
758
|
const items = result.created.map((c) => {
|
|
@@ -735,7 +789,7 @@ export class Pilot implements Agent {
|
|
|
735
789
|
Reply with YES or NO on the first line, then a one-sentence reason on the second line.
|
|
736
790
|
`;
|
|
737
791
|
|
|
738
|
-
const answer = await this.researcher.answerQuestionAboutScreenshot(screenshotState, question);
|
|
792
|
+
const answer = await this.researcher.answerQuestionAboutScreenshot(screenshotState, question).catch(() => null);
|
|
739
793
|
if (!answer) return null;
|
|
740
794
|
|
|
741
795
|
const firstLine = answer.split('\n')[0]?.trim().toUpperCase() ?? '';
|
|
@@ -827,15 +881,6 @@ export class Pilot implements Agent {
|
|
|
827
881
|
private async fetchRequestedContext(text: string, currentState: ActionResult): Promise<string> {
|
|
828
882
|
const parts: string[] = [];
|
|
829
883
|
|
|
830
|
-
if (text.includes('ATTACH_HTML')) {
|
|
831
|
-
const html = await currentState.simplifiedHtml();
|
|
832
|
-
parts.push(dedent`
|
|
833
|
-
<page_html>
|
|
834
|
-
${html}
|
|
835
|
-
</page_html>
|
|
836
|
-
`);
|
|
837
|
-
}
|
|
838
|
-
|
|
839
884
|
if (text.includes('ATTACH_ARIA')) {
|
|
840
885
|
parts.push(dedent`
|
|
841
886
|
<page_aria>
|
|
@@ -1005,6 +1050,21 @@ export class Pilot implements Agent {
|
|
|
1005
1050
|
const ariaDiff = t.output?.pageDiff?.ariaChanges;
|
|
1006
1051
|
if (ariaDiff) line += `\n ${ariaDiff}`;
|
|
1007
1052
|
|
|
1053
|
+
if (t.output?.pageDiff?.urlChanged) line += `\n moved: ${t.output.pageDiff.previousUrl} → ${t.output.pageDiff.currentUrl}`;
|
|
1054
|
+
|
|
1055
|
+
const failedRequests = (t.output?.pageDiff?.requests ?? []).filter((r: any) => r.status >= 400);
|
|
1056
|
+
if (failedRequests.length > 0) {
|
|
1057
|
+
line += `\n requests: ${failedRequests.map((r: any) => `${r.method} ${r.path} → ${r.status}`).join(', ')}`;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
const messages = (t.output?.pageDiff?.messages ?? []).slice(0, PILOT_MESSAGE_LIMIT);
|
|
1061
|
+
if (messages.length > 0) {
|
|
1062
|
+
line += `\n messages: ${messages.map((m: string) => m.slice(0, PILOT_MESSAGE_MAX_LENGTH)).join(' | ')}`;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
const consoleError = t.output?.pageDiff?.consoleErrors?.[0];
|
|
1066
|
+
if (consoleError) line += `\n console: ${consoleError.slice(0, PILOT_MESSAGE_MAX_LENGTH)}`;
|
|
1067
|
+
|
|
1008
1068
|
return line;
|
|
1009
1069
|
})
|
|
1010
1070
|
.join('\n\n');
|
|
@@ -1073,10 +1133,10 @@ export class Pilot implements Agent {
|
|
|
1073
1133
|
role, icon classes with "or" in one XPath. If empty, broaden (drop role filter). Pass discovered
|
|
1074
1134
|
XPath into NEXT instruction.
|
|
1075
1135
|
|
|
1076
|
-
To request more context, mention
|
|
1136
|
+
To request more context, mention ATTACH_ARIA, ATTACH_SUMMARY, or ATTACH_UI_MAP — only when recent actions show failures.
|
|
1077
1137
|
|
|
1078
|
-
Tester tools: click, pressKey, form, see, verify, context, research, xpathCheck,
|
|
1079
|
-
back, getVisitedStates, reset, stop, finish, record.
|
|
1138
|
+
Tester tools: click, pressKey, form, see, verify, interact, context, research, xpathCheck,
|
|
1139
|
+
visualClick, back, getVisitedStates, reset, stop, finish, record.
|
|
1080
1140
|
Use tool names exactly as listed. Do not invent combined names, aliases, or names with channel markers such as "commentary".
|
|
1081
1141
|
|
|
1082
1142
|
${capabilityGroundingRule}
|
|
@@ -1116,3 +1176,11 @@ export class Pilot implements Agent {
|
|
|
1116
1176
|
`;
|
|
1117
1177
|
}
|
|
1118
1178
|
}
|
|
1179
|
+
|
|
1180
|
+
export type SettledStatus = 'passed' | 'failed' | 'unverified' | 'contradiction';
|
|
1181
|
+
|
|
1182
|
+
export interface SettledExpectation {
|
|
1183
|
+
text: string;
|
|
1184
|
+
status: SettledStatus;
|
|
1185
|
+
evidence?: string;
|
|
1186
|
+
}
|
|
@@ -4,7 +4,7 @@ import { normalizeUrl } from '../../state-manager.ts';
|
|
|
4
4
|
import type { StateManager } from '../../state-manager.ts';
|
|
5
5
|
import type { Plan } from '../../test-plan.ts';
|
|
6
6
|
import { tag } from '../../utils/logger.ts';
|
|
7
|
-
import {
|
|
7
|
+
import { isSamePageFamily } from '../../utils/url-matcher.ts';
|
|
8
8
|
import type { Provider } from '../provider.ts';
|
|
9
9
|
import type { Constructor } from '../researcher/mixin.ts';
|
|
10
10
|
|
|
@@ -39,18 +39,7 @@ function buildKey(url: string, feature?: string): string {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export function isTemplateMatch(urlA: string, urlB: string): boolean {
|
|
42
|
-
|
|
43
|
-
const partsB = normalizeUrl(urlB).split('/');
|
|
44
|
-
if (partsA.length !== partsB.length) return false;
|
|
45
|
-
|
|
46
|
-
let diffCount = 0;
|
|
47
|
-
for (let i = 0; i < partsA.length; i++) {
|
|
48
|
-
if (partsA[i] === partsB[i]) continue;
|
|
49
|
-
diffCount++;
|
|
50
|
-
if (diffCount > 1) return false;
|
|
51
|
-
if (!isDynamicSegment(partsA[i]) && !isDynamicSegment(partsB[i])) return false;
|
|
52
|
-
}
|
|
53
|
-
return diffCount === 1;
|
|
42
|
+
return isSamePageFamily(urlA, urlB);
|
|
54
43
|
}
|
|
55
44
|
|
|
56
45
|
export function getPlannedByStateHash(hash: string): PlanRecord | null {
|
package/src/ai/planner.ts
CHANGED
|
@@ -149,7 +149,7 @@ export class Planner extends PlannerBase implements Agent {
|
|
|
149
149
|
|
|
150
150
|
const actionResult = ActionResult.fromState(state);
|
|
151
151
|
const combinedHtml = await actionResult.combinedHtml();
|
|
152
|
-
const similarHash = await findSimilarStateHash(combinedHtml);
|
|
152
|
+
const similarHash = await findSimilarStateHash(combinedHtml, state.url);
|
|
153
153
|
if (similarHash) {
|
|
154
154
|
const planned = getPlannedByStateHash(similarHash);
|
|
155
155
|
if (planned) {
|
package/src/ai/provider.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { NodeSDK } from '@opentelemetry/sdk-node';
|
|
|
4
4
|
import { generateObject, generateText, isStepCount, registerTelemetry } from 'ai';
|
|
5
5
|
import type { ModelMessage } from 'ai';
|
|
6
6
|
import { clearActivity, setActivity } from '../activity.ts';
|
|
7
|
-
import type
|
|
7
|
+
import { type AIConfig, configuredModels, modelName as getModelName } from '../config.js';
|
|
8
8
|
import { executionController } from '../execution-controller.ts';
|
|
9
9
|
import { Observability } from '../observability.ts';
|
|
10
10
|
import { Stats } from '../stats.ts';
|
|
@@ -88,10 +88,6 @@ export class Provider {
|
|
|
88
88
|
this.initLangfuse();
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
private getModelName(model: any): string {
|
|
92
|
-
return model?.modelId || model?.model || 'unknown';
|
|
93
|
-
}
|
|
94
|
-
|
|
95
91
|
async validateConnection(): Promise<void> {
|
|
96
92
|
try {
|
|
97
93
|
await generateText({
|
|
@@ -120,13 +116,13 @@ export class Provider {
|
|
|
120
116
|
return this.config.agenticModel || this.config.model;
|
|
121
117
|
}
|
|
122
118
|
|
|
119
|
+
getVisionModel(): any {
|
|
120
|
+
return this.config.visionModel;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
123
|
getConfiguredModels(): Record<string, string> {
|
|
124
|
-
const models: Record<string, string> = {
|
|
125
|
-
|
|
126
|
-
if (this.config.visionModel) models.visionModel = this.getModelName(this.config.visionModel);
|
|
127
|
-
for (const [agent, agentConfig] of Object.entries(this.config.agents || {})) {
|
|
128
|
-
if (agentConfig?.model) models[agent] = this.getModelName(agentConfig.model);
|
|
129
|
-
}
|
|
124
|
+
const models: Record<string, string> = {};
|
|
125
|
+
for (const [role, model] of Object.entries(configuredModels(this.config))) models[role] = model.name;
|
|
130
126
|
return models;
|
|
131
127
|
}
|
|
132
128
|
|
|
@@ -223,11 +219,7 @@ export class Provider {
|
|
|
223
219
|
}
|
|
224
220
|
|
|
225
221
|
private initLangfuse() {
|
|
226
|
-
const
|
|
227
|
-
const publicKey = langfuseConfig?.publicKey || process.env.LANGFUSE_PUBLIC_KEY;
|
|
228
|
-
const secretKey = langfuseConfig?.secretKey || process.env.LANGFUSE_SECRET_KEY;
|
|
229
|
-
const baseUrl = langfuseConfig?.baseUrl || process.env.LANGFUSE_BASE_URL || process.env.LANGFUSE_HOST;
|
|
230
|
-
const enabled = langfuseConfig?.enabled ?? Boolean(publicKey && secretKey);
|
|
222
|
+
const { enabled, publicKey, secretKey, baseUrl } = this.config.langfuse || {};
|
|
231
223
|
|
|
232
224
|
if (!enabled || !publicKey || !secretKey) {
|
|
233
225
|
return;
|
|
@@ -316,7 +308,7 @@ export class Provider {
|
|
|
316
308
|
}
|
|
317
309
|
|
|
318
310
|
async chat(messages: ModelMessage[], model: any, options: any = {}): Promise<any> {
|
|
319
|
-
const modelName =
|
|
311
|
+
const modelName = getModelName(model);
|
|
320
312
|
setActivity(`🤖 Asking ${modelName}`, 'ai');
|
|
321
313
|
promptLog(`Using model: ${modelName}`);
|
|
322
314
|
|
|
@@ -360,7 +352,7 @@ export class Provider {
|
|
|
360
352
|
}
|
|
361
353
|
|
|
362
354
|
async generateWithTools(messages: ModelMessage[], model: any, tools: any, options: any = {}): Promise<any> {
|
|
363
|
-
const modelName =
|
|
355
|
+
const modelName = getModelName(model);
|
|
364
356
|
setActivity(`🤖 Asking ${modelName} with dynamic tools`, 'ai');
|
|
365
357
|
promptLog(`Using model: ${modelName}`);
|
|
366
358
|
|
|
@@ -373,7 +365,7 @@ export class Provider {
|
|
|
373
365
|
const extraStop = options.stopWhen;
|
|
374
366
|
const stopConditions: any[] = [isStepCount(maxRoundtrips)];
|
|
375
367
|
if (extraStop) stopConditions.push(extraStop);
|
|
376
|
-
const config = this.buildGenerateConfig({ tools, maxOutputTokens: 16384, toolChoice: 'auto' }, { stopWhen: stopConditions, model }, options);
|
|
368
|
+
const config = this.buildGenerateConfig({ tools, maxOutputTokens: 16384, toolChoice: 'auto', experimental_repairToolCall: repairToolCall }, { stopWhen: stopConditions, model }, options);
|
|
377
369
|
try {
|
|
378
370
|
const response = await withRetry(async () => {
|
|
379
371
|
const result = (await this.raceWithIdleTimeout((signal) => generateText({ messages, ...config, abortSignal: signal }), config.timeout || 30000)) as any;
|
|
@@ -418,7 +410,7 @@ export class Provider {
|
|
|
418
410
|
|
|
419
411
|
async generateObject(messages: ModelMessage[], schema: any, model?: any, options: any = {}): Promise<any> {
|
|
420
412
|
const modelToUse = model || this.config.model;
|
|
421
|
-
const modelName =
|
|
413
|
+
const modelName = getModelName(modelToUse);
|
|
422
414
|
setActivity(`🤖 Asking ${modelName} for structured output`, 'ai');
|
|
423
415
|
promptLog(`Using model: ${modelName}`);
|
|
424
416
|
|
|
@@ -620,7 +612,7 @@ export class Provider {
|
|
|
620
612
|
clearActivity();
|
|
621
613
|
responseLog(response.text);
|
|
622
614
|
|
|
623
|
-
this.recordUsage('vision',
|
|
615
|
+
this.recordUsage('vision', getModelName(this.config.visionModel), response.usage);
|
|
624
616
|
|
|
625
617
|
return response;
|
|
626
618
|
} catch (error: any) {
|
|
@@ -635,4 +627,20 @@ export class Provider {
|
|
|
635
627
|
}
|
|
636
628
|
}
|
|
637
629
|
|
|
630
|
+
function repairToolCall(options: ToolCallRepairOptions): any | null {
|
|
631
|
+
if (options.toolCall.toolName.includes('<|channel|>')) return repairChannelMarker(options);
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function repairChannelMarker({ toolCall, tools }: ToolCallRepairOptions): any | null {
|
|
636
|
+
const markerIndex = toolCall.toolName.indexOf('<|channel|>');
|
|
637
|
+
if (markerIndex <= 0) return null;
|
|
638
|
+
const toolName = toolCall.toolName.slice(0, markerIndex);
|
|
639
|
+
if (!tools[toolName]) return null;
|
|
640
|
+
tag('warning').log(`Repaired tool name '${toolCall.toolName}' → '${toolName}'`);
|
|
641
|
+
return { ...toolCall, toolName };
|
|
642
|
+
}
|
|
643
|
+
|
|
638
644
|
export { AiError, Provider as AIProvider };
|
|
645
|
+
|
|
646
|
+
type ToolCallRepairOptions = { toolCall: any; tools: any };
|
|
@@ -4,6 +4,7 @@ import { Worker } from 'node:worker_threads';
|
|
|
4
4
|
import { outputPath } from '../../config.ts';
|
|
5
5
|
import { TTLCache } from '../../utils/cache.ts';
|
|
6
6
|
import { computeHtmlFingerprint } from '../../utils/html-diff.ts';
|
|
7
|
+
import { tag } from '../../utils/logger.ts';
|
|
7
8
|
import { debugLog } from './mixin.ts';
|
|
8
9
|
|
|
9
10
|
const CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 6 hours
|
|
@@ -15,6 +16,14 @@ const memoryCache = new TTLCache<string>(CACHE_TTL_MS);
|
|
|
15
16
|
|
|
16
17
|
let fingerprintWorker: Worker | null = null;
|
|
17
18
|
|
|
19
|
+
export function researchPath(hash: string): string {
|
|
20
|
+
return outputPath('research', `${hash}.md`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function reportResearch(hash: string, text: string): void {
|
|
24
|
+
tag('data').log('research', { path: researchPath(hash), hash, content: text });
|
|
25
|
+
}
|
|
26
|
+
|
|
18
27
|
function getStatesDir(): string {
|
|
19
28
|
return outputPath('states');
|
|
20
29
|
}
|
|
@@ -35,7 +44,7 @@ export function getCachedResearch(hash: string): string {
|
|
|
35
44
|
if (!hash) return '';
|
|
36
45
|
const cached = memoryCache.get(hash);
|
|
37
46
|
if (cached !== undefined) return cached;
|
|
38
|
-
const researchFile =
|
|
47
|
+
const researchFile = researchPath(hash);
|
|
39
48
|
if (!existsSync(researchFile)) return '';
|
|
40
49
|
const stats = statSync(researchFile);
|
|
41
50
|
if (Date.now() - stats.mtimeMs > CACHE_TTL_MS) return '';
|
|
@@ -46,17 +55,19 @@ export function getCachedResearch(hash: string): string {
|
|
|
46
55
|
|
|
47
56
|
export function getPreviousResearch(hash: string): string {
|
|
48
57
|
if (!hash) return '';
|
|
49
|
-
const researchFile =
|
|
58
|
+
const researchFile = researchPath(hash);
|
|
50
59
|
if (!existsSync(researchFile)) return '';
|
|
51
60
|
return readFileSync(researchFile, 'utf8');
|
|
52
61
|
}
|
|
53
62
|
|
|
54
|
-
export function saveResearch(
|
|
63
|
+
export function saveResearch(state: ResearchState, text: string, combinedHtml?: string): string {
|
|
64
|
+
const { hash, url } = state;
|
|
55
65
|
const researchDir = outputPath('research');
|
|
56
66
|
const researchFile = join(researchDir, `${hash}.md`);
|
|
57
67
|
if (!existsSync(researchDir)) mkdirSync(researchDir, { recursive: true });
|
|
58
68
|
writeFileSync(researchFile, text);
|
|
59
69
|
memoryCache.set(hash, text);
|
|
70
|
+
reportResearch(hash, text);
|
|
60
71
|
debugLog(`Research saved to ${researchFile}`);
|
|
61
72
|
|
|
62
73
|
if (combinedHtml) {
|
|
@@ -64,14 +75,16 @@ export function saveResearch(hash: string, text: string, combinedHtml?: string):
|
|
|
64
75
|
if (!existsSync(statesDir)) mkdirSync(statesDir, { recursive: true });
|
|
65
76
|
const fingerprint = computeHtmlFingerprint(combinedHtml);
|
|
66
77
|
const fingerprintFile = join(statesDir, `${hash}.fingerprint`);
|
|
67
|
-
|
|
78
|
+
const record: FingerprintRecord = { entries: fingerprint };
|
|
79
|
+
if (url) record.url = url;
|
|
80
|
+
writeFileSync(fingerprintFile, JSON.stringify(record));
|
|
68
81
|
debugLog(`Fingerprint saved to ${fingerprintFile}`);
|
|
69
82
|
}
|
|
70
83
|
|
|
71
84
|
return researchFile;
|
|
72
85
|
}
|
|
73
86
|
|
|
74
|
-
function findSimilarMatch(combinedHtml: string): Promise<
|
|
87
|
+
function findSimilarMatch(combinedHtml: string, url?: string): Promise<FingerprintMatch | null> {
|
|
75
88
|
const statesDir = getStatesDir();
|
|
76
89
|
if (!existsSync(statesDir)) return Promise.resolve(null);
|
|
77
90
|
|
|
@@ -83,7 +96,7 @@ function findSimilarMatch(combinedHtml: string): Promise<{ hash: string; similar
|
|
|
83
96
|
resolve(null);
|
|
84
97
|
}, FINGERPRINT_WORKER_TIMEOUT_MS);
|
|
85
98
|
|
|
86
|
-
worker.on('message', (data: { matchHash: string | null; similarity: number }) => {
|
|
99
|
+
worker.on('message', (data: { matchHash: string | null; similarity: number; url?: string }) => {
|
|
87
100
|
clearTimeout(timeout);
|
|
88
101
|
const { matchHash, similarity } = data;
|
|
89
102
|
if (!matchHash) {
|
|
@@ -92,7 +105,7 @@ function findSimilarMatch(combinedHtml: string): Promise<{ hash: string; similar
|
|
|
92
105
|
}
|
|
93
106
|
|
|
94
107
|
debugLog(`Similar fingerprint found: ${matchHash} (${similarity}% similar)`);
|
|
95
|
-
resolve({ hash: matchHash, similarity });
|
|
108
|
+
resolve({ hash: matchHash, similarity, url: data.url });
|
|
96
109
|
});
|
|
97
110
|
|
|
98
111
|
worker.postMessage({
|
|
@@ -100,17 +113,22 @@ function findSimilarMatch(combinedHtml: string): Promise<{ hash: string; similar
|
|
|
100
113
|
statesDir,
|
|
101
114
|
maxAgeMs: FINGERPRINT_MAX_AGE_MS,
|
|
102
115
|
threshold: SIMILARITY_THRESHOLD,
|
|
116
|
+
url,
|
|
103
117
|
});
|
|
104
118
|
});
|
|
105
119
|
}
|
|
106
120
|
|
|
107
|
-
export async function findSimilarResearch(combinedHtml: string): Promise<string | null> {
|
|
108
|
-
const match = await findSimilarMatch(combinedHtml);
|
|
121
|
+
export async function findSimilarResearch(combinedHtml: string, url?: string): Promise<string | null> {
|
|
122
|
+
const match = await findSimilarMatch(combinedHtml, url);
|
|
109
123
|
if (!match) return null;
|
|
110
124
|
return getCachedResearch(match.hash) || null;
|
|
111
125
|
}
|
|
112
126
|
|
|
113
|
-
export async function findSimilarStateHash(combinedHtml: string): Promise<string | null> {
|
|
114
|
-
const match = await findSimilarMatch(combinedHtml);
|
|
127
|
+
export async function findSimilarStateHash(combinedHtml: string, url?: string): Promise<string | null> {
|
|
128
|
+
const match = await findSimilarMatch(combinedHtml, url);
|
|
115
129
|
return match?.hash || null;
|
|
116
130
|
}
|
|
131
|
+
|
|
132
|
+
type FingerprintRecord = { entries: string[]; url?: string };
|
|
133
|
+
type FingerprintMatch = { hash: string; similarity: number; url?: string };
|
|
134
|
+
type ResearchState = { hash: string; url?: string };
|
|
@@ -128,7 +128,7 @@ export function WithDeepAnalysis<T extends Constructor>(Base: T) {
|
|
|
128
128
|
updated = `${cached.trimEnd()}\n\n# Extended Research\n\n${sectionMarkdown}\n`;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
saveResearch(pageStateHash, updated);
|
|
131
|
+
saveResearch({ hash: pageStateHash }, updated);
|
|
132
132
|
tag('substep').log(`Overlay research appended: ${focusArea.name}`);
|
|
133
133
|
return sectionMarkdown;
|
|
134
134
|
}
|
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { parentPort } from 'node:worker_threads';
|
|
4
4
|
import { computeHtmlFingerprint } from '../../utils/html-diff.ts';
|
|
5
|
+
import { isSamePageFamily } from '../../utils/url-matcher.ts';
|
|
5
6
|
|
|
6
7
|
function diceSimilarity(a: Set<string>, b: Set<string>): number {
|
|
7
8
|
let intersection = 0;
|
|
@@ -13,8 +14,8 @@ function diceSimilarity(a: Set<string>, b: Set<string>): number {
|
|
|
13
14
|
return Math.round(((2 * intersection) / total) * 100);
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
parentPort!.on('message', (data:
|
|
17
|
-
const { html, statesDir, maxAgeMs, threshold } = data;
|
|
17
|
+
parentPort!.on('message', (data: FingerprintRequest) => {
|
|
18
|
+
const { html, statesDir, maxAgeMs, threshold, url } = data;
|
|
18
19
|
|
|
19
20
|
if (!existsSync(statesDir)) {
|
|
20
21
|
parentPort!.postMessage({ matchHash: null, similarity: 0 });
|
|
@@ -32,22 +33,39 @@ parentPort!.on('message', (data: { html: string; statesDir: string; maxAgeMs: nu
|
|
|
32
33
|
|
|
33
34
|
let bestHash: string | null = null;
|
|
34
35
|
let bestSimilarity = 0;
|
|
36
|
+
let bestUrl: string | undefined;
|
|
35
37
|
|
|
36
38
|
for (const file of files) {
|
|
37
39
|
const filePath = join(statesDir, file);
|
|
38
40
|
const mtime = statSync(filePath).mtimeMs;
|
|
39
41
|
if (now - mtime > maxAgeMs) continue;
|
|
40
42
|
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
+
const record = readFingerprint(filePath);
|
|
44
|
+
if (url && record.url && !isSamePageFamily(url, record.url)) continue;
|
|
45
|
+
const storedFingerprint = new Set(record.entries);
|
|
43
46
|
const similarity = diceSimilarity(currentFingerprint, storedFingerprint);
|
|
44
47
|
|
|
45
48
|
if (similarity > bestSimilarity) {
|
|
46
49
|
bestSimilarity = similarity;
|
|
47
50
|
bestHash = file.replace('.fingerprint', '');
|
|
51
|
+
bestUrl = record.url;
|
|
48
52
|
}
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
const matched = bestSimilarity >= threshold;
|
|
52
|
-
parentPort!.postMessage({ matchHash: matched ? bestHash : null, similarity: bestSimilarity });
|
|
56
|
+
parentPort!.postMessage({ matchHash: matched ? bestHash : null, similarity: bestSimilarity, url: matched ? bestUrl : undefined });
|
|
53
57
|
});
|
|
58
|
+
|
|
59
|
+
function readFingerprint(filePath: string): FingerprintRecord {
|
|
60
|
+
const content = readFileSync(filePath, 'utf8');
|
|
61
|
+
try {
|
|
62
|
+
const record = JSON.parse(content);
|
|
63
|
+
if (Array.isArray(record.entries)) return record;
|
|
64
|
+
} catch {
|
|
65
|
+
return { entries: content.split('\n').filter(Boolean) };
|
|
66
|
+
}
|
|
67
|
+
return { entries: content.split('\n').filter(Boolean) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type FingerprintRecord = { entries: string[]; url?: string };
|
|
71
|
+
type FingerprintRequest = { html: string; statesDir: string; maxAgeMs: number; threshold: number; url?: string };
|