ai-cost-audit 0.1.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.
Files changed (3) hide show
  1. package/README.md +177 -0
  2. package/dist/cli.js +1286 -0
  3. package/package.json +50 -0
package/dist/cli.js ADDED
@@ -0,0 +1,1286 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { writeFile as writeFile2 } from "fs/promises";
5
+ import path8 from "path";
6
+ import { Command } from "commander";
7
+ import pc from "picocolors";
8
+
9
+ // src/config.ts
10
+ import { readFile } from "fs/promises";
11
+ import { existsSync } from "fs";
12
+ import path from "path";
13
+ import { z } from "zod";
14
+ var rangeSchema = z.tuple([z.number().nonnegative(), z.number().nonnegative()]);
15
+ var configSchema = z.object({
16
+ providers: z.array(z.string()).default(["anthropic"]),
17
+ models: z.array(z.string()).default(["claude-opus-4-8", "claude-sonnet-5"]),
18
+ monthlyBudget: z.number().positive().nullable().default(null),
19
+ developers: z.number().int().positive().default(1),
20
+ baselineTokenLimit: z.number().positive().nullable().default(null),
21
+ growthThresholdPct: z.number().positive().default(20),
22
+ requestsPerDay: z.array(z.number().positive()).default([50, 200, 1e3]),
23
+ cache: z.object({
24
+ enabled: z.boolean().default(true),
25
+ requestsPerSession: z.number().int().positive().default(10)
26
+ }).default({}),
27
+ variable: z.object({
28
+ conversationHistory: rangeSchema.default([8e3, 25e3]),
29
+ taskFiles: rangeSchema.default([5e3, 15e3])
30
+ }).default({}),
31
+ calibration: z.record(z.number().positive()).default({}),
32
+ pricingOverrides: z.record(
33
+ z.object({
34
+ inputPerMTok: z.number().nonnegative(),
35
+ outputPerMTok: z.number().nonnegative().optional()
36
+ })
37
+ ).default({}),
38
+ mcp: z.object({
39
+ knownSchemaTokens: z.record(z.number().nonnegative()).default({})
40
+ }).default({}),
41
+ duplication: z.object({
42
+ minBlockTokens: z.number().positive().default(40),
43
+ similarityThreshold: z.number().min(0).max(1).default(0.8)
44
+ }).default({}),
45
+ scan: z.object({
46
+ include: z.array(z.string()).default([
47
+ "CLAUDE.md",
48
+ "CLAUDE.local.md",
49
+ "AGENTS.md",
50
+ ".github/copilot-instructions.md",
51
+ ".github/**/*.instructions.md",
52
+ ".claude/**/*",
53
+ ".cursor/rules/**/*",
54
+ ".cursorrules",
55
+ ".mcp.json",
56
+ "mcp.json",
57
+ "prompts/**/*"
58
+ ]),
59
+ exclude: z.array(z.string()).default(["**/node_modules/**", "**/dist/**", "**/.git/**"])
60
+ }).default({}),
61
+ refDepth: z.number().int().min(0).max(10).default(3),
62
+ includeGlobal: z.boolean().default(true)
63
+ }).strict();
64
+ var CONFIG_FILENAME = "ai-cost-audit.json";
65
+ async function loadConfig(projectPath, explicitPath) {
66
+ let configPath = null;
67
+ if (explicitPath) {
68
+ configPath = path.resolve(explicitPath);
69
+ if (!existsSync(configPath)) {
70
+ throw new Error(`Config file not found: ${configPath}`);
71
+ }
72
+ } else {
73
+ const candidate = path.join(projectPath, CONFIG_FILENAME);
74
+ if (existsSync(candidate)) configPath = candidate;
75
+ }
76
+ let raw = {};
77
+ if (configPath) {
78
+ try {
79
+ raw = JSON.parse(await readFile(configPath, "utf8"));
80
+ } catch (err) {
81
+ throw new Error(
82
+ `Failed to parse ${configPath}: ${err instanceof Error ? err.message : String(err)}`
83
+ );
84
+ }
85
+ }
86
+ const parsed = configSchema.safeParse(raw);
87
+ if (!parsed.success) {
88
+ const issues = parsed.error.issues.map((i) => ` ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
89
+ throw new Error(`Invalid config${configPath ? ` (${configPath})` : ""}:
90
+ ${issues}`);
91
+ }
92
+ return { config: parsed.data, configPath };
93
+ }
94
+
95
+ // src/scan.ts
96
+ import path6 from "path";
97
+
98
+ // src/adapters/claudeCode.ts
99
+ import { readFile as readFile3 } from "fs/promises";
100
+ import { existsSync as existsSync3 } from "fs";
101
+ import os from "os";
102
+ import path3 from "path";
103
+ import fg from "fast-glob";
104
+
105
+ // src/tokenizer.ts
106
+ import { Tiktoken } from "js-tiktoken/lite";
107
+ import o200k_base from "js-tiktoken/ranks/o200k_base";
108
+
109
+ // src/pricing.ts
110
+ var PRICING_AS_OF = "2026-07-01";
111
+ var PRICING_STALE_AFTER_DAYS = 90;
112
+ var PROVIDERS = {
113
+ anthropic: {
114
+ // o200k_base undercounts Claude tokens on typical instruction text;
115
+ // Claude counts are roughly 1.15-1.25x the o200k count.
116
+ calibration: 1.2,
117
+ // Prompt-cache multipliers relative to base input price (5-minute TTL).
118
+ cache: { write: 1.25, read: 0.1 }
119
+ },
120
+ openai: {
121
+ calibration: 1
122
+ // No cache modeling seeded; OpenAI's automatic caching differs — users can
123
+ // approximate via pricingOverrides + a custom provider entry later.
124
+ }
125
+ };
126
+ var MODELS = [
127
+ {
128
+ id: "claude-opus-4-8",
129
+ provider: "anthropic",
130
+ inputPerMTok: 5,
131
+ outputPerMTok: 25
132
+ },
133
+ {
134
+ id: "claude-sonnet-5",
135
+ provider: "anthropic",
136
+ inputPerMTok: 3,
137
+ outputPerMTok: 15,
138
+ note: "intro pricing $2/$10 per MTok through 2026-08-31"
139
+ },
140
+ {
141
+ id: "claude-haiku-4-5",
142
+ provider: "anthropic",
143
+ inputPerMTok: 1,
144
+ outputPerMTok: 5
145
+ },
146
+ {
147
+ id: "gpt",
148
+ provider: "openai",
149
+ inputPerMTok: null,
150
+ outputPerMTok: null,
151
+ note: "set inputPerMTok via config.pricingOverrides.gpt"
152
+ }
153
+ ];
154
+ function isPricingStale(now = /* @__PURE__ */ new Date()) {
155
+ const asOf = new Date(PRICING_AS_OF);
156
+ const ageDays = (now.getTime() - asOf.getTime()) / (1e3 * 60 * 60 * 24);
157
+ return ageDays > PRICING_STALE_AFTER_DAYS;
158
+ }
159
+ function resolveModels(cfg) {
160
+ return cfg.models.map((id) => {
161
+ const base = MODELS.find((m) => m.id === id);
162
+ const override = cfg.pricingOverrides[id];
163
+ if (!base && !override) {
164
+ return {
165
+ id,
166
+ provider: "unknown",
167
+ inputPerMTok: null,
168
+ outputPerMTok: null,
169
+ note: "unknown model \u2014 set pricing via config.pricingOverrides"
170
+ };
171
+ }
172
+ return {
173
+ id,
174
+ provider: base?.provider ?? "unknown",
175
+ inputPerMTok: override?.inputPerMTok ?? base?.inputPerMTok ?? null,
176
+ outputPerMTok: override?.outputPerMTok ?? base?.outputPerMTok ?? null,
177
+ note: base?.note
178
+ };
179
+ });
180
+ }
181
+ function resolveProvider(name, cfg) {
182
+ const base = PROVIDERS[name] ?? { calibration: 1 };
183
+ const calibration = cfg.calibration[name] ?? base.calibration;
184
+ return { ...base, calibration };
185
+ }
186
+
187
+ // src/tokenizer.ts
188
+ var encoder = null;
189
+ function getEncoder() {
190
+ if (!encoder) encoder = new Tiktoken(o200k_base);
191
+ return encoder;
192
+ }
193
+ function countRawTokens(text) {
194
+ if (text.length === 0) return 0;
195
+ return getEncoder().encode(text).length;
196
+ }
197
+ function estimateTokens(text, provider, cfg) {
198
+ const raw = countRawTokens(text);
199
+ const { calibration } = resolveProvider(provider, cfg);
200
+ return Math.round(raw * calibration);
201
+ }
202
+ var DISCLOSURE = "Token counts are offline estimates (\xB1~15-20% for Anthropic models). No model API is called.";
203
+
204
+ // src/discovery.ts
205
+ import { readFile as readFile2 } from "fs/promises";
206
+ import { existsSync as existsSync2, statSync } from "fs";
207
+ import path2 from "path";
208
+ var AT_IMPORT_RE = /(?:^|\s)@((?:\.{0,2}\/)?[\w./-]+\.(?:md|txt|json))\b/gm;
209
+ var MD_LINK_RE = /\[[^\]]*\]\((?!https?:\/\/|mailto:|#)([^)\s]+?\.(?:md|txt|json))(?:#[^)]*)?\)/g;
210
+ function extractReferences(text) {
211
+ const refs = /* @__PURE__ */ new Set();
212
+ for (const match of text.matchAll(AT_IMPORT_RE)) {
213
+ refs.add(match[1]);
214
+ }
215
+ for (const match of text.matchAll(MD_LINK_RE)) {
216
+ refs.add(match[1]);
217
+ }
218
+ return [...refs];
219
+ }
220
+ async function followReferences(rootFile, rootText, projectPath, maxDepth) {
221
+ const found = [];
222
+ const visited = /* @__PURE__ */ new Set([path2.resolve(rootFile)]);
223
+ let frontier = [{ file: rootFile, text: rootText }];
224
+ for (let depth = 0; depth < maxDepth && frontier.length > 0; depth++) {
225
+ const next = [];
226
+ for (const { file, text } of frontier) {
227
+ for (const ref of extractReferences(text)) {
228
+ const baseDir = path2.dirname(file);
229
+ const candidates = [path2.resolve(baseDir, ref), path2.resolve(projectPath, ref)];
230
+ const resolved = candidates.find(
231
+ (c) => existsSync2(c) && statSync(c).isFile() && insideProject(c, projectPath)
232
+ );
233
+ if (!resolved || visited.has(resolved)) continue;
234
+ visited.add(resolved);
235
+ try {
236
+ const refText = await readFile2(resolved, "utf8");
237
+ found.push({ absPath: resolved, referencedFrom: file });
238
+ next.push({ file: resolved, text: refText });
239
+ } catch {
240
+ }
241
+ }
242
+ }
243
+ frontier = next;
244
+ }
245
+ return found;
246
+ }
247
+ function insideProject(absPath, projectPath) {
248
+ const rel = path2.relative(path2.resolve(projectPath), absPath);
249
+ return !rel.startsWith("..") && !path2.isAbsolute(rel);
250
+ }
251
+ function displayPath(absPath, projectPath) {
252
+ const rel = path2.relative(path2.resolve(projectPath), absPath);
253
+ return rel.startsWith("..") ? absPath : rel;
254
+ }
255
+
256
+ // src/adapters/claudeCode.ts
257
+ var claudeCodeAdapter = {
258
+ name: "claude-code",
259
+ async discover(projectPath, cfg) {
260
+ const sources = [];
261
+ const provider = "anthropic";
262
+ const addFile = async (absPath, partial) => {
263
+ if (!existsSync3(absPath)) return null;
264
+ let text;
265
+ try {
266
+ text = await readFile3(absPath, "utf8");
267
+ } catch {
268
+ return null;
269
+ }
270
+ sources.push({
271
+ path: partial.path ?? displayPath(absPath, projectPath),
272
+ adapter: "claude-code",
273
+ kind: partial.kind,
274
+ usage: partial.usage,
275
+ scope: partial.scope,
276
+ tokens: estimateTokens(text, provider, cfg),
277
+ confidence: partial.confidence,
278
+ note: partial.note,
279
+ referencedFrom: partial.referencedFrom,
280
+ text
281
+ });
282
+ return text;
283
+ };
284
+ const rootClaudeMd = path3.join(projectPath, "CLAUDE.md");
285
+ const rootText = await addFile(rootClaudeMd, {
286
+ kind: "repo-instructions",
287
+ usage: "guaranteed",
288
+ scope: "repo",
289
+ confidence: "high"
290
+ });
291
+ await addFile(path3.join(projectPath, "CLAUDE.local.md"), {
292
+ kind: "local-instructions",
293
+ usage: "guaranteed",
294
+ scope: "repo",
295
+ confidence: "high",
296
+ note: "typically gitignored; loads for the local developer only"
297
+ });
298
+ if (rootText) {
299
+ const refs = await followReferences(rootClaudeMd, rootText, projectPath, cfg.refDepth);
300
+ for (const ref of refs) {
301
+ await addFile(ref.absPath, {
302
+ kind: "referenced-doc",
303
+ usage: "guaranteed",
304
+ scope: "repo",
305
+ confidence: "medium",
306
+ note: "pulled in via reference from an instruction file",
307
+ referencedFrom: displayPath(ref.referencedFrom, projectPath)
308
+ });
309
+ }
310
+ }
311
+ if (cfg.includeGlobal) {
312
+ await addFile(path3.join(os.homedir(), ".claude", "CLAUDE.md"), {
313
+ path: "~/.claude/CLAUDE.md",
314
+ kind: "global-instructions",
315
+ usage: "guaranteed",
316
+ scope: "global",
317
+ confidence: "high",
318
+ note: "user-global; reported but not counted toward the CI budget"
319
+ });
320
+ }
321
+ const agentFiles = await fg(".claude/agents/**/*.md", {
322
+ cwd: projectPath,
323
+ absolute: true,
324
+ dot: true
325
+ });
326
+ for (const file of agentFiles.sort()) {
327
+ await addFile(file, {
328
+ kind: "agent",
329
+ usage: "conditional",
330
+ scope: "repo",
331
+ confidence: "medium",
332
+ note: "loads when the agent is invoked; its description line loads every session"
333
+ });
334
+ }
335
+ const skillFiles = await fg(".claude/skills/*/SKILL.md", {
336
+ cwd: projectPath,
337
+ absolute: true,
338
+ dot: true
339
+ });
340
+ for (const file of skillFiles.sort()) {
341
+ let text;
342
+ try {
343
+ text = await readFile3(file, "utf8");
344
+ } catch {
345
+ continue;
346
+ }
347
+ const { frontmatter, body } = splitFrontmatter(text);
348
+ const rel = displayPath(file, projectPath);
349
+ if (frontmatter) {
350
+ sources.push({
351
+ path: `${rel} (description)`,
352
+ adapter: "claude-code",
353
+ kind: "skill-description",
354
+ usage: "guaranteed",
355
+ scope: "repo",
356
+ tokens: estimateTokens(frontmatter, "anthropic", cfg),
357
+ confidence: "high",
358
+ note: "skill metadata loads every session (progressive disclosure)",
359
+ text: frontmatter
360
+ });
361
+ }
362
+ if (body.trim().length > 0) {
363
+ sources.push({
364
+ path: `${rel} (body)`,
365
+ adapter: "claude-code",
366
+ kind: "skill-body",
367
+ usage: "conditional",
368
+ scope: "repo",
369
+ tokens: estimateTokens(body, "anthropic", cfg),
370
+ confidence: "high",
371
+ note: "loads only when the skill is invoked",
372
+ text: body
373
+ });
374
+ }
375
+ }
376
+ const commandFiles = await fg(".claude/commands/**/*.md", {
377
+ cwd: projectPath,
378
+ absolute: true,
379
+ dot: true
380
+ });
381
+ for (const file of commandFiles.sort()) {
382
+ await addFile(file, {
383
+ kind: "command",
384
+ usage: "conditional",
385
+ scope: "repo",
386
+ confidence: "high",
387
+ note: "loads when the command is invoked"
388
+ });
389
+ }
390
+ return sources;
391
+ }
392
+ };
393
+ function splitFrontmatter(text) {
394
+ const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
395
+ if (!match) return { frontmatter: null, body: text };
396
+ return { frontmatter: match[0], body: text.slice(match[0].length) };
397
+ }
398
+
399
+ // src/adapters/genericMcp.ts
400
+ import { readFile as readFile4 } from "fs/promises";
401
+ import { existsSync as existsSync4 } from "fs";
402
+ import path4 from "path";
403
+ var genericMcpAdapter = {
404
+ name: "generic-mcp",
405
+ async discover(projectPath, cfg) {
406
+ const sources = [];
407
+ for (const filename of [".mcp.json", "mcp.json"]) {
408
+ const absPath = path4.join(projectPath, filename);
409
+ if (!existsSync4(absPath)) continue;
410
+ let text;
411
+ try {
412
+ text = await readFile4(absPath, "utf8");
413
+ } catch {
414
+ continue;
415
+ }
416
+ let servers = {};
417
+ try {
418
+ const parsed = JSON.parse(text);
419
+ servers = parsed.mcpServers ?? {};
420
+ } catch {
421
+ sources.push({
422
+ path: filename,
423
+ adapter: "generic-mcp",
424
+ kind: "mcp-config",
425
+ usage: "guaranteed",
426
+ scope: "repo",
427
+ tokens: estimateTokens(text, "anthropic", cfg),
428
+ confidence: "low",
429
+ note: "file is not valid JSON; counted raw",
430
+ text
431
+ });
432
+ continue;
433
+ }
434
+ const serverNames = Object.keys(servers);
435
+ if (serverNames.length === 0) continue;
436
+ for (const name of serverNames) {
437
+ const known = cfg.mcp.knownSchemaTokens[name];
438
+ const configJson = JSON.stringify(servers[name] ?? {}, null, 2);
439
+ if (known !== void 0) {
440
+ sources.push({
441
+ path: `${filename} \u2192 ${name}`,
442
+ adapter: "generic-mcp",
443
+ kind: "mcp-config",
444
+ usage: "guaranteed",
445
+ scope: "repo",
446
+ tokens: known,
447
+ confidence: "medium",
448
+ note: "schema size pinned via config.mcp.knownSchemaTokens",
449
+ text: configJson
450
+ });
451
+ } else {
452
+ sources.push({
453
+ path: `${filename} \u2192 ${name}`,
454
+ adapter: "generic-mcp",
455
+ kind: "mcp-config",
456
+ usage: "guaranteed",
457
+ scope: "repo",
458
+ tokens: estimateTokens(configJson, "anthropic", cfg),
459
+ confidence: "low",
460
+ note: "live tool schemas not measured (requires a running server); actual context load is usually far larger \u2014 pin via config.mcp.knownSchemaTokens",
461
+ text: configJson
462
+ });
463
+ }
464
+ }
465
+ }
466
+ return sources;
467
+ }
468
+ };
469
+
470
+ // src/adapters/instructions.ts
471
+ import { readFile as readFile5 } from "fs/promises";
472
+ import path5 from "path";
473
+ import fg2 from "fast-glob";
474
+ var instructionsAdapter = {
475
+ name: "instructions",
476
+ async discover(projectPath, cfg) {
477
+ const sources = [];
478
+ const add = async (relPath, kind, usage, confidence, note) => {
479
+ let text;
480
+ try {
481
+ text = await readFile5(path5.join(projectPath, relPath), "utf8");
482
+ } catch {
483
+ return;
484
+ }
485
+ sources.push({
486
+ path: relPath,
487
+ adapter: "instructions",
488
+ kind,
489
+ usage,
490
+ scope: "repo",
491
+ tokens: estimateTokens(text, "anthropic", cfg),
492
+ confidence,
493
+ note,
494
+ text
495
+ });
496
+ };
497
+ const glob = (pattern) => fg2(pattern, { cwd: projectPath, dot: true, onlyFiles: true }).then((r) => r.sort());
498
+ for (const file of await glob("AGENTS.md")) {
499
+ await add(file, "repo-instructions", "guaranteed", "high", "loaded by AGENTS.md-aware tools");
500
+ }
501
+ for (const file of await glob(".cursor/rules/**/*.mdc")) {
502
+ await add(
503
+ file,
504
+ "cursor-rules",
505
+ "guaranteed",
506
+ "medium",
507
+ "Cursor rule; may be glob-scoped or manual (frontmatter not parsed in v1)"
508
+ );
509
+ }
510
+ for (const file of await glob(".cursorrules")) {
511
+ await add(
512
+ file,
513
+ "cursor-rules",
514
+ "guaranteed",
515
+ "high",
516
+ "legacy .cursorrules format (Cursor now uses .cursor/rules/*.mdc)"
517
+ );
518
+ }
519
+ for (const file of await glob(".github/copilot-instructions.md")) {
520
+ await add(file, "copilot-instructions", "guaranteed", "high", "loaded on every Copilot chat request");
521
+ }
522
+ for (const file of await glob(".github/**/*.instructions.md")) {
523
+ await add(
524
+ file,
525
+ "copilot-instructions",
526
+ "conditional",
527
+ "medium",
528
+ "path-scoped Copilot instructions; loads when matching files are in play"
529
+ );
530
+ }
531
+ return sources;
532
+ }
533
+ };
534
+
535
+ // src/adapters/index.ts
536
+ var ADAPTERS = [
537
+ claudeCodeAdapter,
538
+ genericMcpAdapter,
539
+ instructionsAdapter
540
+ ];
541
+ async function discoverAll(projectPath, cfg) {
542
+ const seen = /* @__PURE__ */ new Set();
543
+ const all = [];
544
+ for (const adapter of ADAPTERS) {
545
+ const sources = await adapter.discover(projectPath, cfg);
546
+ for (const source of sources) {
547
+ if (seen.has(source.path)) continue;
548
+ seen.add(source.path);
549
+ all.push(source);
550
+ }
551
+ }
552
+ return all;
553
+ }
554
+
555
+ // src/analysis/duplication.ts
556
+ import { createHash } from "crypto";
557
+ function splitBlocks(source, minBlockTokens) {
558
+ const parts = source.text.split(/\r?\n(?=#{1,6}\s)|\r?\n\s*\r?\n/).map((p) => p.trim()).filter((p) => p.length > 0);
559
+ const blocks = [];
560
+ for (const text of parts) {
561
+ const normalized = normalize(text);
562
+ if (normalized.length === 0) continue;
563
+ const tokens = countRawTokens(normalized);
564
+ if (tokens < minBlockTokens) continue;
565
+ blocks.push({ sourcePath: source.path, text, normalized, tokens });
566
+ }
567
+ return blocks;
568
+ }
569
+ function normalize(text) {
570
+ return text.toLowerCase().replace(/\s+/g, " ").trim();
571
+ }
572
+ function shingles(normalized, k = 3) {
573
+ const words = normalized.split(" ");
574
+ const result = /* @__PURE__ */ new Set();
575
+ if (words.length <= k) {
576
+ result.add(words.join(" "));
577
+ return result;
578
+ }
579
+ for (let i = 0; i <= words.length - k; i++) {
580
+ result.add(words.slice(i, i + k).join(" "));
581
+ }
582
+ return result;
583
+ }
584
+ function containment(a, b) {
585
+ let intersection = 0;
586
+ const [small, large] = a.size <= b.size ? [a, b] : [b, a];
587
+ for (const item of small) if (large.has(item)) intersection++;
588
+ const minSize = Math.min(a.size, b.size);
589
+ return minSize === 0 ? 0 : intersection / minSize;
590
+ }
591
+ function findDuplicates(sources, cfg) {
592
+ const blocks = sources.flatMap((s) => splitBlocks(s, cfg.duplication.minBlockTokens));
593
+ const groups = [];
594
+ const byHash = /* @__PURE__ */ new Map();
595
+ for (const block of blocks) {
596
+ const hash = createHash("sha256").update(block.normalized).digest("hex");
597
+ const list = byHash.get(hash);
598
+ if (list) list.push(block);
599
+ else byHash.set(hash, [block]);
600
+ }
601
+ const inExactGroup = /* @__PURE__ */ new Set();
602
+ for (const group of byHash.values()) {
603
+ const uniquePaths = [...new Set(group.map((b) => b.sourcePath))];
604
+ if (group.length < 2 || uniquePaths.length < 2) continue;
605
+ for (const b of group) inExactGroup.add(b);
606
+ groups.push({
607
+ sources: uniquePaths,
608
+ redundantTokens: group[0].tokens * (group.length - 1),
609
+ excerpt: excerpt(group[0].text),
610
+ exact: true
611
+ });
612
+ }
613
+ const remaining = blocks.filter((b) => !inExactGroup.has(b));
614
+ const shingleCache = /* @__PURE__ */ new Map();
615
+ const getShingles = (b) => {
616
+ let s = shingleCache.get(b);
617
+ if (!s) {
618
+ s = shingles(b.normalized);
619
+ shingleCache.set(b, s);
620
+ }
621
+ return s;
622
+ };
623
+ const claimed = /* @__PURE__ */ new Set();
624
+ for (let i = 0; i < remaining.length; i++) {
625
+ const a = remaining[i];
626
+ if (claimed.has(a)) continue;
627
+ const cluster = [a];
628
+ for (let j = i + 1; j < remaining.length; j++) {
629
+ const b = remaining[j];
630
+ if (claimed.has(b) || b.sourcePath === a.sourcePath) continue;
631
+ if (containment(getShingles(a), getShingles(b)) >= cfg.duplication.similarityThreshold) {
632
+ cluster.push(b);
633
+ }
634
+ }
635
+ if (cluster.length >= 2) {
636
+ for (const b of cluster) claimed.add(b);
637
+ const minTokens = Math.min(...cluster.map((b) => b.tokens));
638
+ groups.push({
639
+ sources: [...new Set(cluster.map((b) => b.sourcePath))],
640
+ redundantTokens: minTokens * (cluster.length - 1),
641
+ excerpt: excerpt(a.text),
642
+ exact: false
643
+ });
644
+ }
645
+ }
646
+ return groups.sort((a, b) => b.redundantTokens - a.redundantTokens);
647
+ }
648
+ function excerpt(text) {
649
+ const flat = text.replace(/\s+/g, " ").trim();
650
+ return flat.length > 80 ? `${flat.slice(0, 77)}...` : flat;
651
+ }
652
+
653
+ // src/analysis/findings.ts
654
+ var LARGE_SOURCE_PCT = 25;
655
+ var MCP_SHARE_PCT = 30;
656
+ function deriveFindings(sources, duplicates, gatedBaseline, snapshot, cfg) {
657
+ const findings = [];
658
+ for (const group of duplicates) {
659
+ findings.push({
660
+ rule: group.exact ? "duplicate-content" : "near-duplicate-content",
661
+ severity: "warn",
662
+ message: group.exact ? `${group.redundantTokens.toLocaleString()} redundant tokens: identical content in ${group.sources.join(", ")} ("${group.excerpt}")` : `~${group.redundantTokens.toLocaleString()} redundant tokens: nearly identical content in ${group.sources.join(", ")} ("${group.excerpt}")`,
663
+ sources: group.sources,
664
+ tokens: group.redundantTokens
665
+ });
666
+ }
667
+ if (gatedBaseline > 0) {
668
+ for (const s of sources) {
669
+ if (s.scope !== "repo" || s.usage !== "guaranteed") continue;
670
+ const pct = s.tokens / gatedBaseline * 100;
671
+ if (pct > LARGE_SOURCE_PCT) {
672
+ findings.push({
673
+ rule: "large-source",
674
+ severity: "warn",
675
+ message: `${s.path} alone is ${Math.round(pct)}% of the baseline (${s.tokens.toLocaleString()} tokens). Consider splitting into on-demand skills or conditional instructions.`,
676
+ sources: [s.path],
677
+ tokens: s.tokens
678
+ });
679
+ }
680
+ }
681
+ const mcpTokens = sources.filter((s) => s.adapter === "generic-mcp" && s.scope === "repo" && s.usage === "guaranteed").reduce((sum, s) => sum + s.tokens, 0);
682
+ const mcpPct = mcpTokens / gatedBaseline * 100;
683
+ if (mcpPct > MCP_SHARE_PCT) {
684
+ findings.push({
685
+ rule: "mcp-schema-share",
686
+ severity: "info",
687
+ message: `MCP configuration is ${Math.round(mcpPct)}% of the baseline (${mcpTokens.toLocaleString()} tokens, likely an undercount \u2014 live schemas unmeasured). Consider restricting MCP tools by task.`,
688
+ sources: sources.filter((s) => s.adapter === "generic-mcp").map((s) => s.path),
689
+ tokens: mcpTokens
690
+ });
691
+ }
692
+ }
693
+ if (snapshot && snapshot.gatedBaseline > 0) {
694
+ const growthPct = (gatedBaseline - snapshot.gatedBaseline) / snapshot.gatedBaseline * 100;
695
+ if (growthPct > cfg.growthThresholdPct) {
696
+ const causes = topGrowthCauses(sources, snapshot);
697
+ findings.push({
698
+ rule: "baseline-growth",
699
+ severity: "warn",
700
+ message: `Baseline grew ${growthPct.toFixed(0)}% since the last snapshot (${snapshot.gatedBaseline.toLocaleString()} \u2192 ${gatedBaseline.toLocaleString()} tokens).` + (causes.length > 0 ? ` Primary cause: ${causes[0]}` : ""),
701
+ sources: [],
702
+ tokens: gatedBaseline - snapshot.gatedBaseline
703
+ });
704
+ }
705
+ }
706
+ const legacy = sources.filter((s) => s.path === ".cursorrules");
707
+ if (legacy.length > 0) {
708
+ findings.push({
709
+ rule: "legacy-cursorrules",
710
+ severity: "info",
711
+ message: "Legacy .cursorrules file found. Cursor now uses .cursor/rules/*.mdc, which supports glob scoping (loading rules only when relevant).",
712
+ sources: [".cursorrules"]
713
+ });
714
+ }
715
+ const unmeasured = sources.filter(
716
+ (s) => s.adapter === "generic-mcp" && s.confidence === "low"
717
+ );
718
+ if (unmeasured.length > 0) {
719
+ findings.push({
720
+ rule: "unmeasured-mcp",
721
+ severity: "info",
722
+ message: `${unmeasured.length} MCP server(s) counted by config size only \u2014 live tool schemas require a running server and are usually far larger. Pin measured sizes via config.mcp.knownSchemaTokens.`,
723
+ sources: unmeasured.map((s) => s.path)
724
+ });
725
+ }
726
+ const severityRank = { error: 0, warn: 1, info: 2 };
727
+ return findings.sort(
728
+ (a, b) => severityRank[a.severity] - severityRank[b.severity] || (b.tokens ?? 0) - (a.tokens ?? 0)
729
+ );
730
+ }
731
+ function topGrowthCauses(sources, snapshot) {
732
+ const previous = new Map(snapshot.sources.map((s) => [s.path, s.tokens]));
733
+ const current = new Map(
734
+ sources.filter((s) => s.scope === "repo" && s.usage === "guaranteed").map((s) => [s.path, s.tokens])
735
+ );
736
+ const diffs = [];
737
+ for (const [path9, tokens] of current) {
738
+ const prev = previous.get(path9);
739
+ if (prev === void 0) diffs.push({ path: path9, delta: tokens, added: true });
740
+ else if (tokens !== prev) diffs.push({ path: path9, delta: tokens - prev, added: false });
741
+ }
742
+ for (const [path9, tokens] of previous) {
743
+ if (!current.has(path9)) diffs.push({ path: path9, delta: -tokens, added: false });
744
+ }
745
+ return diffs.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta)).slice(0, 3).map(({ path: path9, delta, added }) => {
746
+ const sign = delta >= 0 ? "+" : "\u2212";
747
+ const amount = `${sign}${Math.abs(delta).toLocaleString()} tokens`;
748
+ if (added) return `Added ${path9} (${amount})`;
749
+ if (delta < 0 && !added) return `${path9} (${amount})`;
750
+ return `${path9} grew (${amount})`;
751
+ });
752
+ }
753
+
754
+ // src/costModel.ts
755
+ function cacheEffectiveMultiplier(write, read, requestsPerSession) {
756
+ const n = Math.max(1, requestsPerSession);
757
+ return (write + read * (n - 1)) / n;
758
+ }
759
+ function cacheFormulaDescription(cfg) {
760
+ const n = cfg.cache.requestsPerSession;
761
+ return `cached cost/request = baseline_tokens x input_price x (write + read x (n-1)) / n, with n = ${n} requests/session (anthropic: write 1.25x, read 0.1x, 5-min TTL)`;
762
+ }
763
+ function computeCosts(baselineTokensByProvider, cfg) {
764
+ return resolveModels(cfg).map((model) => {
765
+ const tokens = baselineTokensByProvider[model.provider] ?? null;
766
+ if (tokens === null || model.inputPerMTok === null) {
767
+ return {
768
+ model: model.id,
769
+ provider: model.provider,
770
+ perRequestUncached: null,
771
+ perRequestCached: null,
772
+ daily: cfg.requestsPerDay.map((r) => ({
773
+ requestsPerDay: r,
774
+ uncached: null,
775
+ cached: null
776
+ })),
777
+ runwayDays: null
778
+ };
779
+ }
780
+ const perRequestUncached = tokens / 1e6 * model.inputPerMTok;
781
+ const provider = resolveProvider(model.provider, cfg);
782
+ let perRequestCached = null;
783
+ if (cfg.cache.enabled && provider.cache) {
784
+ const mult = cacheEffectiveMultiplier(
785
+ provider.cache.write,
786
+ provider.cache.read,
787
+ cfg.cache.requestsPerSession
788
+ );
789
+ perRequestCached = perRequestUncached * mult;
790
+ }
791
+ const daily = cfg.requestsPerDay.map((requestsPerDay) => ({
792
+ requestsPerDay,
793
+ uncached: perRequestUncached * requestsPerDay,
794
+ cached: perRequestCached === null ? null : perRequestCached * requestsPerDay
795
+ }));
796
+ let runwayDays = null;
797
+ if (cfg.monthlyBudget !== null && cfg.requestsPerDay.length > 0) {
798
+ const sorted = [...cfg.requestsPerDay].sort((a, b) => a - b);
799
+ const midRate = sorted[Math.floor(sorted.length / 2)];
800
+ const perDay = (perRequestCached ?? perRequestUncached) * midRate * cfg.developers;
801
+ if (perDay > 0) runwayDays = cfg.monthlyBudget / perDay;
802
+ }
803
+ return {
804
+ model: model.id,
805
+ provider: model.provider,
806
+ perRequestUncached,
807
+ perRequestCached,
808
+ daily,
809
+ runwayDays
810
+ };
811
+ });
812
+ }
813
+
814
+ // src/scan.ts
815
+ var TOOL_NAME = "ai-cost-audit";
816
+ var TOOL_VERSION = "0.1.0";
817
+ async function runScan(projectPath, cfg, snapshot) {
818
+ const sources = await discoverAll(projectPath, cfg);
819
+ const repoGuaranteed = sources.filter((s) => s.scope === "repo" && s.usage === "guaranteed");
820
+ const globalGuaranteed = sources.filter(
821
+ (s) => s.scope === "global" && s.usage === "guaranteed"
822
+ );
823
+ const conditional = sources.filter((s) => s.usage === "conditional");
824
+ const sum = (list) => list.reduce((total, s) => total + s.tokens, 0);
825
+ const gatedBaseline = sum(repoGuaranteed);
826
+ const globalBaseline = sum(globalGuaranteed);
827
+ const guaranteed = gatedBaseline + globalBaseline;
828
+ const byKind = {};
829
+ const byAdapter = {};
830
+ for (const s of sources) {
831
+ byKind[s.kind] = (byKind[s.kind] ?? 0) + s.tokens;
832
+ byAdapter[s.adapter] = (byAdapter[s.adapter] ?? 0) + s.tokens;
833
+ }
834
+ const anthropicCalibration = resolveProvider("anthropic", cfg).calibration;
835
+ const rawBaseline = guaranteed / anthropicCalibration;
836
+ const baselineByProvider = {};
837
+ for (const provider of cfg.providers) {
838
+ baselineByProvider[provider] = Math.round(
839
+ rawBaseline * resolveProvider(provider, cfg).calibration
840
+ );
841
+ }
842
+ const duplicates = findDuplicates(sources, cfg);
843
+ const findings = deriveFindings(sources, duplicates, gatedBaseline, snapshot, cfg);
844
+ const costs = computeCosts(baselineByProvider, cfg);
845
+ const requestRange = {
846
+ min: guaranteed + cfg.variable.conversationHistory[0] + cfg.variable.taskFiles[0],
847
+ max: guaranteed + cfg.variable.conversationHistory[1] + cfg.variable.taskFiles[1]
848
+ };
849
+ const calibration = {};
850
+ for (const provider of cfg.providers) {
851
+ calibration[provider] = resolveProvider(provider, cfg).calibration;
852
+ }
853
+ const report = {
854
+ meta: {
855
+ tool: TOOL_NAME,
856
+ version: TOOL_VERSION,
857
+ scannedAt: (/* @__PURE__ */ new Date()).toISOString(),
858
+ projectPath: path6.resolve(projectPath),
859
+ pricingAsOf: PRICING_AS_OF,
860
+ pricingStale: isPricingStale(),
861
+ calibration,
862
+ cacheFormula: cacheFormulaDescription(cfg),
863
+ disclosure: DISCLOSURE
864
+ },
865
+ totals: {
866
+ gatedBaseline,
867
+ globalBaseline,
868
+ guaranteed,
869
+ conditional: sum(conditional),
870
+ byKind,
871
+ byAdapter
872
+ },
873
+ sources: sources.map(({ text: _text, ...rest }) => rest),
874
+ costs,
875
+ requestRange,
876
+ findings
877
+ };
878
+ return { report, sources };
879
+ }
880
+
881
+ // src/report/markdown.ts
882
+ var KIND_LABELS = {
883
+ "repo-instructions": "Repository instructions",
884
+ "local-instructions": "Local instructions",
885
+ "global-instructions": "Global instructions (user)",
886
+ agent: "Agent definitions",
887
+ "skill-description": "Skill descriptions (always loaded)",
888
+ "skill-body": "Skill bodies (on demand)",
889
+ command: "Slash commands",
890
+ "mcp-config": "MCP configuration",
891
+ "cursor-rules": "Cursor rules",
892
+ "copilot-instructions": "Copilot instructions",
893
+ "referenced-doc": "Referenced documentation"
894
+ };
895
+ function formatUSD(value) {
896
+ if (value === null) return "\u2014";
897
+ if (value === 0) return "$0.00";
898
+ if (value < 0.01) return `$${value.toFixed(4)}`;
899
+ return `$${value.toFixed(2)}`;
900
+ }
901
+ function num(n) {
902
+ return n.toLocaleString("en-US");
903
+ }
904
+ function renderMarkdown(report, cfg) {
905
+ const lines = [];
906
+ const { totals, costs, findings, meta } = report;
907
+ lines.push(`# AI Context Cost Audit`);
908
+ lines.push("");
909
+ lines.push(`Scanned \`${meta.projectPath}\` at ${meta.scannedAt}`);
910
+ lines.push("");
911
+ lines.push(`## Guaranteed Context (loaded on every request)`);
912
+ lines.push("");
913
+ lines.push(`| Source kind | Tokens |`);
914
+ lines.push(`|---|---:|`);
915
+ const guaranteedSources = report.sources.filter((s) => s.usage === "guaranteed");
916
+ const kindTotals = /* @__PURE__ */ new Map();
917
+ for (const s of guaranteedSources) {
918
+ kindTotals.set(s.kind, (kindTotals.get(s.kind) ?? 0) + s.tokens);
919
+ }
920
+ for (const [kind, tokens] of [...kindTotals.entries()].sort((a, b) => b[1] - a[1])) {
921
+ lines.push(`| ${KIND_LABELS[kind] ?? kind} | ${num(tokens)} |`);
922
+ }
923
+ lines.push(`| **Repo baseline (CI-gated)** | **${num(totals.gatedBaseline)}** |`);
924
+ if (totals.globalBaseline > 0) {
925
+ lines.push(
926
+ `| Global user files (not counted toward CI budget) | ${num(totals.globalBaseline)} |`
927
+ );
928
+ }
929
+ lines.push(`| **Total baseline** | **${num(totals.guaranteed)}** |`);
930
+ lines.push("");
931
+ if (totals.conditional > 0) {
932
+ lines.push(
933
+ `Conditional context (loads for some tasks): ${num(totals.conditional)} tokens across ${report.sources.filter((s) => s.usage === "conditional").length} sources.`
934
+ );
935
+ lines.push("");
936
+ }
937
+ lines.push(`## Estimated cost per request (baseline input only)`);
938
+ lines.push("");
939
+ lines.push(`| Model | Uncached | With caching (typical) |`);
940
+ lines.push(`|---|---:|---:|`);
941
+ for (const c of costs) {
942
+ lines.push(
943
+ `| ${c.model} | ${formatUSD(c.perRequestUncached)} | ${formatUSD(c.perRequestCached)} |`
944
+ );
945
+ }
946
+ lines.push("");
947
+ lines.push(
948
+ `Cost figures use the full guaranteed baseline (repo + global). "With caching" models prompt caching: ${meta.cacheFormula}.`
949
+ );
950
+ lines.push("");
951
+ lines.push(`## Daily usage projections`);
952
+ lines.push("");
953
+ for (const c of costs) {
954
+ if (c.perRequestUncached === null) {
955
+ lines.push(`- ${c.model}: pricing not set (see config.pricingOverrides)`);
956
+ continue;
957
+ }
958
+ lines.push(`**${c.model}**`);
959
+ lines.push("");
960
+ lines.push(`| Requests/day | Uncached | With caching |`);
961
+ lines.push(`|---:|---:|---:|`);
962
+ for (const d of c.daily) {
963
+ lines.push(
964
+ `| ${num(d.requestsPerDay)} | ${formatUSD(d.uncached)}/day | ${formatUSD(d.cached)}/day |`
965
+ );
966
+ }
967
+ lines.push("");
968
+ if (c.runwayDays !== null && cfg.monthlyBudget !== null) {
969
+ const sorted = [...cfg.requestsPerDay].sort((a, b) => a - b);
970
+ const midRate = sorted[Math.floor(sorted.length / 2)];
971
+ lines.push(
972
+ `At ${num(midRate)} requests/day per developer` + (cfg.developers > 1 ? ` (${cfg.developers} developers)` : "") + `, your $${cfg.monthlyBudget}/month budget lasts ~${c.runwayDays.toFixed(1)} days.`
973
+ );
974
+ lines.push("");
975
+ }
976
+ }
977
+ lines.push(`## Typical full-request range`);
978
+ lines.push("");
979
+ lines.push(
980
+ `Baseline plus variable context (conversation history ${num(cfg.variable.conversationHistory[0])}\u2013${num(cfg.variable.conversationHistory[1])}, task files ${num(cfg.variable.taskFiles[0])}\u2013${num(cfg.variable.taskFiles[1])} tokens):`
981
+ );
982
+ lines.push("");
983
+ lines.push(
984
+ `**Estimated request range: ${num(report.requestRange.min)}\u2013${num(report.requestRange.max)} input tokens.**`
985
+ );
986
+ lines.push("");
987
+ lines.push(
988
+ `Variable context cannot be predicted exactly; these are configurable ranges, not measurements.`
989
+ );
990
+ lines.push("");
991
+ lines.push(`## High-impact findings`);
992
+ lines.push("");
993
+ if (findings.length === 0) {
994
+ lines.push(`No findings. Baseline looks lean.`);
995
+ } else {
996
+ for (const [i, f] of findings.entries()) {
997
+ lines.push(`${i + 1}. **[${f.severity}] ${f.rule}** \u2014 ${f.message}`);
998
+ }
999
+ }
1000
+ lines.push("");
1001
+ lines.push(`## All discovered sources`);
1002
+ lines.push("");
1003
+ lines.push(`| Path | Kind | Usage | Scope | Tokens | Confidence |`);
1004
+ lines.push(`|---|---|---|---|---:|---|`);
1005
+ for (const s of [...report.sources].sort((a, b) => b.tokens - a.tokens)) {
1006
+ lines.push(
1007
+ `| ${s.path} | ${s.kind} | ${s.usage} | ${s.scope} | ${num(s.tokens)} | ${s.confidence} |`
1008
+ );
1009
+ }
1010
+ lines.push("");
1011
+ const noted = report.sources.filter((s) => s.note);
1012
+ if (noted.length > 0) {
1013
+ lines.push(`Notes:`);
1014
+ for (const s of noted) {
1015
+ lines.push(`- \`${s.path}\`: ${s.note}`);
1016
+ }
1017
+ lines.push("");
1018
+ }
1019
+ lines.push(`---`);
1020
+ lines.push("");
1021
+ lines.push(`*${meta.disclosure}*`);
1022
+ lines.push("");
1023
+ lines.push(
1024
+ `*Calibration factors (applied to o200k_base counts): ${Object.entries(meta.calibration).map(([provider, factor]) => `${provider} \xD7${factor}`).join(", ")}. Pricing as of ${meta.pricingAsOf}` + (meta.pricingStale ? ` \u2014 **stale (>90 days old)**: verify current pricing and override via config.pricingOverrides` : "") + `.*`
1025
+ );
1026
+ lines.push("");
1027
+ lines.push(`*Generated by ${meta.tool} v${meta.version}.*`);
1028
+ return lines.join("\n");
1029
+ }
1030
+
1031
+ // src/report/json.ts
1032
+ function renderJson(report) {
1033
+ return JSON.stringify(report, null, 2) + "\n";
1034
+ }
1035
+
1036
+ // src/report/html.ts
1037
+ function esc(text) {
1038
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1039
+ }
1040
+ function num2(n) {
1041
+ return n.toLocaleString("en-US");
1042
+ }
1043
+ function renderHtml(report, cfg) {
1044
+ const { totals, costs, findings, meta } = report;
1045
+ const severityColor = {
1046
+ error: "#c0392b",
1047
+ warn: "#b7791f",
1048
+ info: "#2b6cb0"
1049
+ };
1050
+ const kindRows = (() => {
1051
+ const kindTotals = /* @__PURE__ */ new Map();
1052
+ for (const s of report.sources.filter((x) => x.usage === "guaranteed")) {
1053
+ kindTotals.set(s.kind, (kindTotals.get(s.kind) ?? 0) + s.tokens);
1054
+ }
1055
+ return [...kindTotals.entries()].sort((a, b) => b[1] - a[1]).map(([kind, tokens]) => `<tr><td>${esc(kind)}</td><td class="r">${num2(tokens)}</td></tr>`).join("\n");
1056
+ })();
1057
+ const costRows = costs.map(
1058
+ (c) => `<tr><td>${esc(c.model)}</td><td class="r">${formatUSD(c.perRequestUncached)}</td><td class="r">${formatUSD(c.perRequestCached)}</td></tr>`
1059
+ ).join("\n");
1060
+ const findingItems = findings.length === 0 ? `<li>No findings. Baseline looks lean.</li>` : findings.map(
1061
+ (f) => `<li><span class="sev" style="background:${severityColor[f.severity]}">${f.severity}</span> <code>${esc(f.rule)}</code> \u2014 ${esc(f.message)}</li>`
1062
+ ).join("\n");
1063
+ const sourceRows = [...report.sources].sort((a, b) => b.tokens - a.tokens).map(
1064
+ (s) => `<tr><td><code>${esc(s.path)}</code></td><td>${esc(s.kind)}</td><td>${esc(s.usage)}</td><td>${esc(s.scope)}</td><td class="r">${num2(s.tokens)}</td><td>${esc(s.confidence)}</td></tr>`
1065
+ ).join("\n");
1066
+ return `<!doctype html>
1067
+ <html lang="en">
1068
+ <head>
1069
+ <meta charset="utf-8">
1070
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1071
+ <title>AI Context Cost Audit</title>
1072
+ <style>
1073
+ :root { color-scheme: light dark; }
1074
+ body { font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; max-width: 900px; margin: 2rem auto; padding: 0 1rem; }
1075
+ h1 { font-size: 1.6rem; } h2 { font-size: 1.2rem; margin-top: 2rem; }
1076
+ table { border-collapse: collapse; width: 100%; margin: .75rem 0; }
1077
+ th, td { text-align: left; padding: .35rem .6rem; border-bottom: 1px solid rgba(128,128,128,.3); }
1078
+ td.r, th.r { text-align: right; font-variant-numeric: tabular-nums; }
1079
+ tr.total td { font-weight: 700; border-top: 2px solid rgba(128,128,128,.5); }
1080
+ code { font: 13px ui-monospace, Menlo, monospace; }
1081
+ .sev { color: #fff; font-size: 11px; padding: 1px 6px; border-radius: 3px; text-transform: uppercase; }
1082
+ .muted { opacity: .7; font-size: 13px; }
1083
+ li { margin: .4rem 0; }
1084
+ .big { font-size: 1.1rem; font-weight: 600; }
1085
+ </style>
1086
+ </head>
1087
+ <body>
1088
+ <h1>AI Context Cost Audit</h1>
1089
+ <p class="muted">Scanned <code>${esc(meta.projectPath)}</code> at ${esc(meta.scannedAt)}</p>
1090
+
1091
+ <h2>Guaranteed context (loaded on every request)</h2>
1092
+ <table>
1093
+ <tr><th>Source kind</th><th class="r">Tokens</th></tr>
1094
+ ${kindRows}
1095
+ <tr class="total"><td>Repo baseline (CI-gated)</td><td class="r">${num2(totals.gatedBaseline)}</td></tr>
1096
+ ${totals.globalBaseline > 0 ? `<tr><td>Global user files (not gated)</td><td class="r">${num2(totals.globalBaseline)}</td></tr>` : ""}
1097
+ <tr class="total"><td>Total baseline</td><td class="r">${num2(totals.guaranteed)}</td></tr>
1098
+ </table>
1099
+
1100
+ <h2>Estimated cost per request (baseline input only)</h2>
1101
+ <table>
1102
+ <tr><th>Model</th><th class="r">Uncached</th><th class="r">With caching (typical)</th></tr>
1103
+ ${costRows}
1104
+ </table>
1105
+ <p class="muted">${esc(meta.cacheFormula)}</p>
1106
+
1107
+ <h2>Typical full-request range</h2>
1108
+ <p class="big">${num2(report.requestRange.min)}&ndash;${num2(report.requestRange.max)} input tokens</p>
1109
+ <p class="muted">Baseline + conversation history (${num2(cfg.variable.conversationHistory[0])}&ndash;${num2(cfg.variable.conversationHistory[1])}) + task files (${num2(cfg.variable.taskFiles[0])}&ndash;${num2(cfg.variable.taskFiles[1])}). Ranges, not measurements.</p>
1110
+
1111
+ <h2>High-impact findings</h2>
1112
+ <ol>
1113
+ ${findingItems}
1114
+ </ol>
1115
+
1116
+ <h2>All discovered sources</h2>
1117
+ <table>
1118
+ <tr><th>Path</th><th>Kind</th><th>Usage</th><th>Scope</th><th class="r">Tokens</th><th>Confidence</th></tr>
1119
+ ${sourceRows}
1120
+ </table>
1121
+
1122
+ <hr>
1123
+ <p class="muted">${esc(meta.disclosure)}</p>
1124
+ <p class="muted">Calibration: ${esc(
1125
+ Object.entries(meta.calibration).map(([provider, factor]) => `${provider} \xD7${factor}`).join(", ")
1126
+ )}. Pricing as of ${esc(meta.pricingAsOf)}${meta.pricingStale ? " \u2014 <strong>stale, verify and override via config</strong>" : ""}.</p>
1127
+ <p class="muted">Generated by ${esc(meta.tool)} v${esc(meta.version)}.</p>
1128
+ </body>
1129
+ </html>
1130
+ `;
1131
+ }
1132
+
1133
+ // src/snapshot.ts
1134
+ import { mkdir, readFile as readFile6, writeFile } from "fs/promises";
1135
+ import { existsSync as existsSync5 } from "fs";
1136
+ import path7 from "path";
1137
+ var SNAPSHOT_DIR = ".ai-cost-audit";
1138
+ var SNAPSHOT_FILE = "snapshot.json";
1139
+ function snapshotPath(projectPath) {
1140
+ return path7.join(projectPath, SNAPSHOT_DIR, SNAPSHOT_FILE);
1141
+ }
1142
+ async function readSnapshot(projectPath) {
1143
+ const file = snapshotPath(projectPath);
1144
+ if (!existsSync5(file)) return null;
1145
+ try {
1146
+ const parsed = JSON.parse(await readFile6(file, "utf8"));
1147
+ if (parsed.version !== 1 || !Array.isArray(parsed.sources)) return null;
1148
+ return parsed;
1149
+ } catch {
1150
+ return null;
1151
+ }
1152
+ }
1153
+ async function writeSnapshot(projectPath, sources, gatedBaseline) {
1154
+ const snapshot = {
1155
+ version: 1,
1156
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1157
+ gatedBaseline,
1158
+ // Per-source tokens (repo-scoped guaranteed only) so growth attribution works.
1159
+ sources: sources.filter((s) => s.scope === "repo" && s.usage === "guaranteed").map((s) => ({ path: s.path, tokens: s.tokens })).sort((a, b) => a.path.localeCompare(b.path))
1160
+ };
1161
+ const file = snapshotPath(projectPath);
1162
+ await mkdir(path7.dirname(file), { recursive: true });
1163
+ await writeFile(file, JSON.stringify(snapshot, null, 2) + "\n", "utf8");
1164
+ return file;
1165
+ }
1166
+
1167
+ // src/budget.ts
1168
+ var EXIT_PASS = 0;
1169
+ var EXIT_VIOLATION = 1;
1170
+ var EXIT_ERROR = 2;
1171
+ function evaluateBudget(gatedBaseline, sources, snapshot, cfg) {
1172
+ const messages = [];
1173
+ let pass = true;
1174
+ if (cfg.baselineTokenLimit !== null && gatedBaseline > cfg.baselineTokenLimit) {
1175
+ pass = false;
1176
+ messages.push(
1177
+ `AI context budget failed: baseline ${gatedBaseline.toLocaleString()} tokens exceeds limit ${cfg.baselineTokenLimit.toLocaleString()}.`
1178
+ );
1179
+ }
1180
+ if (snapshot && snapshot.gatedBaseline > 0) {
1181
+ const growthPct = (gatedBaseline - snapshot.gatedBaseline) / snapshot.gatedBaseline * 100;
1182
+ if (growthPct > cfg.growthThresholdPct) {
1183
+ pass = false;
1184
+ const causes = topGrowthCauses(sources, snapshot);
1185
+ messages.push(
1186
+ `AI context budget failed: baseline grew ${growthPct.toFixed(0)}% (threshold ${cfg.growthThresholdPct}%).
1187
+ Previous: ${snapshot.gatedBaseline.toLocaleString()} tokens
1188
+ Current: ${gatedBaseline.toLocaleString()} tokens
1189
+ Change: ${growthPct >= 0 ? "+" : ""}${growthPct.toFixed(0)}%` + (causes.length > 0 ? `
1190
+ Primary cause: ${causes.join("; ")}` : "")
1191
+ );
1192
+ } else {
1193
+ messages.push(
1194
+ `Baseline vs snapshot: ${snapshot.gatedBaseline.toLocaleString()} \u2192 ${gatedBaseline.toLocaleString()} tokens (${growthPct >= 0 ? "+" : ""}${growthPct.toFixed(1)}%, within ${cfg.growthThresholdPct}% threshold).`
1195
+ );
1196
+ }
1197
+ } else if (!snapshot) {
1198
+ messages.push(
1199
+ `No snapshot found (${cfg.growthThresholdPct}% growth check skipped). Run \`ai-cost-audit scan --update-snapshot\` and commit .ai-cost-audit/snapshot.json.`
1200
+ );
1201
+ }
1202
+ if (pass && cfg.baselineTokenLimit !== null) {
1203
+ messages.push(
1204
+ `Baseline ${gatedBaseline.toLocaleString()} tokens within limit ${cfg.baselineTokenLimit.toLocaleString()}.`
1205
+ );
1206
+ }
1207
+ return { pass, messages };
1208
+ }
1209
+
1210
+ // src/cli.ts
1211
+ var program = new Command();
1212
+ program.name(TOOL_NAME).description(
1213
+ "AI context cost profiler and linter \u2014 know what every AI coding request costs before it's sent"
1214
+ ).version(TOOL_VERSION);
1215
+ program.command("scan").description("Scan a repository for AI context sources and report baseline cost").argument("[path]", "project path to scan", ".").option("-c, --config <file>", "path to config file (default: ./ai-cost-audit.json)").option("-f, --format <format>", "output format: md | json | html", "md").option("-o, --out <file>", "write the report to a file instead of stdout").option("--ci", "run the budget gate: exit 1 on limit breach or growth over threshold").option("--update-snapshot", "write .ai-cost-audit/snapshot.json (commit it for CI diffs)").option("--no-global", "skip user-global files (~/.claude)").option("--ref-depth <n>", "how many levels of @imports / links to follow", parseIntArg).action(async (projectPath, options) => {
1216
+ try {
1217
+ const resolved = path8.resolve(projectPath);
1218
+ const { config, configPath } = await loadConfig(resolved, options.config);
1219
+ if (options.global === false) config.includeGlobal = false;
1220
+ if (options.refDepth !== void 0) config.refDepth = options.refDepth;
1221
+ const snapshot = await readSnapshot(resolved);
1222
+ const { report, sources } = await runScan(resolved, config, snapshot);
1223
+ const format = String(options.format).toLowerCase();
1224
+ let output;
1225
+ if (format === "json") output = renderJson(report);
1226
+ else if (format === "html") output = renderHtml(report, config);
1227
+ else if (format === "md") output = renderMarkdown(report, config);
1228
+ else {
1229
+ process.stderr.write(pc.red(`Unknown format: ${options.format} (use md, json, or html)
1230
+ `));
1231
+ process.exit(EXIT_ERROR);
1232
+ return;
1233
+ }
1234
+ if (options.out) {
1235
+ const outPath = path8.resolve(options.out);
1236
+ await writeFile2(outPath, output, "utf8");
1237
+ process.stderr.write(pc.green(`Report written to ${outPath}
1238
+ `));
1239
+ } else {
1240
+ process.stdout.write(format === "md" ? colorize(output) : output);
1241
+ }
1242
+ if (configPath === null) {
1243
+ process.stderr.write(
1244
+ pc.dim(`(no ai-cost-audit.json found \u2014 ran with defaults)
1245
+ `)
1246
+ );
1247
+ }
1248
+ if (options.updateSnapshot) {
1249
+ const file = await writeSnapshot(resolved, sources, report.totals.gatedBaseline);
1250
+ process.stderr.write(pc.green(`Snapshot written to ${file} \u2014 commit it for CI diffs.
1251
+ `));
1252
+ }
1253
+ if (options.ci) {
1254
+ const gate = evaluateBudget(report.totals.gatedBaseline, sources, snapshot, config);
1255
+ for (const message of gate.messages) {
1256
+ process.stderr.write(
1257
+ (gate.pass ? pc.green(message) : pc.red(message)) + "\n"
1258
+ );
1259
+ }
1260
+ process.exit(gate.pass ? EXIT_PASS : EXIT_VIOLATION);
1261
+ }
1262
+ } catch (err) {
1263
+ process.stderr.write(
1264
+ pc.red(`Error: ${err instanceof Error ? err.message : String(err)}
1265
+ `)
1266
+ );
1267
+ process.exit(EXIT_ERROR);
1268
+ }
1269
+ });
1270
+ function parseIntArg(value) {
1271
+ const n = Number.parseInt(value, 10);
1272
+ if (Number.isNaN(n) || n < 0) throw new Error(`Invalid number: ${value}`);
1273
+ return n;
1274
+ }
1275
+ function colorize(markdown) {
1276
+ return markdown.split("\n").map((line) => {
1277
+ if (line.startsWith("# ")) return pc.bold(pc.cyan(line));
1278
+ if (line.startsWith("## ")) return pc.bold(line);
1279
+ if (/^\d+\. \*\*\[error\]/.test(line)) return pc.red(line);
1280
+ if (/^\d+\. \*\*\[warn\]/.test(line)) return pc.yellow(line);
1281
+ if (/^\d+\. \*\*\[info\]/.test(line)) return pc.blue(line);
1282
+ if (line.startsWith("*") && line.endsWith("*")) return pc.dim(line);
1283
+ return line;
1284
+ }).join("\n");
1285
+ }
1286
+ program.parseAsync(process.argv);