@agent-finops/core 0.5.5 → 0.5.7

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 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).
@@ -1,18 +1,18 @@
1
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.
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 and missing dirs/files never throw.
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. Config (~/.claude.json) almost never
15
- * carries tool schemas, so MCP enumeration is usually limited to server names;
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
- /** "estimated_understated" when an MCP tool schema is unavailable. */
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. */
@@ -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 scanned = { skills: 0, subagents: 0, commands: 0, mcpServers: 0, mcpTools: 0 };
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
- { dir: join(claudeHome, "skills"), scope: "user" },
28
- { dir: join(projectClaude, "skills"), scope: "project" }
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
- { dir: join(projectClaude, "agents"), scope: "project" }
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
- { dir: join(projectClaude, "commands"), scope: "project" }
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 config = await readJson(configPath);
115
- const serverScopes = collectMcpServers(config, projectDir, options.includeAllProjectMcp ?? false);
116
- for (const { id, scope, ownerDirs } of serverScopes) {
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: configPath,
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 amountUsd = roundMoney(sumRecords(groupRecords));
111
- const shareOfSpend = roundRatio(amountUsd / totalUsd);
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