ccsessions-tui 0.1.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 (33) hide show
  1. ccsessions_tui-0.1.0/.github/workflows/ci.yml +27 -0
  2. ccsessions_tui-0.1.0/.github/workflows/publish.yml +35 -0
  3. ccsessions_tui-0.1.0/.gitignore +23 -0
  4. ccsessions_tui-0.1.0/LICENSE +21 -0
  5. ccsessions_tui-0.1.0/PKG-INFO +157 -0
  6. ccsessions_tui-0.1.0/README.md +130 -0
  7. ccsessions_tui-0.1.0/assets/ccsessions_fullscreen.png +0 -0
  8. ccsessions_tui-0.1.0/assets/ccsessions_halfscreen.png +0 -0
  9. ccsessions_tui-0.1.0/assets/ccsessions_window.png +0 -0
  10. ccsessions_tui-0.1.0/ccsessions/__init__.py +1 -0
  11. ccsessions_tui-0.1.0/ccsessions/__main__.py +22 -0
  12. ccsessions_tui-0.1.0/ccsessions/core/__init__.py +0 -0
  13. ccsessions_tui-0.1.0/ccsessions/core/aliases.py +56 -0
  14. ccsessions_tui-0.1.0/ccsessions/core/cache.py +86 -0
  15. ccsessions_tui-0.1.0/ccsessions/core/discovery.py +186 -0
  16. ccsessions_tui-0.1.0/ccsessions/core/live.py +37 -0
  17. ccsessions_tui-0.1.0/ccsessions/core/manage.py +115 -0
  18. ccsessions_tui-0.1.0/ccsessions/core/models.py +77 -0
  19. ccsessions_tui-0.1.0/ccsessions/core/parser.py +191 -0
  20. ccsessions_tui-0.1.0/ccsessions/core/resume.py +37 -0
  21. ccsessions_tui-0.1.0/ccsessions/core/settings.py +24 -0
  22. ccsessions_tui-0.1.0/ccsessions/ui/__init__.py +0 -0
  23. ccsessions_tui-0.1.0/ccsessions/ui/app.py +1063 -0
  24. ccsessions_tui-0.1.0/pyproject.toml +51 -0
  25. ccsessions_tui-0.1.0/tests/conftest.py +67 -0
  26. ccsessions_tui-0.1.0/tests/test_aliases.py +62 -0
  27. ccsessions_tui-0.1.0/tests/test_cache.py +41 -0
  28. ccsessions_tui-0.1.0/tests/test_discovery.py +88 -0
  29. ccsessions_tui-0.1.0/tests/test_manage.py +95 -0
  30. ccsessions_tui-0.1.0/tests/test_parser.py +87 -0
  31. ccsessions_tui-0.1.0/tests/test_resume.py +17 -0
  32. ccsessions_tui-0.1.0/tests/test_settings.py +33 -0
  33. ccsessions_tui-0.1.0/tests/test_ui_filter.py +80 -0
