diff --git a/README.md b/README.md index 12e06c2..bfb870d 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ A detailed, local-first dashboard for Codex usage. It reads `~/.codex/sessions/**/*.jsonl` directly and never uploads session data. +Usage is derived from Codex's cumulative session counters—the same values used by the CLI exit summary—and active logs are rescanned every 30 seconds. + ## Run ```bash diff --git a/public/app.js b/public/app.js index 82a3883..873c065 100644 --- a/public/app.js +++ b/public/app.js @@ -58,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','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(); +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();setInterval(()=>{if(!document.hidden)load()},30_000); diff --git a/server.js b/server.js index 1ada8c3..042fc4d 100644 --- a/server.js +++ b/server.js @@ -33,14 +33,20 @@ async function files(dir) { async function parse(file) { let session={ id:path.basename(file,'.jsonl'), started:'', cwd:'unknown', project:'unknown', source:'unknown', models:new Set(), usage:empty(), events:[] }; - let model='unknown'; + let model='unknown', previousTotal=null; const rl=readline.createInterface({input:fs.createReadStream(file),crlfDelay:Infinity}); for await (const line of rl) try { const x=JSON.parse(line), p=x.payload||{}; if(x.type==='session_meta'){ session.id=p.id||session.id; session.started=p.timestamp||x.timestamp; session.cwd=p.cwd||'unknown'; session.project=path.basename(session.cwd)||session.cwd; session.source=typeof p.source==='string'?p.source:(p.source?.subagent?'subagent':'app'); } if(x.type==='turn_context' && p.model){ model=p.model; session.models.add(model); } - if(x.type==='event_msg' && p.type==='token_count' && p.info?.last_token_usage){ - const t=p.info.last_token_usage, u={input:t.input_tokens||0,cached:t.cached_input_tokens||0,output:t.output_tokens||0,reasoning:t.reasoning_output_tokens||0,total:t.total_tokens||0,turns:0,duration:0}; + if(x.type==='event_msg' && p.type==='token_count' && (p.info?.total_token_usage||p.info?.last_token_usage)){ + // `total_token_usage` is Codex's authoritative session counter and is what + // the CLI prints on exit. Deriving deltas from it avoids double-counting + // occasional replayed/revised `last_token_usage` records. + const t=p.info.total_token_usage||p.info.last_token_usage, cumulative=!!p.info.total_token_usage; + const value=k=>Math.max(0,(t[k]||0)-(cumulative&&previousTotal?(previousTotal[k]||0):0)); + const u={input:value('input_tokens'),cached:value('cached_input_tokens'),output:value('output_tokens'),reasoning:value('reasoning_output_tokens'),total:value('total_tokens'),turns:0,duration:0}; + if(cumulative) previousTotal=t; u.cost=cost(u,model); u.priced=prices[model]?u.total:0; add(session.usage,u); session.events.push({at:x.timestamp||session.started,model,...u}); } if(x.type==='event_msg' && p.type==='task_complete'){ session.usage.turns++; session.usage.duration+=(p.duration_ms||0); } @@ -48,8 +54,12 @@ async function parse(file) { session.models=[...session.models]; return session; } -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{cache.loading=true;try{const list=await files(SESSIONS);const sessions=[];for(let i=0;i{ const types={'.html':'text/html; charset=utf-8','.css':'text/css; charset=utf-8','.js':'text/javascript; charset=utf-8'}; send(res,200,fs.readFileSync(file),types[path.extname(file)]||'application/octet-stream'); }); -scan().then(()=>server.listen(PORT,'127.0.0.1',()=>console.log(`Codex Lens → http://127.0.0.1:${PORT}`))); +scan().then(()=>{server.listen(PORT,'127.0.0.1',()=>console.log(`Codex Lens → http://127.0.0.1:${PORT}`));setInterval(scan,30_000).unref();});