@juliantanx/aiusage 1.0.1

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.
Files changed (47) hide show
  1. package/dist/chunk-C6IKRE2P.js +458 -0
  2. package/dist/dist-XHYTXDVT.js +42 -0
  3. package/dist/index.d.ts +1 -0
  4. package/dist/index.js +2734 -0
  5. package/dist/web/_app/env.js +1 -0
  6. package/dist/web/_app/immutable/assets/0.BTUc0PXb.css +1 -0
  7. package/dist/web/_app/immutable/assets/2.I4bgY8QS.css +1 -0
  8. package/dist/web/_app/immutable/assets/3.BmO4KrOR.css +1 -0
  9. package/dist/web/_app/immutable/assets/4.TAVgUi8b.css +1 -0
  10. package/dist/web/_app/immutable/assets/5.BeDwa7mS.css +1 -0
  11. package/dist/web/_app/immutable/assets/6.BYQidWiw.css +1 -0
  12. package/dist/web/_app/immutable/assets/7.8R_5Laqi.css +1 -0
  13. package/dist/web/_app/immutable/assets/8.BcbNCOTS.css +1 -0
  14. package/dist/web/_app/immutable/assets/9.QhlpTtkf.css +1 -0
  15. package/dist/web/_app/immutable/assets/ToolSelector.BKgB2QVk.css +1 -0
  16. package/dist/web/_app/immutable/assets/ToolSelector.HSbC8-q9.css +1 -0
  17. package/dist/web/_app/immutable/assets/_layout.DLyrrG29.css +1 -0
  18. package/dist/web/_app/immutable/assets/_page.8R_5Laqi.css +1 -0
  19. package/dist/web/_app/immutable/assets/_page.BYQidWiw.css +1 -0
  20. package/dist/web/_app/immutable/assets/_page.BcbNCOTS.css +1 -0
  21. package/dist/web/_app/immutable/assets/_page.BeDwa7mS.css +1 -0
  22. package/dist/web/_app/immutable/assets/_page.BmO4KrOR.css +1 -0
  23. package/dist/web/_app/immutable/assets/_page.I4bgY8QS.css +1 -0
  24. package/dist/web/_app/immutable/assets/_page.QhlpTtkf.css +1 -0
  25. package/dist/web/_app/immutable/assets/_page.TAVgUi8b.css +1 -0
  26. package/dist/web/_app/immutable/chunks/ToolSelector.kb97TteP.js +2 -0
  27. package/dist/web/_app/immutable/chunks/api.DyoIo4nG.js +1 -0
  28. package/dist/web/_app/immutable/chunks/entry.CVLbqi7C.js +3 -0
  29. package/dist/web/_app/immutable/chunks/index.CzqoKkoo.js +1 -0
  30. package/dist/web/_app/immutable/chunks/index.Dm-3G3np.js +1 -0
  31. package/dist/web/_app/immutable/chunks/scheduler.Dx1bs0RQ.js +1 -0
  32. package/dist/web/_app/immutable/chunks/stores.BSF99XdC.js +1 -0
  33. package/dist/web/_app/immutable/entry/app.Cb4qBU5I.js +2 -0
  34. package/dist/web/_app/immutable/entry/start.DPeK9dzE.js +1 -0
  35. package/dist/web/_app/immutable/nodes/0.Bzydyk3L.js +1 -0
  36. package/dist/web/_app/immutable/nodes/1.87xpHPtt.js +1 -0
  37. package/dist/web/_app/immutable/nodes/2.CJyBxUXd.js +1 -0
  38. package/dist/web/_app/immutable/nodes/3.Bqg88zQg.js +1 -0
  39. package/dist/web/_app/immutable/nodes/4.B3zrG0ip.js +1 -0
  40. package/dist/web/_app/immutable/nodes/5.C3h9wXyF.js +1 -0
  41. package/dist/web/_app/immutable/nodes/6.C8xhym7b.js +1 -0
  42. package/dist/web/_app/immutable/nodes/7.C950CTxa.js +1 -0
  43. package/dist/web/_app/immutable/nodes/8.BSeUhVCa.js +1 -0
  44. package/dist/web/_app/immutable/nodes/9.DJXBgSiY.js +1 -0
  45. package/dist/web/_app/version.json +1 -0
  46. package/dist/web/index.html +47 -0
  47. package/package.json +33 -0
