context-doctor 0.7.0 → 0.9.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.
@@ -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
@@ -68,26 +85,39 @@ export async function runHook() {
68
85
  if (parsed.messageCount === 0)
69
86
  return;
70
87
  const profile = profileConversation(parseConversation(parsed.conversationJson), parsed.model);
88
+ // Prefer the API's own figure when the transcript carries it: it includes
89
+ // the system prompt and tool schemas the transcript omits, so it is the
90
+ // real context size rather than a message-only estimate.
91
+ const liveTokens = parsed.reportedInputTokens ?? profile.totalTokens;
71
92
  // Record this parse so the next prompts take fast path 2.
72
- const shouldWarn = profile.totalTokens >= WARN_TOKENS && profile.totalTokens >= prev.t * REGROWTH_FACTOR;
73
- const nextState = { t: shouldWarn ? profile.totalTokens : prev.t, b: sizeBytes };
93
+ const shouldWarn = liveTokens >= threshold && liveTokens >= prev.t * REGROWTH_FACTOR;
94
+ const nextState = { t: shouldWarn ? liveTokens : prev.t, b: sizeBytes };
74
95
  const entries = Object.entries({ ...state, [sessionId]: nextState });
75
96
  writeFileSync(statePath(), JSON.stringify(Object.fromEntries(entries.slice(-100))));
76
- recordLedger({ ev: "check", sid: sessionId.slice(0, 12), tok: profile.totalTokens, warn: shouldWarn });
97
+ recordLedger({ ev: "check", sid: sessionId.slice(0, 12), tok: liveTokens, warn: shouldWarn });
77
98
  if (!shouldWarn)
78
99
  return;
100
+ const windowPct = profile.contextWindow ? (liveTokens / profile.contextWindow) * 100 : undefined;
101
+ const costPerCall = profile.cost && profile.totalTokens > 0
102
+ ? (profile.cost.perCallUsd * liveTokens) / profile.totalTokens
103
+ : undefined;
79
104
  const lines = [
80
- `This session's context is at ~${formatTokens(profile.totalTokens)} tokens` +
81
- (profile.usagePct ? ` (${profile.usagePct.toFixed(0)}% of the window)` : "") +
82
- (profile.cost ? `, costing ~${formatUsd(profile.cost.perCallUsd)} of input per message` : "") +
105
+ `This session's context is at ~${formatTokens(liveTokens)} tokens` +
106
+ (windowPct !== undefined ? ` (${windowPct.toFixed(0)}% of the window)` : "") +
107
+ (costPerCall !== undefined ? `, costing ~${formatUsd(costPerCall)} of input per message` : "") +
83
108
  ".",
84
109
  "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
110
  ];
111
+ // A configured budget is the user's own limit — say so first and by name.
112
+ const verdict = checkBudget(config.budget, { ...profile, totalTokens: liveTokens, usagePct: windowPct });
113
+ if (verdict.overBudget) {
114
+ lines.splice(1, 0, `This project's context budget is exceeded: ${verdict.breaches.join("; ")}. Treat compaction as a priority, not an option.`);
115
+ }
86
116
  const topFinding = profile.findings.find((f) => f.estSavings > 0);
87
117
  if (topFinding) {
88
118
  lines.push(`Largest recoverable waste: ${topFinding.message} (${topFinding.suggestion})`);
89
119
  }
90
- if (profile.totalTokens > WARN_TOKENS * 2) {
120
+ if (liveTokens > threshold * 2) {
91
121
  lines.push("If it fits the flow, offer the user a compaction of the older history.");
92
122
  }
93
123
  console.log(JSON.stringify({
package/dist/impact.js CHANGED
@@ -102,7 +102,7 @@ export async function buildImpactReport(proxyPort = 8787) {
102
102
  }
103
103
  lines.push("");
104
104
  // -- Measured-now: recent session profiles ------------------------------------
105
- lines.push("Your recent sessions — waste still recoverable today");
105
+ lines.push("Your recent sessions — live context and waste still recoverable");
106
106
  lines.push("─".repeat(56));
107
107
  const sessions = listSessions(8).filter((s) => s.sizeBytes <= MAX_SESSION_BYTES);
108
108
  if (sessions.length === 0) {
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.7.0" }, { instructions: SERVER_INSTRUCTIONS });
40
+ const server = new McpServer({ name: "context-doctor", version: "0.9.0" }, { instructions: SERVER_INSTRUCTIONS });
41
41
  server.tool("profile_context", "Profile an LLM conversation or prompt: token breakdown by category, largest messages, and actionable findings about wasted context (duplicates, oversized tool results, base64 blobs, cache-unfriendly ordering). Accepts OpenAI/Anthropic conversation JSON or raw text. Call this immediately whenever the user asks about token usage, context size, LLM cost, or latency — and proactively offer it once a conversation grows long or accumulates large pasted content.", {
42
42
  conversation: z.string().describe("Conversation JSON (OpenAI or Anthropic format, or bare message array) or raw prompt text"),
43
43
  model: z.string().optional().describe("Target model name for context-window math, e.g. claude-sonnet-5 or gpt-4o"),
package/dist/profile.js CHANGED
@@ -15,6 +15,40 @@ function categoryOf(m) {
15
15
  default: return "other";
16
16
  }
17
17
  }
18
+ /**
19
+ * Total recoverable tokens, counted as a UNION rather than a sum.
20
+ *
21
+ * Findings legitimately overlap: an oversized tool result can also be a
22
+ * near-duplicate, and a long history contains both. Adding their estimates
23
+ * would promise savings the same tokens can only deliver once. Each
24
+ * message-scoped finding is attributed to the message that would actually be
25
+ * removed (the LAST index — for a duplicate pair, the later copy), keeping the
26
+ * largest claim per message; whole-conversation findings then take only what
27
+ * is left unclaimed. The result is capped below the total, since no
28
+ * optimization reclaims an entire context.
29
+ */
30
+ function unionSavings(findings, totalTokens) {
31
+ const perMessage = new Map();
32
+ let unscoped = 0;
33
+ for (const f of findings) {
34
+ if (f.estSavings <= 0)
35
+ continue;
36
+ if (f.messages.length === 0) {
37
+ unscoped += f.estSavings;
38
+ continue;
39
+ }
40
+ const target = f.messages[f.messages.length - 1];
41
+ perMessage.set(target, Math.max(perMessage.get(target) ?? 0, f.estSavings));
42
+ }
43
+ const scoped = [...perMessage.values()].reduce((a, b) => a + b, 0);
44
+ // Whole-conversation findings can only claim tokens no message-scoped
45
+ // finding already claimed.
46
+ const headroom = Math.max(0, totalTokens - scoped);
47
+ const total = scoped + Math.min(unscoped, headroom);
48
+ // A context can never be optimized away entirely; 90% is the ceiling any
49
+ // strategy set could plausibly reach.
50
+ return Math.min(total, Math.round(totalTokens * 0.9));
51
+ }
18
52
  function preview(text, len = 90) {
19
53
  const clean = text.replace(/\s+/g, " ").trim();
20
54
  return clean.length > len ? clean.slice(0, len) + "…" : clean;
@@ -235,7 +269,7 @@ export function profileConversation(conv, model) {
235
269
  .slice(0, 5);
236
270
  const severityRank = { high: 0, warn: 1, info: 2 };
237
271
  findings.sort((a, b) => severityRank[a.severity] - severityRank[b.severity] || b.estSavings - a.estSavings);
238
- const totalEstSavings = findings.reduce((s, f) => s + f.estSavings, 0);
272
+ const totalEstSavings = unionSavings(findings, totalTokens);
239
273
  const pricing = pricingFor(model);
240
274
  let cost;
241
275
  if (pricing) {
package/dist/session.d.ts CHANGED
@@ -15,6 +15,21 @@ export interface SessionInfo {
15
15
  sizeBytes: number;
16
16
  }
17
17
  export interface ParsedSession {
18
+ /**
19
+ * The real input size of the most recent request, as reported by the API
20
+ * (input + cache-read + cache-creation tokens). Transcripts do not contain
21
+ * the harness's system prompt, tool schemas or skills, so an estimate over
22
+ * transcript messages alone undercounts badly — measured against these
23
+ * figures, by roughly 60%. When this is present, prefer it: it is ground
24
+ * truth rather than an estimate.
25
+ */
26
+ reportedInputTokens?: number;
27
+ /**
28
+ * Messages dropped because a compaction replaced them. Reporting live
29
+ * context means counting only what the model still sees; this records what
30
+ * was compacted away so the difference can be shown rather than hidden.
31
+ */
32
+ compactedAway?: number;
18
33
  /** Conversation JSON string in Anthropic-ish format, ready for parseConversation(). */
19
34
  conversationJson: string;
20
35
  title?: string;