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.
- package/README.md +2 -2
- package/dist/analysis/accessibility-empathy.d.ts +6 -0
- package/dist/analysis/accessibility-empathy.d.ts.map +1 -1
- package/dist/analysis/accessibility-empathy.js +28 -77
- package/dist/analysis/accessibility-empathy.js.map +1 -1
- package/dist/analysis/competitive-benchmark.js +2 -2
- package/dist/analysis/competitive-benchmark.js.map +1 -1
- package/dist/analysis/persona-comparison.d.ts.map +1 -1
- package/dist/analysis/persona-comparison.js +5 -1
- package/dist/analysis/persona-comparison.js.map +1 -1
- package/dist/browser.js +2 -2
- package/dist/browser.js.map +1 -1
- package/dist/cli.js +44 -7
- package/dist/cli.js.map +1 -1
- package/dist/daemon.d.ts +2 -0
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +8 -1
- package/dist/daemon.js.map +1 -1
- package/dist/mcp-server-remote.d.ts +3 -0
- package/dist/mcp-server-remote.d.ts.map +1 -1
- package/dist/mcp-server-remote.js +12 -1
- package/dist/mcp-server-remote.js.map +1 -1
- package/dist/mcp-tools/account-personas.d.ts +3 -2
- package/dist/mcp-tools/account-personas.d.ts.map +1 -1
- package/dist/mcp-tools/account-personas.js +13 -4
- package/dist/mcp-tools/account-personas.js.map +1 -1
- package/dist/mcp-tools/base/advanced-interaction-tools.js +1 -1
- package/dist/mcp-tools/base/advanced-interaction-tools.js.map +1 -1
- package/dist/mcp-tools/base/audit-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/audit-tools.js +49 -15
- package/dist/mcp-tools/base/audit-tools.js.map +1 -1
- package/dist/mcp-tools/base/browser-state-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/browser-state-tools.js +25 -4
- package/dist/mcp-tools/base/browser-state-tools.js.map +1 -1
- package/dist/mcp-tools/base/cognitive-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/cognitive-tools.js +14 -2
- package/dist/mcp-tools/base/cognitive-tools.js.map +1 -1
- package/dist/mcp-tools/base/gif-tools.d.ts +19 -0
- package/dist/mcp-tools/base/gif-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/gif-tools.js +53 -24
- package/dist/mcp-tools/base/gif-tools.js.map +1 -1
- package/dist/mcp-tools/base/persona-comparison-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/persona-comparison-tools.js +44 -28
- package/dist/mcp-tools/base/persona-comparison-tools.js.map +1 -1
- package/dist/mcp-tools/base/values-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/values-tools.js +12 -0
- package/dist/mcp-tools/base/values-tools.js.map +1 -1
- package/dist/mcp-tools/base/visual-testing-tools.d.ts +17 -0
- package/dist/mcp-tools/base/visual-testing-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/visual-testing-tools.js +184 -148
- package/dist/mcp-tools/base/visual-testing-tools.js.map +1 -1
- package/dist/mcp-tools/inline-image.d.ts +82 -0
- package/dist/mcp-tools/inline-image.d.ts.map +1 -0
- package/dist/mcp-tools/inline-image.js +163 -0
- package/dist/mcp-tools/inline-image.js.map +1 -0
- package/dist/personas.d.ts +37 -0
- package/dist/personas.d.ts.map +1 -1
- package/dist/personas.js +137 -3
- package/dist/personas.js.map +1 -1
- package/dist/security/audit-wrapper.d.ts.map +1 -1
- package/dist/security/audit-wrapper.js +1 -0
- package/dist/security/audit-wrapper.js.map +1 -1
- package/dist/security/script-gate.d.ts +33 -0
- package/dist/security/script-gate.d.ts.map +1 -0
- package/dist/security/script-gate.js +37 -0
- package/dist/security/script-gate.js.map +1 -0
- package/dist/security/tool-permissions.d.ts.map +1 -1
- package/dist/security/tool-permissions.js +4 -0
- package/dist/security/tool-permissions.js.map +1 -1
- 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
|
-
*
|
|
13
|
+
* Resolve a persona before anything is measured, or return the tool's refusal.
|
|
13
14
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
707
|
-
// The
|
|
708
|
-
//
|
|
709
|
-
//
|
|
710
|
-
|
|
711
|
-
|
|
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
|
-
|
|
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 {
|