contexttax 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Adam Danielsson
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.
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: contexttax
3
+ Version: 0.1.0
4
+ Summary: Where your Claude Code usage actually goes. Reads local logs only.
5
+ Author: Adam Danielsson
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/theadamdanielsson/contexttax
8
+ Keywords: claude-code,usage,limits,tokens,context
9
+ Classifier: Environment :: Console
10
+ Classifier: Programming Language :: Python :: 3
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Dynamic: license-file
15
+
16
+ # contexttax
17
+
18
+ Where your Claude Code usage actually goes.
19
+
20
+ Most of it isn't loops or re-reads. It's long conversations. Every turn sends the whole conversation again, so a session at 500k tokens pays for all of it on every message.
21
+
22
+ ```
23
+ uvx contexttax
24
+ ```
25
+
26
+ No install with uv. Or, with plain Python 3:
27
+
28
+ ```
29
+ curl -fsSL https://raw.githubusercontent.com/theadamdanielsson/contexttax/main/contexttax.py | python3 -
30
+ ```
31
+
32
+ ## What it shows
33
+
34
+ My last 27 days:
35
+
36
+ ```
37
+ contexttax · Claude Code · last 27 days · 316 sessions · 8,188 model calls
38
+
39
+ 76% of your usage went to turns where the conversation was already over 200k tokens.
40
+ Your biggest 10% of sessions used 90%. One session alone used 14%.
41
+ A turn at 500k+ context costs 5.0x a turn at 50-100k.
42
+ Median turn: 255k of context. One in ten: over 604k.
43
+
44
+ Starting fresh with a short summary at 200k would have saved about 24%.
45
+
46
+ What people usually blame:
47
+ re-reading the same file 43 of 792 reads
48
+ loops (same call 3 times) 1
49
+ huge tool outputs 1%
50
+ subagents 5%
51
+ cache rebuilt after a break 4%
52
+
53
+ Where it goes: cache reads 70%, cache writes 20%, output 10%
54
+ ```
55
+
56
+ ## Private by design
57
+
58
+ It reads the logs Claude Code already keeps in `~/.claude/projects`. It prints numbers only. It sends nothing anywhere and writes nothing. One file, standard library only, so you can read all of it first.
59
+
60
+ ## How it counts
61
+
62
+ Usage is weighted by API prices, the closest public stand-in for how plan limits are counted. Per model: Opus 5, Sonnet 3, Haiku 1. Within a model: input 1, cache write 1.25 (5-minute) or 2 (1-hour), cache read 0.1, output 5. Plan limits aren't published, so treat the shares as close, not exact.
63
+
64
+ The fresh-start saving assumes each turn past 200k would instead carry a 20k summary. It ignores what a summary leaves out.
65
+
66
+ ## Options
67
+
68
+ ```
69
+ contexttax --days 7 # last week
70
+ contexttax --days 0 # everything in the logs
71
+ contexttax --json # the numbers as JSON
72
+ ```
73
+
74
+ ## Share yours
75
+
76
+ If your numbers look different from mine, I'd like to see them. Open an issue with the output.
77
+
78
+ MIT licence.
@@ -0,0 +1,63 @@
1
+ # contexttax
2
+
3
+ Where your Claude Code usage actually goes.
4
+
5
+ Most of it isn't loops or re-reads. It's long conversations. Every turn sends the whole conversation again, so a session at 500k tokens pays for all of it on every message.
6
+
7
+ ```
8
+ uvx contexttax
9
+ ```
10
+
11
+ No install with uv. Or, with plain Python 3:
12
+
13
+ ```
14
+ curl -fsSL https://raw.githubusercontent.com/theadamdanielsson/contexttax/main/contexttax.py | python3 -
15
+ ```
16
+
17
+ ## What it shows
18
+
19
+ My last 27 days:
20
+
21
+ ```
22
+ contexttax · Claude Code · last 27 days · 316 sessions · 8,188 model calls
23
+
24
+ 76% of your usage went to turns where the conversation was already over 200k tokens.
25
+ Your biggest 10% of sessions used 90%. One session alone used 14%.
26
+ A turn at 500k+ context costs 5.0x a turn at 50-100k.
27
+ Median turn: 255k of context. One in ten: over 604k.
28
+
29
+ Starting fresh with a short summary at 200k would have saved about 24%.
30
+
31
+ What people usually blame:
32
+ re-reading the same file 43 of 792 reads
33
+ loops (same call 3 times) 1
34
+ huge tool outputs 1%
35
+ subagents 5%
36
+ cache rebuilt after a break 4%
37
+
38
+ Where it goes: cache reads 70%, cache writes 20%, output 10%
39
+ ```
40
+
41
+ ## Private by design
42
+
43
+ It reads the logs Claude Code already keeps in `~/.claude/projects`. It prints numbers only. It sends nothing anywhere and writes nothing. One file, standard library only, so you can read all of it first.
44
+
45
+ ## How it counts
46
+
47
+ Usage is weighted by API prices, the closest public stand-in for how plan limits are counted. Per model: Opus 5, Sonnet 3, Haiku 1. Within a model: input 1, cache write 1.25 (5-minute) or 2 (1-hour), cache read 0.1, output 5. Plan limits aren't published, so treat the shares as close, not exact.
48
+
49
+ The fresh-start saving assumes each turn past 200k would instead carry a 20k summary. It ignores what a summary leaves out.
50
+
51
+ ## Options
52
+
53
+ ```
54
+ contexttax --days 7 # last week
55
+ contexttax --days 0 # everything in the logs
56
+ contexttax --json # the numbers as JSON
57
+ ```
58
+
59
+ ## Share yours
60
+
61
+ If your numbers look different from mine, I'd like to see them. Open an issue with the output.
62
+
63
+ MIT licence.
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: contexttax
3
+ Version: 0.1.0
4
+ Summary: Where your Claude Code usage actually goes. Reads local logs only.
5
+ Author: Adam Danielsson
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/theadamdanielsson/contexttax
8
+ Keywords: claude-code,usage,limits,tokens,context
9
+ Classifier: Environment :: Console
10
+ Classifier: Programming Language :: Python :: 3
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Dynamic: license-file
15
+
16
+ # contexttax
17
+
18
+ Where your Claude Code usage actually goes.
19
+
20
+ Most of it isn't loops or re-reads. It's long conversations. Every turn sends the whole conversation again, so a session at 500k tokens pays for all of it on every message.
21
+
22
+ ```
23
+ uvx contexttax
24
+ ```
25
+
26
+ No install with uv. Or, with plain Python 3:
27
+
28
+ ```
29
+ curl -fsSL https://raw.githubusercontent.com/theadamdanielsson/contexttax/main/contexttax.py | python3 -
30
+ ```
31
+
32
+ ## What it shows
33
+
34
+ My last 27 days:
35
+
36
+ ```
37
+ contexttax · Claude Code · last 27 days · 316 sessions · 8,188 model calls
38
+
39
+ 76% of your usage went to turns where the conversation was already over 200k tokens.
40
+ Your biggest 10% of sessions used 90%. One session alone used 14%.
41
+ A turn at 500k+ context costs 5.0x a turn at 50-100k.
42
+ Median turn: 255k of context. One in ten: over 604k.
43
+
44
+ Starting fresh with a short summary at 200k would have saved about 24%.
45
+
46
+ What people usually blame:
47
+ re-reading the same file 43 of 792 reads
48
+ loops (same call 3 times) 1
49
+ huge tool outputs 1%
50
+ subagents 5%
51
+ cache rebuilt after a break 4%
52
+
53
+ Where it goes: cache reads 70%, cache writes 20%, output 10%
54
+ ```
55
+
56
+ ## Private by design
57
+
58
+ It reads the logs Claude Code already keeps in `~/.claude/projects`. It prints numbers only. It sends nothing anywhere and writes nothing. One file, standard library only, so you can read all of it first.
59
+
60
+ ## How it counts
61
+
62
+ Usage is weighted by API prices, the closest public stand-in for how plan limits are counted. Per model: Opus 5, Sonnet 3, Haiku 1. Within a model: input 1, cache write 1.25 (5-minute) or 2 (1-hour), cache read 0.1, output 5. Plan limits aren't published, so treat the shares as close, not exact.
63
+
64
+ The fresh-start saving assumes each turn past 200k would instead carry a 20k summary. It ignores what a summary leaves out.
65
+
66
+ ## Options
67
+
68
+ ```
69
+ contexttax --days 7 # last week
70
+ contexttax --days 0 # everything in the logs
71
+ contexttax --json # the numbers as JSON
72
+ ```
73
+
74
+ ## Share yours
75
+
76
+ If your numbers look different from mine, I'd like to see them. Open an issue with the output.
77
+
78
+ MIT licence.
@@ -0,0 +1,10 @@
1
+ LICENSE
2
+ README.md
3
+ contexttax.py
4
+ pyproject.toml
5
+ contexttax.egg-info/PKG-INFO
6
+ contexttax.egg-info/SOURCES.txt
7
+ contexttax.egg-info/dependency_links.txt
8
+ contexttax.egg-info/entry_points.txt
9
+ contexttax.egg-info/top_level.txt
10
+ tests/test_contexttax.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ contexttax = contexttax:main
@@ -0,0 +1 @@
1
+ contexttax
@@ -0,0 +1,275 @@
1
+ #!/usr/bin/env python3
2
+ """contexttax: where your Claude Code usage actually goes.
3
+
4
+ Reads the session logs Claude Code keeps on this computer (~/.claude/projects) and shows
5
+ how much of your usage went to long conversations, compared with the causes people
6
+ usually blame. Prints numbers only. Reads no conversation text into the output, sends
7
+ nothing anywhere, writes nothing.
8
+
9
+ Usage is weighted by API prices, the closest public stand-in for how plan limits are
10
+ counted: per model, Opus 5, Sonnet 3, Haiku 1; within a model, input 1, cache write
11
+ 1.25 (5-minute) or 2 (1-hour), cache read 0.1, output 5.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import glob
18
+ import json
19
+ import os
20
+ import sys
21
+ from collections import Counter
22
+ from datetime import datetime, timedelta, timezone
23
+
24
+ __version__ = "0.1.0"
25
+
26
+ BIG_CONTEXT = 200_000 # where "long" starts in the report
27
+ SUMMARY = 20_000 # tokens a fresh start would carry over
28
+ BIG_RESULT_CHARS = 40_000
29
+ CHARS_PER_TOKEN = 3.6
30
+
31
+
32
+ def model_weight(model: str) -> float:
33
+ m = model.lower()
34
+ return 5.0 if "opus" in m else 1.0 if "haiku" in m else 3.0
35
+
36
+
37
+ def parse_time(s: str | None) -> float | None:
38
+ if not s:
39
+ return None
40
+ try:
41
+ return datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp()
42
+ except ValueError:
43
+ return None
44
+
45
+
46
+ def result_chars(content) -> int:
47
+ if isinstance(content, str):
48
+ return len(content)
49
+ if isinstance(content, list):
50
+ n = 0
51
+ for c in content:
52
+ if isinstance(c, dict) and c.get("type") == "text":
53
+ n += len(c.get("text", ""))
54
+ elif isinstance(c, dict) and c.get("type") == "image":
55
+ n += 6000
56
+ return n
57
+ return 0
58
+
59
+
60
+ class Turn:
61
+ __slots__ = ("t", "model", "side", "context", "cost", "write_cost", "parts", "write_1h")
62
+
63
+ def __init__(self, t, model, side, usage):
64
+ self.t, self.model, self.side = t, model, side
65
+ cc = usage.get("cache_creation") or {}
66
+ w1h = cc.get("ephemeral_1h_input_tokens", 0) or 0
67
+ w5m = cc.get("ephemeral_5m_input_tokens", 0) or 0
68
+ written = usage.get("cache_creation_input_tokens", 0) or 0
69
+ if not w1h and not w5m:
70
+ w5m = written
71
+ read = usage.get("cache_read_input_tokens", 0) or 0
72
+ fresh = usage.get("input_tokens", 0) or 0
73
+ out = usage.get("output_tokens", 0) or 0
74
+ mw = model_weight(model)
75
+ self.parts = {"cache reads": read * 0.1 * mw, "cache writes": (w5m * 1.25 + w1h * 2) * mw, "output": out * 5 * mw, "fresh input": fresh * mw}
76
+ self.cost = sum(self.parts.values())
77
+ self.write_cost = self.parts["cache writes"]
78
+ self.write_1h = bool(w1h)
79
+ self.context = fresh + written + read
80
+
81
+
82
+ def read_session(path: str):
83
+ """Model calls (deduplicated), tool calls and tool result sizes from one log."""
84
+ turns, uses, results, seen = [], {}, {}, set()
85
+ with open(path, errors="ignore") as fh:
86
+ for line in fh:
87
+ try:
88
+ d = json.loads(line)
89
+ except ValueError:
90
+ continue
91
+ m = d.get("message")
92
+ if not isinstance(m, dict):
93
+ continue
94
+ if d.get("type") == "assistant" and m.get("usage"):
95
+ key = m.get("id") or d.get("requestId")
96
+ model = m.get("model") or ""
97
+ if key not in seen and not model.startswith("<"):
98
+ seen.add(key)
99
+ turns.append(Turn(parse_time(d.get("timestamp")), model, bool(d.get("isSidechain")), m["usage"]))
100
+ for c in m.get("content") or []:
101
+ if isinstance(c, dict) and c.get("type") == "tool_use":
102
+ uses[c.get("id")] = (c.get("name"), c.get("input") or {}, bool(d.get("isSidechain")))
103
+ elif d.get("type") == "user" and isinstance(m.get("content"), list):
104
+ for c in m["content"]:
105
+ if isinstance(c, dict) and c.get("type") == "tool_result":
106
+ results[c.get("tool_use_id")] = (parse_time(d.get("timestamp")), result_chars(c.get("content")))
107
+ turns.sort(key=lambda x: x.t or 0)
108
+ return turns, uses, results
109
+
110
+
111
+ def analyse(root: str, days: int | None):
112
+ since = (datetime.now(timezone.utc) - timedelta(days=days)).timestamp() if days else None
113
+ files = glob.glob(os.path.join(root, "**", "*.jsonl"), recursive=True)
114
+ if since:
115
+ files = [f for f in files if os.path.getmtime(f) >= since]
116
+ total = 0.0
117
+ parts = Counter()
118
+ by_model = Counter()
119
+ session_costs = []
120
+ big_context = 0.0
121
+ fresh_start_saving = 0.0
122
+ small_turns, big_turns = [], []
123
+ contexts = []
124
+ subagents = idle_rebuilds = 0.0
125
+ reads = rereads = loops = 0
126
+ big_result_carry = 0.0
127
+ turn_count = 0
128
+ first = last = None
129
+
130
+ for f in files:
131
+ turns, uses, results = read_session(f)
132
+ if since:
133
+ turns = [t for t in turns if t.t and t.t >= since]
134
+ if not turns:
135
+ continue
136
+ s_cost, prev = 0.0, None
137
+ for t in turns:
138
+ total += t.cost
139
+ s_cost += t.cost
140
+ turn_count += 1
141
+ by_model[t.model] += t.cost
142
+ for k, v in t.parts.items():
143
+ parts[k] += v
144
+ if t.t:
145
+ first = t.t if first is None else min(first, t.t)
146
+ last = t.t if last is None else max(last, t.t)
147
+ if t.side:
148
+ subagents += t.cost
149
+ continue
150
+ contexts.append(t.context)
151
+ if t.context >= BIG_CONTEXT:
152
+ big_context += t.cost
153
+ # Started over with a summary at BIG_CONTEXT, this turn would carry
154
+ # (BIG_CONTEXT - SUMMARY) fewer tokens, re-read from cache.
155
+ fresh_start_saving += (BIG_CONTEXT - SUMMARY) * 0.1 * model_weight(t.model)
156
+ if 50_000 <= t.context < 100_000:
157
+ small_turns.append(t.cost)
158
+ elif t.context >= 500_000:
159
+ big_turns.append(t.cost)
160
+ # Most of the context written to cache at once = the cache was rebuilt.
161
+ written = t.parts["cache writes"]
162
+ if prev and prev.t and t.t and written > 0 and t.context > 20_000:
163
+ life = 3600 if t.write_1h else 300
164
+ if t.t - prev.t > life and written > 0.5 * t.cost:
165
+ idle_rebuilds += written
166
+ prev = t
167
+ session_costs.append(s_cost)
168
+
169
+ # Re-reads of the same file and range; runs of three identical calls.
170
+ seen_reads = Counter()
171
+ sequence = []
172
+ for tid, (name, inp, side) in uses.items():
173
+ if name == "Read":
174
+ reads += 1
175
+ key = (inp.get("file_path"), inp.get("offset"), inp.get("limit"))
176
+ seen_reads[key] += 1
177
+ if seen_reads[key] > 1:
178
+ rereads += 1
179
+ if not side:
180
+ sequence.append(name + json.dumps({k: v for k, v in inp.items() if k != "description"}, sort_keys=True)[:400])
181
+ run = 1
182
+ for a, b in zip(sequence, sequence[1:]):
183
+ run = run + 1 if a == b else 1
184
+ if run == 3:
185
+ loops += 1
186
+ # Big tool results, re-sent from cache on every later turn.
187
+ main_times = [(t.t, model_weight(t.model)) for t in turns if not t.side and t.t]
188
+ for tid, (when, chars) in results.items():
189
+ if chars > BIG_RESULT_CHARS and when:
190
+ later = sum(w for (tt, w) in main_times if tt > when)
191
+ big_result_carry += chars / CHARS_PER_TOKEN * 0.1 * later
192
+
193
+ if not total:
194
+ return None
195
+ session_costs.sort(reverse=True)
196
+ top = session_costs[: max(1, len(session_costs) // 10)]
197
+ contexts.sort()
198
+ return {
199
+ "days": min(days or 10**6, max(1, round(((last or 0) - (first or 0)) / 86400))) if first else 0,
200
+ "sessions": len(session_costs),
201
+ "turns": turn_count,
202
+ "parts": {k: v / total for k, v in parts.items()},
203
+ "models": {k: v / total for k, v in by_model.most_common(4)},
204
+ "top_tenth_share": sum(top) / total,
205
+ "biggest_session_share": session_costs[0] / total,
206
+ "big_context_share": big_context / total,
207
+ "median_context": contexts[len(contexts) // 2] if contexts else 0,
208
+ "p90_context": contexts[int(0.9 * len(contexts))] if contexts else 0,
209
+ "big_vs_small": (sum(big_turns) / len(big_turns)) / (sum(small_turns) / len(small_turns)) if big_turns and small_turns else None,
210
+ "fresh_start_saving": min(fresh_start_saving / total, 0.95),
211
+ "blamed": {
212
+ "subagents": subagents / total,
213
+ "cache rebuilt after a break": idle_rebuilds / total,
214
+ "huge tool outputs": min(big_result_carry / total, 1.0),
215
+ },
216
+ "rereads": (rereads, reads),
217
+ "loops": loops,
218
+ }
219
+
220
+
221
+ def pct(x: float) -> str:
222
+ return f"{100 * x:.0f}%" if x >= 0.01 or x == 0 else "<1%"
223
+
224
+
225
+ def report(r: dict) -> str:
226
+ k = lambda n: f"{n / 1000:.0f}k"
227
+ lines = [
228
+ f"contexttax · Claude Code · last {r['days']} days · {r['sessions']} sessions · {r['turns']:,} model calls",
229
+ "",
230
+ f" {pct(r['big_context_share'])} of your usage went to turns where the conversation was already over {k(BIG_CONTEXT)} tokens.",
231
+ f" Your biggest 10% of sessions used {pct(r['top_tenth_share'])}. One session alone used {pct(r['biggest_session_share'])}.",
232
+ ]
233
+ if r["big_vs_small"]:
234
+ lines.append(f" A turn at 500k+ context costs {r['big_vs_small']:.1f}x a turn at 50-100k.")
235
+ lines += [
236
+ f" Median turn: {k(r['median_context'])} of context. One in ten: over {k(r['p90_context'])}.",
237
+ "",
238
+ f" Starting fresh with a short summary at {k(BIG_CONTEXT)} would have saved about {pct(r['fresh_start_saving'])}.",
239
+ "",
240
+ " What people usually blame:",
241
+ f" re-reading the same file {r['rereads'][0]} of {r['rereads'][1]} reads",
242
+ f" loops (same call 3 times) {r['loops']}",
243
+ f" huge tool outputs {pct(r['blamed']['huge tool outputs'])}",
244
+ f" subagents {pct(r['blamed']['subagents'])}",
245
+ f" cache rebuilt after a break {pct(r['blamed']['cache rebuilt after a break'])}",
246
+ "",
247
+ " Where it goes: " + ", ".join(f"{name} {pct(v)}" for name, v in sorted(r["parts"].items(), key=lambda x: -x[1]) if v >= 0.005),
248
+ " Models: " + ", ".join(f"{m} {pct(v)}" for m, v in r["models"].items() if v >= 0.005),
249
+ "",
250
+ " Weighted by API prices. Read on this computer; nothing was sent anywhere.",
251
+ " github.com/theadamdanielsson/contexttax",
252
+ ]
253
+ return "\n".join(lines)
254
+
255
+
256
+ def main(argv=None) -> int:
257
+ p = argparse.ArgumentParser(prog="contexttax", description="Where your Claude Code usage actually goes. Reads local logs only.")
258
+ p.add_argument("--days", type=int, default=30, help="look back this many days (default 30; 0 = all)")
259
+ p.add_argument("--json", action="store_true", help="print the numbers as JSON")
260
+ p.add_argument("--path", default=os.path.expanduser("~/.claude/projects"), help="where Claude Code keeps its logs")
261
+ p.add_argument("--version", action="version", version=__version__)
262
+ a = p.parse_args(argv)
263
+ if not os.path.isdir(a.path):
264
+ print(f"No Claude Code logs found at {a.path}.", file=sys.stderr)
265
+ return 1
266
+ r = analyse(a.path, a.days or None)
267
+ if not r:
268
+ print("No model calls found in those logs.", file=sys.stderr)
269
+ return 1
270
+ print(json.dumps(r, indent=2) if a.json else report(r))
271
+ return 0
272
+
273
+
274
+ if __name__ == "__main__":
275
+ sys.exit(main())
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "contexttax"
7
+ version = "0.1.0"
8
+ description = "Where your Claude Code usage actually goes. Reads local logs only."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "Adam Danielsson" }]
13
+ keywords = ["claude-code", "usage", "limits", "tokens", "context"]
14
+ classifiers = ["Environment :: Console", "Programming Language :: Python :: 3"]
15
+
16
+ [project.urls]
17
+ Homepage = "https://github.com/theadamdanielsson/contexttax"
18
+
19
+ [project.scripts]
20
+ contexttax = "contexttax:main"
21
+
22
+ [tool.setuptools]
23
+ py-modules = ["contexttax"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,63 @@
1
+ """contexttax on a small made-up log: long-context turns, a sub-agent, a re-read and a loop."""
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ import tempfile
7
+ import unittest
8
+
9
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
10
+ import contexttax # noqa: E402
11
+
12
+
13
+ def call(i, read, written=0, out=100, side=False, model="claude-opus-5-5", t="2026-10-20T10:00:00Z", tools=()):
14
+ content = [{"type": "tool_use", "id": f"tu{i}-{n}", "name": name, "input": inp} for n, (name, inp) in enumerate(tools)]
15
+ return {
16
+ "type": "assistant", "isSidechain": side, "timestamp": t,
17
+ "message": {"id": f"m{i}", "model": model, "content": content,
18
+ "usage": {"input_tokens": 0, "cache_read_input_tokens": read, "cache_creation_input_tokens": written,
19
+ "cache_creation": {"ephemeral_1h_input_tokens": written, "ephemeral_5m_input_tokens": 0}, "output_tokens": out}},
20
+ }
21
+
22
+
23
+ class ContextTax(unittest.TestCase):
24
+ def setUp(self):
25
+ self.dir = tempfile.mkdtemp()
26
+ read = {"file_path": "/a.py"}
27
+ lines = [
28
+ call(1, 50_000, 10_000, tools=[("Read", read)]),
29
+ call(2, 60_000, tools=[("Read", read)]),
30
+ call(3, 300_000, tools=[("Bash", {"command": "zzsecretzz", "description": "a"})]),
31
+ call(4, 300_000, tools=[("Bash", {"command": "zzsecretzz", "description": "b"})]),
32
+ call(5, 600_000, tools=[("Bash", {"command": "zzsecretzz", "description": "c"})]),
33
+ call(6, 20_000, side=True),
34
+ # The same message logged twice (one line per content block) counts once.
35
+ call(6, 20_000, side=True),
36
+ ]
37
+ os.makedirs(os.path.join(self.dir, "proj"))
38
+ with open(os.path.join(self.dir, "proj", "s.jsonl"), "w") as f:
39
+ for line in lines:
40
+ f.write(json.dumps(line) + "\n")
41
+ f.write("not json\n")
42
+
43
+ def test_numbers(self):
44
+ r = contexttax.analyse(self.dir, None)
45
+ self.assertEqual(r["sessions"], 1)
46
+ self.assertEqual(r["turns"], 6)
47
+ self.assertEqual(r["rereads"], (1, 2))
48
+ self.assertEqual(r["loops"], 1) # three identical shell calls; the description is ignored
49
+ self.assertGreater(r["big_context_share"], 0.6)
50
+ self.assertGreater(r["blamed"]["subagents"], 0)
51
+ self.assertAlmostEqual(sum(r["parts"].values()), 1.0, places=6)
52
+
53
+ def test_report_has_no_log_text(self):
54
+ out = contexttax.report(contexttax.analyse(self.dir, None))
55
+ self.assertNotIn("/a.py", out)
56
+ self.assertNotIn("zzsecretzz", out)
57
+
58
+ def test_missing_logs(self):
59
+ self.assertEqual(contexttax.main(["--path", os.path.join(self.dir, "nope")]), 1)
60
+
61
+
62
+ if __name__ == "__main__":
63
+ unittest.main()