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 +3 -0
- agentwaste/__main__.py +5 -0
- agentwaste/analysis.py +68 -0
- agentwaste/card.py +50 -0
- agentwaste/cli.py +55 -0
- agentwaste/cost.py +23 -0
- agentwaste/demo.py +58 -0
- agentwaste/detectors.py +203 -0
- agentwaste/model.py +45 -0
- agentwaste/report.py +81 -0
- agentwaste/sources/__init__.py +0 -0
- agentwaste/sources/claude_code.py +124 -0
- agentwaste-0.1.0.dist-info/METADATA +152 -0
- agentwaste-0.1.0.dist-info/RECORD +17 -0
- agentwaste-0.1.0.dist-info/WHEEL +4 -0
- agentwaste-0.1.0.dist-info/entry_points.txt +2 -0
- agentwaste-0.1.0.dist-info/licenses/LICENSE +21 -0
agentwaste/__init__.py
ADDED
agentwaste/__main__.py
ADDED
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 · 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
|
agentwaste/detectors.py
ADDED
|
@@ -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
|
+
[](https://github.com/nikghodki/agentwaste/actions/workflows/ci.yml)
|
|
36
|
+
[](https://nikghodki.github.io/agentwaste/)
|
|
37
|
+
[](https://www.python.org/)
|
|
38
|
+
[](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,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.
|