@hasna/economy 0.3.5 → 0.3.7

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.
@@ -669,6 +669,16 @@ function addColumnIfMissing(db, table, column, definition) {
669
669
  }
670
670
  function initSchema(db) {
671
671
  db.exec(`
672
+ CREATE TABLE IF NOT EXISTS cost_centers (
673
+ id TEXT PRIMARY KEY,
674
+ kind TEXT NOT NULL,
675
+ name TEXT NOT NULL,
676
+ repo_path TEXT,
677
+ labels_json TEXT DEFAULT '{}',
678
+ created_at TEXT NOT NULL,
679
+ updated_at TEXT NOT NULL
680
+ );
681
+
672
682
  CREATE TABLE IF NOT EXISTS requests (
673
683
  id TEXT PRIMARY KEY,
674
684
  agent TEXT NOT NULL,
@@ -685,6 +695,7 @@ function initSchema(db) {
685
695
  timestamp TEXT NOT NULL,
686
696
  source_request_id TEXT,
687
697
  machine_id TEXT DEFAULT '',
698
+ cost_center_id TEXT,
688
699
  account_key TEXT DEFAULT '',
689
700
  account_tool TEXT DEFAULT '',
690
701
  account_name TEXT DEFAULT '',
@@ -703,6 +714,7 @@ function initSchema(db) {
703
714
  total_tokens INTEGER DEFAULT 0,
704
715
  request_count INTEGER DEFAULT 0,
705
716
  machine_id TEXT DEFAULT '',
717
+ cost_center_id TEXT,
706
718
  account_key TEXT DEFAULT '',
707
719
  account_tool TEXT DEFAULT '',
708
720
  account_name TEXT DEFAULT '',
@@ -723,6 +735,7 @@ function initSchema(db) {
723
735
  id TEXT PRIMARY KEY,
724
736
  project_path TEXT,
725
737
  agent TEXT,
738
+ cost_center_id TEXT,
726
739
  period TEXT NOT NULL,
727
740
  limit_usd REAL NOT NULL,
728
741
  alert_at_percent INTEGER DEFAULT 80,
@@ -753,6 +766,7 @@ function initSchema(db) {
753
766
  CREATE INDEX IF NOT EXISTS idx_sessions_agent ON sessions(agent);
754
767
  CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_path);
755
768
  CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at);
769
+ CREATE INDEX IF NOT EXISTS idx_cost_centers_kind ON cost_centers(kind);
756
770
 
757
771
  CREATE TABLE IF NOT EXISTS model_pricing (
758
772
  model TEXT PRIMARY KEY,
@@ -839,6 +853,12 @@ function initSchema(db) {
839
853
  `);
840
854
  addColumnIfMissing(db, "requests", "machine_id", `TEXT DEFAULT ''`);
841
855
  addColumnIfMissing(db, "sessions", "machine_id", `TEXT DEFAULT ''`);
856
+ if (addColumnIfMissing(db, "cost_centers", "updated_at", `TEXT DEFAULT ''`)) {
857
+ db.exec(`UPDATE cost_centers SET updated_at = created_at WHERE updated_at = '' OR updated_at IS NULL`);
858
+ }
859
+ addColumnIfMissing(db, "requests", "cost_center_id", `TEXT`);
860
+ addColumnIfMissing(db, "sessions", "cost_center_id", `TEXT`);
861
+ addColumnIfMissing(db, "budgets", "cost_center_id", `TEXT`);
842
862
  if (addColumnIfMissing(db, "requests", "cache_create_5m_tokens", "INTEGER DEFAULT 0")) {
843
863
  db.exec(`UPDATE requests SET cache_create_5m_tokens = cache_create_tokens WHERE cache_create_5m_tokens = 0`);
844
864
  }
@@ -867,6 +887,10 @@ function initSchema(db) {
867
887
  CREATE INDEX IF NOT EXISTS idx_sessions_machine ON sessions(machine_id);
868
888
  CREATE INDEX IF NOT EXISTS idx_requests_account ON requests(account_key);
869
889
  CREATE INDEX IF NOT EXISTS idx_sessions_account ON sessions(account_key);
890
+ CREATE INDEX IF NOT EXISTS idx_requests_cost_center ON requests(cost_center_id);
891
+ CREATE INDEX IF NOT EXISTS idx_sessions_cost_center ON sessions(cost_center_id);
892
+ CREATE INDEX IF NOT EXISTS idx_budgets_cost_center ON budgets(cost_center_id);
893
+ CREATE INDEX IF NOT EXISTS idx_cost_centers_kind ON cost_centers(kind);
870
894
  `);
871
895
  }
872
896
  function periodWhere(period) {
@@ -917,6 +941,14 @@ function requestPeriodWhere(period) {
917
941
  return "1=1";
918
942
  }
919
943
  }
944
+ function upsertCostCenter(db, center) {
945
+ const updatedAt = center.updated_at ?? center.created_at;
946
+ db.prepare(`
947
+ INSERT OR REPLACE INTO cost_centers
948
+ (id, kind, name, repo_path, labels_json, created_at, updated_at)
949
+ VALUES (?, ?, ?, ?, ?, ?, ?)
950
+ `).run(center.id, center.kind, center.name, center.repo_path ?? null, center.labels_json ?? "{}", center.created_at, updatedAt);
951
+ }
920
952
  function upsertRequest(db, req) {
921
953
  const now = req.updated_at ?? new Date().toISOString();
922
954
  db.prepare(`
@@ -924,10 +956,10 @@ function upsertRequest(db, req) {
924
956
  (id, agent, session_id, model, input_tokens, output_tokens,
925
957
  cache_read_tokens, cache_create_tokens, cache_create_5m_tokens,
926
958
  cache_create_1h_tokens, cost_usd, cost_basis, duration_ms, timestamp,
927
- source_request_id, machine_id, attribution_tag, account_key, account_tool,
959
+ source_request_id, machine_id, cost_center_id, attribution_tag, account_key, account_tool,
928
960
  account_name, account_email, account_source, updated_at, synced_at)
929
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
930
- `).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 ?? "");
961
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
962
+ `).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.cost_center_id ?? null, 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 ?? "");
931
963
  }
