aicp-tracker 1.3.2 → 1.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aicp-tracker",
3
- "version": "1.3.2",
3
+ "version": "1.3.3",
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
@@ -25,11 +25,9 @@ function sumUsage(entries) {
25
25
  cc += u.cache_creation_input_tokens || 0;
26
26
  cr += u.cache_read_input_tokens || 0;
27
27
  out += u.output_tokens || 0;
28
- // Ephemeral tokens may be at top level (older format) or in cache_creation object (newer format)
29
28
  ephUser += u.ephemeral_user_input_tokens || u.cache_creation?.ephemeral_user_input_tokens || 0;
30
29
  ephAsst += u.ephemeral_assistant_input_tokens || u.cache_creation?.ephemeral_assistant_input_tokens || 0;
31
30
  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
31
  ephUser += u.cache_creation?.ephemeral_5m_input_tokens || 0;
34
32
  ephUser += u.cache_creation?.ephemeral_1h_input_tokens || 0;
35
33
  if (!serviceTier && u.service_tier) serviceTier = u.service_tier;
@@ -43,7 +41,6 @@ function sumUsage(entries) {
43
41
  if (block.name === 'web_fetch' || block.name === 'WebFetch') webFetch++;
44
42
  }
45
43
  }
46
- // Also check server_tool_use object for web requests
47
44
  const srvTool = u.server_tool_use;
