EvoScientist 0.0.1.dev1__py3-none-any.whl → 0.0.1.dev3__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.
@@ -1,35 +1,80 @@
1
1
  """Middleware configuration for the EvoScientist agent."""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  from pathlib import Path
6
+ from typing import TYPE_CHECKING
4
7
 
8
+ from deepagents.backends import FilesystemBackend
5
9
  from deepagents.middleware.skills import SkillsMiddleware
6
10
 
7
11
  from .backends import MergedReadOnlyBackend
12
+ from .memory import EvoMemoryMiddleware
13
+ from .paths import MEMORY_DIR as _DEFAULT_MEMORY_DIR
14
+
15
+ if TYPE_CHECKING:
16
+ from langchain.chat_models import BaseChatModel
8
17
 
9
18
  _DEFAULT_SKILLS_DIR = str(Path(__file__).parent / "skills")
10
19
 
11
20
 
12
21
  def create_skills_middleware(
13
22
  skills_dir: str = _DEFAULT_SKILLS_DIR,
14
- workspace_dir: str = "./workspace/",
23
+ workspace_dir: str = ".",
24
+ user_skills_dir: str | None = None,
15
25
  ) -> SkillsMiddleware:
16
26
  """Create a SkillsMiddleware that loads skills.
17
27
 
18
- Merges user-installed skills (workspace/skills/) with system skills
28
+ Merges user-installed skills (./skills/) with system skills
19
29
  (package built-in). User skills take priority on name conflicts.
20
30
 
21
31
  Args:
22
32
  skills_dir: Path to the system skills directory (package built-in)
23
- workspace_dir: Path to the workspace root (user skills live under workspace/skills/)
33
+ workspace_dir: Path to the project root (user skills live under {workspace_dir}/skills/)
34
+ user_skills_dir: Optional explicit path for user-installed skills. If set,
35
+ this path is used directly instead of {workspace_dir}/skills.
24
36
 
25
37
  Returns:
26
38
  Configured SkillsMiddleware instance
27
39
  """
40
+ if user_skills_dir is None:
41
+ user_skills_dir = str(Path(workspace_dir) / "skills")
28
42
  merged = MergedReadOnlyBackend(
29
- primary_dir=str(Path(workspace_dir) / "skills"),
43
+ primary_dir=user_skills_dir,
30
44
  secondary_dir=skills_dir,
31
45
  )
32
46
  return SkillsMiddleware(
33
47
  backend=merged,
34
48
  sources=["/"],
35
49
  )
