tokentracker-cli 0.10.0 → 0.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokentracker-cli",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "description": "Token usage tracker for AI agent CLIs (Claude Code, Codex, Cursor, Gemini, Kiro, OpenCode, OpenClaw, Every Code, Hermes, GitHub Copilot, Kimi Code, CodeBuddy, oh-my-pi, pi, Craft Agents)",
5
5
  "main": "src/cli.js",
6
6
  "bin": {
@@ -67,7 +67,23 @@ const CURSOR_UNKNOWN_MIGRATION_KEY = "cursorUnknownPurge_2026_04";
67
67
  const ROLLOUT_CUMULATIVE_DELTA_MIGRATION_KEY = "rolloutCumulativeDeltaReparse_2026_05";
68
68
  const CLAUDE_MEM_OBSERVER_REINCLUDE_KEY = "claudeMemObserverReinclude_2026_05_v3";
69
69
  const CLAUDE_MEM_OBSERVER_PATH_SEGMENT = "--claude-mem-observer-sessions";
70
- const CLAUDE_GROUND_TRUTH_REPAIR_KEY = "claudeGroundTruthRepair_2026_05_v1";
70
+ // v1 had a cursor-format bug (wrote plain integer instead of {inode, offset,
71
+ // updatedAt}), which made parseClaudeIncremental reread every jsonl from
72
+ // byte 0 on the next sync and double everything. v2 fixed the format.
73
+ // v3 fixes two latent issues caught by adversarial review:
74
+ // (a) v2 wrote `cursors.hourly.groupQueued[claude|<hour>]` for every
75
+ // repaired bucket. enqueueTouchedBuckets uses presence of that key
76
+ // as the legacy-group marker, so any later sync that touched a
77
+ // claude hour (even just a user-message conv-count++) would re-emit
78
+ // the entire hour as one aggregate row under model=DEFAULT_MODEL,
79
+ // causing a different inflation path. v3 leaves groupQueued alone.
80
+ // (b) v2 only repaired the main queue.jsonl. project.queue.jsonl still
81
+ // carried historical claude-mem observer rows (project_key=
82
+ // "claude-mem/observer-sessions") and the project totals on the
83
+ // Project Usage panel stayed inflated. v3 drops every claude /
84
+ // claude-mem row from project.queue.jsonl too, and resets the
85
+ // matching cursors.projectHourly + project.queue.state offset.
86
+ const CLAUDE_GROUND_TRUTH_REPAIR_KEY = "claudeGroundTruthRepair_2026_05_v3";
71
87
 
72
88
  async function cmdSync(argv) {
73
89
  const opts = parseArgs(argv);
@@ -183,7 +199,13 @@ async function cmdSync(argv) {
183
199
 
184
200
  const claudeFiles = await listClaudeProjectFiles(claudeProjectsDir);
185
201
  await reincludeClaudeMemObserverFiles({ cursors, claudeFiles, queuePath });
186
- await repairClaudeQueueFromGroundTruth({ cursors, queuePath, queueStatePath });
202
+ await repairClaudeQueueFromGroundTruth({
203
+ cursors,
204
+ queuePath,
205
+ queueStatePath,
206
+ projectQueuePath,
207
+ projectQueueStatePath,
208
+ });
187
209
  let claudeResult = { filesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
188
210
  if (claudeFiles.length > 0) {
189
211
  if (progress?.enabled) {
@@ -1212,7 +1234,13 @@ async function migrateRolloutCumulativeDeltaBuckets({ cursors, queuePath, rollou
1212
1234
  // in sync, and reset the cloud upload offset so the corrected rows actually
1213
1235
  // reach the cloud (the ingest endpoint upserts by (source, model,
1214
1236
  // hour_start), so re-uploading other sources is idempotent).
1215
- async function repairClaudeQueueFromGroundTruth({ cursors, queuePath, queueStatePath = null }) {
1237
+ async function repairClaudeQueueFromGroundTruth({
1238
+ cursors,
1239
+ queuePath,
1240
+ queueStatePath = null,
1241
+ projectQueuePath = null,
1242
+ projectQueueStatePath = null,
1243
+ }) {
1216
1244
  if (!cursors || typeof cursors !== "object") return false;
1217
1245
  const migrations = (cursors.migrations ||= {});
1218
1246
  if (migrations[CLAUDE_GROUND_TRUTH_REPAIR_KEY]) return false;
@@ -1292,12 +1320,22 @@ async function repairClaudeQueueFromGroundTruth({ cursors, queuePath, queueState
1292
1320
  bucketsCleared += 1;
1293
1321
  }
1294
1322
  }
1323
+ // Clear stale claude entries from groupQueued (left over by v2 repair).
1324
+ // After v3 we never repopulate it for claude, so nothing should be added
1325
+ // back during the per-model write loop below.
1295
1326
  for (const k of Object.keys(hourly.groupQueued)) {
1296
1327
  if (k.startsWith("claude|") || k.startsWith("claude-mem|")) {
1297
1328
  delete hourly.groupQueued[k];
1298
1329
  }
1299
1330
  }
1300
1331
 
1332
+ // Per-model claude buckets: set queuedKey but DO NOT touch
1333
+ // hourly.groupQueued. groupQueued is used by enqueueTouchedBuckets to
1334
+ // mark a (source, hour) as legacy-aggregate state; writing claude hours
1335
+ // there would force every later sync to re-emit the hour as a single
1336
+ // model=DEFAULT_MODEL aggregate row instead of touching only the bucket
1337
+ // that actually changed. The original v2 release did write groupQueued
1338
+ // here and was the cause of an unknown-bucket inflation regression.
1301
1339
  for (const r of rows) {
1302
1340
  const totals = {
1303
1341
  input_tokens: r.input_tokens,
@@ -1316,21 +1354,30 @@ async function repairClaudeQueueFromGroundTruth({ cursors, queuePath, queueState
1316
1354
  source: "claude",
1317
1355
  hour_start: r.hour_start,
1318
1356
  };
1319
- hourly.groupQueued[groupBucketKey("claude", r.hour_start)] = totalsKey(totals);
1320
1357
  }
1321
1358
 
1322
1359
  // 3. Reset per-file cursors so future incremental sync only reads genuinely
1323
- // new tail content.
1360
+ // new tail content. Format must match what rollout.js expects:
1361
+ // { inode, offset, updatedAt }. Setting a plain integer here breaks
1362
+ // the inode-equality check inside parseClaudeFile, which would treat
1363
+ // the file as untracked and re-read it from byte 0 — silently doubling
1364
+ // everything. (That was the actual cause of the regression after the
1365
+ // first repair attempt.)
1324
1366
  cursors.files ||= {};
1325
1367
  let filesReset = 0;
1368
+ const nowIso = new Date().toISOString();
1326
1369
  for (const fp of fileList) {
1327
- let size = 0;
1370
+ let st;
1328
1371
  try {
1329
- size = fssync.statSync(fp).size;
1372
+ st = fssync.statSync(fp);
1330
1373
  } catch (_e) {
1331
1374
  continue;
1332
1375
  }
1333
- cursors.files[fp] = size;
1376
+ cursors.files[fp] = {
1377
+ inode: st.ino || 0,
1378
+ offset: st.size,
1379
+ updatedAt: nowIso,
1380
+ };
1334
1381
  filesReset += 1;
1335
1382
  }
1336
1383
  cursors.claudeHashes = seenHashes;
@@ -1347,10 +1394,78 @@ async function repairClaudeQueueFromGroundTruth({ cursors, queuePath, queueState
1347
1394
  }
1348
1395
  uploadState.offset = 0;
1349
1396
  uploadState.updatedAt = new Date().toISOString();
1350
- uploadState.note = "reset_after_claude_repair_2026_05_v1";
1397
+ uploadState.note = "reset_after_claude_repair_2026_05_v3";
1351
1398
  await fs.writeFile(queueStatePath, JSON.stringify(uploadState));
1352
1399
  }
1353
1400
 
1401
+ // 5. Repair project queue. Historical claude rows in project.queue.jsonl
1402
+ // were uniformly mis-attributed to project_key=
1403
+ // "claude-mem/observer-sessions" (left over from the observer
1404
+ // relabel migration). We can't reconstruct the true cwd-based
1405
+ // project_key for each historical message reliably, so we drop every
1406
+ // claude/claude-mem row from project.queue.jsonl and reset the
1407
+ // matching cursors.projectHourly state. New claude usage will
1408
+ // accumulate to the correct cwd-derived project_key going forward.
1409
+ let projectRowsRemoved = 0;
1410
+ let projectBucketsCleared = 0;
1411
+ if (typeof projectQueuePath === "string" && projectQueuePath) {
1412
+ let projRaw = "";
1413
+ try {
1414
+ projRaw = await fs.readFile(projectQueuePath, "utf8");
1415
+ } catch (e) {
1416
+ if (e?.code !== "ENOENT") throw e;
1417
+ }
1418
+ if (projRaw) {
1419
+ const projKept = [];
1420
+ for (const line of projRaw.split("\n")) {
1421
+ if (!line.trim()) continue;
1422
+ let row;
1423
+ try {
1424
+ row = JSON.parse(line);
1425
+ } catch (_e) {
1426
+ projKept.push(line);
1427
+ continue;
1428
+ }
1429
+ if (row?.source === "claude" || row?.source === "claude-mem") {
1430
+ projectRowsRemoved += 1;
1431
+ continue;
1432
+ }
1433
+ projKept.push(line);
1434
+ }
1435
+ await ensureDir(path.dirname(projectQueuePath));
1436
+ const tmp = `${projectQueuePath}.tmp.${process.pid}.${Date.now()}`;
1437
+ await fs.writeFile(tmp, projKept.join("\n") + "\n", "utf8");
1438
+ await fs.rename(tmp, projectQueuePath);
1439
+ }
1440
+
1441
+ // Clear matching projectHourly state so the claude project buckets
1442
+ // start fresh.
1443
+ const projHourly = (cursors.projectHourly ||= { buckets: {} });
1444
+ projHourly.buckets ||= {};
1445
+ for (const k of Object.keys(projHourly.buckets)) {
1446
+ const v = projHourly.buckets[k];
1447
+ const src = v?.source || "";
1448
+ if (src === "claude" || src === "claude-mem") {
1449
+ delete projHourly.buckets[k];
1450
+ projectBucketsCleared += 1;
1451
+ }
1452
+ }
1453
+
1454
+ // Reset project upload offset.
1455
+ if (typeof projectQueueStatePath === "string" && projectQueueStatePath) {
1456
+ let st = {};
1457
+ try {
1458
+ st = JSON.parse(await fs.readFile(projectQueueStatePath, "utf8"));
1459
+ } catch (_e) {
1460
+ st = {};
1461
+ }
1462
+ st.offset = 0;
1463
+ st.updatedAt = new Date().toISOString();
1464
+ st.note = "reset_after_claude_repair_2026_05_v3";
1465
+ await fs.writeFile(projectQueueStatePath, JSON.stringify(st));
1466
+ }
1467
+ }
1468
+
1354
1469
  migrations[CLAUDE_GROUND_TRUTH_REPAIR_KEY] = {
1355
1470
  appliedAt: new Date().toISOString(),
1356
1471
  bucketsWritten: rows.length,
@@ -1359,6 +1474,8 @@ async function repairClaudeQueueFromGroundTruth({ cursors, queuePath, queueState
1359
1474
  filesReset,
1360
1475
  hashesRetained: seenHashes.length,
1361
1476
  uploadOffsetReset: typeof queueStatePath === "string" && !!queueStatePath,
1477
+ projectRowsRemoved,
1478
+ projectBucketsCleared,
1362
1479
  };
1363
1480
  return true;
1364
1481
  }