context-doctor 0.31.1 → 0.32.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.
Files changed (68) hide show
  1. package/README.md +25 -25
  2. package/dist/autoclear.d.ts +58 -7
  3. package/dist/autoclear.js +155 -30
  4. package/dist/autopilot.d.ts +23 -3
  5. package/dist/autopilot.js +100 -29
  6. package/dist/cache.d.ts +6 -2
  7. package/dist/cache.js +48 -23
  8. package/dist/cacheplan.d.ts +5 -3
  9. package/dist/cacheplan.js +46 -8
  10. package/dist/calibration.d.ts +7 -2
  11. package/dist/calibration.js +14 -3
  12. package/dist/ci.js +25 -5
  13. package/dist/cli.js +61 -26
  14. package/dist/coldresume.d.ts +32 -7
  15. package/dist/coldresume.js +148 -34
  16. package/dist/compactquality.js +34 -12
  17. package/dist/compactwindow.js +59 -34
  18. package/dist/config.d.ts +6 -0
  19. package/dist/config.js +4 -1
  20. package/dist/cursor.d.ts +9 -0
  21. package/dist/cursor.js +53 -22
  22. package/dist/dashboard.d.ts +10 -0
  23. package/dist/dashboard.js +27 -2
  24. package/dist/diff.js +10 -3
  25. package/dist/doctor.js +133 -18
  26. package/dist/embed.d.ts +9 -0
  27. package/dist/embed.js +40 -5
  28. package/dist/experiment.d.ts +1 -0
  29. package/dist/experiment.js +6 -3
  30. package/dist/hook.js +23 -4
  31. package/dist/impact.d.ts +32 -0
  32. package/dist/impact.js +45 -6
  33. package/dist/install.d.ts +34 -2
  34. package/dist/install.js +223 -30
  35. package/dist/ledger.js +9 -2
  36. package/dist/mcp.d.ts +1 -1
  37. package/dist/mcp.js +2 -2
  38. package/dist/mcpschema.d.ts +16 -1
  39. package/dist/mcpschema.js +95 -24
  40. package/dist/optimize.js +149 -64
  41. package/dist/overhead.js +57 -27
  42. package/dist/pack.d.ts +11 -0
  43. package/dist/pack.js +75 -12
  44. package/dist/parse.js +42 -18
  45. package/dist/pricing.d.ts +57 -5
  46. package/dist/pricing.js +94 -8
  47. package/dist/proxy.d.ts +13 -2
  48. package/dist/proxy.js +258 -43
  49. package/dist/reads.d.ts +1 -1
  50. package/dist/reads.js +61 -24
  51. package/dist/report.d.ts +10 -0
  52. package/dist/report.js +12 -4
  53. package/dist/savings.d.ts +20 -8
  54. package/dist/savings.js +79 -34
  55. package/dist/session.d.ts +37 -0
  56. package/dist/session.js +143 -6
  57. package/dist/split.js +6 -1
  58. package/dist/statusline.d.ts +3 -1
  59. package/dist/statusline.js +29 -17
  60. package/dist/subagentpay.d.ts +5 -2
  61. package/dist/subagentpay.js +50 -16
  62. package/dist/subagents.d.ts +7 -3
  63. package/dist/subagents.js +28 -8
  64. package/dist/tasks.d.ts +22 -7
  65. package/dist/tasks.js +47 -20
  66. package/dist/wrap.d.ts +6 -0
  67. package/dist/wrap.js +142 -6
  68. package/package.json +1 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # context-doctor 🩺
2
2
 
