@agent-finops/core 0.9.5 → 0.9.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/README.md CHANGED
@@ -41,9 +41,9 @@ keep provenance and confidence labels truthful: reserve `verified` for
41
41
  official provider-reported financial evidence, keep modeled/local value
42
42
  `estimated` or `missing`, and leave unvalidated adapters `untested`.
43
43
 
44
- This is the open foundation for aibill's financial-accountability mission. The
45
- published v0.9.1 package includes contracts for locally confirmed ownership,
46
- local self-attested approvals, and opt-in accepted GitHub outcomes. Those are
44
+ This is the open foundation for aibill's financial-accountability mission. This
45
+ package includes contracts for locally confirmed ownership, local
46
+ self-attested approvals, and opt-in accepted GitHub outcomes. Those are
47
47
  not company-wide identity, RBAC, approval routing, invoice reconciliation, or
48
48
  verified business ROI.
49
49
 
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { localAgentFormatDescriptor } from "./localAgentFormats/registry.js";
3
3
  import { MAX_TOKEN_EXPERIMENT_SESSIONS_PER_PHASE_V0, MAX_WASTE_FINDING_EVIDENCE_REFS_V0, TOKEN_REDUCTION_EXPERIMENT_V0_KIND, TOKEN_REDUCTION_EXPERIMENT_V0_VERSION, WASTE_FINDING_V0_KIND, WASTE_FINDING_V0_VERSION, createActionVerificationReference, createTokenReductionExperimentV0, createWasteFindingV0 } from "./actionVerification.js";
4
+ import { safeUntrustedLabel, WITHHELD_FILE_LABEL } from "./untrustedLabel.js";
4
5
  const MINIMUM_SESSIONS = 3;
5
6
  const CONTEXT_RATIO_THRESHOLD = 1.5;
6
7
  const FRESH_MS = 72 * 60 * 60 * 1_000;
@@ -196,7 +197,9 @@ export function resolveWasteFindingTargetV0(input) {
196
197
  status: "resolved",
197
198
  kind: "repeated_read_file",
198
199
  ref: finding.target.ref,
199
- file: match.file,
200
+ // Already neutralized upstream; re-applied because a resolved target
201
+ // is written into the Apply artifact an agent reads.
202
+ file: safeUntrustedLabel(match.file, WITHHELD_FILE_LABEL),
200
203
  readCount: match.readCount,
201
204
  localOnly: true
202
205
  }
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { aggregateCalls, dedupeCumulativeSessionCalls } from "./localAgentLogs.js";
3
3
  import { localAgentFormatDescriptors } from "./localAgentFormats/registry.js";
4
- import { canPriceTokenUsageAtScope, estimateTokenCostUsd, PRICING_TABLE_AS_OF } from "./modelPricing.js";
4
+ import { canPriceTokenUsageAtScope, estimateTokenCostsUsd, PRICING_TABLE_AS_OF } from "./modelPricing.js";
5
5
  import { subscriptionPlans } from "./planMath.js";
6
6
  import { isBundledSampleUsage } from "./schema.js";
7
7
  import { sourceValidationCoverageValues } from "./sourceStatus.js";
@@ -937,13 +937,22 @@ function apiEquivalentObservations(records, calls, trustedProviderIds) {
937
937
  }
938
938
  return observations;
939
939
  }
