context-doctor 0.13.1 → 0.13.2

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/dist/cursor.js CHANGED
@@ -114,7 +114,12 @@ export function listCursorChats(limit = 20) {
114
114
  rows = queryRows(dbPath, "SELECT key, json_extract(value, '$.name') AS name, " +
115
115
  "COALESCE(json_array_length(value, '$.fullConversationHeadersOnly'), " +
116
116
  "json_array_length(value, '$.conversation'), 0) AS n " +
117
- "FROM cursorDiskKV WHERE key LIKE 'composerData:%'");
117
+ // json_valid is not optional: SQLite's JSON functions raise on
118
+ // malformed input, and one non-JSON row under a composerData: key
119
+ // aborts the WHOLE query. cursorDiskKV is a general-purpose store,
120
+ // so that row exists sooner or later — and the user then sees
121
+ // "No Cursor chats found" with every real chat sitting right there.
122
+ "FROM cursorDiskKV WHERE key LIKE 'composerData:%' AND json_valid(value)");
118
123
  }
119
124
  catch {
120
125
  continue; // no composer table (older Cursor) or no SQLite — skip
package/dist/mcp.js CHANGED
@@ -37,7 +37,7 @@ const STRATEGY_IDS = ["dedupe", "trim-tool-results", "trim-tool-calls", "strip-b
37
37
  * recommended pattern.
38
38
  */
39
39
  function createServer() {
40
- const server = new McpServer({ name: "context-doctor", version: "0.13.1" }, { instructions: SERVER_INSTRUCTIONS });
40
+ const server = new McpServer({ name: "context-doctor", version: "0.13.2" }, { 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"),
@@ -120,6 +120,24 @@ const BEST_PRACTICES = {
120
120
  "Use max_completion_tokens headroom math: input + output must fit the window together.",
121
121
  ],
122
122
  };
123
+ /**
124
+ * Set a header on a Node request so every downstream reader sees it.
125
+ *
126
+ * `req.headers` is a parsed convenience copy; the MCP transport reconstructs a
127
+ * Web Request from `req.rawHeaders`, so a header written to only one of them is
128
+ * invisible to the other.
129
+ */
130
+ function setHeader(req, name, value) {
131
+ req.headers[name] = value;
132
+ const raw = req.rawHeaders;
133
+ for (let i = 0; i < raw.length; i += 2) {
134
+ if (raw[i].toLowerCase() === name) {
135
+ raw[i + 1] = value;
136
+ return;
137
+ }
138
+ }
139
+ raw.push(name, value);
140
+ }
123
141
  // -- Transport dispatch --------------------------------------------------------
124
142
  // Default: stdio (Claude Desktop, Claude Code, Cursor spawn us as a child).
125
143
  // --http [--port N] [--host H]: streamable-HTTP endpoint at /mcp for clients
@@ -154,9 +172,26 @@ if (argv.includes("--http")) {
154
172
  res.end(JSON.stringify({ error: "Stateless server: POST /mcp only" }));
155
173
  return;
156
174
  }
175
+ // The streamable-HTTP spec says a client MUST accept both
176
+ // application/json and text/event-stream, and the SDK answers anything
177
+ // else with a 406. Plenty of real callers send only application/json, or
178
+ // `*/*`, or no Accept at all — and to them a 406 looks like the server
179
+ // being broken. Our replies are single JSON-RPC responses with nothing to
180
+ // stream, so those clients get a plain JSON body instead of a refusal.
181
+ const accept = String(req.headers.accept ?? "");
182
+ const askedForSse = accept.includes("text/event-stream");
183
+ if (!askedForSse || !accept.includes("application/json")) {
184
+ // The transport rebuilds the request from rawHeaders (via Hono), so
185
+ // setting req.headers alone changes nothing it will ever look at.
186
+ setHeader(req, "accept", "application/json, text/event-stream");
187
+ }
157
188
  // Fresh server + transport per request (stateless — nothing shared).
158
189
  const server = createServer();
159
- const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
190
+ const transport = new StreamableHTTPServerTransport({
191
+ sessionIdGenerator: undefined,
192
+ // A client that never asked for a stream gets plain JSON back.
193
+ enableJsonResponse: !askedForSse,
194
+ });
160
195
  res.on("close", () => {
161
196
  void transport.close();
162
197
  void server.close();
package/dist/optimize.js CHANGED
@@ -129,6 +129,34 @@ function truncateToTokens(text, maxTokens) {
129
129
  const omitted = text.length - approxChars;
130
130
  return `${head}\n…[context-doctor: trimmed ${omitted} chars of stale tool output]`;
131
131
  }
132
+ /**
133
+ * Remove tool_result blocks whose matching tool_use is not in the same slice.
134
+ *
135
+ * Anthropic and OpenAI both reject a conversation where a tool result refers to
136
+ * a call that is not present, so anything that drops earlier turns has to clean
137
+ * up after itself. A message emptied by this keeps a short note rather than
138
+ * becoming an empty content array, which is also rejected.
139
+ */
140
+ function dropOrphanedToolResults(kept) {
141
+ const availableCalls = new Set();
142
+ for (const m of kept) {
143
+ if (!Array.isArray(m?.content))
144
+ continue;
145
+ for (const b of m.content)
146
+ if (b?.type === "tool_use" && b.id)
147
+ availableCalls.add(b.id);
148
+ }
149
+ for (const m of kept) {
150
+ if (!Array.isArray(m?.content))
151
+ continue;
152
+ const surviving = m.content.filter((b) => b?.type !== "tool_result" || (b.tool_use_id && availableCalls.has(b.tool_use_id)));
153
+ if (surviving.length === m.content.length)
154
+ continue;
155
+ m.content = surviving.length > 0
156
+ ? surviving
157
+ : [{ type: "text", text: "[context-doctor: earlier tool result dropped with the pruned history]" }];
158
+ }
159
+ }
132
160
  function isToolResultMessage(m) {
133
161
  if (m?.role === "tool")
134
162
  return true;
@@ -222,6 +250,11 @@ export function optimizeConversation(input, options = {}) {
222
250
  if (before <= opts.maxToolResultTokens)
223
251
  return;
224
252
  const trimmed = truncateToTokens(text, opts.maxToolResultTokens);
253
+ // The truncation notice has a length of its own, so a result only just
254
+ // over the budget can come back LARGER than it went in. Measured on a
255
+ // real session: 2,941 tokens "optimized" to 2,947.
256
+ if (estimateTokens(trimmed) >= before)
257
+ return;
225
258
  m.content = replaceText(m.content, trimmed);
226
259
  applied.push({
227
260
  strategy: "trim-tool-results",
@@ -246,8 +279,13 @@ export function optimizeConversation(input, options = {}) {
246
279
  const before = estimateTokens(JSON.stringify(b.input));
247
280
  if (before <= opts.maxToolResultTokens)
248
281
  continue;
249
- b.input = trimCallArguments(b.input, opts.maxToolResultTokens);
250
- saved += before - estimateTokens(JSON.stringify(b.input));
282
+ const trimmedInput = trimCallArguments(b.input, opts.maxToolResultTokens);
283
+ // Same trap as tool results: the marker can outweigh what it replaces.
284
+ const after = estimateTokens(JSON.stringify(trimmedInput));
285
+ if (after >= before)
286
+ continue;
287
+ b.input = trimmedInput;
288
+ saved += before - after;
251
289
  }
252
290
  }
253
291
  // OpenAI shape: tool_calls[].function.arguments is a JSON string.
@@ -285,6 +323,11 @@ export function optimizeConversation(input, options = {}) {
285
323
  // Boundary adjustment may leave too little tail to be worth keeping —
286
324
  // in that case skip pruning entirely rather than gutting the conversation.
287
325
  if (messages.length - keepFrom >= 2) {
326
+ // Advancing past LEADING tool results is not enough: a tool_result can
327
+ // sit deeper in the kept tail while its tool_use was pruned, and both
328
+ // APIs reject a conversation containing an orphan. Measured on a real
329
+ // 1,011-message session, which pruned to 7 messages with one orphan.
330
+ dropOrphanedToolResults(messages.slice(keepFrom));
288
331
  const pruned = messages.slice(0, keepFrom);
289
332
  const prunedTokens = pruned.reduce((s, m) => s + estimateTokens(textOf(m.content)), 0);
290
333
  // Digest: first ~200 chars of each pruned turn — enough for a host LLM to
package/dist/pricing.js CHANGED
@@ -42,6 +42,9 @@ export function estimatedTtftSeconds(inputTokens) {
42
42
  return inputTokens / 25_000;
43
43
  }
44
44
  export function formatUsd(amount) {
45
+ // Same reasoning as formatTokens: "$NaN" is worse than "$0.00".
46
+ if (!Number.isFinite(amount))
47
+ return "$0.00";
45
48
  if (amount >= 1)
46
49
  return `$${amount.toFixed(2)}`;
47
50
  if (amount >= 0.01)
package/dist/session.js CHANGED
@@ -12,6 +12,17 @@ import { readdirSync, readFileSync, statSync, existsSync, openSync, readSync, cl
12
12
  import { StringDecoder } from "node:string_decoder";
13
13
  import { homedir } from "node:os";
14
14
  import { join } from "node:path";
15
+ /**
16
+ * Read one usage field defensively.
17
+ *
18
+ * A numeric string plainly means that number, and discarding it would throw
19
+ * away ground truth and silently fall back to the heuristic — so it is parsed.
20
+ * Anything else unusable (objects, null, "abc", negatives) counts as nothing.
21
+ */
22
+ function usageNumber(value) {
23
+ const n = typeof value === "number" ? value : typeof value === "string" && value.trim() !== "" ? Number(value) : Number.NaN;
24
+ return Number.isFinite(n) && n >= 0 ? Math.round(n) : 0;
25
+ }
15
26
  function projectsDir() {
16
27
  return join(homedir(), ".claude", "projects");
17
28
  }
@@ -45,6 +56,21 @@ export function listSessions(limit = 20) {
45
56
  * conversations.json is an array of conversations, each holding a `mapping`
46
57
  * tree of nodes. We profile the most recently updated conversation.
47
58
  */
59
+ /**
60
+ * A stand-in for a non-text export part.
61
+ *
62
+ * The bytes are not in the export, so the exact token cost is unknowable; what
63
+ * matters is that the turn stops being invisible and keeps its place in the
64
+ * conversation.
65
+ */
66
+ function chatGptAttachmentLabel(part) {
67
+ const kind = part?.content_type;
68
+ if (typeof kind === "string")
69
+ return `[${kind}]`;
70
+ if (part?.asset_pointer)
71
+ return "[image]";
72
+ return "[attachment]";
73
+ }
48
74
  function parseChatGPTExport(data, path) {
49
75
  const conversations = data
50
76
  .filter((c) => c && typeof c.mapping === "object")
@@ -58,12 +84,21 @@ function parseChatGPTExport(data, path) {
58
84
  if (!m?.author?.role || !["user", "assistant", "system"].includes(m.author.role))
59
85
  return false;
60
86
  const parts = m.content?.parts;
61
- return Array.isArray(parts) && parts.some((p) => typeof p === "string" && p.length > 0);
87
+ if (!Array.isArray(parts))
88
+ return false;
89
+ // A turn containing an image is exported as multimodal_text, with the
90
+ // picture as an object among the string parts. Requiring a non-empty
91
+ // string dropped those turns entirely, so an image-heavy conversation
92
+ // profiled as smaller than it is.
93
+ return parts.some((p) => (typeof p === "string" && p.length > 0) || (p && typeof p === "object"));
62
94
  })
63
95
  .sort((a, b) => (a.message.create_time ?? 0) - (b.message.create_time ?? 0));
64
96
  const messages = nodes.map((n) => ({
65
97
  role: n.message.author.role,
66
- content: n.message.content.parts.filter((p) => typeof p === "string").join("\n"),
98
+ content: n.message.content.parts
99
+ .map((p) => (typeof p === "string" ? p : chatGptAttachmentLabel(p)))
100
+ .filter((p) => p.length > 0)
101
+ .join("\n"),
67
102
  }));
68
103
  return {
69
104
  conversationJson: JSON.stringify({ messages }),
@@ -170,7 +205,13 @@ export function parseSessionFile(path) {
170
205
  model = message.model;
171
206
  const usage = message.usage;
172
207
  if (entry.type === "assistant" && usage) {
173
- const total = (usage.input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0);
208
+ // Coerce, do not trust: a transcript whose usage numbers are STRINGS
209
+ // turned `1200 + 300` into "12003000" through JavaScript concatenation,
210
+ // an 8000x overstatement that drives the hook, the cost figures and the
211
+ // window percentage. Anything not a finite non-negative number is 0.
212
+ const total = usageNumber(usage.input_tokens) +
213
+ usageNumber(usage.cache_read_input_tokens) +
214
+ usageNumber(usage.cache_creation_input_tokens);
174
215
  if (total > 0) {
175
216
  reportedInputTokens = total;
176
217
  usageSamples.push({ index: messages.length, input: total });
package/dist/tokens.js CHANGED
@@ -50,6 +50,10 @@ function symbolDensity(text) {
50
50
  return (symbols?.length ?? 0) / text.length;
51
51
  }
52
52
  export function estimateTokens(text) {
53
+ // Public API: callers outside this package pass whatever they have, and a
54
+ // TypeError from a token estimator is never the useful answer.
55
+ if (typeof text !== "string")
56
+ text = String(text ?? "");
53
57
  if (!text)
54
58
  return 0;
55
59
  // Denser tokenization for code/JSON-like content, lighter for plain prose.
@@ -60,6 +64,10 @@ export function estimateTokens(text) {
60
64
  /** Per-message structural overhead (role markers, delimiters) is roughly constant. */
61
65
  export const MESSAGE_OVERHEAD_TOKENS = 4;
62
66
  export function formatTokens(n) {
67
+ // A NaN reaching a report renders literally as "NaN tokens"; show nothing
68
+ // rather than something false.
69
+ if (!Number.isFinite(n))
70
+ return "0";
63
71
  if (n >= 1_000_000)
64
72
  return `${(n / 1_000_000).toFixed(1)}M`;
65
73
  if (n >= 10_000)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-doctor",
3
- "version": "0.13.1",
3
+ "version": "0.13.2",
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",