codetime-cli 0.5.0 → 0.7.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 +152 -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 = 3;
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.7.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
@@ -1930,16 +1932,29 @@ function claudeUsageFromMessage(message) {
1930
1932
  const outputTokens = numberField(usage, "output_tokens") || 0;
1931
1933
  const cachedInputTokens = cacheCreationInputTokens + cacheReadInputTokens;
1932
1934
  const totalInputTokens = inputTokens + cachedInputTokens;
1935
+ const cacheCreationSplit = claudeCacheCreationSplit(usage);
1933
1936
  return {
1934
1937
  tokensInput: totalInputTokens || void 0,
1935
1938
  tokensCachedInput: cachedInputTokens || void 0,
1936
1939
  tokensCacheCreationInput: cacheCreationInputTokens || void 0,
1940
+ tokensCacheCreation5mInput: cacheCreationSplit?.fiveMinute,
1941
+ tokensCacheCreation1hInput: cacheCreationSplit?.oneHour,
1937
1942
  tokensCacheReadInput: cacheReadInputTokens || void 0,
1938
1943
  tokensOutput: outputTokens || void 0,
1939
1944
  tokensTotal: totalInputTokens + outputTokens || void 0,
1940
1945
  modelCalls: 1
1941
1946
  };
1942
1947
  }
