codetime-cli 0.7.1 → 0.7.3

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 +186 -20
  2. package/package.json +1 -1
package/bin/codetime.mjs CHANGED
@@ -1030,7 +1030,7 @@ import { readFile as readFile2, stat as stat2 } from "node:fs/promises";
1030
1030
  import path2 from "node:path";
1031
1031
 
1032
1032
  // src/lib/constants.ts
1033
- var PACKAGE_VERSION = true ? "0.7.1" : "0.1.1";
1033
+ var PACKAGE_VERSION = true ? "0.7.3" : "0.1.1";
1034
1034
  var DEFAULT_API_URL = "https://codetime.dev";
1035
1035
  var DEFAULT_BACKFILL_BATCH_SIZE = 50;
1036
1036
  var DEFAULT_BACKFILL_BATCH_BYTES = 800 * 1024;
@@ -1046,6 +1046,10 @@ function stringField(object, key) {
1046
1046
  const value = object[key];
1047
1047
  return typeof value === "string" ? value : void 0;
1048
1048
  }
1049
+ function nonEmptyStringField(object, key) {
1050
+ const value = stringField(object, key);
1051
+ return value && value.trim().length > 0 ? value : void 0;
1052
+ }
1049
1053
  function numberField(object, key) {
1050
1054
  const value = object[key];
1051
1055
  return typeof value === "number" && Number.isFinite(value) ? value : void 0;
@@ -1291,16 +1295,17 @@ async function parseAmpSessionFile(filePath, options) {
1291
1295
  const messages = Array.isArray(raw.messages) ? raw.messages.filter(isPlainObject) : [];
1292
1296
  const ledger = objectField(raw, "usageLedger");
1293
1297
  const rawEvents = Array.isArray(ledger.events) ? ledger.events.filter(isPlainObject) : [];
1298
+ const state = new SessionParserState(filePath, options, (event) => baseAmpEvent(event));
1299
+ state.sessionId = sessionId;
1294
1300
  if (rawEvents.length === 0) {
1295
- return [];
1301
+ collectAmpMessageUsage(state, messages, sessionId, threadId, options);
1302
+ return state.events.filter((event) => matchesBackfillFilters(event, options));
1296
1303
  }
1297
1304
  const events = [...rawEvents].sort((a, b) => {
1298
1305
  const ta = timestampFrom(stringField(a, "timestamp")) || "";
1299
1306
  const tb = timestampFrom(stringField(b, "timestamp")) || "";
1300
1307
  return ta.localeCompare(tb);
1301
1308
  });
1302
- const state = new SessionParserState(filePath, options, (event) => baseAmpEvent(event));
1303
- state.sessionId = sessionId;
1304
1309
  const firstTs = timestampFrom(stringField(events[0], "timestamp")) || (/* @__PURE__ */ new Date()).toISOString();
1305
1310
  state.ensureSessionStarted(firstTs, 0);
1306
1311
  let lastTs = firstTs;
@@ -1315,7 +1320,7 @@ async function parseAmpSessionFile(filePath, options) {
1315
1320
  const cache = cacheTokensFor(messages, toMessageId);
1316
1321
  const cacheRead = cache.cacheReadInputTokens;
1317
1322
  const cacheWrite = cache.cacheCreationInputTokens;
1318
- const totalTokens = input + output + cacheRead + cacheWrite;
1323
+ const { billableOutput, totalTokens } = foldAmpTotal(input, output, cacheRead, cacheWrite, numberField(tokens, "total") || 0);
1319
1324
  if (totalTokens <= 0) {
1320
1325
  continue;
1321
1326
  }
@@ -1324,7 +1329,7 @@ async function parseAmpSessionFile(filePath, options) {
1324
1329
  const fromMessageId = numberField(ev, "fromMessageId");
1325
1330
  const metrics = {
1326
1331
  tokensInput: input + cacheRead + cacheWrite || void 0,
1327
- tokensOutput: output || void 0,
1332
+ tokensOutput: billableOutput || void 0,
1328
1333
  tokensCachedInput: cacheRead + cacheWrite || void 0,
1329
1334
  tokensCacheReadInput: cacheRead || void 0,
1330
1335
  tokensCacheCreationInput: cacheWrite || void 0,
@@ -1367,6 +1372,79 @@ async function parseAmpSessionFile(filePath, options) {
1367
1372
  );
1368
1373
  return state.events.filter((event) => matchesBackfillFilters(event, options));
1369
1374
  }
1375
+ function foldAmpTotal(input, output, cacheRead, cacheWrite, explicitTotal) {
1376
+ const partsSum = input + output + cacheRead + cacheWrite;
1377
+ const missing = Math.max(0, explicitTotal - partsSum);
1378
+ return { billableOutput: output + missing, totalTokens: partsSum + missing };
1379
+ }
1380
+ function collectAmpMessageUsage(state, messages, sessionId, threadId, _options) {
1381
+ const rows = [];
1382
+ for (const message of messages) {
1383
+ if (stringField(message, "role") !== "assistant") {
1384
+ continue;
1385
+ }
1386
+ const usage = objectField(message, "usage");
1387
+ if (Object.keys(usage).length === 0) {
1388
+ continue;
1389
+ }
1390
+ const ts = timestampFrom(stringField(usage, "timestamp")) || timestampFrom(stringField(message, "timestamp"));
1391
+ if (!ts) {
1392
+ continue;
1393
+ }
1394
+ const model = stringField(usage, "model") || stringField(message, "model") || void 0;
1395
+ const input = numberField(usage, "inputTokens") || 0;
1396
+ const output = numberField(usage, "outputTokens") || 0;
1397
+ const cacheWrite = numberField(usage, "cacheCreationInputTokens") || 0;
1398
+ const cacheRead = numberField(usage, "cacheReadInputTokens") || 0;
1399
+ const { billableOutput, totalTokens } = foldAmpTotal(input, output, cacheRead, cacheWrite, numberField(usage, "totalTokens") || 0);
1400
+ if (totalTokens <= 0) {
1401
+ continue;
1402
+ }
1403
+ rows.push({ ts, model, input, billableOutput, cacheRead, cacheWrite, totalTokens });
1404
+ }
1405
+ if (rows.length === 0) {
1406
+ return;
1407
+ }
1408
+ rows.sort((a, b) => a.ts.localeCompare(b.ts));
1409
+ state.ensureSessionStarted(rows[0].ts, 0);
1410
+ let lastTs = rows[0].ts;
1411
+ for (const [index, r] of rows.entries()) {
1412
+ lastTs = r.ts;
1413
+ state.push(
1414
+ baseAmpEvent({
1415
+ ts: r.ts,
1416
+ type: "model.usage",
1417
+ sessionId,
1418
+ model: r.model,
1419
+ confidence: "exact",
1420
+ metrics: {
1421
+ tokensInput: r.input + r.cacheRead + r.cacheWrite || void 0,
1422
+ tokensOutput: r.billableOutput || void 0,
1423
+ tokensCachedInput: r.cacheRead + r.cacheWrite || void 0,
1424
+ tokensCacheReadInput: r.cacheRead || void 0,
1425
+ tokensCacheCreationInput: r.cacheWrite || void 0,
1426
+ tokensTotal: r.totalTokens,
1427
+ modelCalls: 1
1428
+ },
1429
+ refs: stringRefs({ threadId })
1430
+ }),
1431
+ index + 1,
1432
+ "messages",
1433
+ "model.usage"
1434
+ );
1435
+ }
1436
+ state.push(
1437
+ baseAmpEvent({
1438
+ ts: lastTs,
1439
+ type: "session.ended",
1440
+ sessionId,
1441
+ confidence: "derived"
1442
+ }),
1443
+ rows.length,
1444
+ "messages",
1445
+ "session"
1446
+ );
1447
+ }
1370
1448
  function baseAmpEvent(event) {
1371
1449
  return {
1372
1450
  schemaVersion: AGENT_TIME_SCHEMA_VERSION,
@@ -1863,7 +1941,10 @@ async function parseClaudeCodeSessionFile(filePath, options) {
1863
1941
  if (topType !== "assistant") {
1864
1942
  continue;
1865
1943
  }
1866
- model = stringField(message, "model") || model;
1944
+ const parsedModel = stringField(message, "model");
1945
+ if (parsedModel && parsedModel !== "<synthetic>") {
1946
+ model = parsedModel;
1947
+ }
1867
1948
  const messageId = stringField(message, "id");
1868
1949
  const requestId = stringField(raw, "requestId");
1869
1950
  const usageKey = messageId ? `${messageId}:${requestId}` : null;
@@ -2316,6 +2397,9 @@ async function parseCodexSessionFile(filePath, options) {
2316
2397
  let turnIdAtLastUserMessage;
2317
2398
  let sessionMetaLocked = false;
2318
2399
  let lastTokenUsageKey;
2400
+ const replaySecond = detectSubagentReplaySecond(text, lines);
2401
+ let skipReplay = replaySecond !== void 0;
2402
+ let previousTotals = { input: 0, cached: 0, output: 0, reasoning: 0, total: 0 };
2319
2403
  const pendingToolCalls = /* @__PURE__ */ new Map();
2320
2404
  const turnLastEventAt = /* @__PURE__ */ new Map();
2321
2405
  const turnStartedAt = /* @__PURE__ */ new Map();
@@ -2473,8 +2557,19 @@ async function parseCodexSessionFile(filePath, options) {
2473
2557
  if (tierFromInfo) {
2474
2558
  serviceTier = tierFromInfo.toLowerCase();
2475
2559
  }
2476
- const usage = tokenUsageFromPayload(payload);
2560
+ const totalUsage = objectField(info, "total_token_usage");
2561
+ const hasTotal = Object.keys(totalUsage).length > 0;
2562
+ const usage = tokenUsageFromPayload(payload) ?? (hasTotal ? codexUsageDelta(totalUsage, previousTotals, info) : void 0);
2563
+ if (hasTotal) {
2564
+ previousTotals = readCodexCumulative(totalUsage);
2565
+ }
2477
2566
  if (usage) {
2567
+ if (skipReplay) {
2568
+ if (ts.slice(0, 19) === replaySecond) {
2569
+ break;
2570
+ }
2571
+ skipReplay = false;
2572
+ }
2478
2573
  const usageKey = [
2479
2574
  usage.tokensInput,
2480
2575
  usage.tokensCachedInput,
@@ -2727,7 +2822,9 @@ function headlessCodexUsage(raw) {
2727
2822
  const billableOutput = output + reasoning;
2728
2823
  return {
2729
2824
  tokensInput: input || void 0,
2730
- tokensCachedInput: cached || void 0,
2825
+ // Clamp cached to input so non-cached (input - cached) never goes negative
2826
+ // (ccusage cached.min(input) in the headless path too).
2827
+ tokensCachedInput: Math.min(cached, input) || void 0,
2731
2828
  tokensOutput: billableOutput || void 0,
2732
2829
  tokensReasoningOutput: reasoning || void 0,
2733
2830
  tokensTotal: total,
@@ -2755,21 +2852,85 @@ function headlessCodexTimestamp(raw) {
2755
2852
  }
2756
2853
  return void 0;
2757
2854
  }
2855
+ function detectSubagentReplaySecond(text, lines) {
2856
+ if (!text.includes("thread_spawn")) {
2857
+ return void 0;
2858
+ }
2859
+ let firstSecond;
2860
+ for (const line of lines) {
2861
+ const raw = parseJsonLine(line);
2862
+ if (!raw || stringField(raw, "type") !== "event_msg") {
2863
+ continue;
2864
+ }
2865
+ const payload = objectField(raw, "payload");
2866
+ if (stringField(payload, "type") !== "token_count") {
2867
+ continue;
2868
+ }
2869
+ const info = objectField(payload, "info");
2870
+ const hasUsage = Object.keys(objectField(info, "last_token_usage")).length > 0 || Object.keys(objectField(info, "total_token_usage")).length > 0;
2871
+ if (!hasUsage) {
2872
+ continue;
2873
+ }
2874
+ const ts = timestampFrom(raw.timestamp) || timestampFrom(payload.timestamp);
2875
+ if (!ts) {
2876
+ continue;
2877
+ }
2878
+ const second = ts.slice(0, 19);
2879
+ if (firstSecond === void 0) {
2880
+ firstSecond = second;
2881
+ } else {
2882
+ return firstSecond === second ? firstSecond : void 0;
2883
+ }
2884
+ }
2885
+ return void 0;
2886
+ }
2758
2887
  function tokenUsageFromPayload(payload) {
2759
2888
  const info = objectField(payload, "info");
2760
2889
  const usage = objectField(info, "last_token_usage");
2761
2890
  if (Object.keys(usage).length === 0) {
2762
2891
  return;
2763
2892
  }
2893
+ const input = numberField(usage, "input_tokens");
2894
+ const cached = numberField(usage, "cached_input_tokens");
2764
2895
  return {
2765
- tokensInput: numberField(usage, "input_tokens"),
2766
- tokensCachedInput: numberField(usage, "cached_input_tokens"),
2896
+ tokensInput: input,
2897
+ // Cached input can never exceed total input; clamp so the server's
2898
+ // non-cached = input - cached never goes negative (ccusage cached.min(input)).
2899
+ tokensCachedInput: cached === void 0 ? void 0 : Math.min(cached, input ?? 0),
2767
2900
  tokensOutput: numberField(usage, "output_tokens"),
2768
2901
  tokensReasoningOutput: numberField(usage, "reasoning_output_tokens"),
2769
2902
  tokensTotal: numberField(usage, "total_tokens"),
2770
2903
  modelContextWindow: numberField(info, "model_context_window")
2771
2904
  };
2772
2905
  }
2906
+ function readCodexCumulative(usage) {
2907
+ return {
2908
+ input: numberField(usage, "input_tokens") ?? 0,
2909
+ cached: numberField(usage, "cached_input_tokens") ?? 0,
2910
+ output: numberField(usage, "output_tokens") ?? 0,
2911
+ reasoning: numberField(usage, "reasoning_output_tokens") ?? 0,
2912
+ total: numberField(usage, "total_tokens") ?? 0
2913
+ };
2914
+ }
2915
+ function codexUsageDelta(totalUsage, previous, info) {
2916
+ const cur = readCodexCumulative(totalUsage);
2917
+ const input = Math.max(0, cur.input - previous.input);
2918
+ const cached = Math.max(0, cur.cached - previous.cached);
2919
+ const output = Math.max(0, cur.output - previous.output);
2920
+ const reasoning = Math.max(0, cur.reasoning - previous.reasoning);
2921
+ const total = Math.max(0, cur.total - previous.total);
2922
+ if (input === 0 && cached === 0 && output === 0 && reasoning === 0 && total === 0) {
2923
+ return;
2924
+ }
2925
+ return {
2926
+ tokensInput: input || void 0,
2927
+ tokensCachedInput: Math.min(cached, input) || void 0,
2928
+ tokensOutput: output || void 0,
2929
+ tokensReasoningOutput: reasoning || void 0,
2930
+ tokensTotal: total || void 0,
2931
+ modelContextWindow: numberField(info, "model_context_window")
2932
+ };
2933
+ }
2773
2934
  function toolNameFromPayload(payload, payloadType) {
2774
2935
  if (payloadType === "web_search_call") {
2775
2936
  return "web_search";
@@ -2996,11 +3157,10 @@ function readStats(record) {
2996
3157
  return void 0;
2997
3158
  }
2998
3159
  function parseJsonRecord(record, fileStem, fallbackTs) {
2999
- const sessionId = stringField(record, "sessionId") ?? stringField(record, "session_id") ?? fileStem;
3160
+ const sessionId = nonEmptyStringField(record, "sessionId") ?? nonEmptyStringField(record, "session_id") ?? fileStem;
3000
3161
  const sessionTs = timestampFrom(stringField(record, "startTime")) ?? timestampFrom(stringField(record, "lastUpdated")) ?? fallbackTs;
3001
- const messages = arrayField(record, "messages").filter(isPlainObject);
3002
- if (messages.length > 0) {
3003
- const usages = messages.filter((message) => stringField(message, "type") === "gemini").map((message) => parseDirectEvent(message, void 0, sessionTs)).filter((usage) => usage !== void 0);
3162
+ if (Array.isArray(record.messages)) {
3163
+ const usages = arrayField(record, "messages").filter(isPlainObject).filter((message) => stringField(message, "type") === "gemini").map((message) => parseDirectEvent(message, void 0, sessionTs)).filter((usage) => usage !== void 0);
3004
3164
  return { sessionId, usages };
3005
3165
  }
3006
3166
  if (stringField(record, "type") === "gemini") {
@@ -3023,7 +3183,7 @@ function parseJsonlRecords(lines, fileStem, fallbackTs) {
3023
3183
  if (!record) {
3024
3184
  continue;
3025
3185
  }
3026
- const sid = stringField(record, "sessionId") ?? stringField(record, "session_id");
3186
+ const sid = nonEmptyStringField(record, "sessionId") ?? nonEmptyStringField(record, "session_id");
3027
3187
  if (sid) {
3028
3188
  sessionId = sid;
3029
3189
  }
@@ -3036,7 +3196,7 @@ function parseJsonlRecords(lines, fileStem, fallbackTs) {
3036
3196
  if (!usage) {
3037
3197
  continue;
3038
3198
  }
3039
- const id = stringField(record, "id");
3199
+ const id = nonEmptyStringField(record, "id");
3040
3200
  if (id === void 0) {
3041
3201
  usages.push(usage);
3042
3202
  } else {
@@ -3317,7 +3477,9 @@ async function parseOpenCodeSessionFile(dbPath, options) {
3317
3477
  const createdTs = timeCreated;
3318
3478
  const tokens = opencodeUsageFromInfo(info);
3319
3479
  const cost = typeof info.cost === "number" && info.cost > 0 ? info.cost : void 0;
3480
+ let messageHadUsage = false;
3320
3481
  if (tokens) {
3482
+ messageHadUsage = true;
3321
3483
  const metrics = {
3322
3484
  tokensInput: tokens.tokensInput,
3323
3485
  tokensOutput: tokens.tokensOutput,
@@ -3438,7 +3600,7 @@ async function parseOpenCodeSessionFile(dbPath, options) {
3438
3600
  }
3439
3601
  }
3440
3602
  }
3441
- if (pd.type === "step-finish") {
3603
+ if (pd.type === "step-finish" && !messageHadUsage) {
3442
3604
  const stepTokens = opencodeUsageFromInfo(pd);
3443
3605
  const stepCost = typeof pd.cost === "number" && pd.cost > 0 ? pd.cost : void 0;
3444
3606
  if (stepTokens) {
@@ -3911,7 +4073,11 @@ function piUsageFromMessage(message) {
3911
4073
  const output = numberField(usage, "output") || 0;
3912
4074
  const cacheRead = numberField(usage, "cacheRead") || 0;
3913
4075
  const cacheWrite = numberField(usage, "cacheWrite") || 0;
3914
- const totalTokens = numberField(usage, "totalTokens") || input + output + cacheRead + cacheWrite;
4076
+ const explicitTotal = numberField(usage, "totalTokens") || 0;
4077
+ const partsSum = input + output + cacheRead + cacheWrite;
4078
+ const missing = Math.max(0, explicitTotal - partsSum);
4079
+ const billableOutput = output + missing;
4080
+ const totalTokens = partsSum + missing;
3915
4081
  if (totalTokens <= 0) {
3916
4082
  return void 0;
3917
4083
  }
@@ -3919,7 +4085,7 @@ function piUsageFromMessage(message) {
3919
4085
  const costUsd = numberField(cost, "total");
3920
4086
  return {
3921
4087
  tokensInput: input + cacheRead + cacheWrite || void 0,
3922
- tokensOutput: output || void 0,
4088
+ tokensOutput: billableOutput || void 0,
3923
4089
  tokensCachedInput: cacheRead + cacheWrite || void 0,
3924
4090
  tokensCacheReadInput: cacheRead || void 0,
3925
4091
  tokensCacheCreationInput: cacheWrite || void 0,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "codetime-cli",
3
3
  "type": "module",
4
- "version": "0.7.1",
4
+ "version": "0.7.3",
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": {