3
- [![CI](https://github.com/KushalP1/context-doctor/actions/workflows/ci.yml/badge.svg)](https://github.com/KushalP1/context-doctor/actions) [![npm](https://img.shields.io/npm/v/context-doctor)](https://www.npmjs.com/package/context-doctor) [![npm downloads](https://img.shields.io/npm/dm/context-doctor)](https://www.npmjs.com/package/context-doctor) [![MCP Registry](https://img.shields.io/badge/MCP%20Registry-io.github.KushalP1%2Fcontext--doctor-blue)](https://registry.modelcontextprotocol.io) [![license: MIT](https://img.shields.io/badge/license-MIT-blue)](./LICENSE) ![macOS | Linux | Windows](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-lightgrey)
3
+ [![CI](https://github.com/KushalP1/context-doctor/actions/workflows/ci.yml/badge.svg)](https://github.com/KushalP1/context-doctor/actions) [![npm](https://img.shields.io/npm/v/context-doctor)](https://www.npmjs.com/package/context-doctor) [![npm downloads](https://img.shields.io/npm/dm/context-doctor)](https://www.npmjs.com/package/context-doctor) [![MCP Registry](https://img.shields.io/badge/MCP%20Registry-io.github.KushalP1%2Fcontext--doctor-blue)](https://registry.modelcontextprotocol.io) [![AgentHub 已收录:context-doctor](https://myagenthub.cn/badge/io.github.KushalP1/context-doctor)](https://myagenthub.cn/p/io.github.KushalP1/context-doctor) [![license: MIT](https://img.shields.io/badge/license-MIT-blue)](./LICENSE) ![macOS | Linux | Windows](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-lightgrey)
4
4
 
5
5
  **See where your AI agent's tokens go, and stop paying for the ones that do nothing.**
6
6
 
@@ -54,9 +54,9 @@ Pick where you work. Each takes about a minute and is fully reversible.
54
54
 
55
55
  On the author's own Claude Code history (mostly Opus and Fable models with the 1M window, priced at API list prices; `savings` computes yours the same way):
56
56
 
57
- - **Compacting when you come back after the cache expired:** 401 such returns in 133 days; compacting then would have saved **$4,053 net, about $914 a month**. [Details →](#what-it-saves)
58
- - **A 400k auto-compact window instead of ~970k:** **55% less input cost** over 30 days ($6,119 to $2,757), for 15 compactions a week instead of 5. [The trade-off →](#3-compacting-earlier-automatic)
59
- - **Autopilot:** **9.8% less input cost, no session made more expensive.**
57
+ - **Compacting when you come back after the cache expired:** 479 such returns in 133 days; compacting then would have saved **$2,993 net, about $675 a month**. [Details →](#what-it-saves)
58
+ - **A 400k auto-compact window instead of ~970k:** **50% less input cost** over 30 days ($3,648 to $1,814), for 14 compactions a week instead of 5. [The trade-off →](#3-compacting-earlier-automatic)
59
+ - **Autopilot:** **12.7% less input cost, no session made more expensive.**
60
60
  - **pack:** 9 of 10 questions about this repository answered from **0.6% of its tokens**. [How →](#before-it-enters-the-context-pack-and-overhead)
61
61
  - **Counting 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%. `context-doctor accuracy` re-checks the ratio on your own sessions.
62
62
 
@@ -86,39 +86,38 @@ Findings (4)
86
86
 
87
87
  ## What it saves
88
88
 
89
- Measured, not modelled, on every Claude Code session on the author's machine (mostly Opus 5 and Fable 5 with the 1M window), priced the way the prompt cache bills it (cached reads 0.1x, writes 1.25x) at API list prices. `context-doctor savings` runs the same measurement on yours.
89
+ Measured, not modelled, on every Claude Code session on the author's machine (mostly Opus 5 and Fable 5 with the 1M window), priced the way the prompt cache bills it (cached reads at each model's read rate, 2.5-10% of input; writes 1.25x input for the 5-minute cache, 2x for the hour) at API list prices. Re-measured on 2026-10-13 with each request at its own model and current prices (Opus 5.5 is $4 per million input tokens with $0.20 reads). `context-doctor savings` runs the same measurement on yours.
90
90
 
91
- **1. Compacting when you come back** (every Claude Code surface, desktop app included). 133 days, 401 returns to a session over 150k tokens after more than 65 idle minutes; median context at the return, 517k tokens.
91
+ **1. Compacting when you come back** (every Claude Code surface, desktop app included). 133 days, 479 returns to a session over 150k tokens after its prompt cache expired (65 idle minutes on the 1-hour cache, 10 on the 5-minute one that Claude Code 2.1.295 often writes); median context at the return, 522k tokens.
92
92
 
93
93
  | | |
94
94
  |---|---|
95
- | Net saving had you run `/compact` after the first reply | **$4,053 (about $914 a month)** |
96
- | Returns where it paid off | 296 of 401 |
97
- | Worst single case | −$2.30 (a session left a few messages later) |
95
+ | Net saving had you run `/compact` after the first reply | **$2,993 (about $675 a month)** |
96
+ | Returns where it paid off | 342 of 479 |
97
+ | Worst single case | −$1.79 (a session left a few messages later) |
98
98
 
99
99
  Each return counts only the messages up to the next return, and the compaction request is charged as a cost. Not counted: files the model may re-read after compacting, and what the summary leaves out. That is why this is advice with the numbers attached, not something done for you.
100
100
 
101
- **2. Autopilot** (terminal and IDE sessions, API apps). 130 days, 43 sessions, replayed through the shipped autopilot code with real timestamps:
101
+ **2. Autopilot** (terminal and IDE sessions, API apps). 133 days, 67 sessions, replayed through the shipped autopilot code with real timestamps:
102
102
 
103
103
  | | Without autopilot | With autopilot | Saved |
104
104
  |---|---|---|---|
105
- | Input tokens sent | 41.4 billion | 37.6 billion | **3.8 billion (9.2%)** |
106
- | Input cost at API list price | $48,962 | $44,268 | **$4,694 (9.8%)** |
107
- | Sessions made more expensive | | | **0 of 43** |
105
+ | Input cost at API list price | $11,347 | $9,905 | **$1,442 (12.7%)** |
106
+ | Sessions made more expensive | | | **0 of 67** |
108
107
 
109
- 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.
108
+ 30 of 67 sessions save something, the best 34.9%: long sessions that go idle are where the money is. 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.
110
109
 
111
110
  **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:
112
111
 
113
112
  | Window | Input cost, last 30 days | Saved | Compactions a week |
114
113
  |---|---|---|---|
115
- | as now (~970k) | $6,119 | | 5.1 |
116
- | 600k | $3,570 | $2,549 (42%) | 6.5 |
117
- | **400k** | **$2,757** | **$3,362 (55%)** | **14.7** |
118
- | 300k | $2,353 | $3,766 (62%) | 24.5 |
119
- | 200k | $1,963 | $4,155 (68%) | 46.9 |
114
+ | as now (~970k) | $3,648 | | 4.7 |
115
+ | 600k | $2,242 | $1,405 (39%) | 6.5 |
116
+ | **400k** | **$1,814** | **$1,833 (50%)** | **14.2** |
117
+ | 300k | $1,664 | $1,983 (54%) | 25.2 |
118
+ | 200k | $1,501 | $2,146 (59%) | 48.1 |
120
119
 
121
- 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).
120
+ 41 sessions. 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).
122
121
 
123
122
  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.
124
123
 
@@ -180,6 +179,7 @@ MCP servers are part of the same overhead: each one's tool definitions ride on e
180
179
 
181
180
  ## What's new
182
181
 
182
+ - **0.32 A full audit, fixed**: five reviewers read every module against real data; each finding was reproduced, fixed and tested (list in [ROADMAP.md](./ROADMAP.md#fixed-by-a-local-audit-2026-10-13)). The ones that mattered most: `npx context-doctor install` wrote an MCP launcher for a package that does not exist; `uninstall` left Claude Code pointed at a dead proxy port; the optimizer could move one parallel tool result under another's id and drop the note beside them; autopilot could clear output the model had not read; edited histories now respect thinking-block signatures; current prices (Opus 5.5 $4, Sonnet 5.x $2, Haiku 5.5 $0.10) and 1-hour cache writes at 2x; replies counted once and `--days` applied per request. The cold-resume offer follows the cache lifetime a session writes. The figures above were re-measured with it.
183
183
  - **0.31 Save tokens without losing quality**: a PreCompact hook makes every Claude Code compaction keep the session's changed files, open tasks, your instructions and still-failing tests; `context-doctor compactions` measures re-reads, tool errors and corrections before and after each compaction on your history; `compact-window` now charges the re-reads to every compaction it simulates.
184
184
  - **0.30 Where the money goes, and a self-healing service**: `context-doctor subagents` compares each subagent's bill with the same work done in the parent session; `cache-plan` places Anthropic `cache_control` breakpoints in an API request and prices them. Fixes from a local audit: the autopilot service now restarts itself onto a newly installed version, `doctor` no longer writes fake hook checks into the activity ledger, and `<synthetic>` placeholder messages no longer hide a session's model.
185
185
  - **0.29 Python, and where the work goes**: a dependency-free Python package (`python/`) with `pack_documents` for LangChain and LlamaIndex results, `pack_context`, `estimate_tokens` and `profile_messages`, held byte for byte to the TypeScript by parity tests. `context-doctor reads` shows the files read into session after session; `context-doctor tasks` shows cost per commit per project, from your own transcripts.
@@ -258,7 +258,7 @@ Practical upshot: a developer who only wants cheaper, faster API calls never tou
258
258
  | `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 |
259
259
  | `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 |
260
260
  | `context-doctor mcp [--http]` | The MCP server as a subcommand, for clients and registries that launch `npx -y context-doctor mcp` |
261
- | `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 |
261
+ | `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 12.7% less input cost, no session worse |
262
262
  | `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 |
263
263
  | `context-doctor pack <files\|dirs\|-> --query "…"` | Only the chunks of big documents, logs or code a question needs, ranked and fit to `--max-tokens` (default 4000). No query: an outline; `--ids a#2,b#5` picks chunks from it. Offline, no key |
264
264
  | `context-doctor overhead [--days n] [--mcp]` | What every request re-reads before your message: measured first-request size (Claude Code, Codex and Gemini CLI sessions), each memory file (CLAUDE.md and imports, rules, auto memory, AGENTS.md, Cursor rules, GEMINI.md) priced per month, MCP calls per server; `--mcp` sizes each configured server's tool definitions and flags unused ones |
@@ -335,7 +335,7 @@ context-doctor autopilot off # remove it
335
335
 
336
336
  **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.
337
337
 
338
- **Why it cannot make a session more expensive.** A prompt cache matches a byte-identical prefix, and changing old history re-bills everything after the change at the write rate (1.25x instead of 0.1x). So autopilot changes history only when the cache is cold anyway: after an idle gap longer than the cache lifetime the request itself declares (1 hour for Claude Code on a subscription, 5 minutes otherwise), when the whole prompt is re-written regardless. Once cleared, an output stays cleared on every later request, so the prefix is identical between clearings and the cache keeps hitting.
338
+ **Why it cannot make a session more expensive.** A prompt cache matches a byte-identical prefix, and changing old history re-bills everything after the change at the write rate (1.25x or 2x input instead of a read's 2.5-10%). So autopilot changes history only when the cache is cold anyway: after an idle gap longer than the cache lifetime the request itself declares (1 hour for Claude Code on a subscription, 5 minutes otherwise), when the whole prompt is re-written regardless. Once cleared, an output stays cleared on every later request, so the prefix is identical between clearings and the cache keeps hitting. Output the model has not read yet is never cleared. On current Claude models a thinking block is bound to the history before it, and accounts created on or after 2026-08-31 get an error for an edited history; when that happens the proxy resends the request unedited and stops editing that account's histories where thinking follows.
339
339
 
340
340
  **Measured before it shipped**, by replaying every Claude Code session on the author's machine (43 sessions, 130 days) request by request through the shipped code, priced as the cache bills it, with real timestamps: 3.8 billion input tokens not sent and $4,694 at list price, ~$1,080 a month ([details](#what-it-saves)). `scripts/replay-autopilot.mjs` does this on yours:
341
341
 
@@ -345,7 +345,7 @@ context-doctor autopilot off # remove it
345
345
  | Also clear on a warm cache when the saving "should" repay the rewrite | 9.9% | −0.13% (one session worse) |
346
346
  | The proxy's general strategies (dedupe, trim, strip-base64) | 6.7% | −13% (one session worse) |
347
347
 
348
- 94% of input cost on that machine came from requests above 200k tokens, which is where the clearing lands. Smaller requests also mean later auto-compaction and a faster first token.
348
+ Re-measured on 2026-10-13 with current prices, each request at its own model and the audit's fixes (output the model has not read is never cleared, a conversation the proxy has not seen counts as warm): 67 sessions over 133 days, $1,442 of $11,347 at list price (12.7%), no session worse. 94% of input cost on that machine came from requests above 200k tokens, which is where the clearing lands. Smaller requests also mean later auto-compaction and a faster first token.
349
349
 
350
350
  **Never in the way:** anything it cannot parse is forwarded unchanged; only Anthropic `/v1/messages` requests are touched. It adds about 7 ms to a 2.9 MB (~1M token) request. If the proxy dies, the service restarts it within seconds, and the every-prompt hook checks it before each prompt and starts it if needed (measured: 0.6 s once, when it had to). `pause` turns it into a passthrough without restarting anything; `off` removes the setting before stopping the service, and sessions started while it was on need a restart.
351
351
 
@@ -431,7 +431,7 @@ For any other MCP client, the server entry is:
431
431
  "mcpServers": {
432
432
  "context-doctor": {
433
433
  "command": "npx",
434
- "args": ["-y", "context-doctor-mcp"]
434
+ "args": ["-y", "context-doctor", "mcp"]
435
435
  }
436
436
  }
437
437
  }
@@ -768,7 +768,7 @@ For the editor extension: bump `vscode/package.json`, add a `vscode/CHANGELOG.md
768
768
 
769
769
  Locally, `npm run build:mcpb` builds the bundle (`MCPB_SELF_SIGNED=1` exercises the signing path with a throwaway certificate; Desktop still warns for those).
770
770
 
771
- **What the npm download number measures.** `install` writes `npx -y context-doctor-mcp` into MCP configs, and npx re-fetches the tarball whenever a new version exists. So every release is downloaded once by every active install within about a day, and the daily count is almost entirely those refreshes: on this package, release days run ~170 downloads and non-release days ~27. Read it as "size of the active installed base × number of releases", not as new users — a quiet week with no releases will look like a decline while nothing has changed. Two corollaries: the release-day figure is a live count of machines running context-doctor, and a broken release reaches all of them automatically, which is why `prepublishOnly` runs the full test suite. npm's stats also lag by several days and occasionally record a day as zero; a zero on a release day is a gap in their pipeline, not in usage.
771
+ **What the npm download number measures.** `install` run through npx writes `npx -y context-doctor mcp` into MCP configs, and npx re-fetches the tarball whenever a new version exists. So every release is downloaded once by every active install within about a day, and the daily count is almost entirely those refreshes: on this package, release days run ~170 downloads and non-release days ~27. Read it as "size of the active installed base × number of releases", not as new users — a quiet week with no releases will look like a decline while nothing has changed. Two corollaries: the release-day figure is a live count of machines running context-doctor, and a broken release reaches all of them automatically, which is why `prepublishOnly` runs the full test suite. npm's stats also lag by several days and occasionally record a day as zero; a zero on a release day is a gap in their pipeline, not in usage.
772
772
 
773
773
  Known gotcha: if `npm publish` fails with **`404 Not Found - PUT …/context-doctor`** on a package that clearly exists, the real cause is an **expired npm login token** — npm reports unauthenticated publishes as a 404, not a 401. Check with `npm whoami`; if that errors, run `npm login` and publish again.
774
774
 
@@ -51,13 +51,21 @@ export interface AutoClearOptions {
51
51
  * warm clearing off, cold cache only (the never-worse setting).
52
52
  */
53
53
  paybackSafety?: number;
54
- /** Where cleared ids persist so a restart does not un-clear (and re-bill) them. */
54
+ /**
55
+ * Where cleared ids persist so a restart does not un-clear (and re-bill)
56
+ * them, with each conversation's last request time so a restart does not
57
+ * mistake a warm cache for a cold one.
58
+ */
55
59
  statePath?: string;
56
60
  /**
57
- * Treat a conversation this instance has never seen as warm-cached (so
58
- * nothing is cleared on it). The proxy sees every request and leaves this
59
- * off; an in-process wrapper in a fresh process cannot know whether another
60
- * process just sent the conversation, so it turns this on.
61
+ * Treat a conversation this instance has no record of as warm-cached (so
62
+ * nothing is cleared on it). Both the proxy and the in-process wrapper turn
63
+ * this on: a wrapper in a fresh process cannot know whether another process
64
+ * just sent the conversation, and a restarted proxy cannot know either
65
+ * unless the conversation's last request time was persisted (statePath).
66
+ * Calling a warm cache cold pays a full rewrite; calling a cold one warm
67
+ * only delays a free clearing to the next idle gap. Off here so a bare
68
+ * AutoClearer (the savings replay) keeps its first-request-is-cold model.
61
69
  */
62
70
  unseenIsWarm?: boolean;
63
71
  }
@@ -71,7 +79,26 @@ export interface AutoClearResult {
71
79
  reason: string;
72
80
  /** Index of the first message rewritten on this request (the cache breaks from here), or -1. */
73
81
  firstChanged: number;
82
+ /** Ids this request cleared for the first time. */
83
+ newIds: string[];
84
+ /** Every id whose clearing this request carries, earlier batches included. */
85
+ appliedIds: string[];
74
86
  }
87
+ /**
88
+ * Whether a thinking block comes at or after message `index`. On current
89
+ * Claude models (Fable 5.1, Opus 5.5, Sonnet 5.5, Haiku 5.5) a thinking
90
+ * block's signature binds the conversation before it: an edit to an earlier
91
+ * message invalidates every later thinking block, and accounts created on or
92
+ * after 2026-08-31 get a 400 for it ("... bound to a different
93
+ * conversation"), on every retry of the same body.
94
+ */
95
+ export declare function thinkingAfter(messages: unknown[], index: number): boolean;
96
+ /**
97
+ * A request that sets thinking.block_binding has opted into that check (any
98
+ * value, "drop_block" included, which silently drops the reasoning an edit
99
+ * invalidates), so it is treated like an account known to enforce it.
100
+ */
101
+ export declare function optsIntoThinkingCheck(body: Record<string, unknown>): boolean;
75
102
  interface Found {
76
103
  /** Index of the message / input item that holds the result (cache breaks from here). */
77
104
  unit: number;
@@ -93,7 +120,12 @@ interface View {
93
120
  toolName: Map<string, string>;
94
121
  results: Array<Omit<Found, "tokens">>;
95
122
  firstUser: unknown;
96
- firstToolId: string;
123
+ /**
124
+ * Index of the last unit the model wrote (-1 for none). Results after it
125
+ * arrive with this request: the model has not read them yet, so they are
126
+ * never eligible, however many there are (six parallel reads).
127
+ */
128
+ seenUpTo: number;
97
129
  ttlMs: number;
98
130
  }
99
131
  export declare function clearedNote(tokens: number): string;
@@ -114,12 +146,19 @@ export declare class AutoClearer {
114
146
  private convs;
115
147
  private opts;
116
148
  private dirty;
149
+ /** Conversation times changed since the last save (written at most every CONV_SAVE_MS, or on flush). */
150
+ private convsDirty;
151
+ private lastSave;
152
+ /** Accounts (salted credential hashes from the caller) whose API rejected a history autopilot edited. */
153
+ private rejecting;
117
154
  constructor(options?: AutoClearOptions);
118
155
  /**
119
156
  * Rewrite `body` in place: an Anthropic Messages, OpenAI Chat Completions or
120
157
  * OpenAI Responses request. Never throws; anything unrecognised is left alone.
158
+ * `account` names the credential the request carries (the proxy passes a
159
+ * salted hash), for accounts whose API rejects edited history.
121
160
  */
122
- apply(body: Record<string, unknown>, now?: number, modelHint?: string): AutoClearResult;
161
+ apply(body: Record<string, unknown>, now?: number, modelHint?: string, account?: string): AutoClearResult;
123
162
  /**
124
163
  * Warm cache: clearing rewrites everything after the first cleared result
125
164
  * at the write rate (1.25x instead of 0.1x: 1.15x extra, once), and saves
@@ -129,6 +168,18 @@ export declare class AutoClearer {
129
168
  * cover the rewrite `paybackSafety` times over.
130
169
  */
131
170
  private shouldClearWarm;
171
+ /** Write pending state now (the proxy calls this on a timer and before it exits). */
172
+ flush(): void;
173
+ /** Whether this account's API has rejected a history autopilot edited (see thinkingAfter). */
174
+ rejectsEdits(account?: string): boolean;
175
+ /** Remember, across restarts, that this account's API rejects edited history. */
176
+ noteEditsRejected(account: string): void;
177
+ /**
178
+ * Stop applying these clearings: the API refused the request that carried
179
+ * them, and any thinking it produces from now on is bound to the history
180
+ * without them.
181
+ */
182
+ forget(ids: string[]): void;
132
183
  private load;
133
184
  private save;
134
185
  }
package/dist/autoclear.js CHANGED
@@ -55,7 +55,44 @@ export const CLEARABLE_TOOLS = new Set([
55
55
  ]);
56
56
  /** Tokens billed for one image inside a tool result (a full-size screenshot is ~1.6k on Claude). */
57
57
  const IMAGE_TOKENS = 1600;
58
+ /**
59
+ * Whether a thinking block comes at or after message `index`. On current
60
+ * Claude models (Fable 5.1, Opus 5.5, Sonnet 5.5, Haiku 5.5) a thinking
61
+ * block's signature binds the conversation before it: an edit to an earlier
62
+ * message invalidates every later thinking block, and accounts created on or
63
+ * after 2026-08-31 get a 400 for it ("... bound to a different
64
+ * conversation"), on every retry of the same body.
65
+ */
66
+ export function thinkingAfter(messages, index) {
67
+ for (let i = Math.max(0, index); i < messages.length; i++) {
68
+ const content = messages[i]?.content;
69
+ if (Array.isArray(content) && content.some((b) => b?.type === "thinking" || b?.type === "redacted_thinking"))
70
+ return true;
71
+ }
72
+ return false;
73
+ }
74
+ /**
75
+ * A request that sets thinking.block_binding has opted into that check (any
76
+ * value, "drop_block" included, which silently drops the reasoning an edit
77
+ * invalidates), so it is treated like an account known to enforce it.
78
+ */
79
+ export function optsIntoThinkingCheck(body) {
80
+ const thinking = body.thinking;
81
+ return Boolean(thinking && typeof thinking === "object" && thinking.block_binding !== undefined);
82
+ }
58
83
  const HOUR = 3_600_000;
84
+ /**
85
+ * Conversations' last request times are written at most this often (and on
86
+ * flush). A record read back after a crash can be this much older than the
87
+ * truth, so it is read as this much newer: when unsure, warm.
88
+ */
89
+ const CONV_SAVE_MS = 60_000;
90
+ function lastIndex(items, pred) {
91
+ for (let i = items.length - 1; i >= 0; i--)
92
+ if (pred(items[i]))
93
+ return i;
94
+ return -1;
95
+ }
59
96
  export function clearedNote(tokens) {
60
97
  // Not "run it again": a cleared Bash result may come from a command with
61
98
  // side effects (a push, a delete), and repeating that would be wrong.
@@ -101,11 +138,13 @@ function isOpenAIChat(messages) {
101
138
  export function viewOf(body) {
102
139
  const toolName = new Map();
103
140
  const results = [];
104
- let firstToolId = "";
105
141
  // Gemini generateContent: contents[].parts[] with functionCall / functionResponse.
106
- // Ids are optional there, so a missing one is derived from its position
107
- // (stable: history only grows) and scoped to the conversation, because the
108
- // cleared set is keyed by id and positions repeat across conversations.
142
+ // Ids are optional there, so a missing one is derived. Position and the
143
+ // first user message alone repeat whenever the same prompt runs twice, and
144
+ // the cleared set is keyed by id: run 2 then had run 1's clearings applied
145
+ // to its own results, newest included. The response's own content is part
146
+ // of the derived id, so another run's result is another id unless it is
147
+ // byte-identical at the same place (and apply() still requires it eligible).
109
148
  if (Array.isArray(body.contents)) {
110
149
  const units = body.contents;
111
150
  const firstUser = units.find((u) => u?.role === "user")?.parts;
@@ -118,12 +157,12 @@ export function viewOf(body) {
118
157
  const call = p?.functionCall;
119
158
  const resp = p?.functionResponse;
120
159
  if (call && typeof call === "object") {
121
- const id = typeof call.id === "string" && call.id ? call.id : `gemini:${scope}:${i}:${j}`;
122
- toolName.set(id, String(call.name ?? ""));
123
- firstToolId ||= id;
160
+ if (typeof call.id === "string" && call.id)
161
+ toolName.set(call.id, String(call.name ?? ""));
124
162
  }
125
163
  else if (resp && typeof resp === "object") {
126
- const id = typeof resp.id === "string" && resp.id ? resp.id : `gemini:${scope}:${i}:${j}`;
164
+ const content = createHash("sha1").update(JSON.stringify(resp.response ?? "")).digest("hex").slice(0, 12);
165
+ const id = typeof resp.id === "string" && resp.id ? resp.id : `gemini:${scope}:${i}:${j}:${content}`;
127
166
  // The response names its tool, so it classifies on its own.
128
167
  if (!toolName.has(id))
129
168
  toolName.set(id, String(resp.name ?? ""));
@@ -131,7 +170,8 @@ export function viewOf(body) {
131
170
  }
132
171
  });
133
172
  });
134
- return { format: "gemini", units, toolName, results, firstUser, firstToolId, ttlMs: requestTtlMs(body) };
173
+ const seenUpTo = lastIndex(units, (u) => u?.role === "model");
174
+ return { format: "gemini", units, toolName, results, firstUser, seenUpTo, ttlMs: requestTtlMs(body) };
135
175
  }
136
176
  if (Array.isArray(body.input)) {
137
177
  const units = body.input;
@@ -139,19 +179,23 @@ export function viewOf(body) {
139
179
  const t = it?.type;
140
180
  if ((t === "function_call" || t === "custom_tool_call" || t === "local_shell_call") && typeof it.call_id === "string") {
141
181
  toolName.set(it.call_id, t === "local_shell_call" ? "shell" : String(it.name ?? ""));
142
- firstToolId ||= it.call_id;
143
182
  }
144
183
  else if ((t === "function_call_output" || t === "custom_tool_call_output" || t === "local_shell_call_output") && typeof it.call_id === "string") {
145
184
  results.push({ unit: i, id: it.call_id, content: it.output, set: (note) => { units[i] = { ...it, output: note }; } });
146
185
  }
147
186
  });
148
187
  const firstUser = units.find((u) => u?.role === "user")?.content;
149
- return { format: "openai-responses", units, toolName, results, firstUser, firstToolId, ttlMs: requestTtlMs(body) };
188
+ // Model-written items: its tool calls, reasoning and assistant messages.
189
+ // With previous_response_id the input holds none of them, so every result
190
+ // in it is new to the model.
191
+ const seenUpTo = lastIndex(units, (u) => u?.role === "assistant" || ["function_call", "custom_tool_call", "local_shell_call", "reasoning"].includes(String(u?.type)));
192
+ return { format: "openai-responses", units, toolName, results, firstUser, seenUpTo, ttlMs: requestTtlMs(body) };
150
193
  }
151
194
  const messages = body.messages;
152
195
  if (!Array.isArray(messages) || messages.length === 0)
153
196
  return undefined;
154
197
  const firstUser = messages.find((m) => m?.role === "user")?.content;
198
+ const seenUpTo = lastIndex(messages, (m) => m?.role === "assistant");
155
199
  if (isOpenAIChat(messages)) {
156
200
  messages.forEach((m, i) => {
157
201
  if (m?.role === "assistant" && Array.isArray(m.tool_calls)) {
@@ -159,14 +203,13 @@ export function viewOf(body) {
159
203
  if (typeof c?.id !== "string")
160
204
  continue;
161
205
  toolName.set(c.id, String(c.function?.name ?? c.name ?? ""));
162
- firstToolId ||= c.id;
163
206
  }
164
207
  }
165
208
  else if (m?.role === "tool" && typeof m.tool_call_id === "string") {
166
209
  results.push({ unit: i, id: m.tool_call_id, content: m.content, set: (note) => { messages[i] = { ...m, content: note }; } });
167
210
  }
168
211
  });
169
- return { format: "openai-chat", units: messages, toolName, results, firstUser, firstToolId, ttlMs: requestTtlMs(body) };
212
+ return { format: "openai-chat", units: messages, toolName, results, firstUser, seenUpTo, ttlMs: requestTtlMs(body) };
170
213
  }
171
214
  messages.forEach((m, i) => {
172
215
  if (!Array.isArray(m?.content))
@@ -175,25 +218,36 @@ export function viewOf(body) {
175
218
  blocks.forEach((b, j) => {
176
219
  if (m.role === "assistant" && b?.type === "tool_use" && typeof b.id === "string") {
177
220
  toolName.set(b.id, String(b.name ?? ""));
178
- firstToolId ||= b.id;
179
221
  }
180
222
  else if (m.role === "user" && b?.type === "tool_result" && typeof b.tool_use_id === "string") {
181
223
  results.push({ unit: i, id: b.tool_use_id, content: b.content, set: (note) => { blocks[j] = { ...b, content: note }; } });
182
224
  }
183
225
  });
184
226
  });
185
- return { format: "anthropic", units: messages, toolName, results, firstUser, firstToolId, ttlMs: requestTtlMs(body) };
227
+ return { format: "anthropic", units: messages, toolName, results, firstUser, seenUpTo, ttlMs: requestTtlMs(body) };
186
228
  }
187
229
  /**
188
- * A conversation's identity: its first user message plus its first tool call
189
- * id. The message alone can repeat across sessions (the same project context
190
- * opens every session in a folder); tool call ids are unique per session.
230
+ * A conversation's identity, the same from its first request on: the first
231
+ * user message, plus the client's own session tag when it sends one
232
+ * (Anthropic metadata.user_id, which Claude Code fills per session; OpenAI
233
+ * prompt_cache_key). The first user message is read without cache_control:
234
+ * Claude Code marks the last message, which on request 1 is the first one,
235
+ * and moves the marker on request 2. Through 0.31.1 the key also held the
236
+ * first tool call id, so request 2 (the first tool round) was a new,
237
+ * never-seen conversation, called cold eight seconds after request 1.
238
+ * Two sessions that open with the same message and send no tag share a key;
239
+ * that only ever makes one look warm when the other is, the safe direction.
191
240
  * The model is left out on purpose: switching models mid-session keeps the
192
241
  * conversation (and the cleared set) but not the cache, which the TTL check
193
242
  * cannot see, so a model switch is treated as warm, the safe direction.
194
243
  */
195
- function conversationKey(view) {
196
- return createHash("sha1").update(JSON.stringify(view.firstUser ?? "").slice(0, 4000)).update(view.firstToolId).digest("hex");
244
+ function conversationKey(view, body) {
245
+ const first = JSON.stringify(view.firstUser ?? "", (k, v) => (k === "cache_control" ? undefined : v)) ?? "";
246
+ const h = createHash("sha1").update(first.slice(0, 4000));
247
+ const tag = body.metadata?.user_id ?? body.prompt_cache_key;
248
+ if (typeof tag === "string")
249
+ h.update("\0").update(tag);
250
+ return h.digest("hex");
197
251
  }
198
252
  export class AutoClearer {
199
253
  cleared = new Map();
@@ -202,6 +256,11 @@ export class AutoClearer {
202
256
  convs = new Map();
203
257
  opts;
204
258
  dirty = false;
259
+ /** Conversation times changed since the last save (written at most every CONV_SAVE_MS, or on flush). */
260
+ convsDirty = false;
261
+ lastSave = 0;
262
+ /** Accounts (salted credential hashes from the caller) whose API rejected a history autopilot edited. */
263
+ rejecting = new Set();
205
264
  constructor(options = {}) {
206
265
  this.opts = {
207
266
  keepRecent: options.keepRecent ?? 3,
@@ -215,21 +274,26 @@ export class AutoClearer {
215
274
  /**
216
275
  * Rewrite `body` in place: an Anthropic Messages, OpenAI Chat Completions or
217
276
  * OpenAI Responses request. Never throws; anything unrecognised is left alone.
277
+ * `account` names the credential the request carries (the proxy passes a
278
+ * salted hash), for accounts whose API rejects edited history.
218
279
  */
219
- apply(body, now = Date.now(), modelHint) {
220
- const none = (reason, cold = false) => ({ changed: false, newlyCleared: 0, tokensRemoved: 0, cold, reason, firstChanged: -1 });
280
+ apply(body, now = Date.now(), modelHint, account) {
281
+ const none = (reason, cold = false) => ({ changed: false, newlyCleared: 0, tokensRemoved: 0, cold, reason, firstChanged: -1, newIds: [], appliedIds: [] });
221
282
  try {
222
283
  const view = viewOf(body);
223
284
  if (!view)
224
285
  return none("no messages");
225
286
  // Gemini puts the model in the URL, not the body; the proxy passes it.
226
287
  const model = typeof body.model === "string" ? body.model : modelHint;
227
- const key = conversationKey(view);
288
+ const key = conversationKey(view, body);
228
289
  const conv = this.convs.get(key);
229
290
  const cold = conv ? now - conv.last > view.ttlMs : !this.opts.unseenIsWarm;
291
+ // Delete first so the map stays in last-used order and the cap evicts idle conversations.
292
+ this.convs.delete(key);
230
293
  this.convs.set(key, { last: now, requests: (conv?.requests ?? 0) + 1 });
231
294
  if (this.convs.size > 2000)
232
295
  this.convs.delete(this.convs.keys().next().value);
296
+ this.convsDirty = true;
233
297
  const results = view.results.map((r) => {
234
298
  let tokens = this.sizes.get(r.id);
235
299
  if (tokens === undefined) {
@@ -240,8 +304,11 @@ export class AutoClearer {
240
304
  }
241
305
  return { ...r, tokens };
242
306
  });
307
+ // Results that arrive with this request (after the model's last turn)
308
+ // are not eligible however many there are: the model has not read them.
243
309
  const eligible = results.slice(0, Math.max(0, results.length - this.opts.keepRecent))
244
- .filter((r) => CLEARABLE_TOOLS.has(view.toolName.get(r.id) ?? ""));
310
+ .filter((r) => r.unit <= view.seenUpTo && CLEARABLE_TOOLS.has(view.toolName.get(r.id) ?? ""));
311
+ const eligibleIds = new Set(eligible.map((r) => r.id));
245
312
  const pending = eligible.filter((r) => !this.cleared.has(r.id) && r.tokens > 200);
246
313
  const pendingTokens = pending.reduce((n, r) => n + r.tokens, 0);
247
314
  let reason = "nothing to clear";
@@ -262,17 +329,29 @@ export class AutoClearer {
262
329
  else if (pendingTokens > 0) {
263
330
  reason = `waiting for a ${Math.round(this.opts.minClearTokens / 1000)}k batch`;
264
331
  }
332
+ // An account that rejects edited history gets no NEW clearing a
333
+ // thinking block comes after: that block is bound to the history as it
334
+ // was. Clearings already sent keep being applied, because thinking
335
+ // generated since then is bound to the cleared history; dropping them
336
+ // would break the conversation the other way.
337
+ if (taken.length && (this.rejectsEdits(account) || optsIntoThinkingCheck(body)) && thinkingAfter(view.units, Math.min(...taken.map((r) => r.unit)))) {
338
+ taken = [];
339
+ reason = "thinking blocks follow the stale output and this account rejects edited history: left as is";
340
+ }
265
341
  for (const r of taken)
266
342
  this.cleared.set(r.id, r.tokens);
267
343
  if (taken.length)
268
344
  this.dirty = true;
269
345
  // Apply every clearing ever decided for this conversation, so the
270
- // prefix is identical to what was sent last time.
346
+ // prefix is identical to what was sent last time. A remembered id must
347
+ // still be eligible here: a derived id (Gemini without ids) can recur in
348
+ // another conversation, where it must not cost that one its newest result.
271
349
  let removed = 0;
272
350
  let changed = false;
273
351
  let firstChanged = -1;
352
+ const appliedIds = [];
274
353
  for (const r of results) {
275
- if (!this.cleared.has(r.id))
354
+ if (!this.cleared.has(r.id) || !eligibleIds.has(r.id))
276
355
  continue;
277
356
  const note = clearedNote(this.cleared.get(r.id));
278
357
  if (r.content === note)
@@ -280,11 +359,12 @@ export class AutoClearer {
280
359
  removed += Math.max(0, r.tokens - estimateTokens(note, model));
281
360
  r.set(note);
282
361
  changed = true;
362
+ appliedIds.push(r.id);
283
363
  if (firstChanged === -1 || r.unit < firstChanged)
284
364
  firstChanged = r.unit;
285
365
  }
286
366
  this.save();
287
- return { changed, newlyCleared: taken.length, tokensRemoved: removed, cold, reason, firstChanged };
367
+ return { changed, newlyCleared: taken.length, tokensRemoved: removed, cold, reason, firstChanged, newIds: taken.map((r) => r.id), appliedIds };
288
368
  }
289
369
  catch {
290
370
  return none("error: request forwarded unchanged");
@@ -307,6 +387,36 @@ export class AutoClearer {
307
387
  const saving = 0.1 * pendingTokens * requestsSoFar;
308
388
  return saving >= this.opts.paybackSafety * rewriteCost;
309
389
  }
390
+ /** Write pending state now (the proxy calls this on a timer and before it exits). */
391
+ flush() {
392
+ this.save(true);
393
+ }
394
+ /** Whether this account's API has rejected a history autopilot edited (see thinkingAfter). */
395
+ rejectsEdits(account) {
396
+ return account !== undefined && this.rejecting.has(account);
397
+ }
398
+ /** Remember, across restarts, that this account's API rejects edited history. */
399
+ noteEditsRejected(account) {
400
+ if (this.rejecting.has(account))
401
+ return;
402
+ this.rejecting.add(account);
403
+ this.dirty = true;
404
+ this.save();
405
+ }
406
+ /**
407
+ * Stop applying these clearings: the API refused the request that carried
408
+ * them, and any thinking it produces from now on is bound to the history
409
+ * without them.
410
+ */
411
+ forget(ids) {
412
+ let any = false;
413
+ for (const id of ids)
414
+ any = this.cleared.delete(id) || any;
415
+ if (any) {
416
+ this.dirty = true;
417
+ this.save();
418
+ }
419
+ }
310
420
  load() {
311
421
  if (!this.opts.statePath || !existsSync(this.opts.statePath))
312
422
  return;
@@ -314,20 +424,35 @@ export class AutoClearer {
314
424
  const data = JSON.parse(readFileSync(this.opts.statePath, "utf8"));
315
425
  for (const [id, tokens] of data.cleared ?? [])
316
426
  this.cleared.set(id, tokens);
427
+ for (const account of data.rejectsEdits ?? [])
428
+ if (typeof account === "string")
429
+ this.rejecting.add(account);
430
+ // Each conversation's last request time, so a restart does not make
431
+ // every open conversation "unseen" (cold) and rewrite a warm cache.
432
+ for (const [key, last, requests] of data.convs ?? []) {
433
+ if (typeof key === "string" && typeof last === "number")
434
+ this.convs.set(key, { last: last + CONV_SAVE_MS, requests: Number(requests) || 1 });
435
+ }
317
436
  }
318
437
  catch {
319
438
  /* a corrupt state file only means results may be re-cleared once */
320
439
  }
321
440
  }
322
- save() {
323
- if (!this.dirty || !this.opts.statePath)
441
+ save(force = false) {
442
+ if (!this.opts.statePath)
443
+ return;
444
+ const due = this.convsDirty && (force || Date.now() - this.lastSave >= CONV_SAVE_MS);
445
+ if (!this.dirty && !due)
324
446
  return;
325
447
  this.dirty = false;
448
+ this.convsDirty = false;
449
+ this.lastSave = Date.now();
326
450
  try {
327
451
  // Newest 50k ids: far more than any live session holds.
328
452
  const entries = [...this.cleared.entries()].slice(-50_000);
453
+ const convs = [...this.convs.entries()].map(([k, c]) => [k, c.last, c.requests]);
329
454
  mkdirSync(dirname(this.opts.statePath), { recursive: true });
330
- writeFileSync(this.opts.statePath, JSON.stringify({ cleared: entries }));
455
+ writeFileSync(this.opts.statePath, JSON.stringify({ cleared: entries, convs, rejectsEdits: [...this.rejecting] }));
331
456
  }
332
457
  catch {
333
458
  /* persistence is best effort */