@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,467 @@
1
+ /**
2
+ * Session anatomy: where one session's money went, turn by turn.
3
+ *
4
+ * The rest of the analytics layer works on the pre-aggregated cube, which has
5
+ * no notion of a turn. This module works on the request-level records of ONE
6
+ * session, which is the only place the shape of a session is visible: the turn
7
+ * where the context stopped being cheap, the block of subagent turns that
8
+ * doubled the bill, the point where per-turn cost stepped up and stayed there.
9
+ *
10
+ * Pure: no Node imports and no DOM, so the server route, the browser view and
11
+ * the tests all get identical answers. Cost is estimated from the same price
12
+ * book the Cost tab prices with (core/pricing.js), never from a stored total,
13
+ * so a turn whose model is unpriced reports null and is counted as unpriced —
14
+ * it never reports 0.
15
+ */
16
+ import { estimateCost } from '../core/pricing.js';
17
+
18
+ /** @typedef {ReturnType<typeof import('../core/pricing.js').buildPriceBook>} PriceBook */
19
+
20
+ /** Turns of history on each side of a candidate step. */
21
+ export const STEP_WINDOW = 20;
22
+ /** How much higher the next window's median must be to count as a step. */
23
+ export const STEP_RATIO = 2;
24
+ /** …and by how many dollars, so a step from $0.0001 to $0.0004 is not news. */
25
+ export const STEP_MIN_ABS = 0.02;
26
+
27
+ /**
28
+ * One turn of a session, priced.
29
+ *
30
+ * @typedef {object} Turn
31
+ * @property {number} index 0-based position in the session
32
+ * @property {number} turn 1-based turn number — what the UI shows
33
+ * @property {string|null} ts ISO timestamp
34
+ * @property {string|null} model
35
+ * @property {string|null} provider
36
+ * @property {string|null} source adapter id
37
+ * @property {string|null} category record category ('main', 'subagent', …)
38
+ * @property {boolean} subagent true when the category marks a subagent turn
39
+ * @property {string|null} agent agent name the source recorded, if any
40
+ * @property {string|null} parent parent link the source recorded, if any
41
+ * @property {string|null} requestId
42
+ * @property {number|null} cost estimated dollars for this turn; null when unpriced
43
+ * @property {number|null} cumulative running total over the priced turns so far
44
+ * @property {number|null} input fresh input tokens
45
+ * @property {number|null} cacheRead
46
+ * @property {number|null} cacheWrite
47
+ * @property {number|null} cacheRefresh subset of cacheWrite
48
+ * @property {number|null} output
49
+ * @property {number|null} reasoning subset of output
50
+ * @property {number|null} promptTokens input + cache read + cache write
51
+ * @property {number|null} cacheReadShare cache read / prompt tokens
52
+ */
53
+
54
+ /**
55
+ * Price every turn of a session and carry the running total.
56
+ *
57
+ * Records are sorted by timestamp first, so a caller that read them out of a
58
+ * shard in file order still gets a chronological series. `cumulative` stays
59
+ * null until the first priced turn and then holds flat across unpriced ones:
60
+ * an unknown turn must not look like a free one.
61
+ *
62
+ * @param {object[]} records slimmed records, or anything with the same fields
63
+ * @param {PriceBook|null} book the price book the Cost tab uses
64
+ * @returns {Turn[]}
65
+ */
66
+ export function turnSeries(records, book) {
67
+ const rows = [...(records || [])].sort(byTimestamp);
68
+ const out = [];
69
+ let cumulative = null;
70
+ for (let i = 0; i < rows.length; i++) {
71
+ const r = rows[i];
72
+ const input = num(r.input_tokens);
73
+ const cacheRead = num(r.cache_read_tokens);
74
+ const cacheWrite = num(r.cache_write_tokens);
75
+ const cacheRefresh = num(r.cache_refresh_tokens);
76
+ const output = num(r.output_tokens);
77
+ const reasoning = num(r.reasoning_tokens);
78
+ const cost = priceOf(r, book, { input, cacheRead, cacheWrite, cacheRefresh, output });
79
+ if (cost !== null) cumulative = (cumulative === null ? 0 : cumulative) + cost;
80
+ const promptTokens = sumOrNull([input, cacheRead, cacheWrite]);
81
+ out.push({
82
+ index: i,
83
+ turn: i + 1,
84
+ ts: tsOf(r),
85
+ model: str(r.model),
86
+ provider: str(r.provider),
87
+ source: str(r.source),
88
+ category: str(r.category),
89
+ subagent: r.category === 'subagent',
90
+ agent: str(r.agent),
91
+ parent: parentOf(r),
92
+ requestId: str(r.request_id),
93
+ cost,
94
+ cumulative,
95
+ input,
96
+ cacheRead,
97
+ cacheWrite,
98
+ cacheRefresh,
99
+ output,
100
+ reasoning,
101
+ promptTokens,
102
+ cacheReadShare: promptTokens !== null && promptTokens > 0 && cacheRead !== null
103
+ ? cacheRead / promptTokens
104
+ : null,
105
+ });
106
+ }
107
+ return out;
108
+ }
109
+
110
+ /**
111
+ * Roll a priced series up into the numbers a header needs.
112
+ * @param {Turn[]} series
113
+ */
114
+ export function summarizeTurns(series) {
115
+ const turns = series.length;
116
+ let cost = null;
117
+ let priced = 0;
118
+ let subagentTurns = 0;
119
+ let subagentCost = null;
120
+ let cacheRead = 0;
121
+ let prompt = 0;
122
+ let promptTurns = 0;
123
+ for (const t of series) {
124
+ if (t.cost !== null) {
125
+ cost = (cost === null ? 0 : cost) + t.cost;
126
+ priced++;
127
+ if (t.subagent) subagentCost = (subagentCost === null ? 0 : subagentCost) + t.cost;
128
+ }
129
+ if (t.subagent) subagentTurns++;
130
+ if (t.promptTokens !== null) {
131
+ prompt += t.promptTokens;
132
+ promptTurns++;
133
+ if (t.cacheRead !== null) cacheRead += t.cacheRead;
134
+ }
135
+ }
136
+ const first = series.find((t) => t.promptTokens !== null) || null;
137
+ let last = null;
138
+ for (let i = series.length - 1; i >= 0; i--) {
139
+ if (series[i].promptTokens !== null) { last = series[i]; break; }
140
+ }
141
+ return {
142
+ turns,
143
+ priced,
144
+ unpriced: turns - priced,
145
+ cost,
146
+ subagentTurns,
147
+ subagentCost,
148
+ subagentShare: cost !== null && cost > 0 && subagentCost !== null ? subagentCost / cost : null,
149
+ avgCost: priced > 0 && cost !== null ? cost / priced : null,
150
+ cacheReadShare: prompt > 0 ? cacheRead / prompt : null,
151
+ promptFirst: first ? first.promptTokens : null,
152
+ promptLast: last ? last.promptTokens : null,
153
+ promptPeak: promptTurns ? Math.max(...series.filter((t) => t.promptTokens !== null).map((t) => t.promptTokens)) : null,
154
+ from: series.length ? series[0].ts : null,
155
+ to: series.length ? series[series.length - 1].ts : null,
156
+ };
157
+ }
158
+
159
+ /**
160
+ * The turn where per-turn cost stepped up and stayed up.
161
+ *
162
+ * A rolling median of the previous 20 turns against the next 20: robust to the
163
+ * one enormous turn that a mean would chase, and blind to a spike that comes
164
+ * straight back down, which is the point — a step is a change in the regime,
165
+ * not an outlier. Both windows must be full AND at least half of each window
166
+ * must be priced, so a session shorter than 40 turns has no answer, and a
167
+ * window holding one priced turn among nineteen unknown ones is not allowed to
168
+ * pass a one-point "median" off as a regime.
169
+ *
170
+ * @param {Turn[]} series from {@link turnSeries}
171
+ * @param {{window?:number, ratio?:number, minAbs?:number, minPriced?:number}} [opt]
172
+ * @returns {{index:number, turn:number, from:number, to:number, ratio:number, step:number, window:number}|null}
173
+ */
174
+ export function detectStep(series, opt = {}) {
175
+ const w = opt.window ?? STEP_WINDOW;
176
+ const minRatio = opt.ratio ?? STEP_RATIO;
177
+ const minAbs = opt.minAbs ?? STEP_MIN_ABS;
178
+ const minPriced = Math.min(w, opt.minPriced ?? Math.ceil(w / 2));
179
+ const costs = (series || []).map((t) => t.cost);
180
+ for (let i = w; i + w <= costs.length; i++) {
181
+ const lhs = costs.slice(i - w, i);
182
+ const rhs = costs.slice(i, i + w);
183
+ if (priced(lhs) < minPriced || priced(rhs) < minPriced) continue;
184
+ const before = median(lhs);
185
+ const after = median(rhs);
186
+ if (before === null || after === null) continue;
187
+ const step = after - before;
188
+ if (step < minAbs) continue;
189
+ // A rise from a measured zero has no finite ratio; the absolute test above
190
+ // is what keeps it honest.
191
+ const ratio = before > 0 ? after / before : Infinity;
192
+ if (ratio < minRatio) continue;
193
+ return { index: i, turn: i + 1, from: before, to: after, ratio, step, window: w };
194
+ }
195
+ return null;
196
+ }
197
+
198
+ /**
199
+ * How the session fanned out into subagents.
200
+ *
201
+ * No adapter records a parent link today — Claude Code marks a sidechain,
202
+ * OpenCode and Hermes fold their parent id into `category` — so the honest
203
+ * fallback is position: contiguous runs of subagent turns, labelled as
204
+ * grouped so the UI never presents a guess as a hierarchy. If a source ever
205
+ * does carry a link, the tree is built from it instead.
206
+ *
207
+ * Pass the turns from {@link turnSeries} to get cost shares; raw records with
208
+ * no cost fall back to a share of turns, which `shareBasis` reports.
209
+ *
210
+ * @param {(Turn|object)[]} records turns or slimmed records, in order
211
+ * @returns {{grouped:boolean, shareBasis:'cost'|'turns', groups:object[], roots:object[],
212
+ * turns:number, subagentTurns:number, cost:number|null, subagentCost:number|null}}
213
+ */
214
+ export function fanOut(records) {
215
+ const rows = [...(records || [])].sort(byTimestamp);
216
+ const total = totals(rows);
217
+ const linked = rows.some((r) => parentOf(r) !== null);
218
+ const base = {
219
+ turns: rows.length,
220
+ subagentTurns: rows.filter(isSubagent).length,
221
+ cost: total.cost,
222
+ subagentCost: total.subagentCost,
223
+ shareBasis: /** @type {'cost'|'turns'} */ (total.cost !== null && total.cost > 0 ? 'cost' : 'turns'),
224
+ };
225
+ const shareOf = (cost, turns) => {
226
+ if (base.shareBasis === 'cost') return cost === null ? null : cost / /** @type {number} */ (base.cost);
227
+ return base.turns > 0 ? turns / base.turns : null;
228
+ };
229
+
230
+ if (!linked) {
231
+ const groups = contiguousGroups(rows).map((g) => ({ ...g, share: shareOf(g.cost, g.turns) }));
232
+ return { ...base, grouped: true, groups, roots: [] };
233
+ }
234
+ return { ...base, grouped: false, groups: [], roots: buildTree(rows, shareOf) };
235
+ }
236
+
237
+ /**
238
+ * Sources that write one row per session rather than one per request. Hermes
239
+ * is the only one: its `messages.token_count` is unpopulated, so the finest
240
+ * honest granularity it can offer is session x model (see its adapter header).
241
+ *
242
+ * This is a deny-list on purpose. receipt.js keeps the mirror-image allow-list
243
+ * (PER_REQUEST_SOURCES) because a statistic that would be *skewed* by an
244
+ * aggregate row should only trust sources it knows. Here the cost of being
245
+ * wrong runs the other way: an unlisted per-request adapter (otel, generic, or
246
+ * the next one written) must not lose its charts to a card claiming it reports
247
+ * one row per session. So the unknown case fails open to 'per-turn', where the
248
+ * worst outcome is a chart of one point rather than a false statement.
249
+ */
250
+ export const SESSION_LEVEL_SOURCES = ['hermes'];
251
+
252
+ /**
253
+ * 'session-level' when every record comes from a source that writes one row
254
+ * per session, 'per-turn' otherwise, including when the source is unknown.
255
+ *
256
+ * @param {object[]} records
257
+ * @returns {'per-turn'|'session-level'}
258
+ */
259
+ export function sessionKind(records) {
260
+ const rows = records || [];
261
+ if (!rows.length) return 'per-turn';
262
+ let sawAggregate = false;
263
+ for (const r of rows) {
264
+ const so = str(r.source);
265
+ if (so === null) continue;
266
+ if (!SESSION_LEVEL_SOURCES.includes(so)) return 'per-turn';
267
+ sawAggregate = true;
268
+ }
269
+ return sawAggregate ? 'session-level' : 'per-turn';
270
+ }
271
+
272
+ // ------------------------------------------------------------------ internals
273
+
274
+ /** Contiguous runs of subagent turns, and the main-agent runs between them. */
275
+ function contiguousGroups(rows) {
276
+ const out = [];
277
+ let cur = null;
278
+ rows.forEach((r, i) => {
279
+ const kind = isSubagent(r) ? 'subagent' : 'main';
280
+ if (!cur || cur.kind !== kind) {
281
+ cur = {
282
+ kind,
283
+ key: `${kind}-${out.length + 1}`,
284
+ startTurn: i + 1,
285
+ endTurn: i + 1,
286
+ turns: 0,
287
+ cost: null,
288
+ agents: [],
289
+ };
290
+ out.push(cur);
291
+ }
292
+ cur.endTurn = i + 1;
293
+ cur.turns++;
294
+ const c = costOf(r);
295
+ if (c !== null) cur.cost = (cur.cost === null ? 0 : cur.cost) + c;
296
+ const a = str(r.agent);
297
+ if (a !== null && !cur.agents.includes(a)) cur.agents.push(a);
298
+ });
299
+ return out;
300
+ }
301
+
302
+ /**
303
+ * A tree over the parent links a source recorded. One node per distinct parent
304
+ * value: the turns that name it as their parent. A node nests under the group
305
+ * that owns the turn its key points at, so a subagent that spawned its own
306
+ * subagent nests two deep.
307
+ */
308
+ function buildTree(rows, shareOf) {
309
+ const byKey = new Map();
310
+ const ownerOf = new Map();
311
+ for (const r of rows) {
312
+ const id = linkIdOf(r);
313
+ if (id !== null && !ownerOf.has(id)) ownerOf.set(id, r);
314
+ }
315
+ rows.forEach((r, i) => {
316
+ const key = parentOf(r);
317
+ let node = byKey.get(key);
318
+ if (!node) {
319
+ node = {
320
+ key,
321
+ label: null,
322
+ startTurn: i + 1,
323
+ endTurn: i + 1,
324
+ turns: 0,
325
+ cost: null,
326
+ depth: 0,
327
+ agents: [],
328
+ children: [],
329
+ };
330
+ byKey.set(key, node);
331
+ }
332
+ node.endTurn = i + 1;
333
+ node.turns++;
334
+ const c = costOf(r);
335
+ if (c !== null) node.cost = (node.cost === null ? 0 : node.cost) + c;
336
+ const a = str(r.agent);
337
+ if (a !== null && !node.agents.includes(a)) node.agents.push(a);
338
+ });
339
+
340
+ const roots = [];
341
+ const parentNodeOf = new Map();
342
+ for (const [key, node] of byKey) {
343
+ node.label = node.agents[0] ?? (key === null ? 'main' : String(key));
344
+ node.share = shareOf(node.cost, node.turns);
345
+ const owner = key === null ? null : ownerOf.get(key);
346
+ const parentKey = owner ? parentOf(owner) : null;
347
+ const parentNode = owner && parentKey !== key ? byKey.get(parentKey) : null;
348
+ if (parentNode && parentNode !== node) {
349
+ parentNode.children.push(node);
350
+ parentNodeOf.set(node, parentNode);
351
+ } else {
352
+ roots.push(node);
353
+ }
354
+ }
355
+ // Every node in a link cycle is some other node's child, so no root reaches
356
+ // it and the whole branch would silently disappear. Promote the first
357
+ // unreachable node, cut the edge that pointed at it, and repeat.
358
+ const reachable = new Set();
359
+ const mark = (n) => {
360
+ if (reachable.has(n)) return;
361
+ reachable.add(n);
362
+ for (const c of n.children) mark(c);
363
+ };
364
+ for (const r of roots) mark(r);
365
+ for (const node of byKey.values()) {
366
+ if (reachable.has(node)) continue;
367
+ const parentNode = parentNodeOf.get(node);
368
+ if (parentNode) parentNode.children.splice(parentNode.children.indexOf(node), 1);
369
+ roots.push(node);
370
+ mark(node);
371
+ }
372
+ // Depth is what the view indents by, so it has to be finite.
373
+ for (const r of roots) setDepth(r, 0, new Set());
374
+ return roots;
375
+ }
376
+
377
+ function setDepth(node, depth, ancestors) {
378
+ node.depth = depth;
379
+ if (depth >= 12) { node.children = []; return; }
380
+ const path = new Set(ancestors).add(node);
381
+ node.children = node.children.filter((c) => !path.has(c));
382
+ for (const c of node.children) setDepth(c, depth + 1, path);
383
+ }
384
+
385
+ function totals(rows) {
386
+ let cost = null;
387
+ let subagentCost = null;
388
+ for (const r of rows) {
389
+ const c = costOf(r);
390
+ if (c === null) continue;
391
+ cost = (cost === null ? 0 : cost) + c;
392
+ if (isSubagent(r)) subagentCost = (subagentCost === null ? 0 : subagentCost) + c;
393
+ }
394
+ return { cost, subagentCost };
395
+ }
396
+
397
+ /** A turn already carries its price; a raw record may carry the stored estimate. */
398
+ function costOf(r) {
399
+ const v = r.cost ?? r.estimated_cost ?? null;
400
+ return v === null || v === undefined || Number.isNaN(Number(v)) ? null : Number(v);
401
+ }
402
+
403
+ function isSubagent(r) {
404
+ return r.subagent === true || r.category === 'subagent';
405
+ }
406
+
407
+ function priceOf(r, book, tok) {
408
+ if (!book) return null;
409
+ const est = estimateCost({
410
+ input_tokens: tok.input,
411
+ output_tokens: tok.output,
412
+ cache_read_tokens: tok.cacheRead,
413
+ cache_write_tokens: tok.cacheWrite,
414
+ cache_refresh_tokens: tok.cacheRefresh,
415
+ }, r.model, r.provider, book, { tier: r.service_tier ?? null });
416
+ return est.cost;
417
+ }
418
+
419
+ /** The id another record's parent link would point at. */
420
+ function linkIdOf(r) {
421
+ return str(r.id) ?? str(r.requestId) ?? str(r.request_id);
422
+ }
423
+
424
+ function parentOf(r) {
425
+ return str(r.parent) ?? str(r.parent_id) ?? str(r.parent_session_id);
426
+ }
427
+
428
+ function tsOf(r) {
429
+ return str(r.ts) ?? str(r.timestamp);
430
+ }
431
+
432
+ function byTimestamp(a, b) {
433
+ const x = tsOf(a);
434
+ const y = tsOf(b);
435
+ if (x === y) return 0;
436
+ if (x === null) return 1;
437
+ if (y === null) return -1;
438
+ return x < y ? -1 : 1;
439
+ }
440
+
441
+ /** How many of these turns carry a cost at all. */
442
+ function priced(values) {
443
+ let n = 0;
444
+ for (const v of values) if (v !== null && v !== undefined && !Number.isNaN(v)) n++;
445
+ return n;
446
+ }
447
+
448
+ function median(values) {
449
+ const v = values.filter((x) => x !== null && x !== undefined && !Number.isNaN(x)).sort((a, b) => a - b);
450
+ if (!v.length) return null;
451
+ const mid = v.length >> 1;
452
+ return v.length % 2 ? v[mid] : (v[mid - 1] + v[mid]) / 2;
453
+ }
454
+
455
+ function sumOrNull(parts) {
456
+ let t = null;
457
+ for (const v of parts) if (v !== null) t = (t === null ? 0 : t) + v;
458
+ return t;
459
+ }
460
+
461
+ function num(v) {
462
+ return v === null || v === undefined || Number.isNaN(Number(v)) ? null : Number(v);
463
+ }
464
+
465
+ function str(v) {
466
+ return v === null || v === undefined || v === '' ? null : String(v);
467
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Branch-vs-branch comparison — any two branch receipts from buildReceipts()
3
+ * (src/analytics/receipt.js), side by side.
4
+ *
5
+ * Every metric gets a signed position on a symmetric log scale: a 4x cost
6
+ * difference reads the same distance from parity whichever side is bigger,
7
+ * and "twice as expensive" and "half as expensive" are mirror images instead
8
+ * of a percentage that blows up on one side and flattens on the other.
9
+ *
10
+ * Pure: no Node imports, so the CLI, the server and the browser agree.
11
+ */
12
+
13
+ /**
14
+ * @typedef {object} BranchReceipt one entry from buildReceipts().repos[].branches[]
15
+ * @property {string} key
16
+ * @property {number|null} cost
17
+ * @property {number} turns
18
+ * @property {number} sessions
19
+ * @property {number|null} contextShare
20
+ * @property {number|null} subagentShare
21
+ * @property {number} subagentTurns
22
+ * @property {{model:string,cost:number,share:number|null}[]} models
23
+ * @property {number|null} vsMedian
24
+ * @property {number|null} changedLines
25
+ * @property {number|null} costPer100Lines
26
+ * @property {boolean} longLived
27
+ * @property {string|null} first
28
+ * @property {string|null} last
29
+ * @property {object|null} pr
30
+ */
31
+
32
+ /**
33
+ * @typedef {object} CompareRow
34
+ * @property {string} key metric identifier
35
+ * @property {string} label display label
36
+ * @property {'cost'|'count'|'share'|'ratio'|'models'} kind how the UI should format valueA/valueB
37
+ * @property {number|any} valueA
38
+ * @property {number|any} valueB
39
+ * @property {number|null} ratio A over B; null when either side is null or B is zero
40
+ * @property {number|null} position signed log2(ratio)/2, clamped to [-1, 1]; null when ratio is null
41
+ */
42
+
43
+ /** @type {[string, string, 'cost'|'count'|'share'|'ratio'][]} */
44
+ const METRICS = [
45
+ ['cost', 'Cost', 'cost'],
46
+ ['turns', 'Turns', 'count'],
47
+ ['sessions', 'Sessions', 'count'],
48
+ ['contextShare', 'Context share', 'share'],
49
+ ['subagentShare', 'Subagent share', 'share'],
50
+ ['costPerTurn', 'Cost per turn', 'cost'],
51
+ ['costPer100Lines', 'Cost per 100 lines', 'cost'],
52
+ ['vsMedian', "vs this repo's median branch", 'ratio'],
53
+ ];
54
+
55
+ /** Cost per turn is not stored on the receipt; derive it from cost and turns. */
56
+ function costPerTurn(b) {
57
+ if (!b || b.cost === null || b.cost === undefined || !(b.turns > 0)) return null;
58
+ return b.cost / b.turns;
59
+ }
60
+
61
+ function valueFor(b, key) {
62
+ if (key === 'costPerTurn') return costPerTurn(b);
63
+ if (!b) return null;
64
+ const v = b[key];
65
+ return v === null || v === undefined ? null : v;
66
+ }
67
+
68
+ /**
69
+ * A over B, and its signed position on the symmetric log scale.
70
+ * Ratio 1 sits at 0, ratio 4 or more clamps to +1, ratio 0.25 or less clamps
71
+ * to -1 — a doubling always moves the same distance, in either direction.
72
+ * @param {number|null} valueA
73
+ * @param {number|null} valueB
74
+ */
75
+ function ratioAndPosition(valueA, valueB) {
76
+ if (valueA === null || valueB === null || valueB === 0) return { ratio: null, position: null };
77
+ const ratio = valueA / valueB;
78
+ const position = Math.max(-1, Math.min(1, Math.log2(ratio) / 2));
79
+ return { ratio, position };
80
+ }
81
+
82
+ /**
83
+ * Compare two branch receipts metric by metric. Either side may be `null`
84
+ * (nothing picked yet); every row degrades to null values rather than
85
+ * throwing.
86
+ * @param {BranchReceipt|null} a
87
+ * @param {BranchReceipt|null} b
88
+ * @returns {CompareRow[]}
89
+ */
90
+ export function compareBranches(a, b) {
91
+ /** @type {CompareRow[]} */
92
+ const rows = [];
93
+ for (const [key, label, kind] of METRICS) {
94
+ const valueA = valueFor(a, key);
95
+ const valueB = valueFor(b, key);
96
+ const { ratio, position } = ratioAndPosition(valueA, valueB);
97
+ rows.push({ key, label, kind, valueA, valueB, ratio, position });
98
+ }
99
+ rows.push({
100
+ key: 'models',
101
+ label: 'Models',
102
+ kind: 'models',
103
+ valueA: a ? a.models || [] : [],
104
+ valueB: b ? b.models || [] : [],
105
+ ratio: null,
106
+ position: null,
107
+ });
108
+ return rows;
109
+ }
110
+
111
+ /**
112
+ * Look up one branch receipt by (repo, branch key). Returns null on a miss —
113
+ * the store may have moved on since a selection was persisted.
114
+ * @param {{repos:{repo:string,branches:BranchReceipt[]}[]}|null|undefined} receipts
115
+ * @param {string} repo
116
+ * @param {string} key
117
+ * @returns {BranchReceipt|null}
118
+ */
119
+ export function findBranch(receipts, repo, key) {
120
+ if (!receipts || !Array.isArray(receipts.repos)) return null;
121
+ const R = receipts.repos.find((r) => r.repo === repo);
122
+ if (!R) return null;
123
+ return R.branches.find((b) => b.key === key) || null;
124
+ }
125
+
126
+ /**
127
+ * A reasonable default pair to open the tab with: the most expensive feature
128
+ * branch (any repo) versus the median-cost feature branch of that same repo —
129
+ * "feature branch" meaning not long-lived and with a priced cost. Falls back
130
+ * to the two highest-cost branches overall (ignoring the feature-branch
131
+ * filter) when the repo that owns the top branch has no second feature
132
+ * branch to compare it to. Returns null when there are fewer than two
133
+ * branches anywhere.
134
+ * @param {{repos:{repo:string,branches:BranchReceipt[]}[]}|null|undefined} receipts
135
+ * @returns {{a:{repo:string,key:string}, b:{repo:string,key:string}}|null}
136
+ */
137
+ export function pickDefault(receipts) {
138
+ if (!receipts || !Array.isArray(receipts.repos)) return null;
139
+ const all = [];
140
+ for (const R of receipts.repos) for (const b of R.branches) all.push({ repo: R.repo, b });
141
+ if (all.length < 2) return null;
142
+
143
+ const pool = all.filter((x) => !x.b.longLived && x.b.cost !== null);
144
+ if (pool.length >= 2) {
145
+ const top = pool.reduce((best, x) => (x.b.cost > best.b.cost ? x : best));
146
+ const sameRepo = pool.filter((x) => x.repo === top.repo).sort((p, q) => p.b.cost - q.b.cost);
147
+ // `top` is the globally most expensive feature branch, so it is also the
148
+ // most expensive within its own repo — it always lands last in
149
+ // `sameRepo`, never at the median index, so the pair is never one branch
150
+ // against itself.
151
+ if (sameRepo.length >= 2) {
152
+ const median = sameRepo[Math.floor((sameRepo.length - 1) / 2)];
153
+ return { a: { repo: top.repo, key: top.b.key }, b: { repo: median.repo, key: median.b.key } };
154
+ }
155
+ }
156
+
157
+ const byCost = [...all].sort((p, q) => (q.b.cost ?? -1) - (p.b.cost ?? -1));
158
+ return { a: { repo: byCost[0].repo, key: byCost[0].b.key }, b: { repo: byCost[1].repo, key: byCost[1].b.key } };
159
+ }