context-doctor 0.13.3 → 0.13.5

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 CHANGED
@@ -34,6 +34,8 @@ Findings (4)
34
34
  npx context-doctor install
35
35
  ```
36
36
 
37
+ `install` configures every app it detects and does not stop at the first problem: a corrupt Claude Desktop config still gets you Claude Code and Cursor. It does not pretend either. Any target that failed is named with a ✗ line, the summary reads "Done with N problem(s)" instead of "Done.", and the **exit code is 1**, so dotfiles and onboarding scripts can react. A broken config file is never overwritten; fix it and re-run.
38
+
37
39
  That single command is also all it takes to **set up context-doctor on anyone else's machine**. Prefer a global install, or want the unreleased `main`? Both work (Node 20+):
38
40
 
39
41
  ```bash
@@ -90,7 +92,7 @@ Practical upshot: a developer who only wants cheaper, faster API calls never tou
90
92
  | `context-doctor install` / `uninstall` | Wire (or remove) everything: MCP for Claude Desktop/Code/Cursor, the Agent Skill, the every-prompt hook |
91
93
  | `context-doctor analyze <file>` | Profile a conversation: token breakdown, findings, cost + latency estimates. `--fail-over-budget` exits 1 on a breach, for CI |
92
94
  | `context-doctor optimize <file>` | Apply the safe fixes; add `--strategy trim-tool-calls` for big inline file writes, `--strategy prune-history` for consented lossy compaction |
93
- | `context-doctor session [file]` | Profile a Claude Code session: live context, findings, **measured tokens and prompt-cache economics**. Also reads ChatGPT data exports (`conversations.json`) |
95
+ | `context-doctor session [file]` | Profile a Claude Code session: live context, findings, **measured tokens and prompt-cache economics**, and **where the wall clock went** per tool (from transcript timestamps, permission waits included and said so). Also reads ChatGPT data exports (`conversations.json`) |
94
96
  | `context-doctor init [preset]` | Write a `.contextdoctorrc` from a preset (`chat`, `agent`, `batch`) — a budget you can adopt in one command and tune later |
95
97
  | `context-doctor diff <before> <after>` | Compare two profiles: what moved by category, which findings were resolved or introduced, and what it saves in money and latency |
96
98
  | `context-doctor accuracy` | How much of what you are billed for is visible in your transcript — the fixed harness baseline and the per-turn injected content neither you nor the profiler can see |
