aicp-tracker 1.3.2 → 1.3.4
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 +1 -1
- package/src/log-parser.js +188 -84
- package/src/state.js +22 -1
package/package.json
CHANGED
package/src/log-parser.js
CHANGED
|
@@ -12,6 +12,17 @@ function extractTasksFromBranch(branch) {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
function sumUsage(entries) {
|
|
15
|
+
// Claude Code writes multiple JSONL lines per message.id (streaming snapshots).
|
|
16
|
+
// All snapshots for the same message.id have identical token counts, so we count
|
|
17
|
+
// only the LAST occurrence of each message.id to avoid double-counting.
|
|
18
|
+
// The last occurrence also has the most complete content (all tool calls present).
|
|
19
|
+
const lastIdxByMsgId = new Map();
|
|
20
|
+
for (let i = 0; i < entries.length; i++) {
|
|
21
|
+
const msgId = entries[i].message?.id;
|
|
22
|
+
if (msgId && entries[i].message?.usage) lastIdxByMsgId.set(msgId, i);
|
|
23
|
+
}
|
|
24
|
+
const countIdxSet = new Set(lastIdxByMsgId.values());
|
|
25
|
+
|
|
15
26
|
let input = 0, cc = 0, cr = 0, out = 0;
|
|
16
27
|
let ephUser = 0, ephAsst = 0, ephTool = 0;
|
|
17
28
|
let webSearch = 0, webFetch = 0;
|
|
@@ -19,17 +30,21 @@ function sumUsage(entries) {
|
|
|
19
30
|
const models = new Set();
|
|
20
31
|
const uuids = [];
|
|
21
32
|
|
|
22
|
-
for (
|
|
23
|
-
const
|
|
33
|
+
for (let i = 0; i < entries.length; i++) {
|
|
34
|
+
const e = entries[i];
|
|
35
|
+
const u = e.message?.usage;
|
|
36
|
+
if (!u) continue;
|
|
37
|
+
// Skip all but the last snapshot for each message.id
|
|
38
|
+
const msgId = e.message?.id;
|
|
39
|
+
if (msgId && !countIdxSet.has(i)) continue;
|
|
40
|
+
|
|
24
41
|
input += u.input_tokens || 0;
|
|
25
42
|
cc += u.cache_creation_input_tokens || 0;
|
|
26
43
|
cr += u.cache_read_input_tokens || 0;
|
|
27
44
|
out += u.output_tokens || 0;
|
|
28
|
-
// Ephemeral tokens may be at top level (older format) or in cache_creation object (newer format)
|
|
29
45
|
ephUser += u.ephemeral_user_input_tokens || u.cache_creation?.ephemeral_user_input_tokens || 0;
|
|
30
46
|
ephAsst += u.ephemeral_assistant_input_tokens || u.cache_creation?.ephemeral_assistant_input_tokens || 0;
|
|
31
47
|
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
48
|
ephUser += u.cache_creation?.ephemeral_5m_input_tokens || 0;
|
|
34
49
|
ephUser += u.cache_creation?.ephemeral_1h_input_tokens || 0;
|
|
35
50
|
if (!serviceTier && u.service_tier) serviceTier = u.service_tier;
|
|
@@ -43,7 +58,6 @@ function sumUsage(entries) {
|
|
|
43
58
|
if (block.name === 'web_fetch' || block.name === 'WebFetch') webFetch++;
|
|
44
59
|
}
|
|
45
60
|
}
|
|
46
|
-
// Also check server_tool_use object for web requests
|
|
47
61
|
const srvTool = u.server_tool_use;
|
|
48
62
|
if (srvTool) {
|
|
49
63
|
webSearch += srvTool.web_search_requests || 0;
|
|
@@ -55,6 +69,59 @@ function sumUsage(entries) {
|
|
|
55
69
|
return { input, cc, cr, out, ephUser, ephAsst, ephTool, webSearch, webFetch, serviceTier, speed, models, uuids };
|
|
56
70
|
}
|
|
57
71
|
|
|
72
|
+
// Merge two usage objects (both have models as Set, uuids as array)
|
|
73
|
+
function mergeUsages(a, b) {
|
|
74
|
+
return {
|
|
75
|
+
input: a.input + b.input,
|
|
76
|
+
cc: a.cc + b.cc,
|
|
77
|
+
cr: a.cr + b.cr,
|
|
78
|
+
out: a.out + b.out,
|
|
79
|
+
ephUser: a.ephUser + b.ephUser,
|
|
80
|
+
ephAsst: a.ephAsst + b.ephAsst,
|
|
81
|
+
ephTool: a.ephTool + b.ephTool,
|
|
82
|
+
webSearch: a.webSearch + b.webSearch,
|
|
83
|
+
webFetch: a.webFetch + b.webFetch,
|
|
84
|
+
serviceTier: a.serviceTier || b.serviceTier,
|
|
85
|
+
speed: a.speed || b.speed,
|
|
86
|
+
models: new Set([...a.models, ...b.models]),
|
|
87
|
+
uuids: [...a.uuids, ...b.uuids],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Convert usage (with Set) to JSON-serializable form for state storage
|
|
92
|
+
function usageToPlain(u) {
|
|
93
|
+
return {
|
|
94
|
+
input: u.input, cc: u.cc, cr: u.cr, out: u.out,
|
|
95
|
+
ephUser: u.ephUser, ephAsst: u.ephAsst, ephTool: u.ephTool,
|
|
96
|
+
webSearch: u.webSearch, webFetch: u.webFetch,
|
|
97
|
+
serviceTier: u.serviceTier, speed: u.speed,
|
|
98
|
+
models: [...u.models],
|
|
99
|
+
uuids: u.uuids,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Restore usage from plain state (convert models array back to Set)
|
|
104
|
+
function usageFromPlain(p) {
|
|
105
|
+
return {
|
|
106
|
+
input: p.input || 0, cc: p.cc || 0, cr: p.cr || 0, out: p.out || 0,
|
|
107
|
+
ephUser: p.ephUser || 0, ephAsst: p.ephAsst || 0, ephTool: p.ephTool || 0,
|
|
108
|
+
webSearch: p.webSearch || 0, webFetch: p.webFetch || 0,
|
|
109
|
+
serviceTier: p.serviceTier || null, speed: p.speed || null,
|
|
110
|
+
models: new Set(p.models || []),
|
|
111
|
+
uuids: p.uuids || [],
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Extract the minimal ref fields needed by makeRecord from any JSONL entry
|
|
116
|
+
function refFromEntry(entry) {
|
|
117
|
+
return {
|
|
118
|
+
sessionId: entry.sessionId || null,
|
|
119
|
+
timestamp: entry.timestamp || null,
|
|
120
|
+
gitBranch: entry.gitBranch || null,
|
|
121
|
+
parentUuid: entry.parentUuid || null,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
58
125
|
function makeRecord(ref, rootUuid, usage, filePaths) {
|
|
59
126
|
const { input, cc, cr, out, ephUser, ephAsst, ephTool, webSearch, webFetch, serviceTier, speed, models, uuids } = usage;
|
|
60
127
|
return {
|
|
@@ -107,13 +174,69 @@ function extractAllFilePaths(entry) {
|
|
|
107
174
|
return paths;
|
|
108
175
|
}
|
|
109
176
|
|
|
177
|
+
function extractGroupFilePaths(allEntries) {
|
|
178
|
+
const paths = [];
|
|
179
|
+
for (const entry of allEntries) {
|
|
180
|
+
for (const fp of extractAllFilePaths(entry)) paths.push(fp);
|
|
181
|
+
}
|
|
182
|
+
return [...new Set(paths)];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Emit a closed group with per-file splitting (used for groups fully contained in one tick)
|
|
186
|
+
function emitGroup(ref, baseUuid, turns, allEntries) {
|
|
187
|
+
const allFiles = new Set();
|
|
188
|
+
for (const entry of allEntries) {
|
|
189
|
+
for (const fp of extractAllFilePaths(entry)) allFiles.add(fp);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (!allFiles.size) {
|
|
193
|
+
return [makeRecord(ref, baseUuid, sumUsage(turns), [])];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const turnFiles = turns.map(extractFilePath);
|
|
197
|
+
const fileGroups = new Map();
|
|
198
|
+
const firstAnchorIdx = turnFiles.findIndex(f => f !== null);
|
|
199
|
+
let lastFile = turnFiles[firstAnchorIdx];
|
|
200
|
+
|
|
201
|
+
for (let i = 0; i < turns.length; i++) {
|
|
202
|
+
const file = turnFiles[i] !== null ? turnFiles[i]
|
|
203
|
+
: (i < firstAnchorIdx ? turnFiles[firstAnchorIdx] : lastFile);
|
|
204
|
+
if (!fileGroups.has(file)) fileGroups.set(file, []);
|
|
205
|
+
fileGroups.get(file).push(turns[i]);
|
|
206
|
+
if (turnFiles[i] !== null) lastFile = turnFiles[i];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const records = [];
|
|
210
|
+
let idx = 0;
|
|
211
|
+
for (const [fp, fileTurns] of fileGroups) {
|
|
212
|
+
const uuid = idx === 0 ? baseUuid : `${baseUuid}:${idx}`;
|
|
213
|
+
const filesForRecord = [...new Set([fp, ...fileTurns.flatMap(extractAllFilePaths)])];
|
|
214
|
+
records.push(makeRecord(ref, uuid, sumUsage(fileTurns), filesForRecord));
|
|
215
|
+
idx++;
|
|
216
|
+
}
|
|
217
|
+
return records;
|
|
218
|
+
}
|
|
219
|
+
|
|
110
220
|
function parseNewLines(filePath) {
|
|
111
221
|
const carryOverPromptId = state.getLastPromptId(filePath);
|
|
222
|
+
const pendingGroup = state.getPendingGroup(filePath);
|
|
112
223
|
|
|
113
224
|
const offset = state.getOffset(filePath);
|
|
114
225
|
let stat;
|
|
115
226
|
try { stat = fs.statSync(filePath); } catch { return []; }
|
|
116
|
-
|
|
227
|
+
|
|
228
|
+
// No new bytes — flush any pending group now (the session is done or paused long enough)
|
|
229
|
+
if (stat.size <= offset) {
|
|
230
|
+
if (pendingGroup) {
|
|
231
|
+
const usage = usageFromPlain(pendingGroup.usage);
|
|
232
|
+
if (usage.uuids.length > 0) {
|
|
233
|
+
state.clearPendingGroup(filePath);
|
|
234
|
+
return [makeRecord(pendingGroup.ref, pendingGroup.promptId, usage, pendingGroup.filePaths || [])];
|
|
235
|
+
}
|
|
236
|
+
state.clearPendingGroup(filePath);
|
|
237
|
+
}
|
|
238
|
+
return [];
|
|
239
|
+
}
|
|
117
240
|
|
|
118
241
|
const fd = fs.openSync(filePath, 'r');
|
|
119
242
|
const buf = Buffer.alloc(stat.size - offset);
|
|
@@ -121,13 +244,10 @@ function parseNewLines(filePath) {
|
|
|
121
244
|
fs.closeSync(fd);
|
|
122
245
|
state.setOffset(filePath, offset + read);
|
|
123
246
|
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
// (pure tool-call lines, tool-result lines, etc.) are all kept so that no
|
|
128
|
-
// file-path information is accidentally discarded.
|
|
247
|
+
// Keep ALL entries — deduplication by message.id happens inside sumUsage.
|
|
248
|
+
// We must retain every line here because later snapshots of the same message.id
|
|
249
|
+
// contain the Edit/Write tool calls that earlier snapshots lack.
|
|
129
250
|
const entries = [];
|
|
130
|
-
const seenUsageMsgIds = new Set();
|
|
131
251
|
|
|
132
252
|
for (const raw of buf.slice(0, read).toString('utf8').split('\n')) {
|
|
133
253
|
const line = raw.trim();
|
|
@@ -135,34 +255,19 @@ function parseNewLines(filePath) {
|
|
|
135
255
|
let entry;
|
|
136
256
|
try { entry = JSON.parse(line); } catch { continue; }
|
|
137
257
|
if (!entry.uuid) continue;
|
|
138
|
-
|
|
139
|
-
const msgId = entry.message?.id;
|
|
140
|
-
if (msgId && entry.message?.usage) {
|
|
141
|
-
if (seenUsageMsgIds.has(msgId)) continue;
|
|
142
|
-
seenUsageMsgIds.add(msgId);
|
|
143
|
-
}
|
|
144
258
|
entries.push(entry);
|
|
145
259
|
}
|
|
146
260
|
|
|
147
261
|
// ── Sequential grouping ───────────────────────────────────────────────────
|
|
148
262
|
// Any entry that carries a promptId opens (or re-opens) a group.
|
|
149
|
-
// Every subsequent entry
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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;
|
|
263
|
+
// Every subsequent entry belongs to that group until the next promptId appears.
|
|
264
|
+
|
|
265
|
+
let currentPromptId = carryOverPromptId;
|
|
266
|
+
let latestPromptId = carryOverPromptId;
|
|
162
267
|
|
|
163
268
|
const groupOrder = [];
|
|
164
|
-
const groupAllEntries = new Map();
|
|
165
|
-
const groupRef = new Map();
|
|
269
|
+
const groupAllEntries = new Map();
|
|
270
|
+
const groupRef = new Map();
|
|
166
271
|
|
|
167
272
|
function ensureGroup(pid, refEntry) {
|
|
168
273
|
if (!groupAllEntries.has(pid)) {
|
|
@@ -174,16 +279,13 @@ function parseNewLines(filePath) {
|
|
|
174
279
|
|
|
175
280
|
for (const entry of entries) {
|
|
176
281
|
if (entry.promptId) {
|
|
177
|
-
// This entry opens a new group (could be any role/type)
|
|
178
|
-
if (entry.promptId === carryOverPromptId) carryOverSeenInBatch = true;
|
|
179
282
|
currentPromptId = entry.promptId;
|
|
180
283
|
latestPromptId = entry.promptId;
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
// No promptId → belongs to whatever group is currently open
|
|
184
|
-
ensureGroup(currentPromptId, entry);
|
|
185
|
-
groupAllEntries.get(currentPromptId).push(entry);
|
|
284
|
+
} else if (!currentPromptId) {
|
|
285
|
+
continue;
|
|
186
286
|
}
|
|
287
|
+
ensureGroup(currentPromptId, entry);
|
|
288
|
+
groupAllEntries.get(currentPromptId).push(entry);
|
|
187
289
|
}
|
|
188
290
|
|
|
189
291
|
if (latestPromptId) state.setLastPromptId(filePath, latestPromptId);
|
|
@@ -191,54 +293,56 @@ function parseNewLines(filePath) {
|
|
|
191
293
|
// ── Emit records ──────────────────────────────────────────────────────────
|
|
192
294
|
const records = [];
|
|
193
295
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const
|
|
198
|
-
if (
|
|
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;
|
|
296
|
+
// If a pending group exists but its promptId doesn't appear in this batch at all,
|
|
297
|
+
// it has been superseded by new prompts — emit it now before processing new groups.
|
|
298
|
+
if (pendingGroup && groupOrder.length > 0 && !groupOrder.includes(pendingGroup.promptId)) {
|
|
299
|
+
const usage = usageFromPlain(pendingGroup.usage);
|
|
300
|
+
if (usage.uuids.length > 0) {
|
|
301
|
+
records.push(makeRecord(pendingGroup.ref, pendingGroup.promptId, usage, pendingGroup.filePaths || []));
|
|
219
302
|
}
|
|
303
|
+
state.clearPendingGroup(filePath);
|
|
304
|
+
}
|
|
220
305
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const
|
|
224
|
-
const
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
306
|
+
for (let gi = 0; gi < groupOrder.length; gi++) {
|
|
307
|
+
const promptId = groupOrder[gi];
|
|
308
|
+
const isLast = gi === groupOrder.length - 1;
|
|
309
|
+
const allEntries = groupAllEntries.get(promptId);
|
|
310
|
+
const turns = allEntries.filter(e => e.message?.usage);
|
|
311
|
+
const hasPending = pendingGroup && pendingGroup.promptId === promptId;
|
|
312
|
+
|
|
313
|
+
let effectiveUsage, effectiveFilePaths, effectiveRef;
|
|
314
|
+
|
|
315
|
+
if (hasPending) {
|
|
316
|
+
// Merge accumulated pending data with new entries from this tick
|
|
317
|
+
const pendingUsage = usageFromPlain(pendingGroup.usage);
|
|
318
|
+
effectiveUsage = turns.length > 0 ? mergeUsages(pendingUsage, sumUsage(turns)) : pendingUsage;
|
|
319
|
+
effectiveFilePaths = [...new Set([...(pendingGroup.filePaths || []), ...extractGroupFilePaths(allEntries)])];
|
|
320
|
+
effectiveRef = pendingGroup.ref;
|
|
321
|
+
} else {
|
|
322
|
+
if (!turns.length) continue;
|
|
323
|
+
effectiveUsage = sumUsage(turns);
|
|
324
|
+
effectiveFilePaths = extractGroupFilePaths(allEntries);
|
|
325
|
+
effectiveRef = refFromEntry(groupRef.get(promptId));
|
|
234
326
|
}
|
|
235
327
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
328
|
+
if (isLast) {
|
|
329
|
+
// Group may still grow in the next tick — save as pending, don't emit yet
|
|
330
|
+
state.setPendingGroup(filePath, {
|
|
331
|
+
promptId,
|
|
332
|
+
ref: effectiveRef,
|
|
333
|
+
usage: usageToPlain(effectiveUsage),
|
|
334
|
+
filePaths: effectiveFilePaths,
|
|
335
|
+
});
|
|
336
|
+
} else {
|
|
337
|
+
// Group is closed (a subsequent promptId appeared) — emit now
|
|
338
|
+
if (hasPending) state.clearPendingGroup(filePath);
|
|
339
|
+
if (hasPending) {
|
|
340
|
+
// Cross-tick group: emit as one record (tokens already merged, no per-file split)
|
|
341
|
+
records.push(makeRecord(effectiveRef, promptId, effectiveUsage, effectiveFilePaths));
|
|
342
|
+
} else {
|
|
343
|
+
// Single-tick group: use per-file splitting as before
|
|
344
|
+
records.push(...emitGroup(effectiveRef, promptId, turns, allEntries));
|
|
345
|
+
}
|
|
242
346
|
}
|
|
243
347
|
}
|
|
244
348
|
|
package/src/state.js
CHANGED
|
@@ -44,4 +44,25 @@ function setLastPromptId(filePath, promptId) {
|
|
|
44
44
|
save();
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
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 };
|