emdash-core 0.1.37__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 (60) hide show
  1. emdash_core/agent/agents.py +9 -0
  2. emdash_core/agent/background.py +481 -0
  3. emdash_core/agent/inprocess_subagent.py +70 -1
  4. emdash_core/agent/mcp/config.py +78 -2
  5. emdash_core/agent/prompts/main_agent.py +53 -1
  6. emdash_core/agent/prompts/plan_mode.py +65 -44
  7. emdash_core/agent/prompts/subagents.py +73 -1
  8. emdash_core/agent/prompts/workflow.py +179 -28
  9. emdash_core/agent/providers/models.py +1 -1
  10. emdash_core/agent/providers/openai_provider.py +10 -0
  11. emdash_core/agent/research/researcher.py +154 -45
  12. emdash_core/agent/runner/agent_runner.py +145 -19
  13. emdash_core/agent/runner/sdk_runner.py +29 -2
  14. emdash_core/agent/skills.py +81 -1
  15. emdash_core/agent/toolkit.py +87 -11
  16. emdash_core/agent/tools/__init__.py +2 -0
  17. emdash_core/agent/tools/coding.py +344 -52
  18. emdash_core/agent/tools/lsp.py +361 -0
  19. emdash_core/agent/tools/skill.py +21 -1
  20. emdash_core/agent/tools/task.py +16 -19
  21. emdash_core/agent/tools/task_output.py +262 -32
  22. emdash_core/agent/verifier/__init__.py +11 -0
  23. emdash_core/agent/verifier/manager.py +295 -0
  24. emdash_core/agent/verifier/models.py +97 -0
  25. emdash_core/{swarm/worktree_manager.py → agent/worktree.py} +19 -1
  26. emdash_core/api/agent.py +297 -2
  27. emdash_core/api/research.py +3 -3
  28. emdash_core/api/router.py +0 -4
  29. emdash_core/context/longevity.py +197 -0
  30. emdash_core/context/providers/explored_areas.py +83 -39
  31. emdash_core/context/reranker.py +35 -144
  32. emdash_core/context/simple_reranker.py +500 -0
  33. emdash_core/context/tool_relevance.py +84 -0
  34. emdash_core/core/config.py +8 -0
  35. emdash_core/graph/__init__.py +8 -1
  36. emdash_core/graph/connection.py +24 -3
  37. emdash_core/graph/writer.py +7 -1
  38. emdash_core/models/agent.py +10 -0
  39. emdash_core/server.py +1 -6
  40. emdash_core/sse/stream.py +16 -1
  41. emdash_core/utils/__init__.py +0 -2
  42. emdash_core/utils/git.py +103 -0
  43. emdash_core/utils/image.py +147 -160
  44. {emdash_core-0.1.37.dist-info → emdash_core-0.1.60.dist-info}/METADATA +6 -6
  45. {emdash_core-0.1.37.dist-info → emdash_core-0.1.60.dist-info}/RECORD +47 -52
  46. emdash_core/api/swarm.py +0 -223
  47. emdash_core/db/__init__.py +0 -67
  48. emdash_core/db/auth.py +0 -134
  49. emdash_core/db/models.py +0 -91
  50. emdash_core/db/provider.py +0 -222
  51. emdash_core/db/providers/__init__.py +0 -5
  52. emdash_core/db/providers/supabase.py +0 -452
  53. emdash_core/swarm/__init__.py +0 -17
  54. emdash_core/swarm/merge_agent.py +0 -383
  55. emdash_core/swarm/session_manager.py +0 -274
  56. emdash_core/swarm/swarm_runner.py +0 -226
  57. emdash_core/swarm/task_definition.py +0 -137
  58. emdash_core/swarm/worker_spawner.py +0 -319
  59. {emdash_core-0.1.37.dist-info → emdash_core-0.1.60.dist-info}/WHEEL +0 -0
  60. {emdash_core-0.1.37.dist-info → emdash_core-0.1.60.dist-info}/entry_points.txt +0 -0
@@ -231,94 +231,333 @@ Creates the file if it doesn't exist, or overwrites if it does."""
231
231
  )
232
232
 
233
233
 