@@ -252,7 +254,7 @@ const { conversation, tokensBefore, tokensAfter } = optimizeConversation(chatJso
252
254
  - **Near-duplicates** — the same doc re-pasted with different surrounding words (shingle similarity, ≥60%)
253
255
  - **Repeated file reads** — the same file pulled in three or more times, every copy still in context. Counts shell reads too (`cat`, `head`, `tail`, `less`), which is where most of them hide in agent sessions
254
256
  - **Retained error output** — stack traces and failed commands kept verbatim long after the fix landed
255
- - **Repeated identical tool calls** — a signal your agent forgot earlier results
257
+ - **Repeated identical tool calls**, split into the two things they can mean: a **retry** (the same call after a failure, where the fix is in the error text, and three or more is a loop) and a **re-read** (the same call after a success, where the model forgot it already had the answer). Across 42 local sessions that was 15 retries against 151 re-reads, so the old combined advice was wrong for most of them
256
258
  - **Base64 / binary blobs** in text content — checked by character distribution, not just alphabet, so hex digests and long identifiers are not mistaken for encoded binary
257
259
  - **Long history** past the point where models track the middle
258
260
  - **Cache-hostile ordering** — volatile content before stable content breaks prompt caching (Anthropic `cache_control`, OpenAI automatic prefix caching)
package/dist/cli.js CHANGED
@@ -31,6 +31,7 @@ import { checkBudget, loadConfig } from "./config.js";
31
31
  import { startDashboard } from "./dashboard.js";
32
32
  import { listCursorChats, parseCursorChat } from "./cursor.js";
33
33
  import { analyzeCacheUsage, renderCacheReport } from "./cache.js";
34
+ import { renderToolTimings } from "./timing.js";
34
35
  const HELP = `context-doctor — profile and optimize LLM context windows
35
36
 
36
37
  Usage:
@@ -333,7 +334,7 @@ function main() {
333
334
  }
334
335
  const profile = profileConversation(parseConversation(parsed.conversationJson), args.model ?? parsed.model);
335
336
  if (args.json) {
336
- console.log(JSON.stringify({ session: { path: parsed.path, title: parsed.title }, profile }, null, 2));
337
+ console.log(JSON.stringify({ session: { path: parsed.path, title: parsed.title, toolTimings: parsed.toolTimings ?? [] }, profile }, null, 2));
337
338
  }
338
339
  else {
339
340
  console.log(`Session: ${parsed.title ?? "(untitled)"}\nFile: ${parsed.path}`);
@@ -354,12 +355,20 @@ function main() {
354
355
  console.log("");
355
356
  console.log(cache);
356
357
  }
358
+ const timing = renderToolTimings(parsed.toolTimings ?? []);
359
+ if (timing) {
360
+ console.log("");
361
+ console.log(timing);
362
+ }
357
363
  applyBudgetGate(printBudgetStatus(profile, loadConfig(process.cwd(), (m) => console.error(`context-doctor: ${m}`))), args.failOverBudget);
358
364
  }
359
365
  return;
360
366
  }
361
367
  if (args.command === "install") {
362
- runInstall();
368
+ // Partial success is still installed, but not silent: any failed target
369
+ // makes the exit code non-zero so automation can react.
370
+ if (runInstall().failures.length > 0)
371
+ process.exitCode = 1;
363
372
  return;
364
373
  }
365
374
  if (args.command === "uninstall") {
package/dist/install.d.ts CHANGED
@@ -20,5 +20,20 @@ export declare function npxLauncher(platformName: string): {
20
20
  command: string;
21
21
  args: string[];
22
22
  };
23
- export declare function runInstall(): void;
23
+ /** Outcome of an install run, so the CLI can set a truthful exit code. */
24
+ export interface InstallResult {
25
+ /** Detected targets that could not be configured, with the reason. */
26
+ failures: string[];
27
+ }
28
+ /**
29
+ * Install into every detected app.
30
+ *
31
+ * A failure in one app must not stop the others: someone with a corrupt
32
+ * Claude Desktop config still wants Claude Code and Cursor wired. But it
33
+ * must not be reported as success either — automation (dotfiles, CI,
34
+ * onboarding scripts) reads the exit code, and a "Done." with exit 0 over a
35
+ * failed target is a lie that surfaces later as "the tools never showed up".
36
+ * So: keep going, summarize, and return the failures for a non-zero exit.
37
+ */
38
+ export declare function runInstall(): InstallResult;
24
39
  export declare function runUninstall(): void;
package/dist/install.js CHANGED
@@ -214,6 +214,16 @@ function installSkill() {
214
214
  copyFileSync(skillSource, join(skillDest, "SKILL.md"));
215
215
  return join(skillDest, "SKILL.md");
216
216
  }
217
+ /**
218
+ * Install into every detected app.
219
+ *
220
+ * A failure in one app must not stop the others: someone with a corrupt
221
+ * Claude Desktop config still wants Claude Code and Cursor wired. But it
222
+ * must not be reported as success either — automation (dotfiles, CI,
223
+ * onboarding scripts) reads the exit code, and a "Done." with exit 0 over a
224
+ * failed target is a lie that surfaces later as "the tools never showed up".
225
+ * So: keep going, summarize, and return the failures for a non-zero exit.
226
+ */
217
227
  export function runInstall() {
218
228
  const entry = serverEntry();
219
229
  const found = targets().filter((t) => t.detect());
@@ -221,8 +231,9 @@ export function runInstall() {
221
231
  console.log("No supported AI apps detected (Claude Desktop, Claude Code, Cursor).");
222
232
  console.log("Manual setup — add to your app's MCP config:");
223
233
  console.log(JSON.stringify({ mcpServers: { "context-doctor": entry } }, null, 2));
224
- return;
234
+ return { failures: [] };
225
235
  }
236
+ const failures = [];
226
237
  for (const target of found) {
227
238
  try {
228
239
  const config = readJson(target.configPath);
@@ -234,22 +245,44 @@ export function runInstall() {
234
245
  }
235
246
  catch (e) {
236
247
  console.error(`✗ ${target.name}: ${e.message}`);
248
+ failures.push(target.name);
237
249
  }
238
250
  }
239
- const skillPath = installSkill();
240
- if (skillPath)
241
- console.log(`✓ Agent Skill installed for Claude Code (${skillPath})`);
242
- const hookPath = installHook();
243
- if (hookPath) {
244
- console.log(`✓ Claude Code every-prompt hook installed (${hookPath}) — heavy sessions get automatic hygiene guidance`);
245
- // npx resolves the package on every single prompt; a global install makes
246
- // the hook a plain exec instead, which is both faster and update-proof.
247
- if (hookUsesNpx()) {
248
- console.log(" note: the hook falls back to npx. For a faster, permanent hook: npm i -g context-doctor && context-doctor install");
251
+ try {
252
+ const skillPath = installSkill();
253
+ if (skillPath)
254
+ console.log(`✓ Agent Skill installed for Claude Code (${skillPath})`);
255
+ }
256
+ catch (e) {
257
+ console.error(`✗ Agent Skill: ${e.message}`);
258
+ failures.push("Agent Skill");
259
+ }
260
+ try {
261
+ const hookPath = installHook();
262
+ if (hookPath) {
263
+ console.log(`✓ Claude Code every-prompt hook installed (${hookPath}) — heavy sessions get automatic hygiene guidance`);
264
+ // npx resolves the package on every single prompt; a global install makes
265
+ // the hook a plain exec instead, which is both faster and update-proof.
266
+ if (hookUsesNpx()) {
267
+ console.log(" note: the hook falls back to npx. For a faster, permanent hook: npm i -g context-doctor && context-doctor install");
268
+ }
249
269
  }
250
270
  }
251
- console.log("\nDone. Restart the apps to pick up the new tools, then try:");
252
- console.log(' "What\'s eating my context?" — or paste a conversation and ask for a profile.');
271
+ catch (e) {
272
+ // An unreadable settings.json used to escape as a stack trace and abort
273
+ // the run; it is a failed target like any other.
274
+ console.error(`✗ Claude Code every-prompt hook: ${e.message}`);
275
+ failures.push("Claude Code hook");
276
+ }
277
+ if (failures.length > 0) {
278
+ console.log(`\nDone with ${failures.length} problem(s): ${failures.join(", ")}. See the ✗ lines above.`);
279
+ console.log("Everything else was installed. Exit code is 1 so scripts can tell; fix the file(s) and re-run install.");
280
+ }
281
+ else {
282
+ console.log("\nDone. Restart the apps to pick up the new tools, then try:");
283
+ console.log(' "What\'s eating my context?" — or paste a conversation and ask for a profile.');
284
+ }
285
+ return { failures };
253
286
  }
254
287
  export function runUninstall() {
255
288
  for (const target of targets().filter((t) => t.detect())) {
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.3" }, { instructions: SERVER_INSTRUCTIONS });
40
+ const server = new McpServer({ name: "context-doctor", version: "0.13.5" }, { 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/parse.d.ts CHANGED
@@ -22,6 +22,13 @@ export interface NormalizedMessage {
22
22
  toolCallText?: string;
23
23
  /** True when the content contained non-text blocks (images, documents). */
24
24
  hasBinary: boolean;
25
+ /**
26
+ * For tool results: the tool reported failure (Anthropic `is_error`).
27
+ * What separates a retry from a re-read: the same call after an error is
28
+ * the model trying again; the same call after a success is the model having
29
+ * forgotten it already had the answer. Different problems, different fixes.
30
+ */
31
+ isError?: boolean;
25
32
  }
26
33
  export interface NormalizedConversation {
27
34
  messages: NormalizedMessage[];
package/dist/parse.js CHANGED
@@ -18,6 +18,7 @@ function flattenContent(content) {
18
18
  let toolName;
19
19
  let kind;
20
20
  let toolCallText = "";
21
+ let isError;
21
22
  for (const block of content) {
22
23
  if (block == null || typeof block !== "object") {
23
24
  text += String(block ?? "");
@@ -41,6 +42,8 @@ function flattenContent(content) {
41
42
  const inner = flattenContent(b.content);
42
43
  hasBinary = hasBinary || inner.hasBinary;
43
44
  text += inner.text;
45
+ if (b.is_error === true)
46
+ isError = true;
44
47
  break;
45
48
  }
46
49
  case "image":
@@ -60,7 +63,7 @@ function flattenContent(content) {
60
63
  text += JSON.stringify(b);
61
64
  }
62
65
  }
63
- return { text, hasBinary, toolName, kind, toolCallText: toolCallText || undefined };
66
+ return { text, hasBinary, toolName, kind, toolCallText: toolCallText || undefined, isError };
64
67
  }
65
68
  function normalizeMessage(rawInput, index) {
66
69
  // A null or non-object entry appears in truncated and hand-edited files.
@@ -88,7 +91,7 @@ function normalizeMessage(rawInput, index) {
88
91
  text += calls;
89
92
  toolCallText = (toolCallText ?? "") + calls;
90
93
  }
91
- return { index, role, kind, text, toolName, toolCallText, hasBinary: flat.hasBinary };
94
+ return { index, role, kind, text, toolName, toolCallText, hasBinary: flat.hasBinary, isError: flat.isError };
92
95
  }
93
96
  export function parseConversation(input) {
94
97
  let data;
package/dist/profile.d.ts CHANGED
@@ -12,7 +12,7 @@ export interface MessageProfile {
12
12
  preview: string;
13
13
  toolName?: string;
14
14
  }
15
- export type FindingId = "large_tool_result" | "large_tool_call" | "duplicate_content" | "near_duplicate" | "repeated_tool_call" | "repeated_file_read" | "retained_error_output" | "base64_blob" | "long_history" | "large_system_prompt" | "cache_ordering" | "near_window_limit";
15
+ export type FindingId = "large_tool_result" | "large_tool_call" | "duplicate_content" | "near_duplicate" | "repeated_tool_call" | "retried_tool_call" | "repeated_file_read" | "retained_error_output" | "base64_blob" | "long_history" | "large_system_prompt" | "cache_ordering" | "near_window_limit";
16
16
  export interface Finding {
17
17
  id: FindingId;
18
18
  severity: "info" | "warn" | "high";
package/dist/profile.js CHANGED
@@ -259,14 +259,38 @@ export function profileConversation(conv, model) {
259
259
  list.push(p.msg.index);
260
260
  callSeen.set(key, list);
261
261
  }
262
+ // Identical calls come in two kinds that look the same and mean the
263
+ // opposite. If the previous attempt FAILED, the repeat is a retry: the fix is
264
+ // in the error text, and hammering the same command is the waste. If the
265
+ // previous attempt SUCCEEDED, the repeat is a re-read: the model forgot it
266
+ // already had the answer, and the earlier result is what should have stayed
267
+ // in view. Lumping them together gave the wrong advice to both.
268
+ const resultAfter = (callIndex) => perMessage.find((p) => p.msg.index > callIndex && p.msg.kind === "tool_result")?.msg;
262
269
  for (const [, idxs] of callSeen) {
263
- if (idxs.length > 1) {
270
+ if (idxs.length < 2)
271
+ continue;
272
+ // Each repeat is classified by what happened to the attempt just before it.
273
+ const retries = idxs.slice(1).filter((_, i) => resultAfter(idxs[i])?.isError === true).length;
274
+ const rereads = idxs.length - 1 - retries;
275
+ if (retries > 0) {
276
+ findings.push({
277
+ id: "retried_tool_call",
278
+ severity: retries >= 3 ? "warn" : "info",
279
+ estSavings: 0,
280
+ message: `The same tool call was retried ${retries} time(s) after it failed (messages #${idxs.join(", #")}).`,
281
+ suggestion: retries >= 3
282
+ ? "Three or more identical retries of a failing command is a loop. Each attempt keeps its error output in context; the answer is in the first error, not in the fourth attempt."
283
+ : "A retry after a failure is normal once. The failed attempt's output stays in context though, so once the fix is understood the earlier error can go.",
284
+ messages: idxs,
285
+ });
286
+ }
287
+ if (rereads > 0) {
264
288
  findings.push({
265
289
  id: "repeated_tool_call",
266
290
  severity: "info",
267
291
  estSavings: 0,
268
- message: `The same tool call (with identical arguments) appears ${idxs.length} times (messages #${idxs.join(", #")}).`,
269
- suggestion: "Repeated identical calls usually mean the earlier result scrolled out of the model's attention — cache results or surface them in a compact recap instead of re-calling.",
292
+ message: `The same tool call (with identical arguments) was repeated ${rereads} time(s) after it had already succeeded (messages #${idxs.join(", #")}).`,
293
+ suggestion: "Repeating a call that already succeeded means the earlier result scrolled out of the model's attention — keep results in a compact recap instead of re-calling.",
270
294
  messages: idxs,
271
295
  });
272
296
  }
