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 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
- const proxySaved = proxy?.tokensSaved ?? 0;
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, current run): ${formatTokens(proxySaved)} across ${proxy.optimizedRequests}/${proxy.requests} requests ${formatUsd(proxy.estUsdSaved)}`);
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(` · proxy: not running on :${proxyPort} (its exact savings appear here while it runs)`);
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.10.0" }, { instructions: SERVER_INSTRUCTIONS });
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 >= 0.6) {
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 declare function renderProfile(profile: ContextProfile): string;
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
- export function renderProfile(profile) {
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
- lines.push(` #${m.index} [${label}] ~${formatTokens(m.tokens)} ${m.preview}`);
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.10.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",