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 +69 -0
- copex/checkpoint.py +445 -0
- copex/cli.py +1106 -0
- copex/client.py +725 -0
- copex/config.py +311 -0
- copex/mcp.py +561 -0
- copex/metrics.py +383 -0
- copex/models.py +50 -0
- copex/persistence.py +324 -0
- copex/plan.py +358 -0
- copex/ralph.py +247 -0
- copex/tools.py +404 -0
- copex/ui.py +971 -0
- copex-0.8.4.dist-info/METADATA +511 -0
- copex-0.8.4.dist-info/RECORD +18 -0
- copex-0.8.4.dist-info/WHEEL +4 -0
- copex-0.8.4.dist-info/entry_points.txt +2 -0
- copex-0.8.4.dist-info/licenses/LICENSE +21 -0
copex/config.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Configuration management for Copex."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field, field_validator
|
|
10
|
+
|
|
11
|
+
from copex.models import Model, ReasoningEffort
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def find_copilot_cli() -> str | None:
|
|
15
|
+
"""Auto-detect the Copilot CLI path across platforms.
|
|
16
|
+
|
|
17
|
+
Searches in order:
|
|
18
|
+
1. shutil.which('copilot') - system PATH
|
|
19
|
+
2. Common npm global locations
|
|
20
|
+
3. Common installation paths
|
|
21
|
+
|
|
22
|
+
Returns the path if found, None otherwise.
|
|
23
|
+
"""
|
|
24
|
+
# First try PATH (works on all platforms)
|
|
25
|
+
cli_path = shutil.which("copilot")
|
|
26
|
+
if cli_path:
|
|
27
|
+
# On Windows, prefer .cmd over .ps1 for subprocess compatibility
|
|
28
|
+
if sys.platform == "win32" and cli_path.endswith(".ps1"):
|
|
29
|
+
cmd_path = cli_path.replace(".ps1", ".cmd")
|
|
30
|
+
if os.path.exists(cmd_path):
|
|
31
|
+
return cmd_path
|
|
32
|
+
return cli_path
|
|
33
|
+
|
|
34
|
+
# Platform-specific common locations
|
|
35
|
+
if sys.platform == "win32":
|
|
36
|
+
# Windows locations
|
|
37
|
+
candidates = [
|
|
38
|
+
Path(os.environ.get("APPDATA", "")) / "npm" / "copilot.cmd",
|
|
39
|
+
Path(os.environ.get("LOCALAPPDATA", "")) / "npm" / "copilot.cmd",
|
|
40
|
+
Path.home() / "AppData" / "Roaming" / "npm" / "copilot.cmd",
|
|
41
|
+
Path.home() / ".npm-global" / "copilot.cmd",
|
|
42
|
+
]
|
|
43
|
+
# Also check USERPROFILE
|
|
44
|
+
if "USERPROFILE" in os.environ:
|
|
45
|
+
candidates.append(Path(os.environ["USERPROFILE"]) / "AppData" / "Roaming" / "npm" / "copilot.cmd")
|
|
46
|
+
elif sys.platform == "darwin":
|
|
47
|
+
# macOS locations
|
|
48
|
+
candidates = [
|
|
49
|
+
Path.home() / ".npm-global" / "bin" / "copilot",
|
|
50
|
+
Path("/usr/local/bin/copilot"),
|
|
51
|
+
Path("/opt/homebrew/bin/copilot"),
|
|
52
|
+
Path.home() / ".nvm" / "versions" / "node", # Check NVM later
|
|
53
|
+
]
|
|
54
|
+
else:
|
|
55
|
+
# Linux locations
|
|
56
|
+
candidates = [
|
|
57
|
+
Path.home() / ".npm-global" / "bin" / "copilot",
|
|
58
|
+
Path("/usr/local/bin/copilot"),
|
|
59
|
+
Path("/usr/bin/copilot"),
|
|
60
|
+
Path.home() / ".local" / "bin" / "copilot",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
for candidate in candidates:
|
|
64
|
+
if candidate.exists() and candidate.is_file():
|
|
65
|
+
return str(candidate)
|
|
66
|
+
|
|
67
|
+
# Check for NVM installations (macOS/Linux)
|
|
68
|
+
if sys.platform != "win32":
|
|
69
|
+
nvm_dir = Path.home() / ".nvm" / "versions" / "node"
|
|
70
|
+
if nvm_dir.exists():
|
|
71
|
+
# Find latest node version
|
|
72
|
+
versions = sorted(nvm_dir.iterdir(), reverse=True)
|
|
73
|
+
for version in versions:
|
|
74
|
+
copilot_path = version / "bin" / "copilot"
|
|
75
|
+
if copilot_path.exists():
|
|
76
|
+
return str(copilot_path)
|
|
77
|
+
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
UI_THEMES = {"default", "midnight", "mono", "sunset"}
|
|
82
|
+
UI_DENSITIES = {"compact", "extended"}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class RetryConfig(BaseModel):
|
|
86
|
+
"""Retry configuration."""
|
|
87
|
+
|
|
88
|
+
max_retries: int = Field(default=5, ge=1, le=20, description="Maximum retry attempts")
|
|
89
|
+
max_auto_continues: int = Field(default=3, ge=0, le=10, description="Maximum auto-continue cycles after exhausting retries")
|
|
90
|
+
base_delay: float = Field(default=1.0, ge=0.1, description="Base delay between retries (seconds)")
|
|
91
|
+
max_delay: float = Field(default=30.0, ge=1.0, description="Maximum delay between retries (seconds)")
|
|
92
|
+
exponential_base: float = Field(default=2.0, ge=1.5, description="Exponential backoff multiplier")
|
|
93
|
+
retry_on_any_error: bool = Field(
|
|
94
|
+
default=True, description="Retry and auto-continue on any error"
|
|
95
|
+
)
|
|
96
|
+
retry_on_errors: list[str] = Field(
|
|
97
|
+
default=["500", "502", "503", "504", "Internal Server Error", "rate limit"],
|
|
98
|
+
description="Error patterns to retry on (only used if retry_on_any_error=False)",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def get_user_state_path() -> Path:
|
|
103
|
+
"""Get path to user state file."""
|
|
104
|
+
return Path.home() / ".copex" / "state.json"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def load_last_model() -> Model | None:
|
|
108
|
+
"""Load the last used model from user state."""
|
|
109
|
+
state_path = get_user_state_path()
|
|
110
|
+
if not state_path.exists():
|
|
111
|
+
return None
|
|
112
|
+
try:
|
|
113
|
+
import json
|
|
114
|
+
with open(state_path, "r", encoding="utf-8") as f:
|
|
115
|
+
data = json.load(f)
|
|
116
|
+
model_value = data.get("last_model")
|
|
117
|
+
if model_value:
|
|
118
|
+
return Model(model_value)
|
|
119
|
+
except (ValueError, OSError, json.JSONDecodeError):
|
|
120
|
+
pass
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def save_last_model(model: Model) -> None:
|
|
125
|
+
"""Save the last used model to user state."""
|
|
126
|
+
import json
|
|
127
|
+
state_path = get_user_state_path()
|
|
128
|
+
state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
|
|
130
|
+
# Load existing state
|
|
131
|
+
data: dict[str, Any] = {}
|
|
132
|
+
if state_path.exists():
|
|
133
|
+
try:
|
|
134
|
+
with open(state_path, "r", encoding="utf-8") as f:
|
|
135
|
+
data = json.load(f)
|
|
136
|
+
except (OSError, json.JSONDecodeError):
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
# Update and save
|
|
140
|
+
data["last_model"] = model.value
|
|
141
|
+
with open(state_path, "w", encoding="utf-8") as f:
|
|
142
|
+
json.dump(data, f, indent=2)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class CopexConfig(BaseModel):
|
|
146
|
+
"""Main configuration for Copex client."""
|
|
147
|
+
|
|
148
|
+
model: Model = Field(default=Model.CLAUDE_OPUS_4_5, description="Model to use")
|
|
149
|
+
reasoning_effort: ReasoningEffort = Field(
|
|
150
|
+
default=ReasoningEffort.XHIGH, description="Reasoning effort level"
|
|
151
|
+
)
|
|
152
|
+
streaming: bool = Field(default=True, description="Enable streaming responses")
|
|
153
|
+
retry: RetryConfig = Field(default_factory=RetryConfig, description="Retry configuration")
|
|
154
|
+
|
|
155
|
+
# Client options
|
|
156
|
+
cli_path: str | None = Field(default=None, description="Path to Copilot CLI executable")
|
|
157
|
+
cli_url: str | None = Field(default=None, description="URL of existing CLI server")
|
|
158
|
+
cwd: str | None = Field(default=None, description="Working directory for CLI process")
|
|
159
|
+
auto_start: bool = Field(default=True, description="Auto-start CLI server")
|
|
160
|
+
auto_restart: bool = Field(default=True, description="Auto-restart on crash")
|
|
161
|
+
log_level: str = Field(default="warning", description="Log level")
|
|
162
|
+
|
|
163
|
+
# Session options
|
|
164
|
+
timeout: float = Field(default=300.0, ge=10.0, description="Inactivity timeout (seconds) - resets on each event")
|
|
165
|
+
auto_continue: bool = Field(
|
|
166
|
+
default=True, description="Auto-send 'Keep going' on interruption/error"
|
|
167
|
+
)
|
|
168
|
+
continue_prompt: str = Field(
|
|
169
|
+
default="Keep going", description="Prompt to send on auto-continue"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Skills and capabilities
|
|
173
|
+
skills: list[str] = Field(
|
|
174
|
+
default_factory=list,
|
|
175
|
+
description="Skills to enable (e.g., ['code-review', 'azure-openai'])"
|
|
176
|
+
)
|
|
177
|
+
instructions: str | None = Field(
|
|
178
|
+
default=None,
|
|
179
|
+
description="Custom instructions for the session"
|
|
180
|
+
)
|
|
181
|
+
instructions_file: str | None = Field(
|
|
182
|
+
default=None,
|
|
183
|
+
description="Path to instructions file (.md)"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# MCP configuration
|
|
187
|
+
mcp_servers: list[dict[str, Any]] = Field(
|
|
188
|
+
default_factory=list,
|
|
189
|
+
description="MCP server configurations"
|
|
190
|
+
)
|
|
191
|
+
mcp_config_file: str | None = Field(
|
|
192
|
+
default=None,
|
|
193
|
+
description="Path to MCP config JSON file"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# Tool filtering
|
|
197
|
+
available_tools: list[str] | None = Field(
|
|
198
|
+
default=None,
|
|
199
|
+
description="Whitelist of tools to enable (None = all)"
|
|
200
|
+
)
|
|
201
|
+
excluded_tools: list[str] = Field(
|
|
202
|
+
default_factory=list,
|
|
203
|
+
description="Blacklist of tools to disable"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# UI options
|
|
207
|
+
ui_theme: str = Field(
|
|
208
|
+
default="default",
|
|
209
|
+
description="UI theme (default, midnight, mono, sunset)"
|
|
210
|
+
)
|
|
211
|
+
ui_density: str = Field(
|
|
212
|
+
default="extended",
|
|
213
|
+
description="UI density (compact or extended)"
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
@field_validator("ui_theme")
|
|
217
|
+
@classmethod
|
|
218
|
+
def _validate_ui_theme(cls, value: str) -> str:
|
|
219
|
+
if value not in UI_THEMES:
|
|
220
|
+
valid = ", ".join(sorted(UI_THEMES))
|
|
221
|
+
raise ValueError(f"Invalid ui_theme. Valid: {valid}")
|
|
222
|
+
return value
|
|
223
|
+
|
|
224
|
+
@field_validator("ui_density")
|
|
225
|
+
@classmethod
|
|
226
|
+
def _validate_ui_density(cls, value: str) -> str:
|
|
227
|
+
if value not in UI_DENSITIES:
|
|
228
|
+
valid = ", ".join(sorted(UI_DENSITIES))
|
|
229
|
+
raise ValueError(f"Invalid ui_density. Valid: {valid}")
|
|
230
|
+
return value
|
|
231
|
+
|
|
232
|
+
@classmethod
|
|
233
|
+
def from_file(cls, path: str | Path) -> "CopexConfig":
|
|
234
|
+
"""Load configuration from TOML file."""
|
|
235
|
+
import tomllib
|
|
236
|
+
|
|
237
|
+
path = Path(path)
|
|
238
|
+
if not path.exists():
|
|
239
|
+
return cls()
|
|
240
|
+
|
|
241
|
+
with open(path, "rb") as f:
|
|
242
|
+
data = tomllib.load(f)
|
|
243
|
+
|
|
244
|
+
return cls(**data)
|
|
245
|
+
|
|
246
|
+
@classmethod
|
|
247
|
+
def default_path(cls) -> Path:
|
|
248
|
+
"""Get default config file path."""
|
|
249
|
+
return Path.home() / ".config" / "copex" / "config.toml"
|
|
250
|
+
|
|
251
|
+
def to_client_options(self) -> dict[str, Any]:
|
|
252
|
+
"""Convert to CopilotClient options."""
|
|
253
|
+
opts: dict[str, Any] = {
|
|
254
|
+
"auto_start": self.auto_start,
|
|
255
|
+
"auto_restart": self.auto_restart,
|
|
256
|
+
"log_level": self.log_level,
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
# Use provided cli_path or auto-detect
|
|
260
|
+
cli_path = self.cli_path or find_copilot_cli()
|
|
261
|
+
if cli_path:
|
|
262
|
+
opts["cli_path"] = cli_path
|
|
263
|
+
|
|
264
|
+
if self.cli_url:
|
|
265
|
+
opts["cli_url"] = self.cli_url
|
|
266
|
+
if self.cwd:
|
|
267
|
+
opts["cwd"] = self.cwd
|
|
268
|
+
return opts
|
|
269
|
+
|
|
270
|
+
def to_session_options(self) -> dict[str, Any]:
|
|
271
|
+
"""Convert to create_session options."""
|
|
272
|
+
opts: dict[str, Any] = {
|
|
273
|
+
"model": self.model.value,
|
|
274
|
+
"model_reasoning_effort": self.reasoning_effort.value,
|
|
275
|
+
"streaming": self.streaming,
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
# Skills
|
|
279
|
+
if self.skills:
|
|
280
|
+
opts["skills"] = self.skills
|
|
281
|
+
|
|
282
|
+
# Instructions
|
|
283
|
+
if self.instructions:
|
|
284
|
+
opts["instructions"] = self.instructions
|
|
285
|
+
elif self.instructions_file:
|
|
286
|
+
instructions_path = Path(self.instructions_file)
|
|
287
|
+
if not instructions_path.exists():
|
|
288
|
+
raise FileNotFoundError(f"Instructions file not found: {instructions_path}")
|
|
289
|
+
with open(instructions_path, "r", encoding="utf-8") as f:
|
|
290
|
+
opts["instructions"] = f.read()
|
|
291
|
+
|
|
292
|
+
# MCP servers
|
|
293
|
+
if self.mcp_servers:
|
|
294
|
+
opts["mcp_servers"] = self.mcp_servers
|
|
295
|
+
elif self.mcp_config_file:
|
|
296
|
+
config_path = Path(self.mcp_config_file)
|
|
297
|
+
if not config_path.exists():
|
|
298
|
+
raise FileNotFoundError(f"MCP config file not found: {config_path}")
|
|
299
|
+
import json
|
|
300
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
301
|
+
mcp_data = json.load(f)
|
|
302
|
+
if "servers" in mcp_data:
|
|
303
|
+
opts["mcp_servers"] = list(mcp_data["servers"].values())
|
|
304
|
+
|
|
305
|
+
# Tool filtering
|
|
306
|
+
if self.available_tools is not None:
|
|
307
|
+
opts["available_tools"] = self.available_tools
|
|
308
|
+
if self.excluded_tools:
|
|
309
|
+
opts["excluded_tools"] = self.excluded_tools
|
|
310
|
+
|
|
311
|
+
return opts
|