sessioncost 0.2.2__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. {sessioncost-0.2.2/sessioncost.egg-info → sessioncost-0.3.0}/PKG-INFO +1 -1
  2. {sessioncost-0.2.2 → sessioncost-0.3.0}/pyproject.toml +1 -1
  3. {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost/__init__.py +1 -1
  4. {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost/analyse.py +34 -10
  5. {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost/report_template.html +114 -40
  6. sessioncost-0.3.0/sessioncost/summary.py +157 -0
  7. {sessioncost-0.2.2 → sessioncost-0.3.0/sessioncost.egg-info}/PKG-INFO +1 -1
  8. {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost.egg-info/SOURCES.txt +1 -0
  9. {sessioncost-0.2.2 → sessioncost-0.3.0}/LICENSE +0 -0
  10. {sessioncost-0.2.2 → sessioncost-0.3.0}/MANIFEST.in +0 -0
  11. {sessioncost-0.2.2 → sessioncost-0.3.0}/README.md +0 -0
  12. {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost/__main__.py +0 -0
  13. {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost/cli.py +0 -0
  14. {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost/fixes.py +0 -0
  15. {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost/locate.py +0 -0
  16. {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost/model.py +0 -0
  17. {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost/prices.json +0 -0
  18. {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost/prices.py +0 -0
  19. {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost/readers/__init__.py +0 -0
  20. {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost/readers/_pb.py +0 -0
  21. {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost/readers/antigravity.py +0 -0
  22. {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost/readers/claude.py +0 -0
  23. {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost/readers/codex.py +0 -0
  24. {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost/readers/cursor.py +0 -0
  25. {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost/report.py +0 -0
  26. {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost/skill/SKILL.md +0 -0
  27. {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost.egg-info/dependency_links.txt +0 -0
  28. {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost.egg-info/entry_points.txt +0 -0
  29. {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost.egg-info/top_level.txt +0 -0
  30. {sessioncost-0.2.2 → sessioncost-0.3.0}/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.0
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
@@ -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.0"
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.0"
@@ -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"),
@@ -128,10 +130,10 @@ def sent_parts(c: Call, r: dict, setup_tokens: int) -> list[dict]:
128
130
  new = u.new_input - s_new
129
131
  parts = []
130
132
  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")})
133
+ parts.append({"what": "fixed setup (instructions, tools, skills: step 0)", "tokens": setup,
134
+ "usd": (s_cr * cr_rate + s_new * new_rate) / 1e6, "estimated": False})
135
+ parts.append({"what": "earlier conversation (re-read from cache)", "tokens": carried, "usd": carried * cr_rate / 1e6,
136
+ "estimated": False})
135
137
  parts.append({"what": "new input (read for the first time)", "tokens": new, "usd": new * new_rate / 1e6,
136
138
  "estimated": bool(s_new)})
137
139
  return parts
@@ -178,8 +180,18 @@ def money_label(exact: bool, as_of: str, subscription: bool, tokens_exact: bool
178
180
  def analyse(s: Session, prices: dict, subscription: bool = False, engines: list | None = None) -> dict:
179
181
  P = Pricer(prices)
180
182
  subscription = subscription or not s.billed_per_token
181
- setup_tokens = tok(s.setup.chars) if s.setup.known else 0
182
183
  main_calls = s.calls
184
+ # Step 0, the fixed setup: the first call has no history yet, so everything it sent except what you said is the
185
+ # agent's own setup (instructions, tools, skills, project rules). Measured this way it includes the parts the log
186
+ # does not spell out (Claude Code's built-in instructions), and it works for agents that log no setup at all.
187
+ setup_logged = tok(s.setup.chars) if s.setup.known else 0
188
+ setup_tokens = setup_logged
189
+ first = main_calls[0] if main_calls else None
190
+ if first is not None:
191
+ t0 = next((t for t in s.turns if first in t.calls), None)
192
+ said = sum(tok(e.chars) for e in (t0.events if t0 else [])
193
+ if e.kind in SAID and (e.ts is None or first.ts_first is None or e.ts <= first.ts_first))
194
+ setup_tokens = max(first.usage.context - said, 0) # measured; the text estimate only splits it
183
195
  first_id = main_calls[0].msg_id if main_calls else None
184
196
  last_id = main_calls[-1].msg_id if main_calls else None
185
197
 
@@ -191,6 +203,9 @@ def analyse(s: Session, prices: dict, subscription: bool = False, engines: list
191
203
  cc = pricemod.cost(c.usage, r)
192
204
  total = sum(cc.values())
193
205
  sp = sent_parts(c, r, setup_tokens)
206
+ sp_by = {"setup": sum(x["tokens"] for x in sp if x["what"].startswith("fixed setup")),
207
+ "history": sum(x["tokens"] for x in sp if x["what"].startswith("earlier conversation")),
208
+ "new": sum(x["tokens"] for x in sp if x["what"].startswith("new input"))}
194
209
  wp = written_parts(c, r)
195
210
  d = {
196
211
  "agent": c.agent, "n": c.n, "turn": c.turn, "model": c.model,
@@ -207,7 +222,7 @@ def analyse(s: Session, prices: dict, subscription: bool = False, engines: list
207
222
  "context": c.usage.context,
208
223
  "cost_by_class": cc,
209
224
  "usd": total,
210
- "sent": {"tokens": c.usage.context, "usd": sum(p["usd"] for p in sp), "parts": sp},
225
+ "sent": {"tokens": c.usage.context, "usd": sum(p["usd"] for p in sp), "parts": sp, **sp_by},
211
226
  "written": {"tokens": c.usage.output, "usd": sum(p["usd"] for p in wp), "parts": wp},
212
227
  "mix": {"written": cc["output"], "new": cc["input"] + cc["cache_write_5m"] + cc["cache_write_1h"],
213
228
  "reread": cc["cache_read"]},
@@ -231,7 +246,7 @@ def analyse(s: Session, prices: dict, subscription: bool = False, engines: list
231
246
  keep = bool(e.ts and prev is not None and prev.ts_last and c.ts_first
232
247
  and prev.ts_last < e.ts <= c.ts_first)
233
248
  if keep:
234
- said = e.kind in ("typed", "command", "queued", "answer")
249
+ said = e.kind in SAID
235
250
  items.append({"what": e.label + (f": “{e.text}”" if said else ""),
236
251
  "tokens": tok(e.chars)})
237
252
  by_obj[id(c)]["new_items"] = [x for x in items if x["tokens"]]
@@ -259,6 +274,7 @@ def analyse(s: Session, prices: dict, subscription: bool = False, engines: list
259
274
  "usd": sum(d["usd"] for d in tc),
260
275
  "helper_usd": sum(by_obj[id(c)]["usd"] for c in s.helper_calls if c.turn == t.n),
261
276
  "sent": sum(d["sent"]["tokens"] for d in tc), "written": sum(d["written"]["tokens"] for d in tc),
277
+ "sent_by": {k: sum(d["sent"][k] for d in tc) for k in ("setup", "history", "new")},
262
278
  "helper_sent": sum(by_obj[id(c)]["sent"]["tokens"] for c in s.helper_calls if c.turn == t.n),
263
279
  "calls": [calls.index(d) for d in tc],
264
280
  "mix": {k: sum(d["mix"][k] for d in tc) for k in ("written", "new", "reread")},
@@ -282,8 +298,14 @@ def analyse(s: Session, prices: dict, subscription: bool = False, engines: list
282
298
 
283
299
  # ---- setup
284
300
  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],
301
+ parts = [{"what": p.what, "tokens": tok(p.chars)} for p in s.setup.parts]
302
+ if setup_logged > setup_tokens and setup_logged: # the text estimate ran over the measured size: scale it to fit
303
+ parts = [dict(x, tokens=round(x["tokens"] * setup_tokens / setup_logged)) for x in parts]
304
+ elif setup_tokens > setup_logged:
305
+ parts.append({"what": "the agent's built-in instructions and other start-up text (not spelled out in the log)",
306
+ "tokens": setup_tokens - setup_logged})
307
+ setup = {"known": s.setup.known, "measured": first is not None, "tokens": setup_tokens, "logged": setup_logged,
308
+ "parts": parts,
287
309
  "tools": [{"name": p.what, "tokens": tok(p.chars),
288
310
  "used": p.what in used} for p in s.setup.tools]}
289
311
  setup["unused"] = [t["name"] for t in setup["tools"] if not t["used"]]
@@ -340,6 +362,7 @@ def analyse(s: Session, prices: dict, subscription: bool = False, engines: list
340
362
  "working_min": round(working, 1), "waiting_min": round(waiting / 60, 1),
341
363
  "context_max": max((d["context"] for d in calls if d["agent"] == "main"), default=0),
342
364
  "sent": sum(d["sent"]["tokens"] for d in calls),
365
+ "sent_by": {k: sum(d["sent"][k] for d in calls) for k in ("setup", "history", "new")},
343
366
  "output": sum(d["usage"]["output"] for d in calls), "thinking": thinking,
344
367
  "thinking_known": thinking_known,
345
368
  "by_class": by_class,
@@ -366,6 +389,7 @@ def analyse(s: Session, prices: dict, subscription: bool = False, engines: list
366
389
  for tr in turn_rows:
367
390
  if tr["n"] in f.turns:
368
391
  tr["fixes"].append(f.id)
392
+ out["summary"] = summarymod.build(s, out) # what a deeper-fix check would receive: no content (summary.py)
369
393
  return out
370
394
 
371
395
 
@@ -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.
@@ -35,6 +36,12 @@
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}
@@ -271,6 +281,14 @@ input[type=range]{flex:1 1 160px;accent-color:var(--accent);min-width:120px}
271
281
 
272
282
  /* fixes, setup, footer */
273
283
  .fixes{display:grid;gap:10px}
284
+ .deeper{margin-top:14px;border:1px dashed var(--line);border-radius:10px;padding:14px 16px;background:var(--surface)}
285
+ .deeper-head{display:flex;gap:16px;align-items:flex-start;justify-content:space-between;flex-wrap:wrap}
286
+ .deeper-head > div{flex:1 1 420px;min-width:0}
287
+ .deeper h3{margin:0 0 4px;font-size:15px}
288
+ .deeper .soon{margin:12px 0 8px;padding:10px 12px;border-radius:8px;background:var(--chip-bg);color:var(--chip);font-weight:600}
289
+ .deeper ul{margin:6px 0 10px;padding-left:18px;color:var(--ink-2);font-size:13px}
290
+ .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}
291
+ .deeper .row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:8px}
274
292
  .fix{border:1px solid var(--line);border-radius:8px;padding:12px 14px;display:grid;gap:6px;background:var(--raise)}
275
293
  .fix .top{display:flex;flex-wrap:wrap;justify-content:space-between;gap:6px 12px;align-items:baseline}
276
294
  .fix ul{margin:4px 0 0;padding-left:18px;color:var(--ink-2);font-size:12px}
@@ -290,6 +308,7 @@ details summary{cursor:pointer;color:var(--ink-2);font-size:12px}
290
308
  <body>
291
309
  <div class="wrap">
292
310
  <header>
311
+ <button type="button" class="theme" id="theme" aria-label="Switch between light and dark"></button>
293
312
  <div class="eyebrow" id="eyebrow">SessionCost</div>
294
313
  <h1 id="title"></h1>
295
314
  <p class="sub" id="subtitle"></p>
@@ -302,9 +321,9 @@ details summary{cursor:pointer;color:var(--ink-2);font-size:12px}
302
321
 
303
322
  <section class="panel" aria-labelledby="h-turns" id="turns-panel">
304
323
  <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>
324
+ <span class="legend"><span><span class="sw s-setup"></span>0 · fixed setup</span><span><span class="sw s-reread"></span>1 · earlier conversation</span><span><span class="sw s-new"></span>2 · new</span></span></div>
306
325
  <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>
326
+ <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); amber, the earlier conversation sent again; green, what was new. Longer means more tokens. Click a row to see the model calls behind it.</span></div>
308
327
  <div class="turns" id="turns"></div>
309
328
  </section>
310
329
 
@@ -345,6 +364,17 @@ details summary{cursor:pointer;color:var(--ink-2);font-size:12px}
345
364
  <div class="panel-head"><h2 id="h-fix">What to fix</h2><span class="eyebrow">tokens cut, measured from this session</span></div>
346
365
  <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
366
  <div class="fixes" id="fixes"></div>
367
+ <div class="deeper" id="deeper">
368
+ <div class="deeper-head">
369
+ <div><h3>Deeper fixes</h3>
370
+ <p class="lede">These are the three basic checks. Our catalog of measured waste patterns has dozens more: an agent
371
+ polling instead of waiting, files re-read, tool results far bigger than the work, research in the main chat,
372
+ and others. To check them, SessionCost would share a <b>summary</b> of this session: numbers, tool names and
373
+ yes/no flags. Never your messages, code, file names or project.</p></div>
374
+ <button type="button" class="btn primary" id="deeper-btn" aria-expanded="false" aria-controls="deeper-box">Find deeper fixes</button>
375
+ </div>
376
+ <div id="deeper-box" hidden></div>
377
+ </div>
348
378
  </section>
349
379
 
350
380
  <section class="panel" aria-labelledby="h-setup">
@@ -383,6 +413,18 @@ const tin = cs => cs.reduce((a, c) => a + c.context, 0), tout = cs => cs.reduce(
383
413
  const io_ = cs => `${K(tin(cs))} in · ${K(tout(cs))} out`;
384
414
  const who = c => c.agent === 'main' ? 'Call ' + c.n : 'Helper ' + c.agent.split(':').pop().slice(0,6) + ', call ' + c.n;
385
415
 
416
+ /* ---------- light / dark switch: follows the system until clicked, then remembers the choice ---------- */
417
+ (function theme(){
418
+ const btn = $('theme'), root = document.documentElement;
419
+ 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>';
420
+ 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>';
421
+ const isDark = () => root.dataset.theme ? root.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
422
+ const paint = () => { const d = isDark(); btn.innerHTML = (d ? sun : moon) + (d ? 'Light mode' : 'Dark mode'); btn.setAttribute('aria-pressed', String(d)); };
423
+ btn.onclick = () => { const next = isDark() ? 'light' : 'dark'; root.dataset.theme = next;
424
+ try { localStorage.setItem('sessioncost-theme', next); } catch (e) {} paint(); };
425
+ paint();
426
+ })();
427
+
386
428
  /* ---------- header ---------- */
387
429
  $('eyebrow').textContent = 'SessionCost · ' + M.agent_label + (M.date ? ' · ' + M.date : '');
388
430
  $('title').textContent = M.title;
@@ -402,7 +444,10 @@ const GLOSS = {
402
444
  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
445
  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
446
  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.'],
447
+ 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.'],
448
+ 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.'],
449
+ 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.'],
450
+ fresh: ['Step 2 · new', 'What is sent for the first time in a call: your message, a file just read, a command\'s output.'],
406
451
  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
452
  };
408
453
  const q = k => `<span class="q" role="button" tabindex="0" data-help="${k}" aria-label="What is ${esc(GLOSS[k][0])}?">?</span>`;
@@ -428,19 +473,22 @@ const q = k => `<span class="q" role="button" tabindex="0" data-help="${k}" aria
428
473
  }, true);
429
474
  addEventListener('scroll', close, {passive: true});
430
475
  })();
476
+ const SB = T.sent_by || {setup: 0, history: 0, new: T.sent};
477
+ const RR_T = (T.by_class.find(c => c.key === 'cache_read') || {tokens: 0}).tokens;
478
+ const STEPS = [['setup', '0 · fixed setup', 'var(--c-setup)', 'step0'], ['history', '1 · earlier conversation', 'var(--c-reread)', 'history'], ['new', '2 · new', 'var(--c-new)', 'fresh']];
431
479
  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
480
  const ENGINE = (D.fix_engines || []).some(n => n !== 'built-in');
433
481
  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'],
482
+ ['Tokens sent', K(T.sent), `${K(SB.setup)} setup · ${K(SB.history)} conversation · ${K(SB.new)} new`,
483
+ T.sent ? `<span class="chip">${K(RR_T)} from the cache</span>` : '', '', 'tokens'],
436
484
  ['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
485
  ['Model calls', T.calls + T.helper_calls, T.helper_calls ? `${T.calls} main · ${T.helper_calls} by helpers` : 'all in the main chat',
438
486
  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
487
  ['Cost', $$(T.usd), 'the result of the tokens', `<span class="chip ${MO.exact || MO.subscription ? '' : 'warn'}">${esc(MO.short)}</span>`, '', 'total'],
442
488
  ['Agent working', mins(T.working_min), 'summed over turns', '', '', 'working'],
443
489
  ['Waiting for you', mins(T.waiting_min), 'until you replied', '', '', 'waiting'],
490
+ ['Tokens to cut', K(SAVE_T), `${D.fixes.length} fix${D.fixes.length === 1 ? '' : 'es'} · ${pct(SAVE_T, T.sent)} of tokens sent`,
491
+ ENGINE ? '' : '<span class="chip warn">basic fixes only</span>', `<div class="why">saves ${$$(SAVE)} · measured from this session</div>`, 'savings'],
444
492
  ];
445
493
  const TQ = {total: MO.subscription ? 'apieq' : MO.exact ? 'exact' : 'estimate', turns: 'turn', tokens: 'sent', calls: 'call', working: 'working', waiting: 'waiting', savings: 'savings'};
446
494
  $('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 +517,18 @@ function openTurn(n){
469
517
  const short = s => s.length > 70 ? s.slice(0, 69) + '…' : s;
470
518
  const label = MO.subscription ? 'would have cost' : 'cost';
471
519
  $('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])}` : ''}.
520
+ 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')}.
473
521
  That ${label} <b>${$$(T.usd)}</b>${MO.subscription ? ' at pay-per-use prices' : ''}${MO.exact ? '' : ' (an estimate)'}.</p>
474
522
  <div class="cards">
475
523
  ${top ? `<div class="card warm"><div class="eyebrow">Heaviest message</div>
476
524
  <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
525
  <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>
526
+ <div class="card"><div class="eyebrow">What was sent</div>
527
+ <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>
528
+ <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('')}
529
+ <span class="muted">${K(RR_T)} of it re-read from the cache · ${K(T.output)} written back</span></div></div>
481
530
  <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>
531
+ <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
532
  <button type="button" class="act" data-drill-go="calls">See every call →</button></div>
484
533
  <div class="card good"><div class="eyebrow">Biggest cut</div>
485
534
  ${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>
@@ -530,7 +579,7 @@ function chart(rows, opts = {}){
530
579
  }).join('') + `</div>`;
531
580
  }
532
581
  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."};
582
+ 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), amber the earlier conversation (step 1), green what was new (step 2). 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
583
  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
584
  const tname = n => n ? `#${n} ${(D.turns.find(t => t.n === n) || {text: ''}).text}` : 'before your first message';
536
585
  const DRILL = {
@@ -550,22 +599,21 @@ const DRILL = {
550
599
  ['Total', C.length, $$(T.usd), '100%']))}</div></div>`;
551
600
  },
552
601
  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]])}
602
+ const I = tin(C), RR = RR_T, O = tout(C);
603
+ const rows = turnNs.map(n => ({n, ...tmix(n)}));
604
+ const sum = k => rows.reduce((a, x) => a + x[k], 0);
605
+ 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.
606
+ 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">
607
+ <div><h3>Sent per turn</h3>${key(STEPS.map(x => [x[1], x[2]]))}
559
608
  ${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>`;
609
+ {v: x.setup, col: COL.setup, tip: `fixed setup: ${K(x.setup)}`}, {v: x.history, col: COL.reread, tip: `earlier conversation: ${K(x.history)}`}, {v: x.new, col: COL.new, tip: `new: ${K(x.new)}`}],
610
+ 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%'}])}
611
+ ${nums(tbl(['Turn', '0 · Setup', '1 · Conversation', '2 · New', 'Sent', '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.written)]),
612
+ ['Total', K(sum('setup')), K(sum('history')), K(sum('new')), K(I), K(O)]))}</div>
613
+ <div><h3>What was sent, in three steps</h3>${key(STEPS.map(x => [x[1], x[2]]))}
614
+ ${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)},
615
+ ...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)}))])}
616
+ <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
617
  },
570
618
  calls(){
571
619
  const main = C.filter(c => c.agent === 'main'), helpers = D.helpers.filter(h => h.calls);
@@ -617,7 +665,7 @@ const DRILL = {
617
665
  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
666
  ['Total', '', K(SAVE_T), pct(SAVE_T, T.sent), $$(SAVE)])) :
619
667
  '<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>`;
668
+ <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
669
  },
622
670
  };
623
671
  let openDrill = null;
@@ -660,13 +708,13 @@ if (!reduce && 'IntersectionObserver' in window){
660
708
  /* ---------- turns + drill-down ---------- */
661
709
  const maxTurn = Math.max(...D.turns.map(t => t.sent + t.helper_sent), 1);
662
710
  /* 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)}; };
711
+ const tmix = n => { const cs = ofTurn(n), s = k => cs.reduce((a, c) => a + (c.sent[k] || 0), 0);
712
+ return {setup: s('setup'), history: s('history'), new: s('new'), written: tout(cs)}; };
665
713
  const FIXBY = {}; D.fixes.forEach(f => FIXBY[f.id] = f);
666
714
  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>';
715
+ const tot = mix.setup + mix.history + mix.new; if (!tot) return '<div class="stack" style="width:2%"></div>';
716
+ return `<div class="stack" style="width:${Math.max(2, scale * 100)}%" title="sent ${K(tot)}: fixed setup ${K(mix.setup)} · earlier conversation ${K(mix.history)} · new ${K(mix.new)}">` +
717
+ [['s-setup', mix.setup], ['s-reread', mix.history], ['s-new', mix.new]].map(([c, v]) => v > 0 ? `<span class="${c}" style="width:${v / tot * 100}%"></span>` : '').join('') + '</div>';
670
718
  }
671
719
  function turnRow(t, i){
672
720
  const cost = t.usd + t.helper_usd, sent = t.sent + t.helper_sent;
@@ -675,7 +723,7 @@ function turnRow(t, i){
675
723
  return `<div class="turn"><button type="button" aria-expanded="false" data-turn="${i}">
676
724
  <span class="no">${t.n ? '#' + t.n : '·'}</span>
677
725
  <span><span class="ask"><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>${esc(t.text)}</span>
678
- <span class="meta"><span>${t.calls.length} call${t.calls.length === 1 ? '' : 's'}</span><span>${K(t.written)} written</span><span>${$$(cost)}</span><span>${mins(t.minutes)} working</span>${kind ? `<span>${kind}</span>` : ''}${t.waited_min >= 0.1 ? `<span>you replied after ${mins(t.waited_min)}</span>` : ''}</span>
726
+ <span class="meta"><span>${t.calls.length} call${t.calls.length === 1 ? '' : 's'}</span>${(m => `<span>${K(m.setup)} setup · ${K(m.history)} conversation · ${K(m.new)} new</span>`)(tmix(t.n))}<span>${K(t.written)} written</span><span>${$$(cost)}</span><span>${mins(t.minutes)} working</span>${kind ? `<span>${kind}</span>` : ''}${t.waited_min >= 0.1 ? `<span>you replied after ${mins(t.waited_min)}</span>` : ''}</span>
679
727
  ${chips ? `<span class="events" style="margin-top:4px">${chips}</span>` : ''}</span>
680
728
  <span class="mini">${stack(tmix(t.n), sent / maxTurn)}</span>
681
729
  <span class="cost num">${K(sent)}<small>sent · ${pct(sent, T.sent)} of all</small></span>
@@ -876,12 +924,38 @@ $('fixes').innerHTML = D.fixes.length ? D.fixes.map(f => `<div class="fix"><div
876
924
  ${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
925
  : '<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
926
 
927
+ /* ---------- deeper fixes: preview what would be shared; nothing is sent yet ---------- */
928
+ (function deeper(){
929
+ const SUM = D.summary, btn = $('deeper-btn'), box = $('deeper-box');
930
+ if (!SUM){ $('deeper').hidden = true; return; }
931
+ const json = JSON.stringify(SUM, null, 2), kb = Math.max(1, Math.round(json.length / 1024));
932
+ const steps = SUM.calls.reduce((a, c) => a + c.steps.length, 0);
933
+ const codes = new Set(SUM.calls.flatMap(c => c.steps.map(s => s.target).filter(Boolean)));
934
+ 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>
935
+ <b>What the summary holds</b>
936
+ <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>
937
+ <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>
938
+ <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>
939
+ <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>
940
+ <b>What it never holds</b>
941
+ <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>
942
+ <details><summary>See the exact summary (${kb} KB)</summary><pre id="deeper-json"></pre>
943
+ <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>`;
944
+ $('deeper-json').textContent = json;
945
+ $('deeper-copy').onclick = async () => {
946
+ try { await navigator.clipboard.writeText(json); $('deeper-copied').textContent = 'Copied.'; }
947
+ catch (e) { $('deeper-copied').textContent = 'Could not copy here: select the text above instead.'; }
948
+ };
949
+ btn.onclick = () => { const open = box.hidden; box.hidden = !open; btn.setAttribute('aria-expanded', String(open));
950
+ btn.textContent = open ? 'Hide' : 'Find deeper fixes'; if (open) box.scrollIntoView({behavior: reduce ? 'auto' : 'smooth', block: 'nearest'}); };
951
+ })();
952
+
879
953
  /* ---------- setup ---------- */
880
954
  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).';
955
+ if (S.measured && S.tokens){
956
+ $('setup-total').textContent = K(S.tokens) + ' tokens';
957
+ $('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.` +
958
+ (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
959
  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
960
  $('tools-sum').textContent = `Each tool definition (${S.tools.length}) and whether this session used it`;
887
961
  $('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('');
@@ -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.0
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
@@ -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
File without changes