sessioncost 0.2.2__tar.gz → 0.3.1__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.
Files changed (30) hide show
  1. {sessioncost-0.2.2/sessioncost.egg-info → sessioncost-0.3.1}/PKG-INFO +16 -1
  2. {sessioncost-0.2.2 → sessioncost-0.3.1}/README.md +15 -0
  3. {sessioncost-0.2.2 → sessioncost-0.3.1}/pyproject.toml +1 -1
  4. {sessioncost-0.2.2 → sessioncost-0.3.1}/sessioncost/__init__.py +1 -1
  5. {sessioncost-0.2.2 → sessioncost-0.3.1}/sessioncost/analyse.py +58 -19
  6. {sessioncost-0.2.2 → sessioncost-0.3.1}/sessioncost/cli.py +34 -1
  7. {sessioncost-0.2.2 → sessioncost-0.3.1}/sessioncost/report_template.html +200 -57
  8. {sessioncost-0.2.2 → sessioncost-0.3.1}/sessioncost/skill/SKILL.md +13 -0
  9. sessioncost-0.3.1/sessioncost/summary.py +157 -0
  10. {sessioncost-0.2.2 → sessioncost-0.3.1/sessioncost.egg-info}/PKG-INFO +16 -1
  11. {sessioncost-0.2.2 → sessioncost-0.3.1}/sessioncost.egg-info/SOURCES.txt +1 -0
  12. {sessioncost-0.2.2 → sessioncost-0.3.1}/LICENSE +0 -0
  13. {sessioncost-0.2.2 → sessioncost-0.3.1}/MANIFEST.in +0 -0
  14. {sessioncost-0.2.2 → sessioncost-0.3.1}/sessioncost/__main__.py +0 -0
  15. {sessioncost-0.2.2 → sessioncost-0.3.1}/sessioncost/fixes.py +0 -0
  16. {sessioncost-0.2.2 → sessioncost-0.3.1}/sessioncost/locate.py +0 -0
  17. {sessioncost-0.2.2 → sessioncost-0.3.1}/sessioncost/model.py +0 -0
  18. {sessioncost-0.2.2 → sessioncost-0.3.1}/sessioncost/prices.json +0 -0
  19. {sessioncost-0.2.2 → sessioncost-0.3.1}/sessioncost/prices.py +0 -0
  20. {sessioncost-0.2.2 → sessioncost-0.3.1}/sessioncost/readers/__init__.py +0 -0
  21. {sessioncost-0.2.2 → sessioncost-0.3.1}/sessioncost/readers/_pb.py +0 -0
  22. {sessioncost-0.2.2 → sessioncost-0.3.1}/sessioncost/readers/antigravity.py +0 -0
  23. {sessioncost-0.2.2 → sessioncost-0.3.1}/sessioncost/readers/claude.py +0 -0
  24. {sessioncost-0.2.2 → sessioncost-0.3.1}/sessioncost/readers/codex.py +0 -0
  25. {sessioncost-0.2.2 → sessioncost-0.3.1}/sessioncost/readers/cursor.py +0 -0
  26. {sessioncost-0.2.2 → sessioncost-0.3.1}/sessioncost/report.py +0 -0
  27. {sessioncost-0.2.2 → sessioncost-0.3.1}/sessioncost.egg-info/dependency_links.txt +0 -0
  28. {sessioncost-0.2.2 → sessioncost-0.3.1}/sessioncost.egg-info/entry_points.txt +0 -0
  29. {sessioncost-0.2.2 → sessioncost-0.3.1}/sessioncost.egg-info/top_level.txt +0 -0
  30. {sessioncost-0.2.2 → sessioncost-0.3.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sessioncost
3
- Version: 0.2.2
3
+ Version: 0.3.1
4
4
  Summary: See how many tokens each turn of your coding-agent chat sends, why, and what to cut. Cost goes down with it.
5
5
  Author: kish21
6
6
  License-Expression: MIT
@@ -88,6 +88,20 @@ Report: ~/.sessioncost/reports/<session-id>.html
88
88
  Asking costs almost nothing: the agent runs the tool and shows its summary, and never reads the log or the report
89
89
  into the chat.
90
90
 
91
+ ## Updating
92
+
93
+ | How you installed it | How to update |
94
+ |---|---|
95
+ | The one line above | Paste the same line again, then restart your agent. |
96
+ | Claude Code plugin | **/plugin → Marketplaces → sessioncost → Update** (or type `/plugin marketplace update sessioncost`, then `/plugin update sessioncost@sessioncost`), then restart Claude Code. You can also turn on auto-update for this marketplace there. |
97
+ | pip | `pip install -U sessioncost`, then `sessioncost setup`. |
98
+
99
+ ## Old reports
100
+
101
+ Each chat keeps one report in `~/.sessioncost/reports/` (a few hundred KB each). When more than 10 older ones pile up,
102
+ the summary says so. Type `/sessioncost clean` in a chat, or `sessioncost clean` in a terminal, to delete all of them
103
+ except the latest. Nothing is ever deleted unless you ask.
104
+
91
105
  ## If something goes wrong
92
106
 
93
107
  - **`/sessioncost` does nothing:** restart the agent after installing.
@@ -124,6 +138,7 @@ sessioncost 3f2a9c1e # one chat, by the first characters of its id
124
138
  sessioncost path/to/session.jsonl # or an Antigravity .db
125
139
  sessioncost 3f2a9c1e --cursor-usage usage-events.csv # a Cursor chat, with exact tokens from your export
126
140
  sessioncost setup # add /sessioncost to your agents again
141
+ sessioncost clean # delete all saved reports except the latest
127
142
  ```
128
143
 
129
144
  Options: `--project <dir>` (another project folder), `--no-open` (write the report, do not open it), `--json`
@@ -73,6 +73,20 @@ Report: ~/.sessioncost/reports/<session-id>.html
73
73
  Asking costs almost nothing: the agent runs the tool and shows its summary, and never reads the log or the report
74
74
  into the chat.
75
75
 
76
+ ## Updating
77
+
78
+ | How you installed it | How to update |
79
+ |---|---|
80
+ | The one line above | Paste the same line again, then restart your agent. |
81
+ | Claude Code plugin | **/plugin → Marketplaces → sessioncost → Update** (or type `/plugin marketplace update sessioncost`, then `/plugin update sessioncost@sessioncost`), then restart Claude Code. You can also turn on auto-update for this marketplace there. |
82
+ | pip | `pip install -U sessioncost`, then `sessioncost setup`. |
83
+
84
+ ## Old reports
85
+
86
+ Each chat keeps one report in `~/.sessioncost/reports/` (a few hundred KB each). When more than 10 older ones pile up,
87
+ the summary says so. Type `/sessioncost clean` in a chat, or `sessioncost clean` in a terminal, to delete all of them
88
+ except the latest. Nothing is ever deleted unless you ask.
89
+
76
90
  ## If something goes wrong
77
91
 
78
92
  - **`/sessioncost` does nothing:** restart the agent after installing.
@@ -109,6 +123,7 @@ sessioncost 3f2a9c1e # one chat, by the first characters of its id
109
123
  sessioncost path/to/session.jsonl # or an Antigravity .db
110
124
  sessioncost 3f2a9c1e --cursor-usage usage-events.csv # a Cursor chat, with exact tokens from your export
111
125
  sessioncost setup # add /sessioncost to your agents again
126
+ sessioncost clean # delete all saved reports except the latest
112
127
  ```
113
128
 
114
129
  Options: `--project <dir>` (another project folder), `--no-open` (write the report, do not open it), `--json`
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sessioncost"
7
- version = "0.2.2"
7
+ version = "0.3.1"
8
8
  description = "See how many tokens each turn of your coding-agent chat sends, why, and what to cut. Cost goes down with it."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,2 +1,2 @@
1
1
  """SessionCost: see where every token of your agent session went, turn by turn, and what to fix."""
2
- __version__ = "0.2.2"
2
+ __version__ = "0.3.1"
@@ -11,10 +11,12 @@ import re
11
11
 
12
12
  from datetime import datetime
13
13
 
14
- from . import fixes as fixmod
14
+ from . import fixes as fixmod, summary as summarymod
15
15
  from . import prices as pricemod
16
16
  from .model import Call, Session
17
17
 
18
+ SAID = ("typed", "command", "queued", "answer") # what you said, as opposed to what the agent added
19
+
18
20
  CATEGORIES = [
19
21
  ("start", "Start (first call)"),
20
22
  ("questions", "Asking you questions"),
@@ -117,23 +119,34 @@ def call_label(c: Call) -> str:
117
119
  return "thought, wrote nothing visible"
118
120
 
119
121
 
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
+ def sent_parts(c: Call, r: dict, setup_tokens: int, carried_max: int | None) -> list[dict]:
123
+ """What was sent, by what it is: fixed setup (step 0) + earlier conversation (step 1) + new (step 2) = the call's
124
+ context, exactly. Earlier conversation is what the previous call of this thread sent and wrote, minus the setup,
125
+ whether it came from the cache or not (after a pause the cache expires and it is sent again at full price).
126
+ The cache only sets the price: cache reads are counted against the setup first, then the conversation."""
122
127
  u = c.usage
123
128
  cr_rate, new_rate = r["cache_read"], pricemod.new_input_rate(u, r)
124
129
  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
130
+ if carried_max is None: # first call of its thread: nothing earlier to carry
131
+ carried_max = 0 if c.agent == "main" else u.cache_read
132
+ carried = max(0, min(u.context - setup, carried_max))
133
+ new = u.context - setup - carried
134
+ cr_left = u.cache_read
135
+ s_cr = min(setup, cr_left); cr_left -= s_cr
136
+ h_cr = min(carried, cr_left); cr_left -= h_cr
137
+ n_cr = min(new, cr_left)
138
+ price = lambda tokens, cr: (cr * cr_rate + (tokens - cr) * new_rate) / 1e6
129
139
  parts = []
130
140
  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)})
141
+ parts.append({"what": "fixed setup (instructions, tools, skills: step 0)", "tokens": setup,
142
+ "usd": price(setup, s_cr), "estimated": False})
143
+ # the newest piece (the last reply and tool results) is always written to the cache on this call, not read:
144
+ # only call it an expired cache when most of the earlier conversation missed it
145
+ expired = carried > 2000 and h_cr < carried / 2
146
+ parts.append({"what": "earlier conversation" + (" (sent again at full price: the cache had expired)" if expired else ""),
147
+ "tokens": carried, "usd": price(carried, h_cr), "estimated": False})
148
+ parts.append({"what": "new input (sent for the first time)", "tokens": new, "usd": price(new, n_cr),
149
+ "estimated": False})
137
150
  return parts
138
151
 
139
152
 
