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/dist/diff.js ADDED
@@ -0,0 +1,182 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DiffInputError = exports.DIFF_MARKER = void 0;
4
+ exports.parseSnapshot = parseSnapshot;
5
+ exports.diffSnapshots = diffSnapshots;
6
+ exports.signedTok = signedTok;
7
+ exports.headline = headline;
8
+ exports.overThreshold = overThreshold;
9
+ exports.renderDiffTerminal = renderDiffTerminal;
10
+ exports.renderDiffMarkdown = renderDiffMarkdown;
11
+ exports.diffJson = diffJson;
12
+ exports.badgeJson = badgeJson;
13
+ /**
14
+ * `prehello diff base.json head.json`: compare two saved reports (from `prehello --save` / `--json=`). Offline, no git:
15
+ * the GitHub Action (action.yml) produces the two snapshots. Item names come from the scanned repository, so every one
16
+ * goes through mdCode / sanitize before it is printed.
17
+ */
18
+ const fmt_1 = require("./util/fmt");
19
+ const markdown_1 = require("./render/markdown");
20
+ const card_1 = require("./render/card");
21
+ /** Starts with MD_MARKER_PREFIX (so --markdown=<file> can overwrite its own output) and is the sticky-comment marker the Action looks for. */
22
+ exports.DIFF_MARKER = "<!-- generated by prehello diff -->";
23
+ class DiffInputError extends Error {
24
+ }
25
+ exports.DiffInputError = DiffInputError;
26
+ function parseSnapshot(text, what) {
27
+ let j;
28
+ try {
29
+ j = JSON.parse(text.replace(/^\uFEFF/, ""));
30
+ }
31
+ catch {
32
+ throw new DiffInputError(`${what} is not JSON; create it with: prehello --save ${what}`);
33
+ }
34
+ const s = j;
35
+ if (!s || typeof s !== "object" || s.tool?.name !== "prehello" || !Array.isArray(s.agents) || s.schemaVersion === undefined) {
36
+ throw new DiffInputError(`${what} is not a prehello report; create it with: prehello --save ${what}`);
37
+ }
38
+ return s;
39
+ }
40
+ const MAX_ITEMS = 10;
41
+ const itemKey = (i) => `${i.kind}|${i.loadMode}|${i.path ?? i.label ?? i.id ?? ""}`;
42
+ /** Counted when installed or when the repository has files for it (a CI runner has no agent installed). */
43
+ const active = (a) => !!a && (!!a.detected || (a.items?.length ?? 0) > 0);
44
+ function diffSnapshots(base, head) {
45
+ const notes = [];
46
+ if (String(base.schemaVersion) !== String(head.schemaVersion))
47
+ notes.push(`report schema differs (base ${base.schemaVersion}, head ${head.schemaVersion}); numbers may not be comparable`);
48
+ if (base.scan?.tokenizer?.method !== head.scan?.tokenizer?.method)
49
+ notes.push("base and head were counted with different tokenizers");
50
+ if (base.tool.version !== head.tool.version)
51
+ notes.push(`made by different prehello versions (base ${(0, fmt_1.sanitize)(base.tool.version, 20)}, head ${(0, fmt_1.sanitize)(head.tool.version, 20)}); part of the change may be the estimator`);
52
+ const ids = [...new Set([...base.agents, ...head.agents].map((a) => a.id))];
53
+ const agents = [];
54
+ for (const id of ids) {
55
+ const b = base.agents.find((a) => a.id === id);
56
+ const h = head.agents.find((a) => a.id === id);
57
+ if (!active(b) && !active(h))
58
+ continue;
59
+ const bt = active(b) ? b.totals.always.est : null;
60
+ const ht = active(h) ? h.totals.always.est : null;
61
+ const delta = (ht ?? 0) - (bt ?? 0);
62
+ const deferredDelta = (active(h) ? h.totals.deferred?.est ?? 0 : 0) - (active(b) ? b.totals.deferred?.est ?? 0 : 0);
63
+ if (b?.profile?.id && h?.profile?.id && b.profile.id !== h.profile.id)
64
+ notes.push(`${(0, fmt_1.sanitize)(h.name, 40)}: estimate profile changed (${(0, fmt_1.sanitize)(b.profile.id, 30)} -> ${(0, fmt_1.sanitize)(h.profile.id, 30)})`);
65
+ const bi = new Map((active(b) ? b.items ?? [] : []).filter((i) => i.loadMode === "always").map((i) => [itemKey(i), i]));
66
+ const hi = new Map((active(h) ? h.items ?? [] : []).filter((i) => i.loadMode === "always").map((i) => [itemKey(i), i]));
67
+ const changes = [];
68
+ for (const k of new Set([...bi.keys(), ...hi.keys()])) {
69
+ const x = bi.get(k);
70
+ const y = hi.get(k);
71
+ const bv = x?.tokens?.est ?? null;
72
+ const hv = y?.tokens?.est ?? null;
73
+ const d = Math.round((hv ?? 0) - (bv ?? 0));
74
+ if (x && y && d === 0)
75
+ continue;
76
+ const it = (y ?? x);
77
+ changes.push({ key: k, label: it.path ?? it.label ?? it.kind, kind: it.kind, base: x ? bv : null, head: y ? hv : null, delta: d });
78
+ }
79
+ changes.sort((p, q) => Math.abs(q.delta) - Math.abs(p.delta) || p.label.localeCompare(q.label));
80
+ agents.push({
81
+ id,
82
+ name: (h ?? b).name,
83
+ base: bt,
84
+ head: ht,
85
+ delta: Math.round(delta),
86
+ deltaPct: bt && bt >= 100 ? Math.round((delta / bt) * 1000) / 10 : null,
87
+ deferredDelta: Math.round(deferredDelta),
88
+ lowerBound: !!(b?.alwaysIsLowerBound || h?.alwaysIsLowerBound),
89
+ changes: changes.slice(0, MAX_ITEMS),
90
+ moreChanges: Math.max(0, changes.length - MAX_ITEMS),
91
+ });
92
+ }
93
+ return { base, head, agents, notes };
94
+ }
95
+ function signedTok(n) {
96
+ if (Math.round(n) === 0)
97
+ return "±0";
98
+ return (n > 0 ? "+" : "−") + (0, fmt_1.fmtTok)(Math.abs(n));
99
+ }
100
+ function headline(a) {
101
+ if (a.base === null)
102
+ return `${a.name}: new, ~${(0, fmt_1.fmtTok)(a.head ?? 0)} tokens before hello`;
103
+ if (a.head === null)
104
+ return `${a.name}: no longer detected (was ~${(0, fmt_1.fmtTok)(a.base)} tokens before hello)`;
105
+ if (a.delta === 0)
106
+ return `${a.name}: no change, ~${(0, fmt_1.fmtTok)(a.head)} tokens before hello`;
107
+ return `${a.name}: ${signedTok(a.delta)} tokens before hello (~${(0, fmt_1.fmtTok)(a.base)} → ~${(0, fmt_1.fmtTok)(a.head)}${a.deltaPct !== null ? `, ${a.deltaPct > 0 ? "+" : ""}${a.deltaPct}%` : ""})`;
108
+ }
109
+ const cell = (n) => (n === null ? "-" : (0, fmt_1.fmtTok)(n));
110
+ const chg = (c) => (c.base === null ? "added" : c.head === null ? "removed" : "changed");
111
+ function overThreshold(d, max) {
112
+ return d.agents.filter((a) => a.delta > max);
113
+ }
114
+ function renderDiffTerminal(d) {
115
+ const out = ["prehello diff (estimates; always-on context before your first message)", ""];
116
+ if (!d.agents.length)
117
+ out.push("No agent context found in either report.");
118
+ for (const a of d.agents) {
119
+ out.push((0, fmt_1.sanitize)(headline(a), 200));
120
+ for (const c of a.changes)
121
+ out.push(` ${signedTok(c.delta).padStart(7)} ${chg(c).padEnd(7)} ${(0, fmt_1.sanitize)(c.label, 80)}`);
122
+ if (a.moreChanges)
123
+ out.push(` … ${a.moreChanges} more changed item(s)`);
124
+ if (a.deferredDelta)
125
+ out.push(` on demand (skill bodies, deferred tools): ${signedTok(a.deferredDelta)}`);
126
+ }
127
+ for (const n of d.notes)
128
+ out.push(`note: ${n}`);
129
+ return out.join("\n") + "\n";
130
+ }
131
+ function renderDiffMarkdown(d, opts = {}) {
132
+ const over = opts.threshold !== undefined ? overThreshold(d, opts.threshold) : [];
133
+ const out = [exports.DIFF_MARKER, "### prehello: context before hello", ""];
134
+ const moved = d.agents.filter((a) => a.delta !== 0 || a.base === null || a.head === null);
135
+ if (!d.agents.length)
136
+ out.push("No agent context (CLAUDE.md, AGENTS.md, rules, skills, MCP) found in this repository.");
137
+ else if (!moved.length)
138
+ out.push(`No change in always-on context. ${d.agents.map((a) => `${(0, markdown_1.mdCode)(a.name)} ~${(0, fmt_1.fmtTok)(a.head ?? 0)} tokens`).join(", ")}.`);
139
+ else {
140
+ out.push("| Agent | Base | Head | Change |", "|---|---:|---:|---:|");
141
+ for (const a of d.agents)
142
+ out.push(`| ${(0, markdown_1.mdCode)(a.name, true)} | ${cell(a.base)} | ${cell(a.head)}${a.lowerBound ? " (at least)" : ""} | **${signedTok(a.delta)}**${a.deltaPct !== null && a.delta ? ` (${a.deltaPct > 0 ? "+" : ""}${a.deltaPct}%)` : ""} |`);
143
+ out.push("");
144
+ for (const a of d.agents.filter((x) => x.changes.length)) {
145
+ out.push(`<details><summary>${summaryText(a.name)}: ${a.changes.length + a.moreChanges} changed item(s)</summary>`, "", "| Change | Item | Base | Head |", "|---:|---|---:|---:|");
146
+ for (const c of a.changes)
147
+ out.push(`| ${signedTok(c.delta)} | ${chg(c)} ${(0, markdown_1.mdCode)(c.label, true)} | ${cell(c.base)} | ${cell(c.head)} |`);
148
+ if (a.moreChanges)
149
+ out.push(`| | … ${a.moreChanges} more | | |`);
150
+ out.push("", "</details>", "");
151
+ }
152
+ }
153
+ if (over.length)
154
+ out.push("", `**Over the limit:** ${over.map((a) => `${(0, markdown_1.mdCode)(a.name)} grew ${signedTok(a.delta)} (limit +${(0, fmt_1.fmtTok)(opts.threshold)})`).join(", ")}.`);
155
+ for (const n of d.notes)
156
+ out.push("", `_Note: ${(0, fmt_1.sanitize)(n, 200).replace(/[_*`<>[\]]/g, " ")}_`);
157
+ out.push("", `<sub>Estimates (o200k-based, per-agent factors), not exact for any model. Only this repository's files are scanned. [prehello](https://${card_1.REPO_URL}) ${(0, fmt_1.sanitize)(d.head.tool.version, 20)}</sub>`);
158
+ return out.join("\n") + "\n";
159
+ }
160
+ function diffJson(d, threshold) {
161
+ return {
162
+ tool: { name: "prehello", version: d.head.tool.version },
163
+ kind: "diff",
164
+ agents: d.agents,
165
+ notes: d.notes,
166
+ ...(threshold !== undefined ? { threshold, overThreshold: overThreshold(d, threshold).map((a) => a.id) } : {}),
167
+ };
168
+ }
169
+ /** shields.io endpoint JSON (https://shields.io/badges/endpoint-badge). */
170
+ function badgeJson(name, est, lowerBound) {
171
+ const color = est === null ? "lightgrey" : est < 5000 ? "brightgreen" : est < 15000 ? "green" : est < 30000 ? "yellow" : est < 60000 ? "orange" : "red";
172
+ return { schemaVersion: 1, label: "context before hello", message: est === null ? "unmeasured" : `${lowerBound ? "≥" : "~"}${(0, fmt_1.fmtTok)(est)} tokens (${name})`, color };
173
+ }
174
+ /** Plain text for inside <summary>: GitHub does not render Markdown there, so no backticks; HTML-escaped. */
175
+ function summaryText(name) {
176
+ let t = name.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufeff]+/g, " ").replace(/\s+/g, " ").trim();
177
+ if (t.length > 80)
178
+ t = t.slice(0, 77) + "...";
179
+ if (!t)
180
+ t = "(unnamed)";
181
+ return t.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/@/g, "&#64;").replace(/#/g, "&#35;");
182
+ }
@@ -31,7 +31,7 @@ function parseMcpMap(map, warn) {
31
31
  if (!isObj(v))
32
32
  continue;
33
33
  const t = typeof v.type === "string" ? v.type : typeof v.transport === "string" ? v.transport : undefined;
34
- const transport = t === "http" || t === "streamable-http" ? "http" : t === "sse" ? "sse" : t === "stdio" || typeof v.command === "string" ? "stdio" : typeof v.url === "string" ? "http" : "unknown";
34
+ const transport = t === "http" || t === "streamable-http" ? "http" : t === "sse" ? "sse" : t === "stdio" || t === "local" || typeof v.command === "string" ? "stdio" : typeof v.url === "string" || typeof v.httpUrl === "string" ? "http" : "unknown";
35
35
  out.push({ name: (0, fmt_1.sanitize)(name, 60), transport, pkg: guessPkg(v), disabled: v.disabled === true || v.enabled === false });
36
36
  }
