emdash-core 0.1.25__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 (32) hide show
  1. emdash_core/agent/__init__.py +4 -0
  2. emdash_core/agent/events.py +42 -20
  3. emdash_core/agent/inprocess_subagent.py +123 -10
  4. emdash_core/agent/prompts/__init__.py +4 -3
  5. emdash_core/agent/prompts/main_agent.py +32 -2
  6. emdash_core/agent/prompts/plan_mode.py +236 -107
  7. emdash_core/agent/prompts/subagents.py +79 -15
  8. emdash_core/agent/prompts/workflow.py +145 -26
  9. emdash_core/agent/providers/factory.py +2 -2
  10. emdash_core/agent/providers/openai_provider.py +67 -15
  11. emdash_core/agent/runner/__init__.py +49 -0
  12. emdash_core/agent/runner/agent_runner.py +753 -0
  13. emdash_core/agent/runner/context.py +451 -0
  14. emdash_core/agent/runner/factory.py +108 -0
  15. emdash_core/agent/runner/plan.py +217 -0
  16. emdash_core/agent/runner/sdk_runner.py +324 -0
  17. emdash_core/agent/runner/utils.py +67 -0
  18. emdash_core/agent/skills.py +47 -8
  19. emdash_core/agent/toolkit.py +46 -14
  20. emdash_core/agent/toolkits/plan.py +9 -11
  21. emdash_core/agent/tools/__init__.py +2 -2
  22. emdash_core/agent/tools/coding.py +48 -4
  23. emdash_core/agent/tools/modes.py +151 -143
  24. emdash_core/agent/tools/task.py +41 -2
  25. emdash_core/api/agent.py +555 -1
  26. emdash_core/skills/frontend-design/SKILL.md +56 -0
  27. emdash_core/sse/stream.py +4 -0
  28. {emdash_core-0.1.25.dist-info → emdash_core-0.1.33.dist-info}/METADATA +2 -1
  29. {emdash_core-0.1.25.dist-info → emdash_core-0.1.33.dist-info}/RECORD +31 -24
  30. emdash_core/agent/runner.py +0 -1123
  31. {emdash_core-0.1.25.dist-info → emdash_core-0.1.33.dist-info}/WHEEL +0 -0
  32. {emdash_core-0.1.25.dist-info → emdash_core-0.1.33.dist-info}/entry_points.txt +0 -0
@@ -6,6 +6,10 @@ when relevant or explicitly invoked via /skill_name.
6
6
 
7
7
  Similar to Claude Code's skills system:
8
8
  https://docs.anthropic.com/en/docs/claude-code/skills
9
+
10
+ Skills are loaded from two locations:
11
+ 1. Built-in skills bundled with emdash_core (always available)
12
+ 2. User repo skills in .emdash/skills/ (can override built-in)
9
13
  """
10
14
 
11
15
  from dataclasses import dataclass, field
@@ -15,6 +19,11 @@ from typing import Optional
15
19
  from ..utils.logger import log
16
20
 
17
21
 
22
+ def _get_builtin_skills_dir() -> Path:
23
+ """Get the directory containing built-in skills bundled with emdash_core."""
24
+ return Path(__file__).parent.parent / "skills"
25
+
26
+
18
27
  @dataclass
19
28
  class Skill:
20
29
  """A skill configuration loaded from SKILL.md.
@@ -26,6 +35,7 @@ class Skill:
26
35
  tools: List of tools this skill needs access to
27
36
  user_invocable: Whether skill can be invoked with /name
28
37
  file_path: Source file path
