@agent-finops/core 0.1.0 → 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,86 @@
1
+ /**
2
+ * Agent context inventory: enumerate the Claude Code "inventory" that gets
3
+ * loaded into an agent's always-on context (skills, subagents, slash commands,
4
+ * MCP servers + their tools) and estimate how many tokens each item adds.
5
+ *
6
+ * This feeds a later "dead-context cost" feature that prices loaded-but-never-
7
+ * invoked tools. Every read here is read-only and missing dirs/files never throw.
8
+ *
9
+ * CRITICAL token-weight rules (these drive the honesty of the final $ number):
10
+ * - Skills use *progressive disclosure*: only the YAML frontmatter (`name` +
11
+ * `description`) is always loaded — the body loads only when invoked. So a
12
+ * skill's alwaysLoadedTokens reflects ONLY name + description, never the body.
13
+ * - MCP tools: the FULL tool definition (name + description + JSON input schema)
14
+ * is always loaded — the heavy weight. Config (~/.claude.json) almost never
15
+ * carries tool schemas, so MCP enumeration is usually limited to server names;
16
+ * those items are flagged "estimated_understated" because the real weight is
17
+ * larger than what we can see.
18
+ * - Subagents / slash commands: estimate from their description/frontmatter line
19
+ * only (what is surfaced in the always-loaded list), not the whole file body.
20
+ * - Built-in tools (Read/Edit/Bash/Glob/Grep/etc.) are EXCLUDED entirely: always
21
+ * loaded, not prunable, not "waste."
22
+ */
23
+ export type InventoryKind = "skill" | "subagent" | "command" | "mcp_tool" | "mcp_server";
24
+ export type InventoryItem = {
25
+ kind: InventoryKind;
26
+ /**
27
+ * Canonical matchable name. mcp tool: "mcp__<server>__<tool>"; mcp server:
28
+ * the server id; skill/subagent/command: their declared name.
29
+ */
30
+ name: string;
31
+ scope: "user" | "project";
32
+ /** e.g. mcp server name for an mcp_tool, plugin name for a plugin skill. */
33
+ group?: string;
34
+ alwaysLoadedTokens: number;
35
+ /** "estimated_understated" when an MCP tool schema is unavailable. */
36
+ weightConfidence: "estimated" | "estimated_understated";
37
+ path?: string;
38
+ };
39
+ export type AgentInventoryOptions = {
40
+ /** Default: ~/.claude */
41
+ claudeHomeDir?: string;
42
+ /** Default: ~/.claude.json */
43
+ claudeConfigPath?: string;
44
+ /** Default: process.cwd(); scans <projectDir>/.claude/**. */
45
+ projectDir?: string;
46
+ /**
47
+ * Include MCP servers from EVERY project in the config (not just projectDir).
48
+ * Used for the global "across your whole setup" dead-context view so the
49
+ * first run is populated from any directory. Default false (project-scoped).
50
+ */
51
+ includeAllProjectMcp?: boolean;
52
+ };
53
+ export type AgentInventoryResult = {
54
+ items: InventoryItem[];
55
+ scanned: {
56
+ skills: number;
57
+ subagents: number;
58
+ commands: number;
59
+ mcpServers: number;
60
+ mcpTools: number;
61
+ };
62
+ };
63
+ /** Token estimate: Math.ceil(chars / 4). Exported so the parent reuses it. */
64
+ export declare function estimateTokensFromText(text: string): number;
65
+ /**
66
+ * Conservative floor for an MCP server's always-loaded token weight when its
67
+ * tool schemas aren't readable from config. A single tool's
68
+ * name+description+JSON-schema is commonly ~300–800 tokens and servers usually
69
+ * expose several; 700 is a deliberately low estimate, always paired with
70
+ * weightConfidence "estimated_understated" so we under-claim, never over-claim.
71
+ */
72
+ export declare const MCP_SERVER_TOKEN_FLOOR = 700;
73
+ /** Scan this machine's (and the project's) agent inventory. Never throws. */
74
+ export declare function loadAgentInventory(options?: AgentInventoryOptions): Promise<AgentInventoryResult>;
75
+ type Frontmatter = {
76
+ name?: string;
77
+ description?: string;
78
+ };
79
+ /**
80
+ * Extract `name` and `description` from a leading `---` fenced YAML block.
81
+ * Handles quoted values and folded/multi-line descriptions (continuation lines
82
+ * are read until the next top-level `key:` or the closing fence).
83
+ */
84
+ export declare function parseFrontmatter(content: string): Frontmatter;
85
+ export {};
86
+ //# sourceMappingURL=agentInventory.d.ts.map
@@ -0,0 +1,324 @@
1
+ import { readdir, readFile, stat } from "node:fs/promises";
2
+ import { basename, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ /** Token estimate: Math.ceil(chars / 4). Exported so the parent reuses it. */
5
+ export function estimateTokensFromText(text) {
6
+ return Math.ceil(text.length / 4);
7
+ }
8
+ /**
9
+ * Conservative floor for an MCP server's always-loaded token weight when its
10
+ * tool schemas aren't readable from config. A single tool's
11
+ * name+description+JSON-schema is commonly ~300–800 tokens and servers usually
12
+ * expose several; 700 is a deliberately low estimate, always paired with
13
+ * weightConfidence "estimated_understated" so we under-claim, never over-claim.
14
+ */
15
+ export const MCP_SERVER_TOKEN_FLOOR = 700;
16
+ /** Scan this machine's (and the project's) agent inventory. Never throws. */
17
+ export async function loadAgentInventory(options = {}) {
18
+ const home = homedir();
19
+ const claudeHome = options.claudeHomeDir ?? join(home, ".claude");
20
+ const configPath = options.claudeConfigPath ?? join(home, ".claude.json");
21
+ const projectDir = options.projectDir ?? process.cwd();
22
+ const projectClaude = join(projectDir, ".claude");
23
+ const items = [];
24
+ const scanned = { skills: 0, subagents: 0, commands: 0, mcpServers: 0, mcpTools: 0 };
25
+ // --- Skills (user + project) ---
26
+ for (const { dir, scope } of [
27
+ { dir: join(claudeHome, "skills"), scope: "user" },
28
+ { dir: join(projectClaude, "skills"), scope: "project" }
29
+ ]) {
30
+ for (const file of await findFiles(dir, (name) => name === "SKILL.md")) {
31
+ const content = await readFile(file, "utf8").catch(() => "");
32
+ if (!content)
33
+ continue;
34
+ scanned.skills += 1;
35
+ const fm = parseFrontmatter(content);
36
+ const name = fm.name ?? skillNameFromPath(file, dir);
37
+ // Only frontmatter (name + description) is always loaded — progressive disclosure.
38
+ const loadedText = [
39
+ name ? `name: ${name}` : "",
40
+ fm.description ? `description: ${fm.description}` : ""
41
+ ]
42
+ .filter(Boolean)
43
+ .join("\n");
44
+ items.push({
45
+ kind: "skill",
46
+ name,
47
+ scope,
48
+ group: pluginGroupFromPath(file, dir),
49
+ alwaysLoadedTokens: estimateTokensFromText(loadedText),
50
+ weightConfidence: "estimated",
51
+ path: file
52
+ });
53
+ }
54
+ }
55
+ // --- Subagents (user + project) ---
56
+ for (const { dir, scope } of [
57
+ { dir: join(claudeHome, "agents"), scope: "user" },
58
+ { dir: join(projectClaude, "agents"), scope: "project" }
59
+ ]) {
60
+ for (const file of await findFiles(dir, (name) => name.endsWith(".md"))) {
61
+ const content = await readFile(file, "utf8").catch(() => "");
62
+ if (!content)
63
+ continue;
64
+ scanned.subagents += 1;
65
+ const fm = parseFrontmatter(content);
66
+ const name = fm.name ?? basename(file).replace(/\.md$/i, "");
67
+ // Only the surfaced description line is always loaded, not the body.
68
+ const loadedText = [
69
+ `name: ${name}`,
70
+ fm.description ? `description: ${fm.description}` : ""
71
+ ]
72
+ .filter(Boolean)
73
+ .join("\n");
74
+ items.push({
75
+ kind: "subagent",
76
+ name,
77
+ scope,
78
+ alwaysLoadedTokens: estimateTokensFromText(loadedText),
79
+ weightConfidence: "estimated",
80
+ path: file
81
+ });
82
+ }
83
+ }
84
+ // --- Slash commands (user + project) ---
85
+ for (const { dir, scope } of [
86
+ { dir: join(claudeHome, "commands"), scope: "user" },
87
+ { dir: join(projectClaude, "commands"), scope: "project" }
88
+ ]) {
89
+ for (const file of await findFiles(dir, (name) => name.endsWith(".md"))) {
90
+ const content = await readFile(file, "utf8").catch(() => "");
91
+ if (!content)
92
+ continue;
93
+ scanned.commands += 1;
94
+ const fm = parseFrontmatter(content);
95
+ const name = commandNameFromPath(file, dir);
96
+ // Commands surface a name + (optional) description line in the always-loaded list.
97
+ const loadedText = [
98
+ `/${name}`,
99
+ fm.description ?? firstNonFrontmatterLine(content) ?? ""
100
+ ]
101
+ .filter(Boolean)
102
+ .join(" ");
103
+ items.push({
104
+ kind: "command",
105
+ name,
106
+ scope,
107
+ alwaysLoadedTokens: estimateTokensFromText(loadedText),
108
+ weightConfidence: "estimated",
109
+ path: file
110
+ });
111
+ }
112
+ }
113
+ // --- MCP servers (from ~/.claude.json: top-level + per-project map) ---
114
+ const config = await readJson(configPath);
115
+ const serverScopes = collectMcpServers(config, projectDir, options.includeAllProjectMcp ?? false);
116
+ for (const { id, scope } of serverScopes) {
117
+ scanned.mcpServers += 1;
118
+ // We almost never have tool schemas from config, so we can't measure the
119
+ // real weight (full tool definitions). Use a conservative published-typical
120
+ // FLOOR per server instead of the bare id — a single MCP tool's
121
+ // name+description+JSON schema is commonly several hundred tokens, and
122
+ // servers usually expose multiple tools. Flagged "estimated_understated":
123
+ // the true weight is almost certainly higher, never lower.
124
+ items.push({
125
+ kind: "mcp_server",
126
+ name: id,
127
+ scope,
128
+ group: id,
129
+ alwaysLoadedTokens: MCP_SERVER_TOKEN_FLOOR,
130
+ weightConfidence: "estimated_understated",
131
+ path: configPath
132
+ });
133
+ }
134
+ return { items, scanned };
135
+ }
136
+ // --------------------------------------------------------------------------
137
+ // MCP config extraction
138
+ // --------------------------------------------------------------------------
139
+ function collectMcpServers(config, projectDir, includeAllProjectMcp) {
140
+ if (!isRecord(config))
141
+ return [];
142
+ const out = [];
143
+ const seen = new Set();
144
+ const add = (id, scope) => {
145
+ // Dedupe by id across all scopes so a server configured in several projects
146
+ // is counted once in the global view.
147
+ const key = includeAllProjectMcp ? id : `${scope}:${id}`;
148
+ if (seen.has(key))
149
+ return;
150
+ seen.add(key);
151
+ out.push({ id, scope });
152
+ };
153
+ // Top-level mcpServers are user-scope (global).
154
+ if (isRecord(config.mcpServers)) {
155
+ for (const id of Object.keys(config.mcpServers))
156
+ add(id, "user");
157
+ }
158
+ // Per-project mcpServers live under projects[<absolute dir>].mcpServers.
159
+ // Global view: collect every project's servers; otherwise just this project's.
160
+ if (isRecord(config.projects)) {
161
+ const entries = includeAllProjectMcp
162
+ ? Object.values(config.projects)
163
+ : [config.projects[projectDir]];
164
+ for (const projectEntry of entries) {
165
+ if (isRecord(projectEntry) && isRecord(projectEntry.mcpServers)) {
166
+ for (const id of Object.keys(projectEntry.mcpServers))
167
+ add(id, "project");
168
+ }
169
+ }
170
+ }
171
+ return out;
172
+ }
173
+ /**
174
+ * Extract `name` and `description` from a leading `---` fenced YAML block.
175
+ * Handles quoted values and folded/multi-line descriptions (continuation lines
176
+ * are read until the next top-level `key:` or the closing fence).
177
+ */
178
+ export function parseFrontmatter(content) {
179
+ const lines = content.split(/\r?\n/);
180
+ if ((lines[0] ?? "").trim() !== "---")
181
+ return {};
182
+ let end = -1;
183
+ for (let i = 1; i < lines.length; i += 1) {
184
+ if (lines[i].trim() === "---") {
185
+ end = i;
186
+ break;
187
+ }
188
+ }
189
+ if (end === -1)
190
+ return {};
191
+ const fm = {};
192
+ for (let i = 1; i < end; i += 1) {
193
+ const line = lines[i];
194
+ const match = /^([A-Za-z0-9_-]+):\s?(.*)$/.exec(line);
195
+ if (!match)
196
+ continue;
197
+ const key = match[1].toLowerCase();
198
+ if (key !== "name" && key !== "description")
199
+ continue;
200
+ let value = match[2];
201
+ // Block scalar (| or >): gather indented continuation lines.
202
+ if (value.trim() === "|" || value.trim() === ">" || value.trim() === "") {
203
+ const collected = [];
204
+ for (let j = i + 1; j < end; j += 1) {
205
+ if (/^([A-Za-z0-9_-]+):\s?/.test(lines[j]) && !/^\s/.test(lines[j]))
206
+ break;
207
+ collected.push(lines[j].trim());
208
+ i = j;
209
+ }
210
+ value = collected.join(" ").trim();
211
+ }
212
+ else {
213
+ // Plain/quoted scalar may wrap onto following indented (non-key) lines.
214
+ for (let j = i + 1; j < end; j += 1) {
215
+ if (/^([A-Za-z0-9_-]+):\s?/.test(lines[j]) && !/^\s/.test(lines[j]))
216
+ break;
217
+ if (lines[j].trim() === "")
218
+ break;
219
+ value += ` ${lines[j].trim()}`;
220
+ i = j;
221
+ }
222
+ }
223
+ fm[key] = unquote(value.trim());
224
+ }
225
+ return fm;
226
+ }
227
+ function firstNonFrontmatterLine(content) {
228
+ const lines = content.split(/\r?\n/);
229
+ let start = 0;
230
+ if ((lines[0] ?? "").trim() === "---") {
231
+ for (let i = 1; i < lines.length; i += 1) {
232
+ if (lines[i].trim() === "---") {
233
+ start = i + 1;
234
+ break;
235
+ }
236
+ }
237
+ }
238
+ for (let i = start; i < lines.length; i += 1) {
239
+ const trimmed = lines[i].trim().replace(/^#+\s*/, "");
240
+ if (trimmed)
241
+ return trimmed;
242
+ }
243
+ return undefined;
244
+ }
245
+ function unquote(value) {
246
+ if (value.length >= 2) {
247
+ const first = value[0];
248
+ const last = value[value.length - 1];
249
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
250
+ return value.slice(1, -1);
251
+ }
252
+ }
253
+ return value;
254
+ }
255
+ // --------------------------------------------------------------------------
256
+ // Path / name helpers
257
+ // --------------------------------------------------------------------------
258
+ /** Skill name fallback: the directory that contains SKILL.md. */
259
+ function skillNameFromPath(file, root) {
260
+ const parent = basename(join(file, ".."));
261
+ return parent && parent !== basename(root) ? parent : basename(file);
262
+ }
263
+ /** Plugin skills nest one level deeper (root/<plugin>/<skill>/SKILL.md). */
264
+ function pluginGroupFromPath(file, root) {
265
+ const rel = relativeSegments(file, root);
266
+ // rel = [..., <plugin>, <skill>, "SKILL.md"] when nested under a plugin.
267
+ if (rel.length >= 3)
268
+ return rel[0];
269
+ return undefined;
270
+ }
271
+ /** Slash command name: path under commands/ joined by ":" (namespacing). */
272
+ function commandNameFromPath(file, root) {
273
+ const rel = relativeSegments(file, root);
274
+ const parts = rel.map((s) => s).filter(Boolean);
275
+ const last = parts.pop() ?? basename(file);
276
+ const name = last.replace(/\.md$/i, "");
277
+ return parts.length > 0 ? `${parts.join(":")}:${name}` : name;
278
+ }
279
+ function relativeSegments(file, root) {
280
+ const normFile = file.replace(/\\/g, "/");
281
+ const normRoot = root.replace(/\\/g, "/").replace(/\/$/, "");
282
+ const rest = normFile.startsWith(normRoot + "/")
283
+ ? normFile.slice(normRoot.length + 1)
284
+ : basename(file);
285
+ return rest.split("/").filter(Boolean);
286
+ }
287
+ // --------------------------------------------------------------------------
288
+ // Filesystem + typed helpers (style mirrors localAgentLogs.ts)
289
+ // --------------------------------------------------------------------------
290
+ /** Recursively collect files under `root` whose basename matches `match`. */
291
+ async function findFiles(root, match) {
292
+ const isDir = await stat(root).then((s) => s.isDirectory()).catch(() => false);
293
+ if (!isDir)
294
+ return [];
295
+ const out = [];
296
+ const queue = [root];
297
+ while (queue.length > 0) {
298
+ const dir = queue.pop();
299
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
300
+ for (const entry of entries) {
301
+ const path = join(dir, entry.name);
302
+ if (entry.isDirectory())
303
+ queue.push(path);
304
+ else if (entry.isFile() && match(entry.name))
305
+ out.push(path);
306
+ }
307
+ }
308
+ return out;
309
+ }
310
+ async function readJson(path) {
311
+ const content = await readFile(path, "utf8").catch(() => "");
312
+ if (!content)
313
+ return undefined;
314
+ try {
315
+ return JSON.parse(content);
316
+ }
317
+ catch {
318
+ return undefined;
319
+ }
320
+ }
321
+ function isRecord(value) {
322
+ return typeof value === "object" && value !== null;
323
+ }
324
+ //# sourceMappingURL=agentInventory.js.map
package/dist/analyze.js CHANGED
@@ -6,6 +6,37 @@ const confidenceRank = {
6
6
  detected_unverified: 2,
7
7
  missing: 3
8
8
  };
9
+ /**
10
+ * Planning ratios behind every "estimated impact/savings" figure this module
11
+ * emits. These are deliberately ROUND heuristics — orientation numbers for a
12
+ * first conversation, not measured savings — and every consumer labels them
13
+ * estimated. They are aligned with the documented per-model economics in
14
+ * cutList.ts (downgradeRules retain 20–50% of cost on downgrade-safe work;
15
+ * the Batch API retains 50%): applying those cuts to only the eligible slice
16
+ * of a workload typically lands in the 10–30% range below.
17
+ *
18
+ * If you change one, change the doc line with it. No undocumented multiplier
19
+ * may ever reach user-visible output — that is a product bug on an
20
+ * honest-numbers brand, not a style issue.
21
+ */
22
+ const impactRatios = {
23
+ /** Portion of a workflow's spend typically cuttable via caps, caching, and tier routing. */
24
+ workflowSavings: 0.2,
25
+ /** Un-attributed workflow spend treated as margin-exposed until mapped to a client/project (coin-flip prior). */
26
+ workflowMarginRisk: 0.5,
27
+ /** Top-model spend recoverable by moving downgrade-safe work to a cheaper tier (see cutList.ts downgradeRules). */
28
+ modelDowngrade: 0.3,
29
+ /** Cost of oversized-context calls recoverable by trimming prompts/retrieval. */
30
+ promptTrimming: 0.15,
31
+ /** Spend on repeated identical operations recoverable via caching/memoization. */
32
+ caching: 0.25,
33
+ /** Top-agent spend avoidable with budget caps catching runaway loops. */
34
+ agentCaps: 0.15,
35
+ /** Total spend addressable by moving latency-tolerant work to Batch APIs (50% price × eligible slice). */
36
+ batching: 0.1,
37
+ /** Total spend addressable with price/quality routing across multiple providers. */
38
+ routing: 0.1
39
+ };
9
40
  export function analyzeSpend(records) {
10
41
  const summary = {
11
42
  totalUsd: roundMoney(sumRecords(records)),
@@ -78,8 +109,8 @@ export function generateWorkflowWatch(records) {
78
109
  const [clientId, projectId, workflowKey, agentId] = key.split("::");
79
110
  const amountUsd = roundMoney(sumRecords(groupRecords));
80
111
  const shareOfSpend = roundRatio(amountUsd / totalUsd);
81
- const estimatedSavingsUsd = roundMoney(amountUsd * 0.236875);
82
- const estimatedMarginRiskUsd = roundMoney(amountUsd * 0.625);
112
+ const estimatedSavingsUsd = roundMoney(amountUsd * impactRatios.workflowSavings);
113
+ const estimatedMarginRiskUsd = roundMoney(amountUsd * impactRatios.workflowMarginRisk);
83
114
  const confidence = combinedConfidence(groupRecords.map((record) => record.costConfidence));
84
115
  const suggestedOptimization = workflowOptimizationFor(workflowKey, agentId);
85
116
  return {
@@ -115,7 +146,7 @@ export function generateRecommendations(records) {
115
146
  whyItMatters: "Premium model usage tends to become invisible once agents are running in the background. Board owners need a clear rule for which jobs deserve the expensive model.",
116
147
  nextAction: `Audit the top ${topModel.key} operations and move low-risk summarization, extraction, and draft work to a cheaper model tier first.`,
117
148
  priority: "high",
118
- estimatedImpactUsd: roundMoney(topModel.amountUsd * 0.3237),
149
+ estimatedImpactUsd: roundMoney(topModel.amountUsd * impactRatios.modelDowngrade),
119
150
  confidence: topModel.confidence,
120
151
  relatedKeys: [topModel.key]
121
152
  });
@@ -129,7 +160,7 @@ export function generateRecommendations(records) {
129
160
  whyItMatters: "Context bloat compounds across every agent run and can make spend rise even when output quality does not improve.",
130
161
  nextAction: "Sample the largest prompts, cap retrieval chunks, and require justification before agents include full documents or long histories.",
131
162
  priority: "high",
132
- estimatedImpactUsd: roundMoney(sumRecords(highInputTokenRecords) * 0.18),
163
+ estimatedImpactUsd: roundMoney(sumRecords(highInputTokenRecords) * impactRatios.promptTrimming),
133
164
  confidence: combinedConfidence(highInputTokenRecords.map((record) => record.costConfidence)),
134
165
  relatedKeys: unique(highInputTokenRecords.map((record) => record.model))
135
166
  });
@@ -143,7 +174,7 @@ export function generateRecommendations(records) {
143
174
  whyItMatters: "Repeated AI calls are the easiest spend to defend cutting because they usually do not change the customer experience.",
144
175
  nextAction: "Add a local cache or memoization policy for repeated operation labels before expanding this workflow to more clients.",
145
176
  priority: "medium",
146
- estimatedImpactUsd: roundMoney(sumRecords(records.filter((record) => repeatedOperations.includes(record.operation ?? ""))) * 0.25),
177
+ estimatedImpactUsd: roundMoney(sumRecords(records.filter((record) => repeatedOperations.includes(record.operation ?? ""))) * impactRatios.caching),
147
178
  confidence: combinedConfidence(records.map((record) => record.costConfidence)),
148
179
  relatedKeys: repeatedOperations
149
180
  });
@@ -158,7 +189,7 @@ export function generateRecommendations(records) {
158
189
  whyItMatters: "An autonomous agent can quietly turn one bad loop or broad task into a budget issue before anyone reviews the invoice.",
159
190
  nextAction: `Set a warning threshold and hard cap for ${topAgent.key}, then require approval when a run exceeds its expected range.`,
160
191
  priority: "high",
161
- estimatedImpactUsd: roundMoney(topAgent.amountUsd * 0.15),
192
+ estimatedImpactUsd: roundMoney(topAgent.amountUsd * impactRatios.agentCaps),
162
193
  confidence: topAgent.confidence,
163
194
  relatedKeys: [topAgent.key]
164
195
  });
@@ -171,7 +202,7 @@ export function generateRecommendations(records) {
171
202
  whyItMatters: "Batching turns scattered background calls into an intentional queue, which makes spend easier to forecast and approve.",
172
203
  nextAction: "Mark jobs that do not need immediate responses and run them in scheduled batches with a shared context budget.",
173
204
  priority: "medium",
174
- estimatedImpactUsd: roundMoney(sumRecords(records) * 0.08),
205
+ estimatedImpactUsd: roundMoney(sumRecords(records) * impactRatios.batching),
175
206
  confidence: combinedConfidence(records.map((record) => record.costConfidence)),
176
207
  relatedKeys: ["usage-records"]
177
208
  });
