ai-spend-agent 0.6.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/dist/index.js +489 -34
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -3,11 +3,18 @@
|
|
|
3
3
|
The full [aibill](https://github.com/futurastudio/ai-spend-agent) CLI.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
+
npx aibill init
|
|
6
7
|
npx ai-spend-agent
|
|
7
8
|
# short alias
|
|
8
9
|
npx aibill
|
|
9
10
|
```
|
|
10
11
|
|
|
12
|
+
Run `npx aibill init` from a project to detect machine-wide local Claude Code
|
|
13
|
+
and Codex history, print the first evidence-labeled personal receipt, and seed a private
|
|
14
|
+
aggregate cache under `~/.aibill/cache/`. Init never replaces missing personal
|
|
15
|
+
evidence with the bundled sample and never overwrites existing connected
|
|
16
|
+
source or audit state.
|
|
17
|
+
|
|
11
18
|
It reads local Claude Code and Codex metadata, labels API-equivalent estimates,
|
|
12
19
|
and can optionally add official OpenAI or Anthropic provider-reported cost
|
|
13
20
|
through an environment-variable reference. No product telemetry is sent.
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { realpathSync } from "node:fs";
|
|
|
3
3
|
import { mkdir, readFile, rm, stat } from "node:fs/promises";
|
|
4
4
|
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
|
-
import { analyzeSpend, attributeUsageRecords, buildUsageGlance, loadContextHealth, detectLocalCredentials, detectLocalPlans, redactSecrets, readSafeStateText, invalidateConnectedSpendTrustReceipt, resolveSafeScanRoot, resolveSafeStateDirectory, subscriptionPlans, unsafeScanRootReason, selectProviderFinancialHeadlineRecords, writeSafeStateText, verifyConnectedSpendTrustReceipt, verifyConnectedSourceRegistryTrustReceipt, writeConnectedSpendTrustReceipt, loadDeadContext, sampleDeadContext, latestObservedWorkingDirectory, downgradeSampleUsageEvidence, isBundledSampleUsage, loadLocalAgentUsage, loadSampleUsageData, parseUsageRecord, scanLocalUsageSignals, buildMissingSourcePrompts, confirmMapping, createProviderConnectorStub, createLocalFolderSourceRegistry, createScanAuditLog, fetchProviderUsageRecords, addApprovedSource, normalizeSourceRegistry, downgradeUntrustedSourceRegistryClaims, buildSourceStatuses, slugifySourceId, financialEvidenceForRecords, formatSourceStatuses } from "@agent-finops/core";
|
|
6
|
+
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, 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
7
|
import { generateActionPlanMarkdown, generateApplyArtifactMarkdown, generateDemoPackageMarkdown, generateHtmlReport, generateMarkdownReport, generatePlainEnglishSummary, generatePolicyConfigDraftMarkdown, generateReportCardCaption, generateReportCardSvg, generateVerificationPlanMarkdown, groupByDimensions } from "@agent-finops/report";
|
|
8
8
|
export async function runCli(argv = process.argv.slice(2)) {
|
|
9
9
|
if (argv.includes("--version") || argv.includes("-v")) {
|
|
@@ -323,7 +323,7 @@ function quickstartNextSteps(mode, detected) {
|
|
|
323
323
|
steps.push("Need team reconciliation, allocation, budgets, and approvals? Workspace design partners: https://ai-spend-agent.vercel.app");
|
|
324
324
|
return steps;
|
|
325
325
|
}
|
|
326
|
-
async function readPersistedSpend(rootPath) {
|
|
326
|
+
async function readPersistedSpend(rootPath, options = {}) {
|
|
327
327
|
const stateDir = join(rootPath, ".ai-spend-agent");
|
|
328
328
|
try {
|
|
329
329
|
const exactSpendContents = await readSafeStateText(stateDir, "spend.json");
|
|
@@ -340,6 +340,9 @@ async function readPersistedSpend(rootPath) {
|
|
|
340
340
|
const records = mode === "sample" || mode === undefined
|
|
341
341
|
? downgradeSampleUsageEvidence(parsedRecords)
|
|
342
342
|
: parsedRecords;
|
|
343
|
+
if (spend.checkedAt !== undefined && !validIsoString(spend.checkedAt)) {
|
|
344
|
+
throw new Error("persisted spend checkedAt must be an ISO timestamp");
|
|
345
|
+
}
|
|
343
346
|
const providerCoverage = persistedProviderCoverage(spend.accounting);
|
|
344
347
|
const connectedTrust = mode === "connected_provider"
|
|
345
348
|
? await verifyConnectedSpendTrustReceipt(rootPath, exactSpendContents)
|
|
@@ -347,12 +350,16 @@ async function readPersistedSpend(rootPath) {
|
|
|
347
350
|
return {
|
|
348
351
|
mode,
|
|
349
352
|
records,
|
|
353
|
+
...(typeof spend.checkedAt === "string" ? { checkedAt: spend.checkedAt } : {}),
|
|
350
354
|
...(providerCoverage ? { providerCoverage } : {}),
|
|
351
355
|
...(isPlainObject(spend.accounting) ? { accounting: spend.accounting } : {}),
|
|
352
356
|
...(connectedTrust ? { connectedTrust } : {})
|
|
353
357
|
};
|
|
354
358
|
}
|
|
355
|
-
catch {
|
|
359
|
+
catch (error) {
|
|
360
|
+
if (options.strict && !isNodeError(error, "ENOENT")) {
|
|
361
|
+
throw new Error("Existing .ai-spend-agent/spend.json is invalid or unsafe; it was preserved and init stopped.");
|
|
362
|
+
}
|
|
356
363
|
return undefined;
|
|
357
364
|
}
|
|
358
365
|
}
|
|
@@ -992,43 +999,474 @@ async function resetCommand(args) {
|
|
|
992
999
|
].join("\n"));
|
|
993
1000
|
}
|
|
994
1001
|
async function initCommand(args) {
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1002
|
+
if (args.sample) {
|
|
1003
|
+
return {
|
|
1004
|
+
exitCode: 1,
|
|
1005
|
+
stdout: "",
|
|
1006
|
+
stderr: "aibill init only initializes from real local evidence; --sample was not used and no state or cache was changed. Run `npx aibill --sample` for the illustrative demo."
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
let detectedPlanOverride;
|
|
1010
|
+
if (args.plan) {
|
|
1011
|
+
const override = planOverrideFromFlag(args.plan);
|
|
1012
|
+
if (!override) {
|
|
1013
|
+
return {
|
|
1014
|
+
exitCode: 1,
|
|
1015
|
+
stdout: "",
|
|
1016
|
+
stderr: `Unknown --plan "${args.plan}". Valid plans: ${subscriptionPlans.map((plan) => plan.id).join(", ")}`
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
detectedPlanOverride = [override];
|
|
1020
|
+
}
|
|
1021
|
+
const rootPath = await resolveSafeScanRoot(args.path);
|
|
1022
|
+
const cacheDirectory = process.env.AIBILL_CACHE_DIR;
|
|
1023
|
+
await preflightInitCache(cacheDirectory);
|
|
1024
|
+
let stateDir;
|
|
1025
|
+
let stateDirectoryExists = true;
|
|
1026
|
+
try {
|
|
1027
|
+
stateDir = await resolveSafeStateDirectory(rootPath);
|
|
1028
|
+
}
|
|
1029
|
+
catch (error) {
|
|
1030
|
+
if (!isNodeError(error, "ENOENT"))
|
|
1031
|
+
throw error;
|
|
1032
|
+
stateDirectoryExists = false;
|
|
1033
|
+
stateDir = join(rootPath, ".ai-spend-agent");
|
|
1034
|
+
}
|
|
1035
|
+
const statePreparedAt = new Date();
|
|
1036
|
+
// Preflight every existing project file before any mutation. Valid files are
|
|
1037
|
+
// left byte-for-byte alone so a repeated init cannot erase connector
|
|
1038
|
+
// configuration, audit history, provider trust, spend state, or unknown
|
|
1039
|
+
// forward-compatible fields. The manifest remains the completion marker and
|
|
1040
|
+
// is written last.
|
|
1041
|
+
const existingManifest = stateDirectoryExists
|
|
1042
|
+
? await readInitJsonObject(stateDir, "manifest.json", { allowMissing: true })
|
|
1043
|
+
: undefined;
|
|
1044
|
+
const existingRegistry = stateDirectoryExists
|
|
1045
|
+
? await readInitJsonObject(stateDir, "sources.json", { allowMissing: true })
|
|
1046
|
+
: undefined;
|
|
1047
|
+
const existingAuditLog = stateDirectoryExists
|
|
1048
|
+
? await readInitJsonObject(stateDir, "audit-log.json", { allowMissing: true })
|
|
1049
|
+
: undefined;
|
|
1050
|
+
if (existingRegistry)
|
|
1051
|
+
normalizeSourceRegistry(existingRegistry);
|
|
1052
|
+
validateInitAuditLog(existingAuditLog);
|
|
1053
|
+
// Capture one attempt anchor immediately before the concurrent detection /
|
|
1054
|
+
// backfill reads. Snapshot windows, refresh ordering, and the manifest all
|
|
1055
|
+
// refer to this same moment.
|
|
1056
|
+
const asOf = new Date();
|
|
1057
|
+
const planPromise = detectedPlanOverride
|
|
1058
|
+
? Promise.resolve(detectedPlanOverride)
|
|
1059
|
+
: detectLocalPlans({
|
|
1060
|
+
claudeConfigPath: process.env.AI_SPEND_CLAUDE_CONFIG,
|
|
1061
|
+
codexAuthPath: process.env.AI_SPEND_CODEX_AUTH
|
|
1062
|
+
}).catch(() => []);
|
|
1063
|
+
// Attach the rejection handler now. A malformed spend file can fail before
|
|
1064
|
+
// the bounded transcript scan completes; leaving that promise unattended
|
|
1065
|
+
// during the scan would create an unhandled rejection.
|
|
1066
|
+
const persistedPromise = readPersistedSpend(rootPath, { strict: true }).then((persisted) => ({ persisted }), (error) => ({ error }));
|
|
1067
|
+
let logs;
|
|
1068
|
+
let scanError;
|
|
1069
|
+
try {
|
|
1070
|
+
logs = await loadLocalAgentFinancialUsage({
|
|
1071
|
+
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
1072
|
+
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
|
|
1073
|
+
sinceIso: sinceIsoForDays(30, asOf)
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
catch (error) {
|
|
1077
|
+
scanError = sanitizeSecretishError(error instanceof Error ? error.message : String(error));
|
|
1078
|
+
}
|
|
1079
|
+
const [detectedPlans, persistedResult] = await Promise.all([planPromise, persistedPromise]);
|
|
1080
|
+
if ("error" in persistedResult)
|
|
1081
|
+
throw persistedResult.error;
|
|
1082
|
+
const persisted = persistedResult.persisted;
|
|
1083
|
+
const trustedProviderRecords = persisted?.mode === "connected_provider" && persisted.connectedTrust?.trusted === true
|
|
1084
|
+
? selectProviderFinancialHeadlineRecords(persisted.records)
|
|
1085
|
+
: [];
|
|
1086
|
+
let activitySnapshot;
|
|
1087
|
+
let cacheStatus;
|
|
1088
|
+
const structuredSourceFailure = logs !== undefined &&
|
|
1089
|
+
logs.sourceScans.some((scan) => scan.directoryStatus === "unreadable") &&
|
|
1090
|
+
!logs.sourceScans.some((scan) => scan.directoryStatus === "readable");
|
|
1091
|
+
if (logs && !structuredSourceFailure) {
|
|
1092
|
+
let refreshErrorCode = "invalid_evidence";
|
|
1093
|
+
try {
|
|
1094
|
+
const generatedAt = new Date().toISOString();
|
|
1095
|
+
activitySnapshot = buildActivitySnapshot({
|
|
1096
|
+
asOf: asOf.toISOString(),
|
|
1097
|
+
generatedAt,
|
|
1098
|
+
records: [...logs.records, ...trustedProviderRecords],
|
|
1099
|
+
calls: logs.calls,
|
|
1100
|
+
detectedPlans,
|
|
1101
|
+
sourceScans: logs.sourceScans,
|
|
1102
|
+
trustedProviderRecordIds: trustedProviderRecords.map((record) => record.id),
|
|
1103
|
+
billedOverageRecordIds: [],
|
|
1104
|
+
providerCoverage: initProviderCoverage(trustedProviderRecords, persisted?.mode === "connected_provider" && persisted.connectedTrust?.trusted === true
|
|
1105
|
+
? trustedAccountingMap(persisted.accounting, "coverageByProvider")
|
|
1106
|
+
: {}, persisted?.mode === "connected_provider" && persisted.connectedTrust?.trusted === true
|
|
1107
|
+
? trustedAccountingMap(persisted.accounting, "checkedAtByProvider")
|
|
1108
|
+
: {}, persisted?.mode === "connected_provider" && persisted.connectedTrust?.trusted === true
|
|
1109
|
+
? trustedAccountingMap(persisted.accounting, "coverageIntervalsByProvider")
|
|
1110
|
+
: {}, persisted?.mode === "connected_provider" && persisted.connectedTrust?.trusted === true
|
|
1111
|
+
? persisted.checkedAt ?? persisted.connectedTrust.trustedAt
|
|
1112
|
+
: undefined),
|
|
1113
|
+
sampleData: false
|
|
1114
|
+
});
|
|
1115
|
+
refreshErrorCode = "cache_write_failed";
|
|
1116
|
+
const written = await writeActivitySnapshot(activitySnapshot, { cacheDirectory });
|
|
1117
|
+
cacheStatus = written.status === "written" ? "refreshed" : "kept newer snapshot";
|
|
1118
|
+
}
|
|
1119
|
+
catch {
|
|
1120
|
+
scanError = refreshErrorCode === "invalid_evidence"
|
|
1121
|
+
? "the observed evidence could not produce a valid activity snapshot"
|
|
1122
|
+
: "the private activity cache could not be updated";
|
|
1123
|
+
const failed = await recordActivitySnapshotRefreshFailure(asOf.toISOString(), refreshErrorCode, { cacheDirectory });
|
|
1124
|
+
activitySnapshot = failed.snapshot;
|
|
1125
|
+
cacheStatus = "refresh failed";
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
else {
|
|
1129
|
+
const failed = await recordActivitySnapshotRefreshFailure(asOf.toISOString(), structuredSourceFailure ? "source_unreadable" : "scan_failed", { cacheDirectory });
|
|
1130
|
+
activitySnapshot = failed.snapshot;
|
|
1131
|
+
cacheStatus = "refresh failed";
|
|
1132
|
+
}
|
|
1133
|
+
if (!stateDirectoryExists) {
|
|
1134
|
+
stateDir = await resolveSafeStateDirectory(rootPath, { create: true });
|
|
1135
|
+
}
|
|
1136
|
+
await preserveOrCreateInitRegistry(stateDir, rootPath, statePreparedAt, existingRegistry);
|
|
1137
|
+
await preserveOrCreateInitAuditLog(stateDir, rootPath, statePreparedAt, existingAuditLog);
|
|
1138
|
+
const manifest = buildInitManifest(existingManifest, asOf);
|
|
1139
|
+
await writeSafeStateText(stateDir, "manifest.json", `${JSON.stringify(manifest, null, 2)}\n`);
|
|
1140
|
+
return ok(formatInitReceipt({
|
|
1141
|
+
rootPath,
|
|
1142
|
+
asOf,
|
|
1143
|
+
activitySnapshot,
|
|
1144
|
+
logs,
|
|
1145
|
+
detectedPlans,
|
|
1146
|
+
trustedProviderRecords,
|
|
1147
|
+
providerCoverage: persisted?.providerCoverage,
|
|
1148
|
+
trustedProviderState: persisted?.mode === "connected_provider" && persisted.connectedTrust?.trusted === true,
|
|
1149
|
+
untrustedProviderState: persisted?.mode === "connected_provider" && persisted.connectedTrust?.trusted !== true,
|
|
1150
|
+
scanError,
|
|
1151
|
+
cacheStatus
|
|
1152
|
+
}));
|
|
1153
|
+
}
|
|
1154
|
+
async function preflightInitCache(cacheDirectory) {
|
|
1155
|
+
const existing = await readActivitySnapshot({ cacheDirectory });
|
|
1156
|
+
if (existing.status !== "error")
|
|
1157
|
+
return;
|
|
1158
|
+
throw new Error(`Existing private activity cache is ${existing.code.replaceAll("_", " ")}; ` +
|
|
1159
|
+
"it was preserved and init stopped. Remove the cache explicitly before rebuilding it.");
|
|
1160
|
+
}
|
|
1161
|
+
async function preserveOrCreateInitRegistry(stateDir, rootPath, asOf, existing) {
|
|
1162
|
+
if (existing) {
|
|
1163
|
+
// Leave the valid contract byte-for-byte alone. Rewriting would drop
|
|
1164
|
+
// unknown future fields and could invalidate a connected-state receipt
|
|
1165
|
+
// bound to the exact source-registry bytes.
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
await writeSafeStateText(stateDir, "sources.json", `${JSON.stringify(createLocalFolderSourceRegistry(rootPath, asOf), null, 2)}\n`);
|
|
1169
|
+
}
|
|
1170
|
+
async function preserveOrCreateInitAuditLog(stateDir, rootPath, asOf, existing) {
|
|
1171
|
+
if (existing)
|
|
1172
|
+
return;
|
|
1173
|
+
const timestamp = asOf.toISOString();
|
|
1174
|
+
await writeSafeStateText(stateDir, "audit-log.json", `${JSON.stringify(createScanAuditLog([{
|
|
1175
|
+
timestamp,
|
|
1176
|
+
action: "source_registered",
|
|
1177
|
+
sourceId: "local-root",
|
|
1178
|
+
path: rootPath,
|
|
1179
|
+
detail: "Explicit local folder source approved during init."
|
|
1180
|
+
}]), null, 2)}\n`);
|
|
1181
|
+
}
|
|
1182
|
+
function validateInitAuditLog(existing) {
|
|
1183
|
+
if (existing && (existing.version !== 1 || existing.localOnly !== true || !Array.isArray(existing.events))) {
|
|
1184
|
+
throw new Error("Invalid local audit log: expected the canonical local-only audit shape.");
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
async function readInitJsonObject(stateDir, fileName, options) {
|
|
1188
|
+
let contents;
|
|
1189
|
+
try {
|
|
1190
|
+
contents = await readSafeStateText(stateDir, fileName);
|
|
1191
|
+
}
|
|
1192
|
+
catch (error) {
|
|
1193
|
+
if (options.allowMissing && isNodeError(error, "ENOENT"))
|
|
1194
|
+
return undefined;
|
|
1195
|
+
throw error;
|
|
1196
|
+
}
|
|
1197
|
+
let parsed;
|
|
1198
|
+
try {
|
|
1199
|
+
parsed = JSON.parse(contents);
|
|
1200
|
+
}
|
|
1201
|
+
catch {
|
|
1202
|
+
throw new Error(`Invalid local ${fileName}: expected JSON; the existing file was preserved.`);
|
|
1203
|
+
}
|
|
1204
|
+
if (!isPlainObject(parsed)) {
|
|
1205
|
+
throw new Error(`Invalid local ${fileName}: expected a JSON object; the existing file was preserved.`);
|
|
1206
|
+
}
|
|
1207
|
+
return parsed;
|
|
1208
|
+
}
|
|
1209
|
+
function buildInitManifest(existing, asOf) {
|
|
1210
|
+
const timestamp = asOf.toISOString();
|
|
1211
|
+
return {
|
|
1212
|
+
...(existing ?? {}),
|
|
1000
1213
|
product: "aibill",
|
|
1001
|
-
mode: "local-first
|
|
1214
|
+
mode: "local-first",
|
|
1002
1215
|
cloudUpload: false,
|
|
1003
1216
|
cronJobsEnabled: false,
|
|
1004
1217
|
redactionPolicy: "secrets are never printed; detected values are written only as [REDACTED]",
|
|
1005
1218
|
sourceRegistry: "sources.json",
|
|
1006
1219
|
auditLog: "audit-log.json",
|
|
1220
|
+
backfillWindowDays: 30,
|
|
1221
|
+
statusSnapshot: {
|
|
1222
|
+
schema: "aibill.activity_snapshot/v1",
|
|
1223
|
+
storage: "private external cache",
|
|
1224
|
+
networkUploaded: false
|
|
1225
|
+
},
|
|
1226
|
+
initializedAt: typeof existing?.initializedAt === "string" ? existing.initializedAt : timestamp,
|
|
1227
|
+
lastInitializedAt: timestamp,
|
|
1007
1228
|
nextCommands: [
|
|
1008
|
-
"npx aibill
|
|
1009
|
-
|
|
1010
|
-
|
|
1229
|
+
"npx aibill",
|
|
1230
|
+
"npx aibill doctor --sources",
|
|
1231
|
+
"npx aibill report"
|
|
1011
1232
|
]
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
function initProviderCoverage(records, coverageByProvider, checkedAtByProvider, coverageIntervalsByProvider, fallbackCheckedAt) {
|
|
1236
|
+
const providerGroups = new Map();
|
|
1237
|
+
for (const record of records) {
|
|
1238
|
+
const rawProvider = record.source.provider;
|
|
1239
|
+
const provider = activitySnapshotProvider(rawProvider);
|
|
1240
|
+
const group = providerGroups.get(provider) ?? {
|
|
1241
|
+
rawProviders: new Set(),
|
|
1242
|
+
records: []
|
|
1243
|
+
};
|
|
1244
|
+
group.rawProviders.add(rawProvider);
|
|
1245
|
+
group.records.push(record);
|
|
1246
|
+
providerGroups.set(provider, group);
|
|
1247
|
+
}
|
|
1248
|
+
for (const rawProvider of new Set([
|
|
1249
|
+
...Object.keys(coverageByProvider),
|
|
1250
|
+
...Object.keys(checkedAtByProvider),
|
|
1251
|
+
...Object.keys(coverageIntervalsByProvider)
|
|
1252
|
+
])) {
|
|
1253
|
+
const provider = activitySnapshotProvider(rawProvider);
|
|
1254
|
+
const group = providerGroups.get(provider) ?? {
|
|
1255
|
+
rawProviders: new Set(),
|
|
1256
|
+
records: []
|
|
1257
|
+
};
|
|
1258
|
+
group.rawProviders.add(rawProvider);
|
|
1259
|
+
providerGroups.set(provider, group);
|
|
1260
|
+
}
|
|
1261
|
+
return [...providerGroups.entries()].map(([provider, group]) => {
|
|
1262
|
+
const coverages = [...group.rawProviders]
|
|
1263
|
+
.map((rawProvider) => coverageByProvider[rawProvider] ?? coverageByProvider[provider])
|
|
1264
|
+
.filter((coverage) => coverage !== undefined);
|
|
1265
|
+
const status = coverages.length === group.rawProviders.size && coverages.every((coverage) => coverage === "complete")
|
|
1266
|
+
? "complete"
|
|
1267
|
+
: coverages.some((coverage) => coverage === "complete" || coverage === "partial")
|
|
1268
|
+
? "partial"
|
|
1269
|
+
: "unavailable";
|
|
1270
|
+
const checkedValues = [...group.rawProviders]
|
|
1271
|
+
.map((rawProvider) => checkedAtByProvider[rawProvider] ?? checkedAtByProvider[provider])
|
|
1272
|
+
.filter((value) => validIsoString(value));
|
|
1273
|
+
const receiptBoundCheckedAt = checkedValues.length === group.rawProviders.size
|
|
1274
|
+
? checkedValues.sort()[0]
|
|
1275
|
+
: providerGroups.size === 1 && validIsoString(fallbackCheckedAt)
|
|
1276
|
+
? fallbackCheckedAt
|
|
1277
|
+
: undefined;
|
|
1278
|
+
const latestEvidenceAt = group.records
|
|
1279
|
+
.map((record) => record.timestamp)
|
|
1280
|
+
.filter(validIsoString)
|
|
1281
|
+
.sort()
|
|
1282
|
+
.at(-1);
|
|
1283
|
+
const intervals = [...group.rawProviders]
|
|
1284
|
+
.map((rawProvider) => coverageIntervalsByProvider[rawProvider] ?? coverageIntervalsByProvider[provider])
|
|
1285
|
+
.filter(validProviderCoverageInterval);
|
|
1286
|
+
const coverageStart = intervals.length === group.rawProviders.size
|
|
1287
|
+
? intervals.map((interval) => interval.coverageStart).sort().at(-1)
|
|
1288
|
+
: undefined;
|
|
1289
|
+
const coverageEnd = intervals.length === group.rawProviders.size
|
|
1290
|
+
? intervals.map((interval) => interval.coverageEnd).sort()[0]
|
|
1291
|
+
: undefined;
|
|
1292
|
+
return {
|
|
1293
|
+
provider,
|
|
1294
|
+
status,
|
|
1295
|
+
validationCoverage: sourceStatusDefinitions.find((definition) => definition.id === provider)?.validationCoverage
|
|
1296
|
+
?? "untested",
|
|
1297
|
+
...(receiptBoundCheckedAt ? { checkedAt: receiptBoundCheckedAt } : {}),
|
|
1298
|
+
...(receiptBoundCheckedAt && latestEvidenceAt ? { latestEvidenceAt } : {}),
|
|
1299
|
+
...(receiptBoundCheckedAt && coverageStart && coverageEnd && Date.parse(coverageStart) <= Date.parse(coverageEnd)
|
|
1300
|
+
? { coverageStart, coverageEnd }
|
|
1301
|
+
: {})
|
|
1302
|
+
};
|
|
1303
|
+
}).sort((left, right) => left.provider.localeCompare(right.provider));
|
|
1304
|
+
}
|
|
1305
|
+
function validProviderCoverageInterval(value) {
|
|
1306
|
+
return isPlainObject(value) &&
|
|
1307
|
+
validIsoString(value.coverageStart) &&
|
|
1308
|
+
validIsoString(value.coverageEnd) &&
|
|
1309
|
+
Date.parse(value.coverageStart) <= Date.parse(value.coverageEnd);
|
|
1310
|
+
}
|
|
1311
|
+
function activitySnapshotProvider(provider) {
|
|
1312
|
+
if (provider === "openai" || provider === "anthropic" || provider === "cursor" || provider === "github-copilot") {
|
|
1313
|
+
return provider;
|
|
1314
|
+
}
|
|
1315
|
+
return "other";
|
|
1316
|
+
}
|
|
1317
|
+
function formatInitReceipt(input) {
|
|
1318
|
+
const records = input.logs?.records ?? [];
|
|
1319
|
+
const pricedRecords = records.filter((record) => typeof record.amountUsd === "number");
|
|
1320
|
+
const sourceFailures = (input.logs?.sourceScans ?? []).some((scan) => scan.directoryStatus === "unreadable") ||
|
|
1321
|
+
(input.logs?.diagnostics ?? []).some((diagnostic) => diagnostic.severity === "error");
|
|
1322
|
+
const receiptLines = input.scanError || sourceFailures && records.length === 0
|
|
1323
|
+
? ["API-equivalent usage value: unavailable — the local scan could not prove an empty result"]
|
|
1324
|
+
: input.activitySnapshot
|
|
1325
|
+
? initApiEquivalentWindowLines(input.activitySnapshot)
|
|
1326
|
+
: ["API-equivalent usage value: unavailable — no snapshot was produced"];
|
|
1327
|
+
const planLine = input.detectedPlans.length > 0
|
|
1328
|
+
? input.detectedPlans.map((plan) => {
|
|
1329
|
+
const known = plan.planId ?? "unrecognized plan";
|
|
1330
|
+
return `${plan.agent}: ${known} (${plan.billing})`;
|
|
1331
|
+
}).join("; ")
|
|
1332
|
+
: "none detected (billing mode remains unresolved)";
|
|
1333
|
+
const sourceLines = (input.logs?.sourceScans ?? [
|
|
1334
|
+
emptyInitSourceScan("claude-code"),
|
|
1335
|
+
emptyInitSourceScan("codex")
|
|
1336
|
+
]).map((scan) => {
|
|
1337
|
+
const agentRecords = records.filter((record) => record.agentId === scan.agent);
|
|
1338
|
+
const priced = agentRecords.filter((record) => typeof record.amountUsd === "number").length;
|
|
1339
|
+
const skipped = scan.filesSkippedBeforeWindow ?? 0;
|
|
1340
|
+
const validation = scan.jsonlValidationCoverage === "financial_events_only"
|
|
1341
|
+
? "; financial-event JSONL validation only"
|
|
1342
|
+
: "";
|
|
1343
|
+
return ` ${scan.agent}: ${scan.directoryStatus}; ${scan.filesParsed}/${scan.filesDiscovered} files parsed; ${priced}/${agentRecords.length} rows priced${skipped > 0 ? `; ${skipped} old files skipped` : ""}${validation}`;
|
|
1012
1344
|
});
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
{
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
sourceId: "local-root",
|
|
1019
|
-
path: rootPath,
|
|
1020
|
-
detail: "Explicit local folder source approved during init."
|
|
1021
|
-
}
|
|
1022
|
-
]));
|
|
1023
|
-
return ok([
|
|
1345
|
+
const diagnosticLines = (input.logs?.diagnostics ?? [])
|
|
1346
|
+
.filter((diagnostic) => diagnostic.code !== "directory_missing")
|
|
1347
|
+
.map((diagnostic) => ` ! ${sanitizeSecretishError(diagnostic.message)} (${diagnostic.count})`);
|
|
1348
|
+
const providerLines = formatInitProviderEvidence(input);
|
|
1349
|
+
return [
|
|
1024
1350
|
"aibill init",
|
|
1025
|
-
`
|
|
1026
|
-
"
|
|
1027
|
-
"
|
|
1028
|
-
"
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1351
|
+
`state project: ${sanitizeSecretishError(basename(input.rootPath))}`,
|
|
1352
|
+
"local usage scope: all Claude Code + Codex activity on this machine (last 30 days)",
|
|
1353
|
+
"provider scope: trusted connected billing from this state project only (shown separately)",
|
|
1354
|
+
"",
|
|
1355
|
+
"FIRST RECEIPT · API-equivalent usage value · last 30 days",
|
|
1356
|
+
...receiptLines,
|
|
1357
|
+
`observed records: ${records.length}; priced: ${pricedRecords.length}; unpriced: ${records.length - pricedRecords.length}`,
|
|
1358
|
+
...providerLines,
|
|
1359
|
+
`plans: ${planLine}`,
|
|
1360
|
+
"",
|
|
1361
|
+
"source diagnostics (same backfill scan; no second scan):",
|
|
1362
|
+
...sourceLines,
|
|
1363
|
+
...diagnosticLines,
|
|
1364
|
+
input.scanError ? ` ! scan failed: ${input.scanError}` : "",
|
|
1365
|
+
"",
|
|
1366
|
+
`status cache: ${input.cacheStatus} · private local aggregate · nothing uploaded`,
|
|
1367
|
+
"state: .ai-spend-agent",
|
|
1368
|
+
"manifest: written last",
|
|
1369
|
+
"next: npx aibill doctor --sources"
|
|
1370
|
+
].filter((line) => line !== "").join("\n");
|
|
1371
|
+
}
|
|
1372
|
+
function formatInitProviderEvidence(input) {
|
|
1373
|
+
const windowStart = input.asOf.getTime() - 30 * 24 * 60 * 60 * 1_000;
|
|
1374
|
+
const inWindow = input.trustedProviderRecords.filter((record) => {
|
|
1375
|
+
const timestamp = Date.parse(record.timestamp);
|
|
1376
|
+
return Number.isFinite(timestamp) && timestamp >= windowStart && timestamp <= input.asOf.getTime();
|
|
1377
|
+
});
|
|
1378
|
+
const priced = inWindow.filter((record) => typeof record.amountUsd === "number");
|
|
1379
|
+
const verified = priced.filter((record) => record.costConfidence === "verified");
|
|
1380
|
+
const estimatedApiEquivalent = priced.filter((record) => record.costConfidence === "estimated" &&
|
|
1381
|
+
record.providerCostType === "anthropic_claude_code_usage");
|
|
1382
|
+
const estimated = priced.filter((record) => record.costConfidence === "estimated" &&
|
|
1383
|
+
record.providerCostType !== "anthropic_claude_code_usage");
|
|
1384
|
+
const detected = priced.filter((record) => record.costConfidence === "detected_unverified");
|
|
1385
|
+
const unpriced = inWindow.length - priced.length;
|
|
1386
|
+
const lines = [];
|
|
1387
|
+
if (verified.length > 0) {
|
|
1388
|
+
const billedWindow = input.activitySnapshot?.metered?.providerBilled.thirtyDays;
|
|
1389
|
+
lines.push(billedWindow?.amountUsd !== null && billedWindow?.amountUsd !== undefined
|
|
1390
|
+
? `provider-billed cost: ${formatOptionalUsd(billedWindow.amountUsd)} verified (kept separate)`
|
|
1391
|
+
: "provider-billed cost: unavailable — billed bucket boundaries do not prove an exact 30-day amount");
|
|
1392
|
+
}
|
|
1393
|
+
if (estimated.length > 0) {
|
|
1394
|
+
lines.push(`provider financial estimate: ${formatOptionalUsd(analyzeSpend(estimated).totalUsd)} estimated; not verified billed spend (kept separate)`);
|
|
1395
|
+
}
|
|
1396
|
+
if (estimatedApiEquivalent.length > 0) {
|
|
1397
|
+
lines.push(`provider API-equivalent estimate: ${formatOptionalUsd(analyzeSpend(estimatedApiEquivalent).totalUsd)} estimated value; not verified billed spend (kept separate)`);
|
|
1398
|
+
}
|
|
1399
|
+
if (detected.length > 0) {
|
|
1400
|
+
lines.push(`provider cost: ${formatOptionalUsd(analyzeSpend(detected).totalUsd)} detected_unverified; not billed spend (kept separate)`);
|
|
1401
|
+
}
|
|
1402
|
+
if (unpriced > 0) {
|
|
1403
|
+
lines.push(`provider cost: unavailable — ${unpriced} trusted row(s) lacked a supported cost amount`);
|
|
1404
|
+
}
|
|
1405
|
+
const provedEmptyBilledWindow = input.activitySnapshot?.metered?.providerBilled.thirtyDays;
|
|
1406
|
+
if (verified.length === 0 && provedEmptyBilledWindow?.amountUsd === 0 &&
|
|
1407
|
+
provedEmptyBilledWindow.financialEvidence === "verified") {
|
|
1408
|
+
lines.push("provider-billed cost: $0.00 verified for the receipt-bound 30-day interval");
|
|
1409
|
+
}
|
|
1410
|
+
if (lines.length === 0) {
|
|
1411
|
+
lines.push(input.untrustedProviderState
|
|
1412
|
+
? "provider-billed cost: unavailable — untrusted repository state was ignored"
|
|
1413
|
+
: input.trustedProviderRecords.length > 0
|
|
1414
|
+
? "provider cost: unavailable — connected evidence had no supported row in the last 30 days"
|
|
1415
|
+
: input.trustedProviderState
|
|
1416
|
+
? "provider-billed cost: unavailable — trusted connected provider evidence exists, but no receipt-bound 30-day amount was proven"
|
|
1417
|
+
: "provider-billed cost: not connected");
|
|
1418
|
+
}
|
|
1419
|
+
if (input.providerCoverage)
|
|
1420
|
+
lines.push(`provider coverage: ${input.providerCoverage}`);
|
|
1421
|
+
return lines;
|
|
1422
|
+
}
|
|
1423
|
+
function initApiEquivalentWindowLines(snapshot) {
|
|
1424
|
+
if (snapshot.mode === "error") {
|
|
1425
|
+
return ["API-equivalent usage value: unavailable — refresh failed"];
|
|
1426
|
+
}
|
|
1427
|
+
if (snapshot.mode === "empty") {
|
|
1428
|
+
const completeZero = snapshot.coverage.recordsParsed === 0 &&
|
|
1429
|
+
snapshot.coverage.agents.length > 0 &&
|
|
1430
|
+
snapshot.coverage.agents.every((agent) => agent.directoryStatus === "readable" &&
|
|
1431
|
+
agent.malformedLines === 0 &&
|
|
1432
|
+
agent.unreadableFiles === 0 &&
|
|
1433
|
+
agent.unsupportedUsageSnapshots === 0 &&
|
|
1434
|
+
agent.jsonlValidationCoverage === "complete");
|
|
1435
|
+
return [completeZero
|
|
1436
|
+
? "API-equivalent value: ~$0.00 1d · ~$0.00 7d · ~$0.00 30d (estimated value; not billed spend)"
|
|
1437
|
+
: "API-equivalent usage value: unavailable — local source coverage is incomplete; no zero total was inferred"];
|
|
1438
|
+
}
|
|
1439
|
+
if (snapshot.mode === "unresolved" && snapshot.unresolved) {
|
|
1440
|
+
return [formatInitApiWindows("billing unresolved", snapshot.unresolved.apiEquivalent)];
|
|
1441
|
+
}
|
|
1442
|
+
const lines = [];
|
|
1443
|
+
for (const agent of snapshot.subscription?.agents ?? []) {
|
|
1444
|
+
lines.push(formatInitApiWindows(`${agent.agent} subscription value`, agent.apiEquivalent));
|
|
1445
|
+
}
|
|
1446
|
+
if (snapshot.metered && snapshot.metered.apiEquivalent.thirtyDays.recordCount > 0) {
|
|
1447
|
+
lines.push(formatInitApiWindows("metered API-equivalent value", snapshot.metered.apiEquivalent));
|
|
1448
|
+
}
|
|
1449
|
+
if (snapshot.unresolved && snapshot.unresolved.apiEquivalent.thirtyDays.recordCount > 0) {
|
|
1450
|
+
lines.push(formatInitApiWindows("billing unresolved", snapshot.unresolved.apiEquivalent));
|
|
1451
|
+
}
|
|
1452
|
+
return lines.length > 0
|
|
1453
|
+
? lines
|
|
1454
|
+
: ["API-equivalent usage value: unavailable — no priced local value was observed"];
|
|
1455
|
+
}
|
|
1456
|
+
function formatInitApiWindows(label, windows) {
|
|
1457
|
+
const amount = (value) => value === null ? "unavailable" : `~${formatOptionalUsd(value)}`;
|
|
1458
|
+
return `${label}: ${amount(windows.oneDay.amountUsd)} 1d · ${amount(windows.sevenDays.amountUsd)} 7d · ${amount(windows.thirtyDays.amountUsd)} 30d (API-equivalent; not billed spend)`;
|
|
1459
|
+
}
|
|
1460
|
+
function emptyInitSourceScan(agent) {
|
|
1461
|
+
return {
|
|
1462
|
+
agent,
|
|
1463
|
+
directoryStatus: "unreadable",
|
|
1464
|
+
filesDiscovered: 0,
|
|
1465
|
+
filesParsed: 0,
|
|
1466
|
+
malformedLines: 0,
|
|
1467
|
+
unreadableFiles: 0,
|
|
1468
|
+
unsupportedUsageSnapshots: 0
|
|
1469
|
+
};
|
|
1032
1470
|
}
|
|
1033
1471
|
async function scanCommand(args) {
|
|
1034
1472
|
const rootPath = resolve(args.path);
|
|
@@ -1507,6 +1945,16 @@ async function syncProviderCommand(args) {
|
|
|
1507
1945
|
...trustedAccountingMap(trustedPrior?.accounting, "financialsByProvider"),
|
|
1508
1946
|
[result.provider]: result.financials
|
|
1509
1947
|
};
|
|
1948
|
+
const checkedAtByProvider = {
|
|
1949
|
+
...trustedAccountingMap(trustedPrior?.accounting, "checkedAtByProvider"),
|
|
1950
|
+
[result.provider]: result.fetchedAt
|
|
1951
|
+
};
|
|
1952
|
+
const priorCoverageIntervals = trustedAccountingMap(trustedPrior?.accounting, "coverageIntervalsByProvider");
|
|
1953
|
+
const coverageIntervalsByProvider = Object.fromEntries(Object.entries(priorCoverageIntervals).filter(([provider]) => provider !== result.provider));
|
|
1954
|
+
const requestedCoverageInterval = result.coverageInterval;
|
|
1955
|
+
if (requestedCoverageInterval) {
|
|
1956
|
+
coverageIntervalsByProvider[result.provider] = requestedCoverageInterval;
|
|
1957
|
+
}
|
|
1510
1958
|
// Invalidate any earlier receipt before the first mutation. If a later
|
|
1511
1959
|
// local write fails, the partially updated repository state stays
|
|
1512
1960
|
// untrusted rather than inheriting the previous sync's authority.
|
|
@@ -1523,7 +1971,11 @@ async function syncProviderCommand(args) {
|
|
|
1523
1971
|
records,
|
|
1524
1972
|
qa: result.qa,
|
|
1525
1973
|
qaByProvider,
|
|
1974
|
+
checkedAtByProvider,
|
|
1526
1975
|
coverageByProvider,
|
|
1976
|
+
...(Object.keys(coverageIntervalsByProvider).length > 0
|
|
1977
|
+
? { coverageIntervalsByProvider }
|
|
1978
|
+
: {}),
|
|
1527
1979
|
financialsByProvider
|
|
1528
1980
|
});
|
|
1529
1981
|
await recordProviderSourceAttempt(stateDir, result.provider, result.fetchedAt, result.coverage === "partial"
|
|
@@ -1533,9 +1985,11 @@ async function syncProviderCommand(args) {
|
|
|
1533
1985
|
policy: "provider_reported_billed_cost_preferred",
|
|
1534
1986
|
note: "Official provider-reported billed costs are the spend headline. API-equivalent estimates remain separate evidence and are not added to that total.",
|
|
1535
1987
|
coverageByProvider,
|
|
1988
|
+
checkedAtByProvider,
|
|
1989
|
+
coverageIntervalsByProvider,
|
|
1536
1990
|
qaByProvider,
|
|
1537
1991
|
financialsByProvider
|
|
1538
|
-
});
|
|
1992
|
+
}, result.fetchedAt);
|
|
1539
1993
|
await appendAuditEvent(stateDir, {
|
|
1540
1994
|
timestamp: result.fetchedAt,
|
|
1541
1995
|
action: "source_scanned",
|
|
@@ -2373,12 +2827,13 @@ function stripTerminalControlSequences(message) {
|
|
|
2373
2827
|
function isPersistedDataMode(value) {
|
|
2374
2828
|
return value === "sample" || value === "local_logs" || value === "connected_provider";
|
|
2375
2829
|
}
|
|
2376
|
-
async function writeLocalSpendState(stateDir, records, summary, mappings, mode, accounting) {
|
|
2830
|
+
async function writeLocalSpendState(stateDir, records, summary, mappings, mode, accounting, checkedAt) {
|
|
2377
2831
|
if (mode !== "connected_provider") {
|
|
2378
2832
|
await invalidateConnectedSpendTrustReceipt(dirname(stateDir));
|
|
2379
2833
|
}
|
|
2380
2834
|
await writeJson(join(stateDir, "spend.json"), {
|
|
2381
2835
|
mode,
|
|
2836
|
+
...(checkedAt && validIsoString(checkedAt) ? { checkedAt } : {}),
|
|
2382
2837
|
records,
|
|
2383
2838
|
summary,
|
|
2384
2839
|
...(accounting ? { accounting } : {})
|
|
@@ -2486,7 +2941,7 @@ function helpText() {
|
|
|
2486
2941
|
"",
|
|
2487
2942
|
"Other commands:",
|
|
2488
2943
|
" --version, -v Print the package version without reading local data",
|
|
2489
|
-
" init [--path <dir>]
|
|
2944
|
+
" init [--path <dir>] Backfill 30 days machine-wide, print the first evidence-labeled receipt, and cache a private snapshot",
|
|
2490
2945
|
" doctor [--sources] Launch diagnostics; --sources shows validation, evidence, freshness, and errors",
|
|
2491
2946
|
" reset [--path <dir>] Clear persisted spend state (so sample state can't mask real logs)",
|
|
2492
2947
|
" --ignore-state On the default/quickstart run, ignore persisted spend.json for this run",
|
|
@@ -2566,7 +3021,7 @@ export async function runMain() {
|
|
|
2566
3021
|
stdout: "",
|
|
2567
3022
|
stderr: [
|
|
2568
3023
|
`aibill hit an unexpected error: ${message}`,
|
|
2569
|
-
"Nothing was uploaded;
|
|
3024
|
+
"Nothing was uploaded. The command stopped without completing; run diagnostics before retrying.",
|
|
2570
3025
|
"Try `npx aibill doctor` for diagnostics, or open an issue: https://github.com/futurastudio/ai-spend-agent/issues"
|
|
2571
3026
|
].join("\n")
|
|
2572
3027
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-spend-agent",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Local-first financial accountability CLI for Claude Code and Codex work, cost evidence, attribution, runway, provenance, and Context Health.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -54,8 +54,8 @@
|
|
|
54
54
|
"prepack": "npm run build"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@agent-finops/core": "0.6.
|
|
58
|
-
"@agent-finops/report": "0.6.
|
|
57
|
+
"@agent-finops/core": "0.6.1",
|
|
58
|
+
"@agent-finops/report": "0.6.1",
|
|
59
59
|
"yocto-spinner": "^1.2.0"
|
|
60
60
|
}
|
|
61
61
|
}
|