context-doctor 0.5.0 → 0.7.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 +12 -3
- package/dist/cli.js +34 -1
- 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/proxy.d.ts +15 -0
- package/dist/proxy.js +81 -2
- 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/proxy.test.js +44 -0
- package/dist/test/smoke.test.js +16 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -92,7 +92,7 @@ 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
98
|
| `context-doctor watch [file]` | Live monitor of a growing session/agent trace: token/cost line per change, findings as they appear |
|
|
@@ -163,7 +163,11 @@ The proxy dedupes repeated content, trims stale tool results, and strips base64
|
|
|
163
163
|
[context-doctor] POST /v1/messages → 200 in 842ms | optimized 7.3k → 518 tokens (2 changes) | session total: 6.9k tokens ≈ $0.021 saved
|
|
164
164
|
```
|
|
165
165
|
|
|
166
|
-
`GET http://localhost:8787/stats` returns cumulative savings (requests, tokens, estimated USD)
|
|
166
|
+
`GET http://localhost:8787/stats` returns cumulative savings (requests, tokens, estimated USD), **exact upstream usage** read from every response (JSON and SSE), and **prompt-cache advisories** — the proxy watches your real traffic and flags big stable prefixes missing `cache_control` or prefix churn that silently re-bills the cache. Per-model behavior via `--config`:
|
|
167
|
+
|
|
168
|
+
```json
|
|
169
|
+
{ "routes": [{ "modelPrefix": "gpt", "strategies": ["strip-base64"], "keepRecent": 4 }] }
|
|
170
|
+
```
|
|
167
171
|
|
|
168
172
|
Because prompt caching matches byte-identical prefixes, deterministic strategies are chosen so repeated requests stay stable — but if you rely on aggressive cache prefixes, start with `--strategy strip-base64 --strategy dedupe` and add more as you verify.
|
|
169
173
|
|
|
@@ -241,6 +245,7 @@ const { conversation, tokensBefore, tokensAfter } = optimizeConversation(chatJso
|
|
|
241
245
|
|
|
242
246
|
- **Oversized tool results** — the #1 context killer in agent loops
|
|
243
247
|
- **Duplicate content** — the same doc/result pasted twice
|
|
248
|
+
- **Near-duplicates** — the same doc re-pasted with different surrounding words (shingle similarity, ≥60%)
|
|
244
249
|
- **Repeated identical tool calls** — a signal your agent forgot earlier results
|
|
245
250
|
- **Base64 / binary blobs** in text content
|
|
246
251
|
- **Long history** past the point where models track the middle
|
|
@@ -292,7 +297,11 @@ A tool that promises speed must be near-free. Measured overhead per touchpoint:
|
|
|
292
297
|
|
|
293
298
|
Net effect is strongly negative overhead: the tokens these touchpoints save on every subsequent call dwarf what they cost.
|
|
294
299
|
|
|
295
|
-
## Why token counts are "~"
|
|
300
|
+
## Why token counts are "~" (and how to make them exact)
|
|
301
|
+
|
|
302
|
+
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.
|
|
303
|
+
|
|
304
|
+
|
|
296
305
|
|
|
297
306
|
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.
|
|
298
307
|
|
package/dist/cli.js
CHANGED
|
@@ -22,6 +22,7 @@ import { buildImpactReport } from "./impact.js";
|
|
|
22
22
|
import { recordLedger } from "./ledger.js";
|
|
23
23
|
import { runDoctor } from "./doctor.js";
|
|
24
24
|
import { runWatch } from "./watch.js";
|
|
25
|
+
import { exactTokenCount } from "./exact.js";
|
|
25
26
|
const HELP = `context-doctor — profile and optimize LLM context windows
|
|
26
27
|
|
|
27
28
|
Usage:
|
|
@@ -49,6 +50,8 @@ message array). Use "-" to read from stdin.
|
|
|
49
50
|
|
|
50
51
|
Options:
|
|
51
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)
|
|
52
55
|
--json Machine-readable output
|
|
53
56
|
--out <file> (optimize) Write result to file instead of stdout
|
|
54
57
|
--strategy <id> (optimize) Strategy to run; repeatable.
|
|
@@ -58,6 +61,8 @@ Options:
|
|
|
58
61
|
--max-tool-tokens <n> (optimize) Token budget for trimmed tool results (default 300)
|
|
59
62
|
--port <n> (proxy) Port to listen on (default 8787)
|
|
60
63
|
--host <addr> (proxy) Bind address (default 127.0.0.1; use 0.0.0.0 to expose)
|
|
64
|
+
--config <file> (proxy) Per-route overrides: {"routes":[{"modelPrefix":"gpt","strategies":[...],
|
|
65
|
+
"keepRecent":n,"maxToolResultTokens":n}]} — first prefix match wins
|
|
61
66
|
--upstream-anthropic <url> (proxy) Override Anthropic upstream (testing)
|
|
62
67
|
--upstream-openai <url> (proxy) Override OpenAI upstream (testing)
|
|
63
68
|
-h, --help Show this help
|
|
@@ -70,7 +75,7 @@ Examples:
|
|
|
70
75
|
export OPENAI_BASE_URL=http://localhost:8787/v1
|
|
71
76
|
`;
|
|
72
77
|
function parseArgs(argv) {
|
|
73
|
-
const args = { json: false, strategies: [], list: false };
|
|
78
|
+
const args = { json: false, strategies: [], list: false, exact: false };
|
|
74
79
|
const positional = [];
|
|
75
80
|
for (let i = 0; i < argv.length; i++) {
|
|
76
81
|
const a = argv[i];
|
|
@@ -85,6 +90,9 @@ function parseArgs(argv) {
|
|
|
85
90
|
case "--list":
|
|
86
91
|
args.list = true;
|
|
87
92
|
break;
|
|
93
|
+
case "--exact":
|
|
94
|
+
args.exact = true;
|
|
95
|
+
break;
|
|
88
96
|
case "--model":
|
|
89
97
|
args.model = argv[++i];
|
|
90
98
|
break;
|
|
@@ -109,6 +117,9 @@ function parseArgs(argv) {
|
|
|
109
117
|
case "--host":
|
|
110
118
|
args.host = argv[++i];
|
|
111
119
|
break;
|
|
120
|
+
case "--config":
|
|
121
|
+
args.config = argv[++i];
|
|
122
|
+
break;
|
|
112
123
|
case "--upstream-anthropic":
|
|
113
124
|
args.upstreamAnthropic = argv[++i];
|
|
114
125
|
break;
|
|
@@ -189,7 +200,18 @@ function main() {
|
|
|
189
200
|
return;
|
|
190
201
|
}
|
|
191
202
|
if (args.command === "proxy") {
|
|
203
|
+
let routes;
|
|
204
|
+
if (args.config) {
|
|
205
|
+
try {
|
|
206
|
+
routes = JSON.parse(readFileSync(args.config, "utf8")).routes;
|
|
207
|
+
}
|
|
208
|
+
catch (e) {
|
|
209
|
+
console.error(`Could not read --config ${args.config}: ${e.message}`);
|
|
210
|
+
process.exit(1);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
192
213
|
startProxy({
|
|
214
|
+
routes: routes,
|
|
193
215
|
port: args.port,
|
|
194
216
|
host: args.host,
|
|
195
217
|
anthropicUpstream: args.upstreamAnthropic,
|
|
@@ -215,6 +237,17 @@ function main() {
|
|
|
215
237
|
if (args.command === "analyze") {
|
|
216
238
|
const profile = profileConversation(parseConversation(input), args.model);
|
|
217
239
|
console.log(args.json ? JSON.stringify(profile, null, 2) : renderProfile(profile));
|
|
240
|
+
if (args.exact) {
|
|
241
|
+
void exactTokenCount(input, args.model).then((exact) => {
|
|
242
|
+
if (exact.tokens !== undefined) {
|
|
243
|
+
const drift = profile.totalTokens > 0 ? Math.round(((exact.tokens - profile.totalTokens) / exact.tokens) * 100) : 0;
|
|
244
|
+
console.log(`\nExact input tokens: ${exact.tokens} (${exact.source}) — heuristic was off by ${drift}%`);
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
console.log(`\nExact count unavailable: ${exact.note}`);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
}
|
|
218
251
|
return;
|
|
219
252
|
}
|
|
220
253
|
if (args.command === "optimize") {
|
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.7.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/proxy.d.ts
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
import http from "node:http";
|
|
15
15
|
import { OptimizeOptions } from "./optimize.js";
|
|
16
16
|
export interface ProxyOptions extends OptimizeOptions {
|
|
17
|
+
/** Per-model-prefix overrides; first match wins, global options otherwise. */
|
|
18
|
+
routes?: RouteConfig[];
|
|
17
19
|
port?: number;
|
|
18
20
|
/**
|
|
19
21
|
* Bind address. Defaults to 127.0.0.1 — the proxy relays authenticated
|
|
@@ -33,5 +35,18 @@ export interface ProxyStats {
|
|
|
33
35
|
tokensSaved: number;
|
|
34
36
|
/** USD saved on input tokens, when the request's model has a known price. */
|
|
35
37
|
estUsdSaved: number;
|
|
38
|
+
/** Exact usage reported by upstream responses (both providers, incl. SSE). */
|
|
39
|
+
upstreamInputTokens: number;
|
|
40
|
+
upstreamOutputTokens: number;
|
|
41
|
+
/** Prompt-cache advisories observed on live traffic (unique, capped). */
|
|
42
|
+
advice: string[];
|
|
43
|
+
}
|
|
44
|
+
/** Per-model-prefix strategy overrides for the proxy (`--config`). */
|
|
45
|
+
export interface RouteConfig {
|
|
46
|
+
/** Applies when the request body's model starts with this prefix. */
|
|
47
|
+
modelPrefix: string;
|
|
48
|
+
strategies?: OptimizeOptions["strategies"];
|
|
49
|
+
keepRecent?: number;
|
|
50
|
+
maxToolResultTokens?: number;
|
|
36
51
|
}
|
|
37
52
|
export declare function startProxy(opts?: ProxyOptions): http.Server;
|
package/dist/proxy.js
CHANGED
|
@@ -26,6 +26,29 @@ function upstreamFor(url, opts) {
|
|
|
26
26
|
}
|
|
27
27
|
return undefined;
|
|
28
28
|
}
|
|
29
|
+
/** Pull exact usage out of a response body — JSON or SSE, either provider. */
|
|
30
|
+
function extractUsage(text) {
|
|
31
|
+
const last = (re) => {
|
|
32
|
+
let m;
|
|
33
|
+
let v = -1;
|
|
34
|
+
while ((m = re.exec(text)) !== null)
|
|
35
|
+
v = Number(m[1]);
|
|
36
|
+
return v;
|
|
37
|
+
};
|
|
38
|
+
const input = Math.max(last(/"input_tokens"\s*:\s*(\d+)/g), last(/"prompt_tokens"\s*:\s*(\d+)/g));
|
|
39
|
+
const output = Math.max(last(/"output_tokens"\s*:\s*(\d+)/g), last(/"completion_tokens"\s*:\s*(\d+)/g));
|
|
40
|
+
if (input < 0 && output < 0)
|
|
41
|
+
return null;
|
|
42
|
+
return { input: Math.max(input, 0), output: Math.max(output, 0) };
|
|
43
|
+
}
|
|
44
|
+
function fnv1a(s) {
|
|
45
|
+
let h = 0x811c9dc5;
|
|
46
|
+
for (let i = 0; i < s.length; i++) {
|
|
47
|
+
h ^= s.charCodeAt(i);
|
|
48
|
+
h = Math.imul(h, 0x01000193);
|
|
49
|
+
}
|
|
50
|
+
return h >>> 0;
|
|
51
|
+
}
|
|
29
52
|
export function startProxy(opts = {}) {
|
|
30
53
|
const port = opts.port ?? 8787;
|
|
31
54
|
const stats = {
|
|
@@ -36,6 +59,17 @@ export function startProxy(opts = {}) {
|
|
|
36
59
|
tokensAfter: 0,
|
|
37
60
|
tokensSaved: 0,
|
|
38
61
|
estUsdSaved: 0,
|
|
62
|
+
upstreamInputTokens: 0,
|
|
63
|
+
upstreamOutputTokens: 0,
|
|
64
|
+
advice: [],
|
|
65
|
+
};
|
|
66
|
+
/** Last stable-prefix fingerprint per model, for cache-invalidation advice. */
|
|
67
|
+
const prefixFingerprints = new Map();
|
|
68
|
+
const advise = (msg) => {
|
|
69
|
+
if (stats.advice.includes(msg) || stats.advice.length >= 10)
|
|
70
|
+
return;
|
|
71
|
+
stats.advice.push(msg);
|
|
72
|
+
console.error(`[context-doctor] cache advisor: ${msg}`);
|
|
39
73
|
};
|
|
40
74
|
const server = http.createServer(async (req, res) => {
|
|
41
75
|
const url = req.url ?? "/";
|
|
@@ -70,7 +104,39 @@ export function startProxy(opts = {}) {
|
|
|
70
104
|
let note = "passthrough";
|
|
71
105
|
if (req.method === "POST" && body && !isMeasurement) {
|
|
72
106
|
try {
|
|
73
|
-
|
|
107
|
+
// Per-route overrides: first modelPrefix match wins.
|
|
108
|
+
let effective = opts;
|
|
109
|
+
let requestModel;
|
|
110
|
+
try {
|
|
111
|
+
const parsedBody = JSON.parse(body);
|
|
112
|
+
requestModel = parsedBody.model;
|
|
113
|
+
const route = requestModel ? opts.routes?.find((r) => requestModel.startsWith(r.modelPrefix)) : undefined;
|
|
114
|
+
if (route) {
|
|
115
|
+
effective = {
|
|
116
|
+
strategies: route.strategies ?? opts.strategies,
|
|
117
|
+
keepRecent: route.keepRecent ?? opts.keepRecent,
|
|
118
|
+
maxToolResultTokens: route.maxToolResultTokens ?? opts.maxToolResultTokens,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
// Prompt-cache advisor (Anthropic requests): the proxy sees real
|
|
122
|
+
// sequences, so cache-hostile patterns are observable facts here.
|
|
123
|
+
if (url.startsWith("/v1/messages") && requestModel) {
|
|
124
|
+
const stablePrefix = JSON.stringify(parsedBody.tools ?? null) + JSON.stringify(parsedBody.system ?? null);
|
|
125
|
+
if (stablePrefix.length > 4000 && !body.includes("cache_control")) {
|
|
126
|
+
advise(`~${Math.round(stablePrefix.length / 4)}+ tokens of stable system/tools on ${requestModel} without cache_control — adding a breakpoint would cut those to ~10% cost per call`);
|
|
127
|
+
}
|
|
128
|
+
const fp = fnv1a(stablePrefix);
|
|
129
|
+
const prev = prefixFingerprints.get(requestModel);
|
|
130
|
+
if (prev !== undefined && prev !== fp) {
|
|
131
|
+
advise(`system/tools prefix changed between ${requestModel} requests — every change re-bills the whole cached prefix; keep it byte-stable`);
|
|
132
|
+
}
|
|
133
|
+
prefixFingerprints.set(requestModel, fp);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
/* body isn't JSON — global opts apply */
|
|
138
|
+
}
|
|
139
|
+
const result = optimizeConversation(body, effective);
|
|
74
140
|
const saved = result.tokensBefore - result.tokensAfter;
|
|
75
141
|
stats.tokensBefore += result.tokensBefore;
|
|
76
142
|
stats.tokensAfter += result.tokensAfter;
|
|
@@ -109,13 +175,26 @@ export function startProxy(opts = {}) {
|
|
|
109
175
|
res.setHeader(key, value);
|
|
110
176
|
});
|
|
111
177
|
if (upstream.body) {
|
|
112
|
-
// Pipe through chunk-by-chunk so SSE streaming works unchanged
|
|
178
|
+
// Pipe through chunk-by-chunk so SSE streaming works unchanged, while
|
|
179
|
+
// accumulating a bounded copy to read exact usage after the fact.
|
|
180
|
+
const USAGE_SCAN_CAP = 2 * 1024 * 1024;
|
|
181
|
+
let scanBuf = "";
|
|
182
|
+
const decoder = new TextDecoder();
|
|
113
183
|
const reader = upstream.body.getReader();
|
|
114
184
|
for (;;) {
|
|
115
185
|
const { done, value } = await reader.read();
|
|
116
186
|
if (done)
|
|
117
187
|
break;
|
|
118
188
|
res.write(value);
|
|
189
|
+
if (scanBuf.length < USAGE_SCAN_CAP)
|
|
190
|
+
scanBuf += decoder.decode(value, { stream: true });
|
|
191
|
+
}
|
|
192
|
+
if (upstream.ok && !isMeasurement) {
|
|
193
|
+
const usage = extractUsage(scanBuf);
|
|
194
|
+
if (usage) {
|
|
195
|
+
stats.upstreamInputTokens += usage.input;
|
|
196
|
+
stats.upstreamOutputTokens += usage.output;
|
|
197
|
+
}
|
|
119
198
|
}
|
|
120
199
|
}
|
|
121
200
|
res.end();
|
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
|
+
});
|
package/dist/test/proxy.test.js
CHANGED
|
@@ -32,6 +32,7 @@ const upstream = http.createServer((req, res) => {
|
|
|
32
32
|
receivedApiKey = req.headers["x-api-key"];
|
|
33
33
|
res.writeHead(200, { "content-type": "text/event-stream" });
|
|
34
34
|
res.write("event: message_start\ndata: {}\n\n");
|
|
35
|
+
res.write('event: message_delta\ndata: {"usage":{"input_tokens":120,"output_tokens":45}}\n\n');
|
|
35
36
|
res.write("event: message_stop\ndata: {}\n\n");
|
|
36
37
|
res.end();
|
|
37
38
|
});
|
|
@@ -69,6 +70,49 @@ test("/stats reports cumulative savings with dollar estimate", async () => {
|
|
|
69
70
|
assert.ok(stats.tokensSaved > 1000, `saved tokens tracked (${stats.tokensSaved})`);
|
|
70
71
|
assert.ok(stats.estUsdSaved > 0, "dollar savings estimated from the request's model");
|
|
71
72
|
});
|
|
73
|
+
test("response usage is captured from the SSE stream; cache advisor fires on prefix churn", async () => {
|
|
74
|
+
// Second request with a DIFFERENT system prompt on the same model → advisory.
|
|
75
|
+
const churned = JSON.parse(payload);
|
|
76
|
+
churned.system = "You are helpful. TODAY IS A NEW DAY."; // classic cache-buster
|
|
77
|
+
churned.tools = [{ name: "t", description: "x".repeat(5000), input_schema: { type: "object" } }];
|
|
78
|
+
const first = JSON.parse(payload);
|
|
79
|
+
first.tools = churned.tools;
|
|
80
|
+
for (const body of [first, churned]) {
|
|
81
|
+
await fetch(`http://localhost:${proxyPort}/v1/messages`, {
|
|
82
|
+
method: "POST",
|
|
83
|
+
headers: { "content-type": "application/json", "x-api-key": "sk-test-not-real" },
|
|
84
|
+
body: JSON.stringify(body),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
const stats = (await (await fetch(`http://localhost:${proxyPort}/stats`)).json());
|
|
88
|
+
// Mock upstream reports usage in its SSE close event (added below).
|
|
89
|
+
assert.ok(stats.upstreamInputTokens >= 100, `usage input captured: ${stats.upstreamInputTokens}`);
|
|
90
|
+
assert.ok(stats.upstreamOutputTokens >= 40, `usage output captured: ${stats.upstreamOutputTokens}`);
|
|
91
|
+
assert.ok(stats.advice.some((a) => a.includes("prefix changed")), `prefix-churn advisory expected, got: ${JSON.stringify(stats.advice)}`);
|
|
92
|
+
assert.ok(stats.advice.some((a) => a.includes("cache_control")), "missing-cache_control advisory expected");
|
|
93
|
+
});
|
|
94
|
+
test("per-route config: empty strategy list disables optimization for matching models", async () => {
|
|
95
|
+
const { startProxy } = await import("../proxy.js");
|
|
96
|
+
const routed = startProxy({
|
|
97
|
+
port: 0,
|
|
98
|
+
anthropicUpstream: `http://localhost:${upstreamPort}`,
|
|
99
|
+
routes: [{ modelPrefix: "claude-sonnet", strategies: [] }],
|
|
100
|
+
});
|
|
101
|
+
await new Promise((r) => routed.once("listening", () => r()));
|
|
102
|
+
const routedPort = routed.address().port;
|
|
103
|
+
try {
|
|
104
|
+
const sent = payload;
|
|
105
|
+
await fetch(`http://localhost:${routedPort}/v1/messages`, {
|
|
106
|
+
method: "POST",
|
|
107
|
+
headers: { "content-type": "application/json" },
|
|
108
|
+
body: sent,
|
|
109
|
+
});
|
|
110
|
+
assert.equal(received.length, sent.length, "route with no strategies must pass body through unmodified");
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
routed.close();
|
|
114
|
+
}
|
|
115
|
+
});
|
|
72
116
|
test("unsupported paths get a clear 404, health stays up", async () => {
|
|
73
117
|
const notFound = await fetch(`http://localhost:${proxyPort}/v1/nope`, { method: "POST", body: "{}" });
|
|
74
118
|
assert.equal(notFound.status, 404);
|
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);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "context-doctor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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 dist/test/doctor.test.js dist/test/watch.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",
|