session-zoo 0.1.0__py3-none-any.whl
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.
- session_zoo/__init__.py +1 -0
- session_zoo/adapters/__init__.py +16 -0
- session_zoo/adapters/claude_code.py +171 -0
- session_zoo/cli.py +588 -0
- session_zoo/config.py +50 -0
- session_zoo/data/skills/zoo-browse/SKILL.md +30 -0
- session_zoo/data/skills/zoo-restore/SKILL.md +36 -0
- session_zoo/data/skills/zoo-summarize/SKILL.md +38 -0
- session_zoo/data/skills/zoo-sync/SKILL.md +33 -0
- session_zoo/data/skills/zoo-tag/SKILL.md +36 -0
- session_zoo/db.py +184 -0
- session_zoo/installer.py +63 -0
- session_zoo/models.py +38 -0
- session_zoo/py.typed +0 -0
- session_zoo/renderer.py +246 -0
- session_zoo/summarizer.py +149 -0
- session_zoo/sync.py +94 -0
- session_zoo-0.1.0.dist-info/METADATA +176 -0
- session_zoo-0.1.0.dist-info/RECORD +22 -0
- session_zoo-0.1.0.dist-info/WHEEL +4 -0
- session_zoo-0.1.0.dist-info/entry_points.txt +2 -0
- session_zoo-0.1.0.dist-info/licenses/LICENSE +21 -0
session_zoo/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from session_zoo.adapters.claude_code import ClaudeCodeAdapter
|
|
2
|
+
|
|
3
|
+
_ADAPTERS = {
|
|
4
|
+
"claude-code": ClaudeCodeAdapter,
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_adapter(name: str, **kwargs):
|
|
9
|
+
cls = _ADAPTERS.get(name)
|
|
10
|
+
if cls is None:
|
|
11
|
+
raise ValueError(f"Unknown adapter: {name}. Available: {list(_ADAPTERS.keys())}")
|
|
12
|
+
return cls(**kwargs)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def list_adapters() -> list[str]:
|
|
16
|
+
return list(_ADAPTERS.keys())
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from session_zoo.models import Message, Session
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ClaudeCodeAdapter:
|
|
9
|
+
name = "claude-code"
|
|
10
|
+
|
|
11
|
+
def __init__(self, claude_dir: Path | None = None):
|
|
12
|
+
self.claude_dir = claude_dir or Path.home() / ".claude"
|
|
13
|
+
|
|
14
|
+
def discover(self, *, since: datetime | None = None,
|
|
15
|
+
project: str | None = None) -> list[Path]:
|
|
16
|
+
projects_dir = self.claude_dir / "projects"
|
|
17
|
+
if not projects_dir.exists():
|
|
18
|
+
return []
|
|
19
|
+
|
|
20
|
+
paths = []
|
|
21
|
+
for project_dir in projects_dir.iterdir():
|
|
22
|
+
if not project_dir.is_dir():
|
|
23
|
+
continue
|
|
24
|
+
if project and not self._match_project(project_dir.name, project):
|
|
25
|
+
continue
|
|
26
|
+
for jsonl_file in project_dir.glob("*.jsonl"):
|
|
27
|
+
if since and self._get_file_start_time(jsonl_file):
|
|
28
|
+
start = self._get_file_start_time(jsonl_file)
|
|
29
|
+
if start and start < since:
|
|
30
|
+
continue
|
|
31
|
+
paths.append(jsonl_file)
|
|
32
|
+
return sorted(paths)
|
|
33
|
+
|
|
34
|
+
def parse(self, path: Path) -> Session:
|
|
35
|
+
lines = path.read_text().strip().split("\n")
|
|
36
|
+
records = [json.loads(line) for line in lines if line.strip()]
|
|
37
|
+
|
|
38
|
+
session_id = None
|
|
39
|
+
model = None
|
|
40
|
+
git_branch = None
|
|
41
|
+
cwd = None
|
|
42
|
+
messages: list[Message] = []
|
|
43
|
+
total_input = 0
|
|
44
|
+
total_output = 0
|
|
45
|
+
timestamps: list[datetime] = []
|
|
46
|
+
|
|
47
|
+
for record in records:
|
|
48
|
+
rec_type = record.get("type")
|
|
49
|
+
ts_str = record.get("timestamp")
|
|
50
|
+
if ts_str:
|
|
51
|
+
ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
|
52
|
+
timestamps.append(ts)
|
|
53
|
+
|
|
54
|
+
if session_id is None:
|
|
55
|
+
session_id = record.get("sessionId")
|
|
56
|
+
if git_branch is None:
|
|
57
|
+
git_branch = record.get("gitBranch")
|
|
58
|
+
if cwd is None:
|
|
59
|
+
cwd = record.get("cwd")
|
|
60
|
+
|
|
61
|
+
if rec_type not in ("user", "assistant"):
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
msg_data = record.get("message", {})
|
|
65
|
+
role = msg_data.get("role", rec_type)
|
|
66
|
+
content_raw = msg_data.get("content", "")
|
|
67
|
+
|
|
68
|
+
# Extract text content
|
|
69
|
+
if isinstance(content_raw, list):
|
|
70
|
+
text_parts = [
|
|
71
|
+
c.get("text", "") for c in content_raw
|
|
72
|
+
if isinstance(c, dict) and c.get("type") == "text"
|
|
73
|
+
]
|
|
74
|
+
content = "\n".join(text_parts)
|
|
75
|
+
else:
|
|
76
|
+
content = str(content_raw)
|
|
77
|
+
|
|
78
|
+
# Extract tool calls
|
|
79
|
+
tool_calls = []
|
|
80
|
+
if isinstance(content_raw, list):
|
|
81
|
+
for c in content_raw:
|
|
82
|
+
if isinstance(c, dict) and c.get("type") == "tool_use":
|
|
83
|
+
tool_calls.append({
|
|
84
|
+
"id": c.get("id"),
|
|
85
|
+
"name": c.get("name"),
|
|
86
|
+
"input": c.get("input", {}),
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
# Extract token usage
|
|
90
|
+
usage = msg_data.get("usage")
|
|
91
|
+
token_usage = None
|
|
92
|
+
if usage:
|
|
93
|
+
inp = usage.get("input_tokens", 0) + usage.get("cache_creation_input_tokens", 0) + usage.get("cache_read_input_tokens", 0)
|
|
94
|
+
out = usage.get("output_tokens", 0)
|
|
95
|
+
total_input += inp
|
|
96
|
+
total_output += out
|
|
97
|
+
token_usage = {"input": inp, "output": out}
|
|
98
|
+
|
|
99
|
+
# Extract model
|
|
100
|
+
if model is None and msg_data.get("model"):
|
|
101
|
+
model = msg_data["model"]
|
|
102
|
+
|
|
103
|
+
messages.append(Message(
|
|
104
|
+
role=role,
|
|
105
|
+
content=content,
|
|
106
|
+
timestamp=ts if ts_str else datetime.now(timezone.utc),
|
|
107
|
+
tool_calls=tool_calls,
|
|
108
|
+
token_usage=token_usage,
|
|
109
|
+
))
|
|
110
|
+
|
|
111
|
+
# Derive project name from directory
|
|
112
|
+
project_name = self._extract_project_name(path)
|
|
113
|
+
|
|
114
|
+
return Session(
|
|
115
|
+
id=session_id or path.stem,
|
|
116
|
+
tool="claude-code",
|
|
117
|
+
project=project_name,
|
|
118
|
+
source_path=path,
|
|
119
|
+
started_at=min(timestamps) if timestamps else None,
|
|
120
|
+
ended_at=max(timestamps) if timestamps else None,
|
|
121
|
+
model=model or "unknown",
|
|
122
|
+
total_tokens=total_input + total_output,
|
|
123
|
+
messages=messages,
|
|
124
|
+
git_branch=git_branch if git_branch != "HEAD" else None,
|
|
125
|
+
cwd=cwd,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def get_restore_path(self, session: Session) -> Path:
|
|
129
|
+
encoded = self._encode_project_path(session.cwd or f"/unknown/{session.project}")
|
|
130
|
+
return self.claude_dir / "projects" / encoded / f"{session.id}.jsonl"
|
|
131
|
+
|
|
132
|
+
def _extract_project_name(self, path: Path) -> str:
|
|
133
|
+
"""Extract project name from the session's cwd field if available,
|
|
134
|
+
otherwise fall back to the last segment of the encoded directory name."""
|
|
135
|
+
try:
|
|
136
|
+
with open(path) as f:
|
|
137
|
+
for line in f:
|
|
138
|
+
line = line.strip()
|
|
139
|
+
if not line:
|
|
140
|
+
continue
|
|
141
|
+
record = json.loads(line)
|
|
142
|
+
cwd = record.get("cwd")
|
|
143
|
+
if cwd:
|
|
144
|
+
return Path(cwd).name
|
|
145
|
+
except (json.JSONDecodeError, OSError):
|
|
146
|
+
pass
|
|
147
|
+
# Fallback: encoded dir name last segment is ambiguous with hyphens,
|
|
148
|
+
# but best effort — take everything after the last "-" grouping.
|
|
149
|
+
dir_name = path.parent.name # e.g., "-home-user-my-project"
|
|
150
|
+
# Strip leading "-", split on "-", take last token
|
|
151
|
+
parts = dir_name.lstrip("-").split("-")
|
|
152
|
+
return parts[-1] if parts else dir_name
|
|
153
|
+
|
|
154
|
+
def _encode_project_path(self, cwd: str) -> str:
|
|
155
|
+
# Claude Code encodes "/home/user/project" as "-home-user-project"
|
|
156
|
+
return "-" + cwd.strip("/").replace("/", "-")
|
|
157
|
+
|
|
158
|
+
def _match_project(self, dir_name: str, project: str) -> bool:
|
|
159
|
+
return project.lower() in dir_name.lower()
|
|
160
|
+
|
|
161
|
+
def _get_file_start_time(self, path: Path) -> datetime | None:
|
|
162
|
+
try:
|
|
163
|
+
with open(path) as f:
|
|
164
|
+
first_line = f.readline()
|
|
165
|
+
record = json.loads(first_line)
|
|
166
|
+
ts_str = record.get("timestamp")
|
|
167
|
+
if ts_str:
|
|
168
|
+
return datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
|
169
|
+
except (json.JSONDecodeError, OSError):
|
|
170
|
+
pass
|
|
171
|
+
return None
|