context-doctor 0.3.5 → 0.4.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 +38 -8
- package/dist/cli.js +11 -0
- package/dist/hook.js +4 -7
- package/dist/impact.d.ts +11 -0
- package/dist/impact.js +147 -0
- package/dist/install.js +8 -0
- package/dist/ledger.d.ts +24 -0
- package/dist/ledger.js +57 -0
- package/dist/mcp.js +127 -59
- package/dist/test/mcp-http.test.d.ts +6 -0
- package/dist/test/mcp-http.test.js +63 -0
- package/package.json +3 -3
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.
|
|
@@ -64,7 +66,7 @@ One run of `npx context-doctor install` writes five things (each config edit mak
|
|
|
64
66
|
**In every Claude Code / Cowork session afterward:** all of the above via MCP, plus two more layers:
|
|
65
67
|
|
|
66
68
|
- the **skill** loads whenever context work is relevant, and
|
|
67
|
-
- the **hook runs on every single prompt you send**:
|
|
69
|
+
- the **hook runs on every single prompt you send**: lean sessions cost a ~1ms file-size check; once a session is heavy it profiles on growth events and injects a note the model sees with your message — actual token count, cost per message, the single largest recoverable waste — with instructions to work leaner and offer you compaction. It re-fires only after ~40% further growth, can never break a prompt (any failure exits silently), and logs each deep check to a small local ledger that feeds `context-doctor report`.
|
|
68
70
|
|
|
69
71
|
**What it never does:** delete or rewrite your history without asking (pruning is consent-only, and the model writes the replacement summary so nothing is lost silently), send data anywhere (everything runs on your machine), or touch an API key.
|
|
70
72
|
|
|
@@ -83,6 +85,19 @@ MCP is just one of six delivery mechanisms. It's only required when you want the
|
|
|
83
85
|
|
|
84
86
|
Practical upshot: a developer who only wants cheaper, faster API calls never touches MCP (proxy + CLI). A Claude Code user gets the hook and skill without MCP either — the MCP server just adds in-chat tools on top. `install` sets up all of it at once precisely so you don't have to think about which mechanism is which.
|
|
85
87
|
|
|
88
|
+
## All commands at a glance
|
|
89
|
+
|
|
90
|
+
| Command | What it does |
|
|
91
|
+
|---|---|
|
|
92
|
+
| `context-doctor install` / `uninstall` | Wire (or remove) everything: MCP for Claude Desktop/Code/Cursor, the Agent Skill, the every-prompt hook |
|
|
93
|
+
| `context-doctor analyze <file>` | Profile a conversation: token breakdown, findings, cost + latency estimates |
|
|
94
|
+
| `context-doctor optimize <file>` | Apply the safe fixes; `--strategy prune-history` for consented lossy compaction |
|
|
95
|
+
| `context-doctor session [file]` | Profile a Claude Code session transcript (defaults to your most recent; `--list` to browse) |
|
|
96
|
+
| `context-doctor report` | Machine-wide impact report: exact proxy savings, hook activity, recoverable waste in recent sessions |
|
|
97
|
+
| `context-doctor proxy` | Always-on local proxy that optimizes every Anthropic/OpenAI API request in flight (`/stats` for cumulative savings) |
|
|
98
|
+
| `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) |
|
|
99
|
+
| `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 |
|
|
100
|
+
|
|
86
101
|
## What "always-on" means, per surface
|
|
87
102
|
|
|
88
103
|
| Where you run LLMs | Mechanism | Guarantee |
|
|
@@ -163,7 +178,7 @@ Because prompt caching matches byte-identical prefixes, deterministic strategies
|
|
|
163
178
|
| Claude Desktop | `npx context-doctor install` writes the config — just restart the app |
|
|
164
179
|
| Claude Code | Same command — MCP + skill + every-prompt hook, all automatic |
|
|
165
180
|
| Cursor | Same command — writes `~/.cursor/mcp.json` |
|
|
166
|
-
| ChatGPT
|
|
181
|
+
| 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 |
|
|
167
182
|
|
|
168
183
|
For any other MCP client, the server entry is:
|
|
169
184
|
|
|
@@ -185,14 +200,15 @@ For any other MCP client, the server entry is:
|
|
|
185
200
|
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.
|
|
186
201
|
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).
|
|
187
202
|
|
|
188
|
-
### How it works in ChatGPT, step by step
|
|
203
|
+
### How it works in ChatGPT, step by step (honest version)
|
|
204
|
+
|
|
205
|
+
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.
|
|
189
206
|
|
|
190
|
-
1. ChatGPT
|
|
191
|
-
2.
|
|
192
|
-
3.
|
|
193
|
-
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.
|
|
207
|
+
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.
|
|
208
|
+
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`.
|
|
209
|
+
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.
|
|
194
210
|
|
|
195
|
-
|
|
211
|
+
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.
|
|
196
212
|
|
|
197
213
|
### claude.ai on the web
|
|
198
214
|
|
|
@@ -246,6 +262,20 @@ Everything the optimizer does is inspectable: it prints exactly which messages c
|
|
|
246
262
|
|
|
247
263
|
`skills/context-doctor/SKILL.md` (installed by `npx context-doctor install`) teaches Claude to practice context hygiene proactively: summarize big tool results after consuming them, never re-paste duplicated content, keep stable content cache-friendly, and offer compaction when a session gets heavy — so sessions get inherently leaner without you asking.
|
|
248
264
|
|
|
265
|
+
## Measuring the impact: `context-doctor report`
|
|
266
|
+
|
|
267
|
+
```bash
|
|
268
|
+
npx context-doctor report
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
One report for your whole machine, led by a headline of **tokens context-doctor saved**, built only from measured sources:
|
|
272
|
+
|
|
273
|
+
- **exact** proxy savings (real before/after on every request),
|
|
274
|
+
- **exact** savings from every optimization applied via the CLI or the in-chat tools — split by model family (Claude vs GPT), with dollar estimates,
|
|
275
|
+
- **observed per-session shrinkage**: real context reductions recorded between the hook's deep checks after hygiene warnings — shown per session in the table alongside remaining waste.
|
|
276
|
+
|
|
277
|
+
Honest measurement note: proxy numbers are exact. Session numbers are measured-now. What no tool can report is the counterfactual — tokens Claude *avoided* adding because of the hygiene guidance — since the same session can't be re-run without it. The report says so instead of inventing a number.
|
|
278
|
+
|
|
249
279
|
## Performance: what context-doctor itself costs
|
|
250
280
|
|
|
251
281
|
A tool that promises speed must be near-free. Measured overhead per touchpoint:
|
package/dist/cli.js
CHANGED
|
@@ -18,6 +18,8 @@ import { startProxy } from "./proxy.js";
|
|
|
18
18
|
import { runInstall, runUninstall } from "./install.js";
|
|
19
19
|
import { listSessions, parseSessionFile } from "./session.js";
|
|
20
20
|
import { runHook } from "./hook.js";
|
|
21
|
+
import { buildImpactReport } from "./impact.js";
|
|
22
|
+
import { recordLedger } from "./ledger.js";
|
|
21
23
|
const HELP = `context-doctor — profile and optimize LLM context windows
|
|
22
24
|
|
|
23
25
|
Usage:
|
|
@@ -32,6 +34,8 @@ Usage:
|
|
|
32
34
|
(default: the most recent session; --list to browse)
|
|
33
35
|
context-doctor hook Claude Code UserPromptSubmit hook (installed
|
|
34
36
|
automatically by \`install\`; reads hook JSON on stdin)
|
|
37
|
+
context-doctor report Impact report: exact proxy savings, hook activity,
|
|
38
|
+
and remaining recoverable waste in recent sessions
|
|
35
39
|
|
|
36
40
|
Input: a conversation JSON file (OpenAI or Anthropic message format, or a bare
|
|
37
41
|
message array). Use "-" to read from stdin.
|
|
@@ -119,6 +123,10 @@ function main() {
|
|
|
119
123
|
void runHook();
|
|
120
124
|
return;
|
|
121
125
|
}
|
|
126
|
+
if (args.command === "report") {
|
|
127
|
+
void buildImpactReport(args.port).then((r) => console.log(r));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
122
130
|
if (args.command === "session") {
|
|
123
131
|
if (args.list) {
|
|
124
132
|
const sessions = listSessions();
|
|
@@ -215,6 +223,9 @@ function main() {
|
|
|
215
223
|
console.log(output);
|
|
216
224
|
}
|
|
217
225
|
const saved = result.tokensBefore - result.tokensAfter;
|
|
226
|
+
if (saved > 0) {
|
|
227
|
+
recordLedger({ ev: "optimize", src: "cli", saved, model: result.conversation?.model });
|
|
228
|
+
}
|
|
218
229
|
const pct = result.tokensBefore > 0 ? Math.round((saved / result.tokensBefore) * 100) : 0;
|
|
219
230
|
console.error(`\ncontext-doctor: ${formatTokens(result.tokensBefore)} → ${formatTokens(result.tokensAfter)} tokens ` +
|
|
220
231
|
`(saved ~${formatTokens(saved)}, ${pct}%) via ${result.applied.length} change(s)` +
|
package/dist/hook.js
CHANGED
|
@@ -11,15 +11,14 @@
|
|
|
11
11
|
* ~/.claude/settings.json; removed by `context-doctor uninstall`.
|
|
12
12
|
*/
|
|
13
13
|
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
14
|
-
import {
|
|
15
|
-
import { join } from "node:path";
|
|
14
|
+
import { recordLedger, statePath } from "./ledger.js";
|
|
16
15
|
import { parseConversation } from "./parse.js";
|
|
17
16
|
import { profileConversation } from "./profile.js";
|
|
18
17
|
import { parseSessionFile } from "./session.js";
|
|
19
18
|
import { formatTokens } from "./tokens.js";
|
|
20
19
|
import { formatUsd } from "./pricing.js";
|
|
21
|
-
/** Start nudging at 80k tokens of context. */
|
|
22
|
-
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;
|
|
23
22
|
/** Re-nudge only after the context grows another 40% — one reminder, not a nag. */
|
|
24
23
|
const REGROWTH_FACTOR = 1.4;
|
|
25
24
|
/**
|
|
@@ -29,9 +28,6 @@ const REGROWTH_FACTOR = 1.4;
|
|
|
29
28
|
* transcript is never even read.
|
|
30
29
|
*/
|
|
31
30
|
const MIN_BYTES_FOR_WARN = WARN_TOKENS * 4;
|
|
32
|
-
function statePath() {
|
|
33
|
-
return process.env.CONTEXT_DOCTOR_HOOK_STATE ?? join(homedir(), ".claude", ".context-doctor-hook-state.json");
|
|
34
|
-
}
|
|
35
31
|
async function readStdin() {
|
|
36
32
|
const chunks = [];
|
|
37
33
|
for await (const chunk of process.stdin)
|
|
@@ -77,6 +73,7 @@ export async function runHook() {
|
|
|
77
73
|
const nextState = { t: shouldWarn ? profile.totalTokens : prev.t, b: sizeBytes };
|
|
78
74
|
const entries = Object.entries({ ...state, [sessionId]: nextState });
|
|
79
75
|
writeFileSync(statePath(), JSON.stringify(Object.fromEntries(entries.slice(-100))));
|
|
76
|
+
recordLedger({ ev: "check", sid: sessionId.slice(0, 12), tok: profile.totalTokens, warn: shouldWarn });
|
|
80
77
|
if (!shouldWarn)
|
|
81
78
|
return;
|
|
82
79
|
const lines = [
|
package/dist/impact.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `context-doctor report` — one impact report for this machine.
|
|
3
|
+
*
|
|
4
|
+
* Honesty rules baked in: proxy numbers are EXACT (real before/after on every
|
|
5
|
+
* request). Session numbers are MEASURED-NOW (current size + what optimization
|
|
6
|
+
* would still recover). The behavioral counterfactual — what Claude avoided
|
|
7
|
+
* wasting because of hygiene guidance — cannot be measured by anyone: the same
|
|
8
|
+
* session cannot be re-run without it. The report says so instead of inventing
|
|
9
|
+
* a number.
|
|
10
|
+
*/
|
|
11
|
+
export declare function buildImpactReport(proxyPort?: number): Promise<string>;
|
package/dist/impact.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `context-doctor report` — one impact report for this machine.
|
|
3
|
+
*
|
|
4
|
+
* Honesty rules baked in: proxy numbers are EXACT (real before/after on every
|
|
5
|
+
* request). Session numbers are MEASURED-NOW (current size + what optimization
|
|
6
|
+
* would still recover). The behavioral counterfactual — what Claude avoided
|
|
7
|
+
* wasting because of hygiene guidance — cannot be measured by anyone: the same
|
|
8
|
+
* session cannot be re-run without it. The report says so instead of inventing
|
|
9
|
+
* a number.
|
|
10
|
+
*/
|
|
11
|
+
import { readLedger } from "./ledger.js";
|
|
12
|
+
import { listSessions, parseSessionFile } from "./session.js";
|
|
13
|
+
import { parseConversation } from "./parse.js";
|
|
14
|
+
import { profileConversation } from "./profile.js";
|
|
15
|
+
import { formatTokens } from "./tokens.js";
|
|
16
|
+
import { formatUsd, inputCostUsd, pricingFor } from "./pricing.js";
|
|
17
|
+
/** Sessions larger than this are skipped in the report (keeps it snappy). */
|
|
18
|
+
const MAX_SESSION_BYTES = 30 * 1024 * 1024;
|
|
19
|
+
async function fetchProxyStats(port) {
|
|
20
|
+
try {
|
|
21
|
+
const res = await fetch(`http://127.0.0.1:${port}/stats`, { signal: AbortSignal.timeout(500) });
|
|
22
|
+
if (!res.ok)
|
|
23
|
+
return null;
|
|
24
|
+
return (await res.json());
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export async function buildImpactReport(proxyPort = 8787) {
|
|
31
|
+
const lines = [];
|
|
32
|
+
lines.push("CONTEXT DOCTOR — impact report");
|
|
33
|
+
lines.push("═".repeat(56));
|
|
34
|
+
const ledger = readLedger();
|
|
35
|
+
const checks = ledger.filter((e) => e.ev === "check" || e.ev === undefined);
|
|
36
|
+
const optimizes = ledger.filter((e) => e.ev === "optimize");
|
|
37
|
+
// Observed per-session reductions: when a session SHRANK between two deep
|
|
38
|
+
// checks (compaction/cleanup after a warning), that drop is measured fact.
|
|
39
|
+
const bySession = new Map();
|
|
40
|
+
for (const c of checks) {
|
|
41
|
+
if (!c.sid || typeof c.tok !== "number")
|
|
42
|
+
continue;
|
|
43
|
+
const s = bySession.get(c.sid) ?? { toks: [], warns: 0 };
|
|
44
|
+
s.toks.push(c.tok);
|
|
45
|
+
if (c.warn)
|
|
46
|
+
s.warns++;
|
|
47
|
+
bySession.set(c.sid, s);
|
|
48
|
+
}
|
|
49
|
+
const reductionBySession = new Map();
|
|
50
|
+
for (const [sid, s] of bySession) {
|
|
51
|
+
let reduction = 0;
|
|
52
|
+
for (let i = 1; i < s.toks.length; i++) {
|
|
53
|
+
if (s.toks[i] < s.toks[i - 1])
|
|
54
|
+
reduction += s.toks[i - 1] - s.toks[i];
|
|
55
|
+
}
|
|
56
|
+
reductionBySession.set(sid, reduction);
|
|
57
|
+
}
|
|
58
|
+
const totalReduction = [...reductionBySession.values()].reduce((a, b) => a + b, 0);
|
|
59
|
+
// Optimize-event savings, split by model family (claude / gpt / other).
|
|
60
|
+
const optimizeSaved = optimizes.reduce((s, e) => s + (e.saved ?? 0), 0);
|
|
61
|
+
const savedByFamily = new Map();
|
|
62
|
+
let optimizeUsd = 0;
|
|
63
|
+
for (const e of optimizes) {
|
|
64
|
+
const family = /claude/i.test(e.model ?? "") ? "claude" : /gpt|^o\d/i.test(e.model ?? "") ? "gpt" : "other";
|
|
65
|
+
savedByFamily.set(family, (savedByFamily.get(family) ?? 0) + (e.saved ?? 0));
|
|
66
|
+
const pricing = pricingFor(e.model);
|
|
67
|
+
if (pricing && e.saved)
|
|
68
|
+
optimizeUsd += inputCostUsd(e.saved, pricing);
|
|
69
|
+
}
|
|
70
|
+
const proxy = await fetchProxyStats(proxyPort);
|
|
71
|
+
const proxySaved = proxy?.tokensSaved ?? 0;
|
|
72
|
+
// -- Headline: what context-doctor has saved ----------------------------------
|
|
73
|
+
const totalSaved = proxySaved + optimizeSaved + totalReduction;
|
|
74
|
+
lines.push("Tokens context-doctor saved (measured)");
|
|
75
|
+
lines.push("─".repeat(56));
|
|
76
|
+
lines.push(`TOTAL: ~${formatTokens(totalSaved)} tokens`);
|
|
77
|
+
if (proxy) {
|
|
78
|
+
lines.push(` · proxy (exact, current run): ${formatTokens(proxySaved)} across ${proxy.optimizedRequests}/${proxy.requests} requests ≈ ${formatUsd(proxy.estUsdSaved)}`);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
lines.push(` · proxy: not running on :${proxyPort} (its exact savings appear here while it runs)`);
|
|
82
|
+
}
|
|
83
|
+
const familyNote = [...savedByFamily.entries()]
|
|
84
|
+
.filter(([, v]) => v > 0)
|
|
85
|
+
.map(([k, v]) => `${k}: ${formatTokens(v)}`)
|
|
86
|
+
.join(", ");
|
|
87
|
+
lines.push(` · optimizations applied via CLI/chat tools (exact): ${formatTokens(optimizeSaved)} over ${optimizes.length} run(s)` +
|
|
88
|
+
(familyNote ? ` [${familyNote}]` : "") +
|
|
89
|
+
(optimizeUsd > 0 ? ` ≈ ${formatUsd(optimizeUsd)}` : ""));
|
|
90
|
+
lines.push(` · observed session shrinkage after hygiene warnings: ${formatTokens(totalReduction)}`);
|
|
91
|
+
lines.push("");
|
|
92
|
+
// -- Hook activity ------------------------------------------------------------
|
|
93
|
+
lines.push("Hygiene activity (every-prompt hook)");
|
|
94
|
+
lines.push("─".repeat(56));
|
|
95
|
+
if (checks.length > 0) {
|
|
96
|
+
const warnings = checks.filter((e) => e.warn).length;
|
|
97
|
+
lines.push(`${checks.length} deep context checks across ${bySession.size} session(s); ${warnings} warning(s) delivered to the model.`);
|
|
98
|
+
lines.push("(Prompt-level fast checks are not logged — they cost ~1ms and leave no trace by design.)");
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
lines.push("No hook activity recorded yet (ledger appears after the first deep check of a heavy session).");
|
|
102
|
+
}
|
|
103
|
+
lines.push("");
|
|
104
|
+
// -- Measured-now: recent session profiles ------------------------------------
|
|
105
|
+
lines.push("Your recent sessions — waste still recoverable today");
|
|
106
|
+
lines.push("─".repeat(56));
|
|
107
|
+
const sessions = listSessions(8).filter((s) => s.sizeBytes <= MAX_SESSION_BYTES);
|
|
108
|
+
if (sessions.length === 0) {
|
|
109
|
+
lines.push("No Claude Code session transcripts found.");
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
let totalTokens = 0;
|
|
113
|
+
let totalWaste = 0;
|
|
114
|
+
let totalWasteUsd = 0;
|
|
115
|
+
for (const s of sessions) {
|
|
116
|
+
try {
|
|
117
|
+
const parsed = parseSessionFile(s.path);
|
|
118
|
+
if (parsed.messageCount === 0)
|
|
119
|
+
continue;
|
|
120
|
+
const p = profileConversation(parseConversation(parsed.conversationJson), parsed.model);
|
|
121
|
+
totalTokens += p.totalTokens;
|
|
122
|
+
totalWaste += p.totalEstSavings;
|
|
123
|
+
if (p.cost)
|
|
124
|
+
totalWasteUsd += p.cost.savingsPerCallUsd;
|
|
125
|
+
const wastePct = p.totalTokens > 0 ? Math.round((p.totalEstSavings / p.totalTokens) * 100) : 0;
|
|
126
|
+
// Ledger sids are the session UUID's first 12 chars (= filename prefix).
|
|
127
|
+
const sid = (s.path.split("/").pop() ?? "").slice(0, 12);
|
|
128
|
+
const saved = reductionBySession.get(sid) ?? 0;
|
|
129
|
+
const warns = bySession.get(sid)?.warns ?? 0;
|
|
130
|
+
lines.push(` ${(parsed.title ?? s.path.split("/").pop() ?? "session").slice(0, 40).padEnd(42)} ` +
|
|
131
|
+
`${formatTokens(p.totalTokens).padStart(6)} tok · waste ${String(wastePct).padStart(2)}%` +
|
|
132
|
+
(saved > 0 ? ` · saved ${formatTokens(saved)}` : warns > 0 ? ` · ${warns} warning(s)` : ""));
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
/* unreadable session — skip */
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
lines.push("");
|
|
139
|
+
lines.push(`Across these sessions: ~${formatTokens(totalTokens)} tokens held; ~${formatTokens(totalWaste)} still recoverable` +
|
|
140
|
+
(totalWasteUsd > 0 ? ` (≈ ${formatUsd(totalWasteUsd)} of input per message sent in them)` : ""));
|
|
141
|
+
}
|
|
142
|
+
lines.push("");
|
|
143
|
+
lines.push("What no report can show: tokens Claude AVOIDED adding thanks to the standing");
|
|
144
|
+
lines.push("hygiene guidance — the same session cannot be re-run without it. Proxy numbers");
|
|
145
|
+
lines.push("above are exact; session numbers are what optimization would still save now.");
|
|
146
|
+
return lines.join("\n");
|
|
147
|
+
}
|
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.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local activity ledger: one JSONL line per notable event, feeding
|
|
3
|
+
* `context-doctor report`. Best-effort by design — a ledger failure must
|
|
4
|
+
* never break a prompt, a tool call, or an optimize run.
|
|
5
|
+
*
|
|
6
|
+
* Event shapes (all carry ts):
|
|
7
|
+
* check — hook deep-parsed a session {ev?: undefined|"check", sid, tok, warn}
|
|
8
|
+
* (pre-0.3.6 hook entries have no `ev` field; treated as checks)
|
|
9
|
+
* optimize — an optimization was applied {ev: "optimize", src: "cli"|"mcp", saved, model?}
|
|
10
|
+
*/
|
|
11
|
+
export interface LedgerEntry {
|
|
12
|
+
ts: number;
|
|
13
|
+
ev?: "check" | "optimize";
|
|
14
|
+
sid?: string;
|
|
15
|
+
tok?: number;
|
|
16
|
+
warn?: boolean;
|
|
17
|
+
src?: "cli" | "mcp";
|
|
18
|
+
saved?: number;
|
|
19
|
+
model?: string;
|
|
20
|
+
}
|
|
21
|
+
export declare function statePath(): string;
|
|
22
|
+
export declare function ledgerPath(): string;
|
|
23
|
+
export declare function recordLedger(entry: Omit<LedgerEntry, "ts">): void;
|
|
24
|
+
export declare function readLedger(): LedgerEntry[];
|
package/dist/ledger.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local activity ledger: one JSONL line per notable event, feeding
|
|
3
|
+
* `context-doctor report`. Best-effort by design — a ledger failure must
|
|
4
|
+
* never break a prompt, a tool call, or an optimize run.
|
|
5
|
+
*
|
|
6
|
+
* Event shapes (all carry ts):
|
|
7
|
+
* check — hook deep-parsed a session {ev?: undefined|"check", sid, tok, warn}
|
|
8
|
+
* (pre-0.3.6 hook entries have no `ev` field; treated as checks)
|
|
9
|
+
* optimize — an optimization was applied {ev: "optimize", src: "cli"|"mcp", saved, model?}
|
|
10
|
+
*/
|
|
11
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
export function statePath() {
|
|
15
|
+
return process.env.CONTEXT_DOCTOR_HOOK_STATE ?? join(homedir(), ".claude", ".context-doctor-hook-state.json");
|
|
16
|
+
}
|
|
17
|
+
export function ledgerPath() {
|
|
18
|
+
return join(dirname(statePath()), ".context-doctor-ledger.jsonl");
|
|
19
|
+
}
|
|
20
|
+
export function recordLedger(entry) {
|
|
21
|
+
const path = ledgerPath();
|
|
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 });
|
|
26
|
+
// Cap growth: past ~256KB keep the most recent 500 entries.
|
|
27
|
+
if (existsSync(path) && statSync(path).size > 256 * 1024) {
|
|
28
|
+
const lines = readFileSync(path, "utf8").trimEnd().split("\n");
|
|
29
|
+
writeFileSync(path, lines.slice(-500).join("\n") + "\n");
|
|
30
|
+
}
|
|
31
|
+
appendFileSync(path, JSON.stringify({ ts: Date.now(), ...entry }) + "\n");
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
/* best-effort */
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function readLedger() {
|
|
38
|
+
const path = ledgerPath();
|
|
39
|
+
if (!existsSync(path))
|
|
40
|
+
return [];
|
|
41
|
+
try {
|
|
42
|
+
return readFileSync(path, "utf8")
|
|
43
|
+
.trimEnd()
|
|
44
|
+
.split("\n")
|
|
45
|
+
.flatMap((line) => {
|
|
46
|
+
try {
|
|
47
|
+
return [JSON.parse(line)];
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
}
|
package/dist/mcp.js
CHANGED
|
@@ -20,6 +20,7 @@ import { profileConversation } from "./profile.js";
|
|
|
20
20
|
import { optimizeConversation } from "./optimize.js";
|
|
21
21
|
import { renderProfile } from "./report.js";
|
|
22
22
|
import { formatTokens } from "./tokens.js";
|
|
23
|
+
import { recordLedger } from "./ledger.js";
|
|
23
24
|
/**
|
|
24
25
|
* Server instructions are injected by MCP clients (Claude Desktop, Cursor, …)
|
|
25
26
|
* into the system context of EVERY conversation where this server is enabled.
|
|
@@ -29,60 +30,77 @@ import { formatTokens } from "./tokens.js";
|
|
|
29
30
|
// Kept deliberately terse: these ride in EVERY conversation's context, and a
|
|
30
31
|
// context-saving tool must not itself be context overhead (~110 tokens).
|
|
31
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.`;
|
|
32
|
-
const server = new McpServer({ name: "context-doctor", version: "0.3.5" }, { instructions: SERVER_INSTRUCTIONS });
|
|
33
33
|
const STRATEGY_IDS = ["dedupe", "trim-tool-results", "strip-base64", "prune-history"];
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
});
|
|
41
|
-
server.tool("
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}, async ({ conversation, strategies, keep_recent, max_tool_result_tokens }) => {
|
|
48
|
-
const result = optimizeConversation(conversation, {
|
|
49
|
-
strategies: strategies,
|
|
50
|
-
keepRecent: keep_recent,
|
|
51
|
-
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.4.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) }] };
|
|
52
47
|
});
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
conversationJson.length <= ECHO_CAP_CHARS
|
|
65
|
-
? { type: "text", text: conversationJson }
|
|
66
|
-
: {
|
|
67
|
-
type: "text",
|
|
68
|
-
text: `[optimized conversation is ${conversationJson.length} chars — too large to echo into this context. ` +
|
|
69
|
-
`Tell the user the savings above and that \`npx context-doctor optimize <file> --out slim.json\` produces the file directly.]`,
|
|
70
|
-
},
|
|
71
|
-
];
|
|
72
|
-
// Host-model summarization: instead of calling an LLM ourselves (which would
|
|
73
|
-
// need an API key), hand the pruned material to the model that invoked this
|
|
74
|
-
// tool and ask IT to write the summary.
|
|
75
|
-
if (result.prunedDigest) {
|
|
76
|
-
content.push({
|
|
77
|
-
type: "text",
|
|
78
|
-
text: "ACTION REQUIRED (you, the assistant calling this tool): the pruned turns are digested below. " +
|
|
79
|
-
"Write a dense factual summary of them (≤150 tokens: decisions, current state, open items, key identifiers) " +
|
|
80
|
-
"and replace the '[context-doctor: ... pruned]' stub message in the conversation above with your summary " +
|
|
81
|
-
"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,
|
|
82
59
|
});
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
});
|
|
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
|
+
}
|
|
86
104
|
const BEST_PRACTICES = {
|
|
87
105
|
general: [
|
|
88
106
|
"Put stable content first (system prompt, tool definitions, reference docs) and volatile content last — prompt caches match byte-identical prefixes only.",
|
|
@@ -102,11 +120,61 @@ const BEST_PRACTICES = {
|
|
|
102
120
|
"Use max_completion_tokens headroom math: input + output must fit the window together.",
|
|
103
121
|
],
|
|
104
122
|
};
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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,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
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "context-doctor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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",
|
|
@@ -38,10 +38,10 @@
|
|
|
38
38
|
"node": ">=18"
|
|
39
39
|
},
|
|
40
40
|
"scripts": {
|
|
41
|
-
"build": "tsc",
|
|
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"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@modelcontextprotocol/sdk": "^1.0.0",
|