@hasna/economy 0.1.1 → 0.2.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/cli/index.js CHANGED
@@ -639,6 +639,115 @@ var init_codex = __esm(() => {
639
639
  CODEX_CONFIG_PATH = join3(homedir3(), ".codex", "config.toml");
640
640
  });
641
641
 
642
+ // src/lib/config.ts
643
+ var exports_config = {};
644
+ __export(exports_config, {
645
+ setConfigValue: () => setConfigValue,
646
+ saveConfig: () => saveConfig,
647
+ loadConfig: () => loadConfig,
648
+ getConfigValue: () => getConfigValue
649
+ });
650
+ import { existsSync as existsSync4, readFileSync as readFileSync3, writeFileSync, mkdirSync as mkdirSync2 } from "fs";
651
+ import { homedir as homedir4 } from "os";
652
+ import { join as join4 } from "path";
653
+ function loadConfig() {
654
+ try {
655
+ if (existsSync4(CONFIG_PATH)) {
656
+ const raw = readFileSync3(CONFIG_PATH, "utf-8");
657
+ return { ...DEFAULTS, ...JSON.parse(raw) };
658
+ }
659
+ } catch {}
660
+ return { ...DEFAULTS };
661
+ }
662
+ function saveConfig(config) {
663
+ const dir = CONFIG_PATH.substring(0, CONFIG_PATH.lastIndexOf("/"));
664
+ if (!existsSync4(dir))
665
+ mkdirSync2(dir, { recursive: true });
666
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + `
667
+ `);
668
+ }
669
+ function getConfigValue(key) {
670
+ const config = loadConfig();
671
+ return config[key] ?? null;
672
+ }
673
+ function setConfigValue(key, value) {
674
+ const config = loadConfig();
675
+ let parsed = value;
676
+ if (value === "true")
677
+ parsed = true;
678
+ else if (value === "false")
679
+ parsed = false;
680
+ else if (value === "null")
681
+ parsed = null;
682
+ else if (!isNaN(Number(value)))
683
+ parsed = Number(value);
684
+ else if (value.startsWith("[")) {
685
+ try {
686
+ parsed = JSON.parse(value);
687
+ } catch {}
688
+ }
689
+ config[key] = parsed;
690
+ saveConfig(config);
691
+ }
692
+ var CONFIG_PATH, DEFAULTS;
693
+ var init_config = __esm(() => {
694
+ CONFIG_PATH = join4(homedir4(), ".economy", "config.json");
695
+ DEFAULTS = {
696
+ port: 3456,
697
+ "default-period": "today",
698
+ "auto-sync": true,
699
+ "sync-interval": 30,
700
+ "alert-thresholds": [5, 10, 25, 50, 100],
701
+ "webhook-url": null
702
+ };
703
+ });
704
+
705
+ // src/lib/webhooks.ts
706
+ var exports_webhooks = {};
707
+ __export(exports_webhooks, {
708
+ checkAndFireWebhooks: () => checkAndFireWebhooks
709
+ });
710
+ async function checkAndFireWebhooks(db) {
711
+ const config = loadConfig();
712
+ const url = config["webhook-url"];
713
+ if (!url)
714
+ return;
715
+ const statuses = getBudgetStatuses(db);
716
+ for (const b of statuses) {
717
+ if (!b.is_over_alert)
718
+ continue;
719
+ const key = `webhook-budget-${b.id}-${b.period}`;
720
+ const lastFired = getIngestState(db, "webhook", key);
721
+ const pctBucket = Math.floor(b.percent_used / 10) * 10;
722
+ if (lastFired === String(pctBucket))
723
+ continue;
724
+ await fireWebhook(url, {
725
+ event: "budget_alert",
726
+ budget_id: b.id,
727
+ project: b.project_path ?? "global",
728
+ period: b.period,
729
+ spend: b.current_spend_usd,
730
+ limit: b.limit_usd,
731
+ percent: Math.round(b.percent_used * 10) / 10
732
+ });
733
+ setIngestState(db, "webhook", key, String(pctBucket));
734
+ }
735
+ }
736
+ async function fireWebhook(url, payload) {
737
+ try {
738
+ await fetch(url, {
739
+ method: "POST",
740
+ headers: { "Content-Type": "application/json" },
741
+ body: JSON.stringify(payload),
742
+ signal: AbortSignal.timeout(5000)
743
+ });
744
+ } catch {}
745
+ }
746
+ var init_webhooks = __esm(() => {
747
+ init_config();
748
+ init_database();
749
+ });
750
+
642
751
  // src/cli/commands/watch.ts