package/dist/session.d.ts CHANGED
@@ -14,6 +14,14 @@ export interface SessionInfo {
14
14
  modifiedAt: Date;
15
15
  sizeBytes: number;
16
16
  }
17
+ /** Wall-clock spent inside one tool, aggregated across a session. */
18
+ export interface ToolTiming {
19
+ tool: string;
20
+ calls: number;
21
+ totalMs: number;
22
+ medianMs: number;
23
+ maxMs: number;
24
+ }
17
25
  /** One API-reported input size, positioned in the message array. */
18
26
  export interface UsageSample {
19
27
  /** Index into the live `messages` array of the assistant message reporting it. */
@@ -46,6 +54,15 @@ export interface ParsedSession {
46
54
  * comparable to what the heuristic estimates for those same messages.
47
55
  */
48
56
  usageSamples?: UsageSample[];
57
+ /**
58
+ * Time between each tool_use and its tool_result, per tool, from the
59
+ * timestamps every transcript entry carries. This is the other half of the
60
+ * cost picture: tokens are what a call puts INTO context, this is how long
61
+ * it made you wait. Caveat that must travel with the number: the gap also
62
+ * contains any time spent waiting on a permission prompt, so an
63
+ * unattended run reads cleaner than an interactive one.
64
+ */
65
+ toolTimings?: ToolTiming[];
49
66
  /** Conversation JSON string in Anthropic-ish format, ready for parseConversation(). */
50
67
  conversationJson: string;
51
68
  title?: string;
package/dist/session.js CHANGED
@@ -179,6 +179,9 @@ export function parseSessionFile(path) {
179
179
  let reportedInputTokens;
180
180
  /** Every reported size, positioned — the basis for `context-doctor accuracy`. */
181
181
  const usageSamples = [];
182
+ /** Open tool calls awaiting their result, by tool_use id. */
183
+ const pendingCalls = new Map();
184
+ const latenciesByTool = new Map();
182
185
  forEachLine(path, (line) => {
183
186
  if (!line.trim())
184
187
  return;
@@ -219,8 +222,39 @@ export function parseSessionFile(path) {
219
222
  }
220
223
  if (entry.isCompactSummary)
221
224
  lastCompactIndex = messages.length;
225
+ // Pair every tool_use with its tool_result by id and record the gap.
226
+ const at = Date.parse(String(entry.timestamp ?? ""));
227
+ if (Number.isFinite(at) && Array.isArray(message.content)) {
228
+ for (const block of message.content) {
229
+ if (block?.type === "tool_use" && typeof block.id === "string") {
230
+ pendingCalls.set(block.id, { tool: String(block.name ?? "unknown"), at });
231
+ }
232
+ else if (block?.type === "tool_result" && typeof block.tool_use_id === "string") {
233
+ const call = pendingCalls.get(block.tool_use_id);
234
+ if (call) {
235
+ pendingCalls.delete(block.tool_use_id);
236
+ const ms = at - call.at;
237
+ if (ms >= 0)
238
+ latenciesByTool.set(call.tool, [...(latenciesByTool.get(call.tool) ?? []), ms]);
239
+ }
240
+ }
241
+ }
242
+ }
222
243
  messages.push({ role: message.role, content: message.content });
223
244
  });
245
+ const toolTimings = [...latenciesByTool.entries()]
246
+ .map(([tool, ms]) => {
247
+ const sorted = [...ms].sort((a, b) => a - b);
248
+ const mid = Math.floor(sorted.length / 2);
249
+ return {
250
+ tool,
251
+ calls: ms.length,
252
+ totalMs: ms.reduce((a, b) => a + b, 0),
253
+ medianMs: sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2,
254
+ maxMs: sorted[sorted.length - 1],
255
+ };
256
+ })
257
+ .sort((a, b) => b.totalMs - a.totalMs);
224
258
  // A compaction replaces everything before it: the summary entry IS the live
225
259
  // history from that point on. Counting the pre-compaction turns would
226
260
  // overstate context, cost per message and window fill — sometimes hugely.
@@ -238,6 +272,7 @@ export function parseSessionFile(path) {
238
272
  usageSamples: usageSamples
239
273
  .filter((u) => u.index >= compactedAway)
240
274
  .map((u) => ({ index: u.index - compactedAway, input: u.input })),
275
+ toolTimings,
241
276
  path,
242
277
  };
243
278
  }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Where the wall clock went: per-tool latency from transcript timestamps.
3
+ *
4
+ * Tokens say what a tool call put INTO the context; this says how long it made
5
+ * the user wait. Both are needed to decide what to fix first: a Read that
6
+ * costs 9k tokens but returns instantly is a different problem from a Bash
7
+ * call that costs 200 tokens and takes 40 seconds.
8
+ */
9
+ import type { ToolTiming } from "./session.js";
10
+ export declare function renderToolTimings(timings: ToolTiming[], top?: number): string | null;
package/dist/timing.js ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Where the wall clock went: per-tool latency from transcript timestamps.
3
+ *
4
+ * Tokens say what a tool call put INTO the context; this says how long it made
5
+ * the user wait. Both are needed to decide what to fix first: a Read that
6
+ * costs 9k tokens but returns instantly is a different problem from a Bash
7
+ * call that costs 200 tokens and takes 40 seconds.
8
+ */
9
+ function fmtMs(ms) {
10
+ if (ms >= 60_000)
11
+ return `${(ms / 60_000).toFixed(1)}m`;
12
+ if (ms >= 1_000)
13
+ return `${(ms / 1_000).toFixed(1)}s`;
14
+ return `${Math.round(ms)}ms`;
15
+ }
16
+ export function renderToolTimings(timings, top = 6) {
17
+ if (timings.length === 0)
18
+ return null;
19
+ const total = timings.reduce((s, t) => s + t.totalMs, 0);
20
+ if (total <= 0)
21
+ return null;
22
+ const lines = [];
23
+ lines.push("Where the time goes (tool wall clock)");
24
+ lines.push("─".repeat(56));
25
+ lines.push(`Tool calls waited ${fmtMs(total)} in total across ${timings.reduce((s, t) => s + t.calls, 0)} calls.`);
26
+ // MCP tool names run long (mcp__server__tool); fit the column to what is
27
+ // shown, capped so one long name cannot push the numbers off the screen.
28
+ const shown = timings.slice(0, top);
29
+ const width = Math.min(32, Math.max(...shown.map((t) => t.tool.length)));
30
+ const name = (tool) => (tool.length > width ? tool.slice(0, width - 1) + "…" : tool).padEnd(width);
31
+ for (const t of shown) {
32
+ const share = Math.round((t.totalMs / total) * 100);
33
+ lines.push(` ${name(t.tool)} ${fmtMs(t.totalMs).padStart(7)} ${String(share).padStart(3)}% ` +
34
+ `${String(t.calls).padStart(4)} calls · median ${fmtMs(t.medianMs)} · slowest ${fmtMs(t.maxMs)}`);
35
+ }
36
+ if (timings.length > top)
37
+ lines.push(` … and ${timings.length - top} more tool(s)`);
38
+ // The caveat has to travel with the number or the number lies.
39
+ lines.push("Measured from tool_use to tool_result timestamps. Includes any time spent waiting on a");
40
+ lines.push("permission prompt, so interactive sessions read slower than unattended ones.");
41
+ return lines.join("\n");
42
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-doctor",
3
- "version": "0.13.3",
3
+ "version": "0.13.5",
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",