context-doctor 0.23.0 → 0.24.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 CHANGED
@@ -104,6 +104,7 @@ Not claimed, because no process on your machine sends those requests: trimming i
104
104
  ## What's new
105
105
 
106
106
  - **0.20 Autopilot**: stale tool output cleared from every Claude Code request, only when the prompt cache is cold, so it cannot cost more (measured: 9.8% less input cost, ~$1,080 a month on the author's usage, no session worse); runs as a login service on macOS, Linux and Windows; now also for GPT via OpenAI's Chat Completions and Responses APIs.
107
+ - **0.24 A full audit, fixed**: the session parser now rebuilds API messages from Claude Code's one-row-per-block transcripts (it had split each reply into several messages, breaking tool pairing in exported conversations and inflating profiles); the proxy forwards every non-conversation endpoint and never re-encodes bodies it does not rewrite (model listing and file uploads failed or were corrupted through it); `report` and the dashboard no longer count compaction as tokens context-doctor saved, and MCP sketches no longer inflate them; `watch` follows plain conversation files; MCP inputs are bounded. 0.23: four dependency vulnerabilities patched, no false `/compact` alarm after compacting, `savings --share`.
107
108
  - **0.22 Honest about where each lever works, and a new one that works everywhere**: the every-prompt hook now offers `/compact` when you return to a large session after the cache expired (measured: ~$914 a month on the author's history), including in the desktop app. Found and fixed: autopilot cannot reach the desktop app's Code tab, and `doctor` now says so instead of reporting ✓; `savings` counted whole-file history as "last 30 days"; the hook quoted per-message cost at the uncached rate (10x too high). `--version` added.
108
109
  - **0.21 See it before you install it**: `npx context-doctor savings` replays your own recent sessions through autopilot and shows what it would have saved, against what you were actually billed. Install as a **Claude Code plugin** from inside Claude Code. Ready for the official **MCP Registry** (`io.github.KushalP1/context-doctor`), which every release now publishes to; any MCP client can launch it as `npx -y context-doctor mcp`.
109
110
  - **0.20.1 Releases that finish themselves**: one tag publishes to npm and creates a GitHub release with the Claude Desktop bundle (signed when a certificate is configured); the editor extension is ready for the VS Code Marketplace and Open VSX.
@@ -169,7 +170,7 @@ Practical upshot: a developer who only wants cheaper, faster API calls never tou
169
170
  | Command | What it does |
170
171
  |---|---|
171
172
  | `context-doctor install` / `uninstall` | Wire (or remove) everything: MCP for Claude Desktop/Code/Cursor/Codex, the Agent Skill, the every-prompt hook |
172
- | `context-doctor savings [--days n]` | What autopilot saves, or would have saved, on your own recent Claude Code sessions: replayed through the shipped code, against your actual billed usage. A bare `context-doctor` in a terminal shows the same |
173
+ | `context-doctor savings [--days n] [--share [--copy]]` | What your recent Claude Code sessions cost, and what `/compact` at cold resumes and autopilot would save, replayed through the shipped code against your actual billed usage. `--share` prints totals only (no project names or paths) to post. A bare `context-doctor` in a terminal shows the same |
173
174
  | `context-doctor mcp [--http]` | The MCP server as a subcommand, for clients and registries that launch `npx -y context-doctor mcp` |
174
175
  | `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 |
175
176
  | `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 |
@@ -183,7 +184,7 @@ Practical upshot: a developer who only wants cheaper, faster API calls never tou
183
184
  | `context-doctor cursor [--list]` | Profile a chat from Cursor's local history (both storage formats) |
184
185
  | `context-doctor report` | Machine-wide impact report (proxy savings persist across restarts): exact proxy savings, hook activity, recoverable waste in recent sessions |
185
186
  | `context-doctor proxy` | Always-on local proxy that optimizes every Anthropic/OpenAI API request in flight (`/stats` for cumulative savings) |
186
- | `context-doctor watch [file]` | Live monitor of a growing session/agent trace: token/cost line per change, findings as they appear |
187
+ | `context-doctor watch [file]` | Live monitor of a growing session transcript or agent trace (Claude Code / Codex JSONL, or a plain OpenAI/Anthropic conversation JSON file): token/cost line per change, findings as they appear |
187
188
  | `context-doctor doctor` | Self-check the whole installation — one pasteable ✓/✗ diagnosis with fixes |
188
189
  | `context-doctor dashboard` | Local savings dashboard on 127.0.0.1: tokens saved per day, sessions by context in use vs recoverable, budget status |
189
190
  | `context-doctor statusline` | Claude Code status bar: live context vs window, cache share, cost. Wired by `install --statusline`; never overwrites a statusLine you already have |
@@ -281,6 +282,8 @@ The proxy dedupes repeated content, trims stale tool results, and strips base64
281
282
  [context-doctor] POST /v1/messages → 200 in 842ms | optimized 7.3k → 518 tokens (2 changes) | session total: 6.9k tokens ≈ $0.021 saved
282
283
  ```
283
284
 
285
+ Only conversation requests (`/v1/messages`, `/v1/chat/completions`, `/v1/responses`) are ever rewritten. Every other endpoint a client uses through the proxy (listing models, uploading files, counting tokens) is forwarded untouched, to Anthropic when the request carries `anthropic-version` or `x-api-key` and to OpenAI otherwise, and request bodies the proxy does not rewrite reach upstream byte for byte.
286
+
284
287
  `GET http://localhost:8787/stats` returns cumulative savings (requests, tokens, estimated USD), **exact upstream usage** read from every response (JSON and SSE), and **prompt-cache advisories** — the proxy watches your real traffic and flags big stable prefixes missing `cache_control` or prefix churn that silently re-bills the cache. Per-model behavior via `--config`: The advisor now says *where*: for a large system/tools prefix it names the block to mark (the last tool definition, or the last system block), and when the older messages were byte-identical to the previous request it names the exact message to put `cache_control` on, with the token count that run is re-billing each turn. Anyone who already placed breakpoints is left alone.
285
288
 
286
289
  ```json
@@ -521,7 +524,7 @@ One report for your whole machine, led by a headline of **tokens context-doctor
521
524
 
522
525
  - **exact** proxy savings (real before/after on every request),
523
526
  - **exact** savings from every optimization applied via the CLI or the in-chat tools — split by model family (Claude vs GPT), with dollar estimates,
524
- - **observed per-session shrinkage**: real context reductions recorded between the hook's deep checks after hygiene warnings — shown per session in the table alongside remaining waste.
527
+ - and, **on its own line, not in the total**: sessions that got smaller after a hygiene warning. That is mostly compaction, by you or by Claude Code, and a warning may or may not be why it happened, so it is not credited to context-doctor (until 0.24 it was, which overclaimed). The dashboard keeps the same split.
525
528
 
526
529
  Honest measurement note: proxy numbers are exact. Session numbers are measured-now. What no tool can report is the counterfactual — tokens Claude *avoided* adding because of the hygiene guidance — since the same session can't be re-run without it. The report says so instead of inventing a number.
527
530
 
package/dist/autopilot.js CHANGED
@@ -19,7 +19,7 @@
19
19
  * backend, not an API base URL).
20
20
  */
21
21
  import { execFileSync, spawn } from "node:child_process";
22
- import { existsSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
22
+ import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
23
23
  import { homedir, platform } from "node:os";
24
24
  import { dirname, join } from "node:path";
25
25
  import { fileURLToPath } from "node:url";
@@ -215,7 +215,18 @@ export function recentSurfaces(days = 30, home = homedir()) {
215
215
  try {
216
216
  if (statSync(p).mtimeMs < since)
217
217
  continue;
218
- const head = readFileSync(p, "utf8").slice(0, 64 * 1024);
218
+ // The surface is recorded in the first lines; never read a 300 MB
219
+ // transcript to find it (this took 10 s on a real machine).
220
+ const fd = openSync(p, "r");
221
+ const buf = Buffer.alloc(64 * 1024);
222
+ let n = 0;
223
+ try {
224
+ n = readSync(fd, buf, 0, buf.length, 0);
225
+ }
226
+ finally {
227
+ closeSync(fd);
228
+ }
229
+ const head = buf.toString("utf8", 0, n);
219
230
  const m = head.match(/"entrypoint":"([^"]+)"/);
220
231
  const ep = m ? m[1] : "unknown";
221
232
  out.set(ep, (out.get(ep) ?? 0) + 1);
@@ -9,12 +9,14 @@
9
9
  import http from "node:http";
10
10
  export interface DashboardData {
11
11
  generatedAt: string;
12
+ /** tokensSaved: removed by context-doctor itself. shrinkage: sessions that got smaller after a warning (mostly compaction), shown apart. */
12
13
  totals: {
13
14
  tokensSaved: number;
14
15
  usdSaved: number;
15
16
  checks: number;
16
17
  warnings: number;
17
18
  optimizeRuns: number;
19
+ shrinkage: number;
18
20
  };
19
21
  daily: Array<{
20
22
  date: string;
package/dist/dashboard.js CHANGED
@@ -37,7 +37,8 @@ export async function collectDashboardData(proxyPort = 8787) {
37
37
  // real reduction, so it counts alongside explicit optimize runs.
38
38
  const perSession = new Map();
39
39
  for (const c of checks) {
40
- if (!c.sid || typeof c.tok !== "number")
40
+ // "mcp-sketch" was a shared id for unrelated chats (fixed in 0.24); never a session.
41
+ if (!c.sid || c.sid === "mcp-sketch" || typeof c.tok !== "number")
41
42
  continue;
42
43
  perSession.set(c.sid, [...(perSession.get(c.sid) ?? []), c.tok]);
43
44
  }
@@ -94,7 +95,10 @@ export async function collectDashboardData(proxyPort = 8787) {
94
95
  return {
95
96
  generatedAt: new Date().toISOString(),
96
97
  totals: {
97
- tokensSaved: optimizeSaved + shrinkage + (proxy?.tokensSaved ?? 0),
98
+ // What context-doctor removed itself; compaction shrinkage is reported
99
+ // separately (it was counted here until 0.24, which overclaimed).
100
+ tokensSaved: optimizeSaved + (proxy?.tokensSaved ?? 0),
101
+ shrinkage,
98
102
  usdSaved: usdSaved + (proxy?.estUsdSaved ?? 0),
99
103
  checks: checks.length + carried.checks,
100
104
  warnings: checks.filter((c) => c.warn).length + carried.warnings,
package/dist/impact.js CHANGED
@@ -41,7 +41,8 @@ export async function buildImpactReport(proxyPort = 8787) {
41
41
  // checks (compaction/cleanup after a warning), that drop is measured fact.
42
42
  const bySession = new Map();
43
43
  for (const c of checks) {
44
- if (!c.sid || typeof c.tok !== "number")
44
+ // "mcp-sketch" was a shared id for unrelated chats (fixed in 0.24); never a session.
45
+ if (!c.sid || c.sid === "mcp-sketch" || typeof c.tok !== "number")
45
46
  continue;
46
47
  const s = bySession.get(c.sid) ?? { toks: [], warns: 0 };
47
48
  s.toks.push(c.tok);
@@ -77,7 +78,10 @@ export async function buildImpactReport(proxyPort = 8787) {
77
78
  const proxyHistoric = proxyEvents.reduce((s, e) => s + (e.saved ?? 0), 0) + carried.proxySaved;
78
79
  const proxySaved = proxyHistoric + (proxy?.tokensSaved ?? 0);
79
80
  // -- Headline: what context-doctor has saved ----------------------------------
80
- const totalSaved = proxySaved + optimizeSaved + totalReduction;
81
+ // Only what context-doctor itself removed. Sessions also shrink when Claude
82
+ // Code auto-compacts or the user runs /compact; counting that as our saving
83
+ // overclaimed (until 0.24 it was in this total), so it is shown apart.
84
+ const totalSaved = proxySaved + optimizeSaved;
81
85
  lines.push("Tokens context-doctor saved (measured)");
82
86
  lines.push("─".repeat(56));
83
87
  lines.push(`TOTAL: ~${formatTokens(totalSaved)} tokens`);
@@ -97,7 +101,10 @@ export async function buildImpactReport(proxyPort = 8787) {
97
101
  lines.push(` · optimizations applied via CLI/chat tools (exact): ${formatTokens(optimizeSaved)} over ${optimizes.length} run(s)` +
98
102
  (familyNote ? ` [${familyNote}]` : "") +
99
103
  (optimizeUsd > 0 ? ` ≈ ${formatUsd(optimizeUsd)}` : ""));
100
- lines.push(` · observed session shrinkage after hygiene warnings: ${formatTokens(totalReduction)}`);
104
+ lines.push("");
105
+ lines.push(`Sessions that got smaller after a hygiene warning: ${formatTokens(totalReduction)} tokens`);
106
+ lines.push(" (mostly compaction, by you or Claude Code; not counted above, because a warning");
107
+ lines.push(" may or may not be why it happened)");
101
108
  lines.push("");
102
109
  // -- Hook activity ------------------------------------------------------------
103
110
  lines.push("Hygiene activity (every-prompt hook)");
package/dist/mcp.js CHANGED
@@ -49,23 +49,23 @@ const STRATEGY_IDS = ["dedupe", "trim-tool-results", "trim-tool-calls", "strip-b
49
49
  * recommended pattern.
50
50
  */
51
51
  function createServer() {
52
- const server = new McpServer({ name: "context-doctor", version: "0.23.0" }, { instructions: SERVER_INSTRUCTIONS });
52
+ const server = new McpServer({ name: "context-doctor", version: "0.24.0" }, { instructions: SERVER_INSTRUCTIONS });
53
53
  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.", {
54
54
  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`."),
55
55
  sketch: z.object({
56
- turns: z.number().int().nonnegative().describe("User+assistant exchanges so far"),
56
+ turns: z.number().int().nonnegative().max(100_000).describe("User+assistant exchanges so far"),
57
57
  model: z.string().optional().describe("Model this chat runs as, e.g. claude-sonnet-5, gpt-5"),
58
58
  blocks: z.array(z.object({
59
- turn: z.number().int().positive().describe("1-based turn the block sits in"),
59
+ turn: z.number().int().positive().max(100_000).describe("1-based turn the block sits in"),
60
60
  kind: z.enum(["paste", "code", "tool_result", "image", "base64", "text"]),
61
61
  label: z.string().describe("Short name you can refer to later, e.g. 'the nginx config'"),
62
- approx_tokens: z.number().positive().optional(),
63
- approx_lines: z.number().positive().optional(),
64
- approx_words: z.number().positive().optional(),
65
- approx_chars: z.number().positive().optional(),
66
- repeated: z.number().int().positive().optional().describe("Times this same content appears (2+ = duplicate)"),
62
+ approx_tokens: z.number().positive().max(10_000_000).optional(),
63
+ approx_lines: z.number().positive().max(1_000_000).optional(),
64
+ approx_words: z.number().positive().max(5_000_000).optional(),
65
+ approx_chars: z.number().positive().max(40_000_000).optional(),
66
+ repeated: z.number().int().positive().max(1000).optional().describe("Times this same content appears (2+ = duplicate)"),
67
67
  stale: z.boolean().optional().describe("Already acted on; nothing in it is still needed"),
68
- })).describe("Only the blocks over ~500 tokens, repeated, or images. Plain turns need not be listed."),
68
+ })).max(500).describe("Only the blocks over ~500 tokens, repeated, or images. Plain turns need not be listed."),
69
69
  }).optional().describe("Coarse description of the conversation for chat apps. One size hint per block: lines for code, words for prose, chars (or tokens) for logs and tool output, which vary most per line."),