@@ -185,7 +216,7 @@ export function generateRecommendations(records) {
185
216
  whyItMatters: "Without routing policy, teams pay premium prices for tasks where cheaper models or providers would be good enough.",
186
217
  nextAction: "Define default provider/model tiers for extraction, drafting, research, and high-stakes reasoning, then measure quality deltas.",
187
218
  priority: "medium",
188
- estimatedImpactUsd: roundMoney(sumRecords(records) * 0.12),
219
+ estimatedImpactUsd: roundMoney(sumRecords(records) * impactRatios.routing),
189
220
  confidence: combinedConfidence(records.map((record) => record.costConfidence)),
190
221
  relatedKeys: sources
191
222
  });
package/dist/cutList.d.ts CHANGED
@@ -22,8 +22,45 @@ export type CutAction = {
22
22
  /** Lowest confidence of the underlying records (drives how we caveat $). */
23
23
  confidence: CostConfidence;
24
24
  kind: "model_downgrade" | "context_trim" | "cache" | "batch";
25
+ /**
26
+ * IDs of the usage records this action's savings are computed from. Used to
27
+ * deduplicate overlapping recommendations so the same spend is never counted
28
+ * by two actions (see {@link buildRecommendedPlan}).
29
+ */
30
+ recordIds: string[];
25
31
  };
