context-doctor 0.2.0 → 0.3.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 +24 -1
- package/dist/cli.js +12 -0
- package/dist/hook.d.ts +13 -0
- package/dist/hook.js +85 -0
- package/dist/install.js +51 -0
- package/dist/mcp.js +27 -5
- package/dist/optimize.js +30 -20
- package/dist/pricing.js +2 -0
- package/dist/proxy.d.ts +6 -0
- package/dist/proxy.js +7 -3
- package/dist/test/hook.test.d.ts +5 -0
- package/dist/test/hook.test.js +54 -0
- package/dist/test/smoke.test.js +22 -0
- package/dist/tokens.js +1 -0
- package/package.json +3 -3
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,
|
|
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
|
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
|
-
|
|
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.0" }, { 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
|
-
|
|
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
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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,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
|
+
});
|
package/dist/test/smoke.test.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Profile and optimize LLM context windows. See what's eating your tokens and fix it — works with Claude, GPT, Gemini, and any MCP-capable AI app.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"llm",
|
|
@@ -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
|
|
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",
|