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.
- package/CHANGELOG.md +46 -8
- package/README.md +73 -417
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/dist/adapters/common.js +3 -3
- package/dist/adapters/cursor.js +20 -2
- package/dist/adapters/gemini-cli.js +214 -0
- package/dist/adapters/github-copilot.js +249 -0
- package/dist/adapters/index.js +16 -0
- package/dist/adapters/skills.js +10 -0
- package/dist/agents.js +54 -0
- package/dist/calibrate.js +265 -0
- package/dist/cli.js +144 -7
- package/dist/context.js +4 -6
- package/dist/diff.js +182 -0
- package/dist/mcp/configs.js +1 -1
- package/dist/model.js +3 -6
- package/dist/render/card.js +5 -4
- package/dist/render/markdown.js +1 -1
- package/dist/render/sarif.js +2 -2
- package/dist/render/terminal.js +8 -19
- package/dist/safe-write.js +2 -0
- package/dist/scan.js +5 -11
- package/dist/text/imports.js +7 -2
- package/dist/tokens/families.js +48 -16
- package/dist/version.js +5 -0
- package/package.json +8 -4
- package/spec/calibration.json +33 -0
- package/spec/cursor.json +1 -1
- package/spec/gemini-cli.json +17 -0
- package/spec/github-copilot.json +14 -0
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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]}
|
|
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
|
|
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
|
-
|
|
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,
|