234
+ class EditFileTool(CodingTool):
235
+ """Performs exact string replacements in files."""
236
+
237
+ name = "edit_file"
238
+ description = """Performs exact string replacements in files.
239
+
240
+ Usage:
241
+ - You must use read_file at least once before editing. This tool will error if you attempt an edit without reading the file first.
242
+ - When editing text from read_file output, preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix.
243
+ - ALWAYS prefer editing existing files. NEVER write new files unless explicitly required.
244
+ - The edit will FAIL if old_string is not unique in the file. Either provide more surrounding context to make it unique or use replace_all.
245
+ - Use replace_all for replacing/renaming strings across the file (e.g., renaming a variable)."""
246
+
247
+ def execute(
248
+ self,
249
+ file_path: str,
250
+ old_string: str,
251
+ new_string: str,
252
+ replace_all: bool = False,
253
+ ) -> ToolResult:
254
+ """Edit a file using search and replace.
255
+
256
+ Args:
257
+ file_path: Absolute path to the file to modify
258
+ old_string: The text to replace
259
+ new_string: The text to replace it with (must be different from old_string)
260
+ replace_all: Replace all occurrences of old_string (default: false)
261
+
262
+ Returns:
263
+ ToolResult indicating success
264
+ """
265
+ valid, error, full_path = self._validate_path(file_path)
266
+ if not valid:
267
+ return ToolResult.error_result(error)
268
+
269
+ if not full_path.exists():
270
+ return ToolResult.error_result(f"File not found: {file_path}")
271
+
272
+ # Validate old_string != new_string
273
+ if old_string == new_string:
274
+ return ToolResult.error_result("old_string and new_string must be different")
275
+
276
+ try:
277
+ content = full_path.read_text()
278
+
279
+ # Check if old_string exists in file
280
+ if old_string not in content:
281
+ # Try to find similar content for better error message
282
+ lines = content.split('\n')
283
+ search_lines = old_string.split('\n')
284
+ first_search = search_lines[0].strip() if search_lines else ""
285
+
286
+ # Find lines that might be close matches
287
+ close_matches = []
288
+ for i, line in enumerate(lines):
289
+ if first_search and first_search in line:
290
+ close_matches.append(f" Line {i+1}: {line.strip()[:80]}")
291
+
292
+ error_msg = f"old_string not found in file"
293
+ if close_matches:
294
+ error_msg += f"\n\nSimilar lines found:\n" + "\n".join(close_matches[:5])
295
+ error_msg += "\n\nMake sure whitespace/indentation matches exactly."
296
+
297
+ return ToolResult.error_result(error_msg)
298
+
299
+ # Check for uniqueness if not replace_all
300
+ if not replace_all:
301
+ count = content.count(old_string)
302
+ if count > 1:
303
+ return ToolResult.error_result(
304
+ f"old_string found {count} times. Use replace_all=true or provide more context to make it unique."
305
+ )
306
+
307
+ # Perform replacement
308
+ if replace_all:
309
+ new_content = content.replace(old_string, new_string)
310
+ replacements = content.count(old_string)
311
+ else:
312
+ new_content = content.replace(old_string, new_string, 1)
313
+ replacements = 1
314
+
315
+ # Write back
316
+ full_path.write_text(new_content)
317
+
318
+ return ToolResult.success_result(
319
+ data={
320
+ "file_path": file_path,
321
+ "replacements": replacements,
322
+ },
323
+ )
324
+
325
+ except Exception as e:
326
+ return ToolResult.error_result(f"Failed to edit file: {e}")
327
+
328
+ def get_schema(self) -> dict:
329
+ """Get OpenAI function schema."""
330
+ return self._make_schema(
331
+ properties={
332
+ "file_path": {
333
+ "type": "string",
334
+ "description": "The absolute path to the file to modify",
335
+ },
336
+ "old_string": {
337
+ "type": "string",
338
+ "description": "The text to replace",
339
+ },
340
+ "new_string": {
341
+ "type": "string",
342
+ "description": "The text to replace it with (must be different from old_string)",
343
+ },
344
+ "replace_all": {
345
+ "type": "boolean",
346
+ "description": "Replace all occurrences of old_string (default: false)",
347
+ "default": False,
348
+ },
349
+ },
350
+ required=["file_path", "old_string", "new_string"],
351
+ )
352
+
353
+
234
354
  class ApplyDiffTool(CodingTool):
235
- """Apply a diff/patch to a file."""
355
+ """Apply a search/replace diff to a file with fuzzy matching."""
236
356
 
