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 +12 -2
- package/cli.mjs +50 -6
- package/collect.mjs +104 -58
- package/lib.mjs +67 -3
- package/package.json +1 -1
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
|
-
##
|
|
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)
|
|
76
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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: {}
|
|
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] =
|
|
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
|
-
|
|
231
|
+
claudeFiles = [hookInput.transcript_path] // Claude Code hook: just this session
|
|
239
232
|
} else if (codexNotify) {
|
|
240
|
-
|
|
233
|
+
codexFiles = recentFiles(join(HOME, '.codex', 'sessions'), 2) // Codex notify: newest session(s)
|
|
241
234
|
} else {
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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
|
-
|
|
282
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
300
|
-
//
|
|
301
|
-
|
|
302
|
-
|
|
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, {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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