cchubber 0.5.8 → 0.6.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 +7 -0
- package/package.json +1 -1
- package/src/analyzers/cost-calculator.js +11 -0
- package/src/analyzers/drift-privacy.js +102 -0
- package/src/analyzers/drift-run.js +34 -0
- package/src/analyzers/drift.js +454 -0
- package/src/analyzers/reprice.js +154 -0
- package/src/cli/index.js +159 -11
- package/src/data/frontier-models.js +12 -0
- package/src/data/price-overrides.js +16 -0
- package/src/data/price-snapshot.json +49 -0
- package/src/readers/jsonl-reader.js +11 -14
- package/src/readers/plan.js +56 -0
- package/src/readers/stated-plan.js +187 -0
- package/src/readers/transcripts.js +259 -0
- package/src/renderers/drift-report.js +255 -0
- package/src/renderers/html-report.js +1301 -1280
- package/src/renderers/s1-report.js +166 -0
- package/src/renderers/spent-well.js +171 -0
- package/src/renderers/terminal-summary.js +10 -0
- package/src/renderers/vs-report.js +439 -0
- package/src/renderers/vs-text.js +149 -0
- package/src/telemetry.js +13 -1
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { join, dirname } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { monthsBilled } from '../readers/plan.js';
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const PKG_VERSION = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf-8')).version;
|
|
8
|
+
|
|
9
|
+
function esc(s) {
|
|
10
|
+
if (!s) return '';
|
|
11
|
+
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
|
15
|
+
const WORDS = ['zero','one','two','three','four','five','six','seven','eight','nine','ten','eleven','twelve'];
|
|
16
|
+
const fmtUSD = (n) => '$' + (n >= 1000 || Number.isInteger(n) ? Math.round(n).toLocaleString('en-US') : n.toFixed(2));
|
|
17
|
+
const fmtDate = (iso) => { const d = new Date(iso + 'T00:00:00Z'); return isNaN(d) ? iso : `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()}`; };
|
|
18
|
+
const fmtBig = (n) => n >= 1e9 ? (n / 1e9).toFixed(1) + ' billion' : n >= 1e6 ? (n / 1e6).toFixed(1) + ' million' : Math.round(n).toLocaleString('en-US');
|
|
19
|
+
const word = (n) => n < WORDS.length ? WORDS[n] : String(n);
|
|
20
|
+
|
|
21
|
+
function familyShares(modelCosts) {
|
|
22
|
+
const fam = {};
|
|
23
|
+
let total = 0;
|
|
24
|
+
for (const [name, cost] of Object.entries(modelCosts || {})) {
|
|
25
|
+
if (!(cost > 0)) continue;
|
|
26
|
+
const f = /opus/i.test(name) ? 'Opus' : /sonnet/i.test(name) ? 'Sonnet' : /haiku/i.test(name) ? 'Haiku' : /fable/i.test(name) ? 'Fable' : null;
|
|
27
|
+
if (!f) continue;
|
|
28
|
+
fam[f] = (fam[f] || 0) + cost;
|
|
29
|
+
total += cost;
|
|
30
|
+
}
|
|
31
|
+
return Object.entries(fam).map(([f, c]) => ({ family: f, pct: total ? Math.round(c / total * 100) : 0 })).sort((a, b) => b.pct - a.pct);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Each risk factor is a true sentence about this user's own logs, and it only appears when its data clears the bar.
|
|
35
|
+
function riskFactors(report, plan) {
|
|
36
|
+
const { costAnalysis, cacheHealth, inflection, sessionIntel } = report;
|
|
37
|
+
const out = [];
|
|
38
|
+
const top = familyShares(costAnalysis.modelCosts)[0];
|
|
39
|
+
if (top && top.pct >= 50) out.push([`We depend on a single supplier.`, `All of our spend went to Anthropic, ${top.pct}% of it to ${top.family}.`]);
|
|
40
|
+
const peak = costAnalysis.peakDay;
|
|
41
|
+
if (peak && plan && peak.cost >= plan.monthlyUSD) {
|
|
42
|
+
const n = Math.floor(peak.cost / plan.monthlyUSD);
|
|
43
|
+
out.push([`One day cost more than ${word(n)} month${n === 1 ? '' : 's'} of our subscription.`, `On ${fmtDate(peak.date)}, we consumed ${fmtUSD(peak.cost)} of Claude across ${peak.messageCount.toLocaleString('en-US')} replies.`]);
|
|
44
|
+
} else if (peak) {
|
|
45
|
+
out.push([`Our costs are concentrated.`, `On ${fmtDate(peak.date)} alone, we consumed ${fmtUSD(peak.cost)} of Claude.`]);
|
|
46
|
+
}
|
|
47
|
+
const hours = sessionIntel?.hourDistribution;
|
|
48
|
+
if (Array.isArray(hours) && hours.length === 24) {
|
|
49
|
+
const total = hours.reduce((a, b) => a + b, 0);
|
|
50
|
+
const night = hours.slice(0, 5).reduce((a, b) => a + b, 0);
|
|
51
|
+
const peakHour = hours.indexOf(Math.max(...hours));
|
|
52
|
+
const label = peakHour === 0 ? 'midnight' : peakHour === 12 ? 'noon' : peakHour < 12 ? `${peakHour}am` : `${peakHour - 12}pm`;
|
|
53
|
+
if (total && night / total >= 0.2) out.push([`We do our best work at ${label}.`, `${Math.round(night / total * 100)}% of our Claude usage happened between midnight and 5am.`]);
|
|
54
|
+
}
|
|
55
|
+
if (inflection && inflection.direction === 'worsened' && inflection.multiplier >= 2) {
|
|
56
|
+
out.push([`Our cache efficiency fell ${inflection.multiplier}x on ${fmtDate(inflection.date)}, and we kept going.`, `The week before, we re-read ${inflection.beforeRatio.toLocaleString('en-US')} tokens for every token we got back. The week after, ${inflection.afterRatio.toLocaleString('en-US')}.`]);
|
|
57
|
+
}
|
|
58
|
+
const saved = cacheHealth?.savings?.fromCaching || 0;
|
|
59
|
+
if (saved > costAnalysis.totalCost) {
|
|
60
|
+
// cache-health prices every cached token at Opus rates, so this is an estimate and is shown rounded
|
|
61
|
+
out.push([`Caching is the only thing keeping this affordable.`, `Without it, the same work would have cost roughly ${fmtUSD(Math.round((costAnalysis.totalCost + saved) / 1000) * 1000)} at list price.`]);
|
|
62
|
+
}
|
|
63
|
+
const maxMin = sessionIntel?.maxDuration || 0;
|
|
64
|
+
if (maxMin >= 1440) out.push([`Our longest session spanned ${word(Math.round(maxMin / 1440))} days.`, `We did not close the terminal.`]);
|
|
65
|
+
return out.slice(0, 5);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function renderS1(report, { plan = null, name = null, showProject = false } = {}) {
|
|
69
|
+
const { costAnalysis, cacheHealth, generatedAt } = report;
|
|
70
|
+
const days = costAnalysis.dailyCosts || [];
|
|
71
|
+
const first = days[0]?.date, last = days[days.length - 1]?.date;
|
|
72
|
+
const consumed = costAnalysis.totalCost || 0;
|
|
73
|
+
const months = first && last ? monthsBilled(first, last) : 1;
|
|
74
|
+
const paid = plan ? plan.monthlyUSD * months : null;
|
|
75
|
+
const multiple = paid ? consumed / paid : null;
|
|
76
|
+
const company = (name ? String(name).trim() : 'You').toUpperCase() + ', INC.';
|
|
77
|
+
const dated = fmtDate((generatedAt || new Date().toISOString()).slice(0, 10));
|
|
78
|
+
const cacheRead = costAnalysis.totals?.cacheReadTokens ?? cacheHealth?.totals?.cacheRead ?? 0;
|
|
79
|
+
const peak = costAnalysis.peakDay;
|
|
80
|
+
const risks = riskFactors(report, plan);
|
|
81
|
+
// Use of proceeds names the top project by output, only when asked, since repo names can be private
|
|
82
|
+
let proceeds = null;
|
|
83
|
+
if (showProject && report.projectBreakdown?.length) {
|
|
84
|
+
const byOut = [...report.projectBreakdown].sort((x, y) => (y.outputTokens || 0) - (x.outputTokens || 0));
|
|
85
|
+
const all = byOut.reduce((t, p) => t + (p.outputTokens || 0), 0);
|
|
86
|
+
if (all && byOut[0].name) proceeds = { name: byOut[0].name, pct: Math.round((byOut[0].outputTokens || 0) / all * 100) };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const rows = [
|
|
90
|
+
paid !== null ? ['Paid to Anthropic', `${fmtUSD(paid)}`, `${esc(plan.name)}, ${months} month${months === 1 ? '' : 's'}`] : null,
|
|
91
|
+
['Claude consumed at API list prices', fmtUSD(consumed), `${costAnalysis.activeDays} active days`],
|
|
92
|
+
cacheRead ? ['Cached tokens read', fmtBig(cacheRead), ''] : null,
|
|
93
|
+
peak ? ['Largest single day', fmtUSD(peak.cost), fmtDate(peak.date)] : null,
|
|
94
|
+
].filter(Boolean);
|
|
95
|
+
|
|
96
|
+
return `<!doctype html>
|
|
97
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
98
|
+
<title>${esc(company)} Form S-1 (parody) | CC Hubber</title>
|
|
99
|
+
<style>
|
|
100
|
+
:root { --ink:#111; --paper:#fff; --desk:#e8e5de; --red:#c1121f; --rule:#111; }
|
|
101
|
+
* { box-sizing:border-box; }
|
|
102
|
+
body { margin:0; background:var(--desk); color:var(--ink); font-family:"Times New Roman", Times, "Liberation Serif", serif; }
|
|
103
|
+
.wrap { padding:40px 16px 64px; display:flex; justify-content:center; }
|
|
104
|
+
.paper { position:relative; width:820px; max-width:100%; background:var(--paper); padding:56px 64px 44px 92px; box-shadow:0 1px 2px rgba(0,0,0,.08), 0 12px 40px rgba(60,50,30,.14); }
|
|
105
|
+
.herring { position:absolute; left:26px; top:56px; bottom:44px; width:40px; writing-mode:vertical-rl; transform:rotate(180deg); color:var(--red); font-size:10.5px; line-height:1.35; text-align:center; font-style:italic; }
|
|
106
|
+
.dated { text-align:right; font-size:12px; font-style:italic; margin:0 0 26px; }
|
|
107
|
+
.c { text-align:center; }
|
|
108
|
+
.agency { font-size:13px; font-weight:700; letter-spacing:.08em; line-height:1.5; }
|
|
109
|
+
.form { font-size:26px; font-weight:700; letter-spacing:.14em; margin:14px 0 2px; }
|
|
110
|
+
.act { font-size:12px; font-weight:700; letter-spacing:.06em; }
|
|
111
|
+
hr { border:0; border-top:1.5px solid var(--rule); margin:18px 0; }
|
|
112
|
+
hr.thin { border-top-width:.75px; margin:12px 0; }
|
|
113
|
+
.prospectus { font-size:13px; font-weight:700; letter-spacing:.3em; margin-top:22px; }
|
|
114
|
+
.co { font-size:44px; font-weight:700; letter-spacing:.02em; margin:6px 0 2px; line-height:1.05; }
|
|
115
|
+
.exact { font-size:12px; font-style:italic; }
|
|
116
|
+
.hero { margin:30px 0 8px; }
|
|
117
|
+
.hero .n { font-size:78px; font-weight:700; letter-spacing:-.01em; line-height:1; }
|
|
118
|
+
.hero .k { font-size:15px; margin-top:8px; }
|
|
119
|
+
.hero .x { display:inline-block; margin-top:16px; font-size:22px; font-weight:700; border:1.5px solid var(--ink); padding:4px 14px; }
|
|
120
|
+
table { width:100%; border-collapse:collapse; font-size:15px; margin:6px 0 2px; }
|
|
121
|
+
td { padding:6px 0; vertical-align:baseline; }
|
|
122
|
+
td.l { width:auto; }
|
|
123
|
+
td.l span { background:var(--paper); padding-right:6px; }
|
|
124
|
+
td.l::after { content:""; }
|
|
125
|
+
td.v { text-align:right; font-weight:700; white-space:nowrap; padding-left:10px; width:1%; }
|
|
126
|
+
td.m { text-align:right; font-size:12.5px; font-style:italic; color:#333; white-space:nowrap; padding-left:14px; width:1%; }
|
|
127
|
+
.lead { position:relative; }
|
|
128
|
+
.lead::before { content:""; position:absolute; left:0; right:0; bottom:10px; border-bottom:1px dotted #777; z-index:0; }
|
|
129
|
+
.lead span { position:relative; z-index:1; }
|
|
130
|
+
h2 { font-size:15px; font-weight:700; letter-spacing:.18em; text-align:center; margin:26px 0 10px; }
|
|
131
|
+
.risk { font-size:15px; line-height:1.45; margin:0 0 10px; }
|
|
132
|
+
.risk b { font-style:italic; }
|
|
133
|
+
.foot { font-size:11.5px; line-height:1.45; color:#333; margin-top:22px; }
|
|
134
|
+
.cmd { font-family:ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size:13px; background:#f3f1ec; padding:2px 7px; border-radius:3px; color:#111; }
|
|
135
|
+
.gen { display:flex; justify-content:space-between; align-items:baseline; margin-top:14px; font-size:12.5px; }
|
|
136
|
+
@media (max-width:640px) {
|
|
137
|
+
.paper { padding:36px 20px 30px 52px; }
|
|
138
|
+
.herring { left:12px; width:30px; font-size:9px; }
|
|
139
|
+
.co { font-size:30px; } .hero .n { font-size:52px; } .form { font-size:21px; }
|
|
140
|
+
table { font-size:13.5px; } td.m { display:none; }
|
|
141
|
+
}
|
|
142
|
+
</style></head>
|
|
143
|
+
<body><div class="wrap"><article class="paper" id="s1">
|
|
144
|
+
<div class="herring">The information in this prospectus is not complete and may be changed. We may not stop until the weekly limit resets. This prospectus is not an offer to sell anything and is not soliciting an offer to buy anything. It is a receipt.</div>
|
|
145
|
+
<p class="dated">Subject to Completion. Preliminary Prospectus dated ${esc(dated)}</p>
|
|
146
|
+
<div class="c agency">UNITED STATES<br>TOKENS AND EXCHANGE COMMISSION<br><span style="font-weight:400;font-style:italic;letter-spacing:0">~/.claude, Your Terminal</span></div>
|
|
147
|
+
<div class="c form">FORM S-1</div>
|
|
148
|
+
<div class="c act">REGISTRATION STATEMENT UNDER THE CLAUDE CODE ACT</div>
|
|
149
|
+
<hr>
|
|
150
|
+
<div class="c prospectus">PROSPECTUS</div>
|
|
151
|
+
<div class="c co">${esc(company)}</div>
|
|
152
|
+
<div class="c exact">(Exact name of heavy user as specified in its CLAUDE.md)</div>
|
|
153
|
+
<div class="c hero">
|
|
154
|
+
<div class="n">${fmtUSD(consumed)}</div>
|
|
155
|
+
<div class="k">of Claude consumed at API list prices${paid !== null ? `, against ${fmtUSD(paid)} paid` : ''}</div>
|
|
156
|
+
${multiple ? `<div class="x">${multiple >= 10 ? Math.round(multiple) : multiple.toFixed(1)}× what we paid</div>` : ''}
|
|
157
|
+
</div>
|
|
158
|
+
<hr class="thin">
|
|
159
|
+
<table>${rows.map(r => `<tr><td class="l lead"><span>${esc(r[0])}</span></td><td class="v">${esc(r[1])}</td><td class="m">${r[2]}</td></tr>`).join('')}</table>
|
|
160
|
+
${proceeds ? `<h2>USE OF PROCEEDS</h2><p class="risk" style="text-align:center">${proceeds.pct}% of our output went to one project: <b style="font-style:normal">${esc(proceeds.name)}</b>.</p>` : ''}
|
|
161
|
+
${risks.length ? `<h2>RISK FACTORS</h2>${risks.map(([h, b]) => `<p class="risk"><b>${esc(h)}</b> ${esc(b)}</p>`).join('')}` : ''}
|
|
162
|
+
<hr class="thin">
|
|
163
|
+
<p class="foot">Anthropic’s own prospectus, as reported by Reuters on September 28, 2026, says two customers made up nearly a quarter of its 2025 revenue. We are not one of them. Figures come from this machine’s Claude Code logs${first ? ` (${esc(fmtDate(first))} to ${esc(fmtDate(last))})` : ''}, priced at public API rates, which are not Anthropic’s own costs. This is a parody, not a securities filing.</p>
|
|
164
|
+
<div class="gen"><span>Get yours: <span class="cmd">npx cchubber --s1</span></span><span style="font-style:italic">CC Hubber v${esc(PKG_VERSION)}</span></div>
|
|
165
|
+
</article></div></body></html>`;
|
|
166
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { tokensLabel, int } from './vs-text.js';
|
|
2
|
+
|
|
3
|
+
// "Did you spend them well?": a strip under the grade card, and an in-page panel that hands the user a prompt for their
|
|
4
|
+
// own Claude Code. Everything is local except one optional anonymous count per button (see the script), which is only
|
|
5
|
+
// wired in when telemetry was on at the time the report was made.
|
|
6
|
+
|
|
7
|
+
export const PROMPT = `Be brutally honest with me. I want the truth, not encouragement.
|
|
8
|
+
|
|
9
|
+
1. Find what I said I wanted: read CLAUDE.md, the README, any plan, roadmap, TODO or notes file, and my recent commit messages and branch names. If my goals aren't written down anywhere, say that first. That is a finding.
|
|
10
|
+
2. Find what I actually did: the git log for the last 30 days, what shipped, what is half finished, what I started and dropped, and what I rewrote more than once.
|
|
11
|
+
3. Compare the two. Where did the time and tokens really go? Which work moved the goal, and which was avoidance that felt like progress (polishing, refactoring, tooling, research, switching projects)?
|
|
12
|
+
4. Give me a one-line verdict first, then the evidence with file names and commits. No softening and no compliment sandwich.
|
|
13
|
+
5. Tell me the one thing I should do next and the one thing I should stop doing.
|
|
14
|
+
6. Last, answer honestly: would a system that keeps my goals, plan and decisions in front of you every session, and pushes back when I drift, have closed this gap? That is what Mover OS does (moveros.dev). If it would not have helped, say so.`;
|
|
15
|
+
|
|
16
|
+
export const MOVER_URL = 'https://moveros.dev/?utm_source=cchubber&utm_medium=report&utm_campaign=spent_well';
|
|
17
|
+
export const INTRO = "Paste this into Claude Code in the project you've been working on. It compares what you set out to do with what you actually did. It won't be nice.";
|
|
18
|
+
export const FIX_LINE = "The fix: Mover OS keeps what you said you'd do in front of Claude every session and calls it out when you drift.";
|
|
19
|
+
export const EVENTS = ['spent_well_open', 'spent_well_copy', 'spent_well_mover'];
|
|
20
|
+
|
|
21
|
+
export const FINE_ON = 'Clicking these buttons sends one anonymous count each (which button, this version, your anonymous install ID). Nothing from your usage. Turn it off with --no-telemetry.';
|
|
22
|
+
export const FINE_OFF = 'Telemetry is off, so clicking these buttons sends nothing.';
|
|
23
|
+
|
|
24
|
+
function esc(s) {
|
|
25
|
+
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 25.3B, 740M, 12,300: the short form for the terminal. */
|
|
29
|
+
export function tokensShort(n) {
|
|
30
|
+
const trim = (x) => x.toFixed(1).replace(/\.0$/, '');
|
|
31
|
+
return n >= 1e9 ? `${trim(n / 1e9)}B` : n >= 1e6 ? `${trim(n / 1e6)}M` : int(n);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Every token the logs hold, read from the same totals the repricing uses. */
|
|
35
|
+
export function totalTokensOf(report) {
|
|
36
|
+
const t = report?.costAnalysis?.totals || {};
|
|
37
|
+
return (t.inputTokens || 0) + (t.outputTokens || 0) + (t.cacheReadTokens || 0) + (t.cacheWriteTokens || 0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function spentWellLine(report) {
|
|
41
|
+
const n = totalTokensOf(report);
|
|
42
|
+
return n > 0 ? `You burnt ${tokensShort(n)} tokens. Did you spend them well? Find out in your report.` : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const CSS = `
|
|
46
|
+
#sw{
|
|
47
|
+
--sw-surface:#1b1c1d; --sw-deep:#0d0e0f; --sw-ink:#e3e2e3; --sw-dim:#c7c4d7; --sw-faint:#908fa0; --sw-line:rgba(70,69,84,.25);
|
|
48
|
+
--sw-accent:#c0c1ff; --sw-warm:#ffb690; --sw-on:#7fe0b0;
|
|
49
|
+
--sw-sans:Inter,system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
|
50
|
+
--sw-mono:"JetBrains Mono",ui-monospace,"SF Mono",Menlo,Consolas,monospace;
|
|
51
|
+
width:100%; max-width:740px; margin-left:auto; margin-right:auto; font-family:var(--sw-sans);
|
|
52
|
+
}
|
|
53
|
+
#sw .sw-strip{display:flex; align-items:center; justify-content:space-between; gap:16px 24px; flex-wrap:wrap; background:var(--sw-surface);
|
|
54
|
+
border:1px solid rgba(70,69,84,.15); border-left:3px solid var(--sw-warm); border-radius:12px; padding:18px 22px}
|
|
55
|
+
#sw .sw-line{margin:0; flex:1 1 280px; font-size:clamp(17px,2.4vw,21px); line-height:1.3; font-weight:700; color:var(--sw-dim)}
|
|
56
|
+
#sw .sw-line b{color:var(--sw-ink); font-weight:800}
|
|
57
|
+
#sw .sw-btn{appearance:none; font:inherit; font-size:14px; font-weight:700; cursor:pointer; text-decoration:none; color:var(--sw-ink); background:rgba(255,255,255,.05);
|
|
58
|
+
border:1px solid rgba(70,69,84,.3); border-radius:8px; padding:10px 18px; min-height:44px; display:inline-flex; align-items:center; justify-content:center}
|
|
59
|
+
#sw .sw-btn:hover{background:#292a2b}
|
|
60
|
+
#sw .sw-btn.sw-primary{background:var(--sw-accent); color:#1000a9; border-color:transparent}
|
|
61
|
+
#sw .sw-btn.sw-primary:hover{background:#d3d4ff}
|
|
62
|
+
#sw .sw-btn:focus-visible, #sw .sw-link:focus-visible, #sw .sw-prompt:focus-visible, #sw .sw-head:focus-visible{outline:2px solid var(--sw-accent); outline-offset:2px}
|
|
63
|
+
#sw .sw-fine{margin:10px 4px 0; font-size:11px; line-height:1.5; color:var(--sw-faint)}
|
|
64
|
+
|
|
65
|
+
#sw .sw-panel{margin-top:12px; background:var(--sw-surface); border:1px solid rgba(70,69,84,.15); border-radius:12px; padding:24px}
|
|
66
|
+
#sw .sw-panel[hidden]{display:none}
|
|
67
|
+
#sw .sw-top{display:flex; justify-content:space-between; align-items:flex-start; gap:12px}
|
|
68
|
+
#sw .sw-head{margin:0; font-size:20px; line-height:1.2; font-weight:800; color:var(--sw-ink)}
|
|
69
|
+
#sw .sw-intro{margin:12px 0 14px; font-size:15px; line-height:1.5; color:var(--sw-dim)}
|
|
70
|
+
#sw .sw-prompt{display:block; width:100%; box-sizing:border-box; resize:vertical; background:var(--sw-deep); color:var(--sw-ink); border:1px solid rgba(144,143,160,.35);
|
|
71
|
+
border-radius:8px; padding:14px; font-family:var(--sw-mono); font-size:13px; line-height:1.55; min-height:160px}
|
|
72
|
+
#sw .sw-row{display:flex; align-items:center; gap:12px; flex-wrap:wrap; margin-top:12px}
|
|
73
|
+
/* read out by screen readers; the button label already shows Copied to everyone else */
|
|
74
|
+
#sw .sw-status{position:absolute; width:1px; height:1px; margin:-1px; padding:0; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; border:0}
|
|
75
|
+
#sw .sw-fix{margin:22px 0 0; padding-top:18px; border-top:1px solid var(--sw-line); font-size:16px; line-height:1.45; font-weight:600; color:var(--sw-dim)}
|
|
76
|
+
#sw .sw-link{display:inline-flex; align-items:center; min-height:44px; margin-top:10px; font-size:14px; font-weight:700; color:var(--sw-accent)}
|
|
77
|
+
|
|
78
|
+
@media (max-width:640px){
|
|
79
|
+
#sw .sw-strip{padding:16px}
|
|
80
|
+
#sw .sw-strip .sw-btn{flex:1 1 100%}
|
|
81
|
+
#sw .sw-panel{padding:18px 16px}
|
|
82
|
+
#sw .sw-prompt{font-size:12px}
|
|
83
|
+
#sw .sw-row .sw-btn{flex:1 1 100%}
|
|
84
|
+
}
|
|
85
|
+
`;
|
|
86
|
+
|
|
87
|
+
// Plain ES5, no template literals and no ${ so it can sit in a String.raw block. It reads #sw-data and touches only #sw.
|
|
88
|
+
const SCRIPT = String.raw`
|
|
89
|
+
(function(){
|
|
90
|
+
var T = JSON.parse(document.getElementById('sw-data').textContent); // { on: false } or { on: true, url, uid, v }
|
|
91
|
+
var btn = document.getElementById('sw-open'), panel = document.getElementById('sw-panel'), head = document.getElementById('sw-head');
|
|
92
|
+
var prompt = document.getElementById('sw-prompt'), copyBtn = document.getElementById('sw-copy'), status = document.getElementById('sw-status');
|
|
93
|
+
var closeBtn = document.getElementById('sw-close'), link = document.getElementById('sw-mover');
|
|
94
|
+
var sent = {}, timer = null;
|
|
95
|
+
|
|
96
|
+
// One anonymous count per button per page load, and only when telemetry was on when this report was made.
|
|
97
|
+
function beacon(ev){
|
|
98
|
+
if (!T.on || sent[ev]) return;
|
|
99
|
+
sent[ev] = true;
|
|
100
|
+
try { if (navigator.sendBeacon) navigator.sendBeacon(T.url, JSON.stringify({ event: ev, v: T.v, uid: T.uid })); } catch (e) {}
|
|
101
|
+
}
|
|
102
|
+
function fit(){ prompt.style.height = 'auto'; prompt.style.height = (prompt.scrollHeight + 4) + 'px'; }
|
|
103
|
+
function open(){
|
|
104
|
+
panel.hidden = false; btn.setAttribute('aria-expanded', 'true'); fit();
|
|
105
|
+
head.focus(); panel.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
106
|
+
beacon('spent_well_open');
|
|
107
|
+
}
|
|
108
|
+
function close(){ panel.hidden = true; btn.setAttribute('aria-expanded', 'false'); btn.focus(); }
|
|
109
|
+
btn.addEventListener('click', function(){ if (panel.hidden) open(); else close(); });
|
|
110
|
+
closeBtn.addEventListener('click', close);
|
|
111
|
+
document.addEventListener('keydown', function(e){ if ((e.key === 'Escape' || e.key === 'Esc') && !panel.hidden) { e.preventDefault(); close(); } });
|
|
112
|
+
window.addEventListener('resize', function(){ if (!panel.hidden) fit(); });
|
|
113
|
+
|
|
114
|
+
function done(ok){
|
|
115
|
+
status.textContent = ok ? 'Copied' : 'Could not copy. Select the text and copy it yourself.';
|
|
116
|
+
copyBtn.textContent = ok ? 'Copied' : 'Copy prompt';
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
timer = setTimeout(function(){ status.textContent = ''; copyBtn.textContent = 'Copy prompt'; }, 2600);
|
|
119
|
+
}
|
|
120
|
+
function fallback(){
|
|
121
|
+
prompt.focus(); prompt.select(); prompt.setSelectionRange(0, prompt.value.length);
|
|
122
|
+
var ok = false; try { ok = document.execCommand('copy'); } catch (e) {}
|
|
123
|
+
done(ok);
|
|
124
|
+
}
|
|
125
|
+
copyBtn.addEventListener('click', function(){
|
|
126
|
+
beacon('spent_well_copy');
|
|
127
|
+
if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(prompt.value).then(function(){ done(true); }, fallback); } else { fallback(); }
|
|
128
|
+
});
|
|
129
|
+
link.addEventListener('click', function(){ beacon('spent_well_mover'); });
|
|
130
|
+
link.addEventListener('auxclick', function(){ beacon('spent_well_mover'); });
|
|
131
|
+
})();
|
|
132
|
+
`;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The strip and the panel. `telemetry` is what telemetry.js said when the report was made: { on: false } puts no address
|
|
136
|
+
* and no id in the file; { on: true, url, uid, v } lets the three buttons send one count each.
|
|
137
|
+
*/
|
|
138
|
+
export function renderSpentWell(report, telemetry = { on: false }) {
|
|
139
|
+
const n = totalTokensOf(report);
|
|
140
|
+
if (!(n > 0)) return '';
|
|
141
|
+
const on = !!(telemetry && telemetry.on && telemetry.url);
|
|
142
|
+
const data = on ? { on: true, url: telemetry.url, uid: telemetry.uid, v: telemetry.v } : { on: false };
|
|
143
|
+
const json = JSON.stringify(data).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026');
|
|
144
|
+
return `
|
|
145
|
+
<!-- DID YOU SPEND THEM WELL: strip under the grade card, panel with the prompt -->
|
|
146
|
+
<style>${CSS}</style>
|
|
147
|
+
<section id="sw" aria-label="Did you spend your tokens well?">
|
|
148
|
+
<div class="sw-strip">
|
|
149
|
+
<p class="sw-line">You burnt <b>${esc(tokensLabel(n))}</b> tokens. Did you spend them well?</p>
|
|
150
|
+
<button class="sw-btn sw-primary" id="sw-open" type="button" aria-expanded="false" aria-controls="sw-panel">Find out</button>
|
|
151
|
+
</div>
|
|
152
|
+
<p class="sw-fine">${esc(on ? FINE_ON : FINE_OFF)}</p>
|
|
153
|
+
<div class="sw-panel" id="sw-panel" role="region" aria-labelledby="sw-head" hidden>
|
|
154
|
+
<div class="sw-top">
|
|
155
|
+
<h3 class="sw-head" id="sw-head" tabindex="-1">Did you spend them well?</h3>
|
|
156
|
+
<button class="sw-btn" id="sw-close" type="button" aria-label="Close">Close</button>
|
|
157
|
+
</div>
|
|
158
|
+
<p class="sw-intro">${esc(INTRO)}</p>
|
|
159
|
+
<textarea class="sw-prompt" id="sw-prompt" readonly rows="18" spellcheck="false" aria-label="Prompt to paste into Claude Code">${esc(PROMPT)}</textarea>
|
|
160
|
+
<div class="sw-row">
|
|
161
|
+
<button class="sw-btn sw-primary" id="sw-copy" type="button">Copy prompt</button>
|
|
162
|
+
<span class="sw-status" id="sw-status" role="status" aria-live="polite"></span>
|
|
163
|
+
</div>
|
|
164
|
+
<p class="sw-fix">${esc(FIX_LINE)}</p>
|
|
165
|
+
<a class="sw-link" id="sw-mover" href="${esc(MOVER_URL)}" target="_blank" rel="noopener">See Mover OS</a>
|
|
166
|
+
</div>
|
|
167
|
+
</section>
|
|
168
|
+
<script type="application/json" id="sw-data">${json}</script>
|
|
169
|
+
<script id="sw-js">${SCRIPT}</script>
|
|
170
|
+
`;
|
|
171
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { usd0, low, high } from './vs-text.js';
|
|
2
|
+
|
|
1
3
|
// ANSI color codes
|
|
2
4
|
const c = {
|
|
3
5
|
reset: '\x1b[0m',
|
|
@@ -61,3 +63,11 @@ export function renderTerminal(report) {
|
|
|
61
63
|
}
|
|
62
64
|
}
|
|
63
65
|
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
/** The one line after the usual summary: the cheapest and the dearest bill, computed from the reprice object. */
|
|
69
|
+
export function vsOneLiner(rp) {
|
|
70
|
+
if (!rp || !rp.models || rp.models.length < 2) return null;
|
|
71
|
+
const lo = low(rp), hi = high(rp);
|
|
72
|
+
return `On other models: ${lo.label} ${usd0(lo.total)} to ${hi.label} ${usd0(hi.total)}. The race and a card to post are in your report.`;
|
|
73
|
+
}
|