@agent-finops/core 0.1.1 → 0.1.3

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,72 @@
1
+ import { type AgentInventoryOptions, type InventoryItem } from "./agentInventory.js";
2
+ import { type InvocationSummary, type ToolInvocationOptions } from "./toolInvocations.js";
3
+ import type { CutAction } from "./cutList.js";
4
+ export type DeadContextItem = {
5
+ kind: InventoryItem["kind"];
6
+ name: string;
7
+ alwaysLoadedTokens: number;
8
+ weightConfidence: InventoryItem["weightConfidence"];
9
+ };
10
+ export type DeadContextResult = {
11
+ /** True when we found inventory + transcripts AND at least one dead item. */
12
+ hasData: boolean;
13
+ /** True when these are illustrative sample numbers, not the user's real data. */
14
+ isSample?: boolean;
15
+ /** Prunable inventory items considered (built-ins excluded upstream). */
16
+ loadedCount: number;
17
+ /** Items never invoked across the parsed window (the defensible headline). */
18
+ deadCount: number;
19
+ /** Dead items whose token weight we MEASURED (skills/subagents/commands). */
20
+ measuredDeadCount: number;
21
+ /** Dead items whose weight we could NOT measure (MCP servers, no schemas). */
22
+ unmeasuredDeadCount: number;
23
+ /** Measured-only always-loaded tokens across dead items (per turn). */
24
+ deadTokens: number;
25
+ /** Measured-only dead tokens loaded into context over a month (projected). */
26
+ monthlyDeadTokens: number;
27
+ /** deadCount / loadedCount, 0..1. */
28
+ wastePercent: number;
29
+ /** Cache-aware monthly $, MEASURED items only (0 when only MCP is dead). */
30
+ monthlyUsd: number;
31
+ /** Upper bound (no prompt caching), measured items only. */
32
+ monthlyUsdUpperBound: number;
33
+ /** The never-used items, heaviest first. */
34
+ deadItems: DeadContextItem[];
35
+ sessions: number;
36
+ totalTurns: number;
37
+ /** Model whose rates priced the measured estimate. */
38
+ pricingModel: string;
39
+ /** Days the parsed transcripts were assumed to represent. */
40
+ windowDays: number;
41
+ };
42
+ export type DeadContextOptions = AgentInventoryOptions & ToolInvocationOptions & {
43
+ /** Days the parsed transcripts represent (for the /mo projection). Default 30. */
44
+ windowDays?: number;
45
+ /** Representative model for cache/input rates. Default claude-sonnet-4. */
46
+ pricingModel?: string;
47
+ /** Inject inventory/invocations (tests); otherwise loaded from disk. */
48
+ inventory?: {
49
+ items: InventoryItem[];
50
+ };
51
+ invocations?: InvocationSummary;
52
+ };
53
+ /** Load inventory + invocations from disk (or use injected ones) and price the waste. */
54
+ export declare function loadDeadContext(options?: DeadContextOptions): Promise<DeadContextResult>;
55
+ /**
56
+ * Illustrative dead-context numbers for the first-run / demo card, so the
57
+ * feature is always visible even when a user has nothing loaded yet. Clearly
58
+ * flagged isSample. Shows the measured-skills case (count + a small honest $).
59
+ */
60
+ export declare function sampleDeadContext(): DeadContextResult;
61
+ /** Pure core: compare inventory vs. invocations; count all dead, price only measured. */
62
+ export declare function computeDeadContext(items: InventoryItem[], invocations: InvocationSummary, config: {
63
+ windowDays: number;
64
+ pricingModel: string;
65
+ }): DeadContextResult;
66
+ /**
67
+ * Adapt a dead-context result to a {@link CutAction} so it can flow into the
68
+ * ranked cut list / AI Receipt. Returns null unless there is MEASURED waste
69
+ * worth a dollar figure (MCP-only waste is shown as a count, not a cut $).
70
+ */
71
+ export declare function deadContextCutAction(result: DeadContextResult): CutAction | null;
72
+ //# sourceMappingURL=deadContext.d.ts.map
@@ -0,0 +1,174 @@
1
+ import { loadAgentInventory } from "./agentInventory.js";
2
+ import { findPricingRule } from "./modelPricing.js";
3
+ import { loadToolInvocations } from "./toolInvocations.js";
4
+ /**
5
+ * Dead-context: the tools an agent LOADS into context but NEVER calls. Compares
6
+ * the local Claude Code inventory (skills, subagents, slash commands, MCP
7
+ * servers — {@link loadAgentInventory}) against what real transcripts show was
8
+ * invoked ({@link loadToolInvocations}).
9
+ *
10
+ * ACCURACY CONTRACT (this is the credibility lever — read before changing):
11
+ * - The COUNT + utilization % is always defensible and is the headline.
12
+ * - A token/$ magnitude is ONLY computed from items whose weight we actually
13
+ * MEASURED — skill/subagent/command frontmatter (weightConfidence
14
+ * "estimated"). We never price items whose weight we could not measure.
15
+ * - MCP servers have NO readable schemas in local config, so their real
16
+ * weight is unknown. They are COUNTED as dead but NEVER assigned a $/token
17
+ * figure — to size them we'd have to query each server's tools/list. They
18
+ * surface as `unmeasuredDeadCount` so the renderer can say "not measurable".
19
+ * - Pricing is cache-aware (one cache write/session + a read/turn), never the
20
+ * inflated full-input-rate-every-turn number.
21
+ */
22
+ const DEFAULT_WINDOW_DAYS = 30;
23
+ /**
24
+ * Representative model for pricing measured dead context. Claude Code runs
25
+ * Anthropic models; Sonnet rates are a conservative middle. Always "estimated".
26
+ */
27
+ const DEFAULT_PRICING_MODEL = "claude-sonnet-4";
28
+ /** Load inventory + invocations from disk (or use injected ones) and price the waste. */
29
+ export async function loadDeadContext(options = {}) {
30
+ const inventory = options.inventory ?? (await loadAgentInventory(options));
31
+ const invocations = options.invocations ?? (await loadToolInvocations(options));
32
+ return computeDeadContext(inventory.items, invocations, {
33
+ windowDays: options.windowDays ?? DEFAULT_WINDOW_DAYS,
34
+ pricingModel: options.pricingModel ?? DEFAULT_PRICING_MODEL
35
+ });
36
+ }
37
+ /**
38
+ * Illustrative dead-context numbers for the first-run / demo card, so the
39
+ * feature is always visible even when a user has nothing loaded yet. Clearly
40
+ * flagged isSample. Shows the measured-skills case (count + a small honest $).
41
+ */
42
+ export function sampleDeadContext() {
43
+ const loadedCount = 38;
44
+ const deadCount = 29;
45
+ return {
46
+ hasData: true,
47
+ isSample: true,
48
+ loadedCount,
49
+ deadCount,
50
+ measuredDeadCount: deadCount,
51
+ unmeasuredDeadCount: 0,
52
+ deadTokens: 80,
53
+ monthlyDeadTokens: 120_000,
54
+ wastePercent: deadCount / loadedCount,
55
+ monthlyUsd: 0.4,
56
+ monthlyUsdUpperBound: 2.4,
57
+ deadItems: [],
58
+ sessions: 120,
59
+ totalTurns: 1500,
60
+ pricingModel: DEFAULT_PRICING_MODEL,
61
+ windowDays: 30
62
+ };
63
+ }
64
+ /** Pure core: compare inventory vs. invocations; count all dead, price only measured. */
65
+ export function computeDeadContext(items, invocations, config) {
66
+ const windowDays = Math.max(1, config.windowDays);
67
+ const sessions = invocations.sessions;
68
+ const totalTurns = invocations.totalAssistantTurns;
69
+ const usedSkills = new Set(invocations.invokedSkills);
70
+ const usedSubagents = new Set(invocations.invokedSubagents);
71
+ const usedCommands = new Set(invocations.invokedCommands);
72
+ const usedMcpTools = new Set(invocations.invokedMcpTools);
73
+ // An MCP server counts as "used" if any invoked mcp tool belongs to it.
74
+ const usedMcpServers = new Set(invocations.invokedMcpTools.map((tool) => tool.split("__")[1]).filter((id) => Boolean(id)));
75
+ const dead = [];
76
+ let loadedCount = 0;
77
+ for (const item of items) {
78
+ loadedCount += 1;
79
+ if (!isDead(item, { usedSkills, usedSubagents, usedCommands, usedMcpTools, usedMcpServers })) {
80
+ continue;
81
+ }
82
+ dead.push({
83
+ kind: item.kind,
84
+ name: item.name,
85
+ alwaysLoadedTokens: item.alwaysLoadedTokens,
86
+ weightConfidence: item.weightConfidence
87
+ });
88
+ }
89
+ dead.sort((a, b) => b.alwaysLoadedTokens - a.alwaysLoadedTokens);
90
+ // Only items whose weight we actually measured get a $/token figure. MCP
91
+ // servers (weight unknown without querying tools/list) are counted, not priced.
92
+ const measuredDead = dead.filter((item) => item.weightConfidence === "estimated");
93
+ const unmeasuredDead = dead.filter((item) => item.weightConfidence !== "estimated");
94
+ const measuredTokens = measuredDead.reduce((total, item) => total + item.alwaysLoadedTokens, 0);
95
+ const hasData = items.length > 0 && sessions > 0 && dead.length > 0;
96
+ const rates = pricingRates(config.pricingModel);
97
+ // Cached: one cache write per session + a cache read on every later turn.
98
+ const cacheReads = Math.max(0, totalTurns - sessions);
99
+ const windowCachedUsd = (measuredTokens * (sessions * rates.write5mPerM + cacheReads * rates.cacheReadPerM)) / 1_000_000;
100
+ const windowUncachedUsd = (measuredTokens * totalTurns * rates.inputPerM) / 1_000_000;
101
+ const monthFactor = DEFAULT_WINDOW_DAYS / windowDays;
102
+ return {
103
+ hasData,
104
+ loadedCount,
105
+ deadCount: dead.length,
106
+ measuredDeadCount: measuredDead.length,
107
+ unmeasuredDeadCount: unmeasuredDead.length,
108
+ deadTokens: measuredTokens,
109
+ monthlyDeadTokens: Math.round(measuredTokens * totalTurns * monthFactor),
110
+ wastePercent: loadedCount > 0 ? dead.length / loadedCount : 0,
111
+ monthlyUsd: roundMoney(windowCachedUsd * monthFactor),
112
+ monthlyUsdUpperBound: roundMoney(windowUncachedUsd * monthFactor),
113
+ deadItems: dead,
114
+ sessions,
115
+ totalTurns,
116
+ pricingModel: config.pricingModel,
117
+ windowDays
118
+ };
119
+ }
120
+ /**
121
+ * Adapt a dead-context result to a {@link CutAction} so it can flow into the
122
+ * ranked cut list / AI Receipt. Returns null unless there is MEASURED waste
123
+ * worth a dollar figure (MCP-only waste is shown as a count, not a cut $).
124
+ */
125
+ export function deadContextCutAction(result) {
126
+ if (!result.hasData || result.measuredDeadCount === 0 || result.monthlyUsd < 0.5) {
127
+ return null;
128
+ }
129
+ const pct = Math.round(result.wastePercent * 100);
130
+ return {
131
+ id: "dead-context",
132
+ title: `Trim ${result.measuredDeadCount} loaded tool${result.measuredDeadCount === 1 ? "" : "s"} your agent never calls`,
133
+ action: `Remove or lazy-load ${result.deadCount} of ${result.loadedCount} loaded item${result.loadedCount === 1 ? "" : "s"} ` +
134
+ `(${pct}% never invoked) to reclaim ~${result.deadTokens.toLocaleString("en-US")} tokens of dead context per turn.`,
135
+ estimatedMonthlySavingsUsd: result.monthlyUsd,
136
+ affectedSpendUsd: result.monthlyUsd,
137
+ recordCount: result.deadCount,
138
+ // Dead-context savings come from inventory, not priced usage records, so
139
+ // there are no record IDs to dedupe against the spend-based cut actions.
140
+ recordIds: [],
141
+ confidence: "estimated",
142
+ kind: "context_trim"
143
+ };
144
+ }
145
+ function isDead(item, used) {
146
+ switch (item.kind) {
147
+ case "skill":
148
+ return !used.usedSkills.has(item.name);
149
+ case "subagent":
150
+ return !used.usedSubagents.has(item.name);
151
+ case "command":
152
+ return !used.usedCommands.has(item.name);
153
+ case "mcp_tool":
154
+ return !used.usedMcpTools.has(item.name);
155
+ case "mcp_server":
156
+ return !used.usedMcpServers.has(item.name);
157
+ default:
158
+ return false;
159
+ }
160
+ }
161
+ function pricingRates(model) {
162
+ const rule = findPricingRule(model) ?? findPricingRule(DEFAULT_PRICING_MODEL);
163
+ // Fallback to Sonnet-class numbers if even the default is somehow unmatched.
164
+ const inputPerM = rule?.inputPerM ?? 3;
165
+ return {
166
+ inputPerM,
167
+ cacheReadPerM: rule?.cacheReadPerM ?? inputPerM * 0.1,
168
+ write5mPerM: rule?.cacheWrite5mPerM ?? inputPerM * 1.25
169
+ };
170
+ }
171
+ function roundMoney(value) {
172
+ return Math.round(value * 100) / 100;
173
+ }
174
+ //# sourceMappingURL=deadContext.js.map
@@ -10,6 +10,8 @@ export type LocalDiscoveryResult = {
10
10
  rootPath: string;
11
11
  scannedFiles: number;
12
12
  skippedDirectories: string[];
13
+ /** Paths that could not be read (permissions, dangling symlinks, non-UTF8) — skipped, never fatal. */
14
+ unreadablePaths: string[];
13
15
  signals: UsageSignal[];
14
16
  secretsDetected: string[];
15
17
  redactedEvidence: string[];
package/dist/discovery.js CHANGED
@@ -22,30 +22,66 @@ const providerRules = [
22
22
  { provider: "cursor", kind: "invoice", patterns: [/cursor/i], confidence: 0.76 },
23
23
  { provider: "replit", kind: "invoice", patterns: [/replit/i], confidence: 0.72 }
24
24
  ];
25
- const secretAssignmentPattern = /\b([A-Z][A-Z0-9_]*(?:API_KEY|TOKEN|SECRET|PASSWORD))\s*=\s*([^\s#"']+)/g;
25
+ // Name-based redaction: any UPPER_SNAKE env-style assignment whose name ends
26
+ // in a secret-ish suffix. `KEY` deliberately subsumes API_KEY/ADMIN_KEY/etc —
27
+ // over-redacting a public key is harmless; leaking a private one is not.
28
+ const secretAssignmentPattern = /\b([A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?|AUTH))\s*=\s*([^\s#"']+)/g;
29
+ // Value-based redaction: known secret shapes regardless of how they're named.
26
30
  const providerSecretPatterns = [
27
31
  /sk-proj-[A-Za-z0-9_-]{20,}/g,
28
32
  /sk-ant-api\d{2}-[A-Za-z0-9_-]{20,}/g,
29
33
  /sk-[A-Za-z0-9_-]{20,}/g,
30
- /helicone_[A-Za-z0-9_-]{16,}/gi
34
+ /helicone_[A-Za-z0-9_-]{16,}/gi,
35
+ // GitHub tokens: classic PATs, OAuth, server/user/refresh, fine-grained.
36
+ /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g,
37
+ /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g,
38
+ // Google API keys.
39
+ /\bAIza[0-9A-Za-z_-]{30,}\b/g,
40
+ // JWTs (three base64url segments).
41
+ /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g,
42
+ // Slack tokens.
43
+ /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g,
44
+ // AWS access key IDs.
45
+ /\bAKIA[0-9A-Z]{16}\b/g,
46
+ // GitLab PATs and npm tokens.
47
+ /\bglpat-[A-Za-z0-9_-]{16,}\b/g,
48
+ /\bnpm_[A-Za-z0-9]{30,}\b/g
31
49
  ];
32
50
  export async function scanLocalUsageSignals(rootPath) {
33
51
  const result = {
34
52
  rootPath,
35
53
  scannedFiles: 0,
36
54
  skippedDirectories: [],
55
+ unreadablePaths: [],
37
56
  signals: [],
38
57
  secretsDetected: [],
39
58
  redactedEvidence: []
40
59
  };
41
60
  const secrets = new Set();
42
61
  const skipped = new Set();
62
+ const unreadable = new Set();
43
63
  await walk(rootPath, async (path) => {
44
- const fileInfo = await stat(path);
64
+ // A dangling symlink, permission-denied entry, or unreadable file must
65
+ // never reject the whole scan — real machines are messy. Skip and report.
66
+ let fileInfo;
67
+ try {
68
+ fileInfo = await stat(path);
69
+ }
70
+ catch {
71
+ unreadable.add(path);
72
+ return;
73
+ }
45
74
  if (fileInfo.size > maxFileBytes || !isInterestingFile(path)) {
46
75
  return;
47
76
  }
48
- const raw = await readFile(path, "utf8");
77
+ let raw;
78
+ try {
79
+ raw = await readFile(path, "utf8");
80
+ }
81
+ catch {
82
+ unreadable.add(path);
83
+ return;
84
+ }
49
85
  const redacted = redactSecrets(raw);
50
86
  const relativePath = relative(rootPath, path) || basename(path);
51
87
  result.scannedFiles += 1;
@@ -70,8 +106,11 @@ export async function scanLocalUsageSignals(rootPath) {
70
106
  confidence: rule.confidence
71
107
  });
72
108
  }
73
- }, skipped);
109
+ }, skipped, unreadable);
74
110
  result.skippedDirectories = Array.from(skipped).sort();
111
+ result.unreadablePaths = Array.from(unreadable)
112
+ .map((path) => relative(rootPath, path) || basename(path))
113
+ .sort();
75
114
  result.secretsDetected = Array.from(secrets).sort();
76
115
  result.signals = dedupeSignals(result.signals).sort((left, right) => {
77
116
  const provider = left.provider.localeCompare(right.provider);
@@ -96,8 +135,16 @@ function detectSecretNames(text) {
96
135
  }
97
136
  return Array.from(names);
98
137
  }
99
- async function walk(rootPath, visit, skipped) {
100
- const entries = await readdir(rootPath, { withFileTypes: true });
138
+ async function walk(rootPath, visit, skipped, unreadable) {
139
+ let entries;
140
+ try {
141
+ entries = await readdir(rootPath, { withFileTypes: true });
142
+ }
143
+ catch {
144
+ // Permission-denied or vanished directory: skip it, keep scanning.
145
+ unreadable.add(rootPath);
146
+ return;
147
+ }
101
148
  for (const entry of entries) {
102
149
  const path = join(rootPath, entry.name);
103
150
  if (entry.isDirectory()) {
@@ -105,10 +152,10 @@ async function walk(rootPath, visit, skipped) {
105
152
  skipped.add(entry.name);
106
153
  continue;
107
154
  }
108
- await walk(path, visit, skipped);
155
+ await walk(path, visit, skipped, unreadable);
109
156
  continue;
110
157
  }
111
- if (entry.isFile()) {
158
+ if (entry.isFile() || entry.isSymbolicLink()) {
112
159
  await visit(path);
113
160
  }
114
161
  }
package/dist/index.d.ts CHANGED
@@ -1,13 +1,17 @@
1
1
  export * from "./analyze.js";
2
+ export * from "./agentInventory.js";
2
3
  export * from "./attribution.js";
3
4
  export * from "./credentialDetection.js";
4
5
  export * from "./cutList.js";
6
+ export * from "./deadContext.js";
5
7
  export * from "./discovery.js";
8
+ export * from "./toolInvocations.js";
6
9
  export * from "./insights.js";
7
10
  export * from "./localAgentLogs.js";
8
11
  export * from "./modelPricing.js";
9
12
  export * from "./planMath.js";
10
13
  export * from "./sampleData.js";
14
+ export * from "./scanGuard.js";
11
15
  export * from "./schema.js";
12
16
  export * from "./sourceRegistry.js";
13
17
  export * from "./providerConnectors.js";
package/dist/index.js CHANGED
@@ -1,13 +1,17 @@
1
1
  export * from "./analyze.js";
2
+ export * from "./agentInventory.js";
2
3
  export * from "./attribution.js";
3
4
  export * from "./credentialDetection.js";
4
5
  export * from "./cutList.js";
6
+ export * from "./deadContext.js";
5
7
  export * from "./discovery.js";
8
+ export * from "./toolInvocations.js";
6
9
  export * from "./insights.js";
7
10
  export * from "./localAgentLogs.js";
8
11
  export * from "./modelPricing.js";
9
12
  export * from "./planMath.js";
10
13
  export * from "./sampleData.js";
14
+ export * from "./scanGuard.js";
11
15
  export * from "./schema.js";
12
16
  export * from "./sourceRegistry.js";
13
17
  export * from "./providerConnectors.js";
@@ -12,9 +12,11 @@ type ProviderResponse = {
12
12
  export type ProviderQaPagination = {
13
13
  label: string;
14
14
  pagesFetched: number;
15
- stoppedBecause: "complete" | "missing_cursor" | "max_pages";
15
+ stoppedBecause: "complete" | "missing_cursor" | "max_pages" | "fetch_error";
16
16
  maxPages: number;
17
17
  limitPerPage?: number;
18
+ /** Present when stoppedBecause is "fetch_error": the sanitized reason the fetch stopped early. */
19
+ note?: string;
18
20
  };
19
21
  export type ProviderQaRateLimit = {
20
22
  label: string;
@@ -62,6 +64,8 @@ export type CreateProviderConnectionInput = {
62
64
  verifiedRecordCount: number;
63
65
  totalUsd: number;
64
66
  fetchedAt?: Date;
67
+ /** Record-derived completeness; the source's verification label mirrors it. */
68
+ completeness?: ProviderConnectorResult["completeness"];
65
69
  };
66
70
  export type TokenResolver = (reference: string) => string;
67
71
  export type Fetcher = (url: string, init?: {