@agent-finops/core 0.5.8 → 0.6.0

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.
@@ -3,17 +3,67 @@ import { basename, isAbsolute, join, resolve, sep } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  import { estimateTokenCostUsd } from "./modelPricing.js";
5
5
  import { redactSecrets } from "./discovery.js";
6
+ import { createCodexInvocationCollector } from "./toolInvocations.js";
7
+ /**
8
+ * Resolve the repository root most recently observed in transcript metadata.
9
+ *
10
+ * CLI, MCP, and Glance use this only to scope read-only project inventory when
11
+ * the caller did not explicitly choose a path. Absolute working directories
12
+ * never enter rendered output.
13
+ */
14
+ export function latestObservedWorkingDirectory(calls) {
15
+ return calls
16
+ .filter((call) => call.workingDirectory)
17
+ .sort((left, right) => right.timestamp.localeCompare(left.timestamp))[0]
18
+ ?.workingDirectory;
19
+ }
20
+ /**
21
+ * Codex rollout/compaction files can repeat the same session's cumulative
22
+ * token counter. Keep only the latest snapshot per session so financial value,
23
+ * Glance, and project totals never add cumulative checkpoints together.
24
+ * Turn-scoped Claude calls and calls without a stable session id are retained.
25
+ */
26
+ export function dedupeCumulativeSessionCalls(calls) {
27
+ const retained = [];
28
+ const cumulative = new Map();
29
+ for (const call of calls) {
30
+ if (call.usageScope !== "session_cumulative" || !call.sessionId) {
31
+ retained.push(call);
32
+ continue;
33
+ }
34
+ const key = `${call.agent}:${call.sessionId}`;
35
+ const prior = cumulative.get(key);
36
+ if (!prior || isLaterCumulativeSnapshot(call, prior)) {
37
+ cumulative.set(key, call);
38
+ }
39
+ }
40
+ return [...retained, ...cumulative.values()];
41
+ }
42
+ function isLaterCumulativeSnapshot(candidate, prior) {
43
+ const timestampOrder = candidate.timestamp.localeCompare(prior.timestamp);
44
+ if (timestampOrder !== 0)
45
+ return timestampOrder > 0;
46
+ return totalUsageTokens(candidate.usage) > totalUsageTokens(prior.usage);
47
+ }
48
+ function totalUsageTokens(usage) {
49
+ return usage.inputTokens +
50
+ usage.outputTokens +
51
+ (usage.cacheReadTokens ?? 0) +
52
+ (usage.cacheWrite5mTokens ?? 0) +
53
+ (usage.cacheWrite1hTokens ?? 0);
54
+ }
6
55
  /** Parse one Claude Code transcript (JSONL). Exported for tests. */
