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
@@ -1,7 +1,7 @@
1
1
  """Agent mode management tools.
2
2
 
3
- Provides tools for switching between different agent modes
4
- (exploration, planning, coding, etc).
3
+ Provides tools for entering and exiting modes, following
4
+ Claude Code's approach of explicit mode transitions.
5
5
  """
6
6
 
7
7
  from dataclasses import dataclass
@@ -13,27 +13,23 @@ from .base import BaseTool, ToolResult, ToolCategory
13
13
 
14
14
  class AgentMode(Enum):
15
15
  """Available agent modes."""
16
+ PLAN = "plan"
17
+ CODE = "code"
16
18
 
17
- EXPLORATION = "exploration"
18
- PLANNING = "planning"
19
- CODING = "coding"
20
- REVIEW = "review"
21
- DEBUG = "debug"
19
+
20
+ # Modes that can be entered via enter_mode tool
21
+ SUPPORTED_MODES = ["plan"] # Extensible list
22
22
 
23
23
 
24
24
  @dataclass
25
25
  class ModeState:
26
26
  """Singleton state for agent mode."""
27
27
 
28
- current_mode: AgentMode = AgentMode.EXPLORATION
29
- mode_context: dict = None
28
+ current_mode: AgentMode = AgentMode.CODE
29
+ plan_content: Optional[str] = None # Stores the current plan
30
30
 
31
31
  _instance: Optional["ModeState"] = None
32
32
 
33
- def __post_init__(self):
34
- if self.mode_context is None:
35
- self.mode_context = {}
36
-
37
33
  @classmethod
38
34
  def get_instance(cls) -> "ModeState":
39
35
  """Get the singleton instance."""
@@ -47,18 +43,16 @@ class ModeState:
47
43
  cls._instance = None
48
44
 
49
45
 
50
- class SwitchModeTool(BaseTool):
51
- """Tool for switching between agent modes."""
46
+ class EnterModeTool(BaseTool):
47
+ """Tool for entering a different mode from code mode."""
52
48
 
53
- name = "switch_mode"
54
- description = """Switch the agent to a different operating mode.
49
+ name = "enter_mode"
50
+ description = """Enter a different operating mode.
55
51
 
56
- Modes:
57
- - exploration: General codebase exploration and understanding
58
- - planning: Feature planning and specification writing
59
- - coding: Active code editing and implementation
60
- - review: Code review and feedback
61
- - debug: Debugging and issue investigation"""
52
+ Currently supported modes:
53
+ - plan: Enter plan mode to explore the codebase and design an implementation plan.
54
+ In plan mode you can ONLY use read-only tools (no file modifications).
55
+ Use exit_plan when your plan is ready for user approval."""
62
56
  category = ToolCategory.PLANNING
63
57
 
64
58
  def __init__(self, connection=None):
@@ -68,40 +62,49 @@ Modes:
68
62
  def execute(
69
63
  self,
70
64
  mode: str,
71
- context: Optional[str] = None,
65
+ reason: str = "",
66
+ **kwargs,
72
67
  ) -> ToolResult:
73
- """Switch to a new mode.
68
+ """Enter a new mode.
74
69
 
75
70
  Args:
76
- mode: Target mode name
77
- context: Optional context for the new mode
71
+ mode: Mode to enter (currently only "plan" supported)
72
+ reason: Why you're entering this mode (helps context)
78
73
 
79
74
  Returns:
