@hasna/economy 0.2.16 → 0.2.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -79,7 +79,6 @@ var DEFAULT_PRICING;
79
79
  var init_pricing = __esm(() => {
80
80
  init_database();
81
81
  DEFAULT_PRICING = {
82
- "claude-opus-4-7": { inputPer1M: 5, outputPer1M: 25, cacheReadPer1M: 0.5, cacheWritePer1M: 6.25 },
83
82
  "claude-opus-4-6": { inputPer1M: 5, outputPer1M: 25, cacheReadPer1M: 0.5, cacheWritePer1M: 6.25 },
84
83
  "claude-opus-4-5": { inputPer1M: 5, outputPer1M: 25, cacheReadPer1M: 0.5, cacheWritePer1M: 6.25 },
85
84
  "claude-sonnet-4-6": { inputPer1M: 3, outputPer1M: 15, cacheReadPer1M: 0.3, cacheWritePer1M: 3.75 },
@@ -90,95 +89,28 @@ var init_pricing = __esm(() => {
90
89
  "claude-3-opus": { inputPer1M: 15, outputPer1M: 75, cacheReadPer1M: 1.5, cacheWritePer1M: 18.75 },
91
90
  "claude-3-sonnet": { inputPer1M: 3, outputPer1M: 15, cacheReadPer1M: 0.3, cacheWritePer1M: 3.75 },
92
91
  "claude-3-haiku": { inputPer1M: 0.25, outputPer1M: 1.25, cacheReadPer1M: 0.03, cacheWritePer1M: 0.3 },
93
- "gemini-3.1-pro": { inputPer1M: 1.25, outputPer1M: 10, cacheReadPer1M: 0.31, cacheWritePer1M: 0 },
94
- "gemini-2.5-pro": { inputPer1M: 1.25, outputPer1M: 10, cacheReadPer1M: 0, cacheWritePer1M: 0 },
95
- "gemini-2.5-flash": { inputPer1M: 0.15, outputPer1M: 0.6, cacheReadPer1M: 0, cacheWritePer1M: 0 },
96
92
  "gemini-2.0-flash": { inputPer1M: 0.075, outputPer1M: 0.3, cacheReadPer1M: 0, cacheWritePer1M: 0 },
93
+ "gemini-2.5-pro": { inputPer1M: 1.25, outputPer1M: 10, cacheReadPer1M: 0, cacheWritePer1M: 0 },
97
94
  "gemini-1.5-pro": { inputPer1M: 1.25, outputPer1M: 5, cacheReadPer1M: 0, cacheWritePer1M: 0 },
98
95
  "gemini-1.5-flash": { inputPer1M: 0.075, outputPer1M: 0.3, cacheReadPer1M: 0, cacheWritePer1M: 0 },
99
- "gpt-5.4": { inputPer1M: 2.5, outputPer1M: 15, cacheReadPer1M: 0.25, cacheWritePer1M: 0 },
100
- "gpt-5.4-pro": { inputPer1M: 30, outputPer1M: 180, cacheReadPer1M: 0, cacheWritePer1M: 0 },
101
- "gpt-5.4-mini": { inputPer1M: 0.75, outputPer1M: 4.5, cacheReadPer1M: 0.075, cacheWritePer1M: 0 },
102
96
  "gpt-5.3-codex": { inputPer1M: 1.75, outputPer1M: 14, cacheReadPer1M: 0.44, cacheWritePer1M: 0 },
103
- "gpt-5.3-chat": { inputPer1M: 2, outputPer1M: 8, cacheReadPer1M: 0.5, cacheWritePer1M: 0 },
104
97
  "gpt-5.2-codex": { inputPer1M: 1.75, outputPer1M: 14, cacheReadPer1M: 0.44, cacheWritePer1M: 0 },
105
98
  "gpt-5-codex": { inputPer1M: 1.75, outputPer1M: 14, cacheReadPer1M: 0.44, cacheWritePer1M: 0 },
106
- "gpt-5-mini": { inputPer1M: 0.3, outputPer1M: 1.2, cacheReadPer1M: 0.075, cacheWritePer1M: 0 },
107
- "gpt-5.2": { inputPer1M: 2, outputPer1M: 8, cacheReadPer1M: 0.5, cacheWritePer1M: 0 },
108
99
  "gpt-4o": { inputPer1M: 2.5, outputPer1M: 10, cacheReadPer1M: 1.25, cacheWritePer1M: 0 },
109
100
  "gpt-4o-mini": { inputPer1M: 0.15, outputPer1M: 0.6, cacheReadPer1M: 0.075, cacheWritePer1M: 0 },
110
101
  o1: { inputPer1M: 15, outputPer1M: 60, cacheReadPer1M: 7.5, cacheWritePer1M: 0 },
111
102
  "o1-mini": { inputPer1M: 3, outputPer1M: 12, cacheReadPer1M: 1.5, cacheWritePer1M: 0 },
112
103
  o3: { inputPer1M: 10, outputPer1M: 40, cacheReadPer1M: 2.5, cacheWritePer1M: 0 },
113
104
  "o3-mini": { inputPer1M: 1.1, outputPer1M: 4.4, cacheReadPer1M: 0.55, cacheWritePer1M: 0 },
114
- "o4-mini": { inputPer1M: 1.1, outputPer1M: 4.4, cacheReadPer1M: 0.275, cacheWritePer1M: 0 },
115
- "qwen3.6-plus": { inputPer1M: 0.8, outputPer1M: 2, cacheReadPer1M: 0, cacheWritePer1M: 0 },
116
- "qwen3.6": { inputPer1M: 0.3, outputPer1M: 0.6, cacheReadPer1M: 0, cacheWritePer1M: 0 },
117
- "minimax-m2.7": { inputPer1M: 0.7, outputPer1M: 0.7, cacheReadPer1M: 0, cacheWritePer1M: 0 },
118
- "minimax-m2.7-highspeed": { inputPer1M: 0.7, outputPer1M: 0.7, cacheReadPer1M: 0, cacheWritePer1M: 0 },
119
- "minimax-m1": { inputPer1M: 0.2, outputPer1M: 1.1, cacheReadPer1M: 0, cacheWritePer1M: 0 },
120
- "grok-3": { inputPer1M: 3, outputPer1M: 15, cacheReadPer1M: 0, cacheWritePer1M: 0 },
121
- "grok-3-mini": { inputPer1M: 0.3, outputPer1M: 0.5, cacheReadPer1M: 0, cacheWritePer1M: 0 },
122
- "glm-5.1": { inputPer1M: 0.7, outputPer1M: 0.7, cacheReadPer1M: 0, cacheWritePer1M: 0 },
123
- "glm-5": { inputPer1M: 0.7, outputPer1M: 0.7, cacheReadPer1M: 0, cacheWritePer1M: 0 },
124
- "kimi-k2": { inputPer1M: 0.6, outputPer1M: 0.6, cacheReadPer1M: 0, cacheWritePer1M: 0 }
105
+ "o4-mini": { inputPer1M: 1.1, outputPer1M: 4.4, cacheReadPer1M: 0.275, cacheWritePer1M: 0 }
125
106
  };
126
107
  });
127
108
 
128
109
  // src/db/database.ts
129
- var exports_database = {};
130
- __export(exports_database, {
131
- upsertSession: () => upsertSession,
132
- upsertRequest: () => upsertRequest,
133
- upsertProject: () => upsertProject,
134
- upsertModelPricing: () => upsertModelPricing,
135
- upsertGoal: () => upsertGoal,
136
- upsertBudget: () => upsertBudget,
137
- upsertBillingDaily: () => upsertBillingDaily,
138
- setIngestState: () => setIngestState,
139
- seedModelPricing: () => seedModelPricing,
140
- rollupSession: () => rollupSession,
141
- queryTopSessions: () => queryTopSessions,
142
- querySummary: () => querySummary,
143
- querySessions: () => querySessions,
144
- queryRequestsSince: () => queryRequestsSince,
145
- queryProjectBreakdown: () => queryProjectBreakdown,
146
- queryModelBreakdown: () => queryModelBreakdown,
147
- queryDailyBreakdown: () => queryDailyBreakdown,
148
- queryBillingSummary: () => queryBillingSummary,
149
- openDatabase: () => openDatabase,
150
- listProjects: () => listProjects,
151
- listModelPricing: () => listModelPricing,
152
- listMachines: () => listMachines,
153
- listGoals: () => listGoals,
154
- listBudgets: () => listBudgets,
155
- getProject: () => getProject,
156
- getModelPricing: () => getModelPricing,
157
- getMachineId: () => getMachineId,
158
- getIngestState: () => getIngestState,
159
- getGoalStatuses: () => getGoalStatuses,
160
- getDbPath: () => getDbPath,
161
- getDataDir: () => getDataDir,
162
- getBudgetStatuses: () => getBudgetStatuses,
163
- deleteProject: () => deleteProject,
164
- deleteModelPricing: () => deleteModelPricing,
165
- deleteGoal: () => deleteGoal,
166
- deleteBudget: () => deleteBudget,
167
- clearBillingRange: () => clearBillingRange
168
- });
169
110
  import { SqliteAdapter as Database } from "@hasna/cloud";
170
111
  import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from "fs";
171
- import { hostname } from "os";
172
112
  import { homedir } from "os";
173
113
  import { join } from "path";
174
- function getMachineId() {
175
- if (process.env["ECONOMY_MACHINE_ID"])
176
- return process.env["ECONOMY_MACHINE_ID"];
177
- const h = hostname().toLowerCase();
178
- if (h.startsWith("spark") || h.startsWith("apple"))
179
- return h.split(".")[0];
180
- return h.split(".")[0];
181
- }
182
114
  function getDataDir() {
183
115
  const home = process.env["HOME"] || process.env["USERPROFILE"] || homedir();
184
116
  const newDir = join(home, ".hasna", "economy");
@@ -211,7 +143,6 @@ function openDatabase(dbPath, skipSeed = false) {
211
143
  }
212
144
  const db = new Database(path);
213
145
  db.exec("PRAGMA journal_mode = WAL");
214
- db.exec("PRAGMA busy_timeout = 5000");
215
146
  db.exec("PRAGMA foreign_keys = ON");
216
147
  initSchema(db);
217
148
  if (!skipSeed) {
@@ -233,8 +164,7 @@ function initSchema(db) {
233
164
  cost_usd REAL NOT NULL DEFAULT 0,
234
165
  duration_ms INTEGER DEFAULT 0,
235
166
  timestamp TEXT NOT NULL,
236
- source_request_id TEXT,
237
- machine_id TEXT DEFAULT ''
167
+ source_request_id TEXT
238
168
  );
239
169
 
240
170
  CREATE TABLE IF NOT EXISTS sessions (
@@ -246,8 +176,7 @@ function initSchema(db) {
246
176
  ended_at TEXT,
247
177
  total_cost_usd REAL DEFAULT 0,
248
178
  total_tokens INTEGER DEFAULT 0,
249
- request_count INTEGER DEFAULT 0,
250
- machine_id TEXT DEFAULT ''
179
+ request_count INTEGER DEFAULT 0
251
180
  );
252
181
 
253
182
  CREATE TABLE IF NOT EXISTS projects (
@@ -312,27 +241,6 @@ function initSchema(db) {
312
241
  machine_id TEXT,
313
242
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
314
243
  );
315
-
316
- CREATE TABLE IF NOT EXISTS billing_daily (
317
- date TEXT NOT NULL,
318
- provider TEXT NOT NULL,
319
- description TEXT DEFAULT '',
320
- cost_usd REAL NOT NULL DEFAULT 0,
321
- updated_at TEXT NOT NULL,
322
- PRIMARY KEY (date, provider, description)
323
- );
324
-
325
- CREATE INDEX IF NOT EXISTS idx_billing_date ON billing_daily(date);
326
- CREATE INDEX IF NOT EXISTS idx_billing_provider ON billing_daily(provider);
327
- `);
328
- const cols = db.prepare(`PRAGMA table_info(requests)`).all();
329
- if (!cols.some((c) => c.name === "machine_id")) {
330
- db.exec(`ALTER TABLE requests ADD COLUMN machine_id TEXT DEFAULT ''`);
331
- db.exec(`ALTER TABLE sessions ADD COLUMN machine_id TEXT DEFAULT ''`);
332
- }
333
- db.exec(`
334
- CREATE INDEX IF NOT EXISTS idx_requests_machine ON requests(machine_id);
335
- CREATE INDEX IF NOT EXISTS idx_sessions_machine ON sessions(machine_id);
336
244
  `);
337
245
  }
338
246
  function periodWhere(period) {
@@ -342,11 +250,11 @@ function periodWhere(period) {
342
250
  case "yesterday":
343
251
  return `DATE(timestamp) = DATE('now', '-1 day')`;
344
252
  case "week":
345
- return `timestamp >= DATE('now', 'weekday 0', '-7 days')`;
253
+ return `timestamp >= DATE('now', '-7 days')`;
346
254
  case "month":
347
- return `timestamp >= DATE('now', 'start of month')`;
255
+ return `timestamp >= DATE('now', '-30 days')`;
348
256
  case "year":
349
- return `timestamp >= DATE('now', 'start of year')`;
257
+ return `timestamp >= DATE('now', '-365 days')`;
350
258
  case "all":
351
259
  return "1=1";
352
260
  }
@@ -358,11 +266,11 @@ function sessionPeriodWhere(period) {
358
266
  case "yesterday":
359
267
  return `DATE(started_at) = DATE('now', '-1 day')`;
360
268
  case "week":
361
- return `started_at >= DATE('now', 'weekday 0', '-7 days')`;
269
+ return `started_at >= DATE('now', '-7 days')`;
362
270
  case "month":
363
- return `started_at >= DATE('now', 'start of month')`;
271
+ return `started_at >= DATE('now', '-30 days')`;
364
272
  case "year":
365
- return `started_at >= DATE('now', 'start of year')`;
273
+ return `started_at >= DATE('now', '-365 days')`;
366
274
  case "all":
367
275
  return "1=1";
368
276
  }
@@ -372,17 +280,17 @@ function upsertRequest(db, req) {
372
280
  INSERT OR REPLACE INTO requests
373
281
  (id, agent, session_id, model, input_tokens, output_tokens,
374
282
  cache_read_tokens, cache_create_tokens, cost_usd, duration_ms,
375
- timestamp, source_request_id, machine_id)
376
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
377
- `).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.cost_usd, req.duration_ms, req.timestamp, req.source_request_id, req.machine_id ?? "");
283
+ timestamp, source_request_id)
284
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
285
+ `).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.cost_usd, req.duration_ms, req.timestamp, req.source_request_id);
378
286
  }
379
287
  function upsertSession(db, session) {
380
288
  db.prepare(`
381
289
  INSERT OR REPLACE INTO sessions
382
290
  (id, agent, project_path, project_name, started_at, ended_at,
383
- total_cost_usd, total_tokens, request_count, machine_id)
384
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
385
- `).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 ?? "");
291
+ total_cost_usd, total_tokens, request_count)
292
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
293
+ `).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);
386
294
  }
