context-doctor 0.24.0 → 0.26.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 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 autopilot would have saved" width="640">
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: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.
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,15 +85,27 @@ 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 | Same install options | 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
- | **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)) |
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 |
108
+ | **Cursor** (agent) | A native **after-tool-call hook** (`~/.cursor/hooks.json`): in a heavy chat the agent gets the same hygiene guidance and the largest waste, once per 40% of growth. (Cursor's prompt hook can only allow or block a prompt, so this is the event where guidance reaches the model) | 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 |
98
111
  | **Claude Desktop chat** | Standing context rules in every chat; one cheap `profile_context` call the model makes past ~30 turns or on any cost question | One-click `.mcpb` install, `context_checkup` prompt |
@@ -103,6 +116,8 @@ Not claimed, because no process on your machine sends those requests: trimming i
103
116
 
104
117
  ## What's new
105
118
 
119
+ - **0.26 Checked on every platform, three fixes**: tested end to end in Claude Code (terminal, desktop app, plugin from GitHub), Claude Desktop, Cursor, Codex on GPT-5.5, VS Code, the MCP server on every launch path, and the proxy on OpenAI's APIs. Found and fixed: Cursor's agent never received the hook's guidance (Cursor runs Claude Code's hook where output cannot add context), so `install` now adds a native Cursor hook; Codex blocked the MCP tools behind an approval it never grants in `codex exec`, so the tools are now marked read-only; autopilot skipped Codex's newest tools (`exec`, `wait`) and some of Cursor's. Also: a history imported into Codex no longer reads as "2136% of the window", and the chat-app settings paths are current.
120
+ - **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
121
  - **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.
107
122
  - **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`.
108
123
  - **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.
@@ -129,9 +144,10 @@ One run of `context-doctor install` writes these (each config edit makes a `.bac
129
144
  2. **Claude Code config** (`~/.claude.json`) — registers the MCP server
130
145
  3. **Cursor config** (`~/.cursor/mcp.json`) — registers the MCP server
131
146
  4. **Agent Skill** → `~/.claude/skills/context-doctor/` — context-hygiene playbook for Claude Code
132
- 5. **Every-prompt hook** → `~/.claude/settings.json` — the per-query context check for Claude Code (Cursor runs it too)
133
- 6. **Codex**, when present: MCP server in `~/.codex/config.toml`, the hook in `~/.codex/hooks.json`, the skill in `~/.codex/skills/`
134
- 7. With `--statusline`: live context size, cache share and cost in Claude Code's status bar
147
+ 5. **Every-prompt hook** → `~/.claude/settings.json` — the per-query context check for Claude Code
148
+ 6. **Cursor hook**, when Cursor is present → `~/.cursor/hooks.json` (`postToolUse`) — the same check for Cursor's agent, after each tool call
149
+ 7. **Codex**, when present: MCP server in `~/.codex/config.toml`, the hook in `~/.codex/hooks.json`, the skill in `~/.codex/skills/`
150
+ 8. With `--statusline`: live context size, cache share and cost in Claude Code's status bar
135
151
 
136
152
  `context-doctor autopilot on` is separate and opt-in: it adds the background proxy service and one line (`env.ANTHROPIC_BASE_URL`) to `~/.claude/settings.json`, after the proxy has answered a health check.
137
153
 
@@ -170,7 +186,8 @@ Practical upshot: a developer who only wants cheaper, faster API calls never tou
170
186
  | Command | What it does |
171
187
  |---|---|
172
188
  | `context-doctor install` / `uninstall` | Wire (or remove) everything: MCP for Claude Desktop/Code/Cursor/Codex, the Agent Skill, the every-prompt hook |
173
- | `context-doctor savings [--days n] [--share [--copy]]` | What your recent Claude Code sessions cost, and what `/compact` at cold resumes and autopilot 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 |
189
+ | `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 |
190
+ | `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 |
174
191
  | `context-doctor mcp [--http]` | The MCP server as a subcommand, for clients and registries that launch `npx -y context-doctor mcp` |
175
192
  | `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 |
176
193
  | `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 |
@@ -182,7 +199,7 @@ Practical upshot: a developer who only wants cheaper, faster API calls never tou
182
199
  | `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 |
183
200
  | `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 |
184
201
  | `context-doctor cursor [--list]` | Profile a chat from Cursor's local history (both storage formats) |
185
- | `context-doctor report` | Machine-wide impact report (proxy savings persist across restarts): exact proxy savings, hook activity, recoverable waste in recent sessions |
202
+ | `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 |
186
203
  | `context-doctor proxy` | Always-on local proxy that optimizes every Anthropic/OpenAI API request in flight (`/stats` for cumulative savings) |
187
204
  | `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 |
188
205
  | `context-doctor doctor` | Self-check the whole installation — one pasteable ✓/✗ diagnosis with fixes |
@@ -241,7 +258,7 @@ context-doctor autopilot pause # instant passthrough, nothing restarts
241
258
  context-doctor autopilot off # remove it
