ai-spend-agent 0.7.2 → 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.
- package/README.md +9 -5
- package/dist/index.js +174 -63
- package/dist/statuslineRuntime.js +205 -9
- 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
|
|
14
|
-
and
|
|
15
|
-
|
|
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
|
|
27
|
-
and can optionally add official OpenAI or
|
|
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
|
-
?
|
|
195
|
-
:
|
|
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.
|
|
232
|
-
|
|
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:
|
|
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
|
-
?
|
|
252
|
-
:
|
|
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://
|
|
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
|
|
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
|
|
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
|
|
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 (!
|
|
509
|
-
warnings.push("no
|
|
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
|
-
:
|
|
546
|
+
: hasFinancialLogs
|
|
515
547
|
? "your local agent logs (estimated at API-equivalent rates)"
|
|
516
|
-
:
|
|
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
|
|
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
|
|
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) => ![
|
|
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
|
|
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
|
-
|
|
1231
|
-
!
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
...
|
|
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,
|
|
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]:
|
|
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:
|
|
2267
|
+
completeness: syncedCompleteness,
|
|
2164
2268
|
coverage: result.coverage,
|
|
2165
|
-
financials:
|
|
2166
|
-
sourceId:
|
|
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:
|
|
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: ${
|
|
2200
|
-
`boundary approval: ${
|
|
2201
|
-
`validation coverage: ${
|
|
2202
|
-
`financial evidence: ${
|
|
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: ${
|
|
2206
|
-
`synced provider headline: ${formatOptionalUsd(
|
|
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
|
-
...(
|
|
2209
|
-
? [`API-equivalent estimate (kept separate): $${
|
|
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
|
-
:
|
|
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
|
|
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
|
|
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
|
|
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);
|
|
@@ -155,9 +155,18 @@ export function renderStatusline(result, options = {}) {
|
|
|
155
155
|
if (overage) {
|
|
156
156
|
// Billed overage is the sole compact paid-alert bridge and must survive
|
|
157
157
|
// narrow layouts. It never derives from plan pressure.
|
|
158
|
+
if (hasMultipleSubscribedAgents(snapshot)) {
|
|
159
|
+
return assembleMultiSubscriptionOverageLine(snapshot, segments, overage, freshness, tier, columns, now);
|
|
160
|
+
}
|
|
158
161
|
return assembleOverageLine(segments, overage, freshness, columns);
|
|
159
162
|
}
|
|
163
|
+
if (snapshot.mode === "subscription" && hasMultipleSubscribedAgents(snapshot)) {
|
|
164
|
+
return assembleMultiSubscriptionLine(snapshot, freshness, tier, columns, now, options.timeZone);
|
|
165
|
+
}
|
|
160
166
|
if (snapshot.mode === "mixed") {
|
|
167
|
+
if (hasMultipleSubscribedAgents(snapshot)) {
|
|
168
|
+
return assembleMultiSubscriptionMixedLine(snapshot, segments, freshness, tier, columns, now, options.timeZone);
|
|
169
|
+
}
|
|
161
170
|
return assembleMixedLine(snapshot, segments, freshness, tier, columns, now, options.timeZone);
|
|
162
171
|
}
|
|
163
172
|
return assembleLine(segments, freshness, columns, overage);
|
|
@@ -256,6 +265,9 @@ function renderMetered(snapshot, tier) {
|
|
|
256
265
|
return rendered;
|
|
257
266
|
}
|
|
258
267
|
function renderSubscription(snapshot, tier, now, timeZone) {
|
|
268
|
+
if (hasMultipleSubscribedAgents(snapshot)) {
|
|
269
|
+
return renderMultiSubscriptionSegments(snapshot, tier, now, timeZone);
|
|
270
|
+
}
|
|
259
271
|
const agent = selectSubscriptionAgent(snapshot, now);
|
|
260
272
|
if (!agent)
|
|
261
273
|
return ["subscription detected", "runway not reported"];
|
|
@@ -276,6 +288,11 @@ function renderSubscription(snapshot, tier, now, timeZone) {
|
|
|
276
288
|
return segments.length > 0 ? segments : ["subscription detected"];
|
|
277
289
|
}
|
|
278
290
|
function renderMixed(snapshot, tier, now, timeZone) {
|
|
291
|
+
if (hasMultipleSubscribedAgents(snapshot)) {
|
|
292
|
+
const segments = renderMultiSubscriptionSegments(snapshot, tier, now, timeZone);
|
|
293
|
+
appendMeteredSevenDaySegment(snapshot, segments);
|
|
294
|
+
return segments.length > 0 ? segments : ["mixed billing", "amounts unavailable"];
|
|
295
|
+
}
|
|
279
296
|
const agent = selectSubscriptionAgent(snapshot, now);
|
|
280
297
|
const limits = agent ? activeLimits(agent, now) : [];
|
|
281
298
|
const selectedLimits = tier === "full"
|
|
@@ -284,6 +301,42 @@ function renderMixed(snapshot, tier, now, timeZone) {
|
|
|
284
301
|
const segments = selectedLimits.map((limit) => formatLimit(limit, now, timeZone));
|
|
285
302
|
if (limits.length === 0 && tier !== "minimal")
|
|
286
303
|
segments.push("runway not reported");
|
|
304
|
+
appendMeteredSevenDaySegment(snapshot, segments);
|
|
305
|
+
const value = agent?.apiEquivalent.sevenDays;
|
|
306
|
+
if (value?.amountUsd !== null && value?.amountUsd !== undefined &&
|
|
307
|
+
value.financialEvidence === "estimated") {
|
|
308
|
+
segments.push(`sub ~${formatUsd(value.amountUsd)} 7d value`);
|
|
309
|
+
}
|
|
310
|
+
if (agent?.pressure === "extra_usage_credits_exhausted" && tier !== "minimal") {
|
|
311
|
+
segments.push("plan pressure");
|
|
312
|
+
}
|
|
313
|
+
return segments.length > 0 ? segments : ["mixed billing", "amounts unavailable"];
|
|
314
|
+
}
|
|
315
|
+
function renderMultiSubscriptionSegments(snapshot, tier, now, timeZone) {
|
|
316
|
+
const entries = orderedSubscriptionLimits(snapshot, now);
|
|
317
|
+
const selected = tier === "full" ? entries : entries.slice(0, 1);
|
|
318
|
+
const segments = selected.map(({ agent, limit }) => tier === "full"
|
|
319
|
+
? formatAttributedLimit(agent, limit, now, timeZone)
|
|
320
|
+
: formatAttributedLimitCompact(agent, limit));
|
|
321
|
+
if (entries.length === 0 && tier !== "minimal") {
|
|
322
|
+
segments.push("subscription detected", "runway not reported");
|
|
323
|
+
}
|
|
324
|
+
const agents = orderedSubscriptionAgents(snapshot, now);
|
|
325
|
+
const valueAgents = tier === "full" ? agents : agents.slice(0, 1);
|
|
326
|
+
for (const agent of valueAgents) {
|
|
327
|
+
const value = agent.apiEquivalent.sevenDays;
|
|
328
|
+
if (value.amountUsd !== null && value.financialEvidence === "estimated") {
|
|
329
|
+
segments.push(`${subscriptionAgentLabel(agent)} ~${formatUsd(value.amountUsd)} 7d value`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
if (tier !== "minimal") {
|
|
333
|
+
for (const agent of agents.filter(({ pressure }) => pressure === "extra_usage_credits_exhausted")) {
|
|
334
|
+
segments.push(`${subscriptionAgentLabel(agent)} plan pressure`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return segments.length > 0 ? segments : ["subscription detected"];
|
|
338
|
+
}
|
|
339
|
+
function appendMeteredSevenDaySegment(snapshot, segments) {
|
|
287
340
|
const billed = snapshot.metered?.providerBilled.sevenDays;
|
|
288
341
|
const estimated = snapshot.metered?.apiEquivalent.sevenDays;
|
|
289
342
|
if (billed?.amountUsd !== null && billed?.amountUsd !== undefined &&
|
|
@@ -294,15 +347,6 @@ function renderMixed(snapshot, tier, now, timeZone) {
|
|
|
294
347
|
estimated.financialEvidence === "estimated") {
|
|
295
348
|
segments.push(`metered ~${formatUsd(estimated.amountUsd)} 7d`);
|
|
296
349
|
}
|
|
297
|
-
const value = agent?.apiEquivalent.sevenDays;
|
|
298
|
-
if (value?.amountUsd !== null && value?.amountUsd !== undefined &&
|
|
299
|
-
value.financialEvidence === "estimated") {
|
|
300
|
-
segments.push(`sub ~${formatUsd(value.amountUsd)} 7d value`);
|
|
301
|
-
}
|
|
302
|
-
if (agent?.pressure === "extra_usage_credits_exhausted" && tier !== "minimal") {
|
|
303
|
-
segments.push("plan pressure");
|
|
304
|
-
}
|
|
305
|
-
return segments.length > 0 ? segments : ["mixed billing", "amounts unavailable"];
|
|
306
350
|
}
|
|
307
351
|
function renderUnresolved(snapshot, tier) {
|
|
308
352
|
const value = snapshot.unresolved?.apiEquivalent.sevenDays;
|
|
@@ -324,6 +368,56 @@ function selectSubscriptionAgent(snapshot, now) {
|
|
|
324
368
|
return left.agent.localeCompare(right.agent);
|
|
325
369
|
})[0];
|
|
326
370
|
}
|
|
371
|
+
function hasMultipleSubscribedAgents(snapshot) {
|
|
372
|
+
return (snapshot.subscription?.agents.length ?? 0) > 1;
|
|
373
|
+
}
|
|
374
|
+
function orderedSubscriptionLimits(snapshot, now) {
|
|
375
|
+
return (snapshot.subscription?.agents ?? [])
|
|
376
|
+
.flatMap((agent) => activeLimits(agent, now).map((limit) => ({ agent, limit })))
|
|
377
|
+
.sort(compareSubscriptionLimitEntries);
|
|
378
|
+
}
|
|
379
|
+
function primarySubscriptionLimits(snapshot, now) {
|
|
380
|
+
return (snapshot.subscription?.agents ?? [])
|
|
381
|
+
.flatMap((agent) => {
|
|
382
|
+
const limit = [...activeLimits(agent, now)].sort(compareUrgency)[0];
|
|
383
|
+
return limit ? [{ agent, limit }] : [];
|
|
384
|
+
})
|
|
385
|
+
.sort(compareSubscriptionLimitEntries);
|
|
386
|
+
}
|
|
387
|
+
function compareSubscriptionLimitEntries(left, right) {
|
|
388
|
+
const urgency = compareUrgency(left.limit, right.limit);
|
|
389
|
+
if (urgency !== 0)
|
|
390
|
+
return urgency;
|
|
391
|
+
const observationDifference = Date.parse(right.limit.observedAt) - Date.parse(left.limit.observedAt);
|
|
392
|
+
if (observationDifference !== 0)
|
|
393
|
+
return observationDifference;
|
|
394
|
+
return left.agent.agent.localeCompare(right.agent.agent);
|
|
395
|
+
}
|
|
396
|
+
function orderedSubscriptionAgents(snapshot, now) {
|
|
397
|
+
return [...(snapshot.subscription?.agents ?? [])].sort((left, right) => {
|
|
398
|
+
const leftUrgent = [...activeLimits(left, now)].sort(compareUrgency)[0];
|
|
399
|
+
const rightUrgent = [...activeLimits(right, now)].sort(compareUrgency)[0];
|
|
400
|
+
if (leftUrgent && rightUrgent) {
|
|
401
|
+
const urgency = compareUrgency(leftUrgent, rightUrgent);
|
|
402
|
+
if (urgency !== 0)
|
|
403
|
+
return urgency;
|
|
404
|
+
}
|
|
405
|
+
else if (leftUrgent) {
|
|
406
|
+
return -1;
|
|
407
|
+
}
|
|
408
|
+
else if (rightUrgent) {
|
|
409
|
+
return 1;
|
|
410
|
+
}
|
|
411
|
+
const latestDifference = latestLimitObservation(right, now) - latestLimitObservation(left, now);
|
|
412
|
+
if (latestDifference !== 0)
|
|
413
|
+
return latestDifference;
|
|
414
|
+
const recordsDifference = right.apiEquivalent.sevenDays.recordCount -
|
|
415
|
+
left.apiEquivalent.sevenDays.recordCount;
|
|
416
|
+
if (recordsDifference !== 0)
|
|
417
|
+
return recordsDifference;
|
|
418
|
+
return left.agent.localeCompare(right.agent);
|
|
419
|
+
});
|
|
420
|
+
}
|
|
327
421
|
function latestLimitObservation(agent, now) {
|
|
328
422
|
return Math.max(0, ...activeLimits(agent, now).map((limit) => Date.parse(limit.observedAt)));
|
|
329
423
|
}
|
|
@@ -347,6 +441,24 @@ function formatLimit(limit, now, timeZone) {
|
|
|
347
441
|
const reset = formatReset(limit, now, timeZone);
|
|
348
442
|
return `${label} ${formatPercent(limit.remainingPercent)}% left ↻${reset}`;
|
|
349
443
|
}
|
|
444
|
+
function subscriptionAgentLabel(agent) {
|
|
445
|
+
return agent.agent === "claude-code" ? "claude" : "codex";
|
|
446
|
+
}
|
|
447
|
+
function formatAttributedLimit(agent, limit, now, timeZone) {
|
|
448
|
+
const label = limit.kind === "five-hour" ? "5h" : "week";
|
|
449
|
+
return `${subscriptionAgentLabel(agent)} ${label} ${formatPercent(limit.remainingPercent)}% ↻${formatReset(limit, now, timeZone)}`;
|
|
450
|
+
}
|
|
451
|
+
function formatAttributedLimitCompact(agent, limit) {
|
|
452
|
+
const label = limit.kind === "five-hour" ? "5h" : "wk";
|
|
453
|
+
return `${subscriptionAgentLabel(agent)} ${label} ${formatPercent(limit.remainingPercent)}%`;
|
|
454
|
+
}
|
|
455
|
+
function formatAttributedValue(agent, compact = false) {
|
|
456
|
+
const value = agent.apiEquivalent.sevenDays;
|
|
457
|
+
if (value.amountUsd === null || value.financialEvidence !== "estimated")
|
|
458
|
+
return undefined;
|
|
459
|
+
const window = compact ? "/7d" : " 7d";
|
|
460
|
+
return `${subscriptionAgentLabel(agent)} ~${formatUsd(value.amountUsd)}${window} value`;
|
|
461
|
+
}
|
|
350
462
|
function formatReset(limit, now, timeZone) {
|
|
351
463
|
const reset = new Date(limit.resetsAt);
|
|
352
464
|
const zone = validTimeZone(timeZone) ? timeZone : undefined;
|
|
@@ -444,6 +556,21 @@ function assembleOverageLine(segments, overage, freshness, columns) {
|
|
|
444
556
|
["billed"]
|
|
445
557
|
], columns);
|
|
446
558
|
}
|
|
559
|
+
function assembleMultiSubscriptionOverageLine(snapshot, fullSegments, overage, freshness, tier, columns, now) {
|
|
560
|
+
const primary = primarySubscriptionLimits(snapshot, now);
|
|
561
|
+
const compactPrimary = primary.map(({ agent, limit }) => formatAttributedLimitCompact(agent, limit));
|
|
562
|
+
const urgent = compactPrimary[0];
|
|
563
|
+
const candidates = [];
|
|
564
|
+
if (tier === "full")
|
|
565
|
+
candidates.push(["aibill", ...fullSegments, overage, freshness]);
|
|
566
|
+
if (tier !== "minimal") {
|
|
567
|
+
candidates.push(["aibill", ...compactPrimary, overage, freshness]);
|
|
568
|
+
}
|
|
569
|
+
if (urgent)
|
|
570
|
+
candidates.push(["aibill", urgent, overage, freshness]);
|
|
571
|
+
candidates.push(["aibill", overage, freshness], ["aibill", overage], [overage], ["OVERAGE billed"], ["billed"]);
|
|
572
|
+
return firstFittingLine(candidates, columns);
|
|
573
|
+
}
|
|
447
574
|
function assembleMixedLine(snapshot, fullSegments, freshness, tier, columns, now, timeZone) {
|
|
448
575
|
const agent = selectSubscriptionAgent(snapshot, now);
|
|
449
576
|
const urgent = agent ? [...activeLimits(agent, now)].sort(compareUrgency)[0] : undefined;
|
|
@@ -486,6 +613,75 @@ function assembleMixedLine(snapshot, fullSegments, freshness, tier, columns, now
|
|
|
486
613
|
candidates.push(["mix", runway, meteredClear, shortFreshness], ["mix", runway.replace("↻", ""), meteredClear], ["mix", meteredClear], ["mix", metered], ["mix"]);
|
|
487
614
|
return firstFittingLine(candidates, columns);
|
|
488
615
|
}
|
|
616
|
+
function assembleMultiSubscriptionLine(snapshot, freshness, tier, columns, now, timeZone) {
|
|
617
|
+
const entries = orderedSubscriptionLimits(snapshot, now);
|
|
618
|
+
const primary = primarySubscriptionLimits(snapshot, now);
|
|
619
|
+
const agents = orderedSubscriptionAgents(snapshot, now);
|
|
620
|
+
const fullLimits = entries.map(({ agent, limit }) => formatAttributedLimit(agent, limit, now, timeZone));
|
|
621
|
+
const compactLimits = entries.map(({ agent, limit }) => formatAttributedLimitCompact(agent, limit));
|
|
622
|
+
const compactPrimary = primary.map(({ agent, limit }) => formatAttributedLimitCompact(agent, limit));
|
|
623
|
+
const fullValues = agents.flatMap((agent) => formatAttributedValue(agent) ?? []);
|
|
624
|
+
const compactValues = agents.flatMap((agent) => formatAttributedValue(agent, true) ?? []);
|
|
625
|
+
const pressure = agents
|
|
626
|
+
.filter((agent) => agent.pressure === "extra_usage_credits_exhausted")
|
|
627
|
+
.map((agent) => `${subscriptionAgentLabel(agent)} plan pressure`);
|
|
628
|
+
const noRunway = entries.length === 0 ? ["subscription detected", "runway not reported"] : [];
|
|
629
|
+
const urgent = compactPrimary[0];
|
|
630
|
+
const urgentValue = agents[0] ? formatAttributedValue(agents[0], true) : undefined;
|
|
631
|
+
const candidates = [];
|
|
632
|
+
if (tier === "full") {
|
|
633
|
+
candidates.push(["aibill", ...noRunway, ...fullLimits, ...fullValues, ...pressure, freshness]);
|
|
634
|
+
}
|
|
635
|
+
if (tier !== "minimal") {
|
|
636
|
+
candidates.push(["aibill", ...noRunway, ...compactLimits, ...compactValues, ...pressure, freshness], ["aibill", ...noRunway, ...compactLimits, freshness], ["aibill", ...noRunway, ...compactPrimary, ...compactValues, freshness], ["aibill", ...noRunway, ...compactPrimary, freshness]);
|
|
637
|
+
}
|
|
638
|
+
if (urgent) {
|
|
639
|
+
if (urgentValue)
|
|
640
|
+
candidates.push(["aibill", urgent, urgentValue, freshness]);
|
|
641
|
+
candidates.push(["aibill", urgent, freshness], ["aibill", urgent], [urgent]);
|
|
642
|
+
}
|
|
643
|
+
else {
|
|
644
|
+
candidates.push(["aibill", "runway n/r", freshness], ["runway n/r", freshness], ["aibill", ...noRunway, ...compactValues, freshness], ["aibill", ...compactValues, freshness], ["aibill", "subscription detected", freshness], ["subscription detected"]);
|
|
645
|
+
}
|
|
646
|
+
return firstFittingLine(candidates, columns);
|
|
647
|
+
}
|
|
648
|
+
function assembleMultiSubscriptionMixedLine(snapshot, fullSegments, freshness, tier, columns, now, timeZone) {
|
|
649
|
+
const entries = orderedSubscriptionLimits(snapshot, now);
|
|
650
|
+
const primary = primarySubscriptionLimits(snapshot, now);
|
|
651
|
+
const agents = orderedSubscriptionAgents(snapshot, now);
|
|
652
|
+
const compactLimits = entries.map(({ agent, limit }) => formatAttributedLimitCompact(agent, limit));
|
|
653
|
+
const compactPrimary = primary.map(({ agent, limit }) => formatAttributedLimitCompact(agent, limit));
|
|
654
|
+
const compactValues = agents.flatMap((agent) => formatAttributedValue(agent, true) ?? []);
|
|
655
|
+
const noRunway = entries.length === 0 ? ["subscription detected", "runway not reported"] : [];
|
|
656
|
+
const urgent = compactPrimary[0];
|
|
657
|
+
const urgentValue = agents[0] ? formatAttributedValue(agents[0], true) : undefined;
|
|
658
|
+
const meteredBilled = snapshot.metered?.providerBilled.sevenDays;
|
|
659
|
+
const meteredEstimated = snapshot.metered?.apiEquivalent.sevenDays;
|
|
660
|
+
const metered = meteredBilled?.amountUsd !== null && meteredBilled?.amountUsd !== undefined &&
|
|
661
|
+
meteredBilled.financialEvidence === "verified"
|
|
662
|
+
? `metered ${formatBilledUsd(meteredBilled.amountUsd)}/7d billed`
|
|
663
|
+
: meteredEstimated?.amountUsd !== null && meteredEstimated?.amountUsd !== undefined &&
|
|
664
|
+
meteredEstimated.financialEvidence === "estimated"
|
|
665
|
+
? `metered ~${formatUsd(meteredEstimated.amountUsd)}/7d`
|
|
666
|
+
: "metered n/r";
|
|
667
|
+
const shortFreshness = compactFreshness(freshness);
|
|
668
|
+
const candidates = [];
|
|
669
|
+
if (tier === "full")
|
|
670
|
+
candidates.push(["aibill", ...fullSegments, freshness]);
|
|
671
|
+
if (tier !== "minimal") {
|
|
672
|
+
candidates.push(["aibill", "mix", ...noRunway, ...compactLimits, metered, ...compactValues, freshness], ["aibill", "mix", ...noRunway, ...compactPrimary, metered, freshness]);
|
|
673
|
+
}
|
|
674
|
+
if (urgent) {
|
|
675
|
+
if (urgentValue)
|
|
676
|
+
candidates.push(["aibill", "mix", urgent, metered, urgentValue, shortFreshness]);
|
|
677
|
+
candidates.push(["aibill", "mix", urgent, metered, shortFreshness], ["mix", urgent, metered], ["aibill", urgent, shortFreshness], ["aibill", urgent], [urgent]);
|
|
678
|
+
}
|
|
679
|
+
else {
|
|
680
|
+
candidates.push(["aibill", "mix", ...noRunway, metered, freshness], ["aibill", ...noRunway, freshness], ["aibill", "mix", "runway n/r", metered, shortFreshness], ["mix", "runway n/r", metered, shortFreshness], ["mix", "runway n/r", shortFreshness]);
|
|
681
|
+
}
|
|
682
|
+
candidates.push(["mix", metered, shortFreshness], ["mix", metered], ["mix"]);
|
|
683
|
+
return firstFittingLine(candidates, columns);
|
|
684
|
+
}
|
|
489
685
|
function formatLimitCompact(limit) {
|
|
490
686
|
const label = limit.kind === "five-hour" ? "5h" : "wk";
|
|
491
687
|
return `${label} ${formatPercent(limit.remainingPercent)}% left`;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-spend-agent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Local-first financial accountability CLI
|
|
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.
|
|
58
|
-
"@agent-finops/report": "0.
|
|
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
|
}
|