claude-finops 0.7.2 → 0.9.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/README.md +12 -47
- package/config/pricing.json +166 -24
- package/config/settings.json +11 -5
- package/finops/actions.py +1 -1
- package/finops/agents.py +2 -1
- package/finops/analytics.py +721 -611
- package/finops/api.py +179 -55
- package/finops/diagnose.py +17 -20
- package/finops/etl.py +169 -57
- package/finops/integrate.py +22 -57
- package/finops/paths.py +12 -0
- package/finops/plan_history.py +80 -0
- package/finops/pricing.py +57 -6
- package/finops/procs.py +7 -5
- package/finops/report.py +29 -23
- package/finops/segments.py +149 -0
- package/package.json +1 -1
- package/run.cmd +3 -2
- package/run.py +26 -59
- package/web/app.js +297 -333
- package/web/charts.js +60 -16
- package/finops/advisor.py +0 -188
- package/finops/trial.py +0 -169
package/web/app.js
CHANGED
|
@@ -3,7 +3,7 @@ const {fmtUSD, fmtNum, fmtInt, fmtPct, seriesVar} = C;
|
|
|
3
3
|
|
|
4
4
|
/* ============================ state ============================ */
|
|
5
5
|
const S = {
|
|
6
|
-
view: '
|
|
6
|
+
view: 'hygiene',
|
|
7
7
|
opts: null,
|
|
8
8
|
filter: {start: null, end: null, agents: [], models: [], projects: [], categories: [],
|
|
9
9
|
include_sandbox: true, min_cost: null, min_tokens: null},
|
|
@@ -12,6 +12,8 @@ const S = {
|
|
|
12
12
|
grain: 'day',
|
|
13
13
|
cache: new Map(),
|
|
14
14
|
drawerStack: [],
|
|
15
|
+
sessPage: {offset: 0, limit: 200},
|
|
16
|
+
promptPage: {offset: 0, limit: 200},
|
|
15
17
|
};
|
|
16
18
|
const $ = (s, r = document) => r.querySelector(s);
|
|
17
19
|
const h = (html) => { const t = document.createElement('template');
|
|
@@ -71,6 +73,13 @@ const dur = s => s == null ? '—' : s < 60 ? `${Math.round(s)}s`
|
|
|
71
73
|
const shortDay = d => (d || '').slice(5);
|
|
72
74
|
const shortId = s => (s || '').slice(0, 8);
|
|
73
75
|
|
|
76
|
+
// "(▲12% vs yesterday)"; nothing when yesterday had no spend to compare against.
|
|
77
|
+
const vsYesterday = o => {
|
|
78
|
+
const y = o.cost_yesterday?.c, t = o.cost_today?.c;
|
|
79
|
+
if (!y || t == null) return '';
|
|
80
|
+
const d = (t - y) / y * 100;
|
|
81
|
+
return ` (${d >= 0 ? '▲' : '▼'}${Math.abs(d).toFixed(0)}% vs yesterday)`;
|
|
82
|
+
};
|
|
74
83
|
function kpi(label, value, detail, opts = {}) {
|
|
75
84
|
const na = value == null;
|
|
76
85
|
return `<div class="kpi${na ? ' na' : ''}">
|
|
@@ -104,6 +113,7 @@ function wireTable(host, rows, onRow) {
|
|
|
104
113
|
/* ============================ chrome ============================ */
|
|
105
114
|
const NAV = [
|
|
106
115
|
['Command center', [
|
|
116
|
+
['hygiene', '◫', 'Context hygiene'],
|
|
107
117
|
['overview', '◧', 'Executive overview'],
|
|
108
118
|
['advisor', '✦', 'What should I do?'],
|
|
109
119
|
['agents', '◎', 'Agents'],
|
|
@@ -128,7 +138,6 @@ const NAV = [
|
|
|
128
138
|
['diagnose', '✚', 'Why so many tokens?', 'priced'],
|
|
129
139
|
['attribution', '⧉', 'Who used the tokens', 'priced'],
|
|
130
140
|
['waste', '⚠', 'Waste detection'],
|
|
131
|
-
['modelswitch', '⇄', 'Model switch', 'priced'],
|
|
132
141
|
['freemodels', '◇', 'Free models', 'claude'],
|
|
133
142
|
['compare', '⚖', 'Compare models', 'priced'],
|
|
134
143
|
['toolkit', '✎', 'Skills & MCP', 'claude'],
|
|
@@ -297,6 +306,9 @@ function openPop(btn, kind) {
|
|
|
297
306
|
<input type="number" step="0.01" id="mc" value="${f.min_cost ?? ''}" placeholder="any">
|
|
298
307
|
<div class="hd">Minimum billable tokens</div>
|
|
299
308
|
<input type="number" id="mt" value="${f.min_tokens ?? ''}" placeholder="any">
|
|
309
|
+
<div class="note" style="margin:8px 0 6px">These apply per <strong>request</strong>, not per
|
|
310
|
+
session or prompt. A session's totals will count only its requests above the
|
|
311
|
+
threshold, so filtered totals read lower than the session's real cost.</div>
|
|
300
312
|
<button class="chip on" id="applyth" style="width:100%;justify-content:center">Apply</button>`;
|
|
301
313
|
} else {
|
|
302
314
|
const key = kind, src = kind === 'models' ? o.models.map(m => [m.model, modelName(m.model), m.n])
|
|
@@ -360,7 +372,6 @@ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeDrawer(
|
|
|
360
372
|
async function openPrompt(id) {
|
|
361
373
|
const d = drawer('Prompt detail', '<div class="loading">Loading…</div>');
|
|
362
374
|
const p = await fetch(`/api/prompt/${id}`).then(r => r.json());
|
|
363
|
-
const adv = p.advisor || {};
|
|
364
375
|
d.querySelector('.content').innerHTML = `
|
|
365
376
|
<div class="grid g4">
|
|
366
377
|
${kpi('Estimated cost', fmtUSD(p.est_cost_usd), null, {badge: BADGE.estimated})}
|
|
@@ -376,22 +387,6 @@ async function openPrompt(id) {
|
|
|
376
387
|
<span>${fmtInt(p.char_len)} chars · ${fmtInt(p.word_len)} words</span>
|
|
377
388
|
${p.source ? `<span class="pill">${esc(p.source)}</span>` : ''}
|
|
378
389
|
</div>`, {badge: BADGE.actual})}
|
|
379
|
-
${card('Cost drivers & optimization advice', adv.available ? `
|
|
380
|
-
<div class="stack">
|
|
381
|
-
<div><b style="font-size:12px">Why this was expensive</b>
|
|
382
|
-
<ul style="margin:5px 0 0 18px;font-size:12px;color:var(--text-2)">
|
|
383
|
-
${adv.why_expensive.map(x => `<li>${esc(x)}</li>`).join('')}</ul></div>
|
|
384
|
-
<div><b style="font-size:12px">Suggested changes</b>
|
|
385
|
-
<ul style="margin:5px 0 0 18px;font-size:12px;color:var(--text-2)">
|
|
386
|
-
${adv.suggestions.map(x => `<li>${esc(x)}</li>`).join('')}</ul></div>
|
|
387
|
-
<div class="grid g3">
|
|
388
|
-
${kpi('Est. token reduction', '~' + adv.estimated_token_reduction_pct + '%', null, {badge: BADGE.recommendation})}
|
|
389
|
-
${kpi('Est. cost reduction', '~' + adv.estimated_cost_reduction_pct + '%')}
|
|
390
|
-
${kpi('Est. cost avoided', '~' + fmtUSD(adv.estimated_cost_reduction_usd))}
|
|
391
|
-
</div></div>`
|
|
392
|
-
: `<div class="na">${esc(adv.message || 'No analysis available')}</div>`,
|
|
393
|
-
{badge: BADGE.recommendation,
|
|
394
|
-
footer: adv.available ? esc(adv.disclaimer) : null})}
|
|
395
390
|
${card('Requests in this turn', table([
|
|
396
391
|
{h: 'Time', f: r => `<span class="mono">${esc((r.ts||'').slice(11,19))}</span>`},
|
|
397
392
|
{h: 'Model', f: r => `<span class="swatch" style="background:${modelColor(r.model)}"></span>${esc(modelName(r.model))}`},
|
|
@@ -464,6 +459,8 @@ async function openSession(id) {
|
|
|
464
459
|
: '<div class="na">None</div>')}
|
|
465
460
|
</div>
|
|
466
461
|
${card('Session metadata', `<dl class="kv">
|
|
462
|
+
${s.resume ? `<dt>Resume</dt><dd><span class="mono">${esc(s.resume)}</span>
|
|
463
|
+
<button class="btn pb-copy" id="sess-resume" data-copy="${esc(s.resume)}">Copy</button></dd>` : ''}
|
|
467
464
|
<dt>Session ID</dt><dd class="mono">${esc(s.id)}</dd>
|
|
468
465
|
<dt>Title</dt><dd>${s.title ? esc(s.title) : NA()}</dd>
|
|
469
466
|
<dt>Project</dt><dd>${esc(s.project)}</dd>
|
|
@@ -475,6 +472,10 @@ async function openSession(id) {
|
|
|
475
472
|
<dt>Commits / PRs</dt><dd>${NA()}</dd>
|
|
476
473
|
</dl>`)}`;
|
|
477
474
|
wireTable(cont, s.prompts, r => openPrompt(r.prompt_id));
|
|
475
|
+
const rb = cont.querySelector('#sess-resume');
|
|
476
|
+
if (rb) rb.onclick = async () => {
|
|
477
|
+
try { await navigator.clipboard.writeText(rb.dataset.copy); rb.textContent = 'Copied'; }
|
|
478
|
+
catch { rb.textContent = 'Copy failed'; } };
|
|
478
479
|
const tl = s.timeline.map((r, i) => ({i, ...r}));
|
|
479
480
|
C.timeSeries($('#sesschart', cont), {rows: tl, x: 'i', type: 'area',
|
|
480
481
|
series: [{key: 'context_tokens', label: 'Context tokens', color: seriesVar(0)}],
|
|
@@ -512,7 +513,7 @@ VIEWS.overview = async (page) => {
|
|
|
512
513
|
page.innerHTML = `
|
|
513
514
|
<div class="grid g5">
|
|
514
515
|
${kpi('Estimated spend', fmtUSD(o.est_cost_usd),
|
|
515
|
-
`${fmtUSD(o.cost_today.c)} today · ${fmtUSD(o.cost_week.c)} last 7d`, {badge: BADGE.estimated})}
|
|
516
|
+
`${fmtUSD(o.cost_today.c)} today${vsYesterday(o)} · ${fmtUSD(o.cost_week.c)} last 7d`, {badge: BADGE.estimated})}
|
|
516
517
|
${kpi('Billable tokens', fmtNum(o.billable_tokens),
|
|
517
518
|
`${fmtNum(o.output_tokens)} output · ${fmtNum(o.cache_read_tokens)} cache read`,
|
|
518
519
|
{badge: BADGE.actual})}
|
|
@@ -572,12 +573,8 @@ VIEWS.overview = async (page) => {
|
|
|
572
573
|
${card('Optimization opportunities', `<div class="stack">${
|
|
573
574
|
b.recommendations.recommendations.length
|
|
574
575
|
? b.recommendations.recommendations.slice(0, 5).map(r => `<div class="item">
|
|
575
|
-
<div class="hd">${esc(r.title)}
|
|
576
|
-
|
|
577
|
-
<div class="mt"><span>Actual ${fmtUSD(r.actual_cost_usd)}</span>
|
|
578
|
-
<span>Est. alternative ${fmtUSD(r.estimated_alternative_cost_usd)}</span>
|
|
579
|
-
<span style="color:var(--good-ink);font-weight:600">
|
|
580
|
-
Est. saving ${fmtUSD(r.estimated_savings_usd)} (${r.estimated_savings_pct}%)</span></div>
|
|
576
|
+
<div class="hd">${esc(r.title)}</div>
|
|
577
|
+
<div class="mt"><span>Actual ${fmtUSD(r.actual_cost_usd)}</span></div>
|
|
581
578
|
<div class="note">${esc(r.caveat)}</div></div>`).join('')
|
|
582
579
|
: '<div class="empty">No recommendation met the evidence threshold</div>'}</div>`,
|
|
583
580
|
{badge: BADGE.recommendation})}
|
|
@@ -588,7 +585,7 @@ VIEWS.overview = async (page) => {
|
|
|
588
585
|
{badge: BADGE.forecast, hint: forecast.available ? forecast.method : ''})}
|
|
589
586
|
${card('FinOps score', `<div class="scorewrap">
|
|
590
587
|
<div><div class="scorenum">${scorecard.score}</div>
|
|
591
|
-
<div class="scoregrade">out of 100
|
|
588
|
+
<div class="scoregrade">out of 100</div></div>
|
|
592
589
|
<div style="flex:1;min-width:230px" class="stack">${scorecard.dimensions.map(d => `
|
|
593
590
|
<div><div style="display:flex;justify-content:space-between;font-size:11.5px">
|
|
594
591
|
<span>${esc(d.name)}</span><span style="font-variant-numeric:tabular-nums">${d.score}</span></div>
|
|
@@ -602,9 +599,10 @@ VIEWS.overview = async (page) => {
|
|
|
602
599
|
if (alloc.configured) {
|
|
603
600
|
C.gauge(gEl, {pct: alloc.used_pct, status: alloc.status, label: 'of plan allowance'});
|
|
604
601
|
$('#burnnotes', page).innerHTML = `<div class="stack">
|
|
605
|
-
<div class="item"><div class="hd">${statusGlyph(alloc.status)}
|
|
606
|
-
|
|
607
|
-
|
|
602
|
+
<div class="item"><div class="hd">${statusGlyph(alloc.status)} ${alloc.days_until_limit == null
|
|
603
|
+
? `<b>Exceeded</b> your current limit.`
|
|
604
|
+
: `You are likely to reach your current limit in <b>${alloc.days_until_limit}</b> days${alloc.limit_date
|
|
605
|
+
? ` (around ${esc(alloc.limit_date)})` : ''}.`}</div></div>
|
|
608
606
|
<div class="item"><div class="hd">At the current burn rate you will
|
|
609
607
|
${alloc.projected_overage_pct > 0 ? `exceed your allowance by
|
|
610
608
|
<b>${fmtPct(alloc.projected_overage_pct)}</b>` : `finish the period at
|
|
@@ -659,7 +657,7 @@ VIEWS.overview = async (page) => {
|
|
|
659
657
|
], lp, {onRow: 1});
|
|
660
658
|
wireTable($('#topprompts', page), lp, r => openPrompt(r.prompt_id));
|
|
661
659
|
|
|
662
|
-
const ls = await api('sessions', '&limit=10&order=cost');
|
|
660
|
+
const ls = (await api('sessions', '&limit=10&order=cost')).rows;
|
|
663
661
|
$('#topsessions', page).innerHTML = table([
|
|
664
662
|
{h: 'Session', trunc: 1, title: r => r.session_id,
|
|
665
663
|
f: r => esc(r.title || shortId(r.session_id))},
|
|
@@ -670,13 +668,16 @@ VIEWS.overview = async (page) => {
|
|
|
670
668
|
], ls, {onRow: 1});
|
|
671
669
|
wireTable($('#topsessions', page), ls, r => openSession(r.session_id));
|
|
672
670
|
|
|
673
|
-
if (forecast.available) {
|
|
671
|
+
if (forecast.available && !forecast.insufficient_history) {
|
|
674
672
|
C.forecastFan($('#fan', page), {history: burn.series, scenarios: forecast.scenarios,
|
|
675
673
|
remainingDays: forecast.remaining_days});
|
|
676
674
|
$('#fanleg', page).innerHTML = `<span class="it"><span class="swatch"
|
|
677
675
|
style="background:var(--s1)"></span>Cumulative actual (estimated cost)</span>
|
|
678
676
|
<span class="it"><span class="swatch" style="background:var(--s1);opacity:.35"></span>
|
|
679
677
|
Forecast band: conservative → high</span>`;
|
|
678
|
+
} else if (forecast.available) {
|
|
679
|
+
$('#fan', page).innerHTML = '<div class="empty">Fewer than 7 priced days in the window: '
|
|
680
|
+
+ 'bands not shown.</div>';
|
|
680
681
|
} else $('#fan', page).innerHTML = '<div class="empty">Not enough history to forecast</div>';
|
|
681
682
|
|
|
682
683
|
renderAdvisorHero(page, advisor);
|
|
@@ -700,9 +701,7 @@ function renderAdvisorHero(page, advisor) {
|
|
|
700
701
|
: a.basis === 'recommendation' ? 'rec' : 'est'}">${esc(a.basis.split(':')[0])}</span></b>
|
|
701
702
|
<span>${esc(a.detail)}</span></div></div>`).join('')
|
|
702
703
|
: '<div class="empty">Nothing needs your attention in this range</div>'}</div>
|
|
703
|
-
|
|
704
|
-
Estimated savings opportunity in range: <b>${fmtUSD(advisor.estimated_savings_range_usd[0])}
|
|
705
|
-
– ${fmtUSD(advisor.estimated_savings_range_usd[1])}</b> — modelled, not booked.</div>` : ''}
|
|
704
|
+
|
|
706
705
|
</div>`);
|
|
707
706
|
page.insertBefore(hero, page.firstChild);
|
|
708
707
|
}
|
|
@@ -729,14 +728,12 @@ VIEWS.advisor = async (page) => {
|
|
|
729
728
|
</div>
|
|
730
729
|
${card('All recommendations', recs.recommendations.length ? `<div class="stack">
|
|
731
730
|
${recs.recommendations.map(r => `<div class="item"><div class="hd">${esc(r.title)}
|
|
732
|
-
<span class="spacer"></span><span class="badge
|
|
731
|
+
<span class="spacer"></span><span class="badge">${esc(r.confidence)}</span></div>
|
|
733
732
|
${r.current_model ? `<div class="dt"><b>Currently on:</b> ${esc(r.current_model)}${
|
|
734
733
|
r.scope ? ` · ${esc(r.scope)}` : ''}</div>` : ''}
|
|
734
|
+
${r.detail ? `<div class="dt">${esc(r.detail)}</div>` : ''}
|
|
735
735
|
<div class="grid g3" style="gap:8px;margin:4px 0">
|
|
736
|
-
${kpi('
|
|
737
|
-
${kpi('Est. alternative', fmtUSD(r.estimated_alternative_cost_usd), null, {small: 1})}
|
|
738
|
-
${kpi('Est. potential saving', fmtUSD(r.estimated_savings_usd),
|
|
739
|
-
`${r.estimated_savings_pct}%`, {small: 1, badge: BADGE.recommendation})}
|
|
736
|
+
${kpi('Spend involved', fmtUSD(r.actual_cost_usd), null, {small: 1, badge: BADGE.estimated})}
|
|
740
737
|
</div>
|
|
741
738
|
${r.alternatives && r.alternatives.length ? `<div class="dt" style="margin-top:6px">
|
|
742
739
|
<b>Your options${r.agent ? ` within ${esc(r.agent)}` : ''}</b> — pick the trade-off you want:</div>
|
|
@@ -745,13 +742,11 @@ VIEWS.advisor = async (page) => {
|
|
|
745
742
|
? ' <span class="badge rec">suggested</span>' : '')},
|
|
746
743
|
{h: 'Tier', f: a => esc(a.tier)},
|
|
747
744
|
{h: 'Est. cost', num: 1, f: a => fmtUSD(a.estimated_cost_usd)},
|
|
748
|
-
{h: 'Est. saving', num: 1, f: a =>
|
|
749
|
-
`${fmtUSD(a.estimated_savings_usd)} <span class="note">(${a.estimated_savings_pct}%)</span>`},
|
|
750
745
|
], r.alternatives)}` : ''}
|
|
751
746
|
<div class="note">${esc(r.caveat)}</div></div>`).join('')}</div>`
|
|
752
747
|
: '<div class="empty">No recommendation met the evidence threshold for this range</div>',
|
|
753
748
|
{badge: BADGE.recommendation,
|
|
754
|
-
footer: '
|
|
749
|
+
footer: 'These are observations from your own usage. No dollar saving is attached unless the method can support one — a guessed percentage of spend is not a saving.'})}
|
|
755
750
|
${card('Anomalies to inspect', `<div class="stack" id="anolist">${anos.anomalies.map((a, i) =>
|
|
756
751
|
`<div class="item sev-${a.severity} clickable" data-i="${i}"><div class="hd">
|
|
757
752
|
${a.severity === 'high' ? '🚨' : '⚠️'} ${esc(a.title)}</div>
|
|
@@ -781,9 +776,10 @@ const METRICS = [
|
|
|
781
776
|
['avg_context', 'Avg context', fmtNum],
|
|
782
777
|
];
|
|
783
778
|
VIEWS.usage = async (page) => {
|
|
784
|
-
const [tl, models, ov] = await Promise.all([api('timeline', `&grain=${S.grain}`),
|
|
785
|
-
api('models'), api('overview')]);
|
|
779
|
+
const [tl, models, ov, hm] = await Promise.all([api('timeline', `&grain=${S.grain}`),
|
|
780
|
+
api('models'), api('overview'), api('heatmap')]);
|
|
786
781
|
const [, mlabel, mfmt] = METRICS.find(m => m[0] === S.metric) || METRICS[0];
|
|
782
|
+
const hmMetric = ['cost', 'tokens', 'requests'].includes(S.metric) ? S.metric : 'cost';
|
|
787
783
|
page.innerHTML = `
|
|
788
784
|
<div class="grid g5">
|
|
789
785
|
${kpi('Total tokens', fmtNum(ov.billable_tokens), null, {badge: BADGE.actual})}
|
|
@@ -807,7 +803,13 @@ VIEWS.usage = async (page) => {
|
|
|
807
803
|
</div>
|
|
808
804
|
<div class="chart" id="tl"></div>`,
|
|
809
805
|
{badge: S.metric === 'cost' ? BADGE.estimated : BADGE.actual,
|
|
810
|
-
hint: 'click a bucket to drill into that day',
|
|
806
|
+
hint: 'click a bucket to drill into that day',
|
|
807
|
+
footer: 'Days are bucketed in UTC, as Claude Code timestamps its transcripts. '
|
|
808
|
+
+ 'If you work late in a timezone ahead of UTC, that work lands on the previous day here.',
|
|
809
|
+
flush: 0})}
|
|
810
|
+
${card('Peak hours', '<div class="chart" id="heat"></div>',
|
|
811
|
+
{badge: hmMetric === 'cost' ? BADGE.estimated : BADGE.actual,
|
|
812
|
+
hint: `your local time (${hm.tz || 'local'}) · ${hmMetric === 'cost' ? 'estimated cost' : hmMetric}`})}
|
|
811
813
|
${card('Model mix over time', '<div class="legend" id="mixleg"></div>' +
|
|
812
814
|
'<div class="chart" id="mix"></div>', {badge: BADGE.estimated,
|
|
813
815
|
hint: 'stacked estimated cost per model'})}
|
|
@@ -825,6 +827,7 @@ VIEWS.usage = async (page) => {
|
|
|
825
827
|
xLabel: v => S.grain === 'day' ? shortDay(v) : v.slice(8).replace('T', ' ') + ':00',
|
|
826
828
|
onClick: r => { if (S.grain === 'day') { S.filter.start = r.bucket; S.filter.end = r.bucket;
|
|
827
829
|
S.range = 'custom'; bust(); render(); } }});
|
|
830
|
+
C.heatmap($('#heat', page), {cells: hm.cells, metric: hmMetric});
|
|
828
831
|
|
|
829
832
|
// per-model stacked mix
|
|
830
833
|
const byDay = new Map();
|
|
@@ -864,7 +867,8 @@ VIEWS.usage = async (page) => {
|
|
|
864
867
|
|
|
865
868
|
/* ---------- burn & limits ---------- */
|
|
866
869
|
VIEWS.burn = async (page) => {
|
|
867
|
-
const burn = await api('burn')
|
|
870
|
+
const [burn, plan] = await Promise.all([api('burn'), fetch('/api/plan_history')
|
|
871
|
+
.then(r => r.json()).catch(e => ({ok: false, reason: e.message}))]);
|
|
868
872
|
const bp = burn.period;
|
|
869
873
|
const rows = Object.entries(burn.allowances);
|
|
870
874
|
page.innerHTML = `
|
|
@@ -901,8 +905,9 @@ VIEWS.burn = async (page) => {
|
|
|
901
905
|
<dt>Overage</dt><dd>${fmtPct(a.projected_overage_pct)}</dd>
|
|
902
906
|
</dl></div></div>
|
|
903
907
|
<div class="item" style="margin-top:9px">
|
|
904
|
-
<div class="hd">${statusGlyph(a.status)}
|
|
905
|
-
|
|
908
|
+
<div class="hd">${statusGlyph(a.status)} ${a.days_until_limit == null
|
|
909
|
+
? `<b>Exceeded</b> this limit.`
|
|
910
|
+
: `You are likely to reach this limit in <b>${a.days_until_limit}</b> days.`}</div>
|
|
906
911
|
<div class="dt">At the current burn rate you will
|
|
907
912
|
${a.projected_overage_pct > 0
|
|
908
913
|
? `exceed the allowance by <b>${fmtPct(a.projected_overage_pct)}</b>.`
|
|
@@ -915,10 +920,31 @@ VIEWS.burn = async (page) => {
|
|
|
915
920
|
<span class="mono">config/settings.json → limits</span> to enable usage-vs-limit,
|
|
916
921
|
days-until-limit and limit-date projections.</p></div>`,
|
|
917
922
|
{badge: a.configured ? BADGE.estimated : ''})).join('')}</div>
|
|
923
|
+
${card('Plan limits over time', plan.ok ? `
|
|
924
|
+
<div class="grid g3">
|
|
925
|
+
${kpi('5-hour peak', fmtPct(plan.summary.five_hour_peak ?? 0, 0), null, {badge: BADGE.actual})}
|
|
926
|
+
${kpi('Times at 90%+ (5-hour)', fmtInt(plan.summary.five_hour_ge90),
|
|
927
|
+
`${fmtInt(plan.summary.five_hour_hit100)} reached 100%`)}
|
|
928
|
+
${kpi('Weekly peak', fmtPct(plan.summary.weekly_peak ?? 0, 0),
|
|
929
|
+
`${fmtInt(plan.summary.weekly_ge90)} times at 90%+`)}
|
|
930
|
+
</div>
|
|
931
|
+
<div class="legend" id="planleg"></div><div class="chart" id="plan"></div>`
|
|
932
|
+
: `<div class="empty">${esc(plan.reason || 'Unavailable')}</div>`,
|
|
933
|
+
{badge: BADGE.actual, hint: '5-hour and weekly plan usage, % used',
|
|
934
|
+
footer: 'Read from the Claude desktop app\'s local plan-usage-history.json. '
|
|
935
|
+
+ 'Its format is undocumented, so this card may go blank after an app update.'})}
|
|
918
936
|
${card('Daily consumption within the billing period', '<div class="chart" id="bs"></div>',
|
|
919
937
|
{badge: BADGE.estimated})}`;
|
|
920
938
|
for (const [k, a] of rows) if (a.configured)
|
|
921
939
|
C.gauge($(`#g-${k}`, page), {pct: a.used_pct, status: a.status, label: 'of allowance'});
|
|
940
|
+
if (plan.ok) {
|
|
941
|
+
const ps = [{key: 'five_hour', label: '5-hour window', color: seriesVar(0), fmt: v => fmtPct(v, 0)},
|
|
942
|
+
{key: 'weekly', label: 'Weekly window', color: seriesVar(1), fmt: v => fmtPct(v, 0)}];
|
|
943
|
+
C.legend($('#planleg', page), ps.map(s => ({label: s.label, color: s.color})));
|
|
944
|
+
C.timeSeries($('#plan', page), {rows: plan.series, x: 't', type: 'line', series: ps,
|
|
945
|
+
fmt: v => fmtPct(v, 0), max: 100, height: 220,
|
|
946
|
+
xLabel: t => new Date(t).toLocaleString([], {month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'})});
|
|
947
|
+
}
|
|
922
948
|
C.timeSeries($('#bs', page), {rows: burn.series, x: 'day', type: 'bar',
|
|
923
949
|
series: [{key: 'cost', label: 'Estimated cost', color: seriesVar(0), fmt: fmtUSD}],
|
|
924
950
|
fmt: fmtUSD, height: 220, xLabel: shortDay});
|
|
@@ -926,10 +952,23 @@ VIEWS.burn = async (page) => {
|
|
|
926
952
|
|
|
927
953
|
/* ---------- models ---------- */
|
|
928
954
|
VIEWS.models = async (page) => {
|
|
929
|
-
const m = await api('models');
|
|
955
|
+
const [m, lc] = await Promise.all([api('models'), api('long_context_pricing')]);
|
|
930
956
|
const sup = m.superlatives, rows = m.rows;
|
|
931
957
|
const find = k => rows.find(r => r.model === sup[k]);
|
|
932
958
|
page.innerHTML = `
|
|
959
|
+
${lc.unpriced_requests ? `<div class="hero">
|
|
960
|
+
<h3>⚠️ Some long-context requests are priced low</h3>
|
|
961
|
+
<div class="stack">
|
|
962
|
+
<div class="item sev-high">
|
|
963
|
+
<div class="hd">${fmtInt(lc.unpriced_requests)} requests sent more context than their
|
|
964
|
+
model's standard window, so they ran on the long-context variant — which bills at a
|
|
965
|
+
premium this price table does not have.</div>
|
|
966
|
+
<div class="dt">Affected: ${lc.unpriced_models.map(esc).join(', ')}.
|
|
967
|
+
Their estimated cost is <strong>understated</strong>. Add a
|
|
968
|
+
<code>"<model>[1m]"</code> entry to <code>config/pricing.json</code> and rebuild
|
|
969
|
+
to correct it. Requests with a configured long-context price
|
|
970
|
+
(${fmtInt(lc.repriced_requests)}) are already billed at it.</div></div>
|
|
971
|
+
</div></div>` : ''}
|
|
933
972
|
<div class="grid g4">
|
|
934
973
|
${kpi('Most expensive', sup.most_expensive ? esc(modelName(sup.most_expensive)) : null,
|
|
935
974
|
find('most_expensive') ? fmtUSD(find('most_expensive').cost) + ' estimated' : '', {small: 1})}
|
|
@@ -977,7 +1016,9 @@ VIEWS.models = async (page) => {
|
|
|
977
1016
|
$('#mt', page).innerHTML = table([
|
|
978
1017
|
{h: 'Model', f: r => `<span class="swatch" style="background:${modelColor(r.model)}"></span>${esc(r.display_name)}
|
|
979
1018
|
${r.pricing_known ? '' : '<span class="badge na" title="no price entry — default pricing used">default price</span>'}`},
|
|
980
|
-
{h: 'Tier', f: r =>
|
|
1019
|
+
{h: 'Tier', f: r => r.tier === 'unknown'
|
|
1020
|
+
? `<span class="pill">unpriced</span><span class="badge na" title="no price in pricing.json">no price in pricing.json</span>`
|
|
1021
|
+
: `<span class="pill">${esc(r.tier)}</span>`},
|
|
981
1022
|
{h: 'Requests', num: 1, f: r => fmtInt(r.requests)},
|
|
982
1023
|
{h: 'Sessions', num: 1, f: r => fmtInt(r.sessions)},
|
|
983
1024
|
{h: 'Input', num: 1, f: r => fmtNum(r.input_tokens)},
|
|
@@ -986,7 +1027,8 @@ VIEWS.models = async (page) => {
|
|
|
986
1027
|
{h: 'Cache write', num: 1, f: r => fmtNum(r.cache_write_tokens)},
|
|
987
1028
|
{h: 'Tokens', num: 1, f: r => fmtNum(r.tokens)},
|
|
988
1029
|
{h: 'Avg context', num: 1, f: r => fmtNum(r.avg_context)},
|
|
989
|
-
{h: 'Ctx util', num: 1, f: r => r.utilization_pct == null ? '—' : fmtPct(r.utilization_pct)
|
|
1030
|
+
{h: 'Ctx util', num: 1, f: r => (r.utilization_pct == null ? '—' : fmtPct(r.utilization_pct))
|
|
1031
|
+
+ (r.over_window_requests ? ` <span class="note" title="requests over a window this price table cannot explain">· ${fmtInt(r.over_window_requests)} over</span>` : '')},
|
|
990
1032
|
{h: 'Avg latency', num: 1, f: r => r.avg_latency_ms ? (r.avg_latency_ms/1000).toFixed(1)+'s' : '—'},
|
|
991
1033
|
{h: '$/1K out', num: 1, f: r => r.cost_per_1k_output ? '$' + r.cost_per_1k_output.toFixed(3) : '—'},
|
|
992
1034
|
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.cost)},
|
|
@@ -1031,16 +1073,18 @@ VIEWS.projects = async (page) => {
|
|
|
1031
1073
|
/* ---------- sessions ---------- */
|
|
1032
1074
|
VIEWS.sessions = async (page) => {
|
|
1033
1075
|
const order = S.sessOrder || 'cost';
|
|
1034
|
-
const
|
|
1076
|
+
const sp = S.sessPage;
|
|
1077
|
+
const res = await api('sessions', `&limit=${sp.offset + sp.limit}&order=${order}`);
|
|
1078
|
+
const rows = res.rows, total = res.total;
|
|
1035
1079
|
const eff = await api('efficiency');
|
|
1036
1080
|
const avgTok = rows.length ? rows.reduce((a, r) => a + r.tokens, 0) / rows.length : 0;
|
|
1037
1081
|
page.innerHTML = `
|
|
1038
1082
|
<div class="grid g4">
|
|
1039
|
-
${kpi('Sessions in range', fmtInt(
|
|
1040
|
-
${kpi('Avg cost / session', fmtUSD(rows.reduce((a, r) => a + r.cost, 0) / (rows.length || 1)),
|
|
1083
|
+
${kpi('Sessions in range', fmtInt(total), null, {badge: BADGE.actual})}
|
|
1084
|
+
${kpi('Avg cost / loaded session', fmtUSD(rows.reduce((a, r) => a + r.cost, 0) / (rows.length || 1)),
|
|
1041
1085
|
null, {badge: BADGE.estimated})}
|
|
1042
|
-
${kpi('Avg tokens / session', fmtNum(avgTok))}
|
|
1043
|
-
${kpi('Avg tokens / prompt', fmtNum(rows.reduce((a, r) => a + (r.tokens_per_prompt || 0), 0)
|
|
1086
|
+
${kpi('Avg tokens / loaded session', fmtNum(avgTok))}
|
|
1087
|
+
${kpi('Avg tokens / loaded prompt', fmtNum(rows.reduce((a, r) => a + (r.tokens_per_prompt || 0), 0)
|
|
1044
1088
|
/ (rows.filter(r => r.tokens_per_prompt).length || 1)))}
|
|
1045
1089
|
</div>
|
|
1046
1090
|
${card('Session explorer', `<div class="filters" style="margin:0 0 8px">
|
|
@@ -1049,7 +1093,12 @@ VIEWS.sessions = async (page) => {
|
|
|
1049
1093
|
`<button class="chip ${order === k ? 'on' : ''}" data-so="${k}">${l}</button>`).join('')}
|
|
1050
1094
|
<span class="spacer"></span>
|
|
1051
1095
|
<span class="note">rows above ${fmtNum(avgTok * 3)} tokens are unusually expensive</span>
|
|
1052
|
-
</div><div id="st"></div
|
|
1096
|
+
</div><div id="st"></div>
|
|
1097
|
+
<div class="filters" style="margin-top:8px">
|
|
1098
|
+
<span class="note">Showing ${fmtInt(Math.min(rows.length, total))} of ${fmtInt(total)}</span>
|
|
1099
|
+
<span class="spacer"></span>
|
|
1100
|
+
${rows.length < total ? '<button class="btn pb-copy" id="sess-more">Load more</button>' : ''}
|
|
1101
|
+
</div>`, {badge: BADGE.estimated, hint: 'click a row to open the session'})}
|
|
1053
1102
|
<div class="grid g2">
|
|
1054
1103
|
${card('Lowest output yield', table([
|
|
1055
1104
|
{h: 'Session', trunc: 1, f: r => esc(r.title || shortId(r.session_id))},
|
|
@@ -1065,7 +1114,9 @@ VIEWS.sessions = async (page) => {
|
|
|
1065
1114
|
], eff.high_efficiency_sessions, {onRow: 1}), {badge: BADGE.estimated, hint: 'high efficiency'})}
|
|
1066
1115
|
</div>`;
|
|
1067
1116
|
page.querySelectorAll('[data-so]').forEach(b => b.onclick = () => {
|
|
1068
|
-
S.sessOrder = b.dataset.so; bust(); render(); });
|
|
1117
|
+
S.sessOrder = b.dataset.so; S.sessPage.offset = 0; bust(); render(); });
|
|
1118
|
+
const sessMore = $('#sess-more', page);
|
|
1119
|
+
if (sessMore) sessMore.onclick = () => { sp.offset += sp.limit; bust(); render(); };
|
|
1069
1120
|
$('#st', page).innerHTML = table([
|
|
1070
1121
|
{h: 'Session', trunc: 1, title: r => r.session_id,
|
|
1071
1122
|
f: r => `${r.tokens > avgTok * 3 ? '🔴 ' : ''}${esc(r.title || shortId(r.session_id))}
|
|
@@ -1107,8 +1158,10 @@ VIEWS.sessions = async (page) => {
|
|
|
1107
1158
|
VIEWS.prompts = async (page) => {
|
|
1108
1159
|
const order = S.promptOrder || 'cost';
|
|
1109
1160
|
const q = S.promptQ || '';
|
|
1110
|
-
const
|
|
1111
|
-
|
|
1161
|
+
const pp = S.promptPage;
|
|
1162
|
+
const res = await api('prompts',
|
|
1163
|
+
`&limit=${pp.offset + pp.limit}&order=${order}${q ? '&q=' + encodeURIComponent(q) : ''}`);
|
|
1164
|
+
const rows = res.rows, total = res.total;
|
|
1112
1165
|
page.innerHTML = `
|
|
1113
1166
|
${card('Prompt explorer', `
|
|
1114
1167
|
<div class="filters" style="margin:0 0 8px">
|
|
@@ -1119,16 +1172,19 @@ VIEWS.prompts = async (page) => {
|
|
|
1119
1172
|
['efficiency', 'Most efficient'], ['cheapest', 'Cheapest'], ['recent', 'Most recent']]
|
|
1120
1173
|
.map(([k, l]) => `<button class="chip ${order === k ? 'on' : ''}" data-po="${k}">${l}</button>`).join('')}
|
|
1121
1174
|
<span class="spacer"></span>
|
|
1122
|
-
<span class="note"
|
|
1175
|
+
<span class="note">Showing ${fmtInt(Math.min(rows.length, total))} of ${fmtInt(total)} prompts · global filters apply</span>
|
|
1176
|
+
${rows.length < total ? '<button class="btn pb-copy" id="prompt-more">Load more</button>' : ''}
|
|
1123
1177
|
</div><div id="pt"></div>`,
|
|
1124
1178
|
{badge: BADGE.estimated, hint: 'click any row for the full prompt, usage and advice',
|
|
1125
1179
|
footer: 'Prompt text is read from your local transcripts and never leaves this machine.'})}`;
|
|
1126
1180
|
const inp = $('#pq', page);
|
|
1127
1181
|
let t; inp.oninput = e => { clearTimeout(t); const v = e.target.value;
|
|
1128
|
-
t = setTimeout(() => { S.promptQ = v; bust(); render().then(() => {
|
|
1182
|
+
t = setTimeout(() => { S.promptQ = v; S.promptPage.offset = 0; bust(); render().then(() => {
|
|
1129
1183
|
const i = $('#pq'); if (i) { i.focus(); i.setSelectionRange(v.length, v.length); } }); }, 300); };
|
|
1130
1184
|
page.querySelectorAll('[data-po]').forEach(b => b.onclick = () => {
|
|
1131
|
-
S.promptOrder = b.dataset.po; bust(); render(); });
|
|
1185
|
+
S.promptOrder = b.dataset.po; S.promptPage.offset = 0; bust(); render(); });
|
|
1186
|
+
const promptMore = $('#prompt-more', page);
|
|
1187
|
+
if (promptMore) promptMore.onclick = () => { pp.offset += pp.limit; bust(); render(); };
|
|
1132
1188
|
$('#pt', page).innerHTML = table([
|
|
1133
1189
|
{h: 'When', f: r => `<span class="mono">${esc((r.ts || '').slice(0, 16).replace('T', ' '))}</span>`},
|
|
1134
1190
|
{h: 'Prompt', trunc: 1, title: r => r.preview, f: r => esc(r.preview)},
|
|
@@ -1249,10 +1305,108 @@ VIEWS.categories = async (page) => {
|
|
|
1249
1305
|
};
|
|
1250
1306
|
|
|
1251
1307
|
/* ---------- context & cache ---------- */
|
|
1308
|
+
/* ---------- context & session hygiene: observed shares, no savings ---------- */
|
|
1309
|
+
VIEWS.hygiene = async (page) => {
|
|
1310
|
+
const [hy, fit] = await Promise.all([api('hygiene'), api('context_window_fit')]);
|
|
1311
|
+
const T = hy.thresholds.map(String), kT = String(hy.rank_threshold);
|
|
1312
|
+
const K = t => `${Math.round(+t / 1000)}K`;
|
|
1313
|
+
const ab = t => hy.above[t];
|
|
1314
|
+
page.innerHTML = `
|
|
1315
|
+
<div class="note" style="margin:0 0 10px">Everything on this page is observed from your transcripts.
|
|
1316
|
+
It shows where spend sat while a large prefix was being re-sent on every turn. It does
|
|
1317
|
+
<b>not</b> estimate what /compact or a fresh session would have saved — that depends on
|
|
1318
|
+
what the work still needed, which the transcript does not say. Auto-compaction is not
|
|
1319
|
+
recorded; it is detected as the context dropping by more than half. A typed /compact is
|
|
1320
|
+
recorded and also counts.</div>
|
|
1321
|
+
<div class="grid g4">
|
|
1322
|
+
${T.map(t => kpi(`Spend in requests ≥ ${K(t)} context`, fmtPct(ab(t).share_pct),
|
|
1323
|
+
`${fmtUSD(ab(t).cost_usd)} · ${fmtInt(ab(t).requests)} requests`, {badge: BADGE.actual})).join('')}
|
|
1324
|
+
${T.map(t => kpi(`Spend after a session first crossed ${K(t)}`, fmtPct(ab(t).share_after_first_cross_pct),
|
|
1325
|
+
`${fmtInt(ab(t).sessions)} of ${fmtInt(hy.sessions)} sessions crossed it`, {badge: BADGE.actual})).join('')}
|
|
1326
|
+
</div>
|
|
1327
|
+
<div class="grid g3">
|
|
1328
|
+
${kpi('Spend near or over the context window', fmtPct(fit.near_or_over_cost_pct),
|
|
1329
|
+
`≥ ${fit.threshold_pct}% of the window in use · ${fmtInt(fit.near_requests + fit.over_requests)} requests`,
|
|
1330
|
+
{badge: BADGE.actual})}
|
|
1331
|
+
${kpi('Sessions in range', fmtInt(hy.sessions), `${fmtInt(hy.requests)} main-thread requests`, {badge: BADGE.actual})}
|
|
1332
|
+
${kpi('Spend in range', fmtUSD(hy.cost_usd), 'subagent turns excluded (own prefix)', {badge: BADGE.estimated})}
|
|
1333
|
+
</div>
|
|
1334
|
+
${card('Context per request, most expensive session after ' + K(kT), '<div class="chart" id="hy-traj"></div>',
|
|
1335
|
+
{badge: BADGE.actual, hint: 'prompt-side tokens on each request, in order · click a row below to change session'})}
|
|
1336
|
+
${card('Sessions ranked by spend after crossing ' + K(kT), '<div id="hy-sess"></div>',
|
|
1337
|
+
{badge: BADGE.actual, flush: 1,
|
|
1338
|
+
footer: hy.note + ' Subagent turns are excluded because they run against their own prefix.'})}`;
|
|
1339
|
+
|
|
1340
|
+
const drawTraj = s => {
|
|
1341
|
+
const rows = s.context_trajectory.map((c, i) => ({i: i + 1, ctx: c, cum: s.cumulative_cost[i]}));
|
|
1342
|
+
C.timeSeries($('#hy-traj', page), {rows, x: 'i', type: 'area', height: 210, fmt: fmtNum,
|
|
1343
|
+
series: [{key: 'ctx', label: 'Context tokens', color: seriesVar(0)}],
|
|
1344
|
+
xLabel: v => `#${v}`});
|
|
1345
|
+
$('#hy-traj', page).insertAdjacentHTML('beforeend',
|
|
1346
|
+
`<div class="note" style="margin-top:6px">${esc(s.title || shortId(s.session_id))} — ${fmtInt(s.requests)} requests,
|
|
1347
|
+
${fmtUSD(s.cost_usd)}. Crossed ${K(kT)} at request #${s.ever_crossed[kT] ? s.first_cross_idx[kT] + 1 : '—'};
|
|
1348
|
+
${fmtPct(s.cost_after_pct[kT])} of its spend came after that.</div>`);
|
|
1349
|
+
};
|
|
1350
|
+
const rows = hy.sessions_ranked;
|
|
1351
|
+
$('#hy-sess', page).innerHTML = table([
|
|
1352
|
+
{h: 'Session', trunc: 1, f: r => esc(r.title || shortId(r.session_id))},
|
|
1353
|
+
{h: 'Project', trunc: 1, f: r => esc(r.project || '')},
|
|
1354
|
+
{h: 'Context', f: r => `<span class="spk" data-i="${rows.indexOf(r)}" style="display:inline-block;width:110px"></span>`},
|
|
1355
|
+
{h: 'Requests', num: 1, f: r => fmtInt(r.requests)},
|
|
1356
|
+
{h: 'Peak ctx', num: 1, f: r => fmtNum(r.max_context)},
|
|
1357
|
+
...T.map(t => ({h: `After ${K(t)}`, num: 1, f: r => !r.ever_crossed[t] ? '<span class="na">never</span>'
|
|
1358
|
+
: `<b>${fmtUSD(r.cost_after[t])}</b> <span class="note">(${fmtPct(r.cost_after_pct[t])})</span>`})),
|
|
1359
|
+
{h: 'Session cost', num: 1, f: r => fmtUSD(r.cost_usd)},
|
|
1360
|
+
], rows);
|
|
1361
|
+
page.querySelectorAll('#hy-sess .spk').forEach(el => C.spark(el, rows[+el.dataset.i].context_trajectory, seriesVar(0), 22));
|
|
1362
|
+
page.querySelectorAll('#hy-sess table.tbl tbody tr').forEach(tr => {
|
|
1363
|
+
tr.classList.add('clickable');
|
|
1364
|
+
tr.onclick = e => { if (e.altKey) openSession(rows[+tr.dataset.i].session_id); else drawTraj(rows[+tr.dataset.i]); };
|
|
1365
|
+
});
|
|
1366
|
+
if (rows.length) drawTraj(rows[0]);
|
|
1367
|
+
};
|
|
1368
|
+
|
|
1252
1369
|
VIEWS.context = async (page) => {
|
|
1253
|
-
const [ctx, eff] = await Promise.all([api('context'), api('efficiency')]);
|
|
1370
|
+
const [ctx, eff, ttl] = await Promise.all([api('context'), api('efficiency'), api('ttl_replay')]);
|
|
1254
1371
|
const ca = eff.cache;
|
|
1372
|
+
const ttlCard = () => {
|
|
1373
|
+
if (!ttl || !ttl.segments) return '';
|
|
1374
|
+
if (!ttl.reconciled) {
|
|
1375
|
+
return card('Cache TTL comparison', `
|
|
1376
|
+
<p class="note" style="margin:0">Not shown. Replaying your actual 1h TTL produced
|
|
1377
|
+
${fmtUSD(ttl.replay_1h_usd)} against ${fmtUSD(ttl.logged_cost_usd)} of logged cost
|
|
1378
|
+
(${ttl.reconciliation_drift_pct}% drift). A replay that cannot reproduce the bill you
|
|
1379
|
+
did get is not evidence about one you did not, so the comparison is withheld.</p>`,
|
|
1380
|
+
{badge: BADGE.actual, hint: 'arithmetic · failed its own reconciliation check'});
|
|
1381
|
+
}
|
|
1382
|
+
const cheaper5 = ttl.cheaper_ttl === '5m';
|
|
1383
|
+
return card('Cache TTL: what you use vs the alternative', `
|
|
1384
|
+
<p class="note" style="margin:0 0 10px">Replayed over <strong>${fmtInt(ttl.segments)}</strong>
|
|
1385
|
+
cache segments using your real inter-turn gaps. No assumption is made about model
|
|
1386
|
+
behaviour — this is arithmetic on timestamps and token counts.</p>
|
|
1387
|
+
<div class="grid g3" style="gap:8px">
|
|
1388
|
+
${kpi('Your 1h TTL', fmtUSD(ttl.replay_1h_usd), 'replayed', {small: 1, badge: BADGE.actual})}
|
|
1389
|
+
${kpi('Same work on a 5m TTL', fmtUSD(ttl.replay_5m_usd), 'counterfactual', {small: 1})}
|
|
1390
|
+
${kpi(cheaper5 ? 'A 5m TTL would save' : 'Your 1h TTL saves',
|
|
1391
|
+
fmtUSD(Math.abs(ttl.difference_usd)), cheaper5 ? 'switch to save' : 'already the cheaper choice',
|
|
1392
|
+
{small: 1, badge: BADGE.actual})}
|
|
1393
|
+
</div>
|
|
1394
|
+
<div class="stack" style="margin-top:10px">
|
|
1395
|
+
<div class="item sev-${cheaper5 ? 'medium' : 'low'}">
|
|
1396
|
+
<div class="hd">${cheaper5
|
|
1397
|
+
? 'A shorter TTL would be cheaper for how you actually work.'
|
|
1398
|
+
: 'Keep the 1h TTL — a 5m TTL would cost you more, not less.'}</div>
|
|
1399
|
+
<div class="dt">${cheaper5
|
|
1400
|
+
? 'Your turns come close enough together that the prefix rarely expires, so you are paying the 2x write premium for protection you do not use.'
|
|
1401
|
+
: 'The gaps where a 5m prefix would expire force a full-prefix rewrite, and that costs more than the cheaper write rate saves.'}
|
|
1402
|
+
This only matters if the TTL is configurable in your setup.</div></div>
|
|
1403
|
+
</div>`,
|
|
1404
|
+
{badge: BADGE.actual,
|
|
1405
|
+
hint: 'arithmetic · reconciled to ' + ttl.reconciliation_drift_pct + '% of logged cost',
|
|
1406
|
+
footer: ttl.note});
|
|
1407
|
+
};
|
|
1255
1408
|
page.innerHTML = `
|
|
1409
|
+
${ttlCard()}
|
|
1256
1410
|
<div class="grid g5">
|
|
1257
1411
|
${kpi('Avg context / request', fmtNum(ctx.avg_context), null, {badge: BADGE.actual})}
|
|
1258
1412
|
${kpi('Max context seen', fmtNum(ctx.max_context),
|
|
@@ -1262,8 +1416,36 @@ VIEWS.context = async (page) => {
|
|
|
1262
1416
|
'average vs largest configured window')}
|
|
1263
1417
|
${kpi('Tokens / request', fmtNum(eff.tokens_per_request))}
|
|
1264
1418
|
${kpi('Cache hit ratio', ca.reads ? fmtPct(eff.cache_hit_ratio * 100) : null,
|
|
1265
|
-
'reads ÷ (reads + writes)', {badge: BADGE.actual})}
|
|
1419
|
+
'reads ÷ (reads + writes), by token', {badge: BADGE.actual})}
|
|
1266
1420
|
</div>
|
|
1421
|
+
${(() => {
|
|
1422
|
+
const cs = ca.cost_split;
|
|
1423
|
+
if (!cs || !cs.read_cost_share) return '';
|
|
1424
|
+
const mult = cs.write_vs_read_multiple, h1 = cs.write_1h_token_share;
|
|
1425
|
+
return card('Cache reads vs writes, by cost', `
|
|
1426
|
+
<p class="note" style="margin:0 0 10px">Writes are
|
|
1427
|
+
<strong>${fmtPct(100 * (1 - cs.read_token_share))}</strong> of your cache tokens but
|
|
1428
|
+
<strong>${fmtPct(100 * cs.write_cost_share)}</strong> of your cache cost${
|
|
1429
|
+
mult ? `, because a write token costs ${mult.toFixed(1)}x a read token` : ''}.
|
|
1430
|
+
The token ratio above is the flattering number; this is the one that moves the bill.</p>
|
|
1431
|
+
<dl class="kv">
|
|
1432
|
+
<dt>Cache reads</dt><dd>${fmtNum(cs.read_tokens)} tokens · ${fmtUSD(cs.read_cost_usd)}
|
|
1433
|
+
(${fmtPct(100 * cs.read_cost_share)} of cache cost)</dd>
|
|
1434
|
+
<dt>Cache writes</dt><dd>${fmtNum(cs.write_tokens)} tokens · ${fmtUSD(cs.write_cost_usd)}
|
|
1435
|
+
(${fmtPct(100 * cs.write_cost_share)} of cache cost)</dd>
|
|
1436
|
+
<dt>— 5m writes</dt><dd>${fmtNum(cs.write_5m_tokens)} tokens · ${fmtUSD(cs.write_5m_cost_usd)}</dd>
|
|
1437
|
+
<dt>— 1h writes</dt><dd>${fmtNum(cs.write_1h_tokens)} tokens · ${fmtUSD(cs.write_1h_cost_usd)}</dd>
|
|
1438
|
+
</dl>
|
|
1439
|
+
${h1 !== null && h1 >= 0.5 && cs.write_tokens ? `<div class="stack" style="margin-top:10px">
|
|
1440
|
+
<div class="item sev-low">
|
|
1441
|
+
<div class="hd">${fmtPct(100 * h1)} of your cache writes use the 1h TTL.</div>
|
|
1442
|
+
<div class="dt">A 1h write costs more per token than a 5m one, but that does not make
|
|
1443
|
+
it the wrong choice — whether it pays off depends on your real inter-turn gaps.
|
|
1444
|
+
The TTL comparison above replays them and answers it with arithmetic rather than
|
|
1445
|
+
a rule of thumb.</div></div>
|
|
1446
|
+
</div>` : ''}`, {badge: BADGE.estimated,
|
|
1447
|
+
footer: 'Component costs are re-derived per model from config/pricing.json, since a request stores one blended cost.'});
|
|
1448
|
+
})()}
|
|
1267
1449
|
${ctx.large_context_cost_pct > 0 ? `<div class="hero">
|
|
1268
1450
|
<h3>⚠️ Context warnings</h3>
|
|
1269
1451
|
<div class="stack">
|
|
@@ -1283,8 +1465,8 @@ VIEWS.context = async (page) => {
|
|
|
1283
1465
|
<div class="grid g3" style="gap:8px">
|
|
1284
1466
|
${kpi('Est. cost with caching', fmtUSD(ca.cost_with_cache), null, {small: 1, badge: BADGE.estimated})}
|
|
1285
1467
|
${kpi('Est. cost without caching', fmtUSD(ca.cost_without_cache), 'same tokens at input rates', {small: 1})}
|
|
1286
|
-
${kpi('
|
|
1287
|
-
{small: 1, badge: BADGE.estimated})}
|
|
1468
|
+
${kpi('Uncached counterfactual', fmtUSD(ca.uncached_counterfactual_delta_usd),
|
|
1469
|
+
`${fmtPct(ca.uncached_counterfactual_pct)} · not a saving`, {small: 1, badge: BADGE.estimated})}
|
|
1288
1470
|
</div>
|
|
1289
1471
|
<div class="chart" id="cachebar" style="margin-top:10px"></div>
|
|
1290
1472
|
<dl class="kv" style="margin-top:10px">
|
|
@@ -1322,224 +1504,6 @@ VIEWS.context = async (page) => {
|
|
|
1322
1504
|
wireTable($('#hs', page), ctx.heavy_sessions, r => openSession(r.session_id));
|
|
1323
1505
|
};
|
|
1324
1506
|
|
|
1325
|
-
/* ---------- model switch ---------- */
|
|
1326
|
-
// Verdicts from the back-test. Wording matters here: "supported" means your own
|
|
1327
|
-
// history backs the switch, not that we modelled it.
|
|
1328
|
-
const VERDICT = {
|
|
1329
|
-
supported: ['🟢', 'Backed by your data', 'healthy'],
|
|
1330
|
-
caution: ['🟠', 'Trial first', 'approaching'],
|
|
1331
|
-
risky: ['🔴', 'Cost more work', 'critical'],
|
|
1332
|
-
marginal: ['⚪', 'Too close to call', 'high'],
|
|
1333
|
-
};
|
|
1334
|
-
|
|
1335
|
-
function evidenceBody(ev) {
|
|
1336
|
-
if (!ev || !ev.categories?.length) {
|
|
1337
|
-
return `<div class="empty">No category yet has ${ev?.min_prompts || 8}+ prompts on two
|
|
1338
|
-
different models of the same agent, so there is nothing to compare. Run a cheaper model on
|
|
1339
|
-
a handful of real tasks and this fills in.</div>`;
|
|
1340
|
-
}
|
|
1341
|
-
const rows = [];
|
|
1342
|
-
ev.categories.forEach(c => c.candidates.forEach((x, i) => rows.push({c, x, first: i === 0})));
|
|
1343
|
-
return table([
|
|
1344
|
-
{h: 'Work', f: r => r.first ? `<b>${esc(r.c.category.replace('_', ' '))}</b>` : ''},
|
|
1345
|
-
{h: 'You use now', f: r => r.first
|
|
1346
|
-
? `${esc(r.c.current.name)} <span class="note">${fmtUSD(r.c.current.cost_per_prompt)}/prompt ·
|
|
1347
|
-
${Math.round(r.c.current.turns)} turns</span>` : ''},
|
|
1348
|
-
{h: 'Instead of', f: r => `<b>${esc(r.x.name)}</b>`},
|
|
1349
|
-
{h: '$ / prompt', num: 1, f: r => fmtUSD(r.x.cost_per_prompt)},
|
|
1350
|
-
{h: 'Turns', num: 1, f: r => `${Math.round(r.x.turns)} <span class="note">(${r.x.turn_ratio}×)</span>`},
|
|
1351
|
-
{h: 'Re-asked', num: 1, f: r => `${fmtPct(r.x.repeat_pct)}<span class="note">${
|
|
1352
|
-
r.x.repeat_delta > 0 ? ' +' + r.x.repeat_delta : ''}</span>`},
|
|
1353
|
-
{h: 'On', num: 1, f: r => `${fmtInt(r.x.prompts)} prompts`},
|
|
1354
|
-
{h: 'Verdict', f: r => `<span title="${esc(r.x.why)}">${
|
|
1355
|
-
statusChip(VERDICT[r.x.verdict][2], VERDICT[r.x.verdict][1])}</span>`},
|
|
1356
|
-
{h: 'Would save', num: 1, f: r => r.x.verdict === 'supported'
|
|
1357
|
-
? `<b>${fmtUSD(r.x.estimated_savings_usd)}</b>` : `<span class="note">${fmtUSD(r.x.estimated_savings_usd)}</span>`},
|
|
1358
|
-
{h: '', f: r => `<button class="act ghost trial-btn" data-cat="${esc(r.c.category)}"
|
|
1359
|
-
data-model="${esc(r.x.model)}" data-name="${esc(r.x.name)}">Try it →</button>`},
|
|
1360
|
-
], rows) + `<div class="note" style="padding:10px 14px">${esc(ev.method)}</div>`;
|
|
1361
|
-
}
|
|
1362
|
-
|
|
1363
|
-
VIEWS.modelswitch = async (page) => {
|
|
1364
|
-
const [m, ev] = await Promise.all([
|
|
1365
|
-
api('model_switch'),
|
|
1366
|
-
api('model_evidence').catch(() => null),
|
|
1367
|
-
]);
|
|
1368
|
-
const sc = m.savings_by_confidence || {};
|
|
1369
|
-
const conf = c => `<span class="badge rec">${esc(c)} confidence</span>`;
|
|
1370
|
-
page.innerHTML = `
|
|
1371
|
-
<div class="grid g4">
|
|
1372
|
-
${kpi('Backed by your own runs', fmtUSD(ev?.estimated_savings_usd || 0),
|
|
1373
|
-
`${ev?.categories?.filter(c => c.recommended).length || 0} categories where a cheaper model
|
|
1374
|
-
already did the same work for less`, {badge: BADGE.actual})}
|
|
1375
|
-
${kpi('Potential savings', fmtUSD(m.estimated_savings_usd),
|
|
1376
|
-
`${fmtPct(m.total_cost_usd ? 100 * m.estimated_savings_usd / m.total_cost_usd : 0)} of ${fmtUSD(m.total_cost_usd)} in range`,
|
|
1377
|
-
{badge: BADGE.recommendation})}
|
|
1378
|
-
${kpi('Safe to switch', fmtUSD(m.safe_savings_usd), 'High + medium confidence only', {badge: BADGE.recommendation})}
|
|
1379
|
-
${kpi('Try on a sample first', fmtUSD(sc.low || 0), 'Low confidence: coding and refactoring', {badge: BADGE.recommendation})}
|
|
1380
|
-
${kpi('Stays on current model', fmtUSD(m.blocked_by_context_usd),
|
|
1381
|
-
`${fmtInt(m.blocked_by_context_requests)} requests too big for the cheaper model's context`, {badge: BADGE.estimated})}
|
|
1382
|
-
</div>
|
|
1383
|
-
${card('What actually happened when you used a cheaper model',
|
|
1384
|
-
`<div id="ms-ev">${evidenceBody(ev)}</div>`, {badge: BADGE.actual, flush: 1,
|
|
1385
|
-
hint: 'Measured from your own prompts — no repricing, no assumptions about tokens',
|
|
1386
|
-
footer: ev ? esc(ev.caveat) : ''})}
|
|
1387
|
-
<div class="note">The table above is history; the one below is a model. Where they disagree,
|
|
1388
|
-
believe the history: repricing assumes the cheaper model would finish in the same number of
|
|
1389
|
-
turns, and your data shows that is often where the saving goes.</div>
|
|
1390
|
-
${card('Switch these (repriced, not measured)', `<div id="ms-sw"></div>`,
|
|
1391
|
-
{badge: BADGE.recommendation, flush: 1,
|
|
1392
|
-
hint: 'Each request repriced on the model its work needs, same tokens',
|
|
1393
|
-
footer: esc(m.caveat)})}
|
|
1394
|
-
${card('Default model per project', `<div id="ms-pj"></div>`, {badge: BADGE.recommendation, flush: 1,
|
|
1395
|
-
hint: 'Based on how much frontier-model spend is reasoning-heavy work'})}
|
|
1396
|
-
${card('How to switch', `<div class="stack">
|
|
1397
|
-
${(m.providers?.length ? m.providers : ['anthropic']).map(pv => { const h = (m.how_by_agent || {})[pv] || m.how; return `
|
|
1398
|
-
<div class="dt"><b>${esc(h.agent || 'Claude Code')}</b></div>
|
|
1399
|
-
<div class="dt"><b>This session:</b> <code>${esc(h.session)}</code></div>
|
|
1400
|
-
<div class="dt"><b>Whole project:</b> <code>${esc(h.project)}</code></div>
|
|
1401
|
-
${pv === 'anthropic' ? `<div class="dt"><b>Subagents:</b> <code>${esc(m.how.subagent)}</code></div>` : ''}`; }).join('')}
|
|
1402
|
-
<div class="dt note">Kept on the top model: ${esc(Object.entries(m.rules).filter(([, v]) => v === 'keep').map(([k]) => k.replace('_', ' ')).join(', '))}.</div>
|
|
1403
|
-
</div>`)}`;
|
|
1404
|
-
$('#ms-sw', page).innerHTML = table([
|
|
1405
|
-
{h: 'Work', f: r => esc(r.scope)},
|
|
1406
|
-
{h: 'Now', f: r => esc(r.current_name)},
|
|
1407
|
-
{h: 'Switch to', f: r => `<b>${esc(r.recommended_name)}</b>`},
|
|
1408
|
-
{h: 'Confidence', f: r => conf(r.confidence)},
|
|
1409
|
-
{h: 'Requests', num: 1, f: r => fmtInt(r.requests)},
|
|
1410
|
-
{h: 'Est. cost now', num: 1, f: r => fmtUSD(r.cost)},
|
|
1411
|
-
{h: 'After switch', num: 1, f: r => fmtUSD(r.alt)},
|
|
1412
|
-
{h: 'Saves', num: 1, f: r => `<b>${fmtUSD(r.estimated_savings_usd)}</b> (${fmtPct(r.estimated_savings_pct)})`},
|
|
1413
|
-
], m.switches);
|
|
1414
|
-
$('#ms-pj', page).innerHTML = table([
|
|
1415
|
-
{h: 'Project', trunc: 1, f: r => esc(r.project)},
|
|
1416
|
-
{h: 'Frontier spend', num: 1, f: r => fmtUSD(r.frontier_cost)},
|
|
1417
|
-
{h: 'Reasoning-heavy', num: 1, f: r => fmtPct(r.keep_pct)},
|
|
1418
|
-
{h: 'Suggested default', f: r => `<b>${esc(r.suggested_default)}</b>`},
|
|
1419
|
-
{h: 'Could save', num: 1, f: r => fmtUSD(r.estimated_savings_usd)},
|
|
1420
|
-
{h: 'Why', f: r => esc(r.why)},
|
|
1421
|
-
], m.projects);
|
|
1422
|
-
wireTrials(page);
|
|
1423
|
-
addChart(page, 'Savings by switch', el => C.barsH(el, {
|
|
1424
|
-
rows: m.switches.slice(0, 10), label: r => clip(`${r.scope} → ${r.recommended_name}`, 48),
|
|
1425
|
-
value: r => r.estimated_savings_usd,
|
|
1426
|
-
color: r => r.confidence === 'high' ? seriesVar(2) : r.confidence === 'medium' ? seriesVar(0) : 'var(--warning)',
|
|
1427
|
-
sub: r => `<div class="row"><span class="k">Now</span><span class="v">${fmtUSD(r.cost)}</span></div>
|
|
1428
|
-
<div class="row"><span class="k">After</span><span class="v">${fmtUSD(r.alt)}</span></div>
|
|
1429
|
-
<div class="row"><span class="k">Confidence</span><span class="v">${esc(r.confidence)}</span></div>`}),
|
|
1430
|
-
{badge: BADGE.recommendation, hint: 'Colour = confidence (green high, blue medium, amber low)'});
|
|
1431
|
-
};
|
|
1432
|
-
|
|
1433
|
-
/* ---------- trial: stop recommending, start measuring ----------
|
|
1434
|
-
The evidence ends at "strong evidence for a trial, not proof". This runs the
|
|
1435
|
-
trial: real prompts out of your own history, re-run headlessly on the
|
|
1436
|
-
candidate model, priced against what they cost the first time. It spends real
|
|
1437
|
-
money, so nothing happens without two clicks. */
|
|
1438
|
-
function trialPanelHTML(cat, model, name, s) {
|
|
1439
|
-
if (!s.available) {
|
|
1440
|
-
return `<div class="empty">The <code>claude</code> CLI is not on PATH, so a trial cannot be
|
|
1441
|
-
run from here.</div>`;
|
|
1442
|
-
}
|
|
1443
|
-
if (!s.samples.length) {
|
|
1444
|
-
return `<div class="empty">No prompt you actually typed in this category is short enough to
|
|
1445
|
-
re-run safely.</div>`;
|
|
1446
|
-
}
|
|
1447
|
-
const base = s.samples.reduce((a, x) => a + (x.baseline_cost_usd || 0), 0);
|
|
1448
|
-
return `
|
|
1449
|
-
<div class="dt">These are ${s.samples.length} prompts you really sent in
|
|
1450
|
-
<b>${esc(cat.replace('_', ' '))}</b>. Running them again on <b>${esc(name)}</b> costs money —
|
|
1451
|
-
they cost ${fmtUSD(base)} the first time, and the cheaper model should come in under that.</div>
|
|
1452
|
-
<div class="stack trial-samples">${s.samples.map((x, i) => `
|
|
1453
|
-
<div class="dt trial-s" data-i="${i}">
|
|
1454
|
-
<span class="note">${esc(x.day)} · ${esc(x.baseline_name)} · ${fmtUSD(x.baseline_cost_usd)} ·
|
|
1455
|
-
${fmtInt(x.baseline_turns)} turns</span>
|
|
1456
|
-
<div class="trial-text">${esc(x.text.slice(0, 400))}${x.text.length > 400 ? '…' : ''}</div>
|
|
1457
|
-
</div>`).join('')}</div>
|
|
1458
|
-
<div class="dt note">Runs headlessly in a scratch directory. Tools that need permission are
|
|
1459
|
-
denied, because a headless agent cannot ask — so a task that needs your repo will look
|
|
1460
|
-
smaller here than it really is.</div>
|
|
1461
|
-
<div class="live-actions">
|
|
1462
|
-
<button class="act trial-run">▶ Run ${s.samples.length} prompts on ${esc(name)}</button>
|
|
1463
|
-
<button class="act ghost trial-copy">Copy the first prompt instead</button>
|
|
1464
|
-
<span class="trial-msg note"></span>
|
|
1465
|
-
</div>
|
|
1466
|
-
<div class="trial-out"></div>`;
|
|
1467
|
-
}
|
|
1468
|
-
|
|
1469
|
-
const TRIAL_VERDICT = {
|
|
1470
|
-
confirmed: ['healthy', 'Confirmed by running it'],
|
|
1471
|
-
marginal: ['high', 'Smaller than advertised'],
|
|
1472
|
-
contradicted: ['critical', 'History overstated it'],
|
|
1473
|
-
failed: ['critical', 'Runs failed'],
|
|
1474
|
-
unclear: ['high', 'Inconclusive'],
|
|
1475
|
-
};
|
|
1476
|
-
|
|
1477
|
-
function trialResultHTML(r) {
|
|
1478
|
-
const v = TRIAL_VERDICT[r.verdict] || TRIAL_VERDICT.unclear;
|
|
1479
|
-
return `<div class="dt"><b>${statusChip(v[0], v[1])}</b> ${esc(r.why)}</div>` + table([
|
|
1480
|
-
{h: 'Prompt', trunc: 1, f: x => esc(x.prompt)},
|
|
1481
|
-
{h: 'First time', num: 1, f: x => fmtUSD(x.baseline_cost_usd)},
|
|
1482
|
-
{h: 'On ' + esc(r.alias), num: 1, f: x => x.ok ? fmtUSD(x.cost_usd) : '—'},
|
|
1483
|
-
{h: 'Turns', num: 1, f: x => x.ok ? fmtInt(x.turns) : '—'},
|
|
1484
|
-
{h: 'Took', num: 1, f: x => x.ok ? x.elapsed_s + 's' : '—'},
|
|
1485
|
-
{h: 'Denied', num: 1, f: x => x.denials ? fmtInt(x.denials) : ''},
|
|
1486
|
-
{h: 'Result', f: x => x.ok ? `<span class="note">${esc((x.result || '').slice(0, 120))}</span>`
|
|
1487
|
-
: `<span class="status critical">${esc((x.error || 'failed').slice(0, 120))}</span>`},
|
|
1488
|
-
], r.runs) + `<div class="note" style="padding:8px 14px">${esc(r.note)}</div>`;
|
|
1489
|
-
}
|
|
1490
|
-
|
|
1491
|
-
function wireTrials(page) {
|
|
1492
|
-
page.querySelectorAll('.trial-btn').forEach(b => b.onclick = async () => {
|
|
1493
|
-
const row = b.closest('tr');
|
|
1494
|
-
if (row.nextElementSibling?.classList.contains('trial-row')) {
|
|
1495
|
-
row.nextElementSibling.remove(); return;
|
|
1496
|
-
}
|
|
1497
|
-
const {cat, model, name} = b.dataset;
|
|
1498
|
-
const tr = h(`<tr class="trial-row"><td colspan="10"><div class="trial-panel">
|
|
1499
|
-
<div class="empty">Finding prompts you sent…</div></div></td></tr>`);
|
|
1500
|
-
row.after(tr);
|
|
1501
|
-
const host = tr.querySelector('.trial-panel');
|
|
1502
|
-
let s;
|
|
1503
|
-
try {
|
|
1504
|
-
s = await fetch(`/api/trial?category=${encodeURIComponent(cat)}&limit=3`).then(r => r.json());
|
|
1505
|
-
} catch (e) { host.innerHTML = `<div class="empty">${esc(e.message)}</div>`; return; }
|
|
1506
|
-
host.innerHTML = trialPanelHTML(cat, model, name, s);
|
|
1507
|
-
const msg = host.querySelector('.trial-msg');
|
|
1508
|
-
host.querySelector('.trial-copy')?.addEventListener('click', async () => {
|
|
1509
|
-
try { await navigator.clipboard.writeText(s.samples[0].text); msg.textContent = 'Copied.'; }
|
|
1510
|
-
catch { msg.textContent = 'Could not copy.'; }
|
|
1511
|
-
});
|
|
1512
|
-
host.querySelector('.trial-run')?.addEventListener('click', async ev => {
|
|
1513
|
-
const btn = ev.currentTarget;
|
|
1514
|
-
if (btn.dataset.armed !== '1') {
|
|
1515
|
-
btn.dataset.armed = '1';
|
|
1516
|
-
btn.textContent = 'Click again to spend real money';
|
|
1517
|
-
btn.classList.add('warn');
|
|
1518
|
-
return;
|
|
1519
|
-
}
|
|
1520
|
-
btn.disabled = true;
|
|
1521
|
-
btn.textContent = 'Running…';
|
|
1522
|
-
msg.textContent = 'Each prompt runs to completion; this can take a few minutes.';
|
|
1523
|
-
try {
|
|
1524
|
-
const r = await fetch('/api/trial/run', {
|
|
1525
|
-
method: 'POST',
|
|
1526
|
-
headers: {'X-FinOps-Action': '1', 'Content-Type': 'application/json'},
|
|
1527
|
-
body: JSON.stringify({category: cat, model, prompts: s.samples}),
|
|
1528
|
-
}).then(x => x.json());
|
|
1529
|
-
host.querySelector('.trial-out').innerHTML = r.ok
|
|
1530
|
-
? trialResultHTML(r) : `<div class="empty">${esc(r.error || 'failed')}</div>`;
|
|
1531
|
-
msg.textContent = '';
|
|
1532
|
-
} catch (e) {
|
|
1533
|
-
msg.textContent = e.message;
|
|
1534
|
-
}
|
|
1535
|
-
btn.disabled = false;
|
|
1536
|
-
btn.classList.remove('warn');
|
|
1537
|
-
btn.textContent = '▶ Run again';
|
|
1538
|
-
btn.dataset.armed = '';
|
|
1539
|
-
});
|
|
1540
|
-
});
|
|
1541
|
-
}
|
|
1542
|
-
|
|
1543
1507
|
/* ---------- waste ---------- */
|
|
1544
1508
|
VIEWS.waste = async (page) => {
|
|
1545
1509
|
const w = await api('waste');
|
|
@@ -1557,8 +1521,8 @@ VIEWS.waste = async (page) => {
|
|
|
1557
1521
|
</div>
|
|
1558
1522
|
<div class="note"><b>Exposed spend</b> is what the flagged items cost in total — money worth
|
|
1559
1523
|
reviewing, not money wasted. <b>Estimated excess</b> is how much more that work cost than a
|
|
1560
|
-
reasonable baseline, de-duplicated across rules
|
|
1561
|
-
|
|
1524
|
+
reasonable baseline, de-duplicated across rules. Each rule below states the baseline it
|
|
1525
|
+
measures against.</div>
|
|
1562
1526
|
${['high', 'medium', 'low'].map(sev => {
|
|
1563
1527
|
const list = w.findings.filter(f => f.severity === sev);
|
|
1564
1528
|
if (!list.length) return '';
|
|
@@ -1725,6 +1689,8 @@ VIEWS.live = async (page) => {
|
|
|
1725
1689
|
fetch('/api/live?agents=' + encodeURIComponent(S.filter.agents.join(','))).then(r => r.json()), // never cached: always live
|
|
1726
1690
|
fetch('/api/usage').then(r => r.json()).catch(e => ({ok: false, error: e.message}))]);
|
|
1727
1691
|
const list = res.sessions || [];
|
|
1692
|
+
const liveActions = res.actions || ['interrupt', 'close', 'kill'];
|
|
1693
|
+
const canInterrupt = liveActions.includes('interrupt');
|
|
1728
1694
|
const agentName = id => ((S.opts?.agents || []).find(a => a.id === id) || {}).name || id;
|
|
1729
1695
|
const busy = list.filter(x => x.status === 'busy');
|
|
1730
1696
|
const sevOf = x => x.severity === 'high' ? 'high' : x.severity === 'medium' ? 'medium' : 'low';
|
|
@@ -1758,15 +1724,11 @@ VIEWS.live = async (page) => {
|
|
|
1758
1724
|
<div class="dt">${x.status === 'busy' ? '<b>Working now</b>' : 'Idle'}${x.uptime ? ` · up ${esc(x.uptime)}` : ''} ·
|
|
1759
1725
|
last activity ${x.last_write_s == null ? '—' : dur(x.last_write_s)} ago${x.memory_mb == null ? '' : ` · ${fmtInt(x.memory_mb)} MB`} ·
|
|
1760
1726
|
<span class="note">${esc(x.cwd)}</span></div>
|
|
1761
|
-
${x.advice ? `<div class="dt switch-tip"><b>${x.advice.trial ? 'Worth trying' : 'Cheaper model'}:</b>
|
|
1762
|
-
${esc(x.advice.line)}
|
|
1763
|
-
<button class="act ghost" data-copy="${esc(x.advice.command)}"
|
|
1764
|
-
title="${esc(x.advice.why)}">Copy ${esc(x.advice.command)}</button></div>` : ''}
|
|
1765
1727
|
${x.severity !== 'ok' ? `<div class="dt"><b>Advice:</b> ${x.severity === 'high'
|
|
1766
1728
|
? 'Very large context. Use <b>Hand over</b> to continue in a fresh session, or split the remaining work into sub-sessions.'
|
|
1767
1729
|
: 'Getting heavy. Hit <b>Compact</b> at the next break, or close it if the task is done.'}</div>` : ''}
|
|
1768
1730
|
<div class="live-actions">
|
|
1769
|
-
${x.signalable ? `<button class="act" data-a="interrupt" ${x.status !== 'busy' ? 'disabled title="Nothing running"' : ''}>⏸ Interrupt</button
|
|
1731
|
+
${x.signalable ? `${canInterrupt ? `<button class="act" data-a="interrupt" ${x.status !== 'busy' ? 'disabled title="Nothing running"' : ''}>⏸ Interrupt</button>` : ''}
|
|
1770
1732
|
${x.agent === 'claude' ? '<button class="act" data-a="compact" title="Types /compact into that session\'s terminal">🗜 Compact</button>' : ''}
|
|
1771
1733
|
<button class="act warn" data-a="close">⏹ Close session</button>
|
|
1772
1734
|
<button class="act danger" data-a="kill">✖ Force kill</button>` : `<span class="note">${x.agent === 'cursor' ? 'Runs inside the Cursor IDE: stop it there.' : 'No matching process found: stop it in its terminal.'}</span>`}
|
|
@@ -1902,10 +1864,10 @@ VIEWS.diagnose = async (page) => {
|
|
|
1902
1864
|
<div class="hd"><a href="#" class="sess-link" data-sess="${esc(x.session_id)}">${esc(x.title || shortId(x.session_id))}</a>
|
|
1903
1865
|
<span class="note">· ${esc(x.project)}</span><span class="spacer"></span>
|
|
1904
1866
|
<span style="font-variant-numeric:tabular-nums">peak ${fmtNum(x.peak)} · ${fmtUSD(x.cost)} ·
|
|
1905
|
-
~${
|
|
1867
|
+
~${fmtNum(x.tokens_above_100k)} tokens re-read above 100K (not a saving)</span></div>
|
|
1906
1868
|
${x.fixes.map(t => `<div class="dt">• ${esc(t)}</div>`).join('')}${pb(x.playbook)}</div>`).join('')
|
|
1907
1869
|
|| '<div class="empty">No session passed 150K context</div>'}</div>`,
|
|
1908
|
-
{badge: BADGE.recommendation, hint: '"Above baseline" = context re-read beyond 100K
|
|
1870
|
+
{badge: BADGE.recommendation, hint: '"Above baseline" = context re-read beyond 100K'})}
|
|
1909
1871
|
<div id="dx-mem"></div>${card(`Add to ${md}${isCl ? ' / memory' : ''} (from your past prompts)`, `<div class="stack">${d.memory_suggestions.map(x => `
|
|
1910
1872
|
<div class="item sev-${x.kind === 'security' ? 'high' : x.already_saved ? 'low' : 'medium'}">
|
|
1911
1873
|
<div class="hd">${x.kind === 'security' ? '🔐' : x.kind === 'template' ? '⚙' : x.kind === 'reference' ? '🔗' : '📝'}
|
|
@@ -1924,11 +1886,10 @@ VIEWS.diagnose = async (page) => {
|
|
|
1924
1886
|
{badge: BADGE.actual, hint: 'Drivers overlap, so shares do not add up to 100%'})}
|
|
1925
1887
|
<div id="dx-recs"></div>${card('What to change', `<div class="stack">${d.recommendations.map(r => `
|
|
1926
1888
|
<div class="item sev-${r.priority === 1 ? 'high' : r.priority === 2 ? 'medium' : 'low'}">
|
|
1927
|
-
<div class="hd">${esc(r.title)}
|
|
1928
|
-
${r.est_savings_usd ? `<span>~${fmtUSD(r.est_savings_usd)} est.</span>` : ''}</div>
|
|
1889
|
+
<div class="hd">${esc(r.title)}</div>
|
|
1929
1890
|
<div class="dt"><b>Why:</b> ${esc(r.why)}</div>
|
|
1930
1891
|
<div class="dt"><b>How:</b> ${esc(r.how)}</div>
|
|
1931
|
-
${
|
|
1892
|
+
${pb(r.playbook)}</div>`).join('')
|
|
1932
1893
|
|| '<div class="empty">Nothing stands out</div>'}</div>`, {badge: BADGE.recommendation})}
|
|
1933
1894
|
${card(`Projects: ${esc(d.vocab?.name || 'Claude')} config health`, `<div id="dx-proj"></div>`,
|
|
1934
1895
|
{badge: BADGE.recommendation, hint: isCl ? 'CLAUDE.md, memory, .claude/settings.json and MCP checked on disk now' : `${md} checked on disk now`, flush: 1})}
|
|
@@ -2019,8 +1980,6 @@ VIEWS.forecast = async (page) => {
|
|
|
2019
1980
|
return; }
|
|
2020
1981
|
page.innerHTML = `
|
|
2021
1982
|
<div class="grid g4">
|
|
2022
|
-
${kpi('End of day', fmtUSD(f.end_of_day_cost), null, {badge: BADGE.forecast})}
|
|
2023
|
-
${kpi('End of week', fmtUSD(f.end_of_week_cost), null, {badge: BADGE.forecast})}
|
|
2024
1983
|
${kpi('End of billing period', fmtUSD(f.scenarios.expected.end_of_period_cost),
|
|
2025
1984
|
`${f.remaining_days} days remaining`, {badge: BADGE.forecast})}
|
|
2026
1985
|
${kpi('Estimated monthly cost', fmtUSD(f.estimated_monthly_cost), null, {badge: BADGE.forecast})}
|
|
@@ -2034,22 +1993,26 @@ VIEWS.forecast = async (page) => {
|
|
|
2034
1993
|
: kpi('Limit exhaustion date', esc(f.limit_exhaustion_date),
|
|
2035
1994
|
f.will_exceed ? '🔴 forecast exceeds allowance' : '🟢 within allowance', {badge: BADGE.forecast})}
|
|
2036
1995
|
</div>
|
|
2037
|
-
${card('Cumulative spend and forecast fan',
|
|
2038
|
-
|
|
1996
|
+
${card('Cumulative spend and forecast fan', f.insufficient_history
|
|
1997
|
+
? '<div class="empty">Fewer than 7 priced days in the window: bands not shown.</div>'
|
|
1998
|
+
: '<div class="chart" id="fan2"></div><div class="legend" id="fl2"></div>',
|
|
1999
|
+
{badge: BADGE.forecast,
|
|
2039
2000
|
hint: f.method,
|
|
2040
|
-
footer: 'Scenarios are the 14-day mean daily spend minus, at, and plus one standard deviation, projected across the remaining days of the billing period. They assume your recent pattern continues.'})}
|
|
2001
|
+
footer: 'Scenarios are the 14-calendar-day mean daily spend minus, at, and plus one standard deviation, projected across the remaining days of the billing period. Days you did not use Claude count as zero, since the projection runs over calendar days. They assume your recent pattern continues.'})}
|
|
2041
2002
|
${card('Scenarios', table([
|
|
2042
2003
|
{h: 'Scenario', f: r => `<b>${esc(r[0])}</b>`},
|
|
2043
2004
|
{h: 'Daily rate', num: 1, f: r => fmtUSD(r[1].daily_rate)},
|
|
2044
2005
|
{h: 'Projected end of period', num: 1, f: r => fmtUSD(r[1].end_of_period_cost)},
|
|
2045
2006
|
{h: 'vs today', num: 1, f: r => '+' + fmtUSD(r[1].end_of_period_cost - f.period_used)},
|
|
2046
2007
|
], Object.entries(f.scenarios)), {badge: BADGE.forecast})}`;
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2008
|
+
if (!f.insufficient_history) {
|
|
2009
|
+
C.forecastFan($('#fan2', page), {history: burn.series, scenarios: f.scenarios,
|
|
2010
|
+
remainingDays: f.remaining_days, height: 300});
|
|
2011
|
+
$('#fl2', page).innerHTML = `<span class="it"><span class="swatch" style="background:var(--s1)"></span>
|
|
2012
|
+
Cumulative actual (estimated cost)</span>
|
|
2013
|
+
<span class="it"><span class="swatch" style="background:var(--s1);opacity:.35"></span>
|
|
2014
|
+
Forecast band (conservative → high)</span>`;
|
|
2015
|
+
}
|
|
2053
2016
|
};
|
|
2054
2017
|
|
|
2055
2018
|
/* ---------- budgets ---------- */
|
|
@@ -2120,7 +2083,7 @@ VIEWS.budgets = async (page) => {
|
|
|
2120
2083
|
</div>`, {footer: `Plan allowances are NOT available from ${agentWord()} data. Anything you enter here is your own declared figure, used only to compute usage-vs-limit and days-until-limit.`})}`;
|
|
2121
2084
|
$('#savecfg', page).onclick = async () => {
|
|
2122
2085
|
const v = id => { const x = $('#' + id, page).value.trim(); return x === '' ? null : +x; };
|
|
2123
|
-
await fetch('/api/settings', {method: 'POST', headers: {'Content-Type': 'application/json'},
|
|
2086
|
+
await fetch('/api/settings', {method: 'POST', headers: {'Content-Type': 'application/json', 'X-FinOps-Action': '1'},
|
|
2124
2087
|
body: JSON.stringify({
|
|
2125
2088
|
budgets: {monthly_usd: v('b-monthly'), daily_usd: v('b-daily'), monthly_tokens: v('b-tokens')},
|
|
2126
2089
|
limits: {monthly_cost_allowance_usd: v('l-cost'), monthly_token_allowance: v('l-tok'),
|
|
@@ -2144,7 +2107,7 @@ VIEWS.scorecard = async (page) => {
|
|
|
2144
2107
|
${card('AI FinOps Score', `<div class="scorewrap">
|
|
2145
2108
|
<div style="text-align:center">
|
|
2146
2109
|
<div class="scorenum">${sc.score}</div>
|
|
2147
|
-
<div class="scoregrade">out of 100
|
|
2110
|
+
<div class="scoregrade">out of 100</div>
|
|
2148
2111
|
<div class="chart" id="sg" style="width:190px;margin-top:6px"></div></div>
|
|
2149
2112
|
<div style="flex:1;min-width:280px" class="stack">${sc.dimensions.map(d => `
|
|
2150
2113
|
<div><div style="display:flex;justify-content:space-between;font-size:12px">
|
|
@@ -2154,7 +2117,7 @@ VIEWS.scorecard = async (page) => {
|
|
|
2154
2117
|
: d.score >= 30 ? 'approaching' : 'critical'}"><i style="width:${d.score}%"></i></div>
|
|
2155
2118
|
<div class="note">${esc(d.detail)}</div></div>`).join('')}
|
|
2156
2119
|
</div></div>`, {badge: BADGE.estimated,
|
|
2157
|
-
footer: '
|
|
2120
|
+
footer: 'Each dimension is measured from your transcripts; budget adherence needs a budget in Settings.'})}
|
|
2158
2121
|
<div class="grid g3">
|
|
2159
2122
|
${card('✅ What is good', `<ul style="margin:0 0 0 18px;font-size:12.5px">${
|
|
2160
2123
|
sc.what_is_good.map(x => `<li style="margin-bottom:6px">${esc(x)}</li>`).join('')
|
|
@@ -2317,8 +2280,8 @@ VIEWS.exports = async (page) => {
|
|
|
2317
2280
|
<div class="dt">Projected from history. Assumes recent patterns continue; it is not a
|
|
2318
2281
|
commitment or a quote.</div></div>
|
|
2319
2282
|
<div class="item"><div class="hd">${BADGE.recommendation} Recommendation</div>
|
|
2320
|
-
<div class="dt">
|
|
2321
|
-
|
|
2283
|
+
<div class="dt">A recommendation grounded in an observed share of spend. No saving is
|
|
2284
|
+
estimated.</div></div>
|
|
2322
2285
|
</div>
|
|
2323
2286
|
<h3 style="font-size:12.5px;margin:14px 0 6px">Deliberately not fabricated</h3>
|
|
2324
2287
|
<dl class="kv">
|
|
@@ -2341,7 +2304,7 @@ function focusItems(d) {
|
|
|
2341
2304
|
for (const m of d.memory_suggestions || []) if (m.kind === 'security')
|
|
2342
2305
|
out.push({lvl: 1, text: `Credentials pasted in prompts (${m.sessions} sessions): rotate them`, view: 'diagnose', anchor: 'dx-mem'});
|
|
2343
2306
|
for (const r of d.recommendations || []) if (r.priority === 1)
|
|
2344
|
-
out.push({lvl: 1, text: r.title
|
|
2307
|
+
out.push({lvl: 1, text: r.title, view: 'diagnose', anchor: 'dx-recs'});
|
|
2345
2308
|
for (const p of d.projects || []) for (const i of p.issues) if (i.severity === 'high')
|
|
2346
2309
|
out.push({lvl: 1, text: `${p.name}: ${i.title}`, view: 'diagnose', anchor: 'dx-issues'});
|
|
2347
2310
|
const s0 = (d.session_health || [])[0];
|
|
@@ -2491,7 +2454,9 @@ async function render() {
|
|
|
2491
2454
|
applyRange('30d');
|
|
2492
2455
|
// ?view=<name> opens straight to one screen, so a link (or a screenshot run)
|
|
2493
2456
|
// can point at a specific report rather than always landing on the overview.
|
|
2494
|
-
|
|
2457
|
+
let want = new URLSearchParams(location.search).get('view');
|
|
2458
|
+
// The repriced Model switch view was removed; its links now open the measured comparison.
|
|
2459
|
+
if (want === 'modelswitch') want = 'compare';
|
|
2495
2460
|
if (want && NAV.some(([, items]) => items.some(([id]) => id === want))) S.view = want;
|
|
2496
2461
|
await render();
|
|
2497
2462
|
if (!want) maybeFirstTour(); // arriving on a deep link is not a first visit
|
|
@@ -2624,7 +2589,7 @@ VIEWS.freemodels = async (page) => {
|
|
|
2624
2589
|
const d = await fetch('/api/free_models').then(r => r.json());
|
|
2625
2590
|
page.innerHTML = `
|
|
2626
2591
|
<div class="note">${esc(d.how_it_works)} Free models are weaker than Claude: use them for routine
|
|
2627
|
-
work (see <a data-go="
|
|
2592
|
+
work (see <a data-go="compare">Compare models</a> for what your own history shows) and keep Claude for hard problems.</div>
|
|
2628
2593
|
${!d.bin_on_path ? `<div class="item sev-medium"><div class="dt">${esc(d.bin_dir)} is not on your PATH, so the new commands
|
|
2629
2594
|
won't run by name. Add <code>export PATH="$HOME/.local/bin:$PATH"</code> to ~/.zshrc.</div></div>` : ''}
|
|
2630
2595
|
${card('How to use and test a free model', `<div class="stack">
|
|
@@ -2715,6 +2680,7 @@ async function addFreeModel(id) {
|
|
|
2715
2680
|
/* ---------- compare Claude vs free models ---------- */
|
|
2716
2681
|
VIEWS.compare = async (page) => {
|
|
2717
2682
|
const d = await fetch('/api/compare?agents=' + encodeURIComponent(S.filter.agents.join(','))).then(r => r.json());
|
|
2683
|
+
if (d.error) throw new Error(d.error);
|
|
2718
2684
|
const hasFree = d.rows.some(r => r.kind === 'free');
|
|
2719
2685
|
const stars = n => n == null ? '<span class="na">—</span>' : '★'.repeat(Math.floor(n)) + (n % 1 ? '½' : '') +
|
|
2720
2686
|
`<span style="opacity:.25">${'★'.repeat(5 - Math.ceil(n))}</span>`;
|
|
@@ -2727,7 +2693,7 @@ VIEWS.compare = async (page) => {
|
|
|
2727
2693
|
hint: hasFree ? `This machine: ${esc(d.os)}, ${d.ram_gb} GB RAM` : 'List prices, your usage in range', footer: esc(d.note)})}
|
|
2728
2694
|
${!hasFree ? '' : `<div class="grid g3">
|
|
2729
2695
|
${card('Use Claude for', `<div class="dt">Multi-file changes, debugging, anything agentic or long-running.
|
|
2730
|
-
Sonnet is the value pick; keep Opus for the hardest problems (see <a data-go="
|
|
2696
|
+
Sonnet is the value pick; keep Opus for the hardest problems (see <a data-go="compare">Compare models</a>).</div>`)}
|
|
2731
2697
|
${card('Use a free model for', `<div class="dt">Offline or private work, throwaway snippets, explanations,
|
|
2732
2698
|
single-file edits. Cloud Qwen is the strongest free option; locally, pick the biggest one that fits your RAM.</div>`)}
|
|
2733
2699
|
${card('Try it on your own work', `<div class="dt">Give the same small task to a free model (e.g. <code>claude-qwen</code>)
|
|
@@ -2925,13 +2891,13 @@ const TOURS = {
|
|
|
2925
2891
|
{el: 'card:Top 10 most expensive prompts', t: 'Most expensive prompts', see: 'The ten single prompts that cost the most.', get: 'The requests worth rewriting or splitting.', act: 'Click a prompt to see every step it triggered.'},
|
|
2926
2892
|
{el: 'card:Most expensive sessions', t: 'Most expensive sessions', see: 'The longest-running, highest-cost sessions.', get: 'The marathon sessions where context kept being re-read.', act: 'Click one to see where it grew.'},
|
|
2927
2893
|
{el: 'card:Waste detection', t: 'Waste at a glance', see: 'A summary of the patterns that burn tokens for nothing.', get: 'An estimate of what you could avoid.', act: 'Open <b>Waste detection</b> for the full findings and evidence.'},
|
|
2928
|
-
{el: 'card:Optimization opportunities', t: 'Opportunities', see: 'The top ranked changes
|
|
2894
|
+
{el: 'card:Optimization opportunities', t: 'Opportunities', see: 'The top ranked changes, ranked by spend involved.', get: 'What to fix first.', act: 'Open <b>What should I do?</b> for the full list with ready-made prompts.'},
|
|
2929
2895
|
{el: 'card:Forecast', t: 'Forecast', see: 'Projected spend to the end of the billing period.', get: 'An early read on whether you\'ll go over.', act: 'Open <b>Forecast</b> for the optimistic and pessimistic scenarios.'},
|
|
2930
|
-
{el: 'card:FinOps score', t: 'FinOps score', see: 'Your overall score for
|
|
2896
|
+
{el: 'card:FinOps score', t: 'FinOps score', see: 'Your overall score for context share, cache break-even and budget adherence.', get: 'One number for how efficiently you work.', act: 'Open <b>FinOps scorecard</b> to see what each dimension measures.'}],
|
|
2931
2897
|
advisor: [
|
|
2932
|
-
{el: 'card:Biggest optimization opportunity', t: 'Start here', see: 'The
|
|
2898
|
+
{el: 'card:Biggest optimization opportunity', t: 'Start here', see: 'The change involving the most spend, with the evidence behind it.', get: 'The best use of your next ten minutes.', act: 'Read the estimate, then apply the change.'},
|
|
2933
2899
|
{el: 'card:What is going well', t: 'Strengths & gaps', see: 'What your habits already do well, and the items that need attention.', get: 'Confirmation of what to keep doing, and where you lose money.', act: 'Work down the <b>Needs attention</b> list.'},
|
|
2934
|
-
{el: 'card:All recommendations', t: 'All recommendations', see: 'Every recommendation, ranked by
|
|
2900
|
+
{el: 'card:All recommendations', t: 'All recommendations', see: 'Every recommendation, ranked by spend involved, each with its reasoning.', get: 'A prioritised to-do list built from your own usage.', act: 'Copy a recommendation\'s prompt straight into your agent.'},
|
|
2935
2901
|
{el: 'card:Anomalies to inspect', t: 'Anomalies', see: 'Days and sessions that spent far more than your normal.', get: 'Surprises caught before they repeat.', act: 'Click one to see what happened that day.'}],
|
|
2936
2902
|
agents: [
|
|
2937
2903
|
{el: 'kpis', t: 'Agent totals', see: 'One tile per agent with its usage for the current filters.', get: 'How spend splits across Claude Code, Codex, Gemini CLI and Cursor.', act: 'Use the agent chips in the header to focus on one.'},
|
|
@@ -2946,7 +2912,7 @@ const TOURS = {
|
|
|
2946
2912
|
{el: 'card:AI FinOps Score', t: 'Your score', see: 'The overall score with a component breakdown: cache use, model mix, waste and budget.', get: 'Where your habits are strong and where they cost money.', act: 'Hover a component to see how it is calculated.'},
|
|
2947
2913
|
{el: 'card:✅ What is good', t: 'What is good', see: 'The components you already score well on.', get: 'The habits worth keeping.', act: 'Keep these when you change your setup.'},
|
|
2948
2914
|
{el: 'card:⚠️ Needs attention', t: 'Needs attention', see: 'The components dragging the score down.', get: 'A short list of what to fix.', act: 'Start at the top: it carries the most weight.'},
|
|
2949
|
-
{el: 'card:🎯 Biggest opportunity', t: 'Biggest opportunity', see: 'The single change that would move the score most.', get: 'One concrete action
|
|
2915
|
+
{el: 'card:🎯 Biggest opportunity', t: 'Biggest opportunity', see: 'The single change that would move the score most.', get: 'One concrete action grounded in observed spend.', act: 'Apply it, then Sync and re-check the score.'}],
|
|
2950
2916
|
usage: [
|
|
2951
2917
|
{el: 'kpis', t: 'Token breakdown', see: 'Total, input, output, thinking, cache read and cache write tokens, plus requests, sessions, prompts and active days.', get: 'Exactly which kind of token you spend on. Cache read is cheap; uncached input is not.', act: 'Compare cache read against input: a low ratio means context is being re-sent, not reused.'},
|
|
2952
2918
|
{el: 'card0', t: 'Usage over time', see: 'Tokens or cost per day (or per hour on short ranges).', get: 'Spikes and quiet periods at a glance.', act: 'Hover a bar for the day\'s numbers; narrow the range to zoom in.'},
|
|
@@ -2965,7 +2931,7 @@ const TOURS = {
|
|
|
2965
2931
|
context: [
|
|
2966
2932
|
{el: 'kpis', t: 'Context & cache', see: 'Average and peak context per request, and how much is served from cache.', get: 'How much re-reading history costs you, and what caching saves.', act: 'Large average context? Clear or compact sessions more often.'},
|
|
2967
2933
|
{el: 'card:Cost by context size', t: 'Cost by context size', see: 'Spend grouped by how large the context was.', get: 'Proof of how quickly cost climbs with context.', act: 'See how much sits in the largest buckets.'},
|
|
2968
|
-
{el: 'card:Caching: with vs without', t: '
|
|
2934
|
+
{el: 'card:Caching: with vs without', t: 'Caching, with vs without', see: 'What you paid against what the same tokens would cost with no cache at all.', get: 'A counterfactual, not a saving: nobody would have run it that way.', act: 'A small gap means sessions are restarted too often to build a cache.'},
|
|
2969
2935
|
{el: 'card:Sessions with the largest context', t: 'Largest contexts', see: 'The sessions that carried the most history.', get: 'The sessions to split or hand over next time.', act: 'Click one to see where it grew.'},
|
|
2970
2936
|
{el: 'card:Context distribution', t: 'Context distribution', see: 'How your requests spread across context sizes.', get: 'Whether large contexts are the exception or the norm.', act: 'A long right tail means /compact earlier.'}],
|
|
2971
2937
|
projects: [
|
|
@@ -2988,7 +2954,7 @@ const TOURS = {
|
|
|
2988
2954
|
categories: [
|
|
2989
2955
|
{el: 'card:Cost by activity', t: 'Cost by activity', see: 'Spend per kind of work: coding, debugging, docs, review and so on.', get: 'Which kinds of work cost most.', act: 'Cheap, repetitive categories are the ones to move to a smaller model.'},
|
|
2990
2956
|
{el: 'card:Prompts by activity', t: 'Prompts by activity', see: 'How many prompts fall into each category.', get: 'Volume next to cost: a category can be frequent but cheap.', act: 'Compare this with the cost chart to find the expensive outliers.'},
|
|
2991
|
-
{el: 'card:Activity breakdown', t: 'Activity breakdown', see: 'Per category: prompts, tokens, cost and the model used.', get: 'Concrete evidence for routing work to a cheaper model.', act: 'Then open <b>Model switch</b> to see the
|
|
2957
|
+
{el: 'card:Activity breakdown', t: 'Activity breakdown', see: 'Per category: prompts, tokens, cost and the model used.', get: 'Concrete evidence for routing work to a cheaper model.', act: 'Then open <b>Model switch</b> to see the options.'}],
|
|
2992
2958
|
developer: [
|
|
2993
2959
|
{el: 'card:Cost per repository', t: 'Cost per repository', see: 'Spend per repo checked out on this machine.', get: 'Which codebase is expensive to work in.', act: 'Click a repo to filter the dashboards.'},
|
|
2994
2960
|
{el: 'card:Tool usage', t: 'Tool usage', see: 'Each tool the agent called and how often.', get: 'Tools with huge call counts: they fill context and cost money.', act: 'Big Read or Grep counts? Add project memory so the agent re-reads less.'},
|
|
@@ -3015,16 +2981,14 @@ const TOURS = {
|
|
|
3015
2981
|
{el: 'card:Slash commands', t: 'Slash commands', see: 'The commands and user-invoked skills you actually use.', get: 'Which shortcuts earn their keep.', act: 'Delete the ones you never call.'},
|
|
3016
2982
|
{el: 'card:MCP servers', t: 'MCP servers', see: 'Each configured server, whether it was ever called, and its context cost.', get: 'Tool definitions loaded on every request for nothing.', act: 'Remove servers that are configured but never called.'},
|
|
3017
2983
|
{el: 'card:Connectors', t: 'Connectors', see: 'claude.ai connectors and their share of context.', get: 'The same check for connectors as for MCP servers.', act: 'Disconnect what you don\'t use from this machine.'}],
|
|
2984
|
+
hygiene: [
|
|
2985
|
+
{el: 'kpis', t: 'Where spend sat', see: 'The share of spend in requests above each context size, and the share that came after a session first crossed it.', get: 'How much of the bill was re-sending a large prefix.', act: 'A high "after crossing" share means the expensive part of a session was its tail.'},
|
|
2986
|
+
{el: 'card:Sessions ranked', t: 'Sessions', see: 'Each session\'s context trajectory and what it spent after crossing the threshold.', get: 'The sessions where a fresh start would have mattered most.', act: 'Click a row for its trajectory; alt-click to open the session.'}],
|
|
3018
2987
|
waste: [
|
|
3019
2988
|
{el: 'kpis', t: 'Waste headlines', see: 'Estimated excess, exposed spend, and how many prompts, sessions and rules are involved.', get: 'An honest estimate of avoidable spend.', act: '<b>Exposed</b> is what flagged work cost in total; <b>excess</b> is how much more than a fair baseline.'},
|
|
3020
2989
|
{el: 'card:🔴 High waste', t: 'High waste', see: 'The rules that fired hardest: repeated reads, retries, stale sessions.', get: 'The costly patterns, each with the baseline it is measured against.', act: 'Open <b>Show flagged items</b> to see the evidence, then fix these first.'},
|
|
3021
|
-
{el: 'card:🟡 Optimization opportunities', t: 'Medium findings', see: 'Patterns worth changing but not urgent.', get: 'The next tier of
|
|
2990
|
+
{el: 'card:🟡 Optimization opportunities', t: 'Medium findings', see: 'Patterns worth changing but not urgent.', get: 'The next tier of opportunities.', act: 'Batch these into one config change.'},
|
|
3022
2991
|
{el: 'card:⚪ Low-priority observations', t: 'Low-priority observations', see: 'Small findings, kept for completeness.', get: 'Context for the numbers above.', act: 'Skim them; act only if one matches a habit you want to change.'}],
|
|
3023
|
-
modelswitch: [
|
|
3024
|
-
{el: 'kpis', t: 'Model switch', see: 'What you\'d save running routine work on a cheaper model from the same vendor, split by confidence.', get: 'Savings you can trust, separated from savings that need a judgement call.', act: 'Start with the high-confidence figure.'},
|
|
3025
|
-
{el: 'card:Switch these', t: 'Switch these', see: 'The specific prompts or categories that a smaller model could have handled.', get: 'Evidence per item, not a blanket recommendation.', act: 'Check a few prompts yourself before you trust the pattern.'},
|
|
3026
|
-
{el: 'card:Default model per project', t: 'Default per project', see: 'A suggested default model for each project, from the work you do there.', get: 'A setting you change once instead of choosing per prompt.', act: 'Apply it to your cheapest, most repetitive repo first.'},
|
|
3027
|
-
{el: 'card:How to switch', t: 'How to switch', see: 'The exact command or setting for each agent.', get: 'No guessing at flag names.', act: 'Copy the command for your agent and try it on a small task.'}],
|
|
3028
2992
|
freemodels: [
|
|
3029
2993
|
{el: 'card:How to use and test a free model', t: 'How it works', see: 'How to plug a local or free cloud model into Claude Code, and how to test it.', get: 'Zero-cost options for simple or private work.', act: 'Read the RAM guidance before you pick a model.'},
|
|
3030
2994
|
{el: 'card1', t: 'A model', see: 'Each model with its size, RAM needs, strengths and limits.', get: 'A realistic idea of what runs on your machine.', act: 'Click <b>+ Add</b> on a model that fits your RAM.'}],
|
|
@@ -3037,9 +3001,9 @@ const TOURS = {
|
|
|
3037
3001
|
{el: 'card:MCP servers for work you repeat', t: 'MCP suggestions', see: 'Servers suggested from the work your prompts repeat.', get: 'Fewer manual steps and smaller prompts.', act: 'Add one, then check its context cost in <b>Who used the tokens</b>.'},
|
|
3038
3002
|
{el: 'card:Skills from what you repeat', t: 'Skill suggestions', see: 'Skills drafted from the instructions you keep retyping.', get: 'Repetition moved out of your prompts.', act: 'Create a suggested skill in one click.'}],
|
|
3039
3003
|
recommendations: [
|
|
3040
|
-
{el: 'card:Biggest optimization opportunity', t: 'Start here', see: 'The change with the
|
|
3004
|
+
{el: 'card:Biggest optimization opportunity', t: 'Start here', see: 'The change involving the most spend, with the evidence behind it.', get: 'The best single thing to do next.', act: 'Apply it, then Sync and re-check.'},
|
|
3041
3005
|
{el: 'card:What is going well', t: 'Strengths & gaps', see: 'What already works, and what needs attention.', get: 'What to keep and what to change.', act: 'Work down the <b>Needs attention</b> list.'},
|
|
3042
|
-
{el: 'card:All recommendations', t: 'All recommendations', see: 'Every recommendation with its
|
|
3006
|
+
{el: 'card:All recommendations', t: 'All recommendations', see: 'Every recommendation with its spend involved and reasoning.', get: 'A prioritised list of what to change.', act: 'Start with the highest spend involved.'},
|
|
3043
3007
|
{el: 'card:Anomalies to inspect', t: 'Anomalies', see: 'Unusual days and sessions worth a look.', get: 'Problems caught before they become habits.', act: 'Click one to see what happened.'}],
|
|
3044
3008
|
anomalies: [
|
|
3045
3009
|
{el: 'card:Daily spend with anomalies highlighted', t: 'Spend with anomalies', see: 'Daily spend with the outlier days marked against your normal band.', get: 'How far outside normal each day was.', act: 'Hover a highlighted day for its numbers.'},
|