37
37
  return out;
package/dist/model.js CHANGED
@@ -2,10 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.LOAD_MODES = exports.ALL_AGENTS = exports.AGENT_NAMES = exports.SCHEMA_VERSION = void 0;
4
4
  exports.SCHEMA_VERSION = 1;
5
- exports.AGENT_NAMES = {
6
- "claude-code": "Claude Code",
7
- cursor: "Cursor",
8
- codex: "Codex",
9
- };
10
- exports.ALL_AGENTS = ["claude-code", "cursor", "codex"];
5
+ var agents_1 = require("./agents");
6
+ Object.defineProperty(exports, "AGENT_NAMES", { enumerable: true, get: function () { return agents_1.AGENT_NAMES; } });
7
+ Object.defineProperty(exports, "ALL_AGENTS", { enumerable: true, get: function () { return agents_1.ALL_AGENTS; } });
11
8
  exports.LOAD_MODES = ["always", "conditional", "deferred", "on-demand", "unknown"];
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.CARD_FONT = exports.THEMES = exports.AT_LEAST = exports.MIN_CUT_HEADLINE_TOKENS = void 0;
3
+ exports.CARD_FONT = exports.THEMES = exports.AT_LEAST = exports.MIN_CUT_HEADLINE_TOKENS = exports.REPO_URL = void 0;
4
4
  exports.buildHeadline = buildHeadline;
