emdash-core 0.1.33__py3-none-any.whl → 0.1.60__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 (67) hide show
  1. emdash_core/agent/agents.py +93 -23
  2. emdash_core/agent/background.py +481 -0
  3. emdash_core/agent/hooks.py +419 -0
  4. emdash_core/agent/inprocess_subagent.py +114 -10
  5. emdash_core/agent/mcp/config.py +78 -2
  6. emdash_core/agent/prompts/main_agent.py +88 -1
  7. emdash_core/agent/prompts/plan_mode.py +65 -44
  8. emdash_core/agent/prompts/subagents.py +96 -8
  9. emdash_core/agent/prompts/workflow.py +215 -50
  10. emdash_core/agent/providers/models.py +1 -1
  11. emdash_core/agent/providers/openai_provider.py +10 -0
  12. emdash_core/agent/research/researcher.py +154 -45
  13. emdash_core/agent/runner/agent_runner.py +157 -19
  14. emdash_core/agent/runner/context.py +28 -9
  15. emdash_core/agent/runner/sdk_runner.py +29 -2
  16. emdash_core/agent/skills.py +81 -1
  17. emdash_core/agent/toolkit.py +87 -11
  18. emdash_core/agent/toolkits/__init__.py +117 -18
  19. emdash_core/agent/toolkits/base.py +87 -2
  20. emdash_core/agent/toolkits/explore.py +18 -0
  21. emdash_core/agent/toolkits/plan.py +18 -0
  22. emdash_core/agent/tools/__init__.py +2 -0
  23. emdash_core/agent/tools/coding.py +344 -52
  24. emdash_core/agent/tools/lsp.py +361 -0
  25. emdash_core/agent/tools/skill.py +21 -1
  26. emdash_core/agent/tools/task.py +27 -23
  27. emdash_core/agent/tools/task_output.py +262 -32
  28. emdash_core/agent/verifier/__init__.py +11 -0
  29. emdash_core/agent/verifier/manager.py +295 -0
  30. emdash_core/agent/verifier/models.py +97 -0
  31. emdash_core/{swarm/worktree_manager.py → agent/worktree.py} +19 -1
  32. emdash_core/api/agent.py +451 -5
  33. emdash_core/api/research.py +3 -3
  34. emdash_core/api/router.py +0 -4
  35. emdash_core/context/longevity.py +197 -0
  36. emdash_core/context/providers/explored_areas.py +83 -39
  37. emdash_core/context/reranker.py +35 -144
  38. emdash_core/context/simple_reranker.py +500 -0
  39. emdash_core/context/tool_relevance.py +84 -0
  40. emdash_core/core/config.py +8 -0
  41. emdash_core/graph/__init__.py +8 -1
  42. emdash_core/graph/connection.py +24 -3
  43. emdash_core/graph/writer.py +7 -1
  44. emdash_core/ingestion/repository.py +17 -198
  45. emdash_core/models/agent.py +14 -0
  46. emdash_core/server.py +1 -6
  47. emdash_core/sse/stream.py +16 -1
  48. emdash_core/utils/__init__.py +0 -2
  49. emdash_core/utils/git.py +103 -0
  50. emdash_core/utils/image.py +147 -160
  51. {emdash_core-0.1.33.dist-info → emdash_core-0.1.60.dist-info}/METADATA +7 -5
  52. {emdash_core-0.1.33.dist-info → emdash_core-0.1.60.dist-info}/RECORD +54 -58
  53. emdash_core/api/swarm.py +0 -223
  54. emdash_core/db/__init__.py +0 -67
  55. emdash_core/db/auth.py +0 -134
  56. emdash_core/db/models.py +0 -91
  57. emdash_core/db/provider.py +0 -222
  58. emdash_core/db/providers/__init__.py +0 -5
  59. emdash_core/db/providers/supabase.py +0 -452
  60. emdash_core/swarm/__init__.py +0 -17
  61. emdash_core/swarm/merge_agent.py +0 -383
  62. emdash_core/swarm/session_manager.py +0 -274
  63. emdash_core/swarm/swarm_runner.py +0 -226
  64. emdash_core/swarm/task_definition.py +0 -137
  65. emdash_core/swarm/worker_spawner.py +0 -319
  66. {emdash_core-0.1.33.dist-info → emdash_core-0.1.60.dist-info}/WHEEL +0 -0
  67. {emdash_core-0.1.33.dist-info → emdash_core-0.1.60.dist-info}/entry_points.txt +0 -0
