context-doctor 0.2.0 → 0.3.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
@@ -24,14 +24,37 @@ Findings (4)
24
24
 
25
25
  ## Quick start (30 seconds)
26
26
 
27
- **One command sets up everything** — detects Claude Desktop, Claude Code, and Cursor on your machine, wires in the MCP server, and installs the Agent Skill:
27
+ **One command sets up everything** — detects Claude Desktop, Claude Code, and Cursor on your machine, wires in the MCP server, installs the Agent Skill, and registers the Claude Code every-prompt hook:
28
28
 
29
29
  ```bash
30
30
  npx context-doctor install
31
31
  ```
32
32
 
33
+ Until the package lands on npm, install straight from GitHub instead (needs Node 18+):
34
+
35
+ ```bash
36
+ npm install -g github:KushalP1/context-doctor && context-doctor install
37
+ ```
38
+
39
+ That pair of commands is also all it takes to **set up context-doctor on anyone else's machine**.
40
+
33
41
  Restart your apps, then just ask Claude: *"what's eating my context?"* (`npx context-doctor uninstall` reverses it.)
34
42
 
43
+ **No API keys, ever.** Everything is deterministic local code; when an LLM is needed (summarizing pruned history), the model already running in your app does it. The proxy forwards *your app's* credentials untouched — context-doctor itself holds nothing.
44
+
45
+ ## What "always-on" means, per surface
46
+
47
+ | Where you run LLMs | Mechanism | Guarantee |
48
+ |---|---|---|
49
+ | Your own apps/agents (API) | `context-doctor proxy` rewrites every request in flight | **Every call, automatic** |
50
+ | Claude Code / Cowork sessions | `install` registers a **UserPromptSubmit hook**: every query measures the session; heavy sessions get injected hygiene guidance (silent when lean, rate-limited, never blocks a prompt) | **Every query checked** |
51
+ | Claude Desktop chat / Cursor | **MCP server instructions** — standing hygiene directives injected into every conversation where the server is enabled, plus prescriptive tool triggers | **Every conversation carries the rules** |
52
+ | claude.ai (web) / ChatGPT app | Upload `skills/context-doctor/SKILL.md` in the app's Skills settings for the same standing behavior | Manual one-time upload |
53
+
54
+ Nothing runs in the background for the Claude apps — the hook, skill, MCP server, and its instructions are all delivered by the app itself at the right moment. The proxy is the only long-running piece, and only your API-calling apps need it.
55
+
56
+ Optional belt-and-braces for any chat app: add one line to your profile preferences — *"Practice context hygiene: summarize large content instead of re-quoting it, and use context-doctor's tools when conversations get heavy."*
57
+
35
58
  Or use the CLI directly, no install needed:
36
59
 