932
964
  function upsertSession(db, session) {
933
965
  const now = session.updated_at ?? new Date().toISOString();
@@ -935,9 +967,9 @@ function upsertSession(db, session) {
935
967
  INSERT OR REPLACE INTO sessions
936
968
  (id, agent, project_path, project_name, started_at, ended_at,
937
969
  total_cost_usd, total_tokens, request_count, machine_id, attribution_tag,
938
- account_key, account_tool, account_name, account_email, account_source, updated_at, synced_at)
939
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
940
- `).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 ?? "");
970
+ cost_center_id, account_key, account_tool, account_name, account_email, account_source, updated_at, synced_at)
971
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
972
+ `).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.cost_center_id ?? null, session.account_key ?? "", session.account_tool ?? "", session.account_name ?? "", session.account_email ?? "", session.account_source ?? "", now, session.synced_at ?? "");
941
973
  }
942
974
  function rollupSession(db, sessionId) {
943
975
  db.prepare(`
@@ -963,9 +995,12 @@ function rollupSession(db, sessionId) {
963
995
  ELSE account_email END,
964
996
  account_source = CASE WHEN account_source = '' OR account_source IS NULL
965
997
  THEN COALESCE((SELECT account_source FROM requests WHERE session_id = ? AND account_source != '' ORDER BY timestamp DESC LIMIT 1), '')
966
- ELSE account_source END
998
+ ELSE account_source END,
999
+ cost_center_id = CASE WHEN cost_center_id = '' OR cost_center_id IS NULL
1000
+ THEN COALESCE((SELECT cost_center_id FROM requests WHERE session_id = ? AND cost_center_id IS NOT NULL AND cost_center_id != '' ORDER BY timestamp DESC LIMIT 1), NULL)
1001
+ ELSE cost_center_id END
967
1002
  WHERE id = ?
968
- `).run(sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId);
1003
+ `).run(sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId);
969
1004
  }
970
1005
  function querySessions(db, filter = {}) {
971
1006
  const conditions = [];
@@ -1392,6 +1427,139 @@ function queryAccountBreakdown(db, period = "all", machine) {
1392
1427
  result.sort((a, b) => b.cost_usd - a.cost_usd);
1393
1428
  return result;
1394
1429
  }
1430
+ function kindFromCostCenterId(id) {
1431
+ const prefix = id.split(":", 1)[0] ?? "";
1432
+ return ["loop", "app", "repo", "service", "team"].includes(prefix) ? prefix : "";
1433
+ }
1434
+ function addCostCenterBreakdownRow(groups, row, sessionOnly) {
1435
+ if (!row.cost_center_id)
1436
+ return;
1437
+ const derivedKind = row.kind || kindFromCostCenterId(row.cost_center_id) || "service";
1438
+ const group = groups.get(row.cost_center_id) ?? {
1439
+ cost_center_id: row.cost_center_id,
1440
+ kind: derivedKind,
1441
+ name: row.name || row.cost_center_id,
1442
+ repo_path: row.repo_path ?? null,
1443
+ labels_json: row.labels_json || "{}",
1444
+ sessionIds: new Set,
1445
+ requests: 0,
1446
+ total_tokens: 0,
1447
+ api_equivalent_usd: 0,
1448
+ metered_api_usd: 0,
1449
+ subscription_included_usd: 0,
1450
+ estimated_usd: 0,
1451
+ unknown_usd: 0,
1452
+ last_active: ""
1453
+ };
1454
+ group.kind = derivedKind;
1455
+ if (row.name)
1456
+ group.name = row.name;
1457
+ if (row.repo_path && !group.repo_path)
1458
+ group.repo_path = row.repo_path;
1459
+ if (row.labels_json && row.labels_json !== "{}")
1460
+ group.labels_json = row.labels_json;
1461
+ if (row.session_id)
1462
+ group.sessionIds.add(row.session_id);
1463
+ group.requests += row.requests;
1464
+ group.total_tokens += row.total_tokens;
1465
+ group.api_equivalent_usd += row.cost_usd;
1466
+ if (sessionOnly) {
1467
+ group.estimated_usd += row.cost_usd;
1468
+ } else if (row.cost_basis === "metered_api") {
1469
+ group.metered_api_usd += row.cost_usd;
1470
+ } else if (row.cost_basis === "subscription_included") {
1471
+ group.subscription_included_usd += row.cost_usd;
1472
+ } else if (row.cost_basis === "unknown") {
1473
+ group.unknown_usd += row.cost_usd;
1474
+ } else {
1475
+ group.estimated_usd += row.cost_usd;
1476
+ }
1477
+ if (!group.last_active || row.last_active > group.last_active)
1478
+ group.last_active = row.last_active;
1479
+ groups.set(row.cost_center_id, group);
1480
+ }
1481
+ function queryCostCenterBreakdown(db, period = "all", filter = {}) {
1482
+ const requestWhere = filter.since ? "r.timestamp >= ?" : requestPeriodWhere(period);
1483
+ const sessionWhere = filter.since ? "s.started_at >= ?" : sessionPeriodWhere(period);
1484
+ const requestMachineClause = filter.machine ? " AND r.machine_id = ?" : "";
1485
+ const sessionMachineClause = filter.machine ? " AND s.machine_id = ?" : "";
1486
+ const requestParams = [];
1487
+ const sessionParams = [];
1488
+ if (filter.since) {
1489
+ requestParams.push(filter.since);
1490
+ sessionParams.push(filter.since);
1491
+ }
1492
+ if (filter.machine) {
1493
+ requestParams.push(filter.machine);
1494
+ sessionParams.push(filter.machine);
1495
+ }
1496
+ const groups = new Map;
1497
+ const requestRows = db.prepare(`
1498
+ SELECT
1499
+ COALESCE(NULLIF(r.cost_center_id, ''), NULLIF(s.cost_center_id, '')) as cost_center_id,
1500
+ COALESCE(cc.kind, '') as kind,
1501
+ COALESCE(cc.name, COALESCE(NULLIF(r.cost_center_id, ''), NULLIF(s.cost_center_id, ''))) as name,
1502
+ cc.repo_path as repo_path,
1503
+ COALESCE(cc.labels_json, '{}') as labels_json,
1504
+ r.session_id as session_id,
1505
+ 1 as requests,
1506
+ COALESCE(r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_create_tokens, 0) as total_tokens,
1507
+ COALESCE(r.cost_usd, 0) as cost_usd,
1508
+ COALESCE(NULLIF(r.cost_basis, ''), 'estimated') as cost_basis,
1509
+ r.timestamp as last_active
1510
+ FROM requests r
1511
+ LEFT JOIN sessions s ON s.id = r.session_id
1512
+ LEFT JOIN cost_centers cc ON cc.id = COALESCE(NULLIF(r.cost_center_id, ''), NULLIF(s.cost_center_id, ''))
1513
+ WHERE ${requestWhere}${requestMachineClause}
1514
+ AND COALESCE(NULLIF(r.cost_center_id, ''), NULLIF(s.cost_center_id, '')) IS NOT NULL
1515
+ AND COALESCE(NULLIF(r.cost_center_id, ''), NULLIF(s.cost_center_id, '')) != ''
1516
+ `).all(...requestParams);
1517
+ for (const row of requestRows)
1518
+ addCostCenterBreakdownRow(groups, row, false);
1519
+ const sessionOnlyRows = db.prepare(`
1520
+ SELECT
1521
+ s.cost_center_id as cost_center_id,
1522
+ COALESCE(cc.kind, '') as kind,
1523
+ COALESCE(cc.name, s.cost_center_id) as name,
1524
+ cc.repo_path as repo_path,
1525
+ COALESCE(cc.labels_json, '{}') as labels_json,
1526
+ s.id as session_id,
1527
+ COALESCE(s.request_count, 0) as requests,
1528
+ COALESCE(s.total_tokens, 0) as total_tokens,
1529
+ COALESCE(s.total_cost_usd, 0) as cost_usd,
1530
+ 'estimated' as cost_basis,
1531
+ s.started_at as last_active
1532
+ FROM sessions s
1533
+ LEFT JOIN cost_centers cc ON cc.id = s.cost_center_id
1534
+ WHERE ${sessionWhere}${sessionMachineClause}
1535
+ AND s.id NOT IN (SELECT DISTINCT session_id FROM requests)
1536
+ AND s.cost_center_id IS NOT NULL
1537
+ AND s.cost_center_id != ''
1538
+ `).all(...sessionParams);
1539
+ for (const row of sessionOnlyRows)
1540
+ addCostCenterBreakdownRow(groups, row, true);
1541
+ const result = [...groups.values()].map((group) => ({
1542
+ cost_center_id: group.cost_center_id,
1543
+ kind: group.kind,
1544
+ name: group.name,
1545
+ repo_path: group.repo_path,
1546
+ labels_json: group.labels_json,
1547
+ sessions: group.sessionIds.size,
1548
+ requests: group.requests,
1549
+ total_tokens: group.total_tokens,
1550
+ api_equivalent_usd: group.api_equivalent_usd,
1551
+ billable_usd: group.metered_api_usd,
1552
+ metered_api_usd: group.metered_api_usd,
1553
+ subscription_included_usd: group.subscription_included_usd,
1554
+ estimated_usd: group.estimated_usd,
1555
+ unknown_usd: group.unknown_usd,
1556
+ cost_usd: group.api_equivalent_usd,
1557
+ last_active: group.last_active
1558
+ }));
1559
+ const filtered = filter.kind ? result.filter((row) => row.kind === filter.kind) : result;
1560
+ filtered.sort((a, b) => b.cost_usd - a.cost_usd);
1561
+ return filtered;
1562
+ }
1395
1563
  function queryDailyBreakdown(db, days = 30, machine) {
1396
1564
  const machineClause = machine ? " AND machine_id = ?" : "";
1397
1565
  const params = machine ? [`-${days}`, machine] : [`-${days}`];
@@ -1433,9 +1601,9 @@ function deleteProject(db, path) {
1433
1601
  function upsertBudget(db, budget) {
1434
1602
  db.prepare(`
1435
1603
  INSERT OR REPLACE INTO budgets
1436
- (id, project_path, agent, period, limit_usd, alert_at_percent, created_at, updated_at)
1437
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1438
- `).run(budget.id, budget.project_path ?? null, budget.agent ?? null, budget.period, budget.limit_usd, budget.alert_at_percent, budget.created_at, budget.updated_at);
1604
+ (id, project_path, agent, cost_center_id, period, limit_usd, alert_at_percent, created_at, updated_at)
1605
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1606
+ `).run(budget.id, budget.project_path ?? null, budget.agent ?? null, budget.cost_center_id ?? null, budget.period, budget.limit_usd, budget.alert_at_percent, budget.created_at, budget.updated_at);
1439
1607
  }
