prehello 0.0.0-stage → 0.1.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 (51) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/LICENSE +21 -0
  3. package/README.md +515 -2
  4. package/THIRD_PARTY_NOTICES.md +33 -0
  5. package/assets/fonts/Inter-ExtraBold.subset.ttf +0 -0
  6. package/assets/fonts/Inter-Regular.subset.ttf +0 -0
  7. package/assets/fonts/Inter-SemiBold.subset.ttf +0 -0
  8. package/assets/fonts/OFL.txt +94 -0
  9. package/dist/adapters/claude-code.js +480 -0
  10. package/dist/adapters/codex.js +184 -0
  11. package/dist/adapters/common.js +157 -0
  12. package/dist/adapters/cursor.js +257 -0
  13. package/dist/adapters/skills.js +155 -0
  14. package/dist/adapters/types.js +2 -0
  15. package/dist/analyze/findings.js +82 -0
  16. package/dist/analyze/grade.js +55 -0
  17. package/dist/analyze/savings.js +111 -0
  18. package/dist/analyze/summary.js +57 -0
  19. package/dist/analyze/totals.js +21 -0
  20. package/dist/cli.js +454 -0
  21. package/dist/context.js +331 -0
  22. package/dist/fixtures.js +52 -0
  23. package/dist/fs-safe.js +222 -0
  24. package/dist/mcp/catalog.js +44 -0
  25. package/dist/mcp/configs.js +42 -0
  26. package/dist/model.js +11 -0
  27. package/dist/platform.js +115 -0
  28. package/dist/redact.js +39 -0
  29. package/dist/render/card.js +305 -0
  30. package/dist/render/fix.js +22 -0
  31. package/dist/render/json.js +97 -0
  32. package/dist/render/markdown.js +182 -0
  33. package/dist/render/png.js +162 -0
  34. package/dist/render/sarif.js +472 -0
  35. package/dist/render/terminal.js +329 -0
  36. package/dist/render/treemap.js +58 -0
  37. package/dist/safe-write.js +120 -0
  38. package/dist/scan.js +104 -0
  39. package/dist/spec.js +27 -0
  40. package/dist/text/comments.js +60 -0
  41. package/dist/text/frontmatter.js +90 -0
  42. package/dist/text/imports.js +81 -0
  43. package/dist/tokens/counter.js +41 -0
  44. package/dist/tokens/families.js +47 -0
  45. package/dist/util/fmt.js +102 -0
  46. package/dist/util/secrets.js +35 -0
  47. package/package.json +66 -4
  48. package/spec/claude-code.json +22 -0
  49. package/spec/codex.json +14 -0
  50. package/spec/cursor.json +15 -0
  51. package/spec/mcp-weights.json +62 -0
