codetime-cli 0.7.2 → 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 +146 -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.2" : "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;
@@ -2318,6 +2399,7 @@ async function parseCodexSessionFile(filePath, options) {
2318
2399
  let lastTokenUsageKey;
2319
2400
  const replaySecond = detectSubagentReplaySecond(text, lines);
2320
2401
  let skipReplay = replaySecond !== void 0;
2402
+ let previousTotals = { input: 0, cached: 0, output: 0, reasoning: 0, total: 0 };
2321
2403
  const pendingToolCalls = /* @__PURE__ */ new Map();
2322
2404
  const turnLastEventAt = /* @__PURE__ */ new Map();
2323
2405
  const turnStartedAt = /* @__PURE__ */ new Map();
@@ -2475,7 +2557,12 @@ async function parseCodexSessionFile(filePath, options) {
2475
2557
  if (tierFromInfo) {
2476
2558
  serviceTier = tierFromInfo.toLowerCase();
2477
2559
  }
2478
- 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
+ }
2479
2566
  if (usage) {
2480
2567
  if (skipReplay) {
2481
2568
  if (ts.slice(0, 19) === replaySecond) {
@@ -2735,7 +2822,9 @@ function headlessCodexUsage(raw) {
2735
2822
  const billableOutput = output + reasoning;
2736
2823
  return {
2737
2824
  tokensInput: input || void 0,
2738
- 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,
2739
2828
  tokensOutput: billableOutput || void 0,
2740
2829
  tokensReasoningOutput: reasoning || void 0,
2741
2830
  tokensTotal: total,
@@ -2801,15 +2890,47 @@ function tokenUsageFromPayload(payload) {
2801
2890
  if (Object.keys(usage).length === 0) {
2802
2891
  return;
2803
2892
  }
2893
+ const input = numberField(usage, "input_tokens");
2894
+ const cached = numberField(usage, "cached_input_tokens");
2804
2895
  return {
2805
- tokensInput: numberField(usage, "input_tokens"),
2806
- 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),
2807
2900
  tokensOutput: numberField(usage, "output_tokens"),
2808
2901
  tokensReasoningOutput: numberField(usage, "reasoning_output_tokens"),
2809
2902
  tokensTotal: numberField(usage, "total_tokens"),
2810
2903
  modelContextWindow: numberField(info, "model_context_window")
2811
2904
  };
2812
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
+ }
2813
2934
  function toolNameFromPayload(payload, payloadType) {
2814
2935
  if (payloadType === "web_search_call") {
2815
2936
  return "web_search";
@@ -3036,11 +3157,10 @@ function readStats(record) {
3036
3157
  return void 0;
3037
3158
  }
3038
3159
  function parseJsonRecord(record, fileStem, fallbackTs) {
3039
- const sessionId = stringField(record, "sessionId") ?? stringField(record, "session_id") ?? fileStem;
3160
+ const sessionId = nonEmptyStringField(record, "sessionId") ?? nonEmptyStringField(record, "session_id") ?? fileStem;
3040
3161
  const sessionTs = timestampFrom(stringField(record, "startTime")) ?? timestampFrom(stringField(record, "lastUpdated")) ?? fallbackTs;
3041
- const messages = arrayField(record, "messages").filter(isPlainObject);
3042
- if (messages.length > 0) {
3043
- 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);
3044
3164
  return { sessionId, usages };
3045
3165
  }
3046
3166
  if (stringField(record, "type") === "gemini") {
@@ -3063,7 +3183,7 @@ function parseJsonlRecords(lines, fileStem, fallbackTs) {
3063
3183
  if (!record) {
3064
3184
  continue;
3065
3185
  }
3066
- const sid = stringField(record, "sessionId") ?? stringField(record, "session_id");
3186
+ const sid = nonEmptyStringField(record, "sessionId") ?? nonEmptyStringField(record, "session_id");
3067
3187
  if (sid) {
3068
3188
  sessionId = sid;
3069
3189
  }
@@ -3076,7 +3196,7 @@ function parseJsonlRecords(lines, fileStem, fallbackTs) {
3076
3196
  if (!usage) {
3077
3197
  continue;
3078
3198
  }
3079
- const id = stringField(record, "id");
3199
+ const id = nonEmptyStringField(record, "id");
3080
3200
  if (id === void 0) {
3081
3201
  usages.push(usage);
3082
3202
  } else {
@@ -3357,7 +3477,9 @@ async function parseOpenCodeSessionFile(dbPath, options) {
3357
3477
  const createdTs = timeCreated;
3358
3478
  const tokens = opencodeUsageFromInfo(info);
3359
3479
  const cost = typeof info.cost === "number" && info.cost > 0 ? info.cost : void 0;
3480
+ let messageHadUsage = false;
3360
3481
  if (tokens) {
3482
+ messageHadUsage = true;
3361
3483
  const metrics = {
3362
3484
  tokensInput: tokens.tokensInput,
3363
3485
  tokensOutput: tokens.tokensOutput,
@@ -3478,7 +3600,7 @@ async function parseOpenCodeSessionFile(dbPath, options) {
3478
3600
  }
3479
3601
  }
3480
3602
  }
3481
- if (pd.type === "step-finish") {
3603
+ if (pd.type === "step-finish" && !messageHadUsage) {
3482
3604
  const stepTokens = opencodeUsageFromInfo(pd);
3483
3605
  const stepCost = typeof pd.cost === "number" && pd.cost > 0 ? pd.cost : void 0;
3484
3606
  if (stepTokens) {
@@ -3951,7 +4073,11 @@ function piUsageFromMessage(message) {
3951
4073
  const output = numberField(usage, "output") || 0;
3952
4074
  const cacheRead = numberField(usage, "cacheRead") || 0;
3953
4075
  const cacheWrite = numberField(usage, "cacheWrite") || 0;
3954
- 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;
3955
4081
  if (totalTokens <= 0) {
3956
4082
  return void 0;
3957
4083
  }
@@ -3959,7 +4085,7 @@ function piUsageFromMessage(message) {
3959
4085
  const costUsd = numberField(cost, "total");
3960
4086
  return {
3961
4087
  tokensInput: input + cacheRead + cacheWrite || void 0,
3962
- tokensOutput: output || void 0,
4088
+ tokensOutput: billableOutput || void 0,
3963
4089
  tokensCachedInput: cacheRead + cacheWrite || void 0,
3964
4090
  tokensCacheReadInput: cacheRead || void 0,
3965
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.2",
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": {