@vimoxshah/tokenflow 1.1.2 → 1.2.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 (86) hide show
  1. package/CHANGELOG.md +180 -0
  2. package/Dockerfile.team +20 -0
  3. package/README.md +30 -11
  4. package/bin/tokenflow.js +147 -12
  5. package/design/tokens.yaml +330 -0
  6. package/docs/architecture.md +5 -4
  7. package/docs/cli.md +204 -0
  8. package/docs/configuration.md +117 -2
  9. package/docs/design-system.md +187 -0
  10. package/docs/exports-and-budgets.md +85 -0
  11. package/docs/guard-codex.md +132 -0
  12. package/docs/ledger.md +144 -0
  13. package/docs/live-mode.md +40 -0
  14. package/docs/media/overview-aurora-dark.png +0 -0
  15. package/docs/media/receipts-aurora-dark.png +0 -0
  16. package/docs/providers-otel.md +179 -0
  17. package/docs/providers.md +54 -1
  18. package/docs/receipt-schema.md +74 -0
  19. package/docs/roadmap.md +182 -0
  20. package/docs/team-server.md +170 -0
  21. package/docs/ui-views.md +322 -0
  22. package/package.json +7 -2
  23. package/schemas/receipt.v0.json +160 -0
  24. package/scripts/build-menubar-app.sh +3 -1
  25. package/scripts/design-build.js +475 -0
  26. package/src/analytics/anatomy.js +467 -0
  27. package/src/analytics/branch-compare.js +159 -0
  28. package/src/analytics/cache-health.js +141 -0
  29. package/src/analytics/live-view.js +266 -0
  30. package/src/analytics/receipt-schema.js +214 -0
  31. package/src/analytics/receipt.js +709 -0
  32. package/src/analytics/rhythm.js +184 -0
  33. package/src/analytics/whatif.js +263 -0
  34. package/src/commands/budget-scopes.js +133 -0
  35. package/src/commands/doctor-checks.js +400 -0
  36. package/src/commands/guard.js +531 -0
  37. package/src/commands/hooks.js +238 -0
  38. package/src/commands/pricing-diff.js +316 -0
  39. package/src/commands/receipt.js +226 -0
  40. package/src/commands/team-serve.js +407 -0
  41. package/src/commands/week.js +86 -0
  42. package/src/core/annotations.js +97 -0
  43. package/src/core/budget.js +33 -0
  44. package/src/core/bundle.js +45 -2
  45. package/src/core/ingest.js +33 -0
  46. package/src/core/live-status.js +227 -2
  47. package/src/core/policy.js +103 -0
  48. package/src/core/receipt-note.js +123 -0
  49. package/src/core/repo.js +64 -0
  50. package/src/core/sync.js +163 -26
  51. package/src/core/team.js +0 -0
  52. package/src/export/html-snapshot.js +28 -1
  53. package/src/export/menubar.js +21 -0
  54. package/src/export/receipt-card.js +210 -0
  55. package/src/export/week-card.js +185 -0
  56. package/src/providers/mock/index.js +383 -52
  57. package/src/providers/openai/index.js +31 -1
  58. package/src/providers/otel/index.js +656 -0
  59. package/src/server/routes/annotations.js +42 -0
  60. package/src/server/routes/cache-health.js +95 -0
  61. package/src/server/routes/index.js +54 -0
  62. package/src/server/routes/session.js +157 -0
  63. package/src/server/server.js +47 -1
  64. package/src/ui/app.js +541 -308
  65. package/src/ui/charts.js +95 -0
  66. package/src/ui/first-run.js +144 -0
  67. package/src/ui/index.html +4 -1
  68. package/src/ui/palette.js +335 -0
  69. package/src/ui/styles/anatomy.css +117 -0
  70. package/src/ui/styles/annotations.css +40 -0
  71. package/src/ui/styles/branches.css +99 -0
  72. package/src/ui/styles/cache.css +6 -0
  73. package/src/ui/styles/first-run.css +31 -0
  74. package/src/ui/styles/live.css +100 -0
  75. package/src/ui/styles/palette.css +85 -0
  76. package/src/ui/styles/rhythm.css +8 -0
  77. package/src/ui/styles/whatif.css +55 -0
  78. package/src/ui/styles.css +303 -196
  79. package/src/ui/views/anatomy.js +567 -0
  80. package/src/ui/views/annotations.js +121 -0
  81. package/src/ui/views/branches.js +304 -0
  82. package/src/ui/views/cache.js +232 -0
  83. package/src/ui/views/index.js +85 -0
  84. package/src/ui/views/live.js +683 -0
  85. package/src/ui/views/rhythm.js +206 -0
  86. package/src/ui/views/whatif.js +196 -0
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Rhythm: how the work happens — focus, switching, and when a turn costs
3
+ * most.
4
+ *
5
+ * Pure functions over two shapes only:
6
+ * - a session (src/core/store.js `sessionList()`): `{ d, pj, total,
7
+ * durationMs, req, ... }`.
8
+ * - a per-hour rollup (src/analytics/token-usage.js
9
+ * `calculateHourlyUsage()` bucket): `{ hour, total, req, cost, costReq,
10
+ * ... }`.
11
+ *
12
+ * No Node imports (besides the shared, dependency-free formatter module), so
13
+ * this runs the same in the browser, the CLI and the offline snapshot.
14
+ */
15
+ import { pct, usd, compact, hourLabel, shortDate } from '../core/units.js';
16
+
17
+ /** A deep-work session runs this long or longer without a break. */
18
+ export const DEEP_WORK_MS = 45 * 60 * 1000;
19
+ /** ...or, when duration cannot be measured, spans at least this many turns. */
20
+ export const DEEP_WORK_TURNS = 40;
21
+
22
+ function hasKnownDuration(s) {
23
+ return Number.isFinite(s.durationMs);
24
+ }
25
+
26
+ function isDeepWork(s) {
27
+ if (hasKnownDuration(s)) return s.durationMs >= DEEP_WORK_MS;
28
+ const turns = Number.isFinite(s.req) ? s.req : 0;
29
+ return turns >= DEEP_WORK_TURNS;
30
+ }
31
+
32
+ /**
33
+ * Deep-work sessions: count, share of sessions, share of tokens, longest.
34
+ * A deep-work session runs 45 minutes or longer of continuous activity, or —
35
+ * when duration is unknown — spans 40 or more turns (requests).
36
+ * @param {any[]} sessions
37
+ * @returns {{count:number, total:number, shareOfSessions:number|null, shareOfTokens:number|null, longest:any|null}}
38
+ */
39
+ export function deepWork(sessions) {
40
+ if (!sessions || !sessions.length) {
41
+ return { count: 0, total: 0, shareOfSessions: null, shareOfTokens: null, longest: null };
42
+ }
43
+ const flagged = sessions.filter(isDeepWork);
44
+ const totalTokens = sessions.reduce((a, s) => a + (s.total || 0), 0);
45
+ const deepTokens = flagged.reduce((a, s) => a + (s.total || 0), 0);
46
+ let longest = null;
47
+ for (const s of sessions) {
48
+ if (!hasKnownDuration(s)) continue;
49
+ if (!longest || s.durationMs > longest.durationMs) longest = s;
50
+ }
51
+ return {
52
+ count: flagged.length,
53
+ total: sessions.length,
54
+ shareOfSessions: sessions.length ? flagged.length / sessions.length : null,
55
+ shareOfTokens: totalTokens > 0 ? deepTokens / totalTokens : null,
56
+ longest,
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Project-switching per day: distinct projects touched that day, minus one.
62
+ * Only days that actually have a session are counted — a day with no
63
+ * sessions has no measurable switching, which is not the same as zero
64
+ * switching, so it is left out rather than filled in as a fabricated 0.
65
+ * @param {any[]} sessions
66
+ * @returns {{days:{date:string, projects:number, switches:number}[], average:number|null, worst:{date:string, projects:number, switches:number}|null}}
67
+ */
68
+ export function switching(sessions) {
69
+ if (!sessions || !sessions.length) return { days: [], average: null, worst: null };
70
+ const byDay = new Map();
71
+ for (const s of sessions) {
72
+ const day = s.d;
73
+ if (day === null || day === undefined) continue;
74
+ let set = byDay.get(day);
75
+ if (!set) { set = new Set(); byDay.set(day, set); }
76
+ set.add(s.pj ?? 'unknown');
77
+ }
78
+ const days = [...byDay.entries()]
79
+ .map(([date, projects]) => ({ date, projects: projects.size, switches: Math.max(0, projects.size - 1) }))
80
+ .sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
81
+ if (!days.length) return { days, average: null, worst: null };
82
+ const total = days.reduce((a, d) => a + d.switches, 0);
83
+ const average = total / days.length;
84
+ let worst = days[0];
85
+ for (const d of days.slice(1)) {
86
+ if (d.switches > worst.switches || (d.switches === worst.switches && d.date > worst.date)) worst = d;
87
+ }
88
+ return { days, average, worst };
89
+ }
90
+
91
+ /**
92
+ * Cost (or, failing that, tokens) per request by hour of day, from the
93
+ * per-hour rollups behind `ctx.view` (the `hourly.buckets` this dashboard
94
+ * already computes). The rows are shape-detected rather than assumed:
95
+ * - carries `cost` and `costReq` (requests a price could be assigned to) ->
96
+ * estimated cost per PRICED request, metric `'cost'`.
97
+ * - carries `total`/`tokens` and `req` only -> tokens per request, metric
98
+ * `'tokens'`.
99
+ * - neither -> `null`, so the caller can say "not derivable from the
100
+ * aggregate" instead of guessing.
101
+ *
102
+ * `costReq`, not `req`, is the denominator for the cost metric: `cost` is a
103
+ * sum over priced requests only (src/core/store.js addToCube), so dividing
104
+ * by every request would silently treat unpriced traffic as free — a false
105
+ * zero, not a real one. When an hour has requests but none of them priced,
106
+ * its `value` is `null` rather than 0.
107
+ * @param {any[]} rows
108
+ * @returns {{metric:'cost'|'tokens', hours:{hour:number, value:number|null, requests:number}[], costliest:{hour:number, value:number, requests:number}|null} | null}
109
+ */
110
+ export function costliestHour(rows) {
111
+ if (!Array.isArray(rows) || !rows.length) return null;
112
+ const sample = rows[0];
113
+ /** @type {'cost'|'tokens'} */
114
+ let metric;
115
+ if ('cost' in sample && 'costReq' in sample) metric = 'cost';
116
+ else if (('total' in sample || 'tokens' in sample) && 'req' in sample) metric = 'tokens';
117
+ else return null;
118
+
119
+ const hours = rows.map((r) => {
120
+ const denom = metric === 'cost' ? (r.costReq || 0) : (r.req || 0);
121
+ const numer = metric === 'cost' ? (r.cost || 0) : (r.total ?? r.tokens ?? 0);
122
+ return { hour: r.hour, value: denom > 0 ? numer / denom : null, requests: denom };
123
+ });
124
+ const ranked = hours.filter((h) => h.value !== null);
125
+ const costliest = ranked.length ? ranked.reduce((a, b) => (b.value > a.value ? b : a)) : null;
126
+ return { metric, hours, costliest };
127
+ }
128
+
129
+ /**
130
+ * Days ranked by the share of that day's tokens spent in deep-work sessions,
131
+ * top 5. Days with zero tokens are excluded — a 0/0 share is not a real 0%.
132
+ * @param {any[]} sessions
133
+ * @returns {{date:string, total:number, deepTokens:number, sessions:number, share:number}[]}
134
+ */
135
+ export function focusDays(sessions) {
136
+ if (!sessions || !sessions.length) return [];
137
+ const byDay = new Map();
138
+ for (const s of sessions) {
139
+ const day = s.d;
140
+ if (day === null || day === undefined) continue;
141
+ let acc = byDay.get(day);
142
+ if (!acc) { acc = { date: day, total: 0, deepTokens: 0, sessions: 0 }; byDay.set(day, acc); }
143
+ const tokens = s.total || 0;
144
+ acc.total += tokens;
145
+ acc.sessions += 1;
146
+ if (isDeepWork(s)) acc.deepTokens += tokens;
147
+ }
148
+ const days = [...byDay.values()]
149
+ .filter((d) => d.total > 0)
150
+ .map((d) => ({ ...d, share: d.deepTokens / d.total }));
151
+ days.sort((a, b) => b.share - a.share || (a.date < b.date ? -1 : 1));
152
+ return days.slice(0, 5);
153
+ }
154
+
155
+ /**
156
+ * Three story-strip sentences summarizing the rhythm of work, in the style
157
+ * of the Overview's insight cards.
158
+ * @param {{deep:ReturnType<typeof deepWork>, switching:ReturnType<typeof switching>, costliest:ReturnType<typeof costliestHour>}} o
159
+ * @returns {string[]}
160
+ */
161
+ export function rhythmSummary({ deep, switching: sw, costliest }) {
162
+ const s1 = deep.count > 0
163
+ ? `Deep-work sessions were ${pct(deep.shareOfSessions, 1, 'n/a')} of sessions and ${pct(deep.shareOfTokens, 1, 'n/a')} of tokens.`
164
+ : 'No deep-work sessions (45+ minutes, or 40+ turns when duration is unknown) in this slice.';
165
+
166
+ const s2 = sw.days.length
167
+ ? `Projects switched ${sw.average.toFixed(1)} times a day on average, worst was ${sw.worst.switches} switch${sw.worst.switches === 1 ? '' : 'es'} on ${shortDate(sw.worst.date)}.`
168
+ : 'No sessions with a known day, so project switching cannot be measured.';
169
+
170
+ let s3;
171
+ if (!costliest) {
172
+ s3 = 'Cost per request by hour is not derivable from the aggregate.';
173
+ } else if (!costliest.costliest) {
174
+ s3 = costliest.metric === 'cost'
175
+ ? 'No priced requests in this slice, so cost per request by hour is not shown.'
176
+ : 'No requests in this slice, so tokens per request by hour is not shown.';
177
+ } else if (costliest.metric === 'cost') {
178
+ s3 = `The costliest hour was ${hourLabel(costliest.costliest.hour)}:00, at ${usd(costliest.costliest.value)} per priced request.`;
179
+ } else {
180
+ s3 = `The heaviest hour was ${hourLabel(costliest.costliest.hour)}:00, at ${compact(costliest.costliest.value)} tokens per request.`;
181
+ }
182
+
183
+ return [s1, s2, s3];
184
+ }
@@ -0,0 +1,263 @@
1
+ /**
2
+ * What-if repricing: take the tokens a model already used and price them
3
+ * again at another model's published rates.
4
+ *
5
+ * This says nothing about quality, latency or output length. It only prices.
6
+ * It answers "what would this same traffic have cost at model B's rates
7
+ * instead of model A's", nothing more.
8
+ *
9
+ * ## Method
10
+ *
11
+ * Both the "current" and "what-if" figures are recomputed here from the raw
12
+ * token counts times `book.lookup(...)`, never read off the row's own
13
+ * `cost` field. That is deliberate: the per-model aggregate the Cost view
14
+ * shows (`view.dimensions.models[*].cost`) already applied each request's
15
+ * service-tier multiplier (Anthropic Batch, OpenAI Fast/priority, ...), but
16
+ * an aggregated model row only carries token totals, not a per-tier
17
+ * breakdown, so there is nothing left here to multiply. Recomputing "current"
18
+ * through the exact same code path as "what-if" is what makes an identity
19
+ * mapping (source === target) provably yield a zero delta, and it is also
20
+ * why a heavily-tiered slice will show a "current" here that sits a little
21
+ * off the Cost tab's tier-aware number for the same model. That is a known,
22
+ * reported simplification, not a bug.
23
+ *
24
+ * ## Missing rates
25
+ *
26
+ * `book.lookup(model, provider)` returns `null` when the model has no price
27
+ * at all: both "current" and "what-if" are `null` (n/a) for that model, and
28
+ * it is excluded from every overall total (current, what-if AND delta).
29
+ * Folding an unknown number into a sum by pretending it is zero would just
30
+ * be a wrong number wearing a confident face. When the model *does* have a
31
+ * price but one token kind on it does not (a partial user override, for
32
+ * example), that one kind reports `null` and the model's total is flagged
33
+ * `partial`; the kinds that ARE known still sum into the total.
34
+ */
35
+
36
+ /** Token kinds priced individually. Reasoning tokens are a subset of output
37
+ * tokens (see core/schema.js) and the shipped price table has no separate
38
+ * reasoning rate, so there is no fifth kind to compute today. A future rate
39
+ * that names one would show up as `rate.reasoning`, which nothing here reads
40
+ * yet, by design: inventing a reasoning charge the table does not publish
41
+ * would be exactly the kind of invented price this module exists to avoid. */
42
+ const KIND_KEYS = ['input', 'cacheRead', 'cacheWrite', 'output'];
43
+
44
+ /**
45
+ * Price one model's cache-write tokens, splitting the long-TTL (refresh)
46
+ * subset from the short-TTL rest exactly as `core/pricing.js`'s
47
+ * `estimateCost` does, but folded into the single "cache write" kind the
48
+ * spec lists (no separate "cache refresh" row).
49
+ * @param {{cacheWrite:number, cacheRefresh:number}} tok
50
+ * @param {{cacheWrite:number|null|undefined, cacheRefresh:number|null|undefined}} rate
51
+ * @returns {{value:number|null, partial:boolean}}
52
+ */
53
+ function priceCacheWrite(tok, rate) {
54
+ const refresh = tok.cacheRefresh || 0;
55
+ const write = tok.cacheWrite || 0;
56
+ const shortWrite = Math.max(0, write - refresh);
57
+ const haveRefreshRate = rate.cacheRefresh !== null && rate.cacheRefresh !== undefined;
58
+ const haveWriteRate = rate.cacheWrite !== null && rate.cacheWrite !== undefined;
59
+ if (haveRefreshRate && haveWriteRate) {
60
+ return { value: (shortWrite / 1e6) * rate.cacheWrite + (refresh / 1e6) * rate.cacheRefresh, partial: false };
61
+ }
62
+ if (haveWriteRate) {
63
+ // No distinct refresh rate published: bill the whole write total,
64
+ // refresh subset included, at the plain write rate. This is the same
65
+ // fallback core/pricing.js's estimateCost takes.
66
+ return { value: (write / 1e6) * rate.cacheWrite, partial: false };
67
+ }
68
+ return { value: write > 0 ? null : 0, partial: write > 0 };
69
+ }
70
+
71
+ /**
72
+ * Price one model's token counts against one resolved rate entry.
73
+ * @param {{input:number, output:number, cacheRead:number, cacheWrite:number, cacheRefresh:number}} tok
74
+ * @param {{in:number, out:number, cacheRead:number|null, cacheWrite:number|null, cacheRefresh:number|null}|null} rate
75
+ * @returns {{byKind:Record<string, number|null>, total:number|null, partial:boolean}}
76
+ */
77
+ function priceTokens(tok, rate) {
78
+ if (!rate) {
79
+ const na = Object.fromEntries(KIND_KEYS.map((k) => [k, null]));
80
+ return { byKind: na, total: null, partial: false };
81
+ }
82
+ /** @type {Record<string, number|null>} */
83
+ const byKind = {};
84
+ let total = 0;
85
+ let partial = false;
86
+ const simple = (key, tokens, r) => {
87
+ const t = tokens || 0;
88
+ if (r === null || r === undefined || !Number.isFinite(r)) {
89
+ byKind[key] = t > 0 ? null : 0;
90
+ if (t > 0) partial = true;
91
+ return;
92
+ }
93
+ const cost = (t / 1e6) * r;
94
+ byKind[key] = cost;
95
+ total += cost;
96
+ };
97
+ simple('input', tok.input, rate.in);
98
+ simple('output', tok.output, rate.out);
99
+ simple('cacheRead', tok.cacheRead, rate.cacheRead);
100
+ const cw = priceCacheWrite(tok, rate);
101
+ byKind.cacheWrite = cw.value;
102
+ if (cw.value !== null) total += cw.value;
103
+ if (cw.partial) partial = true;
104
+ return { byKind, total, partial };
105
+ }
106
+
107
+ /**
108
+ * Reprice a set of per-model aggregate rows at another model's rates.
109
+ *
110
+ * @param {{
111
+ * rows: {key:string, provider:string, total:number, requests:number,
112
+ * input:number, output:number, cacheRead:number, cacheWrite:number,
113
+ * cacheRefresh:number}[],
114
+ * book: {lookup:(model:string, provider:string)=>({in:number,out:number,
115
+ * cacheRead:number|null,cacheWrite:number|null,cacheRefresh:number|null}|null)},
116
+ * mapping?: Record<string,string>,
117
+ * }} args `rows` is the per-model aggregate the Cost view already computes
118
+ * (`view.dimensions.models`, or any subset of it for the current filters).
119
+ * `mapping` is `{ fromModel: toModel }`; a model absent from `mapping`
120
+ * reprices against itself (identity, zero delta).
121
+ * @returns {{
122
+ * models: {model:string, provider:string, target:string, tokens:number,
123
+ * requests:number, current:number|null, currentByKind:Record<string,number|null>,
124
+ * whatif:number|null, whatifByKind:Record<string,number|null>,
125
+ * delta:number|null, partial:boolean}[],
126
+ * overall: {current:number|null, whatif:number|null, delta:number|null,
127
+ * excluded:number, partial:boolean},
128
+ * }}
129
+ */
130
+ export function reprice({ rows, book, mapping = {} }) {
131
+ const models = (rows || []).map((row) => {
132
+ const target = mapping[row.key] ?? row.key;
133
+ const tok = {
134
+ input: row.input, output: row.output,
135
+ cacheRead: row.cacheRead, cacheWrite: row.cacheWrite, cacheRefresh: row.cacheRefresh,
136
+ };
137
+ const fromRate = book.lookup(row.key, row.provider);
138
+ const toRate = book.lookup(target, row.provider);
139
+ const cur = priceTokens(tok, fromRate);
140
+ const wi = priceTokens(tok, toRate);
141
+ const delta = cur.total !== null && wi.total !== null ? wi.total - cur.total : null;
142
+ return {
143
+ model: row.key,
144
+ provider: row.provider,
145
+ target,
146
+ tokens: row.total,
147
+ requests: row.requests,
148
+ current: cur.total,
149
+ currentByKind: cur.byKind,
150
+ whatif: wi.total,
151
+ whatifByKind: wi.byKind,
152
+ delta,
153
+ // A gap on either side of the mapping is still a gap: either number
154
+ // being incomplete makes the pairing incomplete.
155
+ partial: cur.partial || wi.partial,
156
+ };
157
+ });
158
+
159
+ // A model missing on either side cannot honestly contribute to an overall
160
+ // current/what-if/delta trio: the three must stay `delta === whatif -
161
+ // current`, which only holds over the same set of rows on all three.
162
+ const comparable = models.filter((m) => m.current !== null && m.whatif !== null);
163
+ const sum = (get) => comparable.reduce((a, m) => a + get(m), 0);
164
+ const overall = {
165
+ current: comparable.length ? sum((m) => m.current) : null,
166
+ whatif: comparable.length ? sum((m) => m.whatif) : null,
167
+ delta: comparable.length ? sum((m) => m.delta) : null,
168
+ excluded: models.length - comparable.length,
169
+ partial: models.length > comparable.length || models.some((m) => m.partial),
170
+ };
171
+ return { models, overall };
172
+ }
173
+
174
+ // ---------------------------------------------------------- target list ----
175
+
176
+ const SRC_PROVIDER = {
177
+ anthropic: 'Anthropic',
178
+ openai: 'OpenAI',
179
+ 'openai-thirdparty': 'OpenAI',
180
+ deepseek: 'DeepSeek',
181
+ zai: 'Z.ai',
182
+ google: 'Google',
183
+ };
184
+
185
+ /** Prefix fallback for entries whose `src` does not name a provider directly
186
+ * (`legacy`, `user`): only changes which optgroup a name is filed under, not
187
+ * any rate, since every shipped rate is looked up by model name alone. */
188
+ function inferProvider(name) {
189
+ if (/^claude/i.test(name)) return 'Anthropic';
190
+ if (/^(gpt|o3|o4|chat)/i.test(name)) return 'OpenAI';
191
+ if (/^deepseek/i.test(name)) return 'DeepSeek';
192
+ if (/^gemini/i.test(name)) return 'Google';
193
+ if (/^glm/i.test(name)) return 'Z.ai';
194
+ return 'Other';
195
+ }
196
+
197
+ /**
198
+ * The first top-level alternative of a regex source string: the substring
199
+ * up to the first `|` that sits outside any `(...)` group.
200
+ * @param {string} pattern
201
+ * @returns {string}
202
+ */
203
+ function firstTopLevelAlt(pattern) {
204
+ let depth = 0;
205
+ for (let i = 0; i < pattern.length; i++) {
206
+ const ch = pattern[i];
207
+ if (ch === '\\') { i++; continue; }
208
+ if (ch === '(') depth++;
209
+ else if (ch === ')') depth--;
210
+ else if (ch === '|' && depth === 0) return pattern.slice(0, i);
211
+ }
212
+ return pattern;
213
+ }
214
+
215
+ /**
216
+ * Turn one `BUILTIN_PRICES`-style `match` regex into one representative,
217
+ * literal model name: take the first top-level alternative, drop the
218
+ * anchors, and resolve every `(...)` group to its own first alternative. An
219
+ * alternative that is bare `$` (an end-of-string anchor standing in for
220
+ * "nothing more") resolves to the empty string, not the literal text "$".
221
+ * @param {string} pattern
222
+ * @returns {string}
223
+ */
224
+ export function deriveModelName(pattern) {
225
+ let s = firstTopLevelAlt(pattern);
226
+ s = s.replace(/^\^/, '').replace(/\$$/, '');
227
+ s = s.replace(/\(([^()]*)\)/g, (_, inner) => {
228
+ const alt = firstTopLevelAlt(inner);
229
+ return alt === '$' ? '' : alt;
230
+ });
231
+ s = s.replace(/\\(.)/g, '$1');
232
+ return s;
233
+ }
234
+
235
+ /**
236
+ * Every target a "reprice at" select may offer: one representative name per
237
+ * price-table entry (builtin and user overrides), unioned with every priced
238
+ * model actually seen in the dataset. That way migrating *to* a model you
239
+ * have never used is possible, and a model an imperfect regex-derivation
240
+ * missed is not silently dropped just because it showed up in real usage.
241
+ * Every name is round-tripped through `book.lookup` before being offered, so
242
+ * a derivation slip never produces an option that reprices to n/a.
243
+ * @param {{book:object, seenModels?:({value:string}|string)[]}} args
244
+ * @returns {{name:string, provider:string}[]} sorted by provider, then name.
245
+ */
246
+ export function targetModelOptions({ book, seenModels = [] }) {
247
+ const byName = new Map();
248
+ for (const e of book.entries || []) {
249
+ const name = e.origin === 'user' ? e.key : deriveModelName(e.match);
250
+ if (!name || byName.has(name)) continue;
251
+ if (!book.lookup(name, 'unknown')) continue;
252
+ byName.set(name, SRC_PROVIDER[e.src] || inferProvider(name));
253
+ }
254
+ for (const s of seenModels) {
255
+ const name = typeof s === 'string' ? s : s.value;
256
+ if (!name || byName.has(name)) continue;
257
+ if (!book.lookup(name, 'unknown')) continue;
258
+ byName.set(name, inferProvider(name));
259
+ }
260
+ return [...byName.entries()]
261
+ .map(([name, provider]) => ({ name, provider }))
262
+ .sort((a, b) => (a.provider === b.provider ? a.name.localeCompare(b.name) : a.provider.localeCompare(b.provider)));
263
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * `tokenflow budget` scoped budgets — repo and team caps alongside the single
3
+ * monthly budget in src/core/budget.js.
4
+ *
5
+ * Config (config.yaml):
6
+ *
7
+ * budgets:
8
+ * - id: api-monthly
9
+ * scope: repo # total | repo | team
10
+ * repo: api # required when scope is repo; matched against
11
+ * # the same repository identity `receipt` uses
12
+ * # (worktrees folded into their main checkout)
13
+ * monthlyUsd: 150
14
+ * warnAt: 0.8 # optional, default 0.8 (a fraction, like limits[].warnAt)
15
+ *
16
+ * "Spent" per scope, this calendar month (UTC date, same convention `tokenflow
17
+ * budget` already uses for the single monthly cap):
18
+ * - total: every priced turn in the store this month.
19
+ * - repo: turns this month attributed to that repository, resolved the same
20
+ * way `tokenflow receipt` resolves it (worktrees folded into their
21
+ * main checkout) — a plain `project`/`repository` field match would
22
+ * split one repo's spend across every worktree it ever had.
23
+ * - team: this machine's shared sync folder (`sync.dir`), if enabled; the
24
+ * budget engine never invents a team total nobody's synced.
25
+ *
26
+ * Repo/team scope needs a fresh, month-bounded receipt pass (buildReceiptsForStore
27
+ * caches the *whole* store with no date window, so it cannot answer "this
28
+ * month" by itself) — built here with the exact same pieces buildReceiptsForStore
29
+ * uses internally (createReceiptBuilder + makeRepoResolver). buildReceiptsForStore
30
+ * itself is still called, to tell "this repo has no spend yet this month" apart
31
+ * from "this repo has never been seen in the store" in the row's note.
32
+ */
33
+ import { paths } from '../core/config.js';
34
+ import { Store, readJson, decodeRecord } from '../core/store.js';
35
+ import { buildPriceBook } from '../core/pricing.js';
36
+ import { MEASUREMENT } from '../core/schema.js';
37
+ import { makeRepoResolver } from '../core/repo.js';
38
+ import { createReceiptBuilder } from '../analytics/receipt.js';
39
+ import { buildReceiptsForStore } from '../core/bundle.js';
40
+ import { isEnabled, syncDir } from '../core/sync.js';
41
+ import { aggregate } from '../core/team.js';
42
+ import { scopedBudgetState } from '../core/budget.js';
43
+ import { usd, pct } from '../core/units.js';
44
+
45
+ function monthWindow(now) {
46
+ const d = now instanceof Date ? now : new Date(now || Date.now());
47
+ const today = d.toISOString().slice(0, 10);
48
+ return { from: `${today.slice(0, 7)}-01`, to: today };
49
+ }
50
+
51
+ /**
52
+ * @param {object} b configured budget entry
53
+ * @param {{spentUsd:number, note?:string}} extra
54
+ */
55
+ function row(b, { spentUsd, note }) {
56
+ const st = scopedBudgetState({
57
+ spentUsd,
58
+ monthlyUsd: b.monthlyUsd,
59
+ warnAt: typeof b.warnAt === 'number' ? b.warnAt : 0.8,
60
+ });
61
+ const label = b.scope === 'repo' ? (b.repo || b.id) : b.scope === 'team' ? 'Team' : 'Total';
62
+ return {
63
+ id: b.id,
64
+ scope: b.scope,
65
+ label,
66
+ spentUsd: st.spentUsd,
67
+ monthlyUsd: st.monthlyUsd,
68
+ share: st.share,
69
+ state: st.state,
70
+ note: note || null,
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Evaluate every configured scoped budget against this month's spend.
76
+ * @param {{config?:object, store?:object, now?:Date|string}} [opt]
77
+ * @returns {object[]} rows: `{id, scope, label, spentUsd, monthlyUsd, share, state, note}`
78
+ */
79
+ export function evaluateScopedBudgets({ config, store, now } = {}) {
80
+ const cfg = config || {};
81
+ const budgets = Array.isArray(cfg.budgets) ? cfg.budgets : [];
82
+ const valid = budgets.filter((b) => b && b.id && b.scope && b.monthlyUsd > 0);
83
+ if (!valid.length) return [];
84
+
85
+ const st = store || new Store();
86
+ const { from, to } = monthWindow(now);
87
+ const pricing = readJson(paths().pricing, {});
88
+ const book = buildPriceBook(pricing);
89
+
90
+ // One month-bounded receipts pass, reused by every total/repo row below.
91
+ const builder = createReceiptBuilder({ book, repoOf: makeRepoResolver() });
92
+ st.scanRecords((o) => {
93
+ if (o.ms !== MEASUREMENT.PRIMARY) return;
94
+ if (o.d < from || o.d > to) return;
95
+ builder.add(decodeRecord(o));
96
+ });
97
+ const scoped = builder.finish();
98
+
99
+ const rows = [];
100
+ for (const b of valid) {
101
+ if (b.scope === 'total') {
102
+ rows.push(row(b, { spentUsd: scoped.totals.cost ?? 0 }));
103
+ } else if (b.scope === 'repo') {
104
+ const R = scoped.repos.find((r) => r.repo === b.repo);
105
+ if (R) {
106
+ rows.push(row(b, { spentUsd: R.cost ?? 0 }));
107
+ } else {
108
+ const allTime = buildReceiptsForStore(st, pricing);
109
+ const known = allTime.repos.some((r) => r.repo === b.repo);
110
+ rows.push(row(b, { spentUsd: 0, note: known ? 'no spend this month' : 'repository not seen in the store' }));
111
+ }
112
+ } else if (b.scope === 'team') {
113
+ if (!isEnabled(cfg)) { rows.push(row(b, { spentUsd: 0, note: 'no team data' })); continue; }
114
+ const dir = syncDir(cfg);
115
+ const team = aggregate(dir, { from, to });
116
+ if (!team) { rows.push(row(b, { spentUsd: 0, note: 'no team data' })); continue; }
117
+ rows.push(row(b, { spentUsd: team.totals.estCostUsd || 0 }));
118
+ }
119
+ }
120
+ return rows;
121
+ }
122
+
123
+ /** @param {object[]} rows from evaluateScopedBudgets() */
124
+ export function renderScopedBudgets(rows) {
125
+ if (!rows.length) return 'No scoped budgets configured. Add one to config.yaml under `budgets:`.';
126
+ const L = [];
127
+ for (const r of rows) {
128
+ const tag = r.state === 'over' ? 'OVER' : r.state === 'warn' ? 'WARN' : 'ok';
129
+ const shareTxt = pct(r.share, 0, 'n/a');
130
+ L.push(`[${tag}] ${r.label} (${r.scope}): ${usd(r.spentUsd, 'n/a')} of ${usd(r.monthlyUsd, 'n/a')} (${shareTxt})${r.note ? ` ${r.note}` : ''}`);
131
+ }
132
+ return L.join('\n');
133
+ }