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.
Files changed (41) hide show
  1. emdash_core/__init__.py +6 -1
  2. emdash_core/agent/events.py +29 -0
  3. emdash_core/agent/prompts/__init__.py +5 -0
  4. emdash_core/agent/prompts/main_agent.py +22 -2
  5. emdash_core/agent/prompts/plan_mode.py +126 -0
  6. emdash_core/agent/prompts/subagents.py +11 -7
  7. emdash_core/agent/prompts/workflow.py +138 -43
  8. emdash_core/agent/providers/base.py +4 -0
  9. emdash_core/agent/providers/models.py +7 -0
  10. emdash_core/agent/providers/openai_provider.py +74 -2
  11. emdash_core/agent/runner.py +556 -34
  12. emdash_core/agent/skills.py +319 -0
  13. emdash_core/agent/toolkit.py +48 -0
  14. emdash_core/agent/tools/__init__.py +3 -2
  15. emdash_core/agent/tools/modes.py +197 -53
  16. emdash_core/agent/tools/search.py +4 -0
  17. emdash_core/agent/tools/skill.py +193 -0
  18. emdash_core/agent/tools/spec.py +61 -94
  19. emdash_core/agent/tools/tasks.py +15 -78
  20. emdash_core/api/agent.py +7 -7
  21. emdash_core/api/index.py +1 -1
  22. emdash_core/api/projectmd.py +4 -2
  23. emdash_core/api/router.py +2 -0
  24. emdash_core/api/skills.py +241 -0
  25. emdash_core/checkpoint/__init__.py +40 -0
  26. emdash_core/checkpoint/cli.py +175 -0
  27. emdash_core/checkpoint/git_operations.py +250 -0
  28. emdash_core/checkpoint/manager.py +231 -0
  29. emdash_core/checkpoint/models.py +107 -0
  30. emdash_core/checkpoint/storage.py +201 -0
  31. emdash_core/config.py +1 -1
  32. emdash_core/core/config.py +18 -2
  33. emdash_core/graph/schema.py +5 -5
  34. emdash_core/ingestion/orchestrator.py +19 -10
  35. emdash_core/models/agent.py +1 -1
  36. emdash_core/server.py +42 -0
  37. emdash_core/sse/stream.py +1 -0
  38. {emdash_core-0.1.7.dist-info → emdash_core-0.1.25.dist-info}/METADATA +1 -2
  39. {emdash_core-0.1.7.dist-info → emdash_core-0.1.25.dist-info}/RECORD +41 -31
  40. {emdash_core-0.1.7.dist-info → emdash_core-0.1.25.dist-info}/entry_points.txt +1 -0
  41. {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()
@@ -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, SwitchModeTool, GetModeTool
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
- "SwitchModeTool",
114
+ "EnterModeTool",
115
+ "ExitPlanModeTool",
115
116
  "GetModeTool",
116
117
  # Spec
117
118
  "SubmitSpecTool",