@@ -178,19 +191,36 @@ def money_label(exact: bool, as_of: str, subscription: bool, tokens_exact: bool
178
191
  def analyse(s: Session, prices: dict, subscription: bool = False, engines: list | None = None) -> dict:
179
192
  P = Pricer(prices)
180
193
  subscription = subscription or not s.billed_per_token
181
- setup_tokens = tok(s.setup.chars) if s.setup.known else 0
182
194
  main_calls = s.calls
195
+ # Step 0, the fixed setup: the first call has no history yet, so everything it sent except what you said is the
196
+ # agent's own setup (instructions, tools, skills, project rules). Measured this way it includes the parts the log
197
+ # does not spell out (Claude Code's built-in instructions), and it works for agents that log no setup at all.
198
+ setup_logged = tok(s.setup.chars) if s.setup.known else 0
199
+ setup_tokens = setup_logged
200
+ first = main_calls[0] if main_calls else None
201
+ if first is not None:
202
+ t0 = next((t for t in s.turns if first in t.calls), None)
203
+ said = sum(tok(e.chars) for e in (t0.events if t0 else [])
204
+ if e.kind in SAID and (e.ts is None or first.ts_first is None or e.ts <= first.ts_first))
205
+ setup_tokens = max(first.usage.context - said, 0) # measured; the text estimate only splits it
183
206
  first_id = main_calls[0].msg_id if main_calls else None
184
207
  last_id = main_calls[-1].msg_id if main_calls else None
185
208
 
186
209
  # ---- calls (main first, then helpers), each with its parts
187
210
  calls: list[dict] = []
188
211
  by_obj: dict[int, dict] = {}
212
+ prev_of: dict[str, Call] = {} # the previous call of each thread (main, or one helper)
189
213
  for c in s.all_calls:
190
214
  r = P.rates(c)
191
215
  cc = pricemod.cost(c.usage, r)
192
216
  total = sum(cc.values())
193
- sp = sent_parts(c, r, setup_tokens)
217
+ pv = prev_of.get(c.agent)
218
+ prev_setup = min(setup_tokens, pv.usage.context) if pv is not None and c.agent == "main" else 0
219
+ sp = sent_parts(c, r, setup_tokens, None if pv is None else pv.usage.context - prev_setup + pv.usage.output)
220
+ prev_of[c.agent] = c
221
+ sp_by = {"setup": sum(x["tokens"] for x in sp if x["what"].startswith("fixed setup")),
222
+ "history": sum(x["tokens"] for x in sp if x["what"].startswith("earlier conversation")),
223
+ "new": sum(x["tokens"] for x in sp if x["what"].startswith("new input"))}
194
224
  wp = written_parts(c, r)
195
225
  d = {
196
226
  "agent": c.agent, "n": c.n, "turn": c.turn, "model": c.model,
@@ -207,7 +237,7 @@ def analyse(s: Session, prices: dict, subscription: bool = False, engines: list
207
237
  "context": c.usage.context,
208
238
  "cost_by_class": cc,
209
239
  "usd": total,
210
- "sent": {"tokens": c.usage.context, "usd": sum(p["usd"] for p in sp), "parts": sp},
240
+ "sent": {"tokens": c.usage.context, "usd": sum(p["usd"] for p in sp), "parts": sp, **sp_by},
211
241
  "written": {"tokens": c.usage.output, "usd": sum(p["usd"] for p in wp), "parts": wp},
212
242
  "mix": {"written": cc["output"], "new": cc["input"] + cc["cache_write_5m"] + cc["cache_write_1h"],
213
243
  "reread": cc["cache_read"]},
@@ -231,7 +261,7 @@ def analyse(s: Session, prices: dict, subscription: bool = False, engines: list
231
261
  keep = bool(e.ts and prev is not None and prev.ts_last and c.ts_first
232
262
  and prev.ts_last < e.ts <= c.ts_first)
233
263
  if keep:
234
- said = e.kind in ("typed", "command", "queued", "answer")
264
+ said = e.kind in SAID
235
265
  items.append({"what": e.label + (f": “{e.text}”" if said else ""),
236
266
  "tokens": tok(e.chars)})
237
267
  by_obj[id(c)]["new_items"] = [x for x in items if x["tokens"]]
@@ -259,6 +289,7 @@ def analyse(s: Session, prices: dict, subscription: bool = False, engines: list
259
289
  "usd": sum(d["usd"] for d in tc),
260
290
  "helper_usd": sum(by_obj[id(c)]["usd"] for c in s.helper_calls if c.turn == t.n),
261
291
  "sent": sum(d["sent"]["tokens"] for d in tc), "written": sum(d["written"]["tokens"] for d in tc),
292
+ "sent_by": {k: sum(d["sent"][k] for d in tc) for k in ("setup", "history", "new")},
262
293
  "helper_sent": sum(by_obj[id(c)]["sent"]["tokens"] for c in s.helper_calls if c.turn == t.n),
263
294
  "calls": [calls.index(d) for d in tc],
264
295
  "mix": {k: sum(d["mix"][k] for d in tc) for k in ("written", "new", "reread")},
@@ -282,8 +313,14 @@ def analyse(s: Session, prices: dict, subscription: bool = False, engines: list
282
313
 
283
314
  # ---- setup
284
315
  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],
316
+ parts = [{"what": p.what, "tokens": tok(p.chars)} for p in s.setup.parts]
317
+ if setup_logged > setup_tokens and setup_logged: # the text estimate ran over the measured size: scale it to fit
318
+ parts = [dict(x, tokens=round(x["tokens"] * setup_tokens / setup_logged)) for x in parts]
319
+ elif setup_tokens > setup_logged:
320
+ parts.append({"what": "the agent's built-in instructions and other start-up text (not spelled out in the log)",
321
+ "tokens": setup_tokens - setup_logged})
322
+ setup = {"known": s.setup.known, "measured": first is not None, "tokens": setup_tokens, "logged": setup_logged,
323
+ "parts": parts,
287
324
  "tools": [{"name": p.what, "tokens": tok(p.chars),
288
325
  "used": p.what in used} for p in s.setup.tools]}
289
326
  setup["unused"] = [t["name"] for t in setup["tools"] if not t["used"]]
@@ -340,6 +377,7 @@ def analyse(s: Session, prices: dict, subscription: bool = False, engines: list
340
377
  "working_min": round(working, 1), "waiting_min": round(waiting / 60, 1),
341
378
  "context_max": max((d["context"] for d in calls if d["agent"] == "main"), default=0),
342
379
  "sent": sum(d["sent"]["tokens"] for d in calls),
380
+ "sent_by": {k: sum(d["sent"][k] for d in calls) for k in ("setup", "history", "new")},
343
381
  "output": sum(d["usage"]["output"] for d in calls), "thinking": thinking,
344
382
  "thinking_known": thinking_known,
345
383
  "by_class": by_class,
@@ -366,6 +404,7 @@ def analyse(s: Session, prices: dict, subscription: bool = False, engines: list
366
404
  for tr in turn_rows:
367
405
  if tr["n"] in f.turns:
368
406
  tr["fixes"].append(f.id)
407
+ out["summary"] = summarymod.build(s, out) # what a deeper-fix check would receive: no content (summary.py)
369
408
  return out
370
409
 
371
410
 
@@ -64,6 +64,34 @@ def summary(a: dict, path: Path | None) -> str:
64
64
  return "\n".join(lines)
65
65
 
66
66
 
67
+ OLD_REPORTS_HINT = 10 # past this many older reports, the summary says how to delete them (it never asks)
68
+
69
+
70
+ def old_reports(keep: Path | None, folder: Path = REPORTS) -> list[Path]:
71
+ """Every saved report except the one to keep (the current one; by default the newest)."""
72
+ files = sorted(folder.glob("*.html"), key=lambda f: f.stat().st_mtime, reverse=True) if folder.is_dir() else []
73
+ keep = keep or (files[0] if files else None)
74
+ return [f for f in files if keep is None or f.resolve() != keep.resolve()]
75
+
76
+
77
+ def size(n: int) -> str:
78
+ return f"{n / 1e6:.1f} MB" if n >= 1e6 else f"{max(1, round(n / 1e3))} KB"
79
+
80
+
81
+ def cmd_clean(folder: Path = REPORTS) -> int:
82
+ old = old_reports(None, folder)
83
+ if not old:
84
+ print("No older reports to delete.")
85
+ return 0
86
+ freed = sum(f.stat().st_size for f in old)
87
+ for f in old:
88
+ f.unlink()
89
+ kept = sorted(folder.glob("*.html"))
90
+ print(f"Deleted {plural(len(old), 'older report')} ({size(freed)})." +
91
+ (f" Kept the latest: {kept[0]}" if kept else ""))
92
+ return 0
93
+
94
+
67
95
  def build(path, args) -> tuple[dict, Path | None]:
68
96
  s = readers.read(path)
69
97
  if args.cursor_usage:
@@ -173,7 +201,7 @@ def main(argv: list[str] | None = None) -> int:
173
201
  description="See where every token of your agent session went, turn by turn, and what to fix. "
174
202
  "Local only: reads log files, sends nothing anywhere.")
175
203
  ap.add_argument("target", nargs="?", default="last",
176
- 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)")
204
+ help="'last' (newest Claude Code, Codex, Antigravity or Cursor session of this project, the default), 'list', 'setup' (add /sessioncost to your agents), 'clean' (delete all saved reports but the latest), or a session file (.jsonl, or an Antigravity .db)")
177
205
  ap.add_argument("--project", help="project folder (default: the current folder)")
178
206
  ap.add_argument("--no-open", action="store_true", help="write the report but do not open it")
179
207
  ap.add_argument("--json", action="store_true", help="print the full model as JSON instead of a report")
@@ -188,6 +216,8 @@ def main(argv: list[str] | None = None) -> int:
188
216
 
189
217
  if args.target == "setup":
190
218
  return cmd_setup()
219
+ if args.target == "clean":
220
+ return cmd_clean()
191
221
  if args.target == "list":
192
222
  return cmd_list(args)
193
223
  if args.target == "last":
@@ -213,6 +243,9 @@ def main(argv: list[str] | None = None) -> int:
213
243
  print(json.dumps(a, indent=1, ensure_ascii=False, default=str))
214
244
  return 0
215
245
  print(summary(a, out))
246
+ old = old_reports(out) if out else []
247
+ if len(old) > OLD_REPORTS_HINT:
248
+ print(f"Old reports: {len(old)} ({size(sum(f.stat().st_size for f in old))}) · delete them with: sessioncost clean")
216
249
  if out and not args.no_open:
217
250
  webbrowser.open(out.resolve().as_uri())
218
251
  return 0
@@ -5,6 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>__TITLE__ · SessionCost</title>
7
7
  <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <script>try{var th=localStorage.getItem('sessioncost-theme');if(th==='light'||th==='dark')document.documentElement.dataset.theme=th;}catch(e){}</script>
8
9
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap">
9
10
  <style>
10
11
  /* One column of panels: headline, turns (with drill-down), replay, where the cost went, fixes, fixed setup.
@@ -12,7 +13,7 @@
12
13
  :root{
13
14
  --bg:#f5f6f8; --surface:#ffffff; --raise:#f9fafb; --line:#e3e6eb; --grid:#eceef2;
14
15
  --ink:#14171c; --ink-2:#454b56; --ink-3:#5d636e;
15
- --c-write:#4a3aa7; --c-new:#1baf7a; --c-reread:#d99400; --c-setup:#8a93a3;
16
+ --c-write:#4a3aa7; --c-new:#1baf7a; --c-reread:#d99400; --c-setup:#8a93a3; --c-hist:#3d7fd1;
16
17
  --accent:#2a78d6; --helper:#eb6834;
17
18
  --chip-bg:#e3f1ff; --chip:#1c5cab; --warn-bg:#fdf0e3; --warn:#9a4d0b;
18
19
  --focus:#2a78d6;
@@ -23,18 +24,24 @@
23
24
  @media (prefers-color-scheme: dark){:root:not([data-theme="light"]){
24
25
  --bg:#101216; --surface:#181b20; --raise:#1d2026; --line:#2a2e35; --grid:#22262c;
25
26
  --ink:#f2f3f5; --ink-2:#cdd1d8; --ink-3:#a6acb7;
26
- --c-write:#9085e9; --c-new:#1c9e70; --c-reread:#bf8610; --c-setup:#6b7381;
27
+ --c-write:#9085e9; --c-new:#1c9e70; --c-reread:#bf8610; --c-setup:#6b7381; --c-hist:#5b95e0;
27
28
  --accent:#5b9be8; --helper:#e2723f;
28
29
  --chip-bg:#16273d; --chip:#9ec5f4; --warn-bg:#3a2a16; --warn:#f2bf86;
29
30
  --focus:#86b6ef; color-scheme:dark}}
30
31
  :root[data-theme="dark"]{
31
32
  --bg:#101216; --surface:#181b20; --raise:#1d2026; --line:#2a2e35; --grid:#22262c;
32
33
  --ink:#f2f3f5; --ink-2:#cdd1d8; --ink-3:#a6acb7;
33
- --c-write:#9085e9; --c-new:#1c9e70; --c-reread:#bf8610; --c-setup:#6b7381;
34
+ --c-write:#9085e9; --c-new:#1c9e70; --c-reread:#bf8610; --c-setup:#6b7381; --c-hist:#5b95e0;
34
35
  --accent:#5b9be8; --helper:#e2723f;
35
36
  --chip-bg:#16273d; --chip:#9ec5f4; --warn-bg:#3a2a16; --warn:#f2bf86;
36
37
  --focus:#86b6ef; color-scheme:dark}
37
38
  *{box-sizing:border-box}
39
+ header{position:relative}
40
+ .theme{position:absolute;top:0;right:0;display:inline-flex;align-items:center;gap:6px;font:600 12px var(--f-body);color:var(--ink-2);background:var(--surface);border:1px solid var(--line);border-radius:999px;padding:5px 11px;cursor:pointer}
41
+ .theme:hover{color:var(--ink);border-color:var(--ink-3)}
42
+ .theme:focus-visible{outline:2px solid var(--focus);outline-offset:2px}
43
+ .theme svg{width:14px;height:14px}
44
+ @media (max-width:640px){.theme{position:static;margin-bottom:8px}}
38
45
  [hidden]{display:none !important}
39
46
  html{-webkit-text-size-adjust:100%}
40
47
  body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--f-body);font-size:15px;line-height:1.55;-webkit-font-smoothing:antialiased}
@@ -60,7 +67,10 @@ code{font-family:var(--f-mono);font-size:.92em;overflow-wrap:anywhere}
60
67
  .kpi{background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:12px 14px;min-width:0;display:flex;flex-direction:column;gap:4px}
61
68
  .kpi .v{font-size:26px;white-space:nowrap;font-weight:700;line-height:1.15;letter-spacing:-.02em;font-variant-numeric:tabular-nums}
62
69
  .kpi .s{color:var(--ink-2);font-size:13px}
63
- .kpi .eyebrow{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
70
+ .kpi .eyebrow{white-space:normal;overflow-wrap:anywhere;line-height:1.3}
71
+ /* inside a tile nothing is cut: chips wrap onto a second line */
72
+ .kpi .chip{white-space:normal;border-radius:8px;line-height:1.3;padding:3px 8px;max-width:100%}
73
+ .kpi .s{overflow-wrap:anywhere}
64
74
  button.kpi{font:inherit;color:inherit;text-align:left;cursor:pointer;transition:border-color .15s,box-shadow .15s}
65
75
  button.kpi:hover{border-color:var(--ink-3)}
66
76
  button.kpi:focus-visible{outline:2px solid var(--focus);outline-offset:2px}
@@ -148,6 +158,20 @@ button{font:inherit;color:inherit}
148
158
  /* stacked mini bar */
149
159
  .stack{display:flex;height:10px;border-radius:3px;overflow:hidden;background:var(--grid);min-width:0}
150
160
  .stack span{display:block;height:100%}
161
+ .s-hist{background:var(--c-hist)}
162
+ /* under each bar: the share of what was sent that was re-read from the cache (cheap); the rest went at full price */
163
+ .cacheline{height:4px;margin-top:3px;border-radius:2px;background:var(--grid);overflow:hidden;min-width:0}
164
+ .cacheline span{display:block;height:100%;background:var(--c-reread)}
165
+ .sw.line{height:4px;vertical-align:2px}
166
+ /* the numbers right under a bar: its parts in the bar's colours, then what came from the cache */
167
+ .barnums{font-size:12px;color:var(--ink-2);margin-top:5px;line-height:1.45;font-variant-numeric:tabular-nums}
168
+ .barnums .dot{display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:4px;vertical-align:0}
169
+ .barnums .dot.line{height:4px;width:10px;vertical-align:2px}
170
+ .barnums b{font-weight:600;color:var(--ink)}
171
+ .barnums .cache{color:var(--ink-3)}
172
+ table.t.steps .sub-item{display:block;padding-left:22px;color:var(--ink-3);font-size:12.5px}
173
+ table.t.steps .dot{display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:6px}
174
+ .sumline .dot{display:inline-block;width:10px;height:4px;border-radius:2px;margin-right:6px;vertical-align:2px}
151
175
  .s-write{background:var(--c-write)} .s-new{background:var(--c-new)} .s-reread{background:var(--c-reread)} .s-setup{background:var(--c-setup)}
152
176
 
153
177
  /* bar lists: label | track | value (the value has its own column, so it is never clipped) */
@@ -166,6 +190,8 @@ button{font:inherit;color:inherit}
166
190
  .turn > button:hover{background:var(--raise)}
167
191
  .turn > button:focus-visible{outline:2px solid var(--focus);outline-offset:-2px}
168
192
  .turn .no{color:var(--ink-3);font-size:13px;font-variant-numeric:tabular-nums}
193
+ .turn.step0{border-style:dashed;background:var(--raise)}
194
+ .turn.step0 .no{font-weight:700;color:var(--ink-2)}
169
195
  .turn .ask{font-weight:500;overflow-wrap:anywhere}
170
196
  .turn .meta{font-size:13px;color:var(--ink-3);display:flex;flex-wrap:wrap;gap:2px 10px;margin-top:2px}
171
197
  .turn .cost{text-align:right;font-weight:600}
@@ -271,6 +297,14 @@ input[type=range]{flex:1 1 160px;accent-color:var(--accent);min-width:120px}
271
297
 
272
298
  /* fixes, setup, footer */
273
299
  .fixes{display:grid;gap:10px}
300
+ .deeper{margin-top:14px;border:1px dashed var(--line);border-radius:10px;padding:14px 16px;background:var(--surface)}
301
+ .deeper-head{display:flex;gap:16px;align-items:flex-start;justify-content:space-between;flex-wrap:wrap}
302
+ .deeper-head > div{flex:1 1 420px;min-width:0}
303
+ .deeper h3{margin:0 0 4px;font-size:15px}
304
+ .deeper .soon{margin:12px 0 8px;padding:10px 12px;border-radius:8px;background:var(--chip-bg);color:var(--chip);font-weight:600}
305
+ .deeper ul{margin:6px 0 10px;padding-left:18px;color:var(--ink-2);font-size:13px}
306
+ .deeper pre{max-height:360px;overflow:auto;background:var(--raise);border:1px solid var(--line);border-radius:8px;padding:10px;font:12px/1.45 var(--f-mono);white-space:pre-wrap;overflow-wrap:anywhere}
307
+ .deeper .row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:8px}
274
308
  .fix{border:1px solid var(--line);border-radius:8px;padding:12px 14px;display:grid;gap:6px;background:var(--raise)}
275
309
  .fix .top{display:flex;flex-wrap:wrap;justify-content:space-between;gap:6px 12px;align-items:baseline}
276
310
  .fix ul{margin:4px 0 0;padding-left:18px;color:var(--ink-2);font-size:12px}
@@ -290,6 +324,7 @@ details summary{cursor:pointer;color:var(--ink-2);font-size:12px}
290
324
  <body>
291
325
  <div class="wrap">
292
326
  <header>
327
+ <button type="button" class="theme" id="theme" aria-label="Switch between light and dark"></button>
293
328
  <div class="eyebrow" id="eyebrow">SessionCost</div>
294
329
  <h1 id="title"></h1>
295
330
  <p class="sub" id="subtitle"></p>
@@ -302,9 +337,9 @@ details summary{cursor:pointer;color:var(--ink-2);font-size:12px}
302
337
 
303
338
  <section class="panel" aria-labelledby="h-turns" id="turns-panel">
304
339
  <div class="panel-head"><h2 id="h-turns">Turn by turn</h2>
305
- <span class="legend"><span><span class="sw s-write"></span>written by the model</span><span><span class="sw s-new"></span>new text read</span><span><span class="sw s-reread"></span>re-read from cache</span></span></div>
340
+ <span class="legend"><span><span class="sw s-setup"></span>0 · fixed setup</span><span><span class="sw s-hist"></span>1 · earlier conversation</span><span><span class="sw s-new"></span>2 · new</span><span><span class="sw line s-reread"></span>re-read from cache</span></span></div>
306
341
  <p class="lede">Each row is one thing you asked. Click a row to open its model calls, then click a call to see what it sent and what it wrote. The parts always add up to the number above them.</p>
307
- <div class="howto"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M1.5 8s2.4-4.5 6.5-4.5S14.5 8 14.5 8 12.1 12.5 8 12.5 1.5 8 1.5 8z"/><circle cx="8" cy="8" r="2"/></svg><span><b>How to read:</b> one row per message you sent. The colored bar is its cost: longer means more expensive, and the colors show what the money paid for (purple: the AI writing, green: new text read, amber: re-reading the conversation). Click a row to see the model calls behind it.</span></div>
342
+ <div class="howto"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M1.5 8s2.4-4.5 6.5-4.5S14.5 8 14.5 8 12.1 12.5 8 12.5 1.5 8 1.5 8z"/><circle cx="8" cy="8" r="2"/></svg><span><b>How to read:</b> one row per message you sent. The bar is the tokens it sent, in three parts: grey, the agent's fixed setup (step 0, sent with every call); blue, the earlier conversation sent again (step 1); green, what was new (step 2). The thin amber line under it is how much of that was re-read from the cache, cheaply; the rest went at full price. Click a row to see the model calls behind it.</span></div>
308
343
  <div class="turns" id="turns"></div>
309
344
  </section>
310
345
 
@@ -345,9 +380,20 @@ details summary{cursor:pointer;color:var(--ink-2);font-size:12px}
345
380
  <div class="panel-head"><h2 id="h-fix">What to fix</h2><span class="eyebrow">tokens cut, measured from this session</span></div>
346
381
  <p class="lede">Each saving is what those tokens or calls cost in this session, priced like the rest of this page. Fixes can overlap.</p>
347
382
  <div class="fixes" id="fixes"></div>
383
+ <div class="deeper" id="deeper">
384
+ <div class="deeper-head">
385
+ <div><h3>Deeper fixes</h3>
386
+ <p class="lede">These are the three basic checks. Our catalog of measured waste patterns has dozens more: an agent
387
+ polling instead of waiting, files re-read, tool results far bigger than the work, research in the main chat,
388
+ and others. To check them, SessionCost would share a <b>summary</b> of this session: numbers, tool names and
389
+ yes/no flags. Never your messages, code, file names or project.</p></div>
390
+ <button type="button" class="btn primary" id="deeper-btn" aria-expanded="false" aria-controls="deeper-box">Find deeper fixes</button>
391
+ </div>
392
+ <div id="deeper-box" hidden></div>
393
+ </div>
348
394
  </section>
349
395
 
350
- <section class="panel" aria-labelledby="h-setup">
396
+ <section class="panel" aria-labelledby="h-setup" id="setup-panel">
351
397
  <div class="panel-head"><h2 id="h-setup">Sent with every call</h2><span class="eyebrow num" id="setup-total"></span></div>
352
398
  <p class="lede" id="setup-lede"></p>
353
399
  <div class="bars" id="setup-parts"></div>
@@ -383,6 +429,18 @@ const tin = cs => cs.reduce((a, c) => a + c.context, 0), tout = cs => cs.reduce(
383
429
  const io_ = cs => `${K(tin(cs))} in · ${K(tout(cs))} out`;
384
430
  const who = c => c.agent === 'main' ? 'Call ' + c.n : 'Helper ' + c.agent.split(':').pop().slice(0,6) + ', call ' + c.n;
385
431
 
432
+ /* ---------- light / dark switch: follows the system until clicked, then remembers the choice ---------- */
433
+ (function theme(){
434
+ const btn = $('theme'), root = document.documentElement;
435
+ const sun = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><circle cx="8" cy="8" r="3"/><path d="M8 1v2M8 13v2M1 8h2M13 8h2M3 3l1.4 1.4M11.6 11.6 13 13M3 13l1.4-1.4M11.6 4.4 13 3"/></svg>';
436
+ const moon = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M13.5 9.5A5.5 5.5 0 0 1 6.5 2.5a5.5 5.5 0 1 0 7 7z"/></svg>';
437
+ const isDark = () => root.dataset.theme ? root.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
438
+ const paint = () => { const d = isDark(); btn.innerHTML = (d ? sun : moon) + (d ? 'Light mode' : 'Dark mode'); btn.setAttribute('aria-pressed', String(d)); };
439
+ btn.onclick = () => { const next = isDark() ? 'light' : 'dark'; root.dataset.theme = next;
440
+ try { localStorage.setItem('sessioncost-theme', next); } catch (e) {} paint(); };
441
+ paint();
442
+ })();
443
+
386
444
  /* ---------- header ---------- */
387
445
  $('eyebrow').textContent = 'SessionCost · ' + M.agent_label + (M.date ? ' · ' + M.date : '');
388
446
  $('title').textContent = M.title;
@@ -402,7 +460,11 @@ const GLOSS = {
402
460
  apieq: ['API-equivalent', 'You pay a fixed plan, not per token. This is what the same work would cost at the public pay-per-use prices.'],
403
461
  working: ['Agent working', 'Time from each of your messages to the AI\'s last step on it, added up. Model time is the AI reading and writing; tool time is commands, searches and file reads running.'],
404
462
  waiting: ['Waiting for you', 'Time the AI sat stopped until you answered. It costs nothing, but the work stood still.'],
405
- savings: ['Tokens you could cut', 'How many fewer tokens this session would have sent with each fix, measured from its own calls, not guessed. Fewer tokens sent is what lowers the cost.'],
463
+ savings: ['Tokens to cut', 'How many fewer tokens this session would have sent with each fix, measured from its own calls, not guessed. Fewer tokens sent is what lowers the cost.'],
464
+ step0: ['Step 0 · fixed setup', 'What the agent sends before you type a word, again with every call: its instructions, the list of its tools and skills, your project rules. Measured from the first call of the session. Turning off tools and skills you do not use makes it smaller.'],
465
+ history: ['Step 1 · earlier conversation', 'Everything said and read earlier in the chat, sent again with every call. It grows with every step, which is why long chats get expensive.'],
466
+ fresh: ['Step 2 · new', 'What is sent for the first time in a call: your message, a file just read, a command\'s output.'],
467
+ cache: ['Re-read from the cache', 'Text the model provider already has from a moment ago is read back from its cache at about a tenth of the price. The setup and the earlier conversation usually come from the cache; after a pause of more than a few minutes (one hour at most) the cache expires and everything is sent again at full price.'],
406
468
  sent: ['Tokens sent', 'Everything the agent sent to the model, added up over all its calls: its setup, the whole conversation so far, and whatever it read. Each call re-sends all of it, so this grows fast. It is what you can cut.'],
407
469
  };
408
470
  const q = k => `<span class="q" role="button" tabindex="0" data-help="${k}" aria-label="What is ${esc(GLOSS[k][0])}?">?</span>`;
@@ -428,19 +490,23 @@ const q = k => `<span class="q" role="button" tabindex="0" data-help="${k}" aria
428
490
  }, true);
429
491
  addEventListener('scroll', close, {passive: true});
430
492
  })();
493
+ const SB = T.sent_by || {setup: 0, history: 0, new: T.sent};
494
+ const RR_T = (T.by_class.find(c => c.key === 'cache_read') || {tokens: 0}).tokens;
495
+ const OUT_USD = (T.by_class.find(c => c.key === 'output') || {usd: 0}).usd;
496
+ const STEPS = [['setup', '0 · fixed setup', 'var(--c-setup)', 'step0'], ['history', '1 · earlier conversation', 'var(--c-hist)', 'history'], ['new', '2 · new', 'var(--c-new)', 'fresh']];
431
497
  const SAVE = D.fixes.reduce((a, f) => a + f.saving_usd, 0), SAVE_T = D.fixes.reduce((a, f) => a + f.saving_tokens, 0);
432
498
  const ENGINE = (D.fix_engines || []).some(n => n !== 'built-in');
433
499
  const kp = [
434
- ['Tokens sent', K(T.sent), `${K(T.output)} written by the model`,
435
- (() => { const rr = T.by_class.find(c => c.key === 'cache_read'); return rr && T.sent ? `<span class="chip">${pct(rr.tokens, T.sent)} re-sent history</span>` : ''; })(), '', 'tokens'],
500
+ ['Tokens sent', K(T.sent), `${K(SB.setup)} setup · ${K(SB.history)} conversation · ${K(SB.new)} new`,
501
+ T.sent ? `<span class="chip">${K(RR_T)} from the cache</span>` : '', `<div class="why">+ ${K(T.output)} written by the model</div>`, 'tokens'],
436
502
  ['Turns', T.turns, `message${T.turns === 1 ? '' : 's'} you sent`, T.turns ? `<span class="chip">${K(T.sent / T.turns)} sent per message</span>` : '', '', 'turns'],
437
503
  ['Model calls', T.calls + T.helper_calls, T.helper_calls ? `${T.calls} main · ${T.helper_calls} by helpers` : 'all in the main chat',
438
504
  T.turns ? `<span class="chip">${((T.calls + T.helper_calls) / T.turns).toFixed(1).replace(/\.0$/, '')} per message</span>` : '', '', 'calls'],
439
- ['Tokens you could cut', K(SAVE_T), `${D.fixes.length} fix${D.fixes.length === 1 ? '' : 'es'} · ${pct(SAVE_T, T.sent)} of tokens sent`,
440
- ENGINE ? '' : '<span class="chip warn">basic fixes only</span>', `<div class="why">saves ${$$(SAVE)} · measured from this session</div>`, 'savings'],
441
505
  ['Cost', $$(T.usd), 'the result of the tokens', `<span class="chip ${MO.exact || MO.subscription ? '' : 'warn'}">${esc(MO.short)}</span>`, '', 'total'],
442
506
  ['Agent working', mins(T.working_min), 'summed over turns', '', '', 'working'],
443
507
  ['Waiting for you', mins(T.waiting_min), 'until you replied', '', '', 'waiting'],
508
+ ['Tokens to cut', K(SAVE_T), `${D.fixes.length} fix${D.fixes.length === 1 ? '' : 'es'} · ${pct(SAVE_T, T.sent)} of tokens sent`,
509
+ ENGINE ? '' : '<span class="chip warn">basic fixes only</span>', `<div class="why">saves ${$$(SAVE)} · measured from this session</div>`, 'savings'],
444
510
  ];
445
511
  const TQ = {total: MO.subscription ? 'apieq' : MO.exact ? 'exact' : 'estimate', turns: 'turn', tokens: 'sent', calls: 'call', working: 'working', waiting: 'waiting', savings: 'savings'};
446
512
  $('kpis').innerHTML = kp.map(([l, v, s, chip, extra, key]) => `<button type="button" class="kpi" data-drill="${key}" aria-expanded="false" aria-controls="drill">` +
@@ -469,17 +535,19 @@ function openTurn(n){
469
535
  const short = s => s.length > 70 ? s.slice(0, 69) + '…' : s;
470
536
  const label = MO.subscription ? 'would have cost' : 'cost';
471
537
  $('story').innerHTML = `<p class="headline">This ${esc(M.agent_label)} session sent <b>${K(T.sent)} tokens</b>${q('sent')} to the model
472
- for <b>${T.turns} message${T.turns === 1 ? '' : 's'}</b> from you${big[1] ? `, and <b>${pct(big[1], all)}</b> of the traffic was <b>${big[0]}</b>${q(big[3])}` : ''}.
538
+ for <b>${T.turns} message${T.turns === 1 ? '' : 's'}</b> from you: <b>${K(SB.setup)}</b> fixed setup${q('step0')}, <b>${K(SB.history)}</b> earlier conversation${q('history')} and <b>${K(SB.new)}</b> new${q('fresh')}.
539
+ It wrote <b>${K(T.output)}</b> back${q('out')}: few tokens, but <b>${pct(OUT_USD, T.usd)}</b> of the cost.
473
540
  That ${label} <b>${$$(T.usd)}</b>${MO.subscription ? ' at pay-per-use prices' : ''}${MO.exact ? '' : ' (an estimate)'}.</p>
474
541
  <div class="cards">
475
542
  ${top ? `<div class="card warm"><div class="eyebrow">Heaviest message</div>
476
543
  <div class="say">“${esc(short(top.t.text))}” sent <b>${K(top.s)} tokens</b>, ${pct(top.s, T.sent)} of the session (${$$(top.u)}).</div>
477
544
  <button type="button" class="act" data-turn-go="${top.t.n}">Show me this message →</button></div>` : ''}
478
- <div class="card"><div class="eyebrow">Where the tokens went</div>
479
- <div class="split">${parts.filter(x => x[1] > 0).map(x => `<i style="flex:${x[1]};background:${x[2]}" title="${x[0]}: ${K(x[1])} tokens"></i>`).join('')}</div>
480
- <div class="split-key">${parts.filter(x => x[1] > 0).map(x => `<span><b style="background:${x[2]}"></b>${pct(x[1], all)} ${x[0]}${q(x[3])}</span>`).join('')}</div></div>
545
+ <div class="card"><div class="eyebrow">What was sent</div>
546
+ <div class="split">${STEPS.filter(x => SB[x[0]] > 0).map(x => `<i style="flex:${SB[x[0]]};background:${x[2]}" title="${x[1]}: ${K(SB[x[0]])} tokens"></i>`).join('')}</div>
547
+ <div class="split-key">${STEPS.map(x => `<span><b style="background:${x[2]}"></b><b class="num" style="background:none;width:auto;height:auto">${K(SB[x[0]])}</b>&nbsp;${x[1]}${q(x[3])}</span>`).join('')}
548
+ <span class="muted">${K(RR_T)} of it re-read from the cache${q('cache')}, ${K(T.sent - RR_T)} at full price · ${K(T.output)} written back</span></div></div>
481
549
  <div class="card work"><div class="eyebrow">How hard it worked</div>
482
- <div class="say">It made <b>${C.length} model calls</b>${q('call')}, about <b>${per.toFixed(per >= 10 ? 0 : 1)} per message</b>${T.helper_calls ? `, ${T.helper_calls} of them by helpers${q('helper')}` : ''}. Each call re-sends the conversation so far.</div>
550
+ <div class="say">It made <b>${C.length} model call${C.length === 1 ? '' : 's'}</b>${q('call')}, about <b>${per.toFixed(per >= 10 ? 0 : 1)} per message</b>${T.helper_calls ? `, ${T.helper_calls} of them by helpers${q('helper')}` : ''}. Each call re-sends the conversation so far.</div>
483
551
  <button type="button" class="act" data-drill-go="calls">See every call →</button></div>
484
552
  <div class="card good"><div class="eyebrow">Biggest cut</div>
485
553
  ${fix ? `<div class="say"><b>${esc(fix.title)}</b>: <b>${K(fix.saving_tokens)} fewer tokens</b> sent (${pct(fix.saving_tokens, T.sent)}), saving ${$$(fix.saving_usd)} on a session like this.</div>
@@ -513,7 +581,7 @@ function tbl(head, rows, sum){
513
581
  const ofTurn = n => C.filter(c => c.turn === n);
514
582
  const turnNs = [...new Set([...D.turns.map(t => t.n), ...C.map(c => c.turn)])].sort((a, b) => a - b).filter(n => ofTurn(n).length || n);
515
583
  /* charts for the drill-downs: HTML bars (label | bar | value), one hover tip per panel, numbers one click away */
516
- const COL = {new: 'var(--c-new)', reread: 'var(--c-reread)', out: 'var(--c-write)', setup: 'var(--c-setup)', main: 'var(--accent)', helper: 'var(--helper)'};
584
+ const COL = {new: 'var(--c-new)', hist: 'var(--c-hist)', reread: 'var(--c-reread)', out: 'var(--c-write)', setup: 'var(--c-setup)', main: 'var(--accent)', helper: 'var(--helper)'};
517
585
  const KQ = {'new input': 'new', 'new in': 'new', 're-read from cache': 'reread', 're-read': 'reread', 'written by the model': 'out', 'out': 'out',
518
586
  'fixed setup': 'setup', 'helpers': 'helper', 'main chat': 'call'};
519
587
  const key = items => `<div class="ch-key">${items.map(([name, col]) => `<span><b style="background:${col}"></b>${name}${KQ[name] ? q(KQ[name]) : ''}</span>`).join('')}</div>`;
@@ -530,7 +598,7 @@ function chart(rows, opts = {}){
530
598
  }).join('') + `</div>`;
531
599
  }
532
600
  const nums = html => `<details class="nums"><summary>Show the numbers</summary>${html}</details>`;
533
- const HOW = {"total": "the top bar is the whole cost, split by kind of token; the bars below show each kind and each of your messages. Longer means more money.", "tokens": "each row is one of your messages; its bar is the tokens it sent, split into new text read (green) and the conversation re-sent (amber). Longer means more tokens: that is what to cut.", "calls": "each row is one of your messages; the bar counts its model calls, blue in the main chat and orange by helpers. On the right, the costliest single calls.", "working": "each row is one of your messages; the bar is its working time, blue while the AI read and wrote, grey while tools (commands, searches) ran.", "waiting": "each row is a message where the AI stopped and waited: blue before you wrote, orange while a question card was open. Longer means a longer wait.", "savings": "each bar is one fix; its length is how many fewer tokens this session would have sent with it. The last row is the total."};
601
+ const HOW = {"total": "the top bar is the whole cost, split by kind of token; the bars below show each kind and each of your messages. Longer means more money.", "tokens": "each row is one of your messages; its bar is the tokens it sent: grey the fixed setup (step 0), blue the earlier conversation (step 1), green what was new (step 2). The table adds how much was re-read from the cache. Longer means more tokens: that is what to cut.", "calls": "each row is one of your messages; the bar counts its model calls, blue in the main chat and orange by helpers. On the right, the costliest single calls.", "working": "each row is one of your messages; the bar is its working time, blue while the AI read and wrote, grey while tools (commands, searches) ran.", "waiting": "each row is a message where the AI stopped and waited: blue before you wrote, orange while a question card was open. Longer means a longer wait.", "savings": "each bar is one fix; its length is how many fewer tokens this session would have sent with it. The last row is the total."};
534
602
  const howto = k => `<div class="howto"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M1.5 8s2.4-4.5 6.5-4.5S14.5 8 14.5 8 12.1 12.5 8 12.5 1.5 8 1.5 8z"/><circle cx="8" cy="8" r="2"/></svg><span><b>How to read:</b> ${HOW[k]}</span></div>`;
535
603
  const tname = n => n ? `#${n} ${(D.turns.find(t => t.n === n) || {text: ''}).text}` : 'before your first message';
536
604
  const DRILL = {
@@ -550,22 +618,21 @@ const DRILL = {
550
618
  ['Total', C.length, $$(T.usd), '100%']))}</div></div>`;
551
619
  },
552
620
  tokens(){
553
- const I = tin(C), RR = C.reduce((a, c) => a + c.usage.cache_read, 0), O = tout(C);
554
- const setupRe = C.reduce((a, c) => { const p = c.sent.parts.find(x => x.what.startsWith('fixed setup')); return a + (p ? Math.min(p.tokens, c.usage.cache_read) : 0); }, 0);
555
- const rows = turnNs.map(n => { const cs = ofTurn(n), i = tin(cs), r = cs.reduce((a, c) => a + c.usage.cache_read, 0), o = tout(cs); return {n, i, r, o}; });
556
- return `<p class="lede"><b>${K(I)}</b> tokens sent to the model, <b>${K(RR)}</b> of them (${pct(RR, I)}) the conversation re-sent from the cache; <b>${K(O)}</b> written back.
557
- Every call re-sends the whole conversation so far, so a long chat or a big file read early is paid for again on every later call. Cutting what is sent is what lowers the cost.</p><div class="grid2">
558
- <div><h3>Sent per turn</h3>${key([['new in', COL.new], ['re-read', COL.reread]])}
621
+ const I = tin(C), RR = RR_T, O = tout(C);
622
+ const rows = turnNs.map(n => ({n, ...tmix(n)}));
623
+ const sum = k => rows.reduce((a, x) => a + x[k], 0);
624
+ return `<p class="lede"><b>${K(I)}</b> tokens sent to the model: <b>${K(SB.setup)}</b> fixed setup (step 0), <b>${K(SB.history)}</b> earlier conversation (step 1) and <b>${K(SB.new)}</b> new (step 2). <b>${K(RR)}</b> of them were re-read from the cache, cheaply; <b>${K(O)}</b> were written back.
625
+ Steps 0 and 1 go out again with every call, so they are what grows the bill and what you can cut.</p><div class="grid2">
626
+ <div><h3>Sent per turn</h3>${key(STEPS.map(x => [x[1], x[2]]))}
559
627
  ${chart([...rows.map(x => ({label: tname(x.n), segs: [
560
- {v: x.i - x.r, col: COL.new, tip: `new in: ${K(x.i - x.r)}`}, {v: x.r, col: COL.reread, tip: `re-sent from cache: ${K(x.r)}`}],
561
- value: K(x.i), sub: pct(x.i, I)})), {label: 'Total', sum: true, segs: [], value: K(I), sub: '100%'}])}
562
- ${nums(tbl(['Turn', 'New in', 'Re-read', 'Sent', 'Written'], rows.map(x => [esc(tname(x.n).slice(0, 60)), K(x.i - x.r), K(x.r), K(x.i), K(x.o)]),
563
- ['Total', K(I - RR), K(RR), K(I), K(O)]))}</div>
564
- <div><h3>What the ${pct(RR, I)} re-sent is</h3>${key([['fixed setup', COL.setup], ['earlier conversation', COL.reread]])}
565
- ${chart([{label: 'Re-sent in all', segs: [{v: setupRe, col: COL.setup, tip: `fixed setup: ${K(setupRe)} (${pct(setupRe, RR)})`}, {v: RR - setupRe, col: COL.reread, tip: `earlier conversation: ${K(RR - setupRe)} (${pct(RR - setupRe, RR)})`}], value: K(RR)},
566
- {label: D.setup.known ? 'Fixed setup' : 'Fixed setup (not logged)', segs: [{v: setupRe, col: COL.setup, tip: 'system prompt, tool definitions, lists: sent with every call'}], value: K(setupRe), sub: pct(setupRe, RR)},
567
- {label: 'Earlier conversation', segs: [{v: RR - setupRe, col: COL.reread, tip: 'everything said and read earlier in the chat'}], value: K(RR - setupRe), sub: pct(RR - setupRe, RR)}])}
568
- <p class="muted" style="font-size:12px">Fixed setup: the agent's system prompt, tool definitions and lists, sent with every call. Earlier conversation: everything said and read before this call. ${D.setup.known ? 'The setup size is estimated from its text (characters ÷ 4); the rest is exact.' : 'This agent does not log its setup, so all of it shows as earlier conversation.'}</p></div></div>`;
628
+ {v: x.setup, col: COL.setup, tip: `fixed setup: ${K(x.setup)}`}, {v: x.history, col: COL.hist, tip: `earlier conversation: ${K(x.history)}`}, {v: x.new, col: COL.new, tip: `new: ${K(x.new)}`}],
629
+ value: K(x.setup + x.history + x.new), sub: pct(x.setup + x.history + x.new, I)})), {label: 'Total', sum: true, segs: [], value: K(I), sub: '100%'}])}
630
+ ${nums(tbl(['Turn', '0 · Setup', '1 · Conversation', '2 · New', 'Sent', 'From cache', 'Written'], rows.map(x => [esc(tname(x.n).slice(0, 60)), K(x.setup), K(x.history), K(x.new), K(x.setup + x.history + x.new), K(x.cache), K(x.written)]),
631
+ ['Total', K(sum('setup')), K(sum('history')), K(sum('new')), K(I), K(sum('cache')), K(O)]))}</div>
632
+ <div><h3>What was sent, in three steps</h3>${key(STEPS.map(x => [x[1], x[2]]))}
633
+ ${chart([{label: 'Sent in all', segs: STEPS.map(x => ({v: SB[x[0]], col: x[2], tip: `${x[1]}: ${K(SB[x[0]])} (${pct(SB[x[0]], I)})`})), value: K(I)},
634
+ ...STEPS.map(x => ({label: x[1], segs: [{v: SB[x[0]], col: x[2], tip: GLOSS[x[3]][1]}], value: K(SB[x[0]]), sub: pct(SB[x[0]], I)}))])}
635
+ <p class="muted" style="font-size:12px">Step 0 is measured: the first call has no conversation yet, so everything it sent except your message is the agent's setup${D.setup.known ? '' : ' (this agent does not log its setup, so it is known only from that size)'}. "Sent with every call" below shows what the setup is made of.</p></div></div>`;
569
636
  },
