scrollback 0.1.2__tar.gz → 0.2.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.
- {scrollback-0.1.2 → scrollback-0.2.0}/CHANGELOG.md +22 -1
- {scrollback-0.1.2 → scrollback-0.2.0}/PKG-INFO +13 -3
- {scrollback-0.1.2 → scrollback-0.2.0}/README.md +12 -2
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/__init__.py +1 -1
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/cli.py +11 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/export.py +35 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/models.py +8 -1
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/serialize.py +3 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/sources/claudecode.py +37 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/sources/codex.py +65 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/sources/opencode.py +18 -9
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/store.py +15 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/app.js +3 -1
- {scrollback-0.1.2 → scrollback-0.2.0}/tests/test_stats_resume.py +4 -0
- scrollback-0.2.0/tests/test_usage.py +266 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/.github/workflows/ci.yml +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/.github/workflows/publish.yml +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/.gitignore +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/CONTRIBUTING.md +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/LICENSE +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/ROADMAP.md +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/assets/icon-512.png +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/assets/icon.icns +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/assets/icon.svg +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/assets/screenshots/cli.png +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/assets/screenshots/cli.svg +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/assets/screenshots/web.png +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/pyproject.toml +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/scripts/demo_data.py +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/scripts/screenshots.py +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/assets/icon-256.png +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/assets/icon.icns +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/clipboard.py +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/fts.py +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/highlight.py +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/katexbundle.py +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/launcher_install.py +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/launchers/scrollback.bat +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/launchers/scrollback.command +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/launchers/scrollback.desktop +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/launchers/scrollback.sh +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/mathspan.py +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/minimd.py +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/serverconfig.py +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/sources/__init__.py +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/sources/aider.py +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/sources/base.py +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/sources/registry.py +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/termrender.py +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/__init__.py +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/app.py +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/apple-touch-icon.png +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/favicon.png +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/favicon.svg +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/index.html +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/style.css +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/highlight.min.js +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/hljs-dark.min.css +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/hljs-light.min.css +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/katex.min.css +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/katex.min.js +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/marked.min.js +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/purify.min.js +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/webopen.py +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/tests/test_aider.py +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/tests/test_claude_paging.py +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/tests/test_claude_subagents.py +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/tests/test_cli_helpers.py +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/tests/test_codex.py +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/tests/test_export.py +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/tests/test_fts.py +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/tests/test_highlight.py +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/tests/test_launcher_install.py +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/tests/test_minimd.py +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/tests/test_models.py +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/tests/test_serverconfig.py +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/tests/test_sources_live.py +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/tests/test_store_filters.py +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/tests/test_web_api.py +0 -0
- {scrollback-0.1.2 → scrollback-0.2.0}/tests/test_webopen.py +0 -0
|
@@ -6,6 +6,26 @@ follow [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.2.0] - 2026-06-30
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **Cache & reasoning token accounting.** Sessions now carry
|
|
14
|
+
`tokens_cache_read`, `tokens_cache_write`, and `tokens_reasoning` in
|
|
15
|
+
addition to input/output/cost. In agentic sessions cache reads often
|
|
16
|
+
dominate total token volume, so this makes scrollback's usage numbers
|
|
17
|
+
reconcilable with the agents' own reports:
|
|
18
|
+
- **opencode** reads the corresponding SQLite columns (tolerant of older
|
|
19
|
+
databases that lack them).
|
|
20
|
+
- **Claude Code** now reports usage at all — summed per-turn from each
|
|
21
|
+
assistant message's `usage` block (previously blank).
|
|
22
|
+
- **Codex** parses token-count records where the rollout format includes
|
|
23
|
+
them (best-effort; `None` when absent).
|
|
24
|
+
- **Aider** has no token data on disk and stays `None`.
|
|
25
|
+
- `stats` shows a `cache` (read/write) line and a `reasoning` line; the web
|
|
26
|
+
transcript header shows a cache figure; Markdown/HTML/JSON exports include
|
|
27
|
+
a usage summary.
|
|
28
|
+
|
|
9
29
|
## [0.1.2] - 2026-06-30
|
|
10
30
|
|
|
11
31
|
### Added
|
|
@@ -100,7 +120,8 @@ export it from a CLI and a local web app.
|
|
|
100
120
|
- Negative pagination arguments are rejected; clearer errors for unknown
|
|
101
121
|
sources, failed exports, and unavailable data sources.
|
|
102
122
|
|
|
103
|
-
[Unreleased]: https://github.com/a-attia/scrollback/compare/v0.
|
|
123
|
+
[Unreleased]: https://github.com/a-attia/scrollback/compare/v0.2.0...HEAD
|
|
124
|
+
[0.2.0]: https://github.com/a-attia/scrollback/compare/v0.1.2...v0.2.0
|
|
104
125
|
[0.1.2]: https://github.com/a-attia/scrollback/compare/v0.1.1...v0.1.2
|
|
105
126
|
[0.1.1]: https://github.com/a-attia/scrollback/compare/v0.1.0...v0.1.1
|
|
106
127
|
[0.1.0]: https://github.com/a-attia/scrollback/releases/tag/v0.1.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scrollback
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Navigate, search, copy, and export your AI coding-agent sessions (opencode, Claude Code, ...) from one local, read-only tool.
|
|
5
5
|
Project-URL: Homepage, https://github.com/a-attia/scrollback
|
|
6
6
|
Project-URL: Repository, https://github.com/a-attia/scrollback
|
|
@@ -73,13 +73,13 @@ modifies, locks for writing, or uploads your data.
|
|
|
73
73
|
You can use it two ways. From the **command line**, list, search, and export
|
|
74
74
|
your sessions in a single scriptable tool:
|
|
75
75
|
|
|
76
|
-

|
|
77
77
|
|
|
78
78
|
Or open the **local web app** to read a transcript in full — with rendered
|
|
79
79
|
Markdown, syntax-highlighted code, and typeset LaTeX math:
|
|
80
80
|
|
|
81
81
|

