@hasna/economy 0.2.5 → 0.2.6
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 +2235 -0
- package/package.json +1 -1
|
@@ -0,0 +1,2235 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __returnValue = (v) => v;
|
|
5
|
+
function __exportSetter(name, newValue) {
|
|
6
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
7
|
+
}
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, {
|
|
11
|
+
get: all[name],
|
|
12
|
+
enumerable: true,
|
|
13
|
+
configurable: true,
|
|
14
|
+
set: __exportSetter.bind(all, name)
|
|
15
|
+
});
|
|
16
|
+
};
|
|
17
|
+
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
18
|
+
var __require = import.meta.require;
|
|
19
|
+
|
|
20
|
+
// src/lib/pricing.ts
|
|
21
|
+
var exports_pricing = {};
|
|
22
|
+
__export(exports_pricing, {
|
|
23
|
+
normalizeModelName: () => normalizeModelName,
|
|
24
|
+
getPricingFromDb: () => getPricingFromDb,
|
|
25
|
+
getPricing: () => getPricing,
|
|
26
|
+
ensurePricingSeeded: () => ensurePricingSeeded,
|
|
27
|
+
computeCostFromDb: () => computeCostFromDb,
|
|
28
|
+
computeCost: () => computeCost,
|
|
29
|
+
DEFAULT_PRICING: () => DEFAULT_PRICING
|
|
30
|
+
});
|
|
31
|
+
function normalizeModelName(raw) {
|
|
32
|
+
return raw.replace(/-\d{8}$/, "").replace(/-\d{4}-\d{2}-\d{2}$/, "").toLowerCase();
|
|
33
|
+
}
|
|
34
|
+
function ensurePricingSeeded(db) {
|
|
35
|
+
seedModelPricing(db, DEFAULT_PRICING);
|
|
36
|
+
}
|
|
37
|
+
function getPricingFromDb(db, model) {
|
|
38
|
+
const normalized = normalizeModelName(model);
|
|
39
|
+
const row = getModelPricing(db, normalized);
|
|
40
|
+
if (row) {
|
|
41
|
+
return {
|
|
42
|
+
inputPer1M: row.input_per_1m,
|
|
43
|
+
outputPer1M: row.output_per_1m,
|
|
44
|
+
cacheReadPer1M: row.cache_read_per_1m,
|
|
45
|
+
cacheWritePer1M: row.cache_write_per_1m
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const allRows = db.prepare(`SELECT * FROM model_pricing`).all();
|
|
49
|
+
for (const r of allRows) {
|
|
50
|
+
if (normalized.startsWith(r.model)) {
|
|
51
|
+
return { inputPer1M: r.input_per_1m, outputPer1M: r.output_per_1m, cacheReadPer1M: r.cache_read_per_1m, cacheWritePer1M: r.cache_write_per_1m };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
function getPricing(model) {
|
|
57
|
+
const normalized = normalizeModelName(model);
|
|
58
|
+
if (DEFAULT_PRICING[normalized])
|
|
59
|
+
return DEFAULT_PRICING[normalized] ?? null;
|
|
60
|
+
for (const key of Object.keys(DEFAULT_PRICING)) {
|
|
61
|
+
if (normalized.startsWith(key))
|
|
62
|
+
return DEFAULT_PRICING[key] ?? null;
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
function computeCost(model, inputTokens, outputTokens, cacheReadTokens = 0, cacheWriteTokens = 0) {
|
|
67
|
+
const pricing = getPricing(model);
|
|
68
|
+
if (!pricing)
|
|
69
|
+
return 0;
|
|
70
|
+
return (inputTokens * pricing.inputPer1M + outputTokens * pricing.outputPer1M + cacheReadTokens * pricing.cacheReadPer1M + cacheWriteTokens * pricing.cacheWritePer1M) / 1e6;
|
|
71
|
+
}
|
|
72
|
+
function computeCostFromDb(db, model, inputTokens, outputTokens, cacheReadTokens = 0, cacheWriteTokens = 0) {
|
|
73
|
+
const pricing = getPricingFromDb(db, model) ?? getPricing(model);
|
|
74
|
+
if (!pricing)
|
|
75
|
+
return 0;
|
|
76
|
+
return (inputTokens * pricing.inputPer1M + outputTokens * pricing.outputPer1M + cacheReadTokens * pricing.cacheReadPer1M + cacheWriteTokens * pricing.cacheWritePer1M) / 1e6;
|
|
77
|
+
}
|
|
78
|
+
var DEFAULT_PRICING;
|
|
79
|
+
var init_pricing = __esm(() => {
|
|
80
|
+
init_database();
|
|
81
|
+
DEFAULT_PRICING = {
|
|
82
|
+
"claude-opus-4-6": { inputPer1M: 5, outputPer1M: 25, cacheReadPer1M: 0.5, cacheWritePer1M: 6.25 },
|
|
83
|
+
"claude-opus-4-5": { inputPer1M: 5, outputPer1M: 25, cacheReadPer1M: 0.5, cacheWritePer1M: 6.25 },
|
|
84
|
+
"claude-sonnet-4-6": { inputPer1M: 3, outputPer1M: 15, cacheReadPer1M: 0.3, cacheWritePer1M: 3.75 },
|
|
85
|
+
"claude-sonnet-4-5": { inputPer1M: 3, outputPer1M: 15, cacheReadPer1M: 0.3, cacheWritePer1M: 3.75 },
|
|
86
|
+
"claude-haiku-4-5": { inputPer1M: 1, outputPer1M: 5, cacheReadPer1M: 0.1, cacheWritePer1M: 1.25 },
|
|
87
|
+
"claude-3-5-sonnet": { inputPer1M: 3, outputPer1M: 15, cacheReadPer1M: 0.3, cacheWritePer1M: 3.75 },
|
|
88
|
+
"claude-3-5-haiku": { inputPer1M: 1, outputPer1M: 5, cacheReadPer1M: 0.1, cacheWritePer1M: 1.25 },
|
|
89
|
+
"claude-3-opus": { inputPer1M: 15, outputPer1M: 75, cacheReadPer1M: 1.5, cacheWritePer1M: 18.75 },
|
|
90
|
+
"claude-3-sonnet": { inputPer1M: 3, outputPer1M: 15, cacheReadPer1M: 0.3, cacheWritePer1M: 3.75 },
|
|
91
|
+
"claude-3-haiku": { inputPer1M: 0.25, outputPer1M: 1.25, cacheReadPer1M: 0.03, cacheWritePer1M: 0.3 },
|
|
92
|
+
"gemini-2.0-flash": { inputPer1M: 0.075, outputPer1M: 0.3, cacheReadPer1M: 0, cacheWritePer1M: 0 },
|
|
93
|
+
"gemini-2.5-pro": { inputPer1M: 1.25, outputPer1M: 10, cacheReadPer1M: 0, cacheWritePer1M: 0 },
|
|
94
|
+
"gemini-1.5-pro": { inputPer1M: 1.25, outputPer1M: 5, cacheReadPer1M: 0, cacheWritePer1M: 0 },
|
|
95
|
+
"gemini-1.5-flash": { inputPer1M: 0.075, outputPer1M: 0.3, cacheReadPer1M: 0, cacheWritePer1M: 0 },
|
|
96
|
+
"gpt-5.3-codex": { inputPer1M: 1.75, outputPer1M: 14, cacheReadPer1M: 0.44, cacheWritePer1M: 0 },
|
|
97
|
+
"gpt-5.2-codex": { inputPer1M: 1.75, outputPer1M: 14, cacheReadPer1M: 0.44, cacheWritePer1M: 0 },
|
|
98
|
+
"gpt-5-codex": { inputPer1M: 1.75, outputPer1M: 14, cacheReadPer1M: 0.44, cacheWritePer1M: 0 },
|
|
99
|
+
"gpt-4o": { inputPer1M: 2.5, outputPer1M: 10, cacheReadPer1M: 1.25, cacheWritePer1M: 0 },
|
|
100
|
+
"gpt-4o-mini": { inputPer1M: 0.15, outputPer1M: 0.6, cacheReadPer1M: 0.075, cacheWritePer1M: 0 },
|
|
101
|
+
o1: { inputPer1M: 15, outputPer1M: 60, cacheReadPer1M: 7.5, cacheWritePer1M: 0 },
|
|
102
|
+
"o1-mini": { inputPer1M: 3, outputPer1M: 12, cacheReadPer1M: 1.5, cacheWritePer1M: 0 },
|
|
103
|
+
o3: { inputPer1M: 10, outputPer1M: 40, cacheReadPer1M: 2.5, cacheWritePer1M: 0 },
|
|
104
|
+
"o3-mini": { inputPer1M: 1.1, outputPer1M: 4.4, cacheReadPer1M: 0.55, cacheWritePer1M: 0 },
|
|
105
|
+
"o4-mini": { inputPer1M: 1.1, outputPer1M: 4.4, cacheReadPer1M: 0.275, cacheWritePer1M: 0 }
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// src/db/database.ts
|
|
110
|
+
import { Database } from "bun:sqlite";
|
|
111
|
+
import { existsSync, mkdirSync } from "fs";
|
|
112
|
+
import { homedir } from "os";
|
|
113
|
+
import { join } from "path";
|
|
114
|
+
function getDbPath() {
|
|
115
|
+
return process.env["ECONOMY_DB"] ?? join(homedir(), ".economy", "economy.db");
|
|
116
|
+
}
|
|
117
|
+
function openDatabase(dbPath, skipSeed = false) {
|
|
118
|
+
const path = dbPath ?? getDbPath();
|
|
119
|
+
if (path !== ":memory:") {
|
|
120
|
+
const dir = path.substring(0, path.lastIndexOf("/"));
|
|
121
|
+
if (dir && !existsSync(dir))
|
|
122
|
+
mkdirSync(dir, { recursive: true });
|
|
123
|
+
}
|
|
124
|
+
const db = new Database(path);
|
|
125
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
126
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
127
|
+
initSchema(db);
|
|
128
|
+
if (!skipSeed) {
|
|
129
|
+
Promise.resolve().then(() => (init_pricing(), exports_pricing)).then(({ ensurePricingSeeded: ensurePricingSeeded2 }) => ensurePricingSeeded2(db)).catch(() => {});
|
|
130
|
+
}
|
|
131
|
+
return db;
|
|
132
|
+
}
|
|
133
|
+
function initSchema(db) {
|
|
134
|
+
db.exec(`
|
|
135
|
+
CREATE TABLE IF NOT EXISTS requests (
|
|
136
|
+
id TEXT PRIMARY KEY,
|
|
137
|
+
agent TEXT NOT NULL,
|
|
138
|
+
session_id TEXT NOT NULL,
|
|
139
|
+
model TEXT NOT NULL,
|
|
140
|
+
input_tokens INTEGER DEFAULT 0,
|
|
141
|
+
output_tokens INTEGER DEFAULT 0,
|
|
142
|
+
cache_read_tokens INTEGER DEFAULT 0,
|
|
143
|
+
cache_create_tokens INTEGER DEFAULT 0,
|
|
144
|
+
cost_usd REAL NOT NULL DEFAULT 0,
|
|
145
|
+
duration_ms INTEGER DEFAULT 0,
|
|
146
|
+
timestamp TEXT NOT NULL,
|
|
147
|
+
source_request_id TEXT
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
151
|
+
id TEXT PRIMARY KEY,
|
|
152
|
+
agent TEXT NOT NULL,
|
|
153
|
+
project_path TEXT DEFAULT '',
|
|
154
|
+
project_name TEXT DEFAULT '',
|
|
155
|
+
started_at TEXT NOT NULL,
|
|
156
|
+
ended_at TEXT,
|
|
157
|
+
total_cost_usd REAL DEFAULT 0,
|
|
158
|
+
total_tokens INTEGER DEFAULT 0,
|
|
159
|
+
request_count INTEGER DEFAULT 0
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
163
|
+
id TEXT PRIMARY KEY,
|
|
164
|
+
path TEXT UNIQUE NOT NULL,
|
|
165
|
+
name TEXT NOT NULL,
|
|
166
|
+
description TEXT,
|
|
167
|
+
tags TEXT DEFAULT '[]',
|
|
168
|
+
created_at TEXT NOT NULL
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
CREATE TABLE IF NOT EXISTS budgets (
|
|
172
|
+
id TEXT PRIMARY KEY,
|
|
173
|
+
project_path TEXT,
|
|
174
|
+
agent TEXT,
|
|
175
|
+
period TEXT NOT NULL,
|
|
176
|
+
limit_usd REAL NOT NULL,
|
|
177
|
+
alert_at_percent INTEGER DEFAULT 80,
|
|
178
|
+
created_at TEXT NOT NULL,
|
|
179
|
+
updated_at TEXT NOT NULL
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
CREATE TABLE IF NOT EXISTS goals (
|
|
183
|
+
id TEXT PRIMARY KEY,
|
|
184
|
+
period TEXT NOT NULL,
|
|
185
|
+
project_path TEXT,
|
|
186
|
+
agent TEXT,
|
|
187
|
+
limit_usd REAL NOT NULL,
|
|
188
|
+
created_at TEXT NOT NULL,
|
|
189
|
+
updated_at TEXT NOT NULL
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
CREATE TABLE IF NOT EXISTS ingest_state (
|
|
193
|
+
source TEXT NOT NULL,
|
|
194
|
+
key TEXT NOT NULL,
|
|
195
|
+
value TEXT NOT NULL,
|
|
196
|
+
PRIMARY KEY (source, key)
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
CREATE INDEX IF NOT EXISTS idx_requests_session ON requests(session_id);
|
|
200
|
+
CREATE INDEX IF NOT EXISTS idx_requests_timestamp ON requests(timestamp);
|
|
201
|
+
CREATE INDEX IF NOT EXISTS idx_requests_agent ON requests(agent);
|
|
202
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_agent ON sessions(agent);
|
|
203
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_path);
|
|
204
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at);
|
|
205
|
+
|
|
206
|
+
CREATE TABLE IF NOT EXISTS model_pricing (
|
|
207
|
+
model TEXT PRIMARY KEY,
|
|
208
|
+
input_per_1m REAL NOT NULL DEFAULT 0,
|
|
209
|
+
output_per_1m REAL NOT NULL DEFAULT 0,
|
|
210
|
+
cache_read_per_1m REAL NOT NULL DEFAULT 0,
|
|
211
|
+
cache_write_per_1m REAL NOT NULL DEFAULT 0,
|
|
212
|
+
updated_at TEXT NOT NULL
|
|
213
|
+
);
|
|
214
|
+
`);
|
|
215
|
+
}
|
|
216
|
+
function periodWhere(period) {
|
|
217
|
+
switch (period) {
|
|
218
|
+
case "today":
|
|
219
|
+
return `DATE(timestamp) = DATE('now')`;
|
|
220
|
+
case "week":
|
|
221
|
+
return `timestamp >= DATE('now', '-7 days')`;
|
|
222
|
+
case "month":
|
|
223
|
+
return `timestamp >= DATE('now', '-30 days')`;
|
|
224
|
+
case "year":
|
|
225
|
+
return `timestamp >= DATE('now', '-365 days')`;
|
|
226
|
+
case "all":
|
|
227
|
+
return "1=1";
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function sessionPeriodWhere(period) {
|
|
231
|
+
switch (period) {
|
|
232
|
+
case "today":
|
|
233
|
+
return `DATE(started_at) = DATE('now')`;
|
|
234
|
+
case "week":
|
|
235
|
+
return `started_at >= DATE('now', '-7 days')`;
|
|
236
|
+
case "month":
|
|
237
|
+
return `started_at >= DATE('now', '-30 days')`;
|
|
238
|
+
case "year":
|
|
239
|
+
return `started_at >= DATE('now', '-365 days')`;
|
|
240
|
+
case "all":
|
|
241
|
+
return "1=1";
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
function upsertRequest(db, req) {
|
|
245
|
+
db.prepare(`
|
|
246
|
+
INSERT OR REPLACE INTO requests
|
|
247
|
+
(id, agent, session_id, model, input_tokens, output_tokens,
|
|
248
|
+
cache_read_tokens, cache_create_tokens, cost_usd, duration_ms,
|
|
249
|
+
timestamp, source_request_id)
|
|
250
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
251
|
+
`).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);
|
|
252
|
+
}
|
|
253
|
+
function upsertSession(db, session) {
|
|
254
|
+
db.prepare(`
|
|
255
|
+
INSERT OR REPLACE INTO sessions
|
|
256
|
+
(id, agent, project_path, project_name, started_at, ended_at,
|
|
257
|
+
total_cost_usd, total_tokens, request_count)
|
|
258
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
259
|
+
`).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);
|
|
260
|
+
}
|
|
261
|
+
function rollupSession(db, sessionId) {
|
|
262
|
+
db.prepare(`
|
|
263
|
+
UPDATE sessions SET
|
|
264
|
+
total_cost_usd = (SELECT COALESCE(SUM(cost_usd), 0) FROM requests WHERE session_id = ?),
|
|
265
|
+
total_tokens = (SELECT COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_create_tokens), 0) FROM requests WHERE session_id = ?),
|
|
266
|
+
request_count = (SELECT COUNT(*) FROM requests WHERE session_id = ?),
|
|
267
|
+
ended_at = (SELECT MAX(timestamp) FROM requests WHERE session_id = ?),
|
|
268
|
+
started_at = CASE WHEN started_at = '' OR started_at IS NULL
|
|
269
|
+
THEN (SELECT MIN(timestamp) FROM requests WHERE session_id = ?)
|
|
270
|
+
ELSE started_at END
|
|
271
|
+
WHERE id = ?
|
|
272
|
+
`).run(sessionId, sessionId, sessionId, sessionId, sessionId, sessionId);
|
|
273
|
+
}
|
|
274
|
+
function querySessions(db, filter = {}) {
|
|
275
|
+
const conditions = [];
|
|
276
|
+
const params = [];
|
|
277
|
+
if (filter.agent) {
|
|
278
|
+
conditions.push("agent = ?");
|
|
279
|
+
params.push(filter.agent);
|
|
280
|
+
}
|
|
281
|
+
if (filter.project) {
|
|
282
|
+
conditions.push("project_path LIKE ?");
|
|
283
|
+
params.push(`%${filter.project}%`);
|
|
284
|
+
}
|
|
285
|
+
if (filter.since) {
|
|
286
|
+
conditions.push("started_at >= ?");
|
|
287
|
+
params.push(filter.since);
|
|
288
|
+
}
|
|
289
|
+
if (filter.search) {
|
|
290
|
+
const q = `%${filter.search}%`;
|
|
291
|
+
conditions.push("(project_name LIKE ? OR agent LIKE ? OR id LIKE ?)");
|
|
292
|
+
params.push(q, q, `${filter.search}%`);
|
|
293
|
+
}
|
|
294
|
+
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
295
|
+
const limit = filter.limit ?? 50;
|
|
296
|
+
const offset = filter.offset ?? 0;
|
|
297
|
+
return db.prepare(`
|
|
298
|
+
SELECT * FROM sessions ${where} ORDER BY started_at DESC LIMIT ? OFFSET ?
|
|
299
|
+
`).all(...params, limit, offset);
|
|
300
|
+
}
|
|
301
|
+
function queryTopSessions(db, n = 10, agent) {
|
|
302
|
+
if (agent) {
|
|
303
|
+
return db.prepare(`SELECT * FROM sessions WHERE agent = ? ORDER BY total_cost_usd DESC LIMIT ?`).all(agent, n);
|
|
304
|
+
}
|
|
305
|
+
return db.prepare(`SELECT * FROM sessions ORDER BY total_cost_usd DESC LIMIT ?`).all(n);
|
|
306
|
+
}
|
|
307
|
+
function querySummary(db, period) {
|
|
308
|
+
const rWhere = periodWhere(period);
|
|
309
|
+
const sWhere = sessionPeriodWhere(period);
|
|
310
|
+
const r = db.prepare(`
|
|
311
|
+
SELECT COALESCE(SUM(cost_usd), 0) as total_usd,
|
|
312
|
+
COUNT(*) as requests,
|
|
313
|
+
COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_create_tokens), 0) as tokens
|
|
314
|
+
FROM requests WHERE ${rWhere}
|
|
315
|
+
`).get();
|
|
316
|
+
const codexTotals = db.prepare(`
|
|
317
|
+
SELECT COALESCE(SUM(total_cost_usd), 0) as cost_usd,
|
|
318
|
+
COALESCE(SUM(total_tokens), 0) as tokens,
|
|
319
|
+
COUNT(*) as sessions
|
|
320
|
+
FROM sessions
|
|
321
|
+
WHERE ${sWhere}
|
|
322
|
+
AND id NOT IN (SELECT DISTINCT session_id FROM requests)
|
|
323
|
+
`).get();
|
|
324
|
+
const sessionCount = db.prepare(`SELECT COUNT(*) as sessions FROM sessions WHERE ${sWhere}`).get();
|
|
325
|
+
return {
|
|
326
|
+
total_usd: r.total_usd + codexTotals.cost_usd,
|
|
327
|
+
requests: r.requests,
|
|
328
|
+
tokens: r.tokens + codexTotals.tokens,
|
|
329
|
+
sessions: sessionCount.sessions,
|
|
330
|
+
period
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
function queryModelBreakdown(db) {
|
|
334
|
+
return db.prepare(`
|
|
335
|
+
SELECT model, agent,
|
|
336
|
+
COUNT(*) as requests,
|
|
337
|
+
COALESCE(SUM(input_tokens), 0) as input_tokens,
|
|
338
|
+
COALESCE(SUM(output_tokens), 0) as output_tokens,
|
|
339
|
+
COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_create_tokens), 0) as total_tokens,
|
|
340
|
+
COALESCE(SUM(cost_usd), 0) as cost_usd
|
|
341
|
+
FROM requests GROUP BY model, agent ORDER BY cost_usd DESC
|
|
342
|
+
`).all();
|
|
343
|
+
}
|
|
344
|
+
function queryProjectBreakdown(db) {
|
|
345
|
+
return db.prepare(`
|
|
346
|
+
SELECT
|
|
347
|
+
s.project_path,
|
|
348
|
+
COALESCE(p.name, s.project_name) as project_name,
|
|
349
|
+
COUNT(DISTINCT s.id) as sessions,
|
|
350
|
+
COUNT(r.id) as requests,
|
|
351
|
+
COALESCE(SUM(r.cost_usd), COALESCE(SUM(s.total_cost_usd), 0)) as cost_usd,
|
|
352
|
+
COALESCE(SUM(r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_create_tokens), 0) as total_tokens,
|
|
353
|
+
MAX(s.started_at) as last_active
|
|
354
|
+
FROM sessions s
|
|
355
|
+
LEFT JOIN projects p ON p.path = s.project_path OR p.name = s.project_name
|
|
356
|
+
LEFT JOIN requests r ON r.session_id = s.id
|
|
357
|
+
WHERE s.project_path != '' OR s.project_name != ''
|
|
358
|
+
GROUP BY s.project_path
|
|
359
|
+
ORDER BY cost_usd DESC
|
|
360
|
+
`).all();
|
|
361
|
+
}
|
|
362
|
+
function queryDailyBreakdown(db, days = 30) {
|
|
363
|
+
return db.prepare(`
|
|
364
|
+
SELECT DATE(timestamp) as date, agent, COALESCE(SUM(cost_usd), 0) as cost_usd
|
|
365
|
+
FROM requests
|
|
366
|
+
WHERE timestamp >= DATE('now', ? || ' days')
|
|
367
|
+
GROUP BY DATE(timestamp), agent
|
|
368
|
+
ORDER BY date ASC
|
|
369
|
+
`).all(`-${days}`);
|
|
370
|
+
}
|
|
371
|
+
function upsertProject(db, project) {
|
|
372
|
+
db.prepare(`
|
|
373
|
+
INSERT OR REPLACE INTO projects (id, path, name, description, tags, created_at)
|
|
374
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
375
|
+
`).run(project.id, project.path, project.name, project.description ?? null, JSON.stringify(project.tags), project.created_at);
|
|
376
|
+
}
|
|
377
|
+
function getProject(db, path) {
|
|
378
|
+
const row = db.prepare(`SELECT * FROM projects WHERE path = ?`).get(path);
|
|
379
|
+
if (!row)
|
|
380
|
+
return null;
|
|
381
|
+
return { ...row, tags: JSON.parse(row["tags"] ?? "[]") };
|
|
382
|
+
}
|
|
383
|
+
function listProjects(db) {
|
|
384
|
+
return db.prepare(`SELECT * FROM projects ORDER BY created_at DESC`).all().map((row) => ({ ...row, tags: JSON.parse(row["tags"] ?? "[]") }));
|
|
385
|
+
}
|
|
386
|
+
function deleteProject(db, path) {
|
|
387
|
+
db.prepare(`DELETE FROM projects WHERE path = ?`).run(path);
|
|
388
|
+
}
|
|
389
|
+
function upsertBudget(db, budget) {
|
|
390
|
+
db.prepare(`
|
|
391
|
+
INSERT OR REPLACE INTO budgets
|
|
392
|
+
(id, project_path, agent, period, limit_usd, alert_at_percent, created_at, updated_at)
|
|
393
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
394
|
+
`).run(budget.id, budget.project_path ?? null, budget.agent ?? null, budget.period, budget.limit_usd, budget.alert_at_percent, budget.created_at, budget.updated_at);
|
|
395
|
+
}
|
|
396
|
+
function listBudgets(db) {
|
|
397
|
+
return db.prepare(`SELECT * FROM budgets ORDER BY created_at DESC`).all();
|
|
398
|
+
}
|
|
399
|
+
function deleteBudget(db, id) {
|
|
400
|
+
db.prepare(`DELETE FROM budgets WHERE id = ?`).run(id);
|
|
401
|
+
}
|
|
402
|
+
function getBudgetStatuses(db) {
|
|
403
|
+
const budgets = listBudgets(db);
|
|
404
|
+
return budgets.map((b) => {
|
|
405
|
+
const periodStart = b.period === "daily" ? "DATE('now')" : b.period === "weekly" ? "DATE('now', '-7 days')" : "DATE('now', '-30 days')";
|
|
406
|
+
let spendQuery = `SELECT COALESCE(SUM(cost_usd), 0) as spend FROM requests WHERE timestamp >= ${periodStart}`;
|
|
407
|
+
const params = [];
|
|
408
|
+
if (b.project_path) {
|
|
409
|
+
spendQuery += ` AND session_id IN (SELECT id FROM sessions WHERE project_path = ?)`;
|
|
410
|
+
params.push(b.project_path);
|
|
411
|
+
}
|
|
412
|
+
if (b.agent) {
|
|
413
|
+
spendQuery += ` AND agent = ?`;
|
|
414
|
+
params.push(b.agent);
|
|
415
|
+
}
|
|
416
|
+
const row = db.prepare(spendQuery).get(...params);
|
|
417
|
+
const spend = row.spend;
|
|
418
|
+
const percent = b.limit_usd > 0 ? spend / b.limit_usd * 100 : 0;
|
|
419
|
+
return {
|
|
420
|
+
...b,
|
|
421
|
+
current_spend_usd: spend,
|
|
422
|
+
percent_used: percent,
|
|
423
|
+
is_over_limit: percent >= 100,
|
|
424
|
+
is_over_alert: percent >= b.alert_at_percent
|
|
425
|
+
};
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
function upsertGoal(db, goal) {
|
|
429
|
+
db.prepare(`
|
|
430
|
+
INSERT OR REPLACE INTO goals
|
|
431
|
+
(id, period, project_path, agent, limit_usd, created_at, updated_at)
|
|
432
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
433
|
+
`).run(goal.id, goal.period, goal.project_path ?? null, goal.agent ?? null, goal.limit_usd, goal.created_at, goal.updated_at);
|
|
434
|
+
}
|
|
435
|
+
function deleteGoal(db, id) {
|
|
436
|
+
db.prepare(`DELETE FROM goals WHERE id = ?`).run(id);
|
|
437
|
+
}
|
|
438
|
+
function listGoals(db) {
|
|
439
|
+
return db.prepare(`SELECT * FROM goals ORDER BY created_at DESC`).all();
|
|
440
|
+
}
|
|
441
|
+
function getGoalStatuses(db) {
|
|
442
|
+
const goals = listGoals(db);
|
|
443
|
+
return goals.map((g) => {
|
|
444
|
+
const periodStart = g.period === "day" ? "DATE('now')" : g.period === "week" ? "DATE('now', '-7 days')" : g.period === "month" ? "DATE('now', '-30 days')" : "DATE('now', '-365 days')";
|
|
445
|
+
let spendQuery = `SELECT COALESCE(SUM(cost_usd), 0) as spend FROM requests WHERE timestamp >= ${periodStart}`;
|
|
446
|
+
const params = [];
|
|
447
|
+
if (g.project_path) {
|
|
448
|
+
spendQuery += ` AND session_id IN (SELECT id FROM sessions WHERE project_path = ?)`;
|
|
449
|
+
params.push(g.project_path);
|
|
450
|
+
}
|
|
451
|
+
if (g.agent) {
|
|
452
|
+
spendQuery += ` AND agent = ?`;
|
|
453
|
+
params.push(g.agent);
|
|
454
|
+
}
|
|
455
|
+
const row = db.prepare(spendQuery).get(...params);
|
|
456
|
+
const spend = row.spend;
|
|
457
|
+
const percent = g.limit_usd > 0 ? spend / g.limit_usd * 100 : 0;
|
|
458
|
+
return {
|
|
459
|
+
...g,
|
|
460
|
+
current_spend_usd: spend,
|
|
461
|
+
percent_used: percent,
|
|
462
|
+
is_on_track: percent < 70,
|
|
463
|
+
is_at_risk: percent >= 70 && percent <= 100,
|
|
464
|
+
is_over: percent > 100
|
|
465
|
+
};
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
function getIngestState(db, source, key) {
|
|
469
|
+
const row = db.prepare(`SELECT value FROM ingest_state WHERE source = ? AND key = ?`).get(source, key);
|
|
470
|
+
return row?.value ?? null;
|
|
471
|
+
}
|
|
472
|
+
function setIngestState(db, source, key, value) {
|
|
473
|
+
db.prepare(`INSERT OR REPLACE INTO ingest_state (source, key, value) VALUES (?, ?, ?)`).run(source, key, value);
|
|
474
|
+
}
|
|
475
|
+
function queryRequestsSince(db, since) {
|
|
476
|
+
return db.prepare(`SELECT * FROM requests WHERE timestamp > ? ORDER BY timestamp ASC`).all(since);
|
|
477
|
+
}
|
|
478
|
+
function upsertModelPricing(db, p) {
|
|
479
|
+
db.prepare(`
|
|
480
|
+
INSERT OR REPLACE INTO model_pricing
|
|
481
|
+
(model, input_per_1m, output_per_1m, cache_read_per_1m, cache_write_per_1m, updated_at)
|
|
482
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
483
|
+
`).run(p.model, p.input_per_1m, p.output_per_1m, p.cache_read_per_1m, p.cache_write_per_1m, p.updated_at);
|
|
484
|
+
}
|
|
485
|
+
function getModelPricing(db, model) {
|
|
486
|
+
return db.prepare(`SELECT * FROM model_pricing WHERE model = ?`).get(model);
|
|
487
|
+
}
|
|
488
|
+
function listModelPricing(db) {
|
|
489
|
+
return db.prepare(`SELECT * FROM model_pricing ORDER BY model ASC`).all();
|
|
490
|
+
}
|
|
491
|
+
function deleteModelPricing(db, model) {
|
|
492
|
+
db.prepare(`DELETE FROM model_pricing WHERE model = ?`).run(model);
|
|
493
|
+
}
|
|
494
|
+
function seedModelPricing(db, defaults) {
|
|
495
|
+
const existing = db.prepare(`SELECT COUNT(*) as count FROM model_pricing`).get();
|
|
496
|
+
if (existing.count > 0)
|
|
497
|
+
return;
|
|
498
|
+
const now = new Date().toISOString();
|
|
499
|
+
for (const [model, p] of Object.entries(defaults)) {
|
|
500
|
+
upsertModelPricing(db, {
|
|
501
|
+
model,
|
|
502
|
+
input_per_1m: p.inputPer1M,
|
|
503
|
+
output_per_1m: p.outputPer1M,
|
|
504
|
+
cache_read_per_1m: p.cacheReadPer1M,
|
|
505
|
+
cache_write_per_1m: p.cacheWritePer1M,
|
|
506
|
+
updated_at: now
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
var init_database = () => {};
|
|
511
|
+
|
|
512
|
+
// src/ingest/claude.ts
|
|
513
|
+
import { readdirSync, readFileSync, existsSync as existsSync2, statSync } from "fs";
|
|
514
|
+
import { homedir as homedir2 } from "os";
|
|
515
|
+
import { join as join2, basename } from "path";
|
|
516
|
+
function autoDetectProject(cwd, projects) {
|
|
517
|
+
return projects.find((p) => cwd === p.path || cwd.startsWith(p.path + "/"));
|
|
518
|
+
}
|
|
519
|
+
function dirNameToPath(dirName) {
|
|
520
|
+
return dirName.replace(/^-/, "/").replace(/-/g, "/").replace(/\/\//g, "/-");
|
|
521
|
+
}
|
|
522
|
+
function collectJsonlFiles(projectDir) {
|
|
523
|
+
const files = [];
|
|
524
|
+
function walk(dir) {
|
|
525
|
+
try {
|
|
526
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
527
|
+
if (entry.isDirectory())
|
|
528
|
+
walk(join2(dir, entry.name));
|
|
529
|
+
else if (entry.name.endsWith(".jsonl"))
|
|
530
|
+
files.push(join2(dir, entry.name));
|
|
531
|
+
}
|
|
532
|
+
} catch {}
|
|
533
|
+
}
|
|
534
|
+
walk(projectDir);
|
|
535
|
+
return files;
|
|
536
|
+
}
|
|
537
|
+
async function ingestClaude(db, verbose = false, _telemetryDir) {
|
|
538
|
+
if (!existsSync2(PROJECTS_DIR)) {
|
|
539
|
+
if (verbose)
|
|
540
|
+
console.log("Claude projects dir not found:", PROJECTS_DIR);
|
|
541
|
+
return { files: 0, requests: 0, sessions: 0 };
|
|
542
|
+
}
|
|
543
|
+
let totalFiles = 0;
|
|
544
|
+
let totalRequests = 0;
|
|
545
|
+
const touchedSessions = new Set;
|
|
546
|
+
const registeredProjects = db.prepare(`SELECT path, name FROM projects ORDER BY LENGTH(path) DESC`).all();
|
|
547
|
+
const projectDirs = readdirSync(PROJECTS_DIR, { withFileTypes: true }).filter((d) => d.isDirectory());
|
|
548
|
+
for (const projectDirEntry of projectDirs) {
|
|
549
|
+
const projectDirPath = join2(PROJECTS_DIR, projectDirEntry.name);
|
|
550
|
+
const projectPath = dirNameToPath(projectDirEntry.name);
|
|
551
|
+
const jsonlFiles = collectJsonlFiles(projectDirPath);
|
|
552
|
+
for (const filePath of jsonlFiles) {
|
|
553
|
+
const stateKey = filePath.replace(PROJECTS_DIR, "");
|
|
554
|
+
let fileMtime = "0";
|
|
555
|
+
try {
|
|
556
|
+
fileMtime = statSync(filePath).mtimeMs.toString();
|
|
557
|
+
} catch {
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
const processed = getIngestState(db, "claude", stateKey);
|
|
561
|
+
if (processed === fileMtime)
|
|
562
|
+
continue;
|
|
563
|
+
let lines;
|
|
564
|
+
try {
|
|
565
|
+
lines = readFileSync(filePath, "utf-8").split(`
|
|
566
|
+
`).filter((l) => l.trim());
|
|
567
|
+
} catch {
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
const fileBasename = basename(filePath, ".jsonl");
|
|
571
|
+
const isUuid = /^[0-9a-f-]{36}$/.test(fileBasename);
|
|
572
|
+
let sessionId = isUuid ? fileBasename : fileBasename.replace(/^agent-/, "");
|
|
573
|
+
let sessionCwd = projectPath;
|
|
574
|
+
for (const line of lines) {
|
|
575
|
+
let entry;
|
|
576
|
+
try {
|
|
577
|
+
entry = JSON.parse(line);
|
|
578
|
+
} catch {
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
if (entry.sessionId)
|
|
582
|
+
sessionId = entry.sessionId;
|
|
583
|
+
if (entry.cwd)
|
|
584
|
+
sessionCwd = entry.cwd;
|
|
585
|
+
if (entry.message?.role !== "assistant")
|
|
586
|
+
continue;
|
|
587
|
+
const usage = entry.message.usage;
|
|
588
|
+
if (!usage)
|
|
589
|
+
continue;
|
|
590
|
+
const model = entry.message.model;
|
|
591
|
+
if (!model)
|
|
592
|
+
continue;
|
|
593
|
+
const inputTokens = usage.input_tokens ?? 0;
|
|
594
|
+
const outputTokens = usage.output_tokens ?? 0;
|
|
595
|
+
const cacheWriteTokens = usage.cache_creation_input_tokens ?? 0;
|
|
596
|
+
const cacheReadTokens = usage.cache_read_input_tokens ?? 0;
|
|
597
|
+
const timestamp = entry.timestamp ?? new Date().toISOString();
|
|
598
|
+
if (inputTokens + outputTokens + cacheWriteTokens === 0)
|
|
599
|
+
continue;
|
|
600
|
+
const costUsd = computeCostFromDb(db, model, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens);
|
|
601
|
+
const reqId = `claude-${sessionId}-${timestamp}`;
|
|
602
|
+
upsertRequest(db, {
|
|
603
|
+
id: reqId,
|
|
604
|
+
agent: "claude",
|
|
605
|
+
session_id: sessionId,
|
|
606
|
+
model,
|
|
607
|
+
input_tokens: inputTokens,
|
|
608
|
+
output_tokens: outputTokens,
|
|
609
|
+
cache_read_tokens: cacheReadTokens,
|
|
610
|
+
cache_create_tokens: cacheWriteTokens,
|
|
611
|
+
cost_usd: costUsd,
|
|
612
|
+
duration_ms: 0,
|
|
613
|
+
timestamp,
|
|
614
|
+
source_request_id: reqId
|
|
615
|
+
});
|
|
616
|
+
if (!touchedSessions.has(sessionId)) {
|
|
617
|
+
const existing = db.prepare(`SELECT id FROM sessions WHERE id = ?`).get(sessionId);
|
|
618
|
+
if (!existing) {
|
|
619
|
+
const effectiveCwd = sessionCwd || projectPath;
|
|
620
|
+
const detectedProject = autoDetectProject(effectiveCwd, registeredProjects);
|
|
621
|
+
const session = {
|
|
622
|
+
id: sessionId,
|
|
623
|
+
agent: "claude",
|
|
624
|
+
project_path: detectedProject ? detectedProject.path : effectiveCwd,
|
|
625
|
+
project_name: detectedProject ? detectedProject.name : "",
|
|
626
|
+
started_at: timestamp,
|
|
627
|
+
ended_at: null,
|
|
628
|
+
total_cost_usd: 0,
|
|
629
|
+
total_tokens: 0,
|
|
630
|
+
request_count: 0
|
|
631
|
+
};
|
|
632
|
+
upsertSession(db, session);
|
|
633
|
+
}
|
|
634
|
+
touchedSessions.add(sessionId);
|
|
635
|
+
}
|
|
636
|
+
totalRequests++;
|
|
637
|
+
}
|
|
638
|
+
setIngestState(db, "claude", stateKey, fileMtime);
|
|
639
|
+
totalFiles++;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
for (const sessionId of touchedSessions) {
|
|
643
|
+
rollupSession(db, sessionId);
|
|
644
|
+
}
|
|
645
|
+
return { files: totalFiles, requests: totalRequests, sessions: touchedSessions.size };
|
|
646
|
+
}
|
|
647
|
+
var PROJECTS_DIR;
|
|
648
|
+
var init_claude = __esm(() => {
|
|
649
|
+
init_database();
|
|
650
|
+
init_pricing();
|
|
651
|
+
PROJECTS_DIR = join2(homedir2(), ".claude", "projects");
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
// src/ingest/codex.ts
|
|
655
|
+
import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
|
|
656
|
+
import { homedir as homedir3 } from "os";
|
|
657
|
+
import { join as join3, basename as basename2 } from "path";
|
|
658
|
+
import { Database as Database2 } from "bun:sqlite";
|
|
659
|
+
async function ingestCodex(db, verbose = false) {
|
|
660
|
+
if (!existsSync3(CODEX_DB_PATH)) {
|
|
661
|
+
if (verbose)
|
|
662
|
+
console.log("Codex DB not found:", CODEX_DB_PATH);
|
|
663
|
+
return { sessions: 0 };
|
|
664
|
+
}
|
|
665
|
+
let codexDb = null;
|
|
666
|
+
let ingested = 0;
|
|
667
|
+
try {
|
|
668
|
+
codexDb = new Database2(CODEX_DB_PATH, { readonly: true });
|
|
669
|
+
const threads = codexDb.prepare(`SELECT id, cwd, created_at, updated_at, tokens_used, title FROM threads WHERE tokens_used > 0`).all();
|
|
670
|
+
for (const thread of threads) {
|
|
671
|
+
const stateKey = thread.id;
|
|
672
|
+
const processed = getIngestState(db, "codex", stateKey);
|
|
673
|
+
if (processed === "done")
|
|
674
|
+
continue;
|
|
675
|
+
const costUsd = 0;
|
|
676
|
+
const projectPath = thread.cwd ?? "";
|
|
677
|
+
const projectName = projectPath ? basename2(projectPath) : "unknown";
|
|
678
|
+
const startedAt = thread.created_at ? new Date(thread.created_at * 1000).toISOString() : new Date().toISOString();
|
|
679
|
+
const endedAt = thread.updated_at ? new Date(thread.updated_at * 1000).toISOString() : null;
|
|
680
|
+
upsertSession(db, {
|
|
681
|
+
id: `codex-${thread.id}`,
|
|
682
|
+
agent: "codex",
|
|
683
|
+
project_path: projectPath,
|
|
684
|
+
project_name: projectName,
|
|
685
|
+
started_at: startedAt,
|
|
686
|
+
ended_at: endedAt,
|
|
687
|
+
total_cost_usd: costUsd,
|
|
688
|
+
total_tokens: thread.tokens_used,
|
|
689
|
+
request_count: 1
|
|
690
|
+
});
|
|
691
|
+
setIngestState(db, "codex", stateKey, "done");
|
|
692
|
+
ingested++;
|
|
693
|
+
if (verbose)
|
|
694
|
+
console.log(`Codex session ${thread.id}: ${thread.tokens_used} tokens \u2192 $${costUsd.toFixed(4)}`);
|
|
695
|
+
}
|
|
696
|
+
} finally {
|
|
697
|
+
codexDb?.close();
|
|
698
|
+
}
|
|
699
|
+
return { sessions: ingested };
|
|
700
|
+
}
|
|
701
|
+
var CODEX_DB_PATH, CODEX_CONFIG_PATH;
|
|
702
|
+
var init_codex = __esm(() => {
|
|
703
|
+
init_database();
|
|
704
|
+
CODEX_DB_PATH = join3(homedir3(), ".codex", "state_5.sqlite");
|
|
705
|
+
CODEX_CONFIG_PATH = join3(homedir3(), ".codex", "config.toml");
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
// src/lib/config.ts
|
|
709
|
+
var exports_config = {};
|
|
710
|
+
__export(exports_config, {
|
|
711
|
+
setConfigValue: () => setConfigValue,
|
|
712
|
+
saveConfig: () => saveConfig,
|
|
713
|
+
loadConfig: () => loadConfig,
|
|
714
|
+
getConfigValue: () => getConfigValue
|
|
715
|
+
});
|
|
716
|
+
import { existsSync as existsSync5, readFileSync as readFileSync4, writeFileSync, mkdirSync as mkdirSync2 } from "fs";
|
|
717
|
+
import { homedir as homedir5 } from "os";
|
|
718
|
+
import { join as join5 } from "path";
|
|
719
|
+
function loadConfig() {
|
|
720
|
+
try {
|
|
721
|
+
if (existsSync5(CONFIG_PATH)) {
|
|
722
|
+
const raw = readFileSync4(CONFIG_PATH, "utf-8");
|
|
723
|
+
return { ...DEFAULTS, ...JSON.parse(raw) };
|
|
724
|
+
}
|
|
725
|
+
} catch {}
|
|
726
|
+
return { ...DEFAULTS };
|
|
727
|
+
}
|
|
728
|
+
function saveConfig(config) {
|
|
729
|
+
const dir = CONFIG_PATH.substring(0, CONFIG_PATH.lastIndexOf("/"));
|
|
730
|
+
if (!existsSync5(dir))
|
|
731
|
+
mkdirSync2(dir, { recursive: true });
|
|
732
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + `
|
|
733
|
+
`);
|
|
734
|
+
}
|
|
735
|
+
function getConfigValue(key) {
|
|
736
|
+
const config = loadConfig();
|
|
737
|
+
return config[key] ?? null;
|
|
738
|
+
}
|
|
739
|
+
function setConfigValue(key, value) {
|
|
740
|
+
const config = loadConfig();
|
|
741
|
+
let parsed = value;
|
|
742
|
+
if (value === "true")
|
|
743
|
+
parsed = true;
|
|
744
|
+
else if (value === "false")
|
|
745
|
+
parsed = false;
|
|
746
|
+
else if (value === "null")
|
|
747
|
+
parsed = null;
|
|
748
|
+
else if (!isNaN(Number(value)))
|
|
749
|
+
parsed = Number(value);
|
|
750
|
+
else if (value.startsWith("[")) {
|
|
751
|
+
try {
|
|
752
|
+
parsed = JSON.parse(value);
|
|
753
|
+
} catch {}
|
|
754
|
+
}
|
|
755
|
+
config[key] = parsed;
|
|
756
|
+
saveConfig(config);
|
|
757
|
+
}
|
|
758
|
+
var CONFIG_PATH, DEFAULTS;
|
|
759
|
+
var init_config = __esm(() => {
|
|
760
|
+
CONFIG_PATH = join5(homedir5(), ".economy", "config.json");
|
|
761
|
+
DEFAULTS = {
|
|
762
|
+
port: 3456,
|
|
763
|
+
"default-period": "today",
|
|
764
|
+
"auto-sync": true,
|
|
765
|
+
"sync-interval": 30,
|
|
766
|
+
"alert-thresholds": [5, 10, 25, 50, 100],
|
|
767
|
+
"webhook-url": null
|
|
768
|
+
};
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
// src/lib/webhooks.ts
|
|
772
|
+
var exports_webhooks = {};
|
|
773
|
+
__export(exports_webhooks, {
|
|
774
|
+
checkAndFireWebhooks: () => checkAndFireWebhooks
|
|
775
|
+
});
|
|
776
|
+
async function checkAndFireWebhooks(db) {
|
|
777
|
+
const config = loadConfig();
|
|
778
|
+
const url = config["webhook-url"];
|
|
779
|
+
if (!url)
|
|
780
|
+
return;
|
|
781
|
+
const statuses = getBudgetStatuses(db);
|
|
782
|
+
for (const b of statuses) {
|
|
783
|
+
if (!b.is_over_alert)
|
|
784
|
+
continue;
|
|
785
|
+
const key = `webhook-budget-${b.id}-${b.period}`;
|
|
786
|
+
const lastFired = getIngestState(db, "webhook", key);
|
|
787
|
+
const pctBucket = Math.floor(b.percent_used / 10) * 10;
|
|
788
|
+
if (lastFired === String(pctBucket))
|
|
789
|
+
continue;
|
|
790
|
+
await fireWebhook(url, {
|
|
791
|
+
event: "budget_alert",
|
|
792
|
+
budget_id: b.id,
|
|
793
|
+
project: b.project_path ?? "global",
|
|
794
|
+
period: b.period,
|
|
795
|
+
spend: b.current_spend_usd,
|
|
796
|
+
limit: b.limit_usd,
|
|
797
|
+
percent: Math.round(b.percent_used * 10) / 10
|
|
798
|
+
});
|
|
799
|
+
setIngestState(db, "webhook", key, String(pctBucket));
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
async function fireWebhook(url, payload) {
|
|
803
|
+
try {
|
|
804
|
+
await fetch(url, {
|
|
805
|
+
method: "POST",
|
|
806
|
+
headers: { "Content-Type": "application/json" },
|
|
807
|
+
body: JSON.stringify(payload),
|
|
808
|
+
signal: AbortSignal.timeout(5000)
|
|
809
|
+
});
|
|
810
|
+
} catch {}
|
|
811
|
+
}
|
|
812
|
+
var init_webhooks = __esm(() => {
|
|
813
|
+
init_config();
|
|
814
|
+
init_database();
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
// src/cli/commands/watch.ts
|
|
818
|
+
var exports_watch = {};
|
|
819
|
+
__export(exports_watch, {
|
|
820
|
+
watchCosts: () => watchCosts
|
|
821
|
+
});
|
|
822
|
+
import chalk from "chalk";
|
|
823
|
+
function fmt(usd) {
|
|
824
|
+
return chalk.green(`$${usd.toFixed(4)}`);
|
|
825
|
+
}
|
|
826
|
+
function notify(title, body) {
|
|
827
|
+
try {
|
|
828
|
+
const { execSync } = __require("child_process");
|
|
829
|
+
execSync(`osascript -e 'display notification "${body.replace(/'/g, "")}" with title "${title.replace(/'/g, "")}"'`, { stdio: "ignore" });
|
|
830
|
+
} catch {}
|
|
831
|
+
}
|
|
832
|
+
function renderHeader(todayUsd, weekUsd) {
|
|
833
|
+
process.stdout.write("\x1B[H\x1B[2J");
|
|
834
|
+
console.log(chalk.bold.cyan(" economy watch") + chalk.dim(" \u2014 live cost stream"));
|
|
835
|
+
console.log(chalk.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
836
|
+
console.log(` Today: ${fmt(todayUsd)} Week: ${fmt(weekUsd)}`);
|
|
837
|
+
console.log(chalk.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
838
|
+
console.log(chalk.dim(" [agent] cost model tokens project"));
|
|
839
|
+
console.log(chalk.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
840
|
+
}
|
|
841
|
+
async function watchCosts(opts) {
|
|
842
|
+
const db = openDatabase();
|
|
843
|
+
let lastCheck = new Date(Date.now() - opts.interval * 1000).toISOString();
|
|
844
|
+
const lines = [];
|
|
845
|
+
const MAX_LINES = 20;
|
|
846
|
+
let sessionCumulativeCost = 0;
|
|
847
|
+
let notifyThresholdFired = 0;
|
|
848
|
+
const initialSummaryToday = querySummary(db, "today");
|
|
849
|
+
const initialSummaryWeek = querySummary(db, "week");
|
|
850
|
+
renderHeader(initialSummaryToday.total_usd, initialSummaryWeek.total_usd);
|
|
851
|
+
console.log(chalk.dim(`
|
|
852
|
+
Polling every ${opts.interval}s \u2014 Ctrl+C to exit
|
|
853
|
+
`));
|
|
854
|
+
async function poll() {
|
|
855
|
+
const now = new Date().toISOString();
|
|
856
|
+
await ingestClaude(db);
|
|
857
|
+
await ingestCodex(db);
|
|
858
|
+
const newRequests = queryRequestsSince(db, lastCheck);
|
|
859
|
+
lastCheck = now;
|
|
860
|
+
for (const req of newRequests) {
|
|
861
|
+
if (opts.agent && req.agent !== opts.agent)
|
|
862
|
+
continue;
|
|
863
|
+
const agentLabel = req.agent === "claude" ? chalk.blue("[claude]") : chalk.yellow("[codex] ");
|
|
864
|
+
const tokens = req.input_tokens + req.output_tokens;
|
|
865
|
+
const tokStr = tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens);
|
|
866
|
+
const line = ` ${agentLabel} ${fmt(req.cost_usd).padEnd(14)}${req.model.substring(0, 24).padEnd(26)}${tokStr.padEnd(10)}${req.session_id.substring(0, 12)}`;
|
|
867
|
+
lines.push(line);
|
|
868
|
+
if (lines.length > MAX_LINES)
|
|
869
|
+
lines.shift();
|
|
870
|
+
if (req.cost_usd > 1) {
|
|
871
|
+
notify("economy: high cost", `$${req.cost_usd.toFixed(2)} on ${req.model}`);
|
|
872
|
+
}
|
|
873
|
+
if (opts.notify && opts.notify > 0) {
|
|
874
|
+
sessionCumulativeCost += req.cost_usd;
|
|
875
|
+
const crossedThresholds = Math.floor(sessionCumulativeCost / opts.notify);
|
|
876
|
+
if (crossedThresholds > notifyThresholdFired) {
|
|
877
|
+
notifyThresholdFired = crossedThresholds;
|
|
878
|
+
const { execSync } = __require("child_process");
|
|
879
|
+
try {
|
|
880
|
+
execSync(`osascript -e 'display notification "Economy: $${sessionCumulativeCost.toFixed(2)} spent this session" with title "Cost Alert"'`);
|
|
881
|
+
} catch {}
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
const today = querySummary(db, "today");
|
|
886
|
+
const week = querySummary(db, "week");
|
|
887
|
+
renderHeader(today.total_usd, week.total_usd);
|
|
888
|
+
for (const line of lines)
|
|
889
|
+
console.log(line);
|
|
890
|
+
if (lines.length === 0)
|
|
891
|
+
console.log(chalk.dim(" Waiting for new requests..."));
|
|
892
|
+
console.log(chalk.dim(`
|
|
893
|
+
Last updated: ${new Date().toLocaleTimeString()} \u2014 polling every ${opts.interval}s \u2014 Ctrl+C to exit`));
|
|
894
|
+
}
|
|
895
|
+
await poll();
|
|
896
|
+
const timer = setInterval(poll, opts.interval * 1000);
|
|
897
|
+
process.on("SIGINT", () => {
|
|
898
|
+
clearInterval(timer);
|
|
899
|
+
console.log(chalk.dim(`
|
|
900
|
+
|
|
901
|
+
Stopped watching.`));
|
|
902
|
+
process.exit(0);
|
|
903
|
+
});
|
|
904
|
+
await new Promise(() => {});
|
|
905
|
+
}
|
|
906
|
+
var init_watch = __esm(() => {
|
|
907
|
+
init_database();
|
|
908
|
+
init_claude();
|
|
909
|
+
init_codex();
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
// src/server/serve.ts
|
|
913
|
+
var exports_serve = {};
|
|
914
|
+
__export(exports_serve, {
|
|
915
|
+
startServer: () => startServer,
|
|
916
|
+
createHandler: () => createHandler
|
|
917
|
+
});
|
|
918
|
+
import { randomUUID } from "crypto";
|
|
919
|
+
function json(data, status = 200) {
|
|
920
|
+
return new Response(JSON.stringify(data), {
|
|
921
|
+
status,
|
|
922
|
+
headers: { "Content-Type": "application/json", ...CORS }
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
function ok(data, meta) {
|
|
926
|
+
return json({ data, meta: meta ?? {} });
|
|
927
|
+
}
|
|
928
|
+
function err(message, status = 400) {
|
|
929
|
+
return json({ error: message }, status);
|
|
930
|
+
}
|
|
931
|
+
function applyFields(obj, fields) {
|
|
932
|
+
if (!fields || fields.length === 0)
|
|
933
|
+
return obj;
|
|
934
|
+
return Object.fromEntries(fields.map((f) => [f, obj[f] ?? null]));
|
|
935
|
+
}
|
|
936
|
+
function createHandler(db) {
|
|
937
|
+
return async function handler(req) {
|
|
938
|
+
const url = new URL(req.url);
|
|
939
|
+
const path = url.pathname;
|
|
940
|
+
const method = req.method;
|
|
941
|
+
if (method === "OPTIONS")
|
|
942
|
+
return new Response(null, { status: 204, headers: CORS });
|
|
943
|
+
if (path === "/health")
|
|
944
|
+
return ok({ status: "ok", ts: new Date().toISOString() });
|
|
945
|
+
if (path === "/api/summary" && method === "GET") {
|
|
946
|
+
const period = url.searchParams.get("period") ?? "today";
|
|
947
|
+
return ok(querySummary(db, period));
|
|
948
|
+
}
|
|
949
|
+
if (path === "/api/daily" && method === "GET") {
|
|
950
|
+
const days = Number(url.searchParams.get("days") ?? 30);
|
|
951
|
+
return ok(queryDailyBreakdown(db, days));
|
|
952
|
+
}
|
|
953
|
+
if (path === "/api/sessions" && method === "GET") {
|
|
954
|
+
const agent = url.searchParams.get("agent");
|
|
955
|
+
const project = url.searchParams.get("project") ?? undefined;
|
|
956
|
+
const limit = Number(url.searchParams.get("limit") ?? 50);
|
|
957
|
+
const offset = Number(url.searchParams.get("offset") ?? 0);
|
|
958
|
+
const since = url.searchParams.get("since") ?? undefined;
|
|
959
|
+
const fieldsParam = url.searchParams.get("fields");
|
|
960
|
+
const fields = fieldsParam ? fieldsParam.split(",").map((f) => f.trim()).filter(Boolean) : undefined;
|
|
961
|
+
const sessions = querySessions(db, { agent: agent ?? undefined, project, limit, offset, since });
|
|
962
|
+
return ok(fields ? sessions.map((s) => applyFields(s, fields)) : sessions, { limit, offset });
|
|
963
|
+
}
|
|
964
|
+
if (path === "/api/top" && method === "GET") {
|
|
965
|
+
const n = Number(url.searchParams.get("n") ?? 10);
|
|
966
|
+
const agent = url.searchParams.get("agent") ?? undefined;
|
|
967
|
+
return ok(queryTopSessions(db, n, agent));
|
|
968
|
+
}
|
|
969
|
+
if (path === "/api/models" && method === "GET") {
|
|
970
|
+
return ok(queryModelBreakdown(db));
|
|
971
|
+
}
|
|
972
|
+
if (path === "/api/projects" && method === "GET") {
|
|
973
|
+
return ok(queryProjectBreakdown(db));
|
|
974
|
+
}
|
|
975
|
+
if (path === "/api/breakdown" && method === "GET") {
|
|
976
|
+
const by = url.searchParams.get("by") ?? "model";
|
|
977
|
+
return ok(by === "project" ? queryProjectBreakdown(db) : queryModelBreakdown(db));
|
|
978
|
+
}
|
|
979
|
+
if (path === "/api/budgets" && method === "GET") {
|
|
980
|
+
return ok(getBudgetStatuses(db));
|
|
981
|
+
}
|
|
982
|
+
if (path === "/api/budgets" && method === "POST") {
|
|
983
|
+
const body = await req.json();
|
|
984
|
+
const now = new Date().toISOString();
|
|
985
|
+
upsertBudget(db, {
|
|
986
|
+
id: randomUUID(),
|
|
987
|
+
project_path: body["project_path"] ?? null,
|
|
988
|
+
agent: body["agent"] ?? null,
|
|
989
|
+
period: body["period"] ?? "monthly",
|
|
990
|
+
limit_usd: Number(body["limit_usd"]),
|
|
991
|
+
alert_at_percent: Number(body["alert_at_percent"] ?? 80),
|
|
992
|
+
created_at: now,
|
|
993
|
+
updated_at: now
|
|
994
|
+
});
|
|
995
|
+
return ok({ ok: true });
|
|
996
|
+
}
|
|
997
|
+
const budgetMatch = path.match(/^\/api\/budgets\/(.+)$/);
|
|
998
|
+
if (budgetMatch && method === "DELETE") {
|
|
999
|
+
deleteBudget(db, budgetMatch[1]);
|
|
1000
|
+
return ok({ ok: true });
|
|
1001
|
+
}
|
|
1002
|
+
if (path === "/api/project-registry" && method === "GET") {
|
|
1003
|
+
return ok(listProjects(db));
|
|
1004
|
+
}
|
|
1005
|
+
if (path === "/api/project-registry" && method === "POST") {
|
|
1006
|
+
const body = await req.json();
|
|
1007
|
+
const { basename: basename3 } = await import("path");
|
|
1008
|
+
const projPath = body["path"];
|
|
1009
|
+
upsertProject(db, {
|
|
1010
|
+
id: randomUUID(),
|
|
1011
|
+
path: projPath,
|
|
1012
|
+
name: body["name"] ?? basename3(projPath),
|
|
1013
|
+
description: body["description"] ?? null,
|
|
1014
|
+
tags: body["tags"] ?? [],
|
|
1015
|
+
created_at: new Date().toISOString()
|
|
1016
|
+
});
|
|
1017
|
+
return ok({ ok: true });
|
|
1018
|
+
}
|
|
1019
|
+
const projMatch = path.match(/^\/api\/project-registry\/(.+)$/);
|
|
1020
|
+
if (projMatch && method === "DELETE") {
|
|
1021
|
+
deleteProject(db, decodeURIComponent(projMatch[1]));
|
|
1022
|
+
return ok({ ok: true });
|
|
1023
|
+
}
|
|
1024
|
+
if (path === "/api/pricing" && method === "GET") {
|
|
1025
|
+
return ok(listModelPricing(db));
|
|
1026
|
+
}
|
|
1027
|
+
if (path === "/api/pricing" && method === "POST") {
|
|
1028
|
+
const body = await req.json();
|
|
1029
|
+
upsertModelPricing(db, {
|
|
1030
|
+
model: body["model"],
|
|
1031
|
+
input_per_1m: Number(body["input_per_1m"]),
|
|
1032
|
+
output_per_1m: Number(body["output_per_1m"]),
|
|
1033
|
+
cache_read_per_1m: Number(body["cache_read_per_1m"] ?? 0),
|
|
1034
|
+
cache_write_per_1m: Number(body["cache_write_per_1m"] ?? 0),
|
|
1035
|
+
updated_at: new Date().toISOString()
|
|
1036
|
+
});
|
|
1037
|
+
return ok({ ok: true });
|
|
1038
|
+
}
|
|
1039
|
+
const pricingMatch = path.match(/^\/api\/pricing\/(.+)$/);
|
|
1040
|
+
if (pricingMatch && method === "DELETE") {
|
|
1041
|
+
deleteModelPricing(db, decodeURIComponent(pricingMatch[1]));
|
|
1042
|
+
return ok({ ok: true });
|
|
1043
|
+
}
|
|
1044
|
+
if (path === "/api/sync" && method === "POST") {
|
|
1045
|
+
const body = await req.json().catch(() => ({}));
|
|
1046
|
+
const sources = body["sources"] ?? "all";
|
|
1047
|
+
const results = {};
|
|
1048
|
+
if (sources === "all" || sources === "claude")
|
|
1049
|
+
results["claude"] = await ingestClaude(db);
|
|
1050
|
+
if (sources === "all" || sources === "codex")
|
|
1051
|
+
results["codex"] = await ingestCodex(db);
|
|
1052
|
+
return ok(results);
|
|
1053
|
+
}
|
|
1054
|
+
const sessionRequestsMatch = path.match(/^\/api\/sessions\/([^/]+)\/requests$/);
|
|
1055
|
+
if (sessionRequestsMatch && method === "GET") {
|
|
1056
|
+
const sessionId = sessionRequestsMatch[1];
|
|
1057
|
+
const session = db.prepare(`SELECT * FROM sessions WHERE id = ? OR id LIKE ?`).get(sessionId, `${sessionId}%`);
|
|
1058
|
+
if (!session)
|
|
1059
|
+
return err("Session not found", 404);
|
|
1060
|
+
const requests = db.prepare(`SELECT * FROM requests WHERE session_id = ? ORDER BY timestamp ASC`).all(session["id"]);
|
|
1061
|
+
return ok(requests, { session_id: session["id"], count: requests.length });
|
|
1062
|
+
}
|
|
1063
|
+
if (path === "/api/goals" && method === "GET") {
|
|
1064
|
+
return ok(getGoalStatuses(db));
|
|
1065
|
+
}
|
|
1066
|
+
if (path === "/api/goals" && method === "POST") {
|
|
1067
|
+
const body = await req.json();
|
|
1068
|
+
const now = new Date().toISOString();
|
|
1069
|
+
upsertGoal(db, {
|
|
1070
|
+
id: randomUUID(),
|
|
1071
|
+
period: body["period"] ?? "month",
|
|
1072
|
+
project_path: body["project_path"] ?? null,
|
|
1073
|
+
agent: body["agent"] ?? null,
|
|
1074
|
+
limit_usd: Number(body["limit_usd"]),
|
|
1075
|
+
created_at: now,
|
|
1076
|
+
updated_at: now
|
|
1077
|
+
});
|
|
1078
|
+
return ok({ ok: true });
|
|
1079
|
+
}
|
|
1080
|
+
const goalMatch = path.match(/^\/api\/goals\/(.+)$/);
|
|
1081
|
+
if (goalMatch && method === "DELETE") {
|
|
1082
|
+
deleteGoal(db, goalMatch[1]);
|
|
1083
|
+
return ok({ ok: true });
|
|
1084
|
+
}
|
|
1085
|
+
return err("Not found", 404);
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
function startServer(port = 3456) {
|
|
1089
|
+
const db = openDatabase();
|
|
1090
|
+
ensurePricingSeeded(db);
|
|
1091
|
+
const apiHandler = createHandler(db);
|
|
1092
|
+
const dashboardDir = new URL("../../dashboard/dist", import.meta.url).pathname;
|
|
1093
|
+
Bun.serve({
|
|
1094
|
+
port,
|
|
1095
|
+
async fetch(req) {
|
|
1096
|
+
const url = new URL(req.url);
|
|
1097
|
+
if (url.pathname.startsWith("/api") || url.pathname === "/health") {
|
|
1098
|
+
return apiHandler(req);
|
|
1099
|
+
}
|
|
1100
|
+
try {
|
|
1101
|
+
const { existsSync: existsSync6 } = await import("fs");
|
|
1102
|
+
if (existsSync6(dashboardDir)) {
|
|
1103
|
+
let filePath = url.pathname === "/" ? "/index.html" : url.pathname;
|
|
1104
|
+
const fullPath = dashboardDir + filePath;
|
|
1105
|
+
if (existsSync6(fullPath)) {
|
|
1106
|
+
return new Response(Bun.file(fullPath));
|
|
1107
|
+
}
|
|
1108
|
+
const indexPath = dashboardDir + "/index.html";
|
|
1109
|
+
if (existsSync6(indexPath)) {
|
|
1110
|
+
return new Response(Bun.file(indexPath));
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
} catch {}
|
|
1114
|
+
return apiHandler(req);
|
|
1115
|
+
}
|
|
1116
|
+
});
|
|
1117
|
+
console.log(`economy-serve listening on http://localhost:${port}`);
|
|
1118
|
+
}
|
|
1119
|
+
var CORS;
|
|
1120
|
+
var init_serve = __esm(() => {
|
|
1121
|
+
init_database();
|
|
1122
|
+
init_claude();
|
|
1123
|
+
init_codex();
|
|
1124
|
+
init_pricing();
|
|
1125
|
+
CORS = {
|
|
1126
|
+
"Access-Control-Allow-Origin": "*",
|
|
1127
|
+
"Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS",
|
|
1128
|
+
"Access-Control-Allow-Headers": "Content-Type"
|
|
1129
|
+
};
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
// src/cli/commands/menubar.ts
|
|
1133
|
+
var exports_menubar = {};
|
|
1134
|
+
__export(exports_menubar, {
|
|
1135
|
+
menubarUninstall: () => menubarUninstall,
|
|
1136
|
+
menubarStop: () => menubarStop,
|
|
1137
|
+
menubarStart: () => menubarStart,
|
|
1138
|
+
menubarInstall: () => menubarInstall
|
|
1139
|
+
});
|
|
1140
|
+
import chalk2 from "chalk";
|
|
1141
|
+
import { execSync } from "child_process";
|
|
1142
|
+
import { existsSync as existsSync6, writeFileSync as writeFileSync2 } from "fs";
|
|
1143
|
+
import { tmpdir, arch } from "os";
|
|
1144
|
+
import { join as join6 } from "path";
|
|
1145
|
+
function getArch() {
|
|
1146
|
+
return arch() === "arm64" ? "arm64" : "x86_64";
|
|
1147
|
+
}
|
|
1148
|
+
function isInstalled() {
|
|
1149
|
+
return existsSync6(APP_PATH);
|
|
1150
|
+
}
|
|
1151
|
+
function isRunning() {
|
|
1152
|
+
try {
|
|
1153
|
+
const result = execSync(`pgrep -x EconomyBar`, { stdio: "pipe" }).toString().trim();
|
|
1154
|
+
return result.length > 0;
|
|
1155
|
+
} catch {
|
|
1156
|
+
return false;
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
async function menubarInstall(opts) {
|
|
1160
|
+
if (isInstalled() && !opts.force) {
|
|
1161
|
+
console.log(chalk2.yellow("Economy Bar is already installed. Use --force to reinstall."));
|
|
1162
|
+
console.log(chalk2.dim(` Location: ${APP_PATH}`));
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
const cpuArch = getArch();
|
|
1166
|
+
console.log(chalk2.cyan(`\u2192 Detecting architecture: ${cpuArch}`));
|
|
1167
|
+
console.log(chalk2.cyan("\u2192 Fetching latest release info..."));
|
|
1168
|
+
let assetUrl;
|
|
1169
|
+
try {
|
|
1170
|
+
const res = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, {
|
|
1171
|
+
headers: { Accept: "application/vnd.github+json", "User-Agent": "economy-cli" },
|
|
1172
|
+
signal: AbortSignal.timeout(1e4)
|
|
1173
|
+
});
|
|
1174
|
+
if (!res.ok)
|
|
1175
|
+
throw new Error(`GitHub API error: ${res.status}`);
|
|
1176
|
+
const release = await res.json();
|
|
1177
|
+
const assetName = `economy-bar-${cpuArch}.zip`;
|
|
1178
|
+
const asset = release.assets.find((a) => a.name === assetName);
|
|
1179
|
+
if (!asset)
|
|
1180
|
+
throw new Error(`No asset found for ${assetName}. Check releases at https://github.com/${REPO}/releases`);
|
|
1181
|
+
assetUrl = asset.browser_download_url;
|
|
1182
|
+
} catch (e) {
|
|
1183
|
+
console.error(chalk2.red(`\u2717 Failed to fetch release info: ${e instanceof Error ? e.message : String(e)}`));
|
|
1184
|
+
process.exit(1);
|
|
1185
|
+
}
|
|
1186
|
+
const zipPath = join6(tmpdir(), `economy-bar-${cpuArch}.zip`);
|
|
1187
|
+
const extractDir = join6(tmpdir(), "economy-bar-extracted");
|
|
1188
|
+
console.log(chalk2.cyan(`\u2192 Downloading ${assetUrl}...`));
|
|
1189
|
+
try {
|
|
1190
|
+
const res = await fetch(assetUrl, { signal: AbortSignal.timeout(60000) });
|
|
1191
|
+
if (!res.ok)
|
|
1192
|
+
throw new Error(`Download failed: ${res.status}`);
|
|
1193
|
+
const buffer = await res.arrayBuffer();
|
|
1194
|
+
writeFileSync2(zipPath, Buffer.from(buffer));
|
|
1195
|
+
console.log(chalk2.green(`\u2713 Downloaded (${(buffer.byteLength / 1024 / 1024).toFixed(1)} MB)`));
|
|
1196
|
+
} catch (e) {
|
|
1197
|
+
console.error(chalk2.red(`\u2717 Download failed: ${e instanceof Error ? e.message : String(e)}`));
|
|
1198
|
+
process.exit(1);
|
|
1199
|
+
}
|
|
1200
|
+
console.log(chalk2.cyan("\u2192 Installing to /Applications..."));
|
|
1201
|
+
try {
|
|
1202
|
+
execSync(`rm -rf "${extractDir}" && mkdir -p "${extractDir}"`, { stdio: "ignore" });
|
|
1203
|
+
execSync(`unzip -q "${zipPath}" -d "${extractDir}"`, { stdio: "ignore" });
|
|
1204
|
+
if (isInstalled())
|
|
1205
|
+
execSync(`rm -rf "${APP_PATH}"`, { stdio: "ignore" });
|
|
1206
|
+
execSync(`cp -R "${extractDir}/Economy Bar.app" /Applications/`, { stdio: "ignore" });
|
|
1207
|
+
execSync(`xattr -rd com.apple.quarantine "${APP_PATH}" 2>/dev/null || true`, { stdio: "ignore" });
|
|
1208
|
+
execSync(`rm -rf "${zipPath}" "${extractDir}"`, { stdio: "ignore" });
|
|
1209
|
+
console.log(chalk2.green(`\u2713 Installed to ${APP_PATH}`));
|
|
1210
|
+
} catch (e) {
|
|
1211
|
+
console.error(chalk2.red(`\u2717 Install failed: ${e instanceof Error ? e.message : String(e)}`));
|
|
1212
|
+
process.exit(1);
|
|
1213
|
+
}
|
|
1214
|
+
console.log(chalk2.cyan("\u2192 Launching Economy Bar..."));
|
|
1215
|
+
try {
|
|
1216
|
+
execSync(`open "${APP_PATH}"`, { stdio: "ignore" });
|
|
1217
|
+
console.log(chalk2.bold.green(`
|
|
1218
|
+
\u2713 Economy Bar is running in your menu bar!`));
|
|
1219
|
+
console.log(chalk2.dim(" Make sure economy serve is running: economy serve"));
|
|
1220
|
+
} catch (e) {
|
|
1221
|
+
console.log(chalk2.yellow("\u26A0 Installed but could not auto-launch. Open from /Applications manually."));
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
function menubarUninstall() {
|
|
1225
|
+
if (!isInstalled()) {
|
|
1226
|
+
console.log(chalk2.yellow("Economy Bar is not installed."));
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
if (isRunning()) {
|
|
1230
|
+
try {
|
|
1231
|
+
execSync(`osascript -e 'quit app "Economy Bar"'`, { stdio: "ignore" });
|
|
1232
|
+
execSync(`sleep 1`, { stdio: "ignore" });
|
|
1233
|
+
} catch {}
|
|
1234
|
+
}
|
|
1235
|
+
execSync(`rm -rf "${APP_PATH}"`, { stdio: "ignore" });
|
|
1236
|
+
console.log(chalk2.green("\u2713 Economy Bar uninstalled"));
|
|
1237
|
+
}
|
|
1238
|
+
function menubarStart() {
|
|
1239
|
+
if (!isInstalled()) {
|
|
1240
|
+
console.error(chalk2.red("Economy Bar is not installed. Run: economy menubar install"));
|
|
1241
|
+
process.exit(1);
|
|
1242
|
+
}
|
|
1243
|
+
execSync(`open "${APP_PATH}"`, { stdio: "ignore" });
|
|
1244
|
+
console.log(chalk2.green("\u2713 Economy Bar launched"));
|
|
1245
|
+
}
|
|
1246
|
+
function menubarStop() {
|
|
1247
|
+
if (!isRunning()) {
|
|
1248
|
+
console.log(chalk2.yellow("Economy Bar is not running."));
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
try {
|
|
1252
|
+
execSync(`osascript -e 'quit app "Economy Bar"'`, { stdio: "ignore" });
|
|
1253
|
+
console.log(chalk2.green("\u2713 Economy Bar stopped"));
|
|
1254
|
+
} catch {
|
|
1255
|
+
console.log(chalk2.yellow("Could not quit Economy Bar gracefully"));
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
var APP_PATH = "/Applications/Economy Bar.app", REPO = "hasna/open-economy";
|
|
1259
|
+
var init_menubar = () => {};
|
|
1260
|
+
|
|
1261
|
+
// src/cli/index.ts
|
|
1262
|
+
init_database();
|
|
1263
|
+
init_claude();
|
|
1264
|
+
init_codex();
|
|
1265
|
+
import { Command } from "commander";
|
|
1266
|
+
import chalk3 from "chalk";
|
|
1267
|
+
|
|
1268
|
+
// src/ingest/gemini.ts
|
|
1269
|
+
init_database();
|
|
1270
|
+
import { readdirSync as readdirSync2, readFileSync as readFileSync3, existsSync as existsSync4, statSync as statSync2 } from "fs";
|
|
1271
|
+
import { homedir as homedir4 } from "os";
|
|
1272
|
+
import { join as join4 } from "path";
|
|
1273
|
+
var GEMINI_TMP_DIR = join4(homedir4(), ".gemini", "tmp");
|
|
1274
|
+
async function ingestGemini(db, verbose) {
|
|
1275
|
+
if (!existsSync4(GEMINI_TMP_DIR)) {
|
|
1276
|
+
if (verbose)
|
|
1277
|
+
console.log("Gemini tmp dir not found:", GEMINI_TMP_DIR);
|
|
1278
|
+
return { sessions: 0 };
|
|
1279
|
+
}
|
|
1280
|
+
let totalSessions = 0;
|
|
1281
|
+
const touchedSessions = new Set;
|
|
1282
|
+
let projectHashDirs = [];
|
|
1283
|
+
try {
|
|
1284
|
+
projectHashDirs = readdirSync2(GEMINI_TMP_DIR, { withFileTypes: true }).filter((d) => d.isDirectory() && /^[0-9a-f]{64}$/.test(d.name)).map((d) => join4(GEMINI_TMP_DIR, d.name));
|
|
1285
|
+
} catch {
|
|
1286
|
+
return { sessions: 0 };
|
|
1287
|
+
}
|
|
1288
|
+
for (const projectDir of projectHashDirs) {
|
|
1289
|
+
const chatsDir = join4(projectDir, "chats");
|
|
1290
|
+
if (!existsSync4(chatsDir))
|
|
1291
|
+
continue;
|
|
1292
|
+
let chatFiles = [];
|
|
1293
|
+
try {
|
|
1294
|
+
chatFiles = readdirSync2(chatsDir).filter((f) => f.endsWith(".json")).map((f) => join4(chatsDir, f));
|
|
1295
|
+
} catch {
|
|
1296
|
+
continue;
|
|
1297
|
+
}
|
|
1298
|
+
for (const filePath of chatFiles) {
|
|
1299
|
+
const stateKey = filePath.replace(homedir4(), "~");
|
|
1300
|
+
let fileMtime = "0";
|
|
1301
|
+
try {
|
|
1302
|
+
fileMtime = statSync2(filePath).mtimeMs.toString();
|
|
1303
|
+
} catch {
|
|
1304
|
+
continue;
|
|
1305
|
+
}
|
|
1306
|
+
const processed = getIngestState(db, "gemini", stateKey);
|
|
1307
|
+
if (processed === fileMtime)
|
|
1308
|
+
continue;
|
|
1309
|
+
let chatData;
|
|
1310
|
+
try {
|
|
1311
|
+
chatData = JSON.parse(readFileSync3(filePath, "utf-8"));
|
|
1312
|
+
} catch {
|
|
1313
|
+
continue;
|
|
1314
|
+
}
|
|
1315
|
+
const sessionId = chatData.sessionId;
|
|
1316
|
+
if (!sessionId)
|
|
1317
|
+
continue;
|
|
1318
|
+
const startTime = chatData.startTime ?? new Date().toISOString();
|
|
1319
|
+
const existing = db.prepare(`SELECT id FROM sessions WHERE id = ?`).get(sessionId);
|
|
1320
|
+
if (!existing) {
|
|
1321
|
+
const session = {
|
|
1322
|
+
id: sessionId,
|
|
1323
|
+
agent: "gemini",
|
|
1324
|
+
project_path: "",
|
|
1325
|
+
project_name: "",
|
|
1326
|
+
started_at: startTime,
|
|
1327
|
+
ended_at: chatData.lastUpdated ?? null,
|
|
1328
|
+
total_cost_usd: 0,
|
|
1329
|
+
total_tokens: 0,
|
|
1330
|
+
request_count: 0
|
|
1331
|
+
};
|
|
1332
|
+
upsertSession(db, session);
|
|
1333
|
+
touchedSessions.add(sessionId);
|
|
1334
|
+
totalSessions++;
|
|
1335
|
+
}
|
|
1336
|
+
setIngestState(db, "gemini", stateKey, fileMtime);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
for (const sessionId of touchedSessions) {
|
|
1340
|
+
rollupSession(db, sessionId);
|
|
1341
|
+
}
|
|
1342
|
+
return { sessions: totalSessions };
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
// src/cli/index.ts
|
|
1346
|
+
init_pricing();
|
|
1347
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
1348
|
+
import { execSync as execSync2 } from "child_process";
|
|
1349
|
+
var program = new Command;
|
|
1350
|
+
program.name("economy").description("AI coding cost tracker \u2014 Claude Code, Codex, and Gemini").version("0.2.2");
|
|
1351
|
+
async function autoSync() {
|
|
1352
|
+
const db = openDatabase();
|
|
1353
|
+
ensurePricingSeeded(db);
|
|
1354
|
+
await ingestClaude(db);
|
|
1355
|
+
await ingestCodex(db);
|
|
1356
|
+
await ingestGemini(db);
|
|
1357
|
+
}
|
|
1358
|
+
function sparkline(values) {
|
|
1359
|
+
const chars = "\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588";
|
|
1360
|
+
if (values.length === 0)
|
|
1361
|
+
return "";
|
|
1362
|
+
const max = Math.max(...values);
|
|
1363
|
+
if (max === 0)
|
|
1364
|
+
return chars[0].repeat(values.length);
|
|
1365
|
+
return values.map((v) => chars[Math.min(Math.round(v / max * 7), 7)]).join("");
|
|
1366
|
+
}
|
|
1367
|
+
function fmt2(usd) {
|
|
1368
|
+
let formatted;
|
|
1369
|
+
if (usd >= 0.01) {
|
|
1370
|
+
formatted = "$" + usd.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
1371
|
+
} else if (usd >= 0.0001) {
|
|
1372
|
+
const cents = usd * 100;
|
|
1373
|
+
formatted = cents.toFixed(2).replace(/\.?0+$/, "") + "\xA2";
|
|
1374
|
+
} else if (usd > 0) {
|
|
1375
|
+
formatted = "<0.01\xA2";
|
|
1376
|
+
} else {
|
|
1377
|
+
formatted = "$0.00";
|
|
1378
|
+
}
|
|
1379
|
+
return chalk3.green(formatted);
|
|
1380
|
+
}
|
|
1381
|
+
function fmtTokens(n) {
|
|
1382
|
+
if (n >= 1e9)
|
|
1383
|
+
return `${(n / 1e9).toFixed(1)}B`;
|
|
1384
|
+
if (n >= 1e6)
|
|
1385
|
+
return `${(n / 1e6).toFixed(1)}M`;
|
|
1386
|
+
if (n >= 1000)
|
|
1387
|
+
return `${(n / 1000).toFixed(1)}k`;
|
|
1388
|
+
return n.toLocaleString("en-US");
|
|
1389
|
+
}
|
|
1390
|
+
function fmtCount(n) {
|
|
1391
|
+
return n.toLocaleString("en-US");
|
|
1392
|
+
}
|
|
1393
|
+
function printTable(headers, rows) {
|
|
1394
|
+
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").replace(/\x1b\[[0-9;]*m/g, "").length)));
|
|
1395
|
+
const sep = widths.map((w) => "\u2500".repeat(w + 2)).join("\u253C");
|
|
1396
|
+
const header = headers.map((h, i) => ` ${h.padEnd(widths[i] ?? 0)} `).join("\u2502");
|
|
1397
|
+
console.log(`\u250C${sep.replace(/\u253C/g, "\u252C")}\u2510`);
|
|
1398
|
+
console.log(`\u2502${header}\u2502`);
|
|
1399
|
+
console.log(`\u251C${sep}\u2524`);
|
|
1400
|
+
for (const row of rows) {
|
|
1401
|
+
const line = row.map((cell, i) => {
|
|
1402
|
+
const plain = cell.replace(/\x1b\[[0-9;]*m/g, "");
|
|
1403
|
+
return ` ${cell}${" ".repeat(Math.max(0, (widths[i] ?? 0) - plain.length))} `;
|
|
1404
|
+
}).join("\u2502");
|
|
1405
|
+
console.log(`\u2502${line}\u2502`);
|
|
1406
|
+
}
|
|
1407
|
+
console.log(`\u2514${sep.replace(/\u253C/g, "\u2534")}\u2518`);
|
|
1408
|
+
}
|
|
1409
|
+
function parseSinceDate(since) {
|
|
1410
|
+
const relMatch = since.match(/^(\d+)d$/);
|
|
1411
|
+
if (relMatch) {
|
|
1412
|
+
const days = parseInt(relMatch[1], 10);
|
|
1413
|
+
const d = new Date;
|
|
1414
|
+
d.setDate(d.getDate() - days);
|
|
1415
|
+
return d.toISOString().substring(0, 10);
|
|
1416
|
+
}
|
|
1417
|
+
return since;
|
|
1418
|
+
}
|
|
1419
|
+
function printSummary(label, period) {
|
|
1420
|
+
const db = openDatabase();
|
|
1421
|
+
ensurePricingSeeded(db);
|
|
1422
|
+
const s = querySummary(db, period);
|
|
1423
|
+
console.log();
|
|
1424
|
+
console.log(chalk3.bold.cyan(` ${label}`));
|
|
1425
|
+
console.log();
|
|
1426
|
+
printTable(["Metric", "Value"], [
|
|
1427
|
+
["Total cost", fmt2(s.total_usd)],
|
|
1428
|
+
["Sessions", chalk3.yellow(fmtCount(s.sessions))],
|
|
1429
|
+
["Requests", chalk3.yellow(fmtCount(s.requests))],
|
|
1430
|
+
["Tokens", chalk3.yellow(fmtTokens(s.tokens))]
|
|
1431
|
+
]);
|
|
1432
|
+
console.log();
|
|
1433
|
+
}
|
|
1434
|
+
program.action(async () => {
|
|
1435
|
+
await autoSync();
|
|
1436
|
+
const db = openDatabase();
|
|
1437
|
+
const t = querySummary(db, "today");
|
|
1438
|
+
const w = querySummary(db, "week");
|
|
1439
|
+
const m = querySummary(db, "month");
|
|
1440
|
+
const projects = queryProjectBreakdown(db).slice(0, 3);
|
|
1441
|
+
const daily = queryDailyBreakdown(db, 14).reduce((acc, d) => {
|
|
1442
|
+
acc[d.date] = (acc[d.date] ?? 0) + d.cost_usd;
|
|
1443
|
+
return acc;
|
|
1444
|
+
}, {});
|
|
1445
|
+
const dailyValues = Object.values(daily);
|
|
1446
|
+
console.log();
|
|
1447
|
+
console.log(chalk3.bold.cyan(" Economy"));
|
|
1448
|
+
console.log();
|
|
1449
|
+
printTable(["Period", "Cost", "Sessions", "Requests", "Tokens"], [
|
|
1450
|
+
["Today", fmt2(t.total_usd), fmtCount(t.sessions), fmtCount(t.requests), fmtTokens(t.tokens)],
|
|
1451
|
+
["This Week", fmt2(w.total_usd), fmtCount(w.sessions), fmtCount(w.requests), fmtTokens(w.tokens)],
|
|
1452
|
+
["This Month", fmt2(m.total_usd), fmtCount(m.sessions), fmtCount(m.requests), fmtTokens(m.tokens)]
|
|
1453
|
+
]);
|
|
1454
|
+
if (dailyValues.length > 0) {
|
|
1455
|
+
console.log(`
|
|
1456
|
+
${chalk3.dim("14-day trend:")} ${sparkline(dailyValues)}`);
|
|
1457
|
+
}
|
|
1458
|
+
if (projects.length > 0) {
|
|
1459
|
+
console.log(`
|
|
1460
|
+
${chalk3.dim("Top projects:")}`);
|
|
1461
|
+
for (const p of projects) {
|
|
1462
|
+
console.log(` ${chalk3.white(p.project_name.padEnd(25))} ${fmt2(p.cost_usd)}`);
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
console.log();
|
|
1466
|
+
});
|
|
1467
|
+
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) => {
|
|
1468
|
+
const db = openDatabase();
|
|
1469
|
+
ensurePricingSeeded(db);
|
|
1470
|
+
if (opts.force) {
|
|
1471
|
+
db.exec(`DELETE FROM ingest_state WHERE source = 'claude'`);
|
|
1472
|
+
if (opts.verbose)
|
|
1473
|
+
console.log(chalk3.dim("Cleared ingest cache"));
|
|
1474
|
+
}
|
|
1475
|
+
const anySpecific = opts.claude || opts.codex || opts.gemini;
|
|
1476
|
+
const doClaude = opts.claude || !anySpecific;
|
|
1477
|
+
const doCodex = opts.codex || !anySpecific;
|
|
1478
|
+
const doGemini = opts.gemini || !anySpecific;
|
|
1479
|
+
if (doClaude) {
|
|
1480
|
+
process.stdout.write(chalk3.cyan("\u2192 Ingesting Claude Code telemetry... "));
|
|
1481
|
+
const r = await ingestClaude(db, opts.verbose);
|
|
1482
|
+
console.log(chalk3.green(`\u2713 ${r.files} files, ${r.requests} requests, ${r.sessions} sessions`));
|
|
1483
|
+
}
|
|
1484
|
+
if (doCodex) {
|
|
1485
|
+
process.stdout.write(chalk3.cyan("\u2192 Ingesting Codex sessions... "));
|
|
1486
|
+
const r = await ingestCodex(db, opts.verbose);
|
|
1487
|
+
console.log(chalk3.green(`\u2713 ${r.sessions} sessions`));
|
|
1488
|
+
}
|
|
1489
|
+
if (doGemini) {
|
|
1490
|
+
process.stdout.write(chalk3.cyan("\u2192 Ingesting Gemini CLI sessions... "));
|
|
1491
|
+
const r = await ingestGemini(db, opts.verbose);
|
|
1492
|
+
console.log(chalk3.green(`\u2713 ${r.sessions} sessions`));
|
|
1493
|
+
}
|
|
1494
|
+
try {
|
|
1495
|
+
const { checkAndFireWebhooks: checkAndFireWebhooks2 } = await Promise.resolve().then(() => (init_webhooks(), exports_webhooks));
|
|
1496
|
+
await checkAndFireWebhooks2(db);
|
|
1497
|
+
} catch {}
|
|
1498
|
+
console.log(chalk3.bold.green(`
|
|
1499
|
+
\u2713 Sync complete`));
|
|
1500
|
+
});
|
|
1501
|
+
program.command("today").description("Cost summary for today").action(async () => {
|
|
1502
|
+
await autoSync();
|
|
1503
|
+
printSummary("Today", "today");
|
|
1504
|
+
});
|
|
1505
|
+
program.command("week").description("Cost summary for this week").action(async () => {
|
|
1506
|
+
await autoSync();
|
|
1507
|
+
printSummary("This Week", "week");
|
|
1508
|
+
});
|
|
1509
|
+
program.command("month").description("Cost summary for this month").action(async () => {
|
|
1510
|
+
await autoSync();
|
|
1511
|
+
printSummary("This Month", "month");
|
|
1512
|
+
});
|
|
1513
|
+
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) => {
|
|
1514
|
+
await autoSync();
|
|
1515
|
+
const db = openDatabase();
|
|
1516
|
+
const sinceDate = opts.since ? parseSinceDate(opts.since) : undefined;
|
|
1517
|
+
let sessions = querySessions(db, {
|
|
1518
|
+
agent: opts.agent,
|
|
1519
|
+
project: opts.project,
|
|
1520
|
+
limit: Number(opts.limit ?? 20),
|
|
1521
|
+
since: sinceDate,
|
|
1522
|
+
search: opts.search
|
|
1523
|
+
});
|
|
1524
|
+
if (sessions.length === 0) {
|
|
1525
|
+
console.log(chalk3.yellow("No sessions found."));
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
const f = opts.format ?? "table";
|
|
1529
|
+
if (f === "compact") {
|
|
1530
|
+
for (const s of sessions)
|
|
1531
|
+
process.stdout.write(`${s.id.slice(0, 8)} ${s.agent} ${fmt2(s.total_cost_usd)} ${fmtTokens(s.total_tokens)} ${s.project_name || "\u2014"}
|
|
1532
|
+
`);
|
|
1533
|
+
return;
|
|
1534
|
+
}
|
|
1535
|
+
if (f === "json") {
|
|
1536
|
+
console.log(JSON.stringify(sessions, null, 2));
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
if (f === "csv") {
|
|
1540
|
+
console.log("id,agent,project_name,total_cost_usd,total_tokens,request_count,started_at");
|
|
1541
|
+
for (const s of sessions)
|
|
1542
|
+
console.log(`${s.id},${s.agent},"${s.project_name}",${s.total_cost_usd},${s.total_tokens},${s.request_count},${s.started_at}`);
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
console.log();
|
|
1546
|
+
printTable(["Session ID", "Agent", "Project", "Cost", "Tokens", "Requests", "Started"], sessions.map((s) => [
|
|
1547
|
+
chalk3.dim(s.id.substring(0, 12)),
|
|
1548
|
+
s.agent === "claude" ? chalk3.blue("claude") : chalk3.yellow("codex"),
|
|
1549
|
+
chalk3.white(s.project_name || chalk3.dim("unknown")),
|
|
1550
|
+
fmt2(s.total_cost_usd),
|
|
1551
|
+
chalk3.cyan(fmtTokens(s.total_tokens)),
|
|
1552
|
+
fmtCount(s.request_count),
|
|
1553
|
+
chalk3.dim(s.started_at.substring(0, 16))
|
|
1554
|
+
]));
|
|
1555
|
+
console.log();
|
|
1556
|
+
});
|
|
1557
|
+
program.command("top").description("Most expensive sessions").option("-n <n>", "Number of sessions", "10").option("--agent <agent>", "Filter by agent").option("--since <date>", "Filter sessions since date or relative (e.g. 2026-03-01, 7d, 30d)").action((opts) => {
|
|
1558
|
+
const db = openDatabase();
|
|
1559
|
+
const sinceDate = opts.since ? parseSinceDate(opts.since) : undefined;
|
|
1560
|
+
let sessions = queryTopSessions(db, Number(opts.n ?? 10), opts.agent);
|
|
1561
|
+
if (sinceDate)
|
|
1562
|
+
sessions = sessions.filter((s) => s.started_at >= sinceDate);
|
|
1563
|
+
if (sessions.length === 0) {
|
|
1564
|
+
console.log(chalk3.yellow("No sessions found. Run `economy sync` first."));
|
|
1565
|
+
return;
|
|
1566
|
+
}
|
|
1567
|
+
console.log();
|
|
1568
|
+
printTable(["#", "Project", "Agent", "Cost", "Tokens", "Started"], sessions.map((s, i) => [
|
|
1569
|
+
chalk3.dim(String(i + 1)),
|
|
1570
|
+
chalk3.white(s.project_name || chalk3.dim("unknown")),
|
|
1571
|
+
s.agent === "claude" ? chalk3.blue("claude") : chalk3.yellow("codex"),
|
|
1572
|
+
fmt2(s.total_cost_usd),
|
|
1573
|
+
chalk3.cyan(fmtTokens(s.total_tokens)),
|
|
1574
|
+
chalk3.dim(s.started_at.substring(0, 16))
|
|
1575
|
+
]));
|
|
1576
|
+
console.log();
|
|
1577
|
+
});
|
|
1578
|
+
program.command("breakdown").description("Cost breakdown by model, agent, or project").option("--by <dimension>", "Dimension: model|agent|project", "model").option("--since <date>", "Filter since date or relative (e.g. 2026-03-01, 7d, 30d)").action((opts) => {
|
|
1579
|
+
const db = openDatabase();
|
|
1580
|
+
const sinceDate = opts.since ? parseSinceDate(opts.since) : undefined;
|
|
1581
|
+
console.log();
|
|
1582
|
+
if (opts.by === "project") {
|
|
1583
|
+
const rows = sinceDate ? db.prepare(`
|
|
1584
|
+
SELECT project_path, project_name,
|
|
1585
|
+
COUNT(*) as sessions,
|
|
1586
|
+
COALESCE(SUM(total_tokens), 0) as total_tokens,
|
|
1587
|
+
COALESCE(SUM(request_count), 0) as requests,
|
|
1588
|
+
COALESCE(SUM(total_cost_usd), 0) as cost_usd,
|
|
1589
|
+
MAX(started_at) as last_active
|
|
1590
|
+
FROM sessions WHERE started_at >= ?
|
|
1591
|
+
GROUP BY project_path ORDER BY cost_usd DESC
|
|
1592
|
+
`).all(sinceDate) : queryProjectBreakdown(db);
|
|
1593
|
+
printTable(["Project", "Sessions", "Requests", "Tokens", "Cost"], rows.map((r) => [
|
|
1594
|
+
chalk3.white(r.project_name || chalk3.dim("unknown")),
|
|
1595
|
+
String(r.sessions),
|
|
1596
|
+
String(r.requests),
|
|
1597
|
+
chalk3.cyan(fmtTokens(r.total_tokens)),
|
|
1598
|
+
fmt2(r.cost_usd)
|
|
1599
|
+
]));
|
|
1600
|
+
} else {
|
|
1601
|
+
const rows = sinceDate ? db.prepare(`
|
|
1602
|
+
SELECT model, agent,
|
|
1603
|
+
COUNT(*) as requests,
|
|
1604
|
+
COALESCE(SUM(input_tokens), 0) as input_tokens,
|
|
1605
|
+
COALESCE(SUM(output_tokens), 0) as output_tokens,
|
|
1606
|
+
COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_create_tokens), 0) as total_tokens,
|
|
1607
|
+
COALESCE(SUM(cost_usd), 0) as cost_usd
|
|
1608
|
+
FROM requests WHERE timestamp >= ?
|
|
1609
|
+
GROUP BY model, agent ORDER BY cost_usd DESC
|
|
1610
|
+
`).all(sinceDate) : queryModelBreakdown(db);
|
|
1611
|
+
printTable(["Model", "Agent", "Requests", "Tokens", "Cost"], rows.map((r) => [
|
|
1612
|
+
chalk3.white(r.model),
|
|
1613
|
+
r.agent === "claude" ? chalk3.blue("claude") : chalk3.yellow("codex"),
|
|
1614
|
+
String(r.requests),
|
|
1615
|
+
chalk3.cyan(fmtTokens(r.total_tokens)),
|
|
1616
|
+
fmt2(r.cost_usd)
|
|
1617
|
+
]));
|
|
1618
|
+
}
|
|
1619
|
+
console.log();
|
|
1620
|
+
});
|
|
1621
|
+
program.command("watch").description("Live stream of incoming costs").option("--interval <seconds>", "Poll interval in seconds", "10").option("--agent <agent>", "Filter by agent").option("--notify <amount>", "Fire macOS notification when cumulative cost crosses this USD threshold").action(async (opts) => {
|
|
1622
|
+
const { watchCosts: watchCosts2 } = await Promise.resolve().then(() => (init_watch(), exports_watch));
|
|
1623
|
+
await watchCosts2({ interval: Number(opts.interval ?? 10), agent: opts.agent, notify: opts.notify ? Number(opts.notify) : undefined });
|
|
1624
|
+
});
|
|
1625
|
+
var budgetCmd = program.command("budget").description("Manage spending budgets");
|
|
1626
|
+
budgetCmd.command("set").description("Set a budget").option("--project <path>", "Project path (omit for global)").option("--period <period>", "Period: daily|weekly|monthly", "monthly").option("--limit <usd>", "Budget limit in USD").option("--alert <percent>", "Alert threshold %", "80").option("--agent <agent>", "Limit to agent (claude|codex)").action((opts) => {
|
|
1627
|
+
if (!opts.limit) {
|
|
1628
|
+
console.error(chalk3.red("--limit is required"));
|
|
1629
|
+
process.exit(1);
|
|
1630
|
+
}
|
|
1631
|
+
const db = openDatabase();
|
|
1632
|
+
const now = new Date().toISOString();
|
|
1633
|
+
upsertBudget(db, {
|
|
1634
|
+
id: randomUUID2(),
|
|
1635
|
+
project_path: opts.project ?? null,
|
|
1636
|
+
agent: opts.agent ?? null,
|
|
1637
|
+
period: opts.period ?? "monthly",
|
|
1638
|
+
limit_usd: Number(opts.limit),
|
|
1639
|
+
alert_at_percent: Number(opts.alert ?? 80),
|
|
1640
|
+
created_at: now,
|
|
1641
|
+
updated_at: now
|
|
1642
|
+
});
|
|
1643
|
+
console.log(chalk3.green(`\u2713 Budget set: ${opts.project ?? "global"} \u2014 ${opts.period} $${opts.limit}`));
|
|
1644
|
+
});
|
|
1645
|
+
budgetCmd.command("list").description("List all budgets").action(() => {
|
|
1646
|
+
const db = openDatabase();
|
|
1647
|
+
const statuses = getBudgetStatuses(db);
|
|
1648
|
+
if (statuses.length === 0) {
|
|
1649
|
+
console.log(chalk3.yellow("No budgets set."));
|
|
1650
|
+
return;
|
|
1651
|
+
}
|
|
1652
|
+
console.log();
|
|
1653
|
+
printTable(["Scope", "Period", "Limit", "Spent", "Used%", "Status"], statuses.map((b) => {
|
|
1654
|
+
const pct = b.percent_used.toFixed(1);
|
|
1655
|
+
const status = b.is_over_limit ? chalk3.red("OVER") : b.is_over_alert ? chalk3.yellow("ALERT") : chalk3.green("OK");
|
|
1656
|
+
const pctColor = b.is_over_limit ? chalk3.red(pct + "%") : b.is_over_alert ? chalk3.yellow(pct + "%") : chalk3.green(pct + "%");
|
|
1657
|
+
return [
|
|
1658
|
+
chalk3.white(b.project_path ?? "global"),
|
|
1659
|
+
b.period,
|
|
1660
|
+
fmt2(b.limit_usd),
|
|
1661
|
+
fmt2(b.current_spend_usd),
|
|
1662
|
+
pctColor,
|
|
1663
|
+
status
|
|
1664
|
+
];
|
|
1665
|
+
}));
|
|
1666
|
+
console.log();
|
|
1667
|
+
});
|
|
1668
|
+
budgetCmd.command("remove <id>").description("Remove a budget by ID").action((id) => {
|
|
1669
|
+
const db = openDatabase();
|
|
1670
|
+
deleteBudget(db, id);
|
|
1671
|
+
console.log(chalk3.green(`\u2713 Budget removed`));
|
|
1672
|
+
});
|
|
1673
|
+
var projectCmd = program.command("project").description("Manage tracked projects");
|
|
1674
|
+
projectCmd.command("add <path>").description("Add a project").option("--name <name>", "Human-readable name").action((path, opts) => {
|
|
1675
|
+
const db = openDatabase();
|
|
1676
|
+
const { basename: basename3 } = __require("path");
|
|
1677
|
+
upsertProject(db, {
|
|
1678
|
+
id: randomUUID2(),
|
|
1679
|
+
path,
|
|
1680
|
+
name: opts.name ?? basename3(path),
|
|
1681
|
+
description: null,
|
|
1682
|
+
tags: [],
|
|
1683
|
+
created_at: new Date().toISOString()
|
|
1684
|
+
});
|
|
1685
|
+
console.log(chalk3.green(`\u2713 Project added: ${path}`));
|
|
1686
|
+
});
|
|
1687
|
+
projectCmd.command("list").description("List all projects with costs").action(() => {
|
|
1688
|
+
const db = openDatabase();
|
|
1689
|
+
const projects = queryProjectBreakdown(db);
|
|
1690
|
+
if (projects.length === 0) {
|
|
1691
|
+
console.log(chalk3.yellow("No projects tracked yet."));
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
1694
|
+
console.log();
|
|
1695
|
+
printTable(["Project", "Path", "Sessions", "Cost", "Last Active"], projects.map((p) => [
|
|
1696
|
+
chalk3.white(p.project_name || chalk3.dim("unknown")),
|
|
1697
|
+
chalk3.dim(p.project_path.substring(0, 40)),
|
|
1698
|
+
String(p.sessions),
|
|
1699
|
+
fmt2(p.cost_usd),
|
|
1700
|
+
chalk3.dim(p.last_active?.substring(0, 16) ?? "\u2014")
|
|
1701
|
+
]));
|
|
1702
|
+
console.log();
|
|
1703
|
+
});
|
|
1704
|
+
projectCmd.command("remove <path>").description("Remove a project (keeps historical data)").action((path) => {
|
|
1705
|
+
const db = openDatabase();
|
|
1706
|
+
deleteProject(db, path);
|
|
1707
|
+
console.log(chalk3.green(`\u2713 Project removed`));
|
|
1708
|
+
});
|
|
1709
|
+
projectCmd.command("rename <path> <name>").description("Rename a project").action((path, name) => {
|
|
1710
|
+
const db = openDatabase();
|
|
1711
|
+
const existing = getProject(db, path);
|
|
1712
|
+
if (!existing) {
|
|
1713
|
+
console.error(chalk3.red("Project not found"));
|
|
1714
|
+
process.exit(1);
|
|
1715
|
+
}
|
|
1716
|
+
upsertProject(db, { ...existing, name });
|
|
1717
|
+
console.log(chalk3.green(`\u2713 Renamed to: ${name}`));
|
|
1718
|
+
});
|
|
1719
|
+
projectCmd.command("show <nameOrPath>").description("Detailed project breakdown with sparkline").action(async (nameOrPath) => {
|
|
1720
|
+
await autoSync();
|
|
1721
|
+
const db = openDatabase();
|
|
1722
|
+
const sessions = db.prepare(`SELECT * FROM sessions WHERE project_name LIKE ? OR project_path LIKE ? ORDER BY started_at DESC`).all(`%${nameOrPath}%`, `%${nameOrPath}%`);
|
|
1723
|
+
if (sessions.length === 0) {
|
|
1724
|
+
console.log(chalk3.yellow(`No sessions found for: ${nameOrPath}`));
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
const projectName = sessions[0]["project_name"] || nameOrPath;
|
|
1728
|
+
const projectPath = sessions[0]["project_path"] || "";
|
|
1729
|
+
const totalCost = sessions.reduce((s, r) => s + r["total_cost_usd"], 0);
|
|
1730
|
+
const totalTokens = sessions.reduce((s, r) => s + r["total_tokens"], 0);
|
|
1731
|
+
const daily = db.prepare(`
|
|
1732
|
+
SELECT DATE(r.timestamp) as d, SUM(r.cost_usd) as cost
|
|
1733
|
+
FROM requests r JOIN sessions s ON r.session_id = s.id
|
|
1734
|
+
WHERE (s.project_name LIKE ? OR s.project_path LIKE ?)
|
|
1735
|
+
AND r.timestamp >= DATE('now', '-14 days')
|
|
1736
|
+
GROUP BY d ORDER BY d ASC
|
|
1737
|
+
`).all(`%${nameOrPath}%`, `%${nameOrPath}%`);
|
|
1738
|
+
const dailyValues = daily.map((d) => d.cost);
|
|
1739
|
+
const models = db.prepare(`
|
|
1740
|
+
SELECT r.model, COUNT(*) as reqs, SUM(r.cost_usd) as cost
|
|
1741
|
+
FROM requests r JOIN sessions s ON r.session_id = s.id
|
|
1742
|
+
WHERE s.project_name LIKE ? OR s.project_path LIKE ?
|
|
1743
|
+
GROUP BY r.model ORDER BY cost DESC LIMIT 5
|
|
1744
|
+
`).all(`%${nameOrPath}%`, `%${nameOrPath}%`);
|
|
1745
|
+
console.log();
|
|
1746
|
+
console.log(chalk3.bold.cyan(` ${projectName}`));
|
|
1747
|
+
console.log(chalk3.dim(` ${projectPath}`));
|
|
1748
|
+
console.log();
|
|
1749
|
+
printTable(["Metric", "Value"], [
|
|
1750
|
+
["Total cost", fmt2(totalCost)],
|
|
1751
|
+
["Sessions", fmtCount(sessions.length)],
|
|
1752
|
+
["Total tokens", fmtTokens(totalTokens)]
|
|
1753
|
+
]);
|
|
1754
|
+
if (dailyValues.length > 0) {
|
|
1755
|
+
console.log(`
|
|
1756
|
+
${chalk3.dim("14-day trend:")} ${sparkline(dailyValues)}`);
|
|
1757
|
+
}
|
|
1758
|
+
if (models.length > 0) {
|
|
1759
|
+
console.log(`
|
|
1760
|
+
${chalk3.dim("Model breakdown:")}`);
|
|
1761
|
+
for (const m of models) {
|
|
1762
|
+
console.log(` ${chalk3.white(m.model.padEnd(30))} ${fmt2(m.cost)} (${fmtCount(m.reqs)} reqs)`);
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
const topSessions = sessions.sort((a, b) => b["total_cost_usd"] - a["total_cost_usd"]).slice(0, 5);
|
|
1766
|
+
if (topSessions.length > 0) {
|
|
1767
|
+
console.log(`
|
|
1768
|
+
${chalk3.dim("Top sessions:")}`);
|
|
1769
|
+
for (const s of topSessions) {
|
|
1770
|
+
console.log(` ${chalk3.dim(s["id"].substring(0, 12))} ${fmt2(s["total_cost_usd"])} ${chalk3.dim(String(s["started_at"]).substring(0, 16))}`);
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
console.log();
|
|
1774
|
+
});
|
|
1775
|
+
var configCmd = program.command("config").description("Manage economy configuration");
|
|
1776
|
+
configCmd.command("set <key> <value>").description("Set a config value").action(async (_key, _value) => {
|
|
1777
|
+
const { setConfigValue: setConfigValue2 } = await Promise.resolve().then(() => (init_config(), exports_config));
|
|
1778
|
+
setConfigValue2(_key, _value);
|
|
1779
|
+
console.log(chalk3.green(`\u2713 ${_key} = ${_value}`));
|
|
1780
|
+
});
|
|
1781
|
+
configCmd.command("get <key>").description("Get a config value").action(async (key) => {
|
|
1782
|
+
const { getConfigValue: getConfigValue2 } = await Promise.resolve().then(() => (init_config(), exports_config));
|
|
1783
|
+
console.log(getConfigValue2(key) ?? chalk3.dim("(not set)"));
|
|
1784
|
+
});
|
|
1785
|
+
configCmd.command("webhook-test").description("Send a test payload to the configured webhook URL").action(async () => {
|
|
1786
|
+
const { loadConfig: loadConfig2 } = await Promise.resolve().then(() => (init_config(), exports_config));
|
|
1787
|
+
const config = loadConfig2();
|
|
1788
|
+
const url = config["webhook-url"];
|
|
1789
|
+
if (!url) {
|
|
1790
|
+
console.log(chalk3.yellow("No webhook-url configured. Run: economy config set webhook-url <url>"));
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1793
|
+
const payload = {
|
|
1794
|
+
event: "test",
|
|
1795
|
+
message: "Economy webhook test",
|
|
1796
|
+
timestamp: new Date().toISOString()
|
|
1797
|
+
};
|
|
1798
|
+
try {
|
|
1799
|
+
const res = await fetch(url, {
|
|
1800
|
+
method: "POST",
|
|
1801
|
+
headers: { "Content-Type": "application/json" },
|
|
1802
|
+
body: JSON.stringify(payload),
|
|
1803
|
+
signal: AbortSignal.timeout(5000)
|
|
1804
|
+
});
|
|
1805
|
+
const text = await res.text().catch(() => "");
|
|
1806
|
+
if (res.ok) {
|
|
1807
|
+
console.log(chalk3.green(`\u2713 Webhook responded: HTTP ${res.status}`));
|
|
1808
|
+
if (text)
|
|
1809
|
+
console.log(chalk3.dim(text.slice(0, 200)));
|
|
1810
|
+
} else {
|
|
1811
|
+
console.log(chalk3.red(`\u2717 Webhook failed: HTTP ${res.status}`));
|
|
1812
|
+
if (text)
|
|
1813
|
+
console.log(chalk3.dim(text.slice(0, 200)));
|
|
1814
|
+
}
|
|
1815
|
+
} catch (e) {
|
|
1816
|
+
console.log(chalk3.red(`\u2717 Request failed: ${e instanceof Error ? e.message : String(e)}`));
|
|
1817
|
+
}
|
|
1818
|
+
});
|
|
1819
|
+
configCmd.action(async () => {
|
|
1820
|
+
const { loadConfig: loadConfig2 } = await Promise.resolve().then(() => (init_config(), exports_config));
|
|
1821
|
+
const config = loadConfig2();
|
|
1822
|
+
console.log();
|
|
1823
|
+
printTable(["Key", "Value"], Object.entries(config).map(([k, v]) => [k, String(v)]));
|
|
1824
|
+
console.log();
|
|
1825
|
+
});
|
|
1826
|
+
var pricingCmd = program.command("pricing").description("Manage model pricing rates");
|
|
1827
|
+
pricingCmd.command("list").description("List all model prices").action(() => {
|
|
1828
|
+
const db = openDatabase();
|
|
1829
|
+
ensurePricingSeeded(db);
|
|
1830
|
+
const rows = listModelPricing(db);
|
|
1831
|
+
console.log();
|
|
1832
|
+
printTable(["Model", "Input/1M", "Output/1M", "CacheR/1M", "CacheW/1M", "Out/1k"], rows.map((r) => [
|
|
1833
|
+
chalk3.white(r.model),
|
|
1834
|
+
fmt2(r.input_per_1m),
|
|
1835
|
+
fmt2(r.output_per_1m),
|
|
1836
|
+
fmt2(r.cache_read_per_1m),
|
|
1837
|
+
fmt2(r.cache_write_per_1m),
|
|
1838
|
+
chalk3.dim(fmt2(r.output_per_1m / 1000))
|
|
1839
|
+
]));
|
|
1840
|
+
console.log();
|
|
1841
|
+
});
|
|
1842
|
+
pricingCmd.command("set <model>").description("Set pricing for a model").option("--input <usd>", "Input price per 1M tokens").option("--output <usd>", "Output price per 1M tokens").option("--cache-read <usd>", "Cache read price per 1M tokens", "0").option("--cache-write <usd>", "Cache write price per 1M tokens", "0").action((model, opts) => {
|
|
1843
|
+
if (!opts.input || !opts.output) {
|
|
1844
|
+
console.error(chalk3.red("--input and --output are required"));
|
|
1845
|
+
process.exit(1);
|
|
1846
|
+
}
|
|
1847
|
+
const db = openDatabase();
|
|
1848
|
+
ensurePricingSeeded(db);
|
|
1849
|
+
upsertModelPricing(db, {
|
|
1850
|
+
model,
|
|
1851
|
+
input_per_1m: Number(opts.input),
|
|
1852
|
+
output_per_1m: Number(opts.output),
|
|
1853
|
+
cache_read_per_1m: Number(opts.cacheRead ?? 0),
|
|
1854
|
+
cache_write_per_1m: Number(opts.cacheWrite ?? 0),
|
|
1855
|
+
updated_at: new Date().toISOString()
|
|
1856
|
+
});
|
|
1857
|
+
console.log(chalk3.green(`\u2713 Pricing updated for ${model}`));
|
|
1858
|
+
});
|
|
1859
|
+
pricingCmd.command("remove <model>").description("Remove pricing for a model").action((model) => {
|
|
1860
|
+
const db = openDatabase();
|
|
1861
|
+
deleteModelPricing(db, model);
|
|
1862
|
+
console.log(chalk3.green(`\u2713 Pricing removed for ${model}`));
|
|
1863
|
+
});
|
|
1864
|
+
program.command("serve").description("Start the REST API server").option("-p, --port <port>", "Port", "3456").action(async (opts) => {
|
|
1865
|
+
const port = Number(opts.port ?? 3456);
|
|
1866
|
+
const { startServer: startServer2 } = await Promise.resolve().then(() => (init_serve(), exports_serve));
|
|
1867
|
+
startServer2(port);
|
|
1868
|
+
});
|
|
1869
|
+
program.command("dashboard").description("Open the web dashboard (auto-starts server if not running)").option("-p, --port <port>", "Server port", "3456").action(async (opts) => {
|
|
1870
|
+
const port = Number(opts.port ?? 3456);
|
|
1871
|
+
const url = `http://localhost:${port}`;
|
|
1872
|
+
let serverRunning = false;
|
|
1873
|
+
try {
|
|
1874
|
+
const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(500) });
|
|
1875
|
+
serverRunning = res.ok;
|
|
1876
|
+
} catch {}
|
|
1877
|
+
if (!serverRunning) {
|
|
1878
|
+
console.log(chalk3.cyan(`\u2192 Starting economy server on port ${port}...`));
|
|
1879
|
+
const { spawn } = await import("child_process");
|
|
1880
|
+
const { resolve, dirname } = await import("path");
|
|
1881
|
+
const serveScript = resolve(dirname(process.argv[1]), "..", "server", "index.js");
|
|
1882
|
+
const child = spawn(process.execPath, [serveScript], {
|
|
1883
|
+
detached: true,
|
|
1884
|
+
stdio: "ignore",
|
|
1885
|
+
env: { ...process.env, ECONOMY_PORT: String(port) }
|
|
1886
|
+
});
|
|
1887
|
+
child.unref();
|
|
1888
|
+
let attempts = 0;
|
|
1889
|
+
while (attempts < 20) {
|
|
1890
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
1891
|
+
try {
|
|
1892
|
+
const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(300) });
|
|
1893
|
+
if (res.ok) {
|
|
1894
|
+
serverRunning = true;
|
|
1895
|
+
break;
|
|
1896
|
+
}
|
|
1897
|
+
} catch {}
|
|
1898
|
+
attempts++;
|
|
1899
|
+
}
|
|
1900
|
+
if (serverRunning) {
|
|
1901
|
+
console.log(chalk3.green(`\u2713 Server started`));
|
|
1902
|
+
} else {
|
|
1903
|
+
console.log(chalk3.yellow(`\u26A0 Server didn't respond \u2014 open ${url} manually after running \`economy serve\``));
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
console.log(chalk3.cyan(`Opening ${url}`));
|
|
1907
|
+
try {
|
|
1908
|
+
execSync2(`open ${url}`);
|
|
1909
|
+
} catch {
|
|
1910
|
+
console.log(chalk3.yellow(`Open your browser at ${url}`));
|
|
1911
|
+
}
|
|
1912
|
+
});
|
|
1913
|
+
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) => {
|
|
1914
|
+
const doAll = opts.all || !opts.claude && !opts.codex;
|
|
1915
|
+
if (opts.claude || doAll) {
|
|
1916
|
+
console.log(chalk3.bold.cyan(`
|
|
1917
|
+
Claude Code:`));
|
|
1918
|
+
console.log(chalk3.white(" claude mcp add --transport stdio --scope user economy -- economy-mcp"));
|
|
1919
|
+
}
|
|
1920
|
+
if (opts.codex || doAll) {
|
|
1921
|
+
console.log(chalk3.bold.yellow(`
|
|
1922
|
+
Codex (~/.codex/config.toml):`));
|
|
1923
|
+
console.log(chalk3.white(` [mcp_servers.economy]
|
|
1924
|
+
command = "economy-mcp"
|
|
1925
|
+
args = []`));
|
|
1926
|
+
}
|
|
1927
|
+
console.log();
|
|
1928
|
+
});
|
|
1929
|
+
program.command("session <id>").description("Show detailed breakdown of a single session").action(async (id) => {
|
|
1930
|
+
await autoSync();
|
|
1931
|
+
const db = openDatabase();
|
|
1932
|
+
const session = db.prepare(`SELECT * FROM sessions WHERE id = ? OR id LIKE ?`).get(id, `%${id}%`);
|
|
1933
|
+
if (!session) {
|
|
1934
|
+
console.log(chalk3.red(`Session not found: ${id}`));
|
|
1935
|
+
process.exit(1);
|
|
1936
|
+
}
|
|
1937
|
+
const requests = db.prepare(`SELECT * FROM requests WHERE session_id = ? ORDER BY timestamp ASC`).all(session["id"]);
|
|
1938
|
+
console.log();
|
|
1939
|
+
console.log(chalk3.bold.cyan(` Session: ${session["id"].substring(0, 16)}...`));
|
|
1940
|
+
console.log();
|
|
1941
|
+
printTable(["Field", "Value"], [
|
|
1942
|
+
["Agent", String(session["agent"])],
|
|
1943
|
+
["Project", String(session["project_name"] || session["project_path"] || "\u2014")],
|
|
1944
|
+
["Started", String(session["started_at"]).substring(0, 19)],
|
|
1945
|
+
["Ended", session["ended_at"] ? String(session["ended_at"]).substring(0, 19) : "\u2014"],
|
|
1946
|
+
["Total cost", fmt2(session["total_cost_usd"])],
|
|
1947
|
+
["Total tokens", fmtTokens(session["total_tokens"])],
|
|
1948
|
+
["Requests", fmtCount(session["request_count"])]
|
|
1949
|
+
]);
|
|
1950
|
+
if (requests.length > 0) {
|
|
1951
|
+
console.log(chalk3.dim(`
|
|
1952
|
+
Requests (${requests.length}):
|
|
1953
|
+
`));
|
|
1954
|
+
printTable(["Time", "Model", "Input", "Output", "Cache R", "Cache W", "Cost"], requests.slice(0, 50).map((r) => [
|
|
1955
|
+
chalk3.dim(String(r["timestamp"]).substring(11, 19)),
|
|
1956
|
+
chalk3.white(String(r["model"]).substring(0, 22)),
|
|
1957
|
+
fmtTokens(r["input_tokens"]),
|
|
1958
|
+
fmtTokens(r["output_tokens"]),
|
|
1959
|
+
fmtTokens(r["cache_read_tokens"]),
|
|
1960
|
+
fmtTokens(r["cache_create_tokens"]),
|
|
1961
|
+
fmt2(r["cost_usd"])
|
|
1962
|
+
]));
|
|
1963
|
+
if (requests.length > 50)
|
|
1964
|
+
console.log(chalk3.dim(` ... and ${requests.length - 50} more requests`));
|
|
1965
|
+
}
|
|
1966
|
+
console.log();
|
|
1967
|
+
});
|
|
1968
|
+
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) => {
|
|
1969
|
+
await autoSync();
|
|
1970
|
+
const db = openDatabase();
|
|
1971
|
+
let csv;
|
|
1972
|
+
if (opts.type === "requests") {
|
|
1973
|
+
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')`;
|
|
1974
|
+
const rows = db.prepare(`SELECT * FROM requests WHERE ${where} ORDER BY timestamp ASC`).all();
|
|
1975
|
+
csv = `id,agent,session_id,model,input_tokens,output_tokens,cache_read_tokens,cache_create_tokens,cost_usd,duration_ms,timestamp
|
|
1976
|
+
`;
|
|
1977
|
+
for (const r of rows) {
|
|
1978
|
+
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"]}
|
|
1979
|
+
`;
|
|
1980
|
+
}
|
|
1981
|
+
} else {
|
|
1982
|
+
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')`;
|
|
1983
|
+
const rows = db.prepare(`SELECT * FROM sessions WHERE ${where} ORDER BY started_at DESC`).all();
|
|
1984
|
+
csv = `id,agent,project_path,project_name,started_at,ended_at,total_cost_usd,total_tokens,request_count
|
|
1985
|
+
`;
|
|
1986
|
+
for (const r of rows) {
|
|
1987
|
+
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"]}
|
|
1988
|
+
`;
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
if (opts.output) {
|
|
1992
|
+
const { writeFileSync: writeFileSync3 } = await import("fs");
|
|
1993
|
+
writeFileSync3(opts.output, csv);
|
|
1994
|
+
console.log(chalk3.green(`\u2713 Exported to ${opts.output}`));
|
|
1995
|
+
} else {
|
|
1996
|
+
process.stdout.write(csv);
|
|
1997
|
+
}
|
|
1998
|
+
});
|
|
1999
|
+
program.command("compare <period1> <period2>").description("Compare two periods (today/yesterday/week/lastweek/month/lastmonth)").action(async (p1, p2) => {
|
|
2000
|
+
await autoSync();
|
|
2001
|
+
const db = openDatabase();
|
|
2002
|
+
function dateRange(period) {
|
|
2003
|
+
const now = new Date;
|
|
2004
|
+
const today = now.toISOString().substring(0, 10);
|
|
2005
|
+
switch (period) {
|
|
2006
|
+
case "today":
|
|
2007
|
+
return [today, today];
|
|
2008
|
+
case "yesterday": {
|
|
2009
|
+
const d = new Date(now);
|
|
2010
|
+
d.setDate(d.getDate() - 1);
|
|
2011
|
+
const s = d.toISOString().substring(0, 10);
|
|
2012
|
+
return [s, s];
|
|
2013
|
+
}
|
|
2014
|
+
case "week": {
|
|
2015
|
+
const d = new Date(now);
|
|
2016
|
+
d.setDate(d.getDate() - 7);
|
|
2017
|
+
return [d.toISOString().substring(0, 10), today];
|
|
2018
|
+
}
|
|
2019
|
+
case "lastweek": {
|
|
2020
|
+
const d1 = new Date(now);
|
|
2021
|
+
d1.setDate(d1.getDate() - 14);
|
|
2022
|
+
const d2 = new Date(now);
|
|
2023
|
+
d2.setDate(d2.getDate() - 7);
|
|
2024
|
+
return [d1.toISOString().substring(0, 10), d2.toISOString().substring(0, 10)];
|
|
2025
|
+
}
|
|
2026
|
+
case "month": {
|
|
2027
|
+
const d = new Date(now);
|
|
2028
|
+
d.setDate(d.getDate() - 30);
|
|
2029
|
+
return [d.toISOString().substring(0, 10), today];
|
|
2030
|
+
}
|
|
2031
|
+
case "lastmonth": {
|
|
2032
|
+
const d1 = new Date(now);
|
|
2033
|
+
d1.setDate(d1.getDate() - 60);
|
|
2034
|
+
const d2 = new Date(now);
|
|
2035
|
+
d2.setDate(d2.getDate() - 30);
|
|
2036
|
+
return [d1.toISOString().substring(0, 10), d2.toISOString().substring(0, 10)];
|
|
2037
|
+
}
|
|
2038
|
+
default:
|
|
2039
|
+
return [today, today];
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
function queryRange(from, to) {
|
|
2043
|
+
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);
|
|
2044
|
+
const s = db.prepare(`SELECT COUNT(*) as sessions FROM sessions WHERE DATE(started_at) BETWEEN ? AND ?`).get(from, to);
|
|
2045
|
+
return { ...r, sessions: s.sessions };
|
|
2046
|
+
}
|
|
2047
|
+
const [f1, t1] = dateRange(p1);
|
|
2048
|
+
const [f2, t2] = dateRange(p2);
|
|
2049
|
+
const a = queryRange(f1, t1);
|
|
2050
|
+
const b = queryRange(f2, t2);
|
|
2051
|
+
function delta(v1, v2) {
|
|
2052
|
+
const d = v1 - v2;
|
|
2053
|
+
const pct = v2 > 0 ? (d / v2 * 100).toFixed(1) : "\u2014";
|
|
2054
|
+
const sign = d >= 0 ? "+" : "";
|
|
2055
|
+
const color = d > 0 ? chalk3.red : d < 0 ? chalk3.green : chalk3.dim;
|
|
2056
|
+
return color(`${sign}${pct}%`);
|
|
2057
|
+
}
|
|
2058
|
+
console.log();
|
|
2059
|
+
console.log(chalk3.bold.cyan(` ${p1} vs ${p2}`));
|
|
2060
|
+
console.log();
|
|
2061
|
+
printTable(["Metric", p1, p2, "Change"], [
|
|
2062
|
+
["Cost", fmt2(a.cost), fmt2(b.cost), delta(a.cost, b.cost)],
|
|
2063
|
+
["Sessions", fmtCount(a.sessions), fmtCount(b.sessions), delta(a.sessions, b.sessions)],
|
|
2064
|
+
["Requests", fmtCount(a.requests), fmtCount(b.requests), delta(a.requests, b.requests)],
|
|
2065
|
+
["Tokens", fmtTokens(a.tokens), fmtTokens(b.tokens), delta(a.tokens, b.tokens)]
|
|
2066
|
+
]);
|
|
2067
|
+
console.log();
|
|
2068
|
+
});
|
|
2069
|
+
program.command("forecast").description("Project end-of-month cost based on current burn rate").action(async () => {
|
|
2070
|
+
await autoSync();
|
|
2071
|
+
const db = openDatabase();
|
|
2072
|
+
const now = new Date;
|
|
2073
|
+
const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
|
|
2074
|
+
const dayOfMonth = now.getDate();
|
|
2075
|
+
const monthStart = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
|
|
2076
|
+
const monthSoFar = db.prepare(`SELECT COALESCE(SUM(cost_usd),0) as cost FROM requests WHERE DATE(timestamp) >= ?`).get(monthStart);
|
|
2077
|
+
const dailyAvg = dayOfMonth > 0 ? monthSoFar.cost / dayOfMonth : 0;
|
|
2078
|
+
const projected = dailyAvg * daysInMonth;
|
|
2079
|
+
const sevenDaysAgo = new Date(now);
|
|
2080
|
+
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
|
2081
|
+
const last7 = db.prepare(`SELECT COALESCE(SUM(cost_usd),0) as cost FROM requests WHERE DATE(timestamp) >= ?`).get(sevenDaysAgo.toISOString().substring(0, 10));
|
|
2082
|
+
const last7DailyAvg = last7.cost / 7;
|
|
2083
|
+
const last7Projected = last7DailyAvg * daysInMonth;
|
|
2084
|
+
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);
|
|
2085
|
+
const cheapest = dailyCosts[0];
|
|
2086
|
+
const mostExpensive = dailyCosts[dailyCosts.length - 1];
|
|
2087
|
+
console.log();
|
|
2088
|
+
console.log(chalk3.bold.cyan(` Forecast (${dayOfMonth} of ${daysInMonth} days)`));
|
|
2089
|
+
console.log();
|
|
2090
|
+
printTable(["Metric", "Value"], [
|
|
2091
|
+
["Spent so far", fmt2(monthSoFar.cost)],
|
|
2092
|
+
["Daily average", fmt2(dailyAvg)],
|
|
2093
|
+
[chalk3.bold("Projected total"), chalk3.bold(fmt2(projected).replace(chalk3.green(""), ""))],
|
|
2094
|
+
["Last 7-day rate", `${fmt2(last7DailyAvg)}/day \u2192 ${fmt2(last7Projected)}`],
|
|
2095
|
+
["Cheapest day", cheapest ? `${fmt2(cheapest.cost)} (${cheapest.d})` : "\u2014"],
|
|
2096
|
+
["Most expensive", mostExpensive ? `${fmt2(mostExpensive.cost)} (${mostExpensive.d})` : "\u2014"]
|
|
2097
|
+
]);
|
|
2098
|
+
console.log();
|
|
2099
|
+
});
|
|
2100
|
+
program.command("efficiency").description("Show output/input token ratio per model").action(async () => {
|
|
2101
|
+
await autoSync();
|
|
2102
|
+
const db = openDatabase();
|
|
2103
|
+
const models = db.prepare(`
|
|
2104
|
+
SELECT model, SUM(input_tokens) as input, SUM(output_tokens) as output,
|
|
2105
|
+
SUM(cache_read_tokens) as cache_read, SUM(cache_create_tokens) as cache_write,
|
|
2106
|
+
COUNT(*) as requests, SUM(cost_usd) as cost
|
|
2107
|
+
FROM requests GROUP BY model ORDER BY cost DESC
|
|
2108
|
+
`).all();
|
|
2109
|
+
console.log();
|
|
2110
|
+
console.log(chalk3.bold.cyan(" Token Efficiency"));
|
|
2111
|
+
console.log();
|
|
2112
|
+
printTable(["Model", "Output/Input", "Cache Hit%", "Cost/1k Output", "Requests"], models.map((m) => {
|
|
2113
|
+
const ratio = m.input > 0 ? (m.output / m.input).toFixed(2) : "\u2014";
|
|
2114
|
+
const totalInput = m.input + m.cache_read + m.cache_write;
|
|
2115
|
+
const cacheHit = totalInput > 0 ? (m.cache_read / totalInput * 100).toFixed(1) + "%" : "\u2014";
|
|
2116
|
+
const costPer1kOutput = m.output > 0 ? fmt2(m.cost / m.output * 1000) : "\u2014";
|
|
2117
|
+
return [chalk3.white(m.model), ratio, cacheHit, costPer1kOutput, fmtCount(m.requests)];
|
|
2118
|
+
}));
|
|
2119
|
+
console.log();
|
|
2120
|
+
});
|
|
2121
|
+
var menubarCmd = program.command("menubar").description("Manage the Economy Bar macOS menubar app");
|
|
2122
|
+
menubarCmd.command("install").description("Download and install Economy Bar from GitHub Releases").option("--force", "Overwrite existing installation").action(async (opts) => {
|
|
2123
|
+
const { menubarInstall: menubarInstall2 } = await Promise.resolve().then(() => (init_menubar(), exports_menubar));
|
|
2124
|
+
await menubarInstall2(opts);
|
|
2125
|
+
});
|
|
2126
|
+
menubarCmd.command("uninstall").description("Quit and remove Economy Bar from /Applications").action(async () => {
|
|
2127
|
+
const { menubarUninstall: menubarUninstall2 } = await Promise.resolve().then(() => (init_menubar(), exports_menubar));
|
|
2128
|
+
menubarUninstall2();
|
|
2129
|
+
});
|
|
2130
|
+
menubarCmd.command("start").description("Launch Economy Bar").action(async () => {
|
|
2131
|
+
const { menubarStart: menubarStart2 } = await Promise.resolve().then(() => (init_menubar(), exports_menubar));
|
|
2132
|
+
menubarStart2();
|
|
2133
|
+
});
|
|
2134
|
+
menubarCmd.command("stop").description("Quit Economy Bar").action(async () => {
|
|
2135
|
+
const { menubarStop: menubarStop2 } = await Promise.resolve().then(() => (init_menubar(), exports_menubar));
|
|
2136
|
+
menubarStop2();
|
|
2137
|
+
});
|
|
2138
|
+
var goalCmd = program.command("goal").description("Manage spending goals");
|
|
2139
|
+
goalCmd.command("set").description("Set a spending goal").option("--period <period>", "Period: day|week|month|year", "month").option("--limit <usd>", "Goal limit in USD").option("--project <path>", "Scope to project path").option("--agent <agent>", "Scope to agent").action((opts) => {
|
|
2140
|
+
if (!opts.limit) {
|
|
2141
|
+
console.error(chalk3.red("--limit is required"));
|
|
2142
|
+
process.exit(1);
|
|
2143
|
+
}
|
|
2144
|
+
const db = openDatabase();
|
|
2145
|
+
const now = new Date().toISOString();
|
|
2146
|
+
upsertGoal(db, {
|
|
2147
|
+
id: randomUUID2(),
|
|
2148
|
+
period: opts.period ?? "month",
|
|
2149
|
+
project_path: opts.project ?? null,
|
|
2150
|
+
agent: opts.agent ?? null,
|
|
2151
|
+
limit_usd: Number(opts.limit),
|
|
2152
|
+
created_at: now,
|
|
2153
|
+
updated_at: now
|
|
2154
|
+
});
|
|
2155
|
+
console.log(chalk3.green(`\u2713 Goal set: ${opts.period ?? "month"} $${opts.limit}${opts.project ? ` (${opts.project})` : ""}`));
|
|
2156
|
+
});
|
|
2157
|
+
goalCmd.command("list").description("List all goals with progress").action(() => {
|
|
2158
|
+
const db = openDatabase();
|
|
2159
|
+
const statuses = getGoalStatuses(db);
|
|
2160
|
+
if (statuses.length === 0) {
|
|
2161
|
+
console.log(chalk3.yellow("No goals set."));
|
|
2162
|
+
return;
|
|
2163
|
+
}
|
|
2164
|
+
console.log();
|
|
2165
|
+
printTable(["Period", "Scope", "Limit", "Spent", "Used%", "Status"], statuses.map((g) => {
|
|
2166
|
+
const pct = g.percent_used.toFixed(1);
|
|
2167
|
+
const scope = g.project_path ?? g.agent ?? "global";
|
|
2168
|
+
const status = g.is_over ? chalk3.red("OVER") : g.is_at_risk ? chalk3.yellow("AT RISK") : chalk3.green("ON TRACK");
|
|
2169
|
+
const pctColor = g.is_over ? chalk3.red(pct + "%") : g.is_at_risk ? chalk3.yellow(pct + "%") : chalk3.green(pct + "%");
|
|
2170
|
+
return [
|
|
2171
|
+
g.period,
|
|
2172
|
+
chalk3.white(scope),
|
|
2173
|
+
fmt2(g.limit_usd),
|
|
2174
|
+
fmt2(g.current_spend_usd),
|
|
2175
|
+
pctColor,
|
|
2176
|
+
status
|
|
2177
|
+
];
|
|
2178
|
+
}));
|
|
2179
|
+
console.log();
|
|
2180
|
+
});
|
|
2181
|
+
goalCmd.command("remove <id>").description("Remove a goal").action((id) => {
|
|
2182
|
+
const db = openDatabase();
|
|
2183
|
+
deleteGoal(db, id);
|
|
2184
|
+
console.log(chalk3.green(`\u2713 Goal removed`));
|
|
2185
|
+
});
|
|
2186
|
+
goalCmd.command("status").description("Quick goal progress summary").action(() => {
|
|
2187
|
+
const db = openDatabase();
|
|
2188
|
+
const statuses = getGoalStatuses(db);
|
|
2189
|
+
if (statuses.length === 0) {
|
|
2190
|
+
console.log(chalk3.yellow("No goals set."));
|
|
2191
|
+
return;
|
|
2192
|
+
}
|
|
2193
|
+
console.log();
|
|
2194
|
+
for (const g of statuses) {
|
|
2195
|
+
const scope = g.project_path ?? g.agent ?? "global";
|
|
2196
|
+
const pct = Math.min(g.percent_used, 100);
|
|
2197
|
+
const barFilled = Math.round(pct / 10);
|
|
2198
|
+
const barEmpty = 10 - barFilled;
|
|
2199
|
+
const bar = "\u2588".repeat(barFilled) + "\u2591".repeat(barEmpty);
|
|
2200
|
+
const statusStr = g.is_over ? chalk3.red("\u2717 OVER") : g.is_at_risk ? chalk3.yellow("\u26A0 AT RISK") : chalk3.green("\u2713 ON TRACK");
|
|
2201
|
+
const label = `${g.period} (${scope})`.padEnd(20);
|
|
2202
|
+
console.log(` ${label} ${bar} ${fmt2(g.current_spend_usd)} / ${fmt2(g.limit_usd)} (${g.percent_used.toFixed(0)}%) ${statusStr}`);
|
|
2203
|
+
}
|
|
2204
|
+
console.log();
|
|
2205
|
+
});
|
|
2206
|
+
program.command("remove <type> <id>").alias("rm").description("Remove a record. Type: budget | project | goal | pricing").action((type, id) => {
|
|
2207
|
+
const db = openDatabase();
|
|
2208
|
+
try {
|
|
2209
|
+
switch (type.toLowerCase()) {
|
|
2210
|
+
case "budget":
|
|
2211
|
+
deleteBudget(db, id);
|
|
2212
|
+
console.log(chalk3.green(`\u2713 Budget ${id} removed`));
|
|
2213
|
+
break;
|
|
2214
|
+
case "project":
|
|
2215
|
+
deleteProject(db, id);
|
|
2216
|
+
console.log(chalk3.green(`\u2713 Project ${id} removed`));
|
|
2217
|
+
break;
|
|
2218
|
+
case "goal":
|
|
2219
|
+
deleteGoal(db, id);
|
|
2220
|
+
console.log(chalk3.green(`\u2713 Goal ${id} removed`));
|
|
2221
|
+
break;
|
|
2222
|
+
case "pricing":
|
|
2223
|
+
deleteModelPricing(db, id);
|
|
2224
|
+
console.log(chalk3.green(`\u2713 Pricing entry ${id} removed`));
|
|
2225
|
+
break;
|
|
2226
|
+
default:
|
|
2227
|
+
console.error(chalk3.red(`Unknown type: ${type}. Use: budget | project | goal | pricing`));
|
|
2228
|
+
process.exit(1);
|
|
2229
|
+
}
|
|
2230
|
+
} catch (e) {
|
|
2231
|
+
console.error(chalk3.red(`Failed: ${e instanceof Error ? e.message : String(e)}`));
|
|
2232
|
+
process.exit(1);
|
|
2233
|
+
}
|
|
2234
|
+
});
|
|
2235
|
+
program.parse();
|