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 +22 -5
- package/dist/cli.js +58 -7
- package/dist/config.d.ts +63 -0
- package/dist/config.js +73 -0
- package/dist/dashboard.d.ts +45 -0
- package/dist/dashboard.js +394 -0
- package/dist/hook.js +29 -7
- package/dist/mcp.js +1 -1
- package/dist/proxy.d.ts +15 -0
- package/dist/proxy.js +81 -2
- package/dist/test/config.test.d.ts +2 -0
- package/dist/test/config.test.js +64 -0
- package/dist/test/dashboard.test.d.ts +2 -0
- package/dist/test/dashboard.test.js +37 -0
- package/dist/test/proxy.test.js +44 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -34,14 +34,12 @@ Findings (4)
|
|
|
34
34
|
npx context-doctor install
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
-
|
|
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
|
|
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)
|
|
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 :
|
|
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
|
|
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 :
|
|
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) {
|
package/dist/config.d.ts
ADDED
|
@@ -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) => ({'&':'&','<':'<','>':'>','"':'"'}[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
|
-
|
|
21
|
-
|
|
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
|
|
40
|
+
* hold that many tokens of context. Lean sessions cost one stat() call — the
|
|
28
41
|
* transcript is never even read.
|
|
29
42
|
*/
|
|
30
|
-
|
|
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 <
|
|
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 >=
|
|
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 >
|
|
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.
|
|
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
|
-
|
|
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,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,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
|
+
});
|
package/dist/test/proxy.test.js
CHANGED
|
@@ -32,6 +32,7 @@ const upstream = http.createServer((req, res) => {
|
|
|
32
32
|
receivedApiKey = req.headers["x-api-key"];
|
|
33
33
|
res.writeHead(200, { "content-type": "text/event-stream" });
|
|
34
34
|
res.write("event: message_start\ndata: {}\n\n");
|
|
35
|
+
res.write('event: message_delta\ndata: {"usage":{"input_tokens":120,"output_tokens":45}}\n\n');
|
|
35
36
|
res.write("event: message_stop\ndata: {}\n\n");
|
|
36
37
|
res.end();
|
|
37
38
|
});
|
|
@@ -69,6 +70,49 @@ test("/stats reports cumulative savings with dollar estimate", async () => {
|
|
|
69
70
|
assert.ok(stats.tokensSaved > 1000, `saved tokens tracked (${stats.tokensSaved})`);
|
|
70
71
|
assert.ok(stats.estUsdSaved > 0, "dollar savings estimated from the request's model");
|
|
71
72
|
});
|
|
73
|
+
test("response usage is captured from the SSE stream; cache advisor fires on prefix churn", async () => {
|
|
74
|
+
// Second request with a DIFFERENT system prompt on the same model → advisory.
|
|
75
|
+
const churned = JSON.parse(payload);
|
|
76
|
+
churned.system = "You are helpful. TODAY IS A NEW DAY."; // classic cache-buster
|
|
77
|
+
churned.tools = [{ name: "t", description: "x".repeat(5000), input_schema: { type: "object" } }];
|
|
78
|
+
const first = JSON.parse(payload);
|
|
79
|
+
first.tools = churned.tools;
|
|
80
|
+
for (const body of [first, churned]) {
|
|
81
|
+
await fetch(`http://localhost:${proxyPort}/v1/messages`, {
|
|
82
|
+
method: "POST",
|
|
83
|
+
headers: { "content-type": "application/json", "x-api-key": "sk-test-not-real" },
|
|
84
|
+
body: JSON.stringify(body),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
const stats = (await (await fetch(`http://localhost:${proxyPort}/stats`)).json());
|
|
88
|
+
// Mock upstream reports usage in its SSE close event (added below).
|
|
89
|
+
assert.ok(stats.upstreamInputTokens >= 100, `usage input captured: ${stats.upstreamInputTokens}`);
|
|
90
|
+
assert.ok(stats.upstreamOutputTokens >= 40, `usage output captured: ${stats.upstreamOutputTokens}`);
|
|
91
|
+
assert.ok(stats.advice.some((a) => a.includes("prefix changed")), `prefix-churn advisory expected, got: ${JSON.stringify(stats.advice)}`);
|
|
92
|
+
assert.ok(stats.advice.some((a) => a.includes("cache_control")), "missing-cache_control advisory expected");
|
|
93
|
+
});
|
|
94
|
+
test("per-route config: empty strategy list disables optimization for matching models", async () => {
|
|
95
|
+
const { startProxy } = await import("../proxy.js");
|
|
96
|
+
const routed = startProxy({
|
|
97
|
+
port: 0,
|
|
98
|
+
anthropicUpstream: `http://localhost:${upstreamPort}`,
|
|
99
|
+
routes: [{ modelPrefix: "claude-sonnet", strategies: [] }],
|
|
100
|
+
});
|
|
101
|
+
await new Promise((r) => routed.once("listening", () => r()));
|
|
102
|
+
const routedPort = routed.address().port;
|
|
103
|
+
try {
|
|
104
|
+
const sent = payload;
|
|
105
|
+
await fetch(`http://localhost:${routedPort}/v1/messages`, {
|
|
106
|
+
method: "POST",
|
|
107
|
+
headers: { "content-type": "application/json" },
|
|
108
|
+
body: sent,
|
|
109
|
+
});
|
|
110
|
+
assert.equal(received.length, sent.length, "route with no strategies must pass body through unmodified");
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
routed.close();
|
|
114
|
+
}
|
|
115
|
+
});
|
|
72
116
|
test("unsupported paths get a clear 404, health stays up", async () => {
|
|
73
117
|
const notFound = await fetch(`http://localhost:${proxyPort}/v1/nope`, { method: "POST", body: "{}" });
|
|
74
118
|
assert.equal(notFound.status, 404);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "context-doctor",
|
|
3
|
-
"version": "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",
|