tokentracker-cli 0.7.0 → 0.8.0

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.
@@ -4487,8 +4487,40 @@ function resolveOmpHome(env = process.env) {
4487
4487
  return path.join(home, ".omp");
4488
4488
  }
4489
4489
 
4490
+ // PI_CODING_AGENT_DIR is documented by both pi-coding-agent and oh-my-pi as
4491
+ // their agent directory override. When set, attribute it to whichever tool the
4492
+ // user actually has installed: ~/.pi present → "pi", otherwise "omp" (the
4493
+ // historical default in this codebase, preserved for back-compat).
4494
+ //
4495
+ // Users with both tools installed can disambiguate explicitly with
4496
+ // TOKENTRACKER_PI_AGENT_DIR / TOKENTRACKER_OMP_AGENT_DIR, which take
4497
+ // precedence in their respective resolvers.
4498
+ function decidePiCodingAgentDirOwner(env = process.env) {
4499
+ const home = env.HOME || require("node:os").homedir();
4500
+ // Require an actual directory — a stray file (lockfile, junk) at ~/.pi
4501
+ // shouldn't reroute an existing oh-my-pi user's PI_CODING_AGENT_DIR override.
4502
+ try {
4503
+ if (fssync.statSync(path.join(home, ".pi")).isDirectory()) return "pi";
4504
+ } catch {
4505
+ // ENOENT or EACCES — treat as "no pi install signal".
4506
+ }
4507
+ return "omp";
4508
+ }
4509
+
4510
+ function expandHomePath(dir, env = process.env) {
4511
+ if (typeof dir !== "string" || !dir) return dir;
4512
+ if (dir !== "~" && !dir.startsWith("~/")) return dir;
4513
+ const home = env.HOME || require("node:os").homedir();
4514
+ return dir === "~" ? home : path.join(home, dir.slice(2));
4515
+ }
4516
+
4490
4517
  function resolveOmpAgentDir(env = process.env) {
4491
- if (env.PI_CODING_AGENT_DIR) return env.PI_CODING_AGENT_DIR;
4518
+ if (env.TOKENTRACKER_OMP_AGENT_DIR) {
4519
+ return expandHomePath(env.TOKENTRACKER_OMP_AGENT_DIR, env);
4520
+ }
4521
+ if (env.PI_CODING_AGENT_DIR && decidePiCodingAgentDirOwner(env) === "omp") {
4522
+ return expandHomePath(env.PI_CODING_AGENT_DIR, env);
4523
+ }
4492
4524
  return path.join(resolveOmpHome(env), "agent");
4493
4525
  }
4494
4526
 
