havrin 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,9 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .env
4
+ *.gguf
5
+ *.safetensors
6
+ .wrangler/
7
+ node_modules/
8
+ dist/
9
+ .venv/
havrin-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,38 @@
1
+ Metadata-Version: 2.5
2
+ Name: havrin
3
+ Version: 0.1.0
4
+ Summary: See where your Claude Code usage goes, from your own local session logs
5
+ Project-URL: Homepage, https://havrin.dev
6
+ Author: Gavin Schiller
7
+ License-Expression: MIT
8
+ License-File: havrin/LICENSE
9
+ Keywords: claude,claude-code,cost,llm,usage
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+
16
+ # havrin
17
+
18
+ See where your Claude Code usage goes, from the session logs Claude Code already keeps on your machine.
19
+
20
+ ```sh
21
+ pipx install havrin # or: pip install havrin
22
+ havrin meter --days 30 --what-if
23
+ ```
24
+
25
+ The meter reads the JSONL transcripts in `~/.claude/projects` and prints:
26
+
27
+ - tokens by kind (input, cache writes, cache reads, output) and each kind's cost-weighted share;
28
+ - which tools filled the context, in characters;
29
+ - with `--what-if`: the share of cost you would have saved with subagents on Sonnet, with the main context capped
30
+ (150k tokens by default), and with both. These are estimates replayed from your own logs, not promises.
31
+
32
+ **Privacy:** it only reads local files, sends nothing anywhere, and prints counts, never the content of your sessions.
33
+
34
+ Options: `--days N` (default 7), `--sessions` (one line per transcript), `--dir PATH`, `--sonnet-ratio`, `--cap`.
35
+ Python 3.10+, no dependencies.
36
+
37
+ Havrin is building the rest: routing routine Claude Code steps to cheaper models and your own machine, with your
38
+ tests checking every result. Join the beta at [havrin.dev](https://havrin.dev).
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gavin Schiller
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,23 @@
1
+ # havrin
2
+
3
+ See where your Claude Code usage goes, from the session logs Claude Code already keeps on your machine.
4
+
5
+ ```sh
6
+ pipx install havrin # or: pip install havrin
7
+ havrin meter --days 30 --what-if
8
+ ```
9
+
10
+ The meter reads the JSONL transcripts in `~/.claude/projects` and prints:
11
+
12
+ - tokens by kind (input, cache writes, cache reads, output) and each kind's cost-weighted share;
13
+ - which tools filled the context, in characters;
14
+ - with `--what-if`: the share of cost you would have saved with subagents on Sonnet, with the main context capped
15
+ (150k tokens by default), and with both. These are estimates replayed from your own logs, not promises.
16
+
17
+ **Privacy:** it only reads local files, sends nothing anywhere, and prints counts, never the content of your sessions.
18
+
19
+ Options: `--days N` (default 7), `--sessions` (one line per transcript), `--dir PATH`, `--sonnet-ratio`, `--cap`.
20
+ Python 3.10+, no dependencies.
21
+
22
+ Havrin is building the rest: routing routine Claude Code steps to cheaper models and your own machine, with your
23
+ tests checking every result. Join the beta at [havrin.dev](https://havrin.dev).
@@ -0,0 +1,2 @@
1
+ """Havrin: see where your Claude Code usage goes, from your own local session logs."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,30 @@
1
+ """The `havrin` command. The meter itself is hybrid/meter.py, copied in as havrin/meter.py at build time
2
+ (see pyproject.toml), so the private repo keeps one copy of it."""
3
+ import sys
4
+
5
+ from . import __version__
6
+
7
+ USAGE = """usage: havrin meter [--days N] [--what-if] [--sessions] [--dir PATH]
8
+ havrin --version
9
+
10
+ meter where your Claude Code usage went, read from local session logs (nothing is sent anywhere)"""
11
+
12
+
13
+ def main(argv=None):
14
+ args = sys.argv[1:] if argv is None else argv
15
+ if args[:1] == ["meter"]:
16
+ from . import meter
17
+ meter.main(args[1:], prog="havrin meter")
18
+ return 0
19
+ if args[:1] in (["--version"], ["-V"]):
20
+ print(f"havrin {__version__}")
21
+ return 0
22
+ if args[:1] in ([], ["-h"], ["--help"]):
23
+ print(USAGE)
24
+ return 0
25
+ print(USAGE, file=sys.stderr)
26
+ return 2
27
+
28
+
29
+ if __name__ == "__main__":
30
+ sys.exit(main())
@@ -0,0 +1,171 @@
1
+ """Savings meter, step 1: where a coding agent's usage goes, read from Claude Code's own session logs.
2
+
3
+ Usage: python3 -m hybrid.meter [--days 7] [--dir ~/.claude/projects]
4
+
5
+ Reads the local JSONL transcripts Claude Code writes (one file per session) and prints, per session and in total:
6
+ - tokens by kind;
7
+ - a cost-weighted share of each kind;
8
+ - characters each tool's results added to the context;
9
+ - characters written as text or tool input.
10
+
11
+ Only local files are read, nothing is sent anywhere, and only counts are printed, never content.
12
+ Savings need a comparison (helpers on vs. off); this module only measures.
13
+ """
14
+ import argparse
15
+ import json
16
+ import time
17
+ from collections import Counter
18
+ from pathlib import Path
19
+
20
+ KINDS = ("input", "cache_write_5m", "cache_write_1h", "cache_read", "output")
21
+ # shortcut: Anthropic list-price ratios to new input as of 2026-10 (5-minute cache writes 1.25x, 1-hour writes 2x,
22
+ # reads 0.1x, output 5x); verify against current pricing, and use per-model prices if dollars are needed.
23
+ WEIGHTS = {"input": 1.0, "cache_write_5m": 1.25, "cache_write_1h": 2.0, "cache_read": 0.1, "output": 5.0}
24
+
25
+
26
+ def _tokens(usage):
27
+ split = usage.get("cache_creation") or {}
28
+ write_1h = split.get("ephemeral_1h_input_tokens", 0)
29
+ return Counter({"input": usage.get("input_tokens", 0),
30
+ "cache_write_5m": usage.get("cache_creation_input_tokens", 0) - write_1h,
31
+ "cache_write_1h": write_1h,
32
+ "cache_read": usage.get("cache_read_input_tokens", 0),
33
+ "output": usage.get("output_tokens", 0)})
34
+
35
+
36
+ def _text(content):
37
+ if isinstance(content, str):
38
+ return content
39
+ return "".join(x.get("text", "") for x in content if isinstance(x, dict)) if isinstance(content, list) else ""
40
+
41
+
42
+ def session(path, seen=None):
43
+ """{"requests", "tokens", "models", "read", "written", "start", "end"} for one transcript. A request's usage is
44
+ logged once per content block, so it is counted once per requestId (the last entry wins). Requests already in
45
+ `seen` (a set shared across files: resumed sessions repeat earlier requests) are skipped and new ones added."""
46
+ seen = set() if seen is None else seen
47
+ usage, names, read, written, stamps = {}, {}, Counter(), Counter(), []
48
+ with open(path, encoding="utf-8", errors="replace") as f:
49
+ for line in f:
50
+ try:
51
+ e = json.loads(line)
52
+ except json.JSONDecodeError:
53
+ continue
54
+ if not isinstance(e, dict) or not isinstance(e.get("message"), dict):
55
+ continue
56
+ if e.get("timestamp"):
57
+ stamps.append(e["timestamp"])
58
+ m, kind = e["message"], e.get("type")
59
+ content = m.get("content") if isinstance(m.get("content"), list) else []
60
+ if kind == "assistant":
61
+ rid = e.get("requestId") or e.get("uuid")
62
+ if isinstance(m.get("usage"), dict) and rid not in seen:
63
+ usage[rid] = (m.get("model") or "?", m["usage"])
64
+ for b in content:
65
+ if isinstance(b, dict) and b.get("type") == "tool_use":
66
+ names[b.get("id")] = b.get("name", "?")
67
+ written[b.get("name", "?")] += len(json.dumps(b.get("input", {})))
68
+ elif isinstance(b, dict) and b.get("type") == "text":
69
+ written["text"] += len(b.get("text", ""))
70
+ elif kind == "user":
71
+ for b in content:
72
+ if isinstance(b, dict) and b.get("type") == "tool_result":
73
+ read[names.get(b.get("tool_use_id"), "?")] += len(_text(b.get("content")))
74
+ seen.update(usage)
75
+ models = Counter()
76
+ for model, u in usage.values():
77
+ models[model] += sum(weighted(_tokens(u)).values())
78
+ return {"requests": len(usage), "tokens": sum((_tokens(u) for _, u in usage.values()), Counter()),
79
+ "models": models, "reads": [u.get("cache_read_input_tokens", 0) for _, u in usage.values()],
80
+ "read": read, "written": written, "start": min(stamps, default=""),
81
+ "end": max(stamps, default="")}
82
+
83
+
84
+ def sessions(root, days):
85
+ """(path, session) for transcripts under `root` modified in the last `days` days, oldest first."""
86
+ since = time.time() - days * 86400
87
+ paths = sorted((p for p in Path(root).expanduser().rglob("*.jsonl") if p.stat().st_mtime >= since),
88
+ key=lambda p: p.stat().st_mtime)
89
+ seen = set()
90
+ return [(p, s) for p in paths if (s := session(p, seen))["requests"]]
91
+
92
+
93
+ def _shares(counter, top=6):
94
+ total = sum(counter.values())
95
+ if not total:
96
+ return "none"
97
+ shown = [(k, v) for k, v in counter.most_common(top) if v]
98
+ rest = total - sum(v for _, v in shown)
99
+ return ", ".join(f"{k} {v / total:.0%}" for k, v in shown) + (f", other {rest / total:.0%}" if rest else "")
100
+
101
+
102
+ def weighted(tokens):
103
+ return Counter({k: tokens[k] * WEIGHTS[k] for k in KINDS})
104
+
105
+
106
+ def report(found, per_session=False):
107
+ lines, tokens, models, read, written = [], Counter(), Counter(), Counter(), Counter()
108
+ for path, s in found:
109
+ w = weighted(s["tokens"])
110
+ if per_session:
111
+ lines.append(f"{s['start'][:16]} {path.stem[:8]}: {s['requests']} requests, "
112
+ f"{sum(w.values()) / 1e6:.2f}M input-equivalent tokens ({_shares(w)})")
113
+ tokens.update(s["tokens"]), models.update(s["models"]), read.update(s["read"]), written.update(s["written"])
114
+ if not found:
115
+ return ["no Claude Code sessions with usage found"]
116
+ requests = sum(s["requests"] for _, s in found)
117
+ context = tokens["input"] + tokens["cache_write_5m"] + tokens["cache_write_1h"] + tokens["cache_read"]
118
+ subagents = sum(p.stem.startswith("agent-") for p, _ in found)
119
+ sub_cost = sum(sum(s["models"].values()) for p, s in found if p.stem.startswith("agent-"))
120
+ all_cost = sum(sum(s["models"].values()) for _, s in found)
121
+ return lines + ([""] if lines else []) + [
122
+ f"total: {len(found)} transcripts ({subagents} subagent), {requests} requests, "
123
+ f"{sum(weighted(tokens).values()) / 1e6:.1f}M input-equivalent tokens",
124
+ f" average context per request: {context / requests:,.0f} tokens",
125
+ f" cost-weighted, by kind: {_shares(weighted(tokens))}",
126
+ f" cost-weighted, by model: {_shares(models)}",
127
+ f" subagents' share of cost: {sub_cost / all_cost if all_cost else 0:.0%}",
128
+ f" tokens: " + ", ".join(f"{k} {tokens[k]:,}" for k in KINDS),
129
+ f" chars read, by tool: {_shares(read)} ({sum(read.values()):,} chars)",
130
+ f" chars written, by kind: {_shares(written)} ({sum(written.values()):,} chars)"]
131
+
132
+
133
+ def what_if(found, sonnet_ratio=0.6, cap=150_000):
134
+ """Replay of the measured usage under two changes, in cost-weighted tokens:
135
+ - subagents that ran on Opus repriced at `sonnet_ratio` of Opus (list-price ratio; check current prices);
136
+ - main conversations never re-reading more than `cap` tokens of context per request (as if cleared or compacted
137
+ there). shortcut: an upper bound; work lost by clearing would partly be re-read, which only a live test shows.
138
+ """
139
+ total = sum(sum(s["models"].values()) for _, s in found)
140
+ if not total:
141
+ return ["no usage to replay"]
142
+ agent = [s for p, s in found if p.stem.startswith("agent-")]
143
+ main_ = [s for p, s in found if not p.stem.startswith("agent-")]
144
+ opus_sub = sum(v for s in agent for m, v in s["models"].items() if "opus" in m)
145
+ sub_saving = opus_sub * (1 - sonnet_ratio)
146
+ cap_saving = sum(max(0, r - cap) * WEIGHTS["cache_read"] for s in main_ for r in s["reads"])
147
+ lines = [f"what-if replay of {total / 1e6:.1f}M input-equivalent tokens:",
148
+ f" subagents on Sonnet (Opus subagent cost {opus_sub / total:.0%} of total, Sonnet at "
149
+ f"{sonnet_ratio:.0%} of Opus price): saves {sub_saving / total:.0%}",
150
+ f" main context capped at {cap // 1000}k tokens (upper bound): saves {cap_saving / total:.0%}",
151
+ f" both: saves {(sub_saving + cap_saving) / total:.0%}"]
152
+ return lines
153
+
154
+
155
+ def main(argv=None, prog="python3 -m hybrid.meter"):
156
+ p = argparse.ArgumentParser(prog=prog, description=__doc__.split("\n")[0])
157
+ p.add_argument("--days", type=float, default=7, help="sessions modified in the last N days (default 7)")
158
+ p.add_argument("--dir", default="~/.claude/projects", help="Claude Code transcript directory")
159
+ p.add_argument("--sessions", action="store_true", help="also print one line per transcript")
160
+ p.add_argument("--what-if", action="store_true", help="also replay the usage with subagents on Sonnet and a "
161
+ "context cap (see what_if)")
162
+ p.add_argument("--sonnet-ratio", type=float, default=0.6, help="Sonnet price as a share of Opus (default 0.6)")
163
+ p.add_argument("--cap", type=int, default=150_000, help="context cap in tokens for --what-if (default 150000)")
164
+ args = p.parse_args(argv)
165
+ found = sessions(args.dir, args.days)
166
+ print("\n".join(report(found, args.sessions) + (["", *what_if(found, args.sonnet_ratio, args.cap)]
167
+ if args.what_if else [])))
168
+
169
+
170
+ if __name__ == "__main__":
171
+ main()
@@ -0,0 +1,40 @@
1
+ # Builds the public `havrin` package: the meter only (MIT). The rest of this repo is private and must never
2
+ # enter the wheel or sdist, so both targets list exactly what they ship.
3
+ [build-system]
4
+ requires = ["hatchling>=1.26"]
5
+ build-backend = "hatchling.build"
6
+
7
+ [project]
8
+ name = "havrin"
9
+ dynamic = ["version"]
10
+ description = "See where your Claude Code usage goes, from your own local session logs"
11
+ readme = "havrin/README.md"
12
+ license = "MIT"
13
+ license-files = ["havrin/LICENSE"]
14
+ requires-python = ">=3.10"
15
+ authors = [{ name = "Gavin Schiller" }]
16
+ keywords = ["claude", "claude-code", "llm", "usage", "cost"]
17
+ classifiers = [
18
+ "Environment :: Console",
19
+ "Intended Audience :: Developers",
20
+ "Programming Language :: Python :: 3",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://havrin.dev"
25
+
26
+ [project.scripts]
27
+ havrin = "havrin.__main__:main"
28
+
29
+ [tool.hatch.version]
30
+ path = "havrin/__init__.py"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["havrin"]
34
+ exclude = ["havrin/README.md", "havrin/LICENSE"]
35
+
36
+ [tool.hatch.build.targets.wheel.force-include]
37
+ "hybrid/meter.py" = "havrin/meter.py"
38
+
39
+ [tool.hatch.build.targets.sdist]
40
+ only-include = ["havrin", "hybrid/meter.py", "pyproject.toml"]