237
357
  name = "apply_diff"
238
- description = """Apply a unified diff to a file.
239
- The diff should be in standard unified diff format."""
358
+ description = """Apply changes to a file using search/replace blocks with fuzzy matching.
359
+
360
+ Format:
361
+ <<<<<<< SEARCH
362
+ [exact content to find - include enough context for uniqueness]
363
+ =======
364
+ [replacement content]
365
+ >>>>>>> REPLACE
366
+
367
+ You can include multiple SEARCH/REPLACE blocks in one diff.
368
+ The SEARCH content should match the file exactly (or very closely for fuzzy matching).
369
+ Include enough surrounding lines to make the match unique.
370
+
371
+ Example:
372
+ <<<<<<< SEARCH
373
+ def hello():
374
+ print("Hello")
375
+ =======
376
+ def hello():
377
+ print("Hello, World!")
378
+ >>>>>>> REPLACE"""
379
+
380
+ # Confidence threshold for fuzzy matching (0.0-1.0)
381
+ CONFIDENCE_THRESHOLD = 0.85
382
+ # Buffer lines to extend search area around line hints
383
+ BUFFER_LINES = 40
240
384
 
241
385
  def execute(
242
386
  self,
243
- path: str,
387
+ file_path: str,
244
388
  diff: str,
245
389
  ) -> ToolResult:
246
- """Apply a diff to a file.
390
+ """Apply a search/replace diff to a file.
247
391
 
248
392
  Args:
249
- path: Path to the file
250
- diff: Unified diff content
393
+ file_path: Path to the file to modify
394
+ diff: Search/replace diff content
251
395
 
252
396
  Returns:
253
397
  ToolResult indicating success
