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 +1 -1
- package/src/log-parser.js +96 -44
- package/src/state.js +12 -1
package/package.json
CHANGED
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
|
-
|
|
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)
|
|
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
|
-
//
|
|
124
|
-
|
|
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
|
|
130
|
-
if (!promptGroups.has(
|
|
131
|
-
promptGroups.get(
|
|
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 [
|
|
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,
|
|
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
|
-
|
|
165
|
-
|
|
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]
|
|
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
|
|
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 ?
|
|
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
|
-
|
|
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 };
|