agentwaste 0.1.0__py3-none-any.whl

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.
agentwaste/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """agentwaste: find where your AI coding agent wastes tokens."""
2
+
3
+ __version__ = "0.1.0"
agentwaste/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
agentwaste/analysis.py ADDED
@@ -0,0 +1,68 @@
1
+ """Rolls streams up into one Report: totals, ranked findings, waste %, grade."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+ from .cost import priced_input
9
+ from .detectors import ALL_DETECTORS, Finding
10
+ from .model import Stream
11
+ from .sources import claude_code
12
+
13
+ GRADES = [(10, "A"), (20, "B"), (30, "C"), (45, "D")]
14
+
15
+
16
+ def grade(waste_pct: float) -> str:
17
+ for limit, letter in GRADES:
18
+ if waste_pct < limit:
19
+ return letter
20
+ return "F"
21
+
22
+
23
+ @dataclass
24
+ class Report:
25
+ sessions: int = 0
26
+ streams: int = 0
27
+ model_calls: int = 0
28
+ tool_calls: int = 0
29
+ fresh_input: int = 0
30
+ cache_write: int = 0
31
+ cache_read: int = 0
32
+ output: int = 0
33
+ priced_input: float = 0.0
34
+ first_ts: float | None = None
35
+ last_ts: float | None = None
36
+ findings: list[Finding] = field(default_factory=list)
37
+
38
+ @property
39
+ def waste(self) -> float:
40
+ # Detectors can overlap (a huge result that is also a re-read), so cap
41
+ # the total at what was actually spent.
42
+ return min(sum(f.waste for f in self.findings if f.counted), self.priced_input)
43
+
44
+ @property
45
+ def waste_pct(self) -> float:
46
+ return 100.0 * self.waste / self.priced_input if self.priced_input else 0.0
47
+
48
+ @property
49
+ def grade(self) -> str:
50
+ return grade(self.waste_pct)
51
+
52
+
53
+ def analyze(source: list[Stream] | Path | str, since: float | None = None) -> Report:
54
+ streams = source if isinstance(source, list) else claude_code.load(source, since=since)
55
+ r = Report(streams=len(streams), sessions=len({s.session_id for s in streams}))
56
+ for s in streams:
57
+ for c in s.calls:
58
+ r.model_calls += 1
59
+ r.tool_calls += len(c.tool_calls)
60
+ r.fresh_input += c.input
61
+ r.cache_write += c.cache_write
62
+ r.cache_read += c.cache_read
63
+ r.output += c.output
64
+ r.priced_input += priced_input(c)
65
+ r.first_ts = c.ts if r.first_ts is None else min(r.first_ts, c.ts)
66
+ r.last_ts = c.ts if r.last_ts is None else max(r.last_ts, c.ts)
67
+ r.findings = sorted((d(streams) for d in ALL_DETECTORS), key=lambda f: (f.counted, f.waste), reverse=True)
68
+ return r
agentwaste/card.py ADDED
@@ -0,0 +1,50 @@
1
+ """Shareable "Wrapped"-style card: one self-contained HTML file with
2
+ aggregates only. It must never contain file paths, project names, commands
3
+ or any session content -- tests/test_report_and_cli.py enforces this."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import html
8
+ from datetime import datetime
9
+
10
+ from .analysis import Report
11
+ from .report import human
12
+
13
+ _CSS = """
14
+ body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
15
+ background:#0f1115;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;color:#f5f5f5}
16
+ .card{width:420px;padding:32px;border-radius:20px;background:linear-gradient(160deg,#1d2533,#2a1a2e)}
17
+ .k{font-size:13px;letter-spacing:.08em;text-transform:uppercase;color:#a3a9b8}
18
+ .grade{font-size:96px;font-weight:800;line-height:1;margin:8px 0}
19
+ .big{font-size:28px;font-weight:700}
20
+ .row{display:flex;justify-content:space-between;margin:10px 0;font-size:15px}
21
+ .bar{height:8px;border-radius:4px;background:#ff7a59;margin-top:4px}
22
+ .foot{margin-top:24px;font-size:12px;color:#a3a9b8}
23
+ """
24
+
25
+
26
+ def render_card(r: Report) -> str:
27
+ rows = []
28
+ for f in [f for f in r.findings if f.counted][:3]:
29
+ if not f.count or not r.priced_input:
30
+ continue
31
+ pct = 100 * f.waste / r.priced_input
32
+ rows.append(f'<div class="row"><span>{html.escape(f.title)}</span><span>{pct:.0f}%</span></div>'
33
+ f'<div class="bar" style="width:{max(2, min(100, pct)):.0f}%"></div>')
34
+ span = ""
35
+ if r.first_ts and r.last_ts:
36
+ days = max(1, round((r.last_ts - r.first_ts) / 86400))
37
+ span = f"{days} days"
38
+ made = datetime.now().strftime("%Y-%m-%d")
39
+ return f"""<!doctype html>
40
+ <html lang="en"><head><meta charset="utf-8"><title>My agent waste</title>
41
+ <meta name="viewport" content="width=device-width,initial-scale=1"><style>{_CSS}</style></head>
42
+ <body><div class="card">
43
+ <div class="k">My AI coding agent, {html.escape(span)}</div>
44
+ <div class="grade">{r.grade}</div>
45
+ <div class="big">{r.waste_pct:.0f}% of input tokens wasted</div>
46
+ <div class="k" style="margin-top:6px">{human(r.priced_input)} token-equivalents across {r.sessions} sessions</div>
47
+ <div style="margin-top:24px">{''.join(rows)}</div>
48
+ <div class="foot">Made {made} with agentwaste &middot; github.com/nikghodki/agentwaste</div>
49
+ </div></body></html>
50
+ """
agentwaste/cli.py ADDED
@@ -0,0 +1,55 @@
1
+ """agentwaste command line."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ import time
8
+ from pathlib import Path
9
+
10
+ from . import __version__
11
+ from .analysis import analyze
12
+ from .card import render_card
13
+ from .demo import demo_streams
14
+ from .report import render_text, to_json
15
+ from .sources import claude_code
16
+
17
+
18
+ def build_parser() -> argparse.ArgumentParser:
19
+ p = argparse.ArgumentParser(prog="agentwaste",
20
+ description="Find where your AI coding agent wastes tokens, and how to stop it.")
21
+ p.add_argument("--path", default=str(claude_code.DEFAULT_ROOT),
22
+ help="Claude Code projects directory (default: %(default)s)")
23
+ p.add_argument("--days", type=float, default=30, help="look back this many days; 0 = all (default: 30)")
24
+ p.add_argument("--json", action="store_true", help="print machine-readable JSON instead of the report")
25
+ p.add_argument("--card", metavar="FILE", help="also write a shareable HTML card (aggregates only)")
26
+ p.add_argument("--usd-per-mtok", type=float, metavar="USD",
27
+ help="show dollar estimates at this price per million input tokens")
28
+ p.add_argument("--top", type=int, default=3, help="examples to show per finding (default: 3)")
29
+ p.add_argument("--demo", action="store_true", help="run on built-in synthetic sessions instead of your logs")
30
+ p.add_argument("--version", action="version", version=f"agentwaste {__version__}")
31
+ return p
32
+
33
+
34
+ def main(argv: list[str] | None = None) -> int:
35
+ args = build_parser().parse_args(argv)
36
+ root = Path(args.path).expanduser()
37
+ since = time.time() - args.days * 86400 if args.days else None
38
+ report = analyze(demo_streams()) if args.demo else analyze(root, since=since)
39
+ if not report.model_calls:
40
+ print(f"No Claude Code sessions found in {root}"
41
+ + (f" in the last {args.days:g} days" if args.days else "")
42
+ + ". Try --days 0 or --path.", file=sys.stderr)
43
+ return 1
44
+ if args.demo and not args.json:
45
+ print("Showing demo data (synthetic sessions). Run without --demo to analyze your own logs.\n")
46
+ print(to_json(report, args.usd_per_mtok) if args.json else render_text(report, args.usd_per_mtok, args.top))
47
+ if args.card:
48
+ Path(args.card).write_text(render_card(report))
49
+ if not args.json:
50
+ print(f"\nShareable card written to {args.card}")
51
+ return 0
52
+
53
+
54
+ if __name__ == "__main__":
55
+ sys.exit(main())
agentwaste/cost.py ADDED
@@ -0,0 +1,23 @@
1
+ """Priced input-token equivalents ("eq"). One eq = one fresh input token.
2
+
3
+ Anthropic prompt caching bills cache writes at 1.25x (5-minute TTL) or 2x
4
+ (1-hour TTL) the input price, and cache reads at 0.1x. Dollars are left to
5
+ the caller (--usd-per-mtok) because prices and plans change.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .model import ModelCall
11
+
12
+ W_INPUT = 1.0
13
+ W_CACHE_WRITE_5M = 1.25
14
+ W_CACHE_WRITE_1H = 2.0
15
+ W_CACHE_READ = 0.1
16
+
17
+ TTL_5M = 300
18
+ TTL_1H = 3600
19
+
20
+
21
+ def priced_input(call: ModelCall) -> float:
22
+ return (call.input * W_INPUT + call.cache_write_5m * W_CACHE_WRITE_5M
23
+ + call.cache_write_1h * W_CACHE_WRITE_1H + call.cache_read * W_CACHE_READ)
agentwaste/demo.py ADDED
@@ -0,0 +1,58 @@
1
+ """Deterministic synthetic sessions for `agentwaste --demo`: lets anyone try
2
+ the tool without logs, and keeps screenshots free of real usage."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import random
7
+
8
+ from .model import ModelCall, Stream, ToolCall
9
+
10
+ DAY = 86400
11
+ START = 1_790_000_000 # a fixed date, so output is identical on every run
12
+ FILES = [f"src/app/{n}.py" for n in ("api", "models", "auth", "db", "views", "utils", "config", "tasks")]
13
+
14
+
15
+ def _session(rng: random.Random, n: int, day: int, *, uncached: bool = False, thrash: bool = False,
16
+ calls: int | None = None) -> Stream:
17
+ s = Stream(f"demo-{n}", False, "demo-app", "demo")
18
+ ts = START + day * DAY + rng.randint(8, 18) * 3600
19
+ context = 12_000
20
+ calls = calls or rng.randint(60, 140)
21
+ thrash_at = rng.randint(20, calls - 10) if thrash else -1
22
+ huge_at = rng.randint(10, calls - 5)
23
+ for i in range(calls):
24
+ idle = rng.random() < 0.06
25
+ ts += rng.randint(360, 2400) if idle else rng.randint(4, 90)
26
+ delta = rng.randint(800, 4000)
27
+ c = ModelCall(f"demo-{n}-{i}", ts, model="claude-demo", output=rng.randint(80, 900))
28
+ if uncached:
29
+ c.input = context + delta
30
+ elif i == 0 or idle:
31
+ c.cache_write_5m, c.input = context + delta, rng.randint(50, 400)
32
+ else:
33
+ c.cache_read, c.cache_write_5m, c.input = context, delta, rng.randint(50, 400)
34
+ context += delta
35
+ roll = rng.random()
36
+ if thrash_at <= i < thrash_at + 4:
37
+ t = ToolCall(f"t{n}-{i}", "Bash", {"command": "npm test"}, True, 600, True)
38
+ elif i == huge_at:
39
+ t = ToolCall(f"t{n}-{i}", "Bash", {"command": "grep -rn TODO ."}, True, rng.randint(18_000, 40_000))
40
+ elif roll < 0.45:
41
+ t = ToolCall(f"t{n}-{i}", "Read", {"file_path": rng.choice(FILES)}, True, rng.randint(900, 3500))
42
+ elif roll < 0.60:
43
+ t = ToolCall(f"t{n}-{i}", "Grep", {"pattern": rng.choice(["TODO", "def get_", "import os"])},
44
+ True, rng.randint(200, 1500))
45
+ elif roll < 0.75:
46
+ t = ToolCall(f"t{n}-{i}", "Edit", {"file_path": rng.choice(FILES)}, True, 120)
47
+ else:
48
+ t = ToolCall(f"t{n}-{i}", "Bash", {"command": "pytest -q"}, True, rng.randint(200, 1200))
49
+ c.tool_calls.append(t)
50
+ s.calls.append(c)
51
+ return s
52
+
53
+
54
+ def demo_streams() -> list[Stream]:
55
+ rng = random.Random(42)
56
+ streams = [_session(rng, n, day=n, thrash=n in (2, 7)) for n in range(12)]
57
+ streams.append(_session(rng, 12, day=12, uncached=True, calls=24)) # one session behind a cache-dropping proxy
58
+ return streams
@@ -0,0 +1,203 @@
1
+ """Waste detectors. Each takes streams and returns a Finding. Definitions
2
+ match design/2026-10-26-agentwaste-v1.md; change both together."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ from collections import Counter
8
+ from dataclasses import dataclass, field
9
+
10
+ from .cost import TTL_1H, TTL_5M, W_CACHE_READ, W_CACHE_WRITE_1H, W_CACHE_WRITE_5M, priced_input
11
+ from .model import Stream
12
+
13
+ MIN_REBUILD = 20_000
14
+ HUGE_TOKENS = 10_000
15
+ HUGE_KEEP = 2_000 # a result this size is reasonable; only the excess counts
16
+ THRASH_RUN = 3
17
+ UNCACHED_MIN = 20_000
18
+
19
+ EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"}
20
+ MUTATING_TOOLS = EDIT_TOOLS | {"Bash"}
21
+ READ_ONLY_TOOLS = {"Grep", "Glob", "LS", "WebFetch", "WebSearch"}
22
+
23
+
24
+ @dataclass
25
+ class Finding:
26
+ key: str
27
+ title: str
28
+ fix: str
29
+ count: int = 0
30
+ waste: float = 0.0 # eq
31
+ examples: list[tuple[str, int]] = field(default_factory=list) # (label, occurrences)
32
+ # False for findings whose cost isn't priced like Claude's (self-hosted
33
+ # models): shown, but excluded from the waste total and the grade.
34
+ counted: bool = True
35
+
36
+
37
+ def _top(counter: Counter, n: int = 3) -> list[tuple[str, int]]:
38
+ return counter.most_common(n)
39
+
40
+
41
+ def detect_idle_rebuilds(streams: list[Stream]) -> Finding:
42
+ f = Finding("idle_cache_rebuild", "Re-caching after idle breaks",
43
+ "The prompt cache expires after 5 minutes idle, so the next message re-writes the whole "
44
+ "context at 1.25x. Run /compact (or /clear) before a break, finish a thought before "
45
+ "stepping away, or use the 1-hour cache for long sessions.")
46
+ projects = Counter()
47
+ for s in streams:
48
+ for prev, cur in zip(s.calls, s.calls[1:]):
49
+ if cur.cache_write < MIN_REBUILD:
50
+ continue
51
+ ttl = TTL_1H if cur.cache_write_1h > cur.cache_write_5m else TTL_5M
52
+ if cur.ts - prev.ts > ttl:
53
+ f.count += 1
54
+ f.waste += (cur.cache_write_5m * (W_CACHE_WRITE_5M - W_CACHE_READ)
55
+ + cur.cache_write_1h * (W_CACHE_WRITE_1H - W_CACHE_READ))
56
+ projects[s.project] += 1
57
+ f.examples = _top(projects)
58
+ return f
59
+
60
+
61
+ def detect_rereads(streams: list[Stream]) -> Finding:
62
+ f = Finding("reread", "Re-reading unchanged files",
63
+ "The agent read a file it had already read and not changed. Put the facts it keeps "
64
+ "looking up (config values, key paths, conventions) in CLAUDE.md, or ask it to keep "
65
+ "notes instead of re-opening the file.")
66
+ files = Counter()
67
+ for s in streams:
68
+ seen: set[str] = set()
69
+ for c in s.calls:
70
+ for t in c.tool_calls:
71
+ path = t.input.get("file_path") if isinstance(t.input, dict) else None
72
+ if t.name == "Bash":
73
+ seen.clear()
74
+ elif t.name in EDIT_TOOLS and path:
75
+ seen.discard(path)
76
+ elif t.name == "Read" and path:
77
+ if path in seen:
78
+ f.count += 1
79
+ f.waste += t.result_tokens * W_CACHE_WRITE_5M
80
+ files[path] += 1
81
+ seen.add(path)
82
+ f.examples = _top(files)
83
+ return f
84
+
85
+
86
+ def detect_duplicate_calls(streams: list[Stream]) -> Finding:
87
+ f = Finding("duplicate_call", "Repeating identical lookups",
88
+ "The same search or fetch ran again with nothing changed in between. Ask the agent to "
89
+ "reuse earlier results, or narrow the first search so it doesn't need repeating.")
90
+ labels = Counter()
91
+ for s in streams:
92
+ seen: set[str] = set()
93
+ for c in s.calls:
94
+ for t in c.tool_calls:
95
+ if t.name in MUTATING_TOOLS:
96
+ seen.clear()
97
+ continue
98
+ if t.name not in READ_ONLY_TOOLS:
99
+ continue
100
+ key = t.name + json.dumps(t.input, sort_keys=True, default=str)
101
+ if key in seen:
102
+ f.count += 1
103
+ f.waste += t.result_tokens * W_CACHE_WRITE_5M
104
+ labels[t.name] += 1
105
+ seen.add(key)
106
+ f.examples = _top(labels)
107
+ return f
108
+
109
+
110
+ def detect_huge_results(streams: list[Stream]) -> Finding:
111
+ f = Finding("huge_result", "Huge tool outputs kept in context",
112
+ "A command or read returned over 10k tokens, and the agent carried it in every later "
113
+ "message. Pipe through head/tail, add filters or limits, read files in ranges, or use "
114
+ "a code-search tool instead of recursive grep.")
115
+ tools = Counter()
116
+ for s in streams:
117
+ n = len(s.calls)
118
+ for i, c in enumerate(s.calls):
119
+ later = n - i - 1
120
+ for t in c.tool_calls:
121
+ if t.result_tokens > HUGE_TOKENS:
122
+ f.count += 1
123
+ f.waste += (t.result_tokens - HUGE_KEEP) * (W_CACHE_WRITE_5M + W_CACHE_READ * later)
124
+ tools[t.name] += 1
125
+ f.examples = _top(tools)
126
+ return f
127
+
128
+
129
+ def detect_error_thrash(streams: list[Stream]) -> Finding:
130
+ f = Finding("error_thrash", "Retry loops on failing commands",
131
+ "The same tool failed 3+ times in a row. Stop and give the agent the missing context, "
132
+ "put the fix in CLAUDE.md, or add a hook that halts after repeated failures.")
133
+ tools = Counter()
134
+ for s in streams:
135
+ runs: Counter = Counter()
136
+ for c in s.calls:
137
+ for t in c.tool_calls:
138
+ if not t.has_result:
139
+ continue
140
+ if not t.is_error:
141
+ runs[t.name] = 0
142
+ continue
143
+ runs[t.name] += 1
144
+ if runs[t.name] == THRASH_RUN:
145
+ f.count += 1
146
+ tools[t.name] += 1
147
+ if runs[t.name] >= THRASH_RUN:
148
+ f.waste += priced_input(c)
149
+ f.examples = _top(tools)
150
+ return f
151
+
152
+
153
+ def _is_claude(model: str) -> bool:
154
+ return model.startswith("claude")
155
+
156
+
157
+ def _uncached_calls(streams: list[Stream], claude: bool):
158
+ for s in streams:
159
+ for c in s.calls[1:]:
160
+ if c.input >= UNCACHED_MIN and _is_claude(c.model) == claude:
161
+ yield c
162
+
163
+
164
+ def detect_uncached_context(streams: list[Stream]) -> Finding:
165
+ """Large fresh (uncached) input on a Claude call that follows earlier calls
166
+ in the same stream: the conversation is re-sent at full price instead of
167
+ read from the prompt cache -- typically a proxy dropping cache markers."""
168
+ f = Finding("uncached_context", "Context re-sent without prompt caching",
169
+ "Each turn re-sent the whole conversation uncached, at ~10x the cached price. If you use a "
170
+ "proxy or gateway (LiteLLM, Bedrock, a custom base URL), make sure it passes the "
171
+ "prompt-caching cache_control markers through and that caching is enabled for the model.")
172
+ models = Counter()
173
+ for c in _uncached_calls(streams, claude=True):
174
+ f.count += 1
175
+ f.waste += c.input * (1 - W_CACHE_READ)
176
+ models[c.model] += 1
177
+ f.examples = _top(models)
178
+ return f
179
+
180
+
181
+ def detect_uncached_context_self_hosted(streams: list[Stream]) -> Finding:
182
+ """The same pattern on non-Claude (typically self-hosted) models. Their cost
183
+ is GPU prefill time and latency, not Claude-priced tokens, so it is
184
+ reported but not counted toward waste or the grade."""
185
+ f = Finding("uncached_context_self_hosted", "Self-hosted model re-processing context", "", counted=False)
186
+ models = Counter()
187
+ for c in _uncached_calls(streams, claude=False):
188
+ f.count += 1
189
+ f.waste += c.input * (1 - W_CACHE_READ)
190
+ models[c.model or "unknown model"] += 1
191
+ f.examples = _top(models)
192
+ fresh = sum(c.input for s in streams for c in s.calls if not _is_claude(c.model))
193
+ read = sum(c.cache_read for s in streams for c in s.calls if not _is_claude(c.model))
194
+ hit = 100 * read / (fresh + read) if fresh + read else 0
195
+ f.fix = (f"Only {hit:.0f}% of this model's input was served from its prefix cache; the rest was "
196
+ "re-processed every turn, costing GPU prefill time and latency. Check the server's prefix "
197
+ "caching is on (default in recent vLLM; SGLang uses RadixAttention), that it has free KV-cache "
198
+ "memory, and that a session's requests reach the same replica.")
199
+ return f
200
+
201
+ ALL_DETECTORS = [detect_idle_rebuilds, detect_rereads, detect_duplicate_calls,
202
+ detect_huge_results, detect_error_thrash, detect_uncached_context,
203
+ detect_uncached_context_self_hosted]
agentwaste/model.py ADDED
@@ -0,0 +1,45 @@
1
+ """Source-agnostic session model. Sources (sources/*.py) produce these;
2
+ detectors only ever see these."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass, field
7
+
8
+
9
+ @dataclass
10
+ class ToolCall:
11
+ id: str
12
+ name: str
13
+ input: dict
14
+ has_result: bool = False
15
+ result_tokens: int = 0 # estimated: characters / 4
16
+ is_error: bool = False
17
+
18
+
19
+ @dataclass
20
+ class ModelCall:
21
+ id: str
22
+ ts: float # unix seconds
23
+ model: str = ""
24
+ input: int = 0
25
+ cache_write_5m: int = 0
26
+ cache_write_1h: int = 0
27
+ cache_read: int = 0
28
+ output: int = 0
29
+ tool_calls: list[ToolCall] = field(default_factory=list)
30
+
31
+ @property
32
+ def cache_write(self) -> int:
33
+ return self.cache_write_5m + self.cache_write_1h
34
+
35
+
36
+ @dataclass
37
+ class Stream:
38
+ """One cache context: a session's main thread, or one of its sidechains
39
+ (subagents), which keep their own cache."""
40
+
41
+ session_id: str
42
+ sidechain: bool
43
+ project: str = ""
44
+ source: str = ""
45
+ calls: list[ModelCall] = field(default_factory=list)
agentwaste/report.py ADDED
@@ -0,0 +1,81 @@
1
+ """Terminal and JSON rendering. The terminal report is local-only and may show
2
+ file names; the shareable card (card.py) never does."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ import textwrap
9
+ from datetime import datetime
10
+
11
+ from .analysis import Report
12
+
13
+
14
+ def human(n: float) -> str:
15
+ for unit, size in (("B", 1e9), ("M", 1e6), ("k", 1e3)):
16
+ if abs(n) >= size:
17
+ return f"{n / size:.1f}{unit}"
18
+ return f"{n:.0f}"
19
+
20
+
21
+ def _date(ts: float | None) -> str:
22
+ return datetime.fromtimestamp(ts).strftime("%Y-%m-%d") if ts else "?"
23
+
24
+
25
+ def _short(label: str) -> str:
26
+ home = os.environ.get("HOME", "")
27
+ return "~" + label[len(home):] if home and label.startswith(home + "/") else label
28
+
29
+
30
+ def _usd(eq: float, usd_per_mtok: float | None) -> str:
31
+ return f" (~${eq / 1e6 * usd_per_mtok:,.0f})" if usd_per_mtok else ""
32
+
33
+
34
+ def render_text(r: Report, usd_per_mtok: float | None = None, top: int = 3) -> str:
35
+ lines = [
36
+ f"agentwaste: {r.sessions} sessions, {r.model_calls:,} model calls, {r.tool_calls:,} tool calls"
37
+ f" ({_date(r.first_ts)} to {_date(r.last_ts)})",
38
+ "",
39
+ f"Priced input: {human(r.priced_input)} token-equivalents{_usd(r.priced_input, usd_per_mtok)}"
40
+ f" (cache writes {human(r.cache_write)}, cache reads {human(r.cache_read)},"
41
+ f" fresh {human(r.fresh_input)}; output {human(r.output)})",
42
+ f"Estimated waste: {human(r.waste)} ({r.waste_pct:.0f}%){_usd(r.waste, usd_per_mtok)} Grade: {r.grade}",
43
+ "",
44
+ ]
45
+ info_header = False
46
+ for f in r.findings:
47
+ if not f.count:
48
+ continue
49
+ if not f.counted:
50
+ if not info_header:
51
+ lines += ["Not counted in the grade (self-hosted models: the cost is GPU time, not tokens):", ""]
52
+ info_header = True
53
+ lines.append(f" info {f.title} ({f.count:,}x, {human(f.waste)} tokens re-processed)")
54
+ else:
55
+ pct = 100 * f.waste / r.priced_input if r.priced_input else 0
56
+ lines.append(f"{pct:5.1f}% {f.title} ({f.count:,}x, {human(f.waste)} eq{_usd(f.waste, usd_per_mtok)})")
57
+ for label, n in f.examples[:top]:
58
+ lines.append(f" - {_short(label)}: {n:,}x")
59
+ lines += [" " + l for l in textwrap.wrap("Fix: " + f.fix, 88)]
60
+ lines.append("")
61
+ if not any(f.count for f in r.findings):
62
+ lines.append("No waste patterns found. Nice.")
63
+ lines.append("Token-equivalents weight cache writes 1.25x (1-hour cache 2x) and cache reads 0.1x")
64
+ lines.append("of fresh input. Estimates; see https://github.com/nikghodki/agentwaste for definitions.")
65
+ return "\n".join(lines)
66
+
67
+
68
+ def to_json(r: Report, usd_per_mtok: float | None = None) -> str:
69
+ data = {
70
+ "sessions": r.sessions, "streams": r.streams, "model_calls": r.model_calls,
71
+ "tool_calls": r.tool_calls, "fresh_input": r.fresh_input, "cache_write": r.cache_write,
72
+ "cache_read": r.cache_read, "output": r.output, "priced_input": round(r.priced_input),
73
+ "waste": round(r.waste), "waste_pct": round(r.waste_pct, 2), "grade": r.grade,
74
+ "first_ts": r.first_ts, "last_ts": r.last_ts, "usd_per_mtok": usd_per_mtok,
75
+ "findings": [
76
+ {"key": f.key, "title": f.title, "count": f.count, "waste": round(f.waste), "counted": f.counted,
77
+ "examples": [{"label": l, "count": n} for l, n in f.examples], "fix": f.fix}
78
+ for f in r.findings
79
+ ],
80
+ }
81
+ return json.dumps(data, indent=2)
File without changes
@@ -0,0 +1,124 @@
1
+ """Reads Claude Code transcripts: ~/.claude/projects/<project>/<session>.jsonl.
2
+
3
+ Format notes (observed):
4
+ - One assistant reply is split across several records sharing message.id;
5
+ usage repeats on each, so it is counted once, while tool_use blocks are
6
+ collected from all of them.
7
+ - Tool results arrive in later "user" records and link back by tool_use_id.
8
+ - usage.cache_creation may split cache writes into 5-minute and 1-hour TTLs.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ from datetime import datetime
16
+ from pathlib import Path
17
+
18
+ from ..model import ModelCall, Stream, ToolCall
19
+
20
+ DEFAULT_ROOT = Path("~/.claude/projects").expanduser()
21
+
22
+
23
+ def _ts(value) -> float | None:
24
+ if not isinstance(value, str):
25
+ return None
26
+ try:
27
+ return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
28
+ except ValueError:
29
+ return None
30
+
31
+
32
+ def _result_tokens(content) -> int:
33
+ if content is None:
34
+ return 0
35
+ text = content if isinstance(content, str) else json.dumps(content)
36
+ return len(text) // 4
37
+
38
+
39
+ def _project(cwd: str | None, file: Path) -> str:
40
+ if cwd:
41
+ return os.path.basename(cwd.rstrip("/")) or cwd
42
+ return file.parent.name
43
+
44
+
45
+ def discover(root: Path | str = DEFAULT_ROOT) -> list[Path]:
46
+ return sorted(Path(root).expanduser().rglob("*.jsonl"))
47
+
48
+
49
+ def parse_file(path: Path) -> list[Stream]:
50
+ streams: dict[tuple[str, bool], Stream] = {}
51
+ calls: dict[tuple[str, bool, str], ModelCall] = {}
52
+ tools: dict[str, ToolCall] = {}
53
+
54
+ with open(path, encoding="utf-8", errors="replace") as fh:
55
+ for line in fh:
56
+ try:
57
+ rec = json.loads(line)
58
+ except ValueError:
59
+ continue
60
+ msg = rec.get("message") if isinstance(rec, dict) else None
61
+ sid = rec.get("sessionId") if isinstance(rec, dict) else None
62
+ if not isinstance(msg, dict) or not sid:
63
+ continue
64
+ side = bool(rec.get("isSidechain"))
65
+ key = (sid, side)
66
+ stream = streams.get(key)
67
+ if stream is None:
68
+ stream = streams[key] = Stream(sid, side, _project(rec.get("cwd"), path), "claude-code")
69
+ content = msg.get("content") if isinstance(msg.get("content"), list) else []
70
+
71
+ if rec.get("type") == "assistant":
72
+ mid = msg.get("id") or rec.get("uuid")
73
+ ts = _ts(rec.get("timestamp"))
74
+ # Claude Code writes "<synthetic>" placeholders (errors, interrupts)
75
+ # with zero usage; they are not model calls.
76
+ if not mid or ts is None or msg.get("model") == "<synthetic>":
77
+ continue
78
+ call = calls.get((sid, side, mid))
79
+ if call is None:
80
+ usage = msg.get("usage") or {}
81
+ written = usage.get("cache_creation_input_tokens") or 0
82
+ split = usage.get("cache_creation") or {}
83
+ w1h = split.get("ephemeral_1h_input_tokens") or 0
84
+ w5m = split.get("ephemeral_5m_input_tokens")
85
+ call = ModelCall(
86
+ id=mid, ts=ts, model=msg.get("model") or "",
87
+ input=usage.get("input_tokens") or 0,
88
+ cache_write_5m=(written - w1h) if w5m is None else w5m,
89
+ cache_write_1h=w1h,
90
+ cache_read=usage.get("cache_read_input_tokens") or 0,
91
+ output=usage.get("output_tokens") or 0,
92
+ )
93
+ calls[(sid, side, mid)] = call
94
+ stream.calls.append(call)
95
+ for block in content:
96
+ if isinstance(block, dict) and block.get("type") == "tool_use" and block.get("id"):
97
+ tc = ToolCall(block["id"], block.get("name") or "", block.get("input") or {})
98
+ call.tool_calls.append(tc)
99
+ tools[tc.id] = tc
100
+
101
+ elif rec.get("type") == "user":
102
+ for block in content:
103
+ if not (isinstance(block, dict) and block.get("type") == "tool_result"):
104
+ continue
105
+ tc = tools.get(block.get("tool_use_id"))
106
+ if tc is not None:
107
+ tc.has_result = True
108
+ tc.result_tokens = _result_tokens(block.get("content"))
109
+ tc.is_error = bool(block.get("is_error"))
110
+
111
+ for stream in streams.values():
112
+ stream.calls.sort(key=lambda c: c.ts)
113
+ return [s for s in streams.values() if s.calls]
114
+
115
+
116
+ def load(root: Path | str = DEFAULT_ROOT, since: float | None = None) -> list[Stream]:
117
+ out = []
118
+ for path in discover(root):
119
+ for stream in parse_file(path):
120
+ if since is not None:
121
+ stream.calls = [c for c in stream.calls if c.ts >= since]
122
+ if stream.calls:
123
+ out.append(stream)
124
+ return out
@@ -0,0 +1,152 @@
1
+ Metadata-Version: 2.5
2
+ Name: agentwaste
3
+ Version: 0.1.0
4
+ Summary: Find where your AI coding agent wastes tokens, and how to stop it
5
+ Project-URL: Homepage, https://github.com/nikghodki/agentwaste
6
+ Project-URL: Documentation, https://nikghodki.github.io/agentwaste/
7
+ Project-URL: Issues, https://github.com/nikghodki/agentwaste/issues
8
+ Project-URL: Changelog, https://github.com/nikghodki/agentwaste/blob/main/CHANGELOG.md
9
+ Author-email: nikghodki <nikghodki@gmail.com>
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: ai-agents,claude-code,cli,cost,developer-tools,llm,prompt-caching,tokens
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Software Development
23
+ Classifier: Topic :: Utilities
24
+ Requires-Python: >=3.10
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=7.0; extra == 'dev'
27
+ Description-Content-Type: text/markdown
28
+
29
+ <div align="center">
30
+
31
+ # agentwaste
32
+
33
+ **Your AI coding agent is wasting tokens. Find out where, and how to stop it.**
34
+
35
+ [![CI](https://github.com/nikghodki/agentwaste/actions/workflows/ci.yml/badge.svg)](https://github.com/nikghodki/agentwaste/actions/workflows/ci.yml)
36
+ [![Docs](https://img.shields.io/badge/docs-nikghodki.github.io%2Fagentwaste-ff7a59)](https://nikghodki.github.io/agentwaste/)
37
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/)
38
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](https://github.com/nikghodki/agentwaste/tree/main/LICENSE)
39
+
40
+ [**Documentation**](https://nikghodki.github.io/agentwaste/) Β·
41
+ [Quickstart](https://nikghodki.github.io/agentwaste/quickstart/) Β·
42
+ [How it works](https://nikghodki.github.io/agentwaste/how-it-works/) Β·
43
+ [Contributing](https://github.com/nikghodki/agentwaste/blob/main/CONTRIBUTING.md)
44
+
45
+ <img src="https://raw.githubusercontent.com/nikghodki/agentwaste/main/assets/report.png" alt="agentwaste report: 41% waste, grade D, with re-caching after idle breaks as the top finding and a fix for each pattern" width="820">
46
+
47
+ </div>
48
+
49
+ ## Try it in 10 seconds
50
+
51
+ ```bash
52
+ uvx --from git+https://github.com/nikghodki/agentwaste agentwaste
53
+ ```
54
+
55
+ That's it: no install, no account, no config. It reads the Claude Code logs
56
+ already on your machine and prints a report. No history yet? Add `--demo`.
57
+
58
+ Want the `agentwaste` command permanently? Install it once:
59
+
60
+ ```bash
61
+ uv tool install git+https://github.com/nikghodki/agentwaste # or: pipx install git+https://github.com/nikghodki/agentwaste
62
+ ```
63
+
64
+ The examples below use the installed command. Without installing, put
65
+ `uvx --from git+https://github.com/nikghodki/agentwaste` in front of them.
66
+
67
+ ## Why
68
+
69
+ Usage trackers tell you **how much** you used. agentwaste tells you **why**,
70
+ and how much of it was avoidable.
71
+
72
+ On one developer's month of Claude Code use, **about a third of all input cost
73
+ was re-caching the conversation after stepping away for more than five
74
+ minutes.** Nobody noticed, because nothing showed it.
75
+
76
+ Whether you pay per token or hit subscription limits, that's waste you can fix
77
+ in minutes once you can see it.
78
+
79
+ ## What it finds
80
+
81
+ | | Pattern | What's happening | The fix |
82
+ |---|---|---|---|
83
+ | ⏸️ | **Idle cache rebuild** | You step away, the 5-minute prompt cache expires, and the next message re-writes the whole context at 1.25x | `/compact` before a break, or the 1-hour cache |
84
+ | πŸ” | **Uncached context** | Every turn re-sends the conversation without caching, often because a proxy drops cache markers | Fix the proxy so caching survives |
85
+ | πŸ“„ | **Re-read** | The agent re-reads a file it already read, with no change in between | Put the facts in `CLAUDE.md` |
86
+ | πŸ” | **Duplicate call** | The same search or fetch runs again with nothing changed | Reuse results, search more precisely |
87
+ | πŸ“¦ | **Huge result** | One 10k+ token output, carried in every later message | `head`, filters, ranged reads |
88
+ | πŸ’₯ | **Error thrash** | The same tool fails 3+ times in a row | Give the agent the missing context |
89
+
90
+ Every finding comes with its cost, the worst offenders and a specific fix.
91
+ [Exact definitions β†’](https://nikghodki.github.io/agentwaste/detectors/overview/)
92
+
93
+ ## Share your grade
94
+
95
+ <img src="https://raw.githubusercontent.com/nikghodki/agentwaste/main/assets/card.png" alt="Shareable agentwaste card: grade D, 41% of input tokens wasted, top categories re-caching after idle breaks, huge tool outputs and uncached context" width="300" align="right">
96
+
97
+ ```bash
98
+ agentwaste --card my-card.html
99
+ ```
100
+
101
+ A one-file card with your grade and top waste categories, ready to screenshot
102
+ and post. It contains **aggregates only**: no paths, project names, commands
103
+ or code. A test enforces that on every change.
104
+
105
+ <br clear="right">
106
+
107
+ ## More options
108
+
109
+ ```bash
110
+ agentwaste --days 7 # last week only (default 30; 0 = everything)
111
+ agentwaste --usd-per-mtok 3 # dollar estimates at $3 per million input tokens
112
+ agentwaste --json > waste.json # machine-readable, for scripts and dashboards
113
+ agentwaste --demo # try it on synthetic sessions
114
+ ```
115
+
116
+ Full reference: [CLI](https://nikghodki.github.io/agentwaste/reference/cli/) Β·
117
+ [JSON output](https://nikghodki.github.io/agentwaste/reference/json-output/)
118
+
119
+ ## Private by design
120
+
121
+ - **Local and read-only.** No network requests, nothing uploaded, your logs are never changed.
122
+ - **No dependencies.** Plain Python 3.10+, so there's nothing hidden to audit.
123
+ - **Honest numbers.** Costs are in [token-equivalents](https://nikghodki.github.io/agentwaste/how-it-works/#token-equivalents)
124
+ that weight cache writes, cache reads and fresh input the way they're actually
125
+ billed. Self-hosted models are reported separately, since their cost is GPU
126
+ time, not tokens.
127
+
128
+ ## Roadmap
129
+
130
+ - [x] Claude Code support, six detectors, report, JSON, shareable card
131
+ - [ ] Codex support
132
+ - [ ] `pip install agentwaste` / `uvx agentwaste` from PyPI
133
+ - [ ] Live warnings while you work ("your cache expires in 1 minute")
134
+ - [ ] Before/after comparison: did the fix actually help?
135
+
136
+ Ideas and votes welcome in [issues](https://github.com/nikghodki/agentwaste/issues).
137
+
138
+ ## Contributing
139
+
140
+ Contributions are very welcome, especially **new log sources** (Codex, Gemini
141
+ CLI, OpenCode) and **new detectors**. Start with [CONTRIBUTING.md](https://github.com/nikghodki/agentwaste/blob/main/CONTRIBUTING.md):
142
+
143
+ ```bash
144
+ git clone https://github.com/nikghodki/agentwaste && cd agentwaste
145
+ python3 -m venv .venv && .venv/bin/pip install -e ".[dev]" && .venv/bin/pytest
146
+ ```
147
+
148
+ The original AgentSight SDK lives on in [`legacy/agentsight`](https://github.com/nikghodki/agentwaste/tree/main/legacy/agentsight).
149
+
150
+ ## License
151
+
152
+ [MIT](https://github.com/nikghodki/agentwaste/tree/main/LICENSE)
@@ -0,0 +1,17 @@
1
+ agentwaste/__init__.py,sha256=ILAlddbHEryL1Y-hlvmjxtykD2tFe7Yb2FE8Qip4Y90,88
2
+ agentwaste/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
3
+ agentwaste/analysis.py,sha256=6tzRXOlHw6X9o_siv_oBwroBGQTZQb0fmDDvPoYSmF4,2227
4
+ agentwaste/card.py,sha256=LtnSWV409kxao7e0sFEekIshyUsva2zmXycUb6tcThk,2318
5
+ agentwaste/cli.py,sha256=y66EDiaitX1iGzGyROBl4L7eTL3F4AQyYB_Mof8aCz4,2474
6
+ agentwaste/cost.py,sha256=HFILPHI8vcQne80wqSmvywWaXbSrBBLx_wWQTPdwg0k,671
7
+ agentwaste/demo.py,sha256=mccXk3Jdsfe9sC8_HCbjBJzkv4HG8JY9WuJrbpO41KA,2630
8
+ agentwaste/detectors.py,sha256=G1FeXA6D8D1PE2hU226Hd8RuFpnwyl1asDnZdAvRouQ,8673
9
+ agentwaste/model.py,sha256=2TxXTqqNqL9nYT1Q56NNkAijO9xYz3TMUB3j7LKBzOg,1037
10
+ agentwaste/report.py,sha256=UcstOupTra5EsrM_o2J5jh7aMWSebfLxpcoU1Y-B5z8,3512
11
+ agentwaste/sources/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ agentwaste/sources/claude_code.py,sha256=sDa4DT6fzfb4yFHgHATnIDsl4Wv8KU-UHnfHdO4FWu4,4979
13
+ agentwaste-0.1.0.dist-info/METADATA,sha256=TYwi-XToavcBGRfcLOmiLCtURraWVsvAA7a3gpT9LDM,6992
14
+ agentwaste-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
15
+ agentwaste-0.1.0.dist-info/entry_points.txt,sha256=VZpXwBxudinvItJChxXCZMzLO3JIoojcso1yRNWEMgE,51
16
+ agentwaste-0.1.0.dist-info/licenses/LICENSE,sha256=7wf_NTnQqhAnakahKHHkrJHQ3P8a7HzC6TH6DU5xExo,1070
17
+ agentwaste-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ agentwaste = agentwaste.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NIKHIL GHODKI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.