@@ -0,0 +1,27 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ strategy:
11
+ fail-fast: false
12
+ matrix:
13
+ os: [ubuntu-latest, macos-latest]
14
+ python-version: ["3.10", "3.13"]
15
+ runs-on: ${{ matrix.os }}
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - uses: actions/setup-python@v5
19
+ with:
20
+ python-version: ${{ matrix.python-version }}
21
+ - name: Install
22
+ run: |
23
+ python -m pip install --upgrade pip
24
+ pip install -e .
25
+ pip install pytest
26
+ - name: Test
27
+ run: pytest -v
@@ -0,0 +1,35 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ build:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - uses: actions/setup-python@v5
13
+ with:
14
+ python-version: "3.12"
15
+ - name: Build distributions
16
+ run: |
17
+ python -m pip install --upgrade pip build
18
+ python -m build
19
+ - uses: actions/upload-artifact@v4
20
+ with:
21
+ name: dist
22
+ path: dist/
23
+
24
+ publish:
25
+ needs: build
26
+ runs-on: ubuntu-latest
27
+ environment: pypi
28
+ permissions:
29
+ id-token: write # required for PyPI Trusted Publishing (OIDC)
30
+ steps:
31
+ - uses: actions/download-artifact@v4
32
+ with:
33
+ name: dist
34
+ path: dist/
35
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,23 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .eggs/
8
+
9
+ # Environments
10
+ .venv/
11
+ venv/
12
+
13
+ # Tooling
14
+ .pytest_cache/
15
+ .mypy_cache/
16
+ .ruff_cache/
17
+ .coverage
18
+
19
+ # OS
20
+ .DS_Store
21
+
22
+ # Claude Code local settings
23
+ .claude/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marek Miśkiewicz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,157 @@
1
+ Metadata-Version: 2.4
2
+ Name: ccsessions-tui
3
+ Version: 0.1.0
4
+ Summary: Terminal UI for browsing, previewing, resuming and managing Claude Code sessions
5
+ Project-URL: Homepage, https://github.com/miskiewiczm/ccsessions
6
+ Project-URL: Issues, https://github.com/miskiewiczm/ccsessions/issues
7
+ Author: Marek Miśkiewicz
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: claude,claude-code,sessions,terminal,textual,tui
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: MacOS
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Terminals
23
+ Classifier: Topic :: Utilities
24
+ Requires-Python: >=3.10
25
+ Requires-Dist: textual>=0.80
26
+ Description-Content-Type: text/markdown
27
+
28
+ # ccsessions
29
+
30
+ A fast terminal UI for browsing, previewing, resuming and managing
31
+ [Claude Code](https://claude.com/claude-code) terminal sessions.
32
+
33
+ ![ccsessions in a fullscreen terminal — wide layout with a full-height conversation pane](assets/ccsessions_fullscreen.png)
34
+
35
+ ## Features
36
+
37
+ - **Project & session browser** — every Claude Code project on your machine,
38
+ newest first, with live-session indicators (●), message counts and token
39
+ usage (input / output / cache read / cache write)
40
+ - **Conversation preview** — the tail of any session rendered with roles,
41
+ Markdown (for Claude's replies), tool calls (⚙) and slash commands (⌘),
42
+ without opening Claude Code
43
+ - **One-key resume** — `r` replaces the TUI with `claude --resume <id>` in the
44
+ session's working directory; `c` copies a ready
45
+ `cd <dir> && claude --resume <id>` command to the clipboard (OSC 52)
46
+ - **Archive & delete** — move sessions out of `claude --resume` (reversibly)
47
+ or delete them for good, per session or per project, with confirmation
48
+ dialogs; sessions whose transcript lives on another synced machine are
49
+ detected and marked (✕)
50
+ - **Responsive layout** — wide terminals get a side-by-side layout with a
51
+ full-height conversation pane; narrow (half-screen) terminals get a stacked
52
+ layout with a full-width conversation pane:
53
+
54
+ <img src="assets/ccsessions_halfscreen.png" alt="ccsessions in a half-screen terminal — stacked layout with a full-width conversation pane" width="70%">
55
+ - **Fast** — token stats are cached per transcript (invalidated by
56
+ mtime + size), scanning runs off the UI thread, and conversation previews
57
+ read only the tail of multi-megabyte files
58
+
59
+ ## Requirements
60
+
61
+ - Python ≥ 3.10
62
+ - [Claude Code](https://claude.com/claude-code) installed (`claude` on PATH —
63
+ needed only for resuming)
64
+ - macOS or Linux (Windows untested)
65
+
66
+ ## Installation
67
+
68
+ ```bash
69
+ pipx install ccsessions-tui # or: uv tool install ccsessions-tui
70
+ ```
71
+
72
+ Both install the `ccsessions` command.
73
+
74
+ Or from a clone:
75
+
76
+ ```bash
77
+ git clone https://github.com/miskiewiczm/ccsessions
78
+ pip install -e ccsessions
79
+ ```
80
+
81
+ ## Usage
82
+
83
+ ```bash
84
+ ccsessions
85
+ ```
86
+
87
+ | Key | Action |
88
+ | --- | --- |
89
+ | `j` / `k` / arrows | Move within the focused pane |
90
+ | `Tab` / `Shift+Tab` | Switch panes |
91
+ | `r` | Resume the selected session (in place) |
92
+ | `c` | Copy the resume command to the clipboard |
93
+ | `a` | Archive ↔ restore session · archive ↔ restore project |
94
+ | `d` | Delete session / project (with confirmation) |
95
+ | `n` | Rename project (set a display alias) |
96
+ | `/` | Filter the focused list (projects or sessions) |
97
+ | `Ctrl+R` | Rescan `~/.claude` |
98
+ | `q` | Quit |
99
+
100
+ `a` and `d` act on the **session** when the Sessions pane is focused and on
101
+ the whole **project** when the Projects pane is focused.
102
+
103
+ Project names default to the last component of the project's working
104
+ directory. `n` sets a purely cosmetic alias (stored in
105
+ `~/.config/ccsessions/aliases.json` — nothing under `~/.claude` is touched);
106
+ submitting an empty alias removes the entry and restores the default name.
107
+
108
+ ### Configuration
109
+
110
+ - **Theme** — switch at runtime with Ctrl+P → "Change theme"; the choice is
111
+ remembered across runs (`~/.config/ccsessions/settings.json`). The
112
+ `CCSESSIONS_THEME` env var overrides it. The default `ansi-dark` follows
113
+ your terminal's palette and keeps terminal transparency; RGB themes
114
+ (`nord`, `gruvbox`, `tokyo-night`, `dracula`, …) paint opaque backgrounds.
115
+ - **Code blocks** — fenced code follows the app theme with a matching
116
+ pygments style. Set `CCSESSIONS_CODE_THEME` to pin a specific one
117
+ (any name from `pygments.styles.get_all_styles()`).
118
+ - `~/.config/ccsessions/aliases.json` — project display aliases, managed
119
+ with the `n` key.
120
+
121
+ ## How it works
122
+
123
+ ccsessions reads the data Claude Code already keeps on disk:
124
+
125
+ - `~/.claude/projects/<encoded-path>/*.jsonl` — session transcripts
126
+ - `~/.claude/projects/<encoded-path>/sessions-index.json` — session metadata
127
+ (summaries, first prompts, message counts)
128
+ - `~/.claude/sessions/*.json` — live-session records (PID liveness is checked
129
+ with signal 0)
130
+
131
+ Archiving a session moves its transcript into an `archived/` subdirectory of
132
+ the project folder (invisible to `claude --resume`, fully restorable).
133
+ Archiving a project moves the whole folder to `~/.claude/projects-archive/`;
134
+ archived projects stay listed (dimmed, ▪) after the active ones and can be
135
+ restored with `a`. Deleting removes the transcript and its index entry.
136
+ Token-stats caching lives in `~/.cache/ccsessions/`.
137
+
138
+ > **Note:** Claude Code automatically deletes transcripts older than its
139
+ > `cleanupPeriodDays` setting (30 days by default). If you rely on your
140
+ > session history, raise that value in `~/.claude/settings.json`.
141
+
142
+ ## Privacy
143
+
144
+ Everything happens locally: ccsessions only reads files under `~/.claude/`
145
+ and writes its cache under `~/.cache/ccsessions/`. It makes no network
146
+ requests and sends nothing anywhere.
147
+
148
+ ## Development
149
+
150
+ ```bash
151
+ pip install -e . --group dev
152
+ pytest
153
+ ```
154
+
155
+ ## License
156
+
157
+ [MIT](LICENSE)
@@ -0,0 +1,130 @@
1
+ # ccsessions
2
+
3
+ A fast terminal UI for browsing, previewing, resuming and managing
4
+ [Claude Code](https://claude.com/claude-code) terminal sessions.
5
+
6
+ ![ccsessions in a fullscreen terminal — wide layout with a full-height conversation pane](assets/ccsessions_fullscreen.png)
7
+
8
+ ## Features
9
+
10
+ - **Project & session browser** — every Claude Code project on your machine,
11
+ newest first, with live-session indicators (●), message counts and token
12
+ usage (input / output / cache read / cache write)
13
+ - **Conversation preview** — the tail of any session rendered with roles,
14
+ Markdown (for Claude's replies), tool calls (⚙) and slash commands (⌘),
15
+ without opening Claude Code
16
+ - **One-key resume** — `r` replaces the TUI with `claude --resume <id>` in the
17
+ session's working directory; `c` copies a ready
18
+ `cd <dir> && claude --resume <id>` command to the clipboard (OSC 52)
19
+ - **Archive & delete** — move sessions out of `claude --resume` (reversibly)
20
+ or delete them for good, per session or per project, with confirmation
21
+ dialogs; sessions whose transcript lives on another synced machine are
22
+ detected and marked (✕)
23
+ - **Responsive layout** — wide terminals get a side-by-side layout with a
24
+ full-height conversation pane; narrow (half-screen) terminals get a stacked
25
+ layout with a full-width conversation pane:
26
+
27
+ <img src="assets/ccsessions_halfscreen.png" alt="ccsessions in a half-screen terminal — stacked layout with a full-width conversation pane" width="70%">
28
+ - **Fast** — token stats are cached per transcript (invalidated by
29
+ mtime + size), scanning runs off the UI thread, and conversation previews
30
+ read only the tail of multi-megabyte files
31
+
32
+ ## Requirements
33
+
34
+ - Python ≥ 3.10
35
+ - [Claude Code](https://claude.com/claude-code) installed (`claude` on PATH —
36
+ needed only for resuming)
37
+ - macOS or Linux (Windows untested)
38
+
39
+ ## Installation
40
+
41
+ ```bash
42
+ pipx install ccsessions-tui # or: uv tool install ccsessions-tui
43
+ ```
44
+
45
+ Both install the `ccsessions` command.
46
+
47
+ Or from a clone:
48
+
49
+ ```bash
50
+ git clone https://github.com/miskiewiczm/ccsessions
51
+ pip install -e ccsessions
52
+ ```
53
+
54
+ ## Usage
55
+
56
+ ```bash
57
+ ccsessions
58
+ ```
59
+
60
+ | Key | Action |
61
+ | --- | --- |
62
+ | `j` / `k` / arrows | Move within the focused pane |
63
+ | `Tab` / `Shift+Tab` | Switch panes |
64
+ | `r` | Resume the selected session (in place) |
65
+ | `c` | Copy the resume command to the clipboard |
66
+ | `a` | Archive ↔ restore session · archive ↔ restore project |
67
+ | `d` | Delete session / project (with confirmation) |
68
+ | `n` | Rename project (set a display alias) |
69
+ | `/` | Filter the focused list (projects or sessions) |
70
+ | `Ctrl+R` | Rescan `~/.claude` |
71
+ | `q` | Quit |
72
+
73
+ `a` and `d` act on the **session** when the Sessions pane is focused and on
74
+ the whole **project** when the Projects pane is focused.
75
+
76
+ Project names default to the last component of the project's working
77
+ directory. `n` sets a purely cosmetic alias (stored in
78
+ `~/.config/ccsessions/aliases.json` — nothing under `~/.claude` is touched);
79
+ submitting an empty alias removes the entry and restores the default name.
80
+
81
+ ### Configuration
82
+
83
+ - **Theme** — switch at runtime with Ctrl+P → "Change theme"; the choice is
84
+ remembered across runs (`~/.config/ccsessions/settings.json`). The
85
+ `CCSESSIONS_THEME` env var overrides it. The default `ansi-dark` follows
86
+ your terminal's palette and keeps terminal transparency; RGB themes
87
+ (`nord`, `gruvbox`, `tokyo-night`, `dracula`, …) paint opaque backgrounds.
88
+ - **Code blocks** — fenced code follows the app theme with a matching
89
+ pygments style. Set `CCSESSIONS_CODE_THEME` to pin a specific one
90
+ (any name from `pygments.styles.get_all_styles()`).
91
+ - `~/.config/ccsessions/aliases.json` — project display aliases, managed
92
+ with the `n` key.
93
+
94
+ ## How it works
95
+
96
+ ccsessions reads the data Claude Code already keeps on disk:
97
+
98
+ - `~/.claude/projects/<encoded-path>/*.jsonl` — session transcripts
99
+ - `~/.claude/projects/<encoded-path>/sessions-index.json` — session metadata
100
+ (summaries, first prompts, message counts)
101
+ - `~/.claude/sessions/*.json` — live-session records (PID liveness is checked
102
+ with signal 0)
103
+
104
+ Archiving a session moves its transcript into an `archived/` subdirectory of
105
+ the project folder (invisible to `claude --resume`, fully restorable).
106
+ Archiving a project moves the whole folder to `~/.claude/projects-archive/`;
107
+ archived projects stay listed (dimmed, ▪) after the active ones and can be
108
+ restored with `a`. Deleting removes the transcript and its index entry.
109
+ Token-stats caching lives in `~/.cache/ccsessions/`.
110
+
111
+ > **Note:** Claude Code automatically deletes transcripts older than its
112
+ > `cleanupPeriodDays` setting (30 days by default). If you rely on your
113
+ > session history, raise that value in `~/.claude/settings.json`.
114
+
115
+ ## Privacy
116
+
117
+ Everything happens locally: ccsessions only reads files under `~/.claude/`
118
+ and writes its cache under `~/.cache/ccsessions/`. It makes no network
119
+ requests and sends nothing anywhere.
120
+
121
+ ## Development
122
+
123
+ ```bash
124
+ pip install -e . --group dev
125
+ pytest
126
+ ```
127
+
128
+ ## License
129
+
130
+ [MIT](LICENSE)
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ from .core.resume import ResumeError, ResumeRequest, execute_resume
6
+ from .ui.app import CCSessionsApp
7
+
8
+
9
+ def main() -> int:
10
+ app = CCSessionsApp()
11
+ result = app.run()
12
+ if isinstance(result, ResumeRequest):
13
+ try:
14
+ execute_resume(result) # replaces process; does not return on success
15
+ except ResumeError as e:
16
+ print(f"Resume failed: {e}", file=sys.stderr)
17
+ return 1
18
+ return 0
19
+
20
+
21
+ if __name__ == "__main__":
22
+ sys.exit(main())
File without changes
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from .models import Project
7
+
8
+ DEFAULT_ALIASES_PATH = Path.home() / ".config" / "ccsessions" / "aliases.json"
9
+
10
+
11
+ class ProjectAliases:
12
+ """Cosmetic display names for projects, keyed by project path.
13
+
14
+ Purely a ccsessions-side overlay — nothing under ~/.claude is touched,
15
+ so the default (path-derived) name is always recoverable by removing
16
+ the alias.
17
+ """
18
+
19
+ def __init__(self, path: Path = DEFAULT_ALIASES_PATH) -> None:
20
+ self.path = path
21
+ self._data: dict[str, str] = {}
22
+ self._load()
23
+
24
+ def _load(self) -> None:
25
+ try:
26
+ data = json.loads(self.path.read_text(encoding="utf-8"))
27
+ except (OSError, json.JSONDecodeError):
28
+ return
29
+ if isinstance(data, dict):
30
+ self._data = {
31
+ k: v for k, v in data.items() if isinstance(k, str) and isinstance(v, str)
32
+ }
33
+
34
+ def get(self, project_path: str) -> str:
35
+ return self._data.get(project_path, "")
36
+
37
+ def set(self, project_path: str, alias: str) -> None:
38
+ self._data[project_path] = alias
39
+
40
+ def remove(self, project_path: str) -> None:
41
+ self._data.pop(project_path, None)
42
+
43
+ def save(self) -> None:
44
+ try:
45
+ self.path.parent.mkdir(parents=True, exist_ok=True)
46
+ tmp = self.path.with_suffix(".tmp")
47
+ tmp.write_text(
48
+ json.dumps(self._data, indent=2, ensure_ascii=False), encoding="utf-8"
49
+ )
50
+ tmp.replace(self.path)
51
+ except OSError:
52
+ pass
53
+
54
+ def apply(self, projects: list[Project]) -> None:
55
+ for p in projects:
56
+ p.alias = self.get(p.project_path)
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+
7
+ from .models import TokenStats
8
+
9
+ DEFAULT_CACHE_PATH = Path.home() / ".cache" / "ccsessions" / "token-stats.json"
10
+
11
+
12
+ class TokenCache:
13
+ """Per-JSONL-file token stats cache.
14
+
15
+ An entry stays valid as long as the file's (st_mtime_ns, st_size) are
16
+ unchanged — large finished sessions are read from disk only once.
17
+ """
18
+
19
+ def __init__(self, path: Path = DEFAULT_CACHE_PATH) -> None:
20
+ self.path = path
21
+ self._entries: dict[str, dict] = {}
22
+ self._dirty = False
23
+ self._load()
24
+
25
+ def _load(self) -> None:
26
+ try:
27
+ data = json.loads(self.path.read_text(encoding="utf-8"))
28
+ except (OSError, json.JSONDecodeError):
29
+ return
30
+ if isinstance(data, dict):
31
+ self._entries = {
32
+ k: v for k, v in data.items() if isinstance(v, dict)
33
+ }
34
+
35
+ def get(self, jsonl: Path, stat: os.stat_result | None = None) -> TokenStats | None:
36
+ entry = self._entries.get(str(jsonl))
37
+ if not entry:
38
+ return None
39
+ if stat is None:
40
+ try:
41
+ stat = jsonl.stat()
42
+ except OSError:
43
+ return None
44
+ if entry.get("mtime_ns") != stat.st_mtime_ns or entry.get("size") != stat.st_size:
45
+ return None
46
+ try:
47
+ return TokenStats(
48
+ input=int(entry["input"]),
49
+ output=int(entry["output"]),
50
+ cache_read=int(entry["cache_read"]),
51
+ cache_write=int(entry["cache_write"]),
52
+ messages=int(entry["messages"]),
53
+ )
54
+ except (KeyError, TypeError, ValueError):
55
+ return None
56
+
57
+ def put(self, jsonl: Path, stats: TokenStats, stat: os.stat_result) -> None:
58
+ self._entries[str(jsonl)] = {
59
+ "mtime_ns": stat.st_mtime_ns,
60
+ "size": stat.st_size,
61
+ "input": stats.input,
62
+ "output": stats.output,
63
+ "cache_read": stats.cache_read,
64
+ "cache_write": stats.cache_write,
65
+ "messages": stats.messages,
66
+ }
67
+ self._dirty = True
68
+
69
+ def prune(self, existing: set[str]) -> None:
70
+ """Drop entries for files that no longer exist on disk."""
71
+ stale = [k for k in self._entries if k not in existing]
72
+ for k in stale:
73
+ del self._entries[k]
74
+ self._dirty = True
75
+
76
+ def save(self) -> None:
77
+ if not self._dirty:
78
+ return
79
+ try:
80
+ self.path.parent.mkdir(parents=True, exist_ok=True)
81
+ tmp = self.path.with_suffix(".tmp")
82
+ tmp.write_text(json.dumps(self._entries), encoding="utf-8")
83
+ tmp.replace(self.path)
84
+ self._dirty = False
85
+ except OSError:
86
+ pass