claudenator 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. claudenator-1.0.0/.gitignore +12 -0
  2. claudenator-1.0.0/LICENSE.md +18 -0
  3. claudenator-1.0.0/PKG-INFO +90 -0
  4. claudenator-1.0.0/README.md +60 -0
  5. claudenator-1.0.0/claudenator/__init__.py +18 -0
  6. claudenator-1.0.0/claudenator/__main__.py +6 -0
  7. claudenator-1.0.0/claudenator/cli/__init__.py +1 -0
  8. claudenator-1.0.0/claudenator/cli/main.py +205 -0
  9. claudenator-1.0.0/claudenator/core/__init__.py +1 -0
  10. claudenator-1.0.0/claudenator/core/cache.py +198 -0
  11. claudenator-1.0.0/claudenator/core/config.py +284 -0
  12. claudenator-1.0.0/claudenator/core/errors.py +146 -0
  13. claudenator-1.0.0/claudenator/core/format.py +370 -0
  14. claudenator-1.0.0/claudenator/core/live.py +145 -0
  15. claudenator-1.0.0/claudenator/core/model.py +333 -0
  16. claudenator-1.0.0/claudenator/core/scan.py +321 -0
  17. claudenator-1.0.0/claudenator/core/settings.py +187 -0
  18. claudenator-1.0.0/claudenator/core/stats.py +140 -0
  19. claudenator-1.0.0/claudenator/core/store.py +399 -0
  20. claudenator-1.0.0/claudenator/core/trash.py +350 -0
  21. claudenator-1.0.0/claudenator/tui/__init__.py +1 -0
  22. claudenator-1.0.0/claudenator/tui/about.py +93 -0
  23. claudenator-1.0.0/claudenator/tui/app.py +624 -0
  24. claudenator-1.0.0/claudenator/tui/claudenator.tcss +287 -0
  25. claudenator-1.0.0/claudenator/tui/confirm.py +64 -0
  26. claudenator-1.0.0/claudenator/tui/panes.py +1266 -0
  27. claudenator-1.0.0/claudenator/tui/settings.py +296 -0
  28. claudenator-1.0.0/pyproject.toml +71 -0
  29. claudenator-1.0.0/tests/__init__.py +1 -0
  30. claudenator-1.0.0/tests/conftest.py +71 -0
  31. claudenator-1.0.0/tests/fabricate.py +497 -0
  32. claudenator-1.0.0/tests/test_cache.py +132 -0
  33. claudenator-1.0.0/tests/test_cli.py +528 -0
  34. claudenator-1.0.0/tests/test_config.py +237 -0
  35. claudenator-1.0.0/tests/test_format.py +532 -0
  36. claudenator-1.0.0/tests/test_layers.py +60 -0
  37. claudenator-1.0.0/tests/test_live.py +215 -0
  38. claudenator-1.0.0/tests/test_purge.py +185 -0
  39. claudenator-1.0.0/tests/test_restore.py +431 -0
  40. claudenator-1.0.0/tests/test_settings.py +92 -0
  41. claudenator-1.0.0/tests/test_stats.py +146 -0
  42. claudenator-1.0.0/tests/test_store.py +665 -0
  43. claudenator-1.0.0/tests/test_trash.py +490 -0
  44. claudenator-1.0.0/tests/test_trash_list.py +175 -0
  45. claudenator-1.0.0/tests/test_tui.py +3792 -0
