context-doctor 0.26.0 → 0.27.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/README.md +101 -6
- package/dist/autoclear.d.ts +9 -2
- package/dist/autoclear.js +41 -3
- package/dist/ci.d.ts +39 -0
- package/dist/ci.js +144 -0
- package/dist/cli.js +117 -0
- package/dist/doctor.js +27 -0
- package/dist/embed.d.ts +30 -0
- package/dist/embed.js +127 -0
- package/dist/extract.d.ts +17 -0
- package/dist/extract.js +88 -0
- package/dist/gemini.d.ts +23 -0
- package/dist/gemini.js +192 -0
- package/dist/hook.js +3 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/install.d.ts +2 -0
- package/dist/install.js +32 -9
- package/dist/mcp.d.ts +1 -0
- package/dist/mcp.js +36 -8
- package/dist/mcpschema.d.ts +51 -0
- package/dist/mcpschema.js +125 -0
- package/dist/overhead.d.ts +76 -0
- package/dist/overhead.js +372 -0
- package/dist/pack.d.ts +105 -0
- package/dist/pack.js +395 -0
- package/dist/proxy.d.ts +8 -1
- package/dist/proxy.js +23 -6
- package/dist/session.js +4 -0
- package/dist/split.d.ts +44 -0
- package/dist/split.js +165 -0
- package/dist/wrap.d.ts +44 -0
- package/dist/wrap.js +96 -0
- package/package.json +2 -2
- package/skills/context-doctor/SKILL.md +2 -1
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
sessions: number;
|
|
33
|
+
/** Median tokens of the first request, minus the first user message. */
|
|
34
|
+
median: number;
|
|
35
|
+
p90: number;
|
|
36
|
+
requests: number;
|
|
37
|
+
coldStarts: number;
|
|
38
|
+
days: number;
|
|
39
|
+
model?: string;
|
|
40
|
+
/** USD a month for each 1,000 tokens of fixed overhead, at this usage. */
|
|
41
|
+
usdPerKPerMonth?: number;
|
|
42
|
+
}
|
|
43
|
+
export interface OverheadReport {
|
|
44
|
+
cwd: string;
|
|
45
|
+
baseline?: Baseline;
|
|
46
|
+
files: MemoryFile[];
|
|
47
|
+
findings: OverheadFinding[];
|
|
48
|
+
mcp: McpSection;
|
|
49
|
+
}
|
|
50
|
+
export interface McpSection {
|
|
51
|
+
configs: McpServerConfig[];
|
|
52
|
+
usage: McpUsage;
|
|
53
|
+
/** Present when the servers were launched and asked for their tools (`--mcp`). */
|
|
54
|
+
sizes?: McpServerSize[];
|
|
55
|
+
}
|
|
56
|
+
/** Every memory file the four agents load for a session started in `cwd`. */
|
|
57
|
+
export declare function findMemoryFiles(cwd?: string, home?: string): MemoryFile[];
|
|
58
|
+
/**
|
|
59
|
+
* Fixed overhead per session, measured: the first main-chain request's input
|
|
60
|
+
* (input + cache read + cache write) minus the first user message, plus the
|
|
61
|
+
* request and cold-start counts that turn tokens into a monthly bill.
|
|
62
|
+
*/
|
|
63
|
+
export declare function measureBaseline(days?: number, paths?: string[]): Baseline | undefined;
|
|
64
|
+
/** What is worth changing in the memory files. */
|
|
65
|
+
export declare function overheadFindings(files: MemoryFile[], baseline?: Baseline): OverheadFinding[];
|
|
66
|
+
export declare function overheadReport(opts?: {
|
|
67
|
+
cwd?: string;
|
|
68
|
+
home?: string;
|
|
69
|
+
days?: number;
|
|
70
|
+
paths?: string[];
|
|
71
|
+
}): OverheadReport;
|
|
72
|
+
/** Launch each configured server and size its tool definitions, then add the findings they support. */
|
|
73
|
+
export declare function measureMcpSizes(r: OverheadReport): Promise<void>;
|
|
74
|
+
/** Configured servers nobody called: their definitions ride on every request for nothing. */
|
|
75
|
+
export declare function mcpFindings(m: McpSection, baseline?: Baseline): OverheadFinding[];
|
|
76
|
+
export declare function renderOverhead(r: OverheadReport, home?: string): string;
|
package/dist/overhead.js
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
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 } from "./tokens.js";
|
|
22
|
+
import { formatUsd, pricingFor } from "./pricing.js";
|
|
23
|
+
import { claudeMcpConfigs, mcpUsage, measureServer, toolPrefixName } from "./mcpschema.js";
|
|
24
|
+
const TTL_MS = 3_600_000;
|
|
25
|
+
const CLAUDE = "claude-opus-5"; // Claude's tokenizer ratios; the model only picks the provider here.
|
|
26
|
+
function readText(path) {
|
|
27
|
+
try {
|
|
28
|
+
if (!statSync(path).isFile())
|
|
29
|
+
return undefined;
|
|
30
|
+
return readFileSync(path, "utf8");
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Directories from `cwd` up to (and including) the filesystem root. */
|
|
37
|
+
function ancestors(cwd) {
|
|
38
|
+
const out = [];
|
|
39
|
+
let d = resolve(cwd);
|
|
40
|
+
for (;;) {
|
|
41
|
+
out.push(d);
|
|
42
|
+
const up = dirname(d);
|
|
43
|
+
if (up === d)
|
|
44
|
+
return out;
|
|
45
|
+
d = up;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function mdFiles(dir, ext = /\.md$/) {
|
|
49
|
+
const out = [];
|
|
50
|
+
try {
|
|
51
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
52
|
+
const p = join(dir, e.name);
|
|
53
|
+
if (e.isDirectory())
|
|
54
|
+
out.push(...mdFiles(p, ext));
|
|
55
|
+
else if (ext.test(e.name))
|
|
56
|
+
out.push(p);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
/* absent */
|
|
61
|
+
}
|
|
62
|
+
return out.sort();
|
|
63
|
+
}
|
|
64
|
+
/** `@path` imports in a CLAUDE.md, outside code spans and fences, resolved relative to the file. */
|
|
65
|
+
function imports(text, from, home) {
|
|
66
|
+
const out = [];
|
|
67
|
+
let fence = false;
|
|
68
|
+
for (const line of text.split("\n")) {
|
|
69
|
+
if (/^\s*(```|~~~)/.test(line))
|
|
70
|
+
fence = !fence;
|
|
71
|
+
if (fence)
|
|
72
|
+
continue;
|
|
73
|
+
for (const m of line.replace(/`[^`]*`/g, "").matchAll(/(?:^|\s)@((?:~\/|\.{0,2}\/|[\w.-]+\/)[^\s)]+|[\w.-]+\.\w+)/g)) {
|
|
74
|
+
const raw = m[1];
|
|
75
|
+
out.push(raw.startsWith("~/") ? join(home, raw.slice(2)) : isAbsolute(raw) ? raw : resolve(dirname(from), raw));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
/** Claude Code's auto-memory index for a directory (it loads MEMORY.md, first 200 lines). */
|
|
81
|
+
function autoMemoryPath(cwd, home) {
|
|
82
|
+
return join(home, ".claude", "projects", resolve(cwd).replace(/[^A-Za-z0-9]/g, "-"), "memory", "MEMORY.md");
|
|
83
|
+
}
|
|
84
|
+
/** Every memory file the four agents load for a session started in `cwd`. */
|
|
85
|
+
export function findMemoryFiles(cwd = process.cwd(), home = homedir()) {
|
|
86
|
+
const files = [];
|
|
87
|
+
const seen = new Set();
|
|
88
|
+
const add = (agent, path, via, text) => {
|
|
89
|
+
const key = `${agent}:${resolve(path)}`;
|
|
90
|
+
if (seen.has(key))
|
|
91
|
+
return undefined;
|
|
92
|
+
text ??= readText(path);
|
|
93
|
+
if (text === undefined || !text.trim())
|
|
94
|
+
return undefined;
|
|
95
|
+
seen.add(key);
|
|
96
|
+
files.push({ agent, path, via, tokens: estimateTokens(text, CLAUDE) });
|
|
97
|
+
return text;
|
|
98
|
+
};
|
|
99
|
+
const addClaude = (path, via, depth = 0) => {
|
|
100
|
+
const text = add("Claude Code", path, via);
|
|
101
|
+
if (text === undefined || depth >= 4)
|
|
102
|
+
return;
|
|
103
|
+
for (const p of imports(text, path, home))
|
|
104
|
+
addClaude(p, "import", depth + 1);
|
|
105
|
+
};
|
|
106
|
+
// Claude Code: user memory and rules, then each directory from the root down.
|
|
107
|
+
addClaude(join(home, ".claude", "CLAUDE.md"), "user");
|
|
108
|
+
for (const p of mdFiles(join(home, ".claude", "rules")))
|
|
109
|
+
addClaude(p, "rules");
|
|
110
|
+
for (const d of ancestors(cwd).reverse()) {
|
|
111
|
+
addClaude(join(d, "CLAUDE.md"), "project");
|
|
112
|
+
addClaude(join(d, ".claude", "CLAUDE.md"), "project");
|
|
113
|
+
addClaude(join(d, "CLAUDE.local.md"), "project (local)");
|
|
114
|
+
if (d !== home)
|
|
115
|
+
for (const p of mdFiles(join(d, ".claude", "rules")))
|
|
116
|
+
addClaude(p, "rules");
|
|
117
|
+
}
|
|
118
|
+
const auto = readText(autoMemoryPath(cwd, home));
|
|
119
|
+
if (auto)
|
|
120
|
+
add("Claude Code", autoMemoryPath(cwd, home), "auto memory", auto.split("\n").slice(0, 200).join("\n"));
|
|
121
|
+
// Codex: global AGENTS.md, then each directory down to cwd (it stops at the git root; root-ward extras are rare).
|
|
122
|
+
add("Codex", join(home, ".codex", "AGENTS.md"), "user");
|
|
123
|
+
for (const d of ancestors(cwd).reverse()) {
|
|
124
|
+
if (d === home)
|
|
125
|
+
continue;
|
|
126
|
+
add("Codex", join(d, "AGENTS.override.md"), "project") ?? add("Codex", join(d, "AGENTS.md"), "project");
|
|
127
|
+
}
|
|
128
|
+
// Cursor: legacy .cursorrules and always-applied project rules.
|
|
129
|
+
add("Cursor", join(cwd, ".cursorrules"), "project");
|
|
130
|
+
for (const p of mdFiles(join(cwd, ".cursor", "rules"), /\.mdc?$/)) {
|
|
131
|
+
const text = readText(p) ?? "";
|
|
132
|
+
if (/^alwaysApply:\s*true/m.test(text))
|
|
133
|
+
add("Cursor", p, "rules (always)", text);
|
|
134
|
+
}
|
|
135
|
+
// Gemini CLI: global, then each directory down to cwd.
|
|
136
|
+
add("Gemini CLI", join(home, ".gemini", "GEMINI.md"), "user");
|
|
137
|
+
for (const d of ancestors(cwd).reverse())
|
|
138
|
+
if (d !== home)
|
|
139
|
+
add("Gemini CLI", join(d, "GEMINI.md"), "project");
|
|
140
|
+
return files;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Fixed overhead per session, measured: the first main-chain request's input
|
|
144
|
+
* (input + cache read + cache write) minus the first user message, plus the
|
|
145
|
+
* request and cold-start counts that turn tokens into a monthly bill.
|
|
146
|
+
*/
|
|
147
|
+
export function measureBaseline(days = 30, paths) {
|
|
148
|
+
const since = Date.now() - days * 86_400_000;
|
|
149
|
+
const files = paths ?? listSessions(10_000).filter((s) => s.path.includes(".claude") && s.modifiedAt.getTime() >= since).map((s) => s.path);
|
|
150
|
+
const firsts = [];
|
|
151
|
+
let requests = 0, coldStarts = 0;
|
|
152
|
+
const models = new Map();
|
|
153
|
+
for (const path of files) {
|
|
154
|
+
let firstUser;
|
|
155
|
+
let first;
|
|
156
|
+
let lastId;
|
|
157
|
+
let prevAt;
|
|
158
|
+
forEachLine(path, (line) => {
|
|
159
|
+
let e;
|
|
160
|
+
try {
|
|
161
|
+
e = JSON.parse(line);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (!e || e.isSidechain)
|
|
167
|
+
return;
|
|
168
|
+
if (e.type === "user" && first === undefined && firstUser === undefined && !e.isMeta) {
|
|
169
|
+
const c = e.message?.content;
|
|
170
|
+
const text = typeof c === "string" ? c : Array.isArray(c) ? c.filter((b) => b?.type === "text").map((b) => b.text).join("\n") : "";
|
|
171
|
+
if (text)
|
|
172
|
+
firstUser = text;
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (e.type !== "assistant" || !e.message?.usage || e.message.id === lastId)
|
|
176
|
+
return;
|
|
177
|
+
lastId = e.message.id;
|
|
178
|
+
const u = e.message.usage;
|
|
179
|
+
const prompt = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
|
|
180
|
+
const at = Date.parse(e.timestamp);
|
|
181
|
+
if (!(prompt > 0) || !Number.isFinite(at))
|
|
182
|
+
return;
|
|
183
|
+
if (first === undefined)
|
|
184
|
+
first = Math.max(0, prompt - estimateTokens(firstUser ?? "", CLAUDE));
|
|
185
|
+
if (at >= since) {
|
|
186
|
+
requests++;
|
|
187
|
+
if (prevAt === undefined || at - prevAt > TTL_MS)
|
|
188
|
+
coldStarts++;
|
|
189
|
+
if (e.message.model)
|
|
190
|
+
models.set(e.message.model, (models.get(e.message.model) ?? 0) + 1);
|
|
191
|
+
}
|
|
192
|
+
prevAt = at;
|
|
193
|
+
});
|
|
194
|
+
if (first !== undefined && first > 1000)
|
|
195
|
+
firsts.push(first);
|
|
196
|
+
}
|
|
197
|
+
if (firsts.length === 0)
|
|
198
|
+
return undefined;
|
|
199
|
+
firsts.sort((a, b) => a - b);
|
|
200
|
+
const model = [...models.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
|
|
201
|
+
const price = pricingFor(model);
|
|
202
|
+
// Each overhead token is a cache read on warm requests and a cache write
|
|
203
|
+
// (1.25x input) after every cold start.
|
|
204
|
+
const usdPerKPerMonth = price
|
|
205
|
+
? ((1000 * ((requests - coldStarts) * price.cacheReadPerM + coldStarts * 1.25 * price.inputPerM)) / 1e6) * (30 / days)
|
|
206
|
+
: undefined;
|
|
207
|
+
return {
|
|
208
|
+
sessions: firsts.length,
|
|
209
|
+
median: firsts[Math.floor(firsts.length / 2)],
|
|
210
|
+
p90: firsts[Math.min(firsts.length - 1, Math.floor(firsts.length * 0.9))],
|
|
211
|
+
requests,
|
|
212
|
+
coldStarts,
|
|
213
|
+
days,
|
|
214
|
+
model,
|
|
215
|
+
usdPerKPerMonth,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
function normalize(s) {
|
|
219
|
+
return s.toLowerCase().replace(/\s+/g, " ").trim();
|
|
220
|
+
}
|
|
221
|
+
/** What is worth changing in the memory files. */
|
|
222
|
+
export function overheadFindings(files, baseline) {
|
|
223
|
+
const out = [];
|
|
224
|
+
const cost = (tok) => (baseline?.usdPerKPerMonth ? ` (~${formatUsd((tok / 1000) * baseline.usdPerKPerMonth)}/month at your usage)` : "");
|
|
225
|
+
for (const f of files) {
|
|
226
|
+
if (f.tokens >= 2000) {
|
|
227
|
+
out.push({
|
|
228
|
+
severity: f.tokens >= 5000 ? "warn" : "info",
|
|
229
|
+
message: `${f.path} is ~${formatTokens(f.tokens)} tokens, read on every ${f.agent} request${f.agent === "Claude Code" ? cost(f.tokens) : ""}.`,
|
|
230
|
+
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.`,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// Paragraphs repeated across files of the same agent load twice.
|
|
235
|
+
const byPara = new Map();
|
|
236
|
+
for (const f of files) {
|
|
237
|
+
const text = readText(f.path) ?? "";
|
|
238
|
+
for (const p of text.split(/\n\s*\n/)) {
|
|
239
|
+
const n = normalize(p);
|
|
240
|
+
if (n.length < 120)
|
|
241
|
+
continue;
|
|
242
|
+
const k = `${f.agent}\u0000${n}`;
|
|
243
|
+
(byPara.get(k) ?? byPara.set(k, new Set()).get(k)).add(f.path);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const dup = new Map();
|
|
247
|
+
for (const [k, set] of byPara) {
|
|
248
|
+
if (set.size < 2)
|
|
249
|
+
continue;
|
|
250
|
+
const key = [...set].sort().join(" + ");
|
|
251
|
+
const d = dup.get(key) ?? { tokens: 0, count: 0 };
|
|
252
|
+
d.tokens += estimateTokens(k.split("\u0000")[1], CLAUDE) * (set.size - 1);
|
|
253
|
+
d.count++;
|
|
254
|
+
dup.set(key, d);
|
|
255
|
+
}
|
|
256
|
+
for (const [key, d] of dup) {
|
|
257
|
+
out.push({
|
|
258
|
+
severity: "info",
|
|
259
|
+
message: `${d.count} paragraph(s) appear in more than one loaded file (${key}): ~${formatTokens(d.tokens)} tokens read twice.`,
|
|
260
|
+
suggestion: "Keep each rule in one file.",
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
// Big fenced blocks: examples and reference code rarely need to ride on every request.
|
|
264
|
+
for (const f of files) {
|
|
265
|
+
const text = readText(f.path) ?? "";
|
|
266
|
+
let total = 0;
|
|
267
|
+
for (const m of text.matchAll(/```[\s\S]*?```/g))
|
|
268
|
+
total += estimateTokens(m[0], CLAUDE);
|
|
269
|
+
if (total >= 800) {
|
|
270
|
+
out.push({
|
|
271
|
+
severity: "info",
|
|
272
|
+
message: `${f.path} carries ~${formatTokens(total)} tokens of code blocks.`,
|
|
273
|
+
suggestion: "Move long examples to a file the agent opens when the task needs them.",
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return out;
|
|
278
|
+
}
|
|
279
|
+
export function overheadReport(opts = {}) {
|
|
280
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
281
|
+
const home = opts.home ?? homedir();
|
|
282
|
+
const files = findMemoryFiles(cwd, home);
|
|
283
|
+
const baseline = measureBaseline(opts.days ?? 30, opts.paths);
|
|
284
|
+
const mcp = { configs: claudeMcpConfigs(cwd, home), usage: mcpUsage(opts.days ?? 30, opts.paths) };
|
|
285
|
+
return { cwd, baseline, files, findings: overheadFindings(files, baseline), mcp };
|
|
286
|
+
}
|
|
287
|
+
/** Launch each configured server and size its tool definitions, then add the findings they support. */
|
|
288
|
+
export async function measureMcpSizes(r) {
|
|
289
|
+
r.mcp.sizes = await Promise.all(r.mcp.configs.map((c) => measureServer(c)));
|
|
290
|
+
r.findings.push(...mcpFindings(r.mcp, r.baseline));
|
|
291
|
+
}
|
|
292
|
+
/** Configured servers nobody called: their definitions ride on every request for nothing. */
|
|
293
|
+
export function mcpFindings(m, baseline) {
|
|
294
|
+
const out = [];
|
|
295
|
+
const deferred = m.usage.sessions > 0 && m.usage.toolSearchSessions / m.usage.sessions >= 0.5;
|
|
296
|
+
for (const c of m.configs) {
|
|
297
|
+
const size = m.sizes?.find((s) => s.name === c.name);
|
|
298
|
+
const calls = m.usage.calls.get(toolPrefixName(c.name)) ?? 0;
|
|
299
|
+
const tokens = size?.schemaTokens === undefined ? undefined : deferred ? size.nameTokens : size.schemaTokens;
|
|
300
|
+
if (calls > 0 || tokens === undefined || tokens < 200)
|
|
301
|
+
continue;
|
|
302
|
+
const usd = baseline?.usdPerKPerMonth ? ` (~${formatUsd((tokens / 1000) * baseline.usdPerKPerMonth)}/month)` : "";
|
|
303
|
+
out.push({
|
|
304
|
+
severity: tokens >= 2000 ? "warn" : "info",
|
|
305
|
+
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}.`,
|
|
306
|
+
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.`,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
return out;
|
|
310
|
+
}
|
|
311
|
+
export function renderOverhead(r, home = homedir()) {
|
|
312
|
+
const out = [];
|
|
313
|
+
const short = (p) => (p.startsWith(home) ? "~" + p.slice(home.length) : relative(r.cwd, p) || p);
|
|
314
|
+
out.push("FIXED OVERHEAD: what every request re-reads before your message", "═".repeat(56));
|
|
315
|
+
const b = r.baseline;
|
|
316
|
+
const claudeTokens = r.files.filter((f) => f.agent === "Claude Code").reduce((s, f) => s + f.tokens, 0);
|
|
317
|
+
if (b) {
|
|
318
|
+
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.`);
|
|
319
|
+
if (b.usdPerKPerMonth !== undefined) {
|
|
320
|
+
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.`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
out.push("No Claude Code sessions in the period to measure; memory files are sized below.");
|
|
325
|
+
}
|
|
326
|
+
out.push(...renderMcp(r));
|
|
327
|
+
out.push("", "Memory files loaded in this directory", "─".repeat(56));
|
|
328
|
+
if (r.files.length === 0)
|
|
329
|
+
out.push(" none");
|
|
330
|
+
for (const f of r.files) {
|
|
331
|
+
const usd = f.agent === "Claude Code" && b?.usdPerKPerMonth !== undefined ? ` ${formatUsd((f.tokens / 1000) * b.usdPerKPerMonth)}/mo` : "";
|
|
332
|
+
out.push(` ${f.agent.padEnd(11)} ~${formatTokens(f.tokens).padStart(5)}${usd.padEnd(12)} ${short(f.path)}${f.via === "project" ? "" : ` (${f.via})`}`);
|
|
333
|
+
}
|
|
334
|
+
out.push("", `Findings (${r.findings.length})`, "─".repeat(56));
|
|
335
|
+
if (r.findings.length === 0)
|
|
336
|
+
out.push(" Nothing to trim. The memory files are lean.");
|
|
337
|
+
for (const f of r.findings)
|
|
338
|
+
out.push(`${f.severity === "warn" ? "▲" : "ℹ"} ${f.message.replace(home, "~")}`, ` → ${f.suggestion}`);
|
|
339
|
+
return out.join("\n");
|
|
340
|
+
}
|
|
341
|
+
function renderMcp(r) {
|
|
342
|
+
const { configs, usage, sizes } = r.mcp;
|
|
343
|
+
if (configs.length === 0 && usage.calls.size === 0)
|
|
344
|
+
return [];
|
|
345
|
+
const out = ["", "MCP servers", "─".repeat(56)];
|
|
346
|
+
const deferred = usage.sessions > 0 && usage.toolSearchSessions / usage.sessions >= 0.5;
|
|
347
|
+
out.push(deferred
|
|
348
|
+
? `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.`
|
|
349
|
+
: `Tool search was used in ${usage.toolSearchSessions} of ${usage.sessions} sessions: MCP definitions mostly load in full, on every request.`);
|
|
350
|
+
const configured = new Set(configs.map((c) => toolPrefixName(c.name)));
|
|
351
|
+
for (const c of configs) {
|
|
352
|
+
const s = sizes?.find((x) => x.name === c.name);
|
|
353
|
+
const calls = usage.calls.get(toolPrefixName(c.name)) ?? 0;
|
|
354
|
+
const size = !sizes
|
|
355
|
+
? "size: run with --mcp"
|
|
356
|
+
: s?.error
|
|
357
|
+
? s.error
|
|
358
|
+
: `${s.tools} tools, ~${formatTokens(s.schemaTokens)} tokens of definitions (names ~${formatTokens(s.nameTokens)})`;
|
|
359
|
+
out.push(` ${c.name.padEnd(22)} ${c.scope.padEnd(9)} ${String(calls).padStart(6)} calls ${size}`);
|
|
360
|
+
}
|
|
361
|
+
const app = [...usage.calls.entries()].filter(([n]) => !configured.has(n)).sort((a, b) => b[1] - a[1]);
|
|
362
|
+
if (app.length) {
|
|
363
|
+
out.push(` Provided by the app or connectors (not in your config; toggle them in the app):`);
|
|
364
|
+
for (const [n, calls] of app.slice(0, 10))
|
|
365
|
+
out.push(` ${n.padEnd(38)} ${String(calls).padStart(6)} calls`);
|
|
366
|
+
if (app.length > 10)
|
|
367
|
+
out.push(` … ${app.length - 10} more`);
|
|
368
|
+
}
|
|
369
|
+
if (!sizes && configs.length)
|
|
370
|
+
out.push(` \`context-doctor overhead --mcp\` launches the ${configs.length} configured server(s) once to size their definitions.`);
|
|
371
|
+
return out;
|
|
372
|
+
}
|
package/dist/pack.d.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
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
|
+
/**
|
|
98
|
+
* Read files and directories (recursively, skipping build and dependency
|
|
99
|
+
* folders, binaries, and anything past the size limits) into sources.
|
|
100
|
+
* `skipped` says what was left out and why, so nothing vanishes silently.
|
|
101
|
+
*/
|
|
102
|
+
export declare function readSources(paths: string[], cwd?: string): {
|
|
103
|
+
sources: PackSource[];
|
|
104
|
+
skipped: string[];
|
|
105
|
+
};
|