scrollback 0.1.1__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.1 → scrollback-0.2.0}/CHANGELOG.md +33 -1
- {scrollback-0.1.1 → scrollback-0.2.0}/PKG-INFO +20 -3
- {scrollback-0.1.1 → scrollback-0.2.0}/README.md +19 -2
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/__init__.py +1 -1
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/cli.py +91 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/export.py +35 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/launcher_install.py +40 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/models.py +8 -1
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/serialize.py +3 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/sources/claudecode.py +37 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/sources/codex.py +65 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/sources/opencode.py +18 -9
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/store.py +15 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/app.js +3 -1
- {scrollback-0.1.1 → scrollback-0.2.0}/tests/test_launcher_install.py +26 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/tests/test_stats_resume.py +4 -0
- scrollback-0.2.0/tests/test_usage.py +266 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/.github/workflows/ci.yml +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/.github/workflows/publish.yml +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/.gitignore +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/CONTRIBUTING.md +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/LICENSE +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/ROADMAP.md +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/assets/icon-512.png +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/assets/icon.icns +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/assets/icon.svg +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/assets/screenshots/cli.png +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/assets/screenshots/cli.svg +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/assets/screenshots/web.png +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/pyproject.toml +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/scripts/demo_data.py +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/scripts/screenshots.py +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/assets/icon-256.png +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/assets/icon.icns +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/clipboard.py +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/fts.py +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/highlight.py +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/katexbundle.py +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/launchers/scrollback.bat +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/launchers/scrollback.command +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/launchers/scrollback.desktop +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/launchers/scrollback.sh +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/mathspan.py +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/minimd.py +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/serverconfig.py +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/sources/__init__.py +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/sources/aider.py +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/sources/base.py +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/sources/registry.py +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/termrender.py +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/__init__.py +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/app.py +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/apple-touch-icon.png +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/favicon.png +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/favicon.svg +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/index.html +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/style.css +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/highlight.min.js +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/hljs-dark.min.css +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/hljs-light.min.css +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/katex.min.css +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/katex.min.js +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/marked.min.js +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/purify.min.js +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/webopen.py +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/tests/test_aider.py +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/tests/test_claude_paging.py +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/tests/test_claude_subagents.py +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/tests/test_cli_helpers.py +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/tests/test_codex.py +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/tests/test_export.py +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/tests/test_fts.py +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/tests/test_highlight.py +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/tests/test_minimd.py +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/tests/test_models.py +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/tests/test_serverconfig.py +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/tests/test_sources_live.py +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/tests/test_store_filters.py +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/tests/test_web_api.py +0 -0
- {scrollback-0.1.1 → scrollback-0.2.0}/tests/test_webopen.py +0 -0
|
@@ -6,6 +6,36 @@ 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
|
+
|
|
29
|
+
## [0.1.2] - 2026-06-30
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
- `scrollback uninstall`: removes the artifacts scrollback created (Desktop
|
|
34
|
+
launcher, macOS `.app`, optional search index, launcher log) with a
|
|
35
|
+
confirmation prompt (`--yes` / `--dry-run`). It never touches agent data
|
|
36
|
+
and never self-removes the package; it prints the right `pip`/`pipx
|
|
37
|
+
uninstall` command instead.
|
|
38
|
+
|
|
9
39
|
## [0.1.1] - 2026-06-30
|
|
10
40
|
|
|
11
41
|
### Fixed
|
|
@@ -90,6 +120,8 @@ export it from a CLI and a local web app.
|
|
|
90
120
|
- Negative pagination arguments are rejected; clearer errors for unknown
|
|
91
121
|
sources, failed exports, and unavailable data sources.
|
|
92
122
|
|
|
93
|
-
[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
|
|
125
|
+
[0.1.2]: https://github.com/a-attia/scrollback/compare/v0.1.1...v0.1.2
|
|
94
126
|
[0.1.1]: https://github.com/a-attia/scrollback/compare/v0.1.0...v0.1.1
|
|
95
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
|
|
@@ -299,6 +309,13 @@ window closes. All of this behaviour is decided in Python, so the launcher
|
|
|
299
309
|
scripts stay free of OS-specific assumptions and ship inside the package
|
|
300
310
|
for `pip install` users.
|
|
301
311
|
|
|
312
|
+
To clean up, `scrollback uninstall` removes the artifacts scrollback
|
|
313
|
+
created — the launchers, the macOS `.app`, the optional search index, and the
|
|
314
|
+
launcher log — after a confirmation (`--yes` to skip it, `--dry-run` to
|
|
315
|
+
preview). It never touches your agent data, and it does not remove the Python
|
|
316
|
+
package itself: it prints the right `pip`/`pipx uninstall` command to finish
|
|
317
|
+
the job (a program can't reliably uninstall the package it is running from).
|
|
318
|
+
|
|
302
319
|
## Fast search (optional index)
|
|
303
320
|
|
|
304
321
|
By default, search is a lexical scan over your live data: zero setup,
|
|
@@ -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
|
|
@@ -240,6 +250,13 @@ window closes. All of this behaviour is decided in Python, so the launcher
|
|
|
240
250
|
scripts stay free of OS-specific assumptions and ship inside the package
|
|
241
251
|
for `pip install` users.
|
|
242
252
|
|
|
253
|
+
To clean up, `scrollback uninstall` removes the artifacts scrollback
|
|
254
|
+
created — the launchers, the macOS `.app`, the optional search index, and the
|
|
255
|
+
launcher log — after a confirmation (`--yes` to skip it, `--dry-run` to
|
|
256
|
+
preview). It never touches your agent data, and it does not remove the Python
|
|
257
|
+
package itself: it prints the right `pip`/`pipx uninstall` command to finish
|
|
258
|
+
the job (a program can't reliably uninstall the package it is running from).
|
|
259
|
+
|
|
243
260
|
## Fast search (optional index)
|
|
244
261
|
|
|
245
262
|
By default, search is a lexical scan over your live data: zero setup,
|
|
@@ -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
|
}
|
|
@@ -844,6 +855,72 @@ def cmd_install_launcher(args: argparse.Namespace) -> int:
|
|
|
844
855
|
return 0
|
|
845
856
|
|
|
846
857
|
|
|
858
|
+
def _detect_install_tool() -> str:
|
|
859
|
+
"""Best-effort guess of how scrollback was installed, for the hint.
|
|
860
|
+
|
|
861
|
+
Returns the command the user should run to remove the package itself.
|
|
862
|
+
We never run it: a process cannot reliably uninstall the package it is
|
|
863
|
+
executing from, and the right tool (pip / pipx / conda) depends on how
|
|
864
|
+
it was installed.
|
|
865
|
+
"""
|
|
866
|
+
exe = (sys.executable or "").replace("\\", "/")
|
|
867
|
+
if "/pipx/" in exe or "/.local/pipx/" in exe:
|
|
868
|
+
return "pipx uninstall scrollback"
|
|
869
|
+
return "pip uninstall scrollback"
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def cmd_uninstall(args: argparse.Namespace) -> int:
|
|
873
|
+
"""Remove scrollback-created artifacts; explain how to remove the package.
|
|
874
|
+
|
|
875
|
+
Removes only files scrollback itself created (launchers, the macOS .app,
|
|
876
|
+
the optional search index, the launcher log). It never touches your agent
|
|
877
|
+
data, and it never tries to uninstall the Python package -- that is left
|
|
878
|
+
to pip/pipx, with the exact command printed at the end.
|
|
879
|
+
"""
|
|
880
|
+
from . import fts, launcher_install
|
|
881
|
+
|
|
882
|
+
targets: list = list(launcher_install.installed_artifacts())
|
|
883
|
+
index_path = fts.default_index_path()
|
|
884
|
+
if index_path.exists():
|
|
885
|
+
targets.append(index_path)
|
|
886
|
+
|
|
887
|
+
if not targets:
|
|
888
|
+
_eprint("no scrollback-created artifacts found.")
|
|
889
|
+
_eprint(f"to remove the package itself, run:\n {_detect_install_tool()}")
|
|
890
|
+
return 0
|
|
891
|
+
|
|
892
|
+
label = "would remove" if args.dry_run else "about to remove"
|
|
893
|
+
_eprint(f"{label}:")
|
|
894
|
+
for p in targets:
|
|
895
|
+
_eprint(f" {p}")
|
|
896
|
+
|
|
897
|
+
if args.dry_run:
|
|
898
|
+
return 0
|
|
899
|
+
|
|
900
|
+
if not args.yes:
|
|
901
|
+
try:
|
|
902
|
+
reply = input("remove these? [y/N] ").strip().lower()
|
|
903
|
+
except (EOFError, KeyboardInterrupt):
|
|
904
|
+
reply = ""
|
|
905
|
+
if reply not in ("y", "yes"):
|
|
906
|
+
_eprint("aborted; nothing removed.")
|
|
907
|
+
return 1
|
|
908
|
+
|
|
909
|
+
removed, failed = 0, 0
|
|
910
|
+
for p in targets:
|
|
911
|
+
try:
|
|
912
|
+
launcher_install.remove_path(p)
|
|
913
|
+
_eprint(f"removed {p}")
|
|
914
|
+
removed += 1
|
|
915
|
+
except OSError as exc:
|
|
916
|
+
_eprint(f"could not remove {p}: {exc}")
|
|
917
|
+
failed += 1
|
|
918
|
+
|
|
919
|
+
_eprint(f"\nremoved {removed} item(s)" + (f", {failed} failed" if failed else ""))
|
|
920
|
+
_eprint(f"to remove the package itself, run:\n {_detect_install_tool()}")
|
|
921
|
+
return 1 if failed else 0
|
|
922
|
+
|
|
923
|
+
|
|
847
924
|
class _AppBridge:
|
|
848
925
|
"""JS<->Python API exposed to the pywebview window.
|
|
849
926
|
|
|
@@ -1093,6 +1170,20 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1093
1170
|
"falls back to the Desktop launcher elsewhere)")
|
|
1094
1171
|
sp.set_defaults(func=cmd_install_launcher)
|
|
1095
1172
|
|
|
1173
|
+
# uninstall
|
|
1174
|
+
sp = sub.add_parser(
|
|
1175
|
+
"uninstall",
|
|
1176
|
+
help="remove scrollback-created artifacts (launchers, app, index)",
|
|
1177
|
+
description="Remove files scrollback created (Desktop launcher, macOS "
|
|
1178
|
+
".app, search index, launcher log). Your agent data is never "
|
|
1179
|
+
"touched. The Python package itself is removed with pip/pipx "
|
|
1180
|
+
"-- the exact command is printed at the end.",
|
|
1181
|
+
)
|
|
1182
|
+
sp.add_argument("-y", "--yes", action="store_true", help="skip the confirmation prompt")
|
|
1183
|
+
sp.add_argument("--dry-run", action="store_true",
|
|
1184
|
+
help="show what would be removed, then exit")
|
|
1185
|
+
sp.set_defaults(func=cmd_uninstall)
|
|
1186
|
+
|
|
1096
1187
|
return p
|
|
1097
1188
|
|
|
1098
1189
|
|
|
@@ -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] = []
|
|
@@ -207,3 +207,43 @@ def _install_linux(dest: Path | None) -> list[Path]:
|
|
|
207
207
|
_write(sh, _read_bundled("scrollback.sh"), executable=True)
|
|
208
208
|
created.append(sh)
|
|
209
209
|
return created
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# -- uninstall: locate + remove the artifacts install creates --------------
|
|
213
|
+
|
|
214
|
+
def installed_artifacts() -> list[Path]:
|
|
215
|
+
"""Return the launcher/app/log paths scrollback may have created.
|
|
216
|
+
|
|
217
|
+
Covers the default install locations on every platform (a custom
|
|
218
|
+
``--dest`` is not tracked, so those must be removed by hand). Only paths
|
|
219
|
+
that currently exist are returned. Never includes the user's agent data.
|
|
220
|
+
"""
|
|
221
|
+
home = Path.home()
|
|
222
|
+
candidates: list[Path] = []
|
|
223
|
+
|
|
224
|
+
if sys.platform == "darwin":
|
|
225
|
+
candidates += [
|
|
226
|
+
_desktop_dir() / "scrollback.command",
|
|
227
|
+
home / "Applications" / "scrollback.app",
|
|
228
|
+
home / "Library" / "Logs" / "scrollback-launcher.log",
|
|
229
|
+
]
|
|
230
|
+
elif sys.platform == "win32":
|
|
231
|
+
candidates += [_desktop_dir() / "scrollback.bat"]
|
|
232
|
+
else:
|
|
233
|
+
apps = Path(os.environ.get("XDG_DATA_HOME", home / ".local" / "share")) / "applications"
|
|
234
|
+
candidates += [
|
|
235
|
+
apps / "scrollback.desktop",
|
|
236
|
+
_desktop_dir() / "scrollback.sh",
|
|
237
|
+
]
|
|
238
|
+
|
|
239
|
+
return [p for p in candidates if p.exists()]
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def remove_path(path: Path) -> None:
|
|
243
|
+
"""Delete a file or directory (used to remove a .app bundle)."""
|
|
244
|
+
import shutil
|
|
245
|
+
|
|
246
|
+
if path.is_dir() and not path.is_symlink():
|
|
247
|
+
shutil.rmtree(path)
|
|
248
|
+
else:
|
|
249
|
+
path.unlink()
|
|
@@ -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 {}
|