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.
- package/README.md +25 -25
- package/dist/autoclear.d.ts +58 -7
- package/dist/autoclear.js +155 -30
- package/dist/autopilot.d.ts +23 -3
- package/dist/autopilot.js +100 -29
- package/dist/cache.d.ts +6 -2
- package/dist/cache.js +48 -23
- package/dist/cacheplan.d.ts +5 -3
- package/dist/cacheplan.js +46 -8
- package/dist/calibration.d.ts +7 -2
- package/dist/calibration.js +14 -3
- package/dist/ci.js +25 -5
- package/dist/cli.js +61 -26
- package/dist/coldresume.d.ts +32 -7
- package/dist/coldresume.js +148 -34
- package/dist/compactquality.js +34 -12
- package/dist/compactwindow.js +59 -34
- package/dist/config.d.ts +6 -0
- package/dist/config.js +4 -1
- package/dist/cursor.d.ts +9 -0
- package/dist/cursor.js +53 -22
- package/dist/dashboard.d.ts +10 -0
- package/dist/dashboard.js +27 -2
- package/dist/diff.js +10 -3
- package/dist/doctor.js +133 -18
- package/dist/embed.d.ts +9 -0
- package/dist/embed.js +40 -5
- package/dist/experiment.d.ts +1 -0
- package/dist/experiment.js +6 -3
- package/dist/hook.js +23 -4
- package/dist/impact.d.ts +32 -0
- package/dist/impact.js +45 -6
- package/dist/install.d.ts +34 -2
- package/dist/install.js +223 -30
- package/dist/ledger.js +9 -2
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.js +2 -2
- package/dist/mcpschema.d.ts +16 -1
- package/dist/mcpschema.js +95 -24
- package/dist/optimize.js +149 -64
- package/dist/overhead.js +57 -27
- package/dist/pack.d.ts +11 -0
- package/dist/pack.js +75 -12
- package/dist/parse.js +42 -18
- package/dist/pricing.d.ts +57 -5
- package/dist/pricing.js +94 -8
- package/dist/proxy.d.ts +13 -2
- package/dist/proxy.js +258 -43
- package/dist/reads.d.ts +1 -1
- package/dist/reads.js +61 -24
- package/dist/report.d.ts +10 -0
- package/dist/report.js +12 -4
- package/dist/savings.d.ts +20 -8
- package/dist/savings.js +79 -34
- package/dist/session.d.ts +37 -0
- package/dist/session.js +143 -6
- package/dist/split.js +6 -1
- package/dist/statusline.d.ts +3 -1
- package/dist/statusline.js +29 -17
- package/dist/subagentpay.d.ts +5 -2
- package/dist/subagentpay.js +50 -16
- package/dist/subagents.d.ts +7 -3
- package/dist/subagents.js +28 -8
- package/dist/tasks.d.ts +22 -7
- package/dist/tasks.js +47 -20
- package/dist/wrap.d.ts +6 -0
- package/dist/wrap.js +142 -6
- 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
|
|
75
|
-
: { command: "npx", args: ["-y", "context-doctor
|
|
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
|
-
|
|
88
|
-
|
|
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
|
|
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
|
-
|
|
360
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
const
|
|
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
|
-
|
|
376
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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({
|
package/dist/mcpschema.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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(
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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 () => {
|