tokelytics 0.3.0 → 0.3.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/README.md CHANGED
@@ -12,6 +12,9 @@ npx tokelytics@latest login # opens your browser to approve — no setup, no
12
12
  npx tokelytics@latest watch # watches locally; cloud snapshot up to every 30m
13
13
  ```
14
14
 
15
+ Keep `@latest` in install and onboarding commands. Agents older than 0.3.1
16
+ cannot write to the hardened Firestore schema.
17
+
15
18
  Other commands: `sync` (one pass), `status`, `logout`.
16
19
 
17
20
  ### How login works (and why there's no API key)
@@ -70,6 +70,7 @@ var TIMELINE_RETENTION_MS = 8 * 24 * 60 * 60 * 1e3;
70
70
  var SNAPSHOT_RECENT_TURNS = 32;
71
71
  var SNAPSHOT_SESSIONS = 100;
72
72
  var SNAPSHOT_RETENTION_DAYS = 120;
73
+ var SNAPSHOT_TARGET_BYTES = 7e5;
73
74
  function emptyDashboardSnapshot(now = /* @__PURE__ */ new Date()) {
74
75
  return {
75
76
  version: 2,
@@ -96,12 +97,65 @@ function mergeDashboardSnapshot(previous, turns, limits, device, now = /* @__PUR
96
97
  next.updatedAt = now.toISOString();
97
98
  return next;
98
99
  }
100
+ function prepareDashboardSnapshotForCloud(snapshot2, targetBytes = SNAPSHOT_TARGET_BYTES) {
101
+ const next = structuredClone(snapshot2);
102
+ next.recentTurns = next.recentTurns.map(sanitizeTurn);
103
+ next.sessions = next.sessions.map((session) => ({
104
+ ...session,
105
+ project: safeLabel(session.project),
106
+ gitBranch: safeLabel(session.gitBranch, 120)
107
+ }));
108
+ if (next.device)
109
+ next.device.name = safeLabel(next.device.name, 120);
110
+ while (snapshotBytes(next) > targetBytes && next.sessions.length > 20)
111
+ next.sessions.pop();
112
+ while (snapshotBytes(next) > targetBytes && next.rollups.length > 30)
113
+ next.rollups.shift();
114
+ while (snapshotBytes(next) > targetBytes && next.recentTurns.length > 8)
115
+ next.recentTurns.shift();
116
+ if (snapshotBytes(next) > targetBytes) {
117
+ next.recentTurns = next.recentTurns.map((turn) => ({ ...turn, fileReads: void 0 }));
118
+ }
119
+ if (snapshotBytes(next) > targetBytes) {
120
+ throw new Error(`Dashboard snapshot exceeds the ${targetBytes} byte safety limit after pruning.`);
121
+ }
122
+ return next;
123
+ }
124
+ function snapshotBytes(snapshot2) {
125
+ return new TextEncoder().encode(JSON.stringify(snapshot2)).byteLength;
126
+ }
99
127
  function mergeRecentTurns(existing, incoming) {
100
128
  const byId = new Map(existing.map((turn) => [turn.turnId, turn]));
101
129
  for (const turn of incoming)
102
130
  byId.set(turn.turnId, turn);
103
131
  return [...byId.values()].sort((a, b) => a.ts.localeCompare(b.ts)).slice(-SNAPSHOT_RECENT_TURNS);
104
132
  }
133
+ function sanitizeTurn(turn) {
134
+ return {
135
+ ...turn,
136
+ cwd: void 0,
137
+ project: safeLabel(turn.project),
138
+ gitBranch: safeLabel(turn.gitBranch, 120),
139
+ toolName: safeLabel(turn.toolName ?? void 0, 80) || void 0,
140
+ fileReads: turn.fileReads?.slice(0, 20).map((read) => ({
141
+ ...read,
142
+ path: safePath(read.path)
143
+ }))
144
+ };
145
+ }
146
+ function safeLabel(value, max = 160) {
147
+ if (!value)
148
+ return "";
149
+ return [...value].filter((character) => {
150
+ const code = character.charCodeAt(0);
151
+ return code > 31 && code !== 127;
152
+ }).join("").trim().slice(0, max);
153
+ }
154
+ function safePath(value) {
155
+ const normalized = value.replaceAll("\\", "/");
156
+ const parts = normalized.split("/").filter((part) => part && part !== "." && part !== ".." && !/^[A-Za-z]:$/.test(part));
157
+ return parts.slice(-3).join("/").slice(-200);
158
+ }
105
159
  function mergeSessions(existing, turns) {
106
160
  const byId = new Map(existing.map((session) => [session.sessionId, { ...session }]));
107
161
  for (const turn of turns) {
@@ -327,12 +381,12 @@ function fileReadFromPath(rawPath, cwd, limitLines) {
327
381
  bytes = Math.min(bytes, limitLines * APPROX_BYTES_PER_LINE);
328
382
  }
329
383
  const normalizedRaw = rawPath.replace(/\\/g, "/").replace(/^\.\//, "");
330
- const safePath = !rawIsAbsolute && !normalizedRaw.startsWith("../") ? normalizedRaw : displayPath(absolutePath, cwd);
384
+ const safePath2 = !rawIsAbsolute && !normalizedRaw.startsWith("../") ? normalizedRaw : displayPath(absolutePath, cwd);
331
385
  return {
332
- path: safePath,
386
+ path: safePath2,
333
387
  bytes,
334
388
  estimatedTokens: bytes > 0 ? Math.ceil(bytes / BYTES_PER_TOKEN) : 0,
335
- generated: isGeneratedPath(safePath)
389
+ generated: isGeneratedPath(safePath2)
336
390
  };
337
391
  }
338
392
  function parseJsonObject(value) {
@@ -898,7 +952,10 @@ function configDir() {
898
952
  return process.env.TOKELYTICS_HOME ?? path5.join(os3.homedir(), ".tokelytics");
899
953
  }
900
954
  var statePath = () => path5.join(configDir(), "state.json");
955
+ var stateBackupPath = () => path5.join(configDir(), "state.backup.json");
901
956
  var credsPath = () => path5.join(configDir(), "credentials.json");
957
+ var updatePath = () => path5.join(configDir(), "update.json");
958
+ var watchLockPath = () => path5.join(configDir(), "watch.lock");
902
959
  async function ensureDir() {
903
960
  await fs3.mkdir(configDir(), { recursive: true });
904
961
  }
@@ -909,19 +966,31 @@ async function readJson(file) {
909
966
  return null;
910
967
  }
911
968
  }
912
- async function writeJson(file, value) {
969
+ async function writeJson(file, value, backup) {
913
970
  await ensureDir();
914
- await fs3.writeFile(file, JSON.stringify(value, null, 2), "utf-8");
971
+ const temp = `${file}.${process.pid}.${randomUUID()}.tmp`;
972
+ await fs3.writeFile(temp, JSON.stringify(value, null, 2), "utf-8");
973
+ try {
974
+ if (backup) {
975
+ try {
976
+ await fs3.copyFile(file, backup);
977
+ } catch {
978
+ }
979
+ }
980
+ await fs3.rename(temp, file);
981
+ } finally {
982
+ await fs3.rm(temp, { force: true }).catch(() => void 0);
983
+ }
915
984
  }
916
985
  async function loadState() {
917
- const s = await readJson(statePath());
986
+ const s = await readJson(statePath()) ?? await readJson(stateBackupPath());
918
987
  if (s && s.deviceId && s.scan) return s;
919
988
  const fresh = { deviceId: randomUUID(), scan: emptyScanState(), publication: {} };
920
989
  await writeJson(statePath(), fresh);
921
990
  return fresh;
922
991
  }
923
992
  async function saveState(state) {
924
- await writeJson(statePath(), state);
993
+ await writeJson(statePath(), state, stateBackupPath());
925
994
  }
926
995
  async function loadCredentials() {
927
996
  return readJson(credsPath());
@@ -939,6 +1008,49 @@ async function clearCredentials() {
939
1008
  } catch {
940
1009
  }
941
1010
  }
1011
+ async function loadUpdateState() {
1012
+ return await readJson(updatePath()) ?? {};
1013
+ }
1014
+ async function saveUpdateState(state) {
1015
+ await writeJson(updatePath(), state);
1016
+ }
1017
+ async function acquireWatchLock() {
1018
+ await ensureDir();
1019
+ const file = watchLockPath();
1020
+ for (let attempt = 0; attempt < 2; attempt++) {
1021
+ try {
1022
+ const handle = await fs3.open(file, "wx");
1023
+ await handle.writeFile(JSON.stringify({ pid: process.pid, startedAt: (/* @__PURE__ */ new Date()).toISOString() }), "utf-8");
1024
+ await handle.close();
1025
+ let released = false;
1026
+ return {
1027
+ async release() {
1028
+ if (released) return;
1029
+ released = true;
1030
+ const lock = await readJson(file);
1031
+ if (lock?.pid === process.pid) await fs3.rm(file, { force: true });
1032
+ }
1033
+ };
1034
+ } catch (error) {
1035
+ const code = error.code;
1036
+ if (code !== "EEXIST") throw error;
1037
+ const lock = await readJson(file);
1038
+ if (lock?.pid && processIsAlive(lock.pid)) {
1039
+ throw new Error(`Tokelytics is already running (PID ${lock.pid}).`, { cause: error });
1040
+ }
1041
+ await fs3.rm(file, { force: true });
1042
+ }
1043
+ }
1044
+ throw new Error("Could not acquire the Tokelytics watcher lock.");
1045
+ }
1046
+ function processIsAlive(pid) {
1047
+ try {
1048
+ process.kill(pid, 0);
1049
+ return true;
1050
+ } catch (error) {
1051
+ return error.code === "EPERM";
1052
+ }
1053
+ }
942
1054
 
943
1055
  // src/config.ts
944
1056
  var DEFAULT_FIREBASE = {
@@ -1658,15 +1770,19 @@ var FirestoreSink = class {
1658
1770
  }
1659
1771
  async writeDashboardSnapshot(snapshot2) {
1660
1772
  if (!snapshot2.device?.deviceId) throw new Error("Dashboard snapshot is missing its device id.");
1773
+ const cloudSnapshot = prepareDashboardSnapshotForCloud(snapshot2);
1661
1774
  await this.fs.upsert([
1662
1775
  {
1663
1776
  name: this.fs.docName("users", this.uid, "machines", snapshot2.device.deviceId),
1664
- fields: { ...snapshot2 }
1777
+ fields: { ...cloudSnapshot }
1665
1778
  }
1666
1779
  ]);
1667
1780
  }
1668
1781
  };
1669
1782
 
1783
+ // src/version.ts
1784
+ var AGENT_VERSION = "0.3.2";
1785
+
1670
1786
  // src/sync.ts
1671
1787
  async function runSync(connectors, sink, state, device, limitCollectors = [], options = {}) {
1672
1788
  const now = options.now ?? /* @__PURE__ */ new Date();
@@ -1731,6 +1847,7 @@ async function runSync(connectors, sink, state, device, limitCollectors = [], op
1731
1847
  publication.lastDeviceAt = now.toISOString();
1732
1848
  }
1733
1849
  const changed = turns.length > 0 || changedLimits.length > 0 || updateDevice;
1850
+ if (changed) publication.lastLocalEventAt = now.toISOString();
1734
1851
  publication.dashboardDirty = Boolean(publication.dashboardDirty || changed);
1735
1852
  resetDailyBudget(publication, now);
1736
1853
  const lastCloudWrite = Date.parse(publication.lastCloudWriteAt ?? "");
@@ -1738,14 +1855,29 @@ async function runSync(connectors, sink, state, device, limitCollectors = [], op
1738
1855
  const hasBudget = (publication.cloudWritesToday ?? 0) < maxCloudWritesPerDay;
1739
1856
  let published = false;
1740
1857
  let publicationError;
1858
+ dashboard.sync = {
1859
+ agentVersion: AGENT_VERSION,
1860
+ cloudWritesToday: publication.cloudWritesToday ?? 0,
1861
+ maxCloudWritesPerDay,
1862
+ localChangesPending: Boolean(publication.dashboardDirty),
1863
+ lastLocalEventAt: publication.lastLocalEventAt,
1864
+ lastCloudWriteAt: publication.lastCloudWriteAt
1865
+ };
1741
1866
  if (publication.dashboardDirty && cloudWriteDue && hasBudget) {
1742
1867
  try {
1868
+ dashboard.sync = {
1869
+ ...dashboard.sync,
1870
+ cloudWritesToday: (publication.cloudWritesToday ?? 0) + 1,
1871
+ localChangesPending: false,
1872
+ lastCloudWriteAt: now.toISOString()
1873
+ };
1743
1874
  await sink.writeDashboardSnapshot(dashboard);
1744
1875
  publication.cloudWritesToday = (publication.cloudWritesToday ?? 0) + 1;
1745
1876
  publication.lastCloudWriteAt = now.toISOString();
1746
1877
  publication.dashboardDirty = false;
1747
1878
  published = true;
1748
1879
  } catch (error) {
1880
+ dashboard.sync.localChangesPending = true;
1749
1881
  publicationError = error;
1750
1882
  }
1751
1883
  }
@@ -3705,6 +3837,40 @@ function isUsageFile(filePath) {
3705
3837
  return filePath.endsWith(".jsonl") || filePath.endsWith(".sqlite") || filePath.endsWith(".sqlite-wal");
3706
3838
  }
3707
3839
 
3840
+ // src/update-check.ts
3841
+ var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
3842
+ var REGISTRY_URL = "https://registry.npmjs.org/tokelytics/latest";
3843
+ async function agentUpdateMessage(force = false) {
3844
+ try {
3845
+ const state = await loadUpdateState();
3846
+ const checkedAt = Date.parse(state.checkedAt ?? "");
3847
+ let latest = state.latestVersion;
3848
+ if (force || !Number.isFinite(checkedAt) || Date.now() - checkedAt >= CHECK_INTERVAL_MS) {
3849
+ const response = await fetch(REGISTRY_URL, { signal: AbortSignal.timeout(5e3) });
3850
+ if (!response.ok) return void 0;
3851
+ const body = await response.json();
3852
+ latest = body.version;
3853
+ await saveUpdateState({
3854
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
3855
+ latestVersion: latest
3856
+ });
3857
+ }
3858
+ if (latest && compareVersions(latest, AGENT_VERSION) > 0) {
3859
+ return `Tokelytics ${latest} is available. Update with: npm install -g tokelytics@latest`;
3860
+ }
3861
+ } catch {
3862
+ }
3863
+ return void 0;
3864
+ }
3865
+ function compareVersions(left, right) {
3866
+ const a = left.split(".").map((part) => Number.parseInt(part, 10) || 0);
3867
+ const b = right.split(".").map((part) => Number.parseInt(part, 10) || 0);
3868
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
3869
+ if ((a[i] ?? 0) !== (b[i] ?? 0)) return (a[i] ?? 0) > (b[i] ?? 0) ? 1 : -1;
3870
+ }
3871
+ return 0;
3872
+ }
3873
+
3708
3874
  // src/cli.ts
3709
3875
  var USAGE = `Tokelytics agent
