@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 +37 -45
- package/dist/cli/index.js +228 -100
- package/dist/db/database.d.ts +10 -1
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/pg-migrations.d.ts.map +1 -1
- package/dist/index.js +64 -17
- package/dist/ingest/claude.d.ts +1 -1
- package/dist/ingest/claude.d.ts.map +1 -1
- package/dist/ingest/codex.d.ts +1 -1
- package/dist/ingest/codex.d.ts.map +1 -1
- package/dist/ingest/gemini.d.ts +1 -1
- package/dist/ingest/gemini.d.ts.map +1 -1
- package/dist/lib/package-metadata.d.ts +8 -0
- package/dist/lib/package-metadata.d.ts.map +1 -0
- package/dist/lib/pricing.d.ts +1 -1
- package/dist/lib/pricing.d.ts.map +1 -1
- package/dist/lib/webhooks.d.ts +1 -1
- package/dist/lib/webhooks.d.ts.map +1 -1
- package/dist/mcp/index.js +386 -347
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +237 -26
- package/dist/server/serve.d.ts +1 -1
- package/dist/server/serve.d.ts.map +1 -1
- package/dist/types/index.d.ts +3 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +6 -5
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
|
-
|
|
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
|
+
[](https://www.npmjs.com/package/@hasna/economy)
|
|
6
|
+
[](LICENSE)
|
|
21
7
|
|
|
22
8
|
## Install
|
|
23
9
|
|
|
24
10
|
```bash
|
|
25
|
-
bun
|
|
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
|
|
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
|
-
|
|
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
|
-
##
|
|
43
|
+
## Cloud Sync
|
|
58
44
|
|
|
59
|
-
|
|
60
|
-
import { EconomyClient } from '@hasna/economy-sdk'
|
|
45
|
+
This package supports cloud sync via `@hasna/cloud`:
|
|
61
46
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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, {
|
|
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"]
|
|
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/
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
}
|
|
1666
|
-
|
|
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(
|
|
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();
|