hacklab 0.6.1 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +12 -3
  2. package/dist/commands/brag.js +4 -4
  3. package/dist/commands/brag.js.map +1 -1
  4. package/dist/commands/chat.d.ts.map +1 -1
  5. package/dist/commands/chat.js +16 -12
  6. package/dist/commands/chat.js.map +1 -1
  7. package/dist/commands/drop.d.ts +9 -0
  8. package/dist/commands/drop.d.ts.map +1 -1
  9. package/dist/commands/drop.js +33 -4
  10. package/dist/commands/drop.js.map +1 -1
  11. package/dist/commands/join.d.ts +3 -1
  12. package/dist/commands/join.d.ts.map +1 -1
  13. package/dist/commands/join.js +47 -3
  14. package/dist/commands/join.js.map +1 -1
  15. package/dist/commands/login.d.ts +18 -1
  16. package/dist/commands/login.d.ts.map +1 -1
  17. package/dist/commands/login.js +156 -19
  18. package/dist/commands/login.js.map +1 -1
  19. package/dist/commands/logout.d.ts +2 -0
  20. package/dist/commands/logout.d.ts.map +1 -0
  21. package/dist/commands/logout.js +15 -0
  22. package/dist/commands/logout.js.map +1 -0
  23. package/dist/commands/org.js +5 -5
  24. package/dist/commands/org.js.map +1 -1
  25. package/dist/commands/sync.d.ts.map +1 -1
  26. package/dist/commands/sync.js +9 -1
  27. package/dist/commands/sync.js.map +1 -1
  28. package/dist/commands/whoami.d.ts.map +1 -1
  29. package/dist/commands/whoami.js +10 -2
  30. package/dist/commands/whoami.js.map +1 -1
  31. package/dist/index.js +36 -104
  32. package/dist/index.js.map +1 -1
  33. package/dist/registry.d.ts +15 -0
  34. package/dist/registry.d.ts.map +1 -0
  35. package/dist/registry.js +77 -0
  36. package/dist/registry.js.map +1 -0
  37. package/dist/resolve-command.d.ts +0 -1
  38. package/dist/resolve-command.d.ts.map +1 -1
  39. package/dist/resolve-command.js +2 -17
  40. package/dist/resolve-command.js.map +1 -1
  41. package/dist/session.d.ts +26 -5
  42. package/dist/session.d.ts.map +1 -1
  43. package/dist/session.js +55 -8
  44. package/dist/session.js.map +1 -1
  45. package/dist/share.d.ts +2 -2
  46. package/dist/share.js +2 -2
  47. package/dist/sync.d.ts +18 -34
  48. package/dist/sync.d.ts.map +1 -1
  49. package/dist/sync.js +47 -686
  50. package/dist/sync.js.map +1 -1
  51. package/dist/ui.d.ts +2 -0
  52. package/dist/ui.d.ts.map +1 -1
  53. package/dist/ui.js +4 -0
  54. package/dist/ui.js.map +1 -1
  55. package/dist/utils/openBrowser.d.ts +12 -0
  56. package/dist/utils/openBrowser.d.ts.map +1 -1
  57. package/dist/utils/openBrowser.js +30 -12
  58. package/dist/utils/openBrowser.js.map +1 -1
  59. package/dist/utils/updateCheck.d.ts +10 -0
  60. package/dist/utils/updateCheck.d.ts.map +1 -0
  61. package/dist/utils/updateCheck.js +106 -0
  62. package/dist/utils/updateCheck.js.map +1 -0
  63. package/package.json +3 -2
  64. package/dist/commands/company.d.ts +0 -2
  65. package/dist/commands/company.d.ts.map +0 -1
  66. package/dist/commands/company.js +0 -214
  67. package/dist/commands/company.js.map +0 -1
  68. package/dist/commands/exam.d.ts +0 -5
  69. package/dist/commands/exam.d.ts.map +0 -1
  70. package/dist/commands/exam.js +0 -224
  71. package/dist/commands/exam.js.map +0 -1
  72. package/dist/commands/scan-profile.d.ts +0 -33
  73. package/dist/commands/scan-profile.d.ts.map +0 -1
  74. package/dist/commands/scan-profile.js +0 -279
  75. package/dist/commands/scan-profile.js.map +0 -1
  76. package/dist/dd.d.ts +0 -3
  77. package/dist/dd.d.ts.map +0 -1
  78. package/dist/dd.js +0 -28
  79. package/dist/dd.js.map +0 -1
package/dist/sync.js CHANGED
@@ -1,17 +1,14 @@
1
- import { readdir, readFile, stat } from 'node:fs/promises';
2
- import { homedir } from 'node:os';
3
- import { join } from 'node:path';
4
- import { loadConfig } from './config.js';
1
+ import { formatBytes, formatTokens, scanAllTools, } from './scanners/index.js';
2
+ import { resolveAppUrl } from './session.js';
5
3
  import { dim } from './ui.js';
