context-doctor 0.6.0 → 0.8.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 CHANGED
@@ -34,14 +34,12 @@ Findings (4)
34
34
  npx context-doctor install
35
35
  ```
36
36
 
37
- Until the package lands on npm, install straight from GitHub instead (needs Node 18+):
37
+ That single command is also all it takes to **set up context-doctor on anyone else's machine**. Prefer a global install, or want the unreleased `main`? Both work (Node 18+):
38
38
 
39
39
  ```bash
40
- npm install -g github:KushalP1/context-doctor && context-doctor install
40
+ npm install -g context-doctor && context-doctor install
41
41
  ```
42
42
 
43
- That pair of commands is also all it takes to **set up context-doctor on anyone else's machine**.
44
-
45
43
  Restart your apps, then just ask Claude: *"what's eating my context?"* (`npx context-doctor uninstall` reverses it.)
46
44
 
47
45
  **No API keys, ever.** Everything is deterministic local code; when an LLM is needed (summarizing pruned history), the model already running in your app does it. The proxy forwards *your app's* credentials untouched — context-doctor itself holds nothing.
@@ -97,6 +95,7 @@ Practical upshot: a developer who only wants cheaper, faster API calls never tou
97
95
  | `context-doctor proxy` | Always-on local proxy that optimizes every Anthropic/OpenAI API request in flight (`/stats` for cumulative savings) |
98
96
  | `context-doctor watch [file]` | Live monitor of a growing session/agent trace: token/cost line per change, findings as they appear |
99
97
  | `context-doctor doctor` | Self-check the whole installation — one pasteable ✓/✗ diagnosis with fixes |
98
+ | `context-doctor dashboard` | Local savings dashboard on 127.0.0.1: tokens saved per day, sessions by context in use vs recoverable, budget status |
100
99
  | `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) |
101
100
  | `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 |
102
101
 
@@ -163,7 +162,11 @@ The proxy dedupes repeated content, trims stale tool results, and strips base64
163
162
  [context-doctor] POST /v1/messages → 200 in 842ms | optimized 7.3k → 518 tokens (2 changes) | session total: 6.9k tokens ≈ $0.021 saved
164
163
  ```
165
164
 
166
- `GET http://localhost:8787/stats` returns cumulative savings (requests, tokens, estimated USD) since launch.
165
+ `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`:
166
+
167
+ ```json
168
+ { "routes": [{ "modelPrefix": "gpt", "strategies": ["strip-base64"], "keepRecent": 4 }] }
169
+ ```
167
170
 
168
171
  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
172
 
@@ -279,6 +282,20 @@ One report for your whole machine, led by a headline of **tokens context-doctor
279
282
 
280
283
  Honest measurement note: proxy numbers are exact. Session numbers are measured-now. What no tool can report is the counterfactual — tokens Claude *avoided* adding because of the hygiene guidance — since the same session can't be re-run without it. The report says so instead of inventing a number.
281
284
 
285
+ ## Context budgets (`.contextdoctorrc`)
286
+
287
+ Drop a `.contextdoctorrc` in a project (or your home directory) and context-doctor enforces your limits instead of its defaults:
288
+
289
+ ```json
290
+ {
291
+ "budget": { "maxTokens": 120000, "maxCostPerMessageUsd": 0.5, "maxWindowPct": 60 },
292
+ "strategies": ["dedupe", "trim-tool-results"],
293
+ "keepRecent": 6
294
+ }
295
+ ```
296
+
297
+ The nearest file wins (walking up from the working directory, then `~`). `analyze` and `session` print a budget verdict, the every-prompt hook uses `maxTokens` as its warning threshold and names the breach to the model, and `optimize`/`proxy` pick up the defaults when you do not pass flags.
298
+
282
299
  ## Performance: what context-doctor itself costs
283
300
 
284
301
  A tool that promises speed must be near-free. Measured overhead per touchpoint:
package/dist/cli.js CHANGED
@@ -23,6 +23,8 @@ import { recordLedger } from "./ledger.js";
23
23
  import { runDoctor } from "./doctor.js";
24
24
  import { runWatch } from "./watch.js";
25
25
  import { exactTokenCount } from "./exact.js";
