@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.
@@ -8,8 +8,10 @@ const DEFAULT_WINDOW_DAYS = 30;
8
8
  * this contract so their recommendation and provenance cannot drift.
9
9
  */
10
10
  export async function loadContextHealth(calls, options = {}) {
11
- const inventory = options.inventory ?? await loadAgentInventory(options);
12
- const invocations = options.invocations ?? await loadToolInvocations(options);
11
+ const [inventory, invocations] = await Promise.all([
12
+ options.inventory ?? loadAgentInventory(options),
13
+ options.invocations ?? loadToolInvocations(options)
14
+ ]);
13
15
  const windowDays = options.windowDays ?? DEFAULT_WINDOW_DAYS;
14
16
  const deadContext = computeDeadContext(inventory.items, invocations, {
15
17
  windowDays,
@@ -46,11 +48,17 @@ export function buildContextHealth(input = {}) {
46
48
  if (currentSession) {
47
49
  evidence.push({
48
50
  kind: "session_history",
49
- summary: currentSession.ratioToMedian === null
50
- ? `${currentSession.totalTokens.toLocaleString("en-US")} local transcript tokens; no same-agent baseline yet.`
51
- : `${currentSession.totalTokens.toLocaleString("en-US")} local transcript tokens, ${currentSession.ratioToMedian the median of ${currentSession.comparisonSessions} prior same-agent session${currentSession.comparisonSessions === 1 ? "" : "s"}.`,
52
- source: `${currentSession.agent} local transcripts`,
53
- confidence: currentSession.ratioToMedian === null ? "observed" : "derived"
51
+ summary: currentSession.usageSource === "not_available"
52
+ ? "Latest-turn context usage was not present in this transcript format; cumulative session usage was excluded from Context Health comparison."
53
+ : currentSession.ratioToMedian === null
54
+ ? `${currentSession.contextTokens.toLocaleString("en-US")} input context tokens in the latest observed turn; no comparable baseline yet.`
55
+ : `${currentSession.contextTokens.toLocaleString("en-US")} input context tokens in the latest observed turn, ${currentSession.ratioCapped ? "at least " : ""}${currentSession.ratioToMedian the median of ${currentSession.comparisonSessions} comparable prior session${currentSession.comparisonSessions === 1 ? "" : "s"}.`,
56
+ source: `${currentSession.agent} local transcript latest-turn usage`,
57
+ confidence: currentSession.usageSource === "not_available"
58
+ ? "unmeasured"
59
+ : currentSession.ratioToMedian === null
60
+ ? "observed"
61
+ : "derived"
54
62
  });
55
63
  }
56
64
  if ((contextChurn.compactionEvents ?? 0) > 0) {
@@ -86,8 +94,8 @@ export function buildContextHealth(input = {}) {
86
94
  for (const hook of hookItems.slice(0, 3)) {
87
95
  evidence.push({
88
96
  kind: "hook_config",
89
- summary: `${hook.group ?? hook.name} is configured on ${hook.event ?? "a lifecycle event"} for ${hostLabel(hook.host)}.`,
90
- source: hook.path ?? "installed plugin metadata",
97
+ summary: `${safeHookCategory(hook)} is configured for ${hostLabel(hook.host)}.`,
98
+ source: safeHookProvenance(hook),
91
99
  confidence: "unmeasured"
92
100
  });
93
101
  }
@@ -102,7 +110,7 @@ export function buildContextHealth(input = {}) {
102
110
  if (deadContext.deadCount > 0) {
103
111
  evidence.push({
104
112
  kind: "inventory_usage",
105
- summary: `${deadContext.deadCount} of ${deadContext.loadedCount} discoverable/schema-loaded item${deadContext.loadedCount === 1 ? "" : "s"} were not invoked in the parsed window.`,
113
+ summary: `${deadContext.deadCount} of ${deadContext.loadedCount} configured item${deadContext.loadedCount === 1 ? "" : "s"} had no matching invocation in the parsed window; configuration alone does not prove their definitions were loaded every turn.`,
106
114
  source: "local inventory compared with local transcript invocations",
107
115
  confidence: deadContext.unmeasuredDeadCount > 0 ? "unmeasured" : "derived"
108
116
  });
@@ -110,7 +118,7 @@ export function buildContextHealth(input = {}) {
110
118
  if (activation.invocationUnobservableItems > 0) {
111
119
  evidence.push({
112
120
  kind: "inventory_usage",
113
- summary: `${activation.invocationUnobservableItems} configured item${activation.invocationUnobservableItems === 1 ? "" : "s"} cannot be matched to an explicit invocation in the available transcript format and were excluded from never-invoked counts.`,
121
+ summary: `${activation.invocationUnobservableItems} configured item${activation.invocationUnobservableItems === 1 ? "" : "s"} cannot be matched reliably to an explicit invocation or one concrete configuration scope in the available transcript format and were excluded from never-invoked counts.`,
114
122
  source: "local inventory and transcript capability metadata",
115
123
  confidence: "unmeasured"
116
124
  });
@@ -145,9 +153,9 @@ export function buildContextHealth(input = {}) {
145
153
  },
146
154
  caveats: [
147
155
  "Hook commands are never run by aibill. Configuration proves activation, but runtime output and token size remain unmeasured.",
148
- "A session comparison uses local transcript token totals from the same coding agent; it is not a provider charge or a universal context-window measurement.",
149
- "Never-invoked means no matching invocation was observed in the selected local transcript window, not that an item has no future value.",
150
- "Items whose host transcript does not expose explicit invocation evidence are excluded from never-invoked counts.",
156
+ "A Context Health comparison uses latest-turn input context from comparable local sessions, never cumulative session lifetime usage. It is not a provider charge or a universal context-window measurement.",
157
+ "No matching invocation means none was observed in the selected local transcript window. Configuration alone does not prove an item was loaded every turn or that it has no future value.",
158
+ "Items whose host transcript does not expose explicit invocation evidence, or whose evidence cannot be attributed to one concrete configuration scope, are excluded from never-invoked counts.",
151
159
  "Repeated-read evidence includes only explicit file-read tools and returns basenames only. Shell commands are not guessed to be reads.",
152
160
  "Compaction counts come from explicit transcript markers; absence means not observed in the parsed format, not proof that compaction never occurred.",
153
161
  "No per-session savings claim is made without an observed counterfactual baseline."
@@ -155,16 +163,8 @@ export function buildContextHealth(input = {}) {
155
163
  };
156
164
  }
157
165
  function contextDecision(input) {
158
- const ratio = input.currentSession?.ratioToMedian;
159
- if (ratio !== null && ratio !== undefined && ratio >= 1.5) {
160
- return {
161
- status: "start_fresh",
162
- recommendation: "start_fresh",
163
- headline: `This session is ${ratio}× your same-agent token median.`,
164
- action: "Start fresh before a new task; keep this session only while its existing context is directly useful.",
165
- confidence: input.currentSession.comparisonSessions >= 3 ? "high" : "medium"
166
- };
167
- }
166
+ // Explicit compaction markers are direct evidence and outrank any ratio
167
+ // derived from a comparison cohort.
168
168
  if ((input.contextChurn.compactionEvents ?? 0) >= 2) {
169
169
  const count = input.contextChurn.compactionEvents;
170
170
  return {
@@ -175,6 +175,22 @@ function contextDecision(input) {
175
175
  confidence: "high"
176
176
  };
177
177
  }
178
+ const ratio = input.currentSession?.ratioToMedian;
179
+ if (ratio !== null && ratio !== undefined && ratio >= 1.5) {
180
+ const ratioLabel = input.currentSession?.ratioCapped ? `at least ${ratio}` : `${ratio}`;
181
+ const exactComparisons = input.currentSession?.comparisonBasis === "same_project_and_session_type";
182
+ return {
183
+ status: "start_fresh",
184
+ recommendation: "start_fresh",
185
+ headline: `This turn's context load is ${ratioLabel}× your comparable same-agent token median.`,
186
+ action: "Start fresh before a new task; keep this session only while its existing context is directly useful.",
187
+ confidence: exactComparisons &&
188
+ !input.currentSession?.ratioCapped &&
189
+ input.currentSession.comparisonSessions >= 3
190
+ ? "high"
191
+ : "medium"
192
+ };
193
+ }
178
194
  if (input.hookInjectedItems > 0) {
179
195
  return {
180
196
  status: "watch",
@@ -188,8 +204,8 @@ function contextDecision(input) {
188
204
  return {
189
205
  status: "watch",
190
206
  recommendation: "trim_dead_context",
191
- headline: `${input.deadContext.deadCount} loaded item${input.deadContext.deadCount === 1 ? "" : "s"} were not invoked in this window.`,
192
- action: "Lazy-load or remove only the items you do not expect to need, then re-run Context Health.",
207
+ headline: `${input.deadContext.deadCount} configured item${input.deadContext.deadCount === 1 ? "" : "s"} had no matching invocation in this window.`,
208
+ action: "First verify how each host makes the item available; then lazy-load or remove only items you confirm you do not need, and re-run Context Health.",
193
209
  confidence: input.deadContext.unmeasuredDeadCount > 0 ? "medium" : "high"
194
210
  };
195
211
  }
@@ -210,28 +226,59 @@ function contextDecision(input) {
210
226
  confidence: "low"
211
227
  };
212
228
  }
229
+ const MIN_EXACT_COMPARISONS = 2;
230
+ const MIN_SESSION_TYPE_COMPARISONS = 2;
231
+ const MAX_DISPLAY_RATIO = 20;
213
232
  function buildCurrentSession(sessions, now, activeWithinMinutes) {
214
233
  const latest = latestContextSession(sessions);
215
234
  if (!latest)
216
235
  return null;
217
- const comparisons = sessions.filter((session) => (session.key !== latest.key &&
236
+ const eligible = sessions.filter((session) => (session.key !== latest.key &&
218
237
  session.agent === latest.agent &&
219
- session.totalTokens > 0));
220
- const baseline = median(comparisons.map((session) => session.totalTokens));
238
+ session.contextTokens > 0));
239
+ const sameProjectAndType = eligible.filter((session) => (Boolean(latest.project) &&
240
+ session.project === latest.project &&
241
+ session.sessionType === latest.sessionType));
242
+ const sameType = eligible.filter((session) => (latest.sessionType !== "unknown" &&
243
+ session.sessionType === latest.sessionType));
244
+ const comparisons = sameProjectAndType.length > 0
245
+ ? sameProjectAndType
246
+ : sameType.length > 0
247
+ ? sameType
248
+ : [];
249
+ const comparisonBasis = comparisons === sameProjectAndType
250
+ ? "same_project_and_session_type"
251
+ : comparisons === sameType
252
+ ? "same_session_type"
253
+ : "not_available";
254
+ const minimumComparisons = comparisonBasis === "same_project_and_session_type"
255
+ ? MIN_EXACT_COMPARISONS
256
+ : MIN_SESSION_TYPE_COMPARISONS;
257
+ const baseline = comparisons.length >= minimumComparisons
258
+ ? median(comparisons.map((session) => session.contextTokens))
259
+ : null;
221
260
  const cacheWriteBaseline = median(comparisons
222
261
  .map((session) => session.cacheWriteTokens)
223
262
  .filter((tokens) => tokens > 0));
224
- const ratio = baseline && baseline > 0
225
- ? roundRatio(latest.totalTokens / baseline)
263
+ const rawRatio = baseline && baseline > 0 && latest.contextTokens > 0
264
+ ? latest.contextTokens / baseline
226
265
  : null;
266
+ const ratioCapped = rawRatio !== null && rawRatio > MAX_DISPLAY_RATIO;
267
+ const ratio = rawRatio === null
268
+ ? null
269
+ : roundRatio(Math.min(rawRatio, MAX_DISPLAY_RATIO));
227
270
  const ageMs = Math.max(0, now.getTime() - Date.parse(latest.lastActivityAt));
228
271
  return {
229
272
  status: ageMs <= activeWithinMinutes * 60_000 ? "active" : "recent",
230
273
  agent: latest.agent,
231
274
  project: latest.project,
232
275
  totalTokens: latest.totalTokens,
276
+ contextTokens: latest.contextTokens,
277
+ usageSource: latest.usageSource,
233
278
  ratioToMedian: ratio,
279
+ ratioCapped,
234
280
  comparisonSessions: comparisons.length,
281
+ comparisonBasis,
235
282
  cacheWriteTokens: latest.cacheWriteTokens,
236
283
  cacheWriteRatioToMedian: cacheWriteBaseline && latest.cacheWriteTokens > 0
237
284
  ? roundRatio(latest.cacheWriteTokens / cacheWriteBaseline)
@@ -249,28 +296,53 @@ function contextSessions(calls) {
249
296
  return [...groups.entries()].map(([key, grouped]) => {
250
297
  const ordered = grouped.slice().sort((left, right) => left.timestamp.localeCompare(right.timestamp));
251
298
  const latest = ordered[ordered.length - 1];
299
+ const latestWithTurnUsage = ordered
300
+ .slice()
301
+ .reverse()
302
+ .find((call) => call.latestTurnUsage || call.usageScope !== "session_cumulative");
303
+ const turnUsage = latestWithTurnUsage
304
+ ? latestWithTurnUsage.latestTurnUsage ?? turnUsageFromCall(latestWithTurnUsage)
305
+ : undefined;
252
306
  return {
253
307
  key,
254
308
  agent: latest.agent,
255
309
  project: latest.project ?? ordered[0]?.project,
256
310
  lastActivityAt: latest.timestamp,
257
- totalTokens: ordered.reduce((total, call) => total + (call.usage.inputTokens +
258
- call.usage.outputTokens +
259
- (call.usage.cacheReadTokens ?? 0) +
260
- (call.usage.cacheWrite5mTokens ?? 0) +
261
- (call.usage.cacheWrite1hTokens ?? 0)), 0),
262
- cacheWriteTokens: ordered.reduce((total, call) => total + ((call.usage.cacheWrite5mTokens ?? 0) +
263
- (call.usage.cacheWrite1hTokens ?? 0)), 0)
311
+ totalTokens: turnUsage?.totalTokens ?? 0,
312
+ contextTokens: turnUsage?.contextTokens ?? 0,
313
+ usageSource: turnUsage?.source ?? "not_available",
314
+ cacheWriteTokens: turnUsage
315
+ ? (turnUsage.cacheWrite5mTokens ?? 0) + (turnUsage.cacheWrite1hTokens ?? 0)
316
+ : 0,
317
+ sessionType: latest.activity?.isSubagent === true
318
+ ? "subagent"
319
+ : latest.activity?.isSubagent === false
320
+ ? "parent"
321
+ : "unknown"
264
322
  };
265
323
  });
266
324
  }
325
+ function turnUsageFromCall(call) {
326
+ if (call.usageScope === "session_cumulative")
327
+ return undefined;
328
+ const contextTokens = call.usage.inputTokens +
329
+ (call.usage.cacheReadTokens ?? 0) +
330
+ (call.usage.cacheWrite5mTokens ?? 0) +
331
+ (call.usage.cacheWrite1hTokens ?? 0);
332
+ return {
333
+ ...call.usage,
334
+ contextTokens,
335
+ totalTokens: contextTokens + call.usage.outputTokens,
336
+ source: call.agent === "claude-code" ? "assistant_message_usage" : "call_usage"
337
+ };
338
+ }
267
339
  function latestContextSession(sessions) {
268
340
  return sessions
269
341
  .slice()
270
342
  .sort((left, right) => right.lastActivityAt.localeCompare(left.lastActivityAt))[0];
271
343
  }
272
344
  function buildContextChurn(latest, invocations) {
273
- const signals = invocations.sessionSignals ?? [];
345
+ const signals = mergeSessionSignals(invocations.sessionSignals ?? []);
274
346
  const currentSignal = latest
275
347
  ? signals.find((signal) => (signal.agent === latest.agent &&
276
348
  signal.sessionId &&
@@ -304,12 +376,109 @@ function buildContextChurn(latest, invocations) {
304
376
  observedSubagentSessions: signals.filter((signal) => signal.isSubagent).length
305
377
  };
306
378
  }
379
+ function mergeSessionSignals(signals) {
380
+ const keyed = new Map();
381
+ const anonymous = [];
382
+ for (const signal of signals) {
383
+ if (!signal.sessionId) {
384
+ anonymous.push(signal);
385
+ continue;
386
+ }
387
+ const key = `${signal.agent}:${signal.sessionId}`;
388
+ keyed.set(key, [...(keyed.get(key) ?? []), signal]);
389
+ }
390
+ const merged = [...keyed.entries()]
391
+ .sort(([left], [right]) => left.localeCompare(right))
392
+ .map(([, group]) => mergeSignalGroup(group));
393
+ const sortedAnonymous = anonymous.slice().sort(compareSessionSignals);
394
+ return [...merged, ...sortedAnonymous];
395
+ }
396
+ function mergeSignalGroup(group) {
397
+ const ordered = group.slice().sort(compareSessionSignals);
398
+ const preferred = ordered[0];
399
+ const fileReadCounts = new Map();
400
+ for (const signal of group) {
401
+ for (const file of signal.fileReads) {
402
+ fileReadCounts.set(file.name, Math.max(fileReadCounts.get(file.name) ?? 0, file.count));
403
+ }
404
+ }
405
+ const fileReads = [...fileReadCounts.entries()]
406
+ .map(([name, count]) => ({ name, count }))
407
+ .sort((left, right) => right.count - left.count || left.name.localeCompare(right.name));
408
+ return {
409
+ ...preferred,
410
+ lastActivityAt: group
411
+ .map((signal) => signal.lastActivityAt)
412
+ .filter((value) => Boolean(value))
413
+ .sort((left, right) => right.localeCompare(left))[0],
414
+ compactionEvents: Math.max(...group.map((signal) => signal.compactionEvents)),
415
+ fileReads,
416
+ repeatedFileReads: fileReads.filter((file) => file.count > 1)
417
+ };
418
+ }
419
+ function compareSessionSignals(left, right) {
420
+ return (right.lastActivityAt ?? "").localeCompare(left.lastActivityAt ?? "") ||
421
+ right.compactionEvents - left.compactionEvents ||
422
+ totalFileReads(right) - totalFileReads(left) ||
423
+ sessionSignalSignature(left).localeCompare(sessionSignalSignature(right));
424
+ }
425
+ function totalFileReads(signal) {
426
+ return signal.fileReads.reduce((total, file) => total + file.count, 0);
427
+ }
428
+ function sessionSignalSignature(signal) {
429
+ return JSON.stringify({
430
+ agent: signal.agent,
431
+ sessionId: signal.sessionId,
432
+ isSubagent: signal.isSubagent,
433
+ parentSessionId: signal.parentSessionId,
434
+ files: signal.fileReads
435
+ .map((file) => [file.name, file.count])
436
+ .sort(([left], [right]) => String(left).localeCompare(String(right)))
437
+ });
438
+ }
439
+ function safeHookCategory(hook) {
440
+ if (hook.event === "SessionStart")
441
+ return "Session-start context hook";
442
+ if (hook.event === "UserPromptSubmit")
443
+ return "Prompt-submit context hook";
444
+ if (hook.event === "SubagentStart")
445
+ return "Subagent-start context hook";
446
+ if (hook.activation === "hook_injected")
447
+ return "Context-injecting lifecycle hook";
448
+ return "Lifecycle hook";
449
+ }
450
+ function safeHookProvenance(hook) {
451
+ if (hook.path === "Claude user settings")
452
+ return "Claude user settings";
453
+ if (hook.path === "Claude project settings")
454
+ return "Claude project settings";
455
+ if (hook.path === "Claude project-local settings")
456
+ return "Claude project-local settings";
457
+ const normalized = hook.path?.replace(/\\/g, "/");
458
+ if (normalized?.endsWith("/.claude/settings.local.json")) {
459
+ return ".claude/settings.local.json";
460
+ }
461
+ if (normalized?.endsWith("/.claude/settings.json")) {
462
+ return ".claude/settings.json";
463
+ }
464
+ if (normalized?.endsWith("/hooks/hooks.json")) {
465
+ return "installed plugin hooks/hooks.json";
466
+ }
467
+ const scope = hook.scope === "user"
468
+ ? "user"
469
+ : hook.scope === "local"
470
+ ? "project-local"
471
+ : "project";
472
+ return `${hostLabel(hook.host)} ${scope} hook configuration`;
473
+ }
307
474
  function activationSummary(items, invocations) {
308
475
  return {
309
476
  discoverableItems: items.filter((item) => item.activation === "discoverable").length,
310
477
  explicitlyInvokedItems: items.filter((item) => itemWasInvoked(item, invocations)).length,
311
478
  hookInjectedItems: items.filter((item) => item.activation === "hook_injected").length,
312
479
  lifecycleHooks: items.filter((item) => item.activation === "lifecycle_hook").length,
480
+ mcpConfiguredItems: items.filter((item) => item.activation === "mcp_configured").length,
481
+ mcpAlwaysLoadedItems: items.filter((item) => item.activation === "mcp_always_loaded").length,
313
482
  mcpSchemaLoadedItems: items.filter((item) => item.activation === "mcp_schema_loaded").length,
314
483
  unmeasuredItems: items.filter((item) => item.weightConfidence !== "estimated").length,
315
484
  invocationUnobservableItems: items.filter((item) => item.kind !== "hook" && item.invocationTracking === "not_observable").length
@@ -318,17 +487,20 @@ function activationSummary(items, invocations) {
318
487
  function itemWasInvoked(item, invocations) {
319
488
  if (item.invocationTracking === "not_observable")
320
489
  return false;
490
+ const evidence = item.host && invocations.byHost
491
+ ? invocations.byHost[item.host]
492
+ : invocations;
321
493
  switch (item.kind) {
322
494
  case "skill":
323
- return invocations.invokedSkills.includes(item.name);
495
+ return evidence.invokedSkills.includes(item.name);
324
496
  case "subagent":
325
- return invocations.invokedSubagents.includes(item.name);
497
+ return evidence.invokedSubagents.includes(item.name);
326
498
  case "command":
327
- return invocations.invokedCommands.includes(item.name);
499
+ return evidence.invokedCommands.includes(item.name);
328
500
  case "mcp_tool":
329
- return invocations.invokedMcpTools.includes(item.name);
501
+ return evidence.invokedMcpTools.includes(item.name);
330
502
  case "mcp_server":
331
- return invocations.invokedMcpTools.some((tool) => tool.split("__")[1] === item.name);
503
+ return evidence.invokedMcpTools.some((tool) => tool.split("__")[1] === item.name);
332
504
  case "hook":
333
505
  return false;
334
506
  }
package/dist/cutList.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { CostConfidence, UsageRecord } from "./schema.js";
1
+ import { type CostConfidence, type UsageRecord } from "./schema.js";
2
2
  /**
3
3
  * Actionable, dollar-specific "cut" suggestions.
4
4
  *
@@ -13,7 +13,11 @@ export type CutAction = {
13
13
  title: string;
14
14
  /** One-line, copy-pasteable instruction with the exact target. */
15
15
  action: string;
16
- /** Estimated monthly savings in USD for this single action. */
16
+ /**
17
+ * Estimated monthly savings in USD for this single action. Zero means the
18
+ * evidence identifies value worth investigating but contains no observed
19
+ * counterfactual from which a savings amount can be earned.
20
+ */
17
21
  estimatedMonthlySavingsUsd: number;
18
22
  /** Spend (in the analyzed window) this action touches. */
19
23
  affectedSpendUsd: number;
@@ -24,7 +28,9 @@ export type CutAction = {
24
28
  * aggregate a day of sessions into one record, so calling those "calls"
25
29
  * overstates precision to the exact audience that will check.
26
30
  */
27
- recordUnit: "calls" | "session-days" | "tools";
31
+ recordUnit: "calls" | "daily-aggregates" | "tools";
32
+ /** Whether the number is an intervention model or only observed exposure. */
33
+ impactBasis: "modeled_savings" | "observed_value_no_counterfactual";
28
34
  /** Lowest confidence of the underlying records (drives how we caveat $). */
29
35
  confidence: CostConfidence;
30
36
  kind: "model_downgrade" | "context_trim" | "cache" | "batch";