@hasna/economy 0.2.27 → 0.2.29

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
@@ -534,6 +534,7 @@ __export(exports_database, {
534
534
  queryRequestsSince: () => queryRequestsSince,
535
535
  queryProjectBreakdown: () => queryProjectBreakdown,
536
536
  queryModelBreakdown: () => queryModelBreakdown,
537
+ queryHourlyBreakdown: () => queryHourlyBreakdown,
537
538
  queryDailyBreakdown: () => queryDailyBreakdown,
538
539
  queryBillingSummary: () => queryBillingSummary,
539
540
  queryAgentBreakdown: () => queryAgentBreakdown,
@@ -615,6 +616,26 @@ function openDatabase(dbPath, skipSeed = false) {
615
616
  }
616
617
  return db;
617
618
  }
619
+ function quoteSqlIdent(identifier) {
620
+ return `"${identifier.replace(/"/g, '""')}"`;
621
+ }
622
+ function hasColumn(db, table, column) {
623
+ const columns = db.prepare(`PRAGMA table_info(${quoteSqlIdent(table)})`).all();
624
+ return columns.some((c) => c.name === column);
625
+ }
626
+ function addColumnIfMissing(db, table, column, definition) {
627
+ if (hasColumn(db, table, column))
628
+ return false;
629
+ try {
630
+ db.exec(`ALTER TABLE ${quoteSqlIdent(table)} ADD COLUMN ${quoteSqlIdent(column)} ${definition}`);
631
+ return true;
632
+ } catch (error) {
633
+ const message = error instanceof Error ? error.message : String(error);
634
+ if (/duplicate column name/i.test(message))
635
+ return true;
636
+ throw error;
637
+ }
638
+ }
618
639
  function initSchema(db) {
619
640
  db.exec(`
620
641
  CREATE TABLE IF NOT EXISTS requests (
@@ -785,59 +806,31 @@ function initSchema(db) {
785
806
  CREATE INDEX IF NOT EXISTS idx_usage_agent_date ON usage_snapshots(agent, date);
786
807
  CREATE INDEX IF NOT EXISTS idx_savings_date ON savings_daily(date);
787
808
  `);
788
- const cols = db.prepare(`PRAGMA table_info(requests)`).all();
789
- if (!cols.some((c) => c.name === "machine_id")) {
790
- db.exec(`ALTER TABLE requests ADD COLUMN machine_id TEXT DEFAULT ''`);
791
- db.exec(`ALTER TABLE sessions ADD COLUMN machine_id TEXT DEFAULT ''`);
792
- }
793
- if (!cols.some((c) => c.name === "cache_create_5m_tokens")) {
794
- db.exec(`ALTER TABLE requests ADD COLUMN cache_create_5m_tokens INTEGER DEFAULT 0`);
809
+ addColumnIfMissing(db, "requests", "machine_id", `TEXT DEFAULT ''`);
810
+ addColumnIfMissing(db, "sessions", "machine_id", `TEXT DEFAULT ''`);
811
+ if (addColumnIfMissing(db, "requests", "cache_create_5m_tokens", "INTEGER DEFAULT 0")) {
795
812
  db.exec(`UPDATE requests SET cache_create_5m_tokens = cache_create_tokens WHERE cache_create_5m_tokens = 0`);
796
813
  }
797
- if (!cols.some((c) => c.name === "cache_create_1h_tokens")) {
798
- db.exec(`ALTER TABLE requests ADD COLUMN cache_create_1h_tokens INTEGER DEFAULT 0`);
799
- }
800
- if (!cols.some((c) => c.name === "cost_basis")) {
801
- db.exec(`ALTER TABLE requests ADD COLUMN cost_basis TEXT DEFAULT 'estimated'`);
802
- }
803
- if (!cols.some((c) => c.name === "attribution_tag")) {
804
- db.exec(`ALTER TABLE requests ADD COLUMN attribution_tag TEXT DEFAULT ''`);
805
- }
806
- if (!cols.some((c) => c.name === "updated_at")) {
807
- db.exec(`ALTER TABLE requests ADD COLUMN updated_at TEXT DEFAULT ''`);
814
+ addColumnIfMissing(db, "requests", "cache_create_1h_tokens", "INTEGER DEFAULT 0");
815
+ addColumnIfMissing(db, "requests", "cost_basis", `TEXT DEFAULT 'estimated'`);
816
+ addColumnIfMissing(db, "requests", "attribution_tag", `TEXT DEFAULT ''`);
817
+ if (addColumnIfMissing(db, "requests", "updated_at", `TEXT DEFAULT ''`)) {
808
818
  db.exec(`UPDATE requests SET updated_at = timestamp WHERE updated_at = '' OR updated_at IS NULL`);
809
819
  }
810
- if (!cols.some((c) => c.name === "synced_at")) {
811
- db.exec(`ALTER TABLE requests ADD COLUMN synced_at TEXT DEFAULT ''`);
812
- }
820
+ addColumnIfMissing(db, "requests", "synced_at", `TEXT DEFAULT ''`);
813
821
  for (const column of ["account_key", "account_tool", "account_name", "account_email", "account_source"]) {
814
- if (!cols.some((c) => c.name === column)) {
815
- db.exec(`ALTER TABLE requests ADD COLUMN ${column} TEXT DEFAULT ''`);
816
- }
822
+ addColumnIfMissing(db, "requests", column, `TEXT DEFAULT ''`);
817
823
  }
818
- const sessionCols = db.prepare(`PRAGMA table_info(sessions)`).all();
819
- if (!sessionCols.some((c) => c.name === "attribution_tag")) {
820
- db.exec(`ALTER TABLE sessions ADD COLUMN attribution_tag TEXT DEFAULT ''`);
821
- }
822
- if (!sessionCols.some((c) => c.name === "updated_at")) {
823
- db.exec(`ALTER TABLE sessions ADD COLUMN updated_at TEXT DEFAULT ''`);
824
+ addColumnIfMissing(db, "sessions", "attribution_tag", `TEXT DEFAULT ''`);
825
+ if (addColumnIfMissing(db, "sessions", "updated_at", `TEXT DEFAULT ''`)) {
824
826
  db.exec(`UPDATE sessions SET updated_at = started_at WHERE updated_at = '' OR updated_at IS NULL`);
825
827
  }
826
- if (!sessionCols.some((c) => c.name === "synced_at")) {
827
- db.exec(`ALTER TABLE sessions ADD COLUMN synced_at TEXT DEFAULT ''`);
828
- }
828
+ addColumnIfMissing(db, "sessions", "synced_at", `TEXT DEFAULT ''`);
829
829
  for (const column of ["account_key", "account_tool", "account_name", "account_email", "account_source"]) {
830
- if (!sessionCols.some((c) => c.name === column)) {
831
- db.exec(`ALTER TABLE sessions ADD COLUMN ${column} TEXT DEFAULT ''`);
832
- }
833
- }
834
- const pricingCols = db.prepare(`PRAGMA table_info(model_pricing)`).all();
835
- if (!pricingCols.some((c) => c.name === "cache_write_1h_per_1m")) {
836
- db.exec(`ALTER TABLE model_pricing ADD COLUMN cache_write_1h_per_1m REAL NOT NULL DEFAULT 0`);
837
- }
838
- if (!pricingCols.some((c) => c.name === "cache_storage_per_1m_hour")) {
839
- db.exec(`ALTER TABLE model_pricing ADD COLUMN cache_storage_per_1m_hour REAL NOT NULL DEFAULT 0`);
830
+ addColumnIfMissing(db, "sessions", column, `TEXT DEFAULT ''`);
840
831
  }
832
+ addColumnIfMissing(db, "model_pricing", "cache_write_1h_per_1m", "REAL NOT NULL DEFAULT 0");
833
+ addColumnIfMissing(db, "model_pricing", "cache_storage_per_1m_hour", "REAL NOT NULL DEFAULT 0");
841
834
  db.exec(`
842
835
  CREATE INDEX IF NOT EXISTS idx_requests_machine ON requests(machine_id);
843
836
  CREATE INDEX IF NOT EXISTS idx_sessions_machine ON sessions(machine_id);
@@ -998,17 +991,22 @@ function querySummary(db, period, machine, allMachines = false) {
998
991
  const codexTotals = db.prepare(`
999
992
  SELECT COALESCE(SUM(total_cost_usd), 0) as cost_usd,
1000
993
  COALESCE(SUM(total_tokens), 0) as tokens,
994
+ COALESCE(SUM(request_count), 0) as requests,
1001
995
  COUNT(*) as sessions
1002
996
  FROM sessions
1003
997
  WHERE ${sWhere}${machineClause}
1004
998
  AND id NOT IN (SELECT DISTINCT session_id FROM requests)
1005
999
  `).get();
1006
- const sessionCount = db.prepare(`SELECT COUNT(*) as sessions FROM sessions WHERE ${sWhere}${machineClause}`).get();
1000
+ const requestSessionCount = db.prepare(`
1001
+ SELECT COUNT(DISTINCT session_id) as sessions
1002
+ FROM requests
1003
+ WHERE ${rWhere}${machineClause}
1004
+ `).get();
1007
1005
  return {
1008
1006
  total_usd: r.total_usd + codexTotals.cost_usd,
1009
- requests: r.requests,
1007
+ requests: r.requests + codexTotals.requests,
1010
1008
  tokens: r.tokens + codexTotals.tokens,
1011
- sessions: sessionCount.sessions,
1009
+ sessions: requestSessionCount.sessions + codexTotals.sessions,
1012
1010
  period
1013
1011
  };
1014
1012
  }
@@ -1023,8 +1021,10 @@ function queryModelBreakdown(db) {
1023
1021
  FROM requests GROUP BY model, agent ORDER BY cost_usd DESC
1024
1022
  `).all();
1025
1023
  }
1026
- function queryAgentBreakdown(db, period = "all") {
1024
+ function queryAgentBreakdown(db, period = "all", machine) {
1027
1025
  const requestWhere = requestPeriodWhere(period);
1026
+ const machineClause = machine ? " AND machine_id = ?" : "";
1027
+ const machineParams = machine ? [machine] : [];
1028
1028
  const groups = new Map;
1029
1029
  const requestRows = db.prepare(`
1030
1030
  SELECT agent,
@@ -1040,10 +1040,10 @@ function queryAgentBreakdown(db, period = "all") {
1040
1040
  COALESCE(SUM(cost_usd), 0) as cost_usd,
1041
1041
  MAX(timestamp) as last_active
1042
1042
  FROM requests
1043
- WHERE ${requestWhere}
1043
+ WHERE ${requestWhere}${machineClause}
1044
1044
  GROUP BY agent
1045
1045
  ORDER BY api_equivalent_usd DESC
1046
- `).all();
1046
+ `).all(...machineParams);
1047
1047
  for (const row of requestRows) {
1048
1048
  groups.set(row.agent, row);
1049
1049
  }
@@ -1056,10 +1056,10 @@ function queryAgentBreakdown(db, period = "all") {
1056
1056
  COALESCE(SUM(total_cost_usd), 0) as cost_usd,
1057
1057
  MAX(started_at) as last_active
1058
1058
  FROM sessions
1059
- WHERE ${sessionWhere}
1059
+ WHERE ${sessionWhere}${machineClause}
1060
1060
  AND id NOT IN (SELECT DISTINCT session_id FROM requests)
1061
1061
  GROUP BY agent
1062
- `).all();
1062
+ `).all(...machineParams);
1063
1063
  for (const row of sessionOnlyRows) {
1064
1064
  const existing = groups.get(row.agent) ?? {
1065
1065
  agent: row.agent,
@@ -1136,14 +1136,20 @@ function labelForPath(projectPath, projectName) {
1136
1136
  function groupKeyForPath(projectPath, projectName) {
1137
1137
  return labelForPath(projectPath, projectName).trim().toLowerCase();
1138
1138
  }
1139
- function queryProjectBreakdown(db, period = "all") {
1139
+ function queryProjectBreakdown(db, period = "all", machine) {
1140
1140
  const requestWhere = requestPeriodWhere(period);
1141
1141
  const sessionWhere = sessionPeriodWhere(period);
1142
+ const sessionMachineClause = machine ? " AND (machine_id = ? OR id IN (SELECT DISTINCT session_id FROM requests WHERE machine_id = ?))" : "";
1143
+ const requestMachineClause = machine ? " AND machine_id = ?" : "";
1144
+ const sessionMachineParams = machine ? [machine, machine] : [];
1145
+ const requestMachineParams = machine ? [machine] : [];
1146
+ const sessionOnlyMachineClause = machine ? " AND machine_id = ?" : "";
1147
+ const sessionOnlyMachineParams = machine ? [machine] : [];
1142
1148
  const sessions = db.prepare(`
1143
1149
  SELECT id, project_path, project_name, total_cost_usd, started_at
1144
1150
  FROM sessions
1145
- WHERE project_path != '' OR project_name != ''
1146
- `).all();
1151
+ WHERE (project_path != '' OR project_name != '')${sessionMachineClause}
1152
+ `).all(...sessionMachineParams);
1147
1153
  const groups = new Map;
1148
1154
  for (const s of sessions) {
1149
1155
  const label = labelForPath(s.project_path, s.project_name);
@@ -1169,7 +1175,8 @@ function queryProjectBreakdown(db, period = "all") {
1169
1175
  FROM requests
1170
1176
  WHERE session_id IN (${placeholders})
1171
1177
  AND ${requestWhere}
1172
- `).get(...g.sessionIds) : { sessions: 0, requests: 0, cost_usd: 0, total_tokens: 0, last_active: null };
1178
+ ${requestMachineClause}
1179
+ `).get(...g.sessionIds, ...requestMachineParams) : { sessions: 0, requests: 0, cost_usd: 0, total_tokens: 0, last_active: null };
1173
1180
  const sessionOnlyStats = placeholders.length ? db.prepare(`
1174
1181
  SELECT
1175
1182
  COUNT(*) as sessions,
@@ -1180,8 +1187,9 @@ function queryProjectBreakdown(db, period = "all") {
1180
1187
  FROM sessions
1181
1188
  WHERE id IN (${placeholders})
1182
1189
  AND ${sessionWhere}
1190
+ ${sessionOnlyMachineClause}
1183
1191
  AND id NOT IN (SELECT DISTINCT session_id FROM requests)
1184
- `).get(...g.sessionIds) : { sessions: 0, requests: 0, total_tokens: 0, cost_usd: 0, last_active: null };
1192
+ `).get(...g.sessionIds, ...sessionOnlyMachineParams) : { sessions: 0, requests: 0, total_tokens: 0, cost_usd: 0, last_active: null };
1185
1193
  const totalSessions = reqStats.sessions + sessionOnlyStats.sessions;
1186
1194
  if (totalSessions === 0)
1187
1195
  continue;
@@ -1199,107 +1207,167 @@ function queryProjectBreakdown(db, period = "all") {
1199
1207
  result.sort((a, b) => b.cost_usd - a.cost_usd);
1200
1208
  return result;
1201
1209
  }
1202
- function queryAccountBreakdown(db, period = "all") {
1210
+ function normalizeAccountEmail(email) {
1211
+ return (email ?? "").trim().toLowerCase();
1212
+ }
1213
+ function accountIdentityKey(agent, accountKey, accountName, accountEmail) {
1214
+ const identityAgent = (agent || "").trim();
1215
+ const normalizedEmail = normalizeAccountEmail(accountEmail);
1216
+ if (identityAgent && normalizedEmail)
1217
+ return `${identityAgent}:${normalizedEmail}`;
1218
+ if (identityAgent && accountName)
1219
+ return `${identityAgent}:${accountName}`;
1220
+ if (accountKey)
1221
+ return accountKey;
1222
+ return identityAgent ? `${identityAgent}:unknown` : "";
1223
+ }
1224
+ function addAccountBreakdownRow(groups, row, sessionOnly) {
1225
+ const agent = row.agent || row.account_tool;
1226
+ const email = normalizeAccountEmail(row.account_email);
1227
+ const accountName = row.account_name || email || row.account_key;
1228
+ const key = accountIdentityKey(agent, row.account_key, accountName, email);
1229
+ if (!key)
1230
+ return;
1231
+ const group = groups.get(key) ?? {
1232
+ account_key: key,
1233
+ account_tool: agent,
1234
+ account_name: accountName,
1235
+ account_email: email || null,
1236
+ account_source: row.account_source || "unknown",
1237
+ sessionIds: new Set,
1238
+ requests: 0,
1239
+ total_tokens: 0,
1240
+ api_equivalent_usd: 0,
1241
+ metered_api_usd: 0,
1242
+ subscription_included_usd: 0,
1243
+ estimated_usd: 0,
1244
+ unknown_usd: 0,
1245
+ last_active: ""
1246
+ };
1247
+ if (!group.account_email && email)
1248
+ group.account_email = email;
1249
+ if (!group.account_name && accountName)
1250
+ group.account_name = accountName;
1251
+ if ((!group.account_source || group.account_source === "unknown") && row.account_source && row.account_source !== "unknown") {
1252
+ group.account_source = row.account_source;
1253
+ }
1254
+ if (row.session_id)
1255
+ group.sessionIds.add(row.session_id);
1256
+ group.requests += row.requests;
1257
+ group.total_tokens += row.total_tokens;
1258
+ group.api_equivalent_usd += row.cost_usd;
1259
+ if (sessionOnly) {
1260
+ group.estimated_usd += row.cost_usd;
1261
+ } else if (row.cost_basis === "metered_api") {
1262
+ group.metered_api_usd += row.cost_usd;
1263
+ } else if (row.cost_basis === "subscription_included") {
1264
+ group.subscription_included_usd += row.cost_usd;
1265
+ } else if (row.cost_basis === "unknown") {
1266
+ group.unknown_usd += row.cost_usd;
1267
+ } else {
1268
+ group.estimated_usd += row.cost_usd;
1269
+ }
1270
+ if (!group.last_active || row.last_active > group.last_active)
1271
+ group.last_active = row.last_active;
1272
+ groups.set(key, group);
1273
+ }
1274
+ function queryAccountBreakdown(db, period = "all", machine) {
1203
1275
  const requestWhere = requestPeriodWhere(period);
1204
1276
  const sessionWhere = sessionPeriodWhere(period);
1205
- const sessions = db.prepare(`
1206
- SELECT id, account_key, account_tool, account_name, account_email, account_source,
1207
- total_cost_usd, total_tokens, request_count, started_at
1208
- FROM sessions
1209
- WHERE account_key != '' OR account_tool != '' OR account_name != '' OR account_email != ''
1210
- `).all();
1277
+ const requestMachineClause = machine ? " AND r.machine_id = ?" : "";
1278
+ const sessionMachineClause = machine ? " AND s.machine_id = ?" : "";
1279
+ const requestMachineParams = machine ? [machine] : [];
1280
+ const sessionMachineParams = machine ? [machine] : [];
1211
1281
  const groups = new Map;
1212
- for (const session of sessions) {
1213
- const key = session.account_key || `${session.account_tool}:${session.account_name}`;
1214
- if (!key || key === ":")
1215
- continue;
1216
- const group = groups.get(key) ?? {
1217
- sessionIds: [],
1218
- account_tool: session.account_tool,
1219
- account_name: session.account_name,
1220
- account_email: session.account_email || null,
1221
- account_source: session.account_source || "unknown"
1222
- };
1223
- group.sessionIds.push(session.id);
1224
- groups.set(key, group);
1225
- }
1226
- const result = [];
1227
- for (const [key, group] of groups.entries()) {
1228
- const placeholders = group.sessionIds.map(() => "?").join(",");
1229
- const reqStats = placeholders ? db.prepare(`
1230
- SELECT
1231
- COUNT(DISTINCT session_id) as sessions,
1232
- COUNT(*) as requests,
1233
- COALESCE(SUM(cost_usd), 0) as cost_usd,
1234
- COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_create_tokens), 0) as total_tokens,
1235
- COALESCE(SUM(CASE WHEN cost_basis = 'metered_api' THEN cost_usd ELSE 0 END), 0) as metered_api_usd,
1236
- COALESCE(SUM(CASE WHEN cost_basis = 'subscription_included' THEN cost_usd ELSE 0 END), 0) as subscription_included_usd,
1237
- COALESCE(SUM(CASE WHEN COALESCE(cost_basis, 'estimated') = 'estimated' THEN cost_usd ELSE 0 END), 0) as estimated_usd,
1238
- COALESCE(SUM(CASE WHEN cost_basis = 'unknown' THEN cost_usd ELSE 0 END), 0) as unknown_usd,
1239
- MAX(timestamp) as last_active
1240
- FROM requests
1241
- WHERE session_id IN (${placeholders})
1242
- AND ${requestWhere}
1243
- `).get(...group.sessionIds) : {
1244
- sessions: 0,
1245
- requests: 0,
1246
- cost_usd: 0,
1247
- total_tokens: 0,
1248
- metered_api_usd: 0,
1249
- subscription_included_usd: 0,
1250
- estimated_usd: 0,
1251
- unknown_usd: 0,
1252
- last_active: null
1253
- };
1254
- const sessionOnlyStats = placeholders ? db.prepare(`
1255
- SELECT
1256
- COUNT(*) as sessions,
1257
- COALESCE(SUM(request_count), 0) as requests,
1258
- COALESCE(SUM(total_tokens), 0) as total_tokens,
1259
- COALESCE(SUM(total_cost_usd), 0) as cost_usd,
1260
- MAX(started_at) as last_active
1261
- FROM sessions
1262
- WHERE id IN (${placeholders})
1263
- AND ${sessionWhere}
1264
- AND id NOT IN (SELECT DISTINCT session_id FROM requests)
1265
- `).get(...group.sessionIds) : { sessions: 0, requests: 0, total_tokens: 0, cost_usd: 0, last_active: null };
1266
- const sessionsTotal = reqStats.sessions + sessionOnlyStats.sessions;
1267
- if (sessionsTotal === 0)
1268
- continue;
1269
- const apiEquivalentUsd = reqStats.cost_usd + sessionOnlyStats.cost_usd;
1270
- const estimatedUsd = reqStats.estimated_usd + sessionOnlyStats.cost_usd;
1271
- const billableUsd = reqStats.metered_api_usd;
1272
- const lastActive = [reqStats.last_active, sessionOnlyStats.last_active].filter(Boolean).sort().at(-1) ?? "";
1273
- result.push({
1274
- account_key: key,
1275
- account_tool: group.account_tool,
1276
- account_name: group.account_name,
1277
- account_email: group.account_email,
1278
- account_source: group.account_source,
1279
- sessions: sessionsTotal,
1280
- requests: reqStats.requests + sessionOnlyStats.requests,
1281
- total_tokens: reqStats.total_tokens + sessionOnlyStats.total_tokens,
1282
- api_equivalent_usd: apiEquivalentUsd,
1283
- billable_usd: billableUsd,
1284
- metered_api_usd: reqStats.metered_api_usd,
1285
- subscription_included_usd: reqStats.subscription_included_usd,
1286
- estimated_usd: estimatedUsd,
1287
- unknown_usd: reqStats.unknown_usd,
1288
- cost_usd: apiEquivalentUsd,
1289
- last_active: lastActive
1290
- });
1291
- }
1282
+ const requestRows = db.prepare(`
1283
+ SELECT
1284
+ r.session_id as session_id,
1285
+ COALESCE(NULLIF(r.agent, ''), NULLIF(s.agent, ''), '') as agent,
1286
+ COALESCE(NULLIF(r.account_key, ''), NULLIF(s.account_key, ''), '') as account_key,
1287
+ COALESCE(NULLIF(r.account_tool, ''), NULLIF(s.account_tool, ''), '') as account_tool,
1288
+ COALESCE(NULLIF(r.account_name, ''), NULLIF(s.account_name, ''), '') as account_name,
1289
+ COALESCE(NULLIF(r.account_email, ''), NULLIF(s.account_email, ''), '') as account_email,
1290
+ COALESCE(NULLIF(r.account_source, ''), NULLIF(s.account_source, ''), 'unknown') as account_source,
1291
+ 1 as requests,
1292
+ COALESCE(r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_create_tokens, 0) as total_tokens,
1293
+ COALESCE(r.cost_usd, 0) as cost_usd,
1294
+ COALESCE(NULLIF(r.cost_basis, ''), 'estimated') as cost_basis,
1295
+ r.timestamp as last_active
1296
+ FROM requests r
1297
+ LEFT JOIN sessions s ON s.id = r.session_id
1298
+ WHERE ${requestWhere}${requestMachineClause}
1299
+ AND (
1300
+ COALESCE(NULLIF(r.account_key, ''), NULLIF(s.account_key, ''), '') != ''
1301
+ OR COALESCE(NULLIF(r.account_tool, ''), NULLIF(s.account_tool, ''), '') != ''
1302
+ OR COALESCE(NULLIF(r.account_name, ''), NULLIF(s.account_name, ''), '') != ''
1303
+ OR COALESCE(NULLIF(r.account_email, ''), NULLIF(s.account_email, ''), '') != ''
1304
+ )
1305
+ `).all(...requestMachineParams);
1306
+ for (const row of requestRows)
1307
+ addAccountBreakdownRow(groups, row, false);
1308
+ const sessionOnlyRows = db.prepare(`
1309
+ SELECT
1310
+ s.id as session_id,
1311
+ s.agent as agent,
1312
+ s.account_key as account_key,
1313
+ s.account_tool as account_tool,
1314
+ s.account_name as account_name,
1315
+ s.account_email as account_email,
1316
+ COALESCE(NULLIF(s.account_source, ''), 'unknown') as account_source,
1317
+ COALESCE(s.request_count, 0) as requests,
1318
+ COALESCE(s.total_tokens, 0) as total_tokens,
1319
+ COALESCE(s.total_cost_usd, 0) as cost_usd,
1320
+ 'estimated' as cost_basis,
1321
+ s.started_at as last_active
1322
+ FROM sessions s
1323
+ WHERE ${sessionWhere}${sessionMachineClause}
1324
+ AND s.id NOT IN (SELECT DISTINCT session_id FROM requests)
1325
+ AND (s.account_key != '' OR s.account_tool != '' OR s.account_name != '' OR s.account_email != '')
1326
+ `).all(...sessionMachineParams);
1327
+ for (const row of sessionOnlyRows)
1328
+ addAccountBreakdownRow(groups, row, true);
1329
+ const result = [...groups.values()].map((group) => ({
1330
+ account_key: group.account_key,
1331
+ account_tool: group.account_tool,
1332
+ account_name: group.account_name,
1333
+ account_email: group.account_email,
1334
+ account_source: group.account_source,
1335
+ sessions: group.sessionIds.size,
1336
+ requests: group.requests,
1337
+ total_tokens: group.total_tokens,
1338
+ api_equivalent_usd: group.api_equivalent_usd,
1339
+ billable_usd: group.metered_api_usd,
1340
+ metered_api_usd: group.metered_api_usd,
1341
+ subscription_included_usd: group.subscription_included_usd,
1342
+ estimated_usd: group.estimated_usd,
1343
+ unknown_usd: group.unknown_usd,
1344
+ cost_usd: group.api_equivalent_usd,
1345
+ last_active: group.last_active
1346
+ }));
1292
1347
  result.sort((a, b) => b.cost_usd - a.cost_usd);
1293
1348
  return result;
1294
1349
  }
1295
- function queryDailyBreakdown(db, days = 30) {
1350
+ function queryDailyBreakdown(db, days = 30, machine) {
1351
+ const machineClause = machine ? " AND machine_id = ?" : "";
1352
+ const params = machine ? [`-${days}`, machine] : [`-${days}`];
1296
1353
  return db.prepare(`
1297
1354
  SELECT DATE(timestamp) as date, agent, COALESCE(SUM(cost_usd), 0) as cost_usd
1298
1355
  FROM requests
1299
- WHERE timestamp >= DATE('now', ? || ' days')
1356
+ WHERE timestamp >= DATE('now', ? || ' days')${machineClause}
1300
1357
  GROUP BY DATE(timestamp), agent
1301
1358
  ORDER BY date ASC
1302
- `).all(`-${days}`);
1359
+ `).all(...params);
1360
+ }
1361
+ function queryHourlyBreakdown(db, machine) {
1362
+ const machineClause = machine ? " AND machine_id = ?" : "";
1363
+ const params = machine ? [machine] : [];
1364
+ return db.prepare(`
1365
+ SELECT STRFTIME('%H', timestamp) as hour, agent, COALESCE(SUM(cost_usd), 0) as cost_usd
1366
+ FROM requests
1367
+ WHERE DATE(timestamp) = DATE('now')${machineClause}
1368
+ GROUP BY STRFTIME('%H', timestamp), agent
1369
+ ORDER BY hour ASC
1370
+ `).all(...params);
1303
1371
  }
1304
1372
  function upsertProject(db, project) {
1305
1373
  db.prepare(`
@@ -1428,17 +1496,48 @@ function queryBillingSummary(db, period) {
1428
1496
  }
1429
1497
  return { total_usd: total, by_provider };
1430
1498
  }
1431
- function listMachines(db) {
1499
+ function listMachines(db, period = "all") {
1500
+ const rWhere = requestPeriodWhere(period);
1501
+ const sWhere = sessionPeriodWhere(period);
1432
1502
  return db.prepare(`
1503
+ WITH request_stats AS (
1504
+ SELECT
1505
+ machine_id,
1506
+ COUNT(DISTINCT session_id) as sessions,
1507
+ COUNT(*) as requests,
1508
+ COALESCE(SUM(cost_usd), 0) as total_cost_usd,
1509
+ MAX(timestamp) as last_active
1510
+ FROM requests
1511
+ WHERE machine_id != ''
1512
+ AND ${rWhere}
1513
+ GROUP BY machine_id
1514
+ ),
1515
+ session_only_stats AS (
1516
+ SELECT
1517
+ machine_id,
1518
+ COUNT(*) as sessions,
1519
+ COALESCE(SUM(request_count), 0) as requests,
1520
+ COALESCE(SUM(total_cost_usd), 0) as total_cost_usd,
1521
+ MAX(started_at) as last_active
1522
+ FROM sessions
1523
+ WHERE machine_id != ''
1524
+ AND ${sWhere}
1525
+ AND id NOT IN (SELECT DISTINCT session_id FROM requests)
1526
+ GROUP BY machine_id
1527
+ ),
1528
+ combined AS (
1529
+ SELECT * FROM request_stats
1530
+ UNION ALL
1531
+ SELECT * FROM session_only_stats
1532
+ )
1433
1533
  SELECT
1434
- s.machine_id,
1435
- COUNT(DISTINCT s.id) as sessions,
1436
- COALESCE((SELECT COUNT(*) FROM requests r WHERE r.machine_id = s.machine_id), 0) as requests,
1437
- COALESCE(SUM(s.total_cost_usd), 0) as total_cost_usd,
1438
- MAX(s.started_at) as last_active
1439
- FROM sessions s
1440
- WHERE s.machine_id != ''
1441
- GROUP BY s.machine_id
1534
+ machine_id,
1535
+ COALESCE(SUM(sessions), 0) as sessions,
1536
+ COALESCE(SUM(requests), 0) as requests,
1537
+ COALESCE(SUM(total_cost_usd), 0) as total_cost_usd,
1538
+ MAX(last_active) as last_active
1539
+ FROM combined
1540
+ GROUP BY machine_id
1442
1541
  ORDER BY total_cost_usd DESC
1443
1542
  `).all();
1444
1543
  }
@@ -2331,18 +2430,22 @@ function agentPaths() {
2331
2430
  var init_paths = () => {};
2332
2431
 
2333
2432
  // src/lib/accounts.ts
2334
- function accountKey(tool, name) {
2335
- return `${tool}:${name}`;
2433
+ function normalizeEmail(email) {
2434
+ return (email ?? "").trim().toLowerCase();
2435
+ }
2436
+ function accountKey(tool, name, email) {
2437
+ const normalizedEmail = normalizeEmail(email);
2438
+ return `${tool}:${normalizedEmail || name}`;
2336
2439
  }
2337
2440
  function normalizeDir(value) {
2338
2441
  return value.replace(/\/+$/, "");
2339
2442
  }
2340
2443
  function fromProfile(profile, source) {
2341
2444
  return {
2342
- account_key: accountKey(profile.tool, profile.name),
2445
+ account_key: accountKey(profile.tool, profile.name, profile.email),
2343
2446
  account_tool: profile.tool,
2344
2447
  account_name: profile.name,
2345
- ...profile.email ? { account_email: profile.email } : {},
2448
+ ...profile.email ? { account_email: normalizeEmail(profile.email) } : {},
2346
2449
  account_source: source
2347
2450
  };
2348
2451
  }
@@ -2354,10 +2457,12 @@ function fromOverride(raw, agent) {
2354
2457
  const [tool, name] = value.includes(":") ? value.split(":", 2) : [candidateTool, value];
2355
2458
  if (!tool || !name)
2356
2459
  return null;
2460
+ const email = name.includes("@") ? normalizeEmail(name) : undefined;
2357
2461
  return {
2358
- account_key: accountKey(tool, name),
2462
+ account_key: accountKey(tool, name, email),
2359
2463
  account_tool: tool,
2360
2464
  account_name: name,
2465
+ ...email ? { account_email: email } : {},
2361
2466
  account_source: "override"
2362
2467
  };
2363
2468
  }
@@ -2370,11 +2475,12 @@ function envOverride(agent, env) {
2370
2475
  const name = env[`ECONOMY_${agentPrefix}_ACCOUNT_NAME`] ?? env["ECONOMY_ACCOUNT_NAME"];
2371
2476
  if (!tool || !name)
2372
2477
  return null;
2478
+ const email = normalizeEmail(env[`ECONOMY_${agentPrefix}_ACCOUNT_EMAIL`] ?? env["ECONOMY_ACCOUNT_EMAIL"]);
2373
2479
  return {
2374
- account_key: accountKey(tool, name),
2480
+ account_key: accountKey(tool, name, email),
2375
2481
  account_tool: tool,
2376
2482
  account_name: name,
2377
- account_email: env[`ECONOMY_${agentPrefix}_ACCOUNT_EMAIL`] ?? env["ECONOMY_ACCOUNT_EMAIL"],
2483
+ ...email ? { account_email: email } : {},
2378
2484
  account_source: "override"
2379
2485
  };
2380
2486
  }
@@ -4513,16 +4619,22 @@ function createHandler(db) {
4513
4619
  }
4514
4620
  if (path === "/api/fleet" && method === "GET") {
4515
4621
  const period = url.searchParams.get("period") ?? "month";
4622
+ const machine = url.searchParams.get("machine") ?? undefined;
4516
4623
  return ok({
4517
- summary: querySummary(db, period, undefined, true),
4518
- machines: listMachines(db),
4624
+ summary: querySummary(db, period, machine),
4625
+ machines: listMachines(db, period),
4519
4626
  registry: listMachineRegistry(db),
4520
4627
  current_machine: getMachineId()
4521
4628
  });
4522
4629
  }
4523
4630
  if (path === "/api/daily" && method === "GET") {
4524
4631
  const days = Number(url.searchParams.get("days") ?? 30);
4525
- return ok(queryDailyBreakdown(db, days));
4632
+ const machine = url.searchParams.get("machine") ?? undefined;
4633
+ return ok(queryDailyBreakdown(db, days, machine));
4634
+ }
4635
+ if (path === "/api/hourly" && method === "GET") {
4636
+ const machine = url.searchParams.get("machine") ?? undefined;
4637
+ return ok(queryHourlyBreakdown(db, machine));
4526
4638
  }
4527
4639
  if (path === "/api/sessions" && method === "GET") {
4528
4640
  const agent = url.searchParams.get("agent");
@@ -4592,21 +4704,24 @@ function createHandler(db) {
4592
4704
  }
4593
4705
  if (path === "/api/projects" && method === "GET") {
4594
4706
  const period = url.searchParams.get("period") ?? "all";
4595
- return ok(queryProjectBreakdown(db, period));
4707
+ const machine = url.searchParams.get("machine") ?? undefined;
4708
+ return ok(queryProjectBreakdown(db, period, machine));
4596
4709
  }
4597
4710
  if (path === "/api/accounts" && method === "GET") {
4598
4711
  const period = url.searchParams.get("period") ?? "all";
4599
- return ok(queryAccountBreakdown(db, period));
4712
+ const machine = url.searchParams.get("machine") ?? undefined;
4713
+ return ok(queryAccountBreakdown(db, period, machine));
4600
4714
  }
4601
4715
  if (path === "/api/breakdown" && method === "GET") {
4602
4716
  const by = url.searchParams.get("by") ?? "model";
4603
4717
  const period = url.searchParams.get("period") ?? "all";
4718
+ const machine = url.searchParams.get("machine") ?? undefined;
4604
4719
  if (by === "project")
4605
- return ok(queryProjectBreakdown(db, period));
4720
+ return ok(queryProjectBreakdown(db, period, machine));
4606
4721
  if (by === "agent")
4607
- return ok(queryAgentBreakdown(db, period));
4722
+ return ok(queryAgentBreakdown(db, period, machine));
4608
4723
  if (by === "account")
4609
- return ok(queryAccountBreakdown(db, period));
4724
+ return ok(queryAccountBreakdown(db, period, machine));
4610
4725
  return ok(queryModelBreakdown(db));
4611
4726
  }
4612
4727
  if (path === "/api/budgets" && method === "GET") {
@@ -6217,6 +6332,8 @@ var TOP_LEVEL = [
6217
6332
  "doctor",
6218
6333
  "init",
6219
6334
  "estimate",
6335
+ "accounts",
6336
+ "breakdown",
6220
6337
  "fleet",
6221
6338
  "merge-db",
6222
6339
  "todos",
@@ -6548,7 +6665,7 @@ function registerFleetCommands(program) {
6548
6665
  const db = openDatabase();
6549
6666
  const period = parsePeriod(opts.period, "today");
6550
6667
  const summary = querySummary(db, period, undefined, true);
6551
- const machines = listMachines(db);
6668
+ const machines = listMachines(db, period);
6552
6669
  const registry = listMachineRegistry(db);
6553
6670
  if (opts.json) {
6554
6671
  console.log(JSON.stringify({ period, summary, machines, registry }, null, 2));
@@ -6987,6 +7104,22 @@ function printTable(headers, rows) {
6987
7104
  }
6988
7105
  console.log(`\u2514${sep2.replace(/\u253C/g, "\u2534")}\u2518`);
6989
7106
  }
7107
+ function accountDisplayName(row) {
7108
+ return row.account_email || row.account_name || row.account_key || "unknown";
7109
+ }
7110
+ function printAccountBreakdown(rows) {
7111
+ printTable(["Account", "Agent", "Source", "Sessions", "Requests", "Tokens", "API Eq", "Billable", "Included"], rows.map((r) => [
7112
+ chalk7.white(accountDisplayName(r)),
7113
+ fmtAgent(r.account_tool),
7114
+ chalk7.dim(r.account_source || "unknown"),
7115
+ String(r.sessions),
7116
+ String(r.requests),
7117
+ chalk7.cyan(fmtTokens(r.total_tokens)),
7118
+ fmt4(r.api_equivalent_usd),
7119
+ fmt4(r.billable_usd),
7120
+ fmt4(r.subscription_included_usd)
7121
+ ]));
7122
+ }
6990
7123
  function parseSinceDate(since) {
6991
7124
  const relMatch = since.match(/^(\d+)d$/);
6992
7125
  if (relMatch) {
@@ -7246,29 +7379,106 @@ program.command("breakdown").description("Cost breakdown by model, agent, projec
7246
7379
  ]));
7247
7380
  } else if (opts.by === "account") {
7248
7381
  const rows = sinceDate ? db.prepare(`
7249
- SELECT account_key, account_tool, account_name, account_email, account_source,
7382
+ WITH request_rows AS (
7383
+ SELECT
7384
+ r.session_id as session_id,
7385
+ COALESCE(NULLIF(r.agent, ''), NULLIF(s.agent, ''), NULLIF(r.account_tool, ''), NULLIF(s.account_tool, ''), 'unknown') as account_agent,
7386
+ COALESCE(NULLIF(r.account_key, ''), NULLIF(s.account_key, ''), '') as raw_account_key,
7387
+ COALESCE(NULLIF(r.account_name, ''), NULLIF(s.account_name, ''), '') as raw_account_name,
7388
+ LOWER(TRIM(COALESCE(NULLIF(r.account_email, ''), NULLIF(s.account_email, ''), ''))) as raw_account_email,
7389
+ COALESCE(NULLIF(r.account_source, ''), NULLIF(s.account_source, ''), 'unknown') as account_source,
7390
+ 1 as requests,
7391
+ COALESCE(r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_create_tokens, 0) as total_tokens,
7392
+ COALESCE(r.cost_usd, 0) as cost_usd,
7393
+ COALESCE(NULLIF(r.cost_basis, ''), 'estimated') as cost_basis,
7394
+ r.timestamp as last_active
7395
+ FROM requests r
7396
+ LEFT JOIN sessions s ON s.id = r.session_id
7397
+ WHERE r.timestamp >= ?
7398
+ AND (
7399
+ COALESCE(NULLIF(r.account_key, ''), NULLIF(s.account_key, ''), '') != ''
7400
+ OR COALESCE(NULLIF(r.account_tool, ''), NULLIF(s.account_tool, ''), '') != ''
7401
+ OR COALESCE(NULLIF(r.account_name, ''), NULLIF(s.account_name, ''), '') != ''
7402
+ OR COALESCE(NULLIF(r.account_email, ''), NULLIF(s.account_email, ''), '') != ''
7403
+ )
7404
+ ),
7405
+ session_only_rows AS (
7406
+ SELECT
7407
+ s.id as session_id,
7408
+ COALESCE(NULLIF(s.agent, ''), NULLIF(s.account_tool, ''), 'unknown') as account_agent,
7409
+ s.account_key as raw_account_key,
7410
+ s.account_name as raw_account_name,
7411
+ LOWER(TRIM(COALESCE(s.account_email, ''))) as raw_account_email,
7412
+ COALESCE(NULLIF(s.account_source, ''), 'unknown') as account_source,
7413
+ COALESCE(s.request_count, 0) as requests,
7414
+ COALESCE(s.total_tokens, 0) as total_tokens,
7415
+ COALESCE(s.total_cost_usd, 0) as cost_usd,
7416
+ 'estimated' as cost_basis,
7417
+ s.started_at as last_active
7418
+ FROM sessions s
7419
+ WHERE s.started_at >= ?
7420
+ AND s.id NOT IN (SELECT DISTINCT session_id FROM requests)
7421
+ AND (s.account_key != '' OR s.account_tool != '' OR s.account_name != '' OR s.account_email != '')
7422
+ ),
7423
+ normalized AS (
7424
+ SELECT
7425
+ CASE
7426
+ WHEN raw_account_email != '' THEN account_agent || ':' || raw_account_email
7427
+ WHEN raw_account_name != '' THEN account_agent || ':' || raw_account_name
7428
+ ELSE raw_account_key
7429
+ END as account_key,
7430
+ account_agent as account_tool,
7431
+ raw_account_name as account_name,
7432
+ raw_account_email as account_email,
7433
+ account_source,
7434
+ session_id,
7435
+ requests,
7436
+ total_tokens,
7437
+ cost_usd,
7438
+ cost_basis,
7439
+ last_active
7440
+ FROM request_rows
7441
+ UNION ALL
7442
+ SELECT
7443
+ CASE
7444
+ WHEN raw_account_email != '' THEN account_agent || ':' || raw_account_email
7445
+ WHEN raw_account_name != '' THEN account_agent || ':' || raw_account_name
7446
+ ELSE raw_account_key
7447
+ END as account_key,
7448
+ account_agent as account_tool,
7449
+ raw_account_name as account_name,
7450
+ raw_account_email as account_email,
7451
+ account_source,
7452
+ session_id,
7453
+ requests,
7454
+ total_tokens,
7455
+ cost_usd,
7456
+ cost_basis,
7457
+ last_active
7458
+ FROM session_only_rows
7459
+ )
7460
+ SELECT account_key,
7461
+ account_tool,
7462
+ COALESCE(MAX(NULLIF(account_name, '')), MAX(NULLIF(account_email, '')), account_key) as account_name,
7463
+ NULLIF(account_email, '') as account_email,
7464
+ COALESCE(MAX(NULLIF(account_source, 'unknown')), 'unknown') as account_source,
7250
7465
  COUNT(DISTINCT session_id) as sessions,
7251
- COUNT(*) as requests,
7252
- COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_create_tokens), 0) as total_tokens,
7466
+ COALESCE(SUM(requests), 0) as requests,
7467
+ COALESCE(SUM(total_tokens), 0) as total_tokens,
7253
7468
  COALESCE(SUM(cost_usd), 0) as api_equivalent_usd,
7469
+ COALESCE(SUM(CASE WHEN cost_basis = 'metered_api' THEN cost_usd ELSE 0 END), 0) as billable_usd,
7254
7470
  COALESCE(SUM(CASE WHEN cost_basis = 'metered_api' THEN cost_usd ELSE 0 END), 0) as metered_api_usd,
7255
7471
  COALESCE(SUM(CASE WHEN cost_basis = 'subscription_included' THEN cost_usd ELSE 0 END), 0) as subscription_included_usd,
7256
- MAX(timestamp) as last_active
7257
- FROM requests
7258
- WHERE timestamp >= ?
7259
- AND (account_key != '' OR account_tool != '' OR account_name != '' OR account_email != '')
7260
- GROUP BY account_key, account_tool, account_name, account_email, account_source
7472
+ COALESCE(SUM(CASE WHEN cost_basis NOT IN ('metered_api', 'subscription_included', 'unknown') THEN cost_usd ELSE 0 END), 0) as estimated_usd,
7473
+ COALESCE(SUM(CASE WHEN cost_basis = 'unknown' THEN cost_usd ELSE 0 END), 0) as unknown_usd,
7474
+ COALESCE(SUM(cost_usd), 0) as cost_usd,
7475
+ MAX(last_active) as last_active
7476
+ FROM normalized
7477
+ WHERE account_key != ''
7478
+ GROUP BY account_key, account_tool, account_email
7261
7479
  ORDER BY api_equivalent_usd DESC
7262
- `).all(sinceDate) : queryAccountBreakdown(db);
7263
- printTable(["Account", "Sessions", "Requests", "Tokens", "API Eq", "Billable", "Included"], rows.map((r) => [
7264
- chalk7.white(r.account_key || r.account_name || chalk7.dim("unknown")),
7265
- String(r.sessions),
7266
- String(r.requests),
7267
- chalk7.cyan(fmtTokens(r.total_tokens)),
7268
- fmt4(r.api_equivalent_usd),
7269
- fmt4("billable_usd" in r ? Number(r.billable_usd) : r.metered_api_usd),
7270
- fmt4(r.subscription_included_usd)
7271
- ]));
7480
+ `).all(sinceDate, sinceDate) : queryAccountBreakdown(db);
7481
+ printAccountBreakdown(rows);
7272
7482
  } else {
7273
7483
  const rows = sinceDate ? db.prepare(`
7274
7484
  SELECT model, agent,
@@ -7290,6 +7500,24 @@ program.command("breakdown").description("Cost breakdown by model, agent, projec
7290
7500
  }
7291
7501
  console.log();
7292
7502
  });
7503
+ var ACCOUNT_PERIODS = ["today", "week", "month", "year", "all"];
7504
+ program.command("accounts [period]").description("List account usage by email address and coding agent").option("--json", "Output JSON").action((periodArg, opts) => {
7505
+ const period = requireCliChoice(periodArg, "period", ACCOUNT_PERIODS);
7506
+ const rows = queryAccountBreakdown(openDatabase(), period);
7507
+ if (opts.json) {
7508
+ console.log(JSON.stringify(rows, null, 2));
7509
+ return;
7510
+ }
7511
+ if (rows.length === 0) {
7512
+ console.log(chalk7.yellow("No account-attributed sessions yet. Run `economy sync` first."));
7513
+ return;
7514
+ }
7515
+ console.log();
7516
+ console.log(chalk7.bold.cyan(` Accounts \u2014 ${period}`));
7517
+ console.log();
7518
+ printAccountBreakdown(rows);
7519
+ console.log();
7520
+ });
7293
7521
  program.command("watch").description("Live stream of incoming costs").option("--interval <seconds>", "Poll interval in seconds", "10").option("--daemon", "Watch agent data directories and sync on change").option("--agent <agent>", "Filter by agent").option("--notify <amount>", "Fire macOS notification when cumulative cost crosses this USD threshold").action(async (opts) => {
7294
7522
  const { watchCosts: watchCosts2 } = await Promise.resolve().then(() => (init_watch(), exports_watch));
7295
7523
  await watchCosts2({