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 ADDED
@@ -0,0 +1,4 @@
1
+ # Copyright (c) 2026 Gustavo de Rosa.
2
+ # Licensed under the MIT license.
3
+
4
+ __version__ = "0.1.0"
cpmux/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ # Copyright (c) 2026 Gustavo de Rosa.
2
+ # Licensed under the MIT license.
3
+
4
+ from cpmux.ui.cli import main
5
+
6
+ if __name__ == "__main__":
7
+ main()
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)
@@ -0,0 +1,2 @@
1
+ # Copyright (c) 2026 Gustavo de Rosa.
2
+ # Licensed under the MIT license.
@@ -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