context-doctor 0.22.0 → 0.23.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
@@ -21,22 +21,9 @@ Built and maintained by [gAI Ventures](https://gai.ventures).
21
21
  npx context-doctor savings
22
22
  ```
23
23
 
24
- ```
25
- What context-doctor finds in your Claude Code sessions (last 30 days, 29 sessions)
26
- ────────────────────────────────────────────────────────────────
27
- Input you were billed for $6,021 from the usage your transcripts record
28
-
29
- 1. Compact when you come back $2,492 199 returns to a session over 150k tokens
30
- after the prompt cache expired. Running /compact then, net of the compaction
31
- itself, would have saved this. The every-prompt hook points these moments out;
32
- it works in every Claude Code surface, the desktop app included.
33
-
34
- 2. Autopilot n/a all 29 sessions ran in the desktop app, which
35
- sets its own API address, so the autopilot proxy cannot sit in front of it.
36
- (Had they run in the terminal or an IDE: $788, 12.9%.)
37
- ```
24
+ <img src="https://raw.githubusercontent.com/KushalP1/context-doctor/main/assets/savings.svg" alt="npx context-doctor savings: input billed, what /compact at cold resumes and autopilot would have saved" width="640">
38
25
 
39
- That is the author's machine: every session there runs in the desktop app, so the first line is the one that applies. Yours is computed the same way, from your own transcripts: every session replayed request by request through the shipped code, against what you were actually billed.
26
+ That is the author's machine: every session there runs in the desktop app, so the first line is the one that applies. Yours is computed the same way, from your own transcripts: every session replayed request by request through the shipped code, against what you were actually billed. `context-doctor savings --share` prints a few lines with totals only (no project names or paths) if you want to post your number.
40
27
 
41
28
  **Then install it**, whichever way suits you:
42
29
 
package/dist/autoclear.js CHANGED
@@ -54,7 +54,9 @@ export const CLEARABLE_TOOLS = new Set([
54
54
  const IMAGE_TOKENS = 1600;
55
55
  const HOUR = 3_600_000;
56
56
  export function clearedNote(tokens) {
57
- return `[context-doctor: old tool output cleared to keep context lean (~${tokens} tokens). Run the tool again if you need it.]`;
57
+ // Not "run it again": a cleared Bash result may come from a command with
58
+ // side effects (a push, a delete), and repeating that would be wrong.
59
+ return `[context-doctor: old tool output cleared to keep context lean (~${tokens} tokens). The call above shows what ran; repeat it only if it is read-only, otherwise ask the user.]`;
58
60
  }
59
61
  function resultTokens(content, model) {
60
62
  if (typeof content === "string")
package/dist/cli.js CHANGED
@@ -22,7 +22,7 @@ import { runHook } from "./hook.js";
22
22
  import { buildImpactReport } from "./impact.js";
23
23
  import { measureTokenizer, renderTokenizer } from "./tokenizer-measure.js";
24
24
  import { autopilotOff, autopilotOn, autopilotPause, autopilotPaths, autopilotStatus, DEFAULT_AUTOPILOT_PORT } from "./autopilot.js";
25
- import { estimateSavings, renderSavings } from "./savings.js";
25
+ import { estimateSavings, renderSavings, renderShare } from "./savings.js";
26
26
  import { renderPreferences, copyToClipboard, CHAT_PREFERENCES } from "./preferences.js";
27
27
  import { recordLedger } from "./ledger.js";
28
28
  import { runDoctor } from "./doctor.js";
@@ -51,9 +51,11 @@ Usage:
51
51
  Claude Code, and Cursor automatically
52
52
  context-doctor uninstall Undo install
53
53
  context-doctor --version Print the installed version
54
- context-doctor savings [--days n] What autopilot saves (or would have) on YOUR recent
55
- Claude Code sessions: replayed, priced as billed.
56
- Also what a bare \`context-doctor\` shows
54
+ context-doctor savings [--days n] [--share [--copy]]
55
+ What your recent Claude Code sessions cost, and what
56
+ /compact at cold resumes and autopilot would save,
57
+ replayed and priced as billed. --share: totals only,
58
+ to paste. Also what a bare \`context-doctor\` shows
57
59
  context-doctor autopilot on|off|pause|resume|status
58
60
  Every new Claude Code session goes through the
59
61
  local proxy, which clears stale tool output only
@@ -178,19 +180,19 @@ function parseArgs(argv) {
178
180
  args.strategies.push(argv[++i]);
179
181
  break;
180
182
  case "--keep-recent":
181
- args.keepRecent = Number(argv[++i]);
183
+ args.keepRecent = numArg("--keep-recent", argv[++i], { min: 0, integer: true });
182
184
  break;
183
185
  case "--max-tool-tokens":
184
- args.maxToolTokens = Number(argv[++i]);
186
+ args.maxToolTokens = numArg("--max-tool-tokens", argv[++i], { min: 1, integer: true });
185
187
  break;
186
188
  case "--port":
187
- args.port = Number(argv[++i]);
189
+ args.port = numArg("--port", argv[++i], { min: 0, max: 65535, integer: true });
188
190
  break;
189
191
  case "--interval-ms":
190
- args.intervalMs = Number(argv[++i]);
192
+ args.intervalMs = numArg("--interval-ms", argv[++i], { min: 100, integer: true });
191
193
  break;
192
194
  case "--limit":
193
- args.limit = Number(argv[++i]);
195
+ args.limit = numArg("--limit", argv[++i], { min: 1, integer: true });
194
196
  break;
195
197
  case "--task":
196
198
  args.task = argv[++i];
@@ -202,7 +204,7 @@ function parseArgs(argv) {
202
204
  args.existing = argv[++i];
203
205
  break;
204
206
  case "--budget":
205
- args.budgetUsd = Number(argv[++i]);
207
+ args.budgetUsd = numArg("--budget", argv[++i], { min: 0 });
206
208
  break;
207
209
  case "--dry-run":
208
210
  args.dryRun = true;
@@ -210,6 +212,9 @@ function parseArgs(argv) {
210
212
  case "--copy":
211
213
  args.copy = true;
212
214
  break;
215
+ case "--share":
216
+ args.share = true;
217
+ break;
213
218
  case "--allow-dirty":
214
219
  args.allowDirty = true;
215
220
  break;
@@ -223,7 +228,7 @@ function parseArgs(argv) {
223
228
  args.token = argv[++i];
224
229
  break;
225
230
  case "--days":
226
- args.days = Number(argv[++i]);
231
+ args.days = numArg("--days", argv[++i], { min: 1 });
227
232
  break;
228
233
  case "--autopilot":
229
234
  args.autopilot = true;
@@ -289,6 +294,21 @@ function packageVersion() {
289
294
  return "unknown";
290
295
  }
291
296
  }
297
+ /**
298
+ * A numeric flag's value, or exit 1 with a message naming the flag. Number()
299
+ * alone turned `--days abc` into "the last NaN days" and a bad --port into a
300
+ * random port.
301
+ */
302
+ function numArg(flag, raw, { min, max, integer } = {}) {
303
+ const n = Number(raw);
304
+ const ok = raw !== undefined && raw.trim() !== "" && Number.isFinite(n) && (!integer || Number.isInteger(n)) && (min === undefined || n >= min) && (max === undefined || n <= max);
305
+ if (!ok) {
306
+ const range = min !== undefined && max !== undefined ? ` between ${min} and ${max}` : min !== undefined ? ` of at least ${min}` : "";
307
+ console.error(`${flag} needs ${integer ? "a whole number" : "a number"}${range}; got ${raw === undefined ? "nothing" : JSON.stringify(raw)}.`);
308
+ process.exit(1);
309
+ }
310
+ return n;
311
+ }
292
312
  /** Any Claude Code transcript on this machine? One directory listing, no parsing. */
293
313
  function hasClaudeSessions() {
294
314
  try {
@@ -504,6 +524,12 @@ async function main() {
504
524
  const on = existsSync(autopilotPaths().config);
505
525
  if (args.json)
506
526
  console.log(JSON.stringify(report, null, 2));
527
+ else if (args.share) {
528
+ const text = renderShare(report);
529
+ console.log(text);
530
+ if (args.copy)
531
+ console.log(copyToClipboard(text) ? "\n(copied to the clipboard)" : "\n(no clipboard tool found; copy the lines above)");
532
+ }
507
533
  else {
508
534
  console.log(renderSavings(report, on));
509
535
  if (!args.command)
@@ -582,6 +608,11 @@ async function main() {
582
608
  return; // server keeps the process alive
583
609
  }
584
610
  if (!args.command || !args.file) {
611
+ const known = ["analyze", "optimize"];
612
+ if (args.command && !known.includes(args.command)) {
613
+ console.error(`Unknown command "${args.command}". Run \`context-doctor --help\` for the list.`);
614
+ process.exit(1);
615
+ }
585
616
  console.log(HELP);
