modelmeter-collect 0.8.0 → 0.10.0

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
@@ -12,13 +12,23 @@ It dedupes (state in `~/.modelmeter/collector-state.json`), so every run is safe
12
12
  Not covered: the **ChatGPT** consumer app (no per-message token data exists) and **Cursor**
13
13
  on a Pro plan (usage stays on Cursor's servers).
14
14
 
15
- ## Quick start
15
+ ## Try it, no account
16
+
17
+ ```bash
18
+ # See where your Claude Code / Codex tokens go - signals + tips, runs locally:
19
+ npx modelmeter-collect
20
+ ```
21
+
22
+ With no token configured this reads your local logs and prints your cache reuse, top MCP
23
+ server, context-bloat, and recommendations. Nothing is sent.
24
+
25
+ ## Track it over time
16
26
 
17
27
  ```bash
18
28
  # 1. Grab an ingest token from the Providers tab at https://modelmeter.dev, then:
19
29
  npx modelmeter-collect init mm_live_xxxxxxxx
20
30
 
21
- # 2. Backfill the last couple of weeks:
31
+ # 2. Backfill the last couple of weeks (now that a token is saved, it reports usage):
22
32
  npx modelmeter-collect
23
33
 
24
34
  # Preview what would be sent, without sending:
package/cli.mjs CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  claudeSessionSummary,
24
24
  codexSessionSummary,
25
25
  buildLocalRecommendations,
26
+ summarizeSignals,
26
27
  } from './lib.mjs'
27
28
 
28
29
  const HOME = homedir()
@@ -72,8 +73,9 @@ Commands:
72
73
  status, and exactly what would be sent. Add --recommendations for
73
74
  local optimization tips (cache, MCP, output, context bloat), or
74
75
  --payload for the raw JSON (token counts only, never transcript text).
75
- (none) Scan Claude Code + Codex logs and report token counts. Deduped,
76
- so it is safe to run repeatedly. MODELMETER_DRYRUN=1 previews only.
76
+ (none) No token yet? Shows your local optimization signals + tips from
77
+ Claude Code / Codex logs, no account needed. With a token saved,
78
+ it scans and reports usage (deduped, safe to run repeatedly).
77
79
 
78
80
  It sends only model names and token counts. Never your prompts, never your keys.
79
81
  Get an ingest token from the Providers tab at https://modelmeter.dev`)
@@ -185,7 +187,7 @@ function recentSessionFiles(dir, cutoffMs, limit) {
185
187
  .map((x) => x.p)
186
188
  }
187
189
 
