@agent-finops/core 0.5.8 → 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;
@@ -165,21 +280,39 @@ export function parseCodexRollout(content) {
165
280
  const eventTimestamp = toIso(stringOf(entry.timestamp)) ?? lastActivityAt ?? startedAt;
166
281
  const info = isRecord(payload.info) ? payload.info : undefined;
167
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;
168
284
  if (total) {
169
285
  lastTotal = total;
170
286
  lastActivityAt = eventTimestamp;
171
287
  }
288
+ if (turn) {
289
+ lastTurn = turn;
290
+ lastActivityAt = eventTimestamp;
291
+ }
172
292
  const rateLimits = parseCodexRateLimits(payload.rate_limits, eventTimestamp);
173
293
  if (rateLimits) {
174
294
  lastRateLimits = rateLimits;
175
295
  }
176
296
  }
177
297
  }
178
- 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)
179
304
  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));
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);
183
316
  const activity = buildLocalAgentActivity({
184
317
  prompts,
185
318
  files: fileCounts,
@@ -194,13 +327,16 @@ export function parseCodexRollout(content) {
194
327
  timestamp: lastActivityAt ?? startedAt ?? new Date(0).toISOString(),
195
328
  startedAt,
196
329
  project,
330
+ workingDirectory,
197
331
  sessionId,
198
332
  rateLimits: lastRateLimits,
199
333
  activity,
334
+ latestTurnUsage,
335
+ usageScope: "session_cumulative",
200
336
  usage: {
201
337
  // Codex input_tokens INCLUDES cached tokens; split them out.
202
338
  inputTokens: Math.max(0, input - cached),
203
- outputTokens: numberOf(lastTotal.output_tokens) ?? 0,
339
+ outputTokens: output,
204
340
  cacheReadTokens: cached
205
341
  }
206
342
  }];
@@ -275,13 +411,18 @@ export async function loadLocalAgentUsage(options = {}) {
275
411
  const claudeDir = options.claudeProjectsDir ?? join(home, ".claude", "projects");
276
412
  const codexDir = options.codexSessionsDir ?? join(home, ".codex", "sessions");
277
413
  const calls = [];
414
+ const codexInvocationFiles = options.collectCodexInvocationEvidence
415
+ ? []
416
+ : undefined;
278
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;
279
420
  for (const file of await listJsonlFiles(claudeDir)) {
280
421
  const content = await readFile(file, "utf8").catch(() => "");
281
422
  if (!content)
282
423
  continue;
283
424
  filesParsed += 1;
284
- calls.push(...parseClaudeCodeTranscript(content, file));
425
+ calls.push(...parseClaudeCodeTranscript(content, file, sinceMs));
285
426
  }
286
427
  for (const file of await listJsonlFiles(codexDir)) {
287
428
  if (!basename(file).startsWith("rollout-"))
@@ -290,23 +431,29 @@ export async function loadLocalAgentUsage(options = {}) {
290
431
  if (!content)
291
432
  continue;
292
433
  filesParsed += 1;
293
- 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());
294
440
  }
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;
441
+ const normalizedCalls = dedupeCumulativeSessionCalls(calls);
442
+ const filtered = typeof sinceMs === "number"
443
+ ? normalizedCalls.filter((call) => Date.parse(call.timestamp) >= sinceMs)
444
+ : normalizedCalls;
299
445
  return {
300
446
  records: aggregateCalls(filtered),
301
447
  calls: filtered,
302
448
  filesParsed,
303
- agentsDetected: [...new Set(filtered.map((call) => call.agent))]
449
+ agentsDetected: [...new Set(filtered.map((call) => call.agent))],
450
+ ...(codexInvocationFiles ? { codexInvocationFiles } : {})
304
451
  };
305
452
  }
306
453
  /** Aggregate per-call usage into one UsageRecord per day+agent+model+project. */
307
454
  export function aggregateCalls(calls) {
308
455
  const groups = new Map();
309
- for (const call of calls) {
456
+ for (const call of dedupeCumulativeSessionCalls(calls)) {
310
457
  const day = call.timestamp.slice(0, 10);
311
458
  const key = [day, call.agent, call.model, call.project ?? "unattributed"].join("|");
312
459
  groups.set(key, [...(groups.get(key) ?? []), call]);
@@ -344,6 +491,7 @@ export function aggregateCalls(calls) {
344
491
  projectId: project === "unattributed" || project === "(home)" ? undefined : project,
345
492
  agentId: agent,
346
493
  providerCostType: "local_agent_logs",
494
+ usageGranularity: "daily_aggregate",
347
495
  quantity: groupCalls.length,
348
496
  operation: `${agent} sessions`
349
497
  });
@@ -379,6 +527,9 @@ function projectFromCwd(cwd) {
379
527
  const name = basename(cwd);
380
528
  return name.length > 0 ? name : undefined;
381
529
  }
530
+ function absoluteWorkingDirectory(cwd) {
531
+ return cwd && isAbsolute(cwd) ? resolve(cwd) : undefined;
532
+ }
382
533
  /**
383
534
  * Codex can be launched from HOME and do nearly all of its work through tools
384
535
  * that declare a more specific working directory. Prefer that observed
@@ -412,12 +563,18 @@ function projectFromTranscriptPath(filePath) {
412
563
  const tail = parent.split("-").filter(Boolean).pop();
413
564
  return tail && tail.length > 0 ? tail : undefined;
414
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
+ }
415
572
  const FOCUS_STOP_WORDS = new Set([
416
- "about", "after", "again", "also", "and", "are", "at", "been", "being", "but",
573
+ "about", "above", "after", "again", "also", "and", "are", "at", "been", "being", "but",
417
574
  "can", "check", "could", "did", "does", "doing", "dont", "every", "from",
418
575
  "for", "have", "here", "how", "in", "into", "its", "just", "like", "make", "more",
419
576
  "need", "not", "now", "on", "only", "other", "our", "please", "really", "should",
420
- "something", "sure", "than", "that", "the", "their", "them", "then", "there",
577
+ "earlier", "is", "mentioned", "one", "something", "sure", "than", "that", "the", "their", "them", "then", "there",
421
578
  "these", "they", "thing", "think", "this", "through", "to", "too", "use", "user",
422
579
  "users", "want", "was", "way", "we", "what", "when", "where", "which", "while",
423
580
  "who", "why", "will", "with", "work", "working", "would", "you", "your",
@@ -493,12 +650,49 @@ function focusTopic(prompts) {
493
650
  if (prompts.length === 0)
494
651
  return undefined;
495
652
  const recent = prompts.slice(-12);
496
- 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;
497
690
  const tokenScores = new Map();
498
691
  const pairScores = new Map();
499
692
  recent.forEach((prompt, index) => {
500
693
  const weight = 1 + index / Math.max(1, recent.length - 1);
501
- 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));
502
696
  const unique = [...new Set(tokens)];
503
697
  for (const token of unique) {
504
698
  tokenScores.set(token, (tokenScores.get(token) ?? 0) + weight);
@@ -526,22 +720,9 @@ function focusTopic(prompts) {
526
720
  if (candidateTokens.size === 0)
527
721
  return undefined;
528
722
  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
723
  if (tokens.includes("hover")) {
537
724
  return tokens.includes("ui") ? "hover UI" : "hover interaction";
538
725
  }
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
726
  return tokens.map(displayToken).join(" ");
546
727
  }
547
728
  function inferAction(prompts, title) {
@@ -619,9 +800,36 @@ function topicTokens(value) {
619
800
  // Absolute paths often appear in attached-image metadata and tool-oriented
620
801
  // prompts. They are machine context, not the user's work topic, and can
621
802
  // otherwise outrank meaningful words when only one recent prompt exists.
622
- 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, " ");
623
806
  return promptTokens(sanitizeLocalActivityText(withoutAbsolutePaths));
624
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
+ }
625
833
  /**
626
834
  * Remove known and assignment-shaped credentials from metadata before it can
627
835
  * become a topic, title, Glance field, MCP result, or copy-ready handoff.
@@ -698,6 +906,26 @@ function toIso(value) {
698
906
  const parsed = Date.parse(value);
699
907
  return Number.isFinite(parsed) ? new Date(parsed).toISOString() : undefined;
700
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
+ }
701
929
  function sum(calls, pick) {
702
930
  return calls.reduce((total, call) => total + pick(call), 0);
703
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
  });
@@ -49,11 +49,38 @@ export function parseUsageCsv(contents) {
49
49
  userId: optionalValue(row.user_id),
50
50
  workspaceId: optionalValue(row.workspace_id),
51
51
  apiKeyId: optionalValue(row.api_key_id),
52
- operation: optionalValue(row.operation)
52
+ providerCostType: optionalValue(row.provider_cost_type),
53
+ operation: optionalValue(row.operation),
54
+ usageGranularity: optionalValue(row.usage_granularity),
55
+ workloadSemantics: workloadSemantics(row)
53
56
  });
54
57
  });
55
58
  }
56
59
  function optionalValue(value) {
57
60
  return value === "" ? undefined : value;
58
61
  }
62
+ function workloadSemantics(row) {
63
+ const stableInputFingerprint = optionalValue(row.stable_input_fingerprint);
64
+ const batchEligible = optionalBoolean(row.batch_eligible);
65
+ const downgradeSafe = optionalBoolean(row.downgrade_safe);
66
+ if (stableInputFingerprint === undefined &&
67
+ batchEligible === undefined &&
68
+ downgradeSafe === undefined) {
69
+ return undefined;
70
+ }
71
+ return {
72
+ ...(stableInputFingerprint ? { stableInputFingerprint } : {}),
73
+ ...(batchEligible !== undefined ? { batchEligible } : {}),
74
+ ...(downgradeSafe !== undefined ? { downgradeSafe } : {})
75
+ };
76
+ }
77
+ function optionalBoolean(value) {
78
+ if (value === undefined || value === "")
79
+ return undefined;
80
+ if (value === "true")
81
+ return true;
82
+ if (value === "false")
83
+ return false;
84
+ throw new Error(`Expected true/false CSV value, received ${JSON.stringify(value)}.`);
85
+ }
59
86
  //# sourceMappingURL=sampleData.js.map