burnwatch 0.2.0 → 0.4.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/CHANGELOG.md +24 -0
- package/dist/cli.js +376 -134
- package/dist/cli.js.map +1 -1
- package/dist/cost-impact.d.ts +23 -0
- package/dist/cost-impact.js +281 -0
- package/dist/cost-impact.js.map +1 -0
- package/dist/detector-C4LnLT-O.d.ts +28 -0
- package/dist/hooks/on-file-change.js +324 -6
- package/dist/hooks/on-file-change.js.map +1 -1
- package/dist/hooks/on-prompt.js +2 -1
- package/dist/hooks/on-prompt.js.map +1 -1
- package/dist/hooks/on-session-start.js +114 -27
- package/dist/hooks/on-session-start.js.map +1 -1
- package/dist/hooks/on-stop.js +47 -3
- package/dist/hooks/on-stop.js.map +1 -1
- package/dist/index.d.ts +5 -159
- package/dist/index.js +352 -27
- package/dist/index.js.map +1 -1
- package/dist/interactive-init.d.ts +20 -0
- package/dist/interactive-init.js +239 -0
- package/dist/interactive-init.js.map +1 -0
- package/dist/mcp-server.js +106 -27
- package/dist/mcp-server.js.map +1 -1
- package/dist/types-fDMu4rOd.d.ts +178 -0
- package/package.json +1 -1
- package/registry.json +89 -1
package/dist/index.js
CHANGED
|
@@ -3,7 +3,8 @@ var CONFIDENCE_BADGES = {
|
|
|
3
3
|
live: "\u2705 LIVE",
|
|
4
4
|
calc: "\u{1F7E1} CALC",
|
|
5
5
|
est: "\u{1F7E0} EST",
|
|
6
|
-
blind: "\u{1F534} BLIND"
|
|
6
|
+
blind: "\u{1F534} BLIND",
|
|
7
|
+
excluded: "\u2B1A SKIP"
|
|
7
8
|
};
|
|
8
9
|
|
|
9
10
|
// src/core/registry.ts
|
|
@@ -59,7 +60,7 @@ import * as path2 from "path";
|
|
|
59
60
|
function detectServices(projectRoot) {
|
|
60
61
|
const registry = loadRegistry(projectRoot);
|
|
61
62
|
const results = /* @__PURE__ */ new Map();
|
|
62
|
-
const pkgDeps =
|
|
63
|
+
const pkgDeps = scanAllPackageJsons(projectRoot);
|
|
63
64
|
for (const [serviceId, service] of registry) {
|
|
64
65
|
const matchedPkgs = service.packageNames.filter(
|
|
65
66
|
(pkg) => pkgDeps.has(pkg)
|
|
@@ -71,7 +72,7 @@ function detectServices(projectRoot) {
|
|
|
71
72
|
);
|
|
72
73
|
}
|
|
73
74
|
}
|
|
74
|
-
const envVars =
|
|
75
|
+
const envVars = collectEnvVars(projectRoot);
|
|
75
76
|
for (const [serviceId, service] of registry) {
|
|
76
77
|
const matchedEnvs = service.envPatterns.filter(
|
|
77
78
|
(pattern) => envVars.has(pattern)
|
|
@@ -182,40 +183,118 @@ function getOrCreate(map, serviceId, service) {
|
|
|
182
183
|
}
|
|
183
184
|
return result;
|
|
184
185
|
}
|
|
185
|
-
function
|
|
186
|
+
function scanAllPackageJsons(projectRoot) {
|
|
186
187
|
const deps = /* @__PURE__ */ new Set();
|
|
187
|
-
const
|
|
188
|
+
const pkgFiles = findFiles(projectRoot, "package.json", 4);
|
|
189
|
+
for (const pkgPath of pkgFiles) {
|
|
190
|
+
try {
|
|
191
|
+
const raw = fs2.readFileSync(pkgPath, "utf-8");
|
|
192
|
+
const pkg = JSON.parse(raw);
|
|
193
|
+
for (const name of Object.keys(pkg.dependencies ?? {})) deps.add(name);
|
|
194
|
+
for (const name of Object.keys(pkg.devDependencies ?? {})) deps.add(name);
|
|
195
|
+
} catch {
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return deps;
|
|
199
|
+
}
|
|
200
|
+
function collectEnvVars(projectRoot) {
|
|
201
|
+
const envVars = new Set(Object.keys(process.env));
|
|
202
|
+
const envFiles = findEnvFiles(projectRoot, 3);
|
|
203
|
+
for (const envFile of envFiles) {
|
|
204
|
+
try {
|
|
205
|
+
const content = fs2.readFileSync(envFile, "utf-8");
|
|
206
|
+
const keys = content.split("\n").filter((line) => line.includes("=") && !line.startsWith("#")).map((line) => line.split("=")[0].trim()).filter(Boolean);
|
|
207
|
+
for (const key of keys) {
|
|
208
|
+
envVars.add(key);
|
|
209
|
+
}
|
|
210
|
+
} catch {
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return envVars;
|
|
214
|
+
}
|
|
215
|
+
function findEnvFiles(dir, maxDepth) {
|
|
216
|
+
const results = [];
|
|
217
|
+
if (maxDepth <= 0) return results;
|
|
188
218
|
try {
|
|
189
|
-
const
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
219
|
+
const entries = fs2.readdirSync(dir, { withFileTypes: true });
|
|
220
|
+
for (const entry of entries) {
|
|
221
|
+
if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist") continue;
|
|
222
|
+
const fullPath = path2.join(dir, entry.name);
|
|
223
|
+
if (entry.isDirectory()) {
|
|
224
|
+
results.push(...findEnvFiles(fullPath, maxDepth - 1));
|
|
225
|
+
} else if (entry.name.startsWith(".env")) {
|
|
226
|
+
results.push(fullPath);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
193
229
|
} catch {
|
|
194
230
|
}
|
|
195
|
-
return
|
|
231
|
+
return results;
|
|
232
|
+
}
|
|
233
|
+
function findFiles(dir, fileName, maxDepth) {
|
|
234
|
+
const results = [];
|
|
235
|
+
if (maxDepth <= 0) return results;
|
|
236
|
+
try {
|
|
237
|
+
const entries = fs2.readdirSync(dir, { withFileTypes: true });
|
|
238
|
+
for (const entry of entries) {
|
|
239
|
+
if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist") continue;
|
|
240
|
+
const fullPath = path2.join(dir, entry.name);
|
|
241
|
+
if (entry.isDirectory()) {
|
|
242
|
+
results.push(...findFiles(fullPath, fileName, maxDepth - 1));
|
|
243
|
+
} else if (entry.name === fileName) {
|
|
244
|
+
results.push(fullPath);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
} catch {
|
|
248
|
+
}
|
|
249
|
+
return results;
|
|
196
250
|
}
|
|
197
251
|
function scanImports(projectRoot) {
|
|
198
252
|
const imports = /* @__PURE__ */ new Set();
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
253
|
+
const codeDirs = ["src", "app", "lib", "pages", "components", "utils", "services", "hooks"];
|
|
254
|
+
const dirsToScan = [];
|
|
255
|
+
for (const dir of codeDirs) {
|
|
256
|
+
const fullPath = path2.join(projectRoot, dir);
|
|
257
|
+
if (fs2.existsSync(fullPath)) {
|
|
258
|
+
dirsToScan.push(fullPath);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
try {
|
|
262
|
+
const entries = fs2.readdirSync(projectRoot, { withFileTypes: true });
|
|
263
|
+
for (const entry of entries) {
|
|
264
|
+
if (!entry.isDirectory()) continue;
|
|
265
|
+
if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist" || entry.name.startsWith(".")) continue;
|
|
266
|
+
const subPkgPath = path2.join(projectRoot, entry.name, "package.json");
|
|
267
|
+
if (fs2.existsSync(subPkgPath)) {
|
|
268
|
+
for (const dir of codeDirs) {
|
|
269
|
+
const fullPath = path2.join(projectRoot, entry.name, dir);
|
|
270
|
+
if (fs2.existsSync(fullPath)) {
|
|
271
|
+
dirsToScan.push(fullPath);
|
|
215
272
|
}
|
|
216
273
|
}
|
|
217
274
|
}
|
|
218
|
-
}
|
|
275
|
+
}
|
|
276
|
+
} catch {
|
|
277
|
+
}
|
|
278
|
+
for (const dir of dirsToScan) {
|
|
279
|
+
const files = walkDir(dir, /\.(ts|tsx|js|jsx|mjs|cjs)$/);
|
|
280
|
+
for (const file of files) {
|
|
281
|
+
try {
|
|
282
|
+
const content = fs2.readFileSync(file, "utf-8");
|
|
283
|
+
const importRegex = /(?:from\s+["']|require\s*\(\s*["'])([^./][^"']*?)(?:["'])/g;
|
|
284
|
+
let match;
|
|
285
|
+
while ((match = importRegex.exec(content)) !== null) {
|
|
286
|
+
const pkg = match[1];
|
|
287
|
+
if (pkg) {
|
|
288
|
+
const parts = pkg.split("/");
|
|
289
|
+
if (parts[0]?.startsWith("@") && parts.length >= 2) {
|
|
290
|
+
imports.add(`${parts[0]}/${parts[1]}`);
|
|
291
|
+
} else if (parts[0]) {
|
|
292
|
+
imports.add(parts[0]);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
} catch {
|
|
297
|
+
}
|
|
219
298
|
}
|
|
220
299
|
}
|
|
221
300
|
return imports;
|
|
@@ -483,6 +562,14 @@ function writeLedger(brief, projectRoot) {
|
|
|
483
562
|
`| ${svc.serviceId} | ${spendStr} | ${badge} | ${budgetStr} | ${svc.statusLabel} |`
|
|
484
563
|
);
|
|
485
564
|
}
|
|
565
|
+
const impactAlert = brief.alerts.find(
|
|
566
|
+
(a) => a.serviceId === "_session_impact"
|
|
567
|
+
);
|
|
568
|
+
if (impactAlert) {
|
|
569
|
+
lines.push(
|
|
570
|
+
`| _projected impact_ | \u2014 | \u{1F4C8} EST | \u2014 | ${impactAlert.message} |`
|
|
571
|
+
);
|
|
572
|
+
}
|
|
486
573
|
lines.push("");
|
|
487
574
|
const totalStr = brief.totalIsEstimate ? `~$${brief.totalSpend.toFixed(2)}` : `$${brief.totalSpend.toFixed(2)}`;
|
|
488
575
|
const marginStr = brief.estimateMargin > 0 ? ` (\xB1$${brief.estimateMargin.toFixed(0)} estimated margin)` : "";
|
|
@@ -543,8 +630,245 @@ function readLatestSnapshot(projectRoot) {
|
|
|
543
630
|
return null;
|
|
544
631
|
}
|
|
545
632
|
}
|
|
633
|
+
|
|
634
|
+
// src/cost-impact.ts
|
|
635
|
+
var SERVICE_CALL_PATTERNS = {
|
|
636
|
+
anthropic: [
|
|
637
|
+
/\.messages\.create\s*\(/g,
|
|
638
|
+
/\.completions\.create\s*\(/g,
|
|
639
|
+
/anthropic\.\w+\.create\s*\(/g
|
|
640
|
+
],
|
|
641
|
+
openai: [
|
|
642
|
+
/\.chat\.completions\.create\s*\(/g,
|
|
643
|
+
/\.completions\.create\s*\(/g,
|
|
644
|
+
/\.images\.generate\s*\(/g,
|
|
645
|
+
/\.embeddings\.create\s*\(/g,
|
|
646
|
+
/openai\.\w+\.create\s*\(/g
|
|
647
|
+
],
|
|
648
|
+
"google-gemini": [
|
|
649
|
+
/\.generateContent\s*\(/g,
|
|
650
|
+
/\.generateContentStream\s*\(/g,
|
|
651
|
+
/model\.generate\w*\s*\(/g
|
|
652
|
+
],
|
|
653
|
+
"voyage-ai": [
|
|
654
|
+
/\.embed\s*\(/g,
|
|
655
|
+
/voyageai\.embed\s*\(/g
|
|
656
|
+
],
|
|
657
|
+
scrapfly: [
|
|
658
|
+
/\.scrape\s*\(/g,
|
|
659
|
+
/scrapfly\.scrape\s*\(/g,
|
|
660
|
+
/\.async_scrape\s*\(/g,
|
|
661
|
+
/ScrapeConfig\s*\(/g
|
|
662
|
+
],
|
|
663
|
+
browserbase: [
|
|
664
|
+
/\.createSession\s*\(/g,
|
|
665
|
+
/\.sessions\.create\s*\(/g,
|
|
666
|
+
/stagehand\.act\s*\(/g,
|
|
667
|
+
/stagehand\.extract\s*\(/g
|
|
668
|
+
],
|
|
669
|
+
upstash: [
|
|
670
|
+
/redis\.\w+\s*\(/g,
|
|
671
|
+
/\.set\s*\(/g,
|
|
672
|
+
/\.get\s*\(/g,
|
|
673
|
+
/\.incr\s*\(/g,
|
|
674
|
+
/\.hset\s*\(/g
|
|
675
|
+
],
|
|
676
|
+
resend: [
|
|
677
|
+
/resend\.emails\.send\s*\(/g,
|
|
678
|
+
/\.emails\.send\s*\(/g
|
|
679
|
+
],
|
|
680
|
+
stripe: [
|
|
681
|
+
/stripe\.charges\.create\s*\(/g,
|
|
682
|
+
/stripe\.paymentIntents\.create\s*\(/g,
|
|
683
|
+
/stripe\.checkout\.sessions\.create\s*\(/g
|
|
684
|
+
],
|
|
685
|
+
supabase: [
|
|
686
|
+
/supabase\.from\s*\(/g,
|
|
687
|
+
/\.rpc\s*\(/g,
|
|
688
|
+
/supabase\.storage/g
|
|
689
|
+
],
|
|
690
|
+
inngest: [
|
|
691
|
+
/inngest\.send\s*\(/g,
|
|
692
|
+
/\.createFunction\s*\(/g
|
|
693
|
+
],
|
|
694
|
+
posthog: [
|
|
695
|
+
/posthog\.capture\s*\(/g,
|
|
696
|
+
/\.capture\s*\(/g
|
|
697
|
+
],
|
|
698
|
+
aws: [
|
|
699
|
+
/\.send\s*\(new\s+\w+Command/g,
|
|
700
|
+
/s3Client\.send\s*\(/g,
|
|
701
|
+
/lambdaClient\.send\s*\(/g
|
|
702
|
+
]
|
|
703
|
+
};
|
|
704
|
+
function detectMultipliers(content) {
|
|
705
|
+
const multipliers = [];
|
|
706
|
+
if (/for\s*\(.*;\s*\w+\s*<\s*(\w+)/g.test(content)) {
|
|
707
|
+
const loopMatch = content.match(/for\s*\(.*;\s*\w+\s*<\s*(\d+)/);
|
|
708
|
+
if (loopMatch) {
|
|
709
|
+
const bound = parseInt(loopMatch[1]);
|
|
710
|
+
if (bound > 1) {
|
|
711
|
+
multipliers.push({ label: `for loop (${bound} iterations)`, factor: bound });
|
|
712
|
+
}
|
|
713
|
+
} else {
|
|
714
|
+
multipliers.push({ label: "for loop (variable bound)", factor: 10 });
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
if (/\.\s*map\s*\(\s*(async\s*)?\(/g.test(content)) {
|
|
718
|
+
multipliers.push({ label: ".map() iteration", factor: 10 });
|
|
719
|
+
}
|
|
720
|
+
if (/\.\s*forEach\s*\(\s*(async\s*)?\(/g.test(content)) {
|
|
721
|
+
multipliers.push({ label: ".forEach() iteration", factor: 10 });
|
|
722
|
+
}
|
|
723
|
+
if (/for\s*\(\s*(const|let|var)\s+\w+\s+(of|in)\s+/g.test(content)) {
|
|
724
|
+
multipliers.push({ label: "for...of/in loop", factor: 10 });
|
|
725
|
+
}
|
|
726
|
+
if (/Promise\.all\s*\(/g.test(content)) {
|
|
727
|
+
multipliers.push({ label: "Promise.all (parallel batch)", factor: 10 });
|
|
728
|
+
}
|
|
729
|
+
if (/cron|schedule|interval|setInterval|every\s+\d+\s*(min|hour|day|sec)/gi.test(content)) {
|
|
730
|
+
if (/every\s+5\s*min/gi.test(content) || /\*\/5\s+\*\s+\*/g.test(content)) {
|
|
731
|
+
multipliers.push({ label: "cron: every 5 minutes", factor: 8640 });
|
|
732
|
+
} else if (/every\s+1?\s*hour/gi.test(content) || /0\s+\*\s+\*\s+\*/g.test(content)) {
|
|
733
|
+
multipliers.push({ label: "cron: hourly", factor: 720 });
|
|
734
|
+
} else if (/every\s+1?\s*day/gi.test(content) || /0\s+0\s+\*\s+\*/g.test(content)) {
|
|
735
|
+
multipliers.push({ label: "cron: daily", factor: 30 });
|
|
736
|
+
} else {
|
|
737
|
+
multipliers.push({ label: "scheduled execution", factor: 30 });
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
const batchMatch = content.match(/batch[_\s]?size\s*[=:]\s*(\d+)/i);
|
|
741
|
+
if (batchMatch) {
|
|
742
|
+
const batchSize = parseInt(batchMatch[1]);
|
|
743
|
+
if (batchSize > 1) {
|
|
744
|
+
multipliers.push({ label: `batch size: ${batchSize}`, factor: batchSize });
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
return multipliers;
|
|
748
|
+
}
|
|
749
|
+
var GOTCHA_MULTIPLIERS = {
|
|
750
|
+
scrapfly: {
|
|
751
|
+
low: 1,
|
|
752
|
+
high: 25,
|
|
753
|
+
explanation: "anti-bot bypass consumes 5-25x base credits"
|
|
754
|
+
},
|
|
755
|
+
browserbase: {
|
|
756
|
+
low: 1,
|
|
757
|
+
high: 5,
|
|
758
|
+
explanation: "session duration affects cost \u2014 long sessions burn more"
|
|
759
|
+
},
|
|
760
|
+
anthropic: {
|
|
761
|
+
low: 1,
|
|
762
|
+
high: 60,
|
|
763
|
+
explanation: "Haiku ~$0.25/MTok vs Opus ~$15/MTok (60x range)"
|
|
764
|
+
},
|
|
765
|
+
openai: {
|
|
766
|
+
low: 1,
|
|
767
|
+
high: 30,
|
|
768
|
+
explanation: "GPT-4 mini vs GPT-5 (30x cost range)"
|
|
769
|
+
},
|
|
770
|
+
stripe: {
|
|
771
|
+
low: 1,
|
|
772
|
+
high: 1.5,
|
|
773
|
+
explanation: "international cards add 1-1.5% extra"
|
|
774
|
+
}
|
|
775
|
+
};
|
|
776
|
+
function analyzeCostImpact(filePath, content, projectRoot) {
|
|
777
|
+
if (!/\.(ts|tsx|js|jsx|mjs|cjs)$/.test(filePath)) {
|
|
778
|
+
return [];
|
|
779
|
+
}
|
|
780
|
+
const registry = loadRegistry(projectRoot);
|
|
781
|
+
const impacts = [];
|
|
782
|
+
const multipliers = detectMultipliers(content);
|
|
783
|
+
for (const [serviceId, patterns] of Object.entries(SERVICE_CALL_PATTERNS)) {
|
|
784
|
+
let totalCalls = 0;
|
|
785
|
+
for (const pattern of patterns) {
|
|
786
|
+
pattern.lastIndex = 0;
|
|
787
|
+
const matches = content.match(pattern);
|
|
788
|
+
if (matches) {
|
|
789
|
+
totalCalls += matches.length;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
if (totalCalls === 0) continue;
|
|
793
|
+
const service = registry.get(serviceId);
|
|
794
|
+
if (!service) continue;
|
|
795
|
+
const multiplierFactor = multipliers.length > 0 ? multipliers.reduce((max, m) => Math.max(max, m.factor), 1) : 1;
|
|
796
|
+
const baseMonthlyRuns = multipliers.some((m) => m.label.startsWith("cron")) ? 1 : 50;
|
|
797
|
+
const monthlyInvocations = totalCalls * multiplierFactor * baseMonthlyRuns;
|
|
798
|
+
const gotcha = GOTCHA_MULTIPLIERS[serviceId];
|
|
799
|
+
const unitRate = service.pricing?.unitRate ?? 0;
|
|
800
|
+
let costLow;
|
|
801
|
+
let costHigh;
|
|
802
|
+
if (unitRate > 0) {
|
|
803
|
+
costLow = monthlyInvocations * unitRate * (gotcha?.low ?? 1);
|
|
804
|
+
costHigh = monthlyInvocations * unitRate * (gotcha?.high ?? 1);
|
|
805
|
+
} else if (service.pricing?.monthlyBase !== void 0) {
|
|
806
|
+
costLow = 0;
|
|
807
|
+
costHigh = 0;
|
|
808
|
+
} else {
|
|
809
|
+
const typicalCallCosts = {
|
|
810
|
+
anthropic: 3e-3,
|
|
811
|
+
// ~$3/MTok * ~1K tokens average
|
|
812
|
+
openai: 2e-3,
|
|
813
|
+
"google-gemini": 1e-3,
|
|
814
|
+
scrapfly: 15e-5,
|
|
815
|
+
browserbase: 0.01,
|
|
816
|
+
resend: 1e-3,
|
|
817
|
+
stripe: 0.3
|
|
818
|
+
};
|
|
819
|
+
const perCall = typicalCallCosts[serviceId] ?? 1e-3;
|
|
820
|
+
costLow = monthlyInvocations * perCall * (gotcha?.low ?? 1);
|
|
821
|
+
costHigh = monthlyInvocations * perCall * (gotcha?.high ?? 1);
|
|
822
|
+
}
|
|
823
|
+
if (costLow === 0 && costHigh === 0) continue;
|
|
824
|
+
impacts.push({
|
|
825
|
+
serviceId,
|
|
826
|
+
serviceName: service.name,
|
|
827
|
+
filePath,
|
|
828
|
+
callCount: totalCalls,
|
|
829
|
+
multipliers: multipliers.map((m) => m.label),
|
|
830
|
+
multiplierFactor,
|
|
831
|
+
monthlyInvocations,
|
|
832
|
+
costLow,
|
|
833
|
+
costHigh,
|
|
834
|
+
rangeExplanation: gotcha?.explanation
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
return impacts;
|
|
838
|
+
}
|
|
839
|
+
function formatCostImpactCard(impacts, currentBudgets) {
|
|
840
|
+
const fileName = impacts[0]?.filePath.split("/").pop() ?? "unknown";
|
|
841
|
+
const lines = [];
|
|
842
|
+
lines.push(`[BURNWATCH] \u26A0\uFE0F Cost impact estimate for ${fileName}`);
|
|
843
|
+
for (const impact of impacts) {
|
|
844
|
+
const lowStr = impact.costLow < 1 ? `$${impact.costLow.toFixed(2)}` : `$${impact.costLow.toFixed(0)}`;
|
|
845
|
+
const highStr = impact.costHigh < 1 ? `$${impact.costHigh.toFixed(2)}` : `$${impact.costHigh.toFixed(0)}`;
|
|
846
|
+
const rangeStr = impact.costLow === impact.costHigh ? lowStr : `${lowStr}-${highStr}`;
|
|
847
|
+
lines.push(
|
|
848
|
+
` ${impact.serviceName}: ~${impact.monthlyInvocations.toLocaleString()} calls/mo \u2192 ${rangeStr}/mo` + (impact.rangeExplanation ? ` (${impact.rangeExplanation})` : "")
|
|
849
|
+
);
|
|
850
|
+
const current = currentBudgets[impact.serviceId];
|
|
851
|
+
if (current) {
|
|
852
|
+
const budgetStr = current.budget ? `$${current.spend.toFixed(0)}/$${current.budget} budget` : `$${current.spend.toFixed(0)} (no budget set)`;
|
|
853
|
+
const pctStr = current.budget && current.budget > 0 ? ` (${(current.spend / current.budget * 100).toFixed(0)}%)` : "";
|
|
854
|
+
lines.push(` Current: ${budgetStr}${pctStr}`);
|
|
855
|
+
}
|
|
856
|
+
const registry = loadRegistry();
|
|
857
|
+
const service = registry.get(impact.serviceId);
|
|
858
|
+
if (service?.alternatives && service.alternatives.length > 0 && impact.costHigh > 10) {
|
|
859
|
+
const freeAlts = service.alternatives.filter(
|
|
860
|
+
(a) => a.includes("free") || a.includes("cheerio") || a.includes("playwright") || a.includes("self-hosted")
|
|
861
|
+
);
|
|
862
|
+
if (freeAlts.length > 0) {
|
|
863
|
+
lines.push(` Consider: ${freeAlts.join(", ")} for lower-cost alternative`);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
return lines.join("\n");
|
|
868
|
+
}
|
|
546
869
|
export {
|
|
547
870
|
CONFIDENCE_BADGES,
|
|
871
|
+
analyzeCostImpact,
|
|
548
872
|
buildBrief,
|
|
549
873
|
buildSnapshot,
|
|
550
874
|
detectInFileChange,
|
|
@@ -552,6 +876,7 @@ export {
|
|
|
552
876
|
detectServices,
|
|
553
877
|
ensureProjectDirs,
|
|
554
878
|
formatBrief,
|
|
879
|
+
formatCostImpactCard,
|
|
555
880
|
formatSpendCard,
|
|
556
881
|
getAllServices,
|
|
557
882
|
getService,
|