37
60
  ```bash
@@ -88,7 +111,7 @@ Because prompt caching matches byte-identical prefixes, deterministic strategies
88
111
 
89
112
  > **Note on desktop chat apps:** Claude Desktop and the ChatGPT app talk to their own backends — no tool can sit in that path. For those, use the MCP integration below and add a line to your custom instructions like: *"When a conversation gets long or includes large pasted content, proactively use context-doctor's profile_context tool and tell me what to trim."* The model will then invoke it on its own.
90
113
 
91
- ## Use it inside your AI app (MCP)
114
+ ## Use with the Claude & ChatGPT apps
92
115
 
93
116
  `context-doctor` ships an MCP server, so the AI itself can profile and slim context on demand.
94
117
 
@@ -107,7 +130,25 @@ Because prompt caching matches byte-identical prefixes, deterministic strategies
107
130
 
108
131
  **ChatGPT desktop** (developer mode), **Cursor**, **Claude Code** (`claude mcp add context-doctor -- npx -y context-doctor-mcp`), and any other MCP client: same command, their config syntax.
109
132
 
110
- Then just ask: *"profile this conversation with context-doctor"* or paste an exported chat and say *"what's eating my context?"*
133
+ ### How it works in Claude Desktop, step by step
134
+
135
+ 1. Run `npx context-doctor install` (writes the config above for you) and restart Claude Desktop.
136
+ 2. From then on, **every conversation automatically carries context-doctor's standing instructions** — the MCP server hands Claude hygiene rules on connect: summarize big pastes instead of re-quoting them, offer profiling when the chat gets long, never inline base64.
137
+ 3. Chat normally. When a conversation grows heavy, Claude proactively offers: *"this chat is getting large — want me to profile it?"* — or you ask *"what's eating my context?"* and it calls `profile_context` and shows the token/cost breakdown.
138
+ 4. Say *"optimize it"* and Claude applies the safe fixes; if you agree to pruning old history, **Claude itself writes the replacement summary** (that's the no-API-key summarization).
139
+
140
+ ### How it works in ChatGPT, step by step
141
+
142
+ 1. ChatGPT's desktop app supports MCP in **developer mode**: Settings → Connectors → Advanced → Developer mode, then add a local MCP server with command `npx` and args `-y context-doctor-mcp`.
143
+ 2. Enable the connector in a chat. GPT sees the same three tools with the same trigger guidance baked into their descriptions.
144
+ 3. Ask *"profile this conversation"* or paste an exported chat and ask *"what's eating my context?"* — GPT calls `profile_context` and reports the breakdown; *"optimize it"* works the same, including GPT writing the pruning summary itself.
145
+ 4. Caveat: how prominently standing server instructions surface varies by ChatGPT version — the tool descriptions carry the trigger rules regardless, so profiling still fires on the right questions.
146
+
147
+ For ChatGPT on the web (no MCP): export the conversation and use the CLI — `npx context-doctor analyze chat.json --model gpt-5`.
148
+
149
+ ### claude.ai on the web
150
+
151
+ Your local MCP server can't reach the website, but the behavior can: upload `skills/context-doctor/SKILL.md` under Settings → Capabilities → Skills, and web conversations gain the same standing context-hygiene habits (summarize-don't-requote, offer compaction when heavy).
111
152
 
112
153
  ### MCP tools
113
154
 
@@ -172,6 +213,19 @@ Exact counts require each provider's private tokenizer. `context-doctor` uses a
172
213
 
173
214
  Contributions welcome — this project is small on purpose. Open an issue before a big PR.
174
215
 
216
+ ## Releasing (maintainers)
217
+
218
+ ```bash
219
+ npm version patch # or minor/major — bumps package.json + git tag
220
+ npm test # 14 tests must pass; CI runs the same on 3 OSes
221
+ npm publish # prompts for the npm 2FA code
222
+ git push --follow-tags
223
+ ```
224
+
225
+ Known gotcha: if `npm publish` fails with **`404 Not Found - PUT …/context-doctor`** on a package that clearly exists, the real cause is an **expired npm login token** — npm reports unauthenticated publishes as a 404, not a 401. Check with `npm whoami`; if that errors, run `npm login` and publish again.
226
+
227
+ Also keep the MCP server version in `src/mcp.ts` in sync with `package.json`, and remember `dist/` is committed — run `npm run build` before committing so the CI dist-sync check passes.
228
+
175
229
  ## License
176
230
 
177
231
  MIT
package/dist/cli.js CHANGED
@@ -17,6 +17,7 @@ import { formatTokens } from "./tokens.js";
17
17
  import { startProxy } from "./proxy.js";
18
18
  import { runInstall, runUninstall } from "./install.js";
19
19
  import { listSessions, parseSessionFile } from "./session.js";
20
+ import { runHook } from "./hook.js";
20
21
  const HELP = `context-doctor — profile and optimize LLM context windows
21
22
 
22
23
  Usage:
@@ -29,6 +30,8 @@ Usage:
29
30
  context-doctor uninstall Undo install
30
31
  context-doctor session [file] Profile a Claude Code session transcript
31
32
  (default: the most recent session; --list to browse)
