emdash-core 0.1.33__py3-none-any.whl → 0.1.37__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.
@@ -5,12 +5,16 @@ The main agent (in plan mode) writes the plan to .emdash/<feature>.md.
5
5
  """
6
6
 
7
7
  from pathlib import Path
8
+ from typing import TYPE_CHECKING, Optional
8
9
 
9
10
  from .base import BaseToolkit
10
11
  from ..tools.coding import ReadFileTool, ListFilesTool
11
12
  from ..tools.search import SemanticSearchTool, GrepTool, GlobTool
12
13
  from ...utils.logger import log
13
14
 
15
+ if TYPE_CHECKING:
16
+ from ..agents import AgentMCPServerConfig
17
+
14
18
 
15
19
  class PlanToolkit(BaseToolkit):
16
20
  """Read-only toolkit for Plan subagent.
@@ -24,6 +28,7 @@ class PlanToolkit(BaseToolkit):
24
28
  - glob: Find files by pattern
25
29
  - grep: Search file contents
26
30
  - semantic_search: AI-powered code search
31
+ - MCP server tools (if configured)
27
32
  """
28
33
 
29
34
  TOOLS = [
@@ -34,6 +39,19 @@ class PlanToolkit(BaseToolkit):
34
39
  "semantic_search",
35
40
  ]
36
41
 
42
+ def __init__(
43
+ self,
44
+ repo_root: Path,
45
+ mcp_servers: Optional[list["AgentMCPServerConfig"]] = None,
46
+ ):
47
+ """Initialize the plan toolkit.
48
+
49
+ Args:
50
+ repo_root: Root directory of the repository
51
+ mcp_servers: Optional MCP server configurations for this agent
52
+ """
53
+ super().__init__(repo_root, mcp_servers=mcp_servers)
54
+
37
55
  def _register_tools(self) -> None:
38
56
  """Register read-only exploration tools."""
39
57
  # All read-only exploration tools
@@ -87,11 +87,15 @@ Multiple sub-agents can be launched in parallel."""
87
87
  suggestions=["Provide a clear task description in 'prompt'"],
88
88
  )
89
89
 
90
- available_types = list_agent_types()
90
+ available_types = list_agent_types(self.repo_root)
91
+ log.info(f"TaskTool: repo_root={self.repo_root}, available_types={available_types}")
91
92
  if subagent_type not in available_types:
92
93
  return ToolResult.error_result(
93
94
  f"Unknown agent type: {subagent_type}",
94
- suggestions=[f"Available types: {available_types}"],
95
+ suggestions=[
96
+ f"Available types: {available_types}",
97
+ f"Searched in: {self.repo_root / '.emdash' / 'agents'}",
98
+ ],
95
99
  )
96
100
 
97
101
  # Log current mode for debugging
@@ -254,6 +258,9 @@ Multiple sub-agents can be launched in parallel."""
254
258
 
255
259
  def get_schema(self) -> dict:
256
260
  """Get OpenAI function schema."""
261
+ # Get available agent types dynamically (includes custom agents)
262
+ available_types = list_agent_types(self.repo_root)
263
+
257
264
  return self._make_schema(
258
265
  properties={
259
266
  "description": {
@@ -266,8 +273,8 @@ Multiple sub-agents can be launched in parallel."""
266
273
  },
267
274
  "subagent_type": {
268
275
  "type": "string",
269
- "enum": ["Explore", "Plan"],
270
- "description": "Type of specialized agent",
276
+ "enum": available_types,
277
+ "description": f"Type of specialized agent. Available: {', '.join(available_types)}",
271
278
  "default": "Explore",
272
279
  },