80
- ToolResult indicating success
75
+ ToolResult indicating mode switch
81
76
  """
82
- try:
83
- new_mode = AgentMode(mode.lower())
84
- except ValueError:
85
- valid_modes = [m.value for m in AgentMode]
77
+ mode_lower = mode.lower()
78
+
79
+ if mode_lower not in SUPPORTED_MODES:
86
80
  return ToolResult.error_result(
87
- f"Invalid mode: {mode}",
88
- suggestions=[f"Valid modes: {', '.join(valid_modes)}"],
81
+ f"Unsupported mode: {mode}",
82
+ suggestions=[f"Supported modes: {', '.join(SUPPORTED_MODES)}"],
89
83
  )
90
84
 
91
85
  state = ModeState.get_instance()
92
- old_mode = state.current_mode
93
- state.current_mode = new_mode
94
86
 
95
- if context:
96
- state.mode_context[new_mode.value] = context
87
+ if mode_lower == "plan":
88
+ if state.current_mode == AgentMode.PLAN:
89
+ return ToolResult.error_result(
90
+ "Already in plan mode",
91
+ suggestions=["Use exit_plan to submit your plan for approval"],
92
+ )
93
+
94
+ state.current_mode = AgentMode.PLAN
95
+ state.plan_content = None # Reset plan content
96
+
97
+ return ToolResult.success_result(
98
+ data={
99
+ "status": "entered_plan_mode",
100
+ "mode": "plan",
101
+ "reason": reason,
102
+ "message": "You are now in plan mode. Explore the codebase and design your plan. Use exit_plan when ready.",
103
+ },
104
+ )
97
105
 
98
- return ToolResult.success_result(
99
- data={
100
- "previous_mode": old_mode.value,
101
- "current_mode": new_mode.value,
102
- "context": context,
103
- },
104
- )
106
+ # Placeholder for future modes
107
+ return ToolResult.error_result(f"Mode '{mode}' not yet implemented")
105
108
 
106
109
  def get_schema(self) -> dict:
107
110
  """Get OpenAI function schema."""
@@ -109,30 +112,171 @@ Modes:
109
112
  properties={
110
113
  "mode": {
111
114
  "type": "string",
112
- "enum": [m.value for m in AgentMode],
113
- "description": "Target mode to switch to",
115
+ "enum": SUPPORTED_MODES,
116
+ "description": "Mode to enter",
114
117
  },
115
- "context": {
118
+ "reason": {
116
119
  "type": "string",
117
- "description": "Optional context for the new mode",
120
+ "description": "Brief reason for entering this mode",
118
121
  },
119
122
  },
120
123
  required=["mode"],
121
124
  )
122
125
 
123
126
 
127
+ class ExitPlanModeTool(BaseTool):
128
+ """Tool for exiting plan mode and submitting plan for approval."""
129
+
130
+ name = "exit_plan"
131
+ description = """Exit plan mode and submit your plan for user approval.
132
+
133
+ Scale detail based on task complexity:
134
+ - Simple task: title, summary, files_to_modify (steps optional)
135
+ - Complex task: all fields including phases, risks, open questions
136
+
137
+ Required fields:
138
+ - title: Clear, concise plan title
139
+ - summary: What will be implemented and why
140
+ - files_to_modify: List of file changes with paths, line numbers, and descriptions
141
+
142
+ Optional fields (include only if needed - each must "earn its place"):
143
+ - implementation_steps: For complex tasks needing ordered steps
144
+ - risks: Non-trivial risks only
145
+ - testing_strategy: Beyond obvious test cases
146
+
147
+ The user will either:
148
+ - Approve: You'll return to code mode to implement the plan
149
+ - Reject: You'll receive feedback and can revise in plan mode"""
150
+ category = ToolCategory.PLANNING
151
+
152
+ def __init__(self, connection=None):
153
+ """Initialize without requiring connection."""
154
+ self.connection = connection
155
+
156
+ def execute(
157
+ self,
158
+ title: str,
159
+ summary: str,
160
+ files_to_modify: list[dict] = None,
161
+ implementation_steps: list[str] = None,
162
+ risks: list[str] = None,
163
+ testing_strategy: str = None,
164
+ **kwargs,
165
+ ) -> ToolResult:
166
+ """Exit plan mode and submit plan for approval.
167
+
168
+ Args:
169
+ title: Title of the plan
170
+ summary: Summary of what will be implemented and why
171
+ files_to_modify: List of file changes, each with:
172
+ - path: File path (e.g., "src/auth.py")
173
+ - lines: Line range (e.g., "45-60" or "new file")
174
+ - changes: Description of what changes
175
+ implementation_steps: Ordered list of detailed implementation steps
176
+ risks: List of potential risks or considerations
177
+ testing_strategy: Description of how changes will be tested
178
+
179
+ Returns:
180
+ ToolResult triggering user approval flow
181
+ """
182
+ state = ModeState.get_instance()
183
+
184
+ if state.current_mode != AgentMode.PLAN:
185
+ return ToolResult.error_result(
186
+ "Not in plan mode",
187
+ suggestions=["Use enter_mode with mode='plan' to enter plan mode first"],
188
+ )
189
+
190
+ if not title or not title.strip():
191
+ return ToolResult.error_result("Title is required")
192
+ if not summary or not summary.strip():
193
+ return ToolResult.error_result("Summary is required")
194
+ if not files_to_modify:
195
+ return ToolResult.error_result(
196
+ "files_to_modify is required",
197
+ suggestions=["Include at least one file with path, lines, and changes"],
198
+ )
199
+ # implementation_steps optional for simple tasks where files_to_modify is self-explanatory
200
+
201
+ # Store plan content for reference
202
+ state.plan_content = summary
203
+
204
+ return ToolResult.success_result({
205
+ "status": "plan_submitted",
206
+ "title": title.strip(),
207
+ "summary": summary.strip(),
208
+ "files_to_modify": files_to_modify,
209
+ "implementation_steps": implementation_steps or [],
210
+ "risks": risks or [],
211
+ "testing_strategy": testing_strategy or "",
212
+ "message": "Plan submitted for user approval. Waiting for user response.",
213
+ })
214
+
215
+ def get_schema(self) -> dict:
216
+ """Get OpenAI function schema."""
217
+ return self._make_schema(
218
+ properties={
219
+ "title": {
220
+ "type": "string",
221
+ "description": "Clear, concise title of the plan",
222
+ },
223
+ "summary": {
224
+ "type": "string",
225
+ "description": "Detailed summary of what will be implemented and why",
226
+ },
227
+ "files_to_modify": {
228
+ "type": "array",
229
+ "items": {
230
+ "type": "object",
231
+ "properties": {
232
+ "path": {
233
+ "type": "string",
234
+ "description": "File path (e.g., 'src/auth.py')",
235
+ },
236
+ "lines": {
237
+ "type": "string",
238
+ "description": "Line range (e.g., '45-60') or 'new file'",
239
+ },
240
+ "changes": {
241
+ "type": "string",
242
+ "description": "Description of what changes in this file",
243
+ },
244
+ },
245
+ "required": ["path", "changes"],
246
+ },
247
+ "description": "List of files to modify with line numbers and change descriptions",
248
+ },
249
+ "implementation_steps": {
250
+ "type": "array",
251
+ "items": {"type": "string"},
252
+ "description": "Detailed ordered list of implementation steps with sub-tasks",
253
+ },
254
+ "risks": {
255
+ "type": "array",
256
+ "items": {"type": "string"},
257
+ "description": "Potential risks, breaking changes, or considerations",
258
+ },
259
+ "testing_strategy": {
260
+ "type": "string",
261
+ "description": "How the changes will be tested (unit tests, integration tests, etc.)",
262
+ },
263
+ },
264
+ required=["title", "summary", "files_to_modify"],
265
+ )
266
+
267
+
124
268
  class GetModeTool(BaseTool):
