@hasna/economy 0.3.6 → 0.3.8

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.
Files changed (92) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +73 -60
  3. package/dashboard/README.md +17 -64
  4. package/dist/cli/commands/extras.d.ts.map +1 -1
  5. package/dist/cli/commands/todos.d.ts +3 -0
  6. package/dist/cli/commands/todos.d.ts.map +1 -1
  7. package/dist/cli/index.js +914 -199
  8. package/dist/db/cloud.d.ts +3 -4
  9. package/dist/db/cloud.d.ts.map +1 -1
  10. package/dist/db/database.d.ts +10 -3
  11. package/dist/db/database.d.ts.map +1 -1
  12. package/dist/db/dialect.d.ts +13 -0
  13. package/dist/db/dialect.d.ts.map +1 -0
  14. package/dist/db/pg-migrate.d.ts +30 -0
  15. package/dist/db/pg-migrate.d.ts.map +1 -0
  16. package/dist/db/pg-migrations.d.ts.map +1 -1
  17. package/dist/db/sqlite-adapter.d.ts +36 -0
  18. package/dist/db/sqlite-adapter.d.ts.map +1 -0
  19. package/dist/db/sync-pg.d.ts +1 -1
  20. package/dist/db/sync-pg.d.ts.map +1 -1
  21. package/dist/index.js +309 -59
  22. package/dist/ingest/billing.d.ts +1 -1
  23. package/dist/ingest/billing.d.ts.map +1 -1
  24. package/dist/ingest/claude-quota.d.ts +1 -1
  25. package/dist/ingest/claude-quota.d.ts.map +1 -1
  26. package/dist/ingest/claude.d.ts +1 -1
  27. package/dist/ingest/claude.d.ts.map +1 -1
  28. package/dist/ingest/codex-quota.d.ts +1 -1
  29. package/dist/ingest/codex-quota.d.ts.map +1 -1
  30. package/dist/ingest/codex.d.ts +1 -1
  31. package/dist/ingest/codex.d.ts.map +1 -1
  32. package/dist/ingest/cursor.d.ts +1 -1
  33. package/dist/ingest/cursor.d.ts.map +1 -1
  34. package/dist/ingest/gemini.d.ts +1 -1
  35. package/dist/ingest/gemini.d.ts.map +1 -1
  36. package/dist/ingest/hermes.d.ts +1 -1
  37. package/dist/ingest/hermes.d.ts.map +1 -1
  38. package/dist/ingest/loops.d.ts +6 -0
  39. package/dist/ingest/loops.d.ts.map +1 -0
  40. package/dist/ingest/opencode.d.ts +1 -1
  41. package/dist/ingest/opencode.d.ts.map +1 -1
  42. package/dist/ingest/otel.d.ts +16 -3
  43. package/dist/ingest/otel.d.ts.map +1 -1
  44. package/dist/ingest/pi.d.ts +1 -1
  45. package/dist/ingest/pi.d.ts.map +1 -1
  46. package/dist/ingest/plugin.d.ts +1 -1
  47. package/dist/ingest/plugin.d.ts.map +1 -1
  48. package/dist/lib/accounts.d.ts.map +1 -1
  49. package/dist/lib/analytics.d.ts +1 -1
  50. package/dist/lib/analytics.d.ts.map +1 -1
  51. package/dist/lib/billing-diff.d.ts +1 -1
  52. package/dist/lib/billing-diff.d.ts.map +1 -1
  53. package/dist/lib/brief.d.ts +1 -1
  54. package/dist/lib/brief.d.ts.map +1 -1
  55. package/dist/lib/open-projects.d.ts +10 -2
  56. package/dist/lib/open-projects.d.ts.map +1 -1
  57. package/dist/lib/pricing.d.ts +1 -1
  58. package/dist/lib/pricing.d.ts.map +1 -1
  59. package/dist/lib/savings.d.ts +1 -1
  60. package/dist/lib/savings.d.ts.map +1 -1
  61. package/dist/lib/spikes.d.ts +1 -1
  62. package/dist/lib/spikes.d.ts.map +1 -1
  63. package/dist/lib/store/index.d.ts +9 -1
  64. package/dist/lib/store/index.d.ts.map +1 -1
  65. package/dist/lib/sync-all.d.ts +3 -1
  66. package/dist/lib/sync-all.d.ts.map +1 -1
  67. package/dist/lib/webhooks.d.ts +1 -1
  68. package/dist/lib/webhooks.d.ts.map +1 -1
  69. package/dist/mcp/index.js +757 -166
  70. package/dist/mcp/server.d.ts.map +1 -1
  71. package/dist/openapi.d.ts.map +1 -1
  72. package/dist/otel/index.js +262 -27
  73. package/dist/server/index.js +683 -135
  74. package/dist/server/serve.d.ts +1 -1
  75. package/dist/server/serve.d.ts.map +1 -1
  76. package/dist/types/index.d.ts +37 -4
  77. package/dist/types/index.d.ts.map +1 -1
  78. package/docs/README.md +12 -0
  79. package/docs/cli.md +88 -0
  80. package/docs/configuration.md +86 -0
  81. package/docs/ingestion.md +47 -0
  82. package/docs/mcp.md +65 -0
  83. package/docs/otel.md +53 -0
  84. package/docs/rest-api.md +89 -0
  85. package/package.json +4 -5
  86. package/dist/lib/cloud-sync.d.ts +0 -35
  87. package/dist/lib/cloud-sync.d.ts.map +0 -1
  88. package/dist/lib/fleet-sync.d.ts +0 -61
  89. package/dist/lib/fleet-sync.d.ts.map +0 -1
  90. package/dist/lib/peer-sync.d.ts +0 -21
  91. package/dist/lib/peer-sync.d.ts.map +0 -1
  92. package/dist/storage.js +0 -2746
