@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.
- package/dist/agentInventory.d.ts +86 -0
- package/dist/agentInventory.js +324 -0
- package/dist/analyze.js +39 -8
- package/dist/cutList.d.ts +37 -0
- package/dist/cutList.js +49 -2
- package/dist/deadContext.d.ts +72 -0
- package/dist/deadContext.js +174 -0
- package/dist/discovery.d.ts +2 -0
- package/dist/discovery.js +56 -9
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/modelPricing.js +1 -0
- package/dist/providerConnectors.d.ts +5 -1
- package/dist/providerConnectors.js +128 -21
- package/dist/scanGuard.d.ts +8 -0
- package/dist/scanGuard.js +65 -0
- package/dist/schema.d.ts +29 -29
- package/dist/toolInvocations.d.ts +61 -0
- package/dist/toolInvocations.js +186 -0
- package/package.json +9 -4
- package/samples/anthropic-usage.csv +3 -3
- package/samples/openai-usage.csv +6 -6
- package/dist/tsconfig.tsbuildinfo +0 -1
|
@@ -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 *
|
|
82
|
-
const estimatedMarginRiskUsd = roundMoney(amountUsd *
|
|
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 *
|
|
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) *
|
|
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 ?? ""))) *
|
|
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 *
|
|
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) *
|
|
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) *
|
|
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;
|