@loupekit/mcp 0.10.18 → 0.10.19-next.65
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/dist/index.js +622 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -419,6 +419,581 @@ data: ${JSON.stringify(event)}
|
|
|
419
419
|
res.on("close", done);
|
|
420
420
|
}
|
|
421
421
|
|
|
422
|
+
// src/mapper.ts
|
|
423
|
+
import { readdir, readFile, stat } from "fs/promises";
|
|
424
|
+
import { basename, join, relative, sep } from "path";
|
|
425
|
+
var SIGNAL_WEIGHTS = {
|
|
426
|
+
id: 0.85,
|
|
427
|
+
"aria-label": 0.8,
|
|
428
|
+
text: 0.7,
|
|
429
|
+
class: 0.5,
|
|
430
|
+
"plain-text": 0.4,
|
|
431
|
+
filename: 0.6
|
|
432
|
+
};
|
|
433
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
434
|
+
"node_modules",
|
|
435
|
+
".git",
|
|
436
|
+
"dist",
|
|
437
|
+
"build",
|
|
438
|
+
"out",
|
|
439
|
+
".next",
|
|
440
|
+
".nuxt",
|
|
441
|
+
".svelte-kit",
|
|
442
|
+
"coverage",
|
|
443
|
+
"vendor",
|
|
444
|
+
"__pycache__",
|
|
445
|
+
".venv",
|
|
446
|
+
"venv",
|
|
447
|
+
"target",
|
|
448
|
+
".cache",
|
|
449
|
+
".turbo",
|
|
450
|
+
".parcel-cache",
|
|
451
|
+
".idea",
|
|
452
|
+
".vscode",
|
|
453
|
+
".output",
|
|
454
|
+
".angular",
|
|
455
|
+
"bower_components"
|
|
456
|
+
]);
|
|
457
|
+
var SEARCH_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
458
|
+
".ts",
|
|
459
|
+
".tsx",
|
|
460
|
+
".js",
|
|
461
|
+
".jsx",
|
|
462
|
+
".mjs",
|
|
463
|
+
".cjs",
|
|
464
|
+
".vue",
|
|
465
|
+
".svelte",
|
|
466
|
+
".astro",
|
|
467
|
+
".html",
|
|
468
|
+
".htm",
|
|
469
|
+
".php",
|
|
470
|
+
".blade.php",
|
|
471
|
+
".erb",
|
|
472
|
+
".liquid",
|
|
473
|
+
".twig",
|
|
474
|
+
".css",
|
|
475
|
+
".scss"
|
|
476
|
+
]);
|
|
477
|
+
function isComponentishName(filePath) {
|
|
478
|
+
const name = basename(filePath).replace(/\.[^.]+$/, "").replace(/\.blade$/, "");
|
|
479
|
+
if (name.startsWith("use") && name.length > 3) return true;
|
|
480
|
+
return /^[A-Z][A-Za-z0-9]*$/.test(name);
|
|
481
|
+
}
|
|
482
|
+
function isViewFile(filePath) {
|
|
483
|
+
return /\.(tsx|jsx|vue|svelte|astro|html?|blade\.php|erb|liquid|twig)$/i.test(filePath);
|
|
484
|
+
}
|
|
485
|
+
var BARE_UTILITIES = /* @__PURE__ */ new Set([
|
|
486
|
+
"flex",
|
|
487
|
+
"grid",
|
|
488
|
+
"block",
|
|
489
|
+
"inline",
|
|
490
|
+
"inline-block",
|
|
491
|
+
"hidden",
|
|
492
|
+
"relative",
|
|
493
|
+
"absolute",
|
|
494
|
+
"fixed",
|
|
495
|
+
"sticky",
|
|
496
|
+
"static",
|
|
497
|
+
"container",
|
|
498
|
+
"truncate",
|
|
499
|
+
"italic",
|
|
500
|
+
"underline",
|
|
501
|
+
"uppercase",
|
|
502
|
+
"lowercase",
|
|
503
|
+
"capitalize",
|
|
504
|
+
"antialiased",
|
|
505
|
+
"invisible",
|
|
506
|
+
"visible",
|
|
507
|
+
"contents",
|
|
508
|
+
"isolate",
|
|
509
|
+
"group",
|
|
510
|
+
"peer",
|
|
511
|
+
"clearfix",
|
|
512
|
+
"shadow",
|
|
513
|
+
"border",
|
|
514
|
+
"rounded",
|
|
515
|
+
"outline",
|
|
516
|
+
"ring",
|
|
517
|
+
"transition",
|
|
518
|
+
"transform",
|
|
519
|
+
"animate",
|
|
520
|
+
"cursor-pointer",
|
|
521
|
+
"overflow-hidden"
|
|
522
|
+
]);
|
|
523
|
+
var SCALE_SUFFIX = /^(?:[0-9]+(?:\.[0-9]+)?|px|full|auto|none|screen|min|max|fit|prose|xs|sm|md|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|8xl|9xl|tight|wide|wider|widest|loose|normal|bold|semibold|medium|light|thin|black|white|solid|dashed|dotted|double|hidden|visible|scroll|clip|ellipsis|wrap|nowrap|center|left|right|top|bottom|start|end|baseline|stretch|around|between|evenly|reverse|row|col|nowrap|\[.*\])$/;
|
|
524
|
+
function isUtilityClass(name) {
|
|
525
|
+
const cls = name.trim();
|
|
526
|
+
if (!cls) return true;
|
|
527
|
+
if (cls.includes(":")) return true;
|
|
528
|
+
if (BARE_UTILITIES.has(cls)) return true;
|
|
529
|
+
const parts = cls.split("-");
|
|
530
|
+
if (parts.length >= 2 && /^[a-z]{1,12}$/.test(parts[0]) && SCALE_SUFFIX.test(parts[parts.length - 1])) {
|
|
531
|
+
return true;
|
|
532
|
+
}
|
|
533
|
+
if (parts.length >= 2 && /^(?:bg|text|border|font|leading|tracking|from|to|via|fill|stroke|ring|shadow)$/.test(parts[0])) {
|
|
534
|
+
return true;
|
|
535
|
+
}
|
|
536
|
+
return false;
|
|
537
|
+
}
|
|
538
|
+
function searchableClasses(classes) {
|
|
539
|
+
return [...new Set((classes ?? []).map((c) => c.trim()).filter((c) => c && !isUtilityClass(c)))];
|
|
540
|
+
}
|
|
541
|
+
var MAX_LINE_LENGTH = 1500;
|
|
542
|
+
function looksGenerated(source) {
|
|
543
|
+
let run = 0;
|
|
544
|
+
for (let i = 0; i < source.length; i++) {
|
|
545
|
+
if (source.charCodeAt(i) === 10) {
|
|
546
|
+
run = 0;
|
|
547
|
+
} else if (++run > MAX_LINE_LENGTH) {
|
|
548
|
+
return true;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return false;
|
|
552
|
+
}
|
|
553
|
+
function isSourcePath(filePath) {
|
|
554
|
+
return filePath.startsWith("src/") || filePath.includes("/src/");
|
|
555
|
+
}
|
|
556
|
+
var DEFAULTS = {
|
|
557
|
+
maxFiles: 4e3,
|
|
558
|
+
maxFileBytes: 512 * 1024,
|
|
559
|
+
maxScanBytes: 64 * 1024 * 1024,
|
|
560
|
+
ttlMs: 15e3,
|
|
561
|
+
maxCandidates: 10,
|
|
562
|
+
linePadding: 2
|
|
563
|
+
};
|
|
564
|
+
async function collectFiles(root, opts = {}) {
|
|
565
|
+
const { maxFiles } = { ...DEFAULTS, ...opts };
|
|
566
|
+
const files = [];
|
|
567
|
+
const queue = [root];
|
|
568
|
+
let truncated = false;
|
|
569
|
+
while (queue.length) {
|
|
570
|
+
const dir = queue.shift();
|
|
571
|
+
let entries;
|
|
572
|
+
try {
|
|
573
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
574
|
+
} catch {
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
for (const entry of entries) {
|
|
578
|
+
if (files.length >= maxFiles) {
|
|
579
|
+
truncated = true;
|
|
580
|
+
break;
|
|
581
|
+
}
|
|
582
|
+
const full = join(dir, entry.name);
|
|
583
|
+
if (entry.isDirectory()) {
|
|
584
|
+
if (SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
|
|
585
|
+
queue.push(full);
|
|
586
|
+
} else if (entry.isFile()) {
|
|
587
|
+
if (entry.name.endsWith(".d.ts")) continue;
|
|
588
|
+
const ext = entry.name.includes(".blade.php") ? ".blade.php" : entry.name.slice(entry.name.lastIndexOf("."));
|
|
589
|
+
if (!SEARCH_EXTENSIONS.has(ext)) continue;
|
|
590
|
+
files.push(full);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return { files, truncated };
|
|
595
|
+
}
|
|
596
|
+
var listCache = /* @__PURE__ */ new Map();
|
|
597
|
+
async function cachedFiles(root, opts) {
|
|
598
|
+
const ttl = opts.ttlMs ?? DEFAULTS.ttlMs;
|
|
599
|
+
const hit = listCache.get(root);
|
|
600
|
+
if (hit && Date.now() - hit.at < ttl) return hit.result;
|
|
601
|
+
const result = await collectFiles(root, opts);
|
|
602
|
+
listCache.set(root, { at: Date.now(), result });
|
|
603
|
+
return result;
|
|
604
|
+
}
|
|
605
|
+
function lineOf(source, needle) {
|
|
606
|
+
const at = source.indexOf(needle);
|
|
607
|
+
if (at < 0) return 0;
|
|
608
|
+
return source.slice(0, at).split("\n").length;
|
|
609
|
+
}
|
|
610
|
+
async function mapElementToSource(signals, workspaceRoot, opts = {}) {
|
|
611
|
+
const o = { ...DEFAULTS, ...opts };
|
|
612
|
+
if (!workspaceRoot || !signals) return [];
|
|
613
|
+
const needles = [];
|
|
614
|
+
const id = signals.id?.trim();
|
|
615
|
+
if (id && id.length >= 3) needles.push({ value: id, type: "id" });
|
|
616
|
+
const aria = signals.ariaLabel?.trim();
|
|
617
|
+
if (aria && aria.length >= 3) needles.push({ value: aria, type: "aria-label" });
|
|
618
|
+
const text = signals.text?.trim();
|
|
619
|
+
if (text && text.length >= 3) needles.push({ value: text, type: "text" });
|
|
620
|
+
for (const cls of searchableClasses(signals.classes)) {
|
|
621
|
+
if (cls.length >= 4) needles.push({ value: cls, type: "class" });
|
|
622
|
+
}
|
|
623
|
+
if (!needles.length) return [];
|
|
624
|
+
let outcome;
|
|
625
|
+
try {
|
|
626
|
+
const rootStat = await stat(workspaceRoot);
|
|
627
|
+
if (!rootStat.isDirectory()) return [];
|
|
628
|
+
outcome = await cachedFiles(workspaceRoot, o);
|
|
629
|
+
} catch {
|
|
630
|
+
return [];
|
|
631
|
+
}
|
|
632
|
+
const matches = [];
|
|
633
|
+
let scanned = 0;
|
|
634
|
+
const CONCURRENCY = 16;
|
|
635
|
+
for (let i = 0; i < outcome.files.length; i += CONCURRENCY) {
|
|
636
|
+
if (scanned > o.maxScanBytes) break;
|
|
637
|
+
const batch = outcome.files.slice(i, i + CONCURRENCY);
|
|
638
|
+
const read = await Promise.all(batch.map(async (file) => {
|
|
639
|
+
try {
|
|
640
|
+
const info = await stat(file);
|
|
641
|
+
if (info.size > o.maxFileBytes) return null;
|
|
642
|
+
const source = await readFile(file, "utf8");
|
|
643
|
+
return { file, source };
|
|
644
|
+
} catch {
|
|
645
|
+
return null;
|
|
646
|
+
}
|
|
647
|
+
}));
|
|
648
|
+
for (const entry of read) {
|
|
649
|
+
if (!entry) continue;
|
|
650
|
+
scanned += entry.source.length;
|
|
651
|
+
if (looksGenerated(entry.source)) continue;
|
|
652
|
+
const rel = relative(workspaceRoot, entry.file).split(sep).join("/");
|
|
653
|
+
for (const needle of needles) {
|
|
654
|
+
if (!entry.source.includes(needle.value)) continue;
|
|
655
|
+
matches.push({ filePath: rel, line: lineOf(entry.source, needle.value), matchType: needle.type, needle: needle.value });
|
|
656
|
+
}
|
|
657
|
+
if (text && text.length >= 4) {
|
|
658
|
+
const stem = basename(rel).replace(/\.[^.]+$/, "");
|
|
659
|
+
if (stem.toLowerCase().includes(text.toLowerCase().replace(/\s+/g, ""))) {
|
|
660
|
+
matches.push({ filePath: rel, line: 1, matchType: "filename", needle: stem });
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
return rankMatches(matches, o);
|
|
666
|
+
}
|
|
667
|
+
function rankMatches(raw, opts = {}) {
|
|
668
|
+
const o = { ...DEFAULTS, ...opts };
|
|
669
|
+
if (!raw.length) return [];
|
|
670
|
+
const perFile = /* @__PURE__ */ new Map();
|
|
671
|
+
for (const m of raw) {
|
|
672
|
+
const list = perFile.get(m.filePath) ?? [];
|
|
673
|
+
list.push(m);
|
|
674
|
+
perFile.set(m.filePath, list);
|
|
675
|
+
}
|
|
676
|
+
const candidates = [];
|
|
677
|
+
const seen = /* @__PURE__ */ new Set();
|
|
678
|
+
for (const [filePath, list] of perFile) {
|
|
679
|
+
const best = list.reduce((a, b) => SIGNAL_WEIGHTS[b.matchType] > SIGNAL_WEIGHTS[a.matchType] ? b : a);
|
|
680
|
+
const key = `${filePath}:${best.line}`;
|
|
681
|
+
if (seen.has(key)) continue;
|
|
682
|
+
seen.add(key);
|
|
683
|
+
let score = SIGNAL_WEIGHTS[best.matchType];
|
|
684
|
+
const boosts = [];
|
|
685
|
+
if (isComponentishName(filePath)) {
|
|
686
|
+
score += 0.12;
|
|
687
|
+
boosts.push("component filename");
|
|
688
|
+
}
|
|
689
|
+
if (isViewFile(filePath)) {
|
|
690
|
+
score += 0.08;
|
|
691
|
+
boosts.push("view file");
|
|
692
|
+
}
|
|
693
|
+
if (isSourcePath(filePath)) {
|
|
694
|
+
score += 0.05;
|
|
695
|
+
boosts.push("src");
|
|
696
|
+
}
|
|
697
|
+
const distinct = new Set(list.map((m) => m.matchType)).size;
|
|
698
|
+
if (distinct > 1) {
|
|
699
|
+
score += Math.min(0.12, 0.06 * (distinct - 1));
|
|
700
|
+
boosts.push(`${distinct} signals agree`);
|
|
701
|
+
}
|
|
702
|
+
if (list.length > 1) {
|
|
703
|
+
score += Math.min(0.06, 0.02 * (list.length - 1));
|
|
704
|
+
boosts.push(`${list.length} matches`);
|
|
705
|
+
}
|
|
706
|
+
const reason = `matched ${best.matchType} \u201C${best.needle}\u201D` + (boosts.length ? ` \xB7 ${boosts.join(", ")}` : "");
|
|
707
|
+
candidates.push({
|
|
708
|
+
filePath,
|
|
709
|
+
lineStart: best.line,
|
|
710
|
+
lineEnd: best.line,
|
|
711
|
+
confidence: Math.max(0, Math.min(1, score)),
|
|
712
|
+
reason,
|
|
713
|
+
matchType: best.matchType
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
candidates.sort((a, b) => b.confidence - a.confidence || a.filePath.localeCompare(b.filePath));
|
|
717
|
+
return candidates.slice(0, o.maxCandidates).map((c) => ({
|
|
718
|
+
...c,
|
|
719
|
+
lineStart: Math.max(1, c.lineStart - o.linePadding),
|
|
720
|
+
lineEnd: c.lineEnd + o.linePadding
|
|
721
|
+
}));
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// src/prompt-template.ts
|
|
725
|
+
function describeElement(ctx) {
|
|
726
|
+
const parts = [];
|
|
727
|
+
parts.push(ctx.tag ? `a <${ctx.tag}>` : "an element");
|
|
728
|
+
if (ctx.testid) parts.push(`with data-testid="${ctx.testid}"`);
|
|
729
|
+
else if (ctx.selector) parts.push(`matching ${ctx.selector}`);
|
|
730
|
+
if (ctx.text) parts.push(`containing \u201C${ctx.text.slice(0, 80)}\u201D`);
|
|
731
|
+
return parts.join(" ");
|
|
732
|
+
}
|
|
733
|
+
function generateEditPrompt(ctx) {
|
|
734
|
+
const lines = [];
|
|
735
|
+
lines.push("# Task");
|
|
736
|
+
if (ctx.request) {
|
|
737
|
+
lines.push("");
|
|
738
|
+
lines.push(`The request, in the reporter's words:`);
|
|
739
|
+
lines.push("");
|
|
740
|
+
lines.push(`> ${ctx.request.replace(/\n/g, "\n> ")}`);
|
|
741
|
+
lines.push("");
|
|
742
|
+
}
|
|
743
|
+
lines.push(
|
|
744
|
+
`Implement this for ${describeElement(ctx)}` + (ctx.pageUrl ? ` on ${ctx.pageUrl}` : "") + "."
|
|
745
|
+
);
|
|
746
|
+
if (ctx.candidates?.length) {
|
|
747
|
+
lines.push("");
|
|
748
|
+
lines.push("## Where it probably lives");
|
|
749
|
+
lines.push("");
|
|
750
|
+
lines.push("Ranked by how well each file matched the captured element. **Check the top one before editing it** \u2014 this is a heuristic from the built page, not a resolver:");
|
|
751
|
+
lines.push("");
|
|
752
|
+
for (const c of ctx.candidates) {
|
|
753
|
+
lines.push(`- \`${c.filePath}:${c.lineStart}\` \u2014 ${(c.confidence * 100).toFixed(0)}% \xB7 ${c.reason}`);
|
|
754
|
+
}
|
|
755
|
+
lines.push("");
|
|
756
|
+
lines.push(dedent(`
|
|
757
|
+
If none of these is the component that renders the element, say so and name the
|
|
758
|
+
file you did find, rather than editing the closest-looking candidate. A confident
|
|
759
|
+
edit to the wrong file is worse than a question.
|
|
760
|
+
`));
|
|
761
|
+
} else {
|
|
762
|
+
lines.push("");
|
|
763
|
+
lines.push("## Where it lives");
|
|
764
|
+
lines.push("");
|
|
765
|
+
lines.push("No source candidates were found for this element. Locate the component yourself before editing, and say which file you chose.");
|
|
766
|
+
}
|
|
767
|
+
lines.push("");
|
|
768
|
+
lines.push("## How to make the change");
|
|
769
|
+
lines.push("");
|
|
770
|
+
lines.push(dedent(`
|
|
771
|
+
- **Smallest diff that satisfies the request.** Do not reformat, rename, reorder or
|
|
772
|
+
"tidy" anything you were not asked to touch. If a one-line change does it, that is
|
|
773
|
+
the change.
|
|
774
|
+
- **Follow the file's own conventions** \u2014 its naming, its styling approach, its
|
|
775
|
+
component patterns. If the file uses utility classes, use them; if it uses a
|
|
776
|
+
stylesheet, use that. Match what is there rather than what you would have written.
|
|
777
|
+
- **Keep the element's existing behaviour and accessibility.** Preserve the test id,
|
|
778
|
+
the aria attributes and the event handlers unless the request is about them.
|
|
779
|
+
- **Do not touch unrelated files.**
|
|
780
|
+
`));
|
|
781
|
+
if (ctx.isThread && ctx.threadId) {
|
|
782
|
+
lines.push("");
|
|
783
|
+
lines.push("## What happens next");
|
|
784
|
+
lines.push("");
|
|
785
|
+
lines.push(dedent(`
|
|
786
|
+
1. Edit the file.
|
|
787
|
+
2. Call \`propose_change(id: "${ctx.threadId}", html, css?, notes?)\` with the element's
|
|
788
|
+
rewritten markup, so a human can review the change against the original request.
|
|
789
|
+
3. Call \`update_status(id: "${ctx.threadId}", status: "in_review")\` when it is ready.
|
|
790
|
+
**Never set it to resolved** \u2014 only a person closes a thread. Moving it to In
|
|
791
|
+
Review is what puts it in front of a reviewer.
|
|
792
|
+
4. If the work lands in a pull request, that is what the panel's PR chip is for; keep
|
|
793
|
+
the diff reviewable.
|
|
794
|
+
`));
|
|
795
|
+
if (ctx.hasProposal) {
|
|
796
|
+
lines.push("");
|
|
797
|
+
lines.push("A proposal already exists for this thread \u2014 call `get_comment` first if you are iterating on it, rather than starting over.");
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
return lines.join("\n");
|
|
801
|
+
}
|
|
802
|
+
function dedent(text) {
|
|
803
|
+
const lines = text.replace(/^\n/, "").replace(/\n\s*$/, "").split("\n");
|
|
804
|
+
const indent = Math.min(...lines.filter((l) => l.trim()).map((l) => l.match(/^ */)[0].length));
|
|
805
|
+
return lines.map((l) => l.slice(indent)).join("\n");
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// src/tools/element-context.ts
|
|
809
|
+
function threadContextToPayload(thread) {
|
|
810
|
+
const anchor = thread.anchor;
|
|
811
|
+
const attrs = thread.context;
|
|
812
|
+
return {
|
|
813
|
+
source: "thread",
|
|
814
|
+
threadId: thread.id,
|
|
815
|
+
page: { url: thread.url, title: thread.title },
|
|
816
|
+
element: {
|
|
817
|
+
tag: anchor?.tag ?? "unknown",
|
|
818
|
+
selector: anchor?.cssPath,
|
|
819
|
+
testid: anchor?.testid ?? null,
|
|
820
|
+
id: null,
|
|
821
|
+
text: anchor?.text,
|
|
822
|
+
classes: void 0,
|
|
823
|
+
attrs: anchor?.attrs,
|
|
824
|
+
box: anchor?.rect
|
|
825
|
+
},
|
|
826
|
+
styles: attrs?.styles ?? {},
|
|
827
|
+
screenshot: thread.screenshot
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
function selectionToPayload(selection) {
|
|
831
|
+
return {
|
|
832
|
+
source: "selection",
|
|
833
|
+
correlationId: selection.correlationId,
|
|
834
|
+
page: { url: selection.url },
|
|
835
|
+
element: {
|
|
836
|
+
tag: selection.tag,
|
|
837
|
+
selector: selection.selector,
|
|
838
|
+
testid: selection.testid ?? null,
|
|
839
|
+
id: selection.id ?? null,
|
|
840
|
+
text: selection.text,
|
|
841
|
+
classes: selection.classes,
|
|
842
|
+
attrs: selection.attrs,
|
|
843
|
+
box: selection.box
|
|
844
|
+
},
|
|
845
|
+
styles: selection.styles ?? {},
|
|
846
|
+
screenshot: selection.screenshot
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
function formatElementContext(payload) {
|
|
850
|
+
const e = payload.element;
|
|
851
|
+
const lines = [];
|
|
852
|
+
lines.push(`# Selected element`);
|
|
853
|
+
lines.push("");
|
|
854
|
+
lines.push(`- **Where**: ${payload.page.url}${payload.page.title ? ` \u2014 \u201C${payload.page.title}\u201D` : ""}`);
|
|
855
|
+
lines.push(`- **Tag**: \`<${e.tag}>\``);
|
|
856
|
+
if (e.selector) lines.push(`- **Selector**: \`${e.selector}\``);
|
|
857
|
+
if (e.testid) lines.push(`- **Test id**: \`${e.testid}\``);
|
|
858
|
+
if (e.id) lines.push(`- **Id**: \`${e.id}\``);
|
|
859
|
+
if (e.text) lines.push(`- **Text**: \u201C${e.text}\u201D`);
|
|
860
|
+
if (e.classes?.length) lines.push(`- **Classes**: \`${e.classes.join(" ")}\``);
|
|
861
|
+
if (e.box) lines.push(`- **Box**: ${Math.round(e.box.w)}\xD7${Math.round(e.box.h)} at ${Math.round(e.box.x)},${Math.round(e.box.y)}`);
|
|
862
|
+
if (payload.source === "thread" && payload.threadId) lines.push(`- **Thread**: #${payload.threadId}`);
|
|
863
|
+
const styleKeys = Object.keys(payload.styles);
|
|
864
|
+
if (styleKeys.length) {
|
|
865
|
+
lines.push("");
|
|
866
|
+
lines.push("## Computed styles");
|
|
867
|
+
lines.push("");
|
|
868
|
+
for (const key of styleKeys) lines.push(`- \`${key}: ${payload.styles[key]}\``);
|
|
869
|
+
}
|
|
870
|
+
if (payload.sourceCandidates) {
|
|
871
|
+
lines.push("");
|
|
872
|
+
lines.push("## Source candidates");
|
|
873
|
+
lines.push("");
|
|
874
|
+
if (payload.sourceCandidates.length) {
|
|
875
|
+
for (const c of payload.sourceCandidates) {
|
|
876
|
+
lines.push(`- \`${c.filePath}:${c.lineStart}-${c.lineEnd}\` \u2014 ${(c.confidence * 100).toFixed(0)}% \xB7 ${c.reason}`);
|
|
877
|
+
}
|
|
878
|
+
} else {
|
|
879
|
+
lines.push("_No source candidates found for this element in the workspace._");
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
if (payload.editPrompt) {
|
|
883
|
+
lines.push("");
|
|
884
|
+
lines.push("---");
|
|
885
|
+
lines.push("");
|
|
886
|
+
lines.push(payload.editPrompt);
|
|
887
|
+
}
|
|
888
|
+
return lines.join("\n");
|
|
889
|
+
}
|
|
890
|
+
async function resolvePayload(deps, args) {
|
|
891
|
+
if (args.thread_id) {
|
|
892
|
+
const thread = await deps.fetchThread(args.thread_id);
|
|
893
|
+
return thread ? threadContextToPayload(thread) : null;
|
|
894
|
+
}
|
|
895
|
+
if (args.selection_id) {
|
|
896
|
+
const found = deps.store.get(args.selection_id);
|
|
897
|
+
return found ? selectionToPayload(found) : null;
|
|
898
|
+
}
|
|
899
|
+
const latest = deps.store.latest();
|
|
900
|
+
return latest ? selectionToPayload(latest) : null;
|
|
901
|
+
}
|
|
902
|
+
var NOTHING_SELECTED = "Nothing has been selected yet.\n\nClick an element in the browser with the Loupe widget open, then call this tool again \u2014 the selection reaches the bridge automatically. You can also pass `thread_id` to work from a stored thread instead (see `list_comments`).";
|
|
903
|
+
var NOT_FOUND = (what, id) => `No ${what} found for \`${id}\`.
|
|
904
|
+
|
|
905
|
+
` + (what === "thread" ? "Use `list_comments` to see the available ids." : "It may have been evicted \u2014 the bridge keeps the most recent 50 selections.");
|
|
906
|
+
function createElementContextTools(deps) {
|
|
907
|
+
const withSource = async (payload) => {
|
|
908
|
+
const signals = {
|
|
909
|
+
tag: payload.element.tag,
|
|
910
|
+
id: payload.element.id,
|
|
911
|
+
text: payload.element.text,
|
|
912
|
+
classes: payload.element.classes,
|
|
913
|
+
attrs: payload.element.attrs
|
|
914
|
+
};
|
|
915
|
+
try {
|
|
916
|
+
payload.sourceCandidates = await mapElementToSource(signals, deps.workspaceRoot, deps.mapperOptions);
|
|
917
|
+
} catch {
|
|
918
|
+
payload.sourceCandidates = [];
|
|
919
|
+
}
|
|
920
|
+
return payload;
|
|
921
|
+
};
|
|
922
|
+
return {
|
|
923
|
+
/** The most recent selection. */
|
|
924
|
+
async getLatestSelection() {
|
|
925
|
+
const latest = deps.store.latest();
|
|
926
|
+
if (!latest) return { text: NOTHING_SELECTED, unavailable: true };
|
|
927
|
+
return { text: formatElementContext(await withSource(selectionToPayload(latest))) };
|
|
928
|
+
},
|
|
929
|
+
/** Recent selections, newest first. */
|
|
930
|
+
async getSelectionHistory({ limit } = {}) {
|
|
931
|
+
const history = deps.store.history(limit ?? 10);
|
|
932
|
+
if (!history.length) return { text: NOTHING_SELECTED, unavailable: true };
|
|
933
|
+
const lines = [`# Recent selections (${history.length})`, ""];
|
|
934
|
+
for (const s of history) {
|
|
935
|
+
lines.push(`- \`${s.correlationId}\` \u2014 \`<${s.tag}>\` ${s.selector ? `\`${s.selector}\`` : ""} ${s.text ? `\u201C${s.text.slice(0, 60)}\u201D` : ""} on ${s.url} (${s.at})`);
|
|
936
|
+
}
|
|
937
|
+
lines.push("");
|
|
938
|
+
lines.push("Pass any correlation id to `get_element_context` for the full context.");
|
|
939
|
+
return { text: lines.join("\n") };
|
|
940
|
+
},
|
|
941
|
+
/** Everything about one element: payload, styles, candidates and the prompt. */
|
|
942
|
+
async getElementContext(args = {}, opts = {}) {
|
|
943
|
+
const payload = await resolvePayload(deps, args);
|
|
944
|
+
if (!payload) {
|
|
945
|
+
if (args.thread_id) return { text: NOT_FOUND("thread", args.thread_id), unavailable: true };
|
|
946
|
+
if (args.selection_id) return { text: NOT_FOUND("selection", args.selection_id), unavailable: true };
|
|
947
|
+
return { text: NOTHING_SELECTED, unavailable: true };
|
|
948
|
+
}
|
|
949
|
+
await withSource(payload);
|
|
950
|
+
if (opts.include_prompt !== false) {
|
|
951
|
+
payload.editPrompt = generateEditPrompt({
|
|
952
|
+
tag: payload.element.tag,
|
|
953
|
+
selector: payload.element.selector,
|
|
954
|
+
testid: payload.element.testid,
|
|
955
|
+
text: payload.element.text,
|
|
956
|
+
classes: payload.element.classes,
|
|
957
|
+
pageUrl: payload.page.url,
|
|
958
|
+
candidates: payload.sourceCandidates,
|
|
959
|
+
isThread: payload.source === "thread",
|
|
960
|
+
threadId: payload.threadId
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
return { text: formatElementContext(payload) };
|
|
964
|
+
},
|
|
965
|
+
/** Just the ranked files, for when the agent only needs to know where to look. */
|
|
966
|
+
async findSourceForSelection(args = {}) {
|
|
967
|
+
const payload = await resolvePayload(deps, args);
|
|
968
|
+
if (!payload) {
|
|
969
|
+
if (args.thread_id) return { text: NOT_FOUND("thread", args.thread_id), unavailable: true };
|
|
970
|
+
if (args.selection_id) return { text: NOT_FOUND("selection", args.selection_id), unavailable: true };
|
|
971
|
+
return { text: NOTHING_SELECTED, unavailable: true };
|
|
972
|
+
}
|
|
973
|
+
await withSource(payload);
|
|
974
|
+
const candidates = payload.sourceCandidates ?? [];
|
|
975
|
+
if (!candidates.length) {
|
|
976
|
+
return {
|
|
977
|
+
text: `No source candidates for ${payload.element.tag} in \`${deps.workspaceRoot}\`.
|
|
978
|
+
|
|
979
|
+
The element's text, id, aria-label and non-utility classes were all searched. If the component lives outside this workspace, the agent will have to find it another way.`,
|
|
980
|
+
unavailable: true
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
const lines = [
|
|
984
|
+
`# Source candidates (${candidates.length})`,
|
|
985
|
+
"",
|
|
986
|
+
`Searched \`${deps.workspaceRoot}\` using the element's id, aria-label, text and app classes.`,
|
|
987
|
+
""
|
|
988
|
+
];
|
|
989
|
+
for (const c of candidates) lines.push(`- \`${c.filePath}:${c.lineStart}-${c.lineEnd}\` \u2014 ${(c.confidence * 100).toFixed(0)}% \xB7 ${c.reason}`);
|
|
990
|
+
lines.push("");
|
|
991
|
+
lines.push("This is a heuristic from the built page, not a resolver \u2014 verify before editing.");
|
|
992
|
+
return { text: lines.join("\n") };
|
|
993
|
+
}
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
|
|
422
997
|
// index.ts
|
|
423
998
|
import {
|
|
424
999
|
CHANGE_TYPE_LABELS,
|
|
@@ -588,7 +1163,7 @@ async function proposeChange({ id, html, css, notes }) {
|
|
|
588
1163
|
bus.publishThread(id, "preview_live", { author: proposal.author });
|
|
589
1164
|
return wrap(`Proposal saved for #${id}. The dev team can now review your modified HTML/CSS in the dashboard.`);
|
|
590
1165
|
}
|
|
591
|
-
var server = new McpServer({ name: "loupe", version: "0.10.
|
|
1166
|
+
var server = new McpServer({ name: "loupe", version: "0.10.19" });
|
|
592
1167
|
server.tool(
|
|
593
1168
|
"list_comments",
|
|
594
1169
|
"List Loupe product-feedback comments for the project as a task backlog. Each item carries its board stage, priority and change type, so you can start with the most urgent. Use this to see what a PM has flagged, then work through the items.",
|
|
@@ -625,6 +1200,52 @@ server.tool(
|
|
|
625
1200
|
},
|
|
626
1201
|
proposeChange
|
|
627
1202
|
);
|
|
1203
|
+
var WS = process.env.LOUPE_WORKSPACE || process.cwd();
|
|
1204
|
+
var elementTools = createElementContextTools({
|
|
1205
|
+
store,
|
|
1206
|
+
workspaceRoot: WS,
|
|
1207
|
+
mapperOptions: { maxFiles: Number(process.env.LOUPE_MAP_MAX_FILES ?? 4e3) },
|
|
1208
|
+
fetchThread: async (id) => {
|
|
1209
|
+
try {
|
|
1210
|
+
return await api(`/v1/comments/${encodeURIComponent(id)}`);
|
|
1211
|
+
} catch {
|
|
1212
|
+
return null;
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
});
|
|
1216
|
+
var SELECTION_ARGS = {
|
|
1217
|
+
thread_id: z.string().optional().describe("Work from a stored comment instead of a live selection."),
|
|
1218
|
+
selection_id: z.string().optional().describe(
|
|
1219
|
+
"A specific selection by correlation id (see get_selection_history). Omit for the most recent."
|
|
1220
|
+
)
|
|
1221
|
+
};
|
|
1222
|
+
server.tool(
|
|
1223
|
+
"get_latest_selection",
|
|
1224
|
+
`What the user last selected in the browser, with its element, computed styles and the source files most likely to render it. Use this when someone says "this element" or "what I'm looking at" and no thread exists yet.`,
|
|
1225
|
+
{},
|
|
1226
|
+
async () => wrap((await elementTools.getLatestSelection()).text)
|
|
1227
|
+
);
|
|
1228
|
+
server.tool(
|
|
1229
|
+
"get_selection_history",
|
|
1230
|
+
"The recent element selections, newest first \u2014 useful when the user clicked a few things and you need to pick the right one.",
|
|
1231
|
+
{ limit: z.number().int().positive().max(50).optional().describe("How many to list. Defaults to 10.") },
|
|
1232
|
+
async ({ limit }) => wrap((await elementTools.getSelectionHistory({ limit })).text)
|
|
1233
|
+
);
|
|
1234
|
+
server.tool(
|
|
1235
|
+
"get_element_context",
|
|
1236
|
+
"Full context for one element: the element, the page, its key computed styles, the ranked source files that probably render it, and a ready-made edit prompt. Pass thread_id to work from a stored comment, or nothing to use the latest live selection. Call this before editing anything.",
|
|
1237
|
+
{
|
|
1238
|
+
...SELECTION_ARGS,
|
|
1239
|
+
include_prompt: z.boolean().optional().describe("Include the edit prompt. Defaults to true.")
|
|
1240
|
+
},
|
|
1241
|
+
async ({ thread_id, selection_id, include_prompt }) => wrap((await elementTools.getElementContext({ thread_id, selection_id }, { include_prompt })).text)
|
|
1242
|
+
);
|
|
1243
|
+
server.tool(
|
|
1244
|
+
"find_source_for_selection",
|
|
1245
|
+
"Just the ranked source files for the current selection or a stored thread \u2014 for when you only need to know where the component lives. Heuristic: verify the top candidate before editing it.",
|
|
1246
|
+
SELECTION_ARGS,
|
|
1247
|
+
async ({ thread_id, selection_id }) => wrap((await elementTools.findSourceForSelection({ thread_id, selection_id })).text)
|
|
1248
|
+
);
|
|
628
1249
|
function isEntrypoint() {
|
|
629
1250
|
try {
|
|
630
1251
|
return import.meta.url === pathToFileURL(realpathSync(argv[1] ?? "")).href;
|
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"email": "m.ashraf.saed@gmail.com",
|
|
6
6
|
"url": "https://www.linkedin.com/in/mohamedashrafelsaed/"
|
|
7
7
|
},
|
|
8
|
-
"version": "0.10.
|
|
8
|
+
"version": "0.10.19-next.65",
|
|
9
9
|
"description": "MCP server that exposes Loupe comments to Claude Code as an actionable, fully-contextualized backlog.",
|
|
10
10
|
"keywords": [
|
|
11
11
|
"loupe",
|
|
@@ -46,6 +46,6 @@
|
|
|
46
46
|
"zod": "^3.24.1"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
|
-
"@loupekit/shared": "0.10.
|
|
49
|
+
"@loupekit/shared": "0.10.19-next.65"
|
|
50
50
|
}
|
|
51
51
|
}
|