context-doctor 0.11.0 → 0.12.1

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
@@ -34,7 +34,7 @@ Findings (4)
34
34
  npx context-doctor install
35
35
  ```
36
36
 
37
- That single command is also all it takes to **set up context-doctor on anyone else's machine**. Prefer a global install, or want the unreleased `main`? Both work (Node 18+):
37
+ That single command is also all it takes to **set up context-doctor on anyone else's machine**. Prefer a global install, or want the unreleased `main`? Both work (Node 20+):
38
38
 
39
39
  ```bash
40
40
  npm install -g context-doctor && context-doctor install
@@ -89,7 +89,7 @@ Practical upshot: a developer who only wants cheaper, faster API calls never tou
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
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
- | `context-doctor optimize <file>` | Apply the safe fixes; `--strategy prune-history` for consented lossy compaction |
92
+ | `context-doctor optimize <file>` | Apply the safe fixes; add `--strategy trim-tool-calls` for big inline file writes, `--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
95
  | `context-doctor report` | Machine-wide impact report (proxy savings persist across restarts): exact proxy savings, hook activity, recoverable waste in recent sessions |
@@ -261,8 +261,11 @@ const { conversation, tokensBefore, tokensAfter } = optimizeConversation(chatJso
261
261
  | `dedupe` — replace repeated content with a reference | No | ✅ |
262
262
  | `trim-tool-results` — truncate stale tool outputs | Mostly no | ✅ |
263
263
  | `strip-base64` — remove inline binary blobs | No (for the model) | ✅ |
264
+ | `trim-tool-calls` — shrink the arguments of calls that already ran | Mostly no | opt-in |
264
265
  | `prune-history` — collapse old turns into a stub for summarization | Yes | opt-in |
265
266
 
267
+ `trim-tool-calls` is the big one for agent sessions. Writing a file through a tool call puts the entire file in context permanently, so in file-heavy work the calls outweigh every tool result combined — on a real 278k-token session, the default set reached 248k and adding `trim-tool-calls` reached 102k. It is opt-in because it edits what the model itself wrote.
268
+
266
269
  Everything the optimizer does is inspectable: it prints exactly which messages changed and how many tokens each change saved.
267
270
 
268
271
  **Summarization without an API key:** when `prune-history` runs through the MCP tools, context-doctor hands a digest of the pruned turns back to the model that called it (the Claude/GPT already running in your app) and asks *it* to write the replacement summary — LLM-quality compaction, zero extra cost, no keys.
package/dist/blob.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Inline base64 detection, shared by the profiler and the optimizer.
3
+ *
4
+ * A charset match alone is not enough: `"a".repeat(5000)` is valid base64
5
+ * alphabet, and so are long hex digests, IDs and minified identifiers. Calling
6
+ * those "base64" makes the profiler wrong and — far worse — makes the
7
+ * `strip-base64` strategy DELETE real content. So a candidate must also look
8
+ * like encoded binary: spanning most of the 64-symbol alphabet, with no single
9
+ * character dominating.
10
+ */
11
+ export declare const BASE64_PLACEHOLDER = "[context-doctor: base64 blob removed \u2014 use file/image APIs instead]";
12
+ /** True when the text carries at least one inline base64 blob. */
13
+ export declare function hasBase64Blob(text: string): boolean;
14
+ /** Replace real base64 blobs with a placeholder, leaving lookalikes intact. */
15
+ export declare function stripBase64Blobs(text: string): string;
package/dist/blob.js ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Inline base64 detection, shared by the profiler and the optimizer.
3
+ *
4
+ * A charset match alone is not enough: `"a".repeat(5000)` is valid base64
5
+ * alphabet, and so are long hex digests, IDs and minified identifiers. Calling
6
+ * those "base64" makes the profiler wrong and — far worse — makes the
7
+ * `strip-base64` strategy DELETE real content. So a candidate must also look
8
+ * like encoded binary: spanning most of the 64-symbol alphabet, with no single
9
+ * character dominating.
10
+ */
11
+ /** Runs of base64 alphabet long enough to be worth reporting (~375 bytes+). */
12
+ const BASE64_RE = /(?:data:[\w/+.-]+;base64,)?[A-Za-z0-9+/]{500,}={0,2}/g;
13
+ export const BASE64_PLACEHOLDER = "[context-doctor: base64 blob removed — use file/image APIs instead]";
14
+ /** Distinct characters a genuine base64 payload is expected to span. */
15
+ const MIN_DISTINCT_CHARS = 24;
16
+ /** Above this share for one character, the run is padding or repetition. */
17
+ const MAX_SINGLE_CHAR_SHARE = 0.35;
18
+ function isBase64Blob(candidate) {
19
+ // An explicit data: URI declares its own encoding — no need to guess.
20
+ if (candidate.startsWith("data:"))
21
+ return true;
22
+ const counts = new Map();
23
+ for (const ch of candidate)
24
+ counts.set(ch, (counts.get(ch) ?? 0) + 1);
25
+ if (counts.size < MIN_DISTINCT_CHARS)
26
+ return false;
27
+ let max = 0;
28
+ for (const n of counts.values())
29
+ if (n > max)
30
+ max = n;
31
+ return max / candidate.length < MAX_SINGLE_CHAR_SHARE;
32
+ }
33
+ /** True when the text carries at least one inline base64 blob. */
34
+ export function hasBase64Blob(text) {
35
+ for (const m of text.matchAll(BASE64_RE)) {
36
+ if (isBase64Blob(m[0]))
37
+ return true;
38
+ }
39
+ return false;
40
+ }
41
+ /** Replace real base64 blobs with a placeholder, leaving lookalikes intact. */
42
+ export function stripBase64Blobs(text) {
43
+ return text.replace(BASE64_RE, (m) => (isBase64Blob(m) ? BASE64_PLACEHOLDER : m));
44
+ }
package/dist/cli.js CHANGED
@@ -71,7 +71,8 @@ Options:
71
71
  exceeded — lets CI gate a pull request on context size
72
72
  --out <file> (optimize) Write result to file instead of stdout
73
73
  --strategy <id> (optimize) Strategy to run; repeatable.
74
- Available: dedupe, trim-tool-results, strip-base64, prune-history
74
+ Available: dedupe, trim-tool-results, trim-tool-calls, strip-base64,
75
+ prune-history
75
76
  Default: dedupe, trim-tool-results, strip-base64 (lossless-ish set)
76
77
  --keep-recent <n> (optimize) Messages at the tail to leave untouched (default 6)
77
78
  --max-tool-tokens <n> (optimize) Token budget for trimmed tool results (default 300)
package/dist/doctor.js CHANGED
@@ -26,12 +26,24 @@ function checkMcpEntry(appName, configPath) {
26
26
  const entry = config.mcpServers?.["context-doctor"];
27
27
  if (!entry)
28
28
  return { label: appName, status: "fail", detail: `no context-doctor entry in ${configPath} — run: context-doctor install` };
29
- // Absolute-path entries must point at a file that still exists.
30
- const target = entry.command === "npx" ? null : entry.args?.[0];
29
+ // Absolute-path entries must point at a file that still exists. Launcher
30
+ // forms (npx, and Windows' cmd /c npx) resolve at spawn time, not now.
31
+ const launcher = entry.command === "npx" || entry.command === "cmd";
32
+ const target = launcher ? null : entry.args?.[0];
31
33
  if (target && !existsSync(target)) {
32
34
  return { label: appName, status: "fail", detail: `MCP entry points at missing file ${target} — re-run: context-doctor install` };
33
35
  }
34
- return { label: appName, status: "ok", detail: `MCP wired (${entry.command === "npx" ? "npx, tracks npm releases" : "local build"})` };
36
+ // Configs written before 0.12 pinned the exact node binary, which a Node
37
+ // upgrade removes; the app then silently loses the tools.
38
+ const cmd = String(entry.command ?? "");
39
+ if (cmd.includes("/") && !existsSync(cmd)) {
40
+ return {
41
+ label: appName,
42
+ status: "fail",
43
+ detail: `MCP command ${cmd} no longer exists (a Node upgrade moves version-pinned paths) — re-run: context-doctor install`,
44
+ };
45
+ }
46
+ return { label: appName, status: "ok", detail: `MCP wired (${launcher ? "npx, tracks npm releases" : "local build"})` };
35
47
  }
36
48
  catch (e) {
37
49
  return { label: appName, status: "fail", detail: `${configPath} is not valid JSON (${e.message})` };
package/dist/install.d.ts CHANGED
@@ -6,5 +6,19 @@
6
6
  * Every config edit is a careful JSON merge with a .backup file written first.
7
7
  * `context-doctor uninstall` reverses it.
8
8
  */
9
+ /**
10
+ * How to invoke the published package as an MCP server on a given platform.
11
+ *
12
+ * On Windows npx is `npx.cmd` — a batch script, not an executable. MCP clients
13
+ * spawn their server directly, without a shell, so a bare "npx" fails with
14
+ * ENOENT and the app simply shows no tools and no error. Hence the cmd /c
15
+ * wrapper that every working Windows MCP config uses.
16
+ *
17
+ * Exported so the platform branch is testable from any host OS.
18
+ */
19
+ export declare function npxLauncher(platformName: string): {
20
+ command: string;
21
+ args: string[];
22
+ };
9
23
  export declare function runInstall(): void;
10
24
  export declare function runUninstall(): void;
package/dist/install.js CHANGED
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, rmSync } from "node:fs";
10
10
  import { homedir, platform } from "node:os";
11
- import { dirname, join } from "node:path";
11
+ import { delimiter, dirname, join, sep } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
13
  function claudeDesktopConfigPath() {
14
14
  switch (platform()) {
@@ -28,18 +28,40 @@ function targets() {
28
28
  ];
29
29
  }
30
30
  /**
31
- * The server command to write into configs. When running from a published
32
- * install, npx keeps it auto-updating; from a local checkout, point at the
33
- * built file directly so it works before the package is on npm.
31
+ * The server command to write into configs.
32
+ *
33
+ * Never write `process.execPath`: on Homebrew, nvm and asdf that is a
34
+ * VERSION-PINNED path (…/node/25.6.0/bin/node), so the next Node upgrade
35
+ * silently breaks every config we wrote — the apps simply stop showing the
36
+ * tools, with no error to explain why. `node` from PATH survives upgrades.
37
+ *
38
+ * Installed from npm → npx, which also picks up package updates. Local
39
+ * checkout → the built file, so the repo works before/without publishing.
34
40
  */
35
41
  function serverEntry() {
36
42
  const selfDir = dirname(fileURLToPath(import.meta.url));
37
43
  const localMcp = join(selfDir, "mcp.js");
38
- const runningFromNpx = (process.env.npm_execpath ?? "").includes("npx") || selfDir.includes("_npx");
39
- if (!runningFromNpx && existsSync(localMcp)) {
40
- return { command: process.execPath, args: [localMcp] };
44
+ const fromPackage = selfDir.includes(`${sep}node_modules${sep}`) || selfDir.includes("_npx");
45
+ if (!fromPackage && existsSync(localMcp)) {
46
+ // `node` is node.exe on Windows — directly spawnable, no shell needed.
47
+ return { command: "node", args: [localMcp] };
41
48
  }
42
- return { command: "npx", args: ["-y", "context-doctor-mcp"] };
49
+ return npxLauncher(platform());
50
+ }
51
+ /**
52
+ * How to invoke the published package as an MCP server on a given platform.
53
+ *
54
+ * On Windows npx is `npx.cmd` — a batch script, not an executable. MCP clients
55
+ * spawn their server directly, without a shell, so a bare "npx" fails with
56
+ * ENOENT and the app simply shows no tools and no error. Hence the cmd /c
57
+ * wrapper that every working Windows MCP config uses.
58
+ *
59
+ * Exported so the platform branch is testable from any host OS.
60
+ */
61
+ export function npxLauncher(platformName) {
62
+ return platformName === "win32"
63
+ ? { command: "cmd", args: ["/c", "npx", "-y", "context-doctor-mcp"] }
64
+ : { command: "npx", args: ["-y", "context-doctor-mcp"] };
43
65
  }
44
66
  function readJson(path) {
45
67
  if (!existsSync(path))
@@ -57,17 +79,68 @@ function writeJsonWithBackup(path, data) {
57
79
  copyFileSync(path, path + ".context-doctor.backup");
58
80
  writeFileSync(path, JSON.stringify(data, null, 2));
59
81
  }
60
- /** Shell command used for the Claude Code every-prompt hook. */
82
+ /**
83
+ * Find an executable on PATH without shelling out (works on Windows too).
84
+ * Used to prefer a global `context-doctor` install for the hook: a stable
85
+ * location that survives both package updates and Node upgrades.
86
+ */
87
+ function binOnPath(name) {
88
+ const exts = platform() === "win32" ? [".cmd", ".exe", ".bat", ""] : [""];
89
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
90
+ if (!dir)
91
+ continue;
92
+ for (const ext of exts) {
93
+ const candidate = join(dir, name + ext);
94
+ if (existsSync(candidate))
95
+ return candidate;
96
+ }
97
+ }
98
+ return null;
99
+ }
100
+ /**
101
+ * Shell command used for the Claude Code every-prompt hook.
102
+ *
103
+ * The hook runs on EVERY prompt, so the command must be both fast and durable.
104
+ * In preference order:
105
+ * 1. a local checkout's built cli.js — absolute, stable, zero resolution cost;
106
+ * 2. a global `context-doctor` binary on PATH — same, for npm -g installs;
107
+ * 3. `npx -y context-doctor hook` — last resort.
108
+ *
109
+ * Critically, a path inside npx's `_npx` cache is NEVER written: npm garbage-
110
+ * collects that directory, and the hook would then fail silently on every
111
+ * prompt. `node` (not process.execPath) keeps it alive across Node upgrades.
112
+ */
61
113
  function hookCommand() {
62
114
  const selfDir = dirname(fileURLToPath(import.meta.url));
63
115
  const localCli = join(selfDir, "cli.js");
64
- const runningFromNpx = (process.env.npm_execpath ?? "").includes("npx") || selfDir.includes("_npx");
65
- if (!runningFromNpx && existsSync(localCli)) {
66
- return `"${process.execPath}" "${localCli}" hook`;
67
- }
116
+ const ephemeral = selfDir.includes("_npx");
117
+ if (!ephemeral && existsSync(localCli))
118
+ return `node "${localCli}" hook`;
119
+ const global = binOnPath("context-doctor");
120
+ if (global)
121
+ return `"${global}" hook`;
68
122
  return "npx -y context-doctor hook";
69
123
  }
124
+ /** True when the hook had to fall back to npx — worth telling the user. */
125
+ function hookUsesNpx() {
126
+ return hookCommand().startsWith("npx ");
127
+ }
70
128
  const HOOK_MARKER = "context-doctor";
129
+ /**
130
+ * Is this settings.json hook entry ours?
131
+ *
132
+ * Usually the command contains "context-doctor" (npx form, or a path through
133
+ * the package directory). A repo cloned into a differently-named folder does
134
+ * not, so a command ending in `cli.js hook` counts too — specific enough not
135
+ * to claim an unrelated hook.
136
+ */
137
+ function isOurHookEntry(entry) {
138
+ const raw = JSON.stringify(entry ?? "");
139
+ if (raw.includes(HOOK_MARKER))
140
+ return true;
141
+ const command = String(entry?.hooks?.[0]?.command ?? "");
142
+ return /(cli\.js|context-doctor(\.cmd|\.exe|\.bat)?)"?\s+hook\s*$/.test(command);
143
+ }
71
144
  /**
72
145
  * Register the UserPromptSubmit hook in ~/.claude/settings.json so EVERY
73
146
  * Claude Code query gets a context-size check. Idempotent.
@@ -79,12 +152,28 @@ function installHook() {
79
152
  const settings = readJson(settingsPath);
80
153
  settings.hooks = settings.hooks ?? {};
81
154
  const entries = settings.hooks.UserPromptSubmit ?? [];
82
- const already = entries.some((e) => JSON.stringify(e).includes(HOOK_MARKER));
83
- if (!already) {
84
- entries.push({ hooks: [{ type: "command", command: hookCommand() }] });
85
- settings.hooks.UserPromptSubmit = entries;
155
+ const want = hookCommand();
156
+ // Re-running install must REPAIR a stale entry, not skip it. Earlier versions
157
+ // wrote a version-pinned node binary; if we only checked "is it present?" an
158
+ // upgrade would leave that broken command in place forever.
159
+ const ours = entries.filter(isOurHookEntry);
160
+ const current = ours[0]?.hooks?.[0]?.command;
161
+ if (ours.length === 0) {
162
+ entries.push({ hooks: [{ type: "command", command: want }] });
163
+ }
164
+ else if (current !== want) {
165
+ // Replace every entry of ours with exactly one correct entry.
166
+ const others = entries.filter((e) => !isOurHookEntry(e));
167
+ others.push({ hooks: [{ type: "command", command: want }] });
168
+ settings.hooks.UserPromptSubmit = others;
86
169
  writeJsonWithBackup(settingsPath, settings);
170
+ return settingsPath;
87
171
  }
172
+ else {
173
+ return settingsPath; // already correct — leave the file untouched
174
+ }
175
+ settings.hooks.UserPromptSubmit = entries;
176
+ writeJsonWithBackup(settingsPath, settings);
88
177
  return settingsPath;
89
178
  }
90
179
  function uninstallHook() {
@@ -95,7 +184,7 @@ function uninstallHook() {
95
184
  const entries = settings.hooks?.UserPromptSubmit;
96
185
  if (!entries)
97
186
  return;
98
- const filtered = entries.filter((e) => !JSON.stringify(e).includes(HOOK_MARKER));
187
+ const filtered = entries.filter((e) => !isOurHookEntry(e));
99
188
  if (filtered.length !== entries.length) {
100
189
  settings.hooks.UserPromptSubmit = filtered;
101
190
  if (filtered.length === 0)
@@ -128,6 +217,7 @@ export function runInstall() {
128
217
  try {
129
218
  const config = readJson(target.configPath);
130
219
  config.mcpServers = config.mcpServers ?? {};
220
+ // Always overwrite: re-running install is how a stale entry gets repaired.
131
221
  config.mcpServers["context-doctor"] = entry;
132
222
  writeJsonWithBackup(target.configPath, config);
133
223
  console.log(`✓ ${target.name}: MCP server added (${target.configPath})`);
@@ -140,8 +230,14 @@ export function runInstall() {
140
230
  if (skillPath)
141
231
  console.log(`✓ Agent Skill installed for Claude Code (${skillPath})`);
142
232
  const hookPath = installHook();
143
- if (hookPath)
233
+ if (hookPath) {
144
234
  console.log(`✓ Claude Code every-prompt hook installed (${hookPath}) — heavy sessions get automatic hygiene guidance`);
235
+ // npx resolves the package on every single prompt; a global install makes
236
+ // the hook a plain exec instead, which is both faster and update-proof.
237
+ if (hookUsesNpx()) {
238
+ console.log(" note: the hook falls back to npx. For a faster, permanent hook: npm i -g context-doctor && context-doctor install");
239
+ }
240
+ }
145
241
  console.log("\nDone. Restart the apps to pick up the new tools, then try:");
146
242
  console.log(' "What\'s eating my context?" — or paste a conversation and ask for a profile.');
147
243
  }
package/dist/mcp.js CHANGED
@@ -30,14 +30,14 @@ import { recordLedger } from "./ledger.js";
30
30
  // Kept deliberately terse: these ride in EVERY conversation's context, and a
31
31
  // context-saving tool must not itself be context overhead (~110 tokens).
32
32
  const SERVER_INSTRUCTIONS = `Context hygiene, always: summarize large pastes/tool results instead of carrying them verbatim; reference earlier content, don't re-quote; never inline base64. Past ~30 turns or several large pastes, proactively offer to run profile_context. Any question about tokens, cost, or latency: call profile_context, don't estimate. If optimize_context returns a pruned-turns digest, you write the ≤150-token replacement summary.`;
33
- const STRATEGY_IDS = ["dedupe", "trim-tool-results", "strip-base64", "prune-history"];
33
+ const STRATEGY_IDS = ["dedupe", "trim-tool-results", "trim-tool-calls", "strip-base64", "prune-history"];
34
34
  /**
35
35
  * Build a fully-configured server instance. A factory (not a singleton) so the
36
36
  * stateless HTTP mode can hand every request its own server, per the MCP SDK's
37
37
  * recommended pattern.
38
38
  */
39
39
  function createServer() {
40
- const server = new McpServer({ name: "context-doctor", version: "0.11.0" }, { instructions: SERVER_INSTRUCTIONS });
40
+ const server = new McpServer({ name: "context-doctor", version: "0.12.1" }, { 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"),
@@ -48,7 +48,7 @@ function createServer() {
48
48
  server.tool("optimize_context", "Rewrite a conversation to reclaim tokens using deterministic strategies: dedupe repeated content, trim stale tool results, strip base64 blobs, optionally prune old history. Returns the slimmed conversation JSON plus a savings summary. No LLM calls — safe and inspectable. Call this after profile_context finds recoverable waste and the user wants it fixed; add the prune-history strategy only with the user's consent, then write the replacement summary yourself as the result instructs.", {
49
49
  conversation: z.string().describe("Conversation JSON (OpenAI or Anthropic format, or bare message array)"),
50
50
  strategies: z.array(z.enum(STRATEGY_IDS)).optional()
51
- .describe("Strategies to apply. Default: dedupe, trim-tool-results, strip-base64. Add prune-history for lossy compaction of old turns."),
51
+ .describe("Strategies to apply. Default: dedupe, trim-tool-results, strip-base64. Add trim-tool-calls to shrink big inline file writes, or prune-history for lossy compaction of old turns."),
52
52
  keep_recent: z.number().int().positive().optional().describe("Messages at the tail to leave untouched (default 6)"),
53
53
  max_tool_result_tokens: z.number().int().positive().optional().describe("Token budget for trimmed tool results (default 300)"),
54
54
  }, async ({ conversation, strategies, keep_recent, max_tool_result_tokens }) => {
@@ -6,12 +6,12 @@
6
6
  * Strategies operate on the ORIGINAL JSON structure (not the normalized view)
7
7
  * so the output is a drop-in replacement for the input conversation.
8
8
  */
9
- export type StrategyId = "dedupe" | "trim-tool-results" | "prune-history" | "strip-base64";
9
+ export type StrategyId = "dedupe" | "trim-tool-results" | "trim-tool-calls" | "prune-history" | "strip-base64";
10
10
  export interface OptimizeOptions {
11
11
  strategies?: StrategyId[];
12
12
  /** Tool results older than this many messages from the end get trimmed. */
13
13
  keepRecent?: number;
14
- /** Max tokens a trimmed tool result keeps. */
14
+ /** Max tokens a trimmed tool result — or tool-call argument set — keeps. */
15
15
  maxToolResultTokens?: number;
16
16
  }
17
17
  export interface AppliedChange {
package/dist/optimize.js CHANGED
@@ -8,12 +8,36 @@
8
8
  */
9
9
  import { createHash } from "node:crypto";
10
10
  import { estimateTokens } from "./tokens.js";
11
+ import { hasBase64Blob, stripBase64Blobs } from "./blob.js";
11
12
  const DEFAULTS = {
12
13
  strategies: ["dedupe", "trim-tool-results", "strip-base64"],
13
14
  keepRecent: 6,
14
15
  maxToolResultTokens: 300,
15
16
  };
16
- const BASE64_RE = /(?:data:[\w/+.-]+;base64,)?[A-Za-z0-9+/]{500,}={0,2}/g;
17
+ /**
18
+ * Shrink the arguments of a tool call that has already run.
19
+ *
20
+ * In file-heavy agent sessions the biggest single items in context are not
21
+ * tool RESULTS but tool CALLS: a Write or a `cat > file <<EOF` carries the
22
+ * whole file inline, forever. Once the call has returned, the live context
23
+ * only needs enough of the arguments to identify what was done.
24
+ *
25
+ * Keys are preserved (so the call still reads as itself) and only long string
26
+ * values are cut, with an explicit marker so nothing looks silently complete.
27
+ */
28
+ function trimCallArguments(input, maxTokens) {
29
+ const budgetChars = maxTokens * 4;
30
+ const out = {};
31
+ for (const [key, value] of Object.entries(input)) {
32
+ if (typeof value === "string" && value.length > budgetChars) {
33
+ out[key] = value.slice(0, budgetChars) + `\n[context-doctor: ${value.length - budgetChars} more chars trimmed — this call already ran]`;
34
+ }
35
+ else {
36
+ out[key] = value;
37
+ }
38
+ }
39
+ return out;
40
+ }
17
41
  function hash(text) {
18
42
  return createHash("sha1").update(text.replace(/\s+/g, " ").trim()).digest("hex");
19
43
  }
@@ -109,11 +133,10 @@ export function optimizeConversation(input, options = {}) {
109
133
  if (opts.strategies.includes("strip-base64")) {
110
134
  messages.forEach((m, i) => {
111
135
  const text = textOf(m.content);
112
- if (!BASE64_RE.test(text))
136
+ if (!hasBase64Blob(text))
113
137
  return;
114
- BASE64_RE.lastIndex = 0;
115
138
  const before = estimateTokens(text);
116
- const cleaned = text.replace(BASE64_RE, "[context-doctor: base64 blob removed — use file/image APIs instead]");
139
+ const cleaned = stripBase64Blobs(text);
117
140
  const saved = before - estimateTokens(cleaned);
118
141
  if (saved > 50) {
119
142
  m.content = replaceText(m.content, cleaned);
@@ -159,6 +182,46 @@ export function optimizeConversation(input, options = {}) {
159
182
  });
160
183
  });
161
184
  }
185
+ // -- trim-tool-calls: shrink the arguments of calls that already ran ----------
186
+ if (opts.strategies.includes("trim-tool-calls")) {
187
+ const cutoff = messages.length - opts.keepRecent;
188
+ messages.forEach((m, i) => {
189
+ if (i >= cutoff)
190
+ return;
191
+ let saved = 0;
192
+ // Anthropic shape: tool_use blocks with a structured `input`.
193
+ if (Array.isArray(m.content)) {
194
+ for (const b of m.content) {
195
+ if (b?.type !== "tool_use" || b.input == null || typeof b.input !== "object")
196
+ continue;
197
+ const before = estimateTokens(JSON.stringify(b.input));
198
+ if (before <= opts.maxToolResultTokens)
199
+ continue;
200
+ b.input = trimCallArguments(b.input, opts.maxToolResultTokens);
201
+ saved += before - estimateTokens(JSON.stringify(b.input));
202
+ }
203
+ }
204
+ // OpenAI shape: tool_calls[].function.arguments is a JSON string.
205
+ for (const tc of m.tool_calls ?? []) {
206
+ const args = tc?.function?.arguments;
207
+ if (typeof args !== "string")
208
+ continue;
209
+ const before = estimateTokens(args);
210
+ if (before <= opts.maxToolResultTokens)
211
+ continue;
212
+ tc.function.arguments = truncateToTokens(args, opts.maxToolResultTokens);
213
+ saved += before - estimateTokens(tc.function.arguments);
214
+ }
215
+ if (saved > 0) {
216
+ applied.push({
217
+ strategy: "trim-tool-calls",
218
+ messageIndex: i,
219
+ tokensSaved: saved,
220
+ note: "Arguments of a completed tool call truncated",
221
+ });
222
+ }
223
+ });
224
+ }
162
225
  // -- prune-history: replace the older half with a stub ------------------------
163
226
  // Opt-in only: it is lossy, so it is not in the default strategy set.
164
227
  let prunedDigest;
package/dist/parse.d.ts CHANGED
@@ -27,5 +27,11 @@ export interface NormalizedConversation {
27
27
  messages: NormalizedMessage[];
28
28
  /** Format detected, for reporting. */
29
29
  sourceFormat: "openai" | "anthropic" | "array" | "text";
30
+ /**
31
+ * Set when the input could not be read as a conversation. Silently profiling
32
+ * a broken file as one big "user message" produces a confident, wrong report
33
+ * — the caller should show this instead.
34
+ */
35
+ parseWarning?: string;
30
36
  }
31
37
  export declare function parseConversation(input: string): NormalizedConversation;
package/dist/parse.js CHANGED
@@ -90,16 +90,30 @@ export function parseConversation(input) {
90
90
  try {
91
91
  data = JSON.parse(input);
92
92
  }
93
- catch {
94
- // Not JSON — treat the whole thing as one user message so profiling still works.
93
+ catch (e) {
94
+ // Not JSON — treat the whole thing as one user message so profiling still
95
+ // works for raw prompts. But if it LOOKS like JSON, the user handed us a
96
+ // broken conversation file and deserves to be told, not given a report
97
+ // about a single 9-token "message".
98
+ const head = input.trimStart()[0];
99
+ const parseWarning = input.trim() === ""
100
+ ? "Input is empty — nothing to profile."
101
+ : head === "{" || head === "["
102
+ ? `Input starts like JSON but does not parse (${e.message}). Profiling it as raw text, which is almost certainly not what you want.`
103
+ : undefined;
95
104
  return {
96
105
  sourceFormat: "text",
97
- messages: [{ index: 0, role: "user", kind: "user", text: input, hasBinary: false }],
106
+ parseWarning,
107
+ // Empty input has no message: reporting "1 message, ~4 tokens" for it
108
+ // would be inventing content that is not there.
109
+ messages: input.trim() === "" ? [] : [{ index: 0, role: "user", kind: "user", text: input, hasBinary: false }],
98
110
  };
99
111
  }
100
112
  if (Array.isArray(data)) {
113
+ const looksLikeMessages = data.length === 0 || data.some((m) => m && typeof m === "object" && "role" in m);
101
114
  return {
102
115
  sourceFormat: "array",
116
+ parseWarning: looksLikeMessages ? undefined : "This is a JSON array, but no element has a `role` field — it does not look like a conversation.",
103
117
  messages: data.map((m, i) => normalizeMessage(m, i)),
104
118
  };
105
119
  }
@@ -114,5 +128,8 @@ export function parseConversation(input) {
114
128
  messages.push(...rawMessages.map((m, i) => normalizeMessage(m, i)));
115
129
  const isAnthropic = obj.system != null ||
116
130
  rawMessages.some((m) => Array.isArray(m.content) && m.content.some((b) => b?.type === "tool_use" || b?.type === "tool_result"));
117
- return { sourceFormat: isAnthropic ? "anthropic" : "openai", messages };
131
+ const parseWarning = messages.length === 0
132
+ ? "This JSON has no `messages` array (and no `system`) — it does not look like a conversation. Expected {\"messages\":[{\"role\":…,\"content\":…}]}."
133
+ : undefined;
134
+ return { sourceFormat: isAnthropic ? "anthropic" : "openai", parseWarning, messages };
118
135
  }
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" | "repeated_file_read" | "retained_error_output" | "base64_blob" | "long_history" | "large_system_prompt" | "cache_ordering" | "near_window_limit";
15
+ export type FindingId = "large_tool_result" | "large_tool_call" | "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";
@@ -51,5 +51,7 @@ export interface ContextProfile {
51
51
  /** Present when the model has a known price. All figures are estimates. */
52
52
  cost?: CostEstimate;
53
53
  sourceFormat: string;
54
+ /** Propagated from parsing: input could not be read as a conversation. */
55
+ parseWarning?: string;
54
56
  }
55
57
  export declare function profileConversation(conv: NormalizedConversation, model?: string): ContextProfile;
package/dist/profile.js CHANGED
@@ -5,6 +5,7 @@
5
5
  import { createHash } from "node:crypto";
6
6
  import { contextWindowFor, estimateTokens, MESSAGE_OVERHEAD_TOKENS, providerFor } from "./tokens.js";
7
7
  import { estimatedTtftSeconds, inputCostUsd, pricingFor } from "./pricing.js";
8
+ import { hasBase64Blob } from "./blob.js";
8
9
  function categoryOf(m) {
9
10
  switch (m.kind) {
10
11
  case "system": return "system";
@@ -56,7 +57,6 @@ function preview(text, len = 90) {
56
57
  function contentHash(text) {
57
58
  return createHash("sha1").update(text.replace(/\s+/g, " ").trim()).digest("hex");
58
59
  }
59
- const BASE64_RE = /(?:data:[\w/+.-]+;base64,|[A-Za-z0-9+/]{500,}={0,2})/;
60
60
  /** FNV-1a — cheap deterministic hash for shingle sampling. */
61
61
  function fnv1a(s) {
62
62
  let h = 0x811c9dc5;
@@ -121,6 +121,24 @@ export function profileConversation(conv, model) {
121
121
  });
122
122
  }
123
123
  }
124
+ // -- Large individual tool calls --------------------------------------------
125
+ // Writing a file through a tool call puts the ENTIRE file contents in the
126
+ // context permanently, and in file-heavy agent sessions these outweigh every
127
+ // tool result put together. Unlike a result, the argument cannot be trimmed
128
+ // before the call — but once the call has returned, the live context only
129
+ // needs a reference to what was written.
130
+ for (const p of perMessage) {
131
+ if (p.msg.kind === "tool_call" && p.tokens > 2000) {
132
+ findings.push({
133
+ id: "large_tool_call",
134
+ severity: p.tokens > 8000 ? "high" : "warn",
135
+ estSavings: Math.round(p.tokens * 0.8),
136
+ message: `Tool call at message #${p.msg.index}${p.msg.toolName ? ` (${p.msg.toolName})` : ""} carries ~${p.tokens} tokens of arguments.`,
137
+ suggestion: "Usually a whole file being written inline. Once it has run, replace the arguments with a reference (\"wrote <path>\") — or write files in smaller pieces so no single call carries the entire content.",
138
+ messages: [p.msg.index],
139
+ });
140
+ }
141
+ }
124
142
  // -- Exact duplicate content ------------------------------------------------
