@agent-finops/core 0.1.1 → 0.1.4

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,33 @@
1
+ /**
2
+ * Select a non-overlapping subset of cut actions, highest-savings first. An
3
+ * action is added only if none of its records were already claimed by a
4
+ * previously selected action; otherwise it falls to {@link RecommendedPlan.additional}.
5
+ * This guarantees the recommended total never double-counts a dollar of spend.
6
+ */
7
+ export function buildRecommendedPlan(actions) {
8
+ const sorted = [...actions].sort((left, right) => right.estimatedMonthlySavingsUsd - left.estimatedMonthlySavingsUsd ||
9
+ left.id.localeCompare(right.id));
10
+ const claimed = new Set();
11
+ const recommended = [];
12
+ const additional = [];
13
+ for (const action of sorted) {
14
+ const overlaps = action.recordIds.some((id) => claimed.has(id));
15
+ if (overlaps) {
16
+ additional.push(action);
17
+ continue;
18
+ }
19
+ for (const id of action.recordIds)
20
+ claimed.add(id);
21
+ recommended.push(action);
22
+ }
23
+ return {
24
+ recommended,
25
+ additional,
26
+ recommendedSavingsUsd: roundMoney(recommended.reduce((total, a) => total + a.estimatedMonthlySavingsUsd, 0)),
27
+ additionalSavingsUsd: roundMoney(additional.reduce((total, a) => total + a.estimatedMonthlySavingsUsd, 0)),
28
+ savingsMath: "deduplicated"
29
+ };
30
+ }
1
31
  const confidenceRank = {
2
32
  verified: 0,
3
33
  estimated: 1,
@@ -81,6 +111,8 @@ function modelDowngradeActions(records) {
81
111
  estimatedMonthlySavingsUsd: monthlySavings,
82
112
  affectedSpendUsd,
83
113
  recordCount: groupRecords.length,
114
+ recordUnit: groupRecords.every(isLocalAgentRecord) ? "session-days" : "calls",
115
+ recordIds: groupRecords.map((record) => record.id),
84
116
  confidence: combinedConfidence(groupRecords.map((record) => record.costConfidence)),
85
117
  kind: "model_downgrade"
86
118
  });
