@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,567 @@
1
+ /**
2
+ * Session anatomy: pick one session and see where its money went.
3
+ *
4
+ * Every other tab aggregates. This one does the opposite: it takes a single
5
+ * session's turns and draws the shape of the spend — the waterfall of turn
6
+ * costs against the running total, the prompt growing as the context fills,
7
+ * the block of subagent turns, and the turn where per-turn cost stepped up and
8
+ * stayed up.
9
+ *
10
+ * Turn-level records are the one thing the bundle does not carry, so this tab
11
+ * fetches them from /api/session and degrades to the picker plus an empty
12
+ * state in a saved snapshot, which has no server to ask.
13
+ *
14
+ * Cost here is estimated in the browser from the same price book the Cost tab
15
+ * uses, never from a stored total, and an unpriced turn stays null all the way
16
+ * to the screen.
17
+ */
18
+ import { el, svg, niceTicks, tooltip, tipBody, timeSeries, compositionBar, legend, areaGradient } from '../charts.js';
19
+ import { buildPriceBook } from '../../core/pricing.js';
20
+ import { MEASUREMENT } from '../../core/schema.js';
21
+ import { turnSeries, summarizeTurns, detectStep, fanOut, sessionKind, STEP_WINDOW, STEP_RATIO, STEP_MIN_ABS } from '../../analytics/anatomy.js';
22
+
23
+ /** @typedef {import('./index.js').ViewContext} ViewContext */
24
+
25
+ export const id = 'anatomy';
26
+ export const label = 'Session anatomy';
27
+ export const order = 25;
28
+ export const css = './styles/anatomy.css';
29
+
30
+ /** Sessions offered in the picker, most expensive first. */
31
+ const PICK_LIMIT = 50;
32
+
33
+ /** The only measurement with per-request records behind it. */
34
+ const PRIMARY = MEASUREMENT.PRIMARY;
35
+
36
+ const MAIN_COLOR = 'var(--series-1)';
37
+ const SUB_COLOR = 'var(--series-2)';
38
+ const CUM_COLOR = 'var(--series-3)';
39
+
40
+ /**
41
+ * This tab's slice of dashboard state. app.js owns `S`; `S.anatomy` is ours.
42
+ * @param {ViewContext} ctx
43
+ */
44
+ function state(ctx) {
45
+ if (!ctx.S.anatomy) {
46
+ ctx.S.anatomy = { id: null, filter: '', data: null, loading: false, error: null };
47
+ }
48
+ return ctx.S.anatomy;
49
+ }
50
+
51
+ /**
52
+ * Load one session's turns. A snapshot never gets here: it has no server, and
53
+ * `view()` renders the empty state instead of a control that cannot work.
54
+ * @param {ViewContext} ctx
55
+ * @param {string} sessionId
56
+ */
57
+ async function load(ctx, sessionId) {
58
+ const st = state(ctx);
59
+ st.id = sessionId;
60
+ st.data = null;
61
+ st.error = null;
62
+ st.loading = true;
63
+ ctx.rerender({ recompute: false });
64
+ try {
65
+ const res = await ctx.fetchJson(`/api/session?id=${encodeURIComponent(sessionId)}`);
66
+ // A second pick while this one was in flight wins; drop the stale answer.
67
+ if (state(ctx).id !== sessionId) return;
68
+ st.data = res;
69
+ } catch (err) {
70
+ if (state(ctx).id !== sessionId) return;
71
+ st.error = err.message;
72
+ }
73
+ st.loading = false;
74
+ ctx.rerender({ recompute: false });
75
+ }
76
+
77
+ /**
78
+ * @param {ViewContext} ctx
79
+ * @returns {HTMLElement}
80
+ */
81
+ export function view(ctx) {
82
+ const st = state(ctx);
83
+ const root = el('div', { class: 'grid' });
84
+ root.appendChild(pickerCard(ctx));
85
+
86
+ if (ctx.snapshot) {
87
+ root.appendChild(ctx.card(
88
+ 'Turn detail',
89
+ 'Request-level records stay on your machine. A saved file carries the bundle, not the turns.',
90
+ ctx.emptyCard('Turn-level detail needs the live dashboard.', 'Run tokenflow dashboard.'),
91
+ ));
92
+ return root;
93
+ }
94
+
95
+ if (st.error) {
96
+ root.appendChild(ctx.card('Turn detail', 'The request for this session failed.', el('p', {
97
+ class: 'hint anatomy-error', text: st.error,
98
+ })));
99
+ return root;
100
+ }
101
+ if (st.loading) {
102
+ root.appendChild(ctx.card('Turn detail', 'Reading this session from the store.', el('div', {
103
+ class: 'anatomy-loading',
104
+ }, [el('span', { class: 'anatomy-dot' }), el('span', { text: `Loading turns for ${st.id}` })])));
105
+ return root;
106
+ }
107
+ if (!st.data) return root;
108
+
109
+ const records = st.data.records || [];
110
+ if (sessionKind(records) === 'session-level') {
111
+ root.appendChild(ctx.card(
112
+ 'Turn detail',
113
+ `${st.data.session?.source || 'This source'} reports totals, not requests.`,
114
+ ctx.emptyCard('This source records one row per session, so there is no per-turn view.',
115
+ 'The totals for it are on the Overview and Cost tabs.'),
116
+ ));
117
+ return root;
118
+ }
119
+
120
+ const book = buildPriceBook(ctx.bundle?.pricing || {});
121
+ const series = turnSeries(records, book);
122
+ const sum = summarizeTurns(series);
123
+ if (!series.length) {
124
+ root.appendChild(ctx.card('Turn detail', 'This session has no primary records in the store.',
125
+ ctx.emptyCard('No turns to show.', 'The session row exists, but its records were superseded or removed.')));
126
+ return root;
127
+ }
128
+
129
+ root.appendChild(waterfallCard(ctx, series, sum));
130
+ root.appendChild(contextCard(ctx, series, sum));
131
+ root.appendChild(fanOutCard(ctx, series));
132
+ root.appendChild(stepCard(ctx, series));
133
+ return root;
134
+ }
135
+
136
+ /** Fetch on entry when a session is already picked, so a repaint keeps it. */
137
+ export function onEnter(ctx) {
138
+ if (ctx.snapshot) return;
139
+ const st = state(ctx);
140
+ if (st.id && !st.data && !st.loading && !st.error) load(ctx, st.id);
141
+ }
142
+
143
+ // ------------------------------------------------------------------- picker --
144
+
145
+ /**
146
+ * Sessions matching the filter, most expensive first.
147
+ *
148
+ * Only primary sessions are offered. `Store#upsertSession` files a row for
149
+ * every measurement, so the bundle also carries activity-only sessions (Cline,
150
+ * Cursor, git) with no tokens at all and overlay sessions (Headroom) that
151
+ * re-describe traffic another adapter already counted. The route returns
152
+ * primary records, so offering either would open a session that can only
153
+ * answer "no turns".
154
+ *
155
+ * Filtering happens over the whole list before the top 50 is taken, or a
156
+ * search for a cheap project would find nothing.
157
+ */
158
+ function pickList(ctx) {
159
+ const q = state(ctx).filter.trim().toLowerCase();
160
+ const all = (ctx.bundle?.sessions || []).filter((s) => s.ms === PRIMARY);
161
+ const hit = q
162
+ ? all.filter((s) => `${s.id} ${s.pj} ${s.rp} ${s.br} ${s.d} ${s.m} ${s.so}`.toLowerCase().includes(q))
163
+ : all.slice();
164
+ hit.sort((a, b) => (b.cost || 0) - (a.cost || 0) || (b.req || 0) - (a.req || 0));
165
+ return { rows: hit.slice(0, PICK_LIMIT), total: hit.length };
166
+ }
167
+
168
+ function pickerCard(ctx) {
169
+ const st = state(ctx);
170
+ const body = el('div', { class: 'anatomy-picker' });
171
+
172
+ const search = el('input', {
173
+ type: 'search',
174
+ class: 'anatomy-search',
175
+ placeholder: 'Filter by project, branch, date or model',
176
+ 'aria-label': 'Filter sessions',
177
+ value: st.filter,
178
+ });
179
+ const rows = el('div', { class: 'anatomy-rows' });
180
+ const count = el('p', { class: 'hint anatomy-count' });
181
+
182
+ const paint = () => {
183
+ const { rows: list, total } = pickList(ctx);
184
+ rows.textContent = '';
185
+ rows.appendChild(pickHead());
186
+ for (const s of list) rows.appendChild(pickRow(ctx, s));
187
+ if (!list.length) {
188
+ rows.appendChild(el('p', { class: 'hint', text: 'No session matches that filter.' }));
189
+ }
190
+ count.textContent = total > list.length
191
+ ? `Showing the ${list.length} most expensive of ${total} sessions.`
192
+ : `${total} session${total === 1 ? '' : 's'}.`;
193
+ };
194
+ // Repainting only the list keeps the caret in the box; a full re-render on
195
+ // every keystroke would rebuild the input and lose focus.
196
+ search.addEventListener('input', () => { st.filter = /** @type {HTMLInputElement} */ (search).value; paint(); });
197
+ paint();
198
+
199
+ body.appendChild(search);
200
+ body.appendChild(count);
201
+ body.appendChild(rows);
202
+
203
+ const hint = ctx.snapshot
204
+ ? 'Totals come from the saved bundle. Turn detail needs the live dashboard.'
205
+ : 'Pick a session to see its turns. The most expensive come first.';
206
+ return ctx.card('Sessions', hint, body, st.id && !ctx.snapshot
207
+ ? ctx.btn('Clear selection', () => {
208
+ const s = state(ctx);
209
+ s.id = null; s.data = null; s.error = null; s.loading = false;
210
+ ctx.rerender({ recompute: false });
211
+ }, 'ghost sm')
212
+ : null);
213
+ }
214
+
215
+ function pickHead() {
216
+ const head = el('div', { class: 'anatomy-row anatomy-row-head' });
217
+ for (const [text, cls] of [['Session', ''], ['Project', ''], ['Branch', ''], ['Date', ''], ['Model', ''], ['Turns', 'num'], ['Cost (est.)', 'num']]) {
218
+ head.appendChild(el('span', { class: cls, text }));
219
+ }
220
+ return head;
221
+ }
222
+
223
+ function pickRow(ctx, s) {
224
+ const { usd, int, shortDate } = ctx.fmt;
225
+ const st = state(ctx);
226
+ const cells = [
227
+ el('span', { class: 'mono anatomy-id', text: s.id, title: s.id }),
228
+ el('span', { text: s.pj || 'n/a' }),
229
+ el('span', { text: s.br || 'n/a' }),
230
+ el('span', { text: s.d ? shortDate(s.d) : 'n/a' }),
231
+ el('span', { class: 'anatomy-model', text: s.m || 'n/a', title: s.m || 'Not available' }),
232
+ el('span', { class: 'num', text: int(s.req, 'n/a') }),
233
+ el('span', { class: 'num', text: usd(s.cost, 'n/a') }),
234
+ ];
235
+ // A saved file cannot fetch the turns, so its rows are readable, not clickable.
236
+ const row = el(ctx.snapshot ? 'div' : 'button', {
237
+ class: 'anatomy-row' + (st.id === s.id ? ' is-picked' : ''),
238
+ 'data-session-id': s.id,
239
+ type: ctx.snapshot ? null : 'button',
240
+ }, cells);
241
+ if (!ctx.snapshot) row.addEventListener('click', () => load(ctx, s.id));
242
+ return row;
243
+ }
244
+
245
+ // ---------------------------------------------------------------- waterfall --
246
+
247
+ /**
248
+ * Bars run from the running total before the turn to the running total after
249
+ * it, so every bar is that turn's cost and the tops trace the cumulative line.
250
+ * One dollar axis carries both, which a bare per-turn bar chart beside a
251
+ * cumulative line could not do at a readable scale.
252
+ */
253
+ function waterfall(ctx, series, w) {
254
+ const { usd, int } = ctx.fmt;
255
+ const H = 280;
256
+ const M = { t: 16, r: 16, b: 26, l: 64 };
257
+ const maxCum = Math.max(0, ...series.map((t) => t.cumulative ?? 0));
258
+ const { ticks, max: yMax } = niceTicks(0, maxCum || 1, 5);
259
+ M.l = Math.max(M.l, Math.ceil(Math.max(...ticks.map((t) => usd(t).length)) * 6.6) + 18);
260
+ const iw = Math.max(40, w - M.l - M.r);
261
+ const ih = H - M.t - M.b;
262
+ const n = series.length;
263
+ const band = iw / Math.max(1, n);
264
+ const x = (i) => M.l + band * i + band / 2;
265
+ const y = (v) => M.t + ih - (Math.max(0, v) / (yMax || 1)) * ih;
266
+
267
+ const root = svg('svg', {
268
+ class: 'chart', viewBox: `0 0 ${w} ${H}`, preserveAspectRatio: 'none', role: 'img',
269
+ 'aria-label': 'Cost per turn as a waterfall, with the cumulative total',
270
+ });
271
+ root.style.height = H + 'px';
272
+ for (const t of ticks) {
273
+ root.appendChild(svg('line', { class: 'grid-line', x1: M.l, x2: w - M.r, y1: y(t), y2: y(t) }));
274
+ root.appendChild(svg('text', { class: 'tick', x: M.l - 8, y: y(t) + 3.5, 'text-anchor': 'end' }, [text(usd(t))]));
275
+ }
276
+
277
+ // The money already spent, as ground under the curve. It is the same number
278
+ // the line traces, so it takes the line's colour rather than a fourth one,
279
+ // which also leaves the per-turn ribbon its own hue to be read against.
280
+ const spent = series.filter((t) => t.cumulative !== null);
281
+ if (spent.length) {
282
+ let area = `M${x(spent[0].index).toFixed(2)} ${y(0).toFixed(2)}`;
283
+ for (const t of spent) area += `L${x(t.index).toFixed(2)} ${y(t.cumulative).toFixed(2)}`;
284
+ area += `L${x(spent[spent.length - 1].index).toFixed(2)} ${y(0).toFixed(2)}Z`;
285
+ root.appendChild(svg('path', { class: 'series-area', d: area, fill: areaGradient(root, CUM_COLOR) }));
286
+ }
287
+
288
+ // Each bar runs from the running total before the turn to the running total
289
+ // after it, so the bars tile into a ribbon whose thickness is what that turn
290
+ // cost. Bands touch rather than leaving a gap: at 500 turns a gap would eat
291
+ // the bar. A floor of 3px keeps a cheap turn visible at all.
292
+ const bw = band > 6 ? band - 1 : band;
293
+ for (const t of series) {
294
+ if (t.cost === null || t.cumulative === null) continue;
295
+ const top = y(t.cumulative);
296
+ const bottom = y(t.cumulative - t.cost);
297
+ root.appendChild(svg('rect', {
298
+ x: x(t.index) - bw / 2, y: top, width: bw, height: Math.max(3, bottom - top),
299
+ fill: t.subagent ? SUB_COLOR : MAIN_COLOR,
300
+ }));
301
+ }
302
+
303
+ let d = '';
304
+ let open = false;
305
+ for (const t of series) {
306
+ if (t.cumulative === null) { open = false; continue; }
307
+ d += (open ? 'L' : 'M') + x(t.index).toFixed(2) + ' ' + y(t.cumulative).toFixed(2);
308
+ open = true;
309
+ }
310
+ root.appendChild(svg('path', { class: 'series-line', d, stroke: CUM_COLOR, fill: 'none', 'stroke-width': 1.5 }));
311
+
312
+ // x ticks: about seven turn numbers, first and last always shown
313
+ const step = Math.max(1, Math.ceil(n / 7));
314
+ for (let i = 0; i < n; i += step) {
315
+ root.appendChild(svg('text', { class: 'tick', x: x(i), y: H - 8, 'text-anchor': 'middle' }, [text(String(i + 1))]));
316
+ }
317
+ root.appendChild(svg('line', { class: 'axis-line', x1: M.l, x2: w - M.r, y1: y(0), y2: y(0) }));
318
+
319
+ // One hit area with a crosshair, not one listener per bar: a 500-turn
320
+ // session would otherwise carry a thousand extra nodes.
321
+ const cross = svg('line', { class: 'crosshair', y1: M.t, y2: M.t + ih, opacity: 0 });
322
+ const dot = svg('circle', { r: 4.5, fill: CUM_COLOR, stroke: 'var(--surface-1)', 'stroke-width': 2, opacity: 0 });
323
+ root.appendChild(cross);
324
+ root.appendChild(dot);
325
+ const hit = svg('rect', { class: 'hit', x: M.l, y: M.t, width: iw, height: ih, 'pointer-events': 'all' });
326
+ root.appendChild(hit);
327
+ hit.addEventListener('pointermove', (ev) => {
328
+ const box = root.getBoundingClientRect();
329
+ const px = ((ev.clientX - box.left) / box.width) * w;
330
+ const i = Math.max(0, Math.min(n - 1, Math.floor((px - M.l) / band)));
331
+ const t = series[i];
332
+ cross.setAttribute('x1', String(x(i)));
333
+ cross.setAttribute('x2', String(x(i)));
334
+ cross.setAttribute('opacity', '1');
335
+ if (t.cumulative !== null) {
336
+ dot.setAttribute('cx', String(x(i)));
337
+ dot.setAttribute('cy', String(y(t.cumulative)));
338
+ dot.setAttribute('opacity', '1');
339
+ } else {
340
+ dot.setAttribute('opacity', '0');
341
+ }
342
+ tooltip.show(tipBody(`Turn ${t.turn}${t.subagent ? ' (subagent)' : ''}`, [
343
+ { color: t.subagent ? SUB_COLOR : MAIN_COLOR, name: 'This turn', value: usd(t.cost, 'n/a') },
344
+ { color: CUM_COLOR, name: 'Running total', value: usd(t.cumulative, 'n/a') },
345
+ { color: null, name: 'Prompt', value: t.promptTokens === null ? 'n/a' : int(t.promptTokens) },
346
+ { color: null, name: 'Model', value: t.model || 'n/a' },
347
+ ]), ev);
348
+ });
349
+ hit.addEventListener('pointerleave', () => {
350
+ cross.setAttribute('opacity', '0');
351
+ dot.setAttribute('opacity', '0');
352
+ tooltip.hide();
353
+ });
354
+
355
+ const wrap = el('div');
356
+ wrap.appendChild(root);
357
+ wrap.appendChild(legend([
358
+ { color: MAIN_COLOR, label: 'Turn cost' },
359
+ { color: SUB_COLOR, label: 'Subagent turn' },
360
+ { color: CUM_COLOR, label: 'Running total' },
361
+ ]));
362
+ return wrap;
363
+ }
364
+
365
+ function waterfallCard(ctx, series, sum) {
366
+ const { usd, int, relativeTime } = ctx.fmt;
367
+ const st = state(ctx);
368
+ const parts = [
369
+ `${int(series.length)} turns`,
370
+ `${usd(sum.cost, 'n/a')} estimated`,
371
+ ];
372
+ if (sum.unpriced) parts.push(`${int(sum.unpriced)} turns unpriced`);
373
+ if (st.data.truncated) parts.push(`showing the first ${int(st.data.returned)} of ${int(st.data.total)}`);
374
+ if (sum.from) parts.push(`started ${relativeTime(sum.from)}`);
375
+
376
+ return ctx.chartCard(
377
+ 'anatomy-waterfall',
378
+ 'Cost waterfall',
379
+ `${parts.join(' · ')}. Each bar is one turn, stacked on the running total. Estimated from the price table, never from a vendor invoice.`,
380
+ (w) => waterfall(ctx, series, w),
381
+ {
382
+ columns: [
383
+ { key: 'turn', label: 'Turn', na: 'n/a' },
384
+ { key: 'ts', label: 'Time', na: 'n/a', value: (r) => (r.ts ? r.ts.slice(11, 19) : null) },
385
+ { key: 'model', label: 'Model', na: 'n/a', text: true },
386
+ { key: 'kind', label: 'Kind', na: 'n/a', value: (r) => (r.subagent ? 'subagent' : r.category || 'main') },
387
+ { key: 'cost', label: 'Cost', na: 'n/a', value: (r) => usd(r.cost, 'n/a') },
388
+ { key: 'cumulative', label: 'Running total', na: 'n/a', value: (r) => usd(r.cumulative, 'n/a') },
389
+ { key: 'promptTokens', label: 'Prompt', na: 'n/a', value: (r) => int(r.promptTokens, 'n/a') },
390
+ { key: 'output', label: 'Output', na: 'n/a', value: (r) => int(r.output, 'n/a') },
391
+ ],
392
+ rows: series,
393
+ tall: true,
394
+ emptyText: 'This session has no turns.',
395
+ },
396
+ );
397
+ }
398
+
399
+ // ----------------------------------------------------------- context growth --
400
+
401
+ function contextCard(ctx, series, sum) {
402
+ const { compact, int, pct } = ctx.fmt;
403
+ const data = series.map((t) => ({
404
+ key: t.turn,
405
+ cacheRead: t.cacheRead,
406
+ input: t.input,
407
+ cacheWrite: t.cacheWrite,
408
+ }));
409
+ const keys = [
410
+ { key: 'cacheRead', label: 'Cache read', color: MAIN_COLOR },
411
+ { key: 'input', label: 'Fresh input', color: SUB_COLOR },
412
+ { key: 'cacheWrite', label: 'Cache write', color: CUM_COLOR },
413
+ ];
414
+ const grew = sum.promptFirst !== null && sum.promptLast !== null
415
+ ? `Prompt went from ${compact(sum.promptFirst)} to ${compact(sum.promptLast)} tokens`
416
+ : 'Prompt size is not reported by this source';
417
+
418
+ return ctx.chartCard(
419
+ 'anatomy-context',
420
+ 'Context growth',
421
+ `${grew}. Peak ${compact(sum.promptPeak)}. Cache read is ${pct(sum.cacheReadShare, 0, 'n/a')} of all prompt tokens. Prompt size is fresh input plus cache read plus cache write.`,
422
+ (w) => {
423
+ const chart = timeSeries({
424
+ data, keys, width: w, height: 260, mode: 'stacked',
425
+ fmtY: (v) => compact(v),
426
+ fmtX: (k) => String(k),
427
+ fmtXLong: (k) => `Turn ${k}`,
428
+ ariaLabel: 'Prompt tokens per turn, split into cache read, fresh input and cache write',
429
+ });
430
+ const wrap = el('div');
431
+ wrap.appendChild(chart);
432
+ wrap.appendChild(legend(keys.map((k) => ({ color: k.color, label: k.label }))));
433
+ return wrap;
434
+ },
435
+ {
436
+ columns: [
437
+ { key: 'turn', label: 'Turn', na: 'n/a' },
438
+ { key: 'cacheRead', label: 'Cache read', na: 'n/a', value: (r) => int(r.cacheRead, 'n/a') },
439
+ { key: 'input', label: 'Fresh input', na: 'n/a', value: (r) => int(r.input, 'n/a') },
440
+ { key: 'cacheWrite', label: 'Cache write', na: 'n/a', value: (r) => int(r.cacheWrite, 'n/a') },
441
+ { key: 'promptTokens', label: 'Prompt total', na: 'n/a', value: (r) => int(r.promptTokens, 'n/a') },
442
+ { key: 'cacheReadShare', label: 'Cache read share', na: 'n/a', value: (r) => pct(r.cacheReadShare, 0, 'n/a') },
443
+ ],
444
+ rows: series,
445
+ tall: true,
446
+ emptyText: 'This session has no turns.',
447
+ },
448
+ );
449
+ }
450
+
451
+ // --------------------------------------------------------------- subagents --
452
+
453
+ function fanOutCard(ctx, series) {
454
+ const { usd, int, pct } = ctx.fmt;
455
+ const f = fanOut(series);
456
+ const shareText = (v) => pct(v, 0, 'n/a');
457
+
458
+ if (!f.subagentTurns) {
459
+ return ctx.card('Subagent fan-out', 'Turns marked as a subagent by the source, never inferred from a model or a gap.',
460
+ ctx.emptyCard('No subagent turns in this session.', 'Every turn ran on the main agent.'));
461
+ }
462
+
463
+ const note = f.grouped
464
+ ? 'Subagent turns are grouped by position. The logs record no parent link.'
465
+ : 'Built from the parent link the source recorded.';
466
+ const basis = f.shareBasis === 'cost' ? 'share of estimated cost' : 'share of turns (no turn in this session is priced)';
467
+
468
+ if (f.grouped) {
469
+ const segments = f.groups.map((g) => ({
470
+ label: `${g.kind === 'subagent' ? 'Subagent' : 'Main'} ${g.startTurn} to ${g.endTurn}`,
471
+ value: f.shareBasis === 'cost' ? (g.cost ?? 0) : g.turns,
472
+ color: g.kind === 'subagent' ? SUB_COLOR : MAIN_COLOR,
473
+ }));
474
+ return ctx.chartCard(
475
+ 'anatomy-fanout',
476
+ 'Subagent fan-out',
477
+ `${int(f.subagentTurns)} of ${int(f.turns)} turns ran as a subagent, ${shareText(f.cost ? (f.subagentCost ?? 0) / f.cost : null)} of the spend. Segments are in turn order, sized by ${basis}. ${note}`,
478
+ () => compositionBar(segments, {
479
+ valueLabel: f.shareBasis === 'cost' ? 'Cost' : 'Turns',
480
+ fmt: (v) => (f.shareBasis === 'cost' ? usd(v, 'n/a') : int(v, 'n/a')),
481
+ }),
482
+ {
483
+ columns: [
484
+ { key: 'kind', label: 'Run', na: 'n/a' },
485
+ { key: 'startTurn', label: 'First turn', na: 'n/a' },
486
+ { key: 'endTurn', label: 'Last turn', na: 'n/a' },
487
+ { key: 'turns', label: 'Turns', na: 'n/a', value: (r) => int(r.turns, 'n/a') },
488
+ { key: 'cost', label: 'Cost', na: 'n/a', value: (r) => usd(r.cost, 'n/a') },
489
+ { key: 'share', label: 'Share', na: 'n/a', value: (r) => shareText(r.share) },
490
+ ],
491
+ rows: f.groups,
492
+ emptyText: 'No runs to show.',
493
+ },
494
+ );
495
+ }
496
+
497
+ const flat = [];
498
+ const walk = (node) => { flat.push(node); for (const c of node.children) walk(c); };
499
+ for (const r of f.roots) walk(r);
500
+ return ctx.chartCard(
501
+ 'anatomy-fanout',
502
+ 'Subagent fan-out',
503
+ `${int(f.subagentTurns)} of ${int(f.turns)} turns ran as a subagent. Sized by ${basis}. ${note}`,
504
+ () => {
505
+ const box = el('div', { class: 'anatomy-tree' });
506
+ for (const node of flat) {
507
+ const line = el('div', { class: 'anatomy-branch' });
508
+ line.style.paddingLeft = `${node.depth * 18}px`;
509
+ const swatch = el('span', { class: 'anatomy-swatch' });
510
+ swatch.style.background = node.depth ? SUB_COLOR : MAIN_COLOR;
511
+ line.appendChild(swatch);
512
+ line.appendChild(el('span', { class: 'anatomy-branch-name', text: node.label }));
513
+ line.appendChild(el('span', { class: 'hint', text: `turns ${node.startTurn} to ${node.endTurn} · ${int(node.turns)} turns · ${usd(node.cost, 'n/a')} · ${shareText(node.share)}` }));
514
+ box.appendChild(line);
515
+ }
516
+ return box;
517
+ },
518
+ {
519
+ columns: [
520
+ { key: 'label', label: 'Agent', na: 'n/a' },
521
+ { key: 'depth', label: 'Depth', na: 'n/a' },
522
+ { key: 'startTurn', label: 'First turn', na: 'n/a' },
523
+ { key: 'endTurn', label: 'Last turn', na: 'n/a' },
524
+ { key: 'turns', label: 'Turns', na: 'n/a', value: (r) => int(r.turns, 'n/a') },
525
+ { key: 'cost', label: 'Cost', na: 'n/a', value: (r) => usd(r.cost, 'n/a') },
526
+ { key: 'share', label: 'Share', na: 'n/a', value: (r) => shareText(r.share) },
527
+ ],
528
+ rows: flat,
529
+ emptyText: 'No branches to show.',
530
+ },
531
+ );
532
+ }
533
+
534
+ // -------------------------------------------------------------- step change --
535
+
536
+ function stepCard(ctx, series) {
537
+ const { usd, int } = ctx.fmt;
538
+ const step = detectStep(series);
539
+ const body = el('div', { class: 'anatomy-step' });
540
+ if (step) {
541
+ body.appendChild(el('p', {
542
+ class: 'anatomy-step-line',
543
+ text: `Per-turn cost stepped from ${usd(step.from, 'n/a')} to ${usd(step.to, 'n/a')} at turn ${int(step.turn)}.`,
544
+ }));
545
+ body.appendChild(el('p', {
546
+ class: 'hint',
547
+ text: `That is ${step.ratio === Infinity ? 'a rise from zero' : `${step.ratio.toFixed(1)}x`}, and ${usd(step.step, 'n/a')} more per turn. Both figures are medians, so one expensive turn cannot cause this.`,
548
+ }));
549
+ } else {
550
+ body.appendChild(el('p', { class: 'anatomy-step-line', text: 'No step found.' }));
551
+ body.appendChild(el('p', {
552
+ class: 'hint',
553
+ text: series.length < STEP_WINDOW * 2
554
+ ? `A step needs ${int(STEP_WINDOW)} turns on each side to compare. This session has ${int(series.length)}.`
555
+ : 'Per-turn cost never doubled and stayed doubled.',
556
+ }));
557
+ }
558
+ return ctx.card(
559
+ 'Step change',
560
+ `The median of the ${int(STEP_WINDOW)} turns before a point against the ${int(STEP_WINDOW)} after. A step needs both a ${STEP_RATIO}x rise and at least ${usd(STEP_MIN_ABS, 'n/a')} more per turn.`,
561
+ body,
562
+ );
563
+ }
564
+
565
+ function text(s) {
566
+ return document.createTextNode(String(s));
567
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * The Annotations tab: mark a calendar day ("switched to Opus 5", "started
3
+ * using subagents") and see it drawn on every daily chart in the dashboard.
4
+ *
5
+ * The list itself lives in annotations.json on this machine (src/core/
6
+ * annotations.js). This view only fetches it, edits it through the API, and
7
+ * calls charts.js's `setAnnotations` so every other tab's chart picks up the
8
+ * current list — see the comment on that function for why a module-level
9
+ * setter is the right shape here rather than a prop app.js would have to
10
+ * thread through.
11
+ */
12
+ import { el, setAnnotations } from '../charts.js';
13
+
14
+ /** @typedef {import('./index.js').ViewContext} ViewContext */
15
+
16
+ export const id = 'annotations';
17
+ export const label = 'Annotations';
18
+ export const order = 135;
19
+ export const css = './styles/annotations.css';
20
+
21
+ /** @type {{id:string,date:string,text:string}[]} */
22
+ let items = [];
23
+
24
+ /**
25
+ * @param {ViewContext} ctx
26
+ */
27
+ export async function onEnter(ctx) {
28
+ await load(ctx);
29
+ }
30
+
31
+ async function load(ctx) {
32
+ if (ctx.snapshot) {
33
+ items = ctx.bundle.annotations || [];
34
+ } else {
35
+ const res = await ctx.fetchJson('/api/annotations').catch(() => null);
36
+ items = res?.items || [];
37
+ }
38
+ setAnnotations(items);
39
+ ctx.rerender({ recompute: false });
40
+ }
41
+
42
+ /**
43
+ * @param {ViewContext} ctx
44
+ * @returns {HTMLElement}
45
+ */
46
+ export function view(ctx) {
47
+ // view() runs on every render, including renders triggered from other tabs
48
+ // before this one's onEnter has ever fired; keep charts.js current either way.
49
+ setAnnotations(items);
50
+ const root = el('div', { class: 'grid' });
51
+ root.appendChild(ctx.sectionTitle('Annotations'));
52
+ root.appendChild(listCard(ctx));
53
+ if (!ctx.snapshot) root.appendChild(formCard(ctx));
54
+ root.appendChild(el('p', {
55
+ class: 'hint',
56
+ text: 'Annotations are drawn on every daily chart. They stay in annotations.json on this machine.',
57
+ }));
58
+ return root;
59
+ }
60
+
61
+ function listCard(ctx) {
62
+ const body = el('div');
63
+ const sorted = [...items].sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
64
+ if (!sorted.length) {
65
+ body.appendChild(el('p', { class: 'hint', text: 'No annotations yet.' }));
66
+ } else {
67
+ for (const a of sorted) {
68
+ const row = el('div', { class: 'annotations-row' });
69
+ row.appendChild(el('span', { class: 'annotations-date mono', text: a.date }));
70
+ row.appendChild(el('span', { class: 'annotations-text', text: a.text }));
71
+ if (!ctx.snapshot) {
72
+ row.appendChild(ctx.btn('Remove', () => removeItem(ctx, a.id), 'ghost sm'));
73
+ }
74
+ body.appendChild(row);
75
+ }
76
+ }
77
+ return ctx.card('Marked days', 'Every entry appears as a dashed hairline on every daily chart.', body);
78
+ }
79
+
80
+ function formCard(ctx) {
81
+ const body = el('div', { class: 'annotations-form' });
82
+ const today = ctx.bundle?.meta?.today || '';
83
+ const dateInput = el('input', { type: 'date', value: today, 'aria-label': 'Date' });
84
+ const textInput = el('input', { type: 'text', placeholder: 'e.g. switched to Opus 5', maxlength: '140', 'aria-label': 'Note' });
85
+ const err = el('p', { class: 'hint annotations-error' });
86
+
87
+ const addBtn = ctx.btn('Add', async () => {
88
+ err.textContent = '';
89
+ try {
90
+ const res = await fetch('/api/annotations', {
91
+ method: 'POST',
92
+ headers: { 'content-type': 'application/json' },
93
+ body: JSON.stringify({ op: 'add', date: dateInput.value, text: textInput.value }),
94
+ });
95
+ const out = await res.json();
96
+ if (!res.ok || !out.ok) { err.textContent = out.error || 'could not add annotation'; return; }
97
+ textInput.value = '';
98
+ await load(ctx);
99
+ } catch (e) {
100
+ err.textContent = `save failed: ${e.message}`;
101
+ }
102
+ }, 'sm');
103
+
104
+ body.appendChild(el('label', { class: 'fld' }, [el('span', { text: 'Date' }), dateInput]));
105
+ body.appendChild(el('label', { class: 'fld annotations-note-fld' }, [el('span', { text: 'Note' }), textInput]));
106
+ body.appendChild(addBtn);
107
+ body.appendChild(err);
108
+ return ctx.card('Add annotation', 'Dates are calendar days in the dashboard’s timezone.', body);
109
+ }
110
+
111
+ async function removeItem(ctx, itemId) {
112
+ try {
113
+ const res = await fetch('/api/annotations', {
114
+ method: 'POST',
115
+ headers: { 'content-type': 'application/json' },
116
+ body: JSON.stringify({ op: 'remove', id: itemId }),
117
+ });
118
+ if (!res.ok) return;
119
+ await load(ctx);
120
+ } catch { /* best effort; the list stays as-is until the next reload */ }
121
+ }