aicp-tracker 1.3.2 → 1.3.4

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": "aicp-tracker",
3
- "version": "1.3.2",
3
+ "version": "1.3.4",
4
4
  "description": "AI Code Pulse — Claude Code usage tracker for JIRA cost attribution",
5
5
  "main": "src/daemon.js",
6
6
  "bin": {
package/src/log-parser.js CHANGED
@@ -12,6 +12,17 @@ function extractTasksFromBranch(branch) {
12
12
  }
13
13
 
14
14
  function sumUsage(entries) {
15
+ // Claude Code writes multiple JSONL lines per message.id (streaming snapshots).
16
+ // All snapshots for the same message.id have identical token counts, so we count
17
+ // only the LAST occurrence of each message.id to avoid double-counting.
18
+ // The last occurrence also has the most complete content (all tool calls present).
19
+ const lastIdxByMsgId = new Map();
20
+ for (let i = 0; i < entries.length; i++) {
21
+ const msgId = entries[i].message?.id;
22
+ if (msgId && entries[i].message?.usage) lastIdxByMsgId.set(msgId, i);
23
+ }
24
+ const countIdxSet = new Set(lastIdxByMsgId.values());
25
+
15
26
  let input = 0, cc = 0, cr = 0, out = 0;
16
27
  let ephUser = 0, ephAsst = 0, ephTool = 0;
17
28
  let webSearch = 0, webFetch = 0;
@@ -19,17 +30,21 @@ function sumUsage(entries) {
19
30
  const models = new Set();
20
31
  const uuids = [];
21
32
 
22
- for (const e of entries) {
23
- const u = e.message?.usage || {};
33
+ for (let i = 0; i < entries.length; i++) {
34
+ const e = entries[i];
35
+ const u = e.message?.usage;
36
+ if (!u) continue;
37
+ // Skip all but the last snapshot for each message.id
38
+ const msgId = e.message?.id;
39
+ if (msgId && !countIdxSet.has(i)) continue;
40
+
24
41
  input += u.input_tokens || 0;
25
42
  cc += u.cache_creation_input_tokens || 0;
26
43
  cr += u.cache_read_input_tokens || 0;
27
44
  out += u.output_tokens || 0;
28
- // Ephemeral tokens may be at top level (older format) or in cache_creation object (newer format)
29
45
  ephUser += u.ephemeral_user_input_tokens || u.cache_creation?.ephemeral_user_input_tokens || 0;
30
46
  ephAsst += u.ephemeral_assistant_input_tokens || u.cache_creation?.ephemeral_assistant_input_tokens || 0;
31
47
  ephTool += u.ephemeral_tool_input_tokens || u.cache_creation?.ephemeral_tool_input_tokens || 0;
32
- // Also sum ephemeral cache creation tokens (5m and 1h variants)
33
48
  ephUser += u.cache_creation?.ephemeral_5m_input_tokens || 0;
34
49
  ephUser += u.cache_creation?.ephemeral_1h_input_tokens || 0;
35
50
  if (!serviceTier && u.service_tier) serviceTier = u.service_tier;
@@ -43,7 +58,6 @@ function sumUsage(entries) {
43
58
  if (block.name === 'web_fetch' || block.name === 'WebFetch') webFetch++;
44
59
  }
45
60
  }
46
- // Also check server_tool_use object for web requests
47
61
  const srvTool = u.server_tool_use;
48
62
  if (srvTool) {
49
63
  webSearch += srvTool.web_search_requests || 0;
@@ -55,6 +69,59 @@ function sumUsage(entries) {
55
69
  return { input, cc, cr, out, ephUser, ephAsst, ephTool, webSearch, webFetch, serviceTier, speed, models, uuids };
56
70
  }
57
71
 
72
+ // Merge two usage objects (both have models as Set, uuids as array)
73
+ function mergeUsages(a, b) {
74
+ return {
75
+ input: a.input + b.input,
76
+ cc: a.cc + b.cc,
77
+ cr: a.cr + b.cr,
78
+ out: a.out + b.out,
79
+ ephUser: a.ephUser + b.ephUser,
80
+ ephAsst: a.ephAsst + b.ephAsst,
81
+ ephTool: a.ephTool + b.ephTool,
82
+ webSearch: a.webSearch + b.webSearch,
83
+ webFetch: a.webFetch + b.webFetch,
84
+ serviceTier: a.serviceTier || b.serviceTier,
85
+ speed: a.speed || b.speed,
86
+ models: new Set([...a.models, ...b.models]),
87
+ uuids: [...a.uuids, ...b.uuids],
88
+ };
89
+ }
90
+
91
+ // Convert usage (with Set) to JSON-serializable form for state storage
92
+ function usageToPlain(u) {
93
+ return {
94
+ input: u.input, cc: u.cc, cr: u.cr, out: u.out,
95
+ ephUser: u.ephUser, ephAsst: u.ephAsst, ephTool: u.ephTool,
96
+ webSearch: u.webSearch, webFetch: u.webFetch,
97
+ serviceTier: u.serviceTier, speed: u.speed,
98
+ models: [...u.models],
99
+ uuids: u.uuids,
100
+ };
101
+ }
102
+
103
+ // Restore usage from plain state (convert models array back to Set)
104
+ function usageFromPlain(p) {
105
+ return {
106
+ input: p.input || 0, cc: p.cc || 0, cr: p.cr || 0, out: p.out || 0,
107
+ ephUser: p.ephUser || 0, ephAsst: p.ephAsst || 0, ephTool: p.ephTool || 0,
108
+ webSearch: p.webSearch || 0, webFetch: p.webFetch || 0,
109
+ serviceTier: p.serviceTier || null, speed: p.speed || null,
110
+ models: new Set(p.models || []),
111
+ uuids: p.uuids || [],
112
+ };
113
+ }
114
+
115
+ // Extract the minimal ref fields needed by makeRecord from any JSONL entry
116
+ function refFromEntry(entry) {
117
+ return {
118
+ sessionId: entry.sessionId || null,
119
+ timestamp: entry.timestamp || null,
120
+ gitBranch: entry.gitBranch || null,
121
+ parentUuid: entry.parentUuid || null,
122
+ };
123
+ }
124
+
58
125
  function makeRecord(ref, rootUuid, usage, filePaths) {
59
126
  const { input, cc, cr, out, ephUser, ephAsst, ephTool, webSearch, webFetch, serviceTier, speed, models, uuids } = usage;
60
127
  return {
@@ -107,13 +174,69 @@ function extractAllFilePaths(entry) {
107
174
  return paths;
108
175
  }
109
176
 
177
+ function extractGroupFilePaths(allEntries) {
178
+ const paths = [];
179
+ for (const entry of allEntries) {
180
+ for (const fp of extractAllFilePaths(entry)) paths.push(fp);
181
+ }
182
+ return [...new Set(paths)];
183
+ }
184
+
185
+ // Emit a closed group with per-file splitting (used for groups fully contained in one tick)
186
+ function emitGroup(ref, baseUuid, turns, allEntries) {
187
+ const allFiles = new Set();
188
+ for (const entry of allEntries) {
189
+ for (const fp of extractAllFilePaths(entry)) allFiles.add(fp);
190
+ }
191
+
192
+ if (!allFiles.size) {
193
+ return [makeRecord(ref, baseUuid, sumUsage(turns), [])];
194
+ }
195
+
196
+ const turnFiles = turns.map(extractFilePath);
197
+ const fileGroups = new Map();
198
+ const firstAnchorIdx = turnFiles.findIndex(f => f !== null);
199
+ let lastFile = turnFiles[firstAnchorIdx];
200
+
201
+ for (let i = 0; i < turns.length; i++) {
202
+ const file = turnFiles[i] !== null ? turnFiles[i]
203
+ : (i < firstAnchorIdx ? turnFiles[firstAnchorIdx] : lastFile);
204
+ if (!fileGroups.has(file)) fileGroups.set(file, []);
205
+ fileGroups.get(file).push(turns[i]);
206
+ if (turnFiles[i] !== null) lastFile = turnFiles[i];
207
+ }
208
+
209
+ const records = [];
210
+ let idx = 0;
211
+ for (const [fp, fileTurns] of fileGroups) {
212
+ const uuid = idx === 0 ? baseUuid : `${baseUuid}:${idx}`;
213
+ const filesForRecord = [...new Set([fp, ...fileTurns.flatMap(extractAllFilePaths)])];
214
+ records.push(makeRecord(ref, uuid, sumUsage(fileTurns), filesForRecord));
215
+ idx++;
216
+ }
217
+ return records;
218
+ }
219
+
110
220
  function parseNewLines(filePath) {
111
221
  const carryOverPromptId = state.getLastPromptId(filePath);
222
+ const pendingGroup = state.getPendingGroup(filePath);
112
223
 
113
224
  const offset = state.getOffset(filePath);
114
225
  let stat;
115
226
  try { stat = fs.statSync(filePath); } catch { return []; }
116
- if (stat.size <= offset) return [];
227
+
228
+ // No new bytes — flush any pending group now (the session is done or paused long enough)
229
+ if (stat.size <= offset) {
230
+ if (pendingGroup) {
231
+ const usage = usageFromPlain(pendingGroup.usage);
232
+ if (usage.uuids.length > 0) {
233
+ state.clearPendingGroup(filePath);
234
+ return [makeRecord(pendingGroup.ref, pendingGroup.promptId, usage, pendingGroup.filePaths || [])];
235
+ }
236
+ state.clearPendingGroup(filePath);
237
+ }
238
+ return [];
239
+ }
117
240
 
118
241
  const fd = fs.openSync(filePath, 'r');
119
242
  const buf = Buffer.alloc(stat.size - offset);
@@ -121,13 +244,10 @@ function parseNewLines(filePath) {
121
244
  fs.closeSync(fd);
122
245
  state.setOffset(filePath, offset + read);
123
246
 
124
- // Parse all entries. Only deduplicate by message.id for entries that carry
125
- // usage data those are the only ones where duplicate lines are truly
126
- // identical and would cause token double-counting. Entries without usage
127
- // (pure tool-call lines, tool-result lines, etc.) are all kept so that no
128
- // file-path information is accidentally discarded.
247
+ // Keep ALL entries deduplication by message.id happens inside sumUsage.
248
+ // We must retain every line here because later snapshots of the same message.id
249
+ // contain the Edit/Write tool calls that earlier snapshots lack.
129
250
  const entries = [];
130
- const seenUsageMsgIds = new Set();
131
251
 
132
252
  for (const raw of buf.slice(0, read).toString('utf8').split('\n')) {
133
253
  const line = raw.trim();
@@ -135,34 +255,19 @@ function parseNewLines(filePath) {
135
255
  let entry;
136
256
  try { entry = JSON.parse(line); } catch { continue; }
137
257
  if (!entry.uuid) continue;
138
-
139
- const msgId = entry.message?.id;
140
- if (msgId && entry.message?.usage) {
141
- if (seenUsageMsgIds.has(msgId)) continue;
142
- seenUsageMsgIds.add(msgId);
143
- }
144
258
  entries.push(entry);
145
259
  }
146
260
 
147
261
  // ── Sequential grouping ───────────────────────────────────────────────────
148
262
  // Any entry that carries a promptId opens (or re-opens) a group.
149
- // Every subsequent entry regardless of type or role belongs to that
150
- // group until the next promptId appears. This is the approach the user
151
- // requested: "start from the record that has a promptId and go through all
152
- // records down, counting them in, even if they don't have a promptId, until
153
- // you find a new promptId."
154
- //
155
- // carryOverPromptId: the last promptId seen in a previous batch so that
156
- // assistant entries at the very start of this batch (before any new
157
- // promptId) are still collected rather than dropped.
158
-
159
- let currentPromptId = carryOverPromptId;
160
- let latestPromptId = carryOverPromptId;
161
- let carryOverSeenInBatch = false;
263
+ // Every subsequent entry belongs to that group until the next promptId appears.
264
+
265
+ let currentPromptId = carryOverPromptId;
266
+ let latestPromptId = carryOverPromptId;
162
267
 
163
268
  const groupOrder = [];
164
- const groupAllEntries = new Map(); // promptId → every non-prompt entry in the turn
165
- const groupRef = new Map(); // promptId → the entry that opened the group
269
+ const groupAllEntries = new Map();
270
+ const groupRef = new Map();
166
271
 
167
272
  function ensureGroup(pid, refEntry) {
168
273
  if (!groupAllEntries.has(pid)) {
@@ -174,16 +279,13 @@ function parseNewLines(filePath) {
174
279
 
175
280
  for (const entry of entries) {
176
281
  if (entry.promptId) {
177
- // This entry opens a new group (could be any role/type)
178
- if (entry.promptId === carryOverPromptId) carryOverSeenInBatch = true;
179
282
  currentPromptId = entry.promptId;
180
283
  latestPromptId = entry.promptId;
181
- ensureGroup(currentPromptId, entry);
182
- } else if (currentPromptId) {
183
- // No promptId → belongs to whatever group is currently open
184
- ensureGroup(currentPromptId, entry);
185
- groupAllEntries.get(currentPromptId).push(entry);
284
+ } else if (!currentPromptId) {
285
+ continue;
186
286
  }
287
+ ensureGroup(currentPromptId, entry);
288
+ groupAllEntries.get(currentPromptId).push(entry);
187
289
  }
188
290
 
189
291
  if (latestPromptId) state.setLastPromptId(filePath, latestPromptId);
@@ -191,54 +293,56 @@ function parseNewLines(filePath) {
191
293
  // ── Emit records ──────────────────────────────────────────────────────────
192
294
  const records = [];
193
295
 
194
- for (const promptId of groupOrder) {
195
- const allEntries = groupAllEntries.get(promptId);
196
- // Only entries that actually carry usage data contribute to token counts
197
- const turns = allEntries.filter(e => e.message?.usage);
198
- if (!turns.length) continue;
199
-
200
- const ref = groupRef.get(promptId);
201
-
202
- // Suffix :carry when this is a pure continuation from a previous batch so
203
- // both records land in the DB rather than one being deduplicated away.
204
- const isCarryOver = promptId === carryOverPromptId && !carryOverSeenInBatch;
205
- const baseUuid = isCarryOver ? `${promptId}:carry` : promptId;
206
-
207
- // Collect file paths from EVERY entry in the group (tool-use lines,
208
- // tool-result lines, etc.) not just the ones that carry usage.
209
- const allFiles = new Set();
210
- for (const entry of allEntries) {
211
- for (const fp of extractAllFilePaths(entry)) {
212
- allFiles.add(fp);
213
- }
214
- }
215
-
216
- if (!allFiles.size) {
217
- records.push(makeRecord(ref, baseUuid, sumUsage(turns), []));
218
- continue;
296
+ // If a pending group exists but its promptId doesn't appear in this batch at all,
297
+ // it has been superseded by new prompts — emit it now before processing new groups.
298
+ if (pendingGroup && groupOrder.length > 0 && !groupOrder.includes(pendingGroup.promptId)) {
299
+ const usage = usageFromPlain(pendingGroup.usage);
300
+ if (usage.uuids.length > 0) {
301
+ records.push(makeRecord(pendingGroup.ref, pendingGroup.promptId, usage, pendingGroup.filePaths || []));
219
302
  }
303
+ state.clearPendingGroup(filePath);
304
+ }
220
305
 
221
- // Attribute orphan turns (no file anchor) to the nearest anchor:
222
- // leading orphans → first anchor; trailing orphans → last anchor seen.
223
- const turnFiles = turns.map(extractFilePath);
224
- const fileGroups = new Map();
225
- const firstAnchorIdx = turnFiles.findIndex(f => f !== null);
226
- let lastFile = turnFiles[firstAnchorIdx];
227
-
228
- for (let i = 0; i < turns.length; i++) {
229
- const file = turnFiles[i] !== null ? turnFiles[i]
230
- : (i < firstAnchorIdx ? turnFiles[firstAnchorIdx] : lastFile);
231
- if (!fileGroups.has(file)) fileGroups.set(file, []);
232
- fileGroups.get(file).push(turns[i]);
233
- if (turnFiles[i] !== null) lastFile = turnFiles[i];
306
+ for (let gi = 0; gi < groupOrder.length; gi++) {
307
+ const promptId = groupOrder[gi];
308
+ const isLast = gi === groupOrder.length - 1;
309
+ const allEntries = groupAllEntries.get(promptId);
310
+ const turns = allEntries.filter(e => e.message?.usage);
311
+ const hasPending = pendingGroup && pendingGroup.promptId === promptId;
312
+
313
+ let effectiveUsage, effectiveFilePaths, effectiveRef;
314
+
315
+ if (hasPending) {
316
+ // Merge accumulated pending data with new entries from this tick
317
+ const pendingUsage = usageFromPlain(pendingGroup.usage);
318
+ effectiveUsage = turns.length > 0 ? mergeUsages(pendingUsage, sumUsage(turns)) : pendingUsage;
319
+ effectiveFilePaths = [...new Set([...(pendingGroup.filePaths || []), ...extractGroupFilePaths(allEntries)])];
320
+ effectiveRef = pendingGroup.ref;
321
+ } else {
322
+ if (!turns.length) continue;
323
+ effectiveUsage = sumUsage(turns);
324
+ effectiveFilePaths = extractGroupFilePaths(allEntries);
325
+ effectiveRef = refFromEntry(groupRef.get(promptId));
234
326
  }
235
327
 
236
- let idx = 0;
237
- for (const [fp, fileTurns] of fileGroups) {
238
- const uuid = idx === 0 ? baseUuid : `${baseUuid}:${idx}`;
239
- const filesForRecord = [...new Set([fp, ...fileTurns.flatMap(extractAllFilePaths)])];
240
- records.push(makeRecord(ref, uuid, sumUsage(fileTurns), filesForRecord));
241
- idx++;
328
+ if (isLast) {
329
+ // Group may still grow in the next tick — save as pending, don't emit yet
330
+ state.setPendingGroup(filePath, {
331
+ promptId,
332
+ ref: effectiveRef,
333
+ usage: usageToPlain(effectiveUsage),
334
+ filePaths: effectiveFilePaths,
335
+ });
336
+ } else {
337
+ // Group is closed (a subsequent promptId appeared) — emit now
338
+ if (hasPending) state.clearPendingGroup(filePath);
339
+ if (hasPending) {
340
+ // Cross-tick group: emit as one record (tokens already merged, no per-file split)
341
+ records.push(makeRecord(effectiveRef, promptId, effectiveUsage, effectiveFilePaths));
342
+ } else {
343
+ // Single-tick group: use per-file splitting as before
344
+ records.push(...emitGroup(effectiveRef, promptId, turns, allEntries));
345
+ }
242
346
  }
243
347
  }
244
348
 
package/src/state.js CHANGED
@@ -44,4 +44,25 @@ function setLastPromptId(filePath, promptId) {
44
44
  save();
45
45
  }
46
46
 
47
- module.exports = { getOffset, setOffset, getLastPromptId, setLastPromptId };
47
+ // Pending group: the last open group that has not yet been closed by a subsequent promptId.
48
+ // Stored until the group is definitively closed (different promptId appears, or file stops growing).
49
+ function getPendingGroup(filePath) {
50
+ return load().pendingGroups?.[filePath] || null;
51
+ }
52
+
53
+ function setPendingGroup(filePath, data) {
54
+ const s = load();
55
+ if (!s.pendingGroups) s.pendingGroups = {};
56
+ s.pendingGroups[filePath] = data;
57
+ save();
58
+ }
59
+
60
+ function clearPendingGroup(filePath) {
61
+ const s = load();
62
+ if (s.pendingGroups?.[filePath]) {
63
+ delete s.pendingGroups[filePath];
64
+ save();
65
+ }
66
+ }
67
+
68
+ module.exports = { getOffset, setOffset, getLastPromptId, setLastPromptId, getPendingGroup, setPendingGroup, clearPendingGroup };