586
617
  process.exit(args.command ? 1 : 0);
587
618
  }
@@ -60,6 +60,12 @@ export function detectColdResume(path, now = Date.now(), idleMs = COLD_IDLE_MS,
60
60
  catch {
61
61
  continue;
62
62
  }
63
+ // Compacted since the last reply: the old usage no longer describes the
64
+ // context, and there is nothing large left to warn about.
65
+ if (e?.type === "system" && e.subtype === "compact_boundary")
66
+ return undefined;
67
+ if (e?.isCompactSummary)
68
+ return undefined;
63
69
  if (e?.type !== "assistant" || e.isSidechain)
64
70
  continue;
65
71
  const u = e.message?.usage;
package/dist/mcp.js CHANGED
@@ -49,7 +49,7 @@ const STRATEGY_IDS = ["dedupe", "trim-tool-results", "trim-tool-calls", "strip-b
49
49
  * recommended pattern.
50
50
  */
51
51
  function createServer() {
52
- const server = new McpServer({ name: "context-doctor", version: "0.22.0" }, { instructions: SERVER_INSTRUCTIONS });
52
+ const server = new McpServer({ name: "context-doctor", version: "0.23.0" }, { instructions: SERVER_INSTRUCTIONS });
53
53
  server.tool("profile_context", "Profile an LLM conversation or prompt: token breakdown, largest blocks, and actionable findings about wasted context (duplicates, oversized pastes or tool results, base64 blobs, long history). Two inputs, pass ONE: `conversation` (full OpenAI/Anthropic JSON or raw text, for agents, files and proxies) or `sketch` (for chat apps such as Claude Desktop or ChatGPT where you cannot export the conversation: the turn count plus the few blocks that matter, ~120 tokens to write). Call it whenever the user asks about token usage, context size, cost, speed or limits, and on your own once the conversation passes ~30 turns or holds 3+ large pastes. Act on the top finding in your reply.", {
54
54
  conversation: z.string().optional().describe("Conversation JSON (OpenAI or Anthropic format, or bare message array) or raw prompt text. Omit in chat apps and pass `sketch`."),
55
55
  sketch: z.object({
package/dist/proxy.d.ts CHANGED
@@ -47,7 +47,7 @@ export interface ProxyOptions extends OptimizeOptions {
47
47
  openaiUpstream?: string;
48
48
  }
49
49
  /** Reported by /health so `autopilot status` can tell an outdated service from a current one. */
50
- export declare const PROXY_VERSION = "0.22.0";
50
+ export declare const PROXY_VERSION = "0.23.0";
51
51
  /**
52
52
  * Remove a leading `/t/<token>` from a request path, or return undefined when
53
53
  * the prefix is absent or the token differs. The comparison is constant time
package/dist/proxy.js CHANGED
@@ -20,7 +20,7 @@ import { formatTokens, CHARS_PER_TOKEN, providerFor } from "./tokens.js";
20
20
  import { formatUsd, inputCostUsd, pricingFor } from "./pricing.js";
21
21
  import { recordLedger } from "./ledger.js";
22
22
  /** Reported by /health so `autopilot status` can tell an outdated service from a current one. */
23
- export const PROXY_VERSION = "0.22.0";
23
+ export const PROXY_VERSION = "0.23.0";
24
24
  /** Connection-level headers that must not be forwarded. */
25
25
  const SKIP_REQUEST_HEADERS = new Set(["host", "content-length", "connection", "transfer-encoding", "accept-encoding", "expect"]);
26
26
  const SKIP_RESPONSE_HEADERS = new Set(["content-length", "content-encoding", "transfer-encoding", "connection"]);
package/dist/savings.d.ts CHANGED
@@ -59,3 +59,8 @@ export interface SavingsReport {
59
59
  export declare function replaySession(path: string, options?: AutoClearOptions, since?: number): SessionSavings | undefined;
60
60
  export declare function estimateSavings(days?: number, options?: AutoClearOptions, paths?: string[], onProgress?: (done: number, total: number) => void): SavingsReport;
61
61
  export declare function renderSavings(r: SavingsReport, autopilotOn?: boolean): string;
62
+ /**
63
+ * `savings --share`: a few lines people can paste or screenshot. Totals only:
64
+ * no project names, paths or session ids, so sharing it leaks nothing.
65
+ */
66
+ export declare function renderShare(r: SavingsReport): string;
package/dist/savings.js CHANGED
@@ -208,31 +208,56 @@ export function renderSavings(r, autopilotOn = false) {
208
208
  }
209
209
  const n = r.sessions.length;
210
210
  const allDesktop = r.unreachableSessions === n;
211
- lines.push(`What context-doctor finds in your Claude Code sessions (last ${r.days} days, ${n} session${n === 1 ? "" : "s"})`);
212
- lines.push("─".repeat(64));
213
- lines.push(`Input you were billed for ${usd(r.billedUsd).padStart(10)} from the usage your transcripts record`);
211
+ const pct = (x) => (r.billedUsd > 0 ? ` (${Math.round((x / r.billedUsd) * 100)}%)` : "");
212
+ // Kept within 80 columns: this is most people's first screen of the tool.
213
+ lines.push("What context-doctor finds in your Claude Code sessions");
214
+ lines.push(`Last ${r.days} days, ${n} session${n === 1 ? "" : "s"}, priced at list prices`);
215
+ lines.push("─".repeat(60));
216
+ lines.push(`Input you were billed for ${usd(r.billedUsd).padStart(9)} from your transcripts' usage`);
214
217
  lines.push("");
215
- lines.push(`1. Compact when you come back ${usd(Math.max(0, r.coldResumeNetUsd)).padStart(10)} ${r.coldResumes} return${r.coldResumes === 1 ? "" : "s"} to a session over 150k tokens`);
216
- lines.push(" after the prompt cache expired. Running /compact then, net of the compaction");
217
- lines.push(" itself, would have saved this. The every-prompt hook points these moments out;");
218
- lines.push(" it works in every Claude Code surface, the desktop app included.");
218
+ lines.push(`1. Compact when you come back ${usd(Math.max(0, r.coldResumeNetUsd)).padStart(9)}${pct(Math.max(0, r.coldResumeNetUsd))}`);
219
+ lines.push(` ${r.coldResumes} return${r.coldResumes === 1 ? "" : "s"} to a session over 150k tokens after the prompt cache`);
220
+ lines.push(" expired. Running /compact then, net of the compaction itself, would");
221
+ lines.push(" have saved this. The every-prompt hook points these moments out, in");
222
+ lines.push(" every Claude Code surface, the desktop app included.");
219
223
  lines.push("");
220
224
  if (allDesktop) {
221
- lines.push(`2. Autopilot ${"n/a".padStart(10)} all ${n} sessions ran in the desktop app, which`);
222
- lines.push(" sets its own API address, so the autopilot proxy cannot sit in front of it.");
223
- lines.push(` (Had they run in the terminal or an IDE: ${usd(r.savedUsd)}, ${(r.savedPct * 100).toFixed(1)}%.)`);
225
+ lines.push(`2. Autopilot ${"n/a".padStart(9)}`);
226
+ lines.push(` All ${n} sessions ran in the desktop app, which sets its own API`);
227
+ lines.push(" address, so the autopilot proxy cannot sit in front of them. In a");
228
+ lines.push(` terminal or an IDE they would have saved ${usd(r.savedUsd)} (${(r.savedPct * 100).toFixed(1)}%).`);
224
229
  }
225
230
  else {
226
- lines.push(`2. Autopilot ${usd(r.reachableUsd).padStart(10)} stale tool output cleared only when the cache`);
227
- lines.push(` is cold (never costs more; ${r.worse} session${r.worse === 1 ? "" : "s"} made more expensive), ${tokens(r.savedTokens)} tokens not sent.`);
231
+ lines.push(`2. Autopilot ${usd(r.reachableUsd).padStart(9)}${pct(r.reachableUsd)}`);
232
+ lines.push(" Stale tool output cleared only when the cache is cold, so it never");
233
+ lines.push(` costs more (${r.worse} session${r.worse === 1 ? "" : "s"} made more expensive); ${tokens(r.savedTokens)} tokens not sent.`);
228
234
  if (r.unreachableSessions > 0)
229
- lines.push(` Counts terminal and IDE sessions only; ${r.unreachableSessions} desktop-app session${r.unreachableSessions === 1 ? "" : "s"} excluded (not reachable).`);
235
+ lines.push(` Terminal and IDE sessions only; ${r.unreachableSessions} desktop-app session${r.unreachableSessions === 1 ? "" : "s"} not reachable.`);
230
236
  if (!autopilotOn)
231
237
  lines.push(" Turn it on: context-doctor autopilot on");
232
238
  }
233
239
  lines.push("");
234
- lines.push("Replayed request by request through the shipped code and priced as the prompt");
235
- lines.push("cache bills, at list prices. On a subscription the same tokens come out of your");
236
- lines.push("usage limit instead.");
240
+ lines.push("Replayed request by request through the shipped code and priced as the");
241
+ lines.push("prompt cache bills. On a subscription the same tokens come out of your");
242
+ lines.push("usage limit instead. Share yours: context-doctor savings --share");
243
+ return lines.join("\n");
244
+ }
245
+ /**
246
+ * `savings --share`: a few lines people can paste or screenshot. Totals only:
247
+ * no project names, paths or session ids, so sharing it leaks nothing.
248
+ */
249
+ export function renderShare(r) {
250
+ if (r.sessions.length === 0)
251
+ return "context-doctor found no Claude Code sessions to measure yet.";
252
+ const lines = [
253
+ `My Claude Code input, last ${r.days} days: ${usd(r.billedUsd)} at list price (${r.sessions.length} sessions).`,
254
+ ];
255
+ if (r.coldResumeNetUsd > 0) {
256
+ lines.push(`Running /compact when I came back to a big session after the cache expired would have saved ${usd(r.coldResumeNetUsd)} (${Math.round((r.coldResumeNetUsd / r.billedUsd) * 100)}%, ${r.coldResumes} times).`);
257
+ }
258
+ if (r.reachableUsd > 0) {
259
+ lines.push(`Clearing stale tool output only when the cache is cold would have cut another ${usd(r.reachableUsd)}, with no session made more expensive.`);
260
+ }
261
+ lines.push("Measured from my own transcripts with context-doctor: npx context-doctor savings");
237
262
  return lines.join("\n");
238
263
  }
package/dist/sketch.js CHANGED
@@ -128,7 +128,7 @@ export function profileSketch(sketch) {
128
128
  severity: totalTokens > 100_000 ? "high" : "warn",
129
129
  estSavings: Math.max(0, totalTokens - afterHandoff),
130
130
  message: `${turns} turns: every new message re-reads ~${formatTokens(totalTokens)}.`,
131
- action: `Offer a handoff: write a ≤${HANDOFF_SUMMARY_TOKENS}-token summary (decisions, current state, open items, key identifiers) for the user to start a new chat with. A fresh chat re-reads ~${formatTokens(afterHandoff)} instead.`,
131
+ action: `Offer a handoff: write a ≤${HANDOFF_SUMMARY_TOKENS}-token summary (decisions, current state, open items, key identifiers) for the user to start a new chat with. A fresh chat re-reads ~${formatTokens(afterHandoff)} of conversation instead, plus the app's own system prompt, which every chat carries.`,
132
132
  });
133
133
  }
134
134
  const contextWindow = contextWindowFor(sketch.model);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-doctor",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "See what your Claude Code sessions cost and cut it: offers /compact when you return to a large session after the cache expired (every surface, desktop app included), and a cache-safe autopilot for terminal, IDE and API traffic. Token profiler, hooks, MCP tools and a Claude Code plugin.",
5
5
  "keywords": [
6
6
  "agent",