242
259
  ```
243
260
 
244
- `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 is the lever that works. Your login (subscription or API key) passes through untouched.
261
+ `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.
245
262
 
246
263
  **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.
247
264
 
@@ -314,15 +331,15 @@ Your API key still rides in the request headers, as before. The token protects t
314
331
  | Surface | Runs by itself | What that means |
315
332
  |---|---|---|
316
333
  | **Claude Code** | Yes: hook on every prompt, status line on every refresh | Past ~80k tokens the model receives hygiene guidance naming the largest waste; compaction is offered. Measured: 115 automatic checks, 48 warnings, across 32 sessions on one machine |
317
- | **Cursor** | **Yes**, since 0.15: Cursor loads Claude Code's hook config (`~/.claude/settings.json`) and runs the same hook on every agent prompt, passing its own transcript. Output is accepted through Cursor's Claude-compat layer | Same guidance as Claude Code, inside Cursor's agent, for everyone who ran `install`. Before 0.15 the hook fired but could not read Cursor's transcript format, so it said nothing |
334
+ | **Cursor** | **Yes**, since 0.26: `install` adds a native `postToolUse` hook to `~/.cursor/hooks.json`; Cursor passes it the agent transcript, and its `additional_context` reaches the model | In a heavy chat, the agent gets hygiene guidance and the largest recoverable waste, once per 40% of growth. Cursor's transcript records tool calls but not their output, and keeps turns Cursor has since summarized, so the note gives the chat's accumulated size, with no window share or price. Corrected in 0.26: Cursor also runs Claude Code's hook, but as `beforeSubmitPrompt`, whose output can only allow or block the prompt, so from 0.15 to 0.25 the guidance never reached Cursor's model |
318
335
  | **API traffic through the proxy** | Yes: every request rewritten in flight | Fewer tokens, guaranteed, model not consulted |
319
336
  | **Claude Desktop** | The standing instruction in every chat (we confirmed in the app bundle that Desktop's `LocalMcpServerManager` reads it), a one-click `context_checkup` prompt, and since 0.17 a `profile_context` the model can actually afford to call from chat | Until 0.17 the tool wanted the whole conversation as its argument, so calling it from chat meant re-typing 50k tokens; nobody did, and Desktop's log showed zero calls in a month. Now the model passes a ~120-token **sketch** (turn count, the large or repeated blocks) and gets a sized estimate, findings and the fix to apply. Still a nudge, not a hook: Desktop chat has no hook API and no transcript on disk |
320
- | **Codex (OpenAI): ChatGPT.app's Codex tab, the Codex IDE extension, the `codex` CLI** | **Yes**, since 0.16: `install` writes the hook to `~/.codex/hooks.json`, the MCP server to `~/.codex/config.toml`, and the skill to `~/.codex/skills/`. Codex uses Claude Code's hook contract almost verbatim and passes its own rollout transcript, which carries the API's real usage figures | Same guidance as Claude Code, from measured tokens. One extra step, Codex's rule not ours: a new hook runs only after you trust it once (type `/hooks` in Codex). `session` and `session --list` read Codex rollouts too |
337
+ | **Codex (OpenAI): ChatGPT.app's Codex tab, the Codex IDE extension, the `codex` CLI** | **Yes**, since 0.16: `install` writes the hook to `~/.codex/hooks.json`, the MCP server to `~/.codex/config.toml`, and the skill to `~/.codex/skills/`. Codex uses Claude Code's hook contract almost verbatim and passes its own rollout transcript, which carries the API's real usage figures | Same guidance as Claude Code, from measured tokens. One extra step, Codex's rule not ours: a new hook runs only after you trust it once (type `/hooks` in Codex). The MCP tools are marked read-only, so Codex runs them without an approval prompt, `codex exec` included. `session` and `session --list` read Codex rollouts too |
321
338
  | **ChatGPT chat UI** | No | No MCP, no hooks, no data path in the chat product itself. Use Codex, or a developer-mode connector at a URL you host |
322
339
 
323
340
  So "every chat inherently better" is true for Claude Code, Cursor, Codex and the proxy; for Claude Desktop it is "the rules ride in every chat and the checkup is one cheap tool call away"; and not a claim we make for the ChatGPT chat UI.
324
341
 
325
- **Where there is no hook and no MCP at all** (claude.ai on the web, the Claude and ChatGPT phone apps, plain ChatGPT): the app's per-account preferences are read on every turn, which is the closest those surfaces have to a hook. `context-doctor instructions --copy` puts the ~180-token rules on your clipboard and tells you where to paste them (claude.ai Settings > Profile; ChatGPT Settings > Personalization > Custom instructions).
342
+ **Where there is no hook and no MCP at all** (claude.ai on the web, the Claude and ChatGPT phone apps, plain ChatGPT): the app's per-account preferences are read on every turn, which is the closest those surfaces have to a hook. `context-doctor instructions --copy` puts the ~180-token rules on your clipboard and tells you where to paste them (claude.ai Settings > General; ChatGPT Settings > Personalization > Custom instructions, "What traits should ChatGPT have?").
326
343
 
327
344
  **Do you need to configure anything by hand? Usually no:**
328
345
 
@@ -330,7 +347,7 @@ So "every chat inherently better" is true for Claude Code, Cursor, Codex and the
330
347
  |---|---|
331
348
  | Claude Desktop | `npx context-doctor install` writes the config — just restart the app. Or one click: download `context-doctor-<version>.mcpb` from the [latest release](https://github.com/KushalP1/context-doctor/releases) and open it (Settings > Extensions). The bundle runs on Desktop's own Node, no npm needed |
332
349
  | Claude Code | Same command — MCP + skill + every-prompt hook, all automatic |
333
- | Cursor | Same command — writes `~/.cursor/mcp.json`; the every-prompt hook is picked up from Claude Code's config, which Cursor reads |
350
+ | Cursor | Same command — writes `~/.cursor/mcp.json` and the after-tool-call hook in `~/.cursor/hooks.json`. `context-doctor doctor` checks both |
334
351
  | Codex (OpenAI) | Same command — `~/.codex/config.toml`, `~/.codex/hooks.json`, `~/.codex/skills/`. Then, once, `/hooks` in Codex to trust the hook |
335
352
  | ChatGPT (developer mode) | **Manual + a reachable URL** — ChatGPT connects to servers over the internet, never local commands. Run `context-doctor-mcp --http` on a host/tunnel, then add the URL as a connector. Normal ChatGPT (no dev mode) has no MCP — use the CLI |
336
353
 
@@ -354,7 +371,7 @@ For any other MCP client, the server entry is:
354
371
  3. That call is cheap on purpose. Claude cannot export a Desktop chat, so it passes a **sketch**: how many turns, which blocks are large, repeated, stale or images, with one size hint each (~120 tokens). The server sizes it (usually within ±20%, measured; see "Why token counts are ~"), prices the per-turn re-read (on a subscription that is what spends your usage limit), and returns ranked findings with the action for each: "summarize *the nginx config* into the points still needed", "refer to *test output* by name", "offer a 300-token handoff summary for a fresh chat". The reply ends with an instruction to apply the top one, not just suggest it.
355
372
  4. One click instead of asking: the `context_checkup` prompt in the **+** menu sends that request for you.
356
373
  5. Say *"optimize it"* on an exported conversation and Claude applies the safe fixes; if you agree to pruning old history, **Claude itself writes the replacement summary** (that's the no-API-key summarization).
357
- 6. For the same rules on your phone and on claude.ai, where no MCP server runs: `context-doctor instructions --copy`, then paste into Settings > Profile > personal preferences.
374
+ 6. For the same rules on your phone and on claude.ai, where no MCP server runs: `context-doctor instructions --copy`, then paste into Settings > General > personal preferences.
358
375
 
359
376
  What this does not do: read the chat behind Claude's back or trim it for you. Desktop chat has no hook API and no transcript on disk (checked in the app bundle, v2.2553). The model does the trimming, when the rules and the checkup tell it to.
360
377
 
@@ -364,13 +381,13 @@ ChatGPT's MCP support differs fundamentally from Claude Desktop's: **it never sp
364
381
 
365
382
  1. **Normal ChatGPT (no developer mode): no MCP at all.** context-doctor still helps via the CLI: export the conversation and run `npx context-doctor analyze chat.json --model gpt-5` / `optimize` — no account settings required.
366
383
  2. **ChatGPT developer mode**: run our HTTP transport somewhere reachable — `context-doctor-mcp --http --port 8808` on a small host (bind `--host 0.0.0.0` there), or expose your machine temporarily with a tunnel (`ngrok http 8808`). Then Settings → Connectors → Advanced → Developer mode → add connector with URL `https://<your-host>/mcp`.
