cpmux 0.1.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.
- cpmux/__init__.py +4 -0
- cpmux/__main__.py +7 -0
- cpmux/config.py +595 -0
- cpmux/engine/__init__.py +2 -0
- cpmux/engine/copilot_store.py +94 -0
- cpmux/engine/daemon.py +234 -0
- cpmux/engine/interact.py +59 -0
- cpmux/engine/session.py +115 -0
- cpmux/engine/store.py +293 -0
- cpmux/engine/supervisor.py +410 -0
- cpmux/events.py +171 -0
- cpmux/logging.py +38 -0
- cpmux/py.typed +0 -0
- cpmux/theme.py +198 -0
- cpmux/ui/__init__.py +2 -0
- cpmux/ui/cli.py +904 -0
- cpmux/ui/dashboard.py +378 -0
- cpmux/ui/render.py +72 -0
- cpmux/ui/search.py +89 -0
- cpmux/vcs/__init__.py +2 -0
- cpmux/vcs/git.py +262 -0
- cpmux/vcs/pr.py +240 -0
- cpmux/voice/__init__.py +2 -0
- cpmux/voice/recorder.py +246 -0
- cpmux/voice/synthesizer.py +100 -0
- cpmux/voice/transcriber.py +94 -0
- cpmux-0.1.0.dist-info/METADATA +248 -0
- cpmux-0.1.0.dist-info/RECORD +31 -0
- cpmux-0.1.0.dist-info/WHEEL +4 -0
- cpmux-0.1.0.dist-info/entry_points.txt +2 -0
- cpmux-0.1.0.dist-info/licenses/LICENSE +21 -0
cpmux/__init__.py
ADDED
cpmux/__main__.py
ADDED
cpmux/config.py
ADDED
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
# Copyright (c) 2026 Gustavo de Rosa.
|
|
2
|
+
# Licensed under the MIT license.
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import string
|
|
7
|
+
import unicodedata
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Annotated, Any, Literal
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
from pydantic import (
|
|
14
|
+
BaseModel,
|
|
15
|
+
ConfigDict,
|
|
16
|
+
Field,
|
|
17
|
+
ValidationError,
|
|
18
|
+
ValidationInfo,
|
|
19
|
+
computed_field,
|
|
20
|
+
field_validator,
|
|
21
|
+
model_validator,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from cpmux.vcs.pr import PR_DRAFT_FILENAME
|
|
25
|
+
|
|
26
|
+
_ENV_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}")
|
|
27
|
+
|
|
28
|
+
_PR_INSTRUCTIONS = (
|
|
29
|
+
"When the task is complete, write the pull-request title and description cpmux will use to a file "
|
|
30
|
+
f"named `{PR_DRAFT_FILENAME}` in the repository root (your working directory). Format it as Markdown: "
|
|
31
|
+
"the first line is a level-one heading holding a concise, imperative title, then a blank line, then the "
|
|
32
|
+
"body. Base the description on the changes you actually made. If the repository defines a pull-request "
|
|
33
|
+
"template (check `.github/`, the repository root, or `docs/` for a `PULL_REQUEST_TEMPLATE.md` file or a "
|
|
34
|
+
"`PULL_REQUEST_TEMPLATE/` directory), follow its structure and fill in every applicable section "
|
|
35
|
+
"truthfully; otherwise summarise the change, list the notable edits, and note how you verified them. Do "
|
|
36
|
+
f"not run `git add`, `git commit`, or `git push`, and do not stage `{PR_DRAFT_FILENAME}`; cpmux commits "
|
|
37
|
+
"your work and, when enabled, opens the pull request."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Effort(StrEnum):
|
|
42
|
+
"""Reasoning effort for `copilot --effort`."""
|
|
43
|
+
|
|
44
|
+
none = "none"
|
|
45
|
+
minimal = "minimal"
|
|
46
|
+
low = "low"
|
|
47
|
+
medium = "medium"
|
|
48
|
+
high = "high"
|
|
49
|
+
xhigh = "xhigh"
|
|
50
|
+
max = "max"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Preset(StrEnum):
|
|
54
|
+
"""Permission preset (`yolo` is an alias of `full`)."""
|
|
55
|
+
|
|
56
|
+
readonly = "readonly"
|
|
57
|
+
edit = "edit"
|
|
58
|
+
full = "full"
|
|
59
|
+
yolo = "yolo"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Deps(StrEnum):
|
|
63
|
+
"""Dependency setup for a fresh worktree."""
|
|
64
|
+
|
|
65
|
+
symlink = "symlink"
|
|
66
|
+
copy = "copy"
|
|
67
|
+
install = "install"
|
|
68
|
+
skip = "skip"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def interpolate_env(value: str) -> str:
|
|
72
|
+
"""Expand `${VAR}` and `${VAR:-default}` references in a string.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
value: String containing environment references.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
String with environment references expanded.
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
ValueError: Unset variable without a fallback.
|
|
82
|
+
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def repl(match: re.Match[str]) -> str:
|
|
86
|
+
name, default = match.group(1), match.group(2)
|
|
87
|
+
if name in os.environ:
|
|
88
|
+
return os.environ[name]
|
|
89
|
+
if default is not None:
|
|
90
|
+
return default
|
|
91
|
+
raise ValueError(f"`{name}` is unset; use `${{{name}:-default}}`.")
|
|
92
|
+
|
|
93
|
+
return _ENV_RE.sub(repl, value)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _walk_interpolate(obj: Any) -> Any:
|
|
97
|
+
if isinstance(obj, str):
|
|
98
|
+
return interpolate_env(obj)
|
|
99
|
+
if isinstance(obj, list):
|
|
100
|
+
return [_walk_interpolate(value) for value in obj]
|
|
101
|
+
if isinstance(obj, dict):
|
|
102
|
+
return {key: _walk_interpolate(value) for key, value in obj.items()}
|
|
103
|
+
|
|
104
|
+
return obj
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def slugify(text: str) -> str:
|
|
108
|
+
"""Convert text to a branch- and worktree-safe slug.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
text: Text to normalize.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Branch- and worktree-safe slug.
|
|
115
|
+
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
text = text.strip().lower().splitlines()[0] if text.strip() else "task"
|
|
119
|
+
text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("ascii")
|
|
120
|
+
text = re.sub(r"[^a-z0-9]+", "-", text).strip("-")
|
|
121
|
+
|
|
122
|
+
return (text or "task")[:50]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _validate_template(template: str, field: str, allowed: set[str]) -> str:
|
|
126
|
+
try:
|
|
127
|
+
names = {name for _, name, _, _ in string.Formatter().parse(template) if name}
|
|
128
|
+
except ValueError as exc:
|
|
129
|
+
raise ValueError(f"`{field}` is not a valid format template: {exc}.") from exc
|
|
130
|
+
|
|
131
|
+
unknown = sorted(names - allowed)
|
|
132
|
+
if unknown:
|
|
133
|
+
raise ValueError(f"`{field}` uses unknown placeholder(s) {unknown}; allowed: {sorted(allowed)}.")
|
|
134
|
+
|
|
135
|
+
return template
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class Permissions(BaseModel):
|
|
139
|
+
"""Permission preset with allow, deny, and network options.
|
|
140
|
+
|
|
141
|
+
Attributes:
|
|
142
|
+
preset: Base permission preset.
|
|
143
|
+
allow: Additional allowed tool specifications.
|
|
144
|
+
deny: Additional denied tool specifications.
|
|
145
|
+
add_dir: Additional accessible directories.
|
|
146
|
+
allow_url: Allowed network URL patterns.
|
|
147
|
+
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
model_config = ConfigDict(extra="forbid")
|
|
151
|
+
|
|
152
|
+
preset: Preset = Preset.edit
|
|
153
|
+
allow: list[str] = Field(default_factory=list)
|
|
154
|
+
deny: list[str] = Field(default_factory=list)
|
|
155
|
+
add_dir: list[str] = Field(default_factory=list)
|
|
156
|
+
allow_url: list[str] = Field(default_factory=list)
|
|
157
|
+
|
|
158
|
+
@model_validator(mode="before")
|
|
159
|
+
@classmethod
|
|
160
|
+
def _accept_bare_preset(cls, data: Any) -> Any:
|
|
161
|
+
if isinstance(data, str):
|
|
162
|
+
return {"preset": data}
|
|
163
|
+
return data
|
|
164
|
+
|
|
165
|
+
@field_validator("allow", "deny", "add_dir", "allow_url")
|
|
166
|
+
@classmethod
|
|
167
|
+
def _drop_blank(cls, value: list[str]) -> list[str]:
|
|
168
|
+
return [entry for entry in value if entry.strip()]
|
|
169
|
+
|
|
170
|
+
def to_flags(self) -> list[str]:
|
|
171
|
+
"""Return `copilot` permission flags.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
List of Copilot permission flags.
|
|
175
|
+
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
flags: list[str] = []
|
|
179
|
+
if self.preset in (Preset.full, Preset.yolo):
|
|
180
|
+
flags.append("--allow-all-tools")
|
|
181
|
+
elif self.preset == Preset.edit:
|
|
182
|
+
flags += ["--allow-tool=write", "--allow-tool=shell", "--deny-tool=shell(git push)"]
|
|
183
|
+
elif self.preset == Preset.readonly:
|
|
184
|
+
flags += ["--deny-tool=write", "--deny-tool=shell"]
|
|
185
|
+
|
|
186
|
+
for spec in self.allow:
|
|
187
|
+
flags.append(f"--allow-tool={spec}")
|
|
188
|
+
for spec in self.deny:
|
|
189
|
+
flags.append(f"--deny-tool={spec}")
|
|
190
|
+
for directory in self.add_dir:
|
|
191
|
+
flags += ["--add-dir", directory]
|
|
192
|
+
for url in self.allow_url:
|
|
193
|
+
flags += ["--allow-url", url]
|
|
194
|
+
|
|
195
|
+
return flags
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class PRSettings(BaseModel):
|
|
199
|
+
"""Pull-request defaults applied unless overridden.
|
|
200
|
+
|
|
201
|
+
Attributes:
|
|
202
|
+
draft: Whether pull requests start as drafts.
|
|
203
|
+
labels: Default pull-request labels.
|
|
204
|
+
title_template: Pull-request title template.
|
|
205
|
+
body_template: Pull-request body template.
|
|
206
|
+
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
model_config = ConfigDict(extra="forbid")
|
|
210
|
+
|
|
211
|
+
draft: bool = True
|
|
212
|
+
labels: list[str] = Field(default_factory=list)
|
|
213
|
+
title_template: str = "{name}"
|
|
214
|
+
body_template: str = "## Summary\n\n{prompt}\n"
|
|
215
|
+
|
|
216
|
+
@field_validator("title_template")
|
|
217
|
+
@classmethod
|
|
218
|
+
def _validate_title_template(cls, value: str) -> str:
|
|
219
|
+
return _validate_template(value, "title_template", {"name", "slug", "prompt"})
|
|
220
|
+
|
|
221
|
+
@field_validator("body_template")
|
|
222
|
+
@classmethod
|
|
223
|
+
def _validate_body_template(cls, value: str) -> str:
|
|
224
|
+
return _validate_template(value, "body_template", {"name", "slug", "prompt"})
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class Defaults(BaseModel):
|
|
228
|
+
"""Defaults inherited by all items.
|
|
229
|
+
|
|
230
|
+
Attributes:
|
|
231
|
+
model: Default Copilot model.
|
|
232
|
+
effort: Default reasoning effort.
|
|
233
|
+
permissions: Default permission settings.
|
|
234
|
+
base: Default base branch.
|
|
235
|
+
branch_template: Branch name template.
|
|
236
|
+
pr: Default pull-request settings.
|
|
237
|
+
concurrency: Maximum concurrent sessions.
|
|
238
|
+
deps: Dependency setup mode.
|
|
239
|
+
remote: Git remote name.
|
|
240
|
+
port_base: Starting port for item allocation.
|
|
241
|
+
port_env: Environment variable receiving the allocated port.
|
|
242
|
+
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
model_config = ConfigDict(extra="forbid")
|
|
246
|
+
|
|
247
|
+
model: str = "gpt-5.5"
|
|
248
|
+
effort: Effort = Effort.medium
|
|
249
|
+
permissions: Permissions = Field(default_factory=Permissions)
|
|
250
|
+
base: str = "main"
|
|
251
|
+
branch_template: str = "cpmux/{slug}"
|
|
252
|
+
pr: PRSettings = Field(default_factory=PRSettings)
|
|
253
|
+
concurrency: int = Field(default=4, ge=1, le=64)
|
|
254
|
+
deps: Deps = Deps.symlink
|
|
255
|
+
remote: str = "origin"
|
|
256
|
+
port_base: int | None = Field(default=None, ge=1, le=65535)
|
|
257
|
+
port_env: str = "PORT"
|
|
258
|
+
|
|
259
|
+
@field_validator("port_env")
|
|
260
|
+
@classmethod
|
|
261
|
+
def _validate_env_name(cls, value: str) -> str:
|
|
262
|
+
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", value):
|
|
263
|
+
raise ValueError(f"`port_env` must be a valid environment variable name, got `{value}`.")
|
|
264
|
+
|
|
265
|
+
return value
|
|
266
|
+
|
|
267
|
+
@field_validator("model", "base")
|
|
268
|
+
@classmethod
|
|
269
|
+
def _reject_empty(cls, value: str, info: ValidationInfo) -> str:
|
|
270
|
+
if not value.strip():
|
|
271
|
+
raise ValueError(f"`{info.field_name}` must not be empty.")
|
|
272
|
+
|
|
273
|
+
return value
|
|
274
|
+
|
|
275
|
+
@field_validator("branch_template")
|
|
276
|
+
@classmethod
|
|
277
|
+
def _validate_branch_template(cls, value: str) -> str:
|
|
278
|
+
return _validate_template(value, "branch_template", {"slug", "id"})
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class Item(BaseModel):
|
|
282
|
+
"""Task prompt with optional per-item overrides.
|
|
283
|
+
|
|
284
|
+
Attributes:
|
|
285
|
+
prompt: Task prompt.
|
|
286
|
+
name: Display name override.
|
|
287
|
+
id: Stable identifier override.
|
|
288
|
+
model: Copilot model override.
|
|
289
|
+
effort: Reasoning effort override.
|
|
290
|
+
permissions: Permission settings override.
|
|
291
|
+
branch: Branch name override.
|
|
292
|
+
base: Base branch override.
|
|
293
|
+
labels: Additional pull-request labels.
|
|
294
|
+
draft: Pull-request draft override.
|
|
295
|
+
paths: Additional accessible paths.
|
|
296
|
+
depends_on: Required item identifiers.
|
|
297
|
+
env: Session environment variables.
|
|
298
|
+
include_system: Whether to prepend the plan system prompt.
|
|
299
|
+
|
|
300
|
+
"""
|
|
301
|
+
|
|
302
|
+
model_config = ConfigDict(extra="forbid")
|
|
303
|
+
|
|
304
|
+
prompt: str = Field(min_length=1)
|
|
305
|
+
name: str | None = None
|
|
306
|
+
id: str | None = None
|
|
307
|
+
model: str | None = None
|
|
308
|
+
effort: Effort | None = None
|
|
309
|
+
permissions: Permissions | None = None
|
|
310
|
+
branch: str | None = None
|
|
311
|
+
base: str | None = None
|
|
312
|
+
labels: list[str] = Field(default_factory=list)
|
|
313
|
+
draft: bool | None = None
|
|
314
|
+
paths: list[str] = Field(default_factory=list)
|
|
315
|
+
depends_on: list[str] = Field(default_factory=list)
|
|
316
|
+
env: dict[str, str] = Field(default_factory=dict)
|
|
317
|
+
include_system: bool = True
|
|
318
|
+
|
|
319
|
+
@model_validator(mode="before")
|
|
320
|
+
@classmethod
|
|
321
|
+
def _accept_string_shorthand(cls, data: Any) -> Any:
|
|
322
|
+
if isinstance(data, str):
|
|
323
|
+
return {"prompt": data}
|
|
324
|
+
return data
|
|
325
|
+
|
|
326
|
+
@field_validator("prompt")
|
|
327
|
+
@classmethod
|
|
328
|
+
def _reject_blank_prompt(cls, value: str) -> str:
|
|
329
|
+
if not value.strip():
|
|
330
|
+
raise ValueError("`prompt` must not be blank.")
|
|
331
|
+
|
|
332
|
+
return value
|
|
333
|
+
|
|
334
|
+
@computed_field # type: ignore[prop-decorator]
|
|
335
|
+
@property
|
|
336
|
+
def slug(self) -> str:
|
|
337
|
+
"""Branch- and worktree-safe slug from name, id, or prompt."""
|
|
338
|
+
|
|
339
|
+
return slugify(self.name or self.id or self.prompt)
|
|
340
|
+
|
|
341
|
+
@property
|
|
342
|
+
def key(self) -> str:
|
|
343
|
+
"""Stable identifier: explicit id when set, else the slug."""
|
|
344
|
+
|
|
345
|
+
return self.id or self.slug
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
class ResolvedItem(BaseModel):
|
|
349
|
+
"""Resolved session configuration.
|
|
350
|
+
|
|
351
|
+
Attributes:
|
|
352
|
+
key: Stable item identifier.
|
|
353
|
+
name: Display name.
|
|
354
|
+
slug: Branch- and worktree-safe slug.
|
|
355
|
+
prompt: Resolved task prompt (system and item).
|
|
356
|
+
model: Copilot model.
|
|
357
|
+
effort: Reasoning effort.
|
|
358
|
+
permissions: Permission settings.
|
|
359
|
+
branch: Branch name.
|
|
360
|
+
base: Base branch.
|
|
361
|
+
labels: Pull-request labels.
|
|
362
|
+
draft: Whether the pull request is a draft.
|
|
363
|
+
depends_on: Required item identifiers.
|
|
364
|
+
env: Session environment variables.
|
|
365
|
+
deps: Dependency setup mode.
|
|
366
|
+
remote: Git remote name.
|
|
367
|
+
pr_title: Pull-request title.
|
|
368
|
+
pr_body: Pull-request body.
|
|
369
|
+
|
|
370
|
+
"""
|
|
371
|
+
|
|
372
|
+
key: str
|
|
373
|
+
name: str
|
|
374
|
+
slug: str
|
|
375
|
+
prompt: str
|
|
376
|
+
model: str
|
|
377
|
+
effort: Effort
|
|
378
|
+
permissions: Permissions
|
|
379
|
+
branch: str
|
|
380
|
+
base: str
|
|
381
|
+
labels: list[str]
|
|
382
|
+
draft: bool
|
|
383
|
+
depends_on: list[str]
|
|
384
|
+
env: dict[str, str]
|
|
385
|
+
deps: Deps
|
|
386
|
+
remote: str
|
|
387
|
+
pr_title: str
|
|
388
|
+
pr_body: str
|
|
389
|
+
|
|
390
|
+
def effective_prompt(self) -> str:
|
|
391
|
+
"""Return the resolved prompt with the pull-request authoring instructions."""
|
|
392
|
+
|
|
393
|
+
return f"{self.prompt.rstrip()}\n\n---\n\n{_PR_INSTRUCTIONS}"
|
|
394
|
+
|
|
395
|
+
def spawn_argv(self, worktree: str | Path, session_id: str, log_dir: str | Path) -> list[str]:
|
|
396
|
+
"""Build the headless `copilot` command.
|
|
397
|
+
|
|
398
|
+
Args:
|
|
399
|
+
worktree: Working directory for the Copilot process.
|
|
400
|
+
session_id: Copilot session identifier.
|
|
401
|
+
log_dir: Directory for Copilot logs.
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
Headless Copilot command arguments.
|
|
405
|
+
|
|
406
|
+
"""
|
|
407
|
+
|
|
408
|
+
argv = [
|
|
409
|
+
"copilot",
|
|
410
|
+
"-C",
|
|
411
|
+
str(worktree),
|
|
412
|
+
"-p",
|
|
413
|
+
self.effective_prompt(),
|
|
414
|
+
"--model",
|
|
415
|
+
self.model,
|
|
416
|
+
"--effort",
|
|
417
|
+
str(self.effort),
|
|
418
|
+
"--output-format",
|
|
419
|
+
"json",
|
|
420
|
+
"--session-id",
|
|
421
|
+
session_id,
|
|
422
|
+
"--name",
|
|
423
|
+
self.name,
|
|
424
|
+
"--log-dir",
|
|
425
|
+
str(log_dir),
|
|
426
|
+
]
|
|
427
|
+
argv += self.permissions.to_flags()
|
|
428
|
+
if "--no-ask-user" not in argv:
|
|
429
|
+
argv.append("--no-ask-user")
|
|
430
|
+
|
|
431
|
+
return argv
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
class Plan(BaseModel):
|
|
435
|
+
"""Parsed cpmux run configuration.
|
|
436
|
+
|
|
437
|
+
Attributes:
|
|
438
|
+
version: Configuration schema version.
|
|
439
|
+
system: System prompt prepended to eligible items.
|
|
440
|
+
defaults: Defaults inherited by items.
|
|
441
|
+
items: Declared task items.
|
|
442
|
+
|
|
443
|
+
"""
|
|
444
|
+
|
|
445
|
+
model_config = ConfigDict(extra="forbid")
|
|
446
|
+
|
|
447
|
+
version: Literal[1] = 1
|
|
448
|
+
system: str = ""
|
|
449
|
+
defaults: Defaults = Field(default_factory=Defaults)
|
|
450
|
+
items: Annotated[list[Item], Field(min_length=1)]
|
|
451
|
+
|
|
452
|
+
@model_validator(mode="before")
|
|
453
|
+
@classmethod
|
|
454
|
+
def _interpolate(cls, data: Any) -> Any:
|
|
455
|
+
return _walk_interpolate(data)
|
|
456
|
+
|
|
457
|
+
@field_validator("items")
|
|
458
|
+
@classmethod
|
|
459
|
+
def _validate_unique_keys_and_dependencies(cls, items: list[Item]) -> list[Item]:
|
|
460
|
+
keys = [item.key for item in items]
|
|
461
|
+
dupes = {key for key in keys if keys.count(key) > 1}
|
|
462
|
+
if dupes:
|
|
463
|
+
raise ValueError(
|
|
464
|
+
f"`items` contains duplicate identifiers {sorted(dupes)}; assign distinct `name` or `id` values."
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
known = set(keys)
|
|
468
|
+
for item in items:
|
|
469
|
+
missing = [dep for dep in item.depends_on if dep not in known]
|
|
470
|
+
if missing:
|
|
471
|
+
raise ValueError(
|
|
472
|
+
f"`depends_on` for `{item.key}` references unknown ids {missing}; known ids: {sorted(known)}."
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
pending = {item.key: set(item.depends_on) for item in items}
|
|
476
|
+
while ready := [key for key, deps in pending.items() if not deps]:
|
|
477
|
+
for key in ready:
|
|
478
|
+
del pending[key]
|
|
479
|
+
for deps in pending.values():
|
|
480
|
+
deps.difference_update(ready)
|
|
481
|
+
if pending:
|
|
482
|
+
raise ValueError(f"`depends_on` forms a cycle among {sorted(pending)}; remove the circular dependency.")
|
|
483
|
+
|
|
484
|
+
return items
|
|
485
|
+
|
|
486
|
+
@model_validator(mode="after")
|
|
487
|
+
def _validate_port_range(self) -> "Plan":
|
|
488
|
+
base = self.defaults.port_base
|
|
489
|
+
if base is not None and base + len(self.items) - 1 > 65535:
|
|
490
|
+
raise ValueError(
|
|
491
|
+
f"`port_base` {base} + {len(self.items)} items exceeds port 65535; lower it or split the plan."
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
return self
|
|
495
|
+
|
|
496
|
+
def resolve(self) -> list[ResolvedItem]:
|
|
497
|
+
"""Resolve items in declaration order.
|
|
498
|
+
|
|
499
|
+
Returns:
|
|
500
|
+
Items resolved in declaration order.
|
|
501
|
+
|
|
502
|
+
"""
|
|
503
|
+
|
|
504
|
+
defaults = self.defaults
|
|
505
|
+
resolved: list[ResolvedItem] = []
|
|
506
|
+
|
|
507
|
+
for index, item in enumerate(self.items):
|
|
508
|
+
permissions = item.permissions or defaults.permissions
|
|
509
|
+
if item.paths:
|
|
510
|
+
permissions = permissions.model_copy(update={"add_dir": [*permissions.add_dir, *item.paths]})
|
|
511
|
+
|
|
512
|
+
branch = item.branch or defaults.branch_template.format(slug=item.slug, id=item.key)
|
|
513
|
+
prompt = item.prompt
|
|
514
|
+
if item.include_system and self.system.strip():
|
|
515
|
+
prompt = f"{self.system.strip()}\n\n---\n\n{item.prompt.strip()}"
|
|
516
|
+
pr_settings = defaults.pr
|
|
517
|
+
display_name = item.name or item.slug
|
|
518
|
+
|
|
519
|
+
env = dict(item.env)
|
|
520
|
+
if defaults.port_base is not None:
|
|
521
|
+
env = {defaults.port_env: str(defaults.port_base + index), **env}
|
|
522
|
+
|
|
523
|
+
resolved.append(
|
|
524
|
+
ResolvedItem(
|
|
525
|
+
key=item.key,
|
|
526
|
+
name=display_name,
|
|
527
|
+
slug=item.slug,
|
|
528
|
+
prompt=prompt,
|
|
529
|
+
model=item.model or defaults.model,
|
|
530
|
+
effort=item.effort or defaults.effort,
|
|
531
|
+
permissions=permissions,
|
|
532
|
+
branch=branch,
|
|
533
|
+
base=item.base or defaults.base,
|
|
534
|
+
labels=[label for label in dict.fromkeys([*pr_settings.labels, *item.labels]) if label.strip()],
|
|
535
|
+
draft=pr_settings.draft if item.draft is None else item.draft,
|
|
536
|
+
depends_on=list(item.depends_on),
|
|
537
|
+
env=env,
|
|
538
|
+
deps=defaults.deps,
|
|
539
|
+
remote=defaults.remote,
|
|
540
|
+
pr_title=pr_settings.title_template.format(
|
|
541
|
+
name=display_name, slug=item.slug, prompt=item.prompt.strip()
|
|
542
|
+
),
|
|
543
|
+
pr_body=pr_settings.body_template.format(
|
|
544
|
+
name=display_name, slug=item.slug, prompt=item.prompt.strip()
|
|
545
|
+
),
|
|
546
|
+
)
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
return resolved
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
class ConfigError(Exception):
|
|
553
|
+
"""Raised when a cpmux YAML file is missing or invalid."""
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def load_plan(path: str | Path) -> Plan:
|
|
557
|
+
"""Parse and validate a cpmux YAML file.
|
|
558
|
+
|
|
559
|
+
Args:
|
|
560
|
+
path: YAML configuration path.
|
|
561
|
+
|
|
562
|
+
Returns:
|
|
563
|
+
Validated run plan.
|
|
564
|
+
|
|
565
|
+
Raises:
|
|
566
|
+
ConfigError: Missing or invalid file.
|
|
567
|
+
|
|
568
|
+
"""
|
|
569
|
+
|
|
570
|
+
config_path = Path(path)
|
|
571
|
+
if not config_path.exists():
|
|
572
|
+
raise ConfigError(f"`{config_path}` config file does not exist.")
|
|
573
|
+
|
|
574
|
+
try:
|
|
575
|
+
raw = yaml.safe_load(config_path.read_text()) or {}
|
|
576
|
+
except yaml.YAMLError as exc:
|
|
577
|
+
raise ConfigError(f"`{config_path}` is not valid YAML: {exc}.") from exc
|
|
578
|
+
|
|
579
|
+
if not isinstance(raw, dict):
|
|
580
|
+
raise ConfigError(f"`{config_path}` top-level YAML must be a mapping, got `{type(raw).__name__}`.")
|
|
581
|
+
|
|
582
|
+
try:
|
|
583
|
+
return Plan.model_validate(raw)
|
|
584
|
+
except ValidationError as exc:
|
|
585
|
+
raise ConfigError(f"`{config_path}` is not a valid cpmux plan:\n{_format_validation(exc)}") from exc
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _format_validation(exc: ValidationError) -> str:
|
|
589
|
+
lines = []
|
|
590
|
+
for error in exc.errors():
|
|
591
|
+
location = ".".join(str(part) for part in error["loc"]) or "(root)"
|
|
592
|
+
message = error["msg"].removeprefix("Value error, ")
|
|
593
|
+
lines.append(f" {location}: {message}")
|
|
594
|
+
|
|
595
|
+
return "\n".join(lines)
|
cpmux/engine/__init__.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Copyright (c) 2026 Gustavo de Rosa.
|
|
2
|
+
# Licensed under the MIT license.
|
|
3
|
+
|
|
4
|
+
import sqlite3
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
_DEFAULT_STORE = Path.home() / ".copilot" / "session-store.db"
|
|
9
|
+
_CHUNK = 400
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CopilotStoreUnavailable(Exception):
|
|
13
|
+
"""Copilot session store query failure."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class InvalidFtsQuery(Exception):
|
|
17
|
+
"""Invalid FTS5 query."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class FtsHit:
|
|
22
|
+
"""Ranked match from Copilot's session store.
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
session_id: Copilot session identifier.
|
|
26
|
+
snippet: Matched text excerpt.
|
|
27
|
+
rank: Relevance rank.
|
|
28
|
+
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
session_id: str
|
|
32
|
+
snippet: str
|
|
33
|
+
rank: float
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def search_sessions(session_ids: list[str], query: str, limit: int = 50, db_path: Path | None = None) -> list[FtsHit]:
|
|
37
|
+
"""Search turns in selected Copilot sessions.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
session_ids: Session IDs.
|
|
41
|
+
query: FTS5 query.
|
|
42
|
+
limit: Hit limit.
|
|
43
|
+
db_path: Session store path.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Relevance-ranked hits.
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
InvalidFtsQuery: Query has invalid FTS5 syntax.
|
|
50
|
+
CopilotStoreUnavailable: Store is missing or unreadable.
|
|
51
|
+
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
if not session_ids or not query.strip():
|
|
55
|
+
return []
|
|
56
|
+
|
|
57
|
+
store = db_path or _DEFAULT_STORE
|
|
58
|
+
if not store.exists():
|
|
59
|
+
raise CopilotStoreUnavailable(f"`{store}` Copilot session store not found.")
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
connection = sqlite3.connect(f"file:{store}?mode=ro", uri=True, timeout=2.0)
|
|
63
|
+
except sqlite3.OperationalError as exc:
|
|
64
|
+
raise CopilotStoreUnavailable(f"`{store}` Copilot session store open failed: {exc}.") from exc
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
hits = _query(connection, sorted(set(session_ids)), query, limit)
|
|
68
|
+
except sqlite3.OperationalError as exc:
|
|
69
|
+
message = str(exc).lower()
|
|
70
|
+
if "fts5" in message or "unterminated" in message or "syntax" in message:
|
|
71
|
+
raise InvalidFtsQuery(f"`{query}` is invalid: {exc}.") from exc
|
|
72
|
+
raise CopilotStoreUnavailable(f"copilot session store query failed: {exc}.") from exc
|
|
73
|
+
finally:
|
|
74
|
+
connection.close()
|
|
75
|
+
|
|
76
|
+
hits.sort(key=lambda hit: hit.rank)
|
|
77
|
+
|
|
78
|
+
return hits[:limit]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _query(connection: sqlite3.Connection, session_ids: list[str], query: str, limit: int) -> list[FtsHit]:
|
|
82
|
+
hits: list[FtsHit] = []
|
|
83
|
+
for start in range(0, len(session_ids), _CHUNK):
|
|
84
|
+
chunk = session_ids[start : start + _CHUNK]
|
|
85
|
+
placeholders = ",".join("?" * len(chunk))
|
|
86
|
+
rows = connection.execute(
|
|
87
|
+
f"SELECT session_id, snippet(search_index, 0, '', '', '…', 10), rank "
|
|
88
|
+
f"FROM search_index WHERE session_id IN ({placeholders}) "
|
|
89
|
+
f"AND source_type = 'turn' AND content MATCH ? ORDER BY rank LIMIT ?",
|
|
90
|
+
(*chunk, query, limit),
|
|
91
|
+
).fetchall()
|
|
92
|
+
hits.extend(FtsHit(session_id, " ".join(snippet.split()), rank) for session_id, snippet, rank in rows)
|
|
93
|
+
|
|
94
|
+
return hits
|