aicp-tracker 1.2.6 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aicp-tracker",
3
- "version": "1.2.6",
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,49 +11,27 @@ function extractTasksFromBranch(branch) {
11
11
  return [...new Set(matches.map(m => m.toUpperCase()))];
12
12
  }
13
13
 
14
- // Returns true for a genuine user message (not a tool_result injected by the harness)
15
- function isUserInitiated(entry) {
16
- if (entry.type !== 'user' && entry.message?.role !== 'user') return false;
17
- const content = entry.message?.content ?? entry.content;
18
- if (!content) return false;
19
- if (typeof content === 'string') return true;
20
- if (Array.isArray(content)) return content.some(c => c.type !== 'tool_result');
21
- return true;
22
- }
23
-
24
- // Walk parentUuid upward to find the user-initiated root of this assistant turn.
25
- // Returns rootUuid, or the entry's own uuid if root isn't in the current batch.
26
- function findPromptRoot(entry, byUuid) {
27
- const visited = new Set();
28
- let cur = entry;
29
- while (cur && !visited.has(cur.uuid)) {
30
- visited.add(cur.uuid);
31
- const parent = byUuid.get(cur.parentUuid);
32
- if (!parent) break;
33
- if (isUserInitiated(parent)) return parent.uuid;
34
- cur = parent;
35
- }
36
- return entry.uuid;
37
- }
38
-
39
- // Sum usage fields from an array of assistant entries
40
14
  function sumUsage(entries) {
41
15
  let input = 0, cc = 0, cr = 0, out = 0;
42
16
  let ephUser = 0, ephAsst = 0, ephTool = 0;
43
17
  let webSearch = 0, webFetch = 0;
44
18
  let serviceTier = null, speed = null;
45
19
  const models = new Set();
46
-
47
20
  const uuids = [];
21
+
48
22
  for (const e of entries) {
49
23
  const u = e.message?.usage || {};
50
24
  input += u.input_tokens || 0;
51
25
  cc += u.cache_creation_input_tokens || 0;
52
26
  cr += u.cache_read_input_tokens || 0;
53
27
  out += u.output_tokens || 0;
54
- ephUser += u.ephemeral_user_input_tokens || 0;
55
- ephAsst += u.ephemeral_assistant_input_tokens || 0;
56
- 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;
57
35
  if (!serviceTier && u.service_tier) serviceTier = u.service_tier;
58
36
  if (!speed && u.speed) speed = u.speed;
59
37
 
@@ -65,21 +43,27 @@ function sumUsage(entries) {
65
43
  if (block.name === 'web_fetch' || block.name === 'WebFetch') webFetch++;
66
44
  }
67
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
+ }
68
52
  if (e.message?.model) models.add(e.message.model);
69
53
  if (e.uuid) uuids.push(e.uuid);
70
54
  }
71
55
  return { input, cc, cr, out, ephUser, ephAsst, ephTool, webSearch, webFetch, serviceTier, speed, models, uuids };
72
56
  }
73
57
 
74
- function makeRecord(first, rootUuid, usage, filePaths) {
58
+ function makeRecord(ref, rootUuid, usage, filePaths) {
75
59
  const { input, cc, cr, out, ephUser, ephAsst, ephTool, webSearch, webFetch, serviceTier, speed, models, uuids } = usage;
76
60
  return {
77
- sessionId: first.sessionId || null,
61
+ sessionId: ref.sessionId || null,
78
62
  uuid: rootUuid,
79
- parentUuid: first.parentUuid || null,
80
- timestamp: first.timestamp || new Date().toISOString(),
81
- gitBranch: first.gitBranch || null,
82
- 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),
83
67
  model: [...models].join(',') || null,
84
68
  input_tokens: input,
85
69
  cache_creation_input_tokens: cc,
@@ -98,7 +82,34 @@ function makeRecord(first, rootUuid, usage, filePaths) {
98
82
  };
99
83
  }
100
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
+
101
110
  function parseNewLines(filePath) {
111
+ const carryOverPromptId = state.getLastPromptId(filePath);
112
+
102
113
  const offset = state.getOffset(filePath);
103
114
  let stat;
104
115
  try { stat = fs.statSync(filePath); } catch { return []; }
@@ -110,79 +121,123 @@ function parseNewLines(filePath) {
110
121
  fs.closeSync(fd);
111
122
  state.setOffset(filePath, offset + read);
112
123
 
113
- // ── Parse all entries ─────────────────────────────────────────────────────
114
- 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 = [];
128
+ const seenMsgIds = new Set();
129
+
115
130
  for (const raw of buf.slice(0, read).toString('utf8').split('\n')) {
116
131
  const line = raw.trim();
117
132
  if (!line) continue;
118
133
  let entry;
119
134
  try { entry = JSON.parse(line); } catch { continue; }
120
- if (entry.uuid) byUuid.set(entry.uuid, entry);
135
+ if (!entry.uuid) continue;
136
+
137
+ const msgId = entry.message?.id;
138
+ if (msgId) {
139
+ if (seenMsgIds.has(msgId)) continue;
140
+ seenMsgIds.add(msgId);
141
+ }
142
+ entries.push(entry);
121
143
  }
122
144
 
123
- // ── Group assistant turns by prompt root ──────────────────────────────────
124
- const promptGroups = new Map(); // rootUuid → assistant entries[]
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.
150
+ //
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);
169
+ }
170
+ }
125
171
 
