context-doctor 0.9.0 → 0.11.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 +41 -3
- package/dist/cache.d.ts +30 -0
- package/dist/cache.js +97 -0
- package/dist/cli.js +33 -7
- package/dist/impact.js +10 -3
- package/dist/ledger.d.ts +5 -2
- package/dist/ledger.js +1 -0
- package/dist/mcp.js +1 -1
- package/dist/profile.d.ts +1 -1
- package/dist/profile.js +64 -1
- package/dist/proxy.js +31 -0
- package/dist/report.d.ts +9 -1
- package/dist/report.js +11 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -88,10 +88,11 @@ Practical upshot: a developer who only wants cheaper, faster API calls never tou
|
|
|
88
88
|
| Command | What it does |
|
|
89
89
|
|---|---|
|
|
90
90
|
| `context-doctor install` / `uninstall` | Wire (or remove) everything: MCP for Claude Desktop/Code/Cursor, the Agent Skill, the every-prompt hook |
|
|
91
|
-
| `context-doctor analyze <file>` | Profile a conversation: token breakdown, findings, cost + latency estimates |
|
|
91
|
+
| `context-doctor analyze <file>` | Profile a conversation: token breakdown, findings, cost + latency estimates. `--fail-over-budget` exits 1 on a breach, for CI |
|
|
92
92
|
| `context-doctor optimize <file>` | Apply the safe fixes; `--strategy prune-history` for consented lossy compaction |
|
|
93
|
-
| `context-doctor session [file]` | Profile a Claude Code session
|
|
94
|
-
| `context-doctor
|
|
93
|
+
| `context-doctor session [file]` | Profile a Claude Code session: live context, findings, **measured tokens and prompt-cache economics**. Also reads ChatGPT data exports (`conversations.json`) |
|
|
94
|
+
| `context-doctor cursor [--list]` | Profile a chat from Cursor's local history (both storage formats) |
|
|
95
|
+
| `context-doctor report` | Machine-wide impact report (proxy savings persist across restarts): exact proxy savings, hook activity, recoverable waste in recent sessions |
|
|
95
96
|
| `context-doctor proxy` | Always-on local proxy that optimizes every Anthropic/OpenAI API request in flight (`/stats` for cumulative savings) |
|
|
96
97
|
| `context-doctor watch [file]` | Live monitor of a growing session/agent trace: token/cost line per change, findings as they appear |
|
|
97
98
|
| `context-doctor doctor` | Self-check the whole installation — one pasteable ✓/✗ diagnosis with fixes |
|
|
@@ -245,6 +246,8 @@ const { conversation, tokensBefore, tokensAfter } = optimizeConversation(chatJso
|
|
|
245
246
|
- **Oversized tool results** — the #1 context killer in agent loops
|
|
246
247
|
- **Duplicate content** — the same doc/result pasted twice
|
|
247
248
|
- **Near-duplicates** — the same doc re-pasted with different surrounding words (shingle similarity, ≥60%)
|
|
249
|
+
- **Repeated file reads** — the same file pulled in three or more times, every copy still in context
|
|
250
|
+
- **Retained error output** — stack traces and failed commands kept verbatim long after the fix landed
|
|
248
251
|
- **Repeated identical tool calls** — a signal your agent forgot earlier results
|
|
249
252
|
- **Base64 / binary blobs** in text content
|
|
250
253
|
- **Long history** past the point where models track the middle
|
|
@@ -296,6 +299,36 @@ Drop a `.contextdoctorrc` in a project (or your home directory) and context-doct
|
|
|
296
299
|
|
|
297
300
|
The nearest file wins (walking up from the working directory, then `~`). `analyze` and `session` print a budget verdict, the every-prompt hook uses `maxTokens` as its warning threshold and names the breach to the model, and `optimize`/`proxy` pick up the defaults when you do not pass flags.
|
|
298
301
|
|
|
302
|
+
## Prompt-cache economics (Claude Code sessions)
|
|
303
|
+
|
|
304
|
+
Caching is the largest lever on LLM cost, and transcripts record exactly how it went — so `session` reports it as fact rather than estimate:
|
|
305
|
+
|
|
306
|
+
```
|
|
307
|
+
Prompt cache: 95.6% of input served from cache across 1117 requests
|
|
308
|
+
read 558.6M · written 25.5M · uncached 2k
|
|
309
|
+
input cost $438.88 — caching saved $2481.89 against $2920.76 uncached (list prices)
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
A cache read bills at ~10% of input while a write bills at ~125%, so a session that keeps invalidating its prefix can cost *more* than one with no caching at all. context-doctor warns on the two failure modes: a **low hit rate** (something early in the prompt changes every request) and **cache churn** (writes rivalling reads).
|
|
313
|
+
|
|
314
|
+
## Enforce a budget in CI
|
|
315
|
+
|
|
316
|
+
```bash
|
|
317
|
+
npx context-doctor analyze conversation.json --fail-over-budget
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
Exits 1 when the `.contextdoctorrc` budget is breached, so a pull request can be gated on context size the same way it is gated on tests.
|
|
321
|
+
|
|
322
|
+
## Sharing a profile safely
|
|
323
|
+
|
|
324
|
+
A profile quotes message previews and file paths, so pasting one into an issue pastes fragments of real work. `--redact` keeps every number and the finding structure but replaces content with `[redacted]` and masks paths:
|
|
325
|
+
|
|
326
|
+
```bash
|
|
327
|
+
npx context-doctor session --redact
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
`context-doctor doctor` is safe to paste as-is: it reports integration status, never conversation content.
|
|
331
|
+
|
|
299
332
|
## Performance: what context-doctor itself costs
|
|
300
333
|
|
|
301
334
|
A tool that promises speed must be near-free. Measured overhead per touchpoint:
|
|
@@ -306,6 +339,7 @@ A tool that promises speed must be near-free. Measured overhead per touchpoint:
|
|
|
306
339
|
| MCP server | Spawned once per app session | Tools run only when called; standing instructions cost **~110 tokens per conversation** — deliberately terse |
|
|
307
340
|
| Proxy | Per API request | ~1–3ms of CPU (parse → optimize → re-serialize) against typical model latencies of hundreds of ms; responses stream through chunk-by-chunk, never buffered |
|
|
308
341
|
| Skill | Loads only when relevant | ~1k tokens while active; its always-present description is ~60 tokens |
|
|
342
|
+
| Profiling a session | On demand, and on hook growth events | ~160ms for an 8.5MB / 1,855-message transcript (near-duplicate pairs that cannot clear the similarity bar are skipped without comparison) |
|
|
309
343
|
| CLI / library | Only when you run it | Not in any hot path |
|
|
310
344
|
|
|
311
345
|
Net effect is strongly negative overhead: the tokens these touchpoints save on every subsequent call dwarf what they cost.
|
|
@@ -344,6 +378,10 @@ Known gotcha: if `npm publish` fails with **`404 Not Found - PUT …/context-doc
|
|
|
344
378
|
|
|
345
379
|
Also keep the MCP server version in `src/mcp.ts` in sync with `package.json`, and remember `dist/` is committed — run `npm run build` before committing so the CI dist-sync check passes.
|
|
346
380
|
|
|
381
|
+
## Contributing
|
|
382
|
+
|
|
383
|
+
Issues and PRs welcome — see [CONTRIBUTING.md](./CONTRIBUTING.md) for the six rules that keep this tool trustworthy (no API keys, nothing leaves the machine, no silent data loss, measurements not guesses, the hot path stays cheap, tests with every change) and a list of good first issues. What is planned next lives in [ROADMAP.md](./ROADMAP.md).
|
|
384
|
+
|
|
347
385
|
## License
|
|
348
386
|
|
|
349
387
|
MIT © [gAI Ventures](https://gai.ventures)
|
package/dist/cache.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt-cache analysis for Claude Code sessions.
|
|
3
|
+
*
|
|
4
|
+
* Transcripts record what the API actually charged per request — including
|
|
5
|
+
* cache reads and cache writes — so cache behaviour can be reported as fact
|
|
6
|
+
* rather than estimated. This is usually the largest single lever on cost:
|
|
7
|
+
* a cached read bills at ~10% of input, while a cache write bills at ~125%,
|
|
8
|
+
* so a session that keeps invalidating its prefix pays more than one with no
|
|
9
|
+
* caching at all.
|
|
10
|
+
*/
|
|
11
|
+
export interface CacheUsage {
|
|
12
|
+
requests: number;
|
|
13
|
+
cacheReadTokens: number;
|
|
14
|
+
cacheWriteTokens: number;
|
|
15
|
+
uncachedTokens: number;
|
|
16
|
+
/** Share of input tokens served from cache (0-1). */
|
|
17
|
+
hitRate: number;
|
|
18
|
+
model?: string;
|
|
19
|
+
/** What the input actually cost, at list prices. */
|
|
20
|
+
paidUsd?: number;
|
|
21
|
+
/** What the same input would have cost with no caching at all. */
|
|
22
|
+
uncachedUsd?: number;
|
|
23
|
+
/** paidUsd vs uncachedUsd — positive means caching is paying off. */
|
|
24
|
+
savedUsd?: number;
|
|
25
|
+
/** USD spent on cache writes; high values mean the prefix keeps changing. */
|
|
26
|
+
writeUsd?: number;
|
|
27
|
+
}
|
|
28
|
+
export declare function analyzeCacheUsage(transcriptPath: string): CacheUsage | null;
|
|
29
|
+
/** One-paragraph verdict for humans, or null when there is nothing to say. */
|
|
30
|
+
export declare function renderCacheReport(usage: CacheUsage | null): string | null;
|
package/dist/cache.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt-cache analysis for Claude Code sessions.
|
|
3
|
+
*
|
|
4
|
+
* Transcripts record what the API actually charged per request — including
|
|
5
|
+
* cache reads and cache writes — so cache behaviour can be reported as fact
|
|
6
|
+
* rather than estimated. This is usually the largest single lever on cost:
|
|
7
|
+
* a cached read bills at ~10% of input, while a cache write bills at ~125%,
|
|
8
|
+
* so a session that keeps invalidating its prefix pays more than one with no
|
|
9
|
+
* caching at all.
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { pricingFor } from "./pricing.js";
|
|
13
|
+
/** Cache pricing multipliers relative to the base input rate. */
|
|
14
|
+
const CACHE_WRITE_MULTIPLIER = 1.25;
|
|
15
|
+
export function analyzeCacheUsage(transcriptPath) {
|
|
16
|
+
let raw;
|
|
17
|
+
try {
|
|
18
|
+
raw = readFileSync(transcriptPath, "utf8");
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
let requests = 0;
|
|
24
|
+
let cacheReadTokens = 0;
|
|
25
|
+
let cacheWriteTokens = 0;
|
|
26
|
+
let uncachedTokens = 0;
|
|
27
|
+
let model;
|
|
28
|
+
for (const line of raw.split("\n")) {
|
|
29
|
+
if (!line.trim())
|
|
30
|
+
continue;
|
|
31
|
+
let entry;
|
|
32
|
+
try {
|
|
33
|
+
entry = JSON.parse(line);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (entry.type !== "assistant" || !entry.message)
|
|
39
|
+
continue;
|
|
40
|
+
const usage = entry.message.usage;
|
|
41
|
+
if (!usage)
|
|
42
|
+
continue;
|
|
43
|
+
requests++;
|
|
44
|
+
cacheReadTokens += usage.cache_read_input_tokens ?? 0;
|
|
45
|
+
cacheWriteTokens += usage.cache_creation_input_tokens ?? 0;
|
|
46
|
+
uncachedTokens += usage.input_tokens ?? 0;
|
|
47
|
+
if (typeof entry.message.model === "string")
|
|
48
|
+
model = entry.message.model;
|
|
49
|
+
}
|
|
50
|
+
if (requests === 0)
|
|
51
|
+
return null;
|
|
52
|
+
const totalInput = cacheReadTokens + cacheWriteTokens + uncachedTokens;
|
|
53
|
+
const usage = {
|
|
54
|
+
requests,
|
|
55
|
+
cacheReadTokens,
|
|
56
|
+
cacheWriteTokens,
|
|
57
|
+
uncachedTokens,
|
|
58
|
+
hitRate: totalInput > 0 ? cacheReadTokens / totalInput : 0,
|
|
59
|
+
model,
|
|
60
|
+
};
|
|
61
|
+
const pricing = pricingFor(model);
|
|
62
|
+
if (pricing) {
|
|
63
|
+
const perM = (tokens, rate) => (tokens / 1_000_000) * rate;
|
|
64
|
+
const readUsd = perM(cacheReadTokens, pricing.cacheReadPerM);
|
|
65
|
+
const writeUsd = perM(cacheWriteTokens, pricing.inputPerM * CACHE_WRITE_MULTIPLIER);
|
|
66
|
+
const freshUsd = perM(uncachedTokens, pricing.inputPerM);
|
|
67
|
+
usage.paidUsd = readUsd + writeUsd + freshUsd;
|
|
68
|
+
usage.uncachedUsd = perM(totalInput, pricing.inputPerM);
|
|
69
|
+
usage.savedUsd = usage.uncachedUsd - usage.paidUsd;
|
|
70
|
+
usage.writeUsd = writeUsd;
|
|
71
|
+
}
|
|
72
|
+
return usage;
|
|
73
|
+
}
|
|
74
|
+
/** One-paragraph verdict for humans, or null when there is nothing to say. */
|
|
75
|
+
export function renderCacheReport(usage) {
|
|
76
|
+
if (!usage)
|
|
77
|
+
return null;
|
|
78
|
+
const pct = (usage.hitRate * 100).toFixed(1);
|
|
79
|
+
const lines = [];
|
|
80
|
+
const fmt = (n) => (n >= 1 ? `$${n.toFixed(2)}` : `$${n.toFixed(3)}`);
|
|
81
|
+
const tok = (n) => n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(n);
|
|
82
|
+
lines.push(`Prompt cache: ${pct}% of input served from cache across ${usage.requests} requests`);
|
|
83
|
+
lines.push(` read ${tok(usage.cacheReadTokens)} · written ${tok(usage.cacheWriteTokens)} · uncached ${tok(usage.uncachedTokens)}`);
|
|
84
|
+
if (usage.paidUsd !== undefined && usage.uncachedUsd !== undefined && usage.savedUsd !== undefined) {
|
|
85
|
+
lines.push(` input cost ${fmt(usage.paidUsd)} — caching saved ${fmt(usage.savedUsd)} against ${fmt(usage.uncachedUsd)} uncached (list prices)`);
|
|
86
|
+
}
|
|
87
|
+
// A cache write costs 1.25x input while a read costs 0.1x, so writes that
|
|
88
|
+
// rival reads mean the prefix keeps changing and caching is losing money.
|
|
89
|
+
const writeShare = usage.cacheReadTokens > 0 ? usage.cacheWriteTokens / usage.cacheReadTokens : Infinity;
|
|
90
|
+
if (usage.hitRate < 0.5 && usage.requests > 5) {
|
|
91
|
+
lines.push(" ⚠ Low hit rate: something early in the prompt changes between requests. Keep the system prompt, tool list and reference docs byte-stable and put volatile content last.");
|
|
92
|
+
}
|
|
93
|
+
else if (writeShare > 0.5) {
|
|
94
|
+
lines.push(" ⚠ Cache churn: writes are large relative to reads, and a write costs 1.25x input against 0.1x for a read. The cached prefix is being rebuilt often.");
|
|
95
|
+
}
|
|
96
|
+
return lines.join("\n");
|
|
97
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -26,6 +26,7 @@ import { exactTokenCount } from "./exact.js";
|
|
|
26
26
|
import { checkBudget, loadConfig } from "./config.js";
|
|
27
27
|
import { startDashboard } from "./dashboard.js";
|
|
28
28
|
import { listCursorChats, parseCursorChat } from "./cursor.js";
|
|
29
|
+
import { analyzeCacheUsage, renderCacheReport } from "./cache.js";
|
|
29
30
|
const HELP = `context-doctor — profile and optimize LLM context windows
|
|
30
31
|
|
|
31
32
|
Usage:
|
|
@@ -63,7 +64,11 @@ Options:
|
|
|
63
64
|
--model <name> Model name for window-size math (e.g. claude-sonnet-5, gpt-4o)
|
|
64
65
|
--exact (analyze) Add an exact token count: Anthropic count-tokens API for
|
|
65
66
|
Claude models (needs ANTHROPIC_API_KEY), tiktoken for GPT (if installed)
|
|
67
|
+
--redact Mask message previews and file paths in the report, so it can be
|
|
68
|
+
shared in a bug report without leaking conversation content
|
|
66
69
|
--json Machine-readable output
|
|
70
|
+
--fail-over-budget (analyze/session) Exit 1 when the .contextdoctorrc budget is
|
|
71
|
+
exceeded — lets CI gate a pull request on context size
|
|
67
72
|
--out <file> (optimize) Write result to file instead of stdout
|
|
68
73
|
--strategy <id> (optimize) Strategy to run; repeatable.
|
|
69
74
|
Available: dedupe, trim-tool-results, strip-base64, prune-history
|
|
@@ -86,7 +91,7 @@ Examples:
|
|
|
86
91
|
export OPENAI_BASE_URL=http://localhost:8787/v1
|
|
87
92
|
`;
|
|
88
93
|
function parseArgs(argv) {
|
|
89
|
-
const args = { json: false, strategies: [], list: false, exact: false };
|
|
94
|
+
const args = { json: false, strategies: [], list: false, exact: false, redact: false, failOverBudget: false };
|
|
90
95
|
const positional = [];
|
|
91
96
|
for (let i = 0; i < argv.length; i++) {
|
|
92
97
|
const a = argv[i];
|
|
@@ -104,6 +109,12 @@ function parseArgs(argv) {
|
|
|
104
109
|
case "--exact":
|
|
105
110
|
args.exact = true;
|
|
106
111
|
break;
|
|
112
|
+
case "--redact":
|
|
113
|
+
args.redact = true;
|
|
114
|
+
break;
|
|
115
|
+
case "--fail-over-budget":
|
|
116
|
+
args.failOverBudget = true;
|
|
117
|
+
break;
|
|
107
118
|
case "--model":
|
|
108
119
|
args.model = argv[++i];
|
|
109
120
|
break;
|
|
@@ -148,7 +159,7 @@ function parseArgs(argv) {
|
|
|
148
159
|
function printBudgetStatus(profile, loaded) {
|
|
149
160
|
const budget = loaded.config.budget;
|
|
150
161
|
if (!budget || !loaded.path)
|
|
151
|
-
return;
|
|
162
|
+
return false;
|
|
152
163
|
const verdict = checkBudget(budget, profile);
|
|
153
164
|
console.log("");
|
|
154
165
|
if (verdict.overBudget) {
|
|
@@ -159,6 +170,14 @@ function printBudgetStatus(profile, loaded) {
|
|
|
159
170
|
else {
|
|
160
171
|
console.log(`Within budget (${loaded.path}).`);
|
|
161
172
|
}
|
|
173
|
+
return verdict.overBudget;
|
|
174
|
+
}
|
|
175
|
+
/** Exit 1 when the caller asked CI to fail on a breach. */
|
|
176
|
+
function applyBudgetGate(overBudget, failOverBudget) {
|
|
177
|
+
if (overBudget && failOverBudget) {
|
|
178
|
+
console.error("context-doctor: over budget (--fail-over-budget)");
|
|
179
|
+
process.exitCode = 1;
|
|
180
|
+
}
|
|
162
181
|
}
|
|
163
182
|
function readInput(file) {
|
|
164
183
|
if (file === "-")
|
|
@@ -214,7 +233,7 @@ function main() {
|
|
|
214
233
|
}
|
|
215
234
|
else {
|
|
216
235
|
console.log(`Cursor chat: ${chat.title ?? "(untitled)"}\nId: ${chat.composerId}\n`);
|
|
217
|
-
console.log(renderProfile(profile));
|
|
236
|
+
console.log(renderProfile(profile, { redact: args.redact }));
|
|
218
237
|
if (parsed.reportedInputTokens) {
|
|
219
238
|
console.log("");
|
|
220
239
|
console.log(`Measured context (reported by the API on the last request): ${parsed.reportedInputTokens} tokens.\n` +
|
|
@@ -261,7 +280,7 @@ function main() {
|
|
|
261
280
|
console.log(`Note: ${parsed.compactedAway} earlier message(s) were compacted away and are NOT counted below — this is the live context the model still sees.`);
|
|
262
281
|
}
|
|
263
282
|
console.log("");
|
|
264
|
-
console.log(renderProfile(profile));
|
|
283
|
+
console.log(renderProfile(profile, { redact: args.redact }));
|
|
265
284
|
if (parsed.reportedInputTokens) {
|
|
266
285
|
console.log("");
|
|
267
286
|
console.log(`Measured context (reported by the API on the last request): ${parsed.reportedInputTokens} tokens.\n` +
|
|
@@ -269,7 +288,12 @@ function main() {
|
|
|
269
288
|
"transcript does not record — so it is larger than the breakdown above, which covers\n" +
|
|
270
289
|
"conversation messages only. Findings and savings apply to the messages.");
|
|
271
290
|
}
|
|
272
|
-
|
|
291
|
+
const cache = renderCacheReport(analyzeCacheUsage(path));
|
|
292
|
+
if (cache) {
|
|
293
|
+
console.log("");
|
|
294
|
+
console.log(cache);
|
|
295
|
+
}
|
|
296
|
+
applyBudgetGate(printBudgetStatus(profile, loadConfig(process.cwd(), (m) => console.error(`context-doctor: ${m}`))), args.failOverBudget);
|
|
273
297
|
}
|
|
274
298
|
return;
|
|
275
299
|
}
|
|
@@ -320,9 +344,11 @@ function main() {
|
|
|
320
344
|
if (args.command === "analyze") {
|
|
321
345
|
const loaded = loadConfig(process.cwd(), (m) => console.error(`context-doctor: ${m}`));
|
|
322
346
|
const profile = profileConversation(parseConversation(input), args.model ?? loaded.config.model);
|
|
323
|
-
console.log(args.json ? JSON.stringify(profile, null, 2) : renderProfile(profile));
|
|
347
|
+
console.log(args.json ? JSON.stringify(profile, null, 2) : renderProfile(profile, { redact: args.redact }));
|
|
324
348
|
if (!args.json)
|
|
325
|
-
printBudgetStatus(profile, loaded);
|
|
349
|
+
applyBudgetGate(printBudgetStatus(profile, loaded), args.failOverBudget);
|
|
350
|
+
else
|
|
351
|
+
applyBudgetGate(checkBudget(loaded.config.budget, profile).overBudget, args.failOverBudget);
|
|
326
352
|
if (args.exact) {
|
|
327
353
|
void exactTokenCount(input, args.model).then((exact) => {
|
|
328
354
|
if (exact.tokens !== undefined) {
|
package/dist/impact.js
CHANGED
|
@@ -68,17 +68,24 @@ export async function buildImpactReport(proxyPort = 8787) {
|
|
|
68
68
|
optimizeUsd += inputCostUsd(e.saved, pricing);
|
|
69
69
|
}
|
|
70
70
|
const proxy = await fetchProxyStats(proxyPort);
|
|
71
|
-
|
|
71
|
+
// Persisted checkpoints cover proxy runs that have since exited; the live
|
|
72
|
+
// process reports whatever it has not checkpointed yet.
|
|
73
|
+
const proxyEvents = ledger.filter((e) => e.ev === "proxy");
|
|
74
|
+
const proxyHistoric = proxyEvents.reduce((s, e) => s + (e.saved ?? 0), 0);
|
|
75
|
+
const proxySaved = proxyHistoric + (proxy?.tokensSaved ?? 0);
|
|
72
76
|
// -- Headline: what context-doctor has saved ----------------------------------
|
|
73
77
|
const totalSaved = proxySaved + optimizeSaved + totalReduction;
|
|
74
78
|
lines.push("Tokens context-doctor saved (measured)");
|
|
75
79
|
lines.push("─".repeat(56));
|
|
76
80
|
lines.push(`TOTAL: ~${formatTokens(totalSaved)} tokens`);
|
|
77
81
|
if (proxy) {
|
|
78
|
-
lines.push(` · proxy (exact
|
|
82
|
+
lines.push(` · proxy (exact): ${formatTokens(proxySaved)} — ${formatTokens(proxyHistoric)} from earlier runs, ` +
|
|
83
|
+
`${formatTokens(proxy.tokensSaved)} live across ${proxy.optimizedRequests}/${proxy.requests} requests ≈ ${formatUsd(proxy.estUsdSaved)}`);
|
|
79
84
|
}
|
|
80
85
|
else {
|
|
81
|
-
lines.push(
|
|
86
|
+
lines.push(proxyHistoric > 0
|
|
87
|
+
? ` · proxy (exact, from ${proxyEvents.length} earlier run checkpoint(s)): ${formatTokens(proxyHistoric)} — not running now`
|
|
88
|
+
: ` · proxy: not running on :${proxyPort} (its exact savings appear here once it runs)`);
|
|
82
89
|
}
|
|
83
90
|
const familyNote = [...savedByFamily.entries()]
|
|
84
91
|
.filter(([, v]) => v > 0)
|
package/dist/ledger.d.ts
CHANGED
|
@@ -7,14 +7,17 @@
|
|
|
7
7
|
* check — hook deep-parsed a session {ev?: undefined|"check", sid, tok, warn}
|
|
8
8
|
* (pre-0.3.6 hook entries have no `ev` field; treated as checks)
|
|
9
9
|
* optimize — an optimization was applied {ev: "optimize", src: "cli"|"mcp", saved, model?}
|
|
10
|
+
* proxy — proxy savings checkpoint {ev: "proxy", saved, usd?, requests?}
|
|
10
11
|
*/
|
|
11
12
|
export interface LedgerEntry {
|
|
12
13
|
ts: number;
|
|
13
|
-
ev?: "check" | "optimize";
|
|
14
|
+
ev?: "check" | "optimize" | "proxy";
|
|
14
15
|
sid?: string;
|
|
15
16
|
tok?: number;
|
|
16
17
|
warn?: boolean;
|
|
17
|
-
src?: "cli" | "mcp";
|
|
18
|
+
src?: "cli" | "mcp" | "proxy";
|
|
19
|
+
usd?: number;
|
|
20
|
+
requests?: number;
|
|
18
21
|
saved?: number;
|
|
19
22
|
model?: string;
|
|
20
23
|
}
|
package/dist/ledger.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* check — hook deep-parsed a session {ev?: undefined|"check", sid, tok, warn}
|
|
8
8
|
* (pre-0.3.6 hook entries have no `ev` field; treated as checks)
|
|
9
9
|
* optimize — an optimization was applied {ev: "optimize", src: "cli"|"mcp", saved, model?}
|
|
10
|
+
* proxy — proxy savings checkpoint {ev: "proxy", saved, usd?, requests?}
|
|
10
11
|
*/
|
|
11
12
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
12
13
|
import { homedir } from "node:os";
|
package/dist/mcp.js
CHANGED
|
@@ -37,7 +37,7 @@ const STRATEGY_IDS = ["dedupe", "trim-tool-results", "strip-base64", "prune-hist
|
|
|
37
37
|
* recommended pattern.
|
|
38
38
|
*/
|
|
39
39
|
function createServer() {
|
|
40
|
-
const server = new McpServer({ name: "context-doctor", version: "0.
|
|
40
|
+
const server = new McpServer({ name: "context-doctor", version: "0.11.0" }, { instructions: SERVER_INSTRUCTIONS });
|
|
41
41
|
server.tool("profile_context", "Profile an LLM conversation or prompt: token breakdown by category, largest messages, and actionable findings about wasted context (duplicates, oversized tool results, base64 blobs, cache-unfriendly ordering). Accepts OpenAI/Anthropic conversation JSON or raw text. Call this immediately whenever the user asks about token usage, context size, LLM cost, or latency — and proactively offer it once a conversation grows long or accumulates large pasted content.", {
|
|
42
42
|
conversation: z.string().describe("Conversation JSON (OpenAI or Anthropic format, or bare message array) or raw prompt text"),
|
|
43
43
|
model: z.string().optional().describe("Target model name for context-window math, e.g. claude-sonnet-5 or gpt-4o"),
|
package/dist/profile.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ export interface MessageProfile {
|
|
|
12
12
|
preview: string;
|
|
13
13
|
toolName?: string;
|
|
14
14
|
}
|
|
15
|
-
export type FindingId = "large_tool_result" | "duplicate_content" | "near_duplicate" | "repeated_tool_call" | "base64_blob" | "long_history" | "large_system_prompt" | "cache_ordering" | "near_window_limit";
|
|
15
|
+
export type FindingId = "large_tool_result" | "duplicate_content" | "near_duplicate" | "repeated_tool_call" | "repeated_file_read" | "retained_error_output" | "base64_blob" | "long_history" | "large_system_prompt" | "cache_ordering" | "near_window_limit";
|
|
16
16
|
export interface Finding {
|
|
17
17
|
id: FindingId;
|
|
18
18
|
severity: "info" | "warn" | "high";
|
package/dist/profile.js
CHANGED
|
@@ -84,6 +84,8 @@ function sampledShingles(text) {
|
|
|
84
84
|
}
|
|
85
85
|
return out;
|
|
86
86
|
}
|
|
87
|
+
/** Similarity at or above which two messages count as near-duplicates. */
|
|
88
|
+
const SIMILARITY_THRESHOLD = 0.6;
|
|
87
89
|
function jaccard(a, b) {
|
|
88
90
|
if (a.size === 0 || b.size === 0)
|
|
89
91
|
return 0;
|
|
@@ -149,6 +151,11 @@ export function profileConversation(conv, model) {
|
|
|
149
151
|
.sort((a, b) => b.tokens - a.tokens)
|
|
150
152
|
.slice(0, 150);
|
|
151
153
|
const shingleSets = candidates.map((p) => sampledShingles(p.msg.text));
|
|
154
|
+
// Jaccard has a hard ceiling of |smaller| / |larger|: two shingle sets of
|
|
155
|
+
// very different sizes CANNOT reach the threshold, so those pairs are
|
|
156
|
+
// skipped without intersecting them. Exact, not heuristic — it changes
|
|
157
|
+
// runtime, never results.
|
|
158
|
+
const sizes = shingleSets.map((set) => set.size);
|
|
152
159
|
const exactDup = new Set(findings.filter((f) => f.id === "duplicate_content").flatMap((f) => f.messages));
|
|
153
160
|
for (let i = 0; i < candidates.length; i++) {
|
|
154
161
|
for (let j = i + 1; j < candidates.length; j++) {
|
|
@@ -156,8 +163,12 @@ export function profileConversation(conv, model) {
|
|
|
156
163
|
const b = candidates[j];
|
|
157
164
|
if (exactDup.has(a.msg.index) && exactDup.has(b.msg.index))
|
|
158
165
|
continue; // already flagged exactly
|
|
166
|
+
const small = Math.min(sizes[i], sizes[j]);
|
|
167
|
+
const large = Math.max(sizes[i], sizes[j]);
|
|
168
|
+
if (large === 0 || small / large < SIMILARITY_THRESHOLD)
|
|
169
|
+
continue; // cannot clear the bar
|
|
159
170
|
const sim = jaccard(shingleSets[i], shingleSets[j]);
|
|
160
|
-
if (sim >=
|
|
171
|
+
if (sim >= SIMILARITY_THRESHOLD) {
|
|
161
172
|
const smaller = Math.min(a.tokens, b.tokens);
|
|
162
173
|
const [first, second] = a.msg.index <= b.msg.index ? [a, b] : [b, a];
|
|
163
174
|
findings.push({
|
|
@@ -194,6 +205,58 @@ export function profileConversation(conv, model) {
|
|
|
194
205
|
});
|
|
195
206
|
}
|
|
196
207
|
}
|
|
208
|
+
// -- Same file read again and again -----------------------------------------
|
|
209
|
+
// The dominant waste in agent loops: a file re-read because its earlier
|
|
210
|
+
// contents scrolled out of attention, leaving several full copies in context.
|
|
211
|
+
{
|
|
212
|
+
const readsByPath = new Map();
|
|
213
|
+
for (const p of perMessage) {
|
|
214
|
+
if (p.msg.kind !== "tool_call" || !p.msg.toolCallText)
|
|
215
|
+
continue;
|
|
216
|
+
if (!/read|open|cat|view|get_file/i.test(p.msg.toolName ?? ""))
|
|
217
|
+
continue;
|
|
218
|
+
const match = /"(?:file_path|filePath|path|file)"\s*:\s*"([^"]{3,})"/.exec(p.msg.toolCallText);
|
|
219
|
+
if (!match)
|
|
220
|
+
continue;
|
|
221
|
+
const path = match[1];
|
|
222
|
+
readsByPath.set(path, [...(readsByPath.get(path) ?? []), p.msg.index]);
|
|
223
|
+
}
|
|
224
|
+
for (const [path, indexes] of readsByPath) {
|
|
225
|
+
if (indexes.length < 3)
|
|
226
|
+
continue;
|
|
227
|
+
// Each re-read pulls the file in again; all but the last are recoverable.
|
|
228
|
+
const resultTokens = perMessage
|
|
229
|
+
.filter((p) => p.msg.kind === "tool_result" && indexes.some((i) => p.msg.index === i + 1))
|
|
230
|
+
.reduce((sum, p) => sum + p.tokens, 0);
|
|
231
|
+
const savings = Math.round((resultTokens * (indexes.length - 1)) / indexes.length);
|
|
232
|
+
findings.push({
|
|
233
|
+
id: "repeated_file_read",
|
|
234
|
+
severity: savings > 4000 ? "high" : "warn",
|
|
235
|
+
estSavings: savings,
|
|
236
|
+
message: `${path.split("/").pop()} was read ${indexes.length} times (messages #${indexes.join(", #")}), keeping ${indexes.length} copies in context.`,
|
|
237
|
+
suggestion: "Re-read a file only after it changes; otherwise refer back to the copy already in context.",
|
|
238
|
+
messages: indexes,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// -- Failed tool output kept verbatim ---------------------------------------
|
|
243
|
+
// Stack traces and command failures are read once and never again, yet they
|
|
244
|
+
// are often the largest blocks in an agent transcript.
|
|
245
|
+
for (const p of perMessage) {
|
|
246
|
+
if (p.msg.kind !== "tool_result" || p.tokens < 500)
|
|
247
|
+
continue;
|
|
248
|
+
const looksFailed = /Traceback \(most recent call last\)|command not found|npm error|ERR!|\bexit code [1-9]|Exception in thread|FAILED|error TS\d+/i.test(p.msg.text);
|
|
249
|
+
if (!looksFailed)
|
|
250
|
+
continue;
|
|
251
|
+
findings.push({
|
|
252
|
+
id: "retained_error_output",
|
|
253
|
+
severity: "warn",
|
|
254
|
+
estSavings: Math.round(p.tokens * 0.85),
|
|
255
|
+
message: `Message #${p.msg.index} holds ~${p.tokens} tokens of failed tool output (errors, stack trace or non-zero exit).`,
|
|
256
|
+
suggestion: "Keep the one line that identifies the failure and drop the rest — a full trace has no value once the fix is understood.",
|
|
257
|
+
messages: [p.msg.index],
|
|
258
|
+
});
|
|
259
|
+
}
|
|
197
260
|
// -- Base64 / binary blobs ---------------------------------------------------
|
|
198
261
|
for (const p of perMessage) {
|
|
199
262
|
if (BASE64_RE.test(p.msg.text)) {
|
package/dist/proxy.js
CHANGED
|
@@ -15,6 +15,7 @@ import http from "node:http";
|
|
|
15
15
|
import { optimizeConversation } from "./optimize.js";
|
|
16
16
|
import { formatTokens } from "./tokens.js";
|
|
17
17
|
import { formatUsd, inputCostUsd, pricingFor } from "./pricing.js";
|
|
18
|
+
import { recordLedger } from "./ledger.js";
|
|
18
19
|
/** Connection-level headers that must not be forwarded. */
|
|
19
20
|
const SKIP_REQUEST_HEADERS = new Set(["host", "content-length", "connection", "transfer-encoding", "accept-encoding", "expect"]);
|
|
20
21
|
const SKIP_RESPONSE_HEADERS = new Set(["content-length", "content-encoding", "transfer-encoding", "connection"]);
|
|
@@ -208,6 +209,36 @@ export function startProxy(opts = {}) {
|
|
|
208
209
|
res.end(JSON.stringify({ error: `context-doctor proxy: ${e.message}` }));
|
|
209
210
|
}
|
|
210
211
|
});
|
|
212
|
+
// Savings live in memory, so a restart would erase the record the dashboard
|
|
213
|
+
// and report draw on. Checkpoint the delta to the ledger periodically and on
|
|
214
|
+
// shutdown, so the history survives the process.
|
|
215
|
+
let checkpointedTokens = 0;
|
|
216
|
+
let checkpointedUsd = 0;
|
|
217
|
+
let checkpointedRequests = 0;
|
|
218
|
+
const checkpoint = () => {
|
|
219
|
+
const savedDelta = stats.tokensSaved - checkpointedTokens;
|
|
220
|
+
if (savedDelta <= 0)
|
|
221
|
+
return;
|
|
222
|
+
recordLedger({
|
|
223
|
+
ev: "proxy",
|
|
224
|
+
src: "proxy",
|
|
225
|
+
saved: savedDelta,
|
|
226
|
+
usd: Number((stats.estUsdSaved - checkpointedUsd).toFixed(6)),
|
|
227
|
+
requests: stats.optimizedRequests - checkpointedRequests,
|
|
228
|
+
});
|
|
229
|
+
checkpointedTokens = stats.tokensSaved;
|
|
230
|
+
checkpointedUsd = stats.estUsdSaved;
|
|
231
|
+
checkpointedRequests = stats.optimizedRequests;
|
|
232
|
+
};
|
|
233
|
+
const checkpointTimer = setInterval(checkpoint, 60_000);
|
|
234
|
+
checkpointTimer.unref?.(); // never hold the process open on our account
|
|
235
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
236
|
+
process.once(signal, () => {
|
|
237
|
+
checkpoint();
|
|
238
|
+
process.exit(0);
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
server.on("close", checkpoint);
|
|
211
242
|
const host = opts.host ?? "127.0.0.1";
|
|
212
243
|
server.listen(port, host, () => {
|
|
213
244
|
console.error(`context-doctor proxy listening on http://${host}:${port}`);
|
package/dist/report.d.ts
CHANGED
|
@@ -4,4 +4,12 @@
|
|
|
4
4
|
* anywhere (terminals, issues, chat).
|
|
5
5
|
*/
|
|
6
6
|
import { ContextProfile } from "./profile.js";
|
|
7
|
-
export
|
|
7
|
+
export interface RenderOptions {
|
|
8
|
+
/**
|
|
9
|
+
* Replace anything quoted from the conversation with a placeholder, so a
|
|
10
|
+
* profile can be pasted into a bug report without leaking content. Numbers
|
|
11
|
+
* and structure — the parts that make a report useful — are kept.
|
|
12
|
+
*/
|
|
13
|
+
redact?: boolean;
|
|
14
|
+
}
|
|
15
|
+
export declare function renderProfile(profile: ContextProfile, options?: RenderOptions): string;
|
package/dist/report.js
CHANGED
|
@@ -18,7 +18,13 @@ function bar(fraction, width = 28) {
|
|
|
18
18
|
const filled = Math.round(fraction * width);
|
|
19
19
|
return "█".repeat(filled) + "░".repeat(width - filled);
|
|
20
20
|
}
|
|
21
|
-
|
|
21
|
+
/** Mask filesystem paths and quoted fragments inside a finding's text. */
|
|
22
|
+
function redactText(text) {
|
|
23
|
+
return text
|
|
24
|
+
.replace(/(?:\/[\w.@ -]+){2,}/g, "[path]")
|
|
25
|
+
.replace(/\b[\w.-]+\.(ts|tsx|js|jsx|py|go|rs|java|rb|md|json|ya?ml|sql|sh)\b/gi, "[file]");
|
|
26
|
+
}
|
|
27
|
+
export function renderProfile(profile, options = {}) {
|
|
22
28
|
const lines = [];
|
|
23
29
|
const p = profile;
|
|
24
30
|
lines.push("CONTEXT DOCTOR — profile");
|
|
@@ -52,7 +58,9 @@ export function renderProfile(profile) {
|
|
|
52
58
|
lines.push("─".repeat(56));
|
|
53
59
|
for (const m of p.largestMessages) {
|
|
54
60
|
const label = m.toolName ? `${m.kind}:${m.toolName}` : m.kind;
|
|
55
|
-
|
|
61
|
+
// The preview is the only place raw conversation text reaches the report.
|
|
62
|
+
const body = options.redact ? "[redacted]" : m.preview;
|
|
63
|
+
lines.push(` #${m.index} [${label}] ~${formatTokens(m.tokens)} ${body}`);
|
|
56
64
|
}
|
|
57
65
|
lines.push("");
|
|
58
66
|
// Findings
|
|
@@ -61,7 +69,7 @@ export function renderProfile(profile) {
|
|
|
61
69
|
lines.push("─".repeat(56));
|
|
62
70
|
for (const f of p.findings) {
|
|
63
71
|
const savings = f.estSavings > 0 ? ` [save ~${formatTokens(f.estSavings)}]` : "";
|
|
64
|
-
lines.push(`${SEVERITY_MARK[f.severity]} ${f.message}${savings}`);
|
|
72
|
+
lines.push(`${SEVERITY_MARK[f.severity]} ${options.redact ? redactText(f.message) : f.message}${savings}`);
|
|
65
73
|
lines.push(` → ${f.suggestion}`);
|
|
66
74
|
}
|
|
67
75
|
lines.push("");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "context-doctor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Profile and optimize LLM context windows. See what's eating your tokens and fix it — works with Claude, GPT, Gemini, and any MCP-capable AI app.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"llm",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"build": "tsc && node -e \"const fs=require('fs');['dist/cli.js','dist/mcp.js'].forEach(f=>fs.chmodSync(f,0o755))\"",
|
|
43
43
|
"prepublishOnly": "npm run build",
|
|
44
44
|
"dev": "tsc --watch",
|
|
45
|
-
"test": "npm run build && node --test dist/test/smoke.test.js dist/test/proxy.test.js dist/test/hook.test.js dist/test/mcp-http.test.js dist/test/doctor.test.js dist/test/watch.test.js dist/test/chatgpt-export.test.js dist/test/config.test.js dist/test/dashboard.test.js dist/test/cursor.test.js"
|
|
45
|
+
"test": "npm run build && node --test dist/test/smoke.test.js dist/test/proxy.test.js dist/test/hook.test.js dist/test/mcp-http.test.js dist/test/doctor.test.js dist/test/watch.test.js dist/test/chatgpt-export.test.js dist/test/config.test.js dist/test/dashboard.test.js dist/test/cursor.test.js dist/test/cache.test.js"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@modelcontextprotocol/sdk": "^1.0.0",
|