costhawk 1.2.2 → 1.3.1

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/dist/index.js CHANGED
@@ -3,17 +3,20 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { z } from "zod";
5
5
  // Claude Code local transcript parsing
6
- import { claudeCodeDirectoryExists, discoverTranscripts, parseAllTranscripts, aggregateUsage, } from "./transcript-parser.js";
6
+ import { claudeCodeDirectoryExists, discoverTranscripts, parseAllTranscriptsDetailed, } from "./transcript-parser.js";
7
+ import { codexDirectoryExists, discoverCodexSessions, parseAllCodexSessionsDetailed, } from "./codex-parser.js";
7
8
  import { calculateCost, formatCost, formatTokens, calculateSavings, CLAUDE_SUBSCRIPTIONS, } from "./pricing-constants.js";
8
9
  // Constants
9
10
  const CHARACTER_LIMIT = 25000;
10
11
  const REQUEST_TIMEOUT_MS = 30000; // 30 seconds
11
12
  const API_BASE_URL = process.env.COSTHAWK_API_URL || "https://costhawk.ai";
12
13
  const DEFAULT_API_KEY = process.env.COSTHAWK_API_KEY;
14
+ const CLIENT_VERSION = "1.3.1";
13
15
  // Auto-sync configuration
14
16
  const AUTO_SYNC_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes
15
17
  const AUTO_SYNC_MAX_AGE_HOURS = 24; // Sync sessions from last 24 hours
16
18
  let autoSyncIntervalId = null;
19
+ const CODEX_AUTO_SYNC_ENABLED = process.env.COSTHAWK_CODEX_AUTO_SYNC === "true";
17
20
  // AlertType enum values from backend
