aicp-tracker 1.2.1 → 1.2.2

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 +148 -56
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aicp-tracker",
3
- "version": "1.2.1",
3
+ "version": "1.2.2",
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
@@ -3,23 +3,98 @@
3
3
  const fs = require('fs');
4
4
  const state = require('./state');
5
5
 
6
- // Tool names whose input.file_path / input.path we extract
7
- const FILE_TOOLS = new Set(['Read', 'Edit', 'Write', 'Glob', 'NotebookEdit', 'MultiEdit']);
8
-
9
- /**
10
- * Extracts all JIRA-style task keys from a git branch name.
11
- * Handles all combinations per spec:
12
- * "STORAGE-56: ASD", "STORAGE-56 ASD", "ASD STORAGE-56", "STORAGE-56",
13
- * "storage-56 asd" (case-insensitive), "asd :storage-56",
14
- * "STORAGE-56, STORAGE-57: ASD" (multiple tasks → returns both)
15
- * Returns uppercase deduplicated keys: ["STORAGE-56", "STORAGE-57"]
16
- */
6
+ const WRITE_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit', 'MultiEdit']);
7
+
17
8
  function extractTasksFromBranch(branch) {
18
9
  if (!branch || typeof branch !== 'string') return [];
19
10
  const matches = branch.match(/[A-Za-z][A-Za-z0-9]*-\d+/g) || [];
20
11
  return [...new Set(matches.map(m => m.toUpperCase()))];
21
12
  }