38
+ _builtin: Whether this is a built-in skill bundled with emdash_core
29
39
  """
30
40
 
31
41
  name: str
@@ -34,6 +44,7 @@ class Skill:
34
44
  tools: list[str] = field(default_factory=list)
35
45
  user_invocable: bool = False
36
46
  file_path: Optional[Path] = None
47
+ _builtin: bool = False
37
48
 
38
49
 
39
50
  class SkillRegistry:
@@ -67,7 +78,11 @@ class SkillRegistry:
67
78
  cls._instance._skills_dir = None
68
79
 
69
80
  def load_skills(self, skills_dir: Optional[Path] = None) -> dict[str, Skill]:
70
- """Load skills from .emdash/skills/ directory.
81
+ """Load skills from built-in and user repo directories.
82
+
83
+ Skills are loaded from two locations (in order):
84
+ 1. Built-in skills bundled with emdash_core (always available)
85
+ 2. User repo skills in .emdash/skills/ (can override built-in)
71
86
 
72
87
  Each skill is a directory containing a SKILL.md file:
73
88
 
@@ -94,7 +109,7 @@ class SkillRegistry:
94
109
  ```
95
110
 
96
111
  Args:
97
- skills_dir: Directory containing skill subdirectories.
112
+ skills_dir: Directory containing user skill subdirectories.
98
113
  Defaults to .emdash/skills/ in cwd.
99
114
 
100
115
  Returns:
@@ -105,9 +120,34 @@ class SkillRegistry:
105
120
 
106
121
  self._skills_dir = skills_dir
107
122
 
108
- if not skills_dir.exists():
109
- return {}
123
+ skills = {}
124
+
125
+ # First, load built-in skills bundled with emdash_core
126
+ builtin_dir = _get_builtin_skills_dir()
127
+ if builtin_dir.exists():
128
+ builtin_skills = self._load_skills_from_dir(builtin_dir, is_builtin=True)
129
+ skills.update(builtin_skills)
130
+
131
+ # Then, load user repo skills (can override built-in)
132
+ if skills_dir.exists():
133
+ user_skills = self._load_skills_from_dir(skills_dir, is_builtin=False)
134
+ skills.update(user_skills)
110
135
 
136
+ if skills:
137
+ log.info(f"Loaded {len(skills)} skills ({len([s for s in self._skills.values() if getattr(s, '_builtin', False)])} built-in)")
138
+
139
+ return skills
140
+
141
+ def _load_skills_from_dir(self, skills_dir: Path, is_builtin: bool = False) -> dict[str, Skill]:
142
+ """Load skills from a specific directory.
143
+
144
+ Args:
145
+ skills_dir: Directory containing skill subdirectories
146
+ is_builtin: Whether these are built-in skills
147
+
148
+ Returns:
149
+ Dict mapping skill name to Skill
150
+ """
111
151
  skills = {}
112
152
 
113
153
  # Look for SKILL.md in subdirectories
@@ -125,15 +165,14 @@ class SkillRegistry:
125
165
  try:
126
166
  skill = _parse_skill_file(skill_file, skill_dir.name)
127
167
  if skill:
168
+ skill._builtin = is_builtin # Mark as built-in or user-defined
128
169
  skills[skill.name] = skill
129
170
  self._skills[skill.name] = skill
130
- log.debug(f"Loaded skill: {skill.name}")
171
+ source = "built-in" if is_builtin else "user"
172
+ log.debug(f"Loaded {source} skill: {skill.name}")
131
173
  except Exception as e:
132
174
  log.warning(f"Failed to load skill from {skill_file}: {e}")
133
175
 
134
- if skills:
135
- log.info(f"Loaded {len(skills)} skills")
136
-
137
176
  return skills
138
177
 
139
178
  def get_skill(self, name: str) -> Optional[Skill]:
@@ -41,6 +41,7 @@ class AgentToolkit:
41
41
  repo_root: Optional[Path] = None,
42
42
  plan_mode: bool = False,
43
43
  save_spec_path: Optional[Path] = None,
44
+ plan_file_path: Optional[str] = None,
44
45
  ):
45
46
  """Initialize the agent toolkit.
46
47
 
@@ -53,6 +54,7 @@ class AgentToolkit:
53
54
  If None, uses repo_root from config or current working directory.
54
55
  plan_mode: Whether to include spec planning tools and restrict to read-only.
55
56
  save_spec_path: If provided, specs will be saved to this path.
