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.
- emdash_core/agent/agents.py +93 -23
- emdash_core/agent/background.py +481 -0
- emdash_core/agent/hooks.py +419 -0
- emdash_core/agent/inprocess_subagent.py +114 -10
- emdash_core/agent/mcp/config.py +78 -2
- emdash_core/agent/prompts/main_agent.py +88 -1
- emdash_core/agent/prompts/plan_mode.py +65 -44
- emdash_core/agent/prompts/subagents.py +96 -8
- emdash_core/agent/prompts/workflow.py +215 -50
- emdash_core/agent/providers/models.py +1 -1
- emdash_core/agent/providers/openai_provider.py +10 -0
- emdash_core/agent/research/researcher.py +154 -45
- emdash_core/agent/runner/agent_runner.py +157 -19
- emdash_core/agent/runner/context.py +28 -9
- emdash_core/agent/runner/sdk_runner.py +29 -2
- emdash_core/agent/skills.py +81 -1
- emdash_core/agent/toolkit.py +87 -11
- emdash_core/agent/toolkits/__init__.py +117 -18
- emdash_core/agent/toolkits/base.py +87 -2
- emdash_core/agent/toolkits/explore.py +18 -0
- emdash_core/agent/toolkits/plan.py +18 -0
- emdash_core/agent/tools/__init__.py +2 -0
- emdash_core/agent/tools/coding.py +344 -52
- emdash_core/agent/tools/lsp.py +361 -0
- emdash_core/agent/tools/skill.py +21 -1
- emdash_core/agent/tools/task.py +27 -23
- emdash_core/agent/tools/task_output.py +262 -32
- emdash_core/agent/verifier/__init__.py +11 -0
- emdash_core/agent/verifier/manager.py +295 -0
- emdash_core/agent/verifier/models.py +97 -0
- emdash_core/{swarm/worktree_manager.py → agent/worktree.py} +19 -1
- emdash_core/api/agent.py +451 -5
- emdash_core/api/research.py +3 -3
- emdash_core/api/router.py +0 -4
- emdash_core/context/longevity.py +197 -0
- emdash_core/context/providers/explored_areas.py +83 -39
- emdash_core/context/reranker.py +35 -144
- emdash_core/context/simple_reranker.py +500 -0
- emdash_core/context/tool_relevance.py +84 -0
- emdash_core/core/config.py +8 -0
- emdash_core/graph/__init__.py +8 -1
- emdash_core/graph/connection.py +24 -3
- emdash_core/graph/writer.py +7 -1
- emdash_core/ingestion/repository.py +17 -198
- emdash_core/models/agent.py +14 -0
- emdash_core/server.py +1 -6
- emdash_core/sse/stream.py +16 -1
- emdash_core/utils/__init__.py +0 -2
- emdash_core/utils/git.py +103 -0
- emdash_core/utils/image.py +147 -160
- {emdash_core-0.1.33.dist-info → emdash_core-0.1.60.dist-info}/METADATA +7 -5
- {emdash_core-0.1.33.dist-info → emdash_core-0.1.60.dist-info}/RECORD +54 -58
- emdash_core/api/swarm.py +0 -223
- emdash_core/db/__init__.py +0 -67
- emdash_core/db/auth.py +0 -134
- emdash_core/db/models.py +0 -91
- emdash_core/db/provider.py +0 -222
- emdash_core/db/providers/__init__.py +0 -5
- emdash_core/db/providers/supabase.py +0 -452
- emdash_core/swarm/__init__.py +0 -17
- emdash_core/swarm/merge_agent.py +0 -383
- emdash_core/swarm/session_manager.py +0 -274
- emdash_core/swarm/swarm_runner.py +0 -226
- emdash_core/swarm/task_definition.py +0 -137
- emdash_core/swarm/worker_spawner.py +0 -319
- {emdash_core-0.1.33.dist-info → emdash_core-0.1.60.dist-info}/WHEEL +0 -0
- {emdash_core-0.1.33.dist-info → emdash_core-0.1.60.dist-info}/entry_points.txt +0 -0
emdash_core/api/agent.py
CHANGED
|
@@ -38,6 +38,7 @@ def _run_sdk_agent(
|
|
|
38
38
|
session_id: str,
|
|
39
39
|
emitter,
|
|
40
40
|
plan_mode: bool = False,
|
|
41
|
+
images: list = None,
|
|
41
42
|
):
|
|
42
43
|
"""Run the agent using Anthropic Agent SDK.
|
|
43
44
|
|
|
@@ -72,7 +73,7 @@ def _run_sdk_agent(
|
|
|
72
73
|
# Run async agent in sync context
|
|
73
74
|
async def run_async():
|
|
74
75
|
response_text = ""
|
|
75
|
-
async for event in runner.run(message):
|
|
76
|
+
async for event in runner.run(message, images=images):
|
|
76
77
|
if event.get("type") == "text":
|
|
77
78
|
response_text += event.get("content", "")
|
|
78
79
|
return response_text
|
|
@@ -95,6 +96,8 @@ def _run_agent_sync(
|
|
|
95
96
|
images: list = None,
|
|
96
97
|
plan_mode: bool = False,
|
|
97
98
|
use_sdk: bool = None,
|
|
99
|
+
history: list = None,
|
|
100
|
+
use_worktree: bool = False,
|
|
98
101
|
):
|
|
99
102
|
"""Run the agent synchronously (in thread pool).
|
|
100
103
|
|
|
@@ -103,6 +106,10 @@ def _run_agent_sync(
|
|
|
103
106
|
|
|
104
107
|
For Claude models with use_sdk=True, uses the Anthropic Agent SDK.
|
|
105
108
|
For other models, uses the standard AgentRunner with OpenAI-compatible API.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
history: Optional list of previous messages to pre-populate conversation
|
|
112
|
+
use_worktree: If True, creates a git worktree for isolated changes
|
|
106
113
|
"""
|
|
107
114
|
try:
|
|
108
115
|
_ensure_emdash_importable()
|
|
@@ -140,8 +147,22 @@ def _run_agent_sync(
|
|
|
140
147
|
emitter = AgentEventEmitter(agent_name="Emdash Code")
|
|
141
148
|
emitter.add_handler(SSEBridgeHandler(sse_handler))
|
|
142
149
|
|
|
150
|
+
# Add hook handler for user-defined hooks
|
|
151
|
+
from ..agent.hooks import HookHandler, get_hook_manager
|
|
152
|
+
hook_manager = get_hook_manager()
|
|
153
|
+
hook_manager.set_session_id(session_id)
|
|
154
|
+
emitter.add_handler(HookHandler(hook_manager))
|
|
155
|
+
|
|
143
156
|
# Use SDK for Claude models if enabled
|
|
144
157
|
if use_sdk and is_claude_model(model):
|
|
158
|
+
# Convert images for SDK if provided
|
|
159
|
+
sdk_images = None
|
|
160
|
+
if images:
|
|
161
|
+
import base64
|
|
162
|
+
sdk_images = [
|
|
163
|
+
{"data": base64.b64decode(img.data), "format": img.format}
|
|
164
|
+
for img in images
|
|
165
|
+
]
|
|
145
166
|
return _run_sdk_agent(
|
|
146
167
|
message=message,
|
|
147
168
|
model=model,
|
|
@@ -149,20 +170,42 @@ def _run_agent_sync(
|
|
|
149
170
|
session_id=session_id,
|
|
150
171
|
emitter=emitter,
|
|
151
172
|
plan_mode=plan_mode,
|
|
173
|
+
images=sdk_images,
|
|
152
174
|
)
|
|
153
175
|
|
|
154
176
|
# Standard path: use AgentRunner with OpenAI-compatible API
|
|
177
|
+
# Get repo_root from config (set by server on startup)
|
|
178
|
+
from pathlib import Path
|
|
179
|
+
from ..config import get_config
|
|
180
|
+
from ..utils.logger import log
|
|
181
|
+
config = get_config()
|
|
182
|
+
repo_root = Path(config.repo_root) if config.repo_root else Path.cwd()
|
|
183
|
+
log.info(f"Agent API: config.repo_root={config.repo_root}, resolved repo_root={repo_root}")
|
|
184
|
+
|
|
185
|
+
# Create worktree for isolated changes if requested
|
|
186
|
+
worktree_info = None
|
|
187
|
+
if use_worktree and not plan_mode:
|
|
188
|
+
from ..agent.worktree import WorktreeManager
|
|
189
|
+
try:
|
|
190
|
+
worktree_manager = WorktreeManager(repo_root)
|
|
191
|
+
# Use session_id as task slug (truncated for safety)
|
|
192
|
+
task_slug = session_id[:20] if len(session_id) > 20 else session_id
|
|
193
|
+
worktree_info = worktree_manager.create_worktree(task_slug, force=True)
|
|
194
|
+
repo_root = worktree_info.path
|
|
195
|
+
log.info(f"Created worktree at {repo_root} on branch {worktree_info.branch}")
|
|
196
|
+
except Exception as e:
|
|
197
|
+
log.warning(f"Failed to create worktree: {e}. Running in main repo.")
|
|
198
|
+
worktree_info = None
|
|
199
|
+
|
|
155
200
|
# Create toolkit with plan_mode if requested
|
|
156
201
|
# When in plan mode, generate a plan file path so write_to_file is available
|
|
157
202
|
plan_file_path = None
|
|
158
203
|
if plan_mode:
|
|
159
|
-
from pathlib import Path
|
|
160
|
-
repo_root = Path.cwd()
|
|
161
204
|
plan_file_path = str(repo_root / ".emdash" / "plan.md")
|
|
162
205
|
# Ensure .emdash directory exists
|
|
163
206
|
(repo_root / ".emdash").mkdir(exist_ok=True)
|
|
164
207
|
|
|
165
|
-
toolkit = AgentToolkit(plan_mode=plan_mode, plan_file_path=plan_file_path)
|
|
208
|
+
toolkit = AgentToolkit(repo_root=repo_root, plan_mode=plan_mode, plan_file_path=plan_file_path)
|
|
166
209
|
|
|
167
210
|
runner = AgentRunner(
|
|
168
211
|
toolkit=toolkit,
|
|
@@ -172,20 +215,59 @@ def _run_agent_sync(
|
|
|
172
215
|
emitter=emitter,
|
|
173
216
|
)
|
|
174
217
|
|
|
218
|
+
# Inject pre-loaded conversation history if provided
|
|
219
|
+
if history:
|
|
220
|
+
runner._messages = list(history)
|
|
221
|
+
log.info(f"Injected {len(history)} messages from saved session")
|
|
222
|
+
|
|
175
223
|
# Store session state BEFORE running (so it exists even if interrupted)
|
|
176
224
|
_sessions[session_id] = {
|
|
177
225
|
"runner": runner,
|
|
178
226
|
"message_count": 1,
|
|
179
227
|
"model": model,
|
|
180
228
|
"plan_mode": plan_mode,
|
|
229
|
+
"worktree_info": worktree_info, # Will be None if not using worktree
|
|
181
230
|
}
|
|
182
231
|
|
|
232
|
+
# Set up autosave callback if enabled via env var
|
|
233
|
+
import os
|
|
234
|
+
import json
|
|
235
|
+
if os.environ.get("EMDASH_SESSION_AUTOSAVE", "").lower() == "true":
|
|
236
|
+
sessions_dir = repo_root / ".emdash" / "sessions"
|
|
237
|
+
sessions_dir.mkdir(parents=True, exist_ok=True)
|
|
238
|
+
autosave_path = sessions_dir / "_autosave.json"
|
|
239
|
+
|
|
240
|
+
def autosave_callback(messages):
|
|
241
|
+
"""Save messages to autosave file after each iteration."""
|
|
242
|
+
try:
|
|
243
|
+
# Limit to last 10 messages
|
|
244
|
+
trimmed = messages[-10:] if len(messages) > 10 else messages
|
|
245
|
+
autosave_data = {
|
|
246
|
+
"name": "_autosave",
|
|
247
|
+
"messages": trimmed,
|
|
248
|
+
"model": model,
|
|
249
|
+
"mode": "plan" if plan_mode else "code",
|
|
250
|
+
"session_id": session_id,
|
|
251
|
+
}
|
|
252
|
+
with open(autosave_path, "w") as f:
|
|
253
|
+
json.dump(autosave_data, f, indent=2, default=str)
|
|
254
|
+
log.debug(f"Autosaved {len(trimmed)} messages to {autosave_path}")
|
|
255
|
+
except Exception as e:
|
|
256
|
+
log.debug(f"Autosave failed: {e}")
|
|
257
|
+
|
|
258
|
+
runner._on_iteration_callback = autosave_callback
|
|
259
|
+
log.info("Session autosave enabled")
|
|
260
|
+
|
|
183
261
|
# Convert image data if provided
|
|
184
262
|
agent_images = None
|
|
185
263
|
if images:
|
|
264
|
+
import base64
|
|
186
265
|
from ..agent.providers.base import ImageContent
|
|
187
266
|
agent_images = [
|
|
188
|
-
ImageContent(
|
|
267
|
+
ImageContent(
|
|
268
|
+
image_data=base64.b64decode(img.data),
|
|
269
|
+
format=img.format
|
|
270
|
+
)
|
|
189
271
|
for img in images
|
|
190
272
|
]
|
|
191
273
|
|
|
@@ -215,6 +297,7 @@ async def _run_agent_async(
|
|
|
215
297
|
model = request.model or config.default_model
|
|
216
298
|
max_iterations = request.options.max_iterations
|
|
217
299
|
plan_mode = request.options.mode == AgentMode.PLAN
|
|
300
|
+
use_worktree = request.options.use_worktree
|
|
218
301
|
|
|
219
302
|
# Emit session start
|
|
220
303
|
sse_handler.emit(EventType.SESSION_START, {
|
|
@@ -223,6 +306,7 @@ async def _run_agent_async(
|
|
|
223
306
|
"session_id": session_id,
|
|
224
307
|
"query": request.message,
|
|
225
308
|
"mode": request.options.mode.value,
|
|
309
|
+
"use_worktree": use_worktree,
|
|
226
310
|
})
|
|
227
311
|
|
|
228
312
|
loop = asyncio.get_event_loop()
|
|
@@ -239,6 +323,9 @@ async def _run_agent_async(
|
|
|
239
323
|
session_id,
|
|
240
324
|
request.images,
|
|
241
325
|
plan_mode,
|
|
326
|
+
None, # use_sdk (auto-detect)
|
|
327
|
+
request.history, # Pre-loaded conversation history
|
|
328
|
+
use_worktree,
|
|
242
329
|
)
|
|
243
330
|
|
|
244
331
|
# Emit session end
|
|
@@ -411,6 +498,129 @@ async def delete_session(session_id: str):
|
|
|
411
498
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
412
499
|
|
|
413
500
|
|
|
501
|
+
@router.get("/chat/{session_id}/export")
|
|
502
|
+
async def export_session(session_id: str, limit: int = 10):
|
|
503
|
+
"""Export session messages for persistence.
|
|
504
|
+
|
|
505
|
+
Args:
|
|
506
|
+
session_id: The session ID
|
|
507
|
+
limit: Maximum number of messages to return (default 10)
|
|
508
|
+
|
|
509
|
+
Returns:
|
|
510
|
+
JSON with messages array and metadata
|
|
511
|
+
"""
|
|
512
|
+
if session_id not in _sessions:
|
|
513
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
514
|
+
|
|
515
|
+
session = _sessions[session_id]
|
|
516
|
+
runner = session.get("runner")
|
|
517
|
+
|
|
518
|
+
if not runner:
|
|
519
|
+
return {
|
|
520
|
+
"session_id": session_id,
|
|
521
|
+
"messages": [],
|
|
522
|
+
"message_count": 0,
|
|
523
|
+
"model": session.get("model"),
|
|
524
|
+
"mode": "plan" if session.get("plan_mode") else "code",
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
# Get messages from runner
|
|
528
|
+
messages = getattr(runner, "_messages", [])
|
|
529
|
+
|
|
530
|
+
# Trim to limit (most recent)
|
|
531
|
+
if len(messages) > limit:
|
|
532
|
+
messages = messages[-limit:]
|
|
533
|
+
|
|
534
|
+
return {
|
|
535
|
+
"session_id": session_id,
|
|
536
|
+
"messages": messages,
|
|
537
|
+
"message_count": len(messages),
|
|
538
|
+
"model": session.get("model"),
|
|
539
|
+
"mode": "plan" if session.get("plan_mode") else "code",
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
@router.post("/chat/{session_id}/compact")
|
|
544
|
+
async def compact_session(session_id: str):
|
|
545
|
+
"""Compact the session's message history using LLM summarization.
|
|
546
|
+
|
|
547
|
+
This manually triggers the same compaction that happens automatically
|
|
548
|
+
when context reaches 80% capacity.
|
|
549
|
+
|
|
550
|
+
Returns:
|
|
551
|
+
JSON with the summary text and stats
|
|
552
|
+
"""
|
|
553
|
+
if session_id not in _sessions:
|
|
554
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
555
|
+
|
|
556
|
+
session = _sessions[session_id]
|
|
557
|
+
runner = session.get("runner")
|
|
558
|
+
|
|
559
|
+
if not runner:
|
|
560
|
+
raise HTTPException(status_code=400, detail="Session has no active runner")
|
|
561
|
+
|
|
562
|
+
# Get current messages
|
|
563
|
+
messages = getattr(runner, "_messages", [])
|
|
564
|
+
if len(messages) <= 5:
|
|
565
|
+
return {
|
|
566
|
+
"compacted": False,
|
|
567
|
+
"reason": "Not enough messages to compact (need more than 5)",
|
|
568
|
+
"message_count": len(messages),
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
# Import compaction utilities
|
|
572
|
+
from ..agent.runner.context import compact_messages_with_llm, estimate_context_tokens
|
|
573
|
+
from ..agent.events import AgentEventEmitter
|
|
574
|
+
|
|
575
|
+
# Create a simple emitter that captures the summary
|
|
576
|
+
class SummaryCapture:
|
|
577
|
+
def __init__(self):
|
|
578
|
+
self.summary = None
|
|
579
|
+
|
|
580
|
+
def emit_thinking(self, text):
|
|
581
|
+
pass # Ignore thinking events
|
|
582
|
+
|
|
583
|
+
emitter = SummaryCapture()
|
|
584
|
+
|
|
585
|
+
# Estimate current tokens
|
|
586
|
+
original_tokens = estimate_context_tokens(messages)
|
|
587
|
+
|
|
588
|
+
# Compact messages
|
|
589
|
+
compacted_messages = compact_messages_with_llm(
|
|
590
|
+
messages,
|
|
591
|
+
emitter,
|
|
592
|
+
target_tokens=int(original_tokens * 0.5),
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
# Extract the summary from the compacted messages
|
|
596
|
+
summary_text = None
|
|
597
|
+
for msg in compacted_messages:
|
|
598
|
+
if msg.get("role") == "assistant" and "[Context Summary]" in str(msg.get("content", "")):
|
|
599
|
+
content = msg.get("content", "")
|
|
600
|
+
# Extract text between [Context Summary] and [End Summary]
|
|
601
|
+
start = content.find("[Context Summary]") + len("[Context Summary]")
|
|
602
|
+
end = content.find("[End Summary]")
|
|
603
|
+
if end > start:
|
|
604
|
+
summary_text = content[start:end].strip()
|
|
605
|
+
break
|
|
606
|
+
|
|
607
|
+
# Update runner's messages
|
|
608
|
+
runner._messages = compacted_messages
|
|
609
|
+
|
|
610
|
+
# Estimate new tokens
|
|
611
|
+
new_tokens = estimate_context_tokens(compacted_messages)
|
|
612
|
+
|
|
613
|
+
return {
|
|
614
|
+
"compacted": True,
|
|
615
|
+
"summary": summary_text,
|
|
616
|
+
"original_message_count": len(messages),
|
|
617
|
+
"new_message_count": len(compacted_messages),
|
|
618
|
+
"original_tokens": original_tokens,
|
|
619
|
+
"new_tokens": new_tokens,
|
|
620
|
+
"reduction_percent": round((1 - new_tokens / original_tokens) * 100, 1) if original_tokens > 0 else 0,
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
|
|
414
624
|
@router.get("/chat/{session_id}/plan")
|
|
415
625
|
async def get_pending_plan(session_id: str):
|
|
416
626
|
"""Get the pending plan for a session, if any.
|
|
@@ -860,3 +1070,239 @@ async def reject_plan_mode(session_id: str, feedback: str = ""):
|
|
|
860
1070
|
"X-Session-ID": session_id,
|
|
861
1071
|
},
|
|
862
1072
|
)
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
@router.get("/chat/{session_id}/todos")
|
|
1076
|
+
async def get_todos(session_id: str):
|
|
1077
|
+
"""Get the current todo list for a session.
|
|
1078
|
+
|
|
1079
|
+
Returns the agent's task list including status of each item.
|
|
1080
|
+
"""
|
|
1081
|
+
if session_id not in _sessions:
|
|
1082
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
1083
|
+
|
|
1084
|
+
# Get todos from TaskState singleton
|
|
1085
|
+
from ..agent.tools.tasks import TaskState
|
|
1086
|
+
state = TaskState.get_instance()
|
|
1087
|
+
|
|
1088
|
+
todos = state.get_all_tasks()
|
|
1089
|
+
|
|
1090
|
+
# Count by status
|
|
1091
|
+
pending = sum(1 for t in todos if t["status"] == "pending")
|
|
1092
|
+
in_progress = sum(1 for t in todos if t["status"] == "in_progress")
|
|
1093
|
+
completed = sum(1 for t in todos if t["status"] == "completed")
|
|
1094
|
+
|
|
1095
|
+
return {
|
|
1096
|
+
"session_id": session_id,
|
|
1097
|
+
"todos": todos,
|
|
1098
|
+
"summary": {
|
|
1099
|
+
"total": len(todos),
|
|
1100
|
+
"pending": pending,
|
|
1101
|
+
"in_progress": in_progress,
|
|
1102
|
+
"completed": completed,
|
|
1103
|
+
},
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
@router.post("/chat/{session_id}/todos")
|
|
1108
|
+
async def add_todo(session_id: str, title: str, description: str = ""):
|
|
1109
|
+
"""Add a new todo item to the agent's task list.
|
|
1110
|
+
|
|
1111
|
+
This allows users to inject tasks for the agent to work on.
|
|
1112
|
+
"""
|
|
1113
|
+
if session_id not in _sessions:
|
|
1114
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
1115
|
+
|
|
1116
|
+
if not title or not title.strip():
|
|
1117
|
+
raise HTTPException(status_code=400, detail="Title is required")
|
|
1118
|
+
|
|
1119
|
+
# Add todo via TaskState singleton
|
|
1120
|
+
from ..agent.tools.tasks import TaskState
|
|
1121
|
+
state = TaskState.get_instance()
|
|
1122
|
+
|
|
1123
|
+
task = state.add_task(title=title.strip(), description=description.strip())
|
|
1124
|
+
|
|
1125
|
+
return {
|
|
1126
|
+
"session_id": session_id,
|
|
1127
|
+
"task": task.to_dict(),
|
|
1128
|
+
"total_tasks": len(state.tasks),
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
# ==================== Worktree Management ====================
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
@router.get("/chat/{session_id}/worktree")
|
|
1136
|
+
async def get_worktree_status(session_id: str):
|
|
1137
|
+
"""Get the worktree status for a session.
|
|
1138
|
+
|
|
1139
|
+
Returns information about whether the session is using a worktree,
|
|
1140
|
+
the branch name, and any uncommitted changes.
|
|
1141
|
+
"""
|
|
1142
|
+
if session_id not in _sessions:
|
|
1143
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
1144
|
+
|
|
1145
|
+
session = _sessions[session_id]
|
|
1146
|
+
worktree_info = session.get("worktree_info")
|
|
1147
|
+
|
|
1148
|
+
if not worktree_info:
|
|
1149
|
+
return {
|
|
1150
|
+
"session_id": session_id,
|
|
1151
|
+
"has_worktree": False,
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
# Check for uncommitted changes in the worktree
|
|
1155
|
+
import subprocess
|
|
1156
|
+
try:
|
|
1157
|
+
result = subprocess.run(
|
|
1158
|
+
["git", "status", "--porcelain"],
|
|
1159
|
+
cwd=str(worktree_info.path),
|
|
1160
|
+
capture_output=True,
|
|
1161
|
+
text=True,
|
|
1162
|
+
)
|
|
1163
|
+
has_changes = bool(result.stdout.strip())
|
|
1164
|
+
changes = result.stdout.strip().split("\n") if has_changes else []
|
|
1165
|
+
except Exception:
|
|
1166
|
+
has_changes = False
|
|
1167
|
+
changes = []
|
|
1168
|
+
|
|
1169
|
+
return {
|
|
1170
|
+
"session_id": session_id,
|
|
1171
|
+
"has_worktree": True,
|
|
1172
|
+
"worktree_path": str(worktree_info.path),
|
|
1173
|
+
"branch": worktree_info.branch,
|
|
1174
|
+
"base_branch": worktree_info.base_branch,
|
|
1175
|
+
"has_changes": has_changes,
|
|
1176
|
+
"changes": changes,
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
@router.post("/chat/{session_id}/worktree/apply")
|
|
1181
|
+
async def apply_worktree_changes(session_id: str, commit_message: str = None):
|
|
1182
|
+
"""Apply worktree changes to the main branch.
|
|
1183
|
+
|
|
1184
|
+
This merges the worktree branch into the base branch and cleans up.
|
|
1185
|
+
"""
|
|
1186
|
+
from ..utils.logger import log
|
|
1187
|
+
|
|
1188
|
+
if session_id not in _sessions:
|
|
1189
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
1190
|
+
|
|
1191
|
+
session = _sessions[session_id]
|
|
1192
|
+
worktree_info = session.get("worktree_info")
|
|
1193
|
+
|
|
1194
|
+
if not worktree_info:
|
|
1195
|
+
raise HTTPException(status_code=400, detail="Session is not using a worktree")
|
|
1196
|
+
|
|
1197
|
+
import subprocess
|
|
1198
|
+
from pathlib import Path
|
|
1199
|
+
|
|
1200
|
+
try:
|
|
1201
|
+
worktree_path = worktree_info.path
|
|
1202
|
+
branch = worktree_info.branch
|
|
1203
|
+
base_branch = worktree_info.base_branch
|
|
1204
|
+
|
|
1205
|
+
# First, commit any uncommitted changes in the worktree
|
|
1206
|
+
result = subprocess.run(
|
|
1207
|
+
["git", "status", "--porcelain"],
|
|
1208
|
+
cwd=str(worktree_path),
|
|
1209
|
+
capture_output=True,
|
|
1210
|
+
text=True,
|
|
1211
|
+
)
|
|
1212
|
+
if result.stdout.strip():
|
|
1213
|
+
# Stage all changes
|
|
1214
|
+
subprocess.run(["git", "add", "-A"], cwd=str(worktree_path), check=True)
|
|
1215
|
+
# Commit
|
|
1216
|
+
msg = commit_message or f"Agent session {session_id[:8]} changes"
|
|
1217
|
+
subprocess.run(
|
|
1218
|
+
["git", "commit", "-m", msg],
|
|
1219
|
+
cwd=str(worktree_path),
|
|
1220
|
+
check=True,
|
|
1221
|
+
)
|
|
1222
|
+
log.info(f"Committed changes in worktree: {msg}")
|
|
1223
|
+
|
|
1224
|
+
# Get the main repo root (parent of .emdash-worktrees)
|
|
1225
|
+
from ..config import get_config
|
|
1226
|
+
config = get_config()
|
|
1227
|
+
main_repo = Path(config.repo_root) if config.repo_root else Path.cwd()
|
|
1228
|
+
|
|
1229
|
+
# Merge the worktree branch into base branch
|
|
1230
|
+
subprocess.run(
|
|
1231
|
+
["git", "checkout", base_branch],
|
|
1232
|
+
cwd=str(main_repo),
|
|
1233
|
+
check=True,
|
|
1234
|
+
)
|
|
1235
|
+
subprocess.run(
|
|
1236
|
+
["git", "merge", branch, "--no-ff", "-m", f"Merge {branch}"],
|
|
1237
|
+
cwd=str(main_repo),
|
|
1238
|
+
check=True,
|
|
1239
|
+
)
|
|
1240
|
+
log.info(f"Merged {branch} into {base_branch}")
|
|
1241
|
+
|
|
1242
|
+
# Clean up the worktree
|
|
1243
|
+
from ..agent.worktree import WorktreeManager
|
|
1244
|
+
worktree_manager = WorktreeManager(main_repo)
|
|
1245
|
+
worktree_manager.remove_worktree(worktree_info.task_slug)
|
|
1246
|
+
log.info(f"Removed worktree {worktree_info.task_slug}")
|
|
1247
|
+
|
|
1248
|
+
# Clear worktree info from session
|
|
1249
|
+
session["worktree_info"] = None
|
|
1250
|
+
|
|
1251
|
+
return {
|
|
1252
|
+
"session_id": session_id,
|
|
1253
|
+
"success": True,
|
|
1254
|
+
"message": f"Changes from {branch} merged into {base_branch}",
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
except subprocess.CalledProcessError as e:
|
|
1258
|
+
log.error(f"Failed to apply worktree changes: {e}")
|
|
1259
|
+
raise HTTPException(
|
|
1260
|
+
status_code=500,
|
|
1261
|
+
detail=f"Failed to apply changes: {e.stderr if hasattr(e, 'stderr') else str(e)}"
|
|
1262
|
+
)
|
|
1263
|
+
except Exception as e:
|
|
1264
|
+
log.error(f"Error applying worktree changes: {e}")
|
|
1265
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
1266
|
+
|
|
1267
|
+
|
|
1268
|
+
@router.delete("/chat/{session_id}/worktree")
|
|
1269
|
+
async def discard_worktree(session_id: str):
|
|
1270
|
+
"""Discard worktree changes and clean up.
|
|
1271
|
+
|
|
1272
|
+
This removes the worktree and branch without merging.
|
|
1273
|
+
"""
|
|
1274
|
+
from ..utils.logger import log
|
|
1275
|
+
|
|
1276
|
+
if session_id not in _sessions:
|
|
1277
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
1278
|
+
|
|
1279
|
+
session = _sessions[session_id]
|
|
1280
|
+
worktree_info = session.get("worktree_info")
|
|
1281
|
+
|
|
1282
|
+
if not worktree_info:
|
|
1283
|
+
raise HTTPException(status_code=400, detail="Session is not using a worktree")
|
|
1284
|
+
|
|
1285
|
+
try:
|
|
1286
|
+
from pathlib import Path
|
|
1287
|
+
from ..config import get_config
|
|
1288
|
+
from ..agent.worktree import WorktreeManager
|
|
1289
|
+
|
|
1290
|
+
config = get_config()
|
|
1291
|
+
main_repo = Path(config.repo_root) if config.repo_root else Path.cwd()
|
|
1292
|
+
|
|
1293
|
+
worktree_manager = WorktreeManager(main_repo)
|
|
1294
|
+
worktree_manager.remove_worktree(worktree_info.task_slug)
|
|
1295
|
+
log.info(f"Discarded worktree {worktree_info.task_slug}")
|
|
1296
|
+
|
|
1297
|
+
# Clear worktree info from session
|
|
1298
|
+
session["worktree_info"] = None
|
|
1299
|
+
|
|
1300
|
+
return {
|
|
1301
|
+
"session_id": session_id,
|
|
1302
|
+
"success": True,
|
|
1303
|
+
"message": f"Worktree {worktree_info.task_slug} discarded",
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
except Exception as e:
|
|
1307
|
+
log.error(f"Error discarding worktree: {e}")
|
|
1308
|
+
raise HTTPException(status_code=500, detail=str(e))
|
emdash_core/api/research.py
CHANGED
|
@@ -43,9 +43,9 @@ def _run_research_sync(
|
|
|
43
43
|
import sys
|
|
44
44
|
from pathlib import Path
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
from ..config import get_config
|
|
47
|
+
config = get_config()
|
|
48
|
+
repo_root = Path(config.repo_root) if config.repo_root else Path.cwd()
|
|
49
49
|
|
|
50
50
|
try:
|
|
51
51
|
from ..agent.research.agent import ResearchAgent
|
emdash_core/api/router.py
CHANGED
|
@@ -19,7 +19,6 @@ from . import (
|
|
|
19
19
|
research,
|
|
20
20
|
review,
|
|
21
21
|
embed,
|
|
22
|
-
swarm,
|
|
23
22
|
rules,
|
|
24
23
|
context,
|
|
25
24
|
feature,
|
|
@@ -67,9 +66,6 @@ api_router.include_router(review.router)
|
|
|
67
66
|
# Embeddings
|
|
68
67
|
api_router.include_router(embed.router)
|
|
69
68
|
|
|
70
|
-
# Multi-agent
|
|
71
|
-
api_router.include_router(swarm.router)
|
|
72
|
-
|
|
73
69
|
# Configuration
|
|
74
70
|
api_router.include_router(rules.router)
|
|
75
71
|
api_router.include_router(context.router)
|