cbrowser 19.2.0 → 19.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +2 -2
  2. package/dist/analysis/accessibility-empathy.d.ts +6 -0
  3. package/dist/analysis/accessibility-empathy.d.ts.map +1 -1
  4. package/dist/analysis/accessibility-empathy.js +28 -77
  5. package/dist/analysis/accessibility-empathy.js.map +1 -1
  6. package/dist/analysis/competitive-benchmark.js +2 -2
  7. package/dist/analysis/competitive-benchmark.js.map +1 -1
  8. package/dist/analysis/persona-comparison.d.ts.map +1 -1
  9. package/dist/analysis/persona-comparison.js +5 -1
  10. package/dist/analysis/persona-comparison.js.map +1 -1
  11. package/dist/browser.js +2 -2
  12. package/dist/browser.js.map +1 -1
  13. package/dist/cli.js +44 -7
  14. package/dist/cli.js.map +1 -1
  15. package/dist/daemon.d.ts +2 -0
  16. package/dist/daemon.d.ts.map +1 -1
  17. package/dist/daemon.js +8 -1
  18. package/dist/daemon.js.map +1 -1
  19. package/dist/mcp-server-remote.d.ts +3 -0
  20. package/dist/mcp-server-remote.d.ts.map +1 -1
  21. package/dist/mcp-server-remote.js +12 -1
  22. package/dist/mcp-server-remote.js.map +1 -1
  23. package/dist/mcp-tools/account-personas.d.ts +3 -2
  24. package/dist/mcp-tools/account-personas.d.ts.map +1 -1
  25. package/dist/mcp-tools/account-personas.js +13 -4
  26. package/dist/mcp-tools/account-personas.js.map +1 -1
  27. package/dist/mcp-tools/base/advanced-interaction-tools.js +1 -1
  28. package/dist/mcp-tools/base/advanced-interaction-tools.js.map +1 -1
  29. package/dist/mcp-tools/base/audit-tools.d.ts.map +1 -1
  30. package/dist/mcp-tools/base/audit-tools.js +49 -15
  31. package/dist/mcp-tools/base/audit-tools.js.map +1 -1
  32. package/dist/mcp-tools/base/browser-state-tools.d.ts.map +1 -1
  33. package/dist/mcp-tools/base/browser-state-tools.js +25 -4
  34. package/dist/mcp-tools/base/browser-state-tools.js.map +1 -1
  35. package/dist/mcp-tools/base/cognitive-tools.d.ts.map +1 -1
  36. package/dist/mcp-tools/base/cognitive-tools.js +14 -2
  37. package/dist/mcp-tools/base/cognitive-tools.js.map +1 -1
  38. package/dist/mcp-tools/base/gif-tools.d.ts +19 -0
  39. package/dist/mcp-tools/base/gif-tools.d.ts.map +1 -1
  40. package/dist/mcp-tools/base/gif-tools.js +53 -24
  41. package/dist/mcp-tools/base/gif-tools.js.map +1 -1
  42. package/dist/mcp-tools/base/persona-comparison-tools.d.ts.map +1 -1
  43. package/dist/mcp-tools/base/persona-comparison-tools.js +44 -28
  44. package/dist/mcp-tools/base/persona-comparison-tools.js.map +1 -1
  45. package/dist/mcp-tools/base/values-tools.d.ts.map +1 -1
  46. package/dist/mcp-tools/base/values-tools.js +12 -0
  47. package/dist/mcp-tools/base/values-tools.js.map +1 -1
  48. package/dist/mcp-tools/base/visual-testing-tools.d.ts +17 -0
  49. package/dist/mcp-tools/base/visual-testing-tools.d.ts.map +1 -1
  50. package/dist/mcp-tools/base/visual-testing-tools.js +184 -148
  51. package/dist/mcp-tools/base/visual-testing-tools.js.map +1 -1
  52. package/dist/mcp-tools/inline-image.d.ts +82 -0
  53. package/dist/mcp-tools/inline-image.d.ts.map +1 -0
  54. package/dist/mcp-tools/inline-image.js +163 -0
  55. package/dist/mcp-tools/inline-image.js.map +1 -0
  56. package/dist/personas.d.ts +37 -0
  57. package/dist/personas.d.ts.map +1 -1
  58. package/dist/personas.js +137 -3
  59. package/dist/personas.js.map +1 -1
  60. package/dist/security/audit-wrapper.d.ts.map +1 -1
  61. package/dist/security/audit-wrapper.js +1 -0
  62. package/dist/security/audit-wrapper.js.map +1 -1
  63. package/dist/security/script-gate.d.ts +33 -0
  64. package/dist/security/script-gate.d.ts.map +1 -0
  65. package/dist/security/script-gate.js +37 -0
  66. package/dist/security/script-gate.js.map +1 -0
  67. package/dist/security/tool-permissions.d.ts.map +1 -1
  68. package/dist/security/tool-permissions.js +4 -0
  69. package/dist/security/tool-permissions.js.map +1 -1
  70. package/package.json +1 -1
