context-doctor 0.26.0 → 0.27.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
@@ -2,18 +2,27 @@
2
2
 
3
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) [![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
- **Keep every AI session's context lean, and see exactly what it costs you, without ever making it more expensive.**
5
+ **Cut your Claude Code bill, measured on your own sessions.**
6
+
7
+ ```bash
8
+ npx context-doctor savings
9
+ ```
10
+
11
+ One command shows, in dollars, what your recent sessions cost and what each lever below would have saved on them. Then the levers do it: an every-prompt hook, an auto-compact window, and a proxy that never makes a request more expensive.
6
12
 
7
13
  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
14
 
9
15
  - **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
16
  - **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)
11
17
  - **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.)
18
+ - **Keeps waste out in the first place.** `context-doctor pack` (and the `pack_context` MCP tool) puts only the chunks of a big document, log or codebase that a question needs into the context, within a token budget: 9 of 10 questions about this repo answered from 0.6% of its tokens. `context-doctor overhead` measures what every request re-reads before your message (system prompt, tools, CLAUDE.md, rules, memory) and prices each memory file per month. [How →](#before-it-enters-the-context-pack-and-overhead)
12
19
  - **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.
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.
20
+ - **Works where you work:** Claude Code (terminal, IDE, desktop app, plugin), Cursor, Codex, Gemini CLI, Claude Desktop chat, any Anthropic or OpenAI API app, VS Code, CI. macOS, Linux and Windows, Node 20+. Local, keyless, no telemetry, MIT.
14
21
 
15
22
  Built and maintained by [gAI Ventures](https://gai.ventures).
16
23
 
24
+ > **Which context-doctor is this?** Several projects share the name. This one is [`context-doctor` on npm](https://www.npmjs.com/package/context-doctor) and `io.github.KushalP1/context-doctor` in the [official MCP Registry](https://registry.modelcontextprotocol.io): the one you run as `npx context-doctor`, and the one that prices what it saves on your own history.
25
+
17
26
  ## Quick start
18
27
 
19
28
  **See what it would save you first** (no install, reads your local Claude Code history):
@@ -24,7 +33,7 @@ npx context-doctor savings
24
33
 
25
34
  <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">
26
35
 
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.
36
+ That is the author's machine: every session there runs in the desktop app, which autopilot cannot reach, so lines 1 and 3 are the ones that apply. 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.
28
37
 
29
38
  **Then install it**, whichever way suits you:
30
39
 
@@ -107,15 +116,57 @@ On a Claude subscription you do not pay list price; the same tokens come out of
107
116
  | **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
117
  | **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)) |
109
118
  | **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`) |
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 |
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 |
119
+ | **Gemini CLI** | Every-prompt hook (`BeforeAgent`) with the API's own token counts. On an API key, autopilot too: `GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:8787` (Google sign-in traffic goes elsewhere) | MCP tools, `session` and `watch` read its chats, `overhead` sizes GEMINI.md |
120
+ | **Your own apps on the Anthropic, OpenAI or Gemini API** | Autopilot on `/v1/messages`, `/v1/chat/completions`, `/v1/responses` and Gemini's `generateContent` (`ANTHROPIC_BASE_URL` / `OPENAI_BASE_URL` / `GOOGLE_GEMINI_BASE_URL`), or the full optimizing proxy; no proxy possible (serverless, edge)? `withContextDoctor(client)` runs autopilot inside the SDK client | Exact usage and cache hit rates in `/stats`, prompt-cache placement advice |
121
+ | **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; `pack_context` instead of reading a big local file whole | One-click `.mcpb` install, `context_checkup` prompt |
112
122
  | **claude.ai, ChatGPT, the phone apps** | Your account's standing preferences (`context-doctor instructions --copy`) | Profile an exported chat with `analyze` |
113
123
  | **CI** | `analyze --fail-over-budget` fails a build whose prompts outgrow a budget | `.contextdoctorrc` budgets and presets |
114
124
 
115
125
  Not claimed, because no process on your machine sends those requests: trimming inside Claude Desktop chat, claude.ai, ChatGPT, Cursor's own subscription models, or Codex signed in with ChatGPT. Those get the rules and the measurements above, not autopilot.
116
126
 
127
+ ## Before it enters the context: pack and overhead
128
+
129
+ Everything above slims context that is already there. Two commands stop it getting in.
130
+
131
+ **`pack`: only the parts of a document a question needs.** A 60k-token manual pasted to answer one question is paid on that message and re-read on every message after it. `pack` splits files and folders along their own structure (markdown headings, top-level code declarations, paragraphs; one-line logs and minified files are cut too), ranks the chunks against your question with BM25, and returns the best ones that fit a budget. Every chunk carries an id and its line range, and the result names the next-best ids, so asking for more is one call. Without a question you get an outline to pick from.
132
+
133
+ ```bash
134
+ context-doctor pack docs/ README.md --query "how do I rotate the signing key" --max-tokens 3000
135
+ context-doctor pack big-manual.md # outline: id, lines, tokens, heading per chunk
136
+ context-doctor pack big-manual.md --ids big-manual.md#12,big-manual.md#13
137
+ cat server.log | context-doctor pack - -q "timeout upstream"
138
+ ```
139
+
140
+ ```
141
+ $ context-doctor pack README.md src -q "how is the ledger kept from growing" --max-tokens 2000
142
+ PACKED ~1.9k of ~315k tokens (1%) from 80 files: 7 of 1026 chunks for "how is the ledger kept from growing", budget 2.0k.
143
+
144
+ ── src/ledger.ts#6 · L108-128 · export function recordLedger(entry: Omit<LedgerEntry, "ts">): void { · ~404
145
+ ...
146
+ ── src/test/ledger.test.ts#1 · L1-22 · The ledger is capped, and everything it feeds is a LIFETIME total. · ~409
147
+ ...
148
+ Not included: 1019 chunk(s). Next best: src/watch.ts#1 (...); src/test/compactwindow.test.ts#4 (...)
149
+ ```
150
+
151
+ In Claude Desktop, Claude Code, Cursor and Codex the same thing is the `pack_context` MCP tool, and the standing instructions tell the model to use it when it needs to answer from a large file it is not editing. Ranking is lexical, offline and keyless; it misses pure paraphrase, which is what the outline is for. If you run [Ollama](https://ollama.com) with an embedding model (`ollama pull nomic-embed-text`), `--semantic` (or `semantic: true` on the MCP tool) also ranks by meaning and merges the two rankings; chunk vectors are cached, so repeat packs are fast. Without one it says so and stays lexical. Measured on this repository (README plus source, ~305k tokens): 9 of 10 factual questions were answered from a 2k-token pack. PDFs, Word (`.docx`, `.doc`, `.rtf`), PowerPoint and ODT files are read with tools the OS already has (`pdftotext` where installed, PDFKit and `textutil` on macOS, `unzip` elsewhere); when none is available, pack says what to install. Over HTTP (the ChatGPT connector mode) the tool reads no files, only text you pass.
152
+
153
+ **`overhead`: what every request re-reads before your message.** The system prompt, tool and MCP schemas, skills and your memory files ride on every request: cached, so each read is cheap, but read on every request of every session and written again at full price after each cold start. Transcripts never show them. `overhead` measures the fixed part from the first request of each recent Claude Code session (the API's own count, minus your first message), lists every memory file each agent loads in the current directory (Claude Code's CLAUDE.md with its `@imports`, rules and auto memory; Codex's AGENTS.md; Cursor's always-applied rules; GEMINI.md), and prices each per month from your own request and cold-start counts.
154
+
155
+ ```
156
+ Claude Code, last 30 days: 71 sessions start at a median ~54k tokens (p90 ~62k) before the first message.
157
+ Memory files loaded here are ~3.4k of that; the rest is Claude Code's system prompt, tools, skills and MCP schemas.
158
+ At your usage (14,104 requests, 249 cold starts, claude-fable-5-1 list prices) every 1k tokens of it costs ~$16.97 a month;
159
+ ```
160
+
161
+ Findings point at files over 2k tokens, paragraphs loaded twice from two files, and long code blocks that could live in a file the agent opens when it needs them.
162
+
163
+ To act on a finding, `context-doctor overhead split <file>` plans the fix: sections over ~400 tokens (or mostly code) move word for word to `<name>.reference.md`, each run of them leaving one pointer the agent follows when a task needs it. Lists of links are indexes and stay, and an auto-memory `MEMORY.md` keeps only its index, as Claude Code intends. Nothing is written until you add `--write`, which backs up the original first. On a copy of the author's auto-memory index: ~3.4k to ~1.7k tokens on every request, about $28 a month, nothing lost.
164
+
165
+ MCP servers are part of the same overhead: each one's tool definitions ride on every request. `overhead` counts calls per server from your transcripts, including servers the app or its connectors provide, and `overhead --mcp` launches each configured server once to size its definitions, flagging the ones you never call with what they cost a month. When Claude Code's tool search is on (the report says how often it was used), definitions are deferred and an idle server costs about its tool names instead.
166
+
117
167
  ## What's new
118
168
 
169
+ - **0.27 Keep waste out**: `pack` and the `pack_context` MCP tool put only the chunks of big files (text, code, PDF, Word, PowerPoint) a question needs into the context, within a budget; `overhead` measures and prices the front matter every request re-reads (system prompt, tools, CLAUDE.md, rules, memory, AGENTS.md, Cursor rules, GEMINI.md). MCP servers sized and their use counted; `overhead split` turns a heavy memory file into a lean one plus a reference file. `withContextDoctor(client)` runs autopilot inside an Anthropic or OpenAI SDK client, for apps with no proxy. Gemini joins: `install` wires Gemini CLI's MCP server and every-prompt hook, `session` and `watch` read its chats, and the proxy and autopilot carry Google's Gemini API. A GitHub Action (and `context-doctor ci`) reports memory-file growth on every pull request and gates on a budget. Re-verified on every surface: 229 tests on macOS, Linux and Windows, plus MCP, OpenAI and Gemini end-to-end smoke runs, now in CI, MCP over stdio and HTTP, the OpenAI proxy, and a live `doctor`.
119
170
  - **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
171
  - **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.
121
172
  - **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.
@@ -191,6 +242,10 @@ Practical upshot: a developer who only wants cheaper, faster API calls never tou
191
242
  | `context-doctor mcp [--http]` | The MCP server as a subcommand, for clients and registries that launch `npx -y context-doctor mcp` |
192
243
  | `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 |
193
244
  | `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 |
245
+ | `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 |
246
+ | `context-doctor overhead [--days n] [--mcp]` | What every request re-reads before your message: measured first-request size, 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 |
247
+ | `context-doctor overhead split <file> [--write]` | Move a memory file's big or code-heavy sections word for word to `<name>.reference.md`, leaving pointers; indexes stay. A plan until `--write`, which backs up first |
248
+ | `context-doctor ci [--base <ref>] [--max-tokens n] [--max-increase n]` | Memory files (CLAUDE.md, AGENTS.md, GEMINI.md, rules, their imports) at HEAD vs a base commit; exit 1 over budget. What the GitHub Action runs |
194
249
  | `context-doctor analyze <file>` | Profile a conversation: token breakdown, findings, cost + latency estimates. `--fail-over-budget` exits 1 on a breach, for CI |
195
250
  | `context-doctor optimize <file>` | Apply the safe fixes; add `--strategy trim-tool-calls` for big inline file writes, `--strategy prune-history` for consented lossy compaction |
196
251
  | `context-doctor session [file]` | Profile a Claude Code session: live context, findings, **measured tokens and prompt-cache economics**, **where the wall clock went** per tool, and **what its subagents cost** (their own windows, your bill; never in the parent's profile). Also reads ChatGPT data exports (`conversations.json`) |
@@ -395,6 +450,7 @@ Your local MCP server can't reach the website, but the behavior can. Two options
395
450
  |---|---|
396
451
  | `profile_context` | Token breakdown by category, largest messages, findings with estimated savings. Takes either `conversation` (full JSON or text) or `sketch` (turns + large/repeated blocks, for chat apps) |
397
452
  | `optimize_context` | Rewrites the conversation: dedupe, trim stale tool results, strip base64, optional history pruning |
453
+ | `pack_context` | Only the chunks of large local files or folders (or passed `text`) that a question needs, within `max_tokens`; outline without a query; `ids` to fetch more. Reads no files over HTTP |
398
454
  | `context_best_practices` | Curated checklist, optionally specialized for Anthropic / OpenAI |
399
455
 
400
456
  ## Use it as a library
@@ -408,8 +464,24 @@ console.log(profile.totalTokens, profile.findings);
408
464
  const { conversation, tokensBefore, tokensAfter } = optimizeConversation(chatJson, {
409
465
  strategies: ["dedupe", "trim-tool-results", "strip-base64"],
410
466
  });
467
+
468
+ // Autopilot without a proxy: wrap the SDK client (Anthropic or OpenAI)
469
+ import Anthropic from "@anthropic-ai/sdk";
470
+ import { withContextDoctor, contextDoctorStats } from "context-doctor";
471
+ const client = withContextDoctor(new Anthropic()); // use exactly as before
472
+ await client.messages.create({ model, max_tokens, messages });
473
+ console.log(contextDoctorStats(client)); // { requests, changed, tokensRemoved }
474
+
475
+ // RAG-style context packing: chunk, rank, fit a budget (no embeddings, no key)
476
+ import { packContext, renderPack } from "context-doctor";
477
+ const packed = packContext([{ name: "manual.md", text: manual }], { query: "rotate the signing key", budget: 3000 });
478
+ console.log(packed.selected.map((c) => `${c.id} L${c.startLine}-${c.endLine}`), renderPack(packed));
411
479
  ```
412
480
 
481
+ ### Autopilot inside your app: `withContextDoctor`
482
+
483
+ For apps that cannot send traffic through a local proxy (serverless and edge functions, managed hosts), wrap the SDK client. `messages.create` / `stream`, `chat.completions.create` / `stream` / `parse` and `responses.create` / `stream` / `parse` (beta namespaces too) go through the same autopilot as the proxy: stale tool output is cleared only when the prompt cache is cold. Each request is cloned before it changes, so the history your app holds is never rewritten; only the copy on the wire is slimmer. State lives in the process: one long-lived process per conversation stream behaves like the proxy, and a conversation the process has not seen counts as warm, so a fresh serverless instance never clears blind (pass `statePath` on shared storage if many instances serve the same conversations). Tested with the real `@anthropic-ai/sdk` and `openai` packages.
484
+
413
485
  ## Subagents: their own windows, your bill
414
486
 
415
487
  A subagent has its own context window, so its tokens are correctly absent from the parent's profile. They are not absent from the bill. Claude Code writes each one to `<session>/subagents/agent-<id>.jsonl`, and `session` now reads them:
@@ -573,6 +645,29 @@ A cache read bills at ~10% of input while a write bills at ~125%, so a session t
573
645
 
574
646
  ## Enforce a budget in CI
575
647
 
648
+ **Memory files on every pull request.** CLAUDE.md, AGENTS.md, GEMINI.md and rules are re-read by every request of everyone working in the repository, so their growth deserves review. The repository is also a GitHub Action:
649
+
650
+ ```yaml
651
+ # .github/workflows/context.yml
652
+ on: pull_request
653
+ permissions:
654
+ contents: read
655
+ pull-requests: write # for the comment
656
+ jobs:
657
+ memory:
658
+ runs-on: ubuntu-latest
659
+ steps:
660
+ - uses: actions/checkout@v4
661
+ - uses: KushalP1/context-doctor@v0.27.0
662
+ with:
663
+ max-increase: "2000" # fail a PR that adds more than 2k tokens
664
+ requests-per-day: "5000" # optional: price the change
665
+ ```
666
+
667
+ It compares the memory files and their `@imports` at the PR head with the base branch, writes the table to the job summary, keeps one PR comment up to date, and fails over budget. Locally: `context-doctor ci --base origin/main`.
668
+
669
+ **Conversation fixtures.**
670
+
576
671
  ```bash
577
672
  npx context-doctor analyze conversation.json --fail-over-budget
578
673
  ```
@@ -628,7 +723,7 @@ One honest caveat worth knowing: a transcript stores the conversation, **not** t
628
723
 
629
724
  ## Roadmap
630
725
 
631
- See [ROADMAP.md](./ROADMAP.md) for the full plan with rationale. Headlines: **v0.5** trust & automation (tag-based publishing, `doctor` self-check, live `watch`), **v0.6** accuracy (exact tokenizers, semantic dedupe, more session formats), **v0.7** proxy pro (response accounting, prompt-cache advisor), **v1.0** budgets + local dashboard. Non-goals, permanently: cloud services, telemetry, silent history rewriting, mandatory API keys.
726
+ See [ROADMAP.md](./ROADMAP.md) for the full plan, the per-surface coverage table and the measurements behind every shipped item. Next, in order of expected saving: the MCP schema tax per server in `overhead`; a Python package for RAG pipelines. Non-goals, permanently: cloud services, telemetry, silent history rewriting, mandatory API keys.
632
727
 
633
728
  Contributions welcome — this project is small on purpose. Open an issue before a big PR.
634
729
 
@@ -53,6 +53,13 @@ export interface AutoClearOptions {
53
53
  paybackSafety?: number;
54
54
  /** Where cleared ids persist so a restart does not un-clear (and re-bill) them. */
55
55
  statePath?: string;
56
+ /**
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
+ */
62
+ unseenIsWarm?: boolean;
56
63
  }
57
64
  export interface AutoClearResult {
58
65
  changed: boolean;
@@ -81,7 +88,7 @@ interface Found {
81
88
  * as Codex with an API key sends).
82
89
  */
83
90
  interface View {
84
- format: "anthropic" | "openai-chat" | "openai-responses";
91
+ format: "anthropic" | "openai-chat" | "openai-responses" | "gemini";
85
92
  units: Array<Record<string, unknown>>;
86
93
  toolName: Map<string, string>;
87
94
  results: Array<Omit<Found, "tokens">>;
@@ -112,7 +119,7 @@ export declare class AutoClearer {
112
119
  * Rewrite `body` in place: an Anthropic Messages, OpenAI Chat Completions or
113
120
  * OpenAI Responses request. Never throws; anything unrecognised is left alone.
114
121
  */
115
- apply(body: Record<string, unknown>, now?: number): AutoClearResult;
122
+ apply(body: Record<string, unknown>, now?: number, modelHint?: string): AutoClearResult;
116
123
  /**
117
124
  * Warm cache: clearing rewrites everything after the first cleared result
118
125
  * at the write rate (1.25x instead of 0.1x: 1.15x extra, once), and saves
package/dist/autoclear.js CHANGED
@@ -48,6 +48,8 @@ export const CLEARABLE_TOOLS = new Set([
48
48
  // Codex and other OpenAI-API agents. Codex's code mode runs every tool from
49
49
  // one `exec` cell and polls long commands with `wait` (as Bash/BashOutput).
50
50
  "shell", "exec_command", "local_shell", "read_file", "list_dir", "grep_search", "file_search", "web_search", "exec", "wait",
51
+ // Gemini CLI
52
+ "read_many_files", "run_shell_command", "glob", "search_file_content", "list_directory", "web_fetch", "google_web_search",
51
53
  // Cursor's agent
52
54
  "Shell", "ReadFile", "rg", "run_terminal_cmd", "codebase_search", "SemanticSearch", "ReadLints", "AwaitShell",
53
55
  ]);
@@ -84,6 +86,9 @@ function resultTokens(content, model) {
84
86
  * the request sets prompt_cache_retention "24h".
85
87
  */
86
88
  export function requestTtlMs(body) {
89
+ // Gemini: implicit caching's lifetime is not published, explicit caches default to an hour.
90
+ if (Array.isArray(body.contents))
91
+ return HOUR;
87
92
  const openai = Array.isArray(body.input) || (Array.isArray(body.messages) && isOpenAIChat(body.messages));
88
93
  if (openai)
89
94
  return body.prompt_cache_retention === "24h" ? 24 * HOUR : HOUR;
@@ -97,6 +102,37 @@ export function viewOf(body) {
97
102
  const toolName = new Map();
98
103
  const results = [];
99
104
  let firstToolId = "";
105
+ // 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.
109
+ if (Array.isArray(body.contents)) {
110
+ const units = body.contents;
111
+ const firstUser = units.find((u) => u?.role === "user")?.parts;
112
+ const scope = createHash("sha1").update(JSON.stringify(firstUser ?? "").slice(0, 4000)).digest("hex").slice(0, 10);
113
+ units.forEach((u, i) => {
114
+ if (!Array.isArray(u?.parts))
115
+ return;
116
+ const parts = u.parts;
117
+ parts.forEach((p, j) => {
118
+ const call = p?.functionCall;
119
+ const resp = p?.functionResponse;
120
+ 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;
124
+ }
125
+ else if (resp && typeof resp === "object") {
126
+ const id = typeof resp.id === "string" && resp.id ? resp.id : `gemini:${scope}:${i}:${j}`;
127
+ // The response names its tool, so it classifies on its own.
128
+ if (!toolName.has(id))
129
+ toolName.set(id, String(resp.name ?? ""));
130
+ results.push({ unit: i, id, content: resp.response, set: (note) => { parts[j] = { ...p, functionResponse: { ...resp, response: { output: note } } }; } });
131
+ }
132
+ });
133
+ });
134
+ return { format: "gemini", units, toolName, results, firstUser, firstToolId, ttlMs: requestTtlMs(body) };
135
+ }
100
136
  if (Array.isArray(body.input)) {
101
137
  const units = body.input;
102
138
  units.forEach((it, i) => {
@@ -171,6 +207,7 @@ export class AutoClearer {
171
207
  keepRecent: options.keepRecent ?? 3,
172
208
  minClearTokens: options.minClearTokens ?? 20_000,
173
209
  paybackSafety: options.paybackSafety ?? 0,
210
+ unseenIsWarm: options.unseenIsWarm ?? false,
174
211
  statePath: options.statePath,
175
212
  };
176
213
  this.load();
@@ -179,16 +216,17 @@ export class AutoClearer {
179
216
  * Rewrite `body` in place: an Anthropic Messages, OpenAI Chat Completions or
180
217
  * OpenAI Responses request. Never throws; anything unrecognised is left alone.
181
218
  */
182
- apply(body, now = Date.now()) {
219
+ apply(body, now = Date.now(), modelHint) {
183
220
  const none = (reason, cold = false) => ({ changed: false, newlyCleared: 0, tokensRemoved: 0, cold, reason, firstChanged: -1 });
184
221
  try {
185
222
  const view = viewOf(body);
186
223
  if (!view)
187
224
  return none("no messages");
188
- const model = typeof body.model === "string" ? body.model : undefined;
225
+ // Gemini puts the model in the URL, not the body; the proxy passes it.
226
+ const model = typeof body.model === "string" ? body.model : modelHint;
189
227
  const key = conversationKey(view);
190
228
  const conv = this.convs.get(key);
191
- const cold = !conv || now - conv.last > view.ttlMs;
229
+ const cold = conv ? now - conv.last > view.ttlMs : !this.opts.unseenIsWarm;
192
230
  this.convs.set(key, { last: now, requests: (conv?.requests ?? 0) + 1 });
193
231
  if (this.convs.size > 2000)
194
232
  this.convs.delete(this.convs.keys().next().value);
package/dist/ci.d.ts ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * `context-doctor ci`: the memory-file check for pull requests.
3
+ *
4
+ * CLAUDE.md, AGENTS.md, GEMINI.md and always-applied rules are re-read on
5
+ * every request of every session by everyone who works in the repository, so
6
+ * a PR that grows them by 2k tokens is a standing cost nobody reviews. This
7
+ * compares those files (and what they @import) at HEAD against a base commit,
8
+ * prints a table, and exits 1 over a budget, so CI can comment and gate.
9
+ *
10
+ * Base contents come from `git show <base>:<path>`; nothing is checked out.
11
+ */
12
+ export interface CiFile {
13
+ path: string;
14
+ agent: string;
15
+ base: number;
16
+ head: number;
17
+ /** Loaded through an @import rather than directly. */
18
+ imported?: boolean;
19
+ }
20
+ export interface CiReport {
21
+ base?: string;
22
+ files: CiFile[];
23
+ baseTotal: number;
24
+ headTotal: number;
25
+ breaches: string[];
26
+ }
27
+ export declare function ciReport(opts?: {
28
+ cwd?: string;
29
+ base?: string;
30
+ maxTokens?: number;
31
+ maxIncrease?: number;
32
+ }): CiReport;
33
+ /** Markdown for a PR comment or a job summary. */
34
+ export declare function renderCiMarkdown(r: CiReport, opts?: {
35
+ model?: string;
36
+ requestsPerDay?: number;
37
+ }): string;
38
+ /** Plain-text rendering for a terminal. */
39
+ export declare function renderCiText(r: CiReport): string;
package/dist/ci.js ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * `context-doctor ci`: the memory-file check for pull requests.
3
+ *
4
+ * CLAUDE.md, AGENTS.md, GEMINI.md and always-applied rules are re-read on
5
+ * every request of every session by everyone who works in the repository, so
6
+ * a PR that grows them by 2k tokens is a standing cost nobody reviews. This
7
+ * compares those files (and what they @import) at HEAD against a base commit,
8
+ * prints a table, and exits 1 over a budget, so CI can comment and gate.
9
+ *
10
+ * Base contents come from `git show <base>:<path>`; nothing is checked out.
11
+ */
12
+ import { execFileSync } from "node:child_process";
13
+ import { existsSync, readFileSync } from "node:fs";
14
+ import { join, posix } from "node:path";
15
+ import { estimateTokens, formatTokens } from "./tokens.js";
16
+ import { formatUsd, pricingFor } from "./pricing.js";
17
+ const CLAUDE = "claude-opus-5";
18
+ /** Repo-relative paths (posix) that agents load on every request. */
19
+ const MEMORY_PATTERNS = [
20
+ [/(^|\/)CLAUDE(\.local)?\.md$/, "Claude Code"],
21
+ [/(^|\/)\.claude\/CLAUDE\.md$/, "Claude Code"],
22
+ [/(^|\/)\.claude\/rules\/.+\.md$/, "Claude Code"],
23
+ [/(^|\/)AGENTS(\.override)?\.md$/, "Codex"],
24
+ [/(^|\/)GEMINI\.md$/, "Gemini CLI"],
25
+ [/(^|\/)\.cursorrules$/, "Cursor"],
26
+ [/(^|\/)\.cursor\/rules\/.+\.mdc?$/, "Cursor"],
27
+ ];
28
+ function git(args, cwd) {
29
+ try {
30
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024 });
31
+ }
32
+ catch {
33
+ return undefined;
34
+ }
35
+ }
36
+ function agentFor(path) {
37
+ return MEMORY_PATTERNS.find(([re]) => re.test(path))?.[1];
38
+ }
39
+ /** Cursor rules count only when always applied; the rest load on demand. */
40
+ function counts(path, text) {
41
+ return !/\.mdc?$/.test(path) || !path.includes(".cursor/rules/") || /^alwaysApply:\s*true/m.test(text);
42
+ }
43
+ /** `@path` imports (Claude Code), resolved relative to the importing file, repo-relative. */
44
+ function importsOf(text, from) {
45
+ const out = [];
46
+ let fence = false;
47
+ for (const line of text.split("\n")) {
48
+ if (/^\s*(```|~~~)/.test(line))
49
+ fence = !fence;
50
+ if (fence)
51
+ continue;
52
+ for (const m of line.replace(/`[^`]*`/g, "").matchAll(/(?:^|\s)@((?:\.{0,2}\/|[\w.-]+\/)[^\s)]+|[\w.-]+\.\w+)/g)) {
53
+ if (m[1].startsWith("/") || m[1].startsWith("~"))
54
+ continue; // outside the repo
55
+ out.push(posix.normalize(posix.join(posix.dirname(from), m[1])));
56
+ }
57
+ }
58
+ return out.filter((p) => !p.startsWith(".."));
59
+ }
60
+ /** Token totals per memory file, with a reader for one side (HEAD from disk, base from git). */
61
+ function measure(paths, read) {
62
+ const out = new Map();
63
+ const visit = (p, imported, depth) => {
64
+ if (out.has(p))
65
+ return;
66
+ const text = read(p);
67
+ if (text === undefined || !counts(p, text))
68
+ return;
69
+ out.set(p, { tokens: estimateTokens(text, CLAUDE), imported });
70
+ // Only Claude Code resolves @imports; an imported file can import further.
71
+ if (depth < 4 && (imported || agentFor(p) === "Claude Code"))
72
+ for (const i of importsOf(text, p))
73
+ visit(i, true, depth + 1);
74
+ };
75
+ for (const p of paths)
76
+ visit(p, false, 0);
77
+ return out;
78
+ }
79
+ export function ciReport(opts = {}) {
80
+ const cwd = opts.cwd ?? process.cwd();
81
+ const root = git(["rev-parse", "--show-toplevel"], cwd)?.trim() ?? cwd;
82
+ const headList = (git(["ls-files", "--cached", "--others", "--exclude-standard"], root) ?? "").split("\n").filter(Boolean);
83
+ const baseList = opts.base ? (git(["ls-tree", "-r", "--name-only", opts.base], root) ?? "").split("\n").filter(Boolean) : [];
84
+ const head = measure(headList.filter((p) => agentFor(p)), (p) => (existsSync(join(root, p)) ? readFileSync(join(root, p), "utf8") : undefined));
85
+ const base = opts.base ? measure(baseList.filter((p) => agentFor(p)), (p) => git(["show", `${opts.base}:${p}`], root)) : new Map();
86
+ const files = [...new Set([...head.keys(), ...base.keys()])].sort().map((p) => ({
87
+ path: p,
88
+ agent: agentFor(p) ?? "Claude Code",
89
+ base: base.get(p)?.tokens ?? 0,
90
+ head: head.get(p)?.tokens ?? 0,
91
+ imported: (head.get(p) ?? base.get(p))?.imported || undefined,
92
+ }));
93
+ const baseTotal = files.reduce((s, f) => s + f.base, 0);
94
+ const headTotal = files.reduce((s, f) => s + f.head, 0);
95
+ const breaches = [];
96
+ if (opts.maxTokens !== undefined && headTotal > opts.maxTokens)
97
+ breaches.push(`memory files total ~${formatTokens(headTotal)} tokens, over the ${formatTokens(opts.maxTokens)} budget`);
98
+ if (opts.base && opts.maxIncrease !== undefined && headTotal - baseTotal > opts.maxIncrease) {
99
+ breaches.push(`this change adds ~${formatTokens(headTotal - baseTotal)} tokens, over the ${formatTokens(opts.maxIncrease)} allowed increase`);
100
+ }
101
+ return { base: opts.base, files, baseTotal, headTotal, breaches };
102
+ }
103
+ const signed = (n) => (n > 0 ? `+${formatTokens(n)}` : n < 0 ? `−${formatTokens(-n)}` : "0");
104
+ /** Markdown for a PR comment or a job summary. */
105
+ export function renderCiMarkdown(r, opts = {}) {
106
+ // The marker lets the GitHub Action find and update its own comment.
107
+ const out = ["<!-- context-doctor -->", "### context-doctor: what every agent request re-reads", ""];
108
+ if (r.files.length === 0) {
109
+ out.push("No agent memory files (CLAUDE.md, AGENTS.md, GEMINI.md, rules) in this repository.");
110
+ return out.join("\n");
111
+ }
112
+ const changed = r.base ? r.files.filter((f) => f.base !== f.head) : r.files;
113
+ const delta = r.headTotal - r.baseTotal;
114
+ out.push(r.base
115
+ ? `Memory files: **~${formatTokens(r.baseTotal)} → ~${formatTokens(r.headTotal)} tokens (${signed(delta)})**, loaded into every request of every session in this repo.`
116
+ : `Memory files: **~${formatTokens(r.headTotal)} tokens**, loaded into every request of every session in this repo.`);
117
+ const price = pricingFor(opts.model ?? "claude-sonnet-5");
118
+ if (price && opts.requestsPerDay && r.base && delta !== 0) {
119
+ // Cache reads on warm requests; this is the floor of the cost, cold starts add to it.
120
+ const usd = ((delta * price.cacheReadPerM) / 1e6) * opts.requestsPerDay * 30;
121
+ out.push(`At ${opts.requestsPerDay.toLocaleString("en-US")} requests a day on ${opts.model ?? "claude-sonnet-5"} (cached reads), that is ${usd >= 0 ? "+" : "−"}${formatUsd(Math.abs(usd))} a month.`);
122
+ }
123
+ out.push("");
124
+ if (changed.length) {
125
+ out.push(r.base ? "| File | Agent | Base | This PR | Change |" : "| File | Agent | Tokens |", r.base ? "|---|---|---:|---:|---:|" : "|---|---|---:|");
126
+ for (const f of changed) {
127
+ const name = `\`${f.path}\`${f.imported ? " (imported)" : ""}`;
128
+ out.push(r.base ? `| ${name} | ${f.agent} | ${f.base ? "~" + formatTokens(f.base) : "—"} | ${f.head ? "~" + formatTokens(f.head) : "—"} | ${signed(f.head - f.base)} |` : `| ${name} | ${f.agent} | ~${formatTokens(f.head)} |`);
129
+ }
130
+ }
131
+ else {
132
+ out.push("No memory file changed in this PR.");
133
+ }
134
+ if (r.breaches.length)
135
+ out.push("", ...r.breaches.map((b) => `**Over budget:** ${b}.`));
136
+ const big = r.files.filter((f) => f.head >= 2000);
137
+ if (big.length)
138
+ out.push("", `Large files: ${big.map((f) => `\`${f.path}\``).join(", ")}. \`npx context-doctor overhead split <file>\` moves reference sections out, word for word.`);
139
+ return out.join("\n");
140
+ }
141
+ /** Plain-text rendering for a terminal. */
142
+ export function renderCiText(r) {
143
+ return renderCiMarkdown(r).replace("<!-- context-doctor -->\n", "").replace(/^### /m, "").replace(/\*\*/g, "").replace(/`/g, "");
144
+ }