32
+ /**
33
+ * A non-overlapping "recommended plan" plus the leftover overlapping
34
+ * opportunities. The recommended-plan total is the only savings number safe to
35
+ * present as a single figure: each underlying record is optimized by at most one
36
+ * action, so the total can never exceed the projected spend it draws from.
37
+ */
38
+ export type RecommendedPlan = {
39
+ /** Actions chosen so their underlying records don't overlap. */
40
+ recommended: CutAction[];
41
+ /** Actions dropped because they target spend already claimed above. */
42
+ additional: CutAction[];
43
+ /** Deduplicated monthly savings — safe to display as one number. */
44
+ recommendedSavingsUsd: number;
45
+ /** Savings from the overlapping leftovers — NOT additive with the above. */
46
+ additionalSavingsUsd: number;
47
+ /** How the headline number was derived (for honest labeling). */
48
+ savingsMath: "deduplicated";
49
+ };
50
+ /**
51
+ * Select a non-overlapping subset of cut actions, highest-savings first. An
52
+ * action is added only if none of its records were already claimed by a
53
+ * previously selected action; otherwise it falls to {@link RecommendedPlan.additional}.
54
+ * This guarantees the recommended total never double-counts a dollar of spend.
55
+ */
56
+ export declare function buildRecommendedPlan(actions: CutAction[]): RecommendedPlan;
26
57
  export declare function generateCutList(records: UsageRecord[]): CutAction[];
