@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/insights.js CHANGED
@@ -1,4 +1,4 @@
1
- import { spendInsightSchema } from "./schema.js";
1
+ import { hasCallLevelProvenance, hasPricedEvidence, spendComparisonKey, spendInsightSchema } from "./schema.js";
2
2
  const confidenceRank = {
3
3
  verified: 0,
4
4
  estimated: 1,
@@ -25,24 +25,32 @@ export function generateSpendInsights(records, summary) {
25
25
  }
26
26
  function spikeInsights(records, summary) {
27
27
  return summary.anomalies.map((anomaly) => {
28
- const currentRecords = records.filter((record) => record.timestamp.slice(0, 10) === anomaly.key);
28
+ const currentRecords = records.filter((record) => record.timestamp.slice(0, 10) === anomaly.key &&
29
+ (anomaly.comparisonKey === undefined || spendComparisonKey(record) === anomaly.comparisonKey));
29
30
  const topAgent = topBreakdown(currentRecords, (record) => record.agentId);
30
31
  const topClient = topBreakdown(currentRecords, (record) => record.clientId);
31
32
  const topProject = topBreakdown(currentRecords, (record) => record.projectId);
32
33
  const topModels = breakdown(currentRecords, (record) => record.model).slice(0, 2).map((entry) => entry.key);
33
34
  const deltaUsd = roundMoney(anomaly.currentAmountUsd - anomaly.previousAmountUsd);
34
- const likelyDriver = topAgent?.key ?? topProject?.key ?? topClient?.key ?? "unmapped usage";
35
+ const likelyOwner = topAgent?.key ?? topProject?.key ?? topClient?.key ?? "an unassigned owner";
36
+ const cohortSuffix = stableSuffix(anomaly.comparisonKey ?? "legacy");
37
+ const isProviderBilledCost = currentRecords.length > 0 && currentRecords.every((record) => record.usageGranularity === "billing_bucket" &&
38
+ record.costConfidence === "verified");
39
+ const isAggregateCohort = currentRecords.some((record) => !hasCallLevelProvenance(record));
40
+ const evidenceLabel = isProviderBilledCost ? "Spend" : "Cost/value evidence";
41
+ const evidenceBasis = uniqueStrings(currentRecords.map((record) => `${record.source.provider} · ${record.providerCostType ?? "unclassified"} · ${record.usageGranularity ?? "unclassified"}`)).join(", ");
35
42
  return {
36
- id: `spike-${anomaly.key}`,
43
+ id: `spike-${anomaly.key}-${cohortSuffix}`,
37
44
  kind: "spike_explanation",
38
45
  severity: deltaUsd >= 25 || anomaly.multiplier >= 3 ? "critical" : "high",
39
- title: `Spend spike on ${anomaly.key} needs owner review`,
40
- summary: `${anomaly.key} spend rose ${formatMultiplier(anomaly.multiplier)} day over day, from ${formatUsd(anomaly.previousAmountUsd)} to ${formatUsd(anomaly.currentAmountUsd)}. The likely driver is ${likelyDriver}, so this needs owner review before the pattern repeats.`,
46
+ title: `${evidenceLabel} spike on ${anomaly.key} needs owner review`,
47
+ summary: `${anomaly.key} ${evidenceLabel.toLowerCase()} in one comparable provider cohort rose ${formatMultiplier(anomaly.multiplier)} day over day, from ${formatUsd(anomaly.previousAmountUsd)} to ${formatUsd(anomaly.currentAmountUsd)}. ${likelyOwner} is the best available ownership lead; ${isAggregateCohort ? "this aggregate bucket does not identify causal runs" : "this cohort change does not by itself prove a cause"}.`,
41
48
  evidence: compactEvidence([
42
- { label: "Previous day spend", value: formatUsd(anomaly.previousAmountUsd) },
43
- { label: "Current day spend", value: formatUsd(anomaly.currentAmountUsd) },
44
- { label: "Increase", value: formatUsd(deltaUsd), detail: `${formatMultiplier(anomaly.multiplier)} day-over-day multiplier` },
45
- topAgent ? { label: "Likely driver", value: topAgent.key, detail: `${formatUsd(topAgent.amountUsd)} across ${topAgent.recordCount} records` } : undefined,
49
+ evidenceBasis ? { label: "Comparison basis", value: evidenceBasis } : undefined,
50
+ { label: `Previous cohort ${isProviderBilledCost ? "spend" : "value"}`, value: formatUsd(anomaly.previousAmountUsd) },
51
+ { label: `Current cohort ${isProviderBilledCost ? "spend" : "value"}`, value: formatUsd(anomaly.currentAmountUsd) },
52
+ { label: `${evidenceLabel} increase`, value: formatUsd(deltaUsd), detail: `${formatMultiplier(anomaly.multiplier)} day-over-day multiplier` },
53
+ topAgent ? { label: "Ownership lead", value: topAgent.key, detail: `${formatUsd(topAgent.amountUsd)} across ${topAgent.recordCount} cohort records` } : undefined,
46
54
  topClient ? { label: "Client concentration", value: topClient.key, detail: `${formatUsd(topClient.amountUsd)} on spike day` } : undefined,
47
55
  topModels.length > 0 ? { label: "Dominant models", value: topModels.join(", ") } : undefined
48
56
  ]),
@@ -52,8 +60,10 @@ function spikeInsights(records, summary) {
52
60
  affectedModels: keysFrom(currentRecords, (record) => record.model),
53
61
  estimatedImpactUsd: deltaUsd,
54
62
  confidence: anomaly.confidence,
55
- recommendedAction: `Review the ${likelyDriver} runs from ${anomaly.key}, set a temporary warning threshold for this owner, and pause expansion until the largest calls have an expected budget range.`,
56
- verificationNeeded: "Verify the spike against the provider billing export before treating the dollar amount as finance-grade."
63
+ recommendedAction: `Review and reconcile the provider-cohort records from ${anomaly.key}, confirm the accountable owner, and obtain run-level evidence before diagnosing behavior or changing a policy.`,
64
+ verificationNeeded: isAggregateCohort
65
+ ? "Verify both periods against the same provider report shape; aggregate buckets do not identify causal calls or savings."
66
+ : "Verify both periods use the same call-level schema and inspect the underlying workloads before attributing cause or savings."
57
67
  };
58
68
  });
59
69
  }
@@ -69,31 +79,33 @@ function agentCostDriverInsights(records, summary) {
69
79
  }
70
80
  const topOperation = topBreakdown(agentRecords, (record) => record.operation);
71
81
  const topModel = topBreakdown(agentRecords, (record) => record.model);
72
- const estimatedImpactUsd = roundMoney(topAgent.amountUsd * 0.15);
82
+ const hasRunLevelEvidence = agentRecords.length > 0 && agentRecords.every(hasCallLevelProvenance);
73
83
  return [{
74
- id: `agent-cost-driver-${topAgent.key}`,
75
- kind: "agent_runaway",
76
- severity: share >= 0.5 ? "high" : "medium",
77
- title: `${topAgent.key} is the dominant autonomous spend driver`,
78
- summary: `${topAgent.key} accounts for ${formatPercent(share)} of tracked spend. That is the agent to cap first because one runaway workflow can consume budget before invoice review.`,
84
+ id: `agent-spend-concentration-${topAgent.key}`,
85
+ kind: "optimization_opportunity",
86
+ severity: "medium",
87
+ title: `${topAgent.key} spend concentration needs owner and budget review`,
88
+ summary: `${topAgent.key} is attached to ${formatPercent(share)} of tracked spend. Concentration alone does not prove abnormal behavior or an avoidable dollar amount${hasRunLevelEvidence ? "." : "; the evidence is aggregate rather than run-level."}`,
79
89
  evidence: compactEvidence([
80
- { label: "Agent spend", value: formatUsd(topAgent.amountUsd), detail: `${topAgent.recordCount} records` },
90
+ { label: "Attributed spend", value: formatUsd(topAgent.amountUsd), detail: `${topAgent.recordCount} ${hasRunLevelEvidence ? "call-level" : "aggregate"} record${topAgent.recordCount === 1 ? "" : "s"}` },
81
91
  { label: "Share of tracked spend", value: formatPercent(share) },
82
- topModel ? { label: "Dominant model", value: topModel.key, detail: `${formatUsd(topModel.amountUsd)} inside this agent` } : undefined,
83
- topOperation ? { label: "Dominant operation", value: topOperation.key, detail: `${formatUsd(topOperation.amountUsd)} inside this agent` } : undefined
92
+ topModel ? { label: "Dominant model or billing label", value: topModel.key, detail: `${formatUsd(topModel.amountUsd)} in this concentration` } : undefined,
93
+ topOperation ? { label: "Operation label", value: topOperation.key, detail: hasRunLevelEvidence ? "Call-level attribution" : "Not verified as one call or run" } : undefined
84
94
  ]),
85
95
  affectedClients: keysFrom(agentRecords, (record) => record.clientId),
86
96
  affectedProjects: keysFrom(agentRecords, (record) => record.projectId),
87
97
  affectedAgents: [topAgent.key],
88
98
  affectedModels: keysFrom(agentRecords, (record) => record.model),
89
- estimatedImpactUsd,
99
+ estimatedImpactUsd: 0,
90
100
  confidence: topAgent.confidence,
91
- recommendedAction: `Set a local warning threshold and hard cap for ${topAgent.key}, then require approval when a run exceeds its expected spend range.`,
92
- verificationNeeded: "Confirm whether this agent has an approved budget owner and expected daily range."
101
+ recommendedAction: `Confirm who owns ${topAgent.key}, reconcile the spend to its approved budget, and collect behavioral evidence before setting a cap or savings target.`,
102
+ verificationNeeded: "Confirm the budget owner and expected range; concentration alone is not behavioral evidence."
93
103
  }];
94
104
  }
95
105
  function contextBloatInsights(records) {
96
- const highInputRecords = records.filter((record) => record.inputTokens >= 100_000);
106
+ const highInputRecords = records.filter((record) => hasCallLevelProvenance(record) &&
107
+ hasPricedEvidence(record) &&
108
+ record.inputTokens >= 100_000);
97
109
  if (highInputRecords.length === 0) {
98
110
  return [];
99
111
  }
@@ -111,8 +123,8 @@ function contextBloatInsights(records) {
111
123
  id: `context-bloat-${slug(operationLabel)}`,
112
124
  kind: "context_bloat",
113
125
  severity: scopedSpend >= 60 ? "high" : "medium",
114
- title: `${operationLabel} is carrying oversized context`,
115
- summary: `${operationLabel} includes ${scopedRecords.length} high-input calls and ${formatNumber(totalInputTokens)} input tokens. This is a strong signal that retrieval or prompt context can be trimmed without changing the product surface.`,
126
+ title: `${operationLabel} needs context inspection`,
127
+ summary: `${operationLabel} includes ${scopedRecords.length} high-input calls and ${formatNumber(totalInputTokens)} input tokens. This proves context exposure, not that any particular context is removable or that quality will hold after a cut.`,
116
128
  evidence: [
117
129
  { label: "High-input calls", value: String(scopedRecords.length), detail: "Calls at or above 100,000 input tokens" },
118
130
  { label: "Input tokens", value: formatNumber(totalInputTokens) },
@@ -123,10 +135,10 @@ function contextBloatInsights(records) {
123
135
  affectedProjects: keysFrom(scopedRecords, (record) => record.projectId),
124
136
  affectedAgents: keysFrom(scopedRecords, (record) => record.agentId),
125
137
  affectedModels: keysFrom(scopedRecords, (record) => record.model),
126
- estimatedImpactUsd: roundMoney(scopedSpend * 0.18),
138
+ estimatedImpactUsd: 0,
127
139
  confidence: combinedConfidence(scopedRecords.map((record) => record.costConfidence)),
128
- recommendedAction: `Sample the largest ${operationLabel} prompts, cap retrieved chunks, and require justification before agents include full documents or long histories.`,
129
- verificationNeeded: "Inspect representative prompts locally to confirm whether the large context is necessary for output quality."
140
+ recommendedAction: `Inspect representative ${operationLabel} prompts locally and run a matched before/after with the same acceptance criteria before proposing one reversible context change.`,
141
+ verificationNeeded: "Measure token and quality deltas on matched calls; no savings counterfactual is present yet."
130
142
  }];
131
143
  }
132
144
  function topBreakdown(records, select) {
@@ -150,6 +162,9 @@ function breakdown(records, select) {
150
162
  function keysFrom(records, select) {
151
163
  return Array.from(new Set(records.map(select).filter((value) => value !== undefined)));
152
164
  }
165
+ function uniqueStrings(values) {
166
+ return [...new Set(values)].sort();
167
+ }
153
168
  function compactEvidence(items) {
154
169
  return items.filter((item) => item !== undefined);
155
170
  }
@@ -177,6 +192,14 @@ function formatNumber(value) {
177
192
  function slug(value) {
178
193
  return value.toLowerCase().replace(/[^a-z0-9_]+/g, "-").replace(/^-|-$/g, "") || "unknown";
179
194
  }
195
+ function stableSuffix(value) {
196
+ let hash = 2_166_136_261;
197
+ for (let index = 0; index < value.length; index += 1) {
198
+ hash ^= value.charCodeAt(index);
199
+ hash = Math.imul(hash, 16_777_619);
200
+ }
201
+ return (hash >>> 0).toString(36);
202
+ }
180
203
  function roundMoney(value) {
181
204
  return Math.round(value * 100) / 100;
182
205
  }
@@ -1,5 +1,6 @@
1
1
  import { type TokenUsage } from "./modelPricing.js";
2
2
  import type { UsageRecord } from "./schema.js";
3
+ import { type ParsedInvocationFile } from "./toolInvocations.js";
3
4
  /**
4
5
  * Local agent-session log ingestion: turns the transcript files that coding
5
6
  * agents already write on this machine into UsageRecords, priced at
@@ -25,6 +26,21 @@ export type LocalAgentCall = {
25
26
  startedAt?: string;
26
27
  /** Project attribution derived from the session's working directory. */
27
28
  project?: string;
29
+ /**
30
+ * Internal absolute working directory observed in the local transcript.
31
+ * Renderers must not expose it; adapters use it only to scope local inventory
32
+ * reads to the same repository as the active session.
33
+ */
34
+ workingDirectory?: string;
35
+ /**
36
+ * Numeric-only usage for the latest observed model turn. Codex reports this
37
+ * separately from cumulative `total_token_usage`; Claude assistant-message
38
+ * usage is already turn-scoped. Context Health uses this field so a long
39
+ * session lifetime is never compared with a short session's final turn.
40
+ */
41
+ latestTurnUsage?: LocalAgentTurnUsage;
42
+ /** Whether `usage` is one model turn or the session's cumulative financial total. */
43
+ usageScope?: "turn" | "session_cumulative";
28
44
  usage: TokenUsage;
29
45
  sessionId?: string;
30
46
  /** Provider-reported plan windows embedded in the transcript, when present. */
@@ -35,6 +51,21 @@ export type LocalAgentCall = {
35
51
  */
36
52
  activity?: LocalAgentActivity;
37
53
  };
54
+ /**
55
+ * Resolve the repository root most recently observed in transcript metadata.
56
+ *
57
+ * CLI, MCP, and Glance use this only to scope read-only project inventory when
58
+ * the caller did not explicitly choose a path. Absolute working directories
59
+ * never enter rendered output.
60
+ */
61
+ export declare function latestObservedWorkingDirectory(calls: readonly LocalAgentCall[]): string | undefined;
62
+ export type LocalAgentTurnUsage = TokenUsage & {
63
+ /** Input-side context observed for this turn, including cached/write tokens. */
64
+ contextTokens: number;
65
+ /** Context plus output tokens for this turn. */
66
+ totalTokens: number;
67
+ source: "assistant_message_usage" | "transcript_last_token_usage" | "call_usage";
68
+ };
38
69
  export type LocalAgentActivity = {
39
70
  summary: string;
40
71
  kind: "task" | "automation" | "agent" | "file" | "project";
@@ -67,6 +98,8 @@ export type LocalAgentLogOptions = {
67
98
  codexSessionsDir?: string;
68
99
  /** Only include calls at/after this ISO timestamp. */
69
100
  sinceIso?: string;
101
+ /** Collect privacy-safe Codex invocation summaries during the same JSON pass. */
102
+ collectCodexInvocationEvidence?: boolean;
70
103
  };
71
104
  export type LocalAgentLogResult = {
72
105
  records: UsageRecord[];
@@ -75,11 +108,20 @@ export type LocalAgentLogResult = {
75
108
  filesParsed: number;
76
109
  /** Which agents actually had data on this machine. */
77
110
  agentsDetected: Array<LocalAgentCall["agent"]>;
111
+ /** Present only when requested; contains counts/basenames, never raw text. */
112
+ codexInvocationFiles?: ParsedInvocationFile[];
78
113
  };
114
+ /**
115
+ * Codex rollout/compaction files can repeat the same session's cumulative
116
+ * token counter. Keep only the latest snapshot per session so financial value,
117
+ * Glance, and project totals never add cumulative checkpoints together.
118
+ * Turn-scoped Claude calls and calls without a stable session id are retained.
119
+ */
120
+ export declare function dedupeCumulativeSessionCalls(calls: LocalAgentCall[]): LocalAgentCall[];
79
121
  /** Parse one Claude Code transcript (JSONL). Exported for tests. */
80
- export declare function parseClaudeCodeTranscript(content: string, filePath?: string): LocalAgentCall[];
122
+ export declare function parseClaudeCodeTranscript(content: string, filePath?: string, sinceMs?: number): LocalAgentCall[];
81
123
  /** Parse one Codex rollout file (JSONL event stream). Exported for tests. */
82
- export declare function parseCodexRollout(content: string): LocalAgentCall[];
124
+ export declare function parseCodexRollout(content: string, onEntry?: (entry: Record<string, unknown>) => void): LocalAgentCall[];
83
125
  /** Scan this machine's agent logs and return aggregated UsageRecords. */
84
126
  export declare function loadLocalAgentUsage(options?: LocalAgentLogOptions): Promise<LocalAgentLogResult>;
85
127
  /** Aggregate per-call usage into one UsageRecord per day+agent+model+project. */