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.
@@ -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