@@ -8,17 +8,56 @@ import { z } from "zod";
8
8
  import { getDefaultConfig } from "../../config.js";
9
9
  import { resolveValuesForPersona } from "../../values/persona-values.js";
10
10
  import { writeArtifact } from "../../artifact-store.js";
11
+ import { buildContentWithPreviews } from "../inline-image.js";
11
12
  /**
12
- * Largest heatmap returned inline, in base64 characters.
13
+ * Resolve a persona before anything is measured, or return the tool's refusal.
13
14
  *
14
- * Hosts truncate tool results near 150k characters and substitute a file
15
- * pointer in place of the payload, which downstream arrives as unparseable
16
- * text. Measured: a 1280x800 heatmap is ~738k characters, five times the cap,
17
- * so shipping it inline destroyed the JSON it accompanied. Anything above this
18
- * goes through artifact_fetch instead.
15
+ * Loads the calling account's CMS personas first (scoped to that account), so a
16
+ * custom persona resolves exactly as list_cognitive_personas shows it. An
17
+ * unknown name is refused here: it used to reach the relevance LLM as a bare
18
+ * string, which role-played it from the name alone. (2026-10-09)
19
19
  */
20
- const INLINE_IMAGE_BUDGET = 100_000;
20
+ async function resolveOrRefuse(requested) {
21
+ try {
22
+ const { loadAccountPersonas } = await import("../account-personas.js");
23
+ const { getSessionApiKey } = await import("./cognitive-tools.js");
24
+ await loadAccountPersonas(getSessionApiKey());
25
+ }
26
+ catch { /* falls back to disk and built-ins */ }
27
+ const { resolvePersonaForTool, UnknownPersonaError } = await import("../../personas.js");
28
+ try {
29
+ const r = resolvePersonaForTool(requested);
30
+ return { ok: true, name: r.name, ...(r.resolvedFrom ? { resolvedFrom: r.resolvedFrom } : {}) };
31
+ }
32
+ catch (e) {
33
+ if (!(e instanceof UnknownPersonaError))
34
+ throw e;
35
+ return { ok: false, result: { isError: true, content: [{ type: "text", text: JSON.stringify({
36
+ error: e.message, code: e.code, persona: requested, suggestions: e.suggestions,
37
+ }, null, 2) }] } };
38
+ }
39
+ }
21
40
  import { runVisualRegression, runCrossBrowserTest, runResponsiveTest, runABComparison, crossBrowserDiff, } from "../../visual/index.js";
41
+ /**
42
+ * attention_analysis's result: the JSON, then the heatmap as an image block.
43
+ *
44
+ * Hosts truncate tool results near 150k characters and substitute a file
45
+ * pointer for the payload, which downstream arrives as unparseable text.
46
+ * Measured: a 1280x800 heatmap PNG is ~738k characters of base64, five times
47
+ * the cap, so shipping it inline destroyed the JSON it accompanied (28e5786).
48
+ * That fix kept the cap by dropping the picture whenever it was over a fixed
49
+ * 100k budget -- which, at real page sizes, was always, so the tool whose
50
+ * finding IS the heatmap returned no heatmap.
51
+ *
52
+ * The picture now always rides along as a JPEG preview fitted to what the JSON
53
+ * leaves of the cap, `heatmapPreview` says exactly what was attached, and the
54
+ * full-resolution PNG stays at heatmapUrl / heatmapFile for artifact_fetch.
55
+ */
56
+ export async function assembleAttentionContent(data, heatmapPng, fullResolution) {
57
+ if (!heatmapPng)
58
+ return [{ type: "text", text: JSON.stringify(data, null, 2) }];
59
+ return buildContentWithPreviews(data, [{ name: "heatmap", png: heatmapPng, fullResolution }], (p) => ({ heatmapPreview: p.heatmap }));
60
+ }
22
61
  /**
23
62
  * Register visual testing tools (6 tools: visual_baseline, visual_regression, cross_browser_test, cross_browser_diff, responsive_test, ab_comparison)
24
63
  */
