context-doctor 0.13.5 → 0.14.2

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.
@@ -0,0 +1,44 @@
1
+ /**
2
+ * `context-doctor statusline` — live context health in Claude Code's status bar.
3
+ *
4
+ * Claude Code's `statusLine` setting runs a command on every refresh and shows
5
+ * its first line of stdout. That is "context health where the work happens"
6
+ * without an editor extension: the number that matters, visible while typing.
7
+ *
8
+ * It has to be fast and silent. Fast: the status payload on stdin carries the
9
+ * live context size in recent versions; when it does not, only the tail of the
10
+ * transcript is read (last 256KB, ~1ms on a 20MB file). Silent: any failure
11
+ * prints nothing at all, because a status line that shows an error is worse
12
+ * than one that shows nothing.
13
+ */
14
+ /** The parts of Claude Code's status payload this reads. All optional. */
15
+ interface StatusInput {
16
+ transcript_path?: string;
17
+ model?: {
18
+ id?: string;
19
+ display_name?: string;
20
+ };
21
+ cost?: {
22
+ total_cost_usd?: number;
23
+ };
24
+ context_window?: {
25
+ total_input_tokens?: number;
26
+ context_window_size?: number;
27
+ current_usage?: {
28
+ input_tokens?: number;
29
+ cache_read_input_tokens?: number;
30
+ cache_creation_input_tokens?: number;
31
+ };
32
+ };
33
+ }
34
+ interface LiveUsage {
35
+ tokens: number;
36
+ cacheShare?: number;
37
+ model?: string;
38
+ }
39
+ /** Newest assistant usage from the END of a transcript, without reading the file. */
40
+ export declare function tailUsage(path: string, tailBytes?: number): LiveUsage | undefined;
41
+ /** Build the status line, or null when there is nothing trustworthy to show. */
42
+ export declare function renderStatusLine(input: StatusInput): string | null;
43
+ export declare function runStatusLine(): Promise<void>;
44
+ export {};
@@ -0,0 +1,104 @@
1
+ /**
2
+ * `context-doctor statusline` — live context health in Claude Code's status bar.
3
+ *
4
+ * Claude Code's `statusLine` setting runs a command on every refresh and shows
5
+ * its first line of stdout. That is "context health where the work happens"
6
+ * without an editor extension: the number that matters, visible while typing.
7
+ *
8
+ * It has to be fast and silent. Fast: the status payload on stdin carries the
9
+ * live context size in recent versions; when it does not, only the tail of the
10
+ * transcript is read (last 256KB, ~1ms on a 20MB file). Silent: any failure
11
+ * prints nothing at all, because a status line that shows an error is worse
12
+ * than one that shows nothing.
13
+ */
14
+ import { closeSync, openSync, readSync, statSync } from "node:fs";
15
+ import { contextWindowFor, formatTokens } from "./tokens.js";
16
+ import { formatUsd } from "./pricing.js";
17
+ /** Newest assistant usage from the END of a transcript, without reading the file. */
18
+ export function tailUsage(path, tailBytes = 256 * 1024) {
19
+ try {
20
+ const size = statSync(path).size;
21
+ const n = Math.min(size, tailBytes);
22
+ const fd = openSync(path, "r");
23
+ const buf = Buffer.alloc(n);
24
+ try {
25
+ readSync(fd, buf, 0, n, size - n);
26
+ }
27
+ finally {
28
+ closeSync(fd);
29
+ }
30
+ for (const line of buf.toString("utf8").split("\n").reverse()) {
31
+ let entry;
32
+ try {
33
+ entry = JSON.parse(line);
34
+ }
35
+ catch {
36
+ continue; // the first line of a tail is usually a partial one
37
+ }
38
+ const usage = entry.type === "assistant" ? entry.message?.usage : undefined;
39
+ if (!usage)
40
+ continue;
41
+ const read = usage.cache_read_input_tokens ?? 0;
42
+ const tokens = (usage.input_tokens ?? 0) + read + (usage.cache_creation_input_tokens ?? 0);
43
+ if (tokens > 0)
44
+ return { tokens, cacheShare: read / tokens, model: entry.message?.model };
45
+ }
46
+ }
47
+ catch {
48
+ /* unreadable: the caller shows nothing */
49
+ }
50
+ return undefined;
51
+ }
52
+ /** Ten-cell bar; the visual half of the number. */
53
+ function bar(pct) {
54
+ const filled = Math.max(0, Math.min(10, Math.round(pct / 10)));
55
+ return "▮".repeat(filled) + "░".repeat(10 - filled);
56
+ }
57
+ /** Build the status line, or null when there is nothing trustworthy to show. */
58
+ export function renderStatusLine(input) {
59
+ let live;
60
+ const cw = input.context_window;
61
+ const fromPayload = cw?.current_usage
62
+ ? (cw.current_usage.input_tokens ?? 0) + (cw.current_usage.cache_read_input_tokens ?? 0) + (cw.current_usage.cache_creation_input_tokens ?? 0)
63
+ : cw?.total_input_tokens;
64
+ if (fromPayload && fromPayload > 0) {
65
+ const read = cw?.current_usage?.cache_read_input_tokens;
66
+ live = { tokens: fromPayload, cacheShare: read !== undefined ? read / fromPayload : undefined, model: input.model?.id };
67
+ }
68
+ else if (input.transcript_path) {
69
+ live = tailUsage(input.transcript_path);
70
+ }
71
+ if (!live)
72
+ return null;
73
+ const window = cw?.context_window_size ?? contextWindowFor(live.model ?? input.model?.id);
74
+ const parts = [];
75
+ if (window) {
76
+ const pct = (live.tokens / window) * 100;
77
+ parts.push(`ctx ${formatTokens(live.tokens)}/${formatTokens(window)} ${bar(pct)} ${pct.toFixed(0)}%${pct >= 70 ? " ⚠" : ""}`);
78
+ }
79
+ else {
80
+ parts.push(`ctx ${formatTokens(live.tokens)}`);
81
+ }
82
+ if (live.cacheShare !== undefined)
83
+ parts.push(`cache ${Math.round(live.cacheShare * 100)}%`);
84
+ if (input.cost?.total_cost_usd && input.cost.total_cost_usd > 0)
85
+ parts.push(formatUsd(input.cost.total_cost_usd));
86
+ return parts.join(" · ");
87
+ }
88
+ async function readStdin() {
89
+ const chunks = [];
90
+ for await (const chunk of process.stdin)
91
+ chunks.push(chunk);
92
+ return Buffer.concat(chunks).toString("utf8");
93
+ }
94
+ export async function runStatusLine() {
95
+ try {
96
+ const input = JSON.parse(await readStdin());
97
+ const line = renderStatusLine(input);
98
+ if (line)
99
+ console.log(line);
100
+ }
101
+ catch {
102
+ /* silent by design */
103
+ }
104
+ }
@@ -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.13.5",
3
+ "version": "0.14.2",
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",
@@ -42,7 +42,7 @@
42
42
  "build": "tsc && node -e \"const fs=require('fs');['dist/cli.js','dist/mcp.js'].forEach(f=>fs.chmodSync(f,0o755))\"",
43
43
  "prepublishOnly": "npm test",
44
44
  "dev": "tsc --watch",
45
- "test": "npm run build && node --test dist/test/smoke.test.js dist/test/proxy.test.js dist/test/proxy-abort.test.js dist/test/hook.test.js dist/test/mcp-http.test.js dist/test/doctor.test.js dist/test/watch.test.js dist/test/chatgpt-export.test.js dist/test/config.test.js dist/test/dashboard.test.js dist/test/cursor.test.js dist/test/cache.test.js dist/test/session.test.js dist/test/accuracy.test.js dist/test/cache-stability.test.js dist/test/ledger.test.js"
45
+ "test": "npm run build && node scripts/test.mjs"
46
46
  },
47
47
  "dependencies": {
48
48
  "@modelcontextprotocol/sdk": "^1.0.0",