@agent-finops/core 0.5.7 → 0.5.9

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,15 +3,64 @@ 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) {
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;
17
66
  for (const line of content.split("\n")) {
@@ -39,15 +88,7 @@ export function parseClaudeCodeTranscript(content, filePath = "") {
39
88
  if (entry.type === "user" && message) {
40
89
  for (const prompt of textValues(message.content)) {
41
90
  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);
91
+ pendingPrompts.push(prompt);
51
92
  }
52
93
  }
53
94
  if (entry.type !== "assistant")
@@ -63,51 +104,97 @@ export function parseClaudeCodeTranscript(content, filePath = "") {
63
104
  if (dedupeKey !== ":" && seen.has(dedupeKey))
64
105
  continue;
65
106
  seen.add(dedupeKey);
107
+ const timestamp = toIso(stringOf(entry.timestamp)) ?? new Date(0).toISOString();
108
+ if (typeof sinceMs === "number" && Date.parse(timestamp) < sinceMs) {
109
+ // These prompts led to a call outside the selected evidence window. Do
110
+ // not let them become the focus of a later in-window project/call.
111
+ pendingPrompts.length = 0;
112
+ continue;
113
+ }
66
114
  const cacheCreation = isRecord(usage.cache_creation) ? usage.cache_creation : undefined;
67
115
  const write5m = numberOf(cacheCreation?.ephemeral_5m_input_tokens);
68
116
  const write1h = numberOf(cacheCreation?.ephemeral_1h_input_tokens);
69
117
  const writeTotal = numberOf(usage.cache_creation_input_tokens) ?? 0;
70
- calls.push({
118
+ const parsedUsage = {
119
+ inputTokens: numberOf(usage.input_tokens) ?? 0,
120
+ outputTokens: numberOf(usage.output_tokens) ?? 0,
121
+ cacheReadTokens: numberOf(usage.cache_read_input_tokens) ?? 0,
122
+ // Prefer the 5m/1h breakdown; fall back to the total as 5m (cheaper bound).
123
+ cacheWrite5mTokens: write5m ?? writeTotal,
124
+ cacheWrite1hTokens: write1h ?? 0
125
+ };
126
+ const workingDirectory = absoluteWorkingDirectory(stringOf(entry.cwd));
127
+ const project = projectFromCwd(workingDirectory) ?? projectFromTranscriptPath(filePath);
128
+ const sessionId = stringOf(entry.sessionId);
129
+ const call = {
71
130
  agent: "claude-code",
72
131
  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
- });
132
+ timestamp,
133
+ project,
134
+ workingDirectory,
135
+ sessionId,
136
+ latestTurnUsage: toTurnUsage(parsedUsage, "assistant_message_usage"),
137
+ usageScope: "turn",
138
+ usage: parsedUsage
139
+ };
140
+ calls.push(call);
141
+ const activityKey = localActivityScopeKey(sessionId, workingDirectory, project);
142
+ const evidence = activityEvidence.get(activityKey) ?? {
143
+ prompts: [],
144
+ files: new Map(),
145
+ toolCallCount: 0
146
+ };
147
+ evidence.prompts.push(...pendingPrompts);
148
+ pendingPrompts.length = 0;
149
+ for (const item of recordValues(message.content)) {
150
+ if (item.type !== "tool_use")
151
+ continue;
152
+ evidence.toolCallCount += 1;
153
+ collectToolFiles(item.input, evidence.files);
154
+ }
155
+ activityEvidence.set(activityKey, evidence);
156
+ latestActivityKey = activityKey;
85
157
  }
86
158
  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;
159
+ const activities = new Map();
160
+ for (const [activityKey, evidence] of activityEvidence) {
161
+ const matchingCall = calls.find((call) => (localActivityScopeKey(call.sessionId, call.workingDirectory, call.project) === activityKey));
162
+ const isLatest = activityKey === latestActivityKey;
163
+ const activity = buildLocalAgentActivity({
164
+ title: isLatest ? title : undefined,
165
+ prompts: evidence.prompts.length > 0
166
+ ? evidence.prompts
167
+ : isLatest && fallbackPrompt
168
+ ? [fallbackPrompt]
169
+ : [],
170
+ files: evidence.files,
171
+ toolCallCount: evidence.toolCallCount,
172
+ project: matchingCall?.project ?? projectFromTranscriptPath(filePath),
173
+ isSubagent,
174
+ parentSessionId
175
+ });
176
+ if (activity)
177
+ activities.set(activityKey, activity);
178
+ }
179
+ for (const call of calls) {
180
+ call.activity = activities.get(localActivityScopeKey(call.sessionId, call.workingDirectory, call.project));
99
181
  }
100
182
  return calls;
101
183
  }
102
184
  /** Parse one Codex rollout file (JSONL event stream). Exported for tests. */
103
- export function parseCodexRollout(content) {
185
+ export function parseCodexRollout(content, onEntry) {
104
186
  let model;
105
- let cwd;
187
+ let rootCwd;
106
188
  const toolWorkdirs = new Map();
107
189
  let sessionId;
190
+ let rootSessionMetaSeen = false;
108
191
  let startedAt;
192
+ let rootStartedAtMs;
193
+ let rootTaskStarted = false;
194
+ let inheritedUsageBaseline;
109
195
  let lastActivityAt;
110
196
  let lastTotal;
197
+ let lastTurn;
111
198
  let lastRateLimits;
112
199
  const prompts = [];
113
200
  const fileCounts = new Map();
@@ -126,17 +213,45 @@ export function parseCodexRollout(content) {
126
213
  }
127
214
  if (!isRecord(entry))
128
215
  continue;
216
+ onEntry?.(entry);
129
217
  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;
218
+ if (entry.type === "session_meta" && payload && !rootSessionMetaSeen) {
219
+ // A forked/subagent rollout can embed the parent transcript, including
220
+ // many later session_meta records. The first metadata record belongs to
221
+ // this rollout file; later records are nested/history evidence and must
222
+ // never replace the financial session identity or root cwd.
223
+ rootSessionMetaSeen = true;
224
+ sessionId = stringOf(payload.id);
225
+ rootCwd = stringOf(payload.cwd);
226
+ startedAt = toIso(stringOf(payload.timestamp) ?? stringOf(entry.timestamp));
227
+ rootStartedAtMs = timestampMilliseconds(payload.timestamp ?? entry.timestamp);
134
228
  isSubagent = stringOf(payload.thread_source) === "subagent" || isRecord(payload.source) && "subagent" in payload.source;
135
- parentSessionId = stringOf(payload.parent_thread_id) ?? parentSessionId;
229
+ parentSessionId = stringOf(payload.parent_thread_id);
136
230
  }
137
231
  if (entry.type === "turn_context" && payload) {
138
232
  model = stringOf(payload.model) ?? model;
139
- cwd = stringOf(payload.cwd) ?? cwd;
233
+ rootCwd ??= stringOf(payload.cwd);
234
+ }
235
+ if (isSubagent &&
236
+ !rootTaskStarted &&
237
+ payload?.type === "task_started" &&
238
+ isRootSpecificTaskStart(payload.started_at, rootStartedAtMs)) {
239
+ // Forked Codex rollouts copy the parent's complete event history after
240
+ // the child's root session_meta. The cumulative counter immediately
241
+ // before the child's first task is the inherited baseline, not child
242
+ // usage. Reset qualitative evidence at the same boundary so parent
243
+ // prompts/files cannot become the child's focus.
244
+ inheritedUsageBaseline = lastTotal;
245
+ lastTotal = undefined;
246
+ rootTaskStarted = true;
247
+ prompts.length = 0;
248
+ fileCounts.clear();
249
+ toolWorkdirs.clear();
250
+ toolCallCount = 0;
251
+ model = undefined;
252
+ lastTurn = undefined;
253
+ lastRateLimits = undefined;
254
+ lastActivityAt = toIso(stringOf(entry.timestamp)) ?? startedAt;
140
255
  }
141
256
  if (payload?.type === "function_call" || payload?.type === "custom_tool_call") {
142
257
  toolCallCount += 1;
@@ -146,6 +261,12 @@ export function parseCodexRollout(content) {
146
261
  const normalized = resolve(workdir);
147
262
  toolWorkdirs.set(normalized, (toolWorkdirs.get(normalized) ?? 0) + 1);
148
263
  }
264
+ // Current Codex Desktop records the orchestration wrapper as a custom
265
+ // `exec` call whose input is JavaScript containing nested tool calls.
266
+ // Extract only quoted absolute workdir/cwd values; never evaluate it.
267
+ if (payload.type === "custom_tool_call" && stringOf(payload.name) === "exec") {
268
+ collectEmbeddedToolWorkdirs(stringOf(payload.input), toolWorkdirs);
269
+ }
149
270
  collectToolFiles(args, fileCounts);
150
271
  collectPatchFiles(stringOf(args?.patch) ?? stringOf(args?.input) ?? stringOf(payload.input), fileCounts);
151
272
  }
@@ -159,21 +280,39 @@ export function parseCodexRollout(content) {
159
280
  const eventTimestamp = toIso(stringOf(entry.timestamp)) ?? lastActivityAt ?? startedAt;
160
281
  const info = isRecord(payload.info) ? payload.info : undefined;
161
282
  const total = info && isRecord(info.total_token_usage) ? info.total_token_usage : undefined;
283
+ const turn = info && isRecord(info.last_token_usage) ? info.last_token_usage : undefined;
162
284
  if (total) {
163
285
  lastTotal = total;
164
286
  lastActivityAt = eventTimestamp;
165
287
  }
288
+ if (turn) {
289
+ lastTurn = turn;
290
+ lastActivityAt = eventTimestamp;
291
+ }
166
292
  const rateLimits = parseCodexRateLimits(payload.rate_limits, eventTimestamp);
167
293
  if (rateLimits) {
168
294
  lastRateLimits = rateLimits;
169
295
  }
170
296
  }
171
297
  }
172
- if (!lastTotal)
298
+ // A fork without a recognized root-task boundary is ambiguous: older Codex
299
+ // formats may contain only inherited parent history. Omitting that child is
300
+ // safer than charging the parent cumulative counter again. Likewise, a
301
+ // recognized boundary with no later total_token_usage is not a financial
302
+ // call yet.
303
+ if (!lastTotal || isSubagent && !rootTaskStarted)
173
304
  return [];
174
- const input = numberOf(lastTotal.input_tokens) ?? 0;
175
- const cached = numberOf(lastTotal.cached_input_tokens) ?? 0;
176
- const project = projectFromCwd(dominantCodexCwd(cwd, toolWorkdirs));
305
+ const input = Math.max(0, (numberOf(lastTotal.input_tokens) ?? 0) -
306
+ (numberOf(inheritedUsageBaseline?.input_tokens) ?? 0));
307
+ const cached = Math.max(0, (numberOf(lastTotal.cached_input_tokens) ?? 0) -
308
+ (numberOf(inheritedUsageBaseline?.cached_input_tokens) ?? 0));
309
+ const output = Math.max(0, (numberOf(lastTotal.output_tokens) ?? 0) -
310
+ (numberOf(inheritedUsageBaseline?.output_tokens) ?? 0));
311
+ const latestTurnUsage = lastTurn
312
+ ? codexTurnUsage(lastTurn)
313
+ : undefined;
314
+ const workingDirectory = absoluteWorkingDirectory(dominantCodexCwd(rootCwd, toolWorkdirs));
315
+ const project = projectFromCwd(workingDirectory);
177
316
  const activity = buildLocalAgentActivity({
178
317
  prompts,
179
318
  files: fileCounts,
@@ -188,17 +327,38 @@ export function parseCodexRollout(content) {
188
327
  timestamp: lastActivityAt ?? startedAt ?? new Date(0).toISOString(),
189
328
  startedAt,
190
329
  project,
330
+ workingDirectory,
191
331
  sessionId,
192
332
  rateLimits: lastRateLimits,
193
333
  activity,
334
+ latestTurnUsage,
335
+ usageScope: "session_cumulative",
194
336
  usage: {
195
337
  // Codex input_tokens INCLUDES cached tokens; split them out.
196
338
  inputTokens: Math.max(0, input - cached),
197
- outputTokens: numberOf(lastTotal.output_tokens) ?? 0,
339
+ outputTokens: output,
198
340
  cacheReadTokens: cached
199
341
  }
200
342
  }];
201
343
  }
344
+ function collectEmbeddedToolWorkdirs(input, toolWorkdirs) {
345
+ if (!input)
346
+ return;
347
+ const fieldPattern = /(?:^|[^A-Za-z0-9_])["']?(?:workdir|cwd)["']?\s*:\s*("(?:\\.|[^"\\])*")/g;
348
+ for (const match of input.matchAll(fieldPattern)) {
349
+ let value;
350
+ try {
351
+ value = JSON.parse(match[1]);
352
+ }
353
+ catch {
354
+ continue;
355
+ }
356
+ if (typeof value !== "string" || !isAbsolute(value))
357
+ continue;
358
+ const normalized = resolve(value);
359
+ toolWorkdirs.set(normalized, (toolWorkdirs.get(normalized) ?? 0) + 1);
360
+ }
361
+ }
202
362
  function parseCodexRateLimits(value, observedAt) {
203
363
  if (!isRecord(value) || !observedAt)
204
364
  return undefined;
@@ -251,13 +411,18 @@ export async function loadLocalAgentUsage(options = {}) {
251
411
  const claudeDir = options.claudeProjectsDir ?? join(home, ".claude", "projects");
252
412
  const codexDir = options.codexSessionsDir ?? join(home, ".codex", "sessions");
253
413
  const calls = [];
414
+ const codexInvocationFiles = options.collectCodexInvocationEvidence
415
+ ? []
416
+ : undefined;
254
417
  let filesParsed = 0;
418
+ const since = options.sinceIso ? Date.parse(options.sinceIso) : undefined;
419
+ const sinceMs = typeof since === "number" && Number.isFinite(since) ? since : undefined;
255
420
  for (const file of await listJsonlFiles(claudeDir)) {
256
421
  const content = await readFile(file, "utf8").catch(() => "");
257
422
  if (!content)
258
423
  continue;
259
424
  filesParsed += 1;
260
- calls.push(...parseClaudeCodeTranscript(content, file));
425
+ calls.push(...parseClaudeCodeTranscript(content, file, sinceMs));
261
426
  }
262
427
  for (const file of await listJsonlFiles(codexDir)) {
263
428
  if (!basename(file).startsWith("rollout-"))
@@ -266,23 +431,29 @@ export async function loadLocalAgentUsage(options = {}) {
266
431
  if (!content)
267
432
  continue;
268
433
  filesParsed += 1;
269
- calls.push(...parseCodexRollout(content));
434
+ const collector = codexInvocationFiles
435
+ ? createCodexInvocationCollector(sinceMs)
436
+ : undefined;
437
+ calls.push(...parseCodexRollout(content, collector?.consume));
438
+ if (collector)
439
+ codexInvocationFiles.push(collector.finish());
270
440
  }
271
- const since = options.sinceIso ? Date.parse(options.sinceIso) : undefined;
272
- const filtered = typeof since === "number" && Number.isFinite(since)
273
- ? calls.filter((call) => Date.parse(call.timestamp) >= since)
274
- : calls;
441
+ const normalizedCalls = dedupeCumulativeSessionCalls(calls);
442
+ const filtered = typeof sinceMs === "number"
443
+ ? normalizedCalls.filter((call) => Date.parse(call.timestamp) >= sinceMs)
444
+ : normalizedCalls;
275
445
  return {
276
446
  records: aggregateCalls(filtered),
277
447
  calls: filtered,
278
448
  filesParsed,
279
- agentsDetected: [...new Set(filtered.map((call) => call.agent))]
449
+ agentsDetected: [...new Set(filtered.map((call) => call.agent))],
450
+ ...(codexInvocationFiles ? { codexInvocationFiles } : {})
280
451
  };
281
452
  }
282
453
  /** Aggregate per-call usage into one UsageRecord per day+agent+model+project. */
283
454
  export function aggregateCalls(calls) {
284
455
  const groups = new Map();
285
- for (const call of calls) {
456
+ for (const call of dedupeCumulativeSessionCalls(calls)) {
286
457
  const day = call.timestamp.slice(0, 10);
287
458
  const key = [day, call.agent, call.model, call.project ?? "unattributed"].join("|");
288
459
  groups.set(key, [...(groups.get(key) ?? []), call]);
@@ -314,9 +485,13 @@ export function aggregateCalls(calls) {
314
485
  outputTokens: usage.outputTokens,
315
486
  amountUsd: priced ? amountUsd : null,
316
487
  costConfidence: priced ? "estimated" : "missing",
317
- projectId: project === "unattributed" ? undefined : project,
488
+ // `(home)` is an attribution fallback, not a real project. Keep it on
489
+ // LocalAgentCall for Glance/session context, but do not promote it to a
490
+ // high-confidence project id in receipts or the attribution engine.
491
+ projectId: project === "unattributed" || project === "(home)" ? undefined : project,
318
492
  agentId: agent,
319
493
  providerCostType: "local_agent_logs",
494
+ usageGranularity: "daily_aggregate",
320
495
  quantity: groupCalls.length,
321
496
  operation: `${agent} sessions`
322
497
  });
@@ -352,6 +527,9 @@ function projectFromCwd(cwd) {
352
527
  const name = basename(cwd);
353
528
  return name.length > 0 ? name : undefined;
354
529
  }
530
+ function absoluteWorkingDirectory(cwd) {
531
+ return cwd && isAbsolute(cwd) ? resolve(cwd) : undefined;
532
+ }
355
533
  /**
356
534
  * Codex can be launched from HOME and do nearly all of its work through tools
357
535
  * that declare a more specific working directory. Prefer that observed
@@ -385,12 +563,18 @@ function projectFromTranscriptPath(filePath) {
385
563
  const tail = parent.split("-").filter(Boolean).pop();
386
564
  return tail && tail.length > 0 ? tail : undefined;
387
565
  }
566
+ function localActivityScopeKey(sessionId, workingDirectory, project) {
567
+ // The absolute cwd stays inside this ephemeral parser key and is never
568
+ // persisted or rendered. It prevents one transcript that changes projects
569
+ // from attaching Project A's prompt/file evidence to Project B's calls.
570
+ return [sessionId ?? "unknown-session", workingDirectory ?? project ?? "unattributed"].join("\u0000");
571
+ }
388
572
  const FOCUS_STOP_WORDS = new Set([
389
- "about", "after", "again", "also", "and", "are", "at", "been", "being", "but",
573
+ "about", "above", "after", "again", "also", "and", "are", "at", "been", "being", "but",
390
574
  "can", "check", "could", "did", "does", "doing", "dont", "every", "from",
391
575
  "for", "have", "here", "how", "in", "into", "its", "just", "like", "make", "more",
392
576
  "need", "not", "now", "on", "only", "other", "our", "please", "really", "should",
393
- "something", "sure", "than", "that", "the", "their", "them", "then", "there",
577
+ "earlier", "is", "mentioned", "one", "something", "sure", "than", "that", "the", "their", "them", "then", "there",
394
578
  "these", "they", "thing", "think", "this", "through", "to", "too", "use", "user",
395
579
  "users", "want", "was", "way", "we", "what", "when", "where", "which", "while",
396
580
  "who", "why", "will", "with", "work", "working", "would", "you", "your",
@@ -466,12 +650,49 @@ function focusTopic(prompts) {
466
650
  if (prompts.length === 0)
467
651
  return undefined;
468
652
  const recent = prompts.slice(-12);
469
- const observedTopicTokens = new Set(recent.flatMap((prompt) => (topicTokens(prompt).filter((token) => !FOCUS_STOP_WORDS.has(token)))));
653
+ const promptTokenSets = recent.map((prompt) => (new Set(topicTokens(prompt).filter((token) => !FOCUS_STOP_WORDS.has(token)))));
654
+ const observedTopicTokens = new Set(promptTokenSets.flatMap((tokens) => [...tokens]));
655
+ // Recognized product/work concepts are meaningful even in one prompt. Generic
656
+ // tokens must repeat across distinct prompts below; that keeps attachment
657
+ // prose and one-off screenshot filenames from becoming a confident topic.
658
+ if (observedTopicTokens.has("aibill") && observedTopicTokens.has("prompt")) {
659
+ return "aibill prompt";
660
+ }
661
+ if (observedTopicTokens.has("glance") && observedTopicTokens.has("hover")) {
662
+ return observedTopicTokens.has("ui") ? "Glance hover UI" : "Glance hover";
663
+ }
664
+ if (observedTopicTokens.has("glance") &&
665
+ ["action", "agent", "handoff", "prompt"].some((token) => observedTopicTokens.has(token))) {
666
+ return "Glance agent handoff";
667
+ }
668
+ if (observedTopicTokens.has("landing") && observedTopicTokens.has("page"))
669
+ return "landing page";
670
+ if (observedTopicTokens.has("hover")) {
671
+ return observedTopicTokens.has("ui") ? "hover UI" : "hover interaction";
672
+ }
673
+ if (observedTopicTokens.has("mcp")) {
674
+ return observedTopicTokens.has("feature") ? "MCP feature" : "MCP";
675
+ }
676
+ if (observedTopicTokens.has("seo")) {
677
+ return observedTopicTokens.has("strategy") ? "SEO strategy" : "SEO";
678
+ }
679
+ const promptOccurrences = new Map();
680
+ for (const tokens of promptTokenSets) {
681
+ for (const token of tokens) {
682
+ promptOccurrences.set(token, (promptOccurrences.get(token) ?? 0) + 1);
683
+ }
684
+ }
685
+ const repeatedTokens = new Set([...promptOccurrences.entries()]
686
+ .filter(([, count]) => count >= 2)
687
+ .map(([token]) => token));
688
+ if (repeatedTokens.size === 0)
689
+ return undefined;
470
690
  const tokenScores = new Map();
471
691
  const pairScores = new Map();
472
692
  recent.forEach((prompt, index) => {
473
693
  const weight = 1 + index / Math.max(1, recent.length - 1);
474
- const tokens = topicTokens(prompt).filter((token) => !FOCUS_STOP_WORDS.has(token));
694
+ const tokens = topicTokens(prompt)
695
+ .filter((token) => !FOCUS_STOP_WORDS.has(token) && repeatedTokens.has(token));
475
696
  const unique = [...new Set(tokens)];
476
697
  for (const token of unique) {
477
698
  tokenScores.set(token, (tokenScores.get(token) ?? 0) + weight);
@@ -499,22 +720,9 @@ function focusTopic(prompts) {
499
720
  if (candidateTokens.size === 0)
500
721
  return undefined;
501
722
  const tokens = [...candidateTokens].slice(0, 3);
502
- if (tokens.includes("glance") && tokens.includes("hover")) {
503
- return tokens.includes("ui") ? "Glance hover UI" : "Glance hover";
504
- }
505
- if (observedTopicTokens.has("glance") &&
506
- ["action", "agent", "handoff", "prompt"].some((token) => observedTopicTokens.has(token))) {
507
- return "Glance agent handoff";
508
- }
509
723
  if (tokens.includes("hover")) {
510
724
  return tokens.includes("ui") ? "hover UI" : "hover interaction";
511
725
  }
512
- if (tokens.includes("landing") && tokens.includes("page"))
513
- return "landing page";
514
- if (tokens.includes("mcp"))
515
- return tokens.includes("feature") ? "MCP feature" : "MCP";
516
- if (tokens.includes("seo"))
517
- return tokens.includes("strategy") ? "SEO strategy" : "SEO";
518
726
  return tokens.map(displayToken).join(" ");
519
727
  }
520
728
  function inferAction(prompts, title) {
@@ -592,9 +800,36 @@ function topicTokens(value) {
592
800
  // Absolute paths often appear in attached-image metadata and tool-oriented
593
801
  // prompts. They are machine context, not the user's work topic, and can
594
802
  // otherwise outrank meaningful words when only one recent prompt exists.
595
- const withoutAbsolutePaths = value.replace(/(^|[\s("'=:])(?:file:\/\/)?\/[^\s)"']+/g, "$1");
803
+ const withoutAbsolutePaths = value.replace(/(^|[\s("'=:])(?:file:\/\/)?\/[^\s)"']+/g, "$1")
804
+ .replace(/\b[^\s/\\]+\.(?:png|jpe?g|gif|webp|heic|svg|pdf|mov|mp4)\b/gi, " ")
805
+ .replace(/\b(?:attached|attachment|clipboard|image|images|photo|picture|screenshot|screenshots)\b/gi, " ");
596
806
  return promptTokens(sanitizeLocalActivityText(withoutAbsolutePaths));
597
807
  }
808
+ function codexTurnUsage(value) {
809
+ const rawInput = numberOf(value.input_tokens) ?? 0;
810
+ const cached = Math.min(rawInput, numberOf(value.cached_input_tokens) ?? 0);
811
+ const output = numberOf(value.output_tokens) ?? 0;
812
+ return {
813
+ inputTokens: Math.max(0, rawInput - cached),
814
+ outputTokens: output,
815
+ cacheReadTokens: cached,
816
+ contextTokens: rawInput,
817
+ totalTokens: numberOf(value.total_tokens) ?? rawInput + output,
818
+ source: "transcript_last_token_usage"
819
+ };
820
+ }
821
+ function toTurnUsage(usage, source) {
822
+ const contextTokens = usage.inputTokens +
823
+ (usage.cacheReadTokens ?? 0) +
824
+ (usage.cacheWrite5mTokens ?? 0) +
825
+ (usage.cacheWrite1hTokens ?? 0);
826
+ return {
827
+ ...usage,
828
+ contextTokens,
829
+ totalTokens: contextTokens + usage.outputTokens,
830
+ source
831
+ };
832
+ }
598
833
  /**
599
834
  * Remove known and assignment-shaped credentials from metadata before it can
600
835
  * become a topic, title, Glance field, MCP result, or copy-ready handoff.
@@ -671,6 +906,26 @@ function toIso(value) {
671
906
  const parsed = Date.parse(value);
672
907
  return Number.isFinite(parsed) ? new Date(parsed).toISOString() : undefined;
673
908
  }
909
+ const ROOT_TASK_CLOCK_TOLERANCE_MS = 5_000;
910
+ function isRootSpecificTaskStart(value, rootStartedAtMs) {
911
+ const taskStartedAtMs = timestampMilliseconds(value);
912
+ return typeof rootStartedAtMs === "number" &&
913
+ typeof taskStartedAtMs === "number" &&
914
+ taskStartedAtMs >= rootStartedAtMs - ROOT_TASK_CLOCK_TOLERANCE_MS;
915
+ }
916
+ function timestampMilliseconds(value) {
917
+ if (typeof value === "number" && Number.isFinite(value)) {
918
+ return value < 1_000_000_000_000 ? value * 1_000 : value;
919
+ }
920
+ if (typeof value !== "string" || value.length === 0)
921
+ return undefined;
922
+ const numeric = Number(value);
923
+ if (Number.isFinite(numeric)) {
924
+ return numeric < 1_000_000_000_000 ? numeric * 1_000 : numeric;
925
+ }
926
+ const parsed = Date.parse(value);
927
+ return Number.isFinite(parsed) ? parsed : undefined;
928
+ }
674
929
  function sum(calls, pick) {
675
930
  return calls.reduce((total, call) => total + pick(call), 0);
676
931
  }
@@ -35,6 +35,7 @@ export function normalizeOpenAiCostResponse(response, options) {
35
35
  projectId,
36
36
  apiKeyId,
37
37
  providerCostType: "openai_cost",
38
+ usageGranularity: "billing_bucket",
38
39
  quantity: typeof result.quantity === "number" ? result.quantity : undefined,
39
40
  operation: lineItem
40
41
  });
@@ -74,6 +75,7 @@ export function normalizeOpenAiUsageResponse(response, options) {
74
75
  userId,
75
76
  apiKeyId,
76
77
  providerCostType: "openai_usage_evidence",
78
+ usageGranularity: "usage_bucket",
77
79
  quantity: numberValue(result.num_model_requests),
78
80
  operation: "OpenAI completions usage evidence"
79
81
  });
@@ -121,6 +123,7 @@ export function normalizeAnthropicClaudeCodeUsageResponse(response, options) {
121
123
  userId,
122
124
  projectId: organizationId,
123
125
  providerCostType: "anthropic_claude_code_usage",
126
+ usageGranularity: "daily_aggregate",
124
127
  quantity: sessions,
125
128
  operation: `Claude Code sessions: ${sessions}; LOC +${added}/-${removed}; commits ${commits}; PRs ${prs}`
126
129
  });
@@ -153,6 +156,7 @@ export function normalizeGitHubCopilotSeatResponse(response, options) {
153
156
  userId,
154
157
  projectId: options.accountId,
155
158
  providerCostType: "copilot_seat_reconciliation",
159
+ usageGranularity: "seat",
156
160
  quantity: 1,
157
161
  operation: `GitHub Copilot ${plan} seat; ${lastActivity ? `last activity ${lastActivity}` : "no recent activity reported"}`
158
162
  }];
@@ -191,6 +195,7 @@ export function normalizeAnthropicCostResponse(response, options) {
191
195
  projectId: workspaceId,
192
196
  workspaceId,
193
197
  providerCostType: result.cost_type ?? "anthropic_cost",
198
+ usageGranularity: "billing_bucket",
194
199
  operation: description
195
200
  });
196
201
  }
@@ -222,6 +227,7 @@ export function normalizeGitHubCopilotMetricsResponse(response, options) {
222
227
  costConfidence: "missing",
223
228
  projectId: options.accountId,
224
229
  providerCostType: "copilot_usage_metrics",
230
+ usageGranularity: "daily_aggregate",
225
231
  operation: feature
226
232
  });
227
233
  }
@@ -239,6 +245,7 @@ export function normalizeGitHubCopilotMetricsResponse(response, options) {
239
245
  costConfidence: "missing",
240
246
  projectId: options.accountId,
241
247
  providerCostType: "copilot_cli_metrics",
248
+ usageGranularity: "daily_aggregate",
242
249
  operation: "CLI requests"
243
250
  });
244
251
  }
@@ -270,6 +277,7 @@ export function normalizeCursorSpendResponse(response, options) {
270
277
  userId,
271
278
  projectId: options.accountId,
272
279
  providerCostType: "cursor_spend",
280
+ usageGranularity: "user_aggregate",
273
281
  operation: "Cursor team spend"
274
282
  }];
275
283
  });