@@ -405,7 +444,7 @@ export function registerVisualTestingTools(server) {
405
444
  // ── Attention Transport (v18.28.0) ──
406
445
  server.registerTool("attention_analysis", {
407
446
  title: "Attention Saliency Analysis",
408
- description: "Analyze where a persona's attention goes on a page. Two-layer model: (1) visual saliency via W₂ on CIE-Lab (what POPS), (2) DOM semantic analysis (what MATTERS — CTAs, headings, forms, nav). Blended 35/65 so a gray search bar a power-user prioritizes outweighs a flashy banner they ignore. Returns heatmap overlay, attention metrics, and quality score.",
447
+ description: "Analyze where a persona's attention goes on a page. Two-layer model: (1) visual saliency via W₂ on CIE-Lab (what POPS), (2) DOM semantic analysis (what MATTERS — CTAs, headings, forms, nav). Blended 35/65 so a gray search bar a power-user prioritizes outweighs a flashy banner they ignore. Returns attention metrics, a quality score, and the heatmap overlay: inline as a JPEG preview sized to fit the result (described in heatmapPreview), with the full-resolution PNG at heatmapUrl / artifact_fetch({ file: heatmapFile }).",
409
448
  inputSchema: {
410
449
  url: z.string().describe("URL to analyze"),
411
450
  persona: z.string().optional().default("first-timer").describe("Persona name"),
@@ -426,7 +465,11 @@ export function registerVisualTestingTools(server) {
426
465
  // Declares the attention view. The heatmap is the finding here, and a JSON
427
466
  // list of scores is a lossy description of where a persona actually looks.
428
467
  _meta: { ui: { resourceUri: "ui://cbrowser/attention" } },
429
- }, async ({ url, persona, goal, cellSize, heatmap, device, useValues, freezeAnimations: doFreeze }) => {
468
+ }, async ({ url, persona: requestedPersona, goal, cellSize, heatmap, device, useValues, freezeAnimations: doFreeze }) => {
469
+ const resolved = await resolveOrRefuse(requestedPersona);
470
+ if (!resolved.ok)
471
+ return resolved.result;
472
+ const persona = resolved.name;
430
473
  const { CBrowser } = await import("../../browser.js");
431
474
  const browser = new CBrowser({
432
475
  headless: true,
@@ -486,14 +529,7 @@ export function registerVisualTestingTools(server) {
486
529
  }));
487
530
  // Run attention analysis with DOM semantic layer (visual + semantic blend)
488
531
  const { analyzeAttention } = await import("../../visual/attention-transport.js");
489
- // Same reason as capture: a persona created through the account lives in
490
- // the CMS, and attention_analysis resolves personas through the registry.
491
- try {
492
- const { loadAccountPersonas } = await import("../account-personas.js");
493
- const { getSessionApiKey } = await import("./cognitive-tools.js");
494
- await loadAccountPersonas(getSessionApiKey());
495
- }
496
- catch { /* falls back to disk and built-ins */ }
532
+ // Account personas were loaded by resolveOrRefuse, before the browser.
497
533
  const domAttentionElements = await collectDomAttentionElements(page).catch(() => []);
498
534
  // Persona-judged relevance, replacing keyword overlap in the semantic
499
535
  // layer. Computed HERE rather than inside analyzeAttention so the one
@@ -585,93 +621,93 @@ export function registerVisualTestingTools(server) {
585
621
  narrativeReconciliation = reconcileAttentionNarrative(relevanceReasoning, attentionQuality);
586
622
  }
587
623
  catch { /* annotation only; never block the result on it */ }
588
- const content = [{
589
- type: "text",
590
- text: JSON.stringify({
591
- persona: result.persona,
592
- // Stated, because two tools silently rendering at different sizes is
593
- // exactly what made these coordinates incomparable with a CTC run.
594
- // A reader can now check instead of assuming they match.
595
- renderedViewport: device
596
- ? `device: ${device.toLowerCase()}`
597
- : `${getDefaultConfig().viewportWidth}x${getDefaultConfig().viewportHeight}`,
598
- coordinateSpace: "CSS pixels in the rendered viewport above — compare only against runs reporting the same renderedViewport",
599
- // What actually ran, not what was asked for. A freeze that silently
600
- // failed would produce exactly the variance it was asked to remove,
601
- // under a label saying it had been removed.
602
- animationState,
603
- ...(animationsStillRunning >= 0 ? { animationsRunning: animationsStillRunning } : {}),
604
- ...(animationState === "frozen" && animationsStillRunning > 0 ? {
605
- animationWarning: `freezeAnimations was requested but ${animationsStillRunning} animation(s) are still running; this run is NOT reproducible.`,
606
- } : {}),
607
- ...(animationState === "live" && animationsStillRunning > 0 ? {
608
- animationNote: `${animationsStillRunning} animation(s) were running during capture, so saliency will vary slightly between runs. Pass freezeAnimations:true to compare runs.`,
609
- } : {}),
610
- alignmentScore: result.alignmentScore,
611
- entropy: result.entropy,
612
- concentration: result.concentration,
613
- transportCost: result.transportCost,
614
- topAttentionAreas: result.attentionCompetitors,
615
- ...(attentionQuality ? { attentionQuality } : {}),
616
- // Why this persona attends where it does, and by which method.
617
- // The scores were already being used to build the map while the
618
- // reasoning behind them was computed and thrown away — a judgement
619
- // with no stated reason is one the caller cannot argue with, which
620
- // was the point of leaving keyword matching.
621
- ...(relevanceReasoning ? { attentionReasoning: relevanceReasoning } : {}),
622
- // Only present when a checkable claim in the narrative actually
623
- // contradicts a computed metric. Silent otherwise — a spurious
624
- // "these disagree" on a narrative that was fine is its own defect.
625
- ...(narrativeReconciliation?.contradictsMetrics ? {
626
- narrativeReconciliation: {
627
- contradictsMetrics: true,
628
- contradictingClaims: narrativeReconciliation.contradictingClaims,
629
- note: narrativeReconciliation.reconciliationNote,
630
- },
631
- } : {}),
632
- ...(relevanceSource ? { relevanceMethod: relevanceSource } : {}),
633
- // These two lines used to read as a contradiction — "Scattered
634
- // attention (overwhelmed)" printed directly above "Attention
635
- // concentrated on few areas". The numbers were never wrong; the
636
- // prose was. They answer different questions:
637
- // entropy = normalized Shannon entropy, 1 = evenly spread
638
- // across every cell that gets any attention
639
- // concentration = share of total saliency in the top 20% of cells,
640
- // whose floor is 0.2 for a perfectly uniform page,
641
- // NOT 0
642
- // Saliency spread evenly within a small region scores high on both
643
- // (measured: entropy 0.692 with concentration 1.0), and both
644
- // readings are correct. Each string now names its own basis, and
645
- // `pattern` gives the single combined verdict a caller actually
646
- // wants. (2026-07-28)
647
- interpretation: {
648
- // Each verdict names its BASIS, because two of them in one payload
649
- // read as a contradiction otherwise.
650
- //
651
- // Measured: attentionQuality.interpretation said "design intent is
652
- // working" while this line said "attention diverges from intended
653
- // design", in the same response, off an alignmentScore of 0.484.
654
- // Both were right about different questions -- one asks whether
655
- // the CTAs captured attention, the other whether the overall
656
- // distribution matches where the design puts emphasis -- and a
657
- // reader has no way to know that from two bare verdicts.
658
- // (2026-08-02)
659
- alignment: `${result.alignmentScore > 0.8 ? "Attention follows intended design" : result.alignmentScore > 0.5 ? "Moderate attention alignment" : "Attention diverges from intended design"} (alignmentScore ${result.alignmentScore.toFixed(3)}: how closely the whole attention distribution matches where the design places emphasis — a different question from whether the CTAs specifically captured attention, which attentionQuality answers)`,
660
- entropy: `Evenness of attention across the areas that draw it: ${result.entropy > 0.8 ? "very even" : result.entropy > 0.5 ? "moderately even" : "sharply peaked"} (${result.entropy.toFixed(2)} of 1.0)`,
661
- concentration: `Share of attention landing in the top 20% of the page: ${(result.concentration * 100).toFixed(0)}% (a perfectly uniform page scores 20%)`,
662
- pattern: result.concentration > 0.6
663
- ? (result.entropy > 0.5
664
- ? "Attention pools into a small part of the page, and spreads evenly once there — a dense hotspot rather than a single focal point."
665
- : "Attention locks onto one or two focal points and ignores the rest of the page.")
666
- : (result.entropy > 0.5
667
- ? "Attention is spread broadly across the page with no dominant focal point."
668
- : "Attention is split between a few separate areas, with the rest of the page largely unseen."),
669
- },
670
- computeTimeMs: Math.round(result.computeTimeMs),
671
- hasHeatmap: heatmap !== false,
672
- }, null, 2),
673
- }];
624
+ const data = {
625
+ persona: result.persona,
626
+ ...(resolved.resolvedFrom ? { resolvedFrom: resolved.resolvedFrom } : {}),
627
+ // Stated, because two tools silently rendering at different sizes is
628
+ // exactly what made these coordinates incomparable with a CTC run.
629
+ // A reader can now check instead of assuming they match.
630
+ renderedViewport: device
631
+ ? `device: ${device.toLowerCase()}`
632
+ : `${getDefaultConfig().viewportWidth}x${getDefaultConfig().viewportHeight}`,
633
+ coordinateSpace: "CSS pixels in the rendered viewport above — compare only against runs reporting the same renderedViewport",
634
+ // What actually ran, not what was asked for. A freeze that silently
635
+ // failed would produce exactly the variance it was asked to remove,
636
+ // under a label saying it had been removed.
637
+ animationState,
638
+ ...(animationsStillRunning >= 0 ? { animationsRunning: animationsStillRunning } : {}),
639
+ ...(animationState === "frozen" && animationsStillRunning > 0 ? {
640
+ animationWarning: `freezeAnimations was requested but ${animationsStillRunning} animation(s) are still running; this run is NOT reproducible.`,
641
+ } : {}),
642
+ ...(animationState === "live" && animationsStillRunning > 0 ? {
643
+ animationNote: `${animationsStillRunning} animation(s) were running during capture, so saliency will vary slightly between runs. Pass freezeAnimations:true to compare runs.`,
644
+ } : {}),
645
+ alignmentScore: result.alignmentScore,
646
+ entropy: result.entropy,
647
+ concentration: result.concentration,
648
+ transportCost: result.transportCost,
649
+ topAttentionAreas: result.attentionCompetitors,
650
+ ...(attentionQuality ? { attentionQuality } : {}),
651
+ // Why this persona attends where it does, and by which method.
652
+ // The scores were already being used to build the map while the
653
+ // reasoning behind them was computed and thrown away — a judgement
654
+ // with no stated reason is one the caller cannot argue with, which
655
+ // was the point of leaving keyword matching.
656
+ ...(relevanceReasoning ? { attentionReasoning: relevanceReasoning } : {}),
657
+ // Only present when a checkable claim in the narrative actually
658
+ // contradicts a computed metric. Silent otherwise — a spurious
659
+ // "these disagree" on a narrative that was fine is its own defect.
660
+ ...(narrativeReconciliation?.contradictsMetrics ? {
661
+ narrativeReconciliation: {
662
+ contradictsMetrics: true,
663
+ contradictingClaims: narrativeReconciliation.contradictingClaims,
664
+ note: narrativeReconciliation.reconciliationNote,
665
+ },
666
+ } : {}),
667
+ ...(relevanceSource ? { relevanceMethod: relevanceSource } : {}),
668
+ // These two lines used to read as a contradiction — "Scattered
669
+ // attention (overwhelmed)" printed directly above "Attention
670
+ // concentrated on few areas". The numbers were never wrong; the
671
+ // prose was. They answer different questions:
672
+ // entropy = normalized Shannon entropy, 1 = evenly spread
673
+ // across every cell that gets any attention
674
+ // concentration = share of total saliency in the top 20% of cells,
675
+ // whose floor is 0.2 for a perfectly uniform page,
676
+ // NOT 0
677
+ // Saliency spread evenly within a small region scores high on both
678
+ // (measured: entropy 0.692 with concentration 1.0), and both
679
+ // readings are correct. Each string now names its own basis, and
680
+ // `pattern` gives the single combined verdict a caller actually
681
+ // wants. (2026-07-28)
682
+ interpretation: {
683
+ // Each verdict names its BASIS, because two of them in one payload
684
+ // read as a contradiction otherwise.
685
+ //
686
+ // Measured: attentionQuality.interpretation said "design intent is
687
+ // working" while this line said "attention diverges from intended
688
+ // design", in the same response, off an alignmentScore of 0.484.
689
+ // Both were right about different questions -- one asks whether
690
+ // the CTAs captured attention, the other whether the overall
691
+ // distribution matches where the design puts emphasis -- and a
692
+ // reader has no way to know that from two bare verdicts.
693
+ // (2026-08-02)
694
+ alignment: `${result.alignmentScore > 0.8 ? "Attention follows intended design" : result.alignmentScore > 0.5 ? "Moderate attention alignment" : "Attention diverges from intended design"} (alignmentScore ${result.alignmentScore.toFixed(3)}: how closely the whole attention distribution matches where the design places emphasis — a different question from whether the CTAs specifically captured attention, which attentionQuality answers)`,
695
+ entropy: `Evenness of attention across the areas that draw it: ${result.entropy > 0.8 ? "very even" : result.entropy > 0.5 ? "moderately even" : "sharply peaked"} (${result.entropy.toFixed(2)} of 1.0)`,
696
+ concentration: `Share of attention landing in the top 20% of the page: ${(result.concentration * 100).toFixed(0)}% (a perfectly uniform page scores 20%)`,
697
+ pattern: result.concentration > 0.6
698
+ ? (result.entropy > 0.5
699
+ ? "Attention pools into a small part of the page, and spreads evenly once there — a dense hotspot rather than a single focal point."
700
+ : "Attention locks onto one or two focal points and ignores the rest of the page.")
701
+ : (result.entropy > 0.5
702
+ ? "Attention is spread broadly across the page with no dominant focal point."
703
+ : "Attention is split between a few separate areas, with the rest of the page largely unseen."),
704
+ },
705
+ computeTimeMs: Math.round(result.computeTimeMs),
706
+ hasHeatmap: heatmap !== false,
707
+ };
674
708
  // Generate heatmap overlay and save as public URL
709
+ let heatmapPng;
710
+ let heatmapFullResolution = "";
675
711
  if (heatmap !== false && result.saliencyMap) {
676
712
  try {
677
713
  const { generateHeatmapOverlay } = await import("../../visual/heatmap-overlay.js");
@@ -689,10 +725,12 @@ export function registerVisualTestingTools(server) {
689
725
  const cbrowserDir = join(homedir(), ".cbrowser", "heatmaps");
690
726
  let savedPath = "";
691
727
  let publicUrl = "";
692
- const written = writeArtifact(Buffer.from(heatmapBase64, "base64"), `${heatmapId}.png`);
728
+ const png = Buffer.from(heatmapBase64, "base64");
729
+ const written = writeArtifact(png, `${heatmapId}.png`);
693
730
  if (written) {
694
731
  savedPath = written.path;
695
732
  publicUrl = written.url;
733
+ heatmapFullResolution = "the PNG at heatmapUrl, or artifact_fetch({ file: heatmapFile })";
696
734
  }
697
735
  else {
698
736
  // Served store unavailable — keep the local copy, and return the
@@ -700,44 +738,17 @@ export function registerVisualTestingTools(server) {
700
738
  if (!existsSync(cbrowserDir))
701
739
  mkdirSync(cbrowserDir, { recursive: true });
702
740
  savedPath = join(cbrowserDir, `${heatmapId}.png`);
703
- writeFileSync(savedPath, Buffer.from(heatmapBase64, "base64"));
741
+ writeFileSync(savedPath, png);
704
742
  publicUrl = savedPath;
743
+ heatmapFullResolution = "the PNG at the local path in heatmapUrl (the artifact store was unavailable, so artifact_fetch cannot serve it)";
705
744
  }
706
- // Return both image content and URL
707
- // The heatmap is 700KB+ of base64. Shipping it inline pushed the
708
- // whole result past the host's ~150k cap, at which point the host
709
- // substitutes a file pointer -- so the JSON above arrived as
710
- // unparseable text and the view rendered nothing, while the image
711
- // it was meant to show was the reason it broke.
712
- //
713
- // Small heatmaps still ride along, because a host with no view
714
- // support should not lose the picture entirely. Anything larger is
715
- // left to artifact_fetch, which is the channel built for it.
716
- const inlineChars = heatmapBase64.length;
717
- const inlineOk = inlineChars <= INLINE_IMAGE_BUDGET;
718
- if (inlineOk) {
719
- content.push({
720
- type: "image",
721
- data: heatmapBase64,
722
- mimeType: "image/png",
723
- });
724
- }
725
- // Add URL to the text response
726
- const firstBlock = content[0];
727
- if (firstBlock.type === "text") {
728
- const textContent = JSON.parse(firstBlock.text);
729
- textContent.heatmapUrl = publicUrl;
730
- if (!inlineOk) {
731
- textContent.inlineHeatmapOmitted =
732
- `Heatmap is ${(inlineChars / 1024).toFixed(0)}KB of base64, over the ${(INLINE_IMAGE_BUDGET / 1024).toFixed(0)}KB inline budget. Fetch it with artifact_fetch using heatmapFile, or open heatmapUrl.`;
733
- }
734
- // The filename, not just the URL: the widget sandbox cannot fetch
735
- // that URL, so the view asks artifact_fetch for these bytes over
736
- // the MCP connection instead.
737
- textContent.heatmapFile = `${heatmapId}.png`;
738
- textContent.heatmapNote = "Show this heatmap image to the user. The red areas show where this persona's attention concentrates. Blue areas receive little attention.";
739
- content[0] = { type: "text", text: JSON.stringify(textContent, null, 2) };
740
- }
745
+ data.heatmapUrl = publicUrl;
746
+ // The filename, not just the URL: the widget sandbox cannot fetch
747
+ // that URL, so the view asks artifact_fetch for these bytes over
748
+ // the MCP connection instead.
749
+ data.heatmapFile = `${heatmapId}.png`;
750
+ data.heatmapNote = "Show this heatmap image to the user. The red areas show where this persona's attention concentrates. Blue areas receive little attention.";
751
+ heatmapPng = png;
741
752
  // Auto-save to Visual Reports gallery
742
753
  try {
743
754
  const { saveVisualReport } = await import("../visual-report-saver.js");
@@ -761,7 +772,7 @@ export function registerVisualTestingTools(server) {
761
772
  unlinkSync(screenshotPath);
762
773
  }
763
774
  catch { }
764
- return { content };
775
+ return { content: await assembleAttentionContent(data, heatmapPng, heatmapFullResolution) };
765
776
  }
766
777
  finally {
767
778
  await browser.close();
@@ -769,7 +780,7 @@ export function registerVisualTestingTools(server) {
769
780
  });
770
781
  server.registerTool("attention_compare", {
771
782
  title: "Compare Persona Attention",
772
- description: "Compare attention patterns between two personas on the same page. Shows where they look differently and the Wasserstein divergence between their saliency maps.",
783
+ description: "Compare attention patterns between two personas on the same page. Shows where they look differently and the Wasserstein divergence between their saliency maps. The comparison map comes back inline as a JPEG preview sized to fit the result (described in comparisonHeatmapPreview), with the full-resolution PNG at comparisonHeatmapUrl / artifact_fetch({ file: comparisonHeatmapFile }).",
773
784
  inputSchema: {
774
785
  url: z.string().describe("URL to analyze"),
775
786
  personaA: z.string().describe("First persona"),
@@ -782,7 +793,17 @@ export function registerVisualTestingTools(server) {
782
793
  idempotentHint: true,
783
794
  openWorldHint: true,
784
795
  },
785
- }, async ({ url, personaA, personaB }) => {
796
+ }, async ({ url, personaA: requestedA, personaB: requestedB }) => {
797
+ // Both refused before measuring: an unknown name compared as "maximally
798
+ // different" from power-user, more than the extreme real pair. (2026-10-09)
799
+ const resolvedA = await resolveOrRefuse(requestedA);
800
+ if (!resolvedA.ok)
801
+ return resolvedA.result;
802
+ const resolvedB = await resolveOrRefuse(requestedB);
803
+ if (!resolvedB.ok)
804
+ return resolvedB.result;
805
+ const personaA = resolvedA.name;
806
+ const personaB = resolvedB.name;
786
807
  const { CBrowser } = await import("../../browser.js");
787
808
  // Same configured viewport as attention_analysis. This tool COMPARES two
788
809
  // attention runs, so rendering at a size no other tool uses meant its
@@ -815,16 +836,17 @@ export function registerVisualTestingTools(server) {
815
836
  }));
816
837
  const { compareAttention } = await import("../../visual/attention-transport.js");
817
838
  const result = await compareAttention(screenshotPath, personaA, personaB, 4, domEls);
818
- const content = [];
819
839
  const responseData = {
820
840
  personaA: {
821
841
  name: personaA,
842
+ ...(resolvedA.resolvedFrom ? { resolvedFrom: resolvedA.resolvedFrom } : {}),
822
843
  alignment: result.personaA.alignmentScore,
823
844
  entropy: result.personaA.entropy,
824
845
  concentration: result.personaA.concentration,
825
846
  },
826
847
  personaB: {
827
848
  name: personaB,
849
+ ...(resolvedB.resolvedFrom ? { resolvedFrom: resolvedB.resolvedFrom } : {}),
828
850
  alignment: result.personaB.alignmentScore,
829
851
  entropy: result.personaB.entropy,
830
852
  concentration: result.personaB.concentration,
@@ -838,30 +860,44 @@ export function registerVisualTestingTools(server) {
838
860
  divergentRegions: result.divergentRegions.slice(0, 5),
839
861
  };
840
862
  // Generate comparison heatmap overlay
863
+ let comparisonPng;
864
+ let comparisonFullResolution = "";
841
865
  if (result.personaA.saliencyMap && result.personaB.saliencyMap) {
842
866
  try {
843
867
  const { generateComparisonHeatmap } = await import("../../visual/visual-overlays.js");
844
868
  const compBase64 = await generateComparisonHeatmap(screenshotPath, result.personaA.saliencyMap.cells, result.personaB.saliencyMap.cells, result.personaA.saliencyMap.rows, result.personaA.saliencyMap.cols, personaA, personaB);
845
869
  // Save to public URL
846
870
  const heatmapId = `cmp-${personaA}-${personaB}-${Date.now()}`;
847
- const written = writeArtifact(Buffer.from(compBase64, "base64"), `${heatmapId}.png`);
871
+ comparisonPng = Buffer.from(compBase64, "base64");
872
+ const written = writeArtifact(comparisonPng, `${heatmapId}.png`);
848
873
  // Only advertise a URL when the artifact actually landed in the
849
874
  // served directory. A URL for a file that was not written is the
850
875
  // defect the artifact store exists to end.
851
- if (written)
876
+ if (written) {
852
877
  responseData.comparisonHeatmapUrl = written.url;
878
+ responseData.comparisonHeatmapFile = `${heatmapId}.png`;
879
+ comparisonFullResolution = "the PNG at comparisonHeatmapUrl, or artifact_fetch({ file: comparisonHeatmapFile })";
880
+ }
881
+ else {
882
+ comparisonFullResolution = "not available (the artifact store was unavailable, so the full-resolution PNG was not saved)";
883
+ }
853
884
  responseData.heatmapNote = `Blue = ${personaA} looks here more. Red = ${personaB} looks here more. Transparent = similar attention.`;
854
- content.push({ type: "image", data: compBase64, mimeType: "image/png" });
855
885
  }
856
886
  catch (e) {
857
887
  console.debug(`[attention_compare] Comparison heatmap failed: ${e.message}`);
858
888
  }
859
889
  }
860
- content.unshift({ type: "text", text: JSON.stringify(responseData, null, 2) });
861
890
  try {
862
891
  unlinkSync(screenshotPath);
863
892
  }
864
893
  catch { }
894
+ // The comparison map was pushed inline at full PNG size, ~500 KB of
895
+ // base64 against a ~150k result cap, so on a real page this tool
896
+ // blew the cap that attention_analysis was fixed for. Same remedy:
897
+ // a JPEG preview fitted to what the JSON leaves.
898
+ const content = comparisonPng
899
+ ? await buildContentWithPreviews(responseData, [{ name: "comparison", png: comparisonPng, fullResolution: comparisonFullResolution }], (p) => ({ comparisonHeatmapPreview: p.comparison }))
900
+ : [{ type: "text", text: JSON.stringify(responseData, null, 2) }];
865
901
  return { content };
866
902
  }
867
903
  finally {