package/dist/index.js ADDED
@@ -0,0 +1,2734 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ Aggregator,
4
+ DEFAULT_PRICE_TABLE,
5
+ calculateCost,
6
+ generateRecordId,
7
+ generateSessionKey,
8
+ generateSyncRecordId,
9
+ generateToolCallId,
10
+ getPriceTable,
11
+ getUserOverrides,
12
+ inferProvider,
13
+ removePriceOverride,
14
+ resolvePrice,
15
+ setPriceOverride
16
+ } from "./chunk-C6IKRE2P.js";
17
+
18
+ // src/cli.ts
19
+ import { Command } from "commander";
20
+ import { writeFileSync as writeFileSync4 } from "fs";
21
+
22
+ // src/commands/serve.ts
23
+ import http2 from "http";
24
+ import { readFileSync as readFileSync5, existsSync as existsSync5, statSync as statSync2 } from "fs";
25
+ import { join as join6, extname as extname2, dirname } from "path";
26
+ import { fileURLToPath } from "url";
27
+
28
+ // src/api/server.ts
29
+ import http from "http";
30
+ import { hostname, platform } from "os";
31
+
32
+ // src/config.ts
33
+ import { readFileSync, writeFileSync, existsSync } from "fs";
34
+ import { join } from "path";
35
+ import { homedir } from "os";
36
+ var AIUSAGE_DIR = join(homedir(), ".aiusage");
37
+ var CONFIG_PATH = join(AIUSAGE_DIR, "config.json");
38
+ var SYNC_FIELDS = [
39
+ "ts",
40
+ "inputTokens",
41
+ "outputTokens",
42
+ "cacheReadTokens",
43
+ "cacheWriteTokens",
44
+ "thinkingTokens",
45
+ "cost",
46
+ "costSource",
47
+ "tool",
48
+ "model",
49
+ "provider",
50
+ "sessionKey",
51
+ "device",
52
+ "deviceInstanceId",
53
+ "updatedAt"
54
+ ];
55
+ function loadConfig() {
56
+ if (!existsSync(CONFIG_PATH)) return null;
57
+ try {
58
+ return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+ function saveConfig(config) {
64
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 384 });
65
+ }
66
+ function buildConsentConfig(config) {
67
+ const sync = config.sync;
68
+ if (!sync) return null;
69
+ const backend = sync.backend;
70
+ const target = backend === "github" ? sync.repo ?? "" : `${sync.bucket}/${sync.prefix ?? "aiusage/"}`;
71
+ const endpoint = backend === "github" ? "https://api.github.com" : sync.endpoint ?? "https://s3.amazonaws.com";
72
+ const region = sync.region ?? "global";
73
+ return {
74
+ backend,
75
+ target,
76
+ endpoint,
77
+ region,
78
+ fields: SYNC_FIELDS,
79
+ operations: ["read", "write"],
80
+ schemaVersion: "v1"
81
+ };
82
+ }
83
+ function loadCredential(key) {
84
+ const config = loadConfig();
85
+ if (!config?.credentials) return null;
86
+ return config.credentials[key] ?? null;
87
+ }
88
+
89
+ // src/api/server.ts
90
+ function getDateRangeFilter(range, from, to, prefix = "") {
91
+ const ts = prefix ? `${prefix}.ts` : "ts";
92
+ if (from && to) {
93
+ const startMs = new Date(from).getTime();
94
+ const endMs = (/* @__PURE__ */ new Date(to + "T23:59:59.999Z")).getTime();
95
+ return { where: `AND ${ts} >= @start AND ${ts} < @end`, params: { start: startMs, end: endMs } };
96
+ }
97
+ const now = /* @__PURE__ */ new Date();
98
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
99
+ if (range === "week") {
100
+ const dayOfWeek = today.getDay();
101
+ const startOfWeek = new Date(today);
102
+ startOfWeek.setDate(today.getDate() - dayOfWeek);
103
+ return { where: `AND ${ts} >= @start`, params: { start: startOfWeek.getTime() } };
104
+ }
105
+ if (range === "month") {
106
+ const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
107
+ return { where: `AND ${ts} >= @start`, params: { start: startOfMonth.getTime() } };
108
+ }
109
+ if (range === "last30") {
110
+ const start = new Date(today);
111
+ start.setDate(start.getDate() - 30);
112
+ return { where: `AND ${ts} >= @start`, params: { start: start.getTime() } };
113
+ }
114
+ if (range === "all") {
115
+ return { where: "", params: {} };
116
+ }
117
+ return { where: `AND ${ts} >= @start`, params: { start: today.getTime() } };
118
+ }
119
+ function json(res, data, status = 200) {
120
+ res.writeHead(status, { "Content-Type": "application/json" });
121
+ res.end(JSON.stringify(data));
122
+ }
123
+ function extractProject(sourceFile) {
124
+ const match = sourceFile.match(/\.claude\/projects\/([^/]+)/);
125
+ if (!match) return "unknown";
126
+ const raw = match[1];
127
+ const parts = raw.split("-").filter(Boolean);
128
+ const wpIdx = parts.indexOf("WebstormProjects");
129
+ if (wpIdx >= 0 && wpIdx < parts.length - 1) return parts.slice(wpIdx + 1).join("-");
130
+ const docIdx = parts.indexOf("Documents");
131
+ if (docIdx >= 0 && docIdx < parts.length - 1) return parts.slice(docIdx + 1).join("-");
132
+ if (parts.length <= 3) return "~";
133
+ return parts.slice(-2).join("-") || raw;
134
+ }
135
+ function getToolFilter(tool, prefix = "") {
136
+ if (!tool) return { where: "", params: {} };
137
+ const col = prefix ? `${prefix}.tool` : "tool";
138
+ return { where: `AND ${col} = @tool`, params: { tool } };
139
+ }
140
+ var LOCAL_ONLY_FILTER = "AND source_file NOT LIKE 'synced/%'";
141
+ function getDeviceFilter(device, currentDeviceInstanceId) {
142
+ if (!currentDeviceInstanceId) {
143
+ return { where: "", params: {}, useUnion: false, localOnly: false };
144
+ }
145
+ if (!device) {
146
+ return {
147
+ where: "",
148
+ params: { currentDeviceId: currentDeviceInstanceId },
149
+ useUnion: true,
150
+ localOnly: true
151
+ };
152
+ }
153
+ if (device === currentDeviceInstanceId) {
154
+ return { where: "", params: {}, useUnion: false, localOnly: true };
155
+ }
156
+ return {
157
+ where: "AND device_instance_id = @deviceId",
158
+ params: { deviceId: device },
159
+ useUnion: false,
160
+ localOnly: false
161
+ };
162
+ }
163
+ function createApiServer(db, options) {
164
+ const server = http.createServer(async (req, res) => {
165
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
166
+ res.setHeader("Access-Control-Allow-Origin", "*");
167
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
168
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
169
+ if (req.method === "OPTIONS") {
170
+ res.writeHead(204);
171
+ res.end();
172
+ return;
173
+ }
174
+ const range = url.searchParams.get("range");
175
+ const from = url.searchParams.get("from");
176
+ const to = url.searchParams.get("to");
177
+ const toolParam = url.searchParams.get("tool");
178
+ const VALID_TOOLS = ["claude-code", "codex", "openclaw", "opencode"];
179
+ if (toolParam && !VALID_TOOLS.includes(toolParam)) {
180
+ json(res, { error: { code: "INVALID_PARAM", message: "Invalid tool" } }, 400);
181
+ return;
182
+ }
183
+ try {
184
+ if (url.pathname === "/api/summary") {
185
+ if (range && !["day", "week", "month", "last30", "all"].includes(range)) {
186
+ json(res, { error: { code: "INVALID_PARAM", message: "Invalid range" } }, 400);
187
+ return;
188
+ }
189
+ const device = url.searchParams.get("device");
190
+ const df = getDeviceFilter(device, options?.currentDeviceInstanceId);
191
+ const dr = getDateRangeFilter(range, from, to);
192
+ const tool = url.searchParams.get("tool");
193
+ const tf = getToolFilter(tool);
194
+ let totals;
195
+ let byToolRows;
196
+ if (df.useUnion) {
197
+ const unionSql = `
198
+ SELECT input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, thinking_tokens, cost, ts, session_id
199
+ FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
200
+ UNION ALL
201
+ SELECT input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, thinking_tokens, cost, ts, session_key AS session_id
202
+ FROM synced_records WHERE device_instance_id != @currentDeviceId ${dr.where} ${tf.where}
203
+ `;
204
+ totals = db.prepare(`
205
+ SELECT
206
+ COALESCE(SUM(input_tokens), 0) AS inputTokens,
207
+ COALESCE(SUM(output_tokens), 0) AS outputTokens,
208
+ COALESCE(SUM(cache_read_tokens), 0) AS cacheReadTokens,
209
+ COALESCE(SUM(cache_write_tokens), 0) AS cacheWriteTokens,
210
+ COALESCE(SUM(thinking_tokens), 0) AS thinkingTokens,
211
+ COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens), 0) AS totalTokens,
212
+ COALESCE(SUM(cost), 0) AS totalCost,
213
+ COUNT(DISTINCT strftime('%Y-%m-%d', ts/1000, 'unixepoch')) AS activeDays,
214
+ COUNT(DISTINCT session_id) AS totalSessions
215
+ FROM (${unionSql})
216
+ `).get({ ...dr.params, ...df.params, ...tf.params });
217
+ byToolRows = db.prepare(`
218
+ SELECT tool, SUM(tokens) AS tokens, SUM(cost) AS cost FROM (
219
+ SELECT tool,
220
+ SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens) AS tokens,
221
+ SUM(cost) AS cost
222
+ FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
223
+ GROUP BY tool
224
+ UNION ALL
225
+ SELECT tool,
226
+ SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens) AS tokens,
227
+ SUM(cost) AS cost
228
+ FROM synced_records WHERE device_instance_id != @currentDeviceId ${dr.where} ${tf.where}
229
+ GROUP BY tool
230
+ ) GROUP BY tool ORDER BY cost DESC
231
+ `).all({ ...dr.params, ...df.params, ...tf.params });
232
+ } else if (df.where) {
233
+ totals = db.prepare(`
234
+ SELECT
235
+ COALESCE(SUM(input_tokens), 0) AS inputTokens,
236
+ COALESCE(SUM(output_tokens), 0) AS outputTokens,
237
+ COALESCE(SUM(cache_read_tokens), 0) AS cacheReadTokens,
238
+ COALESCE(SUM(cache_write_tokens), 0) AS cacheWriteTokens,
239
+ COALESCE(SUM(thinking_tokens), 0) AS thinkingTokens,
240
+ COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens), 0) AS totalTokens,
241
+ COALESCE(SUM(cost), 0) AS totalCost,
242
+ COUNT(DISTINCT strftime('%Y-%m-%d', ts/1000, 'unixepoch')) AS activeDays,
243
+ COUNT(DISTINCT session_key) AS totalSessions
244
+ FROM synced_records WHERE 1=1 ${df.where} ${dr.where} ${tf.where}
245
+ `).get({ ...dr.params, ...df.params, ...tf.params });
246
+ byToolRows = db.prepare(`
247
+ SELECT tool,
248
+ SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens) AS tokens,
249
+ SUM(cost) AS cost
250
+ FROM synced_records WHERE 1=1 ${df.where} ${dr.where} ${tf.where}
251
+ GROUP BY tool ORDER BY cost DESC
252
+ `).all({ ...dr.params, ...df.params, ...tf.params });
253
+ } else {
254
+ totals = db.prepare(`
255
+ SELECT
256
+ COALESCE(SUM(input_tokens), 0) AS inputTokens,
257
+ COALESCE(SUM(output_tokens), 0) AS outputTokens,
258
+ COALESCE(SUM(cache_read_tokens), 0) AS cacheReadTokens,
259
+ COALESCE(SUM(cache_write_tokens), 0) AS cacheWriteTokens,
260
+ COALESCE(SUM(thinking_tokens), 0) AS thinkingTokens,
261
+ COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens), 0) AS totalTokens,
262
+ COALESCE(SUM(cost), 0) AS totalCost,
263
+ COUNT(DISTINCT strftime('%Y-%m-%d', ts/1000, 'unixepoch')) AS activeDays,
264
+ COUNT(DISTINCT session_id) AS totalSessions
265
+ FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
266
+ `).get({ ...dr.params, ...tf.params });
267
+ byToolRows = db.prepare(`
268
+ SELECT tool,
269
+ SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens) AS tokens,
270
+ SUM(cost) AS cost
271
+ FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
272
+ GROUP BY tool ORDER BY cost DESC
273
+ `).all({ ...dr.params, ...tf.params });
274
+ }
275
+ const byTool = {};
276
+ for (const row of byToolRows) {
277
+ byTool[row.tool] = { tokens: row.tokens, cost: row.cost };
278
+ }
279
+ const drJoin = getDateRangeFilter(range, from, to, "r");
280
+ const dfJoin = df.where ? df.where.replace(/device_instance_id/g, "r.device_instance_id") : "";
281
+ const tfJoin = getToolFilter(tool, "r");
282
+ const topToolCalls = db.prepare(`
283
+ SELECT tc.name, COUNT(*) AS count
284
+ FROM tool_calls tc
285
+ JOIN records r ON r.id = tc.record_id
286
+ WHERE 1=1 ${dfJoin} ${drJoin.where} ${tfJoin.where}
287
+ GROUP BY tc.name ORDER BY count DESC LIMIT 10
288
+ `).all({ ...drJoin.params, ...df.params, ...tfJoin.params });
289
+ json(res, {
290
+ inputTokens: totals.inputTokens,
291
+ outputTokens: totals.outputTokens,
292
+ cacheReadTokens: totals.cacheReadTokens,
293
+ cacheWriteTokens: totals.cacheWriteTokens,
294
+ thinkingTokens: totals.thinkingTokens,
295
+ totalTokens: totals.totalTokens,
296
+ totalCost: totals.totalCost,
297
+ activeDays: totals.activeDays,
298
+ totalSessions: totals.totalSessions,
299
+ byTool,
300
+ topToolCalls
301
+ });
302
+ return;
303
+ }
304
+ if (url.pathname === "/api/tokens") {
305
+ const dr = getDateRangeFilter(range, from, to);
306
+ const device = url.searchParams.get("device");
307
+ const df = getDeviceFilter(device, options?.currentDeviceInstanceId);
308
+ const tool = url.searchParams.get("tool");
309
+ const tf = getToolFilter(tool);
310
+ let sql;
311
+ let params;
312
+ if (df.useUnion) {
313
+ sql = `
314
+ SELECT strftime('%Y-%m-%d', ts/1000, 'unixepoch') AS date,
315
+ SUM(input_tokens) AS inputTokens,
316
+ SUM(output_tokens) AS outputTokens,
317
+ SUM(cache_read_tokens) AS cacheReadTokens,
318
+ SUM(cache_write_tokens) AS cacheWriteTokens,
319
+ SUM(thinking_tokens) AS thinkingTokens
320
+ FROM (
321
+ SELECT input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, thinking_tokens, ts FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
322
+ UNION ALL
323
+ SELECT input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, thinking_tokens, ts FROM synced_records WHERE device_instance_id != @currentDeviceId ${dr.where} ${tf.where}
324
+ )
325
+ GROUP BY date ORDER BY date`;
326
+ params = { ...dr.params, currentDeviceId: df.params.currentDeviceId, ...tf.params };
327
+ } else if (device && device !== options?.currentDeviceInstanceId) {
328
+ sql = `
329
+ SELECT strftime('%Y-%m-%d', ts/1000, 'unixepoch') AS date,
330
+ SUM(input_tokens) AS inputTokens,
331
+ SUM(output_tokens) AS outputTokens,
332
+ SUM(cache_read_tokens) AS cacheReadTokens,
333
+ SUM(cache_write_tokens) AS cacheWriteTokens,
334
+ SUM(thinking_tokens) AS thinkingTokens
335
+ FROM synced_records WHERE 1=1 ${df.where} ${dr.where} ${tf.where}
336
+ GROUP BY date ORDER BY date`;
337
+ params = { ...df.params, ...dr.params, ...tf.params };
338
+ } else {
339
+ sql = `
340
+ SELECT strftime('%Y-%m-%d', ts/1000, 'unixepoch') AS date,
341
+ SUM(input_tokens) AS inputTokens,
342
+ SUM(output_tokens) AS outputTokens,
343
+ SUM(cache_read_tokens) AS cacheReadTokens,
344
+ SUM(cache_write_tokens) AS cacheWriteTokens,
345
+ SUM(thinking_tokens) AS thinkingTokens
346
+ FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
347
+ GROUP BY date ORDER BY date`;
348
+ params = { ...dr.params, ...tf.params };
349
+ }
350
+ const rows = db.prepare(sql).all(params);
351
+ json(res, { data: rows });
352
+ return;
353
+ }
354
+ if (url.pathname === "/api/cost") {
355
+ const dr = getDateRangeFilter(range, from, to);
356
+ const device = url.searchParams.get("device");
357
+ const df = getDeviceFilter(device, options?.currentDeviceInstanceId);
358
+ const tool = url.searchParams.get("tool");
359
+ const tf = getToolFilter(tool);
360
+ let daily;
361
+ let byToolRows;
362
+ let byModelRows;
363
+ if (df.useUnion) {
364
+ daily = db.prepare(`
365
+ SELECT strftime('%Y-%m-%d', ts/1000, 'unixepoch') AS date,
366
+ SUM(cost) AS cost
367
+ FROM (
368
+ SELECT cost, ts FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
369
+ UNION ALL
370
+ SELECT cost, ts FROM synced_records WHERE device_instance_id != @currentDeviceId ${dr.where} ${tf.where}
371
+ )
372
+ GROUP BY date ORDER BY date
373
+ `).all({ ...dr.params, currentDeviceId: df.params.currentDeviceId, ...tf.params });
374
+ byToolRows = db.prepare(`
375
+ SELECT tool, SUM(cost) AS cost FROM (
376
+ SELECT tool, SUM(cost) AS cost FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where} GROUP BY tool
377
+ UNION ALL
378
+ SELECT tool, SUM(cost) AS cost FROM synced_records WHERE device_instance_id != @currentDeviceId ${dr.where} ${tf.where} GROUP BY tool
379
+ ) GROUP BY tool ORDER BY cost DESC
380
+ `).all({ ...dr.params, currentDeviceId: df.params.currentDeviceId, ...tf.params });
381
+ byModelRows = db.prepare(`
382
+ SELECT model, SUM(cost) AS cost FROM (
383
+ SELECT model, SUM(cost) AS cost FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where} GROUP BY model
384
+ UNION ALL
385
+ SELECT model, SUM(cost) AS cost FROM synced_records WHERE device_instance_id != @currentDeviceId ${dr.where} ${tf.where} GROUP BY model
386
+ ) GROUP BY model ORDER BY cost DESC
387
+ `).all({ ...dr.params, currentDeviceId: df.params.currentDeviceId, ...tf.params });
388
+ } else if (device && device !== options?.currentDeviceInstanceId) {
389
+ daily = db.prepare(`
390
+ SELECT strftime('%Y-%m-%d', ts/1000, 'unixepoch') AS date,
391
+ SUM(cost) AS cost
392
+ FROM synced_records WHERE 1=1 ${df.where} ${dr.where} ${tf.where}
393
+ GROUP BY date ORDER BY date
394
+ `).all({ ...df.params, ...dr.params, ...tf.params });
395
+ byToolRows = db.prepare(`
396
+ SELECT tool, SUM(cost) AS cost
397
+ FROM synced_records WHERE 1=1 ${df.where} ${dr.where} ${tf.where}
398
+ GROUP BY tool ORDER BY cost DESC
399
+ `).all({ ...df.params, ...dr.params, ...tf.params });
400
+ byModelRows = db.prepare(`
401
+ SELECT model, SUM(cost) AS cost
402
+ FROM synced_records WHERE 1=1 ${df.where} ${dr.where} ${tf.where}
403
+ GROUP BY model ORDER BY cost DESC
404
+ `).all({ ...df.params, ...dr.params, ...tf.params });
405
+ } else {
406
+ daily = db.prepare(`
407
+ SELECT strftime('%Y-%m-%d', ts/1000, 'unixepoch') AS date,
408
+ SUM(cost) AS cost
409
+ FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
410
+ GROUP BY date ORDER BY date
411
+ `).all({ ...dr.params, ...tf.params });
412
+ byToolRows = db.prepare(`
413
+ SELECT tool, SUM(cost) AS cost
414
+ FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
415
+ GROUP BY tool ORDER BY cost DESC
416
+ `).all({ ...dr.params, ...tf.params });
417
+ byModelRows = db.prepare(`
418
+ SELECT model, SUM(cost) AS cost
419
+ FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
420
+ GROUP BY model ORDER BY cost DESC
421
+ `).all({ ...dr.params, ...tf.params });
422
+ }
423
+ const byTool = {};
424
+ for (const r of byToolRows) byTool[r.tool] = r.cost;
425
+ const byModel = {};
426
+ for (const r of byModelRows) byModel[r.model] = r.cost;
427
+ json(res, { data: daily, byTool, byModel });
428
+ return;
429
+ }
430
+ if (url.pathname === "/api/models") {
431
+ const dr = getDateRangeFilter(range, from, to);
432
+ const device = url.searchParams.get("device");
433
+ const df = getDeviceFilter(device, options?.currentDeviceInstanceId);
434
+ const tool = url.searchParams.get("tool");
435
+ const tf = getToolFilter(tool);
436
+ let total;
437
+ let rows;
438
+ if (df.useUnion) {
439
+ const unionSql = `
440
+ SELECT model, provider, COUNT(*) AS callCount,
441
+ SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens) AS totalTokens
442
+ FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
443
+ GROUP BY model, provider
444
+ UNION ALL
445
+ SELECT model, provider, COUNT(*) AS callCount,
446
+ SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens) AS totalTokens
447
+ FROM synced_records WHERE device_instance_id != @currentDeviceId ${dr.where} ${tf.where}
448
+ GROUP BY model, provider
449
+ `;
450
+ const mergedRows = db.prepare(`
451
+ SELECT model, provider, SUM(callCount) AS callCount, SUM(totalTokens) AS totalTokens
452
+ FROM (${unionSql})
453
+ GROUP BY model, provider ORDER BY callCount DESC
454
+ `).all({ ...dr.params, ...df.params, ...tf.params });
455
+ total = mergedRows.reduce((s, r) => s + r.callCount, 0) || 1;
456
+ rows = mergedRows;
457
+ } else if (device && device !== options?.currentDeviceInstanceId) {
458
+ rows = db.prepare(`
459
+ SELECT model, provider,
460
+ COUNT(*) AS callCount,
461
+ SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens) AS totalTokens
462
+ FROM synced_records WHERE 1=1 ${df.where} ${dr.where} ${tf.where}
463
+ GROUP BY model, provider ORDER BY callCount DESC
464
+ `).all({ ...df.params, ...dr.params, ...tf.params });
465
+ total = rows.reduce((s, r) => s + r.callCount, 0) || 1;
466
+ } else {
467
+ rows = db.prepare(`
468
+ SELECT model, provider,
469
+ COUNT(*) AS callCount,
470
+ SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens) AS totalTokens
471
+ FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
472
+ GROUP BY model, provider ORDER BY callCount DESC
473
+ `).all({ ...dr.params, ...tf.params });
474
+ total = rows.reduce((s, r) => s + r.callCount, 0) || 1;
475
+ }
476
+ const models = rows.map((r) => ({
477
+ model: r.model,
478
+ provider: r.provider,
479
+ callCount: r.callCount,
480
+ totalTokens: r.totalTokens,
481
+ percentage: Math.round(r.callCount / total * 1e3) / 10
482
+ }));
483
+ json(res, { models });
484
+ return;
485
+ }
486
+ if (url.pathname === "/api/tool-calls") {
487
+ const device = url.searchParams.get("device");
488
+ if (device && device !== options?.currentDeviceInstanceId) {
489
+ json(res, { toolCalls: [] });
490
+ return;
491
+ }
492
+ const dr = getDateRangeFilter(range, from, to, "r");
493
+ const tool = url.searchParams.get("tool");
494
+ const tf = getToolFilter(tool, "r");
495
+ const totalRow = db.prepare(`
496
+ SELECT COUNT(*) AS total FROM tool_calls tc
497
+ JOIN records r ON r.id = tc.record_id
498
+ WHERE 1=1 ${dr.where} ${tf.where}
499
+ `).get({ ...dr.params, ...tf.params });
500
+ const total = totalRow.total || 1;
501
+ const rows = db.prepare(`
502
+ SELECT tc.name, COUNT(*) AS count
503
+ FROM tool_calls tc
504
+ JOIN records r ON r.id = tc.record_id
505
+ WHERE 1=1 ${dr.where} ${tf.where}
506
+ GROUP BY tc.name ORDER BY count DESC
507
+ `).all({ ...dr.params, ...tf.params });
508
+ const toolCalls = rows.map((r) => ({
509
+ name: r.name,
510
+ count: r.count,
511
+ percentage: Math.round(r.count / total * 1e3) / 10
512
+ }));
513
+ json(res, { toolCalls });
514
+ return;
515
+ }
516
+ if (url.pathname === "/api/sessions") {
517
+ const page = Math.max(1, parseInt(url.searchParams.get("page") || "1", 10));
518
+ const pageSize = Math.min(100, Math.max(1, parseInt(url.searchParams.get("pageSize") || "50", 10)));
519
+ const dr = getDateRangeFilter(range, from, to);
520
+ const device = url.searchParams.get("device");
521
+ const df = getDeviceFilter(device, options?.currentDeviceInstanceId);
522
+ const tool = url.searchParams.get("tool");
523
+ if (device && !df.localOnly && !df.useUnion) {
524
+ json(res, {
525
+ sessions: [],
526
+ total: 0,
527
+ page,
528
+ pageSize
529
+ });
530
+ return;
531
+ }
532
+ const tf = getToolFilter(tool);
533
+ const params = { ...dr.params, ...tf.params };
534
+ const totalRow = db.prepare(`
535
+ SELECT COUNT(DISTINCT session_id) AS total
536
+ FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
537
+ `).get(params);
538
+ const sessions = db.prepare(`
539
+ SELECT session_id AS sessionId,
540
+ tool,
541
+ model,
542
+ MIN(ts) AS ts,
543
+ SUM(input_tokens) AS inputTokens,
544
+ SUM(output_tokens) AS outputTokens,
545
+ SUM(cache_read_tokens) AS cacheReadTokens,
546
+ SUM(cache_write_tokens) AS cacheWriteTokens,
547
+ SUM(cost) AS cost
548
+ FROM records
549
+ WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
550
+ GROUP BY session_id
551
+ ORDER BY ts DESC
552
+ LIMIT @limit OFFSET @offset
553
+ `).all({ ...params, limit: pageSize, offset: (page - 1) * pageSize });
554
+ json(res, {
555
+ sessions,
556
+ total: totalRow.total,
557
+ page,
558
+ pageSize
559
+ });
560
+ return;
561
+ }
562
+ if (url.pathname === "/api/projects") {
563
+ const dr = getDateRangeFilter(range, from, to);
564
+ const device = url.searchParams.get("device");
565
+ const df = getDeviceFilter(device, options?.currentDeviceInstanceId);
566
+ const tool = url.searchParams.get("tool");
567
+ const tf = getToolFilter(tool);
568
+ if (df.where && !df.useUnion) {
569
+ json(res, { projects: [] });
570
+ return;
571
+ }
572
+ const rows = db.prepare(`
573
+ SELECT source_file,
574
+ COUNT(*) AS sessionCount,
575
+ SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens) AS totalTokens,
576
+ SUM(cost) AS cost
577
+ FROM records WHERE 1=1 ${dr.where} ${df.localOnly ? LOCAL_ONLY_FILTER : ""} ${tf.where}
578
+ GROUP BY source_file ORDER BY totalTokens DESC
579
+ `).all({ ...dr.params, ...tf.params });
580
+ const projectMap = {};
581
+ for (const row of rows) {
582
+ const project = extractProject(row.source_file);
583
+ if (!projectMap[project]) projectMap[project] = { sessions: 0, tokens: 0, cost: 0 };
584
+ projectMap[project].sessions += row.sessionCount;
585
+ projectMap[project].tokens += row.totalTokens;
586
+ projectMap[project].cost += row.cost;
587
+ }
588
+ const totalTokens = Object.values(projectMap).reduce((s, p) => s + p.tokens, 0) || 1;
589
+ const projects = Object.entries(projectMap).map(([name, data]) => ({
590
+ name,
591
+ sessions: data.sessions,
592
+ tokens: data.tokens,
593
+ cost: data.cost,
594
+ percentage: Math.round(data.tokens / totalTokens * 1e3) / 10
595
+ })).sort((a, b) => b.tokens - a.tokens);
596
+ json(res, { projects });
597
+ return;
598
+ }
599
+ if (url.pathname === "/api/pricing") {
600
+ if (req.method === "GET") {
601
+ const table = getPriceTable();
602
+ const overrides = getUserOverrides();
603
+ const dbModels = db.prepare("SELECT DISTINCT model FROM records ORDER BY model").all();
604
+ const models = dbModels.map((r) => {
605
+ const model = r.model;
606
+ const exactPrice = table[model];
607
+ const resolvedPrice = resolvePrice(model);
608
+ const isOverride = model in overrides;
609
+ const isDefault = model in DEFAULT_PRICE_TABLE && !isOverride;
610
+ let matchedBy = null;
611
+ if (!exactPrice && resolvedPrice) {
612
+ for (const prefix of Object.keys(table)) {
613
+ if (model.startsWith(prefix) || model.toLowerCase().startsWith(prefix)) {
614
+ matchedBy = prefix;
615
+ break;
616
+ }
617
+ }
618
+ if (!matchedBy) {
619
+ const stripped = model.replace(/^[^/]+\//, "").toLowerCase();
620
+ for (const prefix of Object.keys(table)) {
621
+ if (stripped.startsWith(prefix)) {
622
+ matchedBy = prefix;
623
+ break;
624
+ }
625
+ }
626
+ }
627
+ }
628
+ return {
629
+ model,
630
+ price: resolvedPrice ?? null,
631
+ isDefault,
632
+ isOverride,
633
+ matchedBy
634
+ };
635
+ });
636
+ json(res, { models, overrides });
637
+ return;
638
+ }
639
+ if (req.method === "PUT") {
640
+ let body = "";
641
+ for await (const chunk of req) body += chunk;
642
+ try {
643
+ const data = JSON.parse(body);
644
+ if (!data.model || typeof data.input !== "number" || typeof data.output !== "number") {
645
+ json(res, { error: { code: "INVALID_PARAM", message: "model, input, output required" } }, 400);
646
+ return;
647
+ }
648
+ const entry = {
649
+ input: data.input,
650
+ output: data.output,
651
+ cacheRead: data.cacheRead,
652
+ cacheWrite: data.cacheWrite,
653
+ thinking: data.thinking
654
+ };
655
+ setPriceOverride(data.model, entry);
656
+ const cfg = loadConfig() ?? {};
657
+ cfg.priceOverrides = { ...cfg.priceOverrides, [data.model]: entry };
658
+ saveConfig(cfg);
659
+ json(res, { ok: true });
660
+ } catch {
661
+ json(res, { error: { code: "INVALID_JSON", message: "Invalid JSON body" } }, 400);
662
+ }
663
+ return;
664
+ }
665
+ if (req.method === "DELETE") {
666
+ const model = url.searchParams.get("model");
667
+ if (!model) {
668
+ json(res, { error: { code: "INVALID_PARAM", message: "model param required" } }, 400);
669
+ return;
670
+ }
671
+ removePriceOverride(model);
672
+ const cfg = loadConfig() ?? {};
673
+ if (cfg.priceOverrides) {
674
+ delete cfg.priceOverrides[model];
675
+ saveConfig(cfg);
676
+ }
677
+ json(res, { ok: true });
678
+ return;
679
+ }
680
+ }
681
+ if (url.pathname === "/api/pricing/recalc" && req.method === "POST") {
682
+ const { resolvePrice: resolvePrice2 } = await import("./dist-XHYTXDVT.js");
683
+ const BATCH_SIZE2 = 1e3;
684
+ let updated = 0;
685
+ let lastId = "";
686
+ while (true) {
687
+ const records = db.prepare(
688
+ "SELECT id, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, thinking_tokens FROM records WHERE id > ? ORDER BY id LIMIT ?"
689
+ ).all(lastId, BATCH_SIZE2);
690
+ if (records.length === 0) break;
691
+ const updateStmt = db.prepare("UPDATE records SET cost = ?, cost_source = ? WHERE id = ?");
692
+ const tx = db.transaction((batch) => {
693
+ for (const r of batch) {
694
+ const price = resolvePrice2(r.model);
695
+ if (!price) continue;
696
+ const cost = r.input_tokens / 1e6 * price.input + r.output_tokens / 1e6 * price.output + r.cache_read_tokens / 1e6 * (price.cacheRead ?? 0) + r.cache_write_tokens / 1e6 * (price.cacheWrite ?? 0) + r.thinking_tokens / 1e6 * (price.thinking ?? price.output);
697
+ updateStmt.run(cost, "pricing", r.id);
698
+ updated++;
699
+ }
700
+ });
701
+ tx(records);
702
+ lastId = records[records.length - 1].id;
703
+ }
704
+ json(res, { updated });
705
+ return;
706
+ }
707
+ if (url.pathname === "/api/sync") {
708
+ if (req.method === "POST") {
709
+ if (!options?.onSyncStart) {
710
+ json(res, { error: { code: "NOT_AVAILABLE", message: "Sync not configured" } }, 501);
711
+ return;
712
+ }
713
+ const result = options.onSyncStart();
714
+ json(res, result, result.accepted ? 202 : 200);
715
+ return;
716
+ }
717
+ const status = options?.getSyncStatus?.() ?? null;
718
+ json(res, { status });
719
+ return;
720
+ }
721
+ if (url.pathname === "/api/refresh") {
722
+ if (!options?.onRefresh) {
723
+ json(res, { error: { code: "NOT_AVAILABLE", message: "Refresh not available" } }, 501);
724
+ return;
725
+ }
726
+ const result = await options.onRefresh();
727
+ json(res, result);
728
+ return;
729
+ }
730
+ if (url.pathname === "/api/devices") {
731
+ let getDisplayName2 = function(device, deviceInstanceId) {
732
+ if (deviceInstanceId === currentId) return currentDeviceAlias;
733
+ if (device && device !== "unknown" && !/^[0-9a-f]{8}$/.test(device)) return device;
734
+ if (/^[0-9a-f]{8}-/.test(deviceInstanceId)) return deviceInstanceId.slice(0, 8);
735
+ if (device && device !== "unknown") return device;
736
+ return "Unknown Device";
737
+ }, getPlatformLabel2 = function(p) {
738
+ if (p === "win32") return "Windows";
739
+ if (p === "darwin") return "macOS";
740
+ if (p === "linux") return "Linux";
741
+ return "";
742
+ }, inferPlatform2 = function(device, deviceInstanceId) {
743
+ const name = (device || "").toLowerCase();
744
+ const id = (deviceInstanceId || "").toLowerCase();
745
+ if (/^(desktop|laptop)-/.test(name)) return "Windows";
746
+ if (name.endsWith(".local") || /macbook|imac|mac\s*mini|mac\s*pro|mac\s*studio/.test(name)) return "macOS";
747
+ if (/^(ubuntu|debian|centos|fedora|arch|linux|server|node|prod|dev|staging)/.test(name)) return "Linux";
748
+ return "";
749
+ };
750
+ var getDisplayName = getDisplayName2, getPlatformLabel = getPlatformLabel2, inferPlatform = inferPlatform2;
751
+ const currentId = options?.currentDeviceInstanceId;
752
+ if (!currentId) {
753
+ json(res, { currentDeviceInstanceId: null, devices: [] });
754
+ return;
755
+ }
756
+ const config = loadConfig();
757
+ const currentDeviceAlias = config?.device || hostname();
758
+ const localRows = db.prepare(`
759
+ SELECT device, device_instance_id AS deviceInstanceId, COUNT(*) AS recordCount
760
+ FROM records
761
+ WHERE device_instance_id = @currentId AND source_file NOT LIKE 'synced/%'
762
+ GROUP BY device_instance_id
763
+ `).all({ currentId });
764
+ const syncedRows = db.prepare(`
765
+ SELECT device, device_instance_id AS deviceInstanceId, platform, COUNT(*) AS recordCount
766
+ FROM synced_records
767
+ WHERE device_instance_id != @currentId
768
+ GROUP BY device_instance_id
769
+ `).all({ currentId });
770
+ const currentPlatform = config?.platform || platform();
771
+ const deviceMap = /* @__PURE__ */ new Map();
772
+ for (const row of localRows) {
773
+ const displayName = getDisplayName2(row.device, row.deviceInstanceId);
774
+ deviceMap.set(row.deviceInstanceId, { device: row.device, deviceInstanceId: row.deviceInstanceId, displayName, platform: getPlatformLabel2(currentPlatform), recordCount: row.recordCount });
775
+ }
776
+ for (const row of syncedRows) {
777
+ const displayName = getDisplayName2(row.device, row.deviceInstanceId);
778
+ const platformLabel = getPlatformLabel2(row.platform) || inferPlatform2(row.device, row.deviceInstanceId);
779
+ const existing = deviceMap.get(row.deviceInstanceId);
780
+ if (existing) {
781
+ existing.recordCount += row.recordCount;
782
+ } else {
783
+ deviceMap.set(row.deviceInstanceId, { device: row.device, deviceInstanceId: row.deviceInstanceId, displayName, platform: platformLabel, recordCount: row.recordCount });
784
+ }
785
+ }
786
+ const devices = [...deviceMap.values()].sort((a, b) => b.recordCount - a.recordCount);
787
+ json(res, { currentDeviceInstanceId: currentId, devices });
788
+ return;
789
+ }
790
+ json(res, { error: { code: "NOT_FOUND", message: "Endpoint not found" } }, 404);
791
+ } catch (error) {
792
+ console.error("API error:", error);
793
+ json(res, { error: { code: "INTERNAL", message: "Internal server error" } }, 500);
794
+ }
795
+ });
796
+ return server;
797
+ }
798
+
799
+ // src/commands/parse.ts
800
+ import Database from "better-sqlite3";
801
+ import { readFileSync as readFileSync4, readdirSync, statSync, existsSync as existsSync4 } from "fs";
802
+ import { join as join3, extname } from "path";
803
+ import { homedir as homedir2, hostname as hostname2, platform as platform2 } from "os";
804
+
805
+ // src/db/records.ts
806
+ function insertRecord(db, record) {
807
+ db.prepare(`
808
+ INSERT OR REPLACE INTO records (
809
+ id, ts, ingested_at, synced_at, updated_at, line_offset,
810
+ tool, model, provider, input_tokens, output_tokens,
811
+ cache_read_tokens, cache_write_tokens, thinking_tokens,
812
+ cost, cost_source, session_id, source_file, device, device_instance_id, platform
813
+ ) VALUES (
814
+ @id, @ts, @ingestedAt, @syncedAt, @updatedAt, @lineOffset,
815
+ @tool, @model, @provider, @inputTokens, @outputTokens,
816
+ @cacheReadTokens, @cacheWriteTokens, @thinkingTokens,
817
+ @cost, @costSource, @sessionId, @sourceFile, @device, @deviceInstanceId, @platform
818
+ )
819
+ `).run({
820
+ id: record.id,
821
+ ts: record.ts,
822
+ ingestedAt: record.ingestedAt,
823
+ syncedAt: record.syncedAt ?? null,
824
+ updatedAt: record.updatedAt,
825
+ lineOffset: record.lineOffset,
826
+ tool: record.tool,
827
+ model: record.model,
828
+ provider: record.provider,
829
+ inputTokens: record.inputTokens,
830
+ outputTokens: record.outputTokens,
831
+ cacheReadTokens: record.cacheReadTokens,
832
+ cacheWriteTokens: record.cacheWriteTokens,
833
+ thinkingTokens: record.thinkingTokens,
834
+ cost: record.cost,
835
+ costSource: record.costSource,
836
+ sessionId: record.sessionId,
837
+ sourceFile: record.sourceFile,
838
+ device: record.device,
839
+ deviceInstanceId: record.deviceInstanceId,
840
+ platform: record.platform ?? ""
841
+ });
842
+ }
843
+ function getUnsyncedRecords(db) {
844
+ const rows = db.prepare(
845
+ "SELECT * FROM records WHERE synced_at IS NULL OR updated_at > synced_at"
846
+ ).all();
847
+ return rows.map(mapRowToRecord);
848
+ }
849
+ function mapRowToRecord(row) {
850
+ return {
851
+ id: row.id,
852
+ ts: row.ts,
853
+ ingestedAt: row.ingested_at,
854
+ syncedAt: row.synced_at != null ? row.synced_at : void 0,
855
+ updatedAt: row.updated_at,
856
+ lineOffset: row.line_offset,
857
+ tool: row.tool,
858
+ model: row.model,
859
+ provider: row.provider,
860
+ inputTokens: row.input_tokens,
861
+ outputTokens: row.output_tokens,
862
+ cacheReadTokens: row.cache_read_tokens,
863
+ cacheWriteTokens: row.cache_write_tokens,
864
+ thinkingTokens: row.thinking_tokens,
865
+ cost: row.cost,
866
+ costSource: row.cost_source,
867
+ sessionId: row.session_id,
868
+ sourceFile: row.source_file,
869
+ device: row.device,
870
+ deviceInstanceId: row.device_instance_id,
871
+ platform: row.platform || void 0
872
+ };
873
+ }
874
+
875
+ // src/db/tool-calls.ts
876
+ function insertToolCall(db, tc) {
877
+ db.prepare(`
878
+ INSERT OR REPLACE INTO tool_calls (id, record_id, tool, name, ts, call_index)
879
+ VALUES (@id, @recordId, @tool, @name, @ts, @callIndex)
880
+ `).run({
881
+ id: tc.id,
882
+ recordId: tc.recordId ?? null,
883
+ tool: tc.recordId ? null : tc.tool,
884
+ name: tc.name,
885
+ ts: tc.ts,
886
+ callIndex: tc.callIndex
887
+ });
888
+ }
889
+ function getToolCallStats(db) {
890
+ return db.prepare(`
891
+ SELECT name, COUNT(*) as count
892
+ FROM tool_calls
893
+ GROUP BY name
894
+ ORDER BY count DESC
895
+ `).all();
896
+ }
897
+
898
+ // src/init.ts
899
+ import { mkdirSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync2 } from "fs";
900
+ import { join as join2 } from "path";
901
+ import { randomUUID } from "crypto";
902
+ function getState(aiusageDir) {
903
+ const statePath = join2(aiusageDir, "state.json");
904
+ if (!existsSync2(statePath)) return null;
905
+ try {
906
+ const content = readFileSync2(statePath, "utf-8");
907
+ return JSON.parse(content);
908
+ } catch {
909
+ return null;
910
+ }
911
+ }
912
+ function setState(aiusageDir, updates) {
913
+ const statePath = join2(aiusageDir, "state.json");
914
+ const current = getState(aiusageDir) ?? {
915
+ deviceInstanceId: randomUUID(),
916
+ lastSyncStatus: "ok"
917
+ };
918
+ const updated = { ...current, ...updates };
919
+ writeFileSync2(statePath, JSON.stringify(updated, null, 2), "utf-8");
920
+ }
921
+
922
+ // src/watermark.ts
923
+ import { readFileSync as readFileSync3, writeFileSync as writeFileSync3, existsSync as existsSync3 } from "fs";
924
+ function defaultFileData() {
925
+ return {
926
+ "claude-code": {},
927
+ "codex": {},
928
+ "openclaw": {},
929
+ "opencode": {}
930
+ };
931
+ }
932
+ var WatermarkManager = class {
933
+ data;
934
+ path;
935
+ constructor(path) {
936
+ this.path = path;
937
+ this.data = this.load();
938
+ }
939
+ load() {
940
+ if (!existsSync3(this.path)) {
941
+ return { files: defaultFileData() };
942
+ }
943
+ try {
944
+ const content = readFileSync3(this.path, "utf-8");
945
+ const parsed = JSON.parse(content);
946
+ if (parsed && typeof parsed === "object" && !("files" in parsed)) {
947
+ return { files: { ...defaultFileData(), ...parsed } };
948
+ }
949
+ return { files: parsed.files ?? defaultFileData(), opencode: parsed.opencode ?? null };
950
+ } catch {
951
+ return { files: defaultFileData() };
952
+ }
953
+ }
954
+ save() {
955
+ writeFileSync3(this.path, JSON.stringify(this.data, null, 2), "utf-8");
956
+ }
957
+ getEntry(tool, filePath) {
958
+ return this.data.files[tool]?.[filePath] ?? null;
959
+ }
960
+ setEntry(tool, filePath, entry) {
961
+ if (!this.data.files[tool]) {
962
+ this.data.files[tool] = {};
963
+ }
964
+ this.data.files[tool][filePath] = entry;
965
+ }
966
+ cleanup(existingFiles) {
967
+ const existingSet = new Set(existingFiles);
968
+ for (const tool of Object.keys(this.data.files)) {
969
+ for (const filePath of Object.keys(this.data.files[tool])) {
970
+ if (!existingSet.has(filePath)) {
971
+ delete this.data.files[tool][filePath];
972
+ }
973
+ }
974
+ }
975
+ }
976
+ getOpenCodeCursor() {
977
+ return this.data.opencode ?? null;
978
+ }
979
+ setOpenCodeCursor(cursor) {
980
+ this.data.opencode = cursor;
981
+ }
982
+ };
983
+
984
+ // src/commands/parse-opencode.ts
985
+ function runParseOpenCode(db, options) {
986
+ const { dbPath, device, deviceInstanceId, platform: platform4, now, cursor } = options;
987
+ const records = [];
988
+ const toolCalls = [];
989
+ const errors = [];
990
+ let lastCursor = null;
991
+ const messages = db.prepare(
992
+ `SELECT m.id, m.session_id, m.time_created, m.data
993
+ FROM message m
994
+ WHERE m.time_created > ? OR (m.time_created = ? AND m.id > ?)
995
+ ORDER BY m.time_created, m.id`
996
+ ).all(
997
+ cursor?.lastMessageCreatedAt ?? 0,
998
+ cursor?.lastMessageCreatedAt ?? 0,
999
+ cursor?.lastMessageId ?? ""
1000
+ );
1001
+ const partsStmt = db.prepare(
1002
+ `SELECT id, message_id, session_id, time_created, data
1003
+ FROM part
1004
+ WHERE message_id = ?
1005
+ ORDER BY time_created, id`
1006
+ );
1007
+ for (const message of messages) {
1008
+ lastCursor = {
1009
+ lastMessageCreatedAt: message.time_created,
1010
+ lastMessageId: message.id
1011
+ };
1012
+ let parsed;
1013
+ try {
1014
+ parsed = JSON.parse(message.data);
1015
+ } catch (e) {
1016
+ errors.push(`message ${message.id}: invalid JSON: ${e instanceof Error ? e.message : e}`);
1017
+ continue;
1018
+ }
1019
+ if (parsed.role !== "assistant" || !parsed.tokens) continue;
1020
+ const inputTokens = parsed.tokens.input ?? 0;
1021
+ const outputTokens = parsed.tokens.output ?? 0;
1022
+ const reasoningTokens = parsed.tokens.reasoning ?? 0;
1023
+ const cacheReadTokens = parsed.tokens.cache?.read ?? 0;
1024
+ const cacheWriteTokens = parsed.tokens.cache?.write ?? 0;
1025
+ if (inputTokens === 0 && outputTokens === 0 && reasoningTokens === 0 && cacheReadTokens === 0 && cacheWriteTokens === 0) continue;
1026
+ const model = parsed.modelID ?? "unknown";
1027
+ const provider = parsed.providerID ?? inferProvider(model);
1028
+ const ts = parsed.time?.created ?? message.time_created;
1029
+ const recordId = generateRecordId(deviceInstanceId, dbPath + ":" + message.id, message.time_created);
1030
+ const tokenArgs = { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, thinkingTokens: reasoningTokens };
1031
+ const calculatedCost = model !== "unknown" ? calculateCost(model, tokenArgs) : 0;
1032
+ const logCostValid = parsed.cost != null && parsed.cost > 0;
1033
+ const cost = logCostValid ? parsed.cost : calculatedCost;
1034
+ const costSource = logCostValid ? "log" : calculatedCost > 0 ? "pricing" : "unknown";
1035
+ const record = {
1036
+ id: recordId,
1037
+ ts,
1038
+ ingestedAt: now,
1039
+ updatedAt: now,
1040
+ lineOffset: 0,
1041
+ // DB-sourced records have no byte offset; use 0 as sentinel
1042
+ tool: "opencode",
1043
+ model,
1044
+ provider,
1045
+ inputTokens,
1046
+ outputTokens,
1047
+ cacheReadTokens,
1048
+ cacheWriteTokens,
1049
+ thinkingTokens: reasoningTokens,
1050
+ cost,
1051
+ costSource,
1052
+ sessionId: message.session_id,
1053
+ sourceFile: dbPath,
1054
+ device,
1055
+ deviceInstanceId,
1056
+ platform: platform4
1057
+ };
1058
+ records.push(record);
1059
+ const parts = partsStmt.all(message.id);
1060
+ let callIndex = 0;
1061
+ for (const part of parts) {
1062
+ let partParsed;
1063
+ try {
1064
+ partParsed = JSON.parse(part.data);
1065
+ } catch (e) {
1066
+ errors.push(`message ${message.id} part ${part.id}: invalid JSON: ${e instanceof Error ? e.message : e}`);
1067
+ continue;
1068
+ }
1069
+ if (partParsed.type === "tool" && partParsed.tool) {
1070
+ toolCalls.push({
1071
+ id: generateToolCallId(recordId, partParsed.tool, part.time_created, callIndex),
1072
+ recordId,
1073
+ name: partParsed.tool,
1074
+ ts: part.time_created,
1075
+ callIndex
1076
+ });
1077
+ callIndex++;
1078
+ }
1079
+ }
1080
+ }
1081
+ return { records, toolCalls, nextCursor: records.length > 0 ? lastCursor : null, errors };
1082
+ }
1083
+
1084
+ // src/commands/parse.ts
1085
+ function findJsonlFiles(dir) {
1086
+ const results = [];
1087
+ try {
1088
+ const entries = readdirSync(dir, { withFileTypes: true });
1089
+ for (const entry of entries) {
1090
+ const fullPath = join3(dir, entry.name);
1091
+ if (entry.isDirectory()) {
1092
+ results.push(...findJsonlFiles(fullPath));
1093
+ } else if (entry.isFile() && extname(entry.name) === ".jsonl") {
1094
+ results.push(fullPath);
1095
+ }
1096
+ }
1097
+ } catch {
1098
+ }
1099
+ return results;
1100
+ }
1101
+ function defaultOpenCodeDbPath() {
1102
+ const home = homedir2();
1103
+ const plat = platform2();
1104
+ if (plat === "win32") {
1105
+ const appData = process.env.APPDATA ?? join3(home, "AppData", "Roaming");
1106
+ return join3(appData, "opencode", "opencode.db");
1107
+ }
1108
+ if (plat === "darwin") {
1109
+ return join3(home, "Library", "Application Support", "opencode", "opencode.db");
1110
+ }
1111
+ const xdgDataHome = process.env.XDG_DATA_HOME ?? join3(home, ".local", "share");
1112
+ return join3(xdgDataHome, "opencode", "opencode.db");
1113
+ }
1114
+ function discoverLogFiles(sources) {
1115
+ const home = homedir2();
1116
+ const results = [];
1117
+ const claudeDir = sources?.["claude-code"] ?? join3(home, ".claude", "projects");
1118
+ if (existsSync4(claudeDir)) {
1119
+ const claudePaths = findJsonlFiles(claudeDir);
1120
+ if (claudePaths.length > 0) {
1121
+ results.push({ tool: "claude-code", paths: claudePaths });
1122
+ }
1123
+ }
1124
+ const codexDir = sources?.["codex"] ?? join3(home, ".codex", "sessions");
1125
+ if (existsSync4(codexDir)) {
1126
+ const codexPaths = findJsonlFiles(codexDir);
1127
+ if (codexPaths.length > 0) {
1128
+ results.push({ tool: "codex", paths: codexPaths });
1129
+ }
1130
+ }
1131
+ const openclawBase = sources?.["openclaw"] ?? join3(home, ".openclaw", "agents");
1132
+ if (existsSync4(openclawBase)) {
1133
+ let openclawPaths;
1134
+ if (sources?.["openclaw"]) {
1135
+ openclawPaths = findJsonlFiles(openclawBase).filter((p) => !p.includes(".checkpoint."));
1136
+ } else {
1137
+ openclawPaths = [];
1138
+ try {
1139
+ const agentEntries = readdirSync(openclawBase, { withFileTypes: true });
1140
+ for (const agentEntry of agentEntries) {
1141
+ if (!agentEntry.isDirectory()) continue;
1142
+ const sessionsDir = join3(openclawBase, agentEntry.name, "sessions");
1143
+ if (existsSync4(sessionsDir)) {
1144
+ openclawPaths.push(...findJsonlFiles(sessionsDir).filter((p) => !p.includes(".checkpoint.")));
1145
+ }
1146
+ }
1147
+ } catch {
1148
+ }
1149
+ }
1150
+ if (openclawPaths.length > 0) {
1151
+ results.push({ tool: "openclaw", paths: openclawPaths });
1152
+ }
1153
+ }
1154
+ return results;
1155
+ }
1156
+ function extractSessionId(filePath, tool) {
1157
+ if (tool === "claude-code") {
1158
+ const parts = filePath.split("/");
1159
+ const filename = parts[parts.length - 1];
1160
+ return filename.replace(".jsonl", "");
1161
+ }
1162
+ if (tool === "codex") {
1163
+ const filename = filePath.split("/").pop() ?? "";
1164
+ const match = filename.match(/rollout-(.+)\.jsonl$/);
1165
+ return match ? match[1] : filename.replace(".jsonl", "");
1166
+ }
1167
+ if (tool === "openclaw") {
1168
+ const filename = filePath.split("/").pop() ?? "";
1169
+ return filename.replace(".jsonl", "");
1170
+ }
1171
+ return "unknown";
1172
+ }
1173
+ async function runParse(db, filterTool, options) {
1174
+ const state = getState(AIUSAGE_DIR);
1175
+ const config = loadConfig();
1176
+ const device = config?.device || hostname2() || state?.deviceInstanceId?.slice(0, 8) || "unknown";
1177
+ const deviceInstanceId = state?.deviceInstanceId ?? "unknown";
1178
+ const devicePlatform = config?.platform;
1179
+ const watermarkPath = join3(AIUSAGE_DIR, "watermark.json");
1180
+ const wm = new WatermarkManager(watermarkPath);
1181
+ const toolPaths = discoverLogFiles(config?.sources);
1182
+ const aggregator = new Aggregator();
1183
+ let parsedCount = 0;
1184
+ let toolCallCount = 0;
1185
+ const errors = [];
1186
+ for (const { tool, paths } of toolPaths) {
1187
+ if (filterTool && tool !== filterTool) continue;
1188
+ for (const filePath of paths) {
1189
+ try {
1190
+ const stat2 = statSync(filePath);
1191
+ const entry = wm.getEntry(tool, filePath);
1192
+ const offset = entry?.offset ?? 0;
1193
+ if (offset >= stat2.size) continue;
1194
+ const content = readFileSync4(filePath, "utf-8");
1195
+ const lines = content.split("\n");
1196
+ let byteOffset = 0;
1197
+ const sessionId = extractSessionId(filePath, tool);
1198
+ for (const line of lines) {
1199
+ if (!line.trim()) {
1200
+ byteOffset += Buffer.byteLength(line, "utf-8") + 1;
1201
+ continue;
1202
+ }
1203
+ if (byteOffset < offset) {
1204
+ byteOffset += Buffer.byteLength(line, "utf-8") + 1;
1205
+ continue;
1206
+ }
1207
+ const context = aggregator.createContext({
1208
+ tool,
1209
+ sourceFile: filePath,
1210
+ lineOffset: byteOffset,
1211
+ sessionId,
1212
+ device,
1213
+ deviceInstanceId,
1214
+ platform: devicePlatform
1215
+ });
1216
+ const result = aggregator.parseLine(line, context);
1217
+ if (result) {
1218
+ insertRecord(db, result.record);
1219
+ parsedCount++;
1220
+ for (const tc of result.toolCalls) {
1221
+ insertToolCall(db, tc);
1222
+ toolCallCount++;
1223
+ }
1224
+ }
1225
+ byteOffset += Buffer.byteLength(line, "utf-8") + 1;
1226
+ }
1227
+ const orphanResults = aggregator.finalize();
1228
+ for (const result of orphanResults) {
1229
+ for (const tc of result.toolCalls) {
1230
+ insertToolCall(db, tc);
1231
+ toolCallCount++;
1232
+ }
1233
+ }
1234
+ wm.setEntry(tool, filePath, {
1235
+ offset: stat2.size,
1236
+ size: stat2.size,
1237
+ mtime: stat2.mtimeMs
1238
+ });
1239
+ wm.save();
1240
+ } catch (e) {
1241
+ errors.push(`${filePath}: ${e instanceof Error ? e.message : e}`);
1242
+ }
1243
+ }
1244
+ }
1245
+ const openCodeDbPath = options?.openCodeDbPath ?? config?.sources?.["opencode"] ?? defaultOpenCodeDbPath();
1246
+ if ((!filterTool || filterTool === "opencode") && existsSync4(openCodeDbPath)) {
1247
+ try {
1248
+ const openCodeDb = new Database(openCodeDbPath, { readonly: true });
1249
+ try {
1250
+ const result = runParseOpenCode(openCodeDb, {
1251
+ dbPath: openCodeDbPath,
1252
+ device,
1253
+ deviceInstanceId,
1254
+ platform: devicePlatform,
1255
+ now: Date.now(),
1256
+ cursor: wm.getOpenCodeCursor()
1257
+ });
1258
+ for (const record of result.records) insertRecord(db, record);
1259
+ for (const tc of result.toolCalls) insertToolCall(db, tc);
1260
+ if (result.nextCursor) {
1261
+ wm.setOpenCodeCursor(result.nextCursor);
1262
+ wm.save();
1263
+ }
1264
+ parsedCount += result.records.length;
1265
+ toolCallCount += result.toolCalls.length;
1266
+ errors.push(...result.errors);
1267
+ } finally {
1268
+ openCodeDb.close();
1269
+ }
1270
+ } catch (e) {
1271
+ errors.push(`${openCodeDbPath}: ${e instanceof Error ? e.message : e}`);
1272
+ }
1273
+ }
1274
+ if (deviceInstanceId !== "unknown") {
1275
+ db.prepare(
1276
+ `UPDATE records SET device_instance_id = ?, device = ? WHERE device_instance_id = 'unknown'`
1277
+ ).run(deviceInstanceId, device);
1278
+ }
1279
+ if (devicePlatform) {
1280
+ db.prepare(
1281
+ `UPDATE records SET platform = ? WHERE platform = '' AND source_file NOT LIKE 'synced/%'`
1282
+ ).run(devicePlatform);
1283
+ }
1284
+ return { parsedCount, toolCallCount, errors };
1285
+ }
1286
+
1287
+ // src/commands/sync.ts
1288
+ import { join as join5 } from "path";
1289
+
1290
+ // src/db/synced-records.ts
1291
+ function insertSyncedRecord(db, record) {
1292
+ db.prepare(`
1293
+ INSERT INTO synced_records (
1294
+ id, ts, tool, model, provider, input_tokens, output_tokens,
1295
+ cache_read_tokens, cache_write_tokens, thinking_tokens,
1296
+ cost, cost_source, session_key, device, device_instance_id, platform, updated_at
1297
+ ) VALUES (
1298
+ @id, @ts, @tool, @model, @provider, @inputTokens, @outputTokens,
1299
+ @cacheReadTokens, @cacheWriteTokens, @thinkingTokens,
1300
+ @cost, @costSource, @sessionKey, @device, @deviceInstanceId, @platform, @updatedAt
1301
+ )
1302
+ ON CONFLICT(id) DO UPDATE SET
1303
+ ts = excluded.ts,
1304
+ tool = excluded.tool,
1305
+ model = excluded.model,
1306
+ provider = excluded.provider,
1307
+ input_tokens = excluded.input_tokens,
1308
+ output_tokens = excluded.output_tokens,
1309
+ cache_read_tokens = excluded.cache_read_tokens,
1310
+ cache_write_tokens = excluded.cache_write_tokens,
1311
+ thinking_tokens = excluded.thinking_tokens,
1312
+ cost = excluded.cost,
1313
+ cost_source = excluded.cost_source,
1314
+ session_key = excluded.session_key,
1315
+ device = excluded.device,
1316
+ device_instance_id = excluded.device_instance_id,
1317
+ platform = excluded.platform,
1318
+ updated_at = excluded.updated_at
1319
+ WHERE excluded.updated_at > synced_records.updated_at
1320
+ `).run({
1321
+ id: record.id,
1322
+ ts: record.ts,
1323
+ tool: record.tool,
1324
+ model: record.model,
1325
+ provider: record.provider,
1326
+ inputTokens: record.inputTokens,
1327
+ outputTokens: record.outputTokens,
1328
+ cacheReadTokens: record.cacheReadTokens,
1329
+ cacheWriteTokens: record.cacheWriteTokens,
1330
+ thinkingTokens: record.thinkingTokens,
1331
+ cost: record.cost,
1332
+ costSource: record.costSource,
1333
+ sessionKey: record.sessionKey,
1334
+ device: record.device,
1335
+ deviceInstanceId: record.deviceInstanceId,
1336
+ platform: record.platform ?? "",
1337
+ updatedAt: record.updatedAt
1338
+ });
1339
+ }
1340
+ function mergeSyncedRecordsIntoRecords(db) {
1341
+ const now = Date.now();
1342
+ const newRows = db.prepare(`
1343
+ SELECT sr.* FROM synced_records sr
1344
+ LEFT JOIN records r ON sr.id = r.id
1345
+ WHERE r.id IS NULL
1346
+ `).all();
1347
+ if (newRows.length === 0) return 0;
1348
+ const insertStmt = db.prepare(`
1349
+ INSERT OR IGNORE INTO records (
1350
+ id, ts, ingested_at, synced_at, updated_at, line_offset,
1351
+ tool, model, provider, input_tokens, output_tokens,
1352
+ cache_read_tokens, cache_write_tokens, thinking_tokens,
1353
+ cost, cost_source, session_id, source_file, device, device_instance_id
1354
+ ) VALUES (
1355
+ @id, @ts, @ingestedAt, @syncedAt, @updatedAt, 0,
1356
+ @tool, @model, @provider, @inputTokens, @outputTokens,
1357
+ @cacheReadTokens, @cacheWriteTokens, @thinkingTokens,
1358
+ @cost, @costSource, @sessionId, @sourceFile, @device, @deviceInstanceId
1359
+ )
1360
+ `);
1361
+ const tx = db.transaction((rows) => {
1362
+ for (const row of rows) {
1363
+ insertStmt.run({
1364
+ id: row.id,
1365
+ ts: row.ts,
1366
+ ingestedAt: now,
1367
+ syncedAt: now,
1368
+ updatedAt: row.updated_at,
1369
+ tool: row.tool,
1370
+ model: row.model,
1371
+ provider: row.provider,
1372
+ inputTokens: row.input_tokens,
1373
+ outputTokens: row.output_tokens,
1374
+ cacheReadTokens: row.cache_read_tokens,
1375
+ cacheWriteTokens: row.cache_write_tokens,
1376
+ thinkingTokens: row.thinking_tokens,
1377
+ cost: row.cost,
1378
+ costSource: row.cost_source,
1379
+ sessionId: row.session_key,
1380
+ sourceFile: `synced/${row.device_instance_id}`,
1381
+ device: row.device,
1382
+ deviceInstanceId: row.device_instance_id
1383
+ });
1384
+ }
1385
+ });
1386
+ tx(newRows);
1387
+ return newRows.length;
1388
+ }
1389
+
1390
+ // src/sync/mapper.ts
1391
+ function mapStatsRecordToSyncRecord(record) {
1392
+ const id = record.sourceFile.startsWith("synced/") ? record.id : record.tool === "opencode" ? record.id : generateSyncRecordId(record.deviceInstanceId, record.sourceFile, record.lineOffset);
1393
+ return {
1394
+ id,
1395
+ ts: record.ts,
1396
+ tool: record.tool,
1397
+ model: record.model,
1398
+ provider: record.provider,
1399
+ inputTokens: record.inputTokens,
1400
+ outputTokens: record.outputTokens,
1401
+ cacheReadTokens: record.cacheReadTokens,
1402
+ cacheWriteTokens: record.cacheWriteTokens,
1403
+ thinkingTokens: record.thinkingTokens,
1404
+ cost: record.cost,
1405
+ costSource: record.costSource,
1406
+ sessionKey: generateSessionKey(record.device, record.sessionId),
1407
+ device: record.device,
1408
+ deviceInstanceId: record.deviceInstanceId,
1409
+ platform: record.platform,
1410
+ updatedAt: record.updatedAt
1411
+ };
1412
+ }
1413
+
1414
+ // src/sync/index.ts
1415
+ function getSyncPath(ts, deviceInstanceId) {
1416
+ const d = new Date(ts);
1417
+ const date = `${d.getUTCFullYear()}/${String(d.getUTCMonth() + 1).padStart(2, "0")}/${String(d.getUTCDate()).padStart(2, "0")}`;
1418
+ return `${deviceInstanceId}/${date}.ndjson`;
1419
+ }
1420
+ function parseNdjson(content) {
1421
+ const records = /* @__PURE__ */ new Map();
1422
+ for (const line of content.split("\n").filter(Boolean)) {
1423
+ try {
1424
+ const record = JSON.parse(line);
1425
+ if (typeof record.ts === "string") {
1426
+ record.ts = new Date(record.ts).getTime();
1427
+ }
1428
+ if (typeof record.updatedAt === "string") {
1429
+ record.updatedAt = new Date(record.updatedAt).getTime();
1430
+ }
1431
+ records.set(record.id, record);
1432
+ } catch {
1433
+ }
1434
+ }
1435
+ return records;
1436
+ }
1437
+ function mergeRecords(existing, newRecords) {
1438
+ let changed = 0;
1439
+ for (const record of newRecords) {
1440
+ const prev = existing.get(record.id);
1441
+ if (!prev || record.updatedAt > prev.updatedAt) {
1442
+ existing.set(record.id, record);
1443
+ changed++;
1444
+ }
1445
+ }
1446
+ return changed;
1447
+ }
1448
+ var SyncOrchestrator = class {
1449
+ db;
1450
+ backend;
1451
+ options;
1452
+ constructor(db, backend, options) {
1453
+ this.db = db;
1454
+ this.backend = backend;
1455
+ this.options = options;
1456
+ }
1457
+ async sync() {
1458
+ if (!this.options.consentVerified) {
1459
+ return { status: "blocked_pending_consent", pulledCount: 0, uploadedCount: 0, mergedCount: 0 };
1460
+ }
1461
+ try {
1462
+ await this.backend.prepare?.();
1463
+ const pulledCount = await this.pull();
1464
+ this.options.onProgress?.({ phase: "merging", pulledCount });
1465
+ const mergedCount = mergeSyncedRecordsIntoRecords(this.db);
1466
+ const uploadedCount = await this.upload();
1467
+ await this.backend.flush?.();
1468
+ this.options.onProgress?.({ phase: "finalizing", pulledCount, uploadedCount });
1469
+ return { status: "ok", pulledCount, uploadedCount, mergedCount };
1470
+ } catch (error) {
1471
+ return {
1472
+ status: "failed",
1473
+ pulledCount: 0,
1474
+ uploadedCount: 0,
1475
+ mergedCount: 0,
1476
+ error: error instanceof Error ? error.message : "Unknown error"
1477
+ };
1478
+ }
1479
+ }
1480
+ async pull() {
1481
+ const allPaths = await this.backend.listFiles();
1482
+ const localDevicePrefix = `${this.options.deviceInstanceId}/`;
1483
+ const paths = allPaths.filter((p) => !p.startsWith(localDevicePrefix));
1484
+ this.options.onProgress?.({
1485
+ phase: "pulling",
1486
+ completedFiles: 0,
1487
+ totalFiles: paths.length,
1488
+ pulledCount: 0
1489
+ });
1490
+ if (paths.length === 0) return 0;
1491
+ let totalPulled = 0;
1492
+ for (const [index, path] of paths.entries()) {
1493
+ this.options.onProgress?.({
1494
+ phase: "pulling",
1495
+ currentPath: path,
1496
+ completedFiles: index,
1497
+ totalFiles: paths.length,
1498
+ pulledCount: totalPulled
1499
+ });
1500
+ const content = await this.backend.readFile(path);
1501
+ if (!content) continue;
1502
+ for (const line of content.split("\n").filter(Boolean)) {
1503
+ try {
1504
+ const record = JSON.parse(line);
1505
+ if (typeof record.ts === "string") {
1506
+ record.ts = new Date(record.ts).getTime();
1507
+ }
1508
+ if (typeof record.updatedAt === "string") {
1509
+ record.updatedAt = new Date(record.updatedAt).getTime();
1510
+ }
1511
+ insertSyncedRecord(this.db, record);
1512
+ totalPulled++;
1513
+ } catch {
1514
+ }
1515
+ }
1516
+ this.options.onProgress?.({
1517
+ phase: "pulling",
1518
+ currentPath: path,
1519
+ completedFiles: index + 1,
1520
+ totalFiles: paths.length,
1521
+ pulledCount: totalPulled
1522
+ });
1523
+ }
1524
+ return totalPulled;
1525
+ }
1526
+ async uploadFile(path, localSyncRecords) {
1527
+ const existingContent = await this.backend.readFile(path);
1528
+ const existing = existingContent ? parseNdjson(existingContent) : /* @__PURE__ */ new Map();
1529
+ const changedCount = mergeRecords(existing, localSyncRecords);
1530
+ if (changedCount === 0) return 0;
1531
+ const content = Array.from(existing.values()).map((r) => JSON.stringify(r)).join("\n") + "\n";
1532
+ await this.backend.writeFile(path, content);
1533
+ return changedCount;
1534
+ }
1535
+ async upload() {
1536
+ const unsynced = getUnsyncedRecords(this.db);
1537
+ if (unsynced.length === 0) return 0;
1538
+ const byPath = /* @__PURE__ */ new Map();
1539
+ for (const record of unsynced) {
1540
+ const path = getSyncPath(record.ts, this.options.deviceInstanceId);
1541
+ if (!byPath.has(path)) byPath.set(path, []);
1542
+ byPath.get(path).push(record);
1543
+ }
1544
+ let totalUploaded = 0;
1545
+ const uploads = Array.from(byPath.entries());
1546
+ this.options.onProgress?.({
1547
+ phase: "uploading",
1548
+ completedFiles: 0,
1549
+ totalFiles: uploads.length,
1550
+ uploadedCount: 0
1551
+ });
1552
+ for (const [index, [path, localRecords]] of uploads.entries()) {
1553
+ this.options.onProgress?.({
1554
+ phase: "uploading",
1555
+ currentPath: path,
1556
+ completedFiles: index,
1557
+ totalFiles: uploads.length,
1558
+ uploadedCount: totalUploaded
1559
+ });
1560
+ const localSyncRecords = localRecords.map(mapStatsRecordToSyncRecord);
1561
+ const changedCount = await this.uploadFile(path, localSyncRecords);
1562
+ totalUploaded += changedCount;
1563
+ this.options.onProgress?.({
1564
+ phase: "uploading",
1565
+ currentPath: path,
1566
+ completedFiles: index + 1,
1567
+ totalFiles: uploads.length,
1568
+ uploadedCount: totalUploaded
1569
+ });
1570
+ }
1571
+ const syncedAt = Date.now();
1572
+ const updateStmt = this.db.prepare("UPDATE records SET synced_at = ? WHERE id = ?");
1573
+ const tx = this.db.transaction((ids) => {
1574
+ for (const id of ids) updateStmt.run(syncedAt, id);
1575
+ });
1576
+ tx(unsynced.map((r) => r.id));
1577
+ return totalUploaded;
1578
+ }
1579
+ };
1580
+
1581
+ // src/sync/consent.ts
1582
+ import { createHash } from "crypto";
1583
+ function generateConsentFingerprint(config) {
1584
+ const input = [
1585
+ config.backend,
1586
+ config.target,
1587
+ config.endpoint,
1588
+ config.region,
1589
+ config.fields.join(","),
1590
+ config.operations.join(","),
1591
+ config.schemaVersion
1592
+ ].join("|");
1593
+ return createHash("sha256").update(input).digest("hex").slice(0, 16);
1594
+ }
1595
+ function verifyConsent(storedFingerprint, currentConfig) {
1596
+ const currentFingerprint = generateConsentFingerprint(currentConfig);
1597
+ return storedFingerprint === currentFingerprint;
1598
+ }
1599
+
1600
+ // src/sync/git.ts
1601
+ import { execFile } from "child_process";
1602
+ import { promisify } from "util";
1603
+ import { readFile, writeFile, mkdir, readdir, stat } from "fs/promises";
1604
+ import { join as join4 } from "path";
1605
+ var exec = promisify(execFile);
1606
+ var GitSyncBackend = class _GitSyncBackend {
1607
+ repo;
1608
+ token;
1609
+ cacheDir;
1610
+ dataDir;
1611
+ constructor(config) {
1612
+ this.repo = config.repo;
1613
+ this.token = config.token;
1614
+ this.cacheDir = config.cacheDir;
1615
+ this.dataDir = join4(config.cacheDir, "data");
1616
+ }
1617
+ get remoteUrl() {
1618
+ return `https://x-access-token:${this.token}@github.com/${this.repo}.git`;
1619
+ }
1620
+ async git(args, cwd) {
1621
+ const { stdout } = await exec("git", args, {
1622
+ cwd: cwd ?? this.cacheDir,
1623
+ timeout: 6e4,
1624
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
1625
+ });
1626
+ return stdout.trim();
1627
+ }
1628
+ /** Clone or pull the repo to get latest remote state */
1629
+ async prepare() {
1630
+ try {
1631
+ await stat(join4(this.cacheDir, ".git"));
1632
+ await this.git(["fetch", "origin", "main", "--depth=1"]);
1633
+ await this.git(["reset", "--hard", "origin/main"]);
1634
+ } catch {
1635
+ await mkdir(this.cacheDir, { recursive: true });
1636
+ await exec("git", ["clone", "--depth=1", this.remoteUrl, this.cacheDir], {
1637
+ timeout: 12e4,
1638
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
1639
+ });
1640
+ }
1641
+ }
1642
+ async readFile(path) {
1643
+ try {
1644
+ const fullPath = join4(this.dataDir, path);
1645
+ return await readFile(fullPath, "utf-8");
1646
+ } catch {
1647
+ return null;
1648
+ }
1649
+ }
1650
+ async writeFile(path, content) {
1651
+ const fullPath = join4(this.dataDir, path);
1652
+ await mkdir(join4(fullPath, ".."), { recursive: true });
1653
+ await writeFile(fullPath, content, "utf-8");
1654
+ }
1655
+ async listFiles() {
1656
+ try {
1657
+ return await this.walkDir(this.dataDir, "");
1658
+ } catch {
1659
+ return [];
1660
+ }
1661
+ }
1662
+ static MAX_PUSH_RETRIES = 3;
1663
+ /** Commit and push all changes. Returns true if anything was pushed. */
1664
+ async flush() {
1665
+ const status = await this.git(["status", "--porcelain"]);
1666
+ if (!status) return false;
1667
+ await this.git(["add", "data/"]);
1668
+ await this.git(["commit", "-m", `sync ${(/* @__PURE__ */ new Date()).toISOString()}`]);
1669
+ for (let attempt = 1; attempt <= _GitSyncBackend.MAX_PUSH_RETRIES; attempt++) {
1670
+ try {
1671
+ await this.git(["push", "origin", "main"]);
1672
+ return true;
1673
+ } catch (error) {
1674
+ if (attempt >= _GitSyncBackend.MAX_PUSH_RETRIES) throw error;
1675
+ await this.git(["pull", "--rebase", "origin", "main"]);
1676
+ }
1677
+ }
1678
+ return false;
1679
+ }
1680
+ async walkDir(dir, prefix) {
1681
+ const entries = await readdir(dir, { withFileTypes: true });
1682
+ const files = [];
1683
+ for (const entry of entries) {
1684
+ const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
1685
+ if (entry.isDirectory()) {
1686
+ files.push(...await this.walkDir(join4(dir, entry.name), relPath));
1687
+ } else if (entry.name.endsWith(".ndjson")) {
1688
+ files.push(relPath);
1689
+ }
1690
+ }
1691
+ return files.sort();
1692
+ }
1693
+ };
1694
+
1695
+ // src/sync/s3.ts
1696
+ import { S3Client, GetObjectCommand, PutObjectCommand, HeadObjectCommand, ListObjectsV2Command } from "@aws-sdk/client-s3";
1697
+ var S3SyncBackend = class {
1698
+ client;
1699
+ bucket;
1700
+ prefix;
1701
+ constructor(config) {
1702
+ this.bucket = config.bucket;
1703
+ this.prefix = config.prefix.replace(/^\/+/, "").replace(/\/?$/, "/");
1704
+ this.client = new S3Client({
1705
+ region: config.region ?? "auto",
1706
+ ...config.endpoint ? { endpoint: config.endpoint } : {},
1707
+ credentials: {
1708
+ accessKeyId: config.accessKeyId,
1709
+ secretAccessKey: config.secretAccessKey
1710
+ }
1711
+ });
1712
+ }
1713
+ getObjectKey(path) {
1714
+ return `${this.prefix}${path}`;
1715
+ }
1716
+ async readFile(path) {
1717
+ const key = this.getObjectKey(path);
1718
+ try {
1719
+ const command = new GetObjectCommand({
1720
+ Bucket: this.bucket,
1721
+ Key: key
1722
+ });
1723
+ const response = await this.client.send(command);
1724
+ if (!response.Body) return null;
1725
+ return await response.Body.transformToString("utf-8");
1726
+ } catch (error) {
1727
+ if (error.name === "NoSuchKey" || error.$metadata?.httpStatusCode === 404) {
1728
+ return null;
1729
+ }
1730
+ throw error;
1731
+ }
1732
+ }
1733
+ async writeFile(path, content) {
1734
+ const key = this.getObjectKey(path);
1735
+ const command = new PutObjectCommand({
1736
+ Bucket: this.bucket,
1737
+ Key: key,
1738
+ Body: content,
1739
+ ContentType: "application/x-ndjson"
1740
+ });
1741
+ await this.client.send(command);
1742
+ }
1743
+ async listFiles() {
1744
+ const files = [];
1745
+ let continuationToken;
1746
+ do {
1747
+ const command = new ListObjectsV2Command({
1748
+ Bucket: this.bucket,
1749
+ Prefix: `${this.prefix}data/`,
1750
+ ContinuationToken: continuationToken
1751
+ });
1752
+ const response = await this.client.send(command);
1753
+ if (response.Contents) {
1754
+ for (const obj of response.Contents) {
1755
+ const key = obj.Key;
1756
+ const relPath = key.slice(`${this.prefix}data/`.length);
1757
+ if (relPath.endsWith(".ndjson")) {
1758
+ files.push(relPath);
1759
+ }
1760
+ }
1761
+ }
1762
+ continuationToken = response.IsTruncated ? response.NextContinuationToken : void 0;
1763
+ } while (continuationToken);
1764
+ return files.sort();
1765
+ }
1766
+ async fileExists(path) {
1767
+ const key = this.getObjectKey(path);
1768
+ try {
1769
+ const command = new HeadObjectCommand({
1770
+ Bucket: this.bucket,
1771
+ Key: key
1772
+ });
1773
+ await this.client.send(command);
1774
+ return true;
1775
+ } catch (error) {
1776
+ if (error.name === "NotFound" || error.$metadata?.httpStatusCode === 404) {
1777
+ return false;
1778
+ }
1779
+ throw error;
1780
+ }
1781
+ }
1782
+ };
1783
+
1784
+ // src/commands/sync.ts
1785
+ function createBackend(config) {
1786
+ const sync = config.sync;
1787
+ if (!sync) return null;
1788
+ if (sync.backend === "github") {
1789
+ if (!sync.repo) return null;
1790
+ const token = loadCredential(`github/${sync.repo}/token`);
1791
+ if (!token) return null;
1792
+ return new GitSyncBackend({
1793
+ repo: sync.repo,
1794
+ token,
1795
+ cacheDir: join5(AIUSAGE_DIR, "sync-repo")
1796
+ });
1797
+ }
1798
+ if (sync.backend === "s3") {
1799
+ if (!sync.bucket) return null;
1800
+ const accessKeyId = loadCredential(`s3/${sync.bucket}/accessKeyId`);
1801
+ const secretAccessKey = loadCredential(`s3/${sync.bucket}/secretAccessKey`);
1802
+ if (!accessKeyId || !secretAccessKey) return null;
1803
+ return new S3SyncBackend({
1804
+ bucket: sync.bucket,
1805
+ prefix: sync.prefix ?? "aiusage/",
1806
+ accessKeyId,
1807
+ secretAccessKey,
1808
+ endpoint: sync.endpoint,
1809
+ region: sync.region
1810
+ });
1811
+ }
1812
+ return null;
1813
+ }
1814
+ function failedResult(error) {
1815
+ return { status: "failed", pulledCount: 0, uploadedCount: 0, mergedCount: 0, error };
1816
+ }
1817
+ function blockedResult(error) {
1818
+ return { status: "blocked_pending_consent", pulledCount: 0, uploadedCount: 0, mergedCount: 0, error };
1819
+ }
1820
+ async function runSync(db, options) {
1821
+ const config = loadConfig();
1822
+ if (!config?.sync) {
1823
+ return failedResult('Cloud sync not configured. Run "aiusage init" first.');
1824
+ }
1825
+ const state = getState(AIUSAGE_DIR);
1826
+ if (!state?.syncConsentAt || !state?.syncConsentTarget) {
1827
+ setState(AIUSAGE_DIR, { lastSyncStatus: "blocked_pending_consent" });
1828
+ return blockedResult('Sync consent not provided. Run "aiusage init" to approve.');
1829
+ }
1830
+ const consentConfig = buildConsentConfig(config);
1831
+ if (!consentConfig) {
1832
+ return failedResult("Invalid sync configuration.");
1833
+ }
1834
+ if (!verifyConsent(state.syncConsentTarget, consentConfig)) {
1835
+ setState(AIUSAGE_DIR, { lastSyncStatus: "blocked_pending_consent" });
1836
+ return blockedResult('Sync configuration has changed since last approval. Run "aiusage init" to re-approve.');
1837
+ }
1838
+ const backend = createBackend(config);
1839
+ if (!backend) {
1840
+ return failedResult("Could not create sync backend. Check credentials.");
1841
+ }
1842
+ const orchestrator = new SyncOrchestrator(db, backend, {
1843
+ deviceInstanceId: state.deviceInstanceId,
1844
+ consentVerified: true,
1845
+ onProgress: options?.onProgress
1846
+ });
1847
+ const startedAt = Date.now();
1848
+ const result = await orchestrator.sync();
1849
+ const now = Date.now();
1850
+ setState(AIUSAGE_DIR, {
1851
+ lastSyncAt: now,
1852
+ lastSyncStatus: result.status === "ok" ? "ok" : result.status,
1853
+ lastSyncError: result.error,
1854
+ lastSyncTarget: `${config.sync.backend}:${config.sync.repo ?? config.sync.bucket}`,
1855
+ lastSyncUploaded: result.uploadedCount,
1856
+ lastSyncPulled: result.pulledCount,
1857
+ lastSyncDurationMs: now - startedAt
1858
+ });
1859
+ return result;
1860
+ }
1861
+
1862
+ // src/sync/runtime.ts
1863
+ var SyncRuntimeController = class {
1864
+ runSyncFn;
1865
+ getPersistedState;
1866
+ current = { isRunning: false };
1867
+ constructor(options) {
1868
+ this.runSyncFn = options.runSync;
1869
+ this.getPersistedState = options.getPersistedState;
1870
+ }
1871
+ start() {
1872
+ if (this.current.isRunning) {
1873
+ return {
1874
+ accepted: false,
1875
+ alreadyRunning: true,
1876
+ status: this.getStatus()
1877
+ };
1878
+ }
1879
+ this.current = {
1880
+ isRunning: true,
1881
+ startedAt: Date.now()
1882
+ };
1883
+ setImmediate(() => {
1884
+ void this.runInBackground();
1885
+ });
1886
+ return {
1887
+ accepted: true,
1888
+ alreadyRunning: false,
1889
+ status: this.getStatus()
1890
+ };
1891
+ }
1892
+ getStatus() {
1893
+ const persisted = this.getPersistedState();
1894
+ return {
1895
+ ...this.current,
1896
+ lastSyncAt: persisted?.lastSyncAt,
1897
+ lastSyncStatus: persisted?.lastSyncStatus,
1898
+ lastSyncError: persisted?.lastSyncError,
1899
+ lastSyncTarget: persisted?.lastSyncTarget,
1900
+ lastSyncUploaded: persisted?.lastSyncUploaded,
1901
+ lastSyncPulled: persisted?.lastSyncPulled,
1902
+ lastSyncDurationMs: persisted?.lastSyncDurationMs
1903
+ };
1904
+ }
1905
+ async runInBackground() {
1906
+ try {
1907
+ await this.runSyncFn({
1908
+ onProgress: (progress) => {
1909
+ this.current = {
1910
+ ...this.current,
1911
+ isRunning: true,
1912
+ phase: progress.phase,
1913
+ currentPath: progress.currentPath,
1914
+ completedFiles: progress.completedFiles,
1915
+ totalFiles: progress.totalFiles,
1916
+ pulledCount: progress.pulledCount,
1917
+ uploadedCount: progress.uploadedCount
1918
+ };
1919
+ }
1920
+ });
1921
+ } finally {
1922
+ this.current = { isRunning: false };
1923
+ }
1924
+ }
1925
+ };
1926
+
1927
+ // src/commands/serve.ts
1928
+ var MIME_TYPES = {
1929
+ ".html": "text/html",
1930
+ ".js": "application/javascript",
1931
+ ".css": "text/css",
1932
+ ".json": "application/json",
1933
+ ".png": "image/png",
1934
+ ".jpg": "image/jpeg",
1935
+ ".svg": "image/svg+xml",
1936
+ ".ico": "image/x-icon",
1937
+ ".woff": "font/woff",
1938
+ ".woff2": "font/woff2"
1939
+ };
1940
+ function serve(options) {
1941
+ const config = loadConfig();
1942
+ if (config?.priceOverrides) {
1943
+ for (const [model, entry] of Object.entries(config.priceOverrides)) {
1944
+ setPriceOverride(model, entry);
1945
+ }
1946
+ }
1947
+ const syncRuntime = new SyncRuntimeController({
1948
+ runSync: (runtimeOptions) => runSync(options.db, runtimeOptions).then(() => void 0),
1949
+ getPersistedState: () => getState(AIUSAGE_DIR)
1950
+ });
1951
+ const apiServer = createApiServer(options.db, {
1952
+ currentDeviceInstanceId: getState(AIUSAGE_DIR)?.deviceInstanceId,
1953
+ onRefresh: () => runParse(options.db),
1954
+ onSyncStart: () => syncRuntime.start(),
1955
+ getSyncStatus: () => syncRuntime.getStatus()
1956
+ });
1957
+ const webBuildDir = join6(dirname(fileURLToPath(import.meta.url)), "web");
1958
+ const server = http2.createServer(async (req, res) => {
1959
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
1960
+ if (url.pathname.startsWith("/api/")) {
1961
+ apiServer.emit("request", req, res);
1962
+ return;
1963
+ }
1964
+ if (existsSync5(webBuildDir)) {
1965
+ let filePath = join6(webBuildDir, url.pathname);
1966
+ try {
1967
+ if (statSync2(filePath).isDirectory()) {
1968
+ filePath = join6(filePath, "index.html");
1969
+ }
1970
+ } catch {
1971
+ }
1972
+ if (!existsSync5(filePath)) {
1973
+ filePath = join6(webBuildDir, "index.html");
1974
+ }
1975
+ try {
1976
+ const content = readFileSync5(filePath);
1977
+ const ext = extname2(filePath);
1978
+ const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
1979
+ res.writeHead(200, { "Content-Type": contentType });
1980
+ res.end(content);
1981
+ return;
1982
+ } catch {
1983
+ }
1984
+ }
1985
+ res.writeHead(404, { "Content-Type": "application/json" });
1986
+ res.end(JSON.stringify({ error: { code: "NOT_FOUND", message: "Web dashboard not found. Reinstall the package: npm install -g aiusage" } }));
1987
+ });
1988
+ server.listen(options.port, "0.0.0.0", () => {
1989
+ console.log(`aiusage serve listening on http://localhost:${options.port}`);
1990
+ });
1991
+ process.on("SIGINT", () => {
1992
+ console.log("\nShutting down...");
1993
+ server.close(() => {
1994
+ process.exit(0);
1995
+ });
1996
+ });
1997
+ process.on("SIGTERM", () => {
1998
+ server.close(() => {
1999
+ process.exit(0);
2000
+ });
2001
+ });
2002
+ }
2003
+
2004
+ // src/commands/init.ts
2005
+ import { platform as platform3 } from "os";
2006
+ function runInit(options) {
2007
+ const existingConfig = loadConfig();
2008
+ if (options.backend === "skip" || !options.backend) {
2009
+ const config = {
2010
+ device: options.device ?? existingConfig?.device,
2011
+ platform: existingConfig?.platform ?? platform3(),
2012
+ retentionDays: existingConfig?.retentionDays ?? 180,
2013
+ parseInterval: existingConfig?.parseInterval ?? 60,
2014
+ dashboardPollInterval: existingConfig?.dashboardPollInterval ?? 30,
2015
+ credentials: existingConfig?.credentials
2016
+ };
2017
+ saveConfig(config);
2018
+ return { success: true, message: "Configuration saved without cloud sync." };
2019
+ }
2020
+ if (options.backend === "github") {
2021
+ if (!options.repo) {
2022
+ return { success: false, message: "GitHub repository is required (format: username/repo-name)." };
2023
+ }
2024
+ if (!options.token) {
2025
+ return { success: false, message: "GitHub Personal Access Token is required." };
2026
+ }
2027
+ const config = {
2028
+ sync: {
2029
+ backend: "github",
2030
+ repo: options.repo,
2031
+ credentialRef: `keychain://aiusage/github/${options.repo}`
2032
+ },
2033
+ device: options.device ?? existingConfig?.device,
2034
+ platform: existingConfig?.platform ?? platform3(),
2035
+ retentionDays: existingConfig?.retentionDays ?? 180,
2036
+ parseInterval: existingConfig?.parseInterval ?? 60,
2037
+ dashboardPollInterval: existingConfig?.dashboardPollInterval ?? 30,
2038
+ credentials: {
2039
+ ...existingConfig?.credentials,
2040
+ [`github/${options.repo}/token`]: options.token
2041
+ }
2042
+ };
2043
+ const consentConfig = buildConsentConfig(config);
2044
+ if (!consentConfig) {
2045
+ return { success: false, message: "Failed to build consent configuration." };
2046
+ }
2047
+ const fingerprint = generateConsentFingerprint(consentConfig);
2048
+ saveConfig(config);
2049
+ setState(AIUSAGE_DIR, {
2050
+ syncConsentAt: Date.now(),
2051
+ syncConsentTarget: fingerprint
2052
+ });
2053
+ return {
2054
+ success: true,
2055
+ message: `GitHub sync configured: ${options.repo}
2056
+ Consent recorded for schema v1.`
2057
+ };
2058
+ }
2059
+ if (options.backend === "s3") {
2060
+ if (!options.bucket) {
2061
+ return { success: false, message: "S3 bucket name is required." };
2062
+ }
2063
+ if (!options.accessKeyId || !options.secretAccessKey) {
2064
+ return { success: false, message: "S3 access key ID and secret access key are required." };
2065
+ }
2066
+ const prefix = options.prefix ?? "aiusage/";
2067
+ if (!prefix || prefix === "/") {
2068
+ return { success: false, message: "S3 prefix must not be empty or point to bucket root." };
2069
+ }
2070
+ const config = {
2071
+ sync: {
2072
+ backend: "s3",
2073
+ bucket: options.bucket,
2074
+ prefix,
2075
+ endpoint: options.endpoint,
2076
+ region: options.region ?? "auto",
2077
+ credentialRef: `keychain://aiusage/s3/${options.bucket}`
2078
+ },
2079
+ device: options.device ?? existingConfig?.device,
2080
+ platform: existingConfig?.platform ?? platform3(),
2081
+ retentionDays: existingConfig?.retentionDays ?? 180,
2082
+ parseInterval: existingConfig?.parseInterval ?? 60,
2083
+ dashboardPollInterval: existingConfig?.dashboardPollInterval ?? 30,
2084
+ credentials: {
2085
+ ...existingConfig?.credentials,
2086
+ [`s3/${options.bucket}/accessKeyId`]: options.accessKeyId,
2087
+ [`s3/${options.bucket}/secretAccessKey`]: options.secretAccessKey
2088
+ }
2089
+ };
2090
+ const consentConfig = buildConsentConfig(config);
2091
+ if (!consentConfig) {
2092
+ return { success: false, message: "Failed to build consent configuration." };
2093
+ }
2094
+ const fingerprint = generateConsentFingerprint(consentConfig);
2095
+ saveConfig(config);
2096
+ setState(AIUSAGE_DIR, {
2097
+ syncConsentAt: Date.now(),
2098
+ syncConsentTarget: fingerprint
2099
+ });
2100
+ return {
2101
+ success: true,
2102
+ message: `S3 sync configured: ${options.bucket}/${prefix}
2103
+ Consent recorded for schema v1.`
2104
+ };
2105
+ }
2106
+ return { success: false, message: "Unknown backend." };
2107
+ }
2108
+
2109
+ // src/commands/summary.ts
2110
+ function generateSummary(db, options) {
2111
+ const currentId = options?.currentDeviceInstanceId;
2112
+ const device = options?.device;
2113
+ let totalsSql;
2114
+ let totalsParams = {};
2115
+ let byToolSql;
2116
+ let byToolParams = {};
2117
+ const localOnlyFilter = "AND source_file NOT LIKE 'synced/%'";
2118
+ const toolWhere = options?.tool ? "AND tool = @tool" : "";
2119
+ const toolParam = options?.tool ? { tool: options.tool } : {};
2120
+ if (currentId && !device) {
2121
+ totalsSql = `
2122
+ SELECT
2123
+ COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens), 0) AS totalTokens,
2124
+ COALESCE(SUM(cost), 0) AS totalCost,
2125
+ COUNT(*) AS recordCount
2126
+ FROM (
2127
+ SELECT input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, thinking_tokens, cost FROM records WHERE 1=1 ${localOnlyFilter} ${toolWhere}
2128
+ UNION ALL
2129
+ SELECT input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, thinking_tokens, cost FROM synced_records WHERE device_instance_id != @currentId ${toolWhere}
2130
+ )`;
2131
+ totalsParams = { currentId, ...toolParam };
2132
+ byToolSql = `
2133
+ SELECT tool,
2134
+ SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens) AS tokens,
2135
+ SUM(cost) AS cost
2136
+ FROM (
2137
+ SELECT tool, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, thinking_tokens, cost FROM records WHERE 1=1 ${localOnlyFilter} ${toolWhere}
2138
+ UNION ALL
2139
+ SELECT tool, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, thinking_tokens, cost FROM synced_records WHERE device_instance_id != @currentId ${toolWhere}
2140
+ )
2141
+ GROUP BY tool ORDER BY cost DESC`;
2142
+ byToolParams = { currentId, ...toolParam };
2143
+ } else if (currentId && device && device !== currentId) {
2144
+ totalsSql = `
2145
+ SELECT
2146
+ COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens), 0) AS totalTokens,
2147
+ COALESCE(SUM(cost), 0) AS totalCost,
2148
+ COUNT(*) AS recordCount
2149
+ FROM synced_records WHERE device_instance_id = @device ${toolWhere}`;
2150
+ totalsParams = { device, ...toolParam };
2151
+ byToolSql = `
2152
+ SELECT tool,
2153
+ SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens) AS tokens,
2154
+ SUM(cost) AS cost
2155
+ FROM synced_records WHERE device_instance_id = @device ${toolWhere}
2156
+ GROUP BY tool ORDER BY cost DESC`;
2157
+ byToolParams = { device, ...toolParam };
2158
+ } else {
2159
+ totalsSql = `
2160
+ SELECT
2161
+ COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens), 0) AS totalTokens,
2162
+ COALESCE(SUM(cost), 0) AS totalCost,
2163
+ COUNT(*) AS recordCount
2164
+ FROM records WHERE 1=1 ${currentId ? localOnlyFilter : ""} ${toolWhere}`;
2165
+ totalsParams = { ...toolParam };
2166
+ byToolSql = `
2167
+ SELECT tool,
2168
+ SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens) AS tokens,
2169
+ SUM(cost) AS cost
2170
+ FROM records WHERE 1=1 ${currentId ? localOnlyFilter : ""} ${toolWhere}
2171
+ GROUP BY tool ORDER BY cost DESC`;
2172
+ byToolParams = { ...toolParam };
2173
+ }
2174
+ const totals = db.prepare(totalsSql).get(totalsParams);
2175
+ const byToolRows = db.prepare(byToolSql).all(byToolParams);
2176
+ const byTool = {};
2177
+ for (const row of byToolRows) {
2178
+ byTool[row.tool] = { tokens: row.tokens, cost: row.cost };
2179
+ }
2180
+ const toolCallStats = getToolCallStats(db);
2181
+ let deviceCount = 1;
2182
+ let deviceLabel = null;
2183
+ if (currentId) {
2184
+ const localDevices = db.prepare("SELECT DISTINCT device_instance_id FROM records").all();
2185
+ const syncedDevices = db.prepare("SELECT DISTINCT device_instance_id FROM synced_records WHERE device_instance_id != ?").all(currentId);
2186
+ const allDeviceIds = /* @__PURE__ */ new Set([...localDevices.map((d) => d.device_instance_id), ...syncedDevices.map((d) => d.device_instance_id)]);
2187
+ deviceCount = allDeviceIds.size;
2188
+ if (device) {
2189
+ const row = db.prepare("SELECT device FROM synced_records WHERE device_instance_id = ? LIMIT 1").get(device);
2190
+ deviceLabel = row?.device ?? device;
2191
+ }
2192
+ }
2193
+ return {
2194
+ totalTokens: totals.totalTokens,
2195
+ totalCost: totals.totalCost,
2196
+ recordCount: totals.recordCount,
2197
+ byTool,
2198
+ topToolCalls: toolCallStats.slice(0, 3),
2199
+ deviceCount,
2200
+ deviceLabel
2201
+ };
2202
+ }
2203
+
2204
+ // src/commands/status.ts
2205
+ import { statSync as statSync3, existsSync as existsSync6 } from "fs";
2206
+ import { join as join7 } from "path";
2207
+ function formatFileSize(bytes) {
2208
+ if (bytes < 1024) return `${bytes} B`;
2209
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
2210
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
2211
+ }
2212
+ function getDatabasePath(db) {
2213
+ const row = db.prepare("PRAGMA database_list").get();
2214
+ if (!row?.file) return ":memory:";
2215
+ return row.file;
2216
+ }
2217
+ function generateStatus(db) {
2218
+ const state = getState(AIUSAGE_DIR);
2219
+ const recordCount = db.prepare("SELECT COUNT(*) as count FROM records").get().count;
2220
+ const dbPath = getDatabasePath(db);
2221
+ const schemaVersion = db.prepare("SELECT version FROM schema_version ORDER BY version DESC LIMIT 1").get()?.version ?? 0;
2222
+ const tableCount = db.prepare("SELECT COUNT(*) as count FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").get()?.count ?? 0;
2223
+ const viewCount = db.prepare("SELECT COUNT(*) as count FROM sqlite_master WHERE type = 'view'").get()?.count ?? 0;
2224
+ const sizePath = join7(AIUSAGE_DIR, "cache.db");
2225
+ let databaseSize = "0 B";
2226
+ if (existsSync6(sizePath)) {
2227
+ try {
2228
+ const stat2 = statSync3(sizePath);
2229
+ databaseSize = formatFileSize(stat2.size);
2230
+ } catch {
2231
+ }
2232
+ }
2233
+ return {
2234
+ version: "0.0.1",
2235
+ deviceName: state?.deviceInstanceId?.slice(0, 8) ?? "unknown",
2236
+ dbPath,
2237
+ databaseSize,
2238
+ schemaVersion,
2239
+ tableCount,
2240
+ viewCount,
2241
+ recordCount,
2242
+ syncStatus: state?.lastSyncStatus ?? "not_configured",
2243
+ lastSyncAt: state?.lastSyncAt
2244
+ };
2245
+ }
2246
+
2247
+ // src/commands/export.ts
2248
+ function exportData(db, format) {
2249
+ const records = db.prepare("SELECT * FROM records").all();
2250
+ if (format === "csv") {
2251
+ const headers = "timestamp,tool,model,provider,input_tokens,output_tokens,cache_read_tokens,cache_write_tokens,thinking_tokens,cost,cost_source,session_id,device,device_instance_id";
2252
+ const rows = records.map((r) => {
2253
+ const ts = new Date(r.ts).toISOString();
2254
+ return `${ts},${r.tool},${r.model},${r.provider},${r.input_tokens},${r.output_tokens},${r.cache_read_tokens},${r.cache_write_tokens},${r.thinking_tokens},${r.cost},${r.cost_source},${r.session_id},${r.device},${r.device_instance_id}`;
2255
+ });
2256
+ return [headers, ...rows].join("\n");
2257
+ }
2258
+ if (format === "json") {
2259
+ const data = records.map((r) => ({
2260
+ timestamp: new Date(r.ts).toISOString(),
2261
+ tool: r.tool,
2262
+ model: r.model,
2263
+ provider: r.provider,
2264
+ inputTokens: r.input_tokens,
2265
+ outputTokens: r.output_tokens,
2266
+ cacheReadTokens: r.cache_read_tokens,
2267
+ cacheWriteTokens: r.cache_write_tokens,
2268
+ thinkingTokens: r.thinking_tokens,
2269
+ cost: r.cost,
2270
+ costSource: r.cost_source,
2271
+ sessionId: r.session_id,
2272
+ device: r.device,
2273
+ deviceInstanceId: r.device_instance_id
2274
+ }));
2275
+ return JSON.stringify(data, null, 2);
2276
+ }
2277
+ const lines = records.map((r) => JSON.stringify({
2278
+ id: r.id,
2279
+ ts: r.ts,
2280
+ tool: r.tool,
2281
+ model: r.model,
2282
+ provider: r.provider,
2283
+ inputTokens: r.input_tokens,
2284
+ outputTokens: r.output_tokens,
2285
+ cacheReadTokens: r.cache_read_tokens,
2286
+ cacheWriteTokens: r.cache_write_tokens,
2287
+ thinkingTokens: r.thinking_tokens,
2288
+ cost: r.cost,
2289
+ costSource: r.cost_source,
2290
+ sessionKey: r.session_id,
2291
+ device: r.device,
2292
+ deviceInstanceId: r.device_instance_id,
2293
+ updatedAt: r.updated_at
2294
+ }));
2295
+ return lines.join("\n");
2296
+ }
2297
+
2298
+ // src/commands/clean.ts
2299
+ function cleanOldData(db, days) {
2300
+ const cutoff = Date.now() - days * 864e5;
2301
+ const recordsResult = db.prepare("DELETE FROM records WHERE ts < ?").run(cutoff);
2302
+ const deletedCount = recordsResult.changes;
2303
+ const syncedResult = db.prepare("DELETE FROM synced_records WHERE ts < ?").run(cutoff);
2304
+ const deletedSyncedCount = syncedResult.changes;
2305
+ const orphanResult = db.prepare("DELETE FROM tool_calls WHERE record_id IS NULL AND ts < ?").run(cutoff);
2306
+ const deletedOrphanToolCalls = orphanResult.changes;
2307
+ return {
2308
+ deletedCount,
2309
+ deletedSyncedCount,
2310
+ deletedOrphanToolCalls
2311
+ };
2312
+ }
2313
+
2314
+ // src/commands/recalc.ts
2315
+ var BATCH_SIZE = 1e3;
2316
+ function recalcPricing(db) {
2317
+ let updatedCount = 0;
2318
+ let skippedCount = 0;
2319
+ let lastId = "";
2320
+ const updateStmt = db.prepare("UPDATE records SET cost = ?, updated_at = ? WHERE id = ?");
2321
+ while (true) {
2322
+ const records = db.prepare(
2323
+ "SELECT id, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, thinking_tokens, cost, cost_source FROM records WHERE id > ? ORDER BY id LIMIT ?"
2324
+ ).all(lastId, BATCH_SIZE);
2325
+ if (records.length === 0) break;
2326
+ for (const record of records) {
2327
+ if (record.cost_source !== "pricing") {
2328
+ skippedCount++;
2329
+ continue;
2330
+ }
2331
+ const newCost = calculateCost(record.model, {
2332
+ inputTokens: record.input_tokens,
2333
+ outputTokens: record.output_tokens,
2334
+ cacheReadTokens: record.cache_read_tokens,
2335
+ cacheWriteTokens: record.cache_write_tokens,
2336
+ thinkingTokens: record.thinking_tokens
2337
+ });
2338
+ if (newCost !== record.cost) {
2339
+ updateStmt.run(newCost, Date.now(), record.id);
2340
+ updatedCount++;
2341
+ }
2342
+ }
2343
+ lastId = records[records.length - 1].id;
2344
+ }
2345
+ return { updatedCount, skippedCount };
2346
+ }
2347
+
2348
+ // src/db/index.ts
2349
+ import Database2 from "better-sqlite3";
2350
+ import { mkdirSync as mkdirSync2 } from "fs";
2351
+ import { dirname as dirname2 } from "path";
2352
+
2353
+ // src/db/schema.ts
2354
+ function applyPragmas(db) {
2355
+ db.pragma("journal_mode = WAL");
2356
+ db.pragma("foreign_keys = ON");
2357
+ db.pragma("busy_timeout = 5000");
2358
+ }
2359
+ function createReadonlyViews(db) {
2360
+ db.exec(`
2361
+ CREATE VIEW IF NOT EXISTS v_usage_records AS
2362
+ SELECT
2363
+ id,
2364
+ datetime(ts / 1000, 'unixepoch') || '.' || printf('%03d', ts % 1000) || 'Z' AS timestamp,
2365
+ ts,
2366
+ tool,
2367
+ model,
2368
+ provider,
2369
+ input_tokens,
2370
+ output_tokens,
2371
+ cache_read_tokens,
2372
+ cache_write_tokens,
2373
+ thinking_tokens,
2374
+ (input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens) AS total_tokens,
2375
+ cost,
2376
+ cost_source,
2377
+ session_id,
2378
+ source_file,
2379
+ device,
2380
+ device_instance_id,
2381
+ ingested_at,
2382
+ synced_at,
2383
+ updated_at
2384
+ FROM records;
2385
+
2386
+ CREATE VIEW IF NOT EXISTS v_tool_calls AS
2387
+ SELECT
2388
+ tc.id,
2389
+ tc.record_id,
2390
+ tc.name,
2391
+ tc.tool AS tool_call_tool,
2392
+ datetime(tc.ts / 1000, 'unixepoch') || '.' || printf('%03d', tc.ts % 1000) || 'Z' AS timestamp,
2393
+ tc.ts,
2394
+ tc.call_index,
2395
+ r.tool,
2396
+ r.model,
2397
+ r.provider,
2398
+ r.session_id,
2399
+ r.source_file,
2400
+ r.device,
2401
+ r.device_instance_id
2402
+ FROM tool_calls tc
2403
+ LEFT JOIN records r ON r.id = tc.record_id;
2404
+
2405
+ CREATE VIEW IF NOT EXISTS v_sessions AS
2406
+ SELECT
2407
+ session_id,
2408
+ tool,
2409
+ model,
2410
+ provider,
2411
+ device,
2412
+ device_instance_id,
2413
+ COUNT(*) AS record_count,
2414
+ MIN(ts) AS first_ts,
2415
+ datetime(MIN(ts) / 1000, 'unixepoch') || '.' || printf('%03d', MIN(ts) % 1000) || 'Z' AS first_timestamp,
2416
+ MAX(ts) AS last_ts,
2417
+ datetime(MAX(ts) / 1000, 'unixepoch') || '.' || printf('%03d', MAX(ts) % 1000) || 'Z' AS last_timestamp,
2418
+ SUM(input_tokens) AS input_tokens,
2419
+ SUM(output_tokens) AS output_tokens,
2420
+ SUM(cache_read_tokens) AS cache_read_tokens,
2421
+ SUM(cache_write_tokens) AS cache_write_tokens,
2422
+ SUM(thinking_tokens) AS thinking_tokens,
2423
+ SUM(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + thinking_tokens) AS total_tokens,
2424
+ SUM(cost) AS total_cost
2425
+ FROM records
2426
+ GROUP BY session_id, tool, model, provider, device, device_instance_id;
2427
+ `);
2428
+ }
2429
+ function createSchemaVersionTable(db) {
2430
+ db.exec(`
2431
+ CREATE TABLE IF NOT EXISTS schema_version (
2432
+ version INTEGER PRIMARY KEY,
2433
+ applied_at TEXT DEFAULT (datetime('now'))
2434
+ )
2435
+ `);
2436
+ }
2437
+ function createV1Schema(db) {
2438
+ db.exec(`
2439
+ CREATE TABLE records (
2440
+ id TEXT PRIMARY KEY,
2441
+ ts INTEGER NOT NULL,
2442
+ ingested_at INTEGER NOT NULL,
2443
+ synced_at INTEGER,
2444
+ updated_at INTEGER NOT NULL,
2445
+ line_offset INTEGER NOT NULL,
2446
+ tool TEXT NOT NULL,
2447
+ model TEXT NOT NULL,
2448
+ provider TEXT NOT NULL,
2449
+ input_tokens INTEGER DEFAULT 0,
2450
+ output_tokens INTEGER DEFAULT 0,
2451
+ cache_read_tokens INTEGER DEFAULT 0,
2452
+ cache_write_tokens INTEGER DEFAULT 0,
2453
+ thinking_tokens INTEGER DEFAULT 0,
2454
+ cost REAL DEFAULT 0,
2455
+ cost_source TEXT NOT NULL DEFAULT 'pricing',
2456
+ session_id TEXT NOT NULL,
2457
+ source_file TEXT NOT NULL,
2458
+ device TEXT NOT NULL,
2459
+ device_instance_id TEXT NOT NULL
2460
+ );
2461
+
2462
+ CREATE TABLE synced_records (
2463
+ id TEXT PRIMARY KEY,
2464
+ ts INTEGER NOT NULL,
2465
+ tool TEXT NOT NULL,
2466
+ model TEXT NOT NULL,
2467
+ provider TEXT NOT NULL,
2468
+ input_tokens INTEGER DEFAULT 0,
2469
+ output_tokens INTEGER DEFAULT 0,
2470
+ cache_read_tokens INTEGER DEFAULT 0,
2471
+ cache_write_tokens INTEGER DEFAULT 0,
2472
+ thinking_tokens INTEGER DEFAULT 0,
2473
+ cost REAL DEFAULT 0,
2474
+ cost_source TEXT NOT NULL DEFAULT 'pricing',
2475
+ session_key TEXT NOT NULL,
2476
+ device TEXT NOT NULL,
2477
+ device_instance_id TEXT NOT NULL,
2478
+ platform TEXT NOT NULL DEFAULT '',
2479
+ updated_at INTEGER NOT NULL
2480
+ );
2481
+
2482
+ CREATE TABLE sync_tombstones (
2483
+ id TEXT NOT NULL,
2484
+ device_scope TEXT NOT NULL,
2485
+ deleted_at INTEGER NOT NULL,
2486
+ reason TEXT NOT NULL,
2487
+ PRIMARY KEY (id, device_scope)
2488
+ );
2489
+
2490
+ CREATE TABLE tool_calls (
2491
+ id TEXT PRIMARY KEY,
2492
+ record_id TEXT REFERENCES records(id) ON DELETE CASCADE,
2493
+ tool TEXT,
2494
+ name TEXT NOT NULL,
2495
+ ts INTEGER NOT NULL,
2496
+ call_index INTEGER DEFAULT 0
2497
+ );
2498
+
2499
+ CREATE INDEX idx_records_ts ON records(ts DESC);
2500
+ CREATE INDEX idx_records_ingested ON records(ingested_at DESC);
2501
+ CREATE INDEX idx_records_updated ON records(updated_at DESC);
2502
+ CREATE INDEX idx_records_tool ON records(tool);
2503
+ CREATE INDEX idx_records_model ON records(model);
2504
+ CREATE INDEX idx_records_session ON records(session_id);
2505
+ CREATE INDEX idx_records_source ON records(source_file);
2506
+ CREATE INDEX idx_records_cost_source ON records(cost_source);
2507
+ CREATE INDEX idx_synced_records_ts ON synced_records(ts DESC);
2508
+ CREATE INDEX idx_synced_records_tool ON synced_records(tool);
2509
+ CREATE INDEX idx_synced_records_model ON synced_records(model);
2510
+ CREATE INDEX idx_synced_records_session ON synced_records(session_key);
2511
+ CREATE INDEX idx_synced_records_device ON synced_records(device);
2512
+ CREATE INDEX idx_synced_records_updated ON synced_records(updated_at DESC);
2513
+ CREATE INDEX idx_sync_tombstones_deleted_at ON sync_tombstones(deleted_at DESC);
2514
+ CREATE INDEX idx_tombstones_device_scope ON sync_tombstones(device_scope);
2515
+ CREATE INDEX idx_tc_record_id ON tool_calls(record_id);
2516
+ CREATE INDEX idx_tc_name ON tool_calls(name);
2517
+ CREATE INDEX idx_tc_ts ON tool_calls(ts DESC);
2518
+ `);
2519
+ }
2520
+
2521
+ // src/db/migrations/v1.ts
2522
+ function migrateV1(db) {
2523
+ createV1Schema(db);
2524
+ db.prepare("INSERT INTO schema_version (version) VALUES (1)").run();
2525
+ }
2526
+
2527
+ // src/db/migrations/v2.ts
2528
+ function migrateV2(db) {
2529
+ const columns = db.prepare("PRAGMA table_info('synced_records')").all();
2530
+ if (!columns.some((column) => column.name === "platform")) {
2531
+ db.exec(`ALTER TABLE synced_records ADD COLUMN platform TEXT NOT NULL DEFAULT ''`);
2532
+ }
2533
+ db.prepare("INSERT INTO schema_version (version) VALUES (2)").run();
2534
+ }
2535
+
2536
+ // src/db/migrations/v3.ts
2537
+ function migrateV3(db) {
2538
+ createReadonlyViews(db);
2539
+ db.prepare("INSERT INTO schema_version (version) VALUES (3)").run();
2540
+ }
2541
+
2542
+ // src/db/migrations/v4.ts
2543
+ function migrateV4(db) {
2544
+ const columns = db.prepare("PRAGMA table_info('records')").all();
2545
+ if (!columns.some((c) => c.name === "platform")) {
2546
+ db.exec(`ALTER TABLE records ADD COLUMN platform TEXT NOT NULL DEFAULT ''`);
2547
+ }
2548
+ db.prepare("INSERT INTO schema_version (version) VALUES (4)").run();
2549
+ }
2550
+
2551
+ // src/db/migrations/index.ts
2552
+ var MIGRATIONS = [
2553
+ { version: 1, migrate: migrateV1 },
2554
+ { version: 2, migrate: migrateV2 },
2555
+ { version: 3, migrate: migrateV3 },
2556
+ { version: 4, migrate: migrateV4 }
2557
+ ];
2558
+ function runMigrations(db) {
2559
+ createSchemaVersionTable(db);
2560
+ const currentVersion = db.prepare(
2561
+ "SELECT version FROM schema_version ORDER BY version DESC LIMIT 1"
2562
+ ).get();
2563
+ const current = currentVersion?.version ?? 0;
2564
+ for (const migration of MIGRATIONS) {
2565
+ if (migration.version > current) {
2566
+ db.transaction(() => {
2567
+ migration.migrate(db);
2568
+ })();
2569
+ }
2570
+ }
2571
+ }
2572
+
2573
+ // src/db/index.ts
2574
+ function initializeDatabase(db) {
2575
+ applyPragmas(db);
2576
+ runMigrations(db);
2577
+ }
2578
+ function createDatabase(path) {
2579
+ mkdirSync2(dirname2(path), { recursive: true, mode: 448 });
2580
+ const db = new Database2(path);
2581
+ initializeDatabase(db);
2582
+ return db;
2583
+ }
2584
+
2585
+ // src/cli.ts
2586
+ import { join as join8 } from "path";
2587
+ var DB_PATH = join8(AIUSAGE_DIR, "cache.db");
2588
+ var program = new Command();
2589
+ program.name("aiusage").version("1.0.1").description("CLI tool for AI usage statistics");
2590
+ program.action(() => {
2591
+ const db = createDatabase(DB_PATH);
2592
+ const state = getState(AIUSAGE_DIR);
2593
+ const summary = generateSummary(db, {
2594
+ currentDeviceInstanceId: state?.deviceInstanceId
2595
+ });
2596
+ if (summary.deviceCount > 1) {
2597
+ console.log(`\u8BBE\u5907\uFF1A\u5168\u90E8\uFF08${summary.deviceCount} \u53F0\u8BBE\u5907\u5728\u7EBF\uFF09`);
2598
+ }
2599
+ console.log(`Total Tokens: ${summary.totalTokens.toLocaleString()}`);
2600
+ console.log(`Total Cost: $${summary.totalCost.toFixed(4)}`);
2601
+ console.log(`Records: ${summary.recordCount}`);
2602
+ if (Object.keys(summary.byTool).length > 0) {
2603
+ console.log("\nBy Tool:");
2604
+ for (const [tool, stats] of Object.entries(summary.byTool)) {
2605
+ console.log(` ${tool}: ${stats.tokens.toLocaleString()} tokens, $${stats.cost.toFixed(4)}`);
2606
+ }
2607
+ }
2608
+ if (summary.topToolCalls.length > 0) {
2609
+ console.log("\nTop Tool Calls:");
2610
+ for (const tc of summary.topToolCalls) {
2611
+ console.log(` ${tc.name}: ${tc.count}`);
2612
+ }
2613
+ }
2614
+ db.close();
2615
+ });
2616
+ program.command("summary").description("Show usage summary").option("--week", "Show this week").option("--month", "Show this month").option("--from <date>", "Start date (YYYY-MM-DD)").option("--to <date>", "End date (YYYY-MM-DD)").option("--device <id>", "Filter by device instance ID").option("--tool <tool>", "Filter by tool type (claude-code|codex|openclaw|opencode)").action((options) => {
2617
+ const db = createDatabase(DB_PATH);
2618
+ const state = getState(AIUSAGE_DIR);
2619
+ const summary = generateSummary(db, {
2620
+ currentDeviceInstanceId: state?.deviceInstanceId,
2621
+ device: options.device,
2622
+ tool: options.tool
2623
+ });
2624
+ if (summary.deviceLabel) {
2625
+ console.log(`\u8BBE\u5907\uFF1A${summary.deviceLabel}`);
2626
+ } else if (summary.deviceCount > 1) {
2627
+ console.log(`\u8BBE\u5907\uFF1A\u5168\u90E8\uFF08${summary.deviceCount} \u53F0\u8BBE\u5907\u5728\u7EBF\uFF09`);
2628
+ }
2629
+ console.log(`Total Tokens: ${summary.totalTokens.toLocaleString()}`);
2630
+ console.log(`Total Cost: $${summary.totalCost.toFixed(4)}`);
2631
+ console.log(`Records: ${summary.recordCount}`);
2632
+ if (Object.keys(summary.byTool).length > 0) {
2633
+ console.log("\nBy Tool:");
2634
+ for (const [tool, stats] of Object.entries(summary.byTool)) {
2635
+ console.log(` ${tool}: ${stats.tokens.toLocaleString()} tokens, $${stats.cost.toFixed(4)}`);
2636
+ }
2637
+ }
2638
+ db.close();
2639
+ });
2640
+ program.command("status").description("Show current status").action(() => {
2641
+ const db = createDatabase(DB_PATH);
2642
+ const status = generateStatus(db);
2643
+ console.log(`Version: ${status.version}`);
2644
+ console.log(`Device: ${status.deviceName}`);
2645
+ console.log(`DB Path: ${status.dbPath}`);
2646
+ console.log(`Schema: v${status.schemaVersion}`);
2647
+ console.log(`Objects: ${status.tableCount} tables, ${status.viewCount} views`);
2648
+ console.log(`Records: ${status.recordCount}`);
2649
+ console.log(`DB Size: ${status.databaseSize}`);
2650
+ console.log(`Sync Status: ${status.syncStatus}`);
2651
+ if (status.lastSyncAt) {
2652
+ console.log(`Last Sync: ${new Date(status.lastSyncAt).toISOString()}`);
2653
+ }
2654
+ db.close();
2655
+ });
2656
+ program.command("export").description("Export data").requiredOption("--format <format>", "Output format (csv|json|ndjson)").option("--range <range>", "Time range (day|week|month)").option("--from <date>", "Start date (YYYY-MM-DD)").option("--to <date>", "End date (YYYY-MM-DD)").option("-o, --output <file>", "Output file (default: stdout)").action((options) => {
2657
+ const format = options.format;
2658
+ if (!["csv", "json", "ndjson"].includes(format)) {
2659
+ console.error("Invalid format. Use csv, json, or ndjson.");
2660
+ process.exit(1);
2661
+ }
2662
+ const db = createDatabase(DB_PATH);
2663
+ const data = exportData(db, format);
2664
+ if (options.output) {
2665
+ writeFileSync4(options.output, data, "utf-8");
2666
+ console.log(`Exported to ${options.output}`);
2667
+ } else {
2668
+ console.log(data);
2669
+ }
2670
+ db.close();
2671
+ });
2672
+ program.command("parse").description("Parse AI tool session logs").option("--tool <tool>", "Specific tool to parse (claude-code|codex|openclaw|opencode)").action(async (options) => {
2673
+ const db = createDatabase(DB_PATH);
2674
+ try {
2675
+ const result = await runParse(db, options.tool);
2676
+ console.log(`Parsed ${result.parsedCount} records, ${result.toolCallCount} tool calls.`);
2677
+ if (result.errors.length > 0) {
2678
+ console.warn(`${result.errors.length} errors encountered.`);
2679
+ }
2680
+ } catch (e) {
2681
+ console.error(`Parse failed: ${e instanceof Error ? e.message : e}`);
2682
+ process.exit(1);
2683
+ } finally {
2684
+ db.close();
2685
+ }
2686
+ });
2687
+ program.command("clean").description("Clean old data").option("--before <duration>", "Duration (e.g., 30d)", "180d").option("--remote", "Also clean remote data").option("--all-devices", "Clean all devices (with --remote)").option("--yes", "Skip confirmation").option("--approve-delete", "Approve delete permission upgrade").action((options) => {
2688
+ const daysMatch = options.before.match(/^(\d+)d$/);
2689
+ if (!daysMatch) {
2690
+ console.error("Invalid duration format. Use e.g. 30d, 180d.");
2691
+ process.exit(1);
2692
+ }
2693
+ const days = parseInt(daysMatch[1], 10);
2694
+ const db = createDatabase(DB_PATH);
2695
+ const result = cleanOldData(db, days);
2696
+ console.log(`Deleted ${result.deletedCount} records, ${result.deletedSyncedCount} synced records, ${result.deletedOrphanToolCalls} orphan tool calls.`);
2697
+ db.close();
2698
+ });
2699
+ program.command("recalc").description("Recalculate costs").option("--pricing", "Recalculate using latest pricing").action(() => {
2700
+ const db = createDatabase(DB_PATH);
2701
+ const result = recalcPricing(db);
2702
+ console.log(`Updated ${result.updatedCount} records, skipped ${result.skippedCount}.`);
2703
+ db.close();
2704
+ });
2705
+ program.command("serve").description("Start web dashboard").option("-p, --port <port>", "Port number", "3847").action((options) => {
2706
+ const db = createDatabase(DB_PATH);
2707
+ serve({ port: parseInt(options.port), db });
2708
+ });
2709
+ program.command("init").description("Configure cloud sync").option("--backend <backend>", "Sync backend (github|s3|skip)").option("--repo <repo>", "GitHub repository (format: username/repo-name)").option("--token <token>", "GitHub Personal Access Token").option("--bucket <bucket>", "S3 bucket name").option("--prefix <prefix>", "S3 object prefix", "aiusage/").option("--endpoint <endpoint>", "S3 endpoint URL").option("--region <region>", "S3 region", "auto").option("--access-key-id <id>", "S3 access key ID").option("--secret-access-key <key>", "S3 secret access key").option("--device <alias>", "Device alias").action((options) => {
2710
+ const result = runInit(options);
2711
+ if (result.success) {
2712
+ console.log(`\u2713 ${result.message}`);
2713
+ } else {
2714
+ console.error(`\u2717 ${result.message}`);
2715
+ process.exit(1);
2716
+ }
2717
+ });
2718
+ program.command("sync").description("Sync data with cloud storage").action(async () => {
2719
+ const db = createDatabase(DB_PATH);
2720
+ const result = await runSync(db);
2721
+ if (result.status === "ok") {
2722
+ console.log(`\u2713 Sync complete \u2014 pulled: ${result.pulledCount}, merged: ${result.mergedCount}, uploaded: ${result.uploadedCount}`);
2723
+ } else if (result.status === "blocked_pending_consent") {
2724
+ console.error(`\u2717 ${result.error}`);
2725
+ process.exit(1);
2726
+ } else {
2727
+ console.error(`\u2717 Sync failed: ${result.error}`);
2728
+ process.exit(1);
2729
+ }
2730
+ db.close();
2731
+ });
2732
+
2733
+ // src/index.ts
2734
+ program.parse(process.argv);