context-doctor 0.4.0 → 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 +3 -6
- package/dist/cli.js +18 -0
- package/dist/doctor.d.ts +8 -0
- package/dist/doctor.js +121 -0
- package/dist/mcp.js +1 -1
- package/dist/test/doctor.test.d.ts +2 -0
- package/dist/test/doctor.test.js +19 -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
|
@@ -95,6 +95,8 @@ Practical upshot: a developer who only wants cheaper, faster API calls never tou
|
|
|
95
95
|
| `context-doctor session [file]` | Profile a Claude Code session transcript (defaults to your most recent; `--list` to browse) |
|
|
96
96
|
| `context-doctor report` | Machine-wide impact report: exact proxy savings, hook activity, recoverable waste in recent sessions |
|
|
97
97
|
| `context-doctor proxy` | Always-on local proxy that optimizes every Anthropic/OpenAI API request in flight (`/stats` for cumulative savings) |
|
|
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 |
|
|
98
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) |
|
|
99
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 |
|
|
100
102
|
|
|
@@ -296,12 +298,7 @@ Exact counts require each provider's private tokenizer. `context-doctor` uses a
|
|
|
296
298
|
|
|
297
299
|
## Roadmap
|
|
298
300
|
|
|
299
|
-
|
|
300
|
-
- [x] ~~LLM summarization for prune-history~~ (host-model summarization via MCP — no key needed)
|
|
301
|
-
- [ ] Proxy: per-route strategy config + response token accounting
|
|
302
|
-
- [ ] `context-doctor watch` — live profiling of a running agent's JSONL trace
|
|
303
|
-
- [ ] Exact tokenizer adapters (tiktoken, Anthropic count-tokens API) as optional plugins
|
|
304
|
-
- [ ] 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.
|
|
305
302
|
|
|
306
303
|
Contributions welcome — this project is small on purpose. Open an issue before a big PR.
|
|
307
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/mcp.js
CHANGED
|
@@ -37,7 +37,7 @@ const STRATEGY_IDS = ["dedupe", "trim-tool-results", "strip-base64", "prune-hist
|
|
|
37
37
|
* recommended pattern.
|
|
38
38
|
*/
|
|
39
39
|
function createServer() {
|
|
40
|
-
const server = new McpServer({ name: "context-doctor", version: "0.
|
|
40
|
+
const server = new McpServer({ name: "context-doctor", version: "0.5.0" }, { instructions: SERVER_INSTRUCTIONS });
|
|
41
41
|
server.tool("profile_context", "Profile an LLM conversation or prompt: token breakdown by category, largest messages, and actionable findings about wasted context (duplicates, oversized tool results, base64 blobs, cache-unfriendly ordering). Accepts OpenAI/Anthropic conversation JSON or raw text. Call this immediately whenever the user asks about token usage, context size, LLM cost, or latency — and proactively offer it once a conversation grows long or accumulates large pasted content.", {
|
|
42
42
|
conversation: z.string().describe("Conversation JSON (OpenAI or Anthropic format, or bare message array) or raw prompt text"),
|
|
43
43
|
model: z.string().optional().describe("Target model name for context-window math, e.g. claude-sonnet-5 or gpt-4o"),
|
|
@@ -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,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 dist/test/mcp-http.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",
|