context-doctor 0.2.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/LICENSE +21 -0
- package/README.md +177 -0
- package/dist/cli.d.ts +10 -0
- package/dist/cli.js +216 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +12 -0
- package/dist/install.d.ts +10 -0
- package/dist/install.js +120 -0
- package/dist/mcp.d.ts +15 -0
- package/dist/mcp.js +92 -0
- package/dist/optimize.d.ts +36 -0
- package/dist/optimize.js +189 -0
- package/dist/parse.d.ts +31 -0
- package/dist/parse.js +118 -0
- package/dist/pricing.d.ts +26 -0
- package/dist/pricing.js +48 -0
- package/dist/profile.d.ts +55 -0
- package/dist/profile.js +197 -0
- package/dist/proxy.d.ts +31 -0
- package/dist/proxy.js +137 -0
- package/dist/report.d.ts +7 -0
- package/dist/report.js +82 -0
- package/dist/session.d.ts +27 -0
- package/dist/session.js +80 -0
- package/dist/test/proxy.test.d.ts +6 -0
- package/dist/test/proxy.test.js +77 -0
- package/dist/test/smoke.test.d.ts +2 -0
- package/dist/test/smoke.test.js +73 -0
- package/dist/tokens.d.ts +17 -0
- package/dist/tokens.js +69 -0
- package/package.json +54 -0
- package/skills/context-doctor/SKILL.md +39 -0
package/dist/profile.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The profiler: turns a normalized conversation into a breakdown of where
|
|
3
|
+
* tokens go, plus a list of actionable findings ("what's eating my window").
|
|
4
|
+
*/
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { contextWindowFor, estimateTokens, MESSAGE_OVERHEAD_TOKENS, providerFor } from "./tokens.js";
|
|
7
|
+
import { estimatedTtftSeconds, inputCostUsd, pricingFor } from "./pricing.js";
|
|
8
|
+
function categoryOf(m) {
|
|
9
|
+
switch (m.kind) {
|
|
10
|
+
case "system": return "system";
|
|
11
|
+
case "user": return "user";
|
|
12
|
+
case "assistant": return "assistant";
|
|
13
|
+
case "tool_call": return "tool_calls";
|
|
14
|
+
case "tool_result": return "tool_results";
|
|
15
|
+
default: return "other";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function preview(text, len = 90) {
|
|
19
|
+
const clean = text.replace(/\s+/g, " ").trim();
|
|
20
|
+
return clean.length > len ? clean.slice(0, len) + "…" : clean;
|
|
21
|
+
}
|
|
22
|
+
function contentHash(text) {
|
|
23
|
+
return createHash("sha1").update(text.replace(/\s+/g, " ").trim()).digest("hex");
|
|
24
|
+
}
|
|
25
|
+
const BASE64_RE = /(?:data:[\w/+.-]+;base64,|[A-Za-z0-9+/]{500,}={0,2})/;
|
|
26
|
+
export function profileConversation(conv, model) {
|
|
27
|
+
const perMessage = conv.messages.map((m) => ({
|
|
28
|
+
msg: m,
|
|
29
|
+
tokens: estimateTokens(m.text) + MESSAGE_OVERHEAD_TOKENS,
|
|
30
|
+
}));
|
|
31
|
+
const totalTokens = perMessage.reduce((sum, p) => sum + p.tokens, 0);
|
|
32
|
+
const categories = {
|
|
33
|
+
system: 0, user: 0, assistant: 0, tool_calls: 0, tool_results: 0, other: 0,
|
|
34
|
+
};
|
|
35
|
+
for (const p of perMessage)
|
|
36
|
+
categories[categoryOf(p.msg)] += p.tokens;
|
|
37
|
+
const findings = [];
|
|
38
|
+
// -- Large individual tool results ------------------------------------------
|
|
39
|
+
for (const p of perMessage) {
|
|
40
|
+
if (p.msg.kind === "tool_result" && p.tokens > 2000) {
|
|
41
|
+
findings.push({
|
|
42
|
+
id: "large_tool_result",
|
|
43
|
+
severity: p.tokens > 8000 ? "high" : "warn",
|
|
44
|
+
estSavings: Math.round(p.tokens * 0.8),
|
|
45
|
+
message: `Tool result at message #${p.msg.index}${p.msg.toolName ? ` (${p.msg.toolName})` : ""} is ~${p.tokens} tokens.`,
|
|
46
|
+
suggestion: "Truncate or summarize large tool outputs before they enter history; keep only the fields the model actually used.",
|
|
47
|
+
messages: [p.msg.index],
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// -- Exact duplicate content ------------------------------------------------
|
|
52
|
+
const seen = new Map();
|
|
53
|
+
for (const p of perMessage) {
|
|
54
|
+
if (p.msg.text.length < 300)
|
|
55
|
+
continue; // small repeats are cheap
|
|
56
|
+
const h = contentHash(p.msg.text);
|
|
57
|
+
const first = seen.get(h);
|
|
58
|
+
if (first !== undefined) {
|
|
59
|
+
findings.push({
|
|
60
|
+
id: "duplicate_content",
|
|
61
|
+
severity: "warn",
|
|
62
|
+
estSavings: p.tokens - MESSAGE_OVERHEAD_TOKENS,
|
|
63
|
+
message: `Message #${p.msg.index} duplicates the content of message #${first} (~${p.tokens} tokens).`,
|
|
64
|
+
suggestion: "Replace repeated content with a short reference to the first occurrence.",
|
|
65
|
+
messages: [first, p.msg.index],
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
seen.set(h, p.msg.index);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// -- Repeated identical tool calls ------------------------------------------
|
|
73
|
+
const callSeen = new Map();
|
|
74
|
+
for (const p of perMessage) {
|
|
75
|
+
if (p.msg.kind !== "tool_call" || !p.msg.toolCallText)
|
|
76
|
+
continue;
|
|
77
|
+
const key = contentHash(p.msg.toolCallText);
|
|
78
|
+
const list = callSeen.get(key) ?? [];
|
|
79
|
+
list.push(p.msg.index);
|
|
80
|
+
callSeen.set(key, list);
|
|
81
|
+
}
|
|
82
|
+
for (const [, idxs] of callSeen) {
|
|
83
|
+
if (idxs.length > 1) {
|
|
84
|
+
findings.push({
|
|
85
|
+
id: "repeated_tool_call",
|
|
86
|
+
severity: "info",
|
|
87
|
+
estSavings: 0,
|
|
88
|
+
message: `The same tool call (with identical arguments) appears ${idxs.length} times (messages #${idxs.join(", #")}).`,
|
|
89
|
+
suggestion: "Repeated identical calls usually mean the earlier result scrolled out of the model's attention — cache results or surface them in a compact recap instead of re-calling.",
|
|
90
|
+
messages: idxs,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// -- Base64 / binary blobs ---------------------------------------------------
|
|
95
|
+
for (const p of perMessage) {
|
|
96
|
+
if (BASE64_RE.test(p.msg.text)) {
|
|
97
|
+
findings.push({
|
|
98
|
+
id: "base64_blob",
|
|
99
|
+
severity: "high",
|
|
100
|
+
estSavings: Math.round(p.tokens * 0.9),
|
|
101
|
+
message: `Message #${p.msg.index} contains a base64/binary blob (~${p.tokens} tokens of mostly meaningless characters).`,
|
|
102
|
+
suggestion: "Never put base64 in text content — use the provider's file/image APIs or replace with a file reference.",
|
|
103
|
+
messages: [p.msg.index],
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// -- Long history ------------------------------------------------------------
|
|
108
|
+
const turnCount = conv.messages.filter((m) => m.kind === "user" || m.kind === "assistant").length;
|
|
109
|
+
if (turnCount > 40) {
|
|
110
|
+
const olderHalf = perMessage.slice(0, Math.floor(perMessage.length / 2));
|
|
111
|
+
const olderTokens = olderHalf.reduce((s, p) => s + p.tokens, 0);
|
|
112
|
+
findings.push({
|
|
113
|
+
id: "long_history",
|
|
114
|
+
severity: "warn",
|
|
115
|
+
estSavings: Math.round(olderTokens * 0.7),
|
|
116
|
+
message: `Conversation has ${turnCount} turns; the older half holds ~${olderTokens} tokens.`,
|
|
117
|
+
suggestion: "Summarize the older half of the conversation into a compact recap and drop the raw turns (context-doctor optimize --strategy prune-history).",
|
|
118
|
+
messages: [],
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
// -- System prompt share -----------------------------------------------------
|
|
122
|
+
if (totalTokens > 0 && categories.system / totalTokens > 0.25 && categories.system > 2000) {
|
|
123
|
+
findings.push({
|
|
124
|
+
id: "large_system_prompt",
|
|
125
|
+
severity: "info",
|
|
126
|
+
estSavings: 0,
|
|
127
|
+
message: `System prompt is ~${categories.system} tokens (${Math.round((categories.system / totalTokens) * 100)}% of the context).`,
|
|
128
|
+
suggestion: "A big system prompt is fine IF it is stable — put it first and use prompt caching (Anthropic: cache_control; OpenAI: automatic prefix caching) so you stop paying full price for it every call.",
|
|
129
|
+
messages: [],
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
// -- Cache-friendly ordering (advisory) --------------------------------------
|
|
133
|
+
if (providerFor(model) === "anthropic" || providerFor(model) === "openai") {
|
|
134
|
+
findings.push({
|
|
135
|
+
id: "cache_ordering",
|
|
136
|
+
severity: "info",
|
|
137
|
+
estSavings: 0,
|
|
138
|
+
message: "Prompt caching only matches a byte-identical prefix.",
|
|
139
|
+
suggestion: "Keep stable content (system prompt, tool definitions, reference docs) at the start and never interleave it with per-request content — a single changed byte early in the prompt invalidates the cache for everything after it.",
|
|
140
|
+
messages: [],
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
// -- Window pressure ---------------------------------------------------------
|
|
144
|
+
const window = contextWindowFor(model);
|
|
145
|
+
const usagePct = window ? (totalTokens / window) * 100 : undefined;
|
|
146
|
+
if (usagePct !== undefined && usagePct > 70) {
|
|
147
|
+
findings.push({
|
|
148
|
+
id: "near_window_limit",
|
|
149
|
+
severity: usagePct > 90 ? "high" : "warn",
|
|
150
|
+
estSavings: 0,
|
|
151
|
+
message: `Context is at ~${usagePct.toFixed(0)}% of ${model}'s window.`,
|
|
152
|
+
suggestion: "Models degrade well before the hard limit (lost-in-the-middle). Compact now rather than when the request fails.",
|
|
153
|
+
messages: [],
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
const largestMessages = perMessage
|
|
157
|
+
.map((p) => ({
|
|
158
|
+
index: p.msg.index,
|
|
159
|
+
role: p.msg.role,
|
|
160
|
+
kind: p.msg.kind,
|
|
161
|
+
tokens: p.tokens,
|
|
162
|
+
preview: preview(p.msg.text),
|
|
163
|
+
toolName: p.msg.toolName,
|
|
164
|
+
}))
|
|
165
|
+
.sort((a, b) => b.tokens - a.tokens)
|
|
166
|
+
.slice(0, 5);
|
|
167
|
+
const severityRank = { high: 0, warn: 1, info: 2 };
|
|
168
|
+
findings.sort((a, b) => severityRank[a.severity] - severityRank[b.severity] || b.estSavings - a.estSavings);
|
|
169
|
+
const totalEstSavings = findings.reduce((s, f) => s + f.estSavings, 0);
|
|
170
|
+
const pricing = pricingFor(model);
|
|
171
|
+
let cost;
|
|
172
|
+
if (pricing) {
|
|
173
|
+
const perCallUsd = inputCostUsd(totalTokens, pricing);
|
|
174
|
+
const savingsPerCallUsd = inputCostUsd(totalEstSavings, pricing);
|
|
175
|
+
cost = {
|
|
176
|
+
perCallUsd,
|
|
177
|
+
per1kCallsUsd: perCallUsd * 1000,
|
|
178
|
+
savingsPerCallUsd,
|
|
179
|
+
savingsPer1kCallsUsd: savingsPerCallUsd * 1000,
|
|
180
|
+
ttftSeconds: estimatedTtftSeconds(totalTokens),
|
|
181
|
+
ttftSavedSeconds: estimatedTtftSeconds(totalEstSavings),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
totalTokens,
|
|
186
|
+
model,
|
|
187
|
+
contextWindow: window,
|
|
188
|
+
usagePct,
|
|
189
|
+
messageCount: conv.messages.length,
|
|
190
|
+
categories,
|
|
191
|
+
largestMessages,
|
|
192
|
+
findings,
|
|
193
|
+
totalEstSavings,
|
|
194
|
+
cost,
|
|
195
|
+
sourceFormat: conv.sourceFormat,
|
|
196
|
+
};
|
|
197
|
+
}
|
package/dist/proxy.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Always-on optimization: a local reverse proxy that sits between your app
|
|
3
|
+
* and the Anthropic/OpenAI APIs. Every request's message history is optimized
|
|
4
|
+
* in flight (dedupe, trim stale tool results, strip base64) before being
|
|
5
|
+
* forwarded — no code changes in your app, just a base-URL env var:
|
|
6
|
+
*
|
|
7
|
+
* ANTHROPIC_BASE_URL=http://localhost:8787 (Anthropic SDKs)
|
|
8
|
+
* OPENAI_BASE_URL=http://localhost:8787/v1 (OpenAI SDKs)
|
|
9
|
+
*
|
|
10
|
+
* API keys pass through untouched in headers — the proxy stores nothing and
|
|
11
|
+
* talks only to the official upstream endpoints (overridable for testing).
|
|
12
|
+
* Streaming responses are piped through unchanged.
|
|
13
|
+
*/
|
|
14
|
+
import http from "node:http";
|
|
15
|
+
import { OptimizeOptions } from "./optimize.js";
|
|
16
|
+
export interface ProxyOptions extends OptimizeOptions {
|
|
17
|
+
port?: number;
|
|
18
|
+
anthropicUpstream?: string;
|
|
19
|
+
openaiUpstream?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface ProxyStats {
|
|
22
|
+
startedAt: string;
|
|
23
|
+
requests: number;
|
|
24
|
+
optimizedRequests: number;
|
|
25
|
+
tokensBefore: number;
|
|
26
|
+
tokensAfter: number;
|
|
27
|
+
tokensSaved: number;
|
|
28
|
+
/** USD saved on input tokens, when the request's model has a known price. */
|
|
29
|
+
estUsdSaved: number;
|
|
30
|
+
}
|
|
31
|
+
export declare function startProxy(opts?: ProxyOptions): http.Server;
|
package/dist/proxy.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Always-on optimization: a local reverse proxy that sits between your app
|
|
3
|
+
* and the Anthropic/OpenAI APIs. Every request's message history is optimized
|
|
4
|
+
* in flight (dedupe, trim stale tool results, strip base64) before being
|
|
5
|
+
* forwarded — no code changes in your app, just a base-URL env var:
|
|
6
|
+
*
|
|
7
|
+
* ANTHROPIC_BASE_URL=http://localhost:8787 (Anthropic SDKs)
|
|
8
|
+
* OPENAI_BASE_URL=http://localhost:8787/v1 (OpenAI SDKs)
|
|
9
|
+
*
|
|
10
|
+
* API keys pass through untouched in headers — the proxy stores nothing and
|
|
11
|
+
* talks only to the official upstream endpoints (overridable for testing).
|
|
12
|
+
* Streaming responses are piped through unchanged.
|
|
13
|
+
*/
|
|
14
|
+
import http from "node:http";
|
|
15
|
+
import { optimizeConversation } from "./optimize.js";
|
|
16
|
+
import { formatTokens } from "./tokens.js";
|
|
17
|
+
import { formatUsd, inputCostUsd, pricingFor } from "./pricing.js";
|
|
18
|
+
/** Connection-level headers that must not be forwarded. */
|
|
19
|
+
const SKIP_REQUEST_HEADERS = new Set(["host", "content-length", "connection", "transfer-encoding", "accept-encoding", "expect"]);
|
|
20
|
+
const SKIP_RESPONSE_HEADERS = new Set(["content-length", "content-encoding", "transfer-encoding", "connection"]);
|
|
21
|
+
function upstreamFor(url, opts) {
|
|
22
|
+
if (url.startsWith("/v1/messages"))
|
|
23
|
+
return opts.anthropicUpstream ?? "https://api.anthropic.com";
|
|
24
|
+
if (url.startsWith("/v1/chat/completions") || url.startsWith("/v1/responses") || url.startsWith("/v1/embeddings")) {
|
|
25
|
+
return opts.openaiUpstream ?? "https://api.openai.com";
|
|
26
|
+
}
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
export function startProxy(opts = {}) {
|
|
30
|
+
const port = opts.port ?? 8787;
|
|
31
|
+
const stats = {
|
|
32
|
+
startedAt: new Date().toISOString(),
|
|
33
|
+
requests: 0,
|
|
34
|
+
optimizedRequests: 0,
|
|
35
|
+
tokensBefore: 0,
|
|
36
|
+
tokensAfter: 0,
|
|
37
|
+
tokensSaved: 0,
|
|
38
|
+
estUsdSaved: 0,
|
|
39
|
+
};
|
|
40
|
+
const server = http.createServer(async (req, res) => {
|
|
41
|
+
const url = req.url ?? "/";
|
|
42
|
+
try {
|
|
43
|
+
if (url === "/health") {
|
|
44
|
+
res.setHeader("content-type", "application/json");
|
|
45
|
+
res.end(JSON.stringify({ ok: true, service: "context-doctor-proxy" }));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (url === "/stats") {
|
|
49
|
+
res.setHeader("content-type", "application/json");
|
|
50
|
+
res.end(JSON.stringify({ ...stats, estUsdSaved: Number(stats.estUsdSaved.toFixed(4)) }, null, 2));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const upstreamBase = upstreamFor(url, opts);
|
|
54
|
+
if (!upstreamBase) {
|
|
55
|
+
res.statusCode = 404;
|
|
56
|
+
res.setHeader("content-type", "application/json");
|
|
57
|
+
res.end(JSON.stringify({ error: `context-doctor proxy: unsupported path ${url} (supported: /v1/messages, /v1/chat/completions, /v1/responses)` }));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const chunks = [];
|
|
61
|
+
for await (const chunk of req)
|
|
62
|
+
chunks.push(chunk);
|
|
63
|
+
let body = Buffer.concat(chunks).toString("utf8");
|
|
64
|
+
// Optimize the message history in flight. Anything unparseable (or with
|
|
65
|
+
// no messages array, e.g. embeddings) passes through untouched.
|
|
66
|
+
stats.requests++;
|
|
67
|
+
let note = "passthrough";
|
|
68
|
+
if (req.method === "POST" && body) {
|
|
69
|
+
try {
|
|
70
|
+
const result = optimizeConversation(body, opts);
|
|
71
|
+
const saved = result.tokensBefore - result.tokensAfter;
|
|
72
|
+
stats.tokensBefore += result.tokensBefore;
|
|
73
|
+
stats.tokensAfter += result.tokensAfter;
|
|
74
|
+
if (saved > 0) {
|
|
75
|
+
body = JSON.stringify(result.conversation);
|
|
76
|
+
stats.optimizedRequests++;
|
|
77
|
+
stats.tokensSaved += saved;
|
|
78
|
+
const pricing = pricingFor(result.conversation?.model);
|
|
79
|
+
if (pricing)
|
|
80
|
+
stats.estUsdSaved += inputCostUsd(saved, pricing);
|
|
81
|
+
}
|
|
82
|
+
note = saved > 0
|
|
83
|
+
? `optimized ${formatTokens(result.tokensBefore)} → ${formatTokens(result.tokensAfter)} tokens (${result.applied.length} changes)`
|
|
84
|
+
: "clean (nothing to save)";
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
/* not a conversation payload — forward as-is */
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const headers = {};
|
|
91
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
92
|
+
if (!SKIP_REQUEST_HEADERS.has(key.toLowerCase()) && typeof value === "string")
|
|
93
|
+
headers[key] = value;
|
|
94
|
+
}
|
|
95
|
+
const upstreamStart = Date.now();
|
|
96
|
+
const upstream = await fetch(upstreamBase + url, {
|
|
97
|
+
method: req.method ?? "POST",
|
|
98
|
+
headers,
|
|
99
|
+
body: req.method === "GET" || req.method === "HEAD" ? undefined : body,
|
|
100
|
+
});
|
|
101
|
+
console.error(`[context-doctor] ${req.method} ${url} → ${upstream.status} in ${Date.now() - upstreamStart}ms | ${note}` +
|
|
102
|
+
(stats.tokensSaved > 0 ? ` | session total: ${formatTokens(stats.tokensSaved)} tokens ≈ ${formatUsd(stats.estUsdSaved)} saved` : ""));
|
|
103
|
+
res.statusCode = upstream.status;
|
|
104
|
+
upstream.headers.forEach((value, key) => {
|
|
105
|
+
if (!SKIP_RESPONSE_HEADERS.has(key))
|
|
106
|
+
res.setHeader(key, value);
|
|
107
|
+
});
|
|
108
|
+
if (upstream.body) {
|
|
109
|
+
// Pipe through chunk-by-chunk so SSE streaming works unchanged.
|
|
110
|
+
const reader = upstream.body.getReader();
|
|
111
|
+
for (;;) {
|
|
112
|
+
const { done, value } = await reader.read();
|
|
113
|
+
if (done)
|
|
114
|
+
break;
|
|
115
|
+
res.write(value);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
res.end();
|
|
119
|
+
}
|
|
120
|
+
catch (e) {
|
|
121
|
+
console.error(`[context-doctor] error on ${url}: ${e.message}`);
|
|
122
|
+
if (!res.headersSent) {
|
|
123
|
+
res.statusCode = 502;
|
|
124
|
+
res.setHeader("content-type", "application/json");
|
|
125
|
+
}
|
|
126
|
+
res.end(JSON.stringify({ error: `context-doctor proxy: ${e.message}` }));
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
server.listen(port, () => {
|
|
130
|
+
console.error(`context-doctor proxy listening on http://localhost:${port}`);
|
|
131
|
+
console.error(` Anthropic apps/SDKs: export ANTHROPIC_BASE_URL=http://localhost:${port}`);
|
|
132
|
+
console.error(` OpenAI apps/SDKs: export OPENAI_BASE_URL=http://localhost:${port}/v1`);
|
|
133
|
+
console.error(` Every request's context is optimized in flight; savings are logged here.`);
|
|
134
|
+
console.error(` Cumulative savings: http://localhost:${port}/stats`);
|
|
135
|
+
});
|
|
136
|
+
return server;
|
|
137
|
+
}
|
package/dist/report.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render a ContextProfile as a readable terminal/markdown report.
|
|
3
|
+
* Plain text with ASCII bars — no color deps, so output pastes cleanly
|
|
4
|
+
* anywhere (terminals, issues, chat).
|
|
5
|
+
*/
|
|
6
|
+
import { ContextProfile } from "./profile.js";
|
|
7
|
+
export declare function renderProfile(profile: ContextProfile): string;
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render a ContextProfile as a readable terminal/markdown report.
|
|
3
|
+
* Plain text with ASCII bars — no color deps, so output pastes cleanly
|
|
4
|
+
* anywhere (terminals, issues, chat).
|
|
5
|
+
*/
|
|
6
|
+
import { formatTokens } from "./tokens.js";
|
|
7
|
+
import { formatUsd } from "./pricing.js";
|
|
8
|
+
const CATEGORY_LABELS = {
|
|
9
|
+
system: "System prompt",
|
|
10
|
+
user: "User messages",
|
|
11
|
+
assistant: "Assistant replies",
|
|
12
|
+
tool_calls: "Tool calls",
|
|
13
|
+
tool_results: "Tool results",
|
|
14
|
+
other: "Other",
|
|
15
|
+
};
|
|
16
|
+
const SEVERITY_MARK = { high: "✖", warn: "▲", info: "ℹ" };
|
|
17
|
+
function bar(fraction, width = 28) {
|
|
18
|
+
const filled = Math.round(fraction * width);
|
|
19
|
+
return "█".repeat(filled) + "░".repeat(width - filled);
|
|
20
|
+
}
|
|
21
|
+
export function renderProfile(profile) {
|
|
22
|
+
const lines = [];
|
|
23
|
+
const p = profile;
|
|
24
|
+
lines.push("CONTEXT DOCTOR — profile");
|
|
25
|
+
lines.push("═".repeat(56));
|
|
26
|
+
lines.push(`Total: ~${formatTokens(p.totalTokens)} tokens across ${p.messageCount} messages (${p.sourceFormat} format)`);
|
|
27
|
+
if (p.model) {
|
|
28
|
+
const windowNote = p.contextWindow
|
|
29
|
+
? ` of ${formatTokens(p.contextWindow)} window (${p.usagePct.toFixed(1)}%)`
|
|
30
|
+
: " (unknown window size)";
|
|
31
|
+
lines.push(`Model: ${p.model}${windowNote}`);
|
|
32
|
+
}
|
|
33
|
+
if (p.cost) {
|
|
34
|
+
lines.push(`Cost: ~${formatUsd(p.cost.perCallUsd)} input per call · ~${formatUsd(p.cost.per1kCallsUsd)} per 1k calls · ` +
|
|
35
|
+
`~${p.cost.ttftSeconds.toFixed(1)}s of latency per call (estimates)`);
|
|
36
|
+
}
|
|
37
|
+
lines.push("");
|
|
38
|
+
// Category breakdown, largest first
|
|
39
|
+
lines.push("Where the tokens go");
|
|
40
|
+
lines.push("─".repeat(56));
|
|
41
|
+
const cats = Object.entries(p.categories)
|
|
42
|
+
.filter(([, t]) => t > 0)
|
|
43
|
+
.sort((a, b) => b[1] - a[1]);
|
|
44
|
+
const maxLabel = Math.max(...cats.map(([c]) => CATEGORY_LABELS[c].length));
|
|
45
|
+
for (const [cat, tokens] of cats) {
|
|
46
|
+
const frac = p.totalTokens > 0 ? tokens / p.totalTokens : 0;
|
|
47
|
+
lines.push(`${CATEGORY_LABELS[cat].padEnd(maxLabel)} ${bar(frac)} ${String(Math.round(frac * 100)).padStart(3)}% ~${formatTokens(tokens)}`);
|
|
48
|
+
}
|
|
49
|
+
lines.push("");
|
|
50
|
+
// Largest messages
|
|
51
|
+
lines.push("Largest messages");
|
|
52
|
+
lines.push("─".repeat(56));
|
|
53
|
+
for (const m of p.largestMessages) {
|
|
54
|
+
const label = m.toolName ? `${m.kind}:${m.toolName}` : m.kind;
|
|
55
|
+
lines.push(` #${m.index} [${label}] ~${formatTokens(m.tokens)} ${m.preview}`);
|
|
56
|
+
}
|
|
57
|
+
lines.push("");
|
|
58
|
+
// Findings
|
|
59
|
+
if (p.findings.length > 0) {
|
|
60
|
+
lines.push(`Findings (${p.findings.length})`);
|
|
61
|
+
lines.push("─".repeat(56));
|
|
62
|
+
for (const f of p.findings) {
|
|
63
|
+
const savings = f.estSavings > 0 ? ` [save ~${formatTokens(f.estSavings)}]` : "";
|
|
64
|
+
lines.push(`${SEVERITY_MARK[f.severity]} ${f.message}${savings}`);
|
|
65
|
+
lines.push(` → ${f.suggestion}`);
|
|
66
|
+
}
|
|
67
|
+
lines.push("");
|
|
68
|
+
if (p.totalEstSavings > 0) {
|
|
69
|
+
const pct = p.totalTokens > 0 ? Math.round((p.totalEstSavings / p.totalTokens) * 100) : 0;
|
|
70
|
+
let recovery = `Potential recovery: ~${formatTokens(p.totalEstSavings)} tokens (~${pct}% of context)`;
|
|
71
|
+
if (p.cost && p.cost.savingsPerCallUsd > 0) {
|
|
72
|
+
recovery += ` ≈ ${formatUsd(p.cost.savingsPer1kCallsUsd)} per 1k calls, ${p.cost.ttftSavedSeconds.toFixed(1)}s faster per call`;
|
|
73
|
+
}
|
|
74
|
+
lines.push(recovery);
|
|
75
|
+
lines.push(`Run \`context-doctor optimize <file>\` to apply the safe fixes automatically.`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
lines.push("No issues found — this context is in good shape.");
|
|
80
|
+
}
|
|
81
|
+
return lines.join("\n");
|
|
82
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code session analyzer: profile the transcripts Claude Code writes to
|
|
3
|
+
* ~/.claude/projects/<project>/<session>.jsonl, answering "where did my
|
|
4
|
+
* tokens go?" for real sessions instead of hand-exported conversations.
|
|
5
|
+
*
|
|
6
|
+
* Transcript lines are JSON objects; the ones that matter here are
|
|
7
|
+
* `{type: "user"|"assistant", message: {role, content}, isSidechain, ...}`
|
|
8
|
+
* where `message` is in Anthropic Messages format. Everything else
|
|
9
|
+
* (titles, mode changes, hook records) is metadata and skipped.
|
|
10
|
+
*/
|
|
11
|
+
export interface SessionInfo {
|
|
12
|
+
path: string;
|
|
13
|
+
project: string;
|
|
14
|
+
modifiedAt: Date;
|
|
15
|
+
sizeBytes: number;
|
|
16
|
+
}
|
|
17
|
+
export interface ParsedSession {
|
|
18
|
+
/** Conversation JSON string in Anthropic-ish format, ready for parseConversation(). */
|
|
19
|
+
conversationJson: string;
|
|
20
|
+
title?: string;
|
|
21
|
+
model?: string;
|
|
22
|
+
messageCount: number;
|
|
23
|
+
path: string;
|
|
24
|
+
}
|
|
25
|
+
/** All session transcripts on this machine, newest first. */
|
|
26
|
+
export declare function listSessions(limit?: number): SessionInfo[];
|
|
27
|
+
export declare function parseSessionFile(path: string): ParsedSession;
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code session analyzer: profile the transcripts Claude Code writes to
|
|
3
|
+
* ~/.claude/projects/<project>/<session>.jsonl, answering "where did my
|
|
4
|
+
* tokens go?" for real sessions instead of hand-exported conversations.
|
|
5
|
+
*
|
|
6
|
+
* Transcript lines are JSON objects; the ones that matter here are
|
|
7
|
+
* `{type: "user"|"assistant", message: {role, content}, isSidechain, ...}`
|
|
8
|
+
* where `message` is in Anthropic Messages format. Everything else
|
|
9
|
+
* (titles, mode changes, hook records) is metadata and skipped.
|
|
10
|
+
*/
|
|
11
|
+
import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
function projectsDir() {
|
|
15
|
+
return join(homedir(), ".claude", "projects");
|
|
16
|
+
}
|
|
17
|
+
/** All session transcripts on this machine, newest first. */
|
|
18
|
+
export function listSessions(limit = 20) {
|
|
19
|
+
const root = projectsDir();
|
|
20
|
+
if (!existsSync(root))
|
|
21
|
+
return [];
|
|
22
|
+
const sessions = [];
|
|
23
|
+
for (const project of readdirSync(root)) {
|
|
24
|
+
const dir = join(root, project);
|
|
25
|
+
let entries;
|
|
26
|
+
try {
|
|
27
|
+
entries = readdirSync(dir);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
continue; // not a directory
|
|
31
|
+
}
|
|
32
|
+
for (const file of entries) {
|
|
33
|
+
if (!file.endsWith(".jsonl"))
|
|
34
|
+
continue;
|
|
35
|
+
const path = join(dir, file);
|
|
36
|
+
const stat = statSync(path);
|
|
37
|
+
sessions.push({ path, project, modifiedAt: stat.mtime, sizeBytes: stat.size });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return sessions.sort((a, b) => b.modifiedAt.getTime() - a.modifiedAt.getTime()).slice(0, limit);
|
|
41
|
+
}
|
|
42
|
+
export function parseSessionFile(path) {
|
|
43
|
+
const raw = readFileSync(path, "utf8");
|
|
44
|
+
const messages = [];
|
|
45
|
+
let title;
|
|
46
|
+
let model;
|
|
47
|
+
for (const line of raw.split("\n")) {
|
|
48
|
+
if (!line.trim())
|
|
49
|
+
continue;
|
|
50
|
+
let entry;
|
|
51
|
+
try {
|
|
52
|
+
entry = JSON.parse(line);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
// Titles are metadata lines; the last one wins.
|
|
58
|
+
if (entry.type === "custom-title" && entry.customTitle)
|
|
59
|
+
title = entry.customTitle;
|
|
60
|
+
if (entry.type === "ai-title" && entry.aiTitle && !title)
|
|
61
|
+
title = entry.aiTitle;
|
|
62
|
+
if ((entry.type !== "user" && entry.type !== "assistant") || !entry.message)
|
|
63
|
+
continue;
|
|
64
|
+
if (entry.isSidechain)
|
|
65
|
+
continue; // subagent traffic has its own context window
|
|
66
|
+
const message = entry.message;
|
|
67
|
+
if (!message.role || message.content == null)
|
|
68
|
+
continue;
|
|
69
|
+
if (typeof message.model === "string")
|
|
70
|
+
model = message.model;
|
|
71
|
+
messages.push({ role: message.role, content: message.content });
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
conversationJson: JSON.stringify({ messages }),
|
|
75
|
+
title,
|
|
76
|
+
model,
|
|
77
|
+
messageCount: messages.length,
|
|
78
|
+
path,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proxy end-to-end test against a mock upstream: verifies in-flight
|
|
3
|
+
* optimization, tool_result preservation, header passthrough, SSE-style
|
|
4
|
+
* streaming, and the /stats endpoint.
|
|
5
|
+
*/
|
|
6
|
+
import { test, after } from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import http from "node:http";
|
|
9
|
+
import { startProxy } from "../proxy.js";
|
|
10
|
+
const bigTool = "row of data | ".repeat(2000);
|
|
11
|
+
const doc = "TERMS: usage is billed monthly per seat with overage charged at cycle end. ".repeat(8);
|
|
12
|
+
const payload = JSON.stringify({
|
|
13
|
+
model: "claude-sonnet-5",
|
|
14
|
+
max_tokens: 100,
|
|
15
|
+
system: "You are helpful.",
|
|
16
|
+
messages: [
|
|
17
|
+
{ role: "user", content: "check the data\n" + doc },
|
|
18
|
+
{ role: "assistant", content: [{ type: "tool_use", id: "t1", name: "query_db", input: { q: "select *" } }] },
|
|
19
|
+
{ role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: bigTool }] },
|
|
20
|
+
{ role: "assistant", content: "Done." },
|
|
21
|
+
{ role: "user", content: "check the data\n" + doc },
|
|
22
|
+
...Array.from({ length: 7 }, (_, i) => ({ role: "user", content: `follow-up ${i}` })),
|
|
23
|
+
],
|
|
24
|
+
});
|
|
25
|
+
let received = "";
|
|
26
|
+
let receivedApiKey;
|
|
27
|
+
const upstream = http.createServer((req, res) => {
|
|
28
|
+
let body = "";
|
|
29
|
+
req.on("data", (c) => (body += c));
|
|
30
|
+
req.on("end", () => {
|
|
31
|
+
received = body;
|
|
32
|
+
receivedApiKey = req.headers["x-api-key"];
|
|
33
|
+
res.writeHead(200, { "content-type": "text/event-stream" });
|
|
34
|
+
res.write("event: message_start\ndata: {}\n\n");
|
|
35
|
+
res.write("event: message_stop\ndata: {}\n\n");
|
|
36
|
+
res.end();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
await new Promise((r) => upstream.listen(0, r));
|
|
40
|
+
const upstreamPort = upstream.address().port;
|
|
41
|
+
const proxy = startProxy({ port: 0, anthropicUpstream: `http://localhost:${upstreamPort}` });
|
|
42
|
+
await new Promise((r) => proxy.once("listening", () => r()));
|
|
43
|
+
const proxyPort = proxy.address().port;
|
|
44
|
+
after(() => {
|
|
45
|
+
proxy.close();
|
|
46
|
+
upstream.close();
|
|
47
|
+
});
|
|
48
|
+
test("proxy optimizes in flight and passes through auth + streaming", async () => {
|
|
49
|
+
const resp = await fetch(`http://localhost:${proxyPort}/v1/messages`, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: { "content-type": "application/json", "x-api-key": "sk-test-not-real", "anthropic-version": "2023-06-01" },
|
|
52
|
+
body: payload,
|
|
53
|
+
});
|
|
54
|
+
const respText = await resp.text();
|
|
55
|
+
assert.ok(received.length < payload.length, "upstream received a smaller body");
|
|
56
|
+
const parsed = JSON.parse(received);
|
|
57
|
+
const toolBlock = parsed.messages[2].content[0];
|
|
58
|
+
assert.equal(toolBlock.type, "tool_result");
|
|
59
|
+
assert.equal(toolBlock.tool_use_id, "t1");
|
|
60
|
+
assert.equal(parsed.model, "claude-sonnet-5");
|
|
61
|
+
assert.equal(receivedApiKey, "sk-test-not-real");
|
|
62
|
+
assert.equal(resp.status, 200);
|
|
63
|
+
assert.ok(respText.includes("message_start") && respText.includes("message_stop"), "SSE streamed through");
|
|
64
|
+
});
|
|
65
|
+
test("/stats reports cumulative savings with dollar estimate", async () => {
|
|
66
|
+
const stats = await (await fetch(`http://localhost:${proxyPort}/stats`)).json();
|
|
67
|
+
assert.equal(stats.requests, 1);
|
|
68
|
+
assert.equal(stats.optimizedRequests, 1);
|
|
69
|
+
assert.ok(stats.tokensSaved > 1000, `saved tokens tracked (${stats.tokensSaved})`);
|
|
70
|
+
assert.ok(stats.estUsdSaved > 0, "dollar savings estimated from the request's model");
|
|
71
|
+
});
|
|
72
|
+
test("unsupported paths get a clear 404, health stays up", async () => {
|
|
73
|
+
const notFound = await fetch(`http://localhost:${proxyPort}/v1/nope`, { method: "POST", body: "{}" });
|
|
74
|
+
assert.equal(notFound.status, 404);
|
|
75
|
+
const health = await (await fetch(`http://localhost:${proxyPort}/health`)).json();
|
|
76
|
+
assert.equal(health.ok, true);
|
|
77
|
+
});
|