claude-code-log 0.3.2__tar.gz → 0.3.4__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.
- claude_code_log-0.3.2/.claude/settings.json.bak → claude_code_log-0.3.4/.claude/settings.json +4 -4
- claude_code_log-0.3.4/.github/workflows/claude_code.yml +46 -0
- claude_code_log-0.3.4/.github/workflows/claude_code_login.yml +21 -0
- {claude_code_log-0.3.2 → claude_code_log-0.3.4}/.gitignore +1 -0
- {claude_code_log-0.3.2 → claude_code_log-0.3.4}/CHANGELOG.md +18 -0
- {claude_code_log-0.3.2 → claude_code_log-0.3.4}/PKG-INFO +7 -1
- {claude_code_log-0.3.2 → claude_code_log-0.3.4}/README.md +5 -0
- claude_code_log-0.3.4/claude_code_log/cache.py +449 -0
- {claude_code_log-0.3.2 → claude_code_log-0.3.4}/claude_code_log/cli.py +67 -2
- claude_code_log-0.3.4/claude_code_log/converter.py +626 -0
- {claude_code_log-0.3.2 → claude_code_log-0.3.4}/claude_code_log/models.py +23 -1
- {claude_code_log-0.3.2 → claude_code_log-0.3.4}/claude_code_log/parser.py +37 -5
- {claude_code_log-0.3.2 → claude_code_log-0.3.4}/claude_code_log/templates/components/timeline.html +34 -37
- {claude_code_log-0.3.2 → claude_code_log-0.3.4}/claude_code_log/templates/components/timeline_styles.css +23 -5
- {claude_code_log-0.3.2 → claude_code_log-0.3.4}/claude_code_log/templates/transcript.html +6 -2
- {claude_code_log-0.3.2 → claude_code_log-0.3.4}/justfile +7 -3
- {claude_code_log-0.3.2 → claude_code_log-0.3.4}/pyproject.toml +2 -1
- {claude_code_log-0.3.2 → claude_code_log-0.3.4}/uv.lock +12 -1
- claude_code_log-0.3.2/claude_code_log/converter.py +0 -346
- {claude_code_log-0.3.2 → claude_code_log-0.3.4}/.claude/settings.local.json +0 -0
- {claude_code_log-0.3.2 → claude_code_log-0.3.4}/.github/workflows/ci.yml +0 -0
- {claude_code_log-0.3.2 → claude_code_log-0.3.4}/.github/workflows/docs.yml +0 -0
- {claude_code_log-0.3.2 → claude_code_log-0.3.4}/CLAUDE.md +0 -0
- {claude_code_log-0.3.2 → claude_code_log-0.3.4}/LICENSE +0 -0
- {claude_code_log-0.3.2 → claude_code_log-0.3.4}/claude_code_log/__init__.py +0 -0
- {claude_code_log-0.3.2 → claude_code_log-0.3.4}/claude_code_log/py.typed +0 -0
- {claude_code_log-0.3.2 → claude_code_log-0.3.4}/claude_code_log/renderer.py +0 -0
- {claude_code_log-0.3.2 → claude_code_log-0.3.4}/claude_code_log/templates/components/filter_styles.css +0 -0
- {claude_code_log-0.3.2 → claude_code_log-0.3.4}/claude_code_log/templates/components/global_styles.css +0 -0
- {claude_code_log-0.3.2 → claude_code_log-0.3.4}/claude_code_log/templates/components/message_styles.css +0 -0
- {claude_code_log-0.3.2 → claude_code_log-0.3.4}/claude_code_log/templates/components/project_card_styles.css +0 -0
- {claude_code_log-0.3.2 → claude_code_log-0.3.4}/claude_code_log/templates/components/session_nav.html +0 -0
- {claude_code_log-0.3.2 → claude_code_log-0.3.4}/claude_code_log/templates/components/session_nav_styles.css +0 -0
- {claude_code_log-0.3.2 → claude_code_log-0.3.4}/claude_code_log/templates/components/todo_styles.css +0 -0
- {claude_code_log-0.3.2 → claude_code_log-0.3.4}/claude_code_log/templates/index.html +0 -0
- {claude_code_log-0.3.2 → claude_code_log-0.3.4}/claude_code_log/utils.py +0 -0
claude_code_log-0.3.2/.claude/settings.json.bak → claude_code_log-0.3.4/.claude/settings.json
RENAMED
|
@@ -17,19 +17,19 @@
|
|
|
17
17
|
"hooks": [
|
|
18
18
|
{
|
|
19
19
|
"type": "command",
|
|
20
|
-
"command": "uv run ruff check --fix"
|
|
20
|
+
"command": "uv run ruff check --fix 2>&1"
|
|
21
21
|
},
|
|
22
22
|
{
|
|
23
23
|
"type": "command",
|
|
24
|
-
"command": "uv run ty check"
|
|
24
|
+
"command": "uv run ty check 2>&1"
|
|
25
25
|
},
|
|
26
26
|
{
|
|
27
27
|
"type": "command",
|
|
28
|
-
"command": "uv run pyright"
|
|
28
|
+
"command": "uv run pyright 2>&1"
|
|
29
29
|
},
|
|
30
30
|
{
|
|
31
31
|
"type": "command",
|
|
32
|
-
"command": "uv run pytest"
|
|
32
|
+
"command": "uv run pytest 2>&1"
|
|
33
33
|
}
|
|
34
34
|
]
|
|
35
35
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
name: Claude PR Assistant - Authorized Users Only
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
issue_comment:
|
|
5
|
+
types: [created]
|
|
6
|
+
pull_request_review_comment:
|
|
7
|
+
types: [created]
|
|
8
|
+
issues:
|
|
9
|
+
types: [opened, assigned]
|
|
10
|
+
pull_request_review:
|
|
11
|
+
types: [submitted]
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
claude-code-action:
|
|
15
|
+
# Only respond to @claude mentions from daaain
|
|
16
|
+
if: |
|
|
17
|
+
(
|
|
18
|
+
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
|
19
|
+
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
|
20
|
+
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
|
21
|
+
(github.event_name == 'issues' && contains(github.event.issue.body, '@claude'))
|
|
22
|
+
) && github.actor == 'daaain'
|
|
23
|
+
runs-on: ubuntu-latest
|
|
24
|
+
permissions:
|
|
25
|
+
contents: read
|
|
26
|
+
pull-requests: read
|
|
27
|
+
issues: read
|
|
28
|
+
id-token: write
|
|
29
|
+
actions: write
|
|
30
|
+
steps:
|
|
31
|
+
- name: Checkout repository
|
|
32
|
+
uses: actions/checkout@v4
|
|
33
|
+
with:
|
|
34
|
+
fetch-depth: 1
|
|
35
|
+
|
|
36
|
+
- name: Run Claude PR Action
|
|
37
|
+
uses: grll/claude-code-action@beta
|
|
38
|
+
with:
|
|
39
|
+
use_oauth: true
|
|
40
|
+
claude_access_token: ${{ secrets.CLAUDE_ACCESS_TOKEN }}
|
|
41
|
+
claude_refresh_token: ${{ secrets.CLAUDE_REFRESH_TOKEN }}
|
|
42
|
+
claude_expires_at: ${{ secrets.CLAUDE_EXPIRES_AT }}
|
|
43
|
+
secrets_admin_pat: ${{ secrets.SECRETS_ADMIN_PAT }}
|
|
44
|
+
timeout_minutes: "120"
|
|
45
|
+
allowed_tools: "Bash,Edit,MultiEdit,View,GlobTool,Glob,GrepTool,Grep,BatchTool,Batch,LS,Read,Write,Replace,NotebookEditCell,mcp__github_file_ops__commit_files,mcp__github_file_ops__delete_files,mcp__github_file_ops__update_claude_comment,WebSearch,WebFetch"
|
|
46
|
+
max_turns: "500"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
name: Claude OAuth
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
inputs:
|
|
6
|
+
code:
|
|
7
|
+
description: 'Authorization code (leave empty for step 1)'
|
|
8
|
+
required: false
|
|
9
|
+
|
|
10
|
+
permissions:
|
|
11
|
+
actions: write # Required for cache management
|
|
12
|
+
contents: read # Required for basic repository access
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
auth:
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
steps:
|
|
18
|
+
- uses: grll/claude-code-login@v1
|
|
19
|
+
with:
|
|
20
|
+
code: ${{ inputs.code }}
|
|
21
|
+
secrets_admin_pat: ${{ secrets.SECRETS_ADMIN_PAT }}
|
|
@@ -6,6 +6,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
8
|
|
|
9
|
+
## [0.3.4] - 2025-07-13
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- **Implement caching (writes processed JSON files into .claude project directories)**
|
|
14
|
+
- **Extend ToolUseResult to handle List[ContentItem] to support MCP tool results**
|
|
15
|
+
- **Power to Claude**
|
|
16
|
+
- **Add Claude Code OAuth workflows**
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
## [0.3.3] - 2025-07-05
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- **Hide groups in the timeline instead of items + bug fixes**
|
|
24
|
+
- **Get tooltip config working + improve rendering and styling**
|
|
25
|
+
|
|
26
|
+
|
|
9
27
|
## [0.3.2] - 2025-07-03
|
|
10
28
|
|
|
11
29
|
### Changed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: claude-code-log
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
4
4
|
Summary: Convert Claude Code transcript JSONL files to HTML
|
|
5
5
|
Project-URL: Homepage, https://github.com/daaain/claude-code-log
|
|
6
6
|
Project-URL: Issues, https://github.com/daaain/claude-code-log/issues
|
|
@@ -16,6 +16,7 @@ Requires-Dist: dateparser>=1.0.0
|
|
|
16
16
|
Requires-Dist: jinja2>=3.0.0
|
|
17
17
|
Requires-Dist: mistune>=3.1.3
|
|
18
18
|
Requires-Dist: pydantic>=2.0.0
|
|
19
|
+
Requires-Dist: toml>=0.10.2
|
|
19
20
|
Description-Content-Type: text/markdown
|
|
20
21
|
|
|
21
22
|
# Claude Code Log
|
|
@@ -273,6 +274,8 @@ uv run claude-code-log
|
|
|
273
274
|
|
|
274
275
|
## TODO
|
|
275
276
|
|
|
277
|
+
- add a bit of padding to the last message time so the timeline doesn't look empty when opening
|
|
278
|
+
- tutorial overlay
|
|
276
279
|
- integrate `claude-trace` request logs if present?
|
|
277
280
|
- Shortcut / command to resume a specific conversation by session ID $ claude --resume 550e8400-e29b-41d4-a716-446655440000?
|
|
278
281
|
- Localised number formatting and timezone adjustment runtime? For this we'd need to make Jinja template variables more granular
|
|
@@ -285,3 +288,6 @@ uv run claude-code-log
|
|
|
285
288
|
- system blocks like `init` also don't render perfectly, losing new lines
|
|
286
289
|
- add `ccusage` like daily summary and maybe some textual summary too based on Claude generate session summaries?
|
|
287
290
|
– import logs from @claude Github Actions
|
|
291
|
+
- stream logs from @claude Github Actions, see [octotail](https://github.com/getbettr/octotail)
|
|
292
|
+
- extend into a VS Code extension that reads the JSONL real-time and displays stats like current context usage and implements a UI to see messages, todos, permissions, config, MCP status, etc
|
|
293
|
+
- feed the filtered user messages to headless claude CLI to distill the user intent from the session
|
|
@@ -253,6 +253,8 @@ uv run claude-code-log
|
|
|
253
253
|
|
|
254
254
|
## TODO
|
|
255
255
|
|
|
256
|
+
- add a bit of padding to the last message time so the timeline doesn't look empty when opening
|
|
257
|
+
- tutorial overlay
|
|
256
258
|
- integrate `claude-trace` request logs if present?
|
|
257
259
|
- Shortcut / command to resume a specific conversation by session ID $ claude --resume 550e8400-e29b-41d4-a716-446655440000?
|
|
258
260
|
- Localised number formatting and timezone adjustment runtime? For this we'd need to make Jinja template variables more granular
|
|
@@ -265,3 +267,6 @@ uv run claude-code-log
|
|
|
265
267
|
- system blocks like `init` also don't render perfectly, losing new lines
|
|
266
268
|
- add `ccusage` like daily summary and maybe some textual summary too based on Claude generate session summaries?
|
|
267
269
|
– import logs from @claude Github Actions
|
|
270
|
+
- stream logs from @claude Github Actions, see [octotail](https://github.com/getbettr/octotail)
|
|
271
|
+
- extend into a VS Code extension that reads the JSONL real-time and displays stats like current context usage and implements a UI to see messages, todos, permissions, config, MCP status, etc
|
|
272
|
+
- feed the filtered user messages to headless claude CLI to distill the user intent from the session
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Cache management for Claude Code Log to improve performance."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, List, Optional, cast
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from .models import TranscriptEntry
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CachedFileInfo(BaseModel):
|
|
14
|
+
"""Information about a cached JSONL file."""
|
|
15
|
+
|
|
16
|
+
file_path: str
|
|
17
|
+
source_mtime: float
|
|
18
|
+
cached_mtime: float
|
|
19
|
+
message_count: int
|
|
20
|
+
session_ids: List[str]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SessionCacheData(BaseModel):
|
|
24
|
+
"""Cached session-level information."""
|
|
25
|
+
|
|
26
|
+
session_id: str
|
|
27
|
+
summary: Optional[str] = None
|
|
28
|
+
first_timestamp: str
|
|
29
|
+
last_timestamp: str
|
|
30
|
+
message_count: int
|
|
31
|
+
first_user_message: str
|
|
32
|
+
total_input_tokens: int = 0
|
|
33
|
+
total_output_tokens: int = 0
|
|
34
|
+
total_cache_creation_tokens: int = 0
|
|
35
|
+
total_cache_read_tokens: int = 0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ProjectCache(BaseModel):
|
|
39
|
+
"""Project-level cache index structure for index.json."""
|
|
40
|
+
|
|
41
|
+
version: str
|
|
42
|
+
cache_created: str
|
|
43
|
+
last_updated: str
|
|
44
|
+
project_path: str
|
|
45
|
+
|
|
46
|
+
# File-level cache information
|
|
47
|
+
cached_files: Dict[str, CachedFileInfo]
|
|
48
|
+
|
|
49
|
+
# Aggregated project information
|
|
50
|
+
total_message_count: int = 0
|
|
51
|
+
total_input_tokens: int = 0
|
|
52
|
+
total_output_tokens: int = 0
|
|
53
|
+
total_cache_creation_tokens: int = 0
|
|
54
|
+
total_cache_read_tokens: int = 0
|
|
55
|
+
|
|
56
|
+
# Session metadata
|
|
57
|
+
sessions: Dict[str, SessionCacheData]
|
|
58
|
+
|
|
59
|
+
# Timeline information
|
|
60
|
+
earliest_timestamp: str = ""
|
|
61
|
+
latest_timestamp: str = ""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class CacheManager:
|
|
65
|
+
"""Manages cache operations for a project directory."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, project_path: Path, library_version: str):
|
|
68
|
+
"""Initialize cache manager for a project.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
project_path: Path to the project directory containing JSONL files
|
|
72
|
+
library_version: Current version of the library for cache invalidation
|
|
73
|
+
"""
|
|
74
|
+
self.project_path = project_path
|
|
75
|
+
self.library_version = library_version
|
|
76
|
+
self.cache_dir = project_path / "cache"
|
|
77
|
+
self.index_file = self.cache_dir / "index.json"
|
|
78
|
+
|
|
79
|
+
# Ensure cache directory exists
|
|
80
|
+
self.cache_dir.mkdir(exist_ok=True)
|
|
81
|
+
|
|
82
|
+
# Load existing cache index if available
|
|
83
|
+
self._project_cache: Optional[ProjectCache] = None
|
|
84
|
+
self._load_project_cache()
|
|
85
|
+
|
|
86
|
+
def _load_project_cache(self) -> None:
|
|
87
|
+
"""Load the project cache index from disk."""
|
|
88
|
+
if self.index_file.exists():
|
|
89
|
+
try:
|
|
90
|
+
with open(self.index_file, "r", encoding="utf-8") as f:
|
|
91
|
+
cache_data = json.load(f)
|
|
92
|
+
self._project_cache = ProjectCache.model_validate(cache_data)
|
|
93
|
+
|
|
94
|
+
# Check if cache version matches current library version
|
|
95
|
+
if self._project_cache.version != self.library_version:
|
|
96
|
+
print(
|
|
97
|
+
f"Cache version mismatch: {self._project_cache.version} != {self.library_version}, invalidating cache"
|
|
98
|
+
)
|
|
99
|
+
self.clear_cache()
|
|
100
|
+
self._project_cache = None
|
|
101
|
+
except Exception as e:
|
|
102
|
+
print(f"Warning: Failed to load cache index, will rebuild: {e}")
|
|
103
|
+
self._project_cache = None
|
|
104
|
+
|
|
105
|
+
# Initialize empty cache if none exists
|
|
106
|
+
if self._project_cache is None:
|
|
107
|
+
self._project_cache = ProjectCache(
|
|
108
|
+
version=self.library_version,
|
|
109
|
+
cache_created=datetime.now().isoformat(),
|
|
110
|
+
last_updated=datetime.now().isoformat(),
|
|
111
|
+
project_path=str(self.project_path),
|
|
112
|
+
cached_files={},
|
|
113
|
+
sessions={},
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def _save_project_cache(self) -> None:
|
|
117
|
+
"""Save the project cache index to disk."""
|
|
118
|
+
if self._project_cache is None:
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
self._project_cache.last_updated = datetime.now().isoformat()
|
|
122
|
+
|
|
123
|
+
with open(self.index_file, "w", encoding="utf-8") as f:
|
|
124
|
+
json.dump(self._project_cache.model_dump(), f, indent=2)
|
|
125
|
+
|
|
126
|
+
def _get_cache_file_path(self, jsonl_path: Path) -> Path:
|
|
127
|
+
"""Get the cache file path for a given JSONL file."""
|
|
128
|
+
return self.cache_dir / f"{jsonl_path.stem}.json"
|
|
129
|
+
|
|
130
|
+
def is_file_cached(self, jsonl_path: Path) -> bool:
|
|
131
|
+
"""Check if a JSONL file has a valid cache entry."""
|
|
132
|
+
if self._project_cache is None:
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
file_key = jsonl_path.name
|
|
136
|
+
if file_key not in self._project_cache.cached_files:
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
# Check if source file exists and modification time matches
|
|
140
|
+
if not jsonl_path.exists():
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
cached_info = self._project_cache.cached_files[file_key]
|
|
144
|
+
source_mtime = jsonl_path.stat().st_mtime
|
|
145
|
+
|
|
146
|
+
# Cache is valid if modification times match and cache file exists
|
|
147
|
+
cache_file = self._get_cache_file_path(jsonl_path)
|
|
148
|
+
return (
|
|
149
|
+
abs(source_mtime - cached_info.source_mtime) < 1.0 and cache_file.exists()
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def load_cached_entries(self, jsonl_path: Path) -> Optional[List[TranscriptEntry]]:
|
|
153
|
+
"""Load cached transcript entries for a JSONL file."""
|
|
154
|
+
if not self.is_file_cached(jsonl_path):
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
cache_file = self._get_cache_file_path(jsonl_path)
|
|
158
|
+
try:
|
|
159
|
+
with open(cache_file, "r", encoding="utf-8") as f:
|
|
160
|
+
cache_data = json.load(f)
|
|
161
|
+
|
|
162
|
+
# Expect timestamp-keyed format - flatten all entries
|
|
163
|
+
entries_data: List[Dict[str, Any]] = []
|
|
164
|
+
for timestamp_entries in cache_data.values():
|
|
165
|
+
if isinstance(timestamp_entries, list):
|
|
166
|
+
# Type cast to ensure Pyright knows this is List[Dict[str, Any]]
|
|
167
|
+
entries_data.extend(cast(List[Dict[str, Any]], timestamp_entries))
|
|
168
|
+
|
|
169
|
+
# Deserialize back to TranscriptEntry objects
|
|
170
|
+
from .models import parse_transcript_entry
|
|
171
|
+
|
|
172
|
+
entries = [
|
|
173
|
+
parse_transcript_entry(entry_dict) for entry_dict in entries_data
|
|
174
|
+
]
|
|
175
|
+
return entries
|
|
176
|
+
except Exception as e:
|
|
177
|
+
print(f"Warning: Failed to load cached entries from {cache_file}: {e}")
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
def load_cached_entries_filtered(
|
|
181
|
+
self, jsonl_path: Path, from_date: Optional[str], to_date: Optional[str]
|
|
182
|
+
) -> Optional[List[TranscriptEntry]]:
|
|
183
|
+
"""Load cached entries with efficient timestamp-based filtering."""
|
|
184
|
+
if not self.is_file_cached(jsonl_path):
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
cache_file = self._get_cache_file_path(jsonl_path)
|
|
188
|
+
try:
|
|
189
|
+
with open(cache_file, "r", encoding="utf-8") as f:
|
|
190
|
+
cache_data = json.load(f)
|
|
191
|
+
|
|
192
|
+
# If no date filtering needed, fall back to regular loading
|
|
193
|
+
if not from_date and not to_date:
|
|
194
|
+
return self.load_cached_entries(jsonl_path)
|
|
195
|
+
|
|
196
|
+
# Parse date filters
|
|
197
|
+
from .parser import parse_timestamp
|
|
198
|
+
import dateparser
|
|
199
|
+
|
|
200
|
+
from_dt = None
|
|
201
|
+
to_dt = None
|
|
202
|
+
|
|
203
|
+
if from_date:
|
|
204
|
+
from_dt = dateparser.parse(from_date)
|
|
205
|
+
if from_dt and (
|
|
206
|
+
from_date in ["today", "yesterday"] or "days ago" in from_date
|
|
207
|
+
):
|
|
208
|
+
from_dt = from_dt.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
209
|
+
|
|
210
|
+
if to_date:
|
|
211
|
+
to_dt = dateparser.parse(to_date)
|
|
212
|
+
if to_dt:
|
|
213
|
+
if to_date in ["today", "yesterday"] or "days ago" in to_date:
|
|
214
|
+
to_dt = to_dt.replace(
|
|
215
|
+
hour=23, minute=59, second=59, microsecond=999999
|
|
216
|
+
)
|
|
217
|
+
else:
|
|
218
|
+
# For simple date strings like "2023-01-01", set to end of day
|
|
219
|
+
to_dt = to_dt.replace(
|
|
220
|
+
hour=23, minute=59, second=59, microsecond=999999
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# Filter entries by timestamp
|
|
224
|
+
filtered_entries_data: List[Dict[str, Any]] = []
|
|
225
|
+
|
|
226
|
+
for timestamp_key, timestamp_entries in cache_data.items():
|
|
227
|
+
if timestamp_key == "_no_timestamp":
|
|
228
|
+
# Always include entries without timestamps (like summaries)
|
|
229
|
+
if isinstance(timestamp_entries, list):
|
|
230
|
+
# Type cast to ensure Pyright knows this is List[Dict[str, Any]]
|
|
231
|
+
filtered_entries_data.extend(
|
|
232
|
+
cast(List[Dict[str, Any]], timestamp_entries)
|
|
233
|
+
)
|
|
234
|
+
else:
|
|
235
|
+
# Check if timestamp falls within range
|
|
236
|
+
message_dt = parse_timestamp(timestamp_key)
|
|
237
|
+
if message_dt:
|
|
238
|
+
# Convert to naive datetime for comparison
|
|
239
|
+
if message_dt.tzinfo:
|
|
240
|
+
message_dt = message_dt.replace(tzinfo=None)
|
|
241
|
+
|
|
242
|
+
# Apply date filtering
|
|
243
|
+
if from_dt and message_dt < from_dt:
|
|
244
|
+
continue
|
|
245
|
+
if to_dt and message_dt > to_dt:
|
|
246
|
+
continue
|
|
247
|
+
|
|
248
|
+
if isinstance(timestamp_entries, list):
|
|
249
|
+
# Type cast to ensure Pyright knows this is List[Dict[str, Any]]
|
|
250
|
+
filtered_entries_data.extend(
|
|
251
|
+
cast(List[Dict[str, Any]], timestamp_entries)
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# Deserialize filtered entries
|
|
255
|
+
from .models import parse_transcript_entry
|
|
256
|
+
|
|
257
|
+
entries = [
|
|
258
|
+
parse_transcript_entry(entry_dict)
|
|
259
|
+
for entry_dict in filtered_entries_data
|
|
260
|
+
]
|
|
261
|
+
return entries
|
|
262
|
+
except Exception as e:
|
|
263
|
+
print(
|
|
264
|
+
f"Warning: Failed to load filtered cached entries from {cache_file}: {e}"
|
|
265
|
+
)
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
def save_cached_entries(
|
|
269
|
+
self, jsonl_path: Path, entries: List[TranscriptEntry]
|
|
270
|
+
) -> None:
|
|
271
|
+
"""Save parsed transcript entries to cache with timestamp-based structure."""
|
|
272
|
+
cache_file = self._get_cache_file_path(jsonl_path)
|
|
273
|
+
|
|
274
|
+
try:
|
|
275
|
+
# Create timestamp-keyed cache structure for efficient date filtering
|
|
276
|
+
cache_data: Dict[str, Any] = {}
|
|
277
|
+
|
|
278
|
+
for entry in entries:
|
|
279
|
+
# Get timestamp - use empty string as fallback for entries without timestamps
|
|
280
|
+
timestamp = (
|
|
281
|
+
getattr(entry, "timestamp", "")
|
|
282
|
+
if hasattr(entry, "timestamp")
|
|
283
|
+
else ""
|
|
284
|
+
)
|
|
285
|
+
if not timestamp:
|
|
286
|
+
# Use a special key for entries without timestamps (like summaries)
|
|
287
|
+
timestamp = "_no_timestamp"
|
|
288
|
+
|
|
289
|
+
# Store entry data under timestamp
|
|
290
|
+
if timestamp not in cache_data:
|
|
291
|
+
cache_data[timestamp] = []
|
|
292
|
+
|
|
293
|
+
cache_data[timestamp].append(entry.model_dump())
|
|
294
|
+
|
|
295
|
+
with open(cache_file, "w", encoding="utf-8") as f:
|
|
296
|
+
json.dump(cache_data, f, indent=2)
|
|
297
|
+
|
|
298
|
+
# Update cache index
|
|
299
|
+
if self._project_cache is not None:
|
|
300
|
+
source_mtime = jsonl_path.stat().st_mtime
|
|
301
|
+
cached_mtime = cache_file.stat().st_mtime
|
|
302
|
+
|
|
303
|
+
# Extract session IDs from entries
|
|
304
|
+
session_ids: List[str] = []
|
|
305
|
+
for entry in entries:
|
|
306
|
+
if hasattr(entry, "sessionId"):
|
|
307
|
+
session_id = getattr(entry, "sessionId", "")
|
|
308
|
+
if session_id:
|
|
309
|
+
session_ids.append(session_id)
|
|
310
|
+
session_ids = list(set(session_ids)) # Remove duplicates
|
|
311
|
+
|
|
312
|
+
self._project_cache.cached_files[jsonl_path.name] = CachedFileInfo(
|
|
313
|
+
file_path=str(jsonl_path),
|
|
314
|
+
source_mtime=source_mtime,
|
|
315
|
+
cached_mtime=cached_mtime,
|
|
316
|
+
message_count=len(entries),
|
|
317
|
+
session_ids=session_ids,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
self._save_project_cache()
|
|
321
|
+
except Exception as e:
|
|
322
|
+
print(f"Warning: Failed to save cached entries to {cache_file}: {e}")
|
|
323
|
+
|
|
324
|
+
def update_session_cache(self, session_data: Dict[str, SessionCacheData]) -> None:
|
|
325
|
+
"""Update cached session information."""
|
|
326
|
+
if self._project_cache is None:
|
|
327
|
+
return
|
|
328
|
+
|
|
329
|
+
self._project_cache.sessions.update(
|
|
330
|
+
{session_id: data for session_id, data in session_data.items()}
|
|
331
|
+
)
|
|
332
|
+
self._save_project_cache()
|
|
333
|
+
|
|
334
|
+
def update_project_aggregates(
|
|
335
|
+
self,
|
|
336
|
+
total_message_count: int,
|
|
337
|
+
total_input_tokens: int,
|
|
338
|
+
total_output_tokens: int,
|
|
339
|
+
total_cache_creation_tokens: int,
|
|
340
|
+
total_cache_read_tokens: int,
|
|
341
|
+
earliest_timestamp: str,
|
|
342
|
+
latest_timestamp: str,
|
|
343
|
+
) -> None:
|
|
344
|
+
"""Update project-level aggregate information."""
|
|
345
|
+
if self._project_cache is None:
|
|
346
|
+
return
|
|
347
|
+
|
|
348
|
+
self._project_cache.total_message_count = total_message_count
|
|
349
|
+
self._project_cache.total_input_tokens = total_input_tokens
|
|
350
|
+
self._project_cache.total_output_tokens = total_output_tokens
|
|
351
|
+
self._project_cache.total_cache_creation_tokens = total_cache_creation_tokens
|
|
352
|
+
self._project_cache.total_cache_read_tokens = total_cache_read_tokens
|
|
353
|
+
self._project_cache.earliest_timestamp = earliest_timestamp
|
|
354
|
+
self._project_cache.latest_timestamp = latest_timestamp
|
|
355
|
+
|
|
356
|
+
self._save_project_cache()
|
|
357
|
+
|
|
358
|
+
def get_modified_files(self, jsonl_files: List[Path]) -> List[Path]:
|
|
359
|
+
"""Get list of JSONL files that need to be reprocessed."""
|
|
360
|
+
modified_files: List[Path] = []
|
|
361
|
+
|
|
362
|
+
for jsonl_file in jsonl_files:
|
|
363
|
+
if not self.is_file_cached(jsonl_file):
|
|
364
|
+
modified_files.append(jsonl_file)
|
|
365
|
+
|
|
366
|
+
return modified_files
|
|
367
|
+
|
|
368
|
+
def get_cached_project_data(self) -> Optional[ProjectCache]:
|
|
369
|
+
"""Get the cached project data if available."""
|
|
370
|
+
return self._project_cache
|
|
371
|
+
|
|
372
|
+
def clear_cache(self) -> None:
|
|
373
|
+
"""Clear all cache files and reset the project cache."""
|
|
374
|
+
if self.cache_dir.exists():
|
|
375
|
+
for cache_file in self.cache_dir.glob("*.json"):
|
|
376
|
+
if cache_file.name != "index.json": # Don't delete the index file here
|
|
377
|
+
try:
|
|
378
|
+
cache_file.unlink()
|
|
379
|
+
except Exception as e:
|
|
380
|
+
print(f"Warning: Failed to delete cache file {cache_file}: {e}")
|
|
381
|
+
|
|
382
|
+
if self.index_file.exists():
|
|
383
|
+
try:
|
|
384
|
+
self.index_file.unlink()
|
|
385
|
+
except Exception as e:
|
|
386
|
+
print(f"Warning: Failed to delete cache index {self.index_file}: {e}")
|
|
387
|
+
|
|
388
|
+
# Reset the project cache
|
|
389
|
+
self._project_cache = ProjectCache(
|
|
390
|
+
version=self.library_version,
|
|
391
|
+
cache_created=datetime.now().isoformat(),
|
|
392
|
+
last_updated=datetime.now().isoformat(),
|
|
393
|
+
project_path=str(self.project_path),
|
|
394
|
+
cached_files={},
|
|
395
|
+
sessions={},
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
def get_cache_stats(self) -> Dict[str, Any]:
|
|
399
|
+
"""Get cache statistics for reporting."""
|
|
400
|
+
if self._project_cache is None:
|
|
401
|
+
return {"cache_enabled": False}
|
|
402
|
+
|
|
403
|
+
return {
|
|
404
|
+
"cache_enabled": True,
|
|
405
|
+
"cached_files_count": len(self._project_cache.cached_files),
|
|
406
|
+
"total_cached_messages": self._project_cache.total_message_count,
|
|
407
|
+
"total_sessions": len(self._project_cache.sessions),
|
|
408
|
+
"cache_created": self._project_cache.cache_created,
|
|
409
|
+
"last_updated": self._project_cache.last_updated,
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def get_library_version() -> str:
|
|
414
|
+
"""Get the current library version from pyproject.toml."""
|
|
415
|
+
try:
|
|
416
|
+
import toml
|
|
417
|
+
|
|
418
|
+
project_root = Path(__file__).parent.parent
|
|
419
|
+
pyproject_path = project_root / "pyproject.toml"
|
|
420
|
+
|
|
421
|
+
if pyproject_path.exists():
|
|
422
|
+
with open(pyproject_path, "r") as f:
|
|
423
|
+
pyproject_data = toml.load(f)
|
|
424
|
+
return pyproject_data.get("project", {}).get("version", "unknown")
|
|
425
|
+
except ImportError:
|
|
426
|
+
# toml is not available, try parsing manually
|
|
427
|
+
pass
|
|
428
|
+
except Exception:
|
|
429
|
+
pass
|
|
430
|
+
|
|
431
|
+
# Fallback: try to read version manually
|
|
432
|
+
try:
|
|
433
|
+
project_root = Path(__file__).parent.parent
|
|
434
|
+
pyproject_path = project_root / "pyproject.toml"
|
|
435
|
+
|
|
436
|
+
if pyproject_path.exists():
|
|
437
|
+
with open(pyproject_path, "r") as f:
|
|
438
|
+
content = f.read()
|
|
439
|
+
|
|
440
|
+
# Simple regex to extract version
|
|
441
|
+
import re
|
|
442
|
+
|
|
443
|
+
version_match = re.search(r'version\s*=\s*"([^"]+)"', content)
|
|
444
|
+
if version_match:
|
|
445
|
+
return version_match.group(1)
|
|
446
|
+
except Exception:
|
|
447
|
+
pass
|
|
448
|
+
|
|
449
|
+
return "unknown"
|