940
+ /**
941
+ * One call's API-equivalent cost with its tier taken from the largest single
942
+ * request it contains, matching the report's aggregation. Weighting a
943
+ * session-cumulative slice at its own cache-inflated prompt would put it on the
944
+ * wrong tier and skew the allocation.
945
+ */
946
+ function callAmountUsd(call) {
947
+ return estimateTokenCostsUsd(call.model, [call.usage], [call.maxRequestPromptTokens]);
948
+ }
940
949
  function allocateAggregateAmount(amountUsd, calls) {
941
950
  if (amountUsd === null)
942
951
  return calls.map(() => null);
943
- const priceable = calls.map((call) => canPriceTokenUsageAtScope(call.model, call.usage, call.usageScope === "turn" ? "request" : "aggregate") && estimateTokenCostUsd(call.model, call.usage) !== undefined);
952
+ const priceable = calls.map((call) => canPriceTokenUsageAtScope(call.model, call.usage, call.usageScope === "turn" ? "request" : "aggregate", call.maxRequestPromptTokens) && callAmountUsd(call) !== undefined);
944
953
  if (priceable.some((supported) => !supported))
945
954
  return calls.map(() => null);
946
- const weights = calls.map((call) => estimateTokenCostUsd(call.model, call.usage) ?? 0);
955
+ const weights = calls.map((call) => callAmountUsd(call) ?? 0);
947
956
  const totalWeight = weights.reduce((sum, weight) => sum + weight, 0);
948
957
  if (totalWeight <= 0) {
949
958
  return amountUsd === 0 ? calls.map(() => 0) : calls.map(() => null);
package/dist/analyze.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { generateSpendInsights } from "./insights.js";
2
+ import { safeUntrustedLabel, safeUntrustedLabels, WITHHELD_AGENT_LABEL, WITHHELD_ENTITY_LABEL, WITHHELD_OPERATION_LABEL } from "./untrustedLabel.js";
2
3
  import { costConfidenceValues, hasModeledWorkloadEvidence, hasPricedEvidence, spendComparisonKey, spendSummarySchema } from "./schema.js";
3
4
  const confidenceRank = {
4
5
  verified: 0,
@@ -154,11 +155,27 @@ export function generateWorkflowWatch(records) {
154
155
  const hasRunLevelEvidence = groupRecords.every(hasModeledWorkloadEvidence);
155
156
  const suggestedOptimization = workflowDiagnosticFor(workflowKey, agentId, hasRunLevelEvidence);
156
157
  return {
157
- id: slugify(["workflow", clientId, projectId, workflowKey].join("-")),
158
- clientId,
159
- projectId,
160
- workflowKey,
161
- agentId,
158
+ // The id is a STRUCTURED field beside the neutralized prose and it is
159
+ // rendered. Slug the neutralized forms, not the raw grouping keys.
160
+ id: slugify([
161
+ "workflow",
162
+ safeEntity(clientId),
163
+ safeEntity(projectId),
164
+ safeWorkflowLabel(workflowKey)
165
+ ].join("-")),
166
+ // Neutralized, not raw. These fields ship inside the SpendSummary that
167
+ // the MCP tools hand to an agent, so leaving them raw beside neutralized
168
+ // prose is the same inversion as the repeated-read array: the human sees
169
+ // the redaction and the agent gets the payload.
170
+ //
171
+ // workflowWatchAmount re-matches these against the records to recompute
172
+ // a display amount. An ordinary name is byte-identical, so that match is
173
+ // unaffected; a name that reads like an instruction loses its amount,
174
+ // which is the right way round.
175
+ clientId: safeEntity(clientId),
176
+ projectId: safeEntity(projectId),
177
+ workflowKey: safeWorkflowLabel(workflowKey),
178
+ agentId: safeUntrustedLabel(agentId, WITHHELD_AGENT_LABEL),
162
179
  amountUsd,
163
180
  shareOfSpend,
164
181
  recordCount: groupRecords.length,
@@ -168,8 +185,8 @@ export function generateWorkflowWatch(records) {
168
185
  suggestedOptimization,
169
186
  applyArtifact: `Before changing this workload: ${suggestedOptimization}`,
170
187
  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.`
188
+ ? `Reconcile ${safeWorkflowLabel(workflowKey)} to its owner and budget, then define one reversible candidate and compare matched future accepted outcomes plus provider-reported cost.`
189
+ : `Reconcile ${safeWorkflowLabel(workflowKey)} to its owner and budget, then collect call-level workload evidence before modeling or applying a cost change.`
173
190
  };
174
191
  })
175
192
  .filter((entry) => entry.amountUsd > 0)
@@ -186,16 +203,16 @@ export function generateRecommendations(records) {
186
203
  recommendations.push({
187
204
  id: "model-downgrade",
188
205
  title: "Review expensive model workloads for downgrade candidates",
189
- rationale: `${topModel.key} is the largest cost driver in the current local sample.`,
206
+ rationale: `${safeEntity(topModel.key)} is the largest cost driver in the current local sample.`,
190
207
  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.",
191
- nextAction: `Audit the top ${topModel.key} operations and move low-risk summarization, extraction, and draft work to a cheaper model tier first.`,
208
+ nextAction: `Audit the top ${safeEntity(topModel.key)} operations and move low-risk summarization, extraction, and draft work to a cheaper model tier first.`,
192
209
  priority: "high",
193
210
  // The high-level recommendation does not know which model-specific rule
194
211
  // will pass quality verification. Dollar math lives in the exact cut
195
212
  // candidate; concentration alone earns no flat percentage.
196
213
  estimatedImpactUsd: 0,
197
214
  confidence: topModel.confidence,
198
- relatedKeys: [topModel.key]
215
+ relatedKeys: [safeEntity(topModel.key)]
199
216
  });
200
217
  }
201
218
  const highInputTokenRecords = decisionRecords.filter((record) => record.inputTokens >= 100_000);
@@ -209,7 +226,7 @@ export function generateRecommendations(records) {
209
226
  priority: "high",
210
227
  estimatedImpactUsd: 0,
211
228
  confidence: combinedConfidence(highInputTokenRecords.map((record) => record.costConfidence)),
212
- relatedKeys: unique(highInputTokenRecords.map((record) => record.model))
229
+ relatedKeys: safeUntrustedLabels(unique(highInputTokenRecords.map((record) => record.model)))
213
230
  });
214
231
  }
215
232
  // An operation label alone does not prove identical inputs. Require an
@@ -229,7 +246,7 @@ export function generateRecommendations(records) {
229
246
  priority: "medium",
230
247
  estimatedImpactUsd: roundMoney(sumRecords(cacheableRecords)),
231
248
  confidence: combinedConfidence(cacheableRecords.map((record) => record.costConfidence)),
232
- relatedKeys: repeatedOperations
249
+ relatedKeys: safeUntrustedLabels(repeatedOperations, WITHHELD_OPERATION_LABEL)
233
250
  });
234
251
  }
235
252
  const agentSpend = breakdown(decisionRecords, (record) => record.agentId);
@@ -238,13 +255,13 @@ export function generateRecommendations(records) {
238
255
  recommendations.push({
239
256
  id: "agent-caps",
240
257
  title: "Confirm the owner and budget for the highest-cost agent",
241
- rationale: `${topAgent.key} accounts for a material share of sampled usage.`,
258
+ rationale: `${safeEntity(topAgent.key)} accounts for a material share of sampled usage.`,
242
259
  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.`,
260
+ nextAction: `Confirm ${safeEntity(topAgent.key)}'s owner and approved range, then collect run-level evidence before proposing a warning threshold or hard cap.`,
244
261
  priority: "high",
245
262
  estimatedImpactUsd: 0,
246
263
  confidence: topAgent.confidence,
247
- relatedKeys: [topAgent.key]
264
+ relatedKeys: [safeEntity(topAgent.key)]
248
265
  });
249
266
  }
250
267
  const batchableRecords = decisionRecords.filter((record) => record.workloadSemantics?.batchEligible === true &&
@@ -262,7 +279,7 @@ export function generateRecommendations(records) {
262
279
  return total + (record.amountUsd ?? 0) * (1 - retained);
263
280
  }, 0)),
264
281
  confidence: combinedConfidence(batchableRecords.map((record) => record.costConfidence)),
265
- relatedKeys: unique(batchableRecords.map((record) => record.operation).filter(isPresent))
282
+ relatedKeys: safeUntrustedLabels(unique(batchableRecords.map((record) => record.operation).filter(isPresent)), WITHHELD_OPERATION_LABEL)
266
283
  });
