evolution-sdk 0.8.0__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.
- evolution/__init__.py +102 -0
- evolution/adapters/__init__.py +28 -0
- evolution/adapters/base.py +21 -0
- evolution/adapters/crewai.py +86 -0
- evolution/adapters/direct.py +120 -0
- evolution/adapters/langchain.py +112 -0
- evolution/adapters/llamaindex.py +96 -0
- evolution/capture/__init__.py +22 -0
- evolution/capture/introspect.py +164 -0
- evolution/capture/recorder.py +109 -0
- evolution/capture/tracker.py +194 -0
- evolution/evaluators.py +268 -0
- evolution/exceptions.py +52 -0
- evolution/models/__init__.py +40 -0
- evolution/models/artifacts.py +248 -0
- evolution/models/evaluation.py +77 -0
- evolution/models/execution.py +77 -0
- evolution/models/manifest.py +210 -0
- evolution/repository.py +360 -0
- evolution/validator.py +55 -0
- evolution_sdk-0.8.0.dist-info/METADATA +240 -0
- evolution_sdk-0.8.0.dist-info/RECORD +25 -0
- evolution_sdk-0.8.0.dist-info/WHEEL +5 -0
- evolution_sdk-0.8.0.dist-info/licenses/LICENSE +21 -0
- evolution_sdk-0.8.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Execution recording models conforming to Intelligence Manifest Specification v1.0.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class TokenUsage:
|
|
15
|
+
"""Token consumption metrics for an execution run."""
|
|
16
|
+
prompt_tokens: int = 0
|
|
17
|
+
completion_tokens: int = 0
|
|
18
|
+
total_tokens: int = 0
|
|
19
|
+
|
|
20
|
+
def __post_init__(self):
|
|
21
|
+
if self.total_tokens == 0:
|
|
22
|
+
self.total_tokens = self.prompt_tokens + self.completion_tokens
|
|
23
|
+
|
|
24
|
+
def to_dict(self) -> dict[str, Any]:
|
|
25
|
+
return {
|
|
26
|
+
"prompt_tokens": self.prompt_tokens,
|
|
27
|
+
"completion_tokens": self.completion_tokens,
|
|
28
|
+
"total_tokens": self.total_tokens,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_dict(cls, data: dict[str, Any]) -> TokenUsage:
|
|
33
|
+
return cls(
|
|
34
|
+
prompt_tokens=data.get("prompt_tokens", 0),
|
|
35
|
+
completion_tokens=data.get("completion_tokens", 0),
|
|
36
|
+
total_tokens=data.get("total_tokens", 0),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Execution:
|
|
42
|
+
"""Records a single AI system invocation at a specific commit snapshot."""
|
|
43
|
+
commit_id: str
|
|
44
|
+
inputs: str
|
|
45
|
+
outputs: str
|
|
46
|
+
duration_ms: int = 0
|
|
47
|
+
tokens: TokenUsage = field(default_factory=TokenUsage)
|
|
48
|
+
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
49
|
+
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
|
50
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
51
|
+
|
|
52
|
+
def to_dict(self) -> dict[str, Any]:
|
|
53
|
+
return {
|
|
54
|
+
"id": self.id,
|
|
55
|
+
"commit_id": self.commit_id,
|
|
56
|
+
"inputs": self.inputs,
|
|
57
|
+
"outputs": self.outputs,
|
|
58
|
+
"duration_ms": self.duration_ms,
|
|
59
|
+
"tokens": self.tokens.to_dict(),
|
|
60
|
+
"timestamp": self.timestamp,
|
|
61
|
+
"metadata": self.metadata,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def from_dict(cls, data: dict[str, Any]) -> Execution:
|
|
66
|
+
tokens_data = data.get("tokens", {})
|
|
67
|
+
tokens = TokenUsage.from_dict(tokens_data) if isinstance(tokens_data, dict) else TokenUsage()
|
|
68
|
+
return cls(
|
|
69
|
+
id=data.get("id", str(uuid.uuid4())),
|
|
70
|
+
commit_id=data.get("commit_id", ""),
|
|
71
|
+
inputs=data.get("inputs", ""),
|
|
72
|
+
outputs=data.get("outputs", ""),
|
|
73
|
+
duration_ms=data.get("duration_ms", 0),
|
|
74
|
+
tokens=tokens,
|
|
75
|
+
timestamp=data.get("timestamp", datetime.now(timezone.utc).isoformat()),
|
|
76
|
+
metadata=data.get("metadata", {}),
|
|
77
|
+
)
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Intelligence Manifest Manager conforming to Intelligence Manifest Specification v1.0.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from evolution.exceptions import ManifestNotFoundError, ManifestValidationError
|
|
13
|
+
from evolution.models.artifacts import (
|
|
14
|
+
BaseArtifact,
|
|
15
|
+
MemoryArtifact,
|
|
16
|
+
ModelConfigArtifact,
|
|
17
|
+
PolicyArtifact,
|
|
18
|
+
PromptArtifact,
|
|
19
|
+
RetrievalArtifact,
|
|
20
|
+
ToolArtifact,
|
|
21
|
+
artifact_from_dict,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
MANIFEST_FILE_NAME = "evolution.manifest.json"
|
|
25
|
+
DEFAULT_SPEC_VERSION = "1.0.0"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ManifestArtifacts:
|
|
30
|
+
"""Container for grouped typed artifacts."""
|
|
31
|
+
prompts: list[PromptArtifact] = field(default_factory=list)
|
|
32
|
+
memory: list[MemoryArtifact] = field(default_factory=list)
|
|
33
|
+
retrieval: list[RetrievalArtifact] = field(default_factory=list)
|
|
34
|
+
tools: list[ToolArtifact] = field(default_factory=list)
|
|
35
|
+
model_config: ModelConfigArtifact | None = None
|
|
36
|
+
policies: list[PolicyArtifact] = field(default_factory=list)
|
|
37
|
+
|
|
38
|
+
def all(self) -> list[BaseArtifact]:
|
|
39
|
+
"""Returns a flat list of all attached artifacts."""
|
|
40
|
+
res: list[BaseArtifact] = []
|
|
41
|
+
res.extend(self.prompts)
|
|
42
|
+
res.extend(self.memory)
|
|
43
|
+
res.extend(self.retrieval)
|
|
44
|
+
res.extend(self.tools)
|
|
45
|
+
if self.model_config:
|
|
46
|
+
res.append(self.model_config)
|
|
47
|
+
res.extend(self.policies)
|
|
48
|
+
return res
|
|
49
|
+
|
|
50
|
+
def to_dict(self) -> dict[str, Any]:
|
|
51
|
+
"""Serializes artifacts dictionary matching the schema."""
|
|
52
|
+
d: dict[str, Any] = {}
|
|
53
|
+
if self.prompts:
|
|
54
|
+
d["prompts"] = [p.to_dict() for p in self.prompts]
|
|
55
|
+
if self.memory:
|
|
56
|
+
d["memory"] = [m.to_dict() for m in self.memory]
|
|
57
|
+
if self.retrieval:
|
|
58
|
+
d["retrieval"] = [r.to_dict() for r in self.retrieval]
|
|
59
|
+
if self.tools:
|
|
60
|
+
d["tools"] = [t.to_dict() for t in self.tools]
|
|
61
|
+
if self.model_config:
|
|
62
|
+
d["model_config"] = self.model_config.to_dict()
|
|
63
|
+
if self.policies:
|
|
64
|
+
d["policies"] = [p.to_dict() for p in self.policies]
|
|
65
|
+
return d
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class Manifest:
|
|
70
|
+
"""The Intelligence Manifest captures the complete operational state of an AI system."""
|
|
71
|
+
name: str = "ai-intelligence"
|
|
72
|
+
version: str = DEFAULT_SPEC_VERSION
|
|
73
|
+
description: str = "AI system powered by Evolution version control"
|
|
74
|
+
artifacts: ManifestArtifacts = field(default_factory=ManifestArtifacts)
|
|
75
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
76
|
+
|
|
77
|
+
def add_artifact(self, artifact: BaseArtifact) -> Manifest:
|
|
78
|
+
"""Adds or updates a typed artifact in the manifest."""
|
|
79
|
+
if isinstance(artifact, PromptArtifact):
|
|
80
|
+
self.artifacts.prompts = [p for p in self.artifacts.prompts if p.name != artifact.name] + [artifact]
|
|
81
|
+
elif isinstance(artifact, MemoryArtifact):
|
|
82
|
+
self.artifacts.memory = [m for m in self.artifacts.memory if m.name != artifact.name] + [artifact]
|
|
83
|
+
elif isinstance(artifact, RetrievalArtifact):
|
|
84
|
+
self.artifacts.retrieval = [r for r in self.artifacts.retrieval if r.name != artifact.name] + [artifact]
|
|
85
|
+
elif isinstance(artifact, ToolArtifact):
|
|
86
|
+
self.artifacts.tools = [t for t in self.artifacts.tools if t.name != artifact.name] + [artifact]
|
|
87
|
+
elif isinstance(artifact, ModelConfigArtifact):
|
|
88
|
+
self.artifacts.model_config = artifact
|
|
89
|
+
elif isinstance(artifact, PolicyArtifact):
|
|
90
|
+
self.artifacts.policies = [p for p in self.artifacts.policies if p.name != artifact.name] + [artifact]
|
|
91
|
+
else:
|
|
92
|
+
raise TypeError(f"Unsupported artifact class: {type(artifact)}")
|
|
93
|
+
return self
|
|
94
|
+
|
|
95
|
+
def get_artifact(self, name: str) -> BaseArtifact | None:
|
|
96
|
+
"""Finds an artifact by its unique name."""
|
|
97
|
+
for art in self.artifacts.all():
|
|
98
|
+
if art.name == name:
|
|
99
|
+
return art
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
def list_artifacts(self) -> list[BaseArtifact]:
|
|
103
|
+
"""Returns all artifacts as a flat list."""
|
|
104
|
+
return self.artifacts.all()
|
|
105
|
+
|
|
106
|
+
def remove_artifact(self, name: str) -> bool:
|
|
107
|
+
"""Removes an artifact by name. Returns True if removed, False if not found."""
|
|
108
|
+
initial_len = len(self.artifacts.all())
|
|
109
|
+
self.artifacts.prompts = [p for p in self.artifacts.prompts if p.name != name]
|
|
110
|
+
self.artifacts.memory = [m for m in self.artifacts.memory if m.name != name]
|
|
111
|
+
self.artifacts.retrieval = [r for r in self.artifacts.retrieval if r.name != name]
|
|
112
|
+
self.artifacts.tools = [t for t in self.artifacts.tools if t.name != name]
|
|
113
|
+
if self.artifacts.model_config and self.artifacts.model_config.name == name:
|
|
114
|
+
self.artifacts.model_config = None
|
|
115
|
+
self.artifacts.policies = [p for p in self.artifacts.policies if p.name != name]
|
|
116
|
+
return len(self.artifacts.all()) < initial_len
|
|
117
|
+
|
|
118
|
+
def compute_hashes(self, workspace_root: Path | str | None = None) -> None:
|
|
119
|
+
"""Auto-computes SHA-256 hashes for all attached artifacts with valid file paths."""
|
|
120
|
+
for art in self.artifacts.all():
|
|
121
|
+
art.compute_hash(workspace_root=workspace_root)
|
|
122
|
+
|
|
123
|
+
def validate(self) -> None:
|
|
124
|
+
"""Validates manifest against v1.0 Specification.
|
|
125
|
+
Raises ManifestValidationError on non-compliance.
|
|
126
|
+
"""
|
|
127
|
+
from evolution.validator import validate_manifest
|
|
128
|
+
validate_manifest(self)
|
|
129
|
+
|
|
130
|
+
def to_dict(self) -> dict[str, Any]:
|
|
131
|
+
"""Serializes the manifest to a clean dict conforming to Spec v1.0."""
|
|
132
|
+
d: dict[str, Any] = {
|
|
133
|
+
"version": self.version,
|
|
134
|
+
"name": self.name,
|
|
135
|
+
}
|
|
136
|
+
if self.description:
|
|
137
|
+
d["description"] = self.description
|
|
138
|
+
|
|
139
|
+
art_dict = self.artifacts.to_dict()
|
|
140
|
+
if art_dict:
|
|
141
|
+
d["artifacts"] = art_dict
|
|
142
|
+
|
|
143
|
+
if self.metadata:
|
|
144
|
+
d["metadata"] = self.metadata
|
|
145
|
+
return d
|
|
146
|
+
|
|
147
|
+
def to_json(self, indent: int = 2) -> str:
|
|
148
|
+
"""Returns formatted JSON string of the manifest."""
|
|
149
|
+
return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False)
|
|
150
|
+
|
|
151
|
+
def save(self, destination: Path | str | None = None) -> Path:
|
|
152
|
+
"""Saves the manifest as evolution.manifest.json to the specified file or directory."""
|
|
153
|
+
if destination is None:
|
|
154
|
+
dest_path = Path(MANIFEST_FILE_NAME)
|
|
155
|
+
else:
|
|
156
|
+
dest_path = Path(destination)
|
|
157
|
+
if dest_path.is_dir() or not dest_path.name.endswith(".json"):
|
|
158
|
+
dest_path = dest_path / MANIFEST_FILE_NAME
|
|
159
|
+
|
|
160
|
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
161
|
+
dest_path.write_text(self.to_json() + "\n", encoding="utf-8")
|
|
162
|
+
return dest_path
|
|
163
|
+
|
|
164
|
+
@classmethod
|
|
165
|
+
def load(cls, source: Path | str | None = None) -> Manifest:
|
|
166
|
+
"""Loads a manifest from a file or workspace directory."""
|
|
167
|
+
if source is None:
|
|
168
|
+
src_path = Path(MANIFEST_FILE_NAME)
|
|
169
|
+
else:
|
|
170
|
+
src_path = Path(source)
|
|
171
|
+
if src_path.is_dir():
|
|
172
|
+
src_path = src_path / MANIFEST_FILE_NAME
|
|
173
|
+
|
|
174
|
+
if not src_path.is_file():
|
|
175
|
+
raise ManifestNotFoundError(f"Manifest not found at {src_path}")
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
data = json.loads(src_path.read_text(encoding="utf-8"))
|
|
179
|
+
except json.JSONDecodeError as e:
|
|
180
|
+
raise ManifestValidationError(f"Invalid JSON in manifest file: {e}")
|
|
181
|
+
|
|
182
|
+
return cls.from_dict(data)
|
|
183
|
+
|
|
184
|
+
@classmethod
|
|
185
|
+
def from_dict(cls, data: dict[str, Any]) -> Manifest:
|
|
186
|
+
"""Parses a dictionary into a Manifest instance."""
|
|
187
|
+
artifacts = ManifestArtifacts()
|
|
188
|
+
raw_artifacts = data.get("artifacts", {})
|
|
189
|
+
|
|
190
|
+
if isinstance(raw_artifacts, dict):
|
|
191
|
+
for p in raw_artifacts.get("prompts", []):
|
|
192
|
+
artifacts.prompts.append(PromptArtifact.from_dict(p))
|
|
193
|
+
for m in raw_artifacts.get("memory", []):
|
|
194
|
+
artifacts.memory.append(MemoryArtifact.from_dict(m))
|
|
195
|
+
for r in raw_artifacts.get("retrieval", []):
|
|
196
|
+
artifacts.retrieval.append(RetrievalArtifact.from_dict(r))
|
|
197
|
+
for t in raw_artifacts.get("tools", []):
|
|
198
|
+
artifacts.tools.append(ToolArtifact.from_dict(t))
|
|
199
|
+
if "model_config" in raw_artifacts and isinstance(raw_artifacts["model_config"], dict):
|
|
200
|
+
artifacts.model_config = ModelConfigArtifact.from_dict(raw_artifacts["model_config"])
|
|
201
|
+
for pol in raw_artifacts.get("policies", []):
|
|
202
|
+
artifacts.policies.append(PolicyArtifact.from_dict(pol))
|
|
203
|
+
|
|
204
|
+
return cls(
|
|
205
|
+
name=data.get("name", "ai-intelligence"),
|
|
206
|
+
version=data.get("version", DEFAULT_SPEC_VERSION),
|
|
207
|
+
description=data.get("description", ""),
|
|
208
|
+
artifacts=artifacts,
|
|
209
|
+
metadata=data.get("metadata", {}),
|
|
210
|
+
)
|
evolution/repository.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Repository operations for the Evolution Python SDK.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from evolution.exceptions import (
|
|
15
|
+
CommandExecutionError,
|
|
16
|
+
ManifestNotFoundError,
|
|
17
|
+
RepositoryAlreadyExistsError,
|
|
18
|
+
RepositoryNotFoundError,
|
|
19
|
+
)
|
|
20
|
+
from evolution.models.evaluation import EvaluationResult
|
|
21
|
+
from evolution.models.execution import Execution
|
|
22
|
+
from evolution.models.manifest import MANIFEST_FILE_NAME, Manifest
|
|
23
|
+
|
|
24
|
+
EVOLUTION_DIR = ".evolution"
|
|
25
|
+
EXECUTIONS_DIR = "executions"
|
|
26
|
+
EVALUATIONS_DIR = "evaluations"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class CommitInfo:
|
|
31
|
+
"""Represents an Intelligence Commit summary."""
|
|
32
|
+
id: str
|
|
33
|
+
tree_id: str = ""
|
|
34
|
+
parent_ids: list[str] = field(default_factory=list)
|
|
35
|
+
author: str = ""
|
|
36
|
+
timestamp: str = ""
|
|
37
|
+
message: str = ""
|
|
38
|
+
tags: list[str] = field(default_factory=list)
|
|
39
|
+
metadata: dict[str, str] = field(default_factory=dict)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class RepoStatus:
|
|
44
|
+
"""Represents the current repository working tree status."""
|
|
45
|
+
branch: str = "main"
|
|
46
|
+
head_commit: str = ""
|
|
47
|
+
is_clean: bool = True
|
|
48
|
+
staged_files: list[str] = field(default_factory=list)
|
|
49
|
+
modified_files: list[str] = field(default_factory=list)
|
|
50
|
+
untracked_files: list[str] = field(default_factory=list)
|
|
51
|
+
deleted_files: list[str] = field(default_factory=list)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Repository:
|
|
55
|
+
"""Interface to an Evolution intelligence repository."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, root_path: Path | str = "."):
|
|
58
|
+
self.root = Path(root_path).resolve()
|
|
59
|
+
self.evolution_dir = self.root / EVOLUTION_DIR
|
|
60
|
+
self._evo_bin = shutil.which("evo")
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def init(cls, path: Path | str = ".", name: str = "ai-intelligence") -> Repository:
|
|
64
|
+
"""Initializes a new Evolution repository at the target path."""
|
|
65
|
+
target = Path(path).resolve()
|
|
66
|
+
evo_dir = target / EVOLUTION_DIR
|
|
67
|
+
|
|
68
|
+
if evo_dir.is_dir():
|
|
69
|
+
raise RepositoryAlreadyExistsError(f"Evolution repository already exists at {target}")
|
|
70
|
+
|
|
71
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
repo = cls(target)
|
|
73
|
+
|
|
74
|
+
if repo._evo_bin:
|
|
75
|
+
repo._run_cli(["init"])
|
|
76
|
+
else:
|
|
77
|
+
# Native fallback initialization
|
|
78
|
+
evo_dir.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
(evo_dir / "objects").mkdir(exist_ok=True)
|
|
80
|
+
(evo_dir / "refs" / "heads").mkdir(parents=True, exist_ok=True)
|
|
81
|
+
(evo_dir / EXECUTIONS_DIR).mkdir(exist_ok=True)
|
|
82
|
+
(evo_dir / EVALUATIONS_DIR).mkdir(exist_ok=True)
|
|
83
|
+
(evo_dir / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
|
|
84
|
+
|
|
85
|
+
# Create starter manifest if not present
|
|
86
|
+
manifest_path = target / MANIFEST_FILE_NAME
|
|
87
|
+
if not manifest_path.is_file():
|
|
88
|
+
m = Manifest(name=name)
|
|
89
|
+
m.save(target)
|
|
90
|
+
|
|
91
|
+
return repo
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def open(cls, path: Path | str = ".") -> Repository:
|
|
95
|
+
"""Opens an existing Evolution repository."""
|
|
96
|
+
target = Path(path).resolve()
|
|
97
|
+
# Search upward if not directly in repo root
|
|
98
|
+
cur = target
|
|
99
|
+
while cur != cur.parent:
|
|
100
|
+
if (cur / EVOLUTION_DIR).is_dir():
|
|
101
|
+
return cls(cur)
|
|
102
|
+
cur = cur.parent
|
|
103
|
+
|
|
104
|
+
raise RepositoryNotFoundError(f"No Evolution repository found at or above {target}")
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def is_valid(self) -> bool:
|
|
108
|
+
"""Checks if this repository directory contains an initialized .evolution structure."""
|
|
109
|
+
return self.evolution_dir.is_dir()
|
|
110
|
+
|
|
111
|
+
def _run_cli(self, args: list[str]) -> str:
|
|
112
|
+
"""Runs the evo CLI in the workspace root if available."""
|
|
113
|
+
if not self._evo_bin:
|
|
114
|
+
raise CommandExecutionError("evo", 127, "Evolution CLI binary 'evo' not found in PATH")
|
|
115
|
+
|
|
116
|
+
cmd = [self._evo_bin] + args
|
|
117
|
+
try:
|
|
118
|
+
result = subprocess.run(
|
|
119
|
+
cmd,
|
|
120
|
+
cwd=str(self.root),
|
|
121
|
+
capture_output=True,
|
|
122
|
+
text=True,
|
|
123
|
+
check=False,
|
|
124
|
+
)
|
|
125
|
+
except Exception as e:
|
|
126
|
+
raise CommandExecutionError(" ".join(cmd), 1, str(e))
|
|
127
|
+
|
|
128
|
+
if result.returncode != 0:
|
|
129
|
+
raise CommandExecutionError(" ".join(cmd), result.returncode, result.stderr)
|
|
130
|
+
|
|
131
|
+
return result.stdout.strip()
|
|
132
|
+
|
|
133
|
+
# --- Manifest Operations ---
|
|
134
|
+
|
|
135
|
+
def get_manifest(self) -> Manifest:
|
|
136
|
+
"""Loads and returns the current workspace Intelligence Manifest."""
|
|
137
|
+
manifest_path = self.root / MANIFEST_FILE_NAME
|
|
138
|
+
if not manifest_path.is_file():
|
|
139
|
+
raise ManifestNotFoundError(f"Manifest not found in repository at {manifest_path}")
|
|
140
|
+
return Manifest.load(manifest_path)
|
|
141
|
+
|
|
142
|
+
def save_manifest(self, manifest: Manifest, auto_hash: bool = True) -> Path:
|
|
143
|
+
"""Saves a manifest to the repository workspace, optionally auto-hashing artifacts."""
|
|
144
|
+
if auto_hash:
|
|
145
|
+
manifest.compute_hashes(workspace_root=self.root)
|
|
146
|
+
manifest.validate()
|
|
147
|
+
return manifest.save(self.root)
|
|
148
|
+
|
|
149
|
+
# --- Git-Like VCS Operations ---
|
|
150
|
+
|
|
151
|
+
def add(self, *paths: str) -> None:
|
|
152
|
+
"""Stages file paths for the next commit."""
|
|
153
|
+
if self._evo_bin:
|
|
154
|
+
args = ["add"] + list(paths)
|
|
155
|
+
self._run_cli(args)
|
|
156
|
+
|
|
157
|
+
def commit(
|
|
158
|
+
self,
|
|
159
|
+
message: str,
|
|
160
|
+
author: str | None = None,
|
|
161
|
+
tags: list[str] | None = None,
|
|
162
|
+
metadata: dict[str, str] | None = None,
|
|
163
|
+
auto_stage_all: bool = True,
|
|
164
|
+
) -> CommitInfo:
|
|
165
|
+
"""Creates an Intelligence Commit capturing the current intelligence state."""
|
|
166
|
+
# Auto-compute hashes on manifest before commit if manifest exists
|
|
167
|
+
manifest_path = self.root / MANIFEST_FILE_NAME
|
|
168
|
+
if manifest_path.is_file():
|
|
169
|
+
try:
|
|
170
|
+
m = self.get_manifest()
|
|
171
|
+
self.save_manifest(m, auto_hash=True)
|
|
172
|
+
except Exception:
|
|
173
|
+
pass
|
|
174
|
+
|
|
175
|
+
if auto_stage_all and self._evo_bin:
|
|
176
|
+
self._run_cli(["add", "."])
|
|
177
|
+
|
|
178
|
+
if self._evo_bin:
|
|
179
|
+
args = ["commit", "-m", message]
|
|
180
|
+
if author:
|
|
181
|
+
args += ["--author", author]
|
|
182
|
+
if tags:
|
|
183
|
+
for t in tags:
|
|
184
|
+
args += ["--tag", t]
|
|
185
|
+
if metadata:
|
|
186
|
+
for k, v in metadata.items():
|
|
187
|
+
args += ["--meta", f"{k}={v}"]
|
|
188
|
+
|
|
189
|
+
out = self._run_cli(args)
|
|
190
|
+
# Parse commit ID from output e.g. "[main 895c933] commit message" or "commit: 895c933..."
|
|
191
|
+
commit_id = "unknown"
|
|
192
|
+
for line in out.splitlines():
|
|
193
|
+
if "[" in line and "]" in line:
|
|
194
|
+
parts = line.split("[")[1].split("]")[0].split()
|
|
195
|
+
if len(parts) >= 2:
|
|
196
|
+
commit_id = parts[1]
|
|
197
|
+
elif line.startswith("commit "):
|
|
198
|
+
commit_id = line.split()[1]
|
|
199
|
+
|
|
200
|
+
return CommitInfo(id=commit_id, message=message, author=author or "", tags=tags or [], metadata=metadata or {})
|
|
201
|
+
|
|
202
|
+
# Fallback simulation when running in pure-python mode without evo binary
|
|
203
|
+
fake_id = "py-" + message[:8].replace(" ", "_")
|
|
204
|
+
return CommitInfo(id=fake_id, message=message, author=author or "", tags=tags or [], metadata=metadata or {})
|
|
205
|
+
|
|
206
|
+
def status(self) -> RepoStatus:
|
|
207
|
+
"""Returns the current working tree and branch status."""
|
|
208
|
+
if self._evo_bin:
|
|
209
|
+
out = self._run_cli(["status"])
|
|
210
|
+
status = RepoStatus()
|
|
211
|
+
for line in out.splitlines():
|
|
212
|
+
if line.startswith("On branch "):
|
|
213
|
+
status.branch = line.replace("On branch ", "").strip()
|
|
214
|
+
elif "nothing to commit, working tree clean" in line:
|
|
215
|
+
status.is_clean = True
|
|
216
|
+
return status
|
|
217
|
+
return RepoStatus(branch="main", is_clean=True)
|
|
218
|
+
|
|
219
|
+
def diff(self, rev1: str | None = None, rev2: str | None = None) -> str:
|
|
220
|
+
"""Renders unified diff between revisions or working tree."""
|
|
221
|
+
if self._evo_bin:
|
|
222
|
+
args = ["diff"]
|
|
223
|
+
if rev1:
|
|
224
|
+
args.append(rev1)
|
|
225
|
+
if rev2:
|
|
226
|
+
args.append(rev2)
|
|
227
|
+
return self._run_cli(args)
|
|
228
|
+
return ""
|
|
229
|
+
|
|
230
|
+
def checkout(self, target: str, file_path: str | None = None) -> str:
|
|
231
|
+
"""Checks out a branch, commit, or restores a file snapshot."""
|
|
232
|
+
if self._evo_bin:
|
|
233
|
+
args = ["checkout", target]
|
|
234
|
+
if file_path:
|
|
235
|
+
args += ["--", file_path]
|
|
236
|
+
return self._run_cli(args)
|
|
237
|
+
return ""
|
|
238
|
+
|
|
239
|
+
# --- Execution Recording Operations ---
|
|
240
|
+
|
|
241
|
+
def record_execution(
|
|
242
|
+
self,
|
|
243
|
+
inputs: str,
|
|
244
|
+
outputs: str,
|
|
245
|
+
duration_ms: int = 0,
|
|
246
|
+
prompt_tokens: int = 0,
|
|
247
|
+
completion_tokens: int = 0,
|
|
248
|
+
commit_id: str | None = None,
|
|
249
|
+
metadata: dict[str, Any] | None = None,
|
|
250
|
+
) -> Execution:
|
|
251
|
+
"""Records an AI system execution linked to the current HEAD commit snapshot."""
|
|
252
|
+
from evolution.models.execution import TokenUsage
|
|
253
|
+
|
|
254
|
+
if not commit_id:
|
|
255
|
+
# Attempt to resolve current HEAD commit
|
|
256
|
+
head_file = self.evolution_dir / "HEAD"
|
|
257
|
+
if head_file.is_file():
|
|
258
|
+
head_ref = head_file.read_text().strip()
|
|
259
|
+
if head_ref.startswith("ref: "):
|
|
260
|
+
ref_path = self.evolution_dir / head_ref[5:]
|
|
261
|
+
if ref_path.is_file():
|
|
262
|
+
commit_id = ref_path.read_text().strip()
|
|
263
|
+
else:
|
|
264
|
+
commit_id = "uncommitted"
|
|
265
|
+
else:
|
|
266
|
+
commit_id = head_ref
|
|
267
|
+
else:
|
|
268
|
+
commit_id = "uncommitted"
|
|
269
|
+
|
|
270
|
+
exec_obj = Execution(
|
|
271
|
+
commit_id=commit_id,
|
|
272
|
+
inputs=inputs,
|
|
273
|
+
outputs=outputs,
|
|
274
|
+
duration_ms=duration_ms,
|
|
275
|
+
tokens=TokenUsage(prompt_tokens=prompt_tokens, completion_tokens=completion_tokens),
|
|
276
|
+
metadata=metadata or {},
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
self.save_execution(exec_obj)
|
|
280
|
+
return exec_obj
|
|
281
|
+
|
|
282
|
+
def save_execution(self, execution: Execution) -> Path:
|
|
283
|
+
"""Saves an execution record to .evolution/executions/<id>.json."""
|
|
284
|
+
exec_dir = self.evolution_dir / EXECUTIONS_DIR
|
|
285
|
+
exec_dir.mkdir(parents=True, exist_ok=True)
|
|
286
|
+
file_path = exec_dir / f"{execution.id}.json"
|
|
287
|
+
file_path.write_text(json.dumps(execution.to_dict(), indent=2) + "\n", encoding="utf-8")
|
|
288
|
+
return file_path
|
|
289
|
+
|
|
290
|
+
def get_execution(self, execution_id: str) -> Execution:
|
|
291
|
+
"""Loads an execution record by ID."""
|
|
292
|
+
file_path = self.evolution_dir / EXECUTIONS_DIR / f"{execution_id}.json"
|
|
293
|
+
if not file_path.is_file():
|
|
294
|
+
# Try prefix match
|
|
295
|
+
exec_dir = self.evolution_dir / EXECUTIONS_DIR
|
|
296
|
+
if exec_dir.is_dir():
|
|
297
|
+
matches = list(exec_dir.glob(f"{execution_id}*.json"))
|
|
298
|
+
if len(matches) == 1:
|
|
299
|
+
file_path = matches[0]
|
|
300
|
+
elif len(matches) > 1:
|
|
301
|
+
raise ValueError(f"Ambiguous execution ID prefix '{execution_id}'")
|
|
302
|
+
|
|
303
|
+
if not file_path.is_file():
|
|
304
|
+
raise FileNotFoundError(f"Execution '{execution_id}' not found")
|
|
305
|
+
|
|
306
|
+
data = json.loads(file_path.read_text(encoding="utf-8"))
|
|
307
|
+
return Execution.from_dict(data)
|
|
308
|
+
|
|
309
|
+
def list_executions(self) -> list[Execution]:
|
|
310
|
+
"""Lists all recorded executions in reverse chronological order."""
|
|
311
|
+
exec_dir = self.evolution_dir / EXECUTIONS_DIR
|
|
312
|
+
if not exec_dir.is_dir():
|
|
313
|
+
return []
|
|
314
|
+
|
|
315
|
+
executions: list[Execution] = []
|
|
316
|
+
for file in exec_dir.glob("*.json"):
|
|
317
|
+
try:
|
|
318
|
+
data = json.loads(file.read_text(encoding="utf-8"))
|
|
319
|
+
executions.append(Execution.from_dict(data))
|
|
320
|
+
except Exception:
|
|
321
|
+
continue
|
|
322
|
+
|
|
323
|
+
executions.sort(key=lambda e: e.timestamp, reverse=True)
|
|
324
|
+
return executions
|
|
325
|
+
|
|
326
|
+
# --- Evaluation Operations ---
|
|
327
|
+
|
|
328
|
+
def save_evaluation(self, evaluation: EvaluationResult) -> Path:
|
|
329
|
+
"""Saves an evaluation result to .evolution/evaluations/<id>.json."""
|
|
330
|
+
eval_dir = self.evolution_dir / EVALUATIONS_DIR
|
|
331
|
+
eval_dir.mkdir(parents=True, exist_ok=True)
|
|
332
|
+
file_path = eval_dir / f"{evaluation.id}.json"
|
|
333
|
+
file_path.write_text(json.dumps(evaluation.to_dict(), indent=2) + "\n", encoding="utf-8")
|
|
334
|
+
return file_path
|
|
335
|
+
|
|
336
|
+
def get_evaluation(self, evaluation_id: str) -> EvaluationResult:
|
|
337
|
+
"""Loads an evaluation report by ID."""
|
|
338
|
+
file_path = self.evolution_dir / EVALUATIONS_DIR / f"{evaluation_id}.json"
|
|
339
|
+
if not file_path.is_file():
|
|
340
|
+
raise FileNotFoundError(f"Evaluation '{evaluation_id}' not found")
|
|
341
|
+
|
|
342
|
+
data = json.loads(file_path.read_text(encoding="utf-8"))
|
|
343
|
+
return EvaluationResult.from_dict(data)
|
|
344
|
+
|
|
345
|
+
def list_evaluations(self) -> list[EvaluationResult]:
|
|
346
|
+
"""Lists all recorded evaluations."""
|
|
347
|
+
eval_dir = self.evolution_dir / EVALUATIONS_DIR
|
|
348
|
+
if not eval_dir.is_dir():
|
|
349
|
+
return []
|
|
350
|
+
|
|
351
|
+
evaluations: list[EvaluationResult] = []
|
|
352
|
+
for file in eval_dir.glob("*.json"):
|
|
353
|
+
try:
|
|
354
|
+
data = json.loads(file.read_text(encoding="utf-8"))
|
|
355
|
+
evaluations.append(EvaluationResult.from_dict(data))
|
|
356
|
+
except Exception:
|
|
357
|
+
continue
|
|
358
|
+
|
|
359
|
+
evaluations.sort(key=lambda e: e.timestamp, reverse=True)
|
|
360
|
+
return evaluations
|