570
637
  calls(){
571
638
  const main = C.filter(c => c.agent === 'main'), helpers = D.helpers.filter(h => h.calls);
@@ -617,7 +684,7 @@ const DRILL = {
617
684
  nums(tbl(['Fix', 'Where', 'Tokens cut', 'Share of sent', 'Saves'], fx.map(f => [`<b>${esc(f.title)}</b><br><span class="muted">${esc(f.why)}</span>`, esc(f.where), K(f.saving_tokens), pct(f.saving_tokens, T.sent), $$(f.saving_usd)]),
618
685
  ['Total', '', K(SAVE_T), pct(SAVE_T, T.sent), $$(SAVE)])) :
619
686
  '<p class="muted">None of the checks fired for this session.</p>'}
620
- <div><button type="button" class="btn" data-go="fix-panel">See the fixes in detail ↓</button></div>`;
687
+ <div class="row" style="display:flex;gap:8px;flex-wrap:wrap"><button type="button" class="btn" data-go="fix-panel">See the fixes in detail ↓</button>${D.summary ? '<button type="button" class="btn" data-go="deeper">Find deeper fixes ↓</button>' : ''}</div>`;
621
688
  },
622
689
  };
623
690
  let openDrill = null;
@@ -660,13 +727,26 @@ if (!reduce && 'IntersectionObserver' in window){
660
727
  /* ---------- turns + drill-down ---------- */
661
728
  const maxTurn = Math.max(...D.turns.map(t => t.sent + t.helper_sent), 1);
662
729
  /* a turn's tokens by kind: new text read, conversation re-sent, written */
663
- const tmix = n => { const cs = ofTurn(n), rr = cs.reduce((a, c) => a + c.usage.cache_read, 0);
664
- return {new: tin(cs) - rr, reread: rr, written: tout(cs)}; };
730
+ const tmix = n => { const cs = ofTurn(n), s = k => cs.reduce((a, c) => a + (c.sent[k] || 0), 0);
731
+ return {setup: s('setup'), history: s('history'), new: s('new'), written: tout(cs), cache: cs.reduce((a, c) => a + c.usage.cache_read, 0)}; };
665
732
  const FIXBY = {}; D.fixes.forEach(f => FIXBY[f.id] = f);
733
+ const dot = c => `<i class="dot ${c}"></i>`;
734
+ /* under a bar, numbers only (the colours say which part): "■45K + ■1.3K + ■8.2K = 55K", then "▬44K" from the cache.
735
+ The words are in the hover text and for screen readers. */
736
+ function barNums(mix){
737
+ const tot = mix.setup + mix.history + mix.new;
738
+ const parts = [['s-setup', mix.setup, 'fixed setup'], ['s-hist', mix.history, 'earlier conversation'], ['s-new', mix.new, 'new']].filter(x => x[1] > 0);
739
+ const said = parts.map(([, v, w]) => `${K(v)} ${w}`).join(' + ') + ` = ${K(tot)} sent; ${K(mix.cache)} re-read from the cache, ${K(tot - mix.cache)} at full price`;
740
+ return `<div class="barnums" title="${said}" aria-label="${said}"><div aria-hidden="true">${parts.map(([c, v]) => `${dot(c)}<b>${K(v)}</b>`).join(' + ')}${parts.length > 1 ? ` = <b>${K(tot)}</b>` : ''}` +
741
+ `<span class="cache">&nbsp;&nbsp;${dot('s-reread line')}${K(mix.cache)}</span></div></div>`;
742
+ }
666
743
  function stack(mix, scale){
667
- const tot = mix.new + mix.reread; if (!tot) return '<div class="stack" style="width:2%"></div>';
668
- return `<div class="stack" style="width:${Math.max(2, scale * 100)}%" title="sent ${K(tot)}: new text read ${K(mix.new)} · conversation re-sent ${K(mix.reread)} · then written ${K(mix.written)}">` +
669
- [['s-new', mix.new], ['s-reread', mix.reread]].map(([c, v]) => v > 0 ? `<span class="${c}" style="width:${v / tot * 100}%"></span>` : '').join('') + '</div>';
744
+ const tot = mix.setup + mix.history + mix.new; if (!tot) return '<div class="stack" style="width:2%"></div>';
745
+ const w = Math.max(2, scale * 100);
746
+ return `<div class="stack" style="width:${w}%" title="sent ${K(tot)}: fixed setup ${K(mix.setup)} · earlier conversation ${K(mix.history)} · new ${K(mix.new)}">` +
747
+ [['s-setup', mix.setup], ['s-hist', mix.history], ['s-new', mix.new]].map(([c, v]) => v > 0 ? `<span class="${c}" style="width:${v / tot * 100}%"></span>` : '').join('') + '</div>' +
748
+ `<div class="cacheline" style="width:${w}%" title="re-read from the cache: ${K(mix.cache)} of ${K(tot)} (${pct(mix.cache, tot)}); full price: ${K(tot - mix.cache)}"><span style="width:${mix.cache / tot * 100}%"></span></div>` +
749
+ barNums(mix);
670
750
  }
671
751
  function turnRow(t, i){
672
752
  const cost = t.usd + t.helper_usd, sent = t.sent + t.helper_sent;
@@ -681,17 +761,33 @@ function turnRow(t, i){
681
761
  <span class="cost num">${K(sent)}<small>sent · ${pct(sent, T.sent)} of all</small></span>
682
762
  </button><div class="drill" hidden></div></div>`;
683
763
  }
684
- $('turns').innerHTML = D.turns.map(turnRow).join('');
764
+ /* step 0: the agent's setup, sent before your first message and again with every call */
765
+ function step0Row(){
766
+ const s = D.setup.tokens || 0, mainCalls = C.filter(c => c.agent === 'main').length;
767
+ if (!s) return '';
768
+ const sCache = C.filter(c => c.agent === 'main').reduce((a, c) => a + Math.min(c.sent.setup || 0, c.usage.cache_read), 0);
769
+ return `<div class="turn step0"><button type="button" data-go="setup-panel">
770
+ <span class="no">0</span>
771
+ <span><span class="ask">Step 0 · the agent's setup, before your first message</span>
772
+ <span class="meta"><span>its instructions, tools and skills</span><span>sent again with every call</span><span>the grey part of every bar below</span></span></span>
773
+ <span class="mini"><div class="stack" style="width:${Math.max(2, s / maxTurn * 100)}%" title="fixed setup: ${K(s)} per call"><span class="s-setup" style="width:100%"></span></div><div class="cacheline" style="width:${Math.max(2, s / maxTurn * 100)}%" title="re-read from the cache: ${pct(sCache, SB.setup)} of the setup sent"><span style="width:${SB.setup ? sCache / SB.setup * 100 : 0}%"></span></div>
774
+ <div class="barnums" title="${K(s)} per call × ${mainCalls} call${mainCalls === 1 ? '' : 's'} = ${K(SB.setup)}; ${K(sCache)} re-read from the cache" aria-label="${K(s)} per call times ${mainCalls} calls = ${K(SB.setup)}; ${K(sCache)} re-read from the cache"><div aria-hidden="true">${dot('s-setup')}<b>${K(s)}</b> × ${mainCalls} = <b>${K(SB.setup)}</b><span class="cache">&nbsp;&nbsp;${dot('s-reread line')}${K(sCache)}</span></div></div></span>
775
+ <span class="cost num">${K(s)}<small>per call</small></span>
776
+ </button></div>`;
777
+ }
778
+ $('turns').innerHTML = step0Row() + D.turns.map(turnRow).join('');
685
779
  function turnDrill(t){
686
780
  const cs = t.calls.map(i => C[i]);
687
781
  const evs = t.events.map(e => `<span class="ev ${['typed','command','queued','answer'].includes(e.kind) ? 'you' : ''}" title="${esc(e.text)}">${esc(e.label)}${e.kind === 'answer' && e.text ? ': “' + esc(e.text.slice(0, 60)) + '”' : ''}${e.tokens ? ' · ~' + K(e.tokens) : ''}</span>`).join('');
688
- let sum = (cs.length <= 6 ? 'sent ' + cs.map(c => K(c.context)).join(' + ') + ' = <b>' + K(t.sent) + ' tokens</b>'
782
+ let sum = (cs.length === 1 ? `1 call sent <b>${K(t.sent)} tokens</b>`
783
+ : cs.length <= 6 ? `${cs.length} calls sent ` + cs.map(c => K(c.context)).join(' + ') + ' = <b>' + K(t.sent) + ' tokens</b>'
689
784
  : `${cs.length} calls (each listed below) sent <b>${K(t.sent)} tokens</b>`) + ` · cost ${$$(t.usd, 4)}`;
690
785
  if (t.helper_sent) sum += ` · plus helpers started in this turn: ${K(t.helper_sent)} tokens, ${$$(t.helper_usd, 4)}`;
691
786
  return `${evs ? `<div><div class="eyebrow" style="margin-bottom:4px">What came in this turn (~ tokens)</div><div class="events">${evs}</div></div>` : ''}
692
787
  <div class="sumline num">${sum}</div>
693
788
  <div class="calls">${t.calls.map(i => { const c = C[i]; return `<div class="call"><button type="button" aria-expanded="false" data-call="${i}">
694
- <span><span class="what"><span class="caret" aria-hidden="true"><svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 2.5 8 6l-3.5 3.5"/></svg></span>${md(c.label)}</span><br><span class="tag">${who(c)} · ${c.time} · ${esc(catLabel(c.category))} · wrote ${K(c.usage.output)} · ${$$(c.usd, 4)}</span></span>
789
+ <span><span class="what"><span class="caret" aria-hidden="true"><svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 2.5 8 6l-3.5 3.5"/></svg></span>${md(c.label)}</span><br><span class="tag">${who(c)} · ${c.time} · ${esc(catLabel(c.category))} · wrote ${K(c.usage.output)} · ${$$(c.usd, 4)}</span>
790
+ ${barNums({setup: c.sent.setup || 0, history: c.sent.history || 0, new: c.sent.new || 0, cache: c.usage.cache_read})}</span>
695
791
  <span class="c num">${K(c.context)}<small style="display:block;font-size:11px;color:var(--muted);font-weight:400">sent</small></span></button><div class="parts" hidden></div></div>`; }).join('')}</div>`;
696
792
  }
697
793
  function partsTable(title, block, unit){
@@ -701,19 +797,38 @@ function partsTable(title, block, unit){
701
797
  }
702
798
  function callDrill(c){
703
799
  const u = c.usage, cc = c.cost_by_class;
704
- const items = c.new_items.length ? `<details><summary>Where the new input came from (${c.new_items.length})</summary><table class="t">${c.new_items.map(x => `<tr><td>${md(x.what)}</td><td class="n num">~${N(x.tokens)}</td></tr>`).join('')}</table></details>` : '';
800
+ const part = pre => c.sent.parts.find(x => x.what.startsWith(pre)) || {tokens: 0, usd: 0, what: ''};
801
+ const s0 = part('fixed setup'), s1 = part('earlier conversation'), s2 = part('new input');
802
+ const row = (label, tokens, usd, cls = '') => `<tr class="${cls}"><td>${label}</td><td class="n num">${tokens}</td><td class="n num">${usd}</td></tr>`;
803
+ /* step 2 itemised: what the log names, then the rest it does not */
804
+ const named = c.new_items.reduce((a, x) => a + x.tokens, 0), rest = s2.tokens - named;
805
+ const items = c.new_items.map(x => row(`<span class="sub-item">${md(x.what)}</span>`, '~' + N(x.tokens), ''))
806
+ .concat(rest > 50 && c.new_items.length ? [row('<span class="sub-item">not itemised in the log (added by the agent without recording it)</span>', '~' + N(rest), '')] : []).join('');
807
+ const expired = s1.what.includes('expired') ? ' <span class="chip warn">sent again: the cache had expired</span>' : '';
808
+ const wrote = c.written.parts.map(x => row(`<span class="sub-item">${esc(x.what)}${x.estimated ? ' <span class="est">~ estimated split</span>' : ''}</span>`, N(x.tokens), $$(x.usd, 4))).join('');
705
809
  const acts = c.actions.length ? `<details><summary>Actions in this call (${c.actions.length})</summary><table class="t">${c.actions.map(a => `<tr><td>${md(a.label)}${a.error ? ' <span class="chip warn">error</span>' : ''}</td><td class="n num">${a.result_tokens ? '~' + N(a.result_tokens) + ' came back' : ''}</td></tr>`).join('')}</table></details>` : '';
706
810
  const think = u.thinking == null ? '<p class="est">This log does not separate hidden thinking from the rest of the output, so it is estimated.</p>' : '';
707
- return `<div class="parts-grid">${partsTable('What it sent', c.sent, 'sent this call')}${partsTable('What it wrote', c.written, 'written this call')}</div>
811
+ const notCached = Math.max(0, c.context - u.cache_read - u.cache_write_5m - u.cache_write_1h);
812
+ return `<table class="t steps"><thead><tr><th>What this call sent</th><th class="n">tokens</th><th class="n">cost</th></tr></thead><tbody>
813
+ ${c.agent === 'main' ? row(`${dot('s-setup')}Step 0 · fixed setup`, N(s0.tokens), $$(s0.usd, 4)) : ''}
814
+ ${row(`${dot('s-hist')}Step 1 · earlier conversation${expired}`, N(s1.tokens), $$(s1.usd, 4))}
815
+ ${row(`${dot('s-new')}Step 2 · new`, N(s2.tokens), $$(s2.usd, 4))}
816
+ ${items}
817
+ ${row('= sent', N(c.context), $$(c.sent.usd, 4), 'sum')}
818
+ </tbody></table>
819
+ <div class="sumline num">${dot('s-reread line')}of which re-read from the cache <b>${N(u.cache_read)}</b> (cheap) · written to the cache ${N(u.cache_write_5m + u.cache_write_1h)} · not cached ${N(notCached)}</div>
820
+ <table class="t steps"><thead><tr><th>What it wrote</th><th class="n">tokens</th><th class="n">cost</th></tr></thead><tbody>
821
+ ${wrote}${row('= written', N(c.written.tokens), $$(c.written.usd, 4), 'sum')}</tbody></table>
708
822
  <div class="sumline num">sent ${$$(c.sent.usd, 4)} + written ${$$(c.written.usd, 4)} = <b>${$$(c.usd, 4)}</b> for this call · ${esc(c.model)}</div>
709
- <details><summary>Exact token counts from the log</summary><table class="t">
710
- <tr><td>new input, not cached</td><td class="n num">${N(u.input)}</td><td class="n num">${$$(cc.input, 4)}</td></tr>
711
- <tr><td>new input, cached for 5 minutes</td><td class="n num">${N(u.cache_write_5m)}</td><td class="n num">${$$(cc.cache_write_5m, 4)}</td></tr>
712
- <tr><td>new input, cached for 1 hour</td><td class="n num">${N(u.cache_write_1h)}</td><td class="n num">${$$(cc.cache_write_1h, 4)}</td></tr>
713
- <tr><td>re-read from cache</td><td class="n num">${N(u.cache_read)}</td><td class="n num">${$$(cc.cache_read, 4)}</td></tr>
714
- <tr><td>written${u.thinking != null ? ' (of which ' + N(u.thinking) + ' hidden thinking)' : ''}</td><td class="n num">${N(u.output)}</td><td class="n num">${$$(cc.output, 4)}</td></tr>
715
- <tr class="sum"><td>= this call</td><td class="n num">${N(c.context + u.output)}</td><td class="n num">${$$(c.usd, 4)}</td></tr></table></details>
716
- ${items}${acts}${think}`;
823
+ <details><summary>As the log records it (by cache status)</summary><table class="t">
824
+ ${row('new input, not cached', N(u.input), $$(cc.input, 4))}
825
+ ${row('new input, cached for 5 minutes', N(u.cache_write_5m), $$(cc.cache_write_5m, 4))}
826
+ ${row('new input, cached for 1 hour', N(u.cache_write_1h), $$(cc.cache_write_1h, 4))}
827
+ ${row('re-read from cache', N(u.cache_read), $$(cc.cache_read, 4))}
828
+ ${row(`written${u.thinking != null ? ' (of which ' + N(u.thinking) + ' hidden thinking)' : ''}`, N(u.output), $$(cc.output, 4))}
829
+ ${row('= this call', N(c.context + u.output), $$(c.usd, 4), 'sum')}</table>
830
+ <p class="muted" style="font-size:12px">The same tokens as above, grouped the way the log counts them. The steps above group them by what they are.</p></details>
831
+ ${acts}${think}`;
717
832
  }
718
833
  function toggle(btn, box, fill){
719
834
  const open = btn.getAttribute('aria-expanded') !== 'true';
@@ -723,6 +838,8 @@ function toggle(btn, box, fill){
723
838
  return open;
724
839
  }
725
840
  $('turns').addEventListener('click', e => {
841
+ const go = e.target.closest('[data-go]');
842
+ if (go){ $(go.dataset.go).scrollIntoView({behavior: reduce ? 'auto' : 'smooth', block: 'start'}); return; }
726
843
  const cb = e.target.closest('button[data-call]');
727
844
  if (cb){ toggle(cb, cb.nextElementSibling, () => callDrill(C[+cb.dataset.call])); return; }
728
845
  const tb = e.target.closest('button[data-turn]');
@@ -876,12 +993,38 @@ $('fixes').innerHTML = D.fixes.length ? D.fixes.map(f => `<div class="fix"><div
876
993
  ${f.items.length ? `<details><summary>Details (${f.items.length})</summary><ul>${f.items.map(x => `<li>${md(x)}</li>`).join('')}</ul></details>` : ''}</div>`).join('')
877
994
  : '<p class="lede">None of the built-in checks fired: no unused tools sent with every call, no unchanged file read twice, no stop that only waited for a yes.</p>';
878
995
 
996
+ /* ---------- deeper fixes: preview what would be shared; nothing is sent yet ---------- */
997
+ (function deeper(){
998
+ const SUM = D.summary, btn = $('deeper-btn'), box = $('deeper-box');
999
+ if (!SUM){ $('deeper').hidden = true; return; }
1000
+ const json = JSON.stringify(SUM, null, 2), kb = Math.max(1, Math.round(json.length / 1024));
1001
+ const steps = SUM.calls.reduce((a, c) => a + c.steps.length, 0);
1002
+ const codes = new Set(SUM.calls.flatMap(c => c.steps.map(s => s.target).filter(Boolean)));
1003
+ box.innerHTML = `<div class="soon">Coming soon: deeper fixes from our catalog of measured patterns. Nothing has been sent: this is a preview of what would be shared.</div>
1004
+ <b>What the summary holds</b>
1005
+ <ul><li>For each of the ${SUM.calls.length} model calls: when it ran (seconds from the start), its model, and the tokens it sent (setup, earlier conversation, new) and wrote.</li>
1006
+ <li>For each of the ${steps} tool steps: the tool's name, its size in tokens, whether it failed, and how long it took. Commands only by their program (like <code>git</code> or <code>python</code>) and yes/no flags (chained, waits, rewrites a file).</li>
1007
+ <li>Files and commands only as codes (${codes.size} codes like <code>f1</code>, <code>c2</code>), new for every summary, so a file read twice shows as the same code without its name.</li>
1008
+ <li>The size of the agent's setup, how many tools it defines and uses, your ${SUM.turns.length} turns' sizes and waits, and the basic fixes found.</li></ul>
1009
+ <b>What it never holds</b>
1010
+ <ul><li>Your messages or the agent's replies, code or file contents, command text, file or folder names, the project name, the session id, or clock times.</li></ul>
1011
+ <details><summary>See the exact summary (${kb} KB)</summary><pre id="deeper-json"></pre>
1012
+ <div class="row"><button type="button" class="btn" id="deeper-copy">Copy</button><span class="muted" id="deeper-copied" style="font-size:12px"></span></div></details>`;
1013
+ $('deeper-json').textContent = json;
1014
+ $('deeper-copy').onclick = async () => {
1015
+ try { await navigator.clipboard.writeText(json); $('deeper-copied').textContent = 'Copied.'; }
1016
+ catch (e) { $('deeper-copied').textContent = 'Could not copy here: select the text above instead.'; }
1017
+ };
1018
+ btn.onclick = () => { const open = box.hidden; box.hidden = !open; btn.setAttribute('aria-expanded', String(open));
1019
+ btn.textContent = open ? 'Hide' : 'Find deeper fixes'; if (open) box.scrollIntoView({behavior: reduce ? 'auto' : 'smooth', block: 'nearest'}); };
1020
+ })();
1021
+
879
1022
  /* ---------- setup ---------- */
880
1023
  const S = D.setup;
881
- if (S.known){
882
- $('setup-total').textContent = '~' + K(S.tokens) + ' tokens';
883
- $('setup-lede').innerHTML = `Before you say a word, every call carries about <b>${K(S.tokens)}</b> tokens of the agent's own setup. After the first call it is mostly re-read from the cache: cheap per call, but never free.` +
884
- (S.tools.length ? ` <b>${S.unused.length} of ${S.tools.length}</b> tool definitions (~${K(S.unused_tokens)} tokens) were never used in this session.` : '') + ' Sizes are estimates (characters ÷ 4).';
1024
+ if (S.measured && S.tokens){
1025
+ $('setup-total').textContent = K(S.tokens) + ' tokens';
1026
+ $('setup-lede').innerHTML = `Step 0: before you say a word, every call carries <b>${K(S.tokens)}</b> tokens of the agent's own setup, measured from the first call of this session. After the first call it is mostly re-read from the cache: cheap per call, but never free.` +
1027
+ (S.tools.length ? ` <b>${S.unused.length} of ${S.tools.length}</b> tool definitions (~${K(S.unused_tokens)} tokens) were never used in this session.` : '') + (S.parts.length > 1 ? ' The split into parts is estimated from their text (characters ÷ 4).' : '');
885
1028
  barList('setup-parts', [...S.parts].sort((a, b) => b.tokens - a.tokens).map(p => ({l: esc(p.what), v: p.tokens, s: '~' + K(p.tokens)})), '--c-setup');
886
1029
  $('tools-sum').textContent = `Each tool definition (${S.tools.length}) and whether this session used it`;
887
1030
  $('tools').innerHTML = S.tools.map(t => `<div class="${t.used ? '' : 'unused'}"><span>${esc(t.name)}${t.used ? '' : ' · never used'}</span><span class="num">~${K(t.tokens)}</span></div>`).join('');
@@ -29,11 +29,24 @@ no AI model.
29
29
  and its path from the summary. Do not offer to open it. Only if the user says it did not open, open the file with
30
30
  the system's default opener (`start "" <path>` on Windows, `open <path>` on macOS, `xdg-open <path>` on Linux).
31
31
 
32
+ ## Deleting old reports
33
+
34
+ Each session keeps one report in `~/.sessioncost/reports/`. If the summary ends with an "Old reports: ..." line, show
35
+ it as printed and do not ask anything about it. Only when the user asks to delete or clean old reports (for example
36
+ "/sessioncost clean"), run (with the same fallbacks as step 1):
37
+
38
+ ```
39
+ {{RUN}} clean
40
+ ```
41
+
42
+ It deletes every saved report except the latest and prints what it freed. Show that line.
43
+
32
44
  ## Never
33
45
 
34
46
  - Never read the session log or the HTML report into the conversation. Both are large; reading them would cost more
35
47
  tokens than the session you are measuring.
36
48
  - Never run `--json` unless the user asks for the raw numbers, and then show only the part they asked about.
37
49
  - Never estimate costs yourself. The tool's numbers are the answer.
50
+ - Never delete reports unless the user asked for it.
38
51
  - If the user says they are on a subscription plan (Claude Pro or Max, a ChatGPT plan), add `--subscription` so money
39
52
  is labelled API-equivalent. Antigravity is always labelled that way.
@@ -0,0 +1,157 @@
1
+ """The summary SessionCost would send for deeper fixes: numbers, tool names and yes/no flags, never content.
2
+
3
+ What it never holds: your messages, the agent's replies, code, file contents, command text, file or folder names,
4
+ the project name, the session id, or clock times. Files and commands appear only as short codes that are random for
5
+ each summary ("f3", "c7"), so the same file read twice shows as the same code without saying which file it is.
6
+ Times are seconds from the start of the session.
7
+
8
+ Nothing is sent yet: the report shows this summary so you can see exactly what would be shared.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import re
13
+
14
+ from . import __version__
15
+
16
+ FORMAT = "sessioncost-summary/1"
17
+
18
+ # Program names that say what kind of step a command was, and nothing about your project. Anything else is "other".
19
+ PROGRAMS = {
20
+ "git", "gh", "npm", "npx", "pnpm", "yarn", "node", "bun", "deno", "python", "python3", "py", "pip", "pip3", "uv",
21
+ "uvx", "pytest", "go", "cargo", "rustc", "java", "mvn", "gradle", "dotnet", "make", "cmake", "docker", "kubectl",
22
+ "ls", "dir", "cat", "head", "tail", "type", "grep", "rg", "find", "sed", "awk", "echo", "cd", "pwd", "mkdir", "rm",
23
+ "cp", "mv", "touch", "chmod", "curl", "wget", "sleep", "timeout", "start-sleep", "netstat", "lsof", "ps", "kill",
24
+ "tasklist", "taskkill", "get-childitem", "get-content", "set-content", "select-string", "powershell", "pwsh",
25
+ "bash", "sh", "cmd", "wc", "sort", "diff", "tar", "zip", "unzip", "which", "where", "ssh", "scp", "jq",
26
+ }
27
+ GIT_SUB = {"status", "diff", "log", "add", "commit", "push", "pull", "fetch", "checkout", "switch", "branch", "merge",
28
+ "rebase", "stash", "show", "reset", "tag", "clone", "init", "remote", "rev-parse", "ls-files", "restore"}
29
+ SOURCE_EXT = r"\.(py|js|jsx|ts|tsx|mjs|cjs|go|rs|java|kt|cs|cpp|c|h|rb|php|swift|html|css|scss|vue|svelte|json|ya?ml|toml|md)\b"
30
+
31
+
32
+ def _flags(cmd: str) -> list[str]:
33
+ """Yes/no facts about a command's shape. The command text itself is not kept."""
34
+ whole = cmd.strip()
35
+ c = whole.splitlines()[0] if whole else "" # the shape is the first line; a script body is not a chain
36
+ low = c.lower()
37
+ f = []
38
+ if re.search(r"(^|\s)(--help|-h|/\?)(\s|$)|\bhelp\b", low):
39
+ f.append("help")
40
+ if re.match(r"(cd|set-location|pushd)\s", low):
41
+ f.append("cd_prefix")
42
+ if "&&" in c or re.search(r";\s*\S", c):
43
+ f.append("chained")
44
+ if re.search(r"(^|\s)&\s*$|start-process|nohup|run_in_background", low):
45
+ f.append("background")
46
+ if re.match(r"(sleep|start-sleep|timeout)\b", low):
47
+ f.append("wait")
48
+ wl = whole.lower()
49
+ if re.search(r"(sed\s+-i|perl\s+-pi|set-content|out-file|python3?\s+-c|>\s*\S+" + SOURCE_EXT + r"|<<)", wl) \
50
+ and re.search(r"(write_text|>\s*\S+|sed\s+-i|set-content|out-file|\.write\()", wl) and re.search(SOURCE_EXT, wl):
51
+ f.append("rewrites_file")
52
+ return f
53
+
54
+
55
+ def _program(cmd: str) -> tuple[str, str]:
56
+ words = re.findall(r"[^\s;&|]+", cmd.strip())
57
+ i = 0
58
+ while i < len(words) and (words[i].lower() in ("cd", "set-location", "pushd") or "=" in words[i]):
59
+ i += 2 if words[i].lower() in ("cd", "set-location", "pushd") else 1 # skip "cd dir &&" and VAR=x
60
+ while i < len(words) and words[i] in ("&&", ";"):
61
+ i += 1
62
+ if i >= len(words):
63
+ return "other", ""
64
+ prog = re.split(r"[\\/]", words[i].strip("\"'"))[-1].lower().removesuffix(".exe")
65
+ if prog not in PROGRAMS:
66
+ return "other", ""
67
+ sub = ""
68
+ if prog == "git" and i + 1 < len(words) and words[i + 1].lower() in GIT_SUB:
69
+ sub = words[i + 1].lower()
70
+ return prog, sub
71
+
72
+
73
+ SETUP_KINDS = [("system prompt", "system prompt"), ("base instructions", "system prompt"), ("tool definition", "tool definitions"),
74
+ ("skill", "skill list"), ("helper", "helper list"), ("agent", "helper list"), ("loaded on demand", "deferred tool names"),
75
+ ("mcp", "MCP server instructions"), ("instruction", "instruction files"), ("rule", "instruction files"),
76
+ ("context", "session context"), ("built-in", "built-in instructions (not in the log)")]
77
+
78
+
79
+ def _setup_kind(what: str) -> str:
80
+ """Setup parts can be named after your own files (Cursor, Codex): keep only a generic kind."""
81
+ w = what.lower()
82
+ if w.startswith("the agent's built-in"):
83
+ return "built-in instructions (not in the log)"
84
+ return next((kind for key, kind in SETUP_KINDS if key in w), "other")
85
+
86
+
87
+ def _tool_name(tool: str) -> str:
88
+ """The agent's own tool names (Read, Bash, ...) are public. MCP tool names can name private servers: keep only
89
+ that it was an MCP tool."""
90
+ return "mcp" if tool.lower().startswith("mcp") else tool
91
+
92
+
93
+ def build(s, a: dict) -> dict:
94
+ """The summary for one session: s is the Session, a the analysis made from it."""
95
+ codes: dict[tuple[str, str], str] = {}
96
+
97
+ def code(kind: str, target: str) -> str:
98
+ key = (kind, target.strip())
99
+ if key not in codes:
100
+ codes[key] = ("f" if kind == "file" else "c" if kind == "command" else "q") + str(len(codes) + 1)
101
+ return codes[key]
102
+
103
+ t0 = s.started
104
+ secs = lambda d: round((d - t0).total_seconds(), 1) if d and t0 else None
105
+ seen_result: dict[str, str] = {} # target code -> result fingerprint last seen
106
+ by_id = {(c["agent"], c["n"]): c for c in a["calls"]}
107
+
108
+ def step(x) -> dict:
109
+ kind = {"read": "file", "edit": "file", "write": "file", "command": "command"}.get(x.kind, "query")
110
+ st = {"tool": _tool_name(x.tool), "kind": x.kind,
111
+ "in": round(x.args_chars / 4), "out": round(x.result_chars / 4), "wrote": round(x.written_chars / 4),
112
+ "error": x.is_error,
113
+ "secs": round((x.result_ts - x.ts).total_seconds(), 1) if x.ts and x.result_ts else None}
114
+ if x.target:
115
+ st["target"] = code(kind, x.target)
116
+ if x.result_hash:
117
+ st["same_result_as_last_time"] = seen_result.get(st["target"]) == x.result_hash
118
+ seen_result[st["target"]] = x.result_hash
119
+ if x.kind == "command" and x.target:
120
+ st["program"], sub = _program(x.target)
121
+ if sub:
122
+ st["git"] = sub
123
+ st["flags"] = _flags(x.target)
124
+ return st
125
+
126
+ def call(c) -> dict:
127
+ d = by_id.get((c.agent, c.n), {})
128
+ sent = d.get("sent", {})
129
+ return {"thread": "main" if c.agent == "main" else "helper", "turn": c.turn, "t": secs(c.ts_first),
130
+ "model": c.model,
131
+ "sent": {k: sent.get(k, 0) for k in ("setup", "history", "new")},
132
+ "cache_read": c.usage.cache_read, "cache_write": c.usage.cache_write, "written": c.usage.output,
133
+ "thinking": c.usage.thinking, "steps": [step(x) for x in c.actions]}
134
+
135
+ tools = a["setup"]["tools"]
136
+ return {
137
+ "format": FORMAT,
138
+ "made_by": f"sessioncost {__version__}",
139
+ "agent": s.agent, "agent_version": s.agent_version, "models": a["meta"]["models"],
140
+ "session": {"turns": a["totals"]["turns"], "calls": a["totals"]["calls"],
141
+ "helper_calls": a["totals"]["helper_calls"], "working_min": a["totals"]["working_min"],
142
+ "waiting_min": a["totals"]["waiting_min"], "tokens_exact": a["money"]["tokens_exact"]},
143
+ "setup": {"tokens": a["setup"]["tokens"],
144
+ "parts": [{"what": _setup_kind(p["what"]), "tokens": p["tokens"]} for p in a["setup"]["parts"]],
145
+ "tools_defined": len(tools), "tools_used": sum(1 for x in tools if x["used"]),
146
+ "unused_tool_tokens": a["setup"]["unused_tokens"],
147
+ "mcp_tools_defined": sum(1 for x in tools if x["name"].lower().startswith("mcp"))},
148
+ "turns": [{"n": t["n"], "kind": t["kind"], "said_tokens": sum(e["tokens"] for e in t["events"]
149
+ if e["kind"] in ("typed", "command", "queued", "answer")),
150
+ "added_by_agent_tokens": sum(e["tokens"] for e in t["events"]
151
+ if e["kind"] not in ("typed", "command", "queued", "answer")),
152
+ "waited_s": round(t["waited_min"] * 60), "calls": len(t["calls"]),
153
+ "confirm_only": "confirm-turn" in t["fixes"]} for t in a["turns"]],
154
+ "calls": [call(c) for c in s.calls] + [call(c) for h in s.helpers for c in h.calls],
155
+ "helpers": [{"calls": h["calls"], "turn": h["turn"], "tokens": h["tokens"]} for h in a["helpers"]],
156
+ "basic_fixes": [{"id": f["id"], "tokens": f["saving_tokens"]} for f in a["fixes"]],
157
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sessioncost
3
- Version: 0.2.2
3
+ Version: 0.3.1
4
4
  Summary: See how many tokens each turn of your coding-agent chat sends, why, and what to cut. Cost goes down with it.
5
5
  Author: kish21
6
6
  License-Expression: MIT
@@ -88,6 +88,20 @@ Report: ~/.sessioncost/reports/<session-id>.html
88
88
  Asking costs almost nothing: the agent runs the tool and shows its summary, and never reads the log or the report
89
89
  into the chat.
90
90
 
91
+ ## Updating
92
+
93
+ | How you installed it | How to update |
94
+ |---|---|
95
+ | The one line above | Paste the same line again, then restart your agent. |
96
+ | Claude Code plugin | **/plugin → Marketplaces → sessioncost → Update** (or type `/plugin marketplace update sessioncost`, then `/plugin update sessioncost@sessioncost`), then restart Claude Code. You can also turn on auto-update for this marketplace there. |
97
+ | pip | `pip install -U sessioncost`, then `sessioncost setup`. |
98
+
99
+ ## Old reports
100
+
101
+ Each chat keeps one report in `~/.sessioncost/reports/` (a few hundred KB each). When more than 10 older ones pile up,
102
+ the summary says so. Type `/sessioncost clean` in a chat, or `sessioncost clean` in a terminal, to delete all of them
103
+ except the latest. Nothing is ever deleted unless you ask.
104
+
91
105
  ## If something goes wrong
92
106
 
93
107
  - **`/sessioncost` does nothing:** restart the agent after installing.
@@ -124,6 +138,7 @@ sessioncost 3f2a9c1e # one chat, by the first characters of its id
124
138
  sessioncost path/to/session.jsonl # or an Antigravity .db
125
139
  sessioncost 3f2a9c1e --cursor-usage usage-events.csv # a Cursor chat, with exact tokens from your export
126
140
  sessioncost setup # add /sessioncost to your agents again
141
+ sessioncost clean # delete all saved reports except the latest
127
142
  ```
128
143
 
129
144
  Options: `--project <dir>` (another project folder), `--no-open` (write the report, do not open it), `--json`
@@ -13,6 +13,7 @@ sessioncost/prices.json
13
13
  sessioncost/prices.py
14
14
  sessioncost/report.py
15
15
  sessioncost/report_template.html
16
+ sessioncost/summary.py
16
17
  sessioncost.egg-info/PKG-INFO
17
18
  sessioncost.egg-info/SOURCES.txt
18
19
  sessioncost.egg-info/dependency_links.txt
File without changes
File without changes
File without changes