repr-cli 0.2.16__py3-none-any.whl → 0.2.18__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.
Files changed (49) hide show
  1. repr/__init__.py +1 -1
  2. repr/api.py +363 -62
  3. repr/auth.py +47 -38
  4. repr/change_synthesis.py +478 -0
  5. repr/cli.py +4306 -364
  6. repr/config.py +119 -11
  7. repr/configure.py +889 -0
  8. repr/cron.py +419 -0
  9. repr/dashboard/__init__.py +9 -0
  10. repr/dashboard/build.py +126 -0
  11. repr/dashboard/dist/assets/index-B-aCjaCw.js +384 -0
  12. repr/dashboard/dist/assets/index-BYFVbEev.css +1 -0
  13. repr/dashboard/dist/assets/index-BrrhyJFO.css +1 -0
  14. repr/dashboard/dist/assets/index-C7Gzxc4f.js +384 -0
  15. repr/dashboard/dist/assets/index-CQdMXo6g.js +391 -0
  16. repr/dashboard/dist/assets/index-CcEg74ts.js +270 -0
  17. repr/dashboard/dist/assets/index-Cerc-iA_.js +377 -0
  18. repr/dashboard/dist/assets/index-CjVcBW2L.css +1 -0
  19. repr/dashboard/dist/assets/index-Cs8ofFGd.js +384 -0
  20. repr/dashboard/dist/assets/index-Dfl3mR5E.js +377 -0
  21. repr/dashboard/dist/assets/index-DwN0SeMc.css +1 -0
  22. repr/dashboard/dist/assets/index-YFch_e0S.js +384 -0
  23. repr/dashboard/dist/favicon.svg +4 -0
  24. repr/dashboard/dist/index.html +14 -0
  25. repr/dashboard/manager.py +234 -0
  26. repr/dashboard/server.py +1489 -0
  27. repr/db.py +980 -0
  28. repr/hooks.py +3 -2
  29. repr/loaders/__init__.py +22 -0
  30. repr/loaders/base.py +156 -0
  31. repr/loaders/claude_code.py +287 -0
  32. repr/loaders/clawdbot.py +313 -0
  33. repr/loaders/gemini_antigravity.py +381 -0
  34. repr/mcp_server.py +1196 -0
  35. repr/models.py +503 -0
  36. repr/openai_analysis.py +25 -0
  37. repr/session_extractor.py +481 -0
  38. repr/storage.py +328 -0
  39. repr/story_synthesis.py +1296 -0
  40. repr/templates.py +68 -4
  41. repr/timeline.py +710 -0
  42. repr/tools.py +17 -8
  43. {repr_cli-0.2.16.dist-info → repr_cli-0.2.18.dist-info}/METADATA +48 -10
  44. repr_cli-0.2.18.dist-info/RECORD +58 -0
  45. {repr_cli-0.2.16.dist-info → repr_cli-0.2.18.dist-info}/WHEEL +1 -1
  46. {repr_cli-0.2.16.dist-info → repr_cli-0.2.18.dist-info}/entry_points.txt +1 -0
  47. repr_cli-0.2.16.dist-info/RECORD +0 -26
  48. {repr_cli-0.2.16.dist-info → repr_cli-0.2.18.dist-info}/licenses/LICENSE +0 -0
  49. {repr_cli-0.2.16.dist-info → repr_cli-0.2.18.dist-info}/top_level.txt +0 -0
repr/hooks.py CHANGED
@@ -516,12 +516,13 @@ def _spawn_background_generate(repo_path: Path) -> None:
516
516
  log_file = log_dir / "auto_generate.log"
517
517
 
518
518
  # Build command - use sys.executable to find repr command
519
- # repr generate --repo <path> --local --json
519
+ # repr generate --weeks 1 --json --batch-size <queue_size>
520
+ # Note: We rely on default model config for synthesis
520
521
  cmd = [
521
522
  sys.executable, "-m", "repr",
522
523
  "generate",
523
524
  "--repo", str(repo_path),
524
- "--local", # Always use local LLM for auto-generation
525
+ "--days", "7", # limit context to 1 week for quick update
525
526
  "--json",
526
527
  ]