33
+ context-doctor hook Claude Code UserPromptSubmit hook (installed
34
+ automatically by \`install\`; reads hook JSON on stdin)
32
35
 
33
36
  Input: a conversation JSON file (OpenAI or Anthropic message format, or a bare
34
37
  message array). Use "-" to read from stdin.
@@ -43,6 +46,7 @@ Options:
43
46
  --keep-recent <n> (optimize) Messages at the tail to leave untouched (default 6)
44
47
  --max-tool-tokens <n> (optimize) Token budget for trimmed tool results (default 300)
45
48
  --port <n> (proxy) Port to listen on (default 8787)
49
+ --host <addr> (proxy) Bind address (default 127.0.0.1; use 0.0.0.0 to expose)
46
50
  --upstream-anthropic <url> (proxy) Override Anthropic upstream (testing)
47
51
  --upstream-openai <url> (proxy) Override OpenAI upstream (testing)
48
52
  -h, --help Show this help
@@ -88,6 +92,9 @@ function parseArgs(argv) {
88
92
  case "--port":
89
93
  args.port = Number(argv[++i]);
90
94
  break;
95
+ case "--host":
96
+ args.host = argv[++i];
97
+ break;
91
98
  case "--upstream-anthropic":
92
99
  args.upstreamAnthropic = argv[++i];
93
100
  break;
@@ -108,6 +115,10 @@ function readInput(file) {
108
115
  }
109
116
  function main() {
110
117
  const args = parseArgs(process.argv.slice(2));
118
+ if (args.command === "hook") {
119
+ void runHook();
120
+ return;
121
+ }
111
122
  if (args.command === "session") {
112
123
  if (args.list) {
113
124
  const sessions = listSessions();
@@ -154,6 +165,7 @@ function main() {
154
165
  if (args.command === "proxy") {
155
166
  startProxy({
156
167
  port: args.port,
168
+ host: args.host,
157
169
  anthropicUpstream: args.upstreamAnthropic,
158
170
  openaiUpstream: args.upstreamOpenai,
159
171
  strategies: args.strategies.length > 0 ? args.strategies : undefined,
package/dist/hook.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Claude Code UserPromptSubmit hook: runs on EVERY query in Claude Code.
3
+ *
4
+ * Claude Code pipes hook input as JSON on stdin ({session_id, transcript_path,
5
+ * prompt, ...}). We profile the session transcript; when the context is lean
6
+ * we print nothing (zero noise, near-zero cost). When it crosses thresholds we
7
+ * emit additionalContext with targeted hygiene guidance — so every query in a
8
+ * heavy session gets nudged toward a leaner context automatically.
9
+ *
10
+ * Registered by `context-doctor install` under hooks.UserPromptSubmit in
11
+ * ~/.claude/settings.json; removed by `context-doctor uninstall`.
12
+ */
13
+ export declare function runHook(): Promise<void>;
package/dist/hook.js ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Claude Code UserPromptSubmit hook: runs on EVERY query in Claude Code.
3
+ *
4
+ * Claude Code pipes hook input as JSON on stdin ({session_id, transcript_path,
5
+ * prompt, ...}). We profile the session transcript; when the context is lean
6
+ * we print nothing (zero noise, near-zero cost). When it crosses thresholds we
7
+ * emit additionalContext with targeted hygiene guidance — so every query in a
8
+ * heavy session gets nudged toward a leaner context automatically.
9
+ *
10
+ * Registered by `context-doctor install` under hooks.UserPromptSubmit in
11
+ * ~/.claude/settings.json; removed by `context-doctor uninstall`.
12
+ */
13
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { join } from "node:path";
16
+ import { parseConversation } from "./parse.js";
17
+ import { profileConversation } from "./profile.js";
18
+ import { parseSessionFile } from "./session.js";
19
+ import { formatTokens } from "./tokens.js";
20
+ import { formatUsd } from "./pricing.js";
21
+ /** Start nudging at 80k tokens of context. */
22
+ const WARN_TOKENS = 80_000;
23
+ /** Re-nudge only after the context grows another 40% — one reminder, not a nag. */
24
+ const REGROWTH_FACTOR = 1.4;
25
+ function statePath() {
26
+ return process.env.CONTEXT_DOCTOR_HOOK_STATE ?? join(homedir(), ".claude", ".context-doctor-hook-state.json");
27
+ }
28
+ async function readStdin() {
29
+ const chunks = [];
30
+ for await (const chunk of process.stdin)
31
+ chunks.push(chunk);
32
+ return Buffer.concat(chunks).toString("utf8");
33
+ }
34
+ export async function runHook() {
35
+ // A hook must never break the user's prompt: any failure exits silently.
36
+ try {
37
+ const input = JSON.parse(await readStdin());
38
+ const transcriptPath = input.transcript_path;
39
+ if (!transcriptPath || !existsSync(transcriptPath))
40
+ return;
41
+ const parsed = parseSessionFile(transcriptPath);
42
+ if (parsed.messageCount === 0)
43
+ return;
44
+ const profile = profileConversation(parseConversation(parsed.conversationJson), parsed.model);
45
+ if (profile.totalTokens < WARN_TOKENS)
46
+ return;
47
+ // Per-session rate limit so the nudge fires on growth, not on every prompt.
48
+ const sessionId = input.session_id ?? transcriptPath;
49
+ let state = {};
50
+ try {
51
+ state = JSON.parse(readFileSync(statePath(), "utf8"));
52
+ }
53
+ catch {
54
+ /* first run */
55
+ }
56
+ const lastWarnedAt = state[sessionId] ?? 0;
57
+ if (profile.totalTokens < lastWarnedAt * REGROWTH_FACTOR)
58
+ return;
59
+ const entries = Object.entries({ ...state, [sessionId]: profile.totalTokens });
60
+ writeFileSync(statePath(), JSON.stringify(Object.fromEntries(entries.slice(-100))));
61
+ const lines = [
62
+ `This session's context is at ~${formatTokens(profile.totalTokens)} tokens` +
63
+ (profile.usagePct ? ` (${profile.usagePct.toFixed(0)}% of the window)` : "") +
64
+ (profile.cost ? `, costing ~${formatUsd(profile.cost.perCallUsd)} of input per message` : "") +
65
+ ".",
66
+ "Practice context hygiene from here on: summarize large tool results instead of keeping them verbatim, reference earlier content rather than re-reading or re-quoting it, and keep responses lean.",
67
+ ];
68
+ const topFinding = profile.findings.find((f) => f.estSavings > 0);
69
+ if (topFinding) {
70
+ lines.push(`Largest recoverable waste: ${topFinding.message} (${topFinding.suggestion})`);
71
+ }
72
+ if (profile.totalTokens > WARN_TOKENS * 2) {
73
+ lines.push("If it fits the flow, offer the user a compaction of the older history.");
74
+ }
75
+ console.log(JSON.stringify({
76
+ hookSpecificOutput: {
77
+ hookEventName: "UserPromptSubmit",
78
+ additionalContext: `<context-doctor>\n${lines.join("\n")}\n</context-doctor>`,
79
+ },
80
+ }));
81
+ }
82
+ catch {
83
+ /* silent — never disturb the prompt */
84
+ }
85
+ }
package/dist/install.js CHANGED
@@ -57,6 +57,53 @@ function writeJsonWithBackup(path, data) {
57
57
  copyFileSync(path, path + ".context-doctor.backup");
58
58
  writeFileSync(path, JSON.stringify(data, null, 2));
59
59
  }