4
+ // Token scanning lives in one place — the scanners/ module (used by both `join`
5
+ // and `sync`). This file is just the `sync` command's API layer: session
6
+ // checks, the claim upload, and error formatting. Re-export the shared scan
7
+ // helpers so existing importers don't have to reach into scanners/ directly.
8
+ export { formatBytes, formatTokens };
6
9
  export const LOGIN_EXPIRED_MESSAGE = 'login expired. run hacklab login again';
7
- const HOURLY_WINDOW_DAYS = 90;
8
- const modelAccumulator = new Map();
9
- const hourlyAccumulator = new Map();
10
- const _cursorScanStatus = {
11
- value: { source: 'none' },
12
- };
13
10
  function buildApiUrl(session, path) {
14
- return `${session.appUrl.replace(/\/$/, '')}${path}`;
11
+ return `${resolveAppUrl(session)}${path}`;
15
12
  }
16
13
  function isLoopbackAppUrl(value) {
17
14
  try {
@@ -37,7 +34,7 @@ function getCauseMessage(err) {
37
34
  return err.message === 'fetch failed' ? null : err.message;
38
35
  }
39
36
  function formatFetchFailure(session, err) {
40
- const app = session.appUrl.replace(/\/$/, '');
37
+ const app = resolveAppUrl(session);
41
38
  const cause = getCauseMessage(err);
42
39
  const details = cause ? ` (${cause})` : '';
43
40
  if (isLoopbackAppUrl(app)) {
@@ -74,688 +71,54 @@ export async function checkSession(session) {
74
71
  return { status: 'ok' };
75
72
  return {
76
73
  status: 'failed',
77
- message: `Hacklab API not found at ${session.appUrl.replace(/\/$/, '')}. check ${dim('HACKLAB_APP_URL')} or run ${dim('hacklab login')} again`,
74
+ message: `Hacklab API not found at ${resolveAppUrl(session)}. check ${dim('HACKLAB_APP_URL')} or run ${dim('hacklab login')} again`,
78
75
  };
79
76
  }
80
77
  if (res.status >= 500) {
81
78
  return {
82
79
  status: 'failed',
83
- message: `Hacklab API failed at ${session.appUrl.replace(/\/$/, '')} (${res.status}). try again later`,
80
+ message: `Hacklab API failed at ${resolveAppUrl(session)} (${res.status}). try again later`,
84
81
  };
85
82
  }
86
83
  return { status: 'ok' };
87
84
  }
88
- function hourlyCutoff() {
89
- const d = new Date();
90
- d.setUTCDate(d.getUTCDate() - HOURLY_WINDOW_DAYS);
91
- return d.toISOString().slice(0, 10);
92
- }
93
- function addHourly(date, hour, tool, model, tokens, cutoff, messages = 1) {
94
- if (date < cutoff)
95
- return;
96
- const key = `${date}|${hour}|${tool}|${model ?? ''}`;
97
- const existing = hourlyAccumulator.get(key);
98
- if (existing) {
99
- existing.tokens += tokens;
100
- existing.messages += messages;
101
- }
102
- else {
103
- hourlyAccumulator.set(key, { tokens, messages });
104
- }
105
- }
106
- function flushHourly() {
107
- const out = [];
108
- for (const [key, { tokens, messages }] of hourlyAccumulator.entries()) {
109
- const [date, hourStr, tool, model] = key.split('|');
110
- if (!date || hourStr === undefined || !tool)
111
- continue;
112
- out.push({
113
- date,
114
- hour: Number(hourStr),
115
- tool,
116
- model: model || undefined,
117
- tokens,
118
- messages,
119
- });
120
- }
121
- return out;
122
- }
123
- function addToDaily(map, date, model, tokens, messages) {
124
- const key = `${date}|${model}`;
125
- const existing = map.get(key);
126
- if (existing) {
127
- existing.tokens += tokens;
128
- existing.messages += messages;
129
- }
130
- else {
131
- map.set(key, { tokens, messages });
132
- }
133
- }
134
- function dailyMapToEntries(map, tool) {
135
- return Array.from(map.entries()).map(([key, { tokens, messages }]) => {
136
- const [date, model] = key.split('|');
137
- return {
138
- date: date ?? '',
139
- tool,
140
- tokens,
141
- messages,
142
- model: model || undefined,
143
- };
144
- });
145
- }
146
- function toDateStr(ts) {
147
- const v = typeof ts === 'string' && /^\d+$/.test(ts) ? Number(ts) : ts;
148
- const d = new Date(v);
149
- return d.toISOString().slice(0, 10);
150
- }
151
- export function formatTokens(n) {
152
- if (n >= 1_000_000_000)
153
- return `${(n / 1_000_000_000).toFixed(1)}B`;
154
- if (n >= 1_000_000)
155
- return `${(n / 1_000_000).toFixed(1)}M`;
156
- if (n >= 1_000)
157
- return `${(n / 1_000).toFixed(0)}K`;
158
- return String(n);
159
- }
160
- export function formatBytes(n) {
161
- if (n >= 1_073_741_824)
162
- return `${(n / 1_073_741_824).toFixed(1)}GB`;
163
- if (n >= 1_048_576)
164
- return `${(n / 1_048_576).toFixed(1)}MB`;
165
- if (n >= 1_024)
166
- return `${(n / 1_024).toFixed(0)}KB`;
167
- return `${n}B`;
168
- }
169
- async function findFiles(dir, ext) {
170
- const results = [];
171
- async function walk(current) {
172
- try {
173
- const entries = await readdir(current, { withFileTypes: true });
174
- for (const entry of entries) {
175
- const fullPath = join(current, entry.name);
176
- if (entry.isDirectory()) {
177
- await walk(fullPath);
178
- }
179
- else if (entry.name.endsWith(ext)) {
180
- results.push(fullPath);
181
- }
182
- }
183
- }
184
- catch {
185
- // skip unreadable directories
186
- }
187
- }
188
- await walk(dir);
189
- return results;
190
- }
191
- async function scanClaudeCode() {
192
- const claudeDir = join(homedir(), '.claude', 'projects');
193
- try {
194
- await stat(claudeDir);
195
- }
196
- catch {
197
- return [];
198
- }
199
- const files = await findFiles(claudeDir, '.jsonl');
200
- const dailyByModel = new Map();
201
- const cutoff = hourlyCutoff();
202
- for (const filePath of files) {
203
- try {
204
- const content = await readFile(filePath, 'utf8');
205
- for (const line of content.split('\n')) {
206
- if (!line.trim())
207
- continue;
208
- try {
209
- const parsed = JSON.parse(line);
210
- const usage = parsed.message?.usage ?? parsed.usage ?? null;
211
- if (!usage)
212
- continue;
213
- const tokens = (usage.input_tokens ?? 0) +
214
- (usage.output_tokens ?? 0) +
215
- (usage.cache_creation_input_tokens ?? 0) +
216
- (usage.cache_read_input_tokens ?? 0);
217
- if (tokens <= 0)
218
- continue;
219
- let date;
220
- let hour = null;
221
- if (parsed.timestamp) {
222
- const d = new Date(typeof parsed.timestamp === 'string' &&
223
- /^\d+$/.test(parsed.timestamp)
224
- ? Number(parsed.timestamp)
225
- : parsed.timestamp);
226
- date = toDateStr(d);
227
- hour = d.getHours();
228
- }
229
- else {
230
- const s = await stat(filePath);
231
- date = toDateStr(s.mtime);
232
- }
233
- const model = parsed.message?.model ?? '';
234
- addToDaily(dailyByModel, date, model, tokens, 1);
235
- if (model) {
236
- modelAccumulator.set(model, (modelAccumulator.get(model) ?? 0) + tokens);
237
- }
238
- if (hour !== null) {
239
- addHourly(date, hour, 'claude_code', model, tokens, cutoff, 1);
240
- }
241
- }
242
- catch {
243
- // skip malformed lines
244
- }
245
- }
246
- }
247
- catch {
248
- // skip unreadable files
249
- }
250
- }
251
- return dailyMapToEntries(dailyByModel, 'claude_code');
252
- }
253
- async function scanCodex() {
254
- const codexDir = join(homedir(), '.codex', 'sessions');
255
- try {
256
- await stat(codexDir);
257
- }
258
- catch {
259
- return [];
260
- }
261
- const files = await findFiles(codexDir, '.jsonl');
262
- const dailyByModel = new Map();
263
- for (const filePath of files) {
264
- try {
265
- const content = await readFile(filePath, 'utf8');
266
- const relPath = filePath.slice(codexDir.length + 1);
267
- const parts = relPath.split('/');
268
- let date;
269
- if (parts.length >= 3) {
270
- date = `${parts[0]}-${parts[1]?.padStart(2, '0')}-${parts[2]?.padStart(2, '0')}`;
271
- }
272
- else {
273
- const s = await stat(filePath);
274
- date = toDateStr(s.mtime);
275
- }
276
- let maxTotal = 0;
277
- let fileModel = '';
278
- for (const line of content.split('\n')) {
279
- if (!line.trim())
280
- continue;
281
- try {
282
- const parsed = JSON.parse(line);
283
- if (parsed.payload?.model)
284
- fileModel = parsed.payload.model;
285
- const usage = parsed.payload?.info?.total_token_usage;
286
- if (usage) {
287
- const t = (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0);
288
- if (t > maxTotal)
289
- maxTotal = t;
290
- }
291
- }
292
- catch {
293
- // skip
294
- }
295
- }
296
- if (maxTotal > 0) {
297
- addToDaily(dailyByModel, date, fileModel, maxTotal, 1);
298
- if (fileModel) {
299
- modelAccumulator.set(fileModel, (modelAccumulator.get(fileModel) ?? 0) + maxTotal);
300
- }
301
- }
302
- }
303
- catch {
304
- // skip
305
- }
306
- }
307
- return dailyMapToEntries(dailyByModel, 'codex');
308
- }
309
- async function scanHermes() {
310
- const dbPath = join(homedir(), '.hermes', 'state.db');
311
- try {
312
- await stat(dbPath);
313
- }
314
- catch {
315
- return [];
316
- }
317
- const { execSync } = await import('node:child_process');
318
- const dailyByModel = new Map();
319
- const cutoff = hourlyCutoff();
320
- try {
321
- const raw = execSync(`sqlite3 -readonly "${dbPath}" "select s.id, coalesce(s.model,'') as model, s.started_at, s.input_tokens, s.output_tokens, s.cache_read_tokens, s.cache_write_tokens, s.reasoning_tokens, coalesce((select count(*) from messages m where m.session_id = s.id and m.role = 'assistant'), 0) as msg_count from sessions s"`, { encoding: 'utf8', timeout: 10000 }).trim();
322
- if (!raw)
323
- return [];
324
- for (const row of raw.split('\n')) {
325
- const [_id, model, startedAt, inputTokens, outputTokens, cacheRead, cacheWrite, reasoning, msgCount,] = row.split('|');
326
- const tokens = (Number.parseInt(inputTokens ?? '0', 10) || 0) +
327
- (Number.parseInt(outputTokens ?? '0', 10) || 0) +
328
- (Number.parseInt(cacheRead ?? '0', 10) || 0) +
329
- (Number.parseInt(cacheWrite ?? '0', 10) || 0) +
330
- (Number.parseInt(reasoning ?? '0', 10) || 0);
331
- if (tokens <= 0)
332
- continue;
333
- const tsSec = Number.parseFloat(startedAt ?? '0');
334
- if (!Number.isFinite(tsSec) || tsSec <= 0)
335
- continue;
336
- const tsMs = Math.round(tsSec * 1000);
337
- const d = new Date(tsMs);
338
- const date = toDateStr(d);
339
- const hour = d.getHours();
340
- const m = (model ?? '').trim();
341
- const messages = Number.parseInt(msgCount ?? '0', 10) || 1;
342
- addToDaily(dailyByModel, date, m, tokens, messages);
343
- if (m) {
344
- modelAccumulator.set(m, (modelAccumulator.get(m) ?? 0) + tokens);
345
- }
346
- addHourly(date, hour, 'hermes', m, tokens, cutoff, messages);
347
- }
348
- }
349
- catch {
350
- return [];
351
- }
352
- return dailyMapToEntries(dailyByModel, 'hermes');
353
- }
354
- async function scanOpenclaw() {
355
- const agentsDir = join(homedir(), '.openclaw', 'agents');
356
- try {
357
- await stat(agentsDir);
358
- }
359
- catch {
360
- return [];
361
- }
362
- const sessionFiles = [];
85
+ /**
86
+ * Ask the server to mirror the user's pinned GitHub repos into their projects
87
+ * (and drop ones that are no longer pinned/public). Best-effort and non-fatal —
88
+ * returns null on any failure so a GitHub hiccup never fails `sync`/`join`.
89
+ */
90
+ export async function syncGithubRepos(session) {
363
91
  try {
364
- const agentIds = await readdir(agentsDir, { withFileTypes: true });
365
- for (const entry of agentIds) {
366
- if (!entry.isDirectory())
367
- continue;
368
- const sessionsSubdir = join(agentsDir, entry.name, 'sessions');
369
- const discovered = await findFiles(sessionsSubdir, '.jsonl');
370
- sessionFiles.push(...discovered);
371
- }
372
- }
373
- catch {
374
- return [];
375
- }
376
- if (sessionFiles.length === 0)
377
- return [];
378
- const dailyByModel = new Map();
379
- const cutoff = hourlyCutoff();
380
- for (const filePath of sessionFiles) {
381
- try {
382
- const content = await readFile(filePath, 'utf8');
383
- let fileFallbackDate = null;
384
- for (const line of content.split('\n')) {
385
- if (!line.trim())
386
- continue;
387
- try {
388
- const parsed = JSON.parse(line);
389
- const usage = parsed.usage ??
390
- parsed.tokenUsage ??
391
- parsed.message?.usage ??
392
- parsed.response?.usage ??
393
- null;
394
- if (!usage || typeof usage !== 'object')
395
- continue;
396
- const tokens = (usage.total ??
397
- (usage.input ?? usage.inputTokens ?? 0) +
398
- (usage.output ?? usage.outputTokens ?? 0) +
399
- (usage.cacheRead ?? usage.cacheReadTokens ?? 0) +
400
- (usage.cacheWrite ?? usage.cacheWriteTokens ?? 0));
401
- if (!tokens || tokens <= 0)
402
- continue;
403
- let date;
404
- let hour = null;
405
- const ts = parsed.timestamp ?? parsed.t ?? parsed.time ?? null;
406
- if (ts) {
407
- const tsMs = typeof ts === 'string' && /^\d+$/.test(ts) ? Number(ts) : ts;
408
- const d = new Date(tsMs);
409
- if (!Number.isNaN(d.getTime())) {
410
- date = toDateStr(d);
411
- hour = d.getHours();
412
- }
413
- else {
414
- if (!fileFallbackDate) {
415
- const s = await stat(filePath);
416
- fileFallbackDate = toDateStr(s.mtime);
417
- }
418
- date = fileFallbackDate;
419
- }
420
- }
421
- else {
422
- if (!fileFallbackDate) {
423
- const s = await stat(filePath);
424
- fileFallbackDate = toDateStr(s.mtime);
425
- }
426
- date = fileFallbackDate;
427
- }
428
- const model = parsed.model ?? parsed.response?.model ?? '';
429
- addToDaily(dailyByModel, date, model, tokens, 1);
430
- if (model) {
431
- modelAccumulator.set(model, (modelAccumulator.get(model) ?? 0) + tokens);
432
- }
433
- if (hour !== null) {
434
- addHourly(date, hour, 'openclaw', model, tokens, cutoff, 1);
435
- }
436
- }
437
- catch {
438
- // skip malformed lines
439
- }
440
- }
441
- }
442
- catch {
443
- // skip unreadable files
444
- }
445
- }
446
- return dailyMapToEntries(dailyByModel, 'openclaw');
447
- }
448
- async function scanCursorLocal() {
449
- const dbPath = join(homedir(), '.cursor', 'ai-tracking', 'ai-code-tracking.db');
450
- try {
451
- await stat(dbPath);
452
- }
453
- catch {
454
- return { entries: [], stats: null };
455
- }
456
- const { execSync } = await import('node:child_process');
457
- try {
458
- const raw = execSync(`sqlite3 "${dbPath}" "select commitHash, linesAdded, linesDeleted, composerLinesAdded, composerLinesDeleted, humanLinesAdded, humanLinesDeleted, v2AiPercentage from scored_commits;"`, { encoding: 'utf8', timeout: 10000 }).trim();
459
- if (!raw)
460
- return { entries: [], stats: null };
461
- const rows = raw.split('\n');
462
- let totalTokens = 0;
463
- let totalAiLines = 0;
464
- let totalHumanLines = 0;
465
- let aiPctSum = 0;
466
- for (const row of rows) {
467
- const [, , , composerAdded, composerDeleted, humanAdded, , aiPct] = row.split('|');
468
- const aiLines = (parseInt(composerAdded ?? '0', 10) || 0) +
469
- (parseInt(composerDeleted ?? '0', 10) || 0);
470
- totalAiLines += parseInt(composerAdded ?? '0', 10) || 0;
471
- totalHumanLines += parseInt(humanAdded ?? '0', 10) || 0;
472
- aiPctSum += parseFloat(aiPct ?? '0') || 0;
473
- totalTokens += aiLines * 30;
474
- }
475
- let models = [];
476
- try {
477
- const modelRaw = execSync(`sqlite3 "${dbPath}" "select model, count(*) from ai_code_hashes where model is not null and model != '' group by model order by count(*) desc;"`, { encoding: 'utf8', timeout: 5000 }).trim();
478
- if (modelRaw) {
479
- models = modelRaw.split('\n').map((line) => {
480
- const [model, count] = line.split('|');
481
- return { model: model ?? '', uses: parseInt(count ?? '0', 10) || 0 };
482
- });
483
- }
484
- }
485
- catch {
486
- // optional
487
- }
488
- const entries = totalTokens > 0
489
- ? [{ date: toDateStr(new Date()), tool: 'cursor', tokens: totalTokens }]
490
- : [];
491
- return {
492
- entries,
493
- stats: {
494
- totalCommits: rows.length,
495
- aiLinesAdded: totalAiLines,
496
- humanLinesAdded: totalHumanLines,
497
- avgAiPercent: rows.length > 0 ? Math.round((aiPctSum / rows.length) * 10) / 10 : 0,
498
- models,
499
- },
500
- };
501
- }
502
- catch {
503
- return { entries: [], stats: null };
504
- }
505
- }
506
- async function scanCursorApi() {
507
- const config = await loadConfig();
508
- if (!config.cursorApiKey) {
509
- _cursorScanStatus.value = { source: 'none' };
510
- return [];
511
- }
512
- const email = config.cursorEmail;
513
- const now = Date.now();
514
- const fiveYearsMs = 5 * 365 * 24 * 60 * 60 * 1000;
515
- const startDate = now - fiveYearsMs;
516
- const endDate = now;
517
- const dailyByModel = new Map();
518
- const cursorCutoff = hourlyCutoff();
519
- let page = 1;
520
- let hasMore = true;
521
- let retries = 0;
522
- const MAX_RETRIES = 3;
523
- let eventsProcessed = 0;
524
- let partialReason = null;
525
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
526
- while (hasMore) {
527
- const body = {
528
- startDate,
529
- endDate,
530
- page,
531
- pageSize: 100,
532
- };
533
- if (email)
534
- body.email = email;
535
- let res;
536
- try {
537
- res = await fetch('https://api.cursor.com/teams/filtered-usage-events', {
538
- method: 'POST',
539
- headers: {
540
- 'Content-Type': 'application/json',
541
- Authorization: `Bearer ${config.cursorApiKey}`,
542
- },
543
- body: JSON.stringify(body),
544
- });
545
- }
546
- catch (err) {
547
- partialReason = `network: ${err instanceof Error ? err.message : 'unknown'}`;
548
- break;
549
- }
550
- if (!res.ok) {
551
- if (res.status === 429 && retries < MAX_RETRIES) {
552
- const waitMs = 2000 * 2 ** retries;
553
- await sleep(waitMs);
554
- retries++;
555
- continue;
556
- }
557
- if (res.status === 401 || res.status === 403) {
558
- if (eventsProcessed === 0) {
559
- _cursorScanStatus.value = {
560
- source: 'api-failed',
561
- reason: `auth ${res.status} — check CURSOR_API_KEY`,
562
- };
563
- return [];
564
- }
565
- partialReason = `auth ${res.status} mid-scan`;
566
- break;
567
- }
568
- partialReason = `HTTP ${res.status}`;
569
- break;
570
- }
571
- retries = 0;
572
- let data;
573
- try {
574
- data = await res.json();
575
- }
576
- catch {
577
- partialReason = 'invalid json';
578
- break;
579
- }
580
- const events = data.usageEvents ?? [];
581
- for (const event of events) {
582
- if (!event.isTokenBasedCall)
583
- continue;
584
- const tu = event.tokenUsage ??
585
- event;
586
- const tokens = (tu.inputTokens ?? 0) +
587
- (tu.outputTokens ?? 0) +
588
- (tu.cacheWriteTokens ?? 0) +
589
- (tu.cacheReadTokens ?? 0);
590
- if (tokens > 0 && event.timestamp) {
591
- const rawTs = event.timestamp;
592
- const tsMs = typeof rawTs === 'string' && /^\d+$/.test(rawTs)
593
- ? Number(rawTs)
594
- : rawTs;
595
- const eventDate = new Date(tsMs);
596
- const date = toDateStr(eventDate);
597
- const hour = eventDate.getHours();
598
- const model = event.model ?? '';
599
- addToDaily(dailyByModel, date, model, tokens, 1);
600
- if (model) {
601
- modelAccumulator.set(model, (modelAccumulator.get(model) ?? 0) + tokens);
602
- }
603
- addHourly(date, hour, 'cursor', model, tokens, cursorCutoff, 1);
604
- eventsProcessed++;
605
- }
606
- }
607
- hasMore = data.pagination?.hasNextPage ?? false;
608
- page++;
609
- if (page > 500) {
610
- partialReason = 'pagination cap (500 pages)';
611
- break;
612
- }
613
- }
614
- _cursorScanStatus.value = partialReason
615
- ? { source: 'api-partial', events: eventsProcessed, reason: partialReason }
616
- : { source: 'api', events: eventsProcessed };
617
- return dailyMapToEntries(dailyByModel, 'cursor');
618
- }
619
- async function scanOpenCode() {
620
- const openCodeDir = join(homedir(), '.local', 'share', 'opencode');
621
- try {
622
- await stat(openCodeDir);
623
- }
624
- catch {
625
- return [];
626
- }
627
- const cutoff = hourlyCutoff();
628
- const dailyByModel = new Map();
629
- // Schema: data column is JSON with shape:
630
- // { role, modelID, time: { created: ms }, tokens: { input, output, reasoning, cache: { read, write } } }
631
- const dbPath = join(openCodeDir, 'opencode.db');
632
- try {
633
- await stat(dbPath);
634
- const { execSync } = await import('node:child_process');
635
- const raw = execSync(`sqlite3 -readonly "${dbPath}" "SELECT json_extract(data,'$.time.created'), coalesce(json_extract(data,'$.modelID'),''), coalesce(json_extract(data,'$.tokens.input'),0), coalesce(json_extract(data,'$.tokens.output'),0), coalesce(json_extract(data,'$.tokens.cache.read'),0), coalesce(json_extract(data,'$.tokens.cache.write'),0), coalesce(json_extract(data,'$.tokens.reasoning'),0) FROM message WHERE json_extract(data,'$.role')='assistant'"`, { encoding: 'utf8', timeout: 15000, stdio: 'pipe' }).trim();
636
- if (raw) {
637
- for (const row of raw.split('\n')) {
638
- const [timeStr, model, inputStr, outputStr, cacheReadStr, cacheWriteStr, reasoningStr,] = row.split('|');
639
- const tokens = (Number.parseInt(inputStr ?? '0', 10) || 0) +
640
- (Number.parseInt(outputStr ?? '0', 10) || 0) +
641
- (Number.parseInt(cacheReadStr ?? '0', 10) || 0) +
642
- (Number.parseInt(cacheWriteStr ?? '0', 10) || 0) +
643
- (Number.parseInt(reasoningStr ?? '0', 10) || 0);
644
- if (tokens <= 0)
645
- continue;
646
- const tsMs = Number(timeStr ?? '0'); // already milliseconds
647
- if (!Number.isFinite(tsMs) || tsMs <= 0)
648
- continue;
649
- const d = new Date(tsMs);
650
- const date = toDateStr(d);
651
- const hour = d.getHours();
652
- const m = (model ?? '').trim();
653
- addToDaily(dailyByModel, date, m, tokens, 1);
654
- if (m)
655
- modelAccumulator.set(m, (modelAccumulator.get(m) ?? 0) + tokens);
656
- addHourly(date, hour, 'opencode', m, tokens, cutoff, 1);
657
- }
658
- if (dailyByModel.size > 0) {
659
- return dailyMapToEntries(dailyByModel, 'opencode');
660
- }
661
- }
662
- }
663
- catch {
664
- // DB unavailable; fall through to JSON files
665
- }
666
- // Fallback: scan ~/.local/share/opencode/storage/message/{sessionID}/msg_*.json
667
- const messageDir = join(openCodeDir, 'storage', 'message');
668
- try {
669
- await stat(messageDir);
670
- }
671
- catch {
672
- return [];
673
- }
674
- try {
675
- const sessionDirs = await readdir(messageDir, { withFileTypes: true });
676
- for (const sessionEntry of sessionDirs) {
677
- if (!sessionEntry.isDirectory())
678
- continue;
679
- const msgFiles = await findFiles(join(messageDir, sessionEntry.name), '.json');
680
- for (const filePath of msgFiles) {
681
- try {
682
- const parsed = JSON.parse(await readFile(filePath, 'utf8'));
683
- if (parsed.role !== 'assistant')
684
- continue;
685
- const t = parsed.tokens;
686
- if (!t || typeof t !== 'object')
687
- continue;
688
- const tokens = (t.input ?? 0) +
689
- (t.output ?? 0) +
690
- (t.cache?.read ?? 0) +
691
- (t.cache?.write ?? 0) +
692
- (t.reasoning ?? 0);
693
- if (tokens <= 0)
694
- continue;
695
- const tsMs = parsed.time?.created ?? 0;
696
- let date;
697
- let hour = null;
698
- if (tsMs > 0) {
699
- const d = new Date(tsMs);
700
- date = toDateStr(d);
701
- hour = d.getHours();
702
- }
703
- else {
704
- date = toDateStr((await stat(filePath)).mtime);
705
- }
706
- const model = String(parsed.modelID ?? '');
707
- addToDaily(dailyByModel, date, model, tokens, 1);
708
- if (model)
709
- modelAccumulator.set(model, (modelAccumulator.get(model) ?? 0) + tokens);
710
- if (hour !== null)
711
- addHourly(date, hour, 'opencode', model, tokens, cutoff, 1);
712
- }
713
- catch {
714
- // skip malformed files
715
- }
716
- }
717
- }
92
+ const res = await fetchApi(session, '/api/cli/github-sync', {
93
+ method: 'POST',
94
+ headers: { Authorization: `Bearer ${session.token}` },
95
+ });
96
+ if (!res.ok)
97
+ return null;
98
+ return (await res.json());
718
99
  }
719
100
  catch {
720
- // skip
101
+ return null;
721
102
  }
722
- return dailyMapToEntries(dailyByModel, 'opencode');
723
103
  }
104
+ /**
105
+ * Scan local AI usage (via the shared scanners) and upload it to the claim
106
+ * endpoint. Uses the exact same scan path as `join`, so the two commands report
107
+ * identical numbers for the same machine.
108
+ */
724
109
  export async function runSync(session) {
725
- modelAccumulator.clear();
726
- hourlyAccumulator.clear();
727
- _cursorScanStatus.value = { source: 'none' };
728
- const [claudeEntries, codexEntries, cursorApiEntries, cursorLocal, openclawEntries, hermesEntries, opencodeEntries,] = await Promise.all([
729
- scanClaudeCode(),
730
- scanCodex(),
731
- scanCursorApi(),
732
- scanCursorLocal(),
733
- scanOpenclaw(),
734
- scanHermes(),
735
- scanOpenCode(),
736
- ]);
737
- const cursorEntries = cursorApiEntries.length > 0 ? cursorApiEntries : cursorLocal.entries;
738
- const claudeTotal = claudeEntries.reduce((s, e) => s + e.tokens, 0);
739
- const codexTotal = codexEntries.reduce((s, e) => s + e.tokens, 0);
740
- const cursorTotal = cursorEntries.reduce((s, e) => s + e.tokens, 0);
741
- const openclawTotal = openclawEntries.reduce((s, e) => s + e.tokens, 0);
742
- const hermesTotal = hermesEntries.reduce((s, e) => s + e.tokens, 0);
743
- const opencodeTotal = opencodeEntries.reduce((s, e) => s + e.tokens, 0);
744
- const allEntries = [
745
- ...claudeEntries,
746
- ...codexEntries,
747
- ...cursorEntries,
748
- ...openclawEntries,
749
- ...hermesEntries,
750
- ...opencodeEntries,
751
- ];
110
+ const scan = await scanAllTools();
111
+ const claudeTotal = scan.toolTotals.claude_code ?? 0;
112
+ const codexTotal = scan.toolTotals.codex ?? 0;
113
+ const cursorTotal = scan.toolTotals.cursor ?? 0;
114
+ const openclawTotal = scan.toolTotals.openclaw ?? 0;
115
+ const hermesTotal = scan.toolTotals.hermes ?? 0;
116
+ const opencodeTotal = scan.toolTotals.opencode ?? 0;
117
+ const allEntries = scan.dailyTotals;
752
118
  const messagesTotal = allEntries.reduce((s, e) => s + (e.messages ?? 0), 0);
753
- const modelTotals = {};
754
- for (const [name, tokens] of modelAccumulator.entries()) {
755
- if (tokens > 0)
756
- modelTotals[name] = tokens;
757
- }
758
- const hourlyTotals = flushHourly();
119
+ const models = Object.entries(scan.modelTotals)
120
+ .map(([name, tokens]) => ({ name, tokens }))
121
+ .sort((a, b) => b.tokens - a.tokens);
759
122
  const res = await fetchApi(session, '/api/claim/sync', {
760
123
  method: 'POST',
761
124
  headers: {
@@ -772,8 +135,8 @@ export async function runSync(session) {
772
135
  hermes: hermesTotal,
773
136
  opencode: opencodeTotal,
774
137
  },
775
- modelTotals,
776
- hourlyTotals,
138
+ modelTotals: scan.modelTotals,
139
+ hourlyTotals: scan.hourlyTotals,
777
140
  }),
778
141
  });
779
142
  if (res.status === 401) {
@@ -784,7 +147,7 @@ export async function runSync(session) {
784
147
  throw new Error(data?.error ??
785
148
  `sync failed (${res.status})`);
786
149
  }
787
- const result = await res.json();
150
+ const result = (await res.json());
788
151
  return {
789
152
  claudeTotal,
790
153
  codexTotal,
@@ -794,12 +157,10 @@ export async function runSync(session) {
794
157
  opencodeTotal,
795
158
  messagesTotal,
796
159
  allEntries,
797
- cursorStats: cursorLocal.stats,
160
+ cursorStats: scan.cursorStats,
798
161
  result,
799
- models: Array.from(modelAccumulator.entries())
800
- .map(([name, tokens]) => ({ name, tokens }))
801
- .sort((a, b) => b.tokens - a.tokens),
802
- cursorScanStatus: _cursorScanStatus.value,
162
+ models,
163
+ cursorScanStatus: scan.cursorScanStatus,
803
164
  };
804
165
  }
805
166
  //# sourceMappingURL=sync.js.map