ai-spend-agent 0.7.3 → 0.8.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.
Files changed (3) hide show
  1. package/README.md +9 -5
  2. package/dist/index.js +174 -63
  3. package/package.json +4 -4
package/README.md CHANGED
@@ -10,9 +10,12 @@ npx ai-spend-agent
10
10
  npx aibill
11
11
  ```
12
12
 
13
- Run `npx aibill init` from a project to detect machine-wide local Claude Code
14
- and Codex history, print the first evidence-labeled personal receipt, and seed a private
15
- aggregate cache under `~/.aibill/cache/`. Init never replaces missing personal
13
+ Run `npx aibill init` from a project to detect machine-wide Claude Code, Codex,
14
+ and experimental Gemini CLI financial evidence, print the first
15
+ evidence-labeled personal receipt, and seed a private Claude/Codex aggregate
16
+ cache under `~/.aibill/cache/`. Gemini financial rows come only from
17
+ `~/.gemini/tmp/<opaque-project-id>/chats/**/*.{json,jsonl}`; `logs.json` is a
18
+ presence signal and never a financial source. Init never replaces missing personal
16
19
  evidence with the bundled sample and never overwrites existing connected
17
20
  source or audit state.
18
21
 
@@ -23,8 +26,9 @@ hook. Subscription runway appears only when it was transcript-reported; `~`
23
26
  means API-equivalent value, and untilded `billed` money requires verified
24
27
  provider evidence. Remove it with `npx aibill statusline uninstall`.
25
28
 
26
- It reads local Claude Code and Codex metadata, labels API-equivalent estimates,
27
- and can optionally add official OpenAI or Anthropic provider-reported cost
29
+ It reads supported local Claude Code, Codex, and Gemini CLI financial metadata,
30
+ labels API-equivalent estimates, and can optionally add official OpenAI or
31
+ Anthropic provider-reported cost
28
32
  through an environment-variable reference. No product telemetry is sent.
29
33
  aibill never sits in the inference path and never stores, prints, or proxies provider credentials.
30
34
 
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import { mkdir, readFile, rm, stat } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import { basename, dirname, extname, join, resolve } from "node:path";
6
6
  import { fileURLToPath, pathToFileURL } from "node:url";
7
- import { analyzeSpend, attributeUsageRecords, buildUsageGlance, buildActivitySnapshot, loadContextHealth, detectLocalCredentials, detectLocalPlans, redactSecrets, readSafeStateText, invalidateConnectedSpendTrustReceipt, resolveSafeScanRoot, resolveSafeStateDirectory, subscriptionPlans, unsafeScanRootReason, selectProviderFinancialHeadlineRecords, writeSafeStateText, verifyConnectedSpendTrustReceipt, verifyConnectedSourceRegistryTrustReceipt, writeConnectedSpendTrustReceipt, loadDeadContext, sampleDeadContext, latestObservedWorkingDirectory, downgradeSampleUsageEvidence, isBundledSampleUsage, loadLocalAgentUsage, loadLocalAgentFinancialUsage, localAgentFormatDescriptors, localAgentFormatLabel, loadSampleUsageData, parseUsageRecord, scanLocalUsageSignals, buildMissingSourcePrompts, confirmMapping, createProviderConnectorStub, createLocalFolderSourceRegistry, createScanAuditLog, fetchProviderUsageRecords, addApprovedSource, normalizeSourceRegistry, downgradeUntrustedSourceRegistryClaims, buildSourceStatuses, slugifySourceId, financialEvidenceForRecords, formatSourceStatuses, readActivitySnapshot, recordActivitySnapshotRefreshFailure, sourceStatusDefinitions, writeActivitySnapshot } from "@agent-finops/core";
7
+ import { analyzeSpend, attributeUsageRecords, buildUsageGlance, buildActivitySnapshot, loadContextHealth, detectLocalCredentials, detectLocalPlans, redactSecrets, readSafeStateText, invalidateConnectedSpendTrustReceipt, resolveSafeScanRoot, resolveSafeStateDirectory, subscriptionPlans, unsafeScanRootReason, selectProviderFinancialHeadlineRecords, summarizeProviderFinancials, providerFinancialCompleteness, writeSafeStateText, verifyConnectedSpendTrustReceipt, verifyConnectedSourceRegistryTrustReceipt, writeConnectedSpendTrustReceipt, loadDeadContext, sampleDeadContext, latestObservedWorkingDirectory, downgradeSampleUsageEvidence, isBundledSampleUsage, loadLocalAgentUsage, loadLocalAgentFinancialUsage, localAgentFormatDescriptors, localAgentFormatLabel, localAgentFormatSupports, loadSampleUsageData, parseUsageRecord, scanLocalUsageSignals, buildMissingSourcePrompts, confirmMapping, createProviderConnectorStub, createProviderConnection, createLocalFolderSourceRegistry, createScanAuditLog, fetchProviderUsageRecords, addApprovedSource, normalizeSourceRegistry, downgradeUntrustedSourceRegistryClaims, buildSourceStatuses, applyProviderContractGate, applyProviderContractGateToSourceRegistry, slugifySourceId, financialEvidenceForRecords, formatSourceStatuses, readActivitySnapshot, recordActivitySnapshotRefreshFailure, sourceStatusDefinitions, writeActivitySnapshot } from "@agent-finops/core";
8
8
  import { StatuslineInstallerError, installClaudeStatusline, uninstallClaudeStatusline } from "./statuslineInstaller.js";
9
9
  import { readStatuslineCache, renderStatusline } from "./statuslineRuntime.js";
10
10
  import { generateActionPlanMarkdown, generateApplyArtifactMarkdown, generateDemoPackageMarkdown, generateHtmlReport, generateMarkdownReport, generatePlainEnglishSummary, generatePolicyConfigDraftMarkdown, generateReportCardCaption, generateReportCardSvg, generateVerificationPlanMarkdown, groupByDimensions } from "@agent-finops/report";
@@ -177,7 +177,7 @@ async function quickstartCommand(args) {
177
177
  // table + window instead of repeating the whole readout.
178
178
  view: args.groupBy ? "breakdown" : "full"
179
179
  });
180
- const header = [` ${dataModeBanner(mode)}`, ...warnings.map((warning) => ` ! ${warning}`)].join("\n");
180
+ const header = [` ${dataModeBanner(mode, summaryRecords)}`, ...warnings.map((warning) => ` ! ${warning}`)].join("\n");
181
181
  return ok(`${header}\n${summaryText}`);
182
182
  }
183
183
  async function glanceCommand(args) {
@@ -187,12 +187,14 @@ async function glanceCommand(args) {
187
187
  const logs = await loadLocalAgentUsage({
188
188
  claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
189
189
  codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
190
+ geminiSessionsDir: process.env.AI_SPEND_GEMINI_LOGS_DIR,
190
191
  sinceIso: sinceIsoForDays(sinceDays),
191
192
  collectCodexInvocationEvidence: true
192
193
  });
194
+ const glanceCalls = logs.calls.filter((call) => (localAgentFormatSupports(call.agent, "glance")));
193
195
  const calls = args.project
194
- ? logs.calls.filter((call) => call.project === args.project)
195
- : logs.calls;
196
+ ? glanceCalls.filter((call) => call.project === args.project)
197
+ : glanceCalls;
196
198
  const latestWorkingDirectory = latestObservedWorkingDirectory(calls);
197
199
  const contextProjectDir = args.pathExplicit
198
200
  ? resolve(args.path)
@@ -228,10 +230,12 @@ async function glanceCommand(args) {
228
230
  codexInvocationFiles: logs.codexInvocationFiles
229
231
  });
230
232
  const snapshot = buildUsageGlance(calls, {
231
- filesParsed: logs.filesParsed,
232
- detectedAgents: logs.agentsDetected,
233
+ filesParsed: logs.sourceScans
234
+ .filter((scan) => localAgentFormatSupports(scan.agent, "glance"))
235
+ .reduce((total, scan) => total + scan.filesParsed, 0),
236
+ detectedAgents: logs.agentsDetected.filter((agent) => (localAgentFormatSupports(agent, "glance"))),
233
237
  detectedPlans,
234
- limitCalls: logs.calls,
238
+ limitCalls: glanceCalls,
235
239
  contextHealth
236
240
  });
237
241
  return ok(JSON.stringify(snapshot));
@@ -244,12 +248,14 @@ async function contextHealthCommand(args) {
244
248
  const logs = await loadLocalAgentUsage({
245
249
  claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
246
250
  codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
251
+ geminiSessionsDir: process.env.AI_SPEND_GEMINI_LOGS_DIR,
247
252
  sinceIso,
248
253
  collectCodexInvocationEvidence: true
249
254
  });
255
+ const contextCalls = logs.calls.filter((call) => (localAgentFormatSupports(call.agent, "contextHealth")));
250
256
  const calls = args.project
251
- ? logs.calls.filter((call) => call.project === args.project)
252
- : logs.calls;
257
+ ? contextCalls.filter((call) => call.project === args.project)
258
+ : contextCalls;
253
259
  const health = await loadContextHealth(calls, {
254
260
  claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
255
261
  codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
@@ -326,7 +332,7 @@ function quickstartNextSteps(mode, detected) {
326
332
  ? "npx aibill report --sample write a clearly labeled demo Markdown + HTML report"
327
333
  : "npx aibill report write a shareable Markdown + HTML report");
328
334
  steps.push("npx aibill --group-by project see which project has the most observed activity");
329
- steps.push("Need team reconciliation, allocation, budgets, and approvals? Workspace design partners: https://ai-spend-agent.vercel.app");
335
+ steps.push("Need team reconciliation, allocation, budgets, and approvals? Workspace design partners: https://asktilden.com");
330
336
  return steps;
331
337
  }
332
338
  async function readPersistedSpend(rootPath, options = {}) {
@@ -343,9 +349,12 @@ async function readPersistedSpend(rootPath, options = {}) {
343
349
  // tag. A copied/tampered sample must never become connected billing merely
344
350
  // because `mode` was changed in JSON.
345
351
  const mode = isBundledSampleUsage(parsedRecords) ? "sample" : storedMode;
346
- const records = mode === "sample" || mode === undefined
352
+ const parsedModeRecords = mode === "sample" || mode === undefined
347
353
  ? downgradeSampleUsageEvidence(parsedRecords)
348
354
  : parsedRecords;
355
+ const records = mode === "connected_provider"
356
+ ? applyProviderContractGate(parsedModeRecords)
357
+ : parsedModeRecords;
349
358
  if (spend.checkedAt !== undefined && !validIsoString(spend.checkedAt)) {
350
359
  throw new Error("persisted spend checkedAt must be an ISO timestamp");
351
360
  }
@@ -410,7 +419,7 @@ async function loadInstantReadData(args) {
410
419
  looksConnected &&
411
420
  persisted.connectedTrust?.trusted === true) {
412
421
  return {
413
- records: persisted.records,
422
+ records: applyProviderContractGate(persisted.records),
414
423
  mode: "connected",
415
424
  warnings,
416
425
  ...(persisted.providerCoverage ? { providerCoverage: persisted.providerCoverage } : {})
@@ -424,6 +433,7 @@ async function loadInstantReadData(args) {
424
433
  // Env overrides keep tests (and unusual installs) isolated from $HOME.
425
434
  claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
426
435
  codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
436
+ geminiSessionsDir: process.env.AI_SPEND_GEMINI_LOGS_DIR,
427
437
  sinceIso: sinceIsoForDays(args.sinceDays ?? 30),
428
438
  collectCodexInvocationEvidence: true
429
439
  }).catch(() => undefined);
@@ -441,6 +451,16 @@ async function loadInstantReadData(args) {
441
451
  codexInvocationFiles: logs.codexInvocationFiles
442
452
  };
443
453
  }
454
+ const geminiPresence = logs?.sourceScans.find((scan) => scan.agent === "gemini-cli");
455
+ if (geminiPresence && ((geminiPresence.detectionSignals ?? 0) > 0 || geminiPresence.filesDiscovered > 0)) {
456
+ warnings.push("Gemini CLI was detected, but no supported chats JSON/JSONL financial evidence was found. No financial rows were created; logs.json is presence-only evidence. Need this coverage? +1 or contribute a synthetic fixture: https://github.com/futurastudio/ai-spend-agent/issues/new?template=provider_or_agent.yml");
457
+ return {
458
+ records: [],
459
+ mode: "local-logs",
460
+ warnings,
461
+ codexInvocationFiles: logs?.codexInvocationFiles
462
+ };
463
+ }
444
464
  // No real logs. Persisted sample/legacy state may still be shown, but only as
445
465
  // DEMO (never as connected), with a warning when its origin is unknown.
446
466
  if (persisted && persisted.records.length > 0 && persisted.mode !== "connected_provider" && persisted.mode !== "local_logs") {
@@ -457,9 +477,12 @@ async function loadInstantReadData(args) {
457
477
  return { records: await loadSampleUsageData(), mode: "demo", warnings };
458
478
  }
459
479
  /** A one-line, unmissable banner telling the user which data they're seeing. */
460
- function dataModeBanner(mode) {
461
- if (mode === "local-logs")
462
- return "DATA MODE: your local agent logs (estimated at API-equivalent rates)";
480
+ function dataModeBanner(mode, records) {
481
+ if (mode === "local-logs") {
482
+ return records.some((record) => typeof record.amountUsd === "number")
483
+ ? "DATA MODE: your local agent logs (estimated at API-equivalent rates)"
484
+ : "DATA MODE: local agent evidence (financial value unavailable; no demo sample substituted)";
485
+ }
463
486
  if (mode === "connected")
464
487
  return "DATA MODE: connected provider billing";
465
488
  return "DATA MODE: demo sample (illustrative — not your real spend)";
@@ -479,12 +502,18 @@ async function doctorCommand(args) {
479
502
  : "no state";
480
503
  const logs = await loadLocalAgentUsage({
481
504
  claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
482
- codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR
505
+ codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
506
+ geminiSessionsDir: process.env.AI_SPEND_GEMINI_LOGS_DIR
483
507
  }).catch(() => undefined);
484
508
  const detected = logs?.agentsDetected ?? [];
485
509
  const claudeFound = detected.includes("claude-code");
486
510
  const codexFound = detected.includes("codex");
487
- const hasLogs = claudeFound || codexFound;
511
+ const geminiScan = logs?.sourceScans.find((scan) => scan.agent === "gemini-cli");
512
+ const geminiFinancialFound = detected.includes("gemini-cli");
513
+ const geminiPresenceFound = Boolean(geminiScan && ((geminiScan.detectionSignals ?? 0) > 0 || geminiScan.filesDiscovered > 0));
514
+ const geminiFound = geminiFinancialFound || geminiPresenceFound;
515
+ const hasFinancialLogs = claudeFound || codexFound || geminiFinancialFound;
516
+ const hasLocalSource = hasFinancialLogs || geminiPresenceFound;
488
517
  const detection = await detectLocalCredentials({
489
518
  cwd: rootPath,
490
519
  home: process.env.AI_SPEND_CLAUDE_HOME_DIR
@@ -505,15 +534,20 @@ async function doctorCommand(args) {
505
534
  if (persisted?.mode === "connected_provider" && persisted.connectedTrust?.trusted === false) {
506
535
  warnings.push(`${persisted.connectedTrust.message} Run \`npx aibill connect <provider>\` or repeat the prior \`npx aibill sync-provider ...\` command.`);