60
+ /** Shell command used for the Claude Code every-prompt hook. */
61
+ function hookCommand() {
62
+ const selfDir = dirname(fileURLToPath(import.meta.url));
63
+ 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
+ }
68
+ return "npx -y context-doctor hook";
69
+ }
70
+ const HOOK_MARKER = "context-doctor";
71
+ /**
72
+ * Register the UserPromptSubmit hook in ~/.claude/settings.json so EVERY
73
+ * Claude Code query gets a context-size check. Idempotent.
74
+ */
75
+ function installHook() {
76
+ const settingsPath = join(homedir(), ".claude", "settings.json");
77
+ if (!existsSync(join(homedir(), ".claude")))
78
+ return null; // no Claude Code here
79
+ const settings = readJson(settingsPath);
80
+ settings.hooks = settings.hooks ?? {};
81
+ 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;
86
+ writeJsonWithBackup(settingsPath, settings);
87
+ }
88
+ return settingsPath;
89
+ }
90
+ function uninstallHook() {
91
+ const settingsPath = join(homedir(), ".claude", "settings.json");
92
+ if (!existsSync(settingsPath))
93
+ return;
94
+ const settings = readJson(settingsPath);
95
+ const entries = settings.hooks?.UserPromptSubmit;
96
+ if (!entries)
97
+ return;
98
+ const filtered = entries.filter((e) => !JSON.stringify(e).includes(HOOK_MARKER));
99
+ if (filtered.length !== entries.length) {
100
+ settings.hooks.UserPromptSubmit = filtered;
101
+ if (filtered.length === 0)
102
+ delete settings.hooks.UserPromptSubmit;
103
+ writeJsonWithBackup(settingsPath, settings);
104
+ console.log("✓ Claude Code every-prompt hook removed");
105
+ }
106
+ }
60
107
  function installSkill() {
61
108
  const selfDir = dirname(fileURLToPath(import.meta.url));
62
109
  // dist/install.js → package root is one level up; skills/ ships in the package.
@@ -92,6 +139,9 @@ export function runInstall() {
92
139
  const skillPath = installSkill();
93
140
  if (skillPath)
94
141
  console.log(`✓ Agent Skill installed for Claude Code (${skillPath})`);
142
+ const hookPath = installHook();
143
+ if (hookPath)
144
+ console.log(`✓ Claude Code every-prompt hook installed (${hookPath}) — heavy sessions get automatic hygiene guidance`);
95
145
  console.log("\nDone. Restart the apps to pick up the new tools, then try:");
96
146
  console.log(' "What\'s eating my context?" — or paste a conversation and ask for a profile.');
97
147
  }
@@ -116,5 +166,6 @@ export function runUninstall() {
116
166
  rmSync(skillDir, { recursive: true });
117
167
  console.log("✓ Agent Skill removed");
118
168
  }
169
+ uninstallHook();
119
170
  console.log("Done.");
120
171
  }
package/dist/mcp.js CHANGED
@@ -20,16 +20,27 @@ import { profileConversation } from "./profile.js";
20
20
  import { optimizeConversation } from "./optimize.js";
21
21
  import { renderProfile } from "./report.js";
22
22
  import { formatTokens } from "./tokens.js";
