metrascope 0.1.0 → 0.1.1

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,18 @@
1
1
  # metrascope
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/metrascope.svg)](https://www.npmjs.com/package/metrascope)
4
+ [![license](https://img.shields.io/npm/l/metrascope.svg)](./LICENSE)
5
+ [![node](https://img.shields.io/node/v/metrascope.svg)](https://nodejs.org)
6
+
3
7
  See where your coding-agent tokens go. One local command, no upload.
4
8
 
5
9
  A unified, local dashboard for **multiple coding agents** — pick an agent in the
6
10
  header and see its own usage. Auto-detects whatever you have installed.
7
11
 
12
+ ```bash
13
+ npx metrascope
14
+ ```
15
+
8
16
  | Agent | Reads | Tokens | Reasoning | Cache | Est. cost | Rate limit |
9
17
  |---|---|---|---|---|---|---|
10
18
  | **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.1",
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": {
@@ -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 };
@@ -3,7 +3,7 @@
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
7
  <link rel="preconnect" href="https://fonts.googleapis.com">
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
9
  <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">
@@ -188,7 +188,7 @@ tbody tr:hover td{background:color-mix(in srgb,var(--surface-2) 70%,transparent)
188
188
  </style>
189
189
  </head>
190
190
  <body>
191
- <div id="app" class="loading">Loading agent usage&hellip;</div>
191
+ <div id="app" class="loading">Loading Metrascope&hellip;</div>
192
192
  <div class="scrim" id="scrim" onclick="closeDrawer()"></div>
193
193
  <div class="chart-tip" id="chartTip"></div>
194
194
  <aside class="drawer" id="drawer" aria-hidden="true"><div id="drawerContent"></div></aside>
@@ -244,17 +244,16 @@ function render(){
244
244
  const t=DATA.totals, app=document.getElementById('app'), src=DATA.source||{}, c=caps();
245
245
  app.className='wrap';
246
246
  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>
247
+ <header class="anim"><div class="brand"><div class="mark">${esc(src.mark||'CX')}</div><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
248
  ${agentBar()}
249
249
  ${DATA.warnings?.length?`<div class="warnbox anim">${DATA.warnings.map(w=>esc(w.message)).join('<br>')}</div>`:''}
250
250
  ${!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
251
  <div class="summary anim">${kpiStrip(t,c)}</div>
252
- ${c.rateLimit?ratebar(t.latestRateLimit):''}
253
252
  <div class="tabs anim">
254
253
  ${tabBtn('overview','Overview')}${tabBtn('sessions','Sessions')}${tabBtn('prompts','Prompts')}${tabBtn('insights',`Insights · ${DATA.insights.length}`)}
255
254
  </div>
256
255
  <div id="view" class="view"></div>`}`;
257
- if(DATA.sessions.length){renderView();if(c.rateLimit)startRlTimer()}
256
+ if(DATA.sessions.length){renderView()}
258
257
  }
259
258
 
260
259
  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 +352,6 @@ function promptDetail(p){return`<div><div class="section-head"><h2>Prompt #${p.r
353
352
  ${(p.tools||[]).length?`<div>${p.tools.map(t=>`<span class="tool-chip">${esc(t.tool)} · ${fmt(t.count)}</span>`).join('')}</div>`:''}
354
353
  <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
354
 
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
355
  /* ===== charts ===== */
365
356
  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
357
  /* ---- chart tooltips ---- */