context-doctor 0.14.1 → 0.14.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -92,7 +92,7 @@ Practical upshot: a developer who only wants cheaper, faster API calls never tou
92
92
  | `context-doctor install` / `uninstall` | Wire (or remove) everything: MCP for Claude Desktop/Code/Cursor, the Agent Skill, the every-prompt hook |
93
93
  | `context-doctor analyze <file>` | Profile a conversation: token breakdown, findings, cost + latency estimates. `--fail-over-budget` exits 1 on a breach, for CI |
94
94
  | `context-doctor optimize <file>` | Apply the safe fixes; add `--strategy trim-tool-calls` for big inline file writes, `--strategy prune-history` for consented lossy compaction |
95
- | `context-doctor session [file]` | Profile a Claude Code session: live context, findings, **measured tokens and prompt-cache economics**, and **where the wall clock went** per tool (from transcript timestamps, permission waits included and said so). Also reads ChatGPT data exports (`conversations.json`) |
95
+ | `context-doctor session [file]` | Profile a Claude Code session: live context, findings, **measured tokens and prompt-cache economics**, **where the wall clock went** per tool, and **what its subagents cost** (their own windows, your bill; never in the parent's profile). Also reads ChatGPT data exports (`conversations.json`) |
96
96
  | `context-doctor init [preset]` | Write a `.contextdoctorrc` from a preset (`chat`, `agent`, `batch`) — a budget you can adopt in one command and tune later |
97
97
  | `context-doctor experiment --task "…"` | Run one task twice from the same commit, in a fresh session and forked from an `--existing` one, same model and tools; compare bill, cache split, wall clock, and whether `--check` passed. The only command here that spends money, so it caps spend per arm and refuses a dirty tree |
98
98
  | `context-doctor diff <before> <after>` | Compare two profiles: what moved by category, which findings were resolved or introduced, and what it saves in money and latency |
@@ -248,6 +248,38 @@ const { conversation, tokensBefore, tokensAfter } = optimizeConversation(chatJso
248
248
  });
249
249
  ```
250
250
 
251
+ ## Subagents: their own windows, your bill
252
+
253
+ A subagent has its own context window, so its tokens are correctly absent from the parent's profile. They are not absent from the bill. Claude Code writes each one to `<session>/subagents/agent-<id>.jsonl`, and `session` now reads them:
254
+
255
+ ```
256
+ Subagents (their own windows, your bill)
257
+ ────────────────────────────────────────────────────────
258
+ 49 subagent(s) made 4560 API calls: 626.0M input billed, 1.0M output, ~$608.51.
259
+ That is 10% on top of the parent session's own input cost ($5846.45), and none of it appears in the profile above.
260
+ $42.52 51 calls ctx 319k 6m You are auditing part of a FastAPI backend at /Users/kp/tech
261
+ $37.48 107 calls ctx 229k 6m You are auditing the Turtle AI backend (FastAPI, Python) at
262
+ … and 44 more
263
+ 18 subagent(s) ended above 200k tokens of context. A subagent that big is doing a main session's job; give it a narrower brief, or split the task.
264
+ ```
265
+
266
+ Per subagent: what it was asked, how many calls it made, the context it ended with, how long it ran, and its cost at list price with cache reads and writes priced correctly. Models without a price on file are counted but marked unpriced rather than costed at zero. On this machine that was 195 subagents across 19 sessions and about $1,844 at list price that no profile had ever shown.
267
+
268
+ ## The same number inside VS Code and Cursor
269
+
270
+ An extension in [`vscode/`](vscode/) puts context health in the editor's own status bar:
271
+
272
+ ```
273
+ ⌁ ctx 848k · 85% · cache 100% ⚠
274
+ ```
275
+
276
+ It reads the newest Claude Code transcript for the open workspace folder, shows live context, share of window and cache share, turns to the warning colour past 70% (configurable), and opens a terminal running `context-doctor session` when clicked. Nothing leaves the machine; it only reads files Claude Code already writes. Until it is on the marketplace, build and install it locally:
277
+
278
+ ```bash
279
+ cd vscode && npm ci && npm run package
280
+ code --install-extension context-doctor-vscode-0.1.0.vsix # or: cursor --install-extension …
281
+ ```
282
+
251
283
  ## Context health in Claude Code's status bar
252
284
 
253
285
  ```bash
package/dist/cli.js CHANGED
@@ -26,6 +26,7 @@ import { measureAccuracy, renderAccuracy } from "./accuracy.js";
26
26
  import { renderDiff } from "./diff.js";
