@agent-finops/core 0.5.4 → 0.5.6
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/LICENSE +21 -0
- package/README.md +16 -0
- package/dist/agentInventory.d.ts +35 -9
- package/dist/agentInventory.js +309 -11
- package/dist/analyze.js +6 -2
- package/dist/contextHealth.d.ts +101 -0
- package/dist/contextHealth.js +371 -0
- package/dist/deadContext.js +10 -1
- package/dist/discovery.d.ts +14 -1
- package/dist/discovery.js +54 -28
- package/dist/glance.d.ts +161 -0
- package/dist/glance.js +586 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/localAgentLogs.d.ts +42 -2
- package/dist/localAgentLogs.js +460 -7
- package/dist/modelPricing.d.ts +2 -1
- package/dist/modelPricing.js +22 -3
- package/dist/planMath.d.ts +12 -14
- package/dist/planMath.js +18 -18
- package/dist/providerConnectors.d.ts +19 -1
- package/dist/providerConnectors.js +106 -26
- package/dist/scanGuard.d.ts +35 -0
- package/dist/scanGuard.js +196 -8
- package/dist/schema.d.ts +24 -24
- package/dist/sourceRegistry.js +1 -1
- package/dist/toolInvocations.d.ts +42 -1
- package/dist/toolInvocations.js +244 -4
- package/package.json +15 -2
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { loadAgentInventory } from "./agentInventory.js";
|
|
2
|
+
import { computeDeadContext } from "./deadContext.js";
|
|
3
|
+
import { loadToolInvocations } from "./toolInvocations.js";
|
|
4
|
+
const DAY_MS = 24 * 60 * 60 * 1_000;
|
|
5
|
+
const DEFAULT_WINDOW_DAYS = 30;
|
|
6
|
+
/**
|
|
7
|
+
* Load one canonical Context Health snapshot. CLI, MCP, and Glance all consume
|
|
8
|
+
* this contract so their recommendation and provenance cannot drift.
|
|
9
|
+
*/
|
|
10
|
+
export async function loadContextHealth(calls, options = {}) {
|
|
11
|
+
const inventory = options.inventory ?? await loadAgentInventory(options);
|
|
12
|
+
const invocations = options.invocations ?? await loadToolInvocations(options);
|
|
13
|
+
const windowDays = options.windowDays ?? DEFAULT_WINDOW_DAYS;
|
|
14
|
+
const deadContext = computeDeadContext(inventory.items, invocations, {
|
|
15
|
+
windowDays,
|
|
16
|
+
pricingModel: options.pricingModel ?? "claude-sonnet-4"
|
|
17
|
+
});
|
|
18
|
+
return buildContextHealth({
|
|
19
|
+
calls,
|
|
20
|
+
inventory,
|
|
21
|
+
invocations,
|
|
22
|
+
deadContext,
|
|
23
|
+
now: options.now,
|
|
24
|
+
activeWithinMinutes: options.activeWithinMinutes,
|
|
25
|
+
windowDays
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
/** Pure Context Health contract builder for deterministic tests and adapters. */
|
|
29
|
+
export function buildContextHealth(input = {}) {
|
|
30
|
+
const now = input.now ?? new Date();
|
|
31
|
+
const calls = input.calls ?? [];
|
|
32
|
+
const items = input.inventory?.items ?? [];
|
|
33
|
+
const invocations = input.invocations ?? emptyInvocations();
|
|
34
|
+
const windowDays = input.windowDays ?? input.deadContext?.windowDays ?? DEFAULT_WINDOW_DAYS;
|
|
35
|
+
const deadContext = input.deadContext ?? computeDeadContext(items, invocations, {
|
|
36
|
+
windowDays,
|
|
37
|
+
pricingModel: "claude-sonnet-4"
|
|
38
|
+
});
|
|
39
|
+
const sessionGroups = contextSessions(calls);
|
|
40
|
+
const latestSession = latestContextSession(sessionGroups);
|
|
41
|
+
const currentSession = buildCurrentSession(sessionGroups, now, input.activeWithinMinutes ?? 20);
|
|
42
|
+
const contextChurn = buildContextChurn(latestSession, invocations);
|
|
43
|
+
const activation = activationSummary(items, invocations);
|
|
44
|
+
const hookItems = items.filter((item) => item.activation === "hook_injected");
|
|
45
|
+
const evidence = [];
|
|
46
|
+
if (currentSession) {
|
|
47
|
+
evidence.push({
|
|
48
|
+
kind: "session_history",
|
|
49
|
+
summary: currentSession.ratioToMedian === null
|
|
50
|
+
? `${currentSession.totalTokens.toLocaleString("en-US")} local transcript tokens; no same-agent baseline yet.`
|
|
51
|
+
: `${currentSession.totalTokens.toLocaleString("en-US")} local transcript tokens, ${currentSession.ratioToMedian}× the median of ${currentSession.comparisonSessions} prior same-agent session${currentSession.comparisonSessions === 1 ? "" : "s"}.`,
|
|
52
|
+
source: `${currentSession.agent} local transcripts`,
|
|
53
|
+
confidence: currentSession.ratioToMedian === null ? "observed" : "derived"
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
if ((contextChurn.compactionEvents ?? 0) > 0) {
|
|
57
|
+
evidence.push({
|
|
58
|
+
kind: "context_churn",
|
|
59
|
+
summary: `${contextChurn.compactionEvents} explicit compaction event${contextChurn.compactionEvents === 1 ? "" : "s"} observed in the current session transcript.`,
|
|
60
|
+
source: `${currentSession?.agent ?? "coding-agent"} local transcript event metadata`,
|
|
61
|
+
confidence: "observed"
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
if ((contextChurn.repeatedReadEvents ?? 0) > 0) {
|
|
65
|
+
const files = contextChurn.repeatedFiles
|
|
66
|
+
.slice(0, 3)
|
|
67
|
+
.map((file) => `${file.file} ×${file.readCount}`)
|
|
68
|
+
.join(", ");
|
|
69
|
+
evidence.push({
|
|
70
|
+
kind: "context_churn",
|
|
71
|
+
summary: `${contextChurn.repeatedReadEvents} repeat read event${contextChurn.repeatedReadEvents === 1 ? "" : "s"} observed through explicit file-read tools${files ? ` (${files})` : ""}.`,
|
|
72
|
+
source: "local transcript tool-call metadata; basenames only",
|
|
73
|
+
confidence: "observed"
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (currentSession?.cacheWriteRatioToMedian !== null &&
|
|
77
|
+
currentSession?.cacheWriteRatioToMedian !== undefined &&
|
|
78
|
+
currentSession.cacheWriteRatioToMedian >= 1.5) {
|
|
79
|
+
evidence.push({
|
|
80
|
+
kind: "context_churn",
|
|
81
|
+
summary: `${currentSession.cacheWriteTokens.toLocaleString("en-US")} cache-write tokens, ${currentSession.cacheWriteRatioToMedian}× the median of prior same-agent sessions with cache-write data.`,
|
|
82
|
+
source: `${currentSession.agent} local transcript usage metadata`,
|
|
83
|
+
confidence: "derived"
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
for (const hook of hookItems.slice(0, 3)) {
|
|
87
|
+
evidence.push({
|
|
88
|
+
kind: "hook_config",
|
|
89
|
+
summary: `${hook.group ?? hook.name} is configured on ${hook.event ?? "a lifecycle event"} for ${hostLabel(hook.host)}.`,
|
|
90
|
+
source: hook.path ?? "installed plugin metadata",
|
|
91
|
+
confidence: "unmeasured"
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
if (hookItems.length > 3) {
|
|
95
|
+
evidence.push({
|
|
96
|
+
kind: "hook_config",
|
|
97
|
+
summary: `${hookItems.length - 3} additional hook-injected context source${hookItems.length - 3 === 1 ? "" : "s"} detected.`,
|
|
98
|
+
source: "installed plugin metadata",
|
|
99
|
+
confidence: "unmeasured"
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
if (deadContext.deadCount > 0) {
|
|
103
|
+
evidence.push({
|
|
104
|
+
kind: "inventory_usage",
|
|
105
|
+
summary: `${deadContext.deadCount} of ${deadContext.loadedCount} discoverable/schema-loaded item${deadContext.loadedCount === 1 ? "" : "s"} were not invoked in the parsed window.`,
|
|
106
|
+
source: "local inventory compared with local transcript invocations",
|
|
107
|
+
confidence: deadContext.unmeasuredDeadCount > 0 ? "unmeasured" : "derived"
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
if (activation.invocationUnobservableItems > 0) {
|
|
111
|
+
evidence.push({
|
|
112
|
+
kind: "inventory_usage",
|
|
113
|
+
summary: `${activation.invocationUnobservableItems} configured item${activation.invocationUnobservableItems === 1 ? "" : "s"} cannot be matched to an explicit invocation in the available transcript format and were excluded from never-invoked counts.`,
|
|
114
|
+
source: "local inventory and transcript capability metadata",
|
|
115
|
+
confidence: "unmeasured"
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
const decision = contextDecision({
|
|
119
|
+
currentSession,
|
|
120
|
+
contextChurn,
|
|
121
|
+
hookInjectedItems: activation.hookInjectedItems,
|
|
122
|
+
deadContext
|
|
123
|
+
});
|
|
124
|
+
return {
|
|
125
|
+
schemaVersion: 1,
|
|
126
|
+
generatedAt: now.toISOString(),
|
|
127
|
+
...decision,
|
|
128
|
+
currentSession,
|
|
129
|
+
activation,
|
|
130
|
+
deadContext: {
|
|
131
|
+
loadedItems: deadContext.loadedCount,
|
|
132
|
+
neverInvokedItems: deadContext.deadCount,
|
|
133
|
+
measuredNeverInvokedItems: deadContext.measuredDeadCount,
|
|
134
|
+
unmeasuredNeverInvokedItems: deadContext.unmeasuredDeadCount,
|
|
135
|
+
windowDays
|
|
136
|
+
},
|
|
137
|
+
contextChurn,
|
|
138
|
+
evidence,
|
|
139
|
+
provenance: {
|
|
140
|
+
inventory: "local_agent_configuration",
|
|
141
|
+
invocations: "local_claude_code_and_codex_transcripts",
|
|
142
|
+
session: "local_transcript_metadata",
|
|
143
|
+
hookPayload: "not_executed_or_inferred",
|
|
144
|
+
uploaded: false
|
|
145
|
+
},
|
|
146
|
+
caveats: [
|
|
147
|
+
"Hook commands are never run by aibill. Configuration proves activation, but runtime output and token size remain unmeasured.",
|
|
148
|
+
"A session comparison uses local transcript token totals from the same coding agent; it is not a provider charge or a universal context-window measurement.",
|
|
149
|
+
"Never-invoked means no matching invocation was observed in the selected local transcript window, not that an item has no future value.",
|
|
150
|
+
"Items whose host transcript does not expose explicit invocation evidence are excluded from never-invoked counts.",
|
|
151
|
+
"Repeated-read evidence includes only explicit file-read tools and returns basenames only. Shell commands are not guessed to be reads.",
|
|
152
|
+
"Compaction counts come from explicit transcript markers; absence means not observed in the parsed format, not proof that compaction never occurred.",
|
|
153
|
+
"No per-session savings claim is made without an observed counterfactual baseline."
|
|
154
|
+
]
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function contextDecision(input) {
|
|
158
|
+
const ratio = input.currentSession?.ratioToMedian;
|
|
159
|
+
if (ratio !== null && ratio !== undefined && ratio >= 1.5) {
|
|
160
|
+
return {
|
|
161
|
+
status: "start_fresh",
|
|
162
|
+
recommendation: "start_fresh",
|
|
163
|
+
headline: `This session is ${ratio}× your same-agent token median.`,
|
|
164
|
+
action: "Start fresh before a new task; keep this session only while its existing context is directly useful.",
|
|
165
|
+
confidence: input.currentSession.comparisonSessions >= 3 ? "high" : "medium"
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
if ((input.contextChurn.compactionEvents ?? 0) >= 2) {
|
|
169
|
+
const count = input.contextChurn.compactionEvents;
|
|
170
|
+
return {
|
|
171
|
+
status: "start_fresh",
|
|
172
|
+
recommendation: "start_fresh",
|
|
173
|
+
headline: `This session has compacted ${count} times.`,
|
|
174
|
+
action: "Start fresh before the next task; preserve only the concrete state you still need.",
|
|
175
|
+
confidence: "high"
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
if (input.hookInjectedItems > 0) {
|
|
179
|
+
return {
|
|
180
|
+
status: "watch",
|
|
181
|
+
recommendation: "review_hooks",
|
|
182
|
+
headline: `${input.hookInjectedItems} hook-injected context source${input.hookInjectedItems === 1 ? "" : "s"} detected.`,
|
|
183
|
+
action: "Review the installed hook sources before removing anything; their runtime payload size is not measurable from configuration.",
|
|
184
|
+
confidence: "medium"
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (input.deadContext.hasData && input.deadContext.deadCount > 0) {
|
|
188
|
+
return {
|
|
189
|
+
status: "watch",
|
|
190
|
+
recommendation: "trim_dead_context",
|
|
191
|
+
headline: `${input.deadContext.deadCount} loaded item${input.deadContext.deadCount === 1 ? "" : "s"} were not invoked in this window.`,
|
|
192
|
+
action: "Lazy-load or remove only the items you do not expect to need, then re-run Context Health.",
|
|
193
|
+
confidence: input.deadContext.unmeasuredDeadCount > 0 ? "medium" : "high"
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
if (input.currentSession && input.currentSession.comparisonSessions > 0) {
|
|
197
|
+
return {
|
|
198
|
+
status: "healthy",
|
|
199
|
+
recommendation: "continue",
|
|
200
|
+
headline: "No evidence-backed context action is needed.",
|
|
201
|
+
action: "Continue in this session while its context remains useful.",
|
|
202
|
+
confidence: input.currentSession.comparisonSessions >= 3 ? "high" : "medium"
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
status: "insufficient_data",
|
|
207
|
+
recommendation: "collect_more_history",
|
|
208
|
+
headline: "More local session history is needed for a context recommendation.",
|
|
209
|
+
action: "Keep using your coding agents, then re-run Context Health after a few sessions.",
|
|
210
|
+
confidence: "low"
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
function buildCurrentSession(sessions, now, activeWithinMinutes) {
|
|
214
|
+
const latest = latestContextSession(sessions);
|
|
215
|
+
if (!latest)
|
|
216
|
+
return null;
|
|
217
|
+
const comparisons = sessions.filter((session) => (session.key !== latest.key &&
|
|
218
|
+
session.agent === latest.agent &&
|
|
219
|
+
session.totalTokens > 0));
|
|
220
|
+
const baseline = median(comparisons.map((session) => session.totalTokens));
|
|
221
|
+
const cacheWriteBaseline = median(comparisons
|
|
222
|
+
.map((session) => session.cacheWriteTokens)
|
|
223
|
+
.filter((tokens) => tokens > 0));
|
|
224
|
+
const ratio = baseline && baseline > 0
|
|
225
|
+
? roundRatio(latest.totalTokens / baseline)
|
|
226
|
+
: null;
|
|
227
|
+
const ageMs = Math.max(0, now.getTime() - Date.parse(latest.lastActivityAt));
|
|
228
|
+
return {
|
|
229
|
+
status: ageMs <= activeWithinMinutes * 60_000 ? "active" : "recent",
|
|
230
|
+
agent: latest.agent,
|
|
231
|
+
project: latest.project,
|
|
232
|
+
totalTokens: latest.totalTokens,
|
|
233
|
+
ratioToMedian: ratio,
|
|
234
|
+
comparisonSessions: comparisons.length,
|
|
235
|
+
cacheWriteTokens: latest.cacheWriteTokens,
|
|
236
|
+
cacheWriteRatioToMedian: cacheWriteBaseline && latest.cacheWriteTokens > 0
|
|
237
|
+
? roundRatio(latest.cacheWriteTokens / cacheWriteBaseline)
|
|
238
|
+
: null,
|
|
239
|
+
source: "local_transcript_metadata"
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
function contextSessions(calls) {
|
|
243
|
+
const groups = new Map();
|
|
244
|
+
calls.forEach((call, index) => {
|
|
245
|
+
const fallback = `${call.project ?? "unattributed"}:${call.model}:${call.timestamp}:${index}`;
|
|
246
|
+
const key = `${call.agent}:${call.sessionId ?? fallback}`;
|
|
247
|
+
groups.set(key, [...(groups.get(key) ?? []), call]);
|
|
248
|
+
});
|
|
249
|
+
return [...groups.entries()].map(([key, grouped]) => {
|
|
250
|
+
const ordered = grouped.slice().sort((left, right) => left.timestamp.localeCompare(right.timestamp));
|
|
251
|
+
const latest = ordered[ordered.length - 1];
|
|
252
|
+
return {
|
|
253
|
+
key,
|
|
254
|
+
agent: latest.agent,
|
|
255
|
+
project: latest.project ?? ordered[0]?.project,
|
|
256
|
+
lastActivityAt: latest.timestamp,
|
|
257
|
+
totalTokens: ordered.reduce((total, call) => total + (call.usage.inputTokens +
|
|
258
|
+
call.usage.outputTokens +
|
|
259
|
+
(call.usage.cacheReadTokens ?? 0) +
|
|
260
|
+
(call.usage.cacheWrite5mTokens ?? 0) +
|
|
261
|
+
(call.usage.cacheWrite1hTokens ?? 0)), 0),
|
|
262
|
+
cacheWriteTokens: ordered.reduce((total, call) => total + ((call.usage.cacheWrite5mTokens ?? 0) +
|
|
263
|
+
(call.usage.cacheWrite1hTokens ?? 0)), 0)
|
|
264
|
+
};
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
function latestContextSession(sessions) {
|
|
268
|
+
return sessions
|
|
269
|
+
.slice()
|
|
270
|
+
.sort((left, right) => right.lastActivityAt.localeCompare(left.lastActivityAt))[0];
|
|
271
|
+
}
|
|
272
|
+
function buildContextChurn(latest, invocations) {
|
|
273
|
+
const signals = invocations.sessionSignals ?? [];
|
|
274
|
+
const currentSignal = latest
|
|
275
|
+
? signals.find((signal) => (signal.agent === latest.agent &&
|
|
276
|
+
signal.sessionId &&
|
|
277
|
+
latest.key === `${signal.agent}:${signal.sessionId}`))
|
|
278
|
+
: undefined;
|
|
279
|
+
const repeatedFiles = (currentSignal?.repeatedFileReads ?? [])
|
|
280
|
+
.map((file) => ({ file: file.name, readCount: file.count }));
|
|
281
|
+
return {
|
|
282
|
+
currentSessionEvidence: !latest
|
|
283
|
+
? "no_current_session"
|
|
284
|
+
: currentSignal
|
|
285
|
+
? "matched"
|
|
286
|
+
: "not_matched",
|
|
287
|
+
compactionEvents: currentSignal?.compactionEvents ?? null,
|
|
288
|
+
explicitFileReads: currentSignal
|
|
289
|
+
? currentSignal.fileReads.reduce((total, file) => total + file.count, 0)
|
|
290
|
+
: null,
|
|
291
|
+
repeatedReadEvents: currentSignal
|
|
292
|
+
? repeatedFiles.reduce((total, file) => total + file.readCount - 1, 0)
|
|
293
|
+
: null,
|
|
294
|
+
repeatedFiles,
|
|
295
|
+
readCoverage: currentSignal?.readCoverage ?? "not_available",
|
|
296
|
+
currentSessionScope: currentSignal
|
|
297
|
+
? currentSignal.isSubagent
|
|
298
|
+
? "subagent"
|
|
299
|
+
: "parent"
|
|
300
|
+
: latest
|
|
301
|
+
? "unknown"
|
|
302
|
+
: null,
|
|
303
|
+
observedParentSessions: signals.filter((signal) => !signal.isSubagent).length,
|
|
304
|
+
observedSubagentSessions: signals.filter((signal) => signal.isSubagent).length
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
function activationSummary(items, invocations) {
|
|
308
|
+
return {
|
|
309
|
+
discoverableItems: items.filter((item) => item.activation === "discoverable").length,
|
|
310
|
+
explicitlyInvokedItems: items.filter((item) => itemWasInvoked(item, invocations)).length,
|
|
311
|
+
hookInjectedItems: items.filter((item) => item.activation === "hook_injected").length,
|
|
312
|
+
lifecycleHooks: items.filter((item) => item.activation === "lifecycle_hook").length,
|
|
313
|
+
mcpSchemaLoadedItems: items.filter((item) => item.activation === "mcp_schema_loaded").length,
|
|
314
|
+
unmeasuredItems: items.filter((item) => item.weightConfidence !== "estimated").length,
|
|
315
|
+
invocationUnobservableItems: items.filter((item) => item.kind !== "hook" && item.invocationTracking === "not_observable").length
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
function itemWasInvoked(item, invocations) {
|
|
319
|
+
if (item.invocationTracking === "not_observable")
|
|
320
|
+
return false;
|
|
321
|
+
switch (item.kind) {
|
|
322
|
+
case "skill":
|
|
323
|
+
return invocations.invokedSkills.includes(item.name);
|
|
324
|
+
case "subagent":
|
|
325
|
+
return invocations.invokedSubagents.includes(item.name);
|
|
326
|
+
case "command":
|
|
327
|
+
return invocations.invokedCommands.includes(item.name);
|
|
328
|
+
case "mcp_tool":
|
|
329
|
+
return invocations.invokedMcpTools.includes(item.name);
|
|
330
|
+
case "mcp_server":
|
|
331
|
+
return invocations.invokedMcpTools.some((tool) => tool.split("__")[1] === item.name);
|
|
332
|
+
case "hook":
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
function emptyInvocations() {
|
|
337
|
+
return {
|
|
338
|
+
invocations: [],
|
|
339
|
+
invokedMcpTools: [],
|
|
340
|
+
invokedSkills: [],
|
|
341
|
+
invokedSubagents: [],
|
|
342
|
+
invokedCommands: [],
|
|
343
|
+
sessions: 0,
|
|
344
|
+
totalAssistantTurns: 0,
|
|
345
|
+
sessionTurnCounts: [],
|
|
346
|
+
sourceSessions: { claudeCode: 0, codex: 0 }
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
function median(values) {
|
|
350
|
+
if (values.length === 0)
|
|
351
|
+
return null;
|
|
352
|
+
const sorted = values.slice().sort((left, right) => left - right);
|
|
353
|
+
const middle = Math.floor(sorted.length / 2);
|
|
354
|
+
return sorted.length % 2 === 0
|
|
355
|
+
? (sorted[middle - 1] + sorted[middle]) / 2
|
|
356
|
+
: sorted[middle];
|
|
357
|
+
}
|
|
358
|
+
function roundRatio(value) {
|
|
359
|
+
return Math.round(value * 10) / 10;
|
|
360
|
+
}
|
|
361
|
+
function hostLabel(host) {
|
|
362
|
+
if (host === "claude-code")
|
|
363
|
+
return "Claude Code";
|
|
364
|
+
if (host === "codex")
|
|
365
|
+
return "Codex";
|
|
366
|
+
return "an agent host";
|
|
367
|
+
}
|
|
368
|
+
/** Exposed for deterministic benchmark fixtures. */
|
|
369
|
+
export const CONTEXT_HEALTH_DEFAULT_WINDOW_DAYS = DEFAULT_WINDOW_DAYS;
|
|
370
|
+
export const CONTEXT_HEALTH_DAY_MS = DAY_MS;
|
|
371
|
+
//# sourceMappingURL=contextHealth.js.map
|
package/dist/deadContext.js
CHANGED
|
@@ -3,7 +3,7 @@ import { findPricingRule } from "./modelPricing.js";
|
|
|
3
3
|
import { loadToolInvocations } from "./toolInvocations.js";
|
|
4
4
|
/**
|
|
5
5
|
* Dead-context: the tools an agent LOADS into context but NEVER calls. Compares
|
|
6
|
-
* the local
|
|
6
|
+
* the local agent inventory (skills, subagents, slash commands, MCP
|
|
7
7
|
* servers — {@link loadAgentInventory}) against what real transcripts show was
|
|
8
8
|
* invoked ({@link loadToolInvocations}).
|
|
9
9
|
*
|
|
@@ -18,6 +18,8 @@ import { loadToolInvocations } from "./toolInvocations.js";
|
|
|
18
18
|
* surface as `unmeasuredDeadCount` so the renderer can say "not measurable".
|
|
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
|
+
* - Items whose host transcript does not expose matchable invocation evidence
|
|
22
|
+
* are excluded instead of being falsely classified as dead.
|
|
21
23
|
*/
|
|
22
24
|
const DEFAULT_WINDOW_DAYS = 30;
|
|
23
25
|
/**
|
|
@@ -75,6 +77,11 @@ export function computeDeadContext(items, invocations, config) {
|
|
|
75
77
|
const dead = [];
|
|
76
78
|
let loadedCount = 0;
|
|
77
79
|
for (const item of items) {
|
|
80
|
+
// Lifecycle hooks are activation evidence, not prunable inventory. Their
|
|
81
|
+
// runtime output cannot be inferred from config and they cannot be called
|
|
82
|
+
// like a skill/tool, so classifying them as "never invoked" would be false.
|
|
83
|
+
if (item.kind === "hook" || item.invocationTracking === "not_observable")
|
|
84
|
+
continue;
|
|
78
85
|
loadedCount += 1;
|
|
79
86
|
if (!isDead(item, { usedSkills, usedSubagents, usedCommands, usedMcpTools, usedMcpServers })) {
|
|
80
87
|
continue;
|
|
@@ -157,6 +164,8 @@ function isDead(item, used) {
|
|
|
157
164
|
return !used.usedMcpTools.has(item.name);
|
|
158
165
|
case "mcp_server":
|
|
159
166
|
return !used.usedMcpServers.has(item.name);
|
|
167
|
+
case "hook":
|
|
168
|
+
return false;
|
|
160
169
|
default:
|
|
161
170
|
return false;
|
|
162
171
|
}
|
package/dist/discovery.d.ts
CHANGED
|
@@ -3,14 +3,27 @@ export type UsageSignal = {
|
|
|
3
3
|
provider: string;
|
|
4
4
|
kind: UsageSignalKind;
|
|
5
5
|
filePath: string;
|
|
6
|
+
/** Stable rule identity; present on scanner-produced signals. */
|
|
7
|
+
ruleId?: string;
|
|
8
|
+
/** Structured, non-content evidence; present on scanner-produced signals. */
|
|
9
|
+
evidenceMeta?: UsageSignalEvidence;
|
|
10
|
+
/** JSON encoding of evidenceMeta retained for registry/backward compatibility. */
|
|
6
11
|
evidence: string;
|
|
7
12
|
confidence: number;
|
|
8
13
|
};
|
|
14
|
+
export type UsageSignalEvidence = {
|
|
15
|
+
file: string;
|
|
16
|
+
provider: string;
|
|
17
|
+
signal: UsageSignalKind;
|
|
18
|
+
ruleId: string;
|
|
19
|
+
};
|
|
9
20
|
export type LocalDiscoveryResult = {
|
|
10
21
|
rootPath: string;
|
|
11
22
|
scannedFiles: number;
|
|
12
23
|
skippedDirectories: string[];
|
|
13
|
-
/**
|
|
24
|
+
/** Symbolic links found below the approved root. They are never followed. */
|
|
25
|
+
skippedSymlinks: string[];
|
|
26
|
+
/** Paths that could not be read (permissions, vanished entries, non-UTF8) — skipped, never fatal. */
|
|
14
27
|
unreadablePaths: string[];
|
|
15
28
|
signals: UsageSignal[];
|
|
16
29
|
secretsDetected: string[];
|
package/dist/discovery.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { readdir, readFile
|
|
1
|
+
import { lstat, readdir, readFile } from "node:fs/promises";
|
|
2
2
|
import { basename, join, relative } from "node:path";
|
|
3
|
+
import { resolveSafeScanRoot } from "./scanGuard.js";
|
|
3
4
|
const skippedDirectoryNames = new Set([
|
|
4
5
|
".git",
|
|
5
6
|
"node_modules",
|
|
@@ -8,24 +9,25 @@ const skippedDirectoryNames = new Set([
|
|
|
8
9
|
".next",
|
|
9
10
|
".turbo",
|
|
10
11
|
"coverage",
|
|
12
|
+
".ai-spend-agent",
|
|
11
13
|
".ssh",
|
|
12
14
|
"Keychains"
|
|
13
15
|
]);
|
|
14
16
|
const maxFileBytes = 512_000;
|
|
15
17
|
const providerRules = [
|
|
16
|
-
{ provider: "anthropic", kind: "dependency", patterns: [/@anthropic-ai\/sdk/, /anthropic/i], confidence: 0.9 },
|
|
17
|
-
{ provider: "langfuse", kind: "dependency", patterns: [/langfuse/i], confidence: 0.82 },
|
|
18
|
-
{ provider: "openai", kind: "dependency", patterns: [/"openai"\s*:/, /from\s+["']openai["']/, /OPENAI_API_KEY/], confidence: 0.9 },
|
|
19
|
-
{ provider: "vercel-ai-sdk", kind: "dependency", patterns: [/"ai"\s*:/, /from\s+["']ai["']/], confidence: 0.78 },
|
|
20
|
-
{ provider: "litellm", kind: "config", patterns: [/litellm/i, /model_list:/], confidence: 0.84 },
|
|
21
|
-
{ provider: "helicone", kind: "environment", patterns: [/HELICONE_API_KEY/, /helicone/i], confidence: 0.8 },
|
|
22
|
-
{ provider: "cursor", kind: "invoice", patterns: [/cursor/i], confidence: 0.76 },
|
|
23
|
-
{ provider: "replit", kind: "invoice", patterns: [/replit/i], confidence: 0.72 }
|
|
18
|
+
{ id: "provider.anthropic.dependency", provider: "anthropic", kind: "dependency", patterns: [/@anthropic-ai\/sdk/, /anthropic/i], confidence: 0.9 },
|
|
19
|
+
{ id: "provider.langfuse.dependency", provider: "langfuse", kind: "dependency", patterns: [/langfuse/i], confidence: 0.82 },
|
|
20
|
+
{ id: "provider.openai.dependency", provider: "openai", kind: "dependency", patterns: [/"openai"\s*:/, /from\s+["']openai["']/, /OPENAI_API_KEY/], confidence: 0.9 },
|
|
21
|
+
{ id: "provider.vercel-ai-sdk.dependency", provider: "vercel-ai-sdk", kind: "dependency", patterns: [/"ai"\s*:/, /from\s+["']ai["']/], confidence: 0.78 },
|
|
22
|
+
{ id: "provider.litellm.config", provider: "litellm", kind: "config", patterns: [/litellm/i, /model_list:/], confidence: 0.84 },
|
|
23
|
+
{ id: "provider.helicone.environment", provider: "helicone", kind: "environment", patterns: [/HELICONE_API_KEY/, /helicone/i], confidence: 0.8 },
|
|
24
|
+
{ id: "provider.cursor.invoice", provider: "cursor", kind: "invoice", patterns: [/cursor/i], confidence: 0.76 },
|
|
25
|
+
{ id: "provider.replit.invoice", provider: "replit", kind: "invoice", patterns: [/replit/i], confidence: 0.72 }
|
|
24
26
|
];
|
|
25
27
|
// Name-based redaction: any UPPER_SNAKE env-style assignment whose name ends
|
|
26
28
|
// in a secret-ish suffix. `KEY` deliberately subsumes API_KEY/ADMIN_KEY/etc —
|
|
27
29
|
// over-redacting a public key is harmless; leaking a private one is not.
|
|
28
|
-
const secretAssignmentPattern = /\b([A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?|AUTH))\s
|
|
30
|
+
const secretAssignmentPattern = /\b([A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?|AUTH))\s*(?:=|:)\s*(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s#]+)/g;
|
|
29
31
|
// Value-based redaction: known secret shapes regardless of how they're named.
|
|
30
32
|
const providerSecretPatterns = [
|
|
31
33
|
/sk-proj-[A-Za-z0-9_-]{20,}/g,
|
|
@@ -48,10 +50,12 @@ const providerSecretPatterns = [
|
|
|
48
50
|
/\bnpm_[A-Za-z0-9]{30,}\b/g
|
|
49
51
|
];
|
|
50
52
|
export async function scanLocalUsageSignals(rootPath) {
|
|
53
|
+
const canonicalRoot = await resolveSafeScanRoot(rootPath);
|
|
51
54
|
const result = {
|
|
52
|
-
rootPath,
|
|
55
|
+
rootPath: canonicalRoot,
|
|
53
56
|
scannedFiles: 0,
|
|
54
57
|
skippedDirectories: [],
|
|
58
|
+
skippedSymlinks: [],
|
|
55
59
|
unreadablePaths: [],
|
|
56
60
|
signals: [],
|
|
57
61
|
secretsDetected: [],
|
|
@@ -59,18 +63,24 @@ export async function scanLocalUsageSignals(rootPath) {
|
|
|
59
63
|
};
|
|
60
64
|
const secrets = new Set();
|
|
61
65
|
const skipped = new Set();
|
|
66
|
+
const symlinks = new Set();
|
|
62
67
|
const unreadable = new Set();
|
|
63
|
-
await walk(
|
|
64
|
-
// A
|
|
65
|
-
//
|
|
68
|
+
await walk(canonicalRoot, async (path) => {
|
|
69
|
+
// A permission-denied, vanished, or unreadable file must never reject the
|
|
70
|
+
// whole scan — real machines are messy. Symlinks are handled by walk and
|
|
71
|
+
// never reach this callback.
|
|
66
72
|
let fileInfo;
|
|
67
73
|
try {
|
|
68
|
-
fileInfo = await
|
|
74
|
+
fileInfo = await lstat(path);
|
|
69
75
|
}
|
|
70
76
|
catch {
|
|
71
77
|
unreadable.add(path);
|
|
72
78
|
return;
|
|
73
79
|
}
|
|
80
|
+
if (fileInfo.isSymbolicLink()) {
|
|
81
|
+
symlinks.add(path);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
74
84
|
if (fileInfo.size > maxFileBytes || !isInterestingFile(path)) {
|
|
75
85
|
return;
|
|
76
86
|
}
|
|
@@ -83,7 +93,7 @@ export async function scanLocalUsageSignals(rootPath) {
|
|
|
83
93
|
return;
|
|
84
94
|
}
|
|
85
95
|
const redacted = redactSecrets(raw);
|
|
86
|
-
const relativePath = relative(
|
|
96
|
+
const relativePath = relative(canonicalRoot, path) || basename(path);
|
|
87
97
|
result.scannedFiles += 1;
|
|
88
98
|
for (const name of detectSecretNames(raw)) {
|
|
89
99
|
secrets.add(name);
|
|
@@ -97,19 +107,25 @@ export async function scanLocalUsageSignals(rootPath) {
|
|
|
97
107
|
if (!matchedPattern) {
|
|
98
108
|
continue;
|
|
99
109
|
}
|
|
100
|
-
const
|
|
110
|
+
const kind = inferKind(path, rule.kind);
|
|
111
|
+
const evidenceMeta = buildEvidence(relativePath, rule.provider, kind, rule.id);
|
|
101
112
|
result.signals.push({
|
|
102
113
|
provider: rule.provider,
|
|
103
|
-
kind
|
|
114
|
+
kind,
|
|
104
115
|
filePath: relativePath,
|
|
105
|
-
|
|
116
|
+
ruleId: rule.id,
|
|
117
|
+
evidenceMeta,
|
|
118
|
+
evidence: encodeEvidence(evidenceMeta),
|
|
106
119
|
confidence: rule.confidence
|
|
107
120
|
});
|
|
108
121
|
}
|
|
109
|
-
}, skipped, unreadable);
|
|
122
|
+
}, skipped, symlinks, unreadable);
|
|
110
123
|
result.skippedDirectories = Array.from(skipped).sort();
|
|
124
|
+
result.skippedSymlinks = Array.from(symlinks)
|
|
125
|
+
.map((path) => relative(canonicalRoot, path) || basename(path))
|
|
126
|
+
.sort();
|
|
111
127
|
result.unreadablePaths = Array.from(unreadable)
|
|
112
|
-
.map((path) => relative(
|
|
128
|
+
.map((path) => relative(canonicalRoot, path) || basename(path))
|
|
113
129
|
.sort();
|
|
114
130
|
result.secretsDetected = Array.from(secrets).sort();
|
|
115
131
|
result.signals = dedupeSignals(result.signals).sort((left, right) => {
|
|
@@ -135,7 +151,7 @@ function detectSecretNames(text) {
|
|
|
135
151
|
}
|
|
136
152
|
return Array.from(names);
|
|
137
153
|
}
|
|
138
|
-
async function walk(rootPath, visit, skipped, unreadable) {
|
|
154
|
+
async function walk(rootPath, visit, skipped, symlinks, unreadable) {
|
|
139
155
|
let entries;
|
|
140
156
|
try {
|
|
141
157
|
entries = await readdir(rootPath, { withFileTypes: true });
|
|
@@ -147,15 +163,19 @@ async function walk(rootPath, visit, skipped, unreadable) {
|
|
|
147
163
|
}
|
|
148
164
|
for (const entry of entries) {
|
|
149
165
|
const path = join(rootPath, entry.name);
|
|
166
|
+
if (entry.isSymbolicLink()) {
|
|
167
|
+
symlinks.add(path);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
150
170
|
if (entry.isDirectory()) {
|
|
151
171
|
if (skippedDirectoryNames.has(entry.name)) {
|
|
152
172
|
skipped.add(entry.name);
|
|
153
173
|
continue;
|
|
154
174
|
}
|
|
155
|
-
await walk(path, visit, skipped, unreadable);
|
|
175
|
+
await walk(path, visit, skipped, symlinks, unreadable);
|
|
156
176
|
continue;
|
|
157
177
|
}
|
|
158
|
-
if (entry.isFile()
|
|
178
|
+
if (entry.isFile()) {
|
|
159
179
|
await visit(path);
|
|
160
180
|
}
|
|
161
181
|
}
|
|
@@ -198,17 +218,23 @@ function detectExportSignals(filePath, redacted) {
|
|
|
198
218
|
}
|
|
199
219
|
const normalizedProvider = provider === "google" ? "gemini" : provider;
|
|
200
220
|
const kind = isInvoice ? "invoice" : "provider_export";
|
|
221
|
+
const ruleId = `export.${normalizedProvider}.${kind}`;
|
|
222
|
+
const evidenceMeta = buildEvidence(filePath, normalizedProvider, kind, ruleId);
|
|
201
223
|
return [{
|
|
202
224
|
provider: normalizedProvider,
|
|
203
225
|
kind,
|
|
204
226
|
filePath,
|
|
205
|
-
|
|
227
|
+
ruleId,
|
|
228
|
+
evidenceMeta,
|
|
229
|
+
evidence: encodeEvidence(evidenceMeta),
|
|
206
230
|
confidence: isInvoice ? 0.82 : 0.88
|
|
207
231
|
}];
|
|
208
232
|
}
|
|
209
|
-
function buildEvidence(
|
|
210
|
-
|
|
211
|
-
|
|
233
|
+
function buildEvidence(file, provider, signal, ruleId) {
|
|
234
|
+
return { file, provider, signal, ruleId };
|
|
235
|
+
}
|
|
236
|
+
function encodeEvidence(evidence) {
|
|
237
|
+
return JSON.stringify(evidence);
|
|
212
238
|
}
|
|
213
239
|
function dedupeSignals(signals) {
|
|
214
240
|
const byKey = new Map();
|