125
269
  """Tool for getting current agent mode."""
126
270
 
127
271
  name = "get_mode"
128
- description = "Get the current agent operating mode and its context."
272
+ description = "Get the current agent operating mode (plan or code)."
129
273
  category = ToolCategory.PLANNING
130
274
 
131
275
  def __init__(self, connection=None):
132
276
  """Initialize without requiring connection."""
133
277
  self.connection = connection
134
278
 
135
- def execute(self) -> ToolResult:
279
+ def execute(self, **kwargs) -> ToolResult:
136
280
  """Get current mode.
137
281
 
138
282
  Returns:
@@ -143,8 +287,8 @@ class GetModeTool(BaseTool):
143
287
  return ToolResult.success_result(
144
288
  data={
145
289
  "current_mode": state.current_mode.value,
146
- "context": state.mode_context.get(state.current_mode.value),
147
- "available_modes": [m.value for m in AgentMode],
290
+ "has_plan": state.plan_content is not None,
291
+ "available_modes": SUPPORTED_MODES,
148
292
  },
149
293
  )
150
294
 
@@ -22,6 +22,7 @@ Useful for finding code related to concepts like "authentication", "database que
22
22
  entity_types: Optional[list[str]] = None,
23
23
  limit: int = 10,
24
24
  min_score: float = 0.5,
25
+ **kwargs, # Ignore unexpected params from LLM
25
26
  ) -> ToolResult:
26
27
  """Execute semantic search.