23
- const server = new McpServer({ name: "context-doctor", version: "0.2.0" });
23
+ /**
24
+ * Server instructions are injected by MCP clients (Claude Desktop, Cursor, …)
25
+ * into the system context of EVERY conversation where this server is enabled.
26
+ * This is what upgrades plain-chat apps from "tools available on request" to
27
+ * "standing context-hygiene instructions in every chat" — no hook needed.
28
+ */
29
+ const SERVER_INSTRUCTIONS = `context-doctor keeps this conversation's context window lean, fast, and cheap. Standing instructions for every conversation:
30
+ 1. Practice context hygiene continuously: after consuming a large tool result or pasted document, carry forward a one-sentence summary instead of re-quoting it; reference earlier content rather than repeating it; never inline base64.
31
+ 2. When the conversation has grown long (roughly 30+ turns) or contains several large pastes, proactively offer to profile it and call profile_context — do not wait to be asked.
32
+ 3. When the user asks anything about token usage, cost, latency, or "what's eating my context", call profile_context immediately rather than estimating by eye.
33
+ 4. When optimize_context returns a pruned-turns digest, YOU write the replacement summary (dense, factual, ≤150 tokens) as instructed in the result.`;
34
+ const server = new McpServer({ name: "context-doctor", version: "0.3.1" }, { instructions: SERVER_INSTRUCTIONS });
24
35
  const STRATEGY_IDS = ["dedupe", "trim-tool-results", "strip-base64", "prune-history"];
25
- 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.", {
36
+ 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.", {
26
37
  conversation: z.string().describe("Conversation JSON (OpenAI or Anthropic format, or bare message array) or raw prompt text"),
27
38
  model: z.string().optional().describe("Target model name for context-window math, e.g. claude-sonnet-5 or gpt-4o"),
28
39
  }, async ({ conversation, model }) => {
29
40
  const profile = profileConversation(parseConversation(conversation), model);
30
41
  return { content: [{ type: "text", text: renderProfile(profile) }] };
31
42
  });
32
- 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.", {
43
+ 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.", {
33
44
  conversation: z.string().describe("Conversation JSON (OpenAI or Anthropic format, or bare message array)"),
34
45
  strategies: z.array(z.enum(STRATEGY_IDS)).optional()
35
46
  .describe("Strategies to apply. Default: dedupe, trim-tool-results, strip-base64. Add prune-history for lossy compaction of old turns."),