643
752
  var exports_watch = {};
644
753
  __export(exports_watch, {
@@ -867,15 +976,15 @@ function startServer(port = 3456) {
867
976
  return apiHandler(req);
868
977
  }
869
978
  try {
870
- const { existsSync: existsSync4 } = await import("fs");
871
- if (existsSync4(dashboardDir)) {
979
+ const { existsSync: existsSync5 } = await import("fs");
980
+ if (existsSync5(dashboardDir)) {
872
981
  let filePath = url.pathname === "/" ? "/index.html" : url.pathname;
873
982
  const fullPath = dashboardDir + filePath;
874
- if (existsSync4(fullPath)) {
983
+ if (existsSync5(fullPath)) {
875
984
  return new Response(Bun.file(fullPath));
876
985
  }
877
986
  const indexPath = dashboardDir + "/index.html";
878
- if (existsSync4(indexPath)) {
987
+ if (existsSync5(indexPath)) {
879
988
  return new Response(Bun.file(indexPath));
880
989
  }
881
990
  }
@@ -917,7 +1026,22 @@ import chalk2 from "chalk";
917
1026
  import { randomUUID as randomUUID2 } from "crypto";
918
1027
  import { execSync } from "child_process";
919
1028
  var program = new Command;
920
- program.name("economy").description("AI coding cost tracker \u2014 Claude Code, Codex, and Gemini").version("0.1.0");
1029
+ program.name("economy").description("AI coding cost tracker \u2014 Claude Code, Codex, and Gemini").version("0.1.1");
1030
+ async function autoSync() {
1031
+ const db = openDatabase();
1032
+ ensurePricingSeeded(db);
1033
+ await ingestClaude(db);
1034
+ await ingestCodex(db);
1035
+ }
1036
+ function sparkline(values) {
1037
+ const chars = "\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588";
1038
+ if (values.length === 0)
1039
+ return "";
1040
+ const max = Math.max(...values);
1041
+ if (max === 0)
1042
+ return chars[0].repeat(values.length);
1043
+ return values.map((v) => chars[Math.min(Math.round(v / max * 7), 7)]).join("");
1044
+ }
921
1045
  function fmt2(usd) {
922
1046
  let formatted;
923
1047
  if (usd >= 0.01) {
@@ -970,9 +1094,47 @@ function printSummary(label, period) {
970
1094
  ]);
971
1095
  console.log();
972
1096
  }
973
- program.command("sync").description("Ingest cost data from Claude Code and Codex").option("--claude", "Only ingest Claude Code telemetry").option("--codex", "Only ingest Codex sessions").option("-v, --verbose", "Verbose output").action(async (opts) => {
1097
+ program.action(async () => {
1098
+ await autoSync();
1099
+ const db = openDatabase();
1100
+ const t = querySummary(db, "today");
1101
+ const w = querySummary(db, "week");
1102
+ const m = querySummary(db, "month");
1103
+ const projects = queryProjectBreakdown(db).slice(0, 3);
1104
+ const daily = queryDailyBreakdown(db, 14).reduce((acc, d) => {
1105
+ acc[d.date] = (acc[d.date] ?? 0) + d.cost_usd;
1106
+ return acc;
1107
+ }, {});
1108
+ const dailyValues = Object.values(daily);
1109
+ console.log();
1110
+ console.log(chalk2.bold.cyan(" Economy"));
1111
+ console.log();
1112
+ printTable(["Period", "Cost", "Sessions", "Requests", "Tokens"], [
1113
+ ["Today", fmt2(t.total_usd), fmtCount(t.sessions), fmtCount(t.requests), fmtTokens(t.tokens)],
1114
+ ["This Week", fmt2(w.total_usd), fmtCount(w.sessions), fmtCount(w.requests), fmtTokens(w.tokens)],
1115
+ ["This Month", fmt2(m.total_usd), fmtCount(m.sessions), fmtCount(m.requests), fmtTokens(m.tokens)]
1116
+ ]);
1117
+ if (dailyValues.length > 0) {
1118
+ console.log(`
1119
+ ${chalk2.dim("14-day trend:")} ${sparkline(dailyValues)}`);
1120
+ }
1121
+ if (projects.length > 0) {
1122
+ console.log(`
1123
+ ${chalk2.dim("Top projects:")}`);
1124
+ for (const p of projects) {
1125
+ console.log(` ${chalk2.white(p.project_name.padEnd(25))} ${fmt2(p.cost_usd)}`);
1126
+ }
1127
+ }
1128
+ console.log();
1129
+ });
1130
+ program.command("sync").description("Ingest cost data from Claude Code and Codex").option("--claude", "Only ingest Claude Code telemetry").option("--codex", "Only ingest Codex sessions").option("-v, --verbose", "Verbose output").option("--force", "Force re-process all files (ignore mtime cache)").action(async (opts) => {
974
1131
  const db = openDatabase();
975
1132
  ensurePricingSeeded(db);
1133
+ if (opts.force) {
1134
+ db.exec(`DELETE FROM ingest_state WHERE source = 'claude'`);
1135
+ if (opts.verbose)
1136
+ console.log(chalk2.dim("Cleared ingest cache"));
1137
+ }
976
1138
  const doClaude = opts.claude || !opts.claude && !opts.codex;
977
1139
  const doCodex = opts.codex || !opts.claude && !opts.codex;
978
1140
  if (doClaude) {
@@ -985,13 +1147,27 @@ program.command("sync").description("Ingest cost data from Claude Code and Codex
985
1147
  const r = await ingestCodex(db, opts.verbose);
986
1148
  console.log(chalk2.green(`\u2713 ${r.sessions} sessions`));
987
1149
  }
1150
+ try {
1151
+ const { checkAndFireWebhooks: checkAndFireWebhooks2 } = await Promise.resolve().then(() => (init_webhooks(), exports_webhooks));
1152
+ await checkAndFireWebhooks2(db);
1153
+ } catch {}
988
1154
  console.log(chalk2.bold.green(`
989
1155
  \u2713 Sync complete`));
990
1156
  });
991
- program.command("today").description("Cost summary for today").action(() => printSummary("Today", "today"));
992
- program.command("week").description("Cost summary for this week").action(() => printSummary("This Week", "week"));
993
- program.command("month").description("Cost summary for this month").action(() => printSummary("This Month", "month"));
994
- program.command("sessions").description("List coding sessions with costs").option("--agent <agent>", "Filter by agent (claude|codex)").option("--project <path>", "Filter by project path").option("--limit <n>", "Number of sessions", "20").action((opts) => {
1157
+ program.command("today").description("Cost summary for today").action(async () => {
1158
+ await autoSync();
1159
+ printSummary("Today", "today");
1160
+ });
1161
+ program.command("week").description("Cost summary for this week").action(async () => {
1162
+ await autoSync();
1163
+ printSummary("This Week", "week");
1164
+ });
1165
+ program.command("month").description("Cost summary for this month").action(async () => {
1166
+ await autoSync();
1167
+ printSummary("This Month", "month");
1168
+ });
1169
+ program.command("sessions").description("List coding sessions with costs").option("--agent <agent>", "Filter by agent (claude|codex)").option("--project <path>", "Filter by project path").option("--limit <n>", "Number of sessions", "20").action(async (opts) => {
1170
+ await autoSync();
995
1171
  const db = openDatabase();
996
1172
  const sessions = querySessions(db, {
997
1173
  agent: opts.agent,
@@ -1154,6 +1330,79 @@ projectCmd.command("rename <path> <name>").description("Rename a project").actio
1154
1330
  upsertProject(db, { ...existing, name });
1155
1331
  console.log(chalk2.green(`\u2713 Renamed to: ${name}`));
1156
1332
  });
1333
+ projectCmd.command("show <nameOrPath>").description("Detailed project breakdown with sparkline").action(async (nameOrPath) => {
1334
+ await autoSync();
1335
+ const db = openDatabase();
1336
+ const sessions = db.prepare(`SELECT * FROM sessions WHERE project_name LIKE ? OR project_path LIKE ? ORDER BY started_at DESC`).all(`%${nameOrPath}%`, `%${nameOrPath}%`);
1337
+ if (sessions.length === 0) {
1338
+ console.log(chalk2.yellow(`No sessions found for: ${nameOrPath}`));
1339
+ return;
1340
+ }
1341
+ const projectName = sessions[0]["project_name"] || nameOrPath;
1342
+ const projectPath = sessions[0]["project_path"] || "";
1343
+ const totalCost = sessions.reduce((s, r) => s + r["total_cost_usd"], 0);
1344
+ const totalTokens = sessions.reduce((s, r) => s + r["total_tokens"], 0);
1345
+ const daily = db.prepare(`
1346
+ SELECT DATE(r.timestamp) as d, SUM(r.cost_usd) as cost
1347
+ FROM requests r JOIN sessions s ON r.session_id = s.id
1348
+ WHERE (s.project_name LIKE ? OR s.project_path LIKE ?)
1349
+ AND r.timestamp >= DATE('now', '-14 days')
1350
+ GROUP BY d ORDER BY d ASC
1351
+ `).all(`%${nameOrPath}%`, `%${nameOrPath}%`);
1352
+ const dailyValues = daily.map((d) => d.cost);
1353
+ const models = db.prepare(`
1354
+ SELECT r.model, COUNT(*) as reqs, SUM(r.cost_usd) as cost
1355
+ FROM requests r JOIN sessions s ON r.session_id = s.id
1356
+ WHERE s.project_name LIKE ? OR s.project_path LIKE ?
1357
+ GROUP BY r.model ORDER BY cost DESC LIMIT 5
1358
+ `).all(`%${nameOrPath}%`, `%${nameOrPath}%`);
1359
+ console.log();
1360
+ console.log(chalk2.bold.cyan(` ${projectName}`));
1361
+ console.log(chalk2.dim(` ${projectPath}`));
1362
+ console.log();
1363
+ printTable(["Metric", "Value"], [
1364
+ ["Total cost", fmt2(totalCost)],
1365
+ ["Sessions", fmtCount(sessions.length)],
1366
+ ["Total tokens", fmtTokens(totalTokens)]
1367
+ ]);
1368
+ if (dailyValues.length > 0) {
1369
+ console.log(`
1370
+ ${chalk2.dim("14-day trend:")} ${sparkline(dailyValues)}`);
1371
+ }
1372
+ if (models.length > 0) {
1373
+ console.log(`
1374
+ ${chalk2.dim("Model breakdown:")}`);
1375
+ for (const m of models) {
1376
+ console.log(` ${chalk2.white(m.model.padEnd(30))} ${fmt2(m.cost)} (${fmtCount(m.reqs)} reqs)`);
1377
+ }
1378
+ }
1379
+ const topSessions = sessions.sort((a, b) => b["total_cost_usd"] - a["total_cost_usd"]).slice(0, 5);
1380
+ if (topSessions.length > 0) {
1381
+ console.log(`
1382
+ ${chalk2.dim("Top sessions:")}`);
1383
+ for (const s of topSessions) {
1384
+ console.log(` ${chalk2.dim(s["id"].substring(0, 12))} ${fmt2(s["total_cost_usd"])} ${chalk2.dim(String(s["started_at"]).substring(0, 16))}`);
1385
+ }
1386
+ }
1387
+ console.log();
1388
+ });
1389
+ var configCmd = program.command("config").description("Manage economy configuration");
1390
+ configCmd.command("set <key> <value>").description("Set a config value").action(async (_key, _value) => {
1391
+ const { setConfigValue: setConfigValue2 } = await Promise.resolve().then(() => (init_config(), exports_config));
1392
+ setConfigValue2(_key, _value);
1393
+ console.log(chalk2.green(`\u2713 ${_key} = ${_value}`));
1394
+ });
1395
+ configCmd.command("get <key>").description("Get a config value").action(async (key) => {
1396
+ const { getConfigValue: getConfigValue2 } = await Promise.resolve().then(() => (init_config(), exports_config));
1397
+ console.log(getConfigValue2(key) ?? chalk2.dim("(not set)"));
1398
+ });
1399
+ configCmd.action(async () => {
1400
+ const { loadConfig: loadConfig2 } = await Promise.resolve().then(() => (init_config(), exports_config));
1401
+ const config = loadConfig2();
1402
+ console.log();
1403
+ printTable(["Key", "Value"], Object.entries(config).map(([k, v]) => [k, String(v)]));
1404
+ console.log();
1405
+ });
1157
1406
  var pricingCmd = program.command("pricing").description("Manage model pricing rates");
1158
1407
  pricingCmd.command("list").description("List all model prices").action(() => {
1159
1408
  const db = openDatabase();
@@ -1256,4 +1505,197 @@ Codex (~/.codex/config.toml):`));
1256
1505
  }
1257
1506
  console.log();
1258
1507
  });
1508
+ program.command("session <id>").description("Show detailed breakdown of a single session").action(async (id) => {
1509
+ await autoSync();
1510
+ const db = openDatabase();
1511
+ const session = db.prepare(`SELECT * FROM sessions WHERE id = ? OR id LIKE ?`).get(id, `%${id}%`);
1512
+ if (!session) {
1513
+ console.log(chalk2.red(`Session not found: ${id}`));
1514
+ process.exit(1);
1515
+ }
1516
+ const requests = db.prepare(`SELECT * FROM requests WHERE session_id = ? ORDER BY timestamp ASC`).all(session["id"]);
1517
+ console.log();
1518
+ console.log(chalk2.bold.cyan(` Session: ${session["id"].substring(0, 16)}...`));
1519
+ console.log();
1520
+ printTable(["Field", "Value"], [
1521
+ ["Agent", String(session["agent"])],
1522
+ ["Project", String(session["project_name"] || session["project_path"] || "\u2014")],
1523
+ ["Started", String(session["started_at"]).substring(0, 19)],
1524
+ ["Ended", session["ended_at"] ? String(session["ended_at"]).substring(0, 19) : "\u2014"],
1525
+ ["Total cost", fmt2(session["total_cost_usd"])],
1526
+ ["Total tokens", fmtTokens(session["total_tokens"])],
1527
+ ["Requests", fmtCount(session["request_count"])]
1528
+ ]);
1529
+ if (requests.length > 0) {
1530
+ console.log(chalk2.dim(`
1531
+ Requests (${requests.length}):
1532
+ `));
1533
+ printTable(["Time", "Model", "Input", "Output", "Cache R", "Cache W", "Cost"], requests.slice(0, 50).map((r) => [
1534
+ chalk2.dim(String(r["timestamp"]).substring(11, 19)),
1535
+ chalk2.white(String(r["model"]).substring(0, 22)),
1536
+ fmtTokens(r["input_tokens"]),
1537
+ fmtTokens(r["output_tokens"]),
1538
+ fmtTokens(r["cache_read_tokens"]),
1539
+ fmtTokens(r["cache_create_tokens"]),
1540
+ fmt2(r["cost_usd"])
1541
+ ]));
1542
+ if (requests.length > 50)
1543
+ console.log(chalk2.dim(` ... and ${requests.length - 50} more requests`));
1544
+ }
1545
+ console.log();
1546
+ });
1547
+ program.command("export").description("Export data as CSV").option("--type <type>", "Data type: sessions or requests", "sessions").option("--period <period>", "Period: today|week|month|all", "month").option("--output <file>", "Output file path (default: stdout)").action(async (opts) => {
1548
+ await autoSync();
1549
+ const db = openDatabase();
1550
+ let csv;
1551
+ if (opts.type === "requests") {
1552
+ const where = opts.period === "today" ? `DATE(timestamp) = DATE('now')` : opts.period === "week" ? `timestamp >= DATE('now', '-7 days')` : opts.period === "all" ? "1=1" : `timestamp >= DATE('now', '-30 days')`;
1553
+ const rows = db.prepare(`SELECT * FROM requests WHERE ${where} ORDER BY timestamp ASC`).all();
1554
+ csv = `id,agent,session_id,model,input_tokens,output_tokens,cache_read_tokens,cache_create_tokens,cost_usd,duration_ms,timestamp
1555
+ `;
1556
+ for (const r of rows) {
1557
+ csv += `${r["id"]},${r["agent"]},${r["session_id"]},${r["model"]},${r["input_tokens"]},${r["output_tokens"]},${r["cache_read_tokens"]},${r["cache_create_tokens"]},${r["cost_usd"]},${r["duration_ms"]},${r["timestamp"]}
1558
+ `;
1559
+ }
1560
+ } else {
1561
+ const where = opts.period === "today" ? `DATE(started_at) = DATE('now')` : opts.period === "week" ? `started_at >= DATE('now', '-7 days')` : opts.period === "all" ? "1=1" : `started_at >= DATE('now', '-30 days')`;
1562
+ const rows = db.prepare(`SELECT * FROM sessions WHERE ${where} ORDER BY started_at DESC`).all();
1563
+ csv = `id,agent,project_path,project_name,started_at,ended_at,total_cost_usd,total_tokens,request_count
1564
+ `;
1565
+ for (const r of rows) {
1566
+ csv += `${r["id"]},${r["agent"]},"${r["project_path"]}","${r["project_name"]}",${r["started_at"]},${r["ended_at"] ?? ""},${r["total_cost_usd"]},${r["total_tokens"]},${r["request_count"]}
1567
+ `;
1568
+ }
1569
+ }
1570
+ if (opts.output) {
1571
+ const { writeFileSync: writeFileSync2 } = await import("fs");
1572
+ writeFileSync2(opts.output, csv);
1573
+ console.log(chalk2.green(`\u2713 Exported to ${opts.output}`));
1574
+ } else {
1575
+ process.stdout.write(csv);
1576
+ }
1577
+ });
1578
+ program.command("compare <period1> <period2>").description("Compare two periods (today/yesterday/week/lastweek/month/lastmonth)").action(async (p1, p2) => {
1579
+ await autoSync();
1580
+ const db = openDatabase();
1581
+ function dateRange(period) {
1582
+ const now = new Date;
1583
+ const today = now.toISOString().substring(0, 10);
1584
+ switch (period) {
1585
+ case "today":
1586
+ return [today, today];
1587
+ case "yesterday": {
1588
+ const d = new Date(now);
1589
+ d.setDate(d.getDate() - 1);
1590
+ const s = d.toISOString().substring(0, 10);
1591
+ return [s, s];
1592
+ }
1593
+ case "week": {
1594
+ const d = new Date(now);
1595
+ d.setDate(d.getDate() - 7);
1596
+ return [d.toISOString().substring(0, 10), today];
1597
+ }
1598
+ case "lastweek": {
1599
+ const d1 = new Date(now);
1600
+ d1.setDate(d1.getDate() - 14);
1601
+ const d2 = new Date(now);
1602
+ d2.setDate(d2.getDate() - 7);
1603
+ return [d1.toISOString().substring(0, 10), d2.toISOString().substring(0, 10)];
1604
+ }
1605
+ case "month": {
1606
+ const d = new Date(now);
1607
+ d.setDate(d.getDate() - 30);
1608
+ return [d.toISOString().substring(0, 10), today];
1609
+ }
1610
+ case "lastmonth": {
1611
+ const d1 = new Date(now);
1612
+ d1.setDate(d1.getDate() - 60);
1613
+ const d2 = new Date(now);
1614
+ d2.setDate(d2.getDate() - 30);
1615
+ return [d1.toISOString().substring(0, 10), d2.toISOString().substring(0, 10)];
1616
+ }
1617
+ default:
1618
+ return [today, today];
1619
+ }
1620
+ }
1621
+ function queryRange(from, to) {
1622
+ const r = db.prepare(`SELECT COALESCE(SUM(cost_usd),0) as cost, COUNT(*) as requests, COALESCE(SUM(input_tokens+output_tokens+cache_read_tokens+cache_create_tokens),0) as tokens FROM requests WHERE DATE(timestamp) BETWEEN ? AND ?`).get(from, to);
1623
+ const s = db.prepare(`SELECT COUNT(*) as sessions FROM sessions WHERE DATE(started_at) BETWEEN ? AND ?`).get(from, to);
1624
+ return { ...r, sessions: s.sessions };
1625
+ }
1626
+ const [f1, t1] = dateRange(p1);
1627
+ const [f2, t2] = dateRange(p2);
1628
+ const a = queryRange(f1, t1);
1629
+ const b = queryRange(f2, t2);
1630
+ function delta(v1, v2) {
1631
+ const d = v1 - v2;
1632
+ const pct = v2 > 0 ? (d / v2 * 100).toFixed(1) : "\u2014";
1633
+ const sign = d >= 0 ? "+" : "";
1634
+ const color = d > 0 ? chalk2.red : d < 0 ? chalk2.green : chalk2.dim;
1635
+ return color(`${sign}${pct}%`);
1636
+ }
1637
+ console.log();
1638
+ console.log(chalk2.bold.cyan(` ${p1} vs ${p2}`));
1639
+ console.log();
1640
+ printTable(["Metric", p1, p2, "Change"], [
1641
+ ["Cost", fmt2(a.cost), fmt2(b.cost), delta(a.cost, b.cost)],
1642
+ ["Sessions", fmtCount(a.sessions), fmtCount(b.sessions), delta(a.sessions, b.sessions)],
1643
+ ["Requests", fmtCount(a.requests), fmtCount(b.requests), delta(a.requests, b.requests)],
1644
+ ["Tokens", fmtTokens(a.tokens), fmtTokens(b.tokens), delta(a.tokens, b.tokens)]
1645
+ ]);
1646
+ console.log();
1647
+ });
1648
+ program.command("forecast").description("Project end-of-month cost based on current burn rate").action(async () => {
1649
+ await autoSync();
1650
+ const db = openDatabase();
1651
+ const now = new Date;
1652
+ const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
1653
+ const dayOfMonth = now.getDate();
1654
+ const monthStart = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
1655
+ const today = now.toISOString().substring(0, 10);
1656
+ const monthSoFar = db.prepare(`SELECT COALESCE(SUM(cost_usd),0) as cost FROM requests WHERE DATE(timestamp) >= ?`).get(monthStart);
1657
+ const dailyAvg = dayOfMonth > 0 ? monthSoFar.cost / dayOfMonth : 0;
1658
+ const projected = dailyAvg * daysInMonth;
1659
+ const sevenDaysAgo = new Date(now);
1660
+ sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
1661
+ const last7 = db.prepare(`SELECT COALESCE(SUM(cost_usd),0) as cost FROM requests WHERE DATE(timestamp) >= ?`).get(sevenDaysAgo.toISOString().substring(0, 10));
1662
+ const last7DailyAvg = last7.cost / 7;
1663
+ const last7Projected = last7DailyAvg * daysInMonth;
1664
+ const dailyCosts = db.prepare(`SELECT DATE(timestamp) as d, SUM(cost_usd) as cost FROM requests WHERE DATE(timestamp) >= ? GROUP BY d ORDER BY cost ASC`).all(monthStart);
1665
+ const cheapest = dailyCosts[0];
1666
+ const mostExpensive = dailyCosts[dailyCosts.length - 1];
1667
+ console.log();
1668
+ console.log(chalk2.bold.cyan(` Forecast (${dayOfMonth} of ${daysInMonth} days)`));
1669
+ console.log();
1670
+ printTable(["Metric", "Value"], [
1671
+ ["Spent so far", fmt2(monthSoFar.cost)],
1672
+ ["Daily average", fmt2(dailyAvg)],
1673
+ [chalk2.bold("Projected total"), chalk2.bold(fmt2(projected).replace(chalk2.green(""), ""))],
1674
+ ["Last 7-day rate", `${fmt2(last7DailyAvg)}/day \u2192 ${fmt2(last7Projected)}`],
1675
+ ["Cheapest day", cheapest ? `${fmt2(cheapest.cost)} (${cheapest.d})` : "\u2014"],
1676
+ ["Most expensive", mostExpensive ? `${fmt2(mostExpensive.cost)} (${mostExpensive.d})` : "\u2014"]
1677
+ ]);
1678
+ console.log();
1679
+ });
1680
+ program.command("efficiency").description("Show output/input token ratio per model").action(async () => {
1681
+ await autoSync();
1682
+ const db = openDatabase();
1683
+ const models = db.prepare(`
1684
+ SELECT model, SUM(input_tokens) as input, SUM(output_tokens) as output,
1685
+ SUM(cache_read_tokens) as cache_read, SUM(cache_create_tokens) as cache_write,
1686
+ COUNT(*) as requests, SUM(cost_usd) as cost
1687
+ FROM requests GROUP BY model ORDER BY cost DESC
1688
+ `).all();
1689
+ console.log();
1690
+ console.log(chalk2.bold.cyan(" Token Efficiency"));
1691
+ console.log();
1692
+ printTable(["Model", "Output/Input", "Cache Hit%", "Cost/1k Output", "Requests"], models.map((m) => {
1693
+ const ratio = m.input > 0 ? (m.output / m.input).toFixed(2) : "\u2014";
1694
+ const totalInput = m.input + m.cache_read + m.cache_write;
1695
+ const cacheHit = totalInput > 0 ? (m.cache_read / totalInput * 100).toFixed(1) + "%" : "\u2014";
1696
+ const costPer1kOutput = m.output > 0 ? fmt2(m.cost / m.output * 1000) : "\u2014";
1697
+ return [chalk2.white(m.model), ratio, cacheHit, costPer1kOutput, fmtCount(m.requests)];
1698
+ }));
1699
+ console.log();
1700
+ });
1259
1701
  program.parse();
package/dist/mcp/index.js CHANGED
@@ -666,7 +666,10 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
666
666
  case "get_cost_summary": {
667
667
  const period = a["period"] ?? "today";
668
668
  const summary = querySummary(db, period);
669
- return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] };
669
+ const fmtUsd = (n) => "$" + n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
670
+ const fmtTok = (n) => n >= 1e9 ? `${(n / 1e9).toFixed(1)}B` : n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
671
+ const result = { ...summary, summary: `You've spent ${fmtUsd(summary.total_usd)} ${period === "all" ? "total" : period} across ${summary.sessions} sessions (${summary.requests.toLocaleString()} requests, ${fmtTok(summary.tokens)} tokens)` };
672
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
670
673
  }
671
674
  case "get_sessions": {
672
675
  const sessions = querySessions(db, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/economy",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "AI coding cost tracker — CLI + MCP server + REST API + web dashboard for Claude Code, Codex, and Gemini",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",