@@ -0,0 +1,155 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.MAX_SKILLS_PER_DIR = void 0;
7
+ exports.toRec = toRec;
8
+ exports.discoverSkills = discoverSkills;
9
+ exports.skillItems = skillItems;
10
+ const node_path_1 = __importDefault(require("node:path"));
11
+ const fs_safe_1 = require("../fs-safe");
12
+ const frontmatter_1 = require("../text/frontmatter");
13
+ const fmt_1 = require("../util/fmt");
14
+ exports.MAX_SKILLS_PER_DIR = 400;
15
+ function firstLine(body) {
16
+ for (const l of body.split("\n")) {
17
+ const t = l.trim().replace(/^#+\s*/, "");
18
+ if (t)
19
+ return t;
20
+ }
21
+ return "";
22
+ }
23
+ function toRec(name, file, realPath, text) {
24
+ const fm = (0, frontmatter_1.parseFrontmatter)(text);
25
+ const desc = (0, frontmatter_1.asString)(fm.data.description).trim() || firstLine(fm.body);
26
+ const dmi = fm.data["disable-model-invocation"];
27
+ return {
28
+ name: ((0, frontmatter_1.asString)(fm.data.name).trim() || name).slice(0, 200),
29
+ file,
30
+ realPath,
31
+ desc,
32
+ when: (0, frontmatter_1.asString)(fm.data.when_to_use).trim(),
33
+ body: fm.body,
34
+ fm: fm.data,
35
+ scopedTo: [...(0, frontmatter_1.asList)(fm.data.paths), ...(0, frontmatter_1.asList)(fm.data.globs)],
36
+ disableModelInvocation: dmi === true || (typeof dmi === "string" && /^(true|yes|on|1)$/i.test(dmi.trim())), // Claude Code docs: booleans accept yes/no/on/off/1/0
37
+ };
38
+ }
39
+ /** Find <dir>/<name>/SKILL.md (and, if recursive, deeper). Hidden directories (.trash, .system, …) are skipped. */
40
+ function discoverSkills(ctx, dir, o = {}) {
41
+ const out = [];
42
+ const maxDepth = o.maxDepth ?? 4;
43
+ const visit = (d, depth) => {
44
+ for (const e of (0, fs_safe_1.listDir)(d)) {
45
+ if (out.length >= exports.MAX_SKILLS_PER_DIR) {
46
+ ctx.warn(`${ctx.display(dir)}: more than ${exports.MAX_SKILLS_PER_DIR} skills; only the first ${exports.MAX_SKILLS_PER_DIR} were counted, so skill totals here are a lower bound`);
47
+ return;
48
+ }
49
+ if (e.name.startsWith("."))
50
+ continue;
51
+ const p = node_path_1.default.join(d, e.name);
52
+ const isD = e.isDirectory() || (e.isSymbolicLink() && (0, fs_safe_1.isDir)(p));
53
+ if (!isD)
54
+ continue;
55
+ const f = node_path_1.default.join(p, "SKILL.md");
56
+ const r = ctx.read(f, o.jail ? { jail: o.jail } : {});
57
+ if (r.ok)
58
+ out.push(toRec(e.name, f, r.realPath, r.text));
59
+ else if (o.recursive && depth < maxDepth && e.isDirectory())
60
+ visit(p, depth + 1);
61
+ }
62
+ };
63
+ visit(dir, 0);
64
+ return out.sort((a, b) => (0, fmt_1.cmp)(a.file, b.file));
65
+ }
66
+ /**
67
+ * Turn a set of skills into: a listing item (name+description, loads up front), a body item (loads when the skill
68
+ * is used) and an on-demand item for skills the model cannot see (disable-model-invocation / overrides).
69
+ */
70
+ function skillItems(ctx, s) {
71
+ const listed = [];
72
+ const hidden = [];
73
+ for (const r of s.recs) {
74
+ const line = s.lineFor(r);
75
+ if (line === null)
76
+ hidden.push(r);
77
+ else
78
+ listed.push({ r, line });
79
+ }
80
+ const items = [];
81
+ const noun = (k) => `skill${k === 1 ? "" : "s"}`;
82
+ const fromTag = s.tag.startsWith("from ");
83
+ const phrase = (k) => (fromTag ? `${k} ${noun(k)} ${s.tag}` : `${k} ${s.tag} ${noun(k)}`);
84
+ const bodyPhrase = (k) => (fromTag ? `${k} ${noun(k)} ${s.tag}` : `${k} ${s.tag}`);
85
+ const base = { agent: s.agent, path: s.dir, scope: s.scope, evidence: s.evidence, confidence: s.confidence };
86
+ if (listed.length) {
87
+ const text = listed.map((l) => l.line).join("\n") + "\n";
88
+ const raw = ctx.count(`skills-l|${s.agent}|${s.dir}|${text.length}`, text);
89
+ const prof = ctx.profile(s.agent);
90
+ const perAll = listed.map((l) => ({ label: sanitizeLabel(l.r.name), source: l.r.source, tokens: prof.apply(ctx.counter.count(l.line + "\n")).est }));
91
+ const per = [...perAll].sort((a, b) => b.tokens - a.tokens || (0, fmt_1.cmp)(a.label, b.label)).slice(0, 8).map(({ label, tokens }) => ({ label, tokens }));
92
+ const it = ctx.makeItem({
93
+ ...base,
94
+ kind: "skills-listing",
95
+ loadMode: s.listingMode,
96
+ raw,
97
+ chars: text.length,
98
+ label: `Skills listing (${phrase(listed.length)})`,
99
+ cardLabel: s.cardLabel ?? "skills listing",
100
+ notes: s.notes,
101
+ group: s.group,
102
+ });
103
+ it.breakdown = per;
104
+ // "What to cut" parts: one per plugin (or, without a plugin, the whole group), scaled so they add up to the item's estimate.
105
+ if (it.tokens) {
106
+ const groups = new Map();
107
+ for (const x of perAll) {
108
+ const key = x.source ? `plugin ${sanitizeLabel(x.source)}` : phrase(listed.length);
109
+ const g = groups.get(key) ?? { tokens: 0, count: 0 };
110
+ g.tokens += x.tokens;
111
+ g.count++;
112
+ groups.set(key, g);
113
+ }
114
+ const sum = [...groups.values()].reduce((a, g) => a + g.tokens, 0);
115
+ const k = sum > 0 ? it.tokens.est / sum : 1;
116
+ it.parts = [...groups.entries()]
117
+ .map(([name, g]) => ({ name, tokens: Math.round(g.tokens * k), count: g.count }))
118
+ .filter((x) => x.tokens > 0)
119
+ .sort((a, b) => b.tokens - a.tokens || (0, fmt_1.cmp)(a.name, b.name));
120
+ }
121
+ items.push(it);
122
+ }
123
+ const bodyOf = (rs) => rs.map((r) => r.body).join("\n") + "\n";
124
+ const visible = listed.map((l) => l.r);
125
+ if (visible.length) {
126
+ const text = bodyOf(visible);
127
+ items.push(ctx.makeItem({
128
+ ...base,
129
+ kind: "skill-body",
130
+ loadMode: "deferred",
131
+ raw: ctx.count(`skills-b|${s.agent}|${s.dir}|${text.length}`, text),
132
+ chars: text.length,
133
+ label: `Skill bodies (${bodyPhrase(visible.length)})`,
134
+ cardLabel: "skill bodies",
135
+ notes: ["loaded only when a skill is used; supporting files in the skill folder are not counted"],
136
+ }));
137
+ }
138
+ if (hidden.length) {
139
+ const text = bodyOf(hidden);
140
+ items.push(ctx.makeItem({
141
+ ...base,
142
+ kind: "skill-body",
143
+ loadMode: "on-demand",
144
+ raw: ctx.count(`skills-h|${s.agent}|${s.dir}|${text.length}`, text),
145
+ chars: text.length,
146
+ label: `Skills hidden from the model (${bodyPhrase(hidden.length)}, manual invoke only)`,
147
+ cardLabel: "manual-only skills",
148
+ notes: ["not in the listing; the body loads only if you invoke the skill yourself"],
149
+ }));
150
+ }
151
+ return items;
152
+ }
153
+ function sanitizeLabel(s) {
154
+ return s.replace(/[\u0000-\u001f\u007f-\u009f]/g, "?").slice(0, 60);
155
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,82 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.crossFindings = crossFindings;
4
+ exports.rankFindings = rankFindings;
5
+ const context_1 = require("../context");
6
+ const model_1 = require("../model");
7
+ const fmt_1 = require("../util/fmt");
8
+ const MAX_DUP_DOCS = 300;
9
+ const MAX_DUP_PAIRS = 20;
10
+ /** Cross-document findings (F2 duplicates) and MCP lower-bound notes (F5). */
11
+ function crossFindings(ctx, agents) {
12
+ // Bounded on purpose: comparing every pair is quadratic, and 200 copies of one rule file are 19,900 "duplicate" pairs.
13
+ // Compare the biggest MAX_DUP_DOCS files, list the MAX_DUP_PAIRS most similar pairs, and say how many were left out.
14
+ const all = [...ctx.docs.values()].filter((d) => d.tokens >= 200 && d.shingles.size > 0).sort((x, y) => y.tokens - x.tokens || (0, fmt_1.cmp)(x.path, y.path));
15
+ const docs = all.slice(0, MAX_DUP_DOCS);
16
+ if (all.length > docs.length)
17
+ ctx.warn(`duplicate check compared only the ${MAX_DUP_DOCS} largest of ${all.length} context files`);
18
+ const pairs = [];
19
+ for (let i = 0; i < docs.length; i++) {
20
+ for (let j = i + 1; j < docs.length; j++) {
21
+ const a = docs[i];
22
+ const b = docs[j];
23
+ const sim = (0, context_1.jaccard)(a.shingles, b.shingles);
24
+ if (sim < 0.6)
25
+ continue;
26
+ const common = [...a.agents].filter((x) => b.agents.has(x));
27
+ let savings;
28
+ if (common.length) {
29
+ const smaller = Math.min(a.tokens, b.tokens);
30
+ savings = Math.round(ctx.profile(common[0]).apply(smaller).est * sim);
31
+ }
32
+ pairs.push({ a, b, sim, common, savings });
33
+ }
34
+ }
35
+ pairs.sort((x, y) => y.sim - x.sim || (y.savings ?? 0) - (x.savings ?? 0) || (0, fmt_1.cmp)(x.a.path, y.a.path) || (0, fmt_1.cmp)(x.b.path, y.b.path));
36
+ for (const { a, b, sim, common, savings } of pairs.slice(0, MAX_DUP_PAIRS)) {
37
+ ctx.addFinding({
38
+ id: "F2",
39
+ severity: "warn",
40
+ agent: common[0] ?? "all",
41
+ message: `${ctx.display(a.path)} and ${ctx.display(b.path)} are ~${Math.round(sim * 100)}% duplicate text${common.length ? "" : " (loaded by different agents)"}`,
42
+ paths: [a.path, b.path],
43
+ savingsTokens: savings,
44
+ confidence: sim >= 0.9 ? "high" : "medium",
45
+ data: { similarityPct: Math.round(sim * 100) },
46
+ fix: common.length
47
+ ? "Keep one copy. Claude Code @imports do not reduce cost, so delete the duplicate rather than importing it."
48
+ : "Different agents read these files, so do not delete either. To stop them drifting apart, make one the source of truth (for example, a CLAUDE.md that contains just `@AGENTS.md`, or a symlink). This saves no tokens.",
49
+ });
50
+ }
51
+ if (pairs.length > MAX_DUP_PAIRS) {
52
+ ctx.addFinding({
53
+ id: "F2",
54
+ severity: "info",
55
+ agent: "all",
56
+ message: `${pairs.length - MAX_DUP_PAIRS} more near-duplicate pairs are not listed (the ${MAX_DUP_PAIRS} most similar are shown)`,
57
+ paths: [],
58
+ confidence: "high",
59
+ data: { omittedPairs: pairs.length - MAX_DUP_PAIRS },
60
+ });
61
+ }
62
+ for (const ag of agents) {
63
+ const n = ag.totals.always.unmeasuredItems;
64
+ const unm = ag.items.filter((i) => i.loadMode === "always" && i.tokens === null);
65
+ const what = unm.every((i) => i.kind === "mcp-server") ? "MCP servers" : unm.some((i) => i.kind === "mcp-server") ? "MCP servers and unreadable files" : "files that could not be read";
66
+ if (n > 0 && !ag.presentInRepoOnly) {
67
+ ctx.addFinding({
68
+ id: "F5",
69
+ severity: "info",
70
+ agent: ag.id,
71
+ message: `${model_1.AGENT_NAMES[ag.id]}: ${n} always-loaded item${n > 1 ? "s are" : " is"} unmeasured (${what}); the always-on total is a lower bound ("at least")`,
72
+ paths: [],
73
+ confidence: "needs-your-call",
74
+ fix: unm.some((i) => i.kind === "mcp-server") ? "If a server is unused, disable or remove it in the agent's own settings. prehello never edits MCP configs." : undefined,
75
+ });
76
+ }
77
+ }
78
+ }
79
+ function rankFindings(fs) {
80
+ const sev = { warn: 0, info: 1 };
81
+ return [...fs].sort((a, b) => (b.savingsTokens ?? 0) - (a.savingsTokens ?? 0) || sev[a.severity] - sev[b.severity] || (0, fmt_1.cmp)(a.id, b.id) || (0, fmt_1.cmp)(a.message, b.message));
82
+ }
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RUBRIC = void 0;
4
+ exports.gradeAgent = gradeAgent;
5
+ exports.RUBRIC = [
6
+ "Start at 100 points per agent. Rubric uses only documented guidelines; it is a heuristic, not an industry standard.",
7
+ "Always-on tokens as a share of the window (estimate): ≥20% −45 · ≥10% −30 · ≥5% −15 · ≥2% −5",
8
+ "Each always-on instruction file over its documented guideline (Claude Code 200 lines, Cursor rules 500 lines): −5, max −15",
9
+ "Each place content is silently truncated (Codex 32 KiB cap, MEMORY.md 200 lines/25 KB, skill description cap): −5, max −15",
10
+ "Each file the agent ignores (e.g. AGENTS.md under CLAUDE.md precedence, .md in .cursor/rules): −3, max −9",
11
+ "Each near-duplicate file pair (≥60% shared text): −5, max −10",
12
+ "Letters: A ≥ 90 · B ≥ 80 · C ≥ 70 · D ≥ 60 · F below",
13
+ "Unmeasured always-loaded items (e.g. MCP servers) are NOT scored (we don't punish what we can't see). The grade is marked provisional, and an A is capped at B, because a top grade cannot be certified for a setup we could only partly see.",
14
+ "No grade at all is printed when nothing that loads up front was measured.",
15
+ ];
16
+ function gradeAgent(ag, findings, window) {
17
+ const reasons = [];
18
+ let score = 100;
19
+ const always = ag.totals.always.est;
20
+ const share = (always / window) * 100;
21
+ const shareCost = share >= 20 ? 45 : share >= 10 ? 30 : share >= 5 ? 15 : share >= 2 ? 5 : 0;
22
+ if (shareCost) {
23
+ score -= shareCost;
24
+ reasons.push(`always-on is ~${share.toFixed(1)}% of the window (−${shareCost})`);
25
+ }
26
+ const mine = findings.filter((f) => f.agent === ag.id || f.agent === "all");
27
+ const count = (pred) => mine.filter(pred).length;
28
+ const long = count((f) => f.id === "F1");
29
+ const trunc = count((f) => f.id === "F4" || f.id === "F8" || f.id === "F12");
30
+ const ignored = count((f) => f.id === "F7" || f.id === "F11");
31
+ const dup = count((f) => f.id === "F2" && !f.data?.omittedPairs);
32
+ const apply = (n, per, max, why) => {
33
+ if (!n)
34
+ return;
35
+ const c = Math.min(max, n * per);
36
+ score -= c;
37
+ reasons.push(`${n} ${why} (−${c})`);
38
+ };
39
+ apply(long, 5, 15, "file(s) over the documented length guideline");
40
+ apply(trunc, 5, 15, "silent truncation(s)");
41
+ apply(ignored, 3, 9, "ignored file(s)");
42
+ apply(dup, 5, 10, "duplicate pair(s)");
43
+ score = Math.max(0, score);
44
+ let letter = score >= 90 ? "A" : score >= 80 ? "B" : score >= 70 ? "C" : score >= 60 ? "D" : "F";
45
+ const unm = ag.totals.always.unmeasuredItems;
46
+ const partial = ag.lowerBound && unm === 0; // the scan budget cut the scan short
47
+ let cappedAt;
48
+ if (ag.lowerBound && letter === "A") {
49
+ // An "A" would claim a clean bill of health for a setup we could only partly see.
50
+ letter = "B";
51
+ cappedAt = "B";
52
+ reasons.push(partial ? "capped at B: the scan stopped at its budget, so a top grade cannot be certified" : `capped at B: ${unm} always-loaded item${unm > 1 ? "s" : ""} could not be measured, so a top grade cannot be certified`);
53
+ }
54
+ return { letter, score, provisional: ag.lowerBound, ...(cappedAt ? { cappedAt } : {}), reasons };
55
+ }
@@ -0,0 +1,111 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_CUT_N = void 0;
4
+ exports.cutCandidates = cutCandidates;
5
+ exports.cutCandidatesWithSource = cutCandidatesWithSource;
6
+ exports.savingsOf = savingsOf;
7
+ exports.cardTiles = cardTiles;
8
+ const fmt_1 = require("../util/fmt");
9
+ /** Default number of groups in the "what to cut" line. */
10
+ exports.DEFAULT_CUT_N = 3;
11
+ /** Below this the cut list is noise, not advice. */
12
+ const MIN_SAVINGS_TOKENS = 500;
13
+ /**
14
+ * Things a person can actually remove and that cost tokens before the first message: a plugin's skills, a skills folder,
15
+ * an MCP server with a measured size. Instruction files and rules are NOT candidates (they are your own words; use the findings for those).
16
+ */
17
+ function cutCandidates(items) {
18
+ return cutCandidatesWithSource(items).map(({ source: _source, ...c }) => c);
19
+ }
20
+ /** Same list, same order as cutCandidates(), plus the item each group came from (for renderers that need its scope / file). */
21
+ function cutCandidatesWithSource(items) {
22
+ const out = [];
23
+ for (const it of items) {
24
+ if (it.loadMode !== "always" || !it.tokens || it.tokens.est <= 0)
25
+ continue;
26
+ if (it.kind === "skills-listing" && it.parts?.length) {
27
+ for (const p of it.parts)
28
+ out.push({ label: p.name, kind: "skills", tokens: p.tokens, count: p.count, source: it });
29
+ }
30
+ else if (it.kind === "skills-listing") {
31
+ out.push({ label: it.label.replace(/^Skills listing \((.*)\)$/, "$1"), kind: "skills", tokens: it.tokens.est, count: 1, source: it });
32
+ }
33
+ else if (it.kind === "mcp-server" && it.server) {
34
+ out.push({ label: `MCP server ${it.server.name}`, kind: "mcp", tokens: it.tokens.est, count: 1, source: it });
35
+ }
36
+ }
37
+ return out.sort((a, b) => b.tokens - a.tokens || (0, fmt_1.cmp)(a.label, b.label));
38
+ }
39
+ /** What removing the top `n` groups would save (estimate). Null when there is nothing meaningful to say. */
40
+ function savingsOf(items, alwaysEst, window, n = exports.DEFAULT_CUT_N) {
41
+ const cands = cutCandidates(items);
42
+ if (!cands.length || alwaysEst <= 0)
43
+ return null;
44
+ const top = cands.slice(0, n);
45
+ const topTokens = top.reduce((s, c) => s + c.tokens, 0);
46
+ if (topTokens < MIN_SAVINGS_TOKENS)
47
+ return null;
48
+ const groupsTokens = cands.reduce((s, c) => s + c.tokens, 0);
49
+ return {
50
+ n: top.length,
51
+ top,
52
+ topTokens,
53
+ groupsTokens,
54
+ groupsCount: cands.length,
55
+ pctOfAlways: (topTokens / alwaysEst) * 100,
56
+ pctOfGroups: groupsTokens ? (topTokens / groupsTokens) * 100 : 0,
57
+ pctOfWindow: (topTokens / window) * 100,
58
+ };
59
+ }
60
+ /** A tile label must never read like a path: drop any word that looks like one ("3 legacy ~/.codex skills" -> "3 legacy skills"). */
61
+ function pathFree(label) {
62
+ const words = label.split(/\s+/).filter((w) => w && !/[\\/~]/.test(w));
63
+ return words.join(" ") || "skills";
64
+ }
65
+ /**
66
+ * Always-on tiles for the card treemap. Skills listings and MCP servers are expanded into the SAME groups as the cut list
67
+ * (cutCandidates), so the card matches its headline; everything else keeps its generic card label ("rule file 2").
68
+ * Sorted by size. `anonymize` replaces plugin and MCP server names with "plugin 2" / "MCP server 1".
69
+ */
70
+ function cardTiles(items, anonymize = false) {
71
+ const out = [];
72
+ cutCandidates(items).forEach((c, k) => {
73
+ const named = c.kind === "mcp" || /^plugin /.test(c.label);
74
+ out.push({ label: named ? c.label : pathFree(c.label), tokens: c.tokens, kind: c.kind === "mcp" ? "mcp" : "group", named, cut: k });
75
+ });
76
+ // Always-on items that are not removable groups: repeated generic labels collapse into one tile ("rule file x14"), not 14 slivers.
77
+ const plain = items.filter((i) => i.loadMode === "always" && i.tokens && i.tokens.est > 0 && i.kind !== "mcp-server" && i.kind !== "skills-listing");
78
+ const byLabel = new Map();
79
+ for (const it of plain) {
80
+ const g = byLabel.get(it.cardLabel) ?? { n: 0, tokens: 0 };
81
+ g.n++;
82
+ g.tokens += it.tokens.est;
83
+ byLabel.set(it.cardLabel, g);
84
+ }
85
+ for (const [label, g] of byLabel)
86
+ out.push({ label: g.n > 1 ? `${label} x${g.n}` : label, tokens: g.tokens, kind: "item", named: false });
87
+ out.sort((a, b) => b.tokens - a.tokens || (0, fmt_1.cmp)(a.label, b.label));
88
+ if (anonymize) {
89
+ let plugins = 0;
90
+ let servers = 0;
91
+ for (const t of out) {
92
+ if (!t.named)
93
+ continue;
94
+ t.label = t.kind === "mcp" ? `MCP server ${++servers}` : `plugin ${++plugins}`;
95
+ t.named = false;
96
+ }
97
+ }
98
+ // identical labels (two "3 user skills" groups, two servers with the same name) must stay distinguishable
99
+ const dup = new Map();
100
+ for (const t of out)
101
+ dup.set(t.label, (dup.get(t.label) ?? 0) + 1);
102
+ const idx = new Map();
103
+ for (const t of out) {
104
+ if ((dup.get(t.label) ?? 1) > 1) {
105
+ const k = (idx.get(t.label) ?? 0) + 1;
106
+ idx.set(t.label, k);
107
+ t.label = `${t.label} (${k})`;
108
+ }
109
+ }
110
+ return out;
111
+ }
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.coverageOf = coverageOf;
4
+ exports.mcpServersOf = mcpServersOf;
5
+ exports.offendersOf = offendersOf;
6
+ const fmt_1 = require("../util/fmt");
7
+ /** What the scan measured vs. could not measure among always-on items. Counts only; no content. */
8
+ function coverageOf(agent, items) {
9
+ const always = items.filter((i) => i.loadMode === "always");
10
+ const measuredKinds = {};
11
+ const unmeasuredKinds = {};
12
+ let m = 0;
13
+ let u = 0;
14
+ for (const it of always) {
15
+ if (it.tokens) {
16
+ m++;
17
+ measuredKinds[it.kind] = (measuredKinds[it.kind] ?? 0) + 1;
18
+ }
19
+ else {
20
+ u++;
21
+ unmeasuredKinds[it.kind] = (unmeasuredKinds[it.kind] ?? 0) + 1;
22
+ }
23
+ }
24
+ const servers = new Set(items.filter((i) => i.server).map((i) => `${i.server.name}|${i.path}|${i.scope}`));
25
+ return { agent, alwaysMeasuredItems: m, alwaysUnmeasuredItems: u, alwaysFraction: m + u ? m / (m + u) : null, measuredKinds, unmeasuredKinds, mcpServers: servers.size };
26
+ }
27
+ function mcpServersOf(items) {
28
+ const map = new Map();
29
+ for (const it of items) {
30
+ if (!it.server)
31
+ continue;
32
+ const key = `${it.server.name}|${it.path}|${it.scope}`;
33
+ const cur = map.get(key);
34
+ const measured = it.tokens !== null;
35
+ if (!cur)
36
+ map.set(key, { name: it.server.name, source: it.path, scope: it.scope, transport: it.server.transport, measured });
37
+ else
38
+ cur.measured = cur.measured && measured;
39
+ }
40
+ return [...map.values()].sort((a, b) => (0, fmt_1.cmp)(a.name, b.name) || (0, fmt_1.cmp)(a.source, b.source));
41
+ }
42
+ /** Heaviest always-on contributors. Aggregated listings (skills) are expanded into their biggest entries. */
43
+ function offendersOf(agent, items, limit = 15) {
44
+ const out = [];
45
+ for (const it of items) {
46
+ if (it.loadMode !== "always" || !it.tokens)
47
+ continue;
48
+ if (it.breakdown && it.breakdown.length) {
49
+ for (const b of it.breakdown)
50
+ out.push({ agent, label: `skill "${b.label}"`, path: it.path, kind: it.kind, loadMode: it.loadMode, tokens: b.tokens, note: `inside ${it.label}` });
51
+ }
52
+ else {
53
+ out.push({ agent, label: it.label, path: it.path, kind: it.kind, loadMode: it.loadMode, tokens: it.tokens.est });
54
+ }
55
+ }
56
+ return out.sort((a, b) => b.tokens - a.tokens || (0, fmt_1.cmp)(a.label, b.label)).slice(0, limit);
57
+ }
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.sumTotals = sumTotals;
4
+ const model_1 = require("../model");
5
+ function sumTotals(items) {
6
+ const out = {};
7
+ for (const m of model_1.LOAD_MODES)
8
+ out[m] = { est: 0, low: 0, high: 0, measuredItems: 0, unmeasuredItems: 0 };
9
+ for (const it of items) {
10
+ const t = out[it.loadMode];
11
+ if (!it.tokens) {
12
+ t.unmeasuredItems++;
13
+ continue;
14
+ }
15
+ t.est += it.tokens.est;
16
+ t.low += it.tokens.low;
17
+ t.high += it.tokens.high;
18
+ t.measuredItems++;
19
+ }
20
+ return out;
21
+ }