26
+ import { checkBudget, loadConfig } from "./config.js";
27
+ import { startDashboard } from "./dashboard.js";
26
28
  const HELP = `context-doctor — profile and optimize LLM context windows
27
29
 
28
30
  Usage:
@@ -41,10 +43,17 @@ Usage:
41
43
  and remaining recoverable waste in recent sessions
42
44
  context-doctor doctor Self-check the installation (configs, hook, skill,
43
45
  MCP handshake) with one pasteable diagnosis
46
+ context-doctor dashboard Local savings dashboard on 127.0.0.1 (--port n,
47
+ default 8790) — charts from your own machine only
44
48
  context-doctor watch [file] Live-monitor a growing session/agent trace: running
45
49
  token/cost line per change, new findings as they appear
46
50
  (--interval-ms n, default 2000)
47
51
 
52
+ Project config: an optional .contextdoctorrc (nearest, walking up from cwd, then
53
+ ~/.contextdoctorrc) can set a context budget and default strategies:
54
+ {"budget":{"maxTokens":120000,"maxCostPerMessageUsd":0.5,"maxWindowPct":60},
55
+ "strategies":["dedupe","trim-tool-results"],"routes":[...]}
56
+
48
57
  Input: a conversation JSON file (OpenAI or Anthropic message format, or a bare
49
58
  message array). Use "-" to read from stdin.
50
59
 
@@ -61,6 +70,8 @@ Options:
61
70
  --max-tool-tokens <n> (optimize) Token budget for trimmed tool results (default 300)
62
71
  --port <n> (proxy) Port to listen on (default 8787)
63
72
  --host <addr> (proxy) Bind address (default 127.0.0.1; use 0.0.0.0 to expose)
73
+ --config <file> (proxy) Per-route overrides: {"routes":[{"modelPrefix":"gpt","strategies":[...],
74
+ "keepRecent":n,"maxToolResultTokens":n}]} — first prefix match wins
64
75
  --upstream-anthropic <url> (proxy) Override Anthropic upstream (testing)
65
76
  --upstream-openai <url> (proxy) Override OpenAI upstream (testing)
66
77
  -h, --help Show this help
@@ -115,6 +126,9 @@ function parseArgs(argv) {
115
126
  case "--host":
116
127
  args.host = argv[++i];
117
128
  break;
129
+ case "--config":
130
+ args.config = argv[++i];
131
+ break;
118
132
  case "--upstream-anthropic":
119
133
  args.upstreamAnthropic = argv[++i];
120
134
  break;
@@ -128,6 +142,22 @@ function parseArgs(argv) {
128
142
  args.file = positional[1];
129
143
  return args;
130
144
  }
145
+ /** Print budget status under a profile when a .contextdoctorrc defines one. */
146
+ function printBudgetStatus(profile, loaded) {
147
+ const budget = loaded.config.budget;
148
+ if (!budget || !loaded.path)
149
+ return;
150
+ const verdict = checkBudget(budget, profile);
151
+ console.log("");
152
+ if (verdict.overBudget) {
153
+ console.log(`OVER BUDGET (${loaded.path}):`);
154
+ for (const b of verdict.breaches)
155
+ console.log(` x ${b}`);
156
+ }
157
+ else {
158
+ console.log(`Within budget (${loaded.path}).`);
159
+ }
160
+ }
131
161
  function readInput(file) {
132
162
  if (file === "-")
133
163
  return readFileSync(0, "utf8");
@@ -139,6 +169,10 @@ function main() {
139
169
  void runHook();
140
170
  return;
141
171
  }
172
+ if (args.command === "dashboard") {
173
+ startDashboard({ port: args.port, proxyPort: 8787 });
174
+ return; // server keeps the process alive
175
+ }
142
176
  if (args.command === "watch") {
143
177
  runWatch({ file: args.file, intervalMs: args.intervalMs, model: args.model });
144
178
  return; // interval keeps the process alive
@@ -183,6 +217,7 @@ function main() {
183
217
  else {
184
218
  console.log(`Session: ${parsed.title ?? "(untitled)"}\nFile: ${parsed.path}\n`);
185
219
  console.log(renderProfile(profile));
220
+ printBudgetStatus(profile, loadConfig(process.cwd(), (m) => console.error(`context-doctor: ${m}`)));
186
221
  }
187
222
  return;
188
223
  }
@@ -195,14 +230,26 @@ function main() {
195
230
  return;
196
231
  }
197
232
  if (args.command === "proxy") {
233
+ const loadedRc = loadConfig(process.cwd(), (m) => console.error(`context-doctor: ${m}`));
234
+ let routes = loadedRc.config.routes;
235
+ if (args.config) {
236
+ try {
237
+ routes = JSON.parse(readFileSync(args.config, "utf8")).routes;
238
+ }
239
+ catch (e) {
240
+ console.error(`Could not read --config ${args.config}: ${e.message}`);
241
+ process.exit(1);
242
+ }
243
+ }
198
244
  startProxy({
245
+ routes: routes,
199
246
  port: args.port,
200
247
  host: args.host,
201
248
  anthropicUpstream: args.upstreamAnthropic,
202
249
  openaiUpstream: args.upstreamOpenai,
203
- strategies: args.strategies.length > 0 ? args.strategies : undefined,
204
- keepRecent: args.keepRecent,
205
- maxToolResultTokens: args.maxToolTokens,
250
+ strategies: args.strategies.length > 0 ? args.strategies : loadedRc.config.strategies,
251
+ keepRecent: args.keepRecent ?? loadedRc.config.keepRecent,
252
+ maxToolResultTokens: args.maxToolTokens ?? loadedRc.config.maxToolResultTokens,
206
253
  });
207
254
  return; // server keeps the process alive
208
255
  }
@@ -219,8 +266,11 @@ function main() {
219
266
  process.exit(1);
220
267
  }
221
268
  if (args.command === "analyze") {
222
- const profile = profileConversation(parseConversation(input), args.model);
269
+ const loaded = loadConfig(process.cwd(), (m) => console.error(`context-doctor: ${m}`));
270
+ const profile = profileConversation(parseConversation(input), args.model ?? loaded.config.model);
223
271
  console.log(args.json ? JSON.stringify(profile, null, 2) : renderProfile(profile));
272
+ if (!args.json)
273
+ printBudgetStatus(profile, loaded);
224
274
  if (args.exact) {
225
275
  void exactTokenCount(input, args.model).then((exact) => {
226
276
  if (exact.tokens !== undefined) {
@@ -237,10 +287,11 @@ function main() {
237
287
  if (args.command === "optimize") {
238
288
  let result;
239
289
  try {
290
+ const loaded = loadConfig(process.cwd(), (m) => console.error(`context-doctor: ${m}`));
240
291
  result = optimizeConversation(input, {
241
- strategies: args.strategies.length > 0 ? args.strategies : undefined,
242
- keepRecent: args.keepRecent,
243
- maxToolResultTokens: args.maxToolTokens,
292
+ strategies: args.strategies.length > 0 ? args.strategies : loaded.config.strategies,
293
+ keepRecent: args.keepRecent ?? loaded.config.keepRecent,
294
+ maxToolResultTokens: args.maxToolTokens ?? loaded.config.maxToolResultTokens,
244
295
  });
245
296
  }
246
297
  catch (e) {
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Project configuration and context budgets (`.contextdoctorrc`).
3
+ *
4
+ * Discovery walks up from the working directory to the filesystem root, then
5
+ * falls back to ~/.contextdoctorrc — so a repo can set its own budget and a
6
+ * user can set a machine-wide default. First file found wins (no merging:
7
+ * one visible file is easier to reason about than a merge chain).
8
+ *
9
+ * Everything here is optional. With no rc file the tool behaves exactly as
10
+ * it always has.
11
+ */
12
+ import type { StrategyId } from "./optimize.js";
13
+ export declare const RC_FILENAME = ".contextdoctorrc";
14
+ export interface ContextBudget {
15
+ /** Warn once a session/conversation exceeds this many tokens. */
16
+ maxTokens?: number;
17
+ /** Warn once estimated input cost per message exceeds this many USD. */
18
+ maxCostPerMessageUsd?: number;
19
+ /** Warn once the context fills this share of the model window (0-100). */
20
+ maxWindowPct?: number;
21
+ }
22
+ export interface ContextDoctorConfig {
23
+ budget?: ContextBudget;
24
+ /** Default optimize strategies for this project. */
25
+ strategies?: StrategyId[];
26
+ keepRecent?: number;
27
+ maxToolResultTokens?: number;
28
+ /** Proxy per-model overrides, same shape as `proxy --config`. */
29
+ routes?: Array<{
30
+ modelPrefix: string;
31
+ strategies?: StrategyId[];
32
+ keepRecent?: number;
33
+ maxToolResultTokens?: number;
34
+ }>;
35
+ /** Model used for cost math when a conversation does not name one. */
36
+ model?: string;
37
+ }
38
+ export interface LoadedConfig {
39
+ config: ContextDoctorConfig;
40
+ /** Absolute path of the rc file, or undefined when none was found. */
41
+ path?: string;
42
+ }
43
+ /**
44
+ * Load the nearest config. Malformed rc files are reported (so a typo is not
45
+ * silently ignored) but never throw — the tool keeps working with defaults.
46
+ */
47
+ export declare function loadConfig(startDir?: string, onWarn?: (msg: string) => void): LoadedConfig;
48
+ export interface BudgetVerdict {
49
+ /** True when any configured limit is exceeded. */
50
+ overBudget: boolean;
51
+ /** Human-readable lines, one per breached limit. */
52
+ breaches: string[];
53
+ /** The token limit in force, when one is configured. */
54
+ maxTokens?: number;
55
+ }
56
+ /** Compare a profile against the configured budget. */
57
+ export declare function checkBudget(budget: ContextBudget | undefined, profile: {
58
+ totalTokens: number;
59
+ usagePct?: number;
60
+ cost?: {
61
+ perCallUsd: number;
62
+ };
63
+ }): BudgetVerdict;
package/dist/config.js ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Project configuration and context budgets (`.contextdoctorrc`).
3
+ *
4
+ * Discovery walks up from the working directory to the filesystem root, then
5
+ * falls back to ~/.contextdoctorrc — so a repo can set its own budget and a
6
+ * user can set a machine-wide default. First file found wins (no merging:
7
+ * one visible file is easier to reason about than a merge chain).
8
+ *
9
+ * Everything here is optional. With no rc file the tool behaves exactly as
10
+ * it always has.
11
+ */
12
+ import { existsSync, readFileSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { dirname, join, parse as parsePath } from "node:path";
15
+ export const RC_FILENAME = ".contextdoctorrc";
16
+ /** Candidate rc paths: cwd upwards, then the home directory. */
17
+ function candidatePaths(startDir) {
18
+ const paths = [];
19
+ let dir = startDir;
20
+ const { root } = parsePath(dir);
21
+ for (;;) {
22
+ paths.push(join(dir, RC_FILENAME));
23
+ if (dir === root)
24
+ break;
25
+ const parent = dirname(dir);
26
+ if (parent === dir)
27
+ break;
28
+ dir = parent;
29
+ }
30
+ const home = join(homedir(), RC_FILENAME);
31
+ if (!paths.includes(home))
32
+ paths.push(home);
33
+ return paths;
34
+ }
35
+ /**
36
+ * Load the nearest config. Malformed rc files are reported (so a typo is not
37
+ * silently ignored) but never throw — the tool keeps working with defaults.
38
+ */
39
+ export function loadConfig(startDir = process.cwd(), onWarn) {
40
+ for (const path of candidatePaths(startDir)) {
41
+ if (!existsSync(path))
42
+ continue;
43
+ try {
44
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
45
+ if (parsed && typeof parsed === "object")
46
+ return { config: parsed, path };
47
+ onWarn?.(`${path}: expected a JSON object — ignoring`);
48
+ }
49
+ catch (e) {
50
+ onWarn?.(`${path}: ${e.message} — ignoring`);
51
+ }
52
+ return { config: {} };
53
+ }
54
+ return { config: {} };
55
+ }
56
+ /** Compare a profile against the configured budget. */
57
+ export function checkBudget(budget, profile) {
58
+ const breaches = [];
59
+ if (!budget)
60
+ return { overBudget: false, breaches };
61
+ if (budget.maxTokens !== undefined && profile.totalTokens > budget.maxTokens) {
62
+ breaches.push(`context is ${profile.totalTokens} tokens, over the ${budget.maxTokens} budget`);
63
+ }
64
+ if (budget.maxCostPerMessageUsd !== undefined &&
65
+ profile.cost !== undefined &&
66
+ profile.cost.perCallUsd > budget.maxCostPerMessageUsd) {
67
+ breaches.push(`input cost is $${profile.cost.perCallUsd.toFixed(3)} per message, over the $${budget.maxCostPerMessageUsd.toFixed(3)} budget`);
68
+ }
69
+ if (budget.maxWindowPct !== undefined && profile.usagePct !== undefined && profile.usagePct > budget.maxWindowPct) {
70
+ breaches.push(`context fills ${profile.usagePct.toFixed(0)}% of the window, over the ${budget.maxWindowPct}% budget`);
71
+ }
72
+ return { overBudget: breaches.length > 0, breaches, maxTokens: budget.maxTokens };
73
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * `context-doctor dashboard` — a local savings dashboard.
3
+ *
4
+ * Serves one self-contained page on localhost from data already on this
5
+ * machine: the activity ledger, recent session profiles, and the proxy's
6
+ * /stats when it is running. No network calls, no accounts, no telemetry —
7
+ * the server reads local files and answers only the loopback interface.
8
+ */
9
+ import http from "node:http";
10
+ export interface DashboardData {
11
+ generatedAt: string;
12
+ totals: {
13
+ tokensSaved: number;
14
+ usdSaved: number;
15
+ checks: number;
16
+ warnings: number;
17
+ optimizeRuns: number;
18
+ };
19
+ daily: Array<{
20
+ date: string;
21
+ saved: number;
22
+ }>;
23
+ sessions: Array<{
24
+ title: string;
25
+ tokens: number;
26
+ waste: number;
27
+ model?: string;
28
+ }>;
29
+ proxy: {
30
+ requests: number;
31
+ optimizedRequests: number;
32
+ tokensSaved: number;
33
+ estUsdSaved: number;
34
+ } | null;
35
+ budget: {
36
+ path: string;
37
+ overBudget: boolean;
38
+ breaches: string[];
39
+ } | null;
40
+ }
41
+ export declare function collectDashboardData(proxyPort?: number): Promise<DashboardData>;
42
+ export declare function startDashboard(opts?: {
43
+ port?: number;
44
+ proxyPort?: number;
45
+ }): http.Server;
@@ -0,0 +1,394 @@
1
+ /**
2
+ * `context-doctor dashboard` — a local savings dashboard.
3
+ *
4
+ * Serves one self-contained page on localhost from data already on this
5
+ * machine: the activity ledger, recent session profiles, and the proxy's
6
+ * /stats when it is running. No network calls, no accounts, no telemetry —
7
+ * the server reads local files and answers only the loopback interface.
8
+ */
9
+ import http from "node:http";
10
+ import { readLedger } from "./ledger.js";
11
+ import { listSessions, parseSessionFile } from "./session.js";
12
+ import { parseConversation } from "./parse.js";
13
+ import { profileConversation } from "./profile.js";
14
+ import { inputCostUsd, pricingFor } from "./pricing.js";
15
+ import { checkBudget, loadConfig } from "./config.js";
16
+ /** Sessions bigger than this are skipped so the page stays responsive. */
17
+ const MAX_SESSION_BYTES = 30 * 1024 * 1024;
18
+ async function fetchProxyStats(port) {
19
+ try {
20
+ const res = await fetch(`http://127.0.0.1:${port}/stats`, { signal: AbortSignal.timeout(400) });
21
+ if (!res.ok)
22
+ return null;
23
+ return (await res.json());
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ export async function collectDashboardData(proxyPort = 8787) {
30
+ const ledger = readLedger();
31
+ const checks = ledger.filter((e) => e.ev === "check" || e.ev === undefined);
32
+ const optimizes = ledger.filter((e) => e.ev === "optimize");
33
+ // Observed shrinkage: a session getting SMALLER between two deep checks is a
34
+ // real reduction, so it counts alongside explicit optimize runs.
35
+ const perSession = new Map();
36
+ for (const c of checks) {
37
+ if (!c.sid || typeof c.tok !== "number")
38
+ continue;
39
+ perSession.set(c.sid, [...(perSession.get(c.sid) ?? []), c.tok]);
40
+ }
41
+ let shrinkage = 0;
42
+ for (const toks of perSession.values()) {
43
+ for (let i = 1; i < toks.length; i++)
44
+ if (toks[i] < toks[i - 1])
45
+ shrinkage += toks[i - 1] - toks[i];
46
+ }
47
+ const optimizeSaved = optimizes.reduce((s, e) => s + (e.saved ?? 0), 0);
48
+ let usdSaved = 0;
49
+ for (const e of optimizes) {
50
+ const pricing = pricingFor(e.model);
51
+ if (pricing && e.saved)
52
+ usdSaved += inputCostUsd(e.saved, pricing);
53
+ }
54
+ // Daily series: optimize savings bucketed by local date.
55
+ const byDay = new Map();
56
+ for (const e of optimizes) {
57
+ if (!e.saved)
58
+ continue;
59
+ const day = new Date(e.ts).toISOString().slice(0, 10);
60
+ byDay.set(day, (byDay.get(day) ?? 0) + e.saved);
61
+ }
62
+ const daily = [...byDay.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([date, saved]) => ({ date, saved }));
63
+ const sessions = [];
64
+ for (const s of listSessions(8)) {
65
+ if (s.sizeBytes > MAX_SESSION_BYTES)
66
+ continue;
67
+ try {
68
+ const parsed = parseSessionFile(s.path);
69
+ if (parsed.messageCount === 0)
70
+ continue;
71
+ const p = profileConversation(parseConversation(parsed.conversationJson), parsed.model);
72
+ sessions.push({
73
+ title: parsed.title ?? (s.path.split("/").pop() ?? "session").slice(0, 24),
74
+ tokens: p.totalTokens,
75
+ waste: p.totalEstSavings,
76
+ model: parsed.model,
77
+ });
78
+ }
79
+ catch {
80
+ /* unreadable session — skip */
81
+ }
82
+ }
83
+ const proxy = await fetchProxyStats(proxyPort);
84
+ const loaded = loadConfig();
85
+ let budget = null;
86
+ if (loaded.path && loaded.config.budget && sessions.length > 0) {
87
+ const biggest = sessions.reduce((a, b) => (b.tokens > a.tokens ? b : a));
88
+ const verdict = checkBudget(loaded.config.budget, { totalTokens: biggest.tokens });
89
+ budget = { path: loaded.path, overBudget: verdict.overBudget, breaches: verdict.breaches };
90
+ }
91
+ return {
92
+ generatedAt: new Date().toISOString(),
93
+ totals: {
94
+ tokensSaved: optimizeSaved + shrinkage + (proxy?.tokensSaved ?? 0),
95
+ usdSaved: usdSaved + (proxy?.estUsdSaved ?? 0),
96
+ checks: checks.length,
97
+ warnings: checks.filter((c) => c.warn).length,
98
+ optimizeRuns: optimizes.length,
99
+ },
100
+ daily,
101
+ sessions,
102
+ proxy,
103
+ budget,
104
+ };
105
+ }
106
+ export function startDashboard(opts = {}) {
107
+ const port = opts.port ?? 8790;
108
+ const server = http.createServer(async (req, res) => {
109
+ try {
110
+ if ((req.url ?? "").startsWith("/api/data")) {
111
+ const data = await collectDashboardData(opts.proxyPort);
112
+ res.setHeader("content-type", "application/json");
113
+ res.end(JSON.stringify(data));
114
+ return;
115
+ }
116
+ res.setHeader("content-type", "text/html; charset=utf-8");
117
+ res.end(PAGE);
118
+ }
119
+ catch (e) {
120
+ res.statusCode = 500;
121
+ res.end(`dashboard error: ${e.message}`);
122
+ }
123
+ });
124
+ // Loopback only: this page exposes local usage data.
125
+ server.listen(port, "127.0.0.1", () => {
126
+ console.error(`context-doctor dashboard on http://127.0.0.1:${port}`);
127
+ });
128
+ return server;
129
+ }
130
+ /**
131
+ * The page. Self-contained (no external requests), renders inline SVG from
132
+ * /api/data. Palette, mark specs and interaction follow the house data-viz
133
+ * rules: fixed-order categorical slots, thin marks with 4px rounded data-ends,
134
+ * 2px surface gaps, legend + direct labels for the two-series chart, hover
135
+ * tooltips, a table view, and dark steps selected for the dark surface.
136
+ */
137
+ const PAGE = `<!doctype html>
138
+ <html lang="en">
139
+ <head>
140
+ <meta charset="utf-8">
141
+ <meta name="viewport" content="width=device-width, initial-scale=1">
142
+ <title>context-doctor dashboard</title>
143
+ <style>
144
+ :root {
145
+ color-scheme: light;
146
+ --surface-1: #fcfcfb;
147
+ --surface-2: #f4f3f0;
148
+ --border: #e2e1dc;
149
+ --text-primary: #0b0b0b;
150
+ --text-secondary: #52514e;
151
+ --text-muted: #75746f;
152
+ --series-1: #2a78d6;
153
+ --series-2: #eb6834;
154
+ --good: #0ca30c;
155
+ --critical: #d03b3b;
156
+ }
157
+ @media (prefers-color-scheme: dark) {
158
+ :root:where(:not([data-theme="light"])) {
159
+ color-scheme: dark;
160
+ --surface-1: #1a1a19;
161
+ --surface-2: #232322;
162
+ --border: #383835;
163
+ --text-primary: #ffffff;
164
+ --text-secondary: #c3c2b7;
165
+ --text-muted: #9b9a92;
166
+ --series-1: #3987e5;
167
+ --series-2: #d95926;
168
+ }
169
+ }
170
+ :root[data-theme="dark"] {
171
+ color-scheme: dark;
172
+ --surface-1: #1a1a19;
173
+ --surface-2: #232322;
174
+ --border: #383835;
175
+ --text-primary: #ffffff;
176
+ --text-secondary: #c3c2b7;
177
+ --text-muted: #9b9a92;
178
+ --series-1: #3987e5;
179
+ --series-2: #d95926;
180
+ }
181
+ * { box-sizing: border-box; }
182
+ body {
183
+ margin: 0; padding: 32px 24px 64px;
184
+ background: var(--surface-1); color: var(--text-primary);
185
+ font: 15px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
186
+ }
187
+ .wrap { max-width: 940px; margin: 0 auto; }
188
+ header { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
189
+ h1 { font-size: 20px; margin: 0; letter-spacing: -0.01em; }
190
+ .sub { color: var(--text-muted); font-size: 13px; }
191
+ .tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin: 24px 0 8px; }
192
+ .tile { background: var(--surface-2); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; }
193
+ .tile .label { font-size: 12px; color: var(--text-secondary); }
194
+ .tile .value { font-size: 26px; font-weight: 600; letter-spacing: -0.02em; margin-top: 2px; font-variant-numeric: tabular-nums; }
195
+ .tile .note { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
196
+ section { margin-top: 28px; }
197
+ h2 { font-size: 14px; font-weight: 600; margin: 0 0 2px; }
198
+ .caption { font-size: 12.5px; color: var(--text-secondary); margin: 0 0 12px; }
199
+ .legend { display: flex; gap: 14px; font-size: 12.5px; color: var(--text-secondary); margin-bottom: 8px; }
200
+ .legend span { display: inline-flex; align-items: center; gap: 6px; }
201
+ .swatch { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
202
+ .empty { color: var(--text-muted); font-size: 13px; background: var(--surface-2); border: 1px dashed var(--border); border-radius: 10px; padding: 16px; }
203
+ svg { display: block; width: 100%; height: auto; overflow: visible; }
204
+ .grid-line { stroke: var(--border); stroke-width: 1; }
205
+ .axis-text { fill: var(--text-muted); font-size: 11px; }
206
+ .label-text { fill: var(--text-secondary); font-size: 12px; }
207
+ .value-text { fill: var(--text-primary); font-size: 12px; font-variant-numeric: tabular-nums; }
208
+ .tip {
209
+ position: fixed; pointer-events: none; opacity: 0; transition: opacity .1s;
210
+ background: var(--surface-1); color: var(--text-primary);
211
+ border: 1px solid var(--border); border-radius: 8px; padding: 7px 10px;
212
+ font-size: 12.5px; box-shadow: 0 6px 20px rgba(0,0,0,.12); z-index: 10; white-space: nowrap;
213
+ }
214
+ table { border-collapse: collapse; width: 100%; font-size: 13px; margin-top: 10px; }
215
+ th, td { text-align: left; padding: 7px 10px; border-bottom: 1px solid var(--border); }
216
+ th { color: var(--text-secondary); font-weight: 600; }
217
+ td.num { text-align: right; font-variant-numeric: tabular-nums; }
218
+ details summary { cursor: pointer; font-size: 12.5px; color: var(--text-secondary); margin-top: 10px; }
219
+ .banner { border-radius: 10px; padding: 12px 14px; font-size: 13px; margin-top: 18px; border: 1px solid; }
220
+ .banner.ok { border-color: var(--good); color: var(--text-primary); }
221
+ .banner.over { border-color: var(--critical); color: var(--text-primary); }
222
+ footer { margin-top: 36px; color: var(--text-muted); font-size: 12px; }
223
+ </style>
224
+ </head>
225
+ <body>
226
+ <div class="wrap">
227
+ <header>
228
+ <div>
229
+ <h1>context-doctor</h1>
230
+ <div class="sub" id="generated">loading local data…</div>
231
+ </div>
232
+ <div class="sub">everything on this page is read from your machine</div>
233
+ </header>
234
+
235
+ <div class="tiles" id="tiles"></div>
236
+ <div id="budget"></div>
237
+
238
+ <section>
239
+ <h2>Tokens saved per day</h2>
240
+ <p class="caption">Optimizations applied through the CLI and in-chat tools.</p>
241
+ <div id="daily"></div>
242
+ </section>
243
+
244
+ <section>
245
+ <h2>Recent sessions: context in use and still recoverable</h2>
246
+ <p class="caption">Each bar is one session. The second segment is what optimization would still reclaim today.</p>
247
+ <div class="legend">
248
+ <span><i class="swatch" style="background: var(--series-1)"></i>In use</span>
249
+ <span><i class="swatch" style="background: var(--series-2)"></i>Recoverable</span>
250
+ </div>
251
+ <div id="sessions"></div>
252
+ <details>
253
+ <summary>Table view</summary>
254
+ <div id="sessionsTable"></div>
255
+ </details>
256
+ </section>
257
+
258
+ <footer id="proxyNote"></footer>
259
+ </div>
260
+ <div class="tip" id="tip"></div>
261
+
262
+ <script>
263
+ const tip = document.getElementById('tip');
264
+ const fmtTokens = (n) => n >= 1e6 ? (n/1e6).toFixed(1)+'M' : n >= 1e4 ? Math.round(n/1e3)+'k' : n >= 1e3 ? (n/1e3).toFixed(1)+'k' : String(Math.round(n));
265
+ const fmtUsd = (n) => n >= 1 ? '$'+n.toFixed(2) : n >= 0.01 ? '$'+n.toFixed(3) : '$'+n.toFixed(4);
266
+ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
267
+
268
+ function bindTip(el, html) {
269
+ el.addEventListener('pointerenter', (e) => { tip.innerHTML = html; tip.style.opacity = '1'; move(e); });
270
+ el.addEventListener('pointermove', move);
271
+ el.addEventListener('pointerleave', () => { tip.style.opacity = '0'; });
272
+ function move(e) { tip.style.left = (e.clientX + 14) + 'px'; tip.style.top = (e.clientY - 12) + 'px'; }
273
+ }
274
+
275
+ function renderTiles(d) {
276
+ const t = d.totals;
277
+ const tiles = [
278
+ { label: 'Tokens saved', value: fmtTokens(t.tokensSaved), note: t.optimizeRuns + ' optimization run(s)' },
279
+ { label: 'Estimated cost saved', value: t.usdSaved > 0 ? fmtUsd(t.usdSaved) : '—', note: t.usdSaved > 0 ? 'input tokens, priced per model' : 'no priced runs yet (model not recorded)' },
280
+ { label: 'Context checks', value: String(t.checks), note: t.warnings + ' warning(s) delivered' },
281
+ { label: 'Sessions tracked', value: String(d.sessions.length), note: 'most recent on this machine' },
282
+ ];
283
+ document.getElementById('tiles').innerHTML = tiles.map((x) =>
284
+ '<div class="tile"><div class="label">' + esc(x.label) + '</div><div class="value">' + esc(x.value) +
285
+ '</div><div class="note">' + esc(x.note) + '</div></div>').join('');
286
+ }
287
+
288
+ function renderDaily(rows) {
289
+ const host = document.getElementById('daily');
290
+ if (!rows.length) {
291
+ host.innerHTML = '<div class="empty">No optimizations recorded yet. Run <code>context-doctor optimize</code>, or ask Claude to optimize a conversation, and this fills in.</div>';
292
+ return;
293
+ }
294
+ const W = 900, H = 220, padL = 52, padR = 12, padB = 34, padT = 10;
295
+ const max = Math.max(...rows.map((r) => r.saved));
296
+ const innerW = W - padL - padR, innerH = H - padT - padB;
297
+ const slot = innerW / rows.length;
298
+ const barW = Math.max(6, Math.min(48, slot - 8)); // 2px+ surface gap between bars
299
+ const ticks = [0, max / 2, max];
300
+ let svg = '<svg viewBox="0 0 ' + W + ' ' + H + '" role="img" aria-label="Tokens saved per day">';
301
+ for (const tk of ticks) {
302
+ const y = padT + innerH - (tk / max) * innerH;
303
+ svg += '<line class="grid-line" x1="' + padL + '" y1="' + y + '" x2="' + (W - padR) + '" y2="' + y + '"/>';
304
+ svg += '<text class="axis-text" x="' + (padL - 8) + '" y="' + (y + 4) + '" text-anchor="end">' + fmtTokens(tk) + '</text>';
305
+ }
306
+ rows.forEach((r, i) => {
307
+ const h = Math.max(2, (r.saved / max) * innerH);
308
+ const x = padL + i * slot + (slot - barW) / 2;
309
+ const y = padT + innerH - h;
310
+ // 4px rounded data-end, square foot on the baseline.
311
+ const rad = Math.min(4, h);
312
+ svg += '<path data-i="' + i + '" fill="var(--series-1)" d="M' + x + ' ' + (y + rad) +
313
+ ' a' + rad + ' ' + rad + ' 0 0 1 ' + rad + ' -' + rad +
314
+ ' h' + (barW - 2 * rad) +
315
+ ' a' + rad + ' ' + rad + ' 0 0 1 ' + rad + ' ' + rad +
316
+ ' v' + (h - rad) + ' h-' + barW + ' z"/>';
317
+ if (rows.length <= 10) {
318
+ svg += '<text class="axis-text" x="' + (x + barW / 2) + '" y="' + (H - padB + 18) + '" text-anchor="middle">' + esc(r.date.slice(5)) + '</text>';
319
+ }
320
+ });
321
+ svg += '<line class="grid-line" x1="' + padL + '" y1="' + (padT + innerH) + '" x2="' + (W - padR) + '" y2="' + (padT + innerH) + '"/>';
322
+ svg += '</svg>';
323
+ host.innerHTML = svg;
324
+ host.querySelectorAll('path[data-i]').forEach((el) => {
325
+ const r = rows[Number(el.getAttribute('data-i'))];
326
+ bindTip(el, '<strong>' + esc(r.date) + '</strong><br>' + fmtTokens(r.saved) + ' tokens saved');
327
+ });
328
+ }
329
+
330
+ function renderSessions(rows) {
331
+ const host = document.getElementById('sessions');
332
+ if (!rows.length) {
333
+ host.innerHTML = '<div class="empty">No session transcripts found on this machine yet.</div>';
334
+ return;
335
+ }
336
+ const W = 900, rowH = 34, padL = 200, padR = 96, padT = 6;
337
+ const H = padT + rows.length * rowH + 8;
338
+ const max = Math.max(...rows.map((r) => r.tokens));
339
+ const innerW = W - padL - padR;
340
+ let svg = '<svg viewBox="0 0 ' + W + ' ' + H + '" role="img" aria-label="Recent sessions by context size">';
341
+ rows.forEach((r, i) => {
342
+ const y = padT + i * rowH + 7;
343
+ const barH = 16, rad = 4;
344
+ const usedTok = Math.max(0, r.tokens - r.waste);
345
+ const totalW = (r.tokens / max) * innerW;
346
+ const usedW = Math.max(rad, (usedTok / max) * innerW);
347
+ const wasteW = Math.max(0, totalW - usedW - 2); // 2px surface gap between segments
348
+ svg += '<text class="label-text" x="0" y="' + (y + 12) + '">' + esc(r.title.slice(0, 30)) + '</text>';
349
+ svg += '<rect data-u="' + i + '" x="' + padL + '" y="' + y + '" width="' + usedW + '" height="' + barH + '" rx="' + rad + '" fill="var(--series-1)"/>';
350
+ if (wasteW > 1) {
351
+ svg += '<rect data-w="' + i + '" x="' + (padL + usedW + 2) + '" y="' + y + '" width="' + wasteW + '" height="' + barH + '" rx="' + rad + '" fill="var(--series-2)"/>';
352
+ }
353
+ svg += '<text class="value-text" x="' + (W - padR + 8) + '" y="' + (y + 12) + '">' + fmtTokens(r.tokens) + '</text>';
354
+ });
355
+ svg += '</svg>';
356
+ host.innerHTML = svg;
357
+ host.querySelectorAll('rect[data-u]').forEach((el) => {
358
+ const r = rows[Number(el.getAttribute('data-u'))];
359
+ bindTip(el, '<strong>' + esc(r.title) + '</strong><br>' + fmtTokens(r.tokens - r.waste) + ' tokens in use' + (r.model ? '<br>' + esc(r.model) : ''));
360
+ });
361
+ host.querySelectorAll('rect[data-w]').forEach((el) => {
362
+ const r = rows[Number(el.getAttribute('data-w'))];
363
+ bindTip(el, '<strong>' + esc(r.title) + '</strong><br>' + fmtTokens(r.waste) + ' tokens recoverable');
364
+ });
365
+ document.getElementById('sessionsTable').innerHTML =
366
+ '<table><thead><tr><th>Session</th><th class="num">Total</th><th class="num">In use</th><th class="num">Recoverable</th></tr></thead><tbody>' +
367
+ rows.map((r) => '<tr><td>' + esc(r.title) + '</td><td class="num">' + fmtTokens(r.tokens) + '</td><td class="num">' +
368
+ fmtTokens(r.tokens - r.waste) + '</td><td class="num">' + fmtTokens(r.waste) + '</td></tr>').join('') +
369
+ '</tbody></table>';
370
+ }
371
+
372
+ function renderBudget(b) {
373
+ const host = document.getElementById('budget');
374
+ if (!b) { host.innerHTML = ''; return; }
375
+ host.innerHTML = b.overBudget
376
+ ? '<div class="banner over"><strong>Over budget</strong> (' + esc(b.path) + '): ' + esc(b.breaches.join('; ')) + '</div>'
377
+ : '<div class="banner ok"><strong>Within budget</strong> (' + esc(b.path) + ')</div>';
378
+ }
379
+
380
+ fetch('/api/data').then((r) => r.json()).then((d) => {
381
+ document.getElementById('generated').textContent = 'generated ' + new Date(d.generatedAt).toLocaleString();
382
+ renderTiles(d);
383
+ renderBudget(d.budget);
384
+ renderDaily(d.daily);
385
+ renderSessions(d.sessions);
386
+ document.getElementById('proxyNote').textContent = d.proxy
387
+ ? 'Proxy running: ' + d.proxy.optimizedRequests + ' of ' + d.proxy.requests + ' requests optimized, ' + fmtTokens(d.proxy.tokensSaved) + ' tokens saved this run.'
388
+ : 'Proxy not running — start it with "context-doctor proxy" to add exact per-request savings here.';
389
+ }).catch((e) => {
390
+ document.getElementById('generated').textContent = 'could not load local data: ' + e.message;
391
+ });
392
+ </script>
393
+ </body>
394
+ </html>`;
package/dist/hook.js CHANGED
@@ -17,17 +17,32 @@ import { profileConversation } from "./profile.js";
17
17
  import { parseSessionFile } from "./session.js";