@@ -105,13 +137,23 @@ function contextTrimActions(records) {
105
137
  // input-token cost on these large calls.
106
138
  const windowSavings = affectedSpendUsd * 0.25;
107
139
  const monthlySavings = roundMoney(toMonthly(windowSavings, window));
140
+ const sessionAggregates = groupRecords.every(isLocalAgentRecord);
141
+ const count = groupRecords.length;
108
142
  actions.push({
109
143
  id: `trim-${slug(operation)}`,
110
- title: `Trim oversized context on ${operation}`,
111
- action: `Cap retrieval/prompt size on ${groupRecords.length} large ${operation} call${groupRecords.length === 1 ? "" : "s"} (>=100k input tokens) before they fan out.`,
144
+ 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.
149
+ 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.`,
112
152
  estimatedMonthlySavingsUsd: monthlySavings,
113
153
  affectedSpendUsd,
114
- recordCount: groupRecords.length,
154
+ recordCount: count,
155
+ recordUnit: sessionAggregates ? "session-days" : "calls",
156
+ recordIds: groupRecords.map((record) => record.id),
115
157
  confidence: combinedConfidence(groupRecords.map((record) => record.costConfidence)),
116
158
  kind: "context_trim"
117
159
  });
@@ -125,6 +167,13 @@ function cacheActions(records) {
125
167
  if (!record.operation) {
126
168
  continue;
127
169
  }
170
+ // Local agent logs aggregate interactive sessions under one operation
171
+ // label ("claude-code sessions"). Those are NOT repeated identical calls —
172
+ // a result cache is not a real lever there (prompt caching already applies
173
+ // and is priced into the estimate), so recommending one would be wrong.
174
+ if (isLocalAgentRecord(record)) {
175
+ continue;
176
+ }
128
177
  counts.set(record.operation, [...(counts.get(record.operation) ?? []), record]);
129
178
  }
130
179
  const actions = [];
@@ -143,6 +192,8 @@ function cacheActions(records) {
143
192
  estimatedMonthlySavingsUsd: monthlySavings,
144
193
  affectedSpendUsd,
145
194
  recordCount: groupRecords.length,
195
+ recordUnit: "calls",
196
+ recordIds: groupRecords.map((record) => record.id),
146
197
  confidence: combinedConfidence(groupRecords.map((record) => record.costConfidence)),
147
198
  kind: "cache"
148
199
  });
@@ -173,12 +224,18 @@ function batchActions(records) {
173
224
  estimatedMonthlySavingsUsd: monthlySavings,
174
225
  affectedSpendUsd,
175
226
  recordCount: groupRecords.length,
227
+ recordUnit: "calls",
228
+ recordIds: groupRecords.map((record) => record.id),
176
229
  confidence: combinedConfidence(groupRecords.map((record) => record.costConfidence)),
177
230
  kind: "batch"
178
231
  });
179
232
  }
180
233
  return actions;
181
234
  }
235
+ /** Records ingested from local agent transcripts (day-level session aggregates). */
236
+ function isLocalAgentRecord(record) {
237
+ return record.providerCostType === "local_agent_logs";
238
+ }
182
239
  /** Number of distinct calendar days the records span (min 1). */
183
240
  function windowDays(records) {
184
241
  const days = new Set(records.map((record) => record.timestamp.slice(0, 10)));
@@ -0,0 +1,72 @@
1
+ import { type AgentInventoryOptions, type InventoryItem } from "./agentInventory.js";
2
+ import { type InvocationSummary, type ToolInvocationOptions } from "./toolInvocations.js";
3
+ import type { CutAction } from "./cutList.js";
4
+ export type DeadContextItem = {
5
+ kind: InventoryItem["kind"];
6
+ name: string;
7
+ alwaysLoadedTokens: number;
8
+ weightConfidence: InventoryItem["weightConfidence"];
9
+ };
10
+ export type DeadContextResult = {
11
+ /** True when we found inventory + transcripts AND at least one dead item. */
12
+ hasData: boolean;
13
+ /** True when these are illustrative sample numbers, not the user's real data. */
14
+ isSample?: boolean;
15
+ /** Prunable inventory items considered (built-ins excluded upstream). */
16
+ loadedCount: number;
17
+ /** Items never invoked across the parsed window (the defensible headline). */
18
+ deadCount: number;
19
+ /** Dead items whose token weight we MEASURED (skills/subagents/commands). */
20
+ measuredDeadCount: number;
21
+ /** Dead items whose weight we could NOT measure (MCP servers, no schemas). */
22
+ unmeasuredDeadCount: number;
23
+ /** Measured-only always-loaded tokens across dead items (per turn). */
24
+ deadTokens: number;
25
+ /** Measured-only dead tokens loaded into context over a month (projected). */
26
+ monthlyDeadTokens: number;
27
+ /** deadCount / loadedCount, 0..1. */
28
+ wastePercent: number;
29
+ /** Cache-aware monthly $, MEASURED items only (0 when only MCP is dead). */
30
+ monthlyUsd: number;
31
+ /** Upper bound (no prompt caching), measured items only. */
32
+ monthlyUsdUpperBound: number;
33
+ /** The never-used items, heaviest first. */
34
+ deadItems: DeadContextItem[];
35
+ sessions: number;
36
+ totalTurns: number;
37
+ /** Model whose rates priced the measured estimate. */
38
+ pricingModel: string;
39
+ /** Days the parsed transcripts were assumed to represent. */
40
+ windowDays: number;
41
+ };
42
+ export type DeadContextOptions = AgentInventoryOptions & ToolInvocationOptions & {
43
+ /** Days the parsed transcripts represent (for the /mo projection). Default 30. */
44
+ windowDays?: number;
45
+ /** Representative model for cache/input rates. Default claude-sonnet-4. */
46
+ pricingModel?: string;
47
+ /** Inject inventory/invocations (tests); otherwise loaded from disk. */
48
+ inventory?: {
49
+ items: InventoryItem[];
50
+ };
51
+ invocations?: InvocationSummary;
52
+ };
53
+ /** Load inventory + invocations from disk (or use injected ones) and price the waste. */
54
+ export declare function loadDeadContext(options?: DeadContextOptions): Promise<DeadContextResult>;
55
+ /**
56
+ * Illustrative dead-context numbers for the first-run / demo card, so the
57
+ * feature is always visible even when a user has nothing loaded yet. Clearly
58
+ * flagged isSample. Shows the measured-skills case (count + a small honest $).
59
+ */
60
+ export declare function sampleDeadContext(): DeadContextResult;
61
+ /** Pure core: compare inventory vs. invocations; count all dead, price only measured. */
62
+ export declare function computeDeadContext(items: InventoryItem[], invocations: InvocationSummary, config: {
63
+ windowDays: number;
64
+ pricingModel: string;
65
+ }): DeadContextResult;
66
+ /**
67
+ * Adapt a dead-context result to a {@link CutAction} so it can flow into the
68
+ * ranked cut list / AI Receipt. Returns null unless there is MEASURED waste
69
+ * worth a dollar figure (MCP-only waste is shown as a count, not a cut $).
70
+ */
71
+ export declare function deadContextCutAction(result: DeadContextResult): CutAction | null;
72
+ //# sourceMappingURL=deadContext.d.ts.map
@@ -0,0 +1,175 @@
1
+ import { loadAgentInventory } from "./agentInventory.js";
2
+ import { findPricingRule } from "./modelPricing.js";
3
+ import { loadToolInvocations } from "./toolInvocations.js";
4
+ /**
5
+ * Dead-context: the tools an agent LOADS into context but NEVER calls. Compares
6
+ * the local Claude Code inventory (skills, subagents, slash commands, MCP
7
+ * servers — {@link loadAgentInventory}) against what real transcripts show was
8
+ * invoked ({@link loadToolInvocations}).
9
+ *
10
+ * ACCURACY CONTRACT (this is the credibility lever — read before changing):
11
+ * - The COUNT + utilization % is always defensible and is the headline.
12
+ * - A token/$ magnitude is ONLY computed from items whose weight we actually
13
+ * MEASURED — skill/subagent/command frontmatter (weightConfidence
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".
19
+ * - Pricing is cache-aware (one cache write/session + a read/turn), never the
20
+ * inflated full-input-rate-every-turn number.
21
+ */
22
+ const DEFAULT_WINDOW_DAYS = 30;
23
+ /**
24
+ * Representative model for pricing measured dead context. Claude Code runs
25
+ * Anthropic models; Sonnet rates are a conservative middle. Always "estimated".
26
+ */
27
+ const DEFAULT_PRICING_MODEL = "claude-sonnet-4";
28
+ /** Load inventory + invocations from disk (or use injected ones) and price the waste. */
29
+ export async function loadDeadContext(options = {}) {
30
+ const inventory = options.inventory ?? (await loadAgentInventory(options));
31
+ const invocations = options.invocations ?? (await loadToolInvocations(options));
32
+ return computeDeadContext(inventory.items, invocations, {
33
+ windowDays: options.windowDays ?? DEFAULT_WINDOW_DAYS,
34
+ pricingModel: options.pricingModel ?? DEFAULT_PRICING_MODEL
35
+ });
36
+ }
37
+ /**
38
+ * Illustrative dead-context numbers for the first-run / demo card, so the
39
+ * feature is always visible even when a user has nothing loaded yet. Clearly
40
+ * flagged isSample. Shows the measured-skills case (count + a small honest $).
41
+ */
42
+ export function sampleDeadContext() {
43
+ const loadedCount = 38;
44
+ const deadCount = 29;
45
+ return {
46
+ hasData: true,
47
+ isSample: true,
48
+ loadedCount,
49
+ deadCount,
50
+ measuredDeadCount: deadCount,
51
+ unmeasuredDeadCount: 0,
52
+ deadTokens: 80,
53
+ monthlyDeadTokens: 120_000,
54
+ wastePercent: deadCount / loadedCount,
55
+ monthlyUsd: 0.4,
56
+ monthlyUsdUpperBound: 2.4,
57
+ deadItems: [],
58
+ sessions: 120,
59
+ totalTurns: 1500,
60
+ pricingModel: DEFAULT_PRICING_MODEL,
61
+ windowDays: 30
62
+ };
63
+ }
64
+ /** Pure core: compare inventory vs. invocations; count all dead, price only measured. */
65
+ export function computeDeadContext(items, invocations, config) {
66
+ const windowDays = Math.max(1, config.windowDays);
67
+ const sessions = invocations.sessions;
68
+ const totalTurns = invocations.totalAssistantTurns;
69
+ const usedSkills = new Set(invocations.invokedSkills);
70
+ const usedSubagents = new Set(invocations.invokedSubagents);
71
+ const usedCommands = new Set(invocations.invokedCommands);
72
+ const usedMcpTools = new Set(invocations.invokedMcpTools);
73
+ // An MCP server counts as "used" if any invoked mcp tool belongs to it.
74
+ const usedMcpServers = new Set(invocations.invokedMcpTools.map((tool) => tool.split("__")[1]).filter((id) => Boolean(id)));
75
+ const dead = [];
76
+ let loadedCount = 0;
77
+ for (const item of items) {
78
+ loadedCount += 1;
79
+ if (!isDead(item, { usedSkills, usedSubagents, usedCommands, usedMcpTools, usedMcpServers })) {
80
+ continue;
81
+ }
82
+ dead.push({
83
+ kind: item.kind,
84
+ name: item.name,
85
+ alwaysLoadedTokens: item.alwaysLoadedTokens,
86
+ weightConfidence: item.weightConfidence
87
+ });
88
+ }
89
+ dead.sort((a, b) => b.alwaysLoadedTokens - a.alwaysLoadedTokens);
90
+ // Only items whose weight we actually measured get a $/token figure. MCP
91
+ // servers (weight unknown without querying tools/list) are counted, not priced.
92
+ const measuredDead = dead.filter((item) => item.weightConfidence === "estimated");
93
+ const unmeasuredDead = dead.filter((item) => item.weightConfidence !== "estimated");
94
+ const measuredTokens = measuredDead.reduce((total, item) => total + item.alwaysLoadedTokens, 0);
95
+ const hasData = items.length > 0 && sessions > 0 && dead.length > 0;
96
+ const rates = pricingRates(config.pricingModel);
97
+ // Cached: one cache write per session + a cache read on every later turn.
98
+ const cacheReads = Math.max(0, totalTurns - sessions);
99
+ const windowCachedUsd = (measuredTokens * (sessions * rates.write5mPerM + cacheReads * rates.cacheReadPerM)) / 1_000_000;
100
+ const windowUncachedUsd = (measuredTokens * totalTurns * rates.inputPerM) / 1_000_000;
101
+ const monthFactor = DEFAULT_WINDOW_DAYS / windowDays;
102
+ return {
103
+ hasData,
104
+ loadedCount,
105
+ deadCount: dead.length,
106
+ measuredDeadCount: measuredDead.length,
107
+ unmeasuredDeadCount: unmeasuredDead.length,
108
+ deadTokens: measuredTokens,
109
+ monthlyDeadTokens: Math.round(measuredTokens * totalTurns * monthFactor),
110
+ wastePercent: loadedCount > 0 ? dead.length / loadedCount : 0,
111
+ monthlyUsd: roundMoney(windowCachedUsd * monthFactor),
112
+ monthlyUsdUpperBound: roundMoney(windowUncachedUsd * monthFactor),
113
+ deadItems: dead,
114
+ sessions,
115
+ totalTurns,
116
+ pricingModel: config.pricingModel,
117
+ windowDays
118
+ };
119
+ }
120
+ /**
121
+ * Adapt a dead-context result to a {@link CutAction} so it can flow into the
122
+ * ranked cut list / AI Receipt. Returns null unless there is MEASURED waste
123
+ * worth a dollar figure (MCP-only waste is shown as a count, not a cut $).
124
+ */
125
+ export function deadContextCutAction(result) {
126
+ if (!result.hasData || result.measuredDeadCount === 0 || result.monthlyUsd < 0.5) {
127
+ return null;
128
+ }
129
+ const pct = Math.round(result.wastePercent * 100);
130
+ return {
131
+ id: "dead-context",
132
+ title: `Trim ${result.measuredDeadCount} loaded tool${result.measuredDeadCount === 1 ? "" : "s"} your agent never calls`,
133
+ action: `Remove or lazy-load ${result.deadCount} of ${result.loadedCount} loaded item${result.loadedCount === 1 ? "" : "s"} ` +
134
+ `(${pct}% never invoked) to reclaim ~${result.deadTokens.toLocaleString("en-US")} tokens of dead context per turn.`,
135
+ estimatedMonthlySavingsUsd: result.monthlyUsd,
136
+ affectedSpendUsd: result.monthlyUsd,
137
+ recordCount: result.deadCount,
138
+ recordUnit: "tools",
139
+ // Dead-context savings come from inventory, not priced usage records, so
140
+ // there are no record IDs to dedupe against the spend-based cut actions.
141
+ recordIds: [],
142
+ confidence: "estimated",
143
+ kind: "context_trim"
144
+ };
145
+ }
146
+ function isDead(item, used) {
147
+ switch (item.kind) {
148
+ case "skill":
149
+ return !used.usedSkills.has(item.name);
150
+ case "subagent":
151
+ return !used.usedSubagents.has(item.name);
152
+ case "command":
153
+ return !used.usedCommands.has(item.name);
154
+ case "mcp_tool":
155
+ return !used.usedMcpTools.has(item.name);
156
+ case "mcp_server":
157
+ return !used.usedMcpServers.has(item.name);
158
+ default:
159
+ return false;
160
+ }
161
+ }
162
+ function pricingRates(model) {
163
+ const rule = findPricingRule(model) ?? findPricingRule(DEFAULT_PRICING_MODEL);
164
+ // Fallback to Sonnet-class numbers if even the default is somehow unmatched.
165
+ const inputPerM = rule?.inputPerM ?? 3;
166
+ return {
167
+ inputPerM,
168
+ cacheReadPerM: rule?.cacheReadPerM ?? inputPerM * 0.1,
169
+ write5mPerM: rule?.cacheWrite5mPerM ?? inputPerM * 1.25
170
+ };
171
+ }
172
+ function roundMoney(value) {
173
+ return Math.round(value * 100) / 100;
174
+ }
175
+ //# sourceMappingURL=deadContext.js.map
@@ -10,6 +10,8 @@ export type LocalDiscoveryResult = {
10
10
  rootPath: string;
11
11
  scannedFiles: number;
12
12
  skippedDirectories: string[];
13
+ /** Paths that could not be read (permissions, dangling symlinks, non-UTF8) — skipped, never fatal. */
14
+ unreadablePaths: string[];
13
15
  signals: UsageSignal[];
14
16
  secretsDetected: string[];
15
17
  redactedEvidence: string[];
package/dist/discovery.js CHANGED
@@ -22,30 +22,66 @@ const providerRules = [
22
22
  { provider: "cursor", kind: "invoice", patterns: [/cursor/i], confidence: 0.76 },
23
23
  { provider: "replit", kind: "invoice", patterns: [/replit/i], confidence: 0.72 }
24
24
  ];
25
- const secretAssignmentPattern = /\b([A-Z][A-Z0-9_]*(?:API_KEY|TOKEN|SECRET|PASSWORD))\s*=\s*([^\s#"']+)/g;
25
+ // Name-based redaction: any UPPER_SNAKE env-style assignment whose name ends
26
+ // in a secret-ish suffix. `KEY` deliberately subsumes API_KEY/ADMIN_KEY/etc —
27
+ // 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*=\s*([^\s#"']+)/g;
29
+ // Value-based redaction: known secret shapes regardless of how they're named.
26
30
  const providerSecretPatterns = [
27
31
  /sk-proj-[A-Za-z0-9_-]{20,}/g,
28
32
  /sk-ant-api\d{2}-[A-Za-z0-9_-]{20,}/g,
29
33
  /sk-[A-Za-z0-9_-]{20,}/g,
30
- /helicone_[A-Za-z0-9_-]{16,}/gi
34
+ /helicone_[A-Za-z0-9_-]{16,}/gi,
35
+ // GitHub tokens: classic PATs, OAuth, server/user/refresh, fine-grained.
36
+ /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g,
37
+ /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g,
38
+ // Google API keys.
39
+ /\bAIza[0-9A-Za-z_-]{30,}\b/g,
40
+ // JWTs (three base64url segments).
41
+ /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g,
42
+ // Slack tokens.
43
+ /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g,
44
+ // AWS access key IDs.
45
+ /\bAKIA[0-9A-Z]{16}\b/g,
46
+ // GitLab PATs and npm tokens.
47
+ /\bglpat-[A-Za-z0-9_-]{16,}\b/g,
48
+ /\bnpm_[A-Za-z0-9]{30,}\b/g
31
49
  ];
32
50
  export async function scanLocalUsageSignals(rootPath) {
33
51
  const result = {
34
52
  rootPath,
35
53
  scannedFiles: 0,
36
54
  skippedDirectories: [],
55
+ unreadablePaths: [],
37
56
  signals: [],
38
57
  secretsDetected: [],
39
58
  redactedEvidence: []
40
59
  };
41
60
  const secrets = new Set();
42
61
  const skipped = new Set();
62
+ const unreadable = new Set();
43
63
  await walk(rootPath, async (path) => {
44
- const fileInfo = await stat(path);
64
+ // A dangling symlink, permission-denied entry, or unreadable file must
65
+ // never reject the whole scan — real machines are messy. Skip and report.
66
+ let fileInfo;
67
+ try {
68
+ fileInfo = await stat(path);
69
+ }
70
+ catch {
71
+ unreadable.add(path);
72
+ return;
73
+ }
45
74
  if (fileInfo.size > maxFileBytes || !isInterestingFile(path)) {
46
75
  return;
47
76
  }
48
- const raw = await readFile(path, "utf8");
77
+ let raw;
78
+ try {
79
+ raw = await readFile(path, "utf8");
80
+ }
81
+ catch {
82
+ unreadable.add(path);
83
+ return;
84
+ }
49
85
  const redacted = redactSecrets(raw);
50
86
  const relativePath = relative(rootPath, path) || basename(path);
51
87
  result.scannedFiles += 1;
@@ -70,8 +106,11 @@ export async function scanLocalUsageSignals(rootPath) {
70
106
  confidence: rule.confidence
71
107
  });
72
108
  }
73
- }, skipped);
109
+ }, skipped, unreadable);
74
110
  result.skippedDirectories = Array.from(skipped).sort();
111
+ result.unreadablePaths = Array.from(unreadable)
112
+ .map((path) => relative(rootPath, path) || basename(path))
113
+ .sort();
75
114
  result.secretsDetected = Array.from(secrets).sort();
76
115
  result.signals = dedupeSignals(result.signals).sort((left, right) => {
77
116
  const provider = left.provider.localeCompare(right.provider);
@@ -96,8 +135,16 @@ function detectSecretNames(text) {
96
135
  }
97
136
  return Array.from(names);
98
137
  }
99
- async function walk(rootPath, visit, skipped) {
100
- const entries = await readdir(rootPath, { withFileTypes: true });
138
+ async function walk(rootPath, visit, skipped, unreadable) {
139
+ let entries;
140
+ try {
141
+ entries = await readdir(rootPath, { withFileTypes: true });
142
+ }
143
+ catch {
144
+ // Permission-denied or vanished directory: skip it, keep scanning.
145
+ unreadable.add(rootPath);
146
+ return;
147
+ }
101
148
  for (const entry of entries) {
102
149
  const path = join(rootPath, entry.name);
103
150
  if (entry.isDirectory()) {
@@ -105,10 +152,10 @@ async function walk(rootPath, visit, skipped) {
105
152
  skipped.add(entry.name);
106
153
  continue;
107
154
  }
108
- await walk(path, visit, skipped);
155
+ await walk(path, visit, skipped, unreadable);
109
156
  continue;
110
157
  }
111
- if (entry.isFile()) {
158
+ if (entry.isFile() || entry.isSymbolicLink()) {
112
159
  await visit(path);
113
160
  }
114
161
  }
package/dist/index.d.ts CHANGED
@@ -1,13 +1,17 @@
1
1
  export * from "./analyze.js";
2
+ export * from "./agentInventory.js";
2
3
  export * from "./attribution.js";
3
4
  export * from "./credentialDetection.js";
4
5
  export * from "./cutList.js";
6
+ export * from "./deadContext.js";
5
7
  export * from "./discovery.js";
8
+ export * from "./toolInvocations.js";
6
9
  export * from "./insights.js";
7
10
  export * from "./localAgentLogs.js";
8
11
  export * from "./modelPricing.js";
9
12
  export * from "./planMath.js";
10
13
  export * from "./sampleData.js";
14
+ export * from "./scanGuard.js";
11
15
  export * from "./schema.js";
12
16
  export * from "./sourceRegistry.js";
13
17
  export * from "./providerConnectors.js";
package/dist/index.js CHANGED
@@ -1,13 +1,17 @@
1
1
  export * from "./analyze.js";
2
+ export * from "./agentInventory.js";
2
3
  export * from "./attribution.js";
3
4
  export * from "./credentialDetection.js";
4
5
  export * from "./cutList.js";
6
+ export * from "./deadContext.js";
5
7
  export * from "./discovery.js";
8
+ export * from "./toolInvocations.js";
6
9
  export * from "./insights.js";
7
10
  export * from "./localAgentLogs.js";
8
11
  export * from "./modelPricing.js";
9
12
  export * from "./planMath.js";
10
13
  export * from "./sampleData.js";
14
+ export * from "./scanGuard.js";
11
15
  export * from "./schema.js";
12
16
  export * from "./sourceRegistry.js";
13
17
  export * from "./providerConnectors.js";
package/dist/planMath.js CHANGED
@@ -31,15 +31,19 @@ export function computePlanChecks(records) {
31
31
  const candidates = subscriptionPlans.filter((plan) => plan.agent === agent);
32
32
  const suggested = candidates.find((plan) => monthly <= plan.coversUpToUsd) ?? candidates[candidates.length - 1];
33
33
  const savings = suggested ? roundMoney(monthly - suggested.monthlyUsd) : undefined;
34
+ // Always state the projection basis: this number divides by ACTIVE days
35
+ // (days with usage), which can differ from the calendar window shown
36
+ // elsewhere on the readout — a technical reader will divide and check.
37
+ const basis = `projected from ${windowDays} active day${windowDays === 1 ? "" : "s"}`;
34
38
  let headline;
35
39
  if (!suggested) {
36
- headline = `${agent}: ~$${monthly.toFixed(2)}/mo at API rates.`;
40
+ headline = `${agent}: ~$${monthly.toFixed(2)}/mo at API rates (${basis}).`;
37
41
  }
38
42
  else if (typeof savings === "number" && savings > 0) {
39
- headline = `${agent}: ~$${monthly.toFixed(2)}/mo at API rates — ${suggested.name} ($${suggested.monthlyUsd}/mo) likely covers this, ~$${savings.toFixed(2)}/mo cheaper than paying per token.`;
43
+ headline = `${agent}: ~$${monthly.toFixed(2)}/mo at API rates (${basis}) — ${suggested.name} ($${suggested.monthlyUsd}/mo) likely covers this, ~$${savings.toFixed(2)}/mo cheaper than paying per token.`;
40
44
  }
41
45
  else {
42
- headline = `${agent}: ~$${monthly.toFixed(2)}/mo at API rates — within ${suggested.name} ($${suggested.monthlyUsd}/mo); pay-as-you-go API could be cheaper if you drop the subscription.`;
46
+ headline = `${agent}: ~$${monthly.toFixed(2)}/mo at API rates (${basis}) — within ${suggested.name} ($${suggested.monthlyUsd}/mo); pay-as-you-go API could be cheaper if you drop the subscription.`;
43
47
  }
44
48
  checks.push({
45
49
  agent,
@@ -12,9 +12,11 @@ type ProviderResponse = {
12
12
  export type ProviderQaPagination = {
13
13
  label: string;
14
14
  pagesFetched: number;
15
- stoppedBecause: "complete" | "missing_cursor" | "max_pages";
15
+ stoppedBecause: "complete" | "missing_cursor" | "max_pages" | "fetch_error";
16
16
  maxPages: number;
17
17
  limitPerPage?: number;
18
+ /** Present when stoppedBecause is "fetch_error": the sanitized reason the fetch stopped early. */
19
+ note?: string;
18
20
  };
19
21
  export type ProviderQaRateLimit = {
20
22
  label: string;
@@ -62,6 +64,8 @@ export type CreateProviderConnectionInput = {
62
64
  verifiedRecordCount: number;
63
65
  totalUsd: number;
64
66
  fetchedAt?: Date;
67
+ /** Record-derived completeness; the source's verification label mirrors it. */
68
+ completeness?: ProviderConnectorResult["completeness"];
65
69
  };
66
70
  export type TokenResolver = (reference: string) => string;
67
71
  export type Fetcher = (url: string, init?: {