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.
Files changed (113) hide show
  1. package/bin/explorbot-cli.ts +19 -7
  2. package/boat/api-tester/src/cli.ts +17 -0
  3. package/boat/doc-collector/src/cli.ts +14 -1
  4. package/boat/prima/README.md +96 -0
  5. package/boat/prima/package.json +14 -10
  6. package/boat/prima/src/cli.ts +29 -12
  7. package/boat/prima/src/envelope.ts +35 -13
  8. package/boat/prima/src/prima.ts +78 -45
  9. package/dist/bin/explorbot-cli.js +19 -7
  10. package/dist/boat/api-tester/src/cli.js +17 -0
  11. package/dist/boat/doc-collector/src/cli.js +14 -1
  12. package/dist/boat/prima/src/cli.js +26 -7
  13. package/dist/boat/prima/src/envelope.js +32 -8
  14. package/dist/boat/prima/src/prima.js +75 -43
  15. package/dist/models.json +4 -4
  16. package/dist/package.json +6 -2
  17. package/dist/src/action-result.d.ts +13 -0
  18. package/dist/src/action-result.js +46 -15
  19. package/dist/src/action.d.ts +5 -2
  20. package/dist/src/action.js +53 -18
  21. package/dist/src/ai/captain/web-mode.js +1 -2
  22. package/dist/src/ai/captain.d.ts +20 -0
  23. package/dist/src/ai/captain.js +10 -1
  24. package/dist/src/ai/driller.js +6 -2
  25. package/dist/src/ai/fisherman-tools.d.ts +40 -1
  26. package/dist/src/ai/fisherman-tools.js +39 -0
  27. package/dist/src/ai/fisherman.js +2 -1
  28. package/dist/src/ai/navigator.d.ts +28 -0
  29. package/dist/src/ai/navigator.js +223 -175
  30. package/dist/src/ai/pilot.d.ts +7 -4
  31. package/dist/src/ai/pilot.js +89 -30
  32. package/dist/src/ai/planner/subpages.js +2 -16
  33. package/dist/src/ai/planner.js +1 -1
  34. package/dist/src/ai/provider.d.ts +2 -2
  35. package/dist/src/ai/provider.js +28 -22
  36. package/dist/src/ai/researcher/cache.d.ts +10 -3
  37. package/dist/src/ai/researcher/cache.js +23 -10
  38. package/dist/src/ai/researcher/deep-analysis.js +1 -1
  39. package/dist/src/ai/researcher/fingerprint-worker.js +21 -4
  40. package/dist/src/ai/researcher.js +6 -4
  41. package/dist/src/ai/rules.js +1 -5
  42. package/dist/src/ai/session-analyst.js +2 -0
  43. package/dist/src/ai/tester.d.ts +6 -3
  44. package/dist/src/ai/tester.js +30 -35
  45. package/dist/src/ai/tools.d.ts +8 -5
  46. package/dist/src/ai/tools.js +83 -57
  47. package/dist/src/commands/config-command.d.ts +51 -0
  48. package/dist/src/commands/config-command.js +117 -0
  49. package/dist/src/commands/index.js +2 -0
  50. package/dist/src/commands/init-command.js +13 -20
  51. package/dist/src/config.d.ts +8 -1
  52. package/dist/src/config.js +43 -1
  53. package/dist/src/experience-tracker.d.ts +2 -0
  54. package/dist/src/experience-tracker.js +12 -0
  55. package/dist/src/explorbot.js +5 -2
  56. package/dist/src/playwright-recorder.js +6 -12
  57. package/dist/src/remote.d.ts +3 -2
  58. package/dist/src/remote.js +8 -2
  59. package/dist/src/state-manager.d.ts +1 -1
  60. package/dist/src/state-manager.js +3 -1
  61. package/dist/src/test-plan.d.ts +9 -0
  62. package/dist/src/test-plan.js +30 -0
  63. package/dist/src/utils/html-diff.d.ts +5 -0
  64. package/dist/src/utils/html-diff.js +65 -6
  65. package/dist/src/utils/logger.d.ts +1 -1
  66. package/dist/src/utils/logger.js +8 -0
  67. package/dist/src/utils/strings.d.ts +2 -0
  68. package/dist/src/utils/strings.js +32 -0
  69. package/dist/src/utils/url-matcher.d.ts +1 -0
  70. package/dist/src/utils/url-matcher.js +31 -2
  71. package/docs/basics/getting-started.md +33 -10
  72. package/docs/basics/providers.md +6 -4
  73. package/docs/contributing/npm-package.md +73 -4
  74. package/docs/index.json +2 -1
  75. package/docs/reference/commands.md +3 -0
  76. package/docs/reference/websocket.md +50 -0
  77. package/docs/superpowers/specs/2026-08-18-prima-false-verdicts.md +159 -0
  78. package/models.json +4 -4
  79. package/package.json +6 -2
  80. package/src/action-result.ts +61 -16
  81. package/src/action.ts +56 -18
  82. package/src/ai/captain/web-mode.ts +1 -2
  83. package/src/ai/captain.ts +9 -1
  84. package/src/ai/driller.ts +6 -2
  85. package/src/ai/fisherman-tools.ts +35 -0
  86. package/src/ai/fisherman.ts +2 -1
  87. package/src/ai/navigator.ts +238 -179
  88. package/src/ai/pilot.ts +104 -36
  89. package/src/ai/planner/subpages.ts +2 -13
  90. package/src/ai/planner.ts +1 -1
  91. package/src/ai/provider.ts +29 -21
  92. package/src/ai/researcher/cache.ts +29 -11
  93. package/src/ai/researcher/deep-analysis.ts +1 -1
  94. package/src/ai/researcher/fingerprint-worker.ts +23 -5
  95. package/src/ai/researcher.ts +6 -4
  96. package/src/ai/rules.ts +1 -5
  97. package/src/ai/session-analyst.ts +2 -0
  98. package/src/ai/tester.ts +33 -34
  99. package/src/ai/tools.ts +88 -61
  100. package/src/commands/config-command.ts +146 -0
  101. package/src/commands/index.ts +2 -0
  102. package/src/commands/init-command.ts +14 -20
  103. package/src/config.ts +47 -2
  104. package/src/experience-tracker.ts +13 -0
  105. package/src/explorbot.ts +4 -2
  106. package/src/playwright-recorder.ts +6 -11
  107. package/src/remote.ts +8 -2
  108. package/src/state-manager.ts +5 -2
  109. package/src/test-plan.ts +38 -0
  110. package/src/utils/html-diff.ts +72 -7
  111. package/src/utils/logger.ts +9 -1
  112. package/src/utils/strings.ts +36 -0
  113. 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