@@ -45,9 +56,20 @@ server.tool("optimize_context", "Rewrite a conversation to reclaim tokens using
45
56
  const summary = `Saved ~${formatTokens(saved)} tokens (${formatTokens(result.tokensBefore)} → ${formatTokens(result.tokensAfter)}) ` +
46
57
  `via ${result.applied.length} change(s):\n` +
47
58
  result.applied.map((c) => `- [${c.strategy}] message #${c.messageIndex}: ${c.note} (~${formatTokens(c.tokensSaved)})`).join("\n");
59
+ // Echoing a huge optimized conversation back inline would flood the very
60
+ // context this tool exists to save. Above the cap, return the summary and
61
+ // point at the CLI (compact JSON keeps mid-size results affordable).
62
+ const ECHO_CAP_CHARS = 100_000;
63
+ const conversationJson = JSON.stringify(result.conversation);
48
64
  const content = [
49
65
  { type: "text", text: summary },
50
- { type: "text", text: JSON.stringify(result.conversation, null, 2) },
66
+ conversationJson.length <= ECHO_CAP_CHARS
67
+ ? { type: "text", text: conversationJson }
68
+ : {
69
+ type: "text",
70
+ text: `[optimized conversation is ${conversationJson.length} chars — too large to echo into this context. ` +
71
+ `Tell the user the savings above and that \`npx context-doctor optimize <file> --out slim.json\` produces the file directly.]`,
72
+ },
51
73
  ];
52
74
  // Host-model summarization: instead of calling an LLM ourselves (which would
53
75
  // need an API key), hand the pruned material to the model that invoked this
@@ -74,7 +96,7 @@ const BEST_PRACTICES = {
74
96
  ],
75
97
  anthropic: [
76
98
  "Use prompt caching with cache_control breakpoints after your stable prefix — cached reads cost ~10% of base input price.",
77
- "Claude models have a 200k window, but quality degrades under heavy fill; aim to stay under ~70%.",
99
+ "Current Claude generations have a 1M-token window (Haiku 200k), but quality degrades under heavy fill; aim to stay under ~70%.",
78
100
  "For agents: prefer compact tool-result summaries in history and re-fetch details on demand.",
79
101
  ],
80
102
  openai: [
package/dist/optimize.js CHANGED
@@ -163,26 +163,36 @@ export function optimizeConversation(input, options = {}) {
163
163
  // Opt-in only: it is lossy, so it is not in the default strategy set.
164
164
  let prunedDigest;
165
165
  if (opts.strategies.includes("prune-history") && messages.length > opts.keepRecent * 2) {
166
- const keepFrom = messages.length - opts.keepRecent;
167
- const pruned = messages.slice(0, keepFrom);
168
- const prunedTokens = pruned.reduce((s, m) => s + estimateTokens(textOf(m.content)), 0);
169
- // Digest: first ~200 chars of each pruned turn enough for a host LLM to
170
- // write a faithful summary, small enough not to defeat the pruning.
171
- prunedDigest = pruned
172
- .map((m, i) => `[${i}:${m.role}] ${textOf(m.content).replace(/\s+/g, " ").slice(0, 200)}`)
173
- .join("\n");
174
- const stub = {
175
- role: "user",
176
- content: `[context-doctor: ${pruned.length} earlier messages (~${prunedTokens} tokens) pruned. ` +
177
- `Replace this stub with an LLM-written summary of those turns for best results.]`,
178
- };
179
- messages.splice(0, keepFrom, stub);
180
- applied.push({
181
- strategy: "prune-history",
182
- messageIndex: 0,
183
- tokensSaved: prunedTokens - estimateTokens(stub.content),
184
- note: `Pruned ${pruned.length} old messages`,
185
- });
166
+ let keepFrom = messages.length - opts.keepRecent;
167
+ // Never let the kept tail START with a tool result: its matching tool_use
168
+ // would be pruned away and both the Anthropic and OpenAI APIs reject
169
+ // conversations with orphaned tool results. Advance past any leading tool
170
+ // messages (their calls are in the pruned half anyway).
171
+ while (keepFrom < messages.length && isToolResultMessage(messages[keepFrom]))
172
+ keepFrom++;
173
+ // Boundary adjustment may leave too little tail to be worth keeping —
174
+ // in that case skip pruning entirely rather than gutting the conversation.
175
+ if (messages.length - keepFrom >= 2) {
176
+ const pruned = messages.slice(0, keepFrom);
177
+ const prunedTokens = pruned.reduce((s, m) => s + estimateTokens(textOf(m.content)), 0);
178
+ // Digest: first ~200 chars of each pruned turn — enough for a host LLM to
179
+ // write a faithful summary, small enough not to defeat the pruning.
180
+ prunedDigest = pruned
181
+ .map((m, i) => `[${i}:${m.role}] ${textOf(m.content).replace(/\s+/g, " ").slice(0, 200)}`)
182
+ .join("\n");
183
+ const stub = {
184
+ role: "user",
185
+ content: `[context-doctor: ${pruned.length} earlier messages (~${prunedTokens} tokens) pruned. ` +
186
+ `Replace this stub with an LLM-written summary of those turns for best results.]`,
187
+ };
188
+ messages.splice(0, keepFrom, stub);
189
+ applied.push({
190
+ strategy: "prune-history",
191
+ messageIndex: 0,
192
+ tokensSaved: prunedTokens - estimateTokens(stub.content),
193
+ note: `Pruned ${pruned.length} old messages`,
194
+ });
195
+ }
186
196
  }
187
197
  const tokensAfter = messages.reduce((s, m) => s + estimateTokens(textOf(m.content)), 0);
188
198
  return { conversation: data, tokensBefore, tokensAfter, applied, prunedDigest };
package/dist/pricing.js CHANGED
@@ -11,6 +11,8 @@ const PRICING = [
11
11
  [/claude.*opus/i, { inputPerM: 5, outputPerM: 25, cacheReadPerM: 0.5 }],
12
12
  [/claude.*sonnet/i, { inputPerM: 3, outputPerM: 15, cacheReadPerM: 0.3 }],
13
13
  [/claude.*haiku/i, { inputPerM: 1, outputPerM: 5, cacheReadPerM: 0.1 }],
14
+ [/gpt-5.*(mini|nano)/i, { inputPerM: 0.25, outputPerM: 2, cacheReadPerM: 0.025 }],
15
+ [/gpt-5/i, { inputPerM: 1.25, outputPerM: 10, cacheReadPerM: 0.125 }],
14
16
  [/gpt-4o-mini/i, { inputPerM: 0.15, outputPerM: 0.6, cacheReadPerM: 0.075 }],
15
17
  [/gpt-4o|gpt-4\.1/i, { inputPerM: 2.5, outputPerM: 10, cacheReadPerM: 1.25 }],
16
18
  [/gpt-4-turbo/i, { inputPerM: 10, outputPerM: 30, cacheReadPerM: 10 }],
package/dist/proxy.d.ts CHANGED
@@ -15,6 +15,12 @@ import http from "node:http";
15
15
  import { OptimizeOptions } from "./optimize.js";
16
16
  export interface ProxyOptions extends OptimizeOptions {
17
17
  port?: number;
18
+ /**
19
+ * Bind address. Defaults to 127.0.0.1 — the proxy relays authenticated
20
+ * traffic and exposes /stats, so it must not listen on the network unless
21
+ * the user explicitly opts in (e.g. --host 0.0.0.0 inside a container).
22
+ */
23
+ host?: string;
18
24
  anthropicUpstream?: string;
19
25
  openaiUpstream?: string;
20
26
  }
package/dist/proxy.js CHANGED
@@ -63,9 +63,12 @@ export function startProxy(opts = {}) {
63
63
  let body = Buffer.concat(chunks).toString("utf8");
64
64
  // Optimize the message history in flight. Anything unparseable (or with
65
65
  // no messages array, e.g. embeddings) passes through untouched.
66
+ // count_tokens is measurement — optimizing it would silently change the
67
+ // number the caller is trying to read, so it always passes through.
66
68
  stats.requests++;
69
+ const isMeasurement = url.startsWith("/v1/messages/count_tokens");
67
70
  let note = "passthrough";
68
- if (req.method === "POST" && body) {
71
+ if (req.method === "POST" && body && !isMeasurement) {
69
72
  try {
70
73
  const result = optimizeConversation(body, opts);
71
74
  const saved = result.tokensBefore - result.tokensAfter;
@@ -126,8 +129,9 @@ export function startProxy(opts = {}) {
126
129
  res.end(JSON.stringify({ error: `context-doctor proxy: ${e.message}` }));
127
130
  }
128
131
  });
129
- server.listen(port, () => {
130
- console.error(`context-doctor proxy listening on http://localhost:${port}`);
132
+ const host = opts.host ?? "127.0.0.1";
133
+ server.listen(port, host, () => {
134
+ console.error(`context-doctor proxy listening on http://${host}:${port}`);
131
135
  console.error(` Anthropic apps/SDKs: export ANTHROPIC_BASE_URL=http://localhost:${port}`);
132
136
  console.error(` OpenAI apps/SDKs: export OPENAI_BASE_URL=http://localhost:${port}/v1`);
133
137
  console.error(` Every request's context is optimized in flight; savings are logged here.`);
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Hook tests: the every-prompt Claude Code hook must stay silent on lean
3
+ * sessions, fire with guidance on heavy ones, and rate-limit re-fires.
4
+ */
5
+ export {};
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Hook tests: the every-prompt Claude Code hook must stay silent on lean
3
+ * sessions, fire with guidance on heavy ones, and rate-limit re-fires.
4
+ */
5
+ import { test } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { execFile } from "node:child_process";
8
+ import { mkdtempSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join, dirname } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ const cliPath = join(dirname(fileURLToPath(import.meta.url)), "..", "cli.js");
13
+ const dir = mkdtempSync(join(tmpdir(), "ctxdoc-hook-"));
14
+ const statePath = join(dir, "state.json");
15
+ function transcriptLine(role, content) {
16
+ return JSON.stringify({ type: role, message: { role, content } });
17
+ }
18
+ function runHook(transcriptPath, sessionId) {
19
+ return new Promise((resolve, reject) => {
20
+ const child = execFile(process.execPath, [cliPath, "hook"], { env: { ...process.env, CONTEXT_DOCTOR_HOOK_STATE: statePath } }, (err, stdout) => (err ? reject(err) : resolve(stdout)));
21
+ child.stdin.end(JSON.stringify({ session_id: sessionId, transcript_path: transcriptPath }));
22
+ });
23
+ }
24
+ // Lean session: a couple of small turns.
25
+ const leanPath = join(dir, "lean.jsonl");
26
+ writeFileSync(leanPath, [transcriptLine("user", "hi"), transcriptLine("assistant", "hello!")].join("\n"));
27
+ // Heavy session: ~100k tokens of transcript.
28
+ const heavyPath = join(dir, "heavy.jsonl");
29
+ const bigTurn = "We discussed the deployment pipeline and database migrations at length. ".repeat(80);
30
+ writeFileSync(heavyPath, Array.from({ length: 300 }, (_, i) => transcriptLine(i % 2 ? "assistant" : "user", bigTurn)).join("\n"));
31
+ test("hook stays silent on a lean session", async () => {
32
+ const out = await runHook(leanPath, "lean-session");
33
+ assert.equal(out.trim(), "");
34
+ });
35
+ test("hook fires with hygiene guidance on a heavy session", async () => {
36
+ const out = await runHook(heavyPath, "heavy-session");
37
+ const parsed = JSON.parse(out);
38
+ const ctx = parsed.hookSpecificOutput.additionalContext;
39
+ assert.equal(parsed.hookSpecificOutput.hookEventName, "UserPromptSubmit");
40
+ assert.ok(ctx.includes("<context-doctor>"));
41
+ assert.ok(/context is at ~\d/.test(ctx), "reports the measured size");
42
+ assert.ok(ctx.includes("context hygiene"));
43
+ });
44
+ test("hook rate-limits: second prompt in the same heavy session is silent", async () => {
45
+ const out = await runHook(heavyPath, "heavy-session");
46
+ assert.equal(out.trim(), "");
47
+ });
48
+ test("hook never errors on malformed input", async () => {
49
+ const out = await new Promise((resolve, reject) => {
50
+ const child = execFile(process.execPath, [cliPath, "hook"], (err, stdout) => err ? reject(err) : resolve(stdout));
51
+ child.stdin.end("this is not json");
52
+ });
53
+ assert.equal(out.trim(), "");
54
+ });
@@ -66,6 +66,28 @@ test("optimizer output for Anthropic format keeps block structure valid", () =>
66
66
  const assistantMsg = out.messages[1].content;
67
67
  assert.equal(assistantMsg[0].type, "tool_use");
68
68
  });
69
+ test("prune-history never leaves an orphaned tool result at the head of the tail", () => {
70
+ // Build a conversation where the naive prune boundary would land exactly on
71
+ // a tool-result message (its tool_use call falling in the pruned half).
72
+ const filler = "some earlier discussion that will be pruned away. ".repeat(20);
73
+ const conv = JSON.stringify({
74
+ messages: [
75
+ ...Array.from({ length: 8 }, (_, i) => ({ role: i % 2 ? "assistant" : "user", content: `${i} ${filler}` })),
76
+ { role: "assistant", content: [{ type: "tool_use", id: "tX", name: "search", input: { q: "x" } }] },
77
+ { role: "user", content: [{ type: "tool_result", tool_use_id: "tX", content: "results here" }] }, // naive boundary lands HERE
78
+ { role: "assistant", content: "Summary of results." },
79
+ { role: "user", content: "thanks" },
80
+ { role: "assistant", content: "welcome" },
81
+ ],
82
+ });
83
+ const result = optimizeConversation(conv, { strategies: ["prune-history"], keepRecent: 4 });
84
+ const out = result.conversation;
85
+ // First kept message after the stub must NOT be a tool result.
86
+ const firstKept = out.messages[1].content;
87
+ const isToolResult = Array.isArray(firstKept) && firstKept.some((b) => b?.type === "tool_result");
88
+ assert.equal(isToolResult, false, "tail must not start with an orphaned tool_result");
89
+ assert.ok(result.applied.some((c) => c.strategy === "prune-history"), "pruning still happened");
90
+ });
69
91
  test("raw text input still profiles", () => {
70
92
  const profile = profileConversation(parseConversation("just some prompt text"));
71
93
  assert.equal(profile.messageCount, 1);
package/dist/tokens.js CHANGED
@@ -14,6 +14,7 @@ const MODEL_WINDOWS = [
14
14
  // Current-generation Claude (Fable/Mythos 5, Opus 4.6+, Sonnet 4.6+) is 1M.
15
15
  [/claude.*(fable|mythos)|claude.*opus-?(5|4-[678])|claude.*sonnet-?(5|4-6)/i, 1_000_000],
16
16
  [/claude.*sonnet|claude.*opus|claude-\d/i, 200_000],
17
+ [/gpt-5/i, 400_000],
17
18
  [/gpt-4o|gpt-4-turbo|gpt-4\.1|o[134](-|$)/i, 128_000],
18
19
  [/gpt-4(?!o|\.|-turbo)/i, 8_192],
19
20
  [/gpt-3\.5/i, 16_385],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-doctor",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",
@@ -18,7 +18,7 @@
18
18
  "author": "Kushal P",
19
19
  "repository": {
20
20
  "type": "git",
21
- "url": "https://github.com/KushalP1/context-doctor.git"
21
+ "url": "git+https://github.com/KushalP1/context-doctor.git"
22
22
  },
23
23
  "type": "module",
24
24
  "main": "./dist/index.js",
@@ -41,7 +41,7 @@
41
41
  "build": "tsc",
42
42
  "prepublishOnly": "npm run build",
43
43
  "dev": "tsc --watch",
44
- "test": "npm run build && node --test dist/test/*.test.js"
44
+ "test": "npm run build && node --test dist/test/smoke.test.js dist/test/proxy.test.js dist/test/hook.test.js"
45
45
  },
46
46
  "dependencies": {
47
47
  "@modelcontextprotocol/sdk": "^1.0.0",