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.
- {sessioncost-0.2.2/sessioncost.egg-info → sessioncost-0.3.0}/PKG-INFO +1 -1
- {sessioncost-0.2.2 → sessioncost-0.3.0}/pyproject.toml +1 -1
- {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost/__init__.py +1 -1
- {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost/analyse.py +34 -10
- {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost/report_template.html +114 -40
- sessioncost-0.3.0/sessioncost/summary.py +157 -0
- {sessioncost-0.2.2 → sessioncost-0.3.0/sessioncost.egg-info}/PKG-INFO +1 -1
- {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost.egg-info/SOURCES.txt +1 -0
- {sessioncost-0.2.2 → sessioncost-0.3.0}/LICENSE +0 -0
- {sessioncost-0.2.2 → sessioncost-0.3.0}/MANIFEST.in +0 -0
- {sessioncost-0.2.2 → sessioncost-0.3.0}/README.md +0 -0
- {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost/__main__.py +0 -0
- {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost/cli.py +0 -0
- {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost/fixes.py +0 -0
- {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost/locate.py +0 -0
- {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost/model.py +0 -0
- {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost/prices.json +0 -0
- {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost/prices.py +0 -0
- {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost/readers/__init__.py +0 -0
- {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost/readers/_pb.py +0 -0
- {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost/readers/antigravity.py +0 -0
- {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost/readers/claude.py +0 -0
- {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost/readers/codex.py +0 -0
- {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost/readers/cursor.py +0 -0
- {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost/report.py +0 -0
- {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost/skill/SKILL.md +0 -0
- {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost.egg-info/dependency_links.txt +0 -0
- {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost.egg-info/entry_points.txt +0 -0
- {sessioncost-0.2.2 → sessioncost-0.3.0}/sessioncost.egg-info/top_level.txt +0 -0
- {sessioncost-0.2.2 → sessioncost-0.3.0}/setup.cfg +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "sessioncost"
|
|
7
|
-
version = "0.
|
|
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
|
+
__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 (
|
|
132
|
-
"usd": (s_cr * cr_rate + s_new * new_rate) / 1e6, "estimated":
|
|
133
|
-
parts.append({"what": "
|
|
134
|
-
"estimated":
|
|
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
|
|
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
|
-
|
|
286
|
-
|
|
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:
|
|
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-
|
|
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
|
|
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
|
|
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(
|
|
435
|
-
|
|
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
|
|
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">
|
|
479
|
-
<div class="split">${
|
|
480
|
-
<div class="split-key">${
|
|
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> ${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
|
|
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
|
|
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 =
|
|
554
|
-
const
|
|
555
|
-
const
|
|
556
|
-
return `<p class="lede"><b>${K(I)}</b> tokens sent to the model
|
|
557
|
-
|
|
558
|
-
<div><h3>Sent per turn</h3>${key([[
|
|
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.
|
|
561
|
-
value: K(x.
|
|
562
|
-
${nums(tbl(['Turn', '
|
|
563
|
-
['Total', K(
|
|
564
|
-
<div><h3>What
|
|
565
|
-
${chart([{label: '
|
|
566
|
-
|
|
567
|
-
|
|
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
|
|
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),
|
|
664
|
-
return {
|
|
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.
|
|
668
|
-
return `<div class="stack" style="width:${Math.max(2, scale * 100)}%" title="sent ${K(tot)}:
|
|
669
|
-
[['s-
|
|
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
|
|
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.
|
|
882
|
-
$('setup-total').textContent =
|
|
883
|
-
$('setup-lede').innerHTML = `
|
|
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.` : '') + '
|
|
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
|
+
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|