@agent-finops/core 0.1.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.
@@ -0,0 +1,201 @@
1
+ const confidenceRank = {
2
+ verified: 0,
3
+ estimated: 1,
4
+ detected_unverified: 2,
5
+ missing: 3
6
+ };
7
+ const downgradeRules = [
8
+ { match: /^gpt-4\.1$/i, target: "gpt-4.1-mini", costRetained: 0.2 },
9
+ { match: /^gpt-4o$/i, target: "gpt-4o-mini", costRetained: 0.18 },
10
+ { match: /^gpt-4-turbo$/i, target: "gpt-4o-mini", costRetained: 0.12 },
11
+ { match: /^o3$/i, target: "o4-mini", costRetained: 0.25 },
12
+ { match: /^claude-sonnet-4(?:[.-].*)?$/i, target: "claude-haiku-4", costRetained: 0.25 },
13
+ { match: /^claude-opus-4(?:[.-].*)?$/i, target: "claude-sonnet-4", costRetained: 0.3 },
14
+ { match: /^claude-3-5-sonnet.*$/i, target: "claude-3-5-haiku", costRetained: 0.25 }
15
+ ];
16
+ /**
17
+ * Operations that are usually quality-safe to run on a cheaper tier
18
+ * (extraction, triage, drafting, summarization). Used to gate model
19
+ * downgrade suggestions so we don't recommend downgrading high-stakes work.
20
+ */
21
+ const downgradeSafeOperation = /triage|extract|classif|summary|summari|draft|reply|tag|label|categor/i;
22
+ /**
23
+ * Operations that read as offline/asynchronous (nobody is waiting on the
24
+ * response), so they can move to the providers' Batch APIs — a flat 50%
25
+ * discount at both OpenAI and Anthropic. Deliberately narrower than
26
+ * downgradeSafeOperation: drafting/replying is interactive, summarizing a
27
+ * backlog is not.
28
+ */
29
+ const batchSafeOperation = /summar|extract|classif|embed|enrich|index|backfill|digest|report|translat|transcri|batch/i;
30
+ /** Fraction of cost retained on the Batch API (both providers price it at 50%). */
31
+ const batchCostRetained = 0.5;
32
+ export function generateCutList(records) {
33
+ const actions = [
34
+ ...modelDowngradeActions(records),
35
+ ...contextTrimActions(records),
36
+ ...cacheActions(records),
37
+ ...batchActions(records)
38
+ ];
39
+ return actions
40
+ .filter((action) => action.estimatedMonthlySavingsUsd >= 0.5)
41
+ .sort((left, right) => right.estimatedMonthlySavingsUsd - left.estimatedMonthlySavingsUsd ||
42
+ left.id.localeCompare(right.id));
43
+ }
44
+ /** Sum of all per-action estimated monthly savings. */
45
+ export function totalEstimatedMonthlySavingsUsd(actions) {
46
+ return roundMoney(actions.reduce((total, action) => total + action.estimatedMonthlySavingsUsd, 0));
47
+ }
48
+ function modelDowngradeActions(records) {
49
+ const window = windowDays(records);
50
+ const groups = new Map();
51
+ for (const record of records) {
52
+ const rule = downgradeRules.find((candidate) => candidate.match.test(record.model));
53
+ if (!rule) {
54
+ continue;
55
+ }
56
+ const operation = record.operation ?? "general";
57
+ // Only suggest downgrades for clearly downgrade-safe operations, OR when
58
+ // the operation is unknown (we still flag it, but caveat via confidence).
59
+ if (record.operation && !downgradeSafeOperation.test(operation)) {
60
+ continue;
61
+ }
62
+ const key = `${record.model}::${operation}::${rule.target}`;
63
+ groups.set(key, [...(groups.get(key) ?? []), record]);
64
+ }
65
+ const actions = [];
66
+ for (const [key, groupRecords] of groups) {
67
+ const [model, operation, target] = key.split("::");
68
+ const rule = downgradeRules.find((candidate) => candidate.match.test(model));
69
+ const affectedSpendUsd = roundMoney(sumRecords(groupRecords));
70
+ const windowSavings = affectedSpendUsd * (1 - rule.costRetained);
71
+ const monthlySavings = roundMoney(toMonthly(windowSavings, window));
72
+ actions.push({
73
+ id: `downgrade-${slug(model)}-${slug(operation)}`,
74
+ title: `Move ${model} ${operation} calls to ${target}`,
75
+ action: `Route ${groupRecords.length} ${operation} call${groupRecords.length === 1 ? "" : "s"} from ${model} to ${target} (keep ${model} only when output is rejected).`,
76
+ estimatedMonthlySavingsUsd: monthlySavings,
77
+ affectedSpendUsd,
78
+ recordCount: groupRecords.length,
79
+ confidence: combinedConfidence(groupRecords.map((record) => record.costConfidence)),
80
+ kind: "model_downgrade"
81
+ });
82
+ }
83
+ return actions;
84
+ }
85
+ function contextTrimActions(records) {
86
+ const window = windowDays(records);
87
+ const heavy = records.filter((record) => record.inputTokens >= 100_000);
88
+ if (heavy.length === 0) {
89
+ return [];
90
+ }
91
+ const byOperation = new Map();
92
+ for (const record of heavy) {
93
+ const operation = record.operation ?? "large-context calls";
94
+ byOperation.set(operation, [...(byOperation.get(operation) ?? []), record]);
95
+ }
96
+ const actions = [];
97
+ for (const [operation, groupRecords] of byOperation) {
98
+ const affectedSpendUsd = roundMoney(sumRecords(groupRecords));
99
+ // Trimming oversized retrieval/context conservatively recovers ~25% of the
100
+ // input-token cost on these large calls.
101
+ const windowSavings = affectedSpendUsd * 0.25;
102
+ const monthlySavings = roundMoney(toMonthly(windowSavings, window));
103
+ actions.push({
104
+ id: `trim-${slug(operation)}`,
105
+ title: `Trim oversized context on ${operation}`,
106
+ action: `Cap retrieval/prompt size on ${groupRecords.length} large ${operation} call${groupRecords.length === 1 ? "" : "s"} (>=100k input tokens) before they fan out.`,
107
+ estimatedMonthlySavingsUsd: monthlySavings,
108
+ affectedSpendUsd,
109
+ recordCount: groupRecords.length,
110
+ confidence: combinedConfidence(groupRecords.map((record) => record.costConfidence)),
111
+ kind: "context_trim"
112
+ });
113
+ }
114
+ return actions;
115
+ }
116
+ function cacheActions(records) {
117
+ const window = windowDays(records);
118
+ const counts = new Map();
119
+ for (const record of records) {
120
+ if (!record.operation) {
121
+ continue;
122
+ }
123
+ counts.set(record.operation, [...(counts.get(record.operation) ?? []), record]);
124
+ }
125
+ const actions = [];
126
+ for (const [operation, groupRecords] of counts) {
127
+ if (groupRecords.length < 3) {
128
+ continue;
129
+ }
130
+ const affectedSpendUsd = roundMoney(sumRecords(groupRecords));
131
+ // Caching repeated identical-ish operations conservatively recovers ~20%.
132
+ const windowSavings = affectedSpendUsd * 0.2;
133
+ const monthlySavings = roundMoney(toMonthly(windowSavings, window));
134
+ actions.push({
135
+ id: `cache-${slug(operation)}`,
136
+ title: `Cache repeated ${operation} calls`,
137
+ action: `Add a result cache for ${operation} (${groupRecords.length} repeated call${groupRecords.length === 1 ? "" : "s"}) so identical inputs do not re-bill.`,
138
+ estimatedMonthlySavingsUsd: monthlySavings,
139
+ affectedSpendUsd,
140
+ recordCount: groupRecords.length,
141
+ confidence: combinedConfidence(groupRecords.map((record) => record.costConfidence)),
142
+ kind: "cache"
143
+ });
144
+ }
145
+ return actions;
146
+ }
147
+ function batchActions(records) {
148
+ const window = windowDays(records);
149
+ const byOperation = new Map();
150
+ for (const record of records) {
151
+ if (!record.operation || !batchSafeOperation.test(record.operation)) {
152
+ continue;
153
+ }
154
+ byOperation.set(record.operation, [...(byOperation.get(record.operation) ?? []), record]);
155
+ }
156
+ const actions = [];
157
+ for (const [operation, groupRecords] of byOperation) {
158
+ if (groupRecords.length < 3) {
159
+ continue;
160
+ }
161
+ const affectedSpendUsd = roundMoney(sumRecords(groupRecords));
162
+ const windowSavings = affectedSpendUsd * (1 - batchCostRetained);
163
+ const monthlySavings = roundMoney(toMonthly(windowSavings, window));
164
+ actions.push({
165
+ id: `batch-${slug(operation)}`,
166
+ title: `Move ${operation} calls to the Batch API`,
167
+ action: `Submit ${groupRecords.length} ${operation} call${groupRecords.length === 1 ? "" : "s"} through the provider's Batch API (flat 50% off; results within 24h, fine for offline work).`,
168
+ estimatedMonthlySavingsUsd: monthlySavings,
169
+ affectedSpendUsd,
170
+ recordCount: groupRecords.length,
171
+ confidence: combinedConfidence(groupRecords.map((record) => record.costConfidence)),
172
+ kind: "batch"
173
+ });
174
+ }
175
+ return actions;
176
+ }
177
+ /** Number of distinct calendar days the records span (min 1). */
178
+ function windowDays(records) {
179
+ const days = new Set(records.map((record) => record.timestamp.slice(0, 10)));
180
+ return Math.max(1, days.size);
181
+ }
182
+ /** Project a window's savings to a 30-day month. */
183
+ function toMonthly(windowSavings, windowDayCount) {
184
+ return (windowSavings / windowDayCount) * 30;
185
+ }
186
+ function sumRecords(records) {
187
+ return records.reduce((total, record) => total + (record.amountUsd ?? 0), 0);
188
+ }
189
+ function combinedConfidence(confidences) {
190
+ if (confidences.length === 0) {
191
+ return "missing";
192
+ }
193
+ return confidences.reduce((lowest, current) => confidenceRank[current] > confidenceRank[lowest] ? current : lowest);
194
+ }
195
+ function slug(value) {
196
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "x";
197
+ }
198
+ function roundMoney(value) {
199
+ return Math.round(value * 100) / 100;
200
+ }
201
+ //# sourceMappingURL=cutList.js.map
@@ -0,0 +1,19 @@
1
+ export type UsageSignalKind = "dependency" | "config" | "environment" | "source_code" | "provider_export" | "invoice";
2
+ export type UsageSignal = {
3
+ provider: string;
4
+ kind: UsageSignalKind;
5
+ filePath: string;
6
+ evidence: string;
7
+ confidence: number;
8
+ };
9
+ export type LocalDiscoveryResult = {
10
+ rootPath: string;
11
+ scannedFiles: number;
12
+ skippedDirectories: string[];
13
+ signals: UsageSignal[];
14
+ secretsDetected: string[];
15
+ redactedEvidence: string[];
16
+ };
17
+ export declare function scanLocalUsageSignals(rootPath: string): Promise<LocalDiscoveryResult>;
18
+ export declare function redactSecrets(text: string): string;
19
+ //# sourceMappingURL=discovery.d.ts.map
@@ -0,0 +1,176 @@
1
+ import { readdir, readFile, stat } from "node:fs/promises";
2
+ import { basename, join, relative } from "node:path";
3
+ const skippedDirectoryNames = new Set([
4
+ ".git",
5
+ "node_modules",
6
+ "dist",
7
+ "build",
8
+ ".next",
9
+ ".turbo",
10
+ "coverage",
11
+ ".ssh",
12
+ "Keychains"
13
+ ]);
14
+ const maxFileBytes = 512_000;
15
+ const providerRules = [
16
+ { provider: "anthropic", kind: "dependency", patterns: [/@anthropic-ai\/sdk/, /anthropic/i], confidence: 0.9 },
17
+ { provider: "langfuse", kind: "dependency", patterns: [/langfuse/i], confidence: 0.82 },
18
+ { provider: "openai", kind: "dependency", patterns: [/"openai"\s*:/, /from\s+["']openai["']/, /OPENAI_API_KEY/], confidence: 0.9 },
19
+ { provider: "vercel-ai-sdk", kind: "dependency", patterns: [/"ai"\s*:/, /from\s+["']ai["']/], confidence: 0.78 },
20
+ { provider: "litellm", kind: "config", patterns: [/litellm/i, /model_list:/], confidence: 0.84 },
21
+ { provider: "helicone", kind: "environment", patterns: [/HELICONE_API_KEY/, /helicone/i], confidence: 0.8 },
22
+ { provider: "cursor", kind: "invoice", patterns: [/cursor/i], confidence: 0.76 },
23
+ { provider: "replit", kind: "invoice", patterns: [/replit/i], confidence: 0.72 }
24
+ ];
25
+ const secretAssignmentPattern = /\b([A-Z][A-Z0-9_]*(?:API_KEY|TOKEN|SECRET|PASSWORD))\s*=\s*([^\s#"']+)/g;
26
+ const providerSecretPatterns = [
27
+ /sk-proj-[A-Za-z0-9_-]{20,}/g,
28
+ /sk-ant-api\d{2}-[A-Za-z0-9_-]{20,}/g,
29
+ /sk-[A-Za-z0-9_-]{20,}/g,
30
+ /helicone_[A-Za-z0-9_-]{16,}/gi
31
+ ];
32
+ export async function scanLocalUsageSignals(rootPath) {
33
+ const result = {
34
+ rootPath,
35
+ scannedFiles: 0,
36
+ skippedDirectories: [],
37
+ signals: [],
38
+ secretsDetected: [],
39
+ redactedEvidence: []
40
+ };
41
+ const secrets = new Set();
42
+ const skipped = new Set();
43
+ await walk(rootPath, async (path) => {
44
+ const fileInfo = await stat(path);
45
+ if (fileInfo.size > maxFileBytes || !isInterestingFile(path)) {
46
+ return;
47
+ }
48
+ const raw = await readFile(path, "utf8");
49
+ const redacted = redactSecrets(raw);
50
+ const relativePath = relative(rootPath, path) || basename(path);
51
+ result.scannedFiles += 1;
52
+ for (const name of detectSecretNames(raw)) {
53
+ secrets.add(name);
54
+ result.redactedEvidence.push(`${relativePath}: ${name}=[REDACTED]`);
55
+ }
56
+ for (const signal of detectExportSignals(relativePath, redacted)) {
57
+ result.signals.push(signal);
58
+ }
59
+ for (const rule of providerRules) {
60
+ const matchedPattern = rule.patterns.find((pattern) => pattern.test(redacted));
61
+ if (!matchedPattern) {
62
+ continue;
63
+ }
64
+ const evidence = buildEvidence(relativePath, rule.provider, redacted);
65
+ result.signals.push({
66
+ provider: rule.provider,
67
+ kind: inferKind(path, rule.kind),
68
+ filePath: relativePath,
69
+ evidence,
70
+ confidence: rule.confidence
71
+ });
72
+ }
73
+ }, skipped);
74
+ result.skippedDirectories = Array.from(skipped).sort();
75
+ result.secretsDetected = Array.from(secrets).sort();
76
+ result.signals = dedupeSignals(result.signals).sort((left, right) => {
77
+ const provider = left.provider.localeCompare(right.provider);
78
+ return provider === 0 ? left.filePath.localeCompare(right.filePath) : provider;
79
+ });
80
+ result.redactedEvidence = Array.from(new Set(result.redactedEvidence)).sort();
81
+ return result;
82
+ }
83
+ export function redactSecrets(text) {
84
+ let redacted = text.replace(secretAssignmentPattern, (_match, key) => `${key}=[REDACTED]`);
85
+ for (const pattern of providerSecretPatterns) {
86
+ redacted = redacted.replace(pattern, "[REDACTED]");
87
+ }
88
+ return redacted;
89
+ }
90
+ function detectSecretNames(text) {
91
+ const names = new Set();
92
+ let match;
93
+ const pattern = new RegExp(secretAssignmentPattern.source, secretAssignmentPattern.flags);
94
+ while ((match = pattern.exec(text)) !== null) {
95
+ names.add(match[1]);
96
+ }
97
+ return Array.from(names);
98
+ }
99
+ async function walk(rootPath, visit, skipped) {
100
+ const entries = await readdir(rootPath, { withFileTypes: true });
101
+ for (const entry of entries) {
102
+ const path = join(rootPath, entry.name);
103
+ if (entry.isDirectory()) {
104
+ if (skippedDirectoryNames.has(entry.name)) {
105
+ skipped.add(entry.name);
106
+ continue;
107
+ }
108
+ await walk(path, visit, skipped);
109
+ continue;
110
+ }
111
+ if (entry.isFile()) {
112
+ await visit(path);
113
+ }
114
+ }
115
+ }
116
+ function isInterestingFile(path) {
117
+ const name = basename(path);
118
+ return (name === "package.json" ||
119
+ name === ".env" ||
120
+ name.startsWith(".env.") ||
121
+ /\.(ts|tsx|js|jsx|mjs|cjs|json|csv|ya?ml|toml|py|md|txt)$/i.test(name));
122
+ }
123
+ function inferKind(path, fallback) {
124
+ const name = basename(path);
125
+ if (name === ".env" || name.startsWith(".env.")) {
126
+ return "environment";
127
+ }
128
+ if (/\.(ya?ml|toml)$/i.test(name)) {
129
+ return "config";
130
+ }
131
+ if (/invoice|receipt|billing/i.test(name)) {
132
+ return "invoice";
133
+ }
134
+ if (/usage|export|cost|spend/i.test(name) && /\.(csv|json)$/i.test(name)) {
135
+ return "provider_export";
136
+ }
137
+ return fallback;
138
+ }
139
+ function detectExportSignals(filePath, redacted) {
140
+ const lowerPath = filePath.toLowerCase();
141
+ const lowerText = redacted.toLowerCase();
142
+ const providers = ["openai", "anthropic", "cursor", "helicone", "langfuse", "gemini", "google", "replit"];
143
+ const provider = providers.find((candidate) => lowerPath.includes(candidate) || lowerText.includes(candidate));
144
+ if (!provider) {
145
+ return [];
146
+ }
147
+ const isExport = /usage|export|cost|spend/.test(lowerPath) || /cost_usd|amount_usd|total_usd|input_tokens|output_tokens/.test(lowerText);
148
+ const isInvoice = /invoice|receipt|billing/.test(lowerPath) || /total due|amount due|invoice/.test(lowerText);
149
+ if (!isExport && !isInvoice) {
150
+ return [];
151
+ }
152
+ const normalizedProvider = provider === "google" ? "gemini" : provider;
153
+ const kind = isInvoice ? "invoice" : "provider_export";
154
+ return [{
155
+ provider: normalizedProvider,
156
+ kind,
157
+ filePath,
158
+ evidence: `${filePath}: detected ${normalizedProvider} ${kind.replace("_", " ")}`,
159
+ confidence: isInvoice ? 0.82 : 0.88
160
+ }];
161
+ }
162
+ function buildEvidence(filePath, provider, redacted) {
163
+ const line = redacted.split(/\r?\n/).find((candidate) => candidate.toLowerCase().includes(provider.split("-")[0]));
164
+ return line ? `${filePath}: ${line.trim()}` : `${filePath}: detected ${provider} usage signal`;
165
+ }
166
+ function dedupeSignals(signals) {
167
+ const byKey = new Map();
168
+ for (const signal of signals) {
169
+ const key = `${signal.provider}:${signal.filePath}:${signal.kind}`;
170
+ if (!byKey.has(key)) {
171
+ byKey.set(key, signal);
172
+ }
173
+ }
174
+ return Array.from(byKey.values());
175
+ }
176
+ //# sourceMappingURL=discovery.js.map
@@ -0,0 +1,14 @@
1
+ export * from "./analyze.js";
2
+ export * from "./attribution.js";
3
+ export * from "./credentialDetection.js";
4
+ export * from "./cutList.js";
5
+ export * from "./discovery.js";
6
+ export * from "./insights.js";
7
+ export * from "./localAgentLogs.js";
8
+ export * from "./modelPricing.js";
9
+ export * from "./planMath.js";
10
+ export * from "./sampleData.js";
11
+ export * from "./schema.js";
12
+ export * from "./sourceRegistry.js";
13
+ export * from "./providerConnectors.js";
14
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,14 @@
1
+ export * from "./analyze.js";
2
+ export * from "./attribution.js";
3
+ export * from "./credentialDetection.js";
4
+ export * from "./cutList.js";
5
+ export * from "./discovery.js";
6
+ export * from "./insights.js";
7
+ export * from "./localAgentLogs.js";
8
+ export * from "./modelPricing.js";
9
+ export * from "./planMath.js";
10
+ export * from "./sampleData.js";
11
+ export * from "./schema.js";
12
+ export * from "./sourceRegistry.js";
13
+ export * from "./providerConnectors.js";
14
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,3 @@
1
+ import { type SpendInsight, type SpendSummary, type UsageRecord } from "./schema.js";
2
+ export declare function generateSpendInsights(records: UsageRecord[], summary: SpendSummary): SpendInsight[];
3
+ //# sourceMappingURL=insights.d.ts.map
@@ -0,0 +1,183 @@
1
+ import { spendInsightSchema } from "./schema.js";
2
+ const confidenceRank = {
3
+ verified: 0,
4
+ estimated: 1,
5
+ detected_unverified: 2,
6
+ missing: 3
7
+ };
8
+ const severityRank = {
9
+ critical: 0,
10
+ high: 1,
11
+ medium: 2,
12
+ low: 3
13
+ };
14
+ export function generateSpendInsights(records, summary) {
15
+ const insights = [
16
+ ...spikeInsights(records, summary),
17
+ ...agentCostDriverInsights(records, summary),
18
+ ...contextBloatInsights(records)
19
+ ];
20
+ return insights
21
+ .map((insight) => spendInsightSchema.parse(insight))
22
+ .sort((left, right) => severityRank[left.severity] - severityRank[right.severity] ||
23
+ right.estimatedImpactUsd - left.estimatedImpactUsd ||
24
+ left.id.localeCompare(right.id));
25
+ }
26
+ function spikeInsights(records, summary) {
27
+ return summary.anomalies.map((anomaly) => {
28
+ const currentRecords = records.filter((record) => record.timestamp.slice(0, 10) === anomaly.key);
29
+ const topAgent = topBreakdown(currentRecords, (record) => record.agentId);
30
+ const topClient = topBreakdown(currentRecords, (record) => record.clientId);
31
+ const topProject = topBreakdown(currentRecords, (record) => record.projectId);
32
+ const topModels = breakdown(currentRecords, (record) => record.model).slice(0, 2).map((entry) => entry.key);
33
+ const deltaUsd = roundMoney(anomaly.currentAmountUsd - anomaly.previousAmountUsd);
34
+ const likelyDriver = topAgent?.key ?? topProject?.key ?? topClient?.key ?? "unmapped usage";
35
+ return {
36
+ id: `spike-${anomaly.key}`,
37
+ kind: "spike_explanation",
38
+ 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.`,
41
+ 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,
46
+ topClient ? { label: "Client concentration", value: topClient.key, detail: `${formatUsd(topClient.amountUsd)} on spike day` } : undefined,
47
+ topModels.length > 0 ? { label: "Dominant models", value: topModels.join(", ") } : undefined
48
+ ]),
49
+ affectedClients: keysFrom(currentRecords, (record) => record.clientId),
50
+ affectedProjects: keysFrom(currentRecords, (record) => record.projectId),
51
+ affectedAgents: keysFrom(currentRecords, (record) => record.agentId),
52
+ affectedModels: keysFrom(currentRecords, (record) => record.model),
53
+ estimatedImpactUsd: deltaUsd,
54
+ 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."
57
+ };
58
+ });
59
+ }
60
+ function agentCostDriverInsights(records, summary) {
61
+ const topAgent = summary.byAgent[0];
62
+ if (!topAgent || topAgent.key === "unmapped" || topAgent.amountUsd < 25 || summary.totalUsd === 0) {
63
+ return [];
64
+ }
65
+ const agentRecords = records.filter((record) => record.agentId === topAgent.key);
66
+ const share = topAgent.amountUsd / summary.totalUsd;
67
+ if (share < 0.35) {
68
+ return [];
69
+ }
70
+ const topOperation = topBreakdown(agentRecords, (record) => record.operation);
71
+ const topModel = topBreakdown(agentRecords, (record) => record.model);
72
+ const estimatedImpactUsd = roundMoney(topAgent.amountUsd * 0.15);
73
+ 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.`,
79
+ evidence: compactEvidence([
80
+ { label: "Agent spend", value: formatUsd(topAgent.amountUsd), detail: `${topAgent.recordCount} records` },
81
+ { 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
84
+ ]),
85
+ affectedClients: keysFrom(agentRecords, (record) => record.clientId),
86
+ affectedProjects: keysFrom(agentRecords, (record) => record.projectId),
87
+ affectedAgents: [topAgent.key],
88
+ affectedModels: keysFrom(agentRecords, (record) => record.model),
89
+ estimatedImpactUsd,
90
+ 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."
93
+ }];
94
+ }
95
+ function contextBloatInsights(records) {
96
+ const highInputRecords = records.filter((record) => record.inputTokens >= 100_000);
97
+ if (highInputRecords.length === 0) {
98
+ return [];
99
+ }
100
+ const topOperation = topBreakdown(highInputRecords, (record) => record.operation);
101
+ const scopedRecords = topOperation
102
+ ? highInputRecords.filter((record) => record.operation === topOperation.key)
103
+ : highInputRecords;
104
+ const operationLabel = topOperation?.key ?? "large-context calls";
105
+ const totalInputTokens = scopedRecords.reduce((total, record) => total + record.inputTokens, 0);
106
+ const scopedSpend = roundMoney(sumRecords(scopedRecords));
107
+ if (scopedSpend < 20) {
108
+ return [];
109
+ }
110
+ return [{
111
+ id: `context-bloat-${slug(operationLabel)}`,
112
+ kind: "context_bloat",
113
+ 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.`,
116
+ evidence: [
117
+ { label: "High-input calls", value: String(scopedRecords.length), detail: "Calls at or above 100,000 input tokens" },
118
+ { label: "Input tokens", value: formatNumber(totalInputTokens) },
119
+ { label: "Spend attached to large context", value: formatUsd(scopedSpend) },
120
+ { label: "Dominant operation", value: operationLabel }
121
+ ],
122
+ affectedClients: keysFrom(scopedRecords, (record) => record.clientId),
123
+ affectedProjects: keysFrom(scopedRecords, (record) => record.projectId),
124
+ affectedAgents: keysFrom(scopedRecords, (record) => record.agentId),
125
+ affectedModels: keysFrom(scopedRecords, (record) => record.model),
126
+ estimatedImpactUsd: roundMoney(scopedSpend * 0.18),
127
+ 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."
130
+ }];
131
+ }
132
+ function topBreakdown(records, select) {
133
+ return breakdown(records, select)[0];
134
+ }
135
+ function breakdown(records, select) {
136
+ const groups = new Map();
137
+ for (const record of records) {
138
+ const key = select(record) ?? "unmapped";
139
+ groups.set(key, [...(groups.get(key) ?? []), record]);
140
+ }
141
+ return Array.from(groups.entries())
142
+ .map(([key, groupRecords]) => ({
143
+ key,
144
+ amountUsd: roundMoney(sumRecords(groupRecords)),
145
+ recordCount: groupRecords.length,
146
+ confidence: combinedConfidence(groupRecords.map((record) => record.costConfidence))
147
+ }))
148
+ .sort((left, right) => right.amountUsd - left.amountUsd || left.key.localeCompare(right.key));
149
+ }
150
+ function keysFrom(records, select) {
151
+ return Array.from(new Set(records.map(select).filter((value) => value !== undefined)));
152
+ }
153
+ function compactEvidence(items) {
154
+ return items.filter((item) => item !== undefined);
155
+ }
156
+ function sumRecords(records) {
157
+ return records.reduce((total, record) => total + (record.amountUsd ?? 0), 0);
158
+ }
159
+ function combinedConfidence(confidences) {
160
+ if (confidences.length === 0) {
161
+ return "missing";
162
+ }
163
+ return confidences.reduce((lowest, current) => confidenceRank[current] > confidenceRank[lowest] ? current : lowest);
164
+ }
165
+ function formatUsd(value) {
166
+ return `$${value.toFixed(2)}`;
167
+ }
168
+ function formatMultiplier(value) {
169
+ return `${(Math.round(value * 10) / 10).toFixed(1)}x`;
170
+ }
171
+ function formatPercent(value) {
172
+ return `${Math.round(value * 100)}%`;
173
+ }
174
+ function formatNumber(value) {
175
+ return new Intl.NumberFormat("en-US").format(value);
176
+ }
177
+ function slug(value) {
178
+ return value.toLowerCase().replace(/[^a-z0-9_]+/g, "-").replace(/^-|-$/g, "") || "unknown";
179
+ }
180
+ function roundMoney(value) {
181
+ return Math.round(value * 100) / 100;
182
+ }
183
+ //# sourceMappingURL=insights.js.map
@@ -0,0 +1,53 @@
1
+ import { type TokenUsage } from "./modelPricing.js";
2
+ import type { UsageRecord } from "./schema.js";
3
+ /**
4
+ * Local agent-session log ingestion: turns the transcript files that coding
5
+ * agents already write on this machine into UsageRecords, priced at
6
+ * API-equivalent rates ("estimated" confidence).
7
+ *
8
+ * Why this exists: subscription usage (Claude Max, ChatGPT plans) has NO
9
+ * billing API — local logs are the only place that spend is visible. This is
10
+ * also what makes the zero-key first run show REAL numbers.
11
+ *
12
+ * Supported agents:
13
+ * - Claude Code: ~/.claude/projects/** /*.jsonl — one JSON object per line;
14
+ * assistant messages carry message.usage token counts.
15
+ * - Codex CLI: ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl — event stream;
16
+ * the LAST event_msg/token_count carries the session's cumulative
17
+ * total_token_usage (earlier ones are running updates — never summed).
18
+ */
19
+ export type LocalAgentCall = {
20
+ agent: "claude-code" | "codex";
21
+ model: string;
22
+ /** ISO timestamp of the call (or session start for session-level entries). */
23
+ timestamp: string;
24
+ /** Project attribution derived from the session's working directory. */
25
+ project?: string;
26
+ usage: TokenUsage;
27
+ sessionId?: string;
28
+ };
29
+ export type LocalAgentLogOptions = {
30
+ /** Default: ~/.claude/projects */
31
+ claudeProjectsDir?: string;
32
+ /** Default: ~/.codex/sessions */
33
+ codexSessionsDir?: string;
34
+ /** Only include calls at/after this ISO timestamp. */
35
+ sinceIso?: string;
36
+ };
37
+ export type LocalAgentLogResult = {
38
+ records: UsageRecord[];
39
+ /** Per-call entries before aggregation (for drill-down/debugging). */
40
+ calls: LocalAgentCall[];
41
+ filesParsed: number;
42
+ /** Which agents actually had data on this machine. */
43
+ agentsDetected: string[];
44
+ };
45
+ /** Parse one Claude Code transcript (JSONL). Exported for tests. */
46
+ export declare function parseClaudeCodeTranscript(content: string, filePath?: string): LocalAgentCall[];
47
+ /** Parse one Codex rollout file (JSONL event stream). Exported for tests. */
48
+ export declare function parseCodexRollout(content: string): LocalAgentCall[];
49
+ /** Scan this machine's agent logs and return aggregated UsageRecords. */
50
+ export declare function loadLocalAgentUsage(options?: LocalAgentLogOptions): Promise<LocalAgentLogResult>;
51
+ /** Aggregate per-call usage into one UsageRecord per day+agent+model+project. */
52
+ export declare function aggregateCalls(calls: LocalAgentCall[]): UsageRecord[];
53
+ //# sourceMappingURL=localAgentLogs.d.ts.map