@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,210 @@
1
+ /**
2
+ * A branch receipt as a self-contained SVG card — shaped for a Slack drop or
3
+ * a PR-comment attachment, the way the landing page's receipt figure looks.
4
+ *
5
+ * Every colour comes from design/tokens.yaml, read through the project's own
6
+ * YAML parser at render time: nothing here hardcodes a hex value. No external
7
+ * fonts (the system stack from tokens.yaml, with its own fallbacks) and no
8
+ * scripts, so the file opens anywhere and prints anywhere.
9
+ *
10
+ * PNG is an optional second step: a local headless Chromium/Chrome takes a
11
+ * screenshot of the SVG. Nothing is ever downloaded — when no local browser
12
+ * is found, the SVG already on disk is the deliverable and the caller is told
13
+ * so in one line.
14
+ */
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import url from 'node:url';
18
+ import { execFileSync } from 'node:child_process';
19
+ import { parseYaml } from '../core/yaml.js';
20
+ import { usd, pct } from '../core/units.js';
21
+
22
+ const TOKENS_PATH = path.join(path.dirname(url.fileURLToPath(import.meta.url)), '../../design/tokens.yaml');
23
+
24
+ let tokensCache = null;
25
+
26
+ /** design/tokens.yaml, parsed once and cached — the single colour source every card reads. */
27
+ export function loadDesignTokens() {
28
+ if (!tokensCache) tokensCache = parseYaml(fs.readFileSync(TOKENS_PATH, 'utf8'));
29
+ return tokensCache;
30
+ }
31
+
32
+ /**
33
+ * Resolve the surface / ink / accent / series roles a card needs, for one skin
34
+ * and mode. Falls back to aurora/dark so a bad or missing skin name never
35
+ * throws mid-render.
36
+ * @param {object} tokens parsed design/tokens.yaml
37
+ * @param {string} [skin] aurora | terminal | editorial
38
+ * @param {string} [mode] dark | light
39
+ */
40
+ export function resolveTheme(tokens, skin = 'aurora', mode = 'dark') {
41
+ const skins = tokens.skins || {};
42
+ const s = skins[skin] || skins.aurora || Object.values(skins)[0] || {};
43
+ const palette = s[mode] || s.dark || {};
44
+ const series = (tokens.series && tokens.series[mode]) || (tokens.series && tokens.series.dark) || [];
45
+ return {
46
+ surface1: palette['surface-1'],
47
+ surface2: palette['surface-2'],
48
+ surface3: palette['surface-3'],
49
+ textPrimary: palette['text-primary'],
50
+ textSecondary: palette['text-secondary'],
51
+ textMuted: palette['text-muted'],
52
+ accent: palette.accent,
53
+ accentSolid: palette['accent-solid'],
54
+ accentInk: palette['accent-ink'],
55
+ border: palette.border,
56
+ borderStrong: palette['border-strong'],
57
+ // The split bar is the one place a card draws "series" data (context vs
58
+ // work dollars) rather than chrome — the first two categorical steps for
59
+ // this mode, exactly what the landing page's --series-1 / --series-2 are.
60
+ context: series[0] || palette.accent,
61
+ work: series[1] || palette.accent,
62
+ sans: (tokens.fonts && tokens.fonts.sans) || 'system-ui, -apple-system, sans-serif',
63
+ mono: (tokens.fonts && tokens.fonts.mono) || 'ui-monospace, monospace',
64
+ };
65
+ }
66
+
67
+ /** Escape text for use inside SVG element content or a double-quoted attribute. */
68
+ export function escapeXml(s) {
69
+ return String(s === null || s === undefined ? '' : s)
70
+ .replace(/&/g, '&')
71
+ .replace(/</g, '&lt;')
72
+ .replace(/>/g, '&gt;')
73
+ .replace(/"/g, '&quot;');
74
+ }
75
+
76
+ /** `v === null|undefined ? 'n/a' : fmt(v)` — the one place "no em dash" is enforced for cards. */
77
+ export function orNA(v, fmt) {
78
+ return v === null || v === undefined ? 'n/a' : fmt(v);
79
+ }
80
+
81
+ const times = (v) => `${v >= 10 ? Math.round(v) : v.toFixed(1)}×`;
82
+
83
+ /**
84
+ * Render one branch receipt (from `buildReceipts()` / `buildReceiptsForStore`)
85
+ * as a self-contained SVG card.
86
+ * @param {object} b one branch receipt
87
+ * @param {{repo?:string, skin?:string, mode?:string, width?:number, tokens?:object}} [opt]
88
+ */
89
+ export function renderReceiptCardSvg(b, opt = {}) {
90
+ const tokens = opt.tokens || loadDesignTokens();
91
+ const theme = resolveTheme(tokens, opt.skin, opt.mode);
92
+ const W0 = 640;
93
+ const H0 = 460;
94
+ const width = opt.width || W0;
95
+ const height = Math.round(width * (H0 / W0));
96
+
97
+ const title = b.pr ? `${escapeXml(b.key)} · PR #${b.pr.number}` : escapeXml(b.key);
98
+ const repoLine = opt.repo ? escapeXml(opt.repo) : '';
99
+ const cost = orNA(b.cost, (v) => usd(v));
100
+ const ctxShare = b.contextShare;
101
+ const barX = 16;
102
+ const barW = W0 - barX * 2;
103
+ const ctxPct = ctxShare === null || ctxShare === undefined ? 0.5 : ctxShare;
104
+ const ctxW = Math.max(0, Math.round(barW * ctxPct));
105
+ const workW = Math.max(0, barW - ctxW);
106
+
107
+ const modelsLine = b.models && b.models.length
108
+ ? b.models.slice(0, 3).map((m) => `${m.model} ${orNA(m.share, (v) => pct(v, 0, 'n/a'))}`).join(', ')
109
+ : 'n/a';
110
+
111
+ const rows = [
112
+ ['Sessions', String(b.sessions ?? 0)],
113
+ ['Turns', String(b.turns ?? 0)],
114
+ ['Subagent share', orNA(b.subagentShare, (v) => pct(v, 0, 'n/a'))],
115
+ ['Models', modelsLine],
116
+ ['Cost per 100 lines', orNA(b.costPer100Lines, (v) => usd(v))],
117
+ ['vs median branch', orNA(b.vsMedian, times)],
118
+ ];
119
+
120
+ const caveat = b.pr && b.pr.mergedAt ? 'estimated spend, up to the merge' : 'estimated spend';
121
+
122
+ const titleY = repoLine ? 90 : 74;
123
+ const heroY = repoLine ? 150 : 134;
124
+ const barY = heroY + 40;
125
+ const legendY = barY + 26;
126
+
127
+ const L = [];
128
+ L.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${W0} ${H0}" font-family="${escapeXml(theme.sans)}">`);
129
+ L.push(`<rect x="0.5" y="0.5" width="${W0 - 1}" height="${H0 - 1}" rx="16" fill="${theme.surface1}" stroke="${theme.borderStrong}"/>`);
130
+ L.push(`<rect x="16" y="40" width="40" height="4" rx="2" fill="${theme.accent}"/>`);
131
+ L.push(`<text x="16" y="30" font-size="11.5" letter-spacing="0.08em" fill="${theme.textMuted}">AI COST RECEIPT</text>`);
132
+ if (repoLine) L.push(`<text x="16" y="66" font-size="12.5" fill="${theme.textMuted}">${repoLine}</text>`);
133
+ L.push(`<text x="16" y="${titleY}" font-size="15" fill="${theme.textPrimary}">${title}</text>`);
134
+ L.push(`<text x="16" y="${heroY}" font-size="42" font-family="${escapeXml(theme.mono)}" fill="${theme.textPrimary}">${escapeXml(cost)}</text>`);
135
+ L.push(`<text x="16" y="${heroY + 20}" font-size="12.5" fill="${theme.textMuted}">${escapeXml(caveat)}</text>`);
136
+
137
+ L.push(`<rect x="${barX}" y="${barY}" width="${barW}" height="10" rx="5" fill="${theme.surface3}"/>`);
138
+ if (ctxW > 0) L.push(`<rect x="${barX}" y="${barY}" width="${ctxW}" height="10" rx="5" fill="${theme.context}"/>`);
139
+ if (workW > 0) L.push(`<rect x="${barX + ctxW}" y="${barY}" width="${workW}" height="10" rx="5" fill="${theme.work}"/>`);
140
+ L.push(`<circle cx="20" cy="${legendY - 4}" r="4" fill="${theme.context}"/>`);
141
+ L.push(`<text x="30" y="${legendY}" font-size="12.5" fill="${theme.textSecondary}">${escapeXml(orNA(ctxShare, (v) => pct(v, 0, 'n/a')))} re-sent context</text>`);
142
+ L.push(`<circle cx="220" cy="${legendY - 4}" r="4" fill="${theme.work}"/>`);
143
+ L.push(`<text x="230" y="${legendY}" font-size="12.5" fill="${theme.textSecondary}">${escapeXml(orNA(ctxShare, (v) => pct(1 - v, 0, 'n/a')))} fresh work</text>`);
144
+
145
+ let y = legendY + 34;
146
+ const rowH = 24;
147
+ for (const [label, value] of rows) {
148
+ L.push(`<text x="16" y="${y}" font-size="13" fill="${theme.textMuted}">${escapeXml(label)}</text>`);
149
+ L.push(`<text x="${W0 - 16}" y="${y}" text-anchor="end" font-family="${escapeXml(theme.mono)}" font-size="13" fill="${theme.textPrimary}">${escapeXml(value)}</text>`);
150
+ y += rowH;
151
+ }
152
+ L.push(`<line x1="16" y1="${y + 6}" x2="${W0 - 16}" y2="${y + 6}" stroke="${theme.border}"/>`);
153
+ L.push(`<text x="16" y="${y + 26}" font-size="11.5" fill="${theme.textMuted}">No prompt or code content was read. Estimated locally from the session logs on this machine.</text>`);
154
+ L.push('</svg>');
155
+ return L.join('');
156
+ }
157
+
158
+ const DEFAULT_CHROMIUM_CANDIDATES = [
159
+ '/opt/homebrew/bin/chromium',
160
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
161
+ 'chromium',
162
+ 'google-chrome',
163
+ ];
164
+
165
+ /**
166
+ * Locate a local Chromium/Chrome binary. Absolute candidates are checked
167
+ * directly; bare names are resolved against PATH. Never downloads anything.
168
+ * @param {{candidates?:string[], PATH?:string}} [opt]
169
+ * @returns {string|null}
170
+ */
171
+ export function findChromiumBinary(opt = {}) {
172
+ const candidates = opt.candidates || DEFAULT_CHROMIUM_CANDIDATES;
173
+ const dirs = String(opt.PATH ?? process.env.PATH ?? '').split(path.delimiter).filter(Boolean);
174
+ for (const c of candidates) {
175
+ if (path.isAbsolute(c)) {
176
+ if (fs.existsSync(c)) return c;
177
+ continue;
178
+ }
179
+ for (const d of dirs) {
180
+ const full = path.join(d, c);
181
+ if (fs.existsSync(full)) return full;
182
+ }
183
+ }
184
+ return null;
185
+ }
186
+
187
+ /**
188
+ * Rasterize an SVG file to PNG via a local, headless Chromium/Chrome. Never
189
+ * fetches anything: when no local browser is found, the SVG already on disk
190
+ * is the deliverable and the message says so.
191
+ * @param {string} svgFile
192
+ * @param {string} pngFile
193
+ * @param {{width?:number, height?:number, candidates?:string[], PATH?:string}} [opt]
194
+ * @returns {{ok:boolean, message?:string}}
195
+ */
196
+ export function renderSvgToPng(svgFile, pngFile, opt = {}) {
197
+ const bin = findChromiumBinary(opt);
198
+ if (!bin) {
199
+ return { ok: false, message: `PNG needs a local Chromium or Chrome install; wrote the SVG instead: ${svgFile}` };
200
+ }
201
+ const width = opt.width || 640;
202
+ const height = opt.height || 460;
203
+ execFileSync(bin, [
204
+ '--headless=new',
205
+ `--screenshot=${path.resolve(pngFile)}`,
206
+ `--window-size=${width},${height}`,
207
+ `file://${path.resolve(svgFile)}`,
208
+ ], { stdio: 'ignore' });
209
+ return { ok: true };
210
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * "Your AI week" — spend this calendar week vs last, computed from decoded
3
+ * primary records the caller already scanned. Pure, like `analytics/receipt.js`:
4
+ * no store or filesystem access here beyond what `receipt-card.js` needs to
5
+ * read the design tokens for a card.
6
+ *
7
+ * "This week" runs Monday → today (partial); "last week" is the full prior
8
+ * Monday → Sunday, so the two are comparable even mid-week.
9
+ */
10
+ import { weekStart, addDays } from '../analytics/aggregate.js';
11
+ import { splitCost, isAttributableBranch } from '../analytics/receipt.js';
12
+ import { usd, pct } from '../core/units.js';
13
+ import { loadDesignTokens, resolveTheme, escapeXml, orNA } from './receipt-card.js';
14
+
15
+ function toISODate(d) {
16
+ return d.toISOString().slice(0, 10);
17
+ }
18
+
19
+ function newSide() {
20
+ return { cost: 0, priced: 0, turns: 0, sessions: new Set(), context: 0, work: 0, splitTurns: 0 };
21
+ }
22
+
23
+ function finishSide(s) {
24
+ const ctxWork = s.context + s.work;
25
+ return {
26
+ cost: s.priced > 0 ? s.cost : null,
27
+ turns: s.turns,
28
+ sessions: s.sessions.size,
29
+ contextShare: s.splitTurns > 0 && ctxWork > 0 ? s.context / ctxWork : null,
30
+ };
31
+ }
32
+
33
+ /**
34
+ * @param {{thisWeek:object, lastWeek:object, deltaPct:number|null, topBranches:object[], maxSession:object|null}} w
35
+ */
36
+ function buildInsight(w) {
37
+ const { thisWeek, lastWeek, deltaPct, topBranches } = w;
38
+ if (thisWeek.cost === null) return 'No priced turns this week yet.';
39
+ if (lastWeek.cost === null) {
40
+ return `${usd(thisWeek.cost)} across ${thisWeek.turns} turn(s) this week; no priced data from last week to compare.`;
41
+ }
42
+ if (lastWeek.cost === 0) {
43
+ return `${usd(thisWeek.cost)} this week versus ${usd(0)} last week.`;
44
+ }
45
+ const dir = deltaPct >= 0 ? 'up' : 'down';
46
+ let s = `Spend is ${dir} ${pct(Math.abs(deltaPct), 0, 'n/a')} versus last week (${usd(thisWeek.cost)} vs ${usd(lastWeek.cost)}).`;
47
+ if (topBranches.length) s += ` ${topBranches[0].branch} is the biggest line at ${usd(topBranches[0].cost)}.`;
48
+ return s;
49
+ }
50
+
51
+ /**
52
+ * @param {{records?:object[], now?:Date|string, book?:object|null, repoOf?:(rec:object)=>string|null}} [opt]
53
+ * @returns {{thisWeek:object, lastWeek:object, deltaPct:number|null, topBranches:object[], maxSession:object|null, insight:string}}
54
+ */
55
+ export function computeWeek(opt = {}) {
56
+ const records = opt.records || [];
57
+ const book = opt.book ?? null;
58
+ const repoOf = opt.repoOf || ((rec) => rec.repository || rec.project || null);
59
+ const now = opt.now instanceof Date ? opt.now : new Date(opt.now || Date.now());
60
+ const today = toISODate(now);
61
+ const thisFrom = weekStart(today);
62
+ const thisTo = today;
63
+ const lastTo = addDays(thisFrom, -1);
64
+ const lastFrom = addDays(thisFrom, -7);
65
+
66
+ const thisWk = newSide();
67
+ const lastWk = newSide();
68
+ const branchCost = new Map();
69
+ const sessionCost = new Map();
70
+
71
+ for (const rec of records) {
72
+ const d = rec.date || (rec.timestamp ? rec.timestamp.slice(0, 10) : null);
73
+ if (!d) continue;
74
+ let bucket = null;
75
+ let isThisWeek = false;
76
+ if (d >= thisFrom && d <= thisTo) { bucket = thisWk; isThisWeek = true; }
77
+ else if (d >= lastFrom && d <= lastTo) bucket = lastWk;
78
+ if (!bucket) continue;
79
+
80
+ bucket.turns += 1;
81
+ if (rec.session_id) bucket.sessions.add(rec.session_id);
82
+ const cost = rec.estimated_cost;
83
+ const priced = cost !== null && cost !== undefined;
84
+ if (priced) { bucket.cost += cost; bucket.priced += 1; }
85
+ const split = splitCost(rec, book);
86
+ if (split.context !== null) { bucket.context += split.context; bucket.work += split.work; bucket.splitTurns += 1; }
87
+
88
+ if (isThisWeek && priced) {
89
+ if (isAttributableBranch(rec.git_branch)) {
90
+ const repo = repoOf(rec) || 'unknown';
91
+ const key = `${repo}/${rec.git_branch}`;
92
+ const e = branchCost.get(key) || { repo, branch: rec.git_branch, cost: 0 };
93
+ e.cost += cost;
94
+ branchCost.set(key, e);
95
+ }
96
+ if (rec.session_id) sessionCost.set(rec.session_id, (sessionCost.get(rec.session_id) || 0) + cost);
97
+ }
98
+ }
99
+
100
+ const thisWeek = { ...finishSide(thisWk), from: thisFrom, to: thisTo };
101
+ const lastWeek = { ...finishSide(lastWk), from: lastFrom, to: lastTo };
102
+
103
+ const deltaPct = thisWeek.cost !== null && lastWeek.cost !== null && lastWeek.cost > 0
104
+ ? (thisWeek.cost - lastWeek.cost) / lastWeek.cost
105
+ : null;
106
+
107
+ const topBranches = [...branchCost.values()].sort((a, b) => b.cost - a.cost).slice(0, 3);
108
+
109
+ let maxSession = null;
110
+ for (const [id, cost] of sessionCost) {
111
+ if (!maxSession || cost > maxSession.cost) maxSession = { id, cost };
112
+ }
113
+
114
+ const week = { thisWeek, lastWeek, deltaPct, topBranches, maxSession };
115
+ return { ...week, insight: buildInsight(week) };
116
+ }
117
+
118
+ /**
119
+ * Render `computeWeek()`'s result as a self-contained SVG card. Same colour
120
+ * and font contract as `renderReceiptCardSvg`.
121
+ * @param {ReturnType<typeof computeWeek>} week
122
+ * @param {{skin?:string, mode?:string, width?:number, tokens?:object}} [opt]
123
+ */
124
+ export function renderWeekCardSvg(week, opt = {}) {
125
+ const tokens = opt.tokens || loadDesignTokens();
126
+ const theme = resolveTheme(tokens, opt.skin, opt.mode);
127
+ const W0 = 640;
128
+ const H0 = 400;
129
+ const width = opt.width || W0;
130
+ const height = Math.round(width * (H0 / W0));
131
+
132
+ const cost = orNA(week.thisWeek.cost, (v) => usd(v));
133
+ const sub = week.lastWeek.cost !== null ? `${usd(week.lastWeek.cost)} last week` : 'no priced data last week';
134
+ const ctxShare = week.thisWeek.contextShare;
135
+ const barX = 16;
136
+ const barW = W0 - barX * 2;
137
+ const ctxPct = ctxShare === null || ctxShare === undefined ? 0.5 : ctxShare;
138
+ const ctxW = Math.max(0, Math.round(barW * ctxPct));
139
+ const workW = Math.max(0, barW - ctxW);
140
+
141
+ const branchesLine = week.topBranches.length
142
+ ? week.topBranches.map((b) => `${b.branch} ${usd(b.cost)}`).join(', ')
143
+ : 'n/a';
144
+ const maxSessionLine = week.maxSession ? usd(week.maxSession.cost) : 'n/a';
145
+
146
+ const rows = [
147
+ ['Turns', String(week.thisWeek.turns ?? 0)],
148
+ ['Sessions', String(week.thisWeek.sessions ?? 0)],
149
+ ['Top branches', branchesLine],
150
+ ['Most expensive session', maxSessionLine],
151
+ ];
152
+
153
+ const heroY = 116;
154
+ const barY = heroY + 40;
155
+ const legendY = barY + 26;
156
+
157
+ const L = [];
158
+ L.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${W0} ${H0}" font-family="${escapeXml(theme.sans)}">`);
159
+ L.push(`<rect x="0.5" y="0.5" width="${W0 - 1}" height="${H0 - 1}" rx="16" fill="${theme.surface1}" stroke="${theme.borderStrong}"/>`);
160
+ L.push(`<rect x="16" y="40" width="40" height="4" rx="2" fill="${theme.accent}"/>`);
161
+ L.push(`<text x="16" y="30" font-size="11.5" letter-spacing="0.08em" fill="${theme.textMuted}">YOUR AI WEEK</text>`);
162
+ L.push(`<text x="16" y="66" font-size="12.5" fill="${theme.textMuted}">${escapeXml(week.thisWeek.from)} to ${escapeXml(week.thisWeek.to)}</text>`);
163
+ L.push(`<text x="16" y="${heroY}" font-size="42" font-family="${escapeXml(theme.mono)}" fill="${theme.textPrimary}">${escapeXml(cost)}</text>`);
164
+ L.push(`<text x="16" y="${heroY + 20}" font-size="12.5" fill="${theme.textMuted}">${escapeXml(sub)}</text>`);
165
+
166
+ L.push(`<rect x="${barX}" y="${barY}" width="${barW}" height="10" rx="5" fill="${theme.surface3}"/>`);
167
+ if (ctxW > 0) L.push(`<rect x="${barX}" y="${barY}" width="${ctxW}" height="10" rx="5" fill="${theme.context}"/>`);
168
+ if (workW > 0) L.push(`<rect x="${barX + ctxW}" y="${barY}" width="${workW}" height="10" rx="5" fill="${theme.work}"/>`);
169
+ L.push(`<circle cx="20" cy="${legendY - 4}" r="4" fill="${theme.context}"/>`);
170
+ L.push(`<text x="30" y="${legendY}" font-size="12.5" fill="${theme.textSecondary}">${escapeXml(orNA(ctxShare, (v) => pct(v, 0, 'n/a')))} re-sent context</text>`);
171
+ L.push(`<circle cx="220" cy="${legendY - 4}" r="4" fill="${theme.work}"/>`);
172
+ L.push(`<text x="230" y="${legendY}" font-size="12.5" fill="${theme.textSecondary}">${escapeXml(orNA(ctxShare, (v) => pct(1 - v, 0, 'n/a')))} fresh work</text>`);
173
+
174
+ let y = legendY + 34;
175
+ const rowH = 24;
176
+ for (const [label, value] of rows) {
177
+ L.push(`<text x="16" y="${y}" font-size="13" fill="${theme.textMuted}">${escapeXml(label)}</text>`);
178
+ L.push(`<text x="${W0 - 16}" y="${y}" text-anchor="end" font-family="${escapeXml(theme.mono)}" font-size="13" fill="${theme.textPrimary}">${escapeXml(value)}</text>`);
179
+ y += rowH;
180
+ }
181
+ L.push(`<line x1="16" y1="${y + 6}" x2="${W0 - 16}" y2="${y + 6}" stroke="${theme.border}"/>`);
182
+ L.push(`<text x="16" y="${y + 26}" font-size="11.5" fill="${theme.textMuted}">${escapeXml(week.insight)}</text>`);
183
+ L.push('</svg>');
184
+ return L.join('');
185
+ }