emdash-core 0.1.7__py3-none-any.whl → 0.1.33__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 (55) hide show
  1. emdash_core/__init__.py +6 -1
  2. emdash_core/agent/__init__.py +4 -0
  3. emdash_core/agent/events.py +52 -1
  4. emdash_core/agent/inprocess_subagent.py +123 -10
  5. emdash_core/agent/prompts/__init__.py +6 -0
  6. emdash_core/agent/prompts/main_agent.py +53 -3
  7. emdash_core/agent/prompts/plan_mode.py +255 -0
  8. emdash_core/agent/prompts/subagents.py +84 -16
  9. emdash_core/agent/prompts/workflow.py +270 -56
  10. emdash_core/agent/providers/base.py +4 -0
  11. emdash_core/agent/providers/factory.py +2 -2
  12. emdash_core/agent/providers/models.py +7 -0
  13. emdash_core/agent/providers/openai_provider.py +137 -13
  14. emdash_core/agent/runner/__init__.py +49 -0
  15. emdash_core/agent/runner/agent_runner.py +753 -0
  16. emdash_core/agent/runner/context.py +451 -0
  17. emdash_core/agent/runner/factory.py +108 -0
  18. emdash_core/agent/runner/plan.py +217 -0
  19. emdash_core/agent/runner/sdk_runner.py +324 -0
  20. emdash_core/agent/runner/utils.py +67 -0
  21. emdash_core/agent/skills.py +358 -0
  22. emdash_core/agent/toolkit.py +85 -5
  23. emdash_core/agent/toolkits/plan.py +9 -11
  24. emdash_core/agent/tools/__init__.py +3 -2
  25. emdash_core/agent/tools/coding.py +48 -4
  26. emdash_core/agent/tools/modes.py +207 -55
  27. emdash_core/agent/tools/search.py +4 -0
  28. emdash_core/agent/tools/skill.py +193 -0
  29. emdash_core/agent/tools/spec.py +61 -94
  30. emdash_core/agent/tools/task.py +41 -2
  31. emdash_core/agent/tools/tasks.py +15 -78
  32. emdash_core/api/agent.py +562 -8
  33. emdash_core/api/index.py +1 -1
  34. emdash_core/api/projectmd.py +4 -2
  35. emdash_core/api/router.py +2 -0
  36. emdash_core/api/skills.py +241 -0
  37. emdash_core/checkpoint/__init__.py +40 -0
  38. emdash_core/checkpoint/cli.py +175 -0
  39. emdash_core/checkpoint/git_operations.py +250 -0
  40. emdash_core/checkpoint/manager.py +231 -0
  41. emdash_core/checkpoint/models.py +107 -0
  42. emdash_core/checkpoint/storage.py +201 -0
  43. emdash_core/config.py +1 -1
  44. emdash_core/core/config.py +18 -2
  45. emdash_core/graph/schema.py +5 -5
  46. emdash_core/ingestion/orchestrator.py +19 -10
  47. emdash_core/models/agent.py +1 -1
  48. emdash_core/server.py +42 -0
  49. emdash_core/skills/frontend-design/SKILL.md +56 -0
  50. emdash_core/sse/stream.py +5 -0
  51. {emdash_core-0.1.7.dist-info → emdash_core-0.1.33.dist-info}/METADATA +2 -2
  52. {emdash_core-0.1.7.dist-info → emdash_core-0.1.33.dist-info}/RECORD +54 -37
  53. {emdash_core-0.1.7.dist-info → emdash_core-0.1.33.dist-info}/entry_points.txt +1 -0
  54. emdash_core/agent/runner.py +0 -601
  55. {emdash_core-0.1.7.dist-info → emdash_core-0.1.33.dist-info}/WHEEL +0 -0
