Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# burnlog

**Know where your Claude Code tokens go. Stop the bleeding.**
**Know where your AI coding tokens go. Stop the bleeding.**

[![npm version](https://img.shields.io/npm/v/burnlog)](https://www.npmjs.com/package/burnlog)
[![npm downloads](https://img.shields.io/npm/dm/burnlog)](https://www.npmjs.com/package/burnlog)
Expand Down Expand Up @@ -38,13 +38,22 @@ $ burnlog waste

## The problem

Claude Code sessions can cost $5-$200+. You have no idea where that goes. burnlog reads the data Claude Code already stores (`~/.claude/`) and cross-references it with `git log` to answer:
AI coding sessions can cost $5-$200+. You have no idea where that goes. burnlog reads the data your AI coding assistant already stores and cross-references it with `git log` to answer:

- Which project eats the most budget?
- Did that $50 session actually produce commits?
- Am I stuck in retry loops burning tokens for nothing?
- What's my efficiency trend this week vs last?

## Supported providers

burnlog currently supports **Claude Code** (reads from `~/.claude/`). The architecture is designed to support additional AI coding assistants in the future:

- **Claude Code** — fully supported
- **Codex CLI** — planned
- **OpenCode** — planned
- **Amp** — planned

## Try it now

```bash
Expand Down Expand Up @@ -79,9 +88,18 @@ npm install -g burnlog
| `burnlog session <id>` | Deep dive: exchange log, waste signals, correlated commits |
| `burnlog waste` | Detect wasted spend with actionable suggestions |
| `burnlog branch feat/US-402` | Cost breakdown for a feature branch ($/commit, $/line) |
| `burnlog compare feat/US-402 fix/US-411` | Side-by-side efficiency comparison between branches |
| `burnlog branch feat/US-402 fix/US-411` | Side-by-side efficiency comparison between two branches |

All commands support `--period` (7d, 30d, 90d), `--project`, `-f json|csv|table`, and `--offline`.

All commands support `--period` (7d, 30d, 90d), `--project`, and `-f json|csv|table`.
## Dynamic pricing

burnlog fetches up-to-date model pricing from [LiteLLM](https://github.com/BerriAI/litellm) automatically and caches it for 24 hours. This means new models and price changes are picked up without updating burnlog itself.

```bash
burnlog report # auto-fetches pricing if cache expired
burnlog report --offline # uses bundled pricing (no network)
```

## What it detects

Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "burnlog",
"version": "0.2.1",
"version": "0.3.0",
"description": "Correlate AI coding assistant token usage with real development work",
"type": "module",
"bin": {
Expand All @@ -21,7 +21,7 @@
"node": ">=18.0.0"
},
"scripts": {
"build": "tsc -p tsconfig.build.json",
"build": "tsc -p tsconfig.build.json && cp -r src/data dist/",
"dev": "tsx src/index.ts",
"start": "node dist/index.js",
"test": "vitest run",
Expand Down
4 changes: 4 additions & 0 deletions src/cli/formatters/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Barrel re-export: all formatters available from a single import
export * from "./visual.js";
export * from "./table.js";
export * from "./export.js";
256 changes: 30 additions & 226 deletions src/cli/formatters/table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,234 +5,38 @@ import { totalTokens } from "../../core/token-ledger.js";
import { getModelDisplayName } from "../../utils/pricing-tables.js";
import type { EfficiencyResult } from "../../core/efficiency-score.js";

// ── Visual Utilities ──────────────────────────────────────────────

const BAR_BLOCKS = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉", "█"];
const SPARK_CHARS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];

/**
* Render a horizontal bar chart from a 0–1 ratio.
* Uses 1/8-block Unicode characters for sub-character precision.
* Empty space uses a subtle dot character for a cleaner look.
*/
export function renderBar(ratio: number, maxWidth = 20): string {
const clamped = Math.max(0, Math.min(1, ratio));
const fullWidth = clamped * maxWidth;
const fullBlocks = Math.floor(fullWidth);
const remainder = fullWidth - fullBlocks;
const partialIndex = Math.round(remainder * 8);

let bar = BAR_BLOCKS[8].repeat(fullBlocks);
if (partialIndex > 0 && fullBlocks < maxWidth) {
bar += BAR_BLOCKS[partialIndex];
}
const empty = maxWidth - fullBlocks - (partialIndex > 0 ? 1 : 0);
bar += chalk.dim("─").repeat(Math.max(0, empty));
return bar;
}

/**
* Render a sparkline from an array of values.
* Maps each value to one of 8 vertical bar characters.
*/
export function renderSparkline(values: number[]): string {
if (values.length === 0) return "";
const max = Math.max(...values);
const min = Math.min(...values);
const range = max - min;

return values
.map((v) => {
if (range === 0) return SPARK_CHARS[3]; // mid-height if all equal
const normalized = (v - min) / range;
const idx = Math.min(7, Math.round(normalized * 7));
// Color gradient: green (low) → yellow (mid) → red (high)
const char = SPARK_CHARS[idx];
if (idx <= 2) return chalk.green(char);
if (idx <= 4) return chalk.yellow(char);
return chalk.red(char);
})
.join("");
}

/**
* Render the efficiency score with a colored bar gauge.
*/
export function renderScoreGauge(score: number, width = 20): string {
const ratio = score / 100;
const r = Math.round(255 * (1 - ratio));
const g = Math.round(255 * ratio);
const filled = Math.ceil(ratio * width);
const empty = width - filled;
const filledBar = BAR_BLOCKS[8].repeat(filled);
const emptyBar = "─".repeat(Math.max(0, empty));
return `${score}/100 ${chalk.rgb(r, g, 60)(filledBar)}${chalk.dim(emptyBar)}`;
}

// ── Formatting Helpers ────────────────────────────────────────────

export function formatCurrency(amount: number): string {
return `$${amount.toFixed(2)}`;
}

export function formatTokens(count: number): string {
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`;
return count.toString();
}

export function outcomeIcon(outcome: string): string {
switch (outcome) {
case "fully_achieved":
return chalk.green("●");
case "mostly_achieved":
return chalk.green("◐");
case "partially_achieved":
return chalk.yellow("◐");
case "not_achieved":
return chalk.red("○");
default:
return chalk.gray("◌");
}
}

/**
* Render an outcome distribution as a proportional bar + legend.
* Instead of N individual dots, shows a fixed-width stacked bar.
*/
export function renderOutcomeDistribution(sessions: Session[]): string {
const counts = { ok: 0, mostly: 0, partial: 0, fail: 0, unknown: 0 };
for (const s of sessions) {
switch (s.outcome) {
case "fully_achieved": counts.ok++; break;
case "mostly_achieved": counts.mostly++; break;
case "partially_achieved": counts.partial++; break;
case "not_achieved": counts.fail++; break;
default: counts.unknown++; break;
}
}

const total = sessions.length;
if (total === 0) return chalk.dim("no sessions");

// Build a fixed-width proportional bar (20 chars)
const barWidth = 20;
const segments: Array<{ count: number; color: (s: string) => string }> = [
{ count: counts.ok, color: chalk.green },
{ count: counts.mostly, color: chalk.greenBright },
{ count: counts.partial, color: chalk.yellow },
{ count: counts.fail, color: chalk.red },
{ count: counts.unknown, color: chalk.gray },
];

let bar = "";
let allocated = 0;
for (const seg of segments) {
if (seg.count === 0) continue;
const width = Math.max(1, Math.round((seg.count / total) * barWidth));
const clamped = Math.min(width, barWidth - allocated);
bar += seg.color("█".repeat(clamped));
allocated += clamped;
}
// Fill any remaining due to rounding
if (allocated < barWidth) {
bar += chalk.dim("─".repeat(barWidth - allocated));
}

const parts: string[] = [];
if (counts.ok > 0) parts.push(chalk.green(`${counts.ok} OK`));
if (counts.mostly > 0) parts.push(chalk.greenBright(`${counts.mostly} mostly`));
if (counts.partial > 0) parts.push(chalk.yellow(`${counts.partial} partial`));
if (counts.fail > 0) parts.push(chalk.red(`${counts.fail} fail`));
if (counts.unknown > 0) parts.push(chalk.gray(`${counts.unknown} unknown`));

return `${bar} ${parts.join(chalk.dim(" · "))}`;
}

function humanizeType(type: string): string {
return type.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}

// Keep backward-compatible alias
// Re-export visual utilities for backward compatibility
export {
renderBar,
renderSparkline,
renderScoreGauge,
formatCurrency,
formatTokens,
outcomeIcon,
renderOutcomeDistribution,
humanizeType,
truncate,
cleanPromptForDisplay,
wrapIndented,
} from "./visual.js";

import {
renderBar,
renderSparkline,
renderScoreGauge,
formatCurrency,
formatTokens,
outcomeIcon,
renderOutcomeDistribution,
humanizeType,
truncate,
cleanPromptForDisplay,
wrapIndented,
} from "./visual.js";

// Keep backward-compatible alias used internally
const humanizeWasteType = humanizeType;

function truncate(text: string, max: number): string {
if (text.length <= max) return text;
return text.slice(0, max - 1) + "…";
}

function cleanPromptForDisplay(raw: string): string {
if (!raw || raw.length < 80) return raw;

let text = raw.replace(/<(bash-stdout|bash-stderr|task-notification|system-reminder|command-name|command-message|local-command-stdout)[^>]*>[\s\S]*?<\/\1>/gi, (_match, tag) => {
return chalk.dim(`[${tag} collapsed]`);
});

const lines = text.split("\n");
const result: string[] = [];
let noiseBuffer: string[] = [];

const isNoise = (line: string): boolean => {
const t = line.trim();
if (!t) return noiseBuffer.length > 0;
return (
/^[│├└┌┬┼─╰╭╮╯┐┤┴]+/.test(t) ||
/^\/Users\//.test(t) ||
/^-Users-/.test(t) ||
/^\s*sessions:\s*\d+/.test(t) ||
/^\s*last active:/.test(t) ||
/^(Error|Warning|note|hint|Traceback|×):?\s/.test(t) ||
/^\s{6,}/.test(line) ||
/^(Old|To|Done|Changes|Read more):?\s/.test(t) ||
/^\s*at\s+/.test(t) ||
/^\s*python3?\s/.test(t) ||
/^\s*source\s/.test(t) ||
/^\s*brew\s/.test(t) ||
/^\s*If you/.test(t)
);
};

const flushNoise = () => {
if (noiseBuffer.length > 3) {
result.push(chalk.dim(`[... ${noiseBuffer.length} lines of pasted output ...]`));
} else {
result.push(...noiseBuffer);
}
noiseBuffer = [];
};

for (const line of lines) {
if (isNoise(line)) {
noiseBuffer.push(line);
} else {
flushNoise();
result.push(line);
}
}
flushNoise();

return result.join("\n").trim();
}

function wrapIndented(text: string, indent: number): string {
const width = (process.stdout.columns || 100) - indent;
if (text.length <= width) return text;
const pad = " ".repeat(indent);
const words = text.split(" ");
const lines: string[] = [];
let current = "";
for (const word of words) {
if (current && (current.length + 1 + word.length) > width) {
lines.push(current);
current = word;
} else {
current = current ? current + " " + word : word;
}
}
if (current) lines.push(current);
return lines.join("\n" + pad);
}

// ── Report Renderers ──────────────────────────────────────────────

export function renderReportHeader(
Expand Down
Loading
Loading