context-doctor 0.7.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
@@ -34,14 +34,12 @@ Findings (4)
34
34
  npx context-doctor install
35
35
  ```
36
36
 
37
- Until the package lands on npm, install straight from GitHub instead (needs Node 18+):
37
+ That single command is also all it takes to **set up context-doctor on anyone else's machine**. Prefer a global install, or want the unreleased `main`? Both work (Node 18+):
38
38
 
39
39
  ```bash
40
- npm install -g github:KushalP1/context-doctor && context-doctor install
40
+ npm install -g context-doctor && context-doctor install
41
41
  ```
42
42
 
43
- That pair of commands is also all it takes to **set up context-doctor on anyone else's machine**.
44
-
45
43
  Restart your apps, then just ask Claude: *"what's eating my context?"* (`npx context-doctor uninstall` reverses it.)
46
44
 
47
45
  **No API keys, ever.** Everything is deterministic local code; when an LLM is needed (summarizing pruned history), the model already running in your app does it. The proxy forwards *your app's* credentials untouched — context-doctor itself holds nothing.
@@ -97,6 +95,7 @@ Practical upshot: a developer who only wants cheaper, faster API calls never tou
97
95
  | `context-doctor proxy` | Always-on local proxy that optimizes every Anthropic/OpenAI API request in flight (`/stats` for cumulative savings) |
98
96
  | `context-doctor watch [file]` | Live monitor of a growing session/agent trace: token/cost line per change, findings as they appear |
99
97
  | `context-doctor doctor` | Self-check the whole installation — one pasteable ✓/✗ diagnosis with fixes |
98
+ | `context-doctor dashboard` | Local savings dashboard on 127.0.0.1: tokens saved per day, sessions by context in use vs recoverable, budget status |
100
99
  | `context-doctor hook` | The every-prompt Claude Code hook (registered by `install`; you never run this yourself). Warning threshold tunable via `CONTEXT_DOCTOR_WARN_TOKENS` (default 80000) |
101
100
  | `context-doctor-mcp` | The MCP server itself — stdio by default (what the installer wires); `--http [--port 8808] [--host H]` serves streamable HTTP at `/mcp` for URL-based clients like ChatGPT developer-mode connectors |
102
101
 
@@ -283,6 +282,20 @@ One report for your whole machine, led by a headline of **tokens context-doctor
283
282
 
284
283
  Honest measurement note: proxy numbers are exact. Session numbers are measured-now. What no tool can report is the counterfactual — tokens Claude *avoided* adding because of the hygiene guidance — since the same session can't be re-run without it. The report says so instead of inventing a number.
285
284
 
285
+ ## Context budgets (`.contextdoctorrc`)
286
+
287
+ Drop a `.contextdoctorrc` in a project (or your home directory) and context-doctor enforces your limits instead of its defaults:
288
+
289
+ ```json
290
+ {
291
+ "budget": { "maxTokens": 120000, "maxCostPerMessageUsd": 0.5, "maxWindowPct": 60 },
292
+ "strategies": ["dedupe", "trim-tool-results"],
293
+ "keepRecent": 6
294
+ }
295
+ ```
296
+
297
+ 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
+
286
299
  ## Performance: what context-doctor itself costs
287
300
 
288
301
  A tool that promises speed must be near-free. Measured overhead per touchpoint:
@@ -297,9 +310,16 @@ A tool that promises speed must be near-free. Measured overhead per touchpoint:
297
310
 
298
311
  Net effect is strongly negative overhead: the tokens these touchpoints save on every subsequent call dwarf what they cost.
299
312
 
300
- ## Why token counts are "~" (and how to make them exact)
313
+ ## Why token counts are "~" (and where they are exact)
314
+
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.
301
321
 
302
- 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.
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.
303
323
 
304
324
 
305
325
 
package/dist/cli.js CHANGED
@@ -23,6 +23,9 @@ import { recordLedger } from "./ledger.js";
23
23
  import { runDoctor } from "./doctor.js";
24
24
  import { runWatch } from "./watch.js";
25
25
  import { exactTokenCount } from "./exact.js";
26
+ import { checkBudget, loadConfig } from "./config.js";
27
+ import { startDashboard } from "./dashboard.js";
28
+ import { listCursorChats, parseCursorChat } from "./cursor.js";
26
29
  const HELP = `context-doctor — profile and optimize LLM context windows
