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.
- package/README.md +26 -6
- package/dist/cli.js +98 -11
- package/dist/config.d.ts +63 -0
- package/dist/config.js +73 -0
- package/dist/cursor.d.ts +46 -0
- package/dist/cursor.js +203 -0
- package/dist/dashboard.d.ts +45 -0
- package/dist/dashboard.js +394 -0
- package/dist/hook.js +42 -12
- package/dist/impact.js +1 -1
- package/dist/mcp.js +1 -1
- package/dist/profile.js +35 -1
- package/dist/session.d.ts +15 -0
- package/dist/session.js +21 -2
- package/package.json +3 -2
- package/dist/test/chatgpt-export.test.d.ts +0 -2
- package/dist/test/chatgpt-export.test.js +0 -45
- package/dist/test/doctor.test.d.ts +0 -2
- package/dist/test/doctor.test.js +0 -19
- package/dist/test/hook.test.d.ts +0 -5
- package/dist/test/hook.test.js +0 -54
- package/dist/test/mcp-http.test.d.ts +0 -6
- package/dist/test/mcp-http.test.js +0 -63
- package/dist/test/proxy.test.d.ts +0 -6
- package/dist/test/proxy.test.js +0 -121
- package/dist/test/smoke.test.d.ts +0 -2
- package/dist/test/smoke.test.js +0 -111
- package/dist/test/watch.test.d.ts +0 -2
- package/dist/test/watch.test.js +0 -36
|
@@ -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
|
|
@@ -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 =
|
|
73
|
-
const nextState = { t: shouldWarn ?
|
|
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:
|
|
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(
|
|
81
|
-
(
|
|
82
|
-
(
|
|
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 (
|
|
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
|
|
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.
|
|
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
|
|
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;
|