modelmeter-collect 0.4.0 → 0.6.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
@@ -28,6 +28,22 @@ MODELMETER_DRYRUN=1 npx modelmeter-collect
28
28
  `init` writes `~/.modelmeter/config.json` (chmod 600) with your token and the ingest URL.
29
29
  Prefer env vars? Set `MODELMETER_TOKEN` and `MODELMETER_INGEST_URL` and skip `init`.
30
30
 
31
+ ## Check your setup
32
+
33
+ ```bash
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
37
+ ```
38
+
39
+ `doctor` confirms it found your Claude Code and Codex logs and shows precisely what leaves
40
+ your machine: model names, token counts, and tool/MCP names only. Never prompts or keys.
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
+
31
47
  ## Keep it live (per prompt)
32
48
 
33
49
  **Claude Code** — add a `Stop` hook (fires after every response). It passes the session
@@ -71,6 +87,7 @@ does the same job.)
71
87
  | --- | --- | --- |
72
88
  | `MODELMETER_TOKEN` | from config file | Your `mm_live_...` ingest token |
73
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 |
74
91
  | `MODELMETER_LOOKBACK_DAYS` | `14` | How many days of logs to scan |
75
92
  | `MODELMETER_DRYRUN` | unset | When set, print the payload instead of sending |
76
93
 
package/cli.mjs CHANGED
@@ -6,10 +6,24 @@
6
6
  // npx modelmeter-collect init <mm_live_token> # one-time: save the token
7
7
  // npx modelmeter-collect # scan local logs and report
8
8
  // MODELMETER_DRYRUN=1 npx modelmeter-collect # preview without sending
9
- import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from 'node:fs'
9
+ import {
10
+ readFileSync,
11
+ writeFileSync,
12
+ mkdirSync,
13
+ existsSync,
14
+ chmodSync,
15
+ readdirSync,
16
+ statSync,
17
+ } from 'node:fs'
10
18
  import { homedir } from 'node:os'
11
19
  import { join, dirname } from 'node:path'
12
20
  import { fileURLToPath } from 'node:url'
21
+ import {
22
+ formatDoctorReport,
23
+ claudeSessionSummary,
24
+ codexSessionSummary,
25
+ buildLocalRecommendations,
26
+ } from './lib.mjs'
13
27
 
14
28
  const HOME = homedir()
15
29
  const MM_DIR = join(HOME, '.modelmeter')
@@ -47,12 +61,17 @@ function printHelp() {
47
61
 
48
62
  Usage:
49
63
  npx modelmeter-collect init <token> [--url <ingest-url>]
64
+ npx modelmeter-collect doctor [--payload]
50
65
  npx modelmeter-collect scan local logs and report
51
66
  npx modelmeter-collect --help
52
67
 
53
68
  Commands:
54
69
  init Save your ingest token to ~/.modelmeter/config.json (chmod 600).
55
70
  Pass the token as an argument or via MODELMETER_TOKEN.
71
+ doctor Check your setup: which logs were found, last activity, config
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).
56
75
  (none) Scan Claude Code + Codex logs and report token counts. Deduped,
57
76
  so it is safe to run repeatedly. MODELMETER_DRYRUN=1 previews only.
58
77
 
@@ -93,5 +112,142 @@ if (cmd === 'init' || cmd === 'setup') {
93
112
  process.exit(0)
94
113
  }
95
114
 
115
+ // Count .jsonl session files (recent + newest mtime) under a logs directory.
116
+ function discoverLogs(dir, cutoffMs) {
117
+ try {
118
+ statSync(dir)
119
+ } catch {
120
+ return { dir, found: false }
121
+ }
122
+ let recentCount = 0
123
+ let lastWriteMs = 0
124
+ const stack = [dir]
125
+ while (stack.length) {
126
+ const d = stack.pop()
127
+ let entries = []
128
+ try {
129
+ entries = readdirSync(d, { withFileTypes: true })
130
+ } catch {
131
+ continue
132
+ }
133
+ for (const e of entries) {
134
+ const p = join(d, e.name)
135
+ if (e.isDirectory()) stack.push(p)
136
+ else if (e.isFile() && p.endsWith('.jsonl')) {
137
+ let m = 0
138
+ try {
139
+ m = statSync(p).mtimeMs
140
+ } catch {
141
+ continue
142
+ }
143
+ if (m > lastWriteMs) lastWriteMs = m
144
+ if (m >= cutoffMs) recentCount++
145
+ }
146
+ }
147
+ }
148
+ return { dir, found: true, recentCount, lastWriteMs }
149
+ }
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
+
218
+ if (cmd === 'doctor') {
219
+ const cfg = readConfig()
220
+ const lookbackDays = 14
221
+ const nowMs = Date.now()
222
+ const cutoffMs = nowMs - lookbackDays * 86_400_000
223
+ console.log(
224
+ formatDoctorReport({
225
+ configPath: CONFIG_PATH,
226
+ configFound: existsSync(CONFIG_PATH),
227
+ token: process.env.MODELMETER_TOKEN || cfg.token,
228
+ ingestUrl: process.env.MODELMETER_INGEST_URL || cfg.ingestUrl,
229
+ lookbackDays,
230
+ nowMs,
231
+ claude: discoverLogs(join(HOME, '.claude', 'projects'), cutoffMs),
232
+ codex: discoverLogs(join(HOME, '.codex', 'sessions'), cutoffMs),
233
+ }),
234
+ )
235
+ if (args.includes('--recommendations')) {
236
+ printRecommendations(cutoffMs)
237
+ process.exit(0)
238
+ }
239
+ if (args.includes('--payload')) {
240
+ console.log('\nNext batch (dry run, nothing is sent):')
241
+ process.env.MODELMETER_DRYRUN = '1'
242
+ await runCollector() // prints the exact payload (counts only), then exits
243
+ } else {
244
+ console.log(
245
+ '\nRun `npx modelmeter-collect doctor --recommendations` for local optimization tips, or',
246
+ )
247
+ console.log('`doctor --payload` to preview the exact JSON that would be sent.')
248
+ process.exit(0)
249
+ }
250
+ }
251
+
96
252
  // Default: scan and report.
97
253
  await runCollector()
package/collect.mjs CHANGED
@@ -9,15 +9,50 @@
9
9
  //
10
10
  // Config: MODELMETER_TOKEN + MODELMETER_INGEST_URL from env, or ~/.modelmeter/config.json
11
11
  // { "token": "mm_live_...", "ingestUrl": "https://<ref>.supabase.co/functions/v1/ingest" }
12
- import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync, readdirSync } from 'node:fs'
12
+ import {
13
+ readFileSync,
14
+ writeFileSync,
15
+ mkdirSync,
16
+ existsSync,
17
+ statSync,
18
+ readdirSync,
19
+ chmodSync,
20
+ } from 'node:fs'
13
21
  import { homedir } from 'node:os'
14
22
  import { join } from 'node:path'
