prehello 0.1.0 → 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.
@@ -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
+ }
package/dist/cli.js CHANGED
@@ -4,6 +4,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  return (mod && mod.__esModule) ? mod : { "default": mod };
5
5
  };
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
+ const diff_1 = require("./diff");
8
+ const calibrate_1 = require("./calibrate");
7
9
  const node_fs_1 = __importDefault(require("node:fs"));
8
10
  const node_os_1 = __importDefault(require("node:os"));
9
11
  const node_path_1 = __importDefault(require("node:path"));
@@ -27,16 +29,24 @@ Local-only, read-only scan. No network, no telemetry, no API key. Token counts a
27
29
  Usage:
28
30
  prehello [path] [options]
29
31
  prehello explain grade print the Context Grade rubric
32
+ prehello calibrate [path] --file <ctx.txt|->
33
+ compare prehello with Claude Code's /context output
34
+ (--json for JSON, --save <file> writes a redacted record)
35
+ prehello diff <base.json> <head.json> [--markdown[=file]] [--json] [--fail-above <n>]
36
+ token change between two snapshots (exit 2 over the limit)
30
37
 
31
38
  Options:
32
- --agent <list> comma list of: claude-code, cursor, codex (forces them "in use")
39
+ --agent <list> comma list of agents to force "in use":
40
+ ${model_1.ALL_AGENTS.join(", ")}
33
41
  --window <n|200k|1m> context window for percentages (default 200k)
34
42
  --json print the report as JSON to stdout (versioned schema)
35
43
  --json=<file> also write the JSON to <file>
44
+ --save <file> save a snapshot (the JSON report) for prehello diff
45
+ --badge <file> write a shields.io endpoint badge JSON (see docs/github-action.md)
36
46
  --redact strict JSON: no paths or names (share-safe)
37
47
  --card[=<file>] write a share card (no paths or file contents). The file extension picks the format:
38
48
  .svg (default name prehello-card.svg) or .png (prehello-card.png)
39
- write the file name with "=": --card=card.png (a space, "--card card.png", is not accepted)
49
+ name the file with "=" or a space: --card=card.png or --card card.png
40
50
  --markdown[=<file>] a shareable Markdown report (title, headline, tables, what to cut, measured vs not measured) for a
41
51
  GitHub issue / PR / README / Slack. Without a file it is printed to stdout INSTEAD of the terminal
42
52
  report (pipe it: prehello --markdown | pbcopy); with a file (.md or .markdown) it is written there
@@ -93,7 +103,7 @@ re-encodes redirected output as UTF-16). Works the same on Linux, macOS and Wind
93
103
  Exit codes: 0 ok · 1 error (incl. PNG engine missing) · 2 --max-tokens exceeded · 3 partial (some sources unreadable/skipped/budget hit; report still printed)
94
104
  (2 wins over 3 when both apply)
