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 +26 -6
- package/dist/cli.js +98 -11
- package/dist/config.d.ts +63 -0
- package/dist/config.js +73 -0
- package/dist/cursor.d.ts +46 -0
- package/dist/cursor.js +203 -0
- package/dist/dashboard.d.ts +45 -0
- package/dist/dashboard.js +394 -0
- package/dist/hook.js +42 -12
- package/dist/impact.js +1 -1
- package/dist/mcp.js +1 -1
- package/dist/profile.js +35 -1
- package/dist/session.d.ts +15 -0
- package/dist/session.js +21 -2
- package/package.json +3 -2
- package/dist/test/chatgpt-export.test.d.ts +0 -2
- package/dist/test/chatgpt-export.test.js +0 -45
- package/dist/test/doctor.test.d.ts +0 -2
- package/dist/test/doctor.test.js +0 -19
- package/dist/test/hook.test.d.ts +0 -5
- package/dist/test/hook.test.js +0 -54
- package/dist/test/mcp-http.test.d.ts +0 -6
- package/dist/test/mcp-http.test.js +0 -63
- package/dist/test/proxy.test.d.ts +0 -6
- package/dist/test/proxy.test.js +0 -121
- package/dist/test/smoke.test.d.ts +0 -2
- package/dist/test/smoke.test.js +0 -111
- package/dist/test/watch.test.d.ts +0 -2
- package/dist/test/watch.test.js +0 -36
package/README.md
CHANGED
|
@@ -34,14 +34,12 @@ Findings (4)
|
|
|
34
34
|
npx context-doctor install
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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}
|
|
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
|
-
|
|
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 :
|
|
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
|
|
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 :
|
|
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) {
|
package/dist/config.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/cursor.d.ts
ADDED
|
@@ -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
|
+
}
|