feat(ai-spend): Claude Code token & cost analytics#205
Conversation
|
Warning Review limit reached
More reviews will be available in 13 minutes and 49 seconds. Learn how PR review limits work. Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file). ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (13)
📝 WalkthroughWalkthroughA new Changesai-spend CLI Tool
Sequence Diagram(s)sequenceDiagram
participant User
participant CLI as ai-spend CLI (index.ts)
participant Discover as findTranscriptFiles / readEvents
participant Config as loadPricing (Storage)
participant Agg as aggregate
participant Render as renderSummary / renderSessions / renderToday
User->>CLI: run subcommand (summary|sessions|today) with options
CLI->>Config: loadPricing(storage)
Config-->>CLI: PricingTable (defaults + user overrides)
CLI->>Discover: findTranscriptFiles(homeDir)
Discover-->>CLI: string[] of *.jsonl paths
CLI->>Discover: readEvents(files)
Discover-->>CLI: UsageEvent[]
CLI->>Agg: aggregate({ events, pricing, sinceDay, model, project, top, now })
Agg->>Agg: deduplicate by messageId
Agg->>Agg: passesFilters per event
Agg->>Agg: accumulate TokenTotals + costs across model/day/project/session
Agg-->>CLI: Report
CLI->>Render: renderSummary(report) / renderSessions / renderToday
Render-->>CLI: formatted string
CLI-->>User: stdout (text or JSON)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Code Review
This pull request introduces the ai-spend tool, which analyzes and reports Claude Code token usage and costs across local sessions. It includes CLI commands, transcript parsing, cost aggregation, and console rendering. Feedback focuses on optimizing the aggregation logic from O(N^2) to O(N) complexity to prevent lag with large datasets, and improving the robustness of transcript file discovery against file system errors by handling potential exceptions and avoiding hardcoded paths.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| export function aggregate(args: AggregateArgs): Report { | ||
| const { events, pricing, now } = args; | ||
| const top = args.top ?? 10; | ||
|
|
||
| const seen = new Set<string>(); | ||
| const kept: UsageEvent[] = []; | ||
| for (const ev of events) { | ||
| if (seen.has(ev.messageId)) { | ||
| continue; | ||
| } | ||
|
|
||
| seen.add(ev.messageId); | ||
| if (passesFilters(ev, args)) { | ||
| kept.push(ev); | ||
| } | ||
| } | ||
|
|
||
| const totalTokens = emptyTotals(); | ||
| const byModel = new Map<string, { tokens: TokenTotals; priced: boolean }>(); | ||
| const byDay = new Map<string, TokenTotals>(); | ||
| const byProject = new Map<string, { tokens: TokenTotals; sessions: Set<string> }>(); | ||
| const bySession = new Map<string, { tokens: TokenTotals; project: string; lastDay: string }>(); | ||
|
|
||
| for (const ev of kept) { | ||
| addTokens(totalTokens, ev); | ||
|
|
||
| const model = byModel.get(ev.model) ?? { tokens: emptyTotals(), priced: priceFor(ev.model, pricing) !== null }; | ||
| addTokens(model.tokens, ev); | ||
| byModel.set(ev.model, model); | ||
|
|
||
| const day = dayOf(ev.timestamp); | ||
| const dayTok = byDay.get(day) ?? emptyTotals(); | ||
| addTokens(dayTok, ev); | ||
| byDay.set(day, dayTok); | ||
|
|
||
| const proj = byProject.get(ev.project) ?? { tokens: emptyTotals(), sessions: new Set<string>() }; | ||
| addTokens(proj.tokens, ev); | ||
| proj.sessions.add(ev.sessionId); | ||
| byProject.set(ev.project, proj); | ||
|
|
||
| const sess = bySession.get(ev.sessionId) ?? { tokens: emptyTotals(), project: ev.project, lastDay: day }; | ||
| addTokens(sess.tokens, ev); | ||
| if (day > sess.lastDay) { | ||
| sess.lastDay = day; | ||
| } | ||
|
|
||
| bySession.set(ev.sessionId, sess); | ||
| } | ||
|
|
||
| const models: ModelBreakdown[] = [...byModel.entries()] | ||
| .map(([model, v]) => { | ||
| const price = priceFor(model, pricing); | ||
| return { | ||
| model, | ||
| priced: v.priced, | ||
| tokens: v.tokens, | ||
| totalTokens: sumTokens(v.tokens), | ||
| cost: price ? costOf(v.tokens, price) : 0, | ||
| }; | ||
| }) | ||
| .sort((a, b) => b.cost - a.cost || b.totalTokens - a.totalTokens); | ||
|
|
||
| const days: DayBreakdown[] = [...byDay.entries()] | ||
| .map(([day, tokens]) => ({ day, totalTokens: sumTokens(tokens), cost: dayCost(kept, day, pricing) })) | ||
| .sort((a, b) => a.day.localeCompare(b.day)); | ||
|
|
||
| const projects: ProjectBreakdown[] = [...byProject.entries()] | ||
| .map(([project, v]) => ({ | ||
| project, | ||
| sessions: v.sessions.size, | ||
| totalTokens: sumTokens(v.tokens), | ||
| cost: eventsCost( | ||
| kept.filter((e) => e.project === project), | ||
| pricing | ||
| ), | ||
| })) | ||
| .sort((a, b) => b.cost - a.cost || b.totalTokens - a.totalTokens) | ||
| .slice(0, top); | ||
|
|
||
| const sessions: SessionBreakdown[] = [...bySession.entries()] | ||
| .map(([sessionId, v]) => ({ | ||
| sessionId, | ||
| project: v.project, | ||
| lastDay: v.lastDay, | ||
| totalTokens: sumTokens(v.tokens), | ||
| cost: eventsCost( | ||
| kept.filter((e) => e.sessionId === sessionId), | ||
| pricing | ||
| ), | ||
| })) | ||
| .sort((a, b) => b.cost - a.cost || b.totalTokens - a.totalTokens) | ||
| .slice(0, top); | ||
|
|
||
| const allDays = [...byDay.keys()].sort(); | ||
| const cacheHitDenom = totalTokens.input + totalTokens.cacheRead; | ||
|
|
||
| return { | ||
| windowStartDay: args.sinceDay ?? allDays[0] ?? dayOf(now.toISOString()), | ||
| windowEndDay: allDays.at(-1) ?? dayOf(now.toISOString()), | ||
| projectCount: byProject.size, | ||
| sessionCount: bySession.size, | ||
| total: { | ||
| tokens: totalTokens, | ||
| totalTokens: sumTokens(totalTokens), | ||
| cost: eventsCost(kept, pricing), | ||
| cacheHitRate: cacheHitDenom === 0 ? 0 : totalTokens.cacheRead / cacheHitDenom, | ||
| }, | ||
| days, | ||
| models: models.slice(0, top), | ||
| projects, | ||
| sessions, | ||
| }; | ||
| } |
There was a problem hiding this comment.
The current implementation of aggregate has a performance bottleneck with quadratic eventsCost and dayCost. This can cause significant lag when processing large numbers of local Claude Code transcripts.
We can optimize this to
export function aggregate(args: AggregateArgs): Report {
const { events, pricing, now } = args;
const top = args.top ?? 10;
const seen = new Set<string>();
const kept: UsageEvent[] = [];
for (const ev of events) {
if (seen.has(ev.messageId)) {
continue;
}
seen.add(ev.messageId);
if (passesFilters(ev, args)) {
kept.push(ev);
}
}
const totalTokens = emptyTotals();
let totalCost = 0;
const byModel = new Map<string, { tokens: TokenTotals; priced: boolean; cost: number }>();
const byDay = new Map<string, { tokens: TokenTotals; cost: number }>();
const byProject = new Map<string, { tokens: TokenTotals; sessions: Set<string>; cost: number }>();
const bySession = new Map<string, { tokens: TokenTotals; project: string; lastDay: string; cost: number }>();
for (const ev of kept) {
addTokens(totalTokens, ev);
const price = priceFor(ev.model, pricing);
const evCost = price ? costOf({
input: ev.inputTokens,
output: ev.outputTokens,
cacheWrite: ev.cacheCreationTokens,
cacheRead: ev.cacheReadTokens
}, price) : 0;
totalCost += evCost;
const model = byModel.get(ev.model) ?? { tokens: emptyTotals(), priced: price !== null, cost: 0 };
addTokens(model.tokens, ev);
model.cost += evCost;
byModel.set(ev.model, model);
const day = dayOf(ev.timestamp);
const dayTok = byDay.get(day) ?? { tokens: emptyTotals(), cost: 0 };
addTokens(dayTok.tokens, ev);
dayTok.cost += evCost;
byDay.set(day, dayTok);
const proj = byProject.get(ev.project) ?? { tokens: emptyTotals(), sessions: new Set<string>(), cost: 0 };
addTokens(proj.tokens, ev);
proj.sessions.add(ev.sessionId);
proj.cost += evCost;
byProject.set(ev.project, proj);
const sess = bySession.get(ev.sessionId) ?? { tokens: emptyTotals(), project: ev.project, lastDay: day, cost: 0 };
addTokens(sess.tokens, ev);
if (day > sess.lastDay) {
sess.lastDay = day;
}
sess.cost += evCost;
bySession.set(ev.sessionId, sess);
}
const models: ModelBreakdown[] = [...byModel.entries()]
.map(([model, v]) => ({
model,
priced: v.priced,
tokens: v.tokens,
totalTokens: sumTokens(v.tokens),
cost: v.cost,
}))
.sort((a, b) => b.cost - a.cost || b.totalTokens - a.totalTokens);
const days: DayBreakdown[] = [...byDay.entries()]
.map(([day, v]) => ({ day, totalTokens: sumTokens(v.tokens), cost: v.cost }))
.sort((a, b) => a.day.localeCompare(b.day));
const projects: ProjectBreakdown[] = [...byProject.entries()]
.map(([project, v]) => ({
project,
sessions: v.sessions.size,
totalTokens: sumTokens(v.tokens),
cost: v.cost,
}))
.sort((a, b) => b.cost - a.cost || b.totalTokens - a.totalTokens)
.slice(0, top);
const sessions: SessionBreakdown[] = [...bySession.entries()]
.map(([sessionId, v]) => ({
sessionId,
project: v.project,
lastDay: v.lastDay,
totalTokens: sumTokens(v.tokens),
cost: v.cost,
}))
.sort((a, b) => b.cost - a.cost || b.totalTokens - a.totalTokens)
.slice(0, top);
const allDays = [...byDay.keys()].sort();
const cacheHitDenom = totalTokens.input + totalTokens.cacheRead;
return {
windowStartDay: args.sinceDay ?? allDays[0] ?? dayOf(now.toISOString()),
windowEndDay: allDays.at(-1) ?? dayOf(now.toISOString()),
projectCount: byProject.size,
sessionCount: bySession.size,
total: {
tokens: totalTokens,
totalTokens: sumTokens(totalTokens),
cost: totalCost,
cacheHitRate: cacheHitDenom === 0 ? 0 : totalTokens.cacheRead / cacheHitDenom,
},
days,
models: models.slice(0, top),
projects,
sessions,
};
}There was a problem hiding this comment.
/gemini Fixed in f58a5da - aggregate now accumulates per-event cost into per-model/day/project/session maps and a totalCost inside the existing single for-loop over kept, dropping dayCost and the per-group filter+eventsCost re-scans. Now O(N).
There was a problem hiding this comment.
Thank you for the update, genesiscz. I have reviewed the changes in commit f58a5da. Moving the cost accumulation into the main loop is the correct approach to achieve
| export function findTranscriptFiles(homeDir: string): string[] { | ||
| const root = join(homeDir, ".claude", "projects"); | ||
| if (!existsSync(root)) { | ||
| logger.debug({ root }, "ai-spend: no Claude Code projects dir"); | ||
| return []; | ||
| } | ||
|
|
||
| const out: string[] = []; | ||
| for (const entry of readdirSync(root)) { | ||
| const dir = join(root, entry); | ||
| if (!statSync(dir).isDirectory()) { | ||
| continue; | ||
| } | ||
|
|
||
| for (const file of readdirSync(dir)) { | ||
| if (file.endsWith(".jsonl")) { | ||
| out.push(join(dir, file)); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| logger.debug({ count: out.length }, "ai-spend: discovered transcript files"); | ||
| return out; | ||
| } |
There was a problem hiding this comment.
The current implementation of findTranscriptFiles is prone to crashing if there are broken symlinks or permission issues in the ~/.claude/projects directory, as statSync and readdirSync can throw errors. Additionally, calling statSync on every entry is less efficient than using readdirSync(root, { withFileTypes: true }).
To improve this:
- Use
readdirSync(root, { withFileTypes: true })and wrap the directory reading intry-catchblocks. When catching errors, avoid swallowing them silently; log unexpected errors or handle specific ones to prevent silent failures. - Avoid hardcoding the directory path (like
~/.claude/projects). Centralize shared configuration values and directory paths into a single source of truth (e.g., a dedicatedpaths.tsutility) to eliminate duplication and ensure consistency across multiple modules.
import { CLAUDE_DIR } from '../paths';
export function findTranscriptFiles(homeDir: string): string[] {
try {
const root = join(homeDir, CLAUDE_DIR);
const entries = readdirSync(root, { withFileTypes: true });
return entries
.filter(entry => entry.isFile() && entry.name.endsWith('.jsonl'))
.map(entry => join(root, entry.name));
} catch (error) {
console.error('Failed to read transcript files:', error);
return [];
}
}References
- Centralize shared configuration values and directory paths into a single source of truth (e.g., a dedicated
paths.tsutility) to eliminate duplication and ensure consistency across multiple modules. - When catching errors, avoid swallowing all errors silently. Instead, catch specific error types or re-throw unexpected errors to prevent silent failures. Logging unexpected errors is also a good practice.
There was a problem hiding this comment.
/gemini Fixed in f58a5da - the filesystem error-handling concern: each readdir/stat call is now guarded with per-entry try/catch that logs via logger.warn and continues. Declining the rest of the suggested rewrite though: (1) it reads jsonl files flat under the projects dir, but the real layout is one project-dir level down (projects//.jsonl), so a flat read finds zero files and breaks the discovery test; (2) findTranscriptFiles(homeDir) takes homeDir as a param by design so tests can inject a tmpdir, so it cannot use the homedir-bound PROJECTS_DIR constant; (3) console.error violates our @app/logger convention.
There was a problem hiding this comment.
Thank you for the detailed follow-up, genesiscz. I appreciate the clarification on the project directory structure and the rationale behind the design choices. The per-entry try-catch approach for filesystem operations is a robust way to handle potential errors while maintaining the required flexibility for your discovery logic. Given these constraints, your implementation is well-reasoned and addresses the core stability concerns while respecting the existing architectural patterns.
|
@coderabbitai review |
✅ Action performedReview finished.
|
|
@coderabbitai review |
✅ Action performedReview finished.
|
|
@coderabbitai review |
✅ Action performedReview finished.
|
|
@coderabbitai review |
✅ Action performedReview finished.
|
There was a problem hiding this comment.
Actionable comments posted: 8
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/ai-spend/ai-spend.test.ts`:
- Around line 199-210: The environment variable restoration in the finally block
is assigning the value back with process.env.TZ = prev, which will set the
variable to the string "undefined" if it was originally unset. Instead, add a
conditional check: if prev === undefined, use delete process.env.TZ to properly
unset the variable; otherwise use process.env.TZ = prev. Apply this same fix
pattern wherever process environment variables are being restored after tests
(search for similar try/finally blocks that save and restore process.env
values).
In `@src/ai-spend/index.ts`:
- Around line 38-39: The `top` variable on line 38 accepts invalid values
including NaN (from non-numeric input), zero, and negative numbers due to
missing validation after Number.parseInt(). Add validation after parsing
opts.top to ensure it is a positive integer greater than zero, either by
checking the parsed value and throwing an error or using a fallback, before
passing it to the aggregate function on line 39.
In `@src/ai-spend/lib/aggregate.ts`:
- Around line 144-170: The code repeatedly filters the kept array and calls
eventsCost for each day, project, and session breakdown, creating quadratic
complexity. Instead, create cost accumulator Maps (similar to the existing token
maps) during the main for loop that processes kept events. As each event is
processed, accumulate its cost into the appropriate day, project, and session
cost accumulators. Then when building DayBreakdown, ProjectBreakdown, and
SessionBreakdown arrays, retrieve the pre-calculated costs from these
accumulators instead of filtering kept and calling dayCost or eventsCost again.
In `@src/ai-spend/lib/config.ts`:
- Line 17: The shallow merge using spread operators on line 17 causes complete
replacement of model entries instead of merging them at the property level. When
a user provides a partial override like { pricing: { "claude-opus-4": { input: 1
} } }, it replaces the entire model entry and drops default output and cache_*
properties, leading to NaN values in downstream calculations. Implement a deep
merge that iterates through config.pricing entries and combines each model's
properties with its DEFAULT_PRICING counterpart, ensuring that partial overrides
preserve unspecified default properties rather than removing them.
In `@src/ai-spend/lib/discover.ts`:
- Around line 16-23: The filesystem operations in the discovery loop
(readdirSync(root), statSync(dir), and readdirSync(dir)) can throw errors due to
permissions, TOCTOU race conditions, or broken symlinks, causing the entire
command to fail. Wrap the outer loop starting at readdirSync(root) in a
try-catch block to skip problematic entries, and wrap the statSync(dir) call and
inner readdirSync(dir) call in separate try-catch blocks to skip directories
that cannot be accessed. When any filesystem operation fails, log the error and
continue to the next iteration instead of propagating the exception.
In `@src/ai-spend/lib/parse.ts`:
- Line 38: The SafeJSON.parse call for the JSONL input needs to enforce strict
RFC 8259 compliance. Modify the SafeJSON.parse() call that parses the trimmed
input to include the strict mode option by adding a second parameter with {
strict: true } to the parse invocation. This ensures that only valid RFC 8259
JSON is accepted at this non-config ingestion boundary, rejecting any lenient or
comment-json formatted input.
- Around line 36-45: The SafeJSON.parse call on trimmed can return valid JSON
that is not an object (such as null, a string, or a number), but the code
immediately accesses raw.type without guarding against non-object values,
causing a runtime crash. After the SafeJSON.parse assignment and before the
check for raw.type !== "assistant", add a guard condition to verify that raw is
actually an object with the expected shape (for example, checking that raw is
not null and is of type object, and optionally that it has a type property),
returning null if the guard fails to safely skip malformed lines without
crashing.
In `@src/ai-spend/lib/since.ts`:
- Around line 15-16: The ISO_DAY_PATTERN regex test in the condition at lines
15-16 only validates the format of the date string (YYYY-MM-DD shape) but does
not verify that the date values themselves are valid, allowing impossible dates
like February 30th to pass through and be used as-is. After the regex test
passes for the trimmed value, add date validation logic to parse the ISO string
and check if it represents an actual valid date; only return trimmed if the date
is valid, otherwise allow the code to continue to the fallback logic.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 680ec003-058c-428c-af1a-ac4a83c3c42f
📒 Files selected for processing (10)
src/ai-spend/ai-spend.test.tssrc/ai-spend/index.tssrc/ai-spend/lib/aggregate.tssrc/ai-spend/lib/config.tssrc/ai-spend/lib/discover.tssrc/ai-spend/lib/parse.tssrc/ai-spend/lib/pricing.tssrc/ai-spend/lib/render.tssrc/ai-spend/lib/since.tssrc/ai-spend/lib/types.ts
📜 Review details
🧰 Additional context used
📓 Path-based instructions (6)
src/**/*.ts
📄 CodeRabbit inference engine (CLAUDE.md)
src/**/*.ts: Always import and useSafeJSONfrom@app/utils/jsoninstead of the globalJSONobject; useSafeJSON.parse()andSafeJSON.stringify()everywhere
Uselogger(from@app/logger) for diagnostics; write to day-stamped file always and to stderr only when log level permits; useout.result()as the only writer to stdout
Log enough context to triage issues from logs alone without re-running the tool; log key decision branches, external-resource access, mode/config resolution, and result counts
Prefererror: errovererror: err instanceof Error ? err.message : String(err)when the error field accepts unknown type
Files:
src/ai-spend/lib/since.tssrc/ai-spend/lib/config.tssrc/ai-spend/lib/parse.tssrc/ai-spend/lib/render.tssrc/ai-spend/lib/discover.tssrc/ai-spend/lib/pricing.tssrc/ai-spend/index.tssrc/ai-spend/lib/types.tssrc/ai-spend/ai-spend.test.tssrc/ai-spend/lib/aggregate.ts
src/**/*.{ts,tsx}
📄 CodeRabbit inference engine (CLAUDE.md)
src/**/*.{ts,tsx}: Do not useas anytype assertions; use proper type narrowing, type guards, or explicit interfaces instead
When working with union types, use discriminant checks (e.g.entity.className === "User") instead of type assertions
Never use barecatch {}blocks; at minimum log the caught error with context usinglogger.debug()orlogger.warn()
For functions with 3+ parameters or optional parameters, use an object parameter instead of positional parameters
Do not use one-lineifstatements, even for early returns; always use block form with braces
Add an empty line beforeifstatements unless the preceding line is a variable declaration used by thatif
Add an empty line after closing}unless followed byelse,catch,finally, or another}
Always checkisInteractive()from@app/utils/clibefore showing prompts; provide sensible defaults or error withsuggestCommand()in non-interactive mode
Files:
src/ai-spend/lib/since.tssrc/ai-spend/lib/config.tssrc/ai-spend/lib/parse.tssrc/ai-spend/lib/render.tssrc/ai-spend/lib/discover.tssrc/ai-spend/lib/pricing.tssrc/ai-spend/index.tssrc/ai-spend/lib/types.tssrc/ai-spend/ai-spend.test.tssrc/ai-spend/lib/aggregate.ts
**/*.ts
📄 CodeRabbit inference engine (CLAUDE.md)
**/*.ts: Never add a file-path comment as the first line of files (e.g.,// src/path/to/file.ts)
Do not add comments that restate what the code already says; avoid obvious comments like// Build initial contextbeforebuildContext()
Files:
src/ai-spend/lib/since.tssrc/ai-spend/lib/config.tssrc/ai-spend/lib/parse.tssrc/ai-spend/lib/render.tssrc/ai-spend/lib/discover.tssrc/ai-spend/lib/pricing.tssrc/ai-spend/index.tssrc/ai-spend/lib/types.tssrc/ai-spend/ai-spend.test.tssrc/ai-spend/lib/aggregate.ts
src/**/index.ts
📄 CodeRabbit inference engine (CLAUDE.md)
src/**/index.ts: Tool entry points must use a TypeScript file with shebang that shows an interactive tool selector when run without arguments, and executes the specified tool viabun runwhen given arguments
Tool entry points must end withawait runTool(program, { tool })from@app/utils/cli, which owns-v/--readme/help registration and console-level resolution
Files:
src/ai-spend/index.ts
src/*/index.ts
📄 CodeRabbit inference engine (CLAUDE.md)
Tool directories must contain either
index.ts/index.tsxor standalone.ts/.tsxfiles; tool name is derived from directory name or filename without extension
Files:
src/ai-spend/index.ts
src/**/*.test.ts
📄 CodeRabbit inference engine (CLAUDE.md)
When adding database logic, use an in-memory
new Database(":memory:")for testing; group test files alongside source files
Files:
src/ai-spend/ai-spend.test.ts
🧠 Learnings (22)
📚 Learning: 2026-02-24T15:32:37.494Z
Learnt from: genesiscz
Repo: genesiscz/GenesisTools PR: 54
File: src/github/lib/output.ts:109-113
Timestamp: 2026-02-24T15:32:37.494Z
Learning: In TypeScript files under src/, do not require a leading blank line before an if statement that is the first statement inside a function body (immediately after the function signature). The blank line rule should only apply to if statements that come after other statements within the function body. Apply this guideline consistently across TS files in src to reduce unnecessary vertical whitespace and keep concise function bodies.
Applied to files:
src/ai-spend/lib/since.tssrc/ai-spend/lib/config.tssrc/ai-spend/lib/parse.tssrc/ai-spend/lib/render.tssrc/ai-spend/lib/discover.tssrc/ai-spend/lib/pricing.tssrc/ai-spend/index.tssrc/ai-spend/lib/types.tssrc/ai-spend/ai-spend.test.tssrc/ai-spend/lib/aggregate.ts
📚 Learning: 2026-03-09T13:13:58.786Z
Learnt from: genesiscz
Repo: genesiscz/GenesisTools PR: 81
File: src/github/commands/get.ts:209-212
Timestamp: 2026-03-09T13:13:58.786Z
Learning: In the GenesisTools repo (genesiscz/GenesisTools), do not treat CI formatter warnings as enforceable formatting rules for TypeScript files under src/. Focus reviews on logical correctness and consistency with existing code patterns. For files under src (e.g., src/github/commands/get.ts), prioritize code structure, readability, naming, correctness, and adherence to project conventions over automated formatting warnings from CI tools.
Applied to files:
src/ai-spend/lib/since.tssrc/ai-spend/lib/config.tssrc/ai-spend/lib/parse.tssrc/ai-spend/lib/render.tssrc/ai-spend/lib/discover.tssrc/ai-spend/lib/pricing.tssrc/ai-spend/index.tssrc/ai-spend/lib/types.tssrc/ai-spend/ai-spend.test.tssrc/ai-spend/lib/aggregate.ts
📚 Learning: 2026-03-12T01:26:31.610Z
Learnt from: genesiscz
Repo: genesiscz/GenesisTools PR: 95
File: src/timely/utils/entry-processor.ts:0-0
Timestamp: 2026-03-12T01:26:31.610Z
Learning: In code paths where JSON is consumed, prefer strict RFC 8259 validation by using SafeJSON.parse(text, { strict: true }) instead of the lenient default. Apply this at non-config boundaries (e.g., API responses, JSONL, cache outputs, subprocess outputs). Reserve the lenient comment-json behavior only for user-authored config files that may legitimately contain comments or trailing commas. For src/timely/utils/entry-processor.ts and similar modules, replace or wrap JSON parsing with SafeJSON.parse(text, { strict: true }) unless you are explicitly handling config files that require comments.
Applied to files:
src/ai-spend/lib/since.tssrc/ai-spend/lib/config.tssrc/ai-spend/lib/parse.tssrc/ai-spend/lib/render.tssrc/ai-spend/lib/discover.tssrc/ai-spend/lib/pricing.tssrc/ai-spend/index.tssrc/ai-spend/lib/types.tssrc/ai-spend/ai-spend.test.tssrc/ai-spend/lib/aggregate.ts
📚 Learning: 2026-03-12T01:58:27.831Z
Learnt from: genesiscz
Repo: genesiscz/GenesisTools PR: 103
File: src/port/index.ts:137-144
Timestamp: 2026-03-12T01:58:27.831Z
Learning: In GenesisTools, apply a no-obvious-comments rule: do not add inline comments for well-known POSIX patterns or standard idioms (e.g., a process.kill(pid, 0) probe) when surrounding code is self-documenting through descriptive function/variable names. This guidance applies to TypeScript files under src (src/**/*.ts). Only include comments if they add non-obvious rationale, edge-case behavior, or explain complex logic that cannot be inferred from code alone.
Applied to files:
src/ai-spend/lib/since.tssrc/ai-spend/lib/config.tssrc/ai-spend/lib/parse.tssrc/ai-spend/lib/render.tssrc/ai-spend/lib/discover.tssrc/ai-spend/lib/pricing.tssrc/ai-spend/index.tssrc/ai-spend/lib/types.tssrc/ai-spend/ai-spend.test.tssrc/ai-spend/lib/aggregate.ts
📚 Learning: 2026-03-22T22:19:44.520Z
Learnt from: genesiscz
Repo: genesiscz/GenesisTools PR: 119
File: src/indexer/commands/graph.ts:34-34
Timestamp: 2026-03-22T22:19:44.520Z
Learning: In genesiscz/GenesisTools, when using `SafeJSON.parse` in `src/**/*.ts`, it is acceptable to omit `{ strict: true }` if (and only if) the JSON being parsed is internal cache/state written by the same codebase (e.g., data saved by one internal writer and later read from a corresponding cached file). Do not require strict mode for these internal, machine-generated cache files. Require `{ strict: true }` at external/untrusted boundaries instead (e.g., API responses, third-party JSONL, subprocess output, or any JSON whose contents may not have been produced by trusted internal code).
Applied to files:
src/ai-spend/lib/since.tssrc/ai-spend/lib/config.tssrc/ai-spend/lib/parse.tssrc/ai-spend/lib/render.tssrc/ai-spend/lib/discover.tssrc/ai-spend/lib/pricing.tssrc/ai-spend/index.tssrc/ai-spend/lib/types.tssrc/ai-spend/ai-spend.test.tssrc/ai-spend/lib/aggregate.ts
📚 Learning: 2026-03-25T19:55:27.917Z
Learnt from: genesiscz
Repo: genesiscz/GenesisTools PR: 129
File: src/utils/search/stores/vector-store.ts:19-23
Timestamp: 2026-03-25T19:55:27.917Z
Learning: When reviewing this codebase’s “3+ parameters → object parameter” guideline, only suggest object-parameter refactoring when the function’s parameters are ambiguous or include optional/unclear semantics. Do not flag tightly-defined utility/helper functions where (1) all parameters are required, (2) meanings are semantically clear from parameter names, and (3) the ordering is well-ordered and obvious. For example, functions like bruteForceVectorSearch(memoryIndex, queryVector, limit) should be allowed to keep positional parameters because the intent is clear.
Applied to files:
src/ai-spend/lib/since.tssrc/ai-spend/lib/config.tssrc/ai-spend/lib/parse.tssrc/ai-spend/lib/render.tssrc/ai-spend/lib/discover.tssrc/ai-spend/lib/pricing.tssrc/ai-spend/index.tssrc/ai-spend/lib/types.tssrc/ai-spend/ai-spend.test.tssrc/ai-spend/lib/aggregate.ts
📚 Learning: 2026-05-05T03:52:21.057Z
Learnt from: genesiscz
Repo: genesiscz/GenesisTools PR: 163
File: src/debugging-master/core/dashboard-server.ts:115-127
Timestamp: 2026-05-05T03:52:21.057Z
Learning: When reviewing Bun.serve fetch handlers in this repo, don’t treat `req.signal` as possibly `undefined` at runtime. Bun guarantees an `AbortSignal` on every incoming Request, so `req.signal?.addEventListener(...)` is unnecessary for runtime safety and is only a TypeScript narrowing artifact (e.g., the type might be `AbortSignal | null`). Therefore, don’t raise concerns about SSE/subscription cleanup being skipped because `req.signal` could be missing; cleanup decisions should be based on the actual handler lifecycle, not an imagined runtime absence of `req.signal`.
Applied to files:
src/ai-spend/lib/since.tssrc/ai-spend/lib/config.tssrc/ai-spend/lib/parse.tssrc/ai-spend/lib/render.tssrc/ai-spend/lib/discover.tssrc/ai-spend/lib/pricing.tssrc/ai-spend/index.tssrc/ai-spend/lib/types.tssrc/ai-spend/ai-spend.test.tssrc/ai-spend/lib/aggregate.ts
📚 Learning: 2026-06-11T22:07:33.681Z
Learnt from: genesiscz
Repo: genesiscz/GenesisTools PR: 216
File: src/macos/commands/mail/show.ts:8-8
Timestamp: 2026-06-11T22:07:33.681Z
Learning: In genesiscz/GenesisTools, treat `chalk` as the project-wide standard for colored terminal output in TypeScript files under `src/`. During code reviews, do not recommend replacing `chalk` with `picocolors` (or other alternatives) since CLAUDE.md and existing modules (e.g., `mcp-manager`) consistently use `chalk`. Only suggest converting `chalk` usages when the PR/user explicitly requests a full-project migration.
Applied to files:
src/ai-spend/lib/since.tssrc/ai-spend/lib/config.tssrc/ai-spend/lib/parse.tssrc/ai-spend/lib/render.tssrc/ai-spend/lib/discover.tssrc/ai-spend/lib/pricing.tssrc/ai-spend/index.tssrc/ai-spend/lib/types.tssrc/ai-spend/ai-spend.test.tssrc/ai-spend/lib/aggregate.ts
📚 Learning: 2026-06-11T22:07:40.404Z
Learnt from: genesiscz
Repo: genesiscz/GenesisTools PR: 216
File: src/mcp-manager/commands/remove.ts:6-6
Timestamp: 2026-06-11T22:07:40.404Z
Learning: In genesiscz/GenesisTools, when reviewing TypeScript CLI/terminal output code under `src/`, use `chalk` for colored terminal/CLI output. Do not suggest switching to `picocolors` (or any other color library); the repo convention (including `mcp-manager` and `CLAUDE.md`) standardizes on `chalk`.
Applied to files:
src/ai-spend/lib/since.tssrc/ai-spend/lib/config.tssrc/ai-spend/lib/parse.tssrc/ai-spend/lib/render.tssrc/ai-spend/lib/discover.tssrc/ai-spend/lib/pricing.tssrc/ai-spend/index.tssrc/ai-spend/lib/types.tssrc/ai-spend/ai-spend.test.tssrc/ai-spend/lib/aggregate.ts
📚 Learning: 2026-06-11T22:07:46.747Z
Learnt from: genesiscz
Repo: genesiscz/GenesisTools PR: 216
File: src/mcp-manager/utils/providers/claude.ts:7-7
Timestamp: 2026-06-11T22:07:46.747Z
Learning: In genesiscz/GenesisTools, `chalk` is the approved, project-wide standard for colored terminal/CLI output. For TypeScript files under `src/`, code reviews should not flag existing `chalk` usage and should not recommend migrating from `chalk` to `picocolors` (or otherwise proposing a replacement) as part of PRs, especially when the PR only touches a subset of files.
Applied to files:
src/ai-spend/lib/since.tssrc/ai-spend/lib/config.tssrc/ai-spend/lib/parse.tssrc/ai-spend/lib/render.tssrc/ai-spend/lib/discover.tssrc/ai-spend/lib/pricing.tssrc/ai-spend/index.tssrc/ai-spend/lib/types.tssrc/ai-spend/ai-spend.test.tssrc/ai-spend/lib/aggregate.ts
📚 Learning: 2026-02-24T15:32:44.925Z
Learnt from: genesiscz
Repo: genesiscz/GenesisTools PR: 54
File: src/github/lib/review-output.ts:18-20
Timestamp: 2026-02-24T15:32:44.925Z
Learning: In TypeScript files, do not require a blank line between the opening brace of a function and the first statement if the first statement is the if statement immediately after the signature. The blank-line rule applies to separating an if from unrelated preceding code within the same block, not to spacing after the function opening brace. Apply this rule to all TS functions across the codebase.
Applied to files:
src/ai-spend/lib/since.tssrc/ai-spend/lib/config.tssrc/ai-spend/lib/parse.tssrc/ai-spend/lib/render.tssrc/ai-spend/lib/discover.tssrc/ai-spend/lib/pricing.tssrc/ai-spend/index.tssrc/ai-spend/lib/types.tssrc/ai-spend/ai-spend.test.tssrc/ai-spend/lib/aggregate.ts
📚 Learning: 2026-03-12T01:26:03.611Z
Learnt from: genesiscz
Repo: genesiscz/GenesisTools PR: 95
File: src/ask/lib/ChatSessionManager.ts:0-0
Timestamp: 2026-03-12T01:26:03.611Z
Learning: Use SafeJSON.parse(text, { strict: true }) for strict RFC 8259 validation in all non-config boundaries (API responses, JSONL, cache, subprocess output). The 3-arg form SafeJSON.parse(text, null, { strict: true }) is invalid and should not be used. Only lenient default (no options) is appropriate for user-authored config files that may contain comments/trailing commas. Apply this guideline across TypeScript files (src/**/*.ts) wherever SafeJSON.parse is used.
Applied to files:
src/ai-spend/lib/since.tssrc/ai-spend/lib/config.tssrc/ai-spend/lib/parse.tssrc/ai-spend/lib/render.tssrc/ai-spend/lib/discover.tssrc/ai-spend/lib/pricing.tssrc/ai-spend/index.tssrc/ai-spend/lib/types.tssrc/ai-spend/ai-spend.test.tssrc/ai-spend/lib/aggregate.ts
📚 Learning: 2026-03-12T01:26:18.985Z
Learnt from: genesiscz
Repo: genesiscz/GenesisTools PR: 95
File: src/claude/lib/history/search.ts:0-0
Timestamp: 2026-03-12T01:26:18.985Z
Learning: When using SafeJSON.parse in TypeScript code, prefer the two-argument form SafeJSON.parse(text, { strict: true }) to enable strict RFC 8259 validation via the native JSON.parse. Do NOT use the three-argument form SafeJSON.parse(text, null, { strict: true }). Apply strict parsing at remote/third-party API boundaries, JSONL parsing points, and subprocess output. Fall back to the lenient/default form only for user-authored config files that may legitimately contain comments or trailing commas. This pattern keeps strict validation where appropriate and preserves leniency for internal/config data.
Applied to files:
src/ai-spend/lib/since.tssrc/ai-spend/lib/config.tssrc/ai-spend/lib/parse.tssrc/ai-spend/lib/render.tssrc/ai-spend/lib/discover.tssrc/ai-spend/lib/pricing.tssrc/ai-spend/index.tssrc/ai-spend/lib/types.tssrc/ai-spend/ai-spend.test.tssrc/ai-spend/lib/aggregate.ts
📚 Learning: 2026-03-12T01:26:27.000Z
Learnt from: genesiscz
Repo: genesiscz/GenesisTools PR: 95
File: src/debugging-master/commands/tail.ts:0-0
Timestamp: 2026-03-12T01:26:27.000Z
Learning: In the genesiscz/GenesisTools repository, prefer using SafeJSON.parse(text, { strict: true }) (2-argument form) at all non-config JSON boundaries such as API responses, JSONL parsers, cache files, and subprocess stdout. Reserve the lenient default (SafeJSON.parse(text) with no options) only for user-authored config files that may legitimately contain comments or trailing commas.
Applied to files:
src/ai-spend/lib/since.tssrc/ai-spend/lib/config.tssrc/ai-spend/lib/parse.tssrc/ai-spend/lib/render.tssrc/ai-spend/lib/discover.tssrc/ai-spend/lib/pricing.tssrc/ai-spend/index.tssrc/ai-spend/lib/types.tssrc/ai-spend/ai-spend.test.tssrc/ai-spend/lib/aggregate.ts
📚 Learning: 2026-03-12T01:26:24.859Z
Learnt from: genesiscz
Repo: genesiscz/GenesisTools PR: 95
File: src/azure-devops/commands/history-sync.ts:0-0
Timestamp: 2026-03-12T01:26:24.859Z
Learning: In GenesisTools, ensure SafeJSON.parse is called with exactly two arguments. Use SafeJSON.parse(text, { strict: true }) for strict RFC 8259 validation, or pass a reviver function as the second argument. Do not call SafeJSON.parse(text, null, { strict: true }) since the function signature does not support a three-argument form. Apply this guideline to all TypeScript files that use SafeJSON.parse (e.g., src/utils/json.ts) and other related code.
Applied to files:
src/ai-spend/lib/since.tssrc/ai-spend/lib/config.tssrc/ai-spend/lib/parse.tssrc/ai-spend/lib/render.tssrc/ai-spend/lib/discover.tssrc/ai-spend/lib/pricing.tssrc/ai-spend/index.tssrc/ai-spend/lib/types.tssrc/ai-spend/ai-spend.test.tssrc/ai-spend/lib/aggregate.ts
📚 Learning: 2026-03-17T01:30:56.939Z
Learnt from: genesiscz
Repo: genesiscz/GenesisTools PR: 107
File: src/utils/macos/tts.ts:130-139
Timestamp: 2026-03-17T01:30:56.939Z
Learning: In genesiscz/GenesisTools, do not suggest converting two-argument functions with an optional second parameter (for example setMute(muted: boolean, app?: string)) to an object-parameter form. The project prefers simple positional parameters for short utility functions, even when an optional argument is present. The object-parameter guideline should only apply when a function has 3 or more parameters.
Applied to files:
src/ai-spend/lib/since.tssrc/ai-spend/lib/config.tssrc/ai-spend/lib/parse.tssrc/ai-spend/lib/render.tssrc/ai-spend/lib/discover.tssrc/ai-spend/lib/pricing.tssrc/ai-spend/index.tssrc/ai-spend/lib/types.tssrc/ai-spend/ai-spend.test.tssrc/ai-spend/lib/aggregate.ts
📚 Learning: 2026-03-22T22:19:49.876Z
Learnt from: genesiscz
Repo: genesiscz/GenesisTools PR: 119
File: src/indexer/index.ts:41-56
Timestamp: 2026-03-22T22:19:49.876Z
Learning: When using Bun projects, treat `import.meta.dir` as an absolute directory path provided by Bun. If you build paths by concatenating with `import.meta.dir` (e.g., `import.meta.dir + "/file.ts"`), do not require `path.resolve()` as it would be redundant. Only apply `path.resolve()` guidance when the base path is relative (not when the base is already an absolute `import.meta.dir`).
Applied to files:
src/ai-spend/lib/since.tssrc/ai-spend/lib/config.tssrc/ai-spend/lib/parse.tssrc/ai-spend/lib/render.tssrc/ai-spend/lib/discover.tssrc/ai-spend/lib/pricing.tssrc/ai-spend/index.tssrc/ai-spend/lib/types.tssrc/ai-spend/ai-spend.test.tssrc/ai-spend/lib/aggregate.ts
📚 Learning: 2026-03-12T03:48:42.474Z
Learnt from: genesiscz
Repo: genesiscz/GenesisTools PR: 104
File: src/darwinkit/index.ts:146-156
Timestamp: 2026-03-12T03:48:42.474Z
Learning: In TypeScript files that use Commander subcommands and exit after showing help, replace code after Command.help() with the pattern: call sub.outputHelp(); (returns void) followed by process.exit(0) or process.exit(1). This avoids TS7027 unreachable-code because Command.help() returns never. Apply this pattern in all src/**/*.ts files where subcommands need to display help before exiting.
Applied to files:
src/ai-spend/lib/since.tssrc/ai-spend/lib/config.tssrc/ai-spend/lib/parse.tssrc/ai-spend/lib/render.tssrc/ai-spend/lib/discover.tssrc/ai-spend/lib/pricing.tssrc/ai-spend/index.tssrc/ai-spend/lib/types.tssrc/ai-spend/ai-spend.test.tssrc/ai-spend/lib/aggregate.ts
📚 Learning: 2026-05-05T11:58:33.420Z
Learnt from: genesiscz
Repo: genesiscz/GenesisTools PR: 163
File: src/indexer/lib/sources/mail-source.dateSent.probe.test.ts:0-0
Timestamp: 2026-05-05T11:58:33.420Z
Learning: This repo uses Biome 2.x. The console lint rule is `noConsole` (located at `lint/suspicious/noConsole`), not `noConsoleLog`. In this codebase, `noConsole` is disabled in `biome.json`, so adding a `// biome-ignore lint/suspicious/noConsole:<...>` suppression comment is a no-op and should be avoided (CI flags it as having no effect). When reviewing, do not suggest adding Biome suppression comments for console usage; if a `console.*` call must remain, leave it without a `biome-ignore` comment.
Applied to files:
src/ai-spend/lib/since.tssrc/ai-spend/lib/config.tssrc/ai-spend/lib/parse.tssrc/ai-spend/lib/render.tssrc/ai-spend/lib/discover.tssrc/ai-spend/lib/pricing.tssrc/ai-spend/index.tssrc/ai-spend/lib/types.tssrc/ai-spend/ai-spend.test.tssrc/ai-spend/lib/aggregate.ts
📚 Learning: 2026-05-18T14:02:30.445Z
Learnt from: genesiscz
Repo: genesiscz/GenesisTools PR: 171
File: src/utils/ui/layouts/AuthLayout.tsx:34-34
Timestamp: 2026-05-18T14:02:30.445Z
Learning: When reviewing a PR, before leaving any comment on a specific file and hunk, verify that the file (and the relevant lines) actually exist in the PR’s current diff. For example, use `git diff --name-only <base>...<head>` (or the PR’s file list) to confirm the file is part of the diff, since pre-rebase/stale hunk references can lead to incorrect or outdated comments.
Applied to files:
src/ai-spend/lib/since.tssrc/ai-spend/lib/config.tssrc/ai-spend/lib/parse.tssrc/ai-spend/lib/render.tssrc/ai-spend/lib/discover.tssrc/ai-spend/lib/pricing.tssrc/ai-spend/index.tssrc/ai-spend/lib/types.tssrc/ai-spend/ai-spend.test.tssrc/ai-spend/lib/aggregate.ts
📚 Learning: 2026-05-19T18:33:15.211Z
Learnt from: genesiscz
Repo: genesiscz/GenesisTools PR: 176
File: src/telegram/index.ts:25-28
Timestamp: 2026-05-19T18:33:15.211Z
Learning: When reviewing legacy CLI entrypoint files (e.g., src/**/index.ts) that call `await runTool(program, { tool: "..." })`, allow the `.catch()` handler to keep `console.error(err); process.exit(1)` without requiring a switch to `logger.error` **only** for minimal-touch migrations that were done solely to satisfy the “no-default-import” gate and that add no new feature/behavior code. If the PR introduces any new feature logic or expands the catch-handling beyond that migration, prefer `logger.error` (and follow the repo’s normal logging conventions).
Applied to files:
src/ai-spend/index.ts
📚 Learning: 2026-03-22T22:19:53.048Z
Learnt from: genesiscz
Repo: genesiscz/GenesisTools PR: 119
File: src/utils/search/stores/qdrant-vector-store.test.ts:192-206
Timestamp: 2026-03-22T22:19:53.048Z
Learning: In src/**/*.test.ts, it is acceptable to include comments that explain the semantic role or conceptual grouping of numeric/vector test data clusters (e.g., “Cluster 1: 'code' vectors”, “Query close to 'docs' cluster”). Even if variable/identifier names partially suggest intent, these comments should be treated as readable context (describing how clusters/queries relate conceptually) rather than “obvious comments,” and should not be flagged by the no-obvious-comments rule when they genuinely clarify the test data grouping and relationships.
Applied to files:
src/ai-spend/ai-spend.test.ts
🔇 Additional comments (2)
src/ai-spend/lib/types.ts (1)
1-88: LGTM!src/ai-spend/lib/pricing.ts (1)
1-40: LGTM!
- aggregate: single-pass cost accumulators (O(N), drops quadratic re-scans) - parse: strict SafeJSON parse + null/object type guard (no more null-line crash) - discover: per-directory fs error handling (readdir/stat guarded) - config: per-field pricing merge so partial overrides do not yield NaN - index: validate --top as a positive integer - since: reject shape-valid but impossible ISO dates
|
@coderabbitai review |
✅ Action performedReview finished.
|
|
@coderabbitai review |
✅ Action performedReview finished.
|
- aggregate: single-pass cost accumulators (O(N), drops quadratic re-scans) - parse: strict SafeJSON parse + null/object type guard (no more null-line crash) - discover: per-directory fs error handling (readdir/stat guarded) - config: per-field pricing merge so partial overrides do not yield NaN - index: validate --top as a positive integer - since: reject shape-valid but impossible ISO dates
f58a5da to
a8562bb
Compare
#205) Extract ai-spend's option/action core into a shared registerSpendCommand helper so `tools claude spending` and `tools ai-spend` share one implementation with no copy-paste.
|
@coderabbitai review |
✅ Action performedReview finished.
|
|
@coderabbitai review |
✅ Action performedReview finished.
|
What
tools ai-spend— Claude Code token & cost analytics. Scans local Claude Code session transcripts (~/.claude/projects/**/*.jsonl) and reports where your tokens and dollars go — per day, session, model, and project — with cache-hit rate, top burners, and a daily spend sparkline. Fully local, offline, read-only. No API, no upload, no account.CLI surface
Shared flags (work on every subcommand AND on the bare invocation):
--since <when>—Nd(N days back) orYYYY-MM-DD. Default30d.--model <substr>— case-insensitive substring filter on model id.--project <substr>— case-insensitive substring filter on project (cwd) path.--top <n>— leaderboard length. Default10.--json— emit the machine-readable Report as a single JSON object on stdout (suppresses tables).Correctness highlights
message.id(critical). A single API request emits multiple assistant JSONL events that each carry the identicalusageobject; summing every line would double/triple-count. The pure core keeps only the first event seen permessage.idbefore aggregating. Asserted by a test (3 identical events counted once).cost = (in*input + out*output + cacheWrite*cw + cacheRead*cr) / 1e6. Asserted against hand-computed numbers (opus 110.25 + sonnet 3 = 113.25 for a two-model, two-day fixture).cacheRead / (input + cacheRead),0when the denominator is0. Tested.0, rendered with an(unpriced)marker. Tested.timestamp.slice(0,10), no local-Dateconstruction, so output is TZ-independent (test pinsTZ=Pacific/Kiritimati). The pure core takes an injectednowand reads no clock and no filesystem.~/.genesis-tools/ai-spend/config.json(pricingkey) through the Storage util. Tested (merge over defaults).Architecture
Pure deterministic core (
lib/aggregate.ts,pricing.ts,since.ts) takes parsed events + pricing + injectednowand returns aReport. Thin IO layer (discover.ts,parse.ts) resolves~/.claudeviaos.homedir()and parses line-by-line.render.tsturns a Report into TTY text; stdout only viaout.result/out.println, diagnostics vialogger. The three subcommands are thin views over the sameReport.Verification (local)
bunx biome check src/ai-spend— exit 0.bun test src/ai-spend— 21 pass, 0 fail (hermetic: temp dirs +GENESIS_TOOLS_HOME, no network, no live~/.claudedependency).--help, bareai-spend,--since 7d,today --json(single JSON object on stdout, nothing else),sessions --top 3— all exit 0 against real transcripts.Notable fix during impl
Shared options declared on both the root program and each subcommand are treated as global by commander, so a subcommand action's plain opts arg omitted flags resolved onto the parent (
today --jsonprinted the table;sessions --top 3ignored the cap). Fixed by readingcmd.optsWithGlobals()in each action. Verified--jsonand value flags (--top/--since) now resolve correctly on subcommands.v1 non-goals
~/.genesis-tools/claude-code/sessions/*.jsoningestion (the JSONL transcripts already carry every needed field).cacheWriterate.Known cosmetic limitation
The DAILY TREND sparkline uses
asciichart; on very-high-spend days the left y-axis labels are padded/format-fixed to keep leading digits (a $1358 day rendered as "358" before the fix). Padding is now widened and aformatcallback fixes the axis to a$X.XX9-char width.Summary by CodeRabbit
Release Notes
New Features
ai-spendcommand to track and analyze AI API usage costs with support for custom pricing configuration.Tests