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,319 @@
|
|
|
1
|
+
"""Skill loader from .emdash/skills/*/SKILL.md files.
|
|
2
|
+
|
|
3
|
+
Skills are markdown-based instruction files that teach the agent how to
|
|
4
|
+
perform specific, repeatable tasks. They can be automatically applied
|
|
5
|
+
when relevant or explicitly invoked via /skill_name.
|
|
6
|
+
|
|
7
|
+
Similar to Claude Code's skills system:
|
|
8
|
+
https://docs.anthropic.com/en/docs/claude-code/skills
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from ..utils.logger import log
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Skill:
|
|
20
|
+
"""A skill configuration loaded from SKILL.md.
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
name: Unique skill identifier (from directory name or frontmatter)
|
|
24
|
+
description: Brief description of when to use this skill
|
|
25
|
+
instructions: The main prompt/instructions content
|
|
26
|
+
tools: List of tools this skill needs access to
|
|
27
|
+
user_invocable: Whether skill can be invoked with /name
|
|
28
|
+
file_path: Source file path
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
name: str
|
|
32
|
+
description: str = ""
|
|
33
|
+
instructions: str = ""
|
|
34
|
+
tools: list[str] = field(default_factory=list)
|
|
35
|
+
user_invocable: bool = False
|
|
36
|
+
file_path: Optional[Path] = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SkillRegistry:
|
|
40
|
+
"""Registry for managing loaded skills.
|
|
41
|
+
|
|
42
|
+
Singleton that maintains the list of available skills
|
|
43
|
+
and provides lookup functionality.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
_instance: Optional["SkillRegistry"] = None
|
|
47
|
+
_skills: dict[str, Skill]
|
|
48
|
+
_skills_dir: Optional[Path]
|
|
49
|
+
|
|
50
|
+
def __new__(cls):
|
|
51
|
+
if cls._instance is None:
|
|
52
|
+
cls._instance = super().__new__(cls)
|
|
53
|
+
cls._instance._skills = {}
|
|
54
|
+
cls._instance._skills_dir = None
|
|
55
|
+
return cls._instance
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def get_instance(cls) -> "SkillRegistry":
|
|
59
|
+
"""Get the singleton instance."""
|
|
60
|
+
return cls()
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def reset(cls) -> None:
|
|
64
|
+
"""Reset the singleton instance."""
|
|
65
|
+
if cls._instance is not None:
|
|
66
|
+
cls._instance._skills = {}
|
|
67
|
+
cls._instance._skills_dir = None
|
|
68
|
+
|
|
69
|
+
def load_skills(self, skills_dir: Optional[Path] = None) -> dict[str, Skill]:
|
|
70
|
+
"""Load skills from .emdash/skills/ directory.
|
|
71
|
+
|
|
72
|
+
Each skill is a directory containing a SKILL.md file:
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
.emdash/skills/
|
|
76
|
+
├── commit/
|
|
77
|
+
│ └── SKILL.md
|
|
78
|
+
└── review-pr/
|
|
79
|
+
└── SKILL.md
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
SKILL.md format:
|
|
83
|
+
```markdown
|
|
84
|
+
---
|
|
85
|
+
name: commit
|
|
86
|
+
description: Generate commit messages following conventions
|
|
87
|
+
user_invocable: true
|
|
88
|
+
tools: [execute_command, read_file]
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
# Commit Message Generation
|
|
92
|
+
|
|
93
|
+
Instructions for the skill...
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
skills_dir: Directory containing skill subdirectories.
|
|
98
|
+
Defaults to .emdash/skills/ in cwd.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Dict mapping skill name to Skill
|
|
102
|
+
"""
|
|
103
|
+
if skills_dir is None:
|
|
104
|
+
skills_dir = Path.cwd() / ".emdash" / "skills"
|
|
105
|
+
|
|
106
|
+
self._skills_dir = skills_dir
|
|
107
|
+
|
|
108
|
+
if not skills_dir.exists():
|
|
109
|
+
return {}
|
|
110
|
+
|
|
111
|
+
skills = {}
|
|
112
|
+
|
|
113
|
+
# Look for SKILL.md in subdirectories
|
|
114
|
+
for skill_dir in skills_dir.iterdir():
|
|
115
|
+
if not skill_dir.is_dir():
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
skill_file = skill_dir / "SKILL.md"
|
|
119
|
+
if not skill_file.exists():
|
|
120
|
+
# Also try lowercase
|
|
121
|
+
skill_file = skill_dir / "skill.md"
|
|
122
|
+
if not skill_file.exists():
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
skill = _parse_skill_file(skill_file, skill_dir.name)
|
|
127
|
+
if skill:
|
|
128
|
+
skills[skill.name] = skill
|
|
129
|
+
self._skills[skill.name] = skill
|
|
130
|
+
log.debug(f"Loaded skill: {skill.name}")
|
|
131
|
+
except Exception as e:
|
|
132
|
+
log.warning(f"Failed to load skill from {skill_file}: {e}")
|
|
133
|
+
|
|
134
|
+
if skills:
|
|
135
|
+
log.info(f"Loaded {len(skills)} skills")
|
|
136
|
+
|
|
137
|
+
return skills
|
|
138
|
+
|
|
139
|
+
def get_skill(self, name: str) -> Optional[Skill]:
|
|
140
|
+
"""Get a specific skill by name.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
name: Skill name
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Skill or None if not found
|
|
147
|
+
"""
|
|
148
|
+
return self._skills.get(name)
|
|
149
|
+
|
|
150
|
+
def list_skills(self) -> list[str]:
|
|
151
|
+
"""List available skill names.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
List of skill names
|
|
155
|
+
"""
|
|
156
|
+
return list(self._skills.keys())
|
|
157
|
+
|
|
158
|
+
def get_all_skills(self) -> dict[str, Skill]:
|
|
159
|
+
"""Get all loaded skills.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
Dict mapping skill name to Skill
|
|
163
|
+
"""
|
|
164
|
+
return self._skills.copy()
|
|
165
|
+
|
|
166
|
+
def get_user_invocable_skills(self) -> list[Skill]:
|
|
167
|
+
"""Get skills that can be invoked with /name.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
List of user-invocable skills
|
|
171
|
+
"""
|
|
172
|
+
return [s for s in self._skills.values() if s.user_invocable]
|
|
173
|
+
|
|
174
|
+
def get_skills_for_prompt(self) -> str:
|
|
175
|
+
"""Generate skills section for system prompt.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
Formatted string describing available skills
|
|
179
|
+
"""
|
|
180
|
+
if not self._skills:
|
|
181
|
+
return ""
|
|
182
|
+
|
|
183
|
+
lines = ["## Available Skills\n"]
|
|
184
|
+
lines.append("The following skills are available. Use them when the task matches their description:\n")
|
|
185
|
+
|
|
186
|
+
for skill in self._skills.values():
|
|
187
|
+
invocable = " (user-invocable: /{})".format(skill.name) if skill.user_invocable else ""
|
|
188
|
+
lines.append(f"- **{skill.name}**: {skill.description}{invocable}")
|
|
189
|
+
|
|
190
|
+
lines.append("")
|
|
191
|
+
lines.append("To activate a skill, use the `skill` tool with the skill name.")
|
|
192
|
+
lines.append("")
|
|
193
|
+
|
|
194
|
+
return "\n".join(lines)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _parse_skill_file(file_path: Path, default_name: str) -> Optional[Skill]:
|
|
198
|
+
"""Parse a single SKILL.md file.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
file_path: Path to the SKILL.md file
|
|
202
|
+
default_name: Default name from directory (used if not in frontmatter)
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Skill or None if parsing fails
|
|
206
|
+
"""
|
|
207
|
+
content = file_path.read_text()
|
|
208
|
+
|
|
209
|
+
# Extract frontmatter
|
|
210
|
+
frontmatter = {}
|
|
211
|
+
body = content
|
|
212
|
+
|
|
213
|
+
if content.startswith("---"):
|
|
214
|
+
parts = content.split("---", 2)
|
|
215
|
+
if len(parts) >= 3:
|
|
216
|
+
frontmatter = _parse_frontmatter(parts[1])
|
|
217
|
+
body = parts[2].strip()
|
|
218
|
+
|
|
219
|
+
# Get name from frontmatter or use directory name
|
|
220
|
+
name = frontmatter.get("name", default_name)
|
|
221
|
+
|
|
222
|
+
# Validate name format (lowercase, hyphens, max 64 chars)
|
|
223
|
+
if len(name) > 64:
|
|
224
|
+
log.warning(f"Skill name '{name}' exceeds 64 characters, truncating")
|
|
225
|
+
name = name[:64]
|
|
226
|
+
|
|
227
|
+
return Skill(
|
|
228
|
+
name=name,
|
|
229
|
+
description=frontmatter.get("description", ""),
|
|
230
|
+
instructions=body,
|
|
231
|
+
tools=frontmatter.get("tools", []),
|
|
232
|
+
user_invocable=frontmatter.get("user_invocable", False),
|
|
233
|
+
file_path=file_path,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _parse_frontmatter(frontmatter_str: str) -> dict:
|
|
238
|
+
"""Parse YAML-like frontmatter.
|
|
239
|
+
|
|
240
|
+
Simple parser for key: value pairs.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
frontmatter_str: Frontmatter string
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
Dict of parsed values
|
|
247
|
+
"""
|
|
248
|
+
result = {}
|
|
249
|
+
|
|
250
|
+
for line in frontmatter_str.strip().split("\n"):
|
|
251
|
+
if ":" not in line:
|
|
252
|
+
continue
|
|
253
|
+
|
|
254
|
+
key, value = line.split(":", 1)
|
|
255
|
+
key = key.strip()
|
|
256
|
+
value = value.strip()
|
|
257
|
+
|
|
258
|
+
# Parse boolean values
|
|
259
|
+
if value.lower() == "true":
|
|
260
|
+
result[key] = True
|
|
261
|
+
elif value.lower() == "false":
|
|
262
|
+
result[key] = False
|
|
263
|
+
# Parse list values
|
|
264
|
+
elif value.startswith("[") and value.endswith("]"):
|
|
265
|
+
items = value[1:-1].split(",")
|
|
266
|
+
result[key] = [item.strip().strip("'\"") for item in items if item.strip()]
|
|
267
|
+
else:
|
|
268
|
+
result[key] = value.strip("'\"")
|
|
269
|
+
|
|
270
|
+
return result
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# Convenience functions
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def load_skills(skills_dir: Optional[Path] = None) -> dict[str, Skill]:
|
|
277
|
+
"""Load skills from directory.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
skills_dir: Optional skills directory
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
Dict mapping skill name to Skill
|
|
284
|
+
"""
|
|
285
|
+
registry = SkillRegistry.get_instance()
|
|
286
|
+
return registry.load_skills(skills_dir)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def get_skill(name: str) -> Optional[Skill]:
|
|
290
|
+
"""Get a specific skill by name.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
name: Skill name
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
Skill or None if not found
|
|
297
|
+
"""
|
|
298
|
+
registry = SkillRegistry.get_instance()
|
|
299
|
+
return registry.get_skill(name)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def list_skills() -> list[str]:
|
|
303
|
+
"""List available skill names.
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
List of skill names
|
|
307
|
+
"""
|
|
308
|
+
registry = SkillRegistry.get_instance()
|
|
309
|
+
return registry.list_skills()
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def get_user_invocable_skills() -> list[Skill]:
|
|
313
|
+
"""Get skills that can be invoked with /name.
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
List of user-invocable skills
|
|
317
|
+
"""
|
|
318
|
+
registry = SkillRegistry.get_instance()
|
|
319
|
+
return registry.get_user_invocable_skills()
|
emdash_core/agent/toolkit.py
CHANGED
|
@@ -103,6 +103,9 @@ class AgentToolkit:
|
|
|
103
103
|
self.register_tool(GlobTool(self.connection))
|
|
104
104
|
self.register_tool(WebTool(self.connection))
|
|
105
105
|
|
|
106
|
+
# Register skill tools and load skills
|
|
107
|
+
self._register_skill_tools()
|
|
108
|
+
|
|
106
109
|
# Register read-only file tools (always available)
|
|
107
110
|
self.register_tool(ReadFileTool(self._repo_root, self.connection))
|
|
108
111
|
self.register_tool(ListFilesTool(self._repo_root, self.connection))
|
|
@@ -123,6 +126,9 @@ class AgentToolkit:
|
|
|
123
126
|
# Register sub-agent tools for spawning lightweight agents
|
|
124
127
|
self._register_subagent_tools()
|
|
125
128
|
|
|
129
|
+
# Register mode tools
|
|
130
|
+
self._register_mode_tools()
|
|
131
|
+
|
|
126
132
|
# Register task management tools (not in plan mode)
|
|
127
133
|
if not self.plan_mode:
|
|
128
134
|
self._register_task_tools()
|
|
@@ -153,6 +159,25 @@ class AgentToolkit:
|
|
|
153
159
|
self.register_tool(TaskTool(repo_root=self._repo_root, connection=self.connection))
|
|
154
160
|
self.register_tool(TaskOutputTool(repo_root=self._repo_root, connection=self.connection))
|
|
155
161
|
|
|
162
|
+
def _register_mode_tools(self) -> None:
|
|
163
|
+
"""Register mode switching tools.
|
|
164
|
+
|
|
165
|
+
- enter_mode: Available in code mode to enter other modes (e.g., plan)
|
|
166
|
+
- exit_plan: Available in plan mode to submit plan and request approval
|
|
167
|
+
- get_mode: Always available to check current mode
|
|
168
|
+
"""
|
|
169
|
+
from .tools.modes import EnterModeTool, ExitPlanModeTool, GetModeTool
|
|
170
|
+
|
|
171
|
+
# get_mode is always available
|
|
172
|
+
self.register_tool(GetModeTool())
|
|
173
|
+
|
|
174
|
+
if self.plan_mode:
|
|
175
|
+
# In plan mode: can exit with plan submission
|
|
176
|
+
self.register_tool(ExitPlanModeTool())
|
|
177
|
+
else:
|
|
178
|
+
# In code mode: can enter other modes
|
|
179
|
+
self.register_tool(EnterModeTool())
|
|
180
|
+
|
|
156
181
|
def _register_task_tools(self) -> None:
|
|
157
182
|
"""Register task management tools.
|
|
158
183
|
|
|
@@ -187,6 +212,29 @@ class AgentToolkit:
|
|
|
187
212
|
self.register_tool(GetSpecTool())
|
|
188
213
|
self.register_tool(UpdateSpecTool())
|
|
189
214
|
|
|
215
|
+
def _register_skill_tools(self) -> None:
|
|
216
|
+
"""Register skill tools and load skills from .emdash/skills/.
|
|
217
|
+
|
|
218
|
+
Skills are markdown-based instruction files that teach the agent
|
|
219
|
+
how to perform specific, repeatable tasks. Similar to Claude Code's
|
|
220
|
+
skills system.
|
|
221
|
+
"""
|
|
222
|
+
from .tools.skill import SkillTool, ListSkillsTool
|
|
223
|
+
from .skills import SkillRegistry
|
|
224
|
+
|
|
225
|
+
# Load skills from .emdash/skills/
|
|
226
|
+
skills_dir = self._repo_root / ".emdash" / "skills"
|
|
227
|
+
registry = SkillRegistry.get_instance()
|
|
228
|
+
registry.load_skills(skills_dir)
|
|
229
|
+
|
|
230
|
+
# Register skill tools
|
|
231
|
+
self.register_tool(SkillTool(self.connection))
|
|
232
|
+
self.register_tool(ListSkillsTool(self.connection))
|
|
233
|
+
|
|
234
|
+
skills_count = len(registry.list_skills())
|
|
235
|
+
if skills_count > 0:
|
|
236
|
+
log.info(f"Registered skill tools with {skills_count} skills available")
|
|
237
|
+
|
|
190
238
|
def _register_mcp_tools(self) -> None:
|
|
191
239
|
"""Register GitHub MCP tools if available.
|
|
192
240
|
|
|
@@ -43,7 +43,7 @@ from .tasks import (
|
|
|
43
43
|
from .plan import PlanExplorationTool
|
|
44
44
|
|
|
45
45
|
# Mode tools
|
|
46
|
-
from .modes import AgentMode, ModeState,
|
|
46
|
+
from .modes import AgentMode, ModeState, EnterModeTool, ExitPlanModeTool, GetModeTool
|
|
47
47
|
|
|
48
48
|
# Spec tools
|
|
49
49
|
from .spec import SubmitSpecTool, GetSpecTool, UpdateSpecTool
|
|
@@ -111,7 +111,8 @@ __all__ = [
|
|
|
111
111
|
# Mode
|
|
112
112
|
"AgentMode",
|
|
113
113
|
"ModeState",
|
|
114
|
-
"
|
|
114
|
+
"EnterModeTool",
|
|
115
|
+
"ExitPlanModeTool",
|
|
115
116
|
"GetModeTool",
|
|
116
117
|
# Spec
|
|
117
118
|
"SubmitSpecTool",
|