diff --git a/README.md b/README.md index c040fd2..12e06c2 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,9 @@ Open . Set `PORT` or `CODEX_HOME` to override either defa - Cache hit rate, model calls, sessions, turns, and active task duration - Daily usage and estimated API-equivalent cost - Breakdowns by model, project/workspace, and session -- Date, project, and model filtering +- Date, quick-range, project, model, and source filtering +- Interactive project comparison and drill-down, searchable/sortable project rankings +- Per-call, per-day, output-intensity, and estimated cache-saving statistics Cost is an estimate, not a bill. It applies current standard API prices per 1M tokens to the model recorded for each usage event. Unknown, local, experimental, and unpriced model slugs are excluded and pricing coverage is shown in the UI. Reasoning tokens are already part of output tokens and are not charged twice. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..9b60ae1 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,9 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: {} diff --git a/public/app.js b/public/app.js index f5eaa9d..82a3883 100644 --- a/public/app.js +++ b/public/app.js @@ -1,34 +1,49 @@ -const $=s=>document.querySelector(s), fmt=n=>new Intl.NumberFormat('en',{notation:'compact',maximumFractionDigits:1}).format(n||0), money=n=>new Intl.NumberFormat('en-US',{style:'currency',currency:'USD',maximumFractionDigits:n<10?2:0}).format(n||0), pct=n=>`${(n||0).toFixed(1)}%`; +const $=s=>document.querySelector(s), fmt=n=>new Intl.NumberFormat('en',{notation:'compact',maximumFractionDigits:1}).format(n||0), fmt2=n=>new Intl.NumberFormat('en',{notation:'compact',minimumFractionDigits:2,maximumFractionDigits:2}).format(n||0), money=n=>new Intl.NumberFormat('en-US',{style:'currency',currency:'USD',maximumFractionDigits:n<10?2:0}).format(n||0), pct=n=>`${(n||0).toFixed(1)}%`; let data, chartMode='models'; const palette=['#7357ff','#20a4a8','#ff805d','#e4b63f','#4b8cff','#d45bba','#72a33a','#9b6b43','#32343a','#a5a2ff','#19b875','#e85d5d']; const modelColor=name=>palette[Math.max(0,data.models.findIndex(x=>x.name===name))%palette.length]; async function load(){ - const q=new URLSearchParams(); for(const id of ['from','to','project','model']) if($('#'+id).value) q.set(id,$('#'+id).value); + const q=new URLSearchParams(); for(const id of ['from','to','project','model','source']) if($('#'+id).value) q.set(id,$('#'+id).value); $('#subtitle').textContent='Reading local session history…'; try{data=await fetch('/api/stats?'+q).then(r=>r.json());if(data.meta?.schemaVersion!==2)throw new Error('The Codex Lens server is outdated. Stop it and run npm start again.');render()}catch(e){$('#subtitle').innerHTML=`${e.message}`} } function options(el,values,label){const old=el.value;el.innerHTML=``+values.map(x=>``).join('');el.value=old} function esc(s){return String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))} function render(){ - const {total:t,meta}=data; options($('#project'),data.filters.projects,'projects');options($('#model'),data.filters.models,'models'); + const {total:t,meta}=data; options($('#project'),data.filters.projects,'projects');options($('#model'),data.filters.models,'models');options($('#source'),data.filters.sources||[],'sources'); $('#subtitle').textContent=`${meta.files} sessions · ${meta.events.toLocaleString()} model calls · scanned ${new Date(meta.scannedAt).toLocaleTimeString()}`; $('#cost').textContent=money(t.cost);const coverage=t.total?t.priced/t.total*100:0;$('#coverage').textContent=`${pct(coverage)} token pricing coverage · standard API rates`; - $('#tokens').textContent=fmt(t.total);$('#tokenMix').textContent=`${fmt(t.input)} input · ${fmt(t.output)} output`; + const uncached=Math.max(0,t.input-t.cached); + $('#tokens').textContent=fmt2(t.total);$('#tokenMix').textContent=`${fmt2(uncached)} normal input · ${fmt2(t.cached)} cached · ${fmt2(t.output)} output`; const cr=t.input?t.cached/t.input*100:0;$('#cache').textContent=pct(cr);$('#cacheSaved').textContent=`${fmt(t.cached)} cached input tokens`; const matchedSessions=data.meta.matchedSessions??data.meta.files??data.sessions.length;$('#activity').textContent=`${matchedSessions} / ${t.turns.toLocaleString()}`;$('#duration').textContent=`${(t.duration/36e5).toFixed(1)} active task hours`; - const uncached=Math.max(0,t.input-t.cached), peak=data.days.reduce((a,d)=>d.total>(a?.total||0)?d:a,null); + const peak=data.days.reduce((a,d)=>d.total>(a?.total||0)?d:a,null); $('#uncached').textContent=fmt(uncached);$('#uncachedShare').textContent=`${pct(t.input?uncached/t.input*100:0)} of input`; $('#reasoning').textContent=fmt(t.reasoning);$('#reasoningShare').textContent=`${pct(t.output?t.reasoning/t.output*100:0)} of output`; $('#avgSession').textContent=fmt(t.total/(matchedSessions||1));$('#avgCost').textContent=`${money(t.cost/(matchedSessions||1))} API-equiv.`; $('#peakDay').textContent=peak?new Date(peak.name+'T12:00:00').toLocaleDateString(undefined,{month:'short',day:'numeric'}):'—';$('#peakTokens').textContent=peak?`${fmt(peak.total)} tokens`:'No activity'; $('#activeDays').textContent=data.days.length;$('#streak').textContent=`Longest streak ${longestStreak(data.days)} days`; $('#modelCount').textContent=data.models.length;$('#topModel').textContent=data.models[0]?`${data.models[0].name} leads`:'No models'; + const daily=data.days.map(x=>x.total).sort((a,b)=>a-b), median=daily.length?(daily[Math.floor((daily.length-1)/2)]+daily[Math.ceil((daily.length-1)/2)])/2:0; + $('#avgCall').textContent=fmt(t.total/(meta.events||1));$('#callsPerDay').textContent=`${(meta.events/(data.days.length||1)).toFixed(1)} calls / active day`; + $('#medianDay').textContent=fmt(median);$('#dailyAverage').textContent=`${fmt(t.total/(data.days.length||1))} daily average`; + $('#outputRate').textContent=pct(t.total?t.output/t.total*100:0); + const cacheSaving=data.models.reduce((sum,m)=>{const p=data.prices[m.name];return sum+(p?m.cached*(p.input-p.cached)/1e6:0)},0);$('#cacheSaving').textContent=money(cacheSaving); renderChart(); const mm=Math.max(...data.models.map(x=>x.total),1);$('#modelBars').innerHTML='
Uncached input Cached input Output
'+data.models.slice(0,9).map(m=>{const base=modelColor(m.name),uncached=Math.max(0,m.input-m.cached),sum=uncached+m.cached+m.output||1,tip=[m.name,`Uncached input ${fmt(uncached)} (${pct(uncached/sum*100)})`,`Cached input ${fmt(m.cached)} (${pct(m.cached/sum*100)})`,`Output ${fmt(m.output)} (${pct(m.output/sum*100)})`,`Reasoning output ${fmt(m.reasoning)}`,`Cache hit rate ${pct(m.input?m.cached/m.input*100:0)}`,`API-equivalent cost ${money(m.cost)}`].join(' ');return `
${esc(m.name)}
${fmt(m.total)}
`}).join(''); const mixSum=uncached+t.cached+t.output||1, a=uncached/mixSum*100,b=t.cached/mixSum*100;$('#donut').style.background=`conic-gradient(var(--purple) 0 ${a}%,var(--blue) ${a}% ${a+b}%,var(--orange) ${a+b}% 100%)`;$('#donutTotal').textContent=fmt(t.total);$('#mixLegend').innerHTML=[['Uncached input',uncached,'var(--purple)'],['Cached input',t.cached,'var(--blue)'],['Output',t.output,'var(--orange)'],['Reasoning output',t.reasoning,'#222']].map(x=>`
${x[0]} ${fmt(x[1])}
`).join(''); - $('#projectsBody').innerHTML=data.projects.slice(0,30).map(x=>`${esc(x.name)}${fmt(x.total)}${fmt(x.input)}${fmt(x.output)}${pct(x.input?x.cached/x.input*100:0)}${money(x.cost)}${x.turns}`).join(''); + renderProjects(); $('#sessionsBody').innerHTML=data.sessions.map(x=>`${new Date(x.started).toLocaleDateString()}${esc(x.project)}${esc(x.model||'unknown')}${fmt(x.total)}${fmt(x.cached)}${fmt(x.output)}${money(x.cost)}`).join(''); } +function renderProjects(){ + const max=Math.max(...data.projects.map(x=>x.total),1), top=data.projects.slice(0,10); + $('#projectChart').innerHTML=top.map((x,i)=>``).join('')||'

No project activity in this range

'; + document.querySelectorAll('.project-bar').forEach(b=>b.onclick=()=>{$('#project').value=b.dataset.project;load();scrollTo({top:0,behavior:'smooth'})}); + const query=$('#projectSearch').value.toLowerCase(), sort=$('#projectSort').value; + const rows=data.projects.filter(x=>x.name.toLowerCase().includes(query)).sort((a,b)=>sort==='cost'?b.cost-a.cost:sort==='cache'?(b.cached/(b.input||1))-(a.cached/(a.input||1)):sort==='output'?b.output-a.output:b.total-a.total); + $('#projectsBody').innerHTML=rows.slice(0,100).map(x=>`${esc(x.name)}${fmt(x.total)}${fmt(x.input)}${fmt(x.output)}${pct(x.input?x.cached/x.input*100:0)}${money(x.cost)}${x.turns}`).join(''); + document.querySelectorAll('#projectsBody tr').forEach(r=>r.onclick=()=>{$('#project').value=r.dataset.project;load();scrollTo({top:0,behavior:'smooth'})}); +} function longestStreak(days){let best=0,run=0,prev=0;for(const d of days){const n=new Date(d.name+'T00:00:00Z').getTime()/864e5;run=n===prev+1?run+1:1;best=Math.max(best,run);prev=n}return best} function renderChart(){ const max=Math.max(...data.days.map(x=>x.total),1), chart=$('#chart'); @@ -43,4 +58,4 @@ function renderChart(){ $('#axisNote').textContent='100% composition per active day · output remains visible at a minimum 1px'; } } -for(const id of ['from','to','project','model']) $('#'+id).addEventListener('change',load);document.querySelectorAll('.chart-mode').forEach(b=>b.onclick=()=>{chartMode=b.dataset.mode;document.querySelectorAll('.chart-mode').forEach(x=>x.classList.toggle('active',x===b));renderChart()});$('#clear').onclick=()=>{for(const id of ['from','to','project','model'])$('#'+id).value='';load()};$('#rescan').onclick=async()=>{$('#rescan').disabled=true;$('#rescan').textContent='Scanning…';await fetch('/api/rescan',{method:'POST'});$('#rescan').disabled=false;$('#rescan').textContent='↻ Rescan data';load()};load(); +for(const id of ['from','to','project','model','source']) $('#'+id).addEventListener('change',()=>{document.querySelectorAll('.quick-ranges button').forEach(x=>x.classList.remove('active'));load()});document.querySelectorAll('.quick-ranges button').forEach(b=>b.onclick=()=>{const n=b.dataset.days,today=new Date(),local=d=>{const z=new Date(d.getTime()-d.getTimezoneOffset()*6e4);return z.toISOString().slice(0,10)};$('#to').value=n==='all'?'':local(today);if(n==='all')$('#from').value='';else{const from=new Date(today);from.setDate(from.getDate()-Number(n)+(Number(n)>0?1:0));$('#from').value=local(from)}document.querySelectorAll('.quick-ranges button').forEach(x=>x.classList.toggle('active',x===b));load()});$('#projectSearch').addEventListener('input',renderProjects);$('#projectSort').addEventListener('change',renderProjects);document.querySelectorAll('.chart-mode').forEach(b=>b.onclick=()=>{chartMode=b.dataset.mode;document.querySelectorAll('.chart-mode').forEach(x=>x.classList.toggle('active',x===b));renderChart()});$('#clear').onclick=()=>{for(const id of ['from','to','project','model','source'])$('#'+id).value='';document.querySelectorAll('.quick-ranges button').forEach(x=>x.classList.toggle('active',x.dataset.days==='all'));load()};$('#rescan').onclick=async()=>{$('#rescan').disabled=true;$('#rescan').textContent='Scanning…';await fetch('/api/rescan',{method:'POST'});$('#rescan').disabled=false;$('#rescan').textContent='↻ Rescan data';load()};load(); diff --git a/public/fancy.css b/public/fancy.css index 8b8fbeb..3366673 100644 --- a/public/fancy.css +++ b/public/fancy.css @@ -3,6 +3,9 @@ .mini-cards span{font-size:11px;color:var(--muted)} .mini-cards b{font-family:Georgia,serif;font-size:22px;font-weight:500} .mini-cards small{font-size:10px;color:var(--muted)} +.quick-ranges{display:flex;gap:6px;margin:28px 0 -24px}.quick-ranges button{font-size:11px;padding:6px 10px}.quick-ranges .active{background:#1b1c19;color:#fff;border-color:#1b1c19} +.insight-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-top:10px}.insight-strip article{padding:15px 17px;border-top:1px solid var(--line);display:grid;gap:4px}.insight-strip span,.insight-strip small{font-size:10px;color:var(--muted)}.insight-strip b{font:500 20px Georgia,serif} +.project-chart{display:grid;gap:8px;margin-bottom:22px}.project-bar{display:grid;grid-template-columns:minmax(110px,180px) 1fr 75px 65px;align-items:center;gap:12px;border:0;padding:5px;background:transparent;text-align:left}.project-bar:hover{background:#f0eee7}.project-bar>span{overflow:hidden;text-overflow:ellipsis}.project-bar>i{height:10px;background:#e7e5dd;border-radius:8px;overflow:hidden}.project-bar>i b{height:100%;display:block;border-radius:8px}.project-bar strong,.project-bar small{text-align:right}.table-tools{display:flex;justify-content:flex-end;gap:8px;margin-bottom:10px}.table-tools input{min-width:220px}.clickable{cursor:pointer}.clickable:hover{background:#f0eee7} .chart{height:280px} .day{min-width:4px;display:flex;flex-direction:column-reverse;justify-content:flex-start;align-items:stretch} .day span{flex:none;border-radius:1px;transition:filter .15s} @@ -29,4 +32,5 @@ .bar:hover:after{content:attr(data-tip);position:absolute;left:50%;bottom:calc(100% + 8px);transform:translateX(-50%);z-index:20;background:#1b1c19;color:#fff;padding:9px 11px;border-radius:6px;white-space:pre;line-height:1.6;font:10px ui-monospace,monospace;box-shadow:0 8px 24px #0003;pointer-events:none} .bar:hover .bar-fill{filter:saturate(1.25) brightness(.92)} @media(max-width:1200px){.mini-cards{grid-template-columns:repeat(3,1fr)}} -@media(max-width:620px){.mini-cards{grid-template-columns:1fr 1fr}} +@media(max-width:900px){.insight-strip{grid-template-columns:1fr 1fr}} +@media(max-width:620px){.mini-cards{grid-template-columns:1fr 1fr}.quick-ranges{overflow:auto}.project-bar{grid-template-columns:90px 1fr 60px}.project-bar small{display:none}.table-tools{display:grid}.table-tools input{min-width:0}.insight-strip{grid-template-columns:1fr 1fr}} diff --git a/public/index.html b/public/index.html index 7022ca5..ab829a0 100644 --- a/public/index.html +++ b/public/index.html @@ -1,11 +1,13 @@ Codex Lens

LOCAL INTELLIGENCE

Your work, in focus.

Scanning Codex sessions…

-
+
+
API-equivalent costBased on current standard pricing
Total tokens
Cache hit rate
Sessions / turns
Uncached input
Reasoning output
Average session
Busiest day
Active days
Models used
+
Average model call
Median active day
Output intensityOutput as share of all tokens
Estimated cache savingVersus standard input rates

MODEL TIMELINE

Which models powered each day

Stacked totals by model · hover any day for details

MODEL MIX

Models

COMPOSITION

Where tokens go

tokens
-

WORKSPACES

Project leaderboard

ProjectTokensInputOutputCacheEst. costTurns
+

WORKSPACES

Project overview

Click a bar to drill down
ProjectTokensInputOutputCacheEst. costTurns

DEEP DIVE

Heaviest sessions

Top 100 for current filter
StartedProjectModelTokensCachedOutputEst. cost
Codex Lens · Estimates are API-equivalent, not actual ChatGPT/Codex charges. Reasoning tokens are included in output token counts.
diff --git a/server.js b/server.js index 6f6503f..1ada8c3 100644 --- a/server.js +++ b/server.js @@ -52,17 +52,23 @@ let cache={loading:true,scannedAt:null,sessions:[],error:null}; async function scan(){ cache.loading=true; try { const list=await files(SESSIONS); const sessions=[]; for(let i=0;i(!from||s.started>=from)&&(!to||s.starteds.events.map(e=>({...e,session:s.id,project:s.project,source:s.source}))).filter(e=>!modelFilter||e.model===modelFilter); + const from=url.searchParams.get('from'), to=url.searchParams.get('to'), modelFilter=url.searchParams.get('model'), projectFilter=url.searchParams.get('project'), sourceFilter=url.searchParams.get('source'); + // Usage belongs to the time of the model call, not the time the chat began. + // A Codex session can remain active across several days, so filtering sessions + // by `started` silently drops later usage from long-running chats. + const allSessions=cache.sessions.filter(s=>!projectFilter||s.project===projectFilter); + const events=allSessions.flatMap(s=>s.events.map(e=>({...e,session:s.id,project:s.project,source:s.source}))).filter(e=>(!from||e.at>=from)&&(!to||e.ate.session)); + const sessions=allSessions.filter(s=>matchedIds.has(s.id)); const total=empty(), byDay={}, byModel={}, byProject={}, dayModels={}; for(const e of events){ add(total,e); const day=(e.at||'unknown').slice(0,10); add(byDay[day]??=empty(),e); add(byModel[e.model]??=empty(),e); add(byProject[e.project]??=empty(),e); add((dayModels[day]??={})[e.model]??=empty(),e); } for(const s of sessions){ if(modelFilter && !s.models.includes(modelFilter)) continue; total.turns+=s.usage.turns; total.duration+=s.usage.duration; (byProject[s.project]??=empty()).turns+=s.usage.turns; (byProject[s.project]).duration+=s.usage.duration; } const rows=o=>Object.entries(o).map(([name,value])=>({name,...value})).filter(x=>x.total>0).sort((a,b)=>b.total-a.total); - const sessionRows=sessions.map(s=>({...s,models:undefined,events:undefined,model:s.models.join(', '),usage:undefined,...s.usage})).sort((a,b)=>b.total-a.total); + const bySession={}; for(const e of events){ const row=bySession[e.session]??={id:e.session,started:e.at,project:e.project,source:e.source,models:new Set(),...empty()}; row.models.add(e.model); add(row,e); if(e.at({...s,models:undefined,model:[...s.models].join(', ')})).sort((a,b)=>b.total-a.total); const days=rows(byDay).sort((a,b)=>a.name.localeCompare(b.name)).map(d=>({...d,models:Object.fromEntries(Object.entries(dayModels[d.name]||{}).map(([m,u])=>[m,u.total]))})); const modelRows=rows(byModel); - return {meta:{schemaVersion:2,scannedAt:cache.scannedAt,files:cache.sessions.length,matchedSessions:sessions.length,events:events.length,pricingSource:'https://developers.openai.com/api/docs/pricing'},total,days,models:modelRows,projects:rows(byProject),sessions:sessionRows.slice(0,100),filters:{models:modelRows.map(x=>x.name).sort(),projects:[...new Set(cache.sessions.filter(s=>s.usage.total>0).map(s=>s.project))].sort()},prices:Object.fromEntries(Object.entries(prices).map(([k,v])=>[k,{input:v[0],cached:v[1],output:v[2]}]))}; + return {meta:{schemaVersion:2,scannedAt:cache.scannedAt,files:cache.sessions.length,matchedSessions:sessions.length,events:events.length,pricingSource:'https://developers.openai.com/api/docs/pricing'},total,days,models:modelRows,projects:rows(byProject),sessions:sessionRows.slice(0,100),filters:{models:modelRows.map(x=>x.name).sort(),projects:[...new Set(cache.sessions.filter(s=>s.usage.total>0).map(s=>s.project))].sort(),sources:[...new Set(cache.sessions.map(s=>s.source))].sort()},prices:Object.fromEntries(Object.entries(prices).map(([k,v])=>[k,{input:v[0],cached:v[1],output:v[2]}]))}; } function send(res,code,body,type='application/json'){res.writeHead(code,{'content-type':type,'cache-control':'no-store'});res.end(type==='application/json'?JSON.stringify(body):body)}