@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.
- package/README.md +5 -1
- package/dist/agentInventory.d.ts +11 -14
- package/dist/agentInventory.js +180 -46
- package/dist/analyze.js +166 -114
- package/dist/attribution.js +9 -6
- package/dist/contextHealth.d.ts +10 -1
- package/dist/contextHealth.js +216 -44
- package/dist/cutList.d.ts +9 -3
- package/dist/cutList.js +99 -41
- package/dist/deadContext.d.ts +8 -4
- package/dist/deadContext.js +83 -26
- package/dist/glance.d.ts +3 -0
- package/dist/glance.js +46 -12
- package/dist/insights.js +53 -30
- package/dist/localAgentLogs.d.ts +44 -2
- package/dist/localAgentLogs.js +333 -78
- package/dist/providerConnectors.js +8 -0
- package/dist/sampleData.js +28 -1
- package/dist/schema.d.ts +83 -7
- package/dist/schema.js +85 -1
- package/dist/toolInvocations.d.ts +47 -18
- package/dist/toolInvocations.js +200 -48
- package/package.json +2 -2
- package/samples/anthropic-usage.csv +4 -4
- package/samples/openai-usage.csv +7 -7
package/dist/localAgentLogs.js
CHANGED
|
@@ -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
|
|
11
|
-
const
|
|
59
|
+
const pendingPrompts = [];
|
|
60
|
+
const activityEvidence = new Map();
|
|
12
61
|
let title;
|
|
13
62
|
let lastPrompt;
|
|
14
|
-
let
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
74
|
-
project
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
176
|
-
const
|
|
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:
|
|
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
|
-
|
|
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
|
|
272
|
-
const filtered = typeof
|
|
273
|
-
?
|
|
274
|
-
:
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
});
|