ai-spend-agent 0.5.8 → 0.5.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +178 -52
- package/package.json +3 -3
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, resolveSafeScanRoot, resolveSafeStateDirectory, subscriptionPlans, unsafeScanRootReason, selectProviderFinancialHeadlineRecords, writeSafeStateText, loadDeadContext, sampleDeadContext, loadLocalAgentUsage, loadSampleUsageData, scanLocalUsageSignals, buildMissingSourcePrompts, confirmMapping, createProviderConnectorStub, createLocalFolderSourceRegistry, createScanAuditLog, fetchProviderUsageRecords, addApprovedSource, slugifySourceId } from "@agent-finops/core";
|
|
6
|
+
import { analyzeSpend, attributeUsageRecords, buildUsageGlance, loadContextHealth, detectLocalCredentials, detectLocalPlans, redactSecrets, readSafeStateText, resolveSafeScanRoot, resolveSafeStateDirectory, subscriptionPlans, unsafeScanRootReason, selectProviderFinancialHeadlineRecords, writeSafeStateText, loadDeadContext, sampleDeadContext, latestObservedWorkingDirectory, isBundledSampleUsage, loadLocalAgentUsage, loadSampleUsageData, parseUsageRecord, scanLocalUsageSignals, buildMissingSourcePrompts, confirmMapping, createProviderConnectorStub, createLocalFolderSourceRegistry, createScanAuditLog, fetchProviderUsageRecords, addApprovedSource, slugifySourceId } 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")) {
|
|
@@ -82,7 +82,10 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
82
82
|
};
|
|
83
83
|
}
|
|
84
84
|
async function quickstartCommand(args) {
|
|
85
|
-
const
|
|
85
|
+
const sinceDays = args.sinceDays ?? 30;
|
|
86
|
+
if (!validSinceDays(sinceDays))
|
|
87
|
+
return invalidSinceDaysResult();
|
|
88
|
+
const { records, mode, warnings, codexInvocationFiles } = await loadInstantReadData(args);
|
|
86
89
|
const summaryRecords = mode === "connected"
|
|
87
90
|
? selectProviderFinancialHeadlineRecords(records)
|
|
88
91
|
: records;
|
|
@@ -124,7 +127,10 @@ async function quickstartCommand(args) {
|
|
|
124
127
|
// specific hints from leaking into a recording.
|
|
125
128
|
const detection = args.sample
|
|
126
129
|
? { credentials: [], scannedFiles: [] }
|
|
127
|
-
: await detectLocalCredentials({
|
|
130
|
+
: await detectLocalCredentials({
|
|
131
|
+
cwd: resolve(args.path),
|
|
132
|
+
home: process.env.AI_SPEND_CLAUDE_HOME_DIR
|
|
133
|
+
});
|
|
128
134
|
const nextSteps = quickstartNextSteps(mode, detection.credentials);
|
|
129
135
|
// Dead-context cost, globalized across the user's whole Claude Code setup
|
|
130
136
|
// (all projects' MCP + user-scope skills/agents/commands, vs. every
|
|
@@ -142,8 +148,9 @@ async function quickstartCommand(args) {
|
|
|
142
148
|
claudeSettingsPath: process.env.AI_SPEND_CLAUDE_SETTINGS,
|
|
143
149
|
projectDir: resolve(args.path),
|
|
144
150
|
includeAllProjectMcp: true,
|
|
145
|
-
sinceIso:
|
|
146
|
-
windowDays:
|
|
151
|
+
sinceIso: sinceIsoForDays(sinceDays),
|
|
152
|
+
windowDays: sinceDays,
|
|
153
|
+
codexInvocationFiles
|
|
147
154
|
}).catch(() => undefined);
|
|
148
155
|
// Sample dead-context is shown ONLY on the demo readout. A real readout
|
|
149
156
|
// (local logs / connected billing) never gets fabricated waste injected —
|
|
@@ -168,21 +175,21 @@ async function quickstartCommand(args) {
|
|
|
168
175
|
}
|
|
169
176
|
async function glanceCommand(args) {
|
|
170
177
|
const sinceDays = args.sinceDays ?? 30;
|
|
171
|
-
if (!
|
|
172
|
-
return
|
|
173
|
-
exitCode: 1,
|
|
174
|
-
stdout: "",
|
|
175
|
-
stderr: "--since-days must be a whole number between 1 and 365"
|
|
176
|
-
};
|
|
177
|
-
}
|
|
178
|
+
if (!validSinceDays(sinceDays))
|
|
179
|
+
return invalidSinceDaysResult();
|
|
178
180
|
const logs = await loadLocalAgentUsage({
|
|
179
181
|
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
180
182
|
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
|
|
181
|
-
sinceIso:
|
|
183
|
+
sinceIso: sinceIsoForDays(sinceDays),
|
|
184
|
+
collectCodexInvocationEvidence: true
|
|
182
185
|
});
|
|
183
186
|
const calls = args.project
|
|
184
187
|
? logs.calls.filter((call) => call.project === args.project)
|
|
185
188
|
: logs.calls;
|
|
189
|
+
const latestWorkingDirectory = latestObservedWorkingDirectory(calls);
|
|
190
|
+
const contextProjectDir = args.pathExplicit
|
|
191
|
+
? resolve(args.path)
|
|
192
|
+
: latestWorkingDirectory ?? resolve(args.path);
|
|
186
193
|
let detectedPlans;
|
|
187
194
|
if (args.plan) {
|
|
188
195
|
const override = planOverrideFromFlag(args.plan);
|
|
@@ -208,9 +215,10 @@ async function glanceCommand(args) {
|
|
|
208
215
|
codexHomeDir: process.env.AI_SPEND_CODEX_HOME_DIR,
|
|
209
216
|
claudeConfigPath: process.env.AI_SPEND_CLAUDE_CONFIG,
|
|
210
217
|
claudeSettingsPath: process.env.AI_SPEND_CLAUDE_SETTINGS,
|
|
211
|
-
projectDir:
|
|
212
|
-
sinceIso:
|
|
213
|
-
windowDays: sinceDays
|
|
218
|
+
projectDir: contextProjectDir,
|
|
219
|
+
sinceIso: sinceIsoForDays(sinceDays),
|
|
220
|
+
windowDays: sinceDays,
|
|
221
|
+
codexInvocationFiles: logs.codexInvocationFiles
|
|
214
222
|
});
|
|
215
223
|
const snapshot = buildUsageGlance(calls, {
|
|
216
224
|
filesParsed: logs.filesParsed,
|
|
@@ -223,18 +231,14 @@ async function glanceCommand(args) {
|
|
|
223
231
|
}
|
|
224
232
|
async function contextHealthCommand(args) {
|
|
225
233
|
const sinceDays = args.sinceDays ?? 30;
|
|
226
|
-
if (!
|
|
227
|
-
return
|
|
228
|
-
|
|
229
|
-
stdout: "",
|
|
230
|
-
stderr: "--since-days must be a whole number between 1 and 365"
|
|
231
|
-
};
|
|
232
|
-
}
|
|
233
|
-
const sinceIso = new Date(Date.now() - sinceDays * 24 * 60 * 60 * 1_000).toISOString();
|
|
234
|
+
if (!validSinceDays(sinceDays))
|
|
235
|
+
return invalidSinceDaysResult();
|
|
236
|
+
const sinceIso = sinceIsoForDays(sinceDays);
|
|
234
237
|
const logs = await loadLocalAgentUsage({
|
|
235
238
|
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
236
239
|
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
|
|
237
|
-
sinceIso
|
|
240
|
+
sinceIso,
|
|
241
|
+
collectCodexInvocationEvidence: true
|
|
238
242
|
});
|
|
239
243
|
const calls = args.project
|
|
240
244
|
? logs.calls.filter((call) => call.project === args.project)
|
|
@@ -248,7 +252,8 @@ async function contextHealthCommand(args) {
|
|
|
248
252
|
claudeSettingsPath: process.env.AI_SPEND_CLAUDE_SETTINGS,
|
|
249
253
|
projectDir: resolve(args.path),
|
|
250
254
|
sinceIso,
|
|
251
|
-
windowDays: sinceDays
|
|
255
|
+
windowDays: sinceDays,
|
|
256
|
+
codexInvocationFiles: logs.codexInvocationFiles
|
|
252
257
|
});
|
|
253
258
|
return ok(args.json ? JSON.stringify(health) : renderContextHealth(health));
|
|
254
259
|
}
|
|
@@ -264,20 +269,23 @@ function renderContextHealth(health) {
|
|
|
264
269
|
`Confidence: ${health.confidence}`,
|
|
265
270
|
"",
|
|
266
271
|
"Activation",
|
|
267
|
-
` Discoverable: ${activation.discoverableItems} Invoked: ${activation.explicitlyInvokedItems} MCP
|
|
272
|
+
` Discoverable: ${activation.discoverableItems} Invoked: ${activation.explicitlyInvokedItems} MCP configured: ${activation.mcpConfiguredItems}`,
|
|
273
|
+
` MCP always-load requested: ${activation.mcpAlwaysLoadedItems} Legacy schema-loaded label: ${activation.mcpSchemaLoadedItems}`,
|
|
268
274
|
` Hook-injected: ${activation.hookInjectedItems} Other lifecycle hooks: ${activation.lifecycleHooks} Unmeasured weight: ${activation.unmeasuredItems}`,
|
|
269
275
|
` Invocation-unobservable: ${activation.invocationUnobservableItems}`,
|
|
270
276
|
"",
|
|
271
|
-
`
|
|
277
|
+
`No matching invocation among observable inventory (${dead.windowDays}d): ${dead.neverInvokedItems}/${dead.loadedItems} ` +
|
|
272
278
|
`(${dead.measuredNeverInvokedItems} measured, ${dead.unmeasuredNeverInvokedItems} unmeasured)`
|
|
273
279
|
];
|
|
274
280
|
if (health.currentSession) {
|
|
275
281
|
const session = health.currentSession;
|
|
276
|
-
lines.push(`
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
282
|
+
lines.push(`Latest turn: ${session.agent}${session.project ? ` · ${session.project}` : ""} · ` +
|
|
283
|
+
(session.usageSource === "not_available"
|
|
284
|
+
? "input context unavailable; cumulative lifetime usage excluded"
|
|
285
|
+
: `${session.contextTokens.toLocaleString("en-US")} input context tokens · ` +
|
|
286
|
+
(session.ratioToMedian === null
|
|
287
|
+
? `${session.comparisonSessions} comparable prior session${session.comparisonSessions === 1 ? "" : "s"}; baseline not yet sufficient`
|
|
288
|
+
: `${session.ratioCapped ? "at least " : ""}${session.ratioToMedian}× comparable median (${session.comparisonSessions} prior)`)));
|
|
281
289
|
}
|
|
282
290
|
const churn = health.contextChurn;
|
|
283
291
|
if (churn.currentSessionEvidence === "matched") {
|
|
@@ -341,7 +349,9 @@ async function loadInstantReadData(args) {
|
|
|
341
349
|
const logs = await loadLocalAgentUsage({
|
|
342
350
|
// Env overrides keep tests (and unusual installs) isolated from $HOME.
|
|
343
351
|
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
344
|
-
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR
|
|
352
|
+
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
|
|
353
|
+
sinceIso: sinceIsoForDays(args.sinceDays ?? 30),
|
|
354
|
+
collectCodexInvocationEvidence: true
|
|
345
355
|
}).catch(() => undefined);
|
|
346
356
|
if (logs && logs.records.length > 0) {
|
|
347
357
|
// Persisted local_logs state (written by report/apply-artifact) is the
|
|
@@ -350,7 +360,12 @@ async function loadInstantReadData(args) {
|
|
|
350
360
|
if (persisted && persisted.records.length > 0 && persisted.mode !== "connected_provider" && persisted.mode !== "local_logs") {
|
|
351
361
|
warnings.push("Ignored persisted sample/legacy state in .ai-spend-agent/spend.json — showing your real local agent logs. Run `ai-spend-agent reset` to clear it, or pass --ignore-state.");
|
|
352
362
|
}
|
|
353
|
-
return {
|
|
363
|
+
return {
|
|
364
|
+
records: logs.records,
|
|
365
|
+
mode: "local-logs",
|
|
366
|
+
warnings,
|
|
367
|
+
codexInvocationFiles: logs.codexInvocationFiles
|
|
368
|
+
};
|
|
354
369
|
}
|
|
355
370
|
// No real logs. Persisted sample/legacy state may still be shown, but only as
|
|
356
371
|
// DEMO (never as connected), with a warning when its origin is unknown.
|
|
@@ -385,7 +400,10 @@ async function doctorCommand(args) {
|
|
|
385
400
|
const claudeFound = detected.includes("claude-code");
|
|
386
401
|
const codexFound = detected.includes("codex");
|
|
387
402
|
const hasLogs = claudeFound || codexFound;
|
|
388
|
-
const detection = await detectLocalCredentials({
|
|
403
|
+
const detection = await detectLocalCredentials({
|
|
404
|
+
cwd: rootPath,
|
|
405
|
+
home: process.env.AI_SPEND_CLAUDE_HOME_DIR
|
|
406
|
+
}).catch(() => ({ credentials: [] }));
|
|
389
407
|
const providerRefs = detection.credentials.map((credential) => `${credential.provider} (${credential.hint})`);
|
|
390
408
|
const plans = await detectLocalPlans({
|
|
391
409
|
claudeConfigPath: process.env.AI_SPEND_CLAUDE_CONFIG,
|
|
@@ -857,7 +875,10 @@ async function connectCommand(args) {
|
|
|
857
875
|
detail: `${provider} ${type} connector stub registered. No raw secrets stored.`
|
|
858
876
|
});
|
|
859
877
|
// Auto-detect a local key for this provider (never prints the raw value).
|
|
860
|
-
const detection = await detectLocalCredentials({
|
|
878
|
+
const detection = await detectLocalCredentials({
|
|
879
|
+
cwd: rootPath,
|
|
880
|
+
home: process.env.AI_SPEND_CLAUDE_HOME_DIR
|
|
881
|
+
});
|
|
861
882
|
const detected = detection.credentials.find((credential) => credential.provider === provider);
|
|
862
883
|
const lines = [
|
|
863
884
|
"aibill connect",
|
|
@@ -1056,7 +1077,10 @@ async function reportCommand(args) {
|
|
|
1056
1077
|
const rootPath = resolve(args.path);
|
|
1057
1078
|
const stateDir = join(rootPath, ".ai-spend-agent");
|
|
1058
1079
|
try {
|
|
1059
|
-
const
|
|
1080
|
+
const sinceDays = args.sinceDays ?? 30;
|
|
1081
|
+
if (!validSinceDays(sinceDays))
|
|
1082
|
+
return invalidSinceDaysResult();
|
|
1083
|
+
const reportInput = await buildReportInput(stateDir, rootPath, sinceDays);
|
|
1060
1084
|
const outBase = args.out ? resolve(rootPath, args.out) : join(stateDir, "report");
|
|
1061
1085
|
const markdownPath = `${outBase}.md`;
|
|
1062
1086
|
const htmlPath = `${outBase}.html`;
|
|
@@ -1073,8 +1097,8 @@ async function reportCommand(args) {
|
|
|
1073
1097
|
`policy/config draft: ${artifactPaths.policyConfigDraft}`,
|
|
1074
1098
|
`verification plan: ${artifactPaths.verificationPlan}`,
|
|
1075
1099
|
`demo package: ${artifactPaths.demoPackage}`,
|
|
1076
|
-
`total
|
|
1077
|
-
"privacy:
|
|
1100
|
+
`cost/value evidence total: $${reportInput.summary.totalUsd.toFixed(2)}`,
|
|
1101
|
+
"privacy: report rendered locally with no aibill telemetry; only explicit sync-provider contacts the selected provider",
|
|
1078
1102
|
"",
|
|
1079
1103
|
"next:",
|
|
1080
1104
|
` open ${htmlPath} view the full report in your browser`,
|
|
@@ -1128,7 +1152,7 @@ async function reportCardCommand(args) {
|
|
|
1128
1152
|
"Caption to share:",
|
|
1129
1153
|
generateReportCardCaption({ summary, records: headlineRecords, mode }),
|
|
1130
1154
|
"",
|
|
1131
|
-
"privacy: rendered locally; only totals,
|
|
1155
|
+
"privacy: rendered locally; only totals, generic candidate categories, and evidence labels are included."
|
|
1132
1156
|
].join("\n"));
|
|
1133
1157
|
}
|
|
1134
1158
|
catch (error) {
|
|
@@ -1143,11 +1167,32 @@ async function applyArtifactCommand(args) {
|
|
|
1143
1167
|
const rootPath = resolve(args.path);
|
|
1144
1168
|
const stateDir = join(rootPath, ".ai-spend-agent");
|
|
1145
1169
|
try {
|
|
1146
|
-
const
|
|
1170
|
+
const sinceDays = args.sinceDays ?? 30;
|
|
1171
|
+
if (!validSinceDays(sinceDays))
|
|
1172
|
+
return invalidSinceDaysResult();
|
|
1173
|
+
// `--sample` is a privacy boundary, not presentation sugar. It must never
|
|
1174
|
+
// fall through to live transcript, plan, credential, or persisted-state
|
|
1175
|
+
// discovery — regardless of where the flag appears after the command.
|
|
1176
|
+
const reportInput = args.sample
|
|
1177
|
+
? await buildExplicitSampleReportInput(rootPath)
|
|
1178
|
+
: await buildReportInput(stateDir, rootPath, sinceDays);
|
|
1179
|
+
await mkdir(stateDir, { recursive: true });
|
|
1147
1180
|
const artifactPaths = await writeApplyArtifacts(stateDir, reportInput);
|
|
1148
1181
|
// The prompt IS the product of this command — print it so a terminal
|
|
1149
1182
|
// user can copy it right here instead of hunting for a file path.
|
|
1150
1183
|
const codingPrompt = await readFile(artifactPaths.codingPrompt, "utf8");
|
|
1184
|
+
if (args.sample) {
|
|
1185
|
+
return ok([
|
|
1186
|
+
"aibill apply-artifact",
|
|
1187
|
+
"data: DEMO sample data (illustrative — not your logs, account, bill, project, or workflow)",
|
|
1188
|
+
"artifacts: .ai-spend-agent/ (non-executable demo files)",
|
|
1189
|
+
"safety: no live transcripts, account metadata, credentials, or persisted spend state were read",
|
|
1190
|
+
"",
|
|
1191
|
+
"──── non-executable demo prompt (also saved under .ai-spend-agent/) ────",
|
|
1192
|
+
"",
|
|
1193
|
+
codingPrompt.trimEnd()
|
|
1194
|
+
].join("\n"));
|
|
1195
|
+
}
|
|
1151
1196
|
return ok([
|
|
1152
1197
|
"aibill apply-artifact",
|
|
1153
1198
|
`path: ${rootPath}`,
|
|
@@ -1170,9 +1215,51 @@ async function applyArtifactCommand(args) {
|
|
|
1170
1215
|
};
|
|
1171
1216
|
}
|
|
1172
1217
|
}
|
|
1173
|
-
async function
|
|
1218
|
+
async function buildExplicitSampleReportInput(rootPath) {
|
|
1219
|
+
const records = await loadSampleUsageData();
|
|
1220
|
+
return {
|
|
1221
|
+
// Fixed alongside the bundled fixture so repeated demo runs stay stable
|
|
1222
|
+
// and cannot absorb this machine's clock or account state into an asset.
|
|
1223
|
+
generatedAt: "2026-05-20T00:00:00.000Z",
|
|
1224
|
+
summary: analyzeSpend(records),
|
|
1225
|
+
allRecords: records,
|
|
1226
|
+
dataMode: "sample",
|
|
1227
|
+
discovery: emptyDiscovery(rootPath),
|
|
1228
|
+
mappings: attributeUsageRecords(records),
|
|
1229
|
+
missingSourcePrompts: [],
|
|
1230
|
+
confirmedMappings: [],
|
|
1231
|
+
providerRecords: [],
|
|
1232
|
+
providerQa: [],
|
|
1233
|
+
deadContext: sampleDeadContext(),
|
|
1234
|
+
detectedPlans: []
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
async function buildReportInput(stateDir, rootPath, sinceDays = 30) {
|
|
1238
|
+
// One anchor for logs, Context Health, the paste-ready prompt, and every
|
|
1239
|
+
// supporting Apply artifact. This prevents millisecond window drift between
|
|
1240
|
+
// files generated by the same command.
|
|
1241
|
+
const generatedAt = new Date();
|
|
1242
|
+
const sinceIso = sinceIsoForDays(sinceDays, generatedAt);
|
|
1243
|
+
let freshLocalCalls;
|
|
1244
|
+
let freshCodexInvocationFiles;
|
|
1174
1245
|
let spendState = await readOptionalJson(join(stateDir, "spend.json"), undefined);
|
|
1175
1246
|
let mappings = await readOptionalJson(join(stateDir, "mappings.json"), undefined);
|
|
1247
|
+
// Never trust a persisted summary or an absent mode. Re-parse the records,
|
|
1248
|
+
// recover the narrowly identifiable bundled sample written by older
|
|
1249
|
+
// releases, and recompute decision output under the current evidence rules.
|
|
1250
|
+
// Any other unlabeled state remains unlabeled and therefore non-executable.
|
|
1251
|
+
if (spendState?.records && spendState.records.length > 0) {
|
|
1252
|
+
const records = spendState.records.map((record) => parseUsageRecord(record));
|
|
1253
|
+
const mode = spendState.mode ?? (isBundledSampleUsage(records) ? "sample" : undefined);
|
|
1254
|
+
const headlineRecords = mode === "connected_provider"
|
|
1255
|
+
? selectProviderFinancialHeadlineRecords(records)
|
|
1256
|
+
: records;
|
|
1257
|
+
spendState = {
|
|
1258
|
+
records,
|
|
1259
|
+
mode,
|
|
1260
|
+
summary: analyzeSpend(headlineRecords)
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1176
1263
|
// Local-log state is a CACHE, not a source of truth: the quickstart always
|
|
1177
1264
|
// re-reads the logs fresh, so report/apply must too — otherwise yesterday's
|
|
1178
1265
|
// persisted snapshot makes the artifact's numbers disagree with the screen.
|
|
@@ -1192,9 +1279,13 @@ async function buildReportInput(stateDir, rootPath) {
|
|
|
1192
1279
|
if (needsFreshLogs) {
|
|
1193
1280
|
const logs = await loadLocalAgentUsage({
|
|
1194
1281
|
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
1195
|
-
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR
|
|
1282
|
+
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
|
|
1283
|
+
sinceIso,
|
|
1284
|
+
collectCodexInvocationEvidence: true
|
|
1196
1285
|
}).catch(() => undefined);
|
|
1197
1286
|
if (logs && logs.records.length > 0) {
|
|
1287
|
+
freshLocalCalls = logs.calls;
|
|
1288
|
+
freshCodexInvocationFiles = logs.codexInvocationFiles;
|
|
1198
1289
|
const records = logs.records;
|
|
1199
1290
|
const summary = analyzeSpend(records);
|
|
1200
1291
|
const liveMappings = attributeUsageRecords(records);
|
|
@@ -1228,8 +1319,9 @@ async function buildReportInput(stateDir, rootPath) {
|
|
|
1228
1319
|
claudeSettingsPath: process.env.AI_SPEND_CLAUDE_SETTINGS,
|
|
1229
1320
|
projectDir: rootPath,
|
|
1230
1321
|
includeAllProjectMcp: true,
|
|
1231
|
-
sinceIso
|
|
1232
|
-
windowDays:
|
|
1322
|
+
sinceIso,
|
|
1323
|
+
windowDays: sinceDays,
|
|
1324
|
+
codexInvocationFiles: freshCodexInvocationFiles
|
|
1233
1325
|
}).catch(() => undefined)
|
|
1234
1326
|
: undefined;
|
|
1235
1327
|
const detectedPlans = spendState.mode === "local_logs"
|
|
@@ -1238,10 +1330,30 @@ async function buildReportInput(stateDir, rootPath) {
|
|
|
1238
1330
|
codexAuthPath: process.env.AI_SPEND_CODEX_AUTH
|
|
1239
1331
|
}).catch(() => [])
|
|
1240
1332
|
: [];
|
|
1333
|
+
// Report/apply and Glance consume the same canonical Context Health result.
|
|
1334
|
+
// If live transcript calls are unavailable, omit it instead of fabricating a
|
|
1335
|
+
// session-level recommendation from day-aggregate spend records.
|
|
1336
|
+
const contextHealth = spendState.mode === "local_logs" && freshLocalCalls
|
|
1337
|
+
? await loadContextHealth(freshLocalCalls, {
|
|
1338
|
+
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
1339
|
+
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
|
|
1340
|
+
claudeHomeDir: process.env.AI_SPEND_CLAUDE_HOME_DIR,
|
|
1341
|
+
codexHomeDir: process.env.AI_SPEND_CODEX_HOME_DIR,
|
|
1342
|
+
claudeConfigPath: process.env.AI_SPEND_CLAUDE_CONFIG,
|
|
1343
|
+
claudeSettingsPath: process.env.AI_SPEND_CLAUDE_SETTINGS,
|
|
1344
|
+
projectDir: rootPath,
|
|
1345
|
+
includeAllProjectMcp: true,
|
|
1346
|
+
sinceIso,
|
|
1347
|
+
windowDays: sinceDays,
|
|
1348
|
+
codexInvocationFiles: freshCodexInvocationFiles
|
|
1349
|
+
}).catch(() => undefined)
|
|
1350
|
+
: undefined;
|
|
1241
1351
|
return {
|
|
1352
|
+
generatedAt: generatedAt.toISOString(),
|
|
1242
1353
|
summary: spendState.summary,
|
|
1243
1354
|
deadContext,
|
|
1244
1355
|
detectedPlans,
|
|
1356
|
+
contextHealth,
|
|
1245
1357
|
// Evidence ledger is built from the SAME records as the confidence
|
|
1246
1358
|
// breakdown so the two sections can never contradict each other.
|
|
1247
1359
|
allRecords: spendState.mode === "connected_provider"
|
|
@@ -1257,6 +1369,19 @@ async function buildReportInput(stateDir, rootPath) {
|
|
|
1257
1369
|
providerQa: providerRecordsState.qa ? [providerRecordsState.qa] : []
|
|
1258
1370
|
};
|
|
1259
1371
|
}
|
|
1372
|
+
function validSinceDays(value) {
|
|
1373
|
+
return Number.isInteger(value) && value >= 1 && value <= 365;
|
|
1374
|
+
}
|
|
1375
|
+
function sinceIsoForDays(value, now = new Date()) {
|
|
1376
|
+
return new Date(now.getTime() - value * 24 * 60 * 60 * 1_000).toISOString();
|
|
1377
|
+
}
|
|
1378
|
+
function invalidSinceDaysResult() {
|
|
1379
|
+
return {
|
|
1380
|
+
exitCode: 1,
|
|
1381
|
+
stdout: "",
|
|
1382
|
+
stderr: "--since-days must be a whole number between 1 and 365"
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1260
1385
|
function emptyDiscovery(rootPath) {
|
|
1261
1386
|
return {
|
|
1262
1387
|
rootPath,
|
|
@@ -1358,6 +1483,7 @@ function parseArgs(argv) {
|
|
|
1358
1483
|
const next = rest[index + 1];
|
|
1359
1484
|
if (next) {
|
|
1360
1485
|
parsed.path = next;
|
|
1486
|
+
parsed.pathExplicit = true;
|
|
1361
1487
|
index += 1;
|
|
1362
1488
|
}
|
|
1363
1489
|
continue;
|
|
@@ -1654,20 +1780,20 @@ function helpText() {
|
|
|
1654
1780
|
" --ignore-state On the default/quickstart run, ignore persisted spend.json for this run",
|
|
1655
1781
|
" scan [--path <dir>] Scan a local workspace for AI usage signals",
|
|
1656
1782
|
" scan --sample Include deterministic sample spend analysis",
|
|
1657
|
-
" quickstart [--sample]
|
|
1658
|
-
" [--group-by source|model|client|project|agent|user|workspace|apiKey] Default: model",
|
|
1659
|
-
" report [--out <name>]
|
|
1783
|
+
" quickstart [--sample] [--since-days N] Plain-English local readout (default 30 days)",
|
|
1784
|
+
" [--group-by source|model|client|project|agent|user|workspace|apiKey] Default: project for local logs; model otherwise",
|
|
1785
|
+
" report [--out <name>] [--since-days N] Generate local Markdown and HTML reports from the same window",
|
|
1660
1786
|
" report-card [--out f.svg] Write your AI Receipt — a redacted, shareable SVG + caption",
|
|
1661
|
-
" glance [--project <name>] [--plan <id>] Emit the local, machine-readable Glance snapshot JSON",
|
|
1787
|
+
" glance [--project <name>] [--plan <id>] [--since-days N] Emit the local, machine-readable Glance snapshot JSON",
|
|
1662
1788
|
" context [--project <name>] [--since-days N] Show hook-aware Context Health in the terminal",
|
|
1663
1789
|
" [--json] Emit the same canonical Context Health object used by MCP and Glance",
|
|
1664
|
-
" apply
|
|
1790
|
+
" apply [--sample] [--since-days N] Print an evidence-constrained inspection/approval prompt + verification plans",
|
|
1665
1791
|
" apply-artifact Same as `apply` (long form)",
|
|
1666
1792
|
"",
|
|
1667
1793
|
"Cron (production watch): add a crontab entry such as:",
|
|
1668
1794
|
" 0 * * * * cd /path/to/workspace && ai-spend-agent watch --interval 3600 --cycles 1 >> ai-spend-watch.log 2>&1",
|
|
1669
1795
|
"",
|
|
1670
|
-
"Privacy: local
|
|
1796
|
+
"Privacy: local analysis and reports upload nothing. Only explicit sync-provider contacts the selected provider through an env: reference; secrets are never printed or persisted."
|
|
1671
1797
|
].join("\n");
|
|
1672
1798
|
}
|
|
1673
1799
|
// Main-module check that survives npm's bin SYMLINKS: argv[1] is
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-spend-agent",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.9",
|
|
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.5.
|
|
58
|
-
"@agent-finops/report": "0.5.
|
|
57
|
+
"@agent-finops/core": "0.5.9",
|
|
58
|
+
"@agent-finops/report": "0.5.9",
|
|
59
59
|
"yocto-spinner": "^1.2.0"
|
|
60
60
|
}
|
|
61
61
|
}
|