tokelytics 0.1.0 → 0.2.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.
Files changed (2) hide show
  1. package/bin/tokelytics.mjs +77 -0
  2. package/package.json +1 -1
@@ -147,6 +147,42 @@ function buildRollup(provider, day, turns) {
147
147
  hourly: [...hours.values()].sort((a, b) => a.hour - b.hour)
148
148
  };
149
149
  }
150
+ var TIMELINE_BUCKET_MS = 5 * 60 * 1e3;
151
+ var TIMELINE_RETENTION_MS = 8 * 24 * 60 * 60 * 1e3;
152
+ function bucketStartMs(ts) {
153
+ const ms = Date.parse(ts);
154
+ if (!Number.isFinite(ms))
155
+ return null;
156
+ return Math.floor(ms / TIMELINE_BUCKET_MS) * TIMELINE_BUCKET_MS;
157
+ }
158
+ function dayOfMs(t) {
159
+ return new Date(t).toISOString().slice(0, 10);
160
+ }
161
+ function buildTimelineBuckets(turns) {
162
+ const map = /* @__PURE__ */ new Map();
163
+ for (const t of turns) {
164
+ const start = bucketStartMs(t.ts);
165
+ if (start === null)
166
+ continue;
167
+ const bucket = map.get(start) ?? { t: start, models: {} };
168
+ const key = t.model || "unknown";
169
+ const m = bucket.models[key] ??= { i: 0, o: 0, cr: 0, cc: 0, ci: 0, rs: 0 };
170
+ m.i += t.inputTokens;
171
+ m.o += t.outputTokens;
172
+ m.cr += t.cacheReadTokens;
173
+ m.cc += t.cacheCreationTokens;
174
+ m.ci += t.cachedInputTokens;
175
+ m.rs += t.reasoningTokens;
176
+ map.set(start, bucket);
177
+ }
178
+ return [...map.values()].sort((a, b) => a.t - b.t);
179
+ }
180
+ function mergeTimeline(existing, recomputed, affectedDays, nowMs) {
181
+ const cutoff = nowMs - TIMELINE_RETENTION_MS;
182
+ const kept = existing.filter((b) => b.t >= cutoff && !affectedDays.has(dayOfMs(b.t)));
183
+ const fresh = recomputed.filter((b) => b.t >= cutoff);
184
+ return [...kept, ...fresh].sort((a, b) => a.t - b.t);
185
+ }
150
186
  function affectedKeys(turns) {
151
187
  const sessionIds = /* @__PURE__ */ new Set();
152
188
  const dayBuckets = /* @__PURE__ */ new Map();
@@ -915,6 +951,14 @@ var FirestoreRest = class {
915
951
  });
916
952
  if (!res.ok) throw new Error(`Firestore commit failed (${res.status}): ${await res.text()}`);
917
953
  }
954
+ /** Fetch a single document's decoded fields, or null if it doesn't exist. */
955
+ async getDoc(name) {
956
+ const res = await fetch(this.url(name), { method: "GET", headers: await this.headers() });
957
+ if (res.status === 404) return null;
958
+ if (!res.ok) throw new Error(`Firestore get failed (${res.status}): ${await res.text()}`);
959
+ const doc = await res.json();
960
+ return doc.fields ? decodeFields(doc.fields) : {};
961
+ }
918
962
  /** Run an equality query under a parent path, returning decoded documents. */
919
963
  async queryEqual(parentSegments, collectionId, filters) {
920
964
  const parent = parentSegments.length ? this.docName(...parentSegments) : this.docsRoot();
@@ -937,6 +981,22 @@ var FirestoreRest = class {
937
981
  }
938
982
  };
939
983
 
984
+ // src/sink.ts
985
+ function timelineDaysByProvider(buckets, nowMs) {
986
+ const cutoffDay = new Date(nowMs - TIMELINE_RETENTION_MS).toISOString().slice(0, 10);
987
+ const out = /* @__PURE__ */ new Map();
988
+ for (const { provider, day } of buckets) {
989
+ if (day < cutoffDay) continue;
990
+ let set = out.get(provider);
991
+ if (!set) {
992
+ set = /* @__PURE__ */ new Set();
993
+ out.set(provider, set);
994
+ }
995
+ set.add(day);
996
+ }
997
+ return out;
998
+ }
999
+
940
1000
  // src/firestore-sink.ts
941
1001
  var BATCH = 400;
942
1002
  var FirestoreSink = class {
@@ -986,6 +1046,22 @@ var FirestoreSink = class {
986
1046
  ]);
987
1047
  }
988
1048
  }
1049
+ async recomputeTimelines(buckets) {
1050
+ const now = Date.now();
1051
+ for (const [provider, days] of timelineDaysByProvider(buckets, now)) {
1052
+ const recomputed = [];
1053
+ for (const day of days) {
1054
+ recomputed.push(...buildTimelineBuckets(await this.turnsForBucket(provider, day)));
1055
+ }
1056
+ const name = this.fs.docName("users", this.uid, "timelines", provider);
1057
+ const existingDoc = await this.fs.getDoc(name);
1058
+ const existing = existingDoc?.["buckets"] ?? [];
1059
+ const merged = mergeTimeline(existing, recomputed, days, now);
1060
+ await this.fs.upsert([
1061
+ { name, fields: { provider, buckets: merged, updatedAt: new Date(now).toISOString() } }
1062
+ ]);
1063
+ }
1064
+ }
989
1065
  async touchDevice(meta) {
990
1066
  await this.fs.upsert([
991
1067
  { name: this.fs.docName("users", this.uid, "devices", meta.deviceId), fields: { ...meta } }
@@ -1014,6 +1090,7 @@ async function runSync(connectors, sink, state, device) {
1014
1090
  const { sessionIds, dayBuckets } = affectedKeys(turns);
1015
1091
  await sink.recomputeSessions(sessionIds);
1016
1092
  await sink.recomputeRollups(dayBuckets);
1093
+ await sink.recomputeTimelines(dayBuckets);
1017
1094
  }
1018
1095
  await sink.touchDevice({ ...device, lastSyncAt: (/* @__PURE__ */ new Date()).toISOString() });
1019
1096
  return { newTurns: turns.length, byProvider, state: st };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokelytics",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
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",