prehello 0.0.0-stage → 1.0.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 (61) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/LICENSE +21 -0
  3. package/README.md +171 -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 +275 -0
  13. package/dist/adapters/gemini-cli.js +214 -0
  14. package/dist/adapters/github-copilot.js +249 -0
  15. package/dist/adapters/index.js +16 -0
  16. package/dist/adapters/skills.js +165 -0
  17. package/dist/adapters/types.js +2 -0
  18. package/dist/agents.js +54 -0
  19. package/dist/analyze/findings.js +82 -0
  20. package/dist/analyze/grade.js +55 -0
  21. package/dist/analyze/savings.js +111 -0
  22. package/dist/analyze/summary.js +57 -0
  23. package/dist/analyze/totals.js +21 -0
  24. package/dist/calibrate.js +265 -0
  25. package/dist/cli.js +591 -0
  26. package/dist/context.js +329 -0
  27. package/dist/diff.js +182 -0
  28. package/dist/fixtures.js +52 -0
  29. package/dist/fs-safe.js +222 -0
  30. package/dist/mcp/catalog.js +44 -0
  31. package/dist/mcp/configs.js +42 -0
  32. package/dist/model.js +8 -0
  33. package/dist/platform.js +115 -0
  34. package/dist/redact.js +39 -0
  35. package/dist/render/card.js +306 -0
  36. package/dist/render/fix.js +22 -0
  37. package/dist/render/json.js +97 -0
  38. package/dist/render/markdown.js +182 -0
  39. package/dist/render/png.js +162 -0
  40. package/dist/render/sarif.js +472 -0
  41. package/dist/render/terminal.js +318 -0
  42. package/dist/render/treemap.js +58 -0
  43. package/dist/safe-write.js +122 -0
  44. package/dist/scan.js +98 -0
  45. package/dist/spec.js +27 -0
  46. package/dist/text/comments.js +60 -0
  47. package/dist/text/frontmatter.js +90 -0
  48. package/dist/text/imports.js +86 -0
  49. package/dist/tokens/counter.js +41 -0
  50. package/dist/tokens/families.js +79 -0
  51. package/dist/util/fmt.js +102 -0
  52. package/dist/util/secrets.js +35 -0
  53. package/dist/version.js +5 -0
  54. package/package.json +70 -4
  55. package/spec/calibration.json +33 -0
  56. package/spec/claude-code.json +22 -0
  57. package/spec/codex.json +14 -0
  58. package/spec/cursor.json +15 -0
  59. package/spec/gemini-cli.json +17 -0
  60. package/spec/github-copilot.json +14 -0
  61. package/spec/mcp-weights.json +62 -0
