context-doctor 0.2.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.
@@ -0,0 +1,73 @@
1
+ /** Smoke tests: parse → profile → optimize roundtrip for both provider formats. */
2
+ import { test } from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import { parseConversation } from "../parse.js";
5
+ import { profileConversation } from "../profile.js";
6
+ import { optimizeConversation } from "../optimize.js";
7
+ // Large enough to clear the profiler's 2000-token oversized-tool-result threshold.
8
+ const bigText = "Sunny, 18C. ".repeat(900);
9
+ const openaiConv = JSON.stringify({
10
+ messages: [
11
+ { role: "system", content: "You are helpful." },
12
+ { role: "user", content: "weather in SF?" },
13
+ { role: "assistant", content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "get_weather", arguments: '{"city":"SF"}' } }] },
14
+ { role: "tool", tool_call_id: "c1", content: bigText },
15
+ { role: "assistant", content: "It is sunny and 18C in SF." },
16
+ ],
17
+ });
18
+ const anthropicConv = JSON.stringify({
19
+ system: "You are helpful.",
20
+ messages: [
21
+ { role: "user", content: "weather in SF?" },
22
+ { role: "assistant", content: [{ type: "tool_use", id: "t1", name: "get_weather", input: { city: "SF" } }] },
23
+ { role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: bigText }] },
24
+ { role: "assistant", content: "Sunny and 18C." },
25
+ ],
26
+ });
27
+ test("parses OpenAI format and classifies tool plumbing", () => {
28
+ const conv = parseConversation(openaiConv);
29
+ assert.equal(conv.sourceFormat, "openai");
30
+ assert.equal(conv.messages.filter((m) => m.kind === "tool_result").length, 1);
31
+ assert.equal(conv.messages.filter((m) => m.kind === "tool_call").length, 1);
32
+ });
33
+ test("parses Anthropic format including external system prompt", () => {
34
+ const conv = parseConversation(anthropicConv);
35
+ assert.equal(conv.sourceFormat, "anthropic");
36
+ assert.equal(conv.messages[0].kind, "system");
37
+ assert.ok(conv.messages.some((m) => m.toolName === "get_weather"));
38
+ });
39
+ test("profiler flags oversized tool results", () => {
40
+ const profile = profileConversation(parseConversation(openaiConv), "gpt-4o");
41
+ assert.ok(profile.totalTokens > 500);
42
+ assert.equal(profile.contextWindow, 128_000);
43
+ assert.ok(profile.findings.some((f) => f.id === "large_tool_result"));
44
+ });
45
+ test("optimizer trims stale tool results and reports savings", () => {
46
+ const result = optimizeConversation(openaiConv, { keepRecent: 1, maxToolResultTokens: 50 });
47
+ assert.ok(result.tokensAfter < result.tokensBefore);
48
+ assert.ok(result.applied.some((c) => c.strategy === "trim-tool-results"));
49
+ // Output must remain valid JSON with the same message count.
50
+ const out = result.conversation;
51
+ assert.equal(out.messages.length, 5);
52
+ });
53
+ test("optimizer output for Anthropic format keeps block structure valid", () => {
54
+ const result = optimizeConversation(anthropicConv, { keepRecent: 1, maxToolResultTokens: 50 });
55
+ const out = result.conversation;
56
+ for (const m of out.messages) {
57
+ assert.ok(typeof m.content === "string" || Array.isArray(m.content));
58
+ }
59
+ // The trimmed tool_result must KEEP its block type and tool_use_id — the
60
+ // Anthropic API rejects a tool_use with no matching tool_result.
61
+ const toolResultMsg = out.messages[2].content;
62
+ assert.equal(toolResultMsg[0].type, "tool_result");
63
+ assert.equal(toolResultMsg[0].tool_use_id, "t1");
64
+ assert.ok(toolResultMsg[0].content.length < 1000, "tool_result content was trimmed");
65
+ // And the tool_use block on the assistant side is untouched.
66
+ const assistantMsg = out.messages[1].content;
67
+ assert.equal(assistantMsg[0].type, "tool_use");
68
+ });
69
+ test("raw text input still profiles", () => {
70
+ const profile = profileConversation(parseConversation("just some prompt text"));
71
+ assert.equal(profile.messageCount, 1);
72
+ assert.ok(profile.totalTokens > 0);
73
+ });
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Token estimation without provider tokenizer dependencies.
3
+ *
4
+ * Exact token counts require each provider's tokenizer (tiktoken, Anthropic's
5
+ * API, etc.). For profiling purposes an estimate within ~10% is enough to rank
6
+ * what's eating the window, so we use a calibrated chars-per-token heuristic:
7
+ * prose averages ~4 chars/token, code and JSON are denser (~3.2), and
8
+ * whitespace-heavy content is cheaper. This keeps the tool zero-config and
9
+ * fully offline.
10
+ */
11
+ export type Provider = "anthropic" | "openai" | "google" | "generic";
12
+ export declare function contextWindowFor(model?: string): number | undefined;
13
+ export declare function providerFor(model?: string): Provider;
14
+ export declare function estimateTokens(text: string): number;
15
+ /** Per-message structural overhead (role markers, delimiters) is roughly constant. */
16
+ export declare const MESSAGE_OVERHEAD_TOKENS = 4;
17
+ export declare function formatTokens(n: number): string;
package/dist/tokens.js ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Token estimation without provider tokenizer dependencies.
3
+ *
4
+ * Exact token counts require each provider's tokenizer (tiktoken, Anthropic's
5
+ * API, etc.). For profiling purposes an estimate within ~10% is enough to rank
6
+ * what's eating the window, so we use a calibrated chars-per-token heuristic:
7
+ * prose averages ~4 chars/token, code and JSON are denser (~3.2), and
8
+ * whitespace-heavy content is cheaper. This keeps the tool zero-config and
9
+ * fully offline.
10
+ */
11
+ /** Known context window sizes (tokens) by model-name substring, checked in order. */
12
+ const MODEL_WINDOWS = [
13
+ [/claude.*haiku/i, 200_000],
14
+ // Current-generation Claude (Fable/Mythos 5, Opus 4.6+, Sonnet 4.6+) is 1M.
15
+ [/claude.*(fable|mythos)|claude.*opus-?(5|4-[678])|claude.*sonnet-?(5|4-6)/i, 1_000_000],
16
+ [/claude.*sonnet|claude.*opus|claude-\d/i, 200_000],
17
+ [/gpt-4o|gpt-4-turbo|gpt-4\.1|o[134](-|$)/i, 128_000],
18
+ [/gpt-4(?!o|\.|-turbo)/i, 8_192],
19
+ [/gpt-3\.5/i, 16_385],
20
+ [/gemini.*(1\.5|2\.|2-5)/i, 1_000_000],
21
+ [/llama.*3/i, 128_000],
22
+ [/mistral|mixtral/i, 32_000],
23
+ ];
24
+ export function contextWindowFor(model) {
25
+ if (!model)
26
+ return undefined;
27
+ for (const [pattern, window] of MODEL_WINDOWS) {
28
+ if (pattern.test(model))
29
+ return window;
30
+ }
31
+ return undefined;
32
+ }
33
+ export function providerFor(model) {
34
+ if (!model)
35
+ return "generic";
36
+ if (/claude/i.test(model))
37
+ return "anthropic";
38
+ if (/gpt|^o\d/i.test(model))
39
+ return "openai";
40
+ if (/gemini/i.test(model))
41
+ return "google";
42
+ return "generic";
43
+ }
44
+ /** Fraction of characters that are code-ish symbols — used to pick density. */
45
+ function symbolDensity(text) {
46
+ if (text.length === 0)
47
+ return 0;
48
+ const symbols = text.match(/[{}[\]()<>;:=_\/\\|"'`#$%&*+^~-]/g);
49
+ return (symbols?.length ?? 0) / text.length;
50
+ }
51
+ export function estimateTokens(text) {
52
+ if (!text)
53
+ return 0;
54
+ // Denser tokenization for code/JSON-like content, lighter for plain prose.
55
+ const density = symbolDensity(text);
56
+ const charsPerToken = density > 0.08 ? 3.2 : 4.0;
57
+ return Math.ceil(text.length / charsPerToken);
58
+ }
59
+ /** Per-message structural overhead (role markers, delimiters) is roughly constant. */
60
+ export const MESSAGE_OVERHEAD_TOKENS = 4;
61
+ export function formatTokens(n) {
62
+ if (n >= 1_000_000)
63
+ return `${(n / 1_000_000).toFixed(1)}M`;
64
+ if (n >= 10_000)
65
+ return `${Math.round(n / 1000)}k`;
66
+ if (n >= 1_000)
67
+ return `${(n / 1000).toFixed(1)}k`;
68
+ return String(n);
69
+ }
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "context-doctor",
3
+ "version": "0.2.0",
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
+ "keywords": [
6
+ "llm",
7
+ "context-window",
8
+ "tokens",
9
+ "prompt-engineering",
10
+ "mcp",
11
+ "model-context-protocol",
12
+ "claude",
13
+ "openai",
14
+ "context-management",
15
+ "prompt-caching"
16
+ ],
17
+ "license": "MIT",
18
+ "author": "Kushal P",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/KushalP1/context-doctor.git"
22
+ },
23
+ "type": "module",
24
+ "main": "./dist/index.js",
25
+ "types": "./dist/index.d.ts",
26
+ "bin": {
27
+ "context-doctor": "dist/cli.js",
28
+ "ctx-doctor": "dist/cli.js",
29
+ "context-doctor-mcp": "dist/mcp.js"
30
+ },
31
+ "files": [
32
+ "dist",
33
+ "skills",
34
+ "README.md",
35
+ "LICENSE"
36
+ ],
37
+ "engines": {
38
+ "node": ">=18"
39
+ },
40
+ "scripts": {
41
+ "build": "tsc",
42
+ "prepublishOnly": "npm run build",
43
+ "dev": "tsc --watch",
44
+ "test": "npm run build && node --test dist/test/*.test.js"
45
+ },
46
+ "dependencies": {
47
+ "@modelcontextprotocol/sdk": "^1.0.0",
48
+ "zod": "^3.23.0"
49
+ },
50
+ "devDependencies": {
51
+ "@types/node": "^22.0.0",
52
+ "typescript": "^5.5.0"
53
+ }
54
+ }
@@ -0,0 +1,39 @@
1
+ ---
2
+ name: context-doctor
3
+ description: Keep the LLM context window lean and fast. Use when the conversation grows long, large content is pasted or produced by tools, the user asks "what's eating my context/tokens", asks to reduce cost or latency of LLM calls, or asks to profile/optimize/compact a conversation. Also triggers on "context doctor", "token usage", "context window", "prompt caching".
4
+ ---
5
+
6
+ # Context Doctor — context hygiene for this session and the user's LLM apps
7
+
8
+ You have two jobs when this skill triggers: (1) keep **this session's** context lean, and (2) help the user profile and optimize **their own** LLM conversations and apps using the context-doctor toolkit.
9
+
10
+ ## Principles of a healthy context window
11
+
12
+ 1. **Tool results are the #1 context killer.** After using a large tool result, carry forward only the extracted facts; never re-quote large outputs. If a tool returns >2k tokens and you need <10% of it, summarize the relevant part immediately in one sentence.
13
+ 2. **Never duplicate.** If a document, file, or result already appears earlier in the conversation, reference it ("the pricing doc from earlier") instead of re-pasting it.
14
+ 3. **Stable first, volatile last.** Prompt caches match byte-identical prefixes. Anything reusable (system prompts, tool definitions, reference docs) belongs before anything per-request. One changed byte early invalidates the cache for everything after it — this alone can be a 10x cost difference.
15
+ 4. **No base64 in text.** Images and files go through file/image APIs, never inline as base64 text.
16
+ 5. **Summarize old history.** Past ~30-40 turns, models lose the middle of the context. Proactively offer to compact: write a tight summary of the older turns, keep the recent ones verbatim.
17
+ 6. **Fewer input tokens = faster responses.** Time-to-first-token scales with input size. Every token trimmed is latency and money saved on EVERY subsequent call.
18
+
19
+ ## When the conversation you are in grows long
20
+
21
+ Proactively (do not wait to be asked):
22
+ - Summarize large tool outputs right after consuming them.
23
+ - When the session passes roughly 30 turns or contains several large pastes, offer: "This conversation is getting heavy — want me to compact the older history into a summary so responses stay fast and cheap?"
24
+ - When asked to summarize/compact, produce a dense factual summary (decisions made, current state, open items, key identifiers) — not a narrative.
25
+
26
+ ## Using the context-doctor tools
27
+
28
+ If the `context-doctor` MCP tools are available:
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.
31
+ - `context_best_practices` — provider-specific checklist to share with the user.
32
+
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.
34
+
35
+ ## When the user pastes a conversation or asks about their token usage
36
+
37
+ 1. Run `profile_context` (or suggest the CLI) rather than eyeballing.
38
+ 2. Lead with the top finding and its dollar/latency impact, not the full dump.
39
+ 3. Offer to apply the safe fixes via `optimize_context`; only suggest `prune-history` when the user confirms losing old detail is acceptable (and then write the replacement summary yourself).