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.
@@ -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
+ )
@@ -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