95
105
  `;
96
- const VALUE_FLAGS = new Set(["agent", "window", "tokenizer", "claude-profile", "card-theme", "card-size", "card-agent", "cwd", "mcp-catalog", "ceiling", "managed-dir", "system-root", "paste-user-rules", "card-headline", "card-format", "card-scale", "png-engine", "max-tokens", "scan-budget-mb", "scan-budget-ms"]);
106
+ const VALUE_FLAGS = new Set(["agent", "window", "tokenizer", "claude-profile", "card-theme", "card-size", "card-agent", "cwd", "mcp-catalog", "ceiling", "managed-dir", "system-root", "paste-user-rules", "card-headline", "card-format", "card-scale", "png-engine", "max-tokens", "scan-budget-mb", "scan-budget-ms", "file", "context-file", "save", "fail-above", "badge"]);
97
107
  const BOOL_FLAGS = new Set(["json", "card", "redact", "fix", "no-grade", "no-color", "quiet", "verbose", "about-specs", "version", "help", "no-redact", "no-bundled-catalog", "force", "card-anonymize", "anonymize", "markdown", "markdown-compact", "sarif", "ascii", "include-fixtures"]);
98
108
  function parseArgs(argv) {
99
109
  const flags = new Map();
@@ -134,6 +144,11 @@ function parseArgs(argv) {
134
144
  return `option --${name} does not take a value`;
135
145
  if (inline === "" && name === "json")
136
146
  return "--json= needs a file name (for stdout, write --json with no =)";
147
+ // --card card.png: take the next argument as the file name when it ends in .svg/.png and is not an existing directory
148
+ if (name === "card" && inline === undefined && i + 1 < argv.length && /\.(svg|png)$/i.test(argv[i + 1]) && !argv[i + 1].startsWith("-") && !(0, fs_safe_1.isDir)(node_path_1.default.resolve(argv[i + 1]))) {
149
+ flags.set(name, argv[++i]);
150
+ continue;
151
+ }
137
152
  flags.set(name, inline ?? true);
138
153
  }
139
154
  else
@@ -167,8 +182,37 @@ async function main() {
167
182
  return void process.stdout.write((0, terminal_1.renderRubric)());
168
183
  return fail("usage: prehello explain grade");
169
184
  }
170
- if (positional[0] === "ci" || positional[0] === "diff" || positional[0] === "calibrate")
171
- return fail(`'${positional[0]}' is not part of 0.1 (planned for 0.2)`);
185
+ const calibrating = positional[0] === "calibrate";
186
+ if (calibrating)
187
+ positional.shift();
188
+ let contextText;
189
+ if (calibrating) {
190
+ const dash = positional.indexOf("-");
191
+ if (dash >= 0)
192
+ positional.splice(dash, 1);
193
+ const src = (flags.get("file") ?? flags.get("context-file") ?? (dash >= 0 ? "-" : undefined));
194
+ if (src === undefined || src === "-") {
195
+ if (src === undefined && process.stdin.isTTY)
196
+ return fail("usage: prehello calibrate --file ctx.txt (paste the output of /context from Claude Code into ctx.txt), or pipe it: pbpaste | prehello calibrate -");
197
+ contextText = (0, platform_1.decodeUserText)(node_fs_1.default.readFileSync(0));
198
+ }
199
+ else {
200
+ try {
201
+ contextText = (0, platform_1.decodeUserText)(node_fs_1.default.readFileSync(src));
202
+ }
203
+ catch {
204
+ return fail(`cannot read /context text from ${src}`);
205
+ }
206
+ }
207
+ }
208
+ else if (flags.has("file") || flags.has("context-file"))
209
+ return fail("--file and --context-file belong to 'prehello calibrate'");
210
+ if (positional[0] === "diff")
211
+ return runDiff(positional.slice(1), flags);
212
+ if (flags.has("fail-above"))
213
+ return fail("--fail-above belongs to 'prehello diff'");
214
+ if (positional[0] === "ci")
215
+ return fail("there is no 'ci' command: use the GitHub Action (docs/github-action.md), or prehello --save + prehello diff");
172
216
  if (positional.length > 1)
173
217
  return fail("at most one path argument is supported");
174
218
  const env = process.env;
@@ -176,7 +220,7 @@ async function main() {
176
220
  const cwdAbs = node_path_1.default.resolve(target);
177
221
  if (!(0, fs_safe_1.isDir)(cwdAbs)) {
178
222
  if (flags.get("card") === true && positional[0] && /\.(svg|png)$/i.test(positional[0]))
179
- return fail(`not a directory: ${target}. To name the card file write --card=${positional[0]} (with "=", no space)`);
223
+ return fail(`not a directory: ${target}. To name the card file write --card=${positional[0]}`);
180
224
  return fail(`not a directory: ${target}`);
181
225
  }
182
226
  const cwd = (0, fs_safe_1.realpathOrSelf)(cwdAbs);
@@ -204,9 +248,14 @@ async function main() {
204
248
  return fail("--agent needs at least one of: " + model_1.ALL_AGENTS.join(", "));
205
249
  const bad = list.filter((x) => !model_1.ALL_AGENTS.includes(x));
206
250
  if (bad.length)
207
- return fail(`unknown agent '${bad[0]}' (supported in 0.1: ${model_1.ALL_AGENTS.join(", ")})`);
251
+ return fail(`unknown agent '${bad[0]}' (supported: ${model_1.ALL_AGENTS.join(", ")})`);
208
252
  agents = list;
209
253
  }
254
+ if (calibrating) {
255
+ if (agents && (agents.length !== 1 || agents[0] !== "claude-code"))
256
+ return fail("calibrate reads Claude Code's /context; Codex and Cursor have no per-category reading to compare against (yet)");
257
+ agents = ["claude-code"];
258
+ }
210
259
  const cardTheme = String(flags.get("card-theme") ?? "dark");
211
260
  const cardSize = String(flags.get("card-size") ?? "og");
212
261
  if (!["dark", "light"].includes(cardTheme))
@@ -354,6 +403,32 @@ async function main() {
354
403
  return fail(`could not write ${what} to ${file}: ${(0, safe_write_1.describeWriteError)(e.code)}`);
355
404
  }
356
405
  };
406
+ if (calibrating) {
407
+ let reading;
408
+ try {
409
+ reading = (0, calibrate_1.parseContext)(contextText ?? "");
410
+ }
411
+ catch (e) {
412
+ if (e instanceof calibrate_1.ContextParseError)
413
+ return fail(e.message);
414
+ throw e;
415
+ }
416
+ const a = report.agents.find((x) => x.id === "claude-code");
417
+ if (!a)
418
+ return fail("no Claude Code report to compare against");
419
+ const cal = (0, calibrate_1.compare)(reading, a, home);
420
+ if (flags.has("save"))
421
+ write(String(flags.get("save")), JSON.stringify((0, calibrate_1.calibrationJson)(cal, report, a, true), null, 2) + "\n", "calibration", "json");
422
+ if (process.exitCode)
423
+ return;
424
+ if (flags.get("json") === true)
425
+ process.stdout.write(JSON.stringify((0, calibrate_1.calibrationJson)(cal, report, a, flags.has("redact")), null, 2) + "\n");
426
+ else
427
+ process.stdout.write((0, calibrate_1.renderCalibration)(cal, report));
428
+ if (flags.has("save"))
429
+ process.stderr.write(`Calibration written to ${flags.get("save")} (no paths or names).\n`);
430
+ return;
431
+ }
357
432
  const strict = flags.has("redact");
358
433
  const jsonFlag = flags.get("json");
359
434
  const color = !flags.has("no-color") && !env.NO_COLOR && env.TERM !== "dumb" && !!process.stdout.isTTY;
@@ -363,6 +438,13 @@ async function main() {
363
438
  const jsonText = jsonFlag !== undefined ? JSON.stringify((0, json_1.toJson)(report, { cwd, home }, strict), null, 2) + "\n" : "";
364
439
  if (typeof jsonFlag === "string")
365
440
  write(jsonFlag, jsonText, "JSON", "json");
441
+ if (flags.has("save"))
442
+ write(String(flags.get("save")), JSON.stringify((0, json_1.toJson)(report, { cwd, home }, strict), null, 2) + "\n", "snapshot", "json");
443
+ if (flags.has("badge")) {
444
+ // a CI runner has no agent installed: fall back to the agent with the most always-on context in the repository
445
+ const ba = (0, card_1.pickCardAgent)(report, flags.get("card-agent")) ?? [...report.agents].filter((a) => a.items.length).sort((x, y) => y.totals.always.est - x.totals.always.est)[0] ?? null;
446
+ write(String(flags.get("badge")), JSON.stringify(ba ? (0, diff_1.badgeJson)(ba.name, ba.totals.always.measuredItems || !ba.totals.always.unmeasuredItems ? ba.totals.always.est : null, ba.lowerBound) : (0, diff_1.badgeJson)("no agent", null, false)) + "\n", "badge", "badge");
447
+ }
366
448
  let cardMsg = "";
367
449
  let cardFailed = false;
368
450
  const anonymizeNames = flags.has("card-anonymize") || flags.has("anonymize") || strict;
@@ -452,3 +534,58 @@ main().catch((e) => {
452
534
  process.stderr.write(`prehello: internal error: ${e.message}\n`);
453
535
  process.exitCode = 1;
454
536
  });
537
+ function parseTokenCount(v) {
538
+ const m = /^(\d+(?:\.\d+)?)([kKmM]?)$/.exec(v.trim());
539
+ if (!m)
540
+ return undefined;
541
+ return Math.round(parseFloat(m[1]) * (m[2].toLowerCase() === "k" ? 1000 : m[2].toLowerCase() === "m" ? 1_000_000 : 1));
542
+ }
543
+ /** `prehello diff base.json head.json [--markdown[=file]] [--json] [--fail-above N]`. Reads two saved reports; no scan, no git. */
544
+ function runDiff(files, flags) {
545
+ if (files.length !== 2)
546
+ return fail("usage: prehello diff <base.json> <head.json> [--markdown] [--json] [--fail-above <tokens>] (make each file with: prehello --save <file>)");
547
+ let threshold;
548
+ if (flags.has("fail-above")) {
549
+ threshold = parseTokenCount(String(flags.get("fail-above")));
550
+ if (threshold === undefined)
551
+ return fail("--fail-above needs a token count, e.g. 2000 or 2k");
552
+ }
553
+ const snaps = [];
554
+ for (const f of files) {
555
+ let text;
556
+ try {
557
+ text = node_fs_1.default.readFileSync(f === "-" ? 0 : f, "utf8");
558
+ }
559
+ catch {
560
+ return fail(`cannot read ${f}`);
561
+ }
562
+ try {
563
+ snaps.push((0, diff_1.parseSnapshot)(text, f));
564
+ }
565
+ catch (e) {
566
+ if (e instanceof diff_1.DiffInputError)
567
+ return fail(e.message);
568
+ throw e;
569
+ }
570
+ }
571
+ const d = (0, diff_1.diffSnapshots)(snaps[0], snaps[1]);
572
+ const md = flags.get("markdown");
573
+ if (typeof md === "string") {
574
+ try {
575
+ (0, safe_write_1.safeWrite)(node_path_1.default.resolve(md), (0, diff_1.renderDiffMarkdown)(d, { threshold }), { kind: "md", force: flags.has("force") });
576
+ }
577
+ catch (e) {
578
+ return fail(`diff Markdown not written to ${md}: ${e.message}`);
579
+ }
580
+ }
581
+ if (flags.get("json") === true)
582
+ process.stdout.write(JSON.stringify((0, diff_1.diffJson)(d, threshold), null, 2) + "\n");
583
+ else if (md === true)
584
+ process.stdout.write((0, diff_1.renderDiffMarkdown)(d, { threshold }));
585
+ else
586
+ process.stdout.write((0, diff_1.renderDiffTerminal)(d));
587
+ const over = threshold !== undefined ? (0, diff_1.overThreshold)(d, threshold) : [];
588
+ for (const a of over)
589
+ process.stderr.write(`prehello: ${a.name} always-on context grew by ${(0, fmt_1.fmtTok)(a.delta)} tokens (estimate), above --fail-above ${(0, fmt_1.fmtTok)(threshold)}\n`);
590
+ process.exitCode = over.length ? 2 : 0;
591
+ }
package/dist/context.js CHANGED
@@ -9,6 +9,7 @@ exports.lineCount = lineCount;
9
9
  exports.shingles = shingles;
10
10
  exports.jaccard = jaccard;
11
11
  const node_path_1 = __importDefault(require("node:path"));
12
+ const agents_1 = require("./agents");
12
13
  const families_1 = require("./tokens/families");
13
14
  const fs_safe_1 = require("./fs-safe");
14
15
  const redact_1 = require("./redact");
@@ -31,7 +32,7 @@ function readFailWhy(reason, verb) {
31
32
  }
32
33
  /** Names (files) and "dir/" entries looked for in every directory of the bounded project walk. */
33
34
  exports.WALK_CANDIDATES = [
34
- "CLAUDE.md", "CLAUDE.local.md", ".claude/CLAUDE.md", "AGENTS.md", "AGENTS.override.md",
35
+ "CLAUDE.md", "CLAUDE.local.md", ".claude/CLAUDE.md", "AGENTS.md", "AGENTS.override.md", "GEMINI.md",
35
36
  ".cursor/rules/", ".cursor/skills/", ".agents/skills/", ".claude/skills/",
36
37
  ];
37
38
  class Ctx {
@@ -81,10 +82,7 @@ class Ctx {
81
82
  return false;
82
83
  if (this.gitRoot && this.gitRoot !== this.home)
83
84
  return true;
84
- const top = ["CLAUDE.md", "AGENTS.md", "CLAUDE.local.md", ".cursorrules", ".mcp.json"];
85
- if (top.some((f) => (0, fs_safe_1.isFile)(node_path_1.default.join(this.cwd, f))))
86
- return true;
87
- return [".claude", ".cursor", ".codex", ".agents"].some((d) => (0, fs_safe_1.isDir)(node_path_1.default.join(this.cwd, d)));
85
+ return agents_1.AGENTS.flatMap((a) => a.projectMarkers).some((m) => m.endsWith("/") ? (0, fs_safe_1.isDir)(node_path_1.default.join(this.cwd, m.slice(0, -1))) : (0, fs_safe_1.isFile)(node_path_1.default.join(this.cwd, m)));
88
86
  }
89
87
  get cwd() {
90
88
  return this.opts.cwd;
@@ -243,7 +241,7 @@ class Ctx {
243
241
  kind: a.kind,
244
242
  scope: a.scope,
245
243
  loadMode: a.loadMode,
246
- tokens: a.raw === null ? null : prof.apply(a.raw),
244
+ tokens: a.raw === null ? null : prof.apply(a.raw, a.kind === "mcp-server" ? "json" : "markdown"),
247
245
  ...(a.raw === null ? { unmeasuredReason: a.unmeasuredReason ?? "not measurable" } : {}),
248
246
  chars: a.chars ?? 0,
249
247
  lines: a.lines ?? 0,