- If you load a recipe via learnExperience, do NOT rewrite its code in your plan — the
468
- raw recipe is forwarded to Tester automatically. Reference it by step ("apply recipe
469
- steps 1–3, then…") and call out anywhere your scenario diverges from it.
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<Array<{ text: string; status: 'passed' | 'failed' | 'unverified' }>> {
566
- const undecided = task.expected.filter((text) => !task.getCheckedExpectations().includes(text));
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 log shows it happened, failed = the log shows it did not, unverified = the run never established either way'),
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 log neither shows the outcome happening nor shows it failing —
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 response = await this.provider
601
- .generateObject([{ role: 'user' as const, content: userContent }], schema, this.provider.getAgenticModel('pilot'), {
602
- agentName: 'pilot',
603
- telemetry: { functionId: 'pilot.settleExpectations' },
604
- })
605
- .catch(() => null);
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.status]));
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
- return { text, status: (judged.get(text) as 'passed' | 'failed' | 'unverified') || 'unverified' };
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: 'Fisherman not available' };
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 ATTACH_HTML, ATTACH_ARIA, or ATTACH_UI_MAP — only when recent actions show failures.
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, visualClick,
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 { isDynamicSegment } from '../../utils/url-matcher.ts';
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
- const partsA = normalizeUrl(urlA).split('/');
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) {
@@ -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 { AIConfig } from '../config.js';
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> = { model: this.getModelName(this.config.model) };
125
- if (this.config.agenticModel) models.agenticModel = this.getModelName(this.config.agenticModel);
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 langfuseConfig = this.config.langfuse;
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 = this.getModelName(model);
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 = this.getModelName(model);
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 = this.getModelName(modelToUse);
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', this.getModelName(this.config.visionModel), response.usage);
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 = outputPath('research', `${hash}.md`);
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 = outputPath('research', `${hash}.md`);
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(hash: string, text: string, combinedHtml?: string): string {
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
- writeFileSync(fingerprintFile, fingerprint.join('\n'));
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<{ hash: string; similarity: number } | null> {
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: { html: string; statesDir: string; maxAgeMs: number; threshold: number }) => {
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 lines = readFileSync(filePath, 'utf8').split('\n').filter(Boolean);
42
- const storedFingerprint = new Set(lines);
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 };