context-doctor 0.13.1 → 0.13.3
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 +2 -0
- package/dist/cursor.js +6 -1
- package/dist/doctor.js +31 -4
- package/dist/install.js +12 -2
- package/dist/mcp.js +37 -2
- package/dist/optimize.js +45 -2
- package/dist/pricing.js +3 -0
- package/dist/session.js +44 -3
- package/dist/tokens.js +8 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -383,6 +383,8 @@ npm publish # prompts for the npm 2FA code
|
|
|
383
383
|
git push --follow-tags
|
|
384
384
|
```
|
|
385
385
|
|
|
386
|
+
**What the npm download number measures.** `install` writes `npx -y context-doctor-mcp` into MCP configs, and npx re-fetches the tarball whenever a new version exists. So every release is downloaded once by every active install within about a day, and the daily count is almost entirely those refreshes: on this package, release days run ~170 downloads and non-release days ~27. Read it as "size of the active installed base × number of releases", not as new users — a quiet week with no releases will look like a decline while nothing has changed. Two corollaries: the release-day figure is a live count of machines running context-doctor, and a broken release reaches all of them automatically, which is why `prepublishOnly` runs the full test suite. npm's stats also lag by several days and occasionally record a day as zero; a zero on a release day is a gap in their pipeline, not in usage.
|
|
387
|
+
|
|
386
388
|
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.
|
|
387
389
|
|
|
388
390
|
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.
|
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
|
-
|
|
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/doctor.js
CHANGED
|
@@ -50,6 +50,23 @@ function checkMcpEntry(appName, configPath) {
|
|
|
50
50
|
return { label: appName, status: "fail", detail: `${configPath} is not valid JSON (${e.message})` };
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* For a hook command, the path that must exist for it to run — or null when it
|
|
55
|
+
* resolves through PATH (`node`, `npx`) and there is nothing to check here.
|
|
56
|
+
*
|
|
57
|
+
* Forms written by install: `node "<cli.js>" hook`, `"<binary>" hook`,
|
|
58
|
+
* `npx -y context-doctor hook`.
|
|
59
|
+
*/
|
|
60
|
+
function hookBinaryMissing(command) {
|
|
61
|
+
const quoted = [...command.matchAll(/"([^"]+)"/g)].map((m) => m[1]);
|
|
62
|
+
const first = command.trim().split(/\s+/)[0]?.replace(/^"|"$/g, "") ?? "";
|
|
63
|
+
const candidates = quoted.length > 0 ? quoted : /[\\/]/.test(first) ? [first] : [];
|
|
64
|
+
for (const path of candidates) {
|
|
65
|
+
if (!existsSync(path))
|
|
66
|
+
return path;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
53
70
|
/** Spawn our own MCP server and run the initialize handshake over stdio. */
|
|
54
71
|
function checkMcpHandshake() {
|
|
55
72
|
const label = "MCP server handshake";
|
|
@@ -96,10 +113,20 @@ export async function runDoctor() {
|
|
|
96
113
|
if (existsSync(settingsPath)) {
|
|
97
114
|
try {
|
|
98
115
|
const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
116
|
+
const entries = settings.hooks?.UserPromptSubmit ?? [];
|
|
117
|
+
const ours = entries.map((e) => e.hooks?.[0]?.command ?? "").find((c) => /context-doctor|cli\.js"?\s+hook/.test(c));
|
|
118
|
+
if (!ours) {
|
|
119
|
+
checks.push({ label: "Every-prompt hook", status: "fail", detail: "not registered — run: context-doctor install" });
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
// "Registered" is not "working": a hook whose binary has been deleted
|
|
123
|
+
// (an npx cache sweep, a Node upgrade) fails silently on every prompt,
|
|
124
|
+
// and this check used to report it as fine.
|
|
125
|
+
const missing = hookBinaryMissing(ours);
|
|
126
|
+
checks.push(missing
|
|
127
|
+
? { label: "Every-prompt hook", status: "fail", detail: `registered, but ${missing} no longer exists — re-run: context-doctor install` }
|
|
128
|
+
: { label: "Every-prompt hook", status: "ok", detail: "registered in ~/.claude/settings.json; command resolves" });
|
|
129
|
+
}
|
|
103
130
|
}
|
|
104
131
|
catch (e) {
|
|
105
132
|
checks.push({ label: "Every-prompt hook", status: "fail", detail: `settings.json unreadable (${e.message})` });
|
package/dist/install.js
CHANGED
|
@@ -89,6 +89,13 @@ function binOnPath(name) {
|
|
|
89
89
|
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
90
90
|
if (!dir)
|
|
91
91
|
continue;
|
|
92
|
+
// npx prepends its OWN cache's .bin to PATH while it runs the command. So
|
|
93
|
+
// during `npx -y context-doctor install`, the first "global binary" on
|
|
94
|
+
// PATH is inside _npx — the garbage-collected directory this lookup exists
|
|
95
|
+
// to avoid. That hole put a cache path in the hook of every user who
|
|
96
|
+
// followed the README's headline command.
|
|
97
|
+
if (isEphemeralPath(dir))
|
|
98
|
+
continue;
|
|
92
99
|
for (const ext of exts) {
|
|
93
100
|
const candidate = join(dir, name + ext);
|
|
94
101
|
if (existsSync(candidate))
|
|
@@ -97,6 +104,10 @@ function binOnPath(name) {
|
|
|
97
104
|
}
|
|
98
105
|
return null;
|
|
99
106
|
}
|
|
107
|
+
/** Paths npm may delete at any time: the npx cache and the npm cache itself. */
|
|
108
|
+
function isEphemeralPath(path) {
|
|
109
|
+
return /[\\/]_npx[\\/]/.test(path) || /[\\/]\.npm[\\/]/.test(path) || /[\\/]npm-cache[\\/]/i.test(path);
|
|
110
|
+
}
|
|
100
111
|
/**
|
|
101
112
|
* Shell command used for the Claude Code every-prompt hook.
|
|
102
113
|
*
|
|
@@ -113,8 +124,7 @@ function binOnPath(name) {
|
|
|
113
124
|
function hookCommand() {
|
|
114
125
|
const selfDir = dirname(fileURLToPath(import.meta.url));
|
|
115
126
|
const localCli = join(selfDir, "cli.js");
|
|
116
|
-
|
|
117
|
-
if (!ephemeral && existsSync(localCli))
|
|
127
|
+
if (!isEphemeralPath(selfDir + sep) && existsSync(localCli))
|
|
118
128
|
return `node "${localCli}" hook`;
|
|
119
129
|
const global = binOnPath("context-doctor");
|
|
120
130
|
if (global)
|
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.
|
|
40
|
+
const server = new McpServer({ name: "context-doctor", version: "0.13.3" }, { 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({
|
|
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
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.13.3",
|
|
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",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
},
|
|
41
41
|
"scripts": {
|
|
42
42
|
"build": "tsc && node -e \"const fs=require('fs');['dist/cli.js','dist/mcp.js'].forEach(f=>fs.chmodSync(f,0o755))\"",
|
|
43
|
-
"prepublishOnly": "npm
|
|
43
|
+
"prepublishOnly": "npm test",
|
|
44
44
|
"dev": "tsc --watch",
|
|
45
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 dist/test/accuracy.test.js dist/test/cache-stability.test.js dist/test/ledger.test.js"
|
|
46
46
|
},
|