@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,267 @@
1
+ /**
2
+ * Core token-usage analytics: the time series, its moving averages, the
3
+ * composition split, the hour/day profiles, and the trend.
4
+ */
5
+ import {
6
+ groupRows, sumRows, zeroMeasures, finalize, addInto, dateRange, weekStart,
7
+ monthKey, percentile, mean, daysBetween, addDays,
8
+ } from './aggregate.js';
9
+
10
+ /**
11
+ * Daily (or weekly / monthly) token series.
12
+ *
13
+ * Empty calendar days inside the range are emitted with zeros and
14
+ * `active: false`, because a gap in a time series is information — but they are
15
+ * excluded from "average per active day", which is the number people actually
16
+ * mean when they ask how much they use per day.
17
+ *
18
+ * @param {any[][]} rows filtered cube rows
19
+ * @param {object} ix index from indexCube
20
+ * @param {{granularity?:'day'|'week'|'month', from?:string, to?:string, fill?:boolean}} opt
21
+ */
22
+ export function calculateDailyUsage(rows, ix, opt = {}) {
23
+ const gran = opt.granularity || 'day';
24
+ const keyOf = gran === 'week' ? weekStart : gran === 'month' ? monthKey : (d) => d;
25
+ const groups = groupRows(rows, ix, (r, d) => keyOf(r[d.d]));
26
+
27
+ let keys = [...groups.keys()].sort();
28
+ if (opt.fill !== false && gran === 'day') {
29
+ const from = opt.from || keys[0];
30
+ const to = opt.to || keys[keys.length - 1];
31
+ if (from && to) keys = dateRange(from, to);
32
+ }
33
+ return keys.map((k) => {
34
+ const g = groups.get(k);
35
+ const m = g ? g.m : finalize(zeroMeasures());
36
+ return {
37
+ key: k,
38
+ date: k,
39
+ // `active` = any AI activity that day (including sources that report no
40
+ // tokens). `tokenActive` = tokens were actually measured. Averaging over
41
+ // the wrong one is how a dashboard ends up claiming a median of ~0.
42
+ active: !!g && m.req > 0,
43
+ tokenActive: !!g && m.total > 0,
44
+ ...m,
45
+ };
46
+ });
47
+ }
48
+
49
+ /** Trailing simple moving average over a series field. Nulls until the window fills. */
50
+ export function movingAverage(series, window, field = 'total') {
51
+ const out = new Array(series.length).fill(null);
52
+ let sum = 0;
53
+ for (let i = 0; i < series.length; i++) {
54
+ sum += series[i][field] || 0;
55
+ if (i >= window) sum -= series[i - window][field] || 0;
56
+ if (i >= window - 1) out[i] = sum / window;
57
+ }
58
+ return out;
59
+ }
60
+
61
+ /**
62
+ * Average usage. `perActiveDay` is the headline; `perCalendarDay` is offered
63
+ * beside it so the difference between "how hard I work when I work" and "how
64
+ * much I use overall" stays visible instead of being silently chosen for you.
65
+ */
66
+ export function calculateAverageUsage(series) {
67
+ const active = series.filter((d) => d.tokenActive);
68
+ const anyActivity = series.filter((d) => d.active);
69
+ const sum = (f, arr) => arr.reduce((a, b) => a + (b[f] || 0), 0);
70
+ return {
71
+ activeDays: active.length,
72
+ activityDays: anyActivity.length,
73
+ activityOnlyDays: anyActivity.length - active.length,
74
+ calendarDays: series.length,
75
+ perActiveDay: active.length ? sum('total', active) / active.length : null,
76
+ perCalendarDay: series.length ? sum('total', series) / series.length : null,
77
+ outputPerActiveDay: active.length ? sum('out', active) / active.length : null,
78
+ requestsPerActiveDay: active.length ? sum('req', active) / active.length : null,
79
+ medianActiveDay: active.length
80
+ ? percentile(active.map((d) => d.total).sort((a, b) => a - b), 0.5)
81
+ : null,
82
+ };
83
+ }
84
+
85
+ /** Input / output / cache composition, with the two ratios the spec asks for. */
86
+ export function calculateComposition(totals) {
87
+ const t = totals.total || 0;
88
+ return {
89
+ total: t,
90
+ input: totals.in,
91
+ output: totals.out,
92
+ cacheRead: totals.cr,
93
+ cacheWrite: totals.cw,
94
+ cacheRefresh: totals.cf,
95
+ reasoning: totals.rs,
96
+ cache: totals.cr + totals.cw,
97
+ shares: t
98
+ ? {
99
+ input: totals.in / t,
100
+ output: totals.out / t,
101
+ cacheRead: totals.cr / t,
102
+ cacheWrite: totals.cw / t,
103
+ cache: (totals.cr + totals.cw) / t,
104
+ }
105
+ : { input: null, output: null, cacheRead: null, cacheWrite: null, cache: null },
106
+ outputPerInput: totals.in ? totals.out / totals.in : null,
107
+ // Output against ALL prompt-side tokens (fresh + cache read + cache write).
108
+ // With a cache-heavy agent, fresh input is a sliver of what was actually
109
+ // sent, so out/in alone would badly misdescribe the workload.
110
+ outputPerPromptToken: (totals.in + totals.cr + totals.cw)
111
+ ? totals.out / (totals.in + totals.cr + totals.cw) : null,
112
+ promptTokens: totals.in + totals.cr + totals.cw,
113
+ cacheRatio: t ? (totals.cr + totals.cw) / t : null,
114
+ // Of all prompt-side tokens, how many were served from cache rather than
115
+ // re-sent fresh. This is the number that actually reflects cache benefit.
116
+ cacheHitRate: totals.in + totals.cr ? totals.cr / (totals.in + totals.cr) : null,
117
+ reasoningShareOfOutput: totals.out ? totals.rs / totals.out : null,
118
+ refreshShareOfCacheWrite: totals.cw ? totals.cf / totals.cw : null,
119
+ };
120
+ }
121
+
122
+ export function calculateCacheRatio(totals) {
123
+ return calculateComposition(totals).cacheRatio;
124
+ }
125
+
126
+ /**
127
+ * Trend: compare the last `window` active-or-not calendar days against the
128
+ * equally long window before it. Returns null rather than a made-up number
129
+ * when there isn't enough history for a fair comparison.
130
+ */
131
+ export function calculateUsageTrend(series, window = 30) {
132
+ if (series.length < 4) return { window: null, change: null, reason: 'not enough history' };
133
+ const w = Math.min(window, Math.floor(series.length / 2));
134
+ const recent = series.slice(-w);
135
+ const prior = series.slice(-2 * w, -w);
136
+ const sum = (a) => a.reduce((x, d) => x + (d.total || 0), 0);
137
+ const a = sum(prior);
138
+ const b = sum(recent);
139
+ return {
140
+ window: w,
141
+ recentTotal: b,
142
+ priorTotal: a,
143
+ change: a > 0 ? (b - a) / a : null,
144
+ direction: a > 0 ? (b > a * 1.05 ? 'increasing' : b < a * 0.95 ? 'decreasing' : 'flat') : 'unknown',
145
+ reason: a > 0 ? null : 'no usage in the comparison window',
146
+ };
147
+ }
148
+
149
+ /** 24-bucket hour-of-day profile. */
150
+ export function calculateHourlyUsage(rows, ix) {
151
+ const buckets = Array.from({ length: 24 }, (_, h) => ({ hour: h, ...finalize(zeroMeasures()) }));
152
+ for (const r of rows) {
153
+ const b = buckets[r[ix.d.h]];
154
+ if (!b) continue;
155
+ addInto(b, r, ix);
156
+ }
157
+ for (const b of buckets) finalize(b);
158
+ const totals = buckets.map((b) => b.total);
159
+ return { buckets, peakWindow: bestWindow(totals, 3), secondaryWindow: secondBestWindow(totals, 3) };
160
+ }
161
+
162
+ /** Monday-first day-of-week profile, with a per-active-day average. */
163
+ export function calculateDowUsage(rows, ix, series = null) {
164
+ const buckets = Array.from({ length: 7 }, (_, w) => ({ dow: w, days: 0, ...finalize(zeroMeasures()) }));
165
+ for (const r of rows) {
166
+ const b = buckets[r[ix.d.w]];
167
+ if (!b) continue;
168
+ addInto(b, r, ix);
169
+ }
170
+ if (series) {
171
+ for (const d of series) {
172
+ if (!d.tokenActive) continue;
173
+ const w = dowOf(d.date);
174
+ buckets[w].days++;
175
+ }
176
+ }
177
+ for (const b of buckets) {
178
+ finalize(b);
179
+ b.perActiveDay = b.days ? b.total / b.days : null;
180
+ }
181
+ return buckets;
182
+ }
183
+
184
+ /** 7x24 matrix for the hour x weekday heatmap. */
185
+ export function calculateHourDow(rows, ix) {
186
+ const cells = [];
187
+ const grid = Array.from({ length: 7 }, () => Array.from({ length: 24 }, () => zeroMeasures()));
188
+ for (const r of rows) {
189
+ const w = r[ix.d.w];
190
+ const h = r[ix.d.h];
191
+ if (grid[w] && grid[w][h]) addInto(grid[w][h], r, ix);
192
+ }
193
+ let max = 0;
194
+ for (let w = 0; w < 7; w++) {
195
+ for (let h = 0; h < 24; h++) {
196
+ const m = finalize(grid[w][h]);
197
+ if (m.total > max) max = m.total;
198
+ cells.push({ dow: w, hour: h, ...m });
199
+ }
200
+ }
201
+ return { cells, max };
202
+ }
203
+
204
+ /**
205
+ * Calendar-heatmap levels from the *distribution* of the data, not from fixed
206
+ * thresholds — so the heatmap still reads correctly whether your daily usage is
207
+ * measured in thousands or in billions.
208
+ */
209
+ export function calendarLevels(series, levels = 4) {
210
+ const active = series.filter((d) => d.tokenActive).map((d) => d.total).sort((a, b) => a - b);
211
+ if (!active.length) return { thresholds: [], levelOf: () => 0, max: 0 };
212
+ const thresholds = [];
213
+ for (let i = 1; i <= levels; i++) thresholds.push(percentile(active, i / (levels + 1)));
214
+ return {
215
+ thresholds,
216
+ max: active[active.length - 1],
217
+ median: percentile(active, 0.5),
218
+ levelOf(total, isActive) {
219
+ if (!isActive || !total) return 0;
220
+ let l = 1;
221
+ for (const t of thresholds) if (total > t) l++;
222
+ return Math.min(l, levels + 1);
223
+ },
224
+ };
225
+ }
226
+
227
+ export function dowOf(iso) {
228
+ const [y, m, d] = iso.split('-').map(Number);
229
+ return (new Date(Date.UTC(y, m - 1, d)).getUTCDay() + 6) % 7;
230
+ }
231
+
232
+ /** Contiguous window of `size` buckets with the largest sum (wraps midnight). */
233
+ export function bestWindow(values, size) {
234
+ const n = values.length;
235
+ if (!n) return null;
236
+ let best = -1;
237
+ let at = 0;
238
+ for (let i = 0; i < n; i++) {
239
+ let s = 0;
240
+ for (let k = 0; k < size; k++) s += values[(i + k) % n];
241
+ if (s > best) { best = s; at = i; }
242
+ }
243
+ if (best <= 0) return null;
244
+ return { from: at, to: (at + size - 1) % n, total: best, share: best / values.reduce((a, b) => a + b, 0) };
245
+ }
246
+
247
+ function secondBestWindow(values, size) {
248
+ const first = bestWindow(values, size);
249
+ if (!first) return null;
250
+ const masked = values.slice();
251
+ for (let k = 0; k < size; k++) masked[(first.from + k) % masked.length] = 0;
252
+ const second = bestWindow(masked, size);
253
+ if (!second || !second.total) return null;
254
+ return { ...second, share: second.total / values.reduce((a, b) => a + b, 0) };
255
+ }
256
+
257
+ /** Longest run of consecutive active days, and the current run. */
258
+ export function calculateStreaks(series) {
259
+ let best = 0, cur = 0, bestEnd = null;
260
+ for (const d of series) {
261
+ if (d.tokenActive) {
262
+ cur++;
263
+ if (cur > best) { best = cur; bestEnd = d.date; }
264
+ } else cur = 0;
265
+ }
266
+ return { longest: best, longestEndedOn: bestEnd, current: cur };
267
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * `tokenflow diagnostics` — local observability without telemetry.
3
+ *
4
+ * Everything a support request or a debugging session needs, printed from
5
+ * local state only. Nothing is transmitted; `--out <file>` writes the same
6
+ * content to a file the user can choose to share voluntarily.
7
+ */
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { loadConfig, paths } from '../core/config.js';
12
+
13
+ /**
14
+ * @param {{includePaths?: boolean}} opt
15
+ * @returns {object} diagnostics snapshot (plain JSON-able)
16
+ */
17
+ export function collect(opt = {}) {
18
+ const cfg = loadConfig();
19
+ const p = paths();
20
+ const showPaths = !!opt.includePaths;
21
+
22
+ const dataDir = p.data;
23
+ const fileStat = (f) => {
24
+ try { const s = fs.statSync(f); return { exists: true, sizeBytes: s.size, modified: s.mtime.toISOString() }; }
25
+ catch { return { exists: false }; }
26
+ };
27
+
28
+ return {
29
+ version: process.env.npm_package_version || '1.1.0',
30
+ node: process.version,
31
+ platform: `${os.platform()} ${os.arch()} ${os.release()}`,
32
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
33
+ providers: {
34
+ enabled: cfg.providers.length ? cfg.providers : 'all detected',
35
+ configuredSources: Object.keys(cfg.sources || {}),
36
+ },
37
+ store: {
38
+ records: fileStat(`${dataDir}/cube.json`),
39
+ sessions: fileStat(`${dataDir}/sessions.json`),
40
+ status: fileStat(`${dataDir}/status.json`),
41
+ },
42
+ freshness: (() => {
43
+ try {
44
+ const s = JSON.parse(fs.readFileSync(`${dataDir}/status.json`, 'utf8'));
45
+ return { lastRefresh: s.freshness?.lastRefresh ?? null, stale: s.freshness?.stale ?? null };
46
+ } catch { return { lastRefresh: null, stale: 'unknown' }; }
47
+ })(),
48
+ features: {
49
+ budgetConfigured: !!(cfg.budget?.monthly),
50
+ digestScheduleInstalled: fs.existsSync(path.join(os.homedir(), 'Library', 'LaunchAgents', 'app.tokenflow.digest.plist')),
51
+ promptAnalyticsEnabled: !!cfg.promptAnalytics?.enabled,
52
+ rawPromptCaptureEnabled: !!cfg.promptAnalytics?.storeRaw,
53
+ syncEnabled: !!cfg.sync?.enabled,
54
+ syncDir: cfg.sync?.enabled ? (showPaths ? cfg.sync.dir : '<configured>') : null,
55
+ deliveryChannels: Object.entries(cfg.delivery || {})
56
+ .filter(([, v]) => v && Object.values(v).some(Boolean))
57
+ .map(([k]) => k),
58
+ },
59
+ paths: showPaths
60
+ ? { home: p.root, data: dataDir, config: p.config }
61
+ : undefined,
62
+ };
63
+ }
64
+
65
+ export function renderText(d) {
66
+ const L = [];
67
+ L.push('TokenFlow diagnostics');
68
+ L.push(` version: ${d.version} node: ${d.node}`);
69
+ L.push(` platform: ${d.platform} tz: ${d.timezone}`);
70
+ L.push('');
71
+ L.push('Providers');
72
+ L.push(` enabled: ${Array.isArray(d.providers.enabled) ? d.providers.enabled.join(', ') : d.providers.enabled}`);
73
+ L.push('');
74
+ L.push('Store');
75
+ for (const [k, v] of Object.entries(d.store)) {
76
+ L.push(` ${k}: ${v.exists ? `${v.sizeBytes} bytes, modified ${v.modified}` : 'MISSING'}`);
77
+ }
78
+ L.push(` last refresh: ${d.freshness.lastRefresh || 'never'}${d.freshness.stale === true ? ' (STALE)' : ''}`);
79
+ L.push('');
80
+ L.push('Features');
81
+ L.push(` budget alerts: ${d.features.budgetConfigured ? 'configured' : 'not configured'}`);
82
+ L.push(` digest schedule: ${d.features.digestScheduleInstalled ? 'installed' : 'not installed'}`);
83
+ L.push(` prompt analytics: ${d.features.promptAnalyticsEnabled ? `ON${d.features.rawPromptCaptureEnabled ? ' (+RAW)' : ''}` : 'off'}`);
84
+ L.push(` sync: ${d.features.syncEnabled ? `ON → ${d.features.syncDir}` : 'off'}`);
85
+ L.push(` delivery channels: ${d.features.deliveryChannels.length ? d.features.deliveryChannels.join(', ') : 'none'}`);
86
+ if (d.paths) L.push('', 'Paths', ` home: ${d.paths.home}`, ` data: ${d.paths.data}`, ` config: ${d.paths.config}`);
87
+ return L.join('\n');
88
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * `tokenflow digest` — a shareable weekly summary.
3
+ *
4
+ * The missing "show someone else" surface: the dashboard is interactive, the
5
+ * offline HTML export carries the whole dataset, but neither fits in a Slack
6
+ * message or a stand-up note. The digest renders the current view (or an
7
+ * arbitrary --from/--to window) as compact Markdown or plain text: headline
8
+ * numbers, top providers/models/sources, week-over-week delta, and any active
9
+ * alerts — every number from the same cube the dashboard uses, so nothing can
10
+ * disagree. Zero dependencies, no network, no new data collection.
11
+ *
12
+ * tokenflow digest # last 7 days, markdown to stdout
13
+ * tokenflow digest --format text # plain text
14
+ * tokenflow digest --out digest.md # write to a file
15
+ * tokenflow digest --from 2026-08-01 --to 2026-08-07
16
+ */
17
+ import { buildBundle } from '../core/bundle.js';
18
+ import { computeView } from '../analytics/index.js';
19
+ import { filterCube, indexCube, finalize, sumRows, addDays } from '../analytics/aggregate.js';
20
+ import { compact, usd } from '../core/units.js';
21
+ import fs from 'node:fs';
22
+
23
+ const money = (n) => (n == null || !(n > 0) ? null : usd(n));
24
+ const tokens = (n) => (n > 0 ? compact(n) : '0');
25
+
26
+ function rankBy(rows, ix, dim, n = 5) {
27
+ const by = new Map();
28
+ for (const r of rows) {
29
+ if (!(r[ix.m.totalKey] ?? true)) { /* noop; totals computed below */ }
30
+ const k = r[dim];
31
+ by.set(k, (by.get(k) || 0) + r[ix.m.in] + r[ix.m.out] + r[ix.m.cr] + r[ix.m.cw]);
32
+ }
33
+ return [...by.entries()].filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1]).slice(0, n);
34
+ }
35
+
36
+ export function renderDigest({ status, from, to, format = 'markdown' }) {
37
+ const u = status.usage;
38
+ const today = u.today;
39
+ const lines = [];
40
+
41
+ const title = `TokenFlow digest — ${from} → ${to}`;
42
+ if (format === 'text') {
43
+ lines.push(title, '='.repeat(title.length), '');
44
+ } else {
45
+ lines.push(`## ${title}`, '');
46
+ }
47
+ const bullet = (s) => (format === 'text' ? ` • ${s}` : `- ${s}`);
48
+
49
+ // headline
50
+ const cost = today.cost != null ? money(today.cost) : today.costMeasured != null ? `${money(today.costMeasured)} (measured)` : null;
51
+ lines.push(bullet(`Tokens: **${tokens(today.tokens.total)}** (${tokens(today.tokens.input)} in / ${tokens(today.tokens.output)} out / ${tokens(today.tokens.cacheRead)} cache-read)`));
52
+ if (cost) lines.push(bullet(`Estimated spend: **${cost}**`));
53
+ if (today.requests != null) lines.push(bullet(`Requests: ${today.requests} across ${today.sessions ?? '?'} sessions`));
54
+
55
+ // period totals when it's not just today
56
+ if (u.weekToDate && from !== to) {
57
+ lines.push(bullet(`Week-to-date: ${tokens(u.weekToDate.tokens.total)}${money(u.weekToDate.cost) ? ` · ${money(u.weekToDate.cost)}` : ''}`));
58
+ }
59
+
60
+ // breakdowns
61
+ const sec = (name, items, fmt) => {
62
+ if (!items.length) return;
63
+ lines.push('', format === 'text' ? name : `### ${name}`);
64
+ for (const [k, v] of items) lines.push(bullet(`${k}: ${fmt(v)}`));
65
+ };
66
+ sec('By source', status.sourcesToday.map((s) => [s.key, s.tokens]), (v) => tokens(v));
67
+ sec('By provider', status.providersToday.map((p) => [p.key, p.tokens]), (v) => tokens(v));
68
+ sec('Top models', status.modelsToday.map((m) => [m.key, m.tokens]), (v) => tokens(v));
69
+
70
+ // velocity + capacity
71
+ if (status.velocity?.ratio != null) {
72
+ const r = status.velocity.ratio;
73
+ const dir = r >= 1 ? 'above' : 'below';
74
+ lines.push('', bullet(`Pace: ${r.toFixed(2)}× your trailing 14-day average (${dir})`));
75
+ }
76
+ const worst = status.capacity?.summary?.worst;
77
+ if (worst && worst.pctUsed != null) {
78
+ lines.push(bullet(`Nearest limit: ${worst.label} at ${Math.round(worst.pctUsed * 100)}%${worst.resetsInMs ? `, resets in ${Math.round(worst.resetsInMs / 3600000)}h` : ''}`));
79
+ }
80
+
81
+ // alerts
82
+ const alerts = (status.anomalies || []).filter((a) => a.severity === 'high');
83
+ if (alerts.length) {
84
+ lines.push('', format === 'text' ? 'Alerts' : '### Alerts');
85
+ for (const a of alerts.slice(0, 3)) lines.push(bullet(a.detail));
86
+ }
87
+
88
+ lines.push('', format === 'text'
89
+ ? 'local-first · numbers from the same cube as the dashboard'
90
+ : `<sub>local-first · same cube as the dashboard</sub>`);
91
+ return lines.join('\n');
92
+ }
93
+
94
+ /** Build the status snapshot for an arbitrary window without mutating config. */
95
+ export async function run(opts = {}) {
96
+ const b = buildBundle();
97
+ const v = computeView(b, {});
98
+ const ix = indexCube(b.cube);
99
+ const to = opts.to || b.meta.today;
100
+ const from = opts.from || addDays(to, -6);
101
+
102
+ const rows = filterCube(ix, { from, to, includeOverlay: !!b.meta.includeOverlayDefault });
103
+ const m = finalize(sumRows(rows, ix));
104
+ const topSources = rankBy(rows, ix, ix.d.c);
105
+ const topProviders = rankBy(rows, ix, ix.d.p);
106
+ const topModels = rankBy(rows, ix, ix.d.m);
107
+
108
+ const status = {
109
+ usage: {
110
+ today: {
111
+ tokens: { total: m.total, input: m.in, output: m.out, cacheRead: m.cr },
112
+ requests: m.req,
113
+ cost: m.costReq > 0 ? m.cost : null,
114
+ costMeasured: m.costMeasured > 0 ? m.costMeasured : null,
115
+ sessions: filterSessionsCount(b, from, to),
116
+ },
117
+ weekToDate: null,
118
+ },
119
+ sourcesToday: topSources.map(([key, tk]) => ({ key, tokens: tk })),
120
+ providersToday: topProviders.map(([key, tk]) => ({ key, tokens: tk })),
121
+ modelsToday: topModels.map(([key, tk]) => ({ key, tokens: tk })),
122
+ velocity: v.velocity,
123
+ capacity: v.capacity,
124
+ anomalies: v.anomalies,
125
+ };
126
+ return renderDigest({ status, from, to, format: opts.format || 'markdown' });
127
+ }
128
+
129
+ function filterSessionsCount(b, from, to) {
130
+ try {
131
+ return b.sessions.filter((s) => s.date >= from && s.date <= to).length;
132
+ } catch { return null; }
133
+ }
134
+
135
+ /** CLI entry point. */
136
+ export function register(program, ctx) {
137
+ program
138
+ .command('digest')
139
+ .description('shareable weekly/daily summary (markdown or text)')
140
+ .option('--from <date>', 'window start (YYYY-MM-DD)')
141
+ .option('--to <date>', 'window end (YYYY-MM-DD)')
142
+ .option('--format <fmt>', 'markdown | text', 'markdown')
143
+ .option('--out <file>', 'write to file instead of stdout')
144
+ .action(async (flags) => {
145
+ const md = await run({
146
+ from: flags.from, to: flags.to, format: flags.format,
147
+ });
148
+ if (flags.out) {
149
+ fs.writeFileSync(flags.out, md + '\n');
150
+ console.log(`wrote ${flags.out}`);
151
+ } else {
152
+ console.log(md);
153
+ }
154
+ });
155
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * `tokenflow models-compare` — "which model is actually costing me what?"
3
+ *
4
+ * Uses ONLY the user's own measured data from the same cube as every other
5
+ * surface. Metrics are factual arithmetic over the window; the only judgement
6
+ * offered is cost-efficiency per request/token, clearly derived, never a
7
+ * claim about model quality. No useful-output signal exists in local logs,
8
+ * so "cost per useful output" is explicitly reported as unavailable rather
9
+ * than fabricated.
10
+ */
11
+ import { buildBundle } from '../core/bundle.js';
12
+ import { filterCube, indexCube, finalize, sumRows } from '../analytics/aggregate.js';
13
+ import { compact, usd } from '../core/units.js';
14
+
15
+ /**
16
+ * @param {{from?:string, to?:string}} opt
17
+ */
18
+ export function compare({ from, to } = {}) {
19
+ const b = buildBundle();
20
+ const ix = indexCube(b.cube);
21
+ const toD = to || b.meta.today;
22
+ const fromD = from || addDays(toD, -29);
23
+
24
+ const rows = filterCube(ix, { from: fromD, to: toD, includeOverlay: false })
25
+ .filter((r) => r[ix.d.m] !== 'unknown');
26
+ if (!rows.length) return null;
27
+
28
+ const byModel = new Map();
29
+ for (const r of rows) {
30
+ const k = r[ix.d.m];
31
+ let m = byModel.get(k);
32
+ if (!m) {
33
+ m = { key: k, requests: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, pricedReq: 0 };
34
+ byModel.set(k, m);
35
+ }
36
+ m.requests += r[ix.m.req];
37
+ m.input += r[ix.m.in];
38
+ m.output += r[ix.m.out];
39
+ m.cacheRead += r[ix.m.cr];
40
+ m.cacheWrite += r[ix.m.cw];
41
+ m.cost += r[ix.m.cost] || 0;
42
+ if (r[ix.m.costReq] > 0) m.pricedReq += r[ix.m.costReq];
43
+ }
44
+
45
+ return {
46
+ from: fromD, to: toD,
47
+ models: [...byModel.values()]
48
+ .filter((m) => m.requests > 0)
49
+ .sort((a, b) => b.cost - a.cost)
50
+ .map((m) => {
51
+ const tokens = m.input + m.output + m.cacheRead + m.cacheWrite;
52
+ return {
53
+ ...m,
54
+ tokens,
55
+ cacheHitPct: (m.cacheRead + m.cacheWrite) > 0
56
+ ? Math.round((m.cacheRead / (m.cacheRead + m.input + m.cacheWrite)) * 1000) / 10
57
+ : null,
58
+ avgCostPerRequest: m.pricedReq > 0 ? m.cost / m.pricedReq : null,
59
+ avgTokensPerRequest: m.requests > 0 ? Math.round(tokens / m.requests) : null,
60
+ };
61
+ }),
62
+ };
63
+ }
64
+
65
+ function addDays(date, n) {
66
+ const d = new Date(`${date}T00:00:00Z`);
67
+ d.setUTCDate(d.getUTCDate() + n);
68
+ return d.toISOString().slice(0, 10);
69
+ }
70
+
71
+ /** Render the comparison as aligned plain text. */
72
+ export function renderText(cmp) {
73
+ if (!cmp) return 'No model usage in this window.';
74
+ const L = [];
75
+ L.push(`Model comparison — ${cmp.from} → ${cmp.to}`);
76
+ L.push('');
77
+ L.push('Model Requests Tokens Cost $/req tok/req cache-hit');
78
+ L.push('-'.repeat(100));
79
+ for (const m of cmp.models.slice(0, 15)) {
80
+ const perReq = m.avgCostPerRequest != null ? usd(m.avgCostPerRequest) : 'n/a';
81
+ const cache = m.cacheHitPct != null ? `${m.cacheHitPct}%` : 'n/a';
82
+ L.push(
83
+ m.key.padEnd(30).slice(0, 30)
84
+ + String(m.requests.toLocaleString('en-US')).padStart(9)
85
+ + compact(m.tokens).padStart(10)
86
+ + (m.cost > 0 ? usd(m.cost).padStart(12) : 'n/a'.padStart(12))
87
+ + perReq.padStart(11)
88
+ + String(m.avgTokensPerRequest ?? 'n/a').padStart(10)
89
+ + cache.padStart(10),
90
+ );
91
+ }
92
+ L.push('');
93
+ L.push('Costs are estimates from the versioned price table where rates exist; "n/a" means unpriced — never $0.');
94
+ L.push('"cost per useful output" is not computable from local logs and is deliberately not shown.');
95
+ return L.join('\n');
96
+ }