@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.
- package/CHANGELOG.md +180 -0
- package/Dockerfile.team +20 -0
- package/README.md +30 -11
- package/bin/tokenflow.js +147 -12
- package/design/tokens.yaml +330 -0
- package/docs/architecture.md +5 -4
- package/docs/cli.md +204 -0
- package/docs/configuration.md +117 -2
- package/docs/design-system.md +187 -0
- package/docs/exports-and-budgets.md +85 -0
- package/docs/guard-codex.md +132 -0
- package/docs/ledger.md +144 -0
- package/docs/live-mode.md +40 -0
- package/docs/media/overview-aurora-dark.png +0 -0
- package/docs/media/receipts-aurora-dark.png +0 -0
- package/docs/providers-otel.md +179 -0
- package/docs/providers.md +54 -1
- package/docs/receipt-schema.md +74 -0
- package/docs/roadmap.md +182 -0
- package/docs/team-server.md +170 -0
- package/docs/ui-views.md +322 -0
- package/package.json +7 -2
- package/schemas/receipt.v0.json +160 -0
- package/scripts/build-menubar-app.sh +3 -1
- package/scripts/design-build.js +475 -0
- package/src/analytics/anatomy.js +467 -0
- package/src/analytics/branch-compare.js +159 -0
- package/src/analytics/cache-health.js +141 -0
- package/src/analytics/live-view.js +266 -0
- package/src/analytics/receipt-schema.js +214 -0
- package/src/analytics/receipt.js +709 -0
- package/src/analytics/rhythm.js +184 -0
- package/src/analytics/whatif.js +263 -0
- package/src/commands/budget-scopes.js +133 -0
- package/src/commands/doctor-checks.js +400 -0
- package/src/commands/guard.js +531 -0
- package/src/commands/hooks.js +238 -0
- package/src/commands/pricing-diff.js +316 -0
- package/src/commands/receipt.js +226 -0
- package/src/commands/team-serve.js +407 -0
- package/src/commands/week.js +86 -0
- package/src/core/annotations.js +97 -0
- package/src/core/budget.js +33 -0
- package/src/core/bundle.js +45 -2
- package/src/core/ingest.js +33 -0
- package/src/core/live-status.js +227 -2
- package/src/core/policy.js +103 -0
- package/src/core/receipt-note.js +123 -0
- package/src/core/repo.js +64 -0
- package/src/core/sync.js +163 -26
- package/src/core/team.js +0 -0
- package/src/export/html-snapshot.js +28 -1
- package/src/export/menubar.js +21 -0
- package/src/export/receipt-card.js +210 -0
- package/src/export/week-card.js +185 -0
- package/src/providers/mock/index.js +383 -52
- package/src/providers/openai/index.js +31 -1
- package/src/providers/otel/index.js +656 -0
- package/src/server/routes/annotations.js +42 -0
- package/src/server/routes/cache-health.js +95 -0
- package/src/server/routes/index.js +54 -0
- package/src/server/routes/session.js +157 -0
- package/src/server/server.js +47 -1
- package/src/ui/app.js +541 -308
- package/src/ui/charts.js +95 -0
- package/src/ui/first-run.js +144 -0
- package/src/ui/index.html +4 -1
- package/src/ui/palette.js +335 -0
- package/src/ui/styles/anatomy.css +117 -0
- package/src/ui/styles/annotations.css +40 -0
- package/src/ui/styles/branches.css +99 -0
- package/src/ui/styles/cache.css +6 -0
- package/src/ui/styles/first-run.css +31 -0
- package/src/ui/styles/live.css +100 -0
- package/src/ui/styles/palette.css +85 -0
- package/src/ui/styles/rhythm.css +8 -0
- package/src/ui/styles/whatif.css +55 -0
- package/src/ui/styles.css +303 -196
- package/src/ui/views/anatomy.js +567 -0
- package/src/ui/views/annotations.js +121 -0
- package/src/ui/views/branches.js +304 -0
- package/src/ui/views/cache.js +232 -0
- package/src/ui/views/index.js +85 -0
- package/src/ui/views/live.js +683 -0
- package/src/ui/views/rhythm.js +206 -0
- 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
|
+
}
|