codetime-cli 0.5.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.
Files changed (2) hide show
  1. package/bin/codetime.mjs +100 -41
  2. package/package.json +1 -1
package/bin/codetime.mjs CHANGED
@@ -160,6 +160,7 @@ import { fileURLToPath } from "node:url";
160
160
  // ../shared/src/index.ts
161
161
  import { createHash } from "node:crypto";
162
162
  var AGENT_TIME_SCHEMA_VERSION = "2026-04-29";
163
+ var AGENT_ROLLUP_SCHEMA_VERSION = 2;
163
164
  var TELEMETRY_EVENT_TYPES = [
164
165
  "session.started",
165
166
  "session.ended",
@@ -973,11 +974,12 @@ import { readFile as readFile2, stat as stat2 } from "node:fs/promises";
973
974
  import path2 from "node:path";
974
975
 
975
976
  // src/lib/constants.ts
976
- var PACKAGE_VERSION = true ? "0.5.0" : "0.1.1";
977
+ var PACKAGE_VERSION = true ? "0.6.0" : "0.1.1";
977
978
  var DEFAULT_API_URL = "https://codetime.dev";
978
979
  var DEFAULT_BACKFILL_BATCH_SIZE = 50;
979
980
  var DEFAULT_BACKFILL_BATCH_BYTES = 800 * 1024;
980
981
  var ROLLUP_BUCKET_MS = 15 * 60 * 1e3;
982
+ var TURN_GAP_CLAMP_MS = 5 * 60 * 1e3;
981
983
  var DEFAULT_HOOK_SYNC_MIN_INTERVAL_SECONDS = 60;
982
984
 
983
985
  // src/lib/fields.ts
@@ -2243,6 +2245,23 @@ async function parseCodexSessionFile(filePath, options) {
2243
2245
  let sessionMetaLocked = false;
2244
2246
  let lastTokenUsageKey;
2245
2247
  const pendingToolCalls = /* @__PURE__ */ new Map();
2248
+ const turnLastEventAt = /* @__PURE__ */ new Map();
2249
+ const turnStartedAt = /* @__PURE__ */ new Map();
2250
+ const closedTurnIds = /* @__PURE__ */ new Set();
2251
+ const pushEvent = (event, refs) => {
2252
+ const turnId = event.turnId;
2253
+ if (turnId && event.ts) {
2254
+ if (!turnStartedAt.has(turnId)) {
2255
+ turnStartedAt.set(turnId, event.ts);
2256
+ }
2257
+ const prev = turnLastEventAt.get(turnId);
2258
+ if (!prev || event.ts > prev) {
2259
+ turnLastEventAt.set(turnId, event.ts);
2260
+ }
2261
+ }
2262
+ events.push(withBackfillRefs(event, refs));
2263
+ };
2264
+ const turnCloseTs = (turnId) => turnLastEventAt.get(turnId) || turnStartedAt.get(turnId) || (/* @__PURE__ */ new Date()).toISOString();
2246
2265
  for (const [index, line] of lines.entries()) {
2247
2266
  const lineNumber = index + 1;
2248
2267
  const raw = parseJsonLine(line);
@@ -2299,23 +2318,23 @@ async function parseCodexSessionFile(filePath, options) {
2299
2318
  const eventModel = parsedModel || model || "gpt-5";
2300
2319
  const headlessTs = headlessCodexTimestamp(raw) || ts;
2301
2320
  if (!sessionStartEmitted) {
2302
- events.push(withBackfillRefs(baseCodexEvent({
2321
+ pushEvent(baseCodexEvent({
2303
2322
  ts: headlessTs,
2304
2323
  type: "session.started",
2305
2324
  sessionId,
2306
2325
  model: eventModel,
2307
2326
  confidence: "derived"
2308
- }), { filePath, sourcePathHash, lineNumber, topType: topType || "result", payloadType: "session", options }));
2327
+ }), { filePath, sourcePathHash, lineNumber, topType: topType || "result", payloadType: "session", options });
2309
2328
  sessionStartEmitted = true;
2310
2329
  }
2311
- events.push(withBackfillRefs(baseCodexEvent({
2330
+ pushEvent(baseCodexEvent({
2312
2331
  ts: headlessTs,
2313
2332
  type: "model.usage",
2314
2333
  sessionId,
2315
2334
  model: eventModel,
2316
2335
  confidence: "partial",
2317
2336
  metrics: usage
2318
- }), { filePath, sourcePathHash, lineNumber, topType: topType || "result", payloadType: "headless", options }));
2337
+ }), { filePath, sourcePathHash, lineNumber, topType: topType || "result", payloadType: "headless", options });
2319
2338
  }
2320
2339
  continue;
2321
2340
  }
@@ -2325,7 +2344,7 @@ async function parseCodexSessionFile(filePath, options) {
2325
2344
  switch (payloadType) {
2326
2345
  case "task_started": {
2327
2346
  currentTurnId = stringField(payload, "turn_id") || currentTurnId;
2328
- events.push(withBackfillRefs(baseCodexEvent({
2347
+ pushEvent(baseCodexEvent({
2329
2348
  ts,
2330
2349
  type: "turn.started",
2331
2350
  sessionId,
@@ -2334,14 +2353,15 @@ async function parseCodexSessionFile(filePath, options) {
2334
2353
  project,
2335
2354
  model,
2336
2355
  confidence: "exact"
2337
- }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options }));
2356
+ }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options });
2338
2357
  break;
2339
2358
  }
2340
2359
  case "user_message": {
2341
2360
  const message = stringField(payload, "message") || "";
2342
- if (currentTurnId && currentTurnId !== turnIdAtLastUserMessage && lastTurnIdForComplete) {
2343
- events.push(withBackfillRefs(baseCodexEvent({
2344
- ts,
2361
+ if (currentTurnId && currentTurnId !== turnIdAtLastUserMessage && lastTurnIdForComplete && !closedTurnIds.has(lastTurnIdForComplete)) {
2362
+ closedTurnIds.add(lastTurnIdForComplete);
2363
+ pushEvent(baseCodexEvent({
2364
+ ts: turnCloseTs(lastTurnIdForComplete),
2345
2365
  type: "turn.completed",
2346
2366
  sessionId,
2347
2367
  turnId: lastTurnIdForComplete,
@@ -2349,14 +2369,14 @@ async function parseCodexSessionFile(filePath, options) {
2349
2369
  project,
2350
2370
  model,
2351
2371
  confidence: "derived"
2352
- }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options }));
2372
+ }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options });
2353
2373
  }
2354
2374
  if (!currentTurnId || currentTurnId === turnIdAtLastUserMessage) {
2355
2375
  currentTurnId = `turn_${createStableHash([sessionId, lineNumber, ts]).slice(0, 24)}`;
2356
2376
  }
2357
2377
  lastTurnIdForComplete = currentTurnId;
2358
2378
  turnIdAtLastUserMessage = currentTurnId;
2359
- events.push(withBackfillRefs(baseCodexEvent({
2379
+ pushEvent(baseCodexEvent({
2360
2380
  ts,
2361
2381
  type: "prompt.submitted",
2362
2382
  sessionId,
@@ -2372,7 +2392,7 @@ async function parseCodexSessionFile(filePath, options) {
2372
2392
  refs: stringRefs({
2373
2393
  promptHash: message ? `sha256:${createStableHash(message)}` : void 0
2374
2394
  })
2375
- }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options }));
2395
+ }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options });
2376
2396
  break;
2377
2397
  }
2378
2398
  case "token_count": {
@@ -2394,7 +2414,7 @@ async function parseCodexSessionFile(filePath, options) {
2394
2414
  break;
2395
2415
  }
2396
2416
  lastTokenUsageKey = usageKey;
2397
- events.push(withBackfillRefs(baseCodexEvent({
2417
+ pushEvent(baseCodexEvent({
2398
2418
  ts,
2399
2419
  type: "model.usage",
2400
2420
  sessionId,
@@ -2407,13 +2427,13 @@ async function parseCodexSessionFile(filePath, options) {
2407
2427
  model: rewriteCodexModelForTier(model, serviceTier),
2408
2428
  confidence: "partial",
2409
2429
  metrics: usage
2410
- }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options }));
2430
+ }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options });
2411
2431
  }
2412
2432
  break;
2413
2433
  }
2414
2434
  case "agent_message": {
2415
2435
  const message = stringField(payload, "message") || "";
2416
- events.push(withBackfillRefs(baseCodexEvent({
2436
+ pushEvent(baseCodexEvent({
2417
2437
  ts,
2418
2438
  type: "agent.operation",
2419
2439
  operation: "agent message",
@@ -2426,7 +2446,7 @@ async function parseCodexSessionFile(filePath, options) {
2426
2446
  metrics: {
2427
2447
  agentMessageChars: message.length
2428
2448
  }
2429
- }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options }));
2449
+ }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options });
2430
2450
  break;
2431
2451
  }
2432
2452
  case "function_call":
@@ -2438,7 +2458,7 @@ async function parseCodexSessionFile(filePath, options) {
2438
2458
  if (callId) {
2439
2459
  pendingToolCalls.set(callId, { tool, startedAt: ts, turnId: currentTurnId });
2440
2460
  }
2441
- events.push(withBackfillRefs(baseCodexEvent({
2461
+ pushEvent(baseCodexEvent({
2442
2462
  ts,
2443
2463
  type: "tool.started",
2444
2464
  operation: `${tool} started`,
@@ -2455,9 +2475,9 @@ async function parseCodexSessionFile(filePath, options) {
2455
2475
  refs: stringRefs({
2456
2476
  sourceId: callId
2457
2477
  })
2458
- }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options }));
2478
+ }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options });
2459
2479
  if (fileActivities.length > 0) {
2460
- events.push(withBackfillRefs(baseCodexEvent({
2480
+ pushEvent(baseCodexEvent({
2461
2481
  ts,
2462
2482
  type: eventTypeFromFileActivities(fileActivities),
2463
2483
  operation: `${tool} file activity`,
@@ -2473,7 +2493,7 @@ async function parseCodexSessionFile(filePath, options) {
2473
2493
  refs: stringRefs({
2474
2494
  sourceId: callId
2475
2495
  })
2476
- }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options }));
2496
+ }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options });
2477
2497
  }
2478
2498
  break;
2479
2499
  }
@@ -2485,7 +2505,7 @@ async function parseCodexSessionFile(filePath, options) {
2485
2505
  pendingToolCalls.delete(callId);
2486
2506
  }
2487
2507
  const durationMs = pending ? durationMsBetween(pending.startedAt, ts) : void 0;
2488
- events.push(withBackfillRefs(baseCodexEvent({
2508
+ pushEvent(baseCodexEvent({
2489
2509
  ts,
2490
2510
  type: "tool.completed",
2491
2511
  operation: pending ? `${pending.tool} completed` : "tool completed",
@@ -2503,13 +2523,13 @@ async function parseCodexSessionFile(filePath, options) {
2503
2523
  refs: stringRefs({
2504
2524
  sourceId: callId
2505
2525
  })
2506
- }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options }));
2526
+ }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options });
2507
2527
  break;
2508
2528
  }
2509
2529
  case "exec_command_end": {
2510
2530
  const durationMs = durationObjectToMs(objectField(payload, "duration"));
2511
2531
  const success = Number(payload.exit_code) === 0;
2512
- events.push(withBackfillRefs(baseCodexEvent({
2532
+ pushEvent(baseCodexEvent({
2513
2533
  ts,
2514
2534
  type: success ? "command.completed" : "command.failed",
2515
2535
  operation: "command completed",
@@ -2530,7 +2550,7 @@ async function parseCodexSessionFile(filePath, options) {
2530
2550
  sourceId: stringField(payload, "call_id"),
2531
2551
  commandHash: createStableHash(payload.command)
2532
2552
  })
2533
- }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options }));
2553
+ }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options });
2534
2554
  break;
2535
2555
  }
2536
2556
  case "patch_apply_end": {
@@ -2540,7 +2560,7 @@ async function parseCodexSessionFile(filePath, options) {
2540
2560
  cwd,
2541
2561
  displayFilePath2
2542
2562
  );
2543
- events.push(withBackfillRefs(baseCodexEvent({
2563
+ pushEvent(baseCodexEvent({
2544
2564
  ts,
2545
2565
  type: "file.changed",
2546
2566
  operation: "apply patch",
@@ -2557,15 +2577,19 @@ async function parseCodexSessionFile(filePath, options) {
2557
2577
  refs: stringRefs({
2558
2578
  sourceId: stringField(payload, "call_id")
2559
2579
  })
2560
- }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options }));
2580
+ }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options });
2561
2581
  break;
2562
2582
  }
2563
2583
  case "task_complete": {
2564
- events.push(withBackfillRefs(baseCodexEvent({
2584
+ const completedTurnId = stringField(payload, "turn_id") || currentTurnId;
2585
+ if (completedTurnId) {
2586
+ closedTurnIds.add(completedTurnId);
2587
+ }
2588
+ pushEvent(baseCodexEvent({
2565
2589
  ts,
2566
2590
  type: "turn.completed",
2567
2591
  sessionId,
2568
- turnId: stringField(payload, "turn_id") || currentTurnId,
2592
+ turnId: completedTurnId,
2569
2593
  cwd,
2570
2594
  project,
2571
2595
  model,
@@ -2573,15 +2597,15 @@ async function parseCodexSessionFile(filePath, options) {
2573
2597
  metrics: {
2574
2598
  durationMs: numberField(payload, "duration_ms")
2575
2599
  }
2576
- }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options }));
2600
+ }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options });
2577
2601
  break;
2578
2602
  }
2579
2603
  }
2580
2604
  }
2581
- if (lastTurnIdForComplete) {
2582
- const lastTs = events.length > 0 ? events.at(-1).ts : (/* @__PURE__ */ new Date()).toISOString();
2583
- events.push(withBackfillRefs(baseCodexEvent({
2584
- ts: lastTs,
2605
+ if (lastTurnIdForComplete && !closedTurnIds.has(lastTurnIdForComplete)) {
2606
+ closedTurnIds.add(lastTurnIdForComplete);
2607
+ pushEvent(baseCodexEvent({
2608
+ ts: turnCloseTs(lastTurnIdForComplete),
2585
2609
  type: "turn.completed",
2586
2610
  sessionId,
2587
2611
  turnId: lastTurnIdForComplete,
@@ -2589,7 +2613,7 @@ async function parseCodexSessionFile(filePath, options) {
2589
2613
  project,
2590
2614
  model,
2591
2615
  confidence: "derived"
2592
- }), { filePath, sourcePathHash, lineNumber: lines.length, topType: "event_msg", payloadType: "turn.completed", options }));
2616
+ }), { filePath, sourcePathHash, lineNumber: lines.length, topType: "event_msg", payloadType: "turn.completed", options });
2593
2617
  }
2594
2618
  return events.filter((event) => validateCanonicalEvent(event).valid);
2595
2619
  }
@@ -2628,10 +2652,11 @@ function headlessCodexUsage(raw) {
2628
2652
  if (input === 0 && cached === 0 && output === 0 && reasoning === 0 && total === 0) {
2629
2653
  return;
2630
2654
  }
2655
+ const billableOutput = output + reasoning;
2631
2656
  return {
2632
2657
  tokensInput: input || void 0,
2633
2658
  tokensCachedInput: cached || void 0,
2634
- tokensOutput: output || void 0,
2659
+ tokensOutput: billableOutput || void 0,
2635
2660
  tokensReasoningOutput: reasoning || void 0,
2636
2661
  tokensTotal: total,
2637
2662
  modelCalls: 1
@@ -2992,9 +3017,10 @@ async function parseGeminiSessionFile(filePath, options) {
2992
3017
  let lastTs = usages[0].ts;
2993
3018
  for (const [index, usage] of usages.entries()) {
2994
3019
  lastTs = usage.ts;
3020
+ const billableOutput = usage.tokensOutput + usage.tokensReasoning;
2995
3021
  const metrics = {
2996
3022
  tokensInput: usage.tokensInput || void 0,
2997
- tokensOutput: usage.tokensOutput || void 0,
3023
+ tokensOutput: billableOutput || void 0,
2998
3024
  tokensCachedInput: usage.tokensCacheRead || void 0,
2999
3025
  tokensCacheReadInput: usage.tokensCacheRead || void 0,
3000
3026
  tokensReasoningOutput: usage.tokensReasoning || void 0,
@@ -3439,13 +3465,14 @@ function opencodeUsageFromInfo(info) {
3439
3465
  const cacheRead = Math.max(0, numberField(cache, "read") || 0);
3440
3466
  const cacheWrite = Math.max(0, numberField(cache, "write") || 0);
3441
3467
  const totalInput = input + cacheRead + cacheWrite;
3442
- const total = Math.max(0, numberField(tokensObj, "total") || totalInput + output + reasoning);
3468
+ const billableOutput = output + reasoning;
3469
+ const total = Math.max(0, numberField(tokensObj, "total") || totalInput + billableOutput);
3443
3470
  if (total <= 0) {
3444
3471
  return void 0;
3445
3472
  }
3446
3473
  return {
3447
3474
  tokensInput: totalInput || void 0,
3448
- tokensOutput: output || void 0,
3475
+ tokensOutput: billableOutput || void 0,
3449
3476
  tokensReasoningOutput: reasoning || void 0,
3450
3477
  tokensCachedInput: cacheRead + cacheWrite || void 0,
3451
3478
  tokensCacheReadInput: cacheRead || void 0,
@@ -4113,6 +4140,7 @@ function buildSessionRollup(rollupKey, events) {
4113
4140
  const toolRollups = /* @__PURE__ */ new Map();
4114
4141
  const fileRollups = /* @__PURE__ */ new Map();
4115
4142
  const turnRollups = /* @__PURE__ */ new Map();
4143
+ const turnEventTimes = /* @__PURE__ */ new Map();
4116
4144
  let promptCount = 0;
4117
4145
  let turnCount = 0;
4118
4146
  let toolCallCount = 0;
@@ -4210,6 +4238,12 @@ function buildSessionRollup(rollupKey, events) {
4210
4238
  turnRollup.outputTokens += eventOutputTokens;
4211
4239
  turnRollup.totalTokens += eventTotalTokens;
4212
4240
  turnRollups.set(event.turnId, turnRollup);
4241
+ const times = turnEventTimes.get(event.turnId);
4242
+ if (times) {
4243
+ times.push(event.ts);
4244
+ } else {
4245
+ turnEventTimes.set(event.turnId, [event.ts]);
4246
+ }
4213
4247
  }
4214
4248
  inputTokens += eventInputTokens;
4215
4249
  cachedInputTokens += eventCachedInputTokens;
@@ -4327,6 +4361,12 @@ function buildSessionRollup(rollupKey, events) {
4327
4361
  const baseRollup = {
4328
4362
  rollupKey,
4329
4363
  payloadHash: "",
4364
+ // v2 schema: trustworthy gap-clamped turn durations + billable-output token
4365
+ // convention. Set on baseRollup (not after) so it participates in payloadHash:
4366
+ // every historical rollup's hash changes, and a re-backfill (uploaded with
4367
+ // replace=true by default) cleanly refreshes all data onto the new convention.
4368
+ // This full-refresh churn is intentional.
4369
+ schemaVersion: AGENT_ROLLUP_SCHEMA_VERSION,
4330
4370
  source: first.source,
4331
4371
  project,
4332
4372
  sessionId,
@@ -4354,7 +4394,10 @@ function buildSessionRollup(rollupKey, events) {
4354
4394
  fileRollups: [...fileRollups.values()].sort((a, b) => b.writes - a.writes || b.reads - a.reads || a.displayPath.localeCompare(b.displayPath)),
4355
4395
  turnRollups: [...turnRollups.values()].map((rollup) => ({
4356
4396
  ...rollup,
4357
- durationMs: Math.max(0, Date.parse(rollup.lastEventAt) - Date.parse(rollup.startedAt))
4397
+ // Gap-clamped active duration: startedAt/lastEventAt/completedAt keep their
4398
+ // real timestamps, but durationMs only counts active intervals so lazy
4399
+ // completed_at timestamps and long in-turn silences don't inflate it.
4400
+ durationMs: gapClampedTurnDurationMs(rollup, turnEventTimes.get(rollup.turnId) || [])
4358
4401
  })).sort((a, b) => a.startedAt.localeCompare(b.startedAt))
4359
4402
  };
4360
4403
  return {
@@ -4362,6 +4405,22 @@ function buildSessionRollup(rollupKey, events) {
4362
4405
  payloadHash: createPayloadHash(baseRollup)
4363
4406
  };
4364
4407
  }
4408
+ function gapClampedTurnDurationMs(rollup, eventTimes) {
4409
+ const millis = [
4410
+ rollup.promptSubmittedAt,
4411
+ rollup.startedAt,
4412
+ ...eventTimes
4413
+ ].map((ts) => ts ? Date.parse(ts) : Number.NaN).filter((ms) => Number.isFinite(ms));
4414
+ const unique = [...new Set(millis)].sort((a, b) => a - b);
4415
+ if (unique.length < 2) {
4416
+ return 0;
4417
+ }
4418
+ let duration = 0;
4419
+ for (let i = 1; i < unique.length; i += 1) {
4420
+ duration += Math.min(unique[i] - unique[i - 1], TURN_GAP_CLAMP_MS);
4421
+ }
4422
+ return Math.max(0, duration);
4423
+ }
4365
4424
  function floorRollupBucket(ts) {
4366
4425
  const ms = Date.parse(ts);
4367
4426
  if (!Number.isFinite(ms)) {
@@ -4374,7 +4433,7 @@ function totalTokensFromEvent(event) {
4374
4433
  if (typeof explicit === "number" && explicit > 0) {
4375
4434
  return explicit;
4376
4435
  }
4377
- return Math.max(0, event.metrics?.tokensInput || 0) + Math.max(0, event.metrics?.tokensOutput || 0) + Math.max(0, event.metrics?.tokensReasoningOutput || 0);
4436
+ return Math.max(0, event.metrics?.tokensInput || 0) + Math.max(0, event.metrics?.tokensOutput || 0);
4378
4437
  }
4379
4438
  function lineStatsFromEvent(event) {
4380
4439
  const files = event.fileActivities || [];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "codetime-cli",
3
3
  "type": "module",
4
- "version": "0.5.0",
4
+ "version": "0.6.0",
5
5
  "description": "codetime CLI — install AI-agent hooks (Claude Code, Codex, OpenCode, Pi) and report activity to codetime.dev.",
6
6
  "license": "MIT",
7
7
  "publishConfig": {