aicp-tracker 1.3.0 → 1.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aicp-tracker",
3
- "version": "1.3.0",
3
+ "version": "1.3.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/daemon.js CHANGED
@@ -8,6 +8,7 @@ const { parseNewLines } = require('./log-parser');
8
8
  const wal = require('./wal');
9
9
  const { sendPending } = require('./sender');
10
10
  const { CONFIG_DIR } = require('./config');
11
+ const state = require('./state');
11
12
 
12
13
  const PID_FILE = path.join(CONFIG_DIR, 'daemon.pid');
13
14
 
@@ -17,9 +18,15 @@ const PROJECT_PATH = process.env.AICP_PROJECT_PATH
17
18
  || process.cwd();
18
19
 
19
20
  async function tick() {
20
- const files = findJsonlFiles(PROJECT_PATH);
21
+ const files = findJsonlFiles(PROJECT_PATH);
22
+ const cutoff = Date.now() - 10 * 24 * 60 * 60 * 1000; // 10-day backfill limit
21
23
  let total = 0;
24
+
22
25
  for (const f of files) {
26
+ // Never-seen files: only backfill if modified within the last 10 days
27
+ if (state.getOffset(f) === 0) {
28
+ try { if (fs.statSync(f).mtimeMs < cutoff) continue; } catch { continue; }
29
+ }
23
30
  const records = parseNewLines(f);
24
31
  if (records.length) {
25
32
  wal.append(records);
package/src/log-parser.js CHANGED
@@ -121,11 +121,13 @@ function parseNewLines(filePath) {
121
121
  fs.closeSync(fd);
122
122
  state.setOffset(filePath, offset + read);
123
123
 
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.
124
+ // Parse all entries. Only deduplicate by message.id for entries that carry
125
+ // usage data those are the only ones where duplicate lines are truly
126
+ // identical and would cause token double-counting. Entries without usage
127
+ // (pure tool-call lines, tool-result lines, etc.) are all kept so that no
128
+ // file-path information is accidentally discarded.
127
129
  const entries = [];
128
- const seenMsgIds = new Set();
130
+ const seenUsageMsgIds = new Set();
129
131
 
130
132
  for (const raw of buf.slice(0, read).toString('utf8').split('\n')) {
131
133
  const line = raw.trim();
@@ -135,36 +137,35 @@ function parseNewLines(filePath) {
135
137
  if (!entry.uuid) continue;
136
138
 
137
139
  const msgId = entry.message?.id;
138
- if (msgId) {
139
- if (seenMsgIds.has(msgId)) continue;
140
- seenMsgIds.add(msgId);
140
+ if (msgId && entry.message?.usage) {
141
+ if (seenUsageMsgIds.has(msgId)) continue;
142
+ seenUsageMsgIds.add(msgId);
141
143
  }
142
144
  entries.push(entry);
143
145
  }
144
146
 
145
147
  // ── Sequential grouping ───────────────────────────────────────────────────
146
- // A user entry with a promptId starts (or continues) a conversation turn.
147
- // Every assistant entry with usage that followseven 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.
148
+ // Any entry that carries a promptId opens (or re-opens) a group.
149
+ // Every subsequent entry regardless of type or role belongs to that
150
+ // group until the next promptId appears. This is the approach the user
151
+ // requested: "start from the record that has a promptId and go through all
152
+ // records down, counting them in, even if they don't have a promptId, until
153
+ // you find a new promptId."
150
154
  //
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.
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.
154
158
 
155
- let currentPromptId = carryOverPromptId;
156
- let latestPromptId = carryOverPromptId;
159
+ let currentPromptId = carryOverPromptId;
160
+ let latestPromptId = carryOverPromptId;
157
161
  let carryOverSeenInBatch = false;
158
162
 
159
- // groupOrder preserves insertion order so records are emitted chronologically.
160
- const groupOrder = [];
161
- const groupTurns = new Map(); // promptId → assistant entries with usage[]
162
- const groupAllEntries = new Map(); // promptId → ALL entries in this turn (for file extraction)
163
- const groupRef = new Map(); // promptId → reference entry (user msg or first asst turn)
163
+ const groupOrder = [];
164
+ const groupAllEntries = new Map(); // promptId → every non-prompt entry in the turn
165
+ const groupRef = new Map(); // promptId → the entry that opened the group
164
166
 
165
167
  function ensureGroup(pid, refEntry) {
166
- if (!groupTurns.has(pid)) {
167
- groupTurns.set(pid, []);
168
+ if (!groupAllEntries.has(pid)) {
168
169
  groupAllEntries.set(pid, []);
169
170
  groupRef.set(pid, refEntry);
170
171
  groupOrder.push(pid);
@@ -172,21 +173,16 @@ function parseNewLines(filePath) {
172
173
  }
173
174
 
174
175
  for (const entry of entries) {
175
- const isUser = entry.type === 'user' || entry.message?.role === 'user';
176
- const isAssistant = entry.type === 'assistant' || entry.message?.role === 'assistant';
177
-
178
- if (isUser && entry.promptId) {
176
+ if (entry.promptId) {
177
+ // This entry opens a new group (could be any role/type)
179
178
  if (entry.promptId === carryOverPromptId) carryOverSeenInBatch = true;
180
179
  currentPromptId = entry.promptId;
181
180
  latestPromptId = entry.promptId;
182
181
  ensureGroup(currentPromptId, entry);
183
- } else if (isAssistant && currentPromptId) {
184
- // Track ALL assistant entries for file extraction, but only add ones with usage for token counting
182
+ } else if (currentPromptId) {
183
+ // No promptId belongs to whatever group is currently open
185
184
  ensureGroup(currentPromptId, entry);
186
185
  groupAllEntries.get(currentPromptId).push(entry);
187
- if (entry.message?.usage) {
188
- groupTurns.get(currentPromptId).push(entry);
189
- }
190
186
  }
191
187
  }
192
188
 
@@ -196,36 +192,35 @@ function parseNewLines(filePath) {
196
192
  const records = [];
197
193
 
198
194
  for (const promptId of groupOrder) {
199
- const turns = groupTurns.get(promptId);
200
195
  const allEntries = groupAllEntries.get(promptId);
196
+ // Only entries that actually carry usage data contribute to token counts
197
+ const turns = allEntries.filter(e => e.message?.usage);
201
198
  if (!turns.length) continue;
202
199
 
203
200
  const ref = groupRef.get(promptId);
204
201
 
205
- // If this group is a pure carry-over (the previous batch already emitted a
206
- // record with uuid=promptId), suffix :carry so both records accumulate in
207
- // the DB rather than one being deduplicated away.
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.
208
204
  const isCarryOver = promptId === carryOverPromptId && !carryOverSeenInBatch;
209
205
  const baseUuid = isCarryOver ? `${promptId}:carry` : promptId;
210
206
 
211
- // Collect ALL file paths from ALL entries (including ones without usage),
212
- // not just from entries with usage
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.
213
209
  const allFiles = new Set();
214
- const turnFiles = turns.map(extractFilePath);
215
210
  for (const entry of allEntries) {
216
211
  for (const fp of extractAllFilePaths(entry)) {
217
212
  allFiles.add(fp);
218
213
  }
219
214
  }
220
- const hasAnyFile = allFiles.size > 0;
221
215
 
222
- if (!hasAnyFile) {
216
+ if (!allFiles.size) {
223
217
  records.push(makeRecord(ref, baseUuid, sumUsage(turns), []));
224
218
  continue;
225
219
  }
226
220
 
227
221
  // Attribute orphan turns (no file anchor) to the nearest anchor:
228
222
  // leading orphans → first anchor; trailing orphans → last anchor seen.
223
+ const turnFiles = turns.map(extractFilePath);
229
224
  const fileGroups = new Map();
230
225
  const firstAnchorIdx = turnFiles.findIndex(f => f !== null);
231
226
  let lastFile = turnFiles[firstAnchorIdx];
@@ -241,7 +236,6 @@ function parseNewLines(filePath) {
241
236
  let idx = 0;
242
237
  for (const [fp, fileTurns] of fileGroups) {
243
238
  const uuid = idx === 0 ? baseUuid : `${baseUuid}:${idx}`;
244
- // Include all files from the turn(s) in this group, not just the grouped file
245
239
  const filesForRecord = [...new Set([fp, ...fileTurns.flatMap(extractAllFilePaths)])];
246
240
  records.push(makeRecord(ref, uuid, sumUsage(fileTurns), filesForRecord));
247
241
  idx++;