27
27
  import { renderExperiment, runExperiment } from "./experiment.js";
28
28
  import { runStatusLine } from "./statusline.js";
29
+ import { renderSubagents, subagentReport } from "./subagents.js";
29
30
  import { findPreset, PRESETS, RC_FILENAME } from "./config.js";
30
31
  import { runWatch } from "./watch.js";
31
32
  import { exactTokenCount } from "./exact.js";
@@ -387,7 +388,7 @@ function main() {
387
388
  }
388
389
  const profile = profileConversation(parseConversation(parsed.conversationJson), args.model ?? parsed.model);
389
390
  if (args.json) {
390
- console.log(JSON.stringify({ session: { path: parsed.path, title: parsed.title, toolTimings: parsed.toolTimings ?? [] }, profile }, null, 2));
391
+ console.log(JSON.stringify({ session: { path: parsed.path, title: parsed.title, toolTimings: parsed.toolTimings ?? [], subagents: subagentReport(path) }, profile }, null, 2));
391
392
  }
392
393
  else {
393
394
  console.log(`Session: ${parsed.title ?? "(untitled)"}\nFile: ${parsed.path}`);
@@ -403,7 +404,8 @@ function main() {
403
404
  "transcript does not record — so it is larger than the breakdown above, which covers\n" +
404
405
  "conversation messages only. Findings and savings apply to the messages.");
405
406
  }
406
- const cache = renderCacheReport(analyzeCacheUsage(path));
407
+ const cacheUsage = analyzeCacheUsage(path);
408
+ const cache = renderCacheReport(cacheUsage);
407
409
  if (cache) {
408
410
  console.log("");
409
411
  console.log(cache);
@@ -413,6 +415,11 @@ function main() {
413
415
  console.log("");
414
416
  console.log(timing);
415
417
  }
418
+ const subs = renderSubagents(subagentReport(path), cacheUsage?.paidUsd);
419
+ if (subs) {
420
+ console.log("");
421
+ console.log(subs);
422
+ }
416
423
  applyBudgetGate(printBudgetStatus(profile, loadConfig(process.cwd(), (m) => console.error(`context-doctor: ${m}`))), args.failOverBudget);
417
424
  }
418
425
  return;
package/dist/index.d.ts CHANGED
@@ -17,3 +17,4 @@ export { pricingFor, inputCostUsd, estimatedTtftSeconds, formatUsd } from "./pri
17
17
  export type { ModelPricing } from "./pricing.js";
18
18
  export { estimateTokens, contextWindowFor, providerFor, formatTokens } from "./tokens.js";
19
19
  export type { Provider } from "./tokens.js";
20
+ export { renderStatusLine, tailUsage } from "./statusline.js";
package/dist/index.js CHANGED
@@ -10,3 +10,6 @@ export { startProxy } from "./proxy.js";
10
10
  export { listSessions, parseSessionFile } from "./session.js";
11
11
  export { pricingFor, inputCostUsd, estimatedTtftSeconds, formatUsd } from "./pricing.js";
12
12
  export { estimateTokens, contextWindowFor, providerFor, formatTokens } from "./tokens.js";
