context-doctor 0.27.0 → 0.29.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 +49 -36
- package/dist/cli.js +20 -2
- package/dist/gemini.d.ts +2 -0
- package/dist/gemini.js +2 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/mcp.js +1 -1
- package/dist/overhead.d.ts +10 -3
- package/dist/overhead.js +178 -57
- package/dist/pack.d.ts +0 -5
- package/dist/pack.js +23 -0
- package/dist/proxy.d.ts +1 -1
- package/dist/proxy.js +1 -1
- package/dist/reads.d.ts +35 -0
- package/dist/reads.js +151 -0
- package/dist/tasks.d.ts +51 -0
- package/dist/tasks.js +140 -0
- package/dist/wrap.d.ts +6 -3
- package/dist/wrap.js +19 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,58 +1,69 @@
|
|
|
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
|
+
|
|
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
|
+
|
|
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.
|
|
6
10
|
|
|
7
11
|
```bash
|
|
8
|
-
npx context-doctor savings
|
|
12
|
+
npx context-doctor savings # what your recent sessions cost, and what each fix would have saved
|
|
9
13
|
```
|
|
10
14
|
|
|
11
|
-
|
|
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">
|
|
12
16
|
|
|
13
|
-
|
|
17
|
+
## Quick start
|
|
14
18
|
|
|
15
|
-
|
|
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)
|
|
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)
|
|
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.
|
|
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.
|
|
19
|
+
Pick where you work. Each takes about a minute and is fully reversible.
|
|
21
20
|
|
|
22
|
-
|
|
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
|
+
| **Python / RAG pipelines** | `pip install context-doctor` (after the first PyPI release; until then `pip install "git+https://github.com/KushalP1/context-doctor#subdirectory=python"`), then `pack_documents(retrieved_docs, question, budget=4000)` trims LangChain or LlamaIndex results to the chunks that matter. [Python README](./python/README.md) |
|
|
29
|
+
| **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)) |
|
|
23
30
|
|
|
24
|
-
|
|
31
|
+
## What it does
|
|
25
32
|
|
|
26
|
-
|
|
33
|
+
**Measure, in dollars, on your own history**
|
|
34
|
+
- `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.
|
|
35
|
+
- `overhead`: what every request re-reads before your message (system prompt, tools, MCP servers, CLAUDE.md, rules, memory), each file priced per month.
|
|
36
|
+
- `tasks`: cost per commit, per project, from each session's recorded usage and its own `git commit` calls.
|
|
37
|
+
- `reads`: the files read into session after session, and whether a CLAUDE.md summary or `pack` would pay.
|
|
38
|
+
- `session`, `watch`, `analyze`, `report`, `dashboard`: token breakdown and wasted-context findings for any session, conversation file or agent trace.
|
|
27
39
|
|
|
28
|
-
**
|
|
40
|
+
**Keep waste out of the context**
|
|
41
|
+
- `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.
|
|
42
|
+
- `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.
|
|
43
|
+
- `ci` and the GitHub Action: review memory-file growth like any other cost.
|
|
29
44
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
45
|
+
**Remove waste automatically**
|
|
46
|
+
- 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.
|
|
47
|
+
- `compact-window`: sets Claude Code's own auto-compact window, measured on your history first. Works in the desktop app too.
|
|
48
|
+
- 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.
|
|
33
49
|
|
|
34
|
-
|
|
50
|
+
## What it has measured
|
|
35
51
|
|
|
36
|
-
|
|
52
|
+
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):
|
|
37
53
|
|
|
38
|
-
**
|
|
54
|
+
- **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)
|
|
55
|
+
- **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)
|
|
56
|
+
- **Autopilot:** **9.8% less input cost, no session made more expensive.**
|
|
57
|
+
- **pack:** 9 of 10 questions about this repository answered from **0.6% of its tokens**. [How →](#before-it-enters-the-context-pack-and-overhead)
|
|
58
|
+
- **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.
|
|
39
59
|
|
|
40
|
-
|
|
41
|
-
# Everything, every app it finds (Claude Code, Cursor, Codex, Claude Desktop)
|
|
42
|
-
npm install -g context-doctor
|
|
43
|
-
context-doctor install
|
|
44
|
-
context-doctor autopilot on # every new Claude Code session keeps its context lean
|
|
45
|
-
```
|
|
60
|
+
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.
|
|
46
61
|
|
|
47
|
-
|
|
48
|
-
# Or as a Claude Code plugin, from inside Claude Code
|
|
49
|
-
/plugin marketplace add KushalP1/context-doctor
|
|
50
|
-
/plugin install context-doctor@context-doctor
|
|
51
|
-
```
|
|
62
|
+
Built and maintained by [gAI Ventures](https://gai.ventures).
|
|
52
63
|
|
|
53
|
-
|
|
64
|
+
> **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).
|
|
54
65
|
|
|
55
|
-
|
|
66
|
+
A profile looks like this (`npx context-doctor analyze conversation.json`, or `session` for your latest Claude Code session):
|
|
56
67
|
|
|
57
68
|
```
|
|
58
69
|
Where the tokens go
|
|
@@ -166,6 +177,8 @@ MCP servers are part of the same overhead: each one's tool definitions ride on e
|
|
|
166
177
|
|
|
167
178
|
## What's new
|
|
168
179
|
|
|
180
|
+
- **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.
|
|
181
|
+
- **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.
|
|
169
182
|
- **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`.
|
|
170
183
|
- **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.
|
|
171
184
|
- **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.
|
|
@@ -243,7 +256,7 @@ Practical upshot: a developer who only wants cheaper, faster API calls never tou
|
|
|
243
256
|
| `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 |
|
|
244
257
|
| `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
258
|
| `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 |
|
|
259
|
+
| `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 |
|
|
247
260
|
| `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
261
|
| `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 |
|
|
249
262
|
| `context-doctor analyze <file>` | Profile a conversation: token breakdown, findings, cost + latency estimates. `--fail-over-budget` exits 1 on a breach, for CI |
|
|
@@ -480,7 +493,7 @@ console.log(packed.selected.map((c) => `${c.id} L${c.startLine}-${c.endLine}`),
|
|
|
480
493
|
|
|
481
494
|
### Autopilot inside your app: `withContextDoctor`
|
|
482
495
|
|
|
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.
|
|
496
|
+
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.
|
|
484
497
|
|
|
485
498
|
## Subagents: their own windows, your bill
|
|
486
499
|
|
package/dist/cli.js
CHANGED
|
@@ -42,9 +42,11 @@ import { listCursorChats, parseCursorChat } from "./cursor.js";
|
|
|
42
42
|
import { analyzeCacheUsage, renderCacheReport } from "./cache.js";
|
|
43
43
|
import { renderToolTimings } from "./timing.js";
|
|
44
44
|
import { packContextSemantic, readSources, renderPack } from "./pack.js";
|
|
45
|
-
import {
|
|
45
|
+
import { measureBaselines, measureMcpSizes, overheadReport, renderOverhead } from "./overhead.js";
|
|
46
46
|
import { applySplit, planSplit, renderSplit } from "./split.js";
|
|
47
47
|
import { ciReport, renderCiMarkdown, renderCiText } from "./ci.js";
|
|
48
|
+
import { renderReads, repeatedReads } from "./reads.js";
|
|
49
|
+
import { costPerTask, renderTasks } from "./tasks.js";
|
|
48
50
|
const HELP = `context-doctor — profile and optimize LLM context windows
|
|
49
51
|
|
|
50
52
|
Usage:
|
|
@@ -102,6 +104,10 @@ Usage:
|
|
|
102
104
|
context-doctor ci [--base <ref>] [--max-tokens n] [--max-increase n] [--markdown]
|
|
103
105
|
For pull requests: memory files (CLAUDE.md, AGENTS.md,
|
|
104
106
|
GEMINI.md, rules) at HEAD vs base; exit 1 over budget
|
|
107
|
+
context-doctor tasks [--days n] Cost per commit, per project: session cost at list
|
|
108
|
+
price vs commits made during the sessions
|
|
109
|
+
context-doctor reads [--days n] [--limit n] Files read into session after session: reads, tokens,
|
|
110
|
+
cost, and whether a CLAUDE.md summary or pack pays
|
|
105
111
|
context-doctor overhead split <file> [--write]
|
|
106
112
|
Move a memory file's big sections to a reference
|
|
107
113
|
file, leaving pointers (plan first; --write backs up)
|
|
@@ -711,6 +717,16 @@ async function main() {
|
|
|
711
717
|
});
|
|
712
718
|
return; // server keeps the process alive
|
|
713
719
|
}
|
|
720
|
+
if (args.command === "tasks") {
|
|
721
|
+
const report = costPerTask(args.days ?? 30);
|
|
722
|
+
console.log(args.json ? JSON.stringify(report, null, 2) : renderTasks(report, args.limit ?? 12));
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
if (args.command === "reads") {
|
|
726
|
+
const report = repeatedReads(args.days ?? 30);
|
|
727
|
+
console.log(args.json ? JSON.stringify(report, null, 2) : renderReads(report, args.limit ?? 15));
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
714
730
|
if (args.command === "ci") {
|
|
715
731
|
const report = ciReport({ base: args.base, maxTokens: args.maxTokens, maxIncrease: args.maxIncrease });
|
|
716
732
|
console.log(args.json ? JSON.stringify(report, null, 2) : args.markdown ? renderCiMarkdown(report, { model: args.model, requestsPerDay: args.requestsPerDay }) : renderCiText(report));
|
|
@@ -732,7 +748,9 @@ async function main() {
|
|
|
732
748
|
console.error(`Could not read ${file}: ${e.message}`);
|
|
733
749
|
process.exit(1);
|
|
734
750
|
}
|
|
735
|
-
|
|
751
|
+
// Price the file at the rate of the agent that loads it.
|
|
752
|
+
const agent = /AGENTS(\.override)?\.md$/i.test(file) ? "Codex" : /GEMINI\.md$/i.test(file) ? "Gemini CLI" : "Claude Code";
|
|
753
|
+
console.log(renderSplit(plan, measureBaselines(args.days ?? 30).find((b) => b.agent === agent)?.usdPerKPerMonth));
|
|
736
754
|
if (args.write && plan.sections.some((s) => s.moved)) {
|
|
737
755
|
const { backup } = applySplit(plan);
|
|
738
756
|
console.log(`\nWritten. Backup: ${backup}\nUndo: mv "${backup}" "${file}" (and delete or trim ${plan.referencePath}).`);
|
package/dist/gemini.d.ts
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
import { type ParsedSession, type SessionInfo } from "./session.js";
|
|
19
19
|
/** True when the file's first record looks like a Gemini CLI chat (cheap: reads 4 KB). */
|
|
20
20
|
export declare function isGeminiChat(path: string): boolean;
|
|
21
|
+
/** The ordered, de-duplicated, rewind-applied messages of a chat file. */
|
|
22
|
+
export declare function readGeminiMessages(path: string): any[];
|
|
21
23
|
export declare function parseGeminiChat(path: string): ParsedSession;
|
|
22
24
|
/** Gemini CLI chats on this machine: ~/.gemini/tmp/<project>/chats/session-*.json[l]. */
|
|
23
25
|
export declare function listGeminiChats(home?: string): SessionInfo[];
|
package/dist/gemini.js
CHANGED
|
@@ -62,7 +62,7 @@ function resultText(call) {
|
|
|
62
62
|
return typeof call?.resultDisplay === "string" ? call.resultDisplay : r === undefined ? "" : JSON.stringify(r);
|
|
63
63
|
}
|
|
64
64
|
/** The ordered, de-duplicated, rewind-applied messages of a chat file. */
|
|
65
|
-
function
|
|
65
|
+
export function readGeminiMessages(path) {
|
|
66
66
|
const order = [];
|
|
67
67
|
const byId = new Map();
|
|
68
68
|
let anon = 0;
|
|
@@ -115,7 +115,7 @@ export function parseGeminiChat(path) {
|
|
|
115
115
|
const usageSamples = [];
|
|
116
116
|
let model;
|
|
117
117
|
let reportedInputTokens;
|
|
118
|
-
for (const m of
|
|
118
|
+
for (const m of readGeminiMessages(path)) {
|
|
119
119
|
if (m.type === "user") {
|
|
120
120
|
const t = text(m.content);
|
|
121
121
|
if (t)
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/mcp.js
CHANGED
|
@@ -52,7 +52,7 @@ const STRATEGY_IDS = ["dedupe", "trim-tool-results", "trim-tool-calls", "strip-b
|
|
|
52
52
|
* recommended pattern.
|
|
53
53
|
*/
|
|
54
54
|
function createServer({ fileAccess = true } = {}) {
|
|
55
|
-
const server = new McpServer({ name: "context-doctor", version: "0.
|
|
55
|
+
const server = new McpServer({ name: "context-doctor", version: "0.29.0" }, { instructions: SERVER_INSTRUCTIONS });
|
|
56
56
|
server.tool("profile_context", "Profile an LLM conversation or prompt: token breakdown, largest blocks, and actionable findings about wasted context (duplicates, oversized pastes or tool results, base64 blobs, long history). Two inputs, pass ONE: `conversation` (full OpenAI/Anthropic JSON or raw text, for agents, files and proxies) or `sketch` (for chat apps such as Claude Desktop or ChatGPT where you cannot export the conversation: the turn count plus the few blocks that matter, ~120 tokens to write). Call it whenever the user asks about token usage, context size, cost, speed or limits, and on your own once the conversation passes ~30 turns or holds 3+ large pastes. Act on the top finding in your reply.", {
|
|
57
57
|
conversation: z.string().optional().describe("Conversation JSON (OpenAI or Anthropic format, or bare message array) or raw prompt text. Omit in chat apps and pass `sketch`."),
|
|
58
58
|
sketch: z.object({
|
package/dist/overhead.d.ts
CHANGED
|
@@ -29,6 +29,7 @@ export interface OverheadFinding {
|
|
|
29
29
|
suggestion: string;
|
|
30
30
|
}
|
|
31
31
|
export interface Baseline {
|
|
32
|
+
agent: Agent;
|
|
32
33
|
sessions: number;
|
|
33
34
|
/** Median tokens of the first request, minus the first user message. */
|
|
34
35
|
median: number;
|
|
@@ -42,7 +43,10 @@ export interface Baseline {
|
|
|
42
43
|
}
|
|
43
44
|
export interface OverheadReport {
|
|
44
45
|
cwd: string;
|
|
46
|
+
/** Claude Code's baseline (memory-file prices for Claude Code are quoted against it). */
|
|
45
47
|
baseline?: Baseline;
|
|
48
|
+
/** Every agent with sessions in the period: Claude Code, Codex, Gemini CLI. */
|
|
49
|
+
baselines: Baseline[];
|
|
46
50
|
files: MemoryFile[];
|
|
47
51
|
findings: OverheadFinding[];
|
|
48
52
|
mcp: McpSection;
|
|
@@ -56,13 +60,16 @@ export interface McpSection {
|
|
|
56
60
|
/** Every memory file the four agents load for a session started in `cwd`. */
|
|
57
61
|
export declare function findMemoryFiles(cwd?: string, home?: string): MemoryFile[];
|
|
58
62
|
/**
|
|
59
|
-
* Fixed overhead per session, measured:
|
|
60
|
-
* (
|
|
63
|
+
* Fixed overhead per session and agent, measured: each session's first
|
|
64
|
+
* request (the API's own count) minus the first user message, plus the
|
|
61
65
|
* request and cold-start counts that turn tokens into a monthly bill.
|
|
66
|
+
* Claude Code, Codex and Gemini CLI sessions, newest `days` only.
|
|
62
67
|
*/
|
|
68
|
+
export declare function measureBaselines(days?: number, paths?: string[]): Baseline[];
|
|
69
|
+
/** Claude Code's baseline (the one memory-file prices are quoted against). */
|
|
63
70
|
export declare function measureBaseline(days?: number, paths?: string[]): Baseline | undefined;
|
|
64
71
|
/** What is worth changing in the memory files. */
|
|
65
|
-
export declare function overheadFindings(files: MemoryFile[],
|
|
72
|
+
export declare function overheadFindings(files: MemoryFile[], baselines?: Baseline | Baseline[]): OverheadFinding[];
|
|
66
73
|
export declare function overheadReport(opts?: {
|
|
67
74
|
cwd?: string;
|
|
68
75
|
home?: string;
|
package/dist/overhead.js
CHANGED
|
@@ -18,8 +18,9 @@ import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
|
18
18
|
import { homedir } from "node:os";
|
|
19
19
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
20
20
|
import { forEachLine, listSessions } from "./session.js";
|
|
21
|
-
import { estimateTokens, formatTokens } from "./tokens.js";
|
|
21
|
+
import { estimateTokens, formatTokens, providerFor } from "./tokens.js";
|
|
22
22
|
import { formatUsd, pricingFor } from "./pricing.js";
|
|
23
|
+
import { isGeminiChat, readGeminiMessages } from "./gemini.js";
|
|
23
24
|
import { claudeMcpConfigs, mcpUsage, measureServer, toolPrefixName } from "./mcpschema.js";
|
|
24
25
|
const TTL_MS = 3_600_000;
|
|
25
26
|
const CLAUDE = "claude-opus-5"; // Claude's tokenizer ratios; the model only picks the provider here.
|
|
@@ -139,72 +140,147 @@ export function findMemoryFiles(cwd = process.cwd(), home = homedir()) {
|
|
|
139
140
|
add("Gemini CLI", join(d, "GEMINI.md"), "project");
|
|
140
141
|
return files;
|
|
141
142
|
}
|
|
143
|
+
function textOf(content) {
|
|
144
|
+
if (typeof content === "string")
|
|
145
|
+
return content;
|
|
146
|
+
if (!Array.isArray(content))
|
|
147
|
+
return "";
|
|
148
|
+
return content.map((b) => (typeof b === "string" ? b : typeof b?.text === "string" ? b.text : "")).filter(Boolean).join("\n");
|
|
149
|
+
}
|
|
150
|
+
/** Claude Code: usage rows on assistant messages (one per API reply id). */
|
|
151
|
+
function claudeUsage(path) {
|
|
152
|
+
const out = { agent: "Claude Code", reqs: [] };
|
|
153
|
+
let firstUser;
|
|
154
|
+
let lastId;
|
|
155
|
+
forEachLine(path, (line) => {
|
|
156
|
+
let e;
|
|
157
|
+
try {
|
|
158
|
+
e = JSON.parse(line);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (!e || e.isSidechain)
|
|
164
|
+
return;
|
|
165
|
+
if (e.type === "user" && out.first === undefined && firstUser === undefined && !e.isMeta) {
|
|
166
|
+
const text = textOf(e.message?.content);
|
|
167
|
+
if (text)
|
|
168
|
+
firstUser = text;
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (e.type !== "assistant" || !e.message?.usage || e.message.id === lastId)
|
|
172
|
+
return;
|
|
173
|
+
lastId = e.message.id;
|
|
174
|
+
const u = e.message.usage;
|
|
175
|
+
const prompt = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
|
|
176
|
+
const at = Date.parse(e.timestamp);
|
|
177
|
+
if (!(prompt > 0) || !Number.isFinite(at))
|
|
178
|
+
return;
|
|
179
|
+
out.first ??= Math.max(0, prompt - estimateTokens(firstUser ?? "", CLAUDE));
|
|
180
|
+
out.reqs.push({ at, model: e.message.model });
|
|
181
|
+
});
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
142
184
|
/**
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
185
|
+
* Codex rollouts: token_count events carry each request's input (cached part
|
|
186
|
+
* included). Codex sends AGENTS.md and its environment as user messages;
|
|
187
|
+
* those are overhead, so the first real prompt is the one subtracted.
|
|
146
188
|
*/
|
|
147
|
-
|
|
148
|
-
const
|
|
149
|
-
|
|
189
|
+
function codexUsage(path) {
|
|
190
|
+
const out = { agent: "Codex", reqs: [] };
|
|
191
|
+
let firstUser;
|
|
192
|
+
let model;
|
|
193
|
+
forEachLine(path, (line) => {
|
|
194
|
+
let e;
|
|
195
|
+
try {
|
|
196
|
+
e = JSON.parse(line);
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const p = e?.payload;
|
|
202
|
+
if (!p)
|
|
203
|
+
return;
|
|
204
|
+
if (e.type === "turn_context" && typeof p.model === "string")
|
|
205
|
+
model = p.model;
|
|
206
|
+
if (e.type === "response_item" && p.type === "message" && p.role === "user" && firstUser === undefined) {
|
|
207
|
+
const text = textOf(p.content);
|
|
208
|
+
if (text && !/^\s*(<user_instructions>|<environment_context>|# AGENTS\.md)/.test(text))
|
|
209
|
+
firstUser = text;
|
|
210
|
+
}
|
|
211
|
+
if (e.type === "event_msg" && p.type === "token_count") {
|
|
212
|
+
const input = Number(p.info?.last_token_usage?.input_tokens);
|
|
213
|
+
const at = Date.parse(e.timestamp);
|
|
214
|
+
if (!(input > 0) || !Number.isFinite(at))
|
|
215
|
+
return;
|
|
216
|
+
out.first ??= Math.max(0, input - estimateTokens(firstUser ?? "", model));
|
|
217
|
+
out.reqs.push({ at, model });
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
/** Gemini CLI chats: each gemini message records its request's promptTokenCount. */
|
|
223
|
+
function geminiUsage(path) {
|
|
224
|
+
const out = { agent: "Gemini CLI", reqs: [] };
|
|
225
|
+
let firstUser;
|
|
226
|
+
for (const m of readGeminiMessages(path)) {
|
|
227
|
+
if (m?.type === "user" && firstUser === undefined)
|
|
228
|
+
firstUser = textOf(m.content) || undefined;
|
|
229
|
+
if (m?.type !== "gemini")
|
|
230
|
+
continue;
|
|
231
|
+
const input = Number(m.tokens?.input);
|
|
232
|
+
const at = Date.parse(m.timestamp);
|
|
233
|
+
if (!(input > 0) || !Number.isFinite(at))
|
|
234
|
+
continue;
|
|
235
|
+
out.first ??= Math.max(0, input - estimateTokens(firstUser ?? "", m.model));
|
|
236
|
+
out.reqs.push({ at, model: m.model });
|
|
237
|
+
}
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
function usageOf(path) {
|
|
241
|
+
if (isGeminiChat(path))
|
|
242
|
+
return geminiUsage(path);
|
|
243
|
+
if (/[\\/]\.codex[\\/]/.test(path))
|
|
244
|
+
return codexUsage(path);
|
|
245
|
+
return claudeUsage(path);
|
|
246
|
+
}
|
|
247
|
+
/** Cache writes cost 1.25x input on Anthropic; OpenAI and Google charge no write premium. */
|
|
248
|
+
function writeMultiplier(model) {
|
|
249
|
+
return providerFor(model) === "anthropic" ? 1.25 : 1;
|
|
250
|
+
}
|
|
251
|
+
function aggregate(agent, sessions, since, days) {
|
|
150
252
|
const firsts = [];
|
|
151
|
-
let requests = 0, coldStarts = 0;
|
|
253
|
+
let requests = 0, coldStarts = 0, warm = 0;
|
|
152
254
|
const models = new Map();
|
|
153
|
-
for (const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
let lastId;
|
|
255
|
+
for (const s of sessions) {
|
|
256
|
+
if (s.first !== undefined && s.first > 1000)
|
|
257
|
+
firsts.push(s.first);
|
|
157
258
|
let prevAt;
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
try {
|
|
161
|
-
e = JSON.parse(line);
|
|
162
|
-
}
|
|
163
|
-
catch {
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
166
|
-
if (!e || e.isSidechain)
|
|
167
|
-
return;
|
|
168
|
-
if (e.type === "user" && first === undefined && firstUser === undefined && !e.isMeta) {
|
|
169
|
-
const c = e.message?.content;
|
|
170
|
-
const text = typeof c === "string" ? c : Array.isArray(c) ? c.filter((b) => b?.type === "text").map((b) => b.text).join("\n") : "";
|
|
171
|
-
if (text)
|
|
172
|
-
firstUser = text;
|
|
173
|
-
return;
|
|
174
|
-
}
|
|
175
|
-
if (e.type !== "assistant" || !e.message?.usage || e.message.id === lastId)
|
|
176
|
-
return;
|
|
177
|
-
lastId = e.message.id;
|
|
178
|
-
const u = e.message.usage;
|
|
179
|
-
const prompt = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
|
|
180
|
-
const at = Date.parse(e.timestamp);
|
|
181
|
-
if (!(prompt > 0) || !Number.isFinite(at))
|
|
182
|
-
return;
|
|
183
|
-
if (first === undefined)
|
|
184
|
-
first = Math.max(0, prompt - estimateTokens(firstUser ?? "", CLAUDE));
|
|
185
|
-
if (at >= since) {
|
|
259
|
+
for (const r of s.reqs) {
|
|
260
|
+
if (r.at >= since) {
|
|
186
261
|
requests++;
|
|
187
|
-
if (prevAt === undefined || at - prevAt > TTL_MS)
|
|
262
|
+
if (prevAt === undefined || r.at - prevAt > TTL_MS)
|
|
188
263
|
coldStarts++;
|
|
189
|
-
|
|
190
|
-
|
|
264
|
+
else
|
|
265
|
+
warm++;
|
|
266
|
+
if (r.model)
|
|
267
|
+
models.set(r.model, (models.get(r.model) ?? 0) + 1);
|
|
191
268
|
}
|
|
192
|
-
prevAt = at;
|
|
193
|
-
}
|
|
194
|
-
if (first !== undefined && first > 1000)
|
|
195
|
-
firsts.push(first);
|
|
269
|
+
prevAt = r.at;
|
|
270
|
+
}
|
|
196
271
|
}
|
|
197
272
|
if (firsts.length === 0)
|
|
198
273
|
return undefined;
|
|
199
274
|
firsts.sort((a, b) => a - b);
|
|
200
275
|
const model = [...models.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
|
|
201
276
|
const price = pricingFor(model);
|
|
202
|
-
// Each overhead token is a cache read on warm requests and a
|
|
203
|
-
// (
|
|
277
|
+
// Each overhead token is a cache read on warm requests and a full-price
|
|
278
|
+
// write (with the provider's premium) after every cold start.
|
|
204
279
|
const usdPerKPerMonth = price
|
|
205
|
-
? ((1000 * (
|
|
280
|
+
? ((1000 * (warm * price.cacheReadPerM + coldStarts * writeMultiplier(model) * price.inputPerM)) / 1e6) * (30 / days)
|
|
206
281
|
: undefined;
|
|
207
282
|
return {
|
|
283
|
+
agent,
|
|
208
284
|
sessions: firsts.length,
|
|
209
285
|
median: firsts[Math.floor(firsts.length / 2)],
|
|
210
286
|
p90: firsts[Math.min(firsts.length - 1, Math.floor(firsts.length * 0.9))],
|
|
@@ -215,18 +291,52 @@ export function measureBaseline(days = 30, paths) {
|
|
|
215
291
|
usdPerKPerMonth,
|
|
216
292
|
};
|
|
217
293
|
}
|
|
294
|
+
/**
|
|
295
|
+
* Fixed overhead per session and agent, measured: each session's first
|
|
296
|
+
* request (the API's own count) minus the first user message, plus the
|
|
297
|
+
* request and cold-start counts that turn tokens into a monthly bill.
|
|
298
|
+
* Claude Code, Codex and Gemini CLI sessions, newest `days` only.
|
|
299
|
+
*/
|
|
300
|
+
export function measureBaselines(days = 30, paths) {
|
|
301
|
+
const since = Date.now() - days * 86_400_000;
|
|
302
|
+
const files = paths ?? listSessions(10_000).filter((s) => s.modifiedAt.getTime() >= since).map((s) => s.path);
|
|
303
|
+
const byAgent = new Map();
|
|
304
|
+
for (const path of files) {
|
|
305
|
+
let u;
|
|
306
|
+
try {
|
|
307
|
+
u = usageOf(path);
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
(byAgent.get(u.agent) ?? byAgent.set(u.agent, []).get(u.agent)).push(u);
|
|
313
|
+
}
|
|
314
|
+
const order = ["Claude Code", "Codex", "Gemini CLI"];
|
|
315
|
+
return order.flatMap((a) => {
|
|
316
|
+
const b = byAgent.has(a) ? aggregate(a, byAgent.get(a), since, days) : undefined;
|
|
317
|
+
return b ? [b] : [];
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
/** Claude Code's baseline (the one memory-file prices are quoted against). */
|
|
321
|
+
export function measureBaseline(days = 30, paths) {
|
|
322
|
+
return measureBaselines(days, paths).find((b) => b.agent === "Claude Code");
|
|
323
|
+
}
|
|
218
324
|
function normalize(s) {
|
|
219
325
|
return s.toLowerCase().replace(/\s+/g, " ").trim();
|
|
220
326
|
}
|
|
221
327
|
/** What is worth changing in the memory files. */
|
|
222
|
-
export function overheadFindings(files,
|
|
328
|
+
export function overheadFindings(files, baselines) {
|
|
223
329
|
const out = [];
|
|
224
|
-
const
|
|
330
|
+
const list = Array.isArray(baselines) ? baselines : baselines ? [baselines] : [];
|
|
331
|
+
const cost = (tok, agent) => {
|
|
332
|
+
const b = list.find((x) => x.agent === agent);
|
|
333
|
+
return b?.usdPerKPerMonth ? ` (~${formatUsd((tok / 1000) * b.usdPerKPerMonth)}/month at your usage)` : "";
|
|
334
|
+
};
|
|
225
335
|
for (const f of files) {
|
|
226
336
|
if (f.tokens >= 2000) {
|
|
227
337
|
out.push({
|
|
228
338
|
severity: f.tokens >= 5000 ? "warn" : "info",
|
|
229
|
-
message: `${f.path} is ~${formatTokens(f.tokens)} tokens, read on every ${f.agent} request${f.
|
|
339
|
+
message: `${f.path} is ~${formatTokens(f.tokens)} tokens, read on every ${f.agent} request${cost(f.tokens, f.agent)}.`,
|
|
230
340
|
suggestion: `Keep rules the agent needs on most requests; move reference material to a file it reads when relevant. \`context-doctor overhead split ${f.path}\` shows the split first.`,
|
|
231
341
|
});
|
|
232
342
|
}
|
|
@@ -280,9 +390,12 @@ export function overheadReport(opts = {}) {
|
|
|
280
390
|
const cwd = opts.cwd ?? process.cwd();
|
|
281
391
|
const home = opts.home ?? homedir();
|
|
282
392
|
const files = findMemoryFiles(cwd, home);
|
|
283
|
-
const
|
|
284
|
-
const
|
|
285
|
-
|
|
393
|
+
const baselines = measureBaselines(opts.days ?? 30, opts.paths);
|
|
394
|
+
const baseline = baselines.find((b) => b.agent === "Claude Code");
|
|
395
|
+
// MCP usage comes from Claude Code transcripts only (the tool-name scheme is theirs).
|
|
396
|
+
const claudePaths = opts.paths?.filter((p) => !isGeminiChat(p) && !/[\\/]\.codex[\\/]/.test(p));
|
|
397
|
+
const mcp = { configs: claudeMcpConfigs(cwd, home), usage: mcpUsage(opts.days ?? 30, claudePaths) };
|
|
398
|
+
return { cwd, baseline, baselines, files, findings: overheadFindings(files, baselines), mcp };
|
|
286
399
|
}
|
|
287
400
|
/** Launch each configured server and size its tool definitions, then add the findings they support. */
|
|
288
401
|
export async function measureMcpSizes(r) {
|
|
@@ -323,12 +436,20 @@ export function renderOverhead(r, home = homedir()) {
|
|
|
323
436
|
else {
|
|
324
437
|
out.push("No Claude Code sessions in the period to measure; memory files are sized below.");
|
|
325
438
|
}
|
|
439
|
+
for (const o of r.baselines.filter((x) => x.agent !== "Claude Code")) {
|
|
440
|
+
const tokens = r.files.filter((f) => f.agent === o.agent).reduce((s, f) => s + f.tokens, 0);
|
|
441
|
+
out.push("", `${o.agent}, last ${o.days} days: ${o.sessions} sessions start at a median ~${formatTokens(o.median)} tokens (p90 ~${formatTokens(o.p90)}); ${tokens ? `memory files loaded here ~${formatTokens(tokens)} of it` : "no memory files of its own here"}.` +
|
|
442
|
+
(o.usdPerKPerMonth !== undefined
|
|
443
|
+
? ` Each 1k tokens ~${formatUsd(o.usdPerKPerMonth)} a month (${o.requests.toLocaleString("en-US")} requests, ${o.coldStarts.toLocaleString("en-US")} cold starts, ${o.model} list prices).`
|
|
444
|
+
: ""));
|
|
445
|
+
}
|
|
326
446
|
out.push(...renderMcp(r));
|
|
327
447
|
out.push("", "Memory files loaded in this directory", "─".repeat(56));
|
|
328
448
|
if (r.files.length === 0)
|
|
329
449
|
out.push(" none");
|
|
330
450
|
for (const f of r.files) {
|
|
331
|
-
const
|
|
451
|
+
const fb = r.baselines.find((x) => x.agent === f.agent);
|
|
452
|
+
const usd = fb?.usdPerKPerMonth !== undefined ? ` ${formatUsd((f.tokens / 1000) * fb.usdPerKPerMonth)}/mo` : "";
|
|
332
453
|
out.push(` ${f.agent.padEnd(11)} ~${formatTokens(f.tokens).padStart(5)}${usd.padEnd(12)} ${short(f.path)}${f.via === "project" ? "" : ` (${f.via})`}`);
|
|
333
454
|
}
|
|
334
455
|
out.push("", `Findings (${r.findings.length})`, "─".repeat(56));
|
package/dist/pack.d.ts
CHANGED
|
@@ -94,11 +94,6 @@ export declare const PACK_LIMITS: {
|
|
|
94
94
|
fileBytes: number;
|
|
95
95
|
totalBytes: number;
|
|
96
96
|
};
|
|
97
|
-
/**
|
|
98
|
-
* Read files and directories (recursively, skipping build and dependency
|
|
99
|
-
* folders, binaries, and anything past the size limits) into sources.
|
|
100
|
-
* `skipped` says what was left out and why, so nothing vanishes silently.
|
|
101
|
-
*/
|
|
102
97
|
export declare function readSources(paths: string[], cwd?: string): {
|
|
103
98
|
sources: PackSource[];
|
|
104
99
|
skipped: string[];
|
package/dist/pack.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* and no model calls. Lexical ranking misses pure paraphrase; the outline that
|
|
14
14
|
* comes with every result lets the model pick sections by heading when it does.
|
|
15
15
|
*/
|
|
16
|
+
import { spawnSync } from "node:child_process";
|
|
16
17
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
17
18
|
import { basename, extname, join, relative } from "node:path";
|
|
18
19
|
import { estimateTokens, formatTokens } from "./tokens.js";
|
|
@@ -333,6 +334,19 @@ export const PACK_LIMITS = { files: 500, fileBytes: 5 * 1024 * 1024, totalBytes:
|
|
|
333
334
|
* folders, binaries, and anything past the size limits) into sources.
|
|
334
335
|
* `skipped` says what was left out and why, so nothing vanishes silently.
|
|
335
336
|
*/
|
|
337
|
+
/** Files git would consider part of the repository under `dir` (relative to it), or undefined outside git. */
|
|
338
|
+
function gitFiles(dir) {
|
|
339
|
+
try {
|
|
340
|
+
const r = spawnSync("git", ["-C", dir, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, timeout: 30_000 });
|
|
341
|
+
if (r.status !== 0 || typeof r.stdout !== "string")
|
|
342
|
+
return undefined;
|
|
343
|
+
// --cached lists files deleted from the work tree too; only what exists is read.
|
|
344
|
+
return r.stdout.split("\0").filter((f) => f && existsSync(join(dir, f))).sort();
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
return undefined;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
336
350
|
export function readSources(paths, cwd = process.cwd()) {
|
|
337
351
|
const sources = [];
|
|
338
352
|
const skipped = [];
|
|
@@ -354,6 +368,15 @@ export function readSources(paths, cwd = process.cwd()) {
|
|
|
354
368
|
if (st.isDirectory()) {
|
|
355
369
|
if (!top && (SKIP_DIRS.has(basename(p)) || basename(p).startsWith(".")))
|
|
356
370
|
return;
|
|
371
|
+
// Inside a git repository the repository says what is source: tracked
|
|
372
|
+
// files plus untracked ones not ignored, so .gitignore'd build output,
|
|
373
|
+
// data dumps and secrets stay out. Elsewhere, walk with SKIP_DIRS.
|
|
374
|
+
const listed = top ? gitFiles(p) : undefined;
|
|
375
|
+
if (listed) {
|
|
376
|
+
for (const f of listed)
|
|
377
|
+
visit(join(p, f), false);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
357
380
|
for (const e of readdirSync(p).sort())
|
|
358
381
|
visit(join(p, e), false);
|
|
359
382
|
return;
|
package/dist/proxy.d.ts
CHANGED
|
@@ -48,7 +48,7 @@ export interface ProxyOptions extends OptimizeOptions {
|
|
|
48
48
|
googleUpstream?: string;
|
|
49
49
|
}
|
|
50
50
|
/** Reported by /health so `autopilot status` can tell an outdated service from a current one. */
|
|
51
|
-
export declare const PROXY_VERSION = "0.
|
|
51
|
+
export declare const PROXY_VERSION = "0.29.0";
|
|
52
52
|
/**
|
|
53
53
|
* Remove a leading `/t/<token>` from a request path, or return undefined when
|
|
54
54
|
* the prefix is absent or the token differs. The comparison is constant time
|
package/dist/proxy.js
CHANGED
|
@@ -20,7 +20,7 @@ import { formatTokens, CHARS_PER_TOKEN, providerFor } from "./tokens.js";
|
|
|
20
20
|
import { formatUsd, inputCostUsd, pricingFor } from "./pricing.js";
|
|
21
21
|
import { recordLedger } from "./ledger.js";
|
|
22
22
|
/** Reported by /health so `autopilot status` can tell an outdated service from a current one. */
|
|
23
|
-
export const PROXY_VERSION = "0.
|
|
23
|
+
export const PROXY_VERSION = "0.29.0";
|
|
24
24
|
/** Connection-level headers that must not be forwarded. */
|
|
25
25
|
const SKIP_REQUEST_HEADERS = new Set(["host", "content-length", "connection", "transfer-encoding", "accept-encoding", "expect"]);
|
|
26
26
|
const SKIP_RESPONSE_HEADERS = new Set(["content-length", "content-encoding", "transfer-encoding", "connection"]);
|
package/dist/reads.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `context-doctor reads`: the same files, read into session after session.
|
|
3
|
+
*
|
|
4
|
+
* Agents orient themselves by reading the same files at the start of most
|
|
5
|
+
* sessions: the README, the main module, a config. Each read is written into
|
|
6
|
+
* that session's context and re-read on every later request, and the next
|
|
7
|
+
* session pays for it again. This counts Read calls across recent Claude Code
|
|
8
|
+
* transcripts, groups them by file, and says per file whether a short summary
|
|
9
|
+
* in the project's CLAUDE.md would pay (read in most of the project's
|
|
10
|
+
* sessions) or not (CLAUDE.md is paid on every request, so an occasional read
|
|
11
|
+
* is cheaper left as a read; `pack` can cut it to the part a question needs).
|
|
12
|
+
*/
|
|
13
|
+
export interface RepeatedRead {
|
|
14
|
+
path: string;
|
|
15
|
+
project: string;
|
|
16
|
+
/** Sessions that read it at least once. */
|
|
17
|
+
sessions: number;
|
|
18
|
+
/** Sessions of that project in the period. */
|
|
19
|
+
projectSessions: number;
|
|
20
|
+
reads: number;
|
|
21
|
+
/** Tokens of all its results, summed over every read. */
|
|
22
|
+
tokens: number;
|
|
23
|
+
/** Floor of the cost: each read written to the prompt cache once (1.25x input). */
|
|
24
|
+
usd?: number;
|
|
25
|
+
advice: "summarize" | "pack";
|
|
26
|
+
}
|
|
27
|
+
export interface ReadsReport {
|
|
28
|
+
days: number;
|
|
29
|
+
sessions: number;
|
|
30
|
+
reads: number;
|
|
31
|
+
readTokens: number;
|
|
32
|
+
files: RepeatedRead[];
|
|
33
|
+
}
|
|
34
|
+
export declare function repeatedReads(days?: number, paths?: string[], minSessions?: number): ReadsReport;
|
|
35
|
+
export declare function renderReads(r: ReadsReport, limit?: number, home?: string): string;
|
package/dist/reads.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `context-doctor reads`: the same files, read into session after session.
|
|
3
|
+
*
|
|
4
|
+
* Agents orient themselves by reading the same files at the start of most
|
|
5
|
+
* sessions: the README, the main module, a config. Each read is written into
|
|
6
|
+
* that session's context and re-read on every later request, and the next
|
|
7
|
+
* session pays for it again. This counts Read calls across recent Claude Code
|
|
8
|
+
* transcripts, groups them by file, and says per file whether a short summary
|
|
9
|
+
* in the project's CLAUDE.md would pay (read in most of the project's
|
|
10
|
+
* sessions) or not (CLAUDE.md is paid on every request, so an occasional read
|
|
11
|
+
* is cheaper left as a read; `pack` can cut it to the part a question needs).
|
|
12
|
+
*/
|
|
13
|
+
import { forEachLine, listSessions } from "./session.js";
|
|
14
|
+
import { estimateTokens, formatTokens } from "./tokens.js";
|
|
15
|
+
import { formatUsd, pricingFor } from "./pricing.js";
|
|
16
|
+
const CLAUDE = "claude-opus-5";
|
|
17
|
+
/** An image in a tool result bills as an image (~1.6k tokens for a full-size screenshot), not as its base64. */
|
|
18
|
+
const IMAGE_TOKENS = 1600;
|
|
19
|
+
function resultTokens(content) {
|
|
20
|
+
if (typeof content === "string")
|
|
21
|
+
return estimateTokens(content, CLAUDE);
|
|
22
|
+
if (!Array.isArray(content))
|
|
23
|
+
return estimateTokens(JSON.stringify(content ?? ""), CLAUDE);
|
|
24
|
+
let n = 0;
|
|
25
|
+
for (const part of content) {
|
|
26
|
+
if (part?.type === "image")
|
|
27
|
+
n += IMAGE_TOKENS;
|
|
28
|
+
else if (typeof part?.text === "string")
|
|
29
|
+
n += estimateTokens(part.text, CLAUDE);
|
|
30
|
+
else
|
|
31
|
+
n += estimateTokens(JSON.stringify(part ?? ""), CLAUDE);
|
|
32
|
+
}
|
|
33
|
+
return n;
|
|
34
|
+
}
|
|
35
|
+
/** Read calls in one transcript: path -> {reads, tokens}, plus the session's model and project directory. */
|
|
36
|
+
function sessionReads(path) {
|
|
37
|
+
const pending = new Map();
|
|
38
|
+
const files = new Map();
|
|
39
|
+
let cwd;
|
|
40
|
+
let model;
|
|
41
|
+
forEachLine(path, (line) => {
|
|
42
|
+
if (!line.includes('"tool_use"') && !line.includes('"tool_result"') && cwd !== undefined)
|
|
43
|
+
return;
|
|
44
|
+
let e;
|
|
45
|
+
try {
|
|
46
|
+
e = JSON.parse(line);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (!e || e.isSidechain)
|
|
52
|
+
return;
|
|
53
|
+
if (!cwd && typeof e.cwd === "string")
|
|
54
|
+
cwd = e.cwd;
|
|
55
|
+
const content = e.message?.content;
|
|
56
|
+
if (!Array.isArray(content))
|
|
57
|
+
return;
|
|
58
|
+
if (e.type === "assistant") {
|
|
59
|
+
if (typeof e.message.model === "string")
|
|
60
|
+
model = e.message.model;
|
|
61
|
+
for (const b of content) {
|
|
62
|
+
if (b?.type === "tool_use" && b.name === "Read" && typeof b.input?.file_path === "string")
|
|
63
|
+
pending.set(b.id, b.input.file_path);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
else if (e.type === "user") {
|
|
67
|
+
for (const b of content) {
|
|
68
|
+
if (b?.type !== "tool_result")
|
|
69
|
+
continue;
|
|
70
|
+
const file = pending.get(b.tool_use_id);
|
|
71
|
+
if (!file)
|
|
72
|
+
continue;
|
|
73
|
+
pending.delete(b.tool_use_id);
|
|
74
|
+
const f = files.get(file) ?? { reads: 0, tokens: 0 };
|
|
75
|
+
f.reads++;
|
|
76
|
+
f.tokens += resultTokens(b.content);
|
|
77
|
+
files.set(file, f);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
return { cwd, model, files };
|
|
82
|
+
}
|
|
83
|
+
export function repeatedReads(days = 30, paths, minSessions = 3) {
|
|
84
|
+
const since = Date.now() - days * 86_400_000;
|
|
85
|
+
const list = paths ?? listSessions(10_000).filter((s) => s.path.includes(".claude") && s.modifiedAt.getTime() >= since).map((s) => s.path);
|
|
86
|
+
const byFile = new Map();
|
|
87
|
+
const projectSessions = new Map();
|
|
88
|
+
let reads = 0, readTokens = 0;
|
|
89
|
+
for (const p of list) {
|
|
90
|
+
const s = sessionReads(p);
|
|
91
|
+
const project = s.cwd ?? "?";
|
|
92
|
+
projectSessions.set(project, (projectSessions.get(project) ?? 0) + 1);
|
|
93
|
+
const price = pricingFor(s.model);
|
|
94
|
+
for (const [file, f] of s.files) {
|
|
95
|
+
reads += f.reads;
|
|
96
|
+
readTokens += f.tokens;
|
|
97
|
+
const agg = byFile.get(file) ?? { project, sessions: 0, reads: 0, tokens: 0, usd: 0, priced: false };
|
|
98
|
+
agg.sessions++;
|
|
99
|
+
agg.reads += f.reads;
|
|
100
|
+
agg.tokens += f.tokens;
|
|
101
|
+
if (price) {
|
|
102
|
+
agg.usd += (f.tokens * 1.25 * price.inputPerM) / 1e6;
|
|
103
|
+
agg.priced = true;
|
|
104
|
+
}
|
|
105
|
+
byFile.set(file, agg);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const files = [...byFile.entries()]
|
|
109
|
+
.filter(([, a]) => a.sessions >= minSessions)
|
|
110
|
+
.map(([path, a]) => {
|
|
111
|
+
const ps = projectSessions.get(a.project) ?? a.sessions;
|
|
112
|
+
return {
|
|
113
|
+
path,
|
|
114
|
+
project: a.project,
|
|
115
|
+
sessions: a.sessions,
|
|
116
|
+
projectSessions: ps,
|
|
117
|
+
reads: a.reads,
|
|
118
|
+
tokens: a.tokens,
|
|
119
|
+
usd: a.priced ? a.usd : undefined,
|
|
120
|
+
// In most of the project's sessions: a summary in CLAUDE.md is read anyway, so it replaces the reads.
|
|
121
|
+
advice: a.sessions / ps >= 0.5 ? "summarize" : "pack",
|
|
122
|
+
};
|
|
123
|
+
})
|
|
124
|
+
.sort((a, b) => b.tokens - a.tokens);
|
|
125
|
+
return { days, sessions: list.length, reads, readTokens, files };
|
|
126
|
+
}
|
|
127
|
+
export function renderReads(r, limit = 15, home = process.env.HOME ?? "") {
|
|
128
|
+
const short = (p) => (home && p.startsWith(home) ? "~" + p.slice(home.length) : p);
|
|
129
|
+
const out = ["FILES READ INTO SESSION AFTER SESSION", "═".repeat(56)];
|
|
130
|
+
out.push(`Claude Code, last ${r.days} days: ${r.reads.toLocaleString("en-US")} file reads across ${r.sessions} sessions, ~${formatTokens(r.readTokens)} tokens.`);
|
|
131
|
+
if (r.files.length === 0) {
|
|
132
|
+
out.push("No file was read in 3 or more sessions. Nothing to consolidate.");
|
|
133
|
+
return out.join("\n");
|
|
134
|
+
}
|
|
135
|
+
const repeated = r.files.reduce((s, f) => s + f.tokens, 0);
|
|
136
|
+
out.push(`${r.files.length} files were read in 3+ sessions: ~${formatTokens(repeated)} tokens of reads (${Math.round((repeated / Math.max(1, r.readTokens)) * 100)}% of all read tokens).`, "");
|
|
137
|
+
for (const f of r.files.slice(0, limit)) {
|
|
138
|
+
const usd = f.usd !== undefined ? ` ≥${formatUsd(f.usd)}` : "";
|
|
139
|
+
out.push(` ${String(f.sessions).padStart(3)}/${f.projectSessions} sessions ${String(f.reads).padStart(4)} reads ~${formatTokens(f.tokens).padStart(6)}${usd.padEnd(10)} ${short(f.path)}`);
|
|
140
|
+
}
|
|
141
|
+
if (r.files.length > limit)
|
|
142
|
+
out.push(` … ${r.files.length - limit} more (--json for all)`);
|
|
143
|
+
const summarize = r.files.filter((f) => f.advice === "summarize").slice(0, 5);
|
|
144
|
+
out.push("");
|
|
145
|
+
if (summarize.length) {
|
|
146
|
+
out.push(`Read in most of their project's sessions: ${summarize.map((f) => short(f.path)).join(", ")}.`, " → A few lines in that project's CLAUDE.md saying what sessions need from each file replace most of these reads (CLAUDE.md is read on every request, so keep it to what is needed each time).");
|
|
147
|
+
}
|
|
148
|
+
out.push(" → For the rest, read the part a task needs: `pack_context` (MCP) or `context-doctor pack <file> -q \"…\"` returns the relevant chunks with line ranges.");
|
|
149
|
+
out.push(" (≥ = floor of the cost: each read written to the prompt cache once; every later request of that session re-reads it too.)");
|
|
150
|
+
return out.join("\n");
|
|
151
|
+
}
|
package/dist/tasks.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `context-doctor tasks`: what a unit of finished work costs, per project.
|
|
3
|
+
*
|
|
4
|
+
* Tokens and dollars per session say little on their own; a session that
|
|
5
|
+
* shipped five commits and one that shipped none can cost the same. This
|
|
6
|
+
* prices each recent Claude Code session from the usage its transcript
|
|
7
|
+
* records (input, cache writes at 1.25x, cache reads at 0.1x, output), counts
|
|
8
|
+
* the commits the session itself made (successful `git commit` calls in its
|
|
9
|
+
* shell tool, in whichever repository), and reports per project: sessions,
|
|
10
|
+
* cost, commits, cost per commit, and how much went to sessions that
|
|
11
|
+
* committed nothing.
|
|
12
|
+
*
|
|
13
|
+
* Counting from the transcript attributes each commit to exactly one session
|
|
14
|
+
* and finds commits in nested repositories; a time window over `git log`
|
|
15
|
+
* did neither (a parent folder's repo showed none, a busy repo's unrelated
|
|
16
|
+
* history showed hundreds). Commits made by hand are not counted, nor is
|
|
17
|
+
* subagent traffic.
|
|
18
|
+
*/
|
|
19
|
+
export interface SessionCost {
|
|
20
|
+
path: string;
|
|
21
|
+
cwd?: string;
|
|
22
|
+
model?: string;
|
|
23
|
+
start: number;
|
|
24
|
+
end: number;
|
|
25
|
+
usd: number;
|
|
26
|
+
requests: number;
|
|
27
|
+
/** Successful `git commit` calls the session made. */
|
|
28
|
+
commits: number;
|
|
29
|
+
}
|
|
30
|
+
export interface ProjectTasks {
|
|
31
|
+
project: string;
|
|
32
|
+
sessions: number;
|
|
33
|
+
usd: number;
|
|
34
|
+
commits: number;
|
|
35
|
+
/** Sessions in which the project's repository got no commit, and what they cost. */
|
|
36
|
+
idleSessions: number;
|
|
37
|
+
idleUsd: number;
|
|
38
|
+
usdPerCommit?: number;
|
|
39
|
+
}
|
|
40
|
+
export interface TasksReport {
|
|
41
|
+
days: number;
|
|
42
|
+
projects: ProjectTasks[];
|
|
43
|
+
usd: number;
|
|
44
|
+
commits: number;
|
|
45
|
+
}
|
|
46
|
+
/** One session's bill, priced as the prompt cache bills it, from the usage the transcript records. */
|
|
47
|
+
export declare function sessionCost(path: string): SessionCost | undefined;
|
|
48
|
+
/** `git commit` invocations in a shell command (not --amend, --dry-run or commit-tree). */
|
|
49
|
+
export declare function commitCount(cmd: string): number;
|
|
50
|
+
export declare function costPerTask(days?: number, paths?: string[]): TasksReport;
|
|
51
|
+
export declare function renderTasks(r: TasksReport, limit?: number): string;
|
package/dist/tasks.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `context-doctor tasks`: what a unit of finished work costs, per project.
|
|
3
|
+
*
|
|
4
|
+
* Tokens and dollars per session say little on their own; a session that
|
|
5
|
+
* shipped five commits and one that shipped none can cost the same. This
|
|
6
|
+
* prices each recent Claude Code session from the usage its transcript
|
|
7
|
+
* records (input, cache writes at 1.25x, cache reads at 0.1x, output), counts
|
|
8
|
+
* the commits the session itself made (successful `git commit` calls in its
|
|
9
|
+
* shell tool, in whichever repository), and reports per project: sessions,
|
|
10
|
+
* cost, commits, cost per commit, and how much went to sessions that
|
|
11
|
+
* committed nothing.
|
|
12
|
+
*
|
|
13
|
+
* Counting from the transcript attributes each commit to exactly one session
|
|
14
|
+
* and finds commits in nested repositories; a time window over `git log`
|
|
15
|
+
* did neither (a parent folder's repo showed none, a busy repo's unrelated
|
|
16
|
+
* history showed hundreds). Commits made by hand are not counted, nor is
|
|
17
|
+
* subagent traffic.
|
|
18
|
+
*/
|
|
19
|
+
import { basename } from "node:path";
|
|
20
|
+
import { forEachLine, listSessions } from "./session.js";
|
|
21
|
+
import { formatUsd, pricingFor } from "./pricing.js";
|
|
22
|
+
/** One session's bill, priced as the prompt cache bills it, from the usage the transcript records. */
|
|
23
|
+
export function sessionCost(path) {
|
|
24
|
+
let cwd, model, lastId;
|
|
25
|
+
let start = Infinity, end = 0, usd = 0, requests = 0, commits = 0;
|
|
26
|
+
const commitCalls = new Map();
|
|
27
|
+
forEachLine(path, (line) => {
|
|
28
|
+
if (!line.includes('"usage"') && !line.includes('"tool_result"') && cwd !== undefined)
|
|
29
|
+
return;
|
|
30
|
+
let e;
|
|
31
|
+
try {
|
|
32
|
+
e = JSON.parse(line);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (!e || e.isSidechain)
|
|
38
|
+
return;
|
|
39
|
+
if (!cwd && typeof e.cwd === "string")
|
|
40
|
+
cwd = e.cwd;
|
|
41
|
+
const content = Array.isArray(e.message?.content) ? e.message.content : [];
|
|
42
|
+
if (e.type === "assistant") {
|
|
43
|
+
for (const b of content) {
|
|
44
|
+
const cmd = b?.type === "tool_use" && (b.name === "Bash" || b.name === "shell") ? String(b.input?.command ?? "") : "";
|
|
45
|
+
const n = commitCount(cmd);
|
|
46
|
+
if (n)
|
|
47
|
+
commitCalls.set(b.id, n);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
else if (e.type === "user") {
|
|
51
|
+
for (const b of content) {
|
|
52
|
+
if (b?.type !== "tool_result" || !commitCalls.has(b.tool_use_id))
|
|
53
|
+
continue;
|
|
54
|
+
if (!b.is_error)
|
|
55
|
+
commits += commitCalls.get(b.tool_use_id);
|
|
56
|
+
commitCalls.delete(b.tool_use_id);
|
|
57
|
+
}
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (e.type !== "assistant" || !e.message?.usage || e.message.id === lastId)
|
|
61
|
+
return;
|
|
62
|
+
lastId = e.message.id;
|
|
63
|
+
const at = Date.parse(e.timestamp);
|
|
64
|
+
if (Number.isFinite(at)) {
|
|
65
|
+
start = Math.min(start, at);
|
|
66
|
+
end = Math.max(end, at);
|
|
67
|
+
}
|
|
68
|
+
if (typeof e.message.model === "string")
|
|
69
|
+
model = e.message.model;
|
|
70
|
+
const price = pricingFor(e.message.model ?? model);
|
|
71
|
+
if (!price)
|
|
72
|
+
return;
|
|
73
|
+
const u = e.message.usage;
|
|
74
|
+
const n = (k) => (typeof u[k] === "number" && u[k] > 0 ? u[k] : 0);
|
|
75
|
+
usd += (n("input_tokens") * price.inputPerM + n("cache_creation_input_tokens") * price.inputPerM * 1.25 +
|
|
76
|
+
n("cache_read_input_tokens") * price.cacheReadPerM + n("output_tokens") * price.outputPerM) / 1e6;
|
|
77
|
+
requests++;
|
|
78
|
+
});
|
|
79
|
+
if (!requests || !Number.isFinite(start))
|
|
80
|
+
return undefined;
|
|
81
|
+
return { path, cwd, model, start, end, usd, requests, commits };
|
|
82
|
+
}
|
|
83
|
+
/** `git commit` invocations in a shell command (not --amend, --dry-run or commit-tree). */
|
|
84
|
+
export function commitCount(cmd) {
|
|
85
|
+
let n = 0;
|
|
86
|
+
for (const part of cmd.split(/&&|\|\||;|\n/)) {
|
|
87
|
+
if (/^\s*git(\s+-[cC]\s+\S+)*\s+commit(\s|$)/.test(part) && !/--amend|--dry-run/.test(part))
|
|
88
|
+
n++;
|
|
89
|
+
}
|
|
90
|
+
return n;
|
|
91
|
+
}
|
|
92
|
+
export function costPerTask(days = 30, paths) {
|
|
93
|
+
const since = Date.now() - days * 86_400_000;
|
|
94
|
+
const list = paths ?? listSessions(10_000).filter((s) => s.path.includes(".claude") && s.modifiedAt.getTime() >= since).map((s) => s.path);
|
|
95
|
+
const byProject = new Map();
|
|
96
|
+
for (const p of list) {
|
|
97
|
+
const s = sessionCost(p);
|
|
98
|
+
if (!s || s.end < since)
|
|
99
|
+
continue;
|
|
100
|
+
const project = s.cwd ?? "?";
|
|
101
|
+
const agg = byProject.get(project) ?? { sessions: 0, usd: 0, commits: 0, idle: 0, idleUsd: 0 };
|
|
102
|
+
agg.sessions++;
|
|
103
|
+
agg.usd += s.usd;
|
|
104
|
+
agg.commits += s.commits;
|
|
105
|
+
if (s.commits === 0) {
|
|
106
|
+
agg.idle++;
|
|
107
|
+
agg.idleUsd += s.usd;
|
|
108
|
+
}
|
|
109
|
+
byProject.set(project, agg);
|
|
110
|
+
}
|
|
111
|
+
const projects = [...byProject.entries()]
|
|
112
|
+
.map(([project, a]) => ({
|
|
113
|
+
project,
|
|
114
|
+
sessions: a.sessions,
|
|
115
|
+
usd: a.usd,
|
|
116
|
+
commits: a.commits,
|
|
117
|
+
idleSessions: a.idle,
|
|
118
|
+
idleUsd: a.idleUsd,
|
|
119
|
+
usdPerCommit: a.commits ? a.usd / a.commits : undefined,
|
|
120
|
+
}))
|
|
121
|
+
.sort((a, b) => b.usd - a.usd);
|
|
122
|
+
return { days, projects, usd: projects.reduce((s, p) => s + p.usd, 0), commits: projects.reduce((s, p) => s + p.commits, 0) };
|
|
123
|
+
}
|
|
124
|
+
export function renderTasks(r, limit = 12) {
|
|
125
|
+
const out = ["COST PER TASK (the sessions' own commits as the unit of finished work)", "═".repeat(56)];
|
|
126
|
+
if (r.projects.length === 0)
|
|
127
|
+
return [...out, `No Claude Code sessions in the last ${r.days} days.`].join("\n");
|
|
128
|
+
out.push(`Last ${r.days} days at API list prices: ${formatUsd(r.usd)} across ${r.projects.reduce((s, p) => s + p.sessions, 0)} sessions, ${r.commits} commits.`, "");
|
|
129
|
+
out.push(" project sessions cost commits per commit no-commit sessions");
|
|
130
|
+
for (const p of r.projects.slice(0, limit)) {
|
|
131
|
+
const name = (p.project === "?" ? "?" : basename(p.project)).slice(0, 26).padEnd(26);
|
|
132
|
+
const per = p.usdPerCommit !== undefined ? formatUsd(p.usdPerCommit) : "—";
|
|
133
|
+
const idle = `${p.idleSessions} (${formatUsd(p.idleUsd)})`;
|
|
134
|
+
out.push(` ${name} ${String(p.sessions).padStart(8)} ${formatUsd(p.usd).padStart(8)} ${String(p.commits).padStart(8)} ${per.padStart(11)} ${idle}`);
|
|
135
|
+
}
|
|
136
|
+
if (r.projects.length > limit)
|
|
137
|
+
out.push(` … ${r.projects.length - limit} more (--json for all)`);
|
|
138
|
+
out.push("", "Commits are the sessions' own successful `git commit` calls, in any repository; commits made by hand are not counted.", "Sessions with no commit are not waste by definition (research, reviews, deploys); a large share there is worth a look.");
|
|
139
|
+
return out.join("\n");
|
|
140
|
+
}
|
package/dist/wrap.d.ts
CHANGED
|
@@ -7,9 +7,12 @@
|
|
|
7
7
|
* import { withContextDoctor } from "context-doctor";
|
|
8
8
|
* const client = withContextDoctor(new Anthropic());
|
|
9
9
|
*
|
|
10
|
-
* Works on the official Anthropic and
|
|
11
|
-
* like them): messages.create/stream,
|
|
12
|
-
*
|
|
10
|
+
* Works on the official Anthropic, OpenAI and Google Gen AI SDK clients (and
|
|
11
|
+
* anything shaped like them): messages.create/stream,
|
|
12
|
+
* chat.completions.create/stream/parse, responses.create/stream/parse (beta
|
|
13
|
+
* namespaces included) and models.generateContent/generateContentStream.
|
|
14
|
+
* Gemini chat sessions (ai.chats) keep their history inside the SDK, out of
|
|
15
|
+
* reach; use generateContent with your own history to get autopilot. Each request goes
|
|
13
16
|
* through the same AutoClearer the proxy uses: stale tool output is cleared
|
|
14
17
|
* only when the prompt cache is cold, so a request never costs more.
|
|
15
18
|
*
|
package/dist/wrap.js
CHANGED
|
@@ -7,9 +7,12 @@
|
|
|
7
7
|
* import { withContextDoctor } from "context-doctor";
|
|
8
8
|
* const client = withContextDoctor(new Anthropic());
|
|
9
9
|
*
|
|
10
|
-
* Works on the official Anthropic and
|
|
11
|
-
* like them): messages.create/stream,
|
|
12
|
-
*
|
|
10
|
+
* Works on the official Anthropic, OpenAI and Google Gen AI SDK clients (and
|
|
11
|
+
* anything shaped like them): messages.create/stream,
|
|
12
|
+
* chat.completions.create/stream/parse, responses.create/stream/parse (beta
|
|
13
|
+
* namespaces included) and models.generateContent/generateContentStream.
|
|
14
|
+
* Gemini chat sessions (ai.chats) keep their history inside the SDK, out of
|
|
15
|
+
* reach; use generateContent with your own history to get autopilot. Each request goes
|
|
13
16
|
* through the same AutoClearer the proxy uses: stale tool output is cleared
|
|
14
17
|
* only when the prompt cache is cold, so a request never costs more.
|
|
15
18
|
*
|
|
@@ -39,6 +42,9 @@ const WRAPPED = new Set([
|
|
|
39
42
|
"responses.create",
|
|
40
43
|
"responses.stream",
|
|
41
44
|
"responses.parse",
|
|
45
|
+
// Google's @google/genai: ai.models.generateContent({ model, contents, config })
|
|
46
|
+
"models.generateContent",
|
|
47
|
+
"models.generateContentStream",
|
|
42
48
|
]);
|
|
43
49
|
const PARENTS = new Set([...WRAPPED].flatMap((p) => p.split(".").slice(0, -1).map((_, i, a) => a.slice(0, i + 1).join("."))));
|
|
44
50
|
const STATS = Symbol.for("context-doctor.stats");
|
|
@@ -53,15 +59,22 @@ export function withContextDoctor(client, options = {}) {
|
|
|
53
59
|
const prepare = (method, params) => {
|
|
54
60
|
if (!params || typeof params !== "object" || Array.isArray(params))
|
|
55
61
|
return params;
|
|
62
|
+
// Copy the top level and deep-clone only the history, the one part
|
|
63
|
+
// autopilot rewrites: the rest can hold things that do not clone
|
|
64
|
+
// (an AbortSignal or callable tools in @google/genai's config).
|
|
65
|
+
stats.requests++;
|
|
66
|
+
const src = params;
|
|
67
|
+
const field = ["messages", "input", "contents"].find((k) => Array.isArray(src[k]));
|
|
68
|
+
if (!field)
|
|
69
|
+
return params;
|
|
56
70
|
let body;
|
|
57
71
|
try {
|
|
58
|
-
body = structuredClone(
|
|
72
|
+
body = { ...src, [field]: structuredClone(src[field]) };
|
|
59
73
|
}
|
|
60
74
|
catch {
|
|
61
|
-
return params; // something uncloneable
|
|
75
|
+
return params; // something uncloneable inside the history: leave the request alone
|
|
62
76
|
}
|
|
63
77
|
const result = clearer.apply(body);
|
|
64
|
-
stats.requests++;
|
|
65
78
|
if (result.changed) {
|
|
66
79
|
stats.changed++;
|
|
67
80
|
stats.tokensRemoved += result.tokensRemoved;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "context-doctor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.0",
|
|
4
4
|
"description": "Cut your Claude Code bill, measured on your own sessions: `npx context-doctor savings` shows what each lever saves, then the hook, autopilot and an earlier auto-compact window do it. Also Cursor, Codex, Claude Desktop and any Anthropic or OpenAI API app. MCP server and Claude Code plugin.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent",
|