23
+ import {
24
+ findLastTokenCount,
25
+ codexToolFromEvent,
26
+ deriveHourlyUrl,
27
+ claudeEventFromLine,
28
+ codexDelta,
29
+ aggregateDaily,
30
+ aggregateHourly,
31
+ aggregateTools,
32
+ pruneClaudeState,
33
+ mergeDetailBatches,
34
+ } from './lib.mjs'
15
35
 
16
36
  const HOME = homedir()
17
37
  const MM_DIR = join(HOME, '.modelmeter')
18
38
  const STATE_PATH = join(MM_DIR, 'collector-state.json')
19
39
  const CONFIG_PATH = join(MM_DIR, 'config.json')
20
- const LOOKBACK_DAYS = Number(process.env.MODELMETER_LOOKBACK_DAYS) || 14
40
+ // Clamp the lookback to a sane range so a bad env var cannot scan nothing
41
+ // (negative) or traverse months of logs (huge).
42
+ const RAW_LOOKBACK = Number(process.env.MODELMETER_LOOKBACK_DAYS)
43
+ const LOOKBACK_DAYS =
44
+ Number.isFinite(RAW_LOOKBACK) && RAW_LOOKBACK > 0 ? Math.min(RAW_LOOKBACK, 90) : 14
45
+ if (
46
+ process.env.MODELMETER_LOOKBACK_DAYS !== undefined &&
47
+ (!Number.isFinite(RAW_LOOKBACK) || RAW_LOOKBACK <= 0 || RAW_LOOKBACK > 90)
48
+ ) {
49
+ console.error(`modelmeter: MODELMETER_LOOKBACK_DAYS out of range, using ${LOOKBACK_DAYS}`)
50
+ }
51
+
52
+ const FETCH_TIMEOUT_MS = 8000
53
+ // Cap the per-message dedup set so the state file cannot grow without bound. Older
54
+ // entries fall out of the lookback window, so dropping them is safe.
55
+ const CLAUDE_STATE_CAP = 200_000
21
56
 
22
57
  let cfg = {}
23
58
  try {
@@ -29,13 +64,46 @@ const TOKEN = process.env.MODELMETER_TOKEN || cfg.token
29
64
  const INGEST_URL = process.env.MODELMETER_INGEST_URL || cfg.ingestUrl
30
65
  if (!TOKEN || !INGEST_URL) process.exit(0) // not configured: do nothing, never block
31
66
 
32
- let state = { claude: {}, codex: {} }
67
+ // POST JSON with a hard timeout so a stuck network path can never hang a Stop
68
+ // hook or pile up scheduled collectors. Callers handle the thrown abort/error.
69
+ async function postJson(url, body) {
70
+ const controller = new AbortController()
71
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
72
+ try {
73
+ return await fetch(url, {
74
+ method: 'POST',
75
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
76
+ body: JSON.stringify(body),
77
+ signal: controller.signal,
78
+ })
79
+ } finally {
80
+ clearTimeout(timer)
81
+ }
82
+ }
83
+
84
+ let state = { claude: {}, codex: {}, pendingDetail: { hours: [], tools: [] } }
33
85
  try {
34
- 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
+ }
35
92
  } catch {
36
93
  // first run
37
94
  }
38
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
+
39
107
  // --- invocation detection -------------------------------------------------
40
108
  let hookInput = null
41
109
  if (!process.stdin.isTTY) {
@@ -107,59 +175,15 @@ function scanClaude(files) {
107
175
  } catch {
108
176
  continue
109
177
  }
110
- const msg = o.message
111
- if (!msg || msg.role !== 'assistant' || !msg.usage) continue
112
- const id = o.uuid || `${o.timestamp ?? ''}:${msg.id ?? ''}`
113
- if (!id || state.claude[id]) continue
114
- state.claude[id] = 1
115
- const u = msg.usage
116
- const toolNames = Array.isArray(msg.content)
117
- ? msg.content.filter((b) => b && b.type === 'tool_use').map((b) => b.name).filter(Boolean)
118
- : []
119
- events.push({
120
- provider: 'anthropic',
121
- model: msg.model || 'claude-unknown',
122
- occurredOn: (o.timestamp || '').slice(0, 10) || undefined,
123
- occurredAt: o.timestamp || undefined,
124
- tools: toolNames,
125
- uncachedInputTokens: u.input_tokens || 0,
126
- cacheReadInputTokens: u.cache_read_input_tokens || 0,
127
- cacheCreationInputTokens: u.cache_creation_input_tokens || 0,
128
- outputTokens: u.output_tokens || 0,
129
- numRequests: 1,
130
- })
178
+ const ev = claudeEventFromLine(o)
179
+ if (!ev || !ev.id || state.claude[ev.id]) continue
180
+ state.claude[ev.id] = { ts: ev.occurredAt || ev.occurredOn || '' }
181
+ events.push(ev)
131
182
  }
132
183
  }
133
184
  }
134
185
 
135
186
  // --- Codex: cumulative token_count events; report per-session delta.