3710
3876
 
@@ -3727,24 +3893,39 @@ async function main(argv) {
3727
3893
  } catch (err) {
3728
3894
  console.warn(`(Couldn't register device yet \u2014 will retry on first sync: ${err.message})`);
3729
3895
  }
3730
- console.log('Done. Now run "tokelytics watch" to stream your usage.');
3896
+ console.log('Done. Now run "npx tokelytics@latest watch" to keep your dashboard updated.');
3731
3897
  return 0;
3732
3898
  }
3733
3899
  case "sync": {
3734
- const runner = await createRunner();
3735
- const { processedTurns, processedLimits, byProvider } = await runner.syncOnce();
3736
- const detail = Object.entries(byProvider).map(([p, n]) => `${p}:${n}`).join(" ") || "none";
3737
- console.log(`Processed ${processedTurns} turn(s) (${detail}); refreshed ${processedLimits} usage provider(s).`);
3900
+ const update = await agentUpdateMessage();
3901
+ if (update) console.warn(update);
3902
+ const lock = await acquireWatchLock();
3903
+ try {
3904
+ const runner = await createRunner();
3905
+ const { processedTurns, processedLimits, byProvider } = await runner.syncOnce();
3906
+ const detail = Object.entries(byProvider).map(([p, n]) => `${p}:${n}`).join(" ") || "none";
3907
+ console.log(`Processed ${processedTurns} turn(s) (${detail}); refreshed ${processedLimits} usage provider(s).`);
3908
+ } finally {
3909
+ await lock.release();
3910
+ }
3738
3911
  return 0;
3739
3912
  }
