context-doctor 0.4.0 → 0.6.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 +10 -8
- package/dist/cli.js +36 -1
- package/dist/doctor.d.ts +8 -0
- package/dist/doctor.js +121 -0
- package/dist/exact.d.ts +17 -0
- package/dist/exact.js +77 -0
- package/dist/mcp.js +1 -1
- package/dist/profile.d.ts +1 -1
- package/dist/profile.js +69 -0
- package/dist/session.js +45 -0
- package/dist/test/chatgpt-export.test.d.ts +2 -0
- package/dist/test/chatgpt-export.test.js +45 -0
- package/dist/test/doctor.test.d.ts +2 -0
- package/dist/test/doctor.test.js +19 -0
- package/dist/test/smoke.test.js +16 -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
|
@@ -92,9 +92,11 @@ Practical upshot: a developer who only wants cheaper, faster API calls never tou
|
|
|
92
92
|
| `context-doctor install` / `uninstall` | Wire (or remove) everything: MCP for Claude Desktop/Code/Cursor, the Agent Skill, the every-prompt hook |
|
|
93
93
|
| `context-doctor analyze <file>` | Profile a conversation: token breakdown, findings, cost + latency estimates |
|
|
94
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) |
|
|
95
|
+
| `context-doctor session [file]` | Profile a Claude Code session transcript (defaults to your most recent; `--list` to browse) — also reads ChatGPT data exports (`conversations.json`) |
|
|
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
|
|
|
@@ -239,6 +241,7 @@ const { conversation, tokensBefore, tokensAfter } = optimizeConversation(chatJso
|
|
|
239
241
|
|
|
240
242
|
- **Oversized tool results** — the #1 context killer in agent loops
|
|
241
243
|
- **Duplicate content** — the same doc/result pasted twice
|
|
244
|
+
- **Near-duplicates** — the same doc re-pasted with different surrounding words (shingle similarity, ≥60%)
|
|
242
245
|
- **Repeated identical tool calls** — a signal your agent forgot earlier results
|
|
243
246
|
- **Base64 / binary blobs** in text content
|
|
244
247
|
- **Long history** past the point where models track the middle
|
|
@@ -290,18 +293,17 @@ A tool that promises speed must be near-free. Measured overhead per touchpoint:
|
|
|
290
293
|
|
|
291
294
|
Net effect is strongly negative overhead: the tokens these touchpoints save on every subsequent call dwarf what they cost.
|
|
292
295
|
|
|
293
|
-
## Why token counts are "~"
|
|
296
|
+
## Why token counts are "~" (and how to make them exact)
|
|
297
|
+
|
|
298
|
+
Want exact numbers? `analyze --exact` uses the **Anthropic count-tokens API** for Claude models (set `ANTHROPIC_API_KEY`; opt-in network call, key never stored) or **tiktoken** for GPT models (install it next to context-doctor) — and reports how far off the heuristic was.
|
|
299
|
+
|
|
300
|
+
|
|
294
301
|
|
|
295
302
|
Exact counts require each provider's private tokenizer. `context-doctor` uses a calibrated chars-per-token heuristic (denser for code/JSON) that lands within ~10% — plenty accurate for finding what's heavy and measuring savings, and it keeps the tool fully offline with zero configuration.
|
|
296
303
|
|
|
297
304
|
## Roadmap
|
|
298
305
|
|
|
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`
|
|
306
|
+
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
307
|
|
|
306
308
|
Contributions welcome — this project is small on purpose. Open an issue before a big PR.
|
|
307
309
|
|
package/dist/cli.js
CHANGED
|
@@ -20,6 +20,9 @@ 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";
|
|
25
|
+
import { exactTokenCount } from "./exact.js";
|
|
23
26
|
const HELP = `context-doctor — profile and optimize LLM context windows
|
|
24
27
|
|
|
25
28
|
Usage:
|
|
@@ -36,12 +39,19 @@ Usage:
|
|
|
36
39
|
automatically by \`install\`; reads hook JSON on stdin)
|
|
37
40
|
context-doctor report Impact report: exact proxy savings, hook activity,
|
|
38
41
|
and remaining recoverable waste in recent sessions
|
|
42
|
+
context-doctor doctor Self-check the installation (configs, hook, skill,
|
|
43
|
+
MCP handshake) with one pasteable diagnosis
|
|
44
|
+
context-doctor watch [file] Live-monitor a growing session/agent trace: running
|
|
45
|
+
token/cost line per change, new findings as they appear
|
|
46
|
+
(--interval-ms n, default 2000)
|
|
39
47
|
|
|
40
48
|
Input: a conversation JSON file (OpenAI or Anthropic message format, or a bare
|
|
41
49
|
message array). Use "-" to read from stdin.
|
|
42
50
|
|
|
43
51
|
Options:
|
|
44
52
|
--model <name> Model name for window-size math (e.g. claude-sonnet-5, gpt-4o)
|
|
53
|
+
--exact (analyze) Add an exact token count: Anthropic count-tokens API for
|
|
54
|
+
Claude models (needs ANTHROPIC_API_KEY), tiktoken for GPT (if installed)
|
|
45
55
|
--json Machine-readable output
|
|
46
56
|
--out <file> (optimize) Write result to file instead of stdout
|
|
47
57
|
--strategy <id> (optimize) Strategy to run; repeatable.
|
|
@@ -63,7 +73,7 @@ Examples:
|
|
|
63
73
|
export OPENAI_BASE_URL=http://localhost:8787/v1
|
|
64
74
|
`;
|
|
65
75
|
function parseArgs(argv) {
|
|
66
|
-
const args = { json: false, strategies: [], list: false };
|
|
76
|
+
const args = { json: false, strategies: [], list: false, exact: false };
|
|
67
77
|
const positional = [];
|
|
68
78
|
for (let i = 0; i < argv.length; i++) {
|
|
69
79
|
const a = argv[i];
|
|
@@ -78,6 +88,9 @@ function parseArgs(argv) {
|
|
|
78
88
|
case "--list":
|
|
79
89
|
args.list = true;
|
|
80
90
|
break;
|
|
91
|
+
case "--exact":
|
|
92
|
+
args.exact = true;
|
|
93
|
+
break;
|
|
81
94
|
case "--model":
|
|
82
95
|
args.model = argv[++i];
|
|
83
96
|
break;
|
|
@@ -96,6 +109,9 @@ function parseArgs(argv) {
|
|
|
96
109
|
case "--port":
|
|
97
110
|
args.port = Number(argv[++i]);
|
|
98
111
|
break;
|
|
112
|
+
case "--interval-ms":
|
|
113
|
+
args.intervalMs = Number(argv[++i]);
|
|
114
|
+
break;
|
|
99
115
|
case "--host":
|
|
100
116
|
args.host = argv[++i];
|
|
101
117
|
break;
|
|
@@ -123,6 +139,14 @@ function main() {
|
|
|
123
139
|
void runHook();
|
|
124
140
|
return;
|
|
125
141
|
}
|
|
142
|
+
if (args.command === "watch") {
|
|
143
|
+
runWatch({ file: args.file, intervalMs: args.intervalMs, model: args.model });
|
|
144
|
+
return; // interval keeps the process alive
|
|
145
|
+
}
|
|
146
|
+
if (args.command === "doctor") {
|
|
147
|
+
void runDoctor();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
126
150
|
if (args.command === "report") {
|
|
127
151
|
void buildImpactReport(args.port).then((r) => console.log(r));
|
|
128
152
|
return;
|
|
@@ -197,6 +221,17 @@ function main() {
|
|
|
197
221
|
if (args.command === "analyze") {
|
|
198
222
|
const profile = profileConversation(parseConversation(input), args.model);
|
|
199
223
|
console.log(args.json ? JSON.stringify(profile, null, 2) : renderProfile(profile));
|
|
224
|
+
if (args.exact) {
|
|
225
|
+
void exactTokenCount(input, args.model).then((exact) => {
|
|
226
|
+
if (exact.tokens !== undefined) {
|
|
227
|
+
const drift = profile.totalTokens > 0 ? Math.round(((exact.tokens - profile.totalTokens) / exact.tokens) * 100) : 0;
|
|
228
|
+
console.log(`\nExact input tokens: ${exact.tokens} (${exact.source}) — heuristic was off by ${drift}%`);
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
console.log(`\nExact count unavailable: ${exact.note}`);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
}
|
|
200
235
|
return;
|
|
201
236
|
}
|
|
202
237
|
if (args.command === "optimize") {
|
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/exact.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional exact token counting (`analyze --exact`). Zero-config heuristic
|
|
3
|
+
* remains the default; this upgrades the TOTAL where an exact source exists:
|
|
4
|
+
*
|
|
5
|
+
* Claude models — Anthropic's count-tokens API when ANTHROPIC_API_KEY is
|
|
6
|
+
* set (opt-in network call; the key is read from env, never stored).
|
|
7
|
+
* GPT models — tiktoken, when the user has installed it alongside us
|
|
8
|
+
* (optional peer; we never ship the WASM weight by default).
|
|
9
|
+
*
|
|
10
|
+
* Anything else falls back to the heuristic with a note saying why.
|
|
11
|
+
*/
|
|
12
|
+
export interface ExactResult {
|
|
13
|
+
tokens?: number;
|
|
14
|
+
source?: string;
|
|
15
|
+
note?: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function exactTokenCount(conversationJson: string, model?: string): Promise<ExactResult>;
|
package/dist/exact.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional exact token counting (`analyze --exact`). Zero-config heuristic
|
|
3
|
+
* remains the default; this upgrades the TOTAL where an exact source exists:
|
|
4
|
+
*
|
|
5
|
+
* Claude models — Anthropic's count-tokens API when ANTHROPIC_API_KEY is
|
|
6
|
+
* set (opt-in network call; the key is read from env, never stored).
|
|
7
|
+
* GPT models — tiktoken, when the user has installed it alongside us
|
|
8
|
+
* (optional peer; we never ship the WASM weight by default).
|
|
9
|
+
*
|
|
10
|
+
* Anything else falls back to the heuristic with a note saying why.
|
|
11
|
+
*/
|
|
12
|
+
export async function exactTokenCount(conversationJson, model) {
|
|
13
|
+
let conv;
|
|
14
|
+
try {
|
|
15
|
+
conv = JSON.parse(conversationJson);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return { note: "exact counting needs a JSON conversation" };
|
|
19
|
+
}
|
|
20
|
+
const targetModel = model ?? conv.model;
|
|
21
|
+
if (!targetModel)
|
|
22
|
+
return { note: "pass --model to enable exact counting" };
|
|
23
|
+
if (!Array.isArray(conv.messages) || conv.messages.length === 0) {
|
|
24
|
+
return { note: "no messages array — exact counting skipped" };
|
|
25
|
+
}
|
|
26
|
+
if (/claude/i.test(targetModel)) {
|
|
27
|
+
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
28
|
+
if (!apiKey)
|
|
29
|
+
return { note: "set ANTHROPIC_API_KEY to get exact Claude counts (count-tokens API)" };
|
|
30
|
+
try {
|
|
31
|
+
const res = await fetch("https://api.anthropic.com/v1/messages/count_tokens", {
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers: { "x-api-key": apiKey, "anthropic-version": "2023-06-01", "content-type": "application/json" },
|
|
34
|
+
body: JSON.stringify({
|
|
35
|
+
model: targetModel,
|
|
36
|
+
messages: conv.messages,
|
|
37
|
+
...(conv.system != null ? { system: conv.system } : {}),
|
|
38
|
+
}),
|
|
39
|
+
signal: AbortSignal.timeout(15_000),
|
|
40
|
+
});
|
|
41
|
+
const data = (await res.json());
|
|
42
|
+
if (!res.ok || typeof data.input_tokens !== "number") {
|
|
43
|
+
return { note: `count-tokens API: ${data.error?.message ?? `HTTP ${res.status}`} — using heuristic` };
|
|
44
|
+
}
|
|
45
|
+
return { tokens: data.input_tokens, source: "Anthropic count-tokens API" };
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
return { note: `count-tokens API unreachable (${e.message}) — using heuristic` };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (/gpt|^o\d/i.test(targetModel)) {
|
|
52
|
+
try {
|
|
53
|
+
// Optional peer — resolves only if the user installed it next to us.
|
|
54
|
+
// @ts-expect-error optional dependency without bundled types
|
|
55
|
+
const tiktoken = await import("tiktoken");
|
|
56
|
+
const enc = tiktoken.get_encoding("o200k_base");
|
|
57
|
+
try {
|
|
58
|
+
const text = conv.messages
|
|
59
|
+
.map((m) => {
|
|
60
|
+
const c = m.content;
|
|
61
|
+
return typeof c === "string" ? c : JSON.stringify(c ?? "");
|
|
62
|
+
})
|
|
63
|
+
.join("\n");
|
|
64
|
+
// +4/message structural overhead, mirroring OpenAI's chat format math.
|
|
65
|
+
const tokens = enc.encode(text).length + conv.messages.length * 4;
|
|
66
|
+
return { tokens, source: "tiktoken (o200k_base)" };
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
enc.free();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return { note: "install tiktoken next to context-doctor for exact GPT counts (npm i tiktoken)" };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { note: `no exact tokenizer for ${targetModel} — using heuristic` };
|
|
77
|
+
}
|
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.6.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"),
|
package/dist/profile.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ export interface MessageProfile {
|
|
|
12
12
|
preview: string;
|
|
13
13
|
toolName?: string;
|
|
14
14
|
}
|
|
15
|
-
export type FindingId = "large_tool_result" | "duplicate_content" | "repeated_tool_call" | "base64_blob" | "long_history" | "large_system_prompt" | "cache_ordering" | "near_window_limit";
|
|
15
|
+
export type FindingId = "large_tool_result" | "duplicate_content" | "near_duplicate" | "repeated_tool_call" | "base64_blob" | "long_history" | "large_system_prompt" | "cache_ordering" | "near_window_limit";
|
|
16
16
|
export interface Finding {
|
|
17
17
|
id: FindingId;
|
|
18
18
|
severity: "info" | "warn" | "high";
|
package/dist/profile.js
CHANGED
|
@@ -23,6 +23,43 @@ function contentHash(text) {
|
|
|
23
23
|
return createHash("sha1").update(text.replace(/\s+/g, " ").trim()).digest("hex");
|
|
24
24
|
}
|
|
25
25
|
const BASE64_RE = /(?:data:[\w/+.-]+;base64,|[A-Za-z0-9+/]{500,}={0,2})/;
|
|
26
|
+
/** FNV-1a — cheap deterministic hash for shingle sampling. */
|
|
27
|
+
function fnv1a(s) {
|
|
28
|
+
let h = 0x811c9dc5;
|
|
29
|
+
for (let i = 0; i < s.length; i++) {
|
|
30
|
+
h ^= s.charCodeAt(i);
|
|
31
|
+
h = Math.imul(h, 0x01000193);
|
|
32
|
+
}
|
|
33
|
+
return h >>> 0;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Sampled 8-word shingle set for near-duplicate detection. Keeping only
|
|
37
|
+
* ~1/8th of shingles (by hash) shrinks sets ~8x while preserving the Jaccard
|
|
38
|
+
* estimate — pairwise comparison stays cheap even on large sessions.
|
|
39
|
+
*/
|
|
40
|
+
function sampledShingles(text) {
|
|
41
|
+
const words = text.toLowerCase().replace(/\s+/g, " ").trim().split(" ");
|
|
42
|
+
// Sampling is a large-text optimization only: short texts keep every
|
|
43
|
+
// shingle (sampling them starves the Jaccard estimate), long ones keep ~1/8.
|
|
44
|
+
const sample = words.length > 1500;
|
|
45
|
+
const out = new Set();
|
|
46
|
+
for (let i = 0; i + 8 <= words.length; i++) {
|
|
47
|
+
const h = fnv1a(words.slice(i, i + 8).join(" "));
|
|
48
|
+
if (!sample || h % 8 === 0)
|
|
49
|
+
out.add(h);
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
function jaccard(a, b) {
|
|
54
|
+
if (a.size === 0 || b.size === 0)
|
|
55
|
+
return 0;
|
|
56
|
+
let inter = 0;
|
|
57
|
+
const [small, large] = a.size <= b.size ? [a, b] : [b, a];
|
|
58
|
+
for (const v of small)
|
|
59
|
+
if (large.has(v))
|
|
60
|
+
inter++;
|
|
61
|
+
return inter / (a.size + b.size - inter);
|
|
62
|
+
}
|
|
26
63
|
export function profileConversation(conv, model) {
|
|
27
64
|
const perMessage = conv.messages.map((m) => ({
|
|
28
65
|
msg: m,
|
|
@@ -69,6 +106,38 @@ export function profileConversation(conv, model) {
|
|
|
69
106
|
seen.set(h, p.msg.index);
|
|
70
107
|
}
|
|
71
108
|
}
|
|
109
|
+
// -- Near-duplicates: same content wrapped in different lead-ins -------------
|
|
110
|
+
// Exact hashing (above) misses "here's the doc again: <doc>"; sampled-shingle
|
|
111
|
+
// Jaccard catches it. Capped to the 150 largest 300+-char messages.
|
|
112
|
+
{
|
|
113
|
+
const candidates = perMessage
|
|
114
|
+
.filter((p) => p.msg.text.length >= 300)
|
|
115
|
+
.sort((a, b) => b.tokens - a.tokens)
|
|
116
|
+
.slice(0, 150);
|
|
117
|
+
const shingleSets = candidates.map((p) => sampledShingles(p.msg.text));
|
|
118
|
+
const exactDup = new Set(findings.filter((f) => f.id === "duplicate_content").flatMap((f) => f.messages));
|
|
119
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
120
|
+
for (let j = i + 1; j < candidates.length; j++) {
|
|
121
|
+
const a = candidates[i];
|
|
122
|
+
const b = candidates[j];
|
|
123
|
+
if (exactDup.has(a.msg.index) && exactDup.has(b.msg.index))
|
|
124
|
+
continue; // already flagged exactly
|
|
125
|
+
const sim = jaccard(shingleSets[i], shingleSets[j]);
|
|
126
|
+
if (sim >= 0.6) {
|
|
127
|
+
const smaller = Math.min(a.tokens, b.tokens);
|
|
128
|
+
const [first, second] = a.msg.index <= b.msg.index ? [a, b] : [b, a];
|
|
129
|
+
findings.push({
|
|
130
|
+
id: "near_duplicate",
|
|
131
|
+
severity: "warn",
|
|
132
|
+
estSavings: Math.round(smaller * sim * 0.9),
|
|
133
|
+
message: `Messages #${first.msg.index} and #${second.msg.index} are ~${Math.round(sim * 100)}% similar (~${smaller} tokens repeated with different framing).`,
|
|
134
|
+
suggestion: "Replace the later copy with a short reference to the first — repeats survive even when the surrounding words differ.",
|
|
135
|
+
messages: [first.msg.index, second.msg.index],
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
72
141
|
// -- Repeated identical tool calls ------------------------------------------
|
|
73
142
|
const callSeen = new Map();
|
|
74
143
|
for (const p of perMessage) {
|
package/dist/session.js
CHANGED
|
@@ -39,8 +39,53 @@ export function listSessions(limit = 20) {
|
|
|
39
39
|
}
|
|
40
40
|
return sessions.sort((a, b) => b.modifiedAt.getTime() - a.modifiedAt.getTime()).slice(0, limit);
|
|
41
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* ChatGPT data export (chatgpt.com → Settings → Data controls → Export):
|
|
44
|
+
* conversations.json is an array of conversations, each holding a `mapping`
|
|
45
|
+
* tree of nodes. We profile the most recently updated conversation.
|
|
46
|
+
*/
|
|
47
|
+
function parseChatGPTExport(data, path) {
|
|
48
|
+
const conversations = data
|
|
49
|
+
.filter((c) => c && typeof c.mapping === "object")
|
|
50
|
+
.sort((a, b) => (b.update_time ?? 0) - (a.update_time ?? 0));
|
|
51
|
+
const conv = conversations[0];
|
|
52
|
+
if (!conv)
|
|
53
|
+
return { conversationJson: JSON.stringify({ messages: [] }), messageCount: 0, path };
|
|
54
|
+
const nodes = Object.values(conv.mapping)
|
|
55
|
+
.filter((n) => {
|
|
56
|
+
const m = n?.message;
|
|
57
|
+
if (!m?.author?.role || !["user", "assistant", "system"].includes(m.author.role))
|
|
58
|
+
return false;
|
|
59
|
+
const parts = m.content?.parts;
|
|
60
|
+
return Array.isArray(parts) && parts.some((p) => typeof p === "string" && p.length > 0);
|
|
61
|
+
})
|
|
62
|
+
.sort((a, b) => (a.message.create_time ?? 0) - (b.message.create_time ?? 0));
|
|
63
|
+
const messages = nodes.map((n) => ({
|
|
64
|
+
role: n.message.author.role,
|
|
65
|
+
content: n.message.content.parts.filter((p) => typeof p === "string").join("\n"),
|
|
66
|
+
}));
|
|
67
|
+
return {
|
|
68
|
+
conversationJson: JSON.stringify({ messages }),
|
|
69
|
+
title: typeof conv.title === "string" ? conv.title : undefined,
|
|
70
|
+
model: typeof conv.default_model_slug === "string" ? conv.default_model_slug : "gpt-5",
|
|
71
|
+
messageCount: messages.length,
|
|
72
|
+
path,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
42
75
|
export function parseSessionFile(path) {
|
|
43
76
|
const raw = readFileSync(path, "utf8");
|
|
77
|
+
// ChatGPT exports are one big JSON array, not JSONL.
|
|
78
|
+
if (raw.trimStart().startsWith("[")) {
|
|
79
|
+
try {
|
|
80
|
+
const data = JSON.parse(raw);
|
|
81
|
+
if (Array.isArray(data) && data.some((c) => c && typeof c.mapping === "object")) {
|
|
82
|
+
return parseChatGPTExport(data, path);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
/* fall through to JSONL parsing */
|
|
87
|
+
}
|
|
88
|
+
}
|
|
44
89
|
const messages = [];
|
|
45
90
|
let title;
|
|
46
91
|
let model;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** session: ChatGPT data-export (conversations.json) parsing. */
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { parseSessionFile } from "../session.js";
|
|
8
|
+
function node(id, role, text, t) {
|
|
9
|
+
return [id, { id, message: { author: { role }, content: { content_type: "text", parts: [text] }, create_time: t } }];
|
|
10
|
+
}
|
|
11
|
+
const older = {
|
|
12
|
+
title: "Older chat",
|
|
13
|
+
update_time: 100,
|
|
14
|
+
default_model_slug: "gpt-4o",
|
|
15
|
+
mapping: Object.fromEntries([node("a", "user", "old question", 1)]),
|
|
16
|
+
};
|
|
17
|
+
const newer = {
|
|
18
|
+
title: "Trip planning",
|
|
19
|
+
update_time: 200,
|
|
20
|
+
default_model_slug: "gpt-5",
|
|
21
|
+
mapping: Object.fromEntries([
|
|
22
|
+
node("r", "system", "You are helpful.", 1),
|
|
23
|
+
node("x", "user", "Plan me a trip to Japan with a detailed itinerary please.", 2),
|
|
24
|
+
node("y", "assistant", "Day 1: Tokyo. Day 2: Kyoto. Day 3: Osaka with food tour.", 3),
|
|
25
|
+
["tool-node", { id: "tool-node", message: { author: { role: "tool" }, content: { content_type: "text", parts: ["ignored"] }, create_time: 4 } }],
|
|
26
|
+
]),
|
|
27
|
+
};
|
|
28
|
+
test("parses a ChatGPT export: newest conversation, ordered messages, model detected", () => {
|
|
29
|
+
const dir = mkdtempSync(join(tmpdir(), "ctxdoc-gpt-"));
|
|
30
|
+
const file = join(dir, "conversations.json");
|
|
31
|
+
writeFileSync(file, JSON.stringify([older, newer]));
|
|
32
|
+
const parsed = parseSessionFile(file);
|
|
33
|
+
assert.equal(parsed.title, "Trip planning");
|
|
34
|
+
assert.equal(parsed.model, "gpt-5");
|
|
35
|
+
assert.equal(parsed.messageCount, 3); // tool node excluded
|
|
36
|
+
const conv = JSON.parse(parsed.conversationJson);
|
|
37
|
+
assert.equal(conv.messages[0].role, "system");
|
|
38
|
+
assert.equal(conv.messages[1].content.includes("Japan"), true);
|
|
39
|
+
});
|
|
40
|
+
test("JSONL transcripts still parse (no regression)", () => {
|
|
41
|
+
const dir = mkdtempSync(join(tmpdir(), "ctxdoc-jsonl-"));
|
|
42
|
+
const file = join(dir, "s.jsonl");
|
|
43
|
+
writeFileSync(file, JSON.stringify({ type: "user", message: { role: "user", content: "hi" } }) + "\n");
|
|
44
|
+
assert.equal(parseSessionFile(file).messageCount, 1);
|
|
45
|
+
});
|
|
@@ -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
|
+
});
|
package/dist/test/smoke.test.js
CHANGED
|
@@ -88,6 +88,22 @@ test("prune-history never leaves an orphaned tool result at the head of the tail
|
|
|
88
88
|
assert.equal(isToolResult, false, "tail must not start with an orphaned tool_result");
|
|
89
89
|
assert.ok(result.applied.some((c) => c.strategy === "prune-history"), "pruning still happened");
|
|
90
90
|
});
|
|
91
|
+
test("near-duplicate detection catches same doc with different lead-ins", () => {
|
|
92
|
+
// Varied clauses (not a repeated sentence) — like a real document.
|
|
93
|
+
const doc = Array.from({ length: 30 }, (_, i) => `Clause ${i} of the pricing policy covers refund scenario ${i} where the customer holds receipt series ${i * 7} under regional rule ${i % 5}.`).join(" ");
|
|
94
|
+
const conv = JSON.stringify({
|
|
95
|
+
messages: [
|
|
96
|
+
{ role: "user", content: "Here is our policy document for you to review:\n" + doc },
|
|
97
|
+
{ role: "assistant", content: "Understood, thanks for sharing the policy." },
|
|
98
|
+
{ role: "user", content: "Sharing the policy doc again with a totally different intro so exact hashing misses it:\n" + doc },
|
|
99
|
+
],
|
|
100
|
+
});
|
|
101
|
+
const profile = profileConversation(parseConversation(conv));
|
|
102
|
+
const near = profile.findings.find((f) => f.id === "near_duplicate");
|
|
103
|
+
assert.ok(near, "near_duplicate finding expected");
|
|
104
|
+
assert.deepEqual(near.messages, [0, 2]);
|
|
105
|
+
assert.ok(near.estSavings > 50);
|
|
106
|
+
});
|
|
91
107
|
test("raw text input still profiles", () => {
|
|
92
108
|
const profile = profileConversation(parseConversation("just some prompt text"));
|
|
93
109
|
assert.equal(profile.messageCount, 1);
|
|
@@ -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.6.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 dist/test/chatgpt-export.test.js"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@modelcontextprotocol/sdk": "^1.0.0",
|