@@ -17,6 +17,62 @@ var __export = (target, all) => {
17
17
  var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
18
18
  var __require = import.meta.require;
19
19
 
20
+ // src/db/sqlite-adapter.ts
21
+ import { Database } from "bun:sqlite";
22
+
23
+ class SqliteAdapter {
24
+ db;
25
+ constructor(path) {
26
+ this.db = new Database(path, { create: true });
27
+ this.db.exec("PRAGMA journal_mode=WAL");
28
+ this.db.exec("PRAGMA foreign_keys=ON");
29
+ }
30
+ run(sql, ...params) {
31
+ const result = this.db.prepare(sql).run(...params);
32
+ return { changes: result.changes, lastInsertRowid: result.lastInsertRowid };
33
+ }
34
+ get(sql, ...params) {
35
+ return this.db.prepare(sql).get(...params);
36
+ }
37
+ all(sql, ...params) {
38
+ return this.db.prepare(sql).all(...params);
39
+ }
40
+ exec(sql) {
41
+ this.db.exec(sql);
42
+ }
43
+ query(sql) {
44
+ return this.db.query(sql);
45
+ }
46
+ prepare(sql) {
47
+ const stmt = this.db.prepare(sql);
48
+ return {
49
+ run(...params) {
50
+ const r = stmt.run(...params);
51
+ return { changes: r.changes, lastInsertRowid: r.lastInsertRowid };
52
+ },
53
+ get(...params) {
54
+ return stmt.get(...params);
55
+ },
56
+ all(...params) {
57
+ return stmt.all(...params);
58
+ },
59
+ finalize() {
60
+ stmt.finalize();
61
+ }
62
+ };
63
+ }
64
+ close() {
65
+ this.db.close();
66
+ }
67
+ transaction(fn) {
68
+ return this.db.transaction(fn)();
69
+ }
70
+ get raw() {
71
+ return this.db;
72
+ }
73
+ }
74
+ var init_sqlite_adapter = () => {};
75
+
20
76
  // src/lib/pricing.ts
21
77
  var exports_pricing = {};
22
78
  __export(exports_pricing, {
@@ -535,7 +591,6 @@ var init_pricing = __esm(() => {
535
591
  });
536
592
 
537
593
  // src/db/database.ts
538
- import { SqliteAdapter as Database } from "@hasna/cloud";
539
594
  import { execFileSync } from "child_process";
540
595
  import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from "fs";
541
596
  import { hostname, platform } from "os";
@@ -628,7 +683,7 @@ function openDatabase(dbPath, skipSeed = false) {
628
683
  mkdirSync(dir, { recursive: true });
629
684
  }
630
685
  const db = withSqliteBusyRetry(() => {
631
- const opened = new Database(path);
686
+ const opened = new SqliteAdapter(path);
632
687
  try {
633
688
  opened.exec("PRAGMA busy_timeout = 10000");
634
689
  opened.exec("PRAGMA journal_mode = WAL");
@@ -669,6 +724,16 @@ function addColumnIfMissing(db, table, column, definition) {
669
724
  }
670
725
  function initSchema(db) {
671
726
  db.exec(`
727
+ CREATE TABLE IF NOT EXISTS cost_centers (
728
+ id TEXT PRIMARY KEY,
729
+ kind TEXT NOT NULL,
730
+ name TEXT NOT NULL,
731
+ repo_path TEXT,
732
+ labels_json TEXT DEFAULT '{}',
733
+ created_at TEXT NOT NULL,
734
+ updated_at TEXT NOT NULL
735
+ );
736
+
672
737
  CREATE TABLE IF NOT EXISTS requests (
673
738
  id TEXT PRIMARY KEY,
674
739
  agent TEXT NOT NULL,
@@ -685,6 +750,7 @@ function initSchema(db) {
685
750
  timestamp TEXT NOT NULL,
686
751
  source_request_id TEXT,
687
752
  machine_id TEXT DEFAULT '',
753
+ cost_center_id TEXT,
688
754
  account_key TEXT DEFAULT '',
689
755
  account_tool TEXT DEFAULT '',
690
756
  account_name TEXT DEFAULT '',
@@ -703,6 +769,7 @@ function initSchema(db) {
703
769
  total_tokens INTEGER DEFAULT 0,
704
770
  request_count INTEGER DEFAULT 0,
705
771
  machine_id TEXT DEFAULT '',
772
+ cost_center_id TEXT,
706
773
  account_key TEXT DEFAULT '',
707
774
  account_tool TEXT DEFAULT '',
708
775
  account_name TEXT DEFAULT '',
@@ -723,6 +790,7 @@ function initSchema(db) {
723
790
  id TEXT PRIMARY KEY,
724
791
  project_path TEXT,
725
792
  agent TEXT,
793
+ cost_center_id TEXT,
726
794
  period TEXT NOT NULL,
727
795
  limit_usd REAL NOT NULL,
728
796
  alert_at_percent INTEGER DEFAULT 80,
@@ -753,6 +821,7 @@ function initSchema(db) {
753
821
  CREATE INDEX IF NOT EXISTS idx_sessions_agent ON sessions(agent);
754
822
  CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_path);
755
823
  CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at);
824
+ CREATE INDEX IF NOT EXISTS idx_cost_centers_kind ON cost_centers(kind);
756
825
 
757
826
  CREATE TABLE IF NOT EXISTS model_pricing (
758
827
  model TEXT PRIMARY KEY,
@@ -839,6 +908,12 @@ function initSchema(db) {
839
908
  `);
840
909
  addColumnIfMissing(db, "requests", "machine_id", `TEXT DEFAULT ''`);
841
910
  addColumnIfMissing(db, "sessions", "machine_id", `TEXT DEFAULT ''`);
911
+ if (addColumnIfMissing(db, "cost_centers", "updated_at", `TEXT DEFAULT ''`)) {
912
+ db.exec(`UPDATE cost_centers SET updated_at = created_at WHERE updated_at = '' OR updated_at IS NULL`);
913
+ }
914
+ addColumnIfMissing(db, "requests", "cost_center_id", `TEXT`);
915
+ addColumnIfMissing(db, "sessions", "cost_center_id", `TEXT`);
916
+ addColumnIfMissing(db, "budgets", "cost_center_id", `TEXT`);
842
917
  if (addColumnIfMissing(db, "requests", "cache_create_5m_tokens", "INTEGER DEFAULT 0")) {
843
918
  db.exec(`UPDATE requests SET cache_create_5m_tokens = cache_create_tokens WHERE cache_create_5m_tokens = 0`);
844
919
  }
@@ -867,6 +942,10 @@ function initSchema(db) {
867
942
  CREATE INDEX IF NOT EXISTS idx_sessions_machine ON sessions(machine_id);
868
943
  CREATE INDEX IF NOT EXISTS idx_requests_account ON requests(account_key);
869
944
  CREATE INDEX IF NOT EXISTS idx_sessions_account ON sessions(account_key);
945
+ CREATE INDEX IF NOT EXISTS idx_requests_cost_center ON requests(cost_center_id);
946
+ CREATE INDEX IF NOT EXISTS idx_sessions_cost_center ON sessions(cost_center_id);
947
+ CREATE INDEX IF NOT EXISTS idx_budgets_cost_center ON budgets(cost_center_id);
948
+ CREATE INDEX IF NOT EXISTS idx_cost_centers_kind ON cost_centers(kind);
870
949
  `);
871
950
  }
872
951
  function periodWhere(period) {
@@ -917,6 +996,14 @@ function requestPeriodWhere(period) {
917
996
  return "1=1";
918
997
  }
919
998
  }
999
+ function upsertCostCenter(db, center) {
1000
+ const updatedAt = center.updated_at ?? center.created_at;
1001
+ db.prepare(`
1002
+ INSERT OR REPLACE INTO cost_centers
1003
+ (id, kind, name, repo_path, labels_json, created_at, updated_at)
1004
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1005
+ `).run(center.id, center.kind, center.name, center.repo_path ?? null, center.labels_json ?? "{}", center.created_at, updatedAt);
1006
+ }
920
1007
  function upsertRequest(db, req) {
921
1008
  const now = req.updated_at ?? new Date().toISOString();
922
1009
  db.prepare(`
@@ -924,10 +1011,10 @@ function upsertRequest(db, req) {
924
1011
  (id, agent, session_id, model, input_tokens, output_tokens,
925
1012
  cache_read_tokens, cache_create_tokens, cache_create_5m_tokens,
926
1013
  cache_create_1h_tokens, cost_usd, cost_basis, duration_ms, timestamp,
927
- source_request_id, machine_id, attribution_tag, account_key, account_tool,
1014
+ source_request_id, machine_id, cost_center_id, attribution_tag, account_key, account_tool,
928
1015
  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 ?? "");
1016
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1017
+ `).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
1018
  }
932
1019
  function upsertSession(db, session) {
933
1020
  const now = session.updated_at ?? new Date().toISOString();
@@ -935,9 +1022,9 @@ function upsertSession(db, session) {
935
1022
  INSERT OR REPLACE INTO sessions
936
1023
  (id, agent, project_path, project_name, started_at, ended_at,
937
1024
  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 ?? "");
1025
+ cost_center_id, account_key, account_tool, account_name, account_email, account_source, updated_at, synced_at)
1026
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1027
+ `).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
1028
  }
942
1029
  function rollupSession(db, sessionId) {
943
1030
  db.prepare(`
@@ -963,9 +1050,12 @@ function rollupSession(db, sessionId) {
963
1050
  ELSE account_email END,
964
1051
  account_source = CASE WHEN account_source = '' OR account_source IS NULL
965
1052
  THEN COALESCE((SELECT account_source FROM requests WHERE session_id = ? AND account_source != '' ORDER BY timestamp DESC LIMIT 1), '')
966
- ELSE account_source END
1053
+ ELSE account_source END,
1054
+ cost_center_id = CASE WHEN cost_center_id = '' OR cost_center_id IS NULL
1055
+ 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)
1056
+ ELSE cost_center_id END
967
1057
  WHERE id = ?
968
- `).run(sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId);
1058
+ `).run(sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId, sessionId);
969
1059
  }
970
1060
  function querySessions(db, filter = {}) {
971
1061
  const conditions = [];
@@ -1181,9 +1271,12 @@ function labelForPath(projectPath, projectName) {
1181
1271
  function groupKeyForPath(projectPath, projectName) {
1182
1272
  return labelForPath(projectPath, projectName).trim().toLowerCase();
1183
1273
  }
1184
- function queryProjectBreakdown(db, period = "all", machine) {
1185
- const requestWhere = requestPeriodWhere(period);
1186
- const sessionWhere = sessionPeriodWhere(period);
1274
+ function laterTimestamp(current, candidate) {
1275
+ if (!candidate)
1276
+ return current;
1277
+ return candidate > current ? candidate : current;
1278
+ }
1279
+ function queryProjectBreakdownScoped(db, requestWhere, sessionWhere, requestScopeParams = [], sessionScopeParams = [], machine) {
1187
1280
  const sessionMachineClause = machine ? " AND (machine_id = ? OR id IN (SELECT DISTINCT session_id FROM requests WHERE machine_id = ?))" : "";
1188
1281
  const requestMachineClause = machine ? " AND machine_id = ?" : "";
1189
1282
  const sessionMachineParams = machine ? [machine, machine] : [];
@@ -1191,67 +1284,89 @@ function queryProjectBreakdown(db, period = "all", machine) {
1191
1284
  const sessionOnlyMachineClause = machine ? " AND machine_id = ?" : "";
1192
1285
  const sessionOnlyMachineParams = machine ? [machine] : [];
1193
1286
  const sessions = db.prepare(`
1194
- SELECT id, project_path, project_name, total_cost_usd, started_at
1287
+ SELECT id, project_path, project_name
1195
1288
  FROM sessions
1196
1289
  WHERE (project_path != '' OR project_name != '')${sessionMachineClause}
1197
1290
  `).all(...sessionMachineParams);
1198
1291
  const groups = new Map;
1292
+ const groupKeyBySession = new Map;
1199
1293
  for (const s of sessions) {
1200
1294
  const label = labelForPath(s.project_path, s.project_name);
1201
1295
  if (!label)
1202
1296
  continue;
1203
1297
  const key = groupKeyForPath(s.project_path, s.project_name);
1204
- const g = groups.get(key) ?? { label, sessionIds: [], samplePath: s.project_path };
1205
- g.sessionIds.push(s.id);
1298
+ const g = groups.get(key) ?? { label, samplePath: s.project_path, sessions: 0, requests: 0, total_tokens: 0, cost_usd: 0, last_active: "" };
1206
1299
  if (!g.samplePath)
1207
1300
  g.samplePath = s.project_path;
1208
1301
  groups.set(key, g);
1302
+ groupKeyBySession.set(s.id, key);
1303
+ }
1304
+ const groupFor = (sessionId) => {
1305
+ const key = groupKeyBySession.get(sessionId);
1306
+ return key === undefined ? undefined : groups.get(key);
1307
+ };
1308
+ const requestRows = db.prepare(`
1309
+ SELECT session_id,
1310
+ COUNT(*) as requests,
1311
+ COALESCE(SUM(cost_usd), 0) as cost_usd,
1312
+ COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_create_tokens), 0) as total_tokens,
1313
+ MAX(timestamp) as last_active
1314
+ FROM requests
1315
+ WHERE ${requestWhere}${requestMachineClause}
1316
+ GROUP BY session_id
1317
+ `).all(...requestScopeParams, ...requestMachineParams);
1318
+ for (const row of requestRows) {
1319
+ const g = groupFor(row.session_id);
1320
+ if (!g)
1321
+ continue;
1322
+ g.sessions += 1;
1323
+ g.requests += row.requests;
1324
+ g.cost_usd += row.cost_usd;
1325
+ g.total_tokens += row.total_tokens;
1326
+ g.last_active = laterTimestamp(g.last_active, row.last_active);
1327
+ }
1328
+ const sessionsWithRequests = new Set(db.prepare(`SELECT DISTINCT session_id FROM requests`).all().map((row) => row.session_id));
1329
+ const sessionOnlyRows = db.prepare(`
1330
+ SELECT id,
1331
+ COALESCE(request_count, 0) as request_count,
1332
+ COALESCE(total_tokens, 0) as total_tokens,
1333
+ COALESCE(total_cost_usd, 0) as total_cost_usd,
1334
+ started_at
1335
+ FROM sessions
1336
+ WHERE ${sessionWhere}${sessionOnlyMachineClause}
1337
+ `).all(...sessionScopeParams, ...sessionOnlyMachineParams);
1338
+ for (const row of sessionOnlyRows) {
1339
+ if (sessionsWithRequests.has(row.id))
1340
+ continue;
1341
+ const g = groupFor(row.id);
1342
+ if (!g)
1343
+ continue;
1344
+ g.sessions += 1;
1345
+ g.requests += row.request_count;
1346
+ g.total_tokens += row.total_tokens;
1347
+ g.cost_usd += row.total_cost_usd;
1348
+ g.last_active = laterTimestamp(g.last_active, row.started_at);
1209
1349
  }
1210
1350
  const result = [];
1211
1351
  for (const g of groups.values()) {
1212
- const placeholders = g.sessionIds.map(() => "?").join(",");
1213
- const reqStats = placeholders.length ? db.prepare(`
1214
- SELECT
1215
- COUNT(DISTINCT session_id) as sessions,
1216
- COUNT(*) as requests,
1217
- COALESCE(SUM(cost_usd), 0) as cost_usd,
1218
- COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_create_tokens), 0) as total_tokens,
1219
- MAX(timestamp) as last_active
1220
- FROM requests
1221
- WHERE session_id IN (${placeholders})
1222
- AND ${requestWhere}
1223
- ${requestMachineClause}
1224
- `).get(...g.sessionIds, ...requestMachineParams) : { sessions: 0, requests: 0, cost_usd: 0, total_tokens: 0, last_active: null };
1225
- const sessionOnlyStats = placeholders.length ? db.prepare(`
1226
- SELECT
1227
- COUNT(*) as sessions,
1228
- COALESCE(SUM(request_count), 0) as requests,
1229
- COALESCE(SUM(total_tokens), 0) as total_tokens,
1230
- COALESCE(SUM(total_cost_usd), 0) as cost_usd,
1231
- MAX(started_at) as last_active
1232
- FROM sessions
1233
- WHERE id IN (${placeholders})
1234
- AND ${sessionWhere}
1235
- ${sessionOnlyMachineClause}
1236
- AND id NOT IN (SELECT DISTINCT session_id FROM requests)
1237
- `).get(...g.sessionIds, ...sessionOnlyMachineParams) : { sessions: 0, requests: 0, total_tokens: 0, cost_usd: 0, last_active: null };
1238
- const totalSessions = reqStats.sessions + sessionOnlyStats.sessions;
1239
- if (totalSessions === 0)
1352
+ if (g.sessions === 0)
1240
1353
  continue;
1241
- const lastActive = [reqStats.last_active, sessionOnlyStats.last_active].filter(Boolean).sort().at(-1) ?? "";
1242
1354
  result.push({
1243
1355
  project_path: g.samplePath,
1244
1356
  project_name: g.label,
1245
- sessions: totalSessions,
1246
- requests: reqStats.requests + sessionOnlyStats.requests,
1247
- total_tokens: reqStats.total_tokens + sessionOnlyStats.total_tokens,
1248
- cost_usd: reqStats.cost_usd + sessionOnlyStats.cost_usd,
1249
- last_active: lastActive
1357
+ sessions: g.sessions,
1358
+ requests: g.requests,
1359
+ total_tokens: g.total_tokens,
1360
+ cost_usd: g.cost_usd,
1361
+ last_active: g.last_active
1250
1362
  });
1251
1363
  }
1252
1364
  result.sort((a, b) => b.cost_usd - a.cost_usd);
1253
1365
  return result;
1254
1366
  }
1367
+ function queryProjectBreakdown(db, period = "all", machine) {
1368
+ return queryProjectBreakdownScoped(db, requestPeriodWhere(period), sessionPeriodWhere(period), [], [], machine);
1369
+ }
1255
1370
  function normalizeAccountEmail(email) {
1256
1371
  return (email ?? "").trim().toLowerCase();
1257
1372
  }
@@ -1392,6 +1507,142 @@ function queryAccountBreakdown(db, period = "all", machine) {
1392
1507
  result.sort((a, b) => b.cost_usd - a.cost_usd);
1393
1508
  return result;
1394
1509
  }
1510
+ function kindFromCostCenterId(id) {
1511
+ const prefix = id.split(":", 1)[0] ?? "";
1512
+ return ["loop", "app", "repo", "service", "team"].includes(prefix) ? prefix : "";
1513
+ }
1514
+ function addCostCenterBreakdownRow(groups, row, sessionOnly) {
1515
+ if (!row.cost_center_id)
1516
+ return;
1517
+ const derivedKind = row.kind || kindFromCostCenterId(row.cost_center_id) || "service";
1518
+ const group = groups.get(row.cost_center_id) ?? {
1519
+ cost_center_id: row.cost_center_id,
1520
+ kind: derivedKind,
1521
+ name: row.name || row.cost_center_id,
1522
+ repo_path: row.repo_path ?? null,
1523
+ labels_json: row.labels_json || "{}",
1524
+ sessionIds: new Set,
1525
+ requests: 0,
1526
+ total_tokens: 0,
1527
+ api_equivalent_usd: 0,
1528
+ metered_api_usd: 0,
1529
+ subscription_included_usd: 0,
1530
+ estimated_usd: 0,
1531
+ unknown_usd: 0,
1532
+ last_active: ""
1533
+ };
1534
+ group.kind = derivedKind;
1535
+ if (row.name)
1536
+ group.name = row.name;
1537
+ if (row.repo_path && !group.repo_path)
1538
+ group.repo_path = row.repo_path;
1539
+ if (row.labels_json && row.labels_json !== "{}")
1540
+ group.labels_json = row.labels_json;
1541
+ if (row.session_id)
1542
+ group.sessionIds.add(row.session_id);
1543
+ group.requests += row.requests;
1544
+ group.total_tokens += row.total_tokens;
1545
+ group.api_equivalent_usd += row.cost_usd;
1546
+ if (sessionOnly) {
1547
+ group.estimated_usd += row.cost_usd;
1548
+ } else if (row.cost_basis === "metered_api") {
1549
+ group.metered_api_usd += row.cost_usd;
1550
+ } else if (row.cost_basis === "subscription_included") {
1551
+ group.subscription_included_usd += row.cost_usd;
1552
+ } else if (row.cost_basis === "unknown") {
1553
+ group.unknown_usd += row.cost_usd;
1554
+ } else {
1555
+ group.estimated_usd += row.cost_usd;
1556
+ }
1557
+ if (!group.last_active || row.last_active > group.last_active)
1558
+ group.last_active = row.last_active;
1559
+ groups.set(row.cost_center_id, group);
1560
+ }
1561
+ function queryCostCenterBreakdown(db, period = "all", filter = {}) {
1562
+ const requestWhere = filter.since ? "r.timestamp >= ?" : requestPeriodWhere(period);
1563
+ const sessionWhere = filter.since ? "s.started_at >= ?" : sessionPeriodWhere(period);
1564
+ const requestMachineClause = filter.machine ? " AND r.machine_id = ?" : "";
1565
+ const sessionMachineClause = filter.machine ? " AND s.machine_id = ?" : "";
1566
+ const requestParams = [];
1567
+ const sessionParams = [];
1568
+ if (filter.since) {
1569
+ requestParams.push(filter.since);
1570
+ sessionParams.push(filter.since);
1571
+ }
1572
+ if (filter.machine) {
1573
+ requestParams.push(filter.machine);
1574
+ sessionParams.push(filter.machine);
1575
+ }
1576
+ const groups = new Map;
1577
+ const requestRows = db.prepare(`
1578
+ SELECT
1579
+ COALESCE(NULLIF(r.cost_center_id, ''), NULLIF(s.cost_center_id, '')) as cost_center_id,
1580
+ COALESCE(cc.kind, '') as kind,
1581
+ COALESCE(cc.name, COALESCE(NULLIF(r.cost_center_id, ''), NULLIF(s.cost_center_id, ''))) as name,
1582
+ cc.repo_path as repo_path,
1583
+ COALESCE(cc.labels_json, '{}') as labels_json,
1584
+ r.session_id as session_id,
1585
+ 1 as requests,
1586
+ COALESCE(r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_create_tokens, 0) as total_tokens,
1587
+ COALESCE(r.cost_usd, 0) as cost_usd,
1588
+ COALESCE(NULLIF(r.cost_basis, ''), 'estimated') as cost_basis,
1589
+ r.timestamp as last_active
1590
+ FROM requests r
1591
+ LEFT JOIN sessions s ON s.id = r.session_id
1592
+ LEFT JOIN cost_centers cc ON cc.id = COALESCE(NULLIF(r.cost_center_id, ''), NULLIF(s.cost_center_id, ''))
1593
+ WHERE ${requestWhere}${requestMachineClause}
1594
+ AND COALESCE(NULLIF(r.cost_center_id, ''), NULLIF(s.cost_center_id, '')) IS NOT NULL
1595
+ AND COALESCE(NULLIF(r.cost_center_id, ''), NULLIF(s.cost_center_id, '')) != ''
1596
+ `).all(...requestParams);
1597
+ for (const row of requestRows)
1598
+ addCostCenterBreakdownRow(groups, row, false);
1599
+ const sessionOnlyRows = db.prepare(`
1600
+ SELECT
1601
+ s.cost_center_id as cost_center_id,
1602
+ COALESCE(cc.kind, '') as kind,
1603
+ COALESCE(cc.name, s.cost_center_id) as name,
1604
+ cc.repo_path as repo_path,
1605
+ COALESCE(cc.labels_json, '{}') as labels_json,
1606
+ s.id as session_id,
1607
+ COALESCE(s.request_count, 0) as requests,
1608
+ COALESCE(s.total_tokens, 0) as total_tokens,
1609
+ COALESCE(s.total_cost_usd, 0) as cost_usd,
1610
+ 'estimated' as cost_basis,
1611
+ s.started_at as last_active
1612
+ FROM sessions s
1613
+ LEFT JOIN cost_centers cc ON cc.id = s.cost_center_id
1614
+ WHERE ${sessionWhere}${sessionMachineClause}
1615
+ AND s.id NOT IN (SELECT DISTINCT session_id FROM requests)
1616
+ AND s.cost_center_id IS NOT NULL
1617
+ AND s.cost_center_id != ''
1618
+ `).all(...sessionParams);
1619
+ for (const row of sessionOnlyRows)
1620
+ addCostCenterBreakdownRow(groups, row, true);
1621
+ const result = [...groups.values()].map((group) => ({
1622
+ cost_center_id: group.cost_center_id,
1623
+ kind: group.kind,
1624
+ name: group.name,
1625
+ repo_path: group.repo_path,
1626
+ labels_json: group.labels_json,
1627
+ sessions: group.sessionIds.size,
1628
+ requests: group.requests,
1629
+ total_tokens: group.total_tokens,
1630
+ api_equivalent_usd: group.api_equivalent_usd,
1631
+ billable_usd: group.metered_api_usd,
1632
+ metered_api_usd: group.metered_api_usd,
1633
+ subscription_included_usd: group.subscription_included_usd,
1634
+ estimated_usd: group.estimated_usd,
1635
+ unknown_usd: group.unknown_usd,
1636
+ cost_usd: group.api_equivalent_usd,
1637
+ last_active: group.last_active
1638
+ }));
1639
+ const filtered = filter.kind ? result.filter((row) => row.kind === filter.kind) : result;
1640
+ filtered.sort((a, b) => b.cost_usd - a.cost_usd);
1641
+ return filtered;
1642
+ }
1643
+ function queryProjectBreakdownSince(db, since, machine) {
1644
+ return queryProjectBreakdownScoped(db, "timestamp >= ?", "started_at >= ?", [since], [since], machine);
1645
+ }
1395
1646
  function queryDailyBreakdown(db, days = 30, machine) {
1396
1647
  const machineClause = machine ? " AND machine_id = ?" : "";
1397
1648
  const params = machine ? [`-${days}`, machine] : [`-${days}`];
@@ -1433,9 +1684,9 @@ function deleteProject(db, path) {
1433
1684
  function upsertBudget(db, budget) {
1434
1685
  db.prepare(`
1435
1686
  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);
1687
+ (id, project_path, agent, cost_center_id, period, limit_usd, alert_at_percent, created_at, updated_at)
1688
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1689
+ `).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
1690
  }
1440
1691
  function listBudgets(db) {
1441
1692
  return db.prepare(`SELECT * FROM budgets ORDER BY created_at DESC`).all();
@@ -1457,6 +1708,10 @@ function getBudgetStatuses(db) {
1457
1708
  spendQuery += ` AND agent = ?`;
1458
1709
  params.push(b.agent);
1459
1710
  }
1711
+ if (b.cost_center_id) {
1712
+ spendQuery += ` AND COALESCE(NULLIF(cost_center_id, ''), (SELECT NULLIF(s.cost_center_id, '') FROM sessions s WHERE s.id = requests.session_id)) = ?`;
1713
+ params.push(b.cost_center_id);
1714
+ }
1460
1715
  const row = db.prepare(spendQuery).get(...params);
1461
1716
  const spend = row.spend;
1462
1717
  const percent = b.limit_usd > 0 ? spend / b.limit_usd * 100 : 0;
@@ -1947,14 +2202,52 @@ var numOr = (v, d = 0) => {
1947
2202
  const num = Number(v);
1948
2203
  return Number.isFinite(num) ? num : d;
1949
2204
  }, strOr = (v, d = "") => v == null ? d : String(v);
1950
- var init_database = () => {};
2205
+ var init_database = __esm(() => {
2206
+ init_sqlite_adapter();
2207
+ });
2208
+
2209
+ // src/db/dialect.ts
2210
+ function translateSql(sql, dialect) {
2211
+ if (dialect === "sqlite")
2212
+ return sql;
2213
+ return sqliteToPostgres(sql);
2214
+ }
2215
+ function translateParams(params) {
2216
+ const flat = params.length === 1 && Array.isArray(params[0]) ? params[0] : params;
2217
+ return flat.map((p) => p === undefined ? null : p);
2218
+ }
2219
+ function sqliteToPostgres(sql) {
2220
+ let out = sql;
2221
+ let paramIdx = 0;
2222
+ out = out.replace(/\?/g, () => `$${++paramIdx}`);
2223
+ out = out.replace(/datetime\s*\(\s*'now'\s*\)/gi, "NOW()");
2224
+ out = out.replace(/datetime\s*\(\s*'now'\s*,\s*'(-?\d+)\s+(minutes?|hours?|days?|seconds?)'\s*\)/gi, (_match, amount, unit) => {
2225
+ const n = parseInt(amount, 10);
2226
+ const absN = Math.abs(n);
2227
+ const normalizedUnit = unit.toLowerCase().replace(/s$/, "");
2228
+ const plural = absN === 1 ? normalizedUnit : `${normalizedUnit}s`;
2229
+ return n < 0 ? `NOW() - INTERVAL '${absN} ${plural}'` : `NOW() + INTERVAL '${absN} ${plural}'`;
2230
+ });
2231
+ out = out.replace(/lower\s*\(\s*hex\s*\(\s*randomblob\s*\(\s*\d+\s*\)\s*\)\s*\)/gi, "gen_random_uuid()::text");
2232
+ out = out.replace(/GROUP_CONCAT\s*\(/gi, "STRING_AGG(");
2233
+ out = out.replace(/json_extract\s*\(\s*(\w+)\s*,\s*'\$\.(\w+)'\s*\)/gi, (_match, col, key) => `${col}->>'${key}'`);
2234
+ out = out.replace(/\bAUTOINCREMENT\b/gi, "GENERATED ALWAYS AS IDENTITY");
2235
+ out = out.replace(/(?<![I])LIKE/gi, "ILIKE");
2236
+ out = out.replace(/\bIFNULL\s*\(/gi, "COALESCE(");
2237
+ out = out.replace(/INSERT\s+OR\s+REPLACE\s+INTO\s+"?(\w+)"?\s*\(([^)]+)\)\s*VALUES/gi, (_match, table, colList) => `INSERT INTO "${table}" (${colList}) VALUES`);
2238
+ out = out.replace(/INSERT\s+OR\s+REPLACE\s+INTO/gi, "INSERT INTO");
2239
+ if (/INSERT\s+OR\s+IGNORE\s+INTO/i.test(out)) {
2240
+ out = out.replace(/INSERT\s+OR\s+IGNORE\s+INTO/gi, "INSERT INTO");
2241
+ out = out.replace(/;?\s*$/, " ON CONFLICT DO NOTHING");
2242
+ }
2243
+ return out;
2244
+ }
1951
2245
 
1952
2246
  // src/db/sync-pg.ts
1953
2247
  import { Worker, MessageChannel, receiveMessageOnPort } from "worker_threads";
1954
2248
  import { fileURLToPath } from "url";
1955
- import { dirname, join as join9 } from "path";
1956
- import { existsSync as existsSync10 } from "fs";
1957
- import { translateSql, translateParams } from "@hasna/cloud";
2249
+ import { dirname, join as join10 } from "path";
2250
+ import { existsSync as existsSync11 } from "fs";
1958
2251
  function translateSqliteDates(sql) {
1959
2252
  let s = sql;
1960
2253
  const isoDate = (expr) => `to_char(${expr}, 'YYYY-MM-DD')`;
@@ -1979,12 +2272,12 @@ function translateSqliteDates(sql) {
1979
2272
  function resolveWorkerPath() {
1980
2273
  const here = dirname(fileURLToPath(import.meta.url));
1981
2274
  const candidates = [
1982
- join9(here, "pg-sync-worker.ts"),
1983
- join9(here, "pg-sync-worker.js"),
1984
- join9(here, "..", "db", "pg-sync-worker.js")
2275
+ join10(here, "pg-sync-worker.ts"),
2276
+ join10(here, "pg-sync-worker.js"),
2277
+ join10(here, "..", "db", "pg-sync-worker.js")
1985
2278
  ];
1986
2279
  for (const candidate of candidates)
1987
- if (existsSync10(candidate))
2280
+ if (existsSync11(candidate))
1988
2281
  return candidate;
1989
2282
  return candidates[0];
1990
2283
  }
@@ -2076,10 +2369,67 @@ class SyncPgAdapter {
2076
2369
  }
2077
2370
  var init_sync_pg = () => {};
2078
2371
 
2372
+ // src/db/pg-migrate.ts
2373
+ var exports_pg_migrate = {};
2374
+ __export(exports_pg_migrate, {
2375
+ resolvePgSsl: () => resolvePgSsl,
2376
+ applyPgMigrations: () => applyPgMigrations
2377
+ });
2378
+ import pg from "pg";
2379
+ function resolvePgSsl(dsn) {
2380
+ return dsn.includes("sslmode=require") || dsn.includes("ssl=true") ? { rejectUnauthorized: false } : undefined;
2381
+ }
2382
+ async function applyPgMigrations(connectionString, migrations, service = "unknown") {
2383
+ const pool = new pg.Pool({ connectionString, ssl: resolvePgSsl(connectionString) });
2384
+ const result = {
2385
+ service,
2386
+ applied: [],
2387
+ alreadyApplied: [],
2388
+ errors: [],
2389
+ totalMigrations: migrations.length
2390
+ };
2391
+ try {
2392
+ await pool.query(`CREATE TABLE IF NOT EXISTS _pg_migrations (
2393
+ id SERIAL PRIMARY KEY,
2394
+ version INT UNIQUE NOT NULL,
2395
+ applied_at TIMESTAMPTZ DEFAULT NOW()
2396
+ )`);
2397
+ const existing = await pool.query("SELECT version FROM _pg_migrations ORDER BY version");
2398
+ const appliedSet = new Set(existing.rows.map((row) => Number(row["version"])));
2399
+ for (let i = 0;i < migrations.length; i++) {
2400
+ if (appliedSet.has(i)) {
2401
+ result.alreadyApplied.push(i);
2402
+ continue;
2403
+ }
2404
+ try {
2405
+ await pool.query(migrations[i]);
2406
+ await pool.query("INSERT INTO _pg_migrations (version) VALUES ($1) ON CONFLICT DO NOTHING", [i]);
2407
+ result.applied.push(i);
2408
+ } catch (err) {
2409
+ result.errors.push(`Migration ${i}: ${err instanceof Error ? err.message : String(err)}`);
2410
+ break;
2411
+ }
2412
+ }
2413
+ } finally {
2414
+ await pool.end().catch(() => {});
2415
+ }
2416
+ return result;
2417
+ }
2418
+ var init_pg_migrate = () => {};
2419
+
2079
2420
  // src/db/pg-migrations.ts
2080
2421
  var PG_MIGRATIONS;
2081
2422
  var init_pg_migrations = __esm(() => {
2082
2423
  PG_MIGRATIONS = [
2424
+ `CREATE TABLE IF NOT EXISTS cost_centers (
2425
+ id TEXT PRIMARY KEY,
2426
+ kind TEXT NOT NULL,
2427
+ name TEXT NOT NULL,
2428
+ repo_path TEXT,
2429
+ labels_json TEXT DEFAULT '{}',
2430
+ created_at TEXT NOT NULL,
2431
+ updated_at TEXT NOT NULL
2432
+ )`,
2083
2433
  `CREATE TABLE IF NOT EXISTS requests (
2084
2434
  id TEXT PRIMARY KEY,
2085
2435
  agent TEXT NOT NULL,
@@ -2096,6 +2446,7 @@ var init_pg_migrations = __esm(() => {
2096
2446
  timestamp TEXT NOT NULL,
2097
2447
  source_request_id TEXT,
2098
2448
  machine_id TEXT DEFAULT '',
2449
+ cost_center_id TEXT,
2099
2450
  account_key TEXT DEFAULT '',
2100
2451
  account_tool TEXT DEFAULT '',
2101
2452
  account_name TEXT DEFAULT '',
@@ -2113,6 +2464,7 @@ var init_pg_migrations = __esm(() => {
2113
2464
  total_tokens INTEGER DEFAULT 0,
2114
2465
  request_count INTEGER DEFAULT 0,
2115
2466
  machine_id TEXT DEFAULT '',
2467
+ cost_center_id TEXT,
2116
2468
  account_key TEXT DEFAULT '',
2117
2469
  account_tool TEXT DEFAULT '',
2118
2470
  account_name TEXT DEFAULT '',
@@ -2131,6 +2483,7 @@ var init_pg_migrations = __esm(() => {
2131
2483
  id TEXT PRIMARY KEY,
2132
2484
  project_path TEXT,
2133
2485
  agent TEXT,
2486
+ cost_center_id TEXT,
2134
2487
  period TEXT NOT NULL,
2135
2488
  limit_usd REAL NOT NULL,
2136
2489
  alert_at_percent INTEGER DEFAULT 80,
@@ -2160,6 +2513,7 @@ var init_pg_migrations = __esm(() => {
2160
2513
  `CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_path)`,
2161
2514
  `CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at)`,
2162
2515
  `CREATE INDEX IF NOT EXISTS idx_sessions_machine ON sessions(machine_id)`,
2516
+ `CREATE INDEX IF NOT EXISTS idx_cost_centers_kind ON cost_centers(kind)`,
2163
2517
  `CREATE TABLE IF NOT EXISTS model_pricing (
2164
2518
  model TEXT PRIMARY KEY,
2165
2519
  input_per_1m REAL NOT NULL DEFAULT 0,
@@ -2245,6 +2599,7 @@ var init_pg_migrations = __esm(() => {
2245
2599
  `ALTER TABLE requests ADD COLUMN IF NOT EXISTS account_source TEXT DEFAULT ''`,
2246
2600
  `ALTER TABLE requests ADD COLUMN IF NOT EXISTS updated_at TEXT DEFAULT ''`,
2247
2601
  `ALTER TABLE requests ADD COLUMN IF NOT EXISTS synced_at TEXT DEFAULT ''`,
2602
+ `ALTER TABLE requests ADD COLUMN IF NOT EXISTS cost_center_id TEXT`,
2248
2603
  `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS attribution_tag TEXT DEFAULT ''`,
2249
2604
  `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS account_key TEXT DEFAULT ''`,
2250
2605
  `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS account_tool TEXT DEFAULT ''`,
@@ -2253,10 +2608,16 @@ var init_pg_migrations = __esm(() => {
2253
2608
  `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS account_source TEXT DEFAULT ''`,
2254
2609
  `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS updated_at TEXT DEFAULT ''`,
2255
2610
  `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS synced_at TEXT DEFAULT ''`,
2611
+ `ALTER TABLE sessions ADD COLUMN IF NOT EXISTS cost_center_id TEXT`,
2612
+ `ALTER TABLE budgets ADD COLUMN IF NOT EXISTS cost_center_id TEXT`,
2613
+ `ALTER TABLE cost_centers ADD COLUMN IF NOT EXISTS updated_at TEXT DEFAULT ''`,
2256
2614
  `CREATE INDEX IF NOT EXISTS idx_usage_agent_date ON usage_snapshots(agent, date)`,
2257
2615
  `CREATE INDEX IF NOT EXISTS idx_savings_date ON savings_daily(date)`,
2258
2616
  `CREATE INDEX IF NOT EXISTS idx_requests_account ON requests(account_key)`,
2259
2617
  `CREATE INDEX IF NOT EXISTS idx_sessions_account ON sessions(account_key)`,
2618
+ `CREATE INDEX IF NOT EXISTS idx_requests_cost_center ON requests(cost_center_id)`,
2619
+ `CREATE INDEX IF NOT EXISTS idx_sessions_cost_center ON sessions(cost_center_id)`,
2620
+ `CREATE INDEX IF NOT EXISTS idx_budgets_cost_center ON budgets(cost_center_id)`,
2260
2621
  `ALTER TABLE requests ALTER COLUMN input_tokens TYPE BIGINT`,
2261
2622
  `ALTER TABLE requests ALTER COLUMN output_tokens TYPE BIGINT`,
2262
2623
  `ALTER TABLE requests ALTER COLUMN cache_read_tokens TYPE BIGINT`,
@@ -2299,7 +2660,7 @@ __export(exports_cloud, {
2299
2660
  cloudMigrations: () => cloudMigrations,
2300
2661
  authClientFromPool: () => authClientFromPool
2301
2662
  });
2302
- import pg from "pg";
2663
+ import pg2 from "pg";
2303
2664
  function getCloudDatabaseUrl() {
2304
2665
  return process.env["HASNA_ECONOMY_DATABASE_URL"]?.trim() || process.env["ECONOMY_DATABASE_URL"]?.trim() || process.env["DATABASE_URL"]?.trim() || undefined;
2305
2666
  }
@@ -2323,8 +2684,7 @@ function openCloudDatabase(dsn = getCloudDatabaseUrl()) {
2323
2684
  function createCloudPool(dsn = getCloudDatabaseUrl()) {
2324
2685
  if (!dsn)
2325
2686
  throw new Error("economy cloud mode requires a Postgres DSN (HASNA_ECONOMY_DATABASE_URL)");
2326
- const ssl = dsn.includes("sslmode=require") || dsn.includes("ssl=true") ? { rejectUnauthorized: false } : undefined;
2327
- const pool = new pg.Pool({ connectionString: dsn, ssl, max: Number(process.env["ECONOMY_PG_POOL_MAX"] ?? "5"), connectionTimeoutMillis: 1e4 });
2687
+ const pool = new pg2.Pool({ connectionString: dsn, ssl: resolvePgSsl(dsn), max: Number(process.env["ECONOMY_PG_POOL_MAX"] ?? "5"), connectionTimeoutMillis: 1e4 });
2328
2688
  pool.on("error", (err) => {
2329
2689
  console.error(JSON.stringify({ evt: "pg_pool_error", message: err.message }));
2330
2690
  });
@@ -2350,6 +2710,7 @@ function cloudMigrations() {
2350
2710
  }
2351
2711
  var init_cloud = __esm(() => {
2352
2712
  init_sync_pg();
2713
+ init_pg_migrate();
2353
2714
  init_pg_migrations();
2354
2715
  });
2355
2716
 
@@ -2628,13 +2989,14 @@ async function syncOpenProjectsRegistry(db, listActiveProjects) {
2628
2989
  let imported = 0;
2629
2990
  let skipped = 0;
2630
2991
  for (const project of projects) {
2631
- if (!project.path) {
2992
+ const path = resolveProjectPath(project);
2993
+ if (!path) {
2632
2994
  skipped++;
2633
2995
  continue;
2634
2996
  }
2635
2997
  upsertProject(db, {
2636
2998
  id: project.id,
2637
- path: project.path,
2999
+ path,
2638
3000
  name: project.name,
2639
3001
  description: project.description,
2640
3002
  tags: project.tags ?? [],
@@ -2644,20 +3006,21 @@ async function syncOpenProjectsRegistry(db, listActiveProjects) {
2644
3006
  }
2645
3007
  return { imported, skipped };
2646
3008
  }
3009
+ var resolveProjectPath = (project) => project.primary_path || project.path || "";
2647
3010
  var init_open_projects = __esm(() => {
2648
3011
  init_database();
2649
3012
  });
2650
3013
 
2651
3014
  // 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";
3015
+ import { existsSync as existsSync12, readFileSync as readFileSync10, writeFileSync, mkdirSync as mkdirSync2 } from "fs";
3016
+ import { dirname as dirname2, join as join11 } from "path";
2654
3017
  function getConfigPath() {
2655
- return process.env["HASNA_ECONOMY_CONFIG_PATH"] ?? join10(getDataDir(), "config.json");
3018
+ return process.env["HASNA_ECONOMY_CONFIG_PATH"] ?? join11(getDataDir(), "config.json");
2656
3019
  }
2657
3020
  function loadConfig() {
2658
3021
  try {
2659
3022
  const configPath = getConfigPath();
2660
- if (existsSync11(configPath)) {
3023
+ if (existsSync12(configPath)) {
2661
3024
  const raw = readFileSync10(configPath, "utf-8");
2662
3025
  return { ...DEFAULTS, ...JSON.parse(raw) };
2663
3026
  }
@@ -2737,6 +3100,8 @@ async function checkAndFireWebhooks(db) {
2737
3100
  event: "budget_alert",
2738
3101
  budget_id: b.id,
2739
3102
  project: b.project_path ?? "global",
3103
+ cost_center_id: b.cost_center_id ?? null,
3104
+ scope: b.cost_center_id ?? b.project_path ?? "global",
2740
3105
  period: b.period,
2741
3106
  spend: b.current_spend_usd,
2742
3107
  limit: b.limit_usd,
@@ -2960,6 +3325,7 @@ function defaultCostBasisForAgent(agent) {
2960
3325
  }
2961
3326
 
2962
3327
  // src/lib/accounts.ts
3328
+ import { appliedProfileName, resolveStore } from "@hasna/accounts";
2963
3329
  var AGENT_ACCOUNT_TOOLS = {
2964
3330
  claude: ["claude"],
2965
3331
  takumi: ["takumi", "claude"],
@@ -3024,57 +3390,35 @@ function envOverride(agent, env) {
3024
3390
  account_source: "override"
3025
3391
  };
3026
3392
  }
3027
- function knownToolIds(api) {
3028
- try {
3029
- return new Set(api.listTools().map((tool) => tool.id));
3030
- } catch {
3031
- return new Set;
3032
- }
3033
- }
3034
- function profileForEnvDir(api, tool, env) {
3393
+ async function profileForEnvDir(store, tool, env) {
3035
3394
  const configuredDir = env[tool.envVar];
3036
3395
  if (!configuredDir)
3037
3396
  return null;
3038
3397
  const normalized = normalizeDir(configuredDir);
3039
- try {
3040
- return api.listProfiles(tool.id).find((profile) => normalizeDir(profile.dir) === normalized) ?? null;
3041
- } catch {
3042
- return null;
3043
- }
3398
+ return (await store.listProfiles(tool.id)).find((profile) => normalizeDir(profile.dir) === normalized) ?? null;
3044
3399
  }
3045
3400
  async function resolveAccountForAgent(agent, env = process.env) {
3046
3401
  const override = envOverride(agent, env);
3047
3402
  if (override)
3048
3403
  return override;
3049
- let api;
3050
- try {
3051
- api = await import("@hasna/accounts");
3052
- } catch {
3053
- return null;
3054
- }
3055
- const toolIds = knownToolIds(api);
3404
+ const store = resolveStore(env);
3405
+ const tools = new Map((await store.listTools()).map((tool) => [tool.id, tool]));
3056
3406
  for (const toolId of AGENT_ACCOUNT_TOOLS[agent]) {
3057
- if (!toolIds.has(toolId))
3058
- continue;
3059
- let tool;
3060
- try {
3061
- tool = api.getTool(toolId);
3062
- } catch {
3407
+ const tool = tools.get(toolId);
3408
+ if (!tool)
3063
3409
  continue;
3064
- }
3065
- const envProfile = profileForEnvDir(api, tool, env);
3410
+ const envProfile = await profileForEnvDir(store, tool, env);
3066
3411
  if (envProfile)
3067
3412
  return fromProfile(envProfile, "env");
3068
- try {
3069
- const applied = api.appliedProfile(toolId);
3413
+ const appliedName = appliedProfileName(toolId);
3414
+ if (appliedName) {
3415
+ const applied = await store.findProfile(appliedName, toolId);
3070
3416
  if (applied)
3071
3417
  return fromProfile(applied, "applied");
3072
- } catch {}
3073
- try {
3074
- const current = api.currentProfile(toolId);
3075
- if (current)
3076
- return fromProfile(current, "current");
3077
- } catch {}
3418
+ }
3419
+ const current = await store.currentProfile(toolId);
3420
+ if (current)
3421
+ return fromProfile(current, "current");
3078
3422
  }
3079
3423
  return null;
3080
3424
  }
@@ -4095,32 +4439,219 @@ function statSyncSafe(path) {
4095
4439
  }
4096
4440
  }
4097
4441
 
4442
+ // src/ingest/loops.ts
4443
+ init_database();
4444
+ init_pricing();
4445
+ import { existsSync as existsSync8 } from "fs";
4446
+ import { homedir as homedir8 } from "os";
4447
+ import { join as join8 } from "path";
4448
+ import { Database as BunDatabase2 } from "bun:sqlite";
4449
+ var DEFAULT_LOOPS_DB_PATH = join8(homedir8(), ".hasna", "loops", "loops.db");
4450
+ var LOOPS_INGEST_VERSION = "goal-runs-orchestration-v1";
4451
+ function loopsDbPath() {
4452
+ return process.env["HASNA_ECONOMY_LOOPS_DB_PATH"] ?? DEFAULT_LOOPS_DB_PATH;
4453
+ }
4454
+ function loopsModel() {
4455
+ return process.env["HASNA_ECONOMY_LOOPS_MODEL"] ?? process.env["ECONOMY_LOOPS_MODEL"] ?? "gpt-5.3-codex";
4456
+ }
4457
+ function openLoopsDb(dbPath, verbose) {
4458
+ let lastError;
4459
+ for (const readonly of [true, false]) {
4460
+ let loopsDb = null;
4461
+ try {
4462
+ loopsDb = readonly ? new BunDatabase2(dbPath, { readonly: true }) : new BunDatabase2(dbPath);
4463
+ loopsDb.prepare("PRAGMA schema_version").get();
4464
+ return loopsDb;
4465
+ } catch (error) {
4466
+ lastError = error;
4467
+ loopsDb?.close();
4468
+ }
4469
+ }
4470
+ if (verbose) {
4471
+ const message = lastError instanceof Error ? lastError.message : String(lastError);
4472
+ console.log("OpenLoops DB unreadable:", dbPath, message);
4473
+ }
4474
+ return null;
4475
+ }
4476
+ function slug(value) {
4477
+ return value.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "unknown";
4478
+ }
4479
+ function parseTarget(raw) {
4480
+ if (!raw)
4481
+ return {};
4482
+ try {
4483
+ const parsed = JSON.parse(raw);
4484
+ return parsed && typeof parsed === "object" ? parsed : {};
4485
+ } catch {
4486
+ return {};
4487
+ }
4488
+ }
4489
+ function stringValue(value) {
4490
+ return typeof value === "string" ? value.trim() : "";
4491
+ }
4492
+ function accountFields(row) {
4493
+ const target = parseTarget(row.target_json);
4494
+ const tool = stringValue(row.account_tool) || stringValue(target["account_tool"]) || stringValue(target["tool"]) || stringValue(target["agent"]);
4495
+ const profile = stringValue(row.account_profile) || stringValue(target["account_profile"]) || stringValue(target["profile"]) || stringValue(target["account"]);
4496
+ if (!tool || !profile) {
4497
+ return { account_key: "", account_tool: tool, account_name: profile, account_source: "" };
4498
+ }
4499
+ return {
4500
+ account_key: `${tool}:${profile}`,
4501
+ account_tool: tool,
4502
+ account_name: profile,
4503
+ account_source: "loops"
4504
+ };
4505
+ }
4506
+ function readLoopGoalRuns(loopsDb) {
4507
+ return loopsDb.prepare(`
4508
+ SELECT
4509
+ gr.id as goal_run_id,
4510
+ gr.goal_id as goal_id,
4511
+ COALESCE(gr.loop_id, g.loop_id, lr.loop_id) as loop_id,
4512
+ COALESCE(gr.loop_run_id, g.loop_run_id, lr.id) as loop_run_id,
4513
+ COALESCE(l.name, lr.loop_name, 'unknown-loop') as loop_name,
4514
+ l.labels_json as labels_json,
4515
+ l.target_json as target_json,
4516
+ g.objective as objective,
4517
+ gr.phase as phase,
4518
+ gr.status as status,
4519
+ COALESCE(gr.tokens_used, 0) as tokens_used,
4520
+ gr.created_at as created_at,
4521
+ gr.updated_at as updated_at,
4522
+ lr.started_at as loop_run_started_at,
4523
+ wsr.account_profile as account_profile,
4524
+ wsr.account_tool as account_tool
4525
+ FROM goal_runs gr
4526
+ LEFT JOIN goals g ON g.id = gr.goal_id
4527
+ LEFT JOIN loop_runs lr ON lr.id = COALESCE(gr.loop_run_id, g.loop_run_id)
4528
+ LEFT JOIN loops l ON l.id = COALESCE(gr.loop_id, g.loop_id, lr.loop_id)
4529
+ LEFT JOIN (
4530
+ SELECT goal_run_id, MIN(account_profile) as account_profile, MIN(account_tool) as account_tool
4531
+ FROM workflow_step_runs
4532
+ WHERE goal_run_id IS NOT NULL
4533
+ GROUP BY goal_run_id
4534
+ ) wsr ON wsr.goal_run_id = gr.id
4535
+ WHERE COALESCE(gr.tokens_used, 0) > 0
4536
+ ORDER BY gr.created_at ASC, gr.id ASC
4537
+ `).all();
4538
+ }
4539
+ async function ingestLoops(db, verbose = false) {
4540
+ const dbPath = loopsDbPath();
4541
+ if (!existsSync8(dbPath)) {
4542
+ if (verbose)
4543
+ console.log("OpenLoops DB not found:", dbPath);
4544
+ return { sessions: 0, requests: 0 };
4545
+ }
4546
+ const model = loopsModel();
4547
+ const machineId = getMachineId();
4548
+ let loopsDb = null;
4549
+ let requests = 0;
4550
+ const sessions = new Set;
4551
+ try {
4552
+ loopsDb = openLoopsDb(dbPath, verbose);
4553
+ if (!loopsDb)
4554
+ return { sessions: 0, requests: 0 };
4555
+ for (const row of readLoopGoalRuns(loopsDb)) {
4556
+ const loopName = row.loop_name || "unknown-loop";
4557
+ const costCenterId = `loop:${slug(loopName)}`;
4558
+ const sessionId = `loops-${row.loop_run_id ?? row.loop_id ?? row.goal_id}`;
4559
+ const requestId = `loops-${row.goal_run_id}`;
4560
+ const timestamp = row.updated_at ?? row.created_at ?? row.loop_run_started_at ?? new Date().toISOString();
4561
+ const stateValue = `${LOOPS_INGEST_VERSION}:${row.updated_at ?? ""}:${row.tokens_used}:${model}:${costCenterId}`;
4562
+ if (getIngestState(db, "loops", row.goal_run_id) === stateValue)
4563
+ continue;
4564
+ upsertCostCenter(db, {
4565
+ id: costCenterId,
4566
+ kind: "loop",
4567
+ name: loopName,
4568
+ repo_path: null,
4569
+ labels_json: row.labels_json ?? "{}",
4570
+ created_at: row.created_at ?? timestamp
4571
+ });
4572
+ const account = accountFields(row);
4573
+ upsertSession(db, {
4574
+ id: sessionId,
4575
+ agent: "loop",
4576
+ project_path: "",
4577
+ project_name: loopName,
4578
+ started_at: row.loop_run_started_at ?? row.created_at ?? timestamp,
4579
+ ended_at: row.updated_at ?? null,
4580
+ total_cost_usd: 0,
4581
+ total_tokens: 0,
4582
+ request_count: 0,
4583
+ machine_id: machineId,
4584
+ cost_center_id: costCenterId,
4585
+ attribution_tag: "loops-orchestration",
4586
+ ...account
4587
+ });
4588
+ const inputTokens = row.tokens_used;
4589
+ const outputTokens = 0;
4590
+ upsertRequest(db, {
4591
+ id: requestId,
4592
+ agent: "loop",
4593
+ session_id: sessionId,
4594
+ model,
4595
+ input_tokens: inputTokens,
4596
+ output_tokens: outputTokens,
4597
+ cache_read_tokens: 0,
4598
+ cache_create_tokens: 0,
4599
+ cost_usd: computeCostFromDb(db, model, inputTokens, outputTokens),
4600
+ cost_basis: "estimated",
4601
+ duration_ms: 0,
4602
+ timestamp,
4603
+ source_request_id: requestId,
4604
+ machine_id: machineId,
4605
+ cost_center_id: costCenterId,
4606
+ attribution_tag: "loops-orchestration",
4607
+ ...account
4608
+ });
4609
+ sessions.add(sessionId);
4610
+ requests++;
4611
+ setIngestState(db, "loops", row.goal_run_id, stateValue);
4612
+ if (verbose)
4613
+ console.log(`OpenLoops ${loopName}: ${row.tokens_used} orchestration tokens`);
4614
+ }
4615
+ } catch (error) {
4616
+ if (verbose) {
4617
+ const message = error instanceof Error ? error.message : String(error);
4618
+ console.log("OpenLoops ingest skipped:", message);
4619
+ }
4620
+ return { sessions: 0, requests: 0 };
4621
+ } finally {
4622
+ loopsDb?.close();
4623
+ }
4624
+ for (const sessionId of sessions)
4625
+ rollupSession(db, sessionId);
4626
+ return { sessions: sessions.size, requests };
4627
+ }
4628
+
4098
4629
  // src/ingest/claude-quota.ts
4099
4630
  init_database();
4100
- import { existsSync as existsSync8, readFileSync as readFileSync6 } from "fs";
4631
+ import { existsSync as existsSync9, readFileSync as readFileSync6 } from "fs";
4101
4632
 
4102
4633
  // src/lib/paths.ts
4103
- import { homedir as homedir8 } from "os";
4104
- import { join as join8 } from "path";
4634
+ import { homedir as homedir9 } from "os";
4635
+ import { join as join9 } from "path";
4105
4636
  function getHomeDir() {
4106
- return process.env["USERPROFILE"] ?? process.env["HOME"] ?? homedir8();
4637
+ return process.env["USERPROFILE"] ?? process.env["HOME"] ?? homedir9();
4107
4638
  }
4108
4639
  function agentPaths() {
4109
4640
  const home = getHomeDir();
4110
4641
  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")
4642
+ claudeProjects: join9(home, ".claude", "projects"),
4643
+ claudeCredentials: join9(home, ".claude", ".credentials.json"),
4644
+ takumiProjects: join9(home, ".takumi", "projects"),
4645
+ codexDir: join9(home, ".codex"),
4646
+ codexDb: join9(home, ".codex", "state_5.sqlite"),
4647
+ codexAuth: join9(home, ".codex", "auth.json"),
4648
+ codexConfig: join9(home, ".codex", "config.toml"),
4649
+ geminiTmp: join9(home, ".gemini", "tmp"),
4650
+ geminiHistory: join9(home, ".gemini", "history"),
4651
+ opencodeMessages: join9(home, ".local", "share", "opencode", "storage", "message"),
4652
+ piSessions: join9(home, ".pi", "agent", "sessions"),
4653
+ hermesDir: join9(home, ".hermes"),
4654
+ hermesDb: join9(home, ".hermes", "state.db")
4124
4655
  };
4125
4656
  }
4126
4657
 
@@ -4132,7 +4663,7 @@ function readClaudeToken() {
4132
4663
  const fromEnv = process.env["CLAUDE_OAUTH_TOKEN"] ?? process.env["ANTHROPIC_OAUTH_TOKEN"];
4133
4664
  if (fromEnv)
4134
4665
  return { token: fromEnv };
4135
- if (!existsSync8(CREDENTIALS_PATH))
4666
+ if (!existsSync9(CREDENTIALS_PATH))
4136
4667
  return null;
4137
4668
  try {
4138
4669
  const creds = JSON.parse(readFileSync6(CREDENTIALS_PATH, "utf-8"));
@@ -4271,14 +4802,14 @@ async function ingestClaudeQuota(db, verbose = false) {
4271
4802
 
4272
4803
  // src/ingest/codex-quota.ts
4273
4804
  init_database();
4274
- import { existsSync as existsSync9, readFileSync as readFileSync7 } from "fs";
4805
+ import { existsSync as existsSync10, readFileSync as readFileSync7 } from "fs";
4275
4806
  var WHAM_USAGE_URL = process.env["CODEX_USAGE_URL"] ?? "https://chatgpt.com/backend-api/wham/usage";
4276
4807
  function readCodexAuth() {
4277
4808
  const fromEnv = process.env["CODEX_OAUTH_TOKEN"];
4278
4809
  if (fromEnv)
4279
4810
  return { token: fromEnv, authMode: "chatgpt" };
4280
4811
  const authPath = agentPaths().codexAuth;
4281
- if (!existsSync9(authPath))
4812
+ if (!existsSync10(authPath))
4282
4813
  return null;
4283
4814
  try {
4284
4815
  const auth = JSON.parse(readFileSync7(authPath, "utf-8"));
@@ -4411,7 +4942,7 @@ async function ingestCodexQuota(db, verbose = false) {
4411
4942
  // src/lib/sync-all.ts
4412
4943
  init_database();
4413
4944
  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);
4945
+ const anySpecific = Boolean(opts.claude || opts.takumi || opts.codex || opts.gemini || opts.opencode || opts.cursor || opts.pi || opts.hermes || opts.loops);
4415
4946
  const all = !anySpecific;
4416
4947
  const result = { deduped: 0 };
4417
4948
  if (all || opts.claude) {
@@ -4434,6 +4965,8 @@ async function syncAll(db, opts = {}) {
4434
4965
  result.pi = await ingestPi(db, opts.verbose);
4435
4966
  if (all || opts.hermes)
4436
4967
  result.hermes = await ingestHermes(db, opts.verbose);
4968
+ if (all || opts.loops)
4969
+ result.loops = await ingestLoops(db, opts.verbose);
4437
4970
  if (opts.dedupe !== false)
4438
4971
  result.deduped = dedupeRequests(db);
4439
4972
  return result;
@@ -5277,7 +5810,7 @@ function isAuthorizedRequest(req, path) {
5277
5810
 
5278
5811
  // src/server/serve.ts
5279
5812
  import { randomUUID } from "crypto";
5280
- import { existsSync as existsSync12 } from "fs";
5813
+ import { existsSync as existsSync13 } from "fs";
5281
5814
  import { resolve, sep } from "path";
5282
5815
 
5283
5816
  // src/lib/package-metadata.ts
@@ -6063,6 +6596,15 @@ var openApiSpec = {
6063
6596
  type: "string"
6064
6597
  }
6065
6598
  },
6599
+ {
6600
+ name: "since",
6601
+ in: "query",
6602
+ description: "ISO timestamp lower bound; takes precedence over period",
6603
+ schema: {
6604
+ type: "string",
6605
+ format: "date-time"
6606
+ }
6607
+ },
6066
6608
  {
6067
6609
  name: "machine",
6068
6610
  in: "query",
@@ -6914,7 +7456,7 @@ var CORS = {
6914
7456
  var APP = "economy";
6915
7457
  var ECONOMY_WRITE_SCOPE = `${APP}:write`;
6916
7458
  var AGENT_ERROR = `agent must be one of: ${AGENTS.join(", ")}`;
6917
- var SYNC_SOURCES = ["all", ...AGENTS];
7459
+ var SYNC_SOURCES = ["all", ...AGENTS, "loops"];
6918
7460
  var DEFAULT_DASHBOARD_DIR = new URL("../../dashboard/dist", import.meta.url).pathname;
6919
7461
  function json(data, status = 200) {
6920
7462
  return new Response(JSON.stringify(data), {
@@ -6987,13 +7529,13 @@ function createServerFetch(apiHandler, dashboardDir = DEFAULT_DASHBOARD_DIR) {
6987
7529
  if (url.pathname.startsWith("/api") || url.pathname.startsWith("/v1") || FOUNDATION_PATHS.has(url.pathname)) {
6988
7530
  return apiHandler(req);
6989
7531
  }
6990
- if (existsSync12(dashboardDir)) {
7532
+ if (existsSync13(dashboardDir)) {
6991
7533
  const filePath = dashboardPath(dashboardDir, url.pathname);
6992
- if (filePath && existsSync12(filePath)) {
7534
+ if (filePath && existsSync13(filePath)) {
6993
7535
  return new Response(Bun.file(filePath));
6994
7536
  }
6995
7537
  const indexPath = dashboardPath(dashboardDir, "/");
6996
- if (indexPath && existsSync12(indexPath)) {
7538
+ if (indexPath && existsSync13(indexPath)) {
6997
7539
  return new Response(Bun.file(indexPath));
6998
7540
  }
6999
7541
  }
@@ -7155,13 +7697,18 @@ function createHandler(db, options = {}) {
7155
7697
  if (path === "/api/breakdown" && method === "GET") {
7156
7698
  const by = url.searchParams.get("by") ?? "model";
7157
7699
  const period = url.searchParams.get("period") ?? "all";
7700
+ const since = url.searchParams.get("since") ?? undefined;
7158
7701
  const machine = url.searchParams.get("machine") ?? undefined;
7159
7702
  if (by === "project")
7160
- return ok(queryProjectBreakdown(db, period, machine));
7703
+ return ok(since ? queryProjectBreakdownSince(db, since, machine) : queryProjectBreakdown(db, period, machine));
7161
7704
  if (by === "agent")
7162
7705
  return ok(queryAgentBreakdown(db, period, machine));
7163
7706
  if (by === "account")
7164
7707
  return ok(queryAccountBreakdown(db, period, machine));
7708
+ if (by === "cost-center")
7709
+ return ok(queryCostCenterBreakdown(db, period, { machine }));
7710
+ if (["loop", "app", "repo", "service", "team"].includes(by))
7711
+ return ok(queryCostCenterBreakdown(db, period, { kind: by, machine }));
7165
7712
  return ok(queryModelBreakdown(db));
7166
7713
  }
7167
7714
  if (path === "/api/budgets" && method === "GET") {
@@ -7185,6 +7732,7 @@ function createHandler(db, options = {}) {
7185
7732
  id: randomUUID(),
7186
7733
  project_path: body["project_path"] ?? null,
7187
7734
  agent,
7735
+ cost_center_id: optionalString(body["cost_center_id"]) ?? null,
7188
7736
  period: normalizeBudgetPeriod(body["period"]),
7189
7737
  limit_usd: limitUsd,
7190
7738
  alert_at_percent: alertAtPercent,
@@ -7546,12 +8094,12 @@ function parsePort(raw, label) {
7546
8094
  }
7547
8095
  async function runMigrate() {
7548
8096
  const { getCloudDatabaseUrl: getCloudDatabaseUrl2, createCloudPool: createCloudPool2, authClientFromPool: authClientFromPool2, cloudMigrations: cloudMigrations2 } = await Promise.resolve().then(() => (init_cloud(), exports_cloud));
7549
- const { applyPgMigrations } = await import("@hasna/cloud");
8097
+ const { applyPgMigrations: applyPgMigrations2 } = await Promise.resolve().then(() => (init_pg_migrate(), exports_pg_migrate));
7550
8098
  const { ApiKeyStore: ApiKeyStore2 } = await import("@hasna/contracts/auth");
7551
8099
  const dsn = getCloudDatabaseUrl2();
7552
8100
  if (!dsn)
7553
8101
  throw new Error("migrate requires a Postgres DSN: set HASNA_ECONOMY_DATABASE_URL (or ECONOMY_DATABASE_URL / DATABASE_URL)");
7554
- const result = await applyPgMigrations(dsn, cloudMigrations2(), "economy");
8102
+ const result = await applyPgMigrations2(dsn, cloudMigrations2(), "economy");
7555
8103
  const pool = createCloudPool2(dsn);
7556
8104
  try {
7557
8105
  await new ApiKeyStore2(authClientFromPool2(pool)).ensureSchema();