emdash-core 0.1.7__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.
- emdash_core/__init__.py +3 -0
- emdash_core/agent/__init__.py +37 -0
- emdash_core/agent/agents.py +225 -0
- emdash_core/agent/code_reviewer.py +476 -0
- emdash_core/agent/compaction.py +143 -0
- emdash_core/agent/context_manager.py +140 -0
- emdash_core/agent/events.py +338 -0
- emdash_core/agent/handlers.py +224 -0
- emdash_core/agent/inprocess_subagent.py +377 -0
- emdash_core/agent/mcp/__init__.py +50 -0
- emdash_core/agent/mcp/client.py +346 -0
- emdash_core/agent/mcp/config.py +302 -0
- emdash_core/agent/mcp/manager.py +496 -0
- emdash_core/agent/mcp/tool_factory.py +213 -0
- emdash_core/agent/prompts/__init__.py +38 -0
- emdash_core/agent/prompts/main_agent.py +104 -0
- emdash_core/agent/prompts/subagents.py +131 -0
- emdash_core/agent/prompts/workflow.py +136 -0
- emdash_core/agent/providers/__init__.py +34 -0
- emdash_core/agent/providers/base.py +143 -0
- emdash_core/agent/providers/factory.py +80 -0
- emdash_core/agent/providers/models.py +220 -0
- emdash_core/agent/providers/openai_provider.py +463 -0
- emdash_core/agent/providers/transformers_provider.py +217 -0
- emdash_core/agent/research/__init__.py +81 -0
- emdash_core/agent/research/agent.py +143 -0
- emdash_core/agent/research/controller.py +254 -0
- emdash_core/agent/research/critic.py +428 -0
- emdash_core/agent/research/macros.py +469 -0
- emdash_core/agent/research/planner.py +449 -0
- emdash_core/agent/research/researcher.py +436 -0
- emdash_core/agent/research/state.py +523 -0
- emdash_core/agent/research/synthesizer.py +594 -0
- emdash_core/agent/reviewer_profile.py +475 -0
- emdash_core/agent/rules.py +123 -0
- emdash_core/agent/runner.py +601 -0
- emdash_core/agent/session.py +262 -0
- emdash_core/agent/spec_schema.py +66 -0
- emdash_core/agent/specification.py +479 -0
- emdash_core/agent/subagent.py +397 -0
- emdash_core/agent/subagent_prompts.py +13 -0
- emdash_core/agent/toolkit.py +482 -0
- emdash_core/agent/toolkits/__init__.py +64 -0
- emdash_core/agent/toolkits/base.py +96 -0
- emdash_core/agent/toolkits/explore.py +47 -0
- emdash_core/agent/toolkits/plan.py +55 -0
- emdash_core/agent/tools/__init__.py +141 -0
- emdash_core/agent/tools/analytics.py +436 -0
- emdash_core/agent/tools/base.py +131 -0
- emdash_core/agent/tools/coding.py +484 -0
- emdash_core/agent/tools/github_mcp.py +592 -0
- emdash_core/agent/tools/history.py +13 -0
- emdash_core/agent/tools/modes.py +153 -0
- emdash_core/agent/tools/plan.py +206 -0
- emdash_core/agent/tools/plan_write.py +135 -0
- emdash_core/agent/tools/search.py +412 -0
- emdash_core/agent/tools/spec.py +341 -0
- emdash_core/agent/tools/task.py +262 -0
- emdash_core/agent/tools/task_output.py +204 -0
- emdash_core/agent/tools/tasks.py +454 -0
- emdash_core/agent/tools/traversal.py +588 -0
- emdash_core/agent/tools/web.py +179 -0
- emdash_core/analytics/__init__.py +5 -0
- emdash_core/analytics/engine.py +1286 -0
- emdash_core/api/__init__.py +5 -0
- emdash_core/api/agent.py +308 -0
- emdash_core/api/agents.py +154 -0
- emdash_core/api/analyze.py +264 -0
- emdash_core/api/auth.py +173 -0
- emdash_core/api/context.py +77 -0
- emdash_core/api/db.py +121 -0
- emdash_core/api/embed.py +131 -0
- emdash_core/api/feature.py +143 -0
- emdash_core/api/health.py +93 -0
- emdash_core/api/index.py +162 -0
- emdash_core/api/plan.py +110 -0
- emdash_core/api/projectmd.py +210 -0
- emdash_core/api/query.py +320 -0
- emdash_core/api/research.py +122 -0
- emdash_core/api/review.py +161 -0
- emdash_core/api/router.py +76 -0
- emdash_core/api/rules.py +116 -0
- emdash_core/api/search.py +119 -0
- emdash_core/api/spec.py +99 -0
- emdash_core/api/swarm.py +223 -0
- emdash_core/api/tasks.py +109 -0
- emdash_core/api/team.py +120 -0
- emdash_core/auth/__init__.py +17 -0
- emdash_core/auth/github.py +389 -0
- emdash_core/config.py +74 -0
- emdash_core/context/__init__.py +52 -0
- emdash_core/context/models.py +50 -0
- emdash_core/context/providers/__init__.py +11 -0
- emdash_core/context/providers/base.py +74 -0
- emdash_core/context/providers/explored_areas.py +183 -0
- emdash_core/context/providers/touched_areas.py +360 -0
- emdash_core/context/registry.py +73 -0
- emdash_core/context/reranker.py +199 -0
- emdash_core/context/service.py +260 -0
- emdash_core/context/session.py +352 -0
- emdash_core/core/__init__.py +104 -0
- emdash_core/core/config.py +454 -0
- emdash_core/core/exceptions.py +55 -0
- emdash_core/core/models.py +265 -0
- emdash_core/core/review_config.py +57 -0
- emdash_core/db/__init__.py +67 -0
- emdash_core/db/auth.py +134 -0
- emdash_core/db/models.py +91 -0
- emdash_core/db/provider.py +222 -0
- emdash_core/db/providers/__init__.py +5 -0
- emdash_core/db/providers/supabase.py +452 -0
- emdash_core/embeddings/__init__.py +24 -0
- emdash_core/embeddings/indexer.py +534 -0
- emdash_core/embeddings/models.py +192 -0
- emdash_core/embeddings/providers/__init__.py +7 -0
- emdash_core/embeddings/providers/base.py +112 -0
- emdash_core/embeddings/providers/fireworks.py +141 -0
- emdash_core/embeddings/providers/openai.py +104 -0
- emdash_core/embeddings/registry.py +146 -0
- emdash_core/embeddings/service.py +215 -0
- emdash_core/graph/__init__.py +26 -0
- emdash_core/graph/builder.py +134 -0
- emdash_core/graph/connection.py +692 -0
- emdash_core/graph/schema.py +416 -0
- emdash_core/graph/writer.py +667 -0
- emdash_core/ingestion/__init__.py +7 -0
- emdash_core/ingestion/change_detector.py +150 -0
- emdash_core/ingestion/git/__init__.py +5 -0
- emdash_core/ingestion/git/commit_analyzer.py +196 -0
- emdash_core/ingestion/github/__init__.py +6 -0
- emdash_core/ingestion/github/pr_fetcher.py +296 -0
- emdash_core/ingestion/github/task_extractor.py +100 -0
- emdash_core/ingestion/orchestrator.py +540 -0
- emdash_core/ingestion/parsers/__init__.py +10 -0
- emdash_core/ingestion/parsers/base_parser.py +66 -0
- emdash_core/ingestion/parsers/call_graph_builder.py +121 -0
- emdash_core/ingestion/parsers/class_extractor.py +154 -0
- emdash_core/ingestion/parsers/function_extractor.py +202 -0
- emdash_core/ingestion/parsers/import_analyzer.py +119 -0
- emdash_core/ingestion/parsers/python_parser.py +123 -0
- emdash_core/ingestion/parsers/registry.py +72 -0
- emdash_core/ingestion/parsers/ts_ast_parser.js +313 -0
- emdash_core/ingestion/parsers/typescript_parser.py +278 -0
- emdash_core/ingestion/repository.py +346 -0
- emdash_core/models/__init__.py +38 -0
- emdash_core/models/agent.py +68 -0
- emdash_core/models/index.py +77 -0
- emdash_core/models/query.py +113 -0
- emdash_core/planning/__init__.py +7 -0
- emdash_core/planning/agent_api.py +413 -0
- emdash_core/planning/context_builder.py +265 -0
- emdash_core/planning/feature_context.py +232 -0
- emdash_core/planning/feature_expander.py +646 -0
- emdash_core/planning/llm_explainer.py +198 -0
- emdash_core/planning/similarity.py +509 -0
- emdash_core/planning/team_focus.py +821 -0
- emdash_core/server.py +153 -0
- emdash_core/sse/__init__.py +5 -0
- emdash_core/sse/stream.py +196 -0
- emdash_core/swarm/__init__.py +17 -0
- emdash_core/swarm/merge_agent.py +383 -0
- emdash_core/swarm/session_manager.py +274 -0
- emdash_core/swarm/swarm_runner.py +226 -0
- emdash_core/swarm/task_definition.py +137 -0
- emdash_core/swarm/worker_spawner.py +319 -0
- emdash_core/swarm/worktree_manager.py +278 -0
- emdash_core/templates/__init__.py +10 -0
- emdash_core/templates/defaults/agent-builder.md.template +82 -0
- emdash_core/templates/defaults/focus.md.template +115 -0
- emdash_core/templates/defaults/pr-review-enhanced.md.template +309 -0
- emdash_core/templates/defaults/pr-review.md.template +80 -0
- emdash_core/templates/defaults/project.md.template +85 -0
- emdash_core/templates/defaults/research_critic.md.template +112 -0
- emdash_core/templates/defaults/research_planner.md.template +85 -0
- emdash_core/templates/defaults/research_synthesizer.md.template +128 -0
- emdash_core/templates/defaults/reviewer.md.template +81 -0
- emdash_core/templates/defaults/spec.md.template +41 -0
- emdash_core/templates/defaults/tasks.md.template +78 -0
- emdash_core/templates/loader.py +296 -0
- emdash_core/utils/__init__.py +45 -0
- emdash_core/utils/git.py +84 -0
- emdash_core/utils/image.py +502 -0
- emdash_core/utils/logger.py +51 -0
- emdash_core-0.1.7.dist-info/METADATA +35 -0
- emdash_core-0.1.7.dist-info/RECORD +187 -0
- emdash_core-0.1.7.dist-info/WHEEL +4 -0
- emdash_core-0.1.7.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""GitHub OAuth Device Flow authentication.
|
|
2
|
+
|
|
3
|
+
This module implements GitHub's device flow for CLI authentication,
|
|
4
|
+
similar to how `gh auth login` works.
|
|
5
|
+
|
|
6
|
+
Device Flow Steps:
|
|
7
|
+
1. Request device and user codes from GitHub
|
|
8
|
+
2. Display user code and open browser to github.com/login/device
|
|
9
|
+
3. Poll GitHub until user completes authorization
|
|
10
|
+
4. Store access token securely
|
|
11
|
+
|
|
12
|
+
Reference: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import time
|
|
18
|
+
import webbrowser
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Optional
|
|
22
|
+
from urllib.parse import urlencode
|
|
23
|
+
from urllib.request import Request, urlopen
|
|
24
|
+
from urllib.error import HTTPError, URLError
|
|
25
|
+
|
|
26
|
+
from ..utils.logger import log
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Em Dash GitHub OAuth App Client ID
|
|
30
|
+
GITHUB_CLIENT_ID = "Ov23liMPlw6JMmzUainJ"
|
|
31
|
+
|
|
32
|
+
# GitHub OAuth endpoints
|
|
33
|
+
DEVICE_CODE_URL = "https://github.com/login/device/code"
|
|
34
|
+
ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"
|
|
35
|
+
USER_API_URL = "https://api.github.com/user"
|
|
36
|
+
|
|
37
|
+
# Default scopes for Rove (repo access for code search, PRs, etc.)
|
|
38
|
+
DEFAULT_SCOPES = ["repo", "read:user"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class AuthConfig:
|
|
43
|
+
"""Stored authentication configuration."""
|
|
44
|
+
|
|
45
|
+
access_token: str
|
|
46
|
+
token_type: str = "bearer"
|
|
47
|
+
scope: str = ""
|
|
48
|
+
username: Optional[str] = None
|
|
49
|
+
|
|
50
|
+
def to_dict(self) -> dict:
|
|
51
|
+
return {
|
|
52
|
+
"access_token": self.access_token,
|
|
53
|
+
"token_type": self.token_type,
|
|
54
|
+
"scope": self.scope,
|
|
55
|
+
"username": self.username,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def from_dict(cls, data: dict) -> "AuthConfig":
|
|
60
|
+
return cls(
|
|
61
|
+
access_token=data.get("access_token", ""),
|
|
62
|
+
token_type=data.get("token_type", "bearer"),
|
|
63
|
+
scope=data.get("scope", ""),
|
|
64
|
+
username=data.get("username"),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class DeviceCodeResponse:
|
|
70
|
+
"""Response from device code request."""
|
|
71
|
+
|
|
72
|
+
device_code: str
|
|
73
|
+
user_code: str
|
|
74
|
+
verification_uri: str
|
|
75
|
+
expires_in: int
|
|
76
|
+
interval: int
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_config_dir() -> Path:
|
|
80
|
+
"""Get the Rove config directory.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Path to ~/.config/emdash/
|
|
84
|
+
"""
|
|
85
|
+
# Follow XDG spec on Linux/Mac
|
|
86
|
+
xdg_config = os.environ.get("XDG_CONFIG_HOME")
|
|
87
|
+
if xdg_config:
|
|
88
|
+
config_dir = Path(xdg_config) / "emdash"
|
|
89
|
+
else:
|
|
90
|
+
config_dir = Path.home() / ".config" / "emdash"
|
|
91
|
+
|
|
92
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
return config_dir
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_auth_file() -> Path:
|
|
97
|
+
"""Get path to the auth config file."""
|
|
98
|
+
return get_config_dir() / "auth.json"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _make_request(url: str, data: dict, headers: dict = None) -> dict:
|
|
102
|
+
"""Make a POST request and return JSON response."""
|
|
103
|
+
headers = headers or {}
|
|
104
|
+
headers["Accept"] = "application/json"
|
|
105
|
+
|
|
106
|
+
encoded_data = urlencode(data).encode("utf-8")
|
|
107
|
+
request = Request(url, data=encoded_data, headers=headers, method="POST")
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
with urlopen(request, timeout=30) as response:
|
|
111
|
+
return json.loads(response.read().decode("utf-8"))
|
|
112
|
+
except HTTPError as e:
|
|
113
|
+
error_body = e.read().decode("utf-8")
|
|
114
|
+
log.error(f"HTTP error {e.code}: {error_body}")
|
|
115
|
+
raise
|
|
116
|
+
except URLError as e:
|
|
117
|
+
log.error(f"URL error: {e.reason}")
|
|
118
|
+
raise
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _get_request(url: str, token: str) -> dict:
|
|
122
|
+
"""Make a GET request with auth token."""
|
|
123
|
+
headers = {
|
|
124
|
+
"Accept": "application/json",
|
|
125
|
+
"Authorization": f"Bearer {token}",
|
|
126
|
+
}
|
|
127
|
+
request = Request(url, headers=headers)
|
|
128
|
+
|
|
129
|
+
with urlopen(request, timeout=30) as response:
|
|
130
|
+
return json.loads(response.read().decode("utf-8"))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class GitHubAuth:
|
|
134
|
+
"""GitHub OAuth device flow authentication."""
|
|
135
|
+
|
|
136
|
+
def __init__(self, client_id: str = GITHUB_CLIENT_ID):
|
|
137
|
+
self.client_id = client_id
|
|
138
|
+
|
|
139
|
+
def request_device_code(self, scopes: list[str] = None) -> DeviceCodeResponse:
|
|
140
|
+
"""Request a device code from GitHub.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
scopes: OAuth scopes to request
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
DeviceCodeResponse with codes and URIs
|
|
147
|
+
"""
|
|
148
|
+
scopes = scopes or DEFAULT_SCOPES
|
|
149
|
+
|
|
150
|
+
data = {
|
|
151
|
+
"client_id": self.client_id,
|
|
152
|
+
"scope": " ".join(scopes),
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
response = _make_request(DEVICE_CODE_URL, data)
|
|
156
|
+
|
|
157
|
+
return DeviceCodeResponse(
|
|
158
|
+
device_code=response["device_code"],
|
|
159
|
+
user_code=response["user_code"],
|
|
160
|
+
verification_uri=response["verification_uri"],
|
|
161
|
+
expires_in=response["expires_in"],
|
|
162
|
+
interval=response["interval"],
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def poll_for_token(
|
|
166
|
+
self,
|
|
167
|
+
device_code: str,
|
|
168
|
+
interval: int = 5,
|
|
169
|
+
timeout: int = 900,
|
|
170
|
+
) -> Optional[AuthConfig]:
|
|
171
|
+
"""Poll GitHub for access token after user authorizes.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
device_code: Device code from request_device_code
|
|
175
|
+
interval: Polling interval in seconds
|
|
176
|
+
timeout: Maximum time to wait in seconds
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
AuthConfig if successful, None if timeout/cancelled
|
|
180
|
+
"""
|
|
181
|
+
data = {
|
|
182
|
+
"client_id": self.client_id,
|
|
183
|
+
"device_code": device_code,
|
|
184
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
start_time = time.time()
|
|
188
|
+
|
|
189
|
+
while time.time() - start_time < timeout:
|
|
190
|
+
try:
|
|
191
|
+
response = _make_request(ACCESS_TOKEN_URL, data)
|
|
192
|
+
|
|
193
|
+
if "access_token" in response:
|
|
194
|
+
# Success!
|
|
195
|
+
config = AuthConfig(
|
|
196
|
+
access_token=response["access_token"],
|
|
197
|
+
token_type=response.get("token_type", "bearer"),
|
|
198
|
+
scope=response.get("scope", ""),
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
# Fetch username
|
|
202
|
+
try:
|
|
203
|
+
user_info = _get_request(USER_API_URL, config.access_token)
|
|
204
|
+
config.username = user_info.get("login")
|
|
205
|
+
except Exception as e:
|
|
206
|
+
log.warning(f"Failed to fetch username: {e}")
|
|
207
|
+
|
|
208
|
+
return config
|
|
209
|
+
|
|
210
|
+
error = response.get("error")
|
|
211
|
+
|
|
212
|
+
if error == "authorization_pending":
|
|
213
|
+
# User hasn't authorized yet, keep polling
|
|
214
|
+
time.sleep(interval)
|
|
215
|
+
continue
|
|
216
|
+
elif error == "slow_down":
|
|
217
|
+
# We're polling too fast
|
|
218
|
+
interval += 5
|
|
219
|
+
time.sleep(interval)
|
|
220
|
+
continue
|
|
221
|
+
elif error == "expired_token":
|
|
222
|
+
log.error("Device code expired. Please try again.")
|
|
223
|
+
return None
|
|
224
|
+
elif error == "access_denied":
|
|
225
|
+
log.error("Authorization was denied.")
|
|
226
|
+
return None
|
|
227
|
+
else:
|
|
228
|
+
log.error(f"Unexpected error: {error}")
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
except Exception as e:
|
|
232
|
+
log.error(f"Error polling for token: {e}")
|
|
233
|
+
time.sleep(interval)
|
|
234
|
+
|
|
235
|
+
log.error("Timeout waiting for authorization.")
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
def login(self, scopes: list[str] = None, open_browser: bool = True) -> Optional[AuthConfig]:
|
|
239
|
+
"""Perform full device flow login.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
scopes: OAuth scopes to request
|
|
243
|
+
open_browser: Whether to open browser automatically
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
AuthConfig if successful, None otherwise
|
|
247
|
+
"""
|
|
248
|
+
# Request device code
|
|
249
|
+
device_response = self.request_device_code(scopes)
|
|
250
|
+
|
|
251
|
+
# Display instructions
|
|
252
|
+
print()
|
|
253
|
+
print(f"! First, copy your one-time code: {device_response.user_code}")
|
|
254
|
+
print(f"- Then visit: {device_response.verification_uri}")
|
|
255
|
+
|
|
256
|
+
if open_browser:
|
|
257
|
+
input("- Press Enter to open github.com in your browser...")
|
|
258
|
+
webbrowser.open(device_response.verification_uri)
|
|
259
|
+
|
|
260
|
+
print()
|
|
261
|
+
print("Waiting for authorization...")
|
|
262
|
+
|
|
263
|
+
# Poll for token
|
|
264
|
+
config = self.poll_for_token(
|
|
265
|
+
device_response.device_code,
|
|
266
|
+
interval=device_response.interval,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
if config:
|
|
270
|
+
# Save to disk
|
|
271
|
+
save_auth_config(config)
|
|
272
|
+
print()
|
|
273
|
+
if config.username:
|
|
274
|
+
print(f"✓ Authentication complete. Logged in as @{config.username}")
|
|
275
|
+
else:
|
|
276
|
+
print("✓ Authentication complete.")
|
|
277
|
+
|
|
278
|
+
return config
|
|
279
|
+
|
|
280
|
+
def logout(self) -> bool:
|
|
281
|
+
"""Remove stored authentication.
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
True if logout successful
|
|
285
|
+
"""
|
|
286
|
+
auth_file = get_auth_file()
|
|
287
|
+
if auth_file.exists():
|
|
288
|
+
auth_file.unlink()
|
|
289
|
+
return True
|
|
290
|
+
return False
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def save_auth_config(config: AuthConfig) -> None:
|
|
294
|
+
"""Save authentication config to disk.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
config: AuthConfig to save
|
|
298
|
+
"""
|
|
299
|
+
auth_file = get_auth_file()
|
|
300
|
+
|
|
301
|
+
# Set restrictive permissions (owner read/write only)
|
|
302
|
+
with open(auth_file, "w") as f:
|
|
303
|
+
json.dump(config.to_dict(), f, indent=2)
|
|
304
|
+
|
|
305
|
+
# chmod 600
|
|
306
|
+
os.chmod(auth_file, 0o600)
|
|
307
|
+
|
|
308
|
+
log.debug(f"Saved auth config to {auth_file}")
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def load_auth_config() -> Optional[AuthConfig]:
|
|
312
|
+
"""Load authentication config from disk.
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
AuthConfig or None if not found
|
|
316
|
+
"""
|
|
317
|
+
auth_file = get_auth_file()
|
|
318
|
+
|
|
319
|
+
if not auth_file.exists():
|
|
320
|
+
return None
|
|
321
|
+
|
|
322
|
+
try:
|
|
323
|
+
with open(auth_file) as f:
|
|
324
|
+
data = json.load(f)
|
|
325
|
+
return AuthConfig.from_dict(data)
|
|
326
|
+
except Exception as e:
|
|
327
|
+
log.warning(f"Failed to load auth config: {e}")
|
|
328
|
+
return None
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def get_github_token() -> Optional[str]:
|
|
332
|
+
"""Get GitHub token from auth config or environment.
|
|
333
|
+
|
|
334
|
+
Priority:
|
|
335
|
+
1. Rove auth config (~/.config/emdash/auth.json)
|
|
336
|
+
2. GITHUB_TOKEN environment variable
|
|
337
|
+
3. GITHUB_PERSONAL_ACCESS_TOKEN environment variable
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
GitHub access token or None
|
|
341
|
+
"""
|
|
342
|
+
# Check Rove auth first
|
|
343
|
+
config = load_auth_config()
|
|
344
|
+
if config and config.access_token:
|
|
345
|
+
return config.access_token
|
|
346
|
+
|
|
347
|
+
# Fall back to environment variables
|
|
348
|
+
return os.environ.get("GITHUB_TOKEN") or os.environ.get("GITHUB_PERSONAL_ACCESS_TOKEN")
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def is_authenticated() -> bool:
|
|
352
|
+
"""Check if user is authenticated.
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
True if authenticated (either via Rove auth or env var)
|
|
356
|
+
"""
|
|
357
|
+
return get_github_token() is not None
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def get_auth_status() -> dict:
|
|
361
|
+
"""Get current authentication status.
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
Dict with status info
|
|
365
|
+
"""
|
|
366
|
+
config = load_auth_config()
|
|
367
|
+
env_token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GITHUB_PERSONAL_ACCESS_TOKEN")
|
|
368
|
+
|
|
369
|
+
if config and config.access_token:
|
|
370
|
+
return {
|
|
371
|
+
"authenticated": True,
|
|
372
|
+
"source": "emdash auth",
|
|
373
|
+
"username": config.username,
|
|
374
|
+
"scopes": config.scope.split() if config.scope else [],
|
|
375
|
+
}
|
|
376
|
+
elif env_token:
|
|
377
|
+
return {
|
|
378
|
+
"authenticated": True,
|
|
379
|
+
"source": "environment variable",
|
|
380
|
+
"username": None,
|
|
381
|
+
"scopes": [],
|
|
382
|
+
}
|
|
383
|
+
else:
|
|
384
|
+
return {
|
|
385
|
+
"authenticated": False,
|
|
386
|
+
"source": None,
|
|
387
|
+
"username": None,
|
|
388
|
+
"scopes": [],
|
|
389
|
+
}
|
emdash_core/config.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Server configuration."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from pydantic import Field
|
|
8
|
+
from pydantic_settings import BaseSettings
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _get_default_model() -> str:
|
|
12
|
+
"""Get the default model from emdash_core or fallback."""
|
|
13
|
+
try:
|
|
14
|
+
from .agent.providers.factory import DEFAULT_MODEL
|
|
15
|
+
return DEFAULT_MODEL
|
|
16
|
+
except ImportError:
|
|
17
|
+
# Fallback if not available
|
|
18
|
+
return os.getenv("EMDASH_DEFAULT_MODEL", "fireworks:accounts/fireworks/models/minimax-m2p1")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ServerConfig(BaseSettings):
|
|
22
|
+
"""Configuration for the emdash-core server."""
|
|
23
|
+
|
|
24
|
+
# Server settings
|
|
25
|
+
host: str = Field(default="127.0.0.1", description="Host to bind to")
|
|
26
|
+
port: int = Field(default=8765, description="Port to bind to")
|
|
27
|
+
|
|
28
|
+
# Repository settings
|
|
29
|
+
repo_root: Optional[str] = Field(default=None, description="Repository root path")
|
|
30
|
+
|
|
31
|
+
# Database settings
|
|
32
|
+
database_path: str = Field(
|
|
33
|
+
default=".emdash/index/kuzu_db",
|
|
34
|
+
description="Path to Kuzu database relative to repo root"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Agent settings
|
|
38
|
+
default_model: str = Field(default_factory=_get_default_model, description="Default LLM model")
|
|
39
|
+
max_iterations: int = Field(default=50, description="Max agent iterations")
|
|
40
|
+
context_threshold: float = Field(default=0.6, description="Context window threshold for summarization")
|
|
41
|
+
|
|
42
|
+
# SSE settings
|
|
43
|
+
sse_ping_interval: int = Field(default=15, description="SSE ping interval in seconds")
|
|
44
|
+
|
|
45
|
+
class Config:
|
|
46
|
+
env_prefix = "EMDASH_"
|
|
47
|
+
env_file = ".env"
|
|
48
|
+
extra = "ignore"
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def database_full_path(self) -> Path:
|
|
52
|
+
"""Get full path to database."""
|
|
53
|
+
if self.repo_root:
|
|
54
|
+
return Path(self.repo_root) / self.database_path
|
|
55
|
+
return Path(self.database_path)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Global config instance
|
|
59
|
+
_config: Optional[ServerConfig] = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_config() -> ServerConfig:
|
|
63
|
+
"""Get or create server configuration."""
|
|
64
|
+
global _config
|
|
65
|
+
if _config is None:
|
|
66
|
+
_config = ServerConfig()
|
|
67
|
+
return _config
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def set_config(**kwargs) -> ServerConfig:
|
|
71
|
+
"""Set server configuration with overrides."""
|
|
72
|
+
global _config
|
|
73
|
+
_config = ServerConfig(**kwargs)
|
|
74
|
+
return _config
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Session-based context provider system.
|
|
2
|
+
|
|
3
|
+
This module provides an extensible system for extracting and managing
|
|
4
|
+
contextual information during agent sessions.
|
|
5
|
+
|
|
6
|
+
Example usage:
|
|
7
|
+
from emdash_core.context import ContextService
|
|
8
|
+
|
|
9
|
+
# Create service
|
|
10
|
+
service = ContextService()
|
|
11
|
+
|
|
12
|
+
# Get terminal ID (creates one if not exists)
|
|
13
|
+
terminal_id = ContextService.get_terminal_id()
|
|
14
|
+
|
|
15
|
+
# Update context after changes
|
|
16
|
+
service.update_context(terminal_id)
|
|
17
|
+
|
|
18
|
+
# Get formatted context for LLM
|
|
19
|
+
context_prompt = service.get_context_prompt(terminal_id)
|
|
20
|
+
|
|
21
|
+
Adding new providers:
|
|
22
|
+
1. Create a new provider class inheriting from ContextProvider
|
|
23
|
+
2. Implement extract_context() method
|
|
24
|
+
3. Register with ContextProviderRegistry.register("name", MyProvider)
|
|
25
|
+
4. Add to CONTEXT_PROVIDERS env var
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from .models import ContextItem, ContextProviderSpec, SessionContext
|
|
29
|
+
from .reranker import get_rerank_scores, rerank_context_items
|
|
30
|
+
from .registry import ContextProviderRegistry, get_provider
|
|
31
|
+
from .service import ContextService
|
|
32
|
+
from .session import SessionContextManager
|
|
33
|
+
|
|
34
|
+
# Import providers to trigger registration
|
|
35
|
+
from .providers import explored_areas, touched_areas # noqa: F401
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
# Models
|
|
39
|
+
"ContextItem",
|
|
40
|
+
"ContextProviderSpec",
|
|
41
|
+
"SessionContext",
|
|
42
|
+
# Registry
|
|
43
|
+
"ContextProviderRegistry",
|
|
44
|
+
"get_provider",
|
|
45
|
+
# Reranker
|
|
46
|
+
"rerank_context_items",
|
|
47
|
+
"get_rerank_scores",
|
|
48
|
+
# Service
|
|
49
|
+
"ContextService",
|
|
50
|
+
# Session
|
|
51
|
+
"SessionContextManager",
|
|
52
|
+
]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Data models for the context provider system."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class ContextProviderSpec:
|
|
10
|
+
"""Specification for a context provider."""
|
|
11
|
+
|
|
12
|
+
name: str
|
|
13
|
+
description: str
|
|
14
|
+
requires_graph: bool = True
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ContextItem:
|
|
19
|
+
"""A single context item extracted from code."""
|
|
20
|
+
|
|
21
|
+
qualified_name: str
|
|
22
|
+
entity_type: str # 'Function', 'Class', 'File'
|
|
23
|
+
description: Optional[str] = None
|
|
24
|
+
file_path: Optional[str] = None
|
|
25
|
+
score: float = 1.0
|
|
26
|
+
touch_count: int = 1
|
|
27
|
+
last_touched: Optional[datetime] = None
|
|
28
|
+
neighbors: list[str] = field(default_factory=list)
|
|
29
|
+
|
|
30
|
+
def __post_init__(self):
|
|
31
|
+
if self.last_touched is None:
|
|
32
|
+
object.__setattr__(self, "last_touched", datetime.now())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class SessionContext:
|
|
37
|
+
"""Context for a terminal session."""
|
|
38
|
+
|
|
39
|
+
session_id: str
|
|
40
|
+
terminal_id: str
|
|
41
|
+
items: list[ContextItem] = field(default_factory=list)
|
|
42
|
+
created_at: Optional[datetime] = None
|
|
43
|
+
last_active: Optional[datetime] = None
|
|
44
|
+
|
|
45
|
+
def __post_init__(self):
|
|
46
|
+
now = datetime.now()
|
|
47
|
+
if self.created_at is None:
|
|
48
|
+
self.created_at = now
|
|
49
|
+
if self.last_active is None:
|
|
50
|
+
self.last_active = now
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Context providers package."""
|
|
2
|
+
|
|
3
|
+
from .base import ContextProvider
|
|
4
|
+
from .explored_areas import ExploredAreasProvider
|
|
5
|
+
from .touched_areas import TouchedAreasProvider
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"ContextProvider",
|
|
9
|
+
"ExploredAreasProvider",
|
|
10
|
+
"TouchedAreasProvider",
|
|
11
|
+
]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Abstract base class for context providers."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from ..models import ContextItem, ContextProviderSpec
|
|
7
|
+
from ...graph.connection import KuzuConnection
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ContextProvider(ABC):
|
|
11
|
+
"""Base class for context providers.
|
|
12
|
+
|
|
13
|
+
Context providers extract relevant context from various sources
|
|
14
|
+
(AST, git history, semantic search, etc.) to help the LLM
|
|
15
|
+
understand the codebase during a session.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, connection: KuzuConnection, config: Optional[dict] = None):
|
|
19
|
+
"""Initialize context provider.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
connection: Kuzu database connection
|
|
23
|
+
config: Optional provider-specific configuration
|
|
24
|
+
"""
|
|
25
|
+
self.connection = connection
|
|
26
|
+
self.config = config or {}
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def spec(self) -> ContextProviderSpec:
|
|
31
|
+
"""Get provider specification."""
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def name(self) -> str:
|
|
36
|
+
"""Get provider name."""
|
|
37
|
+
return self.spec.name
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def extract_context(self, modified_files: list[str]) -> list[ContextItem]:
|
|
41
|
+
"""Extract context items from modified files.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
modified_files: List of file paths that were modified
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
List of context items extracted from those files
|
|
48
|
+
"""
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
def format_for_prompt(self, items: list[ContextItem]) -> str:
|
|
52
|
+
"""Format context items for LLM system prompt.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
items: Context items to format
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Formatted string for inclusion in system prompt
|
|
59
|
+
"""
|
|
60
|
+
if not items:
|
|
61
|
+
return ""
|
|
62
|
+
|
|
63
|
+
lines = [f"## Context from {self.name}\n"]
|
|
64
|
+
for item in sorted(items, key=lambda x: -x.score):
|
|
65
|
+
lines.append(f"### {item.entity_type}: {item.qualified_name}")
|
|
66
|
+
if item.file_path:
|
|
67
|
+
lines.append(f"File: {item.file_path}")
|
|
68
|
+
if item.description:
|
|
69
|
+
lines.append(f"Description: {item.description}")
|
|
70
|
+
if item.neighbors:
|
|
71
|
+
lines.append(f"Related: {', '.join(item.neighbors[:5])}")
|
|
72
|
+
lines.append("")
|
|
73
|
+
|
|
74
|
+
return "\n".join(lines)
|