context-doctor 0.10.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 +12 -1
- package/dist/cli.js +9 -4
- 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.js +12 -1
- package/dist/proxy.js +31 -0
- package/dist/report.d.ts +9 -1
- package/dist/report.js +11 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -92,7 +92,7 @@ Practical upshot: a developer who only wants cheaper, faster API calls never tou
|
|
|
92
92
|
| `context-doctor optimize <file>` | Apply the safe fixes; `--strategy prune-history` for consented lossy compaction |
|
|
93
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
94
|
| `context-doctor cursor [--list]` | Profile a chat from Cursor's local history (both storage formats) |
|
|
95
|
-
| `context-doctor report` | Machine-wide impact report: exact proxy savings, hook activity, recoverable waste in recent sessions |
|
|
95
|
+
| `context-doctor report` | Machine-wide impact report (proxy savings persist across restarts): exact proxy savings, hook activity, recoverable waste in recent sessions |
|
|
96
96
|
| `context-doctor proxy` | Always-on local proxy that optimizes every Anthropic/OpenAI API request in flight (`/stats` for cumulative savings) |
|
|
97
97
|
| `context-doctor watch [file]` | Live monitor of a growing session/agent trace: token/cost line per change, findings as they appear |
|
|
98
98
|
| `context-doctor doctor` | Self-check the whole installation — one pasteable ✓/✗ diagnosis with fixes |
|
|
@@ -319,6 +319,16 @@ npx context-doctor analyze conversation.json --fail-over-budget
|
|
|
319
319
|
|
|
320
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
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
|
+
|
|
322
332
|
## Performance: what context-doctor itself costs
|
|
323
333
|
|
|
324
334
|
A tool that promises speed must be near-free. Measured overhead per touchpoint:
|
|
@@ -329,6 +339,7 @@ A tool that promises speed must be near-free. Measured overhead per touchpoint:
|
|
|
329
339
|
| MCP server | Spawned once per app session | Tools run only when called; standing instructions cost **~110 tokens per conversation** — deliberately terse |
|
|
330
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 |
|
|
331
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) |
|
|
332
343
|
| CLI / library | Only when you run it | Not in any hot path |
|
|
333
344
|
|
|
334
345
|
Net effect is strongly negative overhead: the tokens these touchpoints save on every subsequent call dwarf what they cost.
|
package/dist/cli.js
CHANGED
|
@@ -64,6 +64,8 @@ Options:
|
|
|
64
64
|
--model <name> Model name for window-size math (e.g. claude-sonnet-5, gpt-4o)
|
|
65
65
|
--exact (analyze) Add an exact token count: Anthropic count-tokens API for
|
|
66
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
|
|
67
69
|
--json Machine-readable output
|
|
68
70
|
--fail-over-budget (analyze/session) Exit 1 when the .contextdoctorrc budget is
|
|
69
71
|
exceeded — lets CI gate a pull request on context size
|
|
@@ -89,7 +91,7 @@ Examples:
|
|
|
89
91
|
export OPENAI_BASE_URL=http://localhost:8787/v1
|
|
90
92
|
`;
|
|
91
93
|
function parseArgs(argv) {
|
|
92
|
-
const args = { json: false, strategies: [], list: false, exact: false, failOverBudget: false };
|
|
94
|
+
const args = { json: false, strategies: [], list: false, exact: false, redact: false, failOverBudget: false };
|
|
93
95
|
const positional = [];
|
|
94
96
|
for (let i = 0; i < argv.length; i++) {
|
|
95
97
|
const a = argv[i];
|
|
@@ -107,6 +109,9 @@ function parseArgs(argv) {
|
|
|
107
109
|
case "--exact":
|
|
108
110
|
args.exact = true;
|
|
109
111
|
break;
|
|
112
|
+
case "--redact":
|
|
113
|
+
args.redact = true;
|
|
114
|
+
break;
|
|
110
115
|
case "--fail-over-budget":
|
|
111
116
|
args.failOverBudget = true;
|
|
112
117
|
break;
|
|
@@ -228,7 +233,7 @@ function main() {
|
|
|
228
233
|
}
|
|
229
234
|
else {
|
|
230
235
|
console.log(`Cursor chat: ${chat.title ?? "(untitled)"}\nId: ${chat.composerId}\n`);
|
|
231
|
-
console.log(renderProfile(profile));
|
|
236
|
+
console.log(renderProfile(profile, { redact: args.redact }));
|
|
232
237
|
if (parsed.reportedInputTokens) {
|
|
233
238
|
console.log("");
|
|
234
239
|
console.log(`Measured context (reported by the API on the last request): ${parsed.reportedInputTokens} tokens.\n` +
|
|
@@ -275,7 +280,7 @@ function main() {
|
|
|
275
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.`);
|
|
276
281
|
}
|
|
277
282
|
console.log("");
|
|
278
|
-
console.log(renderProfile(profile));
|
|
283
|
+
console.log(renderProfile(profile, { redact: args.redact }));
|
|
279
284
|
if (parsed.reportedInputTokens) {
|
|
280
285
|
console.log("");
|
|
281
286
|
console.log(`Measured context (reported by the API on the last request): ${parsed.reportedInputTokens} tokens.\n` +
|
|
@@ -339,7 +344,7 @@ function main() {
|
|
|
339
344
|
if (args.command === "analyze") {
|
|
340
345
|
const loaded = loadConfig(process.cwd(), (m) => console.error(`context-doctor: ${m}`));
|
|
341
346
|
const profile = profileConversation(parseConversation(input), args.model ?? loaded.config.model);
|
|
342
|
-
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 }));
|
|
343
348
|
if (!args.json)
|
|
344
349
|
applyBudgetGate(printBudgetStatus(profile, loaded), args.failOverBudget);
|
|
345
350
|
else
|
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.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({
|
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