Add isolated Codex Desktop evolution sidecar

This commit is contained in:
Admin 2026-04-08 21:26:29 +08:00
parent b0021b46bb
commit 5b799551b0
13 changed files with 1455 additions and 36 deletions

47
AGENTS.md Normal file
View file

@ -0,0 +1,47 @@
# AGENTS
## Codex Desktop Sidecar Evolution
Use this workflow when the user is coding in Codex Desktop with their normal subscription login and wants OpenSpace to do post-task skill capture through the isolated `openspace_evolution` sidecar.
Rules:
- Keep the main coding workflow unchanged.
- Do not switch the main Codex Desktop session to a provider-backed model.
- Do not modify code as part of sidecar evolution unless the user separately asks for code changes.
- Do not let OpenSpace take over the main task.
- Use the sidecar only for post-task skill capture.
- Prefer at most 1 new high-reuse skill per invocation unless the user explicitly asks for more.
When the user asks for sidecar self-evolution, call:
- `openspace_evolution.evolve_from_context`
Trigger phrases:
- `sidecar 自进化一下`
- `做一次 sidecar 自进化`
- `对当前这轮工作做一次 sidecar 自进化`
- `用 sidecar 沉淀一个 skill`
- `基于当前改动做一次 sidecar skill capture`
- `不要改代码,做一次 sidecar 自进化`
If the user uses one of these phrases, default to this workflow automatically unless they explicitly ask for a different behavior.
Derive the tool inputs from:
- the current conversation
- the current `git diff`
- the key changed files
Behavior:
- Infer a concise `task`
- Infer a concise but specific `summary`
- Pass the most relevant changed files in `file_paths`
- Use `max_skills = 1` by default
- After the tool returns, report:
- the skill name
- the skill path
- why the skill is worth keeping
Recommended user-facing invocation:
```text
对当前这轮工作做一次 sidecar 自进化。不要改代码,不要接管任务。请调用 openspace_evolution.evolve_from_context基于当前对话、git diff 和关键改动,自动提炼 task/summary最多生成 1 个高复用 skill并告诉我 skill 名称、路径、为什么值得保留。
```

View file

@ -0,0 +1,221 @@
# Codex Desktop Sidecar Evolution Integration
## Goal
This integration keeps the normal Codex Desktop workflow unchanged while moving OpenSpace skill capture and self-evolution onto a separate provider-backed sidecar path.
The target user experience is:
- Main coding still happens in Codex Desktop with the user's normal subscription login.
- OpenSpace does not take over the main task loop.
- Sidecar evolution can be invoked explicitly after a task and spend provider API tokens instead of the main Codex Desktop session.
## Short Answer: Was this mainly an API-level dual routing change?
No.
The final effect does **not** come from a simple in-process "dual route" inside one OpenSpace runtime where:
- coding uses Codex Desktop subscription auth, and
- evolution uses a provider API
That approach is not viable because Codex Desktop subscription login is not exposed to the Python process as a reusable API credential.
Instead, the final implementation uses **process-level split routing**:
- the main coding session remains in Codex Desktop
- self-evolution runs through an isolated OpenSpace sidecar with its own provider-backed MCP server
API compatibility work was still necessary, but it is only one part of the solution.
## What Was Implemented
### 1. OpenAI-compatible provider bridge for OpenSpace
File:
- `openspace/llm/client.py`
Why it was needed:
- The third-party relay worked with Codex's `/responses` path.
- OpenSpace uses LiteLLM / OpenAI-style chat completion flows.
- The relay was not reliable enough for OpenSpace's normal streaming path.
What changed:
- Added an OpenAI-compatible streaming fallback that talks directly to `/chat/completions`.
- Reconstructed streamed text, reasoning content, and tool calls into the shape OpenSpace already expects.
- Enabled this path through `OPENSPACE_LLM_OPENAI_STREAM_COMPAT`.
Effect:
- OpenSpace can use the relay provider for evolution workloads.
### 2. Evolution-only MCP sidecar
File:
- `openspace/evolution_mcp_server.py`
Why it was needed:
- The user wanted OpenSpace to handle only post-task evolution and skill capture.
- The main coding loop had to stay outside OpenSpace.
What changed:
- Added a separate MCP server exposing only `evolve_from_context`.
- This server builds context from the current workspace, conversation summary, and git diff.
- It captures reusable skills without becoming the main task executor.
Effect:
- OpenSpace now has a narrow sidecar role instead of replacing the host coding agent.
### 3. Sidecar-capable skill engine without full task recording
File:
- `openspace/tool_layer.py`
Why it was needed:
- The original skill evolution path assumed a fuller OpenSpace task/recording pipeline.
- The new sidecar path needed to create skills without enabling the normal OpenSpace recording flow.
What changed:
- Added `enable_skill_engine_without_recording`.
- Kept execution analysis tied to recording.
- Allowed skill evolution and skill store initialization in sidecar mode without enabling full task recordings.
Effect:
- Sidecar capture can work independently without creating full OpenSpace task sessions.
### 4. Isolated Desktop launcher overlay
File:
- `scripts/codex-desktop-evolution`
Why it was needed:
- The main Codex Desktop session had to keep the user's normal login and defaults.
- The sidecar config had to be added without polluting `~/.codex`.
What changed:
- Created an overlay `CODEX_HOME` at `~/.codex-openspace-desktop`.
- Copied the primary Desktop auth and config base into the overlay.
- Added only one extra MCP server: `openspace_evolution`.
- Scrubbed `OPENSPACE_*` variables before launching the main Codex process.
- Avoided inheriting arbitrary shell state or leaking sidecar credentials into the main coding session.
Effect:
- Main Codex Desktop remains normal.
- The sidecar is available only in the isolated overlay profile.
### 5. Agent instruction trigger for sidecar capture
File:
- `AGENTS.md`
Why it was needed:
- The sidecar should be callable naturally from the Desktop workflow.
- The user should not need to restate the full MCP call every time.
What changed:
- Added a repo-level instruction that maps phrases like `sidecar 自进化一下` to `openspace_evolution.evolve_from_context`.
- Limited the default behavior to:
- no code changes
- no main-task takeover
- at most one high-reuse skill by default
Effect:
- The sidecar behaves like a narrow post-task tool integrated into the normal Desktop workflow.
## Other Supporting Changes
### MCP stdout flush fix
File:
- `openspace/mcp_server.py`
What changed:
- Avoided a final stdout flush crash when the MCP stdio transport closes before Python exit.
### Missing dependency for MCP backend
Files:
- `pyproject.toml`
- `requirements.txt`
What changed:
- Added `websockets>=15.0.0`
- Added `openspace-evolution-mcp` as a console entrypoint
### Frontend dependency refresh
File:
- `frontend/package-lock.json`
What changed:
- Updated `lodash-es`
- Updated `vite`
This was a maintenance fix and is not part of the sidecar architecture itself.
## Architecture Summary
The final architecture is:
1. Codex Desktop remains the main coding agent.
2. Codex Desktop keeps using the user's normal subscription login.
3. A separate overlay profile adds an `openspace_evolution` MCP server.
4. That MCP server runs OpenSpace with provider-backed credentials.
5. OpenSpace uses the provider only for post-task evolution and skill capture.
This means the practical "dual routing" exists at the workflow/process boundary, not as a single shared in-process auth router.
## Usage
Launch the Desktop profile that includes the sidecar:
```bash
cd /Users/admin/PycharmProjects/openspace
./scripts/codex-desktop-evolution app
```
Inside that Desktop session, trigger sidecar capture with:
```text
sidecar 自进化一下
```
or the longer explicit form:
```text
对当前这轮工作做一次 sidecar 自进化。不要改代码,不要接管任务。请调用 openspace_evolution.evolve_from_context基于当前对话、git diff 和关键改动,自动提炼 task/summary最多生成 1 个高复用 skill并告诉我 skill 名称、路径、为什么值得保留。
```
## Result
The implemented effect is:
- normal Codex Desktop coding stays unchanged
- OpenSpace self-evolution is available on demand
- provider token spend is isolated to the sidecar path
- the sidecar does not silently take over the main workflow