@@ -4701,6 +4733,243 @@ async function parseOmpIncremental({
4701
4733
  return { recordsProcessed, eventsAggregated, bucketsQueued };
4702
4734
  }
4703
4735
 
4736
+ // ─────────────────────────────────────────────────────────────────────────────
4737
+ // pi (@mariozechner/pi-coding-agent) — passive JSONL reader
4738
+ // (~/.pi/agent/sessions/**/*.jsonl)
4739
+ //
4740
+ // Same on-disk session format as oh-my-pi (omp): one JSONL file per session,
4741
+ // first line type:"session" header, then a tree of message/model_change/etc.
4742
+ // records. Token usage lives on type:"message" entries with role:"assistant"
4743
+ // under message.usage.
4744
+ //
4745
+ // PI_CODING_AGENT_DIR is shared with omp (both upstream tools document it).
4746
+ // resolvePiAgentDir / resolveOmpAgentDir use decidePiCodingAgentDirOwner to
4747
+ // route the override to exactly one provider so the same sessions dir is
4748
+ // never scanned twice.
4749
+ // ─────────────────────────────────────────────────────────────────────────────
4750
+
4751
+ function resolvePiHome(env = process.env) {
4752
+ const home = env.HOME || require("node:os").homedir();
4753
+ return path.join(home, ".pi");
4754
+ }
4755
+
4756
+ function resolvePiAgentDir(env = process.env) {
4757
+ if (env.TOKENTRACKER_PI_AGENT_DIR) {
4758
+ return expandHomePath(env.TOKENTRACKER_PI_AGENT_DIR, env);
4759
+ }
4760
+ if (env.PI_CODING_AGENT_DIR && decidePiCodingAgentDirOwner(env) === "pi") {
4761
+ return expandHomePath(env.PI_CODING_AGENT_DIR, env);
4762
+ }
4763
+ return path.join(resolvePiHome(env), "agent");
4764
+ }
4765
+
4766
+ // Defense in depth for invariant 2 (no double-count). Two explicit overrides
4767
+ // pointing at the same path (e.g. TOKENTRACKER_OMP_AGENT_DIR === TOKENTRACKER_PI_AGENT_DIR,
4768
+ // or TOKENTRACKER_OMP_AGENT_DIR === PI_CODING_AGENT_DIR with ~/.pi present) bypass
4769
+ // the install-signal disambiguator and would otherwise have both providers scan
4770
+ // the same sessions directory under different `source` tags.
4771
+ function piAgentDirCollidesWithOmp(env = process.env) {
4772
+ return path.resolve(resolvePiAgentDir(env)) === path.resolve(resolveOmpAgentDir(env));
4773
+ }
4774
+
4775
+ function resolvePiSessionFiles(env = process.env) {
4776
+ const sessionsDir = path.join(resolvePiAgentDir(env), "sessions");
4777
+ if (!fssync.existsSync(sessionsDir)) return [];
4778
+ const files = [];
4779
+ try {
4780
+ for (const cwdDir of fssync.readdirSync(sessionsDir)) {
4781
+ const cwdPath = path.join(sessionsDir, cwdDir);
4782
+ let stat;
4783
+ try { stat = fssync.statSync(cwdPath); } catch { continue; }
4784
+ if (!stat.isDirectory()) continue;
4785
+ let entries;
4786
+ try { entries = fssync.readdirSync(cwdPath); } catch { continue; }
4787
+ for (const entry of entries) {
4788
+ if (!entry.endsWith(".jsonl")) continue;
4789
+ files.push(path.join(cwdPath, entry));
4790
+ }
4791
+ }
4792
+ } catch {
4793
+ // ignore — return what we have
4794
+ }
4795
+ files.sort((a, b) => a.localeCompare(b));
4796
+ return files;
4797
+ }
4798
+
4799
+ function resolvePiDefaultModel() {
4800
+ // pi has no global default model; model is per-message.
4801
+ return "pi-unknown";
4802
+ }
4803
+
4804
+ async function parsePiIncremental({
4805
+ sessionFiles,
4806
+ cursors,
4807
+ queuePath,
4808
+ onProgress,
4809
+ env,
4810
+ defaultModel,
4811
+ } = {}) {
4812
+ await ensureDir(path.dirname(queuePath));
4813
+ const piState = cursors.pi && typeof cursors.pi === "object" ? cursors.pi : {};
4814
+ const seenIds = new Set(Array.isArray(piState.seenIds) ? piState.seenIds : []);
4815
+ const fileOffsets =
4816
+ piState.fileOffsets && typeof piState.fileOffsets === "object"
4817
+ ? { ...piState.fileOffsets }
4818
+ : {};
4819
+
4820
+ const files = Array.isArray(sessionFiles)
4821
+ ? sessionFiles
4822
+ : resolvePiSessionFiles(env || process.env);
4823
+ const fallbackModel = defaultModel || resolvePiDefaultModel();
4824
+
4825
+ if (files.length === 0) {
4826
+ cursors.pi = {
4827
+ ...piState,
4828
+ seenIds: Array.from(seenIds),
4829
+ fileOffsets,
4830
+ updatedAt: new Date().toISOString(),
4831
+ };
4832
+ return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
4833
+ }
4834
+
4835
+ const hourlyState = normalizeHourlyState(cursors?.hourly);
4836
+ const touchedBuckets = new Set();
4837
+ const cb = typeof onProgress === "function" ? onProgress : null;
4838
+ let recordsProcessed = 0;
4839
+ let eventsAggregated = 0;
4840
+
4841
+ for (let fileIdx = 0; fileIdx < files.length; fileIdx++) {
4842
+ const filePath = files[fileIdx];
4843
+ let stat;
4844
+ try { stat = fssync.statSync(filePath); } catch { continue; }
4845
+
4846
+ const prevEntry = fileOffsets[filePath] || {};
4847
+ const prevSize = Number(prevEntry.size) || 0;
4848
+ const prevIno = prevEntry.ino;
4849
+ const inodeChanged = typeof prevIno === "number" && prevIno !== stat.ino;
4850
+ const startOffset = stat.size < prevSize || inodeChanged ? 0 : prevSize;
4851
+ if (stat.size <= startOffset) continue;
4852
+
4853
+ let stream;
4854
+ try {
4855
+ stream = fssync.createReadStream(filePath, {
4856
+ encoding: "utf8",
4857
+ start: startOffset,
4858
+ });
4859
+ } catch { continue; }
4860
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
4861
+
4862
+ for await (const line of rl) {
4863
+ if (!line || !line.trim()) continue;
4864
+ let entry;
4865
+ try { entry = JSON.parse(line); } catch { continue; }
4866
+
4867
+ if (!entry || entry.type !== "message") continue;
4868
+
4869
+ const msg = entry.message;
4870
+ if (!msg || msg.role !== "assistant") continue;
4871
+
4872
+ const usage = msg.usage;
4873
+ if (!usage || typeof usage !== "object") continue;
4874
+
4875
+ const entryId = typeof entry.id === "string" && entry.id ? entry.id : null;
4876
+ if (!entryId) continue;
4877
+ if (seenIds.has(entryId)) continue;
4878
+
4879
+ recordsProcessed++;
4880
+
4881
+ const input = toNonNegativeInt(usage.input);
4882
+ const output = toNonNegativeInt(usage.output);
4883
+ const cacheRead = toNonNegativeInt(usage.cacheRead);
4884
+ const cacheWrite = toNonNegativeInt(usage.cacheWrite);
4885
+ const reasoningTokens = toNonNegativeInt(usage.reasoningTokens);
4886
+
4887
+ if (input === 0 && output === 0 && cacheRead === 0 && cacheWrite === 0) {
4888
+ seenIds.add(entryId);
4889
+ continue;
4890
+ }
4891
+
4892
+ let tsMs = null;
4893
+ if (Number.isFinite(Number(msg.timestamp)) && Number(msg.timestamp) > 0) {
4894
+ tsMs = Number(msg.timestamp);
4895
+ } else if (typeof entry.timestamp === "string" && entry.timestamp) {
4896
+ const parsed = Date.parse(entry.timestamp);
4897
+ if (Number.isFinite(parsed) && parsed > 0) tsMs = parsed;
4898
+ }
4899
+ if (tsMs == null) {
4900
+ seenIds.add(entryId);
4901
+ continue;
4902
+ }
4903
+
4904
+ const tsIso = new Date(tsMs).toISOString();
4905
+ const bucketStart = toUtcHalfHourStart(tsIso);
4906
+ if (!bucketStart) continue;
4907
+
4908
+ const totalTokens =
4909
+ Number.isFinite(Number(usage.totalTokens)) && Number(usage.totalTokens) > 0
4910
+ ? toNonNegativeInt(usage.totalTokens)
4911
+ : input + output + cacheRead + cacheWrite + reasoningTokens;
4912
+
4913
+ const model = normalizeModelInput(msg.model) || fallbackModel;
4914
+
4915
+ const delta = {
4916
+ input_tokens: input,
4917
+ cached_input_tokens: cacheRead,
4918
+ cache_creation_input_tokens: cacheWrite,
4919
+ output_tokens: output,
4920
+ reasoning_output_tokens: reasoningTokens,
4921
+ total_tokens: totalTokens,
4922
+ conversation_count: 1,
4923
+ };
4924
+
4925
+ const bucket = getHourlyBucket(hourlyState, "pi", model, bucketStart);
4926
+ addTotals(bucket.totals, delta);
4927
+ touchedBuckets.add(bucketKey("pi", model, bucketStart));
4928
+ seenIds.add(entryId);
4929
+ eventsAggregated++;
4930
+
4931
+ if (cb) {
4932
+ cb({
4933
+ index: fileIdx + 1,
4934
+ total: files.length,
4935
+ recordsProcessed,
4936
+ eventsAggregated,
4937
+ bucketsQueued: touchedBuckets.size,
4938
+ });
4939
+ }
4940
+ }
4941
+
4942
+ let postStat = stat;
4943
+ try { postStat = fssync.statSync(filePath); } catch {}
4944
+ fileOffsets[filePath] = {
4945
+ size: postStat.size,
4946
+ mtimeMs: postStat.mtimeMs,
4947
+ ino: postStat.ino,
4948
+ };
4949
+ }
4950
+
4951
+ const seenArr = Array.from(seenIds);
4952
+ const cappedSeen =
4953
+ seenArr.length > 10_000 ? seenArr.slice(seenArr.length - 10_000) : seenArr;
4954
+
4955
+ const bucketsQueued = await enqueueTouchedBuckets({
4956
+ queuePath,
4957
+ hourlyState,
4958
+ touchedBuckets,
4959
+ });
4960
+ const updatedAt = new Date().toISOString();
4961
+ hourlyState.updatedAt = updatedAt;
4962
+ cursors.hourly = hourlyState;
4963
+ cursors.pi = {
4964
+ ...piState,
4965
+ seenIds: cappedSeen,
4966
+ fileOffsets,
4967
+ updatedAt,
4968
+ };
4969
+
4970
+ return { recordsProcessed, eventsAggregated, bucketsQueued };
4971
+ }
4972
+
4704
4973
  // ─────────────────────────────────────────────────────────────────────────────
4705
4974
  // Craft Agents (lukilabs/craft-agents-oss) — passive JSONL reader
4706
4975
  //
@@ -5257,6 +5526,12 @@ module.exports = {
5257
5526
  resolveOmpSessionFiles,
5258
5527
  resolveOmpDefaultModel,
5259
5528
  parseOmpIncremental,
5529
+ resolvePiHome,
5530
+ resolvePiAgentDir,
5531
+ resolvePiSessionFiles,
5532
+ resolvePiDefaultModel,
5533
+ parsePiIncremental,
5534
+ piAgentDirCollidesWithOmp,
5260
5535
  resolveCraftConfigDir,
5261
5536
  resolveCraftWorkspaceRoots,
5262
5537
  resolveCraftSessionFiles,