context-doctor 0.23.1 → 0.25.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 +27 -9
- package/dist/autopilot.js +2 -1
- package/dist/cli.js +40 -1
- package/dist/coldresume.d.ts +19 -0
- package/dist/coldresume.js +49 -0
- package/dist/compactwindow.d.ts +46 -0
- package/dist/compactwindow.js +222 -0
- package/dist/dashboard.d.ts +2 -0
- package/dist/dashboard.js +6 -2
- package/dist/doctor.js +1 -1
- package/dist/hook.js +11 -3
- package/dist/impact.js +24 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mcp.js +10 -10
- package/dist/proxy.d.ts +10 -1
- package/dist/proxy.js +26 -11
- package/dist/savings.d.ts +9 -1
- package/dist/savings.js +10 -1
- package/dist/session.js +52 -2
- package/dist/sketch.js +3 -1
- package/dist/tokens.js +2 -0
- package/dist/watch.js +18 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
Long agent sessions fill up with tool output nobody reads again, and a 500k-token session re-reads all of it on every message. The most expensive moment is coming back after lunch: the prompt cache has expired, so the next message re-sends everything at full price. `context-doctor` measures this on your own sessions and acts on it at the moments that pay.
|
|
8
8
|
|
|
9
9
|
- **Tells you when to `/compact`, at the moment it pays.** When you come back to a large session after the cache expired, the every-prompt hook gives the model the numbers and it offers `/compact` once. Replayed over 133 days of the author's Claude Code history: 401 such returns, and compacting then would have saved **$4,053 net at list price, about $914 a month**. It works in every Claude Code surface, including the desktop app. [What it saves →](#what-it-saves)
|
|
10
|
+
- **Compacts earlier, automatically, in every Claude Code surface.** Claude Code auto-compacts near the full window (measured: ~970k tokens on a 1M model), so long sessions re-read 400k-900k tokens on every message. `context-doctor compact-window` replays your history at smaller windows and, if you choose, sets Claude Code's own `autoCompactWindow`. On the author's last 30 days: **a 400k window would have cut input cost 55%** ($6,119 to $2,757), for 15 compactions a week instead of 5. A native setting, so it works in the desktop app too. [The trade-off →](#3-compacting-earlier-automatic)
|
|
10
11
|
- **Autopilot for terminal and IDE sessions and API apps:** a local proxy that clears stale tool output only when the prompt cache is cold, so it never costs more. **9.8% less input cost, no session made more expensive**, in the same replay. (The desktop app's Code tab sets its own API address, so autopilot cannot reach it; `doctor` tells you if that is your case.)
|
|
11
12
|
- **Counts Claude correctly.** Current Claude models pack 2.75 characters per token, not the 4 most tools assume, so estimates built on 4 undercount Claude by about 40%. The ratios were measured from the API's own counts; `context-doctor accuracy` re-checks them on yours.
|
|
12
13
|
- **Works where you work:** Claude Code (terminal, IDE, desktop app, plugin), Cursor, Codex, Claude Desktop chat, any Anthropic or OpenAI API app, VS Code, CI. macOS, Linux and Windows, Node 20+. Local, keyless, no telemetry, MIT.
|
|
@@ -21,7 +22,7 @@ Built and maintained by [gAI Ventures](https://gai.ventures).
|
|
|
21
22
|
npx context-doctor savings
|
|
22
23
|
```
|
|
23
24
|
|
|
24
|
-
<img src="https://raw.githubusercontent.com/KushalP1/context-doctor/main/assets/savings.svg" alt="npx context-doctor savings: input billed, what /compact at cold resumes and
|
|
25
|
+
<img src="https://raw.githubusercontent.com/KushalP1/context-doctor/main/assets/savings.svg" alt="npx context-doctor savings: input billed, what /compact at cold resumes, autopilot and an earlier auto-compact window would have saved" width="640">
|
|
25
26
|
|
|
26
27
|
That is the author's machine: every session there runs in the desktop app, so the first line is the one that applies. Yours is computed the same way, from your own transcripts: every session replayed request by request through the shipped code, against what you were actually billed. `context-doctor savings --share` prints a few lines with totals only (no project names or paths) if you want to post your number.
|
|
27
28
|
|
|
@@ -40,7 +41,7 @@ context-doctor autopilot on # every new Claude Code session keeps its contex
|
|
|
40
41
|
/plugin install context-doctor@context-doctor
|
|
41
42
|
```
|
|
42
43
|
|
|
43
|
-
The plugin brings the every-prompt check, the MCP tools, and `/context-doctor:savings`, `/context-doctor:checkup` and `/context-doctor:
|
|
44
|
+
The plugin brings the every-prompt check, the MCP tools, and `/context-doctor:savings`, `/context-doctor:checkup`, `/context-doctor:autopilot` and `/context-doctor:compact-window`. 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.
|
|
44
45
|
|
|
45
46
|
`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`.
|
|
46
47
|
|
|
@@ -84,14 +85,26 @@ Each return counts only the messages up to the next return, and the compaction r
|
|
|
84
85
|
|
|
85
86
|
The median session saves 1.9%, the best 37.7%: long sessions are where the money is (94% of input cost on that machine came from requests above 200k tokens). These sessions ran in the desktop app, which autopilot cannot reach, so for the author this is what the same sessions would have saved in a terminal; `context-doctor savings` separates the two for you.
|
|
86
87
|
|
|
88
|
+
**3. Compacting earlier** <a id="3-compacting-earlier-automatic"></a>(automatic, every Claude Code surface, desktop app included). The offer in 1 is advice, and on the author's machine it was followed 1 time in 33. Claude Code has a native setting that needs nobody to follow anything: `autoCompactWindow` in `~/.claude/settings.json` makes it compact as if the window were that size. `context-doctor compact-window` replays each session request by request at several sizes, priced as the cache bills (each simulated compaction pays its own request and a 20k-token summary at the output rate), and changes nothing until you pick one:
|
|
89
|
+
|
|
90
|
+
| Window | Input cost, last 30 days | Saved | Compactions a week |
|
|
91
|
+
|---|---|---|---|
|
|
92
|
+
| as now (~970k) | $6,119 | | 5.1 |
|
|
93
|
+
| 600k | $3,570 | $2,549 (42%) | 6.5 |
|
|
94
|
+
| **400k** | **$2,757** | **$3,362 (55%)** | **14.7** |
|
|
95
|
+
| 300k | $2,353 | $3,766 (62%) | 24.5 |
|
|
96
|
+
| 200k | $1,963 | $4,155 (68%) | 46.9 |
|
|
97
|
+
|
|
98
|
+
29 sessions. The replay's baseline came within 9% of what the transcripts say was billed, on the low side. The cost is real: each compaction replaces the session's history with a summary, so a smaller window trades detail for money. That is why the command shows the table first and sets a window only when you name one (`context-doctor compact-window 400k`; `off` undoes it, and a backup of settings.json is kept).
|
|
99
|
+
|
|
87
100
|
On a Claude subscription you do not pay list price; the same tokens come out of your usage limit instead. Anthropic does not publish how limits weight cached tokens, so read the dollars as the size of the effect, not as your bill.
|
|
88
101
|
|
|
89
102
|
## What happens on each platform
|
|
90
103
|
|
|
91
104
|
| Where you work | Automatic, every request | What you get on top |
|
|
92
105
|
|---|---|---|
|
|
93
|
-
| **Claude Code** in the terminal, VS Code or JetBrains | **Autopilot** clears stale tool output (cold cache only, never more expensive). **Hook** on every prompt: real context size, largest waste, and a `/compact` offer when you return to a large session after the cache expired |
|
|
94
|
-
| **Claude Code** in the desktop app's Code tab | **Hook**, as above, including the cold-resume `/compact` offer. Not autopilot: the app sets its own API address and ignores the one in settings |
|
|
106
|
+
| **Claude Code** in the terminal, VS Code or JetBrains | **Autopilot** clears stale tool output (cold cache only, never more expensive). **Hook** on every prompt: real context size, largest waste, and a `/compact` offer when you return to a large session after the cache expired. **`compact-window`**, if you set one |
|
|
107
|
+
| **Claude Code** in the desktop app's Code tab | **Hook**, as above, including the cold-resume `/compact` offer, shown to you as well as the model. **`compact-window`**, if you set one: Claude Code compacts earlier by itself. Not autopilot: the app sets its own API address and ignores the one in settings | Install via npm or as a **plugin** (`/plugin marketplace add KushalP1/context-doctor`). Status bar context meter, `/context-doctor:savings`, `session`, `watch`, `report`, dashboard |
|
|
95
108
|
| **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)) |
|
|
96
109
|
| **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`) |
|
|
97
110
|
| **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 |
|
|
@@ -103,7 +116,9 @@ Not claimed, because no process on your machine sends those requests: trimming i
|
|
|
103
116
|
|
|
104
117
|
## What's new
|
|
105
118
|
|
|
119
|
+
- **0.25 Compact earlier, by itself**: the `/compact` offer at cold resumes was followed 1 time in 33 on the author's machine, so two changes. The hook now also shows you the notice (it went only to the model, which rarely raised it), with the dollar cost of the message you just sent. And `context-doctor compact-window` measures and sets Claude Code's own auto-compact window, which works without anyone acting on advice, in every surface including the desktop app: 55% less input cost at 400k on the author's last 30 days. `savings` shows it as a third lever.
|
|
106
120
|
- **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.
|
|
121
|
+
- **0.24 A full audit, fixed**: the session parser now rebuilds API messages from Claude Code's one-row-per-block transcripts (it had split each reply into several messages, breaking tool pairing in exported conversations and inflating profiles); the proxy forwards every non-conversation endpoint and never re-encodes bodies it does not rewrite (model listing and file uploads failed or were corrupted through it); `report` and the dashboard no longer count compaction as tokens context-doctor saved, and MCP sketches no longer inflate them; `watch` follows plain conversation files; MCP inputs are bounded. 0.23: four dependency vulnerabilities patched, no false `/compact` alarm after compacting, `savings --share`.
|
|
107
122
|
- **0.22 Honest about where each lever works, and a new one that works everywhere**: the every-prompt hook now offers `/compact` when you return to a large session after the cache expired (measured: ~$914 a month on the author's history), including in the desktop app. Found and fixed: autopilot cannot reach the desktop app's Code tab, and `doctor` now says so instead of reporting ✓; `savings` counted whole-file history as "last 30 days"; the hook quoted per-message cost at the uncached rate (10x too high). `--version` added.
|
|
108
123
|
- **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`.
|
|
109
124
|
- **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.
|
|
@@ -169,7 +184,8 @@ Practical upshot: a developer who only wants cheaper, faster API calls never tou
|
|
|
169
184
|
| Command | What it does |
|
|
170
185
|
|---|---|
|
|
171
186
|
| `context-doctor install` / `uninstall` | Wire (or remove) everything: MCP for Claude Desktop/Code/Cursor/Codex, the Agent Skill, the every-prompt hook |
|
|
172
|
-
| `context-doctor savings [--days n]` | What
|
|
187
|
+
| `context-doctor savings [--days n] [--share [--copy]]` | What your recent Claude Code sessions cost, and what `/compact` at cold resumes, autopilot and an earlier auto-compact window would save, replayed through the shipped code against your actual billed usage. `--share` prints totals only (no project names or paths) to post. A bare `context-doctor` in a terminal shows the same |
|
|
188
|
+
| `context-doctor compact-window [status\|off\|<size>]` | Replay your last 30 days at auto-compact windows from 200k to 800k: input cost, saving, and how many more compactions each means. `400k` (or `250000`, `0.4m`) sets Claude Code's `autoCompactWindow`, with a backup; `off` removes it. Works in every Claude Code surface, the desktop app included |
|
|
173
189
|
| `context-doctor mcp [--http]` | The MCP server as a subcommand, for clients and registries that launch `npx -y context-doctor mcp` |
|
|
174
190
|
| `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 |
|
|
175
191
|
| `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 |
|
|
@@ -181,9 +197,9 @@ Practical upshot: a developer who only wants cheaper, faster API calls never tou
|
|
|
181
197
|
| `context-doctor diff <before> <after>` | Compare two profiles: what moved by category, which findings were resolved or introduced, and what it saves in money and latency |
|
|
182
198
|
| `context-doctor accuracy` | How much of what you are billed for is visible in your transcript (the fixed harness baseline, per-turn injected content), plus a tokenizer check: real chars/token per model from the API's own counts, next to the ratio the estimator uses |
|
|
183
199
|
| `context-doctor cursor [--list]` | Profile a chat from Cursor's local history (both storage formats) |
|
|
184
|
-
| `context-doctor report` | Machine-wide impact report (proxy savings persist across restarts): exact proxy savings, hook activity, recoverable waste in recent sessions |
|
|
200
|
+
| `context-doctor report` | Machine-wide impact report (proxy savings persist across restarts): exact proxy savings, hook activity, how often the cold-resume `/compact` offer was followed by a compaction, recoverable waste in recent sessions |
|
|
185
201
|
| `context-doctor proxy` | Always-on local proxy that optimizes every Anthropic/OpenAI API request in flight (`/stats` for cumulative savings) |
|
|
186
|
-
| `context-doctor watch [file]` | Live monitor of a growing session
|
|
202
|
+
| `context-doctor watch [file]` | Live monitor of a growing session transcript or agent trace (Claude Code / Codex JSONL, or a plain OpenAI/Anthropic conversation JSON file): token/cost line per change, findings as they appear |
|
|
187
203
|
| `context-doctor doctor` | Self-check the whole installation — one pasteable ✓/✗ diagnosis with fixes |
|
|
188
204
|
| `context-doctor dashboard` | Local savings dashboard on 127.0.0.1: tokens saved per day, sessions by context in use vs recoverable, budget status |
|
|
189
205
|
| `context-doctor statusline` | Claude Code status bar: live context vs window, cache share, cost. Wired by `install --statusline`; never overwrites a statusLine you already have |
|
|
@@ -240,7 +256,7 @@ context-doctor autopilot pause # instant passthrough, nothing restarts
|
|
|
240
256
|
context-doctor autopilot off # remove it
|
|
241
257
|
```
|
|
242
258
|
|
|
243
|
-
`autopilot on` runs the local proxy as a background service (launchd on macOS, a systemd user service on Linux, a logon task on Windows), waits until it answers, and only then points Claude Code at it through `env.ANTHROPIC_BASE_URL` in `~/.claude/settings.json`. Every Claude Code session started afterwards in a terminal or an IDE sends its requests through it. **Not the desktop app's Code tab:** it runs Claude Code "host-orchestrated", sets `ANTHROPIC_BASE_URL` from its own account configuration, and drops the same key from settings files (checked in Claude Code 2.1.284). `autopilot on`, `autopilot status` and `doctor` look at which surface your recent sessions ran in and warn when autopilot cannot reach them; for desktop sessions the cold-resume advice
|
|
259
|
+
`autopilot on` runs the local proxy as a background service (launchd on macOS, a systemd user service on Linux, a logon task on Windows), waits until it answers, and only then points Claude Code at it through `env.ANTHROPIC_BASE_URL` in `~/.claude/settings.json`. Every Claude Code session started afterwards in a terminal or an IDE sends its requests through it. **Not the desktop app's Code tab:** it runs Claude Code "host-orchestrated", sets `ANTHROPIC_BASE_URL` from its own account configuration, and drops the same key from settings files (checked in Claude Code 2.1.284). `autopilot on`, `autopilot status` and `doctor` look at which surface your recent sessions ran in and warn when autopilot cannot reach them; for desktop sessions the levers that work are the cold-resume advice and `compact-window`. Your login (subscription or API key) passes through untouched.
|
|
244
260
|
|
|
245
261
|
**What it does to each request:** once old tool output adds up to 20k+ tokens (file reads, shell output, search and web results, screenshots inside them), it replaces that output with a one-line note, keeping the 3 most recent results. The tool call stays in the history, so the model can simply run it again if it needs the output. Your messages, its answers, answers you gave to its questions, subagent reports and MCP results are never touched.
|
|
246
262
|
|
|
@@ -281,6 +297,8 @@ The proxy dedupes repeated content, trims stale tool results, and strips base64
|
|
|
281
297
|
[context-doctor] POST /v1/messages → 200 in 842ms | optimized 7.3k → 518 tokens (2 changes) | session total: 6.9k tokens ≈ $0.021 saved
|
|
282
298
|
```
|
|
283
299
|
|
|
300
|
+
Only conversation requests (`/v1/messages`, `/v1/chat/completions`, `/v1/responses`) are ever rewritten. Every other endpoint a client uses through the proxy (listing models, uploading files, counting tokens) is forwarded untouched, to Anthropic when the request carries `anthropic-version` or `x-api-key` and to OpenAI otherwise, and request bodies the proxy does not rewrite reach upstream byte for byte.
|
|
301
|
+
|
|
284
302
|
`GET http://localhost:8787/stats` returns cumulative savings (requests, tokens, estimated USD), **exact upstream usage** read from every response (JSON and SSE), and **prompt-cache advisories** — the proxy watches your real traffic and flags big stable prefixes missing `cache_control` or prefix churn that silently re-bills the cache. Per-model behavior via `--config`: The advisor now says *where*: for a large system/tools prefix it names the block to mark (the last tool definition, or the last system block), and when the older messages were byte-identical to the previous request it names the exact message to put `cache_control` on, with the token count that run is re-billing each turn. Anyone who already placed breakpoints is left alone.
|
|
285
303
|
|
|
286
304
|
```json
|
|
@@ -521,7 +539,7 @@ One report for your whole machine, led by a headline of **tokens context-doctor
|
|
|
521
539
|
|
|
522
540
|
- **exact** proxy savings (real before/after on every request),
|
|
523
541
|
- **exact** savings from every optimization applied via the CLI or the in-chat tools — split by model family (Claude vs GPT), with dollar estimates,
|
|
524
|
-
- **
|
|
542
|
+
- and, **on its own line, not in the total**: sessions that got smaller after a hygiene warning. That is mostly compaction, by you or by Claude Code, and a warning may or may not be why it happened, so it is not credited to context-doctor (until 0.24 it was, which overclaimed). The dashboard keeps the same split.
|
|
525
543
|
|
|
526
544
|
Honest measurement note: proxy numbers are exact. Session numbers are measured-now. What no tool can report is the counterfactual — tokens Claude *avoided* adding because of the hygiene guidance — since the same session can't be re-run without it. The report says so instead of inventing a number.
|
|
527
545
|
|
package/dist/autopilot.js
CHANGED
|
@@ -253,7 +253,8 @@ export function reachNote(days = 30) {
|
|
|
253
253
|
`! ${desktop} of your ${desktop + other} Claude Code sessions in the last ${days} days (with a recorded surface) ran in the desktop app. The desktop app`,
|
|
254
254
|
" sets its own API address and ignores the one in settings.json, so autopilot cannot reach those sessions.",
|
|
255
255
|
" It applies to Claude Code in a terminal (`claude`) and in IDEs. For desktop sessions, the every-prompt",
|
|
256
|
-
" hook points out when /compact would pay off
|
|
256
|
+
" hook points out when /compact would pay off, and `context-doctor compact-window` makes Claude Code",
|
|
257
|
+
" compact earlier by itself, which the desktop app honours (see `context-doctor savings`).",
|
|
257
258
|
];
|
|
258
259
|
if (other === 0)
|
|
259
260
|
lines.push(" Every recent session of yours is a desktop one: autopilot will not change anything until you use the terminal or an IDE.");
|
package/dist/cli.js
CHANGED
|
@@ -23,6 +23,7 @@ import { buildImpactReport } from "./impact.js";
|
|
|
23
23
|
import { measureTokenizer, renderTokenizer } from "./tokenizer-measure.js";
|
|
24
24
|
import { autopilotOff, autopilotOn, autopilotPause, autopilotPaths, autopilotStatus, DEFAULT_AUTOPILOT_PORT } from "./autopilot.js";
|
|
25
25
|
import { estimateSavings, renderSavings, renderShare } from "./savings.js";
|
|
26
|
+
import { currentCompactWindow, estimateCompactWindows, MAX_WINDOW, MIN_WINDOW, parseWindow, renderCompactWindows, setCompactWindow } from "./compactwindow.js";
|
|
26
27
|
import { renderPreferences, copyToClipboard, CHAT_PREFERENCES } from "./preferences.js";
|
|
27
28
|
import { recordLedger } from "./ledger.js";
|
|
28
29
|
import { runDoctor } from "./doctor.js";
|
|
@@ -56,6 +57,10 @@ Usage:
|
|
|
56
57
|
/compact at cold resumes and autopilot would save,
|
|
57
58
|
replayed and priced as billed. --share: totals only,
|
|
58
59
|
to paste. Also what a bare \`context-doctor\` shows
|
|
60
|
+
context-doctor compact-window [status|off|<size>]
|
|
61
|
+
Claude Code's own auto-compact window (e.g. 400k):
|
|
62
|
+
what each size would have saved on your sessions,
|
|
63
|
+
and set or remove it. Works in the desktop app too
|
|
59
64
|
context-doctor autopilot on|off|pause|resume|status
|
|
60
65
|
Every new Claude Code session goes through the
|
|
61
66
|
local proxy, which clears stale tool output only
|
|
@@ -521,6 +526,12 @@ async function main() {
|
|
|
521
526
|
const report = estimateSavings(args.days ?? 30, {}, undefined, progress);
|
|
522
527
|
if (progress)
|
|
523
528
|
process.stderr.write("\r\x1b[K");
|
|
529
|
+
const setWindow = currentCompactWindow();
|
|
530
|
+
const cwReport = estimateCompactWindows([setWindow ?? 400_000], args.days ?? 30);
|
|
531
|
+
const est = cwReport.estimates[0];
|
|
532
|
+
const cwLine = cwReport.sessions > 0 && est
|
|
533
|
+
? { window: est.window, savedUsd: est.savedUsd, compactionsPerWeek: (est.compactions / cwReport.days) * 7, nowPerWeek: (cwReport.actualCompactions / cwReport.days) * 7, isCurrent: setWindow !== undefined }
|
|
534
|
+
: undefined;
|
|
524
535
|
const on = existsSync(autopilotPaths().config);
|
|
525
536
|
if (args.json)
|
|
526
537
|
console.log(JSON.stringify(report, null, 2));
|
|
@@ -531,7 +542,7 @@ async function main() {
|
|
|
531
542
|
console.log(copyToClipboard(text) ? "\n(copied to the clipboard)" : "\n(no clipboard tool found; copy the lines above)");
|
|
532
543
|
}
|
|
533
544
|
else {
|
|
534
|
-
console.log(renderSavings(report, on));
|
|
545
|
+
console.log(renderSavings(report, on, cwLine));
|
|
535
546
|
if (!args.command)
|
|
536
547
|
console.log("\nAll commands: context-doctor --help");
|
|
537
548
|
}
|
|
@@ -546,6 +557,34 @@ async function main() {
|
|
|
546
557
|
await import("./mcp.js");
|
|
547
558
|
return;
|
|
548
559
|
}
|
|
560
|
+
if (args.command === "compact-window") {
|
|
561
|
+
const sub = args.file ?? "status";
|
|
562
|
+
if (sub === "off") {
|
|
563
|
+
setCompactWindow(undefined);
|
|
564
|
+
console.log("✓ autoCompactWindow removed from ~/.claude/settings.json: Claude Code compacts near the model's full window again (new sessions).");
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
if (sub !== "status") {
|
|
568
|
+
const w = parseWindow(sub);
|
|
569
|
+
if (w === undefined || w < MIN_WINDOW || w > MAX_WINDOW) {
|
|
570
|
+
console.error(`Usage: context-doctor compact-window [status | off | <size>], size between 100k and 1m (e.g. 400k); got "${sub}".`);
|
|
571
|
+
process.exitCode = 1;
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
setCompactWindow(w);
|
|
575
|
+
console.log(`✓ autoCompactWindow = ${w} in ~/.claude/settings.json (a .backup was kept).`);
|
|
576
|
+
console.log(" Claude Code sessions started from now on compact as if the window were that size,");
|
|
577
|
+
console.log(" in the terminal, IDEs and the desktop app. Check in a session with /context.");
|
|
578
|
+
console.log(" Undo: context-doctor compact-window off");
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
const current = currentCompactWindow();
|
|
582
|
+
const windows = [200_000, 300_000, 400_000, 600_000, 800_000];
|
|
583
|
+
if (current && !windows.includes(current))
|
|
584
|
+
windows.push(current);
|
|
585
|
+
console.log(renderCompactWindows(estimateCompactWindows(windows.sort((a, b) => a - b), args.days ?? 30), current));
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
549
588
|
if (args.command === "autopilot") {
|
|
550
589
|
const sub = args.file ?? "status";
|
|
551
590
|
const port = args.port ?? DEFAULT_AUTOPILOT_PORT;
|
package/dist/coldresume.d.ts
CHANGED
|
@@ -41,6 +41,12 @@ export interface ColdResume {
|
|
|
41
41
|
* Reads only the transcript's tail. Undefined when not (or when unreadable).
|
|
42
42
|
*/
|
|
43
43
|
export declare function detectColdResume(path: string, now?: number, idleMs?: number, minTokens?: number): ColdResume | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* The one line shown to the USER in the app (hook `systemMessage`). In the
|
|
46
|
+
* first week the offer lived only inside the model's reply and was acted on
|
|
47
|
+
* 1 time in 33; a notice of its own is harder to miss.
|
|
48
|
+
*/
|
|
49
|
+
export declare function renderColdResumeNotice(c: ColdResume): string;
|
|
44
50
|
/** The note the hook hands the model: the numbers, and one sentence to say. */
|
|
45
51
|
export declare function renderColdResume(c: ColdResume): string;
|
|
46
52
|
export interface ColdResumeEvent {
|
|
@@ -57,3 +63,16 @@ export interface ColdResumeEvent {
|
|
|
57
63
|
* next one (no double counting), and the compaction request is a cost.
|
|
58
64
|
*/
|
|
59
65
|
export declare function coldResumeEvents(path: string, idleMs?: number, minTokens?: number): ColdResumeEvent[];
|
|
66
|
+
/**
|
|
67
|
+
* How often the cold-resume offer was acted on: offers (ledger events) whose
|
|
68
|
+
* session has a compaction within an hour after. `sessionPaths` maps the
|
|
69
|
+
* ledger's 12-char session id to its transcript. Only lines that mention a
|
|
70
|
+
* compact boundary are parsed, so even very large transcripts scan quickly.
|
|
71
|
+
*/
|
|
72
|
+
export declare function coldResumeFollowThrough(offers: {
|
|
73
|
+
ts: number;
|
|
74
|
+
sid?: string;
|
|
75
|
+
}[], sessionPaths: Map<string, string>): {
|
|
76
|
+
offers: number;
|
|
77
|
+
compacted: number;
|
|
78
|
+
};
|
package/dist/coldresume.js
CHANGED
|
@@ -104,6 +104,16 @@ function duration(ms) {
|
|
|
104
104
|
const h = ms / 3_600_000;
|
|
105
105
|
return h < 48 ? `${h.toFixed(h < 10 ? 1 : 0)} hours` : `${Math.round(h / 24)} days`;
|
|
106
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* The one line shown to the USER in the app (hook `systemMessage`). In the
|
|
109
|
+
* first week the offer lived only inside the model's reply and was acted on
|
|
110
|
+
* 1 time in 33; a notice of its own is harder to miss.
|
|
111
|
+
*/
|
|
112
|
+
export function renderColdResumeNotice(c) {
|
|
113
|
+
const cheaper = Math.max(0, Math.round((1 - AFTER_COMPACT_TOKENS / c.tokens) * 100));
|
|
114
|
+
const money = c.resumeUsd !== undefined ? ` (~${formatUsd(c.resumeUsd)} at list price)` : "";
|
|
115
|
+
return `context-doctor: idle ${duration(c.idleMs)}, so this message re-sent the whole ~${formatTokens(c.tokens)}-token context${money}. If you keep working here, /compact now makes each later message ~${cheaper}% cheaper.`;
|
|
116
|
+
}
|
|
107
117
|
/** The note the hook hands the model: the numbers, and one sentence to say. */
|
|
108
118
|
export function renderColdResume(c) {
|
|
109
119
|
const cheaper = Math.max(0, Math.round((1 - AFTER_COMPACT_TOKENS / c.tokens) * 100));
|
|
@@ -178,3 +188,42 @@ export function coldResumeEvents(path, idleMs = COLD_IDLE_MS, minTokens = COLD_M
|
|
|
178
188
|
}
|
|
179
189
|
return events;
|
|
180
190
|
}
|
|
191
|
+
/** A /compact within this long of the offer counts as acting on it. */
|
|
192
|
+
const FOLLOW_MS = 60 * 60_000;
|
|
193
|
+
/**
|
|
194
|
+
* How often the cold-resume offer was acted on: offers (ledger events) whose
|
|
195
|
+
* session has a compaction within an hour after. `sessionPaths` maps the
|
|
196
|
+
* ledger's 12-char session id to its transcript. Only lines that mention a
|
|
197
|
+
* compact boundary are parsed, so even very large transcripts scan quickly.
|
|
198
|
+
*/
|
|
199
|
+
export function coldResumeFollowThrough(offers, sessionPaths) {
|
|
200
|
+
const compactsBySid = new Map();
|
|
201
|
+
let compacted = 0;
|
|
202
|
+
for (const o of offers) {
|
|
203
|
+
const path = o.sid ? sessionPaths.get(o.sid) : undefined;
|
|
204
|
+
if (!path)
|
|
205
|
+
continue;
|
|
206
|
+
let times = compactsBySid.get(o.sid);
|
|
207
|
+
if (!times) {
|
|
208
|
+
const found = [];
|
|
209
|
+
try {
|
|
210
|
+
forEachLine(path, (line) => {
|
|
211
|
+
if (!line.includes("compact_boundary"))
|
|
212
|
+
return;
|
|
213
|
+
try {
|
|
214
|
+
const e = JSON.parse(line);
|
|
215
|
+
const at = Date.parse(e.timestamp);
|
|
216
|
+
if (e.subtype === "compact_boundary" && Number.isFinite(at))
|
|
217
|
+
found.push(at);
|
|
218
|
+
}
|
|
219
|
+
catch { /* not a full JSON row */ }
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
catch { /* unreadable transcript: no compactions known */ }
|
|
223
|
+
compactsBySid.set(o.sid, (times = found));
|
|
224
|
+
}
|
|
225
|
+
if (times.some((t) => t >= o.ts && t - o.ts <= FOLLOW_MS))
|
|
226
|
+
compacted++;
|
|
227
|
+
}
|
|
228
|
+
return { offers: offers.length, compacted };
|
|
229
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `context-doctor compact-window`: compact earlier, automatically, in every
|
|
3
|
+
* Claude Code surface, including the desktop app.
|
|
4
|
+
*
|
|
5
|
+
* Claude Code auto-compacts near the model's window. With a 1M-token window
|
|
6
|
+
* that is ~970k (measured: 31 compactions on the author's machine, median
|
|
7
|
+
* preTokens 970,119), so long sessions spend most of their life re-reading
|
|
8
|
+
* 400k-900k tokens on every message. Claude Code has a native setting for
|
|
9
|
+
* this, `autoCompactWindow` in ~/.claude/settings.json ("Auto-compact window
|
|
10
|
+
* size"): it compacts as if the window were that size. It is a settings key,
|
|
11
|
+
* not an environment variable, so the desktop app (which filters settings env
|
|
12
|
+
* it sets itself) honours it, and no proxy or hook is involved.
|
|
13
|
+
*
|
|
14
|
+
* The trade-off is real: more compactions, each replacing history with a
|
|
15
|
+
* summary. So this measures both sides on the user's own history and changes
|
|
16
|
+
* nothing until asked. The replay uses the usage every transcript records;
|
|
17
|
+
* its baseline came within 9% of the billed figure on the author's history
|
|
18
|
+
* (on the low side, so savings are, if anything, understated).
|
|
19
|
+
*/
|
|
20
|
+
export declare const MIN_WINDOW = 100000;
|
|
21
|
+
export declare const MAX_WINDOW = 1000000;
|
|
22
|
+
export interface WindowEstimate {
|
|
23
|
+
window: number;
|
|
24
|
+
baselineUsd: number;
|
|
25
|
+
withWindowUsd: number;
|
|
26
|
+
savedUsd: number;
|
|
27
|
+
savedPct: number;
|
|
28
|
+
/** Compactions there would be over the period with this window. */
|
|
29
|
+
compactions: number;
|
|
30
|
+
}
|
|
31
|
+
export interface CompactWindowReport {
|
|
32
|
+
days: number;
|
|
33
|
+
sessions: number;
|
|
34
|
+
/** Compactions that actually happened over the period (Claude Code's own, or /compact). */
|
|
35
|
+
actualCompactions: number;
|
|
36
|
+
estimates: WindowEstimate[];
|
|
37
|
+
}
|
|
38
|
+
export declare function estimateCompactWindows(windows: number[], days?: number, paths?: string[]): CompactWindowReport;
|
|
39
|
+
export declare function settingsPath(home?: string): string;
|
|
40
|
+
/** The autoCompactWindow Claude Code will use from user settings, if set. */
|
|
41
|
+
export declare function currentCompactWindow(home?: string): number | undefined;
|
|
42
|
+
/** Set (or with undefined, remove) autoCompactWindow in ~/.claude/settings.json. A .backup is kept. */
|
|
43
|
+
export declare function setCompactWindow(window: number | undefined, home?: string): void;
|
|
44
|
+
/** "400k" / "400000" / "0.4m" -> 400000. Undefined when unparseable. */
|
|
45
|
+
export declare function parseWindow(raw: string): number | undefined;
|
|
46
|
+
export declare function renderCompactWindows(r: CompactWindowReport, current?: number): string;
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `context-doctor compact-window`: compact earlier, automatically, in every
|
|
3
|
+
* Claude Code surface, including the desktop app.
|
|
4
|
+
*
|
|
5
|
+
* Claude Code auto-compacts near the model's window. With a 1M-token window
|
|
6
|
+
* that is ~970k (measured: 31 compactions on the author's machine, median
|
|
7
|
+
* preTokens 970,119), so long sessions spend most of their life re-reading
|
|
8
|
+
* 400k-900k tokens on every message. Claude Code has a native setting for
|
|
9
|
+
* this, `autoCompactWindow` in ~/.claude/settings.json ("Auto-compact window
|
|
10
|
+
* size"): it compacts as if the window were that size. It is a settings key,
|
|
11
|
+
* not an environment variable, so the desktop app (which filters settings env
|
|
12
|
+
* it sets itself) honours it, and no proxy or hook is involved.
|
|
13
|
+
*
|
|
14
|
+
* The trade-off is real: more compactions, each replacing history with a
|
|
15
|
+
* summary. So this measures both sides on the user's own history and changes
|
|
16
|
+
* nothing until asked. The replay uses the usage every transcript records;
|
|
17
|
+
* its baseline came within 9% of the billed figure on the author's history
|
|
18
|
+
* (on the low side, so savings are, if anything, understated).
|
|
19
|
+
*/
|
|
20
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { forEachLine, listSessions } from "./session.js";
|
|
24
|
+
import { pricingFor } from "./pricing.js";
|
|
25
|
+
import { readJson, writeJsonWithBackup } from "./install.js";
|
|
26
|
+
/** Claude Code compacts at about this share of the window (970k of 1M, measured). */
|
|
27
|
+
const TRIGGER_SHARE = 0.97;
|
|
28
|
+
/** Context right after a compaction: system prompt, tools and the summary (median 75,447, measured). */
|
|
29
|
+
const AFTER_COMPACT = 75_000;
|
|
30
|
+
/** Output tokens a compaction summary costs, priced at output's 5x input rate. */
|
|
31
|
+
const SUMMARY_OUTPUT = 20_000;
|
|
32
|
+
const OUTPUT_RATIO = 5;
|
|
33
|
+
const TTL_MS = 3_600_000;
|
|
34
|
+
export const MIN_WINDOW = 100_000;
|
|
35
|
+
export const MAX_WINDOW = 1_000_000;
|
|
36
|
+
function readRequests(path) {
|
|
37
|
+
const reqs = [];
|
|
38
|
+
let lastId, model;
|
|
39
|
+
forEachLine(path, (line) => {
|
|
40
|
+
let e;
|
|
41
|
+
try {
|
|
42
|
+
e = JSON.parse(line);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (!e || e.isSidechain)
|
|
48
|
+
return;
|
|
49
|
+
if (e.type === "system" && e.subtype === "compact_boundary") {
|
|
50
|
+
reqs.push("compact");
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (e.type !== "assistant" || !e.message || e.message.id === lastId)
|
|
54
|
+
return;
|
|
55
|
+
lastId = e.message.id;
|
|
56
|
+
const u = e.message.usage ?? {};
|
|
57
|
+
const prompt = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
|
|
58
|
+
const at = Date.parse(e.timestamp);
|
|
59
|
+
if (prompt > 0 && Number.isFinite(at)) {
|
|
60
|
+
reqs.push({ at, prompt });
|
|
61
|
+
model ??= e.message.model;
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
return { model, reqs };
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Replay one session twice: as it ran, and with compaction whenever the
|
|
68
|
+
* context reaches the window's trigger. Both arms are priced as the prompt
|
|
69
|
+
* cache bills (warm: 0.1x re-read + 1.25x for what is new; cold after an hour
|
|
70
|
+
* idle: 1.25x for everything); each simulated compaction also pays its own
|
|
71
|
+
* request and summary.
|
|
72
|
+
*/
|
|
73
|
+
function replay(reqs, window, since) {
|
|
74
|
+
let base = 0, sim = 0, extra = 0, actual = 0;
|
|
75
|
+
let removed = 0, prevActual, prevSim, prevAt;
|
|
76
|
+
for (const r of reqs) {
|
|
77
|
+
if (r === "compact") {
|
|
78
|
+
if (prevAt !== undefined && prevAt >= since)
|
|
79
|
+
actual++;
|
|
80
|
+
// A real compaction in the transcript: the simulated arm compacts there too.
|
|
81
|
+
if (prevAt !== undefined && prevAt >= since && removed === 0)
|
|
82
|
+
extra++;
|
|
83
|
+
removed = 0;
|
|
84
|
+
prevActual = prevSim = undefined;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const counted = r.at >= since;
|
|
88
|
+
const cold = prevAt === undefined || r.at - prevAt > TTL_MS;
|
|
89
|
+
const cost = (ctx, prev) => {
|
|
90
|
+
if (cold)
|
|
91
|
+
return 1.25 * ctx;
|
|
92
|
+
const fresh = prev === undefined || ctx < prev ? ctx : ctx - prev;
|
|
93
|
+
return 0.1 * (ctx - fresh) + 1.25 * fresh;
|
|
94
|
+
};
|
|
95
|
+
if (counted)
|
|
96
|
+
base += cost(r.prompt, prevActual);
|
|
97
|
+
let ctx = r.prompt - removed;
|
|
98
|
+
// The real context fell below what we had removed (a rewind with no
|
|
99
|
+
// compaction marker): the two arms are back in step.
|
|
100
|
+
if (ctx < AFTER_COMPACT && removed > 0) {
|
|
101
|
+
removed = 0;
|
|
102
|
+
ctx = r.prompt;
|
|
103
|
+
}
|
|
104
|
+
let simPrev = prevSim;
|
|
105
|
+
if (ctx >= TRIGGER_SHARE * window) {
|
|
106
|
+
if (counted) {
|
|
107
|
+
sim += 0.1 * ctx + OUTPUT_RATIO * SUMMARY_OUTPUT;
|
|
108
|
+
extra++;
|
|
109
|
+
}
|
|
110
|
+
removed = r.prompt - AFTER_COMPACT;
|
|
111
|
+
ctx = AFTER_COMPACT;
|
|
112
|
+
simPrev = undefined; // everything after a compaction is new to the cache
|
|
113
|
+
}
|
|
114
|
+
if (counted)
|
|
115
|
+
sim += cost(ctx, simPrev);
|
|
116
|
+
prevActual = r.prompt;
|
|
117
|
+
prevSim = ctx;
|
|
118
|
+
prevAt = r.at;
|
|
119
|
+
}
|
|
120
|
+
return { base, sim, extra, actual };
|
|
121
|
+
}
|
|
122
|
+
export function estimateCompactWindows(windows, days = 30, paths) {
|
|
123
|
+
const since = Date.now() - days * 86_400_000;
|
|
124
|
+
const targets = paths ?? listSessions(10_000).filter((s) => s.path.includes(".claude") && s.modifiedAt.getTime() >= since).map((s) => s.path);
|
|
125
|
+
const acc = windows.map((w) => ({ window: w, base: 0, sim: 0, extra: 0 }));
|
|
126
|
+
let sessions = 0, actualCompactions = 0;
|
|
127
|
+
for (const p of targets) {
|
|
128
|
+
let parsed;
|
|
129
|
+
try {
|
|
130
|
+
parsed = readRequests(p);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const perM = pricingFor(parsed.model)?.inputPerM;
|
|
136
|
+
if (!perM || parsed.reqs.filter((r) => r !== "compact" && r.at >= since).length < 10)
|
|
137
|
+
continue;
|
|
138
|
+
sessions++;
|
|
139
|
+
actualCompactions += replay(parsed.reqs, Number.MAX_SAFE_INTEGER, since).actual;
|
|
140
|
+
for (const a of acc) {
|
|
141
|
+
const r = replay(parsed.reqs, a.window, since);
|
|
142
|
+
a.base += (r.base * perM) / 1e6;
|
|
143
|
+
a.sim += (r.sim * perM) / 1e6;
|
|
144
|
+
a.extra += r.extra;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
days,
|
|
149
|
+
sessions,
|
|
150
|
+
actualCompactions,
|
|
151
|
+
estimates: acc.map((a) => ({
|
|
152
|
+
window: a.window,
|
|
153
|
+
baselineUsd: a.base,
|
|
154
|
+
withWindowUsd: a.sim,
|
|
155
|
+
savedUsd: a.base - a.sim,
|
|
156
|
+
savedPct: a.base > 0 ? (a.base - a.sim) / a.base : 0,
|
|
157
|
+
compactions: a.extra,
|
|
158
|
+
})),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
export function settingsPath(home = homedir()) {
|
|
162
|
+
return join(home, ".claude", "settings.json");
|
|
163
|
+
}
|
|
164
|
+
/** The autoCompactWindow Claude Code will use from user settings, if set. */
|
|
165
|
+
export function currentCompactWindow(home = homedir()) {
|
|
166
|
+
const p = settingsPath(home);
|
|
167
|
+
if (!existsSync(p))
|
|
168
|
+
return undefined;
|
|
169
|
+
try {
|
|
170
|
+
const v = JSON.parse(readFileSync(p, "utf8"))?.autoCompactWindow;
|
|
171
|
+
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/** Set (or with undefined, remove) autoCompactWindow in ~/.claude/settings.json. A .backup is kept. */
|
|
178
|
+
export function setCompactWindow(window, home = homedir()) {
|
|
179
|
+
if (window !== undefined && (!Number.isInteger(window) || window < MIN_WINDOW || window > MAX_WINDOW)) {
|
|
180
|
+
throw new Error(`window must be a whole number of tokens between ${MIN_WINDOW} and ${MAX_WINDOW}`);
|
|
181
|
+
}
|
|
182
|
+
const p = settingsPath(home);
|
|
183
|
+
const settings = readJson(p);
|
|
184
|
+
if (window === undefined)
|
|
185
|
+
delete settings.autoCompactWindow;
|
|
186
|
+
else
|
|
187
|
+
settings.autoCompactWindow = window;
|
|
188
|
+
writeJsonWithBackup(p, settings);
|
|
189
|
+
}
|
|
190
|
+
/** "400k" / "400000" / "0.4m" -> 400000. Undefined when unparseable. */
|
|
191
|
+
export function parseWindow(raw) {
|
|
192
|
+
const m = /^\s*(\d+(?:\.\d+)?)\s*([km]?)\s*$/i.exec(raw);
|
|
193
|
+
if (!m)
|
|
194
|
+
return undefined;
|
|
195
|
+
const n = Number(m[1]) * (m[2].toLowerCase() === "m" ? 1e6 : m[2].toLowerCase() === "k" ? 1e3 : 1);
|
|
196
|
+
return Math.round(n);
|
|
197
|
+
}
|
|
198
|
+
const usd = (n) => (Math.abs(n) >= 100 ? `$${Math.round(n).toLocaleString("en-US")}` : `$${n.toFixed(2)}`);
|
|
199
|
+
const k = (n) => `${Math.round(n / 1000)}k`;
|
|
200
|
+
export function renderCompactWindows(r, current) {
|
|
201
|
+
const lines = [];
|
|
202
|
+
lines.push("Auto-compact window: compact earlier, automatically (Claude Code's own setting)");
|
|
203
|
+
lines.push(`Your last ${r.days} days, ${r.sessions} Claude Code session${r.sessions === 1 ? "" : "s"}, priced at list prices`);
|
|
204
|
+
lines.push("─".repeat(66));
|
|
205
|
+
lines.push(`Now: ${current ? `${k(current)} (set in ~/.claude/settings.json)` : "not set: Claude Code compacts near the model's full window"}`);
|
|
206
|
+
if (r.sessions === 0) {
|
|
207
|
+
lines.push("No sessions with 10+ requests in the window to replay.");
|
|
208
|
+
return lines.join("\n");
|
|
209
|
+
}
|
|
210
|
+
lines.push("");
|
|
211
|
+
lines.push(" window input cost saved compactions");
|
|
212
|
+
for (const e of r.estimates) {
|
|
213
|
+
const mark = current === e.window ? " ← current" : "";
|
|
214
|
+
lines.push(` ${k(e.window).padStart(6)} ${usd(e.withWindowUsd).padStart(10)} ${(usd(e.savedUsd) + ` (${Math.round(e.savedPct * 100)}%)`).padEnd(16)} ${String(e.compactions).padStart(4)} (${((e.compactions / r.days) * 7).toFixed(1)}/week)${mark}`);
|
|
215
|
+
}
|
|
216
|
+
lines.push(` as now ${usd(r.estimates[0]?.baselineUsd ?? 0).padStart(10)} ${String(r.actualCompactions).padStart(4)} (${((r.actualCompactions / r.days) * 7).toFixed(1)}/week)`);
|
|
217
|
+
lines.push("");
|
|
218
|
+
lines.push("Each compaction replaces the session's history with a summary, so a smaller");
|
|
219
|
+
lines.push("window trades detail for cost. Set one with: context-doctor compact-window 400k");
|
|
220
|
+
lines.push("Undo with: context-doctor compact-window off");
|
|
221
|
+
return lines.join("\n");
|
|
222
|
+
}
|
package/dist/dashboard.d.ts
CHANGED
|
@@ -9,12 +9,14 @@
|
|
|
9
9
|
import http from "node:http";
|
|
10
10
|
export interface DashboardData {
|
|
11
11
|
generatedAt: string;
|
|
12
|
+
/** tokensSaved: removed by context-doctor itself. shrinkage: sessions that got smaller after a warning (mostly compaction), shown apart. */
|
|
12
13
|
totals: {
|
|
13
14
|
tokensSaved: number;
|
|
14
15
|
usdSaved: number;
|
|
15
16
|
checks: number;
|
|
16
17
|
warnings: number;
|
|
17
18
|
optimizeRuns: number;
|
|
19
|
+
shrinkage: number;
|
|
18
20
|
};
|
|
19
21
|
daily: Array<{
|
|
20
22
|
date: string;
|
package/dist/dashboard.js
CHANGED
|
@@ -37,7 +37,8 @@ export async function collectDashboardData(proxyPort = 8787) {
|
|
|
37
37
|
// real reduction, so it counts alongside explicit optimize runs.
|
|
38
38
|
const perSession = new Map();
|
|
39
39
|
for (const c of checks) {
|
|
40
|
-
|
|
40
|
+
// "mcp-sketch" was a shared id for unrelated chats (fixed in 0.24); never a session.
|
|
41
|
+
if (!c.sid || c.sid === "mcp-sketch" || typeof c.tok !== "number")
|
|
41
42
|
continue;
|
|
42
43
|
perSession.set(c.sid, [...(perSession.get(c.sid) ?? []), c.tok]);
|
|
43
44
|
}
|
|
@@ -94,7 +95,10 @@ export async function collectDashboardData(proxyPort = 8787) {
|
|
|
94
95
|
return {
|
|
95
96
|
generatedAt: new Date().toISOString(),
|
|
96
97
|
totals: {
|
|
97
|
-
|
|
98
|
+
// What context-doctor removed itself; compaction shrinkage is reported
|
|
99
|
+
// separately (it was counted here until 0.24, which overclaimed).
|
|
100
|
+
tokensSaved: optimizeSaved + (proxy?.tokensSaved ?? 0),
|
|
101
|
+
shrinkage,
|
|
98
102
|
usdSaved: usdSaved + (proxy?.estUsdSaved ?? 0),
|
|
99
103
|
checks: checks.length + carried.checks,
|
|
100
104
|
warnings: checks.filter((c) => c.warn).length + carried.warnings,
|
package/dist/doctor.js
CHANGED
|
@@ -237,7 +237,7 @@ export async function runDoctor() {
|
|
|
237
237
|
const known = [...surfaces.keys()].filter((ep) => ep !== "unknown");
|
|
238
238
|
const desktopOnly = known.length > 0 && known.every((ep) => ep.startsWith("claude-desktop"));
|
|
239
239
|
checks.push(h.ok && h.autopilot && routed && seen === 0 && desktopOnly
|
|
240
|
-
? { label: "Autopilot", status: "warn", detail: "running, but it has seen 0 requests: your recent Claude Code sessions all ran in the desktop app, which ignores settings.json's API address. Autopilot only reaches terminal and IDE sessions" }
|
|
240
|
+
? { label: "Autopilot", status: "warn", detail: "running, but it has seen 0 requests: your recent Claude Code sessions all ran in the desktop app, which ignores settings.json's API address. Autopilot only reaches terminal and IDE sessions; for desktop ones, see `context-doctor compact-window`" }
|
|
241
241
|
: h.ok && h.autopilot && routed
|
|
242
242
|
? { label: "Autopilot", status: "ok", detail: `proxy up on ${proxyUrl(cfg.port)}, Claude Code routed through it${seen >= 0 ? ` (${seen} requests since start)` : ""}${paused ? " (PAUSED: passthrough)" : ""}` }
|
|
243
243
|
: { label: "Autopilot", status: "fail", detail: !h.ok ? `proxy not answering on ${proxyUrl(cfg.port)}: the next Claude Code prompt restarts it; or run: context-doctor autopilot on` : "settings.json no longer routes Claude Code to the proxy: run context-doctor autopilot on" });
|
package/dist/hook.js
CHANGED
|
@@ -20,7 +20,7 @@ import { profileConversation } from "./profile.js";
|
|
|
20
20
|
import { parseSessionFile } from "./session.js";
|
|
21
21
|
import { formatTokens, CHARS_PER_TOKEN } from "./tokens.js";
|
|
22
22
|
import { ensureProxyUp } from "./autopilot.js";
|
|
23
|
-
import { detectColdResume, renderColdResume } from "./coldresume.js";
|
|
23
|
+
import { detectColdResume, renderColdResume, renderColdResumeNotice } from "./coldresume.js";
|
|
24
24
|
import { formatUsd, pricingFor } from "./pricing.js";
|
|
25
25
|
import { checkBudget, loadConfig } from "./config.js";
|
|
26
26
|
/** Default nudge threshold; a project budget or env var can lower/raise it. */
|
|
@@ -165,17 +165,25 @@ export async function runHook() {
|
|
|
165
165
|
// before the growth gate, because a return after idle involves no growth.
|
|
166
166
|
// Once per idle period; the tail read is ~1 ms and only on large files.
|
|
167
167
|
const notes = [];
|
|
168
|
+
let notice;
|
|
168
169
|
const cold = detectColdResume(transcriptPath);
|
|
169
170
|
let cr = prev.cr;
|
|
170
171
|
if (cold && cold.lastReplyAt !== prev.cr) {
|
|
171
172
|
notes.push(renderColdResume(cold));
|
|
173
|
+
notice = renderColdResumeNotice(cold);
|
|
172
174
|
cr = cold.lastReplyAt;
|
|
173
175
|
recordLedger({ ev: "cold_resume", sid: sessionId.slice(0, 12), tok: cold.tokens, model: cold.model });
|
|
174
176
|
}
|
|
175
177
|
const emit = (lines) => {
|
|
176
|
-
if (lines.length === 0)
|
|
178
|
+
if (lines.length === 0 && !notice)
|
|
177
179
|
return;
|
|
178
|
-
|
|
180
|
+
const out = {};
|
|
181
|
+
// systemMessage is shown to the user in the app; additionalContext goes to the model.
|
|
182
|
+
if (notice)
|
|
183
|
+
out.systemMessage = notice;
|
|
184
|
+
if (lines.length > 0)
|
|
185
|
+
out.hookSpecificOutput = { hookEventName: "UserPromptSubmit", additionalContext: `<context-doctor>\n${lines.join("\n")}\n</context-doctor>` };
|
|
186
|
+
console.log(JSON.stringify(out));
|
|
179
187
|
};
|
|
180
188
|
if (prev.b > 0 && sizeBytes < prev.b * REGROWTH_FACTOR) {
|
|
181
189
|
if (cr !== prev.cr)
|
package/dist/impact.js
CHANGED
|
@@ -14,6 +14,7 @@ import { parseConversation } from "./parse.js";
|
|
|
14
14
|
import { profileConversation } from "./profile.js";
|
|
15
15
|
import { formatTokens } from "./tokens.js";
|
|
16
16
|
import { formatUsd, inputCostUsd, pricingFor } from "./pricing.js";
|
|
17
|
+
import { coldResumeFollowThrough } from "./coldresume.js";
|
|
17
18
|
/** Sessions larger than this are skipped in the report (keeps it snappy). */
|
|
18
19
|
const MAX_SESSION_BYTES = 30 * 1024 * 1024;
|
|
19
20
|
async function fetchProxyStats(port) {
|
|
@@ -41,7 +42,8 @@ export async function buildImpactReport(proxyPort = 8787) {
|
|
|
41
42
|
// checks (compaction/cleanup after a warning), that drop is measured fact.
|
|
42
43
|
const bySession = new Map();
|
|
43
44
|
for (const c of checks) {
|
|
44
|
-
|
|
45
|
+
// "mcp-sketch" was a shared id for unrelated chats (fixed in 0.24); never a session.
|
|
46
|
+
if (!c.sid || c.sid === "mcp-sketch" || typeof c.tok !== "number")
|
|
45
47
|
continue;
|
|
46
48
|
const s = bySession.get(c.sid) ?? { toks: [], warns: 0 };
|
|
47
49
|
s.toks.push(c.tok);
|
|
@@ -77,7 +79,10 @@ export async function buildImpactReport(proxyPort = 8787) {
|
|
|
77
79
|
const proxyHistoric = proxyEvents.reduce((s, e) => s + (e.saved ?? 0), 0) + carried.proxySaved;
|
|
78
80
|
const proxySaved = proxyHistoric + (proxy?.tokensSaved ?? 0);
|
|
79
81
|
// -- Headline: what context-doctor has saved ----------------------------------
|
|
80
|
-
|
|
82
|
+
// Only what context-doctor itself removed. Sessions also shrink when Claude
|
|
83
|
+
// Code auto-compacts or the user runs /compact; counting that as our saving
|
|
84
|
+
// overclaimed (until 0.24 it was in this total), so it is shown apart.
|
|
85
|
+
const totalSaved = proxySaved + optimizeSaved;
|
|
81
86
|
lines.push("Tokens context-doctor saved (measured)");
|
|
82
87
|
lines.push("─".repeat(56));
|
|
83
88
|
lines.push(`TOTAL: ~${formatTokens(totalSaved)} tokens`);
|
|
@@ -97,7 +102,10 @@ export async function buildImpactReport(proxyPort = 8787) {
|
|
|
97
102
|
lines.push(` · optimizations applied via CLI/chat tools (exact): ${formatTokens(optimizeSaved)} over ${optimizes.length} run(s)` +
|
|
98
103
|
(familyNote ? ` [${familyNote}]` : "") +
|
|
99
104
|
(optimizeUsd > 0 ? ` ≈ ${formatUsd(optimizeUsd)}` : ""));
|
|
100
|
-
lines.push(
|
|
105
|
+
lines.push("");
|
|
106
|
+
lines.push(`Sessions that got smaller after a hygiene warning: ${formatTokens(totalReduction)} tokens`);
|
|
107
|
+
lines.push(" (mostly compaction, by you or Claude Code; not counted above, because a warning");
|
|
108
|
+
lines.push(" may or may not be why it happened)");
|
|
101
109
|
lines.push("");
|
|
102
110
|
// -- Hook activity ------------------------------------------------------------
|
|
103
111
|
lines.push("Hygiene activity (every-prompt hook)");
|
|
@@ -110,6 +118,18 @@ export async function buildImpactReport(proxyPort = 8787) {
|
|
|
110
118
|
else {
|
|
111
119
|
lines.push("No hook activity recorded yet (ledger appears after the first deep check of a heavy session).");
|
|
112
120
|
}
|
|
121
|
+
const offers = ledger.filter((e) => e.ev === "cold_resume");
|
|
122
|
+
if (offers.length > 0) {
|
|
123
|
+
const paths = new Map();
|
|
124
|
+
for (const s of listSessions(10_000))
|
|
125
|
+
paths.set((s.path.split(/[\\/]/).pop() ?? "").slice(0, 12), s.path);
|
|
126
|
+
const f = coldResumeFollowThrough(offers, paths);
|
|
127
|
+
lines.push(`/compact offered on ${f.offers} return(s) to a large session after the cache expired;`);
|
|
128
|
+
lines.push(`a compaction (yours or Claude Code's) followed within the hour on ${f.compacted}.`);
|
|
129
|
+
if (f.offers >= 5 && f.compacted / f.offers < 0.2) {
|
|
130
|
+
lines.push("Rarely acted on? `context-doctor compact-window` makes Claude Code compact earlier by itself.");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
113
133
|
lines.push("");
|
|
114
134
|
// -- Measured-now: recent session profiles ------------------------------------
|
|
115
135
|
lines.push("Your recent sessions — live context and waste still recoverable");
|
|
@@ -137,7 +157,7 @@ export async function buildImpactReport(proxyPort = 8787) {
|
|
|
137
157
|
totalWasteUsd += p.cost.savingsPerCallUsd * (pr.cacheReadPerM / pr.inputPerM);
|
|
138
158
|
const wastePct = p.totalTokens > 0 ? Math.round((p.totalEstSavings / p.totalTokens) * 100) : 0;
|
|
139
159
|
// Ledger sids are the session UUID's first 12 chars (= filename prefix).
|
|
140
|
-
const sid = (s.path.split(
|
|
160
|
+
const sid = (s.path.split(/[\\/]/).pop() ?? "").slice(0, 12);
|
|
141
161
|
const saved = reductionBySession.get(sid) ?? 0;
|
|
142
162
|
const warns = bySession.get(sid)?.warns ?? 0;
|
|
143
163
|
lines.push(` ${(parsed.title ?? s.path.split("/").pop() ?? "session").slice(0, 40).padEnd(42)} ` +
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/mcp.js
CHANGED
|
@@ -49,23 +49,23 @@ 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.25.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({
|
|
56
|
-
turns: z.number().int().nonnegative().describe("User+assistant exchanges so far"),
|
|
56
|
+
turns: z.number().int().nonnegative().max(100_000).describe("User+assistant exchanges so far"),
|
|
57
57
|
model: z.string().optional().describe("Model this chat runs as, e.g. claude-sonnet-5, gpt-5"),
|
|
58
58
|
blocks: z.array(z.object({
|
|
59
|
-
turn: z.number().int().positive().describe("1-based turn the block sits in"),
|
|
59
|
+
turn: z.number().int().positive().max(100_000).describe("1-based turn the block sits in"),
|
|
60
60
|
kind: z.enum(["paste", "code", "tool_result", "image", "base64", "text"]),
|
|
61
61
|
label: z.string().describe("Short name you can refer to later, e.g. 'the nginx config'"),
|
|
62
|
-
approx_tokens: z.number().positive().optional(),
|
|
63
|
-
approx_lines: z.number().positive().optional(),
|
|
64
|
-
approx_words: z.number().positive().optional(),
|
|
65
|
-
approx_chars: z.number().positive().optional(),
|
|
66
|
-
repeated: z.number().int().positive().optional().describe("Times this same content appears (2+ = duplicate)"),
|
|
62
|
+
approx_tokens: z.number().positive().max(10_000_000).optional(),
|
|
63
|
+
approx_lines: z.number().positive().max(1_000_000).optional(),
|
|
64
|
+
approx_words: z.number().positive().max(5_000_000).optional(),
|
|
65
|
+
approx_chars: z.number().positive().max(40_000_000).optional(),
|
|
66
|
+
repeated: z.number().int().positive().max(1000).optional().describe("Times this same content appears (2+ = duplicate)"),
|
|
67
67
|
stale: z.boolean().optional().describe("Already acted on; nothing in it is still needed"),
|
|
68
|
-
})).describe("Only the blocks over ~500 tokens, repeated, or images. Plain turns need not be listed."),
|
|
68
|
+
})).max(500).describe("Only the blocks over ~500 tokens, repeated, or images. Plain turns need not be listed."),
|
|
69
69
|
}).optional().describe("Coarse description of the conversation for chat apps. One size hint per block: lines for code, words for prose, chars (or tokens) for logs and tool output, which vary most per line."),
|
|
70
70
|
model: z.string().optional().describe("Target model name for context-window math, e.g. claude-sonnet-5 or gpt-4o"),
|
|
71
71
|
}, async ({ conversation, sketch, model }) => {
|
|
@@ -85,7 +85,7 @@ function createServer() {
|
|
|
85
85
|
conversation: z.string().describe("Conversation JSON (OpenAI or Anthropic format, or bare message array)"),
|
|
86
86
|
strategies: z.array(z.enum(STRATEGY_IDS)).optional()
|
|
87
87
|
.describe("Strategies to apply. Default: dedupe, trim-tool-results, strip-base64. Add trim-tool-calls to shrink big inline file writes, or prune-history for lossy compaction of old turns."),
|
|
88
|
-
keep_recent: z.number().int().
|
|
88
|
+
keep_recent: z.number().int().nonnegative().optional().describe("Messages at the tail to leave untouched (default 6; 0 = none)"),
|
|
89
89
|
max_tool_result_tokens: z.number().int().positive().optional().describe("Token budget for trimmed tool results (default 300)"),
|
|
90
90
|
}, async ({ conversation, strategies, keep_recent, max_tool_result_tokens }) => {
|
|
91
91
|
const result = optimizeConversation(conversation, {
|
package/dist/proxy.d.ts
CHANGED
|
@@ -47,13 +47,22 @@ 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.25.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
|
|
54
54
|
* so the token cannot be guessed a character at a time.
|
|
55
55
|
*/
|
|
56
56
|
export declare function stripToken(url: string, token: string): string | undefined;
|
|
57
|
+
/**
|
|
58
|
+
* Where a request goes. The endpoints we optimize are fixed; EVERY other path
|
|
59
|
+
* is forwarded untouched, because a client routed through the proxy (Claude
|
|
60
|
+
* Code under autopilot, an SDK) also lists models, uploads files, counts
|
|
61
|
+
* tokens. Until 0.24 those got a 404 and broke the feature for that client.
|
|
62
|
+
* Unknown paths go to Anthropic when the request says it is one (Anthropic
|
|
63
|
+
* clients always send anthropic-version, or x-api-key), else to OpenAI.
|
|
64
|
+
*/
|
|
65
|
+
export declare function upstreamFor(url: string, opts: ProxyOptions, headers?: http.IncomingHttpHeaders): string;
|
|
57
66
|
export interface ProxyStats {
|
|
58
67
|
startedAt: string;
|
|
59
68
|
requests: number;
|
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.25.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"]);
|
|
@@ -41,13 +41,22 @@ export function stripToken(url, token) {
|
|
|
41
41
|
const rest = end === -1 ? "/" : url.slice(end);
|
|
42
42
|
return rest;
|
|
43
43
|
}
|
|
44
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Where a request goes. The endpoints we optimize are fixed; EVERY other path
|
|
46
|
+
* is forwarded untouched, because a client routed through the proxy (Claude
|
|
47
|
+
* Code under autopilot, an SDK) also lists models, uploads files, counts
|
|
48
|
+
* tokens. Until 0.24 those got a 404 and broke the feature for that client.
|
|
49
|
+
* Unknown paths go to Anthropic when the request says it is one (Anthropic
|
|
50
|
+
* clients always send anthropic-version, or x-api-key), else to OpenAI.
|
|
51
|
+
*/
|
|
52
|
+
export function upstreamFor(url, opts, headers = {}) {
|
|
53
|
+
const anthropic = opts.anthropicUpstream ?? "https://api.anthropic.com";
|
|
54
|
+
const openai = opts.openaiUpstream ?? "https://api.openai.com";
|
|
45
55
|
if (url.startsWith("/v1/messages"))
|
|
46
|
-
return
|
|
47
|
-
if (url.startsWith("/v1/chat/completions") || url.startsWith("/v1/responses") || url.startsWith("/v1/embeddings"))
|
|
48
|
-
return
|
|
49
|
-
|
|
50
|
-
return undefined;
|
|
56
|
+
return anthropic;
|
|
57
|
+
if (url.startsWith("/v1/chat/completions") || url.startsWith("/v1/responses") || url.startsWith("/v1/embeddings"))
|
|
58
|
+
return openai;
|
|
59
|
+
return headers["anthropic-version"] !== undefined || headers["x-api-key"] !== undefined ? anthropic : openai;
|
|
51
60
|
}
|
|
52
61
|
/** Pull exact usage out of a response body — JSON or SSE, either provider. */
|
|
53
62
|
function extractUsage(text) {
|
|
@@ -125,7 +134,7 @@ export function startProxy(opts = {}) {
|
|
|
125
134
|
res.end(JSON.stringify({ ...stats, estUsdSaved: Number(stats.estUsdSaved.toFixed(4)) }, null, 2));
|
|
126
135
|
return;
|
|
127
136
|
}
|
|
128
|
-
const upstreamBase = upstreamFor(url, opts);
|
|
137
|
+
const upstreamBase = upstreamFor(url, opts, req.headers);
|
|
129
138
|
if (!upstreamBase) {
|
|
130
139
|
res.statusCode = 404;
|
|
131
140
|
res.setHeader("content-type", "application/json");
|
|
@@ -135,7 +144,13 @@ export function startProxy(opts = {}) {
|
|
|
135
144
|
const chunks = [];
|
|
136
145
|
for await (const chunk of req)
|
|
137
146
|
chunks.push(chunk);
|
|
138
|
-
|
|
147
|
+
// Keep the original bytes: only conversation JSON is ever rewritten, and
|
|
148
|
+
// anything else (a multipart file upload) must reach upstream byte for
|
|
149
|
+
// byte. Decoding everything as UTF-8 corrupted binary bodies.
|
|
150
|
+
const rawBody = Buffer.concat(chunks);
|
|
151
|
+
let body = rawBody.toString("utf8");
|
|
152
|
+
const originalBody = body;
|
|
153
|
+
const conversationPath = /^\/v1\/(messages|chat\/completions|responses)(\?|$)/.test(url);
|
|
139
154
|
// Optimize the message history in flight. Anything unparseable (or with
|
|
140
155
|
// no messages array, e.g. embeddings) passes through untouched.
|
|
141
156
|
// count_tokens is measurement — optimizing it would silently change the
|
|
@@ -184,7 +199,7 @@ export function startProxy(opts = {}) {
|
|
|
184
199
|
}
|
|
185
200
|
}
|
|
186
201
|
}
|
|
187
|
-
else if (req.method === "POST" && body && !isMeasurement) {
|
|
202
|
+
else if (req.method === "POST" && body && !isMeasurement && conversationPath) {
|
|
188
203
|
try {
|
|
189
204
|
// Per-route overrides: first modelPrefix match wins.
|
|
190
205
|
let effective = opts;
|
|
@@ -284,7 +299,7 @@ export function startProxy(opts = {}) {
|
|
|
284
299
|
const upstream = await fetch(upstreamBase + url, {
|
|
285
300
|
method: req.method ?? "POST",
|
|
286
301
|
headers,
|
|
287
|
-
body: req.method === "GET" || req.method === "HEAD" ? undefined : body,
|
|
302
|
+
body: req.method === "GET" || req.method === "HEAD" ? undefined : body === originalBody ? rawBody : body,
|
|
288
303
|
});
|
|
289
304
|
console.error(`[context-doctor] ${req.method} ${url} → ${upstream.status} in ${Date.now() - upstreamStart}ms | ${note}` +
|
|
290
305
|
(stats.tokensSaved > 0 ? ` | session total: ${formatTokens(stats.tokensSaved)} tokens ≈ ${formatUsd(stats.estUsdSaved)} saved` : ""));
|
package/dist/savings.d.ts
CHANGED
|
@@ -58,7 +58,15 @@ export interface SavingsReport {
|
|
|
58
58
|
/** Replay one Claude Code transcript. Undefined when it holds too few requests to mean anything. */
|
|
59
59
|
export declare function replaySession(path: string, options?: AutoClearOptions, since?: number): SessionSavings | undefined;
|
|
60
60
|
export declare function estimateSavings(days?: number, options?: AutoClearOptions, paths?: string[], onProgress?: (done: number, total: number) => void): SavingsReport;
|
|
61
|
-
|
|
61
|
+
/** Optional third lever, computed by compactwindow.ts and passed in by the CLI. */
|
|
62
|
+
export interface CompactWindowLine {
|
|
63
|
+
window: number;
|
|
64
|
+
savedUsd: number;
|
|
65
|
+
compactionsPerWeek: number;
|
|
66
|
+
nowPerWeek: number;
|
|
67
|
+
isCurrent: boolean;
|
|
68
|
+
}
|
|
69
|
+
export declare function renderSavings(r: SavingsReport, autopilotOn?: boolean, cw?: CompactWindowLine): string;
|
|
62
70
|
/**
|
|
63
71
|
* `savings --share`: a few lines people can paste or screenshot. Totals only:
|
|
64
72
|
* no project names, paths or session ids, so sharing it leaks nothing.
|
package/dist/savings.js
CHANGED
|
@@ -199,7 +199,7 @@ export function estimateSavings(days = 30, options = {}, paths, onProgress) {
|
|
|
199
199
|
}
|
|
200
200
|
const tokens = (n) => (n >= 1e9 ? `${(n / 1e9).toFixed(1)}B` : formatTokens(n));
|
|
201
201
|
const usd = (n) => (n >= 100 ? `$${Math.round(n).toLocaleString("en-US")}` : `$${n.toFixed(2)}`);
|
|
202
|
-
export function renderSavings(r, autopilotOn = false) {
|
|
202
|
+
export function renderSavings(r, autopilotOn = false, cw) {
|
|
203
203
|
const lines = [];
|
|
204
204
|
if (r.sessions.length === 0) {
|
|
205
205
|
lines.push(`No Claude Code sessions with 10+ requests in the last ${r.days} days to replay.`);
|
|
@@ -236,6 +236,15 @@ export function renderSavings(r, autopilotOn = false) {
|
|
|
236
236
|
if (!autopilotOn)
|
|
237
237
|
lines.push(" Turn it on: context-doctor autopilot on");
|
|
238
238
|
}
|
|
239
|
+
if (cw) {
|
|
240
|
+
lines.push("");
|
|
241
|
+
lines.push(`${`3. Compact earlier (${Math.round(cw.window / 1000)}k window)`.padEnd(34)}${usd(Math.max(0, cw.savedUsd)).padStart(9)}${pct(Math.max(0, cw.savedUsd))}`);
|
|
242
|
+
lines.push(` Claude Code's own auto-compact window${cw.isCurrent ? ", as you have it set" : ""}: automatic in every surface,`);
|
|
243
|
+
lines.push(` ${cw.compactionsPerWeek.toFixed(1)} compactions a week instead of ${cw.nowPerWeek.toFixed(1)}. Each keeps a summary, not the`);
|
|
244
|
+
lines.push(" full history. Compare sizes: context-doctor compact-window");
|
|
245
|
+
if (!cw.isCurrent)
|
|
246
|
+
lines.push(` Set it with: context-doctor compact-window ${Math.round(cw.window / 1000)}k`);
|
|
247
|
+
}
|
|
239
248
|
lines.push("");
|
|
240
249
|
lines.push("Replayed request by request through the shipped code and priced as the");
|
|
241
250
|
lines.push("prompt cache bills. On a subscription the same tokens come out of your");
|
package/dist/session.js
CHANGED
|
@@ -274,6 +274,14 @@ function fromCodexLine(entry) {
|
|
|
274
274
|
return null;
|
|
275
275
|
}
|
|
276
276
|
}
|
|
277
|
+
/** Message content as a block array (a plain string becomes one text block). */
|
|
278
|
+
function asBlocks(content) {
|
|
279
|
+
if (Array.isArray(content))
|
|
280
|
+
return content;
|
|
281
|
+
if (typeof content === "string")
|
|
282
|
+
return content ? [{ type: "text", text: content }] : [];
|
|
283
|
+
return content == null ? [] : [content];
|
|
284
|
+
}
|
|
277
285
|
export function parseSessionFile(path) {
|
|
278
286
|
// ChatGPT exports are one big JSON array, not JSONL — and small enough to
|
|
279
287
|
// read whole. Only peek first, so multi-hundred-MB JSONL is never slurped.
|
|
@@ -293,6 +301,12 @@ export function parseSessionFile(path) {
|
|
|
293
301
|
let model;
|
|
294
302
|
/** Index in `messages` of the newest compaction summary, or -1. */
|
|
295
303
|
let lastCompactIndex = -1;
|
|
304
|
+
let lastReplyId;
|
|
305
|
+
/** Where each API reply (by message id) lives in `messages`. */
|
|
306
|
+
const replyIndex = new Map();
|
|
307
|
+
let lastSampledReplyId;
|
|
308
|
+
// A compaction summary marks where the live context starts; never merge into it.
|
|
309
|
+
const prevIndexAllowsMerge = (i) => i !== lastCompactIndex;
|
|
296
310
|
/** Newest API-reported input size, if the transcript carries usage. */
|
|
297
311
|
let reportedInputTokens;
|
|
298
312
|
/** Every reported size, positioned — the basis for `context-doctor accuracy`. */
|
|
@@ -366,7 +380,16 @@ export function parseSessionFile(path) {
|
|
|
366
380
|
usageNumber(usage.cache_creation_input_tokens);
|
|
367
381
|
if (total > 0) {
|
|
368
382
|
reportedInputTokens = total;
|
|
369
|
-
|
|
383
|
+
// One sample per API reply: Claude Code writes a reply as several
|
|
384
|
+
// rows (one per content block) carrying the same usage. The request's
|
|
385
|
+
// input is everything before the reply's first row.
|
|
386
|
+
const replyId = typeof message.id === "string" ? message.id : undefined;
|
|
387
|
+
if (!replyId || replyId !== lastSampledReplyId) {
|
|
388
|
+
const prevMsg = messages[messages.length - 1];
|
|
389
|
+
const startsNew = !(prevMsg?.role === "assistant" && replyId !== undefined && replyId === lastReplyId);
|
|
390
|
+
usageSamples.push({ index: startsNew ? messages.length : messages.length - 1, input: total });
|
|
391
|
+
lastSampledReplyId = replyId;
|
|
392
|
+
}
|
|
370
393
|
}
|
|
371
394
|
}
|
|
372
395
|
if (entry.isCompactSummary)
|
|
@@ -389,7 +412,34 @@ export function parseSessionFile(path) {
|
|
|
389
412
|
}
|
|
390
413
|
}
|
|
391
414
|
}
|
|
392
|
-
messages
|
|
415
|
+
// Rebuild messages the way the API saw them. Claude Code writes one row
|
|
416
|
+
// per content block, so one reply (text + two tool calls) arrived as three
|
|
417
|
+
// "assistant" messages and its results as two "user" ones: exported
|
|
418
|
+
// conversations broke tool_use/tool_result pairing, and every split added
|
|
419
|
+
// per-message overhead to the profile. The API treats consecutive
|
|
420
|
+
// same-role messages as one; so do we.
|
|
421
|
+
// Parallel tool calls are written as they stream: a reply's later
|
|
422
|
+
// tool_use rows can land after the first tool_result rows. A row that
|
|
423
|
+
// belongs to an earlier reply (same message id) joins that reply.
|
|
424
|
+
const replyId = entry.type === "assistant" && typeof message.id === "string" ? message.id : undefined;
|
|
425
|
+
const home = replyId !== undefined ? replyIndex.get(replyId) : undefined;
|
|
426
|
+
if (home !== undefined && home > lastCompactIndex && home < messages.length - 1) {
|
|
427
|
+
messages[home].content = [...asBlocks(messages[home].content), ...asBlocks(message.content)];
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
const prev = messages[messages.length - 1];
|
|
431
|
+
if (entry.type === "assistant")
|
|
432
|
+
lastReplyId = replyId;
|
|
433
|
+
if (prev && prev.role === message.role && !entry.isCompactSummary && prevIndexAllowsMerge(messages.length - 1)) {
|
|
434
|
+
prev.content = [...asBlocks(prev.content), ...asBlocks(message.content)];
|
|
435
|
+
if (replyId !== undefined)
|
|
436
|
+
replyIndex.set(replyId, messages.length - 1);
|
|
437
|
+
}
|
|
438
|
+
else {
|
|
439
|
+
messages.push({ role: message.role, content: message.content });
|
|
440
|
+
if (replyId !== undefined)
|
|
441
|
+
replyIndex.set(replyId, messages.length - 1);
|
|
442
|
+
}
|
|
393
443
|
});
|
|
394
444
|
const toolTimings = [...latenciesByTool.entries()]
|
|
395
445
|
.map(([tool, ms]) => {
|
package/dist/sketch.js
CHANGED
|
@@ -186,6 +186,8 @@ export function renderSketchProfile(p) {
|
|
|
186
186
|
/** Profile a sketch, log it to the ledger like a hook check, and render. */
|
|
187
187
|
export function runSketch(sketch) {
|
|
188
188
|
const profile = profileSketch(sketch);
|
|
189
|
-
|
|
189
|
+
// No session id: each sketch describes a different chat, and a shared id made
|
|
190
|
+
// the dashboard read "big sketch, then a smaller one" as tokens saved.
|
|
191
|
+
recordLedger({ ev: "check", src: "mcp", tok: profile.totalTokens, warn: profile.findings.some((f) => f.severity !== "info"), model: sketch.model });
|
|
190
192
|
return renderSketchProfile(profile);
|
|
191
193
|
}
|
package/dist/tokens.js
CHANGED
|
@@ -102,6 +102,8 @@ export function formatTokens(n) {
|
|
|
102
102
|
// rather than something false.
|
|
103
103
|
if (!Number.isFinite(n))
|
|
104
104
|
return "0";
|
|
105
|
+
if (n >= 1_000_000_000)
|
|
106
|
+
return `${(n / 1_000_000_000).toFixed(1)}B`;
|
|
105
107
|
if (n >= 1_000_000)
|
|
106
108
|
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
107
109
|
if (n >= 10_000)
|
package/dist/watch.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* Polling (not fs.watch) is deliberate: editors/agents rewrite files in ways
|
|
8
8
|
* that break watchers cross-platform, and a 2s stat is effectively free.
|
|
9
9
|
*/
|
|
10
|
-
import { existsSync, statSync } from "node:fs";
|
|
10
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
11
11
|
import { listSessions, parseSessionFile } from "./session.js";
|
|
12
12
|
import { parseConversation } from "./parse.js";
|
|
13
13
|
import { profileConversation } from "./profile.js";
|
|
@@ -31,10 +31,24 @@ export function runWatch(opts) {
|
|
|
31
31
|
if (size === lastSize)
|
|
32
32
|
return; // nothing new — cost of this tick was one stat
|
|
33
33
|
lastSize = size;
|
|
34
|
+
// A session transcript (Claude Code, Codex, ChatGPT export), or else a
|
|
35
|
+
// plain conversation file (OpenAI/Anthropic messages JSON), which is how
|
|
36
|
+
// most agent frameworks dump a trace. Until 0.24 the second kind was
|
|
37
|
+
// read as an empty session and watch printed nothing.
|
|
34
38
|
const parsed = parseSessionFile(file);
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
39
|
+
let conversation;
|
|
40
|
+
let model = opts.model;
|
|
41
|
+
if (parsed.messageCount > 0) {
|
|
42
|
+
conversation = parseConversation(parsed.conversationJson);
|
|
43
|
+
model ??= parsed.model;
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
conversation = parseConversation(readFileSync(file, "utf8"));
|
|
47
|
+
if (conversation.parseWarning || conversation.messages.length === 0)
|
|
48
|
+
return;
|
|
49
|
+
model ??= conversation.model;
|
|
50
|
+
}
|
|
51
|
+
const profile = profileConversation(conversation, model);
|
|
38
52
|
const delta = profile.totalTokens - lastTokens;
|
|
39
53
|
lastTokens = profile.totalTokens;
|
|
40
54
|
const cost = profile.cost ? ` · ${formatUsd(profile.cost.perCallUsd)}/msg` : "";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "context-doctor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "See what your Claude Code sessions cost and cut it: offers /compact when you return to a large session after the cache expired (every surface, desktop app included), and a cache-safe autopilot for terminal, IDE and API traffic. Token profiler, hooks, MCP tools and a Claude Code plugin.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent",
|