@poco-ai/tokenarena 0.1.2
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/LICENSE +21 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2221 -0
- package/dist/index.js.map +1 -0
- package/package.json +39 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2221 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/parsers/claude-code.ts
|
|
4
|
+
import { homedir } from "os";
|
|
5
|
+
import { join as join2, sep as sep2 } from "path";
|
|
6
|
+
|
|
7
|
+
// src/domain/aggregator.ts
|
|
8
|
+
import { hostname } from "os";
|
|
9
|
+
function roundToHalfHour(date) {
|
|
10
|
+
const d = new Date(date);
|
|
11
|
+
d.setMinutes(d.getMinutes() < 30 ? 0 : 30, 0, 0);
|
|
12
|
+
return d;
|
|
13
|
+
}
|
|
14
|
+
function aggregateToBuckets(entries) {
|
|
15
|
+
const map = /* @__PURE__ */ new Map();
|
|
16
|
+
const host = hostname().replace(/\.local$/, "");
|
|
17
|
+
for (const e of entries) {
|
|
18
|
+
const bucketStart = roundToHalfHour(e.timestamp).toISOString();
|
|
19
|
+
const key = `${e.source}|${e.model}|${e.project}|${bucketStart}`;
|
|
20
|
+
if (!map.has(key)) {
|
|
21
|
+
map.set(key, {
|
|
22
|
+
source: e.source,
|
|
23
|
+
model: e.model,
|
|
24
|
+
project: e.project,
|
|
25
|
+
bucketStart,
|
|
26
|
+
hostname: host,
|
|
27
|
+
inputTokens: 0,
|
|
28
|
+
outputTokens: 0,
|
|
29
|
+
reasoningTokens: 0,
|
|
30
|
+
cachedTokens: 0,
|
|
31
|
+
totalTokens: 0
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
const b = map.get(key);
|
|
35
|
+
if (!b) continue;
|
|
36
|
+
b.inputTokens += e.inputTokens || 0;
|
|
37
|
+
b.outputTokens += e.outputTokens || 0;
|
|
38
|
+
b.reasoningTokens += e.reasoningTokens || 0;
|
|
39
|
+
b.cachedTokens += e.cachedTokens || 0;
|
|
40
|
+
b.totalTokens += (e.inputTokens || 0) + (e.outputTokens || 0) + (e.reasoningTokens || 0) + (e.cachedTokens || 0);
|
|
41
|
+
}
|
|
42
|
+
return Array.from(map.values());
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// src/domain/session-extractor.ts
|
|
46
|
+
import { createHash } from "crypto";
|
|
47
|
+
import { hostname as hostname2 } from "os";
|
|
48
|
+
function toEntryTotalTokens(entry) {
|
|
49
|
+
return entry.inputTokens + entry.outputTokens + entry.reasoningTokens + entry.cachedTokens;
|
|
50
|
+
}
|
|
51
|
+
function buildSessionUsage(entries) {
|
|
52
|
+
const usageBySession = /* @__PURE__ */ new Map();
|
|
53
|
+
for (const entry of entries) {
|
|
54
|
+
if (!entry.sessionId) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
let byModel = usageBySession.get(entry.sessionId);
|
|
58
|
+
if (!byModel) {
|
|
59
|
+
byModel = /* @__PURE__ */ new Map();
|
|
60
|
+
usageBySession.set(entry.sessionId, byModel);
|
|
61
|
+
}
|
|
62
|
+
const existing = byModel.get(entry.model);
|
|
63
|
+
if (existing) {
|
|
64
|
+
existing.inputTokens += entry.inputTokens;
|
|
65
|
+
existing.outputTokens += entry.outputTokens;
|
|
66
|
+
existing.reasoningTokens += entry.reasoningTokens;
|
|
67
|
+
existing.cachedTokens += entry.cachedTokens;
|
|
68
|
+
existing.totalTokens += toEntryTotalTokens(entry);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
byModel.set(entry.model, {
|
|
72
|
+
model: entry.model,
|
|
73
|
+
inputTokens: entry.inputTokens,
|
|
74
|
+
outputTokens: entry.outputTokens,
|
|
75
|
+
reasoningTokens: entry.reasoningTokens,
|
|
76
|
+
cachedTokens: entry.cachedTokens,
|
|
77
|
+
totalTokens: toEntryTotalTokens(entry)
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return usageBySession;
|
|
81
|
+
}
|
|
82
|
+
function extractSessions(events, entries = []) {
|
|
83
|
+
const groups = /* @__PURE__ */ new Map();
|
|
84
|
+
for (const e of events) {
|
|
85
|
+
if (!groups.has(e.sessionId)) groups.set(e.sessionId, []);
|
|
86
|
+
groups.get(e.sessionId)?.push(e);
|
|
87
|
+
}
|
|
88
|
+
const sessions = [];
|
|
89
|
+
const host = hostname2().replace(/\.local$/, "");
|
|
90
|
+
const usageBySession = buildSessionUsage(entries);
|
|
91
|
+
for (const [sessionId, sessionEvents] of groups) {
|
|
92
|
+
sessionEvents.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
|
93
|
+
const first = sessionEvents[0];
|
|
94
|
+
const last = sessionEvents[sessionEvents.length - 1];
|
|
95
|
+
const durationSeconds = Math.round(
|
|
96
|
+
(last.timestamp.getTime() - first.timestamp.getTime()) / 1e3
|
|
97
|
+
);
|
|
98
|
+
let activeSeconds = 0;
|
|
99
|
+
let turnStart = null;
|
|
100
|
+
let turnEnd = null;
|
|
101
|
+
let waitingForFirstResponse = false;
|
|
102
|
+
for (const event of sessionEvents) {
|
|
103
|
+
if (event.role === "user") {
|
|
104
|
+
if (turnStart !== null && turnEnd !== null && turnEnd > turnStart) {
|
|
105
|
+
activeSeconds += Math.round(
|
|
106
|
+
(turnEnd.getTime() - turnStart.getTime()) / 1e3
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
turnStart = null;
|
|
110
|
+
turnEnd = null;
|
|
111
|
+
waitingForFirstResponse = true;
|
|
112
|
+
} else if (waitingForFirstResponse) {
|
|
113
|
+
turnStart = event.timestamp;
|
|
114
|
+
turnEnd = event.timestamp;
|
|
115
|
+
waitingForFirstResponse = false;
|
|
116
|
+
} else if (turnStart !== null) {
|
|
117
|
+
turnEnd = event.timestamp;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (turnStart !== null && turnEnd !== null && turnEnd > turnStart) {
|
|
121
|
+
activeSeconds += Math.round(
|
|
122
|
+
(turnEnd.getTime() - turnStart.getTime()) / 1e3
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
const userPromptHours = new Array(24).fill(0);
|
|
126
|
+
let userMessageCount = 0;
|
|
127
|
+
for (const event of sessionEvents) {
|
|
128
|
+
if (event.role === "user") {
|
|
129
|
+
userMessageCount++;
|
|
130
|
+
userPromptHours[event.timestamp.getUTCHours()]++;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const modelUsages = Array.from(
|
|
134
|
+
usageBySession.get(sessionId)?.values() ?? []
|
|
135
|
+
).sort((left, right) => {
|
|
136
|
+
if (right.totalTokens !== left.totalTokens) {
|
|
137
|
+
return right.totalTokens - left.totalTokens;
|
|
138
|
+
}
|
|
139
|
+
return left.model.localeCompare(right.model);
|
|
140
|
+
});
|
|
141
|
+
const inputTokens = modelUsages.reduce(
|
|
142
|
+
(sum, usage) => sum + usage.inputTokens,
|
|
143
|
+
0
|
|
144
|
+
);
|
|
145
|
+
const outputTokens = modelUsages.reduce(
|
|
146
|
+
(sum, usage) => sum + usage.outputTokens,
|
|
147
|
+
0
|
|
148
|
+
);
|
|
149
|
+
const reasoningTokens = modelUsages.reduce(
|
|
150
|
+
(sum, usage) => sum + usage.reasoningTokens,
|
|
151
|
+
0
|
|
152
|
+
);
|
|
153
|
+
const cachedTokens = modelUsages.reduce(
|
|
154
|
+
(sum, usage) => sum + usage.cachedTokens,
|
|
155
|
+
0
|
|
156
|
+
);
|
|
157
|
+
const totalTokens = modelUsages.reduce(
|
|
158
|
+
(sum, usage) => sum + usage.totalTokens,
|
|
159
|
+
0
|
|
160
|
+
);
|
|
161
|
+
const primaryModel = modelUsages[0]?.model ?? "";
|
|
162
|
+
const sessionHash = createHash("sha256").update(sessionId).digest("hex").slice(0, 16);
|
|
163
|
+
sessions.push({
|
|
164
|
+
source: first.source,
|
|
165
|
+
project: first.project || "unknown",
|
|
166
|
+
sessionHash,
|
|
167
|
+
hostname: host,
|
|
168
|
+
firstMessageAt: first.timestamp.toISOString(),
|
|
169
|
+
lastMessageAt: last.timestamp.toISOString(),
|
|
170
|
+
durationSeconds,
|
|
171
|
+
activeSeconds,
|
|
172
|
+
messageCount: sessionEvents.length,
|
|
173
|
+
userMessageCount,
|
|
174
|
+
userPromptHours,
|
|
175
|
+
inputTokens,
|
|
176
|
+
outputTokens,
|
|
177
|
+
reasoningTokens,
|
|
178
|
+
cachedTokens,
|
|
179
|
+
totalTokens,
|
|
180
|
+
primaryModel,
|
|
181
|
+
modelUsages
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
return sessions;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// src/infrastructure/fs/utils.ts
|
|
188
|
+
import { existsSync, readdirSync, readFileSync } from "fs";
|
|
189
|
+
import { basename, join, sep } from "path";
|
|
190
|
+
function findJsonlFiles(dir) {
|
|
191
|
+
const results = [];
|
|
192
|
+
if (!existsSync(dir)) return results;
|
|
193
|
+
try {
|
|
194
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
195
|
+
const fullPath = join(dir, entry.name);
|
|
196
|
+
if (entry.isDirectory()) {
|
|
197
|
+
results.push(...findJsonlFiles(fullPath));
|
|
198
|
+
} else if (entry.name.endsWith(".jsonl")) {
|
|
199
|
+
results.push(fullPath);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
} catch {
|
|
203
|
+
}
|
|
204
|
+
return results;
|
|
205
|
+
}
|
|
206
|
+
function readFileSafe(filePath) {
|
|
207
|
+
try {
|
|
208
|
+
return readFileSync(filePath, "utf-8");
|
|
209
|
+
} catch {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function extractSessionId(filePath) {
|
|
214
|
+
return basename(filePath, ".jsonl");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// src/parsers/registry.ts
|
|
218
|
+
import { existsSync as existsSync2 } from "fs";
|
|
219
|
+
var TOOLS = [];
|
|
220
|
+
var parsers = /* @__PURE__ */ new Map();
|
|
221
|
+
function registerParser(parser) {
|
|
222
|
+
parsers.set(parser.tool.id, parser);
|
|
223
|
+
if (!TOOLS.find((t) => t.id === parser.tool.id)) {
|
|
224
|
+
TOOLS.push(parser.tool);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
function getAllParsers() {
|
|
228
|
+
return Array.from(parsers.values());
|
|
229
|
+
}
|
|
230
|
+
function detectInstalledTools() {
|
|
231
|
+
return TOOLS.filter((t) => existsSync2(t.dataDir));
|
|
232
|
+
}
|
|
233
|
+
function getAllTools() {
|
|
234
|
+
return TOOLS;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// src/parsers/claude-code.ts
|
|
238
|
+
var TOOL = {
|
|
239
|
+
id: "claude-code",
|
|
240
|
+
name: "Claude Code",
|
|
241
|
+
dataDir: join2(homedir(), ".claude", "projects")
|
|
242
|
+
};
|
|
243
|
+
var TRANSCRIPTS_DIR = join2(homedir(), ".claude", "transcripts");
|
|
244
|
+
function extractProject(filePath) {
|
|
245
|
+
const projectsPrefix = TOOL.dataDir + sep2;
|
|
246
|
+
if (!filePath.startsWith(projectsPrefix)) return "unknown";
|
|
247
|
+
const relative = filePath.slice(projectsPrefix.length);
|
|
248
|
+
const firstSeg = relative.split(sep2)[0];
|
|
249
|
+
if (!firstSeg) return "unknown";
|
|
250
|
+
const parts = firstSeg.split("-").filter(Boolean);
|
|
251
|
+
return parts.length > 0 ? parts[parts.length - 1] : "unknown";
|
|
252
|
+
}
|
|
253
|
+
var ClaudeCodeParser = class {
|
|
254
|
+
tool = TOOL;
|
|
255
|
+
async parse() {
|
|
256
|
+
const entries = [];
|
|
257
|
+
const sessionEvents = [];
|
|
258
|
+
const seenUuids = /* @__PURE__ */ new Set();
|
|
259
|
+
const seenSessionIds = /* @__PURE__ */ new Set();
|
|
260
|
+
const projectFiles = findJsonlFiles(TOOL.dataDir);
|
|
261
|
+
for (const filePath of projectFiles) {
|
|
262
|
+
const content = readFileSafe(filePath);
|
|
263
|
+
if (!content) continue;
|
|
264
|
+
const project = extractProject(filePath);
|
|
265
|
+
const sessionId = extractSessionId(filePath);
|
|
266
|
+
seenSessionIds.add(sessionId);
|
|
267
|
+
for (const line of content.split("\n")) {
|
|
268
|
+
if (!line.trim()) continue;
|
|
269
|
+
try {
|
|
270
|
+
const obj = JSON.parse(line);
|
|
271
|
+
const timestamp = obj.timestamp;
|
|
272
|
+
if (!timestamp) continue;
|
|
273
|
+
const ts = new Date(timestamp);
|
|
274
|
+
if (Number.isNaN(ts.getTime())) continue;
|
|
275
|
+
if (obj.type === "user" || obj.type === "assistant") {
|
|
276
|
+
sessionEvents.push({
|
|
277
|
+
sessionId,
|
|
278
|
+
source: "claude-code",
|
|
279
|
+
project,
|
|
280
|
+
timestamp: ts,
|
|
281
|
+
role: obj.type === "user" ? "user" : "assistant"
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
if (obj.type !== "assistant") continue;
|
|
285
|
+
const msg = obj.message;
|
|
286
|
+
if (!msg || !msg.usage) continue;
|
|
287
|
+
const usage = msg.usage;
|
|
288
|
+
if (usage.input_tokens == null && usage.output_tokens == null)
|
|
289
|
+
continue;
|
|
290
|
+
const uuid = obj.uuid;
|
|
291
|
+
if (uuid) {
|
|
292
|
+
if (seenUuids.has(uuid)) continue;
|
|
293
|
+
seenUuids.add(uuid);
|
|
294
|
+
}
|
|
295
|
+
entries.push({
|
|
296
|
+
sessionId,
|
|
297
|
+
source: "claude-code",
|
|
298
|
+
model: msg.model || "unknown",
|
|
299
|
+
project,
|
|
300
|
+
timestamp: ts,
|
|
301
|
+
inputTokens: usage.input_tokens || 0,
|
|
302
|
+
outputTokens: usage.output_tokens || 0,
|
|
303
|
+
reasoningTokens: 0,
|
|
304
|
+
cachedTokens: usage.cache_read_input_tokens || 0
|
|
305
|
+
});
|
|
306
|
+
} catch {
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const transcriptFiles = findJsonlFiles(TRANSCRIPTS_DIR);
|
|
311
|
+
for (const filePath of transcriptFiles) {
|
|
312
|
+
const sessionId = extractSessionId(filePath);
|
|
313
|
+
if (seenSessionIds.has(sessionId)) continue;
|
|
314
|
+
const content = readFileSafe(filePath);
|
|
315
|
+
if (!content) continue;
|
|
316
|
+
for (const line of content.split("\n")) {
|
|
317
|
+
if (!line.trim()) continue;
|
|
318
|
+
try {
|
|
319
|
+
const obj = JSON.parse(line);
|
|
320
|
+
const timestamp = obj.timestamp;
|
|
321
|
+
if (!timestamp) continue;
|
|
322
|
+
const ts = new Date(timestamp);
|
|
323
|
+
if (Number.isNaN(ts.getTime())) continue;
|
|
324
|
+
if (obj.type === "user" || obj.type === "assistant") {
|
|
325
|
+
sessionEvents.push({
|
|
326
|
+
sessionId,
|
|
327
|
+
source: "claude-code",
|
|
328
|
+
project: "unknown",
|
|
329
|
+
timestamp: ts,
|
|
330
|
+
role: obj.type === "user" ? "user" : "assistant"
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
} catch {
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return {
|
|
338
|
+
buckets: aggregateToBuckets(entries),
|
|
339
|
+
sessions: extractSessions(sessionEvents, entries)
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
registerParser(new ClaudeCodeParser());
|
|
344
|
+
|
|
345
|
+
// src/parsers/codex.ts
|
|
346
|
+
import { homedir as homedir2 } from "os";
|
|
347
|
+
import { join as join3 } from "path";
|
|
348
|
+
var TOOL2 = {
|
|
349
|
+
id: "codex",
|
|
350
|
+
name: "Codex CLI",
|
|
351
|
+
dataDir: join3(homedir2(), ".codex", "sessions")
|
|
352
|
+
};
|
|
353
|
+
var CodexParser = class {
|
|
354
|
+
tool = TOOL2;
|
|
355
|
+
async parse() {
|
|
356
|
+
const entries = [];
|
|
357
|
+
const sessionEvents = [];
|
|
358
|
+
const files = findJsonlFiles(TOOL2.dataDir);
|
|
359
|
+
if (files.length === 0) {
|
|
360
|
+
return { buckets: [], sessions: [] };
|
|
361
|
+
}
|
|
362
|
+
for (const filePath of files) {
|
|
363
|
+
const content = readFileSafe(filePath);
|
|
364
|
+
if (!content) continue;
|
|
365
|
+
let sessionProject = "unknown";
|
|
366
|
+
const sessionModel = "unknown";
|
|
367
|
+
for (const line of content.split("\n")) {
|
|
368
|
+
if (!line.trim()) continue;
|
|
369
|
+
try {
|
|
370
|
+
const obj = JSON.parse(line);
|
|
371
|
+
if (obj.type === "session_meta" && obj.payload) {
|
|
372
|
+
const meta = obj.payload;
|
|
373
|
+
if (meta.cwd) {
|
|
374
|
+
sessionProject = meta.cwd.split("/").pop() || "unknown";
|
|
375
|
+
}
|
|
376
|
+
if (meta.git?.repository_url) {
|
|
377
|
+
const match = meta.git.repository_url.match(
|
|
378
|
+
/([^/]+\/[^/]+?)(?:\.git)?$/
|
|
379
|
+
);
|
|
380
|
+
if (match) sessionProject = match[1];
|
|
381
|
+
}
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
384
|
+
} catch {
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
let turnContextModel = "unknown";
|
|
389
|
+
const prevTotal = /* @__PURE__ */ new Map();
|
|
390
|
+
for (const line of content.split("\n")) {
|
|
391
|
+
if (!line.trim()) continue;
|
|
392
|
+
try {
|
|
393
|
+
const obj = JSON.parse(line);
|
|
394
|
+
if (obj.type === "turn_context" && obj.timestamp) {
|
|
395
|
+
const evTs = new Date(obj.timestamp);
|
|
396
|
+
if (!Number.isNaN(evTs.getTime())) {
|
|
397
|
+
sessionEvents.push({
|
|
398
|
+
sessionId: filePath,
|
|
399
|
+
source: "codex",
|
|
400
|
+
project: sessionProject,
|
|
401
|
+
timestamp: evTs,
|
|
402
|
+
role: "user"
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (obj.type === "turn_context" && obj.payload?.model) {
|
|
407
|
+
turnContextModel = obj.payload.model;
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
if (obj.type !== "event_msg") continue;
|
|
411
|
+
const payload = obj.payload;
|
|
412
|
+
if (!payload) continue;
|
|
413
|
+
if (payload.type !== "token_count") continue;
|
|
414
|
+
const info = payload.info;
|
|
415
|
+
if (!info) continue;
|
|
416
|
+
const timestamp = obj.timestamp ? new Date(obj.timestamp) : null;
|
|
417
|
+
if (!timestamp || Number.isNaN(timestamp.getTime())) continue;
|
|
418
|
+
sessionEvents.push({
|
|
419
|
+
sessionId: filePath,
|
|
420
|
+
source: "codex",
|
|
421
|
+
project: sessionProject,
|
|
422
|
+
timestamp,
|
|
423
|
+
role: "assistant"
|
|
424
|
+
});
|
|
425
|
+
let usage = info.last_token_usage;
|
|
426
|
+
if (!usage && info.total_token_usage) {
|
|
427
|
+
const totalKey = `${info.model || payload.model || turnContextModel || ""}`;
|
|
428
|
+
const prev = prevTotal.get(totalKey);
|
|
429
|
+
const curr = info.total_token_usage;
|
|
430
|
+
if (prev) {
|
|
431
|
+
usage = {
|
|
432
|
+
input_tokens: (curr.input_tokens || 0) - (prev.input_tokens || 0),
|
|
433
|
+
output_tokens: (curr.output_tokens || 0) - (prev.output_tokens || 0),
|
|
434
|
+
cached_input_tokens: (curr.cached_input_tokens || 0) - (prev.cached_input_tokens || 0),
|
|
435
|
+
reasoning_output_tokens: (curr.reasoning_output_tokens || 0) - (prev.reasoning_output_tokens || 0)
|
|
436
|
+
};
|
|
437
|
+
} else {
|
|
438
|
+
usage = curr;
|
|
439
|
+
}
|
|
440
|
+
prevTotal.set(totalKey, { ...curr });
|
|
441
|
+
}
|
|
442
|
+
if (!usage) continue;
|
|
443
|
+
const model = info.model || payload.model || turnContextModel || sessionModel;
|
|
444
|
+
const cachedInput = usage.cached_input_tokens || 0;
|
|
445
|
+
const reasoningTokens = usage.reasoning_output_tokens || 0;
|
|
446
|
+
entries.push({
|
|
447
|
+
sessionId: filePath,
|
|
448
|
+
source: "codex",
|
|
449
|
+
model,
|
|
450
|
+
project: sessionProject,
|
|
451
|
+
timestamp,
|
|
452
|
+
inputTokens: (usage.input_tokens || 0) - cachedInput,
|
|
453
|
+
outputTokens: usage.output_tokens || 0,
|
|
454
|
+
reasoningTokens,
|
|
455
|
+
cachedTokens: cachedInput
|
|
456
|
+
});
|
|
457
|
+
} catch {
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return {
|
|
462
|
+
buckets: aggregateToBuckets(entries),
|
|
463
|
+
sessions: extractSessions(sessionEvents, entries)
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
registerParser(new CodexParser());
|
|
468
|
+
|
|
469
|
+
// src/parsers/gemini-cli.ts
|
|
470
|
+
import { existsSync as existsSync3, readdirSync as readdirSync2, readFileSync as readFileSync2 } from "fs";
|
|
471
|
+
import { homedir as homedir3 } from "os";
|
|
472
|
+
import { join as join4 } from "path";
|
|
473
|
+
var TOOL3 = {
|
|
474
|
+
id: "gemini-cli",
|
|
475
|
+
name: "Gemini CLI",
|
|
476
|
+
dataDir: join4(homedir3(), ".gemini", "tmp")
|
|
477
|
+
};
|
|
478
|
+
function findSessionFiles(baseDir) {
|
|
479
|
+
const results = [];
|
|
480
|
+
if (!existsSync3(baseDir)) return results;
|
|
481
|
+
try {
|
|
482
|
+
for (const entry of readdirSync2(baseDir, { withFileTypes: true })) {
|
|
483
|
+
if (!entry.isDirectory()) continue;
|
|
484
|
+
const chatsDir = join4(baseDir, entry.name, "chats");
|
|
485
|
+
if (!existsSync3(chatsDir)) continue;
|
|
486
|
+
try {
|
|
487
|
+
for (const f of readdirSync2(chatsDir)) {
|
|
488
|
+
if (f.startsWith("session-") && f.endsWith(".json")) {
|
|
489
|
+
results.push(join4(chatsDir, f));
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
} catch {
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
} catch {
|
|
496
|
+
return results;
|
|
497
|
+
}
|
|
498
|
+
return results;
|
|
499
|
+
}
|
|
500
|
+
var GeminiCliParser = class {
|
|
501
|
+
tool = TOOL3;
|
|
502
|
+
async parse() {
|
|
503
|
+
const sessionFiles = findSessionFiles(TOOL3.dataDir);
|
|
504
|
+
if (sessionFiles.length === 0) {
|
|
505
|
+
return { buckets: [], sessions: [] };
|
|
506
|
+
}
|
|
507
|
+
const entries = [];
|
|
508
|
+
const sessionEvents = [];
|
|
509
|
+
for (const filePath of sessionFiles) {
|
|
510
|
+
let data;
|
|
511
|
+
try {
|
|
512
|
+
data = JSON.parse(readFileSync2(filePath, "utf-8"));
|
|
513
|
+
} catch {
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
const messages = data.messages || data.history || [];
|
|
517
|
+
for (const msg of messages) {
|
|
518
|
+
const timestamp = msg.timestamp || msg.createTime || data.createTime;
|
|
519
|
+
if (!timestamp) continue;
|
|
520
|
+
const ts = new Date(timestamp);
|
|
521
|
+
if (Number.isNaN(ts.getTime())) continue;
|
|
522
|
+
if (msg.role !== "user" && msg.role !== "assistant") continue;
|
|
523
|
+
const role = msg.role;
|
|
524
|
+
sessionEvents.push({
|
|
525
|
+
sessionId: filePath,
|
|
526
|
+
source: "gemini-cli",
|
|
527
|
+
project: "unknown",
|
|
528
|
+
timestamp: ts,
|
|
529
|
+
role
|
|
530
|
+
});
|
|
531
|
+
const tokens = msg.tokens;
|
|
532
|
+
const usage = msg.usage || msg.usageMetadata || msg.token_count;
|
|
533
|
+
if (!tokens && !usage) continue;
|
|
534
|
+
if (tokens) {
|
|
535
|
+
const cached = tokens.cached || 0;
|
|
536
|
+
const thoughts = tokens.thoughts || 0;
|
|
537
|
+
entries.push({
|
|
538
|
+
sessionId: filePath,
|
|
539
|
+
source: "gemini-cli",
|
|
540
|
+
model: msg.model || data.model || "unknown",
|
|
541
|
+
project: "unknown",
|
|
542
|
+
timestamp: ts,
|
|
543
|
+
inputTokens: (tokens.input || 0) - cached,
|
|
544
|
+
outputTokens: tokens.output || 0,
|
|
545
|
+
reasoningTokens: thoughts,
|
|
546
|
+
cachedTokens: cached
|
|
547
|
+
});
|
|
548
|
+
} else if (usage) {
|
|
549
|
+
const cached = usage.cachedContentTokenCount || 0;
|
|
550
|
+
const thoughts = usage.thoughtsTokenCount || 0;
|
|
551
|
+
entries.push({
|
|
552
|
+
sessionId: filePath,
|
|
553
|
+
source: "gemini-cli",
|
|
554
|
+
model: msg.model || data.model || "unknown",
|
|
555
|
+
project: "unknown",
|
|
556
|
+
timestamp: ts,
|
|
557
|
+
inputTokens: (usage.promptTokenCount || usage.input_tokens || 0) - cached,
|
|
558
|
+
outputTokens: usage.candidatesTokenCount || usage.output_tokens || 0,
|
|
559
|
+
reasoningTokens: thoughts,
|
|
560
|
+
cachedTokens: cached
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return {
|
|
566
|
+
buckets: aggregateToBuckets(entries),
|
|
567
|
+
sessions: extractSessions(sessionEvents, entries)
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
registerParser(new GeminiCliParser());
|
|
572
|
+
|
|
573
|
+
// src/parsers/copilot-cli.ts
|
|
574
|
+
import { existsSync as existsSync4, readdirSync as readdirSync3, readFileSync as readFileSync3 } from "fs";
|
|
575
|
+
import { homedir as homedir4 } from "os";
|
|
576
|
+
import { basename as basename2, join as join5 } from "path";
|
|
577
|
+
var TOOL4 = {
|
|
578
|
+
id: "copilot-cli",
|
|
579
|
+
name: "GitHub Copilot CLI",
|
|
580
|
+
dataDir: join5(homedir4(), ".copilot", "session-state")
|
|
581
|
+
};
|
|
582
|
+
function findEventFiles(baseDir) {
|
|
583
|
+
const results = [];
|
|
584
|
+
if (!existsSync4(baseDir)) return results;
|
|
585
|
+
try {
|
|
586
|
+
for (const entry of readdirSync3(baseDir, { withFileTypes: true })) {
|
|
587
|
+
if (!entry.isDirectory()) continue;
|
|
588
|
+
const eventsFile = join5(baseDir, entry.name, "events.jsonl");
|
|
589
|
+
if (existsSync4(eventsFile)) {
|
|
590
|
+
results.push({ filePath: eventsFile, sessionId: entry.name });
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
} catch {
|
|
594
|
+
return results;
|
|
595
|
+
}
|
|
596
|
+
return results;
|
|
597
|
+
}
|
|
598
|
+
function getProjectFromContext(context) {
|
|
599
|
+
const projectPath = context?.gitRoot || context?.cwd;
|
|
600
|
+
if (!projectPath) return "unknown";
|
|
601
|
+
return basename2(projectPath) || "unknown";
|
|
602
|
+
}
|
|
603
|
+
var CopilotCliParser = class {
|
|
604
|
+
tool = TOOL4;
|
|
605
|
+
async parse() {
|
|
606
|
+
const eventFiles = findEventFiles(TOOL4.dataDir);
|
|
607
|
+
if (eventFiles.length === 0) {
|
|
608
|
+
return { buckets: [], sessions: [] };
|
|
609
|
+
}
|
|
610
|
+
const entries = [];
|
|
611
|
+
const sessionEvents = [];
|
|
612
|
+
for (const { filePath, sessionId } of eventFiles) {
|
|
613
|
+
let content;
|
|
614
|
+
try {
|
|
615
|
+
content = readFileSync3(filePath, "utf-8");
|
|
616
|
+
} catch {
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
let currentProject = "unknown";
|
|
620
|
+
for (const line of content.split("\n")) {
|
|
621
|
+
if (!line.trim()) continue;
|
|
622
|
+
try {
|
|
623
|
+
const obj = JSON.parse(line);
|
|
624
|
+
const timestamp = obj.timestamp ? new Date(obj.timestamp) : null;
|
|
625
|
+
const hasTimestamp = timestamp && !Number.isNaN(timestamp.getTime());
|
|
626
|
+
if (obj.type === "session.start" || obj.type === "session.resume") {
|
|
627
|
+
currentProject = getProjectFromContext(obj.data?.context);
|
|
628
|
+
}
|
|
629
|
+
if (hasTimestamp && timestamp && obj.type === "user.message") {
|
|
630
|
+
sessionEvents.push({
|
|
631
|
+
sessionId,
|
|
632
|
+
source: "copilot-cli",
|
|
633
|
+
project: currentProject,
|
|
634
|
+
timestamp,
|
|
635
|
+
role: "user"
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
if (hasTimestamp && timestamp && obj.type === "assistant.message") {
|
|
639
|
+
sessionEvents.push({
|
|
640
|
+
sessionId,
|
|
641
|
+
source: "copilot-cli",
|
|
642
|
+
project: currentProject,
|
|
643
|
+
timestamp,
|
|
644
|
+
role: "assistant"
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
if (obj.type !== "session.shutdown" || !hasTimestamp || !timestamp)
|
|
648
|
+
continue;
|
|
649
|
+
const modelMetrics = obj.data?.modelMetrics || {};
|
|
650
|
+
for (const [model, metrics] of Object.entries(modelMetrics)) {
|
|
651
|
+
const usage = metrics?.usage;
|
|
652
|
+
if (!usage) continue;
|
|
653
|
+
const totalInput = usage.inputTokens || 0;
|
|
654
|
+
const cachedRead = usage.cacheReadTokens || 0;
|
|
655
|
+
const output = usage.outputTokens || 0;
|
|
656
|
+
if (totalInput === 0 && cachedRead === 0 && output === 0) {
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
entries.push({
|
|
660
|
+
sessionId,
|
|
661
|
+
source: "copilot-cli",
|
|
662
|
+
model,
|
|
663
|
+
project: currentProject,
|
|
664
|
+
timestamp,
|
|
665
|
+
inputTokens: Math.max(0, totalInput - cachedRead),
|
|
666
|
+
outputTokens: output,
|
|
667
|
+
reasoningTokens: 0,
|
|
668
|
+
cachedTokens: cachedRead
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
} catch {
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
return {
|
|
676
|
+
buckets: aggregateToBuckets(entries),
|
|
677
|
+
sessions: extractSessions(sessionEvents, entries)
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
};
|
|
681
|
+
registerParser(new CopilotCliParser());
|
|
682
|
+
|
|
683
|
+
// src/parsers/opencode.ts
|
|
684
|
+
import { execFileSync } from "child_process";
|
|
685
|
+
import { existsSync as existsSync5, readdirSync as readdirSync4, readFileSync as readFileSync4 } from "fs";
|
|
686
|
+
import { homedir as homedir5 } from "os";
|
|
687
|
+
import { basename as basename3, join as join6 } from "path";
|
|
688
|
+
var TOOL5 = {
|
|
689
|
+
id: "opencode",
|
|
690
|
+
name: "OpenCode",
|
|
691
|
+
dataDir: join6(homedir5(), ".local", "share", "opencode")
|
|
692
|
+
};
|
|
693
|
+
var DB_PATH = join6(TOOL5.dataDir, "opencode.db");
|
|
694
|
+
var MESSAGES_DIR = join6(TOOL5.dataDir, "storage", "message");
|
|
695
|
+
var OpenCodeParser = class {
|
|
696
|
+
tool = TOOL5;
|
|
697
|
+
async parse() {
|
|
698
|
+
if (existsSync5(DB_PATH)) {
|
|
699
|
+
try {
|
|
700
|
+
return this.parseFromSqlite();
|
|
701
|
+
} catch (err) {
|
|
702
|
+
process.stderr.write(
|
|
703
|
+
`warn: opencode sqlite parse failed (${err.message}), trying legacy json...
|
|
704
|
+
`
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
return this.parseFromJson();
|
|
709
|
+
}
|
|
710
|
+
parseFromSqlite() {
|
|
711
|
+
const query = `SELECT
|
|
712
|
+
session_id as sessionID,
|
|
713
|
+
json_extract(data, '$.role') as role,
|
|
714
|
+
json_extract(data, '$.time.created') as created,
|
|
715
|
+
json_extract(data, '$.modelID') as modelID,
|
|
716
|
+
json_extract(data, '$.tokens') as tokens,
|
|
717
|
+
json_extract(data, '$.path.root') as rootPath
|
|
718
|
+
FROM message`;
|
|
719
|
+
let output;
|
|
720
|
+
try {
|
|
721
|
+
output = execFileSync("sqlite3", ["-json", DB_PATH, query], {
|
|
722
|
+
encoding: "utf-8",
|
|
723
|
+
maxBuffer: 100 * 1024 * 1024,
|
|
724
|
+
timeout: 3e4
|
|
725
|
+
});
|
|
726
|
+
} catch (err) {
|
|
727
|
+
const nodeErr = err;
|
|
728
|
+
if (nodeErr.status === 127 || nodeErr.message?.includes("ENOENT")) {
|
|
729
|
+
throw new Error(
|
|
730
|
+
"sqlite3 CLI not found. Install sqlite3 to sync opencode data."
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
throw err;
|
|
734
|
+
}
|
|
735
|
+
output = output.trim();
|
|
736
|
+
if (!output || output === "[]") return { buckets: [], sessions: [] };
|
|
737
|
+
let rows;
|
|
738
|
+
try {
|
|
739
|
+
rows = JSON.parse(output);
|
|
740
|
+
} catch {
|
|
741
|
+
throw new Error("Failed to parse sqlite3 JSON output");
|
|
742
|
+
}
|
|
743
|
+
const entries = [];
|
|
744
|
+
const sessionEvents = [];
|
|
745
|
+
for (const row of rows) {
|
|
746
|
+
const timestamp = new Date(row.created);
|
|
747
|
+
if (Number.isNaN(timestamp.getTime())) continue;
|
|
748
|
+
const project = row.rootPath ? basename3(row.rootPath) : "unknown";
|
|
749
|
+
const sessionId = row.sessionID || "unknown";
|
|
750
|
+
if (row.role !== "user" && row.role !== "assistant") continue;
|
|
751
|
+
sessionEvents.push({
|
|
752
|
+
sessionId,
|
|
753
|
+
source: "opencode",
|
|
754
|
+
project,
|
|
755
|
+
timestamp,
|
|
756
|
+
role: row.role === "user" ? "user" : "assistant"
|
|
757
|
+
});
|
|
758
|
+
if (!row.modelID) continue;
|
|
759
|
+
let tokens;
|
|
760
|
+
try {
|
|
761
|
+
tokens = typeof row.tokens === "string" ? JSON.parse(row.tokens) : row.tokens;
|
|
762
|
+
} catch {
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
if (!tokens || !tokens.input && !tokens.output) continue;
|
|
766
|
+
entries.push({
|
|
767
|
+
sessionId,
|
|
768
|
+
source: "opencode",
|
|
769
|
+
model: row.modelID || "unknown",
|
|
770
|
+
project,
|
|
771
|
+
timestamp,
|
|
772
|
+
inputTokens: tokens.input || 0,
|
|
773
|
+
outputTokens: tokens.output || 0,
|
|
774
|
+
reasoningTokens: tokens.reasoning || 0,
|
|
775
|
+
cachedTokens: tokens.cache?.read || 0
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
return {
|
|
779
|
+
buckets: aggregateToBuckets(entries),
|
|
780
|
+
sessions: extractSessions(sessionEvents, entries)
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
parseFromJson() {
|
|
784
|
+
if (!existsSync5(MESSAGES_DIR)) return { buckets: [], sessions: [] };
|
|
785
|
+
const entries = [];
|
|
786
|
+
const sessionEvents = [];
|
|
787
|
+
let sessionDirs;
|
|
788
|
+
try {
|
|
789
|
+
sessionDirs = readdirSync4(MESSAGES_DIR, { withFileTypes: true }).filter(
|
|
790
|
+
(d) => d.isDirectory() && d.name.startsWith("ses_")
|
|
791
|
+
);
|
|
792
|
+
} catch {
|
|
793
|
+
return { buckets: [], sessions: [] };
|
|
794
|
+
}
|
|
795
|
+
for (const sessionDir of sessionDirs) {
|
|
796
|
+
const sessionPath = join6(MESSAGES_DIR, sessionDir.name);
|
|
797
|
+
let msgFiles;
|
|
798
|
+
try {
|
|
799
|
+
msgFiles = readdirSync4(sessionPath).filter((f) => f.endsWith(".json"));
|
|
800
|
+
} catch {
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
for (const file of msgFiles) {
|
|
804
|
+
const filePath = join6(sessionPath, file);
|
|
805
|
+
let data;
|
|
806
|
+
try {
|
|
807
|
+
data = JSON.parse(readFileSync4(filePath, "utf-8"));
|
|
808
|
+
} catch {
|
|
809
|
+
continue;
|
|
810
|
+
}
|
|
811
|
+
const timestamp = new Date(data.time?.created || data.created);
|
|
812
|
+
if (Number.isNaN(timestamp.getTime())) continue;
|
|
813
|
+
const rootPath = data.path?.root;
|
|
814
|
+
const project = rootPath ? basename3(rootPath) : "unknown";
|
|
815
|
+
if (data.role !== "user" && data.role !== "assistant") continue;
|
|
816
|
+
sessionEvents.push({
|
|
817
|
+
sessionId: sessionDir.name,
|
|
818
|
+
source: "opencode",
|
|
819
|
+
project,
|
|
820
|
+
timestamp,
|
|
821
|
+
role: data.role === "user" ? "user" : "assistant"
|
|
822
|
+
});
|
|
823
|
+
if (!data.modelID) continue;
|
|
824
|
+
const tokens = data.tokens;
|
|
825
|
+
if (!tokens || !tokens.input && !tokens.output) continue;
|
|
826
|
+
entries.push({
|
|
827
|
+
sessionId: sessionDir.name,
|
|
828
|
+
source: "opencode",
|
|
829
|
+
model: data.modelID || "unknown",
|
|
830
|
+
project,
|
|
831
|
+
timestamp,
|
|
832
|
+
inputTokens: tokens.input || 0,
|
|
833
|
+
outputTokens: tokens.output || 0,
|
|
834
|
+
reasoningTokens: tokens.reasoning || 0,
|
|
835
|
+
cachedTokens: tokens.cache?.read || 0
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
return {
|
|
840
|
+
buckets: aggregateToBuckets(entries),
|
|
841
|
+
sessions: extractSessions(sessionEvents, entries)
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
};
|
|
845
|
+
registerParser(new OpenCodeParser());
|
|
846
|
+
|
|
847
|
+
// src/parsers/openclaw.ts
|
|
848
|
+
import { existsSync as existsSync6, readdirSync as readdirSync5, readFileSync as readFileSync5 } from "fs";
|
|
849
|
+
import { homedir as homedir6 } from "os";
|
|
850
|
+
import { join as join7 } from "path";
|
|
851
|
+
var POSSIBLE_ROOTS = [
|
|
852
|
+
join7(homedir6(), ".openclaw"),
|
|
853
|
+
join7(homedir6(), ".clawdbot"),
|
|
854
|
+
join7(homedir6(), ".moltbot"),
|
|
855
|
+
join7(homedir6(), ".moldbot")
|
|
856
|
+
];
|
|
857
|
+
var TOOL6 = {
|
|
858
|
+
id: "openclaw",
|
|
859
|
+
name: "OpenClaw",
|
|
860
|
+
dataDir: POSSIBLE_ROOTS[0]
|
|
861
|
+
// Primary data dir for detection
|
|
862
|
+
};
|
|
863
|
+
function getTokens(usage, ...keys) {
|
|
864
|
+
for (const key of keys) {
|
|
865
|
+
const value = usage[key];
|
|
866
|
+
if (typeof value === "number" && value > 0) return value;
|
|
867
|
+
}
|
|
868
|
+
return 0;
|
|
869
|
+
}
|
|
870
|
+
var OpenClawParser = class {
|
|
871
|
+
tool = TOOL6;
|
|
872
|
+
async parse() {
|
|
873
|
+
const entries = [];
|
|
874
|
+
const sessionEvents = [];
|
|
875
|
+
for (const root of POSSIBLE_ROOTS) {
|
|
876
|
+
const agentsDir = join7(root, "agents");
|
|
877
|
+
if (!existsSync6(agentsDir)) continue;
|
|
878
|
+
let agentDirs;
|
|
879
|
+
try {
|
|
880
|
+
agentDirs = readdirSync5(agentsDir, { withFileTypes: true }).filter(
|
|
881
|
+
(d) => d.isDirectory()
|
|
882
|
+
);
|
|
883
|
+
} catch {
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
for (const agentDir of agentDirs) {
|
|
887
|
+
const project = agentDir.name;
|
|
888
|
+
const sessionsDir = join7(agentsDir, agentDir.name, "sessions");
|
|
889
|
+
if (!existsSync6(sessionsDir)) continue;
|
|
890
|
+
let files;
|
|
891
|
+
try {
|
|
892
|
+
files = readdirSync5(sessionsDir).filter((f) => f.endsWith(".jsonl"));
|
|
893
|
+
} catch {
|
|
894
|
+
continue;
|
|
895
|
+
}
|
|
896
|
+
for (const file of files) {
|
|
897
|
+
const filePath = join7(sessionsDir, file);
|
|
898
|
+
let content;
|
|
899
|
+
try {
|
|
900
|
+
content = readFileSync5(filePath, "utf-8");
|
|
901
|
+
} catch {
|
|
902
|
+
continue;
|
|
903
|
+
}
|
|
904
|
+
for (const line of content.split("\n")) {
|
|
905
|
+
if (!line.trim()) continue;
|
|
906
|
+
try {
|
|
907
|
+
const obj = JSON.parse(line);
|
|
908
|
+
if (obj.type !== "message") continue;
|
|
909
|
+
const msg = obj.message;
|
|
910
|
+
if (!msg) continue;
|
|
911
|
+
const timestamp = obj.timestamp || msg.timestamp;
|
|
912
|
+
if (!timestamp) continue;
|
|
913
|
+
const ts = new Date(
|
|
914
|
+
typeof timestamp === "number" ? timestamp : timestamp
|
|
915
|
+
);
|
|
916
|
+
if (Number.isNaN(ts.getTime())) continue;
|
|
917
|
+
if (msg.role !== "user" && msg.role !== "assistant") continue;
|
|
918
|
+
sessionEvents.push({
|
|
919
|
+
sessionId: filePath,
|
|
920
|
+
source: "openclaw",
|
|
921
|
+
project,
|
|
922
|
+
timestamp: ts,
|
|
923
|
+
role: msg.role === "user" ? "user" : "assistant"
|
|
924
|
+
});
|
|
925
|
+
if (msg.role !== "assistant") continue;
|
|
926
|
+
const usage = msg.usage;
|
|
927
|
+
if (!usage) continue;
|
|
928
|
+
entries.push({
|
|
929
|
+
sessionId: filePath,
|
|
930
|
+
source: "openclaw",
|
|
931
|
+
model: msg.model || obj.model || "unknown",
|
|
932
|
+
project,
|
|
933
|
+
timestamp: ts,
|
|
934
|
+
inputTokens: getTokens(
|
|
935
|
+
usage,
|
|
936
|
+
"input",
|
|
937
|
+
"inputTokens",
|
|
938
|
+
"input_tokens",
|
|
939
|
+
"promptTokens",
|
|
940
|
+
"prompt_tokens"
|
|
941
|
+
),
|
|
942
|
+
outputTokens: getTokens(
|
|
943
|
+
usage,
|
|
944
|
+
"output",
|
|
945
|
+
"outputTokens",
|
|
946
|
+
"output_tokens",
|
|
947
|
+
"completionTokens",
|
|
948
|
+
"completion_tokens"
|
|
949
|
+
),
|
|
950
|
+
reasoningTokens: 0,
|
|
951
|
+
cachedTokens: getTokens(
|
|
952
|
+
usage,
|
|
953
|
+
"cacheRead",
|
|
954
|
+
"cache_read",
|
|
955
|
+
"cache_read_input_tokens"
|
|
956
|
+
)
|
|
957
|
+
});
|
|
958
|
+
} catch {
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
return {
|
|
965
|
+
buckets: aggregateToBuckets(entries),
|
|
966
|
+
sessions: extractSessions(sessionEvents, entries)
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
/** Check if any of the possible roots exist */
|
|
970
|
+
isInstalled() {
|
|
971
|
+
return POSSIBLE_ROOTS.some((root) => existsSync6(join7(root, "agents")));
|
|
972
|
+
}
|
|
973
|
+
};
|
|
974
|
+
registerParser(new OpenClawParser());
|
|
975
|
+
|
|
976
|
+
// src/cli.ts
|
|
977
|
+
import { Command, Option } from "commander";
|
|
978
|
+
|
|
979
|
+
// src/infrastructure/config/manager.ts
|
|
980
|
+
import { randomUUID } from "crypto";
|
|
981
|
+
import { existsSync as existsSync7, mkdirSync, readFileSync as readFileSync6, writeFileSync } from "fs";
|
|
982
|
+
import { homedir as homedir7 } from "os";
|
|
983
|
+
import { join as join8 } from "path";
|
|
984
|
+
var CONFIG_DIR = join8(homedir7(), ".tokenarena");
|
|
985
|
+
var isDev = process.env.TOKEN_ARENA_DEV === "1";
|
|
986
|
+
var CONFIG_FILE = join8(CONFIG_DIR, isDev ? "config.dev.json" : "config.json");
|
|
987
|
+
var DEFAULT_API_URL = "http://localhost:3000";
|
|
988
|
+
function getConfigPath() {
|
|
989
|
+
return CONFIG_FILE;
|
|
990
|
+
}
|
|
991
|
+
function getConfigDir() {
|
|
992
|
+
return CONFIG_DIR;
|
|
993
|
+
}
|
|
994
|
+
function loadConfig() {
|
|
995
|
+
if (!existsSync7(CONFIG_FILE)) return null;
|
|
996
|
+
try {
|
|
997
|
+
const raw = readFileSync6(CONFIG_FILE, "utf-8");
|
|
998
|
+
const config = JSON.parse(raw);
|
|
999
|
+
if (!config.apiUrl) {
|
|
1000
|
+
config.apiUrl = DEFAULT_API_URL;
|
|
1001
|
+
}
|
|
1002
|
+
return config;
|
|
1003
|
+
} catch {
|
|
1004
|
+
return null;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
function saveConfig(config) {
|
|
1008
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
1009
|
+
writeFileSync(CONFIG_FILE, `${JSON.stringify(config, null, 2)}
|
|
1010
|
+
`, "utf-8");
|
|
1011
|
+
}
|
|
1012
|
+
function getOrCreateDeviceId(config) {
|
|
1013
|
+
if (config.deviceId) return config.deviceId;
|
|
1014
|
+
const next = randomUUID();
|
|
1015
|
+
saveConfig({ ...config, deviceId: next });
|
|
1016
|
+
return next;
|
|
1017
|
+
}
|
|
1018
|
+
function validateApiKey(key) {
|
|
1019
|
+
return key.startsWith("vbu_");
|
|
1020
|
+
}
|
|
1021
|
+
function getDefaultApiUrl() {
|
|
1022
|
+
return process.env.TOKEN_ARENA_API_URL || DEFAULT_API_URL;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// src/utils/logger.ts
|
|
1026
|
+
var LOG_LEVELS = {
|
|
1027
|
+
debug: 0,
|
|
1028
|
+
info: 1,
|
|
1029
|
+
warn: 2,
|
|
1030
|
+
error: 3
|
|
1031
|
+
};
|
|
1032
|
+
var Logger = class {
|
|
1033
|
+
level;
|
|
1034
|
+
constructor(level = "info") {
|
|
1035
|
+
this.level = level;
|
|
1036
|
+
}
|
|
1037
|
+
setLevel(level) {
|
|
1038
|
+
this.level = level;
|
|
1039
|
+
}
|
|
1040
|
+
debug(msg) {
|
|
1041
|
+
if (LOG_LEVELS[this.level] <= LOG_LEVELS.debug) {
|
|
1042
|
+
process.stderr.write(`[debug] ${msg}
|
|
1043
|
+
`);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
info(msg) {
|
|
1047
|
+
if (LOG_LEVELS[this.level] <= LOG_LEVELS.info) {
|
|
1048
|
+
process.stdout.write(`${msg}
|
|
1049
|
+
`);
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
warn(msg) {
|
|
1053
|
+
if (LOG_LEVELS[this.level] <= LOG_LEVELS.warn) {
|
|
1054
|
+
process.stderr.write(`warn: ${msg}
|
|
1055
|
+
`);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
error(msg) {
|
|
1059
|
+
if (LOG_LEVELS[this.level] <= LOG_LEVELS.error) {
|
|
1060
|
+
process.stderr.write(`error: ${msg}
|
|
1061
|
+
`);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
log(msg) {
|
|
1065
|
+
this.info(msg);
|
|
1066
|
+
}
|
|
1067
|
+
};
|
|
1068
|
+
var logger = new Logger();
|
|
1069
|
+
|
|
1070
|
+
// src/commands/config.ts
|
|
1071
|
+
var VALID_KEYS = ["apiKey", "apiUrl", "syncInterval", "logLevel"];
|
|
1072
|
+
function handleConfig(args) {
|
|
1073
|
+
const sub = args[0];
|
|
1074
|
+
switch (sub) {
|
|
1075
|
+
case "get": {
|
|
1076
|
+
const key = args[1];
|
|
1077
|
+
if (!key) {
|
|
1078
|
+
logger.error("Usage: tokenarena config get <key>");
|
|
1079
|
+
process.exit(1);
|
|
1080
|
+
}
|
|
1081
|
+
const config = loadConfig();
|
|
1082
|
+
if (!config || !(key in config)) {
|
|
1083
|
+
process.exit(0);
|
|
1084
|
+
}
|
|
1085
|
+
console.log(config[key] ?? "");
|
|
1086
|
+
break;
|
|
1087
|
+
}
|
|
1088
|
+
case "set": {
|
|
1089
|
+
const key = args[1];
|
|
1090
|
+
let value = args[2];
|
|
1091
|
+
if (!key || value === void 0) {
|
|
1092
|
+
logger.error("Usage: tokenarena config set <key> <value>");
|
|
1093
|
+
process.exit(1);
|
|
1094
|
+
}
|
|
1095
|
+
if (!VALID_KEYS.includes(key)) {
|
|
1096
|
+
logger.error(`Unknown config key: ${key}`);
|
|
1097
|
+
logger.error(`Valid keys: ${VALID_KEYS.join(", ")}`);
|
|
1098
|
+
process.exit(1);
|
|
1099
|
+
}
|
|
1100
|
+
const config = loadConfig() || {
|
|
1101
|
+
apiKey: "",
|
|
1102
|
+
apiUrl: "http://localhost:3000"
|
|
1103
|
+
};
|
|
1104
|
+
if (key === "syncInterval") {
|
|
1105
|
+
value = parseInt(value, 10);
|
|
1106
|
+
if (Number.isNaN(value)) {
|
|
1107
|
+
logger.error("syncInterval must be a number (milliseconds)");
|
|
1108
|
+
process.exit(1);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
config[key] = value;
|
|
1112
|
+
saveConfig(config);
|
|
1113
|
+
logger.info(`Set ${key} = ${value}`);
|
|
1114
|
+
break;
|
|
1115
|
+
}
|
|
1116
|
+
case "show": {
|
|
1117
|
+
const config = loadConfig();
|
|
1118
|
+
if (!config) {
|
|
1119
|
+
console.log("{}");
|
|
1120
|
+
} else {
|
|
1121
|
+
console.log(JSON.stringify(config, null, 2));
|
|
1122
|
+
}
|
|
1123
|
+
break;
|
|
1124
|
+
}
|
|
1125
|
+
default:
|
|
1126
|
+
logger.error(`Unknown config subcommand: ${sub || "(none)"}`);
|
|
1127
|
+
logger.error("Usage: tokenarena config <get|set|show>");
|
|
1128
|
+
process.exit(1);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// src/services/sync-service.ts
|
|
1133
|
+
import { hostname as hostname3 } from "os";
|
|
1134
|
+
|
|
1135
|
+
// src/domain/project-identity.ts
|
|
1136
|
+
import { createHmac } from "crypto";
|
|
1137
|
+
function toProjectIdentity(input) {
|
|
1138
|
+
if (input.mode === "disabled") {
|
|
1139
|
+
return { projectKey: "unknown", projectLabel: "Unknown Project" };
|
|
1140
|
+
}
|
|
1141
|
+
if (input.mode === "raw") {
|
|
1142
|
+
return { projectKey: input.project, projectLabel: input.project };
|
|
1143
|
+
}
|
|
1144
|
+
const projectKey = createHmac("sha256", input.salt).update(input.project).digest("hex").slice(0, 16);
|
|
1145
|
+
return {
|
|
1146
|
+
projectKey,
|
|
1147
|
+
projectLabel: `Project ${projectKey.slice(0, 6)}`
|
|
1148
|
+
};
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// src/infrastructure/api/client.ts
|
|
1152
|
+
import http from "http";
|
|
1153
|
+
import https from "https";
|
|
1154
|
+
import { URL } from "url";
|
|
1155
|
+
var MAX_RETRIES = 3;
|
|
1156
|
+
var INITIAL_DELAY = 1e3;
|
|
1157
|
+
var TIMEOUT_MS = 6e4;
|
|
1158
|
+
var ApiClient = class {
|
|
1159
|
+
constructor(apiUrl, apiKey) {
|
|
1160
|
+
this.apiUrl = apiUrl;
|
|
1161
|
+
this.apiKey = apiKey;
|
|
1162
|
+
}
|
|
1163
|
+
/**
|
|
1164
|
+
* Ingest buckets and sessions to server
|
|
1165
|
+
*/
|
|
1166
|
+
async ingest(device, buckets, sessions, onProgress) {
|
|
1167
|
+
let lastError;
|
|
1168
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
1169
|
+
try {
|
|
1170
|
+
return await this.sendIngest(device, buckets, sessions, onProgress);
|
|
1171
|
+
} catch (err) {
|
|
1172
|
+
lastError = err;
|
|
1173
|
+
const httpErr = err;
|
|
1174
|
+
if (httpErr.message === "UNAUTHORIZED" || httpErr.statusCode && httpErr.statusCode >= 400 && httpErr.statusCode < 500) {
|
|
1175
|
+
throw err;
|
|
1176
|
+
}
|
|
1177
|
+
if (attempt < MAX_RETRIES - 1) {
|
|
1178
|
+
const delay = INITIAL_DELAY * 2 ** attempt;
|
|
1179
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
throw lastError;
|
|
1184
|
+
}
|
|
1185
|
+
sendIngest(device, buckets, sessions, onProgress) {
|
|
1186
|
+
return new Promise((resolve2, reject) => {
|
|
1187
|
+
const url = new URL("/api/usage/ingest", this.apiUrl);
|
|
1188
|
+
const payload = {
|
|
1189
|
+
schemaVersion: 2,
|
|
1190
|
+
device,
|
|
1191
|
+
buckets,
|
|
1192
|
+
sessions: sessions ?? []
|
|
1193
|
+
};
|
|
1194
|
+
const body = Buffer.from(JSON.stringify(payload));
|
|
1195
|
+
const totalBytes = body.length;
|
|
1196
|
+
const mod = url.protocol === "https:" ? https : http;
|
|
1197
|
+
const req = mod.request(
|
|
1198
|
+
url,
|
|
1199
|
+
{
|
|
1200
|
+
method: "POST",
|
|
1201
|
+
timeout: TIMEOUT_MS,
|
|
1202
|
+
headers: {
|
|
1203
|
+
"Content-Type": "application/json",
|
|
1204
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
1205
|
+
"Content-Length": totalBytes
|
|
1206
|
+
}
|
|
1207
|
+
},
|
|
1208
|
+
(res) => {
|
|
1209
|
+
let data = "";
|
|
1210
|
+
res.on("data", (chunk) => {
|
|
1211
|
+
data += chunk;
|
|
1212
|
+
});
|
|
1213
|
+
res.on("end", () => {
|
|
1214
|
+
if (res.statusCode === 401) {
|
|
1215
|
+
reject(new Error("UNAUTHORIZED"));
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
|
|
1219
|
+
const err = new Error(
|
|
1220
|
+
`HTTP ${res.statusCode}: ${data}`
|
|
1221
|
+
);
|
|
1222
|
+
err.statusCode = res.statusCode;
|
|
1223
|
+
reject(err);
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
try {
|
|
1227
|
+
const response = JSON.parse(data);
|
|
1228
|
+
resolve2({
|
|
1229
|
+
ingested: response.bucketCount ?? response.ingested,
|
|
1230
|
+
sessions: response.sessionCount ?? response.sessions
|
|
1231
|
+
});
|
|
1232
|
+
} catch {
|
|
1233
|
+
reject(new Error(`Invalid JSON response: ${data}`));
|
|
1234
|
+
}
|
|
1235
|
+
});
|
|
1236
|
+
}
|
|
1237
|
+
);
|
|
1238
|
+
req.on("error", (err) => reject(err));
|
|
1239
|
+
req.on("timeout", () => {
|
|
1240
|
+
req.destroy();
|
|
1241
|
+
reject(new Error("Request timed out (60s)"));
|
|
1242
|
+
});
|
|
1243
|
+
const CHUNK = 16 * 1024;
|
|
1244
|
+
let sent = 0;
|
|
1245
|
+
const writeNext = () => {
|
|
1246
|
+
let ok = true;
|
|
1247
|
+
while (ok && sent < totalBytes) {
|
|
1248
|
+
const slice = body.subarray(sent, sent + CHUNK);
|
|
1249
|
+
sent += slice.length;
|
|
1250
|
+
if (onProgress) onProgress(sent, totalBytes);
|
|
1251
|
+
ok = req.write(slice);
|
|
1252
|
+
}
|
|
1253
|
+
if (sent < totalBytes) {
|
|
1254
|
+
req.once("drain", writeNext);
|
|
1255
|
+
} else {
|
|
1256
|
+
req.end();
|
|
1257
|
+
}
|
|
1258
|
+
};
|
|
1259
|
+
writeNext();
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
/**
|
|
1263
|
+
* Fetch user settings from server
|
|
1264
|
+
*/
|
|
1265
|
+
async fetchSettings() {
|
|
1266
|
+
return new Promise((resolve2, reject) => {
|
|
1267
|
+
const url = new URL("/api/usage/settings", this.apiUrl);
|
|
1268
|
+
const mod = url.protocol === "https:" ? https : http;
|
|
1269
|
+
const req = mod.request(
|
|
1270
|
+
url,
|
|
1271
|
+
{
|
|
1272
|
+
method: "GET",
|
|
1273
|
+
timeout: 1e4,
|
|
1274
|
+
headers: {
|
|
1275
|
+
Authorization: `Bearer ${this.apiKey}`
|
|
1276
|
+
}
|
|
1277
|
+
},
|
|
1278
|
+
(res) => {
|
|
1279
|
+
let data = "";
|
|
1280
|
+
res.on("data", (chunk) => {
|
|
1281
|
+
data += chunk;
|
|
1282
|
+
});
|
|
1283
|
+
res.on("end", () => {
|
|
1284
|
+
if (res.statusCode === 401) {
|
|
1285
|
+
reject(new Error("UNAUTHORIZED"));
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
|
|
1289
|
+
resolve2(null);
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
try {
|
|
1293
|
+
const settings = JSON.parse(data);
|
|
1294
|
+
if (settings.schemaVersion !== 2 || !settings.projectMode || !settings.projectHashSalt || !settings.timezone) {
|
|
1295
|
+
resolve2(null);
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
resolve2(settings);
|
|
1299
|
+
} catch {
|
|
1300
|
+
resolve2(null);
|
|
1301
|
+
}
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
);
|
|
1305
|
+
req.on("error", () => resolve2(null));
|
|
1306
|
+
req.on("timeout", () => {
|
|
1307
|
+
req.destroy();
|
|
1308
|
+
resolve2(null);
|
|
1309
|
+
});
|
|
1310
|
+
req.end();
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
1313
|
+
/**
|
|
1314
|
+
* Delete all usage data for the authenticated user
|
|
1315
|
+
*/
|
|
1316
|
+
async deleteAllData(opts) {
|
|
1317
|
+
return new Promise((resolve2, reject) => {
|
|
1318
|
+
const url = new URL("/api/usage/ingest", this.apiUrl);
|
|
1319
|
+
if (opts?.hostname) {
|
|
1320
|
+
url.searchParams.set("hostname", opts.hostname);
|
|
1321
|
+
}
|
|
1322
|
+
const mod = url.protocol === "https:" ? https : http;
|
|
1323
|
+
const req = mod.request(
|
|
1324
|
+
url,
|
|
1325
|
+
{
|
|
1326
|
+
method: "DELETE",
|
|
1327
|
+
timeout: TIMEOUT_MS,
|
|
1328
|
+
headers: {
|
|
1329
|
+
Authorization: `Bearer ${this.apiKey}`
|
|
1330
|
+
}
|
|
1331
|
+
},
|
|
1332
|
+
(res) => {
|
|
1333
|
+
let data = "";
|
|
1334
|
+
res.on("data", (chunk) => {
|
|
1335
|
+
data += chunk;
|
|
1336
|
+
});
|
|
1337
|
+
res.on("end", () => {
|
|
1338
|
+
if (res.statusCode === 401) {
|
|
1339
|
+
reject(new Error("UNAUTHORIZED"));
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
|
|
1343
|
+
const err = new Error(
|
|
1344
|
+
`HTTP ${res.statusCode}: ${data}`
|
|
1345
|
+
);
|
|
1346
|
+
err.statusCode = res.statusCode;
|
|
1347
|
+
reject(err);
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
1350
|
+
try {
|
|
1351
|
+
resolve2(JSON.parse(data));
|
|
1352
|
+
} catch {
|
|
1353
|
+
reject(new Error(`Invalid JSON response: ${data}`));
|
|
1354
|
+
}
|
|
1355
|
+
});
|
|
1356
|
+
}
|
|
1357
|
+
);
|
|
1358
|
+
req.on("error", (err) => reject(err));
|
|
1359
|
+
req.on("timeout", () => {
|
|
1360
|
+
req.destroy();
|
|
1361
|
+
reject(new Error("Request timed out (60s)"));
|
|
1362
|
+
});
|
|
1363
|
+
req.end();
|
|
1364
|
+
});
|
|
1365
|
+
}
|
|
1366
|
+
};
|
|
1367
|
+
|
|
1368
|
+
// src/infrastructure/runtime/lock.ts
|
|
1369
|
+
import {
|
|
1370
|
+
closeSync,
|
|
1371
|
+
existsSync as existsSync8,
|
|
1372
|
+
openSync,
|
|
1373
|
+
readFileSync as readFileSync7,
|
|
1374
|
+
rmSync,
|
|
1375
|
+
writeFileSync as writeFileSync2
|
|
1376
|
+
} from "fs";
|
|
1377
|
+
|
|
1378
|
+
// src/infrastructure/runtime/paths.ts
|
|
1379
|
+
import { mkdirSync as mkdirSync2 } from "fs";
|
|
1380
|
+
import { join as join9 } from "path";
|
|
1381
|
+
function getRuntimeDir() {
|
|
1382
|
+
return join9(getConfigDir(), "runtime");
|
|
1383
|
+
}
|
|
1384
|
+
function getSyncLockPath() {
|
|
1385
|
+
return join9(getRuntimeDir(), "sync.lock");
|
|
1386
|
+
}
|
|
1387
|
+
function getSyncStatePath() {
|
|
1388
|
+
return join9(getRuntimeDir(), "status.json");
|
|
1389
|
+
}
|
|
1390
|
+
function ensureAppRuntimeDirs() {
|
|
1391
|
+
mkdirSync2(getRuntimeDir(), { recursive: true });
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
// src/infrastructure/runtime/lock.ts
|
|
1395
|
+
function isProcessAlive(pid) {
|
|
1396
|
+
try {
|
|
1397
|
+
process.kill(pid, 0);
|
|
1398
|
+
return true;
|
|
1399
|
+
} catch (error) {
|
|
1400
|
+
const code = error.code;
|
|
1401
|
+
return code !== "ESRCH";
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
function readLockMetadata(lockPath) {
|
|
1405
|
+
if (!existsSync8(lockPath)) {
|
|
1406
|
+
return null;
|
|
1407
|
+
}
|
|
1408
|
+
try {
|
|
1409
|
+
return JSON.parse(readFileSync7(lockPath, "utf-8"));
|
|
1410
|
+
} catch {
|
|
1411
|
+
return null;
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
function removeStaleLock(lockPath) {
|
|
1415
|
+
const metadata = readLockMetadata(lockPath);
|
|
1416
|
+
if (!metadata) {
|
|
1417
|
+
rmSync(lockPath, { force: true });
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1420
|
+
if (!isProcessAlive(metadata.pid)) {
|
|
1421
|
+
rmSync(lockPath, { force: true });
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
function tryAcquireSyncLock(source) {
|
|
1425
|
+
ensureAppRuntimeDirs();
|
|
1426
|
+
const lockPath = getSyncLockPath();
|
|
1427
|
+
try {
|
|
1428
|
+
const fd = openSync(lockPath, "wx");
|
|
1429
|
+
const metadata = {
|
|
1430
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1431
|
+
pid: process.pid,
|
|
1432
|
+
source
|
|
1433
|
+
};
|
|
1434
|
+
writeFileSync2(fd, JSON.stringify(metadata, null, 2));
|
|
1435
|
+
closeSync(fd);
|
|
1436
|
+
return {
|
|
1437
|
+
release() {
|
|
1438
|
+
rmSync(lockPath, { force: true });
|
|
1439
|
+
}
|
|
1440
|
+
};
|
|
1441
|
+
} catch (error) {
|
|
1442
|
+
const code = error.code;
|
|
1443
|
+
if (code !== "EEXIST") {
|
|
1444
|
+
throw error;
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
removeStaleLock(lockPath);
|
|
1448
|
+
try {
|
|
1449
|
+
const fd = openSync(lockPath, "wx");
|
|
1450
|
+
const metadata = {
|
|
1451
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1452
|
+
pid: process.pid,
|
|
1453
|
+
source
|
|
1454
|
+
};
|
|
1455
|
+
writeFileSync2(fd, JSON.stringify(metadata, null, 2));
|
|
1456
|
+
closeSync(fd);
|
|
1457
|
+
return {
|
|
1458
|
+
release() {
|
|
1459
|
+
rmSync(lockPath, { force: true });
|
|
1460
|
+
}
|
|
1461
|
+
};
|
|
1462
|
+
} catch (error) {
|
|
1463
|
+
const code = error.code;
|
|
1464
|
+
if (code === "EEXIST") {
|
|
1465
|
+
return null;
|
|
1466
|
+
}
|
|
1467
|
+
throw error;
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
function describeExistingSyncLock() {
|
|
1471
|
+
const metadata = readLockMetadata(getSyncLockPath());
|
|
1472
|
+
if (!metadata) {
|
|
1473
|
+
return null;
|
|
1474
|
+
}
|
|
1475
|
+
return `pid=${metadata.pid}, source=${metadata.source}, createdAt=${metadata.createdAt}`;
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
// src/infrastructure/runtime/state.ts
|
|
1479
|
+
import { existsSync as existsSync9, readFileSync as readFileSync8, writeFileSync as writeFileSync3 } from "fs";
|
|
1480
|
+
function getDefaultState() {
|
|
1481
|
+
return { status: "idle" };
|
|
1482
|
+
}
|
|
1483
|
+
function loadSyncState() {
|
|
1484
|
+
const path = getSyncStatePath();
|
|
1485
|
+
if (!existsSync9(path)) {
|
|
1486
|
+
return getDefaultState();
|
|
1487
|
+
}
|
|
1488
|
+
try {
|
|
1489
|
+
return {
|
|
1490
|
+
...getDefaultState(),
|
|
1491
|
+
...JSON.parse(readFileSync8(path, "utf-8"))
|
|
1492
|
+
};
|
|
1493
|
+
} catch {
|
|
1494
|
+
return getDefaultState();
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
function saveSyncState(next) {
|
|
1498
|
+
ensureAppRuntimeDirs();
|
|
1499
|
+
writeFileSync3(
|
|
1500
|
+
getSyncStatePath(),
|
|
1501
|
+
`${JSON.stringify(next, null, 2)}
|
|
1502
|
+
`,
|
|
1503
|
+
"utf-8"
|
|
1504
|
+
);
|
|
1505
|
+
}
|
|
1506
|
+
function markSyncStarted(source) {
|
|
1507
|
+
const current = loadSyncState();
|
|
1508
|
+
saveSyncState({
|
|
1509
|
+
...current,
|
|
1510
|
+
pid: process.pid,
|
|
1511
|
+
lastAttemptAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1512
|
+
lastSource: source,
|
|
1513
|
+
status: "syncing"
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
function markSyncSucceeded(source, result) {
|
|
1517
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1518
|
+
const current = loadSyncState();
|
|
1519
|
+
saveSyncState({
|
|
1520
|
+
...current,
|
|
1521
|
+
lastCompletedAt: now,
|
|
1522
|
+
lastError: void 0,
|
|
1523
|
+
lastFailureAt: void 0,
|
|
1524
|
+
lastResult: result,
|
|
1525
|
+
lastSource: source,
|
|
1526
|
+
lastSuccessAt: now,
|
|
1527
|
+
pid: void 0,
|
|
1528
|
+
status: "idle"
|
|
1529
|
+
});
|
|
1530
|
+
}
|
|
1531
|
+
function markSyncFailed(source, error, status) {
|
|
1532
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1533
|
+
const current = loadSyncState();
|
|
1534
|
+
saveSyncState({
|
|
1535
|
+
...current,
|
|
1536
|
+
lastCompletedAt: now,
|
|
1537
|
+
lastError: error,
|
|
1538
|
+
lastFailureAt: now,
|
|
1539
|
+
lastSource: source,
|
|
1540
|
+
pid: void 0,
|
|
1541
|
+
status
|
|
1542
|
+
});
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
// src/services/parser-service.ts
|
|
1546
|
+
async function runAllParsers() {
|
|
1547
|
+
const allBuckets = [];
|
|
1548
|
+
const allSessions = [];
|
|
1549
|
+
const parserResults = [];
|
|
1550
|
+
for (const parser of getAllParsers()) {
|
|
1551
|
+
try {
|
|
1552
|
+
const result = await parser.parse();
|
|
1553
|
+
const buckets = result.buckets;
|
|
1554
|
+
const sessions = result.sessions;
|
|
1555
|
+
if (buckets.length > 0) allBuckets.push(...buckets);
|
|
1556
|
+
if (sessions.length > 0) allSessions.push(...sessions);
|
|
1557
|
+
if (buckets.length > 0 || sessions.length > 0) {
|
|
1558
|
+
parserResults.push({
|
|
1559
|
+
source: parser.tool.id,
|
|
1560
|
+
buckets: buckets.length,
|
|
1561
|
+
sessions: sessions.length
|
|
1562
|
+
});
|
|
1563
|
+
}
|
|
1564
|
+
} catch (err) {
|
|
1565
|
+
logger.warn(`${parser.tool.id} parser failed: ${err.message}`);
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
return { buckets: allBuckets, sessions: allSessions, parserResults };
|
|
1569
|
+
}
|
|
1570
|
+
function getDetectedTools() {
|
|
1571
|
+
return detectInstalledTools();
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
// src/services/sync-service.ts
|
|
1575
|
+
var BATCH_SIZE = 100;
|
|
1576
|
+
var SESSION_BATCH_SIZE = 500;
|
|
1577
|
+
function formatBytes(bytes) {
|
|
1578
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
1579
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
1580
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
1581
|
+
}
|
|
1582
|
+
function formatTime(secs) {
|
|
1583
|
+
if (secs < 60) return `${secs}s`;
|
|
1584
|
+
const h = Math.floor(secs / 3600);
|
|
1585
|
+
const m = Math.floor(secs % 3600 / 60);
|
|
1586
|
+
return h > 0 ? m > 0 ? `${h}h ${m}m` : `${h}h` : `${m}m`;
|
|
1587
|
+
}
|
|
1588
|
+
function toDeviceMetadata(config) {
|
|
1589
|
+
return {
|
|
1590
|
+
deviceId: getOrCreateDeviceId(config),
|
|
1591
|
+
hostname: hostname3().replace(/\.local$/, "")
|
|
1592
|
+
};
|
|
1593
|
+
}
|
|
1594
|
+
function toUploadBuckets(buckets, settings, device) {
|
|
1595
|
+
const aggregated = /* @__PURE__ */ new Map();
|
|
1596
|
+
for (const bucket of buckets) {
|
|
1597
|
+
const project = toProjectIdentity({
|
|
1598
|
+
project: bucket.project || "unknown",
|
|
1599
|
+
mode: settings.projectMode,
|
|
1600
|
+
salt: settings.projectHashSalt
|
|
1601
|
+
});
|
|
1602
|
+
const key = [
|
|
1603
|
+
bucket.source,
|
|
1604
|
+
bucket.model,
|
|
1605
|
+
project.projectKey,
|
|
1606
|
+
bucket.bucketStart,
|
|
1607
|
+
device.deviceId
|
|
1608
|
+
].join("|");
|
|
1609
|
+
const existing = aggregated.get(key);
|
|
1610
|
+
if (existing) {
|
|
1611
|
+
existing.inputTokens += bucket.inputTokens;
|
|
1612
|
+
existing.outputTokens += bucket.outputTokens;
|
|
1613
|
+
existing.reasoningTokens += bucket.reasoningTokens || 0;
|
|
1614
|
+
existing.cachedTokens += bucket.cachedTokens || 0;
|
|
1615
|
+
existing.totalTokens += bucket.totalTokens;
|
|
1616
|
+
continue;
|
|
1617
|
+
}
|
|
1618
|
+
aggregated.set(key, {
|
|
1619
|
+
source: bucket.source,
|
|
1620
|
+
model: bucket.model,
|
|
1621
|
+
projectKey: project.projectKey,
|
|
1622
|
+
projectLabel: project.projectLabel,
|
|
1623
|
+
bucketStart: bucket.bucketStart,
|
|
1624
|
+
deviceId: device.deviceId,
|
|
1625
|
+
hostname: device.hostname,
|
|
1626
|
+
inputTokens: bucket.inputTokens,
|
|
1627
|
+
outputTokens: bucket.outputTokens,
|
|
1628
|
+
reasoningTokens: bucket.reasoningTokens || 0,
|
|
1629
|
+
cachedTokens: bucket.cachedTokens || 0,
|
|
1630
|
+
totalTokens: bucket.totalTokens
|
|
1631
|
+
});
|
|
1632
|
+
}
|
|
1633
|
+
return Array.from(aggregated.values());
|
|
1634
|
+
}
|
|
1635
|
+
function toUploadSessions(sessions, settings, device) {
|
|
1636
|
+
return sessions.map((session) => {
|
|
1637
|
+
const project = toProjectIdentity({
|
|
1638
|
+
project: session.project || "unknown",
|
|
1639
|
+
mode: settings.projectMode,
|
|
1640
|
+
salt: settings.projectHashSalt
|
|
1641
|
+
});
|
|
1642
|
+
return {
|
|
1643
|
+
source: session.source,
|
|
1644
|
+
projectKey: project.projectKey,
|
|
1645
|
+
projectLabel: project.projectLabel,
|
|
1646
|
+
sessionHash: session.sessionHash,
|
|
1647
|
+
deviceId: device.deviceId,
|
|
1648
|
+
hostname: device.hostname,
|
|
1649
|
+
firstMessageAt: session.firstMessageAt,
|
|
1650
|
+
lastMessageAt: session.lastMessageAt,
|
|
1651
|
+
durationSeconds: session.durationSeconds,
|
|
1652
|
+
activeSeconds: session.activeSeconds,
|
|
1653
|
+
messageCount: session.messageCount,
|
|
1654
|
+
userMessageCount: session.userMessageCount,
|
|
1655
|
+
inputTokens: session.inputTokens,
|
|
1656
|
+
outputTokens: session.outputTokens,
|
|
1657
|
+
reasoningTokens: session.reasoningTokens,
|
|
1658
|
+
cachedTokens: session.cachedTokens,
|
|
1659
|
+
totalTokens: session.totalTokens,
|
|
1660
|
+
primaryModel: session.primaryModel,
|
|
1661
|
+
modelUsages: session.modelUsages
|
|
1662
|
+
};
|
|
1663
|
+
});
|
|
1664
|
+
}
|
|
1665
|
+
var SyncFailure = class extends Error {
|
|
1666
|
+
constructor(message, kind, causeError) {
|
|
1667
|
+
super(message);
|
|
1668
|
+
this.kind = kind;
|
|
1669
|
+
this.causeError = causeError;
|
|
1670
|
+
}
|
|
1671
|
+
};
|
|
1672
|
+
async function runSync(config, opts = {}) {
|
|
1673
|
+
const { quiet = false, source = "manual", throws = false } = opts;
|
|
1674
|
+
const lock = tryAcquireSyncLock(source);
|
|
1675
|
+
if (!lock) {
|
|
1676
|
+
const detail = describeExistingSyncLock();
|
|
1677
|
+
const message = detail ? `Another sync is already running (${detail}). Skipping.` : "Another sync is already running. Skipping.";
|
|
1678
|
+
markSyncFailed(source, message, "skipped_locked");
|
|
1679
|
+
logger.info(message);
|
|
1680
|
+
return {
|
|
1681
|
+
buckets: 0,
|
|
1682
|
+
sessions: 0,
|
|
1683
|
+
skipped: "locked"
|
|
1684
|
+
};
|
|
1685
|
+
}
|
|
1686
|
+
markSyncStarted(source);
|
|
1687
|
+
let totalIngested = 0;
|
|
1688
|
+
let totalSessionsSynced = 0;
|
|
1689
|
+
let caughtError = null;
|
|
1690
|
+
try {
|
|
1691
|
+
const {
|
|
1692
|
+
buckets: allBuckets,
|
|
1693
|
+
sessions: allSessions,
|
|
1694
|
+
parserResults
|
|
1695
|
+
} = await runAllParsers();
|
|
1696
|
+
if (allBuckets.length === 0 && allSessions.length === 0) {
|
|
1697
|
+
if (!quiet) {
|
|
1698
|
+
logger.info("No new usage data found.");
|
|
1699
|
+
}
|
|
1700
|
+
markSyncSucceeded(source, { buckets: 0, sessions: 0 });
|
|
1701
|
+
return { buckets: 0, sessions: 0 };
|
|
1702
|
+
}
|
|
1703
|
+
if (!quiet && parserResults.length > 0) {
|
|
1704
|
+
for (const p of parserResults) {
|
|
1705
|
+
const parts = [];
|
|
1706
|
+
if (p.buckets > 0) parts.push(`${p.buckets} buckets`);
|
|
1707
|
+
if (p.sessions > 0) parts.push(`${p.sessions} sessions`);
|
|
1708
|
+
logger.info(` ${p.source}: ${parts.join(", ")}`);
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
const apiUrl = config.apiUrl || "http://localhost:3000";
|
|
1712
|
+
const apiClient = new ApiClient(apiUrl, config.apiKey);
|
|
1713
|
+
let settings;
|
|
1714
|
+
try {
|
|
1715
|
+
settings = await apiClient.fetchSettings();
|
|
1716
|
+
} catch (error) {
|
|
1717
|
+
if (error.message === "UNAUTHORIZED") {
|
|
1718
|
+
throw new SyncFailure(
|
|
1719
|
+
"Invalid API key. Run `tokenarena init` to reconfigure.",
|
|
1720
|
+
"auth_error",
|
|
1721
|
+
error
|
|
1722
|
+
);
|
|
1723
|
+
}
|
|
1724
|
+
settings = null;
|
|
1725
|
+
}
|
|
1726
|
+
if (!settings) {
|
|
1727
|
+
throw new SyncFailure(
|
|
1728
|
+
"Could not fetch usage settings. Check your server URL and API key.",
|
|
1729
|
+
"error"
|
|
1730
|
+
);
|
|
1731
|
+
}
|
|
1732
|
+
const device = toDeviceMetadata(config);
|
|
1733
|
+
const uploadBuckets = toUploadBuckets(allBuckets, settings, device);
|
|
1734
|
+
const uploadSessions = toUploadSessions(allSessions, settings, device);
|
|
1735
|
+
if (!quiet) {
|
|
1736
|
+
const projectModeLabel = {
|
|
1737
|
+
hashed: "\u54C8\u5E0C\u5316",
|
|
1738
|
+
raw: "\u539F\u59CB\u540D\u79F0",
|
|
1739
|
+
disabled: "\u5DF2\u9690\u85CF"
|
|
1740
|
+
};
|
|
1741
|
+
logger.info(`\u{1F4C2} \u9879\u76EE\u6A21\u5F0F: ${projectModeLabel[settings.projectMode]}`);
|
|
1742
|
+
}
|
|
1743
|
+
const bucketBatches = Math.ceil(uploadBuckets.length / BATCH_SIZE);
|
|
1744
|
+
const sessionBatches = Math.ceil(
|
|
1745
|
+
uploadSessions.length / SESSION_BATCH_SIZE
|
|
1746
|
+
);
|
|
1747
|
+
const totalBatches = Math.max(bucketBatches, sessionBatches, 1);
|
|
1748
|
+
if (!quiet) {
|
|
1749
|
+
const parts = [];
|
|
1750
|
+
if (uploadBuckets.length > 0) {
|
|
1751
|
+
parts.push(`${uploadBuckets.length} buckets`);
|
|
1752
|
+
}
|
|
1753
|
+
if (uploadSessions.length > 0) {
|
|
1754
|
+
parts.push(`${uploadSessions.length} sessions`);
|
|
1755
|
+
}
|
|
1756
|
+
logger.info(
|
|
1757
|
+
`Uploading ${parts.join(" + ")} (${totalBatches} batch${totalBatches > 1 ? "es" : ""})...`
|
|
1758
|
+
);
|
|
1759
|
+
}
|
|
1760
|
+
for (let batchIdx = 0; batchIdx < totalBatches; batchIdx++) {
|
|
1761
|
+
const batch = uploadBuckets.slice(
|
|
1762
|
+
batchIdx * BATCH_SIZE,
|
|
1763
|
+
(batchIdx + 1) * BATCH_SIZE
|
|
1764
|
+
);
|
|
1765
|
+
const batchSessions = uploadSessions.slice(
|
|
1766
|
+
batchIdx * SESSION_BATCH_SIZE,
|
|
1767
|
+
(batchIdx + 1) * SESSION_BATCH_SIZE
|
|
1768
|
+
);
|
|
1769
|
+
const batchNum = batchIdx + 1;
|
|
1770
|
+
const prefix = totalBatches > 1 ? ` [${batchNum}/${totalBatches}] ` : " ";
|
|
1771
|
+
const result = await apiClient.ingest(
|
|
1772
|
+
device,
|
|
1773
|
+
batch,
|
|
1774
|
+
batchSessions.length > 0 ? batchSessions : void 0,
|
|
1775
|
+
quiet ? void 0 : (sent, total) => {
|
|
1776
|
+
const pct = Math.round(sent / total * 100);
|
|
1777
|
+
process.stdout.write(
|
|
1778
|
+
`\r${prefix}${formatBytes(sent)}/${formatBytes(total)} (${pct}%)\x1B[K`
|
|
1779
|
+
);
|
|
1780
|
+
}
|
|
1781
|
+
);
|
|
1782
|
+
totalIngested += result.ingested ?? batch.length;
|
|
1783
|
+
totalSessionsSynced += result.sessions ?? batchSessions.length;
|
|
1784
|
+
}
|
|
1785
|
+
if (!quiet && (totalBatches > 1 || uploadBuckets.length > 0)) {
|
|
1786
|
+
process.stdout.write("\n");
|
|
1787
|
+
}
|
|
1788
|
+
const syncParts = [`${totalIngested} buckets`];
|
|
1789
|
+
if (totalSessionsSynced > 0) {
|
|
1790
|
+
syncParts.push(`${totalSessionsSynced} sessions`);
|
|
1791
|
+
}
|
|
1792
|
+
logger.info(`Synced ${syncParts.join(" + ")}.`);
|
|
1793
|
+
if (!quiet && totalSessionsSynced > 0) {
|
|
1794
|
+
const totalActive = uploadSessions.reduce(
|
|
1795
|
+
(sum, session) => sum + session.activeSeconds,
|
|
1796
|
+
0
|
|
1797
|
+
);
|
|
1798
|
+
const totalDuration = uploadSessions.reduce(
|
|
1799
|
+
(sum, session) => sum + session.durationSeconds,
|
|
1800
|
+
0
|
|
1801
|
+
);
|
|
1802
|
+
const totalMsgs = uploadSessions.reduce(
|
|
1803
|
+
(sum, session) => sum + session.messageCount,
|
|
1804
|
+
0
|
|
1805
|
+
);
|
|
1806
|
+
logger.info(
|
|
1807
|
+
` active: ${formatTime(totalActive)} / total: ${formatTime(totalDuration)}, ${totalMsgs} messages`
|
|
1808
|
+
);
|
|
1809
|
+
}
|
|
1810
|
+
if (!quiet) {
|
|
1811
|
+
logger.info(`
|
|
1812
|
+
View your dashboard at: ${apiUrl}/usage`);
|
|
1813
|
+
}
|
|
1814
|
+
markSyncSucceeded(source, {
|
|
1815
|
+
buckets: totalIngested,
|
|
1816
|
+
sessions: totalSessionsSynced
|
|
1817
|
+
});
|
|
1818
|
+
return {
|
|
1819
|
+
buckets: totalIngested,
|
|
1820
|
+
sessions: totalSessionsSynced
|
|
1821
|
+
};
|
|
1822
|
+
} catch (error) {
|
|
1823
|
+
const httpErr = error;
|
|
1824
|
+
if (httpErr.message === "UNAUTHORIZED") {
|
|
1825
|
+
caughtError = new SyncFailure(
|
|
1826
|
+
"Invalid API key. Run `tokenarena init` to reconfigure.",
|
|
1827
|
+
"auth_error",
|
|
1828
|
+
error
|
|
1829
|
+
);
|
|
1830
|
+
} else if (error instanceof SyncFailure) {
|
|
1831
|
+
caughtError = error;
|
|
1832
|
+
} else if (totalIngested > 0) {
|
|
1833
|
+
caughtError = new SyncFailure(
|
|
1834
|
+
`Sync partially completed (${totalIngested} buckets uploaded). ${httpErr.message}`,
|
|
1835
|
+
"error",
|
|
1836
|
+
error
|
|
1837
|
+
);
|
|
1838
|
+
} else {
|
|
1839
|
+
caughtError = new SyncFailure(
|
|
1840
|
+
`Sync failed: ${httpErr.message}`,
|
|
1841
|
+
"error",
|
|
1842
|
+
error
|
|
1843
|
+
);
|
|
1844
|
+
}
|
|
1845
|
+
markSyncFailed(source, caughtError.message, caughtError.kind);
|
|
1846
|
+
} finally {
|
|
1847
|
+
lock.release();
|
|
1848
|
+
}
|
|
1849
|
+
if (!caughtError) {
|
|
1850
|
+
return {
|
|
1851
|
+
buckets: totalIngested,
|
|
1852
|
+
sessions: totalSessionsSynced
|
|
1853
|
+
};
|
|
1854
|
+
}
|
|
1855
|
+
logger.error(caughtError.message);
|
|
1856
|
+
if (throws) {
|
|
1857
|
+
throw caughtError.causeError ?? caughtError;
|
|
1858
|
+
}
|
|
1859
|
+
process.exit(1);
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
// src/commands/daemon.ts
|
|
1863
|
+
var DEFAULT_INTERVAL = 5 * 6e4;
|
|
1864
|
+
function log(msg) {
|
|
1865
|
+
const ts = (/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", { hour12: false });
|
|
1866
|
+
process.stdout.write(`[${ts}] ${msg}
|
|
1867
|
+
`);
|
|
1868
|
+
}
|
|
1869
|
+
function sleep(ms) {
|
|
1870
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
1871
|
+
}
|
|
1872
|
+
async function runDaemon(opts = {}) {
|
|
1873
|
+
const config = loadConfig();
|
|
1874
|
+
if (!config?.apiKey) {
|
|
1875
|
+
logger.error("Not configured. Run `tokenarena init` first.");
|
|
1876
|
+
process.exit(1);
|
|
1877
|
+
}
|
|
1878
|
+
const interval = opts.interval || config.syncInterval || DEFAULT_INTERVAL;
|
|
1879
|
+
const intervalMin = Math.round(interval / 6e4);
|
|
1880
|
+
log(`Daemon started (sync every ${intervalMin}m, Ctrl+C to stop)`);
|
|
1881
|
+
while (true) {
|
|
1882
|
+
try {
|
|
1883
|
+
await runSync(config, {
|
|
1884
|
+
quiet: true,
|
|
1885
|
+
source: "daemon",
|
|
1886
|
+
throws: true
|
|
1887
|
+
});
|
|
1888
|
+
} catch (err) {
|
|
1889
|
+
if (err.message === "UNAUTHORIZED") {
|
|
1890
|
+
log("API key invalid. Exiting.");
|
|
1891
|
+
process.exit(1);
|
|
1892
|
+
}
|
|
1893
|
+
log(`Sync error: ${err.message}`);
|
|
1894
|
+
}
|
|
1895
|
+
await sleep(interval);
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
// src/commands/init.ts
|
|
1900
|
+
import { execFile } from "child_process";
|
|
1901
|
+
import { existsSync as existsSync10 } from "fs";
|
|
1902
|
+
import { appendFile, readFile } from "fs/promises";
|
|
1903
|
+
import { homedir as homedir8, platform } from "os";
|
|
1904
|
+
import { createInterface } from "readline";
|
|
1905
|
+
function prompt(question) {
|
|
1906
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1907
|
+
return new Promise((resolve2) => {
|
|
1908
|
+
rl.question(question, (answer) => {
|
|
1909
|
+
rl.close();
|
|
1910
|
+
resolve2(answer.trim());
|
|
1911
|
+
});
|
|
1912
|
+
});
|
|
1913
|
+
}
|
|
1914
|
+
function openBrowser(url) {
|
|
1915
|
+
const cmds = {
|
|
1916
|
+
darwin: "open",
|
|
1917
|
+
linux: "xdg-open",
|
|
1918
|
+
win32: "start"
|
|
1919
|
+
};
|
|
1920
|
+
const cmd = cmds[platform()] || cmds.linux;
|
|
1921
|
+
execFile(cmd, [url], () => {
|
|
1922
|
+
});
|
|
1923
|
+
}
|
|
1924
|
+
async function runInit(opts = {}) {
|
|
1925
|
+
logger.info("\n tokenarena - Token Usage Tracker\n");
|
|
1926
|
+
const existing = loadConfig();
|
|
1927
|
+
if (existing?.apiKey) {
|
|
1928
|
+
const answer = await prompt("Config already exists. Overwrite? (y/N) ");
|
|
1929
|
+
if (answer.toLowerCase() !== "y") {
|
|
1930
|
+
logger.info("Cancelled.");
|
|
1931
|
+
return;
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
const apiUrl = opts.apiUrl || getDefaultApiUrl();
|
|
1935
|
+
logger.info(`Open ${apiUrl}/usage and create your API key from Settings.
|
|
1936
|
+
`);
|
|
1937
|
+
openBrowser(`${apiUrl}/usage`);
|
|
1938
|
+
let apiKey;
|
|
1939
|
+
while (true) {
|
|
1940
|
+
apiKey = await prompt("Paste your API key: ");
|
|
1941
|
+
if (validateApiKey(apiKey)) break;
|
|
1942
|
+
logger.info('Invalid key \u2014 must start with "vbu_". Try again.');
|
|
1943
|
+
}
|
|
1944
|
+
logger.info(`
|
|
1945
|
+
Verifying key ${apiKey.slice(0, 8)}...`);
|
|
1946
|
+
try {
|
|
1947
|
+
const client = new ApiClient(apiUrl, apiKey);
|
|
1948
|
+
const settings = await client.fetchSettings();
|
|
1949
|
+
if (!settings) {
|
|
1950
|
+
logger.info(
|
|
1951
|
+
"Could not verify key settings (network error). Saving anyway.\n"
|
|
1952
|
+
);
|
|
1953
|
+
} else {
|
|
1954
|
+
logger.info("Key verified.\n");
|
|
1955
|
+
}
|
|
1956
|
+
} catch (err) {
|
|
1957
|
+
if (err.message === "UNAUTHORIZED") {
|
|
1958
|
+
logger.error("Invalid API key. Please check and try again.");
|
|
1959
|
+
process.exit(1);
|
|
1960
|
+
}
|
|
1961
|
+
logger.info("Could not verify key (network error). Saving anyway.\n");
|
|
1962
|
+
}
|
|
1963
|
+
const config = {
|
|
1964
|
+
apiKey,
|
|
1965
|
+
apiUrl,
|
|
1966
|
+
...existing?.deviceId ? { deviceId: existing.deviceId } : {}
|
|
1967
|
+
};
|
|
1968
|
+
saveConfig(config);
|
|
1969
|
+
const deviceId = getOrCreateDeviceId(config);
|
|
1970
|
+
config.deviceId = deviceId;
|
|
1971
|
+
logger.info(`Device registered: ${deviceId.slice(0, 8)}...`);
|
|
1972
|
+
const tools = getDetectedTools();
|
|
1973
|
+
if (tools.length > 0) {
|
|
1974
|
+
logger.info(`Detected tools: ${tools.map((t) => t.name).join(", ")}`);
|
|
1975
|
+
} else {
|
|
1976
|
+
logger.info("No AI coding tools detected. Install one and re-run init.");
|
|
1977
|
+
}
|
|
1978
|
+
logger.info("\nRunning initial sync...");
|
|
1979
|
+
await runSync(config, { source: "init" });
|
|
1980
|
+
logger.info(`
|
|
1981
|
+
Setup complete! View your dashboard at: ${apiUrl}/usage`);
|
|
1982
|
+
await setupShellAlias();
|
|
1983
|
+
}
|
|
1984
|
+
async function setupShellAlias() {
|
|
1985
|
+
const shell = process.env.SHELL;
|
|
1986
|
+
if (!shell) {
|
|
1987
|
+
return;
|
|
1988
|
+
}
|
|
1989
|
+
const shellName = shell.split("/").pop() ?? "";
|
|
1990
|
+
const aliasName = "ta";
|
|
1991
|
+
let configFile;
|
|
1992
|
+
let aliasLine;
|
|
1993
|
+
let sourceHint;
|
|
1994
|
+
switch (shellName) {
|
|
1995
|
+
case "zsh":
|
|
1996
|
+
configFile = `${homedir8()}/.zshrc`;
|
|
1997
|
+
aliasLine = `alias ${aliasName}="tokenarena"`;
|
|
1998
|
+
sourceHint = "source ~/.zshrc";
|
|
1999
|
+
break;
|
|
2000
|
+
case "bash":
|
|
2001
|
+
if (platform() === "darwin" && existsSync10(`${homedir8()}/.bash_profile`)) {
|
|
2002
|
+
configFile = `${homedir8()}/.bash_profile`;
|
|
2003
|
+
} else {
|
|
2004
|
+
configFile = `${homedir8()}/.bashrc`;
|
|
2005
|
+
}
|
|
2006
|
+
aliasLine = `alias ${aliasName}="tokenarena"`;
|
|
2007
|
+
sourceHint = `source ${configFile}`;
|
|
2008
|
+
break;
|
|
2009
|
+
case "fish":
|
|
2010
|
+
configFile = `${homedir8()}/.config/fish/config.fish`;
|
|
2011
|
+
aliasLine = `alias ${aliasName} "tokenarena"`;
|
|
2012
|
+
sourceHint = "source ~/.config/fish/config.fish";
|
|
2013
|
+
break;
|
|
2014
|
+
default:
|
|
2015
|
+
return;
|
|
2016
|
+
}
|
|
2017
|
+
const answer = await prompt(
|
|
2018
|
+
`
|
|
2019
|
+
Set up shell alias '${aliasName}' for 'tokenarena'? (Y/n) `
|
|
2020
|
+
);
|
|
2021
|
+
if (answer.toLowerCase() === "n") {
|
|
2022
|
+
return;
|
|
2023
|
+
}
|
|
2024
|
+
try {
|
|
2025
|
+
let existingContent = "";
|
|
2026
|
+
if (existsSync10(configFile)) {
|
|
2027
|
+
existingContent = await readFile(configFile, "utf-8");
|
|
2028
|
+
}
|
|
2029
|
+
const aliasPatterns = [
|
|
2030
|
+
`alias ${aliasName}=`,
|
|
2031
|
+
`alias ${aliasName} "`,
|
|
2032
|
+
`alias ${aliasName}=`
|
|
2033
|
+
];
|
|
2034
|
+
const aliasExists = aliasPatterns.some(
|
|
2035
|
+
(pattern) => existingContent.includes(pattern)
|
|
2036
|
+
);
|
|
2037
|
+
if (aliasExists) {
|
|
2038
|
+
logger.info(
|
|
2039
|
+
`
|
|
2040
|
+
Alias '${aliasName}' already exists in ${configFile}. Skipping.`
|
|
2041
|
+
);
|
|
2042
|
+
return;
|
|
2043
|
+
}
|
|
2044
|
+
const aliasWithComment = `
|
|
2045
|
+
# TokenArena alias
|
|
2046
|
+
${aliasLine}
|
|
2047
|
+
`;
|
|
2048
|
+
await appendFile(configFile, aliasWithComment);
|
|
2049
|
+
logger.info(`
|
|
2050
|
+
Added alias to ${configFile}`);
|
|
2051
|
+
logger.info(` Run '${sourceHint}' or restart your terminal to use it.`);
|
|
2052
|
+
logger.info(` Then you can use: ${aliasName} sync`);
|
|
2053
|
+
} catch (err) {
|
|
2054
|
+
logger.info(
|
|
2055
|
+
`
|
|
2056
|
+
Could not write to ${configFile}: ${err.message}`
|
|
2057
|
+
);
|
|
2058
|
+
logger.info(` Add this line manually: ${aliasLine}`);
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
// src/commands/status.ts
|
|
2063
|
+
import { existsSync as existsSync11 } from "fs";
|
|
2064
|
+
function formatMaybe(value) {
|
|
2065
|
+
return value || "(never)";
|
|
2066
|
+
}
|
|
2067
|
+
async function runStatus() {
|
|
2068
|
+
const config = loadConfig();
|
|
2069
|
+
logger.info("\ntokenarena status\n");
|
|
2070
|
+
if (!config?.apiKey) {
|
|
2071
|
+
logger.info(" Config: not configured");
|
|
2072
|
+
logger.info(` Run \`tokenarena init\` to set up.
|
|
2073
|
+
`);
|
|
2074
|
+
} else {
|
|
2075
|
+
logger.info(` Config: ${getConfigPath()}`);
|
|
2076
|
+
logger.info(` API key: ${config.apiKey.slice(0, 8)}...`);
|
|
2077
|
+
logger.info(` API URL: ${config.apiUrl || "http://localhost:3000"}`);
|
|
2078
|
+
if (config.syncInterval) {
|
|
2079
|
+
logger.info(
|
|
2080
|
+
` Sync interval: ${Math.round(config.syncInterval / 6e4)}m`
|
|
2081
|
+
);
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
logger.info("\n Detected tools:");
|
|
2085
|
+
const detected = detectInstalledTools();
|
|
2086
|
+
if (detected.length === 0) {
|
|
2087
|
+
logger.info(" (none)\n");
|
|
2088
|
+
} else {
|
|
2089
|
+
for (const tool of detected) {
|
|
2090
|
+
logger.info(` ${tool.name}`);
|
|
2091
|
+
}
|
|
2092
|
+
logger.info("");
|
|
2093
|
+
}
|
|
2094
|
+
logger.info(" All supported tools:");
|
|
2095
|
+
for (const tool of getAllTools()) {
|
|
2096
|
+
const installed = existsSync11(tool.dataDir) ? "installed" : "not found";
|
|
2097
|
+
logger.info(` ${tool.name}: ${installed}`);
|
|
2098
|
+
}
|
|
2099
|
+
const syncState = loadSyncState();
|
|
2100
|
+
logger.info("\n Sync state:");
|
|
2101
|
+
logger.info(` Status: ${syncState.status}`);
|
|
2102
|
+
logger.info(` Last attempt: ${formatMaybe(syncState.lastAttemptAt)}`);
|
|
2103
|
+
logger.info(` Last success: ${formatMaybe(syncState.lastSuccessAt)}`);
|
|
2104
|
+
if (syncState.lastSource) {
|
|
2105
|
+
logger.info(` Last source: ${syncState.lastSource}`);
|
|
2106
|
+
}
|
|
2107
|
+
if (syncState.lastError) {
|
|
2108
|
+
logger.info(` Last error: ${syncState.lastError}`);
|
|
2109
|
+
}
|
|
2110
|
+
if (syncState.lastResult) {
|
|
2111
|
+
logger.info(
|
|
2112
|
+
` Last result: ${syncState.lastResult.buckets} buckets, ${syncState.lastResult.sessions} sessions`
|
|
2113
|
+
);
|
|
2114
|
+
}
|
|
2115
|
+
logger.info("");
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
// src/commands/sync.ts
|
|
2119
|
+
async function runSyncCommand(opts = {}) {
|
|
2120
|
+
const config = loadConfig();
|
|
2121
|
+
if (!config?.apiKey) {
|
|
2122
|
+
logger.error("Not configured. Run `tokenarena init` first.");
|
|
2123
|
+
process.exit(1);
|
|
2124
|
+
}
|
|
2125
|
+
await runSync(config, {
|
|
2126
|
+
quiet: opts.quiet,
|
|
2127
|
+
source: "manual"
|
|
2128
|
+
});
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
// src/infrastructure/runtime/cli-version.ts
|
|
2132
|
+
import { readFileSync as readFileSync9 } from "fs";
|
|
2133
|
+
import { dirname, join as join10 } from "path";
|
|
2134
|
+
import { fileURLToPath } from "url";
|
|
2135
|
+
var FALLBACK_VERSION = "0.0.0";
|
|
2136
|
+
var cachedVersion;
|
|
2137
|
+
function getCliVersion(metaUrl = import.meta.url) {
|
|
2138
|
+
if (cachedVersion) {
|
|
2139
|
+
return cachedVersion;
|
|
2140
|
+
}
|
|
2141
|
+
const packageJsonPath = join10(
|
|
2142
|
+
dirname(fileURLToPath(metaUrl)),
|
|
2143
|
+
"..",
|
|
2144
|
+
"package.json"
|
|
2145
|
+
);
|
|
2146
|
+
try {
|
|
2147
|
+
const packageJson = JSON.parse(readFileSync9(packageJsonPath, "utf-8"));
|
|
2148
|
+
cachedVersion = typeof packageJson.version === "string" ? packageJson.version : FALLBACK_VERSION;
|
|
2149
|
+
} catch {
|
|
2150
|
+
cachedVersion = FALLBACK_VERSION;
|
|
2151
|
+
}
|
|
2152
|
+
return cachedVersion;
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
// src/cli.ts
|
|
2156
|
+
var CLI_VERSION = getCliVersion();
|
|
2157
|
+
function createCli() {
|
|
2158
|
+
const program = new Command();
|
|
2159
|
+
program.name("tokenarena").description("Track token burn across AI coding tools").version(CLI_VERSION).showHelpAfterError().showSuggestionAfterError();
|
|
2160
|
+
program.action(async () => {
|
|
2161
|
+
const config = loadConfig();
|
|
2162
|
+
if (!config?.apiKey) {
|
|
2163
|
+
await runInit();
|
|
2164
|
+
} else {
|
|
2165
|
+
await runSync(config, { source: "default" });
|
|
2166
|
+
}
|
|
2167
|
+
});
|
|
2168
|
+
program.command("init").description("Initialize configuration with API key").option("--api-url <url>", "Custom API server URL").action(async (opts) => {
|
|
2169
|
+
await runInit(opts);
|
|
2170
|
+
});
|
|
2171
|
+
program.command("sync").description("Manually sync usage data to server").addOption(new Option("--quiet").hideHelp()).action(async (opts) => {
|
|
2172
|
+
await runSyncCommand(opts);
|
|
2173
|
+
});
|
|
2174
|
+
program.command("daemon").description("Run continuous sync (every 5 minutes by default)").option("--interval <ms>", "Sync interval in milliseconds", parseInt).action(async (opts) => {
|
|
2175
|
+
await runDaemon(opts);
|
|
2176
|
+
});
|
|
2177
|
+
program.command("status").description("Show configuration and detected tools").action(async () => {
|
|
2178
|
+
await runStatus();
|
|
2179
|
+
});
|
|
2180
|
+
program.command("config").description("Manage configuration").argument("<subcommand>", "get|set|show").argument("[key]", "Config key").argument("[value]", "Config value").allowUnknownOption(true).action((_subcommand, _key, _value, cmd) => {
|
|
2181
|
+
const args = cmd.args.slice(1);
|
|
2182
|
+
handleConfig(args);
|
|
2183
|
+
});
|
|
2184
|
+
return program;
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
// src/infrastructure/runtime/main-module.ts
|
|
2188
|
+
import { existsSync as existsSync12, realpathSync } from "fs";
|
|
2189
|
+
import { resolve } from "path";
|
|
2190
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
2191
|
+
function isMainModule(argvEntry = process.argv[1], metaUrl = import.meta.url) {
|
|
2192
|
+
if (!argvEntry) {
|
|
2193
|
+
return false;
|
|
2194
|
+
}
|
|
2195
|
+
const currentModulePath = fileURLToPath2(metaUrl);
|
|
2196
|
+
try {
|
|
2197
|
+
return realpathSync(argvEntry) === realpathSync(currentModulePath);
|
|
2198
|
+
} catch {
|
|
2199
|
+
if (!existsSync12(argvEntry)) {
|
|
2200
|
+
return false;
|
|
2201
|
+
}
|
|
2202
|
+
return resolve(argvEntry) === resolve(currentModulePath);
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
// src/index.ts
|
|
2207
|
+
function normalizeArgv(argv) {
|
|
2208
|
+
return argv.filter((arg, index) => index < 2 || arg !== "--");
|
|
2209
|
+
}
|
|
2210
|
+
function run(argv = process.argv) {
|
|
2211
|
+
const program = createCli();
|
|
2212
|
+
program.parse(normalizeArgv(argv));
|
|
2213
|
+
}
|
|
2214
|
+
if (isMainModule()) {
|
|
2215
|
+
run();
|
|
2216
|
+
}
|
|
2217
|
+
export {
|
|
2218
|
+
normalizeArgv,
|
|
2219
|
+
run
|
|
2220
|
+
};
|
|
2221
|
+
//# sourceMappingURL=index.js.map
|