387
295
  function rollupSession(db, sessionId) {
388
296
  db.prepare(`
@@ -412,10 +320,6 @@ function querySessions(db, filter = {}) {
412
320
  conditions.push("started_at >= ?");
413
321
  params.push(filter.since);
414
322
  }
415
- if (filter.machine) {
416
- conditions.push("machine_id = ?");
417
- params.push(filter.machine);
418
- }
419
323
  if (filter.search) {
420
324
  const q = `%${filter.search}%`;
421
325
  conditions.push("(project_name LIKE ? OR agent LIKE ? OR id LIKE ?)");
@@ -434,25 +338,24 @@ function queryTopSessions(db, n = 10, agent) {
434
338
  }
435
339
  return db.prepare(`SELECT * FROM sessions ORDER BY total_cost_usd DESC LIMIT ?`).all(n);
436
340
  }
437
- function querySummary(db, period, machine) {
341
+ function querySummary(db, period) {
438
342
  const rWhere = periodWhere(period);
439
343
  const sWhere = sessionPeriodWhere(period);
440
- const machineClause = machine ? ` AND machine_id = '${machine.replace(/'/g, "''")}'` : "";
441
344
  const r = db.prepare(`
442
345
  SELECT COALESCE(SUM(cost_usd), 0) as total_usd,
443
346
  COUNT(*) as requests,
444
347
  COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_create_tokens), 0) as tokens
445
- FROM requests WHERE ${rWhere}${machineClause}
348
+ FROM requests WHERE ${rWhere}
446
349
  `).get();
447
350
  const codexTotals = db.prepare(`
448
351
  SELECT COALESCE(SUM(total_cost_usd), 0) as cost_usd,
449
352
  COALESCE(SUM(total_tokens), 0) as tokens,
450
353
  COUNT(*) as sessions
451
354
  FROM sessions
452
- WHERE ${sWhere}${machineClause}
355
+ WHERE ${sWhere}
453
356
  AND id NOT IN (SELECT DISTINCT session_id FROM requests)
454
357
  `).get();
