context-doctor 0.26.0 → 0.28.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 +136 -32
- package/dist/autoclear.d.ts +9 -2
- package/dist/autoclear.js +41 -3
- package/dist/ci.d.ts +39 -0
- package/dist/ci.js +144 -0
- package/dist/cli.js +119 -0
- package/dist/doctor.js +27 -0
- package/dist/embed.d.ts +30 -0
- package/dist/embed.js +127 -0
- package/dist/extract.d.ts +17 -0
- package/dist/extract.js +88 -0
- package/dist/gemini.d.ts +25 -0
- package/dist/gemini.js +192 -0
- package/dist/hook.js +3 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/install.d.ts +2 -0
- package/dist/install.js +32 -9
- package/dist/mcp.d.ts +1 -0
- package/dist/mcp.js +36 -8
- package/dist/mcpschema.d.ts +51 -0
- package/dist/mcpschema.js +125 -0
- package/dist/overhead.d.ts +83 -0
- package/dist/overhead.js +493 -0
- package/dist/pack.d.ts +100 -0
- package/dist/pack.js +418 -0
- package/dist/proxy.d.ts +8 -1
- package/dist/proxy.js +23 -6
- package/dist/session.js +4 -0
- package/dist/split.d.ts +44 -0
- package/dist/split.js +165 -0
- package/dist/wrap.d.ts +47 -0
- package/dist/wrap.js +109 -0
- package/package.json +2 -2
- package/skills/context-doctor/SKILL.md +2 -1
package/README.md
CHANGED
|
@@ -1,49 +1,66 @@
|
|
|
1
1
|
# context-doctor 🩺
|
|
2
2
|
|
|
3
|
-
[](https://github.com/KushalP1/context-doctor/actions) [](https://www.npmjs.com/package/context-doctor) [](https://www.npmjs.com/package/context-doctor) [](./LICENSE) 
|
|
3
|
+
[](https://github.com/KushalP1/context-doctor/actions) [](https://www.npmjs.com/package/context-doctor) [](https://www.npmjs.com/package/context-doctor) [](https://registry.modelcontextprotocol.io) [](./LICENSE) 
|
|
4
4
|
|
|
5
|
-
**
|
|
5
|
+
**See where your AI agent's tokens go, and stop paying for the ones that do nothing.**
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
AI agents re-send their whole context on every message: old tool output, pasted files, long instructions. A 500k-token session pays for all of it again each time, and at full price after a coffee break, when the prompt cache has expired. context-doctor measures that waste on your own sessions, in dollars, and removes it at the moments it pays: before it enters the context, while it sits there, and on every request.
|
|
8
8
|
|
|
9
|
-
|
|
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)
|
|
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.)
|
|
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.
|
|
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.
|
|
9
|
+
It runs on your machine, needs no API key, sends no telemetry, and works with **Claude Code, Claude Desktop, Cursor, Codex, Gemini CLI**, any app on the **Anthropic, OpenAI or Gemini API**, and **CI**. MIT licensed, Node 20+, macOS, Linux and Windows.
|
|
14
10
|
|
|
15
|
-
|
|
11
|
+
```bash
|
|
12
|
+
npx context-doctor savings # what your recent sessions cost, and what each fix would have saved
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
<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">
|
|
16
16
|
|
|
17
17
|
## Quick start
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
Pick where you work. Each takes about a minute and is fully reversible.
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
| You use | Do this |
|
|
22
|
+
|---|---|
|
|
23
|
+
| **Claude Code** (terminal, IDE, desktop app) | Inside Claude Code: `/plugin marketplace add KushalP1/context-doctor`, then `/plugin install context-doctor@context-doctor`. You get the every-prompt check, the MCP tools and `/context-doctor:savings`, `:checkup`, `:overhead`, `:pack`, `:autopilot`, `:compact-window` |
|
|
24
|
+
| **Everything on this machine at once** | `npm install -g context-doctor && context-doctor install` wires Claude Code, Claude Desktop, Cursor, Codex and Gemini CLI. `context-doctor doctor` checks it; `context-doctor uninstall` removes it |
|
|
25
|
+
| **Claude Desktop, Cursor or any MCP client** | Add the server to the client's MCP config (or install the one-click `.mcpb` from the [latest release](https://github.com/KushalP1/context-doctor/releases/latest) in Claude Desktop):<br>`{"mcpServers": {"context-doctor": {"command": "npx", "args": ["-y", "context-doctor", "mcp"]}}}` |
|
|
26
|
+
| **claude.ai, ChatGPT, phone apps** | `npx context-doctor instructions --copy`, then paste into the app's personal preferences |
|
|
27
|
+
| **Your own app on an LLM API** | Run `npx context-doctor proxy --autopilot` and point `ANTHROPIC_BASE_URL`, `OPENAI_BASE_URL` (add `/v1`) or `GOOGLE_GEMINI_BASE_URL` at `http://localhost:8787`. No proxy possible? Wrap the SDK client: `withContextDoctor(new Anthropic())` |
|
|
28
|
+
| **GitHub pull requests** | `uses: KushalP1/context-doctor@v0.27.0` comments how much a PR grows CLAUDE.md, AGENTS.md and rules, and can fail over a budget ([setup](#enforce-a-budget-in-ci)) |
|
|
24
29
|
|
|
25
|
-
|
|
30
|
+
## What it does
|
|
26
31
|
|
|
27
|
-
|
|
32
|
+
**Measure, in dollars, on your own history**
|
|
33
|
+
- `savings`: what your recent Claude Code sessions cost and what each fix below would have saved, replayed request by request against what you were billed.
|
|
34
|
+
- `overhead`: what every request re-reads before your message (system prompt, tools, MCP servers, CLAUDE.md, rules, memory), each file priced per month.
|
|
35
|
+
- `session`, `watch`, `analyze`, `report`, `dashboard`: token breakdown and wasted-context findings for any session, conversation file or agent trace.
|
|
28
36
|
|
|
29
|
-
**
|
|
37
|
+
**Keep waste out of the context**
|
|
38
|
+
- `pack` (and the `pack_context` MCP tool): only the chunks of a big document, PDF, log or codebase that a question needs, within a token budget, instead of the whole thing.
|
|
39
|
+
- `overhead split`: turns a heavy CLAUDE.md or memory file into a lean one plus a reference file the agent opens when needed, word for word, nothing lost.
|
|
40
|
+
- `ci` and the GitHub Action: review memory-file growth like any other cost.
|
|
30
41
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
context-doctor autopilot on # every new Claude Code session keeps its context lean
|
|
36
|
-
```
|
|
42
|
+
**Remove waste automatically**
|
|
43
|
+
- An every-prompt hook (Claude Code, Codex, Gemini CLI; Cursor after each tool call) tells the model its real context size and the largest waste, and offers `/compact` when you return to a big session after the cache expired.
|
|
44
|
+
- `compact-window`: sets Claude Code's own auto-compact window, measured on your history first. Works in the desktop app too.
|
|
45
|
+
- Autopilot: a local proxy (or SDK wrapper) that clears stale tool output only when the prompt cache is cold, so a request never costs more.
|
|
37
46
|
|
|
38
|
-
|
|
39
|
-
# Or as a Claude Code plugin, from inside Claude Code
|
|
40
|
-
/plugin marketplace add KushalP1/context-doctor
|
|
41
|
-
/plugin install context-doctor@context-doctor
|
|
42
|
-
```
|
|
47
|
+
## What it has measured
|
|
43
48
|
|
|
44
|
-
|
|
49
|
+
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):
|
|
45
50
|
|
|
46
|
-
|
|
51
|
+
- **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)
|
|
52
|
+
- **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)
|
|
53
|
+
- **Autopilot:** **9.8% less input cost, no session made more expensive.**
|
|
54
|
+
- **pack:** 9 of 10 questions about this repository answered from **0.6% of its tokens**. [How →](#before-it-enters-the-context-pack-and-overhead)
|
|
55
|
+
- **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.
|
|
56
|
+
|
|
57
|
+
On a Claude subscription you do not pay list price; the same tokens come out of your usage limit instead, so read the dollars as the size of the effect.
|
|
58
|
+
|
|
59
|
+
Built and maintained by [gAI Ventures](https://gai.ventures).
|
|
60
|
+
|
|
61
|
+
> **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).
|
|
62
|
+
|
|
63
|
+
A profile looks like this (`npx context-doctor analyze conversation.json`, or `session` for your latest Claude Code session):
|
|
47
64
|
|
|
48
65
|
```
|
|
49
66
|
Where the tokens go
|
|
@@ -107,15 +124,58 @@ On a Claude subscription you do not pay list price; the same tokens come out of
|
|
|
107
124
|
| **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
125
|
| **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
126
|
| **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
|
-
| **
|
|
111
|
-
| **
|
|
127
|
+
| **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 |
|
|
128
|
+
| **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 |
|
|
129
|
+
| **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
130
|
| **claude.ai, ChatGPT, the phone apps** | Your account's standing preferences (`context-doctor instructions --copy`) | Profile an exported chat with `analyze` |
|
|
113
131
|
| **CI** | `analyze --fail-over-budget` fails a build whose prompts outgrow a budget | `.contextdoctorrc` budgets and presets |
|
|
114
132
|
|
|
115
133
|
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
134
|
|
|
135
|
+
## Before it enters the context: pack and overhead
|
|
136
|
+
|
|
137
|
+
Everything above slims context that is already there. Two commands stop it getting in.
|
|
138
|
+
|
|
139
|
+
**`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.
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
context-doctor pack docs/ README.md --query "how do I rotate the signing key" --max-tokens 3000
|
|
143
|
+
context-doctor pack big-manual.md # outline: id, lines, tokens, heading per chunk
|
|
144
|
+
context-doctor pack big-manual.md --ids big-manual.md#12,big-manual.md#13
|
|
145
|
+
cat server.log | context-doctor pack - -q "timeout upstream"
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
$ context-doctor pack README.md src -q "how is the ledger kept from growing" --max-tokens 2000
|
|
150
|
+
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.
|
|
151
|
+
|
|
152
|
+
── src/ledger.ts#6 · L108-128 · export function recordLedger(entry: Omit<LedgerEntry, "ts">): void { · ~404
|
|
153
|
+
...
|
|
154
|
+
── src/test/ledger.test.ts#1 · L1-22 · The ledger is capped, and everything it feeds is a LIFETIME total. · ~409
|
|
155
|
+
...
|
|
156
|
+
Not included: 1019 chunk(s). Next best: src/watch.ts#1 (...); src/test/compactwindow.test.ts#4 (...)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
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.
|
|
160
|
+
|
|
161
|
+
**`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.
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
Claude Code, last 30 days: 71 sessions start at a median ~54k tokens (p90 ~62k) before the first message.
|
|
165
|
+
Memory files loaded here are ~3.4k of that; the rest is Claude Code's system prompt, tools, skills and MCP schemas.
|
|
166
|
+
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;
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
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.
|
|
170
|
+
|
|
171
|
+
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.
|
|
172
|
+
|
|
173
|
+
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.
|
|
174
|
+
|
|
117
175
|
## What's new
|
|
118
176
|
|
|
177
|
+
- **0.28 Wider reach**: `/context-doctor:overhead` and `/context-doctor:pack` in the Claude Code plugin; `pack` reads a folder inside a git repository the way git does, so `.gitignore`'d build output and secrets stay out; `withContextDoctor` wraps Google's `@google/genai` client too; `overhead` measures the fixed start-of-session cost of Codex and Gemini CLI sessions as well as Claude Code's, priced with each provider's cache rules.
|
|
178
|
+
- **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
179
|
- **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
180
|
- **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
181
|
- **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 +251,10 @@ Practical upshot: a developer who only wants cheaper, faster API calls never tou
|
|
|
191
251
|
| `context-doctor mcp [--http]` | The MCP server as a subcommand, for clients and registries that launch `npx -y context-doctor mcp` |
|
|
192
252
|
| `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
253
|
| `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 |
|
|
254
|
+
| `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 |
|
|
255
|
+
| `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 |
|
|
256
|
+
| `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 |
|
|
257
|
+
| `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
258
|
| `context-doctor analyze <file>` | Profile a conversation: token breakdown, findings, cost + latency estimates. `--fail-over-budget` exits 1 on a breach, for CI |
|
|
195
259
|
| `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
260
|
| `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 +459,7 @@ Your local MCP server can't reach the website, but the behavior can. Two options
|
|
|
395
459
|
|---|---|
|
|
396
460
|
| `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
461
|
| `optimize_context` | Rewrites the conversation: dedupe, trim stale tool results, strip base64, optional history pruning |
|
|
462
|
+
| `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
463
|
| `context_best_practices` | Curated checklist, optionally specialized for Anthropic / OpenAI |
|
|
399
464
|
|
|
400
465
|
## Use it as a library
|
|
@@ -408,8 +473,24 @@ console.log(profile.totalTokens, profile.findings);
|
|
|
408
473
|
const { conversation, tokensBefore, tokensAfter } = optimizeConversation(chatJson, {
|
|
409
474
|
strategies: ["dedupe", "trim-tool-results", "strip-base64"],
|
|
410
475
|
});
|
|
476
|
+
|
|
477
|
+
// Autopilot without a proxy: wrap the SDK client (Anthropic or OpenAI)
|
|
478
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
479
|
+
import { withContextDoctor, contextDoctorStats } from "context-doctor";
|
|
480
|
+
const client = withContextDoctor(new Anthropic()); // use exactly as before
|
|
481
|
+
await client.messages.create({ model, max_tokens, messages });
|
|
482
|
+
console.log(contextDoctorStats(client)); // { requests, changed, tokensRemoved }
|
|
483
|
+
|
|
484
|
+
// RAG-style context packing: chunk, rank, fit a budget (no embeddings, no key)
|
|
485
|
+
import { packContext, renderPack } from "context-doctor";
|
|
486
|
+
const packed = packContext([{ name: "manual.md", text: manual }], { query: "rotate the signing key", budget: 3000 });
|
|
487
|
+
console.log(packed.selected.map((c) => `${c.id} L${c.startLine}-${c.endLine}`), renderPack(packed));
|
|
411
488
|
```
|
|
412
489
|
|
|
490
|
+
### Autopilot inside your app: `withContextDoctor`
|
|
491
|
+
|
|
492
|
+
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) and Google's `models.generateContent` / `generateContentStream` 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.
|
|
493
|
+
|
|
413
494
|
## Subagents: their own windows, your bill
|
|
414
495
|
|
|
415
496
|
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 +654,29 @@ A cache read bills at ~10% of input while a write bills at ~125%, so a session t
|
|
|
573
654
|
|
|
574
655
|
## Enforce a budget in CI
|
|
575
656
|
|
|
657
|
+
**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:
|
|
658
|
+
|
|
659
|
+
```yaml
|
|
660
|
+
# .github/workflows/context.yml
|
|
661
|
+
on: pull_request
|
|
662
|
+
permissions:
|
|
663
|
+
contents: read
|
|
664
|
+
pull-requests: write # for the comment
|
|
665
|
+
jobs:
|
|
666
|
+
memory:
|
|
667
|
+
runs-on: ubuntu-latest
|
|
668
|
+
steps:
|
|
669
|
+
- uses: actions/checkout@v4
|
|
670
|
+
- uses: KushalP1/context-doctor@v0.27.0
|
|
671
|
+
with:
|
|
672
|
+
max-increase: "2000" # fail a PR that adds more than 2k tokens
|
|
673
|
+
requests-per-day: "5000" # optional: price the change
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
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`.
|
|
677
|
+
|
|
678
|
+
**Conversation fixtures.**
|
|
679
|
+
|
|
576
680
|
```bash
|
|
577
681
|
npx context-doctor analyze conversation.json --fail-over-budget
|
|
578
682
|
```
|
|
@@ -628,7 +732,7 @@ One honest caveat worth knowing: a transcript stores the conversation, **not** t
|
|
|
628
732
|
|
|
629
733
|
## Roadmap
|
|
630
734
|
|
|
631
|
-
See [ROADMAP.md](./ROADMAP.md) for the full plan
|
|
735
|
+
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
736
|
|
|
633
737
|
Contributions welcome — this project is small on purpose. Open an issue before a big PR.
|
|
634
738
|
|
package/dist/autoclear.d.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
+
}
|