@@ -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")
@@ -1,11 +1,17 @@
1
1
  """Pydantic models for agent API."""
2
2
 
3
+ import os
3
4
  from enum import Enum
4
5
  from typing import Optional
5
6
 
6
7
  from pydantic import BaseModel, Field
7
8
 
8
9
 
10
+ def _default_use_worktree() -> bool:
11
+ """Get default worktree setting from environment."""
12
+ return os.environ.get("EMDASH_USE_WORKTREE", "false").lower() in ("true", "1", "yes")
13
+
14
+
9
15
  class AgentMode(str, Enum):
10
16
  """Agent operation modes."""
11
17
 
@@ -33,6 +39,10 @@ class AgentChatOptions(BaseModel):
33
39
  default=0.6,
34
40
  description="Context window threshold for summarization (0-1)"
35
41
  )
42
+ use_worktree: bool = Field(
43
+ default_factory=_default_use_worktree,
44
+ description="Use git worktree for isolated changes (EMDASH_USE_WORKTREE env)"
45
+ )
36
46
 
37
47
 
38
48
  class AgentChatRequest(BaseModel):
@@ -51,6 +61,10 @@ class AgentChatRequest(BaseModel):
51
61
  default_factory=list,
52
62
  description="Images for vision-capable models"
53
63
  )