3740
3913
  case "watch": {
3741
- const runner = await createRunner();
3742
- await watch2(runner);
3914
+ const lock = await acquireWatchLock();
3915
+ try {
3916
+ const update = await agentUpdateMessage();
3917
+ if (update) console.warn(update);
3918
+ const runner = await createRunner();
3919
+ await watch2(runner);
3920
+ } finally {
3921
+ await lock.release();
3922
+ }
3743
3923
  return 0;
3744
3924
  }
3745
3925
  case "status": {
3746
3926
  const creds = await loadCredentials();
3747
3927
  const state = await loadState();
3928
+ console.log(`Agent version: ${AGENT_VERSION}`);
3748
3929
  if (!creds) {
3749
3930
  console.log('Not signed in. Run "tokelytics login".');
3750
3931
  } else {
@@ -3760,6 +3941,8 @@ async function main(argv) {
3760
3941
  } catch (err) {
3761
3942
  console.log(`Session needs refresh: ${err.message}`);
3762
3943
  }
3944
+ const update = await agentUpdateMessage(true);
3945
+ if (update) console.log(update);
3763
3946
  return 0;
3764
3947
  }
3765
3948
  case "logout": {
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "tokelytics",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Tokelytics sync agent — streams local AI CLI usage logs (Claude Code, Codex) to your Tokelytics dashboard.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "bin": {
8
- "tokelytics": "./bin/tokelytics.mjs"
8
+ "tokelytics": "bin/tokelytics.mjs"
9
9
  },
10
10
  "files": [
11
11
  "bin",