188
- function printRecommendations(cutoffMs) {
190
+ function readSummaries(cutoffMs) {
189
191
  const summaries = []
190
192
  const sources = [
191
193
  [join(HOME, '.claude', 'projects'), claudeSessionSummary],
@@ -203,6 +205,27 @@ function printRecommendations(cutoffMs) {
203
205
  if (summary) summaries.push(summary)
204
206
  }
205
207
  }
208
+ return summaries
209
+ }
210
+
211
+ function pct(x) {
212
+ return `${Math.round(x * 100)}%`
213
+ }
214
+
215
+ // A glance at the optimization signals (the same dimensions ModelMeter surfaces), so
216
+ // the command shows value, not just discovery.
217
+ function printSignals(summaries) {
218
+ if (summaries.length === 0) return
219
+ const s = summarizeSignals(summaries)
220
+ console.log(`\nOptimization signals (last 14 days, ${s.sessions} sessions, computed locally):`)
221
+ console.log(` cache reuse: ${pct(s.cacheRate)}`)
222
+ console.log(` output share: ${pct(s.outputShare)}`)
223
+ if (s.reasoningShare >= 0.005) console.log(` reasoning: ${pct(s.reasoningShare)} of output`)
224
+ if (s.topMcp) console.log(` top MCP: ${s.topMcp} (${pct(s.topMcpShare)} of tool tokens)`)
225
+ console.log(` context bloat: ${s.bloatedSessions} session${s.bloatedSessions === 1 ? '' : 's'} grew past 2x`)
226
+ }
227
+
228
+ function printRecommendations(summaries) {
206
229
  console.log('\nLocal recommendations (computed from your logs, nothing sent):')
207
230
  const recs = buildLocalRecommendations(summaries)
208
231
  if (recs.length === 0) {
@@ -233,7 +256,7 @@ if (cmd === 'doctor') {
233
256
  }),
234
257
  )
235
258
  if (args.includes('--recommendations')) {
236
- printRecommendations(cutoffMs)
259
+ printRecommendations(readSummaries(cutoffMs))
237
260
  process.exit(0)
238
261
  }
239
262
  if (args.includes('--payload')) {
@@ -241,6 +264,7 @@ if (cmd === 'doctor') {
241
264
  process.env.MODELMETER_DRYRUN = '1'
242
265
  await runCollector() // prints the exact payload (counts only), then exits
243
266
  } else {
267
+ printSignals(readSummaries(cutoffMs))
244
268
  console.log(
245
269
  '\nRun `npx modelmeter-collect doctor --recommendations` for local optimization tips, or',
246
270
  )
@@ -249,5 +273,25 @@ if (cmd === 'doctor') {
249
273
  }
250
274
  }
251
275
 
252
- // Default: scan and report.
253
- await runCollector()
276
+ // Default: with a token configured, scan and report (the hook / scheduled path).
277
+ // Without one, this is a new user trying it - show the local value, no account needed.
278
+ const cfg = readConfig()
279
+ const configured = Boolean(process.env.MODELMETER_TOKEN || cfg.token)
280
+ if (configured) {
281
+ await runCollector()
282
+ } else {
283
+ const cutoffMs = Date.now() - 14 * 86_400_000
284
+ const summaries = readSummaries(cutoffMs)
285
+ console.log('ModelMeter: where your Claude Code and Codex tokens go. Local, token counts only.\n')
286
+ if (summaries.length === 0) {
287
+ console.log('No Claude Code or Codex session logs found in the last 14 days.')
288
+ console.log('Use one of those tools, then run `npx modelmeter-collect` again.')
289
+ process.exit(0)
290
+ }
291
+ printSignals(summaries)
292
+ printRecommendations(summaries)
293
+ console.log('\nThis ran entirely on your machine. Nothing was sent.')
294
+ console.log('Track it over time, across repos, or for your team: get a token at')
295
+ console.log('https://modelmeter.dev, then `npx modelmeter-collect init <token>`.')
296
+ process.exit(0)
297
+ }
package/collect.mjs CHANGED
@@ -19,7 +19,8 @@ import {
19
19
  chmodSync,
20
20
  } from 'node:fs'
21
21
  import { homedir } from 'node:os'
22
- import { join } from 'node:path'
22
+ import { join, dirname } from 'node:path'
23
+ import { createHash } from 'node:crypto'
23
24
  import {
24
25
  findLastTokenCount,
25
26
  codexToolFromEvent,
@@ -29,8 +30,9 @@ import {
29
30
  aggregateDaily,
30
31
  aggregateHourly,
31
32
  aggregateTools,
32
- pruneClaudeState,
33
- mergeDetailBatches,
33
+ claudeSessionSummary,
34
+ codexSessionSummary,
35
+ sessionSendRow,
34
36
  } from './lib.mjs'
35
37
 
36
38
  const HOME = homedir()
@@ -64,6 +66,11 @@ const TOKEN = process.env.MODELMETER_TOKEN || cfg.token
64
66
  const INGEST_URL = process.env.MODELMETER_INGEST_URL || cfg.ingestUrl
65
67
  if (!TOKEN || !INGEST_URL) process.exit(0) // not configured: do nothing, never block
66
68
 
69
+ // Repo attribution is opt-in for the human-readable label only. A repo hash is always
70
+ // sent (opaque). The label (the repo folder name, never the full path) is sent only
71
+ // when explicitly enabled, so a repo name never leaves the machine by default.
72
+ const REPO_LABELS = process.env.MODELMETER_REPO_LABELS === '1' || cfg.repoLabels === true
73
+
67
74
  // POST JSON with a hard timeout so a stuck network path can never hang a Stop
68
75
  // hook or pile up scheduled collectors. Callers handle the thrown abort/error.
69
76
  async function postJson(url, body) {
@@ -81,29 +88,13 @@ async function postJson(url, body) {
81
88
  }
82
89
  }
83
90
 
84
- let state = { claude: {}, codex: {}, pendingDetail: { hours: [], tools: [] } }
91
+ let state = { claude: {}, codex: {} }
85
92
  try {
86
- state = {
87
- claude: {},
88
- codex: {},
89
- pendingDetail: { hours: [], tools: [] },
90
- ...JSON.parse(readFileSync(STATE_PATH, 'utf8')),
91
- }
93
+ state = { claude: {}, codex: {}, ...JSON.parse(readFileSync(STATE_PATH, 'utf8')) }
92
94
  } catch {
93
95
  // first run
94
96
  }
95
97
 
96
- function saveState() {
97
- if (!existsSync(MM_DIR)) mkdirSync(MM_DIR, { recursive: true })
98
- state.claude = pruneClaudeState(state.claude, CLAUDE_STATE_CAP)
99
- writeFileSync(STATE_PATH, JSON.stringify(state))
100
- try {
101
- chmodSync(STATE_PATH, 0o600) // usage metadata is not secret, but keep it owner-only
102
- } catch {
103
- // best effort on platforms without POSIX perms
104
- }
105
- }
106
-
107
98
  // --- invocation detection -------------------------------------------------
108
99
  let hookInput = null
109
100
  if (!process.stdin.isTTY) {
@@ -177,7 +168,7 @@ function scanClaude(files) {
177
168
  }
178
169
  const ev = claudeEventFromLine(o)
179
170
  if (!ev || !ev.id || state.claude[ev.id]) continue
180
- state.claude[ev.id] = { ts: ev.occurredAt || ev.occurredOn || '' }
171
+ state.claude[ev.id] = 1
181
172
  events.push(ev)
182
173
  }
183
174
  }
@@ -234,13 +225,66 @@ function scanCodex(files) {
234
225
  }
235
226
  }
236
227
 
228
+ let claudeFiles = []
229
+ let codexFiles = []
237
230
  if (hookInput?.transcript_path) {
238
- scanClaude([hookInput.transcript_path]) // Claude Code hook: just this session
231
+ claudeFiles = [hookInput.transcript_path] // Claude Code hook: just this session
239
232
  } else if (codexNotify) {
240
- scanCodex(recentFiles(join(HOME, '.codex', 'sessions'), 2)) // Codex notify: newest session(s)
233
+ codexFiles = recentFiles(join(HOME, '.codex', 'sessions'), 2) // Codex notify: newest session(s)
241
234
  } else {
242
- scanClaude(recentFiles(join(HOME, '.claude', 'projects')))
243
- scanCodex(recentFiles(join(HOME, '.codex', 'sessions')))
235
+ claudeFiles = recentFiles(join(HOME, '.claude', 'projects'))
236
+ codexFiles = recentFiles(join(HOME, '.codex', 'sessions'))
237
+ }
238
+ scanClaude(claudeFiles)
239
+ scanCodex(codexFiles)
240
+
241
+ // Per-session summaries (context bloat, comparisons) for the detail endpoint. Hash
242
+ // the session-file basename (the session id), never the path, so no repo/project
243
+ // label leaks here. Recomputed in full each run; the backend overwrites by hash.
244
+ const SESSION_CAP = 500
245
+ function sessionHashFor(file) {
246
+ const base = file.split('/').pop() || file
247
+ return createHash('sha256').update(base).digest('hex').slice(0, 40)
248
+ }
249
+ // Resolve a cwd to its git-root for a stable repo identity (so subdirs of one repo do
250
+ // not fragment). Falls back to the cwd if no .git is found (e.g. the repo is gone).
251
+ function repoRoot(cwd) {
252
+ if (!cwd) return ''
253
+ let dir = cwd
254
+ for (let i = 0; i < 12; i++) {
255
+ try {
256
+ if (existsSync(join(dir, '.git'))) return dir
257
+ } catch {
258
+ // ignore
259
+ }
260
+ const parent = dirname(dir)
261
+ if (parent === dir) break
262
+ dir = parent
263
+ }
264
+ return cwd
265
+ }
266
+ const sessions = []
267
+ for (const [files, summarize] of [
268
+ [claudeFiles, claudeSessionSummary],
269
+ [codexFiles, codexSessionSummary],
270
+ ]) {
271
+ for (const file of files.slice(0, SESSION_CAP)) {
272
+ let text = ''
273
+ try {
274
+ text = readFileSync(file, 'utf8')
275
+ } catch {
276
+ continue
277
+ }
278
+ const summary = summarize(text)
279
+ const row = sessionSendRow(summary, sessionHashFor(file))
280
+ if (!row) continue
281
+ const root = repoRoot(summary.cwd)
282
+ if (root) {
283
+ row.repoHash = createHash('sha256').update(root).digest('hex').slice(0, 40)
284
+ if (REPO_LABELS) row.repoLabel = (root.split('/').pop() || root).slice(0, 80)
285
+ }
286
+ sessions.push(row)
287
+ }
244
288
  }
245
289
 
246
290
  // Collapse to one row per (provider, model, day) so the request stays small.
@@ -255,11 +299,10 @@ const HOURLY_URL = deriveHourlyUrl(INGEST_URL, process.env.MODELMETER_HOURLY_ING
255
299
  // Per-tool / per-MCP attribution. Claude even-splits each turn's tokens across the
256
300
  // tools it called; Codex contributes precomputed deltas. Calls are exact.
257
301
  const toolsPayload = aggregateTools(events, codexToolDeltas, today)
258
- const currentDetail = { hours: hourly, tools: toolsPayload }
259
- const detailBatch = mergeDetailBatches(state.pendingDetail, currentDetail)
260
- const hasDetail = detailBatch.hours.length > 0 || detailBatch.tools.length > 0
261
302
 
262
- if (payload.length === 0 && !hasDetail) {
303
+ const haveDaily = payload.length > 0
304
+ const haveDetail = hourly.length > 0 || toolsPayload.length > 0 || sessions.length > 0
305
+ if (!haveDaily && !haveDetail) {
263
306
  process.exit(0)
264
307
  }
265
308
 
@@ -267,23 +310,34 @@ if (process.env.MODELMETER_DRYRUN) {
267
310
  const tally = {}
268
311
  for (const e of events) tally[e.provider] = (tally[e.provider] || 0) + 1
269
312
  console.log(`DRY RUN: ${events.length} raw events -> ${payload.length} daily rows`, tally)
270
- console.log(` + ${hourly.length} recent hourly rows, ${toolsPayload.length} tool rows -> ${HOURLY_URL}`)
271
- if (state.pendingDetail?.hours?.length || state.pendingDetail?.tools?.length) {
272
- console.log(
273
- ` + pending retry rows: ${state.pendingDetail.hours?.length || 0} hourly, ${state.pendingDetail.tools?.length || 0} tool`,
274
- )
275
- }
313
+ console.log(` + ${hourly.length} recent hourly rows, ${toolsPayload.length} tool rows, ${sessions.length} session summaries -> ${HOURLY_URL}`)
276
314
  console.log(JSON.stringify(payload, null, 2))
277
315
  if (toolsPayload.length) console.log('tools:', JSON.stringify(toolsPayload, null, 2))
316
+ if (sessions.length) console.log('sessions:', JSON.stringify(sessions.slice(0, 3), null, 2))
278
317
  process.exit(0)
279
318
  }
280
319
 
281
- let committed = false
282
- if (payload.length > 0) {
320
+ // With no new daily events there is nothing to commit, but session summaries (and any
321
+ // timestamped detail) are recomputed in full each run and must still be sent, so treat
322
+ // that case as already committed.
323
+ let committed = !haveDaily
324
+ if (haveDaily) {
283
325
  try {
284
326
  const res = await postJson(INGEST_URL, { source: 'collector', events: payload })
285
327
  if (res.ok) {
286
- saveState()
328
+ if (!existsSync(MM_DIR)) mkdirSync(MM_DIR, { recursive: true })
329
+ const claudeIds = Object.keys(state.claude)
330
+ if (claudeIds.length > CLAUDE_STATE_CAP) {
331
+ const next = {}
332
+ for (const id of claudeIds.slice(-CLAUDE_STATE_CAP)) next[id] = 1
333
+ state.claude = next
334
+ }
335
+ writeFileSync(STATE_PATH, JSON.stringify(state))
336
+ try {
337
+ chmodSync(STATE_PATH, 0o600) // usage metadata is not secret, but keep it owner-only
338
+ } catch {
339
+ // best effort on platforms without POSIX perms
340
+ }
287
341
  committed = true
288
342
  console.error(`modelmeter: reported ${payload.length} usage rows`)
289
343
  } else {
@@ -292,32 +346,24 @@ if (payload.length > 0) {
292
346
  } catch (err) {
293
347
  console.error(`modelmeter: ${err.message}`)
294
348
  }
295
- } else {
296
- committed = true // retrying previously committed detail rows
297
349
  }
298
350
 
299
- // Additive detail rows are sent only after daily usage is committed. If the detail
300
- // endpoint fails, keep the merged batch in state and retry on the next run.
301
- if (committed && hasDetail) {
302
- if (!HOURLY_URL) {
303
- state.pendingDetail = detailBatch
304
- saveState()
305
- console.error('modelmeter: detail ingest URL could not be derived; set MODELMETER_HOURLY_INGEST_URL')
306
- process.exit(0)
307
- }
308
- let detailSent = false
351
+ // Detail rows go only after the daily batch is committed (or when there was none), so a
352
+ // daily retry cannot race them. The endpoint applies them in one transaction; retry once
353
+ // to cover a transient failure, then give up (the window self-heals as new data flows).
354
+ if (committed && haveDetail) {
309
355
  for (let attempt = 0; attempt < 2; attempt++) {
310
356
  try {
311
- const res = await postJson(HOURLY_URL, { source: 'collector', ...detailBatch })
312
- if (res.ok) {
313
- detailSent = true
314
- break
315
- }
357
+ const res = await postJson(HOURLY_URL, {
358
+ source: 'collector',
359
+ hours: hourly,
360
+ tools: toolsPayload,
361
+ sessions,
362
+ })
363
+ if (res.ok) break
316
364
  } catch {
317
365
  // fall through to one retry, then give up
318
366
  }
319
367
  }
320
- state.pendingDetail = detailSent ? { hours: [], tools: [] } : detailBatch
321
- saveState()
322
368
  }
323
369
  process.exit(0)
package/lib.mjs CHANGED
@@ -356,7 +356,9 @@ export function claudeSessionSummary(text) {
356
356
  }
357
357
  }
358
358
  if (requests === 0) return null
359
- return { provider: 'anthropic', model, uncached, cacheRead, cacheCreate, output, requests, firstTs, lastTs, cwd, contextSeq, tools }
359
+ // Claude folds extended-thinking into output_tokens with no separate field, so
360
+ // reasoning is 0 here (the breakdown is only available for Codex).
361
+ return { provider: 'anthropic', model, uncached, cacheRead, cacheCreate, output, reasoning: 0, requests, firstTs, lastTs, cwd, contextSeq, tools }
360
362
  }
361
363
 
362
364
  // Summarize one Codex session from its final cumulative token_count. Codex totals are
@@ -401,7 +403,11 @@ export function codexSessionSummary(text) {
401
403
  if (!totals) return null
402
404
  const cacheRead = totals.cached_input_tokens || 0
403
405
  const uncached = Math.max(0, (totals.input_tokens || 0) - cacheRead)
404
- const output = (totals.output_tokens || 0) + (totals.reasoning_output_tokens || 0)
406
+ // Reasoning tokens bill as output (the priciest tier) but are reported separately and
407
+ // are otherwise hidden, so keep the breakdown: output is the total billed output and
408
+ // reasoning is the share of it spent thinking.
409
+ const reasoning = totals.reasoning_output_tokens || 0
410
+ const output = (totals.output_tokens || 0) + reasoning
405
411
  return {
406
412
  provider: 'openai',
407
413
  model,
@@ -409,6 +415,7 @@ export function codexSessionSummary(text) {
409
415
  cacheRead,
410
416
  cacheCreate: 0,
411
417
  output,
418
+ reasoning,
412
419
  requests: Math.max(1, requests),
413
420
  firstTs,
414
421
  lastTs,
@@ -439,6 +446,7 @@ export function sessionSendRow(summary, sessionHash) {
439
446
  cacheRead: summary.cacheRead,
440
447
  cacheCreation: summary.cacheCreate,
441
448
  output: summary.output,
449
+ reasoning: summary.reasoning || 0,
442
450
  maxInputTurn: bloat ? bloat.max : 0,
443
451
  first5Avg: bloat ? bloat.first5 : 0,
444
452
  last5Avg: bloat ? bloat.last5 : 0,
@@ -464,6 +472,7 @@ export function buildLocalRecommendations(summaries) {
464
472
  let cacheRead = 0
465
473
  let cacheCreate = 0
466
474
  let output = 0
475
+ let reasoning = 0
467
476
  let requests = 0
468
477
  const tools = {}
469
478
  for (const s of list) {
@@ -471,6 +480,7 @@ export function buildLocalRecommendations(summaries) {
471
480
  cacheRead += s.cacheRead
472
481
  cacheCreate += s.cacheCreate
473
482
  output += s.output
483
+ reasoning += s.reasoning || 0
474
484
  requests += s.requests
475
485
  for (const [g, v] of Object.entries(s.tools || {})) {
476
486
  const cur = tools[g] || { tokens: 0, calls: 0 }
@@ -547,7 +557,19 @@ export function buildLocalRecommendations(summaries) {
547
557
  })
548
558
  }
549
559
 
550
- // 4. Context bloat (Claude sessions carry a per-turn sequence).
560
+ // 4. Reasoning share (Codex). Reasoning bills as output, the priciest tier, but is
561
+ // hidden by default, so a large reasoning share is invisible cost.
562
+ if (output > 100_000 && reasoning / output > 0.4) {
563
+ recs.push({
564
+ kind: 'reasoning',
565
+ level: 'warn',
566
+ text: `Reasoning is ${pct(
567
+ reasoning / output,
568
+ )} of your output tokens, the priciest tier, and hidden by default. Lower the reasoning effort for routine tasks and reserve high effort for genuinely hard problems.`,
569
+ })
570
+ }
571
+
572
+ // 5. Context bloat (Claude sessions carry a per-turn sequence).
551
573
  let worst = null
552
574
  for (const s of list) {
553
575
  const b = sessionBloat(s.contextSeq)
@@ -567,3 +589,45 @@ export function buildLocalRecommendations(summaries) {
567
589
 
568
590
  return recs
569
591
  }
592
+
593
+ // Headline optimization signals (metrics, not recommendations) for `doctor` to show
594
+ // at a glance: the same dimensions ModelMeter surfaces, computed locally.
595
+ export function summarizeSignals(summaries) {
596
+ const list = (summaries || []).filter(Boolean)
597
+ let uncached = 0
598
+ let cacheRead = 0
599
+ let cacheCreate = 0
600
+ let output = 0
601
+ let reasoning = 0
602
+ let bloated = 0
603
+ const tools = {}
604
+ for (const s of list) {
605
+ uncached += s.uncached
606
+ cacheRead += s.cacheRead
607
+ cacheCreate += s.cacheCreate
608
+ output += s.output
609
+ reasoning += s.reasoning || 0
610
+ const b = sessionBloat(s.contextSeq)
611
+ if (b && b.last5 > b.first5 * 2 && b.last5 > 30_000) bloated++
612
+ for (const [g, v] of Object.entries(s.tools || {})) {
613
+ tools[g] = (tools[g] || 0) + v.tokens
614
+ }
615
+ }
616
+ const inputTotal = uncached + cacheRead + cacheCreate
617
+ const total = inputTotal + output
618
+ const toolArr = Object.entries(tools)
619
+ .map(([tool, tok]) => ({ tool, tok }))
620
+ .sort((a, b) => b.tok - a.tok)
621
+ const toolTotal = toolArr.reduce((n, t) => n + t.tok, 0)
622
+ const topMcp = toolArr.find((t) => t.tool.startsWith('mcp:'))
623
+ return {
624
+ sessions: list.length,
625
+ totalTokens: total,
626
+ cacheRate: inputTotal > 0 ? cacheRead / inputTotal : 0,
627
+ outputShare: total > 0 ? output / total : 0,
628
+ reasoningShare: output > 0 ? reasoning / output : 0,
629
+ topMcp: topMcp ? topMcp.tool : null,
630
+ topMcpShare: topMcp && toolTotal > 0 ? topMcp.tok / toolTotal : 0,
631
+ bloatedSessions: bloated,
632
+ }
633
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modelmeter-collect",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Report LLM token usage from local Claude Code / Codex logs to ModelMeter. Token counts only, never prompts or keys.",
5
5
  "type": "module",
6
6
  "bin": {