@agent-finops/core 0.5.4 → 0.5.6
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/LICENSE +21 -0
- package/README.md +16 -0
- package/dist/agentInventory.d.ts +35 -9
- package/dist/agentInventory.js +309 -11
- package/dist/analyze.js +6 -2
- package/dist/contextHealth.d.ts +101 -0
- package/dist/contextHealth.js +371 -0
- package/dist/deadContext.js +10 -1
- package/dist/discovery.d.ts +14 -1
- package/dist/discovery.js +54 -28
- package/dist/glance.d.ts +161 -0
- package/dist/glance.js +586 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/localAgentLogs.d.ts +42 -2
- package/dist/localAgentLogs.js +460 -7
- package/dist/modelPricing.d.ts +2 -1
- package/dist/modelPricing.js +22 -3
- package/dist/planMath.d.ts +12 -14
- package/dist/planMath.js +18 -18
- package/dist/providerConnectors.d.ts +19 -1
- package/dist/providerConnectors.js +106 -26
- package/dist/scanGuard.d.ts +35 -0
- package/dist/scanGuard.js +196 -8
- package/dist/schema.d.ts +24 -24
- package/dist/sourceRegistry.js +1 -1
- package/dist/toolInvocations.d.ts +42 -1
- package/dist/toolInvocations.js +244 -4
- package/package.json +15 -2
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Futura Studio LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# @agent-finops/core
|
|
2
|
+
|
|
3
|
+
The canonical local-first data and decision engine for
|
|
4
|
+
[aibill](https://github.com/futurastudio/ai-spend-agent): Claude Code/Codex
|
|
5
|
+
activity ingestion, provider cost semantics, attribution, provenance, runway,
|
|
6
|
+
Context Health, and the shared Glance contract.
|
|
7
|
+
|
|
8
|
+
Most users should run `npx aibill`. This package is for integrations that
|
|
9
|
+
need the same evidence-labeled calculations as the CLI and MCP server.
|
|
10
|
+
|
|
11
|
+
Local API-equivalent estimates, subscription context, and official
|
|
12
|
+
provider-reported cost are separate concepts and must not be added together.
|
|
13
|
+
|
|
14
|
+
MIT licensed. See the repository
|
|
15
|
+
[README](https://github.com/futurastudio/ai-spend-agent#readme) and
|
|
16
|
+
[public roadmap](https://github.com/futurastudio/ai-spend-agent/blob/main/ROADMAP.md).
|
package/dist/agentInventory.d.ts
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Agent context inventory: enumerate
|
|
3
|
-
*
|
|
4
|
-
* MCP servers + their tools) and estimate how many tokens each item adds.
|
|
2
|
+
* Agent context inventory: enumerate configured Claude Code and Codex skills,
|
|
3
|
+
* subagents, slash commands, MCP servers, and installed lifecycle hooks.
|
|
5
4
|
*
|
|
6
5
|
* This feeds a later "dead-context cost" feature that prices loaded-but-never-
|
|
7
|
-
* invoked tools. Every read here is read-only
|
|
6
|
+
* invoked tools. Every read here is read-only, hook commands are never run,
|
|
7
|
+
* and missing dirs/files never throw.
|
|
8
8
|
*
|
|
9
9
|
* CRITICAL token-weight rules (these drive the honesty of the final $ number):
|
|
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
13
|
* - MCP tools: the FULL tool definition (name + description + JSON input schema)
|
|
14
|
-
* is always loaded — the heavy weight.
|
|
15
|
-
*
|
|
14
|
+
* is always loaded — the heavy weight. Local host config almost never carries
|
|
15
|
+
* tool schemas, so MCP enumeration is usually limited to server names;
|
|
16
16
|
* those items are flagged "estimated_understated" because the real weight is
|
|
17
17
|
* larger than what we can see.
|
|
18
18
|
* - Subagents / slash commands: estimate from their description/frontmatter line
|
|
@@ -20,7 +20,9 @@
|
|
|
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
|
-
export type InventoryKind = "skill" | "subagent" | "command" | "mcp_tool" | "mcp_server";
|
|
23
|
+
export type InventoryKind = "skill" | "subagent" | "command" | "mcp_tool" | "mcp_server" | "hook";
|
|
24
|
+
export type InventoryActivation = "discoverable" | "mcp_schema_loaded" | "hook_injected" | "lifecycle_hook";
|
|
25
|
+
export type InventoryHost = "claude-code" | "codex";
|
|
24
26
|
export type InventoryItem = {
|
|
25
27
|
kind: InventoryKind;
|
|
26
28
|
/**
|
|
@@ -31,9 +33,17 @@ export type InventoryItem = {
|
|
|
31
33
|
scope: "user" | "project";
|
|
32
34
|
/** e.g. mcp server name for an mcp_tool, plugin name for a plugin skill. */
|
|
33
35
|
group?: string;
|
|
36
|
+
/** How the host makes this item available to the model/runtime. */
|
|
37
|
+
activation: InventoryActivation;
|
|
38
|
+
/** Host that owns an installed lifecycle hook. */
|
|
39
|
+
host?: InventoryHost;
|
|
40
|
+
/** Lifecycle event for hook items, for example SessionStart. */
|
|
41
|
+
event?: string;
|
|
42
|
+
/** Whether local transcripts can prove this item was explicitly invoked. */
|
|
43
|
+
invocationTracking: "observable" | "not_observable";
|
|
34
44
|
alwaysLoadedTokens: number;
|
|
35
|
-
/** "
|
|
36
|
-
weightConfidence: "estimated" | "estimated_understated";
|
|
45
|
+
/** "unmeasured" means config proves activation but not runtime payload size. */
|
|
46
|
+
weightConfidence: "estimated" | "estimated_understated" | "unmeasured";
|
|
37
47
|
path?: string;
|
|
38
48
|
/** For project-scoped MCP servers: the project dirs that load this server. */
|
|
39
49
|
ownerDirs?: string[];
|
|
@@ -43,8 +53,22 @@ export type AgentInventoryOptions = {
|
|
|
43
53
|
claudeHomeDir?: string;
|
|
44
54
|
/** Default: ~/.claude.json */
|
|
45
55
|
claudeConfigPath?: string;
|
|
56
|
+
/** Default: <claudeHomeDir>/settings.json */
|
|
57
|
+
claudeSettingsPath?: string;
|
|
46
58
|
/** Default: process.cwd(); scans <projectDir>/.claude/**. */
|
|
47
59
|
projectDir?: string;
|
|
60
|
+
/** Default: ~/.codex. Used to locate enabled Codex plugins. */
|
|
61
|
+
codexHomeDir?: string;
|
|
62
|
+
/**
|
|
63
|
+
* Explicit installed plugin roots. Primarily useful for deterministic audits
|
|
64
|
+
* and tests; default discovery reads Claude's installed_plugins.json and
|
|
65
|
+
* Codex's enabled plugin list.
|
|
66
|
+
*/
|
|
67
|
+
pluginRoots?: Array<{
|
|
68
|
+
root: string;
|
|
69
|
+
host: InventoryHost;
|
|
70
|
+
scope?: "user" | "project";
|
|
71
|
+
}>;
|
|
48
72
|
/**
|
|
49
73
|
* Include MCP servers from EVERY project in the config (not just projectDir).
|
|
50
74
|
* Used for the global "across your whole setup" dead-context view so the
|
|
@@ -60,6 +84,8 @@ export type AgentInventoryResult = {
|
|
|
60
84
|
commands: number;
|
|
61
85
|
mcpServers: number;
|
|
62
86
|
mcpTools: number;
|
|
87
|
+
hooks: number;
|
|
88
|
+
hookManifests: number;
|
|
63
89
|
};
|
|
64
90
|
};
|
|
65
91
|
/** Token estimate: Math.ceil(chars / 4). Exported so the parent reuses it. */
|
package/dist/agentInventory.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
|
-
import { basename, join } from "node:path";
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
/** Token estimate: Math.ceil(chars / 4). Exported so the parent reuses it. */
|
|
5
5
|
export function estimateTokensFromText(text) {
|
|
@@ -17,17 +17,64 @@ export const MCP_SERVER_TOKEN_FLOOR = 700;
|
|
|
17
17
|
export async function loadAgentInventory(options = {}) {
|
|
18
18
|
const home = homedir();
|
|
19
19
|
const claudeHome = options.claudeHomeDir ?? join(home, ".claude");
|
|
20
|
+
const codexHome = options.codexHomeDir ?? join(home, ".codex");
|
|
20
21
|
const configPath = options.claudeConfigPath ?? join(home, ".claude.json");
|
|
22
|
+
const settingsPath = options.claudeSettingsPath ?? join(claudeHome, "settings.json");
|
|
21
23
|
const projectDir = options.projectDir ?? process.cwd();
|
|
22
24
|
const projectClaude = join(projectDir, ".claude");
|
|
25
|
+
const projectClaudeRoots = resolve(projectClaude) === resolve(claudeHome)
|
|
26
|
+
? []
|
|
27
|
+
: [{ dir: projectClaude, scope: "project" }];
|
|
23
28
|
const items = [];
|
|
24
|
-
const
|
|
29
|
+
const pluginRoots = options.pluginRoots ?? await configuredPluginRoots(claudeHome, codexHome);
|
|
30
|
+
const seenSkills = new Set();
|
|
31
|
+
const scanned = {
|
|
32
|
+
skills: 0,
|
|
33
|
+
subagents: 0,
|
|
34
|
+
commands: 0,
|
|
35
|
+
mcpServers: 0,
|
|
36
|
+
mcpTools: 0,
|
|
37
|
+
hooks: 0,
|
|
38
|
+
hookManifests: 0
|
|
39
|
+
};
|
|
25
40
|
// --- Skills (user + project) ---
|
|
26
|
-
for (const { dir, scope } of [
|
|
27
|
-
{
|
|
28
|
-
|
|
41
|
+
for (const { dir, scope, host, invocationTracking } of [
|
|
42
|
+
{
|
|
43
|
+
dir: join(claudeHome, "skills"),
|
|
44
|
+
scope: "user",
|
|
45
|
+
host: "claude-code",
|
|
46
|
+
invocationTracking: "observable"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
dir: join(codexHome, "skills"),
|
|
50
|
+
scope: "user",
|
|
51
|
+
host: "codex",
|
|
52
|
+
invocationTracking: "not_observable"
|
|
53
|
+
},
|
|
54
|
+
...projectClaudeRoots.map((entry) => ({
|
|
55
|
+
dir: join(entry.dir, "skills"),
|
|
56
|
+
scope: entry.scope,
|
|
57
|
+
host: "claude-code",
|
|
58
|
+
invocationTracking: "observable"
|
|
59
|
+
})),
|
|
60
|
+
{
|
|
61
|
+
dir: join(projectDir, ".agents", "skills"),
|
|
62
|
+
scope: "project",
|
|
63
|
+
host: "codex",
|
|
64
|
+
invocationTracking: "not_observable"
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
dir: join(projectDir, ".codex", "skills"),
|
|
68
|
+
scope: "project",
|
|
69
|
+
host: "codex",
|
|
70
|
+
invocationTracking: "not_observable"
|
|
71
|
+
}
|
|
29
72
|
]) {
|
|
30
73
|
for (const file of await findFiles(dir, (name) => name === "SKILL.md")) {
|
|
74
|
+
const skillKey = `${host}:${resolve(file)}`;
|
|
75
|
+
if (seenSkills.has(skillKey))
|
|
76
|
+
continue;
|
|
77
|
+
seenSkills.add(skillKey);
|
|
31
78
|
const content = await readFile(file, "utf8").catch(() => "");
|
|
32
79
|
if (!content)
|
|
33
80
|
continue;
|
|
@@ -46,6 +93,9 @@ export async function loadAgentInventory(options = {}) {
|
|
|
46
93
|
name,
|
|
47
94
|
scope,
|
|
48
95
|
group: pluginGroupFromPath(file, dir),
|
|
96
|
+
activation: "discoverable",
|
|
97
|
+
host,
|
|
98
|
+
invocationTracking,
|
|
49
99
|
alwaysLoadedTokens: estimateTokensFromText(loadedText),
|
|
50
100
|
weightConfidence: "estimated",
|
|
51
101
|
path: file
|
|
@@ -55,7 +105,10 @@ export async function loadAgentInventory(options = {}) {
|
|
|
55
105
|
// --- Subagents (user + project) ---
|
|
56
106
|
for (const { dir, scope } of [
|
|
57
107
|
{ dir: join(claudeHome, "agents"), scope: "user" },
|
|
58
|
-
|
|
108
|
+
...projectClaudeRoots.map((entry) => ({
|
|
109
|
+
dir: join(entry.dir, "agents"),
|
|
110
|
+
scope: entry.scope
|
|
111
|
+
}))
|
|
59
112
|
]) {
|
|
60
113
|
for (const file of await findFiles(dir, (name) => name.endsWith(".md"))) {
|
|
61
114
|
const content = await readFile(file, "utf8").catch(() => "");
|
|
@@ -75,6 +128,9 @@ export async function loadAgentInventory(options = {}) {
|
|
|
75
128
|
kind: "subagent",
|
|
76
129
|
name,
|
|
77
130
|
scope,
|
|
131
|
+
activation: "discoverable",
|
|
132
|
+
host: "claude-code",
|
|
133
|
+
invocationTracking: "observable",
|
|
78
134
|
alwaysLoadedTokens: estimateTokensFromText(loadedText),
|
|
79
135
|
weightConfidence: "estimated",
|
|
80
136
|
path: file
|
|
@@ -84,7 +140,10 @@ export async function loadAgentInventory(options = {}) {
|
|
|
84
140
|
// --- Slash commands (user + project) ---
|
|
85
141
|
for (const { dir, scope } of [
|
|
86
142
|
{ dir: join(claudeHome, "commands"), scope: "user" },
|
|
87
|
-
|
|
143
|
+
...projectClaudeRoots.map((entry) => ({
|
|
144
|
+
dir: join(entry.dir, "commands"),
|
|
145
|
+
scope: entry.scope
|
|
146
|
+
}))
|
|
88
147
|
]) {
|
|
89
148
|
for (const file of await findFiles(dir, (name) => name.endsWith(".md"))) {
|
|
90
149
|
const content = await readFile(file, "utf8").catch(() => "");
|
|
@@ -104,6 +163,9 @@ export async function loadAgentInventory(options = {}) {
|
|
|
104
163
|
kind: "command",
|
|
105
164
|
name,
|
|
106
165
|
scope,
|
|
166
|
+
activation: "discoverable",
|
|
167
|
+
host: "claude-code",
|
|
168
|
+
invocationTracking: "observable",
|
|
107
169
|
alwaysLoadedTokens: estimateTokensFromText(loadedText),
|
|
108
170
|
weightConfidence: "estimated",
|
|
109
171
|
path: file
|
|
@@ -111,9 +173,38 @@ export async function loadAgentInventory(options = {}) {
|
|
|
111
173
|
}
|
|
112
174
|
}
|
|
113
175
|
// --- MCP servers (from ~/.claude.json: top-level + per-project map) ---
|
|
114
|
-
const
|
|
115
|
-
const
|
|
116
|
-
|
|
176
|
+
const serverScopes = new Map();
|
|
177
|
+
for (const sourcePath of [...new Set([configPath, settingsPath])]) {
|
|
178
|
+
const config = await readJson(sourcePath);
|
|
179
|
+
for (const server of collectMcpServers(config, projectDir, options.includeAllProjectMcp ?? false)) {
|
|
180
|
+
const key = `claude-code:${server.scope}:${server.id}`;
|
|
181
|
+
const prior = serverScopes.get(key);
|
|
182
|
+
if (prior) {
|
|
183
|
+
for (const ownerDir of server.ownerDirs) {
|
|
184
|
+
if (!prior.ownerDirs.includes(ownerDir))
|
|
185
|
+
prior.ownerDirs.push(ownerDir);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
serverScopes.set(key, { ...server, path: sourcePath, host: "claude-code" });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const codexConfigPath = join(codexHome, "config.toml");
|
|
194
|
+
const codexConfigText = await readFile(codexConfigPath, "utf8").catch(() => "");
|
|
195
|
+
for (const id of enabledCodexMcpServerNames(codexConfigText)) {
|
|
196
|
+
const key = `codex:user:${id}`;
|
|
197
|
+
if (!serverScopes.has(key)) {
|
|
198
|
+
serverScopes.set(key, {
|
|
199
|
+
id,
|
|
200
|
+
scope: "user",
|
|
201
|
+
ownerDirs: [],
|
|
202
|
+
path: codexConfigPath,
|
|
203
|
+
host: "codex"
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
for (const { id, scope, ownerDirs, path, host } of serverScopes.values()) {
|
|
117
208
|
scanned.mcpServers += 1;
|
|
118
209
|
// We almost never have tool schemas from config, so we can't measure the
|
|
119
210
|
// real weight (full tool definitions). Use a conservative published-typical
|
|
@@ -126,14 +217,221 @@ export async function loadAgentInventory(options = {}) {
|
|
|
126
217
|
name: id,
|
|
127
218
|
scope,
|
|
128
219
|
group: id,
|
|
220
|
+
activation: "mcp_schema_loaded",
|
|
221
|
+
host,
|
|
222
|
+
invocationTracking: host === "claude-code" ? "observable" : "not_observable",
|
|
129
223
|
alwaysLoadedTokens: MCP_SERVER_TOKEN_FLOOR,
|
|
130
224
|
weightConfidence: "estimated_understated",
|
|
131
|
-
path
|
|
225
|
+
path,
|
|
132
226
|
ownerDirs
|
|
133
227
|
});
|
|
134
228
|
}
|
|
229
|
+
// --- Installed lifecycle hooks (metadata only; commands are NEVER run) ---
|
|
230
|
+
const seenHooks = new Set();
|
|
231
|
+
for (const pluginRoot of pluginRoots) {
|
|
232
|
+
for (const manifestPath of await pluginManifestPaths(pluginRoot.root)) {
|
|
233
|
+
const manifest = await readJson(manifestPath);
|
|
234
|
+
if (!isRecord(manifest))
|
|
235
|
+
continue;
|
|
236
|
+
const pluginName = stringValue(manifest.name) ?? basename(dirname(dirname(manifestPath)));
|
|
237
|
+
const skillsPath = stringValue(manifest.skills)
|
|
238
|
+
? resolve(dirname(dirname(manifestPath)), stringValue(manifest.skills))
|
|
239
|
+
: join(pluginRoot.root, "skills");
|
|
240
|
+
if (isWithinRoot(skillsPath, pluginRoot.root)) {
|
|
241
|
+
for (const file of await findFiles(skillsPath, (name) => name === "SKILL.md")) {
|
|
242
|
+
const skillKey = `${pluginRoot.host}:${resolve(file)}`;
|
|
243
|
+
if (seenSkills.has(skillKey))
|
|
244
|
+
continue;
|
|
245
|
+
seenSkills.add(skillKey);
|
|
246
|
+
const content = await readFile(file, "utf8").catch(() => "");
|
|
247
|
+
if (!content)
|
|
248
|
+
continue;
|
|
249
|
+
const fm = parseFrontmatter(content);
|
|
250
|
+
const name = fm.name ?? skillNameFromPath(file, skillsPath);
|
|
251
|
+
const loadedText = [
|
|
252
|
+
`name: ${name}`,
|
|
253
|
+
fm.description ? `description: ${fm.description}` : ""
|
|
254
|
+
].filter(Boolean).join("\n");
|
|
255
|
+
scanned.skills += 1;
|
|
256
|
+
items.push({
|
|
257
|
+
kind: "skill",
|
|
258
|
+
name,
|
|
259
|
+
scope: pluginRoot.scope ?? "user",
|
|
260
|
+
group: pluginName,
|
|
261
|
+
activation: "discoverable",
|
|
262
|
+
host: pluginRoot.host,
|
|
263
|
+
invocationTracking: pluginRoot.host === "claude-code"
|
|
264
|
+
? "observable"
|
|
265
|
+
: "not_observable",
|
|
266
|
+
alwaysLoadedTokens: estimateTokensFromText(loadedText),
|
|
267
|
+
weightConfidence: "estimated",
|
|
268
|
+
path: file
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const configuredHookPath = stringValue(manifest.hooks);
|
|
273
|
+
const hookPath = configuredHookPath
|
|
274
|
+
? resolve(dirname(dirname(manifestPath)), configuredHookPath)
|
|
275
|
+
: join(dirname(dirname(manifestPath)), "hooks", "hooks.json");
|
|
276
|
+
if (!isWithinRoot(hookPath, pluginRoot.root))
|
|
277
|
+
continue;
|
|
278
|
+
const hookConfig = await readJson(hookPath);
|
|
279
|
+
if (!isRecord(hookConfig) || !isRecord(hookConfig.hooks))
|
|
280
|
+
continue;
|
|
281
|
+
scanned.hookManifests += 1;
|
|
282
|
+
for (const [event, registrations] of Object.entries(hookConfig.hooks)) {
|
|
283
|
+
if (!Array.isArray(registrations) || registrations.length === 0)
|
|
284
|
+
continue;
|
|
285
|
+
const activation = contextInjectingEvent(event)
|
|
286
|
+
? "hook_injected"
|
|
287
|
+
: "lifecycle_hook";
|
|
288
|
+
const key = `${pluginRoot.host}:${pluginName}:${event}:${hookPath}`;
|
|
289
|
+
if (seenHooks.has(key))
|
|
290
|
+
continue;
|
|
291
|
+
seenHooks.add(key);
|
|
292
|
+
scanned.hooks += 1;
|
|
293
|
+
items.push({
|
|
294
|
+
kind: "hook",
|
|
295
|
+
name: `${pluginName}:${event}`,
|
|
296
|
+
scope: pluginRoot.scope ?? "user",
|
|
297
|
+
group: pluginName,
|
|
298
|
+
activation,
|
|
299
|
+
host: pluginRoot.host,
|
|
300
|
+
event,
|
|
301
|
+
invocationTracking: "not_observable",
|
|
302
|
+
// Hook config proves the event exists, not what its command emits at
|
|
303
|
+
// runtime. Assigning tokens or dollars here would be fabricated.
|
|
304
|
+
alwaysLoadedTokens: 0,
|
|
305
|
+
weightConfidence: "unmeasured",
|
|
306
|
+
path: hookPath
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
135
311
|
return { items, scanned };
|
|
136
312
|
}
|
|
313
|
+
function enabledCodexMcpServerNames(configText) {
|
|
314
|
+
const servers = new Map();
|
|
315
|
+
let current;
|
|
316
|
+
for (const rawLine of configText.split(/\r?\n/)) {
|
|
317
|
+
const line = rawLine.trim();
|
|
318
|
+
const section = /^\[mcp_servers\.(?:"([^"]+)"|([A-Za-z0-9_-]+))\]\s*$/.exec(line);
|
|
319
|
+
if (section) {
|
|
320
|
+
current = section[1] ?? section[2];
|
|
321
|
+
if (current)
|
|
322
|
+
servers.set(current, true);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (/^\[/.test(line)) {
|
|
326
|
+
current = undefined;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (current && /^enabled\s*=\s*false\s*(?:#.*)?$/.test(line)) {
|
|
330
|
+
servers.set(current, false);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return [...servers.entries()]
|
|
334
|
+
.filter(([, enabled]) => enabled)
|
|
335
|
+
.map(([name]) => name)
|
|
336
|
+
.sort();
|
|
337
|
+
}
|
|
338
|
+
async function configuredPluginRoots(claudeHome, codexHome) {
|
|
339
|
+
const roots = [];
|
|
340
|
+
// Claude records actual install paths. Marketplace checkouts alone are not
|
|
341
|
+
// treated as active, avoiding a false positive for every available plugin.
|
|
342
|
+
const installed = await readJson(join(claudeHome, "plugins", "installed_plugins.json"));
|
|
343
|
+
for (const installPath of collectStringFields(installed, "installPath")) {
|
|
344
|
+
roots.push({ root: installPath, host: "claude-code", scope: "user" });
|
|
345
|
+
}
|
|
346
|
+
// Codex records enabled plugin ids in config.toml. Match those ids to cached
|
|
347
|
+
// manifests by declared plugin name; disabled/cache-only plugins stay out.
|
|
348
|
+
const configText = await readFile(join(codexHome, "config.toml"), "utf8").catch(() => "");
|
|
349
|
+
const enabledNames = enabledCodexPluginNames(configText);
|
|
350
|
+
if (enabledNames.size > 0) {
|
|
351
|
+
for (const manifestPath of await findFiles(join(codexHome, "plugins", "cache"), (name) => name === "plugin.json")) {
|
|
352
|
+
if (!/\/\.codex-plugin\/plugin\.json$/.test(manifestPath.replace(/\\/g, "/")))
|
|
353
|
+
continue;
|
|
354
|
+
const manifest = await readJson(manifestPath);
|
|
355
|
+
const name = isRecord(manifest) ? stringValue(manifest.name) : undefined;
|
|
356
|
+
if (name && enabledNames.has(name)) {
|
|
357
|
+
roots.push({
|
|
358
|
+
root: dirname(dirname(manifestPath)),
|
|
359
|
+
host: "codex",
|
|
360
|
+
scope: "user"
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return dedupePluginRoots(roots);
|
|
366
|
+
}
|
|
367
|
+
function enabledCodexPluginNames(configText) {
|
|
368
|
+
const names = new Set();
|
|
369
|
+
let current;
|
|
370
|
+
for (const line of configText.split(/\r?\n/)) {
|
|
371
|
+
const section = /^\[plugins\."([^"]+)"\]\s*$/.exec(line.trim());
|
|
372
|
+
if (section) {
|
|
373
|
+
current = section[1]?.split("@")[0];
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
if (/^\[/.test(line.trim())) {
|
|
377
|
+
current = undefined;
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
if (current && /^enabled\s*=\s*true\s*(?:#.*)?$/.test(line.trim())) {
|
|
381
|
+
names.add(current);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return names;
|
|
385
|
+
}
|
|
386
|
+
function collectStringFields(value, field) {
|
|
387
|
+
if (Array.isArray(value)) {
|
|
388
|
+
return value.flatMap((entry) => collectStringFields(entry, field));
|
|
389
|
+
}
|
|
390
|
+
if (!isRecord(value))
|
|
391
|
+
return [];
|
|
392
|
+
const direct = stringValue(value[field]);
|
|
393
|
+
return [
|
|
394
|
+
...(direct ? [direct] : []),
|
|
395
|
+
...Object.values(value).flatMap((entry) => collectStringFields(entry, field))
|
|
396
|
+
];
|
|
397
|
+
}
|
|
398
|
+
function dedupePluginRoots(roots) {
|
|
399
|
+
const seen = new Set();
|
|
400
|
+
return roots.filter((entry) => {
|
|
401
|
+
const key = `${entry.host}:${resolve(entry.root)}`;
|
|
402
|
+
if (seen.has(key))
|
|
403
|
+
return false;
|
|
404
|
+
seen.add(key);
|
|
405
|
+
return true;
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
async function pluginManifestPaths(root) {
|
|
409
|
+
const directCodex = join(root, ".codex-plugin", "plugin.json");
|
|
410
|
+
const directClaude = join(root, ".claude-plugin", "plugin.json");
|
|
411
|
+
const out = [];
|
|
412
|
+
if (await stat(directCodex).then((value) => value.isFile()).catch(() => false)) {
|
|
413
|
+
out.push(directCodex);
|
|
414
|
+
}
|
|
415
|
+
if (await stat(directClaude).then((value) => value.isFile()).catch(() => false)) {
|
|
416
|
+
out.push(directClaude);
|
|
417
|
+
}
|
|
418
|
+
return out;
|
|
419
|
+
}
|
|
420
|
+
function contextInjectingEvent(event) {
|
|
421
|
+
return event === "SessionStart" ||
|
|
422
|
+
event === "UserPromptSubmit" ||
|
|
423
|
+
event === "SubagentStart";
|
|
424
|
+
}
|
|
425
|
+
function isWithinRoot(path, root) {
|
|
426
|
+
const resolvedPath = resolve(path);
|
|
427
|
+
const resolvedRoot = resolve(root);
|
|
428
|
+
return resolvedPath === resolvedRoot || resolvedPath.startsWith(`${resolvedRoot}/`);
|
|
429
|
+
}
|
|
430
|
+
function stringValue(value) {
|
|
431
|
+
return typeof value === "string" && value.trim().length > 0
|
|
432
|
+
? value.trim()
|
|
433
|
+
: undefined;
|
|
434
|
+
}
|
|
137
435
|
// --------------------------------------------------------------------------
|
|
138
436
|
// MCP config extraction
|
|
139
437
|
// --------------------------------------------------------------------------
|
package/dist/analyze.js
CHANGED
|
@@ -107,8 +107,12 @@ export function generateWorkflowWatch(records) {
|
|
|
107
107
|
return [...groups.entries()]
|
|
108
108
|
.map(([key, groupRecords]) => {
|
|
109
109
|
const [clientId, projectId, workflowKey, agentId] = key.split("::");
|
|
110
|
-
const
|
|
111
|
-
const
|
|
110
|
+
const rawAmountUsd = sumRecords(groupRecords);
|
|
111
|
+
const amountUsd = roundMoney(rawAmountUsd);
|
|
112
|
+
// Compute the ratio from unrounded amounts. A tiny single-record total
|
|
113
|
+
// such as $0.0075 rounds to $0.01 for display; dividing that rounded
|
|
114
|
+
// value by the raw total produced 1.3333 and failed the [0, 1] schema.
|
|
115
|
+
const shareOfSpend = roundRatio(Math.min(1, rawAmountUsd / totalUsd));
|
|
112
116
|
const estimatedSavingsUsd = roundMoney(amountUsd * impactRatios.workflowSavings);
|
|
113
117
|
const estimatedMarginRiskUsd = roundMoney(amountUsd * impactRatios.workflowMarginRisk);
|
|
114
118
|
const confidence = combinedConfidence(groupRecords.map((record) => record.costConfidence));
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { type AgentInventoryOptions, type AgentInventoryResult, type InventoryItem } from "./agentInventory.js";
|
|
2
|
+
import { type DeadContextResult } from "./deadContext.js";
|
|
3
|
+
import type { LocalAgentCall } from "./localAgentLogs.js";
|
|
4
|
+
import { type InvocationSummary, type ToolInvocationOptions } from "./toolInvocations.js";
|
|
5
|
+
export type ContextHealthStatus = "healthy" | "watch" | "start_fresh" | "insufficient_data";
|
|
6
|
+
export type ContextHealthRecommendation = "continue" | "start_fresh" | "review_hooks" | "trim_dead_context" | "collect_more_history";
|
|
7
|
+
export type ContextHealthEvidence = {
|
|
8
|
+
kind: "session_history" | "context_churn" | "hook_config" | "inventory_usage";
|
|
9
|
+
summary: string;
|
|
10
|
+
source: string;
|
|
11
|
+
confidence: "observed" | "derived" | "unmeasured";
|
|
12
|
+
};
|
|
13
|
+
export type ContextHealthResult = {
|
|
14
|
+
schemaVersion: 1;
|
|
15
|
+
generatedAt: string;
|
|
16
|
+
status: ContextHealthStatus;
|
|
17
|
+
recommendation: ContextHealthRecommendation;
|
|
18
|
+
headline: string;
|
|
19
|
+
action: string;
|
|
20
|
+
confidence: "high" | "medium" | "low";
|
|
21
|
+
currentSession: {
|
|
22
|
+
status: "active" | "recent";
|
|
23
|
+
agent: LocalAgentCall["agent"];
|
|
24
|
+
project?: string;
|
|
25
|
+
totalTokens: number;
|
|
26
|
+
ratioToMedian: number | null;
|
|
27
|
+
comparisonSessions: number;
|
|
28
|
+
cacheWriteTokens: number;
|
|
29
|
+
cacheWriteRatioToMedian: number | null;
|
|
30
|
+
source: "local_transcript_metadata";
|
|
31
|
+
} | null;
|
|
32
|
+
activation: {
|
|
33
|
+
discoverableItems: number;
|
|
34
|
+
explicitlyInvokedItems: number;
|
|
35
|
+
hookInjectedItems: number;
|
|
36
|
+
lifecycleHooks: number;
|
|
37
|
+
mcpSchemaLoadedItems: number;
|
|
38
|
+
unmeasuredItems: number;
|
|
39
|
+
invocationUnobservableItems: number;
|
|
40
|
+
};
|
|
41
|
+
deadContext: {
|
|
42
|
+
loadedItems: number;
|
|
43
|
+
neverInvokedItems: number;
|
|
44
|
+
measuredNeverInvokedItems: number;
|
|
45
|
+
unmeasuredNeverInvokedItems: number;
|
|
46
|
+
windowDays: number;
|
|
47
|
+
};
|
|
48
|
+
contextChurn: {
|
|
49
|
+
currentSessionEvidence: "matched" | "not_matched" | "no_current_session";
|
|
50
|
+
compactionEvents: number | null;
|
|
51
|
+
explicitFileReads: number | null;
|
|
52
|
+
repeatedReadEvents: number | null;
|
|
53
|
+
repeatedFiles: Array<{
|
|
54
|
+
file: string;
|
|
55
|
+
readCount: number;
|
|
56
|
+
}>;
|
|
57
|
+
readCoverage: "explicit_read_tools_only" | "not_available";
|
|
58
|
+
currentSessionScope: "parent" | "subagent" | "unknown" | null;
|
|
59
|
+
observedParentSessions: number;
|
|
60
|
+
observedSubagentSessions: number;
|
|
61
|
+
};
|
|
62
|
+
evidence: ContextHealthEvidence[];
|
|
63
|
+
provenance: {
|
|
64
|
+
inventory: "local_agent_configuration";
|
|
65
|
+
invocations: "local_claude_code_and_codex_transcripts";
|
|
66
|
+
session: "local_transcript_metadata";
|
|
67
|
+
hookPayload: "not_executed_or_inferred";
|
|
68
|
+
uploaded: false;
|
|
69
|
+
};
|
|
70
|
+
caveats: string[];
|
|
71
|
+
};
|
|
72
|
+
export type BuildContextHealthInput = {
|
|
73
|
+
calls?: LocalAgentCall[];
|
|
74
|
+
inventory?: AgentInventoryResult | {
|
|
75
|
+
items: InventoryItem[];
|
|
76
|
+
};
|
|
77
|
+
invocations?: InvocationSummary;
|
|
78
|
+
deadContext?: DeadContextResult;
|
|
79
|
+
now?: Date;
|
|
80
|
+
activeWithinMinutes?: number;
|
|
81
|
+
windowDays?: number;
|
|
82
|
+
};
|
|
83
|
+
export type LoadContextHealthOptions = AgentInventoryOptions & ToolInvocationOptions & {
|
|
84
|
+
now?: Date;
|
|
85
|
+
activeWithinMinutes?: number;
|
|
86
|
+
windowDays?: number;
|
|
87
|
+
pricingModel?: string;
|
|
88
|
+
inventory?: AgentInventoryResult;
|
|
89
|
+
invocations?: InvocationSummary;
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* Load one canonical Context Health snapshot. CLI, MCP, and Glance all consume
|
|
93
|
+
* this contract so their recommendation and provenance cannot drift.
|
|
94
|
+
*/
|
|
95
|
+
export declare function loadContextHealth(calls: LocalAgentCall[], options?: LoadContextHealthOptions): Promise<ContextHealthResult>;
|
|
96
|
+
/** Pure Context Health contract builder for deterministic tests and adapters. */
|
|
97
|
+
export declare function buildContextHealth(input?: BuildContextHealthInput): ContextHealthResult;
|
|
98
|
+
/** Exposed for deterministic benchmark fixtures. */
|
|
99
|
+
export declare const CONTEXT_HEALTH_DEFAULT_WINDOW_DAYS = 30;
|
|
100
|
+
export declare const CONTEXT_HEALTH_DAY_MS: number;
|
|
101
|
+
//# sourceMappingURL=contextHealth.d.ts.map
|