@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.
- package/dist/agentInventory.d.ts +11 -14
- package/dist/agentInventory.js +180 -46
- package/dist/analyze.js +165 -113
- 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 +305 -77
- 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 +1 -1
- 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;
|
|
@@ -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
|
-
|
|
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
|
-
|
|
182
|
-
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);
|
|
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:
|
|
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
|
-
|
|
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
|
|
296
|
-
const filtered = typeof
|
|
297
|
-
?
|
|
298
|
-
:
|
|
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
|
|
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)
|
|
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
|
});
|
package/dist/sampleData.js
CHANGED
|
@@ -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
|
-
|
|
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
|