@agent-finops/core 0.5.8 → 0.6.0

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/cutList.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { hasCallLevelProvenance, hasModeledWorkloadEvidence, hasPricedEvidence } from "./schema.js";
1
2
  /**
2
3
  * Select a non-overlapping subset of cut actions, highest-savings first. An
3
4
  * action is added only if none of its records were already claimed by a
@@ -62,18 +63,31 @@ const downgradeSafeOperation = /triage|extract|classif|summary|summari|draft|rep
62
63
  * backlog is not.
63
64
  */
64
65
  const batchSafeOperation = /summar|extract|classif|embed|enrich|index|backfill|digest|report|translat|transcri|batch/i;
65
- /** Fraction of cost retained on the Batch API (both providers price it at 50%). */
66
- const batchCostRetained = 0.5;
66
+ /** Published retained-cost fraction for providers whose Batch pricing we explicitly support. */
67
+ const batchCostRetainedByProvider = {
68
+ openai: 0.5,
69
+ anthropic: 0.5
70
+ };
67
71
  export function generateCutList(records) {
72
+ // Connected-provider rows are commonly billing buckets, usage aggregates,
73
+ // seats, or user totals. They may be real financial evidence, but they are
74
+ // not individual calls. Modeled cuts require an adapter to explicitly attest
75
+ // `call`/`invocation` granularity and provide a named workload operation.
76
+ // Local transcript aggregates remain eligible only for the observed-only
77
+ // context exposure path below; they never earn a modeled savings number.
78
+ const callLevelRecords = records.filter(hasModeledWorkloadEvidence);
79
+ const contextEvidenceRecords = records.filter((record) => isLocalAgentRecord(record) || (hasCallLevelProvenance(record) && hasPricedEvidence(record)));
68
80
  const actions = [
69
- ...modelDowngradeActions(records),
70
- ...contextTrimActions(records),
71
- ...cacheActions(records),
72
- ...batchActions(records)
81
+ ...modelDowngradeActions(callLevelRecords),
82
+ ...contextTrimActions(contextEvidenceRecords),
83
+ ...cacheActions(callLevelRecords),
84
+ ...batchActions(callLevelRecords)
73
85
  ];
74
86
  return actions
75
- .filter((action) => action.estimatedMonthlySavingsUsd >= 0.5)
87
+ .filter((action) => (action.impactBasis === "observed_value_no_counterfactual" ||
88
+ action.estimatedMonthlySavingsUsd >= 0.5))
76
89
  .sort((left, right) => right.estimatedMonthlySavingsUsd - left.estimatedMonthlySavingsUsd ||
90
+ right.affectedSpendUsd - left.affectedSpendUsd ||
77
91
  left.id.localeCompare(right.id));
78
92
  }
79
93
  /** Sum of all per-action estimated monthly savings. */