@@ -0,0 +1,241 @@
1
+ """Skill management endpoints."""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ from fastapi import APIRouter, HTTPException
7
+ from pydantic import BaseModel, Field
8
+
9
+ from ..config import get_config
10
+
11
+ router = APIRouter(prefix="/skills", tags=["skills"])
12
+
13
+
14
+ class SkillInfo(BaseModel):
15
+ """Skill information."""
16
+ name: str
17
+ description: str
18
+ user_invocable: bool
19
+ tools: list[str]
20
+ path: str
21
+ exists: bool
22
+
23
+
24
+ class SkillListResponse(BaseModel):
25
+ """Response for listing skills."""
26
+ skills: list[SkillInfo]
27
+ count: int
28
+
29
+
30
+ class CreateSkillRequest(BaseModel):
31
+ """Request to create a skill."""
32
+ name: str = Field(..., description="Skill name (lowercase, hyphens allowed, max 64 chars)")
33
+ description: str = Field(..., description="Brief description of when to use this skill")
34
+ user_invocable: bool = Field(default=True, description="Whether skill can be invoked with /name")
35
+ tools: list[str] = Field(default=[], description="List of tools this skill needs")
36
+ instructions: str = Field(default="", description="Skill instructions/content")
37
+
38
+
39
+ class CreateSkillResponse(BaseModel):
40
+ """Response from creating a skill."""
41
+ success: bool
42
+ name: str
43
+ path: Optional[str] = None
44
+ error: Optional[str] = None
45
+
46
+
47
+ class InvokeSkillRequest(BaseModel):
48
+ """Request to invoke a skill."""
49
+ args: str = Field(default="", description="Optional arguments for the skill")
50
+
51
+
52
+ class InvokeSkillResponse(BaseModel):
53
+ """Response from invoking a skill."""
54
+ skill_name: str
55
+ description: str
56
+ instructions: str
57
+ tools: list[str]
58
+ args: str
59
+
60
+
61
+ def _get_skills_dir() -> Path:
62
+ """Get the skills directory for the current repo."""
63
+ config = get_config()
64
+ if config.repo_root:
65
+ return Path(config.repo_root) / ".emdash" / "skills"
66
+ return Path.cwd() / ".emdash" / "skills"
67
+
68
+
69
+ @router.get("", response_model=SkillListResponse)
70
+ async def list_skills():
71
+ """List all configured skills.
72
+
73
+ Returns skills from the .emdash/skills directory.
74
+ """
75
+ from ..agent.skills import SkillRegistry
76
+
77
+ skills_dir = _get_skills_dir()
78
+ registry = SkillRegistry.get_instance()
79
+
80
+ # Reload skills to get latest
81
+ registry.load_skills(skills_dir)
82
+
83
+ skills = []
84
+ for skill in registry.get_all_skills().values():
85
+ skills.append(SkillInfo(
86
+ name=skill.name,
87
+ description=skill.description,
88
+ user_invocable=skill.user_invocable,
89
+ tools=skill.tools,
90
+ path=str(skill.file_path) if skill.file_path else "",
91
+ exists=True,
92
+ ))
93
+
94
+ return SkillListResponse(skills=skills, count=len(skills))
95
+
96
+
97
+ @router.post("", response_model=CreateSkillResponse)
98
+ async def create_skill(request: CreateSkillRequest):
99
+ """Create a new skill.
100
+
101
+ Creates a skill directory with SKILL.md in .emdash/skills/
102
+ """
103
+ # Validate name
104
+ name = request.name.lower().strip()
105
+ if len(name) > 64:
106
+ raise HTTPException(
107
+ status_code=400,
108
+ detail="Skill name must be 64 characters or less"
109
+ )
110
+
111
+ if not name.replace("-", "").replace("_", "").isalnum():
112
+ raise HTTPException(
113
+ status_code=400,
114
+ detail="Skill name must contain only lowercase letters, numbers, hyphens, and underscores"
115
+ )
116
+
117
+ skills_dir = _get_skills_dir()
118
+ skill_dir = skills_dir / name
119
+ skill_file = skill_dir / "SKILL.md"
120
+
121
+ if skill_dir.exists():
122
+ raise HTTPException(
123
+ status_code=409,
124
+ detail=f"Skill '{name}' already exists"
125
+ )
126
+
127
+ # Build frontmatter
128
+ tools_str = ", ".join(request.tools) if request.tools else ""
129
+
130
+ content = f"""---
131
+ name: {name}
132
+ description: {request.description}
133
+ user_invocable: {str(request.user_invocable).lower()}
134
+ tools: [{tools_str}]
135
+ ---
136
+
137
+ # {name.replace('-', ' ').title()}
138
+
139
+ {request.instructions if request.instructions else f"Instructions for {name} skill."}
140
+
141
+ ## Usage
142
+
143
+ Describe how this skill should be used.
144
+
145
+ ## Examples
146
+
147
+ Provide example scenarios here.
148
+ """
149
+
150
+ try:
151
+ skill_dir.mkdir(parents=True, exist_ok=True)
152
+ skill_file.write_text(content)
153
+ return CreateSkillResponse(
154
+ success=True,
155
+ name=name,
156
+ path=str(skill_file),
157
+ )
158
+ except Exception as e:
159
+ return CreateSkillResponse(
160
+ success=False,
161
+ name=name,
162
+ error=str(e),
163
+ )
164
+
165
+
166
+ @router.get("/{name}", response_model=SkillInfo)
167
+ async def get_skill(name: str):
168
+ """Get a specific skill's information."""
169
+ from ..agent.skills import SkillRegistry
170
+
171
+ skills_dir = _get_skills_dir()
172
+ registry = SkillRegistry.get_instance()
173
+ registry.load_skills(skills_dir)
174
+
175
+ skill = registry.get_skill(name)
176
+
177
+ if skill is None:
178
+ raise HTTPException(
179
+ status_code=404,
180
+ detail=f"Skill '{name}' not found"
181
+ )
182
+
183
+ return SkillInfo(
184
+ name=skill.name,
185
+ description=skill.description,
186
+ user_invocable=skill.user_invocable,
187
+ tools=skill.tools,
188
+ path=str(skill.file_path) if skill.file_path else "",
189
+ exists=True,
190
+ )
191
+
192
+
193
+ @router.delete("/{name}")
194
+ async def delete_skill(name: str):
195
+ """Delete a skill."""
196
+ skills_dir = _get_skills_dir()
197
+ skill_dir = skills_dir / name
198
+
199
+ if not skill_dir.exists():
200
+ raise HTTPException(
201
+ status_code=404,
202
+ detail=f"Skill '{name}' not found"
203
+ )
204
+
205
+ import shutil
206
+ shutil.rmtree(skill_dir)
207
+
208
+ # Reset registry to remove cached skill
209
+ from ..agent.skills import SkillRegistry
210
+ SkillRegistry.reset()
211
+
212
+ return {"deleted": True, "name": name}
213
+
214
+
215
+ @router.post("/{name}/invoke", response_model=InvokeSkillResponse)
216
+ async def invoke_skill(name: str, request: InvokeSkillRequest):
217
+ """Invoke a skill and get its instructions.
218
+
219
+ Returns the skill's instructions for the agent to follow.
220
+ """
221
+ from ..agent.skills import SkillRegistry
222
+
223
+ skills_dir = _get_skills_dir()
224
+ registry = SkillRegistry.get_instance()
225
+ registry.load_skills(skills_dir)
226
+
227
+ skill = registry.get_skill(name)
228
+
229
+ if skill is None:
230
+ raise HTTPException(
231
+ status_code=404,
232
+ detail=f"Skill '{name}' not found"
233
+ )
234
+
235
+ return InvokeSkillResponse(
236
+ skill_name=skill.name,
237
+ description=skill.description,
238
+ instructions=skill.instructions,
239
+ tools=skill.tools,
240
+ args=request.args,
241
+ )
@@ -0,0 +1,40 @@
1
+ """Checkpoint module for git-based agent checkpoints.
2
+
3
+ This module provides automatic checkpoint creation after each agentic
4
+ loop completes, storing file changes as git commits with metadata.
5
+
6
+ Example:
7
+ from emdash_core.checkpoint import CheckpointManager
8
+
9
+ manager = CheckpointManager(repo_root=Path("."))
10
+
11
+ # Create checkpoint after agent run
12
+ checkpoint = manager.create_checkpoint(
13
+ messages=runner._messages,
14
+ model=runner.model,
15
+ system_prompt=runner.system_prompt,
16
+ tools_used=["read_file", "write_to_file"],
17
+ token_usage={"input": 1000, "output": 500},
18
+ )
19
+
20
+ # List checkpoints
21
+ for cp in manager.list_checkpoints():
22
+ print(f"{cp.id}: {cp.summary}")
23
+
24
+ # Restore to checkpoint
25
+ conv = manager.restore_checkpoint("cp_abc123_001")
26
+ """
27
+
28
+ from .manager import CheckpointManager
29
+ from .models import CheckpointMetadata, ConversationState, CheckpointIndex
30
+ from .storage import CheckpointStorage
31
+ from .git_operations import GitCheckpointOperations
32
+
33
+ __all__ = [
34
+ "CheckpointManager",
35
+ "CheckpointMetadata",
36
+ "ConversationState",
37
+ "CheckpointIndex",
38
+ "CheckpointStorage",
39
+ "GitCheckpointOperations",
40
+ ]
@@ -0,0 +1,175 @@
1
+ """CLI commands for checkpoint management."""
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from .manager import CheckpointManager
10
+ from .storage import CheckpointStorage
11
+
12
+
13
+ def find_repo_root() -> Path:
14
+ """Find the git repository root from current directory."""
15
+ current = Path.cwd()
16
+ while current != current.parent:
17
+ if (current / ".git").exists():
18
+ return current
19
+ current = current.parent
20
+ return Path.cwd()
21
+
22
+
23
+ def cmd_list(args: argparse.Namespace) -> int:
24
+ """List available checkpoints."""
25
+ repo_root = find_repo_root()
26
+ storage = CheckpointStorage(repo_root)
27
+ checkpoints = storage.get_checkpoints(
28
+ session_id=args.session,
29
+ limit=args.limit,
30
+ )
31
+
32
+ if not checkpoints:
33
+ print("No checkpoints found.")
34
+ return 0
35
+
36
+ if args.format == "json":
37
+ print(json.dumps([cp.to_dict() for cp in checkpoints], indent=2))
38
+ else:
39
+ # Table format
40
+ print(f"{'ID':<20} {'Session':<10} {'Iter':>4} {'Timestamp':<20} {'Summary':<40} {'Commit':<8}")
41
+ print("-" * 110)
42
+ for cp in checkpoints:
43
+ summary = cp.summary[:37] + "..." if len(cp.summary) > 40 else cp.summary
44
+ commit = cp.commit_sha[:8] if cp.commit_sha else "N/A"
45
+ print(f"{cp.id:<20} {cp.session_id:<10} {cp.iteration:>4} {cp.timestamp[:19]:<20} {summary:<40} {commit:<8}")
46
+
47
+ return 0
48
+
49
+
50
+ def cmd_show(args: argparse.Namespace) -> int:
51
+ """Show checkpoint details."""
52
+ repo_root = find_repo_root()
53
+ storage = CheckpointStorage(repo_root)
54
+ metadata = storage.find_checkpoint(args.checkpoint_id)
55
+
56
+ if not metadata:
57
+ print(f"Checkpoint not found: {args.checkpoint_id}", file=sys.stderr)
58
+ return 1
59
+
60
+ print(f"Checkpoint: {metadata.id}")
61
+ print(f"Session: {metadata.session_id}")
62
+ print(f"Iteration: {metadata.iteration}")
63
+ print(f"Timestamp: {metadata.timestamp}")
64
+ print(f"Commit: {metadata.commit_sha or 'N/A'}")
65
+ print(f"Summary: {metadata.summary}")
66
+ print(f"Tools Used: {', '.join(metadata.tools_used) or 'None'}")
67
+ print(f"Files Modified:")
68
+ for f in metadata.files_modified:
69
+ print(f" - {f}")
70
+ print(f"Token Usage:")
71
+ for key, value in metadata.token_usage.items():
72
+ print(f" - {key}: {value}")
73
+
74
+ return 0
75
+
76
+
77
+ def cmd_restore(args: argparse.Namespace) -> int:
78
+ """Restore to a checkpoint."""
79
+ repo_root = find_repo_root()
80
+ manager = CheckpointManager(repo_root)
81
+
82
+ try:
83
+ conv_state = manager.restore_checkpoint(
84
+ args.checkpoint_id,
85
+ restore_conversation=not args.no_conversation,
86
+ )
87
+
88
+ print(f"Restored to checkpoint {args.checkpoint_id}")
89
+
90
+ if conv_state:
91
+ print(f"Conversation restored with {len(conv_state.messages)} messages")
92
+ print(f"Model: {conv_state.model}")
93
+
94
+ return 0
95
+
96
+ except ValueError as e:
97
+ print(f"Error: {e}", file=sys.stderr)
98
+ return 1
99
+ except Exception as e:
100
+ print(f"Error: {e}", file=sys.stderr)
101
+ return 1
102
+
103
+
104
+ def cmd_diff(args: argparse.Namespace) -> int:
105
+ """Show diff between two checkpoints."""
106
+ repo_root = find_repo_root()
107
+ storage = CheckpointStorage(repo_root)
108
+
109
+ cp1 = storage.find_checkpoint(args.checkpoint1)
110
+ cp2 = storage.find_checkpoint(args.checkpoint2)
111
+
112
+ if not cp1:
113
+ print(f"Checkpoint not found: {args.checkpoint1}", file=sys.stderr)
114
+ return 1
115
+ if not cp2:
116
+ print(f"Checkpoint not found: {args.checkpoint2}", file=sys.stderr)
117
+ return 1
118
+
119
+ if not cp1.commit_sha or not cp2.commit_sha:
120
+ print("Cannot diff: one or both checkpoints have no commit SHA", file=sys.stderr)
121
+ return 1
122
+
123
+ # Use git diff
124
+ import subprocess
125
+ result = subprocess.run(
126
+ ["git", "diff", cp1.commit_sha, cp2.commit_sha],
127
+ cwd=str(repo_root),
128
+ capture_output=True,
129
+ text=True,
130
+ )
131
+ print(result.stdout)
132
+ if result.stderr:
133
+ print(result.stderr, file=sys.stderr)
134
+
135
+ return result.returncode
136
+
137
+
138
+ def main(argv: Optional[list[str]] = None) -> int:
139
+ """Main CLI entry point."""
140
+ parser = argparse.ArgumentParser(
141
+ prog="emdash-checkpoint",
142
+ description="Manage emdash checkpoints",
143
+ )
144
+ subparsers = parser.add_subparsers(dest="command", required=True)
145
+
146
+ # list command
147
+ list_parser = subparsers.add_parser("list", help="List available checkpoints")
148
+ list_parser.add_argument("-s", "--session", help="Filter by session ID")
149
+ list_parser.add_argument("-n", "--limit", type=int, default=20, help="Number of checkpoints to show")
150
+ list_parser.add_argument("--format", choices=["table", "json"], default="table", help="Output format")
151
+ list_parser.set_defaults(func=cmd_list)
152
+
153
+ # show command
154
+ show_parser = subparsers.add_parser("show", help="Show checkpoint details")
155
+ show_parser.add_argument("checkpoint_id", help="Checkpoint ID to show")
156
+ show_parser.set_defaults(func=cmd_show)
157
+
158
+ # restore command
159
+ restore_parser = subparsers.add_parser("restore", help="Restore to a checkpoint")
160
+ restore_parser.add_argument("checkpoint_id", help="Checkpoint ID to restore")
161
+ restore_parser.add_argument("--no-conversation", action="store_true", help="Skip conversation restore")
162
+ restore_parser.set_defaults(func=cmd_restore)
163
+
164
+ # diff command
165
+ diff_parser = subparsers.add_parser("diff", help="Show diff between two checkpoints")
166
+ diff_parser.add_argument("checkpoint1", help="First checkpoint ID")
167
+ diff_parser.add_argument("checkpoint2", help="Second checkpoint ID")
168
+ diff_parser.set_defaults(func=cmd_diff)
169
+
170
+ args = parser.parse_args(argv)
171
+ return args.func(args)
172
+
173
+
174
+ if __name__ == "__main__":
175
+ sys.exit(main())
@@ -0,0 +1,250 @@
1
+ """Git operations for checkpoint system."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from git import Repo
8
+ from git.exc import GitCommandError
9
+
10
+ from ..utils.logger import log
11
+ from .models import CheckpointMetadata
12
+
13
+
14
+ class GitCheckpointOperations:
15
+ """Handles git operations for checkpoints.
16
+
17
+ Creates specially-formatted commits that can be identified
18
+ and parsed as checkpoints.
19
+ """
20
+
21
+ CHECKPOINT_PREFIX = "[emdash-checkpoint]"
22
+ METADATA_START = "---EMDASH_METADATA---"
23
+ METADATA_END = "---END_METADATA---"
24
+
25
+ def __init__(self, repo_root: Path):
26
+ """Initialize git operations.
27
+
28
+ Args:
29
+ repo_root: Root of the git repository
30
+ """
31
+ self.repo_root = repo_root.resolve()
32
+ self.repo = Repo(self.repo_root)
33
+
34
+ def has_changes(self) -> bool:
35
+ """Check if there are uncommitted changes.
36
+
37
+ Returns:
38
+ True if there are staged, unstaged, or untracked changes
39
+ """
40
+ return self.repo.is_dirty(untracked_files=True)
41
+
42
+ def get_modified_files(self) -> list[str]:
43
+ """Get list of modified/added/deleted files.
44
+
45
+ Returns:
46
+ List of file paths relative to repo root
47
+ """
48
+ files = set()
49
+
50
+ # Staged changes
51
+ for item in self.repo.index.diff("HEAD"):
52
+ files.add(item.a_path)
53
+ if item.b_path:
54
+ files.add(item.b_path)
55
+
56
+ # Unstaged changes
57
+ for item in self.repo.index.diff(None):
58
+ files.add(item.a_path)
59
+ if item.b_path:
60
+ files.add(item.b_path)
61
+
62
+ # Untracked files
63
+ files.update(self.repo.untracked_files)
64
+
65
+ return sorted(files)
66
+
67
+ def create_checkpoint_commit(
68
+ self,
69
+ metadata: CheckpointMetadata,
70
+ summary: str,
71
+ ) -> str:
72
+ """Create a checkpoint commit.
73
+
74
+ Stages all changes and creates a commit with structured
75
+ metadata in the commit message.
76
+
77
+ Args:
78
+ metadata: Checkpoint metadata to include
79
+ summary: Human-readable summary of changes
80
+
81
+ Returns:
82
+ Commit SHA
83
+
84
+ Raises:
85
+ GitCommandError: If commit fails
86
+ """
87
+ # Stage all changes
88
+ self.repo.git.add("-A")
89
+
90
+ # Build commit message
91
+ message = self._build_commit_message(metadata, summary)
92
+
93
+ # Create commit
94
+ commit = self.repo.index.commit(message)
95
+ log.info(f"Created checkpoint commit {commit.hexsha[:8]}")
96
+
97
+ return commit.hexsha
98
+
99
+ def _build_commit_message(
100
+ self,
101
+ metadata: CheckpointMetadata,
102
+ summary: str,
103
+ ) -> str:
104
+ """Build the structured commit message.
105
+
106
+ Format:
107
+ [emdash-checkpoint] Auto-checkpoint #{iteration}
108
+
109
+ Summary: {summary}
110
+
111
+ ---EMDASH_METADATA---
112
+ {json metadata}
113
+ ---END_METADATA---
114
+ """
115
+ # Build metadata dict (excluding commit_sha which isn't set yet)
116
+ meta_dict = {
117
+ "checkpoint_version": "1.0",
118
+ "id": metadata.id,
119
+ "session_id": metadata.session_id,
120
+ "iteration": metadata.iteration,
121
+ "timestamp": metadata.timestamp,
122
+ "tools_used": metadata.tools_used,
123
+ "files_modified": metadata.files_modified,
124
+ "token_usage": metadata.token_usage,
125
+ }
126
+
127
+ lines = [
128
+ f"{self.CHECKPOINT_PREFIX} Auto-checkpoint #{metadata.iteration}",
129
+ "",
130
+ f"Summary: {summary}",
131
+ "",
132
+ self.METADATA_START,
133
+ json.dumps(meta_dict, indent=2),
134
+ self.METADATA_END,
135
+ ]
136
+
137
+ return "\n".join(lines)
138
+
139
+ def parse_checkpoint_commit(self, commit) -> Optional[CheckpointMetadata]:
140
+ """Parse checkpoint metadata from a commit.
141
+
142
+ Args:
143
+ commit: Git commit object
144
+
145
+ Returns:
146
+ CheckpointMetadata if commit is a checkpoint, None otherwise
147
+ """
148
+ if not commit.message.startswith(self.CHECKPOINT_PREFIX):
149
+ return None
150
+
151
+ try:
152
+ # Extract JSON between markers
153
+ message = commit.message
154
+ start_idx = message.find(self.METADATA_START)
155
+ end_idx = message.find(self.METADATA_END)
156
+
157
+ if start_idx == -1 or end_idx == -1:
158
+ return None
159
+
160
+ json_str = message[start_idx + len(self.METADATA_START):end_idx].strip()
161
+ data = json.loads(json_str)
162
+
163
+ return CheckpointMetadata(
164
+ id=data.get("id", ""),
165
+ session_id=data.get("session_id", ""),
166
+ iteration=data.get("iteration", 0),
167
+ timestamp=data.get("timestamp", ""),
168
+ commit_sha=commit.hexsha,
169
+ summary=self._extract_summary(message),
170
+ tools_used=data.get("tools_used", []),
171
+ files_modified=data.get("files_modified", []),
172
+ token_usage=data.get("token_usage", {}),
173
+ )
174
+ except (json.JSONDecodeError, KeyError) as e:
175
+ log.warning(f"Failed to parse checkpoint commit {commit.hexsha[:8]}: {e}")
176
+ return None
177
+
178
+ def _extract_summary(self, message: str) -> str:
179
+ """Extract summary line from commit message."""
180
+ for line in message.split("\n"):
181
+ if line.startswith("Summary:"):
182
+ return line[8:].strip()
183
+ return ""
184
+
185
+ def list_checkpoint_commits(self, limit: int = 50) -> list[CheckpointMetadata]:
186
+ """List all checkpoint commits.
187
+
188
+ Args:
189
+ limit: Maximum number of commits to search
190
+
191
+ Returns:
192
+ List of CheckpointMetadata, most recent first
193
+ """
194
+ checkpoints = []
195
+
196
+ try:
197
+ for commit in self.repo.iter_commits(max_count=limit * 2):
198
+ metadata = self.parse_checkpoint_commit(commit)
199
+ if metadata:
200
+ checkpoints.append(metadata)
201
+ if len(checkpoints) >= limit:
202
+ break
203
+ except GitCommandError as e:
204
+ log.warning(f"Failed to list checkpoint commits: {e}")
205
+
206
+ return checkpoints
207
+
208
+ def restore_to_commit(self, commit_sha: str, create_branch: bool = True) -> str:
209
+ """Restore working directory to a specific commit.
210
+
211
+ Args:
212
+ commit_sha: SHA of commit to restore to
213
+ create_branch: If True, create a branch at the restored state
214
+
215
+ Returns:
216
+ Branch name if created, or commit SHA if detached
217
+
218
+ Raises:
219
+ GitCommandError: If restore fails
220
+ """
221
+ # Check for uncommitted changes
222
+ if self.has_changes():
223
+ raise GitCommandError(
224
+ "restore",
225
+ "Cannot restore: uncommitted changes exist. "
226
+ "Please commit or stash your changes first.",
227
+ )
228
+
229
+ if create_branch:
230
+ # Create a new branch at the checkpoint
231
+ branch_name = f"emdash/restore-{commit_sha[:8]}"
232
+ self.repo.git.checkout("-b", branch_name, commit_sha)
233
+ log.info(f"Restored to {commit_sha[:8]} on branch {branch_name}")
234
+ return branch_name
235
+ else:
236
+ # Detached HEAD
237
+ self.repo.git.checkout(commit_sha)
238
+ log.info(f"Restored to {commit_sha[:8]} (detached HEAD)")
239
+ return commit_sha
240
+
241
+ def get_commit(self, commit_sha: str):
242
+ """Get a commit object by SHA.
243
+
244
+ Args:
245
+ commit_sha: Full or partial commit SHA
246
+
247
+ Returns:
248
+ Git commit object
249
+ """
250
+ return self.repo.commit(commit_sha)