|
|
83
83
|
|
|
84
84
|
Both views read the same on-disk session stores, so you can jump between
|
|
85
85
|
them freely. (The screenshots above use synthetic demo data.)
|
|
@@ -235,6 +235,16 @@ busiest projects. `resume` prints the command to continue a session in its
|
|
|
235
235
|
own agent (for example `opencode --session <id>` or `claude --resume <id>`),
|
|
236
236
|
with a `cd` into the session's project directory.
|
|
237
237
|
|
|
238
|
+
**A note on token figures.** Where the source records it, scrollback reports
|
|
239
|
+
tokens in four buckets — *input*, *output*, *cache read*, and *cache write* —
|
|
240
|
+
because they mean different things and are priced very differently. In
|
|
241
|
+
agentic sessions the conversation context is re-sent every turn but served
|
|
242
|
+
from the prompt cache, so **cache reads usually dominate total volume** while
|
|
243
|
+
costing a fraction of fresh input. "Total tokens" is therefore not one
|
|
244
|
+
number; the cost figure (when available) is the most faithful summary of
|
|
245
|
+
consumption. Sources that don't record a given figure show it as blank
|
|
246
|
+
rather than a misleading zero.
|
|
247
|
+
|
|
238
248
|
## The web app
|
|
239
249
|
|
|
240
250
|
`scrollback web` starts a local, read-only browser UI — FastAPI plus a
|
|
@@ -14,13 +14,13 @@ modifies, locks for writing, or uploads your data.
|
|
|
14
14
|
You can use it two ways. From the **command line**, list, search, and export
|
|
15
15
|
your sessions in a single scriptable tool:
|
|
16
16
|
|
|
17
|
-

|
|
18
18
|
|
|
19
19
|
Or open the **local web app** to read a transcript in full — with rendered
|
|
20
20
|
Markdown, syntax-highlighted code, and typeset LaTeX math:
|
|
21
21
|
|
|
22
22
|

