From 8b89b59f9c9338503d29da1adc797130cd898944 Mon Sep 17 00:00:00 2001 From: tototomate123 Date: Fri, 10 Jul 2026 15:43:48 +0200 Subject: [PATCH] Initial Codex Lens dashboard --- .gitignore | 3 ++ README.md | 24 ++++++++++++++ package.json | 9 ++++++ public/app.js | 46 ++++++++++++++++++++++++++ public/fancy.css | 32 +++++++++++++++++++ public/index.html | 11 +++++++ public/style.css | 1 + server.js | 78 +++++++++++++++++++++++++++++++++++++++++++++ test/parser.test.js | 2 ++ 9 files changed, 206 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package.json create mode 100644 public/app.js create mode 100644 public/fancy.css create mode 100644 public/index.html create mode 100644 public/style.css create mode 100644 server.js create mode 100644 test/parser.test.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6968267 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +node_modules/ + diff --git a/README.md b/README.md new file mode 100644 index 0000000..c040fd2 --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# Codex Lens + +A detailed, local-first dashboard for Codex usage. It reads `~/.codex/sessions/**/*.jsonl` directly and never uploads session data. + +## Run + +```bash +cd /home/louis/dev/codex-lens +npm start +``` + +Open . Set `PORT` or `CODEX_HOME` to override either default. + +## What it measures + +- Input, cached input, output, reasoning output, and total tokens +- 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 + +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. + +Pricing source: [OpenAI API pricing](https://developers.openai.com/api/docs/pricing), captured 2026-07-10. Edit the `prices` table in `server.js` as rates change. diff --git a/package.json b/package.json new file mode 100644 index 0000000..4db3cfe --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "name": "codex-lens", + "version": "0.1.0", + "private": true, + "description": "A detailed, local-first Codex usage analytics dashboard", + "scripts": { "start": "node server.js", "test": "node --test" }, + "engines": { "node": ">=20" }, + "license": "MIT" +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..f5eaa9d --- /dev/null +++ b/public/app.js @@ -0,0 +1,46 @@ +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)}%`; +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); + $('#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'); + $('#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 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); + $('#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'; + 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(''); + $('#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 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'); + if(chartMode==='models'){ + const shown=data.models.slice(0,11).map(x=>x.name), other=data.models.slice(11).map(x=>x.name); + $('#modelLegend').innerHTML=shown.map(m=>`${esc(m)}`).join('')+(other.length?'Other':''); + chart.innerHTML=data.days.map(d=>{const daily=d.models||{},parts=shown.map(m=>[m,daily[m]||0,modelColor(m)]),ov=other.reduce((s,m)=>s+(daily[m]||0),0);if(ov)parts.push(['Other',ov,'#bbb']);const tip=[`${d.name} · ${fmt(d.total)} tokens`,...parts.filter(x=>x[1]).sort((a,b)=>b[1]-a[1]).slice(0,5).map(x=>`${x[0]} ${fmt(x[1])}`),`Est. ${money(d.cost)}`].join(' ');return `
${parts.filter(x=>x[1]).map(x=>``).join('')}
`}).join('')||'

No usage in this range

'; + $('#axisNote').textContent='Stacked totals by model · hover any day for details'; + }else{ + $('#modelLegend').innerHTML='Uncached inputCached inputOutput'; + chart.innerHTML=data.days.map(d=>{const u=Math.max(0,d.input-d.cached),sum=u+d.cached+d.output||1,parts=[['Uncached',u,'var(--purple)'],['Cached',d.cached,'var(--blue)'],['Output',d.output,'var(--orange)']],tip=[`${d.name} · token composition`,...parts.map(x=>`${x[0]} ${fmt(x[1])} (${pct(x[1]/sum*100)})`)].join(' ');return `
${parts.map(x=>``).join('')}
`}).join(''); + $('#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(); diff --git a/public/fancy.css b/public/fancy.css new file mode 100644 index 0000000..8b8fbeb --- /dev/null +++ b/public/fancy.css @@ -0,0 +1,32 @@ +.mini-cards{display:grid;grid-template-columns:repeat(6,1fr);gap:10px;margin-top:10px} +.mini-cards article{background:var(--paper);border:1px solid var(--line);border-radius:10px;padding:15px;display:grid;gap:5px} +.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)} +.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} +.day:hover span{filter:brightness(.8)} +.day:hover:after{bottom:calc(100% + 5px);white-space:pre;line-height:1.55;font-size:10px;padding:8px 10px;border-radius:6px;pointer-events:none} +.model-legend{display:flex;gap:13px;flex-wrap:wrap;min-height:20px;margin:-8px 0 5px;font:10px ui-monospace,monospace;color:var(--muted)} +.model-legend i{display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:5px} +.chart-actions{display:flex} +.chart-actions button{font-size:11px;padding:7px 11px;border-radius:0} +.chart-actions button:first-child{border-radius:7px 0 0 7px} +.chart-actions button:last-child{border-radius:0 7px 7px 0} +.chart-actions .active{background:#1b1c19;color:#fff;border-color:#1b1c19} +.axis-note{text-align:right;color:var(--muted);font-size:10px;margin-top:8px} +.bar-legend{display:flex;justify-content:flex-end;align-items:center;gap:6px;color:var(--muted);font-size:10px;margin:-12px 75px 8px 0} +.bar-legend span{width:11px;height:7px;border-radius:2px;margin-left:8px;background:#7357ff} +.bar-legend .shade-light{background:color-mix(in srgb,#7357ff 42%,white)} +.bar-legend .shade-dark{background:color-mix(in srgb,#7357ff 72%,black)} +.bar{position:relative;overflow:visible} +.bar-fill{height:100%;display:flex;overflow:hidden;border-radius:10px} +.bar-fill i{height:100%;display:block;flex:none;border-radius:0} +.bar-fill .uncached-part{background:color-mix(in srgb,var(--model) 42%,white)} +.bar-fill .cached-part{background:var(--model)} +.bar-fill .output-part{background:color-mix(in srgb,var(--model) 72%,black);min-width:1px} +.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}} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..7022ca5 --- /dev/null +++ b/public/index.html @@ -0,0 +1,11 @@ +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
+

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
+

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/public/style.css b/public/style.css new file mode 100644 index 0000000..8f1c0ed --- /dev/null +++ b/public/style.css @@ -0,0 +1 @@ +:root{--bg:#f3f1ea;--paper:#fbfaf6;--ink:#1b1c19;--muted:#77786f;--line:#d9d7ce;--lime:#c8ff57;--purple:#7357ff;--orange:#ff805d;--blue:#77b7ff}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font-family:Inter,ui-sans-serif,system-ui,sans-serif}aside{position:fixed;inset:0 auto 0 0;width:220px;background:#171815;color:#eee;padding:28px 20px;display:flex;flex-direction:column}.brand{display:flex;align-items:center;gap:10px;letter-spacing:.12em;font-size:13px}.brand b{color:var(--lime)}.mark{display:grid;place-items:center;background:var(--lime);color:#171815;width:28px;height:28px;border-radius:50%}nav{display:flex;flex-direction:column;gap:8px;margin-top:68px}nav a{color:#92958b;text-decoration:none;padding:10px 12px;border-radius:7px;font-size:14px}nav a:hover,nav a.active{background:#292b26;color:white}.aside-foot{margin-top:auto;color:#c6c8c0;font-size:12px;line-height:1.7}.aside-foot small{color:#6f7269}.pulse{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--lime);box-shadow:0 0 12px var(--lime);margin-right:7px}main{margin-left:220px;padding:48px clamp(26px,5vw,80px);max-width:1700px}header{display:flex;justify-content:space-between;align-items:flex-start}h1{font-family:Georgia,serif;font-size:clamp(42px,5vw,72px);letter-spacing:-.045em;margin:4px 0 8px;font-weight:500}h2{font-family:Georgia,serif;font-size:25px;margin:3px 0;font-weight:500}p{color:var(--muted)}.eyebrow{font:700 10px ui-monospace,monospace;letter-spacing:.18em;color:#8b8c83;margin:0}button,select,input{font:inherit;border:1px solid var(--line);background:var(--paper);border-radius:7px;padding:9px 12px;color:var(--ink)}button{cursor:pointer}button:hover{border-color:#888}.filters{display:flex;gap:12px;align-items:end;margin:34px 0 18px;flex-wrap:wrap}.filters label{font-size:10px;text-transform:uppercase;letter-spacing:.12em;color:var(--muted);display:grid;gap:6px}.cards{display:grid;grid-template-columns:1.25fr repeat(3,1fr);gap:14px}.cards article,.panel{background:var(--paper);border:1px solid var(--line);border-radius:12px}.cards article{padding:22px;min-height:142px;display:flex;flex-direction:column;position:relative;overflow:hidden}.cards span{font-size:12px;color:var(--muted)}.cards strong{font-family:Georgia,serif;font-size:36px;font-weight:500;margin:auto 0 4px}.cards small,.panel small{color:var(--muted)}.hero-card{background:#1d1e1a!important;color:white}.hero-card span,.hero-card small{color:#aeb0a8}.hero-card strong{color:var(--lime);font-size:46px}.panel{padding:24px;margin-top:14px}.panel-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px}.grid{display:grid;grid-template-columns:1.3fr .7fr;gap:14px}.chart{height:260px;display:flex;align-items:flex-end;gap:3px;border-bottom:1px solid var(--line);position:relative;padding-top:20px}.day{flex:1;min-width:3px;display:flex;align-items:flex-end;height:100%;position:relative}.day span{width:100%;display:block;background:var(--purple);border-radius:2px 2px 0 0;min-height:1px}.day:hover:after{content:attr(data-tip);position:absolute;bottom:100%;left:50%;transform:translateX(-50%);background:#1b1c19;color:white;padding:7px 9px;border-radius:5px;white-space:nowrap;font-size:11px;z-index:5}.legend{font-size:11px;color:var(--muted)}.legend i{display:inline-block;width:8px;height:8px;border-radius:50%;margin:0 4px 0 12px}.in{background:var(--purple)}.cached{background:var(--blue)}.out{background:var(--orange)}.model-row{display:grid;grid-template-columns:160px 1fr 80px;gap:12px;align-items:center;margin:15px 0;font-size:12px}.bar{height:9px;background:#e7e5dd;border-radius:10px;overflow:hidden}.bar i{display:block;height:100%;background:var(--purple);border-radius:10px}.donut-wrap{display:flex;align-items:center;justify-content:center;gap:28px;min-height:220px}.donut{width:170px;height:170px;border-radius:50%;display:grid;place-items:center;background:conic-gradient(var(--purple) 0 70%,var(--blue) 70% 95%,var(--orange) 95%)}.donut>div{width:112px;height:112px;background:var(--paper);border-radius:50%;display:grid;place-content:center;text-align:center}.donut b{font-family:Georgia,serif;font-size:22px}.donut small{display:block}.mix-item{margin:9px 0;font-size:12px}.mix-item i{display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:7px}.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse;font-size:12px}th{text-align:left;color:var(--muted);font-weight:500;padding:10px;border-bottom:1px solid var(--line)}td{padding:13px 10px;border-bottom:1px solid #e7e5de;white-space:nowrap}td:first-child{font-weight:600}.model-pill{background:#ebe8ff;color:#5740cd;padding:4px 7px;border-radius:5px;font:10px ui-monospace,monospace}footer{text-align:center;color:var(--muted);font-size:11px;padding:34px}.error{color:#b33}@media(max-width:1000px){aside{display:none}main{margin:0}.cards{grid-template-columns:1fr 1fr}.grid{grid-template-columns:1fr}}@media(max-width:620px){main{padding:25px 15px}header{display:block}header button{margin-top:12px}.cards{grid-template-columns:1fr}.filters>*{flex:1}.panel{padding:16px}.model-row{grid-template-columns:120px 1fr 65px}.donut-wrap{flex-direction:column}} diff --git a/server.js b/server.js new file mode 100644 index 0000000..6f6503f --- /dev/null +++ b/server.js @@ -0,0 +1,78 @@ +const http = require('node:http'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const readline = require('node:readline'); + +const PORT = Number(process.env.PORT || 4173); +const CODEX_HOME = process.env.CODEX_HOME || path.join(os.homedir(), '.codex'); +const SESSIONS = path.join(CODEX_HOME, 'sessions'); +const PUBLIC = path.join(__dirname, 'public'); + +// USD per 1M tokens. Source: official OpenAI pricing page, fetched 2026-07-10. +// Aliases are intentionally explicit: unknown/local models remain unpriced. +const prices = { + 'gpt-5.6-sol': [5, .5, 30], 'gpt-5.6-terra': [2.5, .25, 15], 'gpt-5.6-luna': [1, .1, 6], + 'gpt-5.5': [5, .5, 30], 'gpt-5.4': [2.5, .25, 15], 'gpt-5.4-mini': [.75, .075, 4.5], + 'gpt-5.3-codex': [1.75, .175, 14], 'gpt-5.2-codex': [1.75, .175, 14], + 'gpt-5.2': [1.75, .175, 14], 'gpt-5.1-codex-max': [1.25, .125, 10], + 'gpt-5.1-codex': [1.25, .125, 10], 'gpt-5.1-codex-mini': [.25, .025, 2], + 'gpt-5-codex': [1.25, .125, 10] +}; + +const empty = () => ({ input: 0, cached: 0, output: 0, reasoning: 0, total: 0, turns: 0, duration: 0, cost: 0, priced: 0 }); +function add(a, b) { for (const k of ['input','cached','output','reasoning','total','turns','duration','cost','priced']) a[k] += b[k] || 0; return a; } +function cost(u, model) { const p = prices[model]; if (!p) return 0; return ((u.input-u.cached)*p[0] + u.cached*p[1] + u.output*p[2]) / 1e6; } + +async function files(dir) { + const out=[]; if (!fs.existsSync(dir)) return out; + for (const e of await fs.promises.readdir(dir,{withFileTypes:true})) { + const p=path.join(dir,e.name); if(e.isDirectory()) out.push(...await files(p)); else if(e.name.endsWith('.jsonl')) out.push(p); + } return out; +} + +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'; + 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}; + 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); } + } catch {} + 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(!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 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 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]}]))}; +} + +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)} +const server=http.createServer(async(req,res)=>{ + const url=new URL(req.url,'http://localhost'); + if(url.pathname==='/api/stats') return send(res,200,stats(url)); + if(url.pathname==='/api/rescan'&&req.method==='POST'){await scan();return send(res,200,{ok:!cache.error,error:cache.error});} + const file=path.join(PUBLIC,url.pathname==='/'?'index.html':url.pathname.slice(1)); + if(!file.startsWith(PUBLIC)||!fs.existsSync(file)) return send(res,404,{error:'Not found'}); + 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}`))); diff --git a/test/parser.test.js b/test/parser.test.js new file mode 100644 index 0000000..be804a0 --- /dev/null +++ b/test/parser.test.js @@ -0,0 +1,2 @@ +const test=require('node:test');const assert=require('node:assert/strict'); +test('API-equivalent cost formula handles cached input separately',()=>{const input=1_000_000,cached=500_000,output=100_000;const cost=((input-cached)*1.75+cached*.175+output*14)/1e6;assert.equal(cost,2.3625)});