@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/README.md +6 -0
- package/dist/agentInventory.d.ts +11 -14
- package/dist/agentInventory.js +180 -46
- package/dist/analyze.js +165 -113
- package/dist/contextHealth.d.ts +10 -1
- package/dist/contextHealth.js +216 -44
- package/dist/cutList.d.ts +9 -3
- package/dist/cutList.js +99 -41
- package/dist/deadContext.d.ts +8 -4
- package/dist/deadContext.js +83 -26
- package/dist/discovery.d.ts +6 -2
- package/dist/discovery.js +36 -14
- package/dist/glance.d.ts +9 -2
- package/dist/glance.js +107 -24
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/insights.js +53 -30
- package/dist/localAgentLogs.d.ts +80 -2
- package/dist/localAgentLogs.js +480 -88
- package/dist/modelPricing.js +0 -1
- package/dist/providerConnectors.d.ts +3 -2
- package/dist/providerConnectors.js +673 -89
- package/dist/sampleData.js +32 -4
- package/dist/schema.d.ts +105 -27
- package/dist/schema.js +110 -2
- package/dist/sourceRegistry.d.ts +30 -5
- package/dist/sourceRegistry.js +250 -21
- package/dist/sourceStatus.d.ts +65 -0
- package/dist/sourceStatus.js +147 -0
- package/dist/stateTrust.d.ts +37 -0
- package/dist/stateTrust.js +277 -0
- package/dist/toolInvocations.d.ts +47 -18
- package/dist/toolInvocations.js +200 -48
- package/package.json +1 -1
- package/samples/anthropic-usage.csv +4 -4
- package/samples/openai-usage.csv +7 -7
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
|
-
/**
|
|
66
|
-
const
|
|
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(
|
|
70
|
-
...contextTrimActions(
|
|
71
|
-
...cacheActions(
|
|
72
|
-
...batchActions(
|
|
81
|
+
...modelDowngradeActions(callLevelRecords),
|
|
82
|
+
...contextTrimActions(contextEvidenceRecords),
|
|
83
|
+
...cacheActions(callLevelRecords),
|
|
84
|
+
...batchActions(callLevelRecords)
|
|
73
85
|
];
|
|
74
86
|
return actions
|
|
75
|
-
.filter((action) => action.
|
|
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
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
if (
|
|
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) ? "
|
|
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
|
-
|
|
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 [
|
|
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:
|
|
165
|
+
id: sessionAggregates
|
|
166
|
+
? `inspect-context-${slug(agent)}-${slug(project ?? "unattributed")}`
|
|
167
|
+
: `inspect-context-${slug(operation)}`,
|
|
144
168
|
title: sessionAggregates
|
|
145
|
-
? `
|
|
146
|
-
: `
|
|
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}
|
|
151
|
-
:
|
|
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 ? "
|
|
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
|
-
|
|
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
|
-
|
|
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 [
|
|
181
|
-
|
|
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
|
-
|
|
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: `
|
|
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 ||
|
|
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
|
-
|
|
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 [
|
|
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
|
|
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
|
}
|
package/dist/deadContext.d.ts
CHANGED
|
@@ -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
|
|
13
|
+
/** Config/catalog file where the item was observed. */
|
|
10
14
|
path?: string;
|
|
11
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
41
|
+
/** The not-observed candidates, measured weight first. */
|
|
38
42
|
deadItems: DeadContextItem[];
|
|
39
43
|
sessions: number;
|
|
40
44
|
totalTurns: number;
|
package/dist/deadContext.js
CHANGED
|
@@ -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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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 =
|
|
33
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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: `
|
|
142
|
-
action: `
|
|
143
|
-
`(${pct}%
|
|
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":
|
package/dist/discovery.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
27
|
+
/** Opaque references for symbolic links below the approved root. They are never followed. */
|
|
25
28
|
skippedSymlinks: string[];
|
|
26
|
-
/**
|
|
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 {
|
|
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) ||
|
|
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
|
-
|
|
100
|
-
|
|
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(
|
|
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:
|
|
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)
|
|
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) ||
|
|
130
|
+
.map((path) => opaquePathReference(relative(canonicalRoot, path) || "."))
|
|
126
131
|
.sort();
|
|
127
132
|
result.unreadablePaths = Array.from(unreadable)
|
|
128
|
-
.map((path) => relative(canonicalRoot, 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(
|
|
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(
|
|
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
|
-
|
|
15
|
-
|
|
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: {
|