507
536
  }
508
- if (!hasLogs)
509
- warnings.push("no real Claude Code / Codex logs found a first run here will show DEMO sample data");
537
+ if (geminiPresenceFound && !geminiFinancialFound) {
538
+ warnings.push("Gemini CLI detected, but no supported chats JSON/JSONL financial evidence was found. No financial rows were created; logs.json is presence-only evidence. Need this coverage? +1 or contribute a synthetic fixture: https://github.com/futurastudio/ai-spend-agent/issues/new?template=provider_or_agent.yml");
539
+ }
540
+ if (!hasLocalSource)
541
+ warnings.push("no supported Claude Code, Codex, or Gemini CLI session evidence found — a first run here will show DEMO sample data");
510
542
  if (providerRefs.length === 0)
511
543
  warnings.push("no provider admin keys detected — connect OpenAI/Anthropic to add official provider-reported cost (local logs stay API-equivalent estimates)");
512
544
  const predictedMode = connectedStateTrusted
513
545
  ? "connected provider billing"
514
- : hasLogs
546
+ : hasFinancialLogs
515
547
  ? "your local agent logs (estimated at API-equivalent rates)"
516
- : "demo sample (illustrative)";
548
+ : geminiPresenceFound
549
+ ? "local Gemini CLI presence only (financial evidence unavailable; no sample substituted)"
550
+ : "demo sample (illustrative)";
517
551
  const lines = [
518
552
  "aibill doctor",
519
553
  `node version: ${process.version}`,
@@ -524,6 +558,11 @@ async function doctorCommand(args) {
524
558
  `state mode: ${stateMode}`,
525
559
  `Claude Code logs: ${claudeFound ? "found" : "not found"}`,
526
560
  `Codex logs: ${codexFound ? "found" : "not found"}`,
561
+ `Gemini CLI sessions: ${geminiFinancialFound
562
+ ? "found"
563
+ : geminiPresenceFound
564
+ ? "detected, but no supported chats financial rows found"
565
+ : "not found"}`,
527
566
  `provider env references: ${providerRefs.length > 0 ? providerRefs.join(", ") : "none detected"}`,
528
567
  `subscription plans: ${planLine}`,
529
568
  "redaction policy: secrets are never printed or persisted",
@@ -549,7 +588,8 @@ async function doctorSourcesCommand(args) {
549
588
  try {
550
589
  localLogs = await loadLocalAgentUsage({
551
590
  claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
552
- codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR
591
+ codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
592
+ geminiSessionsDir: process.env.AI_SPEND_GEMINI_LOGS_DIR
553
593
  });
554
594
  }
555
595
  catch (error) {
@@ -659,14 +699,18 @@ function localFinancialEvidenceNote(records, evidence, scan) {
659
699
  if (scan.directoryStatus === "unreadable") {
660
700
  return "The local transcript path could not be read; absence of usage cannot be confirmed.";
661
701
  }
702
+ if (scan.agent === "gemini-cli" && (scan.detectionSignals ?? 0) > 0 &&
703
+ scan.filesDiscovered === 0) {
704
+ return "Gemini CLI detected, but no supported chats JSON/JSONL financial evidence was found. logs.json is presence-only evidence; zero financial rows were created. Need this coverage? +1 or contribute a synthetic fixture: https://github.com/futurastudio/ai-spend-agent/issues/new?template=provider_or_agent.yml";
705
+ }
662
706
  if (scan.filesDiscovered === 0) {
663
- return "The local transcript directory was readable, but no JSONL files were found.";
707
+ return "The local transcript directory was readable, but no supported session files were found.";
664
708
  }
665
709
  if (scan.unreadableFiles > 0) {
666
710
  return `${scan.filesDiscovered} transcript file(s) were found, but ${scan.unreadableFiles} could not be read; absence of usage cannot be confirmed.`;
667
711
  }
668
712
  if (scan.malformedLines > 0) {
669
- return `${scan.filesDiscovered} transcript file(s) were found, but no valid usage rows were parsed; ${scan.malformedLines} malformed JSONL line(s) were skipped.`;
713
+ return `${scan.filesDiscovered} transcript file(s) were found, but no valid usage rows were parsed; ${scan.malformedLines} malformed session record(s) were skipped.`;
670
714
  }
671
715
  return `${scan.filesDiscovered} transcript file(s) were found, but no supported usage rows were observed.`;
672
716
  }
@@ -697,9 +741,13 @@ function localFinancialEvidenceNote(records, evidence, scan) {
697
741
  function localAgentDiagnosticSummary(diagnostics) {
698
742
  const relevant = diagnostics.filter((diagnostic) => diagnostic.code !== "directory_missing");
699
743
  const unsupported = relevant.filter((diagnostic) => diagnostic.code === "unsupported_token_shape");
700
- const malformed = relevant.filter((diagnostic) => diagnostic.code === "malformed_jsonl");
744
+ const malformed = relevant.filter((diagnostic) => (diagnostic.code === "malformed_jsonl" || diagnostic.code === "malformed_session_file"));
701
745
  const messages = [...new Set(relevant
702
- .filter((diagnostic) => !["unsupported_token_shape", "malformed_jsonl"].includes(diagnostic.code))
746
+ .filter((diagnostic) => ![
747
+ "unsupported_token_shape",
748
+ "malformed_jsonl",
749
+ "malformed_session_file"
750
+ ].includes(diagnostic.code))
703
751
  .map((diagnostic) => diagnostic.message))];
704
752
  const unsupportedCount = unsupported
705
753
  .reduce((total, diagnostic) => total + diagnostic.count, 0);
@@ -709,7 +757,7 @@ function localAgentDiagnosticSummary(diagnostics) {
709
757
  const malformedCount = malformed
710
758
  .reduce((total, diagnostic) => total + diagnostic.count, 0);
711
759
  if (malformedCount > 0) {
712
- messages.push(`${malformedCount} malformed JSONL line(s) were skipped in ${localAgentFormatLabel(malformed[0].agent)} transcripts.`);
760
+ messages.push(`${malformedCount} malformed session record(s) were skipped in ${localAgentFormatLabel(malformed[0].agent)} transcripts.`);
713
761
  }
714
762
  return messages.length > 0 ? messages.join(" ") : undefined;
715
763
  }
@@ -1210,6 +1258,7 @@ async function collectAndPublishActivitySnapshot(input) {
1210
1258
  logs = await loadLocalAgentFinancialUsage({
1211
1259
  claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
1212
1260
  codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
1261
+ geminiSessionsDir: process.env.AI_SPEND_GEMINI_LOGS_DIR,
1213
1262
  sinceIso: sinceIsoForDays(30, asOf)
1214
1263
  });
1215
1264
  }
@@ -1222,13 +1271,14 @@ async function collectAndPublishActivitySnapshot(input) {
1222
1271
  const persisted = persistedResult.persisted;
1223
1272
  const trustedProviderRecords = persisted?.mode === "connected_provider" &&
1224
1273
  persisted.connectedTrust?.trusted === true
1225
- ? selectProviderFinancialHeadlineRecords(persisted.records)
1274
+ ? selectProviderFinancialHeadlineRecords(applyProviderContractGate(persisted.records))
1226
1275
  : [];
1227
1276
  let activitySnapshot;
1228
1277
  let cacheStatus;
1278
+ const statuslineSourceScans = logs?.sourceScans.filter((scan) => (localAgentFormatSupports(scan.agent, "statuslineSnapshot"))) ?? [];
1229
1279
  const structuredSourceFailure = logs !== undefined &&
1230
- logs.sourceScans.some((scan) => scan.directoryStatus === "unreadable") &&
1231
- !logs.sourceScans.some((scan) => scan.directoryStatus === "readable");
1280
+ statuslineSourceScans.some((scan) => scan.directoryStatus === "unreadable") &&
1281
+ !statuslineSourceScans.some((scan) => scan.directoryStatus === "readable");
1232
1282
  if (logs && !structuredSourceFailure) {
1233
1283
  let refreshErrorCode = "invalid_evidence";
1234
1284
  try {
@@ -1516,13 +1566,18 @@ function activitySnapshotProvider(provider) {
1516
1566
  function formatInitReceipt(input) {
1517
1567
  const records = input.logs?.records ?? [];
1518
1568
  const pricedRecords = records.filter((record) => typeof record.amountUsd === "number");
1569
+ const registryOnlyLines = initRegistryOnlyFinancialLines(records);
1519
1570
  const sourceFailures = (input.logs?.sourceScans ?? []).some((scan) => scan.directoryStatus === "unreadable") ||
1520
1571
  (input.logs?.diagnostics ?? []).some((diagnostic) => diagnostic.severity === "error");
1521
- const receiptLines = input.scanError || sourceFailures && records.length === 0
1572
+ const snapshotLines = input.scanError || sourceFailures && records.length === 0
1522
1573
  ? ["API-equivalent usage value: unavailable — the local scan could not prove an empty result"]
1523
1574
  : input.activitySnapshot
1524
1575
  ? initApiEquivalentWindowLines(input.activitySnapshot)
1525
1576
  : ["API-equivalent usage value: unavailable — no snapshot was produced"];
1577
+ const receiptLines = registryOnlyLines.length > 0 &&
1578
+ input.activitySnapshot?.mode === "empty" && !input.scanError
1579
+ ? registryOnlyLines
1580
+ : [...snapshotLines, ...registryOnlyLines];
1526
1581
  const planLine = input.detectedPlans.length > 0
1527
1582
  ? input.detectedPlans.map((plan) => {
1528
1583
  const known = plan.planId ?? "unrecognized plan";
@@ -1536,7 +1591,10 @@ function formatInitReceipt(input) {
1536
1591
  const validation = scan.jsonlValidationCoverage === "financial_events_only"
1537
1592
  ? "; financial-event JSONL validation only"
1538
1593
  : "";
1539
- return ` ${scan.agent}: ${scan.directoryStatus}; ${scan.filesParsed}/${scan.filesDiscovered} files parsed; ${priced}/${agentRecords.length} rows priced${skipped > 0 ? `; ${skipped} old files skipped` : ""}${validation}`;
1594
+ const detection = (scan.detectionSignals ?? 0) > 0
1595
+ ? `; ${scan.detectionSignals} presence-only signal(s)`
1596
+ : "";
1597
+ return ` ${scan.agent}: ${scan.directoryStatus}; ${scan.filesParsed}/${scan.filesDiscovered} files parsed; ${priced}/${agentRecords.length} rows priced${detection}${skipped > 0 ? `; ${skipped} old files skipped` : ""}${validation}`;
1540
1598
  });
1541
1599
  const diagnosticLines = (input.logs?.diagnostics ?? [])
1542
1600
  .filter((diagnostic) => diagnostic.code !== "directory_missing")
@@ -1545,7 +1603,7 @@ function formatInitReceipt(input) {
1545
1603
  return [
1546
1604
  "aibill init",
1547
1605
  `state project: ${sanitizeSecretishError(basename(input.rootPath))}`,
1548
- "local usage scope: all Claude Code + Codex activity on this machine (last 30 days)",
1606
+ "local usage scope: supported Claude Code, Codex, and Gemini CLI financial evidence on this machine (last 30 days)",
1549
1607
  "provider scope: trusted connected billing from this state project only (shown separately)",
1550
1608
  "",
1551
1609
  "FIRST RECEIPT · API-equivalent usage value · last 30 days",
@@ -1565,6 +1623,25 @@ function formatInitReceipt(input) {
1565
1623
  "next: npx aibill doctor --sources"
1566
1624
  ].filter((line) => line !== "").join("\n");
1567
1625
  }
1626
+ function initRegistryOnlyFinancialLines(records) {
1627
+ const lines = [];
1628
+ for (const descriptor of localAgentFormatDescriptors) {
1629
+ if (descriptor.capabilities.statuslineSnapshot)
1630
+ continue;
1631
+ const sourceRecords = records.filter((record) => record.agentId === descriptor.id);
1632
+ if (sourceRecords.length === 0)
1633
+ continue;
1634
+ const priced = sourceRecords.filter((record) => typeof record.amountUsd === "number");
1635
+ if (priced.length === 0) {
1636
+ lines.push(`${descriptor.id} API-equivalent value: unavailable — ${sourceRecords.length} observed row(s) lacked complete token components or a supported model price`);
1637
+ continue;
1638
+ }
1639
+ const amount = analyzeSpend(priced).totalUsd;
1640
+ const unpriced = sourceRecords.length - priced.length;
1641
+ lines.push(`${descriptor.id} experimental value: ~${formatOptionalUsd(amount)} 30d (API-equivalent; fixture-verified; not billed spend${unpriced > 0 ? `; ${unpriced} row(s) unpriced` : ""})`);
1642
+ }
1643
+ return lines;
1644
+ }
1568
1645
  function formatInitProviderEvidence(input) {
1569
1646
  const windowStart = input.asOf.getTime() - 30 * 24 * 60 * 60 * 1_000;
1570
1647
  const inWindow = input.trustedProviderRecords.filter((record) => {
@@ -1836,7 +1913,7 @@ async function runWatchCycle(stateDir, args) {
1836
1913
  // Watch may observe an already trusted provider snapshot, but it may not
1837
1914
  // mint trust from repository-authored provider-records.json or rewrite
1838
1915
  // connected state. Only an explicit provider sync can do that.
1839
- records = persisted.records;
1916
+ records = applyProviderContractGate(persisted.records);
1840
1917
  mode = "connected_provider";
1841
1918
  }
1842
1919
  else {
@@ -1844,7 +1921,8 @@ async function runWatchCycle(stateDir, args) {
1844
1921
  // never serve a stale snapshot.
1845
1922
  const logs = await loadLocalAgentUsage({
1846
1923
  claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
1847
- codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR
1924
+ codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
1925
+ geminiSessionsDir: process.env.AI_SPEND_GEMINI_LOGS_DIR
1848
1926
  }).catch(() => undefined);
1849
1927
  if (logs && logs.records.length > 0) {
1850
1928
  records = logs.records;
@@ -1857,18 +1935,21 @@ async function runWatchCycle(stateDir, args) {
1857
1935
  }
1858
1936
  }
1859
1937
  const headlineRecords = mode === "connected_provider"
1860
- ? selectProviderFinancialHeadlineRecords(records)
1938
+ ? selectProviderFinancialHeadlineRecords(applyProviderContractGate(records))
1861
1939
  : records;
1862
1940
  const summary = analyzeSpend(headlineRecords);
1941
+ const financialAmountAvailable = headlineRecords.some((record) => typeof record.amountUsd === "number");
1863
1942
  const mappings = attributeUsageRecords(records);
1864
1943
  if (mode !== "connected_provider") {
1865
1944
  await writeLocalSpendState(stateDir, records, summary, mappings, mode);
1866
1945
  }
1867
1946
  const snapshot = {
1868
1947
  capturedAt: new Date().toISOString(),
1869
- totalUsd: summary.totalUsd,
1948
+ totalUsd: financialAmountAvailable ? summary.totalUsd : null,
1870
1949
  recordCount: summary.recordCount,
1871
- byModel: summary.byModel.map((entry) => ({ key: entry.key, amountUsd: entry.amountUsd }))
1950
+ byModel: financialAmountAvailable
1951
+ ? summary.byModel.map((entry) => ({ key: entry.key, amountUsd: entry.amountUsd }))
1952
+ : []
1872
1953
  };
1873
1954
  // Append to the rolling history and persist the latest snapshot for the next run.
1874
1955
  const history = await readOptionalJson(join(stateDir, "watch-history.json"), []);
@@ -1878,14 +1959,25 @@ async function runWatchCycle(stateDir, args) {
1878
1959
  timestamp: snapshot.capturedAt,
1879
1960
  action: "scan_completed",
1880
1961
  sourceId: "watch",
1881
- detail: `Watch cycle captured ${snapshot.recordCount} records totaling $${snapshot.totalUsd.toFixed(2)}.`
1962
+ detail: snapshot.totalUsd === null
1963
+ ? `Watch cycle captured ${snapshot.recordCount} records with no priced financial evidence; total unavailable.`
1964
+ : `Watch cycle captured ${snapshot.recordCount} records totaling $${snapshot.totalUsd.toFixed(2)}.`
1882
1965
  });
1883
1966
  return { summary, snapshot, records: headlineRecords, mode };
1884
1967
  }
1885
1968
  function buildDeltaHeadline(previous, current) {
1886
1969
  if (!previous) {
1970
+ if (current.totalUsd === null) {
1971
+ return `First watch snapshot. Financial baseline is unavailable across ${current.recordCount} records; missing/null is not zero. Future priced cycles will establish a numeric baseline.`;
1972
+ }
1887
1973
  return `First watch snapshot. Baseline AI spend is $${current.totalUsd.toFixed(2)} across ${current.recordCount} charges. Future cycles will report what changed.`;
1888
1974
  }
1975
+ if (current.totalUsd === null) {
1976
+ return `Financial evidence is unavailable across ${current.recordCount} records; missing/null is not zero and no numeric delta was calculated.`;
1977
+ }
1978
+ if (previous.totalUsd === null) {
1979
+ return `A priced financial baseline is now available at $${current.totalUsd.toFixed(2)} across ${current.recordCount} records. The prior snapshot was unavailable, so no numeric delta was calculated.`;
1980
+ }
1889
1981
  const deltaUsd = roundMoneyCli(current.totalUsd - previous.totalUsd);
1890
1982
  const lines = [];
1891
1983
  if (Math.abs(deltaUsd) < 0.01) {
@@ -2120,12 +2212,24 @@ async function syncProviderCommand(args) {
2120
2212
  enterprise: args.enterprise,
2121
2213
  accountId: args.accountId
2122
2214
  });
2123
- const records = [
2215
+ const syncedRecords = applyProviderContractGate(result.records);
2216
+ const syncedFinancials = summarizeProviderFinancials(syncedRecords);
2217
+ const syncedCompleteness = providerFinancialCompleteness(syncedRecords, result.coverage);
2218
+ const syncedSource = createProviderConnection({
2219
+ provider: result.provider,
2220
+ sourceId: result.source.id,
2221
+ authReference: args.authReference,
2222
+ verifiedRecordCount: syncedRecords.length,
2223
+ totalUsd: syncedFinancials.headlineUsd,
2224
+ completeness: syncedCompleteness,
2225
+ fetchedAt: new Date(result.fetchedAt)
2226
+ });
2227
+ const records = applyProviderContractGate([
2124
2228
  ...(trustedPrior?.records ?? []).filter((record) => record.source.provider !== result.provider),
2125
- ...result.records
2126
- ].sort((left, right) => left.timestamp.localeCompare(right.timestamp));
2229
+ ...syncedRecords
2230
+ ]).sort((left, right) => left.timestamp.localeCompare(right.timestamp));
2127
2231
  const registry = await readSourceRegistry(stateDir, rootPath);
2128
- const nextRegistry = addApprovedSource(registry, result.source);
2232
+ const nextRegistry = addApprovedSource(registry, syncedSource);
2129
2233
  const headlineRecords = selectProviderFinancialHeadlineRecords(records);
2130
2234
  const summary = analyzeSpend(headlineRecords);
2131
2235
  const mappings = attributeUsageRecords(records);
@@ -2139,7 +2243,7 @@ async function syncProviderCommand(args) {
2139
2243
  };
2140
2244
  const financialsByProvider = {
2141
2245
  ...trustedAccountingMap(trustedPrior?.accounting, "financialsByProvider"),
2142
- [result.provider]: result.financials
2246
+ [result.provider]: syncedFinancials
2143
2247
  };
2144
2248
  const checkedAtByProvider = {
2145
2249
  ...trustedAccountingMap(trustedPrior?.accounting, "checkedAtByProvider"),
@@ -2160,10 +2264,10 @@ async function syncProviderCommand(args) {
2160
2264
  await writeJson(join(stateDir, "provider-records.json"), {
2161
2265
  provider: result.provider,
2162
2266
  fetchedAt: result.fetchedAt,
2163
- completeness: result.completeness,
2267
+ completeness: syncedCompleteness,
2164
2268
  coverage: result.coverage,
2165
- financials: result.financials,
2166
- sourceId: result.source.id,
2269
+ financials: syncedFinancials,
2270
+ sourceId: syncedSource.id,
2167
2271
  records,
2168
2272
  qa: result.qa,
2169
2273
  qaByProvider,
@@ -2189,24 +2293,24 @@ async function syncProviderCommand(args) {
2189
2293
  await appendAuditEvent(stateDir, {
2190
2294
  timestamp: result.fetchedAt,
2191
2295
  action: "source_scanned",
2192
- sourceId: result.source.id,
2296
+ sourceId: syncedSource.id,
2193
2297
  detail: `${args.provider} provider connector synced ${result.records.length} evidence records with ${result.coverage} coverage. Auth reference only; no raw secrets stored.`
2194
2298
  });
2195
2299
  await writeConnectedSpendTrustReceipt(rootPath, await readSafeStateText(stateDir, "spend.json"), { sourceRegistryContents: await readSafeStateText(stateDir, "sources.json") });
2196
2300
  return ok([
2197
2301
  "aibill sync-provider",
2198
2302
  `provider: ${result.provider}`,
2199
- `source: ${result.source.id}`,
2200
- `boundary approval: ${result.source.boundaryApproval}`,
2201
- `validation coverage: ${result.source.validationCoverage}`,
2202
- `financial evidence: ${result.source.financialEvidence}`,
2303
+ `source: ${syncedSource.id}`,
2304
+ `boundary approval: ${syncedSource.boundaryApproval}`,
2305
+ `validation coverage: ${syncedSource.validationCoverage}`,
2306
+ `financial evidence: ${syncedSource.financialEvidence}`,
2203
2307
  `coverage: ${result.coverage}`,
2204
2308
  `records fetched: ${result.records.length}`,
2205
- `headline basis: ${result.financials.headlineBasis}`,
2206
- `synced provider headline: ${formatOptionalUsd(result.financials.headlineUsd)}`,
2309
+ `headline basis: ${syncedFinancials.headlineBasis}`,
2310
+ `synced provider headline: ${formatOptionalUsd(syncedFinancials.headlineUsd)}`,
2207
2311
  `combined headline spend: ${selectProviderFinancialHeadlineRecords(records).some((record) => typeof record.amountUsd === "number") ? formatOptionalUsd(summary.totalUsd) : "unavailable"}`,
2208
- ...(result.financials.apiEquivalentEstimatedUsd !== null
2209
- ? [`API-equivalent estimate (kept separate): $${result.financials.apiEquivalentEstimatedUsd.toFixed(2)}`]
2312
+ ...(syncedFinancials.apiEquivalentEstimatedUsd !== null
2313
+ ? [`API-equivalent estimate (kept separate): $${syncedFinancials.apiEquivalentEstimatedUsd.toFixed(2)}`]
2210
2314
  : []),
2211
2315
  "auth: reference-only; raw secrets were not persisted or printed"
2212
2316
  ].join("\n"));
@@ -2324,7 +2428,10 @@ async function reportCommand(args) {
2324
2428
  `demo package: ${artifactPaths.demoPackage}`,
2325
2429
  reportInput.dataMode === "sample"
2326
2430
  ? `DEMO SAMPLE · illustrative cost/value evidence total: $${reportInput.summary.totalUsd.toFixed(2)} · not user data`
2327
- : `cost/value evidence total: $${reportInput.summary.totalUsd.toFixed(2)}`,
2431
+ : reportInput.dataMode === "connected_provider" &&
2432
+ !(reportInput.allRecords ?? reportInput.providerRecords ?? []).some((record) => typeof record.amountUsd === "number")
2433
+ ? "cost/value evidence total: Unavailable · no priced financial evidence; missing/null is not zero"
2434
+ : `cost/value evidence total: $${reportInput.summary.totalUsd.toFixed(2)}`,
2328
2435
  "privacy: report rendered locally with no aibill telemetry; only explicit sync-provider contacts the selected provider",
2329
2436
  "",
2330
2437
  "next:",
@@ -2510,9 +2617,12 @@ async function buildReportInput(stateDir, rootPath, sinceDays = 30) {
2510
2617
  // A bundled sample remains sample even if a conflicting mode was written.
2511
2618
  // This guards report and Apply separately from the quickstart read path.
2512
2619
  const mode = isBundledSampleUsage(parsedRecords) ? "sample" : storedMode;
2513
- const records = mode === "sample" || mode === undefined
2620
+ const parsedModeRecords = mode === "sample" || mode === undefined
2514
2621
  ? downgradeSampleUsageEvidence(parsedRecords)
2515
2622
  : parsedRecords;
2623
+ const records = mode === "connected_provider"
2624
+ ? applyProviderContractGate(parsedModeRecords)
2625
+ : parsedModeRecords;
2516
2626
  unavailablePersistedLocalLogs = mode === "local_logs";
2517
2627
  const headlineRecords = mode === "connected_provider"
2518
2628
  ? selectProviderFinancialHeadlineRecords(records)
@@ -2560,6 +2670,7 @@ async function buildReportInput(stateDir, rootPath, sinceDays = 30) {
2560
2670
  const logs = await loadLocalAgentUsage({
2561
2671
  claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
2562
2672
  codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
2673
+ geminiSessionsDir: process.env.AI_SPEND_GEMINI_LOGS_DIR,
2563
2674
  sinceIso,
2564
2675
  collectCodexInvocationEvidence: true
2565
2676
  }).catch(() => undefined);
@@ -2588,10 +2699,10 @@ async function buildReportInput(stateDir, rootPath, sinceDays = 30) {
2588
2699
  throw new Error(`${untrustedConnectedStateMessage} No connected totals or Apply actions were generated.`);
2589
2700
  }
2590
2701
  if (unavailablePersistedLocalLogs) {
2591
- throw new Error("Persisted local-log state is an untrusted cache and its source Claude Code/Codex records are unavailable. " +
2702
+ throw new Error("Persisted local-log state is an untrusted cache and its source local-agent records are unavailable. " +
2592
2703
  "Re-run `npx aibill` while the local transcripts are available; no report or Apply action was generated from repository state alone.");
2593
2704
  }
2594
- throw new Error("no persisted spend state and no local Claude Code/Codex logs found. " +
2705
+ throw new Error("no persisted spend state and no supported local-agent financial evidence found. " +
2595
2706
  "Run `npx aibill` first (or `npx aibill scan --sample --path <dir>` for a demo-data report).");
2596
2707
  }
2597
2708
  const [discovery, sourceRegistry, missingSourcePrompts, confirmedMappings, persistedProviderRecordsState] = await Promise.all([
@@ -2651,7 +2762,7 @@ async function buildReportInput(stateDir, rootPath, sinceDays = 30) {
2651
2762
  // If live transcript calls are unavailable, omit it instead of fabricating a
2652
2763
  // session-level recommendation from day-aggregate spend records.
2653
2764
  const contextHealth = spendState.mode === "local_logs" && freshLocalCalls
2654
- ? await loadContextHealth(freshLocalCalls, {
2765
+ ? await loadContextHealth(freshLocalCalls.filter((call) => (localAgentFormatSupports(call.agent, "contextHealth"))), {
2655
2766
  claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
2656
2767
  codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
2657
2768
  claudeHomeDir: process.env.AI_SPEND_CLAUDE_HOME_DIR,
@@ -3057,7 +3168,7 @@ async function readSourceRegistry(stateDir, rootPath) {
3057
3168
  if (parsedSpend.mode === "connected_provider") {
3058
3169
  const trust = await verifyConnectedSourceRegistryTrustReceipt(rootPath, exactSpendContents, exactSourceRegistryContents);
3059
3170
  if (trust.trusted)
3060
- return registry;
3171
+ return applyProviderContractGateToSourceRegistry(registry);
3061
3172
  }
3062
3173
  }
3063
3174
  catch {
@@ -3065,7 +3176,7 @@ async function readSourceRegistry(stateDir, rootPath) {
3065
3176
  // controlled validation/evidence claims are never promoted without the
3066
3177
  // matching external provider-sync receipt.
3067
3178
  }
3068
- return downgradeUntrustedSourceRegistryClaims(registry);
3179
+ return applyProviderContractGateToSourceRegistry(downgradeUntrustedSourceRegistryClaims(registry));
3069
3180
  }
3070
3181
  catch {
3071
3182
  return createLocalFolderSourceRegistry(rootPath);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ai-spend-agent",
3
- "version": "0.7.3",
4
- "description": "Local-first financial accountability CLI for Claude Code and Codex work, cost evidence, attribution, runway, provenance, and Context Health.",
3
+ "version": "0.8.0",
4
+ "description": "Local-first financial accountability CLI: Claude Code/Codex attribution, provenance, and next actions, plus experimental Gemini CLI cost evidence.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
@@ -54,8 +54,8 @@
54
54
  "prepack": "npm run build"
55
55
  },
56
56
  "dependencies": {
57
- "@agent-finops/core": "0.7.3",
58
- "@agent-finops/report": "0.7.3",
57
+ "@agent-finops/core": "0.8.0",
58
+ "@agent-finops/report": "0.8.0",
59
59
  "yocto-spinner": "^1.2.0"
60
60
  }
61
61
  }