57
+ plan_file_path: Path to the plan file (only writable file in plan mode).
56
58
  """
57
59
  self.connection = connection or get_connection()
58
60
  self.session = AgentSession() if enable_session else None
@@ -61,6 +63,7 @@ class AgentToolkit:
61
63
  self._mcp_config_path = mcp_config_path
62
64
  self.plan_mode = plan_mode
63
65
  self.save_spec_path = save_spec_path
66
+ self.plan_file_path = plan_file_path
64
67
 
65
68
  # Get repo_root from config if not explicitly provided
66
69
  if repo_root is None:
@@ -70,8 +73,12 @@ class AgentToolkit:
70
73
  repo_root = Path(config.repo_root)
71
74
  self._repo_root = repo_root or Path.cwd()
72
75
 
73
- # Configure spec state if plan mode
76
+ # Configure mode state and spec state if plan mode
74
77
  if plan_mode:
78
+ from .tools.modes import ModeState, AgentMode
79
+ mode_state = ModeState.get_instance()
80
+ mode_state.current_mode = AgentMode.PLAN
81
+
75
82
  from .tools.spec import SpecState
76
83
  spec_state = SpecState.get_instance()
77
84
  spec_state.configure(save_path=save_spec_path)
@@ -110,8 +117,18 @@ class AgentToolkit:
110
117
  self.register_tool(ReadFileTool(self._repo_root, self.connection))
111
118
  self.register_tool(ListFilesTool(self._repo_root, self.connection))
112
119
 
113
- # Register write tools (only in non-plan mode)
114
- if not self.plan_mode:
120
+ # Register write tools
121
+ if self.plan_mode:
122
+ # In plan mode: only allow writing to the plan file
123
+ if self.plan_file_path:
124
+ from .tools.coding import WriteToFileTool
125
+ self.register_tool(WriteToFileTool(
126
+ self._repo_root,
127
+ self.connection,
128
+ allowed_paths=[self.plan_file_path],
129
+ ))
130
+ else:
131
+ # In code mode: full write access
115
132
  from .tools.coding import (
116
133
  WriteToFileTool,
117
134
  ApplyDiffTool,
@@ -129,8 +146,12 @@ class AgentToolkit:
129
146
  # Register mode tools
130
147
  self._register_mode_tools()
131
148
 
132
- # Register task management tools (not in plan mode)
133
- if not self.plan_mode:
149
+ # Register task management tools
150
+ # In plan mode: only register ask_followup_question for clarifications
151
+ # In code mode: register all task tools
152
+ if self.plan_mode:
153
+ self._register_plan_mode_task_tools()
154
+ else:
134
155
  self._register_task_tools()
135
156
 
136
157
  # Register spec planning tools (only in plan mode)
@@ -162,21 +183,32 @@ class AgentToolkit:
162
183
  def _register_mode_tools(self) -> None:
163
184
  """Register mode switching tools.
164
185
 
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
186
+ - enter_plan_mode: Available in code mode to request entering plan mode
187
+ - exit_plan: Available in both modes to submit plan for approval
167
188
  - get_mode: Always available to check current mode
168
189
  """
169
- from .tools.modes import EnterModeTool, ExitPlanModeTool, GetModeTool
190
+ from .tools.modes import EnterPlanModeTool, ExitPlanModeTool, GetModeTool
170
191
 
171
192
  # get_mode is always available
172
193
  self.register_tool(GetModeTool())
173
194
 
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())
195
+ # exit_plan is available in both modes:
196
+ # - In plan mode: submit plan written to plan file
197
+ # - In code mode: submit plan received from Plan subagent
198
+ self.register_tool(ExitPlanModeTool())
199
+
200
+ if not self.plan_mode:
201
+ # In code mode: can also request to enter plan mode
202
+ self.register_tool(EnterPlanModeTool())
203
+
204
+ def _register_plan_mode_task_tools(self) -> None:
205
+ """Register subset of task tools for plan mode.
206
+
207
+ In plan mode, the agent can ask clarifying questions but
208
+ doesn't need completion/todo tools since exit_plan handles that.
209
+ """
210
+ from .tools.tasks import AskFollowupQuestionTool
211
+ self.register_tool(AskFollowupQuestionTool())
180
212
 
181
213
  def _register_task_tools(self) -> None:
182
214
  """Register task management tools.
