aicp-tracker 1.2.7 → 1.2.8

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/package.json +1 -1
  2. package/src/log-parser.js +113 -110
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aicp-tracker",
3
- "version": "1.2.7",
3
+ "version": "1.2.8",
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
@@ -11,7 +11,6 @@ function extractTasksFromBranch(branch) {
11
11
  return [...new Set(matches.map(m => m.toUpperCase()))];
12
12
  }
13
13
 
14
- // Sum usage fields from an array of assistant entries
15
14
  function sumUsage(entries) {
16
15
  let input = 0, cc = 0, cr = 0, out = 0;
17
16
  let ephUser = 0, ephAsst = 0, ephTool = 0;
@@ -26,9 +25,13 @@ function sumUsage(entries) {
26
25
  cc += u.cache_creation_input_tokens || 0;
27
26
  cr += u.cache_read_input_tokens || 0;
28
27
  out += u.output_tokens || 0;
29
- ephUser += u.ephemeral_user_input_tokens || 0;
30
- ephAsst += u.ephemeral_assistant_input_tokens || 0;
31
- ephTool += u.ephemeral_tool_input_tokens || 0;
28
+ // Ephemeral tokens may be at top level (older format) or in cache_creation object (newer format)
29
+ ephUser += u.ephemeral_user_input_tokens || u.cache_creation?.ephemeral_user_input_tokens || 0;
30
+ ephAsst += u.ephemeral_assistant_input_tokens || u.cache_creation?.ephemeral_assistant_input_tokens || 0;
31
+ 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
+ ephUser += u.cache_creation?.ephemeral_5m_input_tokens || 0;
34
+ ephUser += u.cache_creation?.ephemeral_1h_input_tokens || 0;
32
35
  if (!serviceTier && u.service_tier) serviceTier = u.service_tier;
33
36
  if (!speed && u.speed) speed = u.speed;
34
37
 
@@ -40,21 +43,27 @@ function sumUsage(entries) {
40
43
  if (block.name === 'web_fetch' || block.name === 'WebFetch') webFetch++;
41
44
  }
42
45
  }
46
+ // Also check server_tool_use object for web requests
47
+ const srvTool = u.server_tool_use;
48
+ if (srvTool) {
49
+ webSearch += srvTool.web_search_requests || 0;
50
+ webFetch += srvTool.web_fetch_requests || 0;
51
+ }
43
52
  if (e.message?.model) models.add(e.message.model);
44
53
  if (e.uuid) uuids.push(e.uuid);
45
54
  }
46
55
  return { input, cc, cr, out, ephUser, ephAsst, ephTool, webSearch, webFetch, serviceTier, speed, models, uuids };
47
56
  }
48
57
 
