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 +9 -2
- package/cli.mjs +82 -4
- package/collect.mjs +60 -36
- package/lib.mjs +297 -0
- package/package.json +1 -1
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
|
|
35
|
-
npx modelmeter-collect doctor --
|
|
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 {
|
|
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 --
|
|
68
|
-
|
|
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 --
|
|
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 = {
|
|
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] =
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
if (
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
273
|
-
console.error(`modelmeter:
|
|
274
|
-
} else {
|
|
275
|
-
console.error(`modelmeter: ingest returned ${res.status}`)
|
|
292
|
+
} catch (err) {
|
|
293
|
+
console.error(`modelmeter: ${err.message}`)
|
|
276
294
|
}
|
|
277
|
-
}
|
|
278
|
-
|
|
295
|
+
} else {
|
|
296
|
+
committed = true // retrying previously committed detail rows
|
|
279
297
|
}
|
|
280
298
|
|
|
281
|
-
// Additive
|
|
282
|
-
//
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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