context-doctor 0.12.0 → 0.12.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/doctor.js CHANGED
@@ -26,8 +26,10 @@ function checkMcpEntry(appName, configPath) {
26
26
  const entry = config.mcpServers?.["context-doctor"];
27
27
  if (!entry)
28
28
  return { label: appName, status: "fail", detail: `no context-doctor entry in ${configPath} — run: context-doctor install` };
29
- // Absolute-path entries must point at a file that still exists.
30
- const target = entry.command === "npx" ? null : entry.args?.[0];
29
+ // Absolute-path entries must point at a file that still exists. Launcher
30
+ // forms (npx, and Windows' cmd /c npx) resolve at spawn time, not now.
31
+ const launcher = entry.command === "npx" || entry.command === "cmd";
32
+ const target = launcher ? null : entry.args?.[0];
31
33
  if (target && !existsSync(target)) {
32
34
  return { label: appName, status: "fail", detail: `MCP entry points at missing file ${target} — re-run: context-doctor install` };
33
35
  }
@@ -41,7 +43,7 @@ function checkMcpEntry(appName, configPath) {
41
43
  detail: `MCP command ${cmd} no longer exists (a Node upgrade moves version-pinned paths) — re-run: context-doctor install`,
42
44
  };
43
45
  }
44
- return { label: appName, status: "ok", detail: `MCP wired (${entry.command === "npx" ? "npx, tracks npm releases" : "local build"})` };
46
+ return { label: appName, status: "ok", detail: `MCP wired (${launcher ? "npx, tracks npm releases" : "local build"})` };
45
47
  }