455
- const sessionCount = db.prepare(`SELECT COUNT(*) as sessions FROM sessions WHERE ${sWhere}${machineClause}`).get();
358
+ const sessionCount = db.prepare(`SELECT COUNT(*) as sessions FROM sessions WHERE ${sWhere}`).get();
456
359
  return {
457
360
  total_usd: r.total_usd + codexTotals.cost_usd,
458
361
  requests: r.requests,
@@ -474,39 +377,19 @@ function queryModelBreakdown(db) {
474
377
  }
475
378
  function queryProjectBreakdown(db) {
476
379
  return db.prepare(`
477
- WITH labeled AS (
478
- SELECT
479
- s.id,
480
- s.project_path,
481
- s.total_cost_usd,
482
- s.started_at,
483
- COALESCE(
484
- NULLIF(s.project_name, ''),
485
- CASE
486
- WHEN s.project_path LIKE '%/%'
487
- THEN substr(s.project_path, length(rtrim(s.project_path, replace(s.project_path, '/', ''))) + 1)
488
- ELSE s.project_path
489
- END
490
- ) as label
491
- FROM sessions s
492
- WHERE s.project_path != '' OR s.project_name != ''
493
- )
494
380
  SELECT
495
- MIN(l.project_path) as project_path,
496
- l.label as project_name,
497
- COUNT(DISTINCT l.id) as sessions,
498
- COALESCE((SELECT COUNT(*) FROM requests r WHERE r.session_id IN (SELECT id FROM labeled WHERE label = l.label)), 0) as requests,
499
- COALESCE(
500
- (SELECT SUM(r.cost_usd) FROM requests r WHERE r.session_id IN (SELECT id FROM labeled WHERE label = l.label)),
501
- SUM(l.total_cost_usd)
502
- ) as cost_usd,
503
- COALESCE(
504
- (SELECT SUM(r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_create_tokens) FROM requests r WHERE r.session_id IN (SELECT id FROM labeled WHERE label = l.label)),
505
- 0
506
- ) as total_tokens,
507
- MAX(l.started_at) as last_active
508
- FROM labeled l
509
- GROUP BY l.label
381
+ s.project_path,
382
+ COALESCE(p.name, s.project_name) as project_name,
383
+ COUNT(DISTINCT s.id) as sessions,
384
+ COUNT(r.id) as requests,
385
+ COALESCE(SUM(r.cost_usd), COALESCE(SUM(s.total_cost_usd), 0)) as cost_usd,
386
+ COALESCE(SUM(r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_create_tokens), 0) as total_tokens,
387
+ MAX(s.started_at) as last_active
388
+ FROM sessions s
389
+ LEFT JOIN projects p ON p.path = s.project_path OR p.name = s.project_name
390
+ LEFT JOIN requests r ON r.session_id = s.id
391
+ WHERE s.project_path != '' OR s.project_name != ''
392
+ GROUP BY s.project_path
510
393
  ORDER BY cost_usd DESC
511
394
  `).all();
512
395
  }
@@ -626,40 +509,6 @@ function setIngestState(db, source, key, value) {
626
509
  function queryRequestsSince(db, since) {
627
510
  return db.prepare(`SELECT * FROM requests WHERE timestamp > ? ORDER BY timestamp ASC`).all(since);
628
511
  }
629
- function upsertBillingDaily(db, row) {
630
- db.prepare(`
631
- INSERT OR REPLACE INTO billing_daily (date, provider, description, cost_usd, updated_at)
632
- VALUES (?, ?, ?, ?, ?)
633
- `).run(row.date, row.provider, row.description, row.cost_usd, row.updated_at);
634
- }
635
- function clearBillingRange(db, provider, fromDate, toDate) {
636
- db.prepare(`DELETE FROM billing_daily WHERE provider = ? AND date >= ? AND date <= ?`).run(provider, fromDate, toDate);
637
- }
638
- function queryBillingSummary(db, period) {
639
- const where = period === "today" ? `date = DATE('now')` : period === "yesterday" ? `date = DATE('now', '-1 day')` : period === "week" ? `date >= DATE('now', 'weekday 0', '-7 days')` : period === "month" ? `date >= DATE('now', 'start of month')` : period === "year" ? `date >= DATE('now', 'start of year')` : "1=1";
640
- const rows = db.prepare(`SELECT provider, SUM(cost_usd) as cost FROM billing_daily WHERE ${where} GROUP BY provider`).all();
641
- const by_provider = {};
642
- let total = 0;
643
- for (const r of rows) {
644
- by_provider[r.provider] = r.cost;
645
- total += r.cost;
646
- }
647
- return { total_usd: total, by_provider };
648
- }
649
- function listMachines(db) {
650
- return db.prepare(`
651
- SELECT
652
- s.machine_id,
653
- COUNT(DISTINCT s.id) as sessions,
654
- COALESCE((SELECT COUNT(*) FROM requests r WHERE r.machine_id = s.machine_id), 0) as requests,
655
- COALESCE(SUM(s.total_cost_usd), 0) as total_cost_usd,
656
- MAX(s.started_at) as last_active
657
- FROM sessions s
658
- WHERE s.machine_id != ''
659
- GROUP BY s.machine_id
660
- ORDER BY total_cost_usd DESC
661
- `).all();
662
- }
663
512
  function upsertModelPricing(db, p) {
664
513
  db.prepare(`
665
514
  INSERT OR REPLACE INTO model_pricing
@@ -677,11 +526,11 @@ function deleteModelPricing(db, model) {
677
526
  db.prepare(`DELETE FROM model_pricing WHERE model = ?`).run(model);
678
527
  }
679
528
  function seedModelPricing(db, defaults) {
680
- const existing = new Set(db.prepare(`SELECT model FROM model_pricing`).all().map((r) => r.model));
529
+ const existing = db.prepare(`SELECT COUNT(*) as count FROM model_pricing`).get();
530
+ if (existing.count > 0)
531
+ return;
681
532
  const now = new Date().toISOString();
682
533
  for (const [model, p] of Object.entries(defaults)) {
683
- if (existing.has(model))
684
- continue;
685
534
  upsertModelPricing(db, {
686
535
  model,
687
536
  input_per_1m: p.inputPer1M,
@@ -720,36 +569,29 @@ function collectJsonlFiles(projectDir) {
720
569
  return files;
721
570
  }
722
571
  async function ingestClaude(db, verbose = false, _telemetryDir) {
723
- return ingestJsonlProjects(db, CLAUDE_PROJECTS_DIR, "claude", verbose);
724
- }
725
- async function ingestTakumi(db, verbose = false) {
726
- return ingestJsonlProjects(db, TAKUMI_PROJECTS_DIR, "takumi", verbose);
727
- }
728
- async function ingestJsonlProjects(db, projectsDir, agentName, verbose = false) {
729
- if (!existsSync3(projectsDir)) {
572
+ if (!existsSync3(PROJECTS_DIR)) {
730
573
  if (verbose)
731
- console.log(`${agentName} projects dir not found:`, projectsDir);
574
+ console.log("Claude projects dir not found:", PROJECTS_DIR);
732
575
  return { files: 0, requests: 0, sessions: 0 };
733
576
  }
734
- const machineId = getMachineId();
735
577
  let totalFiles = 0;
736
578
  let totalRequests = 0;
737
579
  const touchedSessions = new Set;
738
580
  const registeredProjects = db.prepare(`SELECT path, name FROM projects ORDER BY LENGTH(path) DESC`).all();
739
- const projectDirs = readdirSync2(projectsDir, { withFileTypes: true }).filter((d) => d.isDirectory());
581
+ const projectDirs = readdirSync2(PROJECTS_DIR, { withFileTypes: true }).filter((d) => d.isDirectory());
740
582
  for (const projectDirEntry of projectDirs) {
741
- const projectDirPath = join4(projectsDir, projectDirEntry.name);
583
+ const projectDirPath = join4(PROJECTS_DIR, projectDirEntry.name);
742
584
  const projectPath = dirNameToPath(projectDirEntry.name);
743
585
  const jsonlFiles = collectJsonlFiles(projectDirPath);
744
586
  for (const filePath of jsonlFiles) {
745
- const stateKey = filePath.replace(projectsDir, "");
587
+ const stateKey = filePath.replace(PROJECTS_DIR, "");
746
588
  let fileMtime = "0";
747
589
  try {
748
590
  fileMtime = statSync2(filePath).mtimeMs.toString();
749
591
  } catch {
750
592
  continue;
751
593
  }
752
- const processed = getIngestState(db, agentName, stateKey);
594
+ const processed = getIngestState(db, "claude", stateKey);
753
595
  if (processed === fileMtime)
754
596
  continue;
755
597
  let lines;
@@ -790,10 +632,10 @@ async function ingestJsonlProjects(db, projectsDir, agentName, verbose = false)
790
632
  if (inputTokens + outputTokens + cacheWriteTokens === 0)
791
633
  continue;
792
634
  const costUsd = computeCostFromDb(db, model, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens);
793
- const reqId = `${agentName}-${sessionId}-${timestamp}`;
635
+ const reqId = `claude-${sessionId}-${timestamp}`;
794
636
  upsertRequest(db, {
795
637
  id: reqId,
796
- agent: agentName,
638
+ agent: "claude",
797
639
  session_id: sessionId,
798
640
  model,
799
641
  input_tokens: inputTokens,
@@ -803,8 +645,7 @@ async function ingestJsonlProjects(db, projectsDir, agentName, verbose = false)
803
645
  cost_usd: costUsd,
804
646
  duration_ms: 0,
805
647
  timestamp,
806
- source_request_id: reqId,
807
- machine_id: machineId
648
+ source_request_id: reqId
808
649
  });
809
650
  if (!touchedSessions.has(sessionId)) {
810
651
  const existing = db.prepare(`SELECT id FROM sessions WHERE id = ?`).get(sessionId);
@@ -813,15 +654,14 @@ async function ingestJsonlProjects(db, projectsDir, agentName, verbose = false)
813
654
  const detectedProject = autoDetectProject(effectiveCwd, registeredProjects);
814
655
  const session = {
815
656
  id: sessionId,
816
- agent: agentName,
657
+ agent: "claude",
817
658
  project_path: detectedProject ? detectedProject.path : effectiveCwd,
818
659
  project_name: detectedProject ? detectedProject.name : "",
819
660
  started_at: timestamp,
820
661
  ended_at: null,
821
662
  total_cost_usd: 0,
822
663
  total_tokens: 0,
823
- request_count: 0,
824
- machine_id: machineId
664
+ request_count: 0
825
665
  };
826
666
  upsertSession(db, session);
827
667
  }
@@ -829,7 +669,7 @@ async function ingestJsonlProjects(db, projectsDir, agentName, verbose = false)
829
669
  }
830
670
  totalRequests++;
831
671
  }
832
- setIngestState(db, agentName, stateKey, fileMtime);
672
+ setIngestState(db, "claude", stateKey, fileMtime);
833
673
  totalFiles++;
834
674
  }
835
675
  }
@@ -838,30 +678,28 @@ async function ingestJsonlProjects(db, projectsDir, agentName, verbose = false)
838
678
  }
839
679
  return { files: totalFiles, requests: totalRequests, sessions: touchedSessions.size };
840
680
  }
841
- var CLAUDE_PROJECTS_DIR, TAKUMI_PROJECTS_DIR;
681
+ var PROJECTS_DIR;
842
682
  var init_claude = __esm(() => {
843
683
  init_database();
844
684
  init_pricing();
845
- CLAUDE_PROJECTS_DIR = join4(homedir2(), ".claude", "projects");
846
- TAKUMI_PROJECTS_DIR = join4(homedir2(), ".takumi", "projects");
685
+ PROJECTS_DIR = join4(homedir2(), ".claude", "projects");
847
686
  });
848
687
 
849
688
  // src/ingest/codex.ts
850
689
  import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
851
690
  import { homedir as homedir3 } from "os";
852
691
  import { join as join5, basename as basename2 } from "path";
853
- import { Database as BunDatabase } from "bun:sqlite";
692
+ import { Database as Database2 } from "bun:sqlite";
854
693
  async function ingestCodex(db, verbose = false) {
855
694
  if (!existsSync4(CODEX_DB_PATH)) {
856
695
  if (verbose)
857
696
  console.log("Codex DB not found:", CODEX_DB_PATH);
858
697
  return { sessions: 0 };
859
698
  }
860
- const machineId = getMachineId();
861
699
  let codexDb = null;
862
700
  let ingested = 0;
863
701
  try {
864
- codexDb = new BunDatabase(CODEX_DB_PATH, { readonly: true });
702
+ codexDb = new Database2(CODEX_DB_PATH, { readonly: true });
865
703
  const threads = codexDb.prepare(`SELECT id, cwd, created_at, updated_at, tokens_used, title FROM threads WHERE tokens_used > 0`).all();
866
704
  for (const thread of threads) {
867
705
  const stateKey = thread.id;
@@ -882,8 +720,7 @@ async function ingestCodex(db, verbose = false) {
882
720
  ended_at: endedAt,
883
721
  total_cost_usd: costUsd,
884
722
  total_tokens: thread.tokens_used,
885
- request_count: 1,
886
- machine_id: machineId
723
+ request_count: 1
887
724
  });
888
725
  setIngestState(db, "codex", stateKey, "done");
889
726
  ingested++;
@@ -902,88 +739,6 @@ var init_codex = __esm(() => {
902
739
  CODEX_CONFIG_PATH = join5(homedir3(), ".codex", "config.toml");
903
740
  });
904
741
 
905
- // src/ingest/gemini.ts
906
- import { readdirSync as readdirSync3, readFileSync as readFileSync4, existsSync as existsSync5, statSync as statSync3 } from "fs";
907
- import { homedir as homedir4 } from "os";
908
- import { join as join6 } from "path";
909
- async function ingestGemini(db, verbose) {
910
- if (!existsSync5(GEMINI_TMP_DIR)) {
911
- if (verbose)
912
- console.log("Gemini tmp dir not found:", GEMINI_TMP_DIR);
913
- return { sessions: 0 };
914
- }
915
- const machineId = getMachineId();
916
- let totalSessions = 0;
917
- const touchedSessions = new Set;
918
- let projectHashDirs = [];
919
- try {
920
- projectHashDirs = readdirSync3(GEMINI_TMP_DIR, { withFileTypes: true }).filter((d) => d.isDirectory() && /^[0-9a-f]{64}$/.test(d.name)).map((d) => join6(GEMINI_TMP_DIR, d.name));
921
- } catch {
922
- return { sessions: 0 };
923
- }
924
- for (const projectDir of projectHashDirs) {
925
- const chatsDir = join6(projectDir, "chats");
926
- if (!existsSync5(chatsDir))
927
- continue;
928
- let chatFiles = [];
929
- try {
930
- chatFiles = readdirSync3(chatsDir).filter((f) => f.endsWith(".json")).map((f) => join6(chatsDir, f));
931
- } catch {
932
- continue;
933
- }
934
- for (const filePath of chatFiles) {
935
- const stateKey = filePath.replace(homedir4(), "~");
936
- let fileMtime = "0";
937
- try {
938
- fileMtime = statSync3(filePath).mtimeMs.toString();
939
- } catch {
940
- continue;
941
- }
942
- const processed = getIngestState(db, "gemini", stateKey);
943
- if (processed === fileMtime)
944
- continue;
945
- let chatData;
946
- try {
947
- chatData = JSON.parse(readFileSync4(filePath, "utf-8"));
948
- } catch {
949
- continue;
950
- }
951
- const sessionId = chatData.sessionId;
952
- if (!sessionId)
953
- continue;
954
- const startTime = chatData.startTime ?? new Date().toISOString();
955
- const existing = db.prepare(`SELECT id FROM sessions WHERE id = ?`).get(sessionId);
956
- if (!existing) {
957
- const session = {
958
- id: sessionId,
959
- agent: "gemini",
960
- project_path: "",
961
- project_name: "",
962
- started_at: startTime,
963
- ended_at: chatData.lastUpdated ?? null,
964
- total_cost_usd: 0,
965
- total_tokens: 0,
966
- request_count: 0,
967
- machine_id: machineId
968
- };
969
- upsertSession(db, session);
970
- touchedSessions.add(sessionId);
971
- totalSessions++;
972
- }
973
- setIngestState(db, "gemini", stateKey, fileMtime);
974
- }
975
- }
976
- for (const sessionId of touchedSessions) {
977
- rollupSession(db, sessionId);
978
- }
979
- return { sessions: totalSessions };
980
- }
981
- var GEMINI_TMP_DIR;
982
- var init_gemini = __esm(() => {
983
- init_database();
984
- GEMINI_TMP_DIR = join6(homedir4(), ".gemini", "tmp");
985
- });
986
-
987
742
  // src/lib/config.ts
988
743
  var exports_config = {};
989
744
  __export(exports_config, {
@@ -992,12 +747,12 @@ __export(exports_config, {
992
747
  loadConfig: () => loadConfig2,
993
748
  getConfigValue: () => getConfigValue
994
749
  });
995
- import { existsSync as existsSync6, readFileSync as readFileSync6, writeFileSync as writeFileSync2, mkdirSync as mkdirSync3 } from "fs";
750
+ import { existsSync as existsSync6, readFileSync as readFileSync5, writeFileSync as writeFileSync2, mkdirSync as mkdirSync3 } from "fs";
996
751
  import { join as join7 } from "path";
997
752
  function loadConfig2() {
998
753
  try {
999
754
  if (existsSync6(CONFIG_PATH2)) {
1000
- const raw = readFileSync6(CONFIG_PATH2, "utf-8");
755
+ const raw = readFileSync5(CONFIG_PATH2, "utf-8");
1001
756
  return { ...DEFAULTS, ...JSON.parse(raw) };
1002
757
  }
1003
758
  } catch {}
@@ -1207,20 +962,6 @@ function ok(data, meta) {
1207
962
  function err(message, status = 400) {
1208
963
  return json({ error: message }, status);
1209
964
  }
1210
- function normalizeBudgetPeriod(value) {
1211
- switch (value) {
1212
- case "day":
1213
- case "daily":
1214
- return "daily";
1215
- case "week":
1216
- case "weekly":
1217
- return "weekly";
1218
- case "month":
1219
- case "monthly":
1220
- default:
1221
- return "monthly";
1222
- }
1223
- }
1224
965
  function applyFields(obj, fields) {
1225
966
  if (!fields || fields.length === 0)
1226
967
  return obj;
@@ -1237,11 +978,7 @@ function createHandler(db) {
1237
978
  return ok({ status: "ok", ts: new Date().toISOString() });
1238
979
  if (path === "/api/summary" && method === "GET") {
1239
980
  const period = url.searchParams.get("period") ?? "today";
1240
- const machine = url.searchParams.get("machine") ?? undefined;
1241
- return ok(querySummary(db, period, machine));
1242
- }
1243
- if (path === "/api/machines" && method === "GET") {
1244
- return ok(listMachines(db), { current_machine: getMachineId() });
981
+ return ok(querySummary(db, period));
1245
982
  }
1246
983
  if (path === "/api/daily" && method === "GET") {
1247
984
  const days = Number(url.searchParams.get("days") ?? 30);
@@ -1250,22 +987,12 @@ function createHandler(db) {
1250
987
  if (path === "/api/sessions" && method === "GET") {
1251
988
  const agent = url.searchParams.get("agent");
1252
989
  const project = url.searchParams.get("project") ?? undefined;
1253
- const search = url.searchParams.get("search") ?? undefined;
1254
- const machine = url.searchParams.get("machine") ?? undefined;
1255
990
  const limit = Number(url.searchParams.get("limit") ?? 50);
1256
991
  const offset = Number(url.searchParams.get("offset") ?? 0);
1257
992
  const since = url.searchParams.get("since") ?? undefined;
1258
993
  const fieldsParam = url.searchParams.get("fields");
1259
994
  const fields = fieldsParam ? fieldsParam.split(",").map((f) => f.trim()).filter(Boolean) : undefined;
1260
- const sessions = querySessions(db, {
1261
- agent: agent ?? undefined,
1262
- project,
1263
- search,
1264
- machine,
1265
- limit,
1266
- offset,
1267
- since
1268
- });
995
+ const sessions = querySessions(db, { agent: agent ?? undefined, project, limit, offset, since });
1269
996
  return ok(fields ? sessions.map((s) => applyFields(s, fields)) : sessions, { limit, offset });
1270
997
  }
1271
998
  if (path === "/api/top" && method === "GET") {
@@ -1293,7 +1020,7 @@ function createHandler(db) {
1293
1020
  id: randomUUID(),
1294
1021
  project_path: body["project_path"] ?? null,
1295
1022
  agent: body["agent"] ?? null,
1296
- period: normalizeBudgetPeriod(body["period"]),
1023
+ period: body["period"] ?? "monthly",
1297
1024
  limit_usd: Number(body["limit_usd"]),
1298
1025
  alert_at_percent: Number(body["alert_at_percent"] ?? 80),
1299
1026
  created_at: now,
@@ -1354,12 +1081,8 @@ function createHandler(db) {
1354
1081
  const results = {};
1355
1082
  if (sources === "all" || sources === "claude")
1356
1083
  results["claude"] = await ingestClaude(db);
1357
- if (sources === "all" || sources === "takumi")
1358
- results["takumi"] = await ingestTakumi(db);
1359
1084
  if (sources === "all" || sources === "codex")
1360
1085
  results["codex"] = await ingestCodex(db);
1361
- if (sources === "all" || sources === "gemini")
1362
- results["gemini"] = await ingestGemini(db);
1363
1086
  return ok(results);
1364
1087
  }
1365
1088
  const sessionRequestsMatch = path.match(/^\/api\/sessions\/([^/]+)\/requests$/);
@@ -1432,7 +1155,6 @@ var init_serve = __esm(() => {
1432
1155
  init_database();
1433
1156
  init_claude();
1434
1157
  init_codex();
1435
- init_gemini();
1436
1158
  init_pricing();
1437
1159
  CORS = {
1438
1160
  "Access-Control-Allow-Origin": "*",
@@ -1570,102 +1292,6 @@ function menubarStop() {
1570
1292
  var APP_PATH = "/Applications/Economy Bar.app", REPO = "hasna/open-economy";
1571
1293
  var init_menubar = () => {};
1572
1294
 
1573
- // src/db/pg-migrations.ts
1574
- var exports_pg_migrations = {};
1575
- __export(exports_pg_migrations, {
1576
- PG_MIGRATIONS: () => PG_MIGRATIONS
1577
- });
1578
- var PG_MIGRATIONS;
1579
- var init_pg_migrations = __esm(() => {
1580
- PG_MIGRATIONS = [
1581
- `CREATE TABLE IF NOT EXISTS requests (
1582
- id TEXT PRIMARY KEY,
1583
- agent TEXT NOT NULL,
1584
- session_id TEXT NOT NULL,
1585
- model TEXT NOT NULL,
1586
- input_tokens INTEGER DEFAULT 0,
1587
- output_tokens INTEGER DEFAULT 0,
1588
- cache_read_tokens INTEGER DEFAULT 0,
1589
- cache_create_tokens INTEGER DEFAULT 0,
1590
- cost_usd REAL NOT NULL DEFAULT 0,
1591
- duration_ms INTEGER DEFAULT 0,
1592
- timestamp TEXT NOT NULL,
1593
- source_request_id TEXT,
1594
- machine_id TEXT DEFAULT ''
1595
- )`,
1596
- `CREATE TABLE IF NOT EXISTS sessions (
1597
- id TEXT PRIMARY KEY,
1598
- agent TEXT NOT NULL,
1599
- project_path TEXT DEFAULT '',
1600
- project_name TEXT DEFAULT '',
1601
- started_at TEXT NOT NULL,
1602
- ended_at TEXT,
1603
- total_cost_usd REAL DEFAULT 0,
1604
- total_tokens INTEGER DEFAULT 0,
1605
- request_count INTEGER DEFAULT 0,
1606
- machine_id TEXT DEFAULT ''
1607
- )`,
1608
- `CREATE TABLE IF NOT EXISTS projects (
1609
- id TEXT PRIMARY KEY,
1610
- path TEXT UNIQUE NOT NULL,
1611
- name TEXT NOT NULL,
1612
- description TEXT,
1613
- tags TEXT DEFAULT '[]',
1614
- created_at TEXT NOT NULL
1615
- )`,
1616
- `CREATE TABLE IF NOT EXISTS budgets (
1617
- id TEXT PRIMARY KEY,
1618
- project_path TEXT,
1619
- agent TEXT,
1620
- period TEXT NOT NULL,
1621
- limit_usd REAL NOT NULL,
1622
- alert_at_percent INTEGER DEFAULT 80,
1623
- created_at TEXT NOT NULL,
1624
- updated_at TEXT NOT NULL
1625
- )`,
1626
- `CREATE TABLE IF NOT EXISTS goals (
1627
- id TEXT PRIMARY KEY,
1628
- period TEXT NOT NULL,
1629
- project_path TEXT,
1630
- agent TEXT,
1631
- limit_usd REAL NOT NULL,
1632
- created_at TEXT NOT NULL,
1633
- updated_at TEXT NOT NULL
1634
- )`,
1635
- `CREATE TABLE IF NOT EXISTS ingest_state (
1636
- source TEXT NOT NULL,
1637
- key TEXT NOT NULL,
1638
- value TEXT NOT NULL,
1639
- PRIMARY KEY (source, key)
1640
- )`,
1641
- `CREATE INDEX IF NOT EXISTS idx_requests_session ON requests(session_id)`,
1642
- `CREATE INDEX IF NOT EXISTS idx_requests_timestamp ON requests(timestamp)`,
1643
- `CREATE INDEX IF NOT EXISTS idx_requests_agent ON requests(agent)`,
1644
- `CREATE INDEX IF NOT EXISTS idx_requests_machine ON requests(machine_id)`,
1645
- `CREATE INDEX IF NOT EXISTS idx_sessions_agent ON sessions(agent)`,
1646
- `CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_path)`,
1647
- `CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at)`,
1648
- `CREATE INDEX IF NOT EXISTS idx_sessions_machine ON sessions(machine_id)`,
1649
- `CREATE TABLE IF NOT EXISTS model_pricing (
1650
- model TEXT PRIMARY KEY,
1651
- input_per_1m REAL NOT NULL DEFAULT 0,
1652
- output_per_1m REAL NOT NULL DEFAULT 0,
1653
- cache_read_per_1m REAL NOT NULL DEFAULT 0,
1654
- cache_write_per_1m REAL NOT NULL DEFAULT 0,
1655
- updated_at TEXT NOT NULL
1656
- )`,
1657
- `CREATE TABLE IF NOT EXISTS feedback (
1658
- id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
1659
- message TEXT NOT NULL,
1660
- email TEXT,
1661
- category TEXT DEFAULT 'general',
1662
- version TEXT,
1663
- machine_id TEXT,
1664
- created_at TEXT NOT NULL DEFAULT NOW()::text
1665
- )`
1666
- ];
1667
- });
1668
-
1669
1295
  // src/cli/index.ts
1670
1296
  import { Command } from "commander";
1671
1297
  import chalk4 from "chalk";
@@ -2024,148 +1650,94 @@ ${chalk.dim("Set it active:")} economy brains model set ${String(status["fine_tu
2024
1650
  init_database();
2025
1651
  init_claude();
2026
1652
  init_codex();
2027
- init_gemini();
2028
1653
 
2029
- // src/ingest/billing.ts
1654
+ // src/ingest/gemini.ts
2030
1655
  init_database();
2031
- function getAnthropicAdminKey() {
2032
- return process.env["HASNAXYZ_ANTHROPIC_LIVE_ADMIN_API_KEY"] ?? process.env["ANTHROPIC_ADMIN_API_KEY"] ?? null;
2033
- }
2034
- function getOpenAIAdminKey() {
2035
- return process.env["HASNAXYZ_OPENAI_LIVE_ADMIN_API_KEY"] ?? process.env["OPENAI_ADMIN_API_KEY"] ?? null;
2036
- }
2037
- function toISODate(d) {
2038
- return d.toISOString().substring(0, 10);
2039
- }
2040
- async function syncAnthropicBilling(db, opts = {}) {
2041
- const key = getAnthropicAdminKey();
2042
- if (!key)
2043
- throw new Error("Missing Anthropic admin key (HASNAXYZ_ANTHROPIC_LIVE_ADMIN_API_KEY)");
2044
- const now = new Date;
2045
- const end = opts.toDate ? new Date(opts.toDate) : new Date(now.getTime() + 24 * 3600000);
2046
- const days = opts.days ?? 31;
2047
- const start = opts.fromDate ? new Date(opts.fromDate) : new Date(end.getTime() - days * 24 * 3600000);
2048
- const startIso = start.toISOString().replace(/\.\d+/, "").replace(/:\d{2}Z$/, ":00Z");
2049
- const endIso = end.toISOString().replace(/\.\d+/, "").replace(/:\d{2}Z$/, ":00Z");
2050
- let totalUsd = 0;
2051
- const buckets = [];
2052
- let nextPage;
2053
- do {
2054
- const url = new URL("https://api.anthropic.com/v1/organizations/cost_report");
2055
- url.searchParams.set("starting_at", startIso);
2056
- url.searchParams.set("ending_at", endIso);
2057
- url.searchParams.set("bucket_width", "1d");
2058
- url.searchParams.set("limit", "31");
2059
- url.searchParams.append("group_by[]", "description");
2060
- if (nextPage)
2061
- url.searchParams.set("page", nextPage);
2062
- const res = await fetch(url.toString(), {
2063
- headers: { "anthropic-version": "2023-06-01", "x-api-key": key }
2064
- });
2065
- const data = await res.json();
2066
- if (data.error)
2067
- throw new Error(`Anthropic API: ${data.error.message}`);
2068
- if (data.data)
2069
- buckets.push(...data.data);
2070
- nextPage = data.has_more ? data.next_page : undefined;
2071
- } while (nextPage);
2072
- const fromDateStr = toISODate(start);
2073
- const toDateStr = toISODate(new Date(end.getTime() - 1000));
2074
- clearBillingRange(db, "anthropic", fromDateStr, toDateStr);
2075
- const updatedAt = new Date().toISOString();
2076
- for (const bucket of buckets) {
2077
- const date = bucket.starting_at.substring(0, 10);
2078
- for (const r of bucket.results) {
2079
- const usd = Number(r.amount) / 100;
2080
- if (usd === 0)
2081
- continue;
2082
- const desc = (r.description ?? "unknown").substring(0, 200);
2083
- upsertBillingDaily(db, { date, provider: "anthropic", description: desc, cost_usd: usd, updated_at: updatedAt });
2084
- totalUsd += usd;
2085
- }
1656
+ import { readdirSync as readdirSync3, readFileSync as readFileSync4, existsSync as existsSync5, statSync as statSync3 } from "fs";
1657
+ import { homedir as homedir4 } from "os";
1658
+ import { join as join6 } from "path";
1659
+ var GEMINI_TMP_DIR = join6(homedir4(), ".gemini", "tmp");
1660
+ async function ingestGemini(db, verbose) {
1661
+ if (!existsSync5(GEMINI_TMP_DIR)) {
1662
+ if (verbose)
1663
+ console.log("Gemini tmp dir not found:", GEMINI_TMP_DIR);
1664
+ return { sessions: 0 };
2086
1665
  }
2087
- return { days: buckets.length, totalUsd };
2088
- }
2089
- async function syncOpenAIBilling(db, opts = {}) {
2090
- const key = getOpenAIAdminKey();
2091
- if (!key)
2092
- throw new Error("Missing OpenAI admin key (HASNAXYZ_OPENAI_LIVE_ADMIN_API_KEY)");
2093
- const now = new Date;
2094
- const end = opts.toDate ? new Date(opts.toDate) : now;
2095
- const days = opts.days ?? 31;
2096
- const start = opts.fromDate ? new Date(opts.fromDate) : new Date(end.getTime() - days * 24 * 3600000);
2097
- const startSec = Math.floor(start.getTime() / 1000);
2098
- const endSec = Math.floor(end.getTime() / 1000);
2099
- let totalUsd = 0;
2100
- const buckets = [];
2101
- let nextPage;
2102
- do {
2103
- const url = new URL("https://api.openai.com/v1/organization/costs");
2104
- url.searchParams.set("start_time", String(startSec));
2105
- url.searchParams.set("end_time", String(endSec));
2106
- url.searchParams.set("bucket_width", "1d");
2107
- url.searchParams.set("limit", "31");
2108
- url.searchParams.append("group_by[]", "line_item");
2109
- if (nextPage)
2110
- url.searchParams.set("page", nextPage);
2111
- const res = await fetch(url.toString(), {
2112
- headers: { Authorization: `Bearer ${key}` }
2113
- });
2114
- const data = await res.json();
2115
- if (data.error)
2116
- throw new Error(`OpenAI API: ${data.error.message}`);
2117
- if (data.data)
2118
- buckets.push(...data.data);
2119
- nextPage = data.has_more ? data.next_page : undefined;
2120
- } while (nextPage);
2121
- const fromDateStr = toISODate(start);
2122
- const toDateStr = toISODate(new Date(end.getTime() - 1000));
2123
- clearBillingRange(db, "openai", fromDateStr, toDateStr);
2124
- const updatedAt = new Date().toISOString();
2125
- for (const bucket of buckets) {
2126
- const date = new Date(bucket.start_time * 1000).toISOString().substring(0, 10);
2127
- for (const r of bucket.results) {
2128
- const usd = Number(r.amount?.value ?? 0);
2129
- if (usd === 0)
1666
+ let totalSessions = 0;
1667
+ const touchedSessions = new Set;
1668
+ let projectHashDirs = [];
1669
+ try {
1670
+ projectHashDirs = readdirSync3(GEMINI_TMP_DIR, { withFileTypes: true }).filter((d) => d.isDirectory() && /^[0-9a-f]{64}$/.test(d.name)).map((d) => join6(GEMINI_TMP_DIR, d.name));
1671
+ } catch {
1672
+ return { sessions: 0 };
1673
+ }
1674
+ for (const projectDir of projectHashDirs) {
1675
+ const chatsDir = join6(projectDir, "chats");
1676
+ if (!existsSync5(chatsDir))
1677
+ continue;
1678
+ let chatFiles = [];
1679
+ try {
1680
+ chatFiles = readdirSync3(chatsDir).filter((f) => f.endsWith(".json")).map((f) => join6(chatsDir, f));
1681
+ } catch {
1682
+ continue;
1683
+ }
1684
+ for (const filePath of chatFiles) {
1685
+ const stateKey = filePath.replace(homedir4(), "~");
1686
+ let fileMtime = "0";
1687
+ try {
1688
+ fileMtime = statSync3(filePath).mtimeMs.toString();
1689
+ } catch {
1690
+ continue;
1691
+ }
1692
+ const processed = getIngestState(db, "gemini", stateKey);
1693
+ if (processed === fileMtime)
1694
+ continue;
1695
+ let chatData;
1696
+ try {
1697
+ chatData = JSON.parse(readFileSync4(filePath, "utf-8"));
1698
+ } catch {
1699
+ continue;
1700
+ }
1701
+ const sessionId = chatData.sessionId;
1702
+ if (!sessionId)
2130
1703
  continue;
2131
- const desc = (r.line_item ?? "unknown").substring(0, 200);
2132
- upsertBillingDaily(db, { date, provider: "openai", description: desc, cost_usd: usd, updated_at: updatedAt });
2133
- totalUsd += usd;
1704
+ const startTime = chatData.startTime ?? new Date().toISOString();
1705
+ const existing = db.prepare(`SELECT id FROM sessions WHERE id = ?`).get(sessionId);
1706
+ if (!existing) {
1707
+ const session = {
1708
+ id: sessionId,
1709
+ agent: "gemini",
1710
+ project_path: "",
1711
+ project_name: "",
1712
+ started_at: startTime,
1713
+ ended_at: chatData.lastUpdated ?? null,
1714
+ total_cost_usd: 0,
1715
+ total_tokens: 0,
1716
+ request_count: 0
1717
+ };
1718
+ upsertSession(db, session);
1719
+ touchedSessions.add(sessionId);
1720
+ totalSessions++;
1721
+ }
1722
+ setIngestState(db, "gemini", stateKey, fileMtime);
2134
1723
  }
2135
1724
  }
2136
- return { days: buckets.length, totalUsd };
2137
- }
2138
-
2139
- // src/cli/index.ts
2140
- init_database();
2141
-
2142
- // src/lib/package-metadata.ts
2143
- import { readFileSync as readFileSync5 } from "fs";
2144
- var cachedMetadata = null;
2145
- function getPackageMetadata() {
2146
- if (cachedMetadata)
2147
- return cachedMetadata;
2148
- const raw = readFileSync5(new URL("../../package.json", import.meta.url), "utf8");
2149
- const parsed = JSON.parse(raw);
2150
- cachedMetadata = {
2151
- name: parsed.name ?? "@hasna/economy",
2152
- version: parsed.version ?? "0.0.0"
2153
- };
2154
- return cachedMetadata;
1725
+ for (const sessionId of touchedSessions) {
1726
+ rollupSession(db, sessionId);
1727
+ }
1728
+ return { sessions: totalSessions };
2155
1729
  }
2156
- var packageMetadata = getPackageMetadata();
2157
1730
 
2158
1731
  // src/cli/index.ts
2159
1732
  init_pricing();
2160
1733
  import { randomUUID as randomUUID2 } from "crypto";
2161
1734
  import { execSync as execSync2 } from "child_process";
2162
1735
  var program = new Command;
2163
- program.name("economy").description("AI coding cost tracker \u2014 Claude Code, Codex, and Gemini").version(packageMetadata.version);
1736
+ program.name("economy").description("AI coding cost tracker \u2014 Claude Code, Codex, and Gemini").version("0.2.2");
2164
1737
  async function autoSync() {
2165
1738
  const db = openDatabase();
2166
1739
  ensurePricingSeeded(db);
2167
1740
  await ingestClaude(db);
2168
- await ingestTakumi(db);
2169
1741
  await ingestCodex(db);
2170
1742
  await ingestGemini(db);
2171
1743
  }
@@ -2278,7 +1850,7 @@ program.action(async () => {
2278
1850
  }
2279
1851
  console.log();
2280
1852
  });
2281
- program.command("sync").description("Ingest cost data from Claude Code, Codex, and Gemini").option("--claude", "Only ingest Claude Code telemetry").option("--takumi", "Only ingest Takumi sessions").option("--codex", "Only ingest Codex sessions").option("--gemini", "Only ingest Gemini CLI sessions").option("-v, --verbose", "Verbose output").option("--force", "Force re-process all files (ignore mtime cache)").option("--backfill-machine", "Tag existing records that have no machine_id with current hostname").option("--recalculate", "Recalculate costs for all requests with cost_usd = 0").action(async (opts) => {
1853
+ program.command("sync").description("Ingest cost data from Claude Code, Codex, and Gemini").option("--claude", "Only ingest Claude Code telemetry").option("--codex", "Only ingest Codex sessions").option("--gemini", "Only ingest Gemini CLI sessions").option("-v, --verbose", "Verbose output").option("--force", "Force re-process all files (ignore mtime cache)").action(async (opts) => {
2282
1854
  const db = openDatabase();
2283
1855
  ensurePricingSeeded(db);
2284
1856
  if (opts.force) {
@@ -2286,9 +1858,8 @@ program.command("sync").description("Ingest cost data from Claude Code, Codex, a
2286
1858
  if (opts.verbose)
2287
1859
  console.log(chalk4.dim("Cleared ingest cache"));
2288
1860
  }
2289
- const anySpecific = opts.claude || opts.takumi || opts.codex || opts.gemini;
1861
+ const anySpecific = opts.claude || opts.codex || opts.gemini;
2290
1862
  const doClaude = opts.claude || !anySpecific;
2291
- const doTakumi = opts.takumi || !anySpecific;
2292
1863
  const doCodex = opts.codex || !anySpecific;
2293
1864
  const doGemini = opts.gemini || !anySpecific;
2294
1865
  if (doClaude) {
@@ -2296,11 +1867,6 @@ program.command("sync").description("Ingest cost data from Claude Code, Codex, a
2296
1867
  const r = await ingestClaude(db, opts.verbose);
2297
1868
  console.log(chalk4.green(`\u2713 ${r.files} files, ${r.requests} requests, ${r.sessions} sessions`));
2298
1869
  }
2299
- if (doTakumi) {
2300
- process.stdout.write(chalk4.cyan("\u2192 Ingesting Takumi sessions... "));
2301
- const r = await ingestTakumi(db, opts.verbose);
2302
- console.log(chalk4.green(`\u2713 ${r.files} files, ${r.requests} requests, ${r.sessions} sessions`));
2303
- }
2304
1870
  if (doCodex) {
2305
1871
  process.stdout.write(chalk4.cyan("\u2192 Ingesting Codex sessions... "));
2306
1872
  const r = await ingestCodex(db, opts.verbose);
@@ -2311,35 +1877,6 @@ program.command("sync").description("Ingest cost data from Claude Code, Codex, a
2311
1877
  const r = await ingestGemini(db, opts.verbose);
2312
1878
  console.log(chalk4.green(`\u2713 ${r.sessions} sessions`));
2313
1879
  }
2314
- if (opts.backfillMachine) {
2315
- const machine = getMachineId();
2316
- const reqCount = db.prepare(`UPDATE requests SET machine_id = ? WHERE machine_id = '' OR machine_id IS NULL`).run(machine);
2317
- const sessCount = db.prepare(`UPDATE sessions SET machine_id = ? WHERE machine_id = '' OR machine_id IS NULL`).run(machine);
2318
- console.log(chalk4.cyan(`\u2192 Backfilled machine_id='${machine}': ${reqCount.changes} requests, ${sessCount.changes} sessions`));
2319
- }
2320
- if (opts.recalculate) {
2321
- const { computeCostFromDb: computeCostFromDb2 } = await Promise.resolve().then(() => (init_pricing(), exports_pricing));
2322
- const zeroRows = db.prepare(`SELECT id, model, input_tokens, output_tokens, cache_read_tokens, cache_create_tokens FROM requests WHERE cost_usd = 0 AND (input_tokens > 0 OR output_tokens > 0)`).all();
2323
- let fixed = 0;
2324
- for (const r of zeroRows) {
2325
- const cost = computeCostFromDb2(db, r.model, r.input_tokens, r.output_tokens, r.cache_read_tokens, r.cache_create_tokens);
2326
- if (cost > 0) {
2327
- db.prepare(`UPDATE requests SET cost_usd = ? WHERE id = ?`).run(cost, r.id);
2328
- fixed++;
2329
- }
2330
- }
2331
- if (fixed > 0) {
2332
- const touchedSessions = new Set(zeroRows.map((r) => {
2333
- const row = db.prepare(`SELECT session_id FROM requests WHERE id = ?`).get(r.id);
2334
- return row?.session_id;
2335
- }).filter(Boolean));
2336
- const { rollupSession: rollupSession2 } = await Promise.resolve().then(() => (init_database(), exports_database));
2337
- for (const sid of touchedSessions) {
2338
- rollupSession2(db, sid);
2339
- }
2340
- }
2341
- console.log(chalk4.cyan(`\u2192 Recalculated: ${fixed}/${zeroRows.length} zero-cost requests now have pricing`));
2342
- }
2343
1880
  try {
2344
1881
  const { checkAndFireWebhooks: checkAndFireWebhooks2 } = await Promise.resolve().then(() => (init_webhooks(), exports_webhooks));
2345
1882
  await checkAndFireWebhooks2(db);
@@ -2359,14 +1896,13 @@ program.command("month").description("Cost summary for this month").action(async
2359
1896
  await autoSync();
2360
1897
  printSummary("This Month", "month");
2361
1898
  });
2362
- program.command("sessions").description("List coding sessions with costs").option("--agent <agent>", "Filter by agent (claude|codex)").option("--project <path>", "Filter by project path").option("--machine <id>", "Filter by machine hostname (e.g. spark01, apple01)").option("--limit <n>", "Number of sessions", "20").option("--format <fmt>", "Output format: table|compact|csv|json", "table").option("--since <date>", "Filter sessions since date or relative (e.g. 2026-03-01, 7d, 30d)").option("--search <query>", "Search by project name, session id prefix, or agent").action(async (opts) => {
1899
+ program.command("sessions").description("List coding sessions with costs").option("--agent <agent>", "Filter by agent (claude|codex)").option("--project <path>", "Filter by project path").option("--limit <n>", "Number of sessions", "20").option("--format <fmt>", "Output format: table|compact|csv|json", "table").option("--since <date>", "Filter sessions since date or relative (e.g. 2026-03-01, 7d, 30d)").option("--search <query>", "Search by project name, session id prefix, or agent").action(async (opts) => {
2363
1900
  await autoSync();
2364
1901
  const db = openDatabase();
2365
1902
  const sinceDate = opts.since ? parseSinceDate(opts.since) : undefined;
2366
1903
  let sessions = querySessions(db, {
2367
1904
  agent: opts.agent,
2368
1905
  project: opts.project,
2369
- machine: opts.machine,
2370
1906
  limit: Number(opts.limit ?? 20),
2371
1907
  since: sinceDate,
2372
1908
  search: opts.search
@@ -2815,29 +2351,6 @@ program.command("session <id>").description("Show detailed breakdown of a single
2815
2351
  }
2816
2352
  console.log();
2817
2353
  });
2818
- program.command("machines").description("List all machines that have synced data").action(async () => {
2819
- await autoSync();
2820
- const db = openDatabase();
2821
- const machines = listMachines(db);
2822
- const current = getMachineId();
2823
- if (machines.length === 0) {
2824
- console.log(chalk4.yellow(`No machine data yet. Current machine: ${current}`));
2825
- return;
2826
- }
2827
- console.log();
2828
- console.log(chalk4.bold.cyan(" Machines"));
2829
- console.log();
2830
- printTable(["Machine", "Sessions", "Requests", "Cost", "Last Active"], machines.map((m) => [
2831
- m.machine_id === current ? chalk4.green(`${m.machine_id} (this)`) : chalk4.white(m.machine_id),
2832
- fmtCount(m.sessions),
2833
- fmtCount(m.requests),
2834
- fmt2(m.total_cost_usd),
2835
- chalk4.dim(m.last_active?.substring(0, 16) ?? "\u2014")
2836
- ]));
2837
- console.log(`
2838
- ${chalk4.dim("Current machine:")} ${chalk4.bold(current)}`);
2839
- console.log();
2840
- });
2841
2354
  program.command("export").description("Export data as CSV").option("--type <type>", "Data type: sessions or requests", "sessions").option("--period <period>", "Period: today|week|month|all", "month").option("--output <file>", "Output file path (default: stdout)").action(async (opts) => {
2842
2355
  await autoSync();
2843
2356
  const db = openDatabase();
@@ -3105,144 +2618,5 @@ program.command("remove <type> <id>").alias("rm").description("Remove a record.
3105
2618
  process.exit(1);
3106
2619
  }
3107
2620
  });
3108
- var CLOUD_RDS_HOST = "hasnaxyz-prod-opensource.c4limg0qgqvk.us-east-1.rds.amazonaws.com";
3109
- var CLOUD_RDS_USER = "hasna_admin";
3110
- var CLOUD_RDS_DB = "economy";
3111
- var CLOUD_TABLES = ["requests", "sessions", "projects", "budgets", "goals", "model_pricing"];
3112
- async function getCloudPassword() {
3113
- if (process.env["ECONOMY_PG_PASSWORD"])
3114
- return process.env["ECONOMY_PG_PASSWORD"];
3115
- const { execSync: exec } = await import("child_process");
3116
- const secretJson = exec(`aws --profile hasna-xyz-hq secretsmanager get-secret-value --secret-id 'rds!db-7a451ce6-83a9-40fa-b24a-81e5d5943511' --query SecretString --output text`, { timeout: 1e4, encoding: "utf-8" });
3117
- return JSON.parse(secretJson).password;
3118
- }
3119
- async function getCloudPg() {
3120
- const { PgAdapterAsync } = await import("@hasna/cloud");
3121
- const pw = encodeURIComponent(await getCloudPassword());
3122
- return new PgAdapterAsync(`postgresql://${CLOUD_RDS_USER}:${pw}@${CLOUD_RDS_HOST}:5432/${CLOUD_RDS_DB}?sslmode=require`);
3123
- }
3124
- var cloudCmd = program.command("cloud").description("Cross-machine sync via cloud PostgreSQL");
3125
- cloudCmd.command("push").description("Push local economy data to cloud PostgreSQL").option("--tables <tables>", "Comma-separated table names (default: all)").action(async (opts) => {
3126
- const { syncPush, SqliteAdapter } = await import("@hasna/cloud");
3127
- const { PG_MIGRATIONS: PG_MIGRATIONS2 } = await Promise.resolve().then(() => (init_pg_migrations(), exports_pg_migrations));
3128
- const cloud = await getCloudPg();
3129
- const local = new SqliteAdapter(getDbPath());
3130
- process.stdout.write(chalk4.cyan("\u2192 Running PG migrations... "));
3131
- for (const sql of PG_MIGRATIONS2) {
3132
- await cloud.run(sql);
3133
- }
3134
- console.log(chalk4.green("\u2713"));
3135
- const tableList = opts.tables ? opts.tables.split(",").map((t) => t.trim()) : CLOUD_TABLES;
3136
- process.stdout.write(chalk4.cyan(`\u2192 Pushing ${tableList.join(", ")}... `));
3137
- const results = await syncPush(local, cloud, { tables: tableList });
3138
- const totalRows = results.reduce((s, r) => s + r.rowsWritten, 0);
3139
- console.log(chalk4.green(`\u2713 ${totalRows} rows across ${tableList.length} tables`));
3140
- local.close();
3141
- await cloud.close();
3142
- console.log(chalk4.bold.green(`
3143
- \u2713 Push complete from ${getMachineId()}`));
3144
- });
3145
- cloudCmd.command("pull").description("Pull cloud PostgreSQL data to local").option("--tables <tables>", "Comma-separated table names (default: all)").action(async (opts) => {
3146
- const { syncPull, SqliteAdapter } = await import("@hasna/cloud");
3147
- const { PG_MIGRATIONS: PG_MIGRATIONS2 } = await Promise.resolve().then(() => (init_pg_migrations(), exports_pg_migrations));
3148
- const cloud = await getCloudPg();
3149
- const local = new SqliteAdapter(getDbPath());
3150
- process.stdout.write(chalk4.cyan("\u2192 Running PG migrations... "));
3151
- for (const sql of PG_MIGRATIONS2) {
3152
- await cloud.run(sql);
3153
- }
3154
- console.log(chalk4.green("\u2713"));
3155
- const tableList = opts.tables ? opts.tables.split(",").map((t) => t.trim()) : CLOUD_TABLES;
3156
- process.stdout.write(chalk4.cyan(`\u2192 Pulling ${tableList.join(", ")}... `));
3157
- const results = await syncPull(cloud, local, { tables: tableList });
3158
- const totalRows = results.reduce((s, r) => s + r.rowsWritten, 0);
3159
- console.log(chalk4.green(`\u2713 ${totalRows} rows across ${tableList.length} tables`));
3160
- local.close();
3161
- await cloud.close();
3162
- console.log(chalk4.bold.green(`
3163
- \u2713 Pull complete to ${getMachineId()}`));
3164
- });
3165
- cloudCmd.command("sync").description("Full sync: ingest local \u2192 push to cloud \u2192 pull from cloud").action(async () => {
3166
- console.log(chalk4.bold.cyan(` Cloud Sync \u2014 ${getMachineId()}
3167
- `));
3168
- process.stdout.write(chalk4.cyan("\u2192 Ingesting local data... "));
3169
- await autoSync();
3170
- console.log(chalk4.green("\u2713"));
3171
- const { syncPush, syncPull, SqliteAdapter } = await import("@hasna/cloud");
3172
- const { PG_MIGRATIONS: PG_MIGRATIONS2 } = await Promise.resolve().then(() => (init_pg_migrations(), exports_pg_migrations));
3173
- const cloud = await getCloudPg();
3174
- const local = new SqliteAdapter(getDbPath());
3175
- for (const sql of PG_MIGRATIONS2) {
3176
- await cloud.run(sql);
3177
- }
3178
- process.stdout.write(chalk4.cyan("\u2192 Pushing local \u2192 cloud... "));
3179
- const pushResults = await syncPush(local, cloud, { tables: CLOUD_TABLES });
3180
- console.log(chalk4.green(`\u2713 ${pushResults.reduce((s, r) => s + r.rowsWritten, 0)} rows`));
3181
- process.stdout.write(chalk4.cyan("\u2192 Pulling cloud \u2192 local... "));
3182
- const pullResults = await syncPull(cloud, local, { tables: CLOUD_TABLES });
3183
- console.log(chalk4.green(`\u2713 ${pullResults.reduce((s, r) => s + r.rowsWritten, 0)} rows`));
3184
- local.close();
3185
- await cloud.close();
3186
- console.log(chalk4.bold.green(`
3187
- \u2713 Cloud sync complete`));
3188
- });
3189
- cloudCmd.command("status").description("Check cloud connection status").action(async () => {
3190
- console.log();
3191
- console.log(` Machine: ${chalk4.white(getMachineId())}`);
3192
- console.log(` RDS Host: ${chalk4.white(CLOUD_RDS_HOST)}`);
3193
- console.log(` Database: ${chalk4.white(CLOUD_RDS_DB)}`);
3194
- try {
3195
- const cloud = await getCloudPg();
3196
- await cloud.get("SELECT 1 as ok");
3197
- const tables = await cloud.all("SELECT tablename FROM pg_tables WHERE schemaname = 'public'");
3198
- console.log(` PostgreSQL: ${chalk4.green("connected")}`);
3199
- console.log(` Tables: ${chalk4.white(tables.map((t) => t.tablename).join(", ") || "(none)")}`);
3200
- await cloud.close();
3201
- } catch (err2) {
3202
- console.log(` PostgreSQL: ${chalk4.red(`failed \u2014 ${err2 instanceof Error ? err2.message : String(err2)}`)}`);
3203
- }
3204
- console.log();
3205
- });
3206
- var billingCmd = program.command("billing").description("Pull actual billing from provider admin APIs (ground truth)");
3207
- billingCmd.command("sync").description("Sync actual billing from Anthropic and OpenAI admin APIs").option("--days <n>", "Days of history to fetch", "31").option("--anthropic", "Only sync Anthropic").option("--openai", "Only sync OpenAI").action(async (opts) => {
3208
- const db = openDatabase();
3209
- const days = Number(opts.days ?? 31);
3210
- const doBoth = !opts.anthropic && !opts.openai;
3211
- if (opts.anthropic || doBoth) {
3212
- process.stdout.write(chalk4.cyan("\u2192 Syncing Anthropic billing... "));
3213
- try {
3214
- const r = await syncAnthropicBilling(db, { days });
3215
- console.log(chalk4.green(`\u2713 ${r.days} days, $${r.totalUsd.toFixed(2)}`));
3216
- } catch (e) {
3217
- console.log(chalk4.red(`\u2717 ${e instanceof Error ? e.message : String(e)}`));
3218
- }
3219
- }
3220
- if (opts.openai || doBoth) {
3221
- process.stdout.write(chalk4.cyan("\u2192 Syncing OpenAI billing... "));
3222
- try {
3223
- const r = await syncOpenAIBilling(db, { days });
3224
- console.log(chalk4.green(`\u2713 ${r.days} days, $${r.totalUsd.toFixed(2)}`));
3225
- } catch (e) {
3226
- console.log(chalk4.red(`\u2717 ${e instanceof Error ? e.message : String(e)}`));
3227
- }
3228
- }
3229
- });
3230
- billingCmd.command("show").description("Show actual billing totals vs our estimated costs").option("--period <p>", "Period: today|yesterday|week|month|year|all", "month").action((opts) => {
3231
- const db = openDatabase();
3232
- const period = opts.period ?? "month";
3233
- const actual = queryBillingSummary(db, period);
3234
- const estimated = querySummary(db, period);
3235
- console.log();
3236
- console.log(chalk4.bold.cyan(` Billing ${period} (actual from admin APIs)
3237
- `));
3238
- printTable(["Provider", "Actual (billed)"], Object.entries(actual.by_provider).map(([p, c]) => [chalk4.white(p), fmt2(c)]));
3239
- console.log();
3240
- console.log(` ${chalk4.bold("Actual total:")} ${fmt2(actual.total_usd)}`);
3241
- console.log(` ${chalk4.dim("Our estimate:")} ${fmt2(estimated.total_usd)}`);
3242
- const diff = estimated.total_usd - actual.total_usd;
3243
- const pct = actual.total_usd > 0 ? diff / actual.total_usd * 100 : 0;
3244
- console.log(` ${chalk4.dim("Difference:")} ${fmt2(Math.abs(diff))} (${diff >= 0 ? "+" : ""}${pct.toFixed(1)}%)`);
3245
- console.log();
3246
- });
3247
2621
  registerBrainsCommand(program);
3248
2622
  program.parse();