codexmeter 1.0.23 → 1.0.25

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codexmeter",
3
- "version": "1.0.23",
3
+ "version": "1.0.25",
4
4
  "description": "Local telemetry dashboard for Codex CLI usage",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,7 +11,16 @@ function normalizeEffortKey(effort) {
11
11
  return k || 'unknown';
12
12
  }
13
13
 
14
- export function buildAggregates(sessions, tz, sessionView = null) {
14
+ function modelKeyFor(session, opts = {}) {
15
+ const key = session.model_name || 'unknown';
16
+ if (key === 'unknown' && opts.includeUnknownModels === false) return null;
17
+ return key;
18
+ }
19
+
20
+ export function buildAggregates(sessions, tz, sessionView = null, opts = {}) {
21
+ const aggregateOpts = {
22
+ includeUnknownModels: opts.includeUnknownModels !== false,
23
+ };
15
24
  const now = Date.now() / 1000;
16
25
  const d7 = now - 7 * 86400;
17
26
  const d30 = now - 30 * 86400;
@@ -25,21 +34,21 @@ export function buildAggregates(sessions, tz, sessionView = null) {
25
34
 
26
35
  const overview = buildOverview(sessions, groupedSessions, d7, d30);
27
36
  const repos = {
28
- total: buildRepos(rawBuckets.total),
29
- d7: buildRepos(rawBuckets.d7),
30
- d30: buildRepos(rawBuckets.d30),
37
+ total: buildRepos(rawBuckets.total, aggregateOpts),
38
+ d7: buildRepos(rawBuckets.d7, aggregateOpts),
39
+ d30: buildRepos(rawBuckets.d30, aggregateOpts),
31
40
  };
32
41
  const models = {
33
- total: buildModels(rawBuckets.total),
34
- d7: buildModels(rawBuckets.d7),
35
- d30: buildModels(rawBuckets.d30),
42
+ total: buildModels(rawBuckets.total, aggregateOpts),
43
+ d7: buildModels(rawBuckets.d7, aggregateOpts),
44
+ d30: buildModels(rawBuckets.d30, aggregateOpts),
36
45
  };
37
46
  const families = {
38
47
  total: buildFamilies(rawBuckets.total),
39
48
  d7: buildFamilies(rawBuckets.d7),
40
49
  d30: buildFamilies(rawBuckets.d30),
41
50
  };
42
- const daily = buildDaily(sessions, groupedSessions, tz);
51
+ const daily = buildDaily(sessions, groupedSessions, tz, aggregateOpts);
43
52
  const heatmap = buildHeatmap(sessions, groupedSessions, tz);
44
53
 
45
54
  return { overview, repos, models, daily, heatmap, families };
@@ -135,7 +144,7 @@ function buildOverview(rawSessions, groupedSessions, d7, d30) {
135
144
  };
136
145
  }
137
146
 
138
- function buildRepos(sessions) {
147
+ function buildRepos(sessions, opts) {
139
148
  const map = new Map();
140
149
  for (const s of sessions) {
141
150
  const key = s.repo_label;
@@ -163,15 +172,17 @@ function buildRepos(sessions) {
163
172
  }
164
173
  r.sessions++;
165
174
 
166
- const mKey = s.model_name || 'unknown';
167
- if (!r.by_model[mKey]) r.by_model[mKey] = { tokens: 0, cost: 0, sessions: 0, exact_priced: 0, heuristic_priced: 0 };
168
- r.by_model[mKey].tokens += s.tokens_used;
169
- if (s.cost !== null) {
170
- r.by_model[mKey].cost += s.cost;
171
- if (s.cost_source === 'exact') r.by_model[mKey].exact_priced++;
172
- if (s.cost_source === 'heuristic') r.by_model[mKey].heuristic_priced++;
175
+ const mKey = modelKeyFor(s, opts);
176
+ if (mKey) {
177
+ if (!r.by_model[mKey]) r.by_model[mKey] = { tokens: 0, cost: 0, sessions: 0, exact_priced: 0, heuristic_priced: 0 };
178
+ r.by_model[mKey].tokens += s.tokens_used;
179
+ if (s.cost !== null) {
180
+ r.by_model[mKey].cost += s.cost;
181
+ if (s.cost_source === 'exact') r.by_model[mKey].exact_priced++;
182
+ if (s.cost_source === 'heuristic') r.by_model[mKey].heuristic_priced++;
183
+ }
184
+ r.by_model[mKey].sessions++;
173
185
  }
174
- r.by_model[mKey].sessions++;
175
186
 
176
187
  const fKey = s.agent_family;
177
188
  if (!r.by_family[fKey]) r.by_family[fKey] = { tokens: 0, cost: 0, sessions: 0, exact_priced: 0, heuristic_priced: 0 };
@@ -186,10 +197,11 @@ function buildRepos(sessions) {
186
197
  return [...map.values()].sort((a, b) => b.tokens - a.tokens);
187
198
  }
188
199
 
189
- function buildModels(sessions) {
200
+ function buildModels(sessions, opts) {
190
201
  const map = new Map();
191
202
  for (const s of sessions) {
192
- const key = s.model_name || 'unknown';
203
+ const key = modelKeyFor(s, opts);
204
+ if (!key) continue;
193
205
  if (!map.has(key)) map.set(key, { model_name: key, tokens: 0, cost: 0, cost_known: 0, exact_priced: 0, heuristic_priced: 0, sessions: 0, by_effort: {} });
194
206
  const m = map.get(key);
195
207
  m.tokens += s.tokens_used;
@@ -230,7 +242,7 @@ function buildFamilies(sessions) {
230
242
  return [...map.values()].sort((a, b) => b.tokens - a.tokens);
231
243
  }
232
244
 
233
- function buildDaily(rawSessions, groupedSessions, tz) {
245
+ function buildDaily(rawSessions, groupedSessions, tz, opts) {
234
246
  const dayMap = new Map();
235
247
  for (const s of rawSessions) {
236
248
  if (!s.started_at || !s.ended_at) continue;
@@ -242,10 +254,10 @@ function buildDaily(rawSessions, groupedSessions, tz) {
242
254
 
243
255
  if (s.has_usage_by_day) {
244
256
  addSessionPresence(dayMap, startMs, endMs, totalDur, tz, s);
245
- addUsageByDay(dayMap, s);
257
+ addUsageByDay(dayMap, s, opts);
246
258
  } else {
247
259
  for (const { dayKey, overlapMs } of splitIntervalByDay(startMs, endMs, tz)) {
248
- addToDay(dayMap, dayKey, s, overlapMs / totalDur);
260
+ addToDay(dayMap, dayKey, s, overlapMs / totalDur, opts);
249
261
  }
250
262
  }
251
263
  }
@@ -253,7 +265,7 @@ function buildDaily(rawSessions, groupedSessions, tz) {
253
265
  for (const s of rawSessions) {
254
266
  if (!s.active_by_day) continue;
255
267
  for (const [dayKey, seconds] of Object.entries(s.active_by_day)) {
256
- addElapsedToDay(dayMap, dayKey, s, seconds || 0);
268
+ addElapsedToDay(dayMap, dayKey, s, seconds || 0, opts);
257
269
  }
258
270
  }
259
271
 
@@ -278,17 +290,19 @@ function buildDaily(rawSessions, groupedSessions, tz) {
278
290
  }));
279
291
  }
280
292
 
281
- function addToDay(dayMap, dayKey, session, fraction) {
293
+ function addToDay(dayMap, dayKey, session, fraction, opts) {
282
294
  if (!dayMap.has(dayKey)) dayMap.set(dayKey, { tokens: 0, cost: 0, elapsed_seconds: 0, sessions: 0, by_model: {}, by_family: {}, by_repo: {} });
283
295
  const d = dayMap.get(dayKey);
284
296
  d.tokens += session.tokens_used * fraction;
285
297
  if (session.cost !== null) d.cost += session.cost * fraction;
286
298
  if (fraction > 0.001) d.sessions++;
287
299
 
288
- const mKey = session.model_name || 'unknown';
289
- if (!d.by_model[mKey]) d.by_model[mKey] = { tokens: 0, cost: 0, elapsed_seconds: 0 };
290
- d.by_model[mKey].tokens += session.tokens_used * fraction;
291
- if (session.cost !== null) d.by_model[mKey].cost += session.cost * fraction;
300
+ const mKey = modelKeyFor(session, opts);
301
+ if (mKey) {
302
+ if (!d.by_model[mKey]) d.by_model[mKey] = { tokens: 0, cost: 0, elapsed_seconds: 0 };
303
+ d.by_model[mKey].tokens += session.tokens_used * fraction;
304
+ if (session.cost !== null) d.by_model[mKey].cost += session.cost * fraction;
305
+ }
292
306
 
293
307
  const fKey = session.agent_family;
294
308
  if (!d.by_family[fKey]) d.by_family[fKey] = { tokens: 0, cost: 0, elapsed_seconds: 0, sessions: 0 };
@@ -380,7 +394,7 @@ function addPresenceToDay(dayMap, dayKey, session, fraction) {
380
394
  if (fraction > 0.001) d.by_repo[rKey].sessions++;
381
395
  }
382
396
 
383
- function addUsageByDay(dayMap, session) {
397
+ function addUsageByDay(dayMap, session, opts) {
384
398
  for (const usageDay of session.usage_by_day || []) {
385
399
  const dayKey = usageDay.day;
386
400
  if (!dayMap.has(dayKey)) dayMap.set(dayKey, { tokens: 0, cost: 0, elapsed_seconds: 0, sessions: 0, by_model: {}, by_family: {}, by_repo: {} });
@@ -388,10 +402,12 @@ function addUsageByDay(dayMap, session) {
388
402
  d.tokens += usageDay.tokens || 0;
389
403
  if (usageDay.cost !== null) d.cost += usageDay.cost;
390
404
 
391
- const mKey = session.model_name || 'unknown';
392
- if (!d.by_model[mKey]) d.by_model[mKey] = { tokens: 0, cost: 0, elapsed_seconds: 0 };
393
- d.by_model[mKey].tokens += usageDay.tokens || 0;
394
- if (usageDay.cost !== null) d.by_model[mKey].cost += usageDay.cost;
405
+ const mKey = modelKeyFor(session, opts);
406
+ if (mKey) {
407
+ if (!d.by_model[mKey]) d.by_model[mKey] = { tokens: 0, cost: 0, elapsed_seconds: 0 };
408
+ d.by_model[mKey].tokens += usageDay.tokens || 0;
409
+ if (usageDay.cost !== null) d.by_model[mKey].cost += usageDay.cost;
410
+ }
395
411
 
396
412
  const fKey = session.agent_family;
397
413
  if (!d.by_family[fKey]) d.by_family[fKey] = { tokens: 0, cost: 0, elapsed_seconds: 0, sessions: 0 };
@@ -405,15 +421,17 @@ function addUsageByDay(dayMap, session) {
405
421
  }
406
422
  }
407
423
 
408
- function addElapsedToDay(dayMap, dayKey, session, seconds) {
424
+ function addElapsedToDay(dayMap, dayKey, session, seconds, opts) {
409
425
  if (!seconds) return;
410
426
  if (!dayMap.has(dayKey)) dayMap.set(dayKey, { tokens: 0, cost: 0, elapsed_seconds: 0, sessions: 0, by_model: {}, by_family: {}, by_repo: {} });
411
427
  const d = dayMap.get(dayKey);
412
428
  d.elapsed_seconds += seconds;
413
429
 
414
- const mKey = session.model_name || 'unknown';
415
- if (!d.by_model[mKey]) d.by_model[mKey] = { tokens: 0, cost: 0, elapsed_seconds: 0 };
416
- d.by_model[mKey].elapsed_seconds += seconds;
430
+ const mKey = modelKeyFor(session, opts);
431
+ if (mKey) {
432
+ if (!d.by_model[mKey]) d.by_model[mKey] = { tokens: 0, cost: 0, elapsed_seconds: 0 };
433
+ d.by_model[mKey].elapsed_seconds += seconds;
434
+ }
417
435
 
418
436
  const fKey = session.agent_family || 'generic';
419
437
  if (!d.by_family[fKey]) d.by_family[fKey] = { tokens: 0, cost: 0, elapsed_seconds: 0, sessions: 0 };
package/server/ingest.js CHANGED
@@ -132,7 +132,7 @@ export async function runIngest(codexHome, state, opts = {}) {
132
132
  agent_nickname: t.agent_nickname,
133
133
  agent_family: classifyAgentFamily(agentRole),
134
134
  is_subagent: isSubagent(agentRole),
135
- parent_thread_id: null,
135
+ parent_thread_id: t.parent_thread_id || null,
136
136
  forked_from_id: null,
137
137
  cost: null,
138
138
  cost_source: 'unavailable',
@@ -142,6 +142,9 @@ export async function runIngest(codexHome, state, opts = {}) {
142
142
  });
143
143
  }
144
144
 
145
+ const sessionById = new Map(sessions.map((session) => [session.thread_id, session]));
146
+ inheritMissingLineageModels(sessions, sessionById);
147
+
145
148
  state.phase = 'enrichment';
146
149
  queueLiveProgress(state);
147
150
 
@@ -226,6 +229,7 @@ export async function runIngest(codexHome, state, opts = {}) {
226
229
  }
227
230
  }
228
231
 
232
+ inheritMissingLineageModels(batch, sessionById);
229
233
  await applyForkUsageCorrections(batch, resolveForkUsageSnapshot, FORK_CORRECTION_CONCURRENCY);
230
234
  for (const s of batch) {
231
235
  if (s._usage_by_day_raw) {
@@ -301,6 +305,7 @@ export async function runIngest(codexHome, state, opts = {}) {
301
305
  }
302
306
  }
303
307
 
308
+ inheritMissingLineageModels(sessions, sessionById);
304
309
  for (const s of sessions) {
305
310
  finalizeSessionMetrics(s, toDayKey);
306
311
  }
@@ -378,26 +383,55 @@ export function restartIngest(codexHome, state, opts = {}) {
378
383
  }
379
384
 
380
385
  function rebuildAggregates(sessions, state, opts, tz, mode = {}) {
381
- const source = mode.partial ? sessions.filter(session => session.materialized) : sessions;
386
+ const source = mode.partial ? sessions.filter(isSafeForPartialAggregation) : sessions;
382
387
  const visibleSource = filterVisibleSessions(source);
383
388
  const filtered = applyFilters(visibleSource, opts);
384
389
  const sessionView = buildSessionView(filtered, source);
385
390
  state.sessions = sessionView;
386
- state.aggregates = buildAggregates(filtered, tz, sessionView);
391
+ state.aggregates = buildAggregates(filtered, tz, sessionView, {
392
+ includeUnknownModels: mode.includeUnknownModels !== false,
393
+ });
387
394
  state.generated_at = new Date().toISOString();
388
395
  }
389
396
 
390
397
  function publishPartialAggregates(sessions, state, opts, tz, toDayKey) {
391
- for (const session of sessions) {
392
- finalizeSessionMetrics(session, toDayKey);
393
- }
394
- assignRootThreadIds(sessions, toDayKey);
395
- rebuildAggregates(sessions, state, opts, tz);
398
+ const partialSessions = createPartialAggregateSessions(sessions, toDayKey);
399
+ rebuildAggregates(partialSessions, state, opts, tz, {
400
+ includeUnknownModels: false,
401
+ });
396
402
  state.partial_ready = true;
397
403
  state.percent = Math.max(state.percent, 0.081);
398
404
  queueLiveProgress(state);
399
405
  }
400
406
 
407
+ export function createPartialAggregateSessions(sessions, toDayKey) {
408
+ assignRootThreadIds(sessions, toDayKey);
409
+ return sessions
410
+ .filter(isSafeForPartialAggregation)
411
+ .map((session) => {
412
+ const clone = { ...session };
413
+ const isUnparsedRollout = clone.rollout_path && !clone.materialized;
414
+ if (isUnparsedRollout) {
415
+ clone.model_name = null;
416
+ clone.cost = null;
417
+ clone.cost_source = 'unavailable';
418
+ clone.has_usage_by_day = true;
419
+ clone.usage_by_day = [];
420
+ }
421
+ finalizeSessionMetrics(clone, toDayKey);
422
+ if (isUnparsedRollout) {
423
+ clone.cost = null;
424
+ clone.cost_source = 'unavailable';
425
+ }
426
+ return clone;
427
+ });
428
+ }
429
+
430
+ export function isSafeForPartialAggregation(session) {
431
+ if (!session?.rollout_path || session.materialized) return true;
432
+ return !getForkLineageParentThreadId(session);
433
+ }
434
+
401
435
  export function filterVisibleSessions(sessions) {
402
436
  return sessions.filter((session) => !isReviewLauncherSession(session));
403
437
  }
@@ -429,6 +463,33 @@ export function selectEnrichmentCandidates(sessions, opts = {}) {
429
463
  return [...warmup, ...recent, ...older];
430
464
  }
431
465
 
466
+ export function inheritMissingLineageModels(targets, sessionById = new Map(targets.map((session) => [session.thread_id, session]))) {
467
+ const memo = new Map();
468
+ const resolving = new Set();
469
+
470
+ const resolveModel = (session) => {
471
+ if (!session) return null;
472
+ if (session.model_name) return session.model_name;
473
+ if (memo.has(session.thread_id)) return memo.get(session.thread_id);
474
+ if (resolving.has(session.thread_id)) return null;
475
+
476
+ resolving.add(session.thread_id);
477
+ const parent = sessionById.get(getForkLineageParentThreadId(session));
478
+ const modelName = resolveModel(parent);
479
+ if (modelName && !session.model_name) {
480
+ session.model_name = modelName;
481
+ }
482
+ resolving.delete(session.thread_id);
483
+
484
+ memo.set(session.thread_id, session.model_name || null);
485
+ return memo.get(session.thread_id);
486
+ };
487
+
488
+ for (const session of targets) {
489
+ resolveModel(session);
490
+ }
491
+ }
492
+
432
493
  export function pickVisibleDateBucket(bufferedSessions, liveReadySessions) {
433
494
  return bufferedSessions[0]?.live_sort_day || liveReadySessions[0]?.live_sort_day || null;
434
495
  }
@@ -636,7 +697,7 @@ async function applyForkUsageCorrections(sessions, resolveForkUsageSnapshot, con
636
697
  }
637
698
 
638
699
  export function getForkLineageParentThreadId(session) {
639
- return session?.forked_from_id || null;
700
+ return session?.forked_from_id || session?.parent_thread_id || null;
640
701
  }
641
702
 
642
703
  export function applyForkUsageCorrection(session, inheritedUsage) {
@@ -14,6 +14,7 @@ const RG_RELEVANT_PATTERN =
14
14
  const RG_TOKEN_COUNT_PATTERN = '"type"\\s*:\\s*"token_count"';
15
15
  const RG_TIMESTAMP_PATTERN = '^\\{[^{}]*"timestamp"\\s*:\\s*"[^"]+"';
16
16
  const RG_ANY_TIMESTAMP_PATTERN = '"timestamp"\\s*:\\s*"[^"]+"';
17
+ const SORTED_BY_TIMESTAMP = Symbol('codexmeter.sortedByTimestamp');
17
18
 
18
19
  export async function enrichFromRollout(rolloutPath, opts = {}) {
19
20
  if (!rolloutPath || !existsSync(rolloutPath)) {
@@ -266,6 +267,7 @@ export async function readUsageTimeline(rolloutPath, opts = {}) {
266
267
  }
267
268
  }
268
269
  timeline.sort((left, right) => left.timestamp - right.timestamp);
270
+ markTimelineSorted(timeline);
269
271
  return timeline;
270
272
  } catch {
271
273
  return [];
@@ -273,25 +275,55 @@ export async function readUsageTimeline(rolloutPath, opts = {}) {
273
275
  }
274
276
 
275
277
  export function findUsageEntryAtOrBefore(timeline, timestampMs) {
276
- if (!Array.isArray(timeline) || !timeline.length || !timestampMs) return null;
277
- const orderedTimeline = timeline.every((entry, index) => index === 0 || timeline[index - 1].timestamp <= entry.timestamp)
278
- ? timeline
279
- : [...timeline].sort((left, right) => left.timestamp - right.timestamp);
278
+ if (!Array.isArray(timeline) || !timeline.length || timestampMs == null) return null;
279
+ const target = Number(timestampMs);
280
+ if (!Number.isFinite(target)) return null;
281
+
282
+ const orderedTimeline = getTimestampOrderedTimeline(timeline);
283
+ let left = 0;
284
+ let right = orderedTimeline.length - 1;
280
285
  let best = null;
281
- for (const entry of orderedTimeline) {
282
- if ((entry?.timestamp || 0) <= timestampMs) {
286
+
287
+ while (left <= right) {
288
+ const mid = Math.floor((left + right) / 2);
289
+ const entry = orderedTimeline[mid];
290
+ if ((entry?.timestamp || 0) <= target) {
283
291
  best = entry;
284
- continue;
292
+ left = mid + 1;
293
+ } else {
294
+ right = mid - 1;
285
295
  }
286
- break;
287
296
  }
288
- return best || null;
297
+
298
+ return best;
289
299
  }
290
300
 
291
301
  export function findUsageAtOrBefore(timeline, timestampMs) {
292
302
  return findUsageEntryAtOrBefore(timeline, timestampMs)?.usage || null;
293
303
  }
294
304
 
305
+ function getTimestampOrderedTimeline(timeline) {
306
+ if (timeline[SORTED_BY_TIMESTAMP]) return timeline;
307
+ if (isTimestampSorted(timeline)) {
308
+ markTimelineSorted(timeline);
309
+ return timeline;
310
+ }
311
+ const orderedTimeline = [...timeline].sort((left, right) => left.timestamp - right.timestamp);
312
+ markTimelineSorted(orderedTimeline);
313
+ return orderedTimeline;
314
+ }
315
+
316
+ function isTimestampSorted(timeline) {
317
+ return timeline.every((entry, index) => index === 0 || timeline[index - 1].timestamp <= entry.timestamp);
318
+ }
319
+
320
+ function markTimelineSorted(timeline) {
321
+ Object.defineProperty(timeline, SORTED_BY_TIMESTAMP, {
322
+ value: true,
323
+ configurable: true,
324
+ });
325
+ }
326
+
295
327
  function shouldUseRipgrep(rolloutPath, minBytes) {
296
328
  try {
297
329
  return statSync(rolloutPath).size >= minBytes;
@@ -14,14 +14,24 @@ export function readThreads(codexHome, onProgress) {
14
14
  const count = db.prepare('SELECT count(*) as c FROM threads').get().c;
15
15
  if (onProgress) onProgress({ total: count, read: 0 });
16
16
 
17
+ const hasThreadSpawnEdges = db.prepare(
18
+ 'SELECT count(*) as c FROM sqlite_master WHERE type = ? AND name = ?'
19
+ ).get('table', 'thread_spawn_edges').c > 0;
20
+ const parentSelect = hasThreadSpawnEdges ? 'e.parent_thread_id' : 'NULL';
21
+ const parentJoin = hasThreadSpawnEdges
22
+ ? 'LEFT JOIN thread_spawn_edges e ON e.child_thread_id = t.id'
23
+ : '';
24
+
17
25
  const stmt = db.prepare(`
18
26
  SELECT
19
- id, rollout_path, created_at, updated_at,
20
- source, model_provider, model, reasoning_effort, cwd, title,
21
- tokens_used, agent_nickname, agent_role,
22
- cli_version, git_branch, git_origin_url
23
- FROM threads
24
- ORDER BY created_at ASC
27
+ t.id, t.rollout_path, t.created_at, t.updated_at,
28
+ t.source, t.model_provider, t.model, t.reasoning_effort, t.cwd, t.title,
29
+ t.tokens_used, t.agent_nickname, t.agent_role,
30
+ t.cli_version, t.git_branch, t.git_origin_url,
31
+ ${parentSelect} AS parent_thread_id
32
+ FROM threads t
33
+ ${parentJoin}
34
+ ORDER BY t.created_at ASC
25
35
  `);
26
36
 
27
37
  const threads = [];
@@ -41,6 +51,7 @@ export function readThreads(codexHome, onProgress) {
41
51
  tokens_used: row.tokens_used || 0,
42
52
  agent_nickname: row.agent_nickname || null,
43
53
  agent_role: row.agent_role || null,
54
+ parent_thread_id: row.parent_thread_id || null,
44
55
  cli_version: row.cli_version || '',
45
56
  git_branch: row.git_branch || null,
46
57
  });