@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,247 @@
1
+ /**
2
+ * Filtering + aggregation primitives.
3
+ *
4
+ * Everything downstream operates on a "slice": the cube rows that survive the
5
+ * global filter bar, plus the sessions that survive the same filter. Slices are
6
+ * computed in-process (in the browser, from a bundle loaded once) so changing a
7
+ * filter costs no network round trip.
8
+ *
9
+ * This module is deliberately free of Node imports so the exact same code runs
10
+ * in the CLI, the API and the browser.
11
+ */
12
+
13
+ export const MEASURES = ['in', 'out', 'cr', 'cw', 'cf', 'rs', 'req', 'cost', 'costMeasured', 'costReq', 'naIn', 'naOut', 'naCr', 'naCw'];
14
+
15
+ /** Column indices for a cube built by src/core/store.js. */
16
+ export function indexCube(cube) {
17
+ const d = {};
18
+ cube.dims.forEach((k, i) => { d[k] = i; });
19
+ const m = {};
20
+ cube.measures.forEach((k, i) => { m[k] = cube.dims.length + i; });
21
+ return { d, m, rows: cube.rows, dims: cube.dims, measures: cube.measures };
22
+ }
23
+
24
+ export const EMPTY_FILTERS = {
25
+ from: null, to: null,
26
+ hourFrom: null, hourTo: null,
27
+ dows: null,
28
+ provider: null, model: null, model_family: null, client: null,
29
+ interface: null, gateway: null, project: null, repository: null, service_tier: null,
30
+ includeOverlay: false,
31
+ includeActivity: true,
32
+ };
33
+
34
+ /**
35
+ * @param {ReturnType<typeof indexCube>} ix
36
+ * @param {Partial<typeof EMPTY_FILTERS>} f
37
+ * @returns {any[][]} cube rows
38
+ */
39
+ export function filterCube(ix, f = {}) {
40
+ const { d } = ix;
41
+ const set = (v) => (v && v.length ? new Set(v) : null);
42
+ const P = set(f.provider), M = set(f.model), MF = set(f.model_family), C = set(f.client),
43
+ I = set(f.interface), G = set(f.gateway), PJ = set(f.project), RP = set(f.repository),
44
+ ST = set(f.service_tier), W = f.dows && f.dows.length ? new Set(f.dows) : null;
45
+ const from = f.from || null, to = f.to || null;
46
+ const hf = f.hourFrom === null || f.hourFrom === undefined ? null : Number(f.hourFrom);
47
+ const ht = f.hourTo === null || f.hourTo === undefined ? null : Number(f.hourTo);
48
+ const overlay = !!f.includeOverlay;
49
+ const activity = f.includeActivity !== false;
50
+
51
+ const out = [];
52
+ for (const r of ix.rows) {
53
+ const ms = r[d.ms];
54
+ if (ms === 'overlay' && !overlay) continue;
55
+ if (ms === 'activity' && !activity) continue;
56
+ if (from && r[d.d] < from) continue;
57
+ if (to && r[d.d] > to) continue;
58
+ if (hf !== null || ht !== null) {
59
+ const h = r[d.h];
60
+ if (hf !== null && ht !== null) {
61
+ // A window may wrap past midnight (22 -> 03).
62
+ const ok = hf <= ht ? h >= hf && h <= ht : h >= hf || h <= ht;
63
+ if (!ok) continue;
64
+ } else if (hf !== null && h < hf) continue;
65
+ else if (ht !== null && h > ht) continue;
66
+ }
67
+ if (W && !W.has(r[d.w])) continue;
68
+ if (P && !P.has(r[d.p])) continue;
69
+ if (M && !M.has(r[d.m])) continue;
70
+ if (MF && !MF.has(r[d.mf])) continue;
71
+ if (C && !C.has(r[d.c])) continue;
72
+ if (I && !I.has(r[d.i])) continue;
73
+ if (G && !G.has(r[d.g])) continue;
74
+ if (PJ && !PJ.has(r[d.pj])) continue;
75
+ if (RP && !RP.has(r[d.rp])) continue;
76
+ if (ST && !ST.has(r[d.st])) continue;
77
+ out.push(r);
78
+ }
79
+ return out;
80
+ }
81
+
82
+ /** Sessions filtered by the same predicate set (dimension keys match the cube). */
83
+ export function filterSessions(sessions, f = {}) {
84
+ const set = (v) => (v && v.length ? new Set(v) : null);
85
+ const P = set(f.provider), M = set(f.model), MF = set(f.model_family), C = set(f.client),
86
+ I = set(f.interface), G = set(f.gateway), PJ = set(f.project), RP = set(f.repository),
87
+ ST = set(f.service_tier), W = f.dows && f.dows.length ? new Set(f.dows) : null;
88
+ const overlay = !!f.includeOverlay;
89
+ const activity = f.includeActivity !== false;
90
+ return sessions.filter((s) => {
91
+ if (s.ms === 'overlay' && !overlay) return false;
92
+ if (s.ms === 'activity' && !activity) return false;
93
+ if (f.from && s.d < f.from) return false;
94
+ if (f.to && s.d > f.to) return false;
95
+ if (W && !W.has(s.w)) return false;
96
+ if (P && !P.has(s.p)) return false;
97
+ if (M && !M.has(s.m)) return false;
98
+ if (MF && !MF.has(s.mf)) return false;
99
+ if (C && !C.has(s.c)) return false;
100
+ if (I && !I.has(s.i)) return false;
101
+ if (G && !G.has(s.g)) return false;
102
+ if (PJ && !PJ.has(s.pj)) return false;
103
+ if (RP && !RP.has(s.rp)) return false;
104
+ if (ST && !ST.has(s.st)) return false;
105
+ return true;
106
+ });
107
+ }
108
+
109
+ export function zeroMeasures() {
110
+ const o = {};
111
+ for (const k of MEASURES) o[k] = 0;
112
+ return o;
113
+ }
114
+
115
+ /** Sum measures across rows. Adds a derived `total` and `naAny`. */
116
+ export function sumRows(rows, ix) {
117
+ const { m } = ix;
118
+ const o = zeroMeasures();
119
+ for (const r of rows) for (const k of MEASURES) o[k] += r[m[k]];
120
+ return finalize(o);
121
+ }
122
+
123
+ export function addInto(acc, r, ix) {
124
+ const { m } = ix;
125
+ for (const k of MEASURES) acc[k] += r[m[k]];
126
+ return acc;
127
+ }
128
+
129
+ export function finalize(o) {
130
+ o.total = o.in + o.out + o.cr + o.cw;
131
+ o.cache = o.cr + o.cw;
132
+ o.naAny = o.naIn + o.naOut + o.naCr + o.naCw;
133
+ return o;
134
+ }
135
+
136
+ /**
137
+ * Group rows by a key derived from dimension columns.
138
+ * @returns {Map<string, {key:string, m:ReturnType<typeof zeroMeasures>, rows:any[][]}>}
139
+ */
140
+ export function groupRows(rows, ix, keyFn, { keepRows = false } = {}) {
141
+ const out = new Map();
142
+ for (const r of rows) {
143
+ const k = keyFn(r, ix.d);
144
+ if (k === null || k === undefined) continue;
145
+ let g = out.get(k);
146
+ if (!g) {
147
+ g = { key: k, m: zeroMeasures(), rows: keepRows ? [] : null };
148
+ out.set(k, g);
149
+ }
150
+ addInto(g.m, r, ix);
151
+ if (keepRows) g.rows.push(r);
152
+ }
153
+ for (const g of out.values()) finalize(g.m);
154
+ return out;
155
+ }
156
+
157
+ /** Sorted, finalized array form of groupRows, largest total first. */
158
+ export function rank(rows, ix, keyFn, { limit = null, keepRows = false } = {}) {
159
+ const arr = [...groupRows(rows, ix, keyFn, { keepRows }).values()];
160
+ arr.sort((a, b) => b.m.total - a.m.total || (a.key < b.key ? -1 : 1));
161
+ return limit ? arr.slice(0, limit) : arr;
162
+ }
163
+
164
+ /** All distinct values of a dimension, with request counts — for the filter bar. */
165
+ export function facet(ix, dim, rows = ix.rows) {
166
+ const i = ix.d[dim];
167
+ const out = new Map();
168
+ for (const r of rows) {
169
+ const v = r[i];
170
+ const e = out.get(v) || { value: v, req: 0, total: 0 };
171
+ e.req += r[ix.m.req];
172
+ e.total += r[ix.m.in] + r[ix.m.out] + r[ix.m.cr] + r[ix.m.cw];
173
+ out.set(v, e);
174
+ }
175
+ return [...out.values()].sort((a, b) => b.total - a.total || (a.value < b.value ? -1 : 1));
176
+ }
177
+
178
+ // --------------------------------------------------------------- calendar ---
179
+
180
+ export function toISODate(d) {
181
+ return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`;
182
+ }
183
+
184
+ export function parseISODate(s) {
185
+ const [y, m, d] = s.split('-').map(Number);
186
+ return new Date(Date.UTC(y, m - 1, d));
187
+ }
188
+
189
+ export function addDays(iso, n) {
190
+ const d = parseISODate(iso);
191
+ d.setUTCDate(d.getUTCDate() + n);
192
+ return toISODate(d);
193
+ }
194
+
195
+ export function daysBetween(a, b) {
196
+ return Math.round((parseISODate(b).getTime() - parseISODate(a).getTime()) / 86400000);
197
+ }
198
+
199
+ /** Inclusive list of every calendar date in a range — including empty ones. */
200
+ export function dateRange(from, to) {
201
+ const out = [];
202
+ if (!from || !to || from > to) return out;
203
+ let cur = from;
204
+ let guard = 0;
205
+ while (cur <= to && guard++ < 20000) {
206
+ out.push(cur);
207
+ cur = addDays(cur, 1);
208
+ }
209
+ return out;
210
+ }
211
+
212
+ /** ISO week key, e.g. "2026-W33". */
213
+ export function weekKey(iso) {
214
+ const d = parseISODate(iso);
215
+ const day = (d.getUTCDay() + 6) % 7;
216
+ d.setUTCDate(d.getUTCDate() - day + 3);
217
+ const firstThursday = new Date(Date.UTC(d.getUTCFullYear(), 0, 4));
218
+ const fday = (firstThursday.getUTCDay() + 6) % 7;
219
+ firstThursday.setUTCDate(firstThursday.getUTCDate() - fday + 3);
220
+ const week = 1 + Math.round((d.getTime() - firstThursday.getTime()) / (7 * 86400000));
221
+ return `${d.getUTCFullYear()}-W${String(week).padStart(2, '0')}`;
222
+ }
223
+
224
+ export function monthKey(iso) {
225
+ return iso.slice(0, 7);
226
+ }
227
+
228
+ /** Monday of the week containing `iso` — the label for weekly buckets. */
229
+ export function weekStart(iso) {
230
+ const d = parseISODate(iso);
231
+ const day = (d.getUTCDay() + 6) % 7;
232
+ d.setUTCDate(d.getUTCDate() - day);
233
+ return toISODate(d);
234
+ }
235
+
236
+ export function percentile(sortedAsc, p) {
237
+ if (!sortedAsc.length) return null;
238
+ if (sortedAsc.length === 1) return sortedAsc[0];
239
+ const idx = (sortedAsc.length - 1) * p;
240
+ const lo = Math.floor(idx);
241
+ const hi = Math.ceil(idx);
242
+ return sortedAsc[lo] + (sortedAsc[hi] - sortedAsc[lo]) * (idx - lo);
243
+ }
244
+
245
+ export function mean(xs) {
246
+ return xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null;
247
+ }
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Anomaly detection.
3
+ *
4
+ * Robust (median / MAD, Iglewicz–Hoaglin modified z-score) detection over the
5
+ * daily series, plus structural events the statistical layer cannot see:
6
+ * calendar gaps in an otherwise-active pattern and entities that appeared for
7
+ * the first time recently.
8
+ *
9
+ * Every anomaly carries its own arithmetic — observed value, baseline median,
10
+ * modified z — so a reader can check the call instead of trusting it. A quiet
11
+ * dataset produces an empty list; that is the honest answer, not a failure.
12
+ *
13
+ * Pure module: no Node imports, deterministic output.
14
+ */
15
+
16
+ const MAD_SCALE = 1.4826;
17
+ const SPIKE_Z = 3.5; // Iglewicz–Hoaglin threshold for a modified z-score
18
+ const HIGH_Z = 6; // well past that: call it high severity
19
+ const BASELINE_WINDOW = 60;
20
+ const MIN_BASELINE = 10;
21
+
22
+ /**
23
+ * @typedef {Object} Anomaly
24
+ * @property {string} id
25
+ * @property {string} type
26
+ * @property {string} date
27
+ * @property {'high'|'warn'|'info'} severity
28
+ * @property {number|null} observed
29
+ * @property {number|null} [expectedMedian]
30
+ * @property {number|null} [ratio]
31
+ * @property {number|null} [z]
32
+ * @property {string} detail
33
+ * @property {string} [metric]
34
+ */
35
+
36
+ function median(sortedAsc) {
37
+ if (!sortedAsc.length) return null;
38
+ const mid = Math.floor(sortedAsc.length / 2);
39
+ return sortedAsc.length % 2 ? sortedAsc[mid] : (sortedAsc[mid - 1] + sortedAsc[mid]) / 2;
40
+ }
41
+
42
+ /**
43
+ * Baseline stats for index i computed over the preceding window, excluding i
44
+ * itself so an event can never drag its own baseline toward it.
45
+ */
46
+ function baseline(values, i, window = BASELINE_WINDOW) {
47
+ const lo = Math.max(0, i - window);
48
+ const hist = values.slice(lo, i).filter((v) => Number.isFinite(v));
49
+ if (hist.length < MIN_BASELINE) return null;
50
+ const med = median([...hist].sort((a, b) => a - b));
51
+ const mad = median(hist.map((v) => Math.abs(v - med)).sort((a, b) => a - b));
52
+ return { n: hist.length, med, mad };
53
+ }
54
+
55
+ /** Modified z-score; null when the MAD is degenerate (e.g. flat history). */
56
+ export function modifiedZ(x, { med, mad }) {
57
+ if (!Number.isFinite(x)) return null;
58
+ if (mad === 0) {
59
+ // Perfectly flat history: any deviation is infinitely surprising
60
+ // statistically, but capping keeps severity honest — this surfaces as a
61
+ // warning, never as "high".
62
+ if (x === med) return 0;
63
+ return x > med ? 4.99 : -4.99;
64
+ }
65
+ return (0.6745 * (x - med)) / mad;
66
+ }
67
+
68
+ const SEV_ORDER = { high: 0, warn: 1, info: 2 };
69
+
70
+ /** @param {Anomaly[]} out */
71
+ function addSpikeEvents(out, series, values, metric, kind, label, fmt) {
72
+ for (let i = 0; i < series.length; i++) {
73
+ const x = values[i];
74
+ if (!Number.isFinite(x) || x <= 0) continue;
75
+ const base = baseline(values, i);
76
+ if (!base || base.med <= 0) continue;
77
+ const z = modifiedZ(x, base);
78
+ if (z === null || z < SPIKE_Z) continue;
79
+ const ratio = x / base.med;
80
+ out.push({
81
+ id: `${kind}:${series[i].key}`,
82
+ type: kind,
83
+ date: series[i].key,
84
+ severity: z >= HIGH_Z ? 'high' : 'warn',
85
+ observed: x,
86
+ expectedMedian: base.med,
87
+ ratio,
88
+ z,
89
+ detail: `${label} of ${fmt(x)} is ${ratio.toFixed(1)}× the trailing ${base.n}-day median (${fmt(base.med)}) — a robust z-score of ${z.toFixed(1)}.`,
90
+ metric,
91
+ });
92
+ }
93
+ }
94
+
95
+ function valuesOf(series, metric) {
96
+ return series.map((d) => Number(d[metric]));
97
+ }
98
+
99
+ function isWeekend(iso) {
100
+ const [y, m, d] = iso.split('-').map(Number);
101
+ const dow = new Date(Date.UTC(y, m - 1, d)).getUTCDay();
102
+ return dow === 0 || dow === 6;
103
+ }
104
+
105
+ /**
106
+ * Detect anomalies over a calendar-complete daily series.
107
+ *
108
+ * @param {{key:string,total:number,cost?:number|null,req:number,active:boolean,
109
+ * tokenActive:boolean,[k:string]:any}[]} daily
110
+ * @returns {Anomaly[]}
111
+ */
112
+ export function detectAnomalies(daily, opt = {}) {
113
+ /** @type {Anomaly[]} */
114
+ const out = [];
115
+ if (!Array.isArray(daily) || daily.length < MIN_BASELINE + 1) return out;
116
+
117
+ const totals = valuesOf(daily, 'total');
118
+ addSpikeEvents(out, daily, totals, 'total', 'token_spike', 'Token usage', (n) => compact(n));
119
+ const hasCost = daily.some((d) => Number.isFinite(Number(d.cost)) && Number(d.cost) > 0);
120
+ if (hasCost) addSpikeEvents(out, daily, valuesOf(daily, 'cost'), 'cost', 'cost_spike', 'Estimated cost', (n) => `$${Number(n).toFixed(2)}`);
121
+
122
+ // ---- request-rate spikes -----------------------------------------------
123
+ addSpikeEvents(out, daily, valuesOf(daily, 'req'), 'req', 'request_spike', 'Request volume', (n) => String(Math.round(n)));
124
+
125
+ // ---- ingestion-gap candidates ------------------------------------------
126
+ // A zero day is normal (weekends, holidays). It is only interesting when it
127
+ // sits inside a stretch where this dataset was otherwise active every
128
+ // weekday — hence "possible gap", never "gap".
129
+ for (let i = 1; i < daily.length - 1; i++) {
130
+ const d = daily[i];
131
+ if (d.total > 0 || d.active || isWeekend(d.key)) continue;
132
+ const around = [daily[i - 1], daily[i + 1]];
133
+ if (!around.every((x) => x.tokenActive)) continue;
134
+ out.push({
135
+ id: `gap:${d.key}`,
136
+ type: 'possible_gap',
137
+ date: d.key,
138
+ severity: 'info',
139
+ observed: 0,
140
+ expectedMedian: null,
141
+ z: null,
142
+ detail: `No activity at all on ${d.key}, a weekday between two active days — possible ingestion gap or a genuine day off.`,
143
+ });
144
+ }
145
+
146
+ // ---- sudden drops ------------------------------------------------------
147
+ // The mirror of a spike, but only meaningful on weekdays with real history:
148
+ // usage halving on a Saturday is a pattern, not an anomaly.
149
+ for (let i = 0; i < daily.length; i++) {
150
+ const x = totals[i];
151
+ if (!(x > 0) || isWeekend(daily[i].key)) continue;
152
+ const base = baseline(totals, i);
153
+ if (!base || base.med <= 0) continue;
154
+ const z = modifiedZ(x, base);
155
+ if (z === null || z > -SPIKE_Z) continue;
156
+ out.push({
157
+ id: `drop:${daily[i].key}`,
158
+ type: 'usage_drop',
159
+ date: daily[i].key,
160
+ severity: z <= -HIGH_Z ? 'high' : 'warn',
161
+ observed: x,
162
+ expectedMedian: base.med,
163
+ ratio: x / base.med,
164
+ z,
165
+ detail: `Weekday usage fell to ${compact(x)} — ${(x / base.med).toFixed(2)}× the trailing ${base.n}-day median (${compact(base.med)}), a robust z-score of ${z.toFixed(1)}.`,
166
+ });
167
+ }
168
+
169
+ const cap = opt.limit ?? 12;
170
+ return out
171
+ .sort((a, b) =>
172
+ SEV_ORDER[a.severity] - SEV_ORDER[b.severity]
173
+ || (a.date < b.date ? 1 : a.date > b.date ? -1 : 0)
174
+ || (b.z ?? 0) - (a.z ?? 0))
175
+ .slice(0, cap);
176
+ }
177
+
178
+ /**
179
+ * Entities (models or providers) seen for the first time within `withinDays`
180
+ * of `today`. First-seen is derived from the full cube rows, independent of
181
+ * the filtered range, because "new" means new to the whole dataset.
182
+ *
183
+ * @param {ReturnType<import('./aggregate.js').indexCube>} ix
184
+ * @param {{today:string, withinDays?:number, dim?:'m'|'p'}} opt
185
+ */
186
+ export function firstSeenEntities(ix, opt) {
187
+ const dimKey = opt.dim === 'p' ? ix.d.p : ix.d.m;
188
+ const noun = opt.dim === 'p' ? 'provider' : 'model';
189
+ const cutoff = isoMinusDays(opt.today, opt.withinDays ?? 7);
190
+ /** @type {Map<string,{first:string,last:string,tokens:number}>} */
191
+ const seen = new Map();
192
+ for (const r of ix.rows) {
193
+ const date = r[ix.d.d];
194
+ if (date > opt.today) continue;
195
+ const key = r[dimKey];
196
+ let e = seen.get(key);
197
+ if (!e) { e = { first: date, last: date, tokens: 0 }; seen.set(key, e); }
198
+ if (date < e.first) e.first = date;
199
+ if (date > e.last) e.last = date;
200
+ e.tokens += r[ix.m.in] + r[ix.m.out] + r[ix.m.cr] + r[ix.m.cw];
201
+ }
202
+ const out = [];
203
+ for (const [key, e] of seen) {
204
+ if (e.first < cutoff) continue;
205
+ out.push({ entity: key, noun, firstSeen: e.first, lastUsed: e.last, tokens: e.tokens });
206
+ }
207
+ return out.sort((a, b) => (a.firstSeen < b.firstSeen ? 1 : -1));
208
+ }
209
+
210
+ function isoMinusDays(iso, n) {
211
+ const [y, m, d] = iso.split('-').map(Number);
212
+ const dt = new Date(Date.UTC(y, m - 1, d - n));
213
+ return `${dt.getUTCFullYear()}-${String(dt.getUTCMonth() + 1).padStart(2, '0')}-${String(dt.getUTCDate()).padStart(2, '0')}`;
214
+ }
215
+
216
+ function compact(n) {
217
+ if (!Number.isFinite(n)) return '—';
218
+ if (n >= 1e9) return `${(n / 1e9).toFixed(2)}B`;
219
+ if (n >= 1e6) return `${(n / 1e6).toFixed(2)}M`;
220
+ if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`;
221
+ return String(Math.round(n));
222
+ }