125
143
  const seen = new Map();
126
144
  for (const p of perMessage) {
@@ -259,7 +277,7 @@ export function profileConversation(conv, model) {
259
277
  }
260
278
  // -- Base64 / binary blobs ---------------------------------------------------
261
279
  for (const p of perMessage) {
262
- if (BASE64_RE.test(p.msg.text)) {
280
+ if (hasBase64Blob(p.msg.text)) {
263
281
  findings.push({
264
282
  id: "base64_blob",
265
283
  severity: "high",
@@ -359,5 +377,6 @@ export function profileConversation(conv, model) {
359
377
  totalEstSavings,
360
378
  cost,
361
379
  sourceFormat: conv.sourceFormat,
380
+ parseWarning: conv.parseWarning,
362
381
  };
363
382
  }
package/dist/report.js CHANGED
@@ -29,6 +29,13 @@ export function renderProfile(profile, options = {}) {
29
29
  const p = profile;
30
30
  lines.push("CONTEXT DOCTOR — profile");
31
31
  lines.push("═".repeat(56));
32
+ // A malformed or non-conversation input still profiles (as raw text), but a
33
+ // report that does not say so reads as a confident answer to the wrong
34
+ // question. Lead with the warning.
35
+ if (p.parseWarning) {
36
+ lines.push(`⚠ ${p.parseWarning}`);
37
+ lines.push("");
38
+ }
32
39
  lines.push(`Total: ~${formatTokens(p.totalTokens)} tokens across ${p.messageCount} messages (${p.sourceFormat} format)`);
33
40
  if (p.model) {
34
41
  const windowNote = p.contextWindow
@@ -67,10 +74,32 @@ export function renderProfile(profile, options = {}) {
67
74
  if (p.findings.length > 0) {
68
75
  lines.push(`Findings (${p.findings.length})`);
69
76
  lines.push("─".repeat(56));
77
+ // A file-heavy session can produce a dozen findings of one kind, each with
78
+ // the same advice. Printing them all buries the other kinds, so show the
79
+ // worst few per kind and total the rest into one line.
80
+ const MAX_PER_KIND = 3;
81
+ const shownPerKind = new Map();
82
+ const heldPerKind = new Map();
70
83
  for (const f of p.findings) {
84
+ const shown = shownPerKind.get(f.id) ?? 0;
85
+ if (shown >= MAX_PER_KIND) {
86
+ const held = heldPerKind.get(f.id) ?? { count: 0, savings: 0 };
87
+ heldPerKind.set(f.id, { count: held.count + 1, savings: held.savings + f.estSavings });
88
+ continue;
89
+ }
90
+ shownPerKind.set(f.id, shown + 1);
71
91
  const savings = f.estSavings > 0 ? ` [save ~${formatTokens(f.estSavings)}]` : "";
72
92
  lines.push(`${SEVERITY_MARK[f.severity]} ${options.redact ? redactText(f.message) : f.message}${savings}`);
73
93
  lines.push(` → ${f.suggestion}`);
94
+ const held = heldPerKind.get(f.id);
95
+ if (shown + 1 === MAX_PER_KIND && held === undefined)
96
+ heldPerKind.set(f.id, { count: 0, savings: 0 });
97
+ }
98
+ for (const [id, held] of heldPerKind) {
99
+ if (held.count === 0)
100
+ continue;
101
+ const more = held.savings > 0 ? `, ~${formatTokens(held.savings)} more recoverable` : "";
102
+ lines.push(` … and ${held.count} more of the same kind (${id})${more}. Use --json for the full list.`);
74
103
  }
75
104
  lines.push("");
76
105
  if (p.totalEstSavings > 0) {
package/dist/session.js CHANGED
@@ -8,7 +8,8 @@
8
8
  * where `message` is in Anthropic Messages format. Everything else
9
9
  * (titles, mode changes, hook records) is metadata and skipped.
10
10
  */
11
- import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
11
+ import { readdirSync, readFileSync, statSync, existsSync, openSync, readSync, closeSync } from "node:fs";
12
+ import { StringDecoder } from "node:string_decoder";
12
13
  import { homedir } from "node:os";
13
14
  import { join } from "node:path";
14
15
  function projectsDir() {
@@ -72,12 +73,60 @@ function parseChatGPTExport(data, path) {
72
73
  path,
73
74
  };
74
75
  }
76
+ /**
77
+ * Read a JSONL transcript line by line without ever materializing the whole
78
+ * file as one string.
79
+ *
80
+ * Agent sessions with large tool results reach hundreds of MB, and those are
81
+ * exactly the sessions that most need analysis — but V8 refuses to build a
82
+ * string past ~512MB, so readFileSync would throw on them (and in the hook,
83
+ * throw *silently*). Streaming has no such ceiling and keeps peak memory at
84
+ * one chunk. StringDecoder carries partial UTF-8 sequences across chunk
85
+ * boundaries so multi-byte characters are never corrupted.
86
+ */
87
+ function forEachLine(path, onLine) {
88
+ const fd = openSync(path, "r");
89
+ const decoder = new StringDecoder("utf8");
90
+ const buf = Buffer.allocUnsafe(4 * 1024 * 1024);
91
+ let pending = "";
92
+ try {
93
+ for (;;) {
94
+ const bytes = readSync(fd, buf, 0, buf.length, null);
95
+ if (bytes === 0)
96
+ break;
97
+ pending += decoder.write(buf.subarray(0, bytes));
98
+ let nl;
99
+ while ((nl = pending.indexOf("\n")) !== -1) {
100
+ onLine(pending.slice(0, nl));
101
+ pending = pending.slice(nl + 1);
102
+ }
103
+ }
104
+ pending += decoder.end();
105
+ if (pending)
106
+ onLine(pending);
107
+ }
108
+ finally {
109
+ closeSync(fd);
110
+ }
111
+ }
112
+ /** Peek at the first bytes to tell a ChatGPT export (JSON array) from JSONL. */
113
+ function startsWithArray(path) {
114
+ const fd = openSync(path, "r");
115
+ try {
116
+ const buf = Buffer.allocUnsafe(64);
117
+ const bytes = readSync(fd, buf, 0, 64, 0);
118
+ return buf.subarray(0, bytes).toString("utf8").trimStart().startsWith("[");
119
+ }
120
+ finally {
121
+ closeSync(fd);
122
+ }
123
+ }
75
124
  export function parseSessionFile(path) {
76
- const raw = readFileSync(path, "utf8");
77
- // ChatGPT exports are one big JSON array, not JSONL.
78
- if (raw.trimStart().startsWith("[")) {
125
+ // ChatGPT exports are one big JSON array, not JSONL — and small enough to
126
+ // read whole. Only peek first, so multi-hundred-MB JSONL is never slurped.
127
+ if (startsWithArray(path)) {
79
128
  try {
80
- const data = JSON.parse(raw);
129
+ const data = JSON.parse(readFileSync(path, "utf8"));
81
130
  if (Array.isArray(data) && data.some((c) => c && typeof c.mapping === "object")) {
82
131
  return parseChatGPTExport(data, path);
83
132
  }
@@ -93,15 +142,15 @@ export function parseSessionFile(path) {
93
142
  let lastCompactIndex = -1;
94
143
  /** Newest API-reported input size, if the transcript carries usage. */
95
144
  let reportedInputTokens;
96
- for (const line of raw.split("\n")) {
145
+ forEachLine(path, (line) => {
97
146
  if (!line.trim())
98
- continue;
147
+ return;
99
148
  let entry;
100
149
  try {
101
150
  entry = JSON.parse(line);
102
151
  }
103
152
  catch {
104
- continue;
153
+ return;
105
154
  }
106
155
  // Titles are metadata lines; the last one wins.
107
156
  if (entry.type === "custom-title" && entry.customTitle)
@@ -109,12 +158,12 @@ export function parseSessionFile(path) {
109
158
  if (entry.type === "ai-title" && entry.aiTitle && !title)
110
159
  title = entry.aiTitle;
111
160
  if ((entry.type !== "user" && entry.type !== "assistant") || !entry.message)
112
- continue;
161
+ return;
113
162
  if (entry.isSidechain)
114
- continue; // subagent traffic has its own context window
163
+ return; // subagent traffic has its own context window
115
164
  const message = entry.message;
116
165
  if (!message.role || message.content == null)
117
- continue;
166
+ return;
118
167
  if (typeof message.model === "string")
119
168
  model = message.model;
120
169
  const usage = message.usage;
@@ -126,7 +175,7 @@ export function parseSessionFile(path) {
126
175
  if (entry.isCompactSummary)
127
176
  lastCompactIndex = messages.length;
128
177
  messages.push({ role: message.role, content: message.content });
129
- }
178
+ });
130
179
  // A compaction replaces everything before it: the summary entry IS the live
131
180
  // history from that point on. Counting the pre-compaction turns would
132
181
  // overstate context, cost per message and window fill — sometimes hugely.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-doctor",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
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",
@@ -36,13 +36,13 @@
36
36
  "LICENSE"
37
37
  ],
38
38
  "engines": {
39
- "node": ">=18"
39
+ "node": ">=20"
40
40
  },
41
41
  "scripts": {
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 dist/test/cache.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 dist/test/session.test.js"
46
46
  },
47
47
  "dependencies": {
48
48
  "@modelcontextprotocol/sdk": "^1.0.0",
@@ -27,7 +27,7 @@ Proactively (do not wait to be asked):
27
27
 
28
28
  If the `context-doctor` MCP tools are available:
29
29
  - `profile_context` — pass a conversation JSON (OpenAI or Anthropic format) or raw text; returns a token breakdown, largest messages, findings with estimated savings.
30
- - `optimize_context` — applies deterministic fixes (dedupe, trim stale tool results, strip base64; opt-in `prune-history`). When the result contains pruned-turn source material and asks for a summary, **you write that summary** (≤150 tokens, dense, factual) and place it where the stub indicates — this is how summarization works without any API key.
30
+ - `optimize_context` — applies deterministic fixes (dedupe, trim stale tool results, strip base64; opt-in `trim-tool-calls` for big inline file writes and `prune-history`). When the result contains pruned-turn source material and asks for a summary, **you write that summary** (≤150 tokens, dense, factual) and place it where the stub indicates — this is how summarization works without any API key.
31
31
  - `context_best_practices` — provider-specific checklist to share with the user.
32
32
 
33
33
  If the tools are not connected, the CLI does the same: `npx context-doctor analyze <file> --model <model>` and `npx context-doctor optimize <file>`. For always-on optimization of the user's own apps: `npx context-doctor proxy` then point `ANTHROPIC_BASE_URL` / `OPENAI_BASE_URL` at it.