add more statistics

This commit is contained in:
2026-07-11 16:44:45 +02:00
parent 8b89b59f9c
commit 55172d747e
6 changed files with 54 additions and 16 deletions
+3 -1
View File
@@ -17,7 +17,9 @@ Open <http://127.0.0.1:4173>. 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.
+9
View File
@@ -0,0 +1,9 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.: {}
+22 -7
View File
@@ -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=`<span class="error">${e.message}</span>`}
}
function options(el,values,label){const old=el.value;el.innerHTML=`<option value="">All ${label}</option>`+values.map(x=>`<option>${esc(x)}</option>`).join('');el.value=old}
function esc(s){return String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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='<div class="bar-legend"><span class="shade-light"></span>Uncached input <span class="shade-base"></span>Cached input <span class="shade-dark"></span>Output</div>'+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('&#10;');return `<div class="model-row"><span class="model-pill" style="color:${base};background:color-mix(in srgb,${base} 12%,white)">${esc(m.name)}</span><div class="bar" data-tip="${tip}"><div class="bar-fill" style="width:${m.total/mm*100}%;--model:${base}"><i class="uncached-part" style="width:${uncached/sum*100}%"></i><i class="cached-part" style="width:${m.cached/sum*100}%"></i><i class="output-part" style="width:${m.output/sum*100}%"></i></div></div><b>${fmt(m.total)}</b></div>`}).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=>`<div class="mix-item"><i style="background:${x[2]}"></i>${x[0]} <b>${fmt(x[1])}</b></div>`).join('');
$('#projectsBody').innerHTML=data.projects.slice(0,30).map(x=>`<tr><td>${esc(x.name)}</td><td>${fmt(x.total)}</td><td>${fmt(x.input)}</td><td>${fmt(x.output)}</td><td>${pct(x.input?x.cached/x.input*100:0)}</td><td>${money(x.cost)}</td><td>${x.turns}</td></tr>`).join('');
renderProjects();
$('#sessionsBody').innerHTML=data.sessions.map(x=>`<tr><td>${new Date(x.started).toLocaleDateString()}</td><td>${esc(x.project)}</td><td><span class="model-pill">${esc(x.model||'unknown')}</span></td><td>${fmt(x.total)}</td><td>${fmt(x.cached)}</td><td>${fmt(x.output)}</td><td>${money(x.cost)}</td></tr>`).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)=>`<button class="project-bar" data-project="${esc(x.name)}" title="Filter dashboard to ${esc(x.name)}"><span>${esc(x.name)}</span><i><b style="width:${x.total/max*100}%;background:${palette[i%palette.length]}"></b></i><strong>${fmt(x.total)}</strong><small>${money(x.cost)}</small></button>`).join('')||'<p>No project activity in this range</p>';
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=>`<tr class="clickable" data-project="${esc(x.name)}"><td>${esc(x.name)}</td><td>${fmt(x.total)}</td><td>${fmt(x.input)}</td><td>${fmt(x.output)}</td><td>${pct(x.input?x.cached/x.input*100:0)}</td><td>${money(x.cost)}</td><td>${x.turns}</td></tr>`).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();
+5 -1
View File
@@ -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}}
+4 -2
View File
@@ -1,11 +1,13 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Codex Lens</title><link rel="stylesheet" href="style.css"><link rel="stylesheet" href="fancy.css"></head><body>
<aside><div class="brand"><span class="mark"></span><div>CODEX <b>LENS</b></div></div><nav><a class="active">Overview</a><a href="#models">Models</a><a href="#projects">Projects</a><a href="#sessions">Sessions</a></nav><div class="aside-foot"><span class="pulse"></span> Local data only<br><small>Nothing leaves this machine</small></div></aside>
<main><header><div><p class="eyebrow">LOCAL INTELLIGENCE</p><h1>Your work, in focus.</h1><p id="subtitle">Scanning Codex sessions…</p></div><button id="rescan">↻ Rescan data</button></header>
<section class="filters"><label>From<input id="from" type="date"></label><label>To<input id="to" type="date"></label><label>Project<select id="project"><option value="">All projects</option></select></label><label>Model<select id="model"><option value="">All models</option></select></label><button id="clear">Clear</button></section>
<section class="quick-ranges" aria-label="Quick date ranges"><button data-days="0">Today</button><button data-days="7">7 days</button><button data-days="30">30 days</button><button data-days="90">90 days</button><button data-days="all" class="active">All time</button></section>
<section class="filters"><label>From<input id="from" type="date"></label><label>To<input id="to" type="date"></label><label>Project<select id="project"><option value="">All projects</option></select></label><label>Model<select id="model"><option value="">All models</option></select></label><label>Source<select id="source"><option value="">All sources</option></select></label><button id="clear">Clear</button></section>
<section class="cards"><article class="hero-card"><span>API-equivalent cost</span><strong id="cost"></strong><small id="coverage">Based on current standard pricing</small></article><article><span>Total tokens</span><strong id="tokens"></strong><small id="tokenMix"></small></article><article><span>Cache hit rate</span><strong id="cache"></strong><small id="cacheSaved"></small></article><article><span>Sessions / turns</span><strong id="activity"></strong><small id="duration"></small></article></section>
<section class="mini-cards"><article><span>Uncached input</span><b id="uncached"></b><small id="uncachedShare"></small></article><article><span>Reasoning output</span><b id="reasoning"></b><small id="reasoningShare"></small></article><article><span>Average session</span><b id="avgSession"></b><small id="avgCost"></small></article><article><span>Busiest day</span><b id="peakDay"></b><small id="peakTokens"></small></article><article><span>Active days</span><b id="activeDays"></b><small id="streak"></small></article><article><span>Models used</span><b id="modelCount"></b><small id="topModel"></small></article></section>
<section class="insight-strip"><article><span>Average model call</span><b id="avgCall"></b><small id="callsPerDay"></small></article><article><span>Median active day</span><b id="medianDay"></b><small id="dailyAverage"></small></article><article><span>Output intensity</span><b id="outputRate"></b><small>Output as share of all tokens</small></article><article><span>Estimated cache saving</span><b id="cacheSaving"></b><small>Versus standard input rates</small></article></section>
<section class="panel wide"><div class="panel-head"><div><p class="eyebrow">MODEL TIMELINE</p><h2>Which models powered each day</h2></div><div class="chart-actions"><button class="chart-mode active" data-mode="models">Models</button><button class="chart-mode" data-mode="tokens">Token mix</button></div></div><div id="modelLegend" class="model-legend"></div><div id="chart" class="chart"></div><div class="axis-note" id="axisNote">Stacked totals by model · hover any day for details</div></section>
<div class="grid"><section class="panel" id="models"><div class="panel-head"><div><p class="eyebrow">MODEL MIX</p><h2>Models</h2></div></div><div id="modelBars"></div></section><section class="panel"><div class="panel-head"><div><p class="eyebrow">COMPOSITION</p><h2>Where tokens go</h2></div></div><div class="donut-wrap"><div id="donut" class="donut"><div><b id="donutTotal"></b><small>tokens</small></div></div><div id="mixLegend"></div></div></section></div>
<section class="panel wide" id="projects"><div class="panel-head"><div><p class="eyebrow">WORKSPACES</p><h2>Project leaderboard</h2></div></div><div class="table-wrap"><table><thead><tr><th>Project</th><th>Tokens</th><th>Input</th><th>Output</th><th>Cache</th><th>Est. cost</th><th>Turns</th></tr></thead><tbody id="projectsBody"></tbody></table></div></section>
<section class="panel wide" id="projects"><div class="panel-head"><div><p class="eyebrow">WORKSPACES</p><h2>Project overview</h2></div><small>Click a bar to drill down</small></div><div id="projectChart" class="project-chart"></div><div class="table-tools"><input id="projectSearch" type="search" placeholder="Search projects…"><select id="projectSort"><option value="tokens">Sort by tokens</option><option value="cost">Sort by cost</option><option value="cache">Sort by cache rate</option><option value="output">Sort by output</option></select></div><div class="table-wrap"><table><thead><tr><th>Project</th><th>Tokens</th><th>Input</th><th>Output</th><th>Cache</th><th>Est. cost</th><th>Turns</th></tr></thead><tbody id="projectsBody"></tbody></table></div></section>
<section class="panel wide" id="sessions"><div class="panel-head"><div><p class="eyebrow">DEEP DIVE</p><h2>Heaviest sessions</h2></div><small>Top 100 for current filter</small></div><div class="table-wrap"><table><thead><tr><th>Started</th><th>Project</th><th>Model</th><th>Tokens</th><th>Cached</th><th>Output</th><th>Est. cost</th></tr></thead><tbody id="sessionsBody"></tbody></table></div></section>
<footer>Codex Lens · Estimates are API-equivalent, not actual ChatGPT/Codex charges. Reasoning tokens are included in output token counts.</footer></main><script src="app.js"></script></body></html>
+11 -5
View File
@@ -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<list.length;i+=12) sessions.push(...await Promise.all(list.slice(i,i+12).map(parse))); cache={loading:false,scannedAt:new Date().toISOString(),sessions,error:null}; } catch(e){cache={...cache,loading:false,error:e.message};} }
function stats(url){
const from=url.searchParams.get('from'), to=url.searchParams.get('to'), modelFilter=url.searchParams.get('model'), projectFilter=url.searchParams.get('project');
const sessions=cache.sessions.filter(s=>(!from||s.started>=from)&&(!to||s.started<to+'T23:59:59.999Z')&&(!projectFilter||s.project===projectFilter));
const events=sessions.flatMap(s=>s.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.at<to+'T23:59:59.999Z')&&(!modelFilter||e.model===modelFilter)&&(!sourceFilter||e.source===sourceFilter));
const matchedIds=new Set(events.map(e=>e.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<row.started)row.started=e.at; }
const sessionRows=Object.values(bySession).map(s=>({...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)}