EvoScientist 0.0.1.dev2__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.
- EvoScientist/EvoScientist.py +45 -13
- EvoScientist/cli.py +237 -1363
- EvoScientist/memory.py +715 -0
- EvoScientist/middleware.py +49 -4
- EvoScientist/paths.py +45 -0
- EvoScientist/skills_manager.py +392 -0
- EvoScientist/stream/__init__.py +25 -0
- EvoScientist/stream/display.py +604 -0
- EvoScientist/stream/events.py +415 -0
- EvoScientist/stream/state.py +343 -0
- EvoScientist/stream/utils.py +23 -16
- EvoScientist/tools.py +64 -0
- {evoscientist-0.0.1.dev2.dist-info → evoscientist-0.0.1.dev3.dist-info}/METADATA +97 -3
- {evoscientist-0.0.1.dev2.dist-info → evoscientist-0.0.1.dev3.dist-info}/RECORD +18 -12
- {evoscientist-0.0.1.dev2.dist-info → evoscientist-0.0.1.dev3.dist-info}/WHEEL +0 -0
- {evoscientist-0.0.1.dev2.dist-info → evoscientist-0.0.1.dev3.dist-info}/entry_points.txt +0 -0
- {evoscientist-0.0.1.dev2.dist-info → evoscientist-0.0.1.dev3.dist-info}/licenses/LICENSE +0 -0
- {evoscientist-0.0.1.dev2.dist-info → evoscientist-0.0.1.dev3.dist-info}/top_level.txt +0 -0
EvoScientist/middleware.py
CHANGED
|
@@ -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 = "
|
|
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 (
|
|
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
|
|
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=
|
|
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
|
EvoScientist/stream/__init__.py
CHANGED
|
@@ -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
|
]
|