@@ -0,0 +1,12 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ build/
6
+ dist/
7
+ .venv/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .coverage
12
+ htmlcov/
@@ -0,0 +1,18 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marcin Orlowski <https://marcinOrlowski.com/>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
6
+ associated documentation files (the "Software"), to deal in the Software without restriction,
7
+ including without limitation the rights to use, copy, modify, merge, publish, distribute,
8
+ sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
9
+ furnished to do so, subject to the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all copies or substantial
12
+ portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
15
+ NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
16
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES
17
+ OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
18
+ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.5
2
+ Name: claudenator
3
+ Version: 1.0.0
4
+ Summary: The only Claude Code session manager you needca
5
+ Project-URL: Homepage, https://github.com/MarcinOrlowski/claudenator
6
+ Project-URL: Issues, https://github.com/MarcinOrlowski/claudenator/issues
7
+ Author-email: Marcin Orlowski <mail@marcinOrlowski.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE.md
10
+ Keywords: claude,claude-code,claude-sessions,cli,session-management,sessions,tui
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: POSIX :: Linux
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Topic :: Utilities
18
+ Requires-Python: >=3.11
19
+ Requires-Dist: qrcat<2,>=1
20
+ Requires-Dist: textual<9,>=8
21
+ Provides-Extra: dev
22
+ Requires-Dist: black; extra == 'dev'
23
+ Requires-Dist: flake8; extra == 'dev'
24
+ Requires-Dist: flake8-docstrings; extra == 'dev'
25
+ Requires-Dist: pytest>=8; extra == 'dev'
26
+ Provides-Extra: release
27
+ Requires-Dist: build>=1.2; extra == 'release'
28
+ Requires-Dist: twine>=6; extra == 'release'
29
+ Description-Content-Type: text/markdown
30
+
31
+ ![Claudenator](https://raw.githubusercontent.com/MarcinOrlowski/claudenator/master/img/logo.webp)
32
+
33
+ [![Version](https://img.shields.io/pypi/v/claudenator?style=flat)](https://pypi.org/project/claudenator/)
34
+ [![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/)
35
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE.md)
36
+
37
+ ## The only Claude Code session manager you need
38
+
39
+ Claude Code writes a lot. Every session leaves a transcript from the main session and its subagents.
40
+ Then goes the environment, file history, jobs and todos. That's tons of disk. Additionally,
41
+ it auto-purges all the sessions older than 30 days, but if you decide to prevent that and keep some
42
+ old sessions for longer, it can stack up a massive pile pretty quickly.
43
+
44
+ This is where Claudenator comes to the rescue. It shows all your sessions, grouped by project
45
+ folder, sorts them by whatever column you like and shows tons of additional information that
46
+ would help you manage them and purge only real garbage.
47
+
48
+ ![Claudenator in action](https://raw.githubusercontent.com/MarcinOrlowski/claudenator/master/img/claudenator.webp)
49
+
50
+ ## Features
51
+
52
+ * Shows every session, its title, project, size, date, messages, model, git branch, tools and moar.
53
+ * A details pane under the table, and a full-screen view for the whole story.
54
+ * Order the table by any column and narrow it to what you look for with a filter.
55
+ * A deleted session first goes to a Trash so it disappears but is not yet gone and can be either
56
+ restored or purged for good.
57
+ * Comes with "Deep scan" feature, that process the full transcript to give you more stats for nerds.
58
+ * Configurable TUI with themes, layout, time formats and more.
59
+
60
+ ## Installation
61
+
62
+ The [pipx](https://pypi.org/project/pipx/) is the recommended way to install it:
63
+
64
+ ```bash
65
+ # Install pipx if not present
66
+ sudo apt install -y pipx
67
+
68
+ # Install the tool
69
+ pipx install claudenator
70
+
71
+ # Upgrade existing installation
72
+ pipx upgrade claudenator
73
+ ```
74
+
75
+ To get the latest code, install straight from the repository:
76
+
77
+ ```bash
78
+ # Install the current "master" branch
79
+ pipx install --force git+https://github.com/MarcinOrlowski/claudenator.git
80
+
81
+ # Install the "dev" branch or any other branch, tag or commit
82
+ pipx install --force git+https://github.com/MarcinOrlowski/claudenator.git@dev
83
+ ```
84
+
85
+ The `--force` option is only needed if there's already existing Claudenator installation present.
86
+
87
+ ## License
88
+
89
+ * Written and copyrighted &copy;2026 by Marcin Orlowski <mail (#) marcinorlowski (.) com>
90
+ * This is open-source software licensed under the [MIT license](http://opensource.org/licenses/MIT)
@@ -0,0 +1,60 @@
1
+ ![Claudenator](https://raw.githubusercontent.com/MarcinOrlowski/claudenator/master/img/logo.webp)
2
+
3
+ [![Version](https://img.shields.io/pypi/v/claudenator?style=flat)](https://pypi.org/project/claudenator/)
4
+ [![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE.md)
6
+
7
+ ## The only Claude Code session manager you need
8
+
9
+ Claude Code writes a lot. Every session leaves a transcript from the main session and its subagents.
10
+ Then goes the environment, file history, jobs and todos. That's tons of disk. Additionally,
11
+ it auto-purges all the sessions older than 30 days, but if you decide to prevent that and keep some
12
+ old sessions for longer, it can stack up a massive pile pretty quickly.
13
+
14
+ This is where Claudenator comes to the rescue. It shows all your sessions, grouped by project
15
+ folder, sorts them by whatever column you like and shows tons of additional information that
16
+ would help you manage them and purge only real garbage.
17
+
18
+ ![Claudenator in action](https://raw.githubusercontent.com/MarcinOrlowski/claudenator/master/img/claudenator.webp)
19
+
20
+ ## Features
21
+
22
+ * Shows every session, its title, project, size, date, messages, model, git branch, tools and moar.
23
+ * A details pane under the table, and a full-screen view for the whole story.
24
+ * Order the table by any column and narrow it to what you look for with a filter.
25
+ * A deleted session first goes to a Trash so it disappears but is not yet gone and can be either
26
+ restored or purged for good.
27
+ * Comes with "Deep scan" feature, that process the full transcript to give you more stats for nerds.
28
+ * Configurable TUI with themes, layout, time formats and more.
29
+
30
+ ## Installation
31
+
32
+ The [pipx](https://pypi.org/project/pipx/) is the recommended way to install it:
33
+
34
+ ```bash
35
+ # Install pipx if not present
36
+ sudo apt install -y pipx
37
+
38
+ # Install the tool
39
+ pipx install claudenator
40
+
41
+ # Upgrade existing installation
42
+ pipx upgrade claudenator
43
+ ```
44
+
45
+ To get the latest code, install straight from the repository:
46
+
47
+ ```bash
48
+ # Install the current "master" branch
49
+ pipx install --force git+https://github.com/MarcinOrlowski/claudenator.git
50
+
51
+ # Install the "dev" branch or any other branch, tag or commit
52
+ pipx install --force git+https://github.com/MarcinOrlowski/claudenator.git@dev
53
+ ```
54
+
55
+ The `--force` option is only needed if there's already existing Claudenator installation present.
56
+
57
+ ## License
58
+
59
+ * Written and copyrighted &copy;2026 by Marcin Orlowski <mail (#) marcinorlowski (.) com>
60
+ * This is open-source software licensed under the [MIT license](http://opensource.org/licenses/MIT)
@@ -0,0 +1,18 @@
1
+ """
2
+ ##################################################################################
3
+ #
4
+ # Claudenator by Marcin Orlowski
5
+ # The only Claude Code session manager you need.
6
+ #
7
+ # @author Marcin Orlowski <mail@marcinOrlowski.com>
8
+ # Copyright ©2026 Marcin Orlowski <MarcinOrlowski.com>
9
+ # @link https://github.com/MarcinOrlowski/claudenator
10
+ #
11
+ ##################################################################################
12
+ """
13
+
14
+ __title__ = "claudenator"
15
+ __version__ = "1.0.0"
16
+ __description__ = "The only Claude Code session manager you need"
17
+ __author__ = "Marcin Orlowski"
18
+ __url__ = "https://github.com/MarcinOrlowski/claudenator"
@@ -0,0 +1,6 @@
1
+ """Entry point for ``python -m claudenator``."""
2
+
3
+ from claudenator.cli.main import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """CLI"""
@@ -0,0 +1,205 @@
1
+ """
2
+ ##################################################################################
3
+ #
4
+ # Claudenator by Marcin Orlowski
5
+ # The only Claude Code session manager you need.
6
+ #
7
+ # @author Marcin Orlowski <mail@marcinOrlowski.com>
8
+ # Copyright ©2026 Marcin Orlowski <MarcinOrlowski.com>
9
+ # @link https://github.com/MarcinOrlowski/claudenator
10
+ #
11
+ ##################################################################################
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ from claudenator import __version__
22
+ from claudenator.core.config import apply_file
23
+ from claudenator.core.errors import ClaudenatorError
24
+ from claudenator.core.format import Formatter, plural_of
25
+ from claudenator.core.model import Session
26
+ from claudenator.core.settings import Settings
27
+ from claudenator.core.store import SessionStore
28
+
29
+ TITLE_WIDTH = 48
30
+
31
+
32
+ def short_title(session: Session) -> str:
33
+ """The title in truncated form."""
34
+ text = session.title
35
+ if len(text) > TITLE_WIDTH:
36
+ text = text[: TITLE_WIDTH - 3].rstrip() + "…"
37
+ return text
38
+
39
+
40
+ def open_screen(settings: Settings, notes: list[str]) -> int:
41
+ """Run the TUI."""
42
+ from claudenator.tui.app import run
43
+
44
+ return run(settings, notes)
45
+
46
+
47
+ def build_parser() -> argparse.ArgumentParser:
48
+ """The argument parser for the ``claudenator`` command."""
49
+ defaults = Settings()
50
+ parser = argparse.ArgumentParser(
51
+ prog="claudenator",
52
+ description="Look at Claude Code sessions and remove the ones you do not want.",
53
+ )
54
+ parser.add_argument(
55
+ "--version", action="version", version=f"%(prog)s {__version__}"
56
+ )
57
+ parser.add_argument(
58
+ "--claude-dir",
59
+ type=Path,
60
+ metavar="DIR",
61
+ help=f"Claude Code's data folder (default: {defaults.claude_dir})",
62
+ )
63
+ parser.add_argument(
64
+ "--data-dir",
65
+ type=Path,
66
+ metavar="DIR",
67
+ help=f"claudenator's own folder for the Trash and the cache (default: {defaults.data_dir})",
68
+ )
69
+ # The process table. Only a test points this anywhere but /proc.
70
+ parser.add_argument("--proc-dir", type=Path, metavar="DIR", help=argparse.SUPPRESS)
71
+ commands = parser.add_subparsers(dest="command", metavar="COMMAND")
72
+
73
+ list_parser = commands.add_parser("list", help="list every session")
74
+ list_parser.add_argument("--json", action="store_true", help="print JSON")
75
+
76
+ info_parser = commands.add_parser("info", help="show one session in full")
77
+ info_parser.add_argument(
78
+ "session_id", metavar="ID", help="a session id, or a unique prefix of one"
79
+ )
80
+ info_parser.add_argument("--json", action="store_true", help="print JSON")
81
+
82
+ scan_parser = commands.add_parser(
83
+ "scan", help="read every transcript in full and cache its figures"
84
+ )
85
+ scan_parser.add_argument(
86
+ "--force",
87
+ action="store_true",
88
+ help="read a transcript again even when its cached figures are fresh",
89
+ )
90
+ return parser
91
+
92
+
93
+ def settings_from(args: argparse.Namespace) -> tuple[Settings, list[str]]:
94
+ """A settings object, and a note for every fault in the settings file."""
95
+ settings = Settings()
96
+ notes = apply_file(settings)
97
+ if args.claude_dir is not None:
98
+ settings.claude_dir = args.claude_dir.expanduser()
99
+ if args.data_dir is not None:
100
+ settings.data_dir = args.data_dir.expanduser()
101
+ if args.proc_dir is not None:
102
+ settings.proc_dir = args.proc_dir.expanduser()
103
+ return settings, notes
104
+
105
+
106
+ def cmd_list(store: SessionStore, args: argparse.Namespace, fmt: Formatter) -> int:
107
+ """Print every session, one per line."""
108
+ sessions = store.list_sessions()
109
+ if args.json:
110
+ print(json.dumps([session.to_dict() for session in sessions], indent=2))
111
+ return 0
112
+ if not sessions:
113
+ print(f"No sessions found under {store.settings.claude_dir}")
114
+ return 0
115
+ rows = [
116
+ (
117
+ session.id[:8],
118
+ fmt.marks(session),
119
+ fmt.list_timestamp(session.last_used),
120
+ fmt.size(session.size),
121
+ short_title(session),
122
+ session.project_path,
123
+ )
124
+ for session in sessions
125
+ ]
126
+ header = ("ID", "STS", "LAST USED", "SIZE", "TITLE", "PROJECT")
127
+ widths = [max(len(row[i]) for row in (header, *rows)) for i in range(5)]
128
+ for row in (header, *rows):
129
+ cells = [row[i].ljust(widths[i]) for i in range(5)]
130
+ print(" ".join(cells + [row[5]]).rstrip())
131
+ total = sum(session.size for session in sessions)
132
+ noun = "session" if len(sessions) == 1 else "sessions"
133
+ print()
134
+ print(f"{len(sessions)} {noun}, {fmt.size(total)} on disk")
135
+ return 0
136
+
137
+
138
+ def cmd_info(store: SessionStore, args: argparse.Namespace, fmt: Formatter) -> int:
139
+ """Print one session in full."""
140
+ details = store.details(args.session_id)
141
+ if args.json:
142
+ print(json.dumps(details.to_dict(), indent=2))
143
+ return 0
144
+ lines = fmt.describe(details)
145
+ width = max(len(label) for label, _ in lines) + 1
146
+ for label, value in lines:
147
+ print(f"{(label + ':').ljust(width)} {value}")
148
+ return 0
149
+
150
+
151
+ def cmd_scan(store: SessionStore, args: argparse.Namespace, fmt: Formatter) -> int:
152
+ """Deep-scan every session and cache the figures. One line per session."""
153
+ sessions = store.list_sessions()
154
+ if not sessions:
155
+ print(f"No sessions found under {store.settings.claude_dir}")
156
+ return 0
157
+ read = kept = failed = 0
158
+ for result in store.scan_many(sessions, force=args.force):
159
+ short = result.session.id[:8]
160
+ if result.error is not None or result.figures is None:
161
+ failed += 1
162
+ print(f"{short} {result.error}", file=sys.stderr)
163
+ continue
164
+ figures = result.figures
165
+ if result.fresh:
166
+ kept += 1
167
+ else:
168
+ read += 1
169
+ turns = f"{fmt.count(figures.turns)} {plural_of('turn', figures.turns)}"
170
+ tokens = f"{fmt.count(figures.tokens)} {plural_of('token', figures.tokens)}"
171
+ length = fmt.duration(figures.duration)
172
+ state = "cached" if result.fresh else "scanned"
173
+ print(f"{short} {turns} {tokens} {length} {state}")
174
+ dropped = store.cache.forget_missing()
175
+ print()
176
+ print(
177
+ f"{fmt.scan_summary(read, kept, failed)}, "
178
+ f"{dropped} gone from the disk and forgotten"
179
+ )
180
+ print(f"Cache: {store.settings.cache_file}")
181
+ return 1 if failed else 0
182
+
183
+
184
+ def main(argv: list[str] | None = None) -> int:
185
+ """Run the command line. Returns the exit code."""
186
+ parser = build_parser()
187
+ args = parser.parse_args(argv)
188
+ settings, notes = settings_from(args)
189
+ if args.command is None:
190
+ return open_screen(settings, notes)
191
+ for note in notes:
192
+ print(f"claudenator: {note}", file=sys.stderr)
193
+ store = SessionStore(settings)
194
+ fmt = Formatter(settings)
195
+ try:
196
+ if args.command == "list":
197
+ return cmd_list(store, args, fmt)
198
+ if args.command == "info":
199
+ return cmd_info(store, args, fmt)
200
+ if args.command == "scan":
201
+ return cmd_scan(store, args, fmt)
202
+ except ClaudenatorError as error:
203
+ print(f"claudenator: {error}", file=sys.stderr)
204
+ return 1
205
+ parser.error(f"unknown command {args.command}")
@@ -0,0 +1 @@
1
+ """Core: CC session data handling"""
@@ -0,0 +1,198 @@
1
+ """
2
+ ##################################################################################
3
+ #
4
+ # Claudenator by Marcin Orlowski
5
+ # The only Claude Code session manager you need.
6
+ #
7
+ # @author Marcin Orlowski <mail@marcinOrlowski.com>
8
+ # Copyright ©2026 Marcin Orlowski <MarcinOrlowski.com>
9
+ # @link https://github.com/MarcinOrlowski/claudenator
10
+ #
11
+ ##################################################################################
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import sqlite3
18
+ from datetime import datetime
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from claudenator.core.errors import CacheDamaged
23
+ from claudenator.core.model import Figures
24
+ from claudenator.core.settings import Settings
25
+
26
+ # One row per transcript, found by its path. The size and change time say
27
+ # which copy of the file the row describes. The row holds our own numbers
28
+ # and nothing the user wrote. A time is stored whole, so what comes back
29
+ # is what went in.
30
+ SCHEMA = """
31
+ CREATE TABLE IF NOT EXISTS figures (
32
+ path TEXT PRIMARY KEY,
33
+ size INTEGER NOT NULL,
34
+ mtime_ns INTEGER NOT NULL,
35
+ scanned_at TEXT NOT NULL,
36
+ turns INTEGER NOT NULL,
37
+ input_tokens INTEGER NOT NULL,
38
+ output_tokens INTEGER NOT NULL,
39
+ cache_read_tokens INTEGER NOT NULL,
40
+ cache_write_tokens INTEGER NOT NULL,
41
+ models TEXT NOT NULL,
42
+ tools TEXT NOT NULL,
43
+ first_at TEXT,
44
+ last_at TEXT
45
+ )
46
+ """
47
+ COLUMNS = (
48
+ "path",
49
+ "size",
50
+ "mtime_ns",
51
+ "scanned_at",
52
+ "turns",
53
+ "input_tokens",
54
+ "output_tokens",
55
+ "cache_read_tokens",
56
+ "cache_write_tokens",
57
+ "models",
58
+ "tools",
59
+ "first_at",
60
+ "last_at",
61
+ )
62
+ # How long a write waits for another copy of the tool to finish its own.
63
+ LOCK_WAIT_SECONDS = 5.0
64
+
65
+
66
+ def _moment(value: Any) -> datetime | None:
67
+ return datetime.fromisoformat(value) if isinstance(value, str) else None
68
+
69
+
70
+ def _pairs(value: Any) -> tuple[tuple[str, int], ...]:
71
+ """The name and count pairs stored as one JSON list, in their stored order."""
72
+ return tuple((str(name), int(count)) for name, count in json.loads(value))
73
+
74
+
75
+ def _row_of(figures: Figures) -> tuple[Any, ...]:
76
+ return (
77
+ str(figures.transcript_path),
78
+ figures.transcript_size,
79
+ figures.transcript_mtime_ns,
80
+ figures.scanned_at.isoformat(),
81
+ figures.turns,
82
+ figures.input_tokens,
83
+ figures.output_tokens,
84
+ figures.cache_read_tokens,
85
+ figures.cache_write_tokens,
86
+ json.dumps(figures.models),
87
+ json.dumps(figures.tools),
88
+ figures.first_at.isoformat() if figures.first_at else None,
89
+ figures.last_at.isoformat() if figures.last_at else None,
90
+ )
91
+
92
+
93
+ def _is_stale(transcript: Path, row: tuple[Any, ...]) -> bool:
94
+ """True when the file's size or change time differ from the row's, or it is gone."""
95
+ try:
96
+ stat = transcript.stat()
97
+ except OSError:
98
+ return True
99
+ return stat.st_size != row[1] or stat.st_mtime_ns != row[2]
100
+
101
+
102
+ def _figures_of(row: tuple[Any, ...], stale: bool) -> Figures:
103
+ return Figures(
104
+ transcript_path=Path(row[0]),
105
+ transcript_size=row[1],
106
+ transcript_mtime_ns=row[2],
107
+ scanned_at=datetime.fromisoformat(row[3]),
108
+ turns=row[4],
109
+ input_tokens=row[5],
110
+ output_tokens=row[6],
111
+ cache_read_tokens=row[7],
112
+ cache_write_tokens=row[8],
113
+ models=_pairs(row[9]),
114
+ tools=_pairs(row[10]),
115
+ first_at=_moment(row[11]),
116
+ last_at=_moment(row[12]),
117
+ stale=stale,
118
+ )
119
+
120
+
121
+ class Cache:
122
+ """The numbers a deep scan found, kept in one SQLite file in the tool's own folder.
123
+
124
+ A row is found by the transcript's path. A transcript that changed since
125
+ its scan still gives its row back, marked stale, so the old numbers stay
126
+ on view until a new scan replaces them.
127
+ """
128
+
129
+ def __init__(self, settings: Settings) -> None:
130
+ self.path = settings.cache_file
131
+
132
+ def _connect(self) -> sqlite3.Connection:
133
+ self.path.parent.mkdir(parents=True, exist_ok=True)
134
+ connection = sqlite3.connect(self.path, timeout=LOCK_WAIT_SECONDS)
135
+ try:
136
+ connection.execute(SCHEMA)
137
+ except sqlite3.DatabaseError as error:
138
+ connection.close()
139
+ raise CacheDamaged(self.path, error) from error
140
+ return connection
141
+
142
+ def get(self, transcript: Path) -> Figures | None:
143
+ """The figures of one transcript, or None when it was never scanned.
144
+
145
+ The figures are stale when the file's size or change time differ from
146
+ the ones the scan saw, or when the file is gone. A damaged cache reads
147
+ as an empty one, so a list or a details pane still works. The next
148
+ ``put`` names the damage.
149
+ """
150
+ if not self.path.exists():
151
+ return None
152
+ names = ", ".join(COLUMNS)
153
+ try:
154
+ with self._connect() as connection:
155
+ row = connection.execute(
156
+ f"SELECT {names} FROM figures WHERE path = ?", (str(transcript),)
157
+ ).fetchone()
158
+ except CacheDamaged:
159
+ return None
160
+ return _figures_of(row, _is_stale(transcript, row)) if row else None
161
+
162
+ def get_all(self) -> dict[Path, Figures]:
163
+ """The figures of every transcript ever scanned, by path, in one read.
164
+
165
+ Each is marked stale the same way ``get`` marks it. A missing or a
166
+ damaged cache gives an empty dict.
167
+ """
168
+ if not self.path.exists():
169
+ return {}
170
+ names = ", ".join(COLUMNS)
171
+ try:
172
+ with self._connect() as connection:
173
+ rows = connection.execute(f"SELECT {names} FROM figures").fetchall()
174
+ except CacheDamaged:
175
+ return {}
176
+ return {
177
+ Path(row[0]): _figures_of(row, _is_stale(Path(row[0]), row)) for row in rows
178
+ }
179
+
180
+ def put(self, figures: Figures) -> None:
181
+ """Remember the figures of one transcript, in place of any older ones."""
182
+ marks = ", ".join("?" for _ in COLUMNS)
183
+ names = ", ".join(COLUMNS)
184
+ with self._connect() as connection:
185
+ connection.execute(
186
+ f"INSERT OR REPLACE INTO figures ({names}) VALUES ({marks})",
187
+ _row_of(figures),
188
+ )
189
+
190
+ def forget_missing(self) -> int:
191
+ """Drop the rows of transcripts that are no longer on the disk. Returns how many."""
192
+ if not self.path.exists():
193
+ return 0
194
+ with self._connect() as connection:
195
+ paths = [row[0] for row in connection.execute("SELECT path FROM figures")]
196
+ gone = [(path,) for path in paths if not Path(path).exists()]
197
+ connection.executemany("DELETE FROM figures WHERE path = ?", gone)
198
+ return len(gone)