13
+ // Status-line building blocks, for editor integrations (the VS Code / Cursor
14
+ // extension in ./vscode) that show the same number in their own status bar.
15
+ export { renderStatusLine, tailUsage } from "./statusline.js";
package/dist/mcp.js CHANGED
@@ -37,7 +37,7 @@ const STRATEGY_IDS = ["dedupe", "trim-tool-results", "trim-tool-calls", "strip-b
37
37
  * recommended pattern.
38
38
  */
39
39
  function createServer() {
40
- const server = new McpServer({ name: "context-doctor", version: "0.14.1" }, { instructions: SERVER_INSTRUCTIONS });
40
+ const server = new McpServer({ name: "context-doctor", version: "0.14.3" }, { instructions: SERVER_INSTRUCTIONS });
41
41
  server.tool("profile_context", "Profile an LLM conversation or prompt: token breakdown by category, largest messages, and actionable findings about wasted context (duplicates, oversized tool results, base64 blobs, cache-unfriendly ordering). Accepts OpenAI/Anthropic conversation JSON or raw text. Call this immediately whenever the user asks about token usage, context size, LLM cost, or latency — and proactively offer it once a conversation grows long or accumulates large pasted content.", {
42
42
  conversation: z.string().describe("Conversation JSON (OpenAI or Anthropic format, or bare message array) or raw prompt text"),
43
43
  model: z.string().optional().describe("Target model name for context-window math, e.g. claude-sonnet-5 or gpt-4o"),
package/dist/session.d.ts CHANGED
@@ -72,4 +72,16 @@ export interface ParsedSession {
72
72
  }
73
73
  /** All session transcripts on this machine, newest first. */
74
74
  export declare function listSessions(limit?: number): SessionInfo[];
75
+ /**
76
+ * Read a JSONL transcript line by line without ever materializing the whole
77
+ * file as one string.
78
+ *
79
+ * Agent sessions with large tool results reach hundreds of MB, and those are
80
+ * exactly the sessions that most need analysis — but V8 refuses to build a
81
+ * string past ~512MB, so readFileSync would throw on them (and in the hook,
82
+ * throw *silently*). Streaming has no such ceiling and keeps peak memory at
83
+ * one chunk. StringDecoder carries partial UTF-8 sequences across chunk
84
+ * boundaries so multi-byte characters are never corrupted.
85
+ */
86
+ export declare function forEachLine(path: string, onLine: (line: string) => void): void;
75
87
  export declare function parseSessionFile(path: string): ParsedSession;
package/dist/session.js CHANGED
@@ -119,7 +119,7 @@ function parseChatGPTExport(data, path) {
119
119
  * one chunk. StringDecoder carries partial UTF-8 sequences across chunk
120
120
  * boundaries so multi-byte characters are never corrupted.
121
121
  */
122
- function forEachLine(path, onLine) {
122
+ export function forEachLine(path, onLine) {
123
123
  const fd = openSync(path, "r");
124
124
  const decoder = new StringDecoder("utf8");
125
125
  const buf = Buffer.allocUnsafe(4 * 1024 * 1024);
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Subagent accounting.
3
+ *
4
+ * Every session profile here excludes subagent traffic, correctly: a subagent
5
+ * has its own context window, so its tokens are not in the parent's context.
6
+ * But they are on the parent's bill. Claude Code writes each subagent to its
7
+ * own transcript under `<session>/subagents/agent-<id>.jsonl`, next to the
8
+ * parent's `<session>.jsonl` — which is why scanning the parent for
9
+ * `isSidechain` entries found nothing for weeks. On this machine: 195
10
+ * subagents across 19 sessions whose final contexts sum to 28 million tokens,
11
+ * none of it ever shown.
12
+ *
13
+ * Reads only the lines that matter (usage, first user turn, timestamps) and
14
+ * never the whole content, so a session with 49 subagents stays fast.
15
+ */
16
+ export interface SubagentSummary {
17
+ id: string;
18
+ model?: string;
19
+ /** What the parent asked it to do: first user message, trimmed. */
20
+ task: string;
21
+ /** API calls the subagent made (assistant turns carrying usage). */
22
+ calls: number;
23
+ /** Context size on its last call: what each further turn would have cost. */
24
+ finalContextTokens: number;
25
+ /** Input billed across all its calls, including cache reads and writes. */
26
+ inputBilledTokens: number;
27
+ cacheReadTokens: number;
28
+ outputTokens: number;
29
+ /** Cost across all calls at list price with cache-read discount. */
30
+ estCostUsd?: number;
31
+ durationMs?: number;
32
+ }
33
+ export interface SubagentReport {
34
+ agents: SubagentSummary[];
35
+ totalInputBilled: number;
36
+ totalOutput: number;
37
+ totalCostUsd: number;
38
+ /** Agents whose model has no price on file, so the total undercounts. */
39
+ unpriced: number;
40
+ }
41
+ /** Where Claude Code keeps a session's subagents: beside the transcript, under its id. */
42
+ export declare function subagentDir(transcriptPath: string): string;
43
+ /** Account for every subagent of a session, or null when there are none. */
44
+ export declare function subagentReport(transcriptPath: string): SubagentReport | null;
45
+ /**
46
+ * @param parentInputUsd what the PARENT session's input actually cost across
47
+ * all its calls (from the cache analysis), so the comparison is total against
48
+ * total. Comparing against a per-call figure produced nonsense like "159x".
49
+ */
50
+ export declare function renderSubagents(report: SubagentReport | null, parentInputUsd?: number, top?: number): string | null;
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Subagent accounting.
3
+ *
4
+ * Every session profile here excludes subagent traffic, correctly: a subagent
5
+ * has its own context window, so its tokens are not in the parent's context.
6
+ * But they are on the parent's bill. Claude Code writes each subagent to its
7
+ * own transcript under `<session>/subagents/agent-<id>.jsonl`, next to the
8
+ * parent's `<session>.jsonl` — which is why scanning the parent for
9
+ * `isSidechain` entries found nothing for weeks. On this machine: 195
10
+ * subagents across 19 sessions whose final contexts sum to 28 million tokens,
11
+ * none of it ever shown.
12
+ *
13
+ * Reads only the lines that matter (usage, first user turn, timestamps) and
14
+ * never the whole content, so a session with 49 subagents stays fast.
15
+ */
16
+ import { existsSync, readdirSync, statSync } from "node:fs";
17
+ import { basename, join } from "node:path";
18
+ import { formatTokens } from "./tokens.js";
19
+ import { formatUsd, pricingFor } from "./pricing.js";
20
+ import { forEachLine } from "./session.js";
21
+ /** Where Claude Code keeps a session's subagents: beside the transcript, under its id. */
22
+ export function subagentDir(transcriptPath) {
23
+ return join(transcriptPath.replace(/\.jsonl$/, ""), "subagents");
24
+ }
25
+ function n(v) {
26
+ return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0;
27
+ }
28
+ function summarize(path) {
29
+ const s = {
30
+ id: basename(path, ".jsonl").replace(/^agent-/, ""),
31
+ task: "",
32
+ calls: 0,
33
+ finalContextTokens: 0,
34
+ inputBilledTokens: 0,
35
+ cacheReadTokens: 0,
36
+ outputTokens: 0,
37
+ };
38
+ let first;
39
+ let last;
40
+ let cost = 0;
41
+ let priced = true;
42
+ forEachLine(path, (line) => {
43
+ // Cheap pre-filter: most lines are content we do not need to parse.
44
+ if (!line.includes('"usage"') && !line.includes('"timestamp"') && !s.task)
45
+ return;
46
+ let e;
47
+ try {
48
+ e = JSON.parse(line);
49
+ }
50
+ catch {
51
+ return;
52
+ }
53
+ const at = Date.parse(String(e.timestamp ?? ""));
54
+ if (Number.isFinite(at)) {
55
+ first = first === undefined ? at : Math.min(first, at);
56
+ last = last === undefined ? at : Math.max(last, at);
57
+ }
58
+ const m = e.message;
59
+ if (!m)
60
+ return;
61
+ if (e.type === "user" && !s.task) {
62
+ const c = m.content;
63
+ const text = typeof c === "string" ? c : Array.isArray(c) ? c.map((b) => (typeof b === "string" ? b : b?.text ?? "")).join(" ") : "";
64
+ s.task = text.replace(/\s+/g, " ").trim().slice(0, 140);
65
+ }
66
+ if (e.type === "assistant" && m.usage) {
67
+ const u = m.usage;
68
+ const input = n(u.input_tokens);
69
+ const read = n(u.cache_read_input_tokens);
70
+ const write = n(u.cache_creation_input_tokens);
71
+ const out = n(u.output_tokens);
72
+ if (input + read + write === 0)
73
+ return;
74
+ if (typeof m.model === "string")
75
+ s.model = m.model;
76
+ s.calls++;
77
+ s.finalContextTokens = input + read + write;
78
+ s.inputBilledTokens += input + read + write;
79
+ s.cacheReadTokens += read;
80
+ s.outputTokens += out;
81
+ const p = pricingFor(s.model);
82
+ if (p) {
83
+ // Cache writes bill at 1.25x input; reads at the cache-read rate.
84
+ cost += ((input + write * 1.25) * p.inputPerM + read * p.cacheReadPerM + out * p.outputPerM) / 1_000_000;
85
+ }
86
+ else {
87
+ priced = false;
88
+ }
89
+ }
90
+ });
91
+ if (first !== undefined && last !== undefined)
92
+ s.durationMs = last - first;
93
+ if (priced && s.calls > 0)
94
+ s.estCostUsd = cost;
95
+ return s;
96
+ }
97
+ /** Account for every subagent of a session, or null when there are none. */
98
+ export function subagentReport(transcriptPath) {
99
+ const dir = subagentDir(transcriptPath);
100
+ if (!existsSync(dir))
101
+ return null;
102
+ let files;
103
+ try {
104
+ files = readdirSync(dir).filter((f) => f.startsWith("agent-") && f.endsWith(".jsonl"));
105
+ }
106
+ catch {
107
+ return null;
108
+ }
109
+ if (files.length === 0)
110
+ return null;
111
+ const agents = files
112
+ .map((f) => join(dir, f))
113
+ .filter((p) => {
114
+ try {
115
+ return statSync(p).size > 0;
116
+ }
117
+ catch {
118
+ return false;
119
+ }
120
+ })
121
+ .map(summarize)
122
+ .filter((a) => a.calls > 0)
123
+ .sort((a, b) => (b.estCostUsd ?? 0) - (a.estCostUsd ?? 0) || b.inputBilledTokens - a.inputBilledTokens);
124
+ if (agents.length === 0)
125
+ return null;
126
+ return {
127
+ agents,
128
+ totalInputBilled: agents.reduce((t, a) => t + a.inputBilledTokens, 0),
129
+ totalOutput: agents.reduce((t, a) => t + a.outputTokens, 0),
130
+ totalCostUsd: agents.reduce((t, a) => t + (a.estCostUsd ?? 0), 0),
131
+ unpriced: agents.filter((a) => a.estCostUsd === undefined).length,
132
+ };
133
+ }
134
+ function fmtDuration(ms) {
135
+ if (ms === undefined)
136
+ return "";
137
+ if (ms >= 3_600_000)
138
+ return `${(ms / 3_600_000).toFixed(1)}h`;
139
+ if (ms >= 60_000)
140
+ return `${(ms / 60_000).toFixed(0)}m`;
141
+ return `${(ms / 1000).toFixed(0)}s`;
142
+ }
143
+ /**
144
+ * @param parentInputUsd what the PARENT session's input actually cost across
145
+ * all its calls (from the cache analysis), so the comparison is total against
146
+ * total. Comparing against a per-call figure produced nonsense like "159x".
147
+ */
148
+ export function renderSubagents(report, parentInputUsd, top = 5) {
149
+ if (!report)
150
+ return null;
151
+ const lines = [];
152
+ lines.push("Subagents (their own windows, your bill)");
153
+ lines.push("─".repeat(56));
154
+ const cost = report.unpriced === 0 ? formatUsd(report.totalCostUsd) : `${formatUsd(report.totalCostUsd)}+ (${report.unpriced} unpriced)`;
155
+ lines.push(`${report.agents.length} subagent(s) made ${report.agents.reduce((t, a) => t + a.calls, 0)} API calls: ` +
156
+ `${formatTokens(report.totalInputBilled)} input billed, ${formatTokens(report.totalOutput)} output, ~${cost}.`);
157
+ if (parentInputUsd !== undefined && parentInputUsd > 0 && report.totalCostUsd > 0) {
158
+ const ratio = report.totalCostUsd / parentInputUsd;
159
+ lines.push(ratio >= 1
160
+ ? `That is ${ratio.toFixed(1)}x what the parent session's own input cost (${formatUsd(parentInputUsd)}). None of it appears in the profile above.`
161
+ : `That is ${Math.round(ratio * 100)}% on top of the parent session's own input cost (${formatUsd(parentInputUsd)}), and none of it appears in the profile above.`);
162
+ }
163
+ for (const a of report.agents.slice(0, top)) {
164
+ const cost = a.estCostUsd !== undefined ? formatUsd(a.estCostUsd) : "unpriced";
165
+ lines.push(` ${cost.padStart(8)} ${String(a.calls).padStart(3)} calls ctx ${formatTokens(a.finalContextTokens).padStart(6)} ${fmtDuration(a.durationMs).padStart(4)} ${a.task.slice(0, 60) || "(no task text)"}`);
166
+ }
167
+ if (report.agents.length > top)
168
+ lines.push(` … and ${report.agents.length - top} more`);
169
+ const heavy = report.agents.filter((a) => a.finalContextTokens > 200_000);
170
+ if (heavy.length > 0) {
171
+ lines.push(`${heavy.length} subagent(s) ended above 200k tokens of context. A subagent that big is doing a main session's job; ` +
172
+ "give it a narrower brief, or split the task.");
173
+ }
174
+ return lines.join("\n");
175
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-doctor",
3
- "version": "0.14.1",
3
+ "version": "0.14.3",
4
4
  "description": "Profile and optimize LLM context windows. See what's eating your tokens and fix it — works with Claude, GPT, Gemini, and any MCP-capable AI app.",
5
5
  "keywords": [
6
6
  "llm",