27
30
 
28
31
  Usage:
@@ -33,18 +36,26 @@ Usage:
33
36
  context-doctor install Wire the MCP server + skill into Claude Desktop,
34
37
  Claude Code, and Cursor automatically
35
38
  context-doctor uninstall Undo install
36
- context-doctor session [file] Profile a Claude Code session transcript
37
- (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
38
42
  context-doctor hook Claude Code UserPromptSubmit hook (installed
39
43
  automatically by \`install\`; reads hook JSON on stdin)
40
44
  context-doctor report Impact report: exact proxy savings, hook activity,
41
45
  and remaining recoverable waste in recent sessions
42
46
  context-doctor doctor Self-check the installation (configs, hook, skill,
43
47
  MCP handshake) with one pasteable diagnosis
48
+ context-doctor dashboard Local savings dashboard on 127.0.0.1 (--port n,
49
+ default 8790) — charts from your own machine only
44
50
  context-doctor watch [file] Live-monitor a growing session/agent trace: running
45
51
  token/cost line per change, new findings as they appear
46
52
  (--interval-ms n, default 2000)
47
53
 
54
+ Project config: an optional .contextdoctorrc (nearest, walking up from cwd, then
55
+ ~/.contextdoctorrc) can set a context budget and default strategies:
56
+ {"budget":{"maxTokens":120000,"maxCostPerMessageUsd":0.5,"maxWindowPct":60},
57
+ "strategies":["dedupe","trim-tool-results"],"routes":[...]}
58
+
48
59
  Input: a conversation JSON file (OpenAI or Anthropic message format, or a bare
49
60
  message array). Use "-" to read from stdin.
50
61
 
@@ -133,6 +144,22 @@ function parseArgs(argv) {
133
144
  args.file = positional[1];
134
145
  return args;
135
146
  }
147
+ /** Print budget status under a profile when a .contextdoctorrc defines one. */
148
+ function printBudgetStatus(profile, loaded) {
149
+ const budget = loaded.config.budget;
150
+ if (!budget || !loaded.path)
151
+ return;
152
+ const verdict = checkBudget(budget, profile);
153
+ console.log("");
154
+ if (verdict.overBudget) {
155
+ console.log(`OVER BUDGET (${loaded.path}):`);
156
+ for (const b of verdict.breaches)
157
+ console.log(` x ${b}`);
158
+ }
159
+ else {
160
+ console.log(`Within budget (${loaded.path}).`);
161
+ }
162
+ }
136
163
  function readInput(file) {
137
164
  if (file === "-")
138
165
  return readFileSync(0, "utf8");
@@ -144,6 +171,10 @@ function main() {
144
171
  void runHook();
145
172
  return;
146
173
  }
174
+ if (args.command === "dashboard") {
175
+ startDashboard({ port: args.port, proxyPort: 8787 });
176
+ return; // server keeps the process alive
177
+ }
147
178
  if (args.command === "watch") {
148
179
  runWatch({ file: args.file, intervalMs: args.intervalMs, model: args.model });
149
180
  return; // interval keeps the process alive
@@ -156,6 +187,45 @@ function main() {
156
187
  void buildImpactReport(args.port).then((r) => console.log(r));
157
188
  return;
158
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
+ }
159
229
  if (args.command === "session") {
160
230
  if (args.list) {
161
231
  const sessions = listSessions();
@@ -186,8 +256,20 @@ function main() {
186
256
  console.log(JSON.stringify({ session: { path: parsed.path, title: parsed.title }, profile }, null, 2));
187
257
  }
188
258
  else {
189
- 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("");
190
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
+ }
272
+ printBudgetStatus(profile, loadConfig(process.cwd(), (m) => console.error(`context-doctor: ${m}`)));
191
273
  }
192
274
  return;
193
275
  }
@@ -200,7 +282,8 @@ function main() {
200
282
  return;
201
283
  }
202
284
  if (args.command === "proxy") {
203
- let routes;
285
+ const loadedRc = loadConfig(process.cwd(), (m) => console.error(`context-doctor: ${m}`));
286
+ let routes = loadedRc.config.routes;
204
287
  if (args.config) {
205
288
  try {
206
289
  routes = JSON.parse(readFileSync(args.config, "utf8")).routes;
@@ -216,9 +299,9 @@ function main() {
216
299
  host: args.host,
217
300
  anthropicUpstream: args.upstreamAnthropic,
218
301
  openaiUpstream: args.upstreamOpenai,
219
- strategies: args.strategies.length > 0 ? args.strategies : undefined,
220
- keepRecent: args.keepRecent,
221
- maxToolResultTokens: args.maxToolTokens,
302
+ strategies: args.strategies.length > 0 ? args.strategies : loadedRc.config.strategies,
303
+ keepRecent: args.keepRecent ?? loadedRc.config.keepRecent,
304
+ maxToolResultTokens: args.maxToolTokens ?? loadedRc.config.maxToolResultTokens,
222
305
  });
223
306
  return; // server keeps the process alive
224
307
  }
@@ -235,8 +318,11 @@ function main() {
235
318
  process.exit(1);
236
319
  }
237
320
  if (args.command === "analyze") {
238
- const profile = profileConversation(parseConversation(input), args.model);
321
+ const loaded = loadConfig(process.cwd(), (m) => console.error(`context-doctor: ${m}`));
322
+ const profile = profileConversation(parseConversation(input), args.model ?? loaded.config.model);
239
323
  console.log(args.json ? JSON.stringify(profile, null, 2) : renderProfile(profile));
324
+ if (!args.json)
325
+ printBudgetStatus(profile, loaded);
240
326
  if (args.exact) {
241
327
  void exactTokenCount(input, args.model).then((exact) => {
242
328
  if (exact.tokens !== undefined) {
@@ -253,10 +339,11 @@ function main() {
253
339
  if (args.command === "optimize") {
254
340
  let result;
255
341
  try {
342
+ const loaded = loadConfig(process.cwd(), (m) => console.error(`context-doctor: ${m}`));
256
343
  result = optimizeConversation(input, {
257
- strategies: args.strategies.length > 0 ? args.strategies : undefined,
258
- keepRecent: args.keepRecent,
259
- maxToolResultTokens: args.maxToolTokens,
344
+ strategies: args.strategies.length > 0 ? args.strategies : loaded.config.strategies,
345
+ keepRecent: args.keepRecent ?? loaded.config.keepRecent,
346
+ maxToolResultTokens: args.maxToolTokens ?? loaded.config.maxToolResultTokens,
260
347
  });
261
348
  }
262
349
  catch (e) {
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Project configuration and context budgets (`.contextdoctorrc`).
3
+ *
4
+ * Discovery walks up from the working directory to the filesystem root, then
5
+ * falls back to ~/.contextdoctorrc — so a repo can set its own budget and a
6
+ * user can set a machine-wide default. First file found wins (no merging:
7
+ * one visible file is easier to reason about than a merge chain).
8
+ *
9
+ * Everything here is optional. With no rc file the tool behaves exactly as
10
+ * it always has.
11
+ */
12
+ import type { StrategyId } from "./optimize.js";
13
+ export declare const RC_FILENAME = ".contextdoctorrc";
14
+ export interface ContextBudget {
15
+ /** Warn once a session/conversation exceeds this many tokens. */
16
+ maxTokens?: number;
17
+ /** Warn once estimated input cost per message exceeds this many USD. */
18
+ maxCostPerMessageUsd?: number;
19
+ /** Warn once the context fills this share of the model window (0-100). */
20
+ maxWindowPct?: number;
21
+ }
22
+ export interface ContextDoctorConfig {
23
+ budget?: ContextBudget;
24
+ /** Default optimize strategies for this project. */
25
+ strategies?: StrategyId[];
26
+ keepRecent?: number;
27
+ maxToolResultTokens?: number;
28
+ /** Proxy per-model overrides, same shape as `proxy --config`. */
29
+ routes?: Array<{
30
+ modelPrefix: string;
31
+ strategies?: StrategyId[];
32
+ keepRecent?: number;
33
+ maxToolResultTokens?: number;
34
+ }>;
35
+ /** Model used for cost math when a conversation does not name one. */
36
+ model?: string;
37
+ }
38
+ export interface LoadedConfig {
39
+ config: ContextDoctorConfig;
40
+ /** Absolute path of the rc file, or undefined when none was found. */
41
+ path?: string;
42
+ }
43
+ /**
44
+ * Load the nearest config. Malformed rc files are reported (so a typo is not
45
+ * silently ignored) but never throw — the tool keeps working with defaults.
46
+ */
47
+ export declare function loadConfig(startDir?: string, onWarn?: (msg: string) => void): LoadedConfig;
48
+ export interface BudgetVerdict {
49
+ /** True when any configured limit is exceeded. */
50
+ overBudget: boolean;
51
+ /** Human-readable lines, one per breached limit. */
52
+ breaches: string[];
53
+ /** The token limit in force, when one is configured. */
54
+ maxTokens?: number;
55
+ }
56
+ /** Compare a profile against the configured budget. */
57
+ export declare function checkBudget(budget: ContextBudget | undefined, profile: {
58
+ totalTokens: number;
59
+ usagePct?: number;
60
+ cost?: {
61
+ perCallUsd: number;
62
+ };
63
+ }): BudgetVerdict;
package/dist/config.js ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Project configuration and context budgets (`.contextdoctorrc`).
3
+ *
4
+ * Discovery walks up from the working directory to the filesystem root, then
5
+ * falls back to ~/.contextdoctorrc — so a repo can set its own budget and a
6
+ * user can set a machine-wide default. First file found wins (no merging:
7
+ * one visible file is easier to reason about than a merge chain).
8
+ *
9
+ * Everything here is optional. With no rc file the tool behaves exactly as
10
+ * it always has.
11
+ */
12
+ import { existsSync, readFileSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { dirname, join, parse as parsePath } from "node:path";
15
+ export const RC_FILENAME = ".contextdoctorrc";
16
+ /** Candidate rc paths: cwd upwards, then the home directory. */
17
+ function candidatePaths(startDir) {
18
+ const paths = [];
19
+ let dir = startDir;
20
+ const { root } = parsePath(dir);
21
+ for (;;) {
22
+ paths.push(join(dir, RC_FILENAME));
23
+ if (dir === root)
24
+ break;
25
+ const parent = dirname(dir);
26
+ if (parent === dir)
27
+ break;
28
+ dir = parent;
29
+ }
30
+ const home = join(homedir(), RC_FILENAME);
31
+ if (!paths.includes(home))
32
+ paths.push(home);
33
+ return paths;
34
+ }
35
+ /**
36
+ * Load the nearest config. Malformed rc files are reported (so a typo is not
37
+ * silently ignored) but never throw — the tool keeps working with defaults.
38
+ */
39
+ export function loadConfig(startDir = process.cwd(), onWarn) {
40
+ for (const path of candidatePaths(startDir)) {
41
+ if (!existsSync(path))
42
+ continue;
43
+ try {
44
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
45
+ if (parsed && typeof parsed === "object")
46
+ return { config: parsed, path };
47
+ onWarn?.(`${path}: expected a JSON object — ignoring`);
48
+ }
49
+ catch (e) {
50
+ onWarn?.(`${path}: ${e.message} — ignoring`);
51
+ }
52
+ return { config: {} };
53
+ }
54
+ return { config: {} };
55
+ }
56
+ /** Compare a profile against the configured budget. */
57
+ export function checkBudget(budget, profile) {
58
+ const breaches = [];
59
+ if (!budget)
60
+ return { overBudget: false, breaches };
61
+ if (budget.maxTokens !== undefined && profile.totalTokens > budget.maxTokens) {
62
+ breaches.push(`context is ${profile.totalTokens} tokens, over the ${budget.maxTokens} budget`);
63
+ }
64
+ if (budget.maxCostPerMessageUsd !== undefined &&
65
+ profile.cost !== undefined &&
66
+ profile.cost.perCallUsd > budget.maxCostPerMessageUsd) {
67
+ breaches.push(`input cost is $${profile.cost.perCallUsd.toFixed(3)} per message, over the $${budget.maxCostPerMessageUsd.toFixed(3)} budget`);
68
+ }
69
+ if (budget.maxWindowPct !== undefined && profile.usagePct !== undefined && profile.usagePct > budget.maxWindowPct) {
70
+ breaches.push(`context fills ${profile.usagePct.toFixed(0)}% of the window, over the ${budget.maxWindowPct}% budget`);
71
+ }
72
+ return { overBudget: breaches.length > 0, breaches, maxTokens: budget.maxTokens };
73
+ }
@@ -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
+ }