267
284
  }
268
285
  return recommendations;
@@ -335,9 +352,33 @@ function isLocalAgentRecord(record) {
335
352
  return record.providerCostType === "local_agent_logs";
336
353
  }
337
354
  function workflowDiagnosticFor(workflowKey, agentId, hasRunLevelEvidence) {
355
+ const workflow = safeWorkflowLabel(workflowKey);
356
+ const agent = safeUntrustedLabel(agentId, WITHHELD_AGENT_LABEL);
338
357
  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.`;
358
+ ? `Confirm the owner and approved budget for ${workflow} (${agent}), reconcile the observed spend, and define one reversible candidate with an accepted-outcome quality bar before approval.`
359
+ : `Confirm the owner and approved budget for ${workflow} (${agent}), reconcile the observed spend, and collect call-level provenance before proposing a reversible optimization.`;
360
+ }
361
+ /**
362
+ * The workflow key IS `record.operation` — a provider/adapter string, untrusted
363
+ * end to end. It reaches `suggestedOptimization`, `applyArtifact` and
364
+ * `verificationPlan`, and those three are rendered as product prose that the
365
+ * report layer deliberately never blanks. So it is neutralized here, at the
366
+ * interpolation point, exactly as the cut-list builders do.
367
+ *
368
+ * The ENTRY's own identity fields are neutralized too: they travel inside the
369
+ * SpendSummary that the MCP tools return, so a raw field sitting beside
370
+ * neutralized prose hands an agent exactly what the human was protected from.
371
+ */
372
+ function safeWorkflowLabel(workflowKey) {
373
+ return safeUntrustedLabel(workflowKey, WITHHELD_OPERATION_LABEL);
374
+ }
375
+ /**
376
+ * A breakdown key rendered for a HUMAN — a model id, an agent id, an
377
+ * operation label. Display only: the raw key is still what the record filters
378
+ * match on, and rewriting a matching key would empty the cohort.
379
+ */
380
+ function safeEntity(value) {
381
+ return safeUntrustedLabel(value, WITHHELD_ENTITY_LABEL);
341
382
  }
342
383
  function slugify(value) {
343
384
  return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
@@ -1,3 +1,4 @@
1
+ import { safeUntrustedLabel, WITHHELD_FILE_LABEL, WITHHELD_PROJECT_LABEL } from "./untrustedLabel.js";
1
2
  import { loadAgentInventory } from "./agentInventory.js";
2
3
  import { computeDeadContext } from "./deadContext.js";
3
4
  import { localAgentFormatSupports } from "./localAgentFormats/registry.js";
@@ -74,7 +75,9 @@ export function buildContextHealth(input = {}) {
74
75
  if ((contextChurn.repeatedReadEvents ?? 0) > 0) {
75
76
  const files = contextChurn.repeatedFiles
76
77
  .slice(0, 3)
77
- .map((file) => `${file.file} ×${file.readCount}`)
78
+ // Basenames, but a basename is still a name the user did not author,
79
+ // and this sentence is returned verbatim by the MCP context-health tool.
80
+ .map((file) => `${safeUntrustedLabel(file.file, WITHHELD_FILE_LABEL)} ×${file.readCount}`)
78
81
  .join(", ");
79
82
  evidence.push({
80
83
  kind: "context_churn",
@@ -231,6 +234,9 @@ function contextDecision(input) {
231
234
  const MIN_EXACT_COMPARISONS = 2;
232
235
  const MIN_SESSION_TYPE_COMPARISONS = 2;
233
236
  const MAX_DISPLAY_RATIO = 20;
237
+ function safeOptionalProject(value) {
238
+ return value === undefined ? undefined : safeUntrustedLabel(value, WITHHELD_PROJECT_LABEL);
239
+ }
234
240
  function buildCurrentSession(sessions, now, activeWithinMinutes) {
235
241
  const latest = latestContextSession(sessions);
236
242
  if (!latest)
@@ -273,7 +279,9 @@ function buildCurrentSession(sessions, now, activeWithinMinutes) {
273
279
  return {
274
280
  status: ageMs <= activeWithinMinutes * 60_000 ? "active" : "recent",
275
281
  agent: latest.agent,
276
- project: latest.project,
282
+ project: latest.project === undefined
283
+ ? undefined
284
+ : safeUntrustedLabel(latest.project, WITHHELD_PROJECT_LABEL),
277
285
  totalTokens: latest.totalTokens,
278
286
  contextTokens: latest.contextTokens,
279
287
  usageSource: latest.usageSource,
@@ -308,7 +316,7 @@ function contextSessions(calls) {
308
316
  return {
309
317
  key,
310
318
  agent: latest.agent,
311
- project: latest.project ?? ordered[0]?.project,
319
+ project: safeOptionalProject(latest.project ?? ordered[0]?.project),
312
320
  lastActivityAt: latest.timestamp,
313
321
  totalTokens: turnUsage?.totalTokens ?? 0,
314
322
  contextTokens: turnUsage?.contextTokens ?? 0,
@@ -350,8 +358,11 @@ function buildContextChurn(latest, invocations) {
350
358
  signal.sessionId &&
351
359
  latest.key === `${signal.agent}:${signal.sessionId}`))
352
360
  : undefined;
361
+ // Neutralized at the source in toolInvocations.ts; re-applied here because a
362
+ // signal can also arrive from a cache written by an earlier version, and
363
+ // because this array is handed to an agent verbatim by the MCP tool.
353
364
  const repeatedFiles = (currentSignal?.repeatedFileReads ?? [])
354
- .map((file) => ({ file: file.name, readCount: file.count }));
365
+ .map((file) => ({ file: safeUntrustedLabel(file.name, WITHHELD_FILE_LABEL), readCount: file.count }));
355
366
  return {
356
367
  currentSessionEvidence: !latest
357
368
  ? "no_current_session"
package/dist/cutList.d.ts CHANGED
@@ -40,7 +40,30 @@ export type CutAction = {
40
40
  * by two actions (see {@link buildRecommendedPlan}).
41
41
  */
42
42
  recordIds: string[];
43
+ /**
44
+ * Median DAY's summed input+cache tokens for this candidate, computed over
45
+ * calendar days (not over records — a project running two models emits two
46
+ * records per day, and calling that "per day" would halve the number).
47
+ *
48
+ * Exists so a grouped render can tell two members apart by the quantity that
49
+ * explains their dollars instead of by the rounded dollar alone. Optional and
50
+ * absent on candidates with no day-level evidence; every renderer MUST drop
51
+ * the figure rather than print a placeholder.
52
+ */
53
+ medianDailyInputTokens?: number;
43
54
  };
55
+ /**
56
+ * Compact token count for prose: 2,140,000 -> "2.1M", 8,300 -> "8.3K".
57
+ * Lives in core because the cut-list guidance strings are built here, and is
58
+ * re-used by the renderers so one candidate's token magnitude reads the same
59
+ * on every surface.
60
+ *
61
+ * The ladder runs to T. It used to stop at B, so a fleet-scale window printed
62
+ * "4212.7B" — a number the reader has to count digits on to place, from a
63
+ * product whose whole claim is arithmetic you can read at a glance. Past T the
64
+ * mantissa gets thousands separators rather than a fourteenth silent digit.
65
+ */
66
+ export declare function formatTokenCount(tokens: number): string;
44
67
  /**
45
68
  * A non-overlapping "recommended plan" plus the leftover overlapping
46
69
  * opportunities. The recommended-plan total is the only savings number safe to
package/dist/cutList.js CHANGED
@@ -1,5 +1,37 @@
1
1
  import { hasCallLevelProvenance, hasModeledWorkloadEvidence, hasPricedEvidence } from "./schema.js";
2
2
  import { localAgentFormatSupports } from "./localAgentFormats/registry.js";
3
+ import { safeUntrustedLabel, WITHHELD_AGENT_LABEL, WITHHELD_MODEL_LABEL, WITHHELD_OPERATION_LABEL, WITHHELD_PROJECT_LABEL } from "./untrustedLabel.js";
4
+ /**
5
+ * Compact token count for prose: 2,140,000 -> "2.1M", 8,300 -> "8.3K".
6
+ * Lives in core because the cut-list guidance strings are built here, and is
7
+ * re-used by the renderers so one candidate's token magnitude reads the same
8
+ * on every surface.
9
+ *
10
+ * The ladder runs to T. It used to stop at B, so a fleet-scale window printed
11
+ * "4212.7B" — a number the reader has to count digits on to place, from a
12
+ * product whose whole claim is arithmetic you can read at a glance. Past T the
13
+ * mantissa gets thousands separators rather than a fourteenth silent digit.
14
+ */
15
+ export function formatTokenCount(tokens) {
16
+ if (!Number.isFinite(tokens))
17
+ return "0";
18
+ const value = Math.max(0, tokens);
19
+ if (value >= 1_000_000_000_000)
20
+ return `${formatMantissa(value / 1_000_000_000_000)}T`;
21
+ if (value >= 1_000_000_000)
22
+ return `${(value / 1_000_000_000).toFixed(1)}B`;
23
+ if (value >= 1_000_000)
24
+ return `${(value / 1_000_000).toFixed(1)}M`;
25
+ if (value >= 1_000)
26
+ return `${(value / 1_000).toFixed(1)}K`;
27
+ return String(Math.round(value));
28
+ }
29
+ /** Top of the ladder: keep one decimal until the integer part needs commas. */
30
+ function formatMantissa(value) {
31
+ return value >= 1_000
32
+ ? Math.round(value).toLocaleString("en-US")
33
+ : value.toFixed(1);
34
+ }
3
35
  /**
4
36
  * Select a non-overlapping subset of cut actions, highest-savings first. An
5
37
  * action is added only if none of its records were already claimed by a
@@ -126,10 +158,18 @@ function modelDowngradeActions(records) {
126
158
  const affectedSpendUsd = roundMoney(sumRecords(groupRecords));
127
159
  const windowSavings = affectedSpendUsd * (1 - rule.costRetained);
128
160
  const monthlySavings = roundMoney(toMonthly(windowSavings, window));
161
+ // Both untrusted. The model gate is anchored but open-ended
162
+ // (`^claude-opus-4(?:[.-].*)?$` accepts anything after the dash), and
163
+ // `downgradeSafeOperation` is a SUBSTRING allowlist — "summary" anywhere
164
+ // in the label passes the whole label through. Neutralize before either
165
+ // reaches a title or an instruction. IDs keep the raw values: an id is
166
+ // identity, not display, and it is rendered through a blanking guard.
167
+ const modelLabel = safeUntrustedLabel(model, WITHHELD_MODEL_LABEL);
168
+ const operationLabel = safeUntrustedLabel(operation, WITHHELD_OPERATION_LABEL);
129
169
  actions.push({
130
170
  id: `downgrade-${slug(model)}-${slug(operation)}`,
131
- title: `Move ${model} ${operation} calls to ${target}`,
132
- action: `Route ${groupRecords.length} ${operation} call${groupRecords.length === 1 ? "" : "s"} from ${model} to ${target} (keep ${model} only when output is rejected).`,
171
+ title: `Move ${modelLabel} ${operationLabel} calls to ${target}`,
172
+ action: `Route ${groupRecords.length} ${operationLabel} call${groupRecords.length === 1 ? "" : "s"} from ${modelLabel} to ${target} (keep ${modelLabel} only when output is rejected).`,
133
173
  estimatedMonthlySavingsUsd: monthlySavings,
134
174
  affectedSpendUsd,
135
175
  recordCount: groupRecords.length,
@@ -155,6 +195,30 @@ function contextTrimActions(records) {
155
195
  : `connected::${operation}`;
156
196
  byOperation.set(key, [...(byOperation.get(key) ?? []), record]);
157
197
  }
198
+ // ONE denominator, and it is the set the sentence is already talking about:
199
+ // this agent's OWN flagged projects. It is both the share's denominator and
200
+ // the rank clause's population, so the percentage, the rank, and the entry's
201
+ // own dollars two lines below all reconcile, and the members of one fan-out
202
+ // sum to 100%.
203
+ //
204
+ // 0.9.7 shipped three denominators in one sentence — a machine-wide local
205
+ // total for the share, this agent's flagged projects for the rank, and the
206
+ // entry's own total underneath. Every figure was individually true, which is
207
+ // exactly why the sentence read as an arithmetic error. Never reintroduce a
208
+ // denominator the sentence does not name.
209
+ const flaggedSpendByAgent = new Map();
210
+ for (const [key, groupRecords] of byOperation) {
211
+ const [scope, agentId] = key.split("::");
212
+ if (scope !== "local" || !agentId)
213
+ continue;
214
+ // roundMoney, matching `affectedSpendUsd` exactly: comparing a rounded
215
+ // group against unrounded peers made a candidate outrank ITSELF and
216
+ // report "rank 2 of 8" for the largest project on the list.
217
+ flaggedSpendByAgent.set(agentId, [
218
+ ...(flaggedSpendByAgent.get(agentId) ?? []),
219
+ roundMoney(sumRecords(groupRecords))
220
+ ]);
221
+ }
158
222
  const actions = [];
159
223
  for (const [key, groupRecords] of byOperation) {
160
224
  const affectedSpendUsd = roundMoney(sumRecords(groupRecords));
@@ -164,7 +228,26 @@ function contextTrimActions(records) {
164
228
  : key.replace(/^connected::/, "");
165
229
  const count = groupRecords.length;
166
230
  const agent = groupRecords[0]?.agentId ?? "coding-agent";
231
+ // `agent` stays raw as the map key and the id slug; only the DISPLAY
232
+ // form is neutralized. Bounded to the adapter enum for local rows in
233
+ // practice, but nothing in the type system enforces that.
234
+ const agentLabel = safeUntrustedLabel(agent, WITHHELD_AGENT_LABEL);
167
235
  const project = groupRecords[0]?.projectId;
236
+ const evidence = sessionAggregates ? dailyContextEvidence(groupRecords) : null;
237
+ const flaggedSpend = flaggedSpendByAgent.get(agent) ?? [];
238
+ // The project label is UNTRUSTED — it is a directory name off the user's
239
+ // disk. Neutralize it ONCE, here, so the title, the guidance, and every
240
+ // surface that re-derives a label from the title all carry the same
241
+ // already-safe text and cannot disagree about it.
242
+ const projectLabel = project
243
+ ? safeUntrustedLabel(project, WITHHELD_PROJECT_LABEL)
244
+ : "Unattributed";
245
+ // The connected path's operation label is a raw provider/adapter string
246
+ // with NO allowlist in front of it, and it lands in both the title and the
247
+ // instruction. It has to be neutralized here for the same reason the
248
+ // project label is: the report layer's prose sanitizer never blanks, and
249
+ // it is only safe to never blank because core neutralized first.
250
+ const operationLabel = safeUntrustedLabel(operation, WITHHELD_OPERATION_LABEL);
168
251
  // Large token volume proves exposure, not that context is removable or
169
252
  // what quality/cost delta a change would produce. Context remains an
170
253
  // inspect-only action until matched before/after evidence exists.
@@ -174,11 +257,18 @@ function contextTrimActions(records) {
174
257
  ? `inspect-context-${slug(agent)}-${slug(project ?? "unattributed")}`
175
258
  : `inspect-context-${slug(operation)}`,
176
259
  title: sessionAggregates
177
- ? `Investigate cumulative context in ${agent}${project ? ` · ${project}` : " · Unattributed"}`
178
- : `Inspect oversized context on ${operation}`,
260
+ ? `Investigate cumulative context in ${agentLabel} · ${projectLabel}`
261
+ : `Inspect oversized context on ${operationLabel}`,
179
262
  action: sessionAggregates
180
- ? `${count} day + agent + model + project aggregate${count === 1 ? "" : "s"} contained at least 100k summed input/cache tokens. Inspect per-session context, compactions, repeated reads, and measured instruction-file size before proposing one reversible change.`
181
- : `${count} call-level ${operation} record${count === 1 ? "" : "s"} exceeded 100k input tokens. Inspect retrieved chunks and prompt history, then run a matched before/after before claiming savings.`,
263
+ ? localContextTrimGuidance({
264
+ count,
265
+ agent: agentLabel,
266
+ projectLabel,
267
+ evidence,
268
+ groupSpendUsd: affectedSpendUsd,
269
+ flaggedSpend
270
+ })
271
+ : `${count} call-level ${operationLabel} record${count === 1 ? "" : "s"} exceeded 100k input tokens. Inspect retrieved chunks and prompt history, then run a matched before/after before claiming savings.`,
182
272
  estimatedMonthlySavingsUsd: monthlySavings,
183
273
  affectedSpendUsd,
184
274
  recordCount: count,
@@ -186,11 +276,172 @@ function contextTrimActions(records) {
186
276
  impactBasis: "observed_value_no_counterfactual",
187
277
  recordIds: groupRecords.map((record) => record.id),
188
278
  confidence: combinedConfidence(groupRecords.map((record) => record.costConfidence)),
189
- kind: "context_trim"
279
+ kind: "context_trim",
280
+ ...(evidence ? { medianDailyInputTokens: evidence.medianDailyInputTokens } : {})
190
281
  });
191
282
  }
192
283
  return actions;
193
284
  }
285
+ function dailyContextEvidence(records) {
286
+ if (records.length === 0)
287
+ return null;
288
+ const byDay = new Map();
289
+ for (const record of records) {
290
+ const day = record.timestamp.slice(0, 10);
291
+ const current = byDay.get(day) ?? { input: 0, output: 0 };
292
+ current.input += record.inputTokens;
293
+ current.output += record.outputTokens;
294
+ byDay.set(day, current);
295
+ }
296
+ const days = [...byDay.entries()].sort((left, right) => left[0].localeCompare(right[0]));
297
+ const first = days[0];
298
+ if (!first)
299
+ return null;
300
+ // ONE REAL DAY, not two independent medians. The sentence says "median day
301
+ // carried X input+cache tokens against Y output"; if X and Y came from
302
+ // different calendar days that sentence would describe a day that never
303
+ // happened. So: order the days by input+cache, take the middle OBSERVED day
304
+ // (the lower of the two middles on an even sample, never their average), and
305
+ // read every median figure off that one day.
306
+ const byInput = [...days].sort((left, right) => left[1].input - right[1].input || left[0].localeCompare(right[0]));
307
+ const medianDay = byInput[Math.floor((byInput.length - 1) / 2)];
308
+ const medianDailyInputTokens = medianDay[1].input;
309
+ const medianDailyOutputTokens = medianDay[1].output;
310
+ const peak = days.reduce((best, entry) => (entry[1].input > best[1].input ? entry : best), first);
311
+ const spendByModel = new Map();
312
+ for (const record of records) {
313
+ spendByModel.set(record.model, (spendByModel.get(record.model) ?? 0) + (record.amountUsd ?? 0));
314
+ }
315
+ return {
316
+ activeDays: days.length,
317
+ medianDailyInputTokens,
318
+ medianDailyOutputTokens,
319
+ peakDay: peak[0],
320
+ peakDayInputTokens: peak[1].input,
321
+ peakOverMedian: medianDailyInputTokens > 0 ? peak[1].input / medianDailyInputTokens : 0,
322
+ inputPerOutput: medianDailyOutputTokens > 0
323
+ ? medianDailyInputTokens / medianDailyOutputTokens
324
+ : null,
325
+ models: [...spendByModel.entries()]
326
+ .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
327
+ .map(([model]) => model)
328
+ };
329
+ }
330
+ /**
331
+ * The guidance a local context candidate carries.
332
+ *
333
+ * Two hard constraints shape it:
334
+ *
335
+ * 1. TRUTH. Every clause states something OBSERVED — a median, a date, a
336
+ * ratio, a share of a single-basis total. Nothing predicts a reduction,
337
+ * nothing prices a change, and no clause is emitted when the evidence
338
+ * behind it is absent. Sharper means more specific about what was seen,
339
+ * never more confident about what would happen.
340
+ *
341
+ * 2. GEOMETRY. The grouped terminal/report render quotes this string from its
342
+ * LARGEST member and drops everything before the first ". " (see
343
+ * `groupedCutActionLines` / `rankCutCandidates` in @agent-finops/report).
344
+ * So sentence 1 carries only per-member counts, and sentence 2 onward
345
+ * NAMES the project it describes — otherwise one project's median would be
346
+ * read as the whole group's. For the same reason sentence 1 interpolates
347
+ * no free text: a label containing ". " would truncate the string at the
348
+ * wrong point.
349
+ */
350
+ function localContextTrimGuidance(input) {
351
+ const { count, evidence } = input;
352
+ // Idempotent: `contextTrimActions` already neutralized the label for the
353
+ // title, and re-running the check on an already-neutral label is a no-op.
354
+ // Repeating it here keeps the guarantee attached to the function that does
355
+ // the interpolating, not to one of its callers.
356
+ const projectLabel = safeUntrustedLabel(input.projectLabel, WITHHELD_PROJECT_LABEL);
357
+ const plural = count === 1 ? "" : "s";
358
+ if (!evidence) {
359
+ // No day-level evidence to quote. Stay honest and short rather than
360
+ // reciting a checklist the product cannot perform.
361
+ return `${count} day + agent + model + project aggregate${plural} each carried at least 100k summed input/cache tokens. ` +
362
+ `Inspect the heaviest sessions in ${projectLabel} before proposing one reversible change.`;
363
+ }
364
+ const dayPlural = evidence.activeDays === 1 ? "" : "s";
365
+ const sentences = [
366
+ `${count} day + agent + model + project aggregate${plural} over ${evidence.activeDays} active day${dayPlural}.`
367
+ ];
368
+ const ratio = evidence.inputPerOutput === null ? null : formatRatio(evidence.inputPerOutput);
369
+ sentences.push(evidence.medianDailyOutputTokens > 0
370
+ ? `${projectLabel} — median day carried ${formatTokenCount(evidence.medianDailyInputTokens)} input+cache tokens ` +
371
+ `against ${formatTokenCount(evidence.medianDailyOutputTokens)} output${ratio ? ` (${ratio}:1)` : ""}.`
372
+ : `${projectLabel} — median day carried ${formatTokenCount(evidence.medianDailyInputTokens)} input+cache tokens ` +
373
+ "with no output tokens recorded that day.");
374
+ // Only when there is a real lead. A uniform project gets no false one.
375
+ if (evidence.peakOverMedian >= 2) {
376
+ sentences.push(`Heaviest day ${evidence.peakDay} carried ${formatTokenCount(evidence.peakDayInputTokens)}, ` +
377
+ `${formatRatio(evidence.peakOverMedian)}× the median day; dates are each session's last activity.`);
378
+ }
379
+ const share = concentrationClause(input);
380
+ if (share)
381
+ sentences.push(share);
382
+ if (evidence.models.length > 1) {
383
+ // Model ids come off the same untrusted logs the project name does.
384
+ const shown = evidence.models
385
+ .slice(0, 3)
386
+ .map((model) => safeUntrustedLabel(model, WITHHELD_MODEL_LABEL));
387
+ const rest = evidence.models.length - shown.length;
388
+ sentences.push(`${evidence.models.length} models ran there: ${shown.join(", ")}${rest > 0 ? ` and ${rest} more` : ""}.`);
389
+ }
390
+ sentences.push(evidence.peakOverMedian >= 2
391
+ ? `Inspect the sessions behind ${evidence.peakDay} before proposing one reversible change.`
392
+ : `Inspect the heaviest sessions in ${projectLabel} before proposing one reversible change.`);
393
+ return sentences.join(" ");
394
+ }
395
+ /**
396
+ * "…holds 76% of the flagged claude-code value observed in this window (rank 1
397
+ * of 8 flagged projects)." An accounting fact about ONE denominator — not a
398
+ * savings claim, not a projection.
399
+ *
400
+ * The denominator is this agent's own flagged total, which is also the rank
401
+ * clause's population and the sum of the per-project dollars the same readout
402
+ * prints two lines below. So the percentage, the rank, and the entry's own
403
+ * total reconcile, and the members of one fan-out sum to 100%.
404
+ *
405
+ * 0.9.7 divided by a machine-wide local total instead: `--full` printed a
406
+ * by-project table saying 83%, this sentence said 46%, and the entry's own
407
+ * dollars implied 76% — three true numbers that read as an arithmetic error.
408
+ * No clamp is needed now and none belongs here: the numerator is one member of
409
+ * the set in the denominator, so a ratio above 1 would be a real bug worth
410
+ * seeing rather than a cosmetic one worth hiding.
411
+ */
412
+ function concentrationClause(input) {
413
+ const { agent, groupSpendUsd, flaggedSpend } = input;
414
+ const flaggedTotalUsd = flaggedSpend.reduce((total, spend) => total + spend, 0);
415
+ if (!(flaggedTotalUsd > 0) || !(groupSpendUsd > 0))
416
+ return null;
417
+ const ratio = groupSpendUsd / flaggedTotalUsd;
418
+ if (!Number.isFinite(ratio) || ratio <= 0)
419
+ return null;
420
+ const percent = Math.round(ratio * 100);
421
+ const rank = flaggedSpend.filter((spend) => spend > groupSpendUsd).length + 1;
422
+ const total = flaggedSpend.length;
423
+ // A ceiling, mirroring the "under 1%" floor. The ordinary solo-dev shape is
424
+ // one main repo and two small side projects: at 99.5%–99.9% the rounded
425
+ // figure prints "100%" on the same screen as an across-line that lists two
426
+ // more flagged projects WITH DOLLARS — which reads as exactly the
427
+ // arithmetic error this sentence's denominator was fixed to kill. "over 99%"
428
+ // is the same fact without the contradiction. A lone flagged project keeps
429
+ // the literal 100%, because there is nothing beside it to contradict.
430
+ const share = percent < 1
431
+ ? "under 1%"
432
+ : percent >= 100 && total > 1 ? "over 99%" : `${percent}%`;
433
+ const rankClause = total > 1 ? ` (rank ${rank} of ${total} flagged projects)` : "";
434
+ return `That project holds ${share} of the flagged ${agent} value observed in this window${rankClause}.`;
435
+ }
436
+ /**
437
+ * "519", "4.4" — integers once the ratio is big enough that a decimal is noise.
438
+ * Shared by the input:output ratio and the "N× the median day" multiple so the
439
+ * two never disagree about precision. Separated above 999, because a bare
440
+ * "1904762:1" is a digit-counting exercise, not a figure.
441
+ */
442
+ function formatRatio(value) {
443
+ return value >= 10 ? Math.round(value).toLocaleString("en-US") : value.toFixed(1);
444
+ }
194
445
  function cacheActions(records) {
195
446
  const window = windowDays(records);
196
447
  const counts = new Map();
@@ -226,10 +477,13 @@ function cacheActions(records) {
226
477
  const affectedSpendUsd = roundMoney(sumRecords(groupRecords));
227
478
  const windowSavings = sumRecords(avoidableRecords);
228
479
  const monthlySavings = roundMoney(toMonthly(windowSavings, window));
480
+ // No allowlist stands in front of this one at all — any operation label
481
+ // with a stable fingerprint reaches the title and the instruction.
482
+ const operationLabel = safeUntrustedLabel(operation, WITHHELD_OPERATION_LABEL);
229
483
  actions.push({
230
484
  id: `cache-${slug(operation)}-${stableSuffix(key)}`,
231
- title: `Cache repeated ${operation} calls`,
232
- action: `Keep the earliest ${operation} call as the canonical miss and cache the ${avoidableRecords.length} subsequent call${avoidableRecords.length === 1 ? "" : "s"} with the same adapter-provided input fingerprint.`,
485
+ title: `Cache repeated ${operationLabel} calls`,
486
+ action: `Keep the earliest ${operationLabel} call as the canonical miss and cache the ${avoidableRecords.length} subsequent call${avoidableRecords.length === 1 ? "" : "s"} with the same adapter-provided input fingerprint.`,
233
487
  estimatedMonthlySavingsUsd: monthlySavings,
234
488
  affectedSpendUsd,
235
489
  recordCount: groupRecords.length,
@@ -274,10 +528,13 @@ function batchActions(records) {
274
528
  const retainedCost = batchCostRetainedByProvider[groupRecords[0].source.provider];
275
529
  const windowSavings = affectedSpendUsd * (1 - retainedCost);
276
530
  const monthlySavings = roundMoney(toMonthly(windowSavings, window));
531
+ // `batchSafeOperation` is a SUBSTRING allowlist: "summar" anywhere in the
532
+ // label admits the whole label, injected prose included.
533
+ const operationLabel = safeUntrustedLabel(operation, WITHHELD_OPERATION_LABEL);
277
534
  actions.push({
278
535
  id: `batch-${slug(operation)}-${stableSuffix(key)}`,
279
- title: `Move ${operation} calls to the Batch API`,
280
- action: `Submit ${groupRecords.length} ${operation} call${groupRecords.length === 1 ? "" : "s"} through the provider's Batch API (flat 50% off; results within 24h, fine for offline work).`,
536
+ title: `Move ${operationLabel} calls to the Batch API`,
537
+ action: `Submit ${groupRecords.length} ${operationLabel} call${groupRecords.length === 1 ? "" : "s"} through the provider's Batch API (flat 50% off; results within 24h, fine for offline work).`,
281
538
  estimatedMonthlySavingsUsd: monthlySavings,
282
539
  affectedSpendUsd,
283
540
  recordCount: groupRecords.length,