@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.
- package/dist/chunk-C6IKRE2P.js +458 -0
- package/dist/dist-XHYTXDVT.js +42 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2734 -0
- package/dist/web/_app/env.js +1 -0
- package/dist/web/_app/immutable/assets/0.BTUc0PXb.css +1 -0
- package/dist/web/_app/immutable/assets/2.I4bgY8QS.css +1 -0
- package/dist/web/_app/immutable/assets/3.BmO4KrOR.css +1 -0
- package/dist/web/_app/immutable/assets/4.TAVgUi8b.css +1 -0
- package/dist/web/_app/immutable/assets/5.BeDwa7mS.css +1 -0
- package/dist/web/_app/immutable/assets/6.BYQidWiw.css +1 -0
- package/dist/web/_app/immutable/assets/7.8R_5Laqi.css +1 -0
- package/dist/web/_app/immutable/assets/8.BcbNCOTS.css +1 -0
- package/dist/web/_app/immutable/assets/9.QhlpTtkf.css +1 -0
- package/dist/web/_app/immutable/assets/ToolSelector.BKgB2QVk.css +1 -0
- package/dist/web/_app/immutable/assets/ToolSelector.HSbC8-q9.css +1 -0
- package/dist/web/_app/immutable/assets/_layout.DLyrrG29.css +1 -0
- package/dist/web/_app/immutable/assets/_page.8R_5Laqi.css +1 -0
- package/dist/web/_app/immutable/assets/_page.BYQidWiw.css +1 -0
- package/dist/web/_app/immutable/assets/_page.BcbNCOTS.css +1 -0
- package/dist/web/_app/immutable/assets/_page.BeDwa7mS.css +1 -0
- package/dist/web/_app/immutable/assets/_page.BmO4KrOR.css +1 -0
- package/dist/web/_app/immutable/assets/_page.I4bgY8QS.css +1 -0
- package/dist/web/_app/immutable/assets/_page.QhlpTtkf.css +1 -0
- package/dist/web/_app/immutable/assets/_page.TAVgUi8b.css +1 -0
- package/dist/web/_app/immutable/chunks/ToolSelector.kb97TteP.js +2 -0
- package/dist/web/_app/immutable/chunks/api.DyoIo4nG.js +1 -0
- package/dist/web/_app/immutable/chunks/entry.CVLbqi7C.js +3 -0
- package/dist/web/_app/immutable/chunks/index.CzqoKkoo.js +1 -0
- package/dist/web/_app/immutable/chunks/index.Dm-3G3np.js +1 -0
- package/dist/web/_app/immutable/chunks/scheduler.Dx1bs0RQ.js +1 -0
- package/dist/web/_app/immutable/chunks/stores.BSF99XdC.js +1 -0
- package/dist/web/_app/immutable/entry/app.Cb4qBU5I.js +2 -0
- package/dist/web/_app/immutable/entry/start.DPeK9dzE.js +1 -0
- package/dist/web/_app/immutable/nodes/0.Bzydyk3L.js +1 -0
- package/dist/web/_app/immutable/nodes/1.87xpHPtt.js +1 -0
- package/dist/web/_app/immutable/nodes/2.CJyBxUXd.js +1 -0
- package/dist/web/_app/immutable/nodes/3.Bqg88zQg.js +1 -0
- package/dist/web/_app/immutable/nodes/4.B3zrG0ip.js +1 -0
- package/dist/web/_app/immutable/nodes/5.C3h9wXyF.js +1 -0
- package/dist/web/_app/immutable/nodes/6.C8xhym7b.js +1 -0
- package/dist/web/_app/immutable/nodes/7.C950CTxa.js +1 -0
- package/dist/web/_app/immutable/nodes/8.BSeUhVCa.js +1 -0
- package/dist/web/_app/immutable/nodes/9.DJXBgSiY.js +1 -0
- package/dist/web/_app/version.json +1 -0
- package/dist/web/index.html +47 -0
- 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);
|