metrascope 0.1.0 → 0.1.2

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 CHANGED
@@ -1,10 +1,22 @@
1
- # metrascope
1
+ <p align="center">
2
+ <img src="assets/logo.svg" width="120" height="120" alt="Metrascope logo">
3
+ </p>
4
+
5
+ <h1 align="center">metrascope</h1>
6
+
7
+ [![npm version](https://img.shields.io/npm/v/metrascope.svg)](https://www.npmjs.com/package/metrascope)
8
+ [![license](https://img.shields.io/npm/l/metrascope.svg)](./LICENSE)
9
+ [![node](https://img.shields.io/node/v/metrascope.svg)](https://nodejs.org)
2
10
 
3
11
  See where your coding-agent tokens go. One local command, no upload.
4
12
 
5
13
  A unified, local dashboard for **multiple coding agents** — pick an agent in the
6
14
  header and see its own usage. Auto-detects whatever you have installed.
7
15
 
16
+ ```bash
17
+ npx metrascope
18
+ ```
19
+
8
20
  | Agent | Reads | Tokens | Reasoning | Cache | Est. cost | Rate limit |
9
21
  |---|---|---|---|---|---|---|
10
22
  | **Codex** | `~/.codex` sessions | ✓ | ✓ | ✓ | — | ✓ |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metrascope",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "See where your coding-agent tokens go. One local command — Codex, Claude Code, Qwen Code, OpenCode & more. Zero upload.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -35,11 +35,11 @@
35
35
  "license": "MIT",
36
36
  "repository": {
37
37
  "type": "git",
38
- "url": "git+https://github.com/Buckibarnes17/codex-spend.git"
38
+ "url": "git+https://github.com/Buckibarnes17/metrascope.git"
39
39
  },
40
- "homepage": "https://github.com/Buckibarnes17/codex-spend#readme",
40
+ "homepage": "https://github.com/Buckibarnes17/metrascope#readme",
41
41
  "bugs": {
42
- "url": "https://github.com/Buckibarnes17/codex-spend/issues"
42
+ "url": "https://github.com/Buckibarnes17/metrascope/issues"
43
43
  },
44
44
  "dependencies": {
45
45
  "express": "^4.21.0",
@@ -9,7 +9,31 @@ const id = 'claude';
9
9
  const label = 'Claude Code';
10
10
  const mark = 'CC';
11
11
  const accent = '#cc785c';
12
- const capabilities = { cost: true, reasoning: false, rateLimit: false, cache: true, tools: true, contextWindow: false };
12
+ const capabilities = { cost: true, reasoning: false, rateLimit: false, cache: true, tools: true, contextWindow: false, limitEvents: true };
13
+
14
+ // Claude Code logs a synthetic 429 assistant message when you hit a usage limit,
15
+ // e.g. "You've hit your session limit · resets 4:20pm (Asia/Kolkata)".
16
+ const LIMIT_RX = /hit your (?:(\w+) )?limit\s*[·.]?\s*resets\s+([0-9]{1,2}:[0-9]{2}\s*[ap]m)\s*\(([^)]+)\)/i;
17
+ function limitHitFromEntry(entry) {
18
+ if (entry.apiErrorStatus !== 429 && entry.error !== 'rate_limit') return null;
19
+ const content = entry.message && entry.message.content;
20
+ const text = Array.isArray(content) ? content.map((b) => b.text || '').join(' ') : (typeof content === 'string' ? content : '');
21
+ const m = text.match(LIMIT_RX);
22
+ if (!m) return null;
23
+ return {
24
+ timestamp: entry.timestamp || null,
25
+ kind: /session/i.test(m[1] || '') ? 'session' : 'weekly',
26
+ reset: m[2].replace(/\s+/g, ''),
27
+ tz: m[3],
28
+ };
29
+ }
30
+ function summarizeLimitHits(hits) {
31
+ const pick = (kind) => {
32
+ const list = hits.filter((h) => h.kind === kind).sort((a, b) => (b.timestamp || '').localeCompare(a.timestamp || ''));
33
+ return { count: list.length, last: list[0] || null };
34
+ };
35
+ return { total: hits.length, session: pick('session'), weekly: pick('weekly') };
36
+ }
13
37
 
14
38
  // Anthropic API per-token pricing (estimate; subscription billing differs).
15
39
  const MODEL_PRICING = {
@@ -122,6 +146,7 @@ async function parse(options = {}) {
122
146
 
123
147
  const warnings = [];
124
148
  const sessions = [];
149
+ const limitHits = [];
125
150
  let projectDirs = [];
126
151
  try { projectDirs = fs.readdirSync(projectsDir).filter((d) => { try { return fs.statSync(path.join(projectsDir, d)).isDirectory(); } catch { return false; } }); } catch { /* */ }
127
152
 
@@ -135,6 +160,7 @@ async function parse(options = {}) {
135
160
  let entries;
136
161
  try { entries = await parseJSONLFile(filePath); } catch { continue; }
137
162
  if (!entries.length) continue;
163
+ for (const e of entries) { const hit = limitHitFromEntry(e); if (hit) limitHits.push(hit); }
138
164
  const rawTurns = extractTurns(entries);
139
165
  if (!rawTurns.length) continue;
140
166
 
@@ -177,7 +203,9 @@ async function parse(options = {}) {
177
203
  }
178
204
  }
179
205
  if (!sessions.length) return emptyResult(source, capabilities, [{ type: 'no-sessions', message: 'No Claude Code sessions with usage found.' }]);
180
- return buildResult(sessions, source, capabilities, warnings);
206
+ const result = buildResult(sessions, source, capabilities, warnings);
207
+ result.limitEvents = summarizeLimitHits(limitHits);
208
+ return result;
181
209
  }
182
210
 
183
211
  module.exports = { id, label, mark, accent, capabilities, home, detect, parse };
Binary file
@@ -0,0 +1,9 @@
1
+ <svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Metrascope">
2
+ <!-- dark bars -->
3
+ <rect x="255" y="615" width="85" height="210" rx="16" fill="#2f333a"/>
4
+ <rect x="405" y="485" width="85" height="340" rx="16" fill="#2f333a"/>
5
+ <rect x="710" y="565" width="85" height="260" rx="16" fill="#2f333a"/>
6
+ <!-- gold "i" bar + dot (the highlighted metric) -->
7
+ <rect x="560" y="310" width="85" height="515" rx="16" fill="#d4a44a"/>
8
+ <circle cx="602" cy="205" r="48" fill="#d4a44a"/>
9
+ </svg>
@@ -3,7 +3,9 @@
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>Agent Spend</title>
6
+ <title>Metrascope</title>
7
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
8
+ <link rel="alternate icon" type="image/png" href="/favicon.png">
7
9
  <link rel="preconnect" href="https://fonts.googleapis.com">
8
10
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
11
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
@@ -37,6 +39,7 @@ button,input{font:inherit}
37
39
  header{display:flex;align-items:center;justify-content:space-between;gap:18px;margin-bottom:22px}
38
40
  .brand{display:flex;align-items:center;gap:12px}
39
41
  .mark{width:36px;height:36px;border-radius:9px;color:var(--brand-fg);background:linear-gradient(160deg,var(--brand),var(--brand-hover));display:grid;place-items:center;font-weight:850;font-size:14px;letter-spacing:.02em;box-shadow:var(--edge)}
42
+ .logo{width:38px;height:38px;display:block;flex:0 0 auto}
40
43
  h1{margin:0;font-size:18px;line-height:1.1;letter-spacing:-.01em}
41
44
  .sub{margin-top:3px;color:var(--soft);font-size:11px;font-family:var(--mono);max-width:44ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
42
45
  .actions{display:flex;align-items:center;gap:9px;flex-wrap:wrap;justify-content:flex-end}
@@ -188,7 +191,7 @@ tbody tr:hover td{background:color-mix(in srgb,var(--surface-2) 70%,transparent)
188
191
  </style>
189
192
  </head>
190
193
  <body>
191
- <div id="app" class="loading">Loading agent usage&hellip;</div>
194
+ <div id="app" class="loading">Loading Metrascope&hellip;</div>
192
195
  <div class="scrim" id="scrim" onclick="closeDrawer()"></div>
193
196
  <div class="chart-tip" id="chartTip"></div>
194
197
  <aside class="drawer" id="drawer" aria-hidden="true"><div id="drawerContent"></div></aside>
@@ -244,17 +247,16 @@ function render(){
244
247
  const t=DATA.totals, app=document.getElementById('app'), src=DATA.source||{}, c=caps();
245
248
  app.className='wrap';
246
249
  app.innerHTML=`
247
- <header class="anim"><div class="brand"><div class="mark">${esc(src.mark||'CX')}</div><div><h1>Agent Spend</h1><div class="sub" title="${esc(src.home||'')}">${esc(src.label||'')} · ${esc(src.home||'')}${t.dateRange?' · '+t.dateRange.from+' → '+t.dateRange.to:''}</div></div></div><div class="actions"><button class="btn" onclick="toggleTheme()">${theme==='dark'?'☀ Light':'☾ Dark'}</button><button class="btn" onclick="openShare()">Share</button><button class="btn primary" onclick="refresh()">↻ Refresh</button></div></header>
250
+ <header class="anim"><div class="brand"><img class="logo" src="/favicon.svg" alt="Metrascope" width="38" height="38"><div><h1>Metrascope</h1><div class="sub" title="${esc(src.home||'')}">${esc(src.label||'')} · ${esc(src.home||'')}${t.dateRange?' · '+t.dateRange.from+' → '+t.dateRange.to:''}</div></div></div><div class="actions"><button class="btn" onclick="toggleTheme()">${theme==='dark'?'☀ Light':'☾ Dark'}</button><button class="btn" onclick="openShare()">Share</button><button class="btn primary" onclick="refresh()">↻ Refresh</button></div></header>
248
251
  ${agentBar()}
249
252
  ${DATA.warnings?.length?`<div class="warnbox anim">${DATA.warnings.map(w=>esc(w.message)).join('<br>')}</div>`:''}
250
253
  ${!DATA.sessions.length?`<div class="card empty anim">No ${esc(src.label||'agent')} sessions with token usage found.<div class="muted" style="margin-top:8px">${esc(src.home||'')}</div></div>`:`
251
254
  <div class="summary anim">${kpiStrip(t,c)}</div>
252
- ${c.rateLimit?ratebar(t.latestRateLimit):''}
253
255
  <div class="tabs anim">
254
256
  ${tabBtn('overview','Overview')}${tabBtn('sessions','Sessions')}${tabBtn('prompts','Prompts')}${tabBtn('insights',`Insights · ${DATA.insights.length}`)}
255
257
  </div>
256
258
  <div id="view" class="view"></div>`}`;
257
- if(DATA.sessions.length){renderView();if(c.rateLimit)startRlTimer()}
259
+ if(DATA.sessions.length){renderView()}
258
260
  }
259
261
 
260
262
  function agentBar(){if(!SOURCES.length)return'';return`<div class="agentbar anim">${SOURCES.map(s=>`<button class="agent ${s.id===sourceId?'on':''}" ${s.available?'':'disabled'} title="${s.available?esc(s.home):'not detected on this machine'}" onclick="setSource('${s.id}')"><span class="amark" style="background:${s.available?s.accent:'var(--surface-3)'};color:${s.available?accentFg(s.accent):'var(--faint)'}">${esc(s.mark)}</span>${esc(s.label)}${s.available?'':' <span class="muted" style="font-size:10px">·n/a</span>'}</button>`).join('')}</div>`}
@@ -353,14 +355,6 @@ function promptDetail(p){return`<div><div class="section-head"><h2>Prompt #${p.r
353
355
  ${(p.tools||[]).length?`<div>${p.tools.map(t=>`<span class="tool-chip">${esc(t.tool)} · ${fmt(t.count)}</span>`).join('')}</div>`:''}
354
356
  <div class="turns">${p.turns.map((t,i)=>`<div class="turn"><div class="rk">${i+1}</div><div><strong style="font-size:12px">${timeFmt(t.timestamp)}</strong><div class="muted">${esc(t.model)}${t.contextWindow?' · ctx '+fmt(t.contextWindow):''}</div><div class="token-strip">${strip(t)}</div></div><div class="num">${fmt(t.totalTokens)}<div class="muted" style="font-weight:600">${fmt(freshInput(t))} fresh · ${fmt(t.cachedInputTokens)} cached · ${fmt(t.outputTokens)} out</div></div></div>`).join('')}</div>`}
355
357
 
356
- /* ===== rate-limit bar ===== */
357
- function windowLabel(min){if(!min)return'window';if(min%1440===0)return(min/1440)+'-day';if(min%60===0)return(min/60)+'-hour';return min+'-min'}
358
- function resetText(unixSec){if(!unixSec)return'';const ms=unixSec*1000-Date.now();if(ms<=0)return'resetting now';const m=Math.floor(ms/60000),h=Math.floor(m/60),d=Math.floor(h/24);if(d>=1)return`resets in ${d}d ${h%24}h`;if(h>=1)return`resets in ${h}h ${m%60}m`;return`resets in ${m}m`}
359
- function rlColor(pct){if(pct==null)return'var(--blue)';if(pct>=80)return'var(--rose)';if(pct>=50)return'var(--amber)';return'var(--green)'}
360
- function rlSeg(k,win,pct,reset){const p=pct==null?0:pct;return`<div class="rl"><div class="rl-top"><span class="k">${k} · ${windowLabel(win)}</span><span class="v" style="color:${rlColor(pct)}">${pct==null?'n/a':pct+'%'}</span></div><div class="rl-track"><div class="rl-fill" style="width:${Math.min(100,p)}%;background:${rlColor(pct)}"></div></div><div class="rl-reset" data-reset="${reset||''}">${reset?resetText(reset):'no reset recorded'}</div></div>`}
361
- function ratebar(r){if(!r)return'';return`<div class="ratebar anim"><div class="plan"><span class="muted" style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em">Plan</span><span class="name">${esc(r.planType||'unknown')}</span>${r.reachedType?`<span class="badge" style="background:var(--rose-bg);border-color:var(--rose-border);color:var(--rose)">limit hit</span>`:''}</div>${rlSeg('Primary',r.primaryWindowMinutes,r.primaryUsedPercent,r.primaryResetsAt)}${rlSeg('Weekly',r.secondaryWindowMinutes,r.secondaryUsedPercent,r.secondaryResetsAt)}</div>`}
362
- function startRlTimer(){if(rlTimer)clearInterval(rlTimer);rlTimer=setInterval(()=>{document.querySelectorAll('.rl-reset[data-reset]').forEach(el=>{const v=el.getAttribute('data-reset');if(v)el.textContent=resetText(Number(v))})},30000)}
363
-
364
358
  /* ===== charts ===== */
365
359
  function setupCanvas(id,h){const c=document.getElementById(id);if(!c)return null;const ctx=c.getContext('2d'),dpr=Math.min(window.devicePixelRatio||1,2);let w=c.clientWidth||(c.parentElement&&c.parentElement.clientWidth)||600;w=Math.max(120,Math.min(w,2400));h=h||c.height||210;c.width=Math.round(w*dpr);c.height=Math.round(h*dpr);c.style.height=h+'px';ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,w,h);return{ctx,width:w,height:h,el:c}}
366
360
  /* ---- chart tooltips ---- */