sessioncost 0.2.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.
@@ -0,0 +1,2 @@
1
+ """SessionCost: see where every token of your agent session went, turn by turn, and what to fix."""
2
+ __version__ = "0.2.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
sessioncost/analyse.py ADDED
@@ -0,0 +1,385 @@
1
+ """From a Session to the full report model: exact cost per call, the drill-down parts (which always add up to their
2
+ parent), the turn rows, where the cost went, the fixed setup, timing and the fixes.
3
+
4
+ Token sizes that the log does not give directly (how much of a call's input was the fixed setup, how much of its
5
+ output was a file vs a message) are estimated as characters / 4 and then scaled so the parts add up exactly to the
6
+ token counts the log does give. Those parts are marked `estimated`.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ from datetime import datetime
13
+
14
+ from . import fixes as fixmod
15
+ from . import prices as pricemod
16
+ from .model import Call, Session
17
+
18
+ CATEGORIES = [
19
+ ("start", "Start (first call)"),
20
+ ("questions", "Asking you questions"),
21
+ ("talk", "Talking to you"),
22
+ ("reading", "Reading and searching"),
23
+ ("commands", "Running commands"),
24
+ ("writing", "Writing files"),
25
+ ("research", "Research and helpers"),
26
+ ("other", "Other steps"),
27
+ ("closing", "Closing message"),
28
+ ]
29
+ CLASS_LABELS = [
30
+ ("input", "New input, not cached"),
31
+ ("cache_write_5m", "New input, cached for 5 minutes"),
32
+ ("cache_write_1h", "New input, cached for 1 hour"),
33
+ ("cache_read", "Re-read from cache"),
34
+ ("output", "Written by the model"),
35
+ ]
36
+
37
+
38
+ def tok(chars: int) -> int:
39
+ return max(0, int(chars) // 4)
40
+
41
+
42
+ def split_exact(estimates: list[tuple[str, int]], total: int) -> list[tuple[str, int]]:
43
+ """Scale estimated sizes so they add up exactly to `total` (largest-remainder rounding)."""
44
+ s = sum(v for _, v in estimates)
45
+ if total <= 0:
46
+ return [(k, 0) for k, _ in estimates]
47
+ if s <= 0:
48
+ return [(k, 0) for k, _ in estimates[:-1]] + [(estimates[-1][0], total)] if estimates else []
49
+ raw = [(k, v * total / s) for k, v in estimates]
50
+ out = [[k, int(v)] for k, v in raw]
51
+ left = total - sum(v for _, v in out)
52
+ order = sorted(range(len(raw)), key=lambda i: -(raw[i][1] - int(raw[i][1])))
53
+ for i in order[:left]:
54
+ out[i][1] += 1
55
+ return [(k, v) for k, v in out]
56
+
57
+
58
+ def iso(d: datetime | None) -> str | None:
59
+ return d.isoformat() if d else None
60
+
61
+
62
+ def hhmm(d: datetime | None) -> str:
63
+ return d.astimezone().strftime("%H:%M") if d else ""
64
+
65
+
66
+ def secs(a: datetime | None, b: datetime | None) -> float:
67
+ return max(0.0, (b - a).total_seconds()) if a and b else 0.0
68
+
69
+
70
+ class Pricer:
71
+ def __init__(self, prices: dict):
72
+ self.prices = prices
73
+ self.unchecked: set[str] = set()
74
+
75
+ def rates(self, c: Call) -> dict:
76
+ r, checked, _ = pricemod.rates(c.model, self.prices)
77
+ if not checked:
78
+ self.unchecked.add(c.model or "unknown model")
79
+ return r
80
+
81
+ def cost(self, c: Call) -> dict:
82
+ return pricemod.cost(c.usage, self.rates(c))
83
+
84
+ def total(self, c: Call) -> float:
85
+ return sum(self.cost(c).values())
86
+
87
+
88
+ def category(c: Call, first_main: bool, last_main: bool) -> str:
89
+ kinds = {a.kind for a in c.actions}
90
+ if c.agent != "main":
91
+ return "research"
92
+ if first_main:
93
+ return "start"
94
+ if "question" in kinds:
95
+ return "questions"
96
+ if kinds & {"write", "edit"}:
97
+ return "writing"
98
+ if "command" in kinds:
99
+ return "commands"
100
+ if kinds & {"read", "search"}:
101
+ return "reading"
102
+ if kinds & {"helper", "web"}:
103
+ return "research"
104
+ if kinds:
105
+ return "other"
106
+ return "closing" if last_main else "talk"
107
+
108
+
109
+ def call_label(c: Call) -> str:
110
+ if c.actions:
111
+ labels = [a.label for a in c.actions]
112
+ if len(labels) > 4:
113
+ return "; ".join(labels[:3]) + f"; and {len(labels) - 3} more"
114
+ return "; ".join(labels)
115
+ if c.first_text:
116
+ return f"wrote to you: “{c.first_text}”"
117
+ return "thought, wrote nothing visible"
118
+
119
+
120
+ def sent_parts(c: Call, r: dict, setup_tokens: int) -> list[dict]:
121
+ """What was sent: fixed setup + carried history + new input = the call's context, exactly."""
122
+ u = c.usage
123
+ cr_rate, new_rate = r["cache_read"], pricemod.new_input_rate(u, r)
124
+ setup = min(setup_tokens, u.context) if c.agent == "main" else 0
125
+ s_cr = min(setup, u.cache_read)
126
+ s_new = setup - s_cr
127
+ carried = u.cache_read - s_cr
128
+ new = u.new_input - s_new
129
+ parts = []
130
+ if setup_tokens and c.agent == "main":
131
+ parts.append({"what": "fixed setup (system prompt, tool definitions, lists)", "tokens": setup,
132
+ "usd": (s_cr * cr_rate + s_new * new_rate) / 1e6, "estimated": True})
133
+ parts.append({"what": "carried history (re-read from cache)", "tokens": carried, "usd": carried * cr_rate / 1e6,
134
+ "estimated": bool(setup_tokens and c.agent == "main")})
135
+ parts.append({"what": "new input (read for the first time)", "tokens": new, "usd": new * new_rate / 1e6,
136
+ "estimated": bool(s_new)})
137
+ return parts
138
+
139
+
140
+ def written_parts(c: Call, r: dict) -> list[dict]:
141
+ """What was written: messages + files + other tool calls + hidden thinking = the call's output, exactly."""
142
+ u = c.usage
143
+ files = sum(a.written_chars for a in c.actions)
144
+ tools = sum(max(0, a.args_chars - a.written_chars) for a in c.actions)
145
+ est = [("messages to you", tok(c.text_chars)), ("files written", tok(files)),
146
+ ("tool calls (commands, questions, searches)", tok(tools))]
147
+ est = [(k, v) for k, v in est if v]
148
+ if u.thinking is not None:
149
+ think = min(u.thinking, u.output)
150
+ exact_think = True
151
+ else:
152
+ think = max(0, u.output - sum(v for _, v in est))
153
+ exact_think = False
154
+ visible = u.output - think
155
+ if visible and not est:
156
+ est = [("other output (formatting)", 1)]
157
+ parts = [{"what": k, "tokens": v, "usd": v * r["output"] / 1e6, "estimated": True}
158
+ for k, v in split_exact(est, visible)] if visible else []
159
+ parts.append({"what": "hidden thinking", "tokens": think, "usd": think * r["output"] / 1e6,
160
+ "estimated": not exact_think})
161
+ return parts
162
+
163
+
164
+ def money_label(exact: bool, as_of: str, subscription: bool, tokens_exact: bool = True) -> tuple[str, str]:
165
+ """(long label, short label)"""
166
+ if not tokens_exact:
167
+ return ("estimate: this log keeps no token counts, so tokens are estimated from the size of the text, at "
168
+ "API prices as of %s%s" % (as_of, "; your plan is not billed per token" if subscription else ""),
169
+ "estimate")
170
+ if subscription:
171
+ return ("API-equivalent: what these tokens would cost at API prices (as of %s). Your plan is not billed per "
172
+ "token." % as_of, "API-equivalent")
173
+ if exact:
174
+ return ("exact, at the API price list as of %s" % as_of, "exact (API prices %s)" % as_of)
175
+ return ("estimate: at least one model has no checked price (prices as of %s)" % as_of, "estimate")
176
+
177
+
178
+ def analyse(s: Session, prices: dict, subscription: bool = False, engines: list | None = None) -> dict:
179
+ P = Pricer(prices)
180
+ subscription = subscription or not s.billed_per_token
181
+ setup_tokens = tok(s.setup.chars) if s.setup.known else 0
182
+ main_calls = s.calls
183
+ first_id = main_calls[0].msg_id if main_calls else None
184
+ last_id = main_calls[-1].msg_id if main_calls else None
185
+
186
+ # ---- calls (main first, then helpers), each with its parts
187
+ calls: list[dict] = []
188
+ by_obj: dict[int, dict] = {}
189
+ for c in s.all_calls:
190
+ r = P.rates(c)
191
+ cc = pricemod.cost(c.usage, r)
192
+ total = sum(cc.values())
193
+ sp = sent_parts(c, r, setup_tokens)
194
+ wp = written_parts(c, r)
195
+ d = {
196
+ "agent": c.agent, "n": c.n, "turn": c.turn, "model": c.model,
197
+ "ts": iso(c.ts_first), "end": iso(c.ts_last), "time": hhmm(c.ts_first),
198
+ "category": category(c, c.msg_id == first_id and c.agent == "main",
199
+ c.msg_id == last_id and c.agent == "main"),
200
+ "label": call_label(c),
201
+ "actions": [{"label": a.label, "kind": a.kind, "result_tokens": tok(a.result_chars), "tool": a.tool,
202
+ "secs": round(secs(a.ts, a.result_ts), 1) if a.ts and a.result_ts else None,
203
+ "error": a.is_error} for a in c.actions],
204
+ "usage": {"input": c.usage.fresh_input, "cache_write_5m": c.usage.cache_write_5m,
205
+ "cache_write_1h": c.usage.cache_write_1h, "cache_read": c.usage.cache_read,
206
+ "output": c.usage.output, "thinking": c.usage.thinking},
207
+ "context": c.usage.context,
208
+ "cost_by_class": cc,
209
+ "usd": total,
210
+ "sent": {"tokens": c.usage.context, "usd": sum(p["usd"] for p in sp), "parts": sp},
211
+ "written": {"tokens": c.usage.output, "usd": sum(p["usd"] for p in wp), "parts": wp},
212
+ "mix": {"written": cc["output"], "new": cc["input"] + cc["cache_write_5m"] + cc["cache_write_1h"],
213
+ "reread": cc["cache_read"]},
214
+ "new_items": [],
215
+ }
216
+ calls.append(d)
217
+ by_obj[id(c)] = d
218
+
219
+ # what came in before each main call (informational: names the sources of "new input")
220
+ prev = None
221
+ for t in s.turns:
222
+ for i, c in enumerate(t.calls):
223
+ items = []
224
+ if prev is not None:
225
+ items += [{"what": f"result of: {a.label}", "tokens": tok(a.result_chars)}
226
+ for a in prev.actions if a.result_chars]
227
+ for e in t.events:
228
+ if i == 0:
229
+ keep = e.ts is None or c.ts_first is None or e.ts <= c.ts_first
230
+ else:
231
+ keep = bool(e.ts and prev is not None and prev.ts_last and c.ts_first
232
+ and prev.ts_last < e.ts <= c.ts_first)
233
+ if keep:
234
+ said = e.kind in ("typed", "command", "queued", "answer")
235
+ items.append({"what": e.label + (f": “{e.text}”" if said else ""),
236
+ "tokens": tok(e.chars)})
237
+ by_obj[id(c)]["new_items"] = [x for x in items if x["tokens"]]
238
+ prev = c
239
+
240
+ total_usd = sum(d["usd"] for d in calls)
241
+ main_usd = sum(by_obj[id(c)]["usd"] for c in main_calls)
242
+ helper_usd = total_usd - main_usd
243
+
244
+ # ---- timing
245
+ question_wait = 0.0
246
+ turn_rows = []
247
+ for t in s.turns:
248
+ q = sum(secs(a.ts, a.result_ts) for c in t.calls for a in c.actions if a.kind == "question")
249
+ question_wait += q
250
+ start = t.ts or (t.calls[0].ts_first if t.calls else None)
251
+ ends = [x for c in t.calls for x in [c.ts_last] + [a.result_ts for a in c.actions] if x]
252
+ end = max(ends) if ends else start
253
+ minutes = max(0.0, secs(start, end) - q) / 60
254
+ tc = [by_obj[id(c)] for c in t.calls]
255
+ turn_rows.append({
256
+ "n": t.n, "kind": t.kind, "text": plain(t.text), "time": hhmm(start), "ts": iso(start),
257
+ "waited_min": round(t.waited_s / 60, 1), "question_wait_min": round(q / 60, 1),
258
+ "minutes": round(minutes, 1),
259
+ "usd": sum(d["usd"] for d in tc),
260
+ "helper_usd": sum(by_obj[id(c)]["usd"] for c in s.helper_calls if c.turn == t.n),
261
+ "sent": sum(d["sent"]["tokens"] for d in tc), "written": sum(d["written"]["tokens"] for d in tc),
262
+ "helper_sent": sum(by_obj[id(c)]["sent"]["tokens"] for c in s.helper_calls if c.turn == t.n),
263
+ "calls": [calls.index(d) for d in tc],
264
+ "mix": {k: sum(d["mix"][k] for d in tc) for k in ("written", "new", "reread")},
265
+ "events": [{"kind": e.kind, "label": e.label, "text": e.text, "tokens": tok(e.chars)} for e in t.events],
266
+ "fixes": [],
267
+ })
268
+ waiting = sum(t.waited_s for t in s.turns) + question_wait
269
+ working = sum(t["minutes"] for t in turn_rows)
270
+
271
+ # ---- where the cost went
272
+ by_step = {k: {"key": k, "label": lbl, "usd": 0.0, "calls": 0} for k, lbl in CATEGORIES}
273
+ for d in calls:
274
+ by_step[d["category"]]["usd"] += d["usd"]
275
+ by_step[d["category"]]["calls"] += 1
276
+ by_class = []
277
+ for k, lbl in CLASS_LABELS:
278
+ tokens = sum(d["usage"][k] for d in calls)
279
+ by_class.append({"key": k, "label": lbl, "tokens": tokens, "usd": sum(d["cost_by_class"][k] for d in calls)})
280
+ thinking_known = all(d["usage"]["thinking"] is not None for d in calls)
281
+ thinking = sum(p["tokens"] for d in calls for p in d["written"]["parts"] if p["what"] == "hidden thinking")
282
+
283
+ # ---- setup
284
+ used = {a.tool for a in s.actions()}
285
+ setup = {"known": s.setup.known, "tokens": setup_tokens,
286
+ "parts": [{"what": p.what, "tokens": tok(p.chars)} for p in s.setup.parts],
287
+ "tools": [{"name": p.what, "tokens": tok(p.chars),
288
+ "used": p.what in used} for p in s.setup.tools]}
289
+ setup["unused"] = [t["name"] for t in setup["tools"] if not t["used"]]
290
+ setup["unused_tokens"] = sum(t["tokens"] for t in setup["tools"] if not t["used"])
291
+
292
+ # ---- replay order: every call by time, with the running total
293
+ order = sorted(range(len(calls)), key=lambda i: (calls[i]["ts"] or "", i))
294
+ running = 0.0
295
+ replay = []
296
+ for i in order:
297
+ running += calls[i]["usd"]
298
+ replay.append({"call": i, "cum_usd": running})
299
+
300
+ as_of = str(prices.get("as_of") or "unknown date")
301
+ split_known = all(c.usage.write_split_known for c in s.all_calls)
302
+ exact = not P.unchecked and s.tokens_exact and split_known
303
+ long_label, short_label = money_label(exact, as_of, subscription, s.tokens_exact)
304
+ if not exact and long_label.startswith("exact"):
305
+ long_label, short_label = ("estimate: this log does not say which cache writes were kept 5 minutes or 1 hour "
306
+ "(they cost 1.25x and 2x the input price), so all are priced at 5 minutes "
307
+ "(prices as of %s)" % as_of, "estimate")
308
+ reasons = [] # why the total is not exact, biggest first
309
+ if not s.tokens_exact:
310
+ reasons.append("This log keeps no token counts, so tokens are estimated from the size of the text.")
311
+ if P.unchecked:
312
+ reasons.append("No confirmed price for " + ", ".join(sorted(P.unchecked)) +
313
+ " (a third-party or default rate was used).")
314
+ if not split_known:
315
+ reasons.append("The log does not split cache writes into 5-minute and 1-hour, so all are priced at the "
316
+ "5-minute rate.")
317
+ why = " ".join(reasons) or "Every model has a confirmed price and every token class is in the log."
318
+ if subscription:
319
+ why += " Your plan is not billed per token: read the figures as API-equivalent."
320
+
321
+
322
+ out = {
323
+ "tool": "sessioncost",
324
+ "meta": {
325
+ "title": plain(s.title), "agent": s.agent, "agent_label": {"claude-code": "Claude Code", "codex": "Codex", "antigravity": "Antigravity",
326
+ "cursor": "Cursor"}.get(s.agent, s.agent),
327
+ "agent_version": s.agent_version, "model": s.model,
328
+ "models": sorted({c.model for c in s.all_calls if c.model}), "session_id": s.session_id,
329
+ "project": _folder(s.cwd), "started": iso(s.started), "ended": iso(s.ended),
330
+ "date": s.started.astimezone().strftime("%d %b %Y") if s.started else "",
331
+ },
332
+ "money": {"label": long_label, "short": short_label, "as_of": as_of, "exact": exact,
333
+ "subscription": subscription, "unchecked_models": sorted(P.unchecked), "why": why,
334
+ "tokens_exact": s.tokens_exact, "write_split_known": split_known,
335
+ "own_cost_usd": s.own_cost_usd},
336
+ "totals": {
337
+ "usd": total_usd, "main_usd": main_usd, "helpers_usd": helper_usd,
338
+ "calls": len(main_calls), "helper_calls": len(s.helper_calls),
339
+ "turns": len([t for t in s.turns if t.n > 0]),
340
+ "working_min": round(working, 1), "waiting_min": round(waiting / 60, 1),
341
+ "context_max": max((d["context"] for d in calls if d["agent"] == "main"), default=0),
342
+ "sent": sum(d["sent"]["tokens"] for d in calls),
343
+ "output": sum(d["usage"]["output"] for d in calls), "thinking": thinking,
344
+ "thinking_known": thinking_known,
345
+ "by_class": by_class,
346
+ "by_step": [v for v in by_step.values() if v["calls"]],
347
+ },
348
+ "setup": setup,
349
+ "turns": turn_rows,
350
+ "calls": calls,
351
+ "replay": replay,
352
+ "helpers": [{"id": h.id, "description": h.description, "type": h.agent_type, "turn": h.turn,
353
+ "calls": len(h.calls), "usd": sum(by_obj[id(c)]["usd"] for c in h.calls),
354
+ "tokens": sum(c.usage.context + c.usage.output for c in h.calls)} for h in s.helpers],
355
+ "notes": list(s.notes),
356
+ }
357
+
358
+ # ---- fixes: built-in detectors + any engine plugged in (see fixes.py)
359
+ engines = engines if engines is not None else fixmod.load_engines()
360
+ out["fix_engines"] = [getattr(e, "name", "built-in") for e in engines]
361
+ s.fixes = fixmod.find(s, P, engines)
362
+ out["fixes"] = [{"id": f.id, "title": f.title, "why": f.why, "saving_usd": f.saving_usd,
363
+ "saving_tokens": f.saving_tokens, "where": f.where, "turns": f.turns, "items": f.items,
364
+ "source": f.source} for f in s.fixes]
365
+ for f in s.fixes:
366
+ for tr in turn_rows:
367
+ if tr["n"] in f.turns:
368
+ tr["fixes"].append(f.id)
369
+ return out
370
+
371
+
372
+ LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)\s]+)\)")
373
+ PATH_RE = re.compile(r"(?<![\w:/\\])(?:[A-Za-z]:[\\/]+|~[\\/]+|[\\/]+)(?:[^\s\\/\"'`()\[\]]+[\\/]+)+([^\s\\/\"'`()\[\]]*)")
374
+
375
+
376
+ def plain(text: str) -> str:
377
+ """What a person reads: markdown links become their text, file paths become their last name
378
+ ("[$architect](C:/Users/me/SKILL.md)" -> "$architect"). Also keeps folder names out of shared screenshots."""
379
+ text = LINK_RE.sub(lambda m: m.group(1), text or "")
380
+ text = re.sub(r"@\[([^\]]+)\]", lambda m: "@" + re.split(r"[\\/]", m.group(1).rstrip("\\/"))[-1], text)
381
+ return PATH_RE.sub(lambda m: m.group(1) or "folder", text).strip()
382
+
383
+
384
+ def _folder(cwd: str) -> str:
385
+ return cwd.replace("\\", "/").rstrip("/").rsplit("/", 1)[-1] if cwd else ""
sessioncost/cli.py ADDED
@@ -0,0 +1,199 @@
1
+ """sessioncost last | list | <session.jsonl> - see README.md."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import sys
7
+ import webbrowser
8
+ from pathlib import Path
9
+
10
+ from . import __version__, locate, prices as pricemod, readers, report
11
+ from .analyse import analyse
12
+ from .readers import cursor
13
+
14
+ REPORTS = Path.home() / ".sessioncost" / "reports"
15
+
16
+
17
+ def money(x: float) -> str:
18
+ return f"${x:,.2f}" if x >= 0.995 or x == 0 else f"${x:.3f}"
19
+
20
+
21
+ def plural(n: int, word: str) -> str:
22
+ return f"{n} {word}{'' if n == 1 else 's'}"
23
+
24
+
25
+ def minutes(m: float) -> str:
26
+ m = float(m or 0)
27
+ if m >= 60:
28
+ return f"{int(m // 60)}h {int(round(m % 60)):02d}m"
29
+ return f"{m:.0f} min" if m >= 10 else f"{m:.1f} min"
30
+
31
+
32
+ def tokens(n: float) -> str:
33
+ n = float(n or 0)
34
+ if n >= 999_500:
35
+ return f"{n / 1e6:.1f}M"
36
+ return f"{n / 1e3:.0f}k" if n >= 1000 else f"{n:.0f}"
37
+
38
+
39
+ def summary(a: dict, path: Path | None) -> str:
40
+ t, mo = a["totals"], a["money"]
41
+ title = a["meta"]["title"]
42
+ title = title if len(title) <= 70 else title[:69] + "…"
43
+ lines = [f"SessionCost: {title}"]
44
+ helpers = f" (+{plural(t['helper_calls'], 'helper call')})" if t["helper_calls"] else ""
45
+ lines.append(f"{tokens(t['sent'])} tokens sent, {tokens(t['output'])} written · {plural(t['turns'], 'turn')} · "
46
+ f"{plural(t['calls'], 'call')}{helpers} · "
47
+ f"{minutes(t['working_min'])} working, {minutes(t['waiting_min'])} waiting for you")
48
+ turns = [x for x in a["turns"] if x["calls"]]
49
+ if turns and t["sent"]:
50
+ top = max(turns, key=lambda x: x["sent"] + x["helper_sent"])
51
+ sent = top["sent"] + top["helper_sent"]
52
+ text = top["text"] if len(top["text"]) <= 50 else top["text"][:49] + "…"
53
+ lines.append(f"Heaviest turn: #{top['n']} “{text}” · {tokens(sent)} tokens sent ({sent / t['sent']:.0%}) · "
54
+ f"{money(top['usd'] + top['helper_usd'])}")
55
+ if a["fixes"]:
56
+ f = a["fixes"][0]
57
+ lines.append(f"Top fix: {f['title']} · {tokens(f['saving_tokens'])} fewer tokens sent · "
58
+ f"saves {money(f['saving_usd'])} (measured from this session)")
59
+ else:
60
+ lines.append("Top fix: none of the built-in checks fired")
61
+ lines.append(f"Cost: {money(t['usd'])} {mo['short']}")
62
+ if path:
63
+ lines.append(f"Report: {path}")
64
+ return "\n".join(lines)
65
+
66
+
67
+ def build(path, args) -> tuple[dict, Path | None]:
68
+ s = readers.read(path)
69
+ if args.cursor_usage:
70
+ if s.agent != "cursor":
71
+ print("sessioncost: --cursor-usage is for Cursor chats; ignored")
72
+ else:
73
+ used = cursor.apply_usage_csv(s, cursor.read_usage_csv(args.cursor_usage))
74
+ print(f"sessioncost: {used} requests from the usage export matched this chat" if used else
75
+ "sessioncost: no row of the usage export falls inside this chat's turns (check the export's dates)")
76
+ a = analyse(s, pricemod.load(args.prices), subscription=args.subscription)
77
+ out = None
78
+ if not args.json:
79
+ REPORTS.mkdir(parents=True, exist_ok=True)
80
+ out = REPORTS / f"{s.session_id}.html"
81
+ out.write_text(report.render(a), encoding="utf-8")
82
+ return a, out
83
+
84
+
85
+ SKILL = Path(__file__).parent / "skill" / "SKILL.md"
86
+ SKILL_HOMES = [ # (agent, its folder, where its personal skills live inside it)
87
+ ("Claude Code", ".claude", "skills"),
88
+ ("Codex", ".codex", "skills"),
89
+ ("Antigravity", ".gemini", "config/skills"),
90
+ ("Cursor", ".cursor", "skills"),
91
+ ]
92
+
93
+
94
+ def setup(home: Path | None = None) -> list[tuple[str, Path]]:
95
+ """Add the /sessioncost skill to every agent installed for this user. Returns (agent, skill file) written."""
96
+ home = home or Path.home()
97
+ # The skill names this exact Python, so it runs even when no 'python' or 'sessioncost' is on the PATH.
98
+ exe = Path(sys.executable).as_posix()
99
+ run = f'"{exe}" -m sessioncost' if " " in exe else f"{exe} -m sessioncost"
100
+ text = SKILL.read_text(encoding="utf-8").replace("{{RUN}}", run)
101
+ if " " in exe:
102
+ text = text.replace(" If that fails because", " In PowerShell, put `& ` in front of the quoted path. If that fails because")
103
+ done = []
104
+ for agent, folder, sub in SKILL_HOMES:
105
+ if not (home / folder).is_dir():
106
+ continue
107
+ dest = home / folder / sub / "sessioncost" / "SKILL.md"
108
+ dest.parent.mkdir(parents=True, exist_ok=True)
109
+ dest.write_text(text, encoding="utf-8")
110
+ done.append((agent, dest))
111
+ return done
112
+
113
+
114
+ def cmd_setup() -> int:
115
+ done = setup()
116
+ if not done:
117
+ print("No Claude Code, Codex, Antigravity or Cursor folder found for this user; nothing to set up.")
118
+ return 1
119
+ for agent, dest in done:
120
+ print(f"{agent:12} /sessioncost added ({dest})")
121
+ print("\nRestart the agent if it is open, then type /sessioncost in a chat.")
122
+ return 0
123
+
124
+
125
+ def cmd_list(args) -> int:
126
+ found = locate.sessions(args.project)
127
+ if not found:
128
+ print(f"No Claude Code, Codex, Antigravity or Cursor sessions found for {args.project or Path.cwd()}")
129
+ return 1
130
+ P = pricemod.load(args.prices)
131
+ print(f"{'When':16} {'Agent':11} {'Session':8} {'Cost':>8} {'Turns':>5} {'Calls':>5} First message")
132
+ for when, p in found[: args.limit]:
133
+ try:
134
+ a = analyse(readers.read(p), P, engines=[])
135
+ except Exception as e: # one broken log must not hide the others
136
+ print(f"{when.astimezone():%Y-%m-%d %H:%M} {'':11} {locate.short_id(p)} {'?':>8} {'':>5} {'':>5} (could not read: {e})")
137
+ continue
138
+ t = a["totals"]
139
+ title = a["meta"]["title"]
140
+ print(f"{when.astimezone():%Y-%m-%d %H:%M} {a['meta']['agent_label']:11} {locate.short_id(p)} {money(t['usd']):>8} {t['turns']:>5} {t['calls']:>5} "
141
+ f"{title[:60]}{'…' if len(title) > 60 else ''}")
142
+ print("\nOpen one: sessioncost <session> (its first characters are enough)")
143
+ return 0
144
+
145
+
146
+ def main(argv: list[str] | None = None) -> int:
147
+ for stream in (sys.stdout, sys.stderr):
148
+ try:
149
+ stream.reconfigure(encoding="utf-8", errors="replace")
150
+ except (AttributeError, ValueError):
151
+ pass
152
+ ap = argparse.ArgumentParser(
153
+ prog="sessioncost",
154
+ description="See where every token of your agent session went, turn by turn, and what to fix. "
155
+ "Local only: reads log files, sends nothing anywhere.")
156
+ ap.add_argument("target", nargs="?", default="last",
157
+ help="'last' (newest Claude Code, Codex, Antigravity or Cursor session of this project, the default), 'list', 'setup' (add /sessioncost to your agents), or a session file (.jsonl, or an Antigravity .db)")
158
+ ap.add_argument("--project", help="project folder (default: the current folder)")
159
+ ap.add_argument("--no-open", action="store_true", help="write the report but do not open it")
160
+ ap.add_argument("--json", action="store_true", help="print the full model as JSON instead of a report")
161
+ ap.add_argument("--prices", help="a prices.json to merge over the built-in one")
162
+ ap.add_argument("--subscription", action="store_true",
163
+ help="you are on a subscription plan (Claude Pro/Max, ChatGPT): label money as API-equivalent, not billed")
164
+ ap.add_argument("--cursor-usage", metavar="CSV",
165
+ help="Cursor only: your usage export from the Cursor dashboard, for exact token counts")
166
+ ap.add_argument("--limit", type=int, default=10, help="list: how many sessions (default 10)")
167
+ ap.add_argument("--version", action="version", version=f"sessioncost {__version__}")
168
+ args = ap.parse_args(argv)
169
+
170
+ if args.target == "setup":
171
+ return cmd_setup()
172
+ if args.target == "list":
173
+ return cmd_list(args)
174
+ if args.target == "last":
175
+ found = locate.sessions(args.project)
176
+ if not found:
177
+ print(f"No Claude Code, Codex, Antigravity or Cursor sessions found for {args.project or Path.cwd()} "
178
+ f"(looked in {locate.claude_home() / 'projects'}, {locate.codex_home() / 'sessions'} and "
179
+ f"{locate.antigravity_home() / 'conversations'}, and Cursor's chats)")
180
+ return 1
181
+ path = found[0][1]
182
+ else:
183
+ path = Path(args.target).expanduser()
184
+ if not path.is_file(): # a session id (or its first characters) from `sessioncost list`
185
+ hits = [p for _, p in locate.sessions(args.project) if locate.session_id(p).startswith(args.target)]
186
+ if len(hits) == 1:
187
+ path = hits[0]
188
+ try:
189
+ a, out = build(path, args)
190
+ except (readers.NotSupported, ValueError) as e:
191
+ print(f"sessioncost: {e}")
192
+ return 2
193
+ if args.json:
194
+ print(json.dumps(a, indent=1, ensure_ascii=False, default=str))
195
+ return 0
196
+ print(summary(a, out))
197
+ if out and not args.no_open:
198
+ webbrowser.open(out.resolve().as_uri())
199
+ return 0