context-doctor 0.31.0 → 0.32.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.
Files changed (68) hide show
  1. package/README.md +25 -25
  2. package/dist/autoclear.d.ts +58 -7
  3. package/dist/autoclear.js +155 -30
  4. package/dist/autopilot.d.ts +23 -3
  5. package/dist/autopilot.js +100 -29
  6. package/dist/cache.d.ts +6 -2
  7. package/dist/cache.js +48 -23
  8. package/dist/cacheplan.d.ts +5 -3
  9. package/dist/cacheplan.js +46 -8
  10. package/dist/calibration.d.ts +7 -2
  11. package/dist/calibration.js +14 -3
  12. package/dist/ci.js +25 -5
  13. package/dist/cli.js +61 -26
  14. package/dist/coldresume.d.ts +32 -7
  15. package/dist/coldresume.js +148 -34
  16. package/dist/compactquality.js +34 -12
  17. package/dist/compactwindow.js +59 -34
  18. package/dist/config.d.ts +6 -0
  19. package/dist/config.js +4 -1
  20. package/dist/cursor.d.ts +9 -0
  21. package/dist/cursor.js +53 -22
  22. package/dist/dashboard.d.ts +10 -0
  23. package/dist/dashboard.js +27 -2
  24. package/dist/diff.js +10 -3
  25. package/dist/doctor.js +133 -18
  26. package/dist/embed.d.ts +9 -0
  27. package/dist/embed.js +40 -5
  28. package/dist/experiment.d.ts +1 -0
  29. package/dist/experiment.js +6 -3
  30. package/dist/hook.js +23 -4
  31. package/dist/impact.d.ts +32 -0
  32. package/dist/impact.js +45 -6
  33. package/dist/install.d.ts +34 -2
  34. package/dist/install.js +223 -30
  35. package/dist/ledger.js +9 -2
  36. package/dist/mcp.d.ts +1 -1
  37. package/dist/mcp.js +2 -2
  38. package/dist/mcpschema.d.ts +16 -1
  39. package/dist/mcpschema.js +95 -24
  40. package/dist/optimize.js +149 -64
  41. package/dist/overhead.js +57 -27
  42. package/dist/pack.d.ts +11 -0
  43. package/dist/pack.js +75 -12
  44. package/dist/parse.js +42 -18
  45. package/dist/pricing.d.ts +57 -5
  46. package/dist/pricing.js +94 -8
  47. package/dist/proxy.d.ts +13 -2
  48. package/dist/proxy.js +258 -43
  49. package/dist/reads.d.ts +1 -1
  50. package/dist/reads.js +61 -24
  51. package/dist/report.d.ts +10 -0
  52. package/dist/report.js +12 -4
  53. package/dist/savings.d.ts +20 -8
  54. package/dist/savings.js +79 -34
  55. package/dist/session.d.ts +37 -0
  56. package/dist/session.js +143 -6
  57. package/dist/split.js +6 -1
  58. package/dist/statusline.d.ts +3 -1
  59. package/dist/statusline.js +29 -17
  60. package/dist/subagentpay.d.ts +5 -2
  61. package/dist/subagentpay.js +50 -16
  62. package/dist/subagents.d.ts +7 -3
  63. package/dist/subagents.js +28 -8
  64. package/dist/tasks.d.ts +22 -7
  65. package/dist/tasks.js +47 -20
  66. package/dist/wrap.d.ts +6 -0
  67. package/dist/wrap.js +142 -6
  68. package/package.json +17 -2