49
- function makeRecord(first, rootUuid, usage, filePaths) {
58
+ function makeRecord(ref, rootUuid, usage, filePaths) {
50
59
  const { input, cc, cr, out, ephUser, ephAsst, ephTool, webSearch, webFetch, serviceTier, speed, models, uuids } = usage;
51
60
  return {
52
- sessionId: first.sessionId || null,
61
+ sessionId: ref.sessionId || null,
53
62
  uuid: rootUuid,
54
- parentUuid: first.parentUuid || null,
55
- timestamp: first.timestamp || new Date().toISOString(),
56
- gitBranch: first.gitBranch || null,
57
- task_from_git_branch: extractTasksFromBranch(first.gitBranch),
63
+ parentUuid: ref.parentUuid || null,
64
+ timestamp: ref.timestamp || new Date().toISOString(),
65
+ gitBranch: ref.gitBranch || null,
66
+ task_from_git_branch: extractTasksFromBranch(ref.gitBranch),
58
67
  model: [...models].join(',') || null,
59
68
  input_tokens: input,
60
69
  cache_creation_input_tokens: cc,
@@ -73,6 +82,31 @@ function makeRecord(first, rootUuid, usage, filePaths) {
73
82
  };
74
83
  }
75
84
 
85
+ function extractFilePath(entry) {
86
+ const content = entry.message?.content;
87
+ if (!Array.isArray(content)) return null;
88
+ for (const block of content) {
89
+ if (block.type === 'tool_use' && WRITE_TOOLS.has(block.name)) {
90
+ const fp = block.input?.file_path || block.input?.path;
91
+ if (fp && typeof fp === 'string') return fp;
92
+ }
93
+ }
94
+ return null;
95
+ }
96
+
97
+ function extractAllFilePaths(entry) {
98
+ const content = entry.message?.content;
99
+ if (!Array.isArray(content)) return [];
100
+ const paths = [];
101
+ for (const block of content) {
102
+ if (block.type === 'tool_use' && WRITE_TOOLS.has(block.name)) {
103
+ const fp = block.input?.file_path || block.input?.path;
104
+ if (fp && typeof fp === 'string') paths.push(fp);
105
+ }
106
+ }
107
+ return paths;
108
+ }
109
+
76
110
  function parseNewLines(filePath) {
77
111
  const carryOverPromptId = state.getLastPromptId(filePath);
78
112
 
@@ -87,11 +121,10 @@ function parseNewLines(filePath) {
87
121
  fs.closeSync(fd);
88
122
  state.setOffset(filePath, offset + read);
89
123
 
90
- // ── Parse all entries ─────────────────────────────────────────────────────
91
- // message.id deduplication: multi-block API responses (thinking + text + tool_use)
92
- // share the same message.id but have different UUIDs. Only the first occurrence
93
- // carries the usage fields that should be counted; subsequent lines are duplicates.
94
- const byUuid = new Map();
124
+ // Parse entries in file order.
125
+ // Deduplicate multi-block API responses sharing the same message.id — only the
126
+ // first occurrence carries usage fields; subsequent lines are structurally identical.
127
+ const entries = [];
95
128
  const seenMsgIds = new Set();
96
129
 
97
130
  for (const raw of buf.slice(0, read).toString('utf8').split('\n')) {
@@ -106,135 +139,105 @@ function parseNewLines(filePath) {
106
139
  if (seenMsgIds.has(msgId)) continue;
107
140
  seenMsgIds.add(msgId);
108
141
  }
109
- byUuid.set(entry.uuid, entry);
142
+ entries.push(entry);
110
143
  }
111
144
 
112
- // ── Build promptId index for all user entries in this batch ───────────────
113
- // Every user entry (real prompt or tool-result) with a `promptId` field gets
114
- // registered. The promptId is a turn-level ID shared by the initiating user
115
- // message and all tool-result user entries that follow in the same turn.
116
- // For old entries without the field, real prompts (text content) use their own uuid.
145
+ // ── Sequential grouping ───────────────────────────────────────────────────
146
+ // A user entry with a promptId starts (or continues) a conversation turn.
147
+ // Every assistant entry with usage that follows even entries without a
148
+ // promptId of their own belongs to the current turn. This captures all
149
+ // tool-use round-trips that Claude Code doesn't tag with a fresh promptId.
117
150
  //
118
- // promptIdByUuid maps: entry.uuid promptId (turn root identifier)
119
- const promptIdByUuid = new Map();
120
- let latestPromptId = carryOverPromptId;
121
-
122
- for (const entry of byUuid.values()) {
123
- const isUser = entry.type === 'user' || entry.message?.role === 'user';
124
- if (!isUser) continue;
125
-
126
- if (entry.promptId) {
127
- promptIdByUuid.set(entry.uuid, entry.promptId);
128
- latestPromptId = entry.promptId;
129
- } else {
130
- // No promptId field — check if it's a real user text message (not tool-result only)
131
- const content = entry.message?.content ?? entry.content;
132
- const hasText = Array.isArray(content)
133
- ? content.some(c => c.type === 'text')
134
- : (typeof content === 'string' && content.length > 0);
135
- if (hasText) {
136
- promptIdByUuid.set(entry.uuid, entry.uuid);
137
- latestPromptId = entry.uuid;
138
- }
151
+ // carryOverPromptId: the last promptId seen in a previous batch. Assistant
152
+ // entries at the start of this batch that precede any new user promptId are
153
+ // continuation turns of that prompt and get collected under it.
154
+
155
+ let currentPromptId = carryOverPromptId;
156
+ let latestPromptId = carryOverPromptId;
157
+ let carryOverSeenInBatch = false;
158
+
159
+ // groupOrder preserves insertion order so records are emitted chronologically.
160
+ const groupOrder = [];
161
+ const groupTurns = new Map(); // promptId → assistant entries[]
162
+ const groupRef = new Map(); // promptId → reference entry (user msg or first asst turn)
163
+
164
+ function ensureGroup(pid, refEntry) {
165
+ if (!groupTurns.has(pid)) {
166
+ groupTurns.set(pid, []);
167
+ groupRef.set(pid, refEntry);
168
+ groupOrder.push(pid);
139
169
  }
140
170
  }
141
171
 
142
- // ── Resolve promptId for an assistant entry ───────────────────────────────
143
- // Walk the parentUuid chain upward. The first ancestor found in promptIdByUuid
144
- // determines which turn this assistant entry belongs to.
145
- // If the chain exits the batch (parent not in byUuid), fall back to the
146
- // carry-over from the previous batch.
147
- function resolvePromptId(entry) {
148
- const visited = new Set();
149
- let cur = entry;
150
- while (cur && !visited.has(cur.uuid)) {
151
- visited.add(cur.uuid);
152
- const parentId = cur.parentUuid;
153
- if (!parentId) break;
154
- if (promptIdByUuid.has(parentId)) return promptIdByUuid.get(parentId);
155
- const parent = byUuid.get(parentId);
156
- if (!parent) return carryOverPromptId || entry.uuid; // batch boundary
157
- cur = parent;
158
- }
159
- return carryOverPromptId || entry.uuid;
160
- }
161
-
162
- // If carryOverPromptId was re-introduced by a user entry in this batch (e.g. the
163
- // real user message itself landed here), treat it as a normal in-batch group so
164
- // the uuid stays as-is. Only suffix when the carry-over was the ONLY way this
165
- // promptId was seen (i.e. no user entry in this batch declares that promptId).
166
- const carryOverSeenInBatch = carryOverPromptId &&
167
- [...promptIdByUuid.values()].includes(carryOverPromptId);
168
-
169
- // ── Group assistant turns by promptId ─────────────────────────────────────
170
- const promptGroups = new Map(); // promptId → assistant entries[]
171
-
172
- for (const entry of byUuid.values()) {
172
+ for (const entry of entries) {
173
+ const isUser = entry.type === 'user' || entry.message?.role === 'user';
173
174
  const isAssistant = entry.type === 'assistant' || entry.message?.role === 'assistant';
174
- if (!isAssistant || !entry.message?.usage) continue;
175
- const pid = resolvePromptId(entry);
176
- if (!promptGroups.has(pid)) promptGroups.set(pid, []);
177
- promptGroups.get(pid).push(entry);
175
+
176
+ if (isUser && entry.promptId) {
177
+ if (entry.promptId === carryOverPromptId) carryOverSeenInBatch = true;
178
+ currentPromptId = entry.promptId;
179
+ latestPromptId = entry.promptId;
180
+ ensureGroup(currentPromptId, entry);
181
+ } else if (isAssistant && entry.message?.usage) {
182
+ if (currentPromptId) {
183
+ ensureGroup(currentPromptId, entry);
184
+ groupTurns.get(currentPromptId).push(entry);
185
+ }
186
+ }
178
187
  }
179
188
 
180
- // Persist the latest promptId so the next batch can attribute orphaned entries
181
189
  if (latestPromptId) state.setLastPromptId(filePath, latestPromptId);
182
190
 
183
- // ── Per prompt: bucket turns by file anchor, emit one record per file ─────
191
+ // ── Emit records ──────────────────────────────────────────────────────────
184
192
  const records = [];
185
193
 
186
- for (const [promptId, turns] of promptGroups) {
187
- turns.sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
188
- const first = turns[0];
194
+ for (const promptId of groupOrder) {
195
+ const turns = groupTurns.get(promptId);
196
+ if (!turns.length) continue;
197
+
198
+ const ref = groupRef.get(promptId);
189
199
 
190
200
  // If this group is a pure carry-over (the previous batch already emitted a
191
- // record with uuid=promptId), suffix `:carry` so the DB holds two distinct
192
- // records that both get accumulated rather than one being deduplicated away.
193
- const baseUuid = (promptId === carryOverPromptId && !carryOverSeenInBatch)
194
- ? `${promptId}:carry`
195
- : promptId;
196
-
197
- // Label each turn with the file it writes (null if none)
198
- const turnFiles = turns.map(e => {
199
- const content = e.message?.content;
200
- if (!Array.isArray(content)) return null;
201
- for (const block of content) {
202
- if (block.type === 'tool_use' && WRITE_TOOLS.has(block.name)) {
203
- const fp = block.input?.file_path || block.input?.path;
204
- if (fp && typeof fp === 'string') return fp;
205
- }
201
+ // record with uuid=promptId), suffix :carry so both records accumulate in
202
+ // the DB rather than one being deduplicated away.
203
+ const isCarryOver = promptId === carryOverPromptId && !carryOverSeenInBatch;
204
+ const baseUuid = isCarryOver ? `${promptId}:carry` : promptId;
205
+
206
+ // Collect ALL file paths from all turns (not just the first file)
207
+ const allFiles = new Set();
208
+ const turnFiles = turns.map(extractFilePath);
209
+ for (const turn of turns) {
210
+ for (const fp of extractAllFilePaths(turn)) {
211
+ allFiles.add(fp);
206
212
  }
207
- return null;
208
- });
209
-
210
- const hasAnyFile = turnFiles.some(f => f !== null);
213
+ }
214
+ const hasAnyFile = allFiles.size > 0;
211
215
 
212
216
  if (!hasAnyFile) {
213
- // Pure conversation or Bash-only — one record, empty file_paths
214
- records.push(makeRecord(first, baseUuid, sumUsage(turns), []));
217
+ records.push(makeRecord(ref, baseUuid, sumUsage(turns), []));
215
218
  continue;
216
219
  }
217
220
 
218
- // Attribute orphan turns (no file) to the NEXT anchor turn.
219
- // Trailing orphans after the last anchor attributed to the last file seen.
220
- const fileGroups = new Map();
221
- let lastFile = turnFiles.find(f => f !== null);
221
+ // Attribute orphan turns (no file anchor) to the nearest anchor:
222
+ // leading orphans first anchor; trailing orphans last anchor seen.
223
+ const fileGroups = new Map();
222
224
  const firstAnchorIdx = turnFiles.findIndex(f => f !== null);
225
+ let lastFile = turnFiles[firstAnchorIdx];
223
226
 
224
227
  for (let i = 0; i < turns.length; i++) {
225
228
  const file = turnFiles[i] !== null ? turnFiles[i]
226
229
  : (i < firstAnchorIdx ? turnFiles[firstAnchorIdx] : lastFile);
227
-
228
230
  if (!fileGroups.has(file)) fileGroups.set(file, []);
229
231
  fileGroups.get(file).push(turns[i]);
230
232
  if (turnFiles[i] !== null) lastFile = turnFiles[i];
231
233
  }
232
234
 
233
- // Emit one record per file; disambiguate uuid by appending file index
234
235
  let idx = 0;
235
236
  for (const [fp, fileTurns] of fileGroups) {
236
237
  const uuid = idx === 0 ? baseUuid : `${baseUuid}:${idx}`;
237
- records.push(makeRecord(first, uuid, sumUsage(fileTurns), [fp]));
238
+ // Include all files from the turn(s) in this group, not just the grouped file
239
+ const filesForRecord = [...new Set([fp, ...fileTurns.flatMap(extractAllFilePaths)])];
240
+ records.push(makeRecord(ref, uuid, sumUsage(fileTurns), filesForRecord));
238
241
  idx++;
239
242
  }
240
243
  }