claude-finops 0.7.2 → 0.8.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 +62 -42
- package/config/settings.json +11 -5
- package/finops/actions.py +1 -1
- package/finops/analytics.py +690 -611
- package/finops/api.py +174 -55
- package/finops/diagnose.py +17 -20
- package/finops/etl.py +149 -52
- package/finops/integrate.py +22 -57
- 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 +253 -329
- package/web/charts.js +26 -15
- 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');
|
|
@@ -104,6 +106,7 @@ function wireTable(host, rows, onRow) {
|
|
|
104
106
|
/* ============================ chrome ============================ */
|
|
105
107
|
const NAV = [
|
|
106
108
|
['Command center', [
|
|
109
|
+
['hygiene', '◫', 'Context hygiene'],
|
|
107
110
|
['overview', '◧', 'Executive overview'],
|
|
108
111
|
['advisor', '✦', 'What should I do?'],
|
|
109
112
|
['agents', '◎', 'Agents'],
|
|
@@ -128,7 +131,6 @@ const NAV = [
|
|
|
128
131
|
['diagnose', '✚', 'Why so many tokens?', 'priced'],
|
|
129
132
|
['attribution', '⧉', 'Who used the tokens', 'priced'],
|
|
130
133
|
['waste', '⚠', 'Waste detection'],
|
|
131
|
-
['modelswitch', '⇄', 'Model switch', 'priced'],
|
|
132
134
|
['freemodels', '◇', 'Free models', 'claude'],
|
|
133
135
|
['compare', '⚖', 'Compare models', 'priced'],
|
|
134
136
|
['toolkit', '✎', 'Skills & MCP', 'claude'],
|
|
@@ -297,6 +299,9 @@ function openPop(btn, kind) {
|
|
|
297
299
|
<input type="number" step="0.01" id="mc" value="${f.min_cost ?? ''}" placeholder="any">
|
|
298
300
|
<div class="hd">Minimum billable tokens</div>
|
|
299
301
|
<input type="number" id="mt" value="${f.min_tokens ?? ''}" placeholder="any">
|
|
302
|
+
<div class="note" style="margin:8px 0 6px">These apply per <strong>request</strong>, not per
|
|
303
|
+
session or prompt. A session's totals will count only its requests above the
|
|
304
|
+
threshold, so filtered totals read lower than the session's real cost.</div>
|
|
300
305
|
<button class="chip on" id="applyth" style="width:100%;justify-content:center">Apply</button>`;
|
|
301
306
|
} else {
|
|
302
307
|
const key = kind, src = kind === 'models' ? o.models.map(m => [m.model, modelName(m.model), m.n])
|
|
@@ -360,7 +365,6 @@ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeDrawer(
|
|
|
360
365
|
async function openPrompt(id) {
|
|
361
366
|
const d = drawer('Prompt detail', '<div class="loading">Loading…</div>');
|
|
362
367
|
const p = await fetch(`/api/prompt/${id}`).then(r => r.json());
|
|
363
|
-
const adv = p.advisor || {};
|
|
364
368
|
d.querySelector('.content').innerHTML = `
|
|
365
369
|
<div class="grid g4">
|
|
366
370
|
${kpi('Estimated cost', fmtUSD(p.est_cost_usd), null, {badge: BADGE.estimated})}
|
|
@@ -376,22 +380,6 @@ async function openPrompt(id) {
|
|
|
376
380
|
<span>${fmtInt(p.char_len)} chars · ${fmtInt(p.word_len)} words</span>
|
|
377
381
|
${p.source ? `<span class="pill">${esc(p.source)}</span>` : ''}
|
|
378
382
|
</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
383
|
${card('Requests in this turn', table([
|
|
396
384
|
{h: 'Time', f: r => `<span class="mono">${esc((r.ts||'').slice(11,19))}</span>`},
|
|
397
385
|
{h: 'Model', f: r => `<span class="swatch" style="background:${modelColor(r.model)}"></span>${esc(modelName(r.model))}`},
|
|
@@ -572,12 +560,8 @@ VIEWS.overview = async (page) => {
|
|
|
572
560
|
${card('Optimization opportunities', `<div class="stack">${
|
|
573
561
|
b.recommendations.recommendations.length
|
|
574
562
|
? 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>
|
|
563
|
+
<div class="hd">${esc(r.title)}</div>
|
|
564
|
+
<div class="mt"><span>Actual ${fmtUSD(r.actual_cost_usd)}</span></div>
|
|
581
565
|
<div class="note">${esc(r.caveat)}</div></div>`).join('')
|
|
582
566
|
: '<div class="empty">No recommendation met the evidence threshold</div>'}</div>`,
|
|
583
567
|
{badge: BADGE.recommendation})}
|
|
@@ -588,7 +572,7 @@ VIEWS.overview = async (page) => {
|
|
|
588
572
|
{badge: BADGE.forecast, hint: forecast.available ? forecast.method : ''})}
|
|
589
573
|
${card('FinOps score', `<div class="scorewrap">
|
|
590
574
|
<div><div class="scorenum">${scorecard.score}</div>
|
|
591
|
-
<div class="scoregrade">out of 100
|
|
575
|
+
<div class="scoregrade">out of 100</div></div>
|
|
592
576
|
<div style="flex:1;min-width:230px" class="stack">${scorecard.dimensions.map(d => `
|
|
593
577
|
<div><div style="display:flex;justify-content:space-between;font-size:11.5px">
|
|
594
578
|
<span>${esc(d.name)}</span><span style="font-variant-numeric:tabular-nums">${d.score}</span></div>
|
|
@@ -602,9 +586,10 @@ VIEWS.overview = async (page) => {
|
|
|
602
586
|
if (alloc.configured) {
|
|
603
587
|
C.gauge(gEl, {pct: alloc.used_pct, status: alloc.status, label: 'of plan allowance'});
|
|
604
588
|
$('#burnnotes', page).innerHTML = `<div class="stack">
|
|
605
|
-
<div class="item"><div class="hd">${statusGlyph(alloc.status)}
|
|
606
|
-
|
|
607
|
-
|
|
589
|
+
<div class="item"><div class="hd">${statusGlyph(alloc.status)} ${alloc.days_until_limit == null
|
|
590
|
+
? `<b>Exceeded</b> your current limit.`
|
|
591
|
+
: `You are likely to reach your current limit in <b>${alloc.days_until_limit}</b> days${alloc.limit_date
|
|
592
|
+
? ` (around ${esc(alloc.limit_date)})` : ''}.`}</div></div>
|
|
608
593
|
<div class="item"><div class="hd">At the current burn rate you will
|
|
609
594
|
${alloc.projected_overage_pct > 0 ? `exceed your allowance by
|
|
610
595
|
<b>${fmtPct(alloc.projected_overage_pct)}</b>` : `finish the period at
|
|
@@ -659,7 +644,7 @@ VIEWS.overview = async (page) => {
|
|
|
659
644
|
], lp, {onRow: 1});
|
|
660
645
|
wireTable($('#topprompts', page), lp, r => openPrompt(r.prompt_id));
|
|
661
646
|
|
|
662
|
-
const ls = await api('sessions', '&limit=10&order=cost');
|
|
647
|
+
const ls = (await api('sessions', '&limit=10&order=cost')).rows;
|
|
663
648
|
$('#topsessions', page).innerHTML = table([
|
|
664
649
|
{h: 'Session', trunc: 1, title: r => r.session_id,
|
|
665
650
|
f: r => esc(r.title || shortId(r.session_id))},
|
|
@@ -670,13 +655,16 @@ VIEWS.overview = async (page) => {
|
|
|
670
655
|
], ls, {onRow: 1});
|
|
671
656
|
wireTable($('#topsessions', page), ls, r => openSession(r.session_id));
|
|
672
657
|
|
|
673
|
-
if (forecast.available) {
|
|
658
|
+
if (forecast.available && !forecast.insufficient_history) {
|
|
674
659
|
C.forecastFan($('#fan', page), {history: burn.series, scenarios: forecast.scenarios,
|
|
675
660
|
remainingDays: forecast.remaining_days});
|
|
676
661
|
$('#fanleg', page).innerHTML = `<span class="it"><span class="swatch"
|
|
677
662
|
style="background:var(--s1)"></span>Cumulative actual (estimated cost)</span>
|
|
678
663
|
<span class="it"><span class="swatch" style="background:var(--s1);opacity:.35"></span>
|
|
679
664
|
Forecast band: conservative → high</span>`;
|
|
665
|
+
} else if (forecast.available) {
|
|
666
|
+
$('#fan', page).innerHTML = '<div class="empty">Fewer than 7 priced days in the window: '
|
|
667
|
+
+ 'bands not shown.</div>';
|
|
680
668
|
} else $('#fan', page).innerHTML = '<div class="empty">Not enough history to forecast</div>';
|
|
681
669
|
|
|
682
670
|
renderAdvisorHero(page, advisor);
|
|
@@ -700,9 +688,7 @@ function renderAdvisorHero(page, advisor) {
|
|
|
700
688
|
: a.basis === 'recommendation' ? 'rec' : 'est'}">${esc(a.basis.split(':')[0])}</span></b>
|
|
701
689
|
<span>${esc(a.detail)}</span></div></div>`).join('')
|
|
702
690
|
: '<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>` : ''}
|
|
691
|
+
|
|
706
692
|
</div>`);
|
|
707
693
|
page.insertBefore(hero, page.firstChild);
|
|
708
694
|
}
|
|
@@ -729,14 +715,12 @@ VIEWS.advisor = async (page) => {
|
|
|
729
715
|
</div>
|
|
730
716
|
${card('All recommendations', recs.recommendations.length ? `<div class="stack">
|
|
731
717
|
${recs.recommendations.map(r => `<div class="item"><div class="hd">${esc(r.title)}
|
|
732
|
-
<span class="spacer"></span><span class="badge
|
|
718
|
+
<span class="spacer"></span><span class="badge">${esc(r.confidence)}</span></div>
|
|
733
719
|
${r.current_model ? `<div class="dt"><b>Currently on:</b> ${esc(r.current_model)}${
|
|
734
720
|
r.scope ? ` · ${esc(r.scope)}` : ''}</div>` : ''}
|
|
721
|
+
${r.detail ? `<div class="dt">${esc(r.detail)}</div>` : ''}
|
|
735
722
|
<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})}
|
|
723
|
+
${kpi('Spend involved', fmtUSD(r.actual_cost_usd), null, {small: 1, badge: BADGE.estimated})}
|
|
740
724
|
</div>
|
|
741
725
|
${r.alternatives && r.alternatives.length ? `<div class="dt" style="margin-top:6px">
|
|
742
726
|
<b>Your options${r.agent ? ` within ${esc(r.agent)}` : ''}</b> — pick the trade-off you want:</div>
|
|
@@ -745,13 +729,11 @@ VIEWS.advisor = async (page) => {
|
|
|
745
729
|
? ' <span class="badge rec">suggested</span>' : '')},
|
|
746
730
|
{h: 'Tier', f: a => esc(a.tier)},
|
|
747
731
|
{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
732
|
], r.alternatives)}` : ''}
|
|
751
733
|
<div class="note">${esc(r.caveat)}</div></div>`).join('')}</div>`
|
|
752
734
|
: '<div class="empty">No recommendation met the evidence threshold for this range</div>',
|
|
753
735
|
{badge: BADGE.recommendation,
|
|
754
|
-
footer: '
|
|
736
|
+
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
737
|
${card('Anomalies to inspect', `<div class="stack" id="anolist">${anos.anomalies.map((a, i) =>
|
|
756
738
|
`<div class="item sev-${a.severity} clickable" data-i="${i}"><div class="hd">
|
|
757
739
|
${a.severity === 'high' ? '🚨' : '⚠️'} ${esc(a.title)}</div>
|
|
@@ -807,7 +789,10 @@ VIEWS.usage = async (page) => {
|
|
|
807
789
|
</div>
|
|
808
790
|
<div class="chart" id="tl"></div>`,
|
|
809
791
|
{badge: S.metric === 'cost' ? BADGE.estimated : BADGE.actual,
|
|
810
|
-
hint: 'click a bucket to drill into that day',
|
|
792
|
+
hint: 'click a bucket to drill into that day',
|
|
793
|
+
footer: 'Days are bucketed in UTC, as Claude Code timestamps its transcripts. '
|
|
794
|
+
+ 'If you work late in a timezone ahead of UTC, that work lands on the previous day here.',
|
|
795
|
+
flush: 0})}
|
|
811
796
|
${card('Model mix over time', '<div class="legend" id="mixleg"></div>' +
|
|
812
797
|
'<div class="chart" id="mix"></div>', {badge: BADGE.estimated,
|
|
813
798
|
hint: 'stacked estimated cost per model'})}
|
|
@@ -901,8 +886,9 @@ VIEWS.burn = async (page) => {
|
|
|
901
886
|
<dt>Overage</dt><dd>${fmtPct(a.projected_overage_pct)}</dd>
|
|
902
887
|
</dl></div></div>
|
|
903
888
|
<div class="item" style="margin-top:9px">
|
|
904
|
-
<div class="hd">${statusGlyph(a.status)}
|
|
905
|
-
|
|
889
|
+
<div class="hd">${statusGlyph(a.status)} ${a.days_until_limit == null
|
|
890
|
+
? `<b>Exceeded</b> this limit.`
|
|
891
|
+
: `You are likely to reach this limit in <b>${a.days_until_limit}</b> days.`}</div>
|
|
906
892
|
<div class="dt">At the current burn rate you will
|
|
907
893
|
${a.projected_overage_pct > 0
|
|
908
894
|
? `exceed the allowance by <b>${fmtPct(a.projected_overage_pct)}</b>.`
|
|
@@ -926,10 +912,23 @@ VIEWS.burn = async (page) => {
|
|
|
926
912
|
|
|
927
913
|
/* ---------- models ---------- */
|
|
928
914
|
VIEWS.models = async (page) => {
|
|
929
|
-
const m = await api('models');
|
|
915
|
+
const [m, lc] = await Promise.all([api('models'), api('long_context_pricing')]);
|
|
930
916
|
const sup = m.superlatives, rows = m.rows;
|
|
931
917
|
const find = k => rows.find(r => r.model === sup[k]);
|
|
932
918
|
page.innerHTML = `
|
|
919
|
+
${lc.unpriced_requests ? `<div class="hero">
|
|
920
|
+
<h3>⚠️ Some long-context requests are priced low</h3>
|
|
921
|
+
<div class="stack">
|
|
922
|
+
<div class="item sev-high">
|
|
923
|
+
<div class="hd">${fmtInt(lc.unpriced_requests)} requests sent more context than their
|
|
924
|
+
model's standard window, so they ran on the long-context variant — which bills at a
|
|
925
|
+
premium this price table does not have.</div>
|
|
926
|
+
<div class="dt">Affected: ${lc.unpriced_models.map(esc).join(', ')}.
|
|
927
|
+
Their estimated cost is <strong>understated</strong>. Add a
|
|
928
|
+
<code>"<model>[1m]"</code> entry to <code>config/pricing.json</code> and rebuild
|
|
929
|
+
to correct it. Requests with a configured long-context price
|
|
930
|
+
(${fmtInt(lc.repriced_requests)}) are already billed at it.</div></div>
|
|
931
|
+
</div></div>` : ''}
|
|
933
932
|
<div class="grid g4">
|
|
934
933
|
${kpi('Most expensive', sup.most_expensive ? esc(modelName(sup.most_expensive)) : null,
|
|
935
934
|
find('most_expensive') ? fmtUSD(find('most_expensive').cost) + ' estimated' : '', {small: 1})}
|
|
@@ -977,7 +976,9 @@ VIEWS.models = async (page) => {
|
|
|
977
976
|
$('#mt', page).innerHTML = table([
|
|
978
977
|
{h: 'Model', f: r => `<span class="swatch" style="background:${modelColor(r.model)}"></span>${esc(r.display_name)}
|
|
979
978
|
${r.pricing_known ? '' : '<span class="badge na" title="no price entry — default pricing used">default price</span>'}`},
|
|
980
|
-
{h: 'Tier', f: r =>
|
|
979
|
+
{h: 'Tier', f: r => r.tier === 'unknown'
|
|
980
|
+
? `<span class="pill">unpriced</span><span class="badge na" title="no price in pricing.json">no price in pricing.json</span>`
|
|
981
|
+
: `<span class="pill">${esc(r.tier)}</span>`},
|
|
981
982
|
{h: 'Requests', num: 1, f: r => fmtInt(r.requests)},
|
|
982
983
|
{h: 'Sessions', num: 1, f: r => fmtInt(r.sessions)},
|
|
983
984
|
{h: 'Input', num: 1, f: r => fmtNum(r.input_tokens)},
|
|
@@ -986,7 +987,8 @@ VIEWS.models = async (page) => {
|
|
|
986
987
|
{h: 'Cache write', num: 1, f: r => fmtNum(r.cache_write_tokens)},
|
|
987
988
|
{h: 'Tokens', num: 1, f: r => fmtNum(r.tokens)},
|
|
988
989
|
{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)
|
|
990
|
+
{h: 'Ctx util', num: 1, f: r => (r.utilization_pct == null ? '—' : fmtPct(r.utilization_pct))
|
|
991
|
+
+ (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
992
|
{h: 'Avg latency', num: 1, f: r => r.avg_latency_ms ? (r.avg_latency_ms/1000).toFixed(1)+'s' : '—'},
|
|
991
993
|
{h: '$/1K out', num: 1, f: r => r.cost_per_1k_output ? '$' + r.cost_per_1k_output.toFixed(3) : '—'},
|
|
992
994
|
{h: 'Est. cost', num: 1, f: r => fmtUSD(r.cost)},
|
|
@@ -1031,16 +1033,18 @@ VIEWS.projects = async (page) => {
|
|
|
1031
1033
|
/* ---------- sessions ---------- */
|
|
1032
1034
|
VIEWS.sessions = async (page) => {
|
|
1033
1035
|
const order = S.sessOrder || 'cost';
|
|
1034
|
-
const
|
|
1036
|
+
const sp = S.sessPage;
|
|
1037
|
+
const res = await api('sessions', `&limit=${sp.offset + sp.limit}&order=${order}`);
|
|
1038
|
+
const rows = res.rows, total = res.total;
|
|
1035
1039
|
const eff = await api('efficiency');
|
|
1036
1040
|
const avgTok = rows.length ? rows.reduce((a, r) => a + r.tokens, 0) / rows.length : 0;
|
|
1037
1041
|
page.innerHTML = `
|
|
1038
1042
|
<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)),
|
|
1043
|
+
${kpi('Sessions in range', fmtInt(total), null, {badge: BADGE.actual})}
|
|
1044
|
+
${kpi('Avg cost / loaded session', fmtUSD(rows.reduce((a, r) => a + r.cost, 0) / (rows.length || 1)),
|
|
1041
1045
|
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)
|
|
1046
|
+
${kpi('Avg tokens / loaded session', fmtNum(avgTok))}
|
|
1047
|
+
${kpi('Avg tokens / loaded prompt', fmtNum(rows.reduce((a, r) => a + (r.tokens_per_prompt || 0), 0)
|
|
1044
1048
|
/ (rows.filter(r => r.tokens_per_prompt).length || 1)))}
|
|
1045
1049
|
</div>
|
|
1046
1050
|
${card('Session explorer', `<div class="filters" style="margin:0 0 8px">
|
|
@@ -1049,7 +1053,12 @@ VIEWS.sessions = async (page) => {
|
|
|
1049
1053
|
`<button class="chip ${order === k ? 'on' : ''}" data-so="${k}">${l}</button>`).join('')}
|
|
1050
1054
|
<span class="spacer"></span>
|
|
1051
1055
|
<span class="note">rows above ${fmtNum(avgTok * 3)} tokens are unusually expensive</span>
|
|
1052
|
-
</div><div id="st"></div
|
|
1056
|
+
</div><div id="st"></div>
|
|
1057
|
+
<div class="filters" style="margin-top:8px">
|
|
1058
|
+
<span class="note">Showing ${fmtInt(Math.min(rows.length, total))} of ${fmtInt(total)}</span>
|
|
1059
|
+
<span class="spacer"></span>
|
|
1060
|
+
${rows.length < total ? '<button class="btn pb-copy" id="sess-more">Load more</button>' : ''}
|
|
1061
|
+
</div>`, {badge: BADGE.estimated, hint: 'click a row to open the session'})}
|
|
1053
1062
|
<div class="grid g2">
|
|
1054
1063
|
${card('Lowest output yield', table([
|
|
1055
1064
|
{h: 'Session', trunc: 1, f: r => esc(r.title || shortId(r.session_id))},
|
|
@@ -1065,7 +1074,9 @@ VIEWS.sessions = async (page) => {
|
|
|
1065
1074
|
], eff.high_efficiency_sessions, {onRow: 1}), {badge: BADGE.estimated, hint: 'high efficiency'})}
|
|
1066
1075
|
</div>`;
|
|
1067
1076
|
page.querySelectorAll('[data-so]').forEach(b => b.onclick = () => {
|
|
1068
|
-
S.sessOrder = b.dataset.so; bust(); render(); });
|
|
1077
|
+
S.sessOrder = b.dataset.so; S.sessPage.offset = 0; bust(); render(); });
|
|
1078
|
+
const sessMore = $('#sess-more', page);
|
|
1079
|
+
if (sessMore) sessMore.onclick = () => { sp.offset += sp.limit; bust(); render(); };
|
|
1069
1080
|
$('#st', page).innerHTML = table([
|
|
1070
1081
|
{h: 'Session', trunc: 1, title: r => r.session_id,
|
|
1071
1082
|
f: r => `${r.tokens > avgTok * 3 ? '🔴 ' : ''}${esc(r.title || shortId(r.session_id))}
|
|
@@ -1107,8 +1118,10 @@ VIEWS.sessions = async (page) => {
|
|
|
1107
1118
|
VIEWS.prompts = async (page) => {
|
|
1108
1119
|
const order = S.promptOrder || 'cost';
|
|
1109
1120
|
const q = S.promptQ || '';
|
|
1110
|
-
const
|
|
1111
|
-
|
|
1121
|
+
const pp = S.promptPage;
|
|
1122
|
+
const res = await api('prompts',
|
|
1123
|
+
`&limit=${pp.offset + pp.limit}&order=${order}${q ? '&q=' + encodeURIComponent(q) : ''}`);
|
|
1124
|
+
const rows = res.rows, total = res.total;
|
|
1112
1125
|
page.innerHTML = `
|
|
1113
1126
|
${card('Prompt explorer', `
|
|
1114
1127
|
<div class="filters" style="margin:0 0 8px">
|
|
@@ -1119,16 +1132,19 @@ VIEWS.prompts = async (page) => {
|
|
|
1119
1132
|
['efficiency', 'Most efficient'], ['cheapest', 'Cheapest'], ['recent', 'Most recent']]
|
|
1120
1133
|
.map(([k, l]) => `<button class="chip ${order === k ? 'on' : ''}" data-po="${k}">${l}</button>`).join('')}
|
|
1121
1134
|
<span class="spacer"></span>
|
|
1122
|
-
<span class="note"
|
|
1135
|
+
<span class="note">Showing ${fmtInt(Math.min(rows.length, total))} of ${fmtInt(total)} prompts · global filters apply</span>
|
|
1136
|
+
${rows.length < total ? '<button class="btn pb-copy" id="prompt-more">Load more</button>' : ''}
|
|
1123
1137
|
</div><div id="pt"></div>`,
|
|
1124
1138
|
{badge: BADGE.estimated, hint: 'click any row for the full prompt, usage and advice',
|
|
1125
1139
|
footer: 'Prompt text is read from your local transcripts and never leaves this machine.'})}`;
|
|
1126
1140
|
const inp = $('#pq', page);
|
|
1127
1141
|
let t; inp.oninput = e => { clearTimeout(t); const v = e.target.value;
|
|
1128
|
-
t = setTimeout(() => { S.promptQ = v; bust(); render().then(() => {
|
|
1142
|
+
t = setTimeout(() => { S.promptQ = v; S.promptPage.offset = 0; bust(); render().then(() => {
|
|
1129
1143
|
const i = $('#pq'); if (i) { i.focus(); i.setSelectionRange(v.length, v.length); } }); }, 300); };
|
|
1130
1144
|
page.querySelectorAll('[data-po]').forEach(b => b.onclick = () => {
|
|
1131
|
-
S.promptOrder = b.dataset.po; bust(); render(); });
|
|
1145
|
+
S.promptOrder = b.dataset.po; S.promptPage.offset = 0; bust(); render(); });
|
|
1146
|
+
const promptMore = $('#prompt-more', page);
|
|
1147
|
+
if (promptMore) promptMore.onclick = () => { pp.offset += pp.limit; bust(); render(); };
|
|
1132
1148
|
$('#pt', page).innerHTML = table([
|
|
1133
1149
|
{h: 'When', f: r => `<span class="mono">${esc((r.ts || '').slice(0, 16).replace('T', ' '))}</span>`},
|
|
1134
1150
|
{h: 'Prompt', trunc: 1, title: r => r.preview, f: r => esc(r.preview)},
|
|
@@ -1249,10 +1265,108 @@ VIEWS.categories = async (page) => {
|
|
|
1249
1265
|
};
|
|
1250
1266
|
|
|
1251
1267
|
/* ---------- context & cache ---------- */
|
|
1268
|
+
/* ---------- context & session hygiene: observed shares, no savings ---------- */
|
|
1269
|
+
VIEWS.hygiene = async (page) => {
|
|
1270
|
+
const [hy, fit] = await Promise.all([api('hygiene'), api('context_window_fit')]);
|
|
1271
|
+
const T = hy.thresholds.map(String), kT = String(hy.rank_threshold);
|
|
1272
|
+
const K = t => `${Math.round(+t / 1000)}K`;
|
|
1273
|
+
const ab = t => hy.above[t];
|
|
1274
|
+
page.innerHTML = `
|
|
1275
|
+
<div class="note" style="margin:0 0 10px">Everything on this page is observed from your transcripts.
|
|
1276
|
+
It shows where spend sat while a large prefix was being re-sent on every turn. It does
|
|
1277
|
+
<b>not</b> estimate what /compact or a fresh session would have saved — that depends on
|
|
1278
|
+
what the work still needed, which the transcript does not say. Auto-compaction is not
|
|
1279
|
+
recorded; it is detected as the context dropping by more than half. A typed /compact is
|
|
1280
|
+
recorded and also counts.</div>
|
|
1281
|
+
<div class="grid g4">
|
|
1282
|
+
${T.map(t => kpi(`Spend in requests ≥ ${K(t)} context`, fmtPct(ab(t).share_pct),
|
|
1283
|
+
`${fmtUSD(ab(t).cost_usd)} · ${fmtInt(ab(t).requests)} requests`, {badge: BADGE.actual})).join('')}
|
|
1284
|
+
${T.map(t => kpi(`Spend after a session first crossed ${K(t)}`, fmtPct(ab(t).share_after_first_cross_pct),
|
|
1285
|
+
`${fmtInt(ab(t).sessions)} of ${fmtInt(hy.sessions)} sessions crossed it`, {badge: BADGE.actual})).join('')}
|
|
1286
|
+
</div>
|
|
1287
|
+
<div class="grid g3">
|
|
1288
|
+
${kpi('Spend near or over the context window', fmtPct(fit.near_or_over_cost_pct),
|
|
1289
|
+
`≥ ${fit.threshold_pct}% of the window in use · ${fmtInt(fit.near_requests + fit.over_requests)} requests`,
|
|
1290
|
+
{badge: BADGE.actual})}
|
|
1291
|
+
${kpi('Sessions in range', fmtInt(hy.sessions), `${fmtInt(hy.requests)} main-thread requests`, {badge: BADGE.actual})}
|
|
1292
|
+
${kpi('Spend in range', fmtUSD(hy.cost_usd), 'subagent turns excluded (own prefix)', {badge: BADGE.estimated})}
|
|
1293
|
+
</div>
|
|
1294
|
+
${card('Context per request, most expensive session after ' + K(kT), '<div class="chart" id="hy-traj"></div>',
|
|
1295
|
+
{badge: BADGE.actual, hint: 'prompt-side tokens on each request, in order · click a row below to change session'})}
|
|
1296
|
+
${card('Sessions ranked by spend after crossing ' + K(kT), '<div id="hy-sess"></div>',
|
|
1297
|
+
{badge: BADGE.actual, flush: 1,
|
|
1298
|
+
footer: hy.note + ' Subagent turns are excluded because they run against their own prefix.'})}`;
|
|
1299
|
+
|
|
1300
|
+
const drawTraj = s => {
|
|
1301
|
+
const rows = s.context_trajectory.map((c, i) => ({i: i + 1, ctx: c, cum: s.cumulative_cost[i]}));
|
|
1302
|
+
C.timeSeries($('#hy-traj', page), {rows, x: 'i', type: 'area', height: 210, fmt: fmtNum,
|
|
1303
|
+
series: [{key: 'ctx', label: 'Context tokens', color: seriesVar(0)}],
|
|
1304
|
+
xLabel: v => `#${v}`});
|
|
1305
|
+
$('#hy-traj', page).insertAdjacentHTML('beforeend',
|
|
1306
|
+
`<div class="note" style="margin-top:6px">${esc(s.title || shortId(s.session_id))} — ${fmtInt(s.requests)} requests,
|
|
1307
|
+
${fmtUSD(s.cost_usd)}. Crossed ${K(kT)} at request #${s.ever_crossed[kT] ? s.first_cross_idx[kT] + 1 : '—'};
|
|
1308
|
+
${fmtPct(s.cost_after_pct[kT])} of its spend came after that.</div>`);
|
|
1309
|
+
};
|
|
1310
|
+
const rows = hy.sessions_ranked;
|
|
1311
|
+
$('#hy-sess', page).innerHTML = table([
|
|
1312
|
+
{h: 'Session', trunc: 1, f: r => esc(r.title || shortId(r.session_id))},
|
|
1313
|
+
{h: 'Project', trunc: 1, f: r => esc(r.project || '')},
|
|
1314
|
+
{h: 'Context', f: r => `<span class="spk" data-i="${rows.indexOf(r)}" style="display:inline-block;width:110px"></span>`},
|
|
1315
|
+
{h: 'Requests', num: 1, f: r => fmtInt(r.requests)},
|
|
1316
|
+
{h: 'Peak ctx', num: 1, f: r => fmtNum(r.max_context)},
|
|
1317
|
+
...T.map(t => ({h: `After ${K(t)}`, num: 1, f: r => !r.ever_crossed[t] ? '<span class="na">never</span>'
|
|
1318
|
+
: `<b>${fmtUSD(r.cost_after[t])}</b> <span class="note">(${fmtPct(r.cost_after_pct[t])})</span>`})),
|
|
1319
|
+
{h: 'Session cost', num: 1, f: r => fmtUSD(r.cost_usd)},
|
|
1320
|
+
], rows);
|
|
1321
|
+
page.querySelectorAll('#hy-sess .spk').forEach(el => C.spark(el, rows[+el.dataset.i].context_trajectory, seriesVar(0), 22));
|
|
1322
|
+
page.querySelectorAll('#hy-sess table.tbl tbody tr').forEach(tr => {
|
|
1323
|
+
tr.classList.add('clickable');
|
|
1324
|
+
tr.onclick = e => { if (e.altKey) openSession(rows[+tr.dataset.i].session_id); else drawTraj(rows[+tr.dataset.i]); };
|
|
1325
|
+
});
|
|
1326
|
+
if (rows.length) drawTraj(rows[0]);
|
|
1327
|
+
};
|
|
1328
|
+
|
|
1252
1329
|
VIEWS.context = async (page) => {
|
|
1253
|
-
const [ctx, eff] = await Promise.all([api('context'), api('efficiency')]);
|
|
1330
|
+
const [ctx, eff, ttl] = await Promise.all([api('context'), api('efficiency'), api('ttl_replay')]);
|
|
1254
1331
|
const ca = eff.cache;
|
|
1332
|
+
const ttlCard = () => {
|
|
1333
|
+
if (!ttl || !ttl.segments) return '';
|
|
1334
|
+
if (!ttl.reconciled) {
|
|
1335
|
+
return card('Cache TTL comparison', `
|
|
1336
|
+
<p class="note" style="margin:0">Not shown. Replaying your actual 1h TTL produced
|
|
1337
|
+
${fmtUSD(ttl.replay_1h_usd)} against ${fmtUSD(ttl.logged_cost_usd)} of logged cost
|
|
1338
|
+
(${ttl.reconciliation_drift_pct}% drift). A replay that cannot reproduce the bill you
|
|
1339
|
+
did get is not evidence about one you did not, so the comparison is withheld.</p>`,
|
|
1340
|
+
{badge: BADGE.actual, hint: 'arithmetic · failed its own reconciliation check'});
|
|
1341
|
+
}
|
|
1342
|
+
const cheaper5 = ttl.cheaper_ttl === '5m';
|
|
1343
|
+
return card('Cache TTL: what you use vs the alternative', `
|
|
1344
|
+
<p class="note" style="margin:0 0 10px">Replayed over <strong>${fmtInt(ttl.segments)}</strong>
|
|
1345
|
+
cache segments using your real inter-turn gaps. No assumption is made about model
|
|
1346
|
+
behaviour — this is arithmetic on timestamps and token counts.</p>
|
|
1347
|
+
<div class="grid g3" style="gap:8px">
|
|
1348
|
+
${kpi('Your 1h TTL', fmtUSD(ttl.replay_1h_usd), 'replayed', {small: 1, badge: BADGE.actual})}
|
|
1349
|
+
${kpi('Same work on a 5m TTL', fmtUSD(ttl.replay_5m_usd), 'counterfactual', {small: 1})}
|
|
1350
|
+
${kpi(cheaper5 ? 'A 5m TTL would save' : 'Your 1h TTL saves',
|
|
1351
|
+
fmtUSD(Math.abs(ttl.difference_usd)), cheaper5 ? 'switch to save' : 'already the cheaper choice',
|
|
1352
|
+
{small: 1, badge: BADGE.actual})}
|
|
1353
|
+
</div>
|
|
1354
|
+
<div class="stack" style="margin-top:10px">
|
|
1355
|
+
<div class="item sev-${cheaper5 ? 'medium' : 'low'}">
|
|
1356
|
+
<div class="hd">${cheaper5
|
|
1357
|
+
? 'A shorter TTL would be cheaper for how you actually work.'
|
|
1358
|
+
: 'Keep the 1h TTL — a 5m TTL would cost you more, not less.'}</div>
|
|
1359
|
+
<div class="dt">${cheaper5
|
|
1360
|
+
? '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.'
|
|
1361
|
+
: 'The gaps where a 5m prefix would expire force a full-prefix rewrite, and that costs more than the cheaper write rate saves.'}
|
|
1362
|
+
This only matters if the TTL is configurable in your setup.</div></div>
|
|
1363
|
+
</div>`,
|
|
1364
|
+
{badge: BADGE.actual,
|
|
1365
|
+
hint: 'arithmetic · reconciled to ' + ttl.reconciliation_drift_pct + '% of logged cost',
|
|
1366
|
+
footer: ttl.note});
|
|
1367
|
+
};
|
|
1255
1368
|
page.innerHTML = `
|
|
1369
|
+
${ttlCard()}
|
|
1256
1370
|
<div class="grid g5">
|
|
1257
1371
|
${kpi('Avg context / request', fmtNum(ctx.avg_context), null, {badge: BADGE.actual})}
|
|
1258
1372
|
${kpi('Max context seen', fmtNum(ctx.max_context),
|
|
@@ -1262,8 +1376,36 @@ VIEWS.context = async (page) => {
|
|
|
1262
1376
|
'average vs largest configured window')}
|
|
1263
1377
|
${kpi('Tokens / request', fmtNum(eff.tokens_per_request))}
|
|
1264
1378
|
${kpi('Cache hit ratio', ca.reads ? fmtPct(eff.cache_hit_ratio * 100) : null,
|
|
1265
|
-
'reads ÷ (reads + writes)', {badge: BADGE.actual})}
|
|
1379
|
+
'reads ÷ (reads + writes), by token', {badge: BADGE.actual})}
|
|
1266
1380
|
</div>
|
|
1381
|
+
${(() => {
|
|
1382
|
+
const cs = ca.cost_split;
|
|
1383
|
+
if (!cs || !cs.read_cost_share) return '';
|
|
1384
|
+
const mult = cs.write_vs_read_multiple, h1 = cs.write_1h_token_share;
|
|
1385
|
+
return card('Cache reads vs writes, by cost', `
|
|
1386
|
+
<p class="note" style="margin:0 0 10px">Writes are
|
|
1387
|
+
<strong>${fmtPct(100 * (1 - cs.read_token_share))}</strong> of your cache tokens but
|
|
1388
|
+
<strong>${fmtPct(100 * cs.write_cost_share)}</strong> of your cache cost${
|
|
1389
|
+
mult ? `, because a write token costs ${mult.toFixed(1)}x a read token` : ''}.
|
|
1390
|
+
The token ratio above is the flattering number; this is the one that moves the bill.</p>
|
|
1391
|
+
<dl class="kv">
|
|
1392
|
+
<dt>Cache reads</dt><dd>${fmtNum(cs.read_tokens)} tokens · ${fmtUSD(cs.read_cost_usd)}
|
|
1393
|
+
(${fmtPct(100 * cs.read_cost_share)} of cache cost)</dd>
|
|
1394
|
+
<dt>Cache writes</dt><dd>${fmtNum(cs.write_tokens)} tokens · ${fmtUSD(cs.write_cost_usd)}
|
|
1395
|
+
(${fmtPct(100 * cs.write_cost_share)} of cache cost)</dd>
|
|
1396
|
+
<dt>— 5m writes</dt><dd>${fmtNum(cs.write_5m_tokens)} tokens · ${fmtUSD(cs.write_5m_cost_usd)}</dd>
|
|
1397
|
+
<dt>— 1h writes</dt><dd>${fmtNum(cs.write_1h_tokens)} tokens · ${fmtUSD(cs.write_1h_cost_usd)}</dd>
|
|
1398
|
+
</dl>
|
|
1399
|
+
${h1 !== null && h1 >= 0.5 && cs.write_tokens ? `<div class="stack" style="margin-top:10px">
|
|
1400
|
+
<div class="item sev-low">
|
|
1401
|
+
<div class="hd">${fmtPct(100 * h1)} of your cache writes use the 1h TTL.</div>
|
|
1402
|
+
<div class="dt">A 1h write costs more per token than a 5m one, but that does not make
|
|
1403
|
+
it the wrong choice — whether it pays off depends on your real inter-turn gaps.
|
|
1404
|
+
The TTL comparison above replays them and answers it with arithmetic rather than
|
|
1405
|
+
a rule of thumb.</div></div>
|
|
1406
|
+
</div>` : ''}`, {badge: BADGE.estimated,
|
|
1407
|
+
footer: 'Component costs are re-derived per model from config/pricing.json, since a request stores one blended cost.'});
|
|
1408
|
+
})()}
|
|
1267
1409
|
${ctx.large_context_cost_pct > 0 ? `<div class="hero">
|
|
1268
1410
|
<h3>⚠️ Context warnings</h3>
|
|
1269
1411
|
<div class="stack">
|
|
@@ -1283,8 +1425,8 @@ VIEWS.context = async (page) => {
|
|
|
1283
1425
|
<div class="grid g3" style="gap:8px">
|
|
1284
1426
|
${kpi('Est. cost with caching', fmtUSD(ca.cost_with_cache), null, {small: 1, badge: BADGE.estimated})}
|
|
1285
1427
|
${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})}
|
|
1428
|
+
${kpi('Uncached counterfactual', fmtUSD(ca.uncached_counterfactual_delta_usd),
|
|
1429
|
+
`${fmtPct(ca.uncached_counterfactual_pct)} · not a saving`, {small: 1, badge: BADGE.estimated})}
|
|
1288
1430
|
</div>
|
|
1289
1431
|
<div class="chart" id="cachebar" style="margin-top:10px"></div>
|
|
1290
1432
|
<dl class="kv" style="margin-top:10px">
|
|
@@ -1322,224 +1464,6 @@ VIEWS.context = async (page) => {
|
|
|
1322
1464
|
wireTable($('#hs', page), ctx.heavy_sessions, r => openSession(r.session_id));
|
|
1323
1465
|
};
|
|
1324
1466
|
|
|
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
1467
|
/* ---------- waste ---------- */
|
|
1544
1468
|
VIEWS.waste = async (page) => {
|
|
1545
1469
|
const w = await api('waste');
|
|
@@ -1557,8 +1481,8 @@ VIEWS.waste = async (page) => {
|
|
|
1557
1481
|
</div>
|
|
1558
1482
|
<div class="note"><b>Exposed spend</b> is what the flagged items cost in total — money worth
|
|
1559
1483
|
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
|
-
|
|
1484
|
+
reasonable baseline, de-duplicated across rules. Each rule below states the baseline it
|
|
1485
|
+
measures against.</div>
|
|
1562
1486
|
${['high', 'medium', 'low'].map(sev => {
|
|
1563
1487
|
const list = w.findings.filter(f => f.severity === sev);
|
|
1564
1488
|
if (!list.length) return '';
|
|
@@ -1725,6 +1649,8 @@ VIEWS.live = async (page) => {
|
|
|
1725
1649
|
fetch('/api/live?agents=' + encodeURIComponent(S.filter.agents.join(','))).then(r => r.json()), // never cached: always live
|
|
1726
1650
|
fetch('/api/usage').then(r => r.json()).catch(e => ({ok: false, error: e.message}))]);
|
|
1727
1651
|
const list = res.sessions || [];
|
|
1652
|
+
const liveActions = res.actions || ['interrupt', 'close', 'kill'];
|
|
1653
|
+
const canInterrupt = liveActions.includes('interrupt');
|
|
1728
1654
|
const agentName = id => ((S.opts?.agents || []).find(a => a.id === id) || {}).name || id;
|
|
1729
1655
|
const busy = list.filter(x => x.status === 'busy');
|
|
1730
1656
|
const sevOf = x => x.severity === 'high' ? 'high' : x.severity === 'medium' ? 'medium' : 'low';
|
|
@@ -1758,15 +1684,11 @@ VIEWS.live = async (page) => {
|
|
|
1758
1684
|
<div class="dt">${x.status === 'busy' ? '<b>Working now</b>' : 'Idle'}${x.uptime ? ` · up ${esc(x.uptime)}` : ''} ·
|
|
1759
1685
|
last activity ${x.last_write_s == null ? '—' : dur(x.last_write_s)} ago${x.memory_mb == null ? '' : ` · ${fmtInt(x.memory_mb)} MB`} ·
|
|
1760
1686
|
<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
1687
|
${x.severity !== 'ok' ? `<div class="dt"><b>Advice:</b> ${x.severity === 'high'
|
|
1766
1688
|
? 'Very large context. Use <b>Hand over</b> to continue in a fresh session, or split the remaining work into sub-sessions.'
|
|
1767
1689
|
: 'Getting heavy. Hit <b>Compact</b> at the next break, or close it if the task is done.'}</div>` : ''}
|
|
1768
1690
|
<div class="live-actions">
|
|
1769
|
-
${x.signalable ? `<button class="act" data-a="interrupt" ${x.status !== 'busy' ? 'disabled title="Nothing running"' : ''}>⏸ Interrupt</button
|
|
1691
|
+
${x.signalable ? `${canInterrupt ? `<button class="act" data-a="interrupt" ${x.status !== 'busy' ? 'disabled title="Nothing running"' : ''}>⏸ Interrupt</button>` : ''}
|
|
1770
1692
|
${x.agent === 'claude' ? '<button class="act" data-a="compact" title="Types /compact into that session\'s terminal">🗜 Compact</button>' : ''}
|
|
1771
1693
|
<button class="act warn" data-a="close">⏹ Close session</button>
|
|
1772
1694
|
<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 +1824,10 @@ VIEWS.diagnose = async (page) => {
|
|
|
1902
1824
|
<div class="hd"><a href="#" class="sess-link" data-sess="${esc(x.session_id)}">${esc(x.title || shortId(x.session_id))}</a>
|
|
1903
1825
|
<span class="note">· ${esc(x.project)}</span><span class="spacer"></span>
|
|
1904
1826
|
<span style="font-variant-numeric:tabular-nums">peak ${fmtNum(x.peak)} · ${fmtUSD(x.cost)} ·
|
|
1905
|
-
~${
|
|
1827
|
+
~${fmtNum(x.tokens_above_100k)} tokens re-read above 100K (not a saving)</span></div>
|
|
1906
1828
|
${x.fixes.map(t => `<div class="dt">• ${esc(t)}</div>`).join('')}${pb(x.playbook)}</div>`).join('')
|
|
1907
1829
|
|| '<div class="empty">No session passed 150K context</div>'}</div>`,
|
|
1908
|
-
{badge: BADGE.recommendation, hint: '"Above baseline" = context re-read beyond 100K
|
|
1830
|
+
{badge: BADGE.recommendation, hint: '"Above baseline" = context re-read beyond 100K'})}
|
|
1909
1831
|
<div id="dx-mem"></div>${card(`Add to ${md}${isCl ? ' / memory' : ''} (from your past prompts)`, `<div class="stack">${d.memory_suggestions.map(x => `
|
|
1910
1832
|
<div class="item sev-${x.kind === 'security' ? 'high' : x.already_saved ? 'low' : 'medium'}">
|
|
1911
1833
|
<div class="hd">${x.kind === 'security' ? '🔐' : x.kind === 'template' ? '⚙' : x.kind === 'reference' ? '🔗' : '📝'}
|
|
@@ -1924,11 +1846,10 @@ VIEWS.diagnose = async (page) => {
|
|
|
1924
1846
|
{badge: BADGE.actual, hint: 'Drivers overlap, so shares do not add up to 100%'})}
|
|
1925
1847
|
<div id="dx-recs"></div>${card('What to change', `<div class="stack">${d.recommendations.map(r => `
|
|
1926
1848
|
<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>
|
|
1849
|
+
<div class="hd">${esc(r.title)}</div>
|
|
1929
1850
|
<div class="dt"><b>Why:</b> ${esc(r.why)}</div>
|
|
1930
1851
|
<div class="dt"><b>How:</b> ${esc(r.how)}</div>
|
|
1931
|
-
${
|
|
1852
|
+
${pb(r.playbook)}</div>`).join('')
|
|
1932
1853
|
|| '<div class="empty">Nothing stands out</div>'}</div>`, {badge: BADGE.recommendation})}
|
|
1933
1854
|
${card(`Projects: ${esc(d.vocab?.name || 'Claude')} config health`, `<div id="dx-proj"></div>`,
|
|
1934
1855
|
{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 +1940,6 @@ VIEWS.forecast = async (page) => {
|
|
|
2019
1940
|
return; }
|
|
2020
1941
|
page.innerHTML = `
|
|
2021
1942
|
<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
1943
|
${kpi('End of billing period', fmtUSD(f.scenarios.expected.end_of_period_cost),
|
|
2025
1944
|
`${f.remaining_days} days remaining`, {badge: BADGE.forecast})}
|
|
2026
1945
|
${kpi('Estimated monthly cost', fmtUSD(f.estimated_monthly_cost), null, {badge: BADGE.forecast})}
|
|
@@ -2034,22 +1953,26 @@ VIEWS.forecast = async (page) => {
|
|
|
2034
1953
|
: kpi('Limit exhaustion date', esc(f.limit_exhaustion_date),
|
|
2035
1954
|
f.will_exceed ? '🔴 forecast exceeds allowance' : '🟢 within allowance', {badge: BADGE.forecast})}
|
|
2036
1955
|
</div>
|
|
2037
|
-
${card('Cumulative spend and forecast fan',
|
|
2038
|
-
|
|
1956
|
+
${card('Cumulative spend and forecast fan', f.insufficient_history
|
|
1957
|
+
? '<div class="empty">Fewer than 7 priced days in the window: bands not shown.</div>'
|
|
1958
|
+
: '<div class="chart" id="fan2"></div><div class="legend" id="fl2"></div>',
|
|
1959
|
+
{badge: BADGE.forecast,
|
|
2039
1960
|
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.'})}
|
|
1961
|
+
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
1962
|
${card('Scenarios', table([
|
|
2042
1963
|
{h: 'Scenario', f: r => `<b>${esc(r[0])}</b>`},
|
|
2043
1964
|
{h: 'Daily rate', num: 1, f: r => fmtUSD(r[1].daily_rate)},
|
|
2044
1965
|
{h: 'Projected end of period', num: 1, f: r => fmtUSD(r[1].end_of_period_cost)},
|
|
2045
1966
|
{h: 'vs today', num: 1, f: r => '+' + fmtUSD(r[1].end_of_period_cost - f.period_used)},
|
|
2046
1967
|
], Object.entries(f.scenarios)), {badge: BADGE.forecast})}`;
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
1968
|
+
if (!f.insufficient_history) {
|
|
1969
|
+
C.forecastFan($('#fan2', page), {history: burn.series, scenarios: f.scenarios,
|
|
1970
|
+
remainingDays: f.remaining_days, height: 300});
|
|
1971
|
+
$('#fl2', page).innerHTML = `<span class="it"><span class="swatch" style="background:var(--s1)"></span>
|
|
1972
|
+
Cumulative actual (estimated cost)</span>
|
|
1973
|
+
<span class="it"><span class="swatch" style="background:var(--s1);opacity:.35"></span>
|
|
1974
|
+
Forecast band (conservative → high)</span>`;
|
|
1975
|
+
}
|
|
2053
1976
|
};
|
|
2054
1977
|
|
|
2055
1978
|
/* ---------- budgets ---------- */
|
|
@@ -2120,7 +2043,7 @@ VIEWS.budgets = async (page) => {
|
|
|
2120
2043
|
</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
2044
|
$('#savecfg', page).onclick = async () => {
|
|
2122
2045
|
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'},
|
|
2046
|
+
await fetch('/api/settings', {method: 'POST', headers: {'Content-Type': 'application/json', 'X-FinOps-Action': '1'},
|
|
2124
2047
|
body: JSON.stringify({
|
|
2125
2048
|
budgets: {monthly_usd: v('b-monthly'), daily_usd: v('b-daily'), monthly_tokens: v('b-tokens')},
|
|
2126
2049
|
limits: {monthly_cost_allowance_usd: v('l-cost'), monthly_token_allowance: v('l-tok'),
|
|
@@ -2144,7 +2067,7 @@ VIEWS.scorecard = async (page) => {
|
|
|
2144
2067
|
${card('AI FinOps Score', `<div class="scorewrap">
|
|
2145
2068
|
<div style="text-align:center">
|
|
2146
2069
|
<div class="scorenum">${sc.score}</div>
|
|
2147
|
-
<div class="scoregrade">out of 100
|
|
2070
|
+
<div class="scoregrade">out of 100</div>
|
|
2148
2071
|
<div class="chart" id="sg" style="width:190px;margin-top:6px"></div></div>
|
|
2149
2072
|
<div style="flex:1;min-width:280px" class="stack">${sc.dimensions.map(d => `
|
|
2150
2073
|
<div><div style="display:flex;justify-content:space-between;font-size:12px">
|
|
@@ -2154,7 +2077,7 @@ VIEWS.scorecard = async (page) => {
|
|
|
2154
2077
|
: d.score >= 30 ? 'approaching' : 'critical'}"><i style="width:${d.score}%"></i></div>
|
|
2155
2078
|
<div class="note">${esc(d.detail)}</div></div>`).join('')}
|
|
2156
2079
|
</div></div>`, {badge: BADGE.estimated,
|
|
2157
|
-
footer: '
|
|
2080
|
+
footer: 'Each dimension is measured from your transcripts; budget adherence needs a budget in Settings.'})}
|
|
2158
2081
|
<div class="grid g3">
|
|
2159
2082
|
${card('✅ What is good', `<ul style="margin:0 0 0 18px;font-size:12.5px">${
|
|
2160
2083
|
sc.what_is_good.map(x => `<li style="margin-bottom:6px">${esc(x)}</li>`).join('')
|
|
@@ -2317,8 +2240,8 @@ VIEWS.exports = async (page) => {
|
|
|
2317
2240
|
<div class="dt">Projected from history. Assumes recent patterns continue; it is not a
|
|
2318
2241
|
commitment or a quote.</div></div>
|
|
2319
2242
|
<div class="item"><div class="hd">${BADGE.recommendation} Recommendation</div>
|
|
2320
|
-
<div class="dt">
|
|
2321
|
-
|
|
2243
|
+
<div class="dt">A recommendation grounded in an observed share of spend. No saving is
|
|
2244
|
+
estimated.</div></div>
|
|
2322
2245
|
</div>
|
|
2323
2246
|
<h3 style="font-size:12.5px;margin:14px 0 6px">Deliberately not fabricated</h3>
|
|
2324
2247
|
<dl class="kv">
|
|
@@ -2341,7 +2264,7 @@ function focusItems(d) {
|
|
|
2341
2264
|
for (const m of d.memory_suggestions || []) if (m.kind === 'security')
|
|
2342
2265
|
out.push({lvl: 1, text: `Credentials pasted in prompts (${m.sessions} sessions): rotate them`, view: 'diagnose', anchor: 'dx-mem'});
|
|
2343
2266
|
for (const r of d.recommendations || []) if (r.priority === 1)
|
|
2344
|
-
out.push({lvl: 1, text: r.title
|
|
2267
|
+
out.push({lvl: 1, text: r.title, view: 'diagnose', anchor: 'dx-recs'});
|
|
2345
2268
|
for (const p of d.projects || []) for (const i of p.issues) if (i.severity === 'high')
|
|
2346
2269
|
out.push({lvl: 1, text: `${p.name}: ${i.title}`, view: 'diagnose', anchor: 'dx-issues'});
|
|
2347
2270
|
const s0 = (d.session_health || [])[0];
|
|
@@ -2491,7 +2414,9 @@ async function render() {
|
|
|
2491
2414
|
applyRange('30d');
|
|
2492
2415
|
// ?view=<name> opens straight to one screen, so a link (or a screenshot run)
|
|
2493
2416
|
// can point at a specific report rather than always landing on the overview.
|
|
2494
|
-
|
|
2417
|
+
let want = new URLSearchParams(location.search).get('view');
|
|
2418
|
+
// The repriced Model switch view was removed; its links now open the measured comparison.
|
|
2419
|
+
if (want === 'modelswitch') want = 'compare';
|
|
2495
2420
|
if (want && NAV.some(([, items]) => items.some(([id]) => id === want))) S.view = want;
|
|
2496
2421
|
await render();
|
|
2497
2422
|
if (!want) maybeFirstTour(); // arriving on a deep link is not a first visit
|
|
@@ -2624,7 +2549,7 @@ VIEWS.freemodels = async (page) => {
|
|
|
2624
2549
|
const d = await fetch('/api/free_models').then(r => r.json());
|
|
2625
2550
|
page.innerHTML = `
|
|
2626
2551
|
<div class="note">${esc(d.how_it_works)} Free models are weaker than Claude: use them for routine
|
|
2627
|
-
work (see <a data-go="
|
|
2552
|
+
work (see <a data-go="compare">Compare models</a> for what your own history shows) and keep Claude for hard problems.</div>
|
|
2628
2553
|
${!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
2554
|
won't run by name. Add <code>export PATH="$HOME/.local/bin:$PATH"</code> to ~/.zshrc.</div></div>` : ''}
|
|
2630
2555
|
${card('How to use and test a free model', `<div class="stack">
|
|
@@ -2715,6 +2640,7 @@ async function addFreeModel(id) {
|
|
|
2715
2640
|
/* ---------- compare Claude vs free models ---------- */
|
|
2716
2641
|
VIEWS.compare = async (page) => {
|
|
2717
2642
|
const d = await fetch('/api/compare?agents=' + encodeURIComponent(S.filter.agents.join(','))).then(r => r.json());
|
|
2643
|
+
if (d.error) throw new Error(d.error);
|
|
2718
2644
|
const hasFree = d.rows.some(r => r.kind === 'free');
|
|
2719
2645
|
const stars = n => n == null ? '<span class="na">—</span>' : '★'.repeat(Math.floor(n)) + (n % 1 ? '½' : '') +
|
|
2720
2646
|
`<span style="opacity:.25">${'★'.repeat(5 - Math.ceil(n))}</span>`;
|
|
@@ -2727,7 +2653,7 @@ VIEWS.compare = async (page) => {
|
|
|
2727
2653
|
hint: hasFree ? `This machine: ${esc(d.os)}, ${d.ram_gb} GB RAM` : 'List prices, your usage in range', footer: esc(d.note)})}
|
|
2728
2654
|
${!hasFree ? '' : `<div class="grid g3">
|
|
2729
2655
|
${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="
|
|
2656
|
+
Sonnet is the value pick; keep Opus for the hardest problems (see <a data-go="compare">Compare models</a>).</div>`)}
|
|
2731
2657
|
${card('Use a free model for', `<div class="dt">Offline or private work, throwaway snippets, explanations,
|
|
2732
2658
|
single-file edits. Cloud Qwen is the strongest free option; locally, pick the biggest one that fits your RAM.</div>`)}
|
|
2733
2659
|
${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 +2851,13 @@ const TOURS = {
|
|
|
2925
2851
|
{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
2852
|
{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
2853
|
{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
|
|
2854
|
+
{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
2855
|
{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
|
|
2856
|
+
{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
2857
|
advisor: [
|
|
2932
|
-
{el: 'card:Biggest optimization opportunity', t: 'Start here', see: 'The
|
|
2858
|
+
{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
2859
|
{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
|
|
2860
|
+
{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
2861
|
{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
2862
|
agents: [
|
|
2937
2863
|
{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 +2872,7 @@ const TOURS = {
|
|
|
2946
2872
|
{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
2873
|
{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
2874
|
{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
|
|
2875
|
+
{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
2876
|
usage: [
|
|
2951
2877
|
{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
2878
|
{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 +2891,7 @@ const TOURS = {
|
|
|
2965
2891
|
context: [
|
|
2966
2892
|
{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
2893
|
{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: '
|
|
2894
|
+
{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
2895
|
{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
2896
|
{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
2897
|
projects: [
|
|
@@ -2988,7 +2914,7 @@ const TOURS = {
|
|
|
2988
2914
|
categories: [
|
|
2989
2915
|
{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
2916
|
{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
|
|
2917
|
+
{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
2918
|
developer: [
|
|
2993
2919
|
{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
2920
|
{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 +2941,14 @@ const TOURS = {
|
|
|
3015
2941
|
{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
2942
|
{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
2943
|
{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.'}],
|
|
2944
|
+
hygiene: [
|
|
2945
|
+
{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.'},
|
|
2946
|
+
{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
2947
|
waste: [
|
|
3019
2948
|
{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
2949
|
{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
|
|
2950
|
+
{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
2951
|
{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
2952
|
freemodels: [
|
|
3029
2953
|
{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
2954
|
{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 +2961,9 @@ const TOURS = {
|
|
|
3037
2961
|
{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
2962
|
{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
2963
|
recommendations: [
|
|
3040
|
-
{el: 'card:Biggest optimization opportunity', t: 'Start here', see: 'The change with the
|
|
2964
|
+
{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
2965
|
{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
|
|
2966
|
+
{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
2967
|
{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
2968
|
anomalies: [
|
|
3045
2969
|
{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.'},
|