View file

@ -2512,9 +2512,9 @@
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/loose-envify": {
@ -3455,9 +3455,9 @@
"license": "MIT"
},
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
"dev": true,
"license": "MIT",
"dependencies": {

View file

@ -0,0 +1,606 @@
"""OpenSpace evolution-only MCP server.
This sidecar is designed for host-agent workflows where the main coding is
handled elsewhere (for example Codex Desktop with subscription auth), while
OpenSpace is only used to capture reusable skills via a separate provider.
"""
from __future__ import annotations
import asyncio
import inspect
import json
import logging
import os
import subprocess
import sys
import uuid
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
class _MCPSafeStdout:
"""Stdout wrapper: binary (.buffer) -> real stdout, text (.write) -> stderr."""
def __init__(self, real_stdout, stderr):
self._real = real_stdout
self._stderr = stderr
@property
def buffer(self):
return self._real.buffer
def fileno(self):
return self._real.fileno()
def write(self, s):
return self._stderr.write(s)
def writelines(self, lines):
return self._stderr.writelines(lines)
def flush(self):
self._stderr.flush()
try:
self._real.flush()
except ValueError:
pass
def isatty(self):
return self._stderr.isatty()
@property
def encoding(self):
return self._stderr.encoding
@property
def errors(self):
return self._stderr.errors
@property
def closed(self):
return self._stderr.closed
def readable(self):
return False
def writable(self):
return True
def seekable(self):
return False
def __getattr__(self, name):
return getattr(self._stderr, name)
_LOG_DIR = Path(__file__).resolve().parent.parent / "logs"
_LOG_DIR.mkdir(parents=True, exist_ok=True)
_real_stdout = sys.stdout
if os.name == "nt":
_stderr_file = open(
_LOG_DIR / "evolution_mcp_stderr.log", "a", encoding="utf-8", buffering=1
)
sys.stderr = _stderr_file
sys.stdout = _MCPSafeStdout(_real_stdout, sys.stderr)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
handlers=[logging.FileHandler(_LOG_DIR / "evolution_mcp_server.log")],
)
logger = logging.getLogger("openspace.evolution_mcp_server")
from mcp.server.fastmcp import FastMCP
_fastmcp_kwargs: dict = {}
try:
if "description" in inspect.signature(FastMCP.__init__).parameters:
_fastmcp_kwargs["description"] = (
"OpenSpace evolution sidecar: capture reusable skills from host-agent work."
)
except (TypeError, ValueError):
pass
mcp = FastMCP("OpenSpace Evolution", **_fastmcp_kwargs)
_openspace_instance = None
_openspace_lock = asyncio.Lock()
_UPLOAD_META_FILENAME = ".upload_meta.json"
def _json_ok(data: Any) -> str:
return json.dumps(data, ensure_ascii=False, indent=2)
def _json_error(error: Any, **extra) -> str:
return json.dumps({"error": str(error), **extra}, ensure_ascii=False)
async def _get_openspace():
global _openspace_instance
if _openspace_instance is not None and _openspace_instance.is_initialized():
return _openspace_instance
async with _openspace_lock:
if _openspace_instance is not None and _openspace_instance.is_initialized():
return _openspace_instance
logger.info("Initializing OpenSpace evolution engine ...")
from openspace.host_detection import (
build_grounding_config_path,
build_llm_kwargs,
load_runtime_env,
)
from openspace.tool_layer import OpenSpace, OpenSpaceConfig
load_runtime_env()
env_model = os.environ.get("OPENSPACE_MODEL", "")
workspace = os.environ.get("OPENSPACE_WORKSPACE")
enable_rec = os.environ.get("OPENSPACE_ENABLE_RECORDING", "false").lower() in (
"true",
"1",
"yes",
)
backend_scope_raw = os.environ.get("OPENSPACE_BACKEND_SCOPE", "shell,system")
backend_scope = [
b.strip() for b in backend_scope_raw.split(",") if b.strip()
] or None
config_path = build_grounding_config_path()
model, llm_kwargs = build_llm_kwargs(env_model)
config = OpenSpaceConfig(
llm_model=model,
llm_kwargs=llm_kwargs,
workspace_dir=workspace,
grounding_max_iterations=1,
enable_recording=enable_rec,
enable_skill_engine_without_recording=True,
recording_backends=["shell"] if enable_rec else None,
backend_scope=backend_scope,
grounding_config_path=config_path,
)
_openspace_instance = OpenSpace(config=config)
await _openspace_instance.initialize()
logger.info("OpenSpace evolution engine ready (model=%s).", model)
return _openspace_instance
def _write_upload_meta(skill_dir: Path, info: Dict[str, Any]) -> None:
meta = {
"origin": info.get("origin", "captured"),
"parent_skill_ids": info.get("parent_skill_ids", []),
"change_summary": info.get("change_summary", ""),
"created_by": info.get("created_by", "openspace"),
"tags": info.get("tags", []),
}
(skill_dir / _UPLOAD_META_FILENAME).write_text(
json.dumps(meta, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
def _extract_json_object(text: str) -> Dict[str, Any]:
raw = (text or "").strip()
if raw.startswith("```"):
raw = raw.strip("`")
parts = raw.split("\n", 1)
raw = parts[1] if len(parts) == 2 else raw
if raw.endswith("```"):
raw = raw[:-3].rstrip()
try:
data = json.loads(raw)
if isinstance(data, dict):
return data
except json.JSONDecodeError:
pass
start = raw.find("{")
end = raw.rfind("}")
if start >= 0 and end > start:
data = json.loads(raw[start : end + 1])
if isinstance(data, dict):
return data
raise ValueError("LLM did not return a valid JSON object")
def _run_git(args: List[str], cwd: Path) -> str:
try:
completed = subprocess.run(
["git", *args],
cwd=str(cwd),
text=True,
capture_output=True,
check=False,
)
except Exception as exc:
logger.debug("git %s failed: %s", " ".join(args), exc)
return ""
if completed.returncode != 0:
return ""
return completed.stdout.strip()
def _truncate(text: str, limit: int) -> str:
if len(text) <= limit:
return text
return text[: limit - 32].rstrip() + "\n...[truncated]..."
def _normalize_file_paths(
workspace: Path,
file_paths: Optional[Iterable[str]],
) -> List[Path]:
normalized: List[Path] = []
for raw in file_paths or []:
if not raw:
continue
path = Path(raw)
if not path.is_absolute():
path = workspace / path
normalized.append(path.resolve())
return normalized
def _build_repo_context(
workspace: Path,
file_paths: List[Path],
) -> str:
sections: List[str] = []
if (workspace / ".git").exists():
status = _run_git(["status", "--short"], workspace)
if status:
sections.append("## Git status\n" + _truncate(status, 4_000))
diff_stat = _run_git(["diff", "--stat"], workspace)
if diff_stat:
sections.append("## Git diff stat\n" + _truncate(diff_stat, 4_000))
staged_stat = _run_git(["diff", "--cached", "--stat"], workspace)
if staged_stat:
sections.append("## Git staged diff stat\n" + _truncate(staged_stat, 4_000))
if file_paths:
rel_paths = []
for path in file_paths:
try:
rel_paths.append(str(path.relative_to(workspace)))
except ValueError:
rel_paths.append(str(path))
scoped_diff = _run_git(
["diff", "--unified=1", "--", *rel_paths],
workspace,
)
if scoped_diff:
sections.append("## Focused diff\n" + _truncate(scoped_diff, 12_000))
if file_paths:
lines = ["## Mentioned files"]
for path in file_paths:
lines.append(f"- {path}")
sections.append("\n".join(lines))
return "\n\n".join(sections) if sections else "(no repository context available)"
def _existing_skill_names(registry) -> List[str]:
names = []
for meta in registry.list_skills():
names.append(meta.name)
return sorted(set(names))
def _build_planning_prompt(
*,
task: str,
summary: str,
workspace: Path,
repo_context: str,
existing_skills: List[str],
max_skills: int,
) -> str:
skill_list = "\n".join(f"- {name}" for name in existing_skills[:200]) or "(none)"
return f"""You are deciding which reusable OpenSpace skills should be captured from a completed coding task.
The main coding work was already completed by a host agent. Your job is ONLY to identify reusable patterns worth turning into new skills.
Task:
{task}
Execution summary:
{summary}
Workspace:
{workspace}
Repository context:
{repo_context}
Existing local skill names:
{skill_list}
Return exactly one JSON object with this shape:
{{
"suggestions": [
{{
"category": "workflow",
"direction": "1-2 sentences describing the reusable pattern to capture."
}}
]
}}
Rules:
- Suggest at most {max_skills} skills.
- Only suggest skills that are reusable across future tasks.
- Categories must be one of: "tool_guide", "workflow", "reference".
- Do not suggest trivial one-step actions.
- Do not restate repo-specific one-off details as a reusable skill.
- Avoid duplicating an existing skill unless the new capability is clearly distinct.
- If nothing is worth capturing, return {{"suggestions": []}}.
"""
async def _plan_suggestions(
*,
openspace,
task: str,
summary: str,
workspace: Path,
repo_context: str,
max_skills: int,
) -> List[Dict[str, str]]:
registry = openspace._skill_registry
if not registry:
return []
logger.info(
"Planning evolution captures for task=%r (max_skills=%d)",
task[:120],
max_skills,
)
prompt = _build_planning_prompt(
task=task,
summary=summary,
workspace=workspace,
repo_context=repo_context,
existing_skills=_existing_skill_names(registry),
max_skills=max_skills,
)
response = await openspace._llm_client.complete(
messages=prompt,
execute_tools=False,
model=openspace.config.llm_model,
)
data = _extract_json_object(response["message"]["content"])
raw_suggestions = data.get("suggestions", [])
if not isinstance(raw_suggestions, list):
raise ValueError("suggestions must be a list")
deduped: List[Dict[str, str]] = []
seen: set[tuple[str, str]] = set()
for item in raw_suggestions:
if not isinstance(item, dict):
continue
category = str(item.get("category", "")).strip()
direction = str(item.get("direction", "")).strip()
if category not in {"tool_guide", "workflow", "reference"} or not direction:
continue
key = (category, direction.lower())
if key in seen:
continue
seen.add(key)
deduped.append({"category": category, "direction": direction})
if len(deduped) >= max_skills:
break
logger.info("Planned %d capture suggestion(s)", len(deduped))
return deduped
async def _prepend_output_dir(openspace, output_dir: Path) -> None:
output_dir.mkdir(parents=True, exist_ok=True)
registry = openspace._skill_registry
if not registry:
return
if output_dir not in registry._skill_dirs:
registry._skill_dirs.insert(0, output_dir)
skill_store = openspace._skill_store
metas = registry.discover_from_dirs([output_dir])
if metas and skill_store:
await skill_store.sync_from_registry(metas)
async def _register_extra_skill_dirs(openspace, dirs: List[Path]) -> None:
registry = openspace._skill_registry
skill_store = openspace._skill_store
if not registry:
return
metas = registry.discover_from_dirs(dirs)
if metas and skill_store:
await skill_store.sync_from_registry(metas)
@mcp.tool()
async def evolve_from_context(
task: str,
summary: str,
workspace_dir: str | None = None,
file_paths: list[str] | None = None,
max_skills: int = 3,
skill_dirs: list[str] | None = None,
output_dir: str | None = None,
) -> str:
"""Capture reusable skills from a completed host-agent task.
Use this when the main task was already handled by another agent
(for example Codex Desktop) and OpenSpace should only spend provider
tokens on post-task skill capture.
Args:
task: Short description of the completed task.
summary: What changed, what was learned, and what seems reusable.
workspace_dir: Repository/workspace path. Defaults to OPENSPACE_WORKSPACE.
file_paths: Optional files worth emphasizing when planning captures.
max_skills: Maximum number of new skills to capture.
skill_dirs: Optional additional skill directories to register first.
output_dir: Override directory for new skills. Defaults to the first
OPENSPACE_HOST_SKILL_DIRS entry.
"""
try:
if not task.strip():
return _json_error("task is required", status="error")
if not summary.strip():
return _json_error("summary is required", status="error")
openspace = await _get_openspace()
if not openspace._skill_evolver or not openspace._skill_registry:
return _json_error("Skill evolution is not enabled", status="error")
workspace = Path(workspace_dir or openspace.config.workspace_dir or os.getcwd()).resolve()
normalized_paths = _normalize_file_paths(workspace, file_paths)
if skill_dirs:
extra_dirs = [Path(p).expanduser().resolve() for p in skill_dirs if p]
if extra_dirs:
await _register_extra_skill_dirs(openspace, extra_dirs)
if output_dir:
await _prepend_output_dir(openspace, Path(output_dir).expanduser().resolve())
repo_context = _build_repo_context(workspace, normalized_paths)
suggestions = await _plan_suggestions(
openspace=openspace,
task=task,
summary=summary,
workspace=workspace,
repo_context=repo_context,
max_skills=max(0, min(max_skills, 8)),
)
if not suggestions:
return _json_ok(
{
"status": "success",
"task": task,
"workspace_dir": str(workspace),
"suggestion_count": 0,
"created_skills": [],
"message": "No reusable skill captures were suggested.",
}
)
from openspace.skill_engine import EvolutionContext, EvolutionTrigger
from openspace.skill_engine.types import (
EvolutionSuggestion,
EvolutionType,
ExecutionAnalysis,
SkillCategory,
)
evolver = openspace._skill_evolver
task_id = f"sidecar_{uuid.uuid4().hex[:12]}"
now = datetime.now()
analysis = ExecutionAnalysis(
task_id=task_id,
timestamp=now,
task_completed=True,
execution_note=_truncate(summary, 1_500),
analyzed_by=openspace.config.llm_model,
analyzed_at=now,
)
created_skills: List[Dict[str, Any]] = []
skipped: List[Dict[str, str]] = []
for suggestion in suggestions:
logger.info(
"Capturing skill (%s): %s",
suggestion["category"],
suggestion["direction"][:180],
)
ctx = EvolutionContext(
trigger=EvolutionTrigger.ANALYSIS,
suggestion=EvolutionSuggestion(
evolution_type=EvolutionType.CAPTURED,
target_skill_ids=[],
category=SkillCategory(suggestion["category"]),
direction=suggestion["direction"],
),
source_task_id=task_id,
recent_analyses=[analysis],
available_tools=[],
)
new_record = await evolver.evolve(ctx)
if not new_record:
logger.info("Capture skipped by evolver")
skipped.append(suggestion)
continue
skill_dir = Path(new_record.path).parent if new_record.path else None
if skill_dir:
_write_upload_meta(
skill_dir,
{
"origin": new_record.lineage.origin.value,
"parent_skill_ids": new_record.lineage.parent_skill_ids,
"change_summary": new_record.lineage.change_summary,
"created_by": new_record.lineage.created_by or "openspace",
"tags": new_record.tags,
},
)
created_skills.append(
{
"name": new_record.name,
"skill_id": new_record.skill_id,
"skill_dir": str(skill_dir) if skill_dir else "",
"path": new_record.path,
"category": suggestion["category"],
"direction": suggestion["direction"],
"upload_ready": bool(skill_dir),
}
)
return _json_ok(
{
"status": "success",
"task": task,
"workspace_dir": str(workspace),
"suggestion_count": len(suggestions),
"created_skills": created_skills,
"skipped_suggestions": skipped,
}
)
except Exception as e:
logger.error("evolve_from_context failed: %s", e, exc_info=True)
return _json_error(e, status="error")
def run_mcp_server() -> None:
import argparse
parser = argparse.ArgumentParser(description="OpenSpace Evolution MCP Server")
parser.add_argument("--transport", choices=["stdio", "sse"], default="stdio")
parser.add_argument("--port", type=int, default=8080)
args = parser.parse_args()
if args.transport == "sse":
mcp.run(transport="sse", sse_params={"port": args.port})
else:
mcp.run(transport="stdio")
if __name__ == "__main__":
run_mcp_server()

View file

@ -1,8 +1,12 @@
import litellm
import json
import asyncio
import os
import time
from types import SimpleNamespace
from typing import List, Sequence, Union, Dict, Optional
import httpx
from openai.types.chat import ChatCompletionToolParam
from openspace.grounding.core.types import ToolSchema, ToolResult, ToolStatus
@ -20,6 +24,161 @@ litellm.suppress_debug_info = True
logger = Logger.get_logger(__name__)
def _is_truthy(value: object) -> bool:
return str(value).strip().lower() in {"1", "true", "yes", "on"}
def _should_use_openai_stream_compat(litellm_kwargs: Optional[Dict] = None) -> bool:
if litellm_kwargs and _is_truthy(litellm_kwargs.get("openai_stream_compat")):
return True
return _is_truthy(os.environ.get("OPENSPACE_LLM_OPENAI_STREAM_COMPAT", ""))
def _build_stream_response(
*,
content: str,
reasoning_content: Optional[str],
tool_calls: List[Dict[str, object]],
):
response_tool_calls = []
for tool_call in tool_calls:
response_tool_calls.append(
SimpleNamespace(
id=tool_call.get("id"),
type=tool_call.get("type", "function"),
function=SimpleNamespace(
name=tool_call.get("function", {}).get("name", ""),
arguments=tool_call.get("function", {}).get("arguments", ""),
),
)
)
message = SimpleNamespace(
content=content,
reasoning_content=reasoning_content,
tool_calls=response_tool_calls or None,
)
return SimpleNamespace(choices=[SimpleNamespace(message=message)])
async def _openai_compat_stream_completion(
*,
model: str,
messages: List[Dict],
timeout: float,
litellm_kwargs: Optional[Dict] = None,
tools: Optional[List[ChatCompletionToolParam]] = None,
tool_choice: Optional[str] = None,
reasoning_effort: Optional[str] = None,
):
kwargs = dict(litellm_kwargs or {})
api_key = kwargs.pop("api_key", None)
api_base = kwargs.pop("api_base", None)
extra_headers = kwargs.pop("extra_headers", None) or {}
kwargs.pop("openai_stream_compat", None)
if not api_key or not api_base:
raise ValueError(
"OpenAI stream compatibility mode requires api_key and api_base."
)
payload = {
"model": model,
"messages": messages,
"stream": True,
}
if tools:
payload["tools"] = tools
if tool_choice is not None:
payload["tool_choice"] = tool_choice
if reasoning_effort:
payload["reasoning_effort"] = reasoning_effort
for key in (
"temperature",
"top_p",
"presence_penalty",
"frequency_penalty",
"max_tokens",
"max_completion_tokens",
"parallel_tool_calls",
"response_format",
):
if key in kwargs and kwargs[key] is not None:
payload[key] = kwargs[key]
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
headers.update(extra_headers)
text_parts: List[str] = []
reasoning_parts: List[str] = []
streamed_tool_calls: Dict[int, Dict[str, object]] = {}
async with httpx.AsyncClient(timeout=timeout) as client:
async with client.stream(
"POST",
f"{api_base.rstrip('/')}/chat/completions",
headers=headers,
json=payload,
) as response:
if response.status_code >= 400:
body = await response.aread()
raise RuntimeError(
f"OpenAI-compat stream request failed: {response.status_code} "
f"{body.decode(errors='replace')}"
)
async for line in response.aiter_lines():
if not line or not line.startswith("data: "):
continue
data = line[6:].strip()
if not data or data == "[DONE]":
break
chunk = json.loads(data)
choices = chunk.get("choices") or []
if not choices:
continue
delta = choices[0].get("delta") or {}
content_delta = delta.get("content")
if content_delta:
text_parts.append(content_delta)
reasoning_delta = delta.get("reasoning_content")
if reasoning_delta:
reasoning_parts.append(reasoning_delta)
for tool_delta in delta.get("tool_calls") or []:
idx = tool_delta.get("index", 0)
state = streamed_tool_calls.setdefault(
idx,
{
"id": None,
"type": "function",
"function": {"name": "", "arguments": ""},
},
)
if tool_delta.get("id"):
state["id"] = tool_delta["id"]
if tool_delta.get("type"):
state["type"] = tool_delta["type"]
fn = tool_delta.get("function") or {}
if fn.get("name"):
state["function"]["name"] += fn["name"]
if fn.get("arguments"):
state["function"]["arguments"] += fn["arguments"]
return _build_stream_response(
content="".join(text_parts),
reasoning_content="".join(reasoning_parts) or None,
tool_calls=[streamed_tool_calls[i] for i in sorted(streamed_tool_calls)],
)
def _sanitize_schema(params: Dict) -> Dict:
"""Sanitize tool parameter schema to comply with Claude API requirements.
@ -263,15 +422,26 @@ Content:
Concise summary:"""
_extra = litellm_kwargs or {}
response = await asyncio.wait_for(
litellm.acompletion(
model=model,
messages=[{"role": "user", "content": prompt}],
timeout=timeout,
**_extra,
),
timeout=timeout + 5
)
if _should_use_openai_stream_compat(_extra):
response = await asyncio.wait_for(
_openai_compat_stream_completion(
model=model,
messages=[{"role": "user", "content": prompt}],
timeout=timeout,
litellm_kwargs=_extra,
),
timeout=timeout + 5,
)
else:
response = await asyncio.wait_for(
litellm.acompletion(
model=model,
messages=[{"role": "user", "content": prompt}],
timeout=timeout,
**_extra,
),
timeout=timeout + 5
)
summary = response.choices[0].message.content.strip()
result = f"[SUMMARY of {len(content):,} chars]\n{summary}"
@ -552,10 +722,24 @@ class LLMClient:
for attempt in range(self.max_retries):
try:
# Add timeout to the completion call
response = await asyncio.wait_for(
litellm.acompletion(**completion_kwargs),
timeout=self.timeout
)
if _should_use_openai_stream_compat(completion_kwargs):
response = await asyncio.wait_for(
_openai_compat_stream_completion(
model=completion_kwargs["model"],
messages=completion_kwargs["messages"],
timeout=self.timeout,
litellm_kwargs=completion_kwargs,
tools=completion_kwargs.get("tools"),
tool_choice=completion_kwargs.get("tool_choice"),
reasoning_effort=completion_kwargs.get("reasoning_effort"),
),
timeout=self.timeout,
)
else:
response = await asyncio.wait_for(
litellm.acompletion(**completion_kwargs),
timeout=self.timeout
)
return response
except asyncio.TimeoutError:
self._logger.error(

View file

@ -48,7 +48,11 @@ class _MCPSafeStdout:
def flush(self):
self._stderr.flush()
self._real.flush()
try:
self._real.flush()
except ValueError:
# The MCP stdio transport may close stdout before Python's final flush.
pass
def isatty(self):
return self._stderr.isatty()

View file

@ -58,6 +58,7 @@ class OpenSpaceConfig:
enable_screenshot: bool = False
enable_video: bool = False
enable_conversation_log: bool = True # Save LLM conversations to conversations.jsonl
enable_skill_engine_without_recording: bool = False # Allow sidecar evolution without full task recording
# Skill Evolution
evolution_max_concurrent: int = 3 # Max parallel evolutions per trigger
@ -242,8 +243,13 @@ class OpenSpace:
logger.info(f"✓ Skills: {len(skills)} discovered")
self._grounding_agent.set_skill_registry(self._skill_registry)
# Initialize ExecutionAnalyzer (requires recording + skills)
if self.config.enable_recording and self._skill_registry:
# Initialize the skill engine whenever skills are available.
# Execution analysis still requires recordings, but skill capture
# can run without them (for host-agent sidecar workflows).
if self._skill_registry and (
self.config.enable_recording
or self.config.enable_skill_engine_without_recording
):
try:
skill_store = SkillStore()
self._skill_store = skill_store # Expose for MCP server reuse
@ -257,19 +263,6 @@ class OpenSpace:
# Bridge: pass quality_manager so analysis can feed back
# LLM-identified tool issues to the tool quality system.
quality_mgr = (
self._grounding_client.quality_manager
if self._grounding_client else None
)
self._execution_analyzer = ExecutionAnalyzer(
store=skill_store,
llm_client=self._llm_client,
model=self.config.execution_analyzer_model,
skill_registry=self._skill_registry,
quality_manager=quality_mgr,
)
logger.info("✓ Execution analysis enabled")
# Share store with GroundingAgent so retrieve_skill
# can access quality metrics for LLM selection.
self._grounding_agent._skill_store = skill_store
@ -287,8 +280,22 @@ class OpenSpace:
f"✓ Skill evolution enabled "
f"(concurrent={self.config.evolution_max_concurrent})"
)
if self.config.enable_recording:
quality_mgr = (
self._grounding_client.quality_manager
if self._grounding_client else None
)
self._execution_analyzer = ExecutionAnalyzer(
store=skill_store,
llm_client=self._llm_client,
model=self.config.execution_analyzer_model,
skill_registry=self._skill_registry,
quality_manager=quality_mgr,
)
logger.info("✓ Execution analysis enabled")
except Exception as e:
logger.warning(f"Execution analyzer init failed (non-fatal): {e}")
logger.warning(f"Skill engine init failed (non-fatal): {e}")
self._initialized = True
logger.info("="*60)

View file

@ -19,6 +19,7 @@ dependencies = [
"openai>=1.0.0",
"jsonschema>=4.25.0",
"mcp>=1.0.0",
"websockets>=15.0.0",
"anthropic>=0.71.0",
"pillow>=12.0.0",
"numpy>=1.24.0",
@ -69,6 +70,7 @@ Repository = "https://github.com/HKUDS/OpenSpace"
openspace = "openspace.__main__:run_main"
openspace-server = "openspace.local_server.main:main"
openspace-mcp = "openspace.mcp_server:run_mcp_server"
openspace-evolution-mcp = "openspace.evolution_mcp_server:run_mcp_server"
openspace-download-skill = "openspace.cloud.cli.download_skill:main"
openspace-upload-skill = "openspace.cloud.cli.upload_skill:main"
openspace-dashboard = "openspace.dashboard_server:main"

View file

@ -4,6 +4,7 @@ python-dotenv>=1.0.0
openai>=1.0.0
jsonschema>=4.25.0
mcp>=1.0.0
websockets>=15.0.0
anthropic>=0.71.0
pillow>=12.0.0
numpy>=1.24.0

170
scripts/codex-desktop-evolution Executable file
View file

@ -0,0 +1,170 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)"
ENV_FILE="$REPO_ROOT/openspace/.env"
PRIMARY_CODEX_HOME="${PRIMARY_CODEX_HOME:-$HOME/.codex}"
PROFILE_HOME="${OPENSPACE_CODEX_HOME:-$HOME/.codex-openspace-desktop}"
PROJECT_NAME="$(basename "$REPO_ROOT")"
PROJECT_SKILL_DIR="$PROFILE_HOME/projects/$PROJECT_NAME/skills"
REPO_PYTHON="$REPO_ROOT/.venv/bin/python"
if [[ ! -f "$PRIMARY_CODEX_HOME/config.toml" ]]; then
echo "Missing $PRIMARY_CODEX_HOME/config.toml" >&2
exit 1
fi
if [[ ! -f "$PRIMARY_CODEX_HOME/auth.json" ]]; then
echo "Missing $PRIMARY_CODEX_HOME/auth.json" >&2
exit 1
fi
read_env_value() {
local key="$1"
if [[ ! -f "$ENV_FILE" ]]; then
return 0
fi
python3 - <<'PY' "$ENV_FILE" "$key"
from pathlib import Path
import sys
env_path = Path(sys.argv[1])
target = sys.argv[2]
for line in env_path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
continue
key, value = stripped.split("=", 1)
if key.strip() != target:
continue
value = value.strip().strip('"').strip("'")
print(value, end="")
break
PY
}
OPENSPACE_MODEL="${OPENSPACE_MODEL:-$(read_env_value OPENSPACE_MODEL)}"
OPENSPACE_MODEL="${OPENSPACE_MODEL:-gpt-5.4}"
OPENSPACE_LLM_API_KEY="${OPENSPACE_LLM_API_KEY:-$(read_env_value OPENSPACE_LLM_API_KEY)}"
OPENSPACE_LLM_API_BASE="${OPENSPACE_LLM_API_BASE:-$(read_env_value OPENSPACE_LLM_API_BASE)}"
OPENSPACE_LLM_API_BASE="${OPENSPACE_LLM_API_BASE:-https://codexapi.space/v1}"
OPENSPACE_LLM_OPENAI_STREAM_COMPAT="${OPENSPACE_LLM_OPENAI_STREAM_COMPAT:-$(read_env_value OPENSPACE_LLM_OPENAI_STREAM_COMPAT)}"
OPENSPACE_LLM_OPENAI_STREAM_COMPAT="${OPENSPACE_LLM_OPENAI_STREAM_COMPAT:-true}"
sync_profile_dir() {
local name="$1"
local src="$PRIMARY_CODEX_HOME/$name"
local dst="$PROFILE_HOME/$name"
if [[ ! -d "$src" ]]; then
return
fi
mkdir -p "$dst"
rsync -a --delete "$src/" "$dst/"
}
bootstrap_profile_home() {
mkdir -p "$PROFILE_HOME"
mkdir -p "$PROJECT_SKILL_DIR"
mkdir -p "$PROFILE_HOME/skills"
sync_profile_dir "plugins"
sync_profile_dir "skills"
sync_profile_dir "pua"
cp "$PRIMARY_CODEX_HOME/auth.json" "$PROFILE_HOME/auth.json"
python3 - <<'PY' "$PRIMARY_CODEX_HOME/config.toml" "$PROFILE_HOME/config.toml" "$REPO_ROOT" "$REPO_PYTHON" "$PROJECT_SKILL_DIR" "$PROFILE_HOME/skills" "$OPENSPACE_MODEL" "$OPENSPACE_LLM_API_KEY" "$OPENSPACE_LLM_API_BASE" "$OPENSPACE_LLM_OPENAI_STREAM_COMPAT"
from pathlib import Path
import os
import sys
src = Path(sys.argv[1])
dst = Path(sys.argv[2])
repo_root = sys.argv[3]
python_cmd = sys.argv[4]
project_skill_dir = sys.argv[5]
profile_skill_dir = sys.argv[6]
model = sys.argv[7]
api_key = sys.argv[8]
api_base = sys.argv[9]
stream_compat = sys.argv[10]
def strip_tables(text: str, table_names: set[str]) -> str:
kept = []
skipping = False
for line in text.splitlines():
stripped = line.strip()
header = stripped.split("#", 1)[0].rstrip()
if header.startswith("[") and header.endswith("]"):
skipping = header in table_names or header.startswith("[mcp_servers.openspace_evolution.")
if skipping:
continue
if skipping:
continue
kept.append(line)
return "\n".join(kept).rstrip() + "\n"
base = strip_tables(
src.read_text(encoding="utf-8"),
{
"[mcp_servers.openspace_evolution]",
"[mcp_servers.openspace_evolution.env]",
},
)
project_marker = f'[projects."{repo_root}"]'
if project_marker not in base:
base += f'\n{project_marker}\ntrust_level = "trusted"\n'
if api_key and Path(python_cmd).is_file() and os.access(python_cmd, os.X_OK):
base += f'''
[mcp_servers.openspace_evolution]
command = "{python_cmd}"
args = ["-m", "openspace.evolution_mcp_server", "--transport", "stdio"]
[mcp_servers.openspace_evolution.env]
OPENSPACE_WORKSPACE = "{repo_root}"
OPENSPACE_HOST_SKILL_DIRS = "{project_skill_dir},{profile_skill_dir}"
OPENSPACE_MODEL = "{model}"
OPENSPACE_LLM_API_KEY = "{api_key}"
OPENSPACE_LLM_API_BASE = "{api_base}"
OPENSPACE_LLM_OPENAI_STREAM_COMPAT = "{stream_compat}"
OPENSPACE_ENABLE_RECORDING = "false"
OPENSPACE_BACKEND_SCOPE = "shell,system"
'''
dst.write_text(base, encoding="utf-8")
PY
}
bootstrap_profile_home
clear_openspace_env() {
local var
for var in ${!OPENSPACE_@}; do
unset "$var"
done
}
if [[ -z "$OPENSPACE_LLM_API_KEY" ]]; then
echo "Warning: OpenSpace evolution sidecar is disabled because no provider key was found in $ENV_FILE or the current environment." >&2
elif [[ ! -x "$REPO_PYTHON" ]]; then
echo "Warning: OpenSpace evolution sidecar is disabled because $REPO_PYTHON is missing." >&2
fi
clear_openspace_env
if [[ "${1:-}" == "app" ]]; then
shift
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
exec env CODEX_HOME="$PROFILE_HOME" codex app "$@"
fi
exec env CODEX_HOME="$PROFILE_HOME" codex app "$@" "$REPO_ROOT"
fi
exec env CODEX_HOME="$PROFILE_HOME" codex -C "$REPO_ROOT" "$@"

136
scripts/codex-openspace Executable file
View file

@ -0,0 +1,136 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)"
ENV_FILE="$REPO_ROOT/openspace/.env"
PRIMARY_CODEX_HOME="${PRIMARY_CODEX_HOME:-$HOME/.codex}"
PROFILE_HOME="${CODEX_HOME:-$HOME/.codex-openspace}"
if [[ ! -f "$ENV_FILE" ]]; then
echo "Missing $ENV_FILE" >&2
exit 1
fi
set -a
source "$ENV_FILE"
set +a
if [[ -z "${OPENSPACE_LLM_API_KEY:-}" ]]; then
echo "OPENSPACE_LLM_API_KEY is missing in $ENV_FILE" >&2
exit 1
fi
OPENSPACE_MODEL="${OPENSPACE_MODEL:-gpt-5.4}"
OPENSPACE_LLM_API_BASE="${OPENSPACE_LLM_API_BASE:-https://codexapi.space/v1}"
OPENSPACE_LLM_OPENAI_STREAM_COMPAT="${OPENSPACE_LLM_OPENAI_STREAM_COMPAT:-true}"
sync_profile_dir() {
local name="$1"
local src="$PRIMARY_CODEX_HOME/$name"
local dst="$PROFILE_HOME/$name"
if [[ ! -d "$src" ]]; then
return
fi
if [[ -L "$dst" ]]; then
rm -f "$dst"
fi
mkdir -p "$dst"
rsync -a --delete "$src/" "$dst/"
}
bootstrap_profile_home() {
mkdir -p "$PROFILE_HOME"
sync_profile_dir "plugins"
sync_profile_dir "skills"
sync_profile_dir "pua"
cat > "$PROFILE_HOME/auth.json" <<EOF
{
"OPENAI_API_KEY": "$OPENSPACE_LLM_API_KEY"
}
EOF
cat > "$PROFILE_HOME/config.toml" <<EOF
model_provider = "codexapi"
model = "$OPENSPACE_MODEL"
model_context_window = 272000
model_auto_compact_token_limit = 220000
model_reasoning_effort = "xhigh"
model_verbosity = "high"
personality = "pragmatic"
network_access = "enabled"
disable_response_storage = true
windows_wsl_setup_acknowledged = true
[projects."$REPO_ROOT"]
trust_level = "trusted"
[mcp_servers.zotero]
enabled = false
type = "stdio"
command = "/Users/admin/.local/bin/zotero-mcp"
[mcp_servers.zotero.env]
ZOTERO_LOCAL = "true"
[mcp_servers.playwright]
type = "stdio"
command = "npx"
args = ["@playwright/mcp@latest", "--user-data-dir=/Users/admin/Library/Caches/ms-playwright/codex-mcp-chrome"]
[mcp_servers.figma]
url = "https://mcp.figma.com/mcp"
enabled = true
[mcp_servers.linear]
enabled = false
url = "https://mcp.linear.app/mcp"
[mcp_servers.openspace]
command = "$REPO_ROOT/.venv/bin/openspace-mcp"
args = ["--transport", "stdio"]
[mcp_servers.openspace.env]
OPENSPACE_HOST_SKILL_DIRS = "$PROFILE_HOME/skills"
OPENSPACE_WORKSPACE = "$REPO_ROOT"
OPENSPACE_MODEL = "$OPENSPACE_MODEL"
OPENSPACE_LLM_API_KEY = "$OPENSPACE_LLM_API_KEY"
OPENSPACE_LLM_API_BASE = "$OPENSPACE_LLM_API_BASE"
OPENSPACE_LLM_OPENAI_STREAM_COMPAT = "$OPENSPACE_LLM_OPENAI_STREAM_COMPAT"
[model_providers.codexapi]
name = "codexapi"
base_url = "$OPENSPACE_LLM_API_BASE"
wire_api = "responses"
requires_openai_auth = false
[features]
multi_agent = true
[plugins."github@openai-curated"]
enabled = true
[plugins."build-web-apps@openai-curated"]
enabled = true
[plugins."figma@openai-curated"]
enabled = true
EOF
}
bootstrap_profile_home
if [[ "${1:-}" == "app" ]]; then
shift
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
exec env CODEX_HOME="$PROFILE_HOME" codex app "$@"
fi
exec env CODEX_HOME="$PROFILE_HOME" codex app "$@" "$REPO_ROOT"
fi
exec env CODEX_HOME="$PROFILE_HOME" codex -C "$REPO_ROOT" "$@"

5
scripts/codex-openspace.sh Executable file
View file

@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
exec "$SCRIPT_DIR/codex-openspace" "$@"

36
scripts/openspace.sh Executable file
View file

@ -0,0 +1,36 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)"
ALT_HOME="${CODEX_HOME:-$HOME/.codex-openspace}"
AUTH_FILE="${OPENSPACE_AUTH_FILE:-$ALT_HOME/auth.json}"
api_key="${OPENSPACE_LLM_API_KEY:-}"
if [[ -z "$api_key" ]]; then
api_key="$(
python3 - "$AUTH_FILE" <<'PY'
import json
import sys
from pathlib import Path
auth_path = Path(sys.argv[1])
data = json.loads(auth_path.read_text(encoding="utf-8"))
print(data.get("OPENAI_API_KEY", ""), end="")
PY
)"
fi
if [[ -z "$api_key" ]]; then
echo "OPENSPACE_LLM_API_KEY is not set and $AUTH_FILE does not contain OPENAI_API_KEY" >&2
exit 1
fi
export OPENSPACE_MODEL="${OPENSPACE_MODEL:-gpt-5.4}"
export OPENSPACE_LLM_API_KEY="$api_key"
export OPENSPACE_LLM_API_BASE="${OPENSPACE_LLM_API_BASE:-https://codexapi.space/v1}"
export OPENSPACE_LLM_OPENAI_STREAM_COMPAT="${OPENSPACE_LLM_OPENAI_STREAM_COMPAT:-true}"
export OPENSPACE_HOST_SKILL_DIRS="${OPENSPACE_HOST_SKILL_DIRS:-$ALT_HOME/skills}"
export OPENSPACE_WORKSPACE="${OPENSPACE_WORKSPACE:-$REPO_ROOT}"
exec "$REPO_ROOT/.venv/bin/openspace" "$@"