367
- 3. Once connected, GPT gets the same three tools with the same trigger guidance: ask *"what's eating my context?"* → it calls `profile_context`; *"optimize it"* works the same, including GPT writing the pruning summary itself.
384
+ 3. Once connected, GPT gets the same three tools, each marked read-only (they only read their input and return text), with the same trigger guidance: ask *"what's eating my context?"* → it calls `profile_context`; *"optimize it"* works the same, including GPT writing the pruning summary itself.
368
385
 
369
386
  Security note for step 2: the HTTP endpoint is unauthenticated — put it behind your tunnel's auth or a reverse proxy if it stays up long-term.
370
387
 
371
388
  ### claude.ai on the web and the phone apps
372
389
 
373
- Your local MCP server can't reach the website, but the behavior can. Two options: `context-doctor instructions --copy` and paste into Settings > Profile > personal preferences (applies everywhere you are signed in, phone included), or upload `skills/context-doctor/SKILL.md` under Settings → Capabilities → Skills. Either way web and mobile conversations gain the same standing habits: summarize, don't re-quote, offer a handoff when heavy.
390
+ Your local MCP server can't reach the website, but the behavior can. Two options: `context-doctor instructions --copy` and paste into Settings > General > personal preferences (applies everywhere you are signed in, phone included), or upload `skills/context-doctor/SKILL.md` under Settings → Capabilities → Skills. Either way web and mobile conversations gain the same standing habits: summarize, don't re-quote, offer a handoff when heavy.
374
391
 
375
392
  ### MCP tools
376
393
 
@@ -439,7 +456,7 @@ ctx 801k/1.0M ▮▮▮▮▮▮▮▮░░ 80% ⚠ · cache 100% · $12.34
439
456
 
440
457
  Live context against the model's window, a warning mark from 70%, the share served from cache, and the session's cost. It reads the size from the status payload when Claude Code provides it, and otherwise from the last 256KB of the transcript (about 1ms on a 20MB file; 80ms end to end including Node startup). It is opt-in and polite: there is only one status line, so it never overwrites one you already have, and `uninstall` removes only its own. Any failure prints nothing rather than an error.
441
458
 
442
- This is the "editor status bar" roadmap item, delivered for the editor most users of this tool are actually in. A VS Code / Cursor extension for the same number remains future work.
459
+ The same number in VS Code and Cursor's status bar: see [the editor extension](#the-same-number-inside-vs-code-and-cursor).
443
460
 
444
461
  ## Does a smaller context actually help? Measure it
445
462
 
