modelmeter-collect 0.8.0 → 0.9.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.
Files changed (3) hide show
  1. package/collect.mjs +104 -58
  2. package/lib.mjs +25 -3
  3. package/package.json +1 -1
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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modelmeter-collect",
3
- "version": "0.8.0",
3
+ "version": "0.9.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": {