context-doctor 0.3.6 โ 0.5.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 +15 -14
- package/dist/cli.js +18 -0
- package/dist/doctor.d.ts +8 -0
- package/dist/doctor.js +121 -0
- package/dist/hook.js +2 -2
- package/dist/install.js +8 -0
- package/dist/ledger.js +4 -1
- package/dist/mcp.js +126 -62
- package/dist/test/doctor.test.d.ts +2 -0
- package/dist/test/doctor.test.js +19 -0
- package/dist/test/mcp-http.test.d.ts +6 -0
- package/dist/test/mcp-http.test.js +63 -0
- package/dist/test/watch.test.d.ts +2 -0
- package/dist/test/watch.test.js +36 -0
- package/dist/watch.d.ts +15 -0
- package/dist/watch.js +62 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# context-doctor ๐ฉบ
|
|
2
2
|
|
|
3
|
+
[](https://github.com/KushalP1/context-doctor/actions) [](https://www.npmjs.com/package/context-doctor)
|
|
4
|
+
|
|
3
5
|
**See what's eating your LLM context window โ and fix it.**
|
|
4
6
|
|
|
5
7
|
Every long-running LLM conversation slowly fills up with junk: duplicated documents, 10k-token tool outputs nobody reads again, base64 blobs, stale history. You pay for those tokens on **every single call**, and model quality drops as the window fills.
|
|
@@ -93,7 +95,10 @@ Practical upshot: a developer who only wants cheaper, faster API calls never tou
|
|
|
93
95
|
| `context-doctor session [file]` | Profile a Claude Code session transcript (defaults to your most recent; `--list` to browse) |
|
|
94
96
|
| `context-doctor report` | Machine-wide impact report: exact proxy savings, hook activity, recoverable waste in recent sessions |
|
|
95
97
|
| `context-doctor proxy` | Always-on local proxy that optimizes every Anthropic/OpenAI API request in flight (`/stats` for cumulative savings) |
|
|
96
|
-
| `context-doctor
|
|
98
|
+
| `context-doctor watch [file]` | Live monitor of a growing session/agent trace: token/cost line per change, findings as they appear |
|
|
99
|
+
| `context-doctor doctor` | Self-check the whole installation โ one pasteable โ/โ diagnosis with fixes |
|
|
100
|
+
| `context-doctor hook` | The every-prompt Claude Code hook (registered by `install`; you never run this yourself). Warning threshold tunable via `CONTEXT_DOCTOR_WARN_TOKENS` (default 80000) |
|
|
101
|
+
| `context-doctor-mcp` | The MCP server itself โ stdio by default (what the installer wires); `--http [--port 8808] [--host H]` serves streamable HTTP at `/mcp` for URL-based clients like ChatGPT developer-mode connectors |
|
|
97
102
|
|
|
98
103
|
## What "always-on" means, per surface
|
|
99
104
|
|
|
@@ -175,7 +180,7 @@ Because prompt caching matches byte-identical prefixes, deterministic strategies
|
|
|
175
180
|
| Claude Desktop | `npx context-doctor install` writes the config โ just restart the app |
|
|
176
181
|
| Claude Code | Same command โ MCP + skill + every-prompt hook, all automatic |
|
|
177
182
|
| Cursor | Same command โ writes `~/.cursor/mcp.json` |
|
|
178
|
-
| ChatGPT
|
|
183
|
+
| ChatGPT (developer mode) | **Manual + a reachable URL** โ ChatGPT connects to servers over the internet, never local commands. Run `context-doctor-mcp --http` on a host/tunnel, then add the URL as a connector. Normal ChatGPT (no dev mode) has no MCP โ use the CLI |
|
|
179
184
|
|
|
180
185
|
For any other MCP client, the server entry is:
|
|
181
186
|
|
|
@@ -197,14 +202,15 @@ For any other MCP client, the server entry is:
|
|
|
197
202
|
3. Chat normally. When a conversation grows heavy, Claude proactively offers: *"this chat is getting large โ want me to profile it?"* โ or you ask *"what's eating my context?"* and it calls `profile_context` and shows the token/cost breakdown.
|
|
198
203
|
4. Say *"optimize it"* and Claude applies the safe fixes; if you agree to pruning old history, **Claude itself writes the replacement summary** (that's the no-API-key summarization).
|
|
199
204
|
|
|
200
|
-
### How it works in ChatGPT, step by step
|
|
205
|
+
### How it works in ChatGPT, step by step (honest version)
|
|
206
|
+
|
|
207
|
+
ChatGPT's MCP support differs fundamentally from Claude Desktop's: **it never spawns local processes**. Its custom connectors (developer mode) have OpenAI's servers connect to a **URL** โ so the MCP server must be reachable from the internet.
|
|
201
208
|
|
|
202
|
-
1. ChatGPT
|
|
203
|
-
2.
|
|
204
|
-
3.
|
|
205
|
-
4. Caveat: how prominently standing server instructions surface varies by ChatGPT version โ the tool descriptions carry the trigger rules regardless, so profiling still fires on the right questions.
|
|
209
|
+
1. **Normal ChatGPT (no developer mode): no MCP at all.** context-doctor still helps via the CLI: export the conversation and run `npx context-doctor analyze chat.json --model gpt-5` / `optimize` โ no account settings required.
|
|
210
|
+
2. **ChatGPT developer mode**: run our HTTP transport somewhere reachable โ `context-doctor-mcp --http --port 8808` on a small host (bind `--host 0.0.0.0` there), or expose your machine temporarily with a tunnel (`ngrok http 8808`). Then Settings โ Connectors โ Advanced โ Developer mode โ add connector with URL `https://<your-host>/mcp`.
|
|
211
|
+
3. Once connected, GPT gets the same three tools with the same trigger guidance: ask *"what's eating my context?"* โ it calls `profile_context`; *"optimize it"* works the same, including GPT writing the pruning summary itself.
|
|
206
212
|
|
|
207
|
-
|
|
213
|
+
Security note for step 2: the HTTP endpoint is unauthenticated โ put it behind your tunnel's auth or a reverse proxy if it stays up long-term.
|
|
208
214
|
|
|
209
215
|
### claude.ai on the web
|
|
210
216
|
|
|
@@ -292,12 +298,7 @@ Exact counts require each provider's private tokenizer. `context-doctor` uses a
|
|
|
292
298
|
|
|
293
299
|
## Roadmap
|
|
294
300
|
|
|
295
|
-
|
|
296
|
-
- [x] ~~LLM summarization for prune-history~~ (host-model summarization via MCP โ no key needed)
|
|
297
|
-
- [ ] Proxy: per-route strategy config + response token accounting
|
|
298
|
-
- [ ] `context-doctor watch` โ live profiling of a running agent's JSONL trace
|
|
299
|
-
- [ ] Exact tokenizer adapters (tiktoken, Anthropic count-tokens API) as optional plugins
|
|
300
|
-
- [ ] Cursor / ChatGPT-export transcript formats for `session`
|
|
301
|
+
See [ROADMAP.md](./ROADMAP.md) for the full plan with rationale. Headlines: **v0.5** trust & automation (tag-based publishing, `doctor` self-check, live `watch`), **v0.6** accuracy (exact tokenizers, semantic dedupe, more session formats), **v0.7** proxy pro (response accounting, prompt-cache advisor), **v1.0** budgets + local dashboard. Non-goals, permanently: cloud services, telemetry, silent history rewriting, mandatory API keys.
|
|
301
302
|
|
|
302
303
|
Contributions welcome โ this project is small on purpose. Open an issue before a big PR.
|
|
303
304
|
|
package/dist/cli.js
CHANGED
|
@@ -20,6 +20,8 @@ import { listSessions, parseSessionFile } from "./session.js";
|
|
|
20
20
|
import { runHook } from "./hook.js";
|
|
21
21
|
import { buildImpactReport } from "./impact.js";
|
|
22
22
|
import { recordLedger } from "./ledger.js";
|
|
23
|
+
import { runDoctor } from "./doctor.js";
|
|
24
|
+
import { runWatch } from "./watch.js";
|
|
23
25
|
const HELP = `context-doctor โ profile and optimize LLM context windows
|
|
24
26
|
|
|
25
27
|
Usage:
|
|
@@ -36,6 +38,11 @@ Usage:
|
|
|
36
38
|
automatically by \`install\`; reads hook JSON on stdin)
|
|
37
39
|
context-doctor report Impact report: exact proxy savings, hook activity,
|
|
38
40
|
and remaining recoverable waste in recent sessions
|
|
41
|
+
context-doctor doctor Self-check the installation (configs, hook, skill,
|
|
42
|
+
MCP handshake) with one pasteable diagnosis
|
|
43
|
+
context-doctor watch [file] Live-monitor a growing session/agent trace: running
|
|
44
|
+
token/cost line per change, new findings as they appear
|
|
45
|
+
(--interval-ms n, default 2000)
|
|
39
46
|
|
|
40
47
|
Input: a conversation JSON file (OpenAI or Anthropic message format, or a bare
|
|
41
48
|
message array). Use "-" to read from stdin.
|
|
@@ -96,6 +103,9 @@ function parseArgs(argv) {
|
|
|
96
103
|
case "--port":
|
|
97
104
|
args.port = Number(argv[++i]);
|
|
98
105
|
break;
|
|
106
|
+
case "--interval-ms":
|
|
107
|
+
args.intervalMs = Number(argv[++i]);
|
|
108
|
+
break;
|
|
99
109
|
case "--host":
|
|
100
110
|
args.host = argv[++i];
|
|
101
111
|
break;
|
|
@@ -123,6 +133,14 @@ function main() {
|
|
|
123
133
|
void runHook();
|
|
124
134
|
return;
|
|
125
135
|
}
|
|
136
|
+
if (args.command === "watch") {
|
|
137
|
+
runWatch({ file: args.file, intervalMs: args.intervalMs, model: args.model });
|
|
138
|
+
return; // interval keeps the process alive
|
|
139
|
+
}
|
|
140
|
+
if (args.command === "doctor") {
|
|
141
|
+
void runDoctor();
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
126
144
|
if (args.command === "report") {
|
|
127
145
|
void buildImpactReport(args.port).then((r) => console.log(r));
|
|
128
146
|
return;
|
package/dist/doctor.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `context-doctor doctor` โ self-check for a local installation.
|
|
3
|
+
*
|
|
4
|
+
* Verifies every integration point end to end and prints one โ/โ/โ line per
|
|
5
|
+
* check, so "it doesn't work" becomes a single pasteable diagnosis. Always
|
|
6
|
+
* exits 0 โ absence of an app is a note, not a failure.
|
|
7
|
+
*/
|
|
8
|
+
export declare function runDoctor(): Promise<void>;
|
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `context-doctor doctor` โ self-check for a local installation.
|
|
3
|
+
*
|
|
4
|
+
* Verifies every integration point end to end and prints one โ/โ/โ line per
|
|
5
|
+
* check, so "it doesn't work" becomes a single pasteable diagnosis. Always
|
|
6
|
+
* exits 0 โ absence of an app is a note, not a failure.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
10
|
+
import { homedir, platform } from "node:os";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { ledgerPath, recordLedger } from "./ledger.js";
|
|
14
|
+
function claudeDesktopConfigPath() {
|
|
15
|
+
switch (platform()) {
|
|
16
|
+
case "darwin": return join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
17
|
+
case "win32": return join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
|
|
18
|
+
default: return join(homedir(), ".config", "Claude", "claude_desktop_config.json");
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function checkMcpEntry(appName, configPath) {
|
|
22
|
+
if (!existsSync(configPath))
|
|
23
|
+
return { label: appName, status: "skip", detail: "app not detected (config file absent)" };
|
|
24
|
+
try {
|
|
25
|
+
const config = JSON.parse(readFileSync(configPath, "utf8"));
|
|
26
|
+
const entry = config.mcpServers?.["context-doctor"];
|
|
27
|
+
if (!entry)
|
|
28
|
+
return { label: appName, status: "fail", detail: `no context-doctor entry in ${configPath} โ run: context-doctor install` };
|
|
29
|
+
// Absolute-path entries must point at a file that still exists.
|
|
30
|
+
const target = entry.command === "npx" ? null : entry.args?.[0];
|
|
31
|
+
if (target && !existsSync(target)) {
|
|
32
|
+
return { label: appName, status: "fail", detail: `MCP entry points at missing file ${target} โ re-run: context-doctor install` };
|
|
33
|
+
}
|
|
34
|
+
return { label: appName, status: "ok", detail: `MCP wired (${entry.command === "npx" ? "npx, tracks npm releases" : "local build"})` };
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
return { label: appName, status: "fail", detail: `${configPath} is not valid JSON (${e.message})` };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Spawn our own MCP server and run the initialize handshake over stdio. */
|
|
41
|
+
function checkMcpHandshake() {
|
|
42
|
+
const label = "MCP server handshake";
|
|
43
|
+
const mcpPath = join(dirname(fileURLToPath(import.meta.url)), "mcp.js");
|
|
44
|
+
return new Promise((resolve) => {
|
|
45
|
+
const child = spawn(process.execPath, [mcpPath], { stdio: ["pipe", "pipe", "ignore"] });
|
|
46
|
+
const timer = setTimeout(() => {
|
|
47
|
+
child.kill();
|
|
48
|
+
resolve({ label, status: "fail", detail: "no initialize response within 5s" });
|
|
49
|
+
}, 5000);
|
|
50
|
+
let out = "";
|
|
51
|
+
child.stdout.on("data", (d) => {
|
|
52
|
+
out += d.toString();
|
|
53
|
+
if (out.includes("\n")) {
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
child.kill();
|
|
56
|
+
try {
|
|
57
|
+
const reply = JSON.parse(out.split("\n")[0]);
|
|
58
|
+
const version = reply.result?.serverInfo?.version;
|
|
59
|
+
const hasInstructions = typeof reply.result?.instructions === "string" && reply.result.instructions.length > 0;
|
|
60
|
+
resolve(version && hasInstructions
|
|
61
|
+
? { label, status: "ok", detail: `v${version} responds; standing instructions present` }
|
|
62
|
+
: { label, status: "fail", detail: "handshake reply missing serverInfo/instructions" });
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
resolve({ label, status: "fail", detail: "unparseable handshake reply" });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
child.on("error", (e) => {
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
resolve({ label, status: "fail", detail: e.message });
|
|
72
|
+
});
|
|
73
|
+
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "doctor", version: "1" } } }) + "\n");
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
export async function runDoctor() {
|
|
77
|
+
const checks = [];
|
|
78
|
+
checks.push(checkMcpEntry("Claude Desktop", claudeDesktopConfigPath()));
|
|
79
|
+
checks.push(checkMcpEntry("Claude Code", join(homedir(), ".claude.json")));
|
|
80
|
+
checks.push(checkMcpEntry("Cursor", join(homedir(), ".cursor", "mcp.json")));
|
|
81
|
+
// Hook registration
|
|
82
|
+
const settingsPath = join(homedir(), ".claude", "settings.json");
|
|
83
|
+
if (existsSync(settingsPath)) {
|
|
84
|
+
try {
|
|
85
|
+
const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
86
|
+
const registered = JSON.stringify(settings.hooks?.UserPromptSubmit ?? []).includes("context-doctor");
|
|
87
|
+
checks.push(registered
|
|
88
|
+
? { label: "Every-prompt hook", status: "ok", detail: "registered in ~/.claude/settings.json" }
|
|
89
|
+
: { label: "Every-prompt hook", status: "fail", detail: "not registered โ run: context-doctor install" });
|
|
90
|
+
}
|
|
91
|
+
catch (e) {
|
|
92
|
+
checks.push({ label: "Every-prompt hook", status: "fail", detail: `settings.json unreadable (${e.message})` });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
checks.push({ label: "Every-prompt hook", status: "skip", detail: "Claude Code not detected" });
|
|
97
|
+
}
|
|
98
|
+
// Skill
|
|
99
|
+
const skillPath = join(homedir(), ".claude", "skills", "context-doctor", "SKILL.md");
|
|
100
|
+
checks.push(existsSync(skillPath)
|
|
101
|
+
? { label: "Agent Skill", status: "ok", detail: skillPath }
|
|
102
|
+
: { label: "Agent Skill", status: "skip", detail: "not installed (run context-doctor install on a Claude Code machine)" });
|
|
103
|
+
// Ledger writable
|
|
104
|
+
try {
|
|
105
|
+
recordLedger({ ev: "check", sid: "doctor-probe", tok: 0, warn: false });
|
|
106
|
+
checks.push({ label: "Ledger", status: "ok", detail: `writable at ${ledgerPath()}` });
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
checks.push({ label: "Ledger", status: "fail", detail: `cannot write ${ledgerPath()}` });
|
|
110
|
+
}
|
|
111
|
+
checks.push(await checkMcpHandshake());
|
|
112
|
+
const mark = { ok: "โ", fail: "โ", skip: "โ" };
|
|
113
|
+
console.log("CONTEXT DOCTOR โ self-check");
|
|
114
|
+
console.log("โ".repeat(56));
|
|
115
|
+
for (const c of checks) {
|
|
116
|
+
console.log(`${mark[c.status]} ${c.label.padEnd(22)} ${c.detail}`);
|
|
117
|
+
}
|
|
118
|
+
const fails = checks.filter((c) => c.status === "fail");
|
|
119
|
+
console.log("");
|
|
120
|
+
console.log(fails.length === 0 ? "All good." : `${fails.length} issue(s) found โ fixes suggested above.`);
|
|
121
|
+
}
|
package/dist/hook.js
CHANGED
|
@@ -17,8 +17,8 @@ import { profileConversation } from "./profile.js";
|
|
|
17
17
|
import { parseSessionFile } from "./session.js";
|
|
18
18
|
import { formatTokens } from "./tokens.js";
|
|
19
19
|
import { formatUsd } from "./pricing.js";
|
|
20
|
-
/** Start nudging at 80k tokens of context. */
|
|
21
|
-
const WARN_TOKENS = 80_000;
|
|
20
|
+
/** Start nudging at 80k tokens of context (override: CONTEXT_DOCTOR_WARN_TOKENS). */
|
|
21
|
+
const WARN_TOKENS = Number(process.env.CONTEXT_DOCTOR_WARN_TOKENS) > 0 ? Number(process.env.CONTEXT_DOCTOR_WARN_TOKENS) : 80_000;
|
|
22
22
|
/** Re-nudge only after the context grows another 40% โ one reminder, not a nag. */
|
|
23
23
|
const REGROWTH_FACTOR = 1.4;
|
|
24
24
|
/**
|
package/dist/install.js
CHANGED
|
@@ -167,5 +167,13 @@ export function runUninstall() {
|
|
|
167
167
|
console.log("โ Agent Skill removed");
|
|
168
168
|
}
|
|
169
169
|
uninstallHook();
|
|
170
|
+
// Remove our bookkeeping files too โ uninstall means gone.
|
|
171
|
+
for (const file of [".context-doctor-hook-state.json", ".context-doctor-ledger.jsonl"]) {
|
|
172
|
+
const p = join(homedir(), ".claude", file);
|
|
173
|
+
if (existsSync(p)) {
|
|
174
|
+
rmSync(p);
|
|
175
|
+
console.log(`โ Removed ${file}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
170
178
|
console.log("Done.");
|
|
171
179
|
}
|
package/dist/ledger.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* (pre-0.3.6 hook entries have no `ev` field; treated as checks)
|
|
9
9
|
* optimize โ an optimization was applied {ev: "optimize", src: "cli"|"mcp", saved, model?}
|
|
10
10
|
*/
|
|
11
|
-
import { appendFileSync, existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
13
|
import { dirname, join } from "node:path";
|
|
14
14
|
export function statePath() {
|
|
@@ -20,6 +20,9 @@ export function ledgerPath() {
|
|
|
20
20
|
export function recordLedger(entry) {
|
|
21
21
|
const path = ledgerPath();
|
|
22
22
|
try {
|
|
23
|
+
// Claude-Desktop-only machines have no ~/.claude โ create it so their
|
|
24
|
+
// optimize events count in `context-doctor report` too.
|
|
25
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
23
26
|
// Cap growth: past ~256KB keep the most recent 500 entries.
|
|
24
27
|
if (existsSync(path) && statSync(path).size > 256 * 1024) {
|
|
25
28
|
const lines = readFileSync(path, "utf8").trimEnd().split("\n");
|
package/dist/mcp.js
CHANGED
|
@@ -30,63 +30,77 @@ import { recordLedger } from "./ledger.js";
|
|
|
30
30
|
// Kept deliberately terse: these ride in EVERY conversation's context, and a
|
|
31
31
|
// context-saving tool must not itself be context overhead (~110 tokens).
|
|
32
32
|
const SERVER_INSTRUCTIONS = `Context hygiene, always: summarize large pastes/tool results instead of carrying them verbatim; reference earlier content, don't re-quote; never inline base64. Past ~30 turns or several large pastes, proactively offer to run profile_context. Any question about tokens, cost, or latency: call profile_context, don't estimate. If optimize_context returns a pruned-turns digest, you write the โค150-token replacement summary.`;
|
|
33
|
-
const server = new McpServer({ name: "context-doctor", version: "0.3.6" }, { instructions: SERVER_INSTRUCTIONS });
|
|
34
33
|
const STRATEGY_IDS = ["dedupe", "trim-tool-results", "strip-base64", "prune-history"];
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
});
|
|
42
|
-
server.tool("
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}, async ({ conversation, strategies, keep_recent, max_tool_result_tokens }) => {
|
|
49
|
-
const result = optimizeConversation(conversation, {
|
|
50
|
-
strategies: strategies,
|
|
51
|
-
keepRecent: keep_recent,
|
|
52
|
-
maxToolResultTokens: max_tool_result_tokens,
|
|
34
|
+
/**
|
|
35
|
+
* Build a fully-configured server instance. A factory (not a singleton) so the
|
|
36
|
+
* stateless HTTP mode can hand every request its own server, per the MCP SDK's
|
|
37
|
+
* recommended pattern.
|
|
38
|
+
*/
|
|
39
|
+
function createServer() {
|
|
40
|
+
const server = new McpServer({ name: "context-doctor", version: "0.5.0" }, { instructions: SERVER_INSTRUCTIONS });
|
|
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
|
+
conversation: z.string().describe("Conversation JSON (OpenAI or Anthropic format, or bare message array) or raw prompt text"),
|
|
43
|
+
model: z.string().optional().describe("Target model name for context-window math, e.g. claude-sonnet-5 or gpt-4o"),
|
|
44
|
+
}, async ({ conversation, model }) => {
|
|
45
|
+
const profile = profileConversation(parseConversation(conversation), model);
|
|
46
|
+
return { content: [{ type: "text", text: renderProfile(profile) }] };
|
|
53
47
|
});
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const conversationJson = JSON.stringify(result.conversation);
|
|
66
|
-
const content = [
|
|
67
|
-
{ type: "text", text: summary },
|
|
68
|
-
conversationJson.length <= ECHO_CAP_CHARS
|
|
69
|
-
? { type: "text", text: conversationJson }
|
|
70
|
-
: {
|
|
71
|
-
type: "text",
|
|
72
|
-
text: `[optimized conversation is ${conversationJson.length} chars โ too large to echo into this context. ` +
|
|
73
|
-
`Tell the user the savings above and that \`npx context-doctor optimize <file> --out slim.json\` produces the file directly.]`,
|
|
74
|
-
},
|
|
75
|
-
];
|
|
76
|
-
// Host-model summarization: instead of calling an LLM ourselves (which would
|
|
77
|
-
// need an API key), hand the pruned material to the model that invoked this
|
|
78
|
-
// tool and ask IT to write the summary.
|
|
79
|
-
if (result.prunedDigest) {
|
|
80
|
-
content.push({
|
|
81
|
-
type: "text",
|
|
82
|
-
text: "ACTION REQUIRED (you, the assistant calling this tool): the pruned turns are digested below. " +
|
|
83
|
-
"Write a dense factual summary of them (โค150 tokens: decisions, current state, open items, key identifiers) " +
|
|
84
|
-
"and replace the '[context-doctor: ... pruned]' stub message in the conversation above with your summary " +
|
|
85
|
-
"before presenting the result.\n\nPRUNED TURNS DIGEST:\n" + result.prunedDigest,
|
|
48
|
+
server.tool("optimize_context", "Rewrite a conversation to reclaim tokens using deterministic strategies: dedupe repeated content, trim stale tool results, strip base64 blobs, optionally prune old history. Returns the slimmed conversation JSON plus a savings summary. No LLM calls โ safe and inspectable. Call this after profile_context finds recoverable waste and the user wants it fixed; add the prune-history strategy only with the user's consent, then write the replacement summary yourself as the result instructs.", {
|
|
49
|
+
conversation: z.string().describe("Conversation JSON (OpenAI or Anthropic format, or bare message array)"),
|
|
50
|
+
strategies: z.array(z.enum(STRATEGY_IDS)).optional()
|
|
51
|
+
.describe("Strategies to apply. Default: dedupe, trim-tool-results, strip-base64. Add prune-history for lossy compaction of old turns."),
|
|
52
|
+
keep_recent: z.number().int().positive().optional().describe("Messages at the tail to leave untouched (default 6)"),
|
|
53
|
+
max_tool_result_tokens: z.number().int().positive().optional().describe("Token budget for trimmed tool results (default 300)"),
|
|
54
|
+
}, async ({ conversation, strategies, keep_recent, max_tool_result_tokens }) => {
|
|
55
|
+
const result = optimizeConversation(conversation, {
|
|
56
|
+
strategies: strategies,
|
|
57
|
+
keepRecent: keep_recent,
|
|
58
|
+
maxToolResultTokens: max_tool_result_tokens,
|
|
86
59
|
});
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
});
|
|
60
|
+
const saved = result.tokensBefore - result.tokensAfter;
|
|
61
|
+
if (saved > 0) {
|
|
62
|
+
recordLedger({ ev: "optimize", src: "mcp", saved, model: result.conversation?.model });
|
|
63
|
+
}
|
|
64
|
+
const summary = `Saved ~${formatTokens(saved)} tokens (${formatTokens(result.tokensBefore)} โ ${formatTokens(result.tokensAfter)}) ` +
|
|
65
|
+
`via ${result.applied.length} change(s):\n` +
|
|
66
|
+
result.applied.map((c) => `- [${c.strategy}] message #${c.messageIndex}: ${c.note} (~${formatTokens(c.tokensSaved)})`).join("\n");
|
|
67
|
+
// Echoing a huge optimized conversation back inline would flood the very
|
|
68
|
+
// context this tool exists to save. Above the cap, return the summary and
|
|
69
|
+
// point at the CLI (compact JSON keeps mid-size results affordable).
|
|
70
|
+
const ECHO_CAP_CHARS = 100_000;
|
|
71
|
+
const conversationJson = JSON.stringify(result.conversation);
|
|
72
|
+
const content = [
|
|
73
|
+
{ type: "text", text: summary },
|
|
74
|
+
conversationJson.length <= ECHO_CAP_CHARS
|
|
75
|
+
? { type: "text", text: conversationJson }
|
|
76
|
+
: {
|
|
77
|
+
type: "text",
|
|
78
|
+
text: `[optimized conversation is ${conversationJson.length} chars โ too large to echo into this context. ` +
|
|
79
|
+
`Tell the user the savings above and that \`npx context-doctor optimize <file> --out slim.json\` produces the file directly.]`,
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
// Host-model summarization: instead of calling an LLM ourselves (which would
|
|
83
|
+
// need an API key), hand the pruned material to the model that invoked this
|
|
84
|
+
// tool and ask IT to write the summary.
|
|
85
|
+
if (result.prunedDigest) {
|
|
86
|
+
content.push({
|
|
87
|
+
type: "text",
|
|
88
|
+
text: "ACTION REQUIRED (you, the assistant calling this tool): the pruned turns are digested below. " +
|
|
89
|
+
"Write a dense factual summary of them (โค150 tokens: decisions, current state, open items, key identifiers) " +
|
|
90
|
+
"and replace the '[context-doctor: ... pruned]' stub message in the conversation above with your summary " +
|
|
91
|
+
"before presenting the result.\n\nPRUNED TURNS DIGEST:\n" + result.prunedDigest,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return { content };
|
|
95
|
+
});
|
|
96
|
+
server.tool("context_best_practices", "Get a curated checklist of context-management best practices, optionally specialized for a provider (anthropic, openai).", {
|
|
97
|
+
provider: z.enum(["general", "anthropic", "openai"]).optional().describe("Provider to specialize tips for (default: general)"),
|
|
98
|
+
}, async ({ provider }) => {
|
|
99
|
+
const tips = [...BEST_PRACTICES.general, ...(provider && provider !== "general" ? BEST_PRACTICES[provider] : [])];
|
|
100
|
+
return { content: [{ type: "text", text: tips.map((t, i) => `${i + 1}. ${t}`).join("\n") }] };
|
|
101
|
+
});
|
|
102
|
+
return server;
|
|
103
|
+
}
|
|
90
104
|
const BEST_PRACTICES = {
|
|
91
105
|
general: [
|
|
92
106
|
"Put stable content first (system prompt, tool definitions, reference docs) and volatile content last โ prompt caches match byte-identical prefixes only.",
|
|
@@ -106,11 +120,61 @@ const BEST_PRACTICES = {
|
|
|
106
120
|
"Use max_completion_tokens headroom math: input + output must fit the window together.",
|
|
107
121
|
],
|
|
108
122
|
};
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
123
|
+
// -- Transport dispatch --------------------------------------------------------
|
|
124
|
+
// Default: stdio (Claude Desktop, Claude Code, Cursor spawn us as a child).
|
|
125
|
+
// --http [--port N] [--host H]: streamable-HTTP endpoint at /mcp for clients
|
|
126
|
+
// that connect to a URL instead of spawning a process โ ChatGPT developer-mode
|
|
127
|
+
// connectors (which require a reachable URL), web MCP clients, remote setups.
|
|
128
|
+
const argv = process.argv.slice(2);
|
|
129
|
+
if (argv.includes("--http")) {
|
|
130
|
+
const argAfter = (flag) => {
|
|
131
|
+
const i = argv.indexOf(flag);
|
|
132
|
+
return i >= 0 ? argv[i + 1] : undefined;
|
|
133
|
+
};
|
|
134
|
+
const port = Number(argAfter("--port")) > 0 ? Number(argAfter("--port")) : 8808;
|
|
135
|
+
const host = argAfter("--host") ?? "127.0.0.1";
|
|
136
|
+
const { createServer: createHttpServer } = await import("node:http");
|
|
137
|
+
const { StreamableHTTPServerTransport } = await import("@modelcontextprotocol/sdk/server/streamableHttp.js");
|
|
138
|
+
createHttpServer(async (req, res) => {
|
|
139
|
+
try {
|
|
140
|
+
if (req.url === "/health") {
|
|
141
|
+
res.setHeader("content-type", "application/json");
|
|
142
|
+
res.end(JSON.stringify({ ok: true, service: "context-doctor-mcp" }));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (!(req.url ?? "").startsWith("/mcp")) {
|
|
146
|
+
res.statusCode = 404;
|
|
147
|
+
res.end(JSON.stringify({ error: "MCP endpoint is /mcp" }));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (req.method !== "POST") {
|
|
151
|
+
// Stateless mode: no standalone SSE stream, no sessions to delete.
|
|
152
|
+
res.statusCode = 405;
|
|
153
|
+
res.setHeader("allow", "POST");
|
|
154
|
+
res.end(JSON.stringify({ error: "Stateless server: POST /mcp only" }));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
// Fresh server + transport per request (stateless โ nothing shared).
|
|
158
|
+
const server = createServer();
|
|
159
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
160
|
+
res.on("close", () => {
|
|
161
|
+
void transport.close();
|
|
162
|
+
void server.close();
|
|
163
|
+
});
|
|
164
|
+
await server.connect(transport);
|
|
165
|
+
await transport.handleRequest(req, res);
|
|
166
|
+
}
|
|
167
|
+
catch (e) {
|
|
168
|
+
if (!res.headersSent)
|
|
169
|
+
res.statusCode = 500;
|
|
170
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
171
|
+
}
|
|
172
|
+
}).listen(port, host, () => {
|
|
173
|
+
console.error(`context-doctor MCP (streamable HTTP) on http://${host}:${port}/mcp`);
|
|
174
|
+
console.error(`ChatGPT developer-mode connectors need a URL their servers can reach โ expose this via your host or a tunnel.`);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
const transport = new StdioServerTransport();
|
|
179
|
+
await createServer().connect(transport);
|
|
180
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** doctor must always produce a diagnosis and exit 0, even on a bare machine. */
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { mkdtempSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join, dirname } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
const cliPath = join(dirname(fileURLToPath(import.meta.url)), "..", "cli.js");
|
|
10
|
+
test("doctor runs, checks the MCP handshake, and exits 0", async () => {
|
|
11
|
+
const stateDir = mkdtempSync(join(tmpdir(), "ctxdoc-doctor-"));
|
|
12
|
+
const out = await new Promise((resolve, reject) => {
|
|
13
|
+
execFile(process.execPath, [cliPath, "doctor"], { env: { ...process.env, CONTEXT_DOCTOR_HOOK_STATE: join(stateDir, "state.json") }, timeout: 20000 }, (err, stdout) => (err ? reject(err) : resolve(stdout)));
|
|
14
|
+
});
|
|
15
|
+
assert.ok(out.includes("CONTEXT DOCTOR โ self-check"));
|
|
16
|
+
assert.ok(out.includes("MCP server handshake"));
|
|
17
|
+
assert.ok(/โ MCP server handshake/.test(out), "our own server must pass its own handshake");
|
|
18
|
+
assert.ok(out.includes("Ledger"));
|
|
19
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP streamable-HTTP transport: spawn `mcp.js --http`, run the initialize
|
|
3
|
+
* handshake and a tool call over plain HTTP, exactly as a URL-based client
|
|
4
|
+
* (e.g. a ChatGPT developer-mode connector) would.
|
|
5
|
+
*/
|
|
6
|
+
import { test, after } from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { join, dirname } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
const mcpPath = join(dirname(fileURLToPath(import.meta.url)), "..", "mcp.js");
|
|
12
|
+
const PORT = 8898;
|
|
13
|
+
const child = spawn(process.execPath, [mcpPath, "--http", "--port", String(PORT)], { stdio: ["ignore", "ignore", "pipe"] });
|
|
14
|
+
await new Promise((resolve, reject) => {
|
|
15
|
+
const timer = setTimeout(() => reject(new Error("HTTP MCP server did not start")), 8000);
|
|
16
|
+
child.stderr.on("data", (d) => {
|
|
17
|
+
if (d.toString().includes("streamable HTTP")) {
|
|
18
|
+
clearTimeout(timer);
|
|
19
|
+
resolve();
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
after(() => child.kill());
|
|
24
|
+
async function rpc(body) {
|
|
25
|
+
const res = await fetch(`http://127.0.0.1:${PORT}/mcp`, {
|
|
26
|
+
method: "POST",
|
|
27
|
+
headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
|
|
28
|
+
body: JSON.stringify(body),
|
|
29
|
+
});
|
|
30
|
+
const text = await res.text();
|
|
31
|
+
// Streamable HTTP may answer as SSE ("data: {...}") or plain JSON.
|
|
32
|
+
const dataLine = text.split("\n").find((l) => l.startsWith("data: "));
|
|
33
|
+
return { status: res.status, json: JSON.parse(dataLine ? dataLine.slice(6) : text) };
|
|
34
|
+
}
|
|
35
|
+
test("initialize over HTTP returns server info + instructions", async () => {
|
|
36
|
+
const { status, json } = await rpc({
|
|
37
|
+
jsonrpc: "2.0",
|
|
38
|
+
id: 1,
|
|
39
|
+
method: "initialize",
|
|
40
|
+
params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "t", version: "1" } },
|
|
41
|
+
});
|
|
42
|
+
assert.equal(status, 200);
|
|
43
|
+
assert.equal(json.result.serverInfo.name, "context-doctor");
|
|
44
|
+
assert.ok(json.result.instructions.includes("Context hygiene"));
|
|
45
|
+
});
|
|
46
|
+
test("tools/call works statelessly over HTTP", async () => {
|
|
47
|
+
const { json } = await rpc({
|
|
48
|
+
jsonrpc: "2.0",
|
|
49
|
+
id: 2,
|
|
50
|
+
method: "tools/call",
|
|
51
|
+
params: {
|
|
52
|
+
name: "profile_context",
|
|
53
|
+
arguments: { conversation: JSON.stringify({ messages: [{ role: "user", content: "hello world" }] }) },
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
assert.ok(json.result.content[0].text.includes("CONTEXT DOCTOR"));
|
|
57
|
+
});
|
|
58
|
+
test("health endpoint responds; non-POST is rejected", async () => {
|
|
59
|
+
const health = (await (await fetch(`http://127.0.0.1:${PORT}/health`)).json());
|
|
60
|
+
assert.equal(health.ok, true);
|
|
61
|
+
const get = await fetch(`http://127.0.0.1:${PORT}/mcp`);
|
|
62
|
+
assert.equal(get.status, 405);
|
|
63
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** watch: emits a status line on growth, surfaces new findings once. */
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { appendFileSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join, dirname } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
const cliPath = join(dirname(fileURLToPath(import.meta.url)), "..", "cli.js");
|
|
10
|
+
function line(role, content) {
|
|
11
|
+
return JSON.stringify({ type: role, message: { role, content } }) + "\n";
|
|
12
|
+
}
|
|
13
|
+
test("watch reports growth and new findings live", async () => {
|
|
14
|
+
const dir = mkdtempSync(join(tmpdir(), "ctxdoc-watch-"));
|
|
15
|
+
const file = join(dir, "trace.jsonl");
|
|
16
|
+
writeFileSync(file, line("user", "hello there"));
|
|
17
|
+
const child = spawn(process.execPath, [cliPath, "watch", file, "--interval-ms", "150"], { stdio: ["ignore", "pipe", "pipe"] });
|
|
18
|
+
let out = "";
|
|
19
|
+
child.stdout.on("data", (d) => (out += d.toString()));
|
|
20
|
+
try {
|
|
21
|
+
// First tick: initial line.
|
|
22
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
23
|
+
assert.ok(/tokens/.test(out), `initial status line expected, got: ${out}`);
|
|
24
|
+
// Grow the file with an oversized tool result โ new status + a finding.
|
|
25
|
+
appendFileSync(file, line("assistant", JSON.stringify([{ type: "tool_use", id: "t1", name: "search", input: {} }])) +
|
|
26
|
+
JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: "data ".repeat(3000) }] } }) +
|
|
27
|
+
"\n");
|
|
28
|
+
await new Promise((r) => setTimeout(r, 700));
|
|
29
|
+
const statusLines = out.split("\n").filter((l) => l.includes("tokens"));
|
|
30
|
+
assert.ok(statusLines.length >= 2, `expected a second status line after growth: ${out}`);
|
|
31
|
+
assert.ok(out.includes("โ "), `expected a finding to surface: ${out}`);
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
child.kill();
|
|
35
|
+
}
|
|
36
|
+
});
|
package/dist/watch.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `context-doctor watch [file]` โ live monitor for a growing session/agent
|
|
3
|
+
* trace. Polls the file (default: your most recent Claude Code session) and
|
|
4
|
+
* on growth re-profiles, printing one status line per change plus any NEW
|
|
5
|
+
* findings as they appear. Ctrl-C to stop.
|
|
6
|
+
*
|
|
7
|
+
* Polling (not fs.watch) is deliberate: editors/agents rewrite files in ways
|
|
8
|
+
* that break watchers cross-platform, and a 2s stat is effectively free.
|
|
9
|
+
*/
|
|
10
|
+
export interface WatchOptions {
|
|
11
|
+
file?: string;
|
|
12
|
+
intervalMs?: number;
|
|
13
|
+
model?: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function runWatch(opts: WatchOptions): void;
|
package/dist/watch.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `context-doctor watch [file]` โ live monitor for a growing session/agent
|
|
3
|
+
* trace. Polls the file (default: your most recent Claude Code session) and
|
|
4
|
+
* on growth re-profiles, printing one status line per change plus any NEW
|
|
5
|
+
* findings as they appear. Ctrl-C to stop.
|
|
6
|
+
*
|
|
7
|
+
* Polling (not fs.watch) is deliberate: editors/agents rewrite files in ways
|
|
8
|
+
* that break watchers cross-platform, and a 2s stat is effectively free.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, statSync } from "node:fs";
|
|
11
|
+
import { listSessions, parseSessionFile } from "./session.js";
|
|
12
|
+
import { parseConversation } from "./parse.js";
|
|
13
|
+
import { profileConversation } from "./profile.js";
|
|
14
|
+
import { formatTokens } from "./tokens.js";
|
|
15
|
+
import { formatUsd } from "./pricing.js";
|
|
16
|
+
export function runWatch(opts) {
|
|
17
|
+
const file = opts.file ?? listSessions(1)[0]?.path;
|
|
18
|
+
if (!file || !existsSync(file)) {
|
|
19
|
+
console.error("No transcript to watch. Pass a .jsonl file or run where Claude Code sessions exist.");
|
|
20
|
+
process.exitCode = 1;
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const intervalMs = opts.intervalMs ?? 2000;
|
|
24
|
+
let lastSize = -1;
|
|
25
|
+
let lastTokens = 0;
|
|
26
|
+
const seenFindings = new Set();
|
|
27
|
+
console.error(`Watching ${file} (every ${intervalMs / 1000}s; Ctrl-C to stop)`);
|
|
28
|
+
const tick = () => {
|
|
29
|
+
try {
|
|
30
|
+
const size = statSync(file).size;
|
|
31
|
+
if (size === lastSize)
|
|
32
|
+
return; // nothing new โ cost of this tick was one stat
|
|
33
|
+
lastSize = size;
|
|
34
|
+
const parsed = parseSessionFile(file);
|
|
35
|
+
if (parsed.messageCount === 0)
|
|
36
|
+
return;
|
|
37
|
+
const profile = profileConversation(parseConversation(parsed.conversationJson), opts.model ?? parsed.model);
|
|
38
|
+
const delta = profile.totalTokens - lastTokens;
|
|
39
|
+
lastTokens = profile.totalTokens;
|
|
40
|
+
const cost = profile.cost ? ` ยท ${formatUsd(profile.cost.perCallUsd)}/msg` : "";
|
|
41
|
+
const pct = profile.usagePct !== undefined ? ` ยท ${profile.usagePct.toFixed(1)}% of window` : "";
|
|
42
|
+
console.log(`[${new Date().toISOString().slice(11, 19)}] ~${formatTokens(profile.totalTokens)} tokens` +
|
|
43
|
+
(delta !== 0 ? ` (${delta > 0 ? "+" : ""}${formatTokens(Math.abs(delta)) === "0" ? delta : (delta > 0 ? "" : "-") + formatTokens(Math.abs(delta))})` : "") +
|
|
44
|
+
`${pct}${cost} ยท ${profile.messageCount} messages`);
|
|
45
|
+
// Surface each finding once, when it first appears.
|
|
46
|
+
for (const f of profile.findings) {
|
|
47
|
+
if (f.estSavings === 0)
|
|
48
|
+
continue;
|
|
49
|
+
const key = `${f.id}:${f.messages.join(",")}`;
|
|
50
|
+
if (seenFindings.has(key))
|
|
51
|
+
continue;
|
|
52
|
+
seenFindings.add(key);
|
|
53
|
+
console.log(` โ ${f.message} [save ~${formatTokens(f.estSavings)}]`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
/* transient read race โ try again next tick */
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
tick();
|
|
61
|
+
setInterval(tick, intervalMs);
|
|
62
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "context-doctor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"build": "tsc && node -e \"const fs=require('fs');['dist/cli.js','dist/mcp.js'].forEach(f=>fs.chmodSync(f,0o755))\"",
|
|
42
42
|
"prepublishOnly": "npm run build",
|
|
43
43
|
"dev": "tsc --watch",
|
|
44
|
-
"test": "npm run build && node --test dist/test/smoke.test.js dist/test/proxy.test.js dist/test/hook.test.js"
|
|
44
|
+
"test": "npm run build && node --test dist/test/smoke.test.js dist/test/proxy.test.js dist/test/hook.test.js dist/test/mcp-http.test.js dist/test/doctor.test.js dist/test/watch.test.js"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@modelcontextprotocol/sdk": "^1.0.0",
|