modelmeter-collect 0.5.0 → 0.8.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
@@ -31,13 +31,19 @@ Prefer env vars? Set `MODELMETER_TOKEN` and `MODELMETER_INGEST_URL` and skip `in
31
31
  ## Check your setup
32
32
 
33
33
  ```bash
34
- npx modelmeter-collect doctor # which logs were found, last activity, config
35
- npx modelmeter-collect doctor --payload # + the exact JSON that would be sent
34
+ npx modelmeter-collect doctor # which logs were found, last activity, config
35
+ npx modelmeter-collect doctor --recommendations # local optimization tips from your logs
36
+ npx modelmeter-collect doctor --payload # + the exact JSON that would be sent
36
37
  ```
37
38
 
38
39
  `doctor` confirms it found your Claude Code and Codex logs and shows precisely what leaves
39
40
  your machine: model names, token counts, and tool/MCP names only. Never prompts or keys.
40
41
 
42
+ `doctor --recommendations` scores your recent sessions locally (nothing is sent) and prints
43
+ optimization tips: cache reuse, a dominant MCP server, output verbosity, and context bloat
44
+ (a session whose per-turn context kept growing). All computed from token counts, never your
45
+ prompt text.
46
+
41
47
  ## Keep it live (per prompt)
42
48
 
43
49
  **Claude Code** — add a `Stop` hook (fires after every response). It passes the session
@@ -81,6 +87,7 @@ does the same job.)
81
87
  | --- | --- | --- |
82
88
  | `MODELMETER_TOKEN` | from config file | Your `mm_live_...` ingest token |
83
89
  | `MODELMETER_INGEST_URL` | from config file | The ingest endpoint |
90
+ | `MODELMETER_HOURLY_INGEST_URL` | derived from ingest URL | Detail endpoint for recent hourly/tool rows |
84
91
  | `MODELMETER_LOOKBACK_DAYS` | `14` | How many days of logs to scan |
85
92
  | `MODELMETER_DRYRUN` | unset | When set, print the payload instead of sending |
86
93
 
package/cli.mjs CHANGED
@@ -18,7 +18,12 @@ import {
18
18
  import { homedir } from 'node:os'
19
19
  import { join, dirname } from 'node:path'
20
20
  import { fileURLToPath } from 'node:url'
21
- import { formatDoctorReport } from './lib.mjs'
21
+ import {
22
+ formatDoctorReport,
23
+ claudeSessionSummary,
24
+ codexSessionSummary,
25
+ buildLocalRecommendations,
26
+ } from './lib.mjs'
22
27
 
23
28
  const HOME = homedir()
24
29
  const MM_DIR = join(HOME, '.modelmeter')
@@ -64,8 +69,9 @@ Commands:
64
69
  init Save your ingest token to ~/.modelmeter/config.json (chmod 600).
65
70
  Pass the token as an argument or via MODELMETER_TOKEN.
66
71
  doctor Check your setup: which logs were found, last activity, config
67
- status, and exactly what would be sent. Add --payload for the raw
68
- JSON (token counts only, never transcript text).
72
+ status, and exactly what would be sent. Add --recommendations for
73
+ local optimization tips (cache, MCP, output, context bloat), or
74
+ --payload for the raw JSON (token counts only, never transcript text).
69
75
  (none) Scan Claude Code + Codex logs and report token counts. Deduped,
70
76
  so it is safe to run repeatedly. MODELMETER_DRYRUN=1 previews only.
71
77
 
@@ -142,6 +148,73 @@ function discoverLogs(dir, cutoffMs) {
142
148
  return { dir, found: true, recentCount, lastWriteMs }
143
149
  }
144
150
 
151
+ // Recent .jsonl session file paths under a logs directory, newest first, capped.
152
+ function recentSessionFiles(dir, cutoffMs, limit) {
153
+ try {
154
+ statSync(dir)
155
+ } catch {
156
+ return []
157
+ }
158
+ const out = []
159
+ const stack = [dir]
160
+ while (stack.length) {
161
+ const d = stack.pop()
162
+ let entries = []
163
+ try {
164
+ entries = readdirSync(d, { withFileTypes: true })
165
+ } catch {
166
+ continue
167
+ }
168
+ for (const e of entries) {
169
+ const p = join(d, e.name)
170
+ if (e.isDirectory()) stack.push(p)
171
+ else if (e.isFile() && p.endsWith('.jsonl')) {
172
+ let m = 0
173
+ try {
174
+ m = statSync(p).mtimeMs
175
+ } catch {
176
+ continue
177
+ }
178
+ if (m >= cutoffMs) out.push({ p, m })
179
+ }
180
+ }
181
+ }
182
+ return out
183
+ .sort((a, b) => b.m - a.m)
184
+ .slice(0, limit)
185
+ .map((x) => x.p)
186
+ }
187
+
188
+ function printRecommendations(cutoffMs) {
189
+ const summaries = []
190
+ const sources = [
191
+ [join(HOME, '.claude', 'projects'), claudeSessionSummary],
192
+ [join(HOME, '.codex', 'sessions'), codexSessionSummary],
193
+ ]
194
+ for (const [dir, summarize] of sources) {
195
+ for (const file of recentSessionFiles(dir, cutoffMs, 200)) {
196
+ let text = ''
197
+ try {
198
+ text = readFileSync(file, 'utf8')
199
+ } catch {
200
+ continue
201
+ }
202
+ const summary = summarize(text)
203
+ if (summary) summaries.push(summary)
204
+ }
205
+ }
206
+ console.log('\nLocal recommendations (computed from your logs, nothing sent):')
207
+ const recs = buildLocalRecommendations(summaries)
208
+ if (recs.length === 0) {
209
+ console.log(' Nothing flagged. Your recent usage looks efficient.')
210
+ return
211
+ }
212
+ for (const r of recs) {
213
+ const mark = r.level === 'warn' ? '!' : r.level === 'ok' ? '+' : '-'
214
+ console.log(` ${mark} ${r.text}`)
215
+ }
216
+ }
217
+
145
218
  if (cmd === 'doctor') {
146
219
  const cfg = readConfig()
147
220
  const lookbackDays = 14
@@ -159,14 +232,19 @@ if (cmd === 'doctor') {
159
232
  codex: discoverLogs(join(HOME, '.codex', 'sessions'), cutoffMs),
160
233
  }),
161
234
  )
235
+ if (args.includes('--recommendations')) {
236
+ printRecommendations(cutoffMs)
237
+ process.exit(0)
238
+ }
162
239
  if (args.includes('--payload')) {
163
240
  console.log('\nNext batch (dry run, nothing is sent):')
164
241
  process.env.MODELMETER_DRYRUN = '1'
165
242
  await runCollector() // prints the exact payload (counts only), then exits
166
243
  } else {
167
244
  console.log(
168
- '\nRun `npx modelmeter-collect doctor --payload` to preview the exact JSON that would be sent.',
245
+ '\nRun `npx modelmeter-collect doctor --recommendations` for local optimization tips, or',
169
246
  )
247
+ console.log('`doctor --payload` to preview the exact JSON that would be sent.')
170
248
  process.exit(0)
171
249
  }
172
250
  }
package/collect.mjs CHANGED
@@ -29,6 +29,8 @@ import {
29
29
  aggregateDaily,
30
30
  aggregateHourly,
31
31
  aggregateTools,
32
+ pruneClaudeState,
33
+ mergeDetailBatches,
32
34
  } from './lib.mjs'
33
35
 
34
36
  const HOME = homedir()
@@ -79,13 +81,29 @@ async function postJson(url, body) {
79
81
  }
80
82
  }
81
83
 
82
- let state = { claude: {}, codex: {} }
84
+ let state = { claude: {}, codex: {}, pendingDetail: { hours: [], tools: [] } }
83
85
  try {
84
- state = { claude: {}, codex: {}, ...JSON.parse(readFileSync(STATE_PATH, 'utf8')) }
86
+ state = {
87
+ claude: {},
88
+ codex: {},
89
+ pendingDetail: { hours: [], tools: [] },
90
+ ...JSON.parse(readFileSync(STATE_PATH, 'utf8')),
91
+ }
85
92
  } catch {
86
93
  // first run
87
94
  }
88
95
 
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
+
89
107
  // --- invocation detection -------------------------------------------------
90
108
  let hookInput = null
91
109
  if (!process.stdin.isTTY) {
@@ -159,7 +177,7 @@ function scanClaude(files) {
159
177
  }
160
178
  const ev = claudeEventFromLine(o)
161
179
  if (!ev || !ev.id || state.claude[ev.id]) continue
162
- state.claude[ev.id] = 1
180
+ state.claude[ev.id] = { ts: ev.occurredAt || ev.occurredOn || '' }
163
181
  events.push(ev)
164
182
  }
165
183
  }
@@ -237,8 +255,11 @@ const HOURLY_URL = deriveHourlyUrl(INGEST_URL, process.env.MODELMETER_HOURLY_ING
237
255
  // Per-tool / per-MCP attribution. Claude even-splits each turn's tokens across the
238
256
  // tools it called; Codex contributes precomputed deltas. Calls are exact.
239
257
  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
240
261
 
241
- if (payload.length === 0) {
262
+ if (payload.length === 0 && !hasDetail) {
242
263
  process.exit(0)
243
264
  }
244
265
 
@@ -247,53 +268,56 @@ if (process.env.MODELMETER_DRYRUN) {
247
268
  for (const e of events) tally[e.provider] = (tally[e.provider] || 0) + 1
248
269
  console.log(`DRY RUN: ${events.length} raw events -> ${payload.length} daily rows`, tally)
249
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
+ }
250
276
  console.log(JSON.stringify(payload, null, 2))
251
277
  if (toolsPayload.length) console.log('tools:', JSON.stringify(toolsPayload, null, 2))
252
278
  process.exit(0)
253
279
  }
254
280
 
255
281
  let committed = false
256
- try {
257
- const res = await postJson(INGEST_URL, { source: 'collector', events: payload })
258
- if (res.ok) {
259
- if (!existsSync(MM_DIR)) mkdirSync(MM_DIR, { recursive: true })
260
- const claudeIds = Object.keys(state.claude)
261
- if (claudeIds.length > CLAUDE_STATE_CAP) {
262
- const next = {}
263
- for (const id of claudeIds.slice(-CLAUDE_STATE_CAP)) next[id] = 1
264
- state.claude = next
265
- }
266
- writeFileSync(STATE_PATH, JSON.stringify(state))
267
- try {
268
- chmodSync(STATE_PATH, 0o600) // usage metadata is not secret, but keep it owner-only
269
- } catch {
270
- // best effort on platforms without POSIX perms
282
+ if (payload.length > 0) {
283
+ try {
284
+ const res = await postJson(INGEST_URL, { source: 'collector', events: payload })
285
+ if (res.ok) {
286
+ saveState()
287
+ committed = true
288
+ console.error(`modelmeter: reported ${payload.length} usage rows`)
289
+ } else {
290
+ console.error(`modelmeter: ingest returned ${res.status}`)
271
291
  }
272
- committed = true
273
- console.error(`modelmeter: reported ${payload.length} usage rows`)
274
- } else {
275
- console.error(`modelmeter: ingest returned ${res.status}`)
292
+ } catch (err) {
293
+ console.error(`modelmeter: ${err.message}`)
276
294
  }
277
- } catch (err) {
278
- console.error(`modelmeter: ${err.message}`)
295
+ } else {
296
+ committed = true // retrying previously committed detail rows
279
297
  }
280
298
 
281
- // Additive + best-effort: only after the daily batch is committed (state written),
282
- // so a retry cannot double-count into the hourly window. The daily state is already
283
- // committed, so these detail rows will not be resent; retry once to cover a transient
284
- // failure, then give up (the window self-heals as new data flows).
285
- if (committed && (hourly.length > 0 || toolsPayload.length > 0)) {
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
286
309
  for (let attempt = 0; attempt < 2; attempt++) {
287
310
  try {
288
- const res = await postJson(HOURLY_URL, {
289
- source: 'collector',
290
- hours: hourly,
291
- tools: toolsPayload,
292
- })
293
- if (res.ok) break
311
+ const res = await postJson(HOURLY_URL, { source: 'collector', ...detailBatch })
312
+ if (res.ok) {
313
+ detailSent = true
314
+ break
315
+ }
294
316
  } catch {
295
317
  // fall through to one retry, then give up
296
318
  }
297
319
  }
320
+ state.pendingDetail = detailSent ? { hours: [], tools: [] } : detailBatch
321
+ saveState()
298
322
  }
299
323
  process.exit(0)
package/lib.mjs CHANGED
@@ -42,6 +42,25 @@ export function findLastTokenCount(obj) {
42
42
  return last
43
43
  }
44
44
 
45
+ // Depth-first find of the first string `cwd` in a parsed line (Claude carries it at
46
+ // top level, Codex nests it under payload). Used only to derive a repo identity; the
47
+ // collector hashes it and never sends the raw path.
48
+ export function findCwd(obj) {
49
+ const stack = [obj]
50
+ let guard = 0
51
+ while (stack.length && guard < 5000) {
52
+ guard++
53
+ const d = stack.pop()
54
+ if (Array.isArray(d)) {
55
+ for (const v of d) stack.push(v)
56
+ } else if (d && typeof d === 'object') {
57
+ if (typeof d.cwd === 'string' && d.cwd) return d.cwd
58
+ for (const v of Object.values(d)) stack.push(v)
59
+ }
60
+ }
61
+ return ''
62
+ }
63
+
45
64
  // Detail endpoint from the ingest URL, tolerating a trailing slash, or an override.
46
65
  export function deriveHourlyUrl(ingestUrl, override) {
47
66
  if (override) return override
@@ -270,3 +289,281 @@ export function aggregateTools(events, codexToolDeltas = [], today) {
270
289
  for (const d of codexToolDeltas) add(d.tool, d.bucketDate, d.calls, d.tokens)
271
290
  return [...byTool.values()]
272
291
  }
292
+
293
+ // --- Local recommendations: session summaries + scoring for `doctor
294
+ // --recommendations`. Computed entirely from local logs, no network. ---
295
+
296
+ function pct(x) {
297
+ return `${Math.round(x * 100)}%`
298
+ }
299
+
300
+ function fmtTok(value) {
301
+ if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(1)}B`
302
+ if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
303
+ if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`
304
+ return String(Math.round(value))
305
+ }
306
+
307
+ // Summarize one Claude session file into per-session totals, the per-turn context
308
+ // size sequence (for bloat detection), and tool token attribution.
309
+ export function claudeSessionSummary(text) {
310
+ let model = 'claude-unknown'
311
+ let uncached = 0
312
+ let cacheRead = 0
313
+ let cacheCreate = 0
314
+ let output = 0
315
+ let requests = 0
316
+ let firstTs = ''
317
+ let lastTs = ''
318
+ let cwd = ''
319
+ const contextSeq = []
320
+ const tools = {}
321
+ for (const line of String(text).split('\n')) {
322
+ if (!line.trim()) continue
323
+ let o
324
+ try {
325
+ o = JSON.parse(line)
326
+ } catch {
327
+ continue
328
+ }
329
+ if (!cwd) cwd = findCwd(o)
330
+ const ev = claudeEventFromLine(o)
331
+ if (!ev) continue
332
+ model = ev.model || model
333
+ if (ev.occurredAt) {
334
+ if (!firstTs || ev.occurredAt < firstTs) firstTs = ev.occurredAt
335
+ if (ev.occurredAt > lastTs) lastTs = ev.occurredAt
336
+ }
337
+ uncached += ev.uncachedInputTokens
338
+ cacheRead += ev.cacheReadInputTokens
339
+ cacheCreate += ev.cacheCreationInputTokens
340
+ output += ev.outputTokens
341
+ requests += 1
342
+ contextSeq.push(ev.uncachedInputTokens + ev.cacheReadInputTokens + ev.cacheCreationInputTokens)
343
+ const turnTokens =
344
+ ev.uncachedInputTokens + ev.cacheReadInputTokens + ev.cacheCreationInputTokens + ev.outputTokens
345
+ const groups = new Map()
346
+ for (const name of ev.tools) {
347
+ const g = toolGroup(name)
348
+ groups.set(g, (groups.get(g) || 0) + 1)
349
+ }
350
+ const share = groups.size > 0 ? Math.round(turnTokens / groups.size) : 0
351
+ for (const [g, calls] of groups) {
352
+ const cur = tools[g] || { tokens: 0, calls: 0 }
353
+ cur.tokens += share
354
+ cur.calls += calls
355
+ tools[g] = cur
356
+ }
357
+ }
358
+ if (requests === 0) return null
359
+ return { provider: 'anthropic', model, uncached, cacheRead, cacheCreate, output, requests, firstTs, lastTs, cwd, contextSeq, tools }
360
+ }
361
+
362
+ // Summarize one Codex session from its final cumulative token_count. Codex totals are
363
+ // cumulative, so there is no reliable per-turn context sequence (bloat is Claude-only).
364
+ export function codexSessionSummary(text) {
365
+ let model = 'gpt-5'
366
+ let totals = null
367
+ let requests = 0
368
+ let firstTs = ''
369
+ let lastTs = ''
370
+ let cwd = ''
371
+ const tools = {}
372
+ for (const line of String(text).split('\n')) {
373
+ if (!line.trim()) continue
374
+ let o
375
+ try {
376
+ o = JSON.parse(line)
377
+ } catch {
378
+ continue
379
+ }
380
+ if (!cwd) cwd = findCwd(o)
381
+ const p = o.payload || o
382
+ const ptype = p.type || o.type
383
+ if (typeof o.model === 'string') model = o.model
384
+ else if (typeof p.model === 'string') model = p.model
385
+ if (typeof o.timestamp === 'string') {
386
+ if (!firstTs || o.timestamp < firstTs) firstTs = o.timestamp
387
+ if (o.timestamp > lastTs) lastTs = o.timestamp
388
+ }
389
+ const tc = findLastTokenCount(o)
390
+ if (tc) {
391
+ totals = tc
392
+ requests += 1
393
+ }
394
+ const g = codexToolFromEvent(p, ptype)
395
+ if (g) {
396
+ const cur = tools[g] || { tokens: 0, calls: 0 }
397
+ cur.calls += 1
398
+ tools[g] = cur
399
+ }
400
+ }
401
+ if (!totals) return null
402
+ const cacheRead = totals.cached_input_tokens || 0
403
+ const uncached = Math.max(0, (totals.input_tokens || 0) - cacheRead)
404
+ const output = (totals.output_tokens || 0) + (totals.reasoning_output_tokens || 0)
405
+ return {
406
+ provider: 'openai',
407
+ model,
408
+ uncached,
409
+ cacheRead,
410
+ cacheCreate: 0,
411
+ output,
412
+ requests: Math.max(1, requests),
413
+ firstTs,
414
+ lastTs,
415
+ cwd,
416
+ contextSeq: [],
417
+ tools,
418
+ }
419
+ }
420
+
421
+ // Shape a session summary into the row the collector sends to ingest-hourly. Derives
422
+ // the bloat metrics from the context sequence and the bucket date from the timestamps.
423
+ // Returns null if there is no date to bucket on. The hash is supplied by the caller
424
+ // (the collector hashes the session-file basename, never the path).
425
+ export function sessionSendRow(summary, sessionHash) {
426
+ if (!summary || !sessionHash) return null
427
+ const bucketDate = (summary.lastTs || summary.firstTs || '').slice(0, 10)
428
+ if (!bucketDate) return null
429
+ const bloat = sessionBloat(summary.contextSeq)
430
+ return {
431
+ sessionHash,
432
+ provider: summary.provider,
433
+ model: summary.model,
434
+ bucketDate,
435
+ firstTs: summary.firstTs || '',
436
+ lastTs: summary.lastTs || '',
437
+ requests: summary.requests,
438
+ uncached: summary.uncached,
439
+ cacheRead: summary.cacheRead,
440
+ cacheCreation: summary.cacheCreate,
441
+ output: summary.output,
442
+ maxInputTurn: bloat ? bloat.max : 0,
443
+ first5Avg: bloat ? bloat.first5 : 0,
444
+ last5Avg: bloat ? bloat.last5 : 0,
445
+ toolCounts: summary.tools,
446
+ }
447
+ }
448
+
449
+ // First-5 vs last-5 average turn size + max, for context-bloat detection.
450
+ export function sessionBloat(seq) {
451
+ if (!Array.isArray(seq) || seq.length < 10) return null
452
+ const avg = (arr) => Math.round(arr.reduce((a, b) => a + b, 0) / arr.length)
453
+ return { first5: avg(seq.slice(0, 5)), last5: avg(seq.slice(-5)), max: Math.max(...seq) }
454
+ }
455
+
456
+ // Turn session summaries into local recommendations. Pure, so the whole engine is
457
+ // fixture-tested. Returns [{ kind, level, text }] in cache -> mcp -> output -> bloat order.
458
+ export function buildLocalRecommendations(summaries) {
459
+ const recs = []
460
+ const list = (summaries || []).filter(Boolean)
461
+ if (list.length === 0) return recs
462
+
463
+ let uncached = 0
464
+ let cacheRead = 0
465
+ let cacheCreate = 0
466
+ let output = 0
467
+ let requests = 0
468
+ const tools = {}
469
+ for (const s of list) {
470
+ uncached += s.uncached
471
+ cacheRead += s.cacheRead
472
+ cacheCreate += s.cacheCreate
473
+ output += s.output
474
+ requests += s.requests
475
+ for (const [g, v] of Object.entries(s.tools || {})) {
476
+ const cur = tools[g] || { tokens: 0, calls: 0 }
477
+ cur.tokens += v.tokens
478
+ cur.calls += v.calls
479
+ tools[g] = cur
480
+ }
481
+ }
482
+ const inputTotal = uncached + cacheRead + cacheCreate
483
+ const total = inputTotal + output
484
+
485
+ // 1. Cache effectiveness.
486
+ if (inputTotal > 0) {
487
+ const readRatio = cacheRead / inputTotal
488
+ const createRatio = cacheCreate / inputTotal
489
+ const uncachedRatio = uncached / inputTotal
490
+ if (createRatio > 0.3 && readRatio < createRatio) {
491
+ recs.push({
492
+ kind: 'cache',
493
+ level: 'warn',
494
+ text: `High cache creation, low reuse: ${pct(createRatio)} of input is cache writes vs ${pct(
495
+ readRatio,
496
+ )} reads. Keep your prompt prefix byte-for-byte stable so it gets reused.`,
497
+ })
498
+ } else if (uncachedRatio > 0.5) {
499
+ recs.push({
500
+ kind: 'cache',
501
+ level: 'warn',
502
+ text: `Repeated uncached context: ${pct(
503
+ uncachedRatio,
504
+ )} of input pays full price. Move stable content (system prompt, tools, examples) into a cached prefix.`,
505
+ })
506
+ } else if (readRatio >= 0.6) {
507
+ recs.push({
508
+ kind: 'cache',
509
+ level: 'ok',
510
+ text: `Good cache reuse: ${pct(readRatio)} of input is cached reads.`,
511
+ })
512
+ }
513
+ }
514
+
515
+ // 2. MCP / tool ranking.
516
+ const toolArr = Object.entries(tools)
517
+ .map(([tool, v]) => ({ tool, ...v }))
518
+ .sort((a, b) => b.tokens - a.tokens)
519
+ const toolTotal = toolArr.reduce((n, t) => n + t.tokens, 0)
520
+ const topMcp = toolArr.find((t) => t.tool.startsWith('mcp:'))
521
+ if (topMcp && toolTotal > 0 && topMcp.tokens / toolTotal >= 0.25) {
522
+ recs.push({
523
+ kind: 'mcp',
524
+ level: 'warn',
525
+ text: `${topMcp.tool} is ${pct(
526
+ topMcp.tokens / toolTotal,
527
+ )} of tool-attributed usage. Disable it when you are not actively using it.`,
528
+ })
529
+ }
530
+
531
+ // 3. Output verbosity.
532
+ if (total > 0 && output / total > 0.4) {
533
+ recs.push({
534
+ kind: 'output',
535
+ level: 'warn',
536
+ text: `Output is ${pct(
537
+ output / total,
538
+ )} of usage. Ask for patch-only responses or short summaries; output is the priciest token tier.`,
539
+ })
540
+ } else if (requests > 0 && Math.round(output / requests) > 5000) {
541
+ recs.push({
542
+ kind: 'output',
543
+ level: 'info',
544
+ text: `Responses average ${fmtTok(
545
+ Math.round(output / requests),
546
+ )} output tokens. A max_tokens cap or terser prompt trims the priciest tier.`,
547
+ })
548
+ }
549
+
550
+ // 4. Context bloat (Claude sessions carry a per-turn sequence).
551
+ let worst = null
552
+ for (const s of list) {
553
+ const b = sessionBloat(s.contextSeq)
554
+ if (b && b.last5 > b.first5 * 2 && b.last5 > 30_000 && (!worst || b.last5 > worst.last5)) {
555
+ worst = b
556
+ }
557
+ }
558
+ if (worst) {
559
+ recs.push({
560
+ kind: 'bloat',
561
+ level: 'warn',
562
+ text: `A recent session's context grew from ${fmtTok(worst.first5)} to ${fmtTok(
563
+ worst.last5,
564
+ )} tokens per turn. Start a fresh session or ask the model to summarize state.`,
565
+ })
566
+ }
567
+
568
+ return recs
569
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modelmeter-collect",
3
- "version": "0.5.0",
3
+ "version": "0.8.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": {