18
21
  const ALERT_TYPE_VALUES = [
19
22
  "BUDGET_EXCEEDED",
@@ -352,6 +355,12 @@ function formatClaudeCodeSyncResultMarkdown(data) {
352
355
  output += `| Metric | Value |\n|--------|-------|\n`;
353
356
  output += `| New Sessions | ${data.sessionsNew} |\n`;
354
357
  output += `| Updated Sessions | ${data.sessionsUpdated} |\n`;
358
+ if (data.sessionsWithDailyUsage !== undefined) {
359
+ output += `| Sessions w/ Daily Usage | ${data.sessionsWithDailyUsage} |\n`;
360
+ }
361
+ if (data.dailyUsageRowsUpserted !== undefined) {
362
+ output += `| Daily Usage Rows Upserted | ${data.dailyUsageRowsUpserted} |\n`;
363
+ }
355
364
  output += `| Total Tokens | ${formatTokens(data.totalTokens)} |\n`;
356
365
  output += `| Estimated Cost | ${formatCost(data.estimatedCost)} |\n`;
357
366
  return truncateResponse(output);
@@ -372,10 +381,67 @@ function formatClaudeCodeSessionsMarkdown(sessions) {
372
381
  }
373
382
  return truncateResponse(output);
374
383
  }
384
+ function formatCodexLocalUsageMarkdown(data) {
385
+ let output = `# Codex Local Usage\n\n`;
386
+ output += `## Summary\n`;
387
+ output += `| Metric | Value |\n|--------|-------|\n`;
388
+ output += `| Sessions | ${data.sessions} |\n`;
389
+ output += `| Total Tokens | ${formatTokens(data.tokens.total)} |\n`;
390
+ output += `| Input Tokens | ${formatTokens(data.tokens.input)} |\n`;
391
+ output += `| Output Tokens | ${formatTokens(data.tokens.output)} |\n`;
392
+ output += `| Cache Write Tokens | ${formatTokens(data.tokens.cacheCreation)} |\n`;
393
+ output += `| Cache Read Tokens | ${formatTokens(data.tokens.cacheRead)} |\n\n`;
394
+ if (data.sessionDetails.length > 0) {
395
+ output += `## Recent Sessions\n`;
396
+ output += `| Session | Model | Tokens |\n|---------|-------|--------|\n`;
397
+ for (const session of data.sessionDetails.slice(0, 10)) {
398
+ const shortId = session.sessionId.slice(0, 8);
399
+ output += `| ${shortId}... | ${session.model} | ${formatTokens(session.tokens)} |\n`;
400
+ }
401
+ if (data.sessionDetails.length > 10) {
402
+ output += `\n*...and ${data.sessionDetails.length - 10} more sessions*\n`;
403
+ }
404
+ }
405
+ return truncateResponse(output);
406
+ }
407
+ function formatCodexSyncResultMarkdown(data) {
408
+ const statusEmoji = data.success ? "✅" : "❌";
409
+ let output = `# Codex Sync Result\n\n`;
410
+ output += `${statusEmoji} ${data.message}\n\n`;
411
+ output += `| Metric | Value |\n|--------|-------|\n`;
412
+ output += `| New Sessions | ${data.sessionsNew} |\n`;
413
+ output += `| Updated Sessions | ${data.sessionsUpdated} |\n`;
414
+ if (data.sessionsWithDailyUsage !== undefined) {
415
+ output += `| Sessions w/ Daily Usage | ${data.sessionsWithDailyUsage} |\n`;
416
+ }
417
+ if (data.dailyUsageRowsUpserted !== undefined) {
418
+ output += `| Daily Usage Rows Upserted | ${data.dailyUsageRowsUpserted} |\n`;
419
+ }
420
+ output += `| Total Tokens | ${formatTokens(data.totalTokens)} |\n`;
421
+ if (data.estimatedCost !== undefined) {
422
+ output += `| Estimated Cost | ${formatCost(data.estimatedCost)} |\n`;
423
+ }
424
+ return truncateResponse(output);
425
+ }
426
+ function formatCodexSessionsMarkdown(sessions) {
427
+ if (sessions.length === 0) {
428
+ return "# Codex Sessions\n\nNo sessions found. Make sure you have used Codex recently.";
429
+ }
430
+ let output = `# Codex Sessions\n\n`;
431
+ output += `Found ${sessions.length} session(s)\n\n`;
432
+ output += `| Session ID | Last Modified | Size |\n|------------|---------------|------|\n`;
433
+ for (const session of sessions) {
434
+ const shortId = session.sessionId.slice(0, 12);
435
+ const date = session.lastModified.toISOString().split("T")[0];
436
+ const sizeKB = (session.size / 1024).toFixed(1);
437
+ output += `| ${shortId}... | ${date} | ${sizeKB} KB |\n`;
438
+ }
439
+ return truncateResponse(output);
440
+ }
375
441
  // Create MCP server
376
442
  const server = new McpServer({
377
443
  name: "costhawk-mcp-server",
378
- version: "1.2.2",
444
+ version: "1.3.1",
379
445
  });
380
446
  // Tool annotations for MCP best practices
381
447
  const READ_ONLY_ANNOTATIONS = {
@@ -560,6 +626,41 @@ const ListClaudeCodeSessionsSchema = {
560
626
  limit: z.number().min(1).max(100).optional().default(20).describe("Maximum number of sessions to show"),
561
627
  format: z.enum(["markdown", "json"]).optional().default("markdown").describe("Response format"),
562
628
  };
629
+ const SyncCodexUsageSchema = {
630
+ apiKey: z
631
+ .string()
632
+ .optional()
633
+ .describe("Your CostHawk API key. If not provided, uses COSTHAWK_API_KEY environment variable."),
634
+ maxAgeHours: z
635
+ .number()
636
+ .min(1)
637
+ .max(720)
638
+ .optional()
639
+ .default(24)
640
+ .describe("Only sync sessions modified within this many hours (1-720, default 24)"),
641
+ format: z.enum(["markdown", "json"]).optional().default("markdown").describe("Response format"),
642
+ };
643
+ const GetLocalCodexUsageSchema = {
644
+ maxAgeHours: z
645
+ .number()
646
+ .min(1)
647
+ .max(720)
648
+ .optional()
649
+ .default(24)
650
+ .describe("Only include sessions modified within this many hours (1-720, default 24)"),
651
+ format: z.enum(["markdown", "json"]).optional().default("markdown").describe("Response format"),
652
+ };
653
+ const ListCodexSessionsSchema = {
654
+ maxAgeHours: z
655
+ .number()
656
+ .min(1)
657
+ .max(720)
658
+ .optional()
659
+ .default(168)
660
+ .describe("Only include sessions modified within this many hours (1-720, default 168 = 7 days)"),
661
+ limit: z.number().min(1).max(100).optional().default(20).describe("Maximum number of sessions to show"),
662
+ format: z.enum(["markdown", "json"]).optional().default("markdown").describe("Response format"),
663
+ };
563
664
  // Register tools using registerTool (recommended API)
564
665
  server.registerTool("costhawk_get_usage_summary", {
565
666
  description: `Get a summary of your AI API usage and costs over a time period. Returns total costs, API calls, and token usage broken down by provider and model. Supports preset periods (last_24h, today, yesterday, last_7d, last_30d) or custom date ranges.`,
@@ -985,7 +1086,7 @@ server.registerTool("costhawk_sync_claude_code_usage", {
985
1086
  try {
986
1087
  // Parse all transcripts within age limit
987
1088
  const maxAgeHours = args.maxAgeHours ?? 24;
988
- const sessions = parseAllTranscripts(maxAgeHours);
1089
+ const sessions = parseAllTranscriptsDetailed(maxAgeHours);
989
1090
  if (sessions.length === 0) {
990
1091
  return {
991
1092
  content: [
@@ -997,18 +1098,28 @@ server.registerTool("costhawk_sync_claude_code_usage", {
997
1098
  };
998
1099
  }
999
1100
  // Transform sessions to API payload format
1000
- const allSessions = sessions.map((session) => ({
1001
- sessionId: session.sessionId,
1002
- projectHash: session.projectHash,
1003
- model: session.model,
1004
- inputTokens: session.tokens.inputTokens,
1005
- outputTokens: session.tokens.outputTokens,
1006
- cacheCreationTokens: session.tokens.cacheCreationTokens,
1007
- cacheReadTokens: session.tokens.cacheReadTokens,
1008
- messageCount: session.messageCount,
1009
- startTime: session.startTime,
1010
- endTime: session.endTime,
1011
- }));
1101
+ const allSessions = sessions.map((session) => {
1102
+ const dailyUsage = Object.entries(session.dailyUsage).map(([date, tokens]) => ({
1103
+ date,
1104
+ inputTokens: tokens.inputTokens,
1105
+ outputTokens: tokens.outputTokens,
1106
+ cacheCreationTokens: tokens.cacheCreationTokens,
1107
+ cacheReadTokens: tokens.cacheReadTokens,
1108
+ }));
1109
+ return {
1110
+ sessionId: session.sessionId,
1111
+ projectHash: session.projectHash,
1112
+ model: session.model,
1113
+ inputTokens: session.tokens.inputTokens,
1114
+ outputTokens: session.tokens.outputTokens,
1115
+ cacheCreationTokens: session.tokens.cacheCreationTokens,
1116
+ cacheReadTokens: session.tokens.cacheReadTokens,
1117
+ messageCount: session.messageCount,
1118
+ startTime: session.startTime,
1119
+ endTime: session.endTime,
1120
+ dailyUsage: dailyUsage.length > 0 ? dailyUsage : undefined,
1121
+ };
1122
+ });
1012
1123
  // Batch sessions in chunks of 100 (API limit)
1013
1124
  const BATCH_SIZE = 100;
1014
1125
  const batches = [];
@@ -1023,7 +1134,7 @@ server.registerTool("costhawk_sync_claude_code_usage", {
1023
1134
  for (const batch of batches) {
1024
1135
  const payload = {
1025
1136
  sessions: batch,
1026
- clientVersion: "1.2.2",
1137
+ clientVersion: CLIENT_VERSION,
1027
1138
  syncedAt: new Date().toISOString(),
1028
1139
  };
1029
1140
  const result = await apiRequest("/api/mcp/usage/claude-code", {
@@ -1076,8 +1187,29 @@ server.registerTool("costhawk_get_local_claude_code_usage", {
1076
1187
  }
1077
1188
  try {
1078
1189
  const maxAgeHours = args.maxAgeHours ?? 24;
1079
- const sessions = parseAllTranscripts(maxAgeHours);
1080
- if (sessions.length === 0) {
1190
+ const rangeEnd = new Date();
1191
+ const rangeStart = new Date(rangeEnd.getTime() - maxAgeHours * 60 * 60 * 1000);
1192
+ const sessions = parseAllTranscriptsDetailed(maxAgeHours, { rangeStart, rangeEnd });
1193
+ const sessionsInRange = sessions
1194
+ .map((session) => {
1195
+ const rangeTokens = session.rangeUsage || {
1196
+ inputTokens: 0,
1197
+ outputTokens: 0,
1198
+ cacheCreationTokens: 0,
1199
+ cacheReadTokens: 0,
1200
+ };
1201
+ const totalTokens = rangeTokens.inputTokens +
1202
+ rangeTokens.outputTokens +
1203
+ rangeTokens.cacheCreationTokens +
1204
+ rangeTokens.cacheReadTokens;
1205
+ return {
1206
+ session,
1207
+ rangeTokens,
1208
+ totalTokens,
1209
+ };
1210
+ })
1211
+ .filter((entry) => entry.totalTokens > 0);
1212
+ if (sessionsInRange.length === 0) {
1081
1213
  return {
1082
1214
  content: [
1083
1215
  {
@@ -1087,23 +1219,33 @@ server.registerTool("costhawk_get_local_claude_code_usage", {
1087
1219
  ],
1088
1220
  };
1089
1221
  }
1090
- // Aggregate usage
1091
- const aggregated = aggregateUsage(sessions);
1222
+ // Aggregate usage for the requested window
1223
+ const aggregatedTokens = sessionsInRange.reduce((acc, entry) => ({
1224
+ inputTokens: acc.inputTokens + entry.rangeTokens.inputTokens,
1225
+ outputTokens: acc.outputTokens + entry.rangeTokens.outputTokens,
1226
+ cacheCreationTokens: acc.cacheCreationTokens + entry.rangeTokens.cacheCreationTokens,
1227
+ cacheReadTokens: acc.cacheReadTokens + entry.rangeTokens.cacheReadTokens,
1228
+ }), {
1229
+ inputTokens: 0,
1230
+ outputTokens: 0,
1231
+ cacheCreationTokens: 0,
1232
+ cacheReadTokens: 0,
1233
+ });
1234
+ const modelCounts = {};
1235
+ for (const entry of sessionsInRange) {
1236
+ modelCounts[entry.session.model] = (modelCounts[entry.session.model] || 0) + 1;
1237
+ }
1092
1238
  // Calculate total cost using dominant model pricing
1093
- const dominantModel = Object.entries(aggregated.models).sort((a, b) => b[1] - a[1])[0]?.[0] || "unknown";
1239
+ const dominantModel = Object.entries(modelCounts).sort((a, b) => b[1] - a[1])[0]?.[0] || "unknown";
1094
1240
  const cost = calculateCost({
1095
- inputTokens: aggregated.tokens.inputTokens,
1096
- outputTokens: aggregated.tokens.outputTokens,
1097
- cacheCreationTokens: aggregated.tokens.cacheCreationTokens,
1098
- cacheReadTokens: aggregated.tokens.cacheReadTokens,
1241
+ inputTokens: aggregatedTokens.inputTokens,
1242
+ outputTokens: aggregatedTokens.outputTokens,
1243
+ cacheCreationTokens: aggregatedTokens.cacheCreationTokens,
1244
+ cacheReadTokens: aggregatedTokens.cacheReadTokens,
1099
1245
  }, dominantModel);
1100
1246
  // Build session details
1101
- const sessionDetails = sessions.map((session) => {
1102
- const sessionCost = calculateCost(session.tokens, session.model);
1103
- const totalTokens = session.tokens.inputTokens +
1104
- session.tokens.outputTokens +
1105
- session.tokens.cacheCreationTokens +
1106
- session.tokens.cacheReadTokens;
1247
+ const sessionDetails = sessionsInRange.map(({ session, rangeTokens, totalTokens }) => {
1248
+ const sessionCost = calculateCost(rangeTokens, session.model);
1107
1249
  return {
1108
1250
  sessionId: session.sessionId,
1109
1251
  projectHash: session.projectHash,
@@ -1116,13 +1258,16 @@ server.registerTool("costhawk_get_local_claude_code_usage", {
1116
1258
  });
1117
1259
  // Build response
1118
1260
  const response = {
1119
- sessions: aggregated.totalSessions,
1261
+ sessions: sessionsInRange.length,
1120
1262
  tokens: {
1121
- input: aggregated.tokens.inputTokens,
1122
- output: aggregated.tokens.outputTokens,
1123
- cacheCreation: aggregated.tokens.cacheCreationTokens,
1124
- cacheRead: aggregated.tokens.cacheReadTokens,
1125
- total: aggregated.totalTokens,
1263
+ input: aggregatedTokens.inputTokens,
1264
+ output: aggregatedTokens.outputTokens,
1265
+ cacheCreation: aggregatedTokens.cacheCreationTokens,
1266
+ cacheRead: aggregatedTokens.cacheReadTokens,
1267
+ total: aggregatedTokens.inputTokens +
1268
+ aggregatedTokens.outputTokens +
1269
+ aggregatedTokens.cacheCreationTokens +
1270
+ aggregatedTokens.cacheReadTokens,
1126
1271
  },
1127
1272
  cost,
1128
1273
  sessionDetails,
@@ -1192,6 +1337,254 @@ server.registerTool("costhawk_list_claude_code_sessions", {
1192
1337
  };
1193
1338
  }
1194
1339
  });
1340
+ server.registerTool("costhawk_sync_codex_usage", {
1341
+ description: `Sync your local Codex usage to CostHawk. Parses session logs from ~/.codex/sessions/ and uploads token counts to your CostHawk dashboard. Requires API key.`,
1342
+ inputSchema: SyncCodexUsageSchema,
1343
+ annotations: WRITE_ANNOTATIONS,
1344
+ }, async (args, _extra) => {
1345
+ const apiKey = getApiKey(args.apiKey);
1346
+ if (!apiKey) {
1347
+ return {
1348
+ content: [
1349
+ {
1350
+ type: "text",
1351
+ text: "Error: No API key provided. You must first sign up at https://costhawk.ai to get an API key. Once approved, go to Settings > Developer > Create Token, then set COSTHAWK_API_KEY in your MCP configuration.",
1352
+ },
1353
+ ],
1354
+ isError: true,
1355
+ };
1356
+ }
1357
+ if (!codexDirectoryExists()) {
1358
+ return {
1359
+ content: [
1360
+ {
1361
+ type: "text",
1362
+ text: "Error: Codex sessions directory not found at ~/.codex/sessions/. Make sure you have used Codex at least once.",
1363
+ },
1364
+ ],
1365
+ isError: true,
1366
+ };
1367
+ }
1368
+ try {
1369
+ const maxAgeHours = args.maxAgeHours ?? 24;
1370
+ const sessions = parseAllCodexSessionsDetailed(maxAgeHours);
1371
+ if (sessions.length === 0) {
1372
+ return {
1373
+ content: [
1374
+ {
1375
+ type: "text",
1376
+ text: `No Codex sessions found within the last ${maxAgeHours} hours. Try increasing maxAgeHours or use Codex first.`,
1377
+ },
1378
+ ],
1379
+ };
1380
+ }
1381
+ const allSessions = sessions.map((session) => {
1382
+ const dailyUsage = Object.entries(session.dailyUsage).map(([date, tokens]) => ({
1383
+ date,
1384
+ inputTokens: tokens.inputTokens,
1385
+ outputTokens: tokens.outputTokens,
1386
+ cacheCreationTokens: tokens.cacheCreationTokens,
1387
+ cacheReadTokens: tokens.cacheReadTokens,
1388
+ }));
1389
+ return {
1390
+ sessionId: session.sessionId,
1391
+ projectHash: session.projectHash,
1392
+ model: session.model,
1393
+ inputTokens: session.tokens.inputTokens,
1394
+ outputTokens: session.tokens.outputTokens,
1395
+ cacheCreationTokens: session.tokens.cacheCreationTokens,
1396
+ cacheReadTokens: session.tokens.cacheReadTokens,
1397
+ messageCount: session.messageCount,
1398
+ startTime: session.startTime,
1399
+ endTime: session.endTime,
1400
+ dailyUsage: dailyUsage.length > 0 ? dailyUsage : undefined,
1401
+ source: "codex",
1402
+ };
1403
+ });
1404
+ const BATCH_SIZE = 100;
1405
+ const batches = [];
1406
+ for (let i = 0; i < allSessions.length; i += BATCH_SIZE) {
1407
+ batches.push(allSessions.slice(i, i + BATCH_SIZE));
1408
+ }
1409
+ let totalNew = 0;
1410
+ let totalUpdated = 0;
1411
+ let totalTokens = 0;
1412
+ let totalCost = 0;
1413
+ let sessionsWithDailyUsage = 0;
1414
+ let dailyUsageRowsUpserted = 0;
1415
+ for (const batch of batches) {
1416
+ const payload = {
1417
+ sessions: batch,
1418
+ clientVersion: CLIENT_VERSION,
1419
+ syncedAt: new Date().toISOString(),
1420
+ };
1421
+ const result = await apiRequest("/api/mcp/usage/codex", {
1422
+ method: "POST",
1423
+ apiKey,
1424
+ body: payload,
1425
+ });
1426
+ totalNew += result.sessionsNew;
1427
+ totalUpdated += result.sessionsUpdated;
1428
+ totalTokens += result.totalTokens;
1429
+ totalCost += result.estimatedCost || 0;
1430
+ sessionsWithDailyUsage += result.sessionsWithDailyUsage || 0;
1431
+ dailyUsageRowsUpserted += result.dailyUsageRowsUpserted || 0;
1432
+ }
1433
+ const result = {
1434
+ success: true,
1435
+ sessionsNew: totalNew,
1436
+ sessionsUpdated: totalUpdated,
1437
+ totalTokens,
1438
+ estimatedCost: totalCost > 0 ? totalCost : undefined,
1439
+ sessionsWithDailyUsage: sessionsWithDailyUsage || undefined,
1440
+ dailyUsageRowsUpserted: dailyUsageRowsUpserted || undefined,
1441
+ message: `Synced ${totalNew} new and ${totalUpdated} updated sessions`,
1442
+ };
1443
+ const text = args.format === "json" ? JSON.stringify(result, null, 2) : formatCodexSyncResultMarkdown(result);
1444
+ return {
1445
+ content: [{ type: "text", text }],
1446
+ };
1447
+ }
1448
+ catch (error) {
1449
+ return {
1450
+ content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` }],
1451
+ isError: true,
1452
+ };
1453
+ }
1454
+ });
1455
+ server.registerTool("costhawk_get_local_codex_usage", {
1456
+ description: `Get Codex usage from local sessions WITHOUT uploading to CostHawk. Works offline. Shows token counts by type.`,
1457
+ inputSchema: GetLocalCodexUsageSchema,
1458
+ annotations: READ_ONLY_ANNOTATIONS,
1459
+ }, async (args, _extra) => {
1460
+ if (!codexDirectoryExists()) {
1461
+ return {
1462
+ content: [
1463
+ {
1464
+ type: "text",
1465
+ text: "Error: Codex sessions directory not found at ~/.codex/sessions/. Make sure you have used Codex at least once.",
1466
+ },
1467
+ ],
1468
+ isError: true,
1469
+ };
1470
+ }
1471
+ try {
1472
+ const maxAgeHours = args.maxAgeHours ?? 24;
1473
+ const rangeEnd = new Date();
1474
+ const rangeStart = new Date(rangeEnd.getTime() - maxAgeHours * 60 * 60 * 1000);
1475
+ const sessions = parseAllCodexSessionsDetailed(maxAgeHours, { rangeStart, rangeEnd });
1476
+ const sessionsInRange = sessions
1477
+ .map((session) => {
1478
+ const rangeTokens = session.rangeUsage || {
1479
+ inputTokens: 0,
1480
+ outputTokens: 0,
1481
+ cacheCreationTokens: 0,
1482
+ cacheReadTokens: 0,
1483
+ };
1484
+ const totalTokens = rangeTokens.inputTokens +
1485
+ rangeTokens.outputTokens +
1486
+ rangeTokens.cacheCreationTokens +
1487
+ rangeTokens.cacheReadTokens;
1488
+ return {
1489
+ session,
1490
+ rangeTokens,
1491
+ totalTokens,
1492
+ };
1493
+ })
1494
+ .filter((entry) => entry.totalTokens > 0);
1495
+ if (sessionsInRange.length === 0) {
1496
+ return {
1497
+ content: [
1498
+ {
1499
+ type: "text",
1500
+ text: `No Codex sessions found within the last ${maxAgeHours} hours. Try increasing maxAgeHours or use Codex first.`,
1501
+ },
1502
+ ],
1503
+ };
1504
+ }
1505
+ const aggregatedTokens = sessionsInRange.reduce((acc, entry) => ({
1506
+ inputTokens: acc.inputTokens + entry.rangeTokens.inputTokens,
1507
+ outputTokens: acc.outputTokens + entry.rangeTokens.outputTokens,
1508
+ cacheCreationTokens: acc.cacheCreationTokens + entry.rangeTokens.cacheCreationTokens,
1509
+ cacheReadTokens: acc.cacheReadTokens + entry.rangeTokens.cacheReadTokens,
1510
+ }), {
1511
+ inputTokens: 0,
1512
+ outputTokens: 0,
1513
+ cacheCreationTokens: 0,
1514
+ cacheReadTokens: 0,
1515
+ });
1516
+ const sessionDetails = sessionsInRange.map(({ session, totalTokens }) => ({
1517
+ sessionId: session.sessionId,
1518
+ model: session.model,
1519
+ tokens: totalTokens,
1520
+ startTime: session.startTime,
1521
+ endTime: session.endTime,
1522
+ }));
1523
+ const response = {
1524
+ sessions: sessionsInRange.length,
1525
+ tokens: {
1526
+ input: aggregatedTokens.inputTokens,
1527
+ output: aggregatedTokens.outputTokens,
1528
+ cacheCreation: aggregatedTokens.cacheCreationTokens,
1529
+ cacheRead: aggregatedTokens.cacheReadTokens,
1530
+ total: aggregatedTokens.inputTokens +
1531
+ aggregatedTokens.outputTokens +
1532
+ aggregatedTokens.cacheCreationTokens +
1533
+ aggregatedTokens.cacheReadTokens,
1534
+ },
1535
+ sessionDetails,
1536
+ };
1537
+ const text = args.format === "json" ? JSON.stringify(response, null, 2) : formatCodexLocalUsageMarkdown(response);
1538
+ return {
1539
+ content: [{ type: "text", text }],
1540
+ };
1541
+ }
1542
+ catch (error) {
1543
+ return {
1544
+ content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` }],
1545
+ isError: true,
1546
+ };
1547
+ }
1548
+ });
1549
+ server.registerTool("costhawk_list_codex_sessions", {
1550
+ description: `List available Codex sessions from local logs. Shows session IDs, modification dates, and file sizes.`,
1551
+ inputSchema: ListCodexSessionsSchema,
1552
+ annotations: READ_ONLY_ANNOTATIONS,
1553
+ }, async (args, _extra) => {
1554
+ if (!codexDirectoryExists()) {
1555
+ return {
1556
+ content: [
1557
+ {
1558
+ type: "text",
1559
+ text: "Error: Codex sessions directory not found at ~/.codex/sessions/. Make sure you have used Codex at least once.",
1560
+ },
1561
+ ],
1562
+ isError: true,
1563
+ };
1564
+ }
1565
+ try {
1566
+ const maxAgeHours = args.maxAgeHours ?? 168;
1567
+ const limit = args.limit ?? 20;
1568
+ const sessions = discoverCodexSessions(maxAgeHours).slice(0, limit);
1569
+ const text = args.format === "json"
1570
+ ? JSON.stringify(sessions.map((s) => ({
1571
+ sessionId: s.sessionId,
1572
+ lastModified: s.lastModified.toISOString(),
1573
+ size: s.size,
1574
+ path: s.path,
1575
+ })), null, 2)
1576
+ : formatCodexSessionsMarkdown(sessions);
1577
+ return {
1578
+ content: [{ type: "text", text }],
1579
+ };
1580
+ }
1581
+ catch (error) {
1582
+ return {
1583
+ content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` }],
1584
+ isError: true,
1585
+ };
1586
+ }
1587
+ });
1195
1588
  // Auto-sync function for Claude Code usage
1196
1589
  async function performAutoSync() {
1197
1590
  const apiKey = DEFAULT_API_KEY;
@@ -1204,24 +1597,34 @@ async function performAutoSync() {
1204
1597
  return;
1205
1598
  }
1206
1599
  try {
1207
- const sessions = parseAllTranscripts(AUTO_SYNC_MAX_AGE_HOURS);
1600
+ const sessions = parseAllTranscriptsDetailed(AUTO_SYNC_MAX_AGE_HOURS);
1208
1601
  if (sessions.length === 0) {
1209
1602
  console.error("[Auto-sync] No sessions to sync");
1210
1603
  return;
1211
1604
  }
1212
1605
  // Transform sessions to API payload format
1213
- const allSessions = sessions.map((session) => ({
1214
- sessionId: session.sessionId,
1215
- projectHash: session.projectHash,
1216
- model: session.model,
1217
- inputTokens: session.tokens.inputTokens,
1218
- outputTokens: session.tokens.outputTokens,
1219
- cacheCreationTokens: session.tokens.cacheCreationTokens,
1220
- cacheReadTokens: session.tokens.cacheReadTokens,
1221
- messageCount: session.messageCount,
1222
- startTime: session.startTime,
1223
- endTime: session.endTime,
1224
- }));
1606
+ const allSessions = sessions.map((session) => {
1607
+ const dailyUsage = Object.entries(session.dailyUsage).map(([date, tokens]) => ({
1608
+ date,
1609
+ inputTokens: tokens.inputTokens,
1610
+ outputTokens: tokens.outputTokens,
1611
+ cacheCreationTokens: tokens.cacheCreationTokens,
1612
+ cacheReadTokens: tokens.cacheReadTokens,
1613
+ }));
1614
+ return {
1615
+ sessionId: session.sessionId,
1616
+ projectHash: session.projectHash,
1617
+ model: session.model,
1618
+ inputTokens: session.tokens.inputTokens,
1619
+ outputTokens: session.tokens.outputTokens,
1620
+ cacheCreationTokens: session.tokens.cacheCreationTokens,
1621
+ cacheReadTokens: session.tokens.cacheReadTokens,
1622
+ messageCount: session.messageCount,
1623
+ startTime: session.startTime,
1624
+ endTime: session.endTime,
1625
+ dailyUsage: dailyUsage.length > 0 ? dailyUsage : undefined,
1626
+ };
1627
+ });
1225
1628
  // Batch sessions in chunks of 100 (API limit)
1226
1629
  const BATCH_SIZE = 100;
1227
1630
  const batches = [];
@@ -1235,7 +1638,7 @@ async function performAutoSync() {
1235
1638
  for (const batch of batches) {
1236
1639
  const payload = {
1237
1640
  sessions: batch,
1238
- clientVersion: "1.2.2",
1641
+ clientVersion: CLIENT_VERSION,
1239
1642
  syncedAt: new Date().toISOString(),
1240
1643
  autoSync: true,
1241
1644
  };
@@ -1254,6 +1657,79 @@ async function performAutoSync() {
1254
1657
  console.error(`[Auto-sync] Error: ${error instanceof Error ? error.message : "Unknown error"}`);
1255
1658
  }
1256
1659
  }
1660
+ // Auto-sync function for Codex usage (optional)
1661
+ async function performCodexAutoSync() {
1662
+ if (!CODEX_AUTO_SYNC_ENABLED) {
1663
+ return;
1664
+ }
1665
+ const apiKey = DEFAULT_API_KEY;
1666
+ if (!apiKey) {
1667
+ console.error("[Codex Auto-sync] Skipped: No COSTHAWK_API_KEY configured");
1668
+ return;
1669
+ }
1670
+ if (!codexDirectoryExists()) {
1671
+ console.error("[Codex Auto-sync] Skipped: Codex sessions directory not found");
1672
+ return;
1673
+ }
1674
+ try {
1675
+ const sessions = parseAllCodexSessionsDetailed(AUTO_SYNC_MAX_AGE_HOURS);
1676
+ if (sessions.length === 0) {
1677
+ console.error("[Codex Auto-sync] No sessions to sync");
1678
+ return;
1679
+ }
1680
+ const allSessions = sessions.map((session) => {
1681
+ const dailyUsage = Object.entries(session.dailyUsage).map(([date, tokens]) => ({
1682
+ date,
1683
+ inputTokens: tokens.inputTokens,
1684
+ outputTokens: tokens.outputTokens,
1685
+ cacheCreationTokens: tokens.cacheCreationTokens,
1686
+ cacheReadTokens: tokens.cacheReadTokens,
1687
+ }));
1688
+ return {
1689
+ sessionId: session.sessionId,
1690
+ projectHash: session.projectHash,
1691
+ model: session.model,
1692
+ inputTokens: session.tokens.inputTokens,
1693
+ outputTokens: session.tokens.outputTokens,
1694
+ cacheCreationTokens: session.tokens.cacheCreationTokens,
1695
+ cacheReadTokens: session.tokens.cacheReadTokens,
1696
+ messageCount: session.messageCount,
1697
+ startTime: session.startTime,
1698
+ endTime: session.endTime,
1699
+ dailyUsage: dailyUsage.length > 0 ? dailyUsage : undefined,
1700
+ source: "codex",
1701
+ };
1702
+ });
1703
+ const BATCH_SIZE = 100;
1704
+ const batches = [];
1705
+ for (let i = 0; i < allSessions.length; i += BATCH_SIZE) {
1706
+ batches.push(allSessions.slice(i, i + BATCH_SIZE));
1707
+ }
1708
+ let totalNew = 0;
1709
+ let totalUpdated = 0;
1710
+ let totalTokens = 0;
1711
+ for (const batch of batches) {
1712
+ const payload = {
1713
+ sessions: batch,
1714
+ clientVersion: CLIENT_VERSION,
1715
+ syncedAt: new Date().toISOString(),
1716
+ autoSync: true,
1717
+ };
1718
+ const result = await apiRequest("/api/mcp/usage/codex", {
1719
+ method: "POST",
1720
+ apiKey,
1721
+ body: payload,
1722
+ });
1723
+ totalNew += result.sessionsNew;
1724
+ totalUpdated += result.sessionsUpdated;
1725
+ totalTokens += result.totalTokens;
1726
+ }
1727
+ console.error(`[Codex Auto-sync] Success: ${totalNew} new, ${totalUpdated} updated, ${formatTokens(totalTokens)} tokens (${batches.length} batch${batches.length > 1 ? "es" : ""})`);
1728
+ }
1729
+ catch (error) {
1730
+ console.error(`[Codex Auto-sync] Error: ${error instanceof Error ? error.message : "Unknown error"}`);
1731
+ }
1732
+ }
1257
1733
  // Start auto-sync interval
1258
1734
  function startAutoSync() {
1259
1735
  if (!DEFAULT_API_KEY) {
@@ -1263,10 +1739,12 @@ function startAutoSync() {
1263
1739
  // Perform initial sync after 30 seconds (give server time to fully start)
1264
1740
  setTimeout(() => {
1265
1741
  performAutoSync();
1742
+ performCodexAutoSync();
1266
1743
  }, 30 * 1000);
1267
1744
  // Then sync every 15 minutes
1268
1745
  autoSyncIntervalId = setInterval(() => {
1269
1746
  performAutoSync();
1747
+ performCodexAutoSync();
1270
1748
  }, AUTO_SYNC_INTERVAL_MS);
1271
1749
  console.error(`[Auto-sync] Enabled: syncing every ${AUTO_SYNC_INTERVAL_MS / 60000} minutes`);
1272
1750
  }