repr-cli 0.2.16__py3-none-any.whl → 0.2.17__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.
- repr/__init__.py +1 -1
- repr/api.py +363 -62
- repr/auth.py +47 -38
- repr/change_synthesis.py +478 -0
- repr/cli.py +4099 -280
- repr/config.py +119 -11
- repr/configure.py +889 -0
- repr/cron.py +419 -0
- repr/dashboard/__init__.py +9 -0
- repr/dashboard/build.py +126 -0
- repr/dashboard/dist/assets/index-BYFVbEev.css +1 -0
- repr/dashboard/dist/assets/index-BrrhyJFO.css +1 -0
- repr/dashboard/dist/assets/index-CcEg74ts.js +270 -0
- repr/dashboard/dist/assets/index-Cerc-iA_.js +377 -0
- repr/dashboard/dist/assets/index-CjVcBW2L.css +1 -0
- repr/dashboard/dist/assets/index-Dfl3mR5E.js +377 -0
- repr/dashboard/dist/favicon.svg +4 -0
- repr/dashboard/dist/index.html +14 -0
- repr/dashboard/manager.py +234 -0
- repr/dashboard/server.py +1298 -0
- repr/db.py +980 -0
- repr/hooks.py +3 -2
- repr/loaders/__init__.py +22 -0
- repr/loaders/base.py +156 -0
- repr/loaders/claude_code.py +287 -0
- repr/loaders/clawdbot.py +313 -0
- repr/loaders/gemini_antigravity.py +381 -0
- repr/mcp_server.py +1196 -0
- repr/models.py +503 -0
- repr/openai_analysis.py +25 -0
- repr/session_extractor.py +481 -0
- repr/storage.py +328 -0
- repr/story_synthesis.py +1296 -0
- repr/templates.py +68 -4
- repr/timeline.py +710 -0
- repr/tools.py +17 -8
- {repr_cli-0.2.16.dist-info → repr_cli-0.2.17.dist-info}/METADATA +48 -10
- repr_cli-0.2.17.dist-info/RECORD +52 -0
- {repr_cli-0.2.16.dist-info → repr_cli-0.2.17.dist-info}/WHEEL +1 -1
- {repr_cli-0.2.16.dist-info → repr_cli-0.2.17.dist-info}/entry_points.txt +1 -0
- repr_cli-0.2.16.dist-info/RECORD +0 -26
- {repr_cli-0.2.16.dist-info → repr_cli-0.2.17.dist-info}/licenses/LICENSE +0 -0
- {repr_cli-0.2.16.dist-info → repr_cli-0.2.17.dist-info}/top_level.txt +0 -0
repr/loaders/clawdbot.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Session loader for Clawdbot format.
|
|
3
|
+
|
|
4
|
+
Clawdbot stores sessions as JSONL files in:
|
|
5
|
+
~/.clawdbot/agents/<agentId>/sessions/*.jsonl
|
|
6
|
+
|
|
7
|
+
Format:
|
|
8
|
+
- First line: {"type": "session", "version": 3, "id": "...", "timestamp": "...", "cwd": "..."}
|
|
9
|
+
- Subsequent lines: various types including:
|
|
10
|
+
- {"type": "model_change", ...}
|
|
11
|
+
- {"type": "message", "message": {"role": "user"|"assistant", "content": [...]}}
|
|
12
|
+
- {"type": "custom", "customType": "...", "data": {...}}
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Iterator
|
|
20
|
+
|
|
21
|
+
from ..models import (
|
|
22
|
+
ContentBlock,
|
|
23
|
+
ContentBlockType,
|
|
24
|
+
MessageRole,
|
|
25
|
+
Session,
|
|
26
|
+
SessionMessage,
|
|
27
|
+
)
|
|
28
|
+
from .base import SessionLoader
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ClawdbotLoader(SessionLoader):
|
|
32
|
+
"""Loader for Clawdbot session files."""
|
|
33
|
+
|
|
34
|
+
CLAWDBOT_HOME = Path.home() / ".clawdbot" / "agents"
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def name(self) -> str:
|
|
38
|
+
return "clawdbot"
|
|
39
|
+
|
|
40
|
+
def find_sessions(
|
|
41
|
+
self,
|
|
42
|
+
project_path: str | Path,
|
|
43
|
+
since: datetime | None = None,
|
|
44
|
+
) -> list[Path]:
|
|
45
|
+
"""
|
|
46
|
+
Find Clawdbot session files related to a project.
|
|
47
|
+
|
|
48
|
+
Note: Clawdbot sessions are organized by agent, not project.
|
|
49
|
+
We scan all agents and filter by cwd in the session metadata.
|
|
50
|
+
"""
|
|
51
|
+
project_path = Path(project_path).resolve()
|
|
52
|
+
|
|
53
|
+
if not self.CLAWDBOT_HOME.exists():
|
|
54
|
+
return []
|
|
55
|
+
|
|
56
|
+
session_files = []
|
|
57
|
+
|
|
58
|
+
# Scan all agents
|
|
59
|
+
for agent_dir in self.CLAWDBOT_HOME.iterdir():
|
|
60
|
+
if not agent_dir.is_dir():
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
sessions_dir = agent_dir / "sessions"
|
|
64
|
+
if not sessions_dir.exists():
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
# Find session files
|
|
68
|
+
for file_path in sessions_dir.glob("*.jsonl"):
|
|
69
|
+
# Quick check: file modification time
|
|
70
|
+
if since is not None:
|
|
71
|
+
mtime = datetime.fromtimestamp(file_path.stat().st_mtime)
|
|
72
|
+
# Handle timezone-aware since
|
|
73
|
+
if since.tzinfo is not None:
|
|
74
|
+
from datetime import timezone
|
|
75
|
+
mtime = mtime.replace(tzinfo=timezone.utc)
|
|
76
|
+
if mtime < since:
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
# Check if session is related to project by reading first line
|
|
80
|
+
try:
|
|
81
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
82
|
+
first_line = f.readline().strip()
|
|
83
|
+
if first_line:
|
|
84
|
+
entry = json.loads(first_line)
|
|
85
|
+
session_cwd = entry.get("cwd", "")
|
|
86
|
+
if session_cwd:
|
|
87
|
+
session_cwd_path = Path(session_cwd).resolve()
|
|
88
|
+
# Check if paths are related
|
|
89
|
+
try:
|
|
90
|
+
if (project_path == session_cwd_path or
|
|
91
|
+
session_cwd_path.is_relative_to(project_path) or
|
|
92
|
+
project_path.is_relative_to(session_cwd_path)):
|
|
93
|
+
session_files.append(file_path)
|
|
94
|
+
except (ValueError, TypeError):
|
|
95
|
+
pass
|
|
96
|
+
except (json.JSONDecodeError, IOError):
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
return sorted(session_files)
|
|
100
|
+
|
|
101
|
+
def find_all_sessions(
|
|
102
|
+
self,
|
|
103
|
+
since: datetime | None = None,
|
|
104
|
+
agent_id: str | None = None,
|
|
105
|
+
) -> list[Path]:
|
|
106
|
+
"""
|
|
107
|
+
Find all Clawdbot sessions, optionally filtered by agent.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
since: Only return sessions after this time
|
|
111
|
+
agent_id: Only return sessions from this agent
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
List of session file paths
|
|
115
|
+
"""
|
|
116
|
+
if not self.CLAWDBOT_HOME.exists():
|
|
117
|
+
return []
|
|
118
|
+
|
|
119
|
+
session_files = []
|
|
120
|
+
|
|
121
|
+
# Filter agents
|
|
122
|
+
if agent_id:
|
|
123
|
+
agent_dirs = [self.CLAWDBOT_HOME / agent_id]
|
|
124
|
+
else:
|
|
125
|
+
agent_dirs = [d for d in self.CLAWDBOT_HOME.iterdir() if d.is_dir()]
|
|
126
|
+
|
|
127
|
+
for agent_dir in agent_dirs:
|
|
128
|
+
if not agent_dir.exists():
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
sessions_dir = agent_dir / "sessions"
|
|
132
|
+
if not sessions_dir.exists():
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
for file_path in sessions_dir.glob("*.jsonl"):
|
|
136
|
+
if since is not None:
|
|
137
|
+
mtime = datetime.fromtimestamp(file_path.stat().st_mtime)
|
|
138
|
+
if mtime < since:
|
|
139
|
+
continue
|
|
140
|
+
session_files.append(file_path)
|
|
141
|
+
|
|
142
|
+
return sorted(session_files)
|
|
143
|
+
|
|
144
|
+
def load_session(self, path: Path) -> Session | None:
|
|
145
|
+
"""Load a Clawdbot session from a JSONL file."""
|
|
146
|
+
try:
|
|
147
|
+
messages = []
|
|
148
|
+
session_id = None
|
|
149
|
+
cwd = None
|
|
150
|
+
model = None
|
|
151
|
+
channel = "cli" # Default, may be overridden
|
|
152
|
+
started_at = None
|
|
153
|
+
ended_at = None
|
|
154
|
+
|
|
155
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
156
|
+
for line in f:
|
|
157
|
+
line = line.strip()
|
|
158
|
+
if not line:
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
entry = json.loads(line)
|
|
163
|
+
except json.JSONDecodeError:
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
entry_type = entry.get("type")
|
|
167
|
+
timestamp_str = entry.get("timestamp")
|
|
168
|
+
|
|
169
|
+
# Track timestamps
|
|
170
|
+
if timestamp_str:
|
|
171
|
+
try:
|
|
172
|
+
ts = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
|
|
173
|
+
if started_at is None or ts < started_at:
|
|
174
|
+
started_at = ts
|
|
175
|
+
if ended_at is None or ts > ended_at:
|
|
176
|
+
ended_at = ts
|
|
177
|
+
except (ValueError, TypeError):
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
# Session header
|
|
181
|
+
if entry_type == "session":
|
|
182
|
+
session_id = entry.get("id")
|
|
183
|
+
cwd = entry.get("cwd")
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
# Model change
|
|
187
|
+
if entry_type == "model_change":
|
|
188
|
+
model = entry.get("modelId")
|
|
189
|
+
continue
|
|
190
|
+
|
|
191
|
+
# Message entries
|
|
192
|
+
if entry_type == "message":
|
|
193
|
+
msg_data = entry.get("message", {})
|
|
194
|
+
role_str = msg_data.get("role", "")
|
|
195
|
+
content_raw = msg_data.get("content", [])
|
|
196
|
+
|
|
197
|
+
role = self._parse_role(role_str)
|
|
198
|
+
if role is None:
|
|
199
|
+
continue
|
|
200
|
+
|
|
201
|
+
content = self._parse_content(content_raw)
|
|
202
|
+
if content:
|
|
203
|
+
messages.append(SessionMessage(
|
|
204
|
+
timestamp=timestamp_str or started_at or datetime.now(),
|
|
205
|
+
role=role,
|
|
206
|
+
content=content,
|
|
207
|
+
uuid=entry.get("id"),
|
|
208
|
+
))
|
|
209
|
+
|
|
210
|
+
# User/assistant messages (alternative format)
|
|
211
|
+
if entry_type in ("user", "assistant"):
|
|
212
|
+
msg_data = entry.get("message", {})
|
|
213
|
+
role = MessageRole.USER if entry_type == "user" else MessageRole.ASSISTANT
|
|
214
|
+
content_raw = msg_data.get("content", [])
|
|
215
|
+
|
|
216
|
+
content = self._parse_content(content_raw)
|
|
217
|
+
if content:
|
|
218
|
+
messages.append(SessionMessage(
|
|
219
|
+
timestamp=timestamp_str or started_at or datetime.now(),
|
|
220
|
+
role=role,
|
|
221
|
+
content=content,
|
|
222
|
+
uuid=entry.get("id"),
|
|
223
|
+
))
|
|
224
|
+
|
|
225
|
+
# Custom types for channel info
|
|
226
|
+
if entry_type == "custom":
|
|
227
|
+
custom_type = entry.get("customType", "")
|
|
228
|
+
if "channel" in custom_type.lower():
|
|
229
|
+
data = entry.get("data", {})
|
|
230
|
+
if data.get("channel"):
|
|
231
|
+
channel = data["channel"]
|
|
232
|
+
|
|
233
|
+
if not session_id:
|
|
234
|
+
# Use filename as fallback
|
|
235
|
+
session_id = path.stem
|
|
236
|
+
|
|
237
|
+
if not messages:
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
return Session(
|
|
241
|
+
id=session_id,
|
|
242
|
+
started_at=started_at or datetime.now(),
|
|
243
|
+
ended_at=ended_at,
|
|
244
|
+
channel=channel,
|
|
245
|
+
messages=messages,
|
|
246
|
+
cwd=cwd,
|
|
247
|
+
model=model,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
except Exception as e:
|
|
251
|
+
import sys
|
|
252
|
+
print(f"Error loading Clawdbot session {path}: {e}", file=sys.stderr)
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
def _parse_role(self, role_str: str) -> MessageRole | None:
|
|
256
|
+
"""Parse role string to MessageRole enum."""
|
|
257
|
+
role_map = {
|
|
258
|
+
"user": MessageRole.USER,
|
|
259
|
+
"assistant": MessageRole.ASSISTANT,
|
|
260
|
+
"system": MessageRole.SYSTEM,
|
|
261
|
+
"tool": MessageRole.TOOL_RESULT,
|
|
262
|
+
"toolResult": MessageRole.TOOL_RESULT,
|
|
263
|
+
}
|
|
264
|
+
return role_map.get(role_str.lower())
|
|
265
|
+
|
|
266
|
+
def _parse_content(self, content) -> list[ContentBlock]:
|
|
267
|
+
"""Parse message content into ContentBlocks."""
|
|
268
|
+
blocks = []
|
|
269
|
+
|
|
270
|
+
if isinstance(content, str):
|
|
271
|
+
if content.strip():
|
|
272
|
+
blocks.append(ContentBlock(
|
|
273
|
+
type=ContentBlockType.TEXT,
|
|
274
|
+
text=content,
|
|
275
|
+
))
|
|
276
|
+
elif isinstance(content, list):
|
|
277
|
+
for item in content:
|
|
278
|
+
if isinstance(item, str):
|
|
279
|
+
if item.strip():
|
|
280
|
+
blocks.append(ContentBlock(
|
|
281
|
+
type=ContentBlockType.TEXT,
|
|
282
|
+
text=item,
|
|
283
|
+
))
|
|
284
|
+
elif isinstance(item, dict):
|
|
285
|
+
item_type = item.get("type", "text")
|
|
286
|
+
|
|
287
|
+
if item_type == "text":
|
|
288
|
+
text = item.get("text", "")
|
|
289
|
+
if text.strip():
|
|
290
|
+
blocks.append(ContentBlock(
|
|
291
|
+
type=ContentBlockType.TEXT,
|
|
292
|
+
text=text,
|
|
293
|
+
))
|
|
294
|
+
elif item_type in ("tool_use", "toolCall"):
|
|
295
|
+
blocks.append(ContentBlock(
|
|
296
|
+
type=ContentBlockType.TOOL_CALL,
|
|
297
|
+
name=item.get("name"),
|
|
298
|
+
input=item.get("input"),
|
|
299
|
+
))
|
|
300
|
+
elif item_type in ("tool_result", "toolResult"):
|
|
301
|
+
blocks.append(ContentBlock(
|
|
302
|
+
type=ContentBlockType.TOOL_RESULT,
|
|
303
|
+
text=str(item.get("content", item.get("result", ""))),
|
|
304
|
+
))
|
|
305
|
+
elif item_type == "thinking":
|
|
306
|
+
thinking = item.get("thinking", item.get("text", ""))
|
|
307
|
+
if thinking.strip():
|
|
308
|
+
blocks.append(ContentBlock(
|
|
309
|
+
type=ContentBlockType.THINKING,
|
|
310
|
+
text=thinking,
|
|
311
|
+
))
|
|
312
|
+
|
|
313
|
+
return blocks
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Session loader for Gemini Antigravity format.
|
|
3
|
+
|
|
4
|
+
Gemini Antigravity stores conversations as:
|
|
5
|
+
- Protobuf files (.pb) in ~/.gemini/antigravity/conversations/
|
|
6
|
+
- Artifacts in ~/.gemini/antigravity/brain/<conversation-id>/
|
|
7
|
+
- task.md - Task checklist
|
|
8
|
+
- implementation_plan.md - Technical plan
|
|
9
|
+
- walkthrough.md - Completion summary
|
|
10
|
+
- *.metadata.json - Timestamps and metadata
|
|
11
|
+
|
|
12
|
+
Since parsing protobuf requires schema definitions, we extract context
|
|
13
|
+
from the markdown artifacts instead, which contain rich information about
|
|
14
|
+
the work done, files modified, and decisions made.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
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 GeminiAntigravityLoader(SessionLoader):
|
|
34
|
+
"""Loader for Gemini Antigravity session artifacts."""
|
|
35
|
+
|
|
36
|
+
GEMINI_HOME = Path.home() / ".gemini" / "antigravity" / "brain"
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def name(self) -> str:
|
|
40
|
+
return "gemini_antigravity"
|
|
41
|
+
|
|
42
|
+
def _extract_project_paths(self, content: str) -> set[str]:
|
|
43
|
+
"""
|
|
44
|
+
Extract project paths from markdown file links.
|
|
45
|
+
|
|
46
|
+
Looks for patterns like: file:///Users/mendrika/Projects/...
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
content: Markdown content
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Set of unique project paths
|
|
53
|
+
"""
|
|
54
|
+
paths = set()
|
|
55
|
+
# Match file:// links
|
|
56
|
+
file_links = re.findall(r'file://(/[^)]+)', content)
|
|
57
|
+
for link in file_links:
|
|
58
|
+
# Remove line number anchors (#L123-L456)
|
|
59
|
+
path = re.sub(r'#L\d+(-L\d+)?$', '', link)
|
|
60
|
+
paths.add(path)
|
|
61
|
+
return paths
|
|
62
|
+
|
|
63
|
+
def _find_common_project_root(self, file_paths: set[str]) -> str | None:
|
|
64
|
+
"""
|
|
65
|
+
Find the common project root from a set of file paths.
|
|
66
|
+
|
|
67
|
+
Looks for common git repository roots or project directories.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
file_paths: Set of file paths
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Common project root path or None
|
|
74
|
+
"""
|
|
75
|
+
if not file_paths:
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
# Convert to Path objects
|
|
79
|
+
paths = [Path(p) for p in file_paths]
|
|
80
|
+
|
|
81
|
+
# Find common parent
|
|
82
|
+
if len(paths) == 1:
|
|
83
|
+
# Single file - find its git root or parent directory
|
|
84
|
+
path = paths[0]
|
|
85
|
+
current = path.parent if path.is_file() else path
|
|
86
|
+
while current != current.parent:
|
|
87
|
+
if (current / ".git").exists():
|
|
88
|
+
return str(current)
|
|
89
|
+
current = current.parent
|
|
90
|
+
return str(path.parent)
|
|
91
|
+
|
|
92
|
+
# Multiple files - find common ancestor
|
|
93
|
+
common = paths[0]
|
|
94
|
+
for path in paths[1:]:
|
|
95
|
+
try:
|
|
96
|
+
# Find common parts
|
|
97
|
+
common_parts = []
|
|
98
|
+
for p1, p2 in zip(common.parts, path.parts):
|
|
99
|
+
if p1 == p2:
|
|
100
|
+
common_parts.append(p1)
|
|
101
|
+
else:
|
|
102
|
+
break
|
|
103
|
+
if common_parts:
|
|
104
|
+
common = Path(*common_parts)
|
|
105
|
+
else:
|
|
106
|
+
return None
|
|
107
|
+
except (ValueError, TypeError):
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
# Check if common path has .git
|
|
111
|
+
current = Path(common)
|
|
112
|
+
while current != current.parent:
|
|
113
|
+
if (current / ".git").exists():
|
|
114
|
+
return str(current)
|
|
115
|
+
current = current.parent
|
|
116
|
+
|
|
117
|
+
return str(common) if common != Path("/") else None
|
|
118
|
+
|
|
119
|
+
def _matches_project(self, session_dir: Path, project_path: Path) -> bool:
|
|
120
|
+
"""
|
|
121
|
+
Check if a session directory contains artifacts related to a project.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
session_dir: Path to session artifact directory
|
|
125
|
+
project_path: Path to project to match
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
True if session is related to project
|
|
129
|
+
"""
|
|
130
|
+
project_path = project_path.resolve()
|
|
131
|
+
|
|
132
|
+
# Read all markdown artifacts
|
|
133
|
+
content = ""
|
|
134
|
+
for artifact in ["task.md", "implementation_plan.md", "walkthrough.md"]:
|
|
135
|
+
artifact_path = session_dir / artifact
|
|
136
|
+
if artifact_path.exists():
|
|
137
|
+
try:
|
|
138
|
+
content += artifact_path.read_text(encoding="utf-8")
|
|
139
|
+
except Exception:
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
if not content:
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
# Extract file paths from content
|
|
146
|
+
file_paths = self._extract_project_paths(content)
|
|
147
|
+
if not file_paths:
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
# Check if any path is within the project
|
|
151
|
+
for file_path in file_paths:
|
|
152
|
+
try:
|
|
153
|
+
file_path_obj = Path(file_path)
|
|
154
|
+
if file_path_obj == project_path:
|
|
155
|
+
return True
|
|
156
|
+
if file_path_obj.is_relative_to(project_path):
|
|
157
|
+
return True
|
|
158
|
+
if project_path.is_relative_to(file_path_obj):
|
|
159
|
+
return True
|
|
160
|
+
except (ValueError, TypeError):
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
def _get_session_timestamps(self, session_dir: Path) -> tuple[datetime | None, datetime | None]:
|
|
166
|
+
"""
|
|
167
|
+
Extract start and end timestamps from metadata files.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
session_dir: Path to session artifact directory
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
Tuple of (started_at, ended_at)
|
|
174
|
+
"""
|
|
175
|
+
timestamps = []
|
|
176
|
+
|
|
177
|
+
# Read all metadata.json files
|
|
178
|
+
for metadata_file in session_dir.glob("*.metadata.json"):
|
|
179
|
+
try:
|
|
180
|
+
with open(metadata_file, "r", encoding="utf-8") as f:
|
|
181
|
+
metadata = json.load(f)
|
|
182
|
+
if "updatedAt" in metadata:
|
|
183
|
+
ts_str = metadata["updatedAt"]
|
|
184
|
+
ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
|
185
|
+
timestamps.append(ts)
|
|
186
|
+
except Exception:
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
if not timestamps:
|
|
190
|
+
# Fallback to directory modification time
|
|
191
|
+
try:
|
|
192
|
+
mtime = datetime.fromtimestamp(session_dir.stat().st_mtime)
|
|
193
|
+
return (mtime, mtime)
|
|
194
|
+
except Exception:
|
|
195
|
+
return (None, None)
|
|
196
|
+
|
|
197
|
+
timestamps.sort()
|
|
198
|
+
return (timestamps[0], timestamps[-1])
|
|
199
|
+
|
|
200
|
+
def find_sessions(
|
|
201
|
+
self,
|
|
202
|
+
project_path: str | Path,
|
|
203
|
+
since: datetime | None = None,
|
|
204
|
+
) -> list[Path]:
|
|
205
|
+
"""Find Gemini Antigravity session directories for a project."""
|
|
206
|
+
project_path = Path(project_path).resolve()
|
|
207
|
+
|
|
208
|
+
if not self.GEMINI_HOME.exists():
|
|
209
|
+
return []
|
|
210
|
+
|
|
211
|
+
session_dirs = []
|
|
212
|
+
|
|
213
|
+
# Scan all conversation directories
|
|
214
|
+
for session_dir in self.GEMINI_HOME.iterdir():
|
|
215
|
+
if not session_dir.is_dir():
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
# Check if session matches project
|
|
219
|
+
if not self._matches_project(session_dir, project_path):
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
# Check timestamp filter
|
|
223
|
+
if since is not None:
|
|
224
|
+
started_at, ended_at = self._get_session_timestamps(session_dir)
|
|
225
|
+
if ended_at is not None:
|
|
226
|
+
# Handle timezone-aware vs timezone-naive comparison
|
|
227
|
+
if since.tzinfo is not None and ended_at.tzinfo is None:
|
|
228
|
+
from datetime import timezone
|
|
229
|
+
ended_at = ended_at.replace(tzinfo=timezone.utc)
|
|
230
|
+
elif since.tzinfo is None and ended_at.tzinfo is not None:
|
|
231
|
+
from datetime import timezone
|
|
232
|
+
since = since.replace(tzinfo=timezone.utc)
|
|
233
|
+
|
|
234
|
+
if ended_at < since:
|
|
235
|
+
continue
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
session_dirs.append(session_dir)
|
|
239
|
+
|
|
240
|
+
return sorted(session_dirs, key=lambda p: p.name)
|
|
241
|
+
|
|
242
|
+
def load_session(self, path: Path) -> Session | None:
|
|
243
|
+
"""Load a Gemini Antigravity session from an artifact directory."""
|
|
244
|
+
try:
|
|
245
|
+
session_id = path.name
|
|
246
|
+
|
|
247
|
+
# Get timestamps
|
|
248
|
+
started_at, ended_at = self._get_session_timestamps(path)
|
|
249
|
+
if started_at is None:
|
|
250
|
+
started_at = datetime.now()
|
|
251
|
+
|
|
252
|
+
# Read artifacts
|
|
253
|
+
task_content = ""
|
|
254
|
+
plan_content = ""
|
|
255
|
+
walkthrough_content = ""
|
|
256
|
+
|
|
257
|
+
task_path = path / "task.md"
|
|
258
|
+
if task_path.exists():
|
|
259
|
+
try:
|
|
260
|
+
task_content = task_path.read_text(encoding="utf-8")
|
|
261
|
+
except Exception:
|
|
262
|
+
pass
|
|
263
|
+
|
|
264
|
+
plan_path = path / "implementation_plan.md"
|
|
265
|
+
if plan_path.exists():
|
|
266
|
+
try:
|
|
267
|
+
plan_content = plan_path.read_text(encoding="utf-8")
|
|
268
|
+
except Exception:
|
|
269
|
+
pass
|
|
270
|
+
|
|
271
|
+
walkthrough_path = path / "walkthrough.md"
|
|
272
|
+
if walkthrough_path.exists():
|
|
273
|
+
try:
|
|
274
|
+
walkthrough_content = walkthrough_path.read_text(encoding="utf-8")
|
|
275
|
+
except Exception:
|
|
276
|
+
pass
|
|
277
|
+
|
|
278
|
+
# Extract project path
|
|
279
|
+
all_content = task_content + plan_content + walkthrough_content
|
|
280
|
+
file_paths = self._extract_project_paths(all_content)
|
|
281
|
+
cwd = self._find_common_project_root(file_paths)
|
|
282
|
+
|
|
283
|
+
# Create messages from artifacts
|
|
284
|
+
messages = []
|
|
285
|
+
|
|
286
|
+
# Extract goal/problem from plan or first task
|
|
287
|
+
goal = self._extract_goal(plan_content, task_content)
|
|
288
|
+
if goal:
|
|
289
|
+
messages.append(SessionMessage(
|
|
290
|
+
timestamp=started_at,
|
|
291
|
+
role=MessageRole.USER,
|
|
292
|
+
content=[ContentBlock(
|
|
293
|
+
type=ContentBlockType.TEXT,
|
|
294
|
+
text=goal,
|
|
295
|
+
)],
|
|
296
|
+
))
|
|
297
|
+
|
|
298
|
+
# Extract summary from walkthrough or completed tasks
|
|
299
|
+
summary = self._extract_summary(walkthrough_content, task_content)
|
|
300
|
+
if summary:
|
|
301
|
+
messages.append(SessionMessage(
|
|
302
|
+
timestamp=ended_at or started_at,
|
|
303
|
+
role=MessageRole.ASSISTANT,
|
|
304
|
+
content=[ContentBlock(
|
|
305
|
+
type=ContentBlockType.TEXT,
|
|
306
|
+
text=summary,
|
|
307
|
+
)],
|
|
308
|
+
))
|
|
309
|
+
|
|
310
|
+
if not messages:
|
|
311
|
+
# No extractable content
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
return Session(
|
|
315
|
+
id=session_id,
|
|
316
|
+
started_at=started_at,
|
|
317
|
+
ended_at=ended_at,
|
|
318
|
+
channel="gemini_antigravity",
|
|
319
|
+
messages=messages,
|
|
320
|
+
cwd=cwd,
|
|
321
|
+
git_branch=None, # Not available in artifacts
|
|
322
|
+
model="gemini-2.0-flash-thinking-exp", # Gemini model
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
except Exception as e:
|
|
326
|
+
import sys
|
|
327
|
+
print(f"Error loading Gemini session {path}: {e}", file=sys.stderr)
|
|
328
|
+
return None
|
|
329
|
+
|
|
330
|
+
def _extract_goal(self, plan_content: str, task_content: str) -> str | None:
|
|
331
|
+
"""Extract the goal/problem from implementation plan or tasks."""
|
|
332
|
+
# Try to extract from implementation plan
|
|
333
|
+
if plan_content:
|
|
334
|
+
# Look for ## Goal section
|
|
335
|
+
goal_match = re.search(r'##\s+Goal\s*\n(.+?)(?:\n##|\Z)', plan_content, re.DOTALL)
|
|
336
|
+
if goal_match:
|
|
337
|
+
goal = goal_match.group(1).strip()
|
|
338
|
+
# Clean up markdown
|
|
339
|
+
goal = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', goal) # Remove links
|
|
340
|
+
return goal[:500] # Limit length
|
|
341
|
+
|
|
342
|
+
# Try first heading
|
|
343
|
+
first_heading = re.search(r'^#\s+(.+)$', plan_content, re.MULTILINE)
|
|
344
|
+
if first_heading:
|
|
345
|
+
return first_heading.group(1).strip()
|
|
346
|
+
|
|
347
|
+
# Fallback to first task
|
|
348
|
+
if task_content:
|
|
349
|
+
# Find first unchecked or checked task
|
|
350
|
+
task_match = re.search(r'-\s+\[[ x]\]\s+(.+?)(?:\n|$)', task_content)
|
|
351
|
+
if task_match:
|
|
352
|
+
task = task_match.group(1).strip()
|
|
353
|
+
# Remove HTML comments
|
|
354
|
+
task = re.sub(r'<!--.*?-->', '', task).strip()
|
|
355
|
+
return f"Task: {task}"
|
|
356
|
+
|
|
357
|
+
return None
|
|
358
|
+
|
|
359
|
+
def _extract_summary(self, walkthrough_content: str, task_content: str) -> str | None:
|
|
360
|
+
"""Extract summary from walkthrough or completed tasks."""
|
|
361
|
+
# Try walkthrough first
|
|
362
|
+
if walkthrough_content:
|
|
363
|
+
# Remove title
|
|
364
|
+
content = re.sub(r'^#\s+.+?\n', '', walkthrough_content, count=1)
|
|
365
|
+
# Get first paragraph or section
|
|
366
|
+
paragraphs = [p.strip() for p in content.split('\n\n') if p.strip() and not p.strip().startswith('#')]
|
|
367
|
+
if paragraphs:
|
|
368
|
+
summary = paragraphs[0]
|
|
369
|
+
# Clean up markdown
|
|
370
|
+
summary = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', summary)
|
|
371
|
+
return summary[:1000]
|
|
372
|
+
|
|
373
|
+
# Fallback to completed tasks summary
|
|
374
|
+
if task_content:
|
|
375
|
+
completed = re.findall(r'-\s+\[x\]\s+(.+?)(?:\n|$)', task_content)
|
|
376
|
+
if completed:
|
|
377
|
+
# Remove HTML comments
|
|
378
|
+
completed = [re.sub(r'<!--.*?-->', '', t).strip() for t in completed]
|
|
379
|
+
return "Completed: " + "; ".join(completed[:5])
|
|
380
|
+
|
|
381
|
+
return None
|