@hasna/economy 0.2.11 → 0.2.13

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
@@ -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
@@ -569,29 +609,36 @@ function collectJsonlFiles(projectDir) {
569
609
  return files;
570
610
  }
571
611
  async function ingestClaude(db, verbose = false, _telemetryDir) {
572
- if (!existsSync3(PROJECTS_DIR)) {
612
+ return ingestJsonlProjects(db, CLAUDE_PROJECTS_DIR, "claude", verbose);
613
+ }
614
+ async function ingestTakumi(db, verbose = false) {
615
+ return ingestJsonlProjects(db, TAKUMI_PROJECTS_DIR, "takumi", verbose);
616
+ }
617
+ async function ingestJsonlProjects(db, projectsDir, agentName, verbose = false) {
618
+ if (!existsSync3(projectsDir)) {
573
619
  if (verbose)
574
- console.log("Claude projects dir not found:", PROJECTS_DIR);
620
+ console.log(`${agentName} projects dir not found:`, projectsDir);
575
621
  return { files: 0, requests: 0, sessions: 0 };
576
622
  }
623
+ const machineId = getMachineId();
577
624
  let totalFiles = 0;
578
625
  let totalRequests = 0;
579
626
  const touchedSessions = new Set;
580
627
  const registeredProjects = db.prepare(`SELECT path, name FROM projects ORDER BY LENGTH(path) DESC`).all();
581
- const projectDirs = readdirSync2(PROJECTS_DIR, { withFileTypes: true }).filter((d) => d.isDirectory());
628
+ const projectDirs = readdirSync2(projectsDir, { withFileTypes: true }).filter((d) => d.isDirectory());
582
629
  for (const projectDirEntry of projectDirs) {
583
- const projectDirPath = join4(PROJECTS_DIR, projectDirEntry.name);
630
+ const projectDirPath = join4(projectsDir, projectDirEntry.name);
584
631
  const projectPath = dirNameToPath(projectDirEntry.name);
585
632
  const jsonlFiles = collectJsonlFiles(projectDirPath);
586
633
  for (const filePath of jsonlFiles) {
587
- const stateKey = filePath.replace(PROJECTS_DIR, "");
634
+ const stateKey = filePath.replace(projectsDir, "");
588
635
  let fileMtime = "0";
589
636
  try {
590
637
  fileMtime = statSync2(filePath).mtimeMs.toString();
591
638
  } catch {
592
639
  continue;
593
640
  }
594
- const processed = getIngestState(db, "claude", stateKey);
641
+ const processed = getIngestState(db, agentName, stateKey);
595
642
  if (processed === fileMtime)
596
643
  continue;
597
644
  let lines;
@@ -632,10 +679,10 @@ async function ingestClaude(db, verbose = false, _telemetryDir) {
632
679
  if (inputTokens + outputTokens + cacheWriteTokens === 0)
633
680
  continue;
634
681
  const costUsd = computeCostFromDb(db, model, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens);
635
- const reqId = `claude-${sessionId}-${timestamp}`;
682
+ const reqId = `${agentName}-${sessionId}-${timestamp}`;
636
683
  upsertRequest(db, {
637
684
  id: reqId,
638
- agent: "claude",
685
+ agent: agentName,
639
686
  session_id: sessionId,
640
687
  model,
641
688
  input_tokens: inputTokens,
@@ -645,7 +692,8 @@ async function ingestClaude(db, verbose = false, _telemetryDir) {
645
692
  cost_usd: costUsd,
646
693
  duration_ms: 0,
647
694
  timestamp,
648
- source_request_id: reqId
695
+ source_request_id: reqId,
696
+ machine_id: machineId
649
697
  });
650
698
  if (!touchedSessions.has(sessionId)) {
651
699
  const existing = db.prepare(`SELECT id FROM sessions WHERE id = ?`).get(sessionId);
@@ -654,14 +702,15 @@ async function ingestClaude(db, verbose = false, _telemetryDir) {
654
702
  const detectedProject = autoDetectProject(effectiveCwd, registeredProjects);
655
703
  const session = {
656
704
  id: sessionId,
657
- agent: "claude",
705
+ agent: agentName,
658
706
  project_path: detectedProject ? detectedProject.path : effectiveCwd,
659
707
  project_name: detectedProject ? detectedProject.name : "",
660
708
  started_at: timestamp,
661
709
  ended_at: null,
662
710
  total_cost_usd: 0,
663
711
  total_tokens: 0,
664
- request_count: 0
712
+ request_count: 0,
713
+ machine_id: machineId
665
714
  };
666
715
  upsertSession(db, session);
667
716
  }
@@ -669,7 +718,7 @@ async function ingestClaude(db, verbose = false, _telemetryDir) {
669
718
  }
670
719
  totalRequests++;
671
720
  }
672
- setIngestState(db, "claude", stateKey, fileMtime);
721
+ setIngestState(db, agentName, stateKey, fileMtime);
673
722
  totalFiles++;
674
723
  }
675
724
  }
@@ -678,11 +727,12 @@ async function ingestClaude(db, verbose = false, _telemetryDir) {
678
727
  }
679
728
  return { files: totalFiles, requests: totalRequests, sessions: touchedSessions.size };
680
729
  }
681
- var PROJECTS_DIR;
730
+ var CLAUDE_PROJECTS_DIR, TAKUMI_PROJECTS_DIR;
682
731
  var init_claude = __esm(() => {
683
732
  init_database();
684
733
  init_pricing();
685
- PROJECTS_DIR = join4(homedir2(), ".claude", "projects");
734
+ CLAUDE_PROJECTS_DIR = join4(homedir2(), ".claude", "projects");
735
+ TAKUMI_PROJECTS_DIR = join4(homedir2(), ".takumi", "projects");
686
736
  });
687
737
 
688
738
  // src/ingest/codex.ts
@@ -696,6 +746,7 @@ async function ingestCodex(db, verbose = false) {
696
746
  console.log("Codex DB not found:", CODEX_DB_PATH);
697
747
  return { sessions: 0 };
698
748
  }
749
+ const machineId = getMachineId();
699
750
  let codexDb = null;
700
751
  let ingested = 0;
701
752
  try {
@@ -720,7 +771,8 @@ async function ingestCodex(db, verbose = false) {
720
771
  ended_at: endedAt,
721
772
  total_cost_usd: costUsd,
722
773
  total_tokens: thread.tokens_used,
723
- request_count: 1
774
+ request_count: 1,
775
+ machine_id: machineId
724
776
  });
725
777
  setIngestState(db, "codex", stateKey, "done");
726
778
  ingested++;
@@ -749,6 +801,7 @@ async function ingestGemini(db, verbose) {
749
801
  console.log("Gemini tmp dir not found:", GEMINI_TMP_DIR);
750
802
  return { sessions: 0 };
751
803
  }
804
+ const machineId = getMachineId();
752
805
  let totalSessions = 0;
753
806
  const touchedSessions = new Set;
754
807
  let projectHashDirs = [];
@@ -799,7 +852,8 @@ async function ingestGemini(db, verbose) {
799
852
  ended_at: chatData.lastUpdated ?? null,
800
853
  total_cost_usd: 0,
801
854
  total_tokens: 0,
802
- request_count: 0
855
+ request_count: 0,
856
+ machine_id: machineId
803
857
  };
804
858
  upsertSession(db, session);
805
859
  touchedSessions.add(sessionId);
@@ -1072,7 +1126,11 @@ function createHandler(db) {
1072
1126
  return ok({ status: "ok", ts: new Date().toISOString() });
1073
1127
  if (path === "/api/summary" && method === "GET") {
1074
1128
  const period = url.searchParams.get("period") ?? "today";
1075
- return ok(querySummary(db, period));
1129
+ const machine = url.searchParams.get("machine") ?? undefined;
1130
+ return ok(querySummary(db, period, machine));
1131
+ }
1132
+ if (path === "/api/machines" && method === "GET") {
1133
+ return ok(listMachines(db), { current_machine: getMachineId() });
1076
1134
  }
1077
1135
  if (path === "/api/daily" && method === "GET") {
1078
1136
  const days = Number(url.searchParams.get("days") ?? 30);
@@ -1082,6 +1140,7 @@ function createHandler(db) {
1082
1140
  const agent = url.searchParams.get("agent");
1083
1141
  const project = url.searchParams.get("project") ?? undefined;
1084
1142
  const search = url.searchParams.get("search") ?? undefined;
1143
+ const machine = url.searchParams.get("machine") ?? undefined;
1085
1144
  const limit = Number(url.searchParams.get("limit") ?? 50);
1086
1145
  const offset = Number(url.searchParams.get("offset") ?? 0);
1087
1146
  const since = url.searchParams.get("since") ?? undefined;
@@ -1091,6 +1150,7 @@ function createHandler(db) {
1091
1150
  agent: agent ?? undefined,
1092
1151
  project,
1093
1152
  search,
1153
+ machine,
1094
1154
  limit,
1095
1155
  offset,
1096
1156
  since
@@ -1183,6 +1243,8 @@ function createHandler(db) {
1183
1243
  const results = {};
1184
1244
  if (sources === "all" || sources === "claude")
1185
1245
  results["claude"] = await ingestClaude(db);
1246
+ if (sources === "all" || sources === "takumi")
1247
+ results["takumi"] = await ingestTakumi(db);
1186
1248
  if (sources === "all" || sources === "codex")
1187
1249
  results["codex"] = await ingestCodex(db);
1188
1250
  if (sources === "all" || sources === "gemini")
@@ -1397,6 +1459,102 @@ function menubarStop() {
1397
1459
  var APP_PATH = "/Applications/Economy Bar.app", REPO = "hasna/open-economy";
1398
1460
  var init_menubar = () => {};
1399
1461
 
1462
+ // src/db/pg-migrations.ts
1463
+ var exports_pg_migrations = {};
1464
+ __export(exports_pg_migrations, {
1465
+ PG_MIGRATIONS: () => PG_MIGRATIONS
1466
+ });
1467
+ var PG_MIGRATIONS;
1468
+ var init_pg_migrations = __esm(() => {
1469
+ PG_MIGRATIONS = [
1470
+ `CREATE TABLE IF NOT EXISTS requests (
1471
+ id TEXT PRIMARY KEY,
1472
+ agent TEXT NOT NULL,
1473
+ session_id TEXT NOT NULL,
1474
+ model TEXT NOT NULL,
1475
+ input_tokens INTEGER DEFAULT 0,
1476
+ output_tokens INTEGER DEFAULT 0,
1477
+ cache_read_tokens INTEGER DEFAULT 0,
1478
+ cache_create_tokens INTEGER DEFAULT 0,
1479
+ cost_usd REAL NOT NULL DEFAULT 0,
1480
+ duration_ms INTEGER DEFAULT 0,
1481
+ timestamp TEXT NOT NULL,
1482
+ source_request_id TEXT,
1483
+ machine_id TEXT DEFAULT ''
1484
+ )`,
1485
+ `CREATE TABLE IF NOT EXISTS sessions (
1486
+ id TEXT PRIMARY KEY,
1487
+ agent TEXT NOT NULL,
1488
+ project_path TEXT DEFAULT '',
1489
+ project_name TEXT DEFAULT '',
1490
+ started_at TEXT NOT NULL,
1491
+ ended_at TEXT,
1492
+ total_cost_usd REAL DEFAULT 0,
1493
+ total_tokens INTEGER DEFAULT 0,
1494
+ request_count INTEGER DEFAULT 0,
1495
+ machine_id TEXT DEFAULT ''
1496
+ )`,
1497
+ `CREATE TABLE IF NOT EXISTS projects (
1498
+ id TEXT PRIMARY KEY,
1499
+ path TEXT UNIQUE NOT NULL,
1500
+ name TEXT NOT NULL,
1501
+ description TEXT,
1502
+ tags TEXT DEFAULT '[]',
1503
+ created_at TEXT NOT NULL
1504
+ )`,
1505
+ `CREATE TABLE IF NOT EXISTS budgets (
1506
+ id TEXT PRIMARY KEY,
1507
+ project_path TEXT,
1508
+ agent TEXT,
1509
+ period TEXT NOT NULL,
1510
+ limit_usd REAL NOT NULL,
1511
+ alert_at_percent INTEGER DEFAULT 80,
1512
+ created_at TEXT NOT NULL,
1513
+ updated_at TEXT NOT NULL
1514
+ )`,
1515
+ `CREATE TABLE IF NOT EXISTS goals (
1516
+ id TEXT PRIMARY KEY,
1517
+ period TEXT NOT NULL,
1518
+ project_path TEXT,
1519
+ agent TEXT,
1520
+ limit_usd REAL NOT NULL,
1521
+ created_at TEXT NOT NULL,
1522
+ updated_at TEXT NOT NULL
1523
+ )`,
1524
+ `CREATE TABLE IF NOT EXISTS ingest_state (
1525
+ source TEXT NOT NULL,
1526
+ key TEXT NOT NULL,
1527
+ value TEXT NOT NULL,
1528
+ PRIMARY KEY (source, key)
1529
+ )`,
1530
+ `CREATE INDEX IF NOT EXISTS idx_requests_session ON requests(session_id)`,
1531
+ `CREATE INDEX IF NOT EXISTS idx_requests_timestamp ON requests(timestamp)`,
1532
+ `CREATE INDEX IF NOT EXISTS idx_requests_agent ON requests(agent)`,
1533
+ `CREATE INDEX IF NOT EXISTS idx_requests_machine ON requests(machine_id)`,
1534
+ `CREATE INDEX IF NOT EXISTS idx_sessions_agent ON sessions(agent)`,
1535
+ `CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_path)`,
1536
+ `CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at)`,
1537
+ `CREATE INDEX IF NOT EXISTS idx_sessions_machine ON sessions(machine_id)`,
1538
+ `CREATE TABLE IF NOT EXISTS model_pricing (
1539
+ model TEXT PRIMARY KEY,
1540
+ input_per_1m REAL NOT NULL DEFAULT 0,
1541
+ output_per_1m REAL NOT NULL DEFAULT 0,
1542
+ cache_read_per_1m REAL NOT NULL DEFAULT 0,
1543
+ cache_write_per_1m REAL NOT NULL DEFAULT 0,
1544
+ updated_at TEXT NOT NULL
1545
+ )`,
1546
+ `CREATE TABLE IF NOT EXISTS feedback (
1547
+ id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
1548
+ message TEXT NOT NULL,
1549
+ email TEXT,
1550
+ category TEXT DEFAULT 'general',
1551
+ version TEXT,
1552
+ machine_id TEXT,
1553
+ created_at TEXT NOT NULL DEFAULT NOW()::text
1554
+ )`
1555
+ ];
1556
+ });
1557
+
1400
1558
  // src/cli/index.ts
1401
1559
  import { Command } from "commander";
1402
1560
  import chalk4 from "chalk";
@@ -1783,6 +1941,7 @@ async function autoSync() {
1783
1941
  const db = openDatabase();
1784
1942
  ensurePricingSeeded(db);
1785
1943
  await ingestClaude(db);
1944
+ await ingestTakumi(db);
1786
1945
  await ingestCodex(db);
1787
1946
  await ingestGemini(db);
1788
1947
  }
@@ -1895,7 +2054,7 @@ program.action(async () => {
1895
2054
  }
1896
2055
  console.log();
1897
2056
  });
1898
- 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) => {
2057
+ 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").action(async (opts) => {
1899
2058
  const db = openDatabase();
1900
2059
  ensurePricingSeeded(db);
1901
2060
  if (opts.force) {
@@ -1903,8 +2062,9 @@ program.command("sync").description("Ingest cost data from Claude Code, Codex, a
1903
2062
  if (opts.verbose)
1904
2063
  console.log(chalk4.dim("Cleared ingest cache"));
1905
2064
  }
1906
- const anySpecific = opts.claude || opts.codex || opts.gemini;
2065
+ const anySpecific = opts.claude || opts.takumi || opts.codex || opts.gemini;
1907
2066
  const doClaude = opts.claude || !anySpecific;
2067
+ const doTakumi = opts.takumi || !anySpecific;
1908
2068
  const doCodex = opts.codex || !anySpecific;
1909
2069
  const doGemini = opts.gemini || !anySpecific;
1910
2070
  if (doClaude) {
@@ -1912,6 +2072,11 @@ program.command("sync").description("Ingest cost data from Claude Code, Codex, a
1912
2072
  const r = await ingestClaude(db, opts.verbose);
1913
2073
  console.log(chalk4.green(`\u2713 ${r.files} files, ${r.requests} requests, ${r.sessions} sessions`));
1914
2074
  }
2075
+ if (doTakumi) {
2076
+ process.stdout.write(chalk4.cyan("\u2192 Ingesting Takumi sessions... "));
2077
+ const r = await ingestTakumi(db, opts.verbose);
2078
+ console.log(chalk4.green(`\u2713 ${r.files} files, ${r.requests} requests, ${r.sessions} sessions`));
2079
+ }
1915
2080
  if (doCodex) {
1916
2081
  process.stdout.write(chalk4.cyan("\u2192 Ingesting Codex sessions... "));
1917
2082
  const r = await ingestCodex(db, opts.verbose);
@@ -1922,6 +2087,12 @@ program.command("sync").description("Ingest cost data from Claude Code, Codex, a
1922
2087
  const r = await ingestGemini(db, opts.verbose);
1923
2088
  console.log(chalk4.green(`\u2713 ${r.sessions} sessions`));
1924
2089
  }
2090
+ if (opts.backfillMachine) {
2091
+ const machine = getMachineId();
2092
+ const reqCount = db.prepare(`UPDATE requests SET machine_id = ? WHERE machine_id = '' OR machine_id IS NULL`).run(machine);
2093
+ const sessCount = db.prepare(`UPDATE sessions SET machine_id = ? WHERE machine_id = '' OR machine_id IS NULL`).run(machine);
2094
+ console.log(chalk4.cyan(`\u2192 Backfilled machine_id='${machine}': ${reqCount.changes} requests, ${sessCount.changes} sessions`));
2095
+ }
1925
2096
  try {
1926
2097
  const { checkAndFireWebhooks: checkAndFireWebhooks2 } = await Promise.resolve().then(() => (init_webhooks(), exports_webhooks));
1927
2098
  await checkAndFireWebhooks2(db);
@@ -1941,13 +2112,14 @@ program.command("month").description("Cost summary for this month").action(async
1941
2112
  await autoSync();
1942
2113
  printSummary("This Month", "month");
1943
2114
  });
1944
- 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) => {
2115
+ 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) => {
1945
2116
  await autoSync();
1946
2117
  const db = openDatabase();
1947
2118
  const sinceDate = opts.since ? parseSinceDate(opts.since) : undefined;
1948
2119
  let sessions = querySessions(db, {
1949
2120
  agent: opts.agent,
1950
2121
  project: opts.project,
2122
+ machine: opts.machine,
1951
2123
  limit: Number(opts.limit ?? 20),
1952
2124
  since: sinceDate,
1953
2125
  search: opts.search
@@ -2396,6 +2568,29 @@ program.command("session <id>").description("Show detailed breakdown of a single
2396
2568
  }
2397
2569
  console.log();
2398
2570
  });
2571
+ program.command("machines").description("List all machines that have synced data").action(async () => {
2572
+ await autoSync();
2573
+ const db = openDatabase();
2574
+ const machines = listMachines(db);
2575
+ const current = getMachineId();
2576
+ if (machines.length === 0) {
2577
+ console.log(chalk4.yellow(`No machine data yet. Current machine: ${current}`));
2578
+ return;
2579
+ }
2580
+ console.log();
2581
+ console.log(chalk4.bold.cyan(" Machines"));
2582
+ console.log();
2583
+ printTable(["Machine", "Sessions", "Requests", "Cost", "Last Active"], machines.map((m) => [
2584
+ m.machine_id === current ? chalk4.green(`${m.machine_id} (this)`) : chalk4.white(m.machine_id),
2585
+ fmtCount(m.sessions),
2586
+ fmtCount(m.requests),
2587
+ fmt2(m.total_cost_usd),
2588
+ chalk4.dim(m.last_active?.substring(0, 16) ?? "\u2014")
2589
+ ]));
2590
+ console.log(`
2591
+ ${chalk4.dim("Current machine:")} ${chalk4.bold(current)}`);
2592
+ console.log();
2593
+ });
2399
2594
  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) => {
2400
2595
  await autoSync();
2401
2596
  const db = openDatabase();
@@ -2663,5 +2858,145 @@ program.command("remove <type> <id>").alias("rm").description("Remove a record.
2663
2858
  process.exit(1);
2664
2859
  }
2665
2860
  });
2861
+ var cloudCmd = program.command("cloud").description("Cross-machine sync via cloud PostgreSQL");
2862
+ cloudCmd.command("push").description("Push local economy data to cloud PostgreSQL").option("--tables <tables>", "Comma-separated table names (default: all)").action(async (opts) => {
2863
+ const { syncPush, PgAdapterAsync, getCloudConfig, SqliteAdapter } = await import("@hasna/cloud");
2864
+ const { PG_MIGRATIONS: PG_MIGRATIONS2 } = await Promise.resolve().then(() => (init_pg_migrations(), exports_pg_migrations));
2865
+ const config = getCloudConfig();
2866
+ if (!config.rds?.host) {
2867
+ console.error(chalk4.red("Cloud not configured. Set RDS host in ~/.hasna/cloud.json"));
2868
+ process.exit(1);
2869
+ }
2870
+ const connStr = `postgresql://${config.rds.username}:${process.env[config.rds.password_env] ?? ""}@${config.rds.host}:${config.rds.port ?? 5432}/economy?sslmode=require`;
2871
+ const local = new SqliteAdapter(getDbPath());
2872
+ const cloud = new PgAdapterAsync(connStr);
2873
+ process.stdout.write(chalk4.cyan("\u2192 Running PG migrations... "));
2874
+ for (const sql of PG_MIGRATIONS2) {
2875
+ await cloud.run(sql);
2876
+ }
2877
+ console.log(chalk4.green("\u2713"));
2878
+ const tableList = opts.tables ? opts.tables.split(",").map((t) => t.trim()) : ["requests", "sessions", "projects", "budgets", "goals", "model_pricing"];
2879
+ process.stdout.write(chalk4.cyan(`\u2192 Pushing ${tableList.join(", ")}... `));
2880
+ const results = await syncPush(local, cloud, { tables: tableList });
2881
+ const totalRows = results.reduce((s, r) => s + r.rowsWritten, 0);
2882
+ console.log(chalk4.green(`\u2713 ${totalRows} rows across ${tableList.length} tables`));
2883
+ local.close();
2884
+ await cloud.close();
2885
+ console.log(chalk4.bold.green(`
2886
+ \u2713 Push complete from ${getMachineId()}`));
2887
+ });
2888
+ cloudCmd.command("pull").description("Pull cloud PostgreSQL data to local").option("--tables <tables>", "Comma-separated table names (default: all)").action(async (opts) => {
2889
+ const { syncPull, PgAdapterAsync, getCloudConfig, SqliteAdapter } = await import("@hasna/cloud");
2890
+ const { PG_MIGRATIONS: PG_MIGRATIONS2 } = await Promise.resolve().then(() => (init_pg_migrations(), exports_pg_migrations));
2891
+ const config = getCloudConfig();
2892
+ if (!config.rds?.host) {
2893
+ console.error(chalk4.red("Cloud not configured. Set RDS host in ~/.hasna/cloud.json"));
2894
+ process.exit(1);
2895
+ }
2896
+ const connStr = `postgresql://${config.rds.username}:${process.env[config.rds.password_env] ?? ""}@${config.rds.host}:${config.rds.port ?? 5432}/economy?sslmode=require`;
2897
+ const local = new SqliteAdapter(getDbPath());
2898
+ const cloud = new PgAdapterAsync(connStr);
2899
+ process.stdout.write(chalk4.cyan("\u2192 Running PG migrations... "));
2900
+ for (const sql of PG_MIGRATIONS2) {
2901
+ await cloud.run(sql);
2902
+ }
2903
+ console.log(chalk4.green("\u2713"));
2904
+ const tableList = opts.tables ? opts.tables.split(",").map((t) => t.trim()) : ["requests", "sessions", "projects", "budgets", "goals", "model_pricing"];
2905
+ process.stdout.write(chalk4.cyan(`\u2192 Pulling ${tableList.join(", ")}... `));
2906
+ const results = await syncPull(cloud, local, { tables: tableList });
2907
+ const totalRows = results.reduce((s, r) => s + r.rowsWritten, 0);
2908
+ console.log(chalk4.green(`\u2713 ${totalRows} rows across ${tableList.length} tables`));
2909
+ local.close();
2910
+ await cloud.close();
2911
+ console.log(chalk4.bold.green(`
2912
+ \u2713 Pull complete to ${getMachineId()}`));
2913
+ });
2914
+ cloudCmd.command("sync").description("Full sync: ingest local, then merge data from all reachable machines via SSH").option("--machines <list>", "Comma-separated machine hostnames (default: spark01,apple01,apple03)").action(async (opts) => {
2915
+ const thisMachine = getMachineId();
2916
+ console.log(chalk4.bold.cyan(` Cloud Sync \u2014 ${thisMachine}
2917
+ `));
2918
+ process.stdout.write(chalk4.cyan("\u2192 Ingesting local data... "));
2919
+ await autoSync();
2920
+ console.log(chalk4.green("\u2713"));
2921
+ const allMachines = (opts.machines ?? "spark01,apple01,apple03").split(",").map((m) => m.trim());
2922
+ const remoteMachines = allMachines.filter((m) => m !== thisMachine);
2923
+ const db = openDatabase();
2924
+ const { existsSync: existsSync8, mkdirSync: mkdirSync4, unlinkSync } = await import("fs");
2925
+ const { join: join9 } = await import("path");
2926
+ const { execSync: exec } = await import("child_process");
2927
+ const tmpDir = join9(process.env["TMPDIR"] ?? "/tmp", "economy-sync");
2928
+ mkdirSync4(tmpDir, { recursive: true });
2929
+ const isLinux = process.platform === "linux";
2930
+ const remoteDbPath = isLinux ? ".hasna/economy/economy.db" : ".hasna/economy/economy.db";
2931
+ for (const machine of remoteMachines) {
2932
+ const localCopy = join9(tmpDir, `${machine}.db`);
2933
+ process.stdout.write(chalk4.cyan(`\u2192 Fetching from ${machine}... `));
2934
+ try {
2935
+ exec(`scp -o ConnectTimeout=5 -o StrictHostKeyChecking=no ${machine}:~/${remoteDbPath} "${localCopy}" 2>/dev/null`, { timeout: 30000 });
2936
+ if (!existsSync8(localCopy)) {
2937
+ console.log(chalk4.yellow("skipped (no file)"));
2938
+ continue;
2939
+ }
2940
+ const { SqliteAdapter } = await import("@hasna/cloud");
2941
+ const remoteDb = new SqliteAdapter(localCopy);
2942
+ remoteDb.exec("PRAGMA busy_timeout = 5000");
2943
+ const sessions = remoteDb.prepare("SELECT * FROM sessions").all();
2944
+ let sCount = 0;
2945
+ for (const s of sessions) {
2946
+ const existing = db.prepare("SELECT id FROM sessions WHERE id = ?").get(s["id"]);
2947
+ if (!existing) {
2948
+ db.prepare(`INSERT OR IGNORE INTO sessions (id, agent, project_path, project_name, started_at, ended_at, total_cost_usd, total_tokens, request_count, machine_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(s["id"], s["agent"], s["project_path"], s["project_name"], s["started_at"], s["ended_at"], s["total_cost_usd"], s["total_tokens"], s["request_count"], s["machine_id"] || machine);
2949
+ sCount++;
2950
+ }
2951
+ }
2952
+ let rCount = 0;
2953
+ try {
2954
+ const cols = remoteDb.prepare("PRAGMA table_info(requests)").all();
2955
+ const hasMachineCol = cols.some((c) => c.name === "machine_id");
2956
+ const requests = remoteDb.prepare("SELECT * FROM requests").all();
2957
+ for (const r of requests) {
2958
+ const existing = db.prepare("SELECT id FROM requests WHERE id = ?").get(r["id"]);
2959
+ if (!existing) {
2960
+ db.prepare(`INSERT OR IGNORE INTO requests (id, agent, session_id, model, input_tokens, output_tokens, cache_read_tokens, cache_create_tokens, cost_usd, duration_ms, timestamp, source_request_id, machine_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(r["id"], r["agent"], r["session_id"], r["model"], r["input_tokens"], r["output_tokens"], r["cache_read_tokens"], r["cache_create_tokens"], r["cost_usd"], r["duration_ms"], r["timestamp"], r["source_request_id"], hasMachineCol ? r["machine_id"] || machine : machine);
2961
+ rCount++;
2962
+ }
2963
+ }
2964
+ } catch {}
2965
+ remoteDb.close();
2966
+ try {
2967
+ unlinkSync(localCopy);
2968
+ } catch {}
2969
+ console.log(chalk4.green(`\u2713 ${sCount} sessions, ${rCount} requests`));
2970
+ } catch (e) {
2971
+ console.log(chalk4.yellow(`skipped (${e instanceof Error ? e.message.split(`
2972
+ `)[0] : "unreachable"})`));
2973
+ try {
2974
+ unlinkSync(localCopy);
2975
+ } catch {}
2976
+ }
2977
+ }
2978
+ console.log(chalk4.bold.green(`
2979
+ \u2713 Cloud sync complete`));
2980
+ });
2981
+ cloudCmd.command("status").description("Check cloud connection status").action(async () => {
2982
+ const { PgAdapterAsync, getCloudConfig } = await import("@hasna/cloud");
2983
+ const config = getCloudConfig();
2984
+ console.log();
2985
+ console.log(` Mode: ${chalk4.white(config.mode)}`);
2986
+ console.log(` Machine: ${chalk4.white(getMachineId())}`);
2987
+ console.log(` RDS Host: ${chalk4.white(config.rds?.host || "(not configured)")}`);
2988
+ if (config.rds?.host && config.rds?.username) {
2989
+ try {
2990
+ const connStr = `postgresql://${config.rds.username}:${process.env[config.rds.password_env] ?? ""}@${config.rds.host}:${config.rds.port ?? 5432}/economy?sslmode=require`;
2991
+ const pg = new PgAdapterAsync(connStr);
2992
+ await pg.get("SELECT 1 as ok");
2993
+ console.log(` PostgreSQL: ${chalk4.green("connected")}`);
2994
+ await pg.close();
2995
+ } catch (err2) {
2996
+ console.log(` PostgreSQL: ${chalk4.red(`failed \u2014 ${err2 instanceof Error ? err2.message : String(err2)}`)}`);
2997
+ }
2998
+ }
2999
+ console.log();
3000
+ });
2666
3001
  registerBrainsCommand(program);
2667
3002
  program.parse();