18
18
  import { formatTokens } from "./tokens.js";
19
19
  import { formatUsd } from "./pricing.js";
20
- /** Start nudging at 80k tokens of context (override: CONTEXT_DOCTOR_WARN_TOKENS). */
21
- const WARN_TOKENS = Number(process.env.CONTEXT_DOCTOR_WARN_TOKENS) > 0 ? Number(process.env.CONTEXT_DOCTOR_WARN_TOKENS) : 80_000;
20
+ import { checkBudget, loadConfig } from "./config.js";
21
+ /** Default nudge threshold; a project budget or env var can lower/raise it. */
22
+ const DEFAULT_WARN_TOKENS = 80_000;
23
+ /**
24
+ * Threshold precedence: CONTEXT_DOCTOR_WARN_TOKENS env var, then the project
25
+ * budget's maxTokens (.contextdoctorrc), then the default.
26
+ */
27
+ function warnThreshold(budgetMaxTokens) {
28
+ const env = Number(process.env.CONTEXT_DOCTOR_WARN_TOKENS);
29
+ if (env > 0)
30
+ return env;
31
+ if (budgetMaxTokens !== undefined && budgetMaxTokens > 0)
32
+ return budgetMaxTokens;
33
+ return DEFAULT_WARN_TOKENS;
34
+ }
22
35
  /** Re-nudge only after the context grows another 40% — one reminder, not a nag. */
