@vimoxshah/tokenflow 1.1.1 → 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 (87) hide show
  1. package/CHANGELOG.md +228 -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-dmg.sh +11 -2
  25. package/scripts/build-menubar-app.sh +58 -7
  26. package/scripts/design-build.js +475 -0
  27. package/src/analytics/anatomy.js +467 -0
  28. package/src/analytics/branch-compare.js +159 -0
  29. package/src/analytics/cache-health.js +141 -0
  30. package/src/analytics/live-view.js +266 -0
  31. package/src/analytics/receipt-schema.js +214 -0
  32. package/src/analytics/receipt.js +709 -0
  33. package/src/analytics/rhythm.js +184 -0
  34. package/src/analytics/whatif.js +263 -0
  35. package/src/commands/budget-scopes.js +133 -0
  36. package/src/commands/doctor-checks.js +400 -0
  37. package/src/commands/guard.js +531 -0
  38. package/src/commands/hooks.js +238 -0
  39. package/src/commands/pricing-diff.js +316 -0
  40. package/src/commands/receipt.js +226 -0
  41. package/src/commands/team-serve.js +407 -0
  42. package/src/commands/week.js +86 -0
  43. package/src/core/annotations.js +97 -0
  44. package/src/core/budget.js +33 -0
  45. package/src/core/bundle.js +45 -2
  46. package/src/core/ingest.js +33 -0
  47. package/src/core/live-status.js +227 -2
  48. package/src/core/policy.js +103 -0
  49. package/src/core/receipt-note.js +123 -0
  50. package/src/core/repo.js +64 -0
  51. package/src/core/sync.js +163 -26
  52. package/src/core/team.js +0 -0
  53. package/src/export/html-snapshot.js +28 -1
  54. package/src/export/menubar.js +21 -0
  55. package/src/export/receipt-card.js +210 -0
  56. package/src/export/week-card.js +185 -0
  57. package/src/providers/mock/index.js +383 -52
  58. package/src/providers/openai/index.js +31 -1
  59. package/src/providers/otel/index.js +656 -0
  60. package/src/server/routes/annotations.js +42 -0
  61. package/src/server/routes/cache-health.js +95 -0
  62. package/src/server/routes/index.js +54 -0
  63. package/src/server/routes/session.js +157 -0
  64. package/src/server/server.js +47 -1
  65. package/src/ui/app.js +541 -308
  66. package/src/ui/charts.js +95 -0
  67. package/src/ui/first-run.js +144 -0
  68. package/src/ui/index.html +4 -1
  69. package/src/ui/palette.js +335 -0
  70. package/src/ui/styles/anatomy.css +117 -0
  71. package/src/ui/styles/annotations.css +40 -0
  72. package/src/ui/styles/branches.css +99 -0
  73. package/src/ui/styles/cache.css +6 -0
  74. package/src/ui/styles/first-run.css +31 -0
  75. package/src/ui/styles/live.css +100 -0
  76. package/src/ui/styles/palette.css +85 -0
  77. package/src/ui/styles/rhythm.css +8 -0
  78. package/src/ui/styles/whatif.css +55 -0
  79. package/src/ui/styles.css +303 -196
  80. package/src/ui/views/anatomy.js +567 -0
  81. package/src/ui/views/annotations.js +121 -0
  82. package/src/ui/views/branches.js +304 -0
  83. package/src/ui/views/cache.js +232 -0
  84. package/src/ui/views/index.js +85 -0
  85. package/src/ui/views/live.js +683 -0
  86. package/src/ui/views/rhythm.js +206 -0
  87. package/src/ui/views/whatif.js +196 -0