46
48
  catch (e) {
47
49
  return { label: appName, status: "fail", detail: `${configPath} is not valid JSON (${e.message})` };
package/dist/install.d.ts CHANGED
@@ -6,5 +6,19 @@
6
6
  * Every config edit is a careful JSON merge with a .backup file written first.
7
7
  * `context-doctor uninstall` reverses it.
8
8
  */
9
+ /**
10
+ * How to invoke the published package as an MCP server on a given platform.
11
+ *
12
+ * On Windows npx is `npx.cmd` — a batch script, not an executable. MCP clients
13
+ * spawn their server directly, without a shell, so a bare "npx" fails with
14
+ * ENOENT and the app simply shows no tools and no error. Hence the cmd /c
15
+ * wrapper that every working Windows MCP config uses.
16
+ *
17
+ * Exported so the platform branch is testable from any host OS.
18
+ */
19
+ export declare function npxLauncher(platformName: string): {
20
+ command: string;
21
+ args: string[];
22
+ };
9
23
  export declare function runInstall(): void;
10
24
  export declare function runUninstall(): void;
package/dist/install.js CHANGED
@@ -43,9 +43,25 @@ function serverEntry() {
43
43
  const localMcp = join(selfDir, "mcp.js");
44
44
  const fromPackage = selfDir.includes(`${sep}node_modules${sep}`) || selfDir.includes("_npx");
45
45
  if (!fromPackage && existsSync(localMcp)) {
46
+ // `node` is node.exe on Windows — directly spawnable, no shell needed.
46
47
  return { command: "node", args: [localMcp] };
47
48
  }
48
- return { command: "npx", args: ["-y", "context-doctor-mcp"] };
49
+ return npxLauncher(platform());
50
+ }
51
+ /**
52
+ * How to invoke the published package as an MCP server on a given platform.
53
+ *
54
+ * On Windows npx is `npx.cmd` — a batch script, not an executable. MCP clients
55
+ * spawn their server directly, without a shell, so a bare "npx" fails with
56
+ * ENOENT and the app simply shows no tools and no error. Hence the cmd /c
57
+ * wrapper that every working Windows MCP config uses.
58
+ *
59
+ * Exported so the platform branch is testable from any host OS.
60
+ */
61
+ export function npxLauncher(platformName) {
62
+ return platformName === "win32"
63
+ ? { command: "cmd", args: ["/c", "npx", "-y", "context-doctor-mcp"] }
64
+ : { command: "npx", args: ["-y", "context-doctor-mcp"] };
49
65
  }
50
66
  function readJson(path) {
51
67
  if (!existsSync(path))
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.12.0" }, { instructions: SERVER_INSTRUCTIONS });
40
+ const server = new McpServer({ name: "context-doctor", version: "0.12.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"),
package/dist/optimize.js CHANGED
@@ -127,6 +127,14 @@ export function optimizeConversation(input, options = {}) {
127
127
  if (!Array.isArray(messages)) {
128
128
  throw new Error("No `messages` array found in input");
129
129
  }
130
+ // Null / non-object entries occur in truncated and hand-edited files; every
131
+ // strategy below would throw on them. Drop them IN PLACE rather than working
132
+ // on a copy — prune-history splices this same array, and the returned
133
+ // conversation is the caller's original object.
134
+ for (let i = messages.length - 1; i >= 0; i--) {
135
+ if (!messages[i] || typeof messages[i] !== "object")
136
+ messages.splice(i, 1);
137
+ }
130
138
  const tokensBefore = messages.reduce((s, m) => s + estimateTokens(textOf(m.content)), 0);
131
139
  const applied = [];
132
140
  // -- strip-base64: replace inline blobs with a placeholder --------------------
package/dist/parse.js CHANGED
@@ -62,7 +62,10 @@ function flattenContent(content) {
62
62
  }
63
63
  return { text, hasBinary, toolName, kind, toolCallText: toolCallText || undefined };
64
64
  }
65
- function normalizeMessage(raw, index) {
65
+ function normalizeMessage(rawInput, index) {
66
+ // A null or non-object entry appears in truncated and hand-edited files.
67
+ // Treat it as an empty message rather than throwing a stack at the user.
68
+ const raw = rawInput && typeof rawInput === "object" ? rawInput : {};
66
69
  const role = String(raw.role ?? "user");
67
70
  const flat = flattenContent(raw.content);
68
71
  let kind = flat.kind ?? (["system", "user", "assistant"].includes(role) ? role : "other");
@@ -73,8 +76,10 @@ function normalizeMessage(raw, index) {
73
76
  if (role === "tool") {
74
77
  kind = "tool_result";
75
78
  }
76
- const toolCalls = raw.tool_calls;
77
- if (Array.isArray(toolCalls) && toolCalls.length > 0) {
79
+ const rawToolCalls = raw.tool_calls;
80
+ // Entries can be null or malformed in hand-edited or truncated exports.
81
+ const toolCalls = Array.isArray(rawToolCalls) ? rawToolCalls.filter((tc) => tc && typeof tc === "object") : undefined;
82
+ if (toolCalls && toolCalls.length > 0) {
78
83
  kind = "tool_call";
79
84
  toolName = toolCalls[0]?.function?.name ?? toolCalls[0]?.name;
80
85
  const calls = toolCalls
@@ -118,7 +123,12 @@ export function parseConversation(input) {
118
123
  };
119
124
  }
120
125
  const obj = data;
121
- const rawMessages = obj.messages ?? [];
126
+ const rawField = obj.messages;
127
+ const rawMessages = Array.isArray(rawField)
128
+ ? rawField
129
+ : [];
130
+ // `messages` present but not an array is a malformed file, not an empty chat.
131
+ const malformedMessages = rawField != null && !Array.isArray(rawField);
122
132
  const messages = [];
123
133
  // Anthropic keeps the system prompt outside the messages array.
124
134
  if (obj.system != null) {
@@ -127,9 +137,11 @@ export function parseConversation(input) {
127
137
  }
128
138
  messages.push(...rawMessages.map((m, i) => normalizeMessage(m, i)));
129
139
  const isAnthropic = obj.system != null ||
130
- rawMessages.some((m) => Array.isArray(m.content) && m.content.some((b) => b?.type === "tool_use" || b?.type === "tool_result"));
131
- const parseWarning = messages.length === 0
132
- ? "This JSON has no `messages` array (and no `system`) it does not look like a conversation. Expected {\"messages\":[{\"role\":…,\"content\":…}]}."
133
- : undefined;
140
+ rawMessages.some((m) => Array.isArray(m?.content) && m.content.some((b) => b?.type === "tool_use" || b?.type === "tool_result"));
141
+ const parseWarning = malformedMessages
142
+ ? `\`messages\` is a ${typeof rawField}, not an arraythis file is malformed.`
143
+ : messages.length === 0
144
+ ? "This JSON has no `messages` array (and no `system`) — it does not look like a conversation. Expected {\"messages\":[{\"role\":…,\"content\":…}]}."
145
+ : undefined;
134
146
  return { sourceFormat: isAnthropic ? "anthropic" : "openai", parseWarning, messages };
135
147
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-doctor",
3
- "version": "0.12.0",
3
+ "version": "0.12.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",
@@ -42,7 +42,7 @@
42
42
  "build": "tsc && node -e \"const fs=require('fs');['dist/cli.js','dist/mcp.js'].forEach(f=>fs.chmodSync(f,0o755))\"",
43
43
  "prepublishOnly": "npm run build",
44
44
  "dev": "tsc --watch",
45
- "test": "npm run build && node --test dist/test/smoke.test.js dist/test/proxy.test.js dist/test/hook.test.js dist/test/mcp-http.test.js dist/test/doctor.test.js dist/test/watch.test.js dist/test/chatgpt-export.test.js dist/test/config.test.js dist/test/dashboard.test.js dist/test/cursor.test.js dist/test/cache.test.js dist/test/session.test.js"
45
+ "test": "npm run build && node --test dist/test/smoke.test.js dist/test/proxy.test.js dist/test/proxy-abort.test.js dist/test/hook.test.js dist/test/mcp-http.test.js dist/test/doctor.test.js dist/test/watch.test.js dist/test/chatgpt-export.test.js dist/test/config.test.js dist/test/dashboard.test.js dist/test/cursor.test.js dist/test/cache.test.js dist/test/session.test.js"
46
46
  },
47
47
  "dependencies": {
48
48
  "@modelcontextprotocol/sdk": "^1.0.0",