@vimoxshah/tokenflow 1.1.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/CHANGELOG.md +180 -0
  2. package/Dockerfile.team +20 -0
  3. package/README.md +30 -11
  4. package/bin/tokenflow.js +147 -12
  5. package/design/tokens.yaml +330 -0
  6. package/docs/architecture.md +5 -4
  7. package/docs/cli.md +204 -0
  8. package/docs/configuration.md +117 -2
  9. package/docs/design-system.md +187 -0
  10. package/docs/exports-and-budgets.md +85 -0
  11. package/docs/guard-codex.md +132 -0
  12. package/docs/ledger.md +144 -0
  13. package/docs/live-mode.md +40 -0
  14. package/docs/media/overview-aurora-dark.png +0 -0
  15. package/docs/media/receipts-aurora-dark.png +0 -0
  16. package/docs/providers-otel.md +179 -0
  17. package/docs/providers.md +54 -1
  18. package/docs/receipt-schema.md +74 -0
  19. package/docs/roadmap.md +182 -0
  20. package/docs/team-server.md +170 -0
  21. package/docs/ui-views.md +322 -0
  22. package/package.json +7 -2
  23. package/schemas/receipt.v0.json +160 -0
  24. package/scripts/build-menubar-app.sh +3 -1
  25. package/scripts/design-build.js +475 -0
  26. package/src/analytics/anatomy.js +467 -0
  27. package/src/analytics/branch-compare.js +159 -0
  28. package/src/analytics/cache-health.js +141 -0
  29. package/src/analytics/live-view.js +266 -0
  30. package/src/analytics/receipt-schema.js +214 -0
  31. package/src/analytics/receipt.js +709 -0
  32. package/src/analytics/rhythm.js +184 -0
  33. package/src/analytics/whatif.js +263 -0
  34. package/src/commands/budget-scopes.js +133 -0
  35. package/src/commands/doctor-checks.js +400 -0
  36. package/src/commands/guard.js +531 -0
  37. package/src/commands/hooks.js +238 -0
  38. package/src/commands/pricing-diff.js +316 -0
  39. package/src/commands/receipt.js +226 -0
  40. package/src/commands/team-serve.js +407 -0
  41. package/src/commands/week.js +86 -0
  42. package/src/core/annotations.js +97 -0
  43. package/src/core/budget.js +33 -0
  44. package/src/core/bundle.js +45 -2
  45. package/src/core/ingest.js +33 -0
  46. package/src/core/live-status.js +227 -2
  47. package/src/core/policy.js +103 -0
  48. package/src/core/receipt-note.js +123 -0
  49. package/src/core/repo.js +64 -0
  50. package/src/core/sync.js +163 -26
  51. package/src/core/team.js +0 -0
  52. package/src/export/html-snapshot.js +28 -1
  53. package/src/export/menubar.js +21 -0
  54. package/src/export/receipt-card.js +210 -0
  55. package/src/export/week-card.js +185 -0
  56. package/src/providers/mock/index.js +383 -52
  57. package/src/providers/openai/index.js +31 -1
  58. package/src/providers/otel/index.js +656 -0
  59. package/src/server/routes/annotations.js +42 -0
  60. package/src/server/routes/cache-health.js +95 -0
  61. package/src/server/routes/index.js +54 -0
  62. package/src/server/routes/session.js +157 -0
  63. package/src/server/server.js +47 -1
  64. package/src/ui/app.js +541 -308
  65. package/src/ui/charts.js +95 -0
  66. package/src/ui/first-run.js +144 -0
  67. package/src/ui/index.html +4 -1
  68. package/src/ui/palette.js +335 -0
  69. package/src/ui/styles/anatomy.css +117 -0
  70. package/src/ui/styles/annotations.css +40 -0
  71. package/src/ui/styles/branches.css +99 -0
  72. package/src/ui/styles/cache.css +6 -0
  73. package/src/ui/styles/first-run.css +31 -0
  74. package/src/ui/styles/live.css +100 -0
  75. package/src/ui/styles/palette.css +85 -0
  76. package/src/ui/styles/rhythm.css +8 -0
  77. package/src/ui/styles/whatif.css +55 -0
  78. package/src/ui/styles.css +303 -196
  79. package/src/ui/views/anatomy.js +567 -0
  80. package/src/ui/views/annotations.js +121 -0
  81. package/src/ui/views/branches.js +304 -0
  82. package/src/ui/views/cache.js +232 -0
  83. package/src/ui/views/index.js +85 -0
  84. package/src/ui/views/live.js +683 -0
  85. package/src/ui/views/rhythm.js +206 -0
  86. package/src/ui/views/whatif.js +196 -0
