aicp-tracker 1.2.6 → 1.2.7

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.7",
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,31 +11,6 @@ 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
14
  // Sum usage fields from an array of assistant entries
40
15
  function sumUsage(entries) {
41
16
  let input = 0, cc = 0, cr = 0, out = 0;
@@ -43,8 +18,8 @@ function sumUsage(entries) {
43
18
  let webSearch = 0, webFetch = 0;
44
19
  let serviceTier = null, speed = null;
45
20
  const models = new Set();
46
-
47
21
  const uuids = [];
22
+
48
23
  for (const e of entries) {
49
24
  const u = e.message?.usage || {};
50
25
  input += u.input_tokens || 0;
@@ -99,6 +74,8 @@ function makeRecord(first, rootUuid, usage, filePaths) {
99
74
  }
100
75
 
101
76
  function parseNewLines(filePath) {
77
+ const carryOverPromptId = state.getLastPromptId(filePath);
78
+
102
79
  const offset = state.getOffset(filePath);
103
80
  let stat;
104
81
  try { stat = fs.statSync(filePath); } catch { return []; }
@@ -111,33 +88,112 @@ function parseNewLines(filePath) {
111
88
  state.setOffset(filePath, offset + read);
112
89
 
113
90
  // ── Parse all entries ─────────────────────────────────────────────────────
114
- const byUuid = new Map();
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();
95
+ const seenMsgIds = new Set();
96
+
115
97
  for (const raw of buf.slice(0, read).toString('utf8').split('\n')) {
116
98
  const line = raw.trim();
117
99
  if (!line) continue;
118
100
  let entry;
119
101
  try { entry = JSON.parse(line); } catch { continue; }
120
- if (entry.uuid) byUuid.set(entry.uuid, entry);
102
+ if (!entry.uuid) continue;
103
+
104
+ const msgId = entry.message?.id;
105
+ if (msgId) {
106
+ if (seenMsgIds.has(msgId)) continue;
107
+ seenMsgIds.add(msgId);
108
+ }
109
+ byUuid.set(entry.uuid, entry);
110
+ }
111
+
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.
117
+ //
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
+ }
139
+ }
140
+ }
141
+
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;
121
160
  }
122
161
 
123
- // ── Group assistant turns by prompt root ──────────────────────────────────
124
- const promptGroups = new Map(); // rootUuid assistant entries[]
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[]
125
171
 
126
172
  for (const entry of byUuid.values()) {
127
173
  const isAssistant = entry.type === 'assistant' || entry.message?.role === 'assistant';
128
174
  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
+ const pid = resolvePromptId(entry);
176
+ if (!promptGroups.has(pid)) promptGroups.set(pid, []);
177
+ promptGroups.get(pid).push(entry);
132
178
  }
133
179
 
180
+ // Persist the latest promptId so the next batch can attribute orphaned entries
181
+ if (latestPromptId) state.setLastPromptId(filePath, latestPromptId);
182
+
134
183
  // ── Per prompt: bucket turns by file anchor, emit one record per file ─────
135
184
  const records = [];
136
185
 
137
- for (const [rootUuid, turns] of promptGroups) {
186
+ for (const [promptId, turns] of promptGroups) {
138
187
  turns.sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
139
188
  const first = turns[0];
140
189
 
190
+ // 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
+
141
197
  // Label each turn with the file it writes (null if none)
142
198
  const turnFiles = turns.map(e => {
143
199
  const content = e.message?.content;
@@ -155,33 +211,29 @@ function parseNewLines(filePath) {
155
211
 
156
212
  if (!hasAnyFile) {
157
213
  // Pure conversation or Bash-only — one record, empty file_paths
158
- records.push(makeRecord(first, rootUuid, sumUsage(turns), []));
214
+ records.push(makeRecord(first, baseUuid, sumUsage(turns), []));
159
215
  continue;
160
216
  }
161
217
 
162
218
  // Attribute orphan turns (no file) to the NEXT anchor turn.
163
219
  // 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
220
+ const fileGroups = new Map();
221
+ let lastFile = turnFiles.find(f => f !== null);
169
222
  const firstAnchorIdx = turnFiles.findIndex(f => f !== null);
170
223
 
171
224
  for (let i = 0; i < turns.length; i++) {
172
225
  const file = turnFiles[i] !== null ? turnFiles[i]
173
- : (i < firstAnchorIdx ? turnFiles[firstAnchorIdx] // pre-anchor → first anchor
174
- : lastFile); // post-anchor → last anchor
226
+ : (i < firstAnchorIdx ? turnFiles[firstAnchorIdx] : lastFile);
175
227
 
176
228
  if (!fileGroups.has(file)) fileGroups.set(file, []);
177
229
  fileGroups.get(file).push(turns[i]);
178
230
  if (turnFiles[i] !== null) lastFile = turnFiles[i];
179
231
  }
180
232
 
181
- // Emit one record per file, uuid disambiguated by appending file index
233
+ // Emit one record per file; disambiguate uuid by appending file index
182
234
  let idx = 0;
183
235
  for (const [fp, fileTurns] of fileGroups) {
184
- const uuid = idx === 0 ? rootUuid : `${rootUuid}:${idx}`;
236
+ const uuid = idx === 0 ? baseUuid : `${baseUuid}:${idx}`;
185
237
  records.push(makeRecord(first, uuid, sumUsage(fileTurns), [fp]));
186
238
  idx++;
187
239
  }
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 };