context-doctor 0.8.0 → 0.9.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
@@ -310,9 +310,16 @@ A tool that promises speed must be near-free. Measured overhead per touchpoint:
310
310
 
311
311
  Net effect is strongly negative overhead: the tokens these touchpoints save on every subsequent call dwarf what they cost.
312
312
 
313
- ## Why token counts are "~" (and how to make them exact)
313
+ ## Why token counts are "~" (and where they are exact)
314
314
 
315
- Want exact numbers? `analyze --exact` uses the **Anthropic count-tokens API** for Claude models (set `ANTHROPIC_API_KEY`; opt-in network call, key never stored) or **tiktoken** for GPT models (install it next to context-doctor) and reports how far off the heuristic was.
315
+ Counting exactly needs each provider's tokenizer, so the default is a calibrated chars-per-token heuristic (denser for code and JSON). It is good enough to rank what is heavy and to measure the effect of a fix, and it keeps the tool offline and zero-config.
316
+
317
+ Two ways to get real numbers instead:
318
+
319
+ - **`analyze --exact`** uses the Anthropic count-tokens API for Claude models (set `ANTHROPIC_API_KEY`; opt-in network call, key never stored) or tiktoken for GPT models (install it alongside), and reports how far the heuristic drifted.
320
+ - **Sessions report measured tokens automatically.** Claude Code transcripts record what the API actually charged, so `session`, the hook and the reports use that figure when it is present — no key, no estimate.
321
+
322
+ One honest caveat worth knowing: a transcript stores the conversation, **not** the harness's system prompt, tool schemas or skills. Measured against the API's own numbers here, a message-only estimate undercounts the true context by roughly 60%. That is why sessions prefer the reported figure, and why the message breakdown is labelled as covering messages only.
316
323
 
317
324
 
318
325
 
package/dist/cli.js CHANGED
@@ -25,6 +25,7 @@ import { runWatch } from "./watch.js";
25
25
  import { exactTokenCount } from "./exact.js";
26
26
  import { checkBudget, loadConfig } from "./config.js";
27
27
  import { startDashboard } from "./dashboard.js";
