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