copex 0.8.4__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.
copex/__init__.py ADDED
@@ -0,0 +1,69 @@
1
+ """Copex - Copilot Extended: A resilient wrapper for GitHub Copilot SDK."""
2
+
3
+ # Checkpointing
4
+ from copex.checkpoint import Checkpoint, CheckpointedRalph, CheckpointStore
5
+ from copex.client import Copex
6
+ from copex.config import CopexConfig, find_copilot_cli
7
+
8
+ # MCP integration
9
+ from copex.mcp import MCPClient, MCPManager, MCPServerConfig, MCPTool, load_mcp_config
10
+
11
+ # Metrics
12
+ from copex.metrics import MetricsCollector, RequestMetrics, SessionMetrics, get_collector
13
+ from copex.models import Model, ReasoningEffort
14
+
15
+ # Persistence
16
+ from copex.persistence import Message, PersistentSession, SessionData, SessionStore
17
+
18
+ # Plan mode
19
+ from copex.plan import Plan, PlanExecutor, PlanStep, StepStatus
20
+
21
+ # Ralph Wiggum loops
22
+ from copex.ralph import RalphConfig, RalphState, RalphWiggum, ralph_loop
23
+
24
+ # Parallel tools
25
+ from copex.tools import ParallelToolExecutor, ToolRegistry, ToolResult
26
+
27
+ __all__ = [
28
+ # Core
29
+ "Copex",
30
+ "CopexConfig",
31
+ "Model",
32
+ "ReasoningEffort",
33
+ "find_copilot_cli",
34
+ # Ralph
35
+ "RalphWiggum",
36
+ "RalphConfig",
37
+ "RalphState",
38
+ "ralph_loop",
39
+ # Plan
40
+ "Plan",
41
+ "PlanStep",
42
+ "PlanExecutor",
43
+ "StepStatus",
44
+ # Persistence
45
+ "SessionStore",
46
+ "PersistentSession",
47
+ "Message",
48
+ "SessionData",
49
+ # Checkpointing
50
+ "CheckpointStore",
51
+ "Checkpoint",
52
+ "CheckpointedRalph",
53
+ # Metrics
54
+ "MetricsCollector",
55
+ "RequestMetrics",
56
+ "SessionMetrics",
57
+ "get_collector",
58
+ # Tools
59
+ "ToolRegistry",
60
+ "ParallelToolExecutor",
61
+ "ToolResult",
62
+ # MCP
63
+ "MCPClient",
64
+ "MCPManager",
65
+ "MCPServerConfig",
66
+ "MCPTool",
67
+ "load_mcp_config",
68
+ ]
69
+ __version__ = "0.4.2"
copex/checkpoint.py ADDED
@@ -0,0 +1,445 @@
1
+ """
2
+ Checkpointing - Save and restore Ralph loop state for crash recovery.
3
+
4
+ Enables:
5
+ - Resuming interrupted Ralph loops
6
+ - Crash recovery
7
+ - Inspection of loop progress
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from dataclasses import asdict, dataclass, field
14
+ from datetime import datetime
15
+ import logging
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ @dataclass
23
+ class Checkpoint:
24
+ """A checkpoint of Ralph loop state."""
25
+
26
+ # Identity
27
+ checkpoint_id: str
28
+ loop_id: str
29
+
30
+ # Loop state
31
+ prompt: str
32
+ iteration: int
33
+ max_iterations: int | None
34
+ completion_promise: str | None
35
+
36
+ # Timestamps
37
+ created_at: str
38
+ updated_at: str
39
+ started_at: str
40
+
41
+ # History
42
+ history: list[str] = field(default_factory=list)
43
+
44
+ # Status
45
+ completed: bool = False
46
+ completion_reason: str | None = None
47
+
48
+ # Metadata
49
+ model: str = "gpt-5.2-codex"
50
+ reasoning_effort: str = "xhigh"
51
+ metadata: dict[str, Any] = field(default_factory=dict)
52
+
53
+ def to_dict(self) -> dict[str, Any]:
54
+ """Convert to dictionary."""
55
+ return asdict(self)
56
+
57
+ @classmethod
58
+ def from_dict(cls, data: dict[str, Any]) -> "Checkpoint":
59
+ """Create from dictionary."""
60
+ return cls(**data)
61
+
62
+
63
+ class CheckpointStore:
64
+ """
65
+ Persistent storage for Ralph loop checkpoints.
66
+
67
+ Usage:
68
+ store = CheckpointStore()
69
+
70
+ # Create checkpoint
71
+ cp = store.create("my-loop", prompt, iteration, ...)
72
+
73
+ # Update on each iteration
74
+ store.update(cp.checkpoint_id, iteration=5, history=[...])
75
+
76
+ # Resume after crash
77
+ cp = store.get_latest("my-loop")
78
+ """
79
+
80
+ def __init__(self, base_dir: Path | str | None = None):
81
+ """
82
+ Initialize checkpoint store.
83
+
84
+ Args:
85
+ base_dir: Directory for checkpoint files. Defaults to ~/.copex/checkpoints
86
+ """
87
+ if base_dir is None:
88
+ base_dir = Path.home() / ".copex" / "checkpoints"
89
+ self.base_dir = Path(base_dir)
90
+ self.base_dir.mkdir(parents=True, exist_ok=True)
91
+
92
+ def _checkpoint_path(self, checkpoint_id: str) -> Path:
93
+ """Get path for a checkpoint file."""
94
+ safe_id = "".join(c if c.isalnum() or c in "-_" else "_" for c in checkpoint_id)
95
+ return self.base_dir / f"{safe_id}.json"
96
+
97
+ def create(
98
+ self,
99
+ loop_id: str,
100
+ prompt: str,
101
+ max_iterations: int | None = None,
102
+ completion_promise: str | None = None,
103
+ model: str = "gpt-5.2-codex",
104
+ reasoning_effort: str = "xhigh",
105
+ metadata: dict[str, Any] | None = None,
106
+ ) -> Checkpoint:
107
+ """
108
+ Create a new checkpoint.
109
+
110
+ Args:
111
+ loop_id: Identifier for this loop (e.g., project name)
112
+ prompt: The loop prompt
113
+ max_iterations: Maximum iterations
114
+ completion_promise: Completion promise text
115
+ model: Model being used
116
+ reasoning_effort: Reasoning effort level
117
+ metadata: Additional metadata
118
+
119
+ Returns:
120
+ New Checkpoint object
121
+ """
122
+ now = datetime.now().isoformat()
123
+ checkpoint_id = f"{loop_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
124
+
125
+ checkpoint = Checkpoint(
126
+ checkpoint_id=checkpoint_id,
127
+ loop_id=loop_id,
128
+ prompt=prompt,
129
+ iteration=0,
130
+ max_iterations=max_iterations,
131
+ completion_promise=completion_promise,
132
+ created_at=now,
133
+ updated_at=now,
134
+ started_at=now,
135
+ model=model,
136
+ reasoning_effort=reasoning_effort,
137
+ metadata=metadata or {},
138
+ )
139
+
140
+ self._save(checkpoint)
141
+ return checkpoint
142
+
143
+ def _save(self, checkpoint: Checkpoint) -> None:
144
+ """Save checkpoint to disk."""
145
+ path = self._checkpoint_path(checkpoint.checkpoint_id)
146
+ with open(path, "w", encoding="utf-8") as f:
147
+ json.dump(checkpoint.to_dict(), f, indent=2, ensure_ascii=False)
148
+
149
+ def update(
150
+ self,
151
+ checkpoint_id: str,
152
+ iteration: int | None = None,
153
+ history: list[str] | None = None,
154
+ completed: bool | None = None,
155
+ completion_reason: str | None = None,
156
+ metadata: dict[str, Any] | None = None,
157
+ ) -> Checkpoint | None:
158
+ """
159
+ Update an existing checkpoint.
160
+
161
+ Returns:
162
+ Updated checkpoint, or None if not found
163
+ """
164
+ checkpoint = self.load(checkpoint_id)
165
+ if not checkpoint:
166
+ return None
167
+
168
+ checkpoint.updated_at = datetime.now().isoformat()
169
+
170
+ if iteration is not None:
171
+ checkpoint.iteration = iteration
172
+ if history is not None:
173
+ checkpoint.history = history
174
+ if completed is not None:
175
+ checkpoint.completed = completed
176
+ if completion_reason is not None:
177
+ checkpoint.completion_reason = completion_reason
178
+ if metadata is not None:
179
+ checkpoint.metadata.update(metadata)
180
+
181
+ self._save(checkpoint)
182
+ return checkpoint
183
+
184
+ def load(self, checkpoint_id: str) -> Checkpoint | None:
185
+ """Load a checkpoint by ID."""
186
+ path = self._checkpoint_path(checkpoint_id)
187
+ if not path.exists():
188
+ return None
189
+
190
+ with open(path, "r", encoding="utf-8") as f:
191
+ data = json.load(f)
192
+
193
+ return Checkpoint.from_dict(data)
194
+
195
+ def get_latest(self, loop_id: str) -> Checkpoint | None:
196
+ """
197
+ Get the latest checkpoint for a loop.
198
+
199
+ Args:
200
+ loop_id: The loop identifier
201
+
202
+ Returns:
203
+ Latest checkpoint, or None if none found
204
+ """
205
+ checkpoints = self.list_checkpoints(loop_id=loop_id)
206
+ if not checkpoints:
207
+ return None
208
+
209
+ # Already sorted by updated_at descending
210
+ latest_id = checkpoints[0]["checkpoint_id"]
211
+ return self.load(latest_id)
212
+
213
+ def get_incomplete(self, loop_id: str | None = None) -> list[Checkpoint]:
214
+ """
215
+ Get all incomplete checkpoints.
216
+
217
+ Args:
218
+ loop_id: Optional filter by loop ID
219
+
220
+ Returns:
221
+ List of incomplete checkpoints
222
+ """
223
+ result = []
224
+ for path in self.base_dir.glob("*.json"):
225
+ try:
226
+ with open(path, "r", encoding="utf-8") as f:
227
+ data = json.load(f)
228
+
229
+ if data.get("completed", False):
230
+ continue
231
+
232
+ if loop_id and data.get("loop_id") != loop_id:
233
+ continue
234
+
235
+ result.append(Checkpoint.from_dict(data))
236
+ except (json.JSONDecodeError, KeyError):
237
+ logger.warning("Skipping invalid checkpoint file: %s", path, exc_info=True)
238
+ continue
239
+
240
+ # Sort by updated_at descending
241
+ result.sort(key=lambda x: x.updated_at, reverse=True)
242
+ return result
243
+
244
+ def delete(self, checkpoint_id: str) -> bool:
245
+ """Delete a checkpoint."""
246
+ path = self._checkpoint_path(checkpoint_id)
247
+ if path.exists():
248
+ path.unlink()
249
+ return True
250
+ return False
251
+
252
+ def cleanup(self, loop_id: str | None = None, keep_latest: int = 5) -> int:
253
+ """
254
+ Clean up old checkpoints, keeping only the latest N.
255
+
256
+ Args:
257
+ loop_id: Optional filter by loop ID
258
+ keep_latest: Number of checkpoints to keep per loop
259
+
260
+ Returns:
261
+ Number of checkpoints deleted
262
+ """
263
+ # Group by loop_id
264
+ by_loop: dict[str, list[dict]] = {}
265
+ for cp in self.list_checkpoints():
266
+ lid = cp["loop_id"]
267
+ if loop_id and lid != loop_id:
268
+ continue
269
+ if lid not in by_loop:
270
+ by_loop[lid] = []
271
+ by_loop[lid].append(cp)
272
+
273
+ deleted = 0
274
+ for lid, checkpoints in by_loop.items():
275
+ # Sort by updated_at descending
276
+ checkpoints.sort(key=lambda x: x["updated_at"], reverse=True)
277
+
278
+ # Delete old ones
279
+ for cp in checkpoints[keep_latest:]:
280
+ if self.delete(cp["checkpoint_id"]):
281
+ deleted += 1
282
+
283
+ return deleted
284
+
285
+ def list_checkpoints(self, loop_id: str | None = None) -> list[dict[str, Any]]:
286
+ """
287
+ List all checkpoints.
288
+
289
+ Args:
290
+ loop_id: Optional filter by loop ID
291
+
292
+ Returns:
293
+ List of checkpoint summaries
294
+ """
295
+ checkpoints = []
296
+ for path in self.base_dir.glob("*.json"):
297
+ try:
298
+ with open(path, "r", encoding="utf-8") as f:
299
+ data = json.load(f)
300
+
301
+ if loop_id and data.get("loop_id") != loop_id:
302
+ continue
303
+
304
+ checkpoints.append({
305
+ "checkpoint_id": data["checkpoint_id"],
306
+ "loop_id": data["loop_id"],
307
+ "iteration": data["iteration"],
308
+ "max_iterations": data.get("max_iterations"),
309
+ "completed": data.get("completed", False),
310
+ "completion_reason": data.get("completion_reason"),
311
+ "created_at": data["created_at"],
312
+ "updated_at": data["updated_at"],
313
+ })
314
+ except (json.JSONDecodeError, KeyError):
315
+ logger.warning("Skipping invalid checkpoint file: %s", path, exc_info=True)
316
+ continue
317
+
318
+ # Sort by updated_at descending
319
+ checkpoints.sort(key=lambda x: x["updated_at"], reverse=True)
320
+ return checkpoints
321
+
322
+
323
+ class CheckpointedRalph:
324
+ """
325
+ Ralph Wiggum with automatic checkpointing.
326
+
327
+ Usage:
328
+ from copex import Copex
329
+ from copex.checkpoint import CheckpointedRalph, CheckpointStore
330
+
331
+ store = CheckpointStore()
332
+
333
+ async with Copex() as copex:
334
+ ralph = CheckpointedRalph(copex, store, loop_id="my-project")
335
+
336
+ # Start or resume a loop
337
+ result = await ralph.loop(
338
+ prompt="Build a REST API",
339
+ completion_promise="DONE",
340
+ )
341
+ """
342
+
343
+ def __init__(
344
+ self,
345
+ client: Any,
346
+ store: CheckpointStore,
347
+ loop_id: str,
348
+ ):
349
+ """
350
+ Initialize checkpointed Ralph.
351
+
352
+ Args:
353
+ client: Copex client
354
+ store: Checkpoint store
355
+ loop_id: Identifier for this loop
356
+ """
357
+ self.client = client
358
+ self.store = store
359
+ self.loop_id = loop_id
360
+ self._checkpoint: Checkpoint | None = None
361
+
362
+ async def loop(
363
+ self,
364
+ prompt: str,
365
+ *,
366
+ max_iterations: int | None = None,
367
+ completion_promise: str | None = None,
368
+ resume: bool = True,
369
+ ) -> Checkpoint:
370
+ """
371
+ Run a checkpointed Ralph loop.
372
+
373
+ Args:
374
+ prompt: Task prompt
375
+ max_iterations: Maximum iterations
376
+ completion_promise: Text that signals completion
377
+ resume: Whether to resume from last checkpoint if available
378
+
379
+ Returns:
380
+ Final checkpoint state
381
+ """
382
+ from copex.ralph import RalphConfig, RalphWiggum
383
+
384
+ # Check for existing checkpoint to resume
385
+ if resume:
386
+ existing = self.store.get_latest(self.loop_id)
387
+ if existing and not existing.completed:
388
+ self._checkpoint = existing
389
+ history = existing.history
390
+ else:
391
+ self._checkpoint = None
392
+ history = []
393
+ else:
394
+ history = []
395
+
396
+ # Create new checkpoint if needed
397
+ if not self._checkpoint:
398
+ model = self.client.config.model.value if hasattr(self.client.config.model, 'value') else str(self.client.config.model)
399
+ reasoning = self.client.config.reasoning_effort.value if hasattr(self.client.config.reasoning_effort, 'value') else str(self.client.config.reasoning_effort)
400
+
401
+ self._checkpoint = self.store.create(
402
+ loop_id=self.loop_id,
403
+ prompt=prompt,
404
+ max_iterations=max_iterations,
405
+ completion_promise=completion_promise,
406
+ model=model,
407
+ reasoning_effort=reasoning,
408
+ )
409
+
410
+ # Create Ralph with config
411
+ config = RalphConfig(
412
+ max_iterations=max_iterations,
413
+ completion_promise=completion_promise,
414
+ )
415
+ ralph = RalphWiggum(self.client, config)
416
+
417
+ # Set up iteration callback to save checkpoints
418
+ def on_iteration(iteration: int, response: str) -> None:
419
+ history.append(response)
420
+ self.store.update(
421
+ self._checkpoint.checkpoint_id,
422
+ iteration=iteration,
423
+ history=history,
424
+ )
425
+
426
+ def on_complete(state) -> None:
427
+ self.store.update(
428
+ self._checkpoint.checkpoint_id,
429
+ iteration=state.iteration,
430
+ completed=True,
431
+ completion_reason=state.completion_reason,
432
+ history=history,
433
+ )
434
+
435
+ # Run the loop
436
+ await ralph.loop(
437
+ prompt,
438
+ max_iterations=max_iterations,
439
+ completion_promise=completion_promise,
440
+ on_iteration=on_iteration,
441
+ on_complete=on_complete,
442
+ )
443
+
444
+ # Return updated checkpoint
445
+ return self.store.load(self._checkpoint.checkpoint_id)