package/dist/install.js CHANGED
@@ -68,11 +68,35 @@ function serverEntry() {
68
68
  * wrapper that every working Windows MCP config uses.
69
69
  *
70
70
  * Exported so the platform branch is testable from any host OS.
71
+ *
72
+ * npx resolves its first operand as a PACKAGE name. context-doctor-mcp is
73
+ * only a bin inside the context-doctor package, so `npx -y context-doctor-mcp`
74
+ * asked npm for a package that does not exist: unless a global install
75
+ * happened to put that command on PATH, every app got a server that never
76
+ * started. The main bin's `mcp` subcommand is the same server.
71
77
  */
72
78
  export function npxLauncher(platformName) {
73
79
  return platformName === "win32"
74
- ? { command: "cmd", args: ["/c", "npx", "-y", "context-doctor-mcp"] }
75
- : { command: "npx", args: ["-y", "context-doctor-mcp"] };
80
+ ? { command: "cmd", args: ["/c", "npx", "-y", "context-doctor", "mcp"] }
81
+ : { command: "npx", args: ["-y", "context-doctor", "mcp"] };
82
+ }
83
+ /**
84
+ * True for the launcher installs before 0.31.2 wrote: npx asked to run a
85
+ * package named context-doctor-mcp, which is not on npm. npx does fall back
86
+ * to a command of that name already on PATH, so it starts where a global
87
+ * install provides one; everywhere else npm answers 404.
88
+ */
89
+ export function launcherNamesMissingPackage(entry) {
90
+ if (!entry)
91
+ return false;
92
+ const words = [String(entry.command ?? ""), ...(Array.isArray(entry.args) ? entry.args.map(String) : [])];
93
+ const npx = words.findIndex((w) => /(^|[\\/])npx(\.cmd)?$/i.test(w));
94
+ if (npx === -1)
95
+ return false;
96
+ const rest = words.slice(npx + 1);
97
+ if (rest.some((w) => w === "-p" || w.startsWith("--package")))
98
+ return false;
99
+ return rest.find((w) => !w.startsWith("-")) === "context-doctor-mcp";
76
100
  }
77
101
  export function readJson(path) {
78
102
  if (!existsSync(path))
@@ -84,10 +108,26 @@ export function readJson(path) {
84
108
  throw new Error(`${path} exists but is not valid JSON — fix or remove it first (${e.message})`);
85
109
  }
86
110
  }
87
- export function writeJsonWithBackup(path, data) {
88
- mkdirSync(dirname(path), { recursive: true });
111
+ /**
112
+ * Config files already backed up (or found absent) in this process.
113
+ *
114
+ * One install writes ~/.claude/settings.json up to three times (prompt hook,
115
+ * compaction hook, status line). Copying before every write left a .backup
116
+ * that already held our own first write, so the user's original was gone
117
+ * before the run even finished. The first write of a run keeps the original.
118
+ */
119
+ const backedUp = new Set();
120
+ /** Copy `path` to its .context-doctor.backup, once per run. */
121
+ export function backupOnce(path) {
122
+ if (backedUp.has(path))
123
+ return;
124
+ backedUp.add(path);
89
125
  if (existsSync(path))
90
126
  copyFileSync(path, path + ".context-doctor.backup");
127
+ }
128
+ export function writeJsonWithBackup(path, data) {
129
+ mkdirSync(dirname(path), { recursive: true });
130
+ backupOnce(path);
91
131
  writeFileSync(path, JSON.stringify(data, null, 2));
92
132
  }
93
133
  /**
@@ -95,7 +135,7 @@ export function writeJsonWithBackup(path, data) {
95
135
  * Used to prefer a global `context-doctor` install for the hook: a stable
96
136
  * location that survives both package updates and Node upgrades.
97
137
  */
98
- function binOnPath(name) {
138
+ export function binOnPath(name) {
99
139
  const exts = platform() === "win32" ? [".cmd", ".exe", ".bat", ""] : [""];
100
140
  for (const dir of (process.env.PATH ?? "").split(delimiter)) {
101
141
  if (!dir)
@@ -344,41 +384,131 @@ function installSkillInto(skillDest) {
344
384
  export function codexDir() {
345
385
  return join(homedir(), ".codex");
346
386
  }
387
+ // Our key as TOML allows it to be written: bare or quoted, any spacing.
388
+ const OUR_KEY = String.raw `\s*mcp_servers\s*\.\s*(?:context-doctor|"context-doctor"|'context-doctor')\s*`;
389
+ /** Our table's header, with or without a trailing comment. */
390
+ const OUR_HEADER = new RegExp(String.raw `^\s*\[${OUR_KEY}\]\s*(?:#.*)?$`);
391
+ /** A subtable of ours, e.g. [mcp_servers.context-doctor.env]. */
392
+ const OUR_SUBHEADER = new RegExp(String.raw `^\s*\[${OUR_KEY}\.[^\]]*\]\s*(?:#.*)?$`);
393
+ const ANY_HEADER = /^\s*\[/;
394
+ /**
395
+ * [start, end) line ranges of our tables (and, when asked, our subtables).
396
+ *
397
+ * A table runs to the next header, except that comment lines directly above
398
+ * that header describe IT: they stay with the next table rather than being
399
+ * deleted along with ours.
400
+ */
401
+ function codexTableRanges(lines, withSubtables) {
402
+ const ranges = [];
403
+ for (let i = 0; i < lines.length; i++) {
404
+ if (!OUR_HEADER.test(lines[i]) && !(withSubtables && OUR_SUBHEADER.test(lines[i])))
405
+ continue;
406
+ let end = i + 1;
407
+ while (end < lines.length && !ANY_HEADER.test(lines[end]))
408
+ end++;
409
+ if (end < lines.length) {
410
+ let k = end;
411
+ while (k > i + 1 && /^\s*(#.*)?$/.test(lines[k - 1]))
412
+ k--;
413
+ // Blank lines after our last key are ours; the comments after them are not.
414
+ while (k < end && lines[k].trim() === "")
415
+ k++;
416
+ end = k;
417
+ }
418
+ ranges.push([i, end]);
419
+ i = end - 1;
420
+ }
421
+ return ranges;
422
+ }
423
+ /**
424
+ * Lines that define context-doctor WITHOUT a header of its own:
425
+ * [mcp_servers] context-doctor = { command = "npx", ... } (inline table)
426
+ * [mcp_servers] context-doctor.command = "npx" (dotted keys)
427
+ * mcp_servers.context-doctor.command = "npx" (dotted, at the root)
428
+ * Appending a [mcp_servers.context-doctor] table next to any of these defines
429
+ * the key twice, which is invalid TOML, so these are refused instead.
430
+ */
431
+ function inlineCodexDefinition(lines) {
432
+ const found = [];
433
+ let table = ""; // "" = the root table; null = inside an array of tables
434
+ const norm = (s) => s.replace(/\s+/g, "").replace(/["']/g, "");
435
+ lines.forEach((line, i) => {
436
+ if (/^\s*\[\[/.test(line)) {
437
+ table = null;
438
+ return;
439
+ }
440
+ const header = line.match(/^\s*\[([^\[\]]*)\]\s*(?:#.*)?$/);
441
+ if (header) {
442
+ table = norm(header[1]);
443
+ return;
444
+ }
445
+ const key = line.match(/^\s*([A-Za-z0-9_\-."' ]+?)\s*=/)?.[1];
446
+ if (key === undefined || table === null)
447
+ return;
448
+ const full = norm(table ? `${table}.${key}` : key);
449
+ const ours = full === "mcp_servers.context-doctor" || full.startsWith("mcp_servers.context-doctor.");
450
+ if ((table === "mcp_servers" || table === "") && ours)
451
+ found.push(i);
452
+ else if (table === "" && full === "mcp_servers" && /["']?context-doctor["']?\s*=/.test(line))
453
+ found.push(i);
454
+ });
455
+ return found;
456
+ }
457
+ function refuseInlineCodexDefinition(lines) {
458
+ const at = inlineCodexDefinition(lines);
459
+ if (at.length > 0) {
460
+ throw new Error(`config.toml defines context-doctor as an inline table or dotted keys (line ${at[0] + 1}), which cannot be edited safely line by line; ` +
461
+ "replace it with a [mcp_servers.context-doctor] table, or delete it, and re-run. Nothing was changed.");
462
+ }
463
+ }
347
464
  /**
348
465
  * Add or replace our [mcp_servers.context-doctor] table in config.toml without
349
466
  * a TOML library: the file is the user's, so everything outside our own table
350
467
  * is copied through byte for byte. Our table is delimited by its header and the
351
- * next header (or EOF).
468
+ * next header (or EOF). Our subtables (e.g. an env the user added) are kept.
352
469
  */
353
470
  export function upsertCodexMcpTable(toml, entry) {
354
- const header = "[mcp_servers.context-doctor]";
355
- const table = `${header}\n` +
471
+ const table = `[mcp_servers.context-doctor]\n` +
356
472
  `command = ${JSON.stringify(entry.command)}\n` +
357
473
  `args = [${entry.args.map((a) => JSON.stringify(a)).join(", ")}]\n`;
358
474
  const lines = toml.split("\n");
359
- const start = lines.findIndex((l) => l.trim() === header);
360
- if (start === -1) {
475
+ refuseInlineCodexDefinition(lines);
476
+ const [first, ...duplicates] = codexTableRanges(lines, false);
477
+ if (!first) {
361
478
  const sep = toml.length === 0 || toml.endsWith("\n\n") ? "" : toml.endsWith("\n") ? "\n" : "\n\n";
362
479
  return toml + sep + table;
363
480
  }
364
- let end = start + 1;
365
- while (end < lines.length && !/^\s*\[/.test(lines[end]))
366
- end++;
367
- // Trim trailing blank lines inside our block so we do not accumulate them.
368
- const before = lines.slice(0, start).join("\n");
369
- const after = lines.slice(end).join("\n");
481
+ // A second table of ours is what earlier versions appended after a header
482
+ // they did not recognise; it makes the file invalid, so repair it here.
483
+ const kept = lines.filter((_, i) => !duplicates.some(([s, e]) => i >= s && i < e));
484
+ const before = kept.slice(0, first[0]).join("\n");
485
+ const after = kept.slice(first[1]).join("\n");
370
486
  return `${before}${before.length ? "\n" : ""}${table}${after.length ? "\n" + after : ""}`;
371
487
  }
372
488
  export function removeCodexMcpTable(toml) {
373
- const header = "[mcp_servers.context-doctor]";
374
489
  const lines = toml.split("\n");
375
- const start = lines.findIndex((l) => l.trim() === header);
376
- if (start === -1)
490
+ refuseInlineCodexDefinition(lines);
491
+ // Subtables go too: a [mcp_servers.context-doctor.env] left alone defines
492
+ // a server with no command, which Codex reports as broken.
493
+ const ranges = codexTableRanges(lines, true);
494
+ if (ranges.length === 0)
377
495
  return toml;
378
- let end = start + 1;
379
- while (end < lines.length && !/^\s*\[/.test(lines[end]))
380
- end++;
381
- return [...lines.slice(0, start), ...lines.slice(end)].join("\n").replace(/\n{3,}/g, "\n\n");
496
+ return lines.filter((_, i) => !ranges.some(([s, e]) => i >= s && i < e)).join("\n").replace(/\n{3,}/g, "\n\n");
497
+ }
498
+ /**
499
+ * The command and args of our Codex server, or undefined when config.toml
500
+ * does not define one. Reads the inline forms too: Codex runs those fine.
501
+ */
502
+ export function codexMcpEntry(toml) {
503
+ const lines = toml.split("\n");
504
+ const [first] = codexTableRanges(lines, false);
505
+ const inline = inlineCodexDefinition(lines);
506
+ if (!first && inline.length === 0)
507
+ return undefined;
508
+ const text = first ? lines.slice(first[0] + 1, first[1]).join("\n") : inline.map((i) => lines[i]).join("\n");
509
+ const command = text.match(/(?:^|[\s{,.])command\s*=\s*(["'])(.*?)\1/m)?.[2];
510
+ const argsText = text.match(/(?:^|[\s{,.])args\s*=\s*\[([^\]]*)\]/m)?.[1] ?? "";
511
+ return { command, args: [...argsText.matchAll(/(["'])(.*?)\1/g)].map((m) => m[2]) };
382
512
  }
383
513
  function installCodex(entry, failures) {
384
514
  const dir = codexDir();
@@ -389,8 +519,7 @@ function installCodex(entry, failures) {
389
519
  const current = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
390
520
  const next = upsertCodexMcpTable(current, entry);
391
521
  if (next !== current) {
392
- if (existsSync(configPath))
393
- copyFileSync(configPath, configPath + ".context-doctor.backup");
522
+ backupOnce(configPath);
394
523
  writeFileSync(configPath, next);
395
524
  }
396
525
  console.log(`✓ Codex: MCP server added (${configPath})`);
@@ -430,7 +559,7 @@ function uninstallCodex() {
430
559
  const current = readFileSync(configPath, "utf8");
431
560
  const next = removeCodexMcpTable(current);
432
561
  if (next !== current) {
433
- copyFileSync(configPath, configPath + ".context-doctor.backup");
562
+ backupOnce(configPath);
434
563
  writeFileSync(configPath, next);
435
564
  console.log("✓ Codex: MCP server removed");
436
565
  }
@@ -561,7 +690,70 @@ export function runInstall(options = {}) {
561
690
  }
562
691
  return { failures };
563
692
  }
564
- export function runUninstall() {
693
+ /**
694
+ * Autopilot points Claude Code at a local proxy (env.ANTHROPIC_BASE_URL in
695
+ * settings.json), kept alive by a login service and by this package's hook.
696
+ * Uninstalling around it left that address in place with nothing behind it,
697
+ * so every new Claude Code session failed to reach the API. It is switched
698
+ * off first, exactly as `autopilot off` does it (setting before service).
699
+ */
700
+ async function uninstallAutopilot() {
701
+ const { autopilotOff, autopilotPaths, proxyUrl, DEFAULT_AUTOPILOT_PORT } = await import("./autopilot.js");
702
+ const paths = autopilotPaths();
703
+ // Only for a home that has autopilot: removing the service addresses
704
+ // systemd and the Windows task scheduler by NAME, not by anything under
705
+ // this home, so a home without autopilot must never reach them.
706
+ if (!existsSync(paths.config))
707
+ return;
708
+ // The port, read before `off` deletes the file; a damaged file usually
709
+ // still names it.
710
+ let port = DEFAULT_AUTOPILOT_PORT;
711
+ try {
712
+ port = Number(readFileSync(paths.config, "utf8").match(/"port"\s*:\s*(\d+)/)?.[1] ?? port);
713
+ }
714
+ catch {
715
+ /* unreadable: the default port */
716
+ }
717
+ try {
718
+ // `autopilot pause` is advice for someone keeping the package; not here.
719
+ for (const line of await autopilotOff(paths))
720
+ if (!line.includes("autopilot pause"))
721
+ console.log(line);
722
+ }
723
+ catch (e) {
724
+ console.error(`✗ Autopilot: ${e.message} (run: context-doctor autopilot off)`);
725
+ return;
726
+ }
727
+ // `off` restores only what a readable config describes. A damaged one
728
+ // leaves the proxy address in settings.json, which is the dead-port case.
729
+ try {
730
+ const settings = readJson(paths.settings);
731
+ if (settings.env?.ANTHROPIC_BASE_URL === proxyUrl(port)) {
732
+ delete settings.env.ANTHROPIC_BASE_URL;
733
+ if (Object.keys(settings.env).length === 0)
734
+ delete settings.env;
735
+ writeJsonWithBackup(paths.settings, settings);
736
+ console.log(`✓ Claude Code no longer routed through ${proxyUrl(port)} (autopilot's config could not say what address, if any, it had replaced)`);
737
+ }
738
+ }
739
+ catch (e) {
740
+ console.error(`✗ Autopilot: ${e.message}`);
741
+ }
742
+ }
743
+ /** Everything of ours under ~/.claude besides configs: state, ledger, autopilot bookkeeping. */
744
+ const OUR_CLAUDE_FILES = [
745
+ ".context-doctor-hook-state.json",
746
+ ".context-doctor-hook-state.d",
747
+ ".context-doctor-ledger.jsonl",
748
+ ".context-doctor-calibration.json",
749
+ ".context-doctor-autopilot-cleared.json",
750
+ ".context-doctor-autopilot-paused",
751
+ ".context-doctor-proxy.log",
752
+ ".context-doctor-proxy-restart",
753
+ ];
754
+ export async function runUninstall() {
755
+ // First: if anything below fails, Claude Code must still reach the API.
756
+ await uninstallAutopilot();
565
757
  for (const target of targets().filter((t) => t.detect())) {
566
758
  try {
567
759
  if (!existsSync(target.configPath))
@@ -585,11 +777,12 @@ export function runUninstall() {
585
777
  uninstallHook();
586
778
  uninstallStatusLine();
587
779
  uninstallCodex();
588
- // Remove our bookkeeping files too — uninstall means gone.
589
- for (const file of [".context-doctor-hook-state.json", ".context-doctor-ledger.jsonl"]) {
780
+ // Remove our bookkeeping files too — uninstall means gone. The hook's
781
+ // per-session state is a directory, one small file per session.
782
+ for (const file of OUR_CLAUDE_FILES) {
590
783
  const p = join(homedir(), ".claude", file);
591
784
  if (existsSync(p)) {
592
- rmSync(p);
785
+ rmSync(p, { recursive: true, force: true });
593
786
  console.log(`✓ Removed ${file}`);
594
787
  }
595
788
  }
package/dist/ledger.js CHANGED
@@ -55,11 +55,18 @@ export function foldTotals(entries) {
55
55
  out.proxyUsd += e.usd ?? 0;
56
56
  out.proxyRequests += e.requests ?? 0;
57
57
  }
58
- else {
58
+ else if ((e.ev === "check" || e.ev === undefined) && e.sid !== "doctor-probe") {
59
+ // Exactly what the live reports (impact, dashboard) count, or totals
60
+ // jump when the ledger rotates. Not every other event is a check:
61
+ // cold_resume and precompact are not, and precompact's tok is a
62
+ // CHARACTER count that read as hundreds of tokens of "shrinkage".
63
+ // "doctor-probe" came from `doctor` before 0.30, not from the hook.
59
64
  out.checks++;
60
65
  if (e.warn)
61
66
  out.warnings++;
62
- if (e.sid && typeof e.tok === "number")
67
+ // "mcp-sketch" was one id shared by unrelated chats (until 0.24): its
68
+ // sizes are not one session's, so no shrinkage is read from them.
69
+ if (e.sid && e.sid !== "mcp-sketch" && typeof e.tok === "number")
63
70
  perSession.set(e.sid, [...(perSession.get(e.sid) ?? []), e.tok]);
64
71
  }
65
72
  }
package/dist/mcp.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * Plug into Claude Desktop, ChatGPT desktop (developer mode), Cursor, or any
6
6
  * MCP client:
7
7
  *
8
- * { "mcpServers": { "context-doctor": { "command": "npx", "args": ["-y", "context-doctor-mcp"] } } }
8
+ * { "mcpServers": { "context-doctor": { "command": "npx", "args": ["-y", "context-doctor", "mcp"] } } }
9
9
  *
10
10
  * Tools:
11
11
  * profile_context — analyze a conversation/prompt, report token breakdown + findings
package/dist/mcp.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * Plug into Claude Desktop, ChatGPT desktop (developer mode), Cursor, or any
6
6
  * MCP client:
7
7
  *
8
- * { "mcpServers": { "context-doctor": { "command": "npx", "args": ["-y", "context-doctor-mcp"] } } }
8
+ * { "mcpServers": { "context-doctor": { "command": "npx", "args": ["-y", "context-doctor", "mcp"] } } }
9
9
  *
10
10
  * Tools:
11
11
  * profile_context — analyze a conversation/prompt, report token breakdown + findings
@@ -52,7 +52,7 @@ const STRATEGY_IDS = ["dedupe", "trim-tool-results", "trim-tool-calls", "strip-b
52
52
  * recommended pattern.
53
53
  */
54
54
  function createServer({ fileAccess = true } = {}) {
55
- const server = new McpServer({ name: "context-doctor", version: "0.31.0" }, { instructions: SERVER_INSTRUCTIONS });
55
+ const server = new McpServer({ name: "context-doctor", version: "0.32.0" }, { instructions: SERVER_INSTRUCTIONS });
56
56
  server.tool("profile_context", "Profile an LLM conversation or prompt: token breakdown, largest blocks, and actionable findings about wasted context (duplicates, oversized pastes or tool results, base64 blobs, long history). Two inputs, pass ONE: `conversation` (full OpenAI/Anthropic JSON or raw text, for agents, files and proxies) or `sketch` (for chat apps such as Claude Desktop or ChatGPT where you cannot export the conversation: the turn count plus the few blocks that matter, ~120 tokens to write). Call it whenever the user asks about token usage, context size, cost, speed or limits, and on your own once the conversation passes ~30 turns or holds 3+ large pastes. Act on the top finding in your reply.", {
57
57
  conversation: z.string().optional().describe("Conversation JSON (OpenAI or Anthropic format, or bare message array) or raw prompt text. Omit in chat apps and pass `sketch`."),
58
58
  sketch: z.object({
@@ -45,7 +45,22 @@ export interface McpUsage {
45
45
  export declare function toolPrefixName(server: string): string;
46
46
  /** MCP servers Claude Code starts in `cwd`, with their launch configs. */
47
47
  export declare function claudeMcpConfigs(cwd?: string, home?: string): McpServerConfig[];
48
- /** MCP calls per server over the last `days`, from Claude Code transcripts (a cheap line scan, no JSON parse per line). */
48
+ /**
49
+ * MCP calls per server over the last `days`, from Claude Code transcripts
50
+ * and their subagents' (`<session>/subagents/agent-*.jsonl`). Only lines
51
+ * naming an MCP tool or ToolSearch are parsed.
52
+ *
53
+ * Subagents: on the author's machine 49 Gmail and 23 Slack connector calls
54
+ * were made only by subagents, so scanning the top-level transcripts alone
55
+ * told the user to remove a server their subagents use. Once per call: a
56
+ * forked session starts as a copy of its parent's history, the same rows
57
+ * (uuid) holding the same calls (tool_use id), so a call is counted once by
58
+ * the two together; like replyCounter, that leaves alone hand-made
59
+ * transcripts that reuse an id. Each call by its own row's time: a file
60
+ * touched in the window also holds every older call of its session (2,983
61
+ * of them in one 30-day count). Rows without a timestamp count with their
62
+ * file.
63
+ */
49
64
  export declare function mcpUsage(days?: number, paths?: string[]): McpUsage;
50
65
  /** Connect to one server the way a client does and size its tool list. */
51
66
  export declare function measureServer(cfg: McpServerConfig, timeoutMs?: number): Promise<McpServerSize>;
package/dist/mcpschema.js CHANGED
@@ -16,10 +16,11 @@
16
16
  * up; with it on, an unused server costs about its tool names, not its
17
17
  * schemas. Usage of ToolSearch in the transcripts says whether that is so.
18
18
  */
19
- import { readFileSync } from "node:fs";
19
+ import { readdirSync, readFileSync } from "node:fs";
20
20
  import { homedir } from "node:os";
21
21
  import { join, resolve } from "node:path";
22
22
  import { forEachLine, listSessions } from "./session.js";
23
+ import { subagentDir } from "./subagents.js";
23
24
  import { estimateTokens } from "./tokens.js";
24
25
  const CLAUDE = "claude-opus-5";
25
26
  /** Claude Code's spelling of a server name inside tool names. */
@@ -51,22 +52,79 @@ export function claudeMcpConfigs(cwd = process.cwd(), home = homedir()) {
51
52
  }
52
53
  return [...out.values()].sort((a, b) => a.name.localeCompare(b.name));
53
54
  }
54
- /** MCP calls per server over the last `days`, from Claude Code transcripts (a cheap line scan, no JSON parse per line). */
55
+ /**
56
+ * MCP calls per server over the last `days`, from Claude Code transcripts
57
+ * and their subagents' (`<session>/subagents/agent-*.jsonl`). Only lines
58
+ * naming an MCP tool or ToolSearch are parsed.
59
+ *
60
+ * Subagents: on the author's machine 49 Gmail and 23 Slack connector calls
61
+ * were made only by subagents, so scanning the top-level transcripts alone
62
+ * told the user to remove a server their subagents use. Once per call: a
63
+ * forked session starts as a copy of its parent's history, the same rows
64
+ * (uuid) holding the same calls (tool_use id), so a call is counted once by
65
+ * the two together; like replyCounter, that leaves alone hand-made
66
+ * transcripts that reuse an id. Each call by its own row's time: a file
67
+ * touched in the window also holds every older call of its session (2,983
68
+ * of them in one 30-day count). Rows without a timestamp count with their
69
+ * file.
70
+ */
55
71
  export function mcpUsage(days = 30, paths) {
56
72
  const since = Date.now() - days * 86_400_000;
57
73
  const files = paths ?? listSessions(10_000).filter((s) => s.path.includes(".claude") && s.modifiedAt.getTime() >= since).map((s) => s.path);
58
74
  const calls = new Map();
75
+ const counted = new Set();
59
76
  let toolSearchSessions = 0;
60
77
  for (const path of files) {
61
78
  let searched = false;
62
- forEachLine(path, (line) => {
63
- if (!line.includes('"tool_use"'))
79
+ const scan = (file) => forEachLine(file, (line) => {
80
+ if (!line.includes('"tool_use"') || (!line.includes('"name":"mcp__') && !line.includes('"name":"ToolSearch"')))
81
+ return;
82
+ let e;
83
+ try {
84
+ e = JSON.parse(line);
85
+ }
86
+ catch {
87
+ return;
88
+ }
89
+ const content = e?.message?.content;
90
+ if (!Array.isArray(content))
64
91
  return;
65
- for (const m of line.matchAll(/"name":"mcp__([^"]+?)__[^"]+"/g))
66
- calls.set(m[1], (calls.get(m[1]) ?? 0) + 1);
67
- if (!searched && line.includes('"name":"ToolSearch"'))
68
- searched = true;
92
+ // Whether a session defers MCP definitions is a property of the
93
+ // session, so ToolSearch counts whenever it was used; calls are dated.
94
+ const before = Date.parse(e?.timestamp) < since;
95
+ for (const b of content) {
96
+ if (b?.type !== "tool_use" || typeof b.name !== "string")
97
+ continue;
98
+ if (b.name === "ToolSearch") {
99
+ searched = true;
100
+ continue;
101
+ }
102
+ if (before)
103
+ continue;
104
+ const server = /^mcp__(.+?)__./.exec(b.name)?.[1];
105
+ if (!server)
106
+ continue;
107
+ if (typeof b.id === "string" && typeof e.uuid === "string") {
108
+ const key = `${e.uuid}\0${b.id}`;
109
+ if (counted.has(key))
110
+ continue;
111
+ counted.add(key);
112
+ }
113
+ calls.set(server, (calls.get(server) ?? 0) + 1);
114
+ }
69
115
  });
116
+ scan(path);
117
+ let agents = [];
118
+ try {
119
+ agents = readdirSync(subagentDir(path)).filter((f) => f.endsWith(".jsonl"));
120
+ }
121
+ catch { /* no subagents */ }
122
+ for (const a of agents) {
123
+ try {
124
+ scan(join(subagentDir(path), a));
125
+ }
126
+ catch { /* one unreadable subagent must not sink the report */ }
127
+ }
70
128
  if (searched)
71
129
  toolSearchSessions++;
72
130
  }
@@ -74,24 +132,37 @@ export function mcpUsage(days = 30, paths) {
74
132
  }
75
133
  /** Connect to one server the way a client does and size its tool list. */
76
134
  export async function measureServer(cfg, timeoutMs = 15_000) {
77
- const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
78
- const client = new Client({ name: "context-doctor-overhead", version: "1" });
79
- let transport;
80
- if (cfg.command) {
81
- const { StdioClientTransport } = await import("@modelcontextprotocol/sdk/client/stdio.js");
82
- transport = new StdioClientTransport({
83
- command: cfg.command,
84
- args: cfg.args ?? [],
85
- env: { ...process.env, ...(cfg.env ?? {}) },
86
- stderr: "ignore",
87
- });
135
+ if (!cfg.command && !(cfg.url && cfg.type !== "sse")) {
136
+ return { name: cfg.name, error: cfg.type === "sse" ? "SSE server: not measured" : "no command or url" };
88
137
  }
89
- else if (cfg.url && cfg.type !== "sse") {
90
- const { StreamableHTTPClientTransport } = await import("@modelcontextprotocol/sdk/client/streamableHttp.js");
91
- transport = new StreamableHTTPClientTransport(new URL(cfg.url));
138
+ // The SDK is a dependency of the npm package, but the Claude Code plugin runs
139
+ // dist/ from a git checkout with no node_modules. An import failure there
140
+ // used to abort the whole `overhead --mcp` report; it is a per-server note.
141
+ let client;
142
+ let transport;
143
+ try {
144
+ const sdk = await import("@modelcontextprotocol/sdk/client/index.js");
145
+ client = new sdk.Client({ name: "context-doctor-overhead", version: "1" });
146
+ if (cfg.command) {
147
+ const { StdioClientTransport } = await import("@modelcontextprotocol/sdk/client/stdio.js");
148
+ transport = new StdioClientTransport({
149
+ command: cfg.command,
150
+ args: cfg.args ?? [],
151
+ env: { ...process.env, ...(cfg.env ?? {}) },
152
+ stderr: "ignore",
153
+ });
154
+ }
155
+ else {
156
+ const { StreamableHTTPClientTransport } = await import("@modelcontextprotocol/sdk/client/streamableHttp.js");
157
+ transport = new StreamableHTTPClientTransport(new URL(cfg.url));
158
+ }
92
159
  }
93
- else {
94
- return { name: cfg.name, error: cfg.type === "sse" ? "SSE server: not measured" : "no command or url" };
160
+ catch (e) {
161
+ const code = e?.code;
162
+ if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") {
163
+ return { name: cfg.name, error: "MCP SDK not installed (not measured)" };
164
+ }
165
+ return { name: cfg.name, error: String(e?.message ?? e).split("\n")[0].slice(0, 100) };
95
166
  }
96
167
  let timer;
97
168
  const work = (async () => {