context-doctor 0.26.0 → 0.28.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,83 @@
1
+ /**
2
+ * `context-doctor overhead`: what every request pays before you type.
3
+ *
4
+ * Each request an agent sends re-sends the same front matter: the harness's
5
+ * system prompt, tool and MCP schemas, skills, and the memory files the user
6
+ * wrote (CLAUDE.md and its imports, rules, auto memory, AGENTS.md, Cursor
7
+ * rules, GEMINI.md). It is cached, so each read is cheap, but it is read on
8
+ * every request of every session, and at full write price after every cold
9
+ * start. Nobody sees it: transcripts do not contain it.
10
+ *
11
+ * Two measurements, both local:
12
+ * - the fixed overhead itself, from the first request of each Claude Code
13
+ * session (the API's own count, minus the first user message);
14
+ * - the memory files that load in a directory, sized with Claude's ratios and
15
+ * priced per month from the user's own request and cold-start counts.
16
+ */
17
+ import { type McpServerConfig, type McpServerSize, type McpUsage } from "./mcpschema.js";
18
+ export type Agent = "Claude Code" | "Codex" | "Cursor" | "Gemini CLI";
19
+ export interface MemoryFile {
20
+ agent: Agent;
21
+ path: string;
22
+ tokens: number;
23
+ /** Why it loads: "project", "user", "import", "rules", "auto memory". */
24
+ via: string;
25
+ }
26
+ export interface OverheadFinding {
27
+ severity: "warn" | "info";
28
+ message: string;
29
+ suggestion: string;
30
+ }
31
+ export interface Baseline {
32
+ agent: Agent;
33
+ sessions: number;
34
+ /** Median tokens of the first request, minus the first user message. */
35
+ median: number;
36
+ p90: number;
37
+ requests: number;
38
+ coldStarts: number;
39
+ days: number;
40
+ model?: string;
41
+ /** USD a month for each 1,000 tokens of fixed overhead, at this usage. */
42
+ usdPerKPerMonth?: number;
43
+ }
44
+ export interface OverheadReport {
45
+ cwd: string;
46
+ /** Claude Code's baseline (memory-file prices for Claude Code are quoted against it). */
47
+ baseline?: Baseline;
48
+ /** Every agent with sessions in the period: Claude Code, Codex, Gemini CLI. */
49
+ baselines: Baseline[];
50
+ files: MemoryFile[];
51
+ findings: OverheadFinding[];
52
+ mcp: McpSection;
53
+ }
54
+ export interface McpSection {
55
+ configs: McpServerConfig[];
56
+ usage: McpUsage;
57
+ /** Present when the servers were launched and asked for their tools (`--mcp`). */
58
+ sizes?: McpServerSize[];
59
+ }
60
+ /** Every memory file the four agents load for a session started in `cwd`. */
61
+ export declare function findMemoryFiles(cwd?: string, home?: string): MemoryFile[];
62
+ /**
63
+ * Fixed overhead per session and agent, measured: each session's first
64
+ * request (the API's own count) minus the first user message, plus the
65
+ * request and cold-start counts that turn tokens into a monthly bill.
66
+ * Claude Code, Codex and Gemini CLI sessions, newest `days` only.
67
+ */
68
+ export declare function measureBaselines(days?: number, paths?: string[]): Baseline[];
69
+ /** Claude Code's baseline (the one memory-file prices are quoted against). */
70
+ export declare function measureBaseline(days?: number, paths?: string[]): Baseline | undefined;
71
+ /** What is worth changing in the memory files. */
72
+ export declare function overheadFindings(files: MemoryFile[], baselines?: Baseline | Baseline[]): OverheadFinding[];
73
+ export declare function overheadReport(opts?: {
74
+ cwd?: string;
75
+ home?: string;
76
+ days?: number;
77
+ paths?: string[];
78
+ }): OverheadReport;
79
+ /** Launch each configured server and size its tool definitions, then add the findings they support. */
80
+ export declare function measureMcpSizes(r: OverheadReport): Promise<void>;
81
+ /** Configured servers nobody called: their definitions ride on every request for nothing. */
82
+ export declare function mcpFindings(m: McpSection, baseline?: Baseline): OverheadFinding[];
83
+ export declare function renderOverhead(r: OverheadReport, home?: string): string;
@@ -0,0 +1,493 @@
1
+ /**
2
+ * `context-doctor overhead`: what every request pays before you type.
3
+ *
4
+ * Each request an agent sends re-sends the same front matter: the harness's
5
+ * system prompt, tool and MCP schemas, skills, and the memory files the user
6
+ * wrote (CLAUDE.md and its imports, rules, auto memory, AGENTS.md, Cursor
7
+ * rules, GEMINI.md). It is cached, so each read is cheap, but it is read on
8
+ * every request of every session, and at full write price after every cold
9
+ * start. Nobody sees it: transcripts do not contain it.
10
+ *
11
+ * Two measurements, both local:
12
+ * - the fixed overhead itself, from the first request of each Claude Code
13
+ * session (the API's own count, minus the first user message);
14
+ * - the memory files that load in a directory, sized with Claude's ratios and
15
+ * priced per month from the user's own request and cold-start counts.
16
+ */
17
+ import { readdirSync, readFileSync, statSync } from "node:fs";
18
+ import { homedir } from "node:os";
19
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
20
+ import { forEachLine, listSessions } from "./session.js";
21
+ import { estimateTokens, formatTokens, providerFor } from "./tokens.js";
22
+ import { formatUsd, pricingFor } from "./pricing.js";
23
+ import { isGeminiChat, readGeminiMessages } from "./gemini.js";
24
+ import { claudeMcpConfigs, mcpUsage, measureServer, toolPrefixName } from "./mcpschema.js";
25
+ const TTL_MS = 3_600_000;
26
+ const CLAUDE = "claude-opus-5"; // Claude's tokenizer ratios; the model only picks the provider here.
27
+ function readText(path) {
28
+ try {
29
+ if (!statSync(path).isFile())
30
+ return undefined;
31
+ return readFileSync(path, "utf8");
32
+ }
33
+ catch {
34
+ return undefined;
35
+ }
36
+ }
37
+ /** Directories from `cwd` up to (and including) the filesystem root. */
38
+ function ancestors(cwd) {
39
+ const out = [];
40
+ let d = resolve(cwd);
41
+ for (;;) {
42
+ out.push(d);
43
+ const up = dirname(d);
44
+ if (up === d)
45
+ return out;
46
+ d = up;
47
+ }
48
+ }
49
+ function mdFiles(dir, ext = /\.md$/) {
50
+ const out = [];
51
+ try {
52
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
53
+ const p = join(dir, e.name);
54
+ if (e.isDirectory())
55
+ out.push(...mdFiles(p, ext));
56
+ else if (ext.test(e.name))
57
+ out.push(p);
58
+ }
59
+ }
60
+ catch {
61
+ /* absent */
62
+ }
63
+ return out.sort();
64
+ }
65
+ /** `@path` imports in a CLAUDE.md, outside code spans and fences, resolved relative to the file. */
66
+ function imports(text, from, home) {
67
+ const out = [];
68
+ let fence = false;
69
+ for (const line of text.split("\n")) {
70
+ if (/^\s*(```|~~~)/.test(line))
71
+ fence = !fence;
72
+ if (fence)
73
+ continue;
74
+ for (const m of line.replace(/`[^`]*`/g, "").matchAll(/(?:^|\s)@((?:~\/|\.{0,2}\/|[\w.-]+\/)[^\s)]+|[\w.-]+\.\w+)/g)) {
75
+ const raw = m[1];
76
+ out.push(raw.startsWith("~/") ? join(home, raw.slice(2)) : isAbsolute(raw) ? raw : resolve(dirname(from), raw));
77
+ }
78
+ }
79
+ return out;
80
+ }
81
+ /** Claude Code's auto-memory index for a directory (it loads MEMORY.md, first 200 lines). */
82
+ function autoMemoryPath(cwd, home) {
83
+ return join(home, ".claude", "projects", resolve(cwd).replace(/[^A-Za-z0-9]/g, "-"), "memory", "MEMORY.md");
84
+ }
85
+ /** Every memory file the four agents load for a session started in `cwd`. */
86
+ export function findMemoryFiles(cwd = process.cwd(), home = homedir()) {
87
+ const files = [];
88
+ const seen = new Set();
89
+ const add = (agent, path, via, text) => {
90
+ const key = `${agent}:${resolve(path)}`;
91
+ if (seen.has(key))
92
+ return undefined;
93
+ text ??= readText(path);
94
+ if (text === undefined || !text.trim())
95
+ return undefined;
96
+ seen.add(key);
97
+ files.push({ agent, path, via, tokens: estimateTokens(text, CLAUDE) });
98
+ return text;
99
+ };
100
+ const addClaude = (path, via, depth = 0) => {
101
+ const text = add("Claude Code", path, via);
102
+ if (text === undefined || depth >= 4)
103
+ return;
104
+ for (const p of imports(text, path, home))
105
+ addClaude(p, "import", depth + 1);
106
+ };
107
+ // Claude Code: user memory and rules, then each directory from the root down.
108
+ addClaude(join(home, ".claude", "CLAUDE.md"), "user");
109
+ for (const p of mdFiles(join(home, ".claude", "rules")))
110
+ addClaude(p, "rules");
111
+ for (const d of ancestors(cwd).reverse()) {
112
+ addClaude(join(d, "CLAUDE.md"), "project");
113
+ addClaude(join(d, ".claude", "CLAUDE.md"), "project");
114
+ addClaude(join(d, "CLAUDE.local.md"), "project (local)");
115
+ if (d !== home)
116
+ for (const p of mdFiles(join(d, ".claude", "rules")))
117
+ addClaude(p, "rules");
118
+ }
119
+ const auto = readText(autoMemoryPath(cwd, home));
120
+ if (auto)
121
+ add("Claude Code", autoMemoryPath(cwd, home), "auto memory", auto.split("\n").slice(0, 200).join("\n"));
122
+ // Codex: global AGENTS.md, then each directory down to cwd (it stops at the git root; root-ward extras are rare).
123
+ add("Codex", join(home, ".codex", "AGENTS.md"), "user");
124
+ for (const d of ancestors(cwd).reverse()) {
125
+ if (d === home)
126
+ continue;
127
+ add("Codex", join(d, "AGENTS.override.md"), "project") ?? add("Codex", join(d, "AGENTS.md"), "project");
128
+ }
129
+ // Cursor: legacy .cursorrules and always-applied project rules.
130
+ add("Cursor", join(cwd, ".cursorrules"), "project");
131
+ for (const p of mdFiles(join(cwd, ".cursor", "rules"), /\.mdc?$/)) {
132
+ const text = readText(p) ?? "";
133
+ if (/^alwaysApply:\s*true/m.test(text))
134
+ add("Cursor", p, "rules (always)", text);
135
+ }
136
+ // Gemini CLI: global, then each directory down to cwd.
137
+ add("Gemini CLI", join(home, ".gemini", "GEMINI.md"), "user");
138
+ for (const d of ancestors(cwd).reverse())
139
+ if (d !== home)
140
+ add("Gemini CLI", join(d, "GEMINI.md"), "project");
141
+ return files;
142
+ }
143
+ function textOf(content) {
144
+ if (typeof content === "string")
145
+ return content;
146
+ if (!Array.isArray(content))
147
+ return "";
148
+ return content.map((b) => (typeof b === "string" ? b : typeof b?.text === "string" ? b.text : "")).filter(Boolean).join("\n");
149
+ }
150
+ /** Claude Code: usage rows on assistant messages (one per API reply id). */
151
+ function claudeUsage(path) {
152
+ const out = { agent: "Claude Code", reqs: [] };
153
+ let firstUser;
154
+ let lastId;
155
+ forEachLine(path, (line) => {
156
+ let e;
157
+ try {
158
+ e = JSON.parse(line);
159
+ }
160
+ catch {
161
+ return;
162
+ }
163
+ if (!e || e.isSidechain)
164
+ return;
165
+ if (e.type === "user" && out.first === undefined && firstUser === undefined && !e.isMeta) {
166
+ const text = textOf(e.message?.content);
167
+ if (text)
168
+ firstUser = text;
169
+ return;
170
+ }
171
+ if (e.type !== "assistant" || !e.message?.usage || e.message.id === lastId)
172
+ return;
173
+ lastId = e.message.id;
174
+ const u = e.message.usage;
175
+ const prompt = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
176
+ const at = Date.parse(e.timestamp);
177
+ if (!(prompt > 0) || !Number.isFinite(at))
178
+ return;
179
+ out.first ??= Math.max(0, prompt - estimateTokens(firstUser ?? "", CLAUDE));
180
+ out.reqs.push({ at, model: e.message.model });
181
+ });
182
+ return out;
183
+ }
184
+ /**
185
+ * Codex rollouts: token_count events carry each request's input (cached part
186
+ * included). Codex sends AGENTS.md and its environment as user messages;
187
+ * those are overhead, so the first real prompt is the one subtracted.
188
+ */
189
+ function codexUsage(path) {
190
+ const out = { agent: "Codex", reqs: [] };
191
+ let firstUser;
192
+ let model;
193
+ forEachLine(path, (line) => {
194
+ let e;
195
+ try {
196
+ e = JSON.parse(line);
197
+ }
198
+ catch {
199
+ return;
200
+ }
201
+ const p = e?.payload;
202
+ if (!p)
203
+ return;
204
+ if (e.type === "turn_context" && typeof p.model === "string")
205
+ model = p.model;
206
+ if (e.type === "response_item" && p.type === "message" && p.role === "user" && firstUser === undefined) {
207
+ const text = textOf(p.content);
208
+ if (text && !/^\s*(<user_instructions>|<environment_context>|# AGENTS\.md)/.test(text))
209
+ firstUser = text;
210
+ }
211
+ if (e.type === "event_msg" && p.type === "token_count") {
212
+ const input = Number(p.info?.last_token_usage?.input_tokens);
213
+ const at = Date.parse(e.timestamp);
214
+ if (!(input > 0) || !Number.isFinite(at))
215
+ return;
216
+ out.first ??= Math.max(0, input - estimateTokens(firstUser ?? "", model));
217
+ out.reqs.push({ at, model });
218
+ }
219
+ });
220
+ return out;
221
+ }
222
+ /** Gemini CLI chats: each gemini message records its request's promptTokenCount. */
223
+ function geminiUsage(path) {
224
+ const out = { agent: "Gemini CLI", reqs: [] };
225
+ let firstUser;
226
+ for (const m of readGeminiMessages(path)) {
227
+ if (m?.type === "user" && firstUser === undefined)
228
+ firstUser = textOf(m.content) || undefined;
229
+ if (m?.type !== "gemini")
230
+ continue;
231
+ const input = Number(m.tokens?.input);
232
+ const at = Date.parse(m.timestamp);
233
+ if (!(input > 0) || !Number.isFinite(at))
234
+ continue;
235
+ out.first ??= Math.max(0, input - estimateTokens(firstUser ?? "", m.model));
236
+ out.reqs.push({ at, model: m.model });
237
+ }
238
+ return out;
239
+ }
240
+ function usageOf(path) {
241
+ if (isGeminiChat(path))
242
+ return geminiUsage(path);
243
+ if (/[\\/]\.codex[\\/]/.test(path))
244
+ return codexUsage(path);
245
+ return claudeUsage(path);
246
+ }
247
+ /** Cache writes cost 1.25x input on Anthropic; OpenAI and Google charge no write premium. */
248
+ function writeMultiplier(model) {
249
+ return providerFor(model) === "anthropic" ? 1.25 : 1;
250
+ }
251
+ function aggregate(agent, sessions, since, days) {
252
+ const firsts = [];
253
+ let requests = 0, coldStarts = 0, warm = 0;
254
+ const models = new Map();
255
+ for (const s of sessions) {
256
+ if (s.first !== undefined && s.first > 1000)
257
+ firsts.push(s.first);
258
+ let prevAt;
259
+ for (const r of s.reqs) {
260
+ if (r.at >= since) {
261
+ requests++;
262
+ if (prevAt === undefined || r.at - prevAt > TTL_MS)
263
+ coldStarts++;
264
+ else
265
+ warm++;
266
+ if (r.model)
267
+ models.set(r.model, (models.get(r.model) ?? 0) + 1);
268
+ }
269
+ prevAt = r.at;
270
+ }
271
+ }
272
+ if (firsts.length === 0)
273
+ return undefined;
274
+ firsts.sort((a, b) => a - b);
275
+ const model = [...models.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
276
+ const price = pricingFor(model);
277
+ // Each overhead token is a cache read on warm requests and a full-price
278
+ // write (with the provider's premium) after every cold start.
279
+ const usdPerKPerMonth = price
280
+ ? ((1000 * (warm * price.cacheReadPerM + coldStarts * writeMultiplier(model) * price.inputPerM)) / 1e6) * (30 / days)
281
+ : undefined;
282
+ return {
283
+ agent,
284
+ sessions: firsts.length,
285
+ median: firsts[Math.floor(firsts.length / 2)],
286
+ p90: firsts[Math.min(firsts.length - 1, Math.floor(firsts.length * 0.9))],
287
+ requests,
288
+ coldStarts,
289
+ days,
290
+ model,
291
+ usdPerKPerMonth,
292
+ };
293
+ }
294
+ /**
295
+ * Fixed overhead per session and agent, measured: each session's first
296
+ * request (the API's own count) minus the first user message, plus the
297
+ * request and cold-start counts that turn tokens into a monthly bill.
298
+ * Claude Code, Codex and Gemini CLI sessions, newest `days` only.
299
+ */
300
+ export function measureBaselines(days = 30, paths) {
301
+ const since = Date.now() - days * 86_400_000;
302
+ const files = paths ?? listSessions(10_000).filter((s) => s.modifiedAt.getTime() >= since).map((s) => s.path);
303
+ const byAgent = new Map();
304
+ for (const path of files) {
305
+ let u;
306
+ try {
307
+ u = usageOf(path);
308
+ }
309
+ catch {
310
+ continue;
311
+ }
312
+ (byAgent.get(u.agent) ?? byAgent.set(u.agent, []).get(u.agent)).push(u);
313
+ }
314
+ const order = ["Claude Code", "Codex", "Gemini CLI"];
315
+ return order.flatMap((a) => {
316
+ const b = byAgent.has(a) ? aggregate(a, byAgent.get(a), since, days) : undefined;
317
+ return b ? [b] : [];
318
+ });
319
+ }
320
+ /** Claude Code's baseline (the one memory-file prices are quoted against). */
321
+ export function measureBaseline(days = 30, paths) {
322
+ return measureBaselines(days, paths).find((b) => b.agent === "Claude Code");
323
+ }
324
+ function normalize(s) {
325
+ return s.toLowerCase().replace(/\s+/g, " ").trim();
326
+ }
327
+ /** What is worth changing in the memory files. */
328
+ export function overheadFindings(files, baselines) {
329
+ const out = [];
330
+ const list = Array.isArray(baselines) ? baselines : baselines ? [baselines] : [];
331
+ const cost = (tok, agent) => {
332
+ const b = list.find((x) => x.agent === agent);
333
+ return b?.usdPerKPerMonth ? ` (~${formatUsd((tok / 1000) * b.usdPerKPerMonth)}/month at your usage)` : "";
334
+ };
335
+ for (const f of files) {
336
+ if (f.tokens >= 2000) {
337
+ out.push({
338
+ severity: f.tokens >= 5000 ? "warn" : "info",
339
+ message: `${f.path} is ~${formatTokens(f.tokens)} tokens, read on every ${f.agent} request${cost(f.tokens, f.agent)}.`,
340
+ suggestion: `Keep rules the agent needs on most requests; move reference material to a file it reads when relevant. \`context-doctor overhead split ${f.path}\` shows the split first.`,
341
+ });
342
+ }
343
+ }
344
+ // Paragraphs repeated across files of the same agent load twice.
345
+ const byPara = new Map();
346
+ for (const f of files) {
347
+ const text = readText(f.path) ?? "";
348
+ for (const p of text.split(/\n\s*\n/)) {
349
+ const n = normalize(p);
350
+ if (n.length < 120)
351
+ continue;
352
+ const k = `${f.agent}\u0000${n}`;
353
+ (byPara.get(k) ?? byPara.set(k, new Set()).get(k)).add(f.path);
354
+ }
355
+ }
356
+ const dup = new Map();
357
+ for (const [k, set] of byPara) {
358
+ if (set.size < 2)
359
+ continue;
360
+ const key = [...set].sort().join(" + ");
361
+ const d = dup.get(key) ?? { tokens: 0, count: 0 };
362
+ d.tokens += estimateTokens(k.split("\u0000")[1], CLAUDE) * (set.size - 1);
363
+ d.count++;
364
+ dup.set(key, d);
365
+ }
366
+ for (const [key, d] of dup) {
367
+ out.push({
368
+ severity: "info",
369
+ message: `${d.count} paragraph(s) appear in more than one loaded file (${key}): ~${formatTokens(d.tokens)} tokens read twice.`,
370
+ suggestion: "Keep each rule in one file.",
371
+ });
372
+ }
373
+ // Big fenced blocks: examples and reference code rarely need to ride on every request.
374
+ for (const f of files) {
375
+ const text = readText(f.path) ?? "";
376
+ let total = 0;
377
+ for (const m of text.matchAll(/```[\s\S]*?```/g))
378
+ total += estimateTokens(m[0], CLAUDE);
379
+ if (total >= 800) {
380
+ out.push({
381
+ severity: "info",
382
+ message: `${f.path} carries ~${formatTokens(total)} tokens of code blocks.`,
383
+ suggestion: "Move long examples to a file the agent opens when the task needs them.",
384
+ });
385
+ }
386
+ }
387
+ return out;
388
+ }
389
+ export function overheadReport(opts = {}) {
390
+ const cwd = opts.cwd ?? process.cwd();
391
+ const home = opts.home ?? homedir();
392
+ const files = findMemoryFiles(cwd, home);
393
+ const baselines = measureBaselines(opts.days ?? 30, opts.paths);
394
+ const baseline = baselines.find((b) => b.agent === "Claude Code");
395
+ // MCP usage comes from Claude Code transcripts only (the tool-name scheme is theirs).
396
+ const claudePaths = opts.paths?.filter((p) => !isGeminiChat(p) && !/[\\/]\.codex[\\/]/.test(p));
397
+ const mcp = { configs: claudeMcpConfigs(cwd, home), usage: mcpUsage(opts.days ?? 30, claudePaths) };
398
+ return { cwd, baseline, baselines, files, findings: overheadFindings(files, baselines), mcp };
399
+ }
400
+ /** Launch each configured server and size its tool definitions, then add the findings they support. */
401
+ export async function measureMcpSizes(r) {
402
+ r.mcp.sizes = await Promise.all(r.mcp.configs.map((c) => measureServer(c)));
403
+ r.findings.push(...mcpFindings(r.mcp, r.baseline));
404
+ }
405
+ /** Configured servers nobody called: their definitions ride on every request for nothing. */
406
+ export function mcpFindings(m, baseline) {
407
+ const out = [];
408
+ const deferred = m.usage.sessions > 0 && m.usage.toolSearchSessions / m.usage.sessions >= 0.5;
409
+ for (const c of m.configs) {
410
+ const size = m.sizes?.find((s) => s.name === c.name);
411
+ const calls = m.usage.calls.get(toolPrefixName(c.name)) ?? 0;
412
+ const tokens = size?.schemaTokens === undefined ? undefined : deferred ? size.nameTokens : size.schemaTokens;
413
+ if (calls > 0 || tokens === undefined || tokens < 200)
414
+ continue;
415
+ const usd = baseline?.usdPerKPerMonth ? ` (~${formatUsd((tokens / 1000) * baseline.usdPerKPerMonth)}/month)` : "";
416
+ out.push({
417
+ severity: tokens >= 2000 ? "warn" : "info",
418
+ message: `MCP server "${c.name}" (${c.scope}) was not called in ${m.usage.sessions} sessions, yet adds ~${formatTokens(tokens)} tokens to every request${deferred ? " even deferred" : ""}${usd}.`,
419
+ suggestion: `Remove it where you do not use it (claude mcp remove ${c.name}${c.scope === "user" ? " -s user" : ""}), or scope it to the projects that need it.`,
420
+ });
421
+ }
422
+ return out;
423
+ }
424
+ export function renderOverhead(r, home = homedir()) {
425
+ const out = [];
426
+ const short = (p) => (p.startsWith(home) ? "~" + p.slice(home.length) : relative(r.cwd, p) || p);
427
+ out.push("FIXED OVERHEAD: what every request re-reads before your message", "═".repeat(56));
428
+ const b = r.baseline;
429
+ const claudeTokens = r.files.filter((f) => f.agent === "Claude Code").reduce((s, f) => s + f.tokens, 0);
430
+ if (b) {
431
+ out.push(`Claude Code, last ${b.days} days: ${b.sessions} sessions start at a median ~${formatTokens(b.median)} tokens (p90 ~${formatTokens(b.p90)}) before the first message.`, `Memory files loaded here are ~${formatTokens(claudeTokens)} of that; the rest is Claude Code's system prompt, tools, skills and MCP schemas.`);
432
+ if (b.usdPerKPerMonth !== undefined) {
433
+ out.push(`At your usage (${b.requests.toLocaleString("en-US")} requests, ${b.coldStarts.toLocaleString("en-US")} cold starts, ${b.model} list prices) every 1k tokens of it costs ~${formatUsd(b.usdPerKPerMonth)} a month;`, `the whole overhead ~${formatUsd((b.median / 1000) * b.usdPerKPerMonth)} a month.`);
434
+ }
435
+ }
436
+ else {
437
+ out.push("No Claude Code sessions in the period to measure; memory files are sized below.");
438
+ }
439
+ for (const o of r.baselines.filter((x) => x.agent !== "Claude Code")) {
440
+ const tokens = r.files.filter((f) => f.agent === o.agent).reduce((s, f) => s + f.tokens, 0);
441
+ out.push("", `${o.agent}, last ${o.days} days: ${o.sessions} sessions start at a median ~${formatTokens(o.median)} tokens (p90 ~${formatTokens(o.p90)}); ${tokens ? `memory files loaded here ~${formatTokens(tokens)} of it` : "no memory files of its own here"}.` +
442
+ (o.usdPerKPerMonth !== undefined
443
+ ? ` Each 1k tokens ~${formatUsd(o.usdPerKPerMonth)} a month (${o.requests.toLocaleString("en-US")} requests, ${o.coldStarts.toLocaleString("en-US")} cold starts, ${o.model} list prices).`
444
+ : ""));
445
+ }
446
+ out.push(...renderMcp(r));
447
+ out.push("", "Memory files loaded in this directory", "─".repeat(56));
448
+ if (r.files.length === 0)
449
+ out.push(" none");
450
+ for (const f of r.files) {
451
+ const fb = r.baselines.find((x) => x.agent === f.agent);
452
+ const usd = fb?.usdPerKPerMonth !== undefined ? ` ${formatUsd((f.tokens / 1000) * fb.usdPerKPerMonth)}/mo` : "";
453
+ out.push(` ${f.agent.padEnd(11)} ~${formatTokens(f.tokens).padStart(5)}${usd.padEnd(12)} ${short(f.path)}${f.via === "project" ? "" : ` (${f.via})`}`);
454
+ }
455
+ out.push("", `Findings (${r.findings.length})`, "─".repeat(56));
456
+ if (r.findings.length === 0)
457
+ out.push(" Nothing to trim. The memory files are lean.");
458
+ for (const f of r.findings)
459
+ out.push(`${f.severity === "warn" ? "▲" : "ℹ"} ${f.message.replace(home, "~")}`, ` → ${f.suggestion}`);
460
+ return out.join("\n");
461
+ }
462
+ function renderMcp(r) {
463
+ const { configs, usage, sizes } = r.mcp;
464
+ if (configs.length === 0 && usage.calls.size === 0)
465
+ return [];
466
+ const out = ["", "MCP servers", "─".repeat(56)];
467
+ const deferred = usage.sessions > 0 && usage.toolSearchSessions / usage.sessions >= 0.5;
468
+ out.push(deferred
469
+ ? `Tool search was used in ${usage.toolSearchSessions} of ${usage.sessions} sessions: Claude Code defers MCP definitions, so a server costs about its tool names until the model looks one up.`
470
+ : `Tool search was used in ${usage.toolSearchSessions} of ${usage.sessions} sessions: MCP definitions mostly load in full, on every request.`);
471
+ const configured = new Set(configs.map((c) => toolPrefixName(c.name)));
472
+ for (const c of configs) {
473
+ const s = sizes?.find((x) => x.name === c.name);
474
+ const calls = usage.calls.get(toolPrefixName(c.name)) ?? 0;
475
+ const size = !sizes
476
+ ? "size: run with --mcp"
477
+ : s?.error
478
+ ? s.error
479
+ : `${s.tools} tools, ~${formatTokens(s.schemaTokens)} tokens of definitions (names ~${formatTokens(s.nameTokens)})`;
480
+ out.push(` ${c.name.padEnd(22)} ${c.scope.padEnd(9)} ${String(calls).padStart(6)} calls ${size}`);
481
+ }
482
+ const app = [...usage.calls.entries()].filter(([n]) => !configured.has(n)).sort((a, b) => b[1] - a[1]);
483
+ if (app.length) {
484
+ out.push(` Provided by the app or connectors (not in your config; toggle them in the app):`);
485
+ for (const [n, calls] of app.slice(0, 10))
486
+ out.push(` ${n.padEnd(38)} ${String(calls).padStart(6)} calls`);
487
+ if (app.length > 10)
488
+ out.push(` … ${app.length - 10} more`);
489
+ }
490
+ if (!sizes && configs.length)
491
+ out.push(` \`context-doctor overhead --mcp\` launches the ${configs.length} configured server(s) once to size their definitions.`);
492
+ return out;
493
+ }
package/dist/pack.d.ts ADDED
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Pack: put only the parts of a document that matter into the context.
3
+ *
4
+ * Everything else in context-doctor slims a conversation after the fact. The
5
+ * cheapest token is the one that never goes in: a 60k-token manual pasted to
6
+ * answer one question costs 60k tokens on that message and on every message
7
+ * after it. Pack splits files into chunks along their own structure (markdown
8
+ * headings, top-level code declarations, paragraphs), ranks the chunks against
9
+ * a question with BM25, and returns the best ones that fit a token budget, with
10
+ * an id and line range on each so the model can ask for more.
11
+ *
12
+ * Offline and keyless like the rest of the tool: lexical ranking, no embeddings
13
+ * and no model calls. Lexical ranking misses pure paraphrase; the outline that
14
+ * comes with every result lets the model pick sections by heading when it does.
15
+ */
16
+ export interface PackSource {
17
+ /** Name shown in chunk ids, e.g. a path relative to where the user is. */
18
+ name: string;
19
+ text: string;
20
+ }
21
+ export interface Chunk {
22
+ /** `name#n`, stable for the same file and chunk size. */
23
+ id: string;
24
+ source: string;
25
+ /** 1-based, inclusive. */
26
+ startLine: number;
27
+ endLine: number;
28
+ /** Markdown heading path or the chunk's first declaration line. */
29
+ heading: string;
30
+ text: string;
31
+ tokens: number;
32
+ /** BM25 score against the query; 0 when there is none. */
33
+ score: number;
34
+ }
35
+ export interface PackOptions {
36
+ /** The question the context is for. Without one, pack returns an outline. */
37
+ query?: string;
38
+ /** Token budget for the packed text (default 4000). */
39
+ budget?: number;
40
+ /** Chunks by id (from an earlier outline or result), packed in this order of priority. */
41
+ ids?: string[];
42
+ /** Upper bound for one chunk (default 400 tokens). */
43
+ maxChunkTokens?: number;
44
+ /** Model whose tokenizer the budget is in. Default: Claude's ratios, the conservative ones. */
45
+ model?: string;
46
+ }
47
+ export interface PackResult {
48
+ mode: "query" | "ids" | "outline";
49
+ query?: string;
50
+ budget: number;
51
+ /** Tokens across every source, i.e. the cost of pasting them whole. */
52
+ totalTokens: number;
53
+ packedTokens: number;
54
+ /** Selected chunks, in source and line order. */
55
+ selected: Chunk[];
56
+ /** Every chunk, scored, in source and line order. */
57
+ chunks: Chunk[];
58
+ /** Ids asked for that do not exist. */
59
+ missingIds: string[];
60
+ /** What semantic ranking did, or why it was off (packContextSemantic only). */
61
+ semanticNote?: string;
62
+ sources: Array<{
63
+ name: string;
64
+ tokens: number;
65
+ chunks: number;
66
+ selected: number;
67
+ }>;
68
+ }
69
+ /** Split one source into chunks of at most `maxTokens`, along its own structure. */
70
+ export declare function chunkSource(source: PackSource, opts?: {
71
+ maxChunkTokens?: number;
72
+ model?: string;
73
+ }): Chunk[];
74
+ /** Lowercased terms: camelCase and snake_case split, stopwords dropped, plural -s folded. */
75
+ export declare function terms(text: string): string[];
76
+ /** BM25 (k1 1.2, b 0.75); heading terms count twice, as a heading says what a section is about. */
77
+ export declare function scoreChunks(chunks: Chunk[], query: string): void;
78
+ /** Chunk, rank and select. Pure: give it text, get the plan back. */
79
+ export declare function packContext(sources: PackSource[], opts?: PackOptions): PackResult;
80
+ /**
81
+ * packContext with optional semantic re-ranking through a local Ollama
82
+ * (`semantic: true`, or a model name). Falls back to keyword ranking, with
83
+ * the reason in `semanticNote`, when no embedding model is available.
84
+ */
85
+ export declare function packContextSemantic(sources: PackSource[], opts?: PackOptions & {
86
+ semantic?: boolean | string;
87
+ }): Promise<PackResult>;
88
+ /** Plain-text rendering for a model or a terminal. */
89
+ export declare function renderPack(r: PackResult, { outlineLimit }?: {
90
+ outlineLimit?: number;
91
+ }): string;
92
+ export declare const PACK_LIMITS: {
93
+ files: number;
94
+ fileBytes: number;
95
+ totalBytes: number;
96
+ };
97
+ export declare function readSources(paths: string[], cwd?: string): {
98
+ sources: PackSource[];
99
+ skipped: string[];
100
+ };