@@ -0,0 +1,709 @@
1
+ /**
2
+ * Receipts — AI spend attributed to the unit of work, and the guard that
3
+ * watches a session while it is still running.
4
+ *
5
+ * Every other surface in this project answers "how much did I use?" This
6
+ * module answers three questions nobody's console can, because they need the
7
+ * session transcript AND the repository on the same machine:
8
+ *
9
+ * 1. What did this branch / pull request cost? buildReceipts()
10
+ * 2. Where does the money go inside a session? splitCost(), sessionStats()
11
+ * 3. Is the session I am in right now getting expensive? evaluateGuard()
12
+ *
13
+ * Two framing rules, stated because the numbers invite the wrong reading:
14
+ *
15
+ * - "Context" dollars are what it cost to re-send the conversation so far
16
+ * (cache reads + cache writes). "Work" dollars are fresh input + output.
17
+ * Context is the CHEAP path per token — a high context share is not waste,
18
+ * it is the price of a long session. What it tells you is where the lever
19
+ * is: the marginal cost of a turn is set by how much context it carries.
20
+ * - A cap simulation reports the dollars ABOVE a threshold. That is an upper
21
+ * bound on what a guard could have saved, never a saving: some of that work
22
+ * would have happened anyway in a fresh session.
23
+ *
24
+ * Pure: no Node imports, so the CLI, the server and the browser get identical
25
+ * answers. Attribution is per record, by the branch checked out when the turn
26
+ * ran — a long session that moves across branches is split across them.
27
+ */
28
+ import { estimateCost } from '../core/pricing.js';
29
+ import { MEASUREMENT } from '../core/schema.js';
30
+ import { usd, compact, pct, shortDate } from '../core/units.js';
31
+
32
+ /** @typedef {ReturnType<typeof import('../core/pricing.js').buildPriceBook>} PriceBook */
33
+
34
+ export const UNATTRIBUTED = '(unattributed)';
35
+
36
+ /** Per-request sources. Session-level aggregates (Hermes) would skew per-turn statistics. */
37
+ export const PER_REQUEST_SOURCES = ['anthropic', 'openai', 'opencode', 'mock'];
38
+
39
+ /** A branch that names a unit of work. Detached HEAD and null do not. */
40
+ export function isAttributableBranch(br) {
41
+ return typeof br === 'string' && br.length > 0 && br !== 'HEAD';
42
+ }
43
+
44
+ /** Sum of the prompt-side buckets, or null when the source reports none of them. */
45
+ export function promptTokens(rec) {
46
+ let t = null;
47
+ for (const k of ['input_tokens', 'cache_read_tokens', 'cache_write_tokens']) {
48
+ const v = rec[k];
49
+ if (v !== null && v !== undefined) t = (t === null ? 0 : t) + v;
50
+ }
51
+ return t;
52
+ }
53
+
54
+ /**
55
+ * Split one record's estimated cost into context dollars (cache read + write)
56
+ * and work dollars (fresh input + output), scaled so the two sum exactly to
57
+ * the stored estimate. Null when the record is unpriced or the cost was
58
+ * measured by a gateway (a measured total has no bucket breakdown).
59
+ * @param {object} rec normalized record
60
+ * @param {PriceBook|null} book price book
61
+ * @returns {{context:number|null, work:number|null}}
62
+ */
63
+ export function splitCost(rec, book) {
64
+ const cost = rec.estimated_cost;
65
+ if (cost === null || cost === undefined || !(cost >= 0)) return { context: null, work: null };
66
+ if (rec.cost_basis === 'measured' || !book) return { context: null, work: null };
67
+ const opt = { tier: rec.service_tier ?? null };
68
+ const ctx = estimateCost({
69
+ cache_read_tokens: rec.cache_read_tokens,
70
+ cache_write_tokens: rec.cache_write_tokens,
71
+ cache_refresh_tokens: rec.cache_refresh_tokens,
72
+ }, rec.model, rec.provider, book, opt);
73
+ const wk = estimateCost({
74
+ input_tokens: rec.input_tokens,
75
+ output_tokens: rec.output_tokens,
76
+ }, rec.model, rec.provider, book, opt);
77
+ if (ctx.cost === null || wk.cost === null) return { context: null, work: null };
78
+ const sum = ctx.cost + wk.cost;
79
+ if (!(sum > 0)) return { context: 0, work: cost };
80
+ const k = cost / sum;
81
+ return { context: ctx.cost * k, work: wk.cost * k };
82
+ }
83
+
84
+ // ------------------------------------------------------------ accumulate ---
85
+
86
+ function newAcc(key) {
87
+ return {
88
+ key,
89
+ cost: 0, pricedTurns: 0, unpricedTurns: 0,
90
+ context: 0, work: 0, splitTurns: 0,
91
+ turns: 0, subagentTurns: 0,
92
+ sessions: new Set(), models: new Map(), providers: new Set(), sources: new Set(),
93
+ first: null, last: null, maxPrompt: null,
94
+ tok: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
95
+ };
96
+ }
97
+
98
+ function addRec(acc, rec, split) {
99
+ acc.turns += 1;
100
+ if (rec.category === 'subagent') acc.subagentTurns += 1;
101
+ if (rec.session_id) acc.sessions.add(rec.session_id);
102
+ if (rec.provider) acc.providers.add(rec.provider);
103
+ if (rec.source) acc.sources.add(rec.source);
104
+ const cost = rec.estimated_cost;
105
+ if (cost !== null && cost !== undefined) {
106
+ acc.cost += cost;
107
+ acc.pricedTurns += 1;
108
+ const mf = rec.model_family || rec.model || 'unknown';
109
+ acc.models.set(mf, (acc.models.get(mf) ?? 0) + cost);
110
+ } else {
111
+ acc.unpricedTurns += 1;
112
+ }
113
+ if (split.context !== null) {
114
+ acc.context += split.context;
115
+ acc.work += split.work;
116
+ acc.splitTurns += 1;
117
+ }
118
+ const ts = rec.timestamp;
119
+ if (ts) {
120
+ if (acc.first === null || ts < acc.first) acc.first = ts;
121
+ if (acc.last === null || ts > acc.last) acc.last = ts;
122
+ }
123
+ const p = promptTokens(rec);
124
+ if (p !== null && (acc.maxPrompt === null || p > acc.maxPrompt)) acc.maxPrompt = p;
125
+ addTok(acc.tok, 'input', rec.input_tokens);
126
+ addTok(acc.tok, 'output', rec.output_tokens);
127
+ addTok(acc.tok, 'cacheRead', rec.cache_read_tokens);
128
+ addTok(acc.tok, 'cacheWrite', rec.cache_write_tokens);
129
+ }
130
+
131
+ function addTok(t, k, v) {
132
+ if (v !== null && v !== undefined) t[k] += v;
133
+ }
134
+
135
+ function finish(acc) {
136
+ const models = [...acc.models.entries()]
137
+ .map(([model, cost]) => ({ model, cost, share: acc.cost > 0 ? cost / acc.cost : null }))
138
+ .sort((a, b) => b.cost - a.cost);
139
+ const ctxWork = acc.context + acc.work;
140
+ return {
141
+ key: acc.key,
142
+ cost: acc.pricedTurns > 0 ? acc.cost : null,
143
+ pricedTurns: acc.pricedTurns,
144
+ unpricedTurns: acc.unpricedTurns,
145
+ coverage: acc.turns > 0 ? acc.pricedTurns / acc.turns : null,
146
+ contextCost: acc.splitTurns > 0 ? acc.context : null,
147
+ workCost: acc.splitTurns > 0 ? acc.work : null,
148
+ contextShare: ctxWork > 0 ? acc.context / ctxWork : null,
149
+ turns: acc.turns,
150
+ subagentTurns: acc.subagentTurns,
151
+ subagentShare: acc.turns > 0 ? acc.subagentTurns / acc.turns : null,
152
+ sessions: acc.sessions.size,
153
+ models,
154
+ providers: [...acc.providers].sort(),
155
+ sources: [...acc.sources].sort(),
156
+ first: acc.first,
157
+ last: acc.last,
158
+ maxPrompt: acc.maxPrompt,
159
+ tokens: { ...acc.tok },
160
+ };
161
+ }
162
+
163
+ function median(xs) {
164
+ if (!xs.length) return null;
165
+ const s = [...xs].sort((a, b) => a - b);
166
+ const m = Math.floor(s.length / 2);
167
+ return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
168
+ }
169
+
170
+ // ---------------------------------------------------------------- receipts ---
171
+
172
+ /** Default repo identity: what the adapter recorded. The CLI supplies a resolver that sees through worktrees. */
173
+ export function defaultRepoOf(rec) {
174
+ return rec.repository || rec.project || null;
175
+ }
176
+
177
+ /**
178
+ * @typedef {{number:number, headRefName:string, additions?:number, deletions?:number,
179
+ * title?:string, createdAt?:string|null, mergedAt?:string|null, repo?:string|null}} PullRequest
180
+ */
181
+
182
+ /** Branch names that live forever. A receipt for one is a receipt for a period, not a unit of work. */
183
+ export const LONG_LIVED_BRANCH = /^(main|master|trunk|develop|dev|staging|release_staging|production|prod)$/i;
184
+
185
+ /**
186
+ * Which merged PR a turn on this branch belongs to, given the branch's merged
187
+ * PRs sorted by merge time: the first PR merged at or after the turn, provided
188
+ * the turn came after the previous PR's merge. Work done before a PR is opened
189
+ * still belongs to it — the PR is how that work shipped. Work after the last
190
+ * merge belongs to nothing yet: `after`.
191
+ * @param {string} ts record timestamp
192
+ * @param {PullRequest[]} sorted PRs with mergedAt, ascending
193
+ * @returns {{idx:number}|'after'}
194
+ */
195
+ function prSlot(ts, sorted) {
196
+ for (let i = 0; i < sorted.length; i++) {
197
+ // An open PR (no mergedAt) owns everything after the last merge.
198
+ if (!sorted[i].mergedAt || ts <= sorted[i].mergedAt) return { idx: i };
199
+ }
200
+ return 'after';
201
+ }
202
+
203
+ function daysBetween(a, b) {
204
+ return Math.max(0, (Date.parse(b) - Date.parse(a)) / 86_400_000);
205
+ }
206
+
207
+ /**
208
+ * Attribute spend to (repo, branch), join to pull requests when supplied, and
209
+ * rank each branch against its repository's median.
210
+ *
211
+ * With a merged PR on the branch, the receipt's headline covers the turns up to
212
+ * that merge (work before the PR opened included). Turns after the merge are
213
+ * follow-up on a checkout that kept the old branch name; they are reported
214
+ * beside the receipt as `prWindow.afterMerge`, never inside it. A branch with
215
+ * several merged PRs attributes each turn to the PR it shipped in; the receipt
216
+ * shows the latest and counts the earlier ones.
217
+ *
218
+ * @param {Iterable<object>} records normalized records (any measurement; only primary count)
219
+ * @param {{book?:PriceBook|null, prs?:PullRequest[], repoOf?:(rec:object)=>string|null,
220
+ * minTurns?:number, automated?:RegExp|null}} [opt]
221
+ */
222
+ export function buildReceipts(records, opt = {}) {
223
+ const b = createReceiptBuilder(opt);
224
+ for (const rec of records) b.add(rec);
225
+ return b.finish();
226
+ }
227
+
228
+ /**
229
+ * The streaming form of buildReceipts(): feed records one at a time (a store
230
+ * scan never has to materialize every record), then finish(). Same result.
231
+ * @param {{book?:PriceBook|null, prs?:PullRequest[], repoOf?:(rec:object)=>string|null,
232
+ * minTurns?:number, automated?:RegExp|null}} [opt]
233
+ */
234
+ export function createReceiptBuilder(opt = {}) {
235
+ const book = opt.book ?? null;
236
+ const repoOf = opt.repoOf || defaultRepoOf;
237
+ const minTurns = opt.minTurns ?? 1;
238
+ const prs = opt.prs || [];
239
+ const automated = opt.automated ?? null;
240
+
241
+ // PRs per (repo-or-any, branch), merged ones sorted by merge time.
242
+ const prIndex = new Map();
243
+ for (const p of prs) {
244
+ const k = `${p.repo || ''} ${p.headRefName}`;
245
+ if (!prIndex.has(k)) prIndex.set(k, []);
246
+ prIndex.get(k).push(p);
247
+ }
248
+ // Merged PRs by merge time, then any open PR last (it owns what follows the last merge).
249
+ for (const list of prIndex.values()) list.sort((a, b) => String(a.mergedAt || '9999') < String(b.mergedAt || '9999') ? -1 : 1);
250
+ const prsFor = (repo, branch) => prIndex.get(`${repo} ${branch}`) || prIndex.get(` ${branch}`) || null;
251
+
252
+ const repos = new Map();
253
+ let seen = 0;
254
+
255
+ function add(rec) {
256
+ if (rec.measurement !== MEASUREMENT.PRIMARY) return;
257
+ seen += 1;
258
+ const repo = repoOf(rec) || 'unknown';
259
+ let R = repos.get(repo);
260
+ if (!R) {
261
+ R = { repo, branches: new Map(), unattributed: newAcc(UNATTRIBUTED) };
262
+ repos.set(repo, R);
263
+ }
264
+ const br = rec.git_branch;
265
+ const split = splitCost(rec, book);
266
+ if (!isAttributableBranch(br)) { addRec(R.unattributed, rec, split); return; }
267
+
268
+ let B = R.branches.get(br);
269
+ if (!B) {
270
+ const list = prsFor(repo, br) || [];
271
+ B = { all: newAcc(br), prs: list, perPr: list.map(() => newAcc(br)), after: newAcc(br), beforeOpened: newAcc(br) };
272
+ R.branches.set(br, B);
273
+ }
274
+ addRec(B.all, rec, split);
275
+ if (B.prs.length) {
276
+ const slot = prSlot(rec.timestamp, B.prs);
277
+ if (slot === 'after') {
278
+ addRec(B.after, rec, split);
279
+ } else {
280
+ addRec(B.perPr[slot.idx], rec, split);
281
+ const created = B.prs[slot.idx].createdAt;
282
+ if (slot.idx === B.prs.length - 1 && created && rec.timestamp < created) addRec(B.beforeOpened, rec, split);
283
+ }
284
+ }
285
+ }
286
+
287
+ return { add, finish: () => finishReceipts(repos, { prs, minTurns, automated, seen }) };
288
+ }
289
+
290
+ function finishReceipts(repos, { prs, minTurns, automated, seen }) {
291
+ const out = [];
292
+ const seenBranches = new Set();
293
+ let total = 0;
294
+ let attributed = 0;
295
+ for (const R of repos.values()) {
296
+ const branches = [];
297
+ for (const B of R.branches.values()) {
298
+ const whole = finish(B.all);
299
+ if (whole.turns < minTurns) continue;
300
+ let b;
301
+ if (B.prs.length) {
302
+ const last = B.prs.length - 1;
303
+ const pr = B.prs[last];
304
+ b = finish(B.perPr[last]);
305
+ const after = finish(B.after);
306
+ const before = finish(B.beforeOpened);
307
+ let priorCost = 0;
308
+ let priorTurns = 0;
309
+ for (let i = 0; i < last; i++) { const f = finish(B.perPr[i]); priorCost += f.cost ?? 0; priorTurns += f.turns; }
310
+ const adds = pr.additions ?? null;
311
+ const dels = pr.deletions ?? null;
312
+ const lines = adds !== null && dels !== null ? adds + dels : null;
313
+ b.pr = { number: pr.number, title: pr.title ?? null, additions: adds, deletions: dels, createdAt: pr.createdAt ?? null, mergedAt: pr.mergedAt ?? null };
314
+ b.changedLines = lines;
315
+ b.costPer100Lines = lines !== null && lines > 0 && b.cost !== null ? (b.cost / lines) * 100 : null;
316
+ b.branch = { cost: whole.cost, turns: whole.turns, sessions: whole.sessions };
317
+ b.prWindow = {
318
+ createdAt: pr.createdAt ?? null,
319
+ mergedAt: pr.mergedAt ?? null,
320
+ beforeOpened: { cost: before.cost, turns: before.turns, days: pr.createdAt && before.first ? daysBetween(before.first, pr.createdAt) : null },
321
+ afterMerge: { cost: after.cost, turns: after.turns, sessions: after.sessions, last: after.last },
322
+ priorPrs: { count: last, cost: priorCost, turns: priorTurns },
323
+ };
324
+ } else {
325
+ b = whole;
326
+ b.pr = null;
327
+ b.changedLines = null;
328
+ b.costPer100Lines = null;
329
+ b.branch = null;
330
+ b.prWindow = null;
331
+ }
332
+ b.longLived = LONG_LIVED_BRANCH.test(b.key);
333
+ branches.push(b);
334
+ }
335
+ const prFor = new Map();
336
+ for (const p of prs) {
337
+ if (p.repo && p.repo !== R.repo) continue;
338
+ prFor.set(p.headRefName, p);
339
+ }
340
+ for (const b of branches) seenBranches.add(b.key);
341
+ const priced = branches.filter((b) => b.cost !== null && b.cost > 0).map((b) => b.cost);
342
+ const med = median(priced);
343
+ for (const b of branches) b.vsMedian = med !== null && med > 0 && b.cost !== null ? b.cost / med : null;
344
+ branches.sort((a, b) => (b.cost ?? -1) - (a.cost ?? -1));
345
+
346
+ const un = finish(R.unattributed);
347
+ // Repo totals count every turn on every branch, including turns after a merge.
348
+ const branchTotal = branches.reduce((a, b) => a + ((b.branch ? b.branch.cost : b.cost) ?? 0), 0);
349
+ const repoCost = branchTotal + (un.cost ?? 0);
350
+ total += repoCost;
351
+ attributed += branchTotal;
352
+
353
+ const matched = branches.filter((b) => b.pr).length;
354
+ const unmatched = [];
355
+ for (const p of prs) {
356
+ if (p.repo && p.repo !== R.repo) continue;
357
+ if (!R.branches.has(p.headRefName)) unmatched.push({ number: p.number, headRefName: p.headRefName, automated: automated ? automated.test(p.headRefName) : null });
358
+ }
359
+
360
+ out.push({
361
+ repo: R.repo,
362
+ cost: repoCost > 0 ? repoCost : null,
363
+ medianBranchCost: med,
364
+ branches,
365
+ unattributed: un,
366
+ prs: { supplied: prFor.size, matched, unmatched },
367
+ });
368
+ }
369
+ out.sort((a, b) => (b.cost ?? -1) - (a.cost ?? -1));
370
+
371
+ return {
372
+ repos: out,
373
+ totals: {
374
+ cost: total > 0 ? total : null,
375
+ attributedCost: attributed,
376
+ unattributedCost: total - attributed,
377
+ attributedShare: total > 0 ? attributed / total : null,
378
+ branches: seenBranches.size,
379
+ records: seen,
380
+ },
381
+ };
382
+ }
383
+
384
+ // ---------------------------------------------------------------- sessions ---
385
+
386
+ /** @type {[string, number, number][]} */
387
+ const TURN_BUCKETS = [
388
+ ['1–50', 0, 50],
389
+ ['51–200', 50, 200],
390
+ ['201–1000', 200, 1000],
391
+ ['1000+', 1000, Infinity],
392
+ ];
393
+
394
+ /**
395
+ * Where the money goes across sessions: the concentration curve, the split
396
+ * between context and work dollars, the marginal cost of a turn as a session
397
+ * ages, and the dollars sitting above each candidate per-session cap.
398
+ *
399
+ * Per-turn statistics use per-request sources only; a source that reports one
400
+ * row per session would make every "turn" look enormous.
401
+ *
402
+ * @param {Iterable<object>} records
403
+ * @param {{book?:PriceBook|null, caps?:number[], perRequestSources?:string[]}} [opt]
404
+ */
405
+ export function sessionStats(records, opt = {}) {
406
+ const book = opt.book ?? null;
407
+ const caps = opt.caps || [50, 100, 200, 500];
408
+ const perRequest = new Set(opt.perRequestSources || PER_REQUEST_SOURCES);
409
+
410
+ const sessions = new Map();
411
+ let total = 0;
412
+ let context = 0;
413
+ let work = 0;
414
+ let splitTurns = 0;
415
+ const bucketCosts = TURN_BUCKETS.map(() => []);
416
+ let bigTurns = 0;
417
+ let bigTurnCost = 0;
418
+ let perReqTurns = 0;
419
+ let perReqCost = 0;
420
+ const seenSources = new Set();
421
+
422
+ for (const rec of records) {
423
+ if (rec.measurement !== MEASUREMENT.PRIMARY) continue;
424
+ const cost = rec.estimated_cost;
425
+ const priced = cost !== null && cost !== undefined;
426
+ if (priced) total += cost;
427
+ const s = splitCost(rec, book);
428
+ if (s.context !== null) { context += s.context; work += s.work; splitTurns += 1; }
429
+
430
+ const key = rec.session_id || `~${rec.source}`;
431
+ let e = sessions.get(key);
432
+ if (!e) {
433
+ e = { cost: 0, turns: 0, branches: new Set(), first: rec.timestamp, last: rec.timestamp, perRequest: perRequest.has(rec.source), turnCosts: [] };
434
+ sessions.set(key, e);
435
+ }
436
+ e.turns += 1;
437
+ if (priced) e.cost += cost;
438
+ if (isAttributableBranch(rec.git_branch)) e.branches.add(rec.git_branch);
439
+ if (rec.timestamp < e.first) e.first = rec.timestamp;
440
+ if (rec.timestamp > e.last) e.last = rec.timestamp;
441
+ if (e.perRequest && priced) {
442
+ seenSources.add(rec.source);
443
+ e.turnCosts.push(cost);
444
+ perReqTurns += 1;
445
+ perReqCost += cost;
446
+ const p = promptTokens(rec);
447
+ if (p !== null && p > 200_000) { bigTurns += 1; bigTurnCost += cost; }
448
+ }
449
+ }
450
+
451
+ for (const e of sessions.values()) {
452
+ if (!e.perRequest) continue;
453
+ e.turnCosts.forEach((c, i) => {
454
+ const b = TURN_BUCKETS.findIndex(([, lo, hi]) => i >= lo && i < hi);
455
+ if (b >= 0) bucketCosts[b].push(c);
456
+ });
457
+ }
458
+
459
+ const all = [...sessions.values()];
460
+ const costs = all.map((e) => e.cost).sort((a, b) => b - a);
461
+ const n = costs.length;
462
+ const shareOfTop = (frac) => {
463
+ if (!n || !(total > 0)) return null;
464
+ const k = Math.max(1, Math.floor(n * frac));
465
+ return costs.slice(0, k).reduce((a, b) => a + b, 0) / total;
466
+ };
467
+ const capTable = caps.map((cap) => {
468
+ let over = 0;
469
+ let above = 0;
470
+ for (const c of costs) if (c > cap) { over += 1; above += c - cap; }
471
+ return { cap, sessionsOver: over, costAbove: above, shareAbove: total > 0 ? above / total : null };
472
+ });
473
+
474
+ return {
475
+ sessions: n,
476
+ totalCost: total > 0 ? total : null,
477
+ contextCost: splitTurns > 0 ? context : null,
478
+ workCost: splitTurns > 0 ? work : null,
479
+ contextShare: context + work > 0 ? context / (context + work) : null,
480
+ medianSessionCost: median(costs),
481
+ p90SessionCost: n ? costs[Math.floor(n * 0.1)] : null,
482
+ top1pctShare: shareOfTop(0.01),
483
+ top10pctShare: shareOfTop(0.10),
484
+ multiBranchSessions: all.filter((e) => e.branches.size > 1).length,
485
+ capTable,
486
+ marginalByTurnIndex: TURN_BUCKETS.map(([label], i) => ({ turns: label, medianCostPerTurn: median(bucketCosts[i]), samples: bucketCosts[i].length })),
487
+ largePromptTurns: {
488
+ threshold: 200_000,
489
+ turns: bigTurns,
490
+ turnShare: perReqTurns > 0 ? bigTurns / perReqTurns : null,
491
+ cost: bigTurnCost,
492
+ costShare: perReqCost > 0 ? bigTurnCost / perReqCost : null,
493
+ sources: [...seenSources].sort(),
494
+ },
495
+ };
496
+ }
497
+
498
+ // ------------------------------------------------------------------- guard ---
499
+
500
+ /**
501
+ * @typedef {Object} GuardPolicy
502
+ * @property {number|null} [warnCostUsd] session spend at which to warn
503
+ * @property {number|null} [maxCostUsd] session spend at which to block
504
+ * @property {number|null} [warnContextTokens] prompt size (tokens) at which to warn
505
+ * @property {number|null} [maxContextTokens] prompt size at which to block
506
+ * @property {number|null} [warnMarginalUsd] median cost of the last turns at which to warn
507
+ */
508
+
509
+ const RECENT_TURNS = 10;
510
+
511
+ /**
512
+ * Judge one running session. Every threshold comes from the policy the user
513
+ * declared; with none declared the verdict is informational and never blocks.
514
+ *
515
+ * @param {object[]} sessionRecords normalized records of ONE session, any order
516
+ * @param {GuardPolicy} [policy]
517
+ * @param {PriceBook|null} [book]
518
+ */
519
+ export function evaluateGuard(sessionRecords, policy = {}, book = null) {
520
+ const recs = sessionRecords
521
+ .filter((r) => r.measurement === MEASUREMENT.PRIMARY)
522
+ .sort((a, b) => (a.timestamp < b.timestamp ? -1 : a.timestamp > b.timestamp ? 1 : 0));
523
+ const acc = newAcc('session');
524
+ for (const r of recs) addRec(acc, r, splitCost(r, book));
525
+ const f = finish(acc);
526
+
527
+ const last = recs.length ? recs[recs.length - 1] : null;
528
+ const contextTokens = last ? promptTokens(last) : null;
529
+ const recent = recs.slice(-RECENT_TURNS).map((r) => r.estimated_cost).filter((c) => c !== null && c !== undefined);
530
+ const marginal = median(recent);
531
+
532
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : null);
533
+ const p = {
534
+ warnCostUsd: num(policy.warnCostUsd),
535
+ maxCostUsd: num(policy.maxCostUsd),
536
+ warnContextTokens: num(policy.warnContextTokens),
537
+ maxContextTokens: num(policy.maxContextTokens),
538
+ warnMarginalUsd: num(policy.warnMarginalUsd),
539
+ };
540
+
541
+ const reasons = [];
542
+ let level = 'ok';
543
+ const cost = f.cost;
544
+ if (p.maxCostUsd !== null && cost !== null && cost >= p.maxCostUsd) {
545
+ level = 'block';
546
+ reasons.push(`session spend ${usd(cost)} has reached the declared cap of ${usd(p.maxCostUsd)}`);
547
+ } else if (p.warnCostUsd !== null && cost !== null && cost >= p.warnCostUsd) {
548
+ level = 'warn';
549
+ reasons.push(`session spend ${usd(cost)} passed the warning level of ${usd(p.warnCostUsd)}`);
550
+ }
551
+ if (p.maxContextTokens !== null && contextTokens !== null && contextTokens >= p.maxContextTokens) {
552
+ level = 'block';
553
+ reasons.push(`the prompt now carries ${compact(contextTokens)} tokens, at or above the declared cap of ${compact(p.maxContextTokens)}`);
554
+ } else if (p.warnContextTokens !== null && contextTokens !== null && contextTokens >= p.warnContextTokens) {
555
+ if (level === 'ok') level = 'warn';
556
+ reasons.push(`the prompt now carries ${compact(contextTokens)} tokens (warning level ${compact(p.warnContextTokens)})`);
557
+ }
558
+ if (p.warnMarginalUsd !== null && marginal !== null && marginal >= p.warnMarginalUsd) {
559
+ if (level === 'ok') level = 'warn';
560
+ reasons.push(`each of the last ${recent.length} turns cost about ${usd(marginal)} (warning level ${usd(p.warnMarginalUsd)})`);
561
+ }
562
+
563
+ const declared = Object.values(p).some((v) => v !== null);
564
+ let suggestion = null;
565
+ if (level !== 'ok') {
566
+ suggestion = contextTokens !== null && contextTokens > 100_000
567
+ ? 'Most of each turn now pays to re-send earlier context. Compact, or start a fresh session with a short brief, and the per-turn cost drops with it.'
568
+ : 'Consider finishing this task in a fresh session with a short brief, or narrowing the remaining scope.';
569
+ }
570
+
571
+ return {
572
+ level,
573
+ declared,
574
+ reasons,
575
+ suggestion,
576
+ cost,
577
+ coverage: f.coverage,
578
+ turns: f.turns,
579
+ subagentTurns: f.subagentTurns,
580
+ contextTokens,
581
+ marginalCostPerTurn: marginal,
582
+ recentTurns: recent.length,
583
+ contextShare: f.contextShare,
584
+ models: f.models,
585
+ first: f.first,
586
+ last: f.last,
587
+ };
588
+ }
589
+
590
+ // -------------------------------------------------------------- rendering ---
591
+
592
+ const money = (v) => (v === null || v === undefined ? '—' : usd(v));
593
+ const share = (v) => (v === null || v === undefined ? '—' : pct(v, 0));
594
+ const times = (v) => (v === null || v === undefined ? '—' : `${v >= 10 ? Math.round(v) : v.toFixed(1)}×`);
595
+
596
+ /**
597
+ * A receipt shaped for a pull-request comment.
598
+ * @param {object} b one branch receipt from buildReceipts()
599
+ * @param {{repo?:string, pricingVersion?:string}} [opt]
600
+ */
601
+ export function renderReceiptMarkdown(b, opt = {}) {
602
+ const L = [];
603
+ const title = b.pr ? `\`${b.key}\` · PR #${b.pr.number}` : `\`${b.key}\``;
604
+ L.push(`### 🧾 AI cost receipt — ${title}`);
605
+ if (opt.repo) L.push(`_${opt.repo}_`);
606
+ L.push('');
607
+ L.push('| | |');
608
+ L.push('|---|---|');
609
+ const costLine = b.cost === null
610
+ ? '— (no priced turns)'
611
+ : `**${money(b.cost)}**${b.contextShare !== null ? ` · ${share(b.contextShare)} re-sent context, ${share(1 - b.contextShare)} fresh work` : ''}`;
612
+ L.push(`| Estimated spend | ${costLine} |`);
613
+ L.push(`| Sessions · turns | ${b.sessions} · ${b.turns}${b.subagentShare !== null && b.subagentTurns > 0 ? ` (${share(b.subagentShare)} subagent)` : ''} |`);
614
+ if (b.models.length) {
615
+ L.push(`| Models | ${b.models.slice(0, 3).map((m) => `${m.model} ${share(m.share)}`).join(', ')}${b.models.length > 3 ? ', …' : ''} |`);
616
+ }
617
+ if (b.pr && b.changedLines !== null) {
618
+ L.push(`| Changed lines | +${b.pr.additions} / −${b.pr.deletions}${b.costPer100Lines !== null ? ` → ${money(b.costPer100Lines)} per 100 lines` : ''} |`);
619
+ }
620
+ if (b.vsMedian !== null) L.push(`| vs this repo's median branch | ${times(b.vsMedian)} |`);
621
+ if (b.maxPrompt !== null) L.push(`| Largest prompt | ${compact(b.maxPrompt)} tokens |`);
622
+ if (b.first && b.last) L.push(`| Window | ${shortDate(b.first.slice(0, 10))} → ${shortDate(b.last.slice(0, 10))}${b.prWindow && b.prWindow.mergedAt ? ` (merged ${shortDate(b.prWindow.mergedAt.slice(0, 10))})` : b.pr ? ' (PR open)' : ''} |`);
623
+ const w = b.prWindow;
624
+ if (w && w.afterMerge.turns > 0) {
625
+ L.push(`| After the merge, same branch | ${money(w.afterMerge.cost)} · ${w.afterMerge.sessions} session(s) · ${w.afterMerge.turns} turn(s) — follow-up on a checkout that kept this branch name; **not** counted above |`);
626
+ }
627
+ L.push('');
628
+ const notes = [];
629
+ if (w && w.beforeOpened.turns > 0 && w.beforeOpened.days !== null && w.beforeOpened.days > 14) {
630
+ notes.push(`${share(b.cost ? (w.beforeOpened.cost ?? 0) / b.cost : null)} of the total predates the PR by up to ${Math.round(w.beforeOpened.days)} days; on a long-lived branch that is earlier work, not this PR's.`);
631
+ }
632
+ if (b.longLived) notes.push(`\`${b.key}\` is a long-lived branch: this is a receipt for a period of work on it, not for one change.`);
633
+ if (w && w.priorPrs.count > 0) notes.push(`${w.priorPrs.count} earlier merged PR(s) on this branch hold another ${money(w.priorPrs.cost)}.`);
634
+ if (b.unpricedTurns > 0) notes.push(`${b.unpricedTurns} turn(s) used a model with no configured price and are not in the total.`);
635
+ notes.push(`Estimated locally by TokenFlow${opt.pricingVersion ? ` (price table ${opt.pricingVersion})` : ''} from the session logs already on this machine. No prompt or code content was read.`);
636
+ L.push(`<sub>${notes.join(' ')}</sub>`);
637
+ return L.join('\n');
638
+ }
639
+
640
+ /**
641
+ * Terminal table of branch receipts for one or more repositories.
642
+ * @param {ReturnType<typeof buildReceipts>} result
643
+ * @param {{top?:number}} [opt]
644
+ */
645
+ export function renderReceiptsTable(result, opt = {}) {
646
+ const top = opt.top ?? 20;
647
+ const L = [];
648
+ for (const R of result.repos) {
649
+ L.push(`${R.repo} — ${money(R.cost)} across ${R.branches.length} branch(es)${R.medianBranchCost !== null ? `, median branch ${money(R.medianBranchCost)}` : ''}`);
650
+ if (R.prs.supplied) L.push(` pull requests: ${R.prs.matched} of ${R.prs.supplied} matched to local sessions`);
651
+ const hdr = ` ${'cost'.padStart(10)} ${'ctx%'.padStart(4)} ${'sess'.padStart(4)} ${'turns'.padStart(6)} ${'sub%'.padStart(4)} ${'×med'.padStart(5)} ${'PR'.padStart(5)} ${'lines'.padStart(7)} ${'after-merge'.padStart(11)} branch`;
652
+ L.push(hdr);
653
+ for (const b of R.branches.slice(0, top)) {
654
+ const after = b.prWindow && b.prWindow.afterMerge.turns > 0 ? money(b.prWindow.afterMerge.cost) : (b.pr ? '—' : '');
655
+ L.push(` ${money(b.cost).padStart(10)} ${share(b.contextShare).padStart(4)} ${String(b.sessions).padStart(4)} ${String(b.turns).padStart(6)} ${share(b.subagentShare).padStart(4)} ${times(b.vsMedian).padStart(5)} ${(b.pr ? `#${b.pr.number}` : '—').padStart(5)} ${(b.changedLines === null ? '—' : String(b.changedLines)).padStart(7)} ${after.padStart(11)} ${b.key}${b.longLived ? ' (long-lived)' : ''}`);
656
+ }
657
+ if (R.branches.length > top) L.push(` … ${R.branches.length - top} more`);
658
+ const afterTotal = R.branches.reduce((a, b) => a + (b.prWindow ? (b.prWindow.afterMerge.cost ?? 0) : 0), 0);
659
+ if (afterTotal > 0) L.push(` spent after a merge on a branch that kept its name (not in any PR above): ${money(afterTotal)}`);
660
+ const un = R.unattributed;
661
+ if (un.turns > 0) L.push(` unattributed (detached HEAD / no branch): ${money(un.cost)} · ${un.sessions} session(s) · ${un.turns} turn(s)`);
662
+ if (R.prs.unmatched.length) {
663
+ const auto = R.prs.unmatched.filter((u) => u.automated === true).length;
664
+ const rest = R.prs.unmatched.length - auto;
665
+ L.push(` PRs with no local session: ${R.prs.unmatched.length}${auto ? ` (${auto} automated, ${rest} unexplained)` : ''}`);
666
+ }
667
+ L.push('');
668
+ }
669
+ const t = result.totals;
670
+ if (t.cost !== null) L.push(`Total ${money(t.cost)} · attributed to a branch ${share(t.attributedShare)} · ${t.branches} branch(es)`);
671
+ return L.join('\n');
672
+ }
673
+
674
+ /**
675
+ * @param {ReturnType<typeof sessionStats>} s
676
+ */
677
+ export function renderSessionStats(s) {
678
+ const L = [];
679
+ L.push(`Sessions: ${s.sessions.toLocaleString('en-US')} estimated spend ${money(s.totalCost)}`);
680
+ if (s.contextShare !== null) {
681
+ L.push(` context (re-sent prompt) ${money(s.contextCost)} = ${share(s.contextShare)} fresh input + output ${money(s.workCost)} = ${share(1 - s.contextShare)}`);
682
+ }
683
+ L.push(` median session ${money(s.medianSessionCost)} · p90 ${money(s.p90SessionCost)} · top 1% of sessions = ${share(s.top1pctShare)} of spend · top 10% = ${share(s.top10pctShare)}`);
684
+ L.push(` sessions that moved across branches: ${s.multiBranchSessions}`);
685
+ L.push('');
686
+ L.push('Median cost of a turn, by how deep into the session it is (per-request sources only):');
687
+ for (const m of s.marginalByTurnIndex) L.push(` turns ${String(m.turns).padEnd(9)} ${money(m.medianCostPerTurn).padStart(8)} (n=${m.samples.toLocaleString('en-US')})`);
688
+ const lp = s.largePromptTurns;
689
+ if (lp.turnShare !== null) {
690
+ L.push(` turns carrying >${compact(lp.threshold)} prompt tokens: ${share(lp.turnShare)} of turns, ${share(lp.costShare)} of their spend [sources: ${lp.sources.join(', ')}]`);
691
+ }
692
+ L.push('');
693
+ L.push('Dollars above a per-session cap (an upper bound on what a guard could have held back, not a saving):');
694
+ for (const c of s.capTable) L.push(` cap ${money(c.cap).padStart(6)}: ${String(c.sessionsOver).padStart(5)} session(s) over · ${money(c.costAbove).padStart(10)} above the cap · ${share(c.shareAbove)} of spend`);
695
+ return L.join('\n');
696
+ }
697
+
698
+ /** Human-readable guard verdict. */
699
+ export function renderGuard(v) {
700
+ const L = [];
701
+ const tag = v.level === 'block' ? 'BLOCK' : v.level === 'warn' ? 'WARN' : 'ok';
702
+ L.push(`[${tag}] session spend ${money(v.cost)}${v.coverage !== null && v.coverage < 1 ? ` (priced ${share(v.coverage)} of turns)` : ''} · ${v.turns} turn(s)${v.subagentTurns ? ` (${v.subagentTurns} subagent)` : ''}`);
703
+ if (v.contextTokens !== null) L.push(` prompt now carries ${compact(v.contextTokens)} tokens · median cost of the last ${v.recentTurns} turn(s) ${money(v.marginalCostPerTurn)}${v.contextShare !== null ? ` · ${share(v.contextShare)} of spend re-sent context` : ''}`);
704
+ if (v.models.length) L.push(` models: ${v.models.slice(0, 3).map((m) => `${m.model} ${share(m.share)}`).join(', ')}`);
705
+ for (const r of v.reasons) L.push(` ! ${r}`);
706
+ if (v.suggestion) L.push(` → ${v.suggestion}`);
707
+ if (!v.declared) L.push(' (no guard thresholds declared — informational only; see `tokenflow guard --set`)');
708
+ return L.join('\n');
709
+ }