22
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
+ function sumUsage(entries) {
41
+ let input = 0, cc = 0, cr = 0, out = 0;
42
+ let ephUser = 0, ephAsst = 0, ephTool = 0;
43
+ let webSearch = 0, webFetch = 0;
44
+ let serviceTier = null, speed = null;
45
+ const models = new Set();
46
+
47
+ for (const e of entries) {
48
+ const u = e.message?.usage || {};
49
+ input += u.input_tokens || 0;
50
+ cc += u.cache_creation_input_tokens || 0;
51
+ cr += u.cache_read_input_tokens || 0;
52
+ out += u.output_tokens || 0;
53
+ ephUser += u.ephemeral_user_input_tokens || 0;
54
+ ephAsst += u.ephemeral_assistant_input_tokens || 0;
55
+ ephTool += u.ephemeral_tool_input_tokens || 0;
56
+ if (!serviceTier && u.service_tier) serviceTier = u.service_tier;
57
+ if (!speed && u.speed) speed = u.speed;
58
+
59
+ const content = e.message?.content;
60
+ if (Array.isArray(content)) {
61
+ for (const block of content) {
62
+ if (block.type !== 'tool_use') continue;
63
+ if (block.name === 'web_search' || block.name === 'WebSearch') webSearch++;
64
+ if (block.name === 'web_fetch' || block.name === 'WebFetch') webFetch++;
65
+ }
66
+ }
67
+ if (e.message?.model) models.add(e.message.model);
68
+ }
69
+ return { input, cc, cr, out, ephUser, ephAsst, ephTool, webSearch, webFetch, serviceTier, speed, models };
70
+ }
71
+
72
+ function makeRecord(first, rootUuid, usage, filePaths) {
73
+ const { input, cc, cr, out, ephUser, ephAsst, ephTool, webSearch, webFetch, serviceTier, speed, models } = usage;
74
+ return {
75
+ sessionId: first.sessionId || null,
76
+ uuid: rootUuid,
77
+ parentUuid: first.parentUuid || null,
78
+ timestamp: first.timestamp || new Date().toISOString(),
79
+ gitBranch: first.gitBranch || null,
80
+ task_from_git_branch: extractTasksFromBranch(first.gitBranch),
81
+ model: [...models].join(',') || null,
82
+ input_tokens: input,
83
+ cache_creation_input_tokens: cc,
84
+ cache_read_input_tokens: cr,
85
+ output_tokens: out,
86
+ ephemeral_user_input_tokens: ephUser,
87
+ ephemeral_assistant_input_tokens: ephAsst,
88
+ ephemeral_tool_input_tokens: ephTool,
89
+ web_search_requests: webSearch,
90
+ web_fetch_requests: webFetch,
91
+ file_paths: filePaths,
92
+ enterprise_usd_per_token: null,
93
+ service_tier: serviceTier,
94
+ speed: speed || (cc > 0 ? 'fast' : 'normal'),
95
+ };
96
+ }
97
+
23
98
  function parseNewLines(filePath) {
24
99
  const offset = state.getOffset(filePath);
25
100
  let stat;
@@ -32,64 +107,81 @@ function parseNewLines(filePath) {
32
107
  fs.closeSync(fd);
33
108
  state.setOffset(filePath, offset + read);
34
109
 
35
- const records = [];
36
- const lines = buf.slice(0, read).toString('utf8').split('\n');
37
-
38
- for (const raw of lines) {
110
+ // ── Parse all entries ─────────────────────────────────────────────────────
111
+ const byUuid = new Map();
112
+ for (const raw of buf.slice(0, read).toString('utf8').split('\n')) {
39
113
  const line = raw.trim();
40
114
  if (!line) continue;
41
115
  let entry;
42
116
  try { entry = JSON.parse(line); } catch { continue; }
117
+ if (entry.uuid) byUuid.set(entry.uuid, entry);
118
+ }
43
119
 
44
- if (entry.type !== 'assistant' && entry.message?.role !== 'assistant') continue;
45
- const usage = entry.message?.usage;
46
- if (!usage) continue;
120
+ // ── Group assistant turns by prompt root ──────────────────────────────────
121
+ const promptGroups = new Map(); // rootUuid → assistant entries[]
47
122
 
48
- let webSearchRequests = 0;
49
- let webFetchRequests = 0;
50
- const filePaths = new Set();
51
- const content = entry.message?.content;
123
+ for (const entry of byUuid.values()) {
124
+ const isAssistant = entry.type === 'assistant' || entry.message?.role === 'assistant';
125
+ if (!isAssistant || !entry.message?.usage) continue;
126
+ const rootUuid = findPromptRoot(entry, byUuid);
127
+ if (!promptGroups.has(rootUuid)) promptGroups.set(rootUuid, []);
128
+ promptGroups.get(rootUuid).push(entry);
129
+ }
52
130
 
53
- if (Array.isArray(content)) {
131
+ // ── Per prompt: bucket turns by file anchor, emit one record per file ─────
132
+ const records = [];
133
+
134
+ for (const [rootUuid, turns] of promptGroups) {
135
+ turns.sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
136
+ const first = turns[0];
137
+
138
+ // Label each turn with the file it writes (null if none)
139
+ const turnFiles = turns.map(e => {
140
+ const content = e.message?.content;
141
+ if (!Array.isArray(content)) return null;
54
142
  for (const block of content) {
55
- if (block.type !== 'tool_use') continue;
56
- if (block.name === 'web_search' || block.name === 'WebSearch') webSearchRequests++;
57
- if (block.name === 'web_fetch' || block.name === 'WebFetch') webFetchRequests++;
58
- if (FILE_TOOLS.has(block.name)) {
143
+ if (block.type === 'tool_use' && WRITE_TOOLS.has(block.name)) {
59
144
  const fp = block.input?.file_path || block.input?.path;
60
- if (fp && typeof fp === 'string') filePaths.add(fp);
145
+ if (fp && typeof fp === 'string') return fp;
61
146
  }
62
147
  }
148
+ return null;
149
+ });
150
+
151
+ const hasAnyFile = turnFiles.some(f => f !== null);
152
+
153
+ if (!hasAnyFile) {
154
+ // Pure conversation or Bash-only — one record, empty file_paths
155
+ records.push(makeRecord(first, rootUuid, sumUsage(turns), []));
156
+ continue;
63
157
  }
64
158
 
65
- records.push({
66
- sessionId: entry.sessionId || null,
67
- uuid: entry.uuid || null,
68
- parentUuid: entry.parentUuid || null,
69
- timestamp: entry.timestamp || new Date().toISOString(),
70
- gitBranch: entry.gitBranch || null,
71
- task_from_git_branch: extractTasksFromBranch(entry.gitBranch),
72
- model: entry.message?.model || null,
73
-
74
- input_tokens: usage.input_tokens || 0,
75
- cache_creation_input_tokens: usage.cache_creation_input_tokens || 0,
76
- cache_read_input_tokens: usage.cache_read_input_tokens || 0,
77
- output_tokens: usage.output_tokens || 0,
78
- ephemeral_user_input_tokens: usage.ephemeral_user_input_tokens || 0,
79
- ephemeral_assistant_input_tokens: usage.ephemeral_assistant_input_tokens || 0,
80
- ephemeral_tool_input_tokens: usage.ephemeral_tool_input_tokens || 0,
81
-
82
- web_search_requests: webSearchRequests,
83
- web_fetch_requests: webFetchRequests,
84
- file_paths: [...filePaths],
85
-
86
- // Claude Code logs do not include per-token USD pricing from Anthropic's API.
87
- // Enterprise billing data must come from Anthropic's billing API separately.
88
- enterprise_usd_per_token: null,
89
-
90
- service_tier: usage.service_tier || null,
91
- speed: usage.speed || (usage.cache_creation_input_tokens > 0 ? 'fast' : 'normal'),
92
- });
159
+ // Attribute orphan turns (no file) to the NEXT anchor turn.
160
+ // Trailing orphans after the last anchor → attributed to the last file seen.
161
+ // Group: Map of filePath → turns[]
162
+ const fileGroups = new Map();
163
+ let lastFile = turnFiles.find(f => f !== null); // seed with first anchor
164
+
165
+ // First pass: find the first anchor and assign pre-anchor orphans to it
166
+ const firstAnchorIdx = turnFiles.findIndex(f => f !== null);
167
+
168
+ for (let i = 0; i < turns.length; i++) {
169
+ const file = turnFiles[i] !== null ? turnFiles[i]
170
+ : (i < firstAnchorIdx ? turnFiles[firstAnchorIdx] // pre-anchor → first anchor
171
+ : lastFile); // post-anchor → last anchor
172
+
173
+ if (!fileGroups.has(file)) fileGroups.set(file, []);
174
+ fileGroups.get(file).push(turns[i]);
175
+ if (turnFiles[i] !== null) lastFile = turnFiles[i];
176
+ }
177
+
178
+ // Emit one record per file, uuid disambiguated by appending file index
179
+ let idx = 0;
180
+ for (const [fp, fileTurns] of fileGroups) {
181
+ const uuid = idx === 0 ? rootUuid : `${rootUuid}:${idx}`;
182
+ records.push(makeRecord(first, uuid, sumUsage(fileTurns), [fp]));
183
+ idx++;
184
+ }
93
185
  }
94
186
 
95
187
  return records;