tokelytics 0.3.5 → 0.3.6

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.
Files changed (2) hide show
  1. package/bin/tokelytics.mjs +140 -70
  2. package/package.json +1 -1
@@ -85,9 +85,20 @@ function mergeDashboardSnapshot(previous, turns, limits, device, now = /* @__PUR
85
85
  const next = structuredClone(previous ?? emptyDashboardSnapshot(now));
86
86
  next.version = 2;
87
87
  if (turns.length > 0) {
88
- next.recentTurns = mergeRecentTurns(next.recentTurns, turns);
89
- next.sessions = mergeSessions(next.sessions, turns);
90
- next.rollups = mergeRollups(next.rollups, turns, now);
88
+ const previousById = new Map(next.recentTurns.map((turn) => [turn.turnId, turn]));
89
+ const replacements = /* @__PURE__ */ new Map();
90
+ const changedTurns = turns.filter((turn) => {
91
+ const prior = previousById.get(turn.turnId);
92
+ if (!prior)
93
+ return true;
94
+ if (sameTurn(prior, turn))
95
+ return false;
96
+ replacements.set(turn.turnId, prior);
97
+ return true;
98
+ });
99
+ next.recentTurns = mergeRecentTurns(next.recentTurns, changedTurns);
100
+ next.sessions = mergeSessions(next.sessions, changedTurns, replacements);
101
+ next.rollups = mergeRollups(next.rollups, changedTurns, now, replacements);
91
102
  }
92
103
  if (limits.length > 0)
93
104
  next.limits = mergeLimits(next.limits, limits);
@@ -130,6 +141,9 @@ function mergeRecentTurns(existing, incoming) {
130
141
  byId.set(turn.turnId, turn);
131
142
  return [...byId.values()].sort((a, b) => a.ts.localeCompare(b.ts)).slice(-SNAPSHOT_RECENT_TURNS);
132
143
  }
