costhawk 1.5.11 → 1.5.12

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.
@@ -1,2 +1,2 @@
1
- export declare const BUILD_COMMIT_SHA = "b4c5b65";
1
+ export declare const BUILD_COMMIT_SHA = "d2dc17c";
2
2
  //# sourceMappingURL=build-info.d.ts.map
@@ -1,3 +1,3 @@
1
1
  // Auto-generated during release builds. Values may be empty in dev.
2
- export const BUILD_COMMIT_SHA = "b4c5b65";
2
+ export const BUILD_COMMIT_SHA = "d2dc17c";
3
3
  //# sourceMappingURL=build-info.js.map
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Cursor Local SQLite Parser (PR1 — dry-run only)
3
+ *
4
+ * Parses Cursor IDE chat history from the local SQLite database to extract
5
+ * token usage data. Read-only. Does not push to any backend.
6
+ *
7
+ * Storage:
8
+ * macOS: ~/Library/Application Support/Cursor/User/globalStorage/state.vscdb
9
+ * Linux: ~/.config/Cursor/User/globalStorage/state.vscdb
10
+ * Windows: %APPDATA%/Cursor/User/globalStorage/state.vscdb
11
+ *
12
+ * Schema:
13
+ * Table cursorDiskKV (key TEXT, value BLOB)
14
+ * Conversations: composerData:<composerId>
15
+ * Messages: bubbleId:<composerId>:<bubbleId>
16
+ *
17
+ * Token data lives at $.tokenCount.inputTokens and $.tokenCount.outputTokens
18
+ * on bubble rows. Model name at $.modelInfo.modelName. Server-side dedup id
19
+ * at $.serverBubbleId.
20
+ *
21
+ * NOTE (PR1 scope): Cursor message timestamps are not yet verified across
22
+ * versions, so this dry-run parser does NOT return startTime/endTime/dailyUsage.
23
+ * PR2 will add timestamp support after verification on real Cursor data.
24
+ *
25
+ * Workspace metadata fields (workspaceHash/workspaceName) are also unverified
26
+ * and return null until PR2 confirms the stable field source.
27
+ */
28
+ import type { TokenUsage } from "./transcript-parser.js";
29
+ /**
30
+ * Single Cursor session in dry-run output.
31
+ *
32
+ * Shape is intentionally distinct from Claude/Codex SessionUsage because
33
+ * Cursor message timestamps are not yet verified — startTime/endTime are
34
+ * deliberately absent until PR2 verifies the stable timestamp source.
35
+ */
36
+ export interface CursorSessionUsageDryRun {
37
+ sessionId: string;
38
+ workspaceHash: string | null;
39
+ workspaceName: string | null;
40
+ model: string;
41
+ tokens: TokenUsage;
42
+ messageCount: number;
43
+ filePath: string;
44
+ }
45
+ export interface CursorParserError {
46
+ code: "CURSOR_DB_NOT_FOUND" | "CURSOR_SQLITE3_NOT_FOUND" | "CURSOR_SQLITE_QUERY_FAILED";
47
+ message: string;
48
+ }
49
+ export interface CursorParserResult {
50
+ sessions: CursorSessionUsageDryRun[];
51
+ filePath: string;
52
+ }
53
+ /**
54
+ * Get the default Cursor SQLite path for the current platform, honoring
55
+ * the COSTHAWK_CURSOR_DB_PATH environment override.
56
+ */
57
+ export declare function getCursorDbPath(): string;
58
+ /**
59
+ * Check whether the Cursor SQLite database exists at the resolved path.
60
+ */
61
+ export declare function cursorDbExists(): boolean;
62
+ /**
63
+ * Type guard — narrows an unknown error to a CursorParserError.
64
+ */
65
+ declare function isCursorParserError(value: unknown): value is CursorParserError;
66
+ /**
67
+ * Parse Cursor usage from local SQLite. Read-only dry run — does NOT push
68
+ * anything to the costcanary backend.
69
+ *
70
+ * Returns aggregated session data per composer with per-session token totals
71
+ * and message counts. Throws CursorParserError on unrecoverable failures
72
+ * (missing DB, missing sqlite3 binary, malformed SQLite output).
73
+ *
74
+ * Dedup strategy: per composer, keep one entry per (serverBubbleId ?? bubbleId).
75
+ * On collision, keep the candidate with the larger token total.
76
+ *
77
+ * Mixed-model handling: if a composer contains multiple non-empty model names,
78
+ * the returned `model` field is "mixed". If no model info is present on any
79
+ * bubble, the field is "unknown".
80
+ *
81
+ * Sort order: total tokens descending. NOT chronological — message timestamps
82
+ * are not yet verified for Cursor.
83
+ */
84
+ export declare function parseCursorUsageDryRun(): CursorParserResult;
85
+ export { isCursorParserError };
86
+ //# sourceMappingURL=cursor-parser.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cursor-parser.d.ts","sourceRoot":"","sources":["../src/cursor-parser.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAOH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AAOzD;;;;;;GAMG;AACH,MAAM,WAAW,wBAAwB;IACvC,SAAS,EAAE,MAAM,CAAC;IAClB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,UAAU,CAAC;IACnB,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,iBAAiB;IAChC,IAAI,EACA,qBAAqB,GACrB,0BAA0B,GAC1B,4BAA4B,CAAC;IACjC,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,wBAAwB,EAAE,CAAC;IACrC,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED;;;GAGG;AACH,wBAAgB,eAAe,IAAI,MAAM,CAmCxC;AAED;;GAEG;AACH,wBAAgB,cAAc,IAAI,OAAO,CAExC;AAUD;;GAEG;AACH,iBAAS,mBAAmB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,iBAAiB,CAYvE;AA+KD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,sBAAsB,IAAI,kBAAkB,CAuH3D;AAID,OAAO,EAAE,mBAAmB,EAAE,CAAC"}
@@ -0,0 +1,355 @@
1
+ /**
2
+ * Cursor Local SQLite Parser (PR1 — dry-run only)
3
+ *
4
+ * Parses Cursor IDE chat history from the local SQLite database to extract
5
+ * token usage data. Read-only. Does not push to any backend.
6
+ *
7
+ * Storage:
8
+ * macOS: ~/Library/Application Support/Cursor/User/globalStorage/state.vscdb
9
+ * Linux: ~/.config/Cursor/User/globalStorage/state.vscdb
10
+ * Windows: %APPDATA%/Cursor/User/globalStorage/state.vscdb
11
+ *
12
+ * Schema:
13
+ * Table cursorDiskKV (key TEXT, value BLOB)
14
+ * Conversations: composerData:<composerId>
15
+ * Messages: bubbleId:<composerId>:<bubbleId>
16
+ *
17
+ * Token data lives at $.tokenCount.inputTokens and $.tokenCount.outputTokens
18
+ * on bubble rows. Model name at $.modelInfo.modelName. Server-side dedup id
19
+ * at $.serverBubbleId.
20
+ *
21
+ * NOTE (PR1 scope): Cursor message timestamps are not yet verified across
22
+ * versions, so this dry-run parser does NOT return startTime/endTime/dailyUsage.
23
+ * PR2 will add timestamp support after verification on real Cursor data.
24
+ *
25
+ * Workspace metadata fields (workspaceHash/workspaceName) are also unverified
26
+ * and return null until PR2 confirms the stable field source.
27
+ */
28
+ import { execFileSync } from "child_process";
29
+ import { existsSync } from "fs";
30
+ import { homedir, platform } from "os";
31
+ import { join } from "path";
32
+ // Defaults — overridable via env vars
33
+ const DEFAULT_SQLITE3_PATH = "/usr/bin/sqlite3";
34
+ const SQLITE_TIMEOUT_MS = 10_000;
35
+ const SQLITE_MAX_BUFFER_BYTES = 32 * 1024 * 1024;
36
+ /**
37
+ * Get the default Cursor SQLite path for the current platform, honoring
38
+ * the COSTHAWK_CURSOR_DB_PATH environment override.
39
+ */
40
+ export function getCursorDbPath() {
41
+ const envOverride = process.env.COSTHAWK_CURSOR_DB_PATH;
42
+ if (envOverride && envOverride.length > 0) {
43
+ return envOverride;
44
+ }
45
+ const home = homedir();
46
+ if (platform() === "darwin") {
47
+ return join(home, "Library", "Application Support", "Cursor", "User", "globalStorage", "state.vscdb");
48
+ }
49
+ if (platform() === "win32") {
50
+ const appData = process.env.APPDATA;
51
+ if (appData) {
52
+ return join(appData, "Cursor", "User", "globalStorage", "state.vscdb");
53
+ }
54
+ return join(home, "AppData", "Roaming", "Cursor", "User", "globalStorage", "state.vscdb");
55
+ }
56
+ // Linux and other unix-likes
57
+ return join(home, ".config", "Cursor", "User", "globalStorage", "state.vscdb");
58
+ }
59
+ /**
60
+ * Check whether the Cursor SQLite database exists at the resolved path.
61
+ */
62
+ export function cursorDbExists() {
63
+ return existsSync(getCursorDbPath());
64
+ }
65
+ /**
66
+ * Resolve the sqlite3 binary path. Defaults to /usr/bin/sqlite3, honoring
67
+ * the COSTHAWK_SQLITE3_PATH environment override.
68
+ */
69
+ function getSqlite3Path() {
70
+ return process.env.COSTHAWK_SQLITE3_PATH ?? DEFAULT_SQLITE3_PATH;
71
+ }
72
+ /**
73
+ * Type guard — narrows an unknown error to a CursorParserError.
74
+ */
75
+ function isCursorParserError(value) {
76
+ if (typeof value !== "object" || value === null) {
77
+ return false;
78
+ }
79
+ const obj = value;
80
+ return (typeof obj.code === "string" &&
81
+ (obj.code === "CURSOR_DB_NOT_FOUND" ||
82
+ obj.code === "CURSOR_SQLITE3_NOT_FOUND" ||
83
+ obj.code === "CURSOR_SQLITE_QUERY_FAILED") &&
84
+ typeof obj.message === "string");
85
+ }
86
+ /**
87
+ * Run a SQL query against the Cursor SQLite via shell-out to the system
88
+ * sqlite3 binary. Returns parsed rows as an array of {key, value} objects,
89
+ * or throws CursorParserError on unrecoverable failures.
90
+ *
91
+ * Uses execFileSync with an arg array (not shell strings) to avoid shell
92
+ * injection. Sets explicit timeout and maxBuffer to defend against runaway
93
+ * queries or oversized state.vscdb files.
94
+ */
95
+ function runCursorQuery(sql) {
96
+ const sqlite3Path = getSqlite3Path();
97
+ const dbPath = getCursorDbPath();
98
+ if (!existsSync(dbPath)) {
99
+ const error = {
100
+ code: "CURSOR_DB_NOT_FOUND",
101
+ message: `Cursor SQLite database not found at ${dbPath}. Make sure Cursor is installed and you have used it at least once. Set COSTHAWK_CURSOR_DB_PATH to override.`,
102
+ };
103
+ throw error;
104
+ }
105
+ let stdout;
106
+ try {
107
+ stdout = execFileSync(sqlite3Path, ["-readonly", "-batch", "-json", "--", dbPath, sql], {
108
+ encoding: "utf8",
109
+ timeout: SQLITE_TIMEOUT_MS,
110
+ maxBuffer: SQLITE_MAX_BUFFER_BYTES,
111
+ });
112
+ }
113
+ catch (err) {
114
+ const errno = err.code;
115
+ if (errno === "ENOENT") {
116
+ const error = {
117
+ code: "CURSOR_SQLITE3_NOT_FOUND",
118
+ message: `sqlite3 binary not found at ${sqlite3Path}. Set COSTHAWK_SQLITE3_PATH to override the default path.`,
119
+ };
120
+ throw error;
121
+ }
122
+ const message = err instanceof Error ? err.message : String(err);
123
+ const error = {
124
+ code: "CURSOR_SQLITE_QUERY_FAILED",
125
+ message,
126
+ };
127
+ throw error;
128
+ }
129
+ if (!stdout || stdout.trim().length === 0) {
130
+ return [];
131
+ }
132
+ // sqlite3 -json output is a JSON array of objects when rows exist,
133
+ // or empty / whitespace when no rows. Anything else is a real failure
134
+ // and must surface as CURSOR_SQLITE_QUERY_FAILED — silently returning
135
+ // [] would mask a parser failure as "no sessions found".
136
+ let parsed;
137
+ try {
138
+ parsed = JSON.parse(stdout);
139
+ }
140
+ catch {
141
+ const error = {
142
+ code: "CURSOR_SQLITE_QUERY_FAILED",
143
+ message: "sqlite3 returned invalid JSON output",
144
+ };
145
+ throw error;
146
+ }
147
+ if (!Array.isArray(parsed)) {
148
+ const error = {
149
+ code: "CURSOR_SQLITE_QUERY_FAILED",
150
+ message: "sqlite3 returned a non-array JSON payload",
151
+ };
152
+ throw error;
153
+ }
154
+ return parsed.filter((row) => typeof row === "object" &&
155
+ row !== null &&
156
+ "key" in row &&
157
+ "value" in row &&
158
+ typeof row.key === "string" &&
159
+ typeof row.value === "string");
160
+ }
161
+ function hasTokenUsage(bubble) {
162
+ return bubble.inputTokens > 0 || bubble.outputTokens > 0;
163
+ }
164
+ const BUBBLE_KEY_REGEX = /^bubbleId:([^:]+):(.+)$/;
165
+ /**
166
+ * Parse a single bubbleId row into structured BubbleData.
167
+ *
168
+ * Returns null if the row key is malformed, the value is not parseable JSON,
169
+ * or the row contains neither a non-empty model name nor any positive token
170
+ * counts. Cursor can store model metadata and token usage on different rows
171
+ * (model name typically lives on user-prompt bubbles, token counts live on
172
+ * assistant-response bubbles), so the parser must accept either signal in
173
+ * isolation and let the per-composer aggregation merge them.
174
+ */
175
+ function parseBubble(row) {
176
+ const match = BUBBLE_KEY_REGEX.exec(row.key);
177
+ if (!match) {
178
+ return null;
179
+ }
180
+ const [, composerId, bubbleId] = match;
181
+ let value;
182
+ try {
183
+ value = JSON.parse(row.value);
184
+ }
185
+ catch {
186
+ return null;
187
+ }
188
+ if (typeof value !== "object" || value === null) {
189
+ return null;
190
+ }
191
+ const obj = value;
192
+ let inputTokens = 0;
193
+ let outputTokens = 0;
194
+ const tokenCount = obj.tokenCount;
195
+ if (typeof tokenCount === "object" && tokenCount !== null) {
196
+ const tc = tokenCount;
197
+ inputTokens = typeof tc.inputTokens === "number" ? tc.inputTokens : 0;
198
+ outputTokens = typeof tc.outputTokens === "number" ? tc.outputTokens : 0;
199
+ }
200
+ let modelName;
201
+ const modelInfo = obj.modelInfo;
202
+ if (typeof modelInfo === "object" && modelInfo !== null) {
203
+ const mi = modelInfo;
204
+ if (typeof mi.modelName === "string" && mi.modelName.length > 0) {
205
+ modelName = mi.modelName;
206
+ }
207
+ }
208
+ // Skip rows with no usable signal at all — neither model metadata nor
209
+ // positive token counts. These are typically system messages, empty
210
+ // bubbles, or tool-call bookkeeping rows.
211
+ if (!modelName && inputTokens === 0 && outputTokens === 0) {
212
+ return null;
213
+ }
214
+ let serverBubbleId;
215
+ if (typeof obj.serverBubbleId === "string" &&
216
+ obj.serverBubbleId.length > 0) {
217
+ serverBubbleId = obj.serverBubbleId;
218
+ }
219
+ return {
220
+ composerId,
221
+ bubbleId,
222
+ serverBubbleId,
223
+ modelName,
224
+ inputTokens,
225
+ outputTokens,
226
+ };
227
+ }
228
+ /**
229
+ * Parse Cursor usage from local SQLite. Read-only dry run — does NOT push
230
+ * anything to the costcanary backend.
231
+ *
232
+ * Returns aggregated session data per composer with per-session token totals
233
+ * and message counts. Throws CursorParserError on unrecoverable failures
234
+ * (missing DB, missing sqlite3 binary, malformed SQLite output).
235
+ *
236
+ * Dedup strategy: per composer, keep one entry per (serverBubbleId ?? bubbleId).
237
+ * On collision, keep the candidate with the larger token total.
238
+ *
239
+ * Mixed-model handling: if a composer contains multiple non-empty model names,
240
+ * the returned `model` field is "mixed". If no model info is present on any
241
+ * bubble, the field is "unknown".
242
+ *
243
+ * Sort order: total tokens descending. NOT chronological — message timestamps
244
+ * are not yet verified for Cursor.
245
+ */
246
+ export function parseCursorUsageDryRun() {
247
+ const dbPath = getCursorDbPath();
248
+ // Throws CursorParserError on missing DB / missing sqlite3 / query failure
249
+ const rows = runCursorQuery("SELECT key, value FROM cursorDiskKV WHERE key LIKE 'bubbleId:%'");
250
+ // Cursor splits model metadata and token usage across different bubble
251
+ // rows: model names typically live on user-prompt bubbles (type 1) with
252
+ // zero token counts, and token counts live on assistant-response bubbles
253
+ // (type 2) with no model info. We collect them separately and merge per
254
+ // composer.
255
+ //
256
+ // - tokenBubblesByComposer: per-composer dedup map for bubbles that carry
257
+ // positive token counts. Dedup key is (serverBubbleId ?? bubbleId).
258
+ // Collision rule: keep the candidate with the larger token total.
259
+ // - modelsByComposer: per-composer set of all distinct non-empty model
260
+ // names found on ANY bubble row in the composer. No dedup needed since
261
+ // models are categorical, not additive.
262
+ //
263
+ // Model harvesting is intentionally not gated on type or on token presence
264
+ // — if Cursor ever stores model names on assistant rows in a future
265
+ // schema, this code already supports it.
266
+ const tokenBubblesByComposer = new Map();
267
+ const modelsByComposer = new Map();
268
+ for (const row of rows) {
269
+ const bubble = parseBubble(row);
270
+ if (!bubble) {
271
+ continue;
272
+ }
273
+ if (bubble.modelName) {
274
+ let composerModels = modelsByComposer.get(bubble.composerId);
275
+ if (!composerModels) {
276
+ composerModels = new Set();
277
+ modelsByComposer.set(bubble.composerId, composerModels);
278
+ }
279
+ composerModels.add(bubble.modelName);
280
+ }
281
+ if (!hasTokenUsage(bubble)) {
282
+ continue;
283
+ }
284
+ let composerMap = tokenBubblesByComposer.get(bubble.composerId);
285
+ if (!composerMap) {
286
+ composerMap = new Map();
287
+ tokenBubblesByComposer.set(bubble.composerId, composerMap);
288
+ }
289
+ const dedupKey = bubble.serverBubbleId ?? bubble.bubbleId;
290
+ const existing = composerMap.get(dedupKey);
291
+ if (existing) {
292
+ const existingTotal = existing.inputTokens + existing.outputTokens;
293
+ const newTotal = bubble.inputTokens + bubble.outputTokens;
294
+ if (newTotal > existingTotal) {
295
+ composerMap.set(dedupKey, bubble);
296
+ }
297
+ continue;
298
+ }
299
+ composerMap.set(dedupKey, bubble);
300
+ }
301
+ // Aggregate per composer into the dry-run output shape.
302
+ const sessions = [];
303
+ for (const [composerId, composerMap] of tokenBubblesByComposer) {
304
+ let inputTokens = 0;
305
+ let outputTokens = 0;
306
+ let messageCount = 0;
307
+ const modelsSeen = modelsByComposer.get(composerId) ?? new Set();
308
+ for (const bubble of composerMap.values()) {
309
+ inputTokens += bubble.inputTokens;
310
+ outputTokens += bubble.outputTokens;
311
+ messageCount += 1;
312
+ }
313
+ if (messageCount === 0) {
314
+ continue;
315
+ }
316
+ let model;
317
+ if (modelsSeen.size === 0) {
318
+ model = "unknown";
319
+ }
320
+ else if (modelsSeen.size === 1) {
321
+ model = Array.from(modelsSeen)[0];
322
+ }
323
+ else {
324
+ model = "mixed";
325
+ }
326
+ sessions.push({
327
+ sessionId: composerId,
328
+ workspaceHash: null, // Unverified in PR1 — set in PR2
329
+ workspaceName: null, // Unverified in PR1 — set in PR2
330
+ model,
331
+ tokens: {
332
+ inputTokens,
333
+ outputTokens,
334
+ cacheCreationTokens: 0, // Cursor does not have prompt cache tokens
335
+ cacheReadTokens: 0,
336
+ },
337
+ messageCount,
338
+ filePath: dbPath,
339
+ });
340
+ }
341
+ // Sort by total tokens descending. NOT recency — no verified timestamps.
342
+ sessions.sort((a, b) => {
343
+ const aTotal = a.tokens.inputTokens + a.tokens.outputTokens;
344
+ const bTotal = b.tokens.inputTokens + b.tokens.outputTokens;
345
+ return bTotal - aTotal;
346
+ });
347
+ return {
348
+ sessions,
349
+ filePath: dbPath,
350
+ };
351
+ }
352
+ // Re-export the type guard so the MCP tool registration in index.ts can
353
+ // distinguish CursorParserError from generic Error in its catch block.
354
+ export { isCursorParserError };
355
+ //# sourceMappingURL=cursor-parser.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cursor-parser.js","sourceRoot":"","sources":["../src/cursor-parser.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ import { homedir, hostname, platform } from "os";
10
10
  // Claude Code local transcript parsing
