ai-spend-agent 0.5.2 → 0.5.3
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 +69 -18
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -10,6 +10,13 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
10
10
|
return ok(helpText());
|
|
11
11
|
}
|
|
12
12
|
const args = parseArgs(argv);
|
|
13
|
+
if (args.groupByInvalid) {
|
|
14
|
+
return {
|
|
15
|
+
exitCode: 1,
|
|
16
|
+
stdout: "",
|
|
17
|
+
stderr: `--group-by needs a dimension: ${groupByDimensions.join("|")}\nexample: npx aibill --group-by project`
|
|
18
|
+
};
|
|
19
|
+
}
|
|
13
20
|
// Zero-key instant demo is the DEFAULT first run. Running `ai-spend-agent`
|
|
14
21
|
// with no subcommand (or `npx ai-spend-agent`), or with only flags such as
|
|
15
22
|
// `--group-by agent`, lands the wow immediately on sample / auto-detected
|
|
@@ -167,7 +174,11 @@ async function loadInstantReadData(args) {
|
|
|
167
174
|
const persisted = args.ignoreState ? undefined : await readPersistedSpend(resolve(args.path));
|
|
168
175
|
// Only real connected/synced provider state is authoritative enough to serve
|
|
169
176
|
// directly. Sample or legacy persisted state must NEVER mask real local logs.
|
|
170
|
-
|
|
177
|
+
// Defense in depth: "connected" is only believed if the records actually
|
|
178
|
+
// came from a provider — local-log records mislabeled connected (a past bug)
|
|
179
|
+
// must never be served as billing data.
|
|
180
|
+
const looksConnected = (persisted?.records ?? []).some((record) => record.providerCostType !== "local_agent_logs");
|
|
181
|
+
if (persisted && persisted.records.length > 0 && persisted.mode === "connected_provider" && looksConnected) {
|
|
171
182
|
return { records: persisted.records, mode: "connected", warnings };
|
|
172
183
|
}
|
|
173
184
|
// Real local agent logs (Claude Code / Codex) beat any sample/legacy state.
|
|
@@ -448,12 +459,16 @@ async function watchCommand(args) {
|
|
|
448
459
|
let iteration = 0;
|
|
449
460
|
while (unbounded || iteration < cycles) {
|
|
450
461
|
const previous = await readOptionalJson(join(stateDir, "watch-latest.json"), null);
|
|
451
|
-
const { summary, snapshot, records } = await runWatchCycle(stateDir, args);
|
|
462
|
+
const { summary, snapshot, records, mode } = await runWatchCycle(stateDir, args);
|
|
452
463
|
const deltaHeadline = buildDeltaHeadline(previous, snapshot);
|
|
464
|
+
// Watch's job is DELTAS: render the compact breakdown view per cycle, not
|
|
465
|
+
// the whole diagnose→verify readout again (the quickstart owns that).
|
|
453
466
|
const plainEnglish = generatePlainEnglishSummary(summary, {
|
|
454
467
|
records,
|
|
455
468
|
groupBy: args.groupBy ?? "model",
|
|
456
|
-
color: args.noColor ? false : undefined
|
|
469
|
+
color: args.noColor ? false : undefined,
|
|
470
|
+
mode: mode === "sample" ? "demo" : mode === "connected_provider" ? "connected" : "local-logs",
|
|
471
|
+
view: "breakdown"
|
|
457
472
|
});
|
|
458
473
|
const stamped = [
|
|
459
474
|
`=== watch cycle @ ${snapshot.capturedAt} ===`,
|
|
@@ -482,6 +497,10 @@ async function watchCommand(args) {
|
|
|
482
497
|
return ok(collected.join("\n\n"));
|
|
483
498
|
}
|
|
484
499
|
async function runWatchCycle(stateDir, args) {
|
|
500
|
+
// The persisted mode must describe where the records ACTUALLY came from.
|
|
501
|
+
// A previous version stamped everything "connected_provider", which made
|
|
502
|
+
// later quickstarts serve stale local-log data as "connected billing" and
|
|
503
|
+
// routed apply/report to the agency artifacts. Never label by assumption.
|
|
485
504
|
let records;
|
|
486
505
|
let mode;
|
|
487
506
|
if (args.sample) {
|
|
@@ -489,13 +508,26 @@ async function runWatchCycle(stateDir, args) {
|
|
|
489
508
|
mode = "sample";
|
|
490
509
|
}
|
|
491
510
|
else {
|
|
492
|
-
|
|
493
|
-
if (records.length > 0) {
|
|
511
|
+
const providerState = await readOptionalJson(join(stateDir, "provider-records.json"), { records: [] });
|
|
512
|
+
if (providerState.records.length > 0) {
|
|
513
|
+
records = providerState.records;
|
|
494
514
|
mode = "connected_provider";
|
|
495
515
|
}
|
|
496
516
|
else {
|
|
497
|
-
|
|
498
|
-
|
|
517
|
+
// Same freshness rule as quickstart/report: re-read local logs live,
|
|
518
|
+
// never serve a stale snapshot.
|
|
519
|
+
const logs = await loadLocalAgentUsage({
|
|
520
|
+
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
521
|
+
codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR
|
|
522
|
+
}).catch(() => undefined);
|
|
523
|
+
if (logs && logs.records.length > 0) {
|
|
524
|
+
records = logs.records;
|
|
525
|
+
mode = "local_logs";
|
|
526
|
+
}
|
|
527
|
+
else {
|
|
528
|
+
records = await loadSampleUsageData();
|
|
529
|
+
mode = "sample";
|
|
530
|
+
}
|
|
499
531
|
}
|
|
500
532
|
}
|
|
501
533
|
const summary = analyzeSpend(records);
|
|
@@ -517,7 +549,7 @@ async function runWatchCycle(stateDir, args) {
|
|
|
517
549
|
sourceId: "watch",
|
|
518
550
|
detail: `Watch cycle captured ${snapshot.recordCount} records totaling $${snapshot.totalUsd.toFixed(2)}.`
|
|
519
551
|
});
|
|
520
|
-
return { summary, snapshot, records };
|
|
552
|
+
return { summary, snapshot, records, mode };
|
|
521
553
|
}
|
|
522
554
|
function buildDeltaHeadline(previous, current) {
|
|
523
555
|
if (!previous) {
|
|
@@ -554,14 +586,6 @@ function buildDeltaHeadline(previous, current) {
|
|
|
554
586
|
}
|
|
555
587
|
return lines.join(" ");
|
|
556
588
|
}
|
|
557
|
-
async function loadLiveRecords(stateDir) {
|
|
558
|
-
const providerState = await readOptionalJson(join(stateDir, "provider-records.json"), { records: [] });
|
|
559
|
-
if (providerState.records.length > 0) {
|
|
560
|
-
return providerState.records;
|
|
561
|
-
}
|
|
562
|
-
const spendState = await readOptionalJson(join(stateDir, "spend.json"), {});
|
|
563
|
-
return spendState.records ?? [];
|
|
564
|
-
}
|
|
565
589
|
function sleep(milliseconds) {
|
|
566
590
|
return new Promise((resolveSleep) => setTimeout(resolveSleep, milliseconds));
|
|
567
591
|
}
|
|
@@ -726,7 +750,15 @@ async function syncProviderCommand(args) {
|
|
|
726
750
|
const rootPath = resolve(args.path);
|
|
727
751
|
const stateDir = join(rootPath, ".ai-spend-agent");
|
|
728
752
|
if (!args.provider || !args.authReference || !args.startTime) {
|
|
729
|
-
return {
|
|
753
|
+
return {
|
|
754
|
+
exitCode: 1,
|
|
755
|
+
stdout: "",
|
|
756
|
+
stderr: [
|
|
757
|
+
"sync-provider requires --provider, --auth-reference env:NAME, and --start-time <unix seconds>.",
|
|
758
|
+
"example:",
|
|
759
|
+
" npx aibill sync-provider --provider anthropic --auth-reference env:ANTHROPIC_ADMIN_KEY --start-time 1750000000"
|
|
760
|
+
].join("\n")
|
|
761
|
+
};
|
|
730
762
|
}
|
|
731
763
|
try {
|
|
732
764
|
const result = await fetchProviderUsageRecords({
|
|
@@ -927,7 +959,14 @@ async function buildReportInput(stateDir, rootPath) {
|
|
|
927
959
|
// re-reads the logs fresh, so report/apply must too — otherwise yesterday's
|
|
928
960
|
// persisted snapshot makes the artifact's numbers disagree with the screen.
|
|
929
961
|
// Persisted state stays authoritative only for connected/sample data.
|
|
930
|
-
const
|
|
962
|
+
const persistedLooksConnected = (spendState?.records ?? []).some((record) => record.providerCostType !== "local_agent_logs");
|
|
963
|
+
const needsFreshLogs = !spendState?.summary ||
|
|
964
|
+
!spendState.records ||
|
|
965
|
+
spendState.records.length === 0 ||
|
|
966
|
+
spendState.mode === "local_logs" ||
|
|
967
|
+
// Mislabeled state (local-log records stamped connected by a past bug)
|
|
968
|
+
// must be superseded by a fresh read, not trusted.
|
|
969
|
+
(spendState.mode === "connected_provider" && !persistedLooksConnected);
|
|
931
970
|
if (needsFreshLogs) {
|
|
932
971
|
const logs = await loadLocalAgentUsage({
|
|
933
972
|
claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
|
|
@@ -1061,6 +1100,18 @@ function parseArgs(argv) {
|
|
|
1061
1100
|
parsed.groupBy = next;
|
|
1062
1101
|
index += 1;
|
|
1063
1102
|
}
|
|
1103
|
+
else {
|
|
1104
|
+
// Missing/invalid dimension: surface an error instead of silently
|
|
1105
|
+
// rendering the full readout the user didn't ask for. A following
|
|
1106
|
+
// flag is NOT the dimension — don't consume it.
|
|
1107
|
+
if (next && !next.startsWith("--")) {
|
|
1108
|
+
parsed.groupByInvalid = next;
|
|
1109
|
+
index += 1;
|
|
1110
|
+
}
|
|
1111
|
+
else {
|
|
1112
|
+
parsed.groupByInvalid = "(missing)";
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1064
1115
|
continue;
|
|
1065
1116
|
}
|
|
1066
1117
|
if (arg === "--path") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-spend-agent",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.3",
|
|
4
4
|
"description": "Your AI spend in one view, in 90 seconds — local-first CLI for OpenAI, Anthropic, Cursor, Copilot + Claude Code/Codex session logs, with a ranked savings cut list, and flags the tools your agent loads but never uses (dead context).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -49,8 +49,8 @@
|
|
|
49
49
|
"prepack": "npm run build"
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@agent-finops/core": "0.5.
|
|
53
|
-
"@agent-finops/report": "0.5.
|
|
52
|
+
"@agent-finops/core": "0.5.3",
|
|
53
|
+
"@agent-finops/report": "0.5.3",
|
|
54
54
|
"yocto-spinner": "^1.2.0"
|
|
55
55
|
}
|
|
56
56
|
}
|