@hasna/economy 0.1.0 → 0.2.0

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
@@ -1,21 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  // @bun
3
- var __create = Object.create;
4
- var __getProtoOf = Object.getPrototypeOf;
5
3
  var __defProp = Object.defineProperty;
6
- var __getOwnPropNames = Object.getOwnPropertyNames;
7
- var __hasOwnProp = Object.prototype.hasOwnProperty;
8
- var __toESM = (mod, isNodeMode, target) => {
9
- target = mod != null ? __create(__getProtoOf(mod)) : {};
10
- const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
11
- for (let key of __getOwnPropNames(mod))
12
- if (!__hasOwnProp.call(to, key))
13
- __defProp(to, key, {
14
- get: () => mod[key],
15
- enumerable: true
16
- });
17
- return to;
18
- };
19
4
  var __export = (target, all) => {
20
5
  for (var name in all)
21
6
  __defProp(target, name, {
@@ -90,19 +75,19 @@ var DEFAULT_PRICING;
90
75
  var init_pricing = __esm(() => {
91
76
  init_database();
92
77
  DEFAULT_PRICING = {
93
- "claude-opus-4-6": { inputPer1M: 15, outputPer1M: 75, cacheReadPer1M: 1.5, cacheWritePer1M: 18.75 },
94
- "claude-opus-4-5": { inputPer1M: 15, outputPer1M: 75, cacheReadPer1M: 1.5, cacheWritePer1M: 18.75 },
78
+ "claude-opus-4-6": { inputPer1M: 5, outputPer1M: 25, cacheReadPer1M: 0.5, cacheWritePer1M: 6.25 },
79
+ "claude-opus-4-5": { inputPer1M: 5, outputPer1M: 25, cacheReadPer1M: 0.5, cacheWritePer1M: 6.25 },
95
80
  "claude-sonnet-4-6": { inputPer1M: 3, outputPer1M: 15, cacheReadPer1M: 0.3, cacheWritePer1M: 3.75 },
96
81
  "claude-sonnet-4-5": { inputPer1M: 3, outputPer1M: 15, cacheReadPer1M: 0.3, cacheWritePer1M: 3.75 },
97
- "claude-haiku-4-5": { inputPer1M: 0.8, outputPer1M: 4, cacheReadPer1M: 0.08, cacheWritePer1M: 1 },
82
+ "claude-haiku-4-5": { inputPer1M: 1, outputPer1M: 5, cacheReadPer1M: 0.1, cacheWritePer1M: 1.25 },
98
83
  "claude-3-5-sonnet": { inputPer1M: 3, outputPer1M: 15, cacheReadPer1M: 0.3, cacheWritePer1M: 3.75 },
99
- "claude-3-5-haiku": { inputPer1M: 0.8, outputPer1M: 4, cacheReadPer1M: 0.08, cacheWritePer1M: 1 },
84
+ "claude-3-5-haiku": { inputPer1M: 1, outputPer1M: 5, cacheReadPer1M: 0.1, cacheWritePer1M: 1.25 },
100
85
  "claude-3-opus": { inputPer1M: 15, outputPer1M: 75, cacheReadPer1M: 1.5, cacheWritePer1M: 18.75 },
101
86
  "claude-3-sonnet": { inputPer1M: 3, outputPer1M: 15, cacheReadPer1M: 0.3, cacheWritePer1M: 3.75 },
102
87
  "claude-3-haiku": { inputPer1M: 0.25, outputPer1M: 1.25, cacheReadPer1M: 0.03, cacheWritePer1M: 0.3 },
103
- "gpt-5.3-codex": { inputPer1M: 30, outputPer1M: 120, cacheReadPer1M: 7.5, cacheWritePer1M: 0 },
104
- "gpt-5.2-codex": { inputPer1M: 30, outputPer1M: 120, cacheReadPer1M: 7.5, cacheWritePer1M: 0 },
105
- "gpt-5-codex": { inputPer1M: 30, outputPer1M: 120, cacheReadPer1M: 7.5, cacheWritePer1M: 0 },
88
+ "gpt-5.3-codex": { inputPer1M: 1.75, outputPer1M: 14, cacheReadPer1M: 0.44, cacheWritePer1M: 0 },
89
+ "gpt-5.2-codex": { inputPer1M: 1.75, outputPer1M: 14, cacheReadPer1M: 0.44, cacheWritePer1M: 0 },
90
+ "gpt-5-codex": { inputPer1M: 1.75, outputPer1M: 14, cacheReadPer1M: 0.44, cacheWritePer1M: 0 },
106
91
  "gpt-4o": { inputPer1M: 2.5, outputPer1M: 10, cacheReadPer1M: 1.25, cacheWritePer1M: 0 },
107
92
  "gpt-4o-mini": { inputPer1M: 0.15, outputPer1M: 0.6, cacheReadPer1M: 0.075, cacheWritePer1M: 0 },
108
93
  o1: { inputPer1M: 15, outputPer1M: 60, cacheReadPer1M: 7.5, cacheWritePer1M: 0 },
@@ -301,8 +286,22 @@ function querySummary(db, period) {
301
286
  COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_create_tokens), 0) as tokens
302
287
  FROM requests WHERE ${rWhere}
303
288
  `).get();
304
- const s = db.prepare(`SELECT COUNT(*) as sessions FROM sessions WHERE ${sWhere}`).get();
305
- return { total_usd: r.total_usd, requests: r.requests, tokens: r.tokens, sessions: s.sessions, period };
289
+ const codexTotals = db.prepare(`
290
+ SELECT COALESCE(SUM(total_cost_usd), 0) as cost_usd,
291
+ COALESCE(SUM(total_tokens), 0) as tokens,
292
+ COUNT(*) as sessions
293
+ FROM sessions
294
+ WHERE ${sWhere}
295
+ AND id NOT IN (SELECT DISTINCT session_id FROM requests)
296
+ `).get();
297
+ const sessionCount = db.prepare(`SELECT COUNT(*) as sessions FROM sessions WHERE ${sWhere}`).get();
298
+ return {
299
+ total_usd: r.total_usd + codexTotals.cost_usd,
300
+ requests: r.requests,
301
+ tokens: r.tokens + codexTotals.tokens,
302
+ sessions: sessionCount.sessions,
303
+ period
304
+ };
306
305
  }
307
306
  function queryModelBreakdown(db) {
308
307
  return db.prepare(`
@@ -317,14 +316,14 @@ function queryModelBreakdown(db) {
317
316
  }
318
317
  function queryProjectBreakdown(db) {
319
318
  return db.prepare(`
320
- SELECT s.project_path, s.project_name,
321
- COUNT(DISTINCT s.id) as sessions,
322
- COALESCE(SUM(r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_create_tokens), 0) as total_tokens,
323
- COUNT(r.id) as requests,
324
- COALESCE(SUM(s.total_cost_usd), 0) as cost_usd,
325
- MAX(s.started_at) as last_active
326
- FROM sessions s LEFT JOIN requests r ON r.session_id = s.id
327
- GROUP BY s.project_path ORDER BY cost_usd DESC
319
+ SELECT project_path, project_name,
320
+ COUNT(*) as sessions,
321
+ COALESCE(SUM(total_tokens), 0) as total_tokens,
322
+ COALESCE(SUM(request_count), 0) as requests,
323
+ COALESCE(SUM(total_cost_usd), 0) as cost_usd,
324
+ MAX(started_at) as last_active
325
+ FROM sessions
326
+ GROUP BY project_path ORDER BY cost_usd DESC
328
327
  `).all();
329
328
  }
330
329
  function queryDailyBreakdown(db, days = 30) {
@@ -438,116 +437,139 @@ function seedModelPricing(db, defaults) {
438
437
  var init_database = () => {};
439
438
 
440
439
  // src/ingest/claude.ts
441
- import { readdirSync, readFileSync, existsSync as existsSync2 } from "fs";
440
+ import { readdirSync, readFileSync, existsSync as existsSync2, statSync } from "fs";
442
441
  import { homedir as homedir2 } from "os";
443
442
  import { join as join2, basename } from "path";
444
- import { randomUUID } from "crypto";
445
- function resolveProjectPath(sessionId) {
446
- if (!existsSync2(PROJECTS_DIR))
447
- return { projectPath: "", projectName: "unknown" };
448
- try {
449
- const projectDirs = readdirSync(PROJECTS_DIR, { withFileTypes: true }).filter((d) => d.isDirectory());
450
- for (const dir of projectDirs) {
451
- const sessionFile = join2(PROJECTS_DIR, dir.name, `${sessionId}.jsonl`);
452
- if (existsSync2(sessionFile)) {
453
- const projectPath = dir.name.replace(/^-/, "/").replace(/-/g, "/");
454
- return { projectPath, projectName: basename(projectPath) };
443
+ function dirNameToPath(dirName) {
444
+ return dirName.replace(/^-/, "/").replace(/-/g, "/").replace(/\/\//g, "/-");
445
+ }
446
+ function collectJsonlFiles(projectDir) {
447
+ const files = [];
448
+ function walk(dir) {
449
+ try {
450
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
451
+ if (entry.isDirectory())
452
+ walk(join2(dir, entry.name));
453
+ else if (entry.name.endsWith(".jsonl"))
454
+ files.push(join2(dir, entry.name));
455
455
  }
456
- }
457
- } catch {}
458
- return { projectPath: "", projectName: "unknown" };
456
+ } catch {}
457
+ }
458
+ walk(projectDir);
459
+ return files;
459
460
  }
460
- async function ingestClaude(db, verbose = false, telemetryDir = TELEMETRY_DIR) {
461
- if (!existsSync2(telemetryDir)) {
461
+ async function ingestClaude(db, verbose = false, _telemetryDir) {
462
+ if (!existsSync2(PROJECTS_DIR)) {
462
463
  if (verbose)
463
- console.log("Claude telemetry dir not found:", telemetryDir);
464
+ console.log("Claude projects dir not found:", PROJECTS_DIR);
464
465
  return { files: 0, requests: 0, sessions: 0 };
465
466
  }
466
- const files = readdirSync(telemetryDir).filter((f) => f.endsWith(".json"));
467
+ let totalFiles = 0;
467
468
  let totalRequests = 0;
468
- let processedFiles = 0;
469
469
  const touchedSessions = new Set;
470
- for (const filename of files) {
471
- const stateKey = filename;
472
- const processed = getIngestState(db, "claude", stateKey);
473
- if (processed === "done")
474
- continue;
475
- const filePath = join2(telemetryDir, filename);
476
- let events;
477
- try {
478
- const raw = readFileSync(filePath, "utf-8");
479
- events = JSON.parse(raw);
480
- if (!Array.isArray(events))
481
- events = [events];
482
- } catch {
483
- if (verbose)
484
- console.log("Skip unreadable:", filename);
485
- continue;
486
- }
487
- for (const event of events) {
488
- const ed = event.event_data;
489
- if (!ed || ed.event_name !== "tengu_api_success")
470
+ const projectDirs = readdirSync(PROJECTS_DIR, { withFileTypes: true }).filter((d) => d.isDirectory());
471
+ for (const projectDirEntry of projectDirs) {
472
+ const projectDirPath = join2(PROJECTS_DIR, projectDirEntry.name);
473
+ const projectPath = dirNameToPath(projectDirEntry.name);
474
+ const projectName = basename(projectPath);
475
+ const jsonlFiles = collectJsonlFiles(projectDirPath);
476
+ for (const filePath of jsonlFiles) {
477
+ const stateKey = filePath.replace(PROJECTS_DIR, "");
478
+ let fileMtime = "0";
479
+ try {
480
+ fileMtime = statSync(filePath).mtimeMs.toString();
481
+ } catch {
490
482
  continue;
491
- const meta = ed.additional_metadata;
492
- if (!meta)
483
+ }
484
+ const processed = getIngestState(db, "claude", stateKey);
485
+ if (processed === fileMtime)
493
486
  continue;
494
- const sessionId = ed.session_id ?? randomUUID();
495
- const timestamp = ed.client_timestamp ?? new Date().toISOString();
496
- const model = meta.model ?? ed.model ?? "unknown";
497
- const costUsd = meta.costUSD ?? 0;
498
- const inputTokens = meta.inputTokens ?? (meta.uncachedInputTokens ?? 0);
499
- const outputTokens = meta.outputTokens ?? 0;
500
- const cacheReadTokens = meta.cachedInputTokens ?? 0;
501
- const requestId = meta.requestId ?? randomUUID();
502
- upsertRequest(db, {
503
- id: `claude-${requestId}`,
504
- agent: "claude",
505
- session_id: sessionId,
506
- model,
507
- input_tokens: inputTokens,
508
- output_tokens: outputTokens,
509
- cache_read_tokens: cacheReadTokens,
510
- cache_create_tokens: 0,
511
- cost_usd: costUsd,
512
- duration_ms: meta.durationMs ?? 0,
513
- timestamp,
514
- source_request_id: requestId
515
- });
516
- if (!touchedSessions.has(sessionId)) {
517
- const { projectPath, projectName } = resolveProjectPath(sessionId);
518
- const existing = db.prepare(`SELECT id FROM sessions WHERE id = ?`).get(sessionId);
519
- if (!existing) {
520
- const session = {
521
- id: sessionId,
522
- agent: "claude",
523
- project_path: projectPath,
524
- project_name: projectName,
525
- started_at: timestamp,
526
- ended_at: null,
527
- total_cost_usd: 0,
528
- total_tokens: 0,
529
- request_count: 0
530
- };
531
- upsertSession(db, session);
487
+ let lines;
488
+ try {
489
+ lines = readFileSync(filePath, "utf-8").split(`
490
+ `).filter((l) => l.trim());
491
+ } catch {
492
+ continue;
493
+ }
494
+ const fileBasename = basename(filePath, ".jsonl");
495
+ const isUuid = /^[0-9a-f-]{36}$/.test(fileBasename);
496
+ let sessionId = isUuid ? fileBasename : fileBasename.replace(/^agent-/, "");
497
+ let sessionCwd = projectPath;
498
+ for (const line of lines) {
499
+ let entry;
500
+ try {
501
+ entry = JSON.parse(line);
502
+ } catch {
503
+ continue;
532
504
  }
533
- touchedSessions.add(sessionId);
505
+ if (entry.sessionId)
506
+ sessionId = entry.sessionId;
507
+ if (entry.cwd)
508
+ sessionCwd = entry.cwd;
509
+ if (entry.message?.role !== "assistant")
510
+ continue;
511
+ const usage = entry.message.usage;
512
+ if (!usage)
513
+ continue;
514
+ const model = entry.message.model;
515
+ if (!model)
516
+ continue;
517
+ const inputTokens = usage.input_tokens ?? 0;
518
+ const outputTokens = usage.output_tokens ?? 0;
519
+ const cacheWriteTokens = usage.cache_creation_input_tokens ?? 0;
520
+ const cacheReadTokens = usage.cache_read_input_tokens ?? 0;
521
+ const timestamp = entry.timestamp ?? new Date().toISOString();
522
+ if (inputTokens + outputTokens + cacheWriteTokens === 0)
523
+ continue;
524
+ const costUsd = computeCostFromDb(db, model, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens);
525
+ const reqId = `claude-${sessionId}-${timestamp}`;
526
+ upsertRequest(db, {
527
+ id: reqId,
528
+ agent: "claude",
529
+ session_id: sessionId,
530
+ model,
531
+ input_tokens: inputTokens,
532
+ output_tokens: outputTokens,
533
+ cache_read_tokens: cacheReadTokens,
534
+ cache_create_tokens: cacheWriteTokens,
535
+ cost_usd: costUsd,
536
+ duration_ms: 0,
537
+ timestamp,
538
+ source_request_id: reqId
539
+ });
540
+ if (!touchedSessions.has(sessionId)) {
541
+ const existing = db.prepare(`SELECT id FROM sessions WHERE id = ?`).get(sessionId);
542
+ if (!existing) {
543
+ const session = {
544
+ id: sessionId,
545
+ agent: "claude",
546
+ project_path: sessionCwd || projectPath,
547
+ project_name: basename(sessionCwd || projectPath),
548
+ started_at: timestamp,
549
+ ended_at: null,
550
+ total_cost_usd: 0,
551
+ total_tokens: 0,
552
+ request_count: 0
553
+ };
554
+ upsertSession(db, session);
555
+ }
556
+ touchedSessions.add(sessionId);
557
+ }
558
+ totalRequests++;
534
559
  }
535
- totalRequests++;
560
+ setIngestState(db, "claude", stateKey, fileMtime);
561
+ totalFiles++;
536
562
  }
537
- setIngestState(db, "claude", stateKey, "done");
538
- processedFiles++;
539
- if (verbose)
540
- console.log(`Processed ${filename}: found ${events.length} events`);
541
563
  }
542
564
  for (const sessionId of touchedSessions) {
543
565
  rollupSession(db, sessionId);
544
566
  }
545
- return { files: processedFiles, requests: totalRequests, sessions: touchedSessions.size };
567
+ return { files: totalFiles, requests: totalRequests, sessions: touchedSessions.size };
546
568
  }
547
- var TELEMETRY_DIR, PROJECTS_DIR;
569
+ var PROJECTS_DIR;
548
570
  var init_claude = __esm(() => {
549
571
  init_database();
550
- TELEMETRY_DIR = join2(homedir2(), ".claude", "telemetry");
572
+ init_pricing();
551
573
  PROJECTS_DIR = join2(homedir2(), ".claude", "projects");
552
574
  });
553
575
 
@@ -584,9 +606,7 @@ async function ingestCodex(db, verbose = false) {
584
606
  const processed = getIngestState(db, "codex", stateKey);
585
607
  if (processed === "done")
586
608
  continue;
587
- const inputTokens = Math.floor(thread.tokens_used * 0.6);
588
- const outputTokens = thread.tokens_used - inputTokens;
589
- const costUsd = computeCost(model, inputTokens, outputTokens);
609
+ const costUsd = 0;
590
610
  const projectPath = thread.cwd ?? "";
591
611
  const projectName = projectPath ? basename2(projectPath) : "unknown";
592
612
  const startedAt = thread.created_at ? new Date(thread.created_at * 1000).toISOString() : new Date().toISOString();
@@ -615,11 +635,119 @@ async function ingestCodex(db, verbose = false) {
615
635
  var CODEX_DB_PATH, CODEX_CONFIG_PATH;
616
636
  var init_codex = __esm(() => {
617
637
  init_database();
618
- init_pricing();
619
638
  CODEX_DB_PATH = join3(homedir3(), ".codex", "state_5.sqlite");
620
639
  CODEX_CONFIG_PATH = join3(homedir3(), ".codex", "config.toml");
621
640
  });
622
641
 
642
+ // src/lib/config.ts
643
+ var exports_config = {};
644
+ __export(exports_config, {
645
+ setConfigValue: () => setConfigValue,
646
+ saveConfig: () => saveConfig,
647
+ loadConfig: () => loadConfig,
648
+ getConfigValue: () => getConfigValue
649
+ });
650
+ import { existsSync as existsSync4, readFileSync as readFileSync3, writeFileSync, mkdirSync as mkdirSync2 } from "fs";
651
+ import { homedir as homedir4 } from "os";
652
+ import { join as join4 } from "path";
653
+ function loadConfig() {
654
+ try {
655
+ if (existsSync4(CONFIG_PATH)) {
656
+ const raw = readFileSync3(CONFIG_PATH, "utf-8");
657
+ return { ...DEFAULTS, ...JSON.parse(raw) };
658
+ }
659
+ } catch {}
660
+ return { ...DEFAULTS };
661
+ }
662
+ function saveConfig(config) {
663
+ const dir = CONFIG_PATH.substring(0, CONFIG_PATH.lastIndexOf("/"));
664
+ if (!existsSync4(dir))
665
+ mkdirSync2(dir, { recursive: true });
666
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + `
667
+ `);
668
+ }
669
+ function getConfigValue(key) {
670
+ const config = loadConfig();
671
+ return config[key] ?? null;
672
+ }
673
+ function setConfigValue(key, value) {
674
+ const config = loadConfig();
675
+ let parsed = value;
676
+ if (value === "true")
677
+ parsed = true;
678
+ else if (value === "false")
679
+ parsed = false;
680
+ else if (value === "null")
681
+ parsed = null;
682
+ else if (!isNaN(Number(value)))
683
+ parsed = Number(value);
684
+ else if (value.startsWith("[")) {
685
+ try {
686
+ parsed = JSON.parse(value);
687
+ } catch {}
688
+ }
689
+ config[key] = parsed;
690
+ saveConfig(config);
691
+ }
692
+ var CONFIG_PATH, DEFAULTS;
693
+ var init_config = __esm(() => {
694
+ CONFIG_PATH = join4(homedir4(), ".economy", "config.json");
695
+ DEFAULTS = {
696
+ port: 3456,
697
+ "default-period": "today",
698
+ "auto-sync": true,
699
+ "sync-interval": 30,
700
+ "alert-thresholds": [5, 10, 25, 50, 100],
701
+ "webhook-url": null
702
+ };
703
+ });
704
+
705
+ // src/lib/webhooks.ts
706
+ var exports_webhooks = {};
707
+ __export(exports_webhooks, {
708
+ checkAndFireWebhooks: () => checkAndFireWebhooks
709
+ });
710
+ async function checkAndFireWebhooks(db) {
711
+ const config = loadConfig();
712
+ const url = config["webhook-url"];
713
+ if (!url)
714
+ return;
715
+ const statuses = getBudgetStatuses(db);
716
+ for (const b of statuses) {
717
+ if (!b.is_over_alert)
718
+ continue;
719
+ const key = `webhook-budget-${b.id}-${b.period}`;
720
+ const lastFired = getIngestState(db, "webhook", key);
721
+ const pctBucket = Math.floor(b.percent_used / 10) * 10;
722
+ if (lastFired === String(pctBucket))
723
+ continue;
724
+ await fireWebhook(url, {
725
+ event: "budget_alert",
726
+ budget_id: b.id,
727
+ project: b.project_path ?? "global",
728
+ period: b.period,
729
+ spend: b.current_spend_usd,
730
+ limit: b.limit_usd,
731
+ percent: Math.round(b.percent_used * 10) / 10
732
+ });
733
+ setIngestState(db, "webhook", key, String(pctBucket));
734
+ }
735
+ }
736
+ async function fireWebhook(url, payload) {
737
+ try {
738
+ await fetch(url, {
739
+ method: "POST",
740
+ headers: { "Content-Type": "application/json" },
741
+ body: JSON.stringify(payload),
742
+ signal: AbortSignal.timeout(5000)
743
+ });
744
+ } catch {}
745
+ }
746
+ var init_webhooks = __esm(() => {
747
+ init_config();
748
+ init_database();
749
+ });
750
+
623
751
  // src/cli/commands/watch.ts
624
752
  var exports_watch = {};
625
753
  __export(exports_watch, {
@@ -703,7 +831,7 @@ var init_watch = __esm(() => {
703
831
  });
704
832
 
705
833
  // src/server/serve.ts
706
- import { randomUUID as randomUUID2 } from "crypto";
834
+ import { randomUUID } from "crypto";
707
835
  function json(data, status = 200) {
708
836
  return new Response(JSON.stringify(data), {
709
837
  status,
@@ -764,7 +892,7 @@ function createHandler(db) {
764
892
  const body = await req.json();
765
893
  const now = new Date().toISOString();
766
894
  upsertBudget(db, {
767
- id: randomUUID2(),
895
+ id: randomUUID(),
768
896
  project_path: body["project_path"] ?? null,
769
897
  agent: body["agent"] ?? null,
770
898
  period: body["period"] ?? "monthly",
@@ -788,7 +916,7 @@ function createHandler(db) {
788
916
  const { basename: basename3 } = await import("path");
789
917
  const projPath = body["path"];
790
918
  upsertProject(db, {
791
- id: randomUUID2(),
919
+ id: randomUUID(),
792
920
  path: projPath,
793
921
  name: body["name"] ?? basename3(projPath),
794
922
  description: body["description"] ?? null,
@@ -838,8 +966,32 @@ function createHandler(db) {
838
966
  function startServer(port = 3456) {
839
967
  const db = openDatabase();
840
968
  ensurePricingSeeded(db);
841
- const handler = createHandler(db);
842
- Bun.serve({ port, fetch: handler });
969
+ const apiHandler = createHandler(db);
970
+ const dashboardDir = new URL("../../dashboard/dist", import.meta.url).pathname;
971
+ Bun.serve({
972
+ port,
973
+ async fetch(req) {
974
+ const url = new URL(req.url);
975
+ if (url.pathname.startsWith("/api") || url.pathname === "/health") {
976
+ return apiHandler(req);
977
+ }
978
+ try {
979
+ const { existsSync: existsSync5 } = await import("fs");
980
+ if (existsSync5(dashboardDir)) {
981
+ let filePath = url.pathname === "/" ? "/index.html" : url.pathname;
982
+ const fullPath = dashboardDir + filePath;
983
+ if (existsSync5(fullPath)) {
984
+ return new Response(Bun.file(fullPath));
985
+ }
986
+ const indexPath = dashboardDir + "/index.html";
987
+ if (existsSync5(indexPath)) {
988
+ return new Response(Bun.file(indexPath));
989
+ }
990
+ }
991
+ } catch {}
992
+ return apiHandler(req);
993
+ }
994
+ });
843
995
  console.log(`economy-serve listening on http://localhost:${port}`);
844
996
  }
845
997
  var CORS;
@@ -871,19 +1023,45 @@ init_codex();
871
1023
  init_pricing();
872
1024
  import { Command } from "commander";
873
1025
  import chalk2 from "chalk";
874
- import { randomUUID as randomUUID3 } from "crypto";
1026
+ import { randomUUID as randomUUID2 } from "crypto";
875
1027
  import { execSync } from "child_process";
876
1028
  var program = new Command;
877
- program.name("economy").description("AI coding cost tracker \u2014 Claude Code, Codex, and Gemini").version("0.1.0");
1029
+ program.name("economy").description("AI coding cost tracker \u2014 Claude Code, Codex, and Gemini").version("0.1.1");
1030
+ async function autoSync() {
1031
+ const db = openDatabase();
1032
+ ensurePricingSeeded(db);
1033
+ await ingestClaude(db);
1034
+ await ingestCodex(db);
1035
+ }
1036
+ function sparkline(values) {
1037
+ const chars = "\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588";
1038
+ if (values.length === 0)
1039
+ return "";
1040
+ const max = Math.max(...values);
1041
+ if (max === 0)
1042
+ return chars[0].repeat(values.length);
1043
+ return values.map((v) => chars[Math.min(Math.round(v / max * 7), 7)]).join("");
1044
+ }
878
1045
  function fmt2(usd) {
879
- return chalk2.green(`$${usd.toFixed(4)}`);
1046
+ let formatted;
1047
+ if (usd >= 0.01) {
1048
+ formatted = "$" + usd.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
1049
+ } else {
1050
+ formatted = "$" + usd.toFixed(6);
1051
+ }
1052
+ return chalk2.green(formatted);
880
1053
  }
881
1054
  function fmtTokens(n) {
1055
+ if (n >= 1e9)
1056
+ return `${(n / 1e9).toFixed(1)}B`;
882
1057
  if (n >= 1e6)
883
- return `${(n / 1e6).toFixed(2)}M`;
1058
+ return `${(n / 1e6).toFixed(1)}M`;
884
1059
  if (n >= 1000)
885
1060
  return `${(n / 1000).toFixed(1)}k`;
886
- return String(n);
1061
+ return n.toLocaleString("en-US");
1062
+ }
1063
+ function fmtCount(n) {
1064
+ return n.toLocaleString("en-US");
887
1065
  }
888
1066
  function printTable(headers, rows) {
889
1067
  const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").replace(/\x1b\[[0-9;]*m/g, "").length)));
@@ -910,15 +1088,53 @@ function printSummary(label, period) {
910
1088
  console.log();
911
1089
  printTable(["Metric", "Value"], [
912
1090
  ["Total cost", fmt2(s.total_usd)],
913
- ["Sessions", chalk2.yellow(String(s.sessions))],
914
- ["Requests", chalk2.yellow(String(s.requests))],
1091
+ ["Sessions", chalk2.yellow(fmtCount(s.sessions))],
1092
+ ["Requests", chalk2.yellow(fmtCount(s.requests))],
915
1093
  ["Tokens", chalk2.yellow(fmtTokens(s.tokens))]
916
1094
  ]);
917
1095
  console.log();
918
1096
  }
919
- program.command("sync").description("Ingest cost data from Claude Code and Codex").option("--claude", "Only ingest Claude Code telemetry").option("--codex", "Only ingest Codex sessions").option("-v, --verbose", "Verbose output").action(async (opts) => {
1097
+ program.action(async () => {
1098
+ await autoSync();
1099
+ const db = openDatabase();
1100
+ const t = querySummary(db, "today");
1101
+ const w = querySummary(db, "week");
1102
+ const m = querySummary(db, "month");
1103
+ const projects = queryProjectBreakdown(db).slice(0, 3);
1104
+ const daily = queryDailyBreakdown(db, 14).reduce((acc, d) => {
1105
+ acc[d.date] = (acc[d.date] ?? 0) + d.cost_usd;
1106
+ return acc;
1107
+ }, {});
1108
+ const dailyValues = Object.values(daily);
1109
+ console.log();
1110
+ console.log(chalk2.bold.cyan(" Economy"));
1111
+ console.log();
1112
+ printTable(["Period", "Cost", "Sessions", "Requests", "Tokens"], [
1113
+ ["Today", fmt2(t.total_usd), fmtCount(t.sessions), fmtCount(t.requests), fmtTokens(t.tokens)],
1114
+ ["This Week", fmt2(w.total_usd), fmtCount(w.sessions), fmtCount(w.requests), fmtTokens(w.tokens)],
1115
+ ["This Month", fmt2(m.total_usd), fmtCount(m.sessions), fmtCount(m.requests), fmtTokens(m.tokens)]
1116
+ ]);
1117
+ if (dailyValues.length > 0) {
1118
+ console.log(`
1119
+ ${chalk2.dim("14-day trend:")} ${sparkline(dailyValues)}`);
1120
+ }
1121
+ if (projects.length > 0) {
1122
+ console.log(`
1123
+ ${chalk2.dim("Top projects:")}`);
1124
+ for (const p of projects) {
1125
+ console.log(` ${chalk2.white(p.project_name.padEnd(25))} ${fmt2(p.cost_usd)}`);
1126
+ }
1127
+ }
1128
+ console.log();
1129
+ });
1130
+ program.command("sync").description("Ingest cost data from Claude Code and Codex").option("--claude", "Only ingest Claude Code telemetry").option("--codex", "Only ingest Codex sessions").option("-v, --verbose", "Verbose output").option("--force", "Force re-process all files (ignore mtime cache)").action(async (opts) => {
920
1131
  const db = openDatabase();
921
1132
  ensurePricingSeeded(db);
1133
+ if (opts.force) {
1134
+ db.exec(`DELETE FROM ingest_state WHERE source = 'claude'`);
1135
+ if (opts.verbose)
1136
+ console.log(chalk2.dim("Cleared ingest cache"));
1137
+ }
922
1138
  const doClaude = opts.claude || !opts.claude && !opts.codex;
923
1139
  const doCodex = opts.codex || !opts.claude && !opts.codex;
924
1140
  if (doClaude) {
@@ -931,13 +1147,27 @@ program.command("sync").description("Ingest cost data from Claude Code and Codex
931
1147
  const r = await ingestCodex(db, opts.verbose);
932
1148
  console.log(chalk2.green(`\u2713 ${r.sessions} sessions`));
933
1149
  }
1150
+ try {
1151
+ const { checkAndFireWebhooks: checkAndFireWebhooks2 } = await Promise.resolve().then(() => (init_webhooks(), exports_webhooks));
1152
+ await checkAndFireWebhooks2(db);
1153
+ } catch {}
934
1154
  console.log(chalk2.bold.green(`
935
1155
  \u2713 Sync complete`));
936
1156
  });
937
- program.command("today").description("Cost summary for today").action(() => printSummary("Today", "today"));
938
- program.command("week").description("Cost summary for this week").action(() => printSummary("This Week", "week"));
939
- program.command("month").description("Cost summary for this month").action(() => printSummary("This Month", "month"));
940
- 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").action((opts) => {
1157
+ program.command("today").description("Cost summary for today").action(async () => {
1158
+ await autoSync();
1159
+ printSummary("Today", "today");
1160
+ });
1161
+ program.command("week").description("Cost summary for this week").action(async () => {
1162
+ await autoSync();
1163
+ printSummary("This Week", "week");
1164
+ });
1165
+ program.command("month").description("Cost summary for this month").action(async () => {
1166
+ await autoSync();
1167
+ printSummary("This Month", "month");
1168
+ });
1169
+ 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").action(async (opts) => {
1170
+ await autoSync();
941
1171
  const db = openDatabase();
942
1172
  const sessions = querySessions(db, {
943
1173
  agent: opts.agent,
@@ -955,7 +1185,7 @@ program.command("sessions").description("List coding sessions with costs").optio
955
1185
  chalk2.white(s.project_name || chalk2.dim("unknown")),
956
1186
  fmt2(s.total_cost_usd),
957
1187
  chalk2.cyan(fmtTokens(s.total_tokens)),
958
- String(s.request_count),
1188
+ fmtCount(s.request_count),
959
1189
  chalk2.dim(s.started_at.substring(0, 16))
960
1190
  ]));
961
1191
  console.log();
@@ -1015,7 +1245,7 @@ budgetCmd.command("set").description("Set a budget").option("--project <path>",
1015
1245
  const db = openDatabase();
1016
1246
  const now = new Date().toISOString();
1017
1247
  upsertBudget(db, {
1018
- id: randomUUID3(),
1248
+ id: randomUUID2(),
1019
1249
  project_path: opts.project ?? null,
1020
1250
  agent: opts.agent ?? null,
1021
1251
  period: opts.period ?? "monthly",
@@ -1059,7 +1289,7 @@ projectCmd.command("add <path>").description("Add a project").option("--name <na
1059
1289
  const db = openDatabase();
1060
1290
  const { basename: basename3 } = __require("path");
1061
1291
  upsertProject(db, {
1062
- id: randomUUID3(),
1292
+ id: randomUUID2(),
1063
1293
  path,
1064
1294
  name: opts.name ?? basename3(path),
1065
1295
  description: null,
@@ -1100,6 +1330,79 @@ projectCmd.command("rename <path> <name>").description("Rename a project").actio
1100
1330
  upsertProject(db, { ...existing, name });
1101
1331
  console.log(chalk2.green(`\u2713 Renamed to: ${name}`));
1102
1332
  });
1333
+ projectCmd.command("show <nameOrPath>").description("Detailed project breakdown with sparkline").action(async (nameOrPath) => {
1334
+ await autoSync();
1335
+ const db = openDatabase();
1336
+ const sessions = db.prepare(`SELECT * FROM sessions WHERE project_name LIKE ? OR project_path LIKE ? ORDER BY started_at DESC`).all(`%${nameOrPath}%`, `%${nameOrPath}%`);
1337
+ if (sessions.length === 0) {
1338
+ console.log(chalk2.yellow(`No sessions found for: ${nameOrPath}`));
1339
+ return;
1340
+ }
1341
+ const projectName = sessions[0]["project_name"] || nameOrPath;
1342
+ const projectPath = sessions[0]["project_path"] || "";
1343
+ const totalCost = sessions.reduce((s, r) => s + r["total_cost_usd"], 0);
1344
+ const totalTokens = sessions.reduce((s, r) => s + r["total_tokens"], 0);
1345
+ const daily = db.prepare(`
1346
+ SELECT DATE(r.timestamp) as d, SUM(r.cost_usd) as cost
1347
+ FROM requests r JOIN sessions s ON r.session_id = s.id
1348
+ WHERE (s.project_name LIKE ? OR s.project_path LIKE ?)
1349
+ AND r.timestamp >= DATE('now', '-14 days')
1350
+ GROUP BY d ORDER BY d ASC
1351
+ `).all(`%${nameOrPath}%`, `%${nameOrPath}%`);
1352
+ const dailyValues = daily.map((d) => d.cost);
1353
+ const models = db.prepare(`
1354
+ SELECT r.model, COUNT(*) as reqs, SUM(r.cost_usd) as cost
1355
+ FROM requests r JOIN sessions s ON r.session_id = s.id
1356
+ WHERE s.project_name LIKE ? OR s.project_path LIKE ?
1357
+ GROUP BY r.model ORDER BY cost DESC LIMIT 5
1358
+ `).all(`%${nameOrPath}%`, `%${nameOrPath}%`);
1359
+ console.log();
1360
+ console.log(chalk2.bold.cyan(` ${projectName}`));
1361
+ console.log(chalk2.dim(` ${projectPath}`));
1362
+ console.log();
1363
+ printTable(["Metric", "Value"], [
1364
+ ["Total cost", fmt2(totalCost)],
1365
+ ["Sessions", fmtCount(sessions.length)],
1366
+ ["Total tokens", fmtTokens(totalTokens)]
1367
+ ]);
1368
+ if (dailyValues.length > 0) {
1369
+ console.log(`
1370
+ ${chalk2.dim("14-day trend:")} ${sparkline(dailyValues)}`);
1371
+ }
1372
+ if (models.length > 0) {
1373
+ console.log(`
1374
+ ${chalk2.dim("Model breakdown:")}`);
1375
+ for (const m of models) {
1376
+ console.log(` ${chalk2.white(m.model.padEnd(30))} ${fmt2(m.cost)} (${fmtCount(m.reqs)} reqs)`);
1377
+ }
1378
+ }
1379
+ const topSessions = sessions.sort((a, b) => b["total_cost_usd"] - a["total_cost_usd"]).slice(0, 5);
1380
+ if (topSessions.length > 0) {
1381
+ console.log(`
1382
+ ${chalk2.dim("Top sessions:")}`);
1383
+ for (const s of topSessions) {
1384
+ console.log(` ${chalk2.dim(s["id"].substring(0, 12))} ${fmt2(s["total_cost_usd"])} ${chalk2.dim(String(s["started_at"]).substring(0, 16))}`);
1385
+ }
1386
+ }
1387
+ console.log();
1388
+ });
1389
+ var configCmd = program.command("config").description("Manage economy configuration");
1390
+ configCmd.command("set <key> <value>").description("Set a config value").action(async (_key, _value) => {
1391
+ const { setConfigValue: setConfigValue2 } = await Promise.resolve().then(() => (init_config(), exports_config));
1392
+ setConfigValue2(_key, _value);
1393
+ console.log(chalk2.green(`\u2713 ${_key} = ${_value}`));
1394
+ });
1395
+ configCmd.command("get <key>").description("Get a config value").action(async (key) => {
1396
+ const { getConfigValue: getConfigValue2 } = await Promise.resolve().then(() => (init_config(), exports_config));
1397
+ console.log(getConfigValue2(key) ?? chalk2.dim("(not set)"));
1398
+ });
1399
+ configCmd.action(async () => {
1400
+ const { loadConfig: loadConfig2 } = await Promise.resolve().then(() => (init_config(), exports_config));
1401
+ const config = loadConfig2();
1402
+ console.log();
1403
+ printTable(["Key", "Value"], Object.entries(config).map(([k, v]) => [k, String(v)]));
1404
+ console.log();
1405
+ });
1103
1406
  var pricingCmd = program.command("pricing").description("Manage model pricing rates");
1104
1407
  pricingCmd.command("list").description("List all model prices").action(() => {
1105
1408
  const db = openDatabase();
@@ -1142,13 +1445,48 @@ program.command("serve").description("Start the REST API server").option("-p, --
1142
1445
  const { startServer: startServer2 } = await Promise.resolve().then(() => (init_server(), exports_server));
1143
1446
  startServer2(port2);
1144
1447
  });
1145
- program.command("dashboard").description("Open the web dashboard in browser").option("-p, --port <port>", "Server port", "3456").action((opts) => {
1146
- const port2 = opts.port ?? "3456";
1147
- console.log(chalk2.cyan(`Opening dashboard at http://localhost:${port2}`));
1448
+ program.command("dashboard").description("Open the web dashboard (auto-starts server if not running)").option("-p, --port <port>", "Server port", "3456").action(async (opts) => {
1449
+ const port2 = Number(opts.port ?? 3456);
1450
+ const url = `http://localhost:${port2}`;
1451
+ let serverRunning = false;
1148
1452
  try {
1149
- execSync(`open http://localhost:${port2}`);
1453
+ const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(500) });
1454
+ serverRunning = res.ok;
1455
+ } catch {}
1456
+ if (!serverRunning) {
1457
+ console.log(chalk2.cyan(`\u2192 Starting economy server on port ${port2}...`));
1458
+ const { spawn } = await import("child_process");
1459
+ const { resolve, dirname: dirname2 } = await import("path");
1460
+ const serveScript = resolve(dirname2(process.argv[1]), "..", "server", "index.js");
1461
+ const child = spawn(process.execPath, [serveScript], {
1462
+ detached: true,
1463
+ stdio: "ignore",
1464
+ env: { ...process.env, ECONOMY_PORT: String(port2) }
1465
+ });
1466
+ child.unref();
1467
+ let attempts = 0;
1468
+ while (attempts < 20) {
1469
+ await new Promise((r) => setTimeout(r, 250));
1470
+ try {
1471
+ const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(300) });
1472
+ if (res.ok) {
1473
+ serverRunning = true;
1474
+ break;
1475
+ }
1476
+ } catch {}
1477
+ attempts++;
1478
+ }
1479
+ if (serverRunning) {
1480
+ console.log(chalk2.green(`\u2713 Server started`));
1481
+ } else {
1482
+ console.log(chalk2.yellow(`\u26A0 Server didn't respond \u2014 open ${url} manually after running \`economy serve\``));
1483
+ }
1484
+ }
1485
+ console.log(chalk2.cyan(`Opening ${url}`));
1486
+ try {
1487
+ execSync(`open ${url}`);
1150
1488
  } catch {
1151
- console.log(chalk2.yellow(`Run \`economy serve\` first, then open http://localhost:${port2}`));
1489
+ console.log(chalk2.yellow(`Open your browser at ${url}`));
1152
1490
  }
1153
1491
  });
1154
1492
  program.command("mcp").description("Show MCP server install commands").option("--claude", "Install into Claude Code").option("--codex", "Install into Codex").option("--all", "Install into all agents").action(async (opts) => {
@@ -1167,4 +1505,197 @@ Codex (~/.codex/config.toml):`));
1167
1505
  }
1168
1506
  console.log();
1169
1507
  });
1508
+ program.command("session <id>").description("Show detailed breakdown of a single session").action(async (id) => {
1509
+ await autoSync();
1510
+ const db = openDatabase();
1511
+ const session = db.prepare(`SELECT * FROM sessions WHERE id = ? OR id LIKE ?`).get(id, `%${id}%`);
1512
+ if (!session) {
1513
+ console.log(chalk2.red(`Session not found: ${id}`));
1514
+ process.exit(1);
1515
+ }
1516
+ const requests = db.prepare(`SELECT * FROM requests WHERE session_id = ? ORDER BY timestamp ASC`).all(session["id"]);
1517
+ console.log();
1518
+ console.log(chalk2.bold.cyan(` Session: ${session["id"].substring(0, 16)}...`));
1519
+ console.log();
1520
+ printTable(["Field", "Value"], [
1521
+ ["Agent", String(session["agent"])],
1522
+ ["Project", String(session["project_name"] || session["project_path"] || "\u2014")],
1523
+ ["Started", String(session["started_at"]).substring(0, 19)],
1524
+ ["Ended", session["ended_at"] ? String(session["ended_at"]).substring(0, 19) : "\u2014"],
1525
+ ["Total cost", fmt2(session["total_cost_usd"])],
1526
+ ["Total tokens", fmtTokens(session["total_tokens"])],
1527
+ ["Requests", fmtCount(session["request_count"])]
1528
+ ]);
1529
+ if (requests.length > 0) {
1530
+ console.log(chalk2.dim(`
1531
+ Requests (${requests.length}):
1532
+ `));
1533
+ printTable(["Time", "Model", "Input", "Output", "Cache R", "Cache W", "Cost"], requests.slice(0, 50).map((r) => [
1534
+ chalk2.dim(String(r["timestamp"]).substring(11, 19)),
1535
+ chalk2.white(String(r["model"]).substring(0, 22)),
1536
+ fmtTokens(r["input_tokens"]),
1537
+ fmtTokens(r["output_tokens"]),
1538
+ fmtTokens(r["cache_read_tokens"]),
1539
+ fmtTokens(r["cache_create_tokens"]),
1540
+ fmt2(r["cost_usd"])
1541
+ ]));
1542
+ if (requests.length > 50)
1543
+ console.log(chalk2.dim(` ... and ${requests.length - 50} more requests`));
1544
+ }
1545
+ console.log();
1546
+ });
1547
+ 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) => {
1548
+ await autoSync();
1549
+ const db = openDatabase();
1550
+ let csv;
1551
+ if (opts.type === "requests") {
1552
+ const where = opts.period === "today" ? `DATE(timestamp) = DATE('now')` : opts.period === "week" ? `timestamp >= DATE('now', '-7 days')` : opts.period === "all" ? "1=1" : `timestamp >= DATE('now', '-30 days')`;
1553
+ const rows = db.prepare(`SELECT * FROM requests WHERE ${where} ORDER BY timestamp ASC`).all();
1554
+ csv = `id,agent,session_id,model,input_tokens,output_tokens,cache_read_tokens,cache_create_tokens,cost_usd,duration_ms,timestamp
1555
+ `;
1556
+ for (const r of rows) {
1557
+ csv += `${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"]}
1558
+ `;
1559
+ }
1560
+ } else {
1561
+ const where = opts.period === "today" ? `DATE(started_at) = DATE('now')` : opts.period === "week" ? `started_at >= DATE('now', '-7 days')` : opts.period === "all" ? "1=1" : `started_at >= DATE('now', '-30 days')`;
1562
+ const rows = db.prepare(`SELECT * FROM sessions WHERE ${where} ORDER BY started_at DESC`).all();
1563
+ csv = `id,agent,project_path,project_name,started_at,ended_at,total_cost_usd,total_tokens,request_count
1564
+ `;
1565
+ for (const r of rows) {
1566
+ csv += `${r["id"]},${r["agent"]},"${r["project_path"]}","${r["project_name"]}",${r["started_at"]},${r["ended_at"] ?? ""},${r["total_cost_usd"]},${r["total_tokens"]},${r["request_count"]}
1567
+ `;
1568
+ }
1569
+ }
1570
+ if (opts.output) {
1571
+ const { writeFileSync: writeFileSync2 } = await import("fs");
1572
+ writeFileSync2(opts.output, csv);
1573
+ console.log(chalk2.green(`\u2713 Exported to ${opts.output}`));
1574
+ } else {
1575
+ process.stdout.write(csv);
1576
+ }
1577
+ });
1578
+ program.command("compare <period1> <period2>").description("Compare two periods (today/yesterday/week/lastweek/month/lastmonth)").action(async (p1, p2) => {
1579
+ await autoSync();
1580
+ const db = openDatabase();
1581
+ function dateRange(period) {
1582
+ const now = new Date;
1583
+ const today = now.toISOString().substring(0, 10);
1584
+ switch (period) {
1585
+ case "today":
1586
+ return [today, today];
1587
+ case "yesterday": {
1588
+ const d = new Date(now);
1589
+ d.setDate(d.getDate() - 1);
1590
+ const s = d.toISOString().substring(0, 10);
1591
+ return [s, s];
1592
+ }
1593
+ case "week": {
1594
+ const d = new Date(now);
1595
+ d.setDate(d.getDate() - 7);
1596
+ return [d.toISOString().substring(0, 10), today];
1597
+ }
1598
+ case "lastweek": {
1599
+ const d1 = new Date(now);
1600
+ d1.setDate(d1.getDate() - 14);
1601
+ const d2 = new Date(now);
1602
+ d2.setDate(d2.getDate() - 7);
1603
+ return [d1.toISOString().substring(0, 10), d2.toISOString().substring(0, 10)];
1604
+ }
1605
+ case "month": {
1606
+ const d = new Date(now);
1607
+ d.setDate(d.getDate() - 30);
1608
+ return [d.toISOString().substring(0, 10), today];
1609
+ }
1610
+ case "lastmonth": {
1611
+ const d1 = new Date(now);
1612
+ d1.setDate(d1.getDate() - 60);
1613
+ const d2 = new Date(now);
1614
+ d2.setDate(d2.getDate() - 30);
1615
+ return [d1.toISOString().substring(0, 10), d2.toISOString().substring(0, 10)];
1616
+ }
1617
+ default:
1618
+ return [today, today];
1619
+ }
1620
+ }
1621
+ function queryRange(from, to) {
1622
+ const r = db.prepare(`SELECT COALESCE(SUM(cost_usd),0) as cost, COUNT(*) as requests, COALESCE(SUM(input_tokens+output_tokens+cache_read_tokens+cache_create_tokens),0) as tokens FROM requests WHERE DATE(timestamp) BETWEEN ? AND ?`).get(from, to);
1623
+ const s = db.prepare(`SELECT COUNT(*) as sessions FROM sessions WHERE DATE(started_at) BETWEEN ? AND ?`).get(from, to);
1624
+ return { ...r, sessions: s.sessions };
1625
+ }
1626
+ const [f1, t1] = dateRange(p1);
1627
+ const [f2, t2] = dateRange(p2);
1628
+ const a = queryRange(f1, t1);
1629
+ const b = queryRange(f2, t2);
1630
+ function delta(v1, v2) {
1631
+ const d = v1 - v2;
1632
+ const pct = v2 > 0 ? (d / v2 * 100).toFixed(1) : "\u2014";
1633
+ const sign = d >= 0 ? "+" : "";
1634
+ const color = d > 0 ? chalk2.red : d < 0 ? chalk2.green : chalk2.dim;
1635
+ return color(`${sign}${pct}%`);
1636
+ }
1637
+ console.log();
1638
+ console.log(chalk2.bold.cyan(` ${p1} vs ${p2}`));
1639
+ console.log();
1640
+ printTable(["Metric", p1, p2, "Change"], [
1641
+ ["Cost", fmt2(a.cost), fmt2(b.cost), delta(a.cost, b.cost)],
1642
+ ["Sessions", fmtCount(a.sessions), fmtCount(b.sessions), delta(a.sessions, b.sessions)],
1643
+ ["Requests", fmtCount(a.requests), fmtCount(b.requests), delta(a.requests, b.requests)],
1644
+ ["Tokens", fmtTokens(a.tokens), fmtTokens(b.tokens), delta(a.tokens, b.tokens)]
1645
+ ]);
1646
+ console.log();
1647
+ });
1648
+ program.command("forecast").description("Project end-of-month cost based on current burn rate").action(async () => {
1649
+ await autoSync();
1650
+ const db = openDatabase();
1651
+ const now = new Date;
1652
+ const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
1653
+ const dayOfMonth = now.getDate();
1654
+ const monthStart = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
1655
+ const today = now.toISOString().substring(0, 10);
1656
+ const monthSoFar = db.prepare(`SELECT COALESCE(SUM(cost_usd),0) as cost FROM requests WHERE DATE(timestamp) >= ?`).get(monthStart);
1657
+ const dailyAvg = dayOfMonth > 0 ? monthSoFar.cost / dayOfMonth : 0;
1658
+ const projected = dailyAvg * daysInMonth;
1659
+ const sevenDaysAgo = new Date(now);
1660
+ sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
1661
+ const last7 = db.prepare(`SELECT COALESCE(SUM(cost_usd),0) as cost FROM requests WHERE DATE(timestamp) >= ?`).get(sevenDaysAgo.toISOString().substring(0, 10));
1662
+ const last7DailyAvg = last7.cost / 7;
1663
+ const last7Projected = last7DailyAvg * daysInMonth;
1664
+ const dailyCosts = db.prepare(`SELECT DATE(timestamp) as d, SUM(cost_usd) as cost FROM requests WHERE DATE(timestamp) >= ? GROUP BY d ORDER BY cost ASC`).all(monthStart);
1665
+ const cheapest = dailyCosts[0];
1666
+ const mostExpensive = dailyCosts[dailyCosts.length - 1];
1667
+ console.log();
1668
+ console.log(chalk2.bold.cyan(` Forecast (${dayOfMonth} of ${daysInMonth} days)`));
1669
+ console.log();
1670
+ printTable(["Metric", "Value"], [
1671
+ ["Spent so far", fmt2(monthSoFar.cost)],
1672
+ ["Daily average", fmt2(dailyAvg)],
1673
+ [chalk2.bold("Projected total"), chalk2.bold(fmt2(projected).replace(chalk2.green(""), ""))],
1674
+ ["Last 7-day rate", `${fmt2(last7DailyAvg)}/day \u2192 ${fmt2(last7Projected)}`],
1675
+ ["Cheapest day", cheapest ? `${fmt2(cheapest.cost)} (${cheapest.d})` : "\u2014"],
1676
+ ["Most expensive", mostExpensive ? `${fmt2(mostExpensive.cost)} (${mostExpensive.d})` : "\u2014"]
1677
+ ]);
1678
+ console.log();
1679
+ });
1680
+ program.command("efficiency").description("Show output/input token ratio per model").action(async () => {
1681
+ await autoSync();
1682
+ const db = openDatabase();
1683
+ const models = db.prepare(`
1684
+ SELECT model, SUM(input_tokens) as input, SUM(output_tokens) as output,
1685
+ SUM(cache_read_tokens) as cache_read, SUM(cache_create_tokens) as cache_write,
1686
+ COUNT(*) as requests, SUM(cost_usd) as cost
1687
+ FROM requests GROUP BY model ORDER BY cost DESC
1688
+ `).all();
1689
+ console.log();
1690
+ console.log(chalk2.bold.cyan(" Token Efficiency"));
1691
+ console.log();
1692
+ printTable(["Model", "Output/Input", "Cache Hit%", "Cost/1k Output", "Requests"], models.map((m) => {
1693
+ const ratio = m.input > 0 ? (m.output / m.input).toFixed(2) : "\u2014";
1694
+ const totalInput = m.input + m.cache_read + m.cache_write;
1695
+ const cacheHit = totalInput > 0 ? (m.cache_read / totalInput * 100).toFixed(1) + "%" : "\u2014";
1696
+ const costPer1kOutput = m.output > 0 ? fmt2(m.cost / m.output * 1000) : "\u2014";
1697
+ return [chalk2.white(m.model), ratio, cacheHit, costPer1kOutput, fmtCount(m.requests)];
1698
+ }));
1699
+ console.log();
1700
+ });
1170
1701
  program.parse();