1948
+ function claudeCacheCreationSplit(usage) {
1949
+ const breakdown = usage.cache_creation;
1950
+ if (!isPlainObject(breakdown)) {
1951
+ return void 0;
1952
+ }
1953
+ return {
1954
+ fiveMinute: numberField(breakdown, "ephemeral_5m_input_tokens") || 0,
1955
+ oneHour: numberField(breakdown, "ephemeral_1h_input_tokens") || 0
1956
+ };
1957
+ }
1943
1958
  function claudeSubagentMetrics(toolResult, fallbackDurationMs) {
1944
1959
  const usage = objectField(toolResult, "usage");
1945
1960
  const inputTokens = numberField(usage, "input_tokens") || 0;
@@ -1948,6 +1963,7 @@ function claudeSubagentMetrics(toolResult, fallbackDurationMs) {
1948
1963
  const outputTokens = numberField(usage, "output_tokens") || 0;
1949
1964
  const cachedInputTokens = cacheCreationInputTokens + cacheReadInputTokens;
1950
1965
  const totalInputTokens = inputTokens + cachedInputTokens;
1966
+ const cacheCreationSplit = claudeCacheCreationSplit(usage);
1951
1967
  const durationMs = numberField(toolResult, "totalDurationMs") || fallbackDurationMs;
1952
1968
  return {
1953
1969
  durationMs,
@@ -1956,6 +1972,8 @@ function claudeSubagentMetrics(toolResult, fallbackDurationMs) {
1956
1972
  tokensInput: totalInputTokens || void 0,
1957
1973
  tokensCachedInput: cachedInputTokens || void 0,
1958
1974
  tokensCacheCreationInput: cacheCreationInputTokens || void 0,
1975
+ tokensCacheCreation5mInput: cacheCreationSplit?.fiveMinute,
1976
+ tokensCacheCreation1hInput: cacheCreationSplit?.oneHour,
1959
1977
  tokensCacheReadInput: cacheReadInputTokens || void 0,
1960
1978
  tokensOutput: outputTokens || void 0,
1961
1979
  tokensTotal: numberField(toolResult, "totalTokens") || totalInputTokens + outputTokens || void 0
@@ -2243,6 +2261,23 @@ async function parseCodexSessionFile(filePath, options) {
2243
2261
  let sessionMetaLocked = false;
2244
2262
  let lastTokenUsageKey;
2245
2263
  const pendingToolCalls = /* @__PURE__ */ new Map();
2264
+ const turnLastEventAt = /* @__PURE__ */ new Map();
2265
+ const turnStartedAt = /* @__PURE__ */ new Map();
2266
+ const closedTurnIds = /* @__PURE__ */ new Set();
2267
+ const pushEvent = (event, refs) => {
2268
+ const turnId = event.turnId;
2269
+ if (turnId && event.ts) {
2270
+ if (!turnStartedAt.has(turnId)) {
2271
+ turnStartedAt.set(turnId, event.ts);
2272
+ }
2273
+ const prev = turnLastEventAt.get(turnId);
2274
+ if (!prev || event.ts > prev) {
2275
+ turnLastEventAt.set(turnId, event.ts);
2276
+ }
2277
+ }
2278
+ events.push(withBackfillRefs(event, refs));
2279
+ };
2280
+ const turnCloseTs = (turnId) => turnLastEventAt.get(turnId) || turnStartedAt.get(turnId) || (/* @__PURE__ */ new Date()).toISOString();
2246
2281
  for (const [index, line] of lines.entries()) {
2247
2282
  const lineNumber = index + 1;
2248
2283
  const raw = parseJsonLine(line);
@@ -2299,23 +2334,23 @@ async function parseCodexSessionFile(filePath, options) {
2299
2334
  const eventModel = parsedModel || model || "gpt-5";
2300
2335
  const headlessTs = headlessCodexTimestamp(raw) || ts;
2301
2336
  if (!sessionStartEmitted) {
2302
- events.push(withBackfillRefs(baseCodexEvent({
2337
+ pushEvent(baseCodexEvent({
2303
2338
  ts: headlessTs,
2304
2339
  type: "session.started",
2305
2340
  sessionId,
2306
2341
  model: eventModel,
2307
2342
  confidence: "derived"
2308
- }), { filePath, sourcePathHash, lineNumber, topType: topType || "result", payloadType: "session", options }));
2343
+ }), { filePath, sourcePathHash, lineNumber, topType: topType || "result", payloadType: "session", options });
2309
2344
  sessionStartEmitted = true;
2310
2345
  }
2311
- events.push(withBackfillRefs(baseCodexEvent({
2346
+ pushEvent(baseCodexEvent({
2312
2347
  ts: headlessTs,
2313
2348
  type: "model.usage",
2314
2349
  sessionId,
2315
2350
  model: eventModel,
2316
2351
  confidence: "partial",
2317
2352
  metrics: usage
2318
- }), { filePath, sourcePathHash, lineNumber, topType: topType || "result", payloadType: "headless", options }));
2353
+ }), { filePath, sourcePathHash, lineNumber, topType: topType || "result", payloadType: "headless", options });
2319
2354
  }
2320
2355
  continue;
2321
2356
  }
@@ -2325,7 +2360,7 @@ async function parseCodexSessionFile(filePath, options) {
2325
2360
  switch (payloadType) {
2326
2361
  case "task_started": {
2327
2362
  currentTurnId = stringField(payload, "turn_id") || currentTurnId;
2328
- events.push(withBackfillRefs(baseCodexEvent({
2363
+ pushEvent(baseCodexEvent({
2329
2364
  ts,
2330
2365
  type: "turn.started",
2331
2366
  sessionId,
@@ -2334,14 +2369,15 @@ async function parseCodexSessionFile(filePath, options) {
2334
2369
  project,
2335
2370
  model,
2336
2371
  confidence: "exact"
2337
- }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options }));
2372
+ }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options });
2338
2373
  break;
2339
2374
  }
2340
2375
  case "user_message": {
2341
2376
  const message = stringField(payload, "message") || "";
2342
- if (currentTurnId && currentTurnId !== turnIdAtLastUserMessage && lastTurnIdForComplete) {
2343
- events.push(withBackfillRefs(baseCodexEvent({
2344
- ts,
2377
+ if (currentTurnId && currentTurnId !== turnIdAtLastUserMessage && lastTurnIdForComplete && !closedTurnIds.has(lastTurnIdForComplete)) {
2378
+ closedTurnIds.add(lastTurnIdForComplete);
2379
+ pushEvent(baseCodexEvent({
2380
+ ts: turnCloseTs(lastTurnIdForComplete),
2345
2381
  type: "turn.completed",
2346
2382
  sessionId,
2347
2383
  turnId: lastTurnIdForComplete,
@@ -2349,14 +2385,14 @@ async function parseCodexSessionFile(filePath, options) {
2349
2385
  project,
2350
2386
  model,
2351
2387
  confidence: "derived"
2352
- }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options }));
2388
+ }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options });
2353
2389
  }
2354
2390
  if (!currentTurnId || currentTurnId === turnIdAtLastUserMessage) {
2355
2391
  currentTurnId = `turn_${createStableHash([sessionId, lineNumber, ts]).slice(0, 24)}`;
2356
2392
  }
2357
2393
  lastTurnIdForComplete = currentTurnId;
2358
2394
  turnIdAtLastUserMessage = currentTurnId;
2359
- events.push(withBackfillRefs(baseCodexEvent({
2395
+ pushEvent(baseCodexEvent({
2360
2396
  ts,
2361
2397
  type: "prompt.submitted",
2362
2398
  sessionId,
@@ -2372,7 +2408,7 @@ async function parseCodexSessionFile(filePath, options) {
2372
2408
  refs: stringRefs({
2373
2409
  promptHash: message ? `sha256:${createStableHash(message)}` : void 0
2374
2410
  })
2375
- }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options }));
2411
+ }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options });
2376
2412
  break;
2377
2413
  }
2378
2414
  case "token_count": {
@@ -2394,7 +2430,7 @@ async function parseCodexSessionFile(filePath, options) {
2394
2430
  break;
2395
2431
  }
2396
2432
  lastTokenUsageKey = usageKey;
2397
- events.push(withBackfillRefs(baseCodexEvent({
2433
+ pushEvent(baseCodexEvent({
2398
2434
  ts,
2399
2435
  type: "model.usage",
2400
2436
  sessionId,
@@ -2407,13 +2443,13 @@ async function parseCodexSessionFile(filePath, options) {
2407
2443
  model: rewriteCodexModelForTier(model, serviceTier),
2408
2444
  confidence: "partial",
2409
2445
  metrics: usage
2410
- }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options }));
2446
+ }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options });
2411
2447
  }
2412
2448
  break;
2413
2449
  }
2414
2450
  case "agent_message": {
2415
2451
  const message = stringField(payload, "message") || "";
2416
- events.push(withBackfillRefs(baseCodexEvent({
2452
+ pushEvent(baseCodexEvent({
2417
2453
  ts,
2418
2454
  type: "agent.operation",
2419
2455
  operation: "agent message",
@@ -2426,7 +2462,7 @@ async function parseCodexSessionFile(filePath, options) {
2426
2462
  metrics: {
2427
2463
  agentMessageChars: message.length
2428
2464
  }
2429
- }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options }));
2465
+ }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options });
2430
2466
  break;
2431
2467
  }
2432
2468
  case "function_call":
@@ -2438,7 +2474,7 @@ async function parseCodexSessionFile(filePath, options) {
2438
2474
  if (callId) {
2439
2475
  pendingToolCalls.set(callId, { tool, startedAt: ts, turnId: currentTurnId });
2440
2476
  }
2441
- events.push(withBackfillRefs(baseCodexEvent({
2477
+ pushEvent(baseCodexEvent({
2442
2478
  ts,
2443
2479
  type: "tool.started",
2444
2480
  operation: `${tool} started`,
@@ -2455,9 +2491,9 @@ async function parseCodexSessionFile(filePath, options) {
2455
2491
  refs: stringRefs({
2456
2492
  sourceId: callId
2457
2493
  })
2458
- }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options }));
2494
+ }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options });
2459
2495
  if (fileActivities.length > 0) {
2460
- events.push(withBackfillRefs(baseCodexEvent({
2496
+ pushEvent(baseCodexEvent({
2461
2497
  ts,
2462
2498
  type: eventTypeFromFileActivities(fileActivities),
2463
2499
  operation: `${tool} file activity`,
@@ -2473,7 +2509,7 @@ async function parseCodexSessionFile(filePath, options) {
2473
2509
  refs: stringRefs({
2474
2510
  sourceId: callId
2475
2511
  })
2476
- }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options }));
2512
+ }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options });
2477
2513
  }
2478
2514
  break;
2479
2515
  }
@@ -2485,7 +2521,7 @@ async function parseCodexSessionFile(filePath, options) {
2485
2521
  pendingToolCalls.delete(callId);
2486
2522
  }
2487
2523
  const durationMs = pending ? durationMsBetween(pending.startedAt, ts) : void 0;
2488
- events.push(withBackfillRefs(baseCodexEvent({
2524
+ pushEvent(baseCodexEvent({
2489
2525
  ts,
2490
2526
  type: "tool.completed",
2491
2527
  operation: pending ? `${pending.tool} completed` : "tool completed",
@@ -2503,13 +2539,13 @@ async function parseCodexSessionFile(filePath, options) {
2503
2539
  refs: stringRefs({
2504
2540
  sourceId: callId
2505
2541
  })
2506
- }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options }));
2542
+ }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options });
2507
2543
  break;
2508
2544
  }
2509
2545
  case "exec_command_end": {
2510
2546
  const durationMs = durationObjectToMs(objectField(payload, "duration"));
2511
2547
  const success = Number(payload.exit_code) === 0;
2512
- events.push(withBackfillRefs(baseCodexEvent({
2548
+ pushEvent(baseCodexEvent({
2513
2549
  ts,
2514
2550
  type: success ? "command.completed" : "command.failed",
2515
2551
  operation: "command completed",
@@ -2530,7 +2566,7 @@ async function parseCodexSessionFile(filePath, options) {
2530
2566
  sourceId: stringField(payload, "call_id"),
2531
2567
  commandHash: createStableHash(payload.command)
2532
2568
  })
2533
- }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options }));
2569
+ }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options });
2534
2570
  break;
2535
2571
  }
2536
2572
  case "patch_apply_end": {
@@ -2540,7 +2576,7 @@ async function parseCodexSessionFile(filePath, options) {
2540
2576
  cwd,
2541
2577
  displayFilePath2
2542
2578
  );
2543
- events.push(withBackfillRefs(baseCodexEvent({
2579
+ pushEvent(baseCodexEvent({
2544
2580
  ts,
2545
2581
  type: "file.changed",
2546
2582
  operation: "apply patch",
@@ -2557,15 +2593,19 @@ async function parseCodexSessionFile(filePath, options) {
2557
2593
  refs: stringRefs({
2558
2594
  sourceId: stringField(payload, "call_id")
2559
2595
  })
2560
- }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options }));
2596
+ }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options });
2561
2597
  break;
2562
2598
  }
2563
2599
  case "task_complete": {
2564
- events.push(withBackfillRefs(baseCodexEvent({
2600
+ const completedTurnId = stringField(payload, "turn_id") || currentTurnId;
2601
+ if (completedTurnId) {
2602
+ closedTurnIds.add(completedTurnId);
2603
+ }
2604
+ pushEvent(baseCodexEvent({
2565
2605
  ts,
2566
2606
  type: "turn.completed",
2567
2607
  sessionId,
2568
- turnId: stringField(payload, "turn_id") || currentTurnId,
2608
+ turnId: completedTurnId,
2569
2609
  cwd,
2570
2610
  project,
2571
2611
  model,
@@ -2573,15 +2613,15 @@ async function parseCodexSessionFile(filePath, options) {
2573
2613
  metrics: {
2574
2614
  durationMs: numberField(payload, "duration_ms")
2575
2615
  }
2576
- }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options }));
2616
+ }), { filePath, sourcePathHash, lineNumber, topType, payloadType, options });
2577
2617
  break;
2578
2618
  }
2579
2619
  }
2580
2620
  }
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,
2621
+ if (lastTurnIdForComplete && !closedTurnIds.has(lastTurnIdForComplete)) {
2622
+ closedTurnIds.add(lastTurnIdForComplete);
2623
+ pushEvent(baseCodexEvent({
2624
+ ts: turnCloseTs(lastTurnIdForComplete),
2585
2625
  type: "turn.completed",
2586
2626
  sessionId,
2587
2627
  turnId: lastTurnIdForComplete,
@@ -2589,7 +2629,7 @@ async function parseCodexSessionFile(filePath, options) {
2589
2629
  project,
2590
2630
  model,
2591
2631
  confidence: "derived"
2592
- }), { filePath, sourcePathHash, lineNumber: lines.length, topType: "event_msg", payloadType: "turn.completed", options }));
2632
+ }), { filePath, sourcePathHash, lineNumber: lines.length, topType: "event_msg", payloadType: "turn.completed", options });
2593
2633
  }
2594
2634
  return events.filter((event) => validateCanonicalEvent(event).valid);
2595
2635
  }
@@ -2628,10 +2668,11 @@ function headlessCodexUsage(raw) {
2628
2668
  if (input === 0 && cached === 0 && output === 0 && reasoning === 0 && total === 0) {
2629
2669
  return;
2630
2670
  }
2671
+ const billableOutput = output + reasoning;
2631
2672
  return {
2632
2673
  tokensInput: input || void 0,
2633
2674
  tokensCachedInput: cached || void 0,
2634
- tokensOutput: output || void 0,
2675
+ tokensOutput: billableOutput || void 0,
2635
2676
  tokensReasoningOutput: reasoning || void 0,
2636
2677
  tokensTotal: total,
2637
2678
  modelCalls: 1
@@ -2992,9 +3033,10 @@ async function parseGeminiSessionFile(filePath, options) {
2992
3033
  let lastTs = usages[0].ts;
2993
3034
  for (const [index, usage] of usages.entries()) {
2994
3035
  lastTs = usage.ts;
3036
+ const billableOutput = usage.tokensOutput + usage.tokensReasoning;
2995
3037
  const metrics = {
2996
3038
  tokensInput: usage.tokensInput || void 0,
2997
- tokensOutput: usage.tokensOutput || void 0,
3039
+ tokensOutput: billableOutput || void 0,
2998
3040
  tokensCachedInput: usage.tokensCacheRead || void 0,
2999
3041
  tokensCacheReadInput: usage.tokensCacheRead || void 0,
3000
3042
  tokensReasoningOutput: usage.tokensReasoning || void 0,
@@ -3439,13 +3481,14 @@ function opencodeUsageFromInfo(info) {
3439
3481
  const cacheRead = Math.max(0, numberField(cache, "read") || 0);
3440
3482
  const cacheWrite = Math.max(0, numberField(cache, "write") || 0);
3441
3483
  const totalInput = input + cacheRead + cacheWrite;
3442
- const total = Math.max(0, numberField(tokensObj, "total") || totalInput + output + reasoning);
3484
+ const billableOutput = output + reasoning;
3485
+ const total = Math.max(0, numberField(tokensObj, "total") || totalInput + billableOutput);
3443
3486
  if (total <= 0) {
3444
3487
  return void 0;
3445
3488
  }
3446
3489
  return {
3447
3490
  tokensInput: totalInput || void 0,
3448
- tokensOutput: output || void 0,
3491
+ tokensOutput: billableOutput || void 0,
3449
3492
  tokensReasoningOutput: reasoning || void 0,
3450
3493
  tokensCachedInput: cacheRead + cacheWrite || void 0,
3451
3494
  tokensCacheReadInput: cacheRead || void 0,
@@ -4110,9 +4153,11 @@ function buildSessionRollup(rollupKey, events) {
4110
4153
  const lastEventAt = ordered.at(-1)?.ts || startedAt;
4111
4154
  const timeBuckets = /* @__PURE__ */ new Map();
4112
4155
  const modelRollups = /* @__PURE__ */ new Map();
4156
+ const modelBuckets = /* @__PURE__ */ new Map();
4113
4157
  const toolRollups = /* @__PURE__ */ new Map();
4114
4158
  const fileRollups = /* @__PURE__ */ new Map();
4115
4159
  const turnRollups = /* @__PURE__ */ new Map();
4160
+ const turnEventTimes = /* @__PURE__ */ new Map();
4116
4161
  let promptCount = 0;
4117
4162
  let turnCount = 0;
4118
4163
  let toolCallCount = 0;
@@ -4130,6 +4175,8 @@ function buildSessionRollup(rollupKey, events) {
4130
4175
  const eventInputTokens = Math.max(0, event.metrics?.tokensInput || 0);
4131
4176
  const eventCachedInputTokens = Math.max(0, event.metrics?.tokensCachedInput || 0);
4132
4177
  const eventCacheCreationInputTokens = Math.max(0, event.metrics?.tokensCacheCreationInput || 0);
4178
+ const eventCacheCreation5mInputTokens = Math.max(0, event.metrics?.tokensCacheCreation5mInput || 0);
4179
+ const eventCacheCreation1hInputTokens = Math.max(0, event.metrics?.tokensCacheCreation1hInput || 0);
4133
4180
  const eventCacheReadInputTokens = Math.max(0, event.metrics?.tokensCacheReadInput || 0);
4134
4181
  const eventOutputTokens = Math.max(0, event.metrics?.tokensOutput || 0);
4135
4182
  const eventReasoningOutputTokens = Math.max(0, event.metrics?.tokensReasoningOutput || 0);
@@ -4210,6 +4257,12 @@ function buildSessionRollup(rollupKey, events) {
4210
4257
  turnRollup.outputTokens += eventOutputTokens;
4211
4258
  turnRollup.totalTokens += eventTotalTokens;
4212
4259
  turnRollups.set(event.turnId, turnRollup);
4260
+ const times = turnEventTimes.get(event.turnId);
4261
+ if (times) {
4262
+ times.push(event.ts);
4263
+ } else {
4264
+ turnEventTimes.set(event.turnId, [event.ts]);
4265
+ }
4213
4266
  }
4214
4267
  inputTokens += eventInputTokens;
4215
4268
  cachedInputTokens += eventCachedInputTokens;
@@ -4244,6 +4297,8 @@ function buildSessionRollup(rollupKey, events) {
4244
4297
  inputTokens: 0,
4245
4298
  cachedInputTokens: 0,
4246
4299
  cacheCreationInputTokens: 0,
4300
+ cacheCreation5mInputTokens: 0,
4301
+ cacheCreation1hInputTokens: 0,
4247
4302
  cacheReadInputTokens: 0,
4248
4303
  outputTokens: 0,
4249
4304
  reasoningOutputTokens: 0,
@@ -4254,12 +4309,40 @@ function buildSessionRollup(rollupKey, events) {
4254
4309
  modelRollup.inputTokens += eventInputTokens;
4255
4310
  modelRollup.cachedInputTokens += eventCachedInputTokens;
4256
4311
  modelRollup.cacheCreationInputTokens += eventCacheCreationInputTokens;
4312
+ modelRollup.cacheCreation5mInputTokens += eventCacheCreation5mInputTokens;
4313
+ modelRollup.cacheCreation1hInputTokens += eventCacheCreation1hInputTokens;
4257
4314
  modelRollup.cacheReadInputTokens += eventCacheReadInputTokens;
4258
4315
  modelRollup.outputTokens += eventOutputTokens;
4259
4316
  modelRollup.reasoningOutputTokens += eventReasoningOutputTokens;
4260
4317
  modelRollup.totalTokens += eventTotalTokens;
4261
4318
  modelRollup.estimatedCostUsd += eventCostUsd;
4262
4319
  modelRollups.set(modelKey, modelRollup);
4320
+ const modelBucketKey = `${bucketTs}\0${modelKey}`;
4321
+ const modelBucket = modelBuckets.get(modelBucketKey) || {
4322
+ ts: bucketTs,
4323
+ model: modelKey,
4324
+ callCount: 0,
4325
+ inputTokens: 0,
4326
+ cachedInputTokens: 0,
4327
+ cacheCreationInputTokens: 0,
4328
+ cacheCreation5mInputTokens: 0,
4329
+ cacheCreation1hInputTokens: 0,
4330
+ cacheReadInputTokens: 0,
4331
+ outputTokens: 0,
4332
+ reasoningOutputTokens: 0,
4333
+ totalTokens: 0
4334
+ };
4335
+ modelBucket.callCount += 1;
4336
+ modelBucket.inputTokens += eventInputTokens;
4337
+ modelBucket.cachedInputTokens += eventCachedInputTokens;
4338
+ modelBucket.cacheCreationInputTokens += eventCacheCreationInputTokens;
4339
+ modelBucket.cacheCreation5mInputTokens += eventCacheCreation5mInputTokens;
4340
+ modelBucket.cacheCreation1hInputTokens += eventCacheCreation1hInputTokens;
4341
+ modelBucket.cacheReadInputTokens += eventCacheReadInputTokens;
4342
+ modelBucket.outputTokens += eventOutputTokens;
4343
+ modelBucket.reasoningOutputTokens += eventReasoningOutputTokens;
4344
+ modelBucket.totalTokens += eventTotalTokens;
4345
+ modelBuckets.set(modelBucketKey, modelBucket);
4263
4346
  }
4264
4347
  if (event.type === "tool.started") {
4265
4348
  bucket.toolCalls += 1;
@@ -4327,6 +4410,13 @@ function buildSessionRollup(rollupKey, events) {
4327
4410
  const baseRollup = {
4328
4411
  rollupKey,
4329
4412
  payloadHash: "",
4413
+ // v3 schema: v2 (gap-clamped turn durations + billable-output token
4414
+ // convention) plus per-model cache-creation TTL split and modelBuckets. Set on
4415
+ // baseRollup (not after) so it participates in payloadHash: every historical
4416
+ // rollup's hash changes, and a re-backfill (uploaded with replace=true by
4417
+ // default) cleanly refreshes all data onto the new convention. This
4418
+ // full-refresh churn is intentional.
4419
+ schemaVersion: AGENT_ROLLUP_SCHEMA_VERSION,
4330
4420
  source: first.source,
4331
4421
  project,
4332
4422
  sessionId,
@@ -4350,11 +4440,16 @@ function buildSessionRollup(rollupKey, events) {
4350
4440
  durationMs: Math.max(0, Date.parse(lastEventAt) - Date.parse(startedAt)),
4351
4441
  timeBuckets: [...timeBuckets.values()].sort((a, b) => a.ts.localeCompare(b.ts)),
4352
4442
  modelRollups: [...modelRollups.values()].sort((a, b) => b.callCount - a.callCount || a.model.localeCompare(b.model)),
4443
+ // Sorted ts ascending, then model lexicographically (wire contract).
4444
+ modelBuckets: [...modelBuckets.values()].sort((a, b) => a.ts.localeCompare(b.ts) || a.model.localeCompare(b.model)),
4353
4445
  toolRollups: [...toolRollups.values()].sort((a, b) => b.callCount - a.callCount || a.tool.localeCompare(b.tool)),
4354
4446
  fileRollups: [...fileRollups.values()].sort((a, b) => b.writes - a.writes || b.reads - a.reads || a.displayPath.localeCompare(b.displayPath)),
4355
4447
  turnRollups: [...turnRollups.values()].map((rollup) => ({
4356
4448
  ...rollup,
4357
- durationMs: Math.max(0, Date.parse(rollup.lastEventAt) - Date.parse(rollup.startedAt))
4449
+ // Gap-clamped active duration: startedAt/lastEventAt/completedAt keep their
4450
+ // real timestamps, but durationMs only counts active intervals so lazy
4451
+ // completed_at timestamps and long in-turn silences don't inflate it.
4452
+ durationMs: gapClampedTurnDurationMs(rollup, turnEventTimes.get(rollup.turnId) || [])
4358
4453
  })).sort((a, b) => a.startedAt.localeCompare(b.startedAt))
4359
4454
  };
4360
4455
  return {
@@ -4362,6 +4457,22 @@ function buildSessionRollup(rollupKey, events) {
4362
4457
  payloadHash: createPayloadHash(baseRollup)
4363
4458
  };
4364
4459
  }
4460
+ function gapClampedTurnDurationMs(rollup, eventTimes) {
4461
+ const millis = [
4462
+ rollup.promptSubmittedAt,
4463
+ rollup.startedAt,
4464
+ ...eventTimes
4465
+ ].map((ts) => ts ? Date.parse(ts) : Number.NaN).filter((ms) => Number.isFinite(ms));
4466
+ const unique = [...new Set(millis)].sort((a, b) => a - b);
4467
+ if (unique.length < 2) {
4468
+ return 0;
4469
+ }
4470
+ let duration = 0;
4471
+ for (let i = 1; i < unique.length; i += 1) {
4472
+ duration += Math.min(unique[i] - unique[i - 1], TURN_GAP_CLAMP_MS);
4473
+ }
4474
+ return Math.max(0, duration);
4475
+ }
4365
4476
  function floorRollupBucket(ts) {
4366
4477
  const ms = Date.parse(ts);
4367
4478
  if (!Number.isFinite(ms)) {
@@ -4374,7 +4485,7 @@ function totalTokensFromEvent(event) {
4374
4485
  if (typeof explicit === "number" && explicit > 0) {
4375
4486
  return explicit;
4376
4487
  }
4377
- return Math.max(0, event.metrics?.tokensInput || 0) + Math.max(0, event.metrics?.tokensOutput || 0) + Math.max(0, event.metrics?.tokensReasoningOutput || 0);
4488
+ return Math.max(0, event.metrics?.tokensInput || 0) + Math.max(0, event.metrics?.tokensOutput || 0);
4378
4489
  }
4379
4490
  function lineStatsFromEvent(event) {
4380
4491
  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.7.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": {