27
28
 
@@ -121,6 +122,7 @@ More precise than semantic search when you know part of the name."""
121
122
  query: str,
122
123
  entity_types: Optional[list[str]] = None,
123
124
  limit: int = 10,
125
+ **kwargs, # Ignore unexpected params from LLM
124
126
  ) -> ToolResult:
125
127
  """Execute text search.
126
128
 
@@ -215,6 +217,7 @@ Useful for finding code patterns, string literals, or specific implementations."
215
217
  file_pattern: Optional[str] = None,
216
218
  max_results: int = 50,
217
219
  context_lines: int = 2,
220
+ **kwargs, # Ignore unexpected params from LLM
218
221
  ) -> ToolResult:
219
222
  """Execute grep search.
220
223
 
@@ -324,6 +327,7 @@ Common patterns:
324
327
  self,
325
328
  pattern: str,
326
329
  max_results: int = 100,
330
+ **kwargs, # Ignore unexpected params from LLM
327
331
  ) -> ToolResult:
328
332
  """Execute glob search for files.
329
333
 
@@ -0,0 +1,193 @@
1
+ """Skill invocation tool.
2
+
3
+ Allows the agent to activate and invoke skills during task execution.
4
+ Skills provide specialized instructions for repeatable tasks.
5
+ """
6
+
7
+ from typing import Optional
8
+
9
+ from .base import BaseTool, ToolResult, ToolCategory
10
+ from ..skills import SkillRegistry, Skill
11
+
12
+
13
+ class SkillTool(BaseTool):
14
+ """Tool for invoking skills.
15
+
16
+ Skills are markdown-based instruction files that teach the agent
17
+ how to perform specific, repeatable tasks. This tool allows
18
+ explicit skill invocation.
19
+ """
20
+
21
+ name = "skill"
22
+ description = """Invoke a skill for specialized task execution.
23
+
24
+ Skills provide focused instructions for common tasks like:
25
+ - commit: Generate commit messages following conventions
26
+ - review-pr: Review PRs with code standards
27
+ - security-review: Security-focused code review
28
+
29
+ When you invoke a skill, you receive its instructions which you should follow.
30
+ Use list_skills to see available skills."""
31
+ category = ToolCategory.PLANNING
32
+
33
+ def __init__(self, connection=None):
34
+ """Initialize without requiring connection."""
35
+ self.connection = connection
36
+
37
+ def execute(
38
+ self,
39
+ skill: str,
40
+ args: str = "",
41
+ **kwargs,
42
+ ) -> ToolResult:
43
+ """Invoke a skill.
44
+
45
+ Args:
46
+ skill: Name of the skill to invoke
47
+ args: Optional arguments to pass to the skill
48
+
49
+ Returns:
50
+ ToolResult with skill instructions
51
+ """
52
+ registry = SkillRegistry.get_instance()
53
+ skill_obj = registry.get_skill(skill)
54
+
55
+ if skill_obj is None:
56
+ available = registry.list_skills()
57
+ if available:
58
+ return ToolResult.error_result(
59
+ f"Skill '{skill}' not found",
60
+ suggestions=[f"Available skills: {', '.join(available)}"],
61
+ )
62
+ else:
63
+ return ToolResult.error_result(
64
+ f"Skill '{skill}' not found. No skills are currently loaded.",
65
+ suggestions=[
66
+ "Create skills in .emdash/skills/<skill-name>/SKILL.md",
67
+ "Skills are loaded from the .emdash/skills/ directory",
68
+ ],
69
+ )
70
+
71
+ # Build the skill activation response
72
+ response_parts = [
73
+ f"# Skill Activated: {skill_obj.name}",
74
+ "",
75
+ f"**Description**: {skill_obj.description}",
76
+ "",
77
+ ]
78
+
79
+ if args:
80
+ response_parts.extend([
81
+ f"**Arguments**: {args}",
82
+ "",
83
+ ])
84
+
85
+ if skill_obj.tools:
86
+ response_parts.extend([
87
+ f"**Required tools**: {', '.join(skill_obj.tools)}",
88
+ "",
89
+ ])
90
+
91
+ response_parts.extend([
92
+ "---",
93
+ "",
94
+ skill_obj.instructions,
95
+ ])
96
+
97
+ return ToolResult.success_result(
98
+ data={
99
+ "skill_name": skill_obj.name,
100
+ "description": skill_obj.description,
101
+ "instructions": skill_obj.instructions,
102
+ "tools": skill_obj.tools,
103
+ "args": args,
104
+ "message": "\n".join(response_parts),
105
+ },
106
+ )
107
+
108
+ def get_schema(self) -> dict:
109
+ """Get OpenAI function schema."""
110
+ registry = SkillRegistry.get_instance()
111
+ available_skills = registry.list_skills()
112
+
113
+ description = self.description
114
+ if available_skills:
115
+ description += f"\n\nCurrently available skills: {', '.join(available_skills)}"
116
+
117
+ return {
118
+ "type": "function",
119
+ "function": {
120
+ "name": self.name,
121
+ "description": description,
122
+ "parameters": {
123
+ "type": "object",
124
+ "properties": {
125
+ "skill": {
126
+ "type": "string",
127
+ "description": "Name of the skill to invoke",
128
+ },
129
+ "args": {
130
+ "type": "string",
131
+ "description": "Optional arguments for the skill (e.g., PR number, file path)",
132
+ },
133
+ },
134
+ "required": ["skill"],
135
+ },
136
+ },
137
+ }
138
+
139
+
140
+ class ListSkillsTool(BaseTool):
141
+ """Tool for listing available skills."""
142
+
143
+ name = "list_skills"
144
+ description = "List all available skills and their descriptions."
145
+ category = ToolCategory.PLANNING
146
+
147
+ def __init__(self, connection=None):
148
+ """Initialize without requiring connection."""
149
+ self.connection = connection
150
+
151
+ def execute(self, **kwargs) -> ToolResult:
152
+ """List available skills.
153
+
154
+ Returns:
155
+ ToolResult with list of skills
156
+ """
157
+ registry = SkillRegistry.get_instance()
158
+ skills = registry.get_all_skills()
159
+
160
+ if not skills:
161
+ return ToolResult.success_result(
162
+ data={
163
+ "skills": [],
164
+ "message": "No skills loaded. Create skills in .emdash/skills/<skill-name>/SKILL.md",
165
+ },
166
+ )
167
+
168
+ skills_list = []
169
+ for skill in skills.values():
170
+ skills_list.append({
171
+ "name": skill.name,
172
+ "description": skill.description,
173
+ "user_invocable": skill.user_invocable,
174
+ "tools": skill.tools,
175
+ })
176
+
177
+ # Build human-readable message
178
+ lines = ["# Available Skills", ""]
179
+ for s in skills_list:
180
+ invocable = f" (invoke with /{s['name']})" if s["user_invocable"] else ""
181
+ lines.append(f"- **{s['name']}**: {s['description']}{invocable}")
182
+
183
+ return ToolResult.success_result(
184
+ data={
185
+ "skills": skills_list,
186
+ "count": len(skills_list),
187
+ "message": "\n".join(lines),
188
+ },
189
+ )
190
+
191
+ def get_schema(self) -> dict:
192
+ """Get OpenAI function schema."""
193
+ return self._make_schema(properties={}, required=[])