@vimoxshah/tokenflow 1.1.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 (101) hide show
  1. package/CONTRIBUTING.md +84 -0
  2. package/LICENSE +21 -0
  3. package/README.md +250 -0
  4. package/Refresh & Open Dashboard.command +22 -0
  5. package/SECURITY.md +42 -0
  6. package/bin/tokenflow.js +1342 -0
  7. package/docs/architecture.md +193 -0
  8. package/docs/cli.md +390 -0
  9. package/docs/configuration.md +281 -0
  10. package/docs/creating-provider.md +262 -0
  11. package/docs/data-model.md +213 -0
  12. package/docs/getting-started.md +266 -0
  13. package/docs/live-mode.md +199 -0
  14. package/docs/media/architecture-hero.svg +86 -0
  15. package/docs/media/cost-editorial-dark.png +0 -0
  16. package/docs/media/health-terminal-light.png +0 -0
  17. package/docs/media/menubar-dark.png +0 -0
  18. package/docs/media/menubar-light.png +0 -0
  19. package/docs/media/models-terminal-dark.png +0 -0
  20. package/docs/media/overview-aurora-dark.png +0 -0
  21. package/docs/media/time-aurora-light.png +0 -0
  22. package/docs/providers.md +309 -0
  23. package/docs/skill.md +64 -0
  24. package/docs/troubleshooting.md +207 -0
  25. package/examples/config.example.yaml +92 -0
  26. package/examples/demo-data/README.md +38 -0
  27. package/examples/demo-data/sample-usage.csv +11 -0
  28. package/package.json +74 -0
  29. package/scripts/build-dmg.sh +33 -0
  30. package/scripts/build-menubar-app.sh +67 -0
  31. package/scripts/lint.js +111 -0
  32. package/scripts/validate-install.js +140 -0
  33. package/skills/tokenflow/SKILL.md +392 -0
  34. package/skills/tokenflow/examples/config.yaml +92 -0
  35. package/skills/tokenflow/examples/generic-mapping.json +26 -0
  36. package/skills/tokenflow/examples/session-transcript.md +191 -0
  37. package/skills/tokenflow/providers/adapter-template.js +135 -0
  38. package/skills/tokenflow/providers/detection-matrix.md +142 -0
  39. package/skills/tokenflow/schemas/config.schema.json +107 -0
  40. package/skills/tokenflow/schemas/normalized-record.json +63 -0
  41. package/src/analytics/aggregate.js +247 -0
  42. package/src/analytics/anomalies.js +222 -0
  43. package/src/analytics/capacity.js +278 -0
  44. package/src/analytics/comparison.js +96 -0
  45. package/src/analytics/dimensions.js +230 -0
  46. package/src/analytics/efficiency.js +138 -0
  47. package/src/analytics/forecast.js +202 -0
  48. package/src/analytics/index.js +327 -0
  49. package/src/analytics/insights.js +283 -0
  50. package/src/analytics/milestones.js +91 -0
  51. package/src/analytics/peak.js +106 -0
  52. package/src/analytics/productivity.js +166 -0
  53. package/src/analytics/token-usage.js +267 -0
  54. package/src/commands/diagnostics.js +88 -0
  55. package/src/commands/digest.js +155 -0
  56. package/src/commands/models-compare.js +96 -0
  57. package/src/core/budget.js +142 -0
  58. package/src/core/bundle.js +191 -0
  59. package/src/core/config.js +202 -0
  60. package/src/core/delivery.js +109 -0
  61. package/src/core/geo.js +99 -0
  62. package/src/core/ingest.js +457 -0
  63. package/src/core/interface-map.js +55 -0
  64. package/src/core/jsonl.js +124 -0
  65. package/src/core/live-status.js +417 -0
  66. package/src/core/model-map.js +157 -0
  67. package/src/core/notify.js +83 -0
  68. package/src/core/pricing.js +288 -0
  69. package/src/core/prompt-analytics.js +127 -0
  70. package/src/core/registry.js +107 -0
  71. package/src/core/restore.js +261 -0
  72. package/src/core/schedule.js +120 -0
  73. package/src/core/schema.js +316 -0
  74. package/src/core/sqlite.js +96 -0
  75. package/src/core/store.js +493 -0
  76. package/src/core/sync.js +151 -0
  77. package/src/core/units.js +147 -0
  78. package/src/core/validate.js +123 -0
  79. package/src/core/watch.js +287 -0
  80. package/src/core/yaml.js +209 -0
  81. package/src/export/bundler.js +107 -0
  82. package/src/export/csv.js +100 -0
  83. package/src/export/html-snapshot.js +101 -0
  84. package/src/export/menubar.js +158 -0
  85. package/src/index.js +18 -0
  86. package/src/providers/anthropic/index.js +294 -0
  87. package/src/providers/cline/index.js +120 -0
  88. package/src/providers/cursor/index.js +143 -0
  89. package/src/providers/generic/index.js +268 -0
  90. package/src/providers/git/index.js +188 -0
  91. package/src/providers/headroom/index.js +114 -0
  92. package/src/providers/hermes/index.js +299 -0
  93. package/src/providers/mock/index.js +117 -0
  94. package/src/providers/openai/index.js +370 -0
  95. package/src/providers/opencode/index.js +245 -0
  96. package/src/sdk.js +46 -0
  97. package/src/server/server.js +264 -0
  98. package/src/ui/app.js +2473 -0
  99. package/src/ui/charts.js +925 -0
  100. package/src/ui/index.html +42 -0
  101. package/src/ui/styles.css +644 -0
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Capacity planning over user-declared limits.
3
+ *
4
+ * The engine never invents vendor quota data — no screen here claims to know
5
+ * what Anthropic or OpenAI think your remaining balance is. Instead this module
6
+ * answers the question with data TokenFlow actually has: a limit you declared
7
+ * in config, evaluated against the measured consumption in the store.
8
+ *
9
+ * ```yaml
10
+ * limits:
11
+ * - id: anthropic-monthly
12
+ * provider: anthropic # optional cube filters: provider | model | project
13
+ * scope: month # day | week | month
14
+ * metric: tokens # tokens | input | output | requests | cost
15
+ * cap: 120000000 # tokens (or dollars when metric: cost)
16
+ * warnAt: 0.8 # optional warn threshold, default 0.8
17
+ * ```
18
+ *
19
+ * From that it derives, per limit: consumption in the current window, percent
20
+ * used, remaining capacity, burn rate (today's hourly pace and the trailing
21
+ * 7-day average), projected exhaustion, and exactly when the window resets.
22
+ *
23
+ * Pure module: all wall-clock facts enter through arguments (`nowMs`,
24
+ * `tzOffsetMinutes`, `today`) so results are testable and identical across
25
+ * CLI, server and browser.
26
+ */
27
+ import { indexCube, filterCube, sumRows, finalize, weekStart } from './aggregate.js';
28
+
29
+ export const LIMIT_SCOPES = /** @type {const} */ ({ DAY: 'day', WEEK: 'week', MONTH: 'month' });
30
+ export const LIMIT_METRICS = /** @type {const} */ ({
31
+ TOKENS: 'tokens', INPUT: 'input', OUTPUT: 'output', REQUESTS: 'requests', COST: 'cost',
32
+ });
33
+
34
+ /** Validate one limit definition from config. @returns {{ok:boolean, errors:string[], value?:object}} */
35
+ export function normalizeLimit(def) {
36
+ const errors = [];
37
+ if (!def || typeof def !== 'object') return { ok: false, errors: ['limit must be an object'] };
38
+ const id = typeof def.id === 'string' && def.id.trim() ? def.id.trim() : null;
39
+ if (!id) errors.push('id is required');
40
+ const scope = String(def.scope || '').toLowerCase();
41
+ const knownScopes = /** @type {string[]} */ (Object.values(LIMIT_SCOPES));
42
+ if (!knownScopes.includes(scope)) {
43
+ errors.push(`scope must be one of ${knownScopes.join(' | ')}`);
44
+ }
45
+ const metric = String(def.metric || LIMIT_METRICS.TOKENS).toLowerCase();
46
+ const knownMetrics = /** @type {string[]} */ (Object.values(LIMIT_METRICS));
47
+ if (!knownMetrics.includes(metric)) {
48
+ errors.push(`metric must be one of ${knownMetrics.join(' | ')}`);
49
+ }
50
+ const cap = Number(def.cap);
51
+ if (!Number.isFinite(cap) || cap <= 0) errors.push('cap must be a positive number');
52
+ let warnAt = def.warnAt === undefined || def.warnAt === null ? 0.8 : Number(def.warnAt);
53
+ if (!Number.isFinite(warnAt) || warnAt <= 0 || warnAt > 1) errors.push('warnAt must be a fraction between 0 and 1');
54
+ for (const k of ['provider', 'model', 'project']) {
55
+ if (def[k] !== undefined && def[k] !== null && typeof def[k] !== 'string') {
56
+ errors.push(`${k} must be a string`);
57
+ }
58
+ }
59
+ if (errors.length) return { ok: false, errors };
60
+ return {
61
+ ok: true,
62
+ errors: [],
63
+ value: {
64
+ id,
65
+ label: typeof def.label === 'string' && def.label.trim() ? def.label.trim() : id,
66
+ scope,
67
+ metric,
68
+ cap,
69
+ warnAt,
70
+ provider: def.provider ?? null,
71
+ model: def.model ?? null,
72
+ project: def.project ?? null,
73
+ },
74
+ };
75
+ }
76
+
77
+ /** Validate a whole limits array. Used by config import + the settings dialog. */
78
+ export function normalizeLimits(list) {
79
+ const out = [];
80
+ const invalid = [];
81
+ if (!Array.isArray(list)) return { limits: out, invalid };
82
+ const seen = new Set();
83
+ list.forEach((def, i) => {
84
+ const v = normalizeLimit(def);
85
+ if (!v.ok) { invalid.push({ index: i, id: def?.id ?? null, errors: v.errors }); return; }
86
+ if (seen.has(v.value.id)) {
87
+ invalid.push({ index: i, id: v.value.id, errors: ['duplicate id'] });
88
+ return;
89
+ }
90
+ seen.add(v.value.id);
91
+ out.push(v.value);
92
+ });
93
+ return { limits: out, invalid };
94
+ }
95
+
96
+ /**
97
+ * Local midnight of `iso` expressed as a UTC epoch ms, given the timezone's
98
+ * offset in minutes east of UTC. This is the anchor for every reset countdown:
99
+ * a window ends when the *user's* calendar rolls over, not the machine's.
100
+ */
101
+ export function localMidnightMs(iso, tzOffsetMinutes) {
102
+ const [y, m, d] = iso.split('-').map(Number);
103
+ return Date.UTC(y, m - 1, d, 0, 0, 0) - tzOffsetMinutes * 60000;
104
+ }
105
+
106
+ function addDaysISO(iso, n) {
107
+ const [y, m, d] = iso.split('-').map(Number);
108
+ const dt = new Date(Date.UTC(y, m - 1, d + n));
109
+ return `${dt.getUTCFullYear()}-${String(dt.getUTCMonth() + 1).padStart(2, '0')}-${String(dt.getUTCDate()).padStart(2, '0')}`;
110
+ }
111
+
112
+ /** @param {string} scope @param {string} todayIso */
113
+ export function windowFor(scope, todayIso) {
114
+ switch (scope) {
115
+ case LIMIT_SCOPES.DAY: return { from: todayIso, to: todayIso };
116
+ case LIMIT_SCOPES.WEEK: return { from: weekStart(todayIso), to: todayIso };
117
+ case LIMIT_SCOPES.MONTH: return { from: `${todayIso.slice(0, 7)}-01`, to: todayIso };
118
+ default: throw new Error(`unknown scope ${scope}`);
119
+ }
120
+ }
121
+
122
+ /** When the current window ends, as UTC ms + a human interval. */
123
+ export function resetFor(scope, todayIso, tzOffsetMinutes, nowMs) { const midnight = localMidnightMs(todayIso, tzOffsetMinutes);
124
+ let end;
125
+ if (scope === LIMIT_SCOPES.DAY) {
126
+ end = localMidnightMs(addDaysISO(todayIso, 1), tzOffsetMinutes);
127
+ } else if (scope === LIMIT_SCOPES.WEEK) {
128
+ end = localMidnightMs(addDaysISO(weekStart(todayIso), 7), tzOffsetMinutes);
129
+ } else {
130
+ const nextMonth = addDaysISO(`${todayIso.slice(0, 7)}-01`, 32).slice(0, 7) + '-01';
131
+ end = localMidnightMs(nextMonth, tzOffsetMinutes);
132
+ }
133
+ return { atMs: end, inMs: Math.max(0, end - nowMs), midnightMs: midnight };
134
+ }
135
+
136
+ /** @param {object} m @param {string} metric */
137
+ function usedFromMeasures(m, metric) {
138
+ switch (metric) {
139
+ case LIMIT_METRICS.INPUT: return { used: m.in, unit: 'tokens' };
140
+ case LIMIT_METRICS.OUTPUT: return { used: m.out, unit: 'tokens' };
141
+ case LIMIT_METRICS.REQUESTS: return { used: m.req, unit: 'requests' };
142
+ case LIMIT_METRICS.COST: return { used: m.cost, unit: 'usd' };
143
+ case LIMIT_METRICS.TOKENS:
144
+ default: return { used: m.total, unit: 'tokens' };
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Evaluate every configured limit against the dataset.
150
+ *
151
+ * @param {object[]} rawLimits limit definitions straight from config
152
+ * @param {{
153
+ * cube: object, today: string, nowMs?: number, tzOffsetMinutes?: number,
154
+ * coverageFrom?: string | null,
155
+ * }} p
156
+ * `coverageFrom` (optional, YYYY-MM-DD) trims the trailing-burn denominator
157
+ * when the dataset is younger than the 7-day window, so a three-day-old
158
+ * store does not understate its own pace.
159
+ */
160
+ export function evaluateLimits(rawLimits, p) {
161
+ const { limits, invalid } = normalizeLimits(rawLimits);
162
+ const nowMs = p.nowMs ?? Date.now();
163
+ const tzOff = p.tzOffsetMinutes ?? 0;
164
+ const states = [];
165
+ if (!limits.length) return { states, invalid };
166
+
167
+ const ix = indexCube(p.cube);
168
+ const trailingTo = addDaysISO(p.today, -1);
169
+ const trailingFrom = addDaysISO(p.today, -7);
170
+ // Quota pacing does not pause on weekends, so zeros count — but a store with
171
+ // less history than the window must not dilute its own burn rate with days
172
+ // it could not possibly have measured.
173
+ const effectiveFrom = p.coverageFrom && p.coverageFrom > trailingFrom ? p.coverageFrom : trailingFrom;
174
+ const basisDays = Math.max(1, daysInclusive(effectiveFrom, trailingTo));
175
+
176
+ for (const lim of limits) {
177
+ const scopeFilters = {
178
+ provider: lim.provider ? [lim.provider] : null,
179
+ model: lim.model ? [lim.model] : null,
180
+ project: lim.project ? [lim.project] : null,
181
+ };
182
+ const win = windowFor(lim.scope, p.today);
183
+ const rows = filterCube(ix, {
184
+ from: win.from, to: win.to, includeOverlay: false, ...scopeFilters,
185
+ });
186
+ const m = finalize(sumRows(rows, ix));
187
+ const { used, unit } = usedFromMeasures(m, lim.metric);
188
+
189
+ const pctUsed = lim.cap > 0 ? used / lim.cap : null;
190
+ let status = 'unknown';
191
+ if (pctUsed !== null) status = pctUsed >= 1 ? 'exceeded' : pctUsed >= lim.warnAt ? 'warn' : 'ok';
192
+
193
+ // Today's slice for the hourly burn (same filters, single day).
194
+ const todayRows = filterCube(ix, {
195
+ from: p.today, to: p.today, includeOverlay: false, ...scopeFilters,
196
+ });
197
+ const tm = finalize(sumRows(todayRows, ix));
198
+ const todayUsed = usedFromMeasures(tm, lim.metric).used;
199
+
200
+ const localMinutes = ((nowMs / 60000 + tzOff) % 1440 + 1440) % 1440;
201
+ const hoursElapsed = Math.max(localMinutes / 60, 0.25); // floor avoids a midnight divide-by-zero
202
+ const burnPerHour = todayUsed > 0 ? todayUsed / hoursElapsed : 0;
203
+
204
+ const trailRows = filterCube(ix, {
205
+ from: effectiveFrom, to: trailingTo, includeOverlay: false, ...scopeFilters,
206
+ });
207
+ const trailM = finalize(sumRows(trailRows, ix));
208
+ const burnPerDay = usedFromMeasures(trailM, lim.metric).used / basisDays;
209
+
210
+ const remaining = Math.max(0, lim.cap - used);
211
+ const eta = exhaustionFor(remaining, burnPerHour, burnPerDay);
212
+ const reset = resetFor(lim.scope, p.today, tzOff, nowMs);
213
+
214
+ states.push({
215
+ ...lim,
216
+ unit,
217
+ window: win,
218
+ used,
219
+ remaining,
220
+ pctUsed,
221
+ status,
222
+ requests: m.req,
223
+ ...(lim.metric === LIMIT_METRICS.COST
224
+ ? { priceCoverage: m.req ? m.costReq / m.req : null }
225
+ : {}),
226
+ burn: {
227
+ perHourToday: burnPerHour || null,
228
+ perDayTrailing: burnPerDay || null,
229
+ basisDays,
230
+ },
231
+ etaHours: eta ? eta.hours : null,
232
+ etaVia: eta ? eta.via : null,
233
+ resetsAtMs: reset.atMs,
234
+ resetsInMs: reset.inMs,
235
+ });
236
+ }
237
+
238
+ states.sort((a, b) => urgency(b) - urgency(a));
239
+ return { states, invalid };
240
+ }
241
+
242
+ function daysInclusive(from, to) {
243
+ const [y1, m1, d1] = from.split('-').map(Number);
244
+ const [y2, m2, d2] = to.split('-').map(Number);
245
+ return Math.round((Date.UTC(y2, m2 - 1, d2) - Date.UTC(y1, m1 - 1, d1)) / 86400000) + 1;
246
+ }
247
+
248
+ function exhaustionFor(remaining, burnPerHour, burnPerDay) {
249
+ if (!(remaining > 0)) return null;
250
+ if (burnPerHour > 0 && Number.isFinite(burnPerHour)) return { hours: remaining / burnPerHour, via: 'hourly' };
251
+ if (burnPerDay !== null && burnPerDay > 0) return { hours: (remaining / burnPerDay) * 24, via: 'daily' };
252
+ return null;
253
+ }
254
+
255
+ function urgency(s) {
256
+ if (s.status === 'exceeded') return 1000;
257
+ const base = (s.pctUsed ?? 0) * 100;
258
+ // A limit that will be crossed before its reset outranks one that will not.
259
+ const beforeReset = s.etaHours !== null && s.resetsInMs > 0 && s.etaHours * 3600000 <= s.resetsInMs ? 200 : 0;
260
+ return base + beforeReset;
261
+ }
262
+
263
+ /** Cross-limit rollup for headers and menu bars. */
264
+ export function summarizeCapacity(states) {
265
+ if (!states.length) return { anyExceeded: false, anyWarn: false, worst: null, firstToHit: null };
266
+ const exceeded = states.filter((s) => s.status === 'exceeded');
267
+ const warned = states.filter((s) => s.status === 'warn');
268
+ const firstToHit = states
269
+ .filter((s) => s.status !== 'exceeded' && s.etaHours !== null && s.resetsInMs > 0 && s.etaHours * 3600000 <= s.resetsInMs)
270
+ .sort((a, b) => a.etaHours - b.etaHours)[0] || null;
271
+ return {
272
+ anyExceeded: exceeded.length > 0,
273
+ anyWarn: warned.length > 0,
274
+ worst: states[0],
275
+ firstToHit,
276
+ counts: { total: states.length, exceeded: exceeded.length, warn: warned.length },
277
+ };
278
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Comparison mode — two arbitrary periods, side by side.
3
+ */
4
+ import { filterCube, filterSessions, sumRows, groupRows, daysBetween, addDays, rank } from './aggregate.js';
5
+ import { interfaceClass } from '../core/schema.js';
6
+
7
+ /**
8
+ * @param {object} ix
9
+ * @param {any[]} sessions
10
+ * @param {object} filters base filters (date fields are overridden)
11
+ * @param {{from:string,to:string}} a
12
+ * @param {{from:string,to:string}} b
13
+ */
14
+ export function calculatePeriodComparison(ix, sessions, filters, a, b) {
15
+ const side = (p) => {
16
+ const rows = filterCube(ix, { ...filters, from: p.from, to: p.to });
17
+ const m = sumRows(rows, ix);
18
+ const sess = filterSessions(sessions, { ...filters, from: p.from, to: p.to });
19
+ const days = new Set(rows.map((r) => r[ix.d.d]));
20
+ const cli = rows.reduce((acc, r) => {
21
+ const c = interfaceClass(r[ix.d.i]);
22
+ const t = r[ix.m.in] + r[ix.m.out] + r[ix.m.cr] + r[ix.m.cw];
23
+ acc.total += t;
24
+ if (c === 'CLI / headless') acc.cli += t;
25
+ return acc;
26
+ }, { cli: 0, total: 0 });
27
+ const byDay = rank(rows, ix, (r) => r[ix.d.d], { limit: 1 });
28
+ return {
29
+ period: p,
30
+ calendarDays: daysBetween(p.from, p.to) + 1,
31
+ activeDays: days.size,
32
+ total: m.total,
33
+ input: m.in,
34
+ output: m.out,
35
+ cacheRead: m.cr,
36
+ cacheWrite: m.cw,
37
+ cache: m.cr + m.cw,
38
+ reasoning: m.rs,
39
+ requests: m.req,
40
+ sessions: sess.length,
41
+ cost: m.costReq > 0 ? m.cost : null,
42
+ avgPerActiveDay: days.size ? m.total / days.size : null,
43
+ avgPerSession: sess.length ? m.total / sess.length : null,
44
+ providers: new Set(rows.map((r) => r[ix.d.p])).size,
45
+ models: new Set(rows.map((r) => r[ix.d.m])).size,
46
+ cliShare: cli.total ? cli.cli / cli.total : null,
47
+ peak: byDay[0] ? { date: byDay[0].key, total: byDay[0].m.total } : null,
48
+ _rows: rows,
49
+ };
50
+ };
51
+
52
+ const A = side(a);
53
+ const B = side(b);
54
+ const METRICS = [
55
+ ['total', 'Total usage'], ['input', 'Input'], ['output', 'Output'],
56
+ ['cache', 'Cache'], ['requests', 'Requests'], ['sessions', 'Sessions'],
57
+ ['avgPerActiveDay', 'Avg / active day'], ['avgPerSession', 'Avg / session'],
58
+ ['activeDays', 'Active days'], ['providers', 'Providers'], ['models', 'Models'],
59
+ ['cliShare', 'CLI share'], ['cost', 'Estimated cost'],
60
+ ];
61
+ const deltas = METRICS.map(([k, label]) => {
62
+ const av = A[k];
63
+ const bv = B[k];
64
+ const change = av === null || bv === null || av === 0 ? null : (bv - av) / av;
65
+ return { key: k, label, a: av, b: bv, change, kind: k === 'cliShare' ? 'share' : k === 'cost' ? 'cost' : 'count' };
66
+ });
67
+
68
+ // Which providers/models moved — the "what changed" part of a comparison.
69
+ const shift = (dim) => {
70
+ const ga = groupRows(A._rows, ix, (r) => r[ix.d[dim]]);
71
+ const gb = groupRows(B._rows, ix, (r) => r[ix.d[dim]]);
72
+ const keys = new Set([...ga.keys(), ...gb.keys()]);
73
+ const rows = [...keys].map((k) => {
74
+ const x = ga.get(k)?.m.total ?? 0;
75
+ const y = gb.get(k)?.m.total ?? 0;
76
+ return { key: k, a: x, b: y, change: x ? (y - x) / x : null, absolute: y - x };
77
+ });
78
+ rows.sort((p, q) => Math.abs(q.absolute) - Math.abs(p.absolute));
79
+ return rows;
80
+ };
81
+
82
+ // Compute the shifts BEFORE dropping the row references they read.
83
+ const providerShift = shift('p');
84
+ const modelShift = shift('m');
85
+ const interfaceShift = shift('i');
86
+ delete A._rows;
87
+ delete B._rows;
88
+ return { a: A, b: B, deltas, providerShift, modelShift, interfaceShift };
89
+ }
90
+
91
+ /** The previous equally-long window immediately before `from`. */
92
+ export function previousPeriod(from, to) {
93
+ const len = daysBetween(from, to) + 1;
94
+ const prevTo = addDays(from, -1);
95
+ return { from: addDays(prevTo, -(len - 1)), to: prevTo };
96
+ }
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Per-dimension analytics: provider, model, interface, project.
3
+ *
4
+ * All four share one shape so the UI can render any of them with the same
5
+ * table and chart components, and so a dimension added by a future adapter
6
+ * needs no new analytics code.
7
+ */
8
+ import { rank, groupRows, filterCube, sumRows, dateRange, daysBetween, addDays, finalize, zeroMeasures, addInto } from './aggregate.js';
9
+ import { interfaceClass } from '../core/schema.js';
10
+
11
+ /**
12
+ * @param {any[][]} rows
13
+ * @param {object} ix
14
+ * @param {string} dim cube dimension key: p | m | mf | c | i | g | pj | rp
15
+ * @param {{limit?:number, activeDays?:boolean, sessions?:any[], grandTotal?:number}} opt
16
+ */
17
+ export function calculateDimensionUsage(rows, ix, dim, opt = {}) {
18
+ const col = ix.d[dim];
19
+ const groups = rank(rows, ix, (r) => r[col], { keepRows: true });
20
+ const grand = opt.grandTotal ?? groups.reduce((a, g) => a + g.m.total, 0);
21
+ const sessionsBy = new Map();
22
+ if (opt.sessions) {
23
+ // Sessions carry the same short dimension keys as the cube.
24
+ const KNOWN = new Set(['p', 'm', 'mf', 'c', 'i', 'g', 'pj', 'rp', 'st']);
25
+ const sdim = KNOWN.has(dim) ? dim : 'rp';
26
+ for (const s of opt.sessions) {
27
+ const k = s[sdim];
28
+ const e = sessionsBy.get(k) || { n: 0, tokens: 0, longMs: 0 };
29
+ e.n++;
30
+ e.tokens += s.total || 0;
31
+ e.longMs += s.durationMs || 0;
32
+ sessionsBy.set(k, e);
33
+ }
34
+ }
35
+
36
+ const out = groups.map((g) => {
37
+ const days = new Set();
38
+ let peakDay = null;
39
+ let peakTotal = -1;
40
+ const byDay = new Map();
41
+ for (const r of g.rows) {
42
+ const d = r[ix.d.d];
43
+ days.add(d);
44
+ const t = r[ix.m.in] + r[ix.m.out] + r[ix.m.cr] + r[ix.m.cw];
45
+ byDay.set(d, (byDay.get(d) || 0) + t);
46
+ }
47
+ for (const [d, t] of byDay) if (t > peakTotal) { peakTotal = t; peakDay = d; }
48
+ const sess = sessionsBy.get(g.key) || null;
49
+ return {
50
+ key: g.key,
51
+ // The dominant provider for this dimension value — needed to resolve a
52
+ // vendor-specific cache multiplier when pricing a model.
53
+ provider: g.rows.length ? g.rows[0][ix.d.p] : null,
54
+ total: g.m.total,
55
+ input: g.m.in,
56
+ output: g.m.out,
57
+ cacheRead: g.m.cr,
58
+ cacheWrite: g.m.cw,
59
+ cache: g.m.cr + g.m.cw,
60
+ cacheRefresh: g.m.cf,
61
+ reasoning: g.m.rs,
62
+ requests: g.m.req,
63
+ cost: g.m.costReq > 0 ? g.m.cost : null,
64
+ costMeasured: g.m.costMeasured || null,
65
+ costCoveredRequests: g.m.costReq,
66
+ activeDays: days.size,
67
+ avgPerActiveDay: days.size ? g.m.total / days.size : null,
68
+ avgPerRequest: g.m.req ? g.m.total / g.m.req : null,
69
+ peakDay,
70
+ peakDayTotal: peakTotal < 0 ? null : peakTotal,
71
+ sessions: sess ? sess.n : null,
72
+ avgPerSession: sess && sess.n ? g.m.total / sess.n : null,
73
+ share: grand ? g.m.total / grand : null,
74
+ missing: { input: g.m.naIn, output: g.m.naOut, cacheRead: g.m.naCr, cacheWrite: g.m.naCw },
75
+ };
76
+ });
77
+ return opt.limit ? out.slice(0, opt.limit) : out;
78
+ }
79
+
80
+ export const calculateProviderUsage = (rows, ix, opt) => calculateDimensionUsage(rows, ix, 'p', opt);
81
+ export const calculateModelUsage = (rows, ix, opt) => calculateDimensionUsage(rows, ix, 'm', opt);
82
+ export const calculateInterfaceUsage = (rows, ix, opt) => calculateDimensionUsage(rows, ix, 'i', opt);
83
+ export const calculateProjectUsage = (rows, ix, opt) => calculateDimensionUsage(rows, ix, 'pj', opt);
84
+
85
+ /**
86
+ * Stacked series per dimension value: one row per bucket, one column per
87
+ * top-N key, with everything else folded into "Other" — never a 9th generated
88
+ * colour.
89
+ */
90
+ export function calculateDimensionSeries(rows, ix, dim, buckets, opt = {}) {
91
+ const topN = opt.topN ?? 6;
92
+ const keyFn = opt.bucketOf || ((d) => d);
93
+ const metric = opt.metric || 'tokens'; // tokens | requests | cost
94
+ const top = rank(rows, ix, (r) => r[ix.d[dim]], { limit: topN }).map((g) => g.key);
95
+ const topSet = new Set(top);
96
+ const byBucket = new Map();
97
+ const measureOf = (r) => {
98
+ if (metric === 'requests') return r[ix.m.req];
99
+ if (metric === 'cost') return r[ix.m.costReq] > 0 ? (r[ix.m.cost] || 0) : 0;
100
+ return r[ix.m.in] + r[ix.m.out] + r[ix.m.cr] + r[ix.m.cw];
101
+ };
102
+ for (const r of rows) {
103
+ const b = keyFn(r[ix.d.d]);
104
+ let row = byBucket.get(b);
105
+ if (!row) {
106
+ row = { key: b };
107
+ for (const k of top) row[k] = 0;
108
+ row.Other = 0;
109
+ byBucket.set(b, row);
110
+ }
111
+ const k = r[ix.d[dim]];
112
+ // Cost is only counted where it was actually computed from a rate; a
113
+ // request with no price contributes to the unpriced remainder, not zero.
114
+ if (topSet.has(k)) row[k] += measureOf(r);
115
+ else row.Other += measureOf(r);
116
+ }
117
+ const hasOther = [...byBucket.values()].some((r) => r.Other > 0);
118
+ const keys = hasOther ? [...top, 'Other'] : top;
119
+ const series = (buckets || [...byBucket.keys()].sort()).map((b) => {
120
+ const row = byBucket.get(b);
121
+ if (row) return row;
122
+ const empty = { key: b };
123
+ for (const k of keys) empty[k] = 0;
124
+ return empty;
125
+ });
126
+ return { keys, series };
127
+ }
128
+
129
+ /**
130
+ * Period-over-period growth per dimension value. The comparison window is the
131
+ * same length as the current one, immediately preceding it.
132
+ */
133
+ export function calculateDimensionGrowth(ix, dim, filters, { from, to }) {
134
+ // An empty store has no coverage; growth against nothing is nothing.
135
+ if (!from || !to) {
136
+ return { window: { from: null, to: null }, previousWindow: { from: null, to: null }, rows: [] };
137
+ }
138
+ const days = daysBetween(from, to) + 1;
139
+ const prevTo = addDays(from, -1);
140
+ const prevFrom = addDays(prevTo, -(days - 1));
141
+ const cur = filterCube(ix, { ...filters, from, to });
142
+ const prev = filterCube(ix, { ...filters, from: prevFrom, to: prevTo });
143
+ const curBy = groupRows(cur, ix, (r) => r[ix.d[dim]]);
144
+ const prevBy = groupRows(prev, ix, (r) => r[ix.d[dim]]);
145
+ const keys = new Set([...curBy.keys(), ...prevBy.keys()]);
146
+ const out = [];
147
+ for (const k of keys) {
148
+ const c = curBy.get(k)?.m.total ?? 0;
149
+ const p = prevBy.get(k)?.m.total ?? 0;
150
+ out.push({
151
+ key: k,
152
+ current: c,
153
+ previous: p,
154
+ change: p > 0 ? (c - p) / p : null,
155
+ absolute: c - p,
156
+ status: p === 0 ? (c > 0 ? 'new' : 'none') : c === 0 ? 'stopped' : c > p ? 'up' : c < p ? 'down' : 'flat',
157
+ });
158
+ }
159
+ out.sort((a, b) => b.current - a.current);
160
+ return { window: { from, to }, previousWindow: { from: prevFrom, to: prevTo }, rows: out };
161
+ }
162
+
163
+ /**
164
+ * Model efficiency scatter: tokens per session (x) against sessions per active
165
+ * day (y), bubble = total tokens. Grouped by provider, which is why the caller
166
+ * caps the colour count — a scatter needs all-pairs colour separation.
167
+ */
168
+ export function calculateModelEfficiency(rows, ix, sessions, { limit = 24 } = {}) {
169
+ const byModel = rank(rows, ix, (r) => r[ix.d.m], { keepRows: true, limit });
170
+ const sessByModel = new Map();
171
+ for (const s of sessions) {
172
+ const e = sessByModel.get(s.m) || { n: 0, days: new Set(), tokens: 0, durations: [] };
173
+ e.n++;
174
+ e.days.add(s.d);
175
+ e.tokens += s.total || 0;
176
+ if (s.durationMs) e.durations.push(s.durationMs);
177
+ sessByModel.set(s.m, e);
178
+ }
179
+ return byModel.map((g) => {
180
+ const s = sessByModel.get(g.key);
181
+ const days = new Set(g.rows.map((r) => r[ix.d.d]));
182
+ const provider = g.rows.length ? g.rows[0][ix.d.p] : 'unknown';
183
+ return {
184
+ model: g.key,
185
+ provider,
186
+ family: g.rows.length ? g.rows[0][ix.d.mf] : 'Unknown',
187
+ total: g.m.total,
188
+ requests: g.m.req,
189
+ sessions: s ? s.n : null,
190
+ activeDays: days.size,
191
+ tokensPerSession: s && s.n ? g.m.total / s.n : null,
192
+ sessionsPerDay: s && s.days.size ? s.n / s.days.size : null,
193
+ tokensPerRequest: g.m.req ? g.m.total / g.m.req : null,
194
+ outputPerRequest: g.m.req ? g.m.out / g.m.req : null,
195
+ medianSessionMs: s && s.durations.length ? median(s.durations) : null,
196
+ };
197
+ });
198
+ }
199
+
200
+ /** CLI vs GUI over time — the "how is my tooling shifting" series. */
201
+ export function calculateInterfaceTrend(rows, ix, buckets, bucketOf = (d) => d) {
202
+ const classes = new Map();
203
+ for (const r of rows) {
204
+ const b = bucketOf(r[ix.d.d]);
205
+ const cls = interfaceClass(r[ix.d.i]);
206
+ let row = classes.get(b);
207
+ if (!row) { row = { key: b }; classes.set(b, row); }
208
+ row[cls] = (row[cls] || 0) + r[ix.m.in] + r[ix.m.out] + r[ix.m.cr] + r[ix.m.cw];
209
+ }
210
+ const keys = [...new Set([...classes.values()].flatMap((r) => Object.keys(r).filter((k) => k !== 'key')))];
211
+ const series = (buckets || [...classes.keys()].sort()).map((b) => {
212
+ const row = classes.get(b) || { key: b };
213
+ for (const k of keys) if (row[k] === undefined) row[k] = 0;
214
+ return row;
215
+ });
216
+ // Share-of-total per bucket, which is what makes a shift legible.
217
+ const shares = series.map((row) => {
218
+ const t = keys.reduce((a, k) => a + row[k], 0);
219
+ const o = { key: row.key };
220
+ for (const k of keys) o[k] = t ? row[k] / t : 0;
221
+ return o;
222
+ });
223
+ return { keys, series, shares };
224
+ }
225
+
226
+ function median(xs) {
227
+ const a = [...xs].sort((x, y) => x - y);
228
+ const m = Math.floor(a.length / 2);
229
+ return a.length % 2 ? a[m] : (a[m - 1] + a[m]) / 2;
230
+ }