context-doctor 0.8.0 → 0.10.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
@@ -88,9 +88,10 @@ Practical upshot: a developer who only wants cheaper, faster API calls never tou
88
88
  | Command | What it does |
89
89
  |---|---|
90
90
  | `context-doctor install` / `uninstall` | Wire (or remove) everything: MCP for Claude Desktop/Code/Cursor, the Agent Skill, the every-prompt hook |
91
- | `context-doctor analyze <file>` | Profile a conversation: token breakdown, findings, cost + latency estimates |
91
+ | `context-doctor analyze <file>` | Profile a conversation: token breakdown, findings, cost + latency estimates. `--fail-over-budget` exits 1 on a breach, for CI |
92
92
  | `context-doctor optimize <file>` | Apply the safe fixes; `--strategy prune-history` for consented lossy compaction |
93
- | `context-doctor session [file]` | Profile a Claude Code session transcript (defaults to your most recent; `--list` to browse) — also reads ChatGPT data exports (`conversations.json`) |
93
+ | `context-doctor session [file]` | Profile a Claude Code session: live context, findings, **measured tokens and prompt-cache economics**. Also reads ChatGPT data exports (`conversations.json`) |
94
+ | `context-doctor cursor [--list]` | Profile a chat from Cursor's local history (both storage formats) |
94
95
  | `context-doctor report` | Machine-wide impact report: exact proxy savings, hook activity, recoverable waste in recent sessions |
95
96
  | `context-doctor proxy` | Always-on local proxy that optimizes every Anthropic/OpenAI API request in flight (`/stats` for cumulative savings) |
96
97
  | `context-doctor watch [file]` | Live monitor of a growing session/agent trace: token/cost line per change, findings as they appear |