27
58
  /** Sum of all per-action estimated monthly savings. */
28
59
  export declare function totalEstimatedMonthlySavingsUsd(actions: CutAction[]): number;
60
+ /**
61
+ * Public view of the observed window, so the renderer can caveat monthly
62
+ * projections honestly: a 30-day figure extrapolated from 1–2 days of data
63
+ * assumes the pattern repeats, which it may not.
64
+ */
65
+ export declare function usageWindowDays(records: UsageRecord[]): number;
29
66
  //# sourceMappingURL=cutList.d.ts.map
package/dist/cutList.js CHANGED
@@ -1,3 +1,33 @@
1
+ /**
2
+ * Select a non-overlapping subset of cut actions, highest-savings first. An
3
+ * action is added only if none of its records were already claimed by a
4
+ * previously selected action; otherwise it falls to {@link RecommendedPlan.additional}.
5
+ * This guarantees the recommended total never double-counts a dollar of spend.
6
+ */
7
+ export function buildRecommendedPlan(actions) {
8
+ const sorted = [...actions].sort((left, right) => right.estimatedMonthlySavingsUsd - left.estimatedMonthlySavingsUsd ||
9
+ left.id.localeCompare(right.id));
10
+ const claimed = new Set();
11
+ const recommended = [];
12
+ const additional = [];
13
+ for (const action of sorted) {
14
+ const overlaps = action.recordIds.some((id) => claimed.has(id));
15
+ if (overlaps) {
16
+ additional.push(action);
17
+ continue;
18
+ }
19
+ for (const id of action.recordIds)
20
+ claimed.add(id);
21
+ recommended.push(action);
22
+ }
23
+ return {
24
+ recommended,
25
+ additional,
26
+ recommendedSavingsUsd: roundMoney(recommended.reduce((total, a) => total + a.estimatedMonthlySavingsUsd, 0)),
27
+ additionalSavingsUsd: roundMoney(additional.reduce((total, a) => total + a.estimatedMonthlySavingsUsd, 0)),
28
+ savingsMath: "deduplicated"
29
+ };
30
+ }
1
31
  const confidenceRank = {
2
32
  verified: 0,
3
33
  estimated: 1,
@@ -5,12 +35,17 @@ const confidenceRank = {
5
35
  missing: 3
6
36
  };
7
37
  const downgradeRules = [
38
+ // Frontier tiers (mid-2026): Fable 5 ($10/$50 per M) -> Opus 4.8 ($5/$25)
39
+ // retains ~50% of cost; GPT-5.x -> matching mini tier retains ~20%.
40
+ { match: /^claude-fable-5(?:[.-].*)?$/i, target: "claude-opus-4-8", costRetained: 0.5 },
41
+ { match: /^gpt-5\.5$/i, target: "gpt-5.5-mini", costRetained: 0.2 },
42
+ { match: /^gpt-5(\.\d+)?$/i, target: "gpt-5-mini", costRetained: 0.2 },
8
43
  { match: /^gpt-4\.1$/i, target: "gpt-4.1-mini", costRetained: 0.2 },
9
44
  { match: /^gpt-4o$/i, target: "gpt-4o-mini", costRetained: 0.18 },
10
45
  { match: /^gpt-4-turbo$/i, target: "gpt-4o-mini", costRetained: 0.12 },
11
46
  { 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 },
47
+ { match: /^claude-sonnet-4(?:[.-].*)?$/i, target: "claude-haiku-4-5", costRetained: 0.25 },
48
+ { match: /^claude-opus-4(?:[.-].*)?$/i, target: "claude-sonnet-4-6", costRetained: 0.3 },
14
49
  { match: /^claude-3-5-sonnet.*$/i, target: "claude-3-5-haiku", costRetained: 0.25 }
15
50
  ];
16
51
  /**
@@ -76,6 +111,7 @@ function modelDowngradeActions(records) {
76
111
  estimatedMonthlySavingsUsd: monthlySavings,
77
112
  affectedSpendUsd,
78
113
  recordCount: groupRecords.length,
114
+ recordIds: groupRecords.map((record) => record.id),
79
115
  confidence: combinedConfidence(groupRecords.map((record) => record.costConfidence)),
80
116
  kind: "model_downgrade"
81
117
  });
@@ -107,6 +143,7 @@ function contextTrimActions(records) {
107
143
  estimatedMonthlySavingsUsd: monthlySavings,
108
144
  affectedSpendUsd,
109
145
  recordCount: groupRecords.length,
146
+ recordIds: groupRecords.map((record) => record.id),
110
147
  confidence: combinedConfidence(groupRecords.map((record) => record.costConfidence)),
111
148
  kind: "context_trim"
112
149
  });
@@ -138,6 +175,7 @@ function cacheActions(records) {
138
175
  estimatedMonthlySavingsUsd: monthlySavings,
139
176
  affectedSpendUsd,
140
177
  recordCount: groupRecords.length,
178
+ recordIds: groupRecords.map((record) => record.id),
141
179
  confidence: combinedConfidence(groupRecords.map((record) => record.costConfidence)),
142
180
  kind: "cache"
143
181
  });
@@ -168,6 +206,7 @@ function batchActions(records) {
168
206
  estimatedMonthlySavingsUsd: monthlySavings,
169
207
  affectedSpendUsd,
170
208
  recordCount: groupRecords.length,
209
+ recordIds: groupRecords.map((record) => record.id),
171
210
  confidence: combinedConfidence(groupRecords.map((record) => record.costConfidence)),
172
211
  kind: "batch"
173
212
  });
@@ -179,6 +218,14 @@ function windowDays(records) {
179
218
  const days = new Set(records.map((record) => record.timestamp.slice(0, 10)));
180
219
  return Math.max(1, days.size);
181
220
  }
221
+ /**
222
+ * Public view of the observed window, so the renderer can caveat monthly
223
+ * projections honestly: a 30-day figure extrapolated from 1–2 days of data
224
+ * assumes the pattern repeats, which it may not.
225
+ */
226
+ export function usageWindowDays(records) {
227
+ return windowDays(records);
228
+ }
182
229
  /** Project a window's savings to a 30-day month. */
183
230
  function toMonthly(windowSavings, windowDayCount) {
184
231
  return (windowSavings / windowDayCount) * 30;