context-doctor 0.20.0 → 0.21.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 +52 -9
- package/dist/autopilot.js +8 -4
- package/dist/cli.js +48 -1
- package/dist/hook.js +16 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mcp.js +1 -1
- package/dist/proxy.d.ts +1 -1
- package/dist/proxy.js +1 -1
- package/dist/savings.d.ts +42 -0
- package/dist/savings.js +198 -0
- package/package.json +8 -4
package/README.md
CHANGED
|
@@ -15,15 +15,41 @@ Built and maintained by [gAI Ventures](https://gai.ventures).
|
|
|
15
15
|
|
|
16
16
|
## Quick start
|
|
17
17
|
|
|
18
|
+
**See what it would save you first** (no install, reads your local Claude Code history):
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npx context-doctor savings
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
What autopilot would have saved you (last 30 days, 53 Claude Code sessions)
|
|
26
|
+
────────────────────────────────────────────────────────
|
|
27
|
+
Input you were billed for $11,205 (from the usage your transcripts record)
|
|
28
|
+
Autopilot would have cut $1,396 12.5%, 1.2B tokens not sent
|
|
29
|
+
Sessions made more expensive 0
|
|
30
|
+
Biggest win $503 tech-Whatsit (31% of that session)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
That is the author's machine. Yours is computed the same way: every recent session replayed request by request through the shipped autopilot code, against what your transcripts show you were actually billed.
|
|
34
|
+
|
|
35
|
+
**Then install it**, whichever way suits you:
|
|
36
|
+
|
|
18
37
|
```bash
|
|
38
|
+
# Everything, every app it finds (Claude Code, Cursor, Codex, Claude Desktop)
|
|
19
39
|
npm install -g context-doctor
|
|
20
|
-
context-doctor install
|
|
40
|
+
context-doctor install
|
|
21
41
|
context-doctor autopilot on # every new Claude Code session keeps its context lean
|
|
22
42
|
```
|
|
23
43
|
|
|
24
|
-
|
|
44
|
+
```text
|
|
45
|
+
# Or as a Claude Code plugin, from inside Claude Code
|
|
46
|
+
/plugin marketplace add KushalP1/context-doctor
|
|
47
|
+
/plugin install context-doctor@context-doctor
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The plugin brings the every-prompt check, the MCP tools, and `/context-doctor:savings`, `/context-doctor:checkup` and `/context-doctor:autopilot`. It needs no npm step: the MCP server ships as one self-contained file, so it also works on Claude Code versions that do not install plugin dependencies.
|
|
25
51
|
|
|
26
|
-
|
|
52
|
+
`context-doctor autopilot status` shows what autopilot did; `context-doctor doctor` checks the whole setup. Everything is reversible: `context-doctor autopilot off`, `context-doctor uninstall`, or `/plugin uninstall`.
|
|
27
53
|
|
|
28
54
|
```
|
|
29
55
|
Where the tokens go
|
|
@@ -62,7 +88,7 @@ What is not counted here: the proxy's full optimizer for your own API apps, the
|
|
|
62
88
|
|
|
63
89
|
| Where you work | Automatic, every request | What you get on top |
|
|
64
90
|
|---|---|---|
|
|
65
|
-
| **Claude Code** (terminal, VS Code, JetBrains, desktop app's Code tab) | **Autopilot** clears stale tool output (cold cache only, never more expensive). **Hook** on every prompt warns the model with the real context size and its largest waste | Status bar context meter, `/context-doctor
|
|
91
|
+
| **Claude Code** (terminal, VS Code, JetBrains, desktop app's Code tab) | **Autopilot** clears stale tool output (cold cache only, never more expensive). **Hook** on every prompt warns the model with the real context size and its largest waste | Install via npm or as a **plugin** (`/plugin marketplace add KushalP1/context-doctor`). Status bar context meter, `/context-doctor:savings`, `session`, `watch`, `report`, dashboard |
|
|
66
92
|
| **Cursor** (agent) | Cursor runs Claude Code's hooks, so the same every-prompt check fires inside Cursor | MCP tools, editor status bar extension, `cursor` profiler. With your own OpenAI key, autopilot too via a tokened tunnel ([how](#putting-the-proxy-on-a-public-url-cursor-with-your-own-openai-key-remote-apps)) |
|
|
67
93
|
| **Codex** (ChatGPT app's Codex tab, IDE extension, CLI) | Every-prompt hook with the API's own token counts | MCP tools, skill, `session` reads Codex rollouts. On an API key, autopilot too (`OPENAI_BASE_URL`) |
|
|
68
94
|
| **Your own apps on the Anthropic or OpenAI API** | Autopilot on `/v1/messages`, `/v1/chat/completions` and `/v1/responses` (`ANTHROPIC_BASE_URL` / `OPENAI_BASE_URL`), or the full optimizing proxy | Exact usage and cache hit rates in `/stats`, prompt-cache placement advice |
|
|
@@ -75,6 +101,8 @@ Not claimed, because no process on your machine sends those requests: trimming i
|
|
|
75
101
|
## What's new
|
|
76
102
|
|
|
77
103
|
- **0.20 Autopilot**: stale tool output cleared from every Claude Code request, only when the prompt cache is cold, so it cannot cost more (measured: 9.8% less input cost, ~$1,080 a month on the author's usage, no session worse); runs as a login service on macOS, Linux and Windows; now also for GPT via OpenAI's Chat Completions and Responses APIs.
|
|
104
|
+
- **0.21 See it before you install it**: `npx context-doctor savings` replays your own recent sessions through autopilot and shows what it would have saved, against what you were actually billed. Install as a **Claude Code plugin** from inside Claude Code. Ready for the official **MCP Registry** (`io.github.KushalP1/context-doctor`), which every release now publishes to; any MCP client can launch it as `npx -y context-doctor mcp`.
|
|
105
|
+
- **0.20.1 Releases that finish themselves**: one tag publishes to npm and creates a GitHub release with the Claude Desktop bundle (signed when a certificate is configured); the editor extension is ready for the VS Code Marketplace and Open VSX.
|
|
78
106
|
- **0.19 Measured Claude tokenizer**: estimates were 40% low for Claude; fixed from the API's own counts, with a per-model check in `accuracy`.
|
|
79
107
|
- **0.18** `proxy --token` for putting the proxy on a public URL safely. **0.17** Claude Desktop: a `profile_context` the model can afford to call from chat, `.mcpb` bundle, standing preferences for web and mobile. **0.16** Codex. **0.15** Cursor.
|
|
80
108
|
|
|
@@ -137,6 +165,8 @@ Practical upshot: a developer who only wants cheaper, faster API calls never tou
|
|
|
137
165
|
| Command | What it does |
|
|
138
166
|
|---|---|
|
|
139
167
|
| `context-doctor install` / `uninstall` | Wire (or remove) everything: MCP for Claude Desktop/Code/Cursor/Codex, the Agent Skill, the every-prompt hook |
|
|
168
|
+
| `context-doctor savings [--days n]` | What autopilot saves, or would have saved, on your own recent Claude Code sessions: replayed through the shipped code, against your actual billed usage. A bare `context-doctor` in a terminal shows the same |
|
|
169
|
+
| `context-doctor mcp [--http]` | The MCP server as a subcommand, for clients and registries that launch `npx -y context-doctor mcp` |
|
|
140
170
|
| `context-doctor autopilot on\|off\|pause\|resume\|status` | Every new Claude Code session goes through the local proxy, which clears stale tool output only when the prompt cache is cold: measured 9.8% less input cost, no session worse |
|
|
141
171
|
| `context-doctor instructions [--copy]` | The ~180-token standing rules (~120 on GPT) for claude.ai / ChatGPT preferences, for web and phones where no server runs |
|
|
142
172
|
| `context-doctor analyze <file>` | Profile a conversation: token breakdown, findings, cost + latency estimates. `--fail-over-budget` exits 1 on a breach, for CI |
|
|
@@ -580,14 +610,27 @@ Contributions welcome — this project is small on purpose. Open an issue before
|
|
|
580
610
|
|
|
581
611
|
## Releasing (maintainers)
|
|
582
612
|
|
|
613
|
+
**One-time setup.** Each row switches on one channel; any that is missing is skipped with a notice, never a failed run. Secrets go in the repo's Settings > Secrets and variables > Actions:
|
|
614
|
+
|
|
615
|
+
| Secret | Turns on | Where to get it |
|
|
616
|
+
|---|---|---|
|
|
617
|
+
| *(no secret: npm trusted publishing)* | `npm publish` on every `v*` tag, with provenance | npmjs.com > package `context-doctor` > Settings > Trusted Publisher > GitHub Actions: owner `KushalP1`, repository `context-doctor`, workflow `publish.yml`. npm is phasing out publish tokens that bypass 2FA; an `NPM_TOKEN` secret still works as a fallback |
|
|
618
|
+
| `MCPB_CERT`, `MCPB_KEY` (+ `MCPB_INTERMEDIATE`) | A signed Claude Desktop bundle, no install warning | A code-signing certificate from a trusted CA; paste the PEM text. `mcpb verify` must pass in CI or the release stops |
|
|
619
|
+
| `VSCE_PAT` | VS Code Marketplace on `vscode-v*` tags | Azure DevOps PAT, scope Marketplace > Manage, for the `gai-ventures` publisher |
|
|
620
|
+
| `OVSX_PAT` | Open VSX (where Cursor installs from) | open-vsx.org > Settings > Access Tokens |
|
|
621
|
+
|
|
622
|
+
**Each release:**
|
|
623
|
+
|
|
583
624
|
```bash
|
|
584
|
-
npm version
|
|
585
|
-
npm test #
|
|
586
|
-
npm publish
|
|
587
|
-
git push --follow-tags
|
|
588
|
-
npm run build:mcpb # context-doctor-<version>.mcpb for Claude Desktop; attach it to the GitHub release
|
|
625
|
+
npm version minor # or patch/major: bumps package.json and tags vX.Y.Z
|
|
626
|
+
npm test # the full suite; CI runs it on 3 OSes x Node 20/22/24
|
|
627
|
+
git push --follow-tags # npm publish + GitHub release with the .mcpb attached
|
|
589
628
|
```
|
|
590
629
|
|
|
630
|
+
For the editor extension: bump `vscode/package.json`, add a `vscode/CHANGELOG.md` entry, then `git tag vscode-vX.Y.Z && git push --tags`. The `.vsix` is attached to a GitHub release either way.
|
|
631
|
+
|
|
632
|
+
Locally, `npm run build:mcpb` builds the bundle (`MCPB_SELF_SIGNED=1` exercises the signing path with a throwaway certificate; Desktop still warns for those).
|
|
633
|
+
|
|
591
634
|
**What the npm download number measures.** `install` writes `npx -y context-doctor-mcp` into MCP configs, and npx re-fetches the tarball whenever a new version exists. So every release is downloaded once by every active install within about a day, and the daily count is almost entirely those refreshes: on this package, release days run ~170 downloads and non-release days ~27. Read it as "size of the active installed base × number of releases", not as new users — a quiet week with no releases will look like a decline while nothing has changed. Two corollaries: the release-day figure is a live count of machines running context-doctor, and a broken release reaches all of them automatically, which is why `prepublishOnly` runs the full test suite. npm's stats also lag by several days and occasionally record a day as zero; a zero on a release day is a gap in their pipeline, not in usage.
|
|
592
635
|
|
|
593
636
|
Known gotcha: if `npm publish` fails with **`404 Not Found - PUT …/context-doctor`** on a package that clearly exists, the real cause is an **expired npm login token** — npm reports unauthenticated publishes as a 404, not a 401. Check with `npm whoami`; if that errors, run `npm login` and publish again.
|
package/dist/autopilot.js
CHANGED
|
@@ -179,8 +179,12 @@ async function waitHealthy(port, ms) {
|
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
181
|
function readConfig(paths) {
|
|
182
|
+
// readJson returns {} for a missing file; only a file with a port is a config.
|
|
183
|
+
if (!existsSync(paths.config))
|
|
184
|
+
return undefined;
|
|
182
185
|
try {
|
|
183
|
-
|
|
186
|
+
const cfg = readJson(paths.config);
|
|
187
|
+
return typeof cfg.port === "number" && cfg.port > 0 ? cfg : undefined;
|
|
184
188
|
}
|
|
185
189
|
catch {
|
|
186
190
|
return undefined;
|
|
@@ -192,11 +196,11 @@ export function currentCli() {
|
|
|
192
196
|
export async function autopilotOn(port = DEFAULT_AUTOPILOT_PORT, paths = autopilotPaths()) {
|
|
193
197
|
const lines = [];
|
|
194
198
|
const cli = currentCli();
|
|
195
|
-
if (isEphemeralPath(cli)) {
|
|
199
|
+
if (isEphemeralPath(cli) || /[\\/]\.claude[\\/]plugins[\\/]/.test(cli)) {
|
|
196
200
|
return {
|
|
197
201
|
ok: false,
|
|
198
202
|
lines: [
|
|
199
|
-
"✗ Running from the npx cache, which
|
|
203
|
+
"✗ Running from the npx cache or a Claude Code plugin copy, which are replaced or deleted on update; a background service cannot point there.",
|
|
200
204
|
" Install it once, then re-run: npm install -g context-doctor && context-doctor autopilot on",
|
|
201
205
|
],
|
|
202
206
|
};
|
|
@@ -318,7 +322,7 @@ export async function ensureProxyUp(paths = autopilotPaths()) {
|
|
|
318
322
|
return;
|
|
319
323
|
if ((await health(cfg.port, 300)).ok)
|
|
320
324
|
return;
|
|
321
|
-
if (!existsSync(cfg.node) || !existsSync(cfg.cli))
|
|
325
|
+
if (typeof cfg.node !== "string" || typeof cfg.cli !== "string" || !existsSync(cfg.node) || !existsSync(cfg.cli))
|
|
322
326
|
return;
|
|
323
327
|
startDetached(serviceCommand(cfg.node, cfg.cli, cfg.port, paths), paths.log);
|
|
324
328
|
await waitHealthy(cfg.port, 2500);
|
package/dist/cli.js
CHANGED
|
@@ -21,7 +21,8 @@ import { listSessions, parseSessionFile } from "./session.js";
|
|
|
21
21
|
import { runHook } from "./hook.js";
|
|
22
22
|
import { buildImpactReport } from "./impact.js";
|
|
23
23
|
import { measureTokenizer, renderTokenizer } from "./tokenizer-measure.js";
|
|
24
|
-
import { autopilotOff, autopilotOn, autopilotPause, autopilotStatus, DEFAULT_AUTOPILOT_PORT } from "./autopilot.js";
|
|
24
|
+
import { autopilotOff, autopilotOn, autopilotPause, autopilotPaths, autopilotStatus, DEFAULT_AUTOPILOT_PORT } from "./autopilot.js";
|
|
25
|
+
import { estimateSavings, renderSavings } from "./savings.js";
|
|
25
26
|
import { renderPreferences, copyToClipboard, CHAT_PREFERENCES } from "./preferences.js";
|
|
26
27
|
import { recordLedger } from "./ledger.js";
|
|
27
28
|
import { runDoctor } from "./doctor.js";
|
|
@@ -49,10 +50,15 @@ Usage:
|
|
|
49
50
|
context-doctor install Wire the MCP server + skill into Claude Desktop,
|
|
50
51
|
Claude Code, and Cursor automatically
|
|
51
52
|
context-doctor uninstall Undo install
|
|
53
|
+
context-doctor savings [--days n] What autopilot saves (or would have) on YOUR recent
|
|
54
|
+
Claude Code sessions: replayed, priced as billed.
|
|
55
|
+
Also what a bare \`context-doctor\` shows
|
|
52
56
|
context-doctor autopilot on|off|pause|resume|status
|
|
53
57
|
Every new Claude Code session goes through the
|
|
54
58
|
local proxy, which clears stale tool output only
|
|
55
59
|
when the prompt cache is cold (never costs more)
|
|
60
|
+
context-doctor mcp [--http] The MCP server (same as context-doctor-mcp), for clients
|
|
61
|
+
that launch \`npx -y context-doctor mcp\`
|
|
56
62
|
context-doctor instructions [--copy] Standing context rules to paste into claude.ai or
|
|
57
63
|
ChatGPT preferences (works on web and mobile too)
|
|
58
64
|
context-doctor session [file] Profile a Claude Code session transcript or a
|
|
@@ -211,6 +217,9 @@ function parseArgs(argv) {
|
|
|
211
217
|
case "--token":
|
|
212
218
|
args.token = argv[++i];
|
|
213
219
|
break;
|
|
220
|
+
case "--days":
|
|
221
|
+
args.days = Number(argv[++i]);
|
|
222
|
+
break;
|
|
214
223
|
case "--autopilot":
|
|
215
224
|
args.autopilot = true;
|
|
216
225
|
break;
|
|
@@ -266,6 +275,15 @@ function readInput(file) {
|
|
|
266
275
|
return readFileSync(0, "utf8");
|
|
267
276
|
return readFileSync(file, "utf8");
|
|
268
277
|
}
|
|
278
|
+
/** Any Claude Code transcript on this machine? One directory listing, no parsing. */
|
|
279
|
+
function hasClaudeSessions() {
|
|
280
|
+
try {
|
|
281
|
+
return listSessions(1).some((s) => s.path.includes(".claude"));
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
269
287
|
async function main() {
|
|
270
288
|
const args = parseArgs(process.argv.slice(2));
|
|
271
289
|
if (args.command === "hook") {
|
|
@@ -459,6 +477,35 @@ async function main() {
|
|
|
459
477
|
process.exitCode = 1;
|
|
460
478
|
return;
|
|
461
479
|
}
|
|
480
|
+
if (args.command === "savings" || (!args.command && process.stdout.isTTY && hasClaudeSessions())) {
|
|
481
|
+
// A bare `context-doctor` in a terminal, with Claude Code history on the
|
|
482
|
+
// machine, answers the question people install this for: what would it
|
|
483
|
+
// save me? Scripts and pipes still get the help text.
|
|
484
|
+
const progress = process.stderr.isTTY
|
|
485
|
+
? (done, total) => process.stderr.write(`\rReplaying your Claude Code sessions through autopilot… ${done}/${total}`)
|
|
486
|
+
: undefined;
|
|
487
|
+
const report = estimateSavings(args.days ?? 30, {}, undefined, progress);
|
|
488
|
+
if (progress)
|
|
489
|
+
process.stderr.write("\r\x1b[K");
|
|
490
|
+
const on = existsSync(autopilotPaths().config);
|
|
491
|
+
if (args.json)
|
|
492
|
+
console.log(JSON.stringify(report, null, 2));
|
|
493
|
+
else {
|
|
494
|
+
console.log(renderSavings(report, on));
|
|
495
|
+
if (!args.command)
|
|
496
|
+
console.log("\nAll commands: context-doctor --help");
|
|
497
|
+
}
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
if (args.command === "mcp") {
|
|
501
|
+
// `context-doctor mcp [--http ...]` is the MCP server, the same program as
|
|
502
|
+
// the context-doctor-mcp binary. Registries and clients that launch a
|
|
503
|
+
// package with `npx -y context-doctor <args>` can only reach the package's
|
|
504
|
+
// main binary, so the server has to be a subcommand of it too.
|
|
505
|
+
process.argv.splice(2, 1);
|
|
506
|
+
await import("./mcp.js");
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
462
509
|
if (args.command === "autopilot") {
|
|
463
510
|
const sub = args.file ?? "status";
|
|
464
511
|
const port = args.port ?? DEFAULT_AUTOPILOT_PORT;
|
package/dist/hook.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
14
14
|
import { createHash } from "node:crypto";
|
|
15
|
+
import { homedir } from "node:os";
|
|
15
16
|
import { join } from "node:path";
|
|
16
17
|
import { recordLedger, statePath } from "./ledger.js";
|
|
17
18
|
import { parseConversation } from "./parse.js";
|
|
@@ -119,6 +120,15 @@ async function readStdin() {
|
|
|
119
120
|
chunks.push(chunk);
|
|
120
121
|
return Buffer.concat(chunks).toString("utf8");
|
|
121
122
|
}
|
|
123
|
+
function settingsHasOurHook() {
|
|
124
|
+
try {
|
|
125
|
+
const hooks = JSON.parse(readFileSync(join(homedir(), ".claude", "settings.json"), "utf8"))?.hooks?.UserPromptSubmit;
|
|
126
|
+
return JSON.stringify(hooks ?? "").includes("context-doctor");
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
122
132
|
export async function runHook() {
|
|
123
133
|
// A hook must never break the user's prompt: any failure exits silently.
|
|
124
134
|
try {
|
|
@@ -126,6 +136,12 @@ export async function runHook() {
|
|
|
126
136
|
// so if the proxy died, start it now. One existsSync when autopilot is
|
|
127
137
|
// off; a ~1ms localhost health check when it is on and healthy.
|
|
128
138
|
const heal = ensureProxyUp().catch(() => undefined);
|
|
139
|
+
// Installed both ways (plugin and `context-doctor install`)? The plugin
|
|
140
|
+
// copy stays silent so the model is told once, not twice.
|
|
141
|
+
if (process.env.CLAUDE_PLUGIN_ROOT && settingsHasOurHook()) {
|
|
142
|
+
await heal;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
129
145
|
const input = JSON.parse(await readStdin());
|
|
130
146
|
await heal;
|
|
131
147
|
const transcriptPath = input.transcript_path;
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/mcp.js
CHANGED
|
@@ -49,7 +49,7 @@ const STRATEGY_IDS = ["dedupe", "trim-tool-results", "trim-tool-calls", "strip-b
|
|
|
49
49
|
* recommended pattern.
|
|
50
50
|
*/
|
|
51
51
|
function createServer() {
|
|
52
|
-
const server = new McpServer({ name: "context-doctor", version: "0.
|
|
52
|
+
const server = new McpServer({ name: "context-doctor", version: "0.21.0" }, { instructions: SERVER_INSTRUCTIONS });
|
|
53
53
|
server.tool("profile_context", "Profile an LLM conversation or prompt: token breakdown, largest blocks, and actionable findings about wasted context (duplicates, oversized pastes or tool results, base64 blobs, long history). Two inputs, pass ONE: `conversation` (full OpenAI/Anthropic JSON or raw text, for agents, files and proxies) or `sketch` (for chat apps such as Claude Desktop or ChatGPT where you cannot export the conversation: the turn count plus the few blocks that matter, ~120 tokens to write). Call it whenever the user asks about token usage, context size, cost, speed or limits, and on your own once the conversation passes ~30 turns or holds 3+ large pastes. Act on the top finding in your reply.", {
|
|
54
54
|
conversation: z.string().optional().describe("Conversation JSON (OpenAI or Anthropic format, or bare message array) or raw prompt text. Omit in chat apps and pass `sketch`."),
|
|
55
55
|
sketch: z.object({
|
package/dist/proxy.d.ts
CHANGED
|
@@ -47,7 +47,7 @@ export interface ProxyOptions extends OptimizeOptions {
|
|
|
47
47
|
openaiUpstream?: string;
|
|
48
48
|
}
|
|
49
49
|
/** Reported by /health so `autopilot status` can tell an outdated service from a current one. */
|
|
50
|
-
export declare const PROXY_VERSION = "0.
|
|
50
|
+
export declare const PROXY_VERSION = "0.21.0";
|
|
51
51
|
/**
|
|
52
52
|
* Remove a leading `/t/<token>` from a request path, or return undefined when
|
|
53
53
|
* the prefix is absent or the token differs. The comparison is constant time
|
package/dist/proxy.js
CHANGED
|
@@ -20,7 +20,7 @@ import { formatTokens, CHARS_PER_TOKEN, providerFor } from "./tokens.js";
|
|
|
20
20
|
import { formatUsd, inputCostUsd, pricingFor } from "./pricing.js";
|
|
21
21
|
import { recordLedger } from "./ledger.js";
|
|
22
22
|
/** Reported by /health so `autopilot status` can tell an outdated service from a current one. */
|
|
23
|
-
export const PROXY_VERSION = "0.
|
|
23
|
+
export const PROXY_VERSION = "0.21.0";
|
|
24
24
|
/** Connection-level headers that must not be forwarded. */
|
|
25
25
|
const SKIP_REQUEST_HEADERS = new Set(["host", "content-length", "connection", "transfer-encoding", "accept-encoding", "expect"]);
|
|
26
26
|
const SKIP_RESPONSE_HEADERS = new Set(["content-length", "content-encoding", "transfer-encoding", "connection"]);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `context-doctor savings`: what autopilot would have saved on YOUR recent
|
|
3
|
+
* Claude Code sessions, measured, not modelled.
|
|
4
|
+
*
|
|
5
|
+
* Each session is replayed request by request, twice: as it was sent, and as
|
|
6
|
+
* autopilot (autoclear.ts, the shipped class) would have sent it. Both arms
|
|
7
|
+
* are priced the way the prompt cache bills (reads 0.1x, writes 1.25x, a
|
|
8
|
+
* cold cache after an idle gap longer than the request's own TTL). The
|
|
9
|
+
* difference between the arms is the saving. The baseline shown to the user
|
|
10
|
+
* is not an estimate: it is the input the sessions were actually billed for,
|
|
11
|
+
* read from the usage every transcript records.
|
|
12
|
+
*/
|
|
13
|
+
import { type AutoClearOptions } from "./autoclear.js";
|
|
14
|
+
export interface SessionSavings {
|
|
15
|
+
path: string;
|
|
16
|
+
project: string;
|
|
17
|
+
model?: string;
|
|
18
|
+
requests: number;
|
|
19
|
+
/** Cache-weighted input actually billed (usage in the transcript), in base-input-token units. */
|
|
20
|
+
billedWeighted: number;
|
|
21
|
+
billedUsd: number;
|
|
22
|
+
/** Replay difference, in the same units. */
|
|
23
|
+
savedWeighted: number;
|
|
24
|
+
savedUsd: number;
|
|
25
|
+
/** Raw input tokens autopilot would not have sent. */
|
|
26
|
+
savedTokens: number;
|
|
27
|
+
savedPct: number;
|
|
28
|
+
}
|
|
29
|
+
export interface SavingsReport {
|
|
30
|
+
days: number;
|
|
31
|
+
sessions: SessionSavings[];
|
|
32
|
+
billedUsd: number;
|
|
33
|
+
savedUsd: number;
|
|
34
|
+
savedTokens: number;
|
|
35
|
+
billedTokens: number;
|
|
36
|
+
savedPct: number;
|
|
37
|
+
worse: number;
|
|
38
|
+
}
|
|
39
|
+
/** Replay one Claude Code transcript. Undefined when it holds too few requests to mean anything. */
|
|
40
|
+
export declare function replaySession(path: string, options?: AutoClearOptions): SessionSavings | undefined;
|
|
41
|
+
export declare function estimateSavings(days?: number, options?: AutoClearOptions, paths?: string[], onProgress?: (done: number, total: number) => void): SavingsReport;
|
|
42
|
+
export declare function renderSavings(r: SavingsReport, autopilotOn?: boolean): string;
|
package/dist/savings.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `context-doctor savings`: what autopilot would have saved on YOUR recent
|
|
3
|
+
* Claude Code sessions, measured, not modelled.
|
|
4
|
+
*
|
|
5
|
+
* Each session is replayed request by request, twice: as it was sent, and as
|
|
6
|
+
* autopilot (autoclear.ts, the shipped class) would have sent it. Both arms
|
|
7
|
+
* are priced the way the prompt cache bills (reads 0.1x, writes 1.25x, a
|
|
8
|
+
* cold cache after an idle gap longer than the request's own TTL). The
|
|
9
|
+
* difference between the arms is the saving. The baseline shown to the user
|
|
10
|
+
* is not an estimate: it is the input the sessions were actually billed for,
|
|
11
|
+
* read from the usage every transcript records.
|
|
12
|
+
*/
|
|
13
|
+
import { basename, dirname } from "node:path";
|
|
14
|
+
import { AutoClearer } from "./autoclear.js";
|
|
15
|
+
import { forEachLine, listSessions } from "./session.js";
|
|
16
|
+
import { pricingFor } from "./pricing.js";
|
|
17
|
+
import { estimateTokens, formatTokens } from "./tokens.js";
|
|
18
|
+
/** System prompt and tool schemas: sent on every request, identical in both arms. */
|
|
19
|
+
const FIXED_TOKENS = 54_000;
|
|
20
|
+
const CLAUDE_CODE_TTL_MS = 3_600_000;
|
|
21
|
+
/** One arm of the replay: the history as sent, priced incrementally. */
|
|
22
|
+
function arm(model, clearer) {
|
|
23
|
+
const msgs = [];
|
|
24
|
+
const tok = [];
|
|
25
|
+
let total = 0, prevLen = 0, weighted = 0, raw = 0;
|
|
26
|
+
const size = (m) => estimateTokens(JSON.stringify(m), model);
|
|
27
|
+
return {
|
|
28
|
+
push(m) { msgs.push(structuredClone(m)); const k = size(m); tok.push(k); total += k; },
|
|
29
|
+
extend(blocks) {
|
|
30
|
+
const i = msgs.length - 1;
|
|
31
|
+
msgs[i].content.push(...structuredClone(blocks));
|
|
32
|
+
total -= tok[i];
|
|
33
|
+
tok[i] = size(msgs[i]);
|
|
34
|
+
total += tok[i];
|
|
35
|
+
},
|
|
36
|
+
request(now, cold) {
|
|
37
|
+
let first = -1;
|
|
38
|
+
if (clearer) {
|
|
39
|
+
const body = { model, messages: msgs, system: [{ type: "text", text: "", cache_control: { type: "ephemeral", ttl: "1h" } }] };
|
|
40
|
+
first = clearer.apply(body, now).firstChanged;
|
|
41
|
+
}
|
|
42
|
+
if (first >= 0)
|
|
43
|
+
for (let i = first; i < msgs.length; i++) {
|
|
44
|
+
total -= tok[i];
|
|
45
|
+
tok[i] = size(msgs[i]);
|
|
46
|
+
total += tok[i];
|
|
47
|
+
}
|
|
48
|
+
const k = cold ? 0 : Math.min(prevLen, first >= 0 ? first : prevLen);
|
|
49
|
+
let read = 0;
|
|
50
|
+
for (let i = 0; i < k; i++)
|
|
51
|
+
read += tok[i];
|
|
52
|
+
const write = total - read;
|
|
53
|
+
weighted += (cold ? FIXED_TOKENS * 1.25 : FIXED_TOKENS * 0.1) + read * 0.1 + write * 1.25;
|
|
54
|
+
raw += FIXED_TOKENS + total;
|
|
55
|
+
prevLen = msgs.length;
|
|
56
|
+
},
|
|
57
|
+
get weighted() { return weighted; },
|
|
58
|
+
get raw() { return raw; },
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/** Replay one Claude Code transcript. Undefined when it holds too few requests to mean anything. */
|
|
62
|
+
export function replaySession(path, options = {}) {
|
|
63
|
+
let model;
|
|
64
|
+
let base = arm(undefined, null), auto = arm(undefined, new AutoClearer(options));
|
|
65
|
+
let baseW = 0, autoW = 0, baseRaw = 0, autoRaw = 0;
|
|
66
|
+
let billed = 0, billedRaw = 0, requests = 0, lastId, lastTs = null;
|
|
67
|
+
const flush = () => { baseW += base.weighted; autoW += auto.weighted; baseRaw += base.raw; autoRaw += auto.raw; };
|
|
68
|
+
forEachLine(path, (line) => {
|
|
69
|
+
let e;
|
|
70
|
+
try {
|
|
71
|
+
e = JSON.parse(line);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (!e || e.isSidechain)
|
|
77
|
+
return;
|
|
78
|
+
if (e.type === "system" && e.subtype === "compact_boundary") {
|
|
79
|
+
// Compaction replaces the history in both arms alike: start fresh.
|
|
80
|
+
flush();
|
|
81
|
+
base = arm(model, null);
|
|
82
|
+
auto = arm(model, new AutoClearer(options));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (e.type === "assistant" && e.message) {
|
|
86
|
+
const m = e.message;
|
|
87
|
+
model = model ?? m.model;
|
|
88
|
+
const blocks = (Array.isArray(m.content) ? m.content : []).filter((b) => b?.type !== "thinking" && b?.type !== "redacted_thinking");
|
|
89
|
+
if (m.id !== lastId) {
|
|
90
|
+
lastId = m.id;
|
|
91
|
+
const u = m.usage ?? {};
|
|
92
|
+
const read = u.cache_read_input_tokens ?? 0, write = u.cache_creation_input_tokens ?? 0, fresh = u.input_tokens ?? 0;
|
|
93
|
+
if (read + write + fresh > 0) {
|
|
94
|
+
billed += fresh + read * 0.1 + write * 1.25;
|
|
95
|
+
billedRaw += fresh + read + write;
|
|
96
|
+
requests++;
|
|
97
|
+
const now = Date.parse(e.timestamp) || 0;
|
|
98
|
+
const cold = lastTs === null || now - lastTs > CLAUDE_CODE_TTL_MS;
|
|
99
|
+
base.request(now, cold);
|
|
100
|
+
auto.request(now, cold);
|
|
101
|
+
lastTs = now;
|
|
102
|
+
}
|
|
103
|
+
base.push({ role: "assistant", content: blocks });
|
|
104
|
+
auto.push({ role: "assistant", content: blocks });
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
base.extend(blocks);
|
|
108
|
+
auto.extend(blocks);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
else if (e.type === "user" && e.message) {
|
|
112
|
+
const c = e.message.content;
|
|
113
|
+
const msg = { role: "user", content: Array.isArray(c) ? c : [{ type: "text", text: String(c ?? "") }] };
|
|
114
|
+
base.push(msg);
|
|
115
|
+
auto.push(msg);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
flush();
|
|
119
|
+
if (requests < 10 || billed <= 0)
|
|
120
|
+
return undefined;
|
|
121
|
+
// The replay decides the SHARE saved (both arms priced the same way); that
|
|
122
|
+
// share is applied to what was actually billed, so the dollars and tokens
|
|
123
|
+
// shown are fractions of the user's real usage, not of an estimate.
|
|
124
|
+
const perM = pricingFor(model)?.inputPerM ?? 0;
|
|
125
|
+
const pct = baseW > 0 ? (baseW - autoW) / baseW : 0;
|
|
126
|
+
const rawPct = baseRaw > 0 ? (baseRaw - autoRaw) / baseRaw : 0;
|
|
127
|
+
const dir = basename(dirname(path));
|
|
128
|
+
return {
|
|
129
|
+
path,
|
|
130
|
+
project: dir.replace(/^-Users-[^-]+-/, "").replace(/^-/, "") || dir,
|
|
131
|
+
model,
|
|
132
|
+
requests,
|
|
133
|
+
billedWeighted: billed,
|
|
134
|
+
billedUsd: (billed / 1e6) * perM,
|
|
135
|
+
savedWeighted: pct * billed,
|
|
136
|
+
savedUsd: ((pct * billed) / 1e6) * perM,
|
|
137
|
+
savedTokens: rawPct * billedRaw,
|
|
138
|
+
savedPct: pct,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
export function estimateSavings(days = 30, options = {}, paths, onProgress) {
|
|
142
|
+
const since = Date.now() - days * 86_400_000;
|
|
143
|
+
const targets = paths ?? listSessions(10_000).filter((s) => s.path.includes(".claude") && s.modifiedAt.getTime() >= since).map((s) => s.path);
|
|
144
|
+
const sessions = [];
|
|
145
|
+
for (const [i, p] of targets.entries()) {
|
|
146
|
+
onProgress?.(i, targets.length);
|
|
147
|
+
try {
|
|
148
|
+
const s = replaySession(p, options);
|
|
149
|
+
if (s)
|
|
150
|
+
sessions.push(s);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
/* one unreadable transcript must not sink the report */
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const sum = (k) => sessions.reduce((n, s) => n + s[k], 0);
|
|
157
|
+
const billedUsd = sum("billedUsd"), savedUsd = sum("savedUsd");
|
|
158
|
+
const billedW = sum("billedWeighted");
|
|
159
|
+
const savedPct = billedW > 0 ? sum("savedWeighted") / billedW : 0;
|
|
160
|
+
return {
|
|
161
|
+
days,
|
|
162
|
+
sessions: sessions.sort((a, b) => b.savedUsd - a.savedUsd),
|
|
163
|
+
billedUsd,
|
|
164
|
+
savedUsd,
|
|
165
|
+
savedTokens: sum("savedTokens"),
|
|
166
|
+
billedTokens: sessions.reduce((n, s) => n + s.billedWeighted, 0),
|
|
167
|
+
savedPct,
|
|
168
|
+
worse: sessions.filter((s) => s.savedPct < -1e-9).length,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
const tokens = (n) => (n >= 1e9 ? `${(n / 1e9).toFixed(1)}B` : formatTokens(n));
|
|
172
|
+
const usd = (n) => (n >= 100 ? `$${Math.round(n).toLocaleString("en-US")}` : `$${n.toFixed(2)}`);
|
|
173
|
+
export function renderSavings(r, autopilotOn = false) {
|
|
174
|
+
const lines = [];
|
|
175
|
+
if (r.sessions.length === 0) {
|
|
176
|
+
lines.push(`No Claude Code sessions with 10+ requests in the last ${r.days} days to replay.`);
|
|
177
|
+
lines.push("Use Claude Code for a while, then run this again: `context-doctor savings`.");
|
|
178
|
+
return lines.join("\n");
|
|
179
|
+
}
|
|
180
|
+
const title = autopilotOn ? "What autopilot saves on your sessions" : "What autopilot would have saved you";
|
|
181
|
+
lines.push(`${title} (last ${r.days} days, ${r.sessions.length} Claude Code session${r.sessions.length === 1 ? "" : "s"})`);
|
|
182
|
+
lines.push("─".repeat(56));
|
|
183
|
+
lines.push(`Input you were billed for ${usd(r.billedUsd).padStart(10)} (from the usage your transcripts record)`);
|
|
184
|
+
lines.push(`Autopilot would have cut ${usd(r.savedUsd).padStart(10)} ${(r.savedPct * 100).toFixed(1)}%, ${tokens(r.savedTokens)} tokens not sent`);
|
|
185
|
+
lines.push(`Sessions made more expensive ${String(r.worse).padStart(10)}`);
|
|
186
|
+
const best = r.sessions[0];
|
|
187
|
+
if (best && best.savedUsd > 0)
|
|
188
|
+
lines.push(`Biggest win ${usd(best.savedUsd).padStart(10)} ${best.project} (${(best.savedPct * 100).toFixed(0)}% of that session)`);
|
|
189
|
+
lines.push("");
|
|
190
|
+
lines.push("Replayed request by request through the shipped autopilot code and priced as");
|
|
191
|
+
lines.push("the prompt cache bills. List prices; on a subscription the same tokens come");
|
|
192
|
+
lines.push("out of your usage limit instead.");
|
|
193
|
+
if (!autopilotOn) {
|
|
194
|
+
lines.push("");
|
|
195
|
+
lines.push("Turn it on for every new session: context-doctor autopilot on");
|
|
196
|
+
}
|
|
197
|
+
return lines.join("\n");
|
|
198
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "context-doctor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "Keep AI coding sessions lean automatically: clears stale tool output from every Claude Code and GPT API request, only when it costs nothing extra (measured 9.8% less input, no session worse). Token profiler, hooks and MCP tools for Claude Code, Cursor, Codex and Claude Desktop.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent",
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"files": [
|
|
47
47
|
"dist",
|
|
48
48
|
"!dist/test",
|
|
49
|
+
"!dist/mcp.bundle.js",
|
|
49
50
|
"skills",
|
|
50
51
|
"README.md",
|
|
51
52
|
"LICENSE"
|
|
@@ -54,11 +55,12 @@
|
|
|
54
55
|
"node": ">=20"
|
|
55
56
|
},
|
|
56
57
|
"scripts": {
|
|
57
|
-
"build": "tsc && node -e \"const fs=require('fs');['dist/cli.js','dist/mcp.js'].forEach(f=>fs.chmodSync(f,0o755))\"",
|
|
58
|
+
"build": "tsc && node scripts/bundle-mcp.mjs && node -e \"const fs=require('fs');['dist/cli.js','dist/mcp.js'].forEach(f=>fs.chmodSync(f,0o755))\"",
|
|
58
59
|
"prepublishOnly": "npm test",
|
|
59
60
|
"dev": "tsc --watch",
|
|
60
61
|
"test": "npm run build && node scripts/test.mjs",
|
|
61
|
-
"build:mcpb": "npm run build && node scripts/build-mcpb.mjs"
|
|
62
|
+
"build:mcpb": "npm run build && node scripts/build-mcpb.mjs",
|
|
63
|
+
"version": "node scripts/sync-versions.mjs"
|
|
62
64
|
},
|
|
63
65
|
"dependencies": {
|
|
64
66
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
@@ -66,10 +68,12 @@
|
|
|
66
68
|
},
|
|
67
69
|
"devDependencies": {
|
|
68
70
|
"@types/node": "^22.0.0",
|
|
71
|
+
"esbuild": "^0.25.12",
|
|
69
72
|
"typescript": "^5.5.0"
|
|
70
73
|
},
|
|
71
74
|
"homepage": "https://github.com/KushalP1/context-doctor#readme",
|
|
72
75
|
"bugs": {
|
|
73
76
|
"url": "https://github.com/KushalP1/context-doctor/issues"
|
|
74
|
-
}
|
|
77
|
+
},
|
|
78
|
+
"mcpName": "io.github.KushalP1/context-doctor"
|
|
75
79
|
}
|