@@ -85,13 +99,13 @@ function modelDowngradeActions(records) {
85
99
  const groups = new Map();
86
100
  for (const record of records) {
87
101
  const rule = downgradeRules.find((candidate) => candidate.match.test(record.model));
88
- if (!rule) {
102
+ if (!rule || !record.operation || record.workloadSemantics?.downgradeSafe !== true) {
89
103
  continue;
90
104
  }
91
- const operation = record.operation ?? "general";
92
- // Only suggest downgrades for clearly downgrade-safe operations, OR when
93
- // the operation is unknown (we still flag it, but caveat via confidence).
94
- if (record.operation && !downgradeSafeOperation.test(operation)) {
105
+ const operation = record.operation;
106
+ // A named, clearly downgrade-safe workload is required. Unknown operations
107
+ // are not sufficient evidence for a routing counterfactual.
108
+ if (!downgradeSafeOperation.test(operation)) {
95
109
  continue;
96
110
  }
97
111
  const key = `${record.model}::${operation}::${rule.target}`;
@@ -111,7 +125,8 @@ function modelDowngradeActions(records) {
111
125
  estimatedMonthlySavingsUsd: monthlySavings,
112
126
  affectedSpendUsd,
113
127
  recordCount: groupRecords.length,
114
- recordUnit: groupRecords.every(isLocalAgentRecord) ? "session-days" : "calls",
128
+ recordUnit: groupRecords.every(isLocalAgentRecord) ? "daily-aggregates" : "calls",
129
+ impactBasis: "modeled_savings",
115
130
  recordIds: groupRecords.map((record) => record.id),
116
131
  confidence: combinedConfidence(groupRecords.map((record) => record.costConfidence)),
117
132
  kind: "model_downgrade"
@@ -120,7 +135,6 @@ function modelDowngradeActions(records) {
120
135
  return actions;
121
136
  }
122
137
  function contextTrimActions(records) {
123
- const window = windowDays(records);
124
138
  const heavy = records.filter((record) => record.inputTokens >= 100_000);
125
139
  if (heavy.length === 0) {
126
140
  return [];
@@ -128,31 +142,40 @@ function contextTrimActions(records) {
128
142
  const byOperation = new Map();
129
143
  for (const record of heavy) {
130
144
  const operation = record.operation ?? "large-context calls";
131
- byOperation.set(operation, [...(byOperation.get(operation) ?? []), record]);
145
+ const key = isLocalAgentRecord(record)
146
+ ? `local::${record.agentId ?? "unknown-agent"}::${record.projectId ?? "unattributed"}`
147
+ : `connected::${operation}`;
148
+ byOperation.set(key, [...(byOperation.get(key) ?? []), record]);
132
149
  }
133
150
  const actions = [];
134
- for (const [operation, groupRecords] of byOperation) {
151
+ for (const [key, groupRecords] of byOperation) {
135
152
  const affectedSpendUsd = roundMoney(sumRecords(groupRecords));
136
- // Trimming oversized retrieval/context conservatively recovers ~25% of the
137
- // input-token cost on these large calls.
138
- const windowSavings = affectedSpendUsd * 0.25;
139
- const monthlySavings = roundMoney(toMonthly(windowSavings, window));
140
153
  const sessionAggregates = groupRecords.every(isLocalAgentRecord);
154
+ const operation = sessionAggregates
155
+ ? groupRecords[0]?.operation ?? "coding-agent activity"
156
+ : key.replace(/^connected::/, "");
141
157
  const count = groupRecords.length;
158
+ const agent = groupRecords[0]?.agentId ?? "coding-agent";
159
+ const project = groupRecords[0]?.projectId;
160
+ // Large token volume proves exposure, not that context is removable or
161
+ // what quality/cost delta a change would produce. Context remains an
162
+ // inspect-only action until matched before/after evidence exists.
163
+ const monthlySavings = 0;
142
164
  actions.push({
143
- id: `trim-${slug(operation)}`,
165
+ id: sessionAggregates
166
+ ? `inspect-context-${slug(agent)}-${slug(project ?? "unattributed")}`
167
+ : `inspect-context-${slug(operation)}`,
144
168
  title: sessionAggregates
145
- ? `Trim heavy context in ${operation}`
146
- : `Trim oversized context on ${operation}`,
147
- // Coding-agent sessions are aggregated per day — the honest levers are
148
- // the context loaded every turn, not "prompt size" on a single call.
169
+ ? `Investigate cumulative context in ${agent}${project ? ` · ${project}` : " · Unattributed"}`
170
+ : `Inspect oversized context on ${operation}`,
149
171
  action: sessionAggregates
150
- ? `${count} session-day${count === 1 ? "" : "s"} averaged >=100k input tokens per record. Cut dead context first (unused MCP servers/skills — see above), keep CLAUDE.md/AGENTS.md lean, and avoid pulling whole directories into context.`
151
- : `Cap retrieval/prompt size on ${count} large ${operation} call${count === 1 ? "" : "s"} (>=100k input tokens) before they fan out.`,
172
+ ? `${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.`
173
+ : `${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.`,
152
174
  estimatedMonthlySavingsUsd: monthlySavings,
153
175
  affectedSpendUsd,
154
176
  recordCount: count,
155
- recordUnit: sessionAggregates ? "session-days" : "calls",
177
+ recordUnit: sessionAggregates ? "daily-aggregates" : "calls",
178
+ impactBasis: "observed_value_no_counterfactual",
156
179
  recordIds: groupRecords.map((record) => record.id),
157
180
  confidence: combinedConfidence(groupRecords.map((record) => record.costConfidence)),
158
181
  kind: "context_trim"
@@ -164,7 +187,8 @@ function cacheActions(records) {
164
187
  const window = windowDays(records);
165
188
  const counts = new Map();
166
189
  for (const record of records) {
167
- if (!record.operation) {
190
+ const fingerprint = record.workloadSemantics?.stableInputFingerprint;
191
+ if (!record.operation || !fingerprint) {
168
192
  continue;
169
193
  }
170
194
  // Local agent logs aggregate interactive sessions under one operation
@@ -174,25 +198,35 @@ function cacheActions(records) {
174
198
  if (isLocalAgentRecord(record)) {
175
199
  continue;
176
200
  }
177
- counts.set(record.operation, [...(counts.get(record.operation) ?? []), record]);
201
+ const key = JSON.stringify([record.source.provider, record.model, record.operation, fingerprint]);
202
+ const current = counts.get(key);
203
+ counts.set(key, {
204
+ operation: record.operation,
205
+ records: [...(current?.records ?? []), record]
206
+ });
178
207
  }
179
208
  const actions = [];
180
- for (const [operation, groupRecords] of counts) {
181
- if (groupRecords.length < 3) {
209
+ for (const [key, group] of counts) {
210
+ const { operation, records: groupRecords } = group;
211
+ if (groupRecords.length < 2) {
182
212
  continue;
183
213
  }
214
+ const chronological = [...groupRecords].sort((left, right) => left.timestamp.localeCompare(right.timestamp) || left.id.localeCompare(right.id));
215
+ // The first observation is the canonical miss. Only subsequent calls with
216
+ // the same explicit fingerprint are modeled as avoidable cache hits.
217
+ const avoidableRecords = chronological.slice(1);
184
218
  const affectedSpendUsd = roundMoney(sumRecords(groupRecords));
185
- // Caching repeated identical-ish operations conservatively recovers ~20%.
186
- const windowSavings = affectedSpendUsd * 0.2;
219
+ const windowSavings = sumRecords(avoidableRecords);
187
220
  const monthlySavings = roundMoney(toMonthly(windowSavings, window));
188
221
  actions.push({
189
- id: `cache-${slug(operation)}`,
222
+ id: `cache-${slug(operation)}-${stableSuffix(key)}`,
190
223
  title: `Cache repeated ${operation} calls`,
191
- action: `Add a result cache for ${operation} (${groupRecords.length} repeated call${groupRecords.length === 1 ? "" : "s"}) so identical inputs do not re-bill.`,
224
+ 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.`,
192
225
  estimatedMonthlySavingsUsd: monthlySavings,
193
226
  affectedSpendUsd,
194
227
  recordCount: groupRecords.length,
195
228
  recordUnit: "calls",
229
+ impactBasis: "modeled_savings",
196
230
  recordIds: groupRecords.map((record) => record.id),
197
231
  confidence: combinedConfidence(groupRecords.map((record) => record.costConfidence)),
198
232
  kind: "cache"
@@ -204,27 +238,43 @@ function batchActions(records) {
204
238
  const window = windowDays(records);
205
239
  const byOperation = new Map();
206
240
  for (const record of records) {
207
- if (!record.operation || !batchSafeOperation.test(record.operation)) {
241
+ if (!record.operation ||
242
+ record.workloadSemantics?.batchEligible !== true ||
243
+ batchCostRetainedByProvider[record.source.provider] === undefined ||
244
+ !batchSafeOperation.test(record.operation)) {
208
245
  continue;
209
246
  }
210
- byOperation.set(record.operation, [...(byOperation.get(record.operation) ?? []), record]);
247
+ const key = JSON.stringify([
248
+ record.source.id,
249
+ record.source.provider,
250
+ record.model,
251
+ record.operation
252
+ ]);
253
+ const current = byOperation.get(key);
254
+ byOperation.set(key, {
255
+ operation: record.operation,
256
+ records: [...(current?.records ?? []), record]
257
+ });
211
258
  }
212
259
  const actions = [];
213
- for (const [operation, groupRecords] of byOperation) {
260
+ for (const [key, group] of byOperation) {
261
+ const { operation, records: groupRecords } = group;
214
262
  if (groupRecords.length < 3) {
215
263
  continue;
216
264
  }
217
265
  const affectedSpendUsd = roundMoney(sumRecords(groupRecords));
218
- const windowSavings = affectedSpendUsd * (1 - batchCostRetained);
266
+ const retainedCost = batchCostRetainedByProvider[groupRecords[0].source.provider];
267
+ const windowSavings = affectedSpendUsd * (1 - retainedCost);
219
268
  const monthlySavings = roundMoney(toMonthly(windowSavings, window));
220
269
  actions.push({
221
- id: `batch-${slug(operation)}`,
270
+ id: `batch-${slug(operation)}-${stableSuffix(key)}`,
222
271
  title: `Move ${operation} calls to the Batch API`,
223
272
  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).`,
224
273
  estimatedMonthlySavingsUsd: monthlySavings,
225
274
  affectedSpendUsd,
226
275
  recordCount: groupRecords.length,
227
276
  recordUnit: "calls",
277
+ impactBasis: "modeled_savings",
228
278
  recordIds: groupRecords.map((record) => record.id),
229
279
  confidence: combinedConfidence(groupRecords.map((record) => record.costConfidence)),
230
280
  kind: "batch"
@@ -265,6 +315,14 @@ function combinedConfidence(confidences) {
265
315
  function slug(value) {
266
316
  return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "x";
267
317
  }
318
+ function stableSuffix(value) {
319
+ let hash = 2_166_136_261;
320
+ for (let index = 0; index < value.length; index += 1) {
321
+ hash ^= value.charCodeAt(index);
322
+ hash = Math.imul(hash, 16_777_619);
323
+ }
324
+ return (hash >>> 0).toString(36);
325
+ }
268
326
  function roundMoney(value) {
269
327
  return Math.round(value * 100) / 100;
270
328
  }
@@ -4,11 +4,15 @@ import type { CutAction } from "./cutList.js";
4
4
  export type DeadContextItem = {
5
5
  kind: InventoryItem["kind"];
6
6
  name: string;
7
+ scope: InventoryItem["scope"];
8
+ activation: InventoryItem["activation"];
9
+ host?: InventoryItem["host"];
10
+ invocationTracking: InventoryItem["invocationTracking"];
7
11
  alwaysLoadedTokens: number;
8
12
  weightConfidence: InventoryItem["weightConfidence"];
9
- /** Config file the item is loaded from — the place to remove it. */
13
+ /** Config/catalog file where the item was observed. */
10
14
  path?: string;
11
- /** Project dirs that load this item (where `claude mcp remove` must run). */
15
+ /** Owning project dirs when scope is local or project. */
12
16
  ownerDirs?: string[];
13
17
  };
14
18
  export type DeadContextResult = {
@@ -18,7 +22,7 @@ export type DeadContextResult = {
18
22
  isSample?: boolean;
19
23
  /** Prunable inventory items considered (built-ins excluded upstream). */
20
24
  loadedCount: number;
21
- /** Items never invoked across the parsed window (the defensible headline). */
25
+ /** Observable items with no matching invocation in the parsed window. */
22
26
  deadCount: number;
23
27
  /** Dead items whose token weight we MEASURED (skills/subagents/commands). */
24
28
  measuredDeadCount: number;
@@ -34,7 +38,7 @@ export type DeadContextResult = {
34
38
  monthlyUsd: number;
35
39
  /** Upper bound (no prompt caching), measured items only. */
36
40
  monthlyUsdUpperBound: number;
37
- /** The never-used items, heaviest first. */
41
+ /** The not-observed candidates, measured weight first. */
38
42
  deadItems: DeadContextItem[];
39
43
  sessions: number;
40
44
  totalTurns: number;
@@ -2,20 +2,20 @@ import { loadAgentInventory } from "./agentInventory.js";
2
2
  import { findPricingRule } from "./modelPricing.js";
3
3
  import { loadToolInvocations } from "./toolInvocations.js";
4
4
  /**
5
- * Dead-context: the tools an agent LOADS into context but NEVER calls. Compares
6
- * the local agent inventory (skills, subagents, slash commands, MCP
7
- * servers {@link loadAgentInventory}) against what real transcripts show was
8
- * invoked ({@link loadToolInvocations}).
5
+ * Inventory-use evidence: compare locally configured/discoverable agent items
6
+ * with explicit invocations in supported transcripts. Configuration alone does
7
+ * not prove an item's full payload was loaded, and absence of an observed call
8
+ * does not prove that an item has no future value.
9
9
  *
10
10
  * ACCURACY CONTRACT (this is the credibility lever — read before changing):
11
11
  * - The COUNT + utilization % is always defensible and is the headline.
12
12
  * - A token/$ magnitude is ONLY computed from items whose weight we actually
13
13
  * MEASURED — skill/subagent/command frontmatter (weightConfidence
14
14
  * "estimated"). We never price items whose weight we could not measure.
15
- * - MCP servers have NO readable schemas in local config, so their real
16
- * weight is unknown. They are COUNTED as dead but NEVER assigned a $/token
17
- * figure to size them we'd have to query each server's tools/list. They
18
- * surface as `unmeasuredDeadCount` so the renderer can say "not measurable".
15
+ * - MCP config has no runtime schemas and current hosts can defer tool loading.
16
+ * Configured servers are counted as not-observed candidates but NEVER
17
+ * assigned a $/token figure. Explicit alwaysLoad is preserved as activation
18
+ * evidence while its payload size remains unmeasured.
19
19
  * - Pricing is cache-aware (one cache write/session + a read/turn), never the
20
20
  * inflated full-input-rate-every-turn number.
21
21
  * - Items whose host transcript does not expose matchable invocation evidence
@@ -29,8 +29,10 @@ const DEFAULT_WINDOW_DAYS = 30;
29
29
  const DEFAULT_PRICING_MODEL = "claude-sonnet-4";
30
30
  /** Load inventory + invocations from disk (or use injected ones) and price the waste. */
31
31
  export async function loadDeadContext(options = {}) {
32
- const inventory = options.inventory ?? (await loadAgentInventory(options));
33
- const invocations = options.invocations ?? (await loadToolInvocations(options));
32
+ const [inventory, invocations] = await Promise.all([
33
+ options.inventory ?? loadAgentInventory(options),
34
+ options.invocations ?? loadToolInvocations(options)
35
+ ]);
34
36
  return computeDeadContext(inventory.items, invocations, {
35
37
  windowDays: options.windowDays ?? DEFAULT_WINDOW_DAYS,
36
38
  pricingModel: options.pricingModel ?? DEFAULT_PRICING_MODEL
@@ -68,12 +70,6 @@ export function computeDeadContext(items, invocations, config) {
68
70
  const windowDays = Math.max(1, config.windowDays);
69
71
  const sessions = invocations.sessions;
70
72
  const totalTurns = invocations.totalAssistantTurns;
71
- const usedSkills = new Set(invocations.invokedSkills);
72
- const usedSubagents = new Set(invocations.invokedSubagents);
73
- const usedCommands = new Set(invocations.invokedCommands);
74
- const usedMcpTools = new Set(invocations.invokedMcpTools);
75
- // An MCP server counts as "used" if any invoked mcp tool belongs to it.
76
- const usedMcpServers = new Set(invocations.invokedMcpTools.map((tool) => tool.split("__")[1]).filter((id) => Boolean(id)));
77
73
  const dead = [];
78
74
  let loadedCount = 0;
79
75
  for (const item of items) {
@@ -82,13 +78,30 @@ export function computeDeadContext(items, invocations, config) {
82
78
  // like a skill/tool, so classifying them as "never invoked" would be false.
83
79
  if (item.kind === "hook" || item.invocationTracking === "not_observable")
84
80
  continue;
81
+ const evidence = invocationEvidenceFor(item.host, invocations);
82
+ const hasMatchingHostCoverage = evidence.sessions > 0 && evidence.totalAssistantTurns > 0;
83
+ // Once host-isolated evidence is available, another host's transcripts are
84
+ // never an observation opportunity for this item and do not enter the
85
+ // candidate denominator. Hostless legacy fixtures keep the old global
86
+ // inventory-count behavior while still producing no candidate without data.
87
+ if (item.host && invocations.byHost && !hasMatchingHostCoverage)
88
+ continue;
85
89
  loadedCount += 1;
86
- if (!isDead(item, { usedSkills, usedSubagents, usedCommands, usedMcpTools, usedMcpServers })) {
90
+ // Configuration without an observed assistant turn in the selected window
91
+ // is inventory, not evidence that an item went unused. Keep the configured
92
+ // count available for coverage reporting, but do not classify candidates.
93
+ if (!hasMatchingHostCoverage)
94
+ continue;
95
+ if (!isDead(item, invocationSets(evidence))) {
87
96
  continue;
88
97
  }
89
98
  dead.push({
90
99
  kind: item.kind,
91
100
  name: item.name,
101
+ scope: item.scope,
102
+ activation: item.activation,
103
+ host: item.host,
104
+ invocationTracking: item.invocationTracking,
92
105
  alwaysLoadedTokens: item.alwaysLoadedTokens,
93
106
  weightConfidence: item.weightConfidence,
94
107
  path: item.path,
@@ -101,13 +114,31 @@ export function computeDeadContext(items, invocations, config) {
101
114
  const measuredDead = dead.filter((item) => item.weightConfidence === "estimated");
102
115
  const unmeasuredDead = dead.filter((item) => item.weightConfidence !== "estimated");
103
116
  const measuredTokens = measuredDead.reduce((total, item) => total + item.alwaysLoadedTokens, 0);
104
- const hasData = items.length > 0 && sessions > 0 && dead.length > 0;
117
+ const hasData = loadedCount > 0 && dead.length > 0;
105
118
  const rates = pricingRates(config.pricingModel);
106
- // Cached: one cache write per session + a cache read on every later turn.
107
- const cacheReads = Math.max(0, totalTurns - sessions);
108
- const windowCachedUsd = (measuredTokens * (sessions * rates.write5mPerM + cacheReads * rates.cacheReadPerM)) / 1_000_000;
109
- const windowUncachedUsd = (measuredTokens * totalTurns * rates.inputPerM) / 1_000_000;
110
119
  const monthFactor = DEFAULT_WINDOW_DAYS / windowDays;
120
+ let windowCachedUsd = 0;
121
+ let windowUncachedUsd = 0;
122
+ let monthlyDeadTokens = 0;
123
+ const measuredByCoverage = new Map();
124
+ for (const item of measuredDead) {
125
+ const key = item.host && invocations.byHost ? item.host : "global";
126
+ const current = measuredByCoverage.get(key) ?? {
127
+ tokens: 0,
128
+ evidence: invocationEvidenceFor(item.host, invocations)
129
+ };
130
+ current.tokens += item.alwaysLoadedTokens;
131
+ measuredByCoverage.set(key, current);
132
+ }
133
+ for (const { tokens, evidence } of measuredByCoverage.values()) {
134
+ // Cached: one cache write per same-host session + a cache read on every
135
+ // later same-host turn. Cross-host turns never price this inventory.
136
+ const cacheReads = Math.max(0, evidence.totalAssistantTurns - evidence.sessions);
137
+ windowCachedUsd += (tokens * (evidence.sessions * rates.write5mPerM +
138
+ cacheReads * rates.cacheReadPerM)) / 1_000_000;
139
+ windowUncachedUsd += (tokens * evidence.totalAssistantTurns * rates.inputPerM) / 1_000_000;
140
+ monthlyDeadTokens += tokens * evidence.totalAssistantTurns * monthFactor;
141
+ }
111
142
  return {
112
143
  hasData,
113
144
  loadedCount,
@@ -115,7 +146,7 @@ export function computeDeadContext(items, invocations, config) {
115
146
  measuredDeadCount: measuredDead.length,
116
147
  unmeasuredDeadCount: unmeasuredDead.length,
117
148
  deadTokens: measuredTokens,
118
- monthlyDeadTokens: Math.round(measuredTokens * totalTurns * monthFactor),
149
+ monthlyDeadTokens: Math.round(monthlyDeadTokens),
119
150
  wastePercent: loadedCount > 0 ? dead.length / loadedCount : 0,
120
151
  monthlyUsd: roundMoney(windowCachedUsd * monthFactor),
121
152
  monthlyUsdUpperBound: roundMoney(windowUncachedUsd * monthFactor),
@@ -138,11 +169,12 @@ export function deadContextCutAction(result) {
138
169
  const pct = Math.round(result.wastePercent * 100);
139
170
  return {
140
171
  id: "dead-context",
141
- title: `Trim ${result.measuredDeadCount} loaded tool${result.measuredDeadCount === 1 ? "" : "s"} your agent never calls`,
142
- action: `Remove or lazy-load ${result.deadCount} of ${result.loadedCount} loaded item${result.loadedCount === 1 ? "" : "s"} ` +
143
- `(${pct}% never invoked) to reclaim ~${result.deadTokens.toLocaleString("en-US")} tokens of dead context per turn.`,
172
+ title: `Review ${result.measuredDeadCount} discoverable item${result.measuredDeadCount === 1 ? "" : "s"} with no observed invocation`,
173
+ action: `Inspect ${result.deadCount} of ${result.loadedCount} observable inventory item${result.loadedCount === 1 ? "" : "s"} ` +
174
+ `(${pct}% had no matching invocation) before proposing a scoped disable, lazy-load, or removal.`,
144
175
  estimatedMonthlySavingsUsd: result.monthlyUsd,
145
176
  affectedSpendUsd: result.monthlyUsd,
177
+ impactBasis: "modeled_savings",
146
178
  recordCount: result.deadCount,
147
179
  recordUnit: "tools",
148
180
  // Dead-context savings come from inventory, not priced usage records, so
@@ -152,6 +184,31 @@ export function deadContextCutAction(result) {
152
184
  kind: "context_trim"
153
185
  };
154
186
  }
187
+ function invocationEvidenceFor(host, invocations) {
188
+ if (host && invocations.byHost)
189
+ return invocations.byHost[host];
190
+ return {
191
+ sessions: invocations.sessions,
192
+ totalAssistantTurns: invocations.totalAssistantTurns,
193
+ sessionTurnCounts: invocations.sessionTurnCounts,
194
+ invokedMcpTools: invocations.invokedMcpTools,
195
+ invokedSkills: invocations.invokedSkills,
196
+ invokedSubagents: invocations.invokedSubagents,
197
+ invokedCommands: invocations.invokedCommands
198
+ };
199
+ }
200
+ function invocationSets(evidence) {
201
+ return {
202
+ usedSkills: new Set(evidence.invokedSkills),
203
+ usedSubagents: new Set(evidence.invokedSubagents),
204
+ usedCommands: new Set(evidence.invokedCommands),
205
+ usedMcpTools: new Set(evidence.invokedMcpTools),
206
+ // An MCP server counts as used only when a same-host MCP tool belongs to it.
207
+ usedMcpServers: new Set(evidence.invokedMcpTools
208
+ .map((tool) => tool.split("__")[1])
209
+ .filter((id) => Boolean(id)))
210
+ };
211
+ }
155
212
  function isDead(item, used) {
156
213
  switch (item.kind) {
157
214
  case "skill":
@@ -2,6 +2,7 @@ export type UsageSignalKind = "dependency" | "config" | "environment" | "source_
2
2
  export type UsageSignal = {
3
3
  provider: string;
4
4
  kind: UsageSignalKind;
5
+ /** Deterministic opaque reference; never a repository-controlled filename. */
5
6
  filePath: string;
6
7
  /** Stable rule identity; present on scanner-produced signals. */
7
8
  ruleId?: string;
@@ -12,6 +13,7 @@ export type UsageSignal = {
12
13
  confidence: number;
13
14
  };
14
15
  export type UsageSignalEvidence = {
16
+ /** Same deterministic opaque reference as UsageSignal.filePath. */
15
17
  file: string;
16
18
  provider: string;
17
19
  signal: UsageSignalKind;
@@ -20,12 +22,14 @@ export type UsageSignalEvidence = {
20
22
  export type LocalDiscoveryResult = {
21
23
  rootPath: string;
22
24
  scannedFiles: number;
25
+ /** Deterministic opaque references for denied/heavy descendant directories. */
23
26
  skippedDirectories: string[];
24
- /** Symbolic links found below the approved root. They are never followed. */
27
+ /** Opaque references for symbolic links below the approved root. They are never followed. */
25
28
  skippedSymlinks: string[];
26
- /** Paths that could not be read (permissions, vanished entries, non-UTF8) — skipped, never fatal. */
29
+ /** Opaque references for unreadable descendants — skipped, never fatal. */
27
30
  unreadablePaths: string[];
28
31
  signals: UsageSignal[];
32
+ /** Deterministic opaque references for detected secret assignments. */
29
33
  secretsDetected: string[];
30
34
  redactedEvidence: string[];
31
35
  };
package/dist/discovery.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { lstat, readdir, readFile } from "node:fs/promises";
2
- import { basename, join, relative } from "node:path";
2
+ import { createHash } from "node:crypto";
3
+ import { basename, join, relative, sep } from "node:path";
3
4
  import { resolveSafeScanRoot } from "./scanGuard.js";
4
5
  const skippedDirectoryNames = new Set([
5
6
  ".git",
@@ -93,13 +94,15 @@ export async function scanLocalUsageSignals(rootPath) {
93
94
  return;
94
95
  }
95
96
  const redacted = redactSecrets(raw);
96
- const relativePath = relative(canonicalRoot, path) || basename(path);
97
+ const relativePath = relative(canonicalRoot, path) || ".";
98
+ const pathReference = opaquePathReference(relativePath);
97
99
  result.scannedFiles += 1;
98
100
  for (const name of detectSecretNames(raw)) {
99
- secrets.add(name);
100
- result.redactedEvidence.push(`${relativePath}: ${name}=[REDACTED]`);
101
+ const secretReference = opaqueSecretReference(name);
102
+ secrets.add(secretReference);
103
+ result.redactedEvidence.push(`${pathReference}: ${secretReference}=[REDACTED]`);
101
104
  }
102
- for (const signal of detectExportSignals(relativePath, redacted)) {
105
+ for (const signal of detectExportSignals(relativePath, redacted, pathReference)) {
103
106
  result.signals.push(signal);
104
107
  }
105
108
  for (const rule of providerRules) {
@@ -108,11 +111,11 @@ export async function scanLocalUsageSignals(rootPath) {
108
111
  continue;
109
112
  }
110
113
  const kind = inferKind(path, rule.kind);
111
- const evidenceMeta = buildEvidence(relativePath, rule.provider, kind, rule.id);
114
+ const evidenceMeta = buildEvidence(pathReference, rule.provider, kind, rule.id);
112
115
  result.signals.push({
113
116
  provider: rule.provider,
114
117
  kind,
115
- filePath: relativePath,
118
+ filePath: pathReference,
116
119
  ruleId: rule.id,
117
120
  evidenceMeta,
118
121
  evidence: encodeEvidence(evidenceMeta),
@@ -120,12 +123,14 @@ export async function scanLocalUsageSignals(rootPath) {
120
123
  });
121
124
  }
122
125
  }, skipped, symlinks, unreadable);
123
- result.skippedDirectories = Array.from(skipped).sort();
126
+ result.skippedDirectories = Array.from(skipped)
127
+ .map((path) => opaquePathReference(relative(canonicalRoot, path) || "."))
128
+ .sort();
124
129
  result.skippedSymlinks = Array.from(symlinks)
125
- .map((path) => relative(canonicalRoot, path) || basename(path))
130
+ .map((path) => opaquePathReference(relative(canonicalRoot, path) || "."))
126
131
  .sort();
127
132
  result.unreadablePaths = Array.from(unreadable)
128
- .map((path) => relative(canonicalRoot, path) || basename(path))
133
+ .map((path) => opaquePathReference(relative(canonicalRoot, path) || "."))
129
134
  .sort();
130
135
  result.secretsDetected = Array.from(secrets).sort();
131
136
  result.signals = dedupeSignals(result.signals).sort((left, right) => {
@@ -169,7 +174,7 @@ async function walk(rootPath, visit, skipped, symlinks, unreadable) {
169
174
  }
170
175
  if (entry.isDirectory()) {
171
176
  if (skippedDirectoryNames.has(entry.name)) {
172
- skipped.add(entry.name);
177
+ skipped.add(path);
173
178
  continue;
174
179
  }
175
180
  await walk(path, visit, skipped, symlinks, unreadable);
@@ -203,7 +208,7 @@ function inferKind(path, fallback) {
203
208
  }
204
209
  return fallback;
205
210
  }
206
- function detectExportSignals(filePath, redacted) {
211
+ function detectExportSignals(filePath, redacted, pathReference) {
207
212
  const lowerPath = filePath.toLowerCase();
208
213
  const lowerText = redacted.toLowerCase();
209
214
  const providers = ["openai", "anthropic", "cursor", "helicone", "langfuse", "gemini", "google", "replit"];
@@ -219,11 +224,11 @@ function detectExportSignals(filePath, redacted) {
219
224
  const normalizedProvider = provider === "google" ? "gemini" : provider;
220
225
  const kind = isInvoice ? "invoice" : "provider_export";
221
226
  const ruleId = `export.${normalizedProvider}.${kind}`;
222
- const evidenceMeta = buildEvidence(filePath, normalizedProvider, kind, ruleId);
227
+ const evidenceMeta = buildEvidence(pathReference, normalizedProvider, kind, ruleId);
223
228
  return [{
224
229
  provider: normalizedProvider,
225
230
  kind,
226
- filePath,
231
+ filePath: pathReference,
227
232
  ruleId,
228
233
  evidenceMeta,
229
234
  evidence: encodeEvidence(evidenceMeta),
@@ -236,6 +241,23 @@ function buildEvidence(file, provider, signal, ruleId) {
236
241
  function encodeEvidence(evidence) {
237
242
  return JSON.stringify(evidence);
238
243
  }
244
+ /**
245
+ * Repository-controlled descendant names are untrusted metadata. Discovery may
246
+ * use the real relative path internally for classification, but persisted and
247
+ * agent-facing output receives only this stable, non-semantic reference.
248
+ */
249
+ function opaquePathReference(relativePath) {
250
+ // Normalize only the current platform's separator. A literal backslash is
251
+ // a valid POSIX filename character and must not alias a nested POSIX path.
252
+ const normalized = (relativePath || ".").split(sep).join("/");
253
+ const digest = createHash("sha256").update(normalized, "utf8").digest("hex").slice(0, 16);
254
+ return `path-${digest}`;
255
+ }
256
+ /** Repository-controlled environment names are untrusted metadata too. */
257
+ function opaqueSecretReference(name) {
258
+ const digest = createHash("sha256").update(name, "utf8").digest("hex").slice(0, 16);
259
+ return `secret-${digest}`;
260
+ }
239
261
  function dedupeSignals(signals) {
240
262
  const byKey = new Map();
241
263
  for (const signal of signals) {
package/dist/glance.d.ts CHANGED
@@ -11,8 +11,12 @@ export type GlanceSession = {
11
11
  durationMinutes: number;
12
12
  apiEquivalentUsd: number | null;
13
13
  costConfidence: "estimated" | "missing";
14
- inputTokens: number;
15
- outputTokens: number;
14
+ /** Null when the transcript reports only a total and no priceable breakdown. */
15
+ inputTokens: number | null;
16
+ /** Null when the transcript reports only a total and no priceable breakdown. */
17
+ outputTokens: number | null;
18
+ /** Provider-reported total retained without inventing input/output components. */
19
+ reportedTotalTokens?: number;
16
20
  };
17
21
  export type GlanceLimit = {
18
22
  agent: LocalAgentCall["agent"];
@@ -57,6 +61,8 @@ export type GlanceFocus = {
57
61
  confidence: "high" | "medium" | "low";
58
62
  };
59
63
  export type GlancePrimaryAction = {
64
+ /** Glance is a bounded session handoff, not the CLI financial apply plan. */
65
+ kind: "session_handoff";
60
66
  intent: "start_fresh" | "review_context" | "trim_context" | "protect_runway" | "continue_focus" | "resume_focus" | "inspect_current_work";
61
67
  label: string;
62
68
  detail: string;
@@ -67,6 +73,7 @@ export type GlancePrimaryAction = {
67
73
  confidence: "high" | "medium" | "low";
68
74
  execution: "copy_prompt";
69
75
  requiresUserConfirmation: true;
76
+ evidenceWindowDays: number;
70
77
  };
71
78
  export type GlanceProvenance = {
72
79
  session: {