@agent-finops/core 0.5.7 → 0.5.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/dist/agentInventory.d.ts +11 -14
- package/dist/agentInventory.js +180 -46
- package/dist/analyze.js +166 -114
- package/dist/attribution.js +9 -6
- package/dist/contextHealth.d.ts +10 -1
- package/dist/contextHealth.js +216 -44
- package/dist/cutList.d.ts +9 -3
- package/dist/cutList.js +99 -41
- package/dist/deadContext.d.ts +8 -4
- package/dist/deadContext.js +83 -26
- package/dist/glance.d.ts +3 -0
- package/dist/glance.js +46 -12
- package/dist/insights.js +53 -30
- package/dist/localAgentLogs.d.ts +44 -2
- package/dist/localAgentLogs.js +333 -78
- package/dist/providerConnectors.js +8 -0
- package/dist/sampleData.js +28 -1
- package/dist/schema.d.ts +83 -7
- package/dist/schema.js +85 -1
- package/dist/toolInvocations.d.ts +47 -18
- package/dist/toolInvocations.js +200 -48
- package/package.json +2 -2
- package/samples/anthropic-usage.csv +4 -4
- package/samples/openai-usage.csv +7 -7
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @agent-finops/core
|
|
2
2
|
|
|
3
|
-
The canonical local-first
|
|
3
|
+
The canonical local-first evidence and decision engine for
|
|
4
4
|
[aibill](https://github.com/futurastudio/ai-spend-agent): Claude Code/Codex
|
|
5
5
|
activity ingestion, provider cost semantics, attribution, provenance, runway,
|
|
6
6
|
Context Health, and the shared Glance contract.
|
|
@@ -8,6 +8,10 @@ Context Health, and the shared Glance contract.
|
|
|
8
8
|
Most users should run `npx aibill`. This package is for integrations that
|
|
9
9
|
need the same evidence-labeled calculations as the CLI and MCP server.
|
|
10
10
|
|
|
11
|
+
This is the open foundation for aibill's financial-accountability mission. It
|
|
12
|
+
does not yet implement company-wide ownership, accepted outcomes, approvals,
|
|
13
|
+
invoice reconciliation, or ROI.
|
|
14
|
+
|
|
11
15
|
Local API-equivalent estimates, subscription context, and official
|
|
12
16
|
provider-reported cost are separate concepts and must not be added together.
|
|
13
17
|
|
package/dist/agentInventory.d.ts
CHANGED
|
@@ -10,19 +10,22 @@
|
|
|
10
10
|
* - Skills use *progressive disclosure*: only the YAML frontmatter (`name` +
|
|
11
11
|
* `description`) is always loaded — the body loads only when invoked. So a
|
|
12
12
|
* skill's alwaysLoadedTokens reflects ONLY name + description, never the body.
|
|
13
|
-
* - MCP
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
13
|
+
* - MCP servers: configuration proves that a server is available, not that its
|
|
14
|
+
* tool schemas were loaded into a model request. Current Claude Code defers
|
|
15
|
+
* schemas by default through Tool Search. We therefore keep configured and
|
|
16
|
+
* explicitly-always-loaded states separate and never invent a token weight
|
|
17
|
+
* from config alone.
|
|
18
18
|
* - Subagents / slash commands: estimate from their description/frontmatter line
|
|
19
19
|
* only (what is surfaced in the always-loaded list), not the whole file body.
|
|
20
20
|
* - Built-in tools (Read/Edit/Bash/Glob/Grep/etc.) are EXCLUDED entirely: always
|
|
21
21
|
* loaded, not prunable, not "waste."
|
|
22
22
|
*/
|
|
23
23
|
export type InventoryKind = "skill" | "subagent" | "command" | "mcp_tool" | "mcp_server" | "hook";
|
|
24
|
-
export type InventoryActivation = "discoverable" | "
|
|
24
|
+
export type InventoryActivation = "discoverable" | "mcp_configured" | "mcp_always_loaded"
|
|
25
|
+
/** Legacy fixture/adapter value. New inventory does not emit this. */
|
|
26
|
+
| "mcp_schema_loaded" | "hook_injected" | "lifecycle_hook";
|
|
25
27
|
export type InventoryHost = "claude-code" | "codex";
|
|
28
|
+
export type InventoryScope = "user" | "local" | "project";
|
|
26
29
|
export type InventoryItem = {
|
|
27
30
|
kind: InventoryKind;
|
|
28
31
|
/**
|
|
@@ -30,7 +33,7 @@ export type InventoryItem = {
|
|
|
30
33
|
* the server id; skill/subagent/command: their declared name.
|
|
31
34
|
*/
|
|
32
35
|
name: string;
|
|
33
|
-
scope:
|
|
36
|
+
scope: InventoryScope;
|
|
34
37
|
/** e.g. mcp server name for an mcp_tool, plugin name for a plugin skill. */
|
|
35
38
|
group?: string;
|
|
36
39
|
/** How the host makes this item available to the model/runtime. */
|
|
@@ -90,13 +93,7 @@ export type AgentInventoryResult = {
|
|
|
90
93
|
};
|
|
91
94
|
/** Token estimate: Math.ceil(chars / 4). Exported so the parent reuses it. */
|
|
92
95
|
export declare function estimateTokensFromText(text: string): number;
|
|
93
|
-
/**
|
|
94
|
-
* Conservative floor for an MCP server's always-loaded token weight when its
|
|
95
|
-
* tool schemas aren't readable from config. A single tool's
|
|
96
|
-
* name+description+JSON-schema is commonly ~300–800 tokens and servers usually
|
|
97
|
-
* expose several; 700 is a deliberately low estimate, always paired with
|
|
98
|
-
* weightConfidence "estimated_understated" so we under-claim, never over-claim.
|
|
99
|
-
*/
|
|
96
|
+
/** @deprecated Config alone cannot prove that MCP schemas were loaded. */
|
|
100
97
|
export declare const MCP_SERVER_TOKEN_FLOOR = 700;
|
|
101
98
|
/** Scan this machine's (and the project's) agent inventory. Never throws. */
|
|
102
99
|
export declare function loadAgentInventory(options?: AgentInventoryOptions): Promise<AgentInventoryResult>;
|
package/dist/agentInventory.js
CHANGED
|
@@ -5,13 +5,7 @@ import { homedir } from "node:os";
|
|
|
5
5
|
export function estimateTokensFromText(text) {
|
|
6
6
|
return Math.ceil(text.length / 4);
|
|
7
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
|
-
*/
|
|
8
|
+
/** @deprecated Config alone cannot prove that MCP schemas were loaded. */
|
|
15
9
|
export const MCP_SERVER_TOKEN_FLOOR = 700;
|
|
16
10
|
/** Scan this machine's (and the project's) agent inventory. Never throws. */
|
|
17
11
|
export async function loadAgentInventory(options = {}) {
|
|
@@ -172,23 +166,28 @@ export async function loadAgentInventory(options = {}) {
|
|
|
172
166
|
});
|
|
173
167
|
}
|
|
174
168
|
}
|
|
175
|
-
// --- MCP servers
|
|
169
|
+
// --- MCP servers -------------------------------------------------------
|
|
170
|
+
// Claude Code's active MCP configuration lives in ~/.claude.json (user and
|
|
171
|
+
// local scopes) plus <project>/.mcp.json (project scope). settings.json is
|
|
172
|
+
// intentionally NOT treated as an active MCP source: legacy/stale mcpServers
|
|
173
|
+
// keys there must not turn into removal advice.
|
|
176
174
|
const serverScopes = new Map();
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
175
|
+
const claudeConfig = await readJson(configPath);
|
|
176
|
+
for (const server of collectMcpServers(claudeConfig, projectDir, options.includeAllProjectMcp ?? false)) {
|
|
177
|
+
mergeServerScope(serverScopes, {
|
|
178
|
+
...server,
|
|
179
|
+
path: configPath,
|
|
180
|
+
host: "claude-code"
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
const projectMcpPath = join(projectDir, ".mcp.json");
|
|
184
|
+
const projectMcpConfig = await readJson(projectMcpPath);
|
|
185
|
+
for (const server of collectProjectMcpServers(projectMcpConfig, projectDir)) {
|
|
186
|
+
mergeServerScope(serverScopes, {
|
|
187
|
+
...server,
|
|
188
|
+
path: projectMcpPath,
|
|
189
|
+
host: "claude-code"
|
|
190
|
+
});
|
|
192
191
|
}
|
|
193
192
|
const codexConfigPath = join(codexHome, "config.toml");
|
|
194
193
|
const codexConfigText = await readFile(codexConfigPath, "utf8").catch(() => "");
|
|
@@ -200,34 +199,100 @@ export async function loadAgentInventory(options = {}) {
|
|
|
200
199
|
scope: "user",
|
|
201
200
|
ownerDirs: [],
|
|
202
201
|
path: codexConfigPath,
|
|
203
|
-
host: "codex"
|
|
202
|
+
host: "codex",
|
|
203
|
+
alwaysLoad: false
|
|
204
204
|
});
|
|
205
205
|
}
|
|
206
206
|
}
|
|
207
|
-
for (const { id, scope, ownerDirs, path, host } of serverScopes.values()) {
|
|
207
|
+
for (const { id, scope, ownerDirs, path, host, alwaysLoad } of serverScopes.values()) {
|
|
208
208
|
scanned.mcpServers += 1;
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
|
|
209
|
+
// Claude transcripts identify an invoked MCP server by name, not by the
|
|
210
|
+
// concrete local config entry that supplied it. When same-named entries
|
|
211
|
+
// from multiple project roots were merged above, treating the aggregate as
|
|
212
|
+
// observable could produce one ambiguous action spanning several owners.
|
|
213
|
+
// Keep it visible as inventory, but exclude it from never-invoked actions.
|
|
214
|
+
const invocationTracking = host === "claude-code" && ownerDirs.length <= 1
|
|
215
|
+
? "observable"
|
|
216
|
+
: "not_observable";
|
|
215
217
|
items.push({
|
|
216
218
|
kind: "mcp_server",
|
|
217
219
|
name: id,
|
|
218
220
|
scope,
|
|
219
221
|
group: id,
|
|
220
|
-
activation: "
|
|
222
|
+
activation: alwaysLoad ? "mcp_always_loaded" : "mcp_configured",
|
|
221
223
|
host,
|
|
222
|
-
invocationTracking
|
|
223
|
-
|
|
224
|
-
|
|
224
|
+
invocationTracking,
|
|
225
|
+
// Config has no tool schema payload. Even alwaysLoad proves intent, not
|
|
226
|
+
// the connected server's runtime schema size.
|
|
227
|
+
alwaysLoadedTokens: 0,
|
|
228
|
+
weightConfidence: "unmeasured",
|
|
225
229
|
path,
|
|
226
230
|
ownerDirs
|
|
227
231
|
});
|
|
228
232
|
}
|
|
229
233
|
// --- Installed lifecycle hooks (metadata only; commands are NEVER run) ---
|
|
230
234
|
const seenHooks = new Set();
|
|
235
|
+
// Ordinary Claude settings can register hooks without an installed plugin.
|
|
236
|
+
// Read only a fixed allowlist of event and hook-type categories. Matcher,
|
|
237
|
+
// command, prompt, URL, headers, environment, and all other runtime payloads
|
|
238
|
+
// are deliberately neither copied nor surfaced in inventory metadata.
|
|
239
|
+
const seenSettingsPaths = new Set();
|
|
240
|
+
for (const source of [
|
|
241
|
+
{
|
|
242
|
+
configPath: settingsPath,
|
|
243
|
+
scope: "user",
|
|
244
|
+
sourceId: "user",
|
|
245
|
+
label: "Claude user settings"
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
configPath: join(projectClaude, "settings.json"),
|
|
249
|
+
scope: "project",
|
|
250
|
+
sourceId: "project",
|
|
251
|
+
label: "Claude project settings"
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
configPath: join(projectClaude, "settings.local.json"),
|
|
255
|
+
scope: "local",
|
|
256
|
+
sourceId: "project-local",
|
|
257
|
+
label: "Claude project-local settings"
|
|
258
|
+
}
|
|
259
|
+
]) {
|
|
260
|
+
const resolvedPath = resolve(source.configPath);
|
|
261
|
+
if (seenSettingsPaths.has(resolvedPath))
|
|
262
|
+
continue;
|
|
263
|
+
seenSettingsPaths.add(resolvedPath);
|
|
264
|
+
const settings = await readJson(source.configPath);
|
|
265
|
+
if (!isRecord(settings) || !isRecord(settings.hooks))
|
|
266
|
+
continue;
|
|
267
|
+
const events = Object.entries(settings.hooks)
|
|
268
|
+
.filter(([event, registrations]) => isKnownClaudeHookEvent(event) && hasConfiguredHookRegistration(registrations))
|
|
269
|
+
.map(([event]) => event);
|
|
270
|
+
if (events.length === 0)
|
|
271
|
+
continue;
|
|
272
|
+
scanned.hookManifests += 1;
|
|
273
|
+
for (const event of events) {
|
|
274
|
+
const key = `claude-code:settings:${source.sourceId}:${event}`;
|
|
275
|
+
if (seenHooks.has(key))
|
|
276
|
+
continue;
|
|
277
|
+
seenHooks.add(key);
|
|
278
|
+
scanned.hooks += 1;
|
|
279
|
+
items.push({
|
|
280
|
+
kind: "hook",
|
|
281
|
+
name: `claude-settings:${source.sourceId}:${event}`,
|
|
282
|
+
scope: source.scope,
|
|
283
|
+
group: source.label,
|
|
284
|
+
activation: contextInjectingEvent(event) ? "hook_injected" : "lifecycle_hook",
|
|
285
|
+
host: "claude-code",
|
|
286
|
+
event,
|
|
287
|
+
invocationTracking: "not_observable",
|
|
288
|
+
alwaysLoadedTokens: 0,
|
|
289
|
+
weightConfidence: "unmeasured",
|
|
290
|
+
// contextHealth surfaces this field as provenance. Use a fixed label,
|
|
291
|
+
// never an absolute user/project path.
|
|
292
|
+
path: source.label
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
}
|
|
231
296
|
for (const pluginRoot of pluginRoots) {
|
|
232
297
|
for (const manifestPath of await pluginManifestPaths(pluginRoot.root)) {
|
|
233
298
|
const manifest = await readJson(manifestPath);
|
|
@@ -422,6 +487,50 @@ function contextInjectingEvent(event) {
|
|
|
422
487
|
event === "UserPromptSubmit" ||
|
|
423
488
|
event === "SubagentStart";
|
|
424
489
|
}
|
|
490
|
+
// Claude rejects unknown event names, and accepting arbitrary object keys here
|
|
491
|
+
// would let instruction-like config keys flow into MCP/context-health output.
|
|
492
|
+
// Keep this list explicit and update it alongside supported Claude hook events.
|
|
493
|
+
const KNOWN_CLAUDE_HOOK_EVENTS = new Set([
|
|
494
|
+
"PreToolUse",
|
|
495
|
+
"PermissionRequest",
|
|
496
|
+
"PostToolUse",
|
|
497
|
+
"PostToolUseFailure",
|
|
498
|
+
"Notification",
|
|
499
|
+
"UserPromptSubmit",
|
|
500
|
+
"SessionStart",
|
|
501
|
+
"SessionEnd",
|
|
502
|
+
"Stop",
|
|
503
|
+
"SubagentStart",
|
|
504
|
+
"SubagentStop",
|
|
505
|
+
"PreCompact",
|
|
506
|
+
"Setup",
|
|
507
|
+
"TeammateIdle",
|
|
508
|
+
"TaskCompleted",
|
|
509
|
+
"ConfigChange",
|
|
510
|
+
"WorktreeCreate",
|
|
511
|
+
"WorktreeRemove",
|
|
512
|
+
"InstructionsLoaded",
|
|
513
|
+
"Elicitation",
|
|
514
|
+
"ElicitationResult"
|
|
515
|
+
]);
|
|
516
|
+
const KNOWN_CLAUDE_HOOK_TYPES = new Set([
|
|
517
|
+
"command",
|
|
518
|
+
"prompt",
|
|
519
|
+
"agent",
|
|
520
|
+
"http"
|
|
521
|
+
]);
|
|
522
|
+
function isKnownClaudeHookEvent(event) {
|
|
523
|
+
return KNOWN_CLAUDE_HOOK_EVENTS.has(event);
|
|
524
|
+
}
|
|
525
|
+
function hasConfiguredHookRegistration(registrations) {
|
|
526
|
+
if (!Array.isArray(registrations))
|
|
527
|
+
return false;
|
|
528
|
+
return registrations.some((registration) => isRecord(registration) &&
|
|
529
|
+
Array.isArray(registration.hooks) &&
|
|
530
|
+
registration.hooks.some((hook) => isRecord(hook) &&
|
|
531
|
+
typeof hook.type === "string" &&
|
|
532
|
+
KNOWN_CLAUDE_HOOK_TYPES.has(hook.type)));
|
|
533
|
+
}
|
|
425
534
|
function isWithinRoot(path, root) {
|
|
426
535
|
const resolvedPath = resolve(path);
|
|
427
536
|
const resolvedRoot = resolve(root);
|
|
@@ -439,25 +548,27 @@ function collectMcpServers(config, projectDir, includeAllProjectMcp) {
|
|
|
439
548
|
if (!isRecord(config))
|
|
440
549
|
return [];
|
|
441
550
|
const byKey = new Map();
|
|
442
|
-
const add = (id, scope, ownerDir) => {
|
|
443
|
-
//
|
|
444
|
-
//
|
|
445
|
-
|
|
446
|
-
const
|
|
551
|
+
const add = (id, value, scope, ownerDir) => {
|
|
552
|
+
// Keep user and local configurations separate. A same-named user server and
|
|
553
|
+
// local override have different ownership, precedence, and rollback.
|
|
554
|
+
const key = `${scope}:${id}`;
|
|
555
|
+
const alwaysLoad = isRecord(value) && value.alwaysLoad === true;
|
|
447
556
|
const existing = byKey.get(key);
|
|
448
557
|
if (existing) {
|
|
449
558
|
if (ownerDir && !existing.ownerDirs.includes(ownerDir))
|
|
450
559
|
existing.ownerDirs.push(ownerDir);
|
|
560
|
+
existing.alwaysLoad ||= alwaysLoad;
|
|
451
561
|
return;
|
|
452
562
|
}
|
|
453
|
-
byKey.set(key, { id, scope, ownerDirs: ownerDir ? [ownerDir] : [] });
|
|
563
|
+
byKey.set(key, { id, scope, ownerDirs: ownerDir ? [ownerDir] : [], alwaysLoad });
|
|
454
564
|
};
|
|
455
565
|
// Top-level mcpServers are user-scope (global).
|
|
456
566
|
if (isRecord(config.mcpServers)) {
|
|
457
|
-
for (const id of Object.
|
|
458
|
-
add(id, "user");
|
|
567
|
+
for (const [id, value] of Object.entries(config.mcpServers))
|
|
568
|
+
add(id, value, "user");
|
|
459
569
|
}
|
|
460
|
-
// Per-project
|
|
570
|
+
// Per-project entries in ~/.claude.json are Claude's LOCAL scope (private to
|
|
571
|
+
// the user and active only from that working directory), not project scope.
|
|
461
572
|
// Global view: collect every project's servers; otherwise just this project's.
|
|
462
573
|
if (isRecord(config.projects)) {
|
|
463
574
|
const entries = includeAllProjectMcp
|
|
@@ -465,13 +576,36 @@ function collectMcpServers(config, projectDir, includeAllProjectMcp) {
|
|
|
465
576
|
: [[projectDir, config.projects[projectDir]]];
|
|
466
577
|
for (const [dir, projectEntry] of entries) {
|
|
467
578
|
if (isRecord(projectEntry) && isRecord(projectEntry.mcpServers)) {
|
|
468
|
-
for (const id of Object.
|
|
469
|
-
add(id, "
|
|
579
|
+
for (const [id, value] of Object.entries(projectEntry.mcpServers))
|
|
580
|
+
add(id, value, "local", dir);
|
|
470
581
|
}
|
|
471
582
|
}
|
|
472
583
|
}
|
|
473
584
|
return [...byKey.values()];
|
|
474
585
|
}
|
|
586
|
+
function collectProjectMcpServers(config, projectDir) {
|
|
587
|
+
if (!isRecord(config) || !isRecord(config.mcpServers))
|
|
588
|
+
return [];
|
|
589
|
+
return Object.entries(config.mcpServers).map(([id, value]) => ({
|
|
590
|
+
id,
|
|
591
|
+
scope: "project",
|
|
592
|
+
ownerDirs: [projectDir],
|
|
593
|
+
alwaysLoad: isRecord(value) && value.alwaysLoad === true
|
|
594
|
+
}));
|
|
595
|
+
}
|
|
596
|
+
function mergeServerScope(target, server) {
|
|
597
|
+
const key = `${server.host}:${server.scope}:${server.id}`;
|
|
598
|
+
const prior = target.get(key);
|
|
599
|
+
if (!prior) {
|
|
600
|
+
target.set(key, server);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
for (const ownerDir of server.ownerDirs) {
|
|
604
|
+
if (!prior.ownerDirs.includes(ownerDir))
|
|
605
|
+
prior.ownerDirs.push(ownerDir);
|
|
606
|
+
}
|
|
607
|
+
prior.alwaysLoad ||= server.alwaysLoad;
|
|
608
|
+
}
|
|
475
609
|
/**
|
|
476
610
|
* Extract `name` and `description` from a leading `---` fenced YAML block.
|
|
477
611
|
* Handles quoted values and folded/multi-line descriptions (continuation lines
|