273
280
  "model_tier": {
emdash_core/api/agent.py CHANGED
@@ -95,6 +95,7 @@ def _run_agent_sync(
95
95
  images: list = None,
96
96
  plan_mode: bool = False,
97
97
  use_sdk: bool = None,
98
+ history: list = None,
98
99
  ):
99
100
  """Run the agent synchronously (in thread pool).
100
101
 
@@ -103,6 +104,9 @@ def _run_agent_sync(
103
104
 
104
105
  For Claude models with use_sdk=True, uses the Anthropic Agent SDK.
105
106
  For other models, uses the standard AgentRunner with OpenAI-compatible API.
107
+
108
+ Args:
109
+ history: Optional list of previous messages to pre-populate conversation
106
110
  """
107
111
  try:
108
112
  _ensure_emdash_importable()
@@ -140,6 +144,12 @@ def _run_agent_sync(
140
144
  emitter = AgentEventEmitter(agent_name="Emdash Code")
141
145
  emitter.add_handler(SSEBridgeHandler(sse_handler))
142
146
 
147
+ # Add hook handler for user-defined hooks
148
+ from ..agent.hooks import HookHandler, get_hook_manager
149
+ hook_manager = get_hook_manager()
150
+ hook_manager.set_session_id(session_id)
151
+ emitter.add_handler(HookHandler(hook_manager))
152
+
143
153
  # Use SDK for Claude models if enabled
144
154
  if use_sdk and is_claude_model(model):
145
155
  return _run_sdk_agent(
@@ -152,17 +162,23 @@ def _run_agent_sync(
152
162
  )
153
163
 
154
164
  # Standard path: use AgentRunner with OpenAI-compatible API
165
+ # Get repo_root from config (set by server on startup)
166
+ from pathlib import Path
167
+ from ..config import get_config
168
+ from ..utils.logger import log
169
+ config = get_config()
170
+ repo_root = Path(config.repo_root) if config.repo_root else Path.cwd()
171
+ log.info(f"Agent API: config.repo_root={config.repo_root}, resolved repo_root={repo_root}")
172
+
155
173
  # Create toolkit with plan_mode if requested
156
174
  # When in plan mode, generate a plan file path so write_to_file is available
157
175
  plan_file_path = None
158
176
  if plan_mode:
159
- from pathlib import Path
160
- repo_root = Path.cwd()
161
177
  plan_file_path = str(repo_root / ".emdash" / "plan.md")
162
178
  # Ensure .emdash directory exists
163
179
  (repo_root / ".emdash").mkdir(exist_ok=True)
164
180
 
165
- toolkit = AgentToolkit(plan_mode=plan_mode, plan_file_path=plan_file_path)
181
+ toolkit = AgentToolkit(repo_root=repo_root, plan_mode=plan_mode, plan_file_path=plan_file_path)
166
182
 
167
183
  runner = AgentRunner(
168
184
  toolkit=toolkit,
@@ -172,6 +188,11 @@ def _run_agent_sync(
172
188
  emitter=emitter,
173
189
  )
174
190
 
191
+ # Inject pre-loaded conversation history if provided
192
+ if history:
193
+ runner._messages = list(history)
194
+ log.info(f"Injected {len(history)} messages from saved session")
195
+
175
196
  # Store session state BEFORE running (so it exists even if interrupted)
176
197
  _sessions[session_id] = {
177
198
  "runner": runner,
@@ -180,6 +201,35 @@ def _run_agent_sync(
180
201
  "plan_mode": plan_mode,
181
202
  }
182
203
 
204
+ # Set up autosave callback if enabled via env var
205
+ import os
206
+ import json
207
+ if os.environ.get("EMDASH_SESSION_AUTOSAVE", "").lower() == "true":
208
+ sessions_dir = repo_root / ".emdash" / "sessions"
209
+ sessions_dir.mkdir(parents=True, exist_ok=True)
210
+ autosave_path = sessions_dir / "_autosave.json"
211
+
212
+ def autosave_callback(messages):
213
+ """Save messages to autosave file after each iteration."""
214
+ try:
215
+ # Limit to last 10 messages
216
+ trimmed = messages[-10:] if len(messages) > 10 else messages
217
+ autosave_data = {
218
+ "name": "_autosave",
219
+ "messages": trimmed,
220
+ "model": model,
221
+ "mode": "plan" if plan_mode else "code",
222
+ "session_id": session_id,
223
+ }
224
+ with open(autosave_path, "w") as f:
225
+ json.dump(autosave_data, f, indent=2, default=str)
226
+ log.debug(f"Autosaved {len(trimmed)} messages to {autosave_path}")
227
+ except Exception as e:
228
+ log.debug(f"Autosave failed: {e}")
229
+
230
+ runner._on_iteration_callback = autosave_callback
231
+ log.info("Session autosave enabled")
232
+
183
233
  # Convert image data if provided
184
234
  agent_images = None
185
235
  if images:
@@ -239,6 +289,8 @@ async def _run_agent_async(
239
289
  session_id,
240
290
  request.images,
241
291
  plan_mode,
292
+ None, # use_sdk (auto-detect)
293
+ request.history, # Pre-loaded conversation history
242
294
  )
243
295
 
244
296
  # Emit session end
@@ -411,6 +463,48 @@ async def delete_session(session_id: str):
411
463
  raise HTTPException(status_code=404, detail="Session not found")
412
464
 
413
465
 
466
+ @router.get("/chat/{session_id}/export")
467
+ async def export_session(session_id: str, limit: int = 10):
468
+ """Export session messages for persistence.
469
+
470
+ Args:
471
+ session_id: The session ID
472
+ limit: Maximum number of messages to return (default 10)
473
+
474
+ Returns:
475
+ JSON with messages array and metadata
476
+ """
477
+ if session_id not in _sessions:
478
+ raise HTTPException(status_code=404, detail="Session not found")
479
+
480
+ session = _sessions[session_id]
481
+ runner = session.get("runner")
482
+
483
+ if not runner:
484
+ return {
485
+ "session_id": session_id,
486
+ "messages": [],
487
+ "message_count": 0,
488
+ "model": session.get("model"),
489
+ "mode": "plan" if session.get("plan_mode") else "code",
490
+ }
491
+
492
+ # Get messages from runner
493
+ messages = getattr(runner, "_messages", [])
494
+
495
+ # Trim to limit (most recent)
496
+ if len(messages) > limit:
497
+ messages = messages[-limit:]
498
+
499
+ return {
500
+ "session_id": session_id,
501
+ "messages": messages,
502
+ "message_count": len(messages),
503
+ "model": session.get("model"),
504
+ "mode": "plan" if session.get("plan_mode") else "code",
505
+ }
506
+
507
+
414
508
  @router.get("/chat/{session_id}/plan")
415
509
  async def get_pending_plan(session_id: str):
416
510
  """Get the pending plan for a session, if any.
@@ -860,3 +954,60 @@ async def reject_plan_mode(session_id: str, feedback: str = ""):
860
954
  "X-Session-ID": session_id,
861
955
  },
862
956
  )
957
+
958
+
959
+ @router.get("/chat/{session_id}/todos")
960
+ async def get_todos(session_id: str):
961
+ """Get the current todo list for a session.
962
+
963
+ Returns the agent's task list including status of each item.
964
+ """
965
+ if session_id not in _sessions:
966
+ raise HTTPException(status_code=404, detail="Session not found")
967
+
968
+ # Get todos from TaskState singleton
969
+ from ..agent.tools.tasks import TaskState
970
+ state = TaskState.get_instance()
971
+
972
+ todos = state.get_all_tasks()
973
+
974
+ # Count by status
975
+ pending = sum(1 for t in todos if t["status"] == "pending")
976
+ in_progress = sum(1 for t in todos if t["status"] == "in_progress")
977
+ completed = sum(1 for t in todos if t["status"] == "completed")
978
+
979
+ return {
980
+ "session_id": session_id,
981
+ "todos": todos,
982
+ "summary": {
983
+ "total": len(todos),
984
+ "pending": pending,
985
+ "in_progress": in_progress,
986
+ "completed": completed,
987
+ },
988
+ }
989
+
990
+
991
+ @router.post("/chat/{session_id}/todos")
992
+ async def add_todo(session_id: str, title: str, description: str = ""):
993
+ """Add a new todo item to the agent's task list.
994
+
995
+ This allows users to inject tasks for the agent to work on.
996
+ """
997
+ if session_id not in _sessions:
998
+ raise HTTPException(status_code=404, detail="Session not found")
999
+
1000
+ if not title or not title.strip():
1001
+ raise HTTPException(status_code=400, detail="Title is required")
1002
+
1003
+ # Add todo via TaskState singleton
1004
+ from ..agent.tools.tasks import TaskState
1005
+ state = TaskState.get_instance()
1006
+
1007
+ task = state.add_task(title=title.strip(), description=description.strip())
1008
+
1009
+ return {
1010
+ "session_id": session_id,
1011
+ "task": task.to_dict(),
1012
+ "total_tasks": len(state.tasks),
1013
+ }
@@ -1,15 +1,9 @@
1
- """Repository management - clone, fetch, and track Git repositories."""
1
+ """Repository management for local Git repositories."""
2
2
 
3
- import hashlib
4
- import os
5
- import shutil
6
- from datetime import datetime
7
3
  from pathlib import Path
8
4
  from typing import Optional
9
- from urllib.parse import urlparse
10
5
 
11
- import git
12
- from git import Repo, GitCommandError
6
+ from git import Repo
13
7
 
14
8
  from ..core.exceptions import RepositoryError
15
9
  from ..core.models import RepositoryEntity
@@ -17,44 +11,37 @@ from ..utils.logger import log
17
11
 
18
12
 
19
13
  class RepositoryManager:
20
- """Manages Git repository operations (clone, fetch, status)."""
14
+ """Manages local Git repository operations."""
21
15
 
22
- def __init__(self, cache_dir: Optional[Path] = None):
23
- """Initialize repository manager.
24
-
25
- Args:
26
- cache_dir: Directory to cache cloned repositories.
27
- Defaults to ~/.emdash/repos
28
- """
29
- if cache_dir is None:
30
- cache_dir = Path.home() / ".emdash" / "repos"
31
-
32
- self.cache_dir = cache_dir
33
- self.cache_dir.mkdir(parents=True, exist_ok=True)
16
+ def __init__(self):
17
+ """Initialize repository manager."""
18
+ pass
34
19
 
35
20
  def get_or_clone(
36
21
  self,
37
22
  repo_path: str,
38
23
  skip_commit_count: bool = False
39
24
  ) -> tuple[Repo, RepositoryEntity]:
40
- """Get a repository from cache or clone it.
25
+ """Get a local repository.
41
26
 
42
27
  Args:
43
- repo_path: URL or local path to repository
44
- skip_commit_count: Whether to skip counting commits
28
+ repo_path: Local path to repository
29
+ skip_commit_count: Whether to skip counting commits (unused, kept for API compatibility)
45
30
 
46
31
  Returns:
47
32
  Tuple of (git.Repo, RepositoryEntity)
48
33
 
49
34
  Raises:
50
- RepositoryError: If repository cannot be accessed
35
+ RepositoryError: If repository cannot be accessed or path doesn't exist
51
36
  """
52
- # Check if it's a local path
53
- if Path(repo_path).exists():
54
- return self._open_local_repo(repo_path)
37
+ # Only support local paths
38
+ if not Path(repo_path).exists():
39
+ raise RepositoryError(
40
+ f"Repository path does not exist: {repo_path}. "
41
+ "Remote repository URLs are not supported - please provide a local path."
42
+ )
55
43
 
56
- # It's a URL - clone or fetch
57
- return self._clone_or_fetch(repo_path, skip_commit_count)
44
+ return self._open_local_repo(repo_path)
58
45
 
59
46
  def _open_local_repo(self, path: str) -> tuple[Repo, RepositoryEntity]:
60
47
  """Open a local repository.
@@ -88,164 +75,6 @@ class RepositoryManager:
88
75
  except Exception as e:
89
76
  raise RepositoryError(f"Failed to open local repository {path}: {e}")
90
77
 
91
- def _clone_or_fetch(
92
- self,
93
- url: str,
94
- skip_commit_count: bool
95
- ) -> tuple[Repo, RepositoryEntity]:
96
- """Clone a repository or fetch updates if already cloned.
97
-
98
- Args:
99
- url: Repository URL
100
- skip_commit_count: Whether to skip counting commits
101
-
102
- Returns:
103
- Tuple of (git.Repo, RepositoryEntity)
104
- """
105
- # Generate cache path from URL
106
- cache_path = self._get_cache_path(url)
107
-
108
- if cache_path.exists():
109
- log.info(f"Repository already cached at {cache_path}")
110
- return self._fetch_updates(cache_path, url, skip_commit_count)
111
- else:
112
- log.info(f"Cloning repository: {url}")
113
- return self._clone_repo(url, cache_path, skip_commit_count)
114
-
115
- def _clone_repo(
116
- self,
117
- url: str,
118
- cache_path: Path,
119
- skip_commit_count: bool
120
- ) -> tuple[Repo, RepositoryEntity]:
121
- """Clone a repository.
122
-
123
- Args:
124
- url: Repository URL
125
- cache_path: Path to clone into
126
- skip_commit_count: Whether to skip counting commits
127
-
128
- Returns:
129
- Tuple of (git.Repo, RepositoryEntity)
130
- """
131
- try:
132
- repo = Repo.clone_from(url, cache_path, depth=None)
133
- log.info(f"Successfully cloned {url}")
134
-
135
- entity = self._create_repository_entity(
136
- repo,
137
- url,
138
- skip_commit_count=skip_commit_count
139
- )
140
- return repo, entity
141
-
142
- except GitCommandError as e:
143
- raise RepositoryError(f"Failed to clone repository {url}: {e}")
144
- except Exception as e:
145
- raise RepositoryError(f"Unexpected error cloning {url}: {e}")
146
-
147
- def _fetch_updates(
148
- self,
149
- cache_path: Path,
150
- url: str,
151
- skip_commit_count: bool
152
- ) -> tuple[Repo, RepositoryEntity]:
153
- """Fetch updates for an existing repository.
154
-
155
- Args:
156
- cache_path: Path to cached repository
157
- url: Repository URL
158
- skip_commit_count: Whether to skip counting commits
159
-
160
- Returns:
161
- Tuple of (git.Repo, RepositoryEntity)
162
- """
163
- try:
164
- repo = Repo(cache_path)
165
-
166
- log.info("Fetching updates from remote...")
167
- repo.remotes.origin.fetch()
168
-
169
- # Pull latest changes
170
- repo.remotes.origin.pull()
171
-
172
- log.info("Repository updated successfully")
173
-
174
- entity = self._create_repository_entity(
175
- repo,
176
- url,
177
- skip_commit_count=skip_commit_count
178
- )
179
- return repo, entity
180
-
181
- except GitCommandError as e:
182
- raise RepositoryError(f"Failed to fetch updates for {url}: {e}")
183
- except Exception as e:
184
- raise RepositoryError(f"Unexpected error fetching updates: {e}")
185
-
186
- def _create_repository_entity(
187
- self,
188
- repo: Repo,
189
- url: str,
190
- skip_commit_count: bool = False
191
- ) -> RepositoryEntity:
192
- """Create a RepositoryEntity from a git.Repo.
193
-
194
- Args:
195
- repo: Git repository
196
- url: Repository URL
197
- skip_commit_count: Whether to skip counting commits
198
-
199
- Returns:
200
- RepositoryEntity
201
- """
202
- # Parse URL to extract owner and name
203
- parsed = urlparse(url)
204
- path_parts = parsed.path.strip("/").split("/")
205
-
206
- if len(path_parts) >= 2:
207
- owner = path_parts[-2]
208
- repo_name = path_parts[-1].replace(".git", "")
209
- else:
210
- owner = None
211
- repo_name = path_parts[-1].replace(".git", "") if path_parts else "unknown"
212
-
213
- commit_count = 0
214
- if not skip_commit_count:
215
- try:
216
- commit_count = sum(1 for _ in repo.iter_commits())
217
- except Exception:
218
- commit_count = 0
219
-
220
- return RepositoryEntity(
221
- url=url,
222
- name=repo_name,
223
- owner=owner,
224
- default_branch=repo.active_branch.name if repo.active_branch else "main",
225
- last_ingested=None,
226
- ingestion_status="pending",
227
- commit_count=commit_count,
228
- )
229
-
230
- def _get_cache_path(self, url: str) -> Path:
231
- """Get the cache path for a repository URL.
232
-
233
- Args:
234
- url: Repository URL
235
-
236
- Returns:
237
- Path to cache directory
238
- """
239
- # Create a unique directory name from URL
240
- url_hash = hashlib.md5(url.encode()).hexdigest()[:12]
241
-
242
- # Extract repo name from URL
243
- parsed = urlparse(url)
244
- path_parts = parsed.path.strip("/").split("/")
245
- repo_name = path_parts[-1].replace(".git", "")
246
-
247
- return self.cache_dir / f"{repo_name}_{url_hash}"
248
-
249
78
  def _get_origin_url(self, repo: Repo) -> Optional[str]:
250
79
  """Get the origin URL of a repository.
251
80
 
@@ -334,13 +163,3 @@ class RepositoryManager:
334
163
  This is a convenience wrapper around get_source_files() for backward compatibility.
335
164
  """
336
165
  return self.get_source_files(repo, ['.py'], ignore_patterns)
337
-
338
- def clear_cache(self):
339
- """Clear all cached repositories."""
340
- log.warning("Clearing repository cache...")
341
-
342
- if self.cache_dir.exists():
343
- shutil.rmtree(self.cache_dir)
344
- self.cache_dir.mkdir(parents=True, exist_ok=True)
345
-
346
- log.info("Cache cleared successfully")
@@ -51,6 +51,10 @@ class AgentChatRequest(BaseModel):
51
51
  default_factory=list,
52
52
  description="Images for vision-capable models"
53
53
  )
54
+ history: list[dict] = Field(
55
+ default_factory=list,
56
+ description="Pre-loaded conversation history from saved session"
57
+ )
54
58
  options: AgentChatOptions = Field(
55
59
  default_factory=AgentChatOptions,
56
60
  description="Agent options"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: emdash-core
3
- Version: 0.1.33
3
+ Version: 0.1.37
4
4
  Summary: EmDash Core - FastAPI server for code intelligence
5
5
  Author: Em Dash Team
6
6
  Requires-Python: >=3.10,<4.0
@@ -13,6 +13,7 @@ Classifier: Programming Language :: Python :: 3.14
13
13
  Requires-Dist: astroid (>=3.0.1,<4.0.0)
14
14
  Requires-Dist: beautifulsoup4 (>=4.12.0)
15
15
  Requires-Dist: claude-agent-sdk (>=0.1.19)
16
+ Requires-Dist: cmake (>=3.25.0)
16
17
  Requires-Dist: duckduckgo-search (>=6.0.0)
17
18
  Requires-Dist: fastapi (>=0.109.0)
18
19
  Requires-Dist: gitpython (>=3.1.40,<4.0.0)
@@ -28,6 +29,7 @@ Requires-Dist: pydantic-settings (>=2.0.0,<3.0.0)
28
29
  Requires-Dist: pygithub (>=2.1.1,<3.0.0)
29
30
  Requires-Dist: python-dotenv (>=1.0.0,<2.0.0)
30
31
  Requires-Dist: python-louvain (>=0.16,<0.17)
32
+ Requires-Dist: pyyaml (>=6.0,<7.0)
31
33
  Requires-Dist: sentence-transformers (>=2.2.0)
32
34
  Requires-Dist: sse-starlette (>=2.0.0)
33
35
  Requires-Dist: supabase (>=2.0.0)