5
5
  exports.assertHeadline = assertHeadline;
6
6
  exports.pickCardAgent = pickCardAgent;
@@ -10,6 +10,8 @@ const fmt_1 = require("../util/fmt");
10
10
  const treemap_1 = require("./treemap");
11
11
  const savings_1 = require("../analyze/savings");
12
12
  const secrets_1 = require("../util/secrets");
13
+ const agents_1 = require("../agents");
14
+ exports.REPO_URL = "github.com/PIYUSH-KUMAR1809/prehello";
13
15
  /** A saving smaller than this (estimated tokens) is noise, not a headline. Same threshold as the cut list itself. */
14
16
  exports.MIN_CUT_HEADLINE_TOKENS = 500;
15
17
  /** Wording of the lower-bound marker. Words, not a glyph: "≥" is missing from several fallback fonts used by SVG renderers. */
@@ -185,7 +187,7 @@ function renderCard(r, a, o) {
185
187
  // footer: wrap on segment boundaries, anchored to the bottom edge
186
188
  const unvalidated = a.profile.id.startsWith("claude") || a.profile.id === "cursor-mixed" || a.profile.id === "heuristic";
187
189
  const footSize = og ? 17 : 20;
188
- const footLines = wrapSegments(["prehello", "local-only scan", `estimate band: ${a.profile.band}${unvalidated ? " (unvalidated)" : ""}`, `window ${(0, fmt_1.fmtWindow)(r.scan.window)}`, ...(r.partial ? ["PARTIAL scan"] : [])], maxCharsFor(innerW, footSize));
190
+ const footLines = wrapSegments(["npx prehello", exports.REPO_URL, "local-only scan", `estimate band: ${a.profile.band}${unvalidated ? " (unvalidated)" : ""}`, `window ${(0, fmt_1.fmtWindow)(r.scan.window)}`, ...(r.partial ? ["PARTIAL scan"] : [])], maxCharsFor(innerW, footSize));
189
191
  const footLineH = footSize + 6;
190
192
  const footTop = H - (og ? 30 : 44) - (footLines.length - 1) * footLineH;
191
193
  // Layout flows upward from the footer so a wrapped (2-3 line) footer can never collide with the chips or the treemap.
@@ -285,8 +287,7 @@ function renderCard(r, a, o) {
285
287
  const unm = a.totals.always.unmeasuredItems + a.totals.deferred.unmeasuredItems;
286
288
  if (unm)
287
289
  chips.push(`unmeasured: ${unm} item${unm > 1 ? "s" : ""}`);
288
- if (a.id === "cursor")
289
- chips.push("unmeasured: User Rules");
290
+ chips.push(...((0, agents_1.agentMeta)(a.id).cardChips ?? []));
290
291
  if (r.partial)
291
292
  chips.unshift("partial scan");
292
293
  let cx = pad;
@@ -124,7 +124,7 @@ function headlineOf(r, o) {
124
124
  function footer(r, shown) {
125
125
  const unvalidated = shown.some((a) => a.profile.id.startsWith("claude") || a.profile.id === "cursor-mixed" || a.profile.id === "heuristic");
126
126
  const bands = [...new Set(shown.filter((a) => a.totals.always.measuredItems).map((a) => `${a.name} ${ascii(a.profile.band)}`))];
127
- return `_Generated by prehello ${r.tool.version} on ${r.generatedAt.slice(0, 10)}. Local-only scan: no paths or file contents are included. All token counts are estimates (${r.scan.tokenizer.method === "bpe-o200k" ? "o200k BPE with per-agent scaling priors" : "chars/3.5 heuristic"}${bands.length ? "; band " + bands.join(", ") : ""}${unvalidated ? ", unvalidated" : ""}), not exact for any model. Window ${(0, fmt_1.fmtWindow)(r.scan.window)}.${r.partial ? " PARTIAL scan: every total is a lower bound." : ""}_`;
127
+ return `_Generated by prehello ${r.tool.version} on ${r.generatedAt.slice(0, 10)}. Local-only scan: no paths or file contents are included. All token counts are estimates (${r.scan.tokenizer.method === "bpe-o200k" ? "o200k BPE with per-agent scaling factors" : "chars/3.5 heuristic"}${bands.length ? "; band " + bands.join(", ") : ""}${unvalidated ? ", unvalidated" : ""}), not exact for any model. Window ${(0, fmt_1.fmtWindow)(r.scan.window)}.${r.partial ? " PARTIAL scan: every total is a lower bound." : ""} Try it: \`npx prehello\` (https://${card_1.REPO_URL})._`;
128
128
  }
129
129
  function renderMarkdown(r, o) {
130
130
  const shown = r.agents.filter((a) => !a.presentInRepoOnly);
@@ -337,7 +337,7 @@ function toSarif(r, o) {
337
337
  name: d.name,
338
338
  shortDescription: { text: d.short },
339
339
  fullDescription: { text: d.full },
340
- help: { text: `${d.help}\nSee the SARIF section of the prehello README for the rule list.`, markdown: `${d.help}\n\nSee the SARIF section of the prehello README for the rule list.` },
340
+ help: { text: `${d.help}\nThe rule list is in docs/REFERENCE.md (SARIF) in the prehello repository.`, markdown: `${d.help}\n\nThe rule list is in docs/REFERENCE.md (SARIF) in the prehello repository.` },
341
341
  defaultConfiguration: { level: d.level },
342
342
  properties: { tags: ["context", "tokens", "coding-agents"], precision: d.precision },
343
343
  })),
@@ -357,7 +357,7 @@ const round1 = (x) => Math.round(x * 10) / 10;
357
357
  function runProps(r, shown, o, noFiles) {
358
358
  return {
359
359
  prehello: {
360
- note: "All token numbers are estimates (o200k BPE with per-agent scaling priors), not exact for any model. An unmeasured item has no number, never 0. A total marked isLowerBound is 'at least'.",
360
+ note: "All token numbers are estimates (o200k BPE with per-agent scaling factors), not exact for any model. An unmeasured item has no number, never 0. A total marked isLowerBound is 'at least'.",
361
361
  tokenizer: r.scan.tokenizer.method,
362
362
  claudeProfile: r.scan.claudeProfile,
363
363
  windowTokens: r.scan.window,
@@ -5,6 +5,7 @@ exports.headlineAgents = headlineAgents;
5
5
  exports.renderTerminal = renderTerminal;
6
6
  exports.renderRubric = renderRubric;
7
7
  const fmt_1 = require("../util/fmt");
8
+ const agents_1 = require("../agents");
8
9
  const redact_1 = require("../redact");
9
10
  const grade_1 = require("../analyze/grade");
10
11
  const MODE_TITLES = {
@@ -14,19 +15,6 @@ const MODE_TITLES = {
14
15
  "on-demand": "ON-DEMAND (only if invoked)",
15
16
  unknown: "UNKNOWN LOAD BEHAVIOUR",
16
17
  };
17
- const LOOKED_FOR = [
18
- ["Claude Code", "~/.claude, ~/.claude.json, `claude` on PATH; CLAUDE.md, .claude/, .mcp.json in the project"],
19
- ["Cursor", "~/.cursor, Cursor.app, `cursor` on PATH; .cursor/, .cursorrules, AGENTS.md in the project"],
20
- ["Codex", "~/.codex, `codex` on PATH; AGENTS.md, .codex/, .agents/skills in the project"],
21
- ];
22
- const WHERE_LOOKED = {
23
- "claude-code": "CLAUDE.md (project, parent folders, ~/.claude), .claude/{rules,skills,commands}, plugins, .mcp.json, ~/.claude.json",
24
- cursor: ".cursor/rules (incl. nested), AGENTS.md, .cursorrules, skills folders, ~/.cursor/{skills,plugins,mcp.json}, .cursor/mcp.json",
25
- codex: "AGENTS.md chain, ~/.codex/{AGENTS.md,config.toml}, .agents/skills, ~/.agents/skills, .codex/config.toml",
26
- };
27
- const AGENT_HINT = {
28
- cursor: "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.",
29
- };
30
18
  const KIND_NOUN = {
31
19
  instructions: ["instruction file", "instruction files"],
32
20
  rule: ["rule", "rules"],
@@ -170,14 +158,14 @@ function renderTerminal(r, o) {
170
158
  const win = (0, fmt_1.fmtWindow)(r.scan.window);
171
159
  const shown = headlineAgents(r);
172
160
  out.push(`${p.bold("prehello " + r.tool.version)} · ${dp(r.scan.cwd) === "." ? "this folder" : (0, fmt_1.shortenLeft)((0, fmt_1.sanitize)(dp(r.scan.cwd), 400), 60)} + user config · window ${win}`);
173
- out.push(...(0, fmt_1.wrap)(`Token counts are ESTIMATES (${r.scan.tokenizer.method === "bpe-o200k" ? "o200k BPE with per-agent scaling priors" : "chars/3.5 heuristic"}), shown with ~. Not exact for any model.`, W).map((l) => p.dim(l)));
161
+ out.push(...(0, fmt_1.wrap)(`Token counts are ESTIMATES (${r.scan.tokenizer.method === "bpe-o200k" ? "o200k BPE with per-agent scaling factors" : "chars/3.5 heuristic"}), shown with ~. Not exact for any model.`, W).map((l) => p.dim(l)));
174
162
  out.push("");
175
163
  if (r.agents.length === 0) {
176
164
  out.push(`No coding agents detected on this machine, and no agent files found in ${dp(r.scan.cwd) === "." ? "this folder" : dp(r.scan.cwd)}.`);
177
165
  out.push("");
178
166
  out.push(p.bold("prehello looked for"));
179
- for (const [n, t] of LOOKED_FOR)
180
- out.push(...(0, fmt_1.wrap)(t, W, ` ${n.padEnd(12)} `, " ".repeat(15)));
167
+ for (const m of agents_1.AGENTS)
168
+ out.push(...(0, fmt_1.wrap)(m.lookedFor, W, ` ${m.name.padEnd(15)} `, " ".repeat(18)));
181
169
  out.push("");
182
170
  out.push(p.bold("Try"));
183
171
  out.push(" prehello ~/code/my-project scan a project folder (default: the current directory)");
@@ -269,9 +257,10 @@ function renderTerminal(r, o) {
269
257
  if (!a.items.length) {
270
258
  const by = a.detectedBy.length ? ` (via ${a.detectedBy.join(", ")})` : "";
271
259
  out.push(p.bold(` ${a.name} is installed${by}, but nothing that loads before hello was found.`));
272
- out.push(...(0, fmt_1.wrap)(`This is NOT a score of 0 and NOT proof the agent loads nothing: it still loads its own system prompt and tools. Looked in: ${WHERE_LOOKED[a.id]}.`, W, " ", " ").map((l) => p.dim(l)));
273
- if (AGENT_HINT[a.id])
274
- out.push(...(0, fmt_1.wrap)(AGENT_HINT[a.id], W, " ", " ").map((l) => p.dim(l)));
260
+ out.push(...(0, fmt_1.wrap)(`This is NOT a score of 0 and NOT proof the agent loads nothing: it still loads its own system prompt and tools. Looked in: ${(0, agents_1.agentMeta)(a.id).whereLooked}.`, W, " ", " ").map((l) => p.dim(l)));
261
+ const hint = (0, agents_1.agentMeta)(a.id).hint;
262
+ if (hint)
263
+ out.push(...(0, fmt_1.wrap)(hint, W, " ", " ").map((l) => p.dim(l)));
275
264
  }
276
265
  if (a.mcpServers.length) {
277
266
  const names = a.mcpServers.map((m) => m.name);
@@ -26,6 +26,8 @@ function looksLikePrehelloOutput(headRaw, kind) {
26
26
  const head = kind === "png" ? headRaw : headRaw.replace(/^(?:\ufeff|\u00ef\u00bb\u00bf)/, "");
27
27
  if (kind === "md")
28
28
  return head.startsWith(exports.MD_MARKER_PREFIX);
29
+ if (kind === "badge")
30
+ return /^\s*\{\s*"schemaVersion":\s*1,\s*"label":\s*"context before hello"/.test(head);
29
31
  // `head` is decoded as latin1, so a PNG's bytes survive: signature, then the tEXt "Software: prehello" chunk tagPng() adds right after IHDR.
30
32
  if (kind === "png")
31
33
  return head.startsWith("\u0089PNG\r\n\u001a\n") && /tEXtSoftware\u0000prehello /.test(head);
package/dist/scan.js CHANGED
@@ -7,20 +7,14 @@ const context_1 = require("./context");
7
7
  const fmt_1 = require("./util/fmt");
8
8
  const counter_1 = require("./tokens/counter");
9
9
  const model_1 = require("./model");
10
- const claude_code_1 = require("./adapters/claude-code");
11
- const cursor_1 = require("./adapters/cursor");
12
- const codex_1 = require("./adapters/codex");
10
+ const adapters_1 = require("./adapters");
13
11
  const totals_1 = require("./analyze/totals");
14
12
  const findings_1 = require("./analyze/findings");
15
13
  const grade_1 = require("./analyze/grade");
16
14
  const savings_1 = require("./analyze/savings");
17
15
  const summary_1 = require("./analyze/summary");
18
- exports.VERSION = "0.1.0";
19
- const ADAPTERS = {
20
- "claude-code": { detect: claude_code_1.detectClaude, discover: claude_code_1.discoverClaude },
21
- cursor: { detect: cursor_1.detectCursor, discover: cursor_1.discoverCursor },
22
- codex: { detect: codex_1.detectCodex, discover: codex_1.discoverCodex },
23
- };
16
+ const version_1 = require("./version");
17
+ Object.defineProperty(exports, "VERSION", { enumerable: true, get: function () { return version_1.VERSION; } });
24
18
  exports.NOT_MEASURABLE = [
25
19
  "the agent's built-in system prompt and built-in tool definitions",
26
20
  "server-side / org rules, Cursor User & Team Rules, claude.ai connectors",
@@ -38,7 +32,7 @@ function scan(input) {
38
32
  for (const id of model_1.ALL_AGENTS) {
39
33
  if (forced && !forced.includes(id))
40
34
  continue;
41
- const ad = ADAPTERS[id];
35
+ const ad = adapters_1.ADAPTERS[id];
42
36
  const det = ad.detect(ctx);
43
37
  const inUse = !!forced || det.detectedBy.length > 0;
44
38
  const repoOnly = !inUse && det.repoFiles.length > 0;
@@ -91,7 +85,7 @@ function scan(input) {
91
85
  const when = epoch && /^\d+$/.test(epoch) ? new Date(parseInt(epoch, 10) * 1000) : opts.now;
92
86
  return {
93
87
  schemaVersion: model_1.SCHEMA_VERSION,
94
- tool: { name: "prehello", version: exports.VERSION },
88
+ tool: { name: "prehello", version: version_1.VERSION },
95
89
  generatedAt: when.toISOString(),
96
90
  scan: { cwd: opts.cwd, window: opts.window, tokenizer: { method: counter.method, note: counter.note }, claudeProfile: opts.claudeProfile, ...(ctx.fixturesSkipped > 0 ? { fixturesSkipped: ctx.fixturesSkipped } : {}) },
97
91
  agents,
@@ -51,12 +51,17 @@ function expandImports(fileText, filePath, home, allowed, opts) {
51
51
  skipped.push({ spec: "(more)", reason: `more than ${MAX_CANDIDATES} @imports; the rest were not followed` });
52
52
  continue;
53
53
  }
54
+ if (opts.relativeOnly && (spec.startsWith("~") || node_path_1.default.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec))) {
55
+ skipped.push({ spec, reason: "absolute or ~/ path (not loaded by this agent)" });
56
+ continue;
57
+ }
54
58
  const target = spec.startsWith("~/") ? node_path_1.default.join(home, spec.slice(2)) : node_path_1.default.resolve(node_path_1.default.dirname(from), spec);
55
59
  const real = (0, fs_safe_1.realpathOrSelf)(target);
56
60
  if (seen.has(real))
57
61
  continue;
58
- if (depth >= MAX_DEPTH) {
59
- skipped.push({ spec, reason: "depth>4" });
62
+ const maxDepth = opts.maxDepth ?? MAX_DEPTH;
63
+ if (depth >= maxDepth) {
64
+ skipped.push({ spec, reason: `depth>${maxDepth}` });
60
65
  continue;
61
66
  }
62
67
  if (!allowed(real)) {
@@ -1,13 +1,30 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.claudeCalibration = claudeCalibration;
3
7
  exports.profileFor = profileFor;
8
+ const node_fs_1 = __importDefault(require("node:fs"));
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ const agents_1 = require("../agents");
11
+ let calCache;
12
+ /** Claude ratios from spec/calibration.json (published count_tokens measurements; see docs/ACCURACY.md). */
13
+ function claudeCalibration() {
14
+ if (!calCache) {
15
+ const j = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(__dirname, "..", "..", "spec", "calibration.json"), "utf8"));
16
+ calCache = j.claude;
17
+ }
18
+ return calCache;
19
+ }
4
20
  function sym(raw, factor, bandPct) {
5
21
  const est = Math.round(raw * factor);
6
22
  return { est, low: Math.round(est * (1 - bandPct)), high: Math.round(est * (1 + bandPct)) };
7
23
  }
24
+ const pct = (x) => `±${Math.round(x * 100)}%`;
8
25
  /**
9
- * Priors from PRODUCT_PLAN §7.2. All Claude/heuristic factors and bands are UNVALIDATED priors until
10
- * calibrated against ground truth (see docs/accuracy.md). The OpenAI band is for the BPE itself.
26
+ * Claude factors come from spec/calibration.json (published ratios, still unvalidated: n is small). Codex is o200k itself.
27
+ * "mixed" agents (Cursor, Copilot) let the user pick the model, so they span GPT-like to Claude-like. Gemini has no measured ratio.
11
28
  */
12
29
  function profileFor(agent, method, claude) {
13
30
  if (method === "heuristic") {
@@ -19,28 +36,43 @@ function profileFor(agent, method, claude) {
19
36
  unvalidated: true,
20
37
  };
21
38
  }
22
- if (agent === "codex") {
39
+ const family = (0, agents_1.agentMeta)(agent).tokenFamily;
40
+ if (family === "openai") {
23
41
  return { id: "openai-o200k", label: "o200k BPE (OpenAI-family)", band: "±3%", apply: (raw) => sym(raw, 1.0, 0.03), unvalidated: false };
24
42
  }
25
- const f = claude === "current" ? 1.5 : 1.2;
43
+ const cal = claudeCalibration();
44
+ const scale = claude === "current" ? 1 : cal.legacyRatio.value;
45
+ const f = (k) => Math.round(cal.byKind[k].factor * scale * 1000) / 1000;
46
+ const b = (k) => cal.byKind[k].band;
26
47
  const claudeProfile = {
27
48
  id: `claude-${claude}`,
28
- label: `o200k × ${f} (Claude-${claude} prior)`,
29
- band: "±20%",
30
- apply: (raw) => sym(raw, f, 0.2),
31
- unvalidated: true,
49
+ label: `o200k × ${f("markdown")} (text) / × ${f("json")} (JSON), Claude-${claude} ratios from published count_tokens measurements`,
50
+ band: pct(b("markdown")),
51
+ apply: (raw, kind = "markdown") => sym(raw, f(kind), b(kind)),
52
+ unvalidated: cal.unvalidated,
32
53
  };
33
- if (agent === "claude-code")
54
+ if (family === "claude")
34
55
  return claudeProfile;
35
- // Cursor: model not discoverable from disk -> span GPT-like (×1.0, −3%) to Claude-like (×f, +20%); est = midpoint.
56
+ if (family === "gemini") {
57
+ // No published Gemini-vs-o200k ratio was found (docs/ACCURACY.md), so prehello does not invent one: the estimate is the
58
+ // raw o200k count and the band is the same GPT-like-to-Claude-like spread used for "model unknown". NOT a Gemini measurement.
59
+ return {
60
+ id: "gemini-unvalidated",
61
+ label: `o200k × 1.0; no measured Gemini ratio, band borrowed from the GPT-like (×1.0) to Claude-like (×${f("markdown")}) spread`,
62
+ band: `wide, unvalidated: −3% to +${Math.round((f("markdown") * (1 + b("markdown")) - 1) * 100)}%`,
63
+ apply: (raw, kind = "markdown") => ({ est: Math.round(raw), low: Math.round(raw * 0.97), high: Math.round(raw * f(kind) * (1 + b(kind))) }),
64
+ unvalidated: true,
65
+ };
66
+ }
67
+ // mixed (Cursor, GitHub Copilot: the model is user-selectable and not discoverable from disk): span GPT-like (×1.0, −3%) to Claude-like (×f, +band); est = midpoint.
36
68
  return {
37
- id: "cursor-mixed",
38
- label: `o200k; model unknown, so midpoint of GPT-like (×1.0) and Claude-like (×${f}) priors`,
39
- band: "wide: GPT-like −3% to Claude-like +20%",
40
- apply: (raw) => {
69
+ id: agent === "cursor" ? "cursor-mixed" : "mixed",
70
+ label: `o200k; model unknown, so midpoint of GPT-like (×1.0) and Claude-like (×${f("markdown")}) ratios`,
71
+ band: `wide: GPT-like −3% to Claude-like +${Math.round(b("markdown") * 100)}%`,
72
+ apply: (raw, kind = "markdown") => {
41
73
  const lo = Math.round(raw * 0.97);
42
- const hi = Math.round(raw * f * 1.2);
43
- return { est: Math.round((raw * (1 + f)) / 2), low: lo, high: hi };
74
+ const hi = Math.round(raw * f(kind) * (1 + b(kind)));
75
+ return { est: Math.round((raw * (1 + f(kind))) / 2), low: lo, high: hi };
44
76
  },
45
77
  unvalidated: true,
46
78
  };
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.VERSION = void 0;
4
+ // Generated by scripts/gen-version.mjs from package.json. Do not edit.
5
+ exports.VERSION = "1.0.0";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "prehello",
3
- "version": "0.1.0",
4
- "description": "See what your coding agent (Claude Code, Cursor, Codex) loads before you say hello, and what you could cut. Local-only, read-only, no API key.",
3
+ "version": "1.0.0",
4
+ "description": "See what your coding agent (Claude Code, Cursor, Codex, GitHub Copilot, Gemini CLI) loads before you say hello, and what you could cut. Local-only, read-only, no API key.",
5
5
  "license": "MIT",
6
6
  "author": "Piyush Kumar",
7
7
  "homepage": "https://github.com/PIYUSH-KUMAR1809/prehello#readme",
@@ -25,11 +25,13 @@
25
25
  "THIRD_PARTY_NOTICES.md"
26
26
  ],
27
27
  "engines": {
28
- "node": ">=18"
28
+ "node": ">=22"
29
29
  },
30
30
  "scripts": {
31
31
  "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
32
+ "prebuild": "node scripts/gen-version.mjs",
32
33
  "build": "tsc -p tsconfig.build.json",
34
+ "pretypecheck": "node scripts/gen-version.mjs",
33
35
  "typecheck": "tsc -p tsconfig.json --noEmit",
34
36
  "test": "npm run build && vitest run",
35
37
  "prepack": "npm run clean && npm run build",
@@ -39,6 +41,8 @@
39
41
  "claude-code",
40
42
  "cursor",
41
43
  "codex",
44
+ "github-copilot",
45
+ "gemini-cli",
42
46
  "agents-md",
43
47
  "ai-agents",
44
48
  "context-window",
@@ -55,7 +59,7 @@
55
59
  "smol-toml": "^1.9.1"
56
60
  },
57
61
  "devDependencies": {
58
- "@types/node": "^20.19.43",
62
+ "@types/node": "^22.19.0",
59
63
  "@types/picomatch": "^4.0.3",
60
64
  "ajv": "^8.20.0",
61
65
  "ajv-formats": "^3.0.1",
@@ -0,0 +1,33 @@
1
+ {
2
+ "version": 1,
3
+ "note": "Claude-to-o200k token ratios from PUBLISHED Anthropic count_tokens measurements (not measured by prehello). factor = median of the published points for that content kind; band = relative half-width used for low/high. Unvalidated until n >= 100 own measurements (scripts/calibrate-anthropic.mjs). See docs/ACCURACY.md.",
4
+ "claude": {
5
+ "measuredAt": "2026-10-23",
6
+ "tokenizer": "Claude 4.7 and later (Opus/Sonnet/Haiku 5.5 give identical counts per both sources)",
7
+ "n": 10,
8
+ "unvalidated": true,
9
+ "byKind": {
10
+ "markdown": {
11
+ "factor": 1.43,
12
+ "band": 0.15,
13
+ "points": [
14
+ { "ratio": 1.486, "what": "English prose reference text, 430 o200k tokens", "source": "https://measuredlab.com/tools/token-counter/" },
15
+ { "ratio": 1.38, "what": "5 Markdown/reST files, 44,621 o200k tokens", "source": "https://aitoollabs.dev/posts/count-tokens-in-codebase/" }
16
+ ]
17
+ },
18
+ "json": {
19
+ "factor": 1.35,
20
+ "band": 0.15,
21
+ "points": [
22
+ { "ratio": 1.282, "what": "JSON reference text, 535 o200k tokens", "source": "https://measuredlab.com/tools/token-counter/" },
23
+ { "ratio": 1.42, "what": "5 YAML/JSON/TOML files, 3,391 o200k tokens", "source": "https://aitoollabs.dev/posts/count-tokens-in-codebase/" }
24
+ ]
25
+ }
26
+ },
27
+ "legacyRatio": {
28
+ "value": 0.82,
29
+ "what": "Sonnet 4.6 (pre-4.7 tokenizer) used 18% fewer tokens than 4.7+ on the same 33 files",
30
+ "source": "https://aitoollabs.dev/posts/count-tokens-in-codebase/"
31
+ }
32
+ }
33
+ }
package/spec/cursor.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "agent": "cursor",
3
3
  "verifiedAt": "2026-10-25",
4
- "verifiedNote": "Rule modes, skill locations (native + compat dirs), skill frontmatter (paths/globs, disable-model-invocation), plugin manifest/default component dirs, and lazy MCP loading (tool names static, descriptions on demand) are from Cursor's docs and blog (fetched 2026-10-25). UNVERIFIED (marked assumed): how Cursor injects agent-requested rule descriptions and the skills listing, ~/.cursor/skills-cursor (observed on a real Mac only), the plugin cache/marketplace on-disk layout and install state, and whether nested .cursor/rules apply (docs and forum disagree).",
4
+ "verifiedNote": "Rule modes, skill locations (native + compat dirs), skill frontmatter (paths/globs, disable-model-invocation), plugin manifest/default component dirs, and lazy MCP loading (tool names static, descriptions on demand) are from Cursor's docs and blog (fetched 2026-10-25). UNVERIFIED (marked assumed): how Cursor injects agent-requested rule descriptions, ~/.cursor/skills-cursor (observed on a real Mac only), the plugin cache/marketplace on-disk layout and install state, and whether nested .cursor/rules apply (docs and forum disagree). Skills listing (observed 2026-11-03 from forum.cursor.com/t/163761 and /t/163768, Cursor staff replies of 2026-06-22/25): <agent_skills><available_skills><agent_skill fullPath=\"...\">description</agent_skill>, descriptions shortened (80 chars + ellipsis seen in cloud agents) and, past an undocumented cap, path only; modelled as 80-char entries with path-only/full-description bounds.",
5
5
  "docs": {
6
6
  "rules": "https://cursor.com/docs/rules",
7
7
  "mcp": "https://cursor.com/docs/mcp",
@@ -0,0 +1,17 @@
1
+ {
2
+ "agent": "gemini-cli",
3
+ "verifiedAt": "2026-11-03",
4
+ "verifiedNote": "GEMINI.md hierarchy (~/.gemini/GEMINI.md, workspace dirs and parents up to context.memoryBoundaryMarkers, default [\".git\"]), just-in-time subdirectory files, context.fileName (string or list), @file imports (max depth 5, code regions ignored), extensions in ~/.gemini/extensions/<name>/gemini-extension.json (contextFileName, mcpServers; settings win on a clash), mcpServers in ~/.gemini/settings.json and .gemini/settings.json (workspace overrides user) and skills (name + description up front) are from the google-gemini/gemini-cli docs (main, fetched 2026-11-03). The docs disagree on subdirectory files (gemini-md.md: just-in-time; reference/configuration.md: eager scan up to 200 dirs); prehello follows gemini-md.md and calls them conditional. ASSUMED: MCP tool schemas loaded up front, extensions enabled, legacy top-level contextFileName. Token accuracy was never validated and no Gemini tokenizer ratio is applied.",
5
+ "docs": {
6
+ "geminiMd": "https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/gemini-md.md",
7
+ "imports": "https://github.com/google-gemini/gemini-cli/blob/main/docs/reference/memport.md",
8
+ "settings": "https://github.com/google-gemini/gemini-cli/blob/main/docs/reference/configuration.md",
9
+ "mcp": "https://github.com/google-gemini/gemini-cli/blob/main/docs/reference/configuration.md#mcpservers",
10
+ "extensions": "https://github.com/google-gemini/gemini-cli/blob/main/docs/extensions/reference.md",
11
+ "skills": "https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/skills.md"
12
+ },
13
+ "caps": {
14
+ "importMaxDepth": 5,
15
+ "discoveryMaxDirs": 200
16
+ }
17
+ }