package/dist/agents.js ADDED
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AGENT_NAMES = exports.ALL_AGENTS = exports.AGENTS = void 0;
4
+ exports.agentMeta = agentMeta;
5
+ exports.AGENTS = [
6
+ {
7
+ id: "claude-code",
8
+ name: "Claude Code",
9
+ tokenFamily: "claude",
10
+ lookedFor: "~/.claude, ~/.claude.json, `claude` on PATH; CLAUDE.md, .claude/, .mcp.json in the project",
11
+ whereLooked: "CLAUDE.md (project, parent folders, ~/.claude), .claude/{rules,skills,commands}, plugins, .mcp.json, ~/.claude.json",
12
+ projectMarkers: ["CLAUDE.md", "CLAUDE.local.md", ".mcp.json", ".claude/"],
13
+ },
14
+ {
15
+ id: "cursor",
16
+ name: "Cursor",
17
+ tokenFamily: "mixed",
18
+ lookedFor: "~/.cursor, Cursor.app, `cursor` on PATH; .cursor/, .cursorrules, AGENTS.md in the project",
19
+ whereLooked: ".cursor/rules (incl. nested), AGENTS.md, .cursorrules, skills folders, ~/.cursor/{skills,plugins,mcp.json}, .cursor/mcp.json",
20
+ hint: "Cursor's User Rules live inside the app, not on disk. Paste them into a file and run with --paste-user-rules <file> to count them.",
21
+ cardChips: ["unmeasured: User Rules"],
22
+ projectMarkers: [".cursorrules", "AGENTS.md", ".cursor/"],
23
+ },
24
+ {
25
+ id: "codex",
26
+ name: "Codex",
27
+ tokenFamily: "openai",
28
+ lookedFor: "~/.codex, `codex` on PATH; AGENTS.md, .codex/, .agents/skills in the project",
29
+ whereLooked: "AGENTS.md chain, ~/.codex/{AGENTS.md,config.toml}, .agents/skills, ~/.agents/skills, .codex/config.toml",
30
+ projectMarkers: ["AGENTS.md", ".codex/", ".agents/"],
31
+ },
32
+ {
33
+ id: "github-copilot",
34
+ name: "GitHub Copilot",
35
+ tokenFamily: "mixed",
36
+ lookedFor: "~/.copilot, `copilot` on PATH, the VS Code Copilot Chat extension; .github/copilot-instructions.md, .github/instructions/, .github/prompts/, .vscode/mcp.json in the project",
37
+ whereLooked: ".github/copilot-instructions.md, AGENTS.md/CLAUDE.md/GEMINI.md (repo root → cwd), .github/instructions/**, .github/prompts/**, ~/.copilot/{copilot-instructions.md,instructions,mcp-config.json}, .mcp.json, .github/mcp.json, .vscode/mcp.json, VS Code user mcp.json",
38
+ hint: "Organization instructions and VS Code settings-based instructions live on GitHub or in VS Code settings, not in the scanned files.",
39
+ projectMarkers: [".github/copilot-instructions.md", ".github/instructions/", ".vscode/mcp.json"],
40
+ },
41
+ {
42
+ id: "gemini-cli",
43
+ name: "Gemini CLI",
44
+ tokenFamily: "gemini",
45
+ lookedFor: "~/.gemini, `gemini` on PATH; GEMINI.md, .gemini/ in the project",
46
+ whereLooked: "~/.gemini/GEMINI.md, GEMINI.md (cwd → project root, and below cwd), context.fileName, ~/.gemini/extensions/*, ~/.gemini/settings.json, .gemini/settings.json, .gemini/skills, .agents/skills",
47
+ projectMarkers: ["GEMINI.md", ".gemini/"],
48
+ },
49
+ ];
50
+ exports.ALL_AGENTS = exports.AGENTS.map((a) => a.id);
51
+ exports.AGENT_NAMES = Object.fromEntries(exports.AGENTS.map((a) => [a.id, a.name]));
52
+ function agentMeta(id) {
53
+ return exports.AGENTS.find((a) => a.id === id);
54
+ }
@@ -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
+ }
@@ -0,0 +1,265 @@
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.ContextParseError = void 0;
7
+ exports.parseNumber = parseNumber;
8
+ exports.parseContext = parseContext;
9
+ exports.compare = compare;
10
+ exports.renderCalibration = renderCalibration;
11
+ exports.calibrationJson = calibrationJson;
12
+ /**
13
+ * `prehello calibrate`: compare prehello's estimate with a Claude Code `/context` reading the user pasted. Offline: the text is
14
+ * parsed locally and nothing is sent anywhere. A file is written only with --save.
15
+ */
16
+ const node_path_1 = __importDefault(require("node:path"));
17
+ const families_1 = require("./tokens/families");
18
+ const fmt_1 = require("./util/fmt");
19
+ class ContextParseError extends Error {
20
+ }
21
+ exports.ContextParseError = ContextParseError;
22
+ const NUM = String.raw `(\d[\d,]*(?:\.\d+)?)\s*([kKmM]?)`;
23
+ function parseNumber(num, suffix) {
24
+ const n = parseFloat(num.replace(/,/g, ""));
25
+ const mult = suffix.toLowerCase() === "k" ? 1000 : suffix.toLowerCase() === "m" ? 1_000_000 : 1;
26
+ const decimals = num.includes(".") ? num.split(".")[1].length : 0;
27
+ const pm = (mult === 1 && decimals === 0 ? 0.5 : 0.5 * Math.pow(10, -decimals)) * mult;
28
+ return { value: Math.round(n * mult), pm: mult === 1 && decimals === 0 ? 0.5 : pm, raw: `${num}${suffix}` };
29
+ }
30
+ const KNOWN = ["system prompt", "system tools", "system tools (deferred)", "mcp tools", "mcp tools (deferred)", "custom agents", "memory files", "skills", "messages", "free space", "autocompact buffer"];
31
+ const SECTIONS = [
32
+ [/^mcp tools\b/i, "mcp tools"],
33
+ [/^custom agents\b/i, "custom agents"],
34
+ [/^memory files\b/i, "memory files"],
35
+ [/^skills\b/i, "skills"],
36
+ [/^system tools\b/i, "system tools"],
37
+ [/^plugins?\b/i, "plugins"],
38
+ ];
39
+ /** Parse the text of Claude Code's `/context` (any of the layouts seen 2025-2026). Unknown lines are ignored and counted. */
40
+ function parseContext(text) {
41
+ // eslint-disable-next-line no-control-regex
42
+ const clean = text.replace(/\u001b\[[0-9;?]*[ -\/]*[@-~]/g, "").replace(/\r\n?/g, "\n");
43
+ const r = { categories: new Map(), details: new Map(), mcpOnDemand: false, recognised: 0, ignored: 0 };
44
+ let section;
45
+ const header = new RegExp(String.raw `([\w.\-\[\]]+)\s+·\s+${NUM}\s*/\s*${NUM}\s+tokens\s*\(`, "i");
46
+ const usedOnly = new RegExp(String.raw `^\s*${NUM}\s*/\s*${NUM}\s+tokens\s*\(`, "i");
47
+ const cat = new RegExp(String.raw `([A-Za-z][A-Za-z ]*?(?:\s*\((?:deferred)\))?):\s+${NUM}\s*(?:tokens)?\s*\(\s*\d+(?:\.\d+)?%\s*\)`, "g");
48
+ const row = new RegExp(String.raw `^\s*[└├│]?[─\s]*└?\s*(.+?):\s+${NUM}\s+tokens\s*$`);
49
+ const summary = new RegExp(String.raw `^\s*└\s+\d[\d,]*\s+\w+\s+·\s+${NUM}\s+tokens`);
50
+ for (const line0 of clean.split("\n")) {
51
+ const line = line0.replace(/[⛁⛀⛶⛝⛃]/g, " ").replace(/^\s*(?:❯|>)?\s*(?:⎿)?\s*/, "");
52
+ if (!line.trim())
53
+ continue;
54
+ const h = header.exec(line);
55
+ if (h) {
56
+ r.model = h[1];
57
+ r.used = parseNumber(h[2], h[3]);
58
+ r.total = parseNumber(h[4], h[5]);
59
+ r.recognised++;
60
+ continue;
61
+ }
62
+ const u = usedOnly.exec(line);
63
+ if (u) {
64
+ r.used = parseNumber(u[1], u[2]);
65
+ r.total = parseNumber(u[3], u[4]);
66
+ r.recognised++;
67
+ continue;
68
+ }
69
+ if (/^\s*claude-[\w.\-\[\]]+\s*$/i.test(line)) {
70
+ r.model = line.trim();
71
+ r.recognised++;
72
+ continue;
73
+ }
74
+ let anyCat = false;
75
+ for (const m of line.matchAll(cat)) {
76
+ const name = m[1].trim().toLowerCase().replace(/\s+/g, " ");
77
+ if (!KNOWN.includes(name) && !/^[a-z ]+( \(deferred\))?$/.test(name))
78
+ continue;
79
+ r.categories.set(name, parseNumber(m[2], m[3]));
80
+ anyCat = true;
81
+ }
82
+ if (anyCat) {
83
+ r.recognised++;
84
+ section = undefined;
85
+ continue;
86
+ }
87
+ const sec = line.trim();
88
+ const sm = SECTIONS.find(([re]) => re.test(sec) && /·|\/\w/.test(sec));
89
+ if (sm) {
90
+ section = sm[1];
91
+ if (section === "mcp tools" && /on-demand|deferred/i.test(sec))
92
+ r.mcpOnDemand = true;
93
+ if (!r.details.has(section))
94
+ r.details.set(section, []);
95
+ r.recognised++;
96
+ continue;
97
+ }
98
+ if (section && summary.test(line)) {
99
+ r.recognised++;
100
+ continue;
101
+ }
102
+ const rw = section ? row.exec(line) : null;
103
+ if (rw && section) {
104
+ const label = rw[1].replace(/^[└├│─\s]+/, "").trim();
105
+ const pm = /\(([^()]*[\/\\][^()]*)\)\s*$/.exec(label);
106
+ const p = pm ? pm[1] : /[\/\\]/.test(label) ? label : undefined;
107
+ r.details.get(section).push({ label, tokens: parseNumber(rw[2], rw[3]), ...(p ? { path: p } : {}) });
108
+ r.recognised++;
109
+ continue;
110
+ }
111
+ if (section && /^\s*(User|Project|Local|Managed|Plugin|Built-in|Enterprise)\b[^:]*$/i.test(line)) {
112
+ r.recognised++;
113
+ continue;
114
+ }
115
+ r.ignored++;
116
+ }
117
+ if (!r.categories.size) {
118
+ throw new ContextParseError("no /context category rows found (expected lines like 'Memory files: 3.2k tokens (1.6%)'). Paste the whole output of /context (or /context all) from Claude Code");
119
+ }
120
+ return r;
121
+ }
122
+ const COMPARED = [
123
+ { cat: "memory files", kinds: ["instructions", "rule", "memory"] },
124
+ { cat: "skills", kinds: ["skills-listing"] },
125
+ { cat: "custom agents", kinds: ["subagents"] },
126
+ { cat: "mcp tools", kinds: ["mcp-server"] },
127
+ ];
128
+ function mk(category, actual, est, unmeasured, note) {
129
+ const a = actual?.value ?? null;
130
+ const err = a !== null && a > 0 && est ? ((est.est - a) / a) * 100 : null;
131
+ const inBand = a !== null && est ? est.low <= a + (actual?.pm ?? 0) && est.high >= a - (actual?.pm ?? 0) : null;
132
+ return { category, actual: a, actualPm: actual?.pm ?? 0, est: est?.est ?? null, low: est?.low ?? null, high: est?.high ?? null, errorPct: err, inBand, unmeasuredItems: unmeasured, ...(note ? { note } : {}) };
133
+ }
134
+ function sumItems(items) {
135
+ const m = items.filter((i) => i.tokens);
136
+ if (!m.length)
137
+ return null;
138
+ return m.reduce((s, i) => ({ est: s.est + i.tokens.est, low: s.low + i.tokens.low, high: s.high + i.tokens.high }), { est: 0, low: 0, high: 0 });
139
+ }
140
+ const norm = (p) => p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
141
+ function compare(reading, a, home) {
142
+ const always = a.items.filter((i) => i.loadMode === "always");
143
+ const rows = [];
144
+ const used = { est: 0, low: 0, high: 0, actual: 0, pm: 0, any: false };
145
+ for (const c of COMPARED) {
146
+ let items = always.filter((i) => c.kinds.includes(i.kind));
147
+ let actual = reading.categories.get(c.cat);
148
+ let note;
149
+ if (c.cat === "mcp tools") {
150
+ if (reading.mcpOnDemand || (!actual && reading.categories.has("mcp tools (deferred)")))
151
+ note = "Claude Code loads these tool schemas on demand (tool search); only the always-on part is compared";
152
+ if (!actual && items.length === 0)
153
+ continue;
154
+ }
155
+ if (!actual && !items.length)
156
+ continue;
157
+ const est = sumItems(items);
158
+ const unmeasured = items.filter((i) => !i.tokens).length;
159
+ if (unmeasured)
160
+ note = `${unmeasured} item(s) unmeasured by prehello: its number is a lower bound`;
161
+ if (!actual)
162
+ actual = { value: 0, pm: 0, raw: "absent" };
163
+ const row = mk(c.cat, actual, est ?? { est: 0, low: 0, high: 0 }, unmeasured, note ?? (reading.categories.has(c.cat) ? undefined : "/context shows no such row (treated as 0)"));
164
+ rows.push(row);
165
+ if (est) {
166
+ used.est += est.est;
167
+ used.low += est.low;
168
+ used.high += est.high;
169
+ }
170
+ used.actual += actual.value;
171
+ used.pm += actual.pm;
172
+ used.any = true;
173
+ items = [];
174
+ }
175
+ const total = mk("compared total", used.any ? { value: used.actual, pm: used.pm, raw: "" } : undefined, used.any ? used : null, rows.reduce((s, r) => s + r.unmeasuredItems, 0));
176
+ // per memory file, matched by path
177
+ const files = [];
178
+ const memItems = always.filter((i) => ["instructions", "rule", "memory"].includes(i.kind) && i.tokens);
179
+ for (const fr of reading.details.get("memory files") ?? []) {
180
+ if (!fr.path)
181
+ continue;
182
+ const p = norm(fr.path.startsWith("~") ? node_path_1.default.join(home, fr.path.slice(1)) : fr.path);
183
+ const hit = memItems.find((i) => norm(i.path) === p) ?? memItems.find((i) => p.endsWith("/" + norm(node_path_1.default.basename(i.path))) && norm(i.path).endsWith(p.split("/").slice(-2).join("/")));
184
+ if (hit)
185
+ files.push(mk(fr.path, fr.tokens, hit.tokens, 0));
186
+ else
187
+ files.push(mk(fr.path, fr.tokens, null, 0, "prehello did not find this file"));
188
+ }
189
+ const notEstimated = [];
190
+ for (const k of ["system prompt", "system tools", "messages"]) {
191
+ const v = reading.categories.get(k);
192
+ if (v)
193
+ notEstimated.push({ category: k, actual: v.value });
194
+ }
195
+ // implied ratio on markdown-like content: actual / raw o200k, where raw = est / factor
196
+ const f = (0, families_1.claudeCalibration)().byKind.markdown.factor * (a.profile.id === "claude-legacy" ? (0, families_1.claudeCalibration)().legacyRatio.value : 1);
197
+ const md = rows.filter((r) => r.category !== "mcp tools" && r.est && r.actual && r.unmeasuredItems === 0);
198
+ const mdEst = md.reduce((s, r) => s + (r.est ?? 0), 0);
199
+ const mdAct = md.reduce((s, r) => s + (r.actual ?? 0), 0);
200
+ const impliedMarkdownRatio = mdEst > 0 && mdAct > 0 ? Math.round(((mdAct * f) / mdEst) * 1000) / 1000 : null;
201
+ return { model: reading.model, rows, files, total, notEstimated, impliedMarkdownRatio, ignoredLines: reading.ignored };
202
+ }
203
+ const pctStr = (x) => (x === null ? "n/a" : `${x >= 0 ? "+" : ""}${x.toFixed(1)}%`);
204
+ const cell = (n) => (n === null ? "-" : (0, fmt_1.fmtTok)(n));
205
+ /** A memory-file label the reader can recognise: relative to the scanned folder when inside it, and never cut at the file name. */
206
+ function fileLabel(p, cwd) {
207
+ const variants = (x) => [x, x.replace(/^\/private(?=\/)/, ""), `/private${x}`];
208
+ for (const base of variants(cwd)) {
209
+ for (const cand of variants(p)) {
210
+ const rel = node_path_1.default.relative(base, cand);
211
+ if (rel && !rel.startsWith("..") && !node_path_1.default.isAbsolute(rel))
212
+ return (0, fmt_1.shortenLabel)((0, fmt_1.sanitize)(rel, 1000), 70);
213
+ }
214
+ }
215
+ return (0, fmt_1.shortenLabel)((0, fmt_1.sanitize)(p, 1000), 70);
216
+ }
217
+ function renderCalibration(c, r) {
218
+ const out = [];
219
+ out.push(`prehello ${r.tool.version} calibrate · Claude Code${c.model ? ` · /context model ${(0, fmt_1.sanitize)(c.model, 60)}` : ""}`);
220
+ out.push("Compares prehello's always-on estimate with your /context reading (offline; nothing is sent anywhere).", "");
221
+ const head = ["category", "/context", "prehello (low-high)", "error", "in band"];
222
+ const lines = [...c.rows, c.total].map((x) => [x.category, cell(x.actual), x.est === null ? "-" : `${(0, fmt_1.fmtTok)(x.est)} (${(0, fmt_1.fmtTok)(x.low)}-${(0, fmt_1.fmtTok)(x.high)})`, pctStr(x.errorPct), x.inBand === null ? "-" : x.inBand ? "yes" : "NO"]);
223
+ const w = head.map((h, i) => Math.max(h.length, ...lines.map((l) => l[i].length)));
224
+ const fmtRow = (l) => " " + l.map((s, i) => (i === 0 ? s.padEnd(w[i]) : s.padStart(w[i]))).join(" ");
225
+ out.push(fmtRow(head));
226
+ for (const l of lines)
227
+ out.push(fmtRow(l));
228
+ for (const x of c.rows)
229
+ if (x.note)
230
+ out.push(` note (${x.category}): ${x.note}`);
231
+ if (c.files.length) {
232
+ out.push("", "Memory files, one by one:");
233
+ for (const f of c.files)
234
+ out.push(` ${fileLabel(f.category, r.scan.cwd)}: /context ${cell(f.actual)}, prehello ${cell(f.est)}, error ${pctStr(f.errorPct)}${f.note ? ` (${f.note})` : ""}`);
235
+ }
236
+ if (c.notEstimated.length)
237
+ out.push("", `Not estimated by prehello (built into Claude Code): ${c.notEstimated.map((n) => `${n.category} ${(0, fmt_1.fmtTok)(n.actual)}`).join(", ")}.`);
238
+ if (c.total.errorPct !== null && Math.abs(c.total.errorPct) > 50)
239
+ out.push("", "The numbers are far apart. Run /context in a fresh Claude Code session started in the folder prehello scans (pass that folder as [path]), and paste its whole output.");
240
+ if (c.impliedMarkdownRatio !== null && c.impliedMarkdownRatio > 0.5 && c.impliedMarkdownRatio < 3)
241
+ out.push(`Implied Claude/o200k ratio for text here: ${c.impliedMarkdownRatio} (prehello uses ${(0, families_1.claudeCalibration)().byKind.markdown.factor}; includes listing wrappers).`);
242
+ out.push("/context rounds its numbers (2.8k = 2,750-2,849); 'in band' allows for that.");
243
+ if (c.ignoredLines)
244
+ out.push(`${c.ignoredLines} line(s) of the pasted text were not recognised and ignored.`);
245
+ out.push("Share it: prehello calibrate --file ctx.txt --save calibration.json, then attach the file to an issue (no paths or names in it).");
246
+ return out.join("\n") + "\n";
247
+ }
248
+ /** Redacted record: no paths, no names. */
249
+ function calibrationJson(c, r, a, redact) {
250
+ return {
251
+ tool: { name: "prehello", version: r.tool.version },
252
+ kind: "calibration",
253
+ generatedAt: r.generatedAt,
254
+ agent: a.id,
255
+ profile: a.profile.id,
256
+ model: c.model ?? null,
257
+ calibration: { measuredAt: (0, families_1.claudeCalibration)().measuredAt, markdownFactor: (0, families_1.claudeCalibration)().byKind.markdown.factor, jsonFactor: (0, families_1.claudeCalibration)().byKind.json.factor },
258
+ rows: c.rows,
259
+ total: c.total,
260
+ files: redact ? c.files.map(({ category: _c, ...rest }, i) => ({ category: `memory file ${i + 1}`, ...rest })) : c.files,
261
+ notEstimated: c.notEstimated,
262
+ impliedMarkdownRatio: c.impliedMarkdownRatio,
263
+ ignoredLines: c.ignoredLines,
264
+ };
265
+ }