254
398
  """
255
- valid, error, full_path = self._validate_path(path)
399
+ valid, error, full_path = self._validate_path(file_path)
256
400
  if not valid:
257
401
  return ToolResult.error_result(error)
258
402
 
259
403
  if not full_path.exists():
260
- return ToolResult.error_result(f"File not found: {path}")
404
+ return ToolResult.error_result(f"File not found: {file_path}")
261
405
 
262
406
  try:
263
- # Try to apply with patch command
264
- # --batch: suppress questions, --forward: skip already-applied patches
265
- result = subprocess.run(
266
- ["patch", "-p0", "--forward", "--batch"],
267
- input=diff,
268
- capture_output=True,
269
- text=True,
270
- cwd=self.repo_root,
271
- timeout=30,
272
- )
407
+ content = full_path.read_text()
408
+ original_content = content
273
409
 
274
- if result.returncode != 0:
275
- # Try with -p1
276
- result = subprocess.run(
277
- ["patch", "-p1", "--forward", "--batch"],
278
- input=diff,
279
- capture_output=True,
280
- text=True,
281
- cwd=self.repo_root,
282
- timeout=30,
410
+ # Parse the diff into search/replace blocks
411
+ blocks = self._parse_diff_blocks(diff)
412
+ if not blocks:
413
+ return ToolResult.error_result(
414
+ "No valid SEARCH/REPLACE blocks found in diff.\n"
415
+ "Expected format:\n"
416
+ "<<<<<<< SEARCH\n"
417
+ "[content to find]\n"
418
+ "=======\n"
419
+ "[replacement]\n"
420
+ ">>>>>>> REPLACE"
283
421
  )
284
422
 
285
- if result.returncode != 0:
423
+ # Apply each block
424
+ applied = 0
425
+ failed = []
426
+ for i, block in enumerate(blocks):
427
+ search_text = block["search"]
428
+ replace_text = block["replace"]
429
+
430
+ # Try exact match first
431
+ if search_text in content:
432
+ # Check uniqueness
433
+ count = content.count(search_text)
434
+ if count > 1:
435
+ failed.append(f"Block {i+1}: SEARCH text found {count} times, add more context")
436
+ continue
437
+ content = content.replace(search_text, replace_text, 1)
438
+ applied += 1
439
+ else:
440
+ # Try fuzzy matching
441
+ match_result = self._fuzzy_find(content, search_text)
442
+ if match_result:
443
+ start, end, confidence = match_result
444
+ if confidence >= self.CONFIDENCE_THRESHOLD:
445
+ content = content[:start] + replace_text + content[end:]
446
+ applied += 1
447
+ else:
448
+ failed.append(
449
+ f"Block {i+1}: Best match confidence {confidence:.2f} "
450
+ f"below threshold {self.CONFIDENCE_THRESHOLD}"
451
+ )
452
+ else:
453
+ # Provide helpful error with similar lines
454
+ similar = self._find_similar_lines(content, search_text)
455
+ error_msg = f"Block {i+1}: SEARCH text not found"
456
+ if similar:
457
+ error_msg += f"\nSimilar lines:\n{similar}"
458
+ failed.append(error_msg)
459
+
460
+ if applied == 0:
286
461
  return ToolResult.error_result(
287
- f"Patch failed: {result.stderr}",
288
- suggestions=["Check the diff format", "Ensure the file matches the diff context"],
462
+ f"Failed to apply any blocks:\n" + "\n".join(failed)
289
463
  )
290
464
 
291
- return ToolResult.success_result(
292
- data={
293
- "path": path,
294
- "output": result.stdout,
295
- },
296
- )
465
+ # Write back
466
+ full_path.write_text(content)
467
+
468
+ result_data = {
469
+ "file_path": file_path,
470
+ "blocks_applied": applied,
471
+ "blocks_total": len(blocks),
472
+ }
473
+ if failed:
474
+ result_data["warnings"] = failed
475
+
476
+ return ToolResult.success_result(data=result_data)
297
477
 
298
- except FileNotFoundError:
299
- return ToolResult.error_result(
300
- "patch command not found",
301
- suggestions=["Install patch: brew install gpatch"],
302
- )
303
- except subprocess.TimeoutExpired:
304
- return ToolResult.error_result("Patch timed out")
305
478
  except Exception as e:
306
479
  return ToolResult.error_result(f"Failed to apply diff: {e}")
307
480
 
481
+ def _parse_diff_blocks(self, diff: str) -> list[dict]:
482
+ """Parse diff into search/replace blocks."""
483
+ import re
484
+
485
+ blocks = []
486
+ # Pattern to match search/replace blocks
487
+ pattern = r'<<<<<<< SEARCH\n(.*?)\n=======\n(.*?)\n>>>>>>> REPLACE'
488
+ matches = re.findall(pattern, diff, re.DOTALL)
489
+
490
+ for search, replace in matches:
491
+ blocks.append({
492
+ "search": search,
493
+ "replace": replace,
494
+ })
495
+
496
+ return blocks
497
+
498
+ def _fuzzy_find(self, content: str, search_text: str) -> tuple[int, int, float] | None:
499
+ """Find best fuzzy match for search_text in content.
500
+
501
+ Returns (start_index, end_index, confidence) or None if no good match.
502
+ """
503
+ from difflib import SequenceMatcher
504
+
505
+ search_lines = search_text.split('\n')
506
+ content_lines = content.split('\n')
507
+ search_len = len(search_lines)
508
+
509
+ if search_len == 0:
510
+ return None
511
+
512
+ best_match = None
513
+ best_confidence = 0.0
514
+
515
+ # Sliding window approach
516
+ for i in range(len(content_lines) - search_len + 1):
517
+ window = '\n'.join(content_lines[i:i + search_len])
518
+ matcher = SequenceMatcher(None, search_text, window)
519
+ confidence = matcher.ratio()
520
+
521
+ if confidence > best_confidence:
522
+ best_confidence = confidence
523
+ # Calculate character positions
524
+ start = sum(len(line) + 1 for line in content_lines[:i])
525
+ end = start + len(window)
526
+ best_match = (start, end, confidence)
527
+
528
+ return best_match if best_confidence >= 0.5 else None
529
+
530
+ def _find_similar_lines(self, content: str, search_text: str) -> str:
531
+ """Find lines in content similar to the first line of search_text."""
532
+ lines = content.split('\n')
533
+ search_first = search_text.split('\n')[0].strip() if search_text else ""
534
+
535
+ if not search_first:
536
+ return ""
537
+
538
+ similar = []
539
+ for i, line in enumerate(lines):
540
+ if search_first[:20] in line or line.strip()[:20] in search_first:
541
+ similar.append(f" Line {i+1}: {line.strip()[:60]}")
542
+ if len(similar) >= 3:
543
+ break
544
+
545
+ return "\n".join(similar)
546
+
308
547
  def get_schema(self) -> dict:
309
548
  """Get OpenAI function schema."""
310
549
  return self._make_schema(
311
550
  properties={
312
- "path": {
551
+ "file_path": {
313
552
  "type": "string",
314
- "description": "Path to the file to patch",
553
+ "description": "The absolute path to the file to modify",
315
554
  },
316
555
  "diff": {
317
556
  "type": "string",
318
- "description": "Unified diff content to apply",
557
+ "description": "Search/replace diff with <<<<<<< SEARCH ... ======= ... >>>>>>> REPLACE blocks",
319
558
  },
320
559
  },
321
- required=["path", "diff"],
560
+ required=["file_path", "diff"],
322
561
  )
323
562
 
324
563
 
@@ -468,22 +707,37 @@ class ExecuteCommandTool(CodingTool):
468
707
 
469
708
  name = "execute_command"
470
709
  description = """Execute a shell command in the repository.
