@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.
- package/CONTRIBUTING.md +84 -0
- package/LICENSE +21 -0
- package/README.md +250 -0
- package/Refresh & Open Dashboard.command +22 -0
- package/SECURITY.md +42 -0
- package/bin/tokenflow.js +1342 -0
- package/docs/architecture.md +193 -0
- package/docs/cli.md +390 -0
- package/docs/configuration.md +281 -0
- package/docs/creating-provider.md +262 -0
- package/docs/data-model.md +213 -0
- package/docs/getting-started.md +266 -0
- package/docs/live-mode.md +199 -0
- package/docs/media/architecture-hero.svg +86 -0
- package/docs/media/cost-editorial-dark.png +0 -0
- package/docs/media/health-terminal-light.png +0 -0
- package/docs/media/menubar-dark.png +0 -0
- package/docs/media/menubar-light.png +0 -0
- package/docs/media/models-terminal-dark.png +0 -0
- package/docs/media/overview-aurora-dark.png +0 -0
- package/docs/media/time-aurora-light.png +0 -0
- package/docs/providers.md +309 -0
- package/docs/skill.md +64 -0
- package/docs/troubleshooting.md +207 -0
- package/examples/config.example.yaml +92 -0
- package/examples/demo-data/README.md +38 -0
- package/examples/demo-data/sample-usage.csv +11 -0
- package/package.json +74 -0
- package/scripts/build-dmg.sh +33 -0
- package/scripts/build-menubar-app.sh +67 -0
- package/scripts/lint.js +111 -0
- package/scripts/validate-install.js +140 -0
- package/skills/tokenflow/SKILL.md +392 -0
- package/skills/tokenflow/examples/config.yaml +92 -0
- package/skills/tokenflow/examples/generic-mapping.json +26 -0
- package/skills/tokenflow/examples/session-transcript.md +191 -0
- package/skills/tokenflow/providers/adapter-template.js +135 -0
- package/skills/tokenflow/providers/detection-matrix.md +142 -0
- package/skills/tokenflow/schemas/config.schema.json +107 -0
- package/skills/tokenflow/schemas/normalized-record.json +63 -0
- package/src/analytics/aggregate.js +247 -0
- package/src/analytics/anomalies.js +222 -0
- package/src/analytics/capacity.js +278 -0
- package/src/analytics/comparison.js +96 -0
- package/src/analytics/dimensions.js +230 -0
- package/src/analytics/efficiency.js +138 -0
- package/src/analytics/forecast.js +202 -0
- package/src/analytics/index.js +327 -0
- package/src/analytics/insights.js +283 -0
- package/src/analytics/milestones.js +91 -0
- package/src/analytics/peak.js +106 -0
- package/src/analytics/productivity.js +166 -0
- package/src/analytics/token-usage.js +267 -0
- package/src/commands/diagnostics.js +88 -0
- package/src/commands/digest.js +155 -0
- package/src/commands/models-compare.js +96 -0
- package/src/core/budget.js +142 -0
- package/src/core/bundle.js +191 -0
- package/src/core/config.js +202 -0
- package/src/core/delivery.js +109 -0
- package/src/core/geo.js +99 -0
- package/src/core/ingest.js +457 -0
- package/src/core/interface-map.js +55 -0
- package/src/core/jsonl.js +124 -0
- package/src/core/live-status.js +417 -0
- package/src/core/model-map.js +157 -0
- package/src/core/notify.js +83 -0
- package/src/core/pricing.js +288 -0
- package/src/core/prompt-analytics.js +127 -0
- package/src/core/registry.js +107 -0
- package/src/core/restore.js +261 -0
- package/src/core/schedule.js +120 -0
- package/src/core/schema.js +316 -0
- package/src/core/sqlite.js +96 -0
- package/src/core/store.js +493 -0
- package/src/core/sync.js +151 -0
- package/src/core/units.js +147 -0
- package/src/core/validate.js +123 -0
- package/src/core/watch.js +287 -0
- package/src/core/yaml.js +209 -0
- package/src/export/bundler.js +107 -0
- package/src/export/csv.js +100 -0
- package/src/export/html-snapshot.js +101 -0
- package/src/export/menubar.js +158 -0
- package/src/index.js +18 -0
- package/src/providers/anthropic/index.js +294 -0
- package/src/providers/cline/index.js +120 -0
- package/src/providers/cursor/index.js +143 -0
- package/src/providers/generic/index.js +268 -0
- package/src/providers/git/index.js +188 -0
- package/src/providers/headroom/index.js +114 -0
- package/src/providers/hermes/index.js +299 -0
- package/src/providers/mock/index.js +117 -0
- package/src/providers/openai/index.js +370 -0
- package/src/providers/opencode/index.js +245 -0
- package/src/sdk.js +46 -0
- package/src/server/server.js +264 -0
- package/src/ui/app.js +2473 -0
- package/src/ui/charts.js +925 -0
- package/src/ui/index.html +42 -0
- package/src/ui/styles.css +644 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Insight generation.
|
|
3
|
+
*
|
|
4
|
+
* Every insight is derived from the slice at render time. Nothing here is a
|
|
5
|
+
* template with a number dropped in — each generator computes its own
|
|
6
|
+
* condition and simply produces no insight when the condition isn't met or
|
|
7
|
+
* when the supporting sample is too small. That is why the panel can be empty:
|
|
8
|
+
* an empty insight panel is the honest output for a thin slice.
|
|
9
|
+
*/
|
|
10
|
+
import { compact, pct, signedPct, shortDate, hourWindow } from '../core/units.js';
|
|
11
|
+
import { previousPeriod } from './comparison.js';
|
|
12
|
+
import { filterCube, groupRows, sumRows, daysBetween } from './aggregate.js';
|
|
13
|
+
import { interfaceClass } from '../core/schema.js';
|
|
14
|
+
|
|
15
|
+
const MIN_DAYS_FOR_TREND = 14;
|
|
16
|
+
const MIN_REQUESTS = 20;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {object} c context
|
|
20
|
+
* @param {object} c.ix cube index
|
|
21
|
+
* @param {any[]} c.rows filtered rows
|
|
22
|
+
* @param {object} c.totals
|
|
23
|
+
* @param {any[]} c.series daily series
|
|
24
|
+
* @param {any[]} c.sessions
|
|
25
|
+
* @param {object} c.filters
|
|
26
|
+
* @param {{from:string,to:string}} c.range
|
|
27
|
+
* @param {object} c.hourly
|
|
28
|
+
* @param {object} c.peaks
|
|
29
|
+
* @param {object} c.composition
|
|
30
|
+
* @param {object} c.trend
|
|
31
|
+
* @param {object} [c.correlations]
|
|
32
|
+
* @param {object} [c.cost]
|
|
33
|
+
* @returns {{icon:string,text:string,kind:string,weight:number}[]}
|
|
34
|
+
*/
|
|
35
|
+
export function generateInsights(c) {
|
|
36
|
+
const out = [];
|
|
37
|
+
const add = (icon, text, kind, weight) => out.push({ icon, text, kind, weight });
|
|
38
|
+
const { ix, rows, totals, series, sessions, range, hourly, peaks, composition, trend } = c;
|
|
39
|
+
const activeDays = series.filter((d) => d.tokenActive).length;
|
|
40
|
+
|
|
41
|
+
if (!rows.length || totals.req < 1) {
|
|
42
|
+
return [{ icon: 'ℹ', text: 'No usage records match the current filters.', kind: 'empty', weight: 0 }];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---- trend -------------------------------------------------------------
|
|
46
|
+
if (trend && trend.change !== null && activeDays >= MIN_DAYS_FOR_TREND) {
|
|
47
|
+
const up = trend.change > 0;
|
|
48
|
+
add(
|
|
49
|
+
up ? '📈' : '📉',
|
|
50
|
+
`Token usage ${up ? 'increased' : 'decreased'} ${signedPct(trend.change)} over the last ${trend.window} days versus the ${trend.window} before.`,
|
|
51
|
+
'trend',
|
|
52
|
+
Math.abs(trend.change) * 100,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---- peak --------------------------------------------------------------
|
|
57
|
+
if (peaks?.peakDay) {
|
|
58
|
+
const median = series.filter((d) => d.tokenActive).map((d) => d.total).sort((a, b) => a - b)[Math.floor(activeDays / 2)] || 0;
|
|
59
|
+
const times = median ? peaks.peakDay.total / median : null;
|
|
60
|
+
add(
|
|
61
|
+
'🔥',
|
|
62
|
+
`Highest usage day was ${shortDate(peaks.peakDay.date)} at ${compact(peaks.peakDay.total)} tokens` +
|
|
63
|
+
(times && times > 1.5 ? ` — ${times.toFixed(1)}× a typical active day.` : '.'),
|
|
64
|
+
'peak',
|
|
65
|
+
60,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---- provider concentration -------------------------------------------
|
|
70
|
+
const byProvider = [...groupRows(rows, ix, (r) => r[ix.d.p]).values()].sort((a, b) => b.m.total - a.m.total);
|
|
71
|
+
if (byProvider.length && totals.total > 0) {
|
|
72
|
+
const top = byProvider[0];
|
|
73
|
+
add(
|
|
74
|
+
'🤖',
|
|
75
|
+
byProvider.length === 1
|
|
76
|
+
? `All measured usage in this slice went to one provider: ${label(top.key)}.`
|
|
77
|
+
: `${label(top.key)} accounts for ${pct(top.m.total / totals.total)} of tokens across ${byProvider.length} providers.`,
|
|
78
|
+
'provider',
|
|
79
|
+
50,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---- interface shift ---------------------------------------------------
|
|
84
|
+
const shift = interfaceShift(c);
|
|
85
|
+
if (shift) {
|
|
86
|
+
add('💻', shift, 'interface', 55);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---- cache -------------------------------------------------------------
|
|
90
|
+
if (composition?.cacheRatio !== null && composition?.cacheRatio !== undefined) {
|
|
91
|
+
const hit = composition.cacheHitRate;
|
|
92
|
+
add(
|
|
93
|
+
'⚡',
|
|
94
|
+
`Cache was ${pct(composition.cacheRatio)} of all token activity` +
|
|
95
|
+
(hit !== null ? `, and ${pct(hit)} of prompt tokens were served from cache rather than re-sent.` : '.'),
|
|
96
|
+
'cache',
|
|
97
|
+
45,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---- output vs prompt --------------------------------------------------
|
|
102
|
+
if (composition?.outputPerPromptToken) {
|
|
103
|
+
const r = composition.outputPerPromptToken;
|
|
104
|
+
const per = 1 / r;
|
|
105
|
+
add(
|
|
106
|
+
'🧮',
|
|
107
|
+
`Every generated token costs about ${per >= 10 ? Math.round(per) : per.toFixed(1)} prompt tokens sent (output is ${pct(r)} of all token activity` +
|
|
108
|
+
(composition.outputPerInput !== null ? `; output/fresh-input ratio ${composition.outputPerInput.toFixed(2)}` : '') +
|
|
109
|
+
`) — this usage is ${r < 0.1 ? 'strongly prompt-heavy' : r < 0.25 ? 'prompt-heavy' : 'output-heavy'}.`,
|
|
110
|
+
'composition',
|
|
111
|
+
35,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
if (composition?.reasoningShareOfOutput > 0.05) {
|
|
115
|
+
add('🧠', `Reasoning accounted for ${pct(composition.reasoningShareOfOutput)} of generated tokens.`, 'composition', 30);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---- time of day -------------------------------------------------------
|
|
119
|
+
if (hourly?.peakWindow && hourly.peakWindow.share > 0.15) {
|
|
120
|
+
const w = hourly.peakWindow;
|
|
121
|
+
let text = `Your busiest window is ${hourWindow(w.from, w.to)} (${pct(w.share)} of tokens).`;
|
|
122
|
+
if (hourly.secondaryWindow && hourly.secondaryWindow.share > 0.1) {
|
|
123
|
+
text += ` A second peak sits at ${hourWindow(hourly.secondaryWindow.from, hourly.secondaryWindow.to)}.`;
|
|
124
|
+
}
|
|
125
|
+
add(hourly.peakWindow.from >= 18 || hourly.peakWindow.from < 6 ? '🌙' : '☀️', text, 'hour', 40);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---- weekend behaviour -------------------------------------------------
|
|
129
|
+
const weekend = series.filter((d) => d.tokenActive && [5, 6].includes(dow(d.date)));
|
|
130
|
+
if (activeDays >= 14) {
|
|
131
|
+
if (weekend.length === 0) {
|
|
132
|
+
add('📅', 'No weekend usage at all in this period — your AI use is entirely on weekdays.', 'rhythm', 25);
|
|
133
|
+
} else if (weekend.length / activeDays > 0.25) {
|
|
134
|
+
add('📅', `${pct(weekend.length / activeDays)} of your active days were weekends.`, 'rhythm', 25);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---- model migration ---------------------------------------------------
|
|
139
|
+
for (const m of modelMigrations(c)) add('🔄', m.text, 'migration', m.weight);
|
|
140
|
+
|
|
141
|
+
// ---- new / dropped providers ------------------------------------------
|
|
142
|
+
for (const t of newOrStopped(c)) add(t.icon, t.text, 'change', t.weight);
|
|
143
|
+
|
|
144
|
+
// ---- sessions ----------------------------------------------------------
|
|
145
|
+
if (sessions.length >= 10 && activeDays > 0) {
|
|
146
|
+
const perDay = sessions.length / activeDays;
|
|
147
|
+
add('🧵', `${sessions.length.toLocaleString('en-US')} sessions over ${activeDays} active days — ${perDay.toFixed(1)} per day, ${compact(totals.total / sessions.length)} tokens each.`, 'sessions', 20);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---- data-honesty notes -----------------------------------------------
|
|
151
|
+
const naRate = totals.req ? totals.naAny / (totals.req * 4) : 0;
|
|
152
|
+
if (naRate > 0.05) {
|
|
153
|
+
add('⚠', `${pct(naRate)} of token fields in this slice were not reported by their source; those gaps are excluded from totals rather than counted as zero.`, 'quality', 15);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---- cost --------------------------------------------------------------
|
|
157
|
+
if (c.cost) {
|
|
158
|
+
if (c.cost.estimated !== null && c.cost.coverage !== null && c.cost.coverage > 0.5) {
|
|
159
|
+
add('💵', `Estimated spend ${money(c.cost.estimated)} (${pct(c.cost.coverage)} of requests priced) — about ${money(c.cost.perMillionTokens)} per million tokens.`, 'cost', 33);
|
|
160
|
+
} else if (c.cost.unpriced?.length) {
|
|
161
|
+
const top = c.cost.unpriced[0];
|
|
162
|
+
add('💵', `Cost is not shown because ${c.cost.unpriced.length} model(s) have no configured price — the largest is ${top.model} at ${compact(top.total)} tokens. Add pricing to enable cost analysis.`, 'cost', 33);
|
|
163
|
+
}
|
|
164
|
+
if (c.cost.measured !== null && c.cost.estimated !== null) {
|
|
165
|
+
const d = c.cost.estimated ? (c.cost.measured - c.cost.estimated) / c.cost.estimated : null;
|
|
166
|
+
if (d !== null && Math.abs(d) > 0.1) {
|
|
167
|
+
add('🔍', `Gateway-measured cost differs from the price-table estimate by ${signedPct(d)} — the measured figure covers only proxy-routed traffic.`, 'cost', 28);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---- correlation ------------------------------------------------------
|
|
173
|
+
if (c.correlations?.available) {
|
|
174
|
+
const top = c.correlations.metrics[0];
|
|
175
|
+
add(
|
|
176
|
+
'🔗',
|
|
177
|
+
`Daily AI usage and ${metricLabel(top.metric)} show a ${top.strength} ${top.direction} correlation (r = ${top.r.toFixed(2)}, n = ${top.n} days). Correlation only — not evidence that one caused the other.`,
|
|
178
|
+
'correlation',
|
|
179
|
+
48,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
out.sort((a, b) => b.weight - a.weight);
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------- helpers ---
|
|
188
|
+
|
|
189
|
+
function interfaceShift(c) {
|
|
190
|
+
const { ix, filters, range, totals } = c;
|
|
191
|
+
if (!range?.from || !range?.to) return null;
|
|
192
|
+
const len = daysBetween(range.from, range.to) + 1;
|
|
193
|
+
if (len < MIN_DAYS_FOR_TREND * 2) return null;
|
|
194
|
+
const prev = previousPeriod(range.from, range.to);
|
|
195
|
+
const share = (from, to) => {
|
|
196
|
+
const rows = filterCube(ix, { ...filters, from, to });
|
|
197
|
+
let cli = 0, all = 0;
|
|
198
|
+
for (const r of rows) {
|
|
199
|
+
const t = r[ix.m.in] + r[ix.m.out] + r[ix.m.cr] + r[ix.m.cw];
|
|
200
|
+
all += t;
|
|
201
|
+
if (interfaceClass(r[ix.d.i]) === 'CLI / headless') cli += t;
|
|
202
|
+
}
|
|
203
|
+
return { share: all ? cli / all : null, total: all };
|
|
204
|
+
};
|
|
205
|
+
const now = share(range.from, range.to);
|
|
206
|
+
const before = share(prev.from, prev.to);
|
|
207
|
+
if (now.share === null || before.share === null || before.total < 1000) return null;
|
|
208
|
+
const diff = now.share - before.share;
|
|
209
|
+
if (Math.abs(diff) < 0.05) return null;
|
|
210
|
+
return `CLI / headless usage moved from ${pct(before.share)} to ${pct(now.share)} of tokens versus the previous ${len} days.`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function modelMigrations(c) {
|
|
214
|
+
const { ix, filters, range } = c;
|
|
215
|
+
const out = [];
|
|
216
|
+
if (!range?.from || !range?.to) return out;
|
|
217
|
+
const len = daysBetween(range.from, range.to) + 1;
|
|
218
|
+
if (len < MIN_DAYS_FOR_TREND * 2) return out;
|
|
219
|
+
const prev = previousPeriod(range.from, range.to);
|
|
220
|
+
const totalsOf = (from, to) => {
|
|
221
|
+
const rows = filterCube(ix, { ...filters, from, to });
|
|
222
|
+
const g = groupRows(rows, ix, (r) => r[ix.d.m]);
|
|
223
|
+
const all = [...g.values()].reduce((a, x) => a + x.m.total, 0);
|
|
224
|
+
return { g, all };
|
|
225
|
+
};
|
|
226
|
+
const a = totalsOf(prev.from, prev.to);
|
|
227
|
+
const b = totalsOf(range.from, range.to);
|
|
228
|
+
if (!a.all || !b.all) return out;
|
|
229
|
+
const keys = new Set([...a.g.keys(), ...b.g.keys()]);
|
|
230
|
+
const moves = [];
|
|
231
|
+
for (const k of keys) {
|
|
232
|
+
const sa = (a.g.get(k)?.m.total ?? 0) / a.all;
|
|
233
|
+
const sb = (b.g.get(k)?.m.total ?? 0) / b.all;
|
|
234
|
+
if (Math.abs(sb - sa) < 0.08) continue;
|
|
235
|
+
moves.push({ model: k, from: sa, to: sb, diff: sb - sa });
|
|
236
|
+
}
|
|
237
|
+
moves.sort((x, y) => Math.abs(y.diff) - Math.abs(x.diff));
|
|
238
|
+
const risers = moves.filter((m) => m.diff > 0).slice(0, 1);
|
|
239
|
+
const fallers = moves.filter((m) => m.diff < 0).slice(0, 1);
|
|
240
|
+
if (risers.length && fallers.length) {
|
|
241
|
+
out.push({
|
|
242
|
+
text: `Model mix is shifting: ${fallers[0].model} fell from ${pct(fallers[0].from)} to ${pct(fallers[0].to)} of tokens while ${risers[0].model} rose from ${pct(risers[0].from)} to ${pct(risers[0].to)}.`,
|
|
243
|
+
weight: 52,
|
|
244
|
+
});
|
|
245
|
+
} else if (risers.length) {
|
|
246
|
+
out.push({ text: `${risers[0].model} grew from ${pct(risers[0].from)} to ${pct(risers[0].to)} of tokens versus the previous period.`, weight: 44 });
|
|
247
|
+
}
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function newOrStopped(c) {
|
|
252
|
+
const { ix, filters, range } = c;
|
|
253
|
+
const out = [];
|
|
254
|
+
if (!range?.from || !range?.to) return out;
|
|
255
|
+
const len = daysBetween(range.from, range.to) + 1;
|
|
256
|
+
if (len < 7) return out;
|
|
257
|
+
const prev = previousPeriod(range.from, range.to);
|
|
258
|
+
const keysOf = (from, to, dim) => new Set(filterCube(ix, { ...filters, from, to }).map((r) => r[ix.d[dim]]));
|
|
259
|
+
for (const [dim, noun] of [['p', 'provider'], ['c', 'client']]) {
|
|
260
|
+
const before = keysOf(prev.from, prev.to, dim);
|
|
261
|
+
const now = keysOf(range.from, range.to, dim);
|
|
262
|
+
const added = [...now].filter((k) => !before.has(k));
|
|
263
|
+
const gone = [...before].filter((k) => !now.has(k));
|
|
264
|
+
if (before.size && added.length) out.push({ icon: '✨', text: `New ${noun}${added.length > 1 ? 's' : ''} this period: ${added.map(label).join(', ')}.`, weight: 42 });
|
|
265
|
+
if (before.size && gone.length) out.push({ icon: '🚪', text: `No usage this period from ${noun}${gone.length > 1 ? 's' : ''} you used before: ${gone.map(label).join(', ')}.`, weight: 38 });
|
|
266
|
+
}
|
|
267
|
+
return out;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function metricLabel(k) {
|
|
271
|
+
return { commits: 'git commits', insertions: 'lines added', files: 'files changed', aiLines: 'AI-authored lines', edits: 'AI edit events' }[k] || k;
|
|
272
|
+
}
|
|
273
|
+
function label(s) {
|
|
274
|
+
return String(s).replace(/^\w/, (m) => m.toUpperCase());
|
|
275
|
+
}
|
|
276
|
+
function money(n) {
|
|
277
|
+
if (n === null || n === undefined) return '—';
|
|
278
|
+
return n < 1 ? `$${n.toFixed(3)}` : n < 1000 ? `$${n.toFixed(2)}` : `$${(n / 1000).toFixed(1)}K`;
|
|
279
|
+
}
|
|
280
|
+
function dow(iso) {
|
|
281
|
+
const [y, m, d] = iso.split('-').map(Number);
|
|
282
|
+
return (new Date(Date.UTC(y, m - 1, d)).getUTCDay() + 6) % 7;
|
|
283
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Milestones — moments worth celebrating, computed from the daily series.
|
|
3
|
+
*
|
|
4
|
+
* Rules of the road:
|
|
5
|
+
* - every milestone is a MEASURED fact about the dataset (a record broken,
|
|
6
|
+
* a round spend threshold reached for the first time, a streak hitting a
|
|
7
|
+
* multiple of seven), never flattery;
|
|
8
|
+
* - ids are stable (`type:date`), so the watcher can announce exactly the
|
|
9
|
+
* ones it has never announced before;
|
|
10
|
+
* - quiet data produces an empty list. No participation trophies.
|
|
11
|
+
*/
|
|
12
|
+
import { compact } from '../core/units.js';
|
|
13
|
+
|
|
14
|
+
export const COST_STEPS = [10, 25, 50, 100, 250, 500, 1000];
|
|
15
|
+
export const STREAK_STEP = 7;
|
|
16
|
+
|
|
17
|
+
/** @typedef {{id:string, type:'biggest_day'|'cost_threshold'|'streak', icon:string, title:string, detail:string, date:string}} Milestone */
|
|
18
|
+
|
|
19
|
+
function money(n) {
|
|
20
|
+
if (n >= 1000) return `$${(n / 1000).toFixed(1)}K`;
|
|
21
|
+
return `$${n.toFixed(2)}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const isActiveDay = (d) => Boolean(d.tokenActive) || Boolean(d.active);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {{key:string,total:number,cost?:number|null,tokenActive?:boolean,
|
|
28
|
+
* active?:boolean}[]} daily calendar-complete series whose LAST entry
|
|
29
|
+
* is today (today may still be partial)
|
|
30
|
+
* @returns {Milestone[]}
|
|
31
|
+
*/
|
|
32
|
+
export function detectMilestones(daily) {
|
|
33
|
+
/** @type {Milestone[]} */
|
|
34
|
+
const out = [];
|
|
35
|
+
if (!Array.isArray(daily) || daily.length < 2) return out;
|
|
36
|
+
|
|
37
|
+
const today = daily[daily.length - 1];
|
|
38
|
+
const past = daily.slice(0, -1);
|
|
39
|
+
const todayTotal = today.total || 0;
|
|
40
|
+
|
|
41
|
+
// ---- biggest measured day -------------------------------------------------
|
|
42
|
+
// Requires real history above zero so day one is not a hollow record.
|
|
43
|
+
const prevMax = past.reduce((m, d) => Math.max(m, d.total || 0), 0);
|
|
44
|
+
if (prevMax > 0 && todayTotal > prevMax) {
|
|
45
|
+
out.push({
|
|
46
|
+
id: `biggest_day:${today.key}`,
|
|
47
|
+
type: 'biggest_day',
|
|
48
|
+
icon: '🏆',
|
|
49
|
+
title: 'Biggest day yet',
|
|
50
|
+
date: today.key,
|
|
51
|
+
detail: `${compact(todayTotal)} tokens — above your previous best of ${compact(prevMax)}.`,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---- first-ever spend threshold ---------------------------------------------
|
|
56
|
+
const todayCost = Number(today.cost) || 0;
|
|
57
|
+
if (todayCost > 0) {
|
|
58
|
+
// Highest newly-reached step wins: a single $120 day after a $8 peak is
|
|
59
|
+
// "First $100 day", not two celebrations.
|
|
60
|
+
for (const step of [...COST_STEPS].reverse()) {
|
|
61
|
+
const crossedBefore = past.some((d) => (Number(d.cost) || 0) >= step);
|
|
62
|
+
if (!crossedBefore && todayCost >= step) {
|
|
63
|
+
out.push({
|
|
64
|
+
id: `cost_${step}:${today.key}`,
|
|
65
|
+
type: 'cost_threshold',
|
|
66
|
+
icon: '💰',
|
|
67
|
+
title: `First $${step} day`,
|
|
68
|
+
date: today.key,
|
|
69
|
+
detail: `Estimated spend reached ${money(todayCost)} today — past $${step} for the first time.`,
|
|
70
|
+
});
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---- consecutive active days -------------------------------------------------
|
|
77
|
+
let streak = 0;
|
|
78
|
+
for (let i = daily.length - 1; i >= 0 && isActiveDay(daily[i]); i--) streak++;
|
|
79
|
+
if (streak >= STREAK_STEP && streak % STREAK_STEP === 0) {
|
|
80
|
+
out.push({
|
|
81
|
+
id: `streak_${streak}:${today.key}`,
|
|
82
|
+
type: 'streak',
|
|
83
|
+
icon: '🔥',
|
|
84
|
+
title: `${streak}-day streak`,
|
|
85
|
+
date: today.key,
|
|
86
|
+
detail: `${streak} consecutive active days.`,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Peak analysis. Every value here is an argmax over the filtered slice — there
|
|
3
|
+
* are no thresholds and nothing is hardcoded.
|
|
4
|
+
*/
|
|
5
|
+
import { groupRows, rank, weekStart, monthKey, finalize, zeroMeasures, addInto } from './aggregate.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {any[][]} rows filtered cube rows
|
|
9
|
+
* @param {object} ix
|
|
10
|
+
* @param {{series?:any[], topN?:number, sessions?:any[]}} opt
|
|
11
|
+
*/
|
|
12
|
+
export function calculatePeakUsage(rows, ix, opt = {}) {
|
|
13
|
+
const topN = opt.topN ?? 10;
|
|
14
|
+
const byDay = rank(rows, ix, (r) => r[ix.d.d]);
|
|
15
|
+
const byWeek = rank(rows, ix, (r) => weekStart(r[ix.d.d]));
|
|
16
|
+
const byMonth = rank(rows, ix, (r) => monthKey(r[ix.d.d]));
|
|
17
|
+
const byHour = rank(rows, ix, (r) => r[ix.d.h]);
|
|
18
|
+
const byProvider = rank(rows, ix, (r) => r[ix.d.p]);
|
|
19
|
+
const byModel = rank(rows, ix, (r) => r[ix.d.m]);
|
|
20
|
+
const byInterface = rank(rows, ix, (r) => r[ix.d.i]);
|
|
21
|
+
const byProject = rank(rows, ix, (r) => r[ix.d.pj]);
|
|
22
|
+
|
|
23
|
+
const maxBy = (groups, field) => {
|
|
24
|
+
let best = null;
|
|
25
|
+
for (const g of groups) if (!best || g.m[field] > best.m[field]) best = g;
|
|
26
|
+
return best ? { key: best.key, value: best.m[field], total: best.m.total } : null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Lowest *active* day: a zero-token calendar gap is not a "low day".
|
|
30
|
+
const activeDays = byDay.filter((g) => g.m.req > 0 && g.m.total > 0);
|
|
31
|
+
const lowest = activeDays.length
|
|
32
|
+
? activeDays.reduce((a, b) => (b.m.total < a.m.total ? b : a))
|
|
33
|
+
: null;
|
|
34
|
+
|
|
35
|
+
const peakSession = opt.sessions && opt.sessions.length
|
|
36
|
+
? opt.sessions.reduce((a, b) => ((b.total || 0) > (a.total || 0) ? b : a))
|
|
37
|
+
: null;
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
topDays: byDay.slice(0, topN).map((g) => ({
|
|
41
|
+
date: g.key, total: g.m.total, input: g.m.in, output: g.m.out,
|
|
42
|
+
cache: g.m.cr + g.m.cw, requests: g.m.req,
|
|
43
|
+
})),
|
|
44
|
+
peakDay: byDay[0] ? { date: byDay[0].key, total: byDay[0].m.total } : null,
|
|
45
|
+
lowestActiveDay: lowest ? { date: lowest.key, total: lowest.m.total } : null,
|
|
46
|
+
peakWeek: byWeek[0] ? { weekStart: byWeek[0].key, total: byWeek[0].m.total } : null,
|
|
47
|
+
peakMonth: byMonth[0] ? { month: byMonth[0].key, total: byMonth[0].m.total } : null,
|
|
48
|
+
peakHour: byHour[0] ? { hour: Number(byHour[0].key), total: byHour[0].m.total } : null,
|
|
49
|
+
peakProvider: byProvider[0] ? { provider: byProvider[0].key, total: byProvider[0].m.total } : null,
|
|
50
|
+
peakModel: byModel[0] ? { model: byModel[0].key, total: byModel[0].m.total } : null,
|
|
51
|
+
peakInterface: byInterface[0] ? { interface: byInterface[0].key, total: byInterface[0].m.total } : null,
|
|
52
|
+
peakProject: byProject[0] ? { project: byProject[0].key, total: byProject[0].m.total } : null,
|
|
53
|
+
highestOutputDay: maxBy(byDay, 'out'),
|
|
54
|
+
highestInputDay: maxBy(byDay, 'in'),
|
|
55
|
+
highestCacheDay: (() => {
|
|
56
|
+
let best = null;
|
|
57
|
+
for (const g of byDay) {
|
|
58
|
+
const c = g.m.cr + g.m.cw;
|
|
59
|
+
if (!best || c > best.value) best = { key: g.key, value: c, total: g.m.total };
|
|
60
|
+
}
|
|
61
|
+
return best;
|
|
62
|
+
})(),
|
|
63
|
+
busiestDayByRequests: maxBy(byDay, 'req'),
|
|
64
|
+
peakSession: peakSession
|
|
65
|
+
? {
|
|
66
|
+
id: peakSession.id, total: peakSession.total, model: peakSession.m,
|
|
67
|
+
project: peakSession.pj, date: peakSession.d, requests: peakSession.req,
|
|
68
|
+
durationMs: peakSession.durationMs,
|
|
69
|
+
}
|
|
70
|
+
: null,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Per-day detail for the heatmap drill-down. */
|
|
75
|
+
export function dayDetail(rows, ix, date, sessions = []) {
|
|
76
|
+
const dayRows = rows.filter((r) => r[ix.d.d] === date);
|
|
77
|
+
if (!dayRows.length) return null;
|
|
78
|
+
const m = finalize(dayRows.reduce((acc, r) => addInto(acc, r, ix), zeroMeasures()));
|
|
79
|
+
const top = (dim) => {
|
|
80
|
+
const g = rank(dayRows, ix, (r) => r[ix.d[dim]], { limit: 3 });
|
|
81
|
+
return g.map((x) => ({ key: x.key, total: x.m.total, share: m.total ? x.m.total / m.total : null }));
|
|
82
|
+
};
|
|
83
|
+
const daySessions = sessions.filter((s) => s.d === date);
|
|
84
|
+
return {
|
|
85
|
+
date,
|
|
86
|
+
total: m.total,
|
|
87
|
+
input: m.in,
|
|
88
|
+
output: m.out,
|
|
89
|
+
cacheRead: m.cr,
|
|
90
|
+
cacheWrite: m.cw,
|
|
91
|
+
cache: m.cr + m.cw,
|
|
92
|
+
reasoning: m.rs,
|
|
93
|
+
requests: m.req,
|
|
94
|
+
cost: m.costReq > 0 ? m.cost : null,
|
|
95
|
+
sessions: daySessions.length,
|
|
96
|
+
providers: top('p'),
|
|
97
|
+
models: top('m'),
|
|
98
|
+
interfaces: top('i'),
|
|
99
|
+
projects: top('pj'),
|
|
100
|
+
hours: (() => {
|
|
101
|
+
const h = Array.from({ length: 24 }, () => 0);
|
|
102
|
+
for (const r of dayRows) h[r[ix.d.h]] += r[ix.m.in] + r[ix.m.out] + r[ix.m.cr] + r[ix.m.cw];
|
|
103
|
+
return h;
|
|
104
|
+
})(),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI activity / productivity **proxies** — and correlations, never causation.
|
|
3
|
+
*
|
|
4
|
+
* Token counts do not measure productivity. What they measure is how much
|
|
5
|
+
* model capacity a day consumed. This module therefore does two separate
|
|
6
|
+
* things and labels them differently:
|
|
7
|
+
*
|
|
8
|
+
* 1. Activity proxies — sessions, sessions/day, tokens/session, active
|
|
9
|
+
* workdays, session length distribution. These are descriptions of
|
|
10
|
+
* behaviour, presented as such.
|
|
11
|
+
*
|
|
12
|
+
* 2. Correlation — when an independent work signal exists (git commits,
|
|
13
|
+
* AI-authored lines, files changed), the Pearson coefficient between it
|
|
14
|
+
* and daily AI usage, over the OVERLAPPING days only, with n reported.
|
|
15
|
+
* Below `minOverlap` days it returns `null` and an explanation rather
|
|
16
|
+
* than a number, because a correlation computed on four days is not a
|
|
17
|
+
* finding, it is decoration.
|
|
18
|
+
*/
|
|
19
|
+
import { dowOf } from './token-usage.js';
|
|
20
|
+
|
|
21
|
+
export function calculateActivityProxies(series, sessions, totals) {
|
|
22
|
+
const active = series.filter((d) => d.tokenActive);
|
|
23
|
+
const byDay = new Map();
|
|
24
|
+
for (const s of sessions) byDay.set(s.d, (byDay.get(s.d) || 0) + 1);
|
|
25
|
+
const sessionDays = byDay.size;
|
|
26
|
+
const weekdayActive = active.filter((d) => dowOf(d.date) < 5).length;
|
|
27
|
+
const weekendActive = active.length - weekdayActive;
|
|
28
|
+
return {
|
|
29
|
+
sessions: sessions.length,
|
|
30
|
+
activeDays: active.length,
|
|
31
|
+
sessionDays,
|
|
32
|
+
sessionsPerActiveDay: sessionDays ? sessions.length / sessionDays : null,
|
|
33
|
+
tokensPerSession: sessions.length ? totals.total / sessions.length : null,
|
|
34
|
+
requestsPerSession: sessions.length ? totals.req / sessions.length : null,
|
|
35
|
+
outputPerSession: sessions.length ? totals.out / sessions.length : null,
|
|
36
|
+
weekdayActiveDays: weekdayActive,
|
|
37
|
+
weekendActiveDays: weekendActive,
|
|
38
|
+
weekendShare: active.length ? weekendActive / active.length : null,
|
|
39
|
+
projects: new Set(sessions.map((s) => s.pj)).size,
|
|
40
|
+
repositories: new Set(sessions.map((s) => s.rp)).size,
|
|
41
|
+
medianSessionsPerDay: sessionDays
|
|
42
|
+
? [...byDay.values()].sort((a, b) => a - b)[Math.floor(sessionDays / 2)]
|
|
43
|
+
: null,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Daily work signals from the activity rollup.
|
|
49
|
+
* @param {{rows:Record<string,object>}} activity
|
|
50
|
+
* @param {{from?:string,to?:string,projects?:string[]}} [f]
|
|
51
|
+
*/
|
|
52
|
+
export function calculateWorkSeries(activity, f = {}) {
|
|
53
|
+
const byDay = new Map();
|
|
54
|
+
const pj = f.projects && f.projects.length ? new Set(f.projects) : null;
|
|
55
|
+
for (const a of Object.values(activity.rows || {})) {
|
|
56
|
+
if (f.from && a.d < f.from) continue;
|
|
57
|
+
if (f.to && a.d > f.to) continue;
|
|
58
|
+
if (pj && !pj.has(a.pj)) continue;
|
|
59
|
+
const e = byDay.get(a.d) || { date: a.d, commits: 0, files: 0, insertions: 0, deletions: 0, aiLines: 0, humanLines: 0, edits: 0 };
|
|
60
|
+
e.commits += a.commits || 0;
|
|
61
|
+
e.files += a.files || 0;
|
|
62
|
+
e.insertions += a.ins || 0;
|
|
63
|
+
e.deletions += a.del || 0;
|
|
64
|
+
e.aiLines += a.aiLines || 0;
|
|
65
|
+
e.humanLines += a.humanLines || 0;
|
|
66
|
+
e.edits += a.edits || 0;
|
|
67
|
+
byDay.set(a.d, e);
|
|
68
|
+
}
|
|
69
|
+
return [...byDay.values()].sort((a, b) => (a.date < b.date ? -1 : 1));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Pearson r. Returns null for degenerate input rather than 0. */
|
|
73
|
+
export function pearson(xs, ys) {
|
|
74
|
+
const n = Math.min(xs.length, ys.length);
|
|
75
|
+
if (n < 3) return null;
|
|
76
|
+
let sx = 0, sy = 0;
|
|
77
|
+
for (let i = 0; i < n; i++) { sx += xs[i]; sy += ys[i]; }
|
|
78
|
+
const mx = sx / n, my = sy / n;
|
|
79
|
+
let num = 0, dx = 0, dy = 0;
|
|
80
|
+
for (let i = 0; i < n; i++) {
|
|
81
|
+
const a = xs[i] - mx, b = ys[i] - my;
|
|
82
|
+
num += a * b; dx += a * a; dy += b * b;
|
|
83
|
+
}
|
|
84
|
+
if (dx === 0 || dy === 0) return null;
|
|
85
|
+
return num / Math.sqrt(dx * dy);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param {any[]} usageSeries output of calculateDailyUsage
|
|
90
|
+
* @param {any[]} workSeries output of calculateWorkSeries
|
|
91
|
+
* @param {{minOverlap?:number, metrics?:string[]}} [opt]
|
|
92
|
+
*/
|
|
93
|
+
export function calculateCorrelations(usageSeries, workSeries, opt = {}) {
|
|
94
|
+
const minOverlap = opt.minOverlap ?? 10;
|
|
95
|
+
const metrics = opt.metrics ?? ['commits', 'insertions', 'files', 'aiLines', 'edits'];
|
|
96
|
+
const usageBy = new Map(usageSeries.map((d) => [d.date, d]));
|
|
97
|
+
const workBy = new Map(workSeries.map((d) => [d.date, d]));
|
|
98
|
+
const overlap = [...workBy.keys()].filter((d) => usageBy.has(d)).sort();
|
|
99
|
+
|
|
100
|
+
const base = {
|
|
101
|
+
available: false,
|
|
102
|
+
overlapDays: overlap.length,
|
|
103
|
+
minOverlap,
|
|
104
|
+
window: overlap.length ? { from: overlap[0], to: overlap[overlap.length - 1] } : null,
|
|
105
|
+
reason: null,
|
|
106
|
+
metrics: [],
|
|
107
|
+
note: 'Correlation only. AI token usage is not a measure of productivity, and a relationship here does not imply that one caused the other.',
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
if (!workSeries.length) {
|
|
111
|
+
return { ...base, reason: 'No independent work signal is available. Enable the git or cursor adapter to correlate usage with shipped work.' };
|
|
112
|
+
}
|
|
113
|
+
if (overlap.length < minOverlap) {
|
|
114
|
+
return {
|
|
115
|
+
...base,
|
|
116
|
+
reason: `Only ${overlap.length} day(s) where both AI usage and work activity were recorded — at least ${minOverlap} are needed before a correlation means anything.`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const usage = overlap.map((d) => usageBy.get(d).total);
|
|
121
|
+
const out = [];
|
|
122
|
+
for (const key of metrics) {
|
|
123
|
+
const work = overlap.map((d) => workBy.get(d)[key] || 0);
|
|
124
|
+
if (work.every((v) => v === 0)) continue;
|
|
125
|
+
const r = pearson(usage, work);
|
|
126
|
+
if (r === null) continue;
|
|
127
|
+
out.push({
|
|
128
|
+
metric: key,
|
|
129
|
+
r,
|
|
130
|
+
strength: Math.abs(r) >= 0.7 ? 'strong' : Math.abs(r) >= 0.4 ? 'moderate' : Math.abs(r) >= 0.2 ? 'weak' : 'negligible',
|
|
131
|
+
direction: r > 0 ? 'positive' : 'negative',
|
|
132
|
+
n: overlap.length,
|
|
133
|
+
series: overlap.map((d, i) => ({ date: d, usage: usage[i], work: work[i] })),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
out.sort((a, b) => Math.abs(b.r) - Math.abs(a.r));
|
|
137
|
+
return { ...base, available: out.length > 0, metrics: out, reason: out.length ? null : 'No work metric had enough variation to correlate.' };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Split a day set into higher- and lower-usage halves and compare the work
|
|
142
|
+
* signal between them. Reported as a difference between groups — explicitly not
|
|
143
|
+
* as "AI made you N% faster".
|
|
144
|
+
*/
|
|
145
|
+
export function calculateActivityContrast(usageSeries, workSeries, metric = 'insertions') {
|
|
146
|
+
const usageBy = new Map(usageSeries.map((d) => [d.date, d.total]));
|
|
147
|
+
const pairs = workSeries
|
|
148
|
+
.filter((w) => usageBy.has(w.date) && usageBy.get(w.date) > 0)
|
|
149
|
+
.map((w) => ({ date: w.date, usage: usageBy.get(w.date), work: w[metric] || 0 }));
|
|
150
|
+
if (pairs.length < 8) return null;
|
|
151
|
+
const sorted = [...pairs].sort((a, b) => a.usage - b.usage);
|
|
152
|
+
const half = Math.floor(sorted.length / 2);
|
|
153
|
+
const low = sorted.slice(0, half);
|
|
154
|
+
const high = sorted.slice(-half);
|
|
155
|
+
const avg = (a) => a.reduce((x, y) => x + y.work, 0) / a.length;
|
|
156
|
+
const lo = avg(low);
|
|
157
|
+
const hi = avg(high);
|
|
158
|
+
return {
|
|
159
|
+
metric,
|
|
160
|
+
n: pairs.length,
|
|
161
|
+
lowUsageMean: lo,
|
|
162
|
+
highUsageMean: hi,
|
|
163
|
+
difference: lo > 0 ? (hi - lo) / lo : null,
|
|
164
|
+
note: 'Days grouped by AI usage; the difference is an observed association between the two groups, not an effect of AI usage.',
|
|
165
|
+
};
|
|
166
|
+
}
|