mirror of
https://github.com/HKUDS/OpenSpace.git
synced 2026-08-28 05:15:00 +00:00
235 lines
7.8 KiB
Python
235 lines
7.8 KiB
Python
"""Path helpers for OpenSpace instruction and auto memory.
|
|
|
|
Implementation notes:
|
|
- ``utils/config.ts::getMemoryPath``
|
|
- ``utils/config.ts::getManagedClaudeRulesDir``
|
|
- ``utils/config.ts::getUserClaudeRulesDir``
|
|
|
|
OpenSpace keeps the same branch structure but renames
|
|
``CLAUDE.md``/``.claude`` to ``OPENSPACE.md``/``.openspace``.
|
|
Step 15.2 fills in the ``AutoMem`` branch via ``memdir.py``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import subprocess
|
|
from pathlib import Path
|
|
from typing import Literal, Optional
|
|
|
|
MemorySource = Literal["Managed", "User", "Project", "Local", "AutoMem"]
|
|
|
|
OPENSPACE_MD = "OPENSPACE.md"
|
|
OPENSPACE_LOCAL_MD = "OPENSPACE.local.md"
|
|
OPENSPACE_DIR = ".openspace"
|
|
RULES_DIR = "rules"
|
|
MANAGED_OPENSPACE_DIR = Path("/etc/openspace")
|
|
OPENSPACE_CONFIG_HOME_ENV = "OPENSPACE_CONFIG_HOME"
|
|
|
|
|
|
def get_openspace_config_home_dir(config_home: Optional[str | Path] = None) -> Path:
|
|
"""Return the user-level OpenSpace config directory.
|
|
|
|
OpenSpace uses ``getClaudeConfigHomeDir()``. OS does not yet have a global
|
|
config-home helper, so this keeps the standard ``~/.openspace`` default
|
|
and allows tests/embedders to pass an explicit directory.
|
|
"""
|
|
|
|
if config_home is not None:
|
|
return Path(config_home).expanduser().resolve()
|
|
env_home = os.environ.get(OPENSPACE_CONFIG_HOME_ENV)
|
|
if env_home:
|
|
return Path(env_home).expanduser().resolve()
|
|
return (Path.home() / ".openspace").resolve()
|
|
|
|
|
|
def get_memory_path(
|
|
memory_type: MemorySource,
|
|
*,
|
|
cwd: Optional[str | Path] = None,
|
|
project_root: Optional[str | Path] = None,
|
|
config_home: Optional[str | Path] = None,
|
|
managed_dir: Optional[str | Path] = None,
|
|
) -> Optional[Path]:
|
|
"""OpenSpace ``getMemoryPath`` equivalent for OpenSpace names.
|
|
|
|
OpenSpace's team-memory branch is skipped by DEC-025 and is not part of the
|
|
OpenSpace runtime type surface.
|
|
"""
|
|
|
|
current_dir = Path(cwd or os.getcwd()).expanduser().resolve()
|
|
managed_root = Path(managed_dir or MANAGED_OPENSPACE_DIR).expanduser().resolve()
|
|
|
|
if memory_type == "User":
|
|
return get_openspace_config_home_dir(config_home) / OPENSPACE_MD
|
|
if memory_type == "Local":
|
|
return current_dir / OPENSPACE_LOCAL_MD
|
|
if memory_type == "Project":
|
|
return current_dir / OPENSPACE_MD
|
|
if memory_type == "Managed":
|
|
return managed_root / OPENSPACE_MD
|
|
if memory_type == "AutoMem":
|
|
from .memdir import get_auto_mem_entrypoint
|
|
|
|
return get_auto_mem_entrypoint(
|
|
cwd=current_dir,
|
|
project_root=project_root or find_project_root(current_dir),
|
|
config_home=config_home,
|
|
)
|
|
raise ValueError(f"Unknown memory type: {memory_type!r}")
|
|
|
|
|
|
def get_managed_openspace_rules_dir(
|
|
*, managed_dir: Optional[str | Path] = None
|
|
) -> Path:
|
|
managed_root = Path(managed_dir or MANAGED_OPENSPACE_DIR).expanduser().resolve()
|
|
return managed_root / OPENSPACE_DIR / RULES_DIR
|
|
|
|
|
|
def get_user_openspace_rules_dir(
|
|
*, config_home: Optional[str | Path] = None
|
|
) -> Path:
|
|
return get_openspace_config_home_dir(config_home) / RULES_DIR
|
|
|
|
|
|
def find_project_root(cwd: Optional[str | Path] = None) -> Path:
|
|
"""Return the git root for *cwd*, falling back to *cwd* itself.
|
|
|
|
The 15.1 design doc intentionally scopes project memory to project root
|
|
→ cwd instead of OpenSpace's filesystem-root → cwd walk. The explicit fallback
|
|
keeps non-git directories deterministic.
|
|
"""
|
|
|
|
current_dir = Path(cwd or os.getcwd()).expanduser().resolve()
|
|
try:
|
|
result = subprocess.run(
|
|
["git", "-C", str(current_dir), "rev-parse", "--show-toplevel"],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=2,
|
|
)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return current_dir
|
|
|
|
root = result.stdout.strip()
|
|
if result.returncode == 0 and root:
|
|
try:
|
|
return Path(root).expanduser().resolve()
|
|
except OSError:
|
|
return current_dir
|
|
return current_dir
|
|
|
|
|
|
def _is_relative_to(path: Path, root: Path) -> bool:
|
|
try:
|
|
path.relative_to(root)
|
|
return True
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def _safe_resolve(path: Path) -> Path:
|
|
try:
|
|
return path.expanduser().resolve()
|
|
except OSError:
|
|
# ``strict=False`` is the default, but resolve can still raise on
|
|
# broken permissions or malformed symlinks. Absolute normalization is
|
|
# sufficient for paths that will be skipped if unreadable later.
|
|
return path.expanduser().absolute()
|
|
|
|
|
|
def is_rules_file_path(path: str | Path) -> bool:
|
|
parts = Path(path).parts
|
|
return (
|
|
Path(path).name.endswith(".md")
|
|
and OPENSPACE_DIR in parts
|
|
and RULES_DIR in parts
|
|
and _has_adjacent_parts(parts, OPENSPACE_DIR, RULES_DIR)
|
|
)
|
|
|
|
|
|
def is_standard_memory_filename(path: str | Path) -> bool:
|
|
return Path(path).name in {OPENSPACE_MD, OPENSPACE_LOCAL_MD}
|
|
|
|
|
|
def _has_adjacent_parts(parts: tuple[str, ...], first: str, second: str) -> bool:
|
|
for index, part in enumerate(parts[:-1]):
|
|
if part == first and parts[index + 1] == second:
|
|
return True
|
|
return False
|
|
|
|
|
|
def validate_memory_path(
|
|
path: str | Path,
|
|
*,
|
|
memory_type: MemorySource,
|
|
cwd: Optional[str | Path] = None,
|
|
project_root: Optional[str | Path] = None,
|
|
config_home: Optional[str | Path] = None,
|
|
managed_dir: Optional[str | Path] = None,
|
|
allow_external: bool = False,
|
|
) -> Path:
|
|
"""Validate a top-level OPENSPACE.md/rules path and return its resolved path.
|
|
|
|
This is intentionally a structural path check, not a permission-engine
|
|
prompt. Startup context loading is not model-initiated tool access.
|
|
"""
|
|
|
|
raw_path = Path(path).expanduser()
|
|
resolved = _safe_resolve(raw_path)
|
|
|
|
current_dir = Path(cwd or os.getcwd()).expanduser().resolve()
|
|
project_base = Path(project_root).expanduser().resolve() if project_root else current_dir
|
|
config_base = get_openspace_config_home_dir(config_home)
|
|
managed_base = Path(managed_dir or MANAGED_OPENSPACE_DIR).expanduser().resolve()
|
|
|
|
if resolved.suffix != ".md":
|
|
raise ValueError(f"Memory path must be a markdown file: {resolved}")
|
|
if not (
|
|
is_standard_memory_filename(resolved)
|
|
or is_rules_file_path(resolved)
|
|
or _is_user_rules_file(resolved, config_base)
|
|
or _is_managed_rules_file(resolved, managed_base)
|
|
or (memory_type == "AutoMem" and resolved.name == "MEMORY.md")
|
|
):
|
|
raise ValueError(f"Not an OpenSpace memory path: {resolved}")
|
|
|
|
allowed_roots: list[Path]
|
|
if memory_type == "Managed":
|
|
allowed_roots = [managed_base]
|
|
elif memory_type == "User":
|
|
allowed_roots = [config_base]
|
|
elif memory_type == "Project":
|
|
allowed_roots = [project_base]
|
|
elif memory_type == "Local":
|
|
allowed_roots = [current_dir]
|
|
elif memory_type == "AutoMem":
|
|
from .memdir import get_auto_mem_path
|
|
|
|
allowed_roots = [
|
|
get_auto_mem_path(
|
|
cwd=current_dir,
|
|
project_root=project_base,
|
|
config_home=config_home,
|
|
)
|
|
]
|
|
else:
|
|
raise ValueError(f"Unknown memory type: {memory_type!r}")
|
|
|
|
if allow_external:
|
|
return resolved
|
|
if not any(_is_relative_to(resolved, root) for root in allowed_roots):
|
|
roots = ", ".join(str(root) for root in allowed_roots)
|
|
raise ValueError(f"Memory path escapes allowed root(s) {roots}: {resolved}")
|
|
return resolved
|
|
|
|
|
|
def _is_user_rules_file(path: Path, config_base: Path) -> bool:
|
|
rules_root = config_base / RULES_DIR
|
|
return path.suffix == ".md" and _is_relative_to(path, rules_root)
|
|
|
|
|
|
def _is_managed_rules_file(path: Path, managed_base: Path) -> bool:
|
|
rules_root = managed_base / OPENSPACE_DIR / RULES_DIR
|
|
return path.suffix == ".md" and _is_relative_to(path, rules_root)
|