471
- Commands are run from the repository root."""
710
+ Commands are run from the repository root.
711
+
712
+ Use run_in_background=true for long-running commands (builds, servers, tests).
713
+ Background commands return immediately with a task_id. You'll be notified
714
+ when they complete, or use task_output(task_id) to check status."""
472
715
 
473
716
  def execute(
474
717
  self,
475
718
  command: str,
476
719
  timeout: int = 60,
720
+ run_in_background: bool = False,
721
+ description: str = "",
477
722
  ) -> ToolResult:
478
723
  """Execute a command.
479
724
 
480
725
  Args:
481
726
  command: Command to execute
482
- timeout: Timeout in seconds
727
+ timeout: Timeout in seconds (ignored for background commands)
728
+ run_in_background: Run command in background, return immediately
729
+ description: Short description of what this command does
483
730
 
484
731
  Returns:
485
- ToolResult with command output
732
+ ToolResult with command output or task info for background
486
733
  """
734
+ if run_in_background:
735
+ return self._run_background(command, description)
736
+ else:
737
+ return self._run_sync(command, timeout)
738
+
739
+ def _run_sync(self, command: str, timeout: int) -> ToolResult:
740
+ """Run command synchronously."""
487
741
  try:
488
742
  result = subprocess.run(
489
743
  command,
@@ -506,10 +760,39 @@ Commands are run from the repository root."""
506
760
  except subprocess.TimeoutExpired:
507
761
  return ToolResult.error_result(
508
762
  f"Command timed out after {timeout}s",
763
+ suggestions=["Use run_in_background=true for long-running commands"],
509
764
  )
510
765
  except Exception as e:
511
766
  return ToolResult.error_result(f"Command failed: {e}")
512
767
 
768
+ def _run_background(self, command: str, description: str) -> ToolResult:
769
+ """Run command in background."""
770
+ from ..background import BackgroundTaskManager
771
+
772
+ try:
773
+ manager = BackgroundTaskManager.get_instance()
774
+ task_id = manager.start_shell(
775
+ command=command,
776
+ description=description or command[:50],
777
+ cwd=self.repo_root,
778
+ )
779
+
780
+ return ToolResult.success_result(
781
+ data={
782
+ "task_id": task_id,
783
+ "status": "running",
784
+ "command": command,
785
+ "message": "Command started in background. You'll be notified when it completes.",
786
+ },
787
+ suggestions=[
788
+ f"Use task_output(task_id='{task_id}') to check status",
789
+ f"Use kill_task(task_id='{task_id}') to stop it",
790
+ ],
791
+ )
792
+
793
+ except Exception as e:
794
+ return ToolResult.error_result(f"Failed to start background command: {e}")
795
+
513
796
  def get_schema(self) -> dict:
514
797
  """Get OpenAI function schema."""
515
798
  return self._make_schema(
@@ -520,9 +803,18 @@ Commands are run from the repository root."""
520
803
  },
521
804
  "timeout": {
522
805
  "type": "integer",
523
- "description": "Timeout in seconds",
806
+ "description": "Timeout in seconds (for synchronous execution)",
524
807
  "default": 60,
525
808
  },
809
+ "run_in_background": {
810
+ "type": "boolean",
811
+ "description": "Run in background and return immediately. Use for long-running commands.",
812
+ "default": False,
813
+ },
814
+ "description": {
815
+ "type": "string",
816
+ "description": "Short description of what this command does",
817
+ },
526
818
  },
527
819
  required=["command"],
528
820
  )