126
- for (const entry of byUuid.values()) {
172
+ for (const entry of entries) {
173
+ const isUser = entry.type === 'user' || entry.message?.role === 'user';
127
174
  const isAssistant = entry.type === 'assistant' || entry.message?.role === 'assistant';
128
- if (!isAssistant || !entry.message?.usage) continue;
129
- const rootUuid = findPromptRoot(entry, byUuid);
130
- if (!promptGroups.has(rootUuid)) promptGroups.set(rootUuid, []);
131
- promptGroups.get(rootUuid).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
+ }
132
187
  }
133
188
 
134
- // ── Per prompt: bucket turns by file anchor, emit one record per file ─────
189
+ if (latestPromptId) state.setLastPromptId(filePath, latestPromptId);
190
+
191
+ // ── Emit records ──────────────────────────────────────────────────────────
135
192
  const records = [];
136
193
 
137
- for (const [rootUuid, turns] of promptGroups) {
138
- turns.sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
139
- const first = turns[0];
194
+ for (const promptId of groupOrder) {
195
+ const turns = groupTurns.get(promptId);
196
+ if (!turns.length) continue;
140
197
 
141
- // Label each turn with the file it writes (null if none)
142
- const turnFiles = turns.map(e => {
143
- const content = e.message?.content;
144
- if (!Array.isArray(content)) return null;
145
- for (const block of content) {
146
- if (block.type === 'tool_use' && WRITE_TOOLS.has(block.name)) {
147
- const fp = block.input?.file_path || block.input?.path;
148
- if (fp && typeof fp === 'string') return fp;
149
- }
150
- }
151
- return null;
152
- });
198
+ const ref = groupRef.get(promptId);
199
+
200
+ // If this group is a pure carry-over (the previous batch already emitted a
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;
153
205
 
154
- const hasAnyFile = turnFiles.some(f => f !== null);
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);
212
+ }
213
+ }
214
+ const hasAnyFile = allFiles.size > 0;
155
215
 
156
216
  if (!hasAnyFile) {
157
- // Pure conversation or Bash-only — one record, empty file_paths
158
- records.push(makeRecord(first, rootUuid, sumUsage(turns), []));
217
+ records.push(makeRecord(ref, baseUuid, sumUsage(turns), []));
159
218
  continue;
160
219
  }
161
220
 
162
- // Attribute orphan turns (no file) to the NEXT anchor turn.
163
- // Trailing orphans after the last anchor attributed to the last file seen.
164
- // Group: Map of filePath → turns[]
165
- const fileGroups = new Map();
166
- let lastFile = turnFiles.find(f => f !== null); // seed with first anchor
167
-
168
- // First pass: find the first anchor and assign pre-anchor orphans to it
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();
169
224
  const firstAnchorIdx = turnFiles.findIndex(f => f !== null);
225
+ let lastFile = turnFiles[firstAnchorIdx];
170
226
 
171
227
  for (let i = 0; i < turns.length; i++) {
172
228
  const file = turnFiles[i] !== null ? turnFiles[i]
173
- : (i < firstAnchorIdx ? turnFiles[firstAnchorIdx] // pre-anchor → first anchor
174
- : lastFile); // post-anchor → last anchor
175
-
229
+ : (i < firstAnchorIdx ? turnFiles[firstAnchorIdx] : lastFile);
176
230
  if (!fileGroups.has(file)) fileGroups.set(file, []);
177
231
  fileGroups.get(file).push(turns[i]);
178
232
  if (turnFiles[i] !== null) lastFile = turnFiles[i];
179
233
  }
180
234
 
181
- // Emit one record per file, uuid disambiguated by appending file index
182
235
  let idx = 0;
183
236
  for (const [fp, fileTurns] of fileGroups) {
184
- const uuid = idx === 0 ? rootUuid : `${rootUuid}:${idx}`;
185
- records.push(makeRecord(first, uuid, sumUsage(fileTurns), [fp]));
237
+ const uuid = idx === 0 ? baseUuid : `${baseUuid}:${idx}`;
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));
186
241
  idx++;
187
242
  }
188
243
  }
package/src/state.js CHANGED
@@ -33,4 +33,15 @@ function setOffset(filePath, offset) {
33
33
  save();
34
34
  }
35
35
 
36
- module.exports = { getOffset, setOffset };
36
+ function getLastPromptId(filePath) {
37
+ return load().lastPromptIds?.[filePath] || null;
38
+ }
39
+
40
+ function setLastPromptId(filePath, promptId) {
41
+ const s = load();
42
+ if (!s.lastPromptIds) s.lastPromptIds = {};
43
+ s.lastPromptIds[filePath] = promptId;
44
+ save();
45
+ }
46
+
47
+ module.exports = { getOffset, setOffset, getLastPromptId, setLastPromptId };