emdash-core 0.1.7__py3-none-any.whl → 0.1.25__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.
- emdash_core/__init__.py +6 -1
- emdash_core/agent/events.py +29 -0
- emdash_core/agent/prompts/__init__.py +5 -0
- emdash_core/agent/prompts/main_agent.py +22 -2
- emdash_core/agent/prompts/plan_mode.py +126 -0
- emdash_core/agent/prompts/subagents.py +11 -7
- emdash_core/agent/prompts/workflow.py +138 -43
- emdash_core/agent/providers/base.py +4 -0
- emdash_core/agent/providers/models.py +7 -0
- emdash_core/agent/providers/openai_provider.py +74 -2
- emdash_core/agent/runner.py +556 -34
- emdash_core/agent/skills.py +319 -0
- emdash_core/agent/toolkit.py +48 -0
- emdash_core/agent/tools/__init__.py +3 -2
- emdash_core/agent/tools/modes.py +197 -53
- emdash_core/agent/tools/search.py +4 -0
- emdash_core/agent/tools/skill.py +193 -0
- emdash_core/agent/tools/spec.py +61 -94
- emdash_core/agent/tools/tasks.py +15 -78
- emdash_core/api/agent.py +7 -7
- emdash_core/api/index.py +1 -1
- emdash_core/api/projectmd.py +4 -2
- emdash_core/api/router.py +2 -0
- emdash_core/api/skills.py +241 -0
- emdash_core/checkpoint/__init__.py +40 -0
- emdash_core/checkpoint/cli.py +175 -0
- emdash_core/checkpoint/git_operations.py +250 -0
- emdash_core/checkpoint/manager.py +231 -0
- emdash_core/checkpoint/models.py +107 -0
- emdash_core/checkpoint/storage.py +201 -0
- emdash_core/config.py +1 -1
- emdash_core/core/config.py +18 -2
- emdash_core/graph/schema.py +5 -5
- emdash_core/ingestion/orchestrator.py +19 -10
- emdash_core/models/agent.py +1 -1
- emdash_core/server.py +42 -0
- emdash_core/sse/stream.py +1 -0
- {emdash_core-0.1.7.dist-info → emdash_core-0.1.25.dist-info}/METADATA +1 -2
- {emdash_core-0.1.7.dist-info → emdash_core-0.1.25.dist-info}/RECORD +41 -31
- {emdash_core-0.1.7.dist-info → emdash_core-0.1.25.dist-info}/entry_points.txt +1 -0
- {emdash_core-0.1.7.dist-info → emdash_core-0.1.25.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)
|