@hasna/economy 0.2.26 → 0.2.28

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.
@@ -566,6 +566,26 @@ function openDatabase(dbPath, skipSeed = false) {
566
566
  }
567
567
  return db;
568
568
  }
569
+ function quoteSqlIdent(identifier) {
570
+ return `"${identifier.replace(/"/g, '""')}"`;
571
+ }
572
+ function hasColumn(db, table, column) {
573
+ const columns = db.prepare(`PRAGMA table_info(${quoteSqlIdent(table)})`).all();
574
+ return columns.some((c) => c.name === column);
575
+ }
576
+ function addColumnIfMissing(db, table, column, definition) {
577
+ if (hasColumn(db, table, column))
578
+ return false;
579
+ try {
580
+ db.exec(`ALTER TABLE ${quoteSqlIdent(table)} ADD COLUMN ${quoteSqlIdent(column)} ${definition}`);
581
+ return true;
582
+ } catch (error) {
583
+ const message = error instanceof Error ? error.message : String(error);
584
+ if (/duplicate column name/i.test(message))
585
+ return true;
586
+ throw error;
587
+ }
588
+ }
569
589
  function initSchema(db) {
570
590
  db.exec(`
571
591
  CREATE TABLE IF NOT EXISTS requests (
@@ -736,59 +756,31 @@ function initSchema(db) {
736
756
  CREATE INDEX IF NOT EXISTS idx_usage_agent_date ON usage_snapshots(agent, date);
737
757
  CREATE INDEX IF NOT EXISTS idx_savings_date ON savings_daily(date);
738
758
  `);
739
- const cols = db.prepare(`PRAGMA table_info(requests)`).all();
740
- if (!cols.some((c) => c.name === "machine_id")) {
741
- db.exec(`ALTER TABLE requests ADD COLUMN machine_id TEXT DEFAULT ''`);
742
- db.exec(`ALTER TABLE sessions ADD COLUMN machine_id TEXT DEFAULT ''`);
743
- }
744
- if (!cols.some((c) => c.name === "cache_create_5m_tokens")) {
745
- db.exec(`ALTER TABLE requests ADD COLUMN cache_create_5m_tokens INTEGER DEFAULT 0`);
759
+ addColumnIfMissing(db, "requests", "machine_id", `TEXT DEFAULT ''`);
760
+ addColumnIfMissing(db, "sessions", "machine_id", `TEXT DEFAULT ''`);
761
+ if (addColumnIfMissing(db, "requests", "cache_create_5m_tokens", "INTEGER DEFAULT 0")) {
746
762
  db.exec(`UPDATE requests SET cache_create_5m_tokens = cache_create_tokens WHERE cache_create_5m_tokens = 0`);
747
763
  }
748
- if (!cols.some((c) => c.name === "cache_create_1h_tokens")) {
749
- db.exec(`ALTER TABLE requests ADD COLUMN cache_create_1h_tokens INTEGER DEFAULT 0`);
750
- }
751
- if (!cols.some((c) => c.name === "cost_basis")) {
752
- db.exec(`ALTER TABLE requests ADD COLUMN cost_basis TEXT DEFAULT 'estimated'`);
753
- }
754
- if (!cols.some((c) => c.name === "attribution_tag")) {
755
- db.exec(`ALTER TABLE requests ADD COLUMN attribution_tag TEXT DEFAULT ''`);
756
- }
757
- if (!cols.some((c) => c.name === "updated_at")) {
758
- db.exec(`ALTER TABLE requests ADD COLUMN updated_at TEXT DEFAULT ''`);
764
+ addColumnIfMissing(db, "requests", "cache_create_1h_tokens", "INTEGER DEFAULT 0");
765
+ addColumnIfMissing(db, "requests", "cost_basis", `TEXT DEFAULT 'estimated'`);
766
+ addColumnIfMissing(db, "requests", "attribution_tag", `TEXT DEFAULT ''`);
767
+ if (addColumnIfMissing(db, "requests", "updated_at", `TEXT DEFAULT ''`)) {
759
768
  db.exec(`UPDATE requests SET updated_at = timestamp WHERE updated_at = '' OR updated_at IS NULL`);
760
769
  }
761
- if (!cols.some((c) => c.name === "synced_at")) {
762
- db.exec(`ALTER TABLE requests ADD COLUMN synced_at TEXT DEFAULT ''`);
763
- }
770
+ addColumnIfMissing(db, "requests", "synced_at", `TEXT DEFAULT ''`);
764
771
  for (const column of ["account_key", "account_tool", "account_name", "account_email", "account_source"]) {
765
- if (!cols.some((c) => c.name === column)) {
766
- db.exec(`ALTER TABLE requests ADD COLUMN ${column} TEXT DEFAULT ''`);
767
- }
768
- }
769
- const sessionCols = db.prepare(`PRAGMA table_info(sessions)`).all();
770
- if (!sessionCols.some((c) => c.name === "attribution_tag")) {
771
- db.exec(`ALTER TABLE sessions ADD COLUMN attribution_tag TEXT DEFAULT ''`);
772
+ addColumnIfMissing(db, "requests", column, `TEXT DEFAULT ''`);
772
773
  }
773
- if (!sessionCols.some((c) => c.name === "updated_at")) {
774
- db.exec(`ALTER TABLE sessions ADD COLUMN updated_at TEXT DEFAULT ''`);
774
+ addColumnIfMissing(db, "sessions", "attribution_tag", `TEXT DEFAULT ''`);
775
+ if (addColumnIfMissing(db, "sessions", "updated_at", `TEXT DEFAULT ''`)) {
775
776
  db.exec(`UPDATE sessions SET updated_at = started_at WHERE updated_at = '' OR updated_at IS NULL`);
776
777
  }
777
- if (!sessionCols.some((c) => c.name === "synced_at")) {
778
- db.exec(`ALTER TABLE sessions ADD COLUMN synced_at TEXT DEFAULT ''`);
779
- }
778
+ addColumnIfMissing(db, "sessions", "synced_at", `TEXT DEFAULT ''`);
780
779
  for (const column of ["account_key", "account_tool", "account_name", "account_email", "account_source"]) {
781
- if (!sessionCols.some((c) => c.name === column)) {
782
- db.exec(`ALTER TABLE sessions ADD COLUMN ${column} TEXT DEFAULT ''`);
783
- }
784
- }
785
- const pricingCols = db.prepare(`PRAGMA table_info(model_pricing)`).all();
786
- if (!pricingCols.some((c) => c.name === "cache_write_1h_per_1m")) {
787
- db.exec(`ALTER TABLE model_pricing ADD COLUMN cache_write_1h_per_1m REAL NOT NULL DEFAULT 0`);
788
- }
789
- if (!pricingCols.some((c) => c.name === "cache_storage_per_1m_hour")) {
790
- db.exec(`ALTER TABLE model_pricing ADD COLUMN cache_storage_per_1m_hour REAL NOT NULL DEFAULT 0`);
780
+ addColumnIfMissing(db, "sessions", column, `TEXT DEFAULT ''`);
791
781
  }
782
+ addColumnIfMissing(db, "model_pricing", "cache_write_1h_per_1m", "REAL NOT NULL DEFAULT 0");
783
+ addColumnIfMissing(db, "model_pricing", "cache_storage_per_1m_hour", "REAL NOT NULL DEFAULT 0");
792
784
  db.exec(`
793
785
  CREATE INDEX IF NOT EXISTS idx_requests_machine ON requests(machine_id);
794
786
  CREATE INDEX IF NOT EXISTS idx_sessions_machine ON sessions(machine_id);
@@ -949,17 +941,22 @@ function querySummary(db, period, machine, allMachines = false) {
949
941
  const codexTotals = db.prepare(`
950
942
  SELECT COALESCE(SUM(total_cost_usd), 0) as cost_usd,
951
943
  COALESCE(SUM(total_tokens), 0) as tokens,
944
+ COALESCE(SUM(request_count), 0) as requests,
952
945
  COUNT(*) as sessions
953
946
  FROM sessions
954
947
  WHERE ${sWhere}${machineClause}
955
948
  AND id NOT IN (SELECT DISTINCT session_id FROM requests)
956
949
  `).get();
957
- const sessionCount = db.prepare(`SELECT COUNT(*) as sessions FROM sessions WHERE ${sWhere}${machineClause}`).get();
950
+ const requestSessionCount = db.prepare(`
951
+ SELECT COUNT(DISTINCT session_id) as sessions
952
+ FROM requests
953
+ WHERE ${rWhere}${machineClause}
954
+ `).get();
958
955
  return {
959
956
  total_usd: r.total_usd + codexTotals.cost_usd,
960
- requests: r.requests,
957
+ requests: r.requests + codexTotals.requests,
961
958
  tokens: r.tokens + codexTotals.tokens,
962
- sessions: sessionCount.sessions,
959
+ sessions: requestSessionCount.sessions + codexTotals.sessions,
963
960
  period
964
961
  };
965
962
  }
@@ -1038,9 +1035,7 @@ function queryAgentBreakdown(db, period = "all") {
1038
1035
  }
1039
1036
  return [...groups.values()].sort((a, b) => b.api_equivalent_usd - a.api_equivalent_usd);
1040
1037
  }
1041
- function labelForPath(projectPath, projectName) {
1042
- if (projectName && projectName.trim() !== "")
1043
- return projectName;
1038
+ function pathProjectLabel(projectPath) {
1044
1039
  if (!projectPath)
1045
1040
  return "";
1046
1041
  const segments = projectPath.split("/").filter(Boolean);
@@ -1049,12 +1044,45 @@ function labelForPath(projectPath, projectName) {
1049
1044
  if (projectPrefix.test(seg))
1050
1045
  return seg;
1051
1046
  }
1052
- const generic = new Set(["web", "app", "apps", "packages", "src", "lib", "server", "client", "api", "frontend", "backend"]);
1047
+ const generic = new Set([
1048
+ "web",
1049
+ "app",
1050
+ "apps",
1051
+ "packages",
1052
+ "src",
1053
+ "lib",
1054
+ "server",
1055
+ "client",
1056
+ "api",
1057
+ "frontend",
1058
+ "backend",
1059
+ "home",
1060
+ "users",
1061
+ "workspace",
1062
+ "workspaces",
1063
+ "hasna"
1064
+ ]);
1053
1065
  for (let i = segments.length - 1;i >= 0; i--) {
1054
1066
  if (!generic.has(segments[i].toLowerCase()))
1055
1067
  return segments[i];
1056
1068
  }
1057
- return segments[segments.length - 1] ?? projectPath;
1069
+ return null;
1070
+ }
1071
+ function isRepoLikeLabel(label) {
1072
+ return /^(open|skill|hook|service|connect|platform|agent|tool|iapp|project|scaffold|capp)-/.test(label) || label.includes("-");
1073
+ }
1074
+ function labelForPath(projectPath, projectName) {
1075
+ const pathLabel = pathProjectLabel(projectPath);
1076
+ if (pathLabel && (!projectName || projectName.trim() === "" || isRepoLikeLabel(pathLabel)))
1077
+ return pathLabel;
1078
+ if (projectName && projectName.trim() !== "")
1079
+ return projectName;
1080
+ if (pathLabel)
1081
+ return pathLabel;
1082
+ return projectPath;
1083
+ }
1084
+ function groupKeyForPath(projectPath, projectName) {
1085
+ return labelForPath(projectPath, projectName).trim().toLowerCase();
1058
1086
  }
1059
1087
  function queryProjectBreakdown(db, period = "all") {
1060
1088
  const requestWhere = requestPeriodWhere(period);
@@ -1069,14 +1097,15 @@ function queryProjectBreakdown(db, period = "all") {
1069
1097
  const label = labelForPath(s.project_path, s.project_name);
1070
1098
  if (!label)
1071
1099
  continue;
1072
- const g = groups.get(label) ?? { sessionIds: [], samplePath: s.project_path };
1100
+ const key = groupKeyForPath(s.project_path, s.project_name);
1101
+ const g = groups.get(key) ?? { label, sessionIds: [], samplePath: s.project_path };
1073
1102
  g.sessionIds.push(s.id);
1074
1103
  if (!g.samplePath)
1075
1104
  g.samplePath = s.project_path;
1076
- groups.set(label, g);
1105
+ groups.set(key, g);
1077
1106
  }
1078
1107
  const result = [];
1079
- for (const [label, g] of groups.entries()) {
1108
+ for (const g of groups.values()) {
1080
1109
  const placeholders = g.sessionIds.map(() => "?").join(",");
1081
1110
  const reqStats = placeholders.length ? db.prepare(`
1082
1111
  SELECT
@@ -1107,7 +1136,7 @@ function queryProjectBreakdown(db, period = "all") {
1107
1136
  const lastActive = [reqStats.last_active, sessionOnlyStats.last_active].filter(Boolean).sort().at(-1) ?? "";
1108
1137
  result.push({
1109
1138
  project_path: g.samplePath,
1110
- project_name: label,
1139
+ project_name: g.label,
1111
1140
  sessions: totalSessions,
1112
1141
  requests: reqStats.requests + sessionOnlyStats.requests,
1113
1142
  total_tokens: reqStats.total_tokens + sessionOnlyStats.total_tokens,
@@ -1338,17 +1367,48 @@ function queryBillingSummary(db, period) {
1338
1367
  }
1339
1368
  return { total_usd: total, by_provider };
1340
1369
  }
1341
- function listMachines(db) {
1370
+ function listMachines(db, period = "all") {
1371
+ const rWhere = requestPeriodWhere(period);
1372
+ const sWhere = sessionPeriodWhere(period);
1342
1373
  return db.prepare(`
1374
+ WITH request_stats AS (
1375
+ SELECT
1376
+ machine_id,
1377
+ COUNT(DISTINCT session_id) as sessions,
1378
+ COUNT(*) as requests,
1379
+ COALESCE(SUM(cost_usd), 0) as total_cost_usd,
1380
+ MAX(timestamp) as last_active
1381
+ FROM requests
1382
+ WHERE machine_id != ''
1383
+ AND ${rWhere}
1384
+ GROUP BY machine_id
1385
+ ),
1386
+ session_only_stats AS (
1387
+ SELECT
1388
+ machine_id,
1389
+ COUNT(*) as sessions,
1390
+ COALESCE(SUM(request_count), 0) as requests,
1391
+ COALESCE(SUM(total_cost_usd), 0) as total_cost_usd,
1392
+ MAX(started_at) as last_active
1393
+ FROM sessions
1394
+ WHERE machine_id != ''
1395
+ AND ${sWhere}
1396
+ AND id NOT IN (SELECT DISTINCT session_id FROM requests)
1397
+ GROUP BY machine_id
1398
+ ),
1399
+ combined AS (
1400
+ SELECT * FROM request_stats
1401
+ UNION ALL
1402
+ SELECT * FROM session_only_stats
1403
+ )
1343
1404
  SELECT
1344
- s.machine_id,
1345
- COUNT(DISTINCT s.id) as sessions,
1346
- COALESCE((SELECT COUNT(*) FROM requests r WHERE r.machine_id = s.machine_id), 0) as requests,
1347
- COALESCE(SUM(s.total_cost_usd), 0) as total_cost_usd,
1348
- MAX(s.started_at) as last_active
1349
- FROM sessions s
1350
- WHERE s.machine_id != ''
1351
- GROUP BY s.machine_id
1405
+ machine_id,
1406
+ COALESCE(SUM(sessions), 0) as sessions,
1407
+ COALESCE(SUM(requests), 0) as requests,
1408
+ COALESCE(SUM(total_cost_usd), 0) as total_cost_usd,
1409
+ MAX(last_active) as last_active
1410
+ FROM combined
1411
+ GROUP BY machine_id
1352
1412
  ORDER BY total_cost_usd DESC
1353
1413
  `).all();
1354
1414
  }
@@ -1430,17 +1490,21 @@ function listMachineRegistry(db) {
1430
1490
  }
1431
1491
  function dedupeRequests(db) {
1432
1492
  const dupes = db.prepare(`
1433
- SELECT source_request_id, agent, MIN(id) as keep_id, COUNT(*) as cnt
1493
+ SELECT source_request_id, agent, COALESCE(machine_id, '') as machine_id, MIN(id) as keep_id, COUNT(*) as cnt
1434
1494
  FROM requests
1435
1495
  WHERE source_request_id != '' AND source_request_id IS NOT NULL
1436
- GROUP BY source_request_id, agent
1496
+ GROUP BY source_request_id, agent, COALESCE(machine_id, '')
1437
1497
  HAVING cnt > 1
1438
1498
  `).all();
1439
1499
  let removed = 0;
1440
1500
  for (const row of dupes) {
1441
1501
  const result = db.prepare(`
1442
- DELETE FROM requests WHERE source_request_id = ? AND agent = ? AND id != ?
1443
- `).run(row.source_request_id, row.agent, row.keep_id);
1502
+ DELETE FROM requests
1503
+ WHERE source_request_id = ?
1504
+ AND agent = ?
1505
+ AND COALESCE(machine_id, '') = ?
1506
+ AND id != ?
1507
+ `).run(row.source_request_id, row.agent, row.machine_id, row.keep_id);
1444
1508
  removed += result.changes;
1445
1509
  }
1446
1510
  return removed;
@@ -4023,7 +4087,7 @@ function createHandler(db) {
4023
4087
  const period = url.searchParams.get("period") ?? "month";
4024
4088
  return ok({
4025
4089
  summary: querySummary(db, period, undefined, true),
4026
- machines: listMachines(db),
4090
+ machines: listMachines(db, period),
4027
4091
  registry: listMachineRegistry(db),
4028
4092
  current_machine: getMachineId()
4029
4093
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/economy",
3
- "version": "0.2.26",
3
+ "version": "0.2.28",
4
4
  "description": "AI coding cost tracker — CLI + MCP server + REST API + web dashboard for Claude Code, Codex, Gemini, OpenCode, Cursor, Pi, and Hermes",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",