burnwatch 0.3.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 +237 -31
- 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 +10 -1
- 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 +248 -1
- 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 +2 -1
- 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
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// src/hooks/on-file-change.ts
|
|
4
4
|
import * as fs5 from "fs";
|
|
5
|
+
import * as path5 from "path";
|
|
5
6
|
|
|
6
7
|
// src/core/config.ts
|
|
7
8
|
import * as fs from "fs";
|
|
@@ -144,8 +145,278 @@ function logEvent(event, projectRoot) {
|
|
|
144
145
|
fs4.mkdirSync(path4.dirname(logPath), { recursive: true });
|
|
145
146
|
fs4.appendFileSync(logPath, JSON.stringify(event) + "\n", "utf-8");
|
|
146
147
|
}
|
|
148
|
+
function readLatestSnapshot(projectRoot) {
|
|
149
|
+
const snapshotDir = path4.join(projectDataDir(projectRoot), "snapshots");
|
|
150
|
+
try {
|
|
151
|
+
const files = fs4.readdirSync(snapshotDir).filter((f) => f.startsWith("snapshot-") && f.endsWith(".json")).sort().reverse();
|
|
152
|
+
if (files.length === 0) return null;
|
|
153
|
+
const raw = fs4.readFileSync(
|
|
154
|
+
path4.join(snapshotDir, files[0]),
|
|
155
|
+
"utf-8"
|
|
156
|
+
);
|
|
157
|
+
return JSON.parse(raw);
|
|
158
|
+
} catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// src/cost-impact.ts
|
|
164
|
+
var SERVICE_CALL_PATTERNS = {
|
|
165
|
+
anthropic: [
|
|
166
|
+
/\.messages\.create\s*\(/g,
|
|
167
|
+
/\.completions\.create\s*\(/g,
|
|
168
|
+
/anthropic\.\w+\.create\s*\(/g
|
|
169
|
+
],
|
|
170
|
+
openai: [
|
|
171
|
+
/\.chat\.completions\.create\s*\(/g,
|
|
172
|
+
/\.completions\.create\s*\(/g,
|
|
173
|
+
/\.images\.generate\s*\(/g,
|
|
174
|
+
/\.embeddings\.create\s*\(/g,
|
|
175
|
+
/openai\.\w+\.create\s*\(/g
|
|
176
|
+
],
|
|
177
|
+
"google-gemini": [
|
|
178
|
+
/\.generateContent\s*\(/g,
|
|
179
|
+
/\.generateContentStream\s*\(/g,
|
|
180
|
+
/model\.generate\w*\s*\(/g
|
|
181
|
+
],
|
|
182
|
+
"voyage-ai": [
|
|
183
|
+
/\.embed\s*\(/g,
|
|
184
|
+
/voyageai\.embed\s*\(/g
|
|
185
|
+
],
|
|
186
|
+
scrapfly: [
|
|
187
|
+
/\.scrape\s*\(/g,
|
|
188
|
+
/scrapfly\.scrape\s*\(/g,
|
|
189
|
+
/\.async_scrape\s*\(/g,
|
|
190
|
+
/ScrapeConfig\s*\(/g
|
|
191
|
+
],
|
|
192
|
+
browserbase: [
|
|
193
|
+
/\.createSession\s*\(/g,
|
|
194
|
+
/\.sessions\.create\s*\(/g,
|
|
195
|
+
/stagehand\.act\s*\(/g,
|
|
196
|
+
/stagehand\.extract\s*\(/g
|
|
197
|
+
],
|
|
198
|
+
upstash: [
|
|
199
|
+
/redis\.\w+\s*\(/g,
|
|
200
|
+
/\.set\s*\(/g,
|
|
201
|
+
/\.get\s*\(/g,
|
|
202
|
+
/\.incr\s*\(/g,
|
|
203
|
+
/\.hset\s*\(/g
|
|
204
|
+
],
|
|
205
|
+
resend: [
|
|
206
|
+
/resend\.emails\.send\s*\(/g,
|
|
207
|
+
/\.emails\.send\s*\(/g
|
|
208
|
+
],
|
|
209
|
+
stripe: [
|
|
210
|
+
/stripe\.charges\.create\s*\(/g,
|
|
211
|
+
/stripe\.paymentIntents\.create\s*\(/g,
|
|
212
|
+
/stripe\.checkout\.sessions\.create\s*\(/g
|
|
213
|
+
],
|
|
214
|
+
supabase: [
|
|
215
|
+
/supabase\.from\s*\(/g,
|
|
216
|
+
/\.rpc\s*\(/g,
|
|
217
|
+
/supabase\.storage/g
|
|
218
|
+
],
|
|
219
|
+
inngest: [
|
|
220
|
+
/inngest\.send\s*\(/g,
|
|
221
|
+
/\.createFunction\s*\(/g
|
|
222
|
+
],
|
|
223
|
+
posthog: [
|
|
224
|
+
/posthog\.capture\s*\(/g,
|
|
225
|
+
/\.capture\s*\(/g
|
|
226
|
+
],
|
|
227
|
+
aws: [
|
|
228
|
+
/\.send\s*\(new\s+\w+Command/g,
|
|
229
|
+
/s3Client\.send\s*\(/g,
|
|
230
|
+
/lambdaClient\.send\s*\(/g
|
|
231
|
+
]
|
|
232
|
+
};
|
|
233
|
+
function detectMultipliers(content) {
|
|
234
|
+
const multipliers = [];
|
|
235
|
+
if (/for\s*\(.*;\s*\w+\s*<\s*(\w+)/g.test(content)) {
|
|
236
|
+
const loopMatch = content.match(/for\s*\(.*;\s*\w+\s*<\s*(\d+)/);
|
|
237
|
+
if (loopMatch) {
|
|
238
|
+
const bound = parseInt(loopMatch[1]);
|
|
239
|
+
if (bound > 1) {
|
|
240
|
+
multipliers.push({ label: `for loop (${bound} iterations)`, factor: bound });
|
|
241
|
+
}
|
|
242
|
+
} else {
|
|
243
|
+
multipliers.push({ label: "for loop (variable bound)", factor: 10 });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (/\.\s*map\s*\(\s*(async\s*)?\(/g.test(content)) {
|
|
247
|
+
multipliers.push({ label: ".map() iteration", factor: 10 });
|
|
248
|
+
}
|
|
249
|
+
if (/\.\s*forEach\s*\(\s*(async\s*)?\(/g.test(content)) {
|
|
250
|
+
multipliers.push({ label: ".forEach() iteration", factor: 10 });
|
|
251
|
+
}
|
|
252
|
+
if (/for\s*\(\s*(const|let|var)\s+\w+\s+(of|in)\s+/g.test(content)) {
|
|
253
|
+
multipliers.push({ label: "for...of/in loop", factor: 10 });
|
|
254
|
+
}
|
|
255
|
+
if (/Promise\.all\s*\(/g.test(content)) {
|
|
256
|
+
multipliers.push({ label: "Promise.all (parallel batch)", factor: 10 });
|
|
257
|
+
}
|
|
258
|
+
if (/cron|schedule|interval|setInterval|every\s+\d+\s*(min|hour|day|sec)/gi.test(content)) {
|
|
259
|
+
if (/every\s+5\s*min/gi.test(content) || /\*\/5\s+\*\s+\*/g.test(content)) {
|
|
260
|
+
multipliers.push({ label: "cron: every 5 minutes", factor: 8640 });
|
|
261
|
+
} else if (/every\s+1?\s*hour/gi.test(content) || /0\s+\*\s+\*\s+\*/g.test(content)) {
|
|
262
|
+
multipliers.push({ label: "cron: hourly", factor: 720 });
|
|
263
|
+
} else if (/every\s+1?\s*day/gi.test(content) || /0\s+0\s+\*\s+\*/g.test(content)) {
|
|
264
|
+
multipliers.push({ label: "cron: daily", factor: 30 });
|
|
265
|
+
} else {
|
|
266
|
+
multipliers.push({ label: "scheduled execution", factor: 30 });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const batchMatch = content.match(/batch[_\s]?size\s*[=:]\s*(\d+)/i);
|
|
270
|
+
if (batchMatch) {
|
|
271
|
+
const batchSize = parseInt(batchMatch[1]);
|
|
272
|
+
if (batchSize > 1) {
|
|
273
|
+
multipliers.push({ label: `batch size: ${batchSize}`, factor: batchSize });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return multipliers;
|
|
277
|
+
}
|
|
278
|
+
var GOTCHA_MULTIPLIERS = {
|
|
279
|
+
scrapfly: {
|
|
280
|
+
low: 1,
|
|
281
|
+
high: 25,
|
|
282
|
+
explanation: "anti-bot bypass consumes 5-25x base credits"
|
|
283
|
+
},
|
|
284
|
+
browserbase: {
|
|
285
|
+
low: 1,
|
|
286
|
+
high: 5,
|
|
287
|
+
explanation: "session duration affects cost \u2014 long sessions burn more"
|
|
288
|
+
},
|
|
289
|
+
anthropic: {
|
|
290
|
+
low: 1,
|
|
291
|
+
high: 60,
|
|
292
|
+
explanation: "Haiku ~$0.25/MTok vs Opus ~$15/MTok (60x range)"
|
|
293
|
+
},
|
|
294
|
+
openai: {
|
|
295
|
+
low: 1,
|
|
296
|
+
high: 30,
|
|
297
|
+
explanation: "GPT-4 mini vs GPT-5 (30x cost range)"
|
|
298
|
+
},
|
|
299
|
+
stripe: {
|
|
300
|
+
low: 1,
|
|
301
|
+
high: 1.5,
|
|
302
|
+
explanation: "international cards add 1-1.5% extra"
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
function analyzeCostImpact(filePath, content, projectRoot) {
|
|
306
|
+
if (!/\.(ts|tsx|js|jsx|mjs|cjs)$/.test(filePath)) {
|
|
307
|
+
return [];
|
|
308
|
+
}
|
|
309
|
+
const registry = loadRegistry(projectRoot);
|
|
310
|
+
const impacts = [];
|
|
311
|
+
const multipliers = detectMultipliers(content);
|
|
312
|
+
for (const [serviceId, patterns] of Object.entries(SERVICE_CALL_PATTERNS)) {
|
|
313
|
+
let totalCalls = 0;
|
|
314
|
+
for (const pattern of patterns) {
|
|
315
|
+
pattern.lastIndex = 0;
|
|
316
|
+
const matches = content.match(pattern);
|
|
317
|
+
if (matches) {
|
|
318
|
+
totalCalls += matches.length;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (totalCalls === 0) continue;
|
|
322
|
+
const service = registry.get(serviceId);
|
|
323
|
+
if (!service) continue;
|
|
324
|
+
const multiplierFactor = multipliers.length > 0 ? multipliers.reduce((max, m) => Math.max(max, m.factor), 1) : 1;
|
|
325
|
+
const baseMonthlyRuns = multipliers.some((m) => m.label.startsWith("cron")) ? 1 : 50;
|
|
326
|
+
const monthlyInvocations = totalCalls * multiplierFactor * baseMonthlyRuns;
|
|
327
|
+
const gotcha = GOTCHA_MULTIPLIERS[serviceId];
|
|
328
|
+
const unitRate = service.pricing?.unitRate ?? 0;
|
|
329
|
+
let costLow;
|
|
330
|
+
let costHigh;
|
|
331
|
+
if (unitRate > 0) {
|
|
332
|
+
costLow = monthlyInvocations * unitRate * (gotcha?.low ?? 1);
|
|
333
|
+
costHigh = monthlyInvocations * unitRate * (gotcha?.high ?? 1);
|
|
334
|
+
} else if (service.pricing?.monthlyBase !== void 0) {
|
|
335
|
+
costLow = 0;
|
|
336
|
+
costHigh = 0;
|
|
337
|
+
} else {
|
|
338
|
+
const typicalCallCosts = {
|
|
339
|
+
anthropic: 3e-3,
|
|
340
|
+
// ~$3/MTok * ~1K tokens average
|
|
341
|
+
openai: 2e-3,
|
|
342
|
+
"google-gemini": 1e-3,
|
|
343
|
+
scrapfly: 15e-5,
|
|
344
|
+
browserbase: 0.01,
|
|
345
|
+
resend: 1e-3,
|
|
346
|
+
stripe: 0.3
|
|
347
|
+
};
|
|
348
|
+
const perCall = typicalCallCosts[serviceId] ?? 1e-3;
|
|
349
|
+
costLow = monthlyInvocations * perCall * (gotcha?.low ?? 1);
|
|
350
|
+
costHigh = monthlyInvocations * perCall * (gotcha?.high ?? 1);
|
|
351
|
+
}
|
|
352
|
+
if (costLow === 0 && costHigh === 0) continue;
|
|
353
|
+
impacts.push({
|
|
354
|
+
serviceId,
|
|
355
|
+
serviceName: service.name,
|
|
356
|
+
filePath,
|
|
357
|
+
callCount: totalCalls,
|
|
358
|
+
multipliers: multipliers.map((m) => m.label),
|
|
359
|
+
multiplierFactor,
|
|
360
|
+
monthlyInvocations,
|
|
361
|
+
costLow,
|
|
362
|
+
costHigh,
|
|
363
|
+
rangeExplanation: gotcha?.explanation
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
return impacts;
|
|
367
|
+
}
|
|
368
|
+
function formatCostImpactCard(impacts, currentBudgets) {
|
|
369
|
+
const fileName = impacts[0]?.filePath.split("/").pop() ?? "unknown";
|
|
370
|
+
const lines = [];
|
|
371
|
+
lines.push(`[BURNWATCH] \u26A0\uFE0F Cost impact estimate for ${fileName}`);
|
|
372
|
+
for (const impact of impacts) {
|
|
373
|
+
const lowStr = impact.costLow < 1 ? `$${impact.costLow.toFixed(2)}` : `$${impact.costLow.toFixed(0)}`;
|
|
374
|
+
const highStr = impact.costHigh < 1 ? `$${impact.costHigh.toFixed(2)}` : `$${impact.costHigh.toFixed(0)}`;
|
|
375
|
+
const rangeStr = impact.costLow === impact.costHigh ? lowStr : `${lowStr}-${highStr}`;
|
|
376
|
+
lines.push(
|
|
377
|
+
` ${impact.serviceName}: ~${impact.monthlyInvocations.toLocaleString()} calls/mo \u2192 ${rangeStr}/mo` + (impact.rangeExplanation ? ` (${impact.rangeExplanation})` : "")
|
|
378
|
+
);
|
|
379
|
+
const current = currentBudgets[impact.serviceId];
|
|
380
|
+
if (current) {
|
|
381
|
+
const budgetStr = current.budget ? `$${current.spend.toFixed(0)}/$${current.budget} budget` : `$${current.spend.toFixed(0)} (no budget set)`;
|
|
382
|
+
const pctStr = current.budget && current.budget > 0 ? ` (${(current.spend / current.budget * 100).toFixed(0)}%)` : "";
|
|
383
|
+
lines.push(` Current: ${budgetStr}${pctStr}`);
|
|
384
|
+
}
|
|
385
|
+
const registry = loadRegistry();
|
|
386
|
+
const service = registry.get(impact.serviceId);
|
|
387
|
+
if (service?.alternatives && service.alternatives.length > 0 && impact.costHigh > 10) {
|
|
388
|
+
const freeAlts = service.alternatives.filter(
|
|
389
|
+
(a) => a.includes("free") || a.includes("cheerio") || a.includes("playwright") || a.includes("self-hosted")
|
|
390
|
+
);
|
|
391
|
+
if (freeAlts.length > 0) {
|
|
392
|
+
lines.push(` Consider: ${freeAlts.join(", ")} for lower-cost alternative`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return lines.join("\n");
|
|
397
|
+
}
|
|
147
398
|
|
|
148
399
|
// src/hooks/on-file-change.ts
|
|
400
|
+
function sessionImpactPath(projectRoot, sessionId) {
|
|
401
|
+
return path5.join(projectDataDir(projectRoot), "cache", `session-impact-${sessionId}.json`);
|
|
402
|
+
}
|
|
403
|
+
function readSessionImpacts(projectRoot, sessionId) {
|
|
404
|
+
try {
|
|
405
|
+
const raw = fs5.readFileSync(sessionImpactPath(projectRoot, sessionId), "utf-8");
|
|
406
|
+
return JSON.parse(raw);
|
|
407
|
+
} catch {
|
|
408
|
+
return {};
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
function writeSessionImpacts(projectRoot, sessionId, impacts) {
|
|
412
|
+
const dir = path5.join(projectDataDir(projectRoot), "cache");
|
|
413
|
+
fs5.mkdirSync(dir, { recursive: true });
|
|
414
|
+
fs5.writeFileSync(
|
|
415
|
+
sessionImpactPath(projectRoot, sessionId),
|
|
416
|
+
JSON.stringify(impacts, null, 2) + "\n",
|
|
417
|
+
"utf-8"
|
|
418
|
+
);
|
|
419
|
+
}
|
|
149
420
|
function main() {
|
|
150
421
|
let input;
|
|
151
422
|
try {
|
|
@@ -172,12 +443,9 @@ function main() {
|
|
|
172
443
|
process.exit(0);
|
|
173
444
|
return;
|
|
174
445
|
}
|
|
175
|
-
const detected = detectInFileChange(filePath, content, projectRoot);
|
|
176
|
-
if (detected.length === 0) {
|
|
177
|
-
process.exit(0);
|
|
178
|
-
return;
|
|
179
|
-
}
|
|
180
446
|
const config = readProjectConfig(projectRoot);
|
|
447
|
+
const contextParts = [];
|
|
448
|
+
const detected = detectInFileChange(filePath, content, projectRoot);
|
|
181
449
|
const newServices = [];
|
|
182
450
|
for (const det of detected) {
|
|
183
451
|
const serviceId = det.service.id;
|
|
@@ -213,10 +481,60 @@ function main() {
|
|
|
213
481
|
(id) => `[BURNWATCH] \u{1F195} New paid service detected: ${id}
|
|
214
482
|
Run 'burnwatch add ${id}' to configure budget and tracking.`
|
|
215
483
|
);
|
|
484
|
+
contextParts.push(alerts.join("\n\n"));
|
|
485
|
+
}
|
|
486
|
+
const impacts = analyzeCostImpact(filePath, content, projectRoot);
|
|
487
|
+
if (impacts.length > 0) {
|
|
488
|
+
const snapshot = readLatestSnapshot(projectRoot);
|
|
489
|
+
const currentBudgets = {};
|
|
490
|
+
if (snapshot) {
|
|
491
|
+
for (const svc of snapshot.services) {
|
|
492
|
+
currentBudgets[svc.serviceId] = {
|
|
493
|
+
spend: svc.spend,
|
|
494
|
+
budget: svc.budget
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
const card = formatCostImpactCard(impacts, currentBudgets);
|
|
499
|
+
contextParts.push(card);
|
|
500
|
+
const sessionImpacts = readSessionImpacts(projectRoot, input.session_id);
|
|
501
|
+
for (const impact of impacts) {
|
|
502
|
+
const existing = sessionImpacts[impact.serviceId];
|
|
503
|
+
if (existing) {
|
|
504
|
+
existing.costLow += impact.costLow;
|
|
505
|
+
existing.costHigh += impact.costHigh;
|
|
506
|
+
} else {
|
|
507
|
+
sessionImpacts[impact.serviceId] = {
|
|
508
|
+
costLow: impact.costLow,
|
|
509
|
+
costHigh: impact.costHigh
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
writeSessionImpacts(projectRoot, input.session_id, sessionImpacts);
|
|
514
|
+
logEvent(
|
|
515
|
+
{
|
|
516
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
517
|
+
sessionId: input.session_id,
|
|
518
|
+
type: "cost_impact",
|
|
519
|
+
data: {
|
|
520
|
+
file: filePath,
|
|
521
|
+
impacts: impacts.map((i) => ({
|
|
522
|
+
serviceId: i.serviceId,
|
|
523
|
+
callCount: i.callCount,
|
|
524
|
+
monthlyInvocations: i.monthlyInvocations,
|
|
525
|
+
costLow: i.costLow,
|
|
526
|
+
costHigh: i.costHigh
|
|
527
|
+
}))
|
|
528
|
+
}
|
|
529
|
+
},
|
|
530
|
+
projectRoot
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
if (contextParts.length > 0) {
|
|
216
534
|
const output = {
|
|
217
535
|
hookSpecificOutput: {
|
|
218
536
|
hookEventName: "PostToolUse",
|
|
219
|
-
additionalContext:
|
|
537
|
+
additionalContext: contextParts.join("\n\n")
|
|
220
538
|
}
|
|
221
539
|
};
|
|
222
540
|
process.stdout.write(JSON.stringify(output));
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/hooks/on-file-change.ts","../../src/core/config.ts","../../src/detection/detector.ts","../../src/core/registry.ts","../../src/core/ledger.ts"],"sourcesContent":["#!/usr/bin/env node\n\n/**\n * PostToolUse hook (Edit|Write) — fires when files are changed.\n *\n * Scans changed files for new service introductions:\n * - New dependencies in package.json\n * - New env vars in .env files\n * - New import statements in source files\n */\n\nimport * as fs from \"node:fs\";\nimport type { HookInput, HookOutput } from \"../core/types.js\";\nimport {\n readProjectConfig,\n writeProjectConfig,\n isInitialized,\n} from \"../core/config.js\";\nimport { detectInFileChange } from \"../detection/detector.js\";\nimport { logEvent } from \"../core/ledger.js\";\nimport type { TrackedService } from \"../core/types.js\";\n\nfunction main(): void {\n // Read hook input from stdin\n let input: HookInput;\n try {\n const stdin = fs.readFileSync(0, \"utf-8\");\n input = JSON.parse(stdin) as HookInput;\n } catch {\n process.exit(0);\n return;\n }\n\n const projectRoot = input.cwd;\n\n // Guard: not initialized\n if (!isInitialized(projectRoot)) {\n process.exit(0);\n return;\n }\n\n // Get file path and content from tool input\n const filePath = input.tool_input?.file_path;\n if (!filePath) {\n process.exit(0);\n return;\n }\n\n // Read the current file content\n let content: string;\n try {\n content = fs.readFileSync(filePath, \"utf-8\");\n } catch {\n process.exit(0);\n return;\n }\n\n // Detect new services in this file change\n const detected = detectInFileChange(filePath, content, projectRoot);\n if (detected.length === 0) {\n process.exit(0);\n return;\n }\n\n const config = readProjectConfig(projectRoot)!;\n const newServices: string[] = [];\n\n for (const det of detected) {\n const serviceId = det.service.id;\n\n // Skip if already tracked\n if (config.services[serviceId]) continue;\n\n // Auto-register as a new tracked service (BLIND until configured)\n const tracked: TrackedService = {\n serviceId,\n detectedVia: det.sources,\n hasApiKey: false,\n firstDetected: new Date().toISOString(),\n };\n\n config.services[serviceId] = tracked;\n newServices.push(serviceId);\n\n // Log the detection\n logEvent(\n {\n timestamp: new Date().toISOString(),\n sessionId: input.session_id,\n type: \"service_detected\",\n data: {\n serviceId,\n sources: det.sources,\n details: det.details,\n file: filePath,\n },\n },\n projectRoot,\n );\n }\n\n // Save updated config\n if (newServices.length > 0) {\n writeProjectConfig(config, projectRoot);\n }\n\n // Alert about new services\n if (newServices.length > 0) {\n const alerts = newServices.map(\n (id) =>\n `[BURNWATCH] 🆕 New paid service detected: ${id}\\n Run 'burnwatch add ${id}' to configure budget and tracking.`,\n );\n\n const output: HookOutput = {\n hookSpecificOutput: {\n hookEventName: \"PostToolUse\",\n additionalContext: alerts.join(\"\\n\\n\"),\n },\n };\n\n process.stdout.write(JSON.stringify(output));\n }\n}\n\nmain();\n","import * as fs from \"node:fs\";\nimport * as path from \"node:path\";\nimport * as os from \"node:os\";\nimport type { TrackedService } from \"./types.js\";\n\n/**\n * Paths for burnwatch configuration and data.\n *\n * Hybrid model:\n * - Global config (API keys, service credentials): ~/.config/burnwatch/\n * - Project config (budgets, tracked services): .burnwatch/\n * - Project data (ledger, events, cache): .burnwatch/data/\n */\n\n/** Global config directory — stores API keys, never in project dirs. */\nexport function globalConfigDir(): string {\n const xdgConfig = process.env[\"XDG_CONFIG_HOME\"];\n if (xdgConfig) return path.join(xdgConfig, \"burnwatch\");\n return path.join(os.homedir(), \".config\", \"burnwatch\");\n}\n\n/** Project config directory — stores budgets, tracked services. */\nexport function projectConfigDir(projectRoot?: string): string {\n const root = projectRoot ?? process.cwd();\n return path.join(root, \".burnwatch\");\n}\n\n/** Project data directory — stores ledger, events, cache. */\nexport function projectDataDir(projectRoot?: string): string {\n return path.join(projectConfigDir(projectRoot), \"data\");\n}\n\n// --- Global config (API keys) ---\n\nexport interface GlobalConfig {\n services: Record<\n string,\n {\n apiKey?: string;\n token?: string;\n orgId?: string;\n }\n >;\n}\n\nexport function readGlobalConfig(): GlobalConfig {\n const configPath = path.join(globalConfigDir(), \"config.json\");\n try {\n const raw = fs.readFileSync(configPath, \"utf-8\");\n return JSON.parse(raw) as GlobalConfig;\n } catch {\n return { services: {} };\n }\n}\n\nexport function writeGlobalConfig(config: GlobalConfig): void {\n const dir = globalConfigDir();\n fs.mkdirSync(dir, { recursive: true });\n const configPath = path.join(dir, \"config.json\");\n fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + \"\\n\", \"utf-8\");\n // Restrict permissions — this file contains API keys\n fs.chmodSync(configPath, 0o600);\n}\n\n// --- Project config (budgets, tracked services) ---\n\nexport interface ProjectConfig {\n projectName: string;\n services: Record<string, TrackedService>;\n createdAt: string;\n updatedAt: string;\n}\n\nexport function readProjectConfig(projectRoot?: string): ProjectConfig | null {\n const configPath = path.join(projectConfigDir(projectRoot), \"config.json\");\n try {\n const raw = fs.readFileSync(configPath, \"utf-8\");\n return JSON.parse(raw) as ProjectConfig;\n } catch {\n return null;\n }\n}\n\nexport function writeProjectConfig(\n config: ProjectConfig,\n projectRoot?: string,\n): void {\n const dir = projectConfigDir(projectRoot);\n fs.mkdirSync(dir, { recursive: true });\n config.updatedAt = new Date().toISOString();\n const configPath = path.join(dir, \"config.json\");\n fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + \"\\n\", \"utf-8\");\n}\n\n/** Ensure all project directories exist. */\nexport function ensureProjectDirs(projectRoot?: string): void {\n const dirs = [\n projectConfigDir(projectRoot),\n projectDataDir(projectRoot),\n path.join(projectDataDir(projectRoot), \"cache\"),\n path.join(projectDataDir(projectRoot), \"snapshots\"),\n ];\n for (const dir of dirs) {\n fs.mkdirSync(dir, { recursive: true });\n }\n}\n\n/** Check if burnwatch is initialized in the given project. */\nexport function isInitialized(projectRoot?: string): boolean {\n return readProjectConfig(projectRoot) !== null;\n}\n","import * as fs from \"node:fs\";\nimport * as path from \"node:path\";\nimport { loadRegistry } from \"../core/registry.js\";\nimport type { ServiceDefinition, DetectionSource } from \"../core/types.js\";\n\nexport interface DetectionResult {\n service: ServiceDefinition;\n sources: DetectionSource[];\n details: string[];\n}\n\n/**\n * Run all detection surfaces against the current project.\n * Returns services detected via any combination of:\n * - package.json dependencies (recursive — finds monorepo subdirectories)\n * - environment variable patterns (process.env + .env* files recursive)\n * - import statement scanning (recursive from project root)\n * - (prompt mention scanning is handled separately in hooks)\n */\nexport function detectServices(projectRoot: string): DetectionResult[] {\n const registry = loadRegistry(projectRoot);\n const results = new Map<string, DetectionResult>();\n\n // Surface 1: Package manifest scanning (recursive — finds all package.json files)\n const pkgDeps = scanAllPackageJsons(projectRoot);\n for (const [serviceId, service] of registry) {\n const matchedPkgs = service.packageNames.filter((pkg) =>\n pkgDeps.has(pkg),\n );\n if (matchedPkgs.length > 0) {\n getOrCreate(results, serviceId, service).sources.push(\"package_json\");\n getOrCreate(results, serviceId, service).details.push(\n `package.json: ${matchedPkgs.join(\", \")}`,\n );\n }\n }\n\n // Surface 2: Environment variable pattern matching\n // Check both process.env AND .env* files in the project tree\n const envVars = collectEnvVars(projectRoot);\n for (const [serviceId, service] of registry) {\n const matchedEnvs = service.envPatterns.filter((pattern) =>\n envVars.has(pattern),\n );\n if (matchedEnvs.length > 0) {\n getOrCreate(results, serviceId, service).sources.push(\"env_var\");\n getOrCreate(results, serviceId, service).details.push(\n `env vars: ${matchedEnvs.join(\", \")}`,\n );\n }\n }\n\n // Surface 3: Import statement analysis (recursive from project root)\n const importHits = scanImports(projectRoot);\n for (const [serviceId, service] of registry) {\n const matchedImports = service.importPatterns.filter((pattern) =>\n importHits.has(pattern),\n );\n if (matchedImports.length > 0) {\n if (\n !getOrCreate(results, serviceId, service).sources.includes(\n \"import_scan\",\n )\n ) {\n getOrCreate(results, serviceId, service).sources.push(\"import_scan\");\n getOrCreate(results, serviceId, service).details.push(\n `imports: ${matchedImports.join(\", \")}`,\n );\n }\n }\n }\n\n return Array.from(results.values());\n}\n\n/**\n * Detect services mentioned in a prompt string.\n * Used by the UserPromptSubmit hook.\n */\nexport function detectMentions(\n prompt: string,\n projectRoot?: string,\n): DetectionResult[] {\n const registry = loadRegistry(projectRoot);\n const results: DetectionResult[] = [];\n const promptLower = prompt.toLowerCase();\n\n for (const [, service] of registry) {\n const matched = service.mentionKeywords.some((keyword) =>\n promptLower.includes(keyword.toLowerCase()),\n );\n if (matched) {\n results.push({\n service,\n sources: [\"prompt_mention\"],\n details: [`mentioned in prompt`],\n });\n }\n }\n\n return results;\n}\n\n/**\n * Detect new services introduced in a file change.\n * Used by the PostToolUse hook for Write/Edit events.\n */\nexport function detectInFileChange(\n filePath: string,\n content: string,\n projectRoot?: string,\n): DetectionResult[] {\n const registry = loadRegistry(projectRoot);\n const results: DetectionResult[] = [];\n const fileName = path.basename(filePath);\n\n // Check if it's a package.json change\n if (fileName === \"package.json\") {\n try {\n const pkg = JSON.parse(content) as {\n dependencies?: Record<string, string>;\n devDependencies?: Record<string, string>;\n };\n const allDeps = new Set([\n ...Object.keys(pkg.dependencies ?? {}),\n ...Object.keys(pkg.devDependencies ?? {}),\n ]);\n\n for (const [, service] of registry) {\n const matched = service.packageNames.filter((p) => allDeps.has(p));\n if (matched.length > 0) {\n results.push({\n service,\n sources: [\"package_json\"],\n details: [`new dependency: ${matched.join(\", \")}`],\n });\n }\n }\n } catch {\n // Not valid JSON, skip\n }\n return results;\n }\n\n // Check if it's an env file change\n if (fileName.startsWith(\".env\")) {\n const envKeys = content\n .split(\"\\n\")\n .filter((line) => line.includes(\"=\") && !line.startsWith(\"#\"))\n .map((line) => line.split(\"=\")[0]!.trim());\n\n for (const [, service] of registry) {\n const matched = service.envPatterns.filter((p) => envKeys.includes(p));\n if (matched.length > 0) {\n results.push({\n service,\n sources: [\"env_var\"],\n details: [`new env var: ${matched.join(\", \")}`],\n });\n }\n }\n return results;\n }\n\n // Check for import statements in source files\n if (/\\.(ts|tsx|js|jsx|mjs|cjs)$/.test(filePath)) {\n for (const [, service] of registry) {\n const matched = service.importPatterns.filter(\n (pattern) =>\n content.includes(`from \"${pattern}`) ||\n content.includes(`from '${pattern}`) ||\n content.includes(`require(\"${pattern}`) ||\n content.includes(`require('${pattern}`),\n );\n if (matched.length > 0) {\n results.push({\n service,\n sources: [\"import_scan\"],\n details: [`import added: ${matched.join(\", \")}`],\n });\n }\n }\n }\n\n return results;\n}\n\n// --- Helpers ---\n\nfunction getOrCreate(\n map: Map<string, DetectionResult>,\n serviceId: string,\n service: ServiceDefinition,\n): DetectionResult {\n let result = map.get(serviceId);\n if (!result) {\n result = { service, sources: [], details: [] };\n map.set(serviceId, result);\n }\n return result;\n}\n\n/**\n * Recursively find and scan ALL package.json files in the project tree.\n * Handles monorepos where dependencies live in subdirectories.\n */\nfunction scanAllPackageJsons(projectRoot: string): Set<string> {\n const deps = new Set<string>();\n const pkgFiles = findFiles(projectRoot, \"package.json\", 4);\n\n for (const pkgPath of pkgFiles) {\n try {\n const raw = fs.readFileSync(pkgPath, \"utf-8\");\n const pkg = JSON.parse(raw) as {\n dependencies?: Record<string, string>;\n devDependencies?: Record<string, string>;\n };\n for (const name of Object.keys(pkg.dependencies ?? {})) deps.add(name);\n for (const name of Object.keys(pkg.devDependencies ?? {})) deps.add(name);\n } catch {\n // Skip malformed package.json\n }\n }\n\n return deps;\n}\n\n/**\n * Collect environment variable names from both process.env\n * and all .env* files found recursively in the project tree.\n */\nfunction collectEnvVars(projectRoot: string): Set<string> {\n const envVars = new Set(Object.keys(process.env));\n\n // Find all .env* files in the project tree\n const envFiles = findEnvFiles(projectRoot, 3);\n\n for (const envFile of envFiles) {\n try {\n const content = fs.readFileSync(envFile, \"utf-8\");\n const keys = content\n .split(\"\\n\")\n .filter((line) => line.includes(\"=\") && !line.startsWith(\"#\"))\n .map((line) => line.split(\"=\")[0]!.trim())\n .filter(Boolean);\n\n for (const key of keys) {\n envVars.add(key);\n }\n } catch {\n // Skip unreadable files\n }\n }\n\n return envVars;\n}\n\n/**\n * Find all .env* files recursively (but not in node_modules, .git, dist, etc.)\n */\nfunction findEnvFiles(dir: string, maxDepth: number): string[] {\n const results: string[] = [];\n if (maxDepth <= 0) return results;\n\n try {\n const entries = fs.readdirSync(dir, { withFileTypes: true });\n for (const entry of entries) {\n if (entry.name === \"node_modules\" || entry.name === \".git\" || entry.name === \"dist\") continue;\n const fullPath = path.join(dir, entry.name);\n if (entry.isDirectory()) {\n results.push(...findEnvFiles(fullPath, maxDepth - 1));\n } else if (entry.name.startsWith(\".env\")) {\n results.push(fullPath);\n }\n }\n } catch {\n // Skip unreadable directories\n }\n\n return results;\n}\n\n/**\n * Find files with a specific name recursively.\n * Used to find package.json files across monorepo subdirectories.\n */\nfunction findFiles(dir: string, fileName: string, maxDepth: number): string[] {\n const results: string[] = [];\n if (maxDepth <= 0) return results;\n\n try {\n const entries = fs.readdirSync(dir, { withFileTypes: true });\n for (const entry of entries) {\n if (entry.name === \"node_modules\" || entry.name === \".git\" || entry.name === \"dist\") continue;\n const fullPath = path.join(dir, entry.name);\n if (entry.isDirectory()) {\n results.push(...findFiles(fullPath, fileName, maxDepth - 1));\n } else if (entry.name === fileName) {\n results.push(fullPath);\n }\n }\n } catch {\n // Skip unreadable directories\n }\n\n return results;\n}\n\n/**\n * Lightweight import scanning.\n * Recursively scans the project for import/require statements.\n * Looks in src/, app/, lib/, pages/, and any other code directories.\n * Does NOT do a full AST parse — just string matching.\n */\nfunction scanImports(projectRoot: string): Set<string> {\n const imports = new Set<string>();\n\n // Scan common code directories + the root itself for source files\n const codeDirs = [\"src\", \"app\", \"lib\", \"pages\", \"components\", \"utils\", \"services\", \"hooks\"];\n const dirsToScan: string[] = [];\n\n for (const dir of codeDirs) {\n const fullPath = path.join(projectRoot, dir);\n if (fs.existsSync(fullPath)) {\n dirsToScan.push(fullPath);\n }\n }\n\n // Also check subdirectories (monorepo support)\n try {\n const entries = fs.readdirSync(projectRoot, { withFileTypes: true });\n for (const entry of entries) {\n if (!entry.isDirectory()) continue;\n if (entry.name === \"node_modules\" || entry.name === \".git\" || entry.name === \"dist\" || entry.name.startsWith(\".\")) continue;\n\n // Check if this subdirectory has its own package.json (monorepo package)\n const subPkgPath = path.join(projectRoot, entry.name, \"package.json\");\n if (fs.existsSync(subPkgPath)) {\n // Scan this subpackage's code directories\n for (const dir of codeDirs) {\n const fullPath = path.join(projectRoot, entry.name, dir);\n if (fs.existsSync(fullPath)) {\n dirsToScan.push(fullPath);\n }\n }\n }\n }\n } catch {\n // Skip if root is unreadable\n }\n\n for (const dir of dirsToScan) {\n const files = walkDir(dir, /\\.(ts|tsx|js|jsx|mjs|cjs)$/);\n for (const file of files) {\n try {\n const content = fs.readFileSync(file, \"utf-8\");\n // Match: import ... from \"package\" or require(\"package\")\n const importRegex =\n /(?:from\\s+[\"']|require\\s*\\(\\s*[\"'])([^./][^\"']*?)(?:[\"'])/g;\n let match: RegExpExecArray | null;\n while ((match = importRegex.exec(content)) !== null) {\n const pkg = match[1];\n if (pkg) {\n // Normalize scoped packages: @scope/pkg/subpath -> @scope/pkg\n const parts = pkg.split(\"/\");\n if (parts[0]?.startsWith(\"@\") && parts.length >= 2) {\n imports.add(`${parts[0]}/${parts[1]}`);\n } else if (parts[0]) {\n imports.add(parts[0]);\n }\n }\n }\n } catch {\n // Skip unreadable files\n }\n }\n }\n\n return imports;\n}\n\n/** Recursively walk a directory, returning files matching the pattern. */\nfunction walkDir(dir: string, pattern: RegExp, maxDepth = 5): string[] {\n const results: string[] = [];\n if (maxDepth <= 0) return results;\n\n try {\n const entries = fs.readdirSync(dir, { withFileTypes: true });\n for (const entry of entries) {\n if (entry.name.startsWith(\".\") || entry.name === \"node_modules\") continue;\n const fullPath = path.join(dir, entry.name);\n if (entry.isDirectory()) {\n results.push(...walkDir(fullPath, pattern, maxDepth - 1));\n } else if (pattern.test(entry.name)) {\n results.push(fullPath);\n }\n }\n } catch {\n // Skip unreadable directories\n }\n\n return results;\n}\n","import * as fs from \"node:fs\";\nimport * as path from \"node:path\";\nimport * as url from \"node:url\";\nimport type { ServiceDefinition } from \"./types.js\";\n\nconst __dirname = path.dirname(url.fileURLToPath(import.meta.url));\n\ninterface RegistryFile {\n version: string;\n lastUpdated: string;\n services: Record<string, ServiceDefinition>;\n}\n\nlet cachedRegistry: Map<string, ServiceDefinition> | null = null;\n\n/**\n * Load the service registry.\n * Checks project-local override first, then falls back to bundled registry.\n */\nexport function loadRegistry(projectRoot?: string): Map<string, ServiceDefinition> {\n if (cachedRegistry) return cachedRegistry;\n\n const registry = new Map<string, ServiceDefinition>();\n\n // Load bundled registry (shipped with package)\n // Try multiple possible locations — depends on whether running from src/ or dist/\n const candidates = [\n path.resolve(__dirname, \"../../registry.json\"), // from src/core/\n path.resolve(__dirname, \"../registry.json\"), // from dist/\n ];\n for (const candidate of candidates) {\n if (fs.existsSync(candidate)) {\n loadRegistryFile(candidate, registry);\n break;\n }\n }\n\n // Load project-local override (if exists)\n if (projectRoot) {\n const localPath = path.join(projectRoot, \".burnwatch\", \"registry.json\");\n if (fs.existsSync(localPath)) {\n loadRegistryFile(localPath, registry);\n }\n }\n\n cachedRegistry = registry;\n return registry;\n}\n\nfunction loadRegistryFile(\n filePath: string,\n registry: Map<string, ServiceDefinition>,\n): void {\n try {\n const raw = fs.readFileSync(filePath, \"utf-8\");\n const data = JSON.parse(raw) as RegistryFile;\n for (const [id, service] of Object.entries(data.services)) {\n registry.set(id, { ...service, id });\n }\n } catch {\n // Silently skip missing or malformed registry files\n }\n}\n\n/** Clear the cached registry (for testing). */\nexport function clearRegistryCache(): void {\n cachedRegistry = null;\n}\n\n/** Get a single service definition by ID. */\nexport function getService(\n id: string,\n projectRoot?: string,\n): ServiceDefinition | undefined {\n return loadRegistry(projectRoot).get(id);\n}\n\n/** Get all service definitions. */\nexport function getAllServices(\n projectRoot?: string,\n): ServiceDefinition[] {\n return Array.from(loadRegistry(projectRoot).values());\n}\n","import * as fs from \"node:fs\";\nimport * as path from \"node:path\";\nimport type { SpendBrief, SpendEvent } from \"./types.js\";\nimport { CONFIDENCE_BADGES } from \"./types.js\";\nimport { projectConfigDir, projectDataDir } from \"./config.js\";\n\n/**\n * Write the spend ledger as a human-readable markdown file.\n * Designed to be git-committable and readable in 10 seconds.\n */\nexport function writeLedger(brief: SpendBrief, projectRoot?: string): void {\n const now = new Date();\n const lines: string[] = [];\n\n lines.push(`# Burnwatch Ledger — ${brief.projectName}`);\n lines.push(`Last updated: ${now.toISOString()}`);\n lines.push(\"\");\n lines.push(`## This Month (${brief.period})`);\n lines.push(\"\");\n lines.push(\"| Service | Spend | Conf | Budget | Status |\");\n lines.push(\"|---------|-------|------|--------|--------|\");\n\n for (const svc of brief.services) {\n const spendStr = svc.isEstimate\n ? `~$${svc.spend.toFixed(2)}`\n : `$${svc.spend.toFixed(2)}`;\n const badge = CONFIDENCE_BADGES[svc.tier];\n const budgetStr = svc.budget ? `$${svc.budget}` : \"—\";\n\n lines.push(\n `| ${svc.serviceId} | ${spendStr} | ${badge} | ${budgetStr} | ${svc.statusLabel} |`,\n );\n }\n\n lines.push(\"\");\n const totalStr = brief.totalIsEstimate\n ? `~$${brief.totalSpend.toFixed(2)}`\n : `$${brief.totalSpend.toFixed(2)}`;\n const marginStr =\n brief.estimateMargin > 0\n ? ` (±$${brief.estimateMargin.toFixed(0)} estimated margin)`\n : \"\";\n lines.push(`## TOTAL: ${totalStr}${marginStr}`);\n lines.push(`## Untracked services: ${brief.untrackedCount}`);\n lines.push(\"\");\n\n if (brief.alerts.length > 0) {\n lines.push(\"## Alerts\");\n for (const alert of brief.alerts) {\n const icon = alert.severity === \"critical\" ? \"🚨\" : \"⚠️\";\n lines.push(`- ${icon} ${alert.message}`);\n }\n lines.push(\"\");\n }\n\n const ledgerPath = path.join(\n projectConfigDir(projectRoot),\n \"spend-ledger.md\",\n );\n fs.mkdirSync(path.dirname(ledgerPath), { recursive: true });\n fs.writeFileSync(ledgerPath, lines.join(\"\\n\") + \"\\n\", \"utf-8\");\n}\n\n/**\n * Append an event to the append-only event log.\n */\nexport function logEvent(event: SpendEvent, projectRoot?: string): void {\n const logPath = path.join(projectDataDir(projectRoot), \"events.jsonl\");\n fs.mkdirSync(path.dirname(logPath), { recursive: true });\n fs.appendFileSync(logPath, JSON.stringify(event) + \"\\n\", \"utf-8\");\n}\n\n/**\n * Read recent events from the event log.\n */\nexport function readRecentEvents(\n count: number,\n projectRoot?: string,\n): SpendEvent[] {\n const logPath = path.join(projectDataDir(projectRoot), \"events.jsonl\");\n try {\n const raw = fs.readFileSync(logPath, \"utf-8\");\n const lines = raw.trim().split(\"\\n\").filter(Boolean);\n return lines\n .slice(-count)\n .map((line) => JSON.parse(line) as SpendEvent);\n } catch {\n return [];\n }\n}\n\n/**\n * Save a spend snapshot to the snapshots directory.\n * Used for delta computation across sessions.\n */\nexport function saveSnapshot(brief: SpendBrief, projectRoot?: string): void {\n const snapshotDir = path.join(projectDataDir(projectRoot), \"snapshots\");\n fs.mkdirSync(snapshotDir, { recursive: true });\n const filename = `snapshot-${new Date().toISOString().replace(/[:.]/g, \"-\")}.json`;\n fs.writeFileSync(\n path.join(snapshotDir, filename),\n JSON.stringify(brief, null, 2) + \"\\n\",\n \"utf-8\",\n );\n}\n\n/**\n * Read the most recent snapshot, if any.\n */\nexport function readLatestSnapshot(\n projectRoot?: string,\n): SpendBrief | null {\n const snapshotDir = path.join(projectDataDir(projectRoot), \"snapshots\");\n try {\n const files = fs\n .readdirSync(snapshotDir)\n .filter((f) => f.startsWith(\"snapshot-\") && f.endsWith(\".json\"))\n .sort()\n .reverse();\n\n if (files.length === 0) return null;\n\n const raw = fs.readFileSync(\n path.join(snapshotDir, files[0]!),\n \"utf-8\",\n );\n return JSON.parse(raw) as SpendBrief;\n } catch {\n return null;\n }\n}\n"],"mappings":"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","names":["fs","fs","path","fs","path","fs","path"]}
|
|
1
|
+
{"version":3,"sources":["../../src/hooks/on-file-change.ts","../../src/core/config.ts","../../src/detection/detector.ts","../../src/core/registry.ts","../../src/core/ledger.ts","../../src/cost-impact.ts"],"sourcesContent":["#!/usr/bin/env node\n\n/**\n * PostToolUse hook (Edit|Write) — fires when files are changed.\n *\n * 1. Scans changed files for new service introductions\n * 2. Analyzes cost impact of SDK calls (invocation sites, multipliers, projected cost)\n * 3. Injects cost impact cards into Claude's context\n */\n\nimport * as fs from \"node:fs\";\nimport * as path from \"node:path\";\nimport type { HookInput, HookOutput } from \"../core/types.js\";\nimport {\n readProjectConfig,\n writeProjectConfig,\n isInitialized,\n projectDataDir,\n} from \"../core/config.js\";\nimport { detectInFileChange } from \"../detection/detector.js\";\nimport { logEvent } from \"../core/ledger.js\";\nimport { readLatestSnapshot } from \"../core/ledger.js\";\nimport type { TrackedService } from \"../core/types.js\";\nimport { analyzeCostImpact, formatCostImpactCard } from \"../cost-impact.js\";\n\n/** Session cost impact accumulator file path */\nfunction sessionImpactPath(projectRoot: string, sessionId: string): string {\n return path.join(projectDataDir(projectRoot), \"cache\", `session-impact-${sessionId}.json`);\n}\n\n/** Read accumulated session cost impacts */\nfunction readSessionImpacts(\n projectRoot: string,\n sessionId: string,\n): Record<string, { costLow: number; costHigh: number }> {\n try {\n const raw = fs.readFileSync(sessionImpactPath(projectRoot, sessionId), \"utf-8\");\n return JSON.parse(raw) as Record<string, { costLow: number; costHigh: number }>;\n } catch {\n return {};\n }\n}\n\n/** Write accumulated session cost impacts */\nfunction writeSessionImpacts(\n projectRoot: string,\n sessionId: string,\n impacts: Record<string, { costLow: number; costHigh: number }>,\n): void {\n const dir = path.join(projectDataDir(projectRoot), \"cache\");\n fs.mkdirSync(dir, { recursive: true });\n fs.writeFileSync(\n sessionImpactPath(projectRoot, sessionId),\n JSON.stringify(impacts, null, 2) + \"\\n\",\n \"utf-8\",\n );\n}\n\nfunction main(): void {\n // Read hook input from stdin\n let input: HookInput;\n try {\n const stdin = fs.readFileSync(0, \"utf-8\");\n input = JSON.parse(stdin) as HookInput;\n } catch {\n process.exit(0);\n return;\n }\n\n const projectRoot = input.cwd;\n\n // Guard: not initialized\n if (!isInitialized(projectRoot)) {\n process.exit(0);\n return;\n }\n\n // Get file path and content from tool input\n const filePath = input.tool_input?.file_path;\n if (!filePath) {\n process.exit(0);\n return;\n }\n\n // Read the current file content\n let content: string;\n try {\n content = fs.readFileSync(filePath, \"utf-8\");\n } catch {\n process.exit(0);\n return;\n }\n\n const config = readProjectConfig(projectRoot)!;\n const contextParts: string[] = [];\n\n // --- Part 1: Detect new services in this file change ---\n const detected = detectInFileChange(filePath, content, projectRoot);\n const newServices: string[] = [];\n\n for (const det of detected) {\n const serviceId = det.service.id;\n\n // Skip if already tracked\n if (config.services[serviceId]) continue;\n\n // Auto-register as a new tracked service (BLIND until configured)\n const tracked: TrackedService = {\n serviceId,\n detectedVia: det.sources,\n hasApiKey: false,\n firstDetected: new Date().toISOString(),\n };\n\n config.services[serviceId] = tracked;\n newServices.push(serviceId);\n\n // Log the detection\n logEvent(\n {\n timestamp: new Date().toISOString(),\n sessionId: input.session_id,\n type: \"service_detected\",\n data: {\n serviceId,\n sources: det.sources,\n details: det.details,\n file: filePath,\n },\n },\n projectRoot,\n );\n }\n\n // Save updated config\n if (newServices.length > 0) {\n writeProjectConfig(config, projectRoot);\n }\n\n // Alert about new services\n if (newServices.length > 0) {\n const alerts = newServices.map(\n (id) =>\n `[BURNWATCH] 🆕 New paid service detected: ${id}\\n Run 'burnwatch add ${id}' to configure budget and tracking.`,\n );\n contextParts.push(alerts.join(\"\\n\\n\"));\n }\n\n // --- Part 2: Cost impact analysis ---\n const impacts = analyzeCostImpact(filePath, content, projectRoot);\n\n if (impacts.length > 0) {\n // Build current budget status from latest snapshot\n const snapshot = readLatestSnapshot(projectRoot);\n const currentBudgets: Record<string, { spend: number; budget?: number }> = {};\n\n if (snapshot) {\n for (const svc of snapshot.services) {\n currentBudgets[svc.serviceId] = {\n spend: svc.spend,\n budget: svc.budget,\n };\n }\n }\n\n // Format and add cost impact card\n const card = formatCostImpactCard(impacts, currentBudgets);\n contextParts.push(card);\n\n // Accumulate session cost impacts\n const sessionImpacts = readSessionImpacts(projectRoot, input.session_id);\n for (const impact of impacts) {\n const existing = sessionImpacts[impact.serviceId];\n if (existing) {\n existing.costLow += impact.costLow;\n existing.costHigh += impact.costHigh;\n } else {\n sessionImpacts[impact.serviceId] = {\n costLow: impact.costLow,\n costHigh: impact.costHigh,\n };\n }\n }\n writeSessionImpacts(projectRoot, input.session_id, sessionImpacts);\n\n // Log cost impact event\n logEvent(\n {\n timestamp: new Date().toISOString(),\n sessionId: input.session_id,\n type: \"cost_impact\",\n data: {\n file: filePath,\n impacts: impacts.map((i) => ({\n serviceId: i.serviceId,\n callCount: i.callCount,\n monthlyInvocations: i.monthlyInvocations,\n costLow: i.costLow,\n costHigh: i.costHigh,\n })),\n },\n },\n projectRoot,\n );\n }\n\n // --- Output ---\n if (contextParts.length > 0) {\n const output: HookOutput = {\n hookSpecificOutput: {\n hookEventName: \"PostToolUse\",\n additionalContext: contextParts.join(\"\\n\\n\"),\n },\n };\n\n process.stdout.write(JSON.stringify(output));\n }\n}\n\nmain();\n","import * as fs from \"node:fs\";\nimport * as path from \"node:path\";\nimport * as os from \"node:os\";\nimport type { TrackedService } from \"./types.js\";\n\n/**\n * Paths for burnwatch configuration and data.\n *\n * Hybrid model:\n * - Global config (API keys, service credentials): ~/.config/burnwatch/\n * - Project config (budgets, tracked services): .burnwatch/\n * - Project data (ledger, events, cache): .burnwatch/data/\n */\n\n/** Global config directory — stores API keys, never in project dirs. */\nexport function globalConfigDir(): string {\n const xdgConfig = process.env[\"XDG_CONFIG_HOME\"];\n if (xdgConfig) return path.join(xdgConfig, \"burnwatch\");\n return path.join(os.homedir(), \".config\", \"burnwatch\");\n}\n\n/** Project config directory — stores budgets, tracked services. */\nexport function projectConfigDir(projectRoot?: string): string {\n const root = projectRoot ?? process.cwd();\n return path.join(root, \".burnwatch\");\n}\n\n/** Project data directory — stores ledger, events, cache. */\nexport function projectDataDir(projectRoot?: string): string {\n return path.join(projectConfigDir(projectRoot), \"data\");\n}\n\n// --- Global config (API keys) ---\n\nexport interface GlobalConfig {\n services: Record<\n string,\n {\n apiKey?: string;\n token?: string;\n orgId?: string;\n }\n >;\n}\n\nexport function readGlobalConfig(): GlobalConfig {\n const configPath = path.join(globalConfigDir(), \"config.json\");\n try {\n const raw = fs.readFileSync(configPath, \"utf-8\");\n return JSON.parse(raw) as GlobalConfig;\n } catch {\n return { services: {} };\n }\n}\n\nexport function writeGlobalConfig(config: GlobalConfig): void {\n const dir = globalConfigDir();\n fs.mkdirSync(dir, { recursive: true });\n const configPath = path.join(dir, \"config.json\");\n fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + \"\\n\", \"utf-8\");\n // Restrict permissions — this file contains API keys\n fs.chmodSync(configPath, 0o600);\n}\n\n// --- Project config (budgets, tracked services) ---\n\nexport interface ProjectConfig {\n projectName: string;\n services: Record<string, TrackedService>;\n createdAt: string;\n updatedAt: string;\n}\n\nexport function readProjectConfig(projectRoot?: string): ProjectConfig | null {\n const configPath = path.join(projectConfigDir(projectRoot), \"config.json\");\n try {\n const raw = fs.readFileSync(configPath, \"utf-8\");\n return JSON.parse(raw) as ProjectConfig;\n } catch {\n return null;\n }\n}\n\nexport function writeProjectConfig(\n config: ProjectConfig,\n projectRoot?: string,\n): void {\n const dir = projectConfigDir(projectRoot);\n fs.mkdirSync(dir, { recursive: true });\n config.updatedAt = new Date().toISOString();\n const configPath = path.join(dir, \"config.json\");\n fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + \"\\n\", \"utf-8\");\n}\n\n/** Ensure all project directories exist. */\nexport function ensureProjectDirs(projectRoot?: string): void {\n const dirs = [\n projectConfigDir(projectRoot),\n projectDataDir(projectRoot),\n path.join(projectDataDir(projectRoot), \"cache\"),\n path.join(projectDataDir(projectRoot), \"snapshots\"),\n ];\n for (const dir of dirs) {\n fs.mkdirSync(dir, { recursive: true });\n }\n}\n\n/** Check if burnwatch is initialized in the given project. */\nexport function isInitialized(projectRoot?: string): boolean {\n return readProjectConfig(projectRoot) !== null;\n}\n","import * as fs from \"node:fs\";\nimport * as path from \"node:path\";\nimport { loadRegistry } from \"../core/registry.js\";\nimport type { ServiceDefinition, DetectionSource } from \"../core/types.js\";\n\nexport interface DetectionResult {\n service: ServiceDefinition;\n sources: DetectionSource[];\n details: string[];\n}\n\n/**\n * Run all detection surfaces against the current project.\n * Returns services detected via any combination of:\n * - package.json dependencies (recursive — finds monorepo subdirectories)\n * - environment variable patterns (process.env + .env* files recursive)\n * - import statement scanning (recursive from project root)\n * - (prompt mention scanning is handled separately in hooks)\n */\nexport function detectServices(projectRoot: string): DetectionResult[] {\n const registry = loadRegistry(projectRoot);\n const results = new Map<string, DetectionResult>();\n\n // Surface 1: Package manifest scanning (recursive — finds all package.json files)\n const pkgDeps = scanAllPackageJsons(projectRoot);\n for (const [serviceId, service] of registry) {\n const matchedPkgs = service.packageNames.filter((pkg) =>\n pkgDeps.has(pkg),\n );\n if (matchedPkgs.length > 0) {\n getOrCreate(results, serviceId, service).sources.push(\"package_json\");\n getOrCreate(results, serviceId, service).details.push(\n `package.json: ${matchedPkgs.join(\", \")}`,\n );\n }\n }\n\n // Surface 2: Environment variable pattern matching\n // Check both process.env AND .env* files in the project tree\n const envVars = collectEnvVars(projectRoot);\n for (const [serviceId, service] of registry) {\n const matchedEnvs = service.envPatterns.filter((pattern) =>\n envVars.has(pattern),\n );\n if (matchedEnvs.length > 0) {\n getOrCreate(results, serviceId, service).sources.push(\"env_var\");\n getOrCreate(results, serviceId, service).details.push(\n `env vars: ${matchedEnvs.join(\", \")}`,\n );\n }\n }\n\n // Surface 3: Import statement analysis (recursive from project root)\n const importHits = scanImports(projectRoot);\n for (const [serviceId, service] of registry) {\n const matchedImports = service.importPatterns.filter((pattern) =>\n importHits.has(pattern),\n );\n if (matchedImports.length > 0) {\n if (\n !getOrCreate(results, serviceId, service).sources.includes(\n \"import_scan\",\n )\n ) {\n getOrCreate(results, serviceId, service).sources.push(\"import_scan\");\n getOrCreate(results, serviceId, service).details.push(\n `imports: ${matchedImports.join(\", \")}`,\n );\n }\n }\n }\n\n return Array.from(results.values());\n}\n\n/**\n * Detect services mentioned in a prompt string.\n * Used by the UserPromptSubmit hook.\n */\nexport function detectMentions(\n prompt: string,\n projectRoot?: string,\n): DetectionResult[] {\n const registry = loadRegistry(projectRoot);\n const results: DetectionResult[] = [];\n const promptLower = prompt.toLowerCase();\n\n for (const [, service] of registry) {\n const matched = service.mentionKeywords.some((keyword) =>\n promptLower.includes(keyword.toLowerCase()),\n );\n if (matched) {\n results.push({\n service,\n sources: [\"prompt_mention\"],\n details: [`mentioned in prompt`],\n });\n }\n }\n\n return results;\n}\n\n/**\n * Detect new services introduced in a file change.\n * Used by the PostToolUse hook for Write/Edit events.\n */\nexport function detectInFileChange(\n filePath: string,\n content: string,\n projectRoot?: string,\n): DetectionResult[] {\n const registry = loadRegistry(projectRoot);\n const results: DetectionResult[] = [];\n const fileName = path.basename(filePath);\n\n // Check if it's a package.json change\n if (fileName === \"package.json\") {\n try {\n const pkg = JSON.parse(content) as {\n dependencies?: Record<string, string>;\n devDependencies?: Record<string, string>;\n };\n const allDeps = new Set([\n ...Object.keys(pkg.dependencies ?? {}),\n ...Object.keys(pkg.devDependencies ?? {}),\n ]);\n\n for (const [, service] of registry) {\n const matched = service.packageNames.filter((p) => allDeps.has(p));\n if (matched.length > 0) {\n results.push({\n service,\n sources: [\"package_json\"],\n details: [`new dependency: ${matched.join(\", \")}`],\n });\n }\n }\n } catch {\n // Not valid JSON, skip\n }\n return results;\n }\n\n // Check if it's an env file change\n if (fileName.startsWith(\".env\")) {\n const envKeys = content\n .split(\"\\n\")\n .filter((line) => line.includes(\"=\") && !line.startsWith(\"#\"))\n .map((line) => line.split(\"=\")[0]!.trim());\n\n for (const [, service] of registry) {\n const matched = service.envPatterns.filter((p) => envKeys.includes(p));\n if (matched.length > 0) {\n results.push({\n service,\n sources: [\"env_var\"],\n details: [`new env var: ${matched.join(\", \")}`],\n });\n }\n }\n return results;\n }\n\n // Check for import statements in source files\n if (/\\.(ts|tsx|js|jsx|mjs|cjs)$/.test(filePath)) {\n for (const [, service] of registry) {\n const matched = service.importPatterns.filter(\n (pattern) =>\n content.includes(`from \"${pattern}`) ||\n content.includes(`from '${pattern}`) ||\n content.includes(`require(\"${pattern}`) ||\n content.includes(`require('${pattern}`),\n );\n if (matched.length > 0) {\n results.push({\n service,\n sources: [\"import_scan\"],\n details: [`import added: ${matched.join(\", \")}`],\n });\n }\n }\n }\n\n return results;\n}\n\n// --- Helpers ---\n\nfunction getOrCreate(\n map: Map<string, DetectionResult>,\n serviceId: string,\n service: ServiceDefinition,\n): DetectionResult {\n let result = map.get(serviceId);\n if (!result) {\n result = { service, sources: [], details: [] };\n map.set(serviceId, result);\n }\n return result;\n}\n\n/**\n * Recursively find and scan ALL package.json files in the project tree.\n * Handles monorepos where dependencies live in subdirectories.\n */\nfunction scanAllPackageJsons(projectRoot: string): Set<string> {\n const deps = new Set<string>();\n const pkgFiles = findFiles(projectRoot, \"package.json\", 4);\n\n for (const pkgPath of pkgFiles) {\n try {\n const raw = fs.readFileSync(pkgPath, \"utf-8\");\n const pkg = JSON.parse(raw) as {\n dependencies?: Record<string, string>;\n devDependencies?: Record<string, string>;\n };\n for (const name of Object.keys(pkg.dependencies ?? {})) deps.add(name);\n for (const name of Object.keys(pkg.devDependencies ?? {})) deps.add(name);\n } catch {\n // Skip malformed package.json\n }\n }\n\n return deps;\n}\n\n/**\n * Collect environment variable names from both process.env\n * and all .env* files found recursively in the project tree.\n */\nfunction collectEnvVars(projectRoot: string): Set<string> {\n const envVars = new Set(Object.keys(process.env));\n\n // Find all .env* files in the project tree\n const envFiles = findEnvFiles(projectRoot, 3);\n\n for (const envFile of envFiles) {\n try {\n const content = fs.readFileSync(envFile, \"utf-8\");\n const keys = content\n .split(\"\\n\")\n .filter((line) => line.includes(\"=\") && !line.startsWith(\"#\"))\n .map((line) => line.split(\"=\")[0]!.trim())\n .filter(Boolean);\n\n for (const key of keys) {\n envVars.add(key);\n }\n } catch {\n // Skip unreadable files\n }\n }\n\n return envVars;\n}\n\n/**\n * Find all .env* files recursively (but not in node_modules, .git, dist, etc.)\n */\nfunction findEnvFiles(dir: string, maxDepth: number): string[] {\n const results: string[] = [];\n if (maxDepth <= 0) return results;\n\n try {\n const entries = fs.readdirSync(dir, { withFileTypes: true });\n for (const entry of entries) {\n if (entry.name === \"node_modules\" || entry.name === \".git\" || entry.name === \"dist\") continue;\n const fullPath = path.join(dir, entry.name);\n if (entry.isDirectory()) {\n results.push(...findEnvFiles(fullPath, maxDepth - 1));\n } else if (entry.name.startsWith(\".env\")) {\n results.push(fullPath);\n }\n }\n } catch {\n // Skip unreadable directories\n }\n\n return results;\n}\n\n/**\n * Find files with a specific name recursively.\n * Used to find package.json files across monorepo subdirectories.\n */\nfunction findFiles(dir: string, fileName: string, maxDepth: number): string[] {\n const results: string[] = [];\n if (maxDepth <= 0) return results;\n\n try {\n const entries = fs.readdirSync(dir, { withFileTypes: true });\n for (const entry of entries) {\n if (entry.name === \"node_modules\" || entry.name === \".git\" || entry.name === \"dist\") continue;\n const fullPath = path.join(dir, entry.name);\n if (entry.isDirectory()) {\n results.push(...findFiles(fullPath, fileName, maxDepth - 1));\n } else if (entry.name === fileName) {\n results.push(fullPath);\n }\n }\n } catch {\n // Skip unreadable directories\n }\n\n return results;\n}\n\n/**\n * Lightweight import scanning.\n * Recursively scans the project for import/require statements.\n * Looks in src/, app/, lib/, pages/, and any other code directories.\n * Does NOT do a full AST parse — just string matching.\n */\nfunction scanImports(projectRoot: string): Set<string> {\n const imports = new Set<string>();\n\n // Scan common code directories + the root itself for source files\n const codeDirs = [\"src\", \"app\", \"lib\", \"pages\", \"components\", \"utils\", \"services\", \"hooks\"];\n const dirsToScan: string[] = [];\n\n for (const dir of codeDirs) {\n const fullPath = path.join(projectRoot, dir);\n if (fs.existsSync(fullPath)) {\n dirsToScan.push(fullPath);\n }\n }\n\n // Also check subdirectories (monorepo support)\n try {\n const entries = fs.readdirSync(projectRoot, { withFileTypes: true });\n for (const entry of entries) {\n if (!entry.isDirectory()) continue;\n if (entry.name === \"node_modules\" || entry.name === \".git\" || entry.name === \"dist\" || entry.name.startsWith(\".\")) continue;\n\n // Check if this subdirectory has its own package.json (monorepo package)\n const subPkgPath = path.join(projectRoot, entry.name, \"package.json\");\n if (fs.existsSync(subPkgPath)) {\n // Scan this subpackage's code directories\n for (const dir of codeDirs) {\n const fullPath = path.join(projectRoot, entry.name, dir);\n if (fs.existsSync(fullPath)) {\n dirsToScan.push(fullPath);\n }\n }\n }\n }\n } catch {\n // Skip if root is unreadable\n }\n\n for (const dir of dirsToScan) {\n const files = walkDir(dir, /\\.(ts|tsx|js|jsx|mjs|cjs)$/);\n for (const file of files) {\n try {\n const content = fs.readFileSync(file, \"utf-8\");\n // Match: import ... from \"package\" or require(\"package\")\n const importRegex =\n /(?:from\\s+[\"']|require\\s*\\(\\s*[\"'])([^./][^\"']*?)(?:[\"'])/g;\n let match: RegExpExecArray | null;\n while ((match = importRegex.exec(content)) !== null) {\n const pkg = match[1];\n if (pkg) {\n // Normalize scoped packages: @scope/pkg/subpath -> @scope/pkg\n const parts = pkg.split(\"/\");\n if (parts[0]?.startsWith(\"@\") && parts.length >= 2) {\n imports.add(`${parts[0]}/${parts[1]}`);\n } else if (parts[0]) {\n imports.add(parts[0]);\n }\n }\n }\n } catch {\n // Skip unreadable files\n }\n }\n }\n\n return imports;\n}\n\n/** Recursively walk a directory, returning files matching the pattern. */\nfunction walkDir(dir: string, pattern: RegExp, maxDepth = 5): string[] {\n const results: string[] = [];\n if (maxDepth <= 0) return results;\n\n try {\n const entries = fs.readdirSync(dir, { withFileTypes: true });\n for (const entry of entries) {\n if (entry.name.startsWith(\".\") || entry.name === \"node_modules\") continue;\n const fullPath = path.join(dir, entry.name);\n if (entry.isDirectory()) {\n results.push(...walkDir(fullPath, pattern, maxDepth - 1));\n } else if (pattern.test(entry.name)) {\n results.push(fullPath);\n }\n }\n } catch {\n // Skip unreadable directories\n }\n\n return results;\n}\n","import * as fs from \"node:fs\";\nimport * as path from \"node:path\";\nimport * as url from \"node:url\";\nimport type { ServiceDefinition } from \"./types.js\";\n\nconst __dirname = path.dirname(url.fileURLToPath(import.meta.url));\n\ninterface RegistryFile {\n version: string;\n lastUpdated: string;\n services: Record<string, ServiceDefinition>;\n}\n\nlet cachedRegistry: Map<string, ServiceDefinition> | null = null;\n\n/**\n * Load the service registry.\n * Checks project-local override first, then falls back to bundled registry.\n */\nexport function loadRegistry(projectRoot?: string): Map<string, ServiceDefinition> {\n if (cachedRegistry) return cachedRegistry;\n\n const registry = new Map<string, ServiceDefinition>();\n\n // Load bundled registry (shipped with package)\n // Try multiple possible locations — depends on whether running from src/ or dist/\n const candidates = [\n path.resolve(__dirname, \"../../registry.json\"), // from src/core/\n path.resolve(__dirname, \"../registry.json\"), // from dist/\n ];\n for (const candidate of candidates) {\n if (fs.existsSync(candidate)) {\n loadRegistryFile(candidate, registry);\n break;\n }\n }\n\n // Load project-local override (if exists)\n if (projectRoot) {\n const localPath = path.join(projectRoot, \".burnwatch\", \"registry.json\");\n if (fs.existsSync(localPath)) {\n loadRegistryFile(localPath, registry);\n }\n }\n\n cachedRegistry = registry;\n return registry;\n}\n\nfunction loadRegistryFile(\n filePath: string,\n registry: Map<string, ServiceDefinition>,\n): void {\n try {\n const raw = fs.readFileSync(filePath, \"utf-8\");\n const data = JSON.parse(raw) as RegistryFile;\n for (const [id, service] of Object.entries(data.services)) {\n registry.set(id, { ...service, id });\n }\n } catch {\n // Silently skip missing or malformed registry files\n }\n}\n\n/** Clear the cached registry (for testing). */\nexport function clearRegistryCache(): void {\n cachedRegistry = null;\n}\n\n/** Get a single service definition by ID. */\nexport function getService(\n id: string,\n projectRoot?: string,\n): ServiceDefinition | undefined {\n return loadRegistry(projectRoot).get(id);\n}\n\n/** Get all service definitions. */\nexport function getAllServices(\n projectRoot?: string,\n): ServiceDefinition[] {\n return Array.from(loadRegistry(projectRoot).values());\n}\n","import * as fs from \"node:fs\";\nimport * as path from \"node:path\";\nimport type { SpendBrief, SpendEvent } from \"./types.js\";\nimport { CONFIDENCE_BADGES } from \"./types.js\";\nimport { projectConfigDir, projectDataDir } from \"./config.js\";\n\n/**\n * Write the spend ledger as a human-readable markdown file.\n * Designed to be git-committable and readable in 10 seconds.\n */\nexport function writeLedger(brief: SpendBrief, projectRoot?: string): void {\n const now = new Date();\n const lines: string[] = [];\n\n lines.push(`# Burnwatch Ledger — ${brief.projectName}`);\n lines.push(`Last updated: ${now.toISOString()}`);\n lines.push(\"\");\n lines.push(`## This Month (${brief.period})`);\n lines.push(\"\");\n lines.push(\"| Service | Spend | Conf | Budget | Status |\");\n lines.push(\"|---------|-------|------|--------|--------|\");\n\n for (const svc of brief.services) {\n const spendStr = svc.isEstimate\n ? `~$${svc.spend.toFixed(2)}`\n : `$${svc.spend.toFixed(2)}`;\n const badge = CONFIDENCE_BADGES[svc.tier];\n const budgetStr = svc.budget ? `$${svc.budget}` : \"—\";\n\n lines.push(\n `| ${svc.serviceId} | ${spendStr} | ${badge} | ${budgetStr} | ${svc.statusLabel} |`,\n );\n }\n\n // Add projected impact row if session impacts exist in alerts\n const impactAlert = brief.alerts.find(\n (a) => a.serviceId === \"_session_impact\",\n );\n if (impactAlert) {\n lines.push(\n `| _projected impact_ | — | 📈 EST | — | ${impactAlert.message} |`,\n );\n }\n\n lines.push(\"\");\n const totalStr = brief.totalIsEstimate\n ? `~$${brief.totalSpend.toFixed(2)}`\n : `$${brief.totalSpend.toFixed(2)}`;\n const marginStr =\n brief.estimateMargin > 0\n ? ` (±$${brief.estimateMargin.toFixed(0)} estimated margin)`\n : \"\";\n lines.push(`## TOTAL: ${totalStr}${marginStr}`);\n lines.push(`## Untracked services: ${brief.untrackedCount}`);\n lines.push(\"\");\n\n if (brief.alerts.length > 0) {\n lines.push(\"## Alerts\");\n for (const alert of brief.alerts) {\n const icon = alert.severity === \"critical\" ? \"🚨\" : \"⚠️\";\n lines.push(`- ${icon} ${alert.message}`);\n }\n lines.push(\"\");\n }\n\n const ledgerPath = path.join(\n projectConfigDir(projectRoot),\n \"spend-ledger.md\",\n );\n fs.mkdirSync(path.dirname(ledgerPath), { recursive: true });\n fs.writeFileSync(ledgerPath, lines.join(\"\\n\") + \"\\n\", \"utf-8\");\n}\n\n/**\n * Append an event to the append-only event log.\n */\nexport function logEvent(event: SpendEvent, projectRoot?: string): void {\n const logPath = path.join(projectDataDir(projectRoot), \"events.jsonl\");\n fs.mkdirSync(path.dirname(logPath), { recursive: true });\n fs.appendFileSync(logPath, JSON.stringify(event) + \"\\n\", \"utf-8\");\n}\n\n/**\n * Read recent events from the event log.\n */\nexport function readRecentEvents(\n count: number,\n projectRoot?: string,\n): SpendEvent[] {\n const logPath = path.join(projectDataDir(projectRoot), \"events.jsonl\");\n try {\n const raw = fs.readFileSync(logPath, \"utf-8\");\n const lines = raw.trim().split(\"\\n\").filter(Boolean);\n return lines\n .slice(-count)\n .map((line) => JSON.parse(line) as SpendEvent);\n } catch {\n return [];\n }\n}\n\n/**\n * Save a spend snapshot to the snapshots directory.\n * Used for delta computation across sessions.\n */\nexport function saveSnapshot(brief: SpendBrief, projectRoot?: string): void {\n const snapshotDir = path.join(projectDataDir(projectRoot), \"snapshots\");\n fs.mkdirSync(snapshotDir, { recursive: true });\n const filename = `snapshot-${new Date().toISOString().replace(/[:.]/g, \"-\")}.json`;\n fs.writeFileSync(\n path.join(snapshotDir, filename),\n JSON.stringify(brief, null, 2) + \"\\n\",\n \"utf-8\",\n );\n}\n\n/**\n * Read the most recent snapshot, if any.\n */\nexport function readLatestSnapshot(\n projectRoot?: string,\n): SpendBrief | null {\n const snapshotDir = path.join(projectDataDir(projectRoot), \"snapshots\");\n try {\n const files = fs\n .readdirSync(snapshotDir)\n .filter((f) => f.startsWith(\"snapshot-\") && f.endsWith(\".json\"))\n .sort()\n .reverse();\n\n if (files.length === 0) return null;\n\n const raw = fs.readFileSync(\n path.join(snapshotDir, files[0]!),\n \"utf-8\",\n );\n return JSON.parse(raw) as SpendBrief;\n } catch {\n return null;\n }\n}\n","/**\n * Predictive cost impact analysis.\n *\n * Scans file content for SDK call sites, detects multipliers (loops, .map(), etc.),\n * and projects monthly cost using registry pricing data.\n */\n\nimport type {\n CostImpact,\n ServiceDefinition,\n} from \"./core/types.js\";\nimport { loadRegistry } from \"./core/registry.js\";\n\n/** SDK call patterns per service — maps serviceId to regex patterns for call sites */\nconst SERVICE_CALL_PATTERNS: Record<string, RegExp[]> = {\n anthropic: [\n /\\.messages\\.create\\s*\\(/g,\n /\\.completions\\.create\\s*\\(/g,\n /anthropic\\.\\w+\\.create\\s*\\(/g,\n ],\n openai: [\n /\\.chat\\.completions\\.create\\s*\\(/g,\n /\\.completions\\.create\\s*\\(/g,\n /\\.images\\.generate\\s*\\(/g,\n /\\.embeddings\\.create\\s*\\(/g,\n /openai\\.\\w+\\.create\\s*\\(/g,\n ],\n \"google-gemini\": [\n /\\.generateContent\\s*\\(/g,\n /\\.generateContentStream\\s*\\(/g,\n /model\\.generate\\w*\\s*\\(/g,\n ],\n \"voyage-ai\": [\n /\\.embed\\s*\\(/g,\n /voyageai\\.embed\\s*\\(/g,\n ],\n scrapfly: [\n /\\.scrape\\s*\\(/g,\n /scrapfly\\.scrape\\s*\\(/g,\n /\\.async_scrape\\s*\\(/g,\n /ScrapeConfig\\s*\\(/g,\n ],\n browserbase: [\n /\\.createSession\\s*\\(/g,\n /\\.sessions\\.create\\s*\\(/g,\n /stagehand\\.act\\s*\\(/g,\n /stagehand\\.extract\\s*\\(/g,\n ],\n upstash: [\n /redis\\.\\w+\\s*\\(/g,\n /\\.set\\s*\\(/g,\n /\\.get\\s*\\(/g,\n /\\.incr\\s*\\(/g,\n /\\.hset\\s*\\(/g,\n ],\n resend: [\n /resend\\.emails\\.send\\s*\\(/g,\n /\\.emails\\.send\\s*\\(/g,\n ],\n stripe: [\n /stripe\\.charges\\.create\\s*\\(/g,\n /stripe\\.paymentIntents\\.create\\s*\\(/g,\n /stripe\\.checkout\\.sessions\\.create\\s*\\(/g,\n ],\n supabase: [\n /supabase\\.from\\s*\\(/g,\n /\\.rpc\\s*\\(/g,\n /supabase\\.storage/g,\n ],\n inngest: [\n /inngest\\.send\\s*\\(/g,\n /\\.createFunction\\s*\\(/g,\n ],\n posthog: [\n /posthog\\.capture\\s*\\(/g,\n /\\.capture\\s*\\(/g,\n ],\n aws: [\n /\\.send\\s*\\(new\\s+\\w+Command/g,\n /s3Client\\.send\\s*\\(/g,\n /lambdaClient\\.send\\s*\\(/g,\n ],\n};\n\n/** Multiplier patterns — things that make calls happen more than once */\ninterface MultiplierMatch {\n label: string;\n factor: number;\n}\n\nfunction detectMultipliers(content: string): MultiplierMatch[] {\n const multipliers: MultiplierMatch[] = [];\n\n // for loops — assume 10x as conservative estimate\n if (/for\\s*\\(.*;\\s*\\w+\\s*<\\s*(\\w+)/g.test(content)) {\n // Try to extract the loop bound\n const loopMatch = content.match(/for\\s*\\(.*;\\s*\\w+\\s*<\\s*(\\d+)/);\n if (loopMatch) {\n const bound = parseInt(loopMatch[1]!);\n if (bound > 1) {\n multipliers.push({ label: `for loop (${bound} iterations)`, factor: bound });\n }\n } else {\n multipliers.push({ label: \"for loop (variable bound)\", factor: 10 });\n }\n }\n\n // .map() calls\n if (/\\.\\s*map\\s*\\(\\s*(async\\s*)?\\(/g.test(content)) {\n multipliers.push({ label: \".map() iteration\", factor: 10 });\n }\n\n // .forEach() calls\n if (/\\.\\s*forEach\\s*\\(\\s*(async\\s*)?\\(/g.test(content)) {\n multipliers.push({ label: \".forEach() iteration\", factor: 10 });\n }\n\n // for...of / for...in\n if (/for\\s*\\(\\s*(const|let|var)\\s+\\w+\\s+(of|in)\\s+/g.test(content)) {\n multipliers.push({ label: \"for...of/in loop\", factor: 10 });\n }\n\n // Promise.all with array\n if (/Promise\\.all\\s*\\(/g.test(content)) {\n multipliers.push({ label: \"Promise.all (parallel batch)\", factor: 10 });\n }\n\n // Cron patterns in comments or configuration\n if (/cron|schedule|interval|setInterval|every\\s+\\d+\\s*(min|hour|day|sec)/gi.test(content)) {\n // Estimate: if hourly = 720/mo, daily = 30/mo, every 5 min = 8640/mo\n if (/every\\s+5\\s*min/gi.test(content) || /\\*\\/5\\s+\\*\\s+\\*/g.test(content)) {\n multipliers.push({ label: \"cron: every 5 minutes\", factor: 8640 });\n } else if (/every\\s+1?\\s*hour/gi.test(content) || /0\\s+\\*\\s+\\*\\s+\\*/g.test(content)) {\n multipliers.push({ label: \"cron: hourly\", factor: 720 });\n } else if (/every\\s+1?\\s*day/gi.test(content) || /0\\s+0\\s+\\*\\s+\\*/g.test(content)) {\n multipliers.push({ label: \"cron: daily\", factor: 30 });\n } else {\n multipliers.push({ label: \"scheduled execution\", factor: 30 });\n }\n }\n\n // Batch size hints\n const batchMatch = content.match(/batch[_\\s]?size\\s*[=:]\\s*(\\d+)/i);\n if (batchMatch) {\n const batchSize = parseInt(batchMatch[1]!);\n if (batchSize > 1) {\n multipliers.push({ label: `batch size: ${batchSize}`, factor: batchSize });\n }\n }\n\n return multipliers;\n}\n\n/** Gotcha-based cost multipliers per service */\nconst GOTCHA_MULTIPLIERS: Record<string, { low: number; high: number; explanation: string }> = {\n scrapfly: {\n low: 1,\n high: 25,\n explanation: \"anti-bot bypass consumes 5-25x base credits\",\n },\n browserbase: {\n low: 1,\n high: 5,\n explanation: \"session duration affects cost — long sessions burn more\",\n },\n anthropic: {\n low: 1,\n high: 60,\n explanation: \"Haiku ~$0.25/MTok vs Opus ~$15/MTok (60x range)\",\n },\n openai: {\n low: 1,\n high: 30,\n explanation: \"GPT-4 mini vs GPT-5 (30x cost range)\",\n },\n stripe: {\n low: 1,\n high: 1.5,\n explanation: \"international cards add 1-1.5% extra\",\n },\n};\n\n/**\n * Analyze a file's content for cost-impacting SDK calls.\n * Returns cost impact estimates for each detected service.\n */\nexport function analyzeCostImpact(\n filePath: string,\n content: string,\n projectRoot?: string,\n): CostImpact[] {\n // Only analyze source files\n if (!/\\.(ts|tsx|js|jsx|mjs|cjs)$/.test(filePath)) {\n return [];\n }\n\n const registry = loadRegistry(projectRoot);\n const impacts: CostImpact[] = [];\n const multipliers = detectMultipliers(content);\n\n for (const [serviceId, patterns] of Object.entries(SERVICE_CALL_PATTERNS)) {\n let totalCalls = 0;\n\n for (const pattern of patterns) {\n // Reset lastIndex for global regexes\n pattern.lastIndex = 0;\n const matches = content.match(pattern);\n if (matches) {\n totalCalls += matches.length;\n }\n }\n\n if (totalCalls === 0) continue;\n\n const service = registry.get(serviceId);\n if (!service) continue;\n\n // Calculate effective multiplier\n const multiplierFactor = multipliers.length > 0\n ? multipliers.reduce((max, m) => Math.max(max, m.factor), 1)\n : 1;\n\n // Assume ~30 working days, ~8 dev hours/day as baseline for monthly projections\n // If no cron detected, assume the code runs during dev sessions: ~50 times/month\n const baseMonthlyRuns = multipliers.some((m) => m.label.startsWith(\"cron\"))\n ? 1 // cron multiplier already encodes frequency\n : 50; // ~2 dev runs per working day\n\n const monthlyInvocations = totalCalls * multiplierFactor * baseMonthlyRuns;\n\n // Get cost estimates\n const gotcha = GOTCHA_MULTIPLIERS[serviceId];\n const unitRate = service.pricing?.unitRate ?? 0;\n\n let costLow: number;\n let costHigh: number;\n\n if (unitRate > 0) {\n costLow = monthlyInvocations * unitRate * (gotcha?.low ?? 1);\n costHigh = monthlyInvocations * unitRate * (gotcha?.high ?? 1);\n } else if (service.pricing?.monthlyBase !== undefined) {\n // Flat-rate services — cost is the plan, not per-invocation\n costLow = 0;\n costHigh = 0;\n } else {\n // Estimate based on typical per-call costs\n const typicalCallCosts: Record<string, number> = {\n anthropic: 0.003, // ~$3/MTok * ~1K tokens average\n openai: 0.002,\n \"google-gemini\": 0.001,\n scrapfly: 0.00015,\n browserbase: 0.01,\n resend: 0.001,\n stripe: 0.30,\n };\n const perCall = typicalCallCosts[serviceId] ?? 0.001;\n costLow = monthlyInvocations * perCall * (gotcha?.low ?? 1);\n costHigh = monthlyInvocations * perCall * (gotcha?.high ?? 1);\n }\n\n // Skip if no meaningful cost\n if (costLow === 0 && costHigh === 0) continue;\n\n impacts.push({\n serviceId,\n serviceName: service.name,\n filePath,\n callCount: totalCalls,\n multipliers: multipliers.map((m) => m.label),\n multiplierFactor,\n monthlyInvocations,\n costLow,\n costHigh,\n rangeExplanation: gotcha?.explanation,\n });\n }\n\n return impacts;\n}\n\n/**\n * Format a cost impact card for injection into Claude's context.\n */\nexport function formatCostImpactCard(\n impacts: CostImpact[],\n currentBudgets: Record<string, { spend: number; budget?: number }>,\n): string {\n const fileName = impacts[0]?.filePath.split(\"/\").pop() ?? \"unknown\";\n const lines: string[] = [];\n\n lines.push(`[BURNWATCH] ⚠️ Cost impact estimate for ${fileName}`);\n\n for (const impact of impacts) {\n const lowStr = impact.costLow < 1\n ? `$${impact.costLow.toFixed(2)}`\n : `$${impact.costLow.toFixed(0)}`;\n const highStr = impact.costHigh < 1\n ? `$${impact.costHigh.toFixed(2)}`\n : `$${impact.costHigh.toFixed(0)}`;\n\n const rangeStr = impact.costLow === impact.costHigh\n ? lowStr\n : `${lowStr}-${highStr}`;\n\n lines.push(\n ` ${impact.serviceName}: ~${impact.monthlyInvocations.toLocaleString()} calls/mo → ${rangeStr}/mo` +\n (impact.rangeExplanation ? ` (${impact.rangeExplanation})` : \"\"),\n );\n\n // Show current budget status if available\n const current = currentBudgets[impact.serviceId];\n if (current) {\n const budgetStr = current.budget\n ? `$${current.spend.toFixed(0)}/$${current.budget} budget`\n : `$${current.spend.toFixed(0)} (no budget set)`;\n const pctStr = current.budget && current.budget > 0\n ? ` (${((current.spend / current.budget) * 100).toFixed(0)}%)`\n : \"\";\n lines.push(` Current: ${budgetStr}${pctStr}`);\n }\n\n // Suggest alternatives from registry\n const registry = loadRegistry();\n const service = registry.get(impact.serviceId);\n if (service?.alternatives && service.alternatives.length > 0 && impact.costHigh > 10) {\n const freeAlts = service.alternatives.filter(\n (a) => a.includes(\"free\") || a.includes(\"cheerio\") || a.includes(\"playwright\") || a.includes(\"self-hosted\"),\n );\n if (freeAlts.length > 0) {\n lines.push(` Consider: ${freeAlts.join(\", \")} for lower-cost alternative`);\n }\n }\n }\n\n return lines.join(\"\\n\");\n}\n"],"mappings":"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","names":["fs","path","fs","path","fs","path","fs","path"]}
|
package/dist/hooks/on-prompt.js
CHANGED