28
+ import { listCursorChats, parseCursorChat } from "./cursor.js";
28
29
  const HELP = `context-doctor — profile and optimize LLM context windows
29
30
 
30
31
  Usage:
@@ -35,8 +36,9 @@ Usage:
35
36
  context-doctor install Wire the MCP server + skill into Claude Desktop,
36
37
  Claude Code, and Cursor automatically
37
38
  context-doctor uninstall Undo install
38
- context-doctor session [file] Profile a Claude Code session transcript
39
- (default: the most recent session; --list to browse)
39
+ context-doctor session [file] Profile a Claude Code session transcript or a
40
+ ChatGPT export (default: most recent; --list to browse)
41
+ context-doctor cursor [--list] Profile a Cursor chat from its local history
40
42
  context-doctor hook Claude Code UserPromptSubmit hook (installed
41
43
  automatically by \`install\`; reads hook JSON on stdin)
42
44
  context-doctor report Impact report: exact proxy savings, hook activity,
@@ -185,6 +187,45 @@ function main() {
185
187
  void buildImpactReport(args.port).then((r) => console.log(r));
186
188
  return;
187
189
  }
190
+ if (args.command === "cursor") {
191
+ let chats;
192
+ try {
193
+ chats = listCursorChats();
194
+ }
195
+ catch (e) {
196
+ console.error(`Could not read Cursor history: ${e.message}`);
197
+ process.exit(1);
198
+ }
199
+ if (chats.length === 0) {
200
+ console.error("No Cursor chats found (looked in Cursor's global and workspace storage).");
201
+ process.exit(1);
202
+ }
203
+ if (args.list) {
204
+ for (const c of chats) {
205
+ console.log(`${String(c.messageCount).padStart(5)} msgs ${(c.title ?? "(untitled)").slice(0, 48).padEnd(50)} ${c.composerId}`);
206
+ }
207
+ return;
208
+ }
209
+ const chat = args.file ? chats.find((c) => c.composerId === args.file) ?? chats[0] : chats[0];
210
+ const parsed = parseCursorChat(chat);
211
+ const profile = profileConversation(parseConversation(parsed.conversationJson), args.model ?? parsed.model);
212
+ if (args.json) {
213
+ console.log(JSON.stringify({ chat: { id: chat.composerId, title: chat.title }, profile }, null, 2));
214
+ }
215
+ else {
216
+ console.log(`Cursor chat: ${chat.title ?? "(untitled)"}\nId: ${chat.composerId}\n`);
217
+ console.log(renderProfile(profile));
218
+ if (parsed.reportedInputTokens) {
219
+ console.log("");
220
+ console.log(`Measured context (reported by the API on the last request): ${parsed.reportedInputTokens} tokens.\n` +
221
+ "That figure includes the harness's system prompt, tool schemas and skills, which the\n" +
222
+ "transcript does not record — so it is larger than the breakdown above, which covers\n" +
223
+ "conversation messages only. Findings and savings apply to the messages.");
224
+ }
225
+ printBudgetStatus(profile, loadConfig(process.cwd(), (m) => console.error(`context-doctor: ${m}`)));
226
+ }
227
+ return;
228
+ }
188
229
  if (args.command === "session") {
189
230
  if (args.list) {
190
231
  const sessions = listSessions();
@@ -215,8 +256,19 @@ function main() {
215
256
  console.log(JSON.stringify({ session: { path: parsed.path, title: parsed.title }, profile }, null, 2));
216
257
  }
217
258
  else {
218
- console.log(`Session: ${parsed.title ?? "(untitled)"}\nFile: ${parsed.path}\n`);
259
+ console.log(`Session: ${parsed.title ?? "(untitled)"}\nFile: ${parsed.path}`);
260
+ if (parsed.compactedAway) {
261
+ console.log(`Note: ${parsed.compactedAway} earlier message(s) were compacted away and are NOT counted below — this is the live context the model still sees.`);
262
+ }
263
+ console.log("");
219
264
  console.log(renderProfile(profile));
265
+ if (parsed.reportedInputTokens) {
266
+ console.log("");
267
+ console.log(`Measured context (reported by the API on the last request): ${parsed.reportedInputTokens} tokens.\n` +
268
+ "That figure includes the harness's system prompt, tool schemas and skills, which the\n" +
269
+ "transcript does not record — so it is larger than the breakdown above, which covers\n" +
270
+ "conversation messages only. Findings and savings apply to the messages.");
271
+ }
220
272
  printBudgetStatus(profile, loadConfig(process.cwd(), (m) => console.error(`context-doctor: ${m}`)));
221
273
  }
222
274
  return;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Cursor chat history: profile the conversations Cursor stores locally.
3
+ *
4
+ * Cursor keeps chats in SQLite (`state.vscdb`) rather than files:
5
+ * composerData:<composerId> → { name, conversation: [{bubbleId, type}] }
6
+ * bubbleId:<composerId>:<id> → { type: 1|2, text, toolFormerData, ... }
7
+ * Message text lives in the per-bubble rows, so a conversation is assembled by
8
+ * walking the header list and looking up each bubble.
9
+ *
10
+ * Reading needs SQLite. Node 22.5+ ships `node:sqlite`; older runtimes fall
11
+ * back to the `sqlite3` CLI when it is installed. When neither is available we
12
+ * say so plainly instead of failing obscurely.
13
+ */
14
+ import type { ParsedSession } from "./session.js";
15
+ export interface CursorChat {
16
+ composerId: string;
17
+ title?: string;
18
+ dbPath: string;
19
+ messageCount: number;
20
+ }
21
+ /** Every Cursor state database on this machine, newest first. */
22
+ export declare function findCursorDatabases(): string[];
23
+ interface Row {
24
+ [column: string]: string | number | null;
25
+ }
26
+ /**
27
+ * Run a read-only query against a Cursor database.
28
+ *
29
+ * Two hard rules learned the expensive way:
30
+ * - NEVER copy the file. A real Cursor history is gigabytes; snapshotting it
31
+ * per invocation fills the disk.
32
+ * - Project in SQL, not in JS. Composer rows hold megabytes of JSON each, so
33
+ * listing pulls only the fields it needs via json_extract.
34
+ * SQLite allows concurrent readers, so opening the live database read-only is
35
+ * safe while Cursor is running.
36
+ */
37
+ export declare function queryRows(dbPath: string, sql: string): Row[];
38
+ /** List Cursor chats, biggest first. */
39
+ export declare function listCursorChats(limit?: number): CursorChat[];
40
+ /**
41
+ * Assemble one Cursor chat into a conversation this tool can profile.
42
+ * Tool calls are folded in as tool_use/tool_result blocks so the profiler's
43
+ * tool-result findings apply to Cursor chats exactly as they do elsewhere.
44
+ */
45
+ export declare function parseCursorChat(chat: CursorChat): ParsedSession;
46
+ export {};
package/dist/cursor.js ADDED
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Cursor chat history: profile the conversations Cursor stores locally.
3
+ *
4
+ * Cursor keeps chats in SQLite (`state.vscdb`) rather than files:
5
+ * composerData:<composerId> → { name, conversation: [{bubbleId, type}] }
6
+ * bubbleId:<composerId>:<id> → { type: 1|2, text, toolFormerData, ... }
7
+ * Message text lives in the per-bubble rows, so a conversation is assembled by
8
+ * walking the header list and looking up each bubble.
9
+ *
10
+ * Reading needs SQLite. Node 22.5+ ships `node:sqlite`; older runtimes fall
11
+ * back to the `sqlite3` CLI when it is installed. When neither is available we
12
+ * say so plainly instead of failing obscurely.
13
+ */
14
+ import { execFileSync } from "node:child_process";
15
+ import { existsSync, readdirSync, statSync } from "node:fs";
16
+ import { createRequire } from "node:module";
17
+ import { homedir, platform } from "node:os";
18
+ import { join } from "node:path";
19
+ const require = createRequire(import.meta.url);
20
+ /** Cursor bubble type codes. */
21
+ const BUBBLE_USER = 1;
22
+ /** Header list for a composer record, whichever shape it uses. */
23
+ function headersOf(data) {
24
+ if (Array.isArray(data.conversation) && data.conversation.length > 0)
25
+ return data.conversation;
26
+ return data.fullConversationHeadersOnly ?? [];
27
+ }
28
+ /** Cursor's per-user storage root, per platform. */
29
+ function cursorStorageDirs() {
30
+ const home = homedir();
31
+ const root = platform() === "darwin"
32
+ ? join(home, "Library", "Application Support", "Cursor", "User")
33
+ : platform() === "win32"
34
+ ? join(process.env.APPDATA ?? join(home, "AppData", "Roaming"), "Cursor", "User")
35
+ : join(home, ".config", "Cursor", "User");
36
+ const dirs = [];
37
+ const global = join(root, "globalStorage");
38
+ if (existsSync(global))
39
+ dirs.push(global);
40
+ const ws = join(root, "workspaceStorage");
41
+ if (existsSync(ws)) {
42
+ try {
43
+ for (const entry of readdirSync(ws))
44
+ dirs.push(join(ws, entry));
45
+ }
46
+ catch {
47
+ /* unreadable — skip */
48
+ }
49
+ }
50
+ return dirs;
51
+ }
52
+ /** Every Cursor state database on this machine, newest first. */
53
+ export function findCursorDatabases() {
54
+ const dbs = [];
55
+ for (const dir of cursorStorageDirs()) {
56
+ const db = join(dir, "state.vscdb");
57
+ if (!existsSync(db))
58
+ continue;
59
+ try {
60
+ dbs.push({ path: db, mtime: statSync(db).mtimeMs });
61
+ }
62
+ catch {
63
+ /* unreadable — skip */
64
+ }
65
+ }
66
+ return dbs.sort((a, b) => b.mtime - a.mtime).map((d) => d.path);
67
+ }
68
+ /**
69
+ * Run a read-only query against a Cursor database.
70
+ *
71
+ * Two hard rules learned the expensive way:
72
+ * - NEVER copy the file. A real Cursor history is gigabytes; snapshotting it
73
+ * per invocation fills the disk.
74
+ * - Project in SQL, not in JS. Composer rows hold megabytes of JSON each, so
75
+ * listing pulls only the fields it needs via json_extract.
76
+ * SQLite allows concurrent readers, so opening the live database read-only is
77
+ * safe while Cursor is running.
78
+ */
79
+ export function queryRows(dbPath, sql) {
80
+ try {
81
+ // Preferred: Node's built-in SQLite (22.5+).
82
+ const { DatabaseSync } = require("node:sqlite");
83
+ const db = new DatabaseSync(dbPath, { readOnly: true });
84
+ try {
85
+ return db.prepare(sql).all();
86
+ }
87
+ finally {
88
+ db.close();
89
+ }
90
+ }
91
+ catch (nodeSqliteErr) {
92
+ // Fallback: the sqlite3 CLI, if the user has it.
93
+ try {
94
+ const out = execFileSync("sqlite3", ["-readonly", "-json", dbPath, sql], {
95
+ encoding: "utf8",
96
+ maxBuffer: 512 * 1024 * 1024,
97
+ stdio: ["ignore", "pipe", "ignore"], // a missing table is expected on some DBs
98
+ });
99
+ return out.trim() ? JSON.parse(out) : [];
100
+ }
101
+ catch {
102
+ throw new Error("reading Cursor chats needs SQLite: Node 22.5+ (built-in) or the sqlite3 command. " +
103
+ `Neither worked here (${nodeSqliteErr.message}).`);
104
+ }
105
+ }
106
+ }
107
+ /** List Cursor chats, biggest first. */
108
+ export function listCursorChats(limit = 20) {
109
+ const chats = [];
110
+ for (const dbPath of findCursorDatabases()) {
111
+ let rows;
112
+ try {
113
+ // json_extract keeps megabyte-sized composer blobs in the database.
114
+ rows = queryRows(dbPath, "SELECT key, json_extract(value, '$.name') AS name, " +
115
+ "COALESCE(json_array_length(value, '$.fullConversationHeadersOnly'), " +
116
+ "json_array_length(value, '$.conversation'), 0) AS n " +
117
+ "FROM cursorDiskKV WHERE key LIKE 'composerData:%'");
118
+ }
119
+ catch {
120
+ continue; // no composer table (older Cursor) or no SQLite — skip
121
+ }
122
+ for (const row of rows) {
123
+ const count = Number(row.n ?? 0);
124
+ if (count === 0)
125
+ continue;
126
+ chats.push({
127
+ composerId: String(row.key).slice("composerData:".length),
128
+ title: row.name == null ? undefined : String(row.name),
129
+ dbPath,
130
+ messageCount: count,
131
+ });
132
+ }
133
+ }
134
+ return chats.sort((a, b) => b.messageCount - a.messageCount).slice(0, limit);
135
+ }
136
+ function safeParse(raw) {
137
+ if (typeof raw !== "string")
138
+ return raw ?? {};
139
+ try {
140
+ return JSON.parse(raw);
141
+ }
142
+ catch {
143
+ return raw;
144
+ }
145
+ }
146
+ function stringify(value) {
147
+ return typeof value === "string" ? value : JSON.stringify(value ?? "");
148
+ }
149
+ /**
150
+ * Assemble one Cursor chat into a conversation this tool can profile.
151
+ * Tool calls are folded in as tool_use/tool_result blocks so the profiler's
152
+ * tool-result findings apply to Cursor chats exactly as they do elsewhere.
153
+ */
154
+ export function parseCursorChat(chat) {
155
+ const id = chat.composerId.replace(/[^a-zA-Z0-9-]/g, "");
156
+ const header = queryRows(chat.dbPath, `SELECT value FROM cursorDiskKV WHERE key = 'composerData:${id}'`)[0];
157
+ const record = header ? JSON.parse(String(header.value)) : {};
158
+ const conversation = headersOf(record);
159
+ const bubbleRows = queryRows(chat.dbPath, `SELECT key, value FROM cursorDiskKV WHERE key LIKE 'bubbleId:${id}:%'`);
160
+ const byId = new Map();
161
+ for (const row of bubbleRows) {
162
+ try {
163
+ byId.set(String(row.key).split(":").pop() ?? "", JSON.parse(String(row.value)));
164
+ }
165
+ catch {
166
+ /* malformed bubble — skip */
167
+ }
168
+ }
169
+ const messages = [];
170
+ for (const entry of conversation) {
171
+ // Legacy entries carry their own text; modern ones point at a bubble row.
172
+ const bubble = (entry.bubbleId ? byId.get(entry.bubbleId) : undefined) ?? entry;
173
+ const role = (bubble.type ?? entry.type) === BUBBLE_USER ? "user" : "assistant";
174
+ const text = typeof bubble.text === "string" ? bubble.text : "";
175
+ const tool = bubble.toolFormerData;
176
+ if (tool && (tool.name || tool.rawArgs || tool.result)) {
177
+ if (text)
178
+ messages.push({ role, content: text });
179
+ messages.push({
180
+ role: "assistant",
181
+ content: [{ type: "tool_use", id: entry.bubbleId ?? "tool", name: String(tool.name ?? "tool"), input: safeParse(tool.rawArgs) }],
182
+ });
183
+ if (tool.result !== undefined) {
184
+ messages.push({
185
+ role: "user",
186
+ content: [{ type: "tool_result", tool_use_id: entry.bubbleId ?? "tool", content: stringify(tool.result) }],
187
+ });
188
+ }
189
+ continue;
190
+ }
191
+ if (!text)
192
+ continue;
193
+ messages.push({ role, content: text });
194
+ }
195
+ return {
196
+ conversationJson: JSON.stringify({ messages }),
197
+ title: chat.title,
198
+ // Cursor does not record the model per chat in a stable place; leaving it
199
+ // unset keeps window/cost math honest rather than guessed.
200
+ messageCount: messages.length,
201
+ path: `${chat.dbPath}#${chat.composerId}`,
202
+ };
203
+ }
package/dist/hook.js CHANGED
@@ -85,23 +85,31 @@ export async function runHook() {
85
85
  if (parsed.messageCount === 0)
86
86
  return;
87
87
  const profile = profileConversation(parseConversation(parsed.conversationJson), parsed.model);
88
+ // Prefer the API's own figure when the transcript carries it: it includes
89
+ // the system prompt and tool schemas the transcript omits, so it is the
90
+ // real context size rather than a message-only estimate.
91
+ const liveTokens = parsed.reportedInputTokens ?? profile.totalTokens;
88
92
  // Record this parse so the next prompts take fast path 2.
89
- const shouldWarn = profile.totalTokens >= threshold && profile.totalTokens >= prev.t * REGROWTH_FACTOR;
90
- const nextState = { t: shouldWarn ? profile.totalTokens : prev.t, b: sizeBytes };
93
+ const shouldWarn = liveTokens >= threshold && liveTokens >= prev.t * REGROWTH_FACTOR;
94
+ const nextState = { t: shouldWarn ? liveTokens : prev.t, b: sizeBytes };
91
95
  const entries = Object.entries({ ...state, [sessionId]: nextState });
92
96
  writeFileSync(statePath(), JSON.stringify(Object.fromEntries(entries.slice(-100))));
93
- recordLedger({ ev: "check", sid: sessionId.slice(0, 12), tok: profile.totalTokens, warn: shouldWarn });
97
+ recordLedger({ ev: "check", sid: sessionId.slice(0, 12), tok: liveTokens, warn: shouldWarn });
94
98
  if (!shouldWarn)
95
99
  return;
100
+ const windowPct = profile.contextWindow ? (liveTokens / profile.contextWindow) * 100 : undefined;
101
+ const costPerCall = profile.cost && profile.totalTokens > 0
102
+ ? (profile.cost.perCallUsd * liveTokens) / profile.totalTokens
103
+ : undefined;
96
104
  const lines = [
97
- `This session's context is at ~${formatTokens(profile.totalTokens)} tokens` +
98
- (profile.usagePct ? ` (${profile.usagePct.toFixed(0)}% of the window)` : "") +
99
- (profile.cost ? `, costing ~${formatUsd(profile.cost.perCallUsd)} of input per message` : "") +
105
+ `This session's context is at ~${formatTokens(liveTokens)} tokens` +
106
+ (windowPct !== undefined ? ` (${windowPct.toFixed(0)}% of the window)` : "") +
107
+ (costPerCall !== undefined ? `, costing ~${formatUsd(costPerCall)} of input per message` : "") +
100
108
  ".",
101
109
  "Practice context hygiene from here on: summarize large tool results instead of keeping them verbatim, reference earlier content rather than re-reading or re-quoting it, and keep responses lean.",
102
110
  ];
103
111
  // A configured budget is the user's own limit — say so first and by name.
104
- const verdict = checkBudget(config.budget, profile);
112
+ const verdict = checkBudget(config.budget, { ...profile, totalTokens: liveTokens, usagePct: windowPct });
105
113
  if (verdict.overBudget) {
106
114
  lines.splice(1, 0, `This project's context budget is exceeded: ${verdict.breaches.join("; ")}. Treat compaction as a priority, not an option.`);
107
115
  }
@@ -109,7 +117,7 @@ export async function runHook() {
109
117
  if (topFinding) {
110
118
  lines.push(`Largest recoverable waste: ${topFinding.message} (${topFinding.suggestion})`);
111
119
  }
112
- if (profile.totalTokens > threshold * 2) {
120
+ if (liveTokens > threshold * 2) {
113
121
  lines.push("If it fits the flow, offer the user a compaction of the older history.");
114
122
  }
115
123
  console.log(JSON.stringify({
package/dist/impact.js CHANGED
@@ -102,7 +102,7 @@ export async function buildImpactReport(proxyPort = 8787) {
102
102
  }
103
103
  lines.push("");
104
104
  // -- Measured-now: recent session profiles ------------------------------------
105
- lines.push("Your recent sessions — waste still recoverable today");
105
+ lines.push("Your recent sessions — live context and waste still recoverable");
106
106
  lines.push("─".repeat(56));
107
107
  const sessions = listSessions(8).filter((s) => s.sizeBytes <= MAX_SESSION_BYTES);
108
108
  if (sessions.length === 0) {
package/dist/mcp.js CHANGED
@@ -37,7 +37,7 @@ const STRATEGY_IDS = ["dedupe", "trim-tool-results", "strip-base64", "prune-hist
37
37
  * recommended pattern.
38
38
  */
39
39
  function createServer() {
40
- const server = new McpServer({ name: "context-doctor", version: "0.8.0" }, { instructions: SERVER_INSTRUCTIONS });
40
+ const server = new McpServer({ name: "context-doctor", version: "0.9.0" }, { 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/profile.js CHANGED
@@ -15,6 +15,40 @@ function categoryOf(m) {
15
15
  default: return "other";
16
16
  }
17
17
  }
18
+ /**
19
+ * Total recoverable tokens, counted as a UNION rather than a sum.
20
+ *
21
+ * Findings legitimately overlap: an oversized tool result can also be a
22
+ * near-duplicate, and a long history contains both. Adding their estimates
23
+ * would promise savings the same tokens can only deliver once. Each
24
+ * message-scoped finding is attributed to the message that would actually be
25
+ * removed (the LAST index — for a duplicate pair, the later copy), keeping the
26
+ * largest claim per message; whole-conversation findings then take only what
27
+ * is left unclaimed. The result is capped below the total, since no
28
+ * optimization reclaims an entire context.
29
+ */
30
+ function unionSavings(findings, totalTokens) {
31
+ const perMessage = new Map();
32
+ let unscoped = 0;
33
+ for (const f of findings) {
34
+ if (f.estSavings <= 0)
35
+ continue;
36
+ if (f.messages.length === 0) {
37
+ unscoped += f.estSavings;
38
+ continue;
39
+ }
40
+ const target = f.messages[f.messages.length - 1];
41
+ perMessage.set(target, Math.max(perMessage.get(target) ?? 0, f.estSavings));
42
+ }
43
+ const scoped = [...perMessage.values()].reduce((a, b) => a + b, 0);
44
+ // Whole-conversation findings can only claim tokens no message-scoped
45
+ // finding already claimed.
46
+ const headroom = Math.max(0, totalTokens - scoped);
47
+ const total = scoped + Math.min(unscoped, headroom);
48
+ // A context can never be optimized away entirely; 90% is the ceiling any
49
+ // strategy set could plausibly reach.
50
+ return Math.min(total, Math.round(totalTokens * 0.9));
51
+ }
18
52
  function preview(text, len = 90) {
19
53
  const clean = text.replace(/\s+/g, " ").trim();
20
54
  return clean.length > len ? clean.slice(0, len) + "…" : clean;
@@ -235,7 +269,7 @@ export function profileConversation(conv, model) {
235
269
  .slice(0, 5);
236
270
  const severityRank = { high: 0, warn: 1, info: 2 };
237
271
  findings.sort((a, b) => severityRank[a.severity] - severityRank[b.severity] || b.estSavings - a.estSavings);
238
- const totalEstSavings = findings.reduce((s, f) => s + f.estSavings, 0);
272
+ const totalEstSavings = unionSavings(findings, totalTokens);
239
273
  const pricing = pricingFor(model);
240
274
  let cost;
241
275
  if (pricing) {
package/dist/session.d.ts CHANGED
@@ -15,6 +15,21 @@ export interface SessionInfo {
15
15
  sizeBytes: number;
16
16
  }
17
17
  export interface ParsedSession {
18
+ /**
19
+ * The real input size of the most recent request, as reported by the API
20
+ * (input + cache-read + cache-creation tokens). Transcripts do not contain
21
+ * the harness's system prompt, tool schemas or skills, so an estimate over
22
+ * transcript messages alone undercounts badly — measured against these
23
+ * figures, by roughly 60%. When this is present, prefer it: it is ground
24
+ * truth rather than an estimate.
25
+ */
26
+ reportedInputTokens?: number;
27
+ /**
28
+ * Messages dropped because a compaction replaced them. Reporting live
29
+ * context means counting only what the model still sees; this records what
30
+ * was compacted away so the difference can be shown rather than hidden.
31
+ */
32
+ compactedAway?: number;
18
33
  /** Conversation JSON string in Anthropic-ish format, ready for parseConversation(). */
19
34
  conversationJson: string;
20
35
  title?: string;
package/dist/session.js CHANGED
@@ -89,6 +89,10 @@ export function parseSessionFile(path) {
89
89
  const messages = [];
90
90
  let title;
91
91
  let model;
92
+ /** Index in `messages` of the newest compaction summary, or -1. */
93
+ let lastCompactIndex = -1;
94
+ /** Newest API-reported input size, if the transcript carries usage. */
95
+ let reportedInputTokens;
92
96
  for (const line of raw.split("\n")) {
93
97
  if (!line.trim())
94
98
  continue;
@@ -113,13 +117,28 @@ export function parseSessionFile(path) {
113
117
  continue;
114
118
  if (typeof message.model === "string")
115
119
  model = message.model;
120
+ const usage = message.usage;
121
+ if (entry.type === "assistant" && usage) {
122
+ const total = (usage.input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0);
123
+ if (total > 0)
124
+ reportedInputTokens = total;
125
+ }
126
+ if (entry.isCompactSummary)
127
+ lastCompactIndex = messages.length;
116
128
  messages.push({ role: message.role, content: message.content });
117
129
  }
130
+ // A compaction replaces everything before it: the summary entry IS the live
131
+ // history from that point on. Counting the pre-compaction turns would
132
+ // overstate context, cost per message and window fill — sometimes hugely.
133
+ const compactedAway = lastCompactIndex >= 0 ? lastCompactIndex : 0;
134
+ const live = lastCompactIndex >= 0 ? messages.slice(lastCompactIndex) : messages;
118
135
  return {
119
- conversationJson: JSON.stringify({ messages }),
136
+ conversationJson: JSON.stringify({ messages: live }),
120
137
  title,
121
138
  model,
122
- messageCount: messages.length,
139
+ messageCount: live.length,
140
+ compactedAway,
141
+ reportedInputTokens,
123
142
  path,
124
143
  };
125
144
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-doctor",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
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",
@@ -30,6 +30,7 @@
30
30
  },
31
31
  "files": [
32
32
  "dist",
33
+ "!dist/test",
33
34
  "skills",
34
35
  "README.md",
35
36
  "LICENSE"
@@ -41,7 +42,7 @@
41
42
  "build": "tsc && node -e \"const fs=require('fs');['dist/cli.js','dist/mcp.js'].forEach(f=>fs.chmodSync(f,0o755))\"",
42
43
  "prepublishOnly": "npm run build",
43
44
  "dev": "tsc --watch",
44
- "test": "npm run build && node --test dist/test/smoke.test.js dist/test/proxy.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"
45
+ "test": "npm run build && node --test dist/test/smoke.test.js dist/test/proxy.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"
45
46
  },
46
47
  "dependencies": {
47
48
  "@modelcontextprotocol/sdk": "^1.0.0",
@@ -1,2 +0,0 @@
1
- /** session: ChatGPT data-export (conversations.json) parsing. */
2
- export {};
@@ -1,45 +0,0 @@
1
- /** session: ChatGPT data-export (conversations.json) parsing. */
2
- import { test } from "node:test";
3
- import assert from "node:assert/strict";
4
- import { mkdtempSync, writeFileSync } from "node:fs";
5
- import { tmpdir } from "node:os";
6
- import { join } from "node:path";
7
- import { parseSessionFile } from "../session.js";
8
- function node(id, role, text, t) {
9
- return [id, { id, message: { author: { role }, content: { content_type: "text", parts: [text] }, create_time: t } }];
10
- }
11
- const older = {
12
- title: "Older chat",
13
- update_time: 100,
14
- default_model_slug: "gpt-4o",
15
- mapping: Object.fromEntries([node("a", "user", "old question", 1)]),
16
- };
17
- const newer = {
18
- title: "Trip planning",
19
- update_time: 200,
20
- default_model_slug: "gpt-5",
21
- mapping: Object.fromEntries([
22
- node("r", "system", "You are helpful.", 1),
23
- node("x", "user", "Plan me a trip to Japan with a detailed itinerary please.", 2),
24
- node("y", "assistant", "Day 1: Tokyo. Day 2: Kyoto. Day 3: Osaka with food tour.", 3),
25
- ["tool-node", { id: "tool-node", message: { author: { role: "tool" }, content: { content_type: "text", parts: ["ignored"] }, create_time: 4 } }],
26
- ]),
27
- };
28
- test("parses a ChatGPT export: newest conversation, ordered messages, model detected", () => {
29
- const dir = mkdtempSync(join(tmpdir(), "ctxdoc-gpt-"));
30
- const file = join(dir, "conversations.json");
31
- writeFileSync(file, JSON.stringify([older, newer]));
32
- const parsed = parseSessionFile(file);
33
- assert.equal(parsed.title, "Trip planning");
34
- assert.equal(parsed.model, "gpt-5");
35
- assert.equal(parsed.messageCount, 3); // tool node excluded
36
- const conv = JSON.parse(parsed.conversationJson);
37
- assert.equal(conv.messages[0].role, "system");
38
- assert.equal(conv.messages[1].content.includes("Japan"), true);
39
- });
40
- test("JSONL transcripts still parse (no regression)", () => {
41
- const dir = mkdtempSync(join(tmpdir(), "ctxdoc-jsonl-"));
42
- const file = join(dir, "s.jsonl");
43
- writeFileSync(file, JSON.stringify({ type: "user", message: { role: "user", content: "hi" } }) + "\n");
44
- assert.equal(parseSessionFile(file).messageCount, 1);
45
- });
@@ -1,2 +0,0 @@
1
- /** Project config discovery + context budget verdicts. */
2
- export {};
@@ -1,64 +0,0 @@
1
- /** Project config discovery + context budget verdicts. */
2
- import { test } from "node:test";
3
- import assert from "node:assert/strict";
4
- import { execFile } from "node:child_process";
5
- import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
6
- import { tmpdir } from "node:os";
7
- import { join, dirname } from "node:path";
8
- import { fileURLToPath } from "node:url";
9
- import { checkBudget, loadConfig, RC_FILENAME } from "../config.js";
10
- const cliPath = join(dirname(fileURLToPath(import.meta.url)), "..", "cli.js");
11
- test("loadConfig walks up to the nearest .contextdoctorrc", () => {
12
- const root = mkdtempSync(join(tmpdir(), "ctxdoc-cfg-"));
13
- const nested = join(root, "packages", "app", "src");
14
- mkdirSync(nested, { recursive: true });
15
- writeFileSync(join(root, RC_FILENAME), JSON.stringify({ budget: { maxTokens: 1234 } }));
16
- const loaded = loadConfig(nested);
17
- assert.equal(loaded.config.budget?.maxTokens, 1234);
18
- assert.equal(loaded.path, join(root, RC_FILENAME));
19
- });
20
- test("a malformed rc warns instead of throwing", () => {
21
- const root = mkdtempSync(join(tmpdir(), "ctxdoc-cfg-bad-"));
22
- writeFileSync(join(root, RC_FILENAME), "{ not json");
23
- const warnings = [];
24
- const loaded = loadConfig(root, (m) => warnings.push(m));
25
- assert.deepEqual(loaded.config, {});
26
- assert.equal(warnings.length, 1);
27
- });
28
- test("checkBudget flags each configured limit independently", () => {
29
- const profile = { totalTokens: 200_000, usagePct: 65, cost: { perCallUsd: 1.2 } };
30
- assert.equal(checkBudget(undefined, profile).overBudget, false);
31
- assert.equal(checkBudget({}, profile).overBudget, false);
32
- const tokens = checkBudget({ maxTokens: 100_000 }, profile);
33
- assert.equal(tokens.overBudget, true);
34
- assert.match(tokens.breaches[0], /over the 100000 budget/);
35
- const cost = checkBudget({ maxCostPerMessageUsd: 0.5 }, profile);
36
- assert.match(cost.breaches[0], /per message/);
37
- const window = checkBudget({ maxWindowPct: 50 }, profile);
38
- assert.match(window.breaches[0], /% of the window/);
39
- const all = checkBudget({ maxTokens: 100_000, maxCostPerMessageUsd: 0.5, maxWindowPct: 50 }, profile);
40
- assert.equal(all.breaches.length, 3);
41
- const within = checkBudget({ maxTokens: 500_000, maxWindowPct: 90 }, profile);
42
- assert.equal(within.overBudget, false);
43
- });
44
- test("analyze reports budget status from the project rc", async () => {
45
- const root = mkdtempSync(join(tmpdir(), "ctxdoc-cfg-cli-"));
46
- writeFileSync(join(root, RC_FILENAME), JSON.stringify({ budget: { maxTokens: 10 } }));
47
- const chat = join(root, "chat.json");
48
- writeFileSync(chat, JSON.stringify({ messages: [{ role: "user", content: "a fairly long message ".repeat(40) }] }));
49
- const out = await new Promise((resolve, reject) => {
50
- execFile(process.execPath, [cliPath, "analyze", chat, "--model", "claude-sonnet-5"], { cwd: root }, (err, stdout) => err ? reject(err) : resolve(stdout));
51
- });
52
- assert.ok(out.includes("OVER BUDGET"), `expected budget breach in output:\n${out}`);
53
- assert.ok(out.includes(RC_FILENAME), "names the rc file responsible");
54
- });
55
- test("analyze stays quiet about budgets when no rc exists", async () => {
56
- const root = mkdtempSync(join(tmpdir(), "ctxdoc-cfg-none-"));
57
- const chat = join(root, "chat.json");
58
- writeFileSync(chat, JSON.stringify({ messages: [{ role: "user", content: "hello" }] }));
59
- const out = await new Promise((resolve, reject) => {
60
- // HOME override keeps a real ~/.contextdoctorrc from leaking into the test.
61
- execFile(process.execPath, [cliPath, "analyze", chat], { cwd: root, env: { ...process.env, HOME: root } }, (err, stdout) => err ? reject(err) : resolve(stdout));
62
- });
63
- assert.ok(!out.includes("BUDGET"), "no budget chatter without an rc");
64
- });
@@ -1,2 +0,0 @@
1
- /** Dashboard: local-only server, real data shape, self-contained page. */
2
- export {};
@@ -1,37 +0,0 @@
1
- /** Dashboard: local-only server, real data shape, self-contained page. */
2
- import { test, after } from "node:test";
3
- import assert from "node:assert/strict";
4
- import { startDashboard, collectDashboardData } from "../dashboard.js";
5
- const server = startDashboard({ port: 0 });
6
- await new Promise((r) => server.once("listening", () => r()));
7
- const port = server.address().port;
8
- after(() => server.close());
9
- test("binds loopback only", () => {
10
- assert.equal(server.address().address, "127.0.0.1");
11
- });
12
- test("/api/data returns the documented shape", async () => {
13
- const data = (await (await fetch(`http://127.0.0.1:${port}/api/data`)).json());
14
- for (const key of ["generatedAt", "totals", "daily", "sessions", "proxy", "budget"]) {
15
- assert.ok(key in data, `missing ${key}`);
16
- }
17
- for (const key of ["tokensSaved", "usdSaved", "checks", "warnings", "optimizeRuns"]) {
18
- assert.equal(typeof data.totals[key], "number", `totals.${key} must be numeric`);
19
- }
20
- assert.ok(Array.isArray(data.daily) && Array.isArray(data.sessions));
21
- });
22
- test("page is self-contained: no external requests", async () => {
23
- const html = await (await fetch(`http://127.0.0.1:${port}/`)).text();
24
- assert.ok(html.startsWith("<!doctype html>"));
25
- // Only same-origin data fetch; nothing pulled from the network.
26
- assert.ok(!/https?:\/\//.test(html.replace(/http:\/\/127\.0\.0\.1/g, "")), "page must not reference remote origins");
27
- assert.ok(html.includes("/api/data"));
28
- // Accessibility affordances required by the house chart rules.
29
- assert.ok(html.includes("Table view"), "table view present");
30
- assert.ok(html.includes('role="img"') || html.includes("aria-label"), "charts labelled");
31
- assert.ok(html.includes("prefers-color-scheme: dark"), "dark mode selected, not flipped");
32
- });
33
- test("collectDashboardData works without a proxy running", async () => {
34
- const data = await collectDashboardData(59999); // nothing listens here
35
- assert.equal(data.proxy, null);
36
- assert.ok(data.totals.tokensSaved >= 0);
37
- });
@@ -1,2 +0,0 @@
1
- /** doctor must always produce a diagnosis and exit 0, even on a bare machine. */
2
- export {};
@@ -1,19 +0,0 @@
1
- /** doctor must always produce a diagnosis and exit 0, even on a bare machine. */
2
- import { test } from "node:test";
3
- import assert from "node:assert/strict";
4
- import { execFile } from "node:child_process";
5
- import { mkdtempSync } from "node:fs";
6
- import { tmpdir } from "node:os";
7
- import { join, dirname } from "node:path";
8
- import { fileURLToPath } from "node:url";
9
- const cliPath = join(dirname(fileURLToPath(import.meta.url)), "..", "cli.js");
10
- test("doctor runs, checks the MCP handshake, and exits 0", async () => {
11
- const stateDir = mkdtempSync(join(tmpdir(), "ctxdoc-doctor-"));
12
- const out = await new Promise((resolve, reject) => {
13
- execFile(process.execPath, [cliPath, "doctor"], { env: { ...process.env, CONTEXT_DOCTOR_HOOK_STATE: join(stateDir, "state.json") }, timeout: 20000 }, (err, stdout) => (err ? reject(err) : resolve(stdout)));
14
- });
15
- assert.ok(out.includes("CONTEXT DOCTOR — self-check"));
16
- assert.ok(out.includes("MCP server handshake"));
17
- assert.ok(/✓ MCP server handshake/.test(out), "our own server must pass its own handshake");
18
- assert.ok(out.includes("Ledger"));
19
- });
@@ -1,5 +0,0 @@
1
- /**
2
- * Hook tests: the every-prompt Claude Code hook must stay silent on lean
3
- * sessions, fire with guidance on heavy ones, and rate-limit re-fires.
4
- */
5
- export {};
@@ -1,54 +0,0 @@
1
- /**
2
- * Hook tests: the every-prompt Claude Code hook must stay silent on lean
3
- * sessions, fire with guidance on heavy ones, and rate-limit re-fires.
4
- */
5
- import { test } from "node:test";
6
- import assert from "node:assert/strict";
7
- import { execFile } from "node:child_process";
8
- import { mkdtempSync, writeFileSync } from "node:fs";
9
- import { tmpdir } from "node:os";
10
- import { join, dirname } from "node:path";
11
- import { fileURLToPath } from "node:url";
12
- const cliPath = join(dirname(fileURLToPath(import.meta.url)), "..", "cli.js");
13
- const dir = mkdtempSync(join(tmpdir(), "ctxdoc-hook-"));
14
- const statePath = join(dir, "state.json");
15
- function transcriptLine(role, content) {
16
- return JSON.stringify({ type: role, message: { role, content } });
17
- }
18
- function runHook(transcriptPath, sessionId) {
19
- return new Promise((resolve, reject) => {
20
- const child = execFile(process.execPath, [cliPath, "hook"], { env: { ...process.env, CONTEXT_DOCTOR_HOOK_STATE: statePath } }, (err, stdout) => (err ? reject(err) : resolve(stdout)));
21
- child.stdin.end(JSON.stringify({ session_id: sessionId, transcript_path: transcriptPath }));
22
- });
23
- }
24
- // Lean session: a couple of small turns.
25
- const leanPath = join(dir, "lean.jsonl");
26
- writeFileSync(leanPath, [transcriptLine("user", "hi"), transcriptLine("assistant", "hello!")].join("\n"));
27
- // Heavy session: ~100k tokens of transcript.
28
- const heavyPath = join(dir, "heavy.jsonl");
29
- const bigTurn = "We discussed the deployment pipeline and database migrations at length. ".repeat(80);
30
- writeFileSync(heavyPath, Array.from({ length: 300 }, (_, i) => transcriptLine(i % 2 ? "assistant" : "user", bigTurn)).join("\n"));
31
- test("hook stays silent on a lean session", async () => {
32
- const out = await runHook(leanPath, "lean-session");
33
- assert.equal(out.trim(), "");
34
- });
35
- test("hook fires with hygiene guidance on a heavy session", async () => {
36
- const out = await runHook(heavyPath, "heavy-session");
37
- const parsed = JSON.parse(out);
38
- const ctx = parsed.hookSpecificOutput.additionalContext;
39
- assert.equal(parsed.hookSpecificOutput.hookEventName, "UserPromptSubmit");
40
- assert.ok(ctx.includes("<context-doctor>"));
41
- assert.ok(/context is at ~\d/.test(ctx), "reports the measured size");
42
- assert.ok(ctx.includes("context hygiene"));
43
- });
44
- test("hook rate-limits: second prompt in the same heavy session is silent", async () => {
45
- const out = await runHook(heavyPath, "heavy-session");
46
- assert.equal(out.trim(), "");
47
- });
48
- test("hook never errors on malformed input", async () => {
49
- const out = await new Promise((resolve, reject) => {
50
- const child = execFile(process.execPath, [cliPath, "hook"], (err, stdout) => err ? reject(err) : resolve(stdout));
51
- child.stdin.end("this is not json");
52
- });
53
- assert.equal(out.trim(), "");
54
- });
@@ -1,6 +0,0 @@
1
- /**
2
- * MCP streamable-HTTP transport: spawn `mcp.js --http`, run the initialize
3
- * handshake and a tool call over plain HTTP, exactly as a URL-based client
4
- * (e.g. a ChatGPT developer-mode connector) would.
5
- */
6
- export {};
@@ -1,63 +0,0 @@
1
- /**
2
- * MCP streamable-HTTP transport: spawn `mcp.js --http`, run the initialize
3
- * handshake and a tool call over plain HTTP, exactly as a URL-based client
4
- * (e.g. a ChatGPT developer-mode connector) would.
5
- */
6
- import { test, after } from "node:test";
7
- import assert from "node:assert/strict";
8
- import { spawn } from "node:child_process";
9
- import { join, dirname } from "node:path";
10
- import { fileURLToPath } from "node:url";
11
- const mcpPath = join(dirname(fileURLToPath(import.meta.url)), "..", "mcp.js");
12
- const PORT = 8898;
13
- const child = spawn(process.execPath, [mcpPath, "--http", "--port", String(PORT)], { stdio: ["ignore", "ignore", "pipe"] });
14
- await new Promise((resolve, reject) => {
15
- const timer = setTimeout(() => reject(new Error("HTTP MCP server did not start")), 8000);
16
- child.stderr.on("data", (d) => {
17
- if (d.toString().includes("streamable HTTP")) {
18
- clearTimeout(timer);
19
- resolve();
20
- }
21
- });
22
- });
23
- after(() => child.kill());
24
- async function rpc(body) {
25
- const res = await fetch(`http://127.0.0.1:${PORT}/mcp`, {
26
- method: "POST",
27
- headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
28
- body: JSON.stringify(body),
29
- });
30
- const text = await res.text();
31
- // Streamable HTTP may answer as SSE ("data: {...}") or plain JSON.
32
- const dataLine = text.split("\n").find((l) => l.startsWith("data: "));
33
- return { status: res.status, json: JSON.parse(dataLine ? dataLine.slice(6) : text) };
34
- }
35
- test("initialize over HTTP returns server info + instructions", async () => {
36
- const { status, json } = await rpc({
37
- jsonrpc: "2.0",
38
- id: 1,
39
- method: "initialize",
40
- params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "t", version: "1" } },
41
- });
42
- assert.equal(status, 200);
43
- assert.equal(json.result.serverInfo.name, "context-doctor");
44
- assert.ok(json.result.instructions.includes("Context hygiene"));
45
- });
46
- test("tools/call works statelessly over HTTP", async () => {
47
- const { json } = await rpc({
48
- jsonrpc: "2.0",
49
- id: 2,
50
- method: "tools/call",
51
- params: {
52
- name: "profile_context",
53
- arguments: { conversation: JSON.stringify({ messages: [{ role: "user", content: "hello world" }] }) },
54
- },
55
- });
56
- assert.ok(json.result.content[0].text.includes("CONTEXT DOCTOR"));
57
- });
58
- test("health endpoint responds; non-POST is rejected", async () => {
59
- const health = (await (await fetch(`http://127.0.0.1:${PORT}/health`)).json());
60
- assert.equal(health.ok, true);
61
- const get = await fetch(`http://127.0.0.1:${PORT}/mcp`);
62
- assert.equal(get.status, 405);
63
- });
@@ -1,6 +0,0 @@
1
- /**
2
- * Proxy end-to-end test against a mock upstream: verifies in-flight
3
- * optimization, tool_result preservation, header passthrough, SSE-style
4
- * streaming, and the /stats endpoint.
5
- */
6
- export {};
@@ -1,121 +0,0 @@
1
- /**
2
- * Proxy end-to-end test against a mock upstream: verifies in-flight
3
- * optimization, tool_result preservation, header passthrough, SSE-style
4
- * streaming, and the /stats endpoint.
5
- */
6
- import { test, after } from "node:test";
7
- import assert from "node:assert/strict";
8
- import http from "node:http";
9
- import { startProxy } from "../proxy.js";
10
- const bigTool = "row of data | ".repeat(2000);
11
- const doc = "TERMS: usage is billed monthly per seat with overage charged at cycle end. ".repeat(8);
12
- const payload = JSON.stringify({
13
- model: "claude-sonnet-5",
14
- max_tokens: 100,
15
- system: "You are helpful.",
16
- messages: [
17
- { role: "user", content: "check the data\n" + doc },
18
- { role: "assistant", content: [{ type: "tool_use", id: "t1", name: "query_db", input: { q: "select *" } }] },
19
- { role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: bigTool }] },
20
- { role: "assistant", content: "Done." },
21
- { role: "user", content: "check the data\n" + doc },
22
- ...Array.from({ length: 7 }, (_, i) => ({ role: "user", content: `follow-up ${i}` })),
23
- ],
24
- });
25
- let received = "";
26
- let receivedApiKey;
27
- const upstream = http.createServer((req, res) => {
28
- let body = "";
29
- req.on("data", (c) => (body += c));
30
- req.on("end", () => {
31
- received = body;
32
- receivedApiKey = req.headers["x-api-key"];
33
- res.writeHead(200, { "content-type": "text/event-stream" });
34
- res.write("event: message_start\ndata: {}\n\n");
35
- res.write('event: message_delta\ndata: {"usage":{"input_tokens":120,"output_tokens":45}}\n\n');
36
- res.write("event: message_stop\ndata: {}\n\n");
37
- res.end();
38
- });
39
- });
40
- await new Promise((r) => upstream.listen(0, r));
41
- const upstreamPort = upstream.address().port;
42
- const proxy = startProxy({ port: 0, anthropicUpstream: `http://localhost:${upstreamPort}` });
43
- await new Promise((r) => proxy.once("listening", () => r()));
44
- const proxyPort = proxy.address().port;
45
- after(() => {
46
- proxy.close();
47
- upstream.close();
48
- });
49
- test("proxy optimizes in flight and passes through auth + streaming", async () => {
50
- const resp = await fetch(`http://localhost:${proxyPort}/v1/messages`, {
51
- method: "POST",
52
- headers: { "content-type": "application/json", "x-api-key": "sk-test-not-real", "anthropic-version": "2023-06-01" },
53
- body: payload,
54
- });
55
- const respText = await resp.text();
56
- assert.ok(received.length < payload.length, "upstream received a smaller body");
57
- const parsed = JSON.parse(received);
58
- const toolBlock = parsed.messages[2].content[0];
59
- assert.equal(toolBlock.type, "tool_result");
60
- assert.equal(toolBlock.tool_use_id, "t1");
61
- assert.equal(parsed.model, "claude-sonnet-5");
62
- assert.equal(receivedApiKey, "sk-test-not-real");
63
- assert.equal(resp.status, 200);
64
- assert.ok(respText.includes("message_start") && respText.includes("message_stop"), "SSE streamed through");
65
- });
66
- test("/stats reports cumulative savings with dollar estimate", async () => {
67
- const stats = await (await fetch(`http://localhost:${proxyPort}/stats`)).json();
68
- assert.equal(stats.requests, 1);
69
- assert.equal(stats.optimizedRequests, 1);
70
- assert.ok(stats.tokensSaved > 1000, `saved tokens tracked (${stats.tokensSaved})`);
71
- assert.ok(stats.estUsdSaved > 0, "dollar savings estimated from the request's model");
72
- });
73
- test("response usage is captured from the SSE stream; cache advisor fires on prefix churn", async () => {
74
- // Second request with a DIFFERENT system prompt on the same model → advisory.
75
- const churned = JSON.parse(payload);
76
- churned.system = "You are helpful. TODAY IS A NEW DAY."; // classic cache-buster
77
- churned.tools = [{ name: "t", description: "x".repeat(5000), input_schema: { type: "object" } }];
78
- const first = JSON.parse(payload);
79
- first.tools = churned.tools;
80
- for (const body of [first, churned]) {
81
- await fetch(`http://localhost:${proxyPort}/v1/messages`, {
82
- method: "POST",
83
- headers: { "content-type": "application/json", "x-api-key": "sk-test-not-real" },
84
- body: JSON.stringify(body),
85
- });
86
- }
87
- const stats = (await (await fetch(`http://localhost:${proxyPort}/stats`)).json());
88
- // Mock upstream reports usage in its SSE close event (added below).
89
- assert.ok(stats.upstreamInputTokens >= 100, `usage input captured: ${stats.upstreamInputTokens}`);
90
- assert.ok(stats.upstreamOutputTokens >= 40, `usage output captured: ${stats.upstreamOutputTokens}`);
91
- assert.ok(stats.advice.some((a) => a.includes("prefix changed")), `prefix-churn advisory expected, got: ${JSON.stringify(stats.advice)}`);
92
- assert.ok(stats.advice.some((a) => a.includes("cache_control")), "missing-cache_control advisory expected");
93
- });
94
- test("per-route config: empty strategy list disables optimization for matching models", async () => {
95
- const { startProxy } = await import("../proxy.js");
96
- const routed = startProxy({
97
- port: 0,
98
- anthropicUpstream: `http://localhost:${upstreamPort}`,
99
- routes: [{ modelPrefix: "claude-sonnet", strategies: [] }],
100
- });
101
- await new Promise((r) => routed.once("listening", () => r()));
102
- const routedPort = routed.address().port;
103
- try {
104
- const sent = payload;
105
- await fetch(`http://localhost:${routedPort}/v1/messages`, {
106
- method: "POST",
107
- headers: { "content-type": "application/json" },
108
- body: sent,
109
- });
110
- assert.equal(received.length, sent.length, "route with no strategies must pass body through unmodified");
111
- }
112
- finally {
113
- routed.close();
114
- }
115
- });
116
- test("unsupported paths get a clear 404, health stays up", async () => {
117
- const notFound = await fetch(`http://localhost:${proxyPort}/v1/nope`, { method: "POST", body: "{}" });
118
- assert.equal(notFound.status, 404);
119
- const health = await (await fetch(`http://localhost:${proxyPort}/health`)).json();
120
- assert.equal(health.ok, true);
121
- });
@@ -1,2 +0,0 @@
1
- /** Smoke tests: parse → profile → optimize roundtrip for both provider formats. */
2
- export {};
@@ -1,111 +0,0 @@
1
- /** Smoke tests: parse → profile → optimize roundtrip for both provider formats. */
2
- import { test } from "node:test";
3
- import assert from "node:assert/strict";
4
- import { parseConversation } from "../parse.js";
5
- import { profileConversation } from "../profile.js";
6
- import { optimizeConversation } from "../optimize.js";
7
- // Large enough to clear the profiler's 2000-token oversized-tool-result threshold.
8
- const bigText = "Sunny, 18C. ".repeat(900);
9
- const openaiConv = JSON.stringify({
10
- messages: [
11
- { role: "system", content: "You are helpful." },
12
- { role: "user", content: "weather in SF?" },
13
- { role: "assistant", content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "get_weather", arguments: '{"city":"SF"}' } }] },
14
- { role: "tool", tool_call_id: "c1", content: bigText },
15
- { role: "assistant", content: "It is sunny and 18C in SF." },
16
- ],
17
- });
18
- const anthropicConv = JSON.stringify({
19
- system: "You are helpful.",
20
- messages: [
21
- { role: "user", content: "weather in SF?" },
22
- { role: "assistant", content: [{ type: "tool_use", id: "t1", name: "get_weather", input: { city: "SF" } }] },
23
- { role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: bigText }] },
24
- { role: "assistant", content: "Sunny and 18C." },
25
- ],
26
- });
27
- test("parses OpenAI format and classifies tool plumbing", () => {
28
- const conv = parseConversation(openaiConv);
29
- assert.equal(conv.sourceFormat, "openai");
30
- assert.equal(conv.messages.filter((m) => m.kind === "tool_result").length, 1);
31
- assert.equal(conv.messages.filter((m) => m.kind === "tool_call").length, 1);
32
- });
33
- test("parses Anthropic format including external system prompt", () => {
34
- const conv = parseConversation(anthropicConv);
35
- assert.equal(conv.sourceFormat, "anthropic");
36
- assert.equal(conv.messages[0].kind, "system");
37
- assert.ok(conv.messages.some((m) => m.toolName === "get_weather"));
38
- });
39
- test("profiler flags oversized tool results", () => {
40
- const profile = profileConversation(parseConversation(openaiConv), "gpt-4o");
41
- assert.ok(profile.totalTokens > 500);
42
- assert.equal(profile.contextWindow, 128_000);
43
- assert.ok(profile.findings.some((f) => f.id === "large_tool_result"));
44
- });
45
- test("optimizer trims stale tool results and reports savings", () => {
46
- const result = optimizeConversation(openaiConv, { keepRecent: 1, maxToolResultTokens: 50 });
47
- assert.ok(result.tokensAfter < result.tokensBefore);
48
- assert.ok(result.applied.some((c) => c.strategy === "trim-tool-results"));
49
- // Output must remain valid JSON with the same message count.
50
- const out = result.conversation;
51
- assert.equal(out.messages.length, 5);
52
- });
53
- test("optimizer output for Anthropic format keeps block structure valid", () => {
54
- const result = optimizeConversation(anthropicConv, { keepRecent: 1, maxToolResultTokens: 50 });
55
- const out = result.conversation;
56
- for (const m of out.messages) {
57
- assert.ok(typeof m.content === "string" || Array.isArray(m.content));
58
- }
59
- // The trimmed tool_result must KEEP its block type and tool_use_id — the
60
- // Anthropic API rejects a tool_use with no matching tool_result.
61
- const toolResultMsg = out.messages[2].content;
62
- assert.equal(toolResultMsg[0].type, "tool_result");
63
- assert.equal(toolResultMsg[0].tool_use_id, "t1");
64
- assert.ok(toolResultMsg[0].content.length < 1000, "tool_result content was trimmed");
65
- // And the tool_use block on the assistant side is untouched.
66
- const assistantMsg = out.messages[1].content;
67
- assert.equal(assistantMsg[0].type, "tool_use");
68
- });
69
- test("prune-history never leaves an orphaned tool result at the head of the tail", () => {
70
- // Build a conversation where the naive prune boundary would land exactly on
71
- // a tool-result message (its tool_use call falling in the pruned half).
72
- const filler = "some earlier discussion that will be pruned away. ".repeat(20);
73
- const conv = JSON.stringify({
74
- messages: [
75
- ...Array.from({ length: 8 }, (_, i) => ({ role: i % 2 ? "assistant" : "user", content: `${i} ${filler}` })),
76
- { role: "assistant", content: [{ type: "tool_use", id: "tX", name: "search", input: { q: "x" } }] },
77
- { role: "user", content: [{ type: "tool_result", tool_use_id: "tX", content: "results here" }] }, // naive boundary lands HERE
78
- { role: "assistant", content: "Summary of results." },
79
- { role: "user", content: "thanks" },
80
- { role: "assistant", content: "welcome" },
81
- ],
82
- });
83
- const result = optimizeConversation(conv, { strategies: ["prune-history"], keepRecent: 4 });
84
- const out = result.conversation;
85
- // First kept message after the stub must NOT be a tool result.
86
- const firstKept = out.messages[1].content;
87
- const isToolResult = Array.isArray(firstKept) && firstKept.some((b) => b?.type === "tool_result");
88
- assert.equal(isToolResult, false, "tail must not start with an orphaned tool_result");
89
- assert.ok(result.applied.some((c) => c.strategy === "prune-history"), "pruning still happened");
90
- });
91
- test("near-duplicate detection catches same doc with different lead-ins", () => {
92
- // Varied clauses (not a repeated sentence) — like a real document.
93
- const doc = Array.from({ length: 30 }, (_, i) => `Clause ${i} of the pricing policy covers refund scenario ${i} where the customer holds receipt series ${i * 7} under regional rule ${i % 5}.`).join(" ");
94
- const conv = JSON.stringify({
95
- messages: [
96
- { role: "user", content: "Here is our policy document for you to review:\n" + doc },
97
- { role: "assistant", content: "Understood, thanks for sharing the policy." },
98
- { role: "user", content: "Sharing the policy doc again with a totally different intro so exact hashing misses it:\n" + doc },
99
- ],
100
- });
101
- const profile = profileConversation(parseConversation(conv));
102
- const near = profile.findings.find((f) => f.id === "near_duplicate");
103
- assert.ok(near, "near_duplicate finding expected");
104
- assert.deepEqual(near.messages, [0, 2]);
105
- assert.ok(near.estSavings > 50);
106
- });
107
- test("raw text input still profiles", () => {
108
- const profile = profileConversation(parseConversation("just some prompt text"));
109
- assert.equal(profile.messageCount, 1);
110
- assert.ok(profile.totalTokens > 0);
111
- });
@@ -1,2 +0,0 @@
1
- /** watch: emits a status line on growth, surfaces new findings once. */
2
- export {};
@@ -1,36 +0,0 @@
1
- /** watch: emits a status line on growth, surfaces new findings once. */
2
- import { test } from "node:test";
3
- import assert from "node:assert/strict";
4
- import { spawn } from "node:child_process";
5
- import { appendFileSync, mkdtempSync, writeFileSync } from "node:fs";
6
- import { tmpdir } from "node:os";
7
- import { join, dirname } from "node:path";
8
- import { fileURLToPath } from "node:url";
9
- const cliPath = join(dirname(fileURLToPath(import.meta.url)), "..", "cli.js");
10
- function line(role, content) {
11
- return JSON.stringify({ type: role, message: { role, content } }) + "\n";
12
- }
13
- test("watch reports growth and new findings live", async () => {
14
- const dir = mkdtempSync(join(tmpdir(), "ctxdoc-watch-"));
15
- const file = join(dir, "trace.jsonl");
16
- writeFileSync(file, line("user", "hello there"));
17
- const child = spawn(process.execPath, [cliPath, "watch", file, "--interval-ms", "150"], { stdio: ["ignore", "pipe", "pipe"] });
18
- let out = "";
19
- child.stdout.on("data", (d) => (out += d.toString()));
20
- try {
21
- // First tick: initial line.
22
- await new Promise((r) => setTimeout(r, 500));
23
- assert.ok(/tokens/.test(out), `initial status line expected, got: ${out}`);
24
- // Grow the file with an oversized tool result → new status + a finding.
25
- appendFileSync(file, line("assistant", JSON.stringify([{ type: "tool_use", id: "t1", name: "search", input: {} }])) +
26
- JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: "data ".repeat(3000) }] } }) +
27
- "\n");
28
- await new Promise((r) => setTimeout(r, 700));
29
- const statusLines = out.split("\n").filter((l) => l.includes("tokens"));
30
- assert.ok(statusLines.length >= 2, `expected a second status line after growth: ${out}`);
31
- assert.ok(out.includes("⚠"), `expected a finding to surface: ${out}`);
32
- }
33
- finally {
34
- child.kill();
35
- }
36
- });