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.
Files changed (43) 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 +4099 -280
  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-BYFVbEev.css +1 -0
  12. repr/dashboard/dist/assets/index-BrrhyJFO.css +1 -0
  13. repr/dashboard/dist/assets/index-CcEg74ts.js +270 -0
  14. repr/dashboard/dist/assets/index-Cerc-iA_.js +377 -0
  15. repr/dashboard/dist/assets/index-CjVcBW2L.css +1 -0
  16. repr/dashboard/dist/assets/index-Dfl3mR5E.js +377 -0
  17. repr/dashboard/dist/favicon.svg +4 -0
  18. repr/dashboard/dist/index.html +14 -0
  19. repr/dashboard/manager.py +234 -0
  20. repr/dashboard/server.py +1298 -0
  21. repr/db.py +980 -0
  22. repr/hooks.py +3 -2
  23. repr/loaders/__init__.py +22 -0
  24. repr/loaders/base.py +156 -0
  25. repr/loaders/claude_code.py +287 -0
  26. repr/loaders/clawdbot.py +313 -0
  27. repr/loaders/gemini_antigravity.py +381 -0
  28. repr/mcp_server.py +1196 -0
  29. repr/models.py +503 -0
  30. repr/openai_analysis.py +25 -0
  31. repr/session_extractor.py +481 -0
  32. repr/storage.py +328 -0
  33. repr/story_synthesis.py +1296 -0
  34. repr/templates.py +68 -4
  35. repr/timeline.py +710 -0
  36. repr/tools.py +17 -8
  37. {repr_cli-0.2.16.dist-info → repr_cli-0.2.17.dist-info}/METADATA +48 -10
  38. repr_cli-0.2.17.dist-info/RECORD +52 -0
  39. {repr_cli-0.2.16.dist-info → repr_cli-0.2.17.dist-info}/WHEEL +1 -1
  40. {repr_cli-0.2.16.dist-info → repr_cli-0.2.17.dist-info}/entry_points.txt +1 -0
  41. repr_cli-0.2.16.dist-info/RECORD +0 -26
  42. {repr_cli-0.2.16.dist-info → repr_cli-0.2.17.dist-info}/licenses/LICENSE +0 -0
  43. {repr_cli-0.2.16.dist-info → repr_cli-0.2.17.dist-info}/top_level.txt +0 -0
@@ -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