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
@@ -3,6 +3,7 @@ import dedent from 'dedent';
3
3
  import { z } from 'zod';
4
4
  import { ActionResult } from "../action-result.js";
5
5
  import { ConfigParser } from "../config.js";
6
+ import { Stats } from "../stats.js";
6
7
  import { TestResult } from "../test-plan.js";
7
8
  import { collectInteractiveNodes, detectFocusArea } from "../utils/aria.js";
8
9
  import { ErrorPageError } from "../utils/error-page.js";
@@ -14,6 +15,8 @@ import { isInteractive } from "./task-agent.js";
14
15
  import { withdrawVisionTools } from "./tools.js";
15
16
  const CHECK_TOOLS = ['verify', 'see', 'research', 'context'];
16
17
  const META_TOOLS = ['record', 'reset', 'stop', 'finish'];
18
+ const PILOT_MESSAGE_LIMIT = 2;
19
+ const PILOT_MESSAGE_MAX_LENGTH = 160;
17
20
  export class Pilot {
18
21
  emoji = '🧭';
19
22
  provider;
@@ -410,9 +413,13 @@ export class Pilot {
410
413
  the elements needed for the scenario. The page summary does not list every element.
411
414
  Prefer interacting with the current page over navigating away.
412
415
 
413
- If you load a recipe via learnExperience, do NOT rewrite its code in your plan — the
414
- raw recipe is forwarded to Tester automatically. Reference it by step ("apply recipe
415
- steps 1–3, then…") and call out anywhere your scenario diverges from it.
416
+ Tester never sees <experience> a recorded recipe reaches it only when you open one.
417
+ The entries listed are what was recorded on the page you are on now; recipes for the
418
+ pages this test moves to are listed when it gets there. Open the ones whose titles fit a
419
+ step taken from here, and say so in the plan when none of them fit.
420
+ Do NOT rewrite a loaded recipe's code — the raw recipe is forwarded to Tester
421
+ automatically. Reference it by step ("apply recipe steps 1–3, then…") and call out
422
+ anywhere your scenario diverges from it.
416
423
 
417
424
  Be concise and specific. Tester will follow your plan.
418
425
  `, 'pilot.planTest', { tools: true, maxToolRoundtrips: 3, task });
@@ -451,7 +458,9 @@ export class Pilot {
451
458
  ${this.formatExpectations(task)}
452
459
 
453
460
  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.
454
- `, 'pilot.reviewNewPage', { task });
461
+
462
+ 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.
463
+ `, 'pilot.reviewNewPage', { tools: true, maxToolRoundtrips: 2, task });
455
464
  }
456
465
  async analyzeProgress(task, currentState, testerConversation) {
457
466
  tag('substep').log('Pilot analyzing progress...');
@@ -478,6 +487,8 @@ export class Pilot {
478
487
  </recent_actions>
479
488
 
480
489
  What should Tester do next?
490
+
491
+ Before proposing new locators for a step that keeps failing, check <experience> for a recorded recipe covering it and load it.
481
492
  `, 'pilot.analyze', { tools: hasFailures, maxToolRoundtrips: hasFailures ? 2 : 0, task });
482
493
  const contextToAttach = await this.fetchRequestedContext(text, currentState);
483
494
  if (contextToAttach) {
@@ -485,21 +496,43 @@ export class Pilot {
485
496
  }
486
497
  return text;
487
498
  }
488
- async settleExpectations(task) {
489
- const undecided = task.expected.filter((text) => !task.getCheckedExpectations().includes(text));
499
+ async settleExpectations(task, finalState) {
500
+ let image = null;
501
+ if (finalState?.screenshot && this.provider.hasVision())
502
+ image = `data:image/png;base64,${finalState.screenshot.toString('base64')}`;
490
503
  const decided = (text) => {
491
504
  if (task.hasAchievedAny() && !task.getRemainingExpectations().includes(text))
492
505
  return 'passed';
493
506
  return 'failed';
494
507
  };
508
+ let undecided = task.expected.filter((text) => !task.getCheckedExpectations().includes(text));
509
+ if (image)
510
+ undecided = task.expected;
495
511
  if (!undecided.length)
496
512
  return task.expected.map((text) => ({ text, status: decided(text) }));
497
513
  const schema = z.object({
498
514
  outcomes: z.array(z.object({
499
515
  expectation: z.string().describe('The expected outcome, repeated exactly as it was given'),
500
- 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'),
516
+ 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'),
517
+ evidence: z.string().nullable().describe('What settled it. For a contradiction, what each side shows. Null when there is nothing to add'),
501
518
  })),
502
519
  });
520
+ let pageEvidence = '';
521
+ if (image) {
522
+ pageEvidence = dedent `
523
+ A screenshot of the whole page as the run left it is attached. It is the proof: an outcome is satisfied
524
+ when the page shows it to somebody looking at it. The log only says what the run did.
525
+
526
+ Not finding something in the picture is not by itself a disagreement. Report "contradiction" only when
527
+ the picture shows something incompatible with what the run claims — a list visibly empty, an error where
528
+ a result was expected, the old value still displayed, a control visibly disabled. When you simply cannot
529
+ make it out, say "unverified" and name what you could not find.
530
+
531
+ The picture covers the full page, but not the inside of a region that scrolls on its own, and not the
532
+ state of the page before the run ended. An outcome established earlier stays established even when the
533
+ page has moved past it, and that is not a contradiction.
534
+ `;
535
+ }
503
536
  const userContent = dedent `
504
537
  A test run has finished. Decide, for each expected outcome, what the run established about it.
505
538
 
@@ -511,22 +544,41 @@ export class Pilot {
511
544
  ${task.notesToString() || 'No steps recorded.'}
512
545
  </run_log>
513
546
 
547
+ ${pageEvidence}
548
+
514
549
  The log is written in the tester's own words, so an outcome can be satisfied by a step that describes it
515
550
  differently. Judge by what the steps show happened, not by whether the wording matches.
516
- Choose "unverified" only when the log neither shows the outcome happening nor shows it failing —
551
+ Choose "unverified" only when the evidence neither shows the outcome happening nor shows it failing —
517
552
  that is a statement about the run, not about the application.
518
553
  `;
519
- const response = await this.provider
520
- .generateObject([{ role: 'user', content: userContent }], schema, this.provider.getAgenticModel('pilot'), {
554
+ const settle = (content, model) => this.provider
555
+ .generateObject([{ role: 'user', content }], schema, model, {
521
556
  agentName: 'pilot',
522
557
  telemetry: { functionId: 'pilot.settleExpectations' },
523
558
  })
524
559
  .catch(() => null);
525
- const judged = new Map((response?.object?.outcomes || []).map((outcome) => [outcome.expectation, outcome.status]));
560
+ let response = null;
561
+ if (image) {
562
+ const seen = [
563
+ { type: 'text', text: userContent },
564
+ { type: 'file', mediaType: 'image/png', data: image },
565
+ ];
566
+ response = await settle(seen, this.provider.getVisionModel());
567
+ if (!response) {
568
+ Stats.visionDisabled = true;
569
+ tag('warning').log('⚠️ Vision model could not judge the outcomes. Settling them from the run log instead.');
570
+ }
571
+ }
572
+ if (!response)
573
+ response = await settle(userContent, this.provider.getAgenticModel('pilot'));
574
+ const judged = new Map((response?.object?.outcomes || []).map((outcome) => [outcome.expectation, outcome]));
526
575
  return task.expected.map((text) => {
527
576
  if (!undecided.includes(text))
528
577
  return { text, status: decided(text) };
529
- return { text, status: judged.get(text) || 'unverified' };
578
+ const outcome = judged.get(text);
579
+ if (!outcome)
580
+ return { text, status: 'unverified' };
581
+ return { text, status: outcome.status || 'unverified', evidence: outcome.evidence };
530
582
  });
531
583
  }
532
584
  formatExpectations(task) {
@@ -538,10 +590,10 @@ export class Pilot {
538
590
  debugLog(`sendToPilot: ${functionId}, tools: ${!!opts.tools}, roundtrips: ${opts.maxToolRoundtrips ?? 0}`);
539
591
  let finalUserText = userText;
540
592
  if (opts.tools) {
593
+ this.conversation.cleanupTag('experience', '...cleaned experience index...');
541
594
  const tocBlock = this.getExperienceToc();
542
- if (tocBlock) {
595
+ if (tocBlock)
543
596
  finalUserText = `${tocBlock}\n\n${userText}`;
544
- }
545
597
  }
546
598
  this.conversation.addUserText(finalUserText);
547
599
  const tools = { ...this.pickPlanningTools(), ...this.buildPreconditionTool(opts.task) };
@@ -553,9 +605,10 @@ export class Pilot {
553
605
  telemetry: { functionId },
554
606
  });
555
607
  const text = result?.response?.text || '';
556
- const learned = (result?.toolExecutions || []).filter((e) => e.toolName === 'learnExperience' && e.output?.content).map((e) => e.output.content);
608
+ const learned = (result?.toolExecutions || []).filter((e) => e.toolName === 'learnExperience' && e.output?.content).map((e) => ({ url: e.output.url, content: e.output.content }));
557
609
  if (learned.length === 0)
558
610
  return text;
611
+ opts.task.applyExperience(learned);
559
612
  return dedent `
560
613
  ${text}
561
614
 
@@ -563,7 +616,7 @@ export class Pilot {
563
616
  Recipes from prior successful runs that Pilot judged relevant. Locators worked then; the page may have changed since.
564
617
  Treat code blocks below as a starting hypothesis. If a locator misses, fall back to ARIA/UI-map.
565
618
 
566
- ${learned.join('\n\n')}
619
+ ${learned.map((recipe) => recipe.content).join('\n\n')}
567
620
  </applied_experience>
568
621
  `;
569
622
  }
@@ -596,6 +649,7 @@ export class Pilot {
596
649
  return planning;
597
650
  }
598
651
  buildPreconditionTool(task) {
652
+ 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.';
599
653
  return {
600
654
  precondition: tool({
601
655
  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".',
@@ -610,7 +664,7 @@ export class Pilot {
610
664
  const skipReason = await this.checkDataAvailability(task, description, 'Fisherman not available');
611
665
  if (skipReason)
612
666
  return { noted: true, prepared: false, skipped: true, reason: skipReason };
613
- return { noted: true, prepared: false, reason: 'Fisherman not available' };
667
+ return { noted: true, prepared: false, reason: unavailable };
614
668
  }
615
669
  const result = await this.fisherman.prepareData(description, task.startUrl, task.sessionName);
616
670
  if (!result.success || result.created.length === 0) {
@@ -619,7 +673,7 @@ export class Pilot {
619
673
  const skipReason = await this.checkDataAvailability(task, description, result.summary);
620
674
  if (skipReason)
621
675
  return { noted: true, prepared: false, skipped: true, reason: skipReason };
622
- return { noted: true, prepared: false, reason: result.summary };
676
+ return { noted: true, prepared: false, reason: `${result.summary || 'Data preparation failed'}. ${unavailable}` };
623
677
  }
624
678
  const items = result.created.map((c) => {
625
679
  const parts = [c.type];
@@ -654,7 +708,7 @@ export class Pilot {
654
708
 
655
709
  Reply with YES or NO on the first line, then a one-sentence reason on the second line.
656
710
  `;
657
- const answer = await this.researcher.answerQuestionAboutScreenshot(screenshotState, question);
711
+ const answer = await this.researcher.answerQuestionAboutScreenshot(screenshotState, question).catch(() => null);
658
712
  if (!answer)
659
713
  return null;
660
714
  const firstLine = answer.split('\n')[0]?.trim().toUpperCase() ?? '';
@@ -737,14 +791,6 @@ export class Pilot {
737
791
  }
738
792
  async fetchRequestedContext(text, currentState) {
739
793
  const parts = [];
740
- if (text.includes('ATTACH_HTML')) {
741
- const html = await currentState.simplifiedHtml();
742
- parts.push(dedent `
743
- <page_html>
744
- ${html}
745
- </page_html>
746
- `);
747
- }
748
794
  if (text.includes('ATTACH_ARIA')) {
749
795
  parts.push(dedent `
750
796
  <page_aria>
@@ -900,6 +946,19 @@ export class Pilot {
900
946
  const ariaDiff = t.output?.pageDiff?.ariaChanges;
901
947
  if (ariaDiff)
902
948
  line += `\n ${ariaDiff}`;
949
+ if (t.output?.pageDiff?.urlChanged)
950
+ line += `\n moved: ${t.output.pageDiff.previousUrl} → ${t.output.pageDiff.currentUrl}`;
951
+ const failedRequests = (t.output?.pageDiff?.requests ?? []).filter((r) => r.status >= 400);
952
+ if (failedRequests.length > 0) {
953
+ line += `\n requests: ${failedRequests.map((r) => `${r.method} ${r.path} → ${r.status}`).join(', ')}`;
954
+ }
955
+ const messages = (t.output?.pageDiff?.messages ?? []).slice(0, PILOT_MESSAGE_LIMIT);
956
+ if (messages.length > 0) {
957
+ line += `\n messages: ${messages.map((m) => m.slice(0, PILOT_MESSAGE_MAX_LENGTH)).join(' | ')}`;
958
+ }
959
+ const consoleError = t.output?.pageDiff?.consoleErrors?.[0];
960
+ if (consoleError)
961
+ line += `\n console: ${consoleError.slice(0, PILOT_MESSAGE_MAX_LENGTH)}`;
903
962
  return line;
904
963
  })
905
964
  .join('\n\n');
@@ -965,10 +1024,10 @@ export class Pilot {
965
1024
  role, icon classes with "or" in one XPath. If empty, broaden (drop role filter). Pass discovered
966
1025
  XPath into NEXT instruction.
967
1026
 
968
- To request more context, mention ATTACH_HTML, ATTACH_ARIA, or ATTACH_UI_MAP — only when recent actions show failures.
1027
+ To request more context, mention ATTACH_ARIA, ATTACH_SUMMARY, or ATTACH_UI_MAP — only when recent actions show failures.
969
1028
 
970
- Tester tools: click, pressKey, form, see, verify, context, research, xpathCheck, visualClick,
971
- back, getVisitedStates, reset, stop, finish, record.
1029
+ Tester tools: click, pressKey, form, see, verify, interact, context, research, xpathCheck,
1030
+ visualClick, back, getVisitedStates, reset, stop, finish, record.
972
1031
  Use tool names exactly as listed. Do not invent combined names, aliases, or names with channel markers such as "commentary".
973
1032
 
974
1033
  ${capabilityGroundingRule}
@@ -1,7 +1,7 @@
1
1
  import dedent from 'dedent';
2
2
  import { z } from 'zod';
3
3
  import { normalizeUrl } from "../../state-manager.js";
4
- import { isDynamicSegment } from "../../utils/url-matcher.js";
4
+ import { isSamePageFamily } from "../../utils/url-matcher.js";
5
5
  const planRegistry = new Map();
6
6
  export function registerPlan(url, plan, feature, stateHash) {
7
7
  const key = buildKey(url, feature);
@@ -28,21 +28,7 @@ function buildKey(url, feature) {
28
28
  return normalized;
29
29
  }
30
30
  export function isTemplateMatch(urlA, urlB) {
31
- const partsA = normalizeUrl(urlA).split('/');
32
- const partsB = normalizeUrl(urlB).split('/');
33
- if (partsA.length !== partsB.length)
34
- return false;
35
- let diffCount = 0;
36
- for (let i = 0; i < partsA.length; i++) {
37
- if (partsA[i] === partsB[i])
38
- continue;
39
- diffCount++;
40
- if (diffCount > 1)
41
- return false;
42
- if (!isDynamicSegment(partsA[i]) && !isDynamicSegment(partsB[i]))
43
- return false;
44
- }
45
- return diffCount === 1;
31
+ return isSamePageFamily(urlA, urlB);
46
32
  }
47
33
  export function getPlannedByStateHash(hash) {
48
34
  for (const record of planRegistry.values()) {
@@ -127,7 +127,7 @@ export class Planner extends PlannerBase {
127
127
  }
128
128
  const actionResult = ActionResult.fromState(state);
129
129
  const combinedHtml = await actionResult.combinedHtml();
130
- const similarHash = await findSimilarStateHash(combinedHtml);
130
+ const similarHash = await findSimilarStateHash(combinedHtml, state.url);
131
131
  if (similarHash) {
132
132
  const planned = getPlannedByStateHash(similarHash);
133
133
  if (planned) {
@@ -1,6 +1,6 @@
1
1
  import { NodeSDK } from '@opentelemetry/sdk-node';
2
2
  import type { ModelMessage } from 'ai';
3
- import type { AIConfig } from '../config.js';
3
+ import { type AIConfig } from '../config.js';
4
4
  import { type RetryOptions } from '../utils/retry.js';
5
5
  import { Conversation } from './conversation.js';
6
6
  declare class AiError extends Error {
@@ -14,10 +14,10 @@ export declare class Provider {
14
14
  defaultRetryOptions: RetryOptions;
15
15
  lastConversation: Conversation | null;
16
16
  constructor(config: AIConfig);
17
- getModelName(model: any): string;
18
17
  validateConnection(): Promise<void>;
19
18
  getModelForAgent(agentName?: string): any;
20
19
  getAgenticModel(agentName?: string): any;
20
+ getVisionModel(): any;
21
21
  getConfiguredModels(): Record<string, string>;
22
22
  getSystemPromptForAgent(agentName: string, currentUrl?: string): string | undefined;
23
23
  getProviderOptionsForAgent(agentName: string): Record<string, any> | undefined;
@@ -3,6 +3,7 @@ import { LangfuseSpanProcessor } from '@langfuse/otel';
3
3
  import { NodeSDK } from '@opentelemetry/sdk-node';
4
4
  import { generateObject, generateText, isStepCount, registerTelemetry } from 'ai';
5
5
  import { clearActivity, setActivity } from "../activity.js";
6
+ import { configuredModels, modelName as getModelName } from '../config.js';
6
7
  import { executionController } from "../execution-controller.js";
7
8
  import { Observability } from "../observability.js";
8
9
  import { Stats } from "../stats.js";
@@ -79,9 +80,6 @@ export class Provider {
79
80
  this.config = config;
80
81
  this.initLangfuse();
81
82
  }
82
- getModelName(model) {
83
- return model?.modelId || model?.model || 'unknown';
84
- }
85
83
  async validateConnection() {
86
84
  try {
87
85
  await generateText({
@@ -108,16 +106,13 @@ export class Provider {
108
106
  }
109
107
  return this.config.agenticModel || this.config.model;
110
108
  }
109
+ getVisionModel() {
110
+ return this.config.visionModel;
111
+ }
111
112
  getConfiguredModels() {
112
- const models = { model: this.getModelName(this.config.model) };
113
- if (this.config.agenticModel)
114
- models.agenticModel = this.getModelName(this.config.agenticModel);
115
- if (this.config.visionModel)
116
- models.visionModel = this.getModelName(this.config.visionModel);
117
- for (const [agent, agentConfig] of Object.entries(this.config.agents || {})) {
118
- if (agentConfig?.model)
119
- models[agent] = this.getModelName(agentConfig.model);
120
- }
113
+ const models = {};
114
+ for (const [role, model] of Object.entries(configuredModels(this.config)))
115
+ models[role] = model.name;
121
116
  return models;
122
117
  }
123
118
  getSystemPromptForAgent(agentName, currentUrl) {
@@ -207,11 +202,7 @@ export class Provider {
207
202
  return retry(reduced.messages, { ...options, _contextRetryLevel: reduced.nextLevel });
208
203
  }
209
204
  initLangfuse() {
210
- const langfuseConfig = this.config.langfuse;
211
- const publicKey = langfuseConfig?.publicKey || process.env.LANGFUSE_PUBLIC_KEY;
212
- const secretKey = langfuseConfig?.secretKey || process.env.LANGFUSE_SECRET_KEY;
213
- const baseUrl = langfuseConfig?.baseUrl || process.env.LANGFUSE_BASE_URL || process.env.LANGFUSE_HOST;
214
- const enabled = langfuseConfig?.enabled ?? Boolean(publicKey && secretKey);
205
+ const { enabled, publicKey, secretKey, baseUrl } = this.config.langfuse || {};
215
206
  if (!enabled || !publicKey || !secretKey) {
216
207
  return;
217
208
  }
@@ -282,7 +273,7 @@ export class Provider {
282
273
  return { conversation, response, toolExecutions };
283
274
  }
284
275
  async chat(messages, model, options = {}) {
285
- const modelName = this.getModelName(model);
276
+ const modelName = getModelName(model);
286
277
  setActivity(`🤖 Asking ${modelName}`, 'ai');
287
278
  promptLog(`Using model: ${modelName}`);
288
279
  const config = this.buildGenerateConfig({ maxOutputTokens: 16384 }, { model, abortSignal: executionController.getAbortSignal() }, options);
@@ -324,7 +315,7 @@ export class Provider {
324
315
  }
325
316
  }
326
317
  async generateWithTools(messages, model, tools, options = {}) {
327
- const modelName = this.getModelName(model);
318
+ const modelName = getModelName(model);
328
319
  setActivity(`🤖 Asking ${modelName} with dynamic tools`, 'ai');
329
320
  promptLog(`Using model: ${modelName}`);
330
321
  const toolNames = Object.keys(tools || {});
@@ -336,7 +327,7 @@ export class Provider {
336
327
  const stopConditions = [isStepCount(maxRoundtrips)];
337
328
  if (extraStop)
338
329
  stopConditions.push(extraStop);
339
- const config = this.buildGenerateConfig({ tools, maxOutputTokens: 16384, toolChoice: 'auto' }, { stopWhen: stopConditions, model }, options);
330
+ const config = this.buildGenerateConfig({ tools, maxOutputTokens: 16384, toolChoice: 'auto', experimental_repairToolCall: repairToolCall }, { stopWhen: stopConditions, model }, options);
340
331
  try {
341
332
  const response = await withRetry(async () => {
342
333
  const result = (await this.raceWithIdleTimeout((signal) => generateText({ messages, ...config, abortSignal: signal }), config.timeout || 30000));
@@ -379,7 +370,7 @@ export class Provider {
379
370
  }
380
371
  async generateObject(messages, schema, model, options = {}) {
381
372
  const modelToUse = model || this.config.model;
382
- const modelName = this.getModelName(modelToUse);
373
+ const modelName = getModelName(modelToUse);
383
374
  setActivity(`🤖 Asking ${modelName} for structured output`, 'ai');
384
375
  promptLog(`Using model: ${modelName}`);
385
376
  const config = this.buildGenerateConfig({ schema }, { model: modelToUse }, options);
@@ -557,7 +548,7 @@ export class Provider {
557
548
  }, this.getRetryOptions());
558
549
  clearActivity();
559
550
  responseLog(response.text);
560
- this.recordUsage('vision', this.getModelName(this.config.visionModel), response.usage);
551
+ this.recordUsage('vision', getModelName(this.config.visionModel), response.usage);
561
552
  return response;
562
553
  }
563
554
  catch (error) {
@@ -571,4 +562,19 @@ export class Provider {
571
562
  return this.config.visionModel !== undefined;
572
563
  }
573
564
  }
565
+ function repairToolCall(options) {
566
+ if (options.toolCall.toolName.includes('<|channel|>'))
567
+ return repairChannelMarker(options);
568
+ return null;
569
+ }
570
+ function repairChannelMarker({ toolCall, tools }) {
571
+ const markerIndex = toolCall.toolName.indexOf('<|channel|>');
572
+ if (markerIndex <= 0)
573
+ return null;
574
+ const toolName = toolCall.toolName.slice(0, markerIndex);
575
+ if (!tools[toolName])
576
+ return null;
577
+ tag('warning').log(`Repaired tool name '${toolCall.toolName}' → '${toolName}'`);
578
+ return { ...toolCall, toolName };
579
+ }
574
580
  export { AiError, Provider as AIProvider };
@@ -1,6 +1,13 @@
1
+ export declare function researchPath(hash: string): string;
2
+ export declare function reportResearch(hash: string, text: string): void;
1
3
  export declare function clearResearchCache(): void;
2
4
  export declare function getCachedResearch(hash: string): string;
3
5
  export declare function getPreviousResearch(hash: string): string;
4
- export declare function saveResearch(hash: string, text: string, combinedHtml?: string): string;
5
- export declare function findSimilarResearch(combinedHtml: string): Promise<string | null>;
6
- export declare function findSimilarStateHash(combinedHtml: string): Promise<string | null>;
6
+ export declare function saveResearch(state: ResearchState, text: string, combinedHtml?: string): string;
7
+ export declare function findSimilarResearch(combinedHtml: string, url?: string): Promise<string | null>;
8
+ export declare function findSimilarStateHash(combinedHtml: string, url?: string): Promise<string | null>;
9
+ type ResearchState = {
10
+ hash: string;
11
+ url?: string;
12
+ };
13
+ export {};
@@ -4,6 +4,7 @@ import { Worker } from 'node:worker_threads';
4
4
  import { outputPath } from "../../config.js";
5
5
  import { TTLCache } from "../../utils/cache.js";
6
6
  import { computeHtmlFingerprint } from "../../utils/html-diff.js";
7
+ import { tag } from "../../utils/logger.js";
7
8
  import { debugLog } from "./mixin.js";
8
9
  const CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 6 hours
9
10
  const FINGERPRINT_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
@@ -11,6 +12,12 @@ const FINGERPRINT_WORKER_TIMEOUT_MS = 10_000;
11
12
  const SIMILARITY_THRESHOLD = 90;
12
13
  const memoryCache = new TTLCache(CACHE_TTL_MS);
13
14
  let fingerprintWorker = null;
15
+ export function researchPath(hash) {
16
+ return outputPath('research', `${hash}.md`);
17
+ }
18
+ export function reportResearch(hash, text) {
19
+ tag('data').log('research', { path: researchPath(hash), hash, content: text });
20
+ }
14
21
  function getStatesDir() {
15
22
  return outputPath('states');
16
23
  }
@@ -30,7 +37,7 @@ export function getCachedResearch(hash) {
30
37
  const cached = memoryCache.get(hash);
31
38
  if (cached !== undefined)
32
39
  return cached;
33
- const researchFile = outputPath('research', `${hash}.md`);
40
+ const researchFile = researchPath(hash);
34
41
  if (!existsSync(researchFile))
35
42
  return '';
36
43
  const stats = statSync(researchFile);
@@ -43,18 +50,20 @@ export function getCachedResearch(hash) {
43
50
  export function getPreviousResearch(hash) {
44
51
  if (!hash)
45
52
  return '';
46
- const researchFile = outputPath('research', `${hash}.md`);
53
+ const researchFile = researchPath(hash);
47
54
  if (!existsSync(researchFile))
48
55
  return '';
49
56
  return readFileSync(researchFile, 'utf8');
50
57
  }
51
- export function saveResearch(hash, text, combinedHtml) {
58
+ export function saveResearch(state, text, combinedHtml) {
59
+ const { hash, url } = state;
52
60
  const researchDir = outputPath('research');
53
61
  const researchFile = join(researchDir, `${hash}.md`);
54
62
  if (!existsSync(researchDir))
55
63
  mkdirSync(researchDir, { recursive: true });
56
64
  writeFileSync(researchFile, text);
57
65
  memoryCache.set(hash, text);
66
+ reportResearch(hash, text);
58
67
  debugLog(`Research saved to ${researchFile}`);
59
68
  if (combinedHtml) {
60
69
  const statesDir = getStatesDir();
@@ -62,12 +71,15 @@ export function saveResearch(hash, text, combinedHtml) {
62
71
  mkdirSync(statesDir, { recursive: true });
63
72
  const fingerprint = computeHtmlFingerprint(combinedHtml);
64
73
  const fingerprintFile = join(statesDir, `${hash}.fingerprint`);
65
- writeFileSync(fingerprintFile, fingerprint.join('\n'));
74
+ const record = { entries: fingerprint };
75
+ if (url)
76
+ record.url = url;
77
+ writeFileSync(fingerprintFile, JSON.stringify(record));
66
78
  debugLog(`Fingerprint saved to ${fingerprintFile}`);
67
79
  }
68
80
  return researchFile;
69
81
  }
70
- function findSimilarMatch(combinedHtml) {
82
+ function findSimilarMatch(combinedHtml, url) {
71
83
  const statesDir = getStatesDir();
72
84
  if (!existsSync(statesDir))
73
85
  return Promise.resolve(null);
@@ -85,23 +97,24 @@ function findSimilarMatch(combinedHtml) {
85
97
  return;
86
98
  }
87
99
  debugLog(`Similar fingerprint found: ${matchHash} (${similarity}% similar)`);
88
- resolve({ hash: matchHash, similarity });
100
+ resolve({ hash: matchHash, similarity, url: data.url });
89
101
  });
90
102
  worker.postMessage({
91
103
  html: combinedHtml,
92
104
  statesDir,
93
105
  maxAgeMs: FINGERPRINT_MAX_AGE_MS,
94
106
  threshold: SIMILARITY_THRESHOLD,
107
+ url,
95
108
  });
96
109
  });
97
110
  }
98
- export async function findSimilarResearch(combinedHtml) {
99
- const match = await findSimilarMatch(combinedHtml);
111
+ export async function findSimilarResearch(combinedHtml, url) {
112
+ const match = await findSimilarMatch(combinedHtml, url);
100
113
  if (!match)
101
114
  return null;
102
115
  return getCachedResearch(match.hash) || null;
103
116
  }
104
- export async function findSimilarStateHash(combinedHtml) {
105
- const match = await findSimilarMatch(combinedHtml);
117
+ export async function findSimilarStateHash(combinedHtml, url) {
118
+ const match = await findSimilarMatch(combinedHtml, url);
106
119
  return match?.hash || null;
107
120
  }
@@ -96,7 +96,7 @@ export function WithDeepAnalysis(Base) {
96
96
  else {
97
97
  updated = `${cached.trimEnd()}\n\n# Extended Research\n\n${sectionMarkdown}\n`;
98
98
  }
99
- saveResearch(pageStateHash, updated);
99
+ saveResearch({ hash: pageStateHash }, updated);
100
100
  tag('substep').log(`Overlay research appended: ${focusArea.name}`);
101
101
  return sectionMarkdown;
102
102
  }
@@ -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.js";
5
+ import { isSamePageFamily } from "../../utils/url-matcher.js";
5
6
  function diceSimilarity(a, b) {
6
7
  let intersection = 0;
7
8
  for (const item of a) {
@@ -14,7 +15,7 @@ function diceSimilarity(a, b) {
14
15
  return Math.round(((2 * intersection) / total) * 100);
15
16
  }
16
17
  parentPort.on('message', (data) => {
17
- const { html, statesDir, maxAgeMs, threshold } = data;
18
+ const { html, statesDir, maxAgeMs, threshold, url } = data;
18
19
  if (!existsSync(statesDir)) {
19
20
  parentPort.postMessage({ matchHash: null, similarity: 0 });
20
21
  return;
@@ -28,19 +29,35 @@ parentPort.on('message', (data) => {
28
29
  const files = readdirSync(statesDir).filter((f) => f.endsWith('.fingerprint'));
29
30
  let bestHash = null;
30
31
  let bestSimilarity = 0;
32
+ let bestUrl;
31
33
  for (const file of files) {
32
34
  const filePath = join(statesDir, file);
33
35
  const mtime = statSync(filePath).mtimeMs;
34
36
  if (now - mtime > maxAgeMs)
35
37
  continue;
36
- const lines = readFileSync(filePath, 'utf8').split('\n').filter(Boolean);
37
- const storedFingerprint = new Set(lines);
38
+ const record = readFingerprint(filePath);
39
+ if (url && record.url && !isSamePageFamily(url, record.url))
40
+ continue;
41
+ const storedFingerprint = new Set(record.entries);
38
42
  const similarity = diceSimilarity(currentFingerprint, storedFingerprint);
39
43
  if (similarity > bestSimilarity) {
40
44
  bestSimilarity = similarity;
41
45
  bestHash = file.replace('.fingerprint', '');
46
+ bestUrl = record.url;
42
47
  }
43
48
  }
44
49
  const matched = bestSimilarity >= threshold;
45
- parentPort.postMessage({ matchHash: matched ? bestHash : null, similarity: bestSimilarity });
50
+ parentPort.postMessage({ matchHash: matched ? bestHash : null, similarity: bestSimilarity, url: matched ? bestUrl : undefined });
46
51
  });
52
+ function readFingerprint(filePath) {
53
+ const content = readFileSync(filePath, 'utf8');
54
+ try {
55
+ const record = JSON.parse(content);
56
+ if (Array.isArray(record.entries))
57
+ return record;
58
+ }
59
+ catch {
60
+ return { entries: content.split('\n').filter(Boolean) };
61
+ }
62
+ return { entries: content.split('\n').filter(Boolean) };
63
+ }