tokentracker-cli 0.5.99 → 0.5.100

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.
@@ -4373,6 +4373,260 @@ async function parseCodebuddyIncremental({
4373
4373
  return { recordsProcessed, eventsAggregated, bucketsQueued };
4374
4374
  }
4375
4375
 
4376
+ // ─────────────────────────────────────────────────────────────────────────────
4377
+ // oh-my-pi (omp) — passive JSONL reader (~/.omp/agent/sessions/**/*.jsonl)
4378
+ //
4379
+ // oh-my-pi writes one append-only JSONL per session:
4380
+ // ~/.omp/agent/sessions/--<cwd-encoded>--/<timestamp>_<sessionId>.jsonl
4381
+ //
4382
+ // Per-line record types: the first line is type:"session" (header).
4383
+ // Only type:"message" lines with message.role=="assistant" carry token usage.
4384
+ // The shape (verbatim from oh-my-pi docs/session.md):
4385
+ //
4386
+ // {
4387
+ // "type": "message",
4388
+ // "id": "a1b2c3d4", ← 8-char dedup key
4389
+ // "parentId": "...",
4390
+ // "timestamp": "2026-02-16T10:21:00.000Z",
4391
+ // "message": {
4392
+ // "role": "assistant",
4393
+ // "provider": "anthropic",
4394
+ // "model": "claude-sonnet-4-5",
4395
+ // "usage": {
4396
+ // "input": 100, "output": 20, "cacheRead": 0, "cacheWrite": 0,
4397
+ // "totalTokens": 120, "reasoningTokens": 0
4398
+ // },
4399
+ // "timestamp": 1760000000000 ← ms epoch, preferred for bucketing
4400
+ // }
4401
+ // }
4402
+ //
4403
+ // oh-my-pi is a router — dispatches to upstream providers (Anthropic, OpenAI,
4404
+ // etc.) and records the upstream model name per message. There is no global
4405
+ // default model setting; model is always per-message (fallback: "omp-unknown").
4406
+ // ─────────────────────────────────────────────────────────────────────────────
4407
+
4408
+ function resolveOmpHome(env = process.env) {
4409
+ const home = env.HOME || require("node:os").homedir();
4410
+ // Honor TokenTracker override first, then oh-my-pi upstream env vars.
4411
+ if (env.OMP_HOME) return env.OMP_HOME;
4412
+ if (env.PI_CONFIG_DIR) return path.join(home, env.PI_CONFIG_DIR);
4413
+ return path.join(home, ".omp");
4414
+ }
4415
+
4416
+ function resolveOmpAgentDir(env = process.env) {
4417
+ if (env.PI_CODING_AGENT_DIR) return env.PI_CODING_AGENT_DIR;
4418
+ return path.join(resolveOmpHome(env), "agent");
4419
+ }
4420
+
4421
+ function resolveOmpSessionFiles(env = process.env) {
4422
+ const sessionsDir = path.join(resolveOmpAgentDir(env), "sessions");
4423
+ if (!fssync.existsSync(sessionsDir)) return [];
4424
+ const files = [];
4425
+ try {
4426
+ for (const cwdDir of fssync.readdirSync(sessionsDir)) {
4427
+ const cwdPath = path.join(sessionsDir, cwdDir);
4428
+ let stat;
4429
+ try { stat = fssync.statSync(cwdPath); } catch { continue; }
4430
+ if (!stat.isDirectory()) continue;
4431
+ let entries;
4432
+ try { entries = fssync.readdirSync(cwdPath); } catch { continue; }
4433
+ for (const entry of entries) {
4434
+ if (!entry.endsWith(".jsonl")) continue;
4435
+ files.push(path.join(cwdPath, entry));
4436
+ }
4437
+ }
4438
+ } catch {
4439
+ // ignore — return what we have
4440
+ }
4441
+ files.sort((a, b) => a.localeCompare(b));
4442
+ return files;
4443
+ }
4444
+
4445
+ function resolveOmpDefaultModel() {
4446
+ // oh-my-pi has no global default model setting; model is per-message.
4447
+ return "omp-unknown";
4448
+ }
4449
+
4450
+ async function parseOmpIncremental({
4451
+ sessionFiles,
4452
+ cursors,
4453
+ queuePath,
4454
+ onProgress,
4455
+ env,
4456
+ defaultModel,
4457
+ } = {}) {
4458
+ await ensureDir(path.dirname(queuePath));
4459
+ const ompState = cursors.omp && typeof cursors.omp === "object" ? cursors.omp : {};
4460
+ const seenIds = new Set(Array.isArray(ompState.seenIds) ? ompState.seenIds : []);
4461
+ const fileOffsets =
4462
+ ompState.fileOffsets && typeof ompState.fileOffsets === "object"
4463
+ ? { ...ompState.fileOffsets }
4464
+ : {};
4465
+
4466
+ const files = Array.isArray(sessionFiles)
4467
+ ? sessionFiles
4468
+ : resolveOmpSessionFiles(env || process.env);
4469
+ const fallbackModel = defaultModel || resolveOmpDefaultModel();
4470
+
4471
+ if (files.length === 0) {
4472
+ cursors.omp = {
4473
+ ...ompState,
4474
+ seenIds: Array.from(seenIds),
4475
+ fileOffsets,
4476
+ updatedAt: new Date().toISOString(),
4477
+ };
4478
+ return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
4479
+ }
4480
+
4481
+ const hourlyState = normalizeHourlyState(cursors?.hourly);
4482
+ const touchedBuckets = new Set();
4483
+ const cb = typeof onProgress === "function" ? onProgress : null;
4484
+ let recordsProcessed = 0;
4485
+ let eventsAggregated = 0;
4486
+
4487
+ for (let fileIdx = 0; fileIdx < files.length; fileIdx++) {
4488
+ const filePath = files[fileIdx];
4489
+ let stat;
4490
+ try { stat = fssync.statSync(filePath); } catch { continue; }
4491
+
4492
+ const prevEntry = fileOffsets[filePath] || {};
4493
+ const prevSize = Number(prevEntry.size) || 0;
4494
+ const prevIno = prevEntry.ino;
4495
+ // Re-read from start if file shrunk (truncate/rewrite) or inode changed.
4496
+ const inodeChanged = typeof prevIno === "number" && prevIno !== stat.ino;
4497
+ const startOffset = stat.size < prevSize || inodeChanged ? 0 : prevSize;
4498
+ if (stat.size <= startOffset) continue;
4499
+
4500
+ let stream;
4501
+ try {
4502
+ stream = fssync.createReadStream(filePath, {
4503
+ encoding: "utf8",
4504
+ start: startOffset,
4505
+ });
4506
+ } catch { continue; }
4507
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
4508
+
4509
+ for await (const line of rl) {
4510
+ if (!line || !line.trim()) continue;
4511
+ let entry;
4512
+ try { entry = JSON.parse(line); } catch { continue; }
4513
+
4514
+ // First line of each file is type:"session" (header) — skip all
4515
+ // non-message records.
4516
+ if (!entry || entry.type !== "message") continue;
4517
+
4518
+ // Only assistant messages carry token usage.
4519
+ const msg = entry.message;
4520
+ if (!msg || msg.role !== "assistant") continue;
4521
+
4522
+ const usage = msg.usage;
4523
+ if (!usage || typeof usage !== "object") continue;
4524
+
4525
+ // Dedup by top-level entry id (8-char string assigned by oh-my-pi).
4526
+ const entryId = typeof entry.id === "string" && entry.id ? entry.id : null;
4527
+ if (!entryId) continue;
4528
+ if (seenIds.has(entryId)) continue;
4529
+
4530
+ recordsProcessed++;
4531
+
4532
+ const input = toNonNegativeInt(usage.input);
4533
+ const output = toNonNegativeInt(usage.output);
4534
+ const cacheRead = toNonNegativeInt(usage.cacheRead);
4535
+ const cacheWrite = toNonNegativeInt(usage.cacheWrite);
4536
+ const reasoningTokens = toNonNegativeInt(usage.reasoningTokens);
4537
+
4538
+ if (input === 0 && output === 0 && cacheRead === 0 && cacheWrite === 0) {
4539
+ seenIds.add(entryId);
4540
+ continue;
4541
+ }
4542
+
4543
+ // Prefer message-level timestamp (ms epoch); fall back to entry-level
4544
+ // ISO string. Entries with no resolvable timestamp are skipped — they
4545
+ // cannot be placed in a bucket.
4546
+ let tsMs = null;
4547
+ if (Number.isFinite(Number(msg.timestamp)) && Number(msg.timestamp) > 0) {
4548
+ tsMs = Number(msg.timestamp);
4549
+ } else if (typeof entry.timestamp === "string" && entry.timestamp) {
4550
+ const parsed = Date.parse(entry.timestamp);
4551
+ if (Number.isFinite(parsed) && parsed > 0) tsMs = parsed;
4552
+ }
4553
+ if (tsMs == null) {
4554
+ seenIds.add(entryId);
4555
+ continue;
4556
+ }
4557
+
4558
+ const tsIso = new Date(tsMs).toISOString();
4559
+ const bucketStart = toUtcHalfHourStart(tsIso);
4560
+ if (!bucketStart) continue;
4561
+
4562
+ // Use provided totalTokens when available; otherwise sum all components.
4563
+ const totalTokens =
4564
+ Number.isFinite(Number(usage.totalTokens)) && Number(usage.totalTokens) > 0
4565
+ ? toNonNegativeInt(usage.totalTokens)
4566
+ : input + output + cacheRead + cacheWrite + reasoningTokens;
4567
+
4568
+ const model = normalizeModelInput(msg.model) || fallbackModel;
4569
+
4570
+ const delta = {
4571
+ input_tokens: input,
4572
+ cached_input_tokens: cacheRead,
4573
+ cache_creation_input_tokens: cacheWrite,
4574
+ output_tokens: output,
4575
+ reasoning_output_tokens: reasoningTokens,
4576
+ total_tokens: totalTokens,
4577
+ conversation_count: 1,
4578
+ };
4579
+
4580
+ const bucket = getHourlyBucket(hourlyState, "omp", model, bucketStart);
4581
+ addTotals(bucket.totals, delta);
4582
+ touchedBuckets.add(bucketKey("omp", model, bucketStart));
4583
+ seenIds.add(entryId);
4584
+ eventsAggregated++;
4585
+
4586
+ if (cb) {
4587
+ cb({
4588
+ index: fileIdx + 1,
4589
+ total: files.length,
4590
+ recordsProcessed,
4591
+ eventsAggregated,
4592
+ bucketsQueued: touchedBuckets.size,
4593
+ });
4594
+ }
4595
+ }
4596
+
4597
+ let postStat = stat;
4598
+ try { postStat = fssync.statSync(filePath); } catch {}
4599
+ fileOffsets[filePath] = {
4600
+ size: postStat.size,
4601
+ mtimeMs: postStat.mtimeMs,
4602
+ ino: postStat.ino,
4603
+ };
4604
+ }
4605
+
4606
+ // Cap dedup set to last 10k IDs to bound cursor state size — same convention
4607
+ // as Kimi/CodeBuddy/Copilot so cursors.json doesn't grow unbounded.
4608
+ const seenArr = Array.from(seenIds);
4609
+ const cappedSeen =
4610
+ seenArr.length > 10_000 ? seenArr.slice(seenArr.length - 10_000) : seenArr;
4611
+
4612
+ const bucketsQueued = await enqueueTouchedBuckets({
4613
+ queuePath,
4614
+ hourlyState,
4615
+ touchedBuckets,
4616
+ });
4617
+ const updatedAt = new Date().toISOString();
4618
+ hourlyState.updatedAt = updatedAt;
4619
+ cursors.hourly = hourlyState;
4620
+ cursors.omp = {
4621
+ ...ompState,
4622
+ seenIds: cappedSeen,
4623
+ fileOffsets,
4624
+ updatedAt,
4625
+ };
4626
+
4627
+ return { recordsProcessed, eventsAggregated, bucketsQueued };
4628
+ }
4629
+
4376
4630
  // ─────────────────────────────────────────────────────────────────────────────
4377
4631
  // GitHub Copilot CLI — OpenTelemetry JSONL exporter
4378
4632
  // User must opt in by setting:
@@ -4593,6 +4847,11 @@ module.exports = {
4593
4847
  resolveKiroCliSessionFiles,
4594
4848
  resolveKiroCliDbPath,
4595
4849
  parseKiroCliIncremental,
4850
+ resolveOmpHome,
4851
+ resolveOmpAgentDir,
4852
+ resolveOmpSessionFiles,
4853
+ resolveOmpDefaultModel,
4854
+ parseOmpIncremental,
4596
4855
  // Exposed for regression tests covering cache-token accounting.
4597
4856
  normalizeGeminiTokens,
4598
4857
  normalizeOpencodeTokens,