@agent-finops/core 0.5.9 → 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 CHANGED
@@ -14,6 +14,12 @@ invoice reconciliation, or ROI.
14
14
 
15
15
  Local API-equivalent estimates, subscription context, and official
16
16
  provider-reported cost are separate concepts and must not be added together.
17
+ Reader/connector validation (`live_verified`, `fixture_verified`, `untested`,
18
+ or `failed`) is also separate from each number's financial evidence
19
+ (`verified`, `estimated`, `detected_unverified`, or `missing`). A live-tested
20
+ local reader still emits estimates or missing cost—not billed spend.
21
+ Source-boundary approval is a third, permission-only field: approving a folder
22
+ for read-only scanning never verifies any financial number inside it.
17
23
 
18
24
  MIT licensed. See the repository
19
25
  [README](https://github.com/futurastudio/ai-spend-agent#readme) and
@@ -2,6 +2,7 @@ export type UsageSignalKind = "dependency" | "config" | "environment" | "source_
2
2
  export type UsageSignal = {
3
3
  provider: string;
4
4
  kind: UsageSignalKind;
5
+ /** Deterministic opaque reference; never a repository-controlled filename. */
5
6
  filePath: string;
6
7
  /** Stable rule identity; present on scanner-produced signals. */
7
8
  ruleId?: string;
@@ -12,6 +13,7 @@ export type UsageSignal = {
12
13
  confidence: number;
13
14
  };
14
15
  export type UsageSignalEvidence = {
16
+ /** Same deterministic opaque reference as UsageSignal.filePath. */
15
17
  file: string;
16
18
  provider: string;
17
19
  signal: UsageSignalKind;
@@ -20,12 +22,14 @@ export type UsageSignalEvidence = {
20
22
  export type LocalDiscoveryResult = {
21
23
  rootPath: string;
22
24
  scannedFiles: number;
25
+ /** Deterministic opaque references for denied/heavy descendant directories. */
23
26
  skippedDirectories: string[];
24
- /** Symbolic links found below the approved root. They are never followed. */
27
+ /** Opaque references for symbolic links below the approved root. They are never followed. */
25
28
  skippedSymlinks: string[];
26
- /** Paths that could not be read (permissions, vanished entries, non-UTF8) — skipped, never fatal. */
29
+ /** Opaque references for unreadable descendants — skipped, never fatal. */
27
30
  unreadablePaths: string[];
28
31
  signals: UsageSignal[];
32
+ /** Deterministic opaque references for detected secret assignments. */
29
33
  secretsDetected: string[];
30
34
  redactedEvidence: string[];
31
35
  };
package/dist/discovery.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { lstat, readdir, readFile } from "node:fs/promises";
2
- import { basename, join, relative } from "node:path";
2
+ import { createHash } from "node:crypto";
3
+ import { basename, join, relative, sep } from "node:path";
3
4
  import { resolveSafeScanRoot } from "./scanGuard.js";
4
5
  const skippedDirectoryNames = new Set([
5
6
  ".git",
@@ -93,13 +94,15 @@ export async function scanLocalUsageSignals(rootPath) {
93
94
  return;
94
95
  }
95
96
  const redacted = redactSecrets(raw);
96
- const relativePath = relative(canonicalRoot, path) || basename(path);
97
+ const relativePath = relative(canonicalRoot, path) || ".";
98
+ const pathReference = opaquePathReference(relativePath);
97
99
  result.scannedFiles += 1;
98
100
  for (const name of detectSecretNames(raw)) {
99
- secrets.add(name);
100
- result.redactedEvidence.push(`${relativePath}: ${name}=[REDACTED]`);
101
+ const secretReference = opaqueSecretReference(name);
102
+ secrets.add(secretReference);
103
+ result.redactedEvidence.push(`${pathReference}: ${secretReference}=[REDACTED]`);
101
104
  }
102
- for (const signal of detectExportSignals(relativePath, redacted)) {
105
+ for (const signal of detectExportSignals(relativePath, redacted, pathReference)) {
103
106
  result.signals.push(signal);
104
107
  }
105
108
  for (const rule of providerRules) {
@@ -108,11 +111,11 @@ export async function scanLocalUsageSignals(rootPath) {
108
111
  continue;
109
112
  }
110
113
  const kind = inferKind(path, rule.kind);
111
- const evidenceMeta = buildEvidence(relativePath, rule.provider, kind, rule.id);
114
+ const evidenceMeta = buildEvidence(pathReference, rule.provider, kind, rule.id);
112
115
  result.signals.push({
113
116
  provider: rule.provider,
114
117
  kind,
115
- filePath: relativePath,
118
+ filePath: pathReference,
116
119
  ruleId: rule.id,
117
120
  evidenceMeta,
118
121
  evidence: encodeEvidence(evidenceMeta),
@@ -120,12 +123,14 @@ export async function scanLocalUsageSignals(rootPath) {
120
123
  });
121
124
  }
122
125
  }, skipped, symlinks, unreadable);
123
- result.skippedDirectories = Array.from(skipped).sort();
126
+ result.skippedDirectories = Array.from(skipped)
127
+ .map((path) => opaquePathReference(relative(canonicalRoot, path) || "."))
128
+ .sort();
124
129
  result.skippedSymlinks = Array.from(symlinks)
125
- .map((path) => relative(canonicalRoot, path) || basename(path))
130
+ .map((path) => opaquePathReference(relative(canonicalRoot, path) || "."))
126
131
  .sort();
127
132
  result.unreadablePaths = Array.from(unreadable)
128
- .map((path) => relative(canonicalRoot, path) || basename(path))
133
+ .map((path) => opaquePathReference(relative(canonicalRoot, path) || "."))
129
134
  .sort();
130
135
  result.secretsDetected = Array.from(secrets).sort();
131
136
  result.signals = dedupeSignals(result.signals).sort((left, right) => {
@@ -169,7 +174,7 @@ async function walk(rootPath, visit, skipped, symlinks, unreadable) {
169
174
  }
170
175
  if (entry.isDirectory()) {
171
176
  if (skippedDirectoryNames.has(entry.name)) {
172
- skipped.add(entry.name);
177
+ skipped.add(path);
173
178
  continue;
174
179
  }
175
180
  await walk(path, visit, skipped, symlinks, unreadable);
@@ -203,7 +208,7 @@ function inferKind(path, fallback) {
203
208
  }
204
209
  return fallback;
205
210
  }
206
- function detectExportSignals(filePath, redacted) {
211
+ function detectExportSignals(filePath, redacted, pathReference) {
207
212
  const lowerPath = filePath.toLowerCase();
208
213
  const lowerText = redacted.toLowerCase();
209
214
  const providers = ["openai", "anthropic", "cursor", "helicone", "langfuse", "gemini", "google", "replit"];
@@ -219,11 +224,11 @@ function detectExportSignals(filePath, redacted) {
219
224
  const normalizedProvider = provider === "google" ? "gemini" : provider;
220
225
  const kind = isInvoice ? "invoice" : "provider_export";
221
226
  const ruleId = `export.${normalizedProvider}.${kind}`;
222
- const evidenceMeta = buildEvidence(filePath, normalizedProvider, kind, ruleId);
227
+ const evidenceMeta = buildEvidence(pathReference, normalizedProvider, kind, ruleId);
223
228
  return [{
224
229
  provider: normalizedProvider,
225
230
  kind,
226
- filePath,
231
+ filePath: pathReference,
227
232
  ruleId,
228
233
  evidenceMeta,
229
234
  evidence: encodeEvidence(evidenceMeta),
@@ -236,6 +241,23 @@ function buildEvidence(file, provider, signal, ruleId) {
236
241
  function encodeEvidence(evidence) {
237
242
  return JSON.stringify(evidence);
238
243
  }
244
+ /**
245
+ * Repository-controlled descendant names are untrusted metadata. Discovery may
246
+ * use the real relative path internally for classification, but persisted and
247
+ * agent-facing output receives only this stable, non-semantic reference.
248
+ */
249
+ function opaquePathReference(relativePath) {
250
+ // Normalize only the current platform's separator. A literal backslash is
251
+ // a valid POSIX filename character and must not alias a nested POSIX path.
252
+ const normalized = (relativePath || ".").split(sep).join("/");
253
+ const digest = createHash("sha256").update(normalized, "utf8").digest("hex").slice(0, 16);
254
+ return `path-${digest}`;
255
+ }
256
+ /** Repository-controlled environment names are untrusted metadata too. */
257
+ function opaqueSecretReference(name) {
258
+ const digest = createHash("sha256").update(name, "utf8").digest("hex").slice(0, 16);
259
+ return `secret-${digest}`;
260
+ }
239
261
  function dedupeSignals(signals) {
240
262
  const byKey = new Map();
241
263
  for (const signal of signals) {
package/dist/glance.d.ts CHANGED
@@ -11,8 +11,12 @@ export type GlanceSession = {
11
11
  durationMinutes: number;
12
12
  apiEquivalentUsd: number | null;
13
13
  costConfidence: "estimated" | "missing";
14
- inputTokens: number;
15
- outputTokens: number;
14
+ /** Null when the transcript reports only a total and no priceable breakdown. */
15
+ inputTokens: number | null;
16
+ /** Null when the transcript reports only a total and no priceable breakdown. */
17
+ outputTokens: number | null;
18
+ /** Provider-reported total retained without inventing input/output components. */
19
+ reportedTotalTokens?: number;
16
20
  };
17
21
  export type GlanceLimit = {
18
22
  agent: LocalAgentCall["agent"];
package/dist/glance.js CHANGED
@@ -193,6 +193,10 @@ function groupSessions(calls) {
193
193
  const last = ordered[ordered.length - 1];
194
194
  const costs = ordered.map(callCost);
195
195
  const costComplete = costs.every((cost) => typeof cost === "number");
196
+ const tokenComponentsComplete = ordered.every((call) => call.usageSupport !== "unsupported_token_shape");
197
+ const reportedTotalTokens = tokenComponentsComplete
198
+ ? undefined
199
+ : sessionReportedTotalTokens(ordered);
196
200
  const startedAt = ordered
197
201
  .map((call) => call.startedAt ?? call.timestamp)
198
202
  .sort()[0];
@@ -205,16 +209,13 @@ function groupSessions(calls) {
205
209
  startedAt,
206
210
  lastActivityAt: last.timestamp,
207
211
  apiEquivalentUsd: costComplete ? costs.reduce((total, cost) => total + cost, 0) : null,
208
- inputTokens: sum(ordered, (call) => (call.usage.inputTokens +
209
- (call.usage.cacheReadTokens ?? 0) +
210
- (call.usage.cacheWrite5mTokens ?? 0) +
211
- (call.usage.cacheWrite1hTokens ?? 0))),
212
- outputTokens: sum(ordered, (call) => call.usage.outputTokens),
213
- totalTokens: sum(ordered, (call) => (call.usage.inputTokens +
214
- call.usage.outputTokens +
215
- (call.usage.cacheReadTokens ?? 0) +
216
- (call.usage.cacheWrite5mTokens ?? 0) +
217
- (call.usage.cacheWrite1hTokens ?? 0))),
212
+ inputTokens: tokenComponentsComplete
213
+ ? sum(ordered, inputSideTokens)
214
+ : null,
215
+ outputTokens: tokenComponentsComplete
216
+ ? sum(ordered, (call) => call.usage.outputTokens)
217
+ : null,
218
+ ...(reportedTotalTokens !== undefined ? { reportedTotalTokens } : {}),
218
219
  activity: ordered
219
220
  .slice()
220
221
  .reverse()
@@ -237,7 +238,10 @@ function toGlanceSession(session, now, activeWithinMinutes) {
237
238
  apiEquivalentUsd: roundUsd(session.apiEquivalentUsd),
238
239
  costConfidence: session.apiEquivalentUsd === null ? "missing" : "estimated",
239
240
  inputTokens: session.inputTokens,
240
- outputTokens: session.outputTokens
241
+ outputTokens: session.outputTokens,
242
+ ...(session.reportedTotalTokens !== undefined
243
+ ? { reportedTotalTokens: session.reportedTotalTokens }
244
+ : {})
241
245
  };
242
246
  }
243
247
  function latestLimits(calls, now) {
@@ -516,8 +520,11 @@ function buildPrimaryAction(input) {
516
520
  : input.limits.length > 0
517
521
  ? "No transcript-reported plan window is currently projected to exhaust before reset."
518
522
  : "Not available; no plan window was reported in the local transcript.";
523
+ const reportedTotalEvidence = input.currentSession?.reportedTotalTokens === undefined
524
+ ? ""
525
+ : `; provider-reported total tokens=${input.currentSession.reportedTotalTokens.toLocaleString("en-US")}; input/output breakdown unavailable`;
519
526
  const sessionEvidence = input.currentSession
520
- ? `${input.currentSession.agent}; model=${input.currentSession.model}; status=${input.currentSession.status}; API-equivalent value=${input.currentSession.apiEquivalentUsd === null ? "unpriced" : `$${input.currentSession.apiEquivalentUsd.toFixed(2)}`} (${input.currentSession.costConfidence}, not billed spend)`
527
+ ? `${input.currentSession.agent}; model=${input.currentSession.model}; status=${input.currentSession.status}; API-equivalent value=${formatGlanceUsd(input.currentSession.apiEquivalentUsd)} (${input.currentSession.costConfidence}, not billed spend)${reportedTotalEvidence}`
521
528
  : "not available";
522
529
  const promptLines = [
523
530
  "Use this aibill Glance evidence to prepare a bounded session handoff.",
@@ -603,8 +610,37 @@ function limitActionName(limit) {
603
610
  : limit.name;
604
611
  }
605
612
  function callCost(call) {
613
+ if (call.usageSupport === "unsupported_token_shape")
614
+ return undefined;
606
615
  return estimateTokenCostUsd(call.model, call.usage);
607
616
  }
617
+ function inputSideTokens(call) {
618
+ return call.usage.inputTokens +
619
+ (call.usage.cacheReadTokens ?? 0) +
620
+ (call.usage.cacheWrite5mTokens ?? 0) +
621
+ (call.usage.cacheWrite1hTokens ?? 0);
622
+ }
623
+ /**
624
+ * Preserve a provider-reported total when a session contains a total-only
625
+ * snapshot. Complete calls can be added from their real components; an
626
+ * unsupported call without a trustworthy total makes the aggregate unknown.
627
+ */
628
+ function sessionReportedTotalTokens(calls) {
629
+ let total = 0;
630
+ for (const call of calls) {
631
+ if (call.usageSupport === "unsupported_token_shape") {
632
+ if (typeof call.reportedTotalTokens !== "number" ||
633
+ !Number.isFinite(call.reportedTotalTokens) ||
634
+ call.reportedTotalTokens < 0) {
635
+ return undefined;
636
+ }
637
+ total += call.reportedTotalTokens;
638
+ continue;
639
+ }
640
+ total += inputSideTokens(call) + call.usage.outputTokens;
641
+ }
642
+ return total;
643
+ }
608
644
  function uniqueAgents(calls) {
609
645
  return [...new Set(calls.map((call) => call.agent))].sort();
610
646
  }
@@ -612,7 +648,20 @@ function sum(calls, pick) {
612
648
  return calls.reduce((total, call) => total + pick(call), 0);
613
649
  }
614
650
  function roundUsd(value) {
615
- return value === null ? null : Math.round(value * 100) / 100;
651
+ if (value === null)
652
+ return null;
653
+ if (value > 0 && value < 0.01) {
654
+ const precise = Math.round(value * 1_000_000) / 1_000_000;
655
+ return precise === 0 ? value : precise;
656
+ }
657
+ return Math.round(value * 100) / 100;
658
+ }
659
+ function formatGlanceUsd(value) {
660
+ if (value === null)
661
+ return "unpriced";
662
+ if (value > 0 && value < 0.01)
663
+ return "<$0.01";
664
+ return `$${value.toFixed(2)}`;
616
665
  }
617
666
  function roundPercent(value) {
618
667
  return Math.round(value * 10) / 10;
package/dist/index.d.ts CHANGED
@@ -17,5 +17,7 @@ export * from "./sampleData.js";
17
17
  export * from "./scanGuard.js";
18
18
  export * from "./schema.js";
19
19
  export * from "./sourceRegistry.js";
20
+ export * from "./sourceStatus.js";
21
+ export * from "./stateTrust.js";
20
22
  export * from "./providerConnectors.js";
21
23
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -17,5 +17,7 @@ export * from "./sampleData.js";
17
17
  export * from "./scanGuard.js";
18
18
  export * from "./schema.js";
19
19
  export * from "./sourceRegistry.js";
20
+ export * from "./sourceStatus.js";
21
+ export * from "./stateTrust.js";
20
22
  export * from "./providerConnectors.js";
21
23
  //# sourceMappingURL=index.js.map
@@ -41,6 +41,14 @@ export type LocalAgentCall = {
41
41
  latestTurnUsage?: LocalAgentTurnUsage;
42
42
  /** Whether `usage` is one model turn or the session's cumulative financial total. */
43
43
  usageScope?: "turn" | "session_cumulative";
44
+ /**
45
+ * Whether the transcript exposed the input/output components required for
46
+ * pricing. A total-only snapshot is still usage evidence, but pricing it as
47
+ * zero would be false precision.
48
+ */
49
+ usageSupport?: "complete" | "unsupported_token_shape";
50
+ /** Provider-reported total retained when component fields are unavailable. */
51
+ reportedTotalTokens?: number;
44
52
  usage: TokenUsage;
45
53
  sessionId?: string;
46
54
  /** Provider-reported plan windows embedded in the transcript, when present. */
@@ -101,6 +109,24 @@ export type LocalAgentLogOptions = {
101
109
  /** Collect privacy-safe Codex invocation summaries during the same JSON pass. */
102
110
  collectCodexInvocationEvidence?: boolean;
103
111
  };
112
+ export type LocalAgentLogDiagnosticCode = "directory_missing" | "directory_unreadable" | "file_unreadable" | "malformed_jsonl" | "unsupported_token_shape";
113
+ export type LocalAgentLogDiagnostic = {
114
+ agent: LocalAgentCall["agent"];
115
+ code: LocalAgentLogDiagnosticCode;
116
+ severity: "info" | "warning" | "error";
117
+ /** Privacy-safe summary; absolute local paths and transcript text are omitted. */
118
+ message: string;
119
+ count: number;
120
+ };
121
+ export type LocalAgentSourceScan = {
122
+ agent: LocalAgentCall["agent"];
123
+ directoryStatus: "readable" | "missing" | "unreadable";
124
+ filesDiscovered: number;
125
+ filesParsed: number;
126
+ malformedLines: number;
127
+ unreadableFiles: number;
128
+ unsupportedUsageSnapshots: number;
129
+ };
104
130
  export type LocalAgentLogResult = {
105
131
  records: UsageRecord[];
106
132
  /** Per-call entries before aggregation (for drill-down/debugging). */
@@ -108,9 +134,18 @@ export type LocalAgentLogResult = {
108
134
  filesParsed: number;
109
135
  /** Which agents actually had data on this machine. */
110
136
  agentsDetected: Array<LocalAgentCall["agent"]>;
137
+ /** Per-source scan outcome, including honest empty and unsupported states. */
138
+ sourceScans: LocalAgentSourceScan[];
139
+ /** Structured, privacy-safe failures/warnings encountered during the scan. */
140
+ diagnostics: LocalAgentLogDiagnostic[];
111
141
  /** Present only when requested; contains counts/basenames, never raw text. */
112
142
  codexInvocationFiles?: ParsedInvocationFile[];
113
143
  };
144
+ type TranscriptParseDiagnostic = {
145
+ code: "malformed_jsonl" | "unsupported_token_shape";
146
+ count: number;
147
+ };
148
+ type TranscriptParseDiagnosticHandler = (diagnostic: TranscriptParseDiagnostic) => void;
114
149
  /**
115
150
  * Codex rollout/compaction files can repeat the same session's cumulative
116
151
  * token counter. Keep only the latest snapshot per session so financial value,
@@ -119,9 +154,9 @@ export type LocalAgentLogResult = {
119
154
  */
120
155
  export declare function dedupeCumulativeSessionCalls(calls: LocalAgentCall[]): LocalAgentCall[];
121
156
  /** Parse one Claude Code transcript (JSONL). Exported for tests. */
122
- export declare function parseClaudeCodeTranscript(content: string, filePath?: string, sinceMs?: number): LocalAgentCall[];
157
+ export declare function parseClaudeCodeTranscript(content: string, filePath?: string, sinceMs?: number, onDiagnostic?: TranscriptParseDiagnosticHandler): LocalAgentCall[];
123
158
  /** Parse one Codex rollout file (JSONL event stream). Exported for tests. */
124
- export declare function parseCodexRollout(content: string, onEntry?: (entry: Record<string, unknown>) => void): LocalAgentCall[];
159
+ export declare function parseCodexRollout(content: string, onEntry?: (entry: Record<string, unknown>) => void, onDiagnostic?: TranscriptParseDiagnosticHandler): LocalAgentCall[];
125
160
  /** Scan this machine's agent logs and return aggregated UsageRecords. */
126
161
  export declare function loadLocalAgentUsage(options?: LocalAgentLogOptions): Promise<LocalAgentLogResult>;
127
162
  /** Aggregate per-call usage into one UsageRecord per day+agent+model+project. */
@@ -132,4 +167,5 @@ export declare function aggregateCalls(calls: LocalAgentCall[]): UsageRecord[];
132
167
  * This intentionally favors dropping a suspicious token over displaying it.
133
168
  */
134
169
  export declare function sanitizeLocalActivityText(value: string): string;
170
+ export {};
135
171
  //# sourceMappingURL=localAgentLogs.d.ts.map