70
70
  model: z.string().optional().describe("Target model name for context-window math, e.g. claude-sonnet-5 or gpt-4o"),
71
71
  }, async ({ conversation, sketch, model }) => {
@@ -85,7 +85,7 @@ function createServer() {
85
85
  conversation: z.string().describe("Conversation JSON (OpenAI or Anthropic format, or bare message array)"),
86
86
  strategies: z.array(z.enum(STRATEGY_IDS)).optional()
87
87
  .describe("Strategies to apply. Default: dedupe, trim-tool-results, strip-base64. Add trim-tool-calls to shrink big inline file writes, or prune-history for lossy compaction of old turns."),
88
- keep_recent: z.number().int().positive().optional().describe("Messages at the tail to leave untouched (default 6)"),
88
+ keep_recent: z.number().int().nonnegative().optional().describe("Messages at the tail to leave untouched (default 6; 0 = none)"),
89
89
  max_tool_result_tokens: z.number().int().positive().optional().describe("Token budget for trimmed tool results (default 300)"),
90
90
  }, async ({ conversation, strategies, keep_recent, max_tool_result_tokens }) => {
91
91
  const result = optimizeConversation(conversation, {
package/dist/proxy.d.ts CHANGED
@@ -47,13 +47,22 @@ export interface ProxyOptions extends OptimizeOptions {
47
47
  openaiUpstream?: string;
48
48
  }
49
49
  /** Reported by /health so `autopilot status` can tell an outdated service from a current one. */
50
- export declare const PROXY_VERSION = "0.23.0";
50
+ export declare const PROXY_VERSION = "0.24.0";
51
51
  /**
52
52
  * Remove a leading `/t/<token>` from a request path, or return undefined when
53
53
  * the prefix is absent or the token differs. The comparison is constant time
54
54
  * so the token cannot be guessed a character at a time.
55
55
  */
56
56
  export declare function stripToken(url: string, token: string): string | undefined;
57
+ /**
58
+ * Where a request goes. The endpoints we optimize are fixed; EVERY other path
59
+ * is forwarded untouched, because a client routed through the proxy (Claude
60
+ * Code under autopilot, an SDK) also lists models, uploads files, counts
61
+ * tokens. Until 0.24 those got a 404 and broke the feature for that client.
62
+ * Unknown paths go to Anthropic when the request says it is one (Anthropic
63
+ * clients always send anthropic-version, or x-api-key), else to OpenAI.
64
+ */
65
+ export declare function upstreamFor(url: string, opts: ProxyOptions, headers?: http.IncomingHttpHeaders): string;
57
66
  export interface ProxyStats {
58
67
  startedAt: string;
59
68
  requests: number;
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.0";
23
+ export const PROXY_VERSION = "0.24.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"]);
@@ -41,13 +41,22 @@ export function stripToken(url, token) {
41
41
  const rest = end === -1 ? "/" : url.slice(end);
42
42
  return rest;
43
43
  }
44
- function upstreamFor(url, opts) {
44
+ /**
45
+ * Where a request goes. The endpoints we optimize are fixed; EVERY other path
46
+ * is forwarded untouched, because a client routed through the proxy (Claude
47
+ * Code under autopilot, an SDK) also lists models, uploads files, counts
48
+ * tokens. Until 0.24 those got a 404 and broke the feature for that client.
49
+ * Unknown paths go to Anthropic when the request says it is one (Anthropic
50
+ * clients always send anthropic-version, or x-api-key), else to OpenAI.
51
+ */
52
+ export function upstreamFor(url, opts, headers = {}) {
53
+ const anthropic = opts.anthropicUpstream ?? "https://api.anthropic.com";
54
+ const openai = opts.openaiUpstream ?? "https://api.openai.com";
45
55
  if (url.startsWith("/v1/messages"))
46
- return opts.anthropicUpstream ?? "https://api.anthropic.com";
47
- if (url.startsWith("/v1/chat/completions") || url.startsWith("/v1/responses") || url.startsWith("/v1/embeddings")) {
48
- return opts.openaiUpstream ?? "https://api.openai.com";
49
- }
50
- return undefined;
56
+ return anthropic;
57
+ if (url.startsWith("/v1/chat/completions") || url.startsWith("/v1/responses") || url.startsWith("/v1/embeddings"))
58
+ return openai;
59
+ return headers["anthropic-version"] !== undefined || headers["x-api-key"] !== undefined ? anthropic : openai;
51
60
  }
52
61
  /** Pull exact usage out of a response body — JSON or SSE, either provider. */
53
62
  function extractUsage(text) {
@@ -125,7 +134,7 @@ export function startProxy(opts = {}) {
125
134
  res.end(JSON.stringify({ ...stats, estUsdSaved: Number(stats.estUsdSaved.toFixed(4)) }, null, 2));
126
135
  return;
127
136
  }
128
- const upstreamBase = upstreamFor(url, opts);
137
+ const upstreamBase = upstreamFor(url, opts, req.headers);
129
138
  if (!upstreamBase) {
130
139
  res.statusCode = 404;
131
140
  res.setHeader("content-type", "application/json");
@@ -135,7 +144,13 @@ export function startProxy(opts = {}) {
135
144
  const chunks = [];
136
145
  for await (const chunk of req)
137
146
  chunks.push(chunk);
138
- let body = Buffer.concat(chunks).toString("utf8");
147
+ // Keep the original bytes: only conversation JSON is ever rewritten, and
148
+ // anything else (a multipart file upload) must reach upstream byte for
149
+ // byte. Decoding everything as UTF-8 corrupted binary bodies.
150
+ const rawBody = Buffer.concat(chunks);
151
+ let body = rawBody.toString("utf8");
152
+ const originalBody = body;
153
+ const conversationPath = /^\/v1\/(messages|chat\/completions|responses)(\?|$)/.test(url);
139
154
  // Optimize the message history in flight. Anything unparseable (or with
140
155
  // no messages array, e.g. embeddings) passes through untouched.
141
156
  // count_tokens is measurement — optimizing it would silently change the
@@ -184,7 +199,7 @@ export function startProxy(opts = {}) {
184
199
  }
185
200
  }
186
201
  }
187
- else if (req.method === "POST" && body && !isMeasurement) {
202
+ else if (req.method === "POST" && body && !isMeasurement && conversationPath) {
188
203
  try {
189
204
  // Per-route overrides: first modelPrefix match wins.
190
205
  let effective = opts;
@@ -284,7 +299,7 @@ export function startProxy(opts = {}) {
284
299
  const upstream = await fetch(upstreamBase + url, {
285
300
  method: req.method ?? "POST",
286
301
  headers,
287
- body: req.method === "GET" || req.method === "HEAD" ? undefined : body,
302
+ body: req.method === "GET" || req.method === "HEAD" ? undefined : body === originalBody ? rawBody : body,
288
303
  });
289
304
  console.error(`[context-doctor] ${req.method} ${url} → ${upstream.status} in ${Date.now() - upstreamStart}ms | ${note}` +
290
305
  (stats.tokensSaved > 0 ? ` | session total: ${formatTokens(stats.tokensSaved)} tokens ≈ ${formatUsd(stats.estUsdSaved)} saved` : ""));
package/dist/session.js CHANGED
@@ -274,6 +274,14 @@ function fromCodexLine(entry) {
274
274
  return null;
275
275
  }
276
276
  }
277
+ /** Message content as a block array (a plain string becomes one text block). */
278
+ function asBlocks(content) {
279
+ if (Array.isArray(content))
280
+ return content;
281
+ if (typeof content === "string")
282
+ return content ? [{ type: "text", text: content }] : [];
283
+ return content == null ? [] : [content];
284
+ }
277
285
  export function parseSessionFile(path) {
278
286
  // ChatGPT exports are one big JSON array, not JSONL — and small enough to
279
287
  // read whole. Only peek first, so multi-hundred-MB JSONL is never slurped.
@@ -293,6 +301,12 @@ export function parseSessionFile(path) {
293
301
  let model;
294
302
  /** Index in `messages` of the newest compaction summary, or -1. */
295
303
  let lastCompactIndex = -1;
304
+ let lastReplyId;
305
+ /** Where each API reply (by message id) lives in `messages`. */
306
+ const replyIndex = new Map();
307
+ let lastSampledReplyId;
308
+ // A compaction summary marks where the live context starts; never merge into it.
309
+ const prevIndexAllowsMerge = (i) => i !== lastCompactIndex;
296
310
  /** Newest API-reported input size, if the transcript carries usage. */
297
311
  let reportedInputTokens;
298
312
  /** Every reported size, positioned — the basis for `context-doctor accuracy`. */
@@ -366,7 +380,16 @@ export function parseSessionFile(path) {
366
380
  usageNumber(usage.cache_creation_input_tokens);
367
381
  if (total > 0) {
368
382
  reportedInputTokens = total;
369
- usageSamples.push({ index: messages.length, input: total });
383
+ // One sample per API reply: Claude Code writes a reply as several
384
+ // rows (one per content block) carrying the same usage. The request's
385
+ // input is everything before the reply's first row.
386
+ const replyId = typeof message.id === "string" ? message.id : undefined;
387
+ if (!replyId || replyId !== lastSampledReplyId) {
388
+ const prevMsg = messages[messages.length - 1];
389
+ const startsNew = !(prevMsg?.role === "assistant" && replyId !== undefined && replyId === lastReplyId);
390
+ usageSamples.push({ index: startsNew ? messages.length : messages.length - 1, input: total });
391
+ lastSampledReplyId = replyId;
392
+ }
370
393
  }
371
394
  }
372
395
  if (entry.isCompactSummary)
@@ -389,7 +412,34 @@ export function parseSessionFile(path) {
389
412
  }
390
413
  }
391
414
  }
392
- messages.push({ role: message.role, content: message.content });
415
+ // Rebuild messages the way the API saw them. Claude Code writes one row
416
+ // per content block, so one reply (text + two tool calls) arrived as three
417
+ // "assistant" messages and its results as two "user" ones: exported
418
+ // conversations broke tool_use/tool_result pairing, and every split added
419
+ // per-message overhead to the profile. The API treats consecutive
420
+ // same-role messages as one; so do we.
421
+ // Parallel tool calls are written as they stream: a reply's later
422
+ // tool_use rows can land after the first tool_result rows. A row that
423
+ // belongs to an earlier reply (same message id) joins that reply.
424
+ const replyId = entry.type === "assistant" && typeof message.id === "string" ? message.id : undefined;
425
+ const home = replyId !== undefined ? replyIndex.get(replyId) : undefined;
426
+ if (home !== undefined && home > lastCompactIndex && home < messages.length - 1) {
427
+ messages[home].content = [...asBlocks(messages[home].content), ...asBlocks(message.content)];
428
+ return;
429
+ }
430
+ const prev = messages[messages.length - 1];
431
+ if (entry.type === "assistant")
432
+ lastReplyId = replyId;
433
+ if (prev && prev.role === message.role && !entry.isCompactSummary && prevIndexAllowsMerge(messages.length - 1)) {
434
+ prev.content = [...asBlocks(prev.content), ...asBlocks(message.content)];
435
+ if (replyId !== undefined)
436
+ replyIndex.set(replyId, messages.length - 1);
437
+ }
438
+ else {
439
+ messages.push({ role: message.role, content: message.content });
440
+ if (replyId !== undefined)
441
+ replyIndex.set(replyId, messages.length - 1);
442
+ }
393
443
  });
394
444
  const toolTimings = [...latenciesByTool.entries()]
395
445
  .map(([tool, ms]) => {
package/dist/sketch.js CHANGED
@@ -186,6 +186,8 @@ export function renderSketchProfile(p) {
186
186
  /** Profile a sketch, log it to the ledger like a hook check, and render. */
187
187
  export function runSketch(sketch) {
188
188
  const profile = profileSketch(sketch);
189
- recordLedger({ ev: "check", sid: "mcp-sketch", src: "mcp", tok: profile.totalTokens, warn: profile.findings.some((f) => f.severity !== "info"), model: sketch.model });
189
+ // No session id: each sketch describes a different chat, and a shared id made
190
+ // the dashboard read "big sketch, then a smaller one" as tokens saved.
191
+ recordLedger({ ev: "check", src: "mcp", tok: profile.totalTokens, warn: profile.findings.some((f) => f.severity !== "info"), model: sketch.model });
190
192
  return renderSketchProfile(profile);
191
193
  }
package/dist/tokens.js CHANGED
@@ -102,6 +102,8 @@ export function formatTokens(n) {
102
102
  // rather than something false.
103
103
  if (!Number.isFinite(n))
104
104
  return "0";
105
+ if (n >= 1_000_000_000)
106
+ return `${(n / 1_000_000_000).toFixed(1)}B`;
105
107
  if (n >= 1_000_000)
106
108
  return `${(n / 1_000_000).toFixed(1)}M`;
107
109
  if (n >= 10_000)
package/dist/watch.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * Polling (not fs.watch) is deliberate: editors/agents rewrite files in ways
8
8
  * that break watchers cross-platform, and a 2s stat is effectively free.
9
9
  */
10
- import { existsSync, statSync } from "node:fs";
10
+ import { existsSync, readFileSync, statSync } from "node:fs";
11
11
  import { listSessions, parseSessionFile } from "./session.js";
12
12
  import { parseConversation } from "./parse.js";
13
13
  import { profileConversation } from "./profile.js";
@@ -31,10 +31,24 @@ export function runWatch(opts) {
31
31
  if (size === lastSize)
32
32
  return; // nothing new — cost of this tick was one stat
33
33
  lastSize = size;
34
+ // A session transcript (Claude Code, Codex, ChatGPT export), or else a
35
+ // plain conversation file (OpenAI/Anthropic messages JSON), which is how
36
+ // most agent frameworks dump a trace. Until 0.24 the second kind was
37
+ // read as an empty session and watch printed nothing.
34
38
  const parsed = parseSessionFile(file);
35
- if (parsed.messageCount === 0)
36
- return;
37
- const profile = profileConversation(parseConversation(parsed.conversationJson), opts.model ?? parsed.model);
39
+ let conversation;
40
+ let model = opts.model;
41
+ if (parsed.messageCount > 0) {
42
+ conversation = parseConversation(parsed.conversationJson);
43
+ model ??= parsed.model;
44
+ }
45
+ else {
46
+ conversation = parseConversation(readFileSync(file, "utf8"));
47
+ if (conversation.parseWarning || conversation.messages.length === 0)
48
+ return;
49
+ model ??= conversation.model;
50
+ }
51
+ const profile = profileConversation(conversation, model);
38
52
  const delta = profile.totalTokens - lastTokens;
39
53
  lastTokens = profile.totalTokens;
40
54
  const cost = profile.cost ? ` · ${formatUsd(profile.cost.perCallUsd)}/msg` : "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-doctor",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "See what your Claude Code sessions cost and cut it: offers /compact when you return to a large session after the cache expired (every surface, desktop app included), and a cache-safe autopilot for terminal, IDE and API traffic. Token profiler, hooks, MCP tools and a Claude Code plugin.",
5
5
  "keywords": [
6
6
  "agent",