|
|
24
24
|
|
|
25
25
|
Both views read the same on-disk session stores, so you can jump between
|
|
26
26
|
them freely. (The screenshots above use synthetic demo data.)
|
|
@@ -176,6 +176,16 @@ busiest projects. `resume` prints the command to continue a session in its
|
|
|
176
176
|
own agent (for example `opencode --session <id>` or `claude --resume <id>`),
|
|
177
177
|
with a `cd` into the session's project directory.
|
|
178
178
|
|
|
179
|
+
**A note on token figures.** Where the source records it, scrollback reports
|
|
180
|
+
tokens in four buckets — *input*, *output*, *cache read*, and *cache write* —
|
|
181
|
+
because they mean different things and are priced very differently. In
|
|
182
|
+
agentic sessions the conversation context is re-sent every turn but served
|
|
183
|
+
from the prompt cache, so **cache reads usually dominate total volume** while
|
|
184
|
+
costing a fraction of fresh input. "Total tokens" is therefore not one
|
|
185
|
+
number; the cost figure (when available) is the most faithful summary of
|
|
186
|
+
consumption. Sources that don't record a given figure show it as blank
|
|
187
|
+
rather than a misleading zero.
|
|
188
|
+
|
|
179
189
|
## The web app
|
|
180
190
|
|
|
181
191
|
`scrollback web` starts a local, read-only browser UI — FastAPI plus a
|
|
@@ -211,6 +211,9 @@ def cmd_stats(args: argparse.Namespace) -> int:
|
|
|
211
211
|
"total_messages": st.total_messages,
|
|
212
212
|
"total_tokens_input": st.total_tokens_input,
|
|
213
213
|
"total_tokens_output": st.total_tokens_output,
|
|
214
|
+
"total_tokens_cache_read": st.total_tokens_cache_read,
|
|
215
|
+
"total_tokens_cache_write": st.total_tokens_cache_write,
|
|
216
|
+
"total_tokens_reasoning": st.total_tokens_reasoning,
|
|
214
217
|
"total_cost": st.total_cost,
|
|
215
218
|
"oldest": st.oldest.isoformat() if st.oldest else None,
|
|
216
219
|
"newest": st.newest.isoformat() if st.newest else None,
|
|
@@ -228,6 +231,11 @@ def cmd_stats(args: argparse.Namespace) -> int:
|
|
|
228
231
|
if st.total_tokens_input or st.total_tokens_output:
|
|
229
232
|
print(f"tokens: {_fmt_tokens(st.total_tokens_input)} in / "
|
|
230
233
|
f"{_fmt_tokens(st.total_tokens_output)} out")
|
|
234
|
+
if st.total_tokens_cache_read or st.total_tokens_cache_write:
|
|
235
|
+
print(f"cache: {_fmt_tokens(st.total_tokens_cache_read)} read / "
|
|
236
|
+
f"{_fmt_tokens(st.total_tokens_cache_write)} write")
|
|
237
|
+
if st.total_tokens_reasoning:
|
|
238
|
+
print(f"reasoning:{_fmt_tokens(st.total_tokens_reasoning)} tokens")
|
|
231
239
|
if st.total_cost:
|
|
232
240
|
print(f"cost: ${st.total_cost:.2f}")
|
|
233
241
|
print()
|
|
@@ -339,6 +347,9 @@ def cmd_list(args: argparse.Namespace) -> int:
|
|
|
339
347
|
"cost": s.cost,
|
|
340
348
|
"tokens_input": s.tokens_input,
|
|
341
349
|
"tokens_output": s.tokens_output,
|
|
350
|
+
"tokens_cache_read": s.tokens_cache_read,
|
|
351
|
+
"tokens_cache_write": s.tokens_cache_write,
|
|
352
|
+
"tokens_reasoning": s.tokens_reasoning,
|
|
342
353
|
"parent_id": s.parent_id,
|
|
343
354
|
"children": [row(c) for c in s.children],
|
|
344
355
|
}
|
|
@@ -26,6 +26,35 @@ def _fmt_dt(dt: datetime | None) -> str:
|
|
|
26
26
|
return dt.strftime("%Y-%m-%d %H:%M:%S %Z").strip() if dt else "?"
|
|
27
27
|
|
|
28
28
|
|
|
29
|
+
def _fmt_tokens(n: int | None) -> str:
|
|
30
|
+
if not n:
|
|
31
|
+
return "0"
|
|
32
|
+
if n < 1000:
|
|
33
|
+
return str(n)
|
|
34
|
+
if n < 1_000_000:
|
|
35
|
+
return f"{n / 1e3:.1f}k"
|
|
36
|
+
return f"{n / 1e6:.1f}M"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _usage_summary(session: Session) -> str:
|
|
40
|
+
"""A compact one-line usage string, or '' when the source reports none."""
|
|
41
|
+
s = session
|
|
42
|
+
if not any((s.tokens_input, s.tokens_output, s.tokens_cache_read,
|
|
43
|
+
s.tokens_cache_write, s.tokens_reasoning, s.cost)):
|
|
44
|
+
return ""
|
|
45
|
+
bits = []
|
|
46
|
+
if s.tokens_input or s.tokens_output:
|
|
47
|
+
bits.append(f"{_fmt_tokens(s.tokens_input)} in / {_fmt_tokens(s.tokens_output)} out")
|
|
48
|
+
if s.tokens_cache_read or s.tokens_cache_write:
|
|
49
|
+
bits.append(f"cache {_fmt_tokens(s.tokens_cache_read)} read / "
|
|
50
|
+
f"{_fmt_tokens(s.tokens_cache_write)} write")
|
|
51
|
+
if s.tokens_reasoning:
|
|
52
|
+
bits.append(f"{_fmt_tokens(s.tokens_reasoning)} reasoning")
|
|
53
|
+
if s.cost:
|
|
54
|
+
bits.append(f"${s.cost:.2f}")
|
|
55
|
+
return "; ".join(bits)
|
|
56
|
+
|
|
57
|
+
|
|
29
58
|
# -- markdown --------------------------------------------------------------
|
|
30
59
|
|
|
31
60
|
|
|
@@ -50,6 +79,9 @@ def to_markdown(session: Session, *, include_reasoning: bool = True,
|
|
|
50
79
|
lines.append(f"- **Created**: {_fmt_dt(session.created)}")
|
|
51
80
|
lines.append(f"- **Updated**: {_fmt_dt(session.updated)}")
|
|
52
81
|
lines.append(f"- **Messages**: {len(session.messages)}")
|
|
82
|
+
usage = _usage_summary(session)
|
|
83
|
+
if usage:
|
|
84
|
+
lines.append(f"- **Usage**: {usage}")
|
|
53
85
|
lines.append("")
|
|
54
86
|
lines.append("---")
|
|
55
87
|
lines.append("")
|
|
@@ -211,6 +243,9 @@ def to_html(session: Session, *, include_reasoning: bool = True,
|
|
|
211
243
|
meta_bits.append(f"model: {session.model}")
|
|
212
244
|
meta_bits.append(f"created: {_fmt_dt(session.created)}")
|
|
213
245
|
meta_bits.append(f"messages: {len(session.messages)}")
|
|
246
|
+
usage = _usage_summary(session)
|
|
247
|
+
if usage:
|
|
248
|
+
meta_bits.append(f"usage: {usage}")
|
|
214
249
|
meta = " · ".join(_html.escape(b) for b in meta_bits)
|
|
215
250
|
|
|
216
251
|
body_parts: list[str] = []
|
|
@@ -111,10 +111,17 @@ class Session:
|
|
|
111
111
|
agent: str | None = None
|
|
112
112
|
parent_id: str | None = None
|
|
113
113
|
message_count: int | None = None
|
|
114
|
-
# Usage accounting (
|
|
114
|
+
# Usage accounting (None when the source does not report a given figure).
|
|
115
|
+
# `tokens_input` / `tokens_output` are fresh (uncached) prompt + generated
|
|
116
|
+
# tokens; the cache figures track prompt-cache reuse (large in agentic
|
|
117
|
+
# sessions and priced very differently); `tokens_reasoning` is the portion
|
|
118
|
+
# of output spent on hidden reasoning, where the source distinguishes it.
|
|
115
119
|
cost: float | None = None
|
|
116
120
|
tokens_input: int | None = None
|
|
117
121
|
tokens_output: int | None = None
|
|
122
|
+
tokens_cache_read: int | None = None
|
|
123
|
+
tokens_cache_write: int | None = None
|
|
124
|
+
tokens_reasoning: int | None = None
|
|
118
125
|
# Children populated when subagent folding is enabled.
|
|
119
126
|
children: tuple["Session", ...] = ()
|
|
120
127
|
messages: tuple[Message, ...] = ()
|
|
@@ -36,6 +36,9 @@ def session_summary(s: Session) -> dict[str, Any]:
|
|
|
36
36
|
"cost": s.cost,
|
|
37
37
|
"tokens_input": s.tokens_input,
|
|
38
38
|
"tokens_output": s.tokens_output,
|
|
39
|
+
"tokens_cache_read": s.tokens_cache_read,
|
|
40
|
+
"tokens_cache_write": s.tokens_cache_write,
|
|
41
|
+
"tokens_reasoning": s.tokens_reasoning,
|
|
39
42
|
"git_branch": (s.raw or {}).get("git_branch"),
|
|
40
43
|
"children": [session_summary(c) for c in s.children],
|
|
41
44
|
}
|
|
@@ -228,6 +228,7 @@ class ClaudeCodeSource(Source):
|
|
|
228
228
|
message_count=meta["msg_count"],
|
|
229
229
|
children=children,
|
|
230
230
|
raw={"path": str(f), "git_branch": meta["git_branch"]},
|
|
231
|
+
**_usage_from_meta(meta),
|
|
231
232
|
)
|
|
232
233
|
|
|
233
234
|
def _subagent_summary(self, parent_path: Path, sub_path: Path) -> Session:
|
|
@@ -252,6 +253,7 @@ class ClaudeCodeSource(Source):
|
|
|
252
253
|
parent_id=parent_id,
|
|
253
254
|
message_count=(sm or {}).get("msg_count", 0),
|
|
254
255
|
raw={"path": str(sub_path)},
|
|
256
|
+
**_usage_from_meta(sm or {}),
|
|
255
257
|
)
|
|
256
258
|
|
|
257
259
|
# -- single session -----------------------------------------------------
|
|
@@ -338,6 +340,7 @@ class ClaudeCodeSource(Source):
|
|
|
338
340
|
message_count=meta["msg_count"],
|
|
339
341
|
messages=(),
|
|
340
342
|
raw={"path": str(path), "git_branch": meta["git_branch"]},
|
|
343
|
+
**_usage_from_meta(meta),
|
|
341
344
|
)
|
|
342
345
|
|
|
343
346
|
def load_messages(
|
|
@@ -411,6 +414,10 @@ def _scan_metadata(path: Path) -> dict[str, Any] | None:
|
|
|
411
414
|
first_user_text: str | None = None
|
|
412
415
|
msg_count = 0
|
|
413
416
|
seen = False
|
|
417
|
+
# Usage accumulators. Claude Code records per-turn `usage` on assistant
|
|
418
|
+
# messages; summing across turns matches the agent's own session totals.
|
|
419
|
+
tok_in = tok_out = tok_cache_read = tok_cache_write = 0
|
|
420
|
+
any_usage = False
|
|
414
421
|
|
|
415
422
|
for obj in _iter_lines(path):
|
|
416
423
|
seen = True
|
|
@@ -439,6 +446,14 @@ def _scan_metadata(path: Path) -> dict[str, Any] | None:
|
|
|
439
446
|
mv = m.get("model")
|
|
440
447
|
if mv and mv != "<synthetic>":
|
|
441
448
|
model = mv
|
|
449
|
+
if isinstance(m, dict):
|
|
450
|
+
u = m.get("usage")
|
|
451
|
+
if isinstance(u, dict):
|
|
452
|
+
any_usage = True
|
|
453
|
+
tok_in += _int(u.get("input_tokens"))
|
|
454
|
+
tok_out += _int(u.get("output_tokens"))
|
|
455
|
+
tok_cache_read += _int(u.get("cache_read_input_tokens"))
|
|
456
|
+
tok_cache_write += _int(u.get("cache_creation_input_tokens"))
|
|
442
457
|
if (
|
|
443
458
|
first_user_text is None
|
|
444
459
|
and t == "user"
|
|
@@ -458,6 +473,27 @@ def _scan_metadata(path: Path) -> dict[str, Any] | None:
|
|
|
458
473
|
"first_ts": first_ts,
|
|
459
474
|
"last_ts": last_ts,
|
|
460
475
|
"msg_count": msg_count,
|
|
476
|
+
# Only report usage when the transcript actually carried any, so
|
|
477
|
+
# sessions with no usage records stay None (not a misleading 0).
|
|
478
|
+
"tokens_input": tok_in if any_usage else None,
|
|
479
|
+
"tokens_output": tok_out if any_usage else None,
|
|
480
|
+
"tokens_cache_read": tok_cache_read if any_usage else None,
|
|
481
|
+
"tokens_cache_write": tok_cache_write if any_usage else None,
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _int(v: Any) -> int:
|
|
486
|
+
"""Coerce a usage value to int, treating missing/garbage as 0."""
|
|
487
|
+
return v if isinstance(v, int) else 0
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _usage_from_meta(meta: dict[str, Any]) -> dict[str, Any]:
|
|
491
|
+
"""Pull the usage fields out of a scan-metadata dict (all may be None)."""
|
|
492
|
+
return {
|
|
493
|
+
"tokens_input": meta.get("tokens_input"),
|
|
494
|
+
"tokens_output": meta.get("tokens_output"),
|
|
495
|
+
"tokens_cache_read": meta.get("tokens_cache_read"),
|
|
496
|
+
"tokens_cache_write": meta.get("tokens_cache_write"),
|
|
461
497
|
}
|
|
462
498
|
|
|
463
499
|
|
|
@@ -560,6 +596,7 @@ def _parse_session(
|
|
|
560
596
|
message_count=len(messages),
|
|
561
597
|
messages=tuple(messages),
|
|
562
598
|
raw={"path": str(path), "git_branch": meta["git_branch"]},
|
|
599
|
+
**_usage_from_meta(meta),
|
|
563
600
|
)
|
|
564
601
|
|
|
565
602
|
|
|
@@ -118,6 +118,7 @@ class CodexSource(Source):
|
|
|
118
118
|
model=meta["model"],
|
|
119
119
|
message_count=meta["msg_count"],
|
|
120
120
|
raw={"path": str(f)},
|
|
121
|
+
**_usage_from_meta(meta),
|
|
121
122
|
)
|
|
122
123
|
|
|
123
124
|
# -- single session -----------------------------------------------------
|
|
@@ -141,12 +142,23 @@ class CodexSource(Source):
|
|
|
141
142
|
message_count=len(messages),
|
|
142
143
|
messages=tuple(messages),
|
|
143
144
|
raw={"path": str(path)},
|
|
145
|
+
**_usage_from_meta(meta),
|
|
144
146
|
)
|
|
145
147
|
|
|
146
148
|
|
|
147
149
|
# -- parsing helpers -------------------------------------------------------
|
|
148
150
|
|
|
149
151
|
|
|
152
|
+
def _usage_from_meta(meta: dict[str, Any]) -> dict[str, Any]:
|
|
153
|
+
"""Pull usage fields out of a scan-metadata dict (all may be None)."""
|
|
154
|
+
return {
|
|
155
|
+
"tokens_input": meta.get("tokens_input"),
|
|
156
|
+
"tokens_output": meta.get("tokens_output"),
|
|
157
|
+
"tokens_cache_read": meta.get("tokens_cache_read"),
|
|
158
|
+
"tokens_cache_write": meta.get("tokens_cache_write"),
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
150
162
|
def _iter_lines(path: Path) -> Iterator[dict[str, Any]]:
|
|
151
163
|
try:
|
|
152
164
|
with path.open("r", encoding="utf-8", errors="replace") as fh:
|
|
@@ -198,6 +210,7 @@ def _scan_meta(path: Path) -> dict[str, Any] | None:
|
|
|
198
210
|
last_ts: str | int | None = None
|
|
199
211
|
msg_count = 0
|
|
200
212
|
seen = False
|
|
213
|
+
usage: dict[str, int] | None = None
|
|
201
214
|
|
|
202
215
|
for obj in _iter_lines(path):
|
|
203
216
|
seen = True
|
|
@@ -219,6 +232,12 @@ def _scan_meta(path: Path) -> dict[str, Any] | None:
|
|
|
219
232
|
txt = _record_text(p).strip()
|
|
220
233
|
if txt and not txt.startswith("<"):
|
|
221
234
|
title = " ".join(txt.split())[:60]
|
|
235
|
+
# Best-effort usage: Codex emits token counts on some record types
|
|
236
|
+
# (e.g. a `token_count` event). Take the LAST cumulative reading we
|
|
237
|
+
# see. Absent in older/other formats -> usage stays None.
|
|
238
|
+
latest = _token_usage(obj, p)
|
|
239
|
+
if latest is not None:
|
|
240
|
+
usage = latest
|
|
222
241
|
|
|
223
242
|
if not seen:
|
|
224
243
|
return None
|
|
@@ -231,6 +250,52 @@ def _scan_meta(path: Path) -> dict[str, Any] | None:
|
|
|
231
250
|
"first_ts": first_ts,
|
|
232
251
|
"last_ts": last_ts,
|
|
233
252
|
"msg_count": msg_count,
|
|
253
|
+
"tokens_input": (usage or {}).get("input"),
|
|
254
|
+
"tokens_output": (usage or {}).get("output"),
|
|
255
|
+
"tokens_cache_read": (usage or {}).get("cache_read"),
|
|
256
|
+
"tokens_cache_write": (usage or {}).get("cache_write"),
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _token_usage(obj: dict[str, Any], payload: dict[str, Any]) -> dict[str, int] | None:
|
|
261
|
+
"""Best-effort extraction of a token-usage reading from a Codex record.
|
|
262
|
+
|
|
263
|
+
Codex's rollout format has changed across versions; this looks for a
|
|
264
|
+
`token_count` / `usage` object in a few known shapes and returns a
|
|
265
|
+
normalized {input, output, cache_read, cache_write} dict, or None. It is
|
|
266
|
+
intentionally forgiving: unknown shapes yield None rather than raising.
|
|
267
|
+
"""
|
|
268
|
+
src = None
|
|
269
|
+
# A record whose own type is token_count/usage carries the fields inline.
|
|
270
|
+
if payload.get("type") in ("token_count", "usage"):
|
|
271
|
+
src = payload
|
|
272
|
+
else:
|
|
273
|
+
for cand in (payload.get("token_count"), payload.get("usage"),
|
|
274
|
+
obj.get("token_count"), obj.get("usage")):
|
|
275
|
+
if isinstance(cand, dict):
|
|
276
|
+
src = cand
|
|
277
|
+
break
|
|
278
|
+
if src is None:
|
|
279
|
+
return None
|
|
280
|
+
# Some versions nest the counts under an "info"/"total"/"last" object.
|
|
281
|
+
for nest in ("info", "total", "last"):
|
|
282
|
+
if isinstance(src.get(nest), dict):
|
|
283
|
+
src = src[nest]
|
|
284
|
+
break
|
|
285
|
+
|
|
286
|
+
def g(*keys: str) -> int:
|
|
287
|
+
for k in keys:
|
|
288
|
+
v = src.get(k)
|
|
289
|
+
if isinstance(v, int):
|
|
290
|
+
return v
|
|
291
|
+
return 0
|
|
292
|
+
|
|
293
|
+
read = g("cached_input_tokens", "cache_read_input_tokens", "cache_read")
|
|
294
|
+
return {
|
|
295
|
+
"input": g("input_tokens", "input", "prompt_tokens"),
|
|
296
|
+
"output": g("output_tokens", "output", "completion_tokens"),
|
|
297
|
+
"cache_read": read,
|
|
298
|
+
"cache_write": g("cache_creation_input_tokens", "cache_write"),
|
|
234
299
|
}
|
|
235
300
|
|
|
236
301
|
|
|
@@ -80,12 +80,13 @@ class OpenCodeSource(Source):
|
|
|
80
80
|
return self._list_sessions()
|
|
81
81
|
|
|
82
82
|
def _list_sessions(self) -> Iterator[Session]:
|
|
83
|
+
# `SELECT s.*` + `_col` keeps us tolerant of schema drift: newer usage
|
|
84
|
+
# columns (cache/reasoning) are read when present and default to None
|
|
85
|
+
# on older opencode databases that lack them.
|
|
83
86
|
with self._connect() as conn:
|
|
84
87
|
rows = conn.execute(
|
|
85
88
|
"""
|
|
86
|
-
SELECT s
|
|
87
|
-
s.time_updated, s.model, s.agent, s.parent_id,
|
|
88
|
-
s.cost, s.tokens_input, s.tokens_output,
|
|
89
|
+
SELECT s.*,
|
|
89
90
|
(SELECT COUNT(*) FROM message m WHERE m.session_id = s.id)
|
|
90
91
|
AS msg_count
|
|
91
92
|
FROM session s
|
|
@@ -104,9 +105,7 @@ class OpenCodeSource(Source):
|
|
|
104
105
|
agent=r["agent"],
|
|
105
106
|
parent_id=r["parent_id"],
|
|
106
107
|
message_count=r["msg_count"],
|
|
107
|
-
|
|
108
|
-
tokens_input=r["tokens_input"],
|
|
109
|
-
tokens_output=r["tokens_output"],
|
|
108
|
+
**_usage_from_row(r),
|
|
110
109
|
)
|
|
111
110
|
|
|
112
111
|
# -- single session -----------------------------------------------------
|
|
@@ -175,10 +174,8 @@ class OpenCodeSource(Source):
|
|
|
175
174
|
agent=srow["agent"],
|
|
176
175
|
parent_id=srow["parent_id"],
|
|
177
176
|
message_count=message_count,
|
|
178
|
-
cost=_col(srow, "cost"),
|
|
179
|
-
tokens_input=_col(srow, "tokens_input"),
|
|
180
|
-
tokens_output=_col(srow, "tokens_output"),
|
|
181
177
|
messages=messages,
|
|
178
|
+
**_usage_from_row(srow),
|
|
182
179
|
)
|
|
183
180
|
|
|
184
181
|
# -- windowed loading ---------------------------------------------------
|
|
@@ -271,6 +268,18 @@ def _col(row: sqlite3.Row, key: str) -> Any:
|
|
|
271
268
|
return None
|
|
272
269
|
|
|
273
270
|
|
|
271
|
+
def _usage_from_row(row: sqlite3.Row) -> dict[str, Any]:
|
|
272
|
+
"""Extract the usage fields from a session row (missing columns -> None)."""
|
|
273
|
+
return {
|
|
274
|
+
"cost": _col(row, "cost"),
|
|
275
|
+
"tokens_input": _col(row, "tokens_input"),
|
|
276
|
+
"tokens_output": _col(row, "tokens_output"),
|
|
277
|
+
"tokens_cache_read": _col(row, "tokens_cache_read"),
|
|
278
|
+
"tokens_cache_write": _col(row, "tokens_cache_write"),
|
|
279
|
+
"tokens_reasoning": _col(row, "tokens_reasoning"),
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
|
|
274
283
|
def _loads(s: str | None) -> dict[str, Any]:
|
|
275
284
|
if not s:
|
|
276
285
|
return {}
|
|
@@ -40,6 +40,9 @@ class Stats:
|
|
|
40
40
|
total_messages: int
|
|
41
41
|
total_tokens_input: int
|
|
42
42
|
total_tokens_output: int
|
|
43
|
+
total_tokens_cache_read: int
|
|
44
|
+
total_tokens_cache_write: int
|
|
45
|
+
total_tokens_reasoning: int
|
|
43
46
|
total_cost: float
|
|
44
47
|
oldest: datetime | None
|
|
45
48
|
newest: datetime | None
|
|
@@ -82,6 +85,9 @@ class Store:
|
|
|
82
85
|
total_messages = 0
|
|
83
86
|
total_tokens_in = 0
|
|
84
87
|
total_tokens_out = 0
|
|
88
|
+
total_cache_read = 0
|
|
89
|
+
total_cache_write = 0
|
|
90
|
+
total_reasoning = 0
|
|
85
91
|
total_cost = 0.0
|
|
86
92
|
oldest: datetime | None = None
|
|
87
93
|
newest: datetime | None = None
|
|
@@ -98,6 +104,12 @@ class Store:
|
|
|
98
104
|
total_tokens_in += s.tokens_input
|
|
99
105
|
if s.tokens_output:
|
|
100
106
|
total_tokens_out += s.tokens_output
|
|
107
|
+
if s.tokens_cache_read:
|
|
108
|
+
total_cache_read += s.tokens_cache_read
|
|
109
|
+
if s.tokens_cache_write:
|
|
110
|
+
total_cache_write += s.tokens_cache_write
|
|
111
|
+
if s.tokens_reasoning:
|
|
112
|
+
total_reasoning += s.tokens_reasoning
|
|
101
113
|
if s.cost:
|
|
102
114
|
total_cost += s.cost
|
|
103
115
|
when = s.updated or s.created
|
|
@@ -112,6 +124,9 @@ class Store:
|
|
|
112
124
|
total_messages=total_messages,
|
|
113
125
|
total_tokens_input=total_tokens_in,
|
|
114
126
|
total_tokens_output=total_tokens_out,
|
|
127
|
+
total_tokens_cache_read=total_cache_read,
|
|
128
|
+
total_tokens_cache_write=total_cache_write,
|
|
129
|
+
total_tokens_reasoning=total_reasoning,
|
|
115
130
|
total_cost=total_cost,
|
|
116
131
|
oldest=oldest,
|
|
117
132
|
newest=newest,
|
|
@@ -690,7 +690,9 @@ function renderHeader(meta) {
|
|
|
690
690
|
copyId,
|
|
691
691
|
meta.model ? el("span", {}, "model: " + meta.model) : null,
|
|
692
692
|
meta.git_branch ? el("span", {}, "branch: " + meta.git_branch) : null,
|
|
693
|
-
meta.tokens_input != null ? el("span", {}, `tokens ${fmtTokens(meta.tokens_input)}/${fmtTokens(meta.tokens_output)}`) : null,
|
|
693
|
+
meta.tokens_input != null ? el("span", { title: "input / output tokens" }, `tokens ${fmtTokens(meta.tokens_input)}/${fmtTokens(meta.tokens_output)}`) : null,
|
|
694
|
+
meta.tokens_cache_read != null && (meta.tokens_cache_read || meta.tokens_cache_write)
|
|
695
|
+
? el("span", { title: "prompt cache read / write" }, `cache ${fmtTokens(meta.tokens_cache_read)}/${fmtTokens(meta.tokens_cache_write)}`) : null,
|
|
694
696
|
el("span", {}, fmtDate(meta.created)),
|
|
695
697
|
el("span", {}, `${meta.message_count} messages`),
|
|
696
698
|
meta.directory ? el("span", {}, meta.directory) : null
|
|
@@ -59,6 +59,10 @@ def test_stats_aggregates():
|
|
|
59
59
|
assert st.total_messages == 22
|
|
60
60
|
assert st.total_tokens_input == 220
|
|
61
61
|
assert st.total_tokens_output == 37
|
|
62
|
+
# These sessions carry no cache/reasoning usage, so the new totals are 0.
|
|
63
|
+
assert st.total_tokens_cache_read == 0
|
|
64
|
+
assert st.total_tokens_cache_write == 0
|
|
65
|
+
assert st.total_tokens_reasoning == 0
|
|
62
66
|
assert abs(st.total_cost - 0.75) < 1e-9
|
|
63
67
|
assert st.oldest.day == 1 and st.newest.day == 5
|
|
64
68
|
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Usage/token accounting across adapters, stats, and export.
|
|
2
|
+
|
|
3
|
+
Covers the input/output/cache-read/cache-write/reasoning fields end to end:
|
|
4
|
+
each adapter parses what its format exposes, sessions with no usage stay
|
|
5
|
+
None (not a misleading 0), Store.stats() sums them, and the export/summary
|
|
6
|
+
surfaces render them.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import sqlite3
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from scrollback import export
|
|
14
|
+
from scrollback.models import Message, Part, Session
|
|
15
|
+
from scrollback.sources.claudecode import ClaudeCodeSource
|
|
16
|
+
from scrollback.sources.codex import CodexSource
|
|
17
|
+
from scrollback.sources.opencode import OpenCodeSource
|
|
18
|
+
from scrollback.store import Store
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# -- opencode (SQLite columns) --------------------------------------------
|
|
22
|
+
|
|
23
|
+
def _make_opencode_db(path: Path, *, with_cache_cols: bool) -> None:
|
|
24
|
+
conn = sqlite3.connect(path)
|
|
25
|
+
cache_cols = (
|
|
26
|
+
", tokens_cache_read INTEGER, tokens_cache_write INTEGER, "
|
|
27
|
+
"tokens_reasoning INTEGER"
|
|
28
|
+
if with_cache_cols else ""
|
|
29
|
+
)
|
|
30
|
+
conn.executescript(
|
|
31
|
+
f"""
|
|
32
|
+
CREATE TABLE session (
|
|
33
|
+
id TEXT PRIMARY KEY, project_id TEXT, parent_id TEXT,
|
|
34
|
+
directory TEXT, title TEXT, time_created INTEGER,
|
|
35
|
+
time_updated INTEGER, agent TEXT, model TEXT,
|
|
36
|
+
cost REAL, tokens_input INTEGER, tokens_output INTEGER{cache_cols}
|
|
37
|
+
);
|
|
38
|
+
CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT,
|
|
39
|
+
time_created INTEGER, data TEXT);
|
|
40
|
+
CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT,
|
|
41
|
+
session_id TEXT, time_created INTEGER, data TEXT);
|
|
42
|
+
"""
|
|
43
|
+
)
|
|
44
|
+
if with_cache_cols:
|
|
45
|
+
conn.execute(
|
|
46
|
+
"INSERT INTO session (id, title, time_created, time_updated, cost, "
|
|
47
|
+
"tokens_input, tokens_output, tokens_cache_read, tokens_cache_write, "
|
|
48
|
+
"tokens_reasoning) VALUES (?,?,?,?,?,?,?,?,?,?)",
|
|
49
|
+
("s1", "Heat eqn", 1000, 2000, 0.42, 1500, 3000, 250000, 40000, 800),
|
|
50
|
+
)
|
|
51
|
+
else:
|
|
52
|
+
conn.execute(
|
|
53
|
+
"INSERT INTO session (id, title, time_created, time_updated, cost, "
|
|
54
|
+
"tokens_input, tokens_output) VALUES (?,?,?,?,?,?,?)",
|
|
55
|
+
("s1", "Heat eqn", 1000, 2000, 0.42, 1500, 3000),
|
|
56
|
+
)
|
|
57
|
+
conn.commit()
|
|
58
|
+
conn.close()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_opencode_reads_cache_columns(tmp_path):
|
|
62
|
+
db = tmp_path / "opencode.db"
|
|
63
|
+
_make_opencode_db(db, with_cache_cols=True)
|
|
64
|
+
src = OpenCodeSource(db_path=db)
|
|
65
|
+
|
|
66
|
+
listed = next(iter(src.list_sessions()))
|
|
67
|
+
assert listed.tokens_input == 1500
|
|
68
|
+
assert listed.tokens_output == 3000
|
|
69
|
+
assert listed.tokens_cache_read == 250000
|
|
70
|
+
assert listed.tokens_cache_write == 40000
|
|
71
|
+
assert listed.tokens_reasoning == 800
|
|
72
|
+
|
|
73
|
+
full = src.load_session("s1")
|
|
74
|
+
assert full.tokens_cache_read == 250000
|
|
75
|
+
assert full.tokens_cache_write == 40000
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_opencode_tolerates_db_without_cache_columns(tmp_path):
|
|
79
|
+
# Older opencode DBs lack the cache columns; the adapter must not crash and
|
|
80
|
+
# should report them as None.
|
|
81
|
+
db = tmp_path / "opencode.db"
|
|
82
|
+
_make_opencode_db(db, with_cache_cols=False)
|
|
83
|
+
src = OpenCodeSource(db_path=db)
|
|
84
|
+
|
|
85
|
+
listed = next(iter(src.list_sessions()))
|
|
86
|
+
assert listed.tokens_input == 1500
|
|
87
|
+
assert listed.tokens_cache_read is None
|
|
88
|
+
assert listed.tokens_cache_write is None
|
|
89
|
+
assert listed.tokens_reasoning is None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# -- Claude Code (per-turn usage in JSONL) --------------------------------
|
|
93
|
+
|
|
94
|
+
def _cc_assistant(text, ts, sid, usage=None):
|
|
95
|
+
msg = {"role": "assistant", "model": "claude-x",
|
|
96
|
+
"content": [{"type": "text", "text": text}]}
|
|
97
|
+
if usage is not None:
|
|
98
|
+
msg["usage"] = usage
|
|
99
|
+
return {"type": "assistant", "timestamp": ts, "sessionId": sid, "message": msg}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _cc_user(text, ts, sid):
|
|
103
|
+
return {"type": "user", "timestamp": ts, "sessionId": sid, "cwd": "/proj",
|
|
104
|
+
"message": {"role": "user", "content": text}}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_claudecode_sums_per_turn_usage(tmp_path):
|
|
108
|
+
root = tmp_path / "projects" # adapter root is the `projects/` dir
|
|
109
|
+
sid = "aaaaaaaa-1111-2222-3333-444444444444"
|
|
110
|
+
f = root / "-proj" / f"{sid}.jsonl"
|
|
111
|
+
f.parent.mkdir(parents=True)
|
|
112
|
+
rows = [
|
|
113
|
+
_cc_user("q1", "2026-03-14T09:30:00Z", sid),
|
|
114
|
+
_cc_assistant("a1", "2026-03-14T09:30:05Z", sid, usage={
|
|
115
|
+
"input_tokens": 100, "output_tokens": 200,
|
|
116
|
+
"cache_read_input_tokens": 5000, "cache_creation_input_tokens": 300,
|
|
117
|
+
}),
|
|
118
|
+
_cc_user("q2", "2026-03-14T09:31:00Z", sid),
|
|
119
|
+
_cc_assistant("a2", "2026-03-14T09:31:05Z", sid, usage={
|
|
120
|
+
"input_tokens": 50, "output_tokens": 400,
|
|
121
|
+
"cache_read_input_tokens": 8000, "cache_creation_input_tokens": 100,
|
|
122
|
+
}),
|
|
123
|
+
]
|
|
124
|
+
with f.open("w") as fh:
|
|
125
|
+
for r in rows:
|
|
126
|
+
fh.write(json.dumps(r) + "\n")
|
|
127
|
+
|
|
128
|
+
src = ClaudeCodeSource(root=root)
|
|
129
|
+
s = next(iter(src.list_sessions()))
|
|
130
|
+
assert s.tokens_input == 150 # 100 + 50
|
|
131
|
+
assert s.tokens_output == 600 # 200 + 400
|
|
132
|
+
assert s.tokens_cache_read == 13000 # 5000 + 8000
|
|
133
|
+
assert s.tokens_cache_write == 400 # 300 + 100
|
|
134
|
+
|
|
135
|
+
# load_session path agrees with the listing path.
|
|
136
|
+
full = src.load_session(s.id)
|
|
137
|
+
assert full.tokens_cache_read == 13000
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_claudecode_usage_none_when_absent(tmp_path):
|
|
141
|
+
root = tmp_path / "projects"
|
|
142
|
+
sid = "bbbbbbbb-1111-2222-3333-444444444444"
|
|
143
|
+
f = root / "-proj" / f"{sid}.jsonl"
|
|
144
|
+
f.parent.mkdir(parents=True)
|
|
145
|
+
with f.open("w") as fh:
|
|
146
|
+
fh.write(json.dumps(_cc_user("hi", "2026-03-14T09:30:00Z", sid)) + "\n")
|
|
147
|
+
fh.write(json.dumps(_cc_assistant("yo", "2026-03-14T09:30:05Z", sid)) + "\n")
|
|
148
|
+
|
|
149
|
+
s = next(iter(ClaudeCodeSource(root=root).list_sessions()))
|
|
150
|
+
assert s.tokens_input is None
|
|
151
|
+
assert s.tokens_cache_read is None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# -- Codex (best-effort; None when the format carries no usage) ------------
|
|
155
|
+
|
|
156
|
+
def _codex_rollout(tmp_path: Path, rows: list[dict]) -> Path:
|
|
157
|
+
d = tmp_path / "sessions" / "2025" / "01" / "31"
|
|
158
|
+
d.mkdir(parents=True)
|
|
159
|
+
f = d / "rollout-2025-01-31T12-34-56-abcd1234-ef56-7890-abcd-ef1234567890.jsonl"
|
|
160
|
+
f.write_text("\n".join(json.dumps(r) for r in rows) + "\n")
|
|
161
|
+
return f
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_codex_usage_none_without_token_records(tmp_path):
|
|
165
|
+
_codex_rollout(tmp_path, [
|
|
166
|
+
{"type": "session_meta", "timestamp": "2025-01-31T12:34:56Z",
|
|
167
|
+
"cwd": "/proj", "model": "gpt-5-codex"},
|
|
168
|
+
{"type": "response_item", "timestamp": "2025-01-31T12:35:00Z",
|
|
169
|
+
"payload": {"type": "message", "role": "user",
|
|
170
|
+
"content": [{"type": "input_text", "text": "hi"}]}},
|
|
171
|
+
])
|
|
172
|
+
s = next(iter(CodexSource(root=tmp_path / "sessions").list_sessions()))
|
|
173
|
+
assert s.tokens_input is None
|
|
174
|
+
assert s.tokens_cache_read is None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_codex_parses_token_count_when_present(tmp_path):
|
|
178
|
+
_codex_rollout(tmp_path, [
|
|
179
|
+
{"type": "session_meta", "timestamp": "2025-01-31T12:34:56Z",
|
|
180
|
+
"cwd": "/proj", "model": "gpt-5-codex"},
|
|
181
|
+
{"type": "response_item", "timestamp": "2025-01-31T12:35:00Z",
|
|
182
|
+
"payload": {"type": "message", "role": "user",
|
|
183
|
+
"content": [{"type": "input_text", "text": "hi"}]}},
|
|
184
|
+
{"type": "event_msg", "timestamp": "2025-01-31T12:35:10Z",
|
|
185
|
+
"payload": {"type": "token_count", "input_tokens": 900,
|
|
186
|
+
"output_tokens": 120, "cached_input_tokens": 4000}},
|
|
187
|
+
])
|
|
188
|
+
s = next(iter(CodexSource(root=tmp_path / "sessions").list_sessions()))
|
|
189
|
+
assert s.tokens_input == 900
|
|
190
|
+
assert s.tokens_output == 120
|
|
191
|
+
assert s.tokens_cache_read == 4000
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# -- stats aggregation -----------------------------------------------------
|
|
195
|
+
|
|
196
|
+
class _FakeSource:
|
|
197
|
+
name = "fake"
|
|
198
|
+
label = "Fake"
|
|
199
|
+
|
|
200
|
+
def __init__(self, sessions):
|
|
201
|
+
self._s = sessions
|
|
202
|
+
|
|
203
|
+
def is_available(self):
|
|
204
|
+
return True
|
|
205
|
+
|
|
206
|
+
def location(self):
|
|
207
|
+
return Path("/tmp/fake")
|
|
208
|
+
|
|
209
|
+
def list_sessions(self):
|
|
210
|
+
return iter(self._s)
|
|
211
|
+
|
|
212
|
+
def load_session(self, sid):
|
|
213
|
+
return next((s for s in self._s if s.id == sid), None)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _sess(sid, **usage):
|
|
217
|
+
from datetime import datetime, timezone
|
|
218
|
+
dt = datetime(2026, 3, 14, tzinfo=timezone.utc)
|
|
219
|
+
return Session(id=sid, source="fake", title=sid, directory=None,
|
|
220
|
+
created=dt, updated=dt, message_count=1, **usage)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def test_stats_sums_cache_and_reasoning():
|
|
224
|
+
store = Store([_FakeSource([
|
|
225
|
+
_sess("a", tokens_input=100, tokens_output=200,
|
|
226
|
+
tokens_cache_read=1000, tokens_cache_write=50, tokens_reasoning=10),
|
|
227
|
+
_sess("b", tokens_input=5, tokens_output=7,
|
|
228
|
+
tokens_cache_read=2000, tokens_cache_write=25, tokens_reasoning=3),
|
|
229
|
+
_sess("c"), # no usage -> contributes nothing
|
|
230
|
+
])])
|
|
231
|
+
st = store.stats()
|
|
232
|
+
assert st.total_tokens_input == 105
|
|
233
|
+
assert st.total_tokens_output == 207
|
|
234
|
+
assert st.total_tokens_cache_read == 3000
|
|
235
|
+
assert st.total_tokens_cache_write == 75
|
|
236
|
+
assert st.total_tokens_reasoning == 13
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# -- export surfacing ------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
def _msg():
|
|
242
|
+
return Message(id="m", role="assistant", created=None,
|
|
243
|
+
parts=(Part(id="p", type="text", text="hi"),))
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_export_usage_summary_in_markdown_and_html():
|
|
247
|
+
s = Session(id="s", source="opencode", title="t", directory=None,
|
|
248
|
+
created=None, updated=None, messages=(_msg(),), message_count=1,
|
|
249
|
+
tokens_input=1500, tokens_output=3000,
|
|
250
|
+
tokens_cache_read=250000, tokens_cache_write=40000, cost=0.42)
|
|
251
|
+
md = export.to_markdown(s)
|
|
252
|
+
assert "**Usage**" in md
|
|
253
|
+
assert "1.5k in / 3.0k out" in md
|
|
254
|
+
assert "cache 250.0k read / 40.0k write" in md
|
|
255
|
+
assert "$0.42" in md
|
|
256
|
+
|
|
257
|
+
html = export.to_html(s)
|
|
258
|
+
assert "usage:" in html
|
|
259
|
+
assert "250.0k read" in html
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def test_export_no_usage_line_when_absent():
|
|
263
|
+
s = Session(id="s", source="aider", title="t", directory=None,
|
|
264
|
+
created=None, updated=None, messages=(_msg(),), message_count=1)
|
|
265
|
+
assert "**Usage**" not in export.to_markdown(s)
|
|
266
|
+
assert "usage:" not in export.to_html(s)
|
|
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
|
|
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
|
|
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
|
|
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
|