@@ -0,0 +1,304 @@
1
+ /**
2
+ * The Compare branches tab: any two branch receipts from buildReceipts()
3
+ * (src/analytics/receipt.js), side by side on a symmetric log scale.
4
+ *
5
+ * Reads ctx.bundle.receipts directly, never ctx.view: a branch receipt is
6
+ * bounded by its branch, not by the date/provider filter bar (see
7
+ * viewReceipts in app.js). That also means this tab needs nothing from the
8
+ * server — everything here is already in the bundle the exporter embeds, so
9
+ * it works the same in the live dashboard and in a saved offline snapshot.
10
+ */
11
+ import { el } from '../charts.js';
12
+ import { compareBranches, pickDefault, findBranch } from '../../analytics/branch-compare.js';
13
+
14
+ /** @typedef {import('./index.js').ViewContext} ViewContext */
15
+
16
+ export const id = 'branches';
17
+ export const label = 'Compare branches';
18
+ export const order = 125;
19
+ export const css = './styles/branches.css';
20
+
21
+ const AXIS_TICKS = [
22
+ { position: -1, label: '1/4x or less' },
23
+ { position: -0.5, label: '1/2x' },
24
+ { position: 0, label: 'parity' },
25
+ { position: 0.5, label: '2x' },
26
+ { position: 1, label: '4x or more' },
27
+ ];
28
+
29
+ /** `>= 10` rounds to a whole multiple; below that a decimal still means something. */
30
+ function timesFmt(v) {
31
+ return `${v >= 10 ? Math.round(v) : v.toFixed(1)}x`;
32
+ }
33
+
34
+ function fmtByKind(ctx, kind, v) {
35
+ const { usd, int, pct } = ctx.fmt;
36
+ if (kind === 'cost') return usd(v);
37
+ if (kind === 'count') return int(v);
38
+ if (kind === 'share') return pct(v, 0);
39
+ if (kind === 'ratio') return timesFmt(v);
40
+ return String(v);
41
+ }
42
+
43
+ /** A value formatted for one end of a bar row; never a bare 0 for "missing". */
44
+ function endText(ctx, row, value) {
45
+ return value === null || value === undefined ? '—' : fmtByKind(ctx, row.kind, value);
46
+ }
47
+
48
+ function modelsText(ctx, models) {
49
+ if (!models || !models.length) return null;
50
+ const top = models.slice(0, 3).map((m) => `${m.model} ${ctx.fmt.pct(m.share, 0)}`).join(', ');
51
+ return models.length > 3 ? `${top}, …` : top;
52
+ }
53
+
54
+ /**
55
+ * Read the persisted selection, or fall back to pickDefault() — checked
56
+ * against the current bundle every render, so a branch that disappeared
57
+ * after a refresh never leaves a dead selector behind.
58
+ * @param {ViewContext} ctx
59
+ * @param {object} receipts
60
+ * @returns {{a:{repo:string,key:string}, b:{repo:string,key:string}}|null}
61
+ */
62
+ function ensureSelection(ctx, receipts) {
63
+ const valid = (ref) => ref && findBranch(receipts, ref.repo, ref.key);
64
+ const cur = ctx.S.branchCompare;
65
+ if (cur && valid(cur.a) && valid(cur.b)) return cur;
66
+ const def = pickDefault(receipts);
67
+ if (!def) return null;
68
+ ctx.S.branchCompare = def;
69
+ return def;
70
+ }
71
+
72
+ /**
73
+ * The Compare branches tab body.
74
+ * @param {ViewContext} ctx
75
+ * @returns {HTMLElement}
76
+ */
77
+ export function view(ctx) {
78
+ const receipts = ctx.bundle.receipts;
79
+ const root = el('div', { class: 'grid' });
80
+
81
+ if (!receipts || !Array.isArray(receipts.repos) || !receipts.repos.length) {
82
+ root.appendChild(ctx.card('Compare branches', 'Any two branches, side by side.', ctx.emptyCard(
83
+ 'No branch receipts yet',
84
+ 'Receipts need sessions that recorded a git branch and a priced model. See the Receipts tab.',
85
+ )));
86
+ return root;
87
+ }
88
+
89
+ const sel = ensureSelection(ctx, receipts);
90
+ if (!sel) {
91
+ root.appendChild(ctx.card('Compare branches', 'Any two branches, side by side.', ctx.emptyCard(
92
+ 'Need at least two branches to compare',
93
+ 'Only one branch has been attributed to a receipt so far on this machine.',
94
+ )));
95
+ return root;
96
+ }
97
+
98
+ const a = findBranch(receipts, sel.a.repo, sel.a.key);
99
+ const b = findBranch(receipts, sel.b.repo, sel.b.key);
100
+
101
+ root.appendChild(selectorCard(ctx, receipts, sel, a, b));
102
+ root.appendChild(divergingCard(ctx, a, b));
103
+ root.appendChild(sideCards(ctx, sel, a, b));
104
+ root.appendChild(el('p', { class: 'hint branches-foot', text: 'Branch receipts are estimated from the logs on this machine. Cost per 100 lines needs a pull request join from the CLI.' }));
105
+ return root;
106
+ }
107
+
108
+ // ------------------------------------------------------------------ selectors
109
+
110
+ function headerRow(sel, a, b) {
111
+ const row = el('div', { class: 'branches-head' });
112
+ const nameBlock = (repo, key, br) => {
113
+ const parts = [el('span', { class: 'branches-head-name', text: `${repo} · ${key}` })];
114
+ if (br && br.longLived) {
115
+ parts.push(el('span', { class: 'badge', text: 'long-lived', title: 'A branch that lives forever: this is a receipt for a period of work on it, not for one change.' }));
116
+ }
117
+ if (br && br.pr) parts.push(el('span', { class: 'badge', text: `PR #${br.pr.number}` }));
118
+ return el('div', {}, parts);
119
+ };
120
+ row.appendChild(nameBlock(sel.a.repo, sel.a.key, a));
121
+ row.appendChild(el('span', { class: 'branches-head-vs', text: 'vs' }));
122
+ row.appendChild(nameBlock(sel.b.repo, sel.b.key, b));
123
+ return row;
124
+ }
125
+
126
+ function sideSelector(ctx, receipts, sel, side) {
127
+ const wrap = el('div', { class: 'branches-side' });
128
+ wrap.appendChild(el('div', { class: 'branches-side-label', text: side === 'a' ? 'Side A' : 'Side B' }));
129
+ const fields = el('div', { class: 'branches-fields' });
130
+
131
+ const repoSel = el('select', { 'aria-label': `Repository ${side.toUpperCase()}` });
132
+ for (const R of receipts.repos) repoSel.appendChild(el('option', { value: R.repo, text: R.repo }));
133
+ repoSel.value = sel[side].repo;
134
+
135
+ const branchSel = el('select', { 'aria-label': `Branch ${side.toUpperCase()}` });
136
+ const fillBranches = (repoName) => {
137
+ branchSel.textContent = '';
138
+ const R = receipts.repos.find((r) => r.repo === repoName);
139
+ for (const br of (R ? R.branches : [])) {
140
+ branchSel.appendChild(el('option', { value: br.key, text: `${br.key} · ${br.cost === null ? '—' : ctx.fmt.usd(br.cost)}` }));
141
+ }
142
+ };
143
+ fillBranches(sel[side].repo);
144
+ branchSel.value = sel[side].key;
145
+
146
+ repoSel.addEventListener('change', () => {
147
+ fillBranches(repoSel.value);
148
+ const R = receipts.repos.find((r) => r.repo === repoSel.value);
149
+ const firstKey = R && R.branches.length ? R.branches[0].key : null;
150
+ if (!firstKey) return;
151
+ ctx.S.branchCompare = { ...ctx.S.branchCompare, [side]: { repo: repoSel.value, key: firstKey } };
152
+ ctx.rerender({ recompute: false });
153
+ });
154
+ branchSel.addEventListener('change', () => {
155
+ ctx.S.branchCompare = { ...ctx.S.branchCompare, [side]: { repo: repoSel.value, key: branchSel.value } };
156
+ ctx.rerender({ recompute: false });
157
+ });
158
+
159
+ fields.appendChild(repoSel);
160
+ fields.appendChild(branchSel);
161
+ wrap.appendChild(fields);
162
+ return wrap;
163
+ }
164
+
165
+ function selectorCard(ctx, receipts, sel, a, b) {
166
+ const body = el('div');
167
+ body.appendChild(headerRow(sel, a, b));
168
+ const selRow = el('div', { class: 'branches-select-row' });
169
+ selRow.appendChild(sideSelector(ctx, receipts, sel, 'a'));
170
+ selRow.appendChild(sideSelector(ctx, receipts, sel, 'b'));
171
+ body.appendChild(selRow);
172
+ return ctx.card(
173
+ 'Compare branches',
174
+ "Any two branches, side by side. Defaults to the most expensive feature branch against the median-cost feature branch in the same repository.",
175
+ body,
176
+ );
177
+ }
178
+
179
+ // -------------------------------------------------------------- diverging bar
180
+
181
+ function barColor(position, side) {
182
+ const p = Math.abs(position);
183
+ if (p < 0.1) return 'var(--div-3)';
184
+ if (p < 0.5) return side === 'a' ? 'var(--div-2)' : 'var(--div-4)';
185
+ return side === 'a' ? 'var(--div-1)' : 'var(--div-5)';
186
+ }
187
+
188
+ function axisRow() {
189
+ const row = el('div', { class: 'branches-axis' });
190
+ row.appendChild(el('div', { class: 'branches-axis-spacer' }));
191
+ row.appendChild(el('div', { class: 'branches-axis-ends' }));
192
+ const track = el('div', { class: 'branches-axis-track' });
193
+ for (const t of AXIS_TICKS) {
194
+ const tick = el('div', { class: 'branches-axis-tick', text: t.label });
195
+ tick.style.left = `${((t.position + 1) / 2) * 100}%`;
196
+ track.appendChild(tick);
197
+ }
198
+ row.appendChild(track);
199
+ row.appendChild(el('div', { class: 'branches-axis-ends' }));
200
+ return row;
201
+ }
202
+
203
+ function barRow(ctx, row) {
204
+ const wrap = el('div', { class: 'branches-row' });
205
+ wrap.appendChild(el('div', { class: 'branches-row-label', text: row.label }));
206
+ const bar = el('div', { class: 'branches-bar' });
207
+ bar.appendChild(el('span', { class: 'branches-end branches-end-a', text: endText(ctx, row, row.valueA) }));
208
+
209
+ const track = el('div', { class: 'branches-track' });
210
+ const halfA = el('div', { class: 'branches-half branches-half-a' });
211
+ const halfB = el('div', { class: 'branches-half branches-half-b' });
212
+ if (row.position !== null) {
213
+ const fill = el('div', { class: 'branches-fill' });
214
+ fill.style.width = `${Math.abs(row.position) * 100}%`;
215
+ if (row.position >= 0) {
216
+ fill.style.background = barColor(row.position, 'a');
217
+ halfA.appendChild(fill);
218
+ } else {
219
+ fill.style.background = barColor(row.position, 'b');
220
+ halfB.appendChild(fill);
221
+ }
222
+ }
223
+ track.appendChild(halfA);
224
+ track.appendChild(el('div', { class: 'branches-centre' }));
225
+ track.appendChild(halfB);
226
+ bar.appendChild(track);
227
+
228
+ bar.appendChild(el('span', { class: 'branches-end branches-end-b', text: endText(ctx, row, row.valueB) }));
229
+ wrap.appendChild(bar);
230
+ return wrap;
231
+ }
232
+
233
+ function legendRow() {
234
+ const row = el('div', { class: 'branches-legend' });
235
+ const swatch = (color) => el('span', { class: 'branches-swatch', style: `background:${color}` });
236
+ row.appendChild(el('span', {}, [swatch('var(--div-1)'), 'A costs more']));
237
+ row.appendChild(el('span', {}, [swatch('var(--div-3)'), 'parity']));
238
+ row.appendChild(el('span', {}, [swatch('var(--div-5)'), 'B costs more']));
239
+ return row;
240
+ }
241
+
242
+ function renderDivergingChart(ctx, rows) {
243
+ const root = el('div');
244
+ root.appendChild(axisRow());
245
+ for (const row of rows) root.appendChild(barRow(ctx, row));
246
+ root.appendChild(legendRow());
247
+ return root;
248
+ }
249
+
250
+ function tableCell(ctx, row, value) {
251
+ if (row.kind === 'models') return modelsText(ctx, value);
252
+ return value === null || value === undefined ? null : fmtByKind(ctx, row.kind, value);
253
+ }
254
+
255
+ function divergingCard(ctx, a, b) {
256
+ const rows = compareBranches(a, b);
257
+ const barRows = rows.filter((r) => r.kind !== 'models');
258
+ const tableSpec = {
259
+ columns: [
260
+ { key: 'label', label: 'Metric', text: true, value: (r) => r.label },
261
+ { key: 'a', label: 'A', value: (r) => tableCell(ctx, r, r.valueA) },
262
+ { key: 'b', label: 'B', value: (r) => tableCell(ctx, r, r.valueB) },
263
+ { key: 'ratio', label: 'Ratio (A / B)', value: (r) => (r.ratio === null ? null : timesFmt(r.ratio)) },
264
+ ],
265
+ rows,
266
+ };
267
+ return ctx.chartCard(
268
+ 'branches-diverging',
269
+ 'Branch comparison',
270
+ 'A on the left, B on the right. Bar length is a symmetric log scale: twice as expensive moves the same distance whichever side it is on.',
271
+ () => renderDivergingChart(ctx, barRows),
272
+ tableSpec,
273
+ );
274
+ }
275
+
276
+ // ------------------------------------------------------------------ side cards
277
+
278
+ function sideSummary(ctx, sideLabel, repo, key, br) {
279
+ const { usd, pct, int, shortDate } = ctx.fmt;
280
+ const card = el('div', { class: 'branches-side-card' });
281
+ card.appendChild(el('h4', { text: `${sideLabel}: ${repo} · ${key}` }));
282
+ if (!br) {
283
+ card.appendChild(el('p', { text: 'This branch is no longer in the receipt store.' }));
284
+ return card;
285
+ }
286
+ const costText = usd(br.cost);
287
+ const shareText = br.contextShare === null ? '' : ` (${pct(br.contextShare, 0)} re-sent context, ${pct(1 - br.contextShare, 0)} fresh work)`;
288
+ card.appendChild(el('p', { text: `${costText} across ${int(br.sessions)} session(s) and ${int(br.turns)} turn(s)${shareText}.` }));
289
+ if (br.subagentTurns > 0) card.appendChild(el('p', { text: `${pct(br.subagentShare, 0)} of turns ran as a subagent.` }));
290
+ const modelsLine = modelsText(ctx, br.models);
291
+ if (modelsLine) card.appendChild(el('p', { text: `Models: ${modelsLine}.` }));
292
+ if (br.vsMedian !== null) card.appendChild(el('p', { text: `${timesFmt(br.vsMedian)} this repo's median branch cost.` }));
293
+ if (br.first && br.last) card.appendChild(el('p', { text: `Active ${shortDate(br.first.slice(0, 10))} to ${shortDate(br.last.slice(0, 10))}.` }));
294
+ if (br.longLived) card.appendChild(el('p', { text: 'Long-lived branch: this is a receipt for a period of work on it, not for one change.' }));
295
+ if (br.pr) card.appendChild(el('p', { text: `PR #${br.pr.number}${br.pr.mergedAt ? ` merged ${shortDate(br.pr.mergedAt.slice(0, 10))}` : ' still open'}.` }));
296
+ return card;
297
+ }
298
+
299
+ function sideCards(ctx, sel, a, b) {
300
+ const wrap = el('div', { class: 'branches-side-cards' });
301
+ wrap.appendChild(sideSummary(ctx, 'A', sel.a.repo, sel.a.key, a));
302
+ wrap.appendChild(sideSummary(ctx, 'B', sel.b.repo, sel.b.key, b));
303
+ return ctx.card('Receipts', "The same numbers as the Receipts tab, for just these two branches.", wrap);
304
+ }
@@ -0,0 +1,232 @@
1
+ /**
2
+ * The Cache health tab: is the prompt cache doing its job.
3
+ *
4
+ * The prompt cache is where most of the money goes: a cache hit is cheap, a
5
+ * cache miss re-sends the whole context. Two halves, kept apart on purpose:
6
+ *
7
+ * - hit rate, write split and the per-model table come from the cube
8
+ * (ctx.view), so they work offline, in a snapshot, everywhere.
9
+ * - churn events come from request-level records through /api/cache-health,
10
+ * so they are live-dashboard only. A snapshot shows why instead of a dead
11
+ * control (see docs/ui-views.md, "Working offline").
12
+ */
13
+ import { el, timeSeries } from '../charts.js';
14
+ import { hitRateSeries, writeSplitSeries } from '../../analytics/cache-health.js';
15
+
16
+ /** @typedef {import('./index.js').ViewContext} ViewContext */
17
+
18
+ export const id = 'cache';
19
+ export const label = 'Cache health';
20
+ export const order = 95;
21
+ export const css = './styles/cache.css';
22
+
23
+ /**
24
+ * Fetch state for the churn card, kept in the module rather than in `ctx.S`
25
+ * because the churn endpoint has nothing to do with the global filter bar
26
+ * beyond the date window. `key` is the from/to pair the current data (or
27
+ * in-flight fetch) answers; `view()` compares it on every render and starts
28
+ * a new fetch only when it changed.
29
+ */
30
+ let churn = { key: null, status: 'idle', data: null, error: null };
31
+
32
+ function dateKey(filters) {
33
+ return `${filters.from || ''}|${filters.to || ''}`;
34
+ }
35
+
36
+ /**
37
+ * Start (or skip) the churn fetch for the current date filters.
38
+ * @param {ViewContext} ctx
39
+ */
40
+ function ensureChurn(ctx) {
41
+ if (ctx.snapshot) {
42
+ if (churn.status !== 'unavailable') churn = { key: 'snapshot', status: 'unavailable', data: null, error: null };
43
+ return;
44
+ }
45
+ const key = dateKey(ctx.filters);
46
+ if (churn.key === key && churn.status !== 'idle') return;
47
+ churn = { key, status: 'loading', data: null, error: null };
48
+ const q = new URLSearchParams();
49
+ if (ctx.filters.from) q.set('from', ctx.filters.from);
50
+ if (ctx.filters.to) q.set('to', ctx.filters.to);
51
+ ctx.fetchJson(`/api/cache-health?${q.toString()}`)
52
+ .then((data) => {
53
+ if (churn.key !== key) return; // superseded by a newer filter change
54
+ churn = { key, status: 'ready', data, error: null };
55
+ ctx.rerender({ recompute: false });
56
+ })
57
+ .catch((err) => {
58
+ if (churn.key !== key) return;
59
+ churn = { key, status: 'error', data: null, error: err && err.message ? err.message : String(err) };
60
+ ctx.rerender({ recompute: false });
61
+ });
62
+ }
63
+
64
+ /** @param {ViewContext} ctx */
65
+ export function onEnter(ctx) {
66
+ ensureChurn(ctx);
67
+ }
68
+
69
+ /** @param {ViewContext} ctx */
70
+ export function view(ctx) {
71
+ ensureChurn(ctx);
72
+ const root = el('div', { class: 'grid' });
73
+ root.appendChild(kpiRow(ctx));
74
+ root.appendChild(hitRateCard(ctx));
75
+ root.appendChild(writeSplitCard(ctx));
76
+ root.appendChild(modelTableCard(ctx));
77
+ root.appendChild(churnCard(ctx));
78
+ return root;
79
+ }
80
+
81
+ function kpiRow(ctx) {
82
+ const { pct, usd, int } = ctx.fmt;
83
+ const t = ctx.view.totals;
84
+ const hitRate = (t.cr + t.in) > 0 ? t.cr / (t.cr + t.in) : null;
85
+ const writeShare = t.cw > 0 ? t.cf / t.cw : null;
86
+
87
+ const box = el('div', { class: 'cards' });
88
+ box.appendChild(ctx.kpi('Cache hit rate', pct(hitRate, 1), 'cache_read / (cache_read + input), current filters'));
89
+ box.appendChild(ctx.kpi('Long-TTL write share', pct(writeShare, 1), 'cache_refresh / cache_write, current filters'));
90
+
91
+ const snapshotSub = ctx.snapshot ? 'live dashboard only' : null;
92
+ const errorSub = churn.status === 'error' ? 'failed to load' : null;
93
+ const eventsValue = ctx.snapshot ? '—'
94
+ : churn.status === 'ready' ? int(churn.data.summary.totalEvents)
95
+ : churn.status === 'error' ? '—'
96
+ : '…';
97
+ box.appendChild(ctx.kpi('Churn events', eventsValue, snapshotSub || errorSub));
98
+
99
+ const premiumValue = ctx.snapshot ? '—'
100
+ : churn.status === 'ready' ? usd(churn.data.summary.totalPremiumUsd)
101
+ : churn.status === 'error' ? '—'
102
+ : '…';
103
+ box.appendChild(ctx.kpi('Churn premium (estimated)', premiumValue, snapshotSub || errorSub));
104
+ return box;
105
+ }
106
+
107
+ function hitRateCard(ctx) {
108
+ const { shortDate, pct } = ctx.fmt;
109
+ const rows = hitRateSeries(ctx.view.daily);
110
+ const renderChart = (w) => timeSeries({
111
+ data: rows.map((r) => ({ key: r.date })),
112
+ keys: [],
113
+ overlays: [{ values: rows.map((r) => r.hitRate), label: 'Hit rate', color: 'var(--series-1)' }],
114
+ fmtY: (v) => pct(v, 0),
115
+ fmtX: (k) => shortDate(k),
116
+ fmtXLong: (k) => shortDate(k),
117
+ width: w,
118
+ height: 220,
119
+ ariaLabel: 'Daily cache hit rate',
120
+ });
121
+ const tableSpec = {
122
+ columns: [
123
+ { key: 'date', label: 'Date', text: true, value: (r) => shortDate(r.date) },
124
+ { key: 'hitRate', label: 'Hit rate', value: (r) => (r.hitRate === null ? null : pct(r.hitRate, 1)) },
125
+ ],
126
+ rows,
127
+ };
128
+ return ctx.chartCard(
129
+ 'cache-hit-rate',
130
+ 'Cache hit rate over time',
131
+ 'Daily cache_read / (cache_read + input) for the current filters. A day with no input or cache tokens shows no value, not 0%.',
132
+ renderChart,
133
+ tableSpec,
134
+ );
135
+ }
136
+
137
+ function writeSplitCard(ctx) {
138
+ const { shortDate, compact } = ctx.fmt;
139
+ const rows = writeSplitSeries(ctx.view.daily);
140
+ const keys = [
141
+ { key: 'shortTTL', label: 'Short-TTL writes', color: 'var(--series-1)' },
142
+ { key: 'longTTL', label: 'Long-TTL writes', color: 'var(--series-2)' },
143
+ ];
144
+ const renderChart = (w) => timeSeries({
145
+ data: rows.map((r) => ({ key: r.date, shortTTL: r.shortTTL, longTTL: r.longTTL })),
146
+ keys,
147
+ mode: 'stacked',
148
+ fmtY: (v) => compact(v),
149
+ fmtX: (k) => shortDate(k),
150
+ fmtXLong: (k) => shortDate(k),
151
+ width: w,
152
+ height: 220,
153
+ ariaLabel: 'Daily cache write split, short-TTL versus long-TTL',
154
+ });
155
+ const tableSpec = {
156
+ columns: [
157
+ { key: 'date', label: 'Date', text: true, value: (r) => shortDate(r.date) },
158
+ { key: 'shortTTL', label: 'Short-TTL writes', value: (r) => compact(r.shortTTL) },
159
+ { key: 'longTTL', label: 'Long-TTL writes', value: (r) => compact(r.longTTL) },
160
+ ],
161
+ rows,
162
+ };
163
+ return ctx.chartCard(
164
+ 'cache-write-split',
165
+ 'Cache write split',
166
+ 'Short-TTL cache writes (cache_write minus cache_refresh) against long-TTL writes (cache_refresh), stacked by day.',
167
+ renderChart,
168
+ tableSpec,
169
+ );
170
+ }
171
+
172
+ function modelTableCard(ctx) {
173
+ const { pct, int } = ctx.fmt;
174
+ const rows = (ctx.view.dimensions.models || []).map((m) => {
175
+ const denom = (m.cacheRead || 0) + (m.input || 0);
176
+ return {
177
+ model: m.key,
178
+ provider: m.provider,
179
+ hitRate: denom > 0 ? m.cacheRead / denom : null,
180
+ requests: m.requests,
181
+ };
182
+ });
183
+ const tbl = ctx.charts.table([
184
+ { key: 'model', label: 'Model', text: true },
185
+ { key: 'provider', label: 'Provider', text: true },
186
+ { key: 'hitRate', label: 'Hit rate', value: (r) => (r.hitRate === null ? null : pct(r.hitRate, 1)) },
187
+ { key: 'requests', label: 'Requests', value: (r) => int(r.requests) },
188
+ ], rows, { emptyText: 'No models match the current filters.' });
189
+ return ctx.card('Cache hit rate by model', 'cache_read / (cache_read + input) per model, for the current filters.', tbl);
190
+ }
191
+
192
+ function churnCard(ctx) {
193
+ if (ctx.snapshot) {
194
+ return ctx.emptyCard(
195
+ 'Churn events need the live dashboard.',
196
+ 'Churn detection reads request records, which stay on the machine. Open the live dashboard to see them.',
197
+ );
198
+ }
199
+
200
+ const { int, usd } = ctx.fmt;
201
+ const columns = [
202
+ { key: 'sessionId', label: 'Session', text: true, value: (r) => (r.sessionId ? String(r.sessionId).slice(0, 12) : null) },
203
+ { key: 'project', label: 'Project', text: true },
204
+ { key: 'branch', label: 'Branch', text: true },
205
+ { key: 'turnIndex', label: 'Turn', value: (r) => int(r.turnIndex) },
206
+ { key: 'writeTokens', label: 'Write tokens', value: (r) => int(r.writeTokens) },
207
+ { key: 'previousReadTokens', label: 'Previous read', value: (r) => int(r.previousReadTokens) },
208
+ { key: 'premiumUsd', label: 'Premium (estimated)', value: (r) => (r.premiumUsd === null ? null : usd(r.premiumUsd)) },
209
+ { key: 'timestamp', label: 'When', value: (r) => (r.timestamp ? r.timestamp.replace('T', ' ').slice(0, 19) : null) },
210
+ ];
211
+
212
+ let rows = [];
213
+ let emptyText = 'No churn events in the current window.';
214
+ let note = null;
215
+ if (churn.status === 'loading' || churn.status === 'idle') {
216
+ emptyText = 'Loading churn events...';
217
+ } else if (churn.status === 'error') {
218
+ emptyText = `Could not load churn events: ${churn.error}`;
219
+ } else if (churn.data) {
220
+ // The route already sorts by estimated premium, highest first.
221
+ rows = (churn.data.events || []).slice(0, 20);
222
+ if (churn.data.truncated) {
223
+ note = `Scan capped at ${int(churn.data.scanned)} records. Some churn events may be missed.`;
224
+ }
225
+ }
226
+
227
+ const body = el('div');
228
+ body.appendChild(el('p', { class: 'hint', text: 'A churn event is a turn whose cache write is at least 10,000 tokens. It is also at least half of the previous turn\'s cache read in the same session. That pattern means the cache was invalidated and rewritten, for example when a system prompt changes.' }));
229
+ if (note) body.appendChild(el('p', { class: 'hint cache-status', text: note }));
230
+ body.appendChild(ctx.charts.table(columns, rows, { emptyText }));
231
+ return ctx.card('Churn events', 'Top 20 by estimated cost premium.', body);
232
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The dashboard view registry.
3
+ *
4
+ * A registered view is a whole tab that lives outside app.js: one module in
5
+ * this directory, one stylesheet under src/ui/styles/, and nothing else. app.js
6
+ * merges this list with its built-in tabs and dispatches to it, so adding a tab
7
+ * never means editing app.js — which is what lets several tabs be written at
8
+ * the same time without a merge conflict in one 2600-line file.
9
+ *
10
+ * A view module must NOT import app.js. It receives everything it may use as a
11
+ * ViewContext argument. That keeps the module graph acyclic, which the offline
12
+ * snapshot bundler (src/export/bundler.js) requires.
13
+ *
14
+ * The full contract, including how a view degrades offline, is in
15
+ * docs/ui-views.md.
16
+ */
17
+ import * as live from './live.js';
18
+ import * as cache from './cache.js';
19
+ import * as annotations from './annotations.js';
20
+ import * as branches from './branches.js';
21
+ import * as anatomy from './anatomy.js';
22
+ import * as whatif from './whatif.js';
23
+ import * as rhythm from './rhythm.js';
24
+
25
+ /**
26
+ * Everything a view is allowed to use from the host application.
27
+ *
28
+ * app.js rebuilds this object for every render, and `bundle`, `view`, `filters`
29
+ * and `live` are getters over the live state, so a ctx captured in a closure
30
+ * (a timer, an event handler) never reads a stale value.
31
+ *
32
+ * @typedef {object} ViewContext
33
+ * @property {object} S dashboard state. Read freely; write only fields you own.
34
+ * @property {object} bundle the aggregate bundle this page was loaded with.
35
+ * @property {object} view the computed analytics view for the current filters.
36
+ * @property {object} filters the active filter object (same object as `S.filters`).
37
+ * @property {boolean} snapshot true when running from an offline HTML snapshot.
38
+ * @property {(tag:string, attrs?:object, kids?:any)=>any} el build a DOM element.
39
+ * @property {(title:string, hint:string, body:any, actions?:any)=>any} card
40
+ * @property {(id:string, title:string, hint:string, renderChart:(w:number)=>any, tableSpec?:object, extraActions?:any)=>any} chartCard chart with its mandatory table twin.
41
+ * @property {(label:string, onClick:(ev:any)=>void, cls?:string, id?:string)=>any} btn
42
+ * @property {(label:string, value:string, sub?:any, opt?:object)=>any} kpi
43
+ * @property {(change:number|null, opt?:{goodUp?:boolean})=>any} deltaChip
44
+ * @property {(t:string)=>any} sectionTitle
45
+ * @property {(text:string, detail?:string)=>any} emptyCard
46
+ * @property {(title:string, body:any, foot?:any)=>void} openModal
47
+ * @property {()=>void} closeModal
48
+ * @property {(key:string, value:string)=>void} drillTo filter to one value and re-render.
49
+ * @property {object} fmt the number and date formatters from core/units.js.
50
+ * @property {object} charts namespace import of src/ui/charts.js.
51
+ * @property {(path:string, opt?:object)=>Promise<any>} fetchJson resolves to null in a snapshot.
52
+ * @property {(fn:()=>void, ms:number)=>any} schedule an interval app.js clears for you.
53
+ * @property {(opt?:{recompute?:boolean})=>void} rerender recompute and repaint.
54
+ */
55
+
56
+ /**
57
+ * One tab. `id`, `label`, `order` and `view` are required; the rest optional.
58
+ *
59
+ * `order` places the tab: the built-in tabs hold 10, 20, 40 … 140, so a view
60
+ * can slot anywhere by picking a number between them. `css` is a path relative
61
+ * to src/ui/ and must point at a single file under src/ui/styles/.
62
+ *
63
+ * @typedef {object} ViewModule
64
+ * @property {string} id unique, and never one of app.js's built-in tab ids.
65
+ * @property {string} label the tab caption.
66
+ * @property {number} order position among all tabs.
67
+ * @property {string} [css] e.g. './styles/live.css'.
68
+ * @property {(ctx: ViewContext) => any} view returns the element for the tab body.
69
+ * @property {(ctx: ViewContext) => void} [onEnter] the tab became active.
70
+ * @property {(ctx: ViewContext) => void} [onLeave] the tab stopped being active.
71
+ */
72
+
73
+ /**
74
+ * Every registered view, in registration order. app.js sorts by `order`.
75
+ * @type {ViewModule[]}
76
+ */
77
+ export const VIEWS = [
78
+ live,
79
+ cache,
80
+ annotations,
81
+ branches,
82
+ anatomy,
83
+ whatif,
84
+ rhythm,
85
+ ];