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/persistence.py ADDED
@@ -0,0 +1,324 @@
1
+ """
2
+ Session Persistence - Save and restore conversation history.
3
+
4
+ Allows saving sessions to disk and resuming later, useful for:
5
+ - Long-running tasks that span multiple sessions
6
+ - Crash recovery
7
+ - Sharing context between different runs
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
+ from copex.models import Model, ReasoningEffort
22
+
23
+
24
+ @dataclass
25
+ class Message:
26
+ """A message in the conversation history."""
27
+
28
+ role: str # "user", "assistant", "system"
29
+ content: str
30
+ timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
31
+ metadata: dict[str, Any] = field(default_factory=dict)
32
+
33
+
34
+ @dataclass
35
+ class SessionData:
36
+ """Persistent session data."""
37
+
38
+ id: str
39
+ created_at: str
40
+ updated_at: str
41
+ model: str
42
+ reasoning_effort: str
43
+ messages: list[Message]
44
+ metadata: dict[str, Any] = field(default_factory=dict)
45
+
46
+ def to_dict(self) -> dict[str, Any]:
47
+ """Convert to dictionary for serialization."""
48
+ return {
49
+ "id": self.id,
50
+ "created_at": self.created_at,
51
+ "updated_at": self.updated_at,
52
+ "model": self.model,
53
+ "reasoning_effort": self.reasoning_effort,
54
+ "messages": [asdict(m) for m in self.messages],
55
+ "metadata": self.metadata,
56
+ }
57
+
58
+ @classmethod
59
+ def from_dict(cls, data: dict[str, Any]) -> "SessionData":
60
+ """Create from dictionary."""
61
+ messages = [Message(**m) for m in data.get("messages", [])]
62
+ return cls(
63
+ id=data["id"],
64
+ created_at=data["created_at"],
65
+ updated_at=data["updated_at"],
66
+ model=data["model"],
67
+ reasoning_effort=data["reasoning_effort"],
68
+ messages=messages,
69
+ metadata=data.get("metadata", {}),
70
+ )
71
+
72
+
73
+ class SessionStore:
74
+ """
75
+ Persistent storage for Copex sessions.
76
+
77
+ Usage:
78
+ store = SessionStore()
79
+
80
+ # Save a session
81
+ store.save(session_id, messages, model, reasoning)
82
+
83
+ # Load a session
84
+ data = store.load(session_id)
85
+
86
+ # List all sessions
87
+ sessions = store.list_sessions()
88
+ """
89
+
90
+ def __init__(self, base_dir: Path | str | None = None):
91
+ """
92
+ Initialize session store.
93
+
94
+ Args:
95
+ base_dir: Directory for session files. Defaults to ~/.copex/sessions
96
+ """
97
+ if base_dir is None:
98
+ base_dir = Path.home() / ".copex" / "sessions"
99
+ self.base_dir = Path(base_dir)
100
+ self.base_dir.mkdir(parents=True, exist_ok=True)
101
+
102
+ def _session_path(self, session_id: str) -> Path:
103
+ """Get path for a session file."""
104
+ # Sanitize session ID for filesystem
105
+ safe_id = "".join(c if c.isalnum() or c in "-_" else "_" for c in session_id)
106
+ return self.base_dir / f"{safe_id}.json"
107
+
108
+ def save(
109
+ self,
110
+ session_id: str,
111
+ messages: list[Message],
112
+ model: Model | str,
113
+ reasoning_effort: ReasoningEffort | str,
114
+ metadata: dict[str, Any] | None = None,
115
+ ) -> Path:
116
+ """
117
+ Save a session to disk.
118
+
119
+ Returns:
120
+ Path to the saved session file
121
+ """
122
+ path = self._session_path(session_id)
123
+ now = datetime.now().isoformat()
124
+
125
+ # Check if session exists
126
+ if path.exists():
127
+ existing = self.load(session_id)
128
+ created_at = existing.created_at if existing else now
129
+ else:
130
+ created_at = now
131
+
132
+ data = SessionData(
133
+ id=session_id,
134
+ created_at=created_at,
135
+ updated_at=now,
136
+ model=model.value if isinstance(model, Model) else model,
137
+ reasoning_effort=(
138
+ reasoning_effort.value
139
+ if isinstance(reasoning_effort, ReasoningEffort)
140
+ else reasoning_effort
141
+ ),
142
+ messages=messages,
143
+ metadata=metadata or {},
144
+ )
145
+
146
+ with open(path, "w", encoding="utf-8") as f:
147
+ json.dump(data.to_dict(), f, indent=2, ensure_ascii=False)
148
+
149
+ return path
150
+
151
+ def load(self, session_id: str) -> SessionData | None:
152
+ """Load a session from disk."""
153
+ path = self._session_path(session_id)
154
+ if not path.exists():
155
+ return None
156
+
157
+ with open(path, "r", encoding="utf-8") as f:
158
+ data = json.load(f)
159
+
160
+ return SessionData.from_dict(data)
161
+
162
+ def delete(self, session_id: str) -> bool:
163
+ """Delete a session."""
164
+ path = self._session_path(session_id)
165
+ if path.exists():
166
+ path.unlink()
167
+ return True
168
+ return False
169
+
170
+ def list_sessions(self) -> list[dict[str, Any]]:
171
+ """
172
+ List all saved sessions.
173
+
174
+ Returns:
175
+ List of session summaries (id, created_at, updated_at, message_count)
176
+ """
177
+ sessions = []
178
+ for path in self.base_dir.glob("*.json"):
179
+ try:
180
+ with open(path, "r", encoding="utf-8") as f:
181
+ data = json.load(f)
182
+ sessions.append({
183
+ "id": data["id"],
184
+ "created_at": data["created_at"],
185
+ "updated_at": data["updated_at"],
186
+ "model": data["model"],
187
+ "message_count": len(data.get("messages", [])),
188
+ })
189
+ except (json.JSONDecodeError, KeyError):
190
+ logger.warning("Skipping invalid session file: %s", path, exc_info=True)
191
+ continue
192
+
193
+ # Sort by updated_at descending
194
+ sessions.sort(key=lambda x: x["updated_at"], reverse=True)
195
+ return sessions
196
+
197
+ def export(self, session_id: str, format: str = "json") -> str:
198
+ """
199
+ Export a session to a string.
200
+
201
+ Args:
202
+ session_id: Session to export
203
+ format: Export format ("json" or "markdown")
204
+
205
+ Returns:
206
+ Exported session as string
207
+ """
208
+ data = self.load(session_id)
209
+ if not data:
210
+ raise ValueError(f"Session not found: {session_id}")
211
+
212
+ if format == "json":
213
+ return json.dumps(data.to_dict(), indent=2, ensure_ascii=False)
214
+
215
+ elif format == "markdown":
216
+ lines = [
217
+ f"# Session: {data.id}",
218
+ "",
219
+ f"- **Model**: {data.model}",
220
+ f"- **Reasoning**: {data.reasoning_effort}",
221
+ f"- **Created**: {data.created_at}",
222
+ f"- **Updated**: {data.updated_at}",
223
+ "",
224
+ "---",
225
+ "",
226
+ ]
227
+
228
+ for msg in data.messages:
229
+ role_label = {"user": "👤 User", "assistant": "🤖 Assistant", "system": "⚙️ System"}.get(
230
+ msg.role, msg.role
231
+ )
232
+ lines.append(f"### {role_label}")
233
+ lines.append(f"*{msg.timestamp}*")
234
+ lines.append("")
235
+ lines.append(msg.content)
236
+ lines.append("")
237
+ lines.append("---")
238
+ lines.append("")
239
+
240
+ return "\n".join(lines)
241
+
242
+ else:
243
+ raise ValueError(f"Unknown format: {format}")
244
+
245
+
246
+ class PersistentSession:
247
+ """
248
+ A session wrapper that auto-saves to disk.
249
+
250
+ Usage:
251
+ session = PersistentSession("my-project", store)
252
+ session.add_user_message("Hello")
253
+ session.add_assistant_message("Hi there!")
254
+ # Automatically saved after each message
255
+ """
256
+
257
+ def __init__(
258
+ self,
259
+ session_id: str,
260
+ store: SessionStore,
261
+ model: Model | str = Model.CLAUDE_OPUS_4_5,
262
+ reasoning_effort: ReasoningEffort | str = ReasoningEffort.XHIGH,
263
+ auto_save: bool = True,
264
+ ):
265
+ self.session_id = session_id
266
+ self.store = store
267
+ self.model = model
268
+ self.reasoning_effort = reasoning_effort
269
+ self.auto_save = auto_save
270
+ self.messages: list[Message] = []
271
+ self.metadata: dict[str, Any] = {}
272
+
273
+ # Load existing session if it exists
274
+ existing = store.load(session_id)
275
+ if existing:
276
+ self.messages = existing.messages
277
+ self.metadata = existing.metadata
278
+ self.model = existing.model
279
+ self.reasoning_effort = existing.reasoning_effort
280
+
281
+ def add_message(self, role: str, content: str, metadata: dict[str, Any] | None = None) -> None:
282
+ """Add a message to the session."""
283
+ msg = Message(role=role, content=content, metadata=metadata or {})
284
+ self.messages.append(msg)
285
+ if self.auto_save:
286
+ self.save()
287
+
288
+ def add_user_message(self, content: str) -> None:
289
+ """Add a user message."""
290
+ self.add_message("user", content)
291
+
292
+ def add_assistant_message(self, content: str, reasoning: str | None = None) -> None:
293
+ """Add an assistant message."""
294
+ metadata = {"reasoning": reasoning} if reasoning else {}
295
+ self.add_message("assistant", content, metadata)
296
+
297
+ def save(self) -> Path:
298
+ """Save the session to disk."""
299
+ return self.store.save(
300
+ self.session_id,
301
+ self.messages,
302
+ self.model,
303
+ self.reasoning_effort,
304
+ self.metadata,
305
+ )
306
+
307
+ def clear(self) -> None:
308
+ """Clear all messages (keeps session file)."""
309
+ self.messages = []
310
+ if self.auto_save:
311
+ self.save()
312
+
313
+ def get_context(self, max_messages: int | None = None) -> list[dict[str, str]]:
314
+ """
315
+ Get messages formatted for API context.
316
+
317
+ Args:
318
+ max_messages: Limit to last N messages (None = all)
319
+
320
+ Returns:
321
+ List of {"role": ..., "content": ...} dicts
322
+ """
323
+ messages = self.messages[-max_messages:] if max_messages else self.messages
324
+ return [{"role": m.role, "content": m.content} for m in messages]
copex/plan.py ADDED
@@ -0,0 +1,358 @@
1
+ """
2
+ Plan Mode - Step-by-step task planning and execution for Copex.
3
+
4
+ Provides structured planning capabilities:
5
+ - Generate step-by-step plans from task descriptions
6
+ - Execute plans step by step with progress tracking
7
+ - Interactive review before execution
8
+ - Resume execution from specific steps
9
+ - Save/load plans to files
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import re
16
+ from dataclasses import dataclass, field
17
+ from datetime import datetime
18
+ from enum import Enum
19
+ from pathlib import Path
20
+ from typing import Any, Callable
21
+
22
+
23
+ class StepStatus(Enum):
24
+ """Status of a plan step."""
25
+
26
+ PENDING = "pending"
27
+ RUNNING = "running"
28
+ COMPLETED = "completed"
29
+ FAILED = "failed"
30
+ SKIPPED = "skipped"
31
+
32
+
33
+ @dataclass
34
+ class PlanStep:
35
+ """A single step in a plan."""
36
+
37
+ number: int
38
+ description: str
39
+ status: StepStatus = StepStatus.PENDING
40
+ result: str | None = None
41
+ error: str | None = None
42
+ started_at: datetime | None = None
43
+ completed_at: datetime | None = None
44
+
45
+ def to_dict(self) -> dict[str, Any]:
46
+ """Convert step to dictionary for serialization."""
47
+ return {
48
+ "number": self.number,
49
+ "description": self.description,
50
+ "status": self.status.value,
51
+ "result": self.result,
52
+ "error": self.error,
53
+ "started_at": self.started_at.isoformat() if self.started_at else None,
54
+ "completed_at": self.completed_at.isoformat() if self.completed_at else None,
55
+ }
56
+
57
+ @classmethod
58
+ def from_dict(cls, data: dict[str, Any]) -> PlanStep:
59
+ """Create step from dictionary."""
60
+ return cls(
61
+ number=data["number"],
62
+ description=data["description"],
63
+ status=StepStatus(data.get("status", "pending")),
64
+ result=data.get("result"),
65
+ error=data.get("error"),
66
+ started_at=datetime.fromisoformat(data["started_at"]) if data.get("started_at") else None,
67
+ completed_at=datetime.fromisoformat(data["completed_at"]) if data.get("completed_at") else None,
68
+ )
69
+
70
+
71
+ @dataclass
72
+ class Plan:
73
+ """A complete execution plan."""
74
+
75
+ task: str
76
+ steps: list[PlanStep] = field(default_factory=list)
77
+ created_at: datetime = field(default_factory=datetime.now)
78
+ completed_at: datetime | None = None
79
+
80
+ @property
81
+ def is_complete(self) -> bool:
82
+ """Check if all steps are completed."""
83
+ return all(
84
+ step.status in (StepStatus.COMPLETED, StepStatus.SKIPPED)
85
+ for step in self.steps
86
+ )
87
+
88
+ @property
89
+ def current_step(self) -> PlanStep | None:
90
+ """Get the next pending step."""
91
+ for step in self.steps:
92
+ if step.status == StepStatus.PENDING:
93
+ return step
94
+ return None
95
+
96
+ @property
97
+ def completed_count(self) -> int:
98
+ """Count of completed steps."""
99
+ return sum(
100
+ 1 for step in self.steps
101
+ if step.status in (StepStatus.COMPLETED, StepStatus.SKIPPED)
102
+ )
103
+
104
+ @property
105
+ def failed_count(self) -> int:
106
+ """Count of failed steps."""
107
+ return sum(1 for step in self.steps if step.status == StepStatus.FAILED)
108
+
109
+ def to_dict(self) -> dict[str, Any]:
110
+ """Convert plan to dictionary for serialization."""
111
+ return {
112
+ "task": self.task,
113
+ "steps": [step.to_dict() for step in self.steps],
114
+ "created_at": self.created_at.isoformat(),
115
+ "completed_at": self.completed_at.isoformat() if self.completed_at else None,
116
+ }
117
+
118
+ @classmethod
119
+ def from_dict(cls, data: dict[str, Any]) -> Plan:
120
+ """Create plan from dictionary."""
121
+ return cls(
122
+ task=data["task"],
123
+ steps=[PlanStep.from_dict(s) for s in data.get("steps", [])],
124
+ created_at=datetime.fromisoformat(data["created_at"]) if data.get("created_at") else datetime.now(),
125
+ completed_at=datetime.fromisoformat(data["completed_at"]) if data.get("completed_at") else None,
126
+ )
127
+
128
+ def to_json(self) -> str:
129
+ """Serialize plan to JSON string."""
130
+ return json.dumps(self.to_dict(), indent=2)
131
+
132
+ @classmethod
133
+ def from_json(cls, json_str: str) -> Plan:
134
+ """Create plan from JSON string."""
135
+ return cls.from_dict(json.loads(json_str))
136
+
137
+ def save(self, path: Path) -> None:
138
+ """Save plan to a file."""
139
+ path.write_text(self.to_json())
140
+
141
+ @classmethod
142
+ def load(cls, path: Path) -> Plan:
143
+ """Load plan from a file."""
144
+ return cls.from_json(path.read_text())
145
+
146
+ def to_markdown(self) -> str:
147
+ """Format plan as markdown."""
148
+ lines = [f"# Plan: {self.task}", ""]
149
+ for step in self.steps:
150
+ status_icon = {
151
+ StepStatus.PENDING: "⬜",
152
+ StepStatus.RUNNING: "🔄",
153
+ StepStatus.COMPLETED: "✅",
154
+ StepStatus.FAILED: "❌",
155
+ StepStatus.SKIPPED: "⏭️",
156
+ }.get(step.status, "⬜")
157
+ lines.append(f"{status_icon} **Step {step.number}:** {step.description}")
158
+ if step.result:
159
+ lines.append(f" - Result: {step.result[:100]}...")
160
+ if step.error:
161
+ lines.append(f" - Error: {step.error}")
162
+ return "\n".join(lines)
163
+
164
+
165
+ PLAN_GENERATION_PROMPT = """You are a planning assistant. Generate a step-by-step plan for the following task.
166
+
167
+ TASK: {task}
168
+
169
+ Generate a numbered list of concrete, actionable steps. Each step should be:
170
+ 1. Specific and executable
171
+ 2. Self-contained (can be done independently or builds on previous steps)
172
+ 3. Verifiable (you can check if it's done)
173
+
174
+ Format your response EXACTLY as:
175
+ STEP 1: [description]
176
+ STEP 2: [description]
177
+ ...
178
+
179
+ Include 3-10 steps. Be concise but thorough."""
180
+
181
+
182
+ STEP_EXECUTION_PROMPT = """You are executing step {step_number} of a plan.
183
+
184
+ OVERALL TASK: {task}
185
+
186
+ COMPLETED STEPS:
187
+ {completed_steps}
188
+
189
+ CURRENT STEP: {current_step}
190
+
191
+ Execute this step now. When done, summarize what you accomplished."""
192
+
193
+
194
+ class PlanExecutor:
195
+ """Executes plans step by step using a Copex client."""
196
+
197
+ def __init__(self, client: Any):
198
+ """Initialize executor with a Copex client."""
199
+ self.client = client
200
+ self._cancelled = False
201
+
202
+ def cancel(self) -> None:
203
+ """Cancel ongoing execution."""
204
+ self._cancelled = True
205
+
206
+ async def generate_plan(
207
+ self,
208
+ task: str,
209
+ *,
210
+ on_plan_generated: Callable[[Plan], None] | None = None,
211
+ ) -> Plan:
212
+ """Generate a plan for a task."""
213
+ prompt = PLAN_GENERATION_PROMPT.format(task=task)
214
+ response = await self.client.send(prompt)
215
+
216
+ steps = self._parse_steps(response.content)
217
+ plan = Plan(task=task, steps=steps)
218
+
219
+ if on_plan_generated:
220
+ on_plan_generated(plan)
221
+
222
+ return plan
223
+
224
+ def _parse_steps(self, content: str) -> list[PlanStep]:
225
+ """Parse steps from AI response."""
226
+ steps = []
227
+
228
+ # Try line-by-line parsing first (most reliable)
229
+ lines = content.strip().split("\n")
230
+ for line in lines:
231
+ line = line.strip()
232
+ if not line:
233
+ continue
234
+
235
+ # Match "STEP N: description" or "N. description" or "N: description"
236
+ match = re.match(
237
+ r"^(?:STEP\s*)?(\d+)[.:\)]\s*(.+)$",
238
+ line,
239
+ re.IGNORECASE,
240
+ )
241
+ if match:
242
+ desc = match.group(2).strip()
243
+ if desc:
244
+ steps.append(PlanStep(number=len(steps) + 1, description=desc))
245
+
246
+ # Fallback: if line parsing failed, try multi-line pattern
247
+ if not steps:
248
+ pattern = r"(?:STEP\s*)?(\d+)[.:]\s*(.+?)(?=(?:\n\s*)?(?:STEP\s*)?\d+[.:]|\Z)"
249
+ matches = re.findall(pattern, content, re.IGNORECASE | re.DOTALL)
250
+ for i, (num, desc) in enumerate(matches, 1):
251
+ description = " ".join(desc.strip().split())
252
+ if description:
253
+ steps.append(PlanStep(number=i, description=description))
254
+
255
+ # Final fallback: split by lines and clean prefixes
256
+ if not steps:
257
+ for i, line in enumerate(lines, 1):
258
+ clean = re.sub(r"^[\d]+[.:)]\s*", "", line.strip())
259
+ clean = re.sub(r"^[-*]\s*", "", clean)
260
+ if clean:
261
+ steps.append(PlanStep(number=i, description=clean))
262
+
263
+ return steps
264
+
265
+ async def execute_plan(
266
+ self,
267
+ plan: Plan,
268
+ *,
269
+ from_step: int = 1,
270
+ on_step_start: Callable[[PlanStep], None] | None = None,
271
+ on_step_complete: Callable[[PlanStep], None] | None = None,
272
+ on_error: Callable[[PlanStep, Exception], bool] | None = None,
273
+ ) -> Plan:
274
+ """
275
+ Execute a plan step by step.
276
+
277
+ Args:
278
+ plan: The plan to execute
279
+ from_step: Start execution from this step number
280
+ on_step_start: Called when a step starts
281
+ on_step_complete: Called when a step completes
282
+ on_error: Called on error, return True to continue, False to stop
283
+
284
+ Returns:
285
+ The updated plan with execution results
286
+ """
287
+ self._cancelled = False
288
+
289
+ for step in plan.steps:
290
+ if self._cancelled:
291
+ break
292
+
293
+ if step.number < from_step:
294
+ if step.status == StepStatus.PENDING:
295
+ step.status = StepStatus.SKIPPED
296
+ continue
297
+
298
+ if step.status in (StepStatus.COMPLETED, StepStatus.SKIPPED):
299
+ continue
300
+
301
+ step.status = StepStatus.RUNNING
302
+ step.started_at = datetime.now()
303
+
304
+ if on_step_start:
305
+ on_step_start(step)
306
+
307
+ try:
308
+ # Build context from completed steps
309
+ completed_steps = "\n".join(
310
+ f"Step {s.number}: {s.description} - {s.result or 'Done'}"
311
+ for s in plan.steps
312
+ if s.status == StepStatus.COMPLETED and s.number < step.number
313
+ ) or "(none)"
314
+
315
+ prompt = STEP_EXECUTION_PROMPT.format(
316
+ step_number=step.number,
317
+ task=plan.task,
318
+ completed_steps=completed_steps,
319
+ current_step=step.description,
320
+ )
321
+
322
+ response = await self.client.send(prompt)
323
+ step.result = response.content
324
+ step.status = StepStatus.COMPLETED
325
+ step.completed_at = datetime.now()
326
+
327
+ if on_step_complete:
328
+ on_step_complete(step)
329
+
330
+ except Exception as e:
331
+ step.status = StepStatus.FAILED
332
+ step.error = str(e)
333
+ step.completed_at = datetime.now()
334
+
335
+ if on_error:
336
+ should_continue = on_error(step, e)
337
+ if not should_continue:
338
+ break
339
+ else:
340
+ break
341
+
342
+ if plan.is_complete:
343
+ plan.completed_at = datetime.now()
344
+
345
+ return plan
346
+
347
+ async def execute_step(
348
+ self,
349
+ plan: Plan,
350
+ step_number: int,
351
+ ) -> PlanStep:
352
+ """Execute a single step from a plan."""
353
+ step = next((s for s in plan.steps if s.number == step_number), None)
354
+ if not step:
355
+ raise ValueError(f"Step {step_number} not found in plan")
356
+
357
+ await self.execute_plan(plan, from_step=step_number)
358
+ return step