@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,138 @@
1
+ /**
2
+ * Efficiency + cost.
3
+ *
4
+ * "Efficiency" here means observable ratios, nothing more. A high
5
+ * output/input ratio is not automatically good and a low one is not
6
+ * automatically bad — the UI presents these as measurements with an
7
+ * explanation, not as a score.
8
+ */
9
+
10
+ export function calculateEfficiency(totals, { sessions = 0, activeDays = 0, requests = null } = {}) {
11
+ const req = requests ?? totals.req;
12
+ const t = totals.total;
13
+ return {
14
+ outputPerInput: totals.in ? totals.out / totals.in : null,
15
+ outputPerPromptToken: (totals.in + totals.cr + totals.cw) ? totals.out / (totals.in + totals.cr + totals.cw) : null,
16
+ cacheRatio: t ? (totals.cr + totals.cw) / t : null,
17
+ cacheHitRate: totals.in + totals.cr ? totals.cr / (totals.in + totals.cr) : null,
18
+ // How many prompt tokens were re-sent fresh for every one served from
19
+ // cache. Above 1 means the cache is mostly missing.
20
+ freshPerCachedPrompt: totals.cr ? totals.in / totals.cr : null,
21
+ tokensPerSession: sessions ? t / sessions : null,
22
+ outputPerSession: sessions ? totals.out / sessions : null,
23
+ requestsPerSession: sessions ? req / sessions : null,
24
+ tokensPerActiveDay: activeDays ? t / activeDays : null,
25
+ tokensPerRequest: req ? t / req : null,
26
+ outputPerRequest: req ? totals.out / req : null,
27
+ reasoningShareOfOutput: totals.out ? totals.rs / totals.out : null,
28
+ refreshShareOfCacheWrite: totals.cw ? totals.cf / totals.cw : null,
29
+ };
30
+ }
31
+
32
+ /**
33
+ * Cost analysis with explicit coverage reporting.
34
+ *
35
+ * `coverage` is the fraction of requests in the slice whose model had a price.
36
+ * Presenting a total cost without it would imply a completeness that isn't
37
+ * there, so the UI always shows them together and lists the unpriced models
38
+ * by volume so the gap is actionable rather than mysterious.
39
+ */
40
+ export function calculateCost(totals, { sessions = 0, activeDays = 0, unpriced = [], tiers = [], premiumTiers = [], overlayMeasured = null, overlayRequests = 0 } = {}) {
41
+ const covered = totals.costReq || 0;
42
+ const coverage = totals.req ? covered / totals.req : null;
43
+ const est = covered > 0 ? totals.cost : null;
44
+ // Measured cost comes from two places: a primary record that carried a
45
+ // billed amount, and an overlay source whose tokens are excluded from the
46
+ // totals but whose money is real. They are added together and kept strictly
47
+ // apart from the estimate — one is evidence, the other is arithmetic.
48
+ const measuredIn = totals.costMeasured > 0 ? totals.costMeasured : 0;
49
+ const measuredOverlay = overlayMeasured > 0 ? overlayMeasured : 0;
50
+ const measured = measuredIn + measuredOverlay > 0 ? measuredIn + measuredOverlay : null;
51
+ const premium = tiers.filter((t) => premiumTiers.includes(t.key));
52
+ const premiumTokens = premium.reduce((a, t) => a + t.total, 0);
53
+ return {
54
+ tiers,
55
+ premiumTierShare: totals.total ? premiumTokens / totals.total : null,
56
+ premiumTierTokens: premiumTokens,
57
+ premiumTierNames: premium.map((t) => t.key),
58
+ // Long-context premiums are a known, stated gap rather than a silent one.
59
+ underEstimateNote: 'Long-context premium tiers are not applied (they need a per-request prompt size plus a per-model threshold), so a long-context-heavy workload is under-estimated.',
60
+ estimated: est,
61
+ measured,
62
+ measuredInSlice: measuredIn || null,
63
+ measuredOverlay: measuredOverlay || null,
64
+ measuredNote: measuredOverlay
65
+ ? `Includes $${measuredOverlay.toFixed(2)} billed through a gateway across ${overlayRequests.toLocaleString()} request(s). Those tokens are excluded from the totals above so the client adapter's count is not doubled — the cost is not.`
66
+ : null,
67
+ basisNote: est === null && measured === null
68
+ ? 'No pricing configured for any model in this slice.'
69
+ : coverage !== null && coverage < 0.999
70
+ ? `Estimate covers ${(coverage * 100).toFixed(1)}% of requests; the rest have no configured price.`
71
+ : 'Estimate covers every request in this slice.',
72
+ coverage,
73
+ coveredRequests: covered,
74
+ totalRequests: totals.req,
75
+ perDay: est !== null && activeDays ? est / activeDays : null,
76
+ perSession: est !== null && sessions ? est / sessions : null,
77
+ perMillionTokens: est !== null && totals.total ? est / (totals.total / 1e6) : null,
78
+ perMillionOutput: est !== null && totals.out ? est / (totals.out / 1e6) : null,
79
+ unpriced,
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Models with no configured price, ranked by the tokens they'd account for.
85
+ * This is the list the "Configure pricing" call-to-action shows.
86
+ */
87
+ export function unpricedModels(rows, ix, priceLookup) {
88
+ const by = new Map();
89
+ for (const r of rows) {
90
+ if (r[ix.m.costReq] > 0) continue;
91
+ const model = r[ix.d.m];
92
+ const e = by.get(model) || { model, provider: r[ix.d.p], total: 0, requests: 0 };
93
+ e.total += r[ix.m.in] + r[ix.m.out] + r[ix.m.cr] + r[ix.m.cw];
94
+ e.requests += r[ix.m.req];
95
+ by.set(model, e);
96
+ }
97
+ const out = [...by.values()].filter((e) => e.requests > 0 && (!priceLookup || !priceLookup(e.model, e.provider)));
98
+ out.sort((a, b) => b.total - a.total);
99
+ return out;
100
+ }
101
+
102
+ /** Session shape distribution — the "long vs short session" analysis. */
103
+ export function calculateSessionProfile(sessions) {
104
+ if (!sessions.length) {
105
+ return { count: 0, buckets: [], medianTokens: null, medianDurationMs: null, p90Tokens: null, longSessions: 0, shortSessions: 0, highOutput: 0 };
106
+ }
107
+ const tokens = sessions.map((s) => s.total || 0).sort((a, b) => a - b);
108
+ const durs = sessions.map((s) => s.durationMs || 0).filter((x) => x > 0).sort((a, b) => a - b);
109
+ const p = (arr, q) => (arr.length ? arr[Math.min(arr.length - 1, Math.floor(arr.length * q))] : null);
110
+ const medianTokens = p(tokens, 0.5);
111
+ const p90 = p(tokens, 0.9);
112
+ // Buckets are relative to this dataset's own distribution.
113
+ const edges = [p(tokens, 0.25), medianTokens, p(tokens, 0.75), p90];
114
+ const labels = ['Smallest 25%', '25–50%', '50–75%', '75–90%', 'Top 10%'];
115
+ const counts = new Array(5).fill(0);
116
+ const totals = new Array(5).fill(0);
117
+ for (const s of sessions) {
118
+ const t = s.total || 0;
119
+ let i = 0;
120
+ while (i < edges.length && t > edges[i]) i++;
121
+ counts[i]++;
122
+ totals[i] += t;
123
+ }
124
+ const grand = tokens.reduce((a, b) => a + b, 0);
125
+ return {
126
+ count: sessions.length,
127
+ medianTokens,
128
+ p90Tokens: p90,
129
+ medianDurationMs: durs.length ? durs[Math.floor(durs.length / 2)] : null,
130
+ buckets: labels.map((label, i) => ({
131
+ label, sessions: counts[i], tokens: totals[i], share: grand ? totals[i] / grand : null,
132
+ upperEdge: i < edges.length ? edges[i] : null,
133
+ })),
134
+ longSessions: sessions.filter((s) => (s.durationMs || 0) > 30 * 60000).length,
135
+ shortSessions: sessions.filter((s) => (s.durationMs || 0) > 0 && s.durationMs < 2 * 60000).length,
136
+ highOutput: sessions.filter((s) => medianTokens && (s.out || 0) > 2 * (medianTokens * 0.15)).length,
137
+ };
138
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Forecasting.
3
+ *
4
+ * A deliberately conservative model: an ordinary least-squares trend over the
5
+ * most recent N calendar days, a robust (median/MAD) residual spread, and hard
6
+ * minimum-data gates. When there is not enough history the answer is `null`
7
+ * with the reason attached — never a confident-looking number built on three
8
+ * data points.
9
+ *
10
+ * Everything here is pure and deterministic: same series in, same forecast
11
+ * out. The browser, the CLI and the watch daemon all call these functions, so
12
+ * no surface can disagree about the future.
13
+ */
14
+ import { daysBetween } from './aggregate.js';
15
+
16
+ export const MIN_POINTS = 5;
17
+ const MAD_SCALE = 1.4826; // consistency constant for normally distributed errors
18
+
19
+ /** Ordinary least squares fit of y = intercept + slope * x over index pairs. */
20
+ function ols(ys) {
21
+ const n = ys.length;
22
+ let sx = 0;
23
+ let sy = 0;
24
+ let sxx = 0;
25
+ let sxy = 0;
26
+ for (let i = 0; i < n; i++) {
27
+ sx += i;
28
+ sy += ys[i];
29
+ sxx += i * i;
30
+ sxy += i * ys[i];
31
+ }
32
+ const denom = n * sxx - sx * sx;
33
+ if (denom === 0) return { slope: 0, intercept: sy / n };
34
+ const slope = (n * sxy - sx * sy) / denom;
35
+ return { slope, intercept: (sy - slope * sx) / n };
36
+ }
37
+
38
+ function median(sortedAsc) {
39
+ if (!sortedAsc.length) return null;
40
+ const mid = Math.floor(sortedAsc.length / 2);
41
+ return sortedAsc.length % 2 ? sortedAsc[mid] : (sortedAsc[mid - 1] + sortedAsc[mid]) / 2;
42
+ }
43
+
44
+ /** Median absolute deviation scaled to a standard-deviation estimate. */
45
+ export function robustSigma(values) {
46
+ if (values.length < 2) return null;
47
+ const med = median([...values].sort((a, b) => a - b));
48
+ const devs = values.map((v) => Math.abs(v - med));
49
+ const mad = median(devs.sort((a, b) => a - b));
50
+ return mad * MAD_SCALE;
51
+ }
52
+
53
+ /**
54
+ * Forecast the next value(s) of a daily series with a linear trend.
55
+ *
56
+ * @param {{key:string, [k:string]:any}[]} series calendar-complete daily rows
57
+ * as produced by calculateDailyUsage (must be sorted by key ascending)
58
+ * @param {{window?:number, metric?:string, horizonDays?:number}} opt
59
+ * @returns {{
60
+ * next: number|null, interval:[number,number]|null,
61
+ * horizon: {days:number, total:number}|null,
62
+ * slope:number|null, confidence:'high'|'medium'|'low'|null,
63
+ * n:number, reason?:string,
64
+ * }} nulls when the sample is too thin; `reason` says why
65
+ */
66
+ export function linearForecast(series, opt = {}) {
67
+ const metric = opt.metric || 'total';
68
+ const window = Math.max(MIN_POINTS, opt.window || 14);
69
+ const tail = series.slice(-window);
70
+ const ys = tail.map((d) => Number(d[metric])).filter((v) => Number.isFinite(v));
71
+
72
+ if (ys.length < MIN_POINTS) {
73
+ return {
74
+ next: null, interval: null, horizon: null, slope: null, confidence: null,
75
+ n: ys.length, reason: `${ys.length} usable day(s); need at least ${MIN_POINTS}`,
76
+ };
77
+ }
78
+
79
+ const { slope, intercept } = ols(ys);
80
+ const residuals = ys.map((y, i) => y - (intercept + slope * i));
81
+ const sigma = robustSigma(residuals) ?? 0;
82
+ const nextX = ys.length;
83
+ const predict = (x) => Math.max(0, intercept + slope * x);
84
+
85
+ // A robust sigma near zero means the model fits well but may still miss
86
+ // regime changes; widen slightly so "high confidence" stays honest.
87
+ const sigmaEff = Math.max(sigma, 1e-9);
88
+ const meanY = ys.reduce((a, b) => a + b, 0) / ys.length;
89
+ const cv = meanY > 0 ? sigma / meanY : Infinity;
90
+
91
+ const confidence = ys.length >= 10 && cv <= 0.35 && sigmaEff > 0
92
+ ? 'high'
93
+ : ys.length >= 7 && cv <= 0.8 ? 'medium' : 'low';
94
+
95
+ const horizonDays = opt.horizonDays || 7;
96
+ let horizonSum = 0;
97
+ for (let i = 1; i <= horizonDays; i++) horizonSum += predict(nextX + i - 1);
98
+
99
+ return {
100
+ next: predict(nextX),
101
+ interval: [Math.max(0, predict(nextX) - 1.96 * sigma), predict(nextX) + 1.96 * sigma],
102
+ horizon: { days: horizonDays, total: horizonSum },
103
+ slope,
104
+ confidence,
105
+ n: ys.length,
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Project how the current calendar month ends.
111
+ *
112
+ * Month-to-date is measured fact; the remainder is forecast. The two are never
113
+ * added silently without saying which part is which.
114
+ *
115
+ * @param {{key:string, [k:string]:any}[]} daily calendar-complete series
116
+ * @param {string} todayIso YYYY-MM-DD
117
+ * @param {{metric?:string}} opt
118
+ */
119
+ export function monthEndProjection(daily, todayIso, opt = {}) {
120
+ const metric = opt.metric || 'total';
121
+ const monthStart = `${todayIso.slice(0, 7)}-01`;
122
+ const mtd = daily.filter((d) => d.key >= monthStart && d.key <= todayIso);
123
+ const actualToDate = mtd.reduce((a, d) => a + (Number(d[metric]) || 0), 0);
124
+ const remainingDays = Math.max(
125
+ 0,
126
+ daysBetween(todayIso, lastDayOfMonth(todayIso)),
127
+ );
128
+ if (mtd.length < MIN_POINTS && remainingDays > 0) {
129
+ return {
130
+ projected: null, actualToDate, remainingDays,
131
+ forecastRemainder: null, confidence: null,
132
+ reason: `${mtd.length} day(s) this month; need at least ${MIN_POINTS} to project`,
133
+ };
134
+ }
135
+ // The remainder comes from one call so measured and forecast can never be
136
+ // mixed twice, and the projection uses exactly the model whose confidence we
137
+ // report.
138
+ const fc = linearForecast(daily.filter((d) => d.key <= todayIso), { ...opt, horizonDays: remainingDays });
139
+ const remainder = remainingDays === 0 ? 0 : fc.horizon ? fc.horizon.total : null;
140
+ return {
141
+ projected: remainder === null ? null : actualToDate + remainder,
142
+ actualToDate,
143
+ remainingDays,
144
+ forecastRemainder: remainder,
145
+ confidence: fc.confidence,
146
+ perDay: fc.next,
147
+ reason: fc.reason,
148
+ };
149
+ }
150
+
151
+ /** Last calendar day of the month containing `iso`. */
152
+ export function lastDayOfMonth(iso) {
153
+ const [y, m] = iso.split('-').map(Number);
154
+ return `${y}-${String(m).padStart(2, '0')}-${String(new Date(Date.UTC(y, m, 0)).getUTCDate()).padStart(2, '0')}`;
155
+ }
156
+
157
+ /**
158
+ * Hours until `remaining` is exhausted at a given burn rate.
159
+ *
160
+ * @param {{remaining:number, burnPerHour:number|null, burnPerDay?:number|null, nowMs:number}} p
161
+ * @returns {{hours:number|null, via:'hourly'|'daily'} | null}
162
+ * null when there is nothing to exhaust or no measurable burn.
163
+ */
164
+ export function exhaustionEta({ remaining, burnPerHour, burnPerDay = null, nowMs }) {
165
+ if (!Number.isFinite(remaining) || remaining <= 0) return null;
166
+ if (burnPerHour !== null && Number.isFinite(burnPerHour) && burnPerHour > 0) {
167
+ return { hours: remaining / burnPerHour, via: 'hourly' };
168
+ }
169
+ if (burnPerDay !== null && Number.isFinite(burnPerDay) && burnPerDay > 0) {
170
+ return { hours: (remaining / burnPerDay) * 24, via: 'daily' };
171
+ }
172
+ return null;
173
+ }
174
+
175
+ /**
176
+ * Convenience wrapper used by live status + CLI: one object describing where
177
+ * usage is heading.
178
+ *
179
+ * @param {{key:string, cost?:number|null, [k:string]:any}[]} daily
180
+ * @param {string} todayIso
181
+ */
182
+ export function buildForecast(daily, todayIso) {
183
+ const tokens = linearForecast(daily, { metric: 'total' });
184
+ const costSeries = daily.some((d) => Number.isFinite(Number(d.cost)) && Number(d.cost) > 0);
185
+ const cost = costSeries ? linearForecast(daily, { metric: 'cost' }) : null;
186
+ const monthTokens = monthEndProjection(daily, todayIso, { metric: 'total' });
187
+ const monthCost = costSeries ? monthEndProjection(daily, todayIso, { metric: 'cost' }) : null;
188
+ return {
189
+ generatedFor: todayIso,
190
+ tomorrow: tokens.next,
191
+ tomorrowInterval: tokens.interval,
192
+ next7days: tokens.horizon ? tokens.horizon.total : null,
193
+ next7daysCost: cost?.horizon ? cost.horizon.total : null,
194
+ monthEnd: monthTokens.projected,
195
+ monthEndActualToDate: monthTokens.actualToDate,
196
+ monthEndCost: monthCost?.projected ?? null,
197
+ monthEndCostActualToDate: monthCost?.actualToDate ?? null,
198
+ confidence: tokens.confidence,
199
+ n: tokens.n,
200
+ reason: tokens.reason || null,
201
+ };
202
+ }
@@ -0,0 +1,327 @@
1
+ /**
2
+ * The analytics facade + plugin registry.
3
+ *
4
+ * `computeView(bundle, filters)` is the single entry point that turns a data
5
+ * bundle plus a filter state into everything a dashboard needs. The browser
6
+ * calls it on every filter change; the CLI calls it for `status`; the snapshot
7
+ * exporter calls it once. One implementation, so the numbers can never disagree
8
+ * between surfaces.
9
+ *
10
+ * ## Plugin model
11
+ *
12
+ * Analytics modules register themselves with `registerAnalytics({id, section,
13
+ * compute})`. `compute` receives the fully prepared slice and returns whatever
14
+ * shape it likes, exposed at `view.plugins[id]`. That is how cost, git,
15
+ * carbon, team, or prompt-efficiency analytics get added later without
16
+ * touching this file.
17
+ */
18
+ import {
19
+ indexCube, filterCube, filterSessions, sumRows, facet, EMPTY_FILTERS,
20
+ weekStart, monthKey, dateRange, daysBetween, addDays,
21
+ } from './aggregate.js';
22
+ import {
23
+ calculateDailyUsage, movingAverage, calculateAverageUsage, calculateComposition,
24
+ calculateUsageTrend, calculateHourlyUsage, calculateDowUsage, calculateHourDow,
25
+ calendarLevels, calculateStreaks,
26
+ } from './token-usage.js';
27
+ import {
28
+ calculateDimensionUsage, calculateDimensionSeries, calculateDimensionGrowth,
29
+ calculateModelEfficiency, calculateInterfaceTrend,
30
+ } from './dimensions.js';
31
+ import { calculatePeakUsage, dayDetail } from './peak.js';
32
+ import { calculateEfficiency, calculateCost, unpricedModels, calculateSessionProfile } from './efficiency.js';
33
+ import { calculatePeriodComparison, previousPeriod } from './comparison.js';
34
+ import { calculateActivityProxies, calculateWorkSeries, calculateCorrelations, calculateActivityContrast } from './productivity.js';
35
+ import { generateInsights } from './insights.js';
36
+ import { buildForecast, linearForecast, monthEndProjection, exhaustionEta } from './forecast.js';
37
+ import { detectAnomalies, firstSeenEntities } from './anomalies.js';
38
+ import { evaluateLimits, summarizeCapacity, normalizeLimits } from './capacity.js';
39
+ import { buildPriceBook } from '../core/pricing.js';
40
+
41
+ const plugins = new Map();
42
+
43
+ /** @param {{id:string, section?:string, title?:string, compute:(slice:object)=>any}} def */
44
+ export function registerAnalytics(def) {
45
+ if (!def || !def.id || typeof def.compute !== 'function') {
46
+ throw new Error('registerAnalytics requires { id, compute }');
47
+ }
48
+ plugins.set(def.id, { section: 'custom', title: def.id, ...def });
49
+ return def;
50
+ }
51
+ export function listAnalytics() {
52
+ return [...plugins.values()].map((p) => ({ id: p.id, section: p.section, title: p.title }));
53
+ }
54
+ export function clearAnalytics() {
55
+ plugins.clear();
56
+ }
57
+
58
+ export const QUICK_RANGES = [
59
+ { id: 'today', label: 'Today' },
60
+ { id: 'yesterday', label: 'Yesterday' },
61
+ { id: '7d', label: 'Last 7 days' },
62
+ { id: '30d', label: 'Last 30 days' },
63
+ { id: '90d', label: 'Last 90 days' },
64
+ { id: 'mtd', label: 'This month' },
65
+ { id: 'lastmonth', label: 'Last month' },
66
+ { id: 'all', label: 'All data' },
67
+ { id: 'custom', label: 'Custom' },
68
+ ];
69
+
70
+ /**
71
+ * Resolve a quick range against the dataset's own coverage, so "last 30 days"
72
+ * means the last 30 days of the calendar, and "all data" means exactly what is
73
+ * in the store.
74
+ */
75
+ export function resolveRange(id, coverage, today) {
76
+ const to = today || coverage.to;
77
+ const from = coverage.from;
78
+ switch (id) {
79
+ case 'today': return { from: to, to };
80
+ case 'yesterday': return { from: addDays(to, -1), to: addDays(to, -1) };
81
+ case '7d': return { from: addDays(to, -6), to };
82
+ case '30d': return { from: addDays(to, -29), to };
83
+ case '90d': return { from: addDays(to, -89), to };
84
+ case 'mtd': return { from: `${to.slice(0, 7)}-01`, to };
85
+ case 'lastmonth': {
86
+ const first = `${to.slice(0, 7)}-01`;
87
+ const lastMonthEnd = addDays(first, -1);
88
+ return { from: `${lastMonthEnd.slice(0, 7)}-01`, to: lastMonthEnd };
89
+ }
90
+ case 'all':
91
+ default:
92
+ return { from, to };
93
+ }
94
+ }
95
+
96
+ /**
97
+ * @param {{cube:object, sessions:any[], activity:object, meta?:object,
98
+ * pricing?:object, limits?:object[]}} bundle
99
+ * @param {Partial<typeof EMPTY_FILTERS> & {granularity?:'day'|'week'|'month', compare?:{a:object,b:object}, drillDate?:string}} [filters]
100
+ */
101
+ export function computeView(bundle, filters = {}) {
102
+ const ix = indexCube(bundle.cube);
103
+ const f = { ...EMPTY_FILTERS, ...filters };
104
+ const coverage = bundle.meta?.coverage || datasetCoverage(ix);
105
+ const range = {
106
+ from: f.from || coverage.from,
107
+ to: f.to || coverage.to,
108
+ };
109
+ const eff = { ...f, from: range.from, to: range.to };
110
+
111
+ const rows = filterCube(ix, eff);
112
+ const sessions = filterSessions(bundle.sessions || [], eff);
113
+ const totals = sumRows(rows, ix);
114
+ const granularity = filters.granularity || 'day';
115
+
116
+ const daily = calculateDailyUsage(rows, ix, { granularity: 'day', from: range.from, to: range.to });
117
+ const series = granularity === 'day'
118
+ ? daily
119
+ : calculateDailyUsage(rows, ix, { granularity, from: range.from, to: range.to, fill: false });
120
+ const bucketOf = granularity === 'week' ? weekStart : granularity === 'month' ? monthKey : (d) => d;
121
+ const buckets = series.map((s) => s.key);
122
+
123
+ const averages = calculateAverageUsage(daily);
124
+ const composition = calculateComposition(totals);
125
+ const trend = calculateUsageTrend(daily, 30);
126
+ const hourly = calculateHourlyUsage(rows, ix);
127
+ const dowUsage = calculateDowUsage(rows, ix, daily);
128
+ const hourDow = calculateHourDow(rows, ix);
129
+ const levels = calendarLevels(daily);
130
+ const streaks = calculateStreaks(daily);
131
+ const peaks = calculatePeakUsage(rows, ix, { series: daily, sessions, topN: 10 });
132
+
133
+ const providers = calculateDimensionUsage(rows, ix, 'p', { sessions, grandTotal: totals.total });
134
+ const models = calculateDimensionUsage(rows, ix, 'm', { sessions, grandTotal: totals.total });
135
+ const families = calculateDimensionUsage(rows, ix, 'mf', { sessions, grandTotal: totals.total });
136
+ const clients = calculateDimensionUsage(rows, ix, 'c', { sessions, grandTotal: totals.total });
137
+ const interfaces = calculateDimensionUsage(rows, ix, 'i', { sessions, grandTotal: totals.total });
138
+ const projects = calculateDimensionUsage(rows, ix, 'pj', { sessions, grandTotal: totals.total, limit: 25 });
139
+ const gateways = calculateDimensionUsage(rows, ix, 'g', { sessions, grandTotal: totals.total });
140
+ const tiers = calculateDimensionUsage(rows, ix, 'st', { sessions, grandTotal: totals.total });
141
+
142
+ const providerSeries = calculateDimensionSeries(rows, ix, 'p', buckets, { topN: 6, bucketOf });
143
+ const modelSeries = calculateDimensionSeries(rows, ix, 'm', buckets, { topN: 6, bucketOf });
144
+ const interfaceTrend = calculateInterfaceTrend(rows, ix, buckets, bucketOf);
145
+ const efficiency = calculateEfficiency(totals, { sessions: sessions.length, activeDays: averages.activeDays });
146
+ const sessionProfile = calculateSessionProfile(sessions);
147
+ const modelEfficiency = calculateModelEfficiency(rows, ix, sessions, { limit: 24 });
148
+
149
+ const book = buildPriceBook(bundle.pricing || {});
150
+ const unpriced = unpricedModels(rows, ix, (m, p) => book.lookup(m, p));
151
+ // Attach the rate provenance to each model row so the Cost page can show
152
+ // where every number came from rather than asserting it.
153
+ // Rows are decorated in place with rate provenance the dimension helper
154
+ // does not know about.
155
+ for (const m of /** @type {Record<string, any>[]} */ (models)) {
156
+ const entry = book.lookup(m.key, m.provider) || book.lookup(m.key, 'unknown');
157
+ m.priceSource = entry ? (entry.origin === 'user' ? 'your override' : entry.src) : null;
158
+ m.rates = entry ? { in: entry.in, out: entry.out, cacheRead: entry.cacheRead, cacheWrite: entry.cacheWrite, cacheRefresh: entry.cacheRefresh } : null;
159
+ }
160
+ // An overlay source is a gateway's own billing log: its tokens describe
161
+ // traffic a client adapter already counted, so they stay out of the totals —
162
+ // but its cost is *measured*, not estimated, and dropping it would throw away
163
+ // the only hard money number in the dataset. Sum it from an overlay-only
164
+ // slice and report it beside the estimate, never inside it.
165
+ const overlayRows = f.includeOverlay ? [] : filterCube(ix, { ...eff, includeOverlay: true }).filter((r) => r[ix.d.ms] === 'overlay');
166
+ const overlay = overlayRows.length ? sumRows(overlayRows, ix) : null;
167
+ const cost = calculateCost(totals, {
168
+ sessions: sessions.length, activeDays: averages.activeDays, unpriced, tiers,
169
+ // Tiers billed above the standard rate, so the UI can call them out.
170
+ premiumTiers: ['priority', 'fast'],
171
+ overlayMeasured: overlay ? (overlay.costMeasured || 0) + (overlay.cost || 0) : null,
172
+ overlayRequests: overlay ? overlay.req : 0,
173
+ });
174
+
175
+ const providerGrowth = calculateDimensionGrowth(ix, 'p', eff, range);
176
+ const modelGrowth = calculateDimensionGrowth(ix, 'm', eff, range);
177
+
178
+ const work = calculateWorkSeries(bundle.activity || { rows: {} }, { from: range.from, to: range.to, projects: f.project });
179
+ const correlations = calculateCorrelations(daily, work);
180
+ const contrast = calculateActivityContrast(daily, work, 'insertions');
181
+ const proxies = calculateActivityProxies(daily, sessions, totals);
182
+
183
+ const comparison = filters.compare
184
+ ? calculatePeriodComparison(ix, bundle.sessions || [], eff, filters.compare.a, filters.compare.b)
185
+ : null;
186
+
187
+ const drill = filters.drillDate ? dayDetail(rows, ix, filters.drillDate, sessions) : null;
188
+
189
+ // ---- forward-looking + structural intelligence --------------------------
190
+ // `today` is the dataset timezone's calendar today (meta), so "this month"
191
+ // and reset windows mean the user's local calendar, never the host's.
192
+ const todayIso = bundle.meta?.today || range.to;
193
+ const forecast = buildForecast(daily, todayIso);
194
+ const anomalies = detectAnomalies(daily);
195
+ const firstSeen = {
196
+ models: firstSeenEntities(ix, { today: todayIso, dim: 'm', withinDays: 7 }).slice(0, 3),
197
+ providers: firstSeenEntities(ix, { today: todayIso, dim: 'p', withinDays: 7 }).slice(0, 3),
198
+ };
199
+ // Capacity is evaluated against the WHOLE primary dataset on purpose: a
200
+ // quota window is a fact about your accounts, not about the dashboard's
201
+ // current filter state. Per-limit provider/model/project scoping happens
202
+ // inside evaluateLimits.
203
+ const capEval = evaluateLimits(bundle.limits || [], {
204
+ cube: bundle.cube,
205
+ today: todayIso,
206
+ nowMs: Date.now(),
207
+ tzOffsetMinutes: bundle.meta?.tzOffsetMinutes ?? 0,
208
+ coverageFrom: coverage.from,
209
+ });
210
+ const capacity = { ...capEval, summary: summarizeCapacity(capEval.states) };
211
+
212
+ const view = {
213
+ range,
214
+ coverage,
215
+ filters: eff,
216
+ granularity,
217
+ kpis: buildKpis(totals, averages, peaks, sessions, providers, models, daily),
218
+ totals,
219
+ series,
220
+ daily,
221
+ movingAverages: {
222
+ ma7: movingAverage(daily, 7),
223
+ ma30: movingAverage(daily, 30),
224
+ },
225
+ averages,
226
+ composition,
227
+ trend,
228
+ hourly,
229
+ dowUsage,
230
+ hourDow,
231
+ calendar: { days: daily, levels },
232
+ streaks,
233
+ peaks,
234
+ dimensions: { providers, models, families, clients, interfaces, projects, gateways, tiers },
235
+ stacks: { providerSeries, modelSeries, interfaceTrend },
236
+ growth: { providers: providerGrowth, models: modelGrowth },
237
+ efficiency,
238
+ sessionProfile,
239
+ modelEfficiency,
240
+ cost,
241
+ productivity: { proxies, work, correlations, contrast },
242
+ comparison,
243
+ drill,
244
+ forecast,
245
+ anomalies,
246
+ firstSeen,
247
+ capacity,
248
+ facets: {
249
+ provider: facet(ix, 'p'),
250
+ model: facet(ix, 'm'),
251
+ model_family: facet(ix, 'mf'),
252
+ client: facet(ix, 'c'),
253
+ interface: facet(ix, 'i'),
254
+ gateway: facet(ix, 'g'),
255
+ project: facet(ix, 'pj'),
256
+ repository: facet(ix, 'rp'),
257
+ service_tier: facet(ix, 'st'),
258
+ },
259
+ plugins: {},
260
+ };
261
+
262
+ view.insights = generateInsights({
263
+ ix, rows, totals, series: daily, sessions, filters: eff, range,
264
+ hourly, peaks, composition, trend, cost, correlations,
265
+ });
266
+
267
+ const slice = { ix, rows, sessions, totals, view, bundle, filters: eff, range };
268
+ for (const p of plugins.values()) {
269
+ try {
270
+ view.plugins[p.id] = p.compute(slice);
271
+ } catch (err) {
272
+ view.plugins[p.id] = { error: err.message };
273
+ }
274
+ }
275
+ return view;
276
+ }
277
+
278
+ function buildKpis(totals, averages, peaks, sessions, providers, models, daily) {
279
+ const activeDays = averages.activeDays;
280
+ return {
281
+ total: { value: totals.total, label: 'Total usage', unit: 'tokens', measured: true },
282
+ input: { value: totals.in, label: 'Input', unit: 'tokens', na: totals.naIn },
283
+ output: { value: totals.out, label: 'Output', unit: 'tokens', na: totals.naOut },
284
+ cache: { value: totals.cr + totals.cw, label: 'Cache', unit: 'tokens', na: totals.naCr + totals.naCw },
285
+ avgPerDay: { value: averages.perActiveDay, label: 'Avg / active day', unit: 'tokens' },
286
+ peak: {
287
+ value: peaks.peakDay?.total ?? null,
288
+ label: 'Peak day',
289
+ unit: 'tokens',
290
+ detail: peaks.peakDay?.date ?? null,
291
+ },
292
+ activeDays: { value: activeDays, label: 'Active days', unit: 'days', detail: `${daily.length} in range` },
293
+ sessions: { value: sessions.length, label: 'Sessions', unit: 'sessions' },
294
+ sessionsPerDay: { value: activeDays ? sessions.length / activeDays : null, label: 'Avg sessions / day', unit: '' },
295
+ providers: { value: providers.length, label: 'Providers', unit: '' },
296
+ models: { value: models.length, label: 'Models', unit: '' },
297
+ requests: { value: totals.req, label: 'Requests', unit: '' },
298
+ };
299
+ }
300
+
301
+ export function datasetCoverage(ix) {
302
+ let from = null;
303
+ let to = null;
304
+ for (const r of ix.rows) {
305
+ const d = r[ix.d.d];
306
+ if (from === null || d < from) from = d;
307
+ if (to === null || d > to) to = d;
308
+ }
309
+ return { from, to };
310
+ }
311
+
312
+ export {
313
+ indexCube, filterCube, filterSessions, sumRows, facet, EMPTY_FILTERS,
314
+ calculateDailyUsage, movingAverage, calculateAverageUsage, calculateComposition,
315
+ calculateUsageTrend, calculateHourlyUsage, calculateDowUsage, calculateHourDow,
316
+ calendarLevels, calculateStreaks,
317
+ calculateDimensionUsage, calculateDimensionSeries, calculateDimensionGrowth,
318
+ calculateModelEfficiency, calculateInterfaceTrend,
319
+ calculatePeakUsage, dayDetail,
320
+ calculateEfficiency, calculateCost, unpricedModels, calculateSessionProfile,
321
+ calculatePeriodComparison, previousPeriod,
322
+ calculateActivityProxies, calculateWorkSeries, calculateCorrelations, calculateActivityContrast,
323
+ generateInsights, daysBetween, addDays, dateRange, weekStart, monthKey,
324
+ buildForecast, linearForecast, monthEndProjection, exhaustionEta,
325
+ detectAnomalies, firstSeenEntities,
326
+ evaluateLimits, summarizeCapacity, normalizeLimits,
327
+ };