@agent-finops/core 0.5.8 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -36,10 +36,12 @@ export function parseClaudeCodeInvocations(content, sinceMs) {
36
36
  if (timestamp && (!lastActivityAt || timestamp > lastActivityAt)) {
37
37
  lastActivityAt = timestamp;
38
38
  }
39
- // sinceIso filter: skip lines older than the cutoff when a timestamp exists.
39
+ // A selected window requires dated evidence. Undated lines cannot prove an
40
+ // invocation occurred inside that window and must not create removal-safe
41
+ // coverage by accident.
40
42
  if (typeof sinceMs === "number") {
41
43
  const ts = Date.parse(stringOf(entry.timestamp) ?? "");
42
- if (Number.isFinite(ts) && ts < sinceMs)
44
+ if (!Number.isFinite(ts) || ts < sinceMs)
43
45
  continue;
44
46
  }
45
47
  if (isClaudeCompactionEntry(entry))
@@ -114,6 +116,27 @@ export function parseClaudeCodeInvocations(content, sinceMs) {
114
116
  }
115
117
  /** Parse ONE Codex rollout's tool/skill/subagent invocations. */
116
118
  export function parseCodexInvocations(content, sinceMs) {
119
+ const collector = createCodexInvocationCollector(sinceMs);
120
+ for (const line of content.split("\n")) {
121
+ if (!line.trim())
122
+ continue;
123
+ let entry;
124
+ try {
125
+ entry = JSON.parse(line);
126
+ }
127
+ catch {
128
+ continue;
129
+ }
130
+ if (isRecord(entry))
131
+ collector.consume(entry);
132
+ }
133
+ return collector.finish();
134
+ }
135
+ /**
136
+ * Stateful Codex invocation parser used to share localAgentLogs' JSONL pass.
137
+ * One collector is created per rollout file and discarded after `finish()`.
138
+ */
139
+ export function createCodexInvocationCollector(sinceMs) {
117
140
  const counts = new Map();
118
141
  const mcpTools = new Set();
119
142
  const skills = new Set();
@@ -122,35 +145,70 @@ export function parseCodexInvocations(content, sinceMs) {
122
145
  const fileReads = new Map();
123
146
  let assistantTurns = 0;
124
147
  let sessionId;
148
+ let rootSessionMetaSeen = false;
149
+ let rootStartedAtMs;
150
+ let rootTaskStarted = false;
125
151
  let lastActivityAt;
126
152
  let compactionEvents = 0;
127
- for (const line of content.split("\n")) {
128
- if (!line.trim())
129
- continue;
130
- let entry;
131
- try {
132
- entry = JSON.parse(line);
153
+ let isSubagent = false;
154
+ let parentSessionId;
155
+ const nestedSessions = new Map();
156
+ const resetObservedEvidence = () => {
157
+ counts.clear();
158
+ mcpTools.clear();
159
+ skills.clear();
160
+ subagents.clear();
161
+ commands.clear();
162
+ fileReads.clear();
163
+ assistantTurns = 0;
164
+ compactionEvents = 0;
165
+ };
166
+ const consume = (entry) => {
167
+ const payload = isRecord(entry.payload) ? entry.payload : undefined;
168
+ if (entry.type === "session_meta" && payload) {
169
+ const metadata = codexSessionMetadata(payload);
170
+ if (!rootSessionMetaSeen) {
171
+ // Forked rollouts can contain a complete parent history after the
172
+ // first line. Bind this file to its first/root metadata exactly once.
173
+ rootSessionMetaSeen = true;
174
+ sessionId = metadata.sessionId;
175
+ isSubagent = metadata.isSubagent;
176
+ parentSessionId = metadata.parentSessionId;
177
+ rootStartedAtMs = timestampMilliseconds(payload.timestamp ?? entry.timestamp);
178
+ }
179
+ else if (metadata.sessionId !== sessionId ||
180
+ metadata.parentSessionId !== parentSessionId ||
181
+ metadata.isSubagent !== isSubagent) {
182
+ const key = [
183
+ metadata.sessionId ?? "",
184
+ metadata.parentSessionId ?? "",
185
+ metadata.isSubagent ? "subagent" : "session"
186
+ ].join("\u0000");
187
+ nestedSessions.set(key, metadata);
188
+ }
133
189
  }
134
- catch {
135
- continue;
190
+ if (isSubagent &&
191
+ !rootTaskStarted &&
192
+ payload?.type === "task_started" &&
193
+ isRootSpecificTaskStart(payload.started_at, rootStartedAtMs)) {
194
+ // Everything before this boundary is inherited parent history copied
195
+ // into the fork. It is useful nested provenance, but it is not evidence
196
+ // that the child invoked those tools or incurred those compactions.
197
+ rootTaskStarted = true;
198
+ resetObservedEvidence();
199
+ lastActivityAt = stringOf(entry.timestamp) ?? lastActivityAt;
136
200
  }
137
- if (!isRecord(entry))
138
- continue;
139
201
  const timestampValue = stringOf(entry.timestamp);
140
202
  if (timestampValue && (!lastActivityAt || timestampValue > lastActivityAt)) {
141
203
  lastActivityAt = timestampValue;
142
204
  }
143
205
  if (typeof sinceMs === "number") {
144
206
  const timestamp = Date.parse(stringOf(entry.timestamp) ?? "");
145
- if (Number.isFinite(timestamp) && timestamp < sinceMs)
146
- continue;
207
+ if (!Number.isFinite(timestamp) || timestamp < sinceMs)
208
+ return;
147
209
  }
148
- const payload = isRecord(entry.payload) ? entry.payload : undefined;
149
210
  if (!payload)
150
- continue;
151
- if (entry.type === "session_meta") {
152
- sessionId = stringOf(payload.id) ?? sessionId;
153
- }
211
+ return;
154
212
  // Codex writes both a top-level `compacted` entry and a separate
155
213
  // `context_compacted` event for one compaction. Count only the former.
156
214
  if (entry.type === "compacted")
@@ -158,7 +216,7 @@ export function parseCodexInvocations(content, sinceMs) {
158
216
  // Codex emits one turn_context per model turn.
159
217
  if (entry.type === "turn_context") {
160
218
  assistantTurns += 1;
161
- continue;
219
+ return;
162
220
  }
163
221
  if (payload.type === "message" && payload.role === "user") {
164
222
  for (const text of codexTextValues(payload.content)) {
@@ -166,14 +224,14 @@ export function parseCodexInvocations(content, sinceMs) {
166
224
  if (command)
167
225
  commands.add(command);
168
226
  }
169
- continue;
227
+ return;
170
228
  }
171
229
  if (payload.type !== "function_call" && payload.type !== "custom_tool_call") {
172
- continue;
230
+ return;
173
231
  }
174
232
  const name = stringOf(payload.name);
175
233
  if (!name)
176
- continue;
234
+ return;
177
235
  counts.set(name, (counts.get(name) ?? 0) + 1);
178
236
  const input = codexToolInput(payload);
179
237
  if (name.startsWith("mcp__")) {
@@ -194,25 +252,35 @@ export function parseCodexInvocations(content, sinceMs) {
194
252
  const file = input && explicitReadFile(name, input);
195
253
  if (file)
196
254
  fileReads.set(file, (fileReads.get(file) ?? 0) + 1);
197
- }
198
- return {
199
- invocations: [...counts.entries()]
200
- .map(([name, count]) => ({ name, count }))
201
- .sort((left, right) => right.count - left.count || left.name.localeCompare(right.name)),
202
- invokedMcpTools: [...mcpTools].sort(),
203
- invokedSkills: [...skills].sort(),
204
- invokedSubagents: [...subagents].sort(),
205
- invokedCommands: [...commands].sort(),
206
- assistantTurns,
207
- contextSignal: buildSessionContextSignal({
208
- agent: "codex",
209
- sessionId,
210
- lastActivityAt,
211
- compactionEvents,
212
- fileReads,
213
- isSubagent: false
214
- })
215
255
  };
256
+ const finish = () => {
257
+ // Without a recognized root task boundary, an older-format child file is
258
+ // indistinguishable from copied parent history. Preserve nested metadata,
259
+ // but do not manufacture child invocation/turn coverage from it.
260
+ if (isSubagent && !rootTaskStarted)
261
+ resetObservedEvidence();
262
+ return {
263
+ invocations: [...counts.entries()]
264
+ .map(([name, count]) => ({ name, count }))
265
+ .sort((left, right) => right.count - left.count || left.name.localeCompare(right.name)),
266
+ invokedMcpTools: [...mcpTools].sort(),
267
+ invokedSkills: [...skills].sort(),
268
+ invokedSubagents: [...subagents].sort(),
269
+ invokedCommands: [...commands].sort(),
270
+ assistantTurns,
271
+ contextSignal: buildSessionContextSignal({
272
+ agent: "codex",
273
+ sessionId,
274
+ lastActivityAt,
275
+ compactionEvents,
276
+ fileReads,
277
+ isSubagent,
278
+ parentSessionId,
279
+ nestedSessions: [...nestedSessions.values()]
280
+ })
281
+ };
282
+ };
283
+ return { consume, finish };
216
284
  }
217
285
  /** Scan this machine's Claude Code + Codex transcripts and aggregate invocations. */
218
286
  export async function loadToolInvocations(options = {}) {
@@ -227,6 +295,10 @@ export async function loadToolInvocations(options = {}) {
227
295
  const commands = new Set();
228
296
  const sessionTurnCounts = [];
229
297
  const sessionSignals = [];
298
+ const hostEvidence = {
299
+ "claude-code": createHostInvocationAccumulator(),
300
+ codex: createHostInvocationAccumulator()
301
+ };
230
302
  let sessions = 0;
231
303
  let claudeCodeSessions = 0;
232
304
  let codexSessions = 0;
@@ -234,11 +306,18 @@ export async function loadToolInvocations(options = {}) {
234
306
  const content = await readFile(file, "utf8").catch(() => "");
235
307
  if (!content)
236
308
  continue;
309
+ const parsed = parseClaudeCodeInvocations(content, sinceMs);
310
+ // A transcript file is not coverage for the selected window merely
311
+ // because it exists on disk. Without an in-window assistant turn there
312
+ // was no opportunity to observe an invocation, so counting the file would
313
+ // manufacture configured-without-invocation findings from stale history.
314
+ if (parsed.assistantTurns === 0)
315
+ continue;
237
316
  sessions += 1;
238
317
  claudeCodeSessions += 1;
239
- const parsed = parseClaudeCodeInvocations(content, sinceMs);
240
318
  sessionTurnCounts.push(parsed.assistantTurns);
241
319
  sessionSignals.push(parsed.contextSignal);
320
+ addHostInvocationEvidence(hostEvidence["claude-code"], parsed);
242
321
  for (const { name, count } of parsed.invocations) {
243
322
  counts.set(name, (counts.get(name) ?? 0) + count);
244
323
  }
@@ -251,17 +330,26 @@ export async function loadToolInvocations(options = {}) {
251
330
  for (const c of parsed.invokedCommands)
252
331
  commands.add(c);
253
332
  }
254
- for (const file of await listJsonlFiles(codexDir)) {
255
- if (!file.split(/[\\/]/).pop()?.startsWith("rollout-"))
256
- continue;
257
- const content = await readFile(file, "utf8").catch(() => "");
258
- if (!content)
333
+ const codexInvocationFiles = options.codexInvocationFiles ?? await (async () => {
334
+ const parsedFiles = [];
335
+ for (const file of await listJsonlFiles(codexDir)) {
336
+ if (!file.split(/[\\/]/).pop()?.startsWith("rollout-"))
337
+ continue;
338
+ const content = await readFile(file, "utf8").catch(() => "");
339
+ if (!content)
340
+ continue;
341
+ parsedFiles.push(parseCodexInvocations(content, sinceMs));
342
+ }
343
+ return parsedFiles;
344
+ })();
345
+ for (const parsed of codexInvocationFiles) {
346
+ if (parsed.assistantTurns === 0)
259
347
  continue;
260
348
  sessions += 1;
261
349
  codexSessions += 1;
262
- const parsed = parseCodexInvocations(content, sinceMs);
263
350
  sessionTurnCounts.push(parsed.assistantTurns);
264
351
  sessionSignals.push(parsed.contextSignal);
352
+ addHostInvocationEvidence(hostEvidence.codex, parsed);
265
353
  for (const { name, count } of parsed.invocations) {
266
354
  counts.set(name, (counts.get(name) ?? 0) + count);
267
355
  }
@@ -290,9 +378,40 @@ export async function loadToolInvocations(options = {}) {
290
378
  claudeCode: claudeCodeSessions,
291
379
  codex: codexSessions
292
380
  },
381
+ byHost: {
382
+ "claude-code": finishHostInvocationEvidence(hostEvidence["claude-code"]),
383
+ codex: finishHostInvocationEvidence(hostEvidence.codex)
384
+ },
293
385
  sessionSignals
294
386
  };
295
387
  }
388
+ function createHostInvocationAccumulator() {
389
+ return {
390
+ sessionTurnCounts: [],
391
+ mcpTools: new Set(),
392
+ skills: new Set(),
393
+ subagents: new Set(),
394
+ commands: new Set()
395
+ };
396
+ }
397
+ function addHostInvocationEvidence(accumulator, parsed) {
398
+ accumulator.sessionTurnCounts.push(parsed.assistantTurns);
399
+ parsed.invokedMcpTools.forEach((value) => accumulator.mcpTools.add(value));
400
+ parsed.invokedSkills.forEach((value) => accumulator.skills.add(value));
401
+ parsed.invokedSubagents.forEach((value) => accumulator.subagents.add(value));
402
+ parsed.invokedCommands.forEach((value) => accumulator.commands.add(value));
403
+ }
404
+ function finishHostInvocationEvidence(accumulator) {
405
+ return {
406
+ sessions: accumulator.sessionTurnCounts.length,
407
+ totalAssistantTurns: accumulator.sessionTurnCounts.reduce((sum, count) => sum + count, 0),
408
+ sessionTurnCounts: accumulator.sessionTurnCounts,
409
+ invokedMcpTools: [...accumulator.mcpTools].sort(),
410
+ invokedSkills: [...accumulator.skills].sort(),
411
+ invokedSubagents: [...accumulator.subagents].sort(),
412
+ invokedCommands: [...accumulator.commands].sort()
413
+ };
414
+ }
296
415
  /** Extract "/foo" slash-command names from a user entry's content. */
297
416
  function slashCommandsFrom(entry) {
298
417
  const message = isRecord(entry.message) ? entry.message : undefined;
@@ -417,9 +536,42 @@ function buildSessionContextSignal(input) {
417
536
  repeatedFileReads: fileReads.filter((file) => file.count > 1),
418
537
  isSubagent: input.isSubagent,
419
538
  ...(input.parentSessionId ? { parentSessionId: input.parentSessionId } : {}),
539
+ ...(input.nestedSessions && input.nestedSessions.length > 0
540
+ ? { nestedSessions: input.nestedSessions }
541
+ : {}),
420
542
  readCoverage: "explicit_read_tools_only"
421
543
  };
422
544
  }
545
+ function codexSessionMetadata(payload) {
546
+ return {
547
+ ...(stringOf(payload.id) ? { sessionId: stringOf(payload.id) } : {}),
548
+ isSubagent: stringOf(payload.thread_source) === "subagent" ||
549
+ isRecord(payload.source) && "subagent" in payload.source,
550
+ ...(stringOf(payload.parent_thread_id)
551
+ ? { parentSessionId: stringOf(payload.parent_thread_id) }
552
+ : {})
553
+ };
554
+ }
555
+ const ROOT_TASK_CLOCK_TOLERANCE_MS = 5_000;
556
+ function isRootSpecificTaskStart(value, rootStartedAtMs) {
557
+ const taskStartedAtMs = timestampMilliseconds(value);
558
+ return typeof rootStartedAtMs === "number" &&
559
+ typeof taskStartedAtMs === "number" &&
560
+ taskStartedAtMs >= rootStartedAtMs - ROOT_TASK_CLOCK_TOLERANCE_MS;
561
+ }
562
+ function timestampMilliseconds(value) {
563
+ if (typeof value === "number" && Number.isFinite(value)) {
564
+ return value < 1_000_000_000_000 ? value * 1_000 : value;
565
+ }
566
+ if (typeof value !== "string" || value.length === 0)
567
+ return undefined;
568
+ const numeric = Number(value);
569
+ if (Number.isFinite(numeric)) {
570
+ return numeric < 1_000_000_000_000 ? numeric * 1_000 : numeric;
571
+ }
572
+ const parsed = Date.parse(value);
573
+ return Number.isFinite(parsed) ? parsed : undefined;
574
+ }
423
575
  function isRecord(value) {
424
576
  return typeof value === "object" && value !== null;
425
577
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-finops/core",
3
- "version": "0.5.8",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -1,4 +1,4 @@
1
- id,timestamp,source_id,source_name,provider,source_confidence,observed_from,model,input_tokens,output_tokens,amount_usd,cost_confidence,client_id,project_id,agent_id,user_id,workspace_id,api_key_id,operation
2
- ant-001,2026-05-17T16:00:00.000Z,anthropic-sample,Anthropic sample export,anthropic,detected_unverified,sample_csv,claude-fable-5,90000,9000,8.00,detected_unverified,client-acme,project-support,agent-drafter,user-ops-lead,workspace-acme,anthropic-key-support,reply_draft
3
- ant-002,2026-05-18T12:20:00.000Z,anthropic-sample,Anthropic sample export,anthropic,detected_unverified,sample_csv,claude-haiku-4-5,42000,5000,5.50,detected_unverified,client-acme,project-support,agent-drafter,user-support-rep,workspace-acme,anthropic-key-support,reply_draft
4
- ant-003,2026-05-19T17:05:00.000Z,anthropic-sample,Anthropic sample export,anthropic,detected_unverified,sample_csv,claude-fable-5,140000,16000,16.90,detected_unverified,client-beta,project-research,agent-analyst,user-research-lead,workspace-beta,anthropic-key-research,research_summary
1
+ id,timestamp,source_id,source_name,provider,source_confidence,observed_from,model,input_tokens,output_tokens,amount_usd,cost_confidence,client_id,project_id,agent_id,user_id,workspace_id,api_key_id,provider_cost_type,operation,usage_granularity,stable_input_fingerprint,batch_eligible,downgrade_safe
2
+ ant-001,2026-05-17T16:00:00.000Z,anthropic-sample,Anthropic sample export,anthropic,detected_unverified,sample_csv,claude-fable-5,90000,9000,8.00,detected_unverified,client-acme,project-support,agent-drafter,user-ops-lead,workspace-acme,anthropic-key-support,sample_call,reply_draft,call,,false,true
3
+ ant-002,2026-05-18T12:20:00.000Z,anthropic-sample,Anthropic sample export,anthropic,detected_unverified,sample_csv,claude-haiku-4-5,42000,5000,5.50,detected_unverified,client-acme,project-support,agent-drafter,user-support-rep,workspace-acme,anthropic-key-support,sample_call,reply_draft,call,,false,true
4
+ ant-003,2026-05-19T17:05:00.000Z,anthropic-sample,Anthropic sample export,anthropic,detected_unverified,sample_csv,claude-fable-5,140000,16000,16.90,detected_unverified,client-beta,project-research,agent-analyst,user-research-lead,workspace-beta,anthropic-key-research,sample_call,research_summary,call,research-summary-anthropic-v1,true,true
@@ -1,7 +1,7 @@
1
- id,timestamp,source_id,source_name,provider,source_confidence,observed_from,model,input_tokens,output_tokens,amount_usd,cost_confidence,client_id,project_id,agent_id,user_id,workspace_id,api_key_id,operation
2
- oai-001,2026-05-17T10:00:00.000Z,openai-sample,OpenAI sample export,openai,estimated,sample_csv,gpt-5.5,60000,7000,4.80,estimated,client-acme,project-support,agent-triage,user-ops-lead,workspace-acme,key-support,ticket_triage
3
- oai-002,2026-05-17T13:30:00.000Z,openai-sample,OpenAI sample export,openai,estimated,sample_csv,gpt-5.5-mini,32000,5000,2.70,estimated,client-acme,project-support,agent-triage,user-support-rep,workspace-acme,key-support,ticket_triage
4
- oai-003,2026-05-18T09:10:00.000Z,openai-sample,OpenAI sample export,openai,verified,sample_csv,gpt-5.5,70000,8500,6.10,verified,client-beta,project-research,agent-analyst,user-research-lead,workspace-beta,key-research,research_summary
5
- oai-004,2026-05-18T15:45:00.000Z,openai-sample,OpenAI sample export,openai,verified,sample_csv,gpt-5.5-mini,24000,4200,2.00,verified,client-acme,project-support,agent-triage,user-support-rep,workspace-acme,key-support,ticket_triage
6
- oai-005,2026-05-19T11:15:00.000Z,openai-sample,OpenAI sample export,openai,estimated,sample_csv,gpt-5.5,180000,14000,22.40,estimated,client-beta,project-research,agent-analyst,user-research-lead,workspace-beta,key-research,research_summary
7
- oai-006,2026-05-19T14:40:00.000Z,openai-sample,OpenAI sample export,openai,estimated,sample_csv,gpt-5.5,160000,12000,18.60,estimated,client-beta,project-research,agent-analyst,user-research-lead,workspace-beta,key-research,research_summary
1
+ id,timestamp,source_id,source_name,provider,source_confidence,observed_from,model,input_tokens,output_tokens,amount_usd,cost_confidence,client_id,project_id,agent_id,user_id,workspace_id,api_key_id,provider_cost_type,operation,usage_granularity,stable_input_fingerprint,batch_eligible,downgrade_safe
2
+ oai-001,2026-05-17T10:00:00.000Z,openai-sample,OpenAI sample export,openai,estimated,sample_csv,gpt-5.5,60000,7000,4.80,estimated,client-acme,project-support,agent-triage,user-ops-lead,workspace-acme,key-support,sample_call,ticket_triage,call,,false,true
3
+ oai-002,2026-05-17T13:30:00.000Z,openai-sample,OpenAI sample export,openai,estimated,sample_csv,gpt-5.5-mini,32000,5000,2.70,estimated,client-acme,project-support,agent-triage,user-support-rep,workspace-acme,key-support,sample_call,ticket_triage,call,,false,true
4
+ oai-003,2026-05-18T09:10:00.000Z,openai-sample,OpenAI sample export,openai,estimated,sample_csv,gpt-5.5,70000,8500,6.10,estimated,client-beta,project-research,agent-analyst,user-research-lead,workspace-beta,key-research,sample_call,research_summary,call,research-summary-v1,true,true
5
+ oai-004,2026-05-18T15:45:00.000Z,openai-sample,OpenAI sample export,openai,estimated,sample_csv,gpt-5.5-mini,24000,4200,2.00,estimated,client-acme,project-support,agent-triage,user-support-rep,workspace-acme,key-support,sample_call,ticket_triage,call,,false,true
6
+ oai-005,2026-05-19T11:15:00.000Z,openai-sample,OpenAI sample export,openai,estimated,sample_csv,gpt-5.5,180000,14000,22.40,estimated,client-beta,project-research,agent-analyst,user-research-lead,workspace-beta,key-research,sample_call,research_summary,call,research-summary-v1,true,true
7
+ oai-006,2026-05-19T14:40:00.000Z,openai-sample,OpenAI sample export,openai,estimated,sample_csv,gpt-5.5,160000,12000,18.60,estimated,client-beta,project-research,agent-analyst,user-research-lead,workspace-beta,key-research,sample_call,research_summary,call,research-summary-v1,true,true