23
36
  const REGROWTH_FACTOR = 1.4;
24
37
  /**
25
38
  * Fast-path gate: text tokens are at least ~4 bytes each and the transcript
26
39
  * carries JSON overhead on top, so a file smaller than this cannot possibly
27
- * hold WARN_TOKENS of context. Lean sessions cost one stat() call — the
40
+ * hold that many tokens of context. Lean sessions cost one stat() call — the
28
41
  * transcript is never even read.
29
42
  */
30
- const MIN_BYTES_FOR_WARN = WARN_TOKENS * 4;
43
+ function minBytesForWarn(threshold) {
44
+ return threshold * 4;
45
+ }
31
46
  async function readStdin() {
32
47
  const chunks = [];
33
48
  for await (const chunk of process.stdin)
@@ -44,8 +59,10 @@ export async function runHook() {
44
59
  // Fast path 1: a small transcript cannot exceed the threshold — exit on a
45
60
  // single stat() without reading the file. This is the every-prompt cost
46
61
  // for lean sessions: ~1ms.
62
+ const { config } = loadConfig(input.cwd ?? process.cwd());
63
+ const threshold = warnThreshold(config.budget?.maxTokens);
47
64
  const sizeBytes = statSync(transcriptPath).size;
48
- if (sizeBytes < MIN_BYTES_FOR_WARN)
65
+ if (sizeBytes < minBytesForWarn(threshold))
49
66
  return;
50
67
  // Fast path 2: growth gate BEFORE parsing. If the file hasn't grown ~40%
51
68
  // since the last full parse, nothing new can trigger — exit without the
@@ -69,7 +86,7 @@ export async function runHook() {
69
86
  return;
70
87
  const profile = profileConversation(parseConversation(parsed.conversationJson), parsed.model);
71
88
  // Record this parse so the next prompts take fast path 2.
72
- const shouldWarn = profile.totalTokens >= WARN_TOKENS && profile.totalTokens >= prev.t * REGROWTH_FACTOR;
89
+ const shouldWarn = profile.totalTokens >= threshold && profile.totalTokens >= prev.t * REGROWTH_FACTOR;
73
90
  const nextState = { t: shouldWarn ? profile.totalTokens : prev.t, b: sizeBytes };
74
91
  const entries = Object.entries({ ...state, [sessionId]: nextState });
75
92
  writeFileSync(statePath(), JSON.stringify(Object.fromEntries(entries.slice(-100))));
@@ -83,11 +100,16 @@ export async function runHook() {
83
100
  ".",
84
101
  "Practice context hygiene from here on: summarize large tool results instead of keeping them verbatim, reference earlier content rather than re-reading or re-quoting it, and keep responses lean.",
85
102
  ];
103
+ // A configured budget is the user's own limit — say so first and by name.
104
+ const verdict = checkBudget(config.budget, profile);
105
+ if (verdict.overBudget) {
106
+ lines.splice(1, 0, `This project's context budget is exceeded: ${verdict.breaches.join("; ")}. Treat compaction as a priority, not an option.`);
107
+ }
86
108
  const topFinding = profile.findings.find((f) => f.estSavings > 0);
87
109
  if (topFinding) {
88
110
  lines.push(`Largest recoverable waste: ${topFinding.message} (${topFinding.suggestion})`);
89
111
  }
90
- if (profile.totalTokens > WARN_TOKENS * 2) {
112
+ if (profile.totalTokens > threshold * 2) {
91
113
  lines.push("If it fits the flow, offer the user a compaction of the older history.");
92
114
  }
93
115
  console.log(JSON.stringify({
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.6.0" }, { instructions: SERVER_INSTRUCTIONS });
40
+ const server = new McpServer({ name: "context-doctor", version: "0.8.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/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
- const result = optimizeConversation(body, opts);
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();
@@ -0,0 +1,2 @@
1
+ /** Project config discovery + context budget verdicts. */
2
+ export {};
@@ -0,0 +1,64 @@
1
+ /** Project config discovery + context budget verdicts. */
2
+ import { test } from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import { execFile } from "node:child_process";
5
+ import { mkdirSync, 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
+ import { checkBudget, loadConfig, RC_FILENAME } from "../config.js";
10
+ const cliPath = join(dirname(fileURLToPath(import.meta.url)), "..", "cli.js");
11
+ test("loadConfig walks up to the nearest .contextdoctorrc", () => {
12
+ const root = mkdtempSync(join(tmpdir(), "ctxdoc-cfg-"));
13
+ const nested = join(root, "packages", "app", "src");
14
+ mkdirSync(nested, { recursive: true });
15
+ writeFileSync(join(root, RC_FILENAME), JSON.stringify({ budget: { maxTokens: 1234 } }));
16
+ const loaded = loadConfig(nested);
17
+ assert.equal(loaded.config.budget?.maxTokens, 1234);
18
+ assert.equal(loaded.path, join(root, RC_FILENAME));
19
+ });
20
+ test("a malformed rc warns instead of throwing", () => {
21
+ const root = mkdtempSync(join(tmpdir(), "ctxdoc-cfg-bad-"));
22
+ writeFileSync(join(root, RC_FILENAME), "{ not json");
23
+ const warnings = [];
24
+ const loaded = loadConfig(root, (m) => warnings.push(m));
25
+ assert.deepEqual(loaded.config, {});
26
+ assert.equal(warnings.length, 1);
27
+ });
28
+ test("checkBudget flags each configured limit independently", () => {
29
+ const profile = { totalTokens: 200_000, usagePct: 65, cost: { perCallUsd: 1.2 } };
30
+ assert.equal(checkBudget(undefined, profile).overBudget, false);
31
+ assert.equal(checkBudget({}, profile).overBudget, false);
32
+ const tokens = checkBudget({ maxTokens: 100_000 }, profile);
33
+ assert.equal(tokens.overBudget, true);
34
+ assert.match(tokens.breaches[0], /over the 100000 budget/);
35
+ const cost = checkBudget({ maxCostPerMessageUsd: 0.5 }, profile);
36
+ assert.match(cost.breaches[0], /per message/);
37
+ const window = checkBudget({ maxWindowPct: 50 }, profile);
38
+ assert.match(window.breaches[0], /% of the window/);
39
+ const all = checkBudget({ maxTokens: 100_000, maxCostPerMessageUsd: 0.5, maxWindowPct: 50 }, profile);
40
+ assert.equal(all.breaches.length, 3);
41
+ const within = checkBudget({ maxTokens: 500_000, maxWindowPct: 90 }, profile);
42
+ assert.equal(within.overBudget, false);
43
+ });
44
+ test("analyze reports budget status from the project rc", async () => {
45
+ const root = mkdtempSync(join(tmpdir(), "ctxdoc-cfg-cli-"));
46
+ writeFileSync(join(root, RC_FILENAME), JSON.stringify({ budget: { maxTokens: 10 } }));
47
+ const chat = join(root, "chat.json");
48
+ writeFileSync(chat, JSON.stringify({ messages: [{ role: "user", content: "a fairly long message ".repeat(40) }] }));
49
+ const out = await new Promise((resolve, reject) => {
50
+ execFile(process.execPath, [cliPath, "analyze", chat, "--model", "claude-sonnet-5"], { cwd: root }, (err, stdout) => err ? reject(err) : resolve(stdout));
51
+ });
52
+ assert.ok(out.includes("OVER BUDGET"), `expected budget breach in output:\n${out}`);
53
+ assert.ok(out.includes(RC_FILENAME), "names the rc file responsible");
54
+ });
55
+ test("analyze stays quiet about budgets when no rc exists", async () => {
56
+ const root = mkdtempSync(join(tmpdir(), "ctxdoc-cfg-none-"));
57
+ const chat = join(root, "chat.json");
58
+ writeFileSync(chat, JSON.stringify({ messages: [{ role: "user", content: "hello" }] }));
59
+ const out = await new Promise((resolve, reject) => {
60
+ // HOME override keeps a real ~/.contextdoctorrc from leaking into the test.
61
+ execFile(process.execPath, [cliPath, "analyze", chat], { cwd: root, env: { ...process.env, HOME: root } }, (err, stdout) => err ? reject(err) : resolve(stdout));
62
+ });
63
+ assert.ok(!out.includes("BUDGET"), "no budget chatter without an rc");
64
+ });
@@ -0,0 +1,2 @@
1
+ /** Dashboard: local-only server, real data shape, self-contained page. */
2
+ export {};
@@ -0,0 +1,37 @@
1
+ /** Dashboard: local-only server, real data shape, self-contained page. */
2
+ import { test, after } from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import { startDashboard, collectDashboardData } from "../dashboard.js";
5
+ const server = startDashboard({ port: 0 });
6
+ await new Promise((r) => server.once("listening", () => r()));
7
+ const port = server.address().port;
8
+ after(() => server.close());
9
+ test("binds loopback only", () => {
10
+ assert.equal(server.address().address, "127.0.0.1");
11
+ });
12
+ test("/api/data returns the documented shape", async () => {
13
+ const data = (await (await fetch(`http://127.0.0.1:${port}/api/data`)).json());
14
+ for (const key of ["generatedAt", "totals", "daily", "sessions", "proxy", "budget"]) {
15
+ assert.ok(key in data, `missing ${key}`);
16
+ }
17
+ for (const key of ["tokensSaved", "usdSaved", "checks", "warnings", "optimizeRuns"]) {
18
+ assert.equal(typeof data.totals[key], "number", `totals.${key} must be numeric`);
19
+ }
20
+ assert.ok(Array.isArray(data.daily) && Array.isArray(data.sessions));
21
+ });
22
+ test("page is self-contained: no external requests", async () => {
23
+ const html = await (await fetch(`http://127.0.0.1:${port}/`)).text();
24
+ assert.ok(html.startsWith("<!doctype html>"));
25
+ // Only same-origin data fetch; nothing pulled from the network.
26
+ assert.ok(!/https?:\/\//.test(html.replace(/http:\/\/127\.0\.0\.1/g, "")), "page must not reference remote origins");
27
+ assert.ok(html.includes("/api/data"));
28
+ // Accessibility affordances required by the house chart rules.
29
+ assert.ok(html.includes("Table view"), "table view present");
30
+ assert.ok(html.includes('role="img"') || html.includes("aria-label"), "charts labelled");
31
+ assert.ok(html.includes("prefers-color-scheme: dark"), "dark mode selected, not flipped");
32
+ });
33
+ test("collectDashboardData works without a proxy running", async () => {
34
+ const data = await collectDashboardData(59999); // nothing listens here
35
+ assert.equal(data.proxy, null);
36
+ assert.ok(data.totals.tokensSaved >= 0);
37
+ });
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-doctor",
3
- "version": "0.6.0",
3
+ "version": "0.8.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 dist/test/chatgpt-export.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 dist/test/config.test.js dist/test/dashboard.test.js"
45
45
  },
46
46
  "dependencies": {
47
47
  "@modelcontextprotocol/sdk": "^1.0.0",