7
- export function parseClaudeCodeTranscript(content, filePath = "") {
56
+ export function parseClaudeCodeTranscript(content, filePath = "", sinceMs, onDiagnostic) {
8
57
  const calls = [];
9
58
  const seen = new Set();
10
- const prompts = [];
11
- const fileCounts = new Map();
59
+ const pendingPrompts = [];
60
+ const activityEvidence = new Map();
12
61
  let title;
13
62
  let lastPrompt;
14
- let toolCallCount = 0;
63
+ let latestActivityKey;
15
64
  let isSubagent = filePath.split(sep).includes("subagents");
16
65
  let parentSessionId;
66
+ let malformedLines = 0;
17
67
  for (const line of content.split("\n")) {
18
68
  if (!line.trim())
19
69
  continue;
@@ -22,6 +72,7 @@ export function parseClaudeCodeTranscript(content, filePath = "") {
22
72
  entry = JSON.parse(line);
23
73
  }
24
74
  catch {
75
+ malformedLines += 1;
25
76
  continue;
26
77
  }
27
78
  if (!isRecord(entry))
@@ -39,15 +90,7 @@ export function parseClaudeCodeTranscript(content, filePath = "") {
39
90
  if (entry.type === "user" && message) {
40
91
  for (const prompt of textValues(message.content)) {
41
92
  if (isHumanPrompt(prompt))
42
- prompts.push(prompt);
43
- }
44
- }
45
- if (entry.type === "assistant" && message) {
46
- for (const item of recordValues(message.content)) {
47
- if (item.type !== "tool_use")
48
- continue;
49
- toolCallCount += 1;
50
- collectToolFiles(item.input, fileCounts);
93
+ pendingPrompts.push(prompt);
51
94
  }
52
95
  }
53
96
  if (entry.type !== "assistant")
@@ -63,57 +106,107 @@ export function parseClaudeCodeTranscript(content, filePath = "") {
63
106
  if (dedupeKey !== ":" && seen.has(dedupeKey))
64
107
  continue;
65
108
  seen.add(dedupeKey);
109
+ const timestamp = toIso(stringOf(entry.timestamp)) ?? new Date(0).toISOString();
110
+ if (typeof sinceMs === "number" && Date.parse(timestamp) < sinceMs) {
111
+ // These prompts led to a call outside the selected evidence window. Do
112
+ // not let them become the focus of a later in-window project/call.
113
+ pendingPrompts.length = 0;
114
+ continue;
115
+ }
66
116
  const cacheCreation = isRecord(usage.cache_creation) ? usage.cache_creation : undefined;
67
117
  const write5m = numberOf(cacheCreation?.ephemeral_5m_input_tokens);
68
118
  const write1h = numberOf(cacheCreation?.ephemeral_1h_input_tokens);
69
119
  const writeTotal = numberOf(usage.cache_creation_input_tokens) ?? 0;
70
- calls.push({
120
+ const parsedUsage = {
121
+ inputTokens: numberOf(usage.input_tokens) ?? 0,
122
+ outputTokens: numberOf(usage.output_tokens) ?? 0,
123
+ cacheReadTokens: numberOf(usage.cache_read_input_tokens) ?? 0,
124
+ // Prefer the 5m/1h breakdown; fall back to the total as 5m (cheaper bound).
125
+ cacheWrite5mTokens: write5m ?? writeTotal,
126
+ cacheWrite1hTokens: write1h ?? 0
127
+ };
128
+ const workingDirectory = absoluteWorkingDirectory(stringOf(entry.cwd));
129
+ const project = projectFromCwd(workingDirectory) ?? projectFromTranscriptPath(filePath);
130
+ const sessionId = stringOf(entry.sessionId);
131
+ const call = {
71
132
  agent: "claude-code",
72
133
  model: stringOf(message.model) ?? "claude-code",
73
- timestamp: toIso(stringOf(entry.timestamp)) ?? new Date(0).toISOString(),
74
- project: projectFromCwd(stringOf(entry.cwd)) ?? projectFromTranscriptPath(filePath),
75
- sessionId: stringOf(entry.sessionId),
76
- usage: {
77
- inputTokens: numberOf(usage.input_tokens) ?? 0,
78
- outputTokens: numberOf(usage.output_tokens) ?? 0,
79
- cacheReadTokens: numberOf(usage.cache_read_input_tokens) ?? 0,
80
- // Prefer the 5m/1h breakdown; fall back to the total as 5m (cheaper bound).
81
- cacheWrite5mTokens: write5m ?? writeTotal,
82
- cacheWrite1hTokens: write1h ?? 0
83
- }
84
- });
134
+ timestamp,
135
+ project,
136
+ workingDirectory,
137
+ sessionId,
138
+ latestTurnUsage: toTurnUsage(parsedUsage, "assistant_message_usage"),
139
+ usageScope: "turn",
140
+ usage: parsedUsage
141
+ };
142
+ calls.push(call);
143
+ const activityKey = localActivityScopeKey(sessionId, workingDirectory, project);
144
+ const evidence = activityEvidence.get(activityKey) ?? {
145
+ prompts: [],
146
+ files: new Map(),
147
+ toolCallCount: 0
148
+ };
149
+ evidence.prompts.push(...pendingPrompts);
150
+ pendingPrompts.length = 0;
151
+ for (const item of recordValues(message.content)) {
152
+ if (item.type !== "tool_use")
153
+ continue;
154
+ evidence.toolCallCount += 1;
155
+ collectToolFiles(item.input, evidence.files);
156
+ }
157
+ activityEvidence.set(activityKey, evidence);
158
+ latestActivityKey = activityKey;
85
159
  }
86
160
  const fallbackPrompt = lastPrompt && isHumanPrompt(lastPrompt) ? lastPrompt : undefined;
87
- const activity = buildLocalAgentActivity({
88
- title,
89
- prompts: prompts.length > 0 ? prompts : fallbackPrompt ? [fallbackPrompt] : [],
90
- files: fileCounts,
91
- toolCallCount,
92
- project: calls[0]?.project ?? projectFromTranscriptPath(filePath),
93
- isSubagent,
94
- parentSessionId
95
- });
96
- if (activity) {
97
- for (const call of calls)
98
- call.activity = activity;
161
+ const activities = new Map();
162
+ for (const [activityKey, evidence] of activityEvidence) {
163
+ const matchingCall = calls.find((call) => (localActivityScopeKey(call.sessionId, call.workingDirectory, call.project) === activityKey));
164
+ const isLatest = activityKey === latestActivityKey;
165
+ const activity = buildLocalAgentActivity({
166
+ title: isLatest ? title : undefined,
167
+ prompts: evidence.prompts.length > 0
168
+ ? evidence.prompts
169
+ : isLatest && fallbackPrompt
170
+ ? [fallbackPrompt]
171
+ : [],
172
+ files: evidence.files,
173
+ toolCallCount: evidence.toolCallCount,
174
+ project: matchingCall?.project ?? projectFromTranscriptPath(filePath),
175
+ isSubagent,
176
+ parentSessionId
177
+ });
178
+ if (activity)
179
+ activities.set(activityKey, activity);
180
+ }
181
+ for (const call of calls) {
182
+ call.activity = activities.get(localActivityScopeKey(call.sessionId, call.workingDirectory, call.project));
183
+ }
184
+ if (malformedLines > 0) {
185
+ onDiagnostic?.({ code: "malformed_jsonl", count: malformedLines });
99
186
  }
100
187
  return calls;
101
188
  }
102
189
  /** Parse one Codex rollout file (JSONL event stream). Exported for tests. */
103
- export function parseCodexRollout(content) {
190
+ export function parseCodexRollout(content, onEntry, onDiagnostic) {
104
191
  let model;
105
- let cwd;
192
+ let rootCwd;
106
193
  const toolWorkdirs = new Map();
107
194
  let sessionId;
195
+ let rootSessionMetaSeen = false;
108
196
  let startedAt;
197
+ let rootStartedAtMs;
198
+ let rootTaskStarted = false;
199
+ let inheritedUsageBaseline;
109
200
  let lastActivityAt;
110
201
  let lastTotal;
202
+ let lastTurn;
111
203
  let lastRateLimits;
112
204
  const prompts = [];
113
205
  const fileCounts = new Map();
114
206
  let toolCallCount = 0;
115
207
  let isSubagent = false;
116
208
  let parentSessionId;
209
+ let malformedLines = 0;
117
210
  for (const line of content.split("\n")) {
118
211
  if (!line.trim())
119
212
  continue;
@@ -122,21 +215,50 @@ export function parseCodexRollout(content) {
122
215
  entry = JSON.parse(line);
123
216
  }
124
217
  catch {
218
+ malformedLines += 1;
125
219
  continue;
126
220
  }
127
221
  if (!isRecord(entry))
128
222
  continue;
223
+ onEntry?.(entry);
129
224
  const payload = isRecord(entry.payload) ? entry.payload : undefined;
130
- if (entry.type === "session_meta" && payload) {
131
- sessionId = stringOf(payload.id) ?? sessionId;
132
- cwd = stringOf(payload.cwd) ?? cwd;
133
- startedAt = toIso(stringOf(payload.timestamp) ?? stringOf(entry.timestamp)) ?? startedAt;
225
+ if (entry.type === "session_meta" && payload && !rootSessionMetaSeen) {
226
+ // A forked/subagent rollout can embed the parent transcript, including
227
+ // many later session_meta records. The first metadata record belongs to
228
+ // this rollout file; later records are nested/history evidence and must
229
+ // never replace the financial session identity or root cwd.
230
+ rootSessionMetaSeen = true;
231
+ sessionId = stringOf(payload.id);
232
+ rootCwd = stringOf(payload.cwd);
233
+ startedAt = toIso(stringOf(payload.timestamp) ?? stringOf(entry.timestamp));
234
+ rootStartedAtMs = timestampMilliseconds(payload.timestamp ?? entry.timestamp);
134
235
  isSubagent = stringOf(payload.thread_source) === "subagent" || isRecord(payload.source) && "subagent" in payload.source;
135
- parentSessionId = stringOf(payload.parent_thread_id) ?? parentSessionId;
236
+ parentSessionId = stringOf(payload.parent_thread_id);
136
237
  }
137
238
  if (entry.type === "turn_context" && payload) {
138
239
  model = stringOf(payload.model) ?? model;
139
- cwd = stringOf(payload.cwd) ?? cwd;
240
+ rootCwd ??= stringOf(payload.cwd);
241
+ }
242
+ if (isSubagent &&
243
+ !rootTaskStarted &&
244
+ payload?.type === "task_started" &&
245
+ isRootSpecificTaskStart(payload.started_at, rootStartedAtMs)) {
246
+ // Forked Codex rollouts copy the parent's complete event history after
247
+ // the child's root session_meta. The cumulative counter immediately
248
+ // before the child's first task is the inherited baseline, not child
249
+ // usage. Reset qualitative evidence at the same boundary so parent
250
+ // prompts/files cannot become the child's focus.
251
+ inheritedUsageBaseline = lastTotal;
252
+ lastTotal = undefined;
253
+ rootTaskStarted = true;
254
+ prompts.length = 0;
255
+ fileCounts.clear();
256
+ toolWorkdirs.clear();
257
+ toolCallCount = 0;
258
+ model = undefined;
259
+ lastTurn = undefined;
260
+ lastRateLimits = undefined;
261
+ lastActivityAt = toIso(stringOf(entry.timestamp)) ?? startedAt;
140
262
  }
141
263
  if (payload?.type === "function_call" || payload?.type === "custom_tool_call") {
142
264
  toolCallCount += 1;
@@ -165,21 +287,58 @@ export function parseCodexRollout(content) {
165
287
  const eventTimestamp = toIso(stringOf(entry.timestamp)) ?? lastActivityAt ?? startedAt;
166
288
  const info = isRecord(payload.info) ? payload.info : undefined;
167
289
  const total = info && isRecord(info.total_token_usage) ? info.total_token_usage : undefined;
290
+ const turn = info && isRecord(info.last_token_usage) ? info.last_token_usage : undefined;
168
291
  if (total) {
169
292
  lastTotal = total;
170
293
  lastActivityAt = eventTimestamp;
171
294
  }
295
+ if (turn) {
296
+ lastTurn = turn;
297
+ lastActivityAt = eventTimestamp;
298
+ }
172
299
  const rateLimits = parseCodexRateLimits(payload.rate_limits, eventTimestamp);
173
300
  if (rateLimits) {
174
301
  lastRateLimits = rateLimits;
175
302
  }
176
303
  }
177
304
  }
178
- if (!lastTotal)
305
+ // A fork without a recognized root-task boundary is ambiguous: older Codex
306
+ // formats may contain only inherited parent history. Omitting that child is
307
+ // safer than charging the parent cumulative counter again. Likewise, a
308
+ // recognized boundary with no later total_token_usage is not a financial
309
+ // call yet.
310
+ if (malformedLines > 0) {
311
+ onDiagnostic?.({ code: "malformed_jsonl", count: malformedLines });
312
+ }
313
+ if (!lastTotal || isSubagent && !rootTaskStarted)
179
314
  return [];
180
- const input = numberOf(lastTotal.input_tokens) ?? 0;
181
- const cached = numberOf(lastTotal.cached_input_tokens) ?? 0;
182
- const project = projectFromCwd(dominantCodexCwd(cwd, toolWorkdirs));
315
+ const rawInput = numberOf(lastTotal.input_tokens);
316
+ const rawOutput = numberOf(lastTotal.output_tokens);
317
+ const rawCached = numberOf(lastTotal.cached_input_tokens);
318
+ const rawReportedTotal = numberOf(lastTotal.total_tokens);
319
+ const baselineInput = numberOf(inheritedUsageBaseline?.input_tokens);
320
+ const baselineOutput = numberOf(inheritedUsageBaseline?.output_tokens);
321
+ const baselineReportedTotal = numberOf(inheritedUsageBaseline?.total_tokens);
322
+ const currentComponentsComplete = rawInput !== undefined && rawOutput !== undefined && !((rawReportedTotal ?? 0) > 0 && rawInput === 0 && rawOutput === 0);
323
+ const baselineComponentsComplete = !inheritedUsageBaseline || (baselineInput !== undefined && baselineOutput !== undefined && !((baselineReportedTotal ?? 0) > 0 && baselineInput === 0 && baselineOutput === 0));
324
+ const usageSupport = currentComponentsComplete && baselineComponentsComplete
325
+ ? "complete"
326
+ : "unsupported_token_shape";
327
+ if (usageSupport === "unsupported_token_shape") {
328
+ onDiagnostic?.({ code: "unsupported_token_shape", count: 1 });
329
+ }
330
+ const input = Math.max(0, (rawInput ?? 0) - (baselineInput ?? 0));
331
+ const cached = Math.max(0, (rawCached ?? 0) -
332
+ (numberOf(inheritedUsageBaseline?.cached_input_tokens) ?? 0));
333
+ const output = Math.max(0, (rawOutput ?? 0) - (baselineOutput ?? 0));
334
+ const reportedTotalTokens = rawReportedTotal === undefined
335
+ ? undefined
336
+ : Math.max(0, rawReportedTotal - (baselineReportedTotal ?? 0));
337
+ const latestTurnUsage = lastTurn
338
+ ? codexTurnUsage(lastTurn)
339
+ : undefined;
340
+ const workingDirectory = absoluteWorkingDirectory(dominantCodexCwd(rootCwd, toolWorkdirs));
341
+ const project = projectFromCwd(workingDirectory);
183
342
  const activity = buildLocalAgentActivity({
184
343
  prompts,
185
344
  files: fileCounts,
@@ -194,13 +353,18 @@ export function parseCodexRollout(content) {
194
353
  timestamp: lastActivityAt ?? startedAt ?? new Date(0).toISOString(),
195
354
  startedAt,
196
355
  project,
356
+ workingDirectory,
197
357
  sessionId,
198
358
  rateLimits: lastRateLimits,
199
359
  activity,
360
+ latestTurnUsage,
361
+ usageScope: "session_cumulative",
362
+ usageSupport,
363
+ ...(reportedTotalTokens !== undefined ? { reportedTotalTokens } : {}),
200
364
  usage: {
201
365
  // Codex input_tokens INCLUDES cached tokens; split them out.
202
366
  inputTokens: Math.max(0, input - cached),
203
- outputTokens: numberOf(lastTotal.output_tokens) ?? 0,
367
+ outputTokens: output,
204
368
  cacheReadTokens: cached
205
369
  }
206
370
  }];
@@ -275,38 +439,78 @@ export async function loadLocalAgentUsage(options = {}) {
275
439
  const claudeDir = options.claudeProjectsDir ?? join(home, ".claude", "projects");
276
440
  const codexDir = options.codexSessionsDir ?? join(home, ".codex", "sessions");
277
441
  const calls = [];
442
+ const codexInvocationFiles = options.collectCodexInvocationEvidence
443
+ ? []
444
+ : undefined;
278
445
  let filesParsed = 0;
279
- for (const file of await listJsonlFiles(claudeDir)) {
280
- const content = await readFile(file, "utf8").catch(() => "");
446
+ const since = options.sinceIso ? Date.parse(options.sinceIso) : undefined;
447
+ const sinceMs = typeof since === "number" && Number.isFinite(since) ? since : undefined;
448
+ const diagnostics = [];
449
+ const sourceScans = [
450
+ emptySourceScan("claude-code"),
451
+ emptySourceScan("codex")
452
+ ];
453
+ const claudeScan = sourceScans[0];
454
+ const codexScan = sourceScans[1];
455
+ for (const file of await listJsonlFiles(claudeDir, claudeScan, diagnostics)) {
456
+ let content;
457
+ try {
458
+ content = await readFile(file, "utf8");
459
+ }
460
+ catch (error) {
461
+ recordUnreadableFile("claude-code", claudeScan, diagnostics, error);
462
+ continue;
463
+ }
281
464
  if (!content)
282
465
  continue;
283
466
  filesParsed += 1;
284
- calls.push(...parseClaudeCodeTranscript(content, file));
467
+ claudeScan.filesParsed += 1;
468
+ calls.push(...parseClaudeCodeTranscript(content, file, sinceMs, (diagnostic) => {
469
+ recordParseDiagnostic("claude-code", claudeScan, diagnostics, diagnostic);
470
+ }));
285
471
  }
286
- for (const file of await listJsonlFiles(codexDir)) {
472
+ for (const file of await listJsonlFiles(codexDir, codexScan, diagnostics)) {
287
473
  if (!basename(file).startsWith("rollout-"))
288
474
  continue;
289
- const content = await readFile(file, "utf8").catch(() => "");
475
+ let content;
476
+ try {
477
+ content = await readFile(file, "utf8");
478
+ }
479
+ catch (error) {
480
+ recordUnreadableFile("codex", codexScan, diagnostics, error);
481
+ continue;
482
+ }
290
483
  if (!content)
291
484
  continue;
292
485
  filesParsed += 1;
293
- calls.push(...parseCodexRollout(content));
486
+ codexScan.filesParsed += 1;
487
+ const collector = codexInvocationFiles
488
+ ? createCodexInvocationCollector(sinceMs)
489
+ : undefined;
490
+ calls.push(...parseCodexRollout(content, collector?.consume, (diagnostic) => {
491
+ recordParseDiagnostic("codex", codexScan, diagnostics, diagnostic);
492
+ }));
493
+ if (collector)
494
+ codexInvocationFiles.push(collector.finish());
294
495
  }
295
- const since = options.sinceIso ? Date.parse(options.sinceIso) : undefined;
296
- const filtered = typeof since === "number" && Number.isFinite(since)
297
- ? calls.filter((call) => Date.parse(call.timestamp) >= since)
298
- : calls;
496
+ const normalizedCalls = dedupeCumulativeSessionCalls(calls);
497
+ const filtered = typeof sinceMs === "number"
498
+ ? normalizedCalls.filter((call) => Date.parse(call.timestamp) >= sinceMs)
499
+ : normalizedCalls;
299
500
  return {
300
501
  records: aggregateCalls(filtered),
301
502
  calls: filtered,
302
503
  filesParsed,
303
- agentsDetected: [...new Set(filtered.map((call) => call.agent))]
504
+ agentsDetected: [...new Set(filtered.map((call) => call.agent))],
505
+ sourceScans,
506
+ diagnostics,
507
+ ...(codexInvocationFiles ? { codexInvocationFiles } : {})
304
508
  };
305
509
  }
306
510
  /** Aggregate per-call usage into one UsageRecord per day+agent+model+project. */
307
511
  export function aggregateCalls(calls) {
308
512
  const groups = new Map();
309
- for (const call of calls) {
513
+ for (const call of dedupeCumulativeSessionCalls(calls)) {
310
514
  const day = call.timestamp.slice(0, 10);
311
515
  const key = [day, call.agent, call.model, call.project ?? "unattributed"].join("|");
312
516
  groups.set(key, [...(groups.get(key) ?? []), call]);
@@ -321,8 +525,9 @@ export function aggregateCalls(calls) {
321
525
  cacheWrite5mTokens: sum(groupCalls, (c) => c.usage.cacheWrite5mTokens ?? 0),
322
526
  cacheWrite1hTokens: sum(groupCalls, (c) => c.usage.cacheWrite1hTokens ?? 0)
323
527
  };
324
- const amountUsd = estimateTokenCostUsd(model, usage);
325
- const priced = typeof amountUsd === "number";
528
+ const usageSupported = groupCalls.every((call) => call.usageSupport !== "unsupported_token_shape");
529
+ const amountUsd = usageSupported ? estimateTokenCostUsd(model, usage) : undefined;
530
+ const priced = usageSupported && typeof amountUsd === "number";
326
531
  records.push({
327
532
  id: slug(["local", agent, day, model, project].join("-")),
328
533
  timestamp: new Date(`${day}T00:00:00Z`).toISOString(),
@@ -344,31 +549,138 @@ export function aggregateCalls(calls) {
344
549
  projectId: project === "unattributed" || project === "(home)" ? undefined : project,
345
550
  agentId: agent,
346
551
  providerCostType: "local_agent_logs",
552
+ usageGranularity: "daily_aggregate",
347
553
  quantity: groupCalls.length,
348
554
  operation: `${agent} sessions`
349
555
  });
350
556
  }
351
557
  return records.sort((left, right) => left.id.localeCompare(right.id));
352
558
  }
353
- async function listJsonlFiles(root) {
354
- const exists = await stat(root).then((s) => s.isDirectory()).catch(() => false);
355
- if (!exists)
559
+ async function listJsonlFiles(root, scan, diagnostics) {
560
+ let rootStat;
561
+ try {
562
+ rootStat = await stat(root);
563
+ }
564
+ catch (error) {
565
+ if (isNodeError(error, "ENOENT")) {
566
+ scan.directoryStatus = "missing";
567
+ diagnostics.push({
568
+ agent: scan.agent,
569
+ code: "directory_missing",
570
+ severity: "info",
571
+ message: `${agentLabel(scan.agent)} transcript directory was not found.`,
572
+ count: 1
573
+ });
574
+ }
575
+ else {
576
+ scan.directoryStatus = "unreadable";
577
+ diagnostics.push({
578
+ agent: scan.agent,
579
+ code: "directory_unreadable",
580
+ severity: "error",
581
+ message: `${agentLabel(scan.agent)} transcript directory could not be read${errorCodeSuffix(error)}.`,
582
+ count: 1
583
+ });
584
+ }
585
+ return [];
586
+ }
587
+ if (!rootStat.isDirectory()) {
588
+ scan.directoryStatus = "unreadable";
589
+ diagnostics.push({
590
+ agent: scan.agent,
591
+ code: "directory_unreadable",
592
+ severity: "error",
593
+ message: `${agentLabel(scan.agent)} transcript path is not a readable directory.`,
594
+ count: 1
595
+ });
356
596
  return [];
597
+ }
598
+ scan.directoryStatus = "readable";
357
599
  const out = [];
358
600
  const queue = [root];
359
601
  while (queue.length > 0) {
360
602
  const dir = queue.pop();
361
- const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
603
+ let entries;
604
+ try {
605
+ entries = await readdir(dir, { withFileTypes: true });
606
+ }
607
+ catch (error) {
608
+ scan.directoryStatus = "unreadable";
609
+ diagnostics.push({
610
+ agent: scan.agent,
611
+ code: "directory_unreadable",
612
+ severity: "error",
613
+ message: `${agentLabel(scan.agent)} transcript directory could not be read${errorCodeSuffix(error)}.`,
614
+ count: 1
615
+ });
616
+ continue;
617
+ }
362
618
  for (const entry of entries) {
363
619
  const path = join(dir, entry.name);
364
620
  if (entry.isDirectory())
365
621
  queue.push(path);
366
- else if (entry.isFile() && entry.name.endsWith(".jsonl"))
622
+ else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
367
623
  out.push(path);
624
+ scan.filesDiscovered += 1;
625
+ }
368
626
  }
369
627
  }
370
628
  return out;
371
629
  }
630
+ function emptySourceScan(agent) {
631
+ return {
632
+ agent,
633
+ directoryStatus: "readable",
634
+ filesDiscovered: 0,
635
+ filesParsed: 0,
636
+ malformedLines: 0,
637
+ unreadableFiles: 0,
638
+ unsupportedUsageSnapshots: 0
639
+ };
640
+ }
641
+ function recordUnreadableFile(agent, scan, diagnostics, error) {
642
+ scan.unreadableFiles += 1;
643
+ diagnostics.push({
644
+ agent,
645
+ code: "file_unreadable",
646
+ severity: "error",
647
+ message: `${agentLabel(agent)} transcript file could not be read${errorCodeSuffix(error)}.`,
648
+ count: 1
649
+ });
650
+ }
651
+ function recordParseDiagnostic(agent, scan, diagnostics, diagnostic) {
652
+ if (diagnostic.code === "malformed_jsonl") {
653
+ scan.malformedLines += diagnostic.count;
654
+ diagnostics.push({
655
+ agent,
656
+ code: diagnostic.code,
657
+ severity: "warning",
658
+ message: `${diagnostic.count} malformed JSONL line(s) were skipped in ${agentLabel(agent)} transcripts.`,
659
+ count: diagnostic.count
660
+ });
661
+ return;
662
+ }
663
+ scan.unsupportedUsageSnapshots += diagnostic.count;
664
+ diagnostics.push({
665
+ agent,
666
+ code: diagnostic.code,
667
+ severity: "warning",
668
+ message: `${diagnostic.count} ${agentLabel(agent)} token snapshot(s) lacked the input/output components required for pricing.`,
669
+ count: diagnostic.count
670
+ });
671
+ }
672
+ function agentLabel(agent) {
673
+ return agent === "claude-code" ? "Claude Code" : "Codex";
674
+ }
675
+ function errorCodeSuffix(error) {
676
+ const code = error instanceof Error
677
+ ? error.code
678
+ : undefined;
679
+ return code && /^[A-Z0-9_]+$/.test(code) ? ` (${code})` : "";
680
+ }
681
+ function isNodeError(error, code) {
682
+ return error instanceof Error && error.code === code;
683
+ }
372
684
  function projectFromCwd(cwd) {
373
685
  if (!cwd)
374
686
  return undefined;
@@ -379,6 +691,9 @@ function projectFromCwd(cwd) {
379
691
  const name = basename(cwd);
380
692
  return name.length > 0 ? name : undefined;
381
693
  }
694
+ function absoluteWorkingDirectory(cwd) {
695
+ return cwd && isAbsolute(cwd) ? resolve(cwd) : undefined;
696
+ }
382
697
  /**
383
698
  * Codex can be launched from HOME and do nearly all of its work through tools
384
699
  * that declare a more specific working directory. Prefer that observed
@@ -412,12 +727,18 @@ function projectFromTranscriptPath(filePath) {
412
727
  const tail = parent.split("-").filter(Boolean).pop();
413
728
  return tail && tail.length > 0 ? tail : undefined;
414
729
  }
730
+ function localActivityScopeKey(sessionId, workingDirectory, project) {
731
+ // The absolute cwd stays inside this ephemeral parser key and is never
732
+ // persisted or rendered. It prevents one transcript that changes projects
733
+ // from attaching Project A's prompt/file evidence to Project B's calls.
734
+ return [sessionId ?? "unknown-session", workingDirectory ?? project ?? "unattributed"].join("\u0000");
735
+ }
415
736
  const FOCUS_STOP_WORDS = new Set([
416
- "about", "after", "again", "also", "and", "are", "at", "been", "being", "but",
737
+ "about", "above", "after", "again", "also", "and", "are", "at", "been", "being", "but",
417
738
  "can", "check", "could", "did", "does", "doing", "dont", "every", "from",
418
739
  "for", "have", "here", "how", "in", "into", "its", "just", "like", "make", "more",
419
740
  "need", "not", "now", "on", "only", "other", "our", "please", "really", "should",
420
- "something", "sure", "than", "that", "the", "their", "them", "then", "there",
741
+ "earlier", "is", "mentioned", "one", "something", "sure", "than", "that", "the", "their", "them", "then", "there",
421
742
  "these", "they", "thing", "think", "this", "through", "to", "too", "use", "user",
422
743
  "users", "want", "was", "way", "we", "what", "when", "where", "which", "while",
423
744
  "who", "why", "will", "with", "work", "working", "would", "you", "your",
@@ -493,12 +814,49 @@ function focusTopic(prompts) {
493
814
  if (prompts.length === 0)
494
815
  return undefined;
495
816
  const recent = prompts.slice(-12);
496
- const observedTopicTokens = new Set(recent.flatMap((prompt) => (topicTokens(prompt).filter((token) => !FOCUS_STOP_WORDS.has(token)))));
817
+ const promptTokenSets = recent.map((prompt) => (new Set(topicTokens(prompt).filter((token) => !FOCUS_STOP_WORDS.has(token)))));
818
+ const observedTopicTokens = new Set(promptTokenSets.flatMap((tokens) => [...tokens]));
819
+ // Recognized product/work concepts are meaningful even in one prompt. Generic
820
+ // tokens must repeat across distinct prompts below; that keeps attachment
821
+ // prose and one-off screenshot filenames from becoming a confident topic.
822
+ if (observedTopicTokens.has("aibill") && observedTopicTokens.has("prompt")) {
823
+ return "aibill prompt";
824
+ }
825
+ if (observedTopicTokens.has("glance") && observedTopicTokens.has("hover")) {
826
+ return observedTopicTokens.has("ui") ? "Glance hover UI" : "Glance hover";
827
+ }
828
+ if (observedTopicTokens.has("glance") &&
829
+ ["action", "agent", "handoff", "prompt"].some((token) => observedTopicTokens.has(token))) {
830
+ return "Glance agent handoff";
831
+ }
832
+ if (observedTopicTokens.has("landing") && observedTopicTokens.has("page"))
833
+ return "landing page";
834
+ if (observedTopicTokens.has("hover")) {
835
+ return observedTopicTokens.has("ui") ? "hover UI" : "hover interaction";
836
+ }
837
+ if (observedTopicTokens.has("mcp")) {
838
+ return observedTopicTokens.has("feature") ? "MCP feature" : "MCP";
839
+ }
840
+ if (observedTopicTokens.has("seo")) {
841
+ return observedTopicTokens.has("strategy") ? "SEO strategy" : "SEO";
842
+ }
843
+ const promptOccurrences = new Map();
844
+ for (const tokens of promptTokenSets) {
845
+ for (const token of tokens) {
846
+ promptOccurrences.set(token, (promptOccurrences.get(token) ?? 0) + 1);
847
+ }
848
+ }
849
+ const repeatedTokens = new Set([...promptOccurrences.entries()]
850
+ .filter(([, count]) => count >= 2)
851
+ .map(([token]) => token));
852
+ if (repeatedTokens.size === 0)
853
+ return undefined;
497
854
  const tokenScores = new Map();
498
855
  const pairScores = new Map();
499
856
  recent.forEach((prompt, index) => {
500
857
  const weight = 1 + index / Math.max(1, recent.length - 1);
501
- const tokens = topicTokens(prompt).filter((token) => !FOCUS_STOP_WORDS.has(token));
858
+ const tokens = topicTokens(prompt)
859
+ .filter((token) => !FOCUS_STOP_WORDS.has(token) && repeatedTokens.has(token));
502
860
  const unique = [...new Set(tokens)];
503
861
  for (const token of unique) {
504
862
  tokenScores.set(token, (tokenScores.get(token) ?? 0) + weight);
@@ -526,22 +884,9 @@ function focusTopic(prompts) {
526
884
  if (candidateTokens.size === 0)
527
885
  return undefined;
528
886
  const tokens = [...candidateTokens].slice(0, 3);
529
- if (tokens.includes("glance") && tokens.includes("hover")) {
530
- return tokens.includes("ui") ? "Glance hover UI" : "Glance hover";
531
- }
532
- if (observedTopicTokens.has("glance") &&
533
- ["action", "agent", "handoff", "prompt"].some((token) => observedTopicTokens.has(token))) {
534
- return "Glance agent handoff";
535
- }
536
887
  if (tokens.includes("hover")) {
537
888
  return tokens.includes("ui") ? "hover UI" : "hover interaction";
538
889
  }
539
- if (tokens.includes("landing") && tokens.includes("page"))
540
- return "landing page";
541
- if (tokens.includes("mcp"))
542
- return tokens.includes("feature") ? "MCP feature" : "MCP";
543
- if (tokens.includes("seo"))
544
- return tokens.includes("strategy") ? "SEO strategy" : "SEO";
545
890
  return tokens.map(displayToken).join(" ");
546
891
  }
547
892
  function inferAction(prompts, title) {
@@ -619,9 +964,36 @@ function topicTokens(value) {
619
964
  // Absolute paths often appear in attached-image metadata and tool-oriented
620
965
  // prompts. They are machine context, not the user's work topic, and can
621
966
  // otherwise outrank meaningful words when only one recent prompt exists.
622
- const withoutAbsolutePaths = value.replace(/(^|[\s("'=:])(?:file:\/\/)?\/[^\s)"']+/g, "$1");
967
+ const withoutAbsolutePaths = value.replace(/(^|[\s("'=:])(?:file:\/\/)?\/[^\s)"']+/g, "$1")
968
+ .replace(/\b[^\s/\\]+\.(?:png|jpe?g|gif|webp|heic|svg|pdf|mov|mp4)\b/gi, " ")
969
+ .replace(/\b(?:attached|attachment|clipboard|image|images|photo|picture|screenshot|screenshots)\b/gi, " ");
623
970
  return promptTokens(sanitizeLocalActivityText(withoutAbsolutePaths));
624
971
  }
972
+ function codexTurnUsage(value) {
973
+ const rawInput = numberOf(value.input_tokens) ?? 0;
974
+ const cached = Math.min(rawInput, numberOf(value.cached_input_tokens) ?? 0);
975
+ const output = numberOf(value.output_tokens) ?? 0;
976
+ return {
977
+ inputTokens: Math.max(0, rawInput - cached),
978
+ outputTokens: output,
979
+ cacheReadTokens: cached,
980
+ contextTokens: rawInput,
981
+ totalTokens: numberOf(value.total_tokens) ?? rawInput + output,
982
+ source: "transcript_last_token_usage"
983
+ };
984
+ }
985
+ function toTurnUsage(usage, source) {
986
+ const contextTokens = usage.inputTokens +
987
+ (usage.cacheReadTokens ?? 0) +
988
+ (usage.cacheWrite5mTokens ?? 0) +
989
+ (usage.cacheWrite1hTokens ?? 0);
990
+ return {
991
+ ...usage,
992
+ contextTokens,
993
+ totalTokens: contextTokens + usage.outputTokens,
994
+ source
995
+ };
996
+ }
625
997
  /**
626
998
  * Remove known and assignment-shaped credentials from metadata before it can
627
999
  * become a topic, title, Glance field, MCP result, or copy-ready handoff.
@@ -698,6 +1070,26 @@ function toIso(value) {
698
1070
  const parsed = Date.parse(value);
699
1071
  return Number.isFinite(parsed) ? new Date(parsed).toISOString() : undefined;
700
1072
  }
1073
+ const ROOT_TASK_CLOCK_TOLERANCE_MS = 5_000;
1074
+ function isRootSpecificTaskStart(value, rootStartedAtMs) {
1075
+ const taskStartedAtMs = timestampMilliseconds(value);
1076
+ return typeof rootStartedAtMs === "number" &&
1077
+ typeof taskStartedAtMs === "number" &&
1078
+ taskStartedAtMs >= rootStartedAtMs - ROOT_TASK_CLOCK_TOLERANCE_MS;
1079
+ }
1080
+ function timestampMilliseconds(value) {
1081
+ if (typeof value === "number" && Number.isFinite(value)) {
1082
+ return value < 1_000_000_000_000 ? value * 1_000 : value;
1083
+ }
1084
+ if (typeof value !== "string" || value.length === 0)
1085
+ return undefined;
1086
+ const numeric = Number(value);
1087
+ if (Number.isFinite(numeric)) {
1088
+ return numeric < 1_000_000_000_000 ? numeric * 1_000 : numeric;
1089
+ }
1090
+ const parsed = Date.parse(value);
1091
+ return Number.isFinite(parsed) ? parsed : undefined;
1092
+ }
701
1093
  function sum(calls, pick) {
702
1094
  return calls.reduce((total, call) => total + pick(call), 0);
703
1095
  }