@hasna/economy 0.2.10 → 0.2.12

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/README.md CHANGED
@@ -1,69 +1,61 @@
1
1
  # @hasna/economy
2
2
 
3
- AI coding cost tracker for Claude Code, Codex, and Gemini.
3
+ AI coding cost tracker — CLI + MCP server + REST API + web dashboard for Claude Code, Codex, and Gemini
4
4
 
5
- Track every dollar spent across all your AI coding sessions — per request, per session, per project, per day.
6
-
7
- ## Features
8
-
9
- - **Claude Code** — exact costs from telemetry JSONL (`costUSD` per request)
10
- - **Codex** — estimated costs from token count × model pricing
11
- - **SQLite backend** — all data stored locally at `~/.economy/economy.db`
12
- - **DB-backed pricing** — model rates editable via CLI, seeded from defaults
13
- - **CLI** — `economy sync`, `economy today`, `economy sessions`, `economy watch`, etc.
14
- - **Live watch** — `economy watch` streams costs as they arrive
15
- - **Budgets** — set per-project or global budgets with alert thresholds
16
- - **MCP server** — agents can query their own costs
17
- - **REST API** — `economy serve` on port 3456
18
- - **Web dashboard** — charts, sessions table, model/project breakdown
19
- - **macOS menubar** — live cost display in your menu bar
20
- - **SDK** — `@hasna/economy-sdk` for programmatic access
5
+ [![npm](https://img.shields.io/npm/v/@hasna/economy)](https://www.npmjs.com/package/@hasna/economy)
6
+ [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE)
21
7
 
22
8
  ## Install
23
9
 
24
10
  ```bash
25
- bun add -g @hasna/economy
26
- economy sync
27
- economy today
11
+ bun install -g @hasna/economy
28
12
  ```
29
13
 
30
- ## Usage
14
+ ## CLI Usage
31
15
 
32
16
  ```bash
33
- economy sync # ingest Claude Code + Codex data
34
- economy today # today's cost summary
35
- economy week # this week
36
- economy month # this month
37
- economy sessions # list sessions with costs
38
- economy top # most expensive sessions
39
- economy watch # live cost stream
40
- economy breakdown # by model/agent/project
41
- economy budget set --period monthly --limit 100
42
- economy budget list
43
- economy project add /path/to/project --name "My Project"
44
- economy pricing list
45
- economy pricing set gpt-4o --input 2.50 --output 10.00
46
- economy serve # start REST API on port 3456
47
- economy dashboard # open web dashboard
48
- economy mcp --all # show MCP install commands
17
+ economy --help
49
18
  ```
50
19
 
51
20
  ## MCP Server
52
21
 
53
22
  ```bash
54
- claude mcp add --transport stdio --scope user economy -- economy-mcp
23
+ economy-mcp --help
24
+ ```
25
+
26
+ ## REST API
27
+
28
+ ```bash
29
+ economy-serve --help
30
+ ```
31
+
32
+ ## Native macOS Menubar
33
+
34
+ The `menubar/` app is a native SwiftUI menu bar app built with `MenuBarExtra`, not Electron. It targets macOS 26 and talks to the REST API exposed by `economy-serve`. The server URL is configurable inside the app and defaults to `http://127.0.0.1:3456`.
35
+
36
+ Build it on macOS with Xcode / Swift 6.2:
37
+
38
+ ```bash
39
+ cd menubar
40
+ swift build -c release
55
41
  ```
56
42
 
57
- ## SDK
43
+ ## Cloud Sync
58
44
 
59
- ```ts
60
- import { EconomyClient } from '@hasna/economy-sdk'
45
+ This package supports cloud sync via `@hasna/cloud`:
61
46
 
62
- const client = new EconomyClient()
63
- const today = await client.getSummary('today')
64
- console.log(`Today's cost: $${today.total_usd.toFixed(4)}`)
47
+ ```bash
48
+ cloud setup
49
+ cloud sync push --service economy
50
+ cloud sync pull --service economy
65
51
  ```
66
52
 
53
+ ## Data Directory
54
+
55
+ Data is stored in `~/.hasna/economy/`.
56
+
57
+ The main SQLite database lives at `~/.hasna/economy/economy.db`. Older `~/.economy/` data is auto-migrated on first open.
58
+
67
59
  ## License
68
60
 
69
- Apache-2.0
61
+ Apache-2.0 -- see [LICENSE](LICENSE)
package/dist/cli/index.js CHANGED
@@ -109,8 +109,17 @@ var init_pricing = __esm(() => {
109
109
  // src/db/database.ts
110
110
  import { SqliteAdapter as Database } from "@hasna/cloud";
111
111
  import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from "fs";
112
+ import { hostname } from "os";
112
113
  import { homedir } from "os";
113
114
  import { join } from "path";
115
+ function getMachineId() {
116
+ if (process.env["ECONOMY_MACHINE_ID"])
117
+ return process.env["ECONOMY_MACHINE_ID"];
118
+ const h = hostname().toLowerCase();
119
+ if (h.startsWith("spark") || h.startsWith("apple"))
120
+ return h.split(".")[0];
121
+ return h.split(".")[0];
122
+ }
114
123
  function getDataDir() {
115
124
  const home = process.env["HOME"] || process.env["USERPROFILE"] || homedir();
116
125
  const newDir = join(home, ".hasna", "economy");
@@ -143,6 +152,7 @@ function openDatabase(dbPath, skipSeed = false) {
143
152
  }
144
153
  const db = new Database(path);
145
154
  db.exec("PRAGMA journal_mode = WAL");
155
+ db.exec("PRAGMA busy_timeout = 5000");
146
156
  db.exec("PRAGMA foreign_keys = ON");
147
157
  initSchema(db);
148
158
  if (!skipSeed) {
@@ -164,7 +174,8 @@ function initSchema(db) {
164
174
  cost_usd REAL NOT NULL DEFAULT 0,
165
175
  duration_ms INTEGER DEFAULT 0,
166
176
  timestamp TEXT NOT NULL,
167
- source_request_id TEXT
177
+ source_request_id TEXT,
178
+ machine_id TEXT DEFAULT ''
168
179
  );
169
180
 
170
181
  CREATE TABLE IF NOT EXISTS sessions (
@@ -176,7 +187,8 @@ function initSchema(db) {
176
187
  ended_at TEXT,
177
188
  total_cost_usd REAL DEFAULT 0,
178
189
  total_tokens INTEGER DEFAULT 0,
179
- request_count INTEGER DEFAULT 0
190
+ request_count INTEGER DEFAULT 0,
191
+ machine_id TEXT DEFAULT ''
180
192
  );
181
193
 
182
194
  CREATE TABLE IF NOT EXISTS projects (
@@ -242,6 +254,15 @@ function initSchema(db) {
242
254
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
243
255
  );
244
256
  `);
257
+ const cols = db.prepare(`PRAGMA table_info(requests)`).all();
258
+ if (!cols.some((c) => c.name === "machine_id")) {
259
+ db.exec(`ALTER TABLE requests ADD COLUMN machine_id TEXT DEFAULT ''`);
260
+ db.exec(`ALTER TABLE sessions ADD COLUMN machine_id TEXT DEFAULT ''`);
261
+ }
262
+ db.exec(`
263
+ CREATE INDEX IF NOT EXISTS idx_requests_machine ON requests(machine_id);
264
+ CREATE INDEX IF NOT EXISTS idx_sessions_machine ON sessions(machine_id);
265
+ `);
245
266
  }
246
267
  function periodWhere(period) {
247
268
  switch (period) {
@@ -280,17 +301,17 @@ function upsertRequest(db, req) {
280
301
  INSERT OR REPLACE INTO requests
281
302
  (id, agent, session_id, model, input_tokens, output_tokens,
282
303
  cache_read_tokens, cache_create_tokens, cost_usd, duration_ms,
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);
304
+ timestamp, source_request_id, machine_id)
305
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
306
+ `).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 ?? "");
286
307
  }
287
308
  function upsertSession(db, session) {
288
309
  db.prepare(`
289
310
  INSERT OR REPLACE INTO sessions
290
311
  (id, agent, project_path, project_name, started_at, ended_at,
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);
312
+ total_cost_usd, total_tokens, request_count, machine_id)
313
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
314
+ `).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 ?? "");
294
315
  }
295
316
  function rollupSession(db, sessionId) {
296
317
  db.prepare(`
@@ -320,6 +341,10 @@ function querySessions(db, filter = {}) {
320
341
  conditions.push("started_at >= ?");
321
342
  params.push(filter.since);
322
343
  }
344
+ if (filter.machine) {
345
+ conditions.push("machine_id = ?");
346
+ params.push(filter.machine);
347
+ }
323
348
  if (filter.search) {
324
349
  const q = `%${filter.search}%`;
325
350
  conditions.push("(project_name LIKE ? OR agent LIKE ? OR id LIKE ?)");
@@ -338,24 +363,25 @@ function queryTopSessions(db, n = 10, agent) {
338
363
  }
339
364
  return db.prepare(`SELECT * FROM sessions ORDER BY total_cost_usd DESC LIMIT ?`).all(n);
340
365
  }
341
- function querySummary(db, period) {
366
+ function querySummary(db, period, machine) {
342
367
  const rWhere = periodWhere(period);
343
368
  const sWhere = sessionPeriodWhere(period);
369
+ const machineClause = machine ? ` AND machine_id = '${machine.replace(/'/g, "''")}'` : "";
344
370
  const r = db.prepare(`
345
371
  SELECT COALESCE(SUM(cost_usd), 0) as total_usd,
346
372
  COUNT(*) as requests,
347
373
  COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_create_tokens), 0) as tokens
348
- FROM requests WHERE ${rWhere}
374
+ FROM requests WHERE ${rWhere}${machineClause}
349
375
  `).get();
350
376
  const codexTotals = db.prepare(`
351
377
  SELECT COALESCE(SUM(total_cost_usd), 0) as cost_usd,
352
378
  COALESCE(SUM(total_tokens), 0) as tokens,
353
379
  COUNT(*) as sessions
354
380
  FROM sessions
355
- WHERE ${sWhere}
381
+ WHERE ${sWhere}${machineClause}
356
382
  AND id NOT IN (SELECT DISTINCT session_id FROM requests)
357
383
  `).get();
358
- const sessionCount = db.prepare(`SELECT COUNT(*) as sessions FROM sessions WHERE ${sWhere}`).get();
384
+ const sessionCount = db.prepare(`SELECT COUNT(*) as sessions FROM sessions WHERE ${sWhere}${machineClause}`).get();
359
385
  return {
360
386
  total_usd: r.total_usd + codexTotals.cost_usd,
361
387
  requests: r.requests,
@@ -509,6 +535,20 @@ function setIngestState(db, source, key, value) {
509
535
  function queryRequestsSince(db, since) {
510
536
  return db.prepare(`SELECT * FROM requests WHERE timestamp > ? ORDER BY timestamp ASC`).all(since);
511
537
  }
538
+ function listMachines(db) {
539
+ return db.prepare(`
540
+ SELECT
541
+ s.machine_id,
542
+ COUNT(DISTINCT s.id) as sessions,
543
+ COALESCE((SELECT COUNT(*) FROM requests r WHERE r.machine_id = s.machine_id), 0) as requests,
544
+ COALESCE(SUM(s.total_cost_usd), 0) as total_cost_usd,
545
+ MAX(s.started_at) as last_active
546
+ FROM sessions s
547
+ WHERE s.machine_id != ''
548
+ GROUP BY s.machine_id
549
+ ORDER BY total_cost_usd DESC
550
+ `).all();
551
+ }
512
552
  function upsertModelPricing(db, p) {
513
553
  db.prepare(`
514
554
  INSERT OR REPLACE INTO model_pricing
@@ -574,6 +614,7 @@ async function ingestClaude(db, verbose = false, _telemetryDir) {
574
614
  console.log("Claude projects dir not found:", PROJECTS_DIR);
575
615
  return { files: 0, requests: 0, sessions: 0 };
576
616
  }
617
+ const machineId = getMachineId();
577
618
  let totalFiles = 0;
578
619
  let totalRequests = 0;
579
620
  const touchedSessions = new Set;
@@ -645,7 +686,8 @@ async function ingestClaude(db, verbose = false, _telemetryDir) {
645
686
  cost_usd: costUsd,
646
687
  duration_ms: 0,
647
688
  timestamp,
648
- source_request_id: reqId
689
+ source_request_id: reqId,
690
+ machine_id: machineId
649
691
  });
650
692
  if (!touchedSessions.has(sessionId)) {
651
693
  const existing = db.prepare(`SELECT id FROM sessions WHERE id = ?`).get(sessionId);
@@ -661,7 +703,8 @@ async function ingestClaude(db, verbose = false, _telemetryDir) {
661
703
  ended_at: null,
662
704
  total_cost_usd: 0,
663
705
  total_tokens: 0,
664
- request_count: 0
706
+ request_count: 0,
707
+ machine_id: machineId
665
708
  };
666
709
  upsertSession(db, session);
667
710
  }
@@ -689,17 +732,18 @@ var init_claude = __esm(() => {
689
732
  import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
690
733
  import { homedir as homedir3 } from "os";
691
734
  import { join as join5, basename as basename2 } from "path";
692
- import { Database as Database2 } from "bun:sqlite";
735
+ import { Database as BunDatabase } from "bun:sqlite";
693
736
  async function ingestCodex(db, verbose = false) {
694
737
  if (!existsSync4(CODEX_DB_PATH)) {
695
738
  if (verbose)
696
739
  console.log("Codex DB not found:", CODEX_DB_PATH);
697
740
  return { sessions: 0 };
698
741
  }
742
+ const machineId = getMachineId();
699
743
  let codexDb = null;
700
744
  let ingested = 0;
701
745
  try {
702
- codexDb = new Database2(CODEX_DB_PATH, { readonly: true });
746
+ codexDb = new BunDatabase(CODEX_DB_PATH, { readonly: true });
703
747
  const threads = codexDb.prepare(`SELECT id, cwd, created_at, updated_at, tokens_used, title FROM threads WHERE tokens_used > 0`).all();
704
748
  for (const thread of threads) {
705
749
  const stateKey = thread.id;
@@ -720,7 +764,8 @@ async function ingestCodex(db, verbose = false) {
720
764
  ended_at: endedAt,
721
765
  total_cost_usd: costUsd,
722
766
  total_tokens: thread.tokens_used,
723
- request_count: 1
767
+ request_count: 1,
768
+ machine_id: machineId
724
769
  });
725
770
  setIngestState(db, "codex", stateKey, "done");
726
771
  ingested++;
@@ -739,6 +784,88 @@ var init_codex = __esm(() => {
739
784
  CODEX_CONFIG_PATH = join5(homedir3(), ".codex", "config.toml");
740
785
  });
741
786
 
787
+ // src/ingest/gemini.ts
788
+ import { readdirSync as readdirSync3, readFileSync as readFileSync4, existsSync as existsSync5, statSync as statSync3 } from "fs";
789
+ import { homedir as homedir4 } from "os";
790
+ import { join as join6 } from "path";
791
+ async function ingestGemini(db, verbose) {
792
+ if (!existsSync5(GEMINI_TMP_DIR)) {
793
+ if (verbose)
794
+ console.log("Gemini tmp dir not found:", GEMINI_TMP_DIR);
795
+ return { sessions: 0 };
796
+ }
797
+ const machineId = getMachineId();
798
+ let totalSessions = 0;
799
+ const touchedSessions = new Set;
800
+ let projectHashDirs = [];
801
+ try {
802
+ 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));
803
+ } catch {
804
+ return { sessions: 0 };
805
+ }
806
+ for (const projectDir of projectHashDirs) {
807
+ const chatsDir = join6(projectDir, "chats");
808
+ if (!existsSync5(chatsDir))
809
+ continue;
810
+ let chatFiles = [];
811
+ try {
812
+ chatFiles = readdirSync3(chatsDir).filter((f) => f.endsWith(".json")).map((f) => join6(chatsDir, f));
813
+ } catch {
814
+ continue;
815
+ }
816
+ for (const filePath of chatFiles) {
817
+ const stateKey = filePath.replace(homedir4(), "~");
818
+ let fileMtime = "0";
819
+ try {
820
+ fileMtime = statSync3(filePath).mtimeMs.toString();
821
+ } catch {
822
+ continue;
823
+ }
824
+ const processed = getIngestState(db, "gemini", stateKey);
825
+ if (processed === fileMtime)
826
+ continue;
827
+ let chatData;
828
+ try {
829
+ chatData = JSON.parse(readFileSync4(filePath, "utf-8"));
830
+ } catch {
831
+ continue;
832
+ }
833
+ const sessionId = chatData.sessionId;
834
+ if (!sessionId)
835
+ continue;
836
+ const startTime = chatData.startTime ?? new Date().toISOString();
837
+ const existing = db.prepare(`SELECT id FROM sessions WHERE id = ?`).get(sessionId);
838
+ if (!existing) {
839
+ const session = {
840
+ id: sessionId,
841
+ agent: "gemini",
842
+ project_path: "",
843
+ project_name: "",
844
+ started_at: startTime,
845
+ ended_at: chatData.lastUpdated ?? null,
846
+ total_cost_usd: 0,
847
+ total_tokens: 0,
848
+ request_count: 0,
849
+ machine_id: machineId
850
+ };
851
+ upsertSession(db, session);
852
+ touchedSessions.add(sessionId);
853
+ totalSessions++;
854
+ }
855
+ setIngestState(db, "gemini", stateKey, fileMtime);
856
+ }
857
+ }
858
+ for (const sessionId of touchedSessions) {
859
+ rollupSession(db, sessionId);
860
+ }
861
+ return { sessions: totalSessions };
862
+ }
863
+ var GEMINI_TMP_DIR;
864
+ var init_gemini = __esm(() => {
865
+ init_database();
866
+ GEMINI_TMP_DIR = join6(homedir4(), ".gemini", "tmp");
867
+ });
868
+
742
869
  // src/lib/config.ts
743
870
  var exports_config = {};
744
871
  __export(exports_config, {
@@ -747,12 +874,12 @@ __export(exports_config, {
747
874
  loadConfig: () => loadConfig2,
748
875
  getConfigValue: () => getConfigValue
749
876
  });
750
- import { existsSync as existsSync6, readFileSync as readFileSync5, writeFileSync as writeFileSync2, mkdirSync as mkdirSync3 } from "fs";
877
+ import { existsSync as existsSync6, readFileSync as readFileSync6, writeFileSync as writeFileSync2, mkdirSync as mkdirSync3 } from "fs";
751
878
  import { join as join7 } from "path";
752
879
  function loadConfig2() {
753
880
  try {
754
881
  if (existsSync6(CONFIG_PATH2)) {
755
- const raw = readFileSync5(CONFIG_PATH2, "utf-8");
882
+ const raw = readFileSync6(CONFIG_PATH2, "utf-8");
756
883
  return { ...DEFAULTS, ...JSON.parse(raw) };
757
884
  }
758
885
  } catch {}
@@ -962,6 +1089,20 @@ function ok(data, meta) {
962
1089
  function err(message, status = 400) {
963
1090
  return json({ error: message }, status);
964
1091
  }
1092
+ function normalizeBudgetPeriod(value) {
1093
+ switch (value) {
1094
+ case "day":
1095
+ case "daily":
1096
+ return "daily";
1097
+ case "week":
1098
+ case "weekly":
1099
+ return "weekly";
1100
+ case "month":
1101
+ case "monthly":
1102
+ default:
1103
+ return "monthly";
1104
+ }
1105
+ }
965
1106
  function applyFields(obj, fields) {
966
1107
  if (!fields || fields.length === 0)
967
1108
  return obj;
@@ -978,7 +1119,11 @@ function createHandler(db) {
978
1119
  return ok({ status: "ok", ts: new Date().toISOString() });
979
1120
  if (path === "/api/summary" && method === "GET") {
980
1121
  const period = url.searchParams.get("period") ?? "today";
981
- return ok(querySummary(db, period));
1122
+ const machine = url.searchParams.get("machine") ?? undefined;
1123
+ return ok(querySummary(db, period, machine));
1124
+ }
1125
+ if (path === "/api/machines" && method === "GET") {
1126
+ return ok(listMachines(db), { current_machine: getMachineId() });
982
1127
  }
983
1128
  if (path === "/api/daily" && method === "GET") {
984
1129
  const days = Number(url.searchParams.get("days") ?? 30);
@@ -987,12 +1132,22 @@ function createHandler(db) {
987
1132
  if (path === "/api/sessions" && method === "GET") {
988
1133
  const agent = url.searchParams.get("agent");
989
1134
  const project = url.searchParams.get("project") ?? undefined;
1135
+ const search = url.searchParams.get("search") ?? undefined;
1136
+ const machine = url.searchParams.get("machine") ?? undefined;
990
1137
  const limit = Number(url.searchParams.get("limit") ?? 50);
991
1138
  const offset = Number(url.searchParams.get("offset") ?? 0);
992
1139
  const since = url.searchParams.get("since") ?? undefined;
993
1140
  const fieldsParam = url.searchParams.get("fields");
994
1141
  const fields = fieldsParam ? fieldsParam.split(",").map((f) => f.trim()).filter(Boolean) : undefined;
995
- const sessions = querySessions(db, { agent: agent ?? undefined, project, limit, offset, since });
1142
+ const sessions = querySessions(db, {
1143
+ agent: agent ?? undefined,
1144
+ project,
1145
+ search,
1146
+ machine,
1147
+ limit,
1148
+ offset,
1149
+ since
1150
+ });
996
1151
  return ok(fields ? sessions.map((s) => applyFields(s, fields)) : sessions, { limit, offset });
997
1152
  }
998
1153
  if (path === "/api/top" && method === "GET") {
@@ -1020,7 +1175,7 @@ function createHandler(db) {
1020
1175
  id: randomUUID(),
1021
1176
  project_path: body["project_path"] ?? null,
1022
1177
  agent: body["agent"] ?? null,
1023
- period: body["period"] ?? "monthly",
1178
+ period: normalizeBudgetPeriod(body["period"]),
1024
1179
  limit_usd: Number(body["limit_usd"]),
1025
1180
  alert_at_percent: Number(body["alert_at_percent"] ?? 80),
1026
1181
  created_at: now,
@@ -1083,6 +1238,8 @@ function createHandler(db) {
1083
1238
  results["claude"] = await ingestClaude(db);
1084
1239
  if (sources === "all" || sources === "codex")
1085
1240
  results["codex"] = await ingestCodex(db);
1241
+ if (sources === "all" || sources === "gemini")
1242
+ results["gemini"] = await ingestGemini(db);
1086
1243
  return ok(results);
1087
1244
  }
1088
1245
  const sessionRequestsMatch = path.match(/^\/api\/sessions\/([^/]+)\/requests$/);
@@ -1155,6 +1312,7 @@ var init_serve = __esm(() => {
1155
1312
  init_database();
1156
1313
  init_claude();
1157
1314
  init_codex();
1315
+ init_gemini();
1158
1316
  init_pricing();
1159
1317
  CORS = {
1160
1318
  "Access-Control-Allow-Origin": "*",
@@ -1650,90 +1808,30 @@ ${chalk.dim("Set it active:")} economy brains model set ${String(status["fine_tu
1650
1808
  init_database();
1651
1809
  init_claude();
1652
1810
  init_codex();
1811
+ init_gemini();
1653
1812
 
1654
- // src/ingest/gemini.ts
1655
- init_database();
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 };
1665
- }
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)
1703
- continue;
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);
1723
- }
1724
- }
1725
- for (const sessionId of touchedSessions) {
1726
- rollupSession(db, sessionId);
1727
- }
1728
- return { sessions: totalSessions };
1813
+ // src/lib/package-metadata.ts
1814
+ import { readFileSync as readFileSync5 } from "fs";
1815
+ var cachedMetadata = null;
1816
+ function getPackageMetadata() {
1817
+ if (cachedMetadata)
1818
+ return cachedMetadata;
1819
+ const raw = readFileSync5(new URL("../../package.json", import.meta.url), "utf8");
1820
+ const parsed = JSON.parse(raw);
1821
+ cachedMetadata = {
1822
+ name: parsed.name ?? "@hasna/economy",
1823
+ version: parsed.version ?? "0.0.0"
1824
+ };
1825
+ return cachedMetadata;
1729
1826
  }
1827
+ var packageMetadata = getPackageMetadata();
1730
1828
 
1731
1829
  // src/cli/index.ts
1732
1830
  init_pricing();
1733
1831
  import { randomUUID as randomUUID2 } from "crypto";
1734
1832
  import { execSync as execSync2 } from "child_process";
1735
1833
  var program = new Command;
1736
- program.name("economy").description("AI coding cost tracker \u2014 Claude Code, Codex, and Gemini").version("0.2.2");
1834
+ program.name("economy").description("AI coding cost tracker \u2014 Claude Code, Codex, and Gemini").version(packageMetadata.version);
1737
1835
  async function autoSync() {
1738
1836
  const db = openDatabase();
1739
1837
  ensurePricingSeeded(db);
@@ -1850,7 +1948,7 @@ program.action(async () => {
1850
1948
  }
1851
1949
  console.log();
1852
1950
  });
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) => {
1951
+ 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)").option("--backfill-machine", "Tag existing records that have no machine_id with current hostname").action(async (opts) => {
1854
1952
  const db = openDatabase();
1855
1953
  ensurePricingSeeded(db);
1856
1954
  if (opts.force) {
@@ -1877,6 +1975,12 @@ program.command("sync").description("Ingest cost data from Claude Code, Codex, a
1877
1975
  const r = await ingestGemini(db, opts.verbose);
1878
1976
  console.log(chalk4.green(`\u2713 ${r.sessions} sessions`));
1879
1977
  }
1978
+ if (opts.backfillMachine) {
1979
+ const machine = getMachineId();
1980
+ const reqCount = db.prepare(`UPDATE requests SET machine_id = ? WHERE machine_id = '' OR machine_id IS NULL`).run(machine);
1981
+ const sessCount = db.prepare(`UPDATE sessions SET machine_id = ? WHERE machine_id = '' OR machine_id IS NULL`).run(machine);
1982
+ console.log(chalk4.cyan(`\u2192 Backfilled machine_id='${machine}': ${reqCount.changes} requests, ${sessCount.changes} sessions`));
1983
+ }
1880
1984
  try {
1881
1985
  const { checkAndFireWebhooks: checkAndFireWebhooks2 } = await Promise.resolve().then(() => (init_webhooks(), exports_webhooks));
1882
1986
  await checkAndFireWebhooks2(db);
@@ -1896,13 +2000,14 @@ program.command("month").description("Cost summary for this month").action(async
1896
2000
  await autoSync();
1897
2001
  printSummary("This Month", "month");
1898
2002
  });
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) => {
2003
+ 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) => {
1900
2004
  await autoSync();
1901
2005
  const db = openDatabase();
1902
2006
  const sinceDate = opts.since ? parseSinceDate(opts.since) : undefined;
1903
2007
  let sessions = querySessions(db, {
1904
2008
  agent: opts.agent,
1905
2009
  project: opts.project,
2010
+ machine: opts.machine,
1906
2011
  limit: Number(opts.limit ?? 20),
1907
2012
  since: sinceDate,
1908
2013
  search: opts.search
@@ -2351,6 +2456,29 @@ program.command("session <id>").description("Show detailed breakdown of a single
2351
2456
  }
2352
2457
  console.log();
2353
2458
  });
2459
+ program.command("machines").description("List all machines that have synced data").action(async () => {
2460
+ await autoSync();
2461
+ const db = openDatabase();
2462
+ const machines = listMachines(db);
2463
+ const current = getMachineId();
2464
+ if (machines.length === 0) {
2465
+ console.log(chalk4.yellow(`No machine data yet. Current machine: ${current}`));
2466
+ return;
2467
+ }
2468
+ console.log();
2469
+ console.log(chalk4.bold.cyan(" Machines"));
2470
+ console.log();
2471
+ printTable(["Machine", "Sessions", "Requests", "Cost", "Last Active"], machines.map((m) => [
2472
+ m.machine_id === current ? chalk4.green(`${m.machine_id} (this)`) : chalk4.white(m.machine_id),
2473
+ fmtCount(m.sessions),
2474
+ fmtCount(m.requests),
2475
+ fmt2(m.total_cost_usd),
2476
+ chalk4.dim(m.last_active?.substring(0, 16) ?? "\u2014")
2477
+ ]));
2478
+ console.log(`
2479
+ ${chalk4.dim("Current machine:")} ${chalk4.bold(current)}`);
2480
+ console.log();
2481
+ });
2354
2482
  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) => {
2355
2483
  await autoSync();
2356
2484
  const db = openDatabase();