50
+
51
+
52
+ def create_memory_middleware(
53
+ memory_dir: str = str(_DEFAULT_MEMORY_DIR),
54
+ extraction_model: BaseChatModel | None = None,
55
+ trigger: tuple[str, int] = ("messages", 20),
56
+ ) -> EvoMemoryMiddleware:
57
+ """Create an EvoMemoryMiddleware for long-term memory.
58
+
59
+ Uses a FilesystemBackend rooted at ``memory_dir`` so that memory
60
+ persists across threads and sessions.
61
+
62
+ Args:
63
+ memory_dir: Path to the shared memory directory (not per-session).
64
+ extraction_model: Chat model for auto-extraction (optional; if None,
65
+ only prompt-guided manual memory updates via edit_file will work).
66
+ trigger: When to auto-extract. Default: every 20 human messages.
67
+
68
+ Returns:
69
+ Configured EvoMemoryMiddleware instance.
70
+ """
71
+ memory_backend = FilesystemBackend(
72
+ root_dir=memory_dir,
73
+ virtual_mode=True,
74
+ )
75
+ return EvoMemoryMiddleware(
76
+ backend=memory_backend,
77
+ memory_path="/MEMORY.md",
78
+ extraction_model=extraction_model,
79
+ trigger=trigger,
80
+ )
EvoScientist/paths.py ADDED
@@ -0,0 +1,45 @@
1
+ """Path resolution utilities for EvoScientist runtime directories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+
9
+
10
+ def _expand(path: str) -> Path:
11
+ return Path(path).expanduser()
12
+
13
+
14
+ def _env_path(key: str) -> Path | None:
15
+ value = os.getenv(key)
16
+ if not value:
17
+ return None
18
+ return _expand(value)
19
+
20
+
21
+ STATE_ROOT = _env_path("EVOSCIENTIST_HOME") or (Path.cwd() / ".evoscientist")
22
+
23
+ WORKSPACE_ROOT = _env_path("EVOSCIENTIST_WORKSPACE_DIR") or (STATE_ROOT / "workspace")
24
+
25
+ RUNS_DIR = _env_path("EVOSCIENTIST_RUNS_DIR") or (WORKSPACE_ROOT / "runs")
26
+ MEMORY_DIR = _env_path("EVOSCIENTIST_MEMORY_DIR") or (WORKSPACE_ROOT / "memory")
27
+ USER_SKILLS_DIR = _env_path("EVOSCIENTIST_SKILLS_DIR") or (WORKSPACE_ROOT / "skills")
28
+
29
+
30
+ def ensure_dirs() -> None:
31
+ """Create runtime directories if they do not exist."""
32
+ for path in (WORKSPACE_ROOT, RUNS_DIR, MEMORY_DIR, USER_SKILLS_DIR):
33
+ path.mkdir(parents=True, exist_ok=True)
34
+
35
+
36
+ def default_workspace_dir() -> Path:
37
+ """Default workspace for non-CLI usage."""
38
+ return WORKSPACE_ROOT
39
+
40
+
41
+ def new_run_dir(session_id: str | None = None) -> Path:
42
+ """Create a new run directory name under RUNS_DIR (path only)."""
43
+ if session_id is None:
44
+ session_id = datetime.now().strftime("%Y%m%d_%H%M%S")
45
+ return RUNS_DIR / session_id
@@ -0,0 +1,392 @@
1
+ """Skill installation and management for EvoScientist.
2
+
3
+ This module provides functions for installing, listing, and uninstalling user skills.
4
+ Skills are installed to USER_SKILLS_DIR (~/.evoscientist/workspace/skills/).
5
+
6
+ Supported installation sources:
7
+ - Local directory paths
8
+ - GitHub URLs (https://github.com/owner/repo or .../tree/branch/path)
9
+ - GitHub shorthand (owner/repo@skill-name)
10
+
11
+ Usage:
12
+ from EvoScientist.skills_manager import install_skill, list_skills, uninstall_skill
13
+
14
+ # Install from local path
15
+ install_skill("./my-skill")
16
+
17
+ # Install from GitHub
18
+ install_skill("https://github.com/user/repo/tree/main/my-skill")
19
+
20
+ # List installed skills
21
+ for skill in list_skills():
22
+ print(skill["name"], skill["description"])
23
+
24
+ # Uninstall a skill
25
+ uninstall_skill("my-skill")
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import os
31
+ import re
32
+ import shutil
33
+ import subprocess
34
+ import tempfile
35
+ from dataclasses import dataclass
36
+ from pathlib import Path
37
+ from typing import Iterator
38
+
39
+ import yaml
40
+
41
+ from .paths import USER_SKILLS_DIR
42
+
43
+
44
+ @dataclass
45
+ class SkillInfo:
46
+ """Information about an installed skill."""
47
+
48
+ name: str
49
+ description: str
50
+ path: Path
51
+ source: str # "user" or "system"
52
+
53
+
54
+ def _parse_skill_md(skill_md_path: Path) -> dict[str, str]:
55
+ """Parse SKILL.md frontmatter to extract name and description.
56
+
57
+ SKILL.md format:
58
+ ---
59
+ name: skill-name
60
+ description: A brief description...
61
+ ---
62
+ # Skill Title
63
+ ...
64
+
65
+ Returns:
66
+ Dictionary with 'name' and 'description' keys.
67
+ """
68
+ content = skill_md_path.read_text(encoding="utf-8")
69
+
70
+ # Extract YAML frontmatter
71
+ frontmatter_match = re.match(r"^---\s*\n(.*?)\n---", content, re.DOTALL)
72
+ if not frontmatter_match:
73
+ # No frontmatter, use directory name
74
+ return {
75
+ "name": skill_md_path.parent.name,
76
+ "description": "(no description)",
77
+ }
78
+
79
+ try:
80
+ frontmatter = yaml.safe_load(frontmatter_match.group(1))
81
+ return {
82
+ "name": frontmatter.get("name", skill_md_path.parent.name),
83
+ "description": frontmatter.get("description", "(no description)"),
84
+ }
85
+ except yaml.YAMLError:
86
+ return {
87
+ "name": skill_md_path.parent.name,
88
+ "description": "(invalid frontmatter)",
89
+ }
90
+
91
+
92
+ def _parse_github_url(url: str) -> tuple[str, str | None, str | None]:
93
+ """Parse a GitHub URL into (repo, ref, path).
94
+
95
+ Supports formats:
96
+ https://github.com/owner/repo
97
+ https://github.com/owner/repo/tree/main/path/to/skill
98
+ github.com/owner/repo/tree/branch/path
99
+ owner/repo@skill-name (shorthand from skills.sh)
100
+
101
+ Returns:
102
+ (repo, ref_or_none, path_or_none)
103
+ """
104
+ # Shorthand: owner/repo@path
105
+ if "@" in url and "://" not in url:
106
+ repo, path = url.split("@", 1)
107
+ return repo.strip(), None, path.strip()
108
+
109
+ # Strip protocol and github.com prefix
110
+ cleaned = re.sub(r"^https?://", "", url)
111
+ cleaned = re.sub(r"^github\.com/", "", cleaned)
112
+ cleaned = cleaned.rstrip("/")
113
+
114
+ # Match: owner/repo/tree/ref/path...
115
+ m = re.match(r"^([^/]+/[^/]+)/tree/([^/]+)(?:/(.+))?$", cleaned)
116
+ if m:
117
+ return m.group(1), m.group(2), m.group(3)
118
+
119
+ # Match: owner/repo (no tree)
120
+ m = re.match(r"^([^/]+/[^/]+)$", cleaned)
121
+ if m:
122
+ return m.group(1), None, None
123
+
124
+ raise ValueError(f"Cannot parse GitHub URL: {url}")
125
+
126
+
127
+ def _clone_repo(repo: str, ref: str | None, dest: str) -> None:
128
+ """Shallow-clone a GitHub repo."""
129
+ clone_url = f"https://github.com/{repo}.git"
130
+ cmd = ["git", "clone", "--depth", "1"]
131
+ if ref:
132
+ cmd += ["--branch", ref]
133
+ cmd += [clone_url, dest]
134
+
135
+ result = subprocess.run(cmd, capture_output=True, text=True)
136
+ if result.returncode != 0:
137
+ raise RuntimeError(f"git clone failed: {result.stderr.strip()}")
138
+
139
+
140
+ def _is_github_url(source: str) -> bool:
141
+ """Check if the source looks like a GitHub URL or shorthand."""
142
+ if "github.com" in source.lower():
143
+ return True
144
+ if "://" in source:
145
+ return False # Non-GitHub URL
146
+ # Check for owner/repo@skill shorthand
147
+ if "@" in source and "/" in source.split("@")[0]:
148
+ return True
149
+ # Check for owner/repo format (but not local paths like ./foo or /foo)
150
+ if "/" in source and not source.startswith((".", "/")):
151
+ parts = source.split("/")
152
+ # GitHub shorthand: exactly 2 parts, both non-empty, no extensions
153
+ if len(parts) == 2 and all(parts) and "." not in parts[0]:
154
+ return True
155
+ return False
156
+
157
+
158
+ def _validate_skill_dir(path: Path) -> bool:
159
+ """Check if a directory contains a valid skill (has SKILL.md)."""
160
+ return (path / "SKILL.md").is_file()
161
+
162
+
163
+ def install_skill(source: str, dest_dir: str | None = None) -> dict:
164
+ """Install a skill from a local path or GitHub URL.
165
+
166
+ Args:
167
+ source: Local directory path or GitHub URL/shorthand.
168
+ dest_dir: Destination directory (defaults to USER_SKILLS_DIR).
169
+
170
+ Returns:
171
+ Dictionary with installation result:
172
+ - success: bool
173
+ - name: skill name (if successful)
174
+ - path: installed path (if successful)
175
+ - error: error message (if failed)
176
+ """
177
+ dest_dir = dest_dir or str(USER_SKILLS_DIR)
178
+ os.makedirs(dest_dir, exist_ok=True)
179
+
180
+ if _is_github_url(source):
181
+ return _install_from_github(source, dest_dir)
182
+ else:
183
+ return _install_from_local(source, dest_dir)
184
+
185
+
186
+ def _install_from_local(source: str, dest_dir: str) -> dict:
187
+ """Install a skill from a local directory path."""
188
+ source_path = Path(source).expanduser().resolve()
189
+
190
+ if not source_path.exists():
191
+ return {"success": False, "error": f"Path does not exist: {source}"}
192
+
193
+ if not source_path.is_dir():
194
+ return {"success": False, "error": f"Not a directory: {source}"}
195
+
196
+ if not _validate_skill_dir(source_path):
197
+ return {"success": False, "error": f"No SKILL.md found in: {source}"}
198
+
199
+ # Parse SKILL.md to get the skill name
200
+ skill_info = _parse_skill_md(source_path / "SKILL.md")
201
+ skill_name = skill_info["name"]
202
+
203
+ # Destination path
204
+ target_path = Path(dest_dir) / skill_name
205
+
206
+ # Remove existing if present
207
+ if target_path.exists():
208
+ shutil.rmtree(target_path)
209
+
210
+ # Copy skill directory
211
+ shutil.copytree(source_path, target_path)
212
+
213
+ return {
214
+ "success": True,
215
+ "name": skill_name,
216
+ "path": str(target_path),
217
+ "description": skill_info["description"],
218
+ }
219
+
220
+
221
+ def _install_from_github(source: str, dest_dir: str) -> dict:
222
+ """Install a skill from a GitHub URL or shorthand."""
223
+ try:
224
+ repo, ref, path = _parse_github_url(source)
225
+ except ValueError as e:
226
+ return {"success": False, "error": str(e)}
227
+
228
+ with tempfile.TemporaryDirectory(prefix="evoscientist-skill-") as tmp:
229
+ clone_dir = os.path.join(tmp, "repo")
230
+
231
+ try:
232
+ _clone_repo(repo, ref, clone_dir)
233
+ except RuntimeError as e:
234
+ return {"success": False, "error": str(e)}
235
+
236
+ # Determine the skill source directory
237
+ if path:
238
+ skill_source = Path(clone_dir) / path
239
+ else:
240
+ skill_source = Path(clone_dir)
241
+
242
+ if not skill_source.exists():
243
+ return {"success": False, "error": f"Path not found in repo: {path or '/'}"}
244
+
245
+ if not _validate_skill_dir(skill_source):
246
+ # Maybe the repo root contains multiple skills?
247
+ if not path:
248
+ # Try to find skills in repo root
249
+ found_skills = []
250
+ for entry in os.listdir(clone_dir):
251
+ entry_path = Path(clone_dir) / entry
252
+ if entry_path.is_dir() and _validate_skill_dir(entry_path):
253
+ found_skills.append(entry)
254
+
255
+ if found_skills:
256
+ return {
257
+ "success": False,
258
+ "error": (
259
+ f"Multiple skills found in repo. "
260
+ f"Please specify one: {', '.join(found_skills)}"
261
+ ),
262
+ }
263
+
264
+ return {"success": False, "error": f"No SKILL.md found in: {source}"}
265
+
266
+ # Parse skill info and copy
267
+ skill_info = _parse_skill_md(skill_source / "SKILL.md")
268
+ skill_name = skill_info["name"]
269
+ target_path = Path(dest_dir) / skill_name
270
+
271
+ if target_path.exists():
272
+ shutil.rmtree(target_path)
273
+
274
+ # Copy, excluding .git directory
275
+ def ignore_git(dir_name: str, files: list[str]) -> list[str]:
276
+ return [f for f in files if f == ".git"]
277
+
278
+ shutil.copytree(skill_source, target_path, ignore=ignore_git)
279
+
280
+ return {
281
+ "success": True,
282
+ "name": skill_name,
283
+ "path": str(target_path),
284
+ "description": skill_info["description"],
285
+ "source": source,
286
+ }
287
+
288
+
289
+ def list_skills(include_system: bool = False) -> list[SkillInfo]:
290
+ """List all installed user skills.
291
+
292
+ Args:
293
+ include_system: If True, also include system (built-in) skills.
294
+
295
+ Returns:
296
+ List of SkillInfo objects for each installed skill.
297
+ """
298
+ skills: list[SkillInfo] = []
299
+
300
+ # User skills
301
+ user_dir = Path(USER_SKILLS_DIR)
302
+ if user_dir.exists():
303
+ for entry in sorted(user_dir.iterdir()):
304
+ if entry.is_dir() and _validate_skill_dir(entry):
305
+ skill_md = entry / "SKILL.md"
306
+ info = _parse_skill_md(skill_md)
307
+ skills.append(
308
+ SkillInfo(
309
+ name=info["name"],
310
+ description=info["description"],
311
+ path=entry,
312
+ source="user",
313
+ )
314
+ )
315
+
316
+ # System skills (optional)
317
+ if include_system:
318
+ from .EvoScientist import SKILLS_DIR
319
+
320
+ system_dir = Path(SKILLS_DIR)
321
+ if system_dir.exists():
322
+ for entry in sorted(system_dir.iterdir()):
323
+ if entry.is_dir() and _validate_skill_dir(entry):
324
+ # Skip if user has overridden this skill
325
+ if any(s.name == entry.name for s in skills):
326
+ continue
327
+ skill_md = entry / "SKILL.md"
328
+ info = _parse_skill_md(skill_md)
329
+ skills.append(
330
+ SkillInfo(
331
+ name=info["name"],
332
+ description=info["description"],
333
+ path=entry,
334
+ source="system",
335
+ )
336
+ )
337
+
338
+ return skills
339
+
340
+
341
+ def uninstall_skill(name: str) -> dict:
342
+ """Uninstall a user-installed skill.
343
+
344
+ Args:
345
+ name: Name of the skill to uninstall.
346
+
347
+ Returns:
348
+ Dictionary with result:
349
+ - success: bool
350
+ - error: error message (if failed)
351
+ """
352
+ user_dir = Path(USER_SKILLS_DIR)
353
+ target_path = user_dir / name
354
+
355
+ if not target_path.exists():
356
+ # Try to find by directory name (in case name differs from dir name)
357
+ found = None
358
+ if user_dir.exists():
359
+ for entry in user_dir.iterdir():
360
+ if entry.is_dir() and _validate_skill_dir(entry):
361
+ info = _parse_skill_md(entry / "SKILL.md")
362
+ if info["name"] == name:
363
+ found = entry
364
+ break
365
+
366
+ if not found:
367
+ return {"success": False, "error": f"Skill not found: {name}"}
368
+ target_path = found
369
+
370
+ # Check if it's a user skill (not system)
371
+ if not str(target_path).startswith(str(user_dir)):
372
+ return {"success": False, "error": f"Cannot uninstall system skill: {name}"}
373
+
374
+ # Remove the skill directory
375
+ shutil.rmtree(target_path)
376
+
377
+ return {"success": True, "name": name}
378
+
379
+
380
+ def get_skill_info(name: str) -> SkillInfo | None:
381
+ """Get information about a specific skill.
382
+
383
+ Args:
384
+ name: Name of the skill.
385
+
386
+ Returns:
387
+ SkillInfo if found, None otherwise.
388
+ """
389
+ for skill in list_skills(include_system=True):
390
+ if skill.name == name:
391
+ return skill
392
+ return None
@@ -6,6 +6,9 @@ Provides:
6
6
  - ToolCallTracker: Incremental JSON parsing for tool parameters
7
7
  - ToolResultFormatter: Content-aware result formatting with Rich
8
8
  - Utility functions and constants
9
+ - SubAgentState / StreamState: Stream state tracking
10
+ - stream_agent_events: Async event generator
11
+ - Display functions: Rich rendering for streaming and final output
9
12
  """
10
13
 
11
14
  from .emitter import StreamEventEmitter, StreamEvent
@@ -25,6 +28,15 @@ from .utils import (
25
28
  truncate_with_line_hint,
26
29
  get_status_symbol,
27
30
  )
31
+ from .state import SubAgentState, StreamState, _parse_todo_items, _build_todo_stats
32
+ from .events import stream_agent_events
33
+ from .display import (
34
+ console,
35
+ formatter,
36
+ format_tool_result_compact,
37
+ create_streaming_display,
38
+ display_final_results,
39
+ )
28
40
 
29
41
  __all__ = [
30
42
  # Emitter
@@ -50,4 +62,17 @@ __all__ = [
50
62
  "count_lines",
51
63
  "truncate_with_line_hint",
52
64
  "get_status_symbol",
65
+ # State
66
+ "SubAgentState",
67
+ "StreamState",
68
+ "_parse_todo_items",
69
+ "_build_todo_stats",
70
+ # Events
71
+ "stream_agent_events",
72
+ # Display
73
+ "console",
74
+ "formatter",
75
+ "format_tool_result_compact",
76
+ "create_streaming_display",
77
+ "display_final_results",
53
78
  ]