@@ -648,6 +665,18 @@ Also keep the MCP server version in `src/mcp.ts` in sync with `package.json`, an
648
665
 
649
666
  Issues and PRs welcome — see [CONTRIBUTING.md](./CONTRIBUTING.md) for the six rules that keep this tool trustworthy (no API keys, nothing leaves the machine, no silent data loss, measurements not guesses, the hot path stays cheap, tests with every change) and a list of good first issues. What is planned next lives in [ROADMAP.md](./ROADMAP.md).
650
667
 
668
+ Two end-to-end checks beyond `npm test`, both run against a throwaway `HOME`:
669
+
670
+ ```bash
671
+ HOME=$(mktemp -d) node scripts/smoke-mcp.mjs node dist/mcp.js
672
+ ```
673
+
674
+ ```bash
675
+ HOME=$(mktemp -d) node scripts/smoke-openai-proxy.mjs
676
+ ```
677
+
678
+ The first connects to the MCP server the way a client does (it takes any launch command, `npx -y context-doctor mcp` included, or `--http <url>`), lists the tools and prompt, calls every tool and checks that bad input is rejected. The second runs the proxy against a local mock of OpenAI: streaming, the Responses API, usage capture and autopilot.
679
+
651
680
  ## License
652
681
 
653
682
  MIT © [gAI Ventures](https://gai.ventures)
package/dist/autoclear.js CHANGED
@@ -45,10 +45,11 @@ import { estimateTokens } from "./tokens.js";
45
45
  export const CLEARABLE_TOOLS = new Set([
46
46
  // Claude Code
47
47
  "Read", "Bash", "BashOutput", "Grep", "Glob", "LS", "WebFetch", "WebSearch", "NotebookRead",
48
- // Codex and other OpenAI-API agents
49
- "shell", "exec_command", "local_shell", "read_file", "list_dir", "grep_search", "file_search", "web_search",
48
+ // Codex and other OpenAI-API agents. Codex's code mode runs every tool from
49
+ // one `exec` cell and polls long commands with `wait` (as Bash/BashOutput).
50
+ "shell", "exec_command", "local_shell", "read_file", "list_dir", "grep_search", "file_search", "web_search", "exec", "wait",
50
51
  // Cursor's agent
51
- "Shell", "ReadFile", "rg", "run_terminal_cmd", "codebase_search",
52
+ "Shell", "ReadFile", "rg", "run_terminal_cmd", "codebase_search", "SemanticSearch", "ReadLints", "AwaitShell",
52
53
  ]);
53
54
  /** Tokens billed for one image inside a tool result (a full-size screenshot is ~1.6k on Claude). */
54
55
  const IMAGE_TOKENS = 1600;
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 (see `context-doctor savings`).",
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;
@@ -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
+ };
@@ -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/doctor.js CHANGED
@@ -165,6 +165,24 @@ export async function runDoctor() {
165
165
  else {
166
166
  checks.push({ label: "Codex", status: "skip", detail: "not detected (ChatGPT's Codex agent, IDE extension or CLI)" });
167
167
  }
168
+ // Cursor: its agent gets the guidance through a native postToolUse hook
169
+ // (the Claude Code hook it also runs cannot add context there).
170
+ if (existsSync(join(homedir(), ".cursor"))) {
171
+ const hooksPath = join(homedir(), ".cursor", "hooks.json");
172
+ try {
173
+ const hooks = existsSync(hooksPath) ? JSON.parse(readFileSync(hooksPath, "utf8")) : {};
174
+ const ours = (hooks.hooks?.postToolUse ?? []).map((e) => e.command ?? "").find((c) => /(cli\.js|context-doctor(\.cmd|\.exe|\.bat)?)"?\s+hook\s*$/.test(c));
175
+ const missing = ours ? hookBinaryMissing(ours) : undefined;
176
+ checks.push(!ours
177
+ ? { label: "Cursor hook", status: "fail", detail: "not registered — run: context-doctor install" }
178
+ : missing
179
+ ? { label: "Cursor hook", status: "fail", detail: `registered, but ${missing} no longer exists — re-run: context-doctor install` }
180
+ : { label: "Cursor hook", status: "ok", detail: "registered in ~/.cursor/hooks.json (after each tool call in heavy chats)" });
181
+ }
182
+ catch (e) {
183
+ checks.push({ label: "Cursor hook", status: "fail", detail: `~/.cursor/hooks.json unreadable (${e.message})` });
184
+ }
185
+ }
168
186
  // Status line (opt-in, so absence is a note, not a failure)
169
187
  if (existsSync(settingsPath)) {
170
188
  try {
@@ -237,7 +255,7 @@ export async function runDoctor() {
237
255
  const known = [...surfaces.keys()].filter((ep) => ep !== "unknown");
238
256
  const desktopOnly = known.length > 0 && known.every((ep) => ep.startsWith("claude-desktop"));
239
257
  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" }
258
+ ? { 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
259
  : h.ok && h.autopilot && routed
242
260
  ? { 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
261
  : { 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.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Claude Code UserPromptSubmit hook: runs on EVERY query in Claude Code.
2
+ * Claude Code UserPromptSubmit hook: runs on EVERY query in Claude Code (and
3
+ * Codex, same format), and after every tool call in Cursor (postToolUse).
3
4
  *
4
5
  * Claude Code pipes hook input as JSON on stdin ({session_id, transcript_path,
5
6
  * prompt, ...}). We profile the session transcript; when the context is lean
package/dist/hook.js CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Claude Code UserPromptSubmit hook: runs on EVERY query in Claude Code.
2
+ * Claude Code UserPromptSubmit hook: runs on EVERY query in Claude Code (and
3
+ * Codex, same format), and after every tool call in Cursor (postToolUse).
3
4
  *
4
5
  * Claude Code pipes hook input as JSON on stdin ({session_id, transcript_path,
5
6
  * prompt, ...}). We profile the session transcript; when the context is lean
@@ -18,9 +19,9 @@ import { recordLedger, statePath } from "./ledger.js";
18
19
  import { parseConversation } from "./parse.js";
19
20
  import { profileConversation } from "./profile.js";
20
21
  import { parseSessionFile } from "./session.js";
21
- import { formatTokens, CHARS_PER_TOKEN } from "./tokens.js";
22
+ import { formatTokens, CHARS_PER_TOKEN, LARGEST_WINDOW } from "./tokens.js";
22
23
  import { ensureProxyUp } from "./autopilot.js";
23
- import { detectColdResume, renderColdResume } from "./coldresume.js";
24
+ import { detectColdResume, renderColdResume, renderColdResumeNotice } from "./coldresume.js";
24
25
  import { formatUsd, pricingFor } from "./pricing.js";
25
26
  import { checkBudget, loadConfig } from "./config.js";
26
27
  /** Default nudge threshold; a project budget or env var can lower/raise it. */
@@ -145,13 +146,23 @@ export async function runHook() {
145
146
  }
146
147
  const input = JSON.parse(await readStdin());
147
148
  await heal;
149
+ // Cursor runs this hook twice over: Claude Code's UserPromptSubmit as its
150
+ // beforeSubmitPrompt (which cannot add context, so there is nothing to do),
151
+ // and natively after each tool call, where `additional_context` reaches
152
+ // the model. Codex and Claude Code send UserPromptSubmit.
153
+ // Cursor marks every hook input with cursor_version; any of its events but
154
+ // postToolUse would only spend the once-per-growth warning on output Cursor
155
+ // drops.
156
+ const cursor = input.hook_event_name === "postToolUse";
157
+ if (input.hook_event_name === "beforeSubmitPrompt" || (input.cursor_version && !cursor))
158
+ return;
148
159
  const transcriptPath = input.transcript_path;
149
160
  if (!transcriptPath || !existsSync(transcriptPath))
150
161
  return;
151
162
  // Fast path 1: a small transcript cannot exceed the threshold — exit on a
152
163
  // single stat() without reading the file. This is the every-prompt cost
153
164
  // for lean sessions: ~1ms.
154
- const { config } = loadConfig(input.cwd ?? process.cwd());
165
+ const { config } = loadConfig(input.cwd ?? input.workspace_roots?.[0] ?? process.cwd());
155
166
  const threshold = warnThreshold(config.budget?.maxTokens);
156
167
  const sizeBytes = statSync(transcriptPath).size;
157
168
  if (sizeBytes < minBytesForWarn(threshold))
@@ -159,23 +170,38 @@ export async function runHook() {
159
170
  // Fast path 2: growth gate BEFORE parsing. If the file hasn't grown ~40%
160
171
  // since the last full parse, nothing new can trigger — exit without the
161
172
  // expensive read. Heavy-but-quiet sessions cost one stat + tiny state read.
162
- const sessionId = input.session_id ?? transcriptPath;
173
+ const sessionId = input.session_id ?? input.conversation_id ?? transcriptPath;
163
174
  const prev = readSessionState(sessionId);
164
175
  // Cold resume: back on a large session after the cache expired. Checked
165
176
  // before the growth gate, because a return after idle involves no growth.
166
177
  // Once per idle period; the tail read is ~1 ms and only on large files.
167
178
  const notes = [];
179
+ let notice;
168
180
  const cold = detectColdResume(transcriptPath);
169
181
  let cr = prev.cr;
170
182
  if (cold && cold.lastReplyAt !== prev.cr) {
171
183
  notes.push(renderColdResume(cold));
184
+ notice = renderColdResumeNotice(cold);
172
185
  cr = cold.lastReplyAt;
173
186
  recordLedger({ ev: "cold_resume", sid: sessionId.slice(0, 12), tok: cold.tokens, model: cold.model });
174
187
  }
175
188
  const emit = (lines) => {
176
- if (lines.length === 0)
189
+ if (lines.length === 0 && !notice)
190
+ return;
191
+ const block = `<context-doctor>\n${lines.join("\n")}\n</context-doctor>`;
192
+ if (cursor) {
193
+ // Cursor's postToolUse has no user-facing field, so only the model's note goes out.
194
+ if (lines.length > 0)
195
+ console.log(JSON.stringify({ additional_context: block }));
177
196
  return;
178
- console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: `<context-doctor>\n${lines.join("\n")}\n</context-doctor>` } }));
197
+ }
198
+ const out = {};
199
+ // systemMessage is shown to the user in the app; additionalContext goes to the model.
200
+ if (notice)
201
+ out.systemMessage = notice;
202
+ if (lines.length > 0)
203
+ out.hookSpecificOutput = { hookEventName: "UserPromptSubmit", additionalContext: block };
204
+ console.log(JSON.stringify(out));
179
205
  };
180
206
  if (prev.b > 0 && sizeBytes < prev.b * REGROWTH_FACTOR) {
181
207
  if (cr !== prev.cr)
@@ -187,7 +213,8 @@ export async function runHook() {
187
213
  const parsed = parseSessionFile(transcriptPath);
188
214
  if (parsed.messageCount === 0)
189
215
  return;
190
- const profile = profileConversation(parseConversation(parsed.conversationJson), parsed.model);
216
+ const model = parsed.model ?? input.model;
217
+ const profile = profileConversation(parseConversation(parsed.conversationJson), model);
191
218
  // Prefer the API's own figure when the transcript carries it: it includes
192
219
  // the system prompt and tool schemas the transcript omits, so it is the
193
220
  // real context size rather than a message-only estimate.
@@ -200,15 +227,27 @@ export async function runHook() {
200
227
  emit(notes);
201
228
  return;
202
229
  }
203
- const windowPct = profile.contextWindow ? (liveTokens / profile.contextWindow) * 100 : undefined;
230
+ // An estimate past the window (no API figure, e.g. a history imported into
231
+ // Codex) is not the live context: the app must already be trimming it.
232
+ const overWindow = !cursor && parsed.reportedInputTokens === undefined && liveTokens > (profile.contextWindow ?? LARGEST_WINDOW);
233
+ const windowPct = !cursor && !overWindow && profile.contextWindow ? (liveTokens / profile.contextWindow) * 100 : undefined;
204
234
  // Agent sessions run on the prompt cache: a message normally re-reads the
205
235
  // context at the cached rate (0.1x) and pays the full rate only when the
206
236
  // cache has expired. Quoting the uncached figure alone overstated the
207
237
  // per-message cost tenfold (fixed in 0.22).
208
- const pricing = pricingFor(parsed.model);
238
+ const pricing = pricingFor(model);
209
239
  const cachedUsd = pricing ? (liveTokens * pricing.cacheReadPerM) / 1e6 : undefined;
210
240
  const coldUsd = pricing ? (liveTokens * pricing.inputPerM * 1.25) / 1e6 : undefined;
211
- const lines = [
241
+ // Cursor's agent transcript records tool calls but not their output, and
242
+ // keeps turns Cursor has since summarized, so it measures how much the chat
243
+ // has accumulated, not the live context: no window share or price for it.
244
+ const lines = cursor ? [
245
+ `This chat has accumulated ~${formatTokens(liveTokens)} tokens of messages and tool calls (tool output not counted; Cursor summarizes older turns itself, so the live context may differ).`,
246
+ "Practice context hygiene from here on: summarize large tool results instead of keeping them verbatim, reference earlier content rather than re-reading or re-quoting it, and keep responses lean.",
247
+ ] : overWindow ? [
248
+ `This session's transcript holds ~${formatTokens(liveTokens)} tokens, more than ${profile.contextWindow ? `the model's ~${formatTokens(profile.contextWindow)}` : "any current model's"} window, so the app is already summarizing or dropping older turns; what the model sees is a part of it.`,
249
+ "Practice context hygiene from here on: summarize large tool results instead of keeping them verbatim, reference earlier content rather than re-reading or re-quoting it, and keep responses lean.",
250
+ ] : [
212
251
  `This session's context is at ~${formatTokens(liveTokens)} tokens` +
213
252
  (windowPct !== undefined ? ` (${windowPct.toFixed(0)}% of the window)` : "") +
214
253
  (cachedUsd !== undefined ? `: each message re-reads it for ~${formatUsd(cachedUsd)} from the prompt cache, ~${formatUsd(coldUsd)} when the cache has expired` : "") +
@@ -225,7 +264,9 @@ export async function runHook() {
225
264
  lines.push(`Largest recoverable waste: ${topFinding.message} (${topFinding.suggestion})`);
226
265
  }
227
266
  if (liveTokens > threshold * 2) {
228
- lines.push("If it fits the flow, offer the user a compaction of the older history.");
267
+ lines.push(cursor
268
+ ? "If the task changes, suggest the user start a new chat from a short summary of this one."
269
+ : "If it fits the flow, offer the user a compaction of the older history.");
229
270
  }
230
271
  emit([...notes, ...lines]);
231
272
  }
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) {
@@ -117,6 +118,18 @@ export async function buildImpactReport(proxyPort = 8787) {
117
118
  else {
118
119
  lines.push("No hook activity recorded yet (ledger appears after the first deep check of a heavy session).");
119
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
+ }
120
133
  lines.push("");
121
134
  // -- Measured-now: recent session profiles ------------------------------------
122
135
  lines.push("Your recent sessions — live context and waste still recoverable");
@@ -144,7 +157,7 @@ export async function buildImpactReport(proxyPort = 8787) {
144
157
  totalWasteUsd += p.cost.savingsPerCallUsd * (pr.cacheReadPerM / pr.inputPerM);
145
158
  const wastePct = p.totalTokens > 0 ? Math.round((p.totalEstSavings / p.totalTokens) * 100) : 0;
146
159
  // Ledger sids are the session UUID's first 12 chars (= filename prefix).
147
- const sid = (s.path.split("/").pop() ?? "").slice(0, 12);
160
+ const sid = (s.path.split(/[\\/]/).pop() ?? "").slice(0, 12);
148
161
  const saved = reductionBySession.get(sid) ?? 0;
149
162
  const warns = bySession.get(sid)?.warns ?? 0;
150
163
  lines.push(` ${(parsed.title ?? s.path.split("/").pop() ?? "session").slice(0, 40).padEnd(42)} ` +
package/dist/index.d.ts CHANGED
@@ -25,3 +25,4 @@ export * from "./autoclear.js";
25
25
  export * from "./autopilot.js";
26
26
  export * from "./savings.js";
27
27
  export * from "./coldresume.js";
28
+ export * from "./compactwindow.js";
package/dist/index.js CHANGED
@@ -20,3 +20,4 @@ export * from "./autoclear.js";
20
20
  export * from "./autopilot.js";
21
21
  export * from "./savings.js";
22
22
  export * from "./coldresume.js";
23
+ export * from "./compactwindow.js";
package/dist/install.d.ts CHANGED
@@ -31,6 +31,16 @@ export declare function isEphemeralPath(path: string): boolean;
31
31
  * ours. Returns what happened so install can print the truth.
32
32
  */
33
33
  export declare function installStatusLine(): "installed" | "already" | "kept-foreign" | "no-claude-code";
34
+ /**
35
+ * Cursor runs Claude Code's UserPromptSubmit hook as its own
36
+ * beforeSubmitPrompt, which can only allow or block a prompt: what the hook
37
+ * says never reaches Cursor's model. Cursor's postToolUse can add context
38
+ * (`additional_context`), and its input carries the agent transcript, so the
39
+ * check runs there, in Cursor's own ~/.cursor/hooks.json. It fires after every
40
+ * tool call, which the hook's stat-only fast path and regrowth gate make cheap.
41
+ */
42
+ export declare function cursorHooksPath(): string;
43
+ export declare function installCursorHook(): string | null;
34
44
  export declare function codexDir(): string;
35
45
  /**
36
46
  * Add or replace our [mcp_servers.context-doctor] table in config.toml without
package/dist/install.js CHANGED
@@ -237,6 +237,53 @@ function installHookInto(settingsPath, gateDir) {
237
237
  function uninstallHook() {
238
238
  uninstallHookFrom(join(homedir(), ".claude", "settings.json"), "Claude Code every-prompt hook");
239
239
  uninstallHookFrom(join(homedir(), ".codex", "hooks.json"), "Codex every-prompt hook");
240
+ uninstallCursorHook();
241
+ }
242
+ /**
243
+ * Cursor runs Claude Code's UserPromptSubmit hook as its own
244
+ * beforeSubmitPrompt, which can only allow or block a prompt: what the hook
245
+ * says never reaches Cursor's model. Cursor's postToolUse can add context
246
+ * (`additional_context`), and its input carries the agent transcript, so the
247
+ * check runs there, in Cursor's own ~/.cursor/hooks.json. It fires after every
248
+ * tool call, which the hook's stat-only fast path and regrowth gate make cheap.
249
+ */
250
+ export function cursorHooksPath() {
251
+ return join(homedir(), ".cursor", "hooks.json");
252
+ }
253
+ const isOurCursorEntry = (e) => /(cli\.js|context-doctor(\.cmd|\.exe|\.bat)?)"?\s+hook\s*$/.test(String(e?.command ?? ""));
254
+ export function installCursorHook() {
255
+ if (!existsSync(join(homedir(), ".cursor")))
256
+ return null;
257
+ const path = cursorHooksPath();
258
+ const config = readJson(path);
259
+ config.version = config.version ?? 1;
260
+ config.hooks = config.hooks ?? {};
261
+ const entries = config.hooks.postToolUse ?? [];
262
+ const want = hookCommand();
263
+ const ours = entries.filter(isOurCursorEntry);
264
+ if (ours.length === 1 && ours[0].command === want)
265
+ return path; // already correct
266
+ config.hooks.postToolUse = [...entries.filter((e) => !isOurCursorEntry(e)), { command: want, timeout: 10 }];
267
+ writeJsonWithBackup(path, config);
268
+ return path;
269
+ }
270
+ function uninstallCursorHook() {
271
+ const path = cursorHooksPath();
272
+ if (!existsSync(path))
273
+ return;
274
+ const config = readJson(path);
275
+ const entries = config.hooks?.postToolUse;
276
+ if (!entries)
277
+ return;
278
+ const filtered = entries.filter((e) => !isOurCursorEntry(e));
279
+ if (filtered.length === entries.length)
280
+ return;
281
+ if (filtered.length === 0)
282
+ delete config.hooks.postToolUse;
283
+ else
284
+ config.hooks.postToolUse = filtered;
285
+ writeJsonWithBackup(path, config);
286
+ console.log("✓ Cursor after-tool-call hook removed");
240
287
  }
241
288
  function uninstallHookFrom(settingsPath, label) {
242
289
  if (!existsSync(settingsPath))
@@ -440,6 +487,15 @@ export function runInstall(options = {}) {
440
487
  console.error(`✗ Claude Code every-prompt hook: ${e.message}`);
441
488
  failures.push("Claude Code hook");
442
489
  }
490
+ try {
491
+ const cursorHook = installCursorHook();
492
+ if (cursorHook)
493
+ console.log(`✓ Cursor after-tool-call hook installed (${cursorHook}) — Cursor's agent gets the same guidance in heavy sessions`);
494
+ }
495
+ catch (e) {
496
+ console.error(`✗ Cursor hook: ${e.message}`);
497
+ failures.push("Cursor hook");
498
+ }
443
499
  installCodex(entry, failures);
444
500
  if (options.statusLine) {
445
501
  try {
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.24.0" }, { instructions: SERVER_INSTRUCTIONS });
52
+ const server = new McpServer({ name: "context-doctor", version: "0.26.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({
@@ -68,7 +68,9 @@ function createServer() {
68
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
- }, async ({ conversation, sketch, model }) => {
71
+ },
72
+ // Pure: reads its input and returns text. Clients (Codex, ChatGPT) may skip approval for read-only tools.
73
+ { title: "Profile context", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ conversation, sketch, model }) => {
72
74
  if (sketch) {
73
75
  return { content: [{ type: "text", text: runSketch({ ...sketch, model: sketch.model ?? model }) }] };
74
76
  }
@@ -87,7 +89,9 @@ function createServer() {
87
89
  .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
90
  keep_recent: z.number().int().nonnegative().optional().describe("Messages at the tail to leave untouched (default 6; 0 = none)"),
89
91
  max_tool_result_tokens: z.number().int().positive().optional().describe("Token budget for trimmed tool results (default 300)"),
90
- }, async ({ conversation, strategies, keep_recent, max_tool_result_tokens }) => {
92
+ },
93
+ // Pure: reads its input and returns text. Clients (Codex, ChatGPT) may skip approval for read-only tools.
94
+ { title: "Optimize context", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ conversation, strategies, keep_recent, max_tool_result_tokens }) => {
91
95
  const result = optimizeConversation(conversation, {
92
96
  strategies: strategies,
93
97
  keepRecent: keep_recent,
@@ -148,7 +152,9 @@ function createServer() {
148
152
  }));
149
153
  server.tool("context_best_practices", "Get a curated checklist of context-management best practices, optionally specialized for a provider (anthropic, openai).", {
150
154
  provider: z.enum(["general", "anthropic", "openai"]).optional().describe("Provider to specialize tips for (default: general)"),
151
- }, async ({ provider }) => {
155
+ },
156
+ // Pure: reads its input and returns text. Clients (Codex, ChatGPT) may skip approval for read-only tools.
157
+ { title: "Context best practices", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, async ({ provider }) => {
152
158
  const tips = [...BEST_PRACTICES.general, ...(provider && provider !== "general" ? BEST_PRACTICES[provider] : [])];
153
159
  return { content: [{ type: "text", text: tips.map((t, i) => `${i + 1}. ${t}`).join("\n") }] };
154
160
  });
@@ -16,8 +16,8 @@ export const CHAT_PREFERENCES = [
16
16
  ].join(" ");
17
17
  /** Where to paste, per app. Paths are the UI labels, not URLs, so they survive redesigns. */
18
18
  export const PREFERENCE_TARGETS = [
19
- "claude.ai / Claude Desktop / Claude mobile: Settings > Profile > \"What personal preferences should Claude consider in responses?\"",
20
- "ChatGPT: Settings > Personalization > Custom instructions > \"How would you like ChatGPT to respond?\"",
19
+ "claude.ai / Claude Desktop / Claude mobile: Settings > General > \"What personal preferences should Claude consider in responses?\"",
20
+ "ChatGPT: Settings > Personalization > Custom instructions > \"What traits should ChatGPT have?\"",
21
21
  ];
22
22
  /** Copy text to the system clipboard; false when no clipboard tool is available. */
23
23
  export function copyToClipboard(text, platform = process.platform) {
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.24.0";
50
+ export declare const PROXY_VERSION = "0.26.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.24.0";
23
+ export const PROXY_VERSION = "0.26.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"]);
@@ -384,7 +384,9 @@ export function startProxy(opts = {}) {
384
384
  console.error(`context-doctor proxy listening on http://${host}:${port}`);
385
385
  console.error(` Anthropic apps/SDKs: export ANTHROPIC_BASE_URL=http://localhost:${port}${prefix}`);
386
386
  console.error(` OpenAI apps/SDKs: export OPENAI_BASE_URL=http://localhost:${port}${prefix}/v1`);
387
- console.error(` Every request's context is optimized in flight; savings are logged here.`);
387
+ console.error(opts.autopilot
388
+ ? ` Autopilot: stale re-runnable tool output is cleared only when the prompt cache is cold; savings are logged here.`
389
+ : ` Every request's context is optimized in flight; savings are logged here.`);
388
390
  console.error(` Cumulative savings: http://localhost:${port}${prefix}/stats`);
389
391
  if (opts.token) {
390
392
  console.error(` Token required: every path except /health must start with /t/<token>/.`);
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
- export declare function renderSavings(r: SavingsReport, autopilotOn?: boolean): string;
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/tokens.d.ts CHANGED
@@ -9,6 +9,8 @@
9
9
  * fully offline.
10
10
  */
11
11
  export type Provider = "anthropic" | "openai" | "google" | "generic";
12
+ /** The largest window any model above has: no live context can exceed it. */
13
+ export declare const LARGEST_WINDOW: number;
12
14
  export declare function contextWindowFor(model?: string): number | undefined;
13
15
  export declare function providerFor(model?: string): Provider;
14
16
  /**
package/dist/tokens.js CHANGED
@@ -22,6 +22,8 @@ const MODEL_WINDOWS = [
22
22
  [/llama.*3/i, 128_000],
23
23
  [/mistral|mixtral/i, 32_000],
24
24
  ];
25
+ /** The largest window any model above has: no live context can exceed it. */
26
+ export const LARGEST_WINDOW = Math.max(...MODEL_WINDOWS.map(([, w]) => w));
25
27
  export function contextWindowFor(model) {
26
28
  if (!model)
27
29
  return undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-doctor",
3
- "version": "0.24.0",
3
+ "version": "0.26.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",