527
528
 
@@ -0,0 +1,22 @@
1
+ """
2
+ Session loaders for different AI assistant formats.
3
+
4
+ Supported formats:
5
+ - Claude Code: ~/.claude/projects/<project>/*.jsonl
6
+ - Clawdbot: ~/.clawdbot/agents/<id>/sessions/*.jsonl
7
+ - Gemini Antigravity: ~/.gemini/antigravity/brain/<conversation-id>/
8
+ """
9
+
10
+ from .claude_code import ClaudeCodeLoader
11
+ from .clawdbot import ClawdbotLoader
12
+ from .gemini_antigravity import GeminiAntigravityLoader
13
+ from .base import SessionLoader, detect_session_source, load_sessions_for_project
14
+
15
+ __all__ = [
16
+ "SessionLoader",
17
+ "ClaudeCodeLoader",
18
+ "ClawdbotLoader",
19
+ "GeminiAntigravityLoader",
20
+ "detect_session_source",
21
+ "load_sessions_for_project",
22
+ ]
repr/loaders/base.py ADDED
@@ -0,0 +1,156 @@
1
+ """
2
+ Base session loader interface and utilities.
3
+ """
4
+
5
+ import os
6
+ from abc import ABC, abstractmethod
7
+ from datetime import datetime, timedelta
8
+ from pathlib import Path
9
+ from typing import Iterator
10
+
11
+ from ..models import Session
12
+
13
+
14
+ class SessionLoader(ABC):
15
+ """Abstract base class for session loaders."""
16
+
17
+ @property
18
+ @abstractmethod
19
+ def name(self) -> str:
20
+ """Loader name (e.g., 'claude_code', 'clawdbot')."""
21
+ pass
22
+
23
+ @abstractmethod
24
+ def find_sessions(
25
+ self,
26
+ project_path: str | Path,
27
+ since: datetime | None = None,
28
+ ) -> list[Path]:
29
+ """
30
+ Find session files for a project.
31
+
32
+ Args:
33
+ project_path: Path to the project/repo
34
+ since: Only return sessions after this time
35
+
36
+ Returns:
37
+ List of session file paths
38
+ """
39
+ pass
40
+
41
+ @abstractmethod
42
+ def load_session(self, path: Path) -> Session | None:
43
+ """
44
+ Load a single session from a file.
45
+
46
+ Args:
47
+ path: Path to the session file
48
+
49
+ Returns:
50
+ Session object or None if loading fails
51
+ """
52
+ pass
53
+
54
+ def load_sessions(
55
+ self,
56
+ project_path: str | Path,
57
+ since: datetime | None = None,
58
+ ) -> Iterator[Session]:
59
+ """
60
+ Load all sessions for a project.
61
+
62
+ Args:
63
+ project_path: Path to the project/repo
64
+ since: Only return sessions after this time
65
+
66
+ Yields:
67
+ Session objects
68
+ """
69
+ for path in self.find_sessions(project_path, since):
70
+ session = self.load_session(path)
71
+ if session:
72
+ yield session
73
+
74
+
75
+ def detect_session_source(project_path: str | Path) -> list[str]:
76
+ """
77
+ Detect which session sources are available for a project.
78
+
79
+ Args:
80
+ project_path: Path to the project/repo
81
+
82
+ Returns:
83
+ List of available sources: ["claude_code", "clawdbot", "gemini_antigravity"]
84
+ """
85
+ from .claude_code import ClaudeCodeLoader
86
+ from .clawdbot import ClawdbotLoader
87
+ from .gemini_antigravity import GeminiAntigravityLoader
88
+
89
+ sources = []
90
+ project_path = Path(project_path).resolve()
91
+
92
+ # Check Claude Code
93
+ claude_loader = ClaudeCodeLoader()
94
+ if claude_loader.find_sessions(project_path):
95
+ sources.append("claude_code")
96
+
97
+ # Check Clawdbot
98
+ clawdbot_loader = ClawdbotLoader()
99
+ if clawdbot_loader.find_sessions(project_path):
100
+ sources.append("clawdbot")
101
+
102
+ # Check Gemini Antigravity
103
+ gemini_loader = GeminiAntigravityLoader()
104
+ if gemini_loader.find_sessions(project_path):
105
+ sources.append("gemini_antigravity")
106
+
107
+ return sources
108
+
109
+
110
+ def load_sessions_for_project(
111
+ project_path: str | Path,
112
+ sources: list[str] | None = None,
113
+ since: datetime | None = None,
114
+ days_back: int | None = None,
115
+ ) -> list[Session]:
116
+ """
117
+ Load sessions from all sources for a project.
118
+
119
+ Args:
120
+ project_path: Path to the project/repo
121
+ sources: Specific sources to use, or None for auto-detect
122
+ since: Only return sessions after this time
123
+ days_back: Alternative to since: load sessions from last N days
124
+
125
+ Returns:
126
+ List of sessions sorted by start time
127
+ """
128
+ from .claude_code import ClaudeCodeLoader
129
+ from .clawdbot import ClawdbotLoader
130
+ from .gemini_antigravity import GeminiAntigravityLoader
131
+
132
+ # Calculate since from days_back if provided
133
+ if days_back is not None and since is None:
134
+ since = datetime.now() - timedelta(days=days_back)
135
+
136
+ # Auto-detect sources if not specified
137
+ if sources is None:
138
+ sources = detect_session_source(project_path)
139
+
140
+ # Load from all sources
141
+ loaders = {
142
+ "claude_code": ClaudeCodeLoader(),
143
+ "clawdbot": ClawdbotLoader(),
144
+ "gemini_antigravity": GeminiAntigravityLoader(),
145
+ }
146
+
147
+ all_sessions = []
148
+ for source in sources:
149
+ if source in loaders:
150
+ loader = loaders[source]
151
+ for session in loader.load_sessions(project_path, since):
152
+ all_sessions.append(session)
153
+
154
+ # Sort by start time
155
+ all_sessions.sort(key=lambda s: s.started_at)
156
+ return all_sessions
@@ -0,0 +1,287 @@
1
+ """
2
+ Session loader for Claude Code format.
3
+
4
+ Claude Code stores sessions as JSONL files in:
5
+ ~/.claude/projects/<project-path-encoded>/*.jsonl
6
+
7
+ Each line is a JSON object with fields like:
8
+ - type: "user", "assistant", "progress", "tool_use", etc.
9
+ - sessionId: UUID of the session
10
+ - uuid: UUID of this message
11
+ - timestamp: ISO timestamp
12
+ - message: {role, content} for user/assistant types
13
+ - cwd: Working directory
14
+ - gitBranch: Current git branch
15
+ """
16
+
17
+ import json
18
+ import os
19
+ from datetime import datetime
20
+ from pathlib import Path
21
+ from typing import Iterator
22
+
23
+ from ..models import (
24
+ ContentBlock,
25
+ ContentBlockType,
26
+ MessageRole,
27
+ Session,
28
+ SessionMessage,
29
+ )
30
+ from .base import SessionLoader
31
+
32
+
33
+ class ClaudeCodeLoader(SessionLoader):
34
+ """Loader for Claude Code session files."""
35
+
36
+ CLAUDE_HOME = Path.home() / ".claude" / "projects"
37
+
38
+ @property
39
+ def name(self) -> str:
40
+ return "claude_code"
41
+
42
+ def _encode_project_path(self, project_path: Path) -> str:
43
+ """
44
+ Encode a project path to Claude Code's directory naming convention.
45
+
46
+ Claude Code uses path with / replaced by - and leading -.
47
+ e.g., /Users/mendrika/Projects/foo -> -Users-mendrika-Projects-foo
48
+ """
49
+ path_str = str(project_path.resolve())
50
+ # Replace path separators with dashes, remove leading slash
51
+ encoded = path_str.replace("/", "-")
52
+ if encoded.startswith("-"):
53
+ pass # Keep leading dash
54
+ else:
55
+ encoded = "-" + encoded
56
+ return encoded
57
+
58
+ def _decode_project_path(self, encoded: str) -> Path:
59
+ """
60
+ Decode Claude Code's directory name back to a path.
61
+ """
62
+ # Replace dashes with slashes, but skip leading dash
63
+ if encoded.startswith("-"):
64
+ decoded = encoded[1:].replace("-", "/")
65
+ else:
66
+ decoded = encoded.replace("-", "/")
67
+ return Path("/" + decoded)
68
+
69
+ def find_sessions(
70
+ self,
71
+ project_path: str | Path,
72
+ since: datetime | None = None,
73
+ ) -> list[Path]:
74
+ """Find Claude Code session files for a project."""
75
+ project_path = Path(project_path).resolve()
76
+
77
+ if not self.CLAUDE_HOME.exists():
78
+ return []
79
+
80
+ # Try exact encoded path match
81
+ encoded = self._encode_project_path(project_path)
82
+ project_dir = self.CLAUDE_HOME / encoded
83
+
84
+ if not project_dir.exists():
85
+ # Try to find by scanning directories (handles path variations)
86
+ for dir_path in self.CLAUDE_HOME.iterdir():
87
+ if dir_path.is_dir():
88
+ decoded = self._decode_project_path(dir_path.name)
89
+ # Check if project_path is a prefix or match
90
+ try:
91
+ if project_path == decoded or decoded.is_relative_to(project_path):
92
+ project_dir = dir_path
93
+ break
94
+ if project_path.is_relative_to(decoded):
95
+ project_dir = dir_path
96
+ break
97
+ except (ValueError, TypeError):
98
+ continue
99
+ else:
100
+ return []
101
+
102
+ # Find all .jsonl files
103
+ session_files = []
104
+ for file_path in project_dir.glob("*.jsonl"):
105
+ if since is not None:
106
+ # Quick check: file modification time
107
+ mtime = datetime.fromtimestamp(file_path.stat().st_mtime)
108
+ # Handle timezone-aware since
109
+ if since.tzinfo is not None:
110
+ from datetime import timezone
111
+ mtime = mtime.replace(tzinfo=timezone.utc)
112
+ if mtime < since:
113
+ continue
114
+ session_files.append(file_path)
115
+
116
+ return sorted(session_files)
117
+
118
+ def load_session(self, path: Path) -> Session | None:
119
+ """Load a Claude Code session from a JSONL file."""
120
+ try:
121
+ messages = []
122
+ session_id = None
123
+ cwd = None
124
+ git_branch = None
125
+ model = None
126
+ started_at = None
127
+ ended_at = None
128
+
129
+ with open(path, "r", encoding="utf-8") as f:
130
+ for line in f:
131
+ line = line.strip()
132
+ if not line:
133
+ continue
134
+
135
+ try:
136
+ entry = json.loads(line)
137
+ except json.JSONDecodeError:
138
+ continue
139
+
140
+ entry_type = entry.get("type")
141
+ timestamp_str = entry.get("timestamp")
142
+
143
+ # Extract session metadata
144
+ if session_id is None and entry.get("sessionId"):
145
+ session_id = entry["sessionId"]
146
+ if cwd is None and entry.get("cwd"):
147
+ cwd = entry["cwd"]
148
+ if git_branch is None and entry.get("gitBranch"):
149
+ git_branch = entry["gitBranch"]
150
+
151
+ # Track timestamps
152
+ if timestamp_str:
153
+ try:
154
+ ts = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
155
+ if started_at is None or ts < started_at:
156
+ started_at = ts
157
+ if ended_at is None or ts > ended_at:
158
+ ended_at = ts
159
+ except (ValueError, TypeError):
160
+ pass
161
+
162
+ # Process user/assistant messages
163
+ if entry_type == "user":
164
+ msg_data = entry.get("message", {})
165
+ content = self._parse_content(msg_data.get("content", ""))
166
+ if content:
167
+ messages.append(SessionMessage(
168
+ timestamp=timestamp_str or started_at or datetime.now(),
169
+ role=MessageRole.USER,
170
+ content=content,
171
+ uuid=entry.get("uuid"),
172
+ ))
173
+
174
+ elif entry_type == "assistant":
175
+ msg_data = entry.get("message", {})
176
+ content_raw = msg_data.get("content", [])
177
+ content = self._parse_assistant_content(content_raw)
178
+
179
+ # Extract model
180
+ if model is None and msg_data.get("model"):
181
+ model = msg_data["model"]
182
+
183
+ if content:
184
+ messages.append(SessionMessage(
185
+ timestamp=timestamp_str or ended_at or datetime.now(),
186
+ role=MessageRole.ASSISTANT,
187
+ content=content,
188
+ uuid=entry.get("uuid"),
189
+ ))
190
+
191
+ if not session_id or not messages:
192
+ return None
193
+
194
+ return Session(
195
+ id=session_id,
196
+ started_at=started_at or datetime.now(),
197
+ ended_at=ended_at,
198
+ channel="cli", # Claude Code is CLI-based
199
+ messages=messages,
200
+ cwd=cwd,
201
+ git_branch=git_branch,
202
+ model=model,
203
+ )
204
+
205
+ except Exception as e:
206
+ # Log error but don't crash
207
+ import sys
208
+ print(f"Error loading Claude Code session {path}: {e}", file=sys.stderr)
209
+ return None
210
+
211
+ def _parse_content(self, content) -> list[ContentBlock]:
212
+ """Parse message content into ContentBlocks."""
213
+ blocks = []
214
+
215
+ if isinstance(content, str):
216
+ if content.strip():
217
+ blocks.append(ContentBlock(
218
+ type=ContentBlockType.TEXT,
219
+ text=content,
220
+ ))
221
+ elif isinstance(content, list):
222
+ for item in content:
223
+ if isinstance(item, str):
224
+ if item.strip():
225
+ blocks.append(ContentBlock(
226
+ type=ContentBlockType.TEXT,
227
+ text=item,
228
+ ))
229
+ elif isinstance(item, dict):
230
+ item_type = item.get("type", "text")
231
+ if item_type == "text":
232
+ text = item.get("text", "")
233
+ if text.strip():
234
+ blocks.append(ContentBlock(
235
+ type=ContentBlockType.TEXT,
236
+ text=text,
237
+ ))
238
+ elif item_type == "tool_use":
239
+ blocks.append(ContentBlock(
240
+ type=ContentBlockType.TOOL_CALL,
241
+ name=item.get("name"),
242
+ input=item.get("input"),
243
+ ))
244
+
245
+ return blocks
246
+
247
+ def _parse_assistant_content(self, content) -> list[ContentBlock]:
248
+ """Parse assistant message content including tool calls and thinking."""
249
+ blocks = []
250
+
251
+ if isinstance(content, list):
252
+ for item in content:
253
+ if isinstance(item, dict):
254
+ item_type = item.get("type", "text")
255
+
256
+ if item_type == "text":
257
+ text = item.get("text", "")
258
+ if text.strip():
259
+ blocks.append(ContentBlock(
260
+ type=ContentBlockType.TEXT,
261
+ text=text,
262
+ ))
263
+ elif item_type == "tool_use":
264
+ blocks.append(ContentBlock(
265
+ type=ContentBlockType.TOOL_CALL,
266
+ name=item.get("name"),
267
+ input=item.get("input"),
268
+ ))
269
+ elif item_type == "thinking":
270
+ thinking = item.get("thinking", "")
271
+ if thinking.strip():
272
+ blocks.append(ContentBlock(
273
+ type=ContentBlockType.THINKING,
274
+ text=thinking,
275
+ ))
276
+ elif isinstance(item, str) and item.strip():
277
+ blocks.append(ContentBlock(
278
+ type=ContentBlockType.TEXT,
279
+ text=item,
280
+ ))
281
+ elif isinstance(content, str) and content.strip():
282
+ blocks.append(ContentBlock(
283
+ type=ContentBlockType.TEXT,
284
+ text=content,
285
+ ))
286
+
287
+ return blocks