64
+ history: list[dict] = Field(
65
+ default_factory=list,
66
+ description="Pre-loaded conversation history from saved session"
67
+ )
54
68
  options: AgentChatOptions = Field(
55
69
  default_factory=AgentChatOptions,
56
70
  description="Agent options"
emdash_core/server.py CHANGED
@@ -89,18 +89,13 @@ async def lifespan(app: FastAPI):
89
89
  if config.repo_root:
90
90
  print(f"Repository root: {config.repo_root}")
91
91
 
92
- # Write port file for CLI/Electron discovery
93
- port_file = Path.home() / ".emdash" / "server.port"
94
- port_file.parent.mkdir(parents=True, exist_ok=True)
95
- port_file.write_text(str(config.port))
92
+ # Note: Port file management is handled by ServerManager (per-repo files)
96
93
 
97
94
  yield
98
95
 
99
96
  # Shutdown
100
97
  print("EmDash Core shutting down...")
101
98
  _shutdown_executors()
102
- if port_file.exists():
103
- port_file.unlink()
104
99
 
105
100
 
106
101
  def create_app(
emdash_core/sse/stream.py CHANGED
@@ -8,6 +8,8 @@ from typing import Any, AsyncIterator
8
8
 
9
9
  from pydantic import BaseModel
10
10
 
11
+ from ..utils.logger import log
12
+
11
13
 
12
14
  class EventType(str, Enum):
13
15
  """Types of events emitted by agents (matches emdash.agent.events.EventType)."""
@@ -27,10 +29,12 @@ class EventType(str, Enum):
27
29
  # Output
28
30
  RESPONSE = "response"
29
31
  PARTIAL_RESPONSE = "partial_response"
32
+ ASSISTANT_TEXT = "assistant_text"
30
33
 
31
34
  # Interaction
32
35
  CLARIFICATION = "clarification"
33
36
  CLARIFICATION_RESPONSE = "clarification_response"
37
+ PLAN_MODE_REQUESTED = "plan_mode_requested"
34
38
  PLAN_SUBMITTED = "plan_submitted"
35
39
 
36
40
  # Errors
@@ -100,9 +104,20 @@ class SSEHandler:
100
104
  if self._closed:
101
105
  return
102
106
 
107
+ # Convert event type, with error handling for unknown types
108
+ try:
109
+ event_type = EventType(event.type.value)
110
+ except ValueError:
111
+ log.warning(
112
+ f"Unknown SSE event type: {event.type.value} - event dropped. "
113
+ "This may indicate EventType enum in sse/stream.py is out of sync "
114
+ "with agent/events.py"
115
+ )
116
+ return
117
+
103
118
  # Convert to SSEEvent
104
119
  sse_event = SSEEvent(
105
- type=EventType(event.type.value),
120
+ type=event_type,
106
121
  data=event.data,
107
122
  timestamp=event.timestamp,
108
123
  agent_name=event.agent_name or self._agent_name,
@@ -7,7 +7,6 @@ from .image import (
7
7
  read_clipboard_image,
8
8
  encode_image_to_base64,
9
9
  encode_image_for_llm,
10
- resize_image_if_needed,
11
10
  get_image_info,
12
11
  estimate_image_tokens,
13
12
  read_and_prepare_image,
@@ -31,7 +30,6 @@ __all__ = [
31
30
  "read_clipboard_image",
32
31
  "encode_image_to_base64",
33
32
  "encode_image_for_llm",
34
- "resize_image_if_needed",
35
33
  "get_image_info",
36
34
  "estimate_image_tokens",
37
35
  "read_and_prepare_image",
emdash_core/utils/git.py CHANGED
@@ -82,3 +82,106 @@ def get_normalized_remote_url(repo_root: Path) -> Optional[str]:
82
82
  if remote_url:
83
83
  return normalize_repo_url(remote_url)
84
84
  return None
85
+
86
+
87
+ def get_current_branch(repo_root: Path) -> Optional[str]:
88
+ """Get the current git branch name.
89
+
90
+ Args:
91
+ repo_root: Path to the git repository root
92
+
93
+ Returns:
94
+ The current branch name or None if not found/not in a git repo
95
+ """
96
+ try:
97
+ result = subprocess.run(
98
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
99
+ cwd=repo_root,
100
+ capture_output=True,
101
+ text=True,
102
+ timeout=5,
103
+ )
104
+ if result.returncode == 0:
105
+ return result.stdout.strip()
106
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
107
+ pass
108
+ return None
109
+
110
+
111
+ def get_git_status_summary(repo_root: Path) -> Optional[str]:
112
+ """Get a brief summary of git status.
113
+
114
+ Args:
115
+ repo_root: Path to the git repository root
116
+
117
+ Returns:
118
+ Brief status summary (e.g., "3 modified, 2 untracked") or None
119
+ """
120
+ try:
121
+ result = subprocess.run(
122
+ ["git", "status", "--porcelain"],
123
+ cwd=repo_root,
124
+ capture_output=True,
125
+ text=True,
126
+ timeout=5,
127
+ )
128
+ if result.returncode != 0:
129
+ return None
130
+
131
+ lines = result.stdout.strip().split("\n") if result.stdout.strip() else []
132
+ if not lines:
133
+ return "clean"
134
+
135
+ # Count by status type
136
+ modified = 0
137
+ untracked = 0
138
+ staged = 0
139
+ deleted = 0
140
+
141
+ for line in lines:
142
+ if not line:
143
+ continue
144
+ status = line[:2]
145
+ if status == "??":
146
+ untracked += 1
147
+ elif status[0] in ("M", "A", "D", "R", "C"):
148
+ staged += 1
149
+ elif status[1] == "M":
150
+ modified += 1
151
+ elif status[1] == "D":
152
+ deleted += 1
153
+
154
+ parts = []
155
+ if staged:
156
+ parts.append(f"{staged} staged")
157
+ if modified:
158
+ parts.append(f"{modified} modified")
159
+ if deleted:
160
+ parts.append(f"{deleted} deleted")
161
+ if untracked:
162
+ parts.append(f"{untracked} untracked")
163
+
164
+ return ", ".join(parts) if parts else "clean"
165
+
166
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
167
+ pass
168
+ return None
169
+
170
+
171
+ def get_repo_name(repo_root: Path) -> Optional[str]:
172
+ """Get the repository name from the remote URL or directory name.
173
+
174
+ Args:
175
+ repo_root: Path to the git repository root
176
+
177
+ Returns:
178
+ Repository name (e.g., "user/repo") or None
179
+ """
180
+ remote_url = get_normalized_remote_url(repo_root)
181
+ if remote_url:
182
+ # Extract user/repo from https://github.com/user/repo
183
+ parts = remote_url.rstrip("/").split("/")
184
+ if len(parts) >= 2:
185
+ return f"{parts[-2]}/{parts[-1]}"
186
+ # Fallback to directory name
187
+ return repo_root.name