136
- function findLastTokenCount(obj) {
137
- let last = null
138
- const stack = [obj]
139
- while (stack.length) {
140
- const d = stack.pop()
141
- if (Array.isArray(d)) stack.push(...d)
142
- else if (d && typeof d === 'object') {
143
- if (d.type === 'token_count' && d.info?.total_token_usage) last = d.info.total_token_usage
144
- for (const v of Object.values(d)) stack.push(v)
145
- }
146
- }
147
- return last
148
- }
149
- // Codex tool names are plain (exec_command, apply_patch, ...); MCP calls carry an
150
- // invocation with a server name. Returns a group key, or null if not a tool call.
151
- function codexToolFromEvent(payload, ptype) {
152
- if (ptype === 'function_call' || ptype === 'custom_tool_call') {
153
- return typeof payload.name === 'string' && payload.name ? payload.name : null
154
- }
155
- if (ptype === 'mcp_tool_call_end' || ptype === 'mcp_tool_call_begin') {
156
- const inv = payload.invocation || {}
157
- const server = inv.server || inv.server_name
158
- return server ? `mcp:${server}` : 'mcp'
159
- }
160
- return null
161
- }
162
-
163
187
  function scanCodex(files) {
164
188
  for (const file of files) {
165
189
  const m = file.match(/rollout-(\d{4}-\d{2}-\d{2})T[\d-]+-([0-9a-f-]+)\.jsonl$/)
@@ -198,49 +222,14 @@ function scanCodex(files) {
198
222
  const g = codexToolFromEvent(p, ptype)
199
223
  if (g) toolCalls.push({ ts: ts || totalsTs || '', group: g })
200
224
  }
201
- if (!totals) continue
202
- const stored = state.codex[sessionId]
203
- const prevTotal =
204
- (stored && stored.total) ||
205
- (stored && stored.input_tokens != null ? stored : null) || {
206
- input_tokens: 0,
207
- cached_input_tokens: 0,
208
- output_tokens: 0,
209
- reasoning_output_tokens: 0,
210
- }
211
- const prevLastTs = (stored && stored.lastTs) || ''
212
- const dInput = Math.max(0, (totals.input_tokens || 0) - prevTotal.input_tokens)
213
- const dCached = Math.max(0, (totals.cached_input_tokens || 0) - prevTotal.cached_input_tokens)
214
- const dOut = Math.max(0, (totals.output_tokens || 0) - prevTotal.output_tokens)
215
- const dReason = Math.max(0, (totals.reasoning_output_tokens || 0) - prevTotal.reasoning_output_tokens)
216
- const newTotalTokens = Math.max(0, dInput - dCached) + dCached + (dOut + dReason)
217
- if (dInput + dCached + dOut + dReason > 0) {
218
- const occurredOn = (totalsTs || '').slice(0, 10) || m[1]
219
- events.push({
220
- provider: 'openai',
221
- model,
222
- occurredOn,
223
- occurredAt: totalsTs || undefined, // enables the 5-hour window for Codex
224
- uncachedInputTokens: Math.max(0, dInput - dCached),
225
- cacheReadInputTokens: dCached,
226
- cacheCreationInputTokens: 0,
227
- outputTokens: dOut + dReason, // reasoning tokens bill as output
228
- numRequests: 1,
229
- })
230
- // Tool calls new since the last run; even-split this run's new tokens across them.
231
- const newCalls = toolCalls.filter((c) => c.ts && c.ts > prevLastTs)
232
- if (newCalls.length > 0 && newTotalTokens > 0) {
233
- const share = Math.round(newTotalTokens / newCalls.length)
234
- for (const c of newCalls) {
235
- codexToolDeltas.push({
236
- tool: c.group,
237
- bucketDate: (c.ts || '').slice(0, 10) || occurredOn,
238
- calls: 1,
239
- tokens: share,
240
- })
241
- }
242
- }
243
- state.codex[sessionId] = { total: totals, lastTs: maxTs }
225
+ const { event, toolDeltas, state: nextState } = codexDelta(
226
+ { totals, totalsTs, maxTs, model, fileDate: m[1], toolCalls },
227
+ state.codex[sessionId],
228
+ )
229
+ if (event) {
230
+ events.push(event)
231
+ for (const d of toolDeltas) codexToolDeltas.push(d)
232
+ state.codex[sessionId] = nextState
244
233
  }
245
234
  }
246
235
  }
@@ -255,108 +244,22 @@ if (hookInput?.transcript_path) {
255
244
  }
256
245
 
257
246
  // Collapse to one row per (provider, model, day) so the request stays small.
258
- const byKey = new Map()
259
- for (const e of events) {
260
- const date = e.occurredOn || new Date().toISOString().slice(0, 10)
261
- const key = `${e.provider}|${e.model}|${date}`
262
- const cur = byKey.get(key) || {
263
- provider: e.provider,
264
- model: e.model,
265
- occurredOn: date,
266
- uncachedInputTokens: 0,
267
- cacheReadInputTokens: 0,
268
- cacheCreationInputTokens: 0,
269
- outputTokens: 0,
270
- numRequests: 0,
271
- }
272
- cur.uncachedInputTokens += e.uncachedInputTokens || 0
273
- cur.cacheReadInputTokens += e.cacheReadInputTokens || 0
274
- cur.cacheCreationInputTokens += e.cacheCreationInputTokens || 0
275
- cur.outputTokens += e.outputTokens || 0
276
- cur.numRequests += e.numRequests || 1
277
- byKey.set(key, cur)
278
- }
279
- const payload = [...byKey.values()]
247
+ const today = new Date().toISOString().slice(0, 10)
248
+ const payload = aggregateDaily(events, today)
280
249
 
281
- // Recent hourly buckets feed the 5-hour rolling window via a separate, additive
282
- // endpoint. Only events with a real timestamp in the last 8 hours qualify, so a
283
- // backfill scan never pollutes the recent window. (Codex deltas lack per-event
284
- // timestamps, so the 5-hour window is Claude Code for now.)
285
- const HOUR_MS = 3_600_000
286
- const recentCutoff = Date.now() - 8 * HOUR_MS
287
- const byHour = new Map()
288
- for (const e of events) {
289
- if (!e.occurredAt) continue
290
- const t = new Date(e.occurredAt).getTime()
291
- if (Number.isNaN(t) || t < recentCutoff) continue
292
- const d = new Date(t)
293
- d.setMinutes(0, 0, 0)
294
- const hourIso = d.toISOString()
295
- const key = `${e.provider}|${e.model}|${hourIso}`
296
- const cur = byHour.get(key) || {
297
- provider: e.provider,
298
- model: e.model,
299
- bucketHour: hourIso,
300
- uncachedInputTokens: 0,
301
- cacheReadInputTokens: 0,
302
- cacheCreationInputTokens: 0,
303
- outputTokens: 0,
304
- numRequests: 0,
305
- }
306
- cur.uncachedInputTokens += e.uncachedInputTokens || 0
307
- cur.cacheReadInputTokens += e.cacheReadInputTokens || 0
308
- cur.cacheCreationInputTokens += e.cacheCreationInputTokens || 0
309
- cur.outputTokens += e.outputTokens || 0
310
- cur.numRequests += e.numRequests || 1
311
- byHour.set(key, cur)
312
- }
313
- const hourly = [...byHour.values()]
314
- const HOURLY_URL = INGEST_URL.replace(/\/ingest$/, '/ingest-hourly')
250
+ // Recent hourly buckets (last 8h, timestamped events only) feed the 5-hour rolling
251
+ // window via a separate, additive endpoint, so a backfill cannot pollute it.
252
+ const hourly = aggregateHourly(events, Date.now())
253
+ const HOURLY_URL = deriveHourlyUrl(INGEST_URL, process.env.MODELMETER_HOURLY_INGEST_URL)
315
254
 
316
- // Per-tool / per-MCP attribution. Group MCP tools by server (mcp__server__tool ->
317
- // mcp:server) and keep built-ins by name. Calls are exact; tokens are an even
318
- // split of each turn's usage across the distinct tool groups it called.
319
- function toolGroup(name) {
320
- if (typeof name !== 'string' || !name) return 'unknown'
321
- if (name.startsWith('mcp__')) {
322
- const parts = name.split('__')
323
- return parts[1] ? `mcp:${parts[1]}` : 'mcp:unknown'
324
- }
325
- return name
326
- }
327
- const byTool = new Map()
328
- for (const e of events) {
329
- if (!Array.isArray(e.tools) || e.tools.length === 0) continue
330
- const date = e.occurredOn || new Date().toISOString().slice(0, 10)
331
- const callsByGroup = new Map()
332
- for (const name of e.tools) {
333
- const g = toolGroup(name)
334
- callsByGroup.set(g, (callsByGroup.get(g) || 0) + 1)
335
- }
336
- const eventTokens =
337
- (e.uncachedInputTokens || 0) +
338
- (e.cacheReadInputTokens || 0) +
339
- (e.cacheCreationInputTokens || 0) +
340
- (e.outputTokens || 0)
341
- const tokenShare = Math.round(eventTokens / callsByGroup.size)
342
- for (const [g, calls] of callsByGroup) {
343
- const key = `${g}|${date}`
344
- const cur = byTool.get(key) || { tool: g, bucketDate: date, calls: 0, tokens: 0 }
345
- cur.calls += calls
346
- cur.tokens += tokenShare
347
- byTool.set(key, cur)
348
- }
349
- }
350
- for (const d of codexToolDeltas) {
351
- const key = `${d.tool}|${d.bucketDate}`
352
- const cur = byTool.get(key) || { tool: d.tool, bucketDate: d.bucketDate, calls: 0, tokens: 0 }
353
- cur.calls += d.calls
354
- cur.tokens += d.tokens
355
- byTool.set(key, cur)
356
- }
357
- const toolsPayload = [...byTool.values()]
255
+ // Per-tool / per-MCP attribution. Claude even-splits each turn's tokens across the
256
+ // tools it called; Codex contributes precomputed deltas. Calls are exact.
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
358
261
 
359
- if (payload.length === 0) {
262
+ if (payload.length === 0 && !hasDetail) {
360
263
  process.exit(0)
361
264
  }
362
265
 
@@ -365,41 +268,56 @@ if (process.env.MODELMETER_DRYRUN) {
365
268
  for (const e of events) tally[e.provider] = (tally[e.provider] || 0) + 1
366
269
  console.log(`DRY RUN: ${events.length} raw events -> ${payload.length} daily rows`, tally)
367
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
+ }
368
276
  console.log(JSON.stringify(payload, null, 2))
369
277
  if (toolsPayload.length) console.log('tools:', JSON.stringify(toolsPayload, null, 2))
370
278
  process.exit(0)
371
279
  }
372
280
 
373
281
  let committed = false
374
- try {
375
- const res = await fetch(INGEST_URL, {
376
- method: 'POST',
377
- headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
378
- body: JSON.stringify({ source: 'collector', events: payload }),
379
- })
380
- if (res.ok) {
381
- if (!existsSync(MM_DIR)) mkdirSync(MM_DIR, { recursive: true })
382
- writeFileSync(STATE_PATH, JSON.stringify(state))
383
- committed = true
384
- console.error(`modelmeter: reported ${payload.length} usage rows`)
385
- } else {
386
- console.error(`modelmeter: ingest returned ${res.status}`)
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}`)
291
+ }
292
+ } catch (err) {
293
+ console.error(`modelmeter: ${err.message}`)
387
294
  }
388
- } catch (err) {
389
- console.error(`modelmeter: ${err.message}`)
295
+ } else {
296
+ committed = true // retrying previously committed detail rows
390
297
  }
391
298
 
392
- // Additive + best-effort: only after the daily batch is committed (state written),
393
- // so a retry cannot double-count into the hourly window.
394
- if (committed && (hourly.length > 0 || toolsPayload.length > 0)) {
395
- try {
396
- await fetch(HOURLY_URL, {
397
- method: 'POST',
398
- headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
399
- body: JSON.stringify({ source: 'collector', hours: hourly, tools: toolsPayload }),
400
- })
401
- } catch {
402
- // detail (hourly + per-tool) is best-effort; never block the collector on it
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
309
+ for (let attempt = 0; attempt < 2; attempt++) {
310
+ try {
311
+ const res = await postJson(HOURLY_URL, { source: 'collector', ...detailBatch })
312
+ if (res.ok) {
313
+ detailSent = true
314
+ break
315
+ }
316
+ } catch {
317
+ // fall through to one retry, then give up
318
+ }
403
319
  }
320
+ state.pendingDetail = detailSent ? { hours: [], tools: [] } : detailBatch
321
+ saveState()
404
322
  }
405
323
  process.exit(0)
package/lib.mjs ADDED
@@ -0,0 +1,503 @@
1
+ // Pure, unit-tested core of the collector. All file I/O, networking, and state
2
+ // persistence live in collect.mjs; everything here is deterministic given its
3
+ // inputs, so it can be fixture-tested (see lib.test.mjs).
4
+
5
+ // Group a Claude tool name: MCP tools (mcp__server__tool) collapse to mcp:server,
6
+ // built-ins keep their name.
7
+ export function toolGroup(name) {
8
+ if (typeof name !== 'string' || !name) return 'unknown'
9
+ if (name.startsWith('mcp__')) {
10
+ const parts = name.split('__')
11
+ return parts[1] ? `mcp:${parts[1]}` : 'mcp:unknown'
12
+ }
13
+ return name
14
+ }
15
+
16
+ // Group a Codex tool event. Built-in calls carry a plain name; MCP calls carry an
17
+ // invocation with a server. Returns a group key, or null if it is not a tool call.
18
+ export function codexToolFromEvent(payload, ptype) {
19
+ if (ptype === 'function_call' || ptype === 'custom_tool_call') {
20
+ return typeof payload.name === 'string' && payload.name ? payload.name : null
21
+ }
22
+ if (ptype === 'mcp_tool_call_end' || ptype === 'mcp_tool_call_begin') {
23
+ const inv = payload.invocation || {}
24
+ const server = inv.server || inv.server_name
25
+ return server ? `mcp:${server}` : 'mcp'
26
+ }
27
+ return null
28
+ }
29
+
30
+ // Depth-first find of the last token_count usage block in a Codex line.
31
+ export function findLastTokenCount(obj) {
32
+ let last = null
33
+ const stack = [obj]
34
+ while (stack.length) {
35
+ const d = stack.pop()
36
+ if (Array.isArray(d)) stack.push(...d)
37
+ else if (d && typeof d === 'object') {
38
+ if (d.type === 'token_count' && d.info?.total_token_usage) last = d.info.total_token_usage
39
+ for (const v of Object.values(d)) stack.push(v)
40
+ }
41
+ }
42
+ return last
43
+ }
44
+
45
+ // Detail endpoint from the ingest URL, tolerating a trailing slash, or an override.
46
+ export function deriveHourlyUrl(ingestUrl, override) {
47
+ if (override) return override
48
+ try {
49
+ const u = new URL(ingestUrl)
50
+ u.pathname = u.pathname.replace(/\/ingest\/?$/, '/ingest-hourly')
51
+ return u.toString()
52
+ } catch {
53
+ return ingestUrl.replace(/\/ingest\/?$/, '/ingest-hourly')
54
+ }
55
+ }
56
+
57
+ // Build an event from one parsed Claude transcript line, or null if it is not an
58
+ // assistant message with usage. Carries an `id` for the caller to dedup on.
59
+ export function claudeEventFromLine(o) {
60
+ const msg = o && o.message
61
+ if (!msg || msg.role !== 'assistant' || !msg.usage) return null
62
+ const u = msg.usage
63
+ const tools = Array.isArray(msg.content)
64
+ ? msg.content
65
+ .filter((b) => b && b.type === 'tool_use')
66
+ .map((b) => b.name)
67
+ .filter(Boolean)
68
+ : []
69
+ return {
70
+ id: o.uuid || `${o.timestamp ?? ''}:${msg.id ?? ''}`,
71
+ provider: 'anthropic',
72
+ model: msg.model || 'claude-unknown',
73
+ occurredOn: (o.timestamp || '').slice(0, 10) || undefined,
74
+ occurredAt: o.timestamp || undefined,
75
+ tools,
76
+ uncachedInputTokens: u.input_tokens || 0,
77
+ cacheReadInputTokens: u.cache_read_input_tokens || 0,
78
+ cacheCreationInputTokens: u.cache_creation_input_tokens || 0,
79
+ outputTokens: u.output_tokens || 0,
80
+ numRequests: 1,
81
+ }
82
+ }
83
+
84
+ // Codex cumulative-delta + tool attribution. Given what a session walk collected and
85
+ // the previous session state, returns the new event (or null), the tool deltas, and
86
+ // the next state. Tool calls are only attributed up to the token-accounted watermark
87
+ // (totalsTs), so calls after the latest token_count are left for the next run.
88
+ export function codexDelta({ totals, totalsTs, maxTs, model, fileDate, toolCalls }, prev) {
89
+ if (!totals) return { event: null, toolDeltas: [], state: prev ?? null }
90
+ const prevTotal =
91
+ (prev && prev.total) ||
92
+ (prev && prev.input_tokens != null ? prev : null) || {
93
+ input_tokens: 0,
94
+ cached_input_tokens: 0,
95
+ output_tokens: 0,
96
+ reasoning_output_tokens: 0,
97
+ }
98
+ const prevLastTs = (prev && prev.lastTs) || ''
99
+ const dInput = Math.max(0, (totals.input_tokens || 0) - prevTotal.input_tokens)
100
+ const dCached = Math.max(0, (totals.cached_input_tokens || 0) - prevTotal.cached_input_tokens)
101
+ const dOut = Math.max(0, (totals.output_tokens || 0) - prevTotal.output_tokens)
102
+ const dReason = Math.max(0, (totals.reasoning_output_tokens || 0) - prevTotal.reasoning_output_tokens)
103
+ if (dInput + dCached + dOut + dReason <= 0) {
104
+ return { event: null, toolDeltas: [], state: prev ?? null }
105
+ }
106
+ const newTotalTokens = Math.max(0, dInput - dCached) + dCached + (dOut + dReason)
107
+ const occurredOn = (totalsTs || '').slice(0, 10) || fileDate
108
+ const event = {
109
+ provider: 'openai',
110
+ model: model || 'gpt-5',
111
+ occurredOn,
112
+ occurredAt: totalsTs || undefined,
113
+ uncachedInputTokens: Math.max(0, dInput - dCached),
114
+ cacheReadInputTokens: dCached,
115
+ cacheCreationInputTokens: 0,
116
+ outputTokens: dOut + dReason,
117
+ numRequests: 1,
118
+ }
119
+ // Tool calls are deduped by the maxTs watermark: each is counted exactly once, in
120
+ // the run where it is first seen, and never recounted. Calls are exact. The
121
+ // even-split token figure is an estimate; a tail call's tokens can land in a later
122
+ // run's batch, but no call is ever dropped.
123
+ const newCalls = (toolCalls || []).filter((c) => c.ts && c.ts > prevLastTs)
124
+ const toolDeltas = []
125
+ if (newCalls.length > 0 && newTotalTokens > 0) {
126
+ const share = Math.round(newTotalTokens / newCalls.length)
127
+ for (const c of newCalls) {
128
+ toolDeltas.push({
129
+ tool: c.group,
130
+ bucketDate: (c.ts || '').slice(0, 10) || occurredOn,
131
+ calls: 1,
132
+ tokens: share,
133
+ })
134
+ }
135
+ }
136
+ return { event, toolDeltas, state: { total: totals, lastTs: maxTs || totalsTs || '' } }
137
+ }
138
+
139
+ // Show enough of the token to recognize it, never the secret part.
140
+ export function maskToken(token) {
141
+ if (!token) return '(not set)'
142
+ return token.length > 12 ? `${token.slice(0, 12)}...` : token
143
+ }
144
+
145
+ function relAgo(ms, nowMs) {
146
+ if (!ms) return 'never'
147
+ const s = Math.max(0, Math.round((nowMs - ms) / 1000))
148
+ if (s < 60) return `${s}s ago`
149
+ if (s < 3600) return `${Math.round(s / 60)}m ago`
150
+ if (s < 86_400) return `${Math.round(s / 3600)}h ago`
151
+ return `${Math.round(s / 86_400)}d ago`
152
+ }
153
+
154
+ // Render the `doctor` report from gathered facts (pure, so it is unit-tested).
155
+ // info: { configPath, configFound, token, ingestUrl, lookbackDays, nowMs,
156
+ // claude/codex: { dir, found, recentCount, lastWriteMs } }
157
+ export function formatDoctorReport(info) {
158
+ const lines = ['modelmeter-collect doctor', '']
159
+ lines.push(`Config ${info.configPath}`)
160
+ lines.push(` status: ${info.configFound ? 'found' : 'not found'}`)
161
+ lines.push(` token: ${maskToken(info.token)}`)
162
+ lines.push(` ingest URL: ${info.ingestUrl || '(not set)'}`)
163
+ lines.push('')
164
+ for (const [label, d] of [
165
+ ['Claude Code', info.claude],
166
+ ['Codex', info.codex],
167
+ ]) {
168
+ lines.push(`${label} ${d.dir}`)
169
+ if (!d.found) {
170
+ lines.push(' logs: not found')
171
+ } else {
172
+ const n = d.recentCount
173
+ lines.push(` logs: found, ${n} session file${n === 1 ? '' : 's'} in the last ${info.lookbackDays} days`)
174
+ lines.push(` last write: ${relAgo(d.lastWriteMs, info.nowMs)}`)
175
+ }
176
+ lines.push('')
177
+ }
178
+ lines.push('Privacy')
179
+ lines.push(' sent: model names, token counts, tool and MCP names, dates')
180
+ lines.push(' never sent: prompts, responses, file contents, API keys')
181
+ return lines.join('\n')
182
+ }
183
+
184
+ // Collapse events to one daily row per (provider, model, date).
185
+ export function aggregateDaily(events, today) {
186
+ const byKey = new Map()
187
+ for (const e of events) {
188
+ const date = e.occurredOn || today
189
+ const key = `${e.provider}|${e.model}|${date}`
190
+ const cur = byKey.get(key) || {
191
+ provider: e.provider,
192
+ model: e.model,
193
+ occurredOn: date,
194
+ uncachedInputTokens: 0,
195
+ cacheReadInputTokens: 0,
196
+ cacheCreationInputTokens: 0,
197
+ outputTokens: 0,
198
+ numRequests: 0,
199
+ }
200
+ cur.uncachedInputTokens += e.uncachedInputTokens || 0
201
+ cur.cacheReadInputTokens += e.cacheReadInputTokens || 0
202
+ cur.cacheCreationInputTokens += e.cacheCreationInputTokens || 0
203
+ cur.outputTokens += e.outputTokens || 0
204
+ cur.numRequests += e.numRequests || 1
205
+ byKey.set(key, cur)
206
+ }
207
+ return [...byKey.values()]
208
+ }
209
+
210
+ // Recent hourly buckets for the 5-hour window. Only events with a real timestamp in
211
+ // the lookback window qualify, so a backfill cannot pollute the recent window.
212
+ export function aggregateHourly(events, nowMs, lookbackMs = 8 * 3_600_000) {
213
+ const cutoff = nowMs - lookbackMs
214
+ const byHour = new Map()
215
+ for (const e of events) {
216
+ if (!e.occurredAt) continue
217
+ const t = new Date(e.occurredAt).getTime()
218
+ if (Number.isNaN(t) || t < cutoff) continue
219
+ const d = new Date(t)
220
+ d.setMinutes(0, 0, 0)
221
+ const hourIso = d.toISOString()
222
+ const key = `${e.provider}|${e.model}|${hourIso}`
223
+ const cur = byHour.get(key) || {
224
+ provider: e.provider,
225
+ model: e.model,
226
+ bucketHour: hourIso,
227
+ uncachedInputTokens: 0,
228
+ cacheReadInputTokens: 0,
229
+ cacheCreationInputTokens: 0,
230
+ outputTokens: 0,
231
+ numRequests: 0,
232
+ }
233
+ cur.uncachedInputTokens += e.uncachedInputTokens || 0
234
+ cur.cacheReadInputTokens += e.cacheReadInputTokens || 0
235
+ cur.cacheCreationInputTokens += e.cacheCreationInputTokens || 0
236
+ cur.outputTokens += e.outputTokens || 0
237
+ cur.numRequests += e.numRequests || 1
238
+ byHour.set(key, cur)
239
+ }
240
+ return [...byHour.values()]
241
+ }
242
+
243
+ // Per-tool / MCP rows. Claude events carry a `tools` array (even-split tokens across
244
+ // the distinct groups a turn called); Codex contributes precomputed tool deltas.
245
+ export function aggregateTools(events, codexToolDeltas = [], today) {
246
+ const byTool = new Map()
247
+ const add = (tool, date, calls, tokens) => {
248
+ const key = `${tool}|${date}`
249
+ const cur = byTool.get(key) || { tool, bucketDate: date, calls: 0, tokens: 0 }
250
+ cur.calls += calls
251
+ cur.tokens += tokens
252
+ byTool.set(key, cur)
253
+ }
254
+ for (const e of events) {
255
+ if (!Array.isArray(e.tools) || e.tools.length === 0) continue
256
+ const date = e.occurredOn || today
257
+ const callsByGroup = new Map()
258
+ for (const name of e.tools) {
259
+ const g = toolGroup(name)
260
+ callsByGroup.set(g, (callsByGroup.get(g) || 0) + 1)
261
+ }
262
+ const eventTokens =
263
+ (e.uncachedInputTokens || 0) +
264
+ (e.cacheReadInputTokens || 0) +
265
+ (e.cacheCreationInputTokens || 0) +
266
+ (e.outputTokens || 0)
267
+ const tokenShare = Math.round(eventTokens / callsByGroup.size)
268
+ for (const [g, calls] of callsByGroup) add(g, date, calls, tokenShare)
269
+ }
270
+ for (const d of codexToolDeltas) add(d.tool, d.bucketDate, d.calls, d.tokens)
271
+ return [...byTool.values()]
272
+ }
273
+
274
+ // --- Local recommendations: session summaries + scoring for `doctor
275
+ // --recommendations`. Computed entirely from local logs, no network. ---
276
+
277
+ function pct(x) {
278
+ return `${Math.round(x * 100)}%`
279
+ }
280
+
281
+ function fmtTok(value) {
282
+ if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(1)}B`
283
+ if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
284
+ if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`
285
+ return String(Math.round(value))
286
+ }
287
+
288
+ // Summarize one Claude session file into per-session totals, the per-turn context
289
+ // size sequence (for bloat detection), and tool token attribution.
290
+ export function claudeSessionSummary(text) {
291
+ let model = 'claude-unknown'
292
+ let uncached = 0
293
+ let cacheRead = 0
294
+ let cacheCreate = 0
295
+ let output = 0
296
+ let requests = 0
297
+ const contextSeq = []
298
+ const tools = {}
299
+ for (const line of String(text).split('\n')) {
300
+ if (!line.trim()) continue
301
+ let o
302
+ try {
303
+ o = JSON.parse(line)
304
+ } catch {
305
+ continue
306
+ }
307
+ const ev = claudeEventFromLine(o)
308
+ if (!ev) continue
309
+ model = ev.model || model
310
+ uncached += ev.uncachedInputTokens
311
+ cacheRead += ev.cacheReadInputTokens
312
+ cacheCreate += ev.cacheCreationInputTokens
313
+ output += ev.outputTokens
314
+ requests += 1
315
+ contextSeq.push(ev.uncachedInputTokens + ev.cacheReadInputTokens + ev.cacheCreationInputTokens)
316
+ const turnTokens =
317
+ ev.uncachedInputTokens + ev.cacheReadInputTokens + ev.cacheCreationInputTokens + ev.outputTokens
318
+ const groups = new Map()
319
+ for (const name of ev.tools) {
320
+ const g = toolGroup(name)
321
+ groups.set(g, (groups.get(g) || 0) + 1)
322
+ }
323
+ const share = groups.size > 0 ? Math.round(turnTokens / groups.size) : 0
324
+ for (const [g, calls] of groups) {
325
+ const cur = tools[g] || { tokens: 0, calls: 0 }
326
+ cur.tokens += share
327
+ cur.calls += calls
328
+ tools[g] = cur
329
+ }
330
+ }
331
+ if (requests === 0) return null
332
+ return { provider: 'anthropic', model, uncached, cacheRead, cacheCreate, output, requests, contextSeq, tools }
333
+ }
334
+
335
+ // Summarize one Codex session from its final cumulative token_count. Codex totals are
336
+ // cumulative, so there is no reliable per-turn context sequence (bloat is Claude-only).
337
+ export function codexSessionSummary(text) {
338
+ let model = 'gpt-5'
339
+ let totals = null
340
+ let requests = 0
341
+ const tools = {}
342
+ for (const line of String(text).split('\n')) {
343
+ if (!line.trim()) continue
344
+ let o
345
+ try {
346
+ o = JSON.parse(line)
347
+ } catch {
348
+ continue
349
+ }
350
+ const p = o.payload || o
351
+ const ptype = p.type || o.type
352
+ if (typeof o.model === 'string') model = o.model
353
+ else if (typeof p.model === 'string') model = p.model
354
+ const tc = findLastTokenCount(o)
355
+ if (tc) {
356
+ totals = tc
357
+ requests += 1
358
+ }
359
+ const g = codexToolFromEvent(p, ptype)
360
+ if (g) {
361
+ const cur = tools[g] || { tokens: 0, calls: 0 }
362
+ cur.calls += 1
363
+ tools[g] = cur
364
+ }
365
+ }
366
+ if (!totals) return null
367
+ const cacheRead = totals.cached_input_tokens || 0
368
+ const uncached = Math.max(0, (totals.input_tokens || 0) - cacheRead)
369
+ const output = (totals.output_tokens || 0) + (totals.reasoning_output_tokens || 0)
370
+ return {
371
+ provider: 'openai',
372
+ model,
373
+ uncached,
374
+ cacheRead,
375
+ cacheCreate: 0,
376
+ output,
377
+ requests: Math.max(1, requests),
378
+ contextSeq: [],
379
+ tools,
380
+ }
381
+ }
382
+
383
+ // First-5 vs last-5 average turn size + max, for context-bloat detection.
384
+ export function sessionBloat(seq) {
385
+ if (!Array.isArray(seq) || seq.length < 10) return null
386
+ const avg = (arr) => Math.round(arr.reduce((a, b) => a + b, 0) / arr.length)
387
+ return { first5: avg(seq.slice(0, 5)), last5: avg(seq.slice(-5)), max: Math.max(...seq) }
388
+ }
389
+
390
+ // Turn session summaries into local recommendations. Pure, so the whole engine is
391
+ // fixture-tested. Returns [{ kind, level, text }] in cache -> mcp -> output -> bloat order.
392
+ export function buildLocalRecommendations(summaries) {
393
+ const recs = []
394
+ const list = (summaries || []).filter(Boolean)
395
+ if (list.length === 0) return recs
396
+
397
+ let uncached = 0
398
+ let cacheRead = 0
399
+ let cacheCreate = 0
400
+ let output = 0
401
+ let requests = 0
402
+ const tools = {}
403
+ for (const s of list) {
404
+ uncached += s.uncached
405
+ cacheRead += s.cacheRead
406
+ cacheCreate += s.cacheCreate
407
+ output += s.output
408
+ requests += s.requests
409
+ for (const [g, v] of Object.entries(s.tools || {})) {
410
+ const cur = tools[g] || { tokens: 0, calls: 0 }
411
+ cur.tokens += v.tokens
412
+ cur.calls += v.calls
413
+ tools[g] = cur
414
+ }
415
+ }
416
+ const inputTotal = uncached + cacheRead + cacheCreate
417
+ const total = inputTotal + output
418
+
419
+ // 1. Cache effectiveness.
420
+ if (inputTotal > 0) {
421
+ const readRatio = cacheRead / inputTotal
422
+ const createRatio = cacheCreate / inputTotal
423
+ const uncachedRatio = uncached / inputTotal
424
+ if (createRatio > 0.3 && readRatio < createRatio) {
425
+ recs.push({
426
+ kind: 'cache',
427
+ level: 'warn',
428
+ text: `High cache creation, low reuse: ${pct(createRatio)} of input is cache writes vs ${pct(
429
+ readRatio,
430
+ )} reads. Keep your prompt prefix byte-for-byte stable so it gets reused.`,
431
+ })
432
+ } else if (uncachedRatio > 0.5) {
433
+ recs.push({
434
+ kind: 'cache',
435
+ level: 'warn',
436
+ text: `Repeated uncached context: ${pct(
437
+ uncachedRatio,
438
+ )} of input pays full price. Move stable content (system prompt, tools, examples) into a cached prefix.`,
439
+ })
440
+ } else if (readRatio >= 0.6) {
441
+ recs.push({
442
+ kind: 'cache',
443
+ level: 'ok',
444
+ text: `Good cache reuse: ${pct(readRatio)} of input is cached reads.`,
445
+ })
446
+ }
447
+ }
448
+
449
+ // 2. MCP / tool ranking.
450
+ const toolArr = Object.entries(tools)
451
+ .map(([tool, v]) => ({ tool, ...v }))
452
+ .sort((a, b) => b.tokens - a.tokens)
453
+ const toolTotal = toolArr.reduce((n, t) => n + t.tokens, 0)
454
+ const topMcp = toolArr.find((t) => t.tool.startsWith('mcp:'))
455
+ if (topMcp && toolTotal > 0 && topMcp.tokens / toolTotal >= 0.25) {
456
+ recs.push({
457
+ kind: 'mcp',
458
+ level: 'warn',
459
+ text: `${topMcp.tool} is ${pct(
460
+ topMcp.tokens / toolTotal,
461
+ )} of tool-attributed usage. Disable it when you are not actively using it.`,
462
+ })
463
+ }
464
+
465
+ // 3. Output verbosity.
466
+ if (total > 0 && output / total > 0.4) {
467
+ recs.push({
468
+ kind: 'output',
469
+ level: 'warn',
470
+ text: `Output is ${pct(
471
+ output / total,
472
+ )} of usage. Ask for patch-only responses or short summaries; output is the priciest token tier.`,
473
+ })
474
+ } else if (requests > 0 && Math.round(output / requests) > 5000) {
475
+ recs.push({
476
+ kind: 'output',
477
+ level: 'info',
478
+ text: `Responses average ${fmtTok(
479
+ Math.round(output / requests),
480
+ )} output tokens. A max_tokens cap or terser prompt trims the priciest tier.`,
481
+ })
482
+ }
483
+
484
+ // 4. Context bloat (Claude sessions carry a per-turn sequence).
485
+ let worst = null
486
+ for (const s of list) {
487
+ const b = sessionBloat(s.contextSeq)
488
+ if (b && b.last5 > b.first5 * 2 && b.last5 > 30_000 && (!worst || b.last5 > worst.last5)) {
489
+ worst = b
490
+ }
491
+ }
492
+ if (worst) {
493
+ recs.push({
494
+ kind: 'bloat',
495
+ level: 'warn',
496
+ text: `A recent session's context grew from ${fmtTok(worst.first5)} to ${fmtTok(
497
+ worst.last5,
498
+ )} tokens per turn. Start a fresh session or ask the model to summarize state.`,
499
+ })
500
+ }
501
+
502
+ return recs
503
+ }
package/lib.test.mjs ADDED
@@ -0,0 +1,173 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import {
5
+ aggregateDaily,
6
+ aggregateHourly,
7
+ aggregateTools,
8
+ claudeEventFromLine,
9
+ codexDelta,
10
+ deriveHourlyUrl,
11
+ findLastTokenCount,
12
+ formatDoctorReport,
13
+ mergeDetailBatches,
14
+ pruneClaudeState,
15
+ } from './lib.mjs'
16
+
17
+ test('codexDelta leaves post-token-count tool calls for the next batch', () => {
18
+ const toolCalls = [
19
+ { ts: '2026-06-18T12:01:00.000Z', group: 'exec_command' },
20
+ { ts: '2026-06-18T12:03:00.000Z', group: 'apply_patch' },
21
+ ]
22
+ const first = codexDelta(
23
+ {
24
+ totals: { input_tokens: 100, cached_input_tokens: 0, output_tokens: 0, reasoning_output_tokens: 0 },
25
+ totalsTs: '2026-06-18T12:02:00.000Z',
26
+ maxTs: '2026-06-18T12:03:00.000Z',
27
+ model: 'gpt-5',
28
+ fileDate: '2026-06-18',
29
+ toolCalls,
30
+ },
31
+ null,
32
+ )
33
+ assert.deepEqual(
34
+ first.toolDeltas.map((d) => d.tool),
35
+ ['exec_command'],
36
+ )
37
+ assert.equal(first.state.lastToolTs, '2026-06-18T12:02:00.000Z')
38
+
39
+ const second = codexDelta(
40
+ {
41
+ totals: { input_tokens: 200, cached_input_tokens: 0, output_tokens: 0, reasoning_output_tokens: 0 },
42
+ totalsTs: '2026-06-18T12:04:00.000Z',
43
+ maxTs: '2026-06-18T12:04:00.000Z',
44
+ model: 'gpt-5',
45
+ fileDate: '2026-06-18',
46
+ toolCalls,
47
+ },
48
+ first.state,
49
+ )
50
+ assert.deepEqual(
51
+ second.toolDeltas.map((d) => d.tool),
52
+ ['apply_patch'],
53
+ )
54
+ })
55
+
56
+ test('pruneClaudeState keeps newest timestamped ids and normalizes legacy entries', () => {
57
+ const pruned = pruneClaudeState(
58
+ {
59
+ old: { ts: '2026-06-18T10:00:00.000Z' },
60
+ legacy: 1,
61
+ newest: { ts: '2026-06-18T12:00:00.000Z' },
62
+ middle: { ts: '2026-06-18T11:00:00.000Z' },
63
+ },
64
+ 2,
65
+ )
66
+ assert.deepEqual(Object.keys(pruned), ['middle', 'newest'])
67
+ assert.deepEqual(pruned.middle, { ts: '2026-06-18T11:00:00.000Z' })
68
+ })
69
+
70
+ test('deriveHourlyUrl fails closed for nonstandard ingest URLs unless overridden', () => {
71
+ assert.equal(
72
+ deriveHourlyUrl('https://x.test/functions/v1/ingest/'),
73
+ 'https://x.test/functions/v1/ingest-hourly',
74
+ )
75
+ assert.equal(deriveHourlyUrl('https://x.test/custom'), null)
76
+ assert.equal(deriveHourlyUrl('https://x.test/custom', 'https://x.test/detail'), 'https://x.test/detail')
77
+ })
78
+
79
+ test('mergeDetailBatches deduplicates additive rows by bucket', () => {
80
+ const merged = mergeDetailBatches(
81
+ {
82
+ hours: [
83
+ {
84
+ provider: 'openai',
85
+ model: 'gpt-5',
86
+ bucketHour: '2026-06-18T12:00:00.000Z',
87
+ uncachedInputTokens: 1,
88
+ cacheReadInputTokens: 2,
89
+ cacheCreationInputTokens: 3,
90
+ outputTokens: 4,
91
+ numRequests: 1,
92
+ },
93
+ ],
94
+ tools: [{ tool: 'exec_command', bucketDate: '2026-06-18', calls: 1, tokens: 10 }],
95
+ },
96
+ {
97
+ hours: [
98
+ {
99
+ provider: 'openai',
100
+ model: 'gpt-5',
101
+ bucketHour: '2026-06-18T12:00:00.000Z',
102
+ uncachedInputTokens: 5,
103
+ cacheReadInputTokens: 0,
104
+ cacheCreationInputTokens: 0,
105
+ outputTokens: 6,
106
+ numRequests: 1,
107
+ },
108
+ ],
109
+ tools: [{ tool: 'exec_command', bucketDate: '2026-06-18', calls: 2, tokens: 20 }],
110
+ },
111
+ )
112
+ assert.equal(merged.hours.length, 1)
113
+ assert.equal(merged.hours[0].uncachedInputTokens, 6)
114
+ assert.equal(merged.hours[0].outputTokens, 10)
115
+ assert.equal(merged.hours[0].numRequests, 2)
116
+ assert.deepEqual(merged.tools, [{ tool: 'exec_command', bucketDate: '2026-06-18', calls: 3, tokens: 30 }])
117
+ })
118
+
119
+ test('claude parsing and aggregations exclude prompt text', () => {
120
+ const event = claudeEventFromLine({
121
+ uuid: 'c1',
122
+ timestamp: '2026-06-18T12:34:56.000Z',
123
+ message: {
124
+ role: 'assistant',
125
+ model: 'claude-sonnet',
126
+ usage: {
127
+ input_tokens: 100,
128
+ cache_read_input_tokens: 20,
129
+ cache_creation_input_tokens: 5,
130
+ output_tokens: 30,
131
+ },
132
+ content: [
133
+ { type: 'text', text: 'response text must not be copied' },
134
+ { type: 'tool_use', name: 'mcp__supabase__query' },
135
+ { type: 'tool_use', name: 'exec_command' },
136
+ ],
137
+ },
138
+ })
139
+ assert.equal(event.id, 'c1')
140
+ assert.equal(event.provider, 'anthropic')
141
+ assert.deepEqual(event.tools, ['mcp__supabase__query', 'exec_command'])
142
+ assert.equal(JSON.stringify(event).includes('response text'), false)
143
+
144
+ assert.equal(aggregateDaily([event], '2026-06-18')[0].uncachedInputTokens, 100)
145
+ assert.equal(aggregateHourly([event], new Date('2026-06-18T13:00:00.000Z').getTime()).length, 1)
146
+ assert.deepEqual(
147
+ aggregateTools([event], [], '2026-06-18').map((d) => d.tool).sort(),
148
+ ['exec_command', 'mcp:supabase'],
149
+ )
150
+ })
151
+
152
+ test('findLastTokenCount and doctor report cover nested Codex usage and privacy copy', () => {
153
+ const totals = findLastTokenCount({
154
+ payload: [
155
+ { type: 'token_count', info: { total_token_usage: { input_tokens: 1 } } },
156
+ { nested: { type: 'token_count', info: { total_token_usage: { input_tokens: 2 } } } },
157
+ ],
158
+ })
159
+ assert.deepEqual(totals, { input_tokens: 2 })
160
+
161
+ const report = formatDoctorReport({
162
+ configPath: '/tmp/config.json',
163
+ configFound: true,
164
+ token: 'mm_live_abcdefghijklmnop',
165
+ ingestUrl: 'https://x.test/functions/v1/ingest',
166
+ lookbackDays: 14,
167
+ nowMs: 1000,
168
+ claude: { dir: '/tmp/claude', found: false },
169
+ codex: { dir: '/tmp/codex', found: true, recentCount: 1, lastWriteMs: 1000 },
170
+ })
171
+ assert.match(report, /mm_live_abcd\.\.\./)
172
+ assert.match(report, /never sent: prompts/)
173
+ })
package/package.json CHANGED
@@ -1,14 +1,19 @@
1
1
  {
2
2
  "name": "modelmeter-collect",
3
- "version": "0.4.0",
3
+ "version": "0.6.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": {
7
7
  "modelmeter-collect": "cli.mjs"
8
8
  },
9
+ "scripts": {
10
+ "test": "node --test lib.test.mjs"
11
+ },
9
12
  "files": [
10
13
  "cli.mjs",
11
14
  "collect.mjs",
15
+ "lib.mjs",
16
+ "lib.test.mjs",
12
17
  "README.md"
13
18
  ],
14
19
  "engines": {