@agent-finops/core 0.5.9 → 0.6.1

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/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
@@ -1,5 +1,7 @@
1
1
  export * from "./analyze.js";
2
2
  export * from "./agentInventory.js";
3
+ export * from "./activitySnapshot.js";
4
+ export * from "./activitySnapshotCache.js";
3
5
  export * from "./attribution.js";
4
6
  export * from "./credentialDetection.js";
5
7
  export * from "./contextHealth.js";
@@ -17,5 +19,7 @@ export * from "./sampleData.js";
17
19
  export * from "./scanGuard.js";
18
20
  export * from "./schema.js";
19
21
  export * from "./sourceRegistry.js";
22
+ export * from "./sourceStatus.js";
23
+ export * from "./stateTrust.js";
20
24
  export * from "./providerConnectors.js";
21
25
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  export * from "./analyze.js";
2
2
  export * from "./agentInventory.js";
3
+ export * from "./activitySnapshot.js";
4
+ export * from "./activitySnapshotCache.js";
3
5
  export * from "./attribution.js";
4
6
  export * from "./credentialDetection.js";
5
7
  export * from "./contextHealth.js";
@@ -17,5 +19,7 @@ export * from "./sampleData.js";
17
19
  export * from "./scanGuard.js";
18
20
  export * from "./schema.js";
19
21
  export * from "./sourceRegistry.js";
22
+ export * from "./sourceStatus.js";
23
+ export * from "./stateTrust.js";
20
24
  export * from "./providerConnectors.js";
21
25
  //# 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,43 @@ export type LocalAgentLogOptions = {
101
109
  /** Collect privacy-safe Codex invocation summaries during the same JSON pass. */
102
110
  collectCodexInvocationEvidence?: boolean;
103
111
  };
112
+ /**
113
+ * Options accepted by the financial-only loader. Invocation collection is
114
+ * intentionally unavailable: this path reads only the evidence needed for a
115
+ * financial snapshot and transcript-reported plan limits.
116
+ */
117
+ export type LocalAgentFinancialLogOptions = Omit<LocalAgentLogOptions, "collectCodexInvocationEvidence">;
118
+ export type LocalAgentLogDiagnosticCode = "directory_missing" | "directory_unreadable" | "file_unreadable" | "malformed_jsonl" | "unsupported_token_shape";
119
+ export type LocalAgentLogDiagnostic = {
120
+ agent: LocalAgentCall["agent"];
121
+ code: LocalAgentLogDiagnosticCode;
122
+ severity: "info" | "warning" | "error";
123
+ /** Privacy-safe summary; absolute local paths and transcript text are omitted. */
124
+ message: string;
125
+ count: number;
126
+ };
127
+ export type LocalAgentSourceScan = {
128
+ agent: LocalAgentCall["agent"];
129
+ directoryStatus: "readable" | "missing" | "unreadable";
130
+ filesDiscovered: number;
131
+ filesParsed: number;
132
+ /** Malformed lines observed inside `jsonlValidationCoverage`. */
133
+ malformedLines: number;
134
+ unreadableFiles: number;
135
+ unsupportedUsageSnapshots: number;
136
+ /** Regular files safely excluded because their metadata predates `sinceIso`. */
137
+ filesSkippedBeforeWindow?: number;
138
+ /** Codex files resolved from bounded head/tail financial evidence. */
139
+ filesReadFinancially?: number;
140
+ /** Bytes not replayed as events after the required Codex financial state was proved. */
141
+ bytesSkippedAsNonFinancialHistory?: number;
142
+ /** JSONL lines classified from their envelope and skipped before JSON decoding. */
143
+ nonFinancialLinesPrefiltered?: number;
144
+ /** Bytes covered by the non-financial event prefilter. */
145
+ nonFinancialBytesPrefiltered?: number;
146
+ /** Whether JSON syntax was checked for every line or financial events only. */
147
+ jsonlValidationCoverage?: "complete" | "financial_events_only";
148
+ };
104
149
  export type LocalAgentLogResult = {
105
150
  records: UsageRecord[];
106
151
  /** Per-call entries before aggregation (for drill-down/debugging). */
@@ -108,9 +153,18 @@ export type LocalAgentLogResult = {
108
153
  filesParsed: number;
109
154
  /** Which agents actually had data on this machine. */
110
155
  agentsDetected: Array<LocalAgentCall["agent"]>;
156
+ /** Per-source scan outcome, including honest empty and unsupported states. */
157
+ sourceScans: LocalAgentSourceScan[];
158
+ /** Structured, privacy-safe failures/warnings encountered during the scan. */
159
+ diagnostics: LocalAgentLogDiagnostic[];
111
160
  /** Present only when requested; contains counts/basenames, never raw text. */
112
161
  codexInvocationFiles?: ParsedInvocationFile[];
113
162
  };
163
+ type TranscriptParseDiagnostic = {
164
+ code: "malformed_jsonl" | "unsupported_token_shape";
165
+ count: number;
166
+ };
167
+ type TranscriptParseDiagnosticHandler = (diagnostic: TranscriptParseDiagnostic) => void;
114
168
  /**
115
169
  * Codex rollout/compaction files can repeat the same session's cumulative
116
170
  * token counter. Keep only the latest snapshot per session so financial value,
@@ -119,11 +173,22 @@ export type LocalAgentLogResult = {
119
173
  */
120
174
  export declare function dedupeCumulativeSessionCalls(calls: LocalAgentCall[]): LocalAgentCall[];
121
175
  /** Parse one Claude Code transcript (JSONL). Exported for tests. */
122
- export declare function parseClaudeCodeTranscript(content: string, filePath?: string, sinceMs?: number): LocalAgentCall[];
176
+ export declare function parseClaudeCodeTranscript(content: string, filePath?: string, sinceMs?: number, onDiagnostic?: TranscriptParseDiagnosticHandler): LocalAgentCall[];
123
177
  /** 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[];
178
+ export declare function parseCodexRollout(content: string, onEntry?: (entry: Record<string, unknown>) => void, onDiagnostic?: TranscriptParseDiagnosticHandler): LocalAgentCall[];
125
179
  /** Scan this machine's agent logs and return aggregated UsageRecords. */
126
180
  export declare function loadLocalAgentUsage(options?: LocalAgentLogOptions): Promise<LocalAgentLogResult>;
181
+ /**
182
+ * Stream only the financial evidence required by init/status snapshots.
183
+ *
184
+ * Unlike `loadLocalAgentUsage`, this path never derives prompts, file focus,
185
+ * tool activity, or invocation evidence. It deliberately returns the same
186
+ * result contract so callers can preserve the existing evidence and
187
+ * diagnostics vocabulary without maintaining a second loader schema. Codex
188
+ * project attribution intentionally uses only root metadata; home-launched
189
+ * tool-workdir inference remains exclusive to the full qualitative loader.
190
+ */
191
+ export declare function loadLocalAgentFinancialUsage(options?: LocalAgentFinancialLogOptions): Promise<LocalAgentLogResult>;
127
192
  /** Aggregate per-call usage into one UsageRecord per day+agent+model+project. */
128
193
  export declare function aggregateCalls(calls: LocalAgentCall[]): UsageRecord[];
129
194
  /**
@@ -132,4 +197,5 @@ export declare function aggregateCalls(calls: LocalAgentCall[]): UsageRecord[];
132
197
  * This intentionally favors dropping a suspicious token over displaying it.
133
198
  */
134
199
  export declare function sanitizeLocalActivityText(value: string): string;
200
+ export {};
135
201
  //# sourceMappingURL=localAgentLogs.d.ts.map