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 +1 -1
- package/src/log-parser.js +133 -78
- package/src/state.js +12 -1
package/package.json
CHANGED
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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(
|
|
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:
|
|
61
|
+
sessionId: ref.sessionId || null,
|
|
78
62
|
uuid: rootUuid,
|
|
79
|
-
parentUuid:
|
|
80
|
-
timestamp:
|
|
81
|
-
gitBranch:
|
|
82
|
-
task_from_git_branch: extractTasksFromBranch(
|
|
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
|
-
//
|
|
114
|
-
|
|
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)
|
|
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
|
-
// ──
|
|
124
|
-
|
|
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
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
189
|
+
if (latestPromptId) state.setLastPromptId(filePath, latestPromptId);
|
|
190
|
+
|
|
191
|
+
// ── Emit records ──────────────────────────────────────────────────────────
|
|
135
192
|
const records = [];
|
|
136
193
|
|
|
137
|
-
for (const
|
|
138
|
-
turns
|
|
139
|
-
|
|
194
|
+
for (const promptId of groupOrder) {
|
|
195
|
+
const turns = groupTurns.get(promptId);
|
|
196
|
+
if (!turns.length) continue;
|
|
140
197
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
163
|
-
//
|
|
164
|
-
|
|
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]
|
|
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 ?
|
|
185
|
-
|
|
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
|
-
|
|
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 };
|