@hasna/economy 0.2.21 → 0.2.23

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.
@@ -583,7 +583,12 @@ function initSchema(db) {
583
583
  duration_ms INTEGER DEFAULT 0,
584
584
  timestamp TEXT NOT NULL,
585
585
  source_request_id TEXT,
586
- machine_id TEXT DEFAULT ''
586
+ machine_id TEXT DEFAULT '',
587
+ account_key TEXT DEFAULT '',
588
+ account_tool TEXT DEFAULT '',
589
+ account_name TEXT DEFAULT '',
590
+ account_email TEXT DEFAULT '',
591
+ account_source TEXT DEFAULT ''
587
592
  );
588
593
 
589
594
  CREATE TABLE IF NOT EXISTS sessions (
@@ -596,7 +601,12 @@ function initSchema(db) {
596
601
  total_cost_usd REAL DEFAULT 0,
597
602
  total_tokens INTEGER DEFAULT 0,
598
603
  request_count INTEGER DEFAULT 0,
599
- machine_id TEXT DEFAULT ''
604
+ machine_id TEXT DEFAULT '',
605
+ account_key TEXT DEFAULT '',
606
+ account_tool TEXT DEFAULT '',
607
+ account_name TEXT DEFAULT '',
608
+ account_email TEXT DEFAULT '',
609
+ account_source TEXT DEFAULT ''
600
610
  );
601
611
 
602
612
  CREATE TABLE IF NOT EXISTS projects (
@@ -751,6 +761,11 @@ function initSchema(db) {
751
761
  if (!cols.some((c) => c.name === "synced_at")) {
752
762
  db.exec(`ALTER TABLE requests ADD COLUMN synced_at TEXT DEFAULT ''`);
753
763
  }
764
+ 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
+ }
754
769
  const sessionCols = db.prepare(`PRAGMA table_info(sessions)`).all();
755
770
  if (!sessionCols.some((c) => c.name === "attribution_tag")) {
756
771
  db.exec(`ALTER TABLE sessions ADD COLUMN attribution_tag TEXT DEFAULT ''`);
@@ -762,6 +777,11 @@ function initSchema(db) {
762
777
  if (!sessionCols.some((c) => c.name === "synced_at")) {
763
778
  db.exec(`ALTER TABLE sessions ADD COLUMN synced_at TEXT DEFAULT ''`);
764
779
  }
780
+ 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
+ }
765
785
  const pricingCols = db.prepare(`PRAGMA table_info(model_pricing)`).all();
766
786
  if (!pricingCols.some((c) => c.name === "cache_write_1h_per_1m")) {
767
787
  db.exec(`ALTER TABLE model_pricing ADD COLUMN cache_write_1h_per_1m REAL NOT NULL DEFAULT 0`);
@@ -772,6 +792,8 @@ function initSchema(db) {
772
792
  db.exec(`
773
793
  CREATE INDEX IF NOT EXISTS idx_requests_machine ON requests(machine_id);
774
794
  CREATE INDEX IF NOT EXISTS idx_sessions_machine ON sessions(machine_id);
795
+ CREATE INDEX IF NOT EXISTS idx_requests_account ON requests(account_key);
796
+ CREATE INDEX IF NOT EXISTS idx_sessions_account ON sessions(account_key);
775
797
  `);
776
798
  }
777
799
  function periodWhere(period) {
@@ -806,6 +828,22 @@ function sessionPeriodWhere(period) {
806
828
  return "1=1";
807
829
  }
808
830
  }
831
+ function requestPeriodWhere(period) {
832
+ switch (period) {
833
+ case "today":
834
+ return `DATE(timestamp) = DATE('now')`;
835
+ case "yesterday":
836
+ return `DATE(timestamp) = DATE('now', '-1 day')`;
837
+ case "week":
838
+ return `timestamp >= DATE('now', 'weekday 0', '-7 days')`;
839
+ case "month":
840
+ return `timestamp >= DATE('now', 'start of month')`;
841
+ case "year":
842
+ return `timestamp >= DATE('now', 'start of year')`;
843
+ case "all":
844
+ return "1=1";
845
+ }
846
+ }
809
847
  function upsertRequest(db, req) {
810
848
  const now = req.updated_at ?? new Date().toISOString();
811
849
  db.prepare(`
@@ -813,18 +851,20 @@ function upsertRequest(db, req) {
813
851
  (id, agent, session_id, model, input_tokens, output_tokens,
814
852
  cache_read_tokens, cache_create_tokens, cache_create_5m_tokens,
815
853
  cache_create_1h_tokens, cost_usd, cost_basis, duration_ms, timestamp,
816
- source_request_id, machine_id, attribution_tag, updated_at, synced_at)
817
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
818
- `).run(req.id, req.agent, req.session_id, req.model, req.input_tokens, req.output_tokens, req.cache_read_tokens, req.cache_create_tokens, req.cache_create_5m_tokens ?? req.cache_create_tokens, req.cache_create_1h_tokens ?? 0, req.cost_usd, req.cost_basis ?? "estimated", req.duration_ms, req.timestamp, req.source_request_id, req.machine_id ?? "", req.attribution_tag ?? process.env["ECONOMY_TAG"] ?? "", now, req.synced_at ?? "");
854
+ source_request_id, machine_id, attribution_tag, account_key, account_tool,
855
+ account_name, account_email, account_source, updated_at, synced_at)
856
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
857
+ `).run(req.id, req.agent, req.session_id, req.model, req.input_tokens, req.output_tokens, req.cache_read_tokens, req.cache_create_tokens, req.cache_create_5m_tokens ?? req.cache_create_tokens, req.cache_create_1h_tokens ?? 0, req.cost_usd, req.cost_basis ?? "estimated", req.duration_ms, req.timestamp, req.source_request_id, req.machine_id ?? "", req.attribution_tag ?? process.env["ECONOMY_TAG"] ?? "", req.account_key ?? "", req.account_tool ?? "", req.account_name ?? "", req.account_email ?? "", req.account_source ?? "", now, req.synced_at ?? "");
819
858
  }
820
859
  function upsertSession(db, session) {
821
860
  const now = session.updated_at ?? new Date().toISOString();
822
861
  db.prepare(`
823
862
  INSERT OR REPLACE INTO sessions
824
863
  (id, agent, project_path, project_name, started_at, ended_at,
825
- total_cost_usd, total_tokens, request_count, machine_id, attribution_tag, updated_at, synced_at)
826
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
827
- `).run(session.id, session.agent, session.project_path, session.project_name, session.started_at, session.ended_at ?? null, session.total_cost_usd, session.total_tokens, session.request_count, session.machine_id ?? "", session.attribution_tag ?? process.env["ECONOMY_TAG"] ?? "", now, session.synced_at ?? "");
864
+ total_cost_usd, total_tokens, request_count, machine_id, attribution_tag,
865
+ account_key, account_tool, account_name, account_email, account_source, updated_at, synced_at)
866
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
867
+ `).run(session.id, session.agent, session.project_path, session.project_name, session.started_at, session.ended_at ?? null, session.total_cost_usd, session.total_tokens, session.request_count, session.machine_id ?? "", session.attribution_tag ?? process.env["ECONOMY_TAG"] ?? "", session.account_key ?? "", session.account_tool ?? "", session.account_name ?? "", session.account_email ?? "", session.account_source ?? "", now, session.synced_at ?? "");
828
868
  }
829
869
  function rollupSession(db, sessionId) {
830
870
  db.prepare(`
@@ -835,9 +875,24 @@ function rollupSession(db, sessionId) {
835
875
  ended_at = (SELECT MAX(timestamp) FROM requests WHERE session_id = ?),
836
876
  started_at = CASE WHEN started_at = '' OR started_at IS NULL
837
877
  THEN (SELECT MIN(timestamp) FROM requests WHERE session_id = ?)
838
- ELSE started_at END
878
+ ELSE started_at END,
879
+ account_key = CASE WHEN account_key = '' OR account_key IS NULL
880
+ THEN COALESCE((SELECT account_key FROM requests WHERE session_id = ? AND account_key != '' ORDER BY timestamp DESC LIMIT 1), '')
881
+ ELSE account_key END,
882
+ account_tool = CASE WHEN account_tool = '' OR account_tool IS NULL
883
+ THEN COALESCE((SELECT account_tool FROM requests WHERE session_id = ? AND account_tool != '' ORDER BY timestamp DESC LIMIT 1), '')
884
+ ELSE account_tool END,
885
+ account_name = CASE WHEN account_name = '' OR account_name IS NULL
886
+ THEN COALESCE((SELECT account_name FROM requests WHERE session_id = ? AND account_name != '' ORDER BY timestamp DESC LIMIT 1), '')
887
+ ELSE account_name END,
888
+ account_email = CASE WHEN account_email = '' OR account_email IS NULL
889
+ THEN COALESCE((SELECT account_email FROM requests WHERE session_id = ? AND account_email != '' ORDER BY timestamp DESC LIMIT 1), '')
890
+ ELSE account_email END,
891
+ account_source = CASE WHEN account_source = '' OR account_source IS NULL
892
+ THEN COALESCE((SELECT account_source FROM requests WHERE session_id = ? AND account_source != '' ORDER BY timestamp DESC LIMIT 1), '')
893
+ ELSE account_source END
839
894
  WHERE id = ?
840
- `).run(sessionId, sessionId, sessionId, sessionId, sessionId, sessionId);
895
+ `).run(sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId);
841
896
  }
842
897
  function querySessions(db, filter = {}) {
843
898
  const conditions = [];
@@ -850,6 +905,11 @@ function querySessions(db, filter = {}) {
850
905
  conditions.push("project_path LIKE ?");
851
906
  params.push(`%${filter.project}%`);
852
907
  }
908
+ if (filter.account) {
909
+ const q = `%${filter.account}%`;
910
+ conditions.push("(account_key LIKE ? OR account_name LIKE ? OR account_email LIKE ?)");
911
+ params.push(q, q, q);
912
+ }
853
913
  if (filter.since) {
854
914
  conditions.push("started_at >= ?");
855
915
  params.push(filter.since);
@@ -914,6 +974,70 @@ function queryModelBreakdown(db) {
914
974
  FROM requests GROUP BY model, agent ORDER BY cost_usd DESC
915
975
  `).all();
916
976
  }
977
+ function queryAgentBreakdown(db, period = "all") {
978
+ const requestWhere = requestPeriodWhere(period);
979
+ const groups = new Map;
980
+ const requestRows = db.prepare(`
981
+ SELECT agent,
982
+ COUNT(DISTINCT session_id) as sessions,
983
+ COUNT(*) as requests,
984
+ COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_create_tokens), 0) as total_tokens,
985
+ COALESCE(SUM(cost_usd), 0) as api_equivalent_usd,
986
+ COALESCE(SUM(CASE WHEN cost_basis = 'metered_api' THEN cost_usd ELSE 0 END), 0) as metered_api_usd,
987
+ COALESCE(SUM(CASE WHEN cost_basis = 'subscription_included' THEN cost_usd ELSE 0 END), 0) as subscription_included_usd,
988
+ COALESCE(SUM(CASE WHEN COALESCE(cost_basis, 'estimated') = 'estimated' THEN cost_usd ELSE 0 END), 0) as estimated_usd,
989
+ COALESCE(SUM(CASE WHEN cost_basis = 'unknown' THEN cost_usd ELSE 0 END), 0) as unknown_usd,
990
+ COALESCE(SUM(CASE WHEN cost_basis = 'metered_api' THEN cost_usd ELSE 0 END), 0) as billable_usd,
991
+ COALESCE(SUM(cost_usd), 0) as cost_usd,
992
+ MAX(timestamp) as last_active
993
+ FROM requests
994
+ WHERE ${requestWhere}
995
+ GROUP BY agent
996
+ ORDER BY api_equivalent_usd DESC
997
+ `).all();
998
+ for (const row of requestRows) {
999
+ groups.set(row.agent, row);
1000
+ }
1001
+ const sessionWhere = sessionPeriodWhere(period);
1002
+ const sessionOnlyRows = db.prepare(`
1003
+ SELECT agent,
1004
+ COUNT(*) as sessions,
1005
+ COALESCE(SUM(request_count), 0) as requests,
1006
+ COALESCE(SUM(total_tokens), 0) as total_tokens,
1007
+ COALESCE(SUM(total_cost_usd), 0) as cost_usd,
1008
+ MAX(started_at) as last_active
1009
+ FROM sessions
1010
+ WHERE ${sessionWhere}
1011
+ AND id NOT IN (SELECT DISTINCT session_id FROM requests)
1012
+ GROUP BY agent
1013
+ `).all();
1014
+ for (const row of sessionOnlyRows) {
1015
+ const existing = groups.get(row.agent) ?? {
1016
+ agent: row.agent,
1017
+ sessions: 0,
1018
+ requests: 0,
1019
+ total_tokens: 0,
1020
+ api_equivalent_usd: 0,
1021
+ billable_usd: 0,
1022
+ metered_api_usd: 0,
1023
+ subscription_included_usd: 0,
1024
+ estimated_usd: 0,
1025
+ unknown_usd: 0,
1026
+ cost_usd: 0,
1027
+ last_active: ""
1028
+ };
1029
+ existing.sessions += row.sessions;
1030
+ existing.requests += row.requests;
1031
+ existing.total_tokens += row.total_tokens;
1032
+ existing.api_equivalent_usd += row.cost_usd;
1033
+ existing.estimated_usd += row.cost_usd;
1034
+ existing.cost_usd += row.cost_usd;
1035
+ if (!existing.last_active || row.last_active > existing.last_active)
1036
+ existing.last_active = row.last_active;
1037
+ groups.set(row.agent, existing);
1038
+ }
1039
+ return [...groups.values()].sort((a, b) => b.api_equivalent_usd - a.api_equivalent_usd);
1040
+ }
917
1041
  function labelForPath(projectPath, projectName) {
918
1042
  if (projectName && projectName.trim() !== "")
919
1043
  return projectName;
@@ -932,11 +1056,13 @@ function labelForPath(projectPath, projectName) {
932
1056
  }
933
1057
  return segments[segments.length - 1] ?? projectPath;
934
1058
  }
935
- function queryProjectBreakdown(db) {
1059
+ function queryProjectBreakdown(db, period = "all") {
1060
+ const where = sessionPeriodWhere(period);
936
1061
  const sessions = db.prepare(`
937
1062
  SELECT id, project_path, project_name, total_cost_usd, started_at
938
1063
  FROM sessions
939
- WHERE project_path != '' OR project_name != ''
1064
+ WHERE ${where}
1065
+ AND (project_path != '' OR project_name != '')
940
1066
  `).all();
941
1067
  const groups = new Map;
942
1068
  for (const s of sessions) {
@@ -975,6 +1101,87 @@ function queryProjectBreakdown(db) {
975
1101
  result.sort((a, b) => b.cost_usd - a.cost_usd);
976
1102
  return result;
977
1103
  }
1104
+ function queryAccountBreakdown(db, period = "all") {
1105
+ const sWhere = sessionPeriodWhere(period);
1106
+ const sessions = db.prepare(`
1107
+ SELECT id, account_key, account_tool, account_name, account_email, account_source,
1108
+ total_cost_usd, total_tokens, request_count, started_at
1109
+ FROM sessions
1110
+ WHERE ${sWhere}
1111
+ AND (account_key != '' OR account_tool != '' OR account_name != '' OR account_email != '')
1112
+ `).all();
1113
+ const groups = new Map;
1114
+ for (const session of sessions) {
1115
+ const key = session.account_key || `${session.account_tool}:${session.account_name}`;
1116
+ if (!key || key === ":")
1117
+ continue;
1118
+ const group = groups.get(key) ?? {
1119
+ sessionIds: [],
1120
+ account_tool: session.account_tool,
1121
+ account_name: session.account_name,
1122
+ account_email: session.account_email || null,
1123
+ account_source: session.account_source || "unknown",
1124
+ totalCost: 0,
1125
+ totalTokens: 0,
1126
+ requests: 0,
1127
+ lastActive: ""
1128
+ };
1129
+ group.sessionIds.push(session.id);
1130
+ group.totalCost += session.total_cost_usd || 0;
1131
+ group.totalTokens += session.total_tokens || 0;
1132
+ group.requests += session.request_count || 0;
1133
+ if (!group.lastActive || session.started_at > group.lastActive)
1134
+ group.lastActive = session.started_at;
1135
+ groups.set(key, group);
1136
+ }
1137
+ const result = [];
1138
+ for (const [key, group] of groups.entries()) {
1139
+ const placeholders = group.sessionIds.map(() => "?").join(",");
1140
+ const reqStats = placeholders ? db.prepare(`
1141
+ SELECT
1142
+ COUNT(*) as requests,
1143
+ COALESCE(SUM(cost_usd), 0) as cost_usd,
1144
+ COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_create_tokens), 0) as total_tokens,
1145
+ COALESCE(SUM(CASE WHEN cost_basis = 'metered_api' THEN cost_usd ELSE 0 END), 0) as metered_api_usd,
1146
+ COALESCE(SUM(CASE WHEN cost_basis = 'subscription_included' THEN cost_usd ELSE 0 END), 0) as subscription_included_usd,
1147
+ COALESCE(SUM(CASE WHEN COALESCE(cost_basis, 'estimated') = 'estimated' THEN cost_usd ELSE 0 END), 0) as estimated_usd,
1148
+ COALESCE(SUM(CASE WHEN cost_basis = 'unknown' THEN cost_usd ELSE 0 END), 0) as unknown_usd
1149
+ FROM requests WHERE session_id IN (${placeholders})
1150
+ `).get(...group.sessionIds) : {
1151
+ requests: 0,
1152
+ cost_usd: 0,
1153
+ total_tokens: 0,
1154
+ metered_api_usd: 0,
1155
+ subscription_included_usd: 0,
1156
+ estimated_usd: 0,
1157
+ unknown_usd: 0
1158
+ };
1159
+ const hasRequestCosts = reqStats.requests > 0;
1160
+ const apiEquivalentUsd = hasRequestCosts ? reqStats.cost_usd : group.totalCost;
1161
+ const estimatedUsd = hasRequestCosts ? reqStats.estimated_usd : group.totalCost;
1162
+ const billableUsd = reqStats.metered_api_usd;
1163
+ result.push({
1164
+ account_key: key,
1165
+ account_tool: group.account_tool,
1166
+ account_name: group.account_name,
1167
+ account_email: group.account_email,
1168
+ account_source: group.account_source,
1169
+ sessions: group.sessionIds.length,
1170
+ requests: reqStats.requests || group.requests,
1171
+ total_tokens: reqStats.total_tokens || group.totalTokens,
1172
+ api_equivalent_usd: apiEquivalentUsd,
1173
+ billable_usd: billableUsd,
1174
+ metered_api_usd: reqStats.metered_api_usd,
1175
+ subscription_included_usd: reqStats.subscription_included_usd,
1176
+ estimated_usd: estimatedUsd,
1177
+ unknown_usd: reqStats.unknown_usd,
1178
+ cost_usd: apiEquivalentUsd,
1179
+ last_active: group.lastActive
1180
+ });
1181
+ }
1182
+ result.sort((a, b) => b.cost_usd - a.cost_usd);
1183
+ return result;
1184
+ }
978
1185
  function queryDailyBreakdown(db, days = 30) {
979
1186
  return db.prepare(`
980
1187
  SELECT DATE(timestamp) as date, agent, COALESCE(SUM(cost_usd), 0) as cost_usd
@@ -1157,6 +1364,12 @@ function upsertSubscription(db, sub) {
1157
1364
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1158
1365
  `).run(sub.id, sub.agent, sub.provider, sub.plan, sub.monthly_fee_usd, sub.included_usage_usd, sub.billing_cycle_start, sub.reset_policy, sub.active, sub.created_at, sub.updated_at);
1159
1366
  }
1367
+ function listSubscriptions(db) {
1368
+ return db.prepare(`SELECT * FROM subscriptions ORDER BY provider, plan`).all();
1369
+ }
1370
+ function deleteSubscription(db, id) {
1371
+ db.prepare(`DELETE FROM subscriptions WHERE id = ?`).run(id);
1372
+ }
1160
1373
  function upsertUsageSnapshot(db, snap) {
1161
1374
  const now = snap.updated_at ?? new Date().toISOString();
1162
1375
  const id = snap.id ?? `${snap.agent}-${snap.date}-${snap.metric}-${snap.machine_id}`;
@@ -1228,7 +1441,12 @@ var init_pg_migrations = __esm(() => {
1228
1441
  duration_ms INTEGER DEFAULT 0,
1229
1442
  timestamp TEXT NOT NULL,
1230
1443
  source_request_id TEXT,
1231
- machine_id TEXT DEFAULT ''
1444
+ machine_id TEXT DEFAULT '',
1445
+ account_key TEXT DEFAULT '',
1446
+ account_tool TEXT DEFAULT '',
1447
+ account_name TEXT DEFAULT '',
1448
+ account_email TEXT DEFAULT '',
1449
+ account_source TEXT DEFAULT ''
1232
1450
  )`,
1233
1451
  `CREATE TABLE IF NOT EXISTS sessions (
1234
1452
  id TEXT PRIMARY KEY,
@@ -1240,7 +1458,12 @@ var init_pg_migrations = __esm(() => {
1240
1458
  total_cost_usd REAL DEFAULT 0,
1241
1459
  total_tokens INTEGER DEFAULT 0,
1242
1460
  request_count INTEGER DEFAULT 0,
1243
- machine_id TEXT DEFAULT ''
1461
+ machine_id TEXT DEFAULT '',
1462
+ account_key TEXT DEFAULT '',
1463
+ account_tool TEXT DEFAULT '',
1464
+ account_name TEXT DEFAULT '',
1465
+ account_email TEXT DEFAULT '',
1466
+ account_source TEXT DEFAULT ''
1244
1467
  )`,
1245
1468
  `CREATE TABLE IF NOT EXISTS projects (
1246
1469
  id TEXT PRIMARY KEY,
@@ -1361,13 +1584,25 @@ var init_pg_migrations = __esm(() => {
1361
1584
  )`,
1362
1585
  `ALTER TABLE requests ADD COLUMN IF NOT EXISTS cost_basis TEXT DEFAULT 'estimated'`,
1363
1586
  `ALTER TABLE requests ADD COLUMN IF NOT EXISTS attribution_tag TEXT DEFAULT ''`,
1587
+ `ALTER TABLE requests ADD COLUMN IF NOT EXISTS account_key TEXT DEFAULT ''`,
1588
+ `ALTER TABLE requests ADD COLUMN IF NOT EXISTS account_tool TEXT DEFAULT ''`,
1589
+ `ALTER TABLE requests ADD COLUMN IF NOT EXISTS account_name TEXT DEFAULT ''`,
1590
+ `ALTER TABLE requests ADD COLUMN IF NOT EXISTS account_email TEXT DEFAULT ''`,
1591
+ `ALTER TABLE requests ADD COLUMN IF NOT EXISTS account_source TEXT DEFAULT ''`,
1364
1592
  `ALTER TABLE requests ADD COLUMN IF NOT EXISTS updated_at TEXT DEFAULT ''`,
1365
1593
  `ALTER TABLE requests ADD COLUMN IF NOT EXISTS synced_at TEXT DEFAULT ''`,
1366
1594
  `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS attribution_tag TEXT DEFAULT ''`,
1595
+ `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS account_key TEXT DEFAULT ''`,
1596
+ `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS account_tool TEXT DEFAULT ''`,
1597
+ `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS account_name TEXT DEFAULT ''`,
1598
+ `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS account_email TEXT DEFAULT ''`,
1599
+ `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS account_source TEXT DEFAULT ''`,
1367
1600
  `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS updated_at TEXT DEFAULT ''`,
1368
1601
  `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS synced_at TEXT DEFAULT ''`,
1369
1602
  `CREATE INDEX IF NOT EXISTS idx_usage_agent_date ON usage_snapshots(agent, date)`,
1370
- `CREATE INDEX IF NOT EXISTS idx_savings_date ON savings_daily(date)`
1603
+ `CREATE INDEX IF NOT EXISTS idx_savings_date ON savings_daily(date)`,
1604
+ `CREATE INDEX IF NOT EXISTS idx_requests_account ON requests(account_key)`,
1605
+ `CREATE INDEX IF NOT EXISTS idx_sessions_account ON sessions(account_key)`
1371
1606
  ];
1372
1607
  });
1373
1608
 
@@ -1378,7 +1613,7 @@ __export(exports_billing, {
1378
1613
  syncGeminiBilling: () => syncGeminiBilling,
1379
1614
  syncAnthropicBilling: () => syncAnthropicBilling
1380
1615
  });
1381
- import { readFileSync as readFileSync10 } from "fs";
1616
+ import { readFileSync as readFileSync9 } from "fs";
1382
1617
  function getAnthropicAdminKey() {
1383
1618
  return process.env["HASNAXYZ_ANTHROPIC_LIVE_ADMIN_API_KEY"] ?? process.env["ANTHROPIC_ADMIN_API_KEY"] ?? null;
1384
1619
  }
@@ -1572,7 +1807,7 @@ async function syncGeminiBilling(db, opts = {}) {
1572
1807
  const start = opts.fromDate ? new Date(opts.fromDate) : new Date(end.getTime() - days * 24 * 3600000);
1573
1808
  const fromDateStr = toISODate(start);
1574
1809
  const toDateStr = toISODate(end);
1575
- const rows = parseBillingRows(readFileSync10(exportPath, "utf-8"));
1810
+ const rows = parseBillingRows(readFileSync9(exportPath, "utf-8"));
1576
1811
  clearBillingRange(db, "gemini", fromDateStr, toDateStr);
1577
1812
  const updatedAt = new Date().toISOString();
1578
1813
  let totalUsd = 0;
@@ -1634,7 +1869,7 @@ var init_open_projects = __esm(() => {
1634
1869
  });
1635
1870
 
1636
1871
  // src/lib/config.ts
1637
- import { existsSync as existsSync10, readFileSync as readFileSync11, writeFileSync, mkdirSync as mkdirSync2 } from "fs";
1872
+ import { existsSync as existsSync10, readFileSync as readFileSync10, writeFileSync, mkdirSync as mkdirSync2 } from "fs";
1638
1873
  import { dirname, join as join9 } from "path";
1639
1874
  function getConfigPath() {
1640
1875
  return process.env["HASNA_ECONOMY_CONFIG_PATH"] ?? join9(getDataDir(), "config.json");
@@ -1643,7 +1878,7 @@ function loadConfig() {
1643
1878
  try {
1644
1879
  const configPath = getConfigPath();
1645
1880
  if (existsSync10(configPath)) {
1646
- const raw = readFileSync11(configPath, "utf-8");
1881
+ const raw = readFileSync10(configPath, "utf-8");
1647
1882
  return { ...DEFAULTS, ...JSON.parse(raw) };
1648
1883
  }
1649
1884
  } catch {}
@@ -1809,7 +2044,6 @@ function periodWhere2(period, column) {
1809
2044
  }
1810
2045
  function prorateMonthlyFee(monthlyFee, period) {
1811
2046
  const now = new Date;
1812
- const dayOfMonth = now.getDate();
1813
2047
  const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
1814
2048
  switch (period) {
1815
2049
  case "today":
@@ -1891,6 +2125,131 @@ function defaultCostBasisForAgent(agent) {
1891
2125
  return "estimated";
1892
2126
  }
1893
2127
 
2128
+ // src/lib/accounts.ts
2129
+ var AGENT_ACCOUNT_TOOLS = {
2130
+ claude: ["claude"],
2131
+ takumi: ["takumi", "claude"],
2132
+ codex: ["codex"],
2133
+ gemini: ["gemini"],
2134
+ opencode: ["opencode"],
2135
+ cursor: ["cursor"],
2136
+ pi: ["pi"],
2137
+ hermes: ["hermes"]
2138
+ };
2139
+ function accountKey(tool, name) {
2140
+ return `${tool}:${name}`;
2141
+ }
2142
+ function normalizeDir(value) {
2143
+ return value.replace(/\/+$/, "");
2144
+ }
2145
+ function fromProfile(profile, source) {
2146
+ return {
2147
+ account_key: accountKey(profile.tool, profile.name),
2148
+ account_tool: profile.tool,
2149
+ account_name: profile.name,
2150
+ ...profile.email ? { account_email: profile.email } : {},
2151
+ account_source: source
2152
+ };
2153
+ }
2154
+ function fromOverride(raw, agent) {
2155
+ const value = raw.trim();
2156
+ if (!value)
2157
+ return null;
2158
+ const candidateTool = AGENT_ACCOUNT_TOOLS[agent][0] ?? agent;
2159
+ const [tool, name] = value.includes(":") ? value.split(":", 2) : [candidateTool, value];
2160
+ if (!tool || !name)
2161
+ return null;
2162
+ return {
2163
+ account_key: accountKey(tool, name),
2164
+ account_tool: tool,
2165
+ account_name: name,
2166
+ account_source: "override"
2167
+ };
2168
+ }
2169
+ function envOverride(agent, env) {
2170
+ const agentPrefix = agent.toUpperCase().replace(/[^A-Z0-9]/g, "_");
2171
+ const raw = env[`ECONOMY_${agentPrefix}_ACCOUNT_KEY`] ?? env[`ECONOMY_${agentPrefix}_ACCOUNT`] ?? env["ECONOMY_ACCOUNT_KEY"] ?? env["ECONOMY_ACCOUNT"];
2172
+ if (raw)
2173
+ return fromOverride(raw, agent);
2174
+ const tool = env[`ECONOMY_${agentPrefix}_ACCOUNT_TOOL`] ?? env["ECONOMY_ACCOUNT_TOOL"];
2175
+ const name = env[`ECONOMY_${agentPrefix}_ACCOUNT_NAME`] ?? env["ECONOMY_ACCOUNT_NAME"];
2176
+ if (!tool || !name)
2177
+ return null;
2178
+ return {
2179
+ account_key: accountKey(tool, name),
2180
+ account_tool: tool,
2181
+ account_name: name,
2182
+ account_email: env[`ECONOMY_${agentPrefix}_ACCOUNT_EMAIL`] ?? env["ECONOMY_ACCOUNT_EMAIL"],
2183
+ account_source: "override"
2184
+ };
2185
+ }
2186
+ function knownToolIds(api) {
2187
+ try {
2188
+ return new Set(api.listTools().map((tool) => tool.id));
2189
+ } catch {
2190
+ return new Set;
2191
+ }
2192
+ }
2193
+ function profileForEnvDir(api, tool, env) {
2194
+ const configuredDir = env[tool.envVar];
2195
+ if (!configuredDir)
2196
+ return null;
2197
+ const normalized = normalizeDir(configuredDir);
2198
+ try {
2199
+ return api.listProfiles(tool.id).find((profile) => normalizeDir(profile.dir) === normalized) ?? null;
2200
+ } catch {
2201
+ return null;
2202
+ }
2203
+ }
2204
+ async function resolveAccountForAgent(agent, env = process.env) {
2205
+ const override = envOverride(agent, env);
2206
+ if (override)
2207
+ return override;
2208
+ let api;
2209
+ try {
2210
+ api = await import("@hasna/accounts");
2211
+ } catch {
2212
+ return null;
2213
+ }
2214
+ const toolIds = knownToolIds(api);
2215
+ for (const toolId of AGENT_ACCOUNT_TOOLS[agent]) {
2216
+ if (!toolIds.has(toolId))
2217
+ continue;
2218
+ let tool;
2219
+ try {
2220
+ tool = api.getTool(toolId);
2221
+ } catch {
2222
+ continue;
2223
+ }
2224
+ const envProfile = profileForEnvDir(api, tool, env);
2225
+ if (envProfile)
2226
+ return fromProfile(envProfile, "env");
2227
+ try {
2228
+ const applied = api.appliedProfile(toolId);
2229
+ if (applied)
2230
+ return fromProfile(applied, "applied");
2231
+ } catch {}
2232
+ try {
2233
+ const current = api.currentProfile(toolId);
2234
+ if (current)
2235
+ return fromProfile(current, "current");
2236
+ } catch {}
2237
+ }
2238
+ return null;
2239
+ }
2240
+ function withAccount(record, account) {
2241
+ if (!account)
2242
+ return record;
2243
+ return {
2244
+ ...record,
2245
+ account_key: account.account_key,
2246
+ account_tool: account.account_tool,
2247
+ account_name: account.account_name,
2248
+ account_email: account.account_email ?? "",
2249
+ account_source: account.account_source
2250
+ };
2251
+ }
2252
+
1894
2253
  // src/ingest/claude.ts
1895
2254
  function autoDetectProject(cwd, projects) {
1896
2255
  return projects.find((p) => cwd === p.path || cwd.startsWith(p.path + "/"));
@@ -1932,6 +2291,7 @@ async function ingestJsonlProjects(db, projectsDir, agentName, verbose = false)
1932
2291
  let totalRequests = 0;
1933
2292
  const touchedSessions = new Set;
1934
2293
  const registeredProjects = db.prepare(`SELECT path, name FROM projects ORDER BY LENGTH(path) DESC`).all();
2294
+ const account = await resolveAccountForAgent(agentName);
1935
2295
  const projectDirs = readdirSync2(projectsDir, { withFileTypes: true }).filter((d) => d.isDirectory());
1936
2296
  for (const projectDirEntry of projectDirs) {
1937
2297
  const projectDirPath = join2(projectsDir, projectDirEntry.name);
@@ -1995,7 +2355,7 @@ async function ingestJsonlProjects(db, projectsDir, agentName, verbose = false)
1995
2355
  }
1996
2356
  const sourceRequestId = entry.requestId ?? entry.request_id ?? entry.message.id ?? entry.uuid ?? `${sessionId}-${timestamp}`;
1997
2357
  const reqId = `${agentName}-${sourceRequestId}`;
1998
- upsertRequest(db, {
2358
+ upsertRequest(db, withAccount({
1999
2359
  id: reqId,
2000
2360
  agent: agentName,
2001
2361
  session_id: sessionId,
@@ -2012,7 +2372,7 @@ async function ingestJsonlProjects(db, projectsDir, agentName, verbose = false)
2012
2372
  timestamp,
2013
2373
  source_request_id: sourceRequestId,
2014
2374
  machine_id: machineId
2015
- });
2375
+ }, account));
2016
2376
  if (!touchedSessions.has(sessionId)) {
2017
2377
  const existing = db.prepare(`SELECT id FROM sessions WHERE id = ?`).get(sessionId);
2018
2378
  if (!existing) {
@@ -2030,7 +2390,7 @@ async function ingestJsonlProjects(db, projectsDir, agentName, verbose = false)
2030
2390
  request_count: 0,
2031
2391
  machine_id: machineId
2032
2392
  };
2033
- upsertSession(db, session);
2393
+ upsertSession(db, withAccount(session, account));
2034
2394
  }
2035
2395
  touchedSessions.add(sessionId);
2036
2396
  }
@@ -2076,7 +2436,7 @@ import { join as join3, basename as basename2 } from "path";
2076
2436
  import { Database as BunDatabase } from "bun:sqlite";
2077
2437
  var DEFAULT_CODEX_DB_PATH = join3(homedir3(), ".codex", "state_5.sqlite");
2078
2438
  var DEFAULT_CODEX_CONFIG_PATH = join3(homedir3(), ".codex", "config.toml");
2079
- var CODEX_INGEST_VERSION = "rollout-token-dedupe-v2";
2439
+ var CODEX_INGEST_VERSION = "rollout-aggregate-v3";
2080
2440
  function codexDbPath() {
2081
2441
  return process.env["HASNA_ECONOMY_CODEX_DB_PATH"] ?? DEFAULT_CODEX_DB_PATH;
2082
2442
  }
@@ -2109,8 +2469,9 @@ function buildThreadQuery(codexDb) {
2109
2469
  function readTokenEvents(rolloutPath) {
2110
2470
  if (!rolloutPath || !existsSync3(rolloutPath))
2111
2471
  return [];
2112
- const events = [];
2113
- const seen = new Set;
2472
+ const fallbackUsages = new Map;
2473
+ let fallbackTimestamp;
2474
+ let aggregate = null;
2114
2475
  for (const line of readFileSync2(rolloutPath, "utf-8").split(`
2115
2476
  `)) {
2116
2477
  if (!line.trim())
@@ -2127,20 +2488,48 @@ function readTokenEvents(rolloutPath) {
2127
2488
  if (!payload || payload["type"] !== "token_count")
2128
2489
  continue;
2129
2490
  const info = payload["info"];
2491
+ const timestamp = entry["timestamp"];
2492
+ const entryTimestamp = typeof timestamp === "string" ? timestamp : undefined;
2493
+ const totalUsage = info?.["total_token_usage"];
2494
+ if (totalUsage && tokenTotal(totalUsage) > 0) {
2495
+ aggregate = { usage: totalUsage, timestamp: entryTimestamp };
2496
+ continue;
2497
+ }
2130
2498
  const usage = info?.["last_token_usage"];
2131
2499
  if (!usage)
2132
2500
  continue;
2133
- const total = usage.total_tokens ?? (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0);
2134
- if (total <= 0)
2501
+ if (tokenTotal(usage) <= 0)
2135
2502
  continue;
2136
2503
  const key = JSON.stringify(usage);
2137
- if (seen.has(key))
2138
- continue;
2139
- seen.add(key);
2140
- const timestamp = entry["timestamp"];
2141
- events.push({ usage, timestamp: typeof timestamp === "string" ? timestamp : undefined });
2504
+ if (!fallbackUsages.has(key))
2505
+ fallbackUsages.set(key, usage);
2506
+ fallbackTimestamp = entryTimestamp ?? fallbackTimestamp;
2142
2507
  }
2143
- return events;
2508
+ if (aggregate)
2509
+ return [aggregate];
2510
+ if (fallbackUsages.size === 0)
2511
+ return [];
2512
+ return [{ usage: sumTokenUsages([...fallbackUsages.values()]), timestamp: fallbackTimestamp }];
2513
+ }
2514
+ function tokenTotal(usage) {
2515
+ return usage.total_tokens ?? (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0);
2516
+ }
2517
+ function sumTokenUsages(usages) {
2518
+ const result = {
2519
+ input_tokens: 0,
2520
+ cached_input_tokens: 0,
2521
+ output_tokens: 0,
2522
+ reasoning_output_tokens: 0,
2523
+ total_tokens: 0
2524
+ };
2525
+ for (const usage of usages) {
2526
+ result.input_tokens = (result.input_tokens ?? 0) + (usage.input_tokens ?? 0);
2527
+ result.cached_input_tokens = (result.cached_input_tokens ?? 0) + (usage.cached_input_tokens ?? 0);
2528
+ result.output_tokens = (result.output_tokens ?? 0) + (usage.output_tokens ?? 0);
2529
+ result.reasoning_output_tokens = (result.reasoning_output_tokens ?? 0) + (usage.reasoning_output_tokens ?? 0);
2530
+ result.total_tokens = (result.total_tokens ?? 0) + tokenTotal(usage);
2531
+ }
2532
+ return result;
2144
2533
  }
2145
2534
  function fallbackEvents(totalTokens) {
2146
2535
  const inputTokens = Math.floor(totalTokens * 0.6);
@@ -2164,6 +2553,7 @@ async function ingestCodex(db, verbose = false) {
2164
2553
  let codexDb = null;
2165
2554
  let ingested = 0;
2166
2555
  let requests = 0;
2556
+ const account = await resolveAccountForAgent("codex");
2167
2557
  try {
2168
2558
  codexDb = new BunDatabase(dbPath, { readonly: true });
2169
2559
  const threads = codexDb.prepare(buildThreadQuery(codexDb)).all();
@@ -2178,7 +2568,7 @@ async function ingestCodex(db, verbose = false) {
2178
2568
  const sessionId = `codex-${thread.id}`;
2179
2569
  const startedAt = thread.created_at ? new Date(thread.created_at * 1000).toISOString() : new Date().toISOString();
2180
2570
  const endedAt = thread.updated_at ? new Date(thread.updated_at * 1000).toISOString() : null;
2181
- upsertSession(db, {
2571
+ upsertSession(db, withAccount({
2182
2572
  id: sessionId,
2183
2573
  agent: "codex",
2184
2574
  project_path: projectPath,
@@ -2189,9 +2579,10 @@ async function ingestCodex(db, verbose = false) {
2189
2579
  total_tokens: 0,
2190
2580
  request_count: 0,
2191
2581
  machine_id: machineId
2192
- });
2582
+ }, account));
2193
2583
  const events = readTokenEvents(thread.rollout_path);
2194
2584
  const tokenEvents = events.length > 0 ? events : fallbackEvents(thread.tokens_used);
2585
+ const ingestedTokens = tokenEvents.reduce((sum, event) => sum + tokenTotal(event.usage), 0);
2195
2586
  db.prepare(`DELETE FROM requests WHERE session_id = ?`).run(sessionId);
2196
2587
  tokenEvents.forEach((event, index) => {
2197
2588
  const usage = event.usage;
@@ -2202,7 +2593,7 @@ async function ingestCodex(db, verbose = false) {
2202
2593
  const costUsd = computeCostFromDb(db, model, inputTokens, outputTokens, cacheReadTokens, 0);
2203
2594
  const timestamp = event.timestamp ?? (thread.created_at ? new Date(thread.created_at * 1000 + index).toISOString() : new Date().toISOString());
2204
2595
  const requestId = `${sessionId}-${index}`;
2205
- upsertRequest(db, {
2596
+ upsertRequest(db, withAccount({
2206
2597
  id: requestId,
2207
2598
  agent: "codex",
2208
2599
  session_id: sessionId,
@@ -2217,14 +2608,14 @@ async function ingestCodex(db, verbose = false) {
2217
2608
  timestamp,
2218
2609
  source_request_id: requestId,
2219
2610
  machine_id: machineId
2220
- });
2611
+ }, account));
2221
2612
  requests++;
2222
2613
  });
2223
2614
  rollupSession(db, sessionId);
2224
2615
  setIngestState(db, "codex", thread.id, stateValue);
2225
2616
  ingested++;
2226
2617
  if (verbose)
2227
- console.log(`Codex session ${thread.id}: ${thread.tokens_used} tokens on ${model}`);
2618
+ console.log(`Codex session ${thread.id}: ${ingestedTokens} tokens on ${model}`);
2228
2619
  }
2229
2620
  } finally {
2230
2621
  codexDb?.close();
@@ -2291,6 +2682,7 @@ async function ingestGemini(db, verbose) {
2291
2682
  let totalSessions = 0;
2292
2683
  let totalRequests = 0;
2293
2684
  const touchedSessions = new Set;
2685
+ const account = await resolveAccountForAgent("gemini");
2294
2686
  const projectDirs = listProjectDirs(tmpDir, historyDir);
2295
2687
  for (const projectDir of projectDirs) {
2296
2688
  const chatsDir = join4(projectDir, "chats");
@@ -2339,7 +2731,7 @@ async function ingestGemini(db, verbose) {
2339
2731
  request_count: 0,
2340
2732
  machine_id: machineId
2341
2733
  };
2342
- upsertSession(db, session);
2734
+ upsertSession(db, withAccount(session, account));
2343
2735
  totalSessions++;
2344
2736
  }
2345
2737
  touchedSessions.add(sessionId);
@@ -2363,7 +2755,7 @@ async function ingestGemini(db, verbose) {
2363
2755
  const costUsd = numberField(message.costUsd, message.cost_usd) || computedCost;
2364
2756
  const timestamp = message.timestamp ?? chatData.lastUpdated ?? startTime;
2365
2757
  const requestId = `gemini-${sessionId}-${message.id ?? index}`;
2366
- upsertRequest(db, {
2758
+ upsertRequest(db, withAccount({
2367
2759
  id: requestId,
2368
2760
  agent: "gemini",
2369
2761
  session_id: sessionId,
@@ -2378,7 +2770,7 @@ async function ingestGemini(db, verbose) {
2378
2770
  timestamp,
2379
2771
  source_request_id: message.id ?? requestId,
2380
2772
  machine_id: machineId
2381
- });
2773
+ }, account));
2382
2774
  totalRequests++;
2383
2775
  }
2384
2776
  setIngestState(db, "gemini", stateKey, fileMtime);
@@ -2427,6 +2819,7 @@ async function ingestOpenCode(db, verbose = false) {
2427
2819
  const touched = new Set;
2428
2820
  const machineId = getMachineId();
2429
2821
  const now = new Date().toISOString();
2822
+ const account = await resolveAccountForAgent("opencode");
2430
2823
  for (const file of files) {
2431
2824
  const mtime = statSync4(file).mtimeMs;
2432
2825
  const stateKey = file;
@@ -2456,7 +2849,7 @@ async function ingestOpenCode(db, verbose = false) {
2456
2849
  const sourceId = file.replace(OPENCODE_STORAGE, "");
2457
2850
  const reqId = `opencode-${sourceId}`;
2458
2851
  const costUsd = usage.cost ?? computeCostFromDb(db, model, input, output, cacheRead, cacheWrite, 0);
2459
- upsertRequest(db, {
2852
+ upsertRequest(db, withAccount({
2460
2853
  id: reqId,
2461
2854
  agent: "opencode",
2462
2855
  session_id: sessionId,
@@ -2472,10 +2865,10 @@ async function ingestOpenCode(db, verbose = false) {
2472
2865
  source_request_id: sourceId,
2473
2866
  machine_id: machineId,
2474
2867
  updated_at: now
2475
- });
2868
+ }, account));
2476
2869
  requests++;
2477
2870
  if (!touched.has(sessionId)) {
2478
- upsertSession(db, {
2871
+ upsertSession(db, withAccount({
2479
2872
  id: sessionId,
2480
2873
  agent: "opencode",
2481
2874
  project_path: "",
@@ -2487,7 +2880,7 @@ async function ingestOpenCode(db, verbose = false) {
2487
2880
  request_count: 0,
2488
2881
  machine_id: machineId,
2489
2882
  updated_at: now
2490
- });
2883
+ }, account));
2491
2884
  touched.add(sessionId);
2492
2885
  }
2493
2886
  setIngestState(db, "opencode", stateKey, String(mtime));
@@ -2534,6 +2927,7 @@ async function ingestCursor(db, verbose = false) {
2534
2927
  const machineId = getMachineId();
2535
2928
  const now = new Date().toISOString();
2536
2929
  let snapshots = 0;
2930
+ const account = await resolveAccountForAgent("cursor");
2537
2931
  const usage = await cursorFetch("/api/usage", token);
2538
2932
  if (usage?.premiumRequests != null && usage.maxPremiumRequests) {
2539
2933
  upsertUsageSnapshot(db, {
@@ -2581,7 +2975,7 @@ async function ingestCursor(db, verbose = false) {
2581
2975
  }
2582
2976
  const sessionId = `cursor-${today}-${machineId}`;
2583
2977
  if (onDemand + included > 0) {
2584
- upsertSession(db, {
2978
+ upsertSession(db, withAccount({
2585
2979
  id: sessionId,
2586
2980
  agent: "cursor",
2587
2981
  project_path: "",
@@ -2593,8 +2987,8 @@ async function ingestCursor(db, verbose = false) {
2593
2987
  request_count: 1,
2594
2988
  machine_id: machineId,
2595
2989
  updated_at: now
2596
- });
2597
- upsertRequest(db, {
2990
+ }, account));
2991
+ upsertRequest(db, withAccount({
2598
2992
  id: `cursor-${today}-${machineId}-usage`,
2599
2993
  agent: "cursor",
2600
2994
  session_id: sessionId,
@@ -2610,7 +3004,7 @@ async function ingestCursor(db, verbose = false) {
2610
3004
  source_request_id: today,
2611
3005
  machine_id: machineId,
2612
3006
  updated_at: now
2613
- });
3007
+ }, account));
2614
3008
  rollupSession(db, sessionId);
2615
3009
  }
2616
3010
  setIngestState(db, "cursor", `sync-${today}`, now);
@@ -2643,6 +3037,7 @@ async function ingestPi(db, verbose = false) {
2643
3037
  const touched = new Set;
2644
3038
  const machineId = getMachineId();
2645
3039
  const now = new Date().toISOString();
3040
+ const account = await resolveAccountForAgent("pi");
2646
3041
  for (const file of files) {
2647
3042
  const mtime = statSync5(file).mtimeMs;
2648
3043
  const prev = getIngestState(db, "pi", file);
@@ -2668,7 +3063,7 @@ async function ingestPi(db, verbose = false) {
2668
3063
  const model = turn.model ?? turn.provider ?? "unknown";
2669
3064
  const timestamp = turn.timestamp ?? new Date(statSync5(file).mtime).toISOString();
2670
3065
  const reqId = `pi-${sessionId}-${i}`;
2671
- upsertRequest(db, {
3066
+ upsertRequest(db, withAccount({
2672
3067
  id: reqId,
2673
3068
  agent: "pi",
2674
3069
  session_id: sessionId,
@@ -2684,11 +3079,11 @@ async function ingestPi(db, verbose = false) {
2684
3079
  source_request_id: `${sessionId}-${i}`,
2685
3080
  machine_id: machineId,
2686
3081
  updated_at: now
2687
- });
3082
+ }, account));
2688
3083
  requests++;
2689
3084
  }
2690
3085
  if (turns.length > 0) {
2691
- upsertSession(db, {
3086
+ upsertSession(db, withAccount({
2692
3087
  id: sessionId,
2693
3088
  agent: "pi",
2694
3089
  project_path: "",
@@ -2700,7 +3095,7 @@ async function ingestPi(db, verbose = false) {
2700
3095
  request_count: 0,
2701
3096
  machine_id: machineId,
2702
3097
  updated_at: now
2703
- });
3098
+ }, account));
2704
3099
  touched.add(sessionId);
2705
3100
  }
2706
3101
  setIngestState(db, "pi", file, String(mtime));
@@ -2748,13 +3143,14 @@ async function ingestHermes(db, verbose = false) {
2748
3143
  const machineId = getMachineId();
2749
3144
  const now = new Date().toISOString();
2750
3145
  let requests = 0;
3146
+ const account = await resolveAccountForAgent("hermes");
2751
3147
  for (const row of rows) {
2752
3148
  const sessionId = `hermes-${row.id}`;
2753
3149
  const startedAt = new Date(row.started_at * 1000).toISOString();
2754
3150
  const endedAt = row.ended_at ? new Date(row.ended_at * 1000).toISOString() : null;
2755
3151
  const cost = row.actual_cost_usd ?? row.estimated_cost_usd ?? 0;
2756
3152
  const tokens = row.input_tokens + row.output_tokens + row.cache_read_tokens + row.cache_write_tokens + row.reasoning_tokens;
2757
- upsertSession(db, {
3153
+ upsertSession(db, withAccount({
2758
3154
  id: sessionId,
2759
3155
  agent: "hermes",
2760
3156
  project_path: row.source ?? "",
@@ -2766,9 +3162,9 @@ async function ingestHermes(db, verbose = false) {
2766
3162
  request_count: 1,
2767
3163
  machine_id: machineId,
2768
3164
  updated_at: now
2769
- });
3165
+ }, account));
2770
3166
  const reqId = `hermes-${row.id}-rollup`;
2771
- upsertRequest(db, {
3167
+ upsertRequest(db, withAccount({
2772
3168
  id: reqId,
2773
3169
  agent: "hermes",
2774
3170
  session_id: sessionId,
@@ -2784,7 +3180,7 @@ async function ingestHermes(db, verbose = false) {
2784
3180
  source_request_id: row.id,
2785
3181
  machine_id: machineId,
2786
3182
  updated_at: now
2787
- });
3183
+ }, account));
2788
3184
  requests++;
2789
3185
  rollupSession(db, sessionId);
2790
3186
  if (verbose)
@@ -2804,7 +3200,7 @@ function statSyncSafe(path) {
2804
3200
 
2805
3201
  // src/ingest/claude-quota.ts
2806
3202
  init_database();
2807
- import { existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
3203
+ import { existsSync as existsSync8, readFileSync as readFileSync6 } from "fs";
2808
3204
 
2809
3205
  // src/lib/paths.ts
2810
3206
  import { homedir as homedir8 } from "os";
@@ -2842,7 +3238,7 @@ function readClaudeToken() {
2842
3238
  if (!existsSync8(CREDENTIALS_PATH))
2843
3239
  return null;
2844
3240
  try {
2845
- const creds = JSON.parse(readFileSync7(CREDENTIALS_PATH, "utf-8"));
3241
+ const creds = JSON.parse(readFileSync6(CREDENTIALS_PATH, "utf-8"));
2846
3242
  const oauth = creds.claudeAiOauth;
2847
3243
  if (!oauth?.accessToken)
2848
3244
  return null;
@@ -2978,7 +3374,7 @@ async function ingestClaudeQuota(db, verbose = false) {
2978
3374
 
2979
3375
  // src/ingest/codex-quota.ts
2980
3376
  init_database();
2981
- import { existsSync as existsSync9, readFileSync as readFileSync8 } from "fs";
3377
+ import { existsSync as existsSync9, readFileSync as readFileSync7 } from "fs";
2982
3378
  var WHAM_USAGE_URL = process.env["CODEX_USAGE_URL"] ?? "https://chatgpt.com/backend-api/wham/usage";
2983
3379
  function readCodexAuth() {
2984
3380
  const fromEnv = process.env["CODEX_OAUTH_TOKEN"];
@@ -2988,7 +3384,7 @@ function readCodexAuth() {
2988
3384
  if (!existsSync9(authPath))
2989
3385
  return null;
2990
3386
  try {
2991
- const auth = JSON.parse(readFileSync8(authPath, "utf-8"));
3387
+ const auth = JSON.parse(readFileSync7(authPath, "utf-8"));
2992
3388
  const token = auth.tokens?.access_token;
2993
3389
  if (!token)
2994
3390
  return null;
@@ -3122,12 +3518,12 @@ init_database();
3122
3518
  init_database();
3123
3519
 
3124
3520
  // src/lib/package-metadata.ts
3125
- import { readFileSync as readFileSync9 } from "fs";
3521
+ import { readFileSync as readFileSync8 } from "fs";
3126
3522
  var cachedMetadata = null;
3127
3523
  function getPackageMetadata() {
3128
3524
  if (cachedMetadata)
3129
3525
  return cachedMetadata;
3130
- const raw = readFileSync9(new URL("../../package.json", import.meta.url), "utf8");
3526
+ const raw = readFileSync8(new URL("../../package.json", import.meta.url), "utf8");
3131
3527
  const parsed = JSON.parse(raw);
3132
3528
  cachedMetadata = {
3133
3529
  name: parsed.name ?? "@hasna/economy",
@@ -3179,44 +3575,27 @@ async function runCloudMigrations(cloud) {
3179
3575
  await cloud.run(sql);
3180
3576
  }
3181
3577
  }
3182
- function isCloudIncrementalEnabled() {
3183
- return process.env["ECONOMY_CLOUD_INCREMENTAL"] === "1" || process.env["ECONOMY_CLOUD_INCREMENTAL"] === "true";
3184
- }
3185
3578
  async function cloudPush(opts) {
3186
- const { syncPush, incrementalSyncPush, ensureSyncMetaTable, SqliteAdapter } = await import("@hasna/cloud");
3579
+ const { syncPush, SqliteAdapter } = await import("@hasna/cloud");
3187
3580
  const cloud = await getCloudPg();
3188
3581
  const local = new SqliteAdapter(getDbPath());
3189
3582
  await runCloudMigrations(cloud);
3190
3583
  const tables = opts?.tables ?? [...CLOUD_TABLES];
3191
- let rows = 0;
3192
- if (isCloudIncrementalEnabled()) {
3193
- ensureSyncMetaTable(local);
3194
- const results = incrementalSyncPush(local, cloud, tables, { conflictColumn: "updated_at" });
3195
- rows = results.reduce((s, r) => s + r.synced_rows, 0);
3196
- } else {
3197
- const results = await syncPush(local, cloud, { tables, conflictColumn: "updated_at" });
3198
- rows = results.reduce((s, r) => s + r.rowsWritten, 0);
3199
- }
3584
+ const results = await syncPush(local, cloud, { tables, conflictColumn: "updated_at" });
3585
+ const rows = results.reduce((s, r) => s + r.rowsWritten, 0);
3200
3586
  touchMachineRegistry(local, "push");
3201
3587
  local.close();
3202
3588
  await cloud.close();
3203
3589
  return { rows, machine: getMachineId() };
3204
3590
  }
3205
3591
  async function cloudPull(opts) {
3206
- const { syncPull, incrementalSyncPull, ensureSyncMetaTable, SqliteAdapter } = await import("@hasna/cloud");
3592
+ const { syncPull, SqliteAdapter } = await import("@hasna/cloud");
3207
3593
  const cloud = await getCloudPg();
3208
3594
  const local = new SqliteAdapter(getDbPath());
3209
3595
  await runCloudMigrations(cloud);
3210
3596
  const tables = opts?.tables ?? [...CLOUD_TABLES];
3211
- let rows = 0;
3212
- if (isCloudIncrementalEnabled()) {
3213
- ensureSyncMetaTable(local);
3214
- const results = incrementalSyncPull(cloud, local, tables, { conflictColumn: "updated_at" });
3215
- rows = results.reduce((s, r) => s + r.synced_rows, 0);
3216
- } else {
3217
- const results = await syncPull(cloud, local, { tables, conflictColumn: "updated_at" });
3218
- rows = results.reduce((s, r) => s + r.rowsWritten, 0);
3219
- }
3597
+ const results = await syncPull(cloud, local, { tables, conflictColumn: "updated_at" });
3598
+ const rows = results.reduce((s, r) => s + r.rowsWritten, 0);
3220
3599
  touchMachineRegistry(local, "pull");
3221
3600
  local.close();
3222
3601
  await cloud.close();
@@ -3526,6 +3905,7 @@ function createHandler(db) {
3526
3905
  const project = url.searchParams.get("project") ?? undefined;
3527
3906
  const search = url.searchParams.get("search") ?? undefined;
3528
3907
  const machine = url.searchParams.get("machine") ?? undefined;
3908
+ const account = url.searchParams.get("account") ?? undefined;
3529
3909
  const limit = Number(url.searchParams.get("limit") ?? 50);
3530
3910
  const offset = Number(url.searchParams.get("offset") ?? 0);
3531
3911
  const since = url.searchParams.get("since") ?? undefined;
@@ -3536,6 +3916,7 @@ function createHandler(db) {
3536
3916
  project,
3537
3917
  search,
3538
3918
  machine,
3919
+ account,
3539
3920
  limit,
3540
3921
  offset,
3541
3922
  since
@@ -3586,11 +3967,23 @@ function createHandler(db) {
3586
3967
  return ok(results);
3587
3968
  }
3588
3969
  if (path === "/api/projects" && method === "GET") {
3589
- return ok(queryProjectBreakdown(db));
3970
+ const period = url.searchParams.get("period") ?? "all";
3971
+ return ok(queryProjectBreakdown(db, period));
3972
+ }
3973
+ if (path === "/api/accounts" && method === "GET") {
3974
+ const period = url.searchParams.get("period") ?? "all";
3975
+ return ok(queryAccountBreakdown(db, period));
3590
3976
  }
3591
3977
  if (path === "/api/breakdown" && method === "GET") {
3592
3978
  const by = url.searchParams.get("by") ?? "model";
3593
- return ok(by === "project" ? queryProjectBreakdown(db) : queryModelBreakdown(db));
3979
+ const period = url.searchParams.get("period") ?? "all";
3980
+ if (by === "project")
3981
+ return ok(queryProjectBreakdown(db, period));
3982
+ if (by === "agent")
3983
+ return ok(queryAgentBreakdown(db, period));
3984
+ if (by === "account")
3985
+ return ok(queryAccountBreakdown(db, period));
3986
+ return ok(queryModelBreakdown(db));
3594
3987
  }
3595
3988
  if (path === "/api/budgets" && method === "GET") {
3596
3989
  return ok(getBudgetStatuses(db));
@@ -3725,6 +4118,50 @@ function createHandler(db) {
3725
4118
  const agent = url.searchParams.get("agent") ?? undefined;
3726
4119
  return ok(querySavingsSummary(db, period, agent && isAgent(agent) ? agent : undefined));
3727
4120
  }
4121
+ if (path === "/api/subscriptions" && method === "GET") {
4122
+ return ok(listSubscriptions(db));
4123
+ }
4124
+ if (path === "/api/subscriptions" && method === "POST") {
4125
+ const body = await jsonBody(req);
4126
+ if (!body)
4127
+ return err("invalid JSON body");
4128
+ const provider = optionalString(body["provider"])?.trim();
4129
+ const plan = optionalString(body["plan"])?.trim();
4130
+ if (!provider)
4131
+ return err("provider is required");
4132
+ if (!plan)
4133
+ return err("plan is required");
4134
+ const monthlyFee = finiteNumber(body["monthly_fee_usd"] ?? body["fee_usd"] ?? 0);
4135
+ const includedUsage = finiteNumber(body["included_usage_usd"] ?? 0);
4136
+ if (monthlyFee == null || monthlyFee < 0)
4137
+ return err("monthly_fee_usd must be a non-negative number");
4138
+ if (includedUsage == null || includedUsage < 0)
4139
+ return err("included_usage_usd must be a non-negative number");
4140
+ const agent = optionalAgent(body["agent"]);
4141
+ if (agent === undefined)
4142
+ return err(AGENT_ERROR);
4143
+ const now = new Date().toISOString();
4144
+ const subscription = {
4145
+ id: optionalString(body["id"])?.trim() || randomUUID(),
4146
+ agent,
4147
+ provider,
4148
+ plan,
4149
+ monthly_fee_usd: monthlyFee,
4150
+ included_usage_usd: includedUsage,
4151
+ billing_cycle_start: optionalString(body["billing_cycle_start"]),
4152
+ reset_policy: optionalString(body["reset_policy"]) ?? "monthly",
4153
+ active: body["active"] === false || body["active"] === 0 ? 0 : 1,
4154
+ created_at: optionalString(body["created_at"]) ?? now,
4155
+ updated_at: now
4156
+ };
4157
+ upsertSubscription(db, subscription);
4158
+ return ok(subscription);
4159
+ }
4160
+ const subscriptionMatch = path.match(/^\/api\/subscriptions\/(.+)$/);
4161
+ if (subscriptionMatch && method === "DELETE") {
4162
+ deleteSubscription(db, decodeURIComponent(subscriptionMatch[1]));
4163
+ return ok({ ok: true });
4164
+ }
3728
4165
  const sessionRequestsMatch = path.match(/^\/api\/sessions\/([^/]+)\/requests$/);
3729
4166
  if (sessionRequestsMatch && method === "GET") {
3730
4167
  const sessionId = decodeURIComponent(sessionRequestsMatch[1]);