context-doctor 0.28.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 +4 -0
- package/dist/cli.js +16 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/mcp.js +1 -1
- 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/package.json +1 -1
package/README.md
CHANGED
|
@@ -25,6 +25,7 @@ Pick where you work. Each takes about a minute and is fully reversible.
|
|
|
25
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
26
|
| **claude.ai, ChatGPT, phone apps** | `npx context-doctor instructions --copy`, then paste into the app's personal preferences |
|
|
27
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) |
|
|
28
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)) |
|
|
29
30
|
|
|
30
31
|
## What it does
|
|
@@ -32,6 +33,8 @@ Pick where you work. Each takes about a minute and is fully reversible.
|
|
|
32
33
|
**Measure, in dollars, on your own history**
|
|
33
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.
|
|
34
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.
|
|
35
38
|
- `session`, `watch`, `analyze`, `report`, `dashboard`: token breakdown and wasted-context findings for any session, conversation file or agent trace.
|
|
36
39
|
|
|
37
40
|
**Keep waste out of the context**
|
|
@@ -174,6 +177,7 @@ MCP servers are part of the same overhead: each one's tool definitions ride on e
|
|
|
174
177
|
|
|
175
178
|
## What's new
|
|
176
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.
|
|
177
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.
|
|
178
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`.
|
|
179
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.
|
package/dist/cli.js
CHANGED
|
@@ -45,6 +45,8 @@ import { packContextSemantic, readSources, renderPack } from "./pack.js";
|
|
|
45
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));
|
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/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/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",
|