144
+ function sameTurn(left, right) {
145
+ return JSON.stringify(left) === JSON.stringify(right);
146
+ }
133
147
  function sanitizeTurn(turn) {
134
148
  return {
135
149
  ...turn,
@@ -156,17 +170,30 @@ function safePath(value) {
156
170
  const parts = normalized.split("/").filter((part) => part && part !== "." && part !== ".." && !/^[A-Za-z]:$/.test(part));
157
171
  return parts.slice(-3).join("/").slice(-200);
158
172
  }
159
- function mergeSessions(existing, turns) {
173
+ function mergeSessions(existing, turns, replacements = /* @__PURE__ */ new Map()) {
160
174
  const byId = new Map(existing.map((session) => [session.sessionId, { ...session }]));
161
175
  for (const turn of turns) {
162
- const session = byId.get(turn.sessionId) ?? emptySession(turn);
163
- session.turnCount++;
164
- session.inputTokens += turn.inputTokens;
165
- session.outputTokens += turn.outputTokens;
166
- session.cacheReadTokens += turn.cacheReadTokens;
167
- session.cacheCreationTokens += turn.cacheCreationTokens;
168
- session.cachedInputTokens += turn.cachedInputTokens;
169
- session.reasoningTokens += turn.reasoningTokens;
176
+ const prior = replacements.get(turn.turnId);
177
+ if (prior)
178
+ applySessionTurn(byId, prior, -1);
179
+ applySessionTurn(byId, turn, 1);
180
+ }
181
+ return [...byId.values()].sort((a, b) => b.lastTs.localeCompare(a.lastTs)).slice(0, SNAPSHOT_SESSIONS);
182
+ }
183
+ function applySessionTurn(byId, turn, direction) {
184
+ const session = byId.get(turn.sessionId) ?? emptySession(turn);
185
+ session.turnCount += direction;
186
+ session.inputTokens = Math.max(0, session.inputTokens + direction * turn.inputTokens);
187
+ session.outputTokens = Math.max(0, session.outputTokens + direction * turn.outputTokens);
188
+ session.cacheReadTokens = Math.max(0, session.cacheReadTokens + direction * turn.cacheReadTokens);
189
+ session.cacheCreationTokens = Math.max(0, session.cacheCreationTokens + direction * turn.cacheCreationTokens);
190
+ session.cachedInputTokens = Math.max(0, session.cachedInputTokens + direction * turn.cachedInputTokens);
191
+ session.reasoningTokens = Math.max(0, session.reasoningTokens + direction * turn.reasoningTokens);
192
+ if (session.turnCount <= 0) {
193
+ byId.delete(turn.sessionId);
194
+ return;
195
+ }
196
+ if (direction > 0) {
170
197
  if (!session.firstTs || turn.ts < session.firstTs)
171
198
  session.firstTs = turn.ts;
172
199
  if (!session.lastTs || turn.ts > session.lastTs)
@@ -178,9 +205,8 @@ function mergeSessions(existing, turns) {
178
205
  if (turn.model && (!session.model || modelPriority(turn.model) > modelPriority(session.model))) {
179
206
  session.model = turn.model;
180
207
  }
181
- byId.set(turn.sessionId, session);
182
208
  }
183
- return [...byId.values()].sort((a, b) => b.lastTs.localeCompare(a.lastTs)).slice(0, SNAPSHOT_SESSIONS);
209
+ byId.set(turn.sessionId, session);
184
210
  }
185
211
  function emptySession(turn) {
186
212
  return {
@@ -200,48 +226,62 @@ function emptySession(turn) {
200
226
  reasoningTokens: 0
201
227
  };
202
228
  }
203
- function mergeRollups(existing, turns, now) {
229
+ function mergeRollups(existing, turns, now, replacements = /* @__PURE__ */ new Map()) {
204
230
  const byId = new Map(existing.map((rollup) => [rollup.id, structuredClone(rollup)]));
205
231
  for (const turn of turns) {
206
- const day = dayOf(turn.ts);
207
- if (!day)
208
- continue;
209
- const id = rollupId(turn.provider, day);
210
- const rollup = byId.get(id) ?? { id, provider: turn.provider, day, models: {}, hourly: [] };
211
- const model = turn.model || "unknown";
212
- const totals = rollup.models[model] ??= {
213
- model,
214
- turns: 0,
215
- inputTokens: 0,
216
- outputTokens: 0,
217
- cacheReadTokens: 0,
218
- cacheCreationTokens: 0,
219
- cachedInputTokens: 0,
220
- reasoningTokens: 0
221
- };
222
- totals.turns++;
223
- totals.inputTokens += turn.inputTokens;
224
- totals.outputTokens += turn.outputTokens;
225
- totals.cacheReadTokens += turn.cacheReadTokens;
226
- totals.cacheCreationTokens += turn.cacheCreationTokens;
227
- totals.cachedInputTokens += turn.cachedInputTokens;
228
- totals.reasoningTokens += turn.reasoningTokens;
229
- const hour = hourOf(turn.ts);
230
- if (hour !== null) {
231
- const bucket = rollup.hourly.find((item) => item.hour === hour) ?? { hour, output: 0, turns: 0 };
232
- bucket.output += turn.outputTokens;
233
- bucket.turns++;
234
- if (!rollup.hourly.some((item) => item.hour === hour))
235
- rollup.hourly.push(bucket);
236
- rollup.hourly.sort((a, b) => a.hour - b.hour);
237
- }
238
- byId.set(id, rollup);
232
+ const prior = replacements.get(turn.turnId);
233
+ if (prior)
234
+ applyRollupTurn(byId, prior, -1);
235
+ applyRollupTurn(byId, turn, 1);
239
236
  }
240
237
  const cutoff = new Date(now);
241
238
  cutoff.setUTCDate(cutoff.getUTCDate() - (SNAPSHOT_RETENTION_DAYS - 1));
242
239
  const cutoffDay = cutoff.toISOString().slice(0, 10);
243
240
  return [...byId.values()].filter((rollup) => rollup.day >= cutoffDay).sort((a, b) => a.day.localeCompare(b.day) || a.provider.localeCompare(b.provider));
244
241
  }
242
+ function applyRollupTurn(byId, turn, direction) {
243
+ const day = dayOf(turn.ts);
244
+ if (!day)
245
+ return;
246
+ const id = rollupId(turn.provider, day);
247
+ const rollup = byId.get(id) ?? { id, provider: turn.provider, day, models: {}, hourly: [] };
248
+ const model = turn.model || "unknown";
249
+ const totals = rollup.models[model] ??= {
250
+ model,
251
+ turns: 0,
252
+ inputTokens: 0,
253
+ outputTokens: 0,
254
+ cacheReadTokens: 0,
255
+ cacheCreationTokens: 0,
256
+ cachedInputTokens: 0,
257
+ reasoningTokens: 0
258
+ };
259
+ totals.turns += direction;
260
+ totals.inputTokens = Math.max(0, totals.inputTokens + direction * turn.inputTokens);
261
+ totals.outputTokens = Math.max(0, totals.outputTokens + direction * turn.outputTokens);
262
+ totals.cacheReadTokens = Math.max(0, totals.cacheReadTokens + direction * turn.cacheReadTokens);
263
+ totals.cacheCreationTokens = Math.max(0, totals.cacheCreationTokens + direction * turn.cacheCreationTokens);
264
+ totals.cachedInputTokens = Math.max(0, totals.cachedInputTokens + direction * turn.cachedInputTokens);
265
+ totals.reasoningTokens = Math.max(0, totals.reasoningTokens + direction * turn.reasoningTokens);
266
+ if (totals.turns <= 0)
267
+ delete rollup.models[model];
268
+ const hour = hourOf(turn.ts);
269
+ if (hour !== null) {
270
+ const bucket = rollup.hourly.find((item) => item.hour === hour) ?? { hour, output: 0, turns: 0 };
271
+ bucket.output = Math.max(0, bucket.output + direction * turn.outputTokens);
272
+ bucket.turns += direction;
273
+ if (bucket.turns <= 0) {
274
+ rollup.hourly = rollup.hourly.filter((item) => item.hour !== hour);
275
+ } else if (!rollup.hourly.some((item) => item.hour === hour)) {
276
+ rollup.hourly.push(bucket);
277
+ }
278
+ rollup.hourly.sort((a, b) => a.hour - b.hour);
279
+ }
280
+ if (Object.keys(rollup.models).length === 0)
281
+ byId.delete(id);
282
+ else
283
+ byId.set(id, rollup);
284
+ }
245
285
  function mergeLimits(existing, incoming) {
246
286
  const byProvider = new Map(existing.map((limit) => [limit.provider, limit]));
247
287
  for (const limit of incoming)
@@ -1802,7 +1842,7 @@ var FirestoreSink = class {
1802
1842
  };
1803
1843
 
1804
1844
  // src/version.ts
1805
- var AGENT_VERSION = "0.3.5";
1845
+ var AGENT_VERSION = "0.3.6";
1806
1846
 
1807
1847
  // src/sync.ts
1808
1848
  async function runSync(connectors, sink, state, device, limitCollectors = [], options = {}) {
@@ -1817,15 +1857,11 @@ async function runSync(connectors, sink, state, device, limitCollectors = [], op
1817
1857
  let st = state;
1818
1858
  const collected = [];
1819
1859
  const limits = [];
1820
- const byProvider = {};
1821
1860
  for (const c of connectors) {
1822
1861
  const { turns: turns2, limits: nativeLimits2 = [], state: next } = await c.collect(st);
1823
1862
  st = next;
1824
1863
  if (refreshLimits) limits.push(...nativeLimits2);
1825
- for (const t of turns2) {
1826
- collected.push(t);
1827
- byProvider[t.provider] = (byProvider[t.provider] ?? 0) + 1;
1828
- }
1864
+ collected.push(...turns2);
1829
1865
  }
1830
1866
  if (refreshLimits) {
1831
1867
  const providerLimits = await Promise.all(
@@ -1841,7 +1877,16 @@ async function runSync(connectors, sink, state, device, limitCollectors = [], op
1841
1877
  }
1842
1878
  const unique = /* @__PURE__ */ new Map();
1843
1879
  for (const t of collected) unique.set(t.turnId, t);
1844
- const turns = [...unique.values()];
1880
+ const collectedTurns = [...unique.values()];
1881
+ const previousTurns = new Map((options.dashboard?.recentTurns ?? []).map((turn) => [turn.turnId, turn]));
1882
+ const turns = collectedTurns.filter((turn) => {
1883
+ const previous = previousTurns.get(turn.turnId);
1884
+ return !previous || !sameTurn(previous, turn);
1885
+ });
1886
+ const changedByProvider = {};
1887
+ for (const turn of turns) {
1888
+ changedByProvider[turn.provider] = (changedByProvider[turn.provider] ?? 0) + 1;
1889
+ }
1845
1890
  const nativeLimits = refreshLimits ? dedupeLimits(limits) : [];
1846
1891
  const changedLimits = nativeLimits.filter(
1847
1892
  (limit) => publication.limitFingerprints?.[limit.provider] !== limitFingerprint(limit)
@@ -1907,14 +1952,18 @@ async function runSync(connectors, sink, state, device, limitCollectors = [], op
1907
1952
  publicationError = error;
1908
1953
  }
1909
1954
  }
1955
+ const scheduledCloudWrite = publication.dashboardDirty && hasBudget && !options.suppressCloudPublish ? Number.isFinite(Date.parse(publication.lastCloudWriteAt ?? "")) ? new Date(
1956
+ Math.max(now.getTime(), Date.parse(publication.lastCloudWriteAt) + cloudSyncIntervalMs)
1957
+ ).toISOString() : now.toISOString() : void 0;
1910
1958
  return {
1911
1959
  processedTurns: turns.length,
1912
1960
  processedLimits: changedLimits.length,
1913
- byProvider,
1961
+ byProvider: changedByProvider,
1914
1962
  state: st,
1915
1963
  dashboard,
1916
1964
  publication,
1917
1965
  published,
1966
+ nextCloudWriteAt: scheduledCloudWrite,
1918
1967
  publicationError
1919
1968
  };
1920
1969
  }
@@ -2005,6 +2054,7 @@ async function createRunner() {
2005
2054
  byProvider: res.byProvider,
2006
2055
  published: res.published,
2007
2056
  cloudWritesToday: res.publication.cloudWritesToday ?? 0,
2057
+ nextCloudWriteAt: res.nextCloudWriteAt,
2008
2058
  publicationError: res.publicationError
2009
2059
  };
2010
2060
  }
@@ -3798,7 +3848,7 @@ function close(server) {
3798
3848
  }
3799
3849
 
3800
3850
  // src/watch.ts
3801
- var DEBOUNCE_MS = 1200;
3851
+ var TURN_SETTLE_MS = 2500;
3802
3852
  var LIMIT_REFRESH_MS = 6e4;
3803
3853
  var FALLBACK_SCAN_MS = 5e3;
3804
3854
  var DEVICE_HEARTBEAT_MS = 60 * 6e4;
@@ -3807,6 +3857,7 @@ var MAX_CLOUD_WRITES_PER_DAY = 16;
3807
3857
  var QUOTA_RETRY_MS = 15 * 6e4;
3808
3858
  var QUOTA_RETRY_JITTER_MS = 5 * 6e4;
3809
3859
  async function watch2(runner) {
3860
+ const logState = {};
3810
3861
  let localServer;
3811
3862
  try {
3812
3863
  localServer = await startLocalDashboardServer(() => runner.localSnapshot(), runner.uid);
@@ -3821,7 +3872,7 @@ async function watch2(runner) {
3821
3872
  deviceHeartbeatMs: DEVICE_HEARTBEAT_MS,
3822
3873
  cloudSyncIntervalMs: CLOUD_SYNC_INTERVAL_MS,
3823
3874
  maxCloudWritesPerDay: MAX_CLOUD_WRITES_PER_DAY
3824
- }, false);
3875
+ }, false, logState);
3825
3876
  const paths = runner.watchPaths();
3826
3877
  const watcher = esm_default.watch(paths, {
3827
3878
  ignoreInitial: true,
@@ -3835,7 +3886,12 @@ async function watch2(runner) {
3835
3886
  let running = false;
3836
3887
  let scheduledAt = 0;
3837
3888
  let cloudBlockedUntil = startupRetryAfter ? Date.now() + startupRetryAfter : 0;
3838
- const schedule = (request, delay = DEBOUNCE_MS) => {
3889
+ const takePending = () => {
3890
+ const request = pending;
3891
+ pending = void 0;
3892
+ return request;
3893
+ };
3894
+ const schedule = (request, delay = TURN_SETTLE_MS) => {
3839
3895
  pending = mergeRequests(pending, request);
3840
3896
  if (running) return;
3841
3897
  const target = Date.now() + delay;
@@ -3845,21 +3901,19 @@ async function watch2(runner) {
3845
3901
  timer = setTimeout(async () => {
3846
3902
  timer = void 0;
3847
3903
  scheduledAt = 0;
3848
- const next = pending;
3849
- pending = void 0;
3904
+ const next = takePending();
3850
3905
  if (!next) return;
3851
3906
  running = true;
3852
- const retryAfterMs = await safeSync(runner, next, Date.now() < cloudBlockedUntil);
3907
+ const retryAfterMs = await safeSync(runner, next, Date.now() < cloudBlockedUntil, logState);
3853
3908
  running = false;
3854
3909
  if (retryAfterMs) {
3855
3910
  cloudBlockedUntil = Date.now() + retryAfterMs;
3856
3911
  } else if (Date.now() >= cloudBlockedUntil) {
3857
3912
  cloudBlockedUntil = 0;
3858
3913
  }
3859
- if (pending) {
3860
- const queued = pending;
3861
- pending = void 0;
3862
- schedule(queued, 0);
3914
+ const queued = takePending();
3915
+ if (queued) {
3916
+ schedule(queued, queued.reason === "change" ? TURN_SETTLE_MS : 0);
3863
3917
  }
3864
3918
  }, Math.max(0, target - Date.now()));
3865
3919
  };
@@ -3899,21 +3953,37 @@ Press Ctrl+C to stop.`);
3899
3953
  process.once("SIGTERM", stop);
3900
3954
  });
3901
3955
  }
3902
- async function safeSync(runner, request, suppressCloudPublish) {
3956
+ async function safeSync(runner, request, suppressCloudPublish, logState) {
3903
3957
  try {
3904
3958
  const { reason, ...options } = request;
3905
- const { processedTurns, byProvider, published, cloudWritesToday, publicationError } = await runner.syncOnce({
3959
+ const {
3960
+ processedTurns,
3961
+ byProvider,
3962
+ published,
3963
+ cloudWritesToday,
3964
+ nextCloudWriteAt,
3965
+ publicationError
3966
+ } = await runner.syncOnce({
3906
3967
  ...options,
3907
3968
  suppressCloudPublish
3908
3969
  });
3909
3970
  if (processedTurns > 0) {
3910
3971
  const detail = Object.entries(byProvider).map(([p, n]) => `${p}:${n}`).join(" ");
3911
3972
  console.log(
3912
- `[${(/* @__PURE__ */ new Date()).toLocaleTimeString()}] processed ${processedTurns} turn(s) locally (${detail}); ` + (published ? `cloud snapshot published (${cloudWritesToday}/${MAX_CLOUD_WRITES_PER_DAY} today)` : "cloud backup queued")
3973
+ `[${(/* @__PURE__ */ new Date()).toLocaleTimeString()}] processed ${processedTurns} completed turn(s) locally (${detail}).`
3913
3974
  );
3914
3975
  } else if (reason === "startup") {
3976
+ console.log(`[${(/* @__PURE__ */ new Date()).toLocaleTimeString()}] local snapshot ready.`);
3977
+ }
3978
+ if (published) {
3979
+ logState.cloudSchedule = void 0;
3980
+ console.log(
3981
+ `[${(/* @__PURE__ */ new Date()).toLocaleTimeString()}] cloud snapshot published (${cloudWritesToday}/${MAX_CLOUD_WRITES_PER_DAY} writes today).`
3982
+ );
3983
+ } else if (nextCloudWriteAt && nextCloudWriteAt !== logState.cloudSchedule) {
3984
+ logState.cloudSchedule = nextCloudWriteAt;
3915
3985
  console.log(
3916
- `[${(/* @__PURE__ */ new Date()).toLocaleTimeString()}] local snapshot ready; ` + (published ? `cloud snapshot published (${cloudWritesToday}/${MAX_CLOUD_WRITES_PER_DAY} today)` : "cloud backup queued")
3986
+ `Cloud backup scheduled for ${new Date(nextCloudWriteAt).toLocaleTimeString()} (new turns will be combined into this snapshot).`
3917
3987
  );
3918
3988
  }
3919
3989
  if (publicationError) return cloudRetryDelay(publicationError);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokelytics",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
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",