@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/dist/analyze.js CHANGED
@@ -1,43 +1,23 @@
1
1
  import { generateSpendInsights } from "./insights.js";
2
- import { costConfidenceValues, spendSummarySchema } from "./schema.js";
2
+ import { costConfidenceValues, hasModeledWorkloadEvidence, hasPricedEvidence, spendComparisonKey, spendSummarySchema } from "./schema.js";
3
3
  const confidenceRank = {
4
4
  verified: 0,
5
5
  estimated: 1,
6
6
  detected_unverified: 2,
7
7
  missing: 3
8
8
  };
9
- /**
10
- * Planning ratios behind every "estimated impact/savings" figure this module
11
- * emits. These are deliberately ROUND heuristics — orientation numbers for a
12
- * first conversation, not measured savings — and every consumer labels them
13
- * estimated. They are aligned with the documented per-model economics in
14
- * cutList.ts (downgradeRules retain 20–50% of cost on downgrade-safe work;
15
- * the Batch API retains 50%): applying those cuts to only the eligible slice
16
- * of a workload typically lands in the 10–30% range below.
17
- *
18
- * If you change one, change the doc line with it. No undocumented multiplier
19
- * may ever reach user-visible output — that is a product bug on an
20
- * honest-numbers brand, not a style issue.
21
- */
22
- const impactRatios = {
23
- /** Portion of a workflow's spend typically cuttable via caps, caching, and tier routing. */
24
- workflowSavings: 0.2,
25
- /** Un-attributed workflow spend treated as margin-exposed until mapped to a client/project (coin-flip prior). */
26
- workflowMarginRisk: 0.5,
27
- /** Top-model spend recoverable by moving downgrade-safe work to a cheaper tier (see cutList.ts downgradeRules). */
28
- modelDowngrade: 0.3,
29
- /** Cost of oversized-context calls recoverable by trimming prompts/retrieval. */
30
- promptTrimming: 0.15,
31
- /** Spend on repeated identical operations recoverable via caching/memoization. */
32
- caching: 0.25,
33
- /** Top-agent spend avoidable with budget caps catching runaway loops. */
34
- agentCaps: 0.15,
35
- /** Total spend addressable by moving latency-tolerant work to Batch APIs (50% price × eligible slice). */
36
- batching: 0.1,
37
- /** Total spend addressable with price/quality routing across multiple providers. */
38
- routing: 0.1
9
+ /** Published retained-cost fraction for providers whose Batch pricing we explicitly support. */
10
+ const batchCostRetainedByProvider = {
11
+ openai: 0.5,
12
+ anthropic: 0.5
39
13
  };
40
14
  export function analyzeSpend(records) {
15
+ // Billing buckets, usage aggregates, seats, and user totals are useful for
16
+ // financial breakdowns and spend-spike detection. They do not prove a
17
+ // workload-level counterfactual. Only records with explicit call/invocation
18
+ // provenance and a named operation feed modeled recommendations/insights.
19
+ const decisionRecords = records.filter(hasModeledWorkloadEvidence);
20
+ const anomalyRecords = records.filter((record) => !isLocalAgentRecord(record));
41
21
  const summary = {
42
22
  totalUsd: roundMoney(sumRecords(records)),
43
23
  recordCount: records.length,
@@ -52,51 +32,106 @@ export function analyzeSpend(records) {
52
32
  byWorkspace: breakdown(records, (record) => record.workspaceId),
53
33
  byApiKey: breakdown(records, (record) => record.apiKeyId),
54
34
  workflowWatch: generateWorkflowWatch(records),
55
- anomalies: detectSpendSpikes(records),
56
- recommendations: generateRecommendations(records),
35
+ anomalies: detectSpendSpikes(anomalyRecords),
36
+ recommendations: generateRecommendations(decisionRecords),
57
37
  insights: []
58
38
  };
59
- summary.insights = generateSpendInsights(records, summary);
39
+ // Insights may explain stable provider-billing cohorts, but their own
40
+ // engines distinguish aggregate accounting evidence from run-level evidence.
41
+ // Recompute without local transcript aggregates so cumulative local session
42
+ // rows cannot leak into provider anomaly or ownership diagnostics.
43
+ if (anomalyRecords.length > 0) {
44
+ const evidenceSummary = anomalyRecords.length === records.length
45
+ ? summary
46
+ : {
47
+ totalUsd: roundMoney(sumRecords(anomalyRecords)),
48
+ recordCount: anomalyRecords.length,
49
+ confidence: combinedConfidence(anomalyRecords.map((record) => record.costConfidence)),
50
+ confidenceBreakdown: confidenceBreakdown(anomalyRecords),
51
+ bySource: breakdown(anomalyRecords, (record) => record.source.id),
52
+ byModel: breakdown(anomalyRecords, (record) => record.model),
53
+ byClient: breakdown(anomalyRecords, (record) => record.clientId),
54
+ byProject: breakdown(anomalyRecords, (record) => record.projectId),
55
+ byAgent: breakdown(anomalyRecords, (record) => record.agentId),
56
+ byUser: breakdown(anomalyRecords, (record) => record.userId),
57
+ byWorkspace: breakdown(anomalyRecords, (record) => record.workspaceId),
58
+ byApiKey: breakdown(anomalyRecords, (record) => record.apiKeyId),
59
+ workflowWatch: generateWorkflowWatch(anomalyRecords),
60
+ anomalies: detectSpendSpikes(anomalyRecords),
61
+ recommendations: generateRecommendations(anomalyRecords),
62
+ insights: []
63
+ };
64
+ summary.insights = generateSpendInsights(anomalyRecords, evidenceSummary);
65
+ }
60
66
  return spendSummarySchema.parse(summary);
61
67
  }
62
68
  export function detectSpendSpikes(records) {
63
- const byDay = new Map();
69
+ // Local coding-agent records are day + agent + model + project aggregates.
70
+ // A cumulative session counter may be attributed to its final observation
71
+ // day, so comparing those rows day-over-day would manufacture a "spike."
72
+ // Only provider/call-level records can participate in this detector.
73
+ const byCohort = new Map();
64
74
  for (const record of records) {
75
+ if (isLocalAgentRecord(record) || !hasPricedEvidence(record))
76
+ continue;
77
+ const comparisonKey = spendComparisonKey(record);
78
+ // Unknown row shape is not a comparable cohort. Pooling all unclassified
79
+ // provider rows creates fake spikes when source mix changes between days.
80
+ if (!comparisonKey)
81
+ continue;
65
82
  const day = record.timestamp.slice(0, 10);
83
+ const byDay = byCohort.get(comparisonKey) ?? new Map();
66
84
  byDay.set(day, [...(byDay.get(day) ?? []), record]);
85
+ byCohort.set(comparisonKey, byDay);
67
86
  }
68
- const days = [...byDay.keys()].sort();
69
87
  const anomalies = [];
70
- for (let index = 1; index < days.length; index += 1) {
71
- const previousDay = days[index - 1];
72
- const currentDay = days[index];
73
- const previousAmountUsd = roundMoney(sumRecords(byDay.get(previousDay) ?? []));
74
- const currentRecords = byDay.get(currentDay) ?? [];
75
- const currentAmountUsd = roundMoney(sumRecords(currentRecords));
76
- if (previousAmountUsd === 0 || currentAmountUsd - previousAmountUsd < 10) {
77
- continue;
78
- }
79
- const multiplier = currentAmountUsd / previousAmountUsd;
80
- if (multiplier >= 1.75) {
81
- anomalies.push({
82
- kind: "day_over_day_spike",
83
- key: currentDay,
84
- previousAmountUsd,
85
- currentAmountUsd,
86
- multiplier: roundMoney(multiplier),
87
- confidence: combinedConfidence(currentRecords.map((record) => record.costConfidence))
88
- });
88
+ for (const [comparisonKey, byDay] of [...byCohort.entries()].sort(([left], [right]) => left.localeCompare(right))) {
89
+ const days = [...byDay.keys()].sort();
90
+ for (let index = 1; index < days.length; index += 1) {
91
+ const previousDay = days[index - 1];
92
+ const currentDay = days[index];
93
+ if (!isNextCalendarDay(previousDay, currentDay))
94
+ continue;
95
+ const previousRecords = byDay.get(previousDay) ?? [];
96
+ const previousAmountUsd = roundMoney(sumRecords(previousRecords));
97
+ const currentRecords = byDay.get(currentDay) ?? [];
98
+ const currentAmountUsd = roundMoney(sumRecords(currentRecords));
99
+ if (previousAmountUsd === 0 || currentAmountUsd - previousAmountUsd < 10) {
100
+ continue;
101
+ }
102
+ const multiplier = currentAmountUsd / previousAmountUsd;
103
+ if (multiplier >= 1.75) {
104
+ anomalies.push({
105
+ kind: "day_over_day_spike",
106
+ key: currentDay,
107
+ comparisonKey,
108
+ previousAmountUsd,
109
+ currentAmountUsd,
110
+ multiplier: roundMoney(multiplier),
111
+ confidence: combinedConfidence([...previousRecords, ...currentRecords].map((record) => record.costConfidence))
112
+ });
113
+ }
89
114
  }
90
115
  }
91
- return anomalies;
116
+ return anomalies.sort((left, right) => left.key.localeCompare(right.key) ||
117
+ (left.comparisonKey ?? "").localeCompare(right.comparisonKey ?? ""));
118
+ }
119
+ function isNextCalendarDay(previousDay, currentDay) {
120
+ const previous = Date.parse(`${previousDay}T00:00:00Z`);
121
+ const current = Date.parse(`${currentDay}T00:00:00Z`);
122
+ return Number.isFinite(previous) && Number.isFinite(current) && current - previous === 86_400_000;
92
123
  }
93
124
  export function generateWorkflowWatch(records) {
94
- const totalUsd = sumRecords(records);
125
+ // Workflow Watch is an ownership/concentration diagnostic. Do not attach a
126
+ // generic savings or margin prior: savings need a named counterfactual and
127
+ // margin risk needs real revenue/margin inputs that UsageRecord does not have.
128
+ const decisionRecords = records.filter((record) => !isLocalAgentRecord(record));
129
+ const totalUsd = sumRecords(decisionRecords);
95
130
  if (totalUsd === 0) {
96
131
  return [];
97
132
  }
98
133
  const groups = new Map();
99
- for (const record of records) {
134
+ for (const record of decisionRecords) {
100
135
  const clientId = record.clientId ?? "unmapped-client";
101
136
  const projectId = record.projectId ?? "unmapped-project";
102
137
  const workflowKey = record.operation ?? "unmapped-workflow";
@@ -113,10 +148,11 @@ export function generateWorkflowWatch(records) {
113
148
  // such as $0.0075 rounds to $0.01 for display; dividing that rounded
114
149
  // value by the raw total produced 1.3333 and failed the [0, 1] schema.
115
150
  const shareOfSpend = roundRatio(Math.min(1, rawAmountUsd / totalUsd));
116
- const estimatedSavingsUsd = roundMoney(amountUsd * impactRatios.workflowSavings);
117
- const estimatedMarginRiskUsd = roundMoney(amountUsd * impactRatios.workflowMarginRisk);
151
+ const estimatedSavingsUsd = 0;
152
+ const estimatedMarginRiskUsd = 0;
118
153
  const confidence = combinedConfidence(groupRecords.map((record) => record.costConfidence));
119
- const suggestedOptimization = workflowOptimizationFor(workflowKey, agentId);
154
+ const hasRunLevelEvidence = groupRecords.every(hasModeledWorkloadEvidence);
155
+ const suggestedOptimization = workflowDiagnosticFor(workflowKey, agentId, hasRunLevelEvidence);
120
156
  return {
121
157
  id: slugify(["workflow", clientId, projectId, workflowKey].join("-")),
122
158
  clientId,
@@ -130,103 +166,103 @@ export function generateWorkflowWatch(records) {
130
166
  estimatedMarginRiskUsd,
131
167
  estimatedSavingsUsd,
132
168
  suggestedOptimization,
133
- applyArtifact: `Copy this into your coding agent to cut cost: ${suggestedOptimization}`,
134
- verificationPlan: `After applying, rerun the ${workflowKey} workflow on the same sample and compare spend, latency, and output acceptance before rolling it out.`
169
+ applyArtifact: `Before changing this workload: ${suggestedOptimization}`,
170
+ verificationPlan: hasRunLevelEvidence
171
+ ? `Reconcile ${workflowKey} to its owner and budget, then define one reversible candidate and compare matched future accepted outcomes plus provider-reported cost.`
172
+ : `Reconcile ${workflowKey} to its owner and budget, then collect call-level workload evidence before modeling or applying a cost change.`
135
173
  };
136
174
  })
137
175
  .filter((entry) => entry.amountUsd > 0)
138
- .sort((left, right) => right.estimatedMarginRiskUsd - left.estimatedMarginRiskUsd || left.id.localeCompare(right.id))
176
+ .sort((left, right) => right.amountUsd - left.amountUsd || left.id.localeCompare(right.id))
139
177
  .slice(0, 5);
140
178
  }
141
179
  export function generateRecommendations(records) {
180
+ const decisionRecords = records.filter(hasModeledWorkloadEvidence);
142
181
  const recommendations = [];
143
- const modelSpend = breakdown(records, (record) => record.model);
182
+ const downgradeRecords = decisionRecords.filter((record) => record.workloadSemantics?.downgradeSafe === true);
183
+ const modelSpend = breakdown(downgradeRecords, (record) => record.model);
144
184
  const topModel = modelSpend[0];
145
185
  if (topModel && topModel.amountUsd >= 20) {
146
186
  recommendations.push({
147
187
  id: "model-downgrade",
148
188
  title: "Review expensive model workloads for downgrade candidates",
149
189
  rationale: `${topModel.key} is the largest cost driver in the current local sample.`,
150
- whyItMatters: "Premium model usage tends to become invisible once agents are running in the background. Board owners need a clear rule for which jobs deserve the expensive model.",
190
+ whyItMatters: "Premium model usage tends to become invisible once agents are running in the background. Spend owners need a clear rule for which jobs deserve the expensive model.",
151
191
  nextAction: `Audit the top ${topModel.key} operations and move low-risk summarization, extraction, and draft work to a cheaper model tier first.`,
152
192
  priority: "high",
153
- estimatedImpactUsd: roundMoney(topModel.amountUsd * impactRatios.modelDowngrade),
193
+ // The high-level recommendation does not know which model-specific rule
194
+ // will pass quality verification. Dollar math lives in the exact cut
195
+ // candidate; concentration alone earns no flat percentage.
196
+ estimatedImpactUsd: 0,
154
197
  confidence: topModel.confidence,
155
198
  relatedKeys: [topModel.key]
156
199
  });
157
200
  }
158
- const highInputTokenRecords = records.filter((record) => record.inputTokens >= 100_000);
201
+ const highInputTokenRecords = decisionRecords.filter((record) => record.inputTokens >= 100_000);
159
202
  if (highInputTokenRecords.length > 0) {
160
203
  recommendations.push({
161
204
  id: "prompt-context-trimming",
162
- title: "Trim large prompts and retrieved context",
163
- rationale: "High input-token calls suggest prompt or retrieval context may be oversized.",
164
- whyItMatters: "Context bloat compounds across every agent run and can make spend rise even when output quality does not improve.",
165
- nextAction: "Sample the largest prompts, cap retrieval chunks, and require justification before agents include full documents or long histories.",
205
+ title: "Inspect large prompts and retrieved context",
206
+ rationale: "High-input call records identify context exposure, but no matched reduction counterfactual is attached.",
207
+ whyItMatters: "Large context can consume budget, but token volume alone does not prove which context is removable or what quality tradeoff a cut would cause.",
208
+ nextAction: "Inspect the largest prompts locally and run a matched before/after with the same output-acceptance criteria before applying a broader limit.",
166
209
  priority: "high",
167
- estimatedImpactUsd: roundMoney(sumRecords(highInputTokenRecords) * impactRatios.promptTrimming),
210
+ estimatedImpactUsd: 0,
168
211
  confidence: combinedConfidence(highInputTokenRecords.map((record) => record.costConfidence)),
169
212
  relatedKeys: unique(highInputTokenRecords.map((record) => record.model))
170
213
  });
171
214
  }
172
- // Session aggregates from local agent logs share one operation label per
173
- // agent that's aggregation, not repetition, and a result cache is not a
174
- // real lever for interactive coding sessions. Exclude them here.
175
- const cacheableRecords = records.filter((record) => record.providerCostType !== "local_agent_logs");
176
- const repeatedOperations = repeatedValues(cacheableRecords.map((record) => record.operation).filter(isPresent));
177
- if (repeatedOperations.length > 0) {
215
+ // An operation label alone does not prove identical inputs. Require an
216
+ // adapter-provided stable fingerprint repeated within the same
217
+ // provider/model/operation cohort before presenting a cache candidate.
218
+ const cacheKeys = decisionRecords.map(cacheEvidenceKey).filter(isPresent);
219
+ const repeatedCacheKeys = repeatedValues(cacheKeys);
220
+ const cacheableRecords = cacheAvoidableRecords(decisionRecords, repeatedCacheKeys);
221
+ if (cacheableRecords.length > 0) {
222
+ const repeatedOperations = unique(cacheableRecords.map((record) => record.operation).filter(isPresent));
178
223
  recommendations.push({
179
224
  id: "caching",
180
225
  title: "Cache repeated operations",
181
- rationale: "Repeated operation labels are present in the sample and may be cacheable.",
226
+ rationale: "The same adapter-provided stable input fingerprint repeats within a provider/model workload.",
182
227
  whyItMatters: "Repeated AI calls are the easiest spend to defend cutting because they usually do not change the customer experience.",
183
228
  nextAction: "Add a local cache or memoization policy for repeated operation labels before expanding this workflow to more clients.",
184
229
  priority: "medium",
185
- estimatedImpactUsd: roundMoney(sumRecords(cacheableRecords.filter((record) => repeatedOperations.includes(record.operation ?? ""))) * impactRatios.caching),
230
+ estimatedImpactUsd: roundMoney(sumRecords(cacheableRecords)),
186
231
  confidence: combinedConfidence(cacheableRecords.map((record) => record.costConfidence)),
187
232
  relatedKeys: repeatedOperations
188
233
  });
189
234
  }
190
- const agentSpend = breakdown(records, (record) => record.agentId);
235
+ const agentSpend = breakdown(decisionRecords, (record) => record.agentId);
191
236
  const topAgent = agentSpend[0];
192
237
  if (topAgent && topAgent.amountUsd >= 25) {
193
238
  recommendations.push({
194
239
  id: "agent-caps",
195
- title: "Set local spend caps for the highest-cost agent",
240
+ title: "Confirm the owner and budget for the highest-cost agent",
196
241
  rationale: `${topAgent.key} accounts for a material share of sampled usage.`,
197
- whyItMatters: "An autonomous agent can quietly turn one bad loop or broad task into a budget issue before anyone reviews the invoice.",
198
- nextAction: `Set a warning threshold and hard cap for ${topAgent.key}, then require approval when a run exceeds its expected range.`,
242
+ whyItMatters: "Concentration is an accountability signal, but it does not by itself prove abnormal behavior or an avoidable dollar amount.",
243
+ nextAction: `Confirm ${topAgent.key}'s owner and approved range, then collect run-level evidence before proposing a warning threshold or hard cap.`,
199
244
  priority: "high",
200
- estimatedImpactUsd: roundMoney(topAgent.amountUsd * impactRatios.agentCaps),
245
+ estimatedImpactUsd: 0,
201
246
  confidence: topAgent.confidence,
202
247
  relatedKeys: [topAgent.key]
203
248
  });
204
249
  }
205
- if (records.length >= 8) {
250
+ const batchableRecords = decisionRecords.filter((record) => record.workloadSemantics?.batchEligible === true &&
251
+ batchCostRetainedByProvider[record.source.provider] !== undefined);
252
+ if (batchableRecords.length >= 3) {
206
253
  recommendations.push({
207
254
  id: "batching",
208
255
  title: "Batch low-latency-tolerant work",
209
- rationale: "The sample contains enough discrete calls to review for batching opportunities.",
256
+ rationale: "At least three call-level records are explicitly attested as latency-tolerant and Batch-eligible.",
210
257
  whyItMatters: "Batching turns scattered background calls into an intentional queue, which makes spend easier to forecast and approve.",
211
258
  nextAction: "Mark jobs that do not need immediate responses and run them in scheduled batches with a shared context budget.",
212
259
  priority: "medium",
213
- estimatedImpactUsd: roundMoney(sumRecords(records) * impactRatios.batching),
214
- confidence: combinedConfidence(records.map((record) => record.costConfidence)),
215
- relatedKeys: ["usage-records"]
216
- });
217
- }
218
- const sources = unique(records.map((record) => record.source.id));
219
- if (sources.length > 1) {
220
- recommendations.push({
221
- id: "routing",
222
- title: "Route workloads by price and quality requirements",
223
- rationale: "Multiple AI providers are represented, so routing policy can reduce avoidable spend.",
224
- whyItMatters: "Without routing policy, teams pay premium prices for tasks where cheaper models or providers would be good enough.",
225
- nextAction: "Define default provider/model tiers for extraction, drafting, research, and high-stakes reasoning, then measure quality deltas.",
226
- priority: "medium",
227
- estimatedImpactUsd: roundMoney(sumRecords(records) * impactRatios.routing),
228
- confidence: combinedConfidence(records.map((record) => record.costConfidence)),
229
- relatedKeys: sources
260
+ estimatedImpactUsd: roundMoney(batchableRecords.reduce((total, record) => {
261
+ const retained = batchCostRetainedByProvider[record.source.provider];
262
+ return total + (record.amountUsd ?? 0) * (1 - retained);
263
+ }, 0)),
264
+ confidence: combinedConfidence(batchableRecords.map((record) => record.costConfidence)),
265
+ relatedKeys: unique(batchableRecords.map((record) => record.operation).filter(isPresent))
230
266
  });
231
267
  }
232
268
  return recommendations;
@@ -271,21 +307,37 @@ function repeatedValues(values) {
271
307
  .map(([value]) => value)
272
308
  .sort();
273
309
  }
310
+ function cacheEvidenceKey(record) {
311
+ const fingerprint = record.workloadSemantics?.stableInputFingerprint;
312
+ if (!record.operation || !fingerprint)
313
+ return undefined;
314
+ return JSON.stringify([record.source.provider, record.model, record.operation, fingerprint]);
315
+ }
316
+ function cacheAvoidableRecords(records, repeatedKeys) {
317
+ const groups = new Map();
318
+ for (const record of records) {
319
+ const key = cacheEvidenceKey(record);
320
+ if (!key || !repeatedKeys.includes(key))
321
+ continue;
322
+ groups.set(key, [...(groups.get(key) ?? []), record]);
323
+ }
324
+ return [...groups.values()].flatMap((group) => [...group]
325
+ .sort((left, right) => left.timestamp.localeCompare(right.timestamp) || left.id.localeCompare(right.id))
326
+ .slice(1));
327
+ }
274
328
  function unique(values) {
275
329
  return [...new Set(values)].sort();
276
330
  }
277
331
  function isPresent(value) {
278
332
  return value !== undefined;
279
333
  }
280
- function workflowOptimizationFor(workflowKey, agentId) {
281
- const normalized = workflowKey.toLowerCase();
282
- if (normalized.includes("research") || normalized.includes("summary")) {
283
- return `Cap context for ${workflowKey}, cache repeated research inputs, and route first-pass summaries from ${agentId} to a cheaper model tier unless confidence drops.`;
284
- }
285
- if (normalized.includes("draft") || normalized.includes("copy")) {
286
- return `Move first-draft generation for ${workflowKey} to a cheaper model tier, keep premium review only for final approval, and cache brand/context blocks.`;
287
- }
288
- return `Add a per-run budget cap for ${workflowKey}, route low-risk calls to a cheaper model tier, and cache stable inputs before expanding ${agentId}.`;
334
+ function isLocalAgentRecord(record) {
335
+ return record.providerCostType === "local_agent_logs";
336
+ }
337
+ function workflowDiagnosticFor(workflowKey, agentId, hasRunLevelEvidence) {
338
+ return hasRunLevelEvidence
339
+ ? `Confirm the owner and approved budget for ${workflowKey} (${agentId}), reconcile the observed spend, and define one reversible candidate with an accepted-outcome quality bar before approval.`
340
+ : `Confirm the owner and approved budget for ${workflowKey} (${agentId}), reconcile the observed spend, and collect call-level provenance before proposing a reversible optimization.`;
289
341
  }
290
342
  function slugify(value) {
291
343
  return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
@@ -26,7 +26,7 @@ export function attributeUsageRecords(records) {
26
26
  }
27
27
  function buildCandidates(record) {
28
28
  const candidates = [];
29
- if (record.projectId) {
29
+ if (record.projectId && !isPlaceholderEntityId(record.projectId)) {
30
30
  candidates.push({
31
31
  entityType: "project",
32
32
  entityId: record.projectId,
@@ -34,7 +34,7 @@ function buildCandidates(record) {
34
34
  evidence: [`usage record includes projectId ${record.projectId}`]
35
35
  });
36
36
  }
37
- if (record.clientId) {
37
+ if (record.clientId && !isPlaceholderEntityId(record.clientId)) {
38
38
  candidates.push({
39
39
  entityType: "client",
40
40
  entityId: record.clientId,
@@ -42,7 +42,7 @@ function buildCandidates(record) {
42
42
  evidence: [`usage record includes clientId ${record.clientId}`]
43
43
  });
44
44
  }
45
- if (record.agentId) {
45
+ if (record.agentId && !isPlaceholderEntityId(record.agentId)) {
46
46
  candidates.push({
47
47
  entityType: "agent",
48
48
  entityId: record.agentId,
@@ -50,7 +50,7 @@ function buildCandidates(record) {
50
50
  evidence: [`usage record includes agentId ${record.agentId}`]
51
51
  });
52
52
  }
53
- if (record.userId) {
53
+ if (record.userId && !isPlaceholderEntityId(record.userId)) {
54
54
  candidates.push({
55
55
  entityType: "user",
56
56
  entityId: record.userId,
@@ -58,7 +58,7 @@ function buildCandidates(record) {
58
58
  evidence: [`usage record includes userId ${record.userId}`]
59
59
  });
60
60
  }
61
- if (record.workspaceId) {
61
+ if (record.workspaceId && !isPlaceholderEntityId(record.workspaceId)) {
62
62
  candidates.push({
63
63
  entityType: "workspace",
64
64
  entityId: record.workspaceId,
@@ -66,7 +66,7 @@ function buildCandidates(record) {
66
66
  evidence: [`usage record includes workspaceId ${record.workspaceId}`]
67
67
  });
68
68
  }
69
- if (record.apiKeyId) {
69
+ if (record.apiKeyId && !isPlaceholderEntityId(record.apiKeyId)) {
70
70
  candidates.push({
71
71
  entityType: "api_key",
72
72
  entityId: record.apiKeyId,
@@ -103,6 +103,9 @@ function buildCandidates(record) {
103
103
  }
104
104
  return dedupeCandidates(candidates);
105
105
  }
106
+ function isPlaceholderEntityId(value) {
107
+ return ["(home)", "home", "unknown", "unattributed", "unmapped", "(unmapped)"].includes(value.trim().toLowerCase());
108
+ }
106
109
  function dedupeCandidates(candidates) {
107
110
  const byKey = new Map();
108
111
  for (const candidate of candidates) {
@@ -1,6 +1,6 @@
1
1
  import { type AgentInventoryOptions, type AgentInventoryResult, type InventoryItem } from "./agentInventory.js";
2
2
  import { type DeadContextResult } from "./deadContext.js";
3
- import type { LocalAgentCall } from "./localAgentLogs.js";
3
+ import type { LocalAgentCall, LocalAgentTurnUsage } from "./localAgentLogs.js";
4
4
  import { type InvocationSummary, type ToolInvocationOptions } from "./toolInvocations.js";
5
5
  export type ContextHealthStatus = "healthy" | "watch" | "start_fresh" | "insufficient_data";
6
6
  export type ContextHealthRecommendation = "continue" | "start_fresh" | "review_hooks" | "trim_dead_context" | "collect_more_history";
@@ -22,9 +22,15 @@ export type ContextHealthResult = {
22
22
  status: "active" | "recent";
23
23
  agent: LocalAgentCall["agent"];
24
24
  project?: string;
25
+ /** Latest observed turn total, never cumulative session lifetime usage. */
25
26
  totalTokens: number;
27
+ /** Latest observed input-side context, including cached input. */
28
+ contextTokens: number;
29
+ usageSource: LocalAgentTurnUsage["source"] | "not_available";
26
30
  ratioToMedian: number | null;
31
+ ratioCapped: boolean;
27
32
  comparisonSessions: number;
33
+ comparisonBasis: "same_project_and_session_type" | "same_session_type" | "not_available";
28
34
  cacheWriteTokens: number;
29
35
  cacheWriteRatioToMedian: number | null;
30
36
  source: "local_transcript_metadata";
@@ -34,6 +40,9 @@ export type ContextHealthResult = {
34
40
  explicitlyInvokedItems: number;
35
41
  hookInjectedItems: number;
36
42
  lifecycleHooks: number;
43
+ mcpConfiguredItems: number;
44
+ mcpAlwaysLoadedItems: number;
45
+ /** Legacy adapter state; current local inventory does not infer this. */
37
46
  mcpSchemaLoadedItems: number;
38
47
  unmeasuredItems: number;
39
48
  invocationUnobservableItems: number;