48
45
  if (srvTool) {
49
46
  webSearch += srvTool.web_search_requests || 0;
@@ -55,6 +52,59 @@ function sumUsage(entries) {
55
52
  return { input, cc, cr, out, ephUser, ephAsst, ephTool, webSearch, webFetch, serviceTier, speed, models, uuids };
56
53
  }
57
54
 
55
+ // Merge two usage objects (both have models as Set, uuids as array)
56
+ function mergeUsages(a, b) {
57
+ return {
58
+ input: a.input + b.input,
59
+ cc: a.cc + b.cc,
60
+ cr: a.cr + b.cr,
61
+ out: a.out + b.out,
62
+ ephUser: a.ephUser + b.ephUser,
63
+ ephAsst: a.ephAsst + b.ephAsst,
64
+ ephTool: a.ephTool + b.ephTool,
65
+ webSearch: a.webSearch + b.webSearch,
66
+ webFetch: a.webFetch + b.webFetch,
67
+ serviceTier: a.serviceTier || b.serviceTier,
68
+ speed: a.speed || b.speed,
69
+ models: new Set([...a.models, ...b.models]),
70
+ uuids: [...a.uuids, ...b.uuids],
71
+ };
72
+ }
73
+
74
+ // Convert usage (with Set) to JSON-serializable form for state storage
75
+ function usageToPlain(u) {
76
+ return {
77
+ input: u.input, cc: u.cc, cr: u.cr, out: u.out,
78
+ ephUser: u.ephUser, ephAsst: u.ephAsst, ephTool: u.ephTool,
79
+ webSearch: u.webSearch, webFetch: u.webFetch,
80
+ serviceTier: u.serviceTier, speed: u.speed,
81
+ models: [...u.models],
82
+ uuids: u.uuids,
83
+ };
84
+ }
85
+
86
+ // Restore usage from plain state (convert models array back to Set)
87
+ function usageFromPlain(p) {
88
+ return {
89
+ input: p.input || 0, cc: p.cc || 0, cr: p.cr || 0, out: p.out || 0,
90
+ ephUser: p.ephUser || 0, ephAsst: p.ephAsst || 0, ephTool: p.ephTool || 0,
91
+ webSearch: p.webSearch || 0, webFetch: p.webFetch || 0,
92
+ serviceTier: p.serviceTier || null, speed: p.speed || null,
93
+ models: new Set(p.models || []),
94
+ uuids: p.uuids || [],
95
+ };
96
+ }
97
+
98
+ // Extract the minimal ref fields needed by makeRecord from any JSONL entry
99
+ function refFromEntry(entry) {
100
+ return {
101
+ sessionId: entry.sessionId || null,
102
+ timestamp: entry.timestamp || null,
103
+ gitBranch: entry.gitBranch || null,
104
+ parentUuid: entry.parentUuid || null,
105
+ };
106
+ }
107
+
58
108
  function makeRecord(ref, rootUuid, usage, filePaths) {
59
109
  const { input, cc, cr, out, ephUser, ephAsst, ephTool, webSearch, webFetch, serviceTier, speed, models, uuids } = usage;
60
110
  return {
@@ -107,13 +157,69 @@ function extractAllFilePaths(entry) {
107
157
  return paths;
108
158
  }
109
159
 
160
+ function extractGroupFilePaths(allEntries) {
161
+ const paths = [];
162
+ for (const entry of allEntries) {
163
+ for (const fp of extractAllFilePaths(entry)) paths.push(fp);
164
+ }
165
+ return [...new Set(paths)];
166
+ }
167
+
168
+ // Emit a closed group with per-file splitting (used for groups fully contained in one tick)
169
+ function emitGroup(ref, baseUuid, turns, allEntries) {
170
+ const allFiles = new Set();
171
+ for (const entry of allEntries) {
172
+ for (const fp of extractAllFilePaths(entry)) allFiles.add(fp);
173
+ }
174
+
175
+ if (!allFiles.size) {
176
+ return [makeRecord(ref, baseUuid, sumUsage(turns), [])];
177
+ }
178
+
179
+ const turnFiles = turns.map(extractFilePath);
180
+ const fileGroups = new Map();
181
+ const firstAnchorIdx = turnFiles.findIndex(f => f !== null);
182
+ let lastFile = turnFiles[firstAnchorIdx];
183
+
184
+ for (let i = 0; i < turns.length; i++) {
185
+ const file = turnFiles[i] !== null ? turnFiles[i]
186
+ : (i < firstAnchorIdx ? turnFiles[firstAnchorIdx] : lastFile);
187
+ if (!fileGroups.has(file)) fileGroups.set(file, []);
188
+ fileGroups.get(file).push(turns[i]);
189
+ if (turnFiles[i] !== null) lastFile = turnFiles[i];
190
+ }
191
+
192
+ const records = [];
193
+ let idx = 0;
194
+ for (const [fp, fileTurns] of fileGroups) {
195
+ const uuid = idx === 0 ? baseUuid : `${baseUuid}:${idx}`;
196
+ const filesForRecord = [...new Set([fp, ...fileTurns.flatMap(extractAllFilePaths)])];
197
+ records.push(makeRecord(ref, uuid, sumUsage(fileTurns), filesForRecord));
198
+ idx++;
199
+ }
200
+ return records;
201
+ }
202
+
110
203
  function parseNewLines(filePath) {
111
204
  const carryOverPromptId = state.getLastPromptId(filePath);
205
+ const pendingGroup = state.getPendingGroup(filePath);
112
206
 
113
207
  const offset = state.getOffset(filePath);
114
208
  let stat;
115
209
  try { stat = fs.statSync(filePath); } catch { return []; }
116
- if (stat.size <= offset) return [];
210
+
211
+ // No new bytes — flush any pending group now (the session is done or paused long enough)
212
+ if (stat.size <= offset) {
213
+ if (pendingGroup) {
214
+ const usage = usageFromPlain(pendingGroup.usage);
215
+ if (usage.uuids.length > 0) {
216
+ state.clearPendingGroup(filePath);
217
+ return [makeRecord(pendingGroup.ref, pendingGroup.promptId, usage, pendingGroup.filePaths || [])];
218
+ }
219
+ state.clearPendingGroup(filePath);
220
+ }
221
+ return [];
222
+ }
117
223
 
118
224
  const fd = fs.openSync(filePath, 'r');
119
225
  const buf = Buffer.alloc(stat.size - offset);
@@ -121,11 +227,8 @@ function parseNewLines(filePath) {
121
227
  fs.closeSync(fd);
122
228
  state.setOffset(filePath, offset + read);
123
229
 
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.
230
+ // Parse entries; deduplicate by message.id only for entries that carry usage data
231
+ // (non-usage entries like tool-call lines must all be kept to avoid losing file paths)
129
232
  const entries = [];
130
233
  const seenUsageMsgIds = new Set();
131
234
 
@@ -146,23 +249,14 @@ function parseNewLines(filePath) {
146
249
 
147
250
  // ── Sequential grouping ───────────────────────────────────────────────────
148
251
  // 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;
252
+ // Every subsequent entry belongs to that group until the next promptId appears.
253
+
254
+ let currentPromptId = carryOverPromptId;
255
+ let latestPromptId = carryOverPromptId;
162
256
 
163
257
  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
258
+ const groupAllEntries = new Map();
259
+ const groupRef = new Map();
166
260
 
167
261
  function ensureGroup(pid, refEntry) {
168
262
  if (!groupAllEntries.has(pid)) {
@@ -174,13 +268,10 @@ function parseNewLines(filePath) {
174
268
 
175
269
  for (const entry of entries) {
176
270
  if (entry.promptId) {
177
- // This entry opens a new group (could be any role/type)
178
- if (entry.promptId === carryOverPromptId) carryOverSeenInBatch = true;
179
271
  currentPromptId = entry.promptId;
180
272
  latestPromptId = entry.promptId;
181
273
  ensureGroup(currentPromptId, entry);
182
274
  } else if (currentPromptId) {
183
- // No promptId → belongs to whatever group is currently open
184
275
  ensureGroup(currentPromptId, entry);
185
276
  groupAllEntries.get(currentPromptId).push(entry);
186
277
  }
@@ -191,54 +282,56 @@ function parseNewLines(filePath) {
191
282
  // ── Emit records ──────────────────────────────────────────────────────────
192
283
  const records = [];
193
284
 
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;
285
+ // If a pending group exists but its promptId doesn't appear in this batch at all,
286
+ // it has been superseded by new prompts — emit it now before processing new groups.
287
+ if (pendingGroup && groupOrder.length > 0 && !groupOrder.includes(pendingGroup.promptId)) {
288
+ const usage = usageFromPlain(pendingGroup.usage);
289
+ if (usage.uuids.length > 0) {
290
+ records.push(makeRecord(pendingGroup.ref, pendingGroup.promptId, usage, pendingGroup.filePaths || []));
219
291
  }
292
+ state.clearPendingGroup(filePath);
293
+ }
220
294
 
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];
295
+ for (let gi = 0; gi < groupOrder.length; gi++) {
296
+ const promptId = groupOrder[gi];
297
+ const isLast = gi === groupOrder.length - 1;
298
+ const allEntries = groupAllEntries.get(promptId);
299
+ const turns = allEntries.filter(e => e.message?.usage);
300
+ const hasPending = pendingGroup && pendingGroup.promptId === promptId;
301
+
302
+ let effectiveUsage, effectiveFilePaths, effectiveRef;
303
+
304
+ if (hasPending) {
305
+ // Merge accumulated pending data with new entries from this tick
306
+ const pendingUsage = usageFromPlain(pendingGroup.usage);
307
+ effectiveUsage = turns.length > 0 ? mergeUsages(pendingUsage, sumUsage(turns)) : pendingUsage;
308
+ effectiveFilePaths = [...new Set([...(pendingGroup.filePaths || []), ...extractGroupFilePaths(allEntries)])];
309
+ effectiveRef = pendingGroup.ref;
310
+ } else {
311
+ if (!turns.length) continue;
312
+ effectiveUsage = sumUsage(turns);
313
+ effectiveFilePaths = extractGroupFilePaths(allEntries);
314
+ effectiveRef = refFromEntry(groupRef.get(promptId));
234
315
  }
235
316
 
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++;
317
+ if (isLast) {
318
+ // Group may still grow in the next tick — save as pending, don't emit yet
319
+ state.setPendingGroup(filePath, {
320
+ promptId,
321
+ ref: effectiveRef,
322
+ usage: usageToPlain(effectiveUsage),
323
+ filePaths: effectiveFilePaths,
324
+ });
325
+ } else {
326
+ // Group is closed (a subsequent promptId appeared) — emit now
327
+ if (hasPending) state.clearPendingGroup(filePath);
328
+ if (hasPending) {
329
+ // Cross-tick group: emit as one record (tokens already merged, no per-file split)
330
+ records.push(makeRecord(effectiveRef, promptId, effectiveUsage, effectiveFilePaths));
331
+ } else {
332
+ // Single-tick group: use per-file splitting as before
333
+ records.push(...emitGroup(effectiveRef, promptId, turns, allEntries));
334
+ }
242
335
  }
243
336
  }
244
337
 
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 };