@@ -245,6 +246,8 @@ const { conversation, tokensBefore, tokensAfter } = optimizeConversation(chatJso
245
246
  - **Oversized tool results** — the #1 context killer in agent loops
246
247
  - **Duplicate content** — the same doc/result pasted twice
247
248
  - **Near-duplicates** — the same doc re-pasted with different surrounding words (shingle similarity, ≥60%)
249
+ - **Repeated file reads** — the same file pulled in three or more times, every copy still in context
250
+ - **Retained error output** — stack traces and failed commands kept verbatim long after the fix landed
248
251
  - **Repeated identical tool calls** — a signal your agent forgot earlier results
249
252
  - **Base64 / binary blobs** in text content
250
253
  - **Long history** past the point where models track the middle
@@ -296,6 +299,26 @@ Drop a `.contextdoctorrc` in a project (or your home directory) and context-doct
296
299
 
297
300
  The nearest file wins (walking up from the working directory, then `~`). `analyze` and `session` print a budget verdict, the every-prompt hook uses `maxTokens` as its warning threshold and names the breach to the model, and `optimize`/`proxy` pick up the defaults when you do not pass flags.
298
301
 
302
+ ## Prompt-cache economics (Claude Code sessions)
303
+
304
+ Caching is the largest lever on LLM cost, and transcripts record exactly how it went — so `session` reports it as fact rather than estimate:
305
+
306
+ ```
307
+ Prompt cache: 95.6% of input served from cache across 1117 requests
308
+ read 558.6M · written 25.5M · uncached 2k
309
+ input cost $438.88 — caching saved $2481.89 against $2920.76 uncached (list prices)
310
+ ```
311
+
312
+ A cache read bills at ~10% of input while a write bills at ~125%, so a session that keeps invalidating its prefix can cost *more* than one with no caching at all. context-doctor warns on the two failure modes: a **low hit rate** (something early in the prompt changes every request) and **cache churn** (writes rivalling reads).
313
+
314
+ ## Enforce a budget in CI
315
+
316
+ ```bash
317
+ npx context-doctor analyze conversation.json --fail-over-budget
318
+ ```
319
+
320
+ Exits 1 when the `.contextdoctorrc` budget is breached, so a pull request can be gated on context size the same way it is gated on tests.
321
+
299
322
  ## Performance: what context-doctor itself costs
300
323
 
301
324
  A tool that promises speed must be near-free. Measured overhead per touchpoint:
@@ -310,9 +333,16 @@ A tool that promises speed must be near-free. Measured overhead per touchpoint:
310
333
 
311
334
  Net effect is strongly negative overhead: the tokens these touchpoints save on every subsequent call dwarf what they cost.
312
335
 
313
- ## Why token counts are "~" (and how to make them exact)
336
+ ## Why token counts are "~" (and where they are exact)
337
+
338
+ 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.
314
339
 
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.
340
+ Two ways to get real numbers instead:
341
+
342
+ - **`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.
343
+ - **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.
344
+
345
+ 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
346
 
317
347
 
318
348
 
@@ -337,6 +367,10 @@ Known gotcha: if `npm publish` fails with **`404 Not Found - PUT …/context-doc
337
367
 
338
368
  Also keep the MCP server version in `src/mcp.ts` in sync with `package.json`, and remember `dist/` is committed — run `npm run build` before committing so the CI dist-sync check passes.
339
369
 
370
+ ## Contributing
371
+
372
+ Issues and PRs welcome — see [CONTRIBUTING.md](./CONTRIBUTING.md) for the six rules that keep this tool trustworthy (no API keys, nothing leaves the machine, no silent data loss, measurements not guesses, the hot path stays cheap, tests with every change) and a list of good first issues. What is planned next lives in [ROADMAP.md](./ROADMAP.md).
373
+
340
374
  ## License
341
375
 
342
376
  MIT © [gAI Ventures](https://gai.ventures)
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Prompt-cache analysis for Claude Code sessions.
3
+ *
4
+ * Transcripts record what the API actually charged per request — including
5
+ * cache reads and cache writes — so cache behaviour can be reported as fact
6
+ * rather than estimated. This is usually the largest single lever on cost:
7
+ * a cached read bills at ~10% of input, while a cache write bills at ~125%,
8
+ * so a session that keeps invalidating its prefix pays more than one with no
9
+ * caching at all.
10
+ */
11
+ export interface CacheUsage {
12
+ requests: number;
13
+ cacheReadTokens: number;
14
+ cacheWriteTokens: number;
15
+ uncachedTokens: number;
16
+ /** Share of input tokens served from cache (0-1). */
17
+ hitRate: number;
18
+ model?: string;
19
+ /** What the input actually cost, at list prices. */
20
+ paidUsd?: number;
21
+ /** What the same input would have cost with no caching at all. */
22
+ uncachedUsd?: number;
23
+ /** paidUsd vs uncachedUsd — positive means caching is paying off. */
24
+ savedUsd?: number;
25
+ /** USD spent on cache writes; high values mean the prefix keeps changing. */
26
+ writeUsd?: number;
27
+ }
28
+ export declare function analyzeCacheUsage(transcriptPath: string): CacheUsage | null;
29
+ /** One-paragraph verdict for humans, or null when there is nothing to say. */
30
+ export declare function renderCacheReport(usage: CacheUsage | null): string | null;
package/dist/cache.js ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Prompt-cache analysis for Claude Code sessions.
3
+ *
4
+ * Transcripts record what the API actually charged per request — including
5
+ * cache reads and cache writes — so cache behaviour can be reported as fact
6
+ * rather than estimated. This is usually the largest single lever on cost:
7
+ * a cached read bills at ~10% of input, while a cache write bills at ~125%,
8
+ * so a session that keeps invalidating its prefix pays more than one with no
9
+ * caching at all.
10
+ */
11
+ import { readFileSync } from "node:fs";
12
+ import { pricingFor } from "./pricing.js";
13
+ /** Cache pricing multipliers relative to the base input rate. */
14
+ const CACHE_WRITE_MULTIPLIER = 1.25;
15
+ export function analyzeCacheUsage(transcriptPath) {
16
+ let raw;
17
+ try {
18
+ raw = readFileSync(transcriptPath, "utf8");
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ let requests = 0;
24
+ let cacheReadTokens = 0;
25
+ let cacheWriteTokens = 0;
26
+ let uncachedTokens = 0;
27
+ let model;
28
+ for (const line of raw.split("\n")) {
29
+ if (!line.trim())
30
+ continue;
31
+ let entry;
32
+ try {
33
+ entry = JSON.parse(line);
34
+ }
35
+ catch {
36
+ continue;
37
+ }
38
+ if (entry.type !== "assistant" || !entry.message)
39
+ continue;
40
+ const usage = entry.message.usage;
41
+ if (!usage)
42
+ continue;
43
+ requests++;
44
+ cacheReadTokens += usage.cache_read_input_tokens ?? 0;
45
+ cacheWriteTokens += usage.cache_creation_input_tokens ?? 0;
46
+ uncachedTokens += usage.input_tokens ?? 0;
47
+ if (typeof entry.message.model === "string")
48
+ model = entry.message.model;
49
+ }
50
+ if (requests === 0)
51
+ return null;
52
+ const totalInput = cacheReadTokens + cacheWriteTokens + uncachedTokens;
53
+ const usage = {
54
+ requests,
55
+ cacheReadTokens,
56
+ cacheWriteTokens,
57
+ uncachedTokens,
58
+ hitRate: totalInput > 0 ? cacheReadTokens / totalInput : 0,
59
+ model,
60
+ };
61
+ const pricing = pricingFor(model);
62
+ if (pricing) {
63
+ const perM = (tokens, rate) => (tokens / 1_000_000) * rate;
64
+ const readUsd = perM(cacheReadTokens, pricing.cacheReadPerM);
65
+ const writeUsd = perM(cacheWriteTokens, pricing.inputPerM * CACHE_WRITE_MULTIPLIER);
66
+ const freshUsd = perM(uncachedTokens, pricing.inputPerM);
67
+ usage.paidUsd = readUsd + writeUsd + freshUsd;
68
+ usage.uncachedUsd = perM(totalInput, pricing.inputPerM);
69
+ usage.savedUsd = usage.uncachedUsd - usage.paidUsd;
70
+ usage.writeUsd = writeUsd;
71
+ }
72
+ return usage;
73
+ }
74
+ /** One-paragraph verdict for humans, or null when there is nothing to say. */
75
+ export function renderCacheReport(usage) {
76
+ if (!usage)
77
+ return null;
78
+ const pct = (usage.hitRate * 100).toFixed(1);
79
+ const lines = [];
80
+ const fmt = (n) => (n >= 1 ? `$${n.toFixed(2)}` : `$${n.toFixed(3)}`);
81
+ const tok = (n) => n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(n);
82
+ lines.push(`Prompt cache: ${pct}% of input served from cache across ${usage.requests} requests`);
83
+ lines.push(` read ${tok(usage.cacheReadTokens)} · written ${tok(usage.cacheWriteTokens)} · uncached ${tok(usage.uncachedTokens)}`);
84
+ if (usage.paidUsd !== undefined && usage.uncachedUsd !== undefined && usage.savedUsd !== undefined) {
85
+ lines.push(` input cost ${fmt(usage.paidUsd)} — caching saved ${fmt(usage.savedUsd)} against ${fmt(usage.uncachedUsd)} uncached (list prices)`);
86
+ }
87
+ // A cache write costs 1.25x input while a read costs 0.1x, so writes that
88
+ // rival reads mean the prefix keeps changing and caching is losing money.
89
+ const writeShare = usage.cacheReadTokens > 0 ? usage.cacheWriteTokens / usage.cacheReadTokens : Infinity;
90
+ if (usage.hitRate < 0.5 && usage.requests > 5) {
91
+ lines.push(" ⚠ Low hit rate: something early in the prompt changes between requests. Keep the system prompt, tool list and reference docs byte-stable and put volatile content last.");
92
+ }
93
+ else if (writeShare > 0.5) {
94
+ lines.push(" ⚠ Cache churn: writes are large relative to reads, and a write costs 1.25x input against 0.1x for a read. The cached prefix is being rebuilt often.");
95
+ }
96
+ return lines.join("\n");
97
+ }
package/dist/cli.js CHANGED
@@ -25,6 +25,8 @@ 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";
29
+ import { analyzeCacheUsage, renderCacheReport } from "./cache.js";
28
30
  const HELP = `context-doctor — profile and optimize LLM context windows
29
31
 
30
32
  Usage:
@@ -35,8 +37,9 @@ Usage:
35
37
  context-doctor install Wire the MCP server + skill into Claude Desktop,
36
38
  Claude Code, and Cursor automatically
37
39
  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)
40
+ context-doctor session [file] Profile a Claude Code session transcript or a
41
+ ChatGPT export (default: most recent; --list to browse)
42
+ context-doctor cursor [--list] Profile a Cursor chat from its local history
40
43
  context-doctor hook Claude Code UserPromptSubmit hook (installed
41
44
  automatically by \`install\`; reads hook JSON on stdin)
42
45
  context-doctor report Impact report: exact proxy savings, hook activity,
@@ -62,6 +65,8 @@ Options:
62
65
  --exact (analyze) Add an exact token count: Anthropic count-tokens API for
63
66
  Claude models (needs ANTHROPIC_API_KEY), tiktoken for GPT (if installed)
64
67
  --json Machine-readable output
68
+ --fail-over-budget (analyze/session) Exit 1 when the .contextdoctorrc budget is
69
+ exceeded — lets CI gate a pull request on context size
65
70
  --out <file> (optimize) Write result to file instead of stdout
66
71
  --strategy <id> (optimize) Strategy to run; repeatable.
67
72
  Available: dedupe, trim-tool-results, strip-base64, prune-history
@@ -84,7 +89,7 @@ Examples:
84
89
  export OPENAI_BASE_URL=http://localhost:8787/v1
85
90
  `;
86
91
  function parseArgs(argv) {
87
- const args = { json: false, strategies: [], list: false, exact: false };
92
+ const args = { json: false, strategies: [], list: false, exact: false, failOverBudget: false };
88
93
  const positional = [];
89
94
  for (let i = 0; i < argv.length; i++) {
90
95
  const a = argv[i];
@@ -102,6 +107,9 @@ function parseArgs(argv) {
102
107
  case "--exact":
103
108
  args.exact = true;
104
109
  break;
110
+ case "--fail-over-budget":
111
+ args.failOverBudget = true;
112
+ break;
105
113
  case "--model":
106
114
  args.model = argv[++i];
107
115
  break;
@@ -146,7 +154,7 @@ function parseArgs(argv) {
146
154
  function printBudgetStatus(profile, loaded) {
147
155
  const budget = loaded.config.budget;
148
156
  if (!budget || !loaded.path)
149
- return;
157
+ return false;
150
158
  const verdict = checkBudget(budget, profile);
151
159
  console.log("");
152
160
  if (verdict.overBudget) {
@@ -157,6 +165,14 @@ function printBudgetStatus(profile, loaded) {
157
165
  else {
158
166
  console.log(`Within budget (${loaded.path}).`);
159
167
  }
168
+ return verdict.overBudget;
169
+ }
170
+ /** Exit 1 when the caller asked CI to fail on a breach. */
171
+ function applyBudgetGate(overBudget, failOverBudget) {
172
+ if (overBudget && failOverBudget) {
173
+ console.error("context-doctor: over budget (--fail-over-budget)");
174
+ process.exitCode = 1;
175
+ }
160
176
  }
161
177
  function readInput(file) {
162
178
  if (file === "-")
@@ -185,6 +201,45 @@ function main() {
185
201
  void buildImpactReport(args.port).then((r) => console.log(r));
186
202
  return;
187
203
  }
204
+ if (args.command === "cursor") {
205
+ let chats;
206
+ try {
207
+ chats = listCursorChats();
208
+ }
209
+ catch (e) {
210
+ console.error(`Could not read Cursor history: ${e.message}`);
211
+ process.exit(1);
212
+ }
213
+ if (chats.length === 0) {
214
+ console.error("No Cursor chats found (looked in Cursor's global and workspace storage).");
215
+ process.exit(1);
216
+ }
217
+ if (args.list) {
218
+ for (const c of chats) {
219
+ console.log(`${String(c.messageCount).padStart(5)} msgs ${(c.title ?? "(untitled)").slice(0, 48).padEnd(50)} ${c.composerId}`);
220
+ }
221
+ return;
222
+ }
223
+ const chat = args.file ? chats.find((c) => c.composerId === args.file) ?? chats[0] : chats[0];
224
+ const parsed = parseCursorChat(chat);
225
+ const profile = profileConversation(parseConversation(parsed.conversationJson), args.model ?? parsed.model);
226
+ if (args.json) {
227
+ console.log(JSON.stringify({ chat: { id: chat.composerId, title: chat.title }, profile }, null, 2));
228
+ }
229
+ else {
230
+ console.log(`Cursor chat: ${chat.title ?? "(untitled)"}\nId: ${chat.composerId}\n`);
231
+ console.log(renderProfile(profile));
232
+ if (parsed.reportedInputTokens) {
233
+ console.log("");
234
+ console.log(`Measured context (reported by the API on the last request): ${parsed.reportedInputTokens} tokens.\n` +
235
+ "That figure includes the harness's system prompt, tool schemas and skills, which the\n" +
236
+ "transcript does not record — so it is larger than the breakdown above, which covers\n" +
237
+ "conversation messages only. Findings and savings apply to the messages.");
238
+ }
239
+ printBudgetStatus(profile, loadConfig(process.cwd(), (m) => console.error(`context-doctor: ${m}`)));
240
+ }
241
+ return;
242
+ }
188
243
  if (args.command === "session") {
189
244
  if (args.list) {
190
245
  const sessions = listSessions();
@@ -215,9 +270,25 @@ function main() {
215
270
  console.log(JSON.stringify({ session: { path: parsed.path, title: parsed.title }, profile }, null, 2));
216
271
  }
217
272
  else {
218
- console.log(`Session: ${parsed.title ?? "(untitled)"}\nFile: ${parsed.path}\n`);
273
+ console.log(`Session: ${parsed.title ?? "(untitled)"}\nFile: ${parsed.path}`);
274
+ if (parsed.compactedAway) {
275
+ 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.`);
276
+ }
277
+ console.log("");
219
278
  console.log(renderProfile(profile));
220
- printBudgetStatus(profile, loadConfig(process.cwd(), (m) => console.error(`context-doctor: ${m}`)));
279
+ if (parsed.reportedInputTokens) {
280
+ console.log("");
281
+ console.log(`Measured context (reported by the API on the last request): ${parsed.reportedInputTokens} tokens.\n` +
282
+ "That figure includes the harness's system prompt, tool schemas and skills, which the\n" +
283
+ "transcript does not record — so it is larger than the breakdown above, which covers\n" +
284
+ "conversation messages only. Findings and savings apply to the messages.");
285
+ }
286
+ const cache = renderCacheReport(analyzeCacheUsage(path));
287
+ if (cache) {
288
+ console.log("");
289
+ console.log(cache);
290
+ }
291
+ applyBudgetGate(printBudgetStatus(profile, loadConfig(process.cwd(), (m) => console.error(`context-doctor: ${m}`))), args.failOverBudget);
221
292
  }
222
293
  return;
223
294
  }
@@ -270,7 +341,9 @@ function main() {
270
341
  const profile = profileConversation(parseConversation(input), args.model ?? loaded.config.model);
271
342
  console.log(args.json ? JSON.stringify(profile, null, 2) : renderProfile(profile));
272
343
  if (!args.json)
273
- printBudgetStatus(profile, loaded);
344
+ applyBudgetGate(printBudgetStatus(profile, loaded), args.failOverBudget);
345
+ else
346
+ applyBudgetGate(checkBudget(loaded.config.budget, profile).overBudget, args.failOverBudget);
274
347
  if (args.exact) {
275
348
  void exactTokenCount(input, args.model).then((exact) => {
276
349
  if (exact.tokens !== undefined) {
@@ -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({