11
11
  import { claudeCodeDirectoryExists, discoverTranscripts, getClaudeProjectsDir, parseAllTranscriptsDetailed, } from "./transcript-parser.js";
12
12
  import { codexDirectoryExists, discoverCodexSessions, getCodexSessionsDir, parseAllCodexSessionsDetailed, } from "./codex-parser.js";
13
+ import { cursorDbExists, getCursorDbPath, isCursorParserError, parseCursorUsageDryRun, } from "./cursor-parser.js";
13
14
  import { calculateCost, formatCost, formatTokens, calculateSavings, CLAUDE_SUBSCRIPTIONS, getClaudeCodePricing, getLLMPricing, } from "./pricing-constants.js";
14
15
  import { BUILD_COMMIT_SHA } from "./build-info.js";
15
16
  // Constants
@@ -1360,6 +1361,35 @@ function formatUsageByTagMarkdown(data, tagKey) {
1360
1361
  }
1361
1362
  return truncateResponse(output);
1362
1363
  }
1364
+ function formatUsageBreakdownMarkdown(projectData, featureData) {
1365
+ let output = `# Cost Breakdown\n\n`;
1366
+ output += `## By Project\n\n`;
1367
+ if (projectData.length === 0) {
1368
+ output += `No project attribution data found.\n\n`;
1369
+ }
1370
+ else {
1371
+ output += `| Project | Cost | Calls | % of Total |\n`;
1372
+ output += `|---------|------|-------|------------|\n`;
1373
+ for (const item of projectData) {
1374
+ output += `| ${item.value} | ${formatCurrency(item.cost)} | ${formatNumber(item.calls)} | ${item.percentage.toFixed(1)}% |\n`;
1375
+ }
1376
+ output += `\n`;
1377
+ }
1378
+ output += `## By Feature\n\n`;
1379
+ if (featureData.length === 0) {
1380
+ output += `No feature attribution data found.\n\n`;
1381
+ }
1382
+ else {
1383
+ output += `| Feature | Cost | Calls | % of Total |\n`;
1384
+ output += `|---------|------|-------|------------|\n`;
1385
+ for (const item of featureData) {
1386
+ output += `| ${item.value} | ${formatCurrency(item.cost)} | ${formatNumber(item.calls)} | ${item.percentage.toFixed(1)}% |\n`;
1387
+ }
1388
+ output += `\n`;
1389
+ }
1390
+ output += `---\n*Use tagKey "project" or "feature" to drill into a specific dimension.*\n`;
1391
+ return truncateResponse(output);
1392
+ }
1363
1393
  function formatAnomaliesMarkdown(anomalies) {
1364
1394
  if (anomalies.length === 0) {
1365
1395
  return "# Anomaly Detection\n\nNo anomalies detected. Your usage patterns appear normal.";
@@ -2289,7 +2319,7 @@ const UsageByTagSchema = {
2289
2319
  .string()
2290
2320
  .optional()
2291
2321
  .describe("Your CostHawk API key. If not provided, uses COSTHAWK_API_KEY environment variable."),
2292
- tagKey: z.string().describe("Tag key to group by. Examples: user_id, feature, environment, team"),
2322
+ tagKey: z.string().describe('Tag key to group by. Use "breakdown" for a full project + feature cost breakdown. Other options: project, feature, user_id, environment, team, provider, model'),
2293
2323
  startDate: z.string().optional().describe("Start date (ISO 8601). Defaults to 30 days ago"),
2294
2324
  endDate: z.string().optional().describe("End date (ISO 8601). Defaults to now"),
2295
2325
  limit: z.number().min(1).max(100).optional().default(20).describe("Maximum number of results (1-100)"),
@@ -2549,6 +2579,18 @@ const GetLocalCodexUsageSchema = {
2549
2579
  .describe("Only include sessions modified within this many hours (1-720, default 24)"),
2550
2580
  format: z.enum(["markdown", "json"]).optional().default("markdown").describe("Response format"),
2551
2581
  };
2582
+ // PR1 dry-run schema for Cursor: no maxAgeHours (Cursor message timestamps
2583
+ // are unverified across versions — adding an age filter would be misleading
2584
+ // until PR2 confirms the stable timestamp source). No apiKey or pricing
2585
+ // (PR1 defers cost calculation entirely). Format is JSON-only in PR1;
2586
+ // markdown formatter parity will be added in PR2 if needed.
2587
+ const GetLocalCursorUsageSchema = {
2588
+ format: z
2589
+ .enum(["json"])
2590
+ .optional()
2591
+ .default("json")
2592
+ .describe("Response format. Only json is supported in this PR1 dry-run release."),
2593
+ };
2552
2594
  const ListCodexSessionsSchema = {
2553
2595
  maxAgeHours: z
2554
2596
  .number()
@@ -2609,7 +2651,7 @@ server.registerTool("costhawk_get_usage_summary", {
2609
2651
  }
2610
2652
  });
2611
2653
  server.registerTool("costhawk_get_usage_by_tag", {
2612
- description: `Get usage costs grouped by a specific tag/attribute. Use this to see which features, users, or environments are driving costs.\n\nIMPORTANT: Always display the COMPLETE output in your response using markdown tables. Never summarize or truncate.`,
2654
+ description: `Get usage costs grouped by a specific tag/attribute. Use tagKey "breakdown" for a full project + feature cost breakdown. Other tagKey options: project, feature, user_id, environment, team, provider, model.\n\nIMPORTANT: Always display the COMPLETE output in your response using markdown tables. Never summarize or truncate.`,
2613
2655
  inputSchema: UsageByTagSchema,
2614
2656
  annotations: READ_ONLY_ANNOTATIONS,
2615
2657
  }, async (args, _extra) => {
@@ -2626,6 +2668,29 @@ server.registerTool("costhawk_get_usage_by_tag", {
2626
2668
  };
2627
2669
  }
2628
2670
  try {
2671
+ if (args.tagKey === "breakdown") {
2672
+ const baseParams = new URLSearchParams();
2673
+ if (args.startDate)
2674
+ baseParams.set("startDate", args.startDate);
2675
+ if (args.endDate)
2676
+ baseParams.set("endDate", args.endDate);
2677
+ if (args.limit)
2678
+ baseParams.set("limit", String(args.limit));
2679
+ const projectParams = new URLSearchParams(baseParams);
2680
+ projectParams.set("tagKey", "project");
2681
+ const featureParams = new URLSearchParams(baseParams);
2682
+ featureParams.set("tagKey", "feature");
2683
+ const [projectData, featureData] = await Promise.all([
2684
+ apiRequest(`/api/mcp/usage/by-tag?${projectParams}`, { apiKey }),
2685
+ apiRequest(`/api/mcp/usage/by-tag?${featureParams}`, { apiKey }),
2686
+ ]);
2687
+ const text = args.format === "json"
2688
+ ? JSON.stringify({ project: projectData, feature: featureData }, null, 2)
2689
+ : formatUsageBreakdownMarkdown(projectData, featureData);
2690
+ return {
2691
+ content: [{ type: "text", text }],
2692
+ };
2693
+ }
2629
2694
  const queryParams = new URLSearchParams();
2630
2695
  queryParams.set("tagKey", args.tagKey);
2631
2696
  if (args.startDate)
@@ -3832,6 +3897,90 @@ server.registerTool("costhawk_list_codex_sessions", {
3832
3897
  };
3833
3898
  }
3834
3899
  });
3900
+ // ─── Cursor Local Usage (PR1 dry-run only) ───
3901
+ //
3902
+ // Reads Cursor IDE chat history from local SQLite via shell-out to the system
3903
+ // sqlite3 binary. Read-only. Does NOT push to costcanary backend. Does NOT
3904
+ // modify any state. Returns aggregated per-conversation token counts.
3905
+ //
3906
+ // Cursor message timestamps are not yet verified across versions, so this PR1
3907
+ // release does not return startTime/endTime/dailyUsage. PR2 will add timestamp
3908
+ // support after verification on real Cursor data.
3909
+ server.registerTool("costhawk_get_local_cursor_usage", {
3910
+ description: `Get Cursor IDE usage from local SQLite WITHOUT uploading to CostHawk. Read-only dry run. Shows per-conversation token counts and models from the user's local Cursor database.
3911
+
3912
+ NOTE: This is an early dry-run preview. It does not include per-message timestamps yet — those will be added in a future release after verification on real Cursor data. It does not push any data to CostHawk.
3913
+
3914
+ IMPORTANT: Always display the COMPLETE output in your response. Never summarize or truncate.`,
3915
+ inputSchema: GetLocalCursorUsageSchema,
3916
+ annotations: READ_ONLY_ANNOTATIONS,
3917
+ }, async (_args, _extra) => {
3918
+ // Precheck the SQLite file before invoking sqlite3 so the user gets a
3919
+ // helpful "install Cursor first" message instead of an opaque ENOENT.
3920
+ if (!cursorDbExists()) {
3921
+ return {
3922
+ content: [
3923
+ {
3924
+ type: "text",
3925
+ text: `Error: Cursor SQLite database not found at ${getCursorDbPath()}. Make sure Cursor is installed and you have used it at least once. Set COSTHAWK_CURSOR_DB_PATH to override the default path.`,
3926
+ },
3927
+ ],
3928
+ isError: true,
3929
+ };
3930
+ }
3931
+ try {
3932
+ const result = parseCursorUsageDryRun();
3933
+ if (result.sessions.length === 0) {
3934
+ return {
3935
+ content: [
3936
+ {
3937
+ type: "text",
3938
+ text: `No Cursor sessions with token data found at ${result.filePath}. The database exists but no token-bearing bubbles were detected. Try having a few real conversations in Cursor first.`,
3939
+ },
3940
+ ],
3941
+ };
3942
+ }
3943
+ const totalSessions = result.sessions.length;
3944
+ const totalInputTokens = result.sessions.reduce((acc, s) => acc + s.tokens.inputTokens, 0);
3945
+ const totalOutputTokens = result.sessions.reduce((acc, s) => acc + s.tokens.outputTokens, 0);
3946
+ const totalMessages = result.sessions.reduce((acc, s) => acc + s.messageCount, 0);
3947
+ const response = {
3948
+ dryRun: true,
3949
+ filePath: result.filePath,
3950
+ totals: {
3951
+ sessions: totalSessions,
3952
+ messages: totalMessages,
3953
+ inputTokens: totalInputTokens,
3954
+ outputTokens: totalOutputTokens,
3955
+ totalTokens: totalInputTokens + totalOutputTokens,
3956
+ },
3957
+ sessions: result.sessions,
3958
+ };
3959
+ const text = JSON.stringify(response, null, 2);
3960
+ return {
3961
+ content: [{ type: "text", text }],
3962
+ };
3963
+ }
3964
+ catch (err) {
3965
+ // CursorParserError carries a typed code; generic errors fall back
3966
+ // to a plain message. Both are surfaced to the MCP host with isError.
3967
+ const code = isCursorParserError(err) ? err.code : "UNKNOWN";
3968
+ const message = err instanceof Error
3969
+ ? err.message
3970
+ : typeof err === "object" && err !== null && "message" in err
3971
+ ? String(err.message)
3972
+ : "Unknown error";
3973
+ return {
3974
+ content: [
3975
+ {
3976
+ type: "text",
3977
+ text: `Error [${code}]: ${message}`,
3978
+ },
3979
+ ],
3980
+ isError: true,
3981
+ };
3982
+ }
3983
+ });
3835
3984
  // ─── Proxy Guide & Integrations Tools ───
3836
3985
  server.registerTool("costhawk_get_proxy_guide", {
3837
3986
  description: `Get the complete CostHawk API proxy setup guide. Learn how to route your OpenAI, Anthropic, or Google Gemini API calls through CostHawk for automatic cost tracking, token counting, and latency monitoring. Covers SDK configuration (Python + TypeScript), wrapped key management, response headers, error codes, and rate limits. No API key required.\n\nIMPORTANT: Always display the COMPLETE output in your response using markdown tables. Never summarize or truncate.`,