tokelytics 0.3.0 → 0.3.1
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 +3 -0
- package/bin/tokelytics.mjs +198 -15
- package/package.json +2 -2
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)
|
package/bin/tokelytics.mjs
CHANGED
|
@@ -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
|
|
384
|
+
const safePath2 = !rawIsAbsolute && !normalizedRaw.startsWith("../") ? normalizedRaw : displayPath(absolutePath, cwd);
|
|
331
385
|
return {
|
|
332
|
-
path:
|
|
386
|
+
path: safePath2,
|
|
333
387
|
bytes,
|
|
334
388
|
estimatedTokens: bytes > 0 ? Math.ceil(bytes / BYTES_PER_TOKEN) : 0,
|
|
335
|
-
generated: isGeneratedPath(
|
|
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
|
-
|
|
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: { ...
|
|
1777
|
+
fields: { ...cloudSnapshot }
|
|
1665
1778
|
}
|
|
1666
1779
|
]);
|
|
1667
1780
|
}
|
|
1668
1781
|
};
|
|
1669
1782
|
|
|
1783
|
+
// src/version.ts
|
|
1784
|
+
var AGENT_VERSION = "0.3.1";
|
|
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
|
|
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
|
|
3735
|
-
|
|
3736
|
-
const
|
|
3737
|
-
|
|
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
|
|
3742
|
-
|
|
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.
|
|
3
|
+
"version": "0.3.1",
|
|
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": "
|
|
8
|
+
"tokelytics": "bin/tokelytics.mjs"
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"bin",
|