@@ -1,19 +1,22 @@
1
- """Plan toolkit - exploration tools plus plan writing capability."""
1
+ """Plan toolkit - read-only exploration tools for planning.
2
+
3
+ The Plan subagent explores the codebase and returns a plan as text.
4
+ The main agent (in plan mode) writes the plan to .emdash/<feature>.md.
5
+ """
2
6
 
3
7
  from pathlib import Path
4
8
 
5
9
  from .base import BaseToolkit
6
10
  from ..tools.coding import ReadFileTool, ListFilesTool
7
11
  from ..tools.search import SemanticSearchTool, GrepTool, GlobTool
8
- from ..tools.plan_write import WritePlanTool
9
12
  from ...utils.logger import log
10
13
 
11
14
 
12
15
  class PlanToolkit(BaseToolkit):
13
- """Toolkit for planning with limited write access (plan files only).
16
+ """Read-only toolkit for Plan subagent.
14
17
 
15
- Provides all read-only exploration tools plus the ability to write
16
- implementation plans to .emdash/plans/*.md.
18
+ The Plan subagent explores the codebase and returns a structured plan.
19
+ It does NOT write files - the main agent handles that.
17
20
 
18
21
  Tools available:
19
22
  - read_file: Read file contents
@@ -21,7 +24,6 @@ class PlanToolkit(BaseToolkit):
21
24
  - glob: Find files by pattern
22
25
  - grep: Search file contents
23
26
  - semantic_search: AI-powered code search
24
- - write_plan: Write implementation plans (restricted to .emdash/plans/)
25
27
  """
26
28
 
27
29
  TOOLS = [
@@ -30,11 +32,10 @@ class PlanToolkit(BaseToolkit):
30
32
  "glob",
31
33
  "grep",
32
34
  "semantic_search",
33
- "write_plan",
34
35
  ]
35
36
 
36
37
  def _register_tools(self) -> None:
37
- """Register exploration and plan writing tools."""
38
+ """Register read-only exploration tools."""
38
39
  # All read-only exploration tools
39
40
  self.register_tool(ReadFileTool(repo_root=self.repo_root))
40
41
  self.register_tool(ListFilesTool(repo_root=self.repo_root))
@@ -49,7 +50,4 @@ class PlanToolkit(BaseToolkit):
49
50
  except Exception as e:
50
51
  log.debug(f"Semantic search not available: {e}")
51
52
 
52
- # Special: can only write to .emdash/plans/*.md
53
- self.register_tool(WritePlanTool(repo_root=self.repo_root))
54
-
55
53
  log.debug(f"PlanToolkit registered {len(self._tools)} tools")
@@ -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, EnterModeTool, ExitPlanModeTool, GetModeTool
46
+ from .modes import AgentMode, ModeState, EnterPlanModeTool, ExitPlanModeTool, GetModeTool
47
47
 
48
48
  # Spec tools
49
49
  from .spec import SubmitSpecTool, GetSpecTool, UpdateSpecTool
@@ -111,7 +111,7 @@ __all__ = [
111
111
  # Mode
112
112
  "AgentMode",
113
113
  "ModeState",
114
- "EnterModeTool",
114
+ "EnterPlanModeTool",
115
115
  "ExitPlanModeTool",
116
116
  "GetModeTool",
117
117
  # Spec
@@ -64,6 +64,8 @@ Returns the file content as text."""
64
64
  path: str,
65
65
  start_line: Optional[int] = None,
66
66
  end_line: Optional[int] = None,
67
+ offset: Optional[int] = None,
68
+ limit: Optional[int] = None,
67
69
  ) -> ToolResult:
68
70
  """Read a file.
69
71
 
@@ -71,6 +73,8 @@ Returns the file content as text."""
71
73
  path: Path to the file
72
74
  start_line: Optional starting line (1-indexed)
73
75
  end_line: Optional ending line (1-indexed)
76
+ offset: Alternative to start_line - line number to start from (1-indexed)
77
+ limit: Alternative to end_line - number of lines to read
74
78
 
75
79
  Returns:
76
80
  ToolResult with file content
@@ -89,8 +93,14 @@ Returns the file content as text."""
89
93
  content = full_path.read_text()
90
94
  lines = content.split("\n")
91
95
 
92
- # Handle line ranges
93
- if start_line or end_line:
96
+ # Handle line ranges - support both start_line/end_line and offset/limit
97
+ # offset/limit take precedence if provided
98
+ if offset is not None or limit is not None:
99
+ start_idx = (offset - 1) if offset else 0
100
+ end_idx = start_idx + limit if limit else len(lines)
101
+ lines = lines[start_idx:end_idx]
102
+ content = "\n".join(lines)
103
+ elif start_line or end_line:
94
104
  start_idx = (start_line - 1) if start_line else 0
95
105
  end_idx = end_line if end_line else len(lines)
96
106
  lines = lines[start_idx:end_idx]
@@ -123,6 +133,14 @@ Returns the file content as text."""
123
133
  "type": "integer",
124
134
  "description": "Ending line number (1-indexed)",
125
135
  },
136
+ "offset": {
137
+ "type": "integer",
138
+ "description": "Line number to start reading from (1-indexed). Alternative to start_line.",
139
+ },
140
+ "limit": {
141
+ "type": "integer",
142
+ "description": "Number of lines to read. Alternative to end_line.",
143
+ },
126
144
  },
127
145
  required=["path"],
128
146
  )
@@ -135,6 +153,18 @@ class WriteToFileTool(CodingTool):
135
153
  description = """Write content to a file.
136
154
  Creates the file if it doesn't exist, or overwrites if it does."""
137
155
 
156
+ def __init__(self, repo_root: Path, connection=None, allowed_paths: list[str] | None = None):
157
+ """Initialize with optional path restrictions.
158
+
159
+ Args:
160
+ repo_root: Root directory of the repository
161
+ connection: Optional connection (not used for file ops)
162
+ allowed_paths: If provided, only these paths can be written to.
163
+ Used in plan mode to restrict writes to the plan file.
164
+ """
165
+ super().__init__(repo_root, connection)
166
+ self.allowed_paths = allowed_paths
167
+
138
168
  def execute(
139
169
  self,
140
170
  path: str,
@@ -153,6 +183,19 @@ Creates the file if it doesn't exist, or overwrites if it does."""
153
183
  if not valid:
154
184
  return ToolResult.error_result(error)
155
185
 
186
+ # Check allowed paths restriction (used in plan mode)
187
+ if self.allowed_paths is not None:
188
+ path_str = str(full_path)
189
+ is_allowed = any(
190
+ path_str == allowed or path_str.endswith(allowed.lstrip("/"))
191
+ for allowed in self.allowed_paths
192
+ )
193
+ if not is_allowed:
194
+ return ToolResult.error_result(
195
+ f"In plan mode, you can only write to: {', '.join(self.allowed_paths)}",
196
+ suggestions=["Write your plan to the designated plan file"],
197
+ )
198
+
156
199
  try:
157
200
  # Create parent directories
158
201
  full_path.parent.mkdir(parents=True, exist_ok=True)
@@ -218,8 +261,9 @@ The diff should be in standard unified diff format."""
218
261
 
219
262
  try:
220
263
  # Try to apply with patch command
264
+ # --batch: suppress questions, --forward: skip already-applied patches
221
265
  result = subprocess.run(
222
- ["patch", "-p0", "--forward"],
266
+ ["patch", "-p0", "--forward", "--batch"],
223
267
  input=diff,
224
268
  capture_output=True,
225
269
  text=True,
@@ -230,7 +274,7 @@ The diff should be in standard unified diff format."""
230
274
  if result.returncode != 0:
231
275
  # Try with -p1
232
276
  result = subprocess.run(
233
- ["patch", "-p1", "--forward"],
277
+ ["patch", "-p1", "--forward", "--batch"],
234
278
  input=diff,
235
279
  capture_output=True,
236
280
  text=True,