1440
1608
  function listBudgets(db) {
1441
1609
  return db.prepare(`SELECT * FROM budgets ORDER BY created_at DESC`).all();
@@ -1457,6 +1625,10 @@ function getBudgetStatuses(db) {
1457
1625
  spendQuery += ` AND agent = ?`;
1458
1626
  params.push(b.agent);
1459
1627
  }
1628
+ if (b.cost_center_id) {
1629
+ spendQuery += ` AND COALESCE(NULLIF(cost_center_id, ''), (SELECT NULLIF(s.cost_center_id, '') FROM sessions s WHERE s.id = requests.session_id)) = ?`;
1630
+ params.push(b.cost_center_id);
1631
+ }
1460
1632
  const row = db.prepare(spendQuery).get(...params);
1461
1633
  const spend = row.spend;
1462
1634
  const percent = b.limit_usd > 0 ? spend / b.limit_usd * 100 : 0;
@@ -1952,8 +2124,8 @@ var init_database = () => {};
1952
2124
  // src/db/sync-pg.ts
1953
2125
  import { Worker, MessageChannel, receiveMessageOnPort } from "worker_threads";
1954
2126
  import { fileURLToPath } from "url";
1955
- import { dirname, join as join9 } from "path";
1956
- import { existsSync as existsSync10 } from "fs";
2127
+ import { dirname, join as join10 } from "path";
2128
+ import { existsSync as existsSync11 } from "fs";
1957
2129
  import { translateSql, translateParams } from "@hasna/cloud";
1958
2130
  function translateSqliteDates(sql) {
1959
2131
  let s = sql;
@@ -1979,12 +2151,12 @@ function translateSqliteDates(sql) {
1979
2151
  function resolveWorkerPath() {
1980
2152
  const here = dirname(fileURLToPath(import.meta.url));
1981
2153
  const candidates = [
1982
- join9(here, "pg-sync-worker.ts"),
1983
- join9(here, "pg-sync-worker.js"),
1984
- join9(here, "..", "db", "pg-sync-worker.js")
2154
+ join10(here, "pg-sync-worker.ts"),
2155
+ join10(here, "pg-sync-worker.js"),
2156
+ join10(here, "..", "db", "pg-sync-worker.js")
1985
2157
  ];
1986
2158
  for (const candidate of candidates)
1987
- if (existsSync10(candidate))
2159
+ if (existsSync11(candidate))
1988
2160
  return candidate;
1989
2161
  return candidates[0];
1990
2162
  }
@@ -2080,6 +2252,15 @@ var init_sync_pg = () => {};
2080
2252
  var PG_MIGRATIONS;
2081
2253
  var init_pg_migrations = __esm(() => {
2082
2254
  PG_MIGRATIONS = [
2255
+ `CREATE TABLE IF NOT EXISTS cost_centers (
2256
+ id TEXT PRIMARY KEY,
2257
+ kind TEXT NOT NULL,
2258
+ name TEXT NOT NULL,
2259
+ repo_path TEXT,
2260
+ labels_json TEXT DEFAULT '{}',
2261
+ created_at TEXT NOT NULL,
2262
+ updated_at TEXT NOT NULL
2263
+ )`,
2083
2264
  `CREATE TABLE IF NOT EXISTS requests (
2084
2265
  id TEXT PRIMARY KEY,
2085
2266
  agent TEXT NOT NULL,
@@ -2096,6 +2277,7 @@ var init_pg_migrations = __esm(() => {
2096
2277
  timestamp TEXT NOT NULL,
2097
2278
  source_request_id TEXT,
2098
2279
  machine_id TEXT DEFAULT '',
2280
+ cost_center_id TEXT,
2099
2281
  account_key TEXT DEFAULT '',
2100
2282
  account_tool TEXT DEFAULT '',
2101
2283
  account_name TEXT DEFAULT '',
@@ -2113,6 +2295,7 @@ var init_pg_migrations = __esm(() => {
2113
2295
  total_tokens INTEGER DEFAULT 0,
2114
2296
  request_count INTEGER DEFAULT 0,
2115
2297
  machine_id TEXT DEFAULT '',
2298
+ cost_center_id TEXT,
2116
2299
  account_key TEXT DEFAULT '',
2117
2300
  account_tool TEXT DEFAULT '',
2118
2301
  account_name TEXT DEFAULT '',
@@ -2131,6 +2314,7 @@ var init_pg_migrations = __esm(() => {
2131
2314
  id TEXT PRIMARY KEY,
2132
2315
  project_path TEXT,
2133
2316
  agent TEXT,
2317
+ cost_center_id TEXT,
2134
2318
  period TEXT NOT NULL,
2135
2319
  limit_usd REAL NOT NULL,
2136
2320
  alert_at_percent INTEGER DEFAULT 80,
@@ -2160,6 +2344,7 @@ var init_pg_migrations = __esm(() => {
2160
2344
  `CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_path)`,
2161
2345
  `CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at)`,
2162
2346
  `CREATE INDEX IF NOT EXISTS idx_sessions_machine ON sessions(machine_id)`,
2347
+ `CREATE INDEX IF NOT EXISTS idx_cost_centers_kind ON cost_centers(kind)`,
2163
2348
  `CREATE TABLE IF NOT EXISTS model_pricing (
2164
2349
  model TEXT PRIMARY KEY,
2165
2350
  input_per_1m REAL NOT NULL DEFAULT 0,
@@ -2245,6 +2430,7 @@ var init_pg_migrations = __esm(() => {
2245
2430
  `ALTER TABLE requests ADD COLUMN IF NOT EXISTS account_source TEXT DEFAULT ''`,
2246
2431
  `ALTER TABLE requests ADD COLUMN IF NOT EXISTS updated_at TEXT DEFAULT ''`,
2247
2432
  `ALTER TABLE requests ADD COLUMN IF NOT EXISTS synced_at TEXT DEFAULT ''`,
2433
+ `ALTER TABLE requests ADD COLUMN IF NOT EXISTS cost_center_id TEXT`,
2248
2434
  `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS attribution_tag TEXT DEFAULT ''`,
2249
2435
  `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS account_key TEXT DEFAULT ''`,
2250
2436
  `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS account_tool TEXT DEFAULT ''`,
@@ -2253,10 +2439,16 @@ var init_pg_migrations = __esm(() => {
2253
2439
  `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS account_source TEXT DEFAULT ''`,
2254
2440
  `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS updated_at TEXT DEFAULT ''`,
2255
2441
  `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS synced_at TEXT DEFAULT ''`,
2442
+ `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS cost_center_id TEXT`,
2443
+ `ALTER TABLE budgets ADD COLUMN IF NOT EXISTS cost_center_id TEXT`,
2444
+ `ALTER TABLE cost_centers ADD COLUMN IF NOT EXISTS updated_at TEXT DEFAULT ''`,
2256
2445
  `CREATE INDEX IF NOT EXISTS idx_usage_agent_date ON usage_snapshots(agent, date)`,
2257
2446
  `CREATE INDEX IF NOT EXISTS idx_savings_date ON savings_daily(date)`,
2258
2447
  `CREATE INDEX IF NOT EXISTS idx_requests_account ON requests(account_key)`,
2259
2448
  `CREATE INDEX IF NOT EXISTS idx_sessions_account ON sessions(account_key)`,
2449
+ `CREATE INDEX IF NOT EXISTS idx_requests_cost_center ON requests(cost_center_id)`,
2450
+ `CREATE INDEX IF NOT EXISTS idx_sessions_cost_center ON sessions(cost_center_id)`,
2451
+ `CREATE INDEX IF NOT EXISTS idx_budgets_cost_center ON budgets(cost_center_id)`,
2260
2452
  `ALTER TABLE requests ALTER COLUMN input_tokens TYPE BIGINT`,
2261
2453
  `ALTER TABLE requests ALTER COLUMN output_tokens TYPE BIGINT`,
2262
2454
  `ALTER TABLE requests ALTER COLUMN cache_read_tokens TYPE BIGINT`,
@@ -2649,15 +2841,15 @@ var init_open_projects = __esm(() => {
2649
2841
  });
2650
2842
 
2651
2843
  // src/lib/config.ts
2652
- import { existsSync as existsSync11, readFileSync as readFileSync10, writeFileSync, mkdirSync as mkdirSync2 } from "fs";
2653
- import { dirname as dirname2, join as join10 } from "path";
2844
+ import { existsSync as existsSync12, readFileSync as readFileSync10, writeFileSync, mkdirSync as mkdirSync2 } from "fs";
2845
+ import { dirname as dirname2, join as join11 } from "path";
2654
2846
  function getConfigPath() {
2655
- return process.env["HASNA_ECONOMY_CONFIG_PATH"] ?? join10(getDataDir(), "config.json");
2847
+ return process.env["HASNA_ECONOMY_CONFIG_PATH"] ?? join11(getDataDir(), "config.json");
2656
2848
  }
2657
2849
  function loadConfig() {
2658
2850
  try {
2659
2851
  const configPath = getConfigPath();
2660
- if (existsSync11(configPath)) {
2852
+ if (existsSync12(configPath)) {
2661
2853
  const raw = readFileSync10(configPath, "utf-8");
2662
2854
  return { ...DEFAULTS, ...JSON.parse(raw) };
2663
2855
  }
@@ -2737,6 +2929,8 @@ async function checkAndFireWebhooks(db) {
2737
2929
  event: "budget_alert",
2738
2930
  budget_id: b.id,
2739
2931
  project: b.project_path ?? "global",
2932
+ cost_center_id: b.cost_center_id ?? null,
2933
+ scope: b.cost_center_id ?? b.project_path ?? "global",
2740
2934
  period: b.period,
2741
2935
  spend: b.current_spend_usd,
2742
2936
  limit: b.limit_usd,
@@ -4095,32 +4289,219 @@ function statSyncSafe(path) {
4095
4289
  }
4096
4290
  }
4097
4291
 
4292
+ // src/ingest/loops.ts
4293
+ init_database();
4294
+ init_pricing();
4295
+ import { existsSync as existsSync8 } from "fs";
4296
+ import { homedir as homedir8 } from "os";
4297
+ import { join as join8 } from "path";
4298
+ import { Database as BunDatabase2 } from "bun:sqlite";
4299
+ var DEFAULT_LOOPS_DB_PATH = join8(homedir8(), ".hasna", "loops", "loops.db");
4300
+ var LOOPS_INGEST_VERSION = "goal-runs-orchestration-v1";
4301
+ function loopsDbPath() {
4302
+ return process.env["HASNA_ECONOMY_LOOPS_DB_PATH"] ?? DEFAULT_LOOPS_DB_PATH;
4303
+ }
4304
+ function loopsModel() {
4305
+ return process.env["HASNA_ECONOMY_LOOPS_MODEL"] ?? process.env["ECONOMY_LOOPS_MODEL"] ?? "gpt-5.3-codex";
4306
+ }
4307
+ function openLoopsDb(dbPath, verbose) {
4308
+ let lastError;
4309
+ for (const readonly of [true, false]) {
4310
+ let loopsDb = null;
4311
+ try {
4312
+ loopsDb = readonly ? new BunDatabase2(dbPath, { readonly: true }) : new BunDatabase2(dbPath);
4313
+ loopsDb.prepare("PRAGMA schema_version").get();
4314
+ return loopsDb;
4315
+ } catch (error) {
4316
+ lastError = error;
4317
+ loopsDb?.close();
4318
+ }
4319
+ }
4320
+ if (verbose) {
4321
+ const message = lastError instanceof Error ? lastError.message : String(lastError);
4322
+ console.log("OpenLoops DB unreadable:", dbPath, message);
4323
+ }
4324
+ return null;
4325
+ }
4326
+ function slug(value) {
4327
+ return value.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "unknown";
4328
+ }
4329
+ function parseTarget(raw) {
4330
+ if (!raw)
4331
+ return {};
4332
+ try {
4333
+ const parsed = JSON.parse(raw);
4334
+ return parsed && typeof parsed === "object" ? parsed : {};
4335
+ } catch {
4336
+ return {};
4337
+ }
4338
+ }
4339
+ function stringValue(value) {
4340
+ return typeof value === "string" ? value.trim() : "";
4341
+ }
4342
+ function accountFields(row) {
4343
+ const target = parseTarget(row.target_json);
4344
+ const tool = stringValue(row.account_tool) || stringValue(target["account_tool"]) || stringValue(target["tool"]) || stringValue(target["agent"]);
4345
+ const profile = stringValue(row.account_profile) || stringValue(target["account_profile"]) || stringValue(target["profile"]) || stringValue(target["account"]);
4346
+ if (!tool || !profile) {
4347
+ return { account_key: "", account_tool: tool, account_name: profile, account_source: "" };
4348
+ }
4349
+ return {
4350
+ account_key: `${tool}:${profile}`,
4351
+ account_tool: tool,
4352
+ account_name: profile,
4353
+ account_source: "loops"
4354
+ };
4355
+ }
4356
+ function readLoopGoalRuns(loopsDb) {
4357
+ return loopsDb.prepare(`
4358
+ SELECT
4359
+ gr.id as goal_run_id,
4360
+ gr.goal_id as goal_id,
4361
+ COALESCE(gr.loop_id, g.loop_id, lr.loop_id) as loop_id,
4362
+ COALESCE(gr.loop_run_id, g.loop_run_id, lr.id) as loop_run_id,
4363
+ COALESCE(l.name, lr.loop_name, 'unknown-loop') as loop_name,
4364
+ l.labels_json as labels_json,
4365
+ l.target_json as target_json,
4366
+ g.objective as objective,
4367
+ gr.phase as phase,
4368
+ gr.status as status,
4369
+ COALESCE(gr.tokens_used, 0) as tokens_used,
4370
+ gr.created_at as created_at,
4371
+ gr.updated_at as updated_at,
4372
+ lr.started_at as loop_run_started_at,
4373
+ wsr.account_profile as account_profile,
4374
+ wsr.account_tool as account_tool
4375
+ FROM goal_runs gr
4376
+ LEFT JOIN goals g ON g.id = gr.goal_id
4377
+ LEFT JOIN loop_runs lr ON lr.id = COALESCE(gr.loop_run_id, g.loop_run_id)
4378
+ LEFT JOIN loops l ON l.id = COALESCE(gr.loop_id, g.loop_id, lr.loop_id)
4379
+ LEFT JOIN (
4380
+ SELECT goal_run_id, MIN(account_profile) as account_profile, MIN(account_tool) as account_tool
4381
+ FROM workflow_step_runs
4382
+ WHERE goal_run_id IS NOT NULL
4383
+ GROUP BY goal_run_id
4384
+ ) wsr ON wsr.goal_run_id = gr.id
4385
+ WHERE COALESCE(gr.tokens_used, 0) > 0
4386
+ ORDER BY gr.created_at ASC, gr.id ASC
4387
+ `).all();
4388
+ }
4389
+ async function ingestLoops(db, verbose = false) {
4390
+ const dbPath = loopsDbPath();
4391
+ if (!existsSync8(dbPath)) {
4392
+ if (verbose)
4393
+ console.log("OpenLoops DB not found:", dbPath);
4394
+ return { sessions: 0, requests: 0 };
4395
+ }
4396
+ const model = loopsModel();
4397
+ const machineId = getMachineId();
4398
+ let loopsDb = null;
4399
+ let requests = 0;
4400
+ const sessions = new Set;
4401
+ try {
4402
+ loopsDb = openLoopsDb(dbPath, verbose);
4403
+ if (!loopsDb)
4404
+ return { sessions: 0, requests: 0 };
4405
+ for (const row of readLoopGoalRuns(loopsDb)) {
4406
+ const loopName = row.loop_name || "unknown-loop";
4407
+ const costCenterId = `loop:${slug(loopName)}`;
4408
+ const sessionId = `loops-${row.loop_run_id ?? row.loop_id ?? row.goal_id}`;
4409
+ const requestId = `loops-${row.goal_run_id}`;
4410
+ const timestamp = row.updated_at ?? row.created_at ?? row.loop_run_started_at ?? new Date().toISOString();
4411
+ const stateValue = `${LOOPS_INGEST_VERSION}:${row.updated_at ?? ""}:${row.tokens_used}:${model}:${costCenterId}`;
4412
+ if (getIngestState(db, "loops", row.goal_run_id) === stateValue)
4413
+ continue;
4414
+ upsertCostCenter(db, {
4415
+ id: costCenterId,
4416
+ kind: "loop",
4417
+ name: loopName,
4418
+ repo_path: null,
4419
+ labels_json: row.labels_json ?? "{}",
4420
+ created_at: row.created_at ?? timestamp
4421
+ });
4422
+ const account = accountFields(row);
4423
+ upsertSession(db, {
4424
+ id: sessionId,
4425
+ agent: "loop",
4426
+ project_path: "",
4427
+ project_name: loopName,
4428
+ started_at: row.loop_run_started_at ?? row.created_at ?? timestamp,
4429
+ ended_at: row.updated_at ?? null,
4430
+ total_cost_usd: 0,
4431
+ total_tokens: 0,
4432
+ request_count: 0,
4433
+ machine_id: machineId,
4434
+ cost_center_id: costCenterId,
4435
+ attribution_tag: "loops-orchestration",
4436
+ ...account
4437
+ });
4438
+ const inputTokens = row.tokens_used;
4439
+ const outputTokens = 0;
4440
+ upsertRequest(db, {
4441
+ id: requestId,
4442
+ agent: "loop",
4443
+ session_id: sessionId,
4444
+ model,
4445
+ input_tokens: inputTokens,
4446
+ output_tokens: outputTokens,
4447
+ cache_read_tokens: 0,
4448
+ cache_create_tokens: 0,
4449
+ cost_usd: computeCostFromDb(db, model, inputTokens, outputTokens),
4450
+ cost_basis: "estimated",
4451
+ duration_ms: 0,
4452
+ timestamp,
4453
+ source_request_id: requestId,
4454
+ machine_id: machineId,
4455
+ cost_center_id: costCenterId,
4456
+ attribution_tag: "loops-orchestration",
4457
+ ...account
4458
+ });
4459
+ sessions.add(sessionId);
4460
+ requests++;
4461
+ setIngestState(db, "loops", row.goal_run_id, stateValue);
4462
+ if (verbose)
4463
+ console.log(`OpenLoops ${loopName}: ${row.tokens_used} orchestration tokens`);
4464
+ }
4465
+ } catch (error) {
4466
+ if (verbose) {
4467
+ const message = error instanceof Error ? error.message : String(error);
4468
+ console.log("OpenLoops ingest skipped:", message);
4469
+ }
4470
+ return { sessions: 0, requests: 0 };
4471
+ } finally {
4472
+ loopsDb?.close();
4473
+ }
4474
+ for (const sessionId of sessions)
4475
+ rollupSession(db, sessionId);
4476
+ return { sessions: sessions.size, requests };
4477
+ }
4478
+
4098
4479
  // src/ingest/claude-quota.ts
4099
4480
  init_database();
4100
- import { existsSync as existsSync8, readFileSync as readFileSync6 } from "fs";
4481
+ import { existsSync as existsSync9, readFileSync as readFileSync6 } from "fs";
4101
4482
 
4102
4483
  // src/lib/paths.ts
4103
- import { homedir as homedir8 } from "os";
4104
- import { join as join8 } from "path";
4484
+ import { homedir as homedir9 } from "os";
4485
+ import { join as join9 } from "path";
4105
4486
  function getHomeDir() {
4106
- return process.env["USERPROFILE"] ?? process.env["HOME"] ?? homedir8();
4487
+ return process.env["USERPROFILE"] ?? process.env["HOME"] ?? homedir9();
4107
4488
  }
4108
4489
  function agentPaths() {
4109
4490
  const home = getHomeDir();
4110
4491
  return {
4111
- claudeProjects: join8(home, ".claude", "projects"),
4112
- claudeCredentials: join8(home, ".claude", ".credentials.json"),
4113
- takumiProjects: join8(home, ".takumi", "projects"),
4114
- codexDir: join8(home, ".codex"),
4115
- codexDb: join8(home, ".codex", "state_5.sqlite"),
4116
- codexAuth: join8(home, ".codex", "auth.json"),
4117
- codexConfig: join8(home, ".codex", "config.toml"),
4118
- geminiTmp: join8(home, ".gemini", "tmp"),
4119
- geminiHistory: join8(home, ".gemini", "history"),
4120
- opencodeMessages: join8(home, ".local", "share", "opencode", "storage", "message"),
4121
- piSessions: join8(home, ".pi", "agent", "sessions"),
4122
- hermesDir: join8(home, ".hermes"),
4123
- hermesDb: join8(home, ".hermes", "state.db")
4492
+ claudeProjects: join9(home, ".claude", "projects"),
4493
+ claudeCredentials: join9(home, ".claude", ".credentials.json"),
4494
+ takumiProjects: join9(home, ".takumi", "projects"),
4495
+ codexDir: join9(home, ".codex"),
4496
+ codexDb: join9(home, ".codex", "state_5.sqlite"),
4497
+ codexAuth: join9(home, ".codex", "auth.json"),
4498
+ codexConfig: join9(home, ".codex", "config.toml"),
4499
+ geminiTmp: join9(home, ".gemini", "tmp"),
4500
+ geminiHistory: join9(home, ".gemini", "history"),
4501
+ opencodeMessages: join9(home, ".local", "share", "opencode", "storage", "message"),
4502
+ piSessions: join9(home, ".pi", "agent", "sessions"),
4503
+ hermesDir: join9(home, ".hermes"),
4504
+ hermesDb: join9(home, ".hermes", "state.db")
4124
4505
  };
4125
4506
  }
4126
4507
 
@@ -4132,7 +4513,7 @@ function readClaudeToken() {
4132
4513
  const fromEnv = process.env["CLAUDE_OAUTH_TOKEN"] ?? process.env["ANTHROPIC_OAUTH_TOKEN"];
4133
4514
  if (fromEnv)
4134
4515
  return { token: fromEnv };
4135
- if (!existsSync8(CREDENTIALS_PATH))
4516
+ if (!existsSync9(CREDENTIALS_PATH))
4136
4517
  return null;
4137
4518
  try {
4138
4519
  const creds = JSON.parse(readFileSync6(CREDENTIALS_PATH, "utf-8"));
@@ -4271,14 +4652,14 @@ async function ingestClaudeQuota(db, verbose = false) {
4271
4652
 
4272
4653
  // src/ingest/codex-quota.ts
4273
4654
  init_database();
4274
- import { existsSync as existsSync9, readFileSync as readFileSync7 } from "fs";
4655
+ import { existsSync as existsSync10, readFileSync as readFileSync7 } from "fs";
4275
4656
  var WHAM_USAGE_URL = process.env["CODEX_USAGE_URL"] ?? "https://chatgpt.com/backend-api/wham/usage";
4276
4657
  function readCodexAuth() {
4277
4658
  const fromEnv = process.env["CODEX_OAUTH_TOKEN"];
4278
4659
  if (fromEnv)
4279
4660
  return { token: fromEnv, authMode: "chatgpt" };
4280
4661
  const authPath = agentPaths().codexAuth;
4281
- if (!existsSync9(authPath))
4662
+ if (!existsSync10(authPath))
4282
4663
  return null;
4283
4664
  try {
4284
4665
  const auth = JSON.parse(readFileSync7(authPath, "utf-8"));
@@ -4411,7 +4792,7 @@ async function ingestCodexQuota(db, verbose = false) {
4411
4792
  // src/lib/sync-all.ts
4412
4793
  init_database();
4413
4794
  async function syncAll(db, opts = {}) {
4414
- const anySpecific = Boolean(opts.claude || opts.takumi || opts.codex || opts.gemini || opts.opencode || opts.cursor || opts.pi || opts.hermes);
4795
+ const anySpecific = Boolean(opts.claude || opts.takumi || opts.codex || opts.gemini || opts.opencode || opts.cursor || opts.pi || opts.hermes || opts.loops);
4415
4796
  const all = !anySpecific;
4416
4797
  const result = { deduped: 0 };
4417
4798
  if (all || opts.claude) {
@@ -4434,6 +4815,8 @@ async function syncAll(db, opts = {}) {
4434
4815
  result.pi = await ingestPi(db, opts.verbose);
4435
4816
  if (all || opts.hermes)
4436
4817
  result.hermes = await ingestHermes(db, opts.verbose);
4818
+ if (all || opts.loops)
4819
+ result.loops = await ingestLoops(db, opts.verbose);
4437
4820
  if (opts.dedupe !== false)
4438
4821
  result.deduped = dedupeRequests(db);
4439
4822
  return result;
@@ -5277,7 +5660,7 @@ function isAuthorizedRequest(req, path) {
5277
5660
 
5278
5661
  // src/server/serve.ts
5279
5662
  import { randomUUID } from "crypto";
5280
- import { existsSync as existsSync12 } from "fs";
5663
+ import { existsSync as existsSync13 } from "fs";
5281
5664
  import { resolve, sep } from "path";
5282
5665
 
5283
5666
  // src/lib/package-metadata.ts
@@ -6914,7 +7297,7 @@ var CORS = {
6914
7297
  var APP = "economy";
6915
7298
  var ECONOMY_WRITE_SCOPE = `${APP}:write`;
6916
7299
  var AGENT_ERROR = `agent must be one of: ${AGENTS.join(", ")}`;
6917
- var SYNC_SOURCES = ["all", ...AGENTS];
7300
+ var SYNC_SOURCES = ["all", ...AGENTS, "loops"];
6918
7301
  var DEFAULT_DASHBOARD_DIR = new URL("../../dashboard/dist", import.meta.url).pathname;
6919
7302
  function json(data, status = 200) {
6920
7303
  return new Response(JSON.stringify(data), {
@@ -6987,13 +7370,13 @@ function createServerFetch(apiHandler, dashboardDir = DEFAULT_DASHBOARD_DIR) {
6987
7370
  if (url.pathname.startsWith("/api") || url.pathname.startsWith("/v1") || FOUNDATION_PATHS.has(url.pathname)) {
6988
7371
  return apiHandler(req);
6989
7372
  }
6990
- if (existsSync12(dashboardDir)) {
7373
+ if (existsSync13(dashboardDir)) {
6991
7374
  const filePath = dashboardPath(dashboardDir, url.pathname);
6992
- if (filePath && existsSync12(filePath)) {
7375
+ if (filePath && existsSync13(filePath)) {
6993
7376
  return new Response(Bun.file(filePath));
6994
7377
  }
6995
7378
  const indexPath = dashboardPath(dashboardDir, "/");
6996
- if (indexPath && existsSync12(indexPath)) {
7379
+ if (indexPath && existsSync13(indexPath)) {
6997
7380
  return new Response(Bun.file(indexPath));
6998
7381
  }
6999
7382
  }
@@ -7162,6 +7545,10 @@ function createHandler(db, options = {}) {
7162
7545
  return ok(queryAgentBreakdown(db, period, machine));
7163
7546
  if (by === "account")
7164
7547
  return ok(queryAccountBreakdown(db, period, machine));
7548
+ if (by === "cost-center")
7549
+ return ok(queryCostCenterBreakdown(db, period, { machine }));
7550
+ if (["loop", "app", "repo", "service", "team"].includes(by))
7551
+ return ok(queryCostCenterBreakdown(db, period, { kind: by, machine }));
7165
7552
  return ok(queryModelBreakdown(db));
7166
7553
  }
7167
7554
  if (path === "/api/budgets" && method === "GET") {
@@ -7185,6 +7572,7 @@ function createHandler(db, options = {}) {
7185
7572
  id: randomUUID(),
7186
7573
  project_path: body["project_path"] ?? null,
7187
7574
  agent,
7575
+ cost_center_id: optionalString(body["cost_center_id"]) ?? null,
7188
7576
  period: normalizeBudgetPeriod(body["period"]),
7189
7577
  limit_usd: limitUsd,
7190
7578
  alert_at_percent: alertAtPercent,