完成运行时与启动链路的全面相对路径整改,统一以仓库根为路径基准并移除 oclaw 双层硬编码。

同时补齐路径回归测试与开源自检文档,确保从非仓库 cwd 启动时 workspace 根、配置和 wiki 路径仍稳定可用。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-26 09:22:42 +08:00
parent dbbe3add6a
commit 9d2900db02
40 changed files with 223 additions and 82 deletions

View file

@ -1,12 +1,21 @@
# Oclaw Migration Guide # Oclaw Migration Guide
This repository is migrating from legacy `oclaw/` runtime wiring to the new `oclaw/` architecture root. This repository now uses **repo-root relative** runtime wiring.
## Current status ## Current status
- `oclaw/` is the target root for new code. - The repository root is the only runtime base path.
- `oclaw/app_server/*` compatibility modules have been removed. - `oclaw/app_server/*` compatibility modules have been removed.
- Gateway dispatch now routes through shared `server_methods` handlers for both WS and HTTP method endpoints. - Gateway dispatch now routes through shared `server_methods` handlers for both WS and HTTP method endpoints.
## Path baseline (important for open-source portability)
- Do not hardcode `PROJECT_ROOT / "oclaw" / ...`.
- Use repo-root relative paths such as:
- config: `oclaw.json`
- data: `data/ai_ops.sqlite`
- workspaces: `runtime/workspaces/*`
- wiki root default: `docs/memory-system/wiki`
- Startup scripts should run from repo root and explicitly set workspace env vars; avoid relying on parent-directory cwd.
## Developer rules ## Developer rules
- Add new business logic under `oclaw/` (interfaces/application/domain/infrastructure/shared). - Add new business logic under `oclaw/` (interfaces/application/domain/infrastructure/shared).
- Avoid adding new core logic into legacy `oclaw/` modules. - Avoid adding new core logic into legacy `oclaw/` modules.

View file

@ -15,6 +15,19 @@
补充:工具脚本也统一放在 `scripts/`(例如 `seed_mcp_registry.py`、`ws_probe.py`)。 补充:工具脚本也统一放在 `scripts/`(例如 `seed_mcp_registry.py`、`ws_probe.py`)。
路径基准约定(开源必读):
- 运行目录固定为仓库根(`.../oclaw`),不要把网关/worker 常驻进程放到上一级目录。
- 默认配置文件路径为仓库根下的 `oclaw.json`(可用 `OCLAW_CONFIG_PATH` 覆盖)。
- 默认数据目录为仓库根下的 `data/`(主库:`data/ai_ops.sqlite`)。
- 相对路径一律按仓库根解析;不要在代码里拼接 `oclaw/...` 二级前缀。
开源前路径自检清单:
- 把仓库目录临时重命名后,`scripts/start_gateway.ps1 -SkipInstall -Background` 仍能启动。
- `http://127.0.0.1:8787/chat` 返回 200,且后台日志中不出现 `.../oclaw/oclaw/...` 路径。
- `python -m pytest tests/test_workspace_path_guard.py tests/test_oclaw_startup_workspace_resolution.py -q` 通过。
--- ---
## 2. 首次初始化 ## 2. 首次初始化
@ -362,7 +375,7 @@ AIA_REPLAY_REASONING_SIGNATURE_POLICY=auto
## 13. memory-wiki 插件启用与排障 ## 13. memory-wiki 插件启用与排障
### 13.1 启用配置(oclaw/oclaw.json) ### 13.1 启用配置(oclaw.json)
将 `memory-wiki` 放入启用列表,并建议把 memory slot 指向它: 将 `memory-wiki` 放入启用列表,并建议把 memory slot 指向它:
@ -375,7 +388,7 @@ AIA_REPLAY_REASONING_SIGNATURE_POLICY=auto
}, },
"entries": { "entries": {
"memory-wiki": { "memory-wiki": {
"wiki_root": "oclaw/docs/memory-system/wiki", "wiki_root": "docs/memory-system/wiki",
"max_search_results": 20, "max_search_results": 20,
"max_get_lines": 800, "max_get_lines": 800,
"auto": { "auto": {

View file

@ -811,7 +811,7 @@ def build_admin_router() -> APIRouter:
def api_runtime_scan_artifacts(authorization: str | None = Header(default=None)) -> dict[str, Any]: def api_runtime_scan_artifacts(authorization: str | None = Header(default=None)) -> dict[str, Any]:
ctx = _resolve_auth(SqliteStore(db_path()), authorization) ctx = _resolve_auth(SqliteStore(db_path()), authorization)
_require_permission(ctx, "admin:read") _require_permission(ctx, "admin:read")
root = (PROJECT_ROOT / "oclaw" / "runtime" / "data" / "scan").resolve() root = (PROJECT_ROOT / "runtime" / "data" / "scan").resolve()
allowed_prefixes = ("history_entries_", "state_scan_") allowed_prefixes = ("history_entries_", "state_scan_")
items: list[dict[str, Any]] = [] items: list[dict[str, Any]] = []
if root.exists() and root.is_dir(): if root.exists() and root.is_dir():
@ -836,7 +836,7 @@ def build_admin_router() -> APIRouter:
def api_runtime_scan_artifacts_cleanup(authorization: str | None = Header(default=None)) -> dict[str, Any]: def api_runtime_scan_artifacts_cleanup(authorization: str | None = Header(default=None)) -> dict[str, Any]:
ctx = _resolve_auth(SqliteStore(db_path()), authorization) ctx = _resolve_auth(SqliteStore(db_path()), authorization)
_require_permission(ctx, "admin:runtime:write") _require_permission(ctx, "admin:runtime:write")
root = (PROJECT_ROOT / "oclaw" / "runtime" / "data" / "scan").resolve() root = (PROJECT_ROOT / "runtime" / "data" / "scan").resolve()
allowed_prefixes = ("history_entries_", "state_scan_") allowed_prefixes = ("history_entries_", "state_scan_")
removed = 0 removed = 0
if root.exists() and root.is_dir(): if root.exists() and root.is_dir():
@ -858,7 +858,7 @@ def build_admin_router() -> APIRouter:
body = payload or {} body = payload or {}
ctx = _resolve_auth(SqliteStore(db_path()), authorization) ctx = _resolve_auth(SqliteStore(db_path()), authorization)
_require_permission(ctx, "admin:runtime:write") _require_permission(ctx, "admin:runtime:write")
root = (PROJECT_ROOT / "oclaw" / "runtime" / "data" / "scan").resolve() root = (PROJECT_ROOT / "runtime" / "data" / "scan").resolve()
try: try:
keep_latest = int(body.get("keep_latest", 20)) keep_latest = int(body.get("keep_latest", 20))
except Exception: except Exception:

View file

@ -152,7 +152,7 @@ def _config_open_file_handler(opts: dict[str, Any]) -> Any:
return None return None
if not assert_valid_params(params, _validate_config_get_params, "config.openFile", respond): if not assert_valid_params(params, _validate_config_get_params, "config.openFile", respond):
return None return None
config_path = os.getenv("OCLAW_CONFIG_PATH") or "oclaw/oclaw.json" config_path = os.getenv("OCLAW_CONFIG_PATH") or "oclaw.json"
respond(True, {"ok": True, "path": config_path}, None, None) respond(True, {"ok": True, "path": config_path}, None, None)
return None return None

View file

@ -36,11 +36,11 @@ def _resolve_startup_workspace_dir(cfg: dict[str, Any]) -> str:
default_agent_id = resolve_default_agent_id(cfg) default_agent_id = resolve_default_agent_id(cfg)
ws = resolve_agent_workspace_dir(cfg, default_agent_id) ws = resolve_agent_workspace_dir(cfg, default_agent_id)
ws_text = str(ws or "").strip() ws_text = str(ws or "").strip()
if ws_text: if ws_text and ws_text not in {".", "./"}:
return ws_text return ws_text
except Exception: except Exception:
pass pass
return str((PROJECT_ROOT / "oclaw" / "runtime" / "workspaces" / "main").resolve()) return str((PROJECT_ROOT / "runtime" / "workspaces" / "main").resolve())
def _resolve_startup_workspace_dirs(cfg: dict[str, Any]) -> list[tuple[str, str]]: def _resolve_startup_workspace_dirs(cfg: dict[str, Any]) -> list[tuple[str, str]]:
@ -78,7 +78,7 @@ def _relocate_root_scan_artifacts() -> None:
Normalize them into runtime/data/scan on gateway startup. Normalize them into runtime/data/scan on gateway startup.
""" """
root = PROJECT_ROOT.resolve() root = PROJECT_ROOT.resolve()
target_dir = (PROJECT_ROOT / "oclaw" / "runtime" / "data" / "scan").resolve() target_dir = (PROJECT_ROOT / "runtime" / "data" / "scan").resolve()
target_dir.mkdir(parents=True, exist_ok=True) target_dir.mkdir(parents=True, exist_ok=True)
patterns = ("history_entries_*.json", "state_scan_*.json") patterns = ("history_entries_*.json", "state_scan_*.json")
for pat in patterns: for pat in patterns:

View file

@ -14,7 +14,8 @@ from pathlib import Path
def _project_root() -> Path: def _project_root() -> Path:
if getattr(sys, "frozen", False): if getattr(sys, "frozen", False):
return Path(sys.executable).resolve().parent return Path(sys.executable).resolve().parent
return Path(__file__).resolve().parents[3] # Source layout: <repo>/platform/config/paths.py
return Path(__file__).resolve().parents[2]
PROJECT_ROOT = _project_root() PROJECT_ROOT = _project_root()
@ -22,7 +23,7 @@ _DATA_MIGRATION_DONE = False
def _canonical_data_root() -> Path: def _canonical_data_root() -> Path:
return (PROJECT_ROOT / "oclaw" / "data").resolve() return (PROJECT_ROOT / "data").resolve()
def _legacy_platform_data_root() -> Path: def _legacy_platform_data_root() -> Path:
@ -30,7 +31,7 @@ def _legacy_platform_data_root() -> Path:
def _legacy_root_data_root() -> Path: def _legacy_root_data_root() -> Path:
return (PROJECT_ROOT / "data").resolve() return (PROJECT_ROOT.parent / "data").resolve()
def _backup_keep_count() -> int: def _backup_keep_count() -> int:
@ -177,7 +178,7 @@ def _run_default_data_migration(canonical_db: Path) -> None:
def db_path() -> str: def db_path() -> str:
global _DATA_MIGRATION_DONE global _DATA_MIGRATION_DONE
p = os.getenv("AIA_ASSISTANT_DB_PATH") or os.getenv("OPS_ASSISTANT_DB_PATH") or "oclaw/data/ai_ops.sqlite" p = os.getenv("AIA_ASSISTANT_DB_PATH") or os.getenv("OPS_ASSISTANT_DB_PATH") or "data/ai_ops.sqlite"
path = Path(p) path = Path(p)
if not path.is_absolute(): if not path.is_absolute():
path = (PROJECT_ROOT / path).resolve() path = (PROJECT_ROOT / path).resolve()

View file

@ -6,7 +6,7 @@ from oclaw.platform.config.paths import PROJECT_ROOT
def oclaw_root() -> Path: def oclaw_root() -> Path:
return (PROJECT_ROOT / "oclaw").resolve() return Path(PROJECT_ROOT).resolve()
def runtime_root() -> Path: def runtime_root() -> Path:

View file

@ -19,7 +19,7 @@ class PromptDoc:
def _prompts_root() -> Path: def _prompts_root() -> Path:
return (PROJECT_ROOT / "oclaw" / "prompts").resolve() return (PROJECT_ROOT / "prompts").resolve()
def _runtime_prompts_root() -> Path: def _runtime_prompts_root() -> Path:

View file

@ -30,7 +30,7 @@ def _workspace_for_role(role: str) -> str:
def _role_workspace_signature(role_id: str) -> tuple[Any, ...]: def _role_workspace_signature(role_id: str) -> tuple[Any, ...]:
base = (PROJECT_ROOT / "oclaw" / "runtime" / "workspaces").resolve() base = (PROJECT_ROOT / "runtime" / "workspaces").resolve()
role_root = (base / role_id).resolve() role_root = (base / role_id).resolve()
if not role_root.exists() or not role_root.is_dir(): if not role_root.exists() or not role_root.is_dir():
return ("missing", role_id, str(base)) return ("missing", role_id, str(base))
@ -85,7 +85,7 @@ def build_role_system_context(role: str, template_vars: dict[str, Any] | None =
cached = _ROLE_CONTEXT_CACHE.get(cache_key) cached = _ROLE_CONTEXT_CACHE.get(cache_key)
if isinstance(cached, str) and cached.strip(): if isinstance(cached, str) and cached.strip():
return cached return cached
base = (PROJECT_ROOT / "oclaw" / "runtime" / "workspaces").resolve() base = (PROJECT_ROOT / "runtime" / "workspaces").resolve()
roots: list[Path] = [] roots: list[Path] = []
role_root = (base / role_id).resolve() role_root = (base / role_id).resolve()
if role_root.exists() and role_root.is_dir(): if role_root.exists() and role_root.is_dir():

View file

@ -17,7 +17,7 @@ def build_plugin_config_schema(*_args, **_kwargs) -> dict[str, Any]:
"wiki_root": { "wiki_root": {
"type": "string", "type": "string",
"description": "Wiki root directory relative to workspace root.", "description": "Wiki root directory relative to workspace root.",
"default": "oclaw/docs/memory-system/wiki", "default": "docs/memory-system/wiki",
}, },
"max_search_results": { "max_search_results": {
"type": "integer", "type": "integer",
@ -45,9 +45,9 @@ class WikiRuntime:
def _resolve_runtime(api: Any) -> WikiRuntime: def _resolve_runtime(api: Any) -> WikiRuntime:
cfg = dict(getattr(api, "plugin_config", {}) or {}) cfg = dict(getattr(api, "plugin_config", {}) or {})
root_cfg = str(cfg.get("wiki_root") or "oclaw/docs/memory-system/wiki").strip() root_cfg = str(cfg.get("wiki_root") or "docs/memory-system/wiki").strip()
if not root_cfg: if not root_cfg:
root_cfg = "oclaw/docs/memory-system/wiki" root_cfg = "docs/memory-system/wiki"
root = Path(root_cfg) root = Path(root_cfg)
if not root.is_absolute(): if not root.is_absolute():
root = (_project_root() / root).resolve() root = (_project_root() / root).resolve()

View file

@ -434,7 +434,7 @@ class OclawGateway:
@staticmethod @staticmethod
def _tabular_limits_from_config() -> dict[str, int]: def _tabular_limits_from_config() -> dict[str, int]:
cfg_path_raw = str(os.getenv("AIA_OCLAW_CONFIG_PATH") or "").strip() cfg_path_raw = str(os.getenv("AIA_OCLAW_CONFIG_PATH") or "").strip()
cfg_path = Path(cfg_path_raw).expanduser() if cfg_path_raw else (Path(PROJECT_ROOT) / "oclaw" / "oclaw.json") cfg_path = Path(cfg_path_raw).expanduser() if cfg_path_raw else (Path(PROJECT_ROOT) / "oclaw.json")
if not cfg_path.is_absolute(): if not cfg_path.is_absolute():
cfg_path = (Path(PROJECT_ROOT) / cfg_path).resolve() cfg_path = (Path(PROJECT_ROOT) / cfg_path).resolve()
try: try:

View file

@ -11,7 +11,7 @@ def _project_root() -> Path:
def _load_config() -> dict[str, Any]: def _load_config() -> dict[str, Any]:
cfg_path = _project_root() / "oclaw" / "oclaw.json" cfg_path = _project_root() / "oclaw.json"
if not cfg_path.exists(): if not cfg_path.exists():
return {} return {}
try: try:
@ -29,7 +29,7 @@ def _resolve_wiki_entry(cfg: dict[str, Any]) -> dict[str, Any]:
def _resolve_runtime(entry: dict[str, Any]) -> tuple[Path, int, int, bool, int, bool]: def _resolve_runtime(entry: dict[str, Any]) -> tuple[Path, int, int, bool, int, bool]:
root_cfg = str(entry.get("wiki_root") or "oclaw/docs/memory-system/wiki").strip() root_cfg = str(entry.get("wiki_root") or "docs/memory-system/wiki").strip()
root = Path(root_cfg) root = Path(root_cfg)
if not root.is_absolute(): if not root.is_absolute():
root = (_project_root() / root).resolve() root = (_project_root() / root).resolve()

View file

@ -13,7 +13,7 @@ def resolve_hooks_config_storage_path() -> Path:
Path used for persistent ``hooks.internal.entries`` edits. Path used for persistent ``hooks.internal.entries`` edits.
Matches ``resolve_runtime_config`` file resolution: ``OCLAW_CONFIG_PATH`` (optional Matches ``resolve_runtime_config`` file resolution: ``OCLAW_CONFIG_PATH`` (optional
relative to ``PROJECT_ROOT``), else ``<PROJECT_ROOT>/oclaw/oclaw.json``. relative to ``PROJECT_ROOT``), else ``<PROJECT_ROOT>/oclaw.json``.
""" """
raw = str(os.getenv("OCLAW_CONFIG_PATH") or "").strip() raw = str(os.getenv("OCLAW_CONFIG_PATH") or "").strip()
if raw: if raw:
@ -21,7 +21,7 @@ def resolve_hooks_config_storage_path() -> Path:
if not p.is_absolute(): if not p.is_absolute():
p = (Path(PROJECT_ROOT) / p).resolve() p = (Path(PROJECT_ROOT) / p).resolve()
return p return p
return (Path(PROJECT_ROOT) / "oclaw" / "oclaw.json").resolve() return (Path(PROJECT_ROOT) / "oclaw.json").resolve()
def load_storage_config_document() -> dict[str, Any]: def load_storage_config_document() -> dict[str, Any]:

View file

@ -58,7 +58,7 @@ def _reset_hooks_runtime_state_for_test() -> None:
def _ensure_oclaw_path() -> Path: def _ensure_oclaw_path() -> Path:
oclaw_dir = (Path(PROJECT_ROOT) / "oclaw").resolve() oclaw_dir = Path(PROJECT_ROOT).resolve()
if str(oclaw_dir) not in sys.path: if str(oclaw_dir) not in sys.path:
sys.path.insert(0, str(oclaw_dir)) sys.path.insert(0, str(oclaw_dir))
return oclaw_dir return oclaw_dir
@ -111,7 +111,7 @@ def resolve_runtime_config() -> dict[str, Any]:
cfg = _deep_merge_dict(cfg, file_cfg) cfg = _deep_merge_dict(cfg, file_cfg)
return cfg return cfg
default_cfg_path = (Path(PROJECT_ROOT) / "oclaw" / "oclaw.json").resolve() default_cfg_path = (Path(PROJECT_ROOT) / "oclaw.json").resolve()
file_cfg = _read_json_file(default_cfg_path) file_cfg = _read_json_file(default_cfg_path)
if isinstance(file_cfg, dict): if isinstance(file_cfg, dict):
cfg = _deep_merge_dict(cfg, file_cfg) cfg = _deep_merge_dict(cfg, file_cfg)

View file

@ -169,7 +169,7 @@ def after_turn_memory(
except Exception: except Exception:
pass pass
try: try:
cfg_path = Path(__file__).resolve().parents[2] / "oclaw" / "oclaw.json" cfg_path = Path(__file__).resolve().parents[1] / "oclaw.json"
cfg = json.loads(cfg_path.read_text(encoding="utf-8")) if cfg_path.exists() else {} cfg = json.loads(cfg_path.read_text(encoding="utf-8")) if cfg_path.exists() else {}
plugins = cfg.get("plugins") if isinstance(cfg, dict) else {} plugins = cfg.get("plugins") if isinstance(cfg, dict) else {}
entries = plugins.get("entries") if isinstance(plugins, dict) else {} entries = plugins.get("entries") if isinstance(plugins, dict) else {}

View file

@ -33,13 +33,17 @@ from oclaw.runtime.hooks.user_config_hooks import (
) )
from oclaw.runtime.hooks.workspace import load_workspace_hook_entries from oclaw.runtime.hooks.workspace import load_workspace_hook_entries
from oclaw.runtime.hooks_runtime import resolve_runtime_config from oclaw.runtime.hooks_runtime import resolve_runtime_config
from oclaw.runtime.tools.experts.workspace.workspace_base import workspace_root
def _resolve_cli_workspace(ns: argparse.Namespace) -> str: def _resolve_cli_workspace(ns: argparse.Namespace) -> str:
w = getattr(ns, "workspace", None) w = getattr(ns, "workspace", None)
if isinstance(w, str) and w.strip(): if isinstance(w, str) and w.strip():
return w.strip() return w.strip()
return str(os.getenv("OCLAW_WORKSPACE") or os.getcwd()).strip() or "." env_ws = str(os.getenv("OCLAW_WORKSPACE") or "").strip()
if env_ws:
return env_ws
return str(workspace_root())
def prepare_hooks_cli_config(cfg: dict[str, Any] | None) -> dict[str, Any]: def prepare_hooks_cli_config(cfg: dict[str, Any] | None) -> dict[str, Any]:

View file

@ -53,7 +53,8 @@ def _expand_env_value(v: str) -> str:
def _mcp_local_env_paths_in_load_order() -> list[Path]: def _mcp_local_env_paths_in_load_order() -> list[Path]:
return [ return [
Path(db_path()).resolve().parent / "mcp_local.env", Path(db_path()).resolve().parent / "mcp_local.env",
(PROJECT_ROOT / "oclaw" / "_local" / "mcp_local.env").resolve(), (PROJECT_ROOT / "_local" / "mcp_local.env").resolve(),
(PROJECT_ROOT.parent / "_local" / "mcp_local.env").resolve(),
] ]
@ -66,7 +67,7 @@ def mcp_local_env_merged() -> dict[str, str]:
def mcp_local_env_file_path() -> Path: def mcp_local_env_file_path() -> Path:
return (PROJECT_ROOT / "oclaw" / "_local" / "mcp_local.env").resolve() return (PROJECT_ROOT / "_local" / "mcp_local.env").resolve()
def gateway_mcp_env_extras() -> dict[str, str]: def gateway_mcp_env_extras() -> dict[str, str]:

View file

@ -13,7 +13,7 @@ from oclaw.platform.persistence.sqlite_store import SqliteStore
from oclaw.runtime.tools.mcp.registry import McpRegistry from oclaw.runtime.tools.mcp.registry import McpRegistry
_EXPORT_FILENAME = "mcp_registry_migrated.json" _EXPORT_FILENAME = "mcp_registry_migrated.json"
_LOCAL_DIR = (PROJECT_ROOT / "oclaw" / "_local").resolve() _LOCAL_DIR = (PROJECT_ROOT / "_local").resolve()
_EXPORT_PATH = (_LOCAL_DIR / _EXPORT_FILENAME).resolve() _EXPORT_PATH = (_LOCAL_DIR / _EXPORT_FILENAME).resolve()

View file

@ -144,19 +144,19 @@ def register_memory_parser(root_sub: argparse._SubParsersAction[argparse.Argumen
memory_sub = memory.add_subparsers(dest="memory_cmd", required=True) memory_sub = memory.add_subparsers(dest="memory_cmd", required=True)
status = memory_sub.add_parser("status", help="Show memory workload status") status = memory_sub.add_parser("status", help="Show memory workload status")
status.add_argument("--base-dir", default="oclaw/docs/memory-system", help="Memory system base directory") status.add_argument("--base-dir", default="docs/memory-system", help="Memory system base directory")
status.add_argument("--review-backlog", type=int, default=0, help="Current due review backlog") status.add_argument("--review-backlog", type=int, default=0, help="Current due review backlog")
status.set_defaults(func=_cmd_memory_status) status.set_defaults(func=_cmd_memory_status)
daily = memory_sub.add_parser("daily", help="Generate today's automated memory run") daily = memory_sub.add_parser("daily", help="Generate today's automated memory run")
daily.add_argument("--base-dir", default="oclaw/docs/memory-system", help="Memory system base directory") daily.add_argument("--base-dir", default="docs/memory-system", help="Memory system base directory")
daily.add_argument("--date", default=None, help="Run date in YYYY-MM-DD") daily.add_argument("--date", default=None, help="Run date in YYYY-MM-DD")
daily.add_argument("--focus", default=None, help="Override focus topic") daily.add_argument("--focus", default=None, help="Override focus topic")
daily.add_argument("--review-backlog", type=int, default=0, help="Current due review backlog") daily.add_argument("--review-backlog", type=int, default=0, help="Current due review backlog")
daily.set_defaults(func=_cmd_memory_daily) daily.set_defaults(func=_cmd_memory_daily)
weekly = memory_sub.add_parser("weekly", help="Generate weekly review run sheet") weekly = memory_sub.add_parser("weekly", help="Generate weekly review run sheet")
weekly.add_argument("--base-dir", default="oclaw/docs/memory-system", help="Memory system base directory") weekly.add_argument("--base-dir", default="docs/memory-system", help="Memory system base directory")
weekly.add_argument("--date", default=None, help="Run date in YYYY-MM-DD") weekly.add_argument("--date", default=None, help="Run date in YYYY-MM-DD")
weekly.add_argument("--review-backlog", type=int, default=0, help="Current due review backlog") weekly.add_argument("--review-backlog", type=int, default=0, help="Current due review backlog")
weekly.set_defaults(func=_cmd_memory_weekly) weekly.set_defaults(func=_cmd_memory_weekly)

View file

@ -17,7 +17,12 @@ function Write-Step([string]$msg) {
$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) $repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))
$repoParent = Split-Path -Parent $repoRoot $repoParent = Split-Path -Parent $repoRoot
Set-Location $repoParent Set-Location $repoRoot
$env:PYTHONPATH = $repoParent
$env:PYTHONSAFEPATH = "1"
$env:AIA_WORKSPACE_ROOT = $repoRoot
$env:OPS_WORKSPACE_ROOT = $repoRoot
$env:OCLAW_WORKSPACE = $repoRoot
Write-Step "Starting gateway + desktop" Write-Step "Starting gateway + desktop"

View file

@ -22,6 +22,10 @@ if (-not (Test-Path $desktopDir)) {
Set-Location $desktopDir Set-Location $desktopDir
$env:PYTHONPATH = $repoParent $env:PYTHONPATH = $repoParent
$env:PYTHONSAFEPATH = "1"
$env:AIA_WORKSPACE_ROOT = $repoRoot
$env:OPS_WORKSPACE_ROOT = $repoRoot
$env:OCLAW_WORKSPACE = $repoRoot
if (-not $KeepExistingGateway) { if (-not $KeepExistingGateway) {
Write-Host "==> Cleaning previous gateway listener" -ForegroundColor Cyan Write-Host "==> Cleaning previous gateway listener" -ForegroundColor Cyan

View file

@ -19,16 +19,20 @@ function Fail([string]$msg) {
$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) $repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))
$repoParent = Split-Path -Parent $repoRoot $repoParent = Split-Path -Parent $repoRoot
Set-Location $repoParent Set-Location $repoRoot
$runDir = Join-Path $PSScriptRoot ".run" $runDir = Join-Path $PSScriptRoot ".run"
New-Item -ItemType Directory -Force -Path $runDir | Out-Null New-Item -ItemType Directory -Force -Path $runDir | Out-Null
$pidFile = Join-Path $runDir "gateway.pid" $pidFile = Join-Path $runDir "gateway.pid"
Write-Step "Project root: $repoRoot" Write-Step "Project root: $repoRoot"
Write-Step "Working directory: $repoParent" Write-Step "Working directory: $repoRoot"
$env:PYTHONPATH = $repoParent $env:PYTHONPATH = $repoParent
$env:PYTHONSAFEPATH = "1"
$env:AIA_WORKSPACE_ROOT = $repoRoot
$env:OPS_WORKSPACE_ROOT = $repoRoot
$env:OCLAW_WORKSPACE = $repoRoot
$venvPython = Join-Path $repoRoot ".venv/Scripts/python.exe" $venvPython = Join-Path $repoRoot ".venv/Scripts/python.exe"
if (Test-Path $venvPython) { if (Test-Path $venvPython) {
@ -70,7 +74,7 @@ if ($WithWikiWorker) {
if ($Background) { if ($Background) {
Write-Step "Starting gateway in background" Write-Step "Starting gateway in background"
$p = Start-Process -FilePath $pythonExe -ArgumentList @("-m","oclaw.runtime.operations","gateway","start","--host",$BindHost,"--port",$Port) -WorkingDirectory $repoParent -PassThru -WindowStyle Hidden $p = Start-Process -FilePath $pythonExe -ArgumentList @("-m","oclaw.runtime.operations","gateway","start","--host",$BindHost,"--port",$Port) -WorkingDirectory $repoRoot -PassThru -WindowStyle Hidden
Set-Content -Path $pidFile -Value "$($p.Id)" -Encoding ascii Set-Content -Path $pidFile -Value "$($p.Id)" -Encoding ascii
Write-Host "gateway.pid = $pidFile" -ForegroundColor DarkGray Write-Host "gateway.pid = $pidFile" -ForegroundColor DarkGray
Write-Host "PID = $($p.Id)" -ForegroundColor Green Write-Host "PID = $($p.Id)" -ForegroundColor Green

View file

@ -17,12 +17,16 @@ function Fail([string]$msg) {
$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) $repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))
$repoParent = Split-Path -Parent $repoRoot $repoParent = Split-Path -Parent $repoRoot
Set-Location $repoParent Set-Location $repoRoot
Write-Step "Project root: $repoRoot" Write-Step "Project root: $repoRoot"
Write-Step "Working directory: $repoParent" Write-Step "Working directory: $repoRoot"
$env:PYTHONPATH = $repoParent $env:PYTHONPATH = $repoParent
$env:PYTHONSAFEPATH = "1"
$env:AIA_WORKSPACE_ROOT = $repoRoot
$env:OPS_WORKSPACE_ROOT = $repoRoot
$env:OCLAW_WORKSPACE = $repoRoot
$venvPython = Join-Path $repoRoot ".venv/Scripts/python.exe" $venvPython = Join-Path $repoRoot ".venv/Scripts/python.exe"
if (Test-Path $venvPython) { if (Test-Path $venvPython) {

View file

@ -6,10 +6,11 @@ $ErrorActionPreference = "Stop"
function Resolve-RepoRoot { function Resolve-RepoRoot {
$here = Split-Path -Parent $PSCommandPath $here = Split-Path -Parent $PSCommandPath
return (Resolve-Path (Join-Path $here "..")).Path return (Resolve-Path (Join-Path $here "..\\..\\..")).Path
} }
$repoRoot = Resolve-RepoRoot $repoRoot = Resolve-RepoRoot
$repoParent = Split-Path -Parent $repoRoot
$runDir = Join-Path $PSScriptRoot ".run" $runDir = Join-Path $PSScriptRoot ".run"
$null = New-Item -ItemType Directory -Force -Path $runDir -ErrorAction SilentlyContinue $null = New-Item -ItemType Directory -Force -Path $runDir -ErrorAction SilentlyContinue
$pidFile = Join-Path $runDir "wiki_worker.pid" $pidFile = Join-Path $runDir "wiki_worker.pid"
@ -25,6 +26,16 @@ function Test-AlivePid([int]$procId) {
} }
} }
function Test-IsWikiWorkerPid([int]$procId) {
try {
$wmi = Get-CimInstance Win32_Process -Filter "ProcessId = $procId" -ErrorAction Stop
$cmd = [string]($wmi.CommandLine)
return $cmd -like "*oclaw.runtime.workers.wiki.main*"
} catch {
return $false
}
}
$venvPython = Join-Path $repoRoot ".venv/Scripts/python.exe" $venvPython = Join-Path $repoRoot ".venv/Scripts/python.exe"
if (Test-Path $venvPython) { if (Test-Path $venvPython) {
$pythonExe = $venvPython $pythonExe = $venvPython
@ -35,6 +46,11 @@ if (Test-Path $venvPython) {
} }
$pythonExe = "python" $pythonExe = "python"
} }
$env:PYTHONPATH = $repoParent
$env:PYTHONSAFEPATH = "1"
$env:AIA_WORKSPACE_ROOT = $repoRoot
$env:OPS_WORKSPACE_ROOT = $repoRoot
$env:OCLAW_WORKSPACE = $repoRoot
if (-not $Background) { if (-not $Background) {
& $pythonExe -m oclaw.runtime.workers.wiki.main & $pythonExe -m oclaw.runtime.workers.wiki.main
@ -45,7 +61,7 @@ if (Test-Path $pidFile) {
$raw = (Get-Content $pidFile -ErrorAction SilentlyContinue | Select-Object -First 1) $raw = (Get-Content $pidFile -ErrorAction SilentlyContinue | Select-Object -First 1)
$existing = 0 $existing = 0
[void][int]::TryParse([string]$raw, [ref]$existing) [void][int]::TryParse([string]$raw, [ref]$existing)
if ($existing -gt 0 -and (Test-AlivePid $existing)) { if ($existing -gt 0 -and (Test-AlivePid $existing) -and (Test-IsWikiWorkerPid $existing)) {
Write-Host "[ok] wiki worker already running pid=$existing" Write-Host "[ok] wiki worker already running pid=$existing"
exit 0 exit 0
} }

View file

@ -6,8 +6,12 @@ function Write-Step([string]$msg) {
$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) $repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))
$repoParent = Split-Path -Parent $repoRoot $repoParent = Split-Path -Parent $repoRoot
Set-Location $repoParent Set-Location $repoRoot
$env:PYTHONPATH = $repoParent $env:PYTHONPATH = $repoParent
$env:PYTHONSAFEPATH = "1"
$env:AIA_WORKSPACE_ROOT = $repoRoot
$env:OPS_WORKSPACE_ROOT = $repoRoot
$env:OCLAW_WORKSPACE = $repoRoot
$venvPython = Join-Path $repoRoot ".venv/Scripts/python.exe" $venvPython = Join-Path $repoRoot ".venv/Scripts/python.exe"
if (Test-Path $venvPython) { if (Test-Path $venvPython) {
@ -17,7 +21,7 @@ if (Test-Path $venvPython) {
} }
Write-Step "Project root: $repoRoot" Write-Step "Project root: $repoRoot"
Write-Step "Working directory: $repoParent" Write-Step "Working directory: $repoRoot"
Write-Step "Stack status" Write-Step "Stack status"
& $pythonExe -m oclaw.runtime.operations stack status & $pythonExe -m oclaw.runtime.operations stack status

View file

@ -10,8 +10,12 @@ function Write-Step([string]$msg) {
$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) $repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))
$repoParent = Split-Path -Parent $repoRoot $repoParent = Split-Path -Parent $repoRoot
Set-Location $repoParent Set-Location $repoRoot
$env:PYTHONPATH = $repoParent $env:PYTHONPATH = $repoParent
$env:PYTHONSAFEPATH = "1"
$env:AIA_WORKSPACE_ROOT = $repoRoot
$env:OPS_WORKSPACE_ROOT = $repoRoot
$env:OCLAW_WORKSPACE = $repoRoot
$venvPython = Join-Path $repoRoot ".venv/Scripts/python.exe" $venvPython = Join-Path $repoRoot ".venv/Scripts/python.exe"
if (Test-Path $venvPython) { if (Test-Path $venvPython) {
@ -21,7 +25,7 @@ if (Test-Path $venvPython) {
} }
Write-Step "Project root: $repoRoot" Write-Step "Project root: $repoRoot"
Write-Step "Working directory: $repoParent" Write-Step "Working directory: $repoRoot"
Write-Step "Stopping stack services..." Write-Step "Stopping stack services..."
try { try {

View file

@ -16,7 +16,7 @@ from oclaw.platform.persistence.sqlite_store import SqliteStore
def _load_cfg() -> dict: def _load_cfg() -> dict:
cfg_file = (Path(PROJECT_ROOT) / "oclaw" / "oclaw.json").resolve() cfg_file = (Path(PROJECT_ROOT) / "oclaw.json").resolve()
if not cfg_file.exists(): if not cfg_file.exists():
return {} return {}
try: try:
@ -30,7 +30,7 @@ def _wiki_root_from_cfg(cfg: dict) -> Path:
plugins = cfg.get("plugins") if isinstance(cfg, dict) else {} plugins = cfg.get("plugins") if isinstance(cfg, dict) else {}
entries = plugins.get("entries") if isinstance(plugins, dict) else {} entries = plugins.get("entries") if isinstance(plugins, dict) else {}
mw = entries.get("memory-wiki") if isinstance(entries, dict) else {} mw = entries.get("memory-wiki") if isinstance(entries, dict) else {}
root_cfg = str((mw or {}).get("wiki_root") or "oclaw/docs/memory-system/wiki").strip() root_cfg = str((mw or {}).get("wiki_root") or "docs/memory-system/wiki").strip()
root = Path(root_cfg) root = Path(root_cfg)
if not root.is_absolute(): if not root.is_absolute():
root = (Path(PROJECT_ROOT) / root).resolve() root = (Path(PROJECT_ROOT) / root).resolve()

View file

@ -21,7 +21,7 @@ def _project_context_roots() -> tuple[Path, ...]:
raw_ws = str(os.getenv("OCLAW_WORKSPACE") or "").strip() raw_ws = str(os.getenv("OCLAW_WORKSPACE") or "").strip()
if raw_ws: if raw_ws:
roots.append(Path(raw_ws).expanduser()) roots.append(Path(raw_ws).expanduser())
roots.append(Path(PROJECT_ROOT) / "oclaw" / "runtime" / "workspaces" / "main") roots.append(Path(PROJECT_ROOT) / "runtime" / "workspaces" / "main")
roots.append(Path(PROJECT_ROOT)) roots.append(Path(PROJECT_ROOT))
out: list[Path] = [] out: list[Path] = []
seen: set[str] = set() seen: set[str] = set()

View file

@ -87,12 +87,15 @@ def default_skills_root() -> Path:
# Optional hard-disable for legacy fallback lookups. # Optional hard-disable for legacy fallback lookups.
if str(os.getenv("AIA_DISABLE_LEGACY_SKILLS_FALLBACK") or "").strip().lower() in {"1", "true", "yes", "on"}: if str(os.getenv("AIA_DISABLE_LEGACY_SKILLS_FALLBACK") or "").strip().lower() in {"1", "true", "yes", "on"}:
return preferred return preferred
typo_legacy = (PROJECT_ROOT / "oclaw" / "sills").resolve() typo_legacy = (PROJECT_ROOT / "sills").resolve()
if typo_legacy.exists() and typo_legacy.is_dir(): if typo_legacy.exists() and typo_legacy.is_dir():
return typo_legacy return typo_legacy
legacy = (PROJECT_ROOT / "oclaw" / "skills").resolve() legacy = (PROJECT_ROOT / "skills").resolve()
if legacy.exists() and legacy.is_dir(): if legacy.exists() and legacy.is_dir():
return legacy return legacy
legacy_parent = (PROJECT_ROOT.parent / "skills").resolve()
if legacy_parent.exists() and legacy_parent.is_dir():
return legacy_parent
return preferred return preferred

View file

@ -9,7 +9,7 @@ from oclaw.runtime.tools.base import ToolSpec
def _plugin_cfg() -> dict[str, Any]: def _plugin_cfg() -> dict[str, Any]:
cfg_path = (PROJECT_ROOT / "oclaw" / "oclaw.json").resolve() cfg_path = (PROJECT_ROOT / "oclaw.json").resolve()
if not cfg_path.exists(): if not cfg_path.exists():
return {} return {}
try: try:
@ -25,7 +25,7 @@ def _plugin_cfg() -> dict[str, Any]:
def _wiki_handlers() -> dict[str, Callable[[dict[str, Any]], dict[str, Any]]]: def _wiki_handlers() -> dict[str, Callable[[dict[str, Any]], dict[str, Any]]]:
api_path = (PROJECT_ROOT / "oclaw" / "runtime" / "extensions" / "memory-wiki" / "api.py").resolve() api_path = (PROJECT_ROOT / "runtime" / "extensions" / "memory-wiki" / "api.py").resolve()
spec = importlib.util.spec_from_file_location("memory_wiki_api", str(api_path)) spec = importlib.util.spec_from_file_location("memory_wiki_api", str(api_path))
if spec is None or spec.loader is None: if spec is None or spec.loader is None:
return {} return {}

View file

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
from pathlib import Path
from typing import Any from typing import Any
from oclaw.runtime.tools.base import ToolSpec from oclaw.runtime.tools.base import ToolSpec
@ -59,11 +60,30 @@ def read_file_tool() -> ToolSpec:
def write_file_tool() -> ToolSpec: def write_file_tool() -> ToolSpec:
def _normalize_write_path(path: str) -> str:
raw = str(path or "").strip().strip('"').strip("'")
if not raw:
raise ValueError("path_required")
p = Path(raw)
if p.is_absolute():
return str(p)
# Keep generated files out of repo root: default relative writes go under data/<workspace_name>/...
ws = resolve_workspace_path(".")
ws_name = ws.name or "workspace"
rel = raw.lstrip("./\\")
if not rel:
raise ValueError("path_required")
return str(Path("data") / ws_name / rel)
def handler(args: dict[str, Any]) -> dict[str, Any]: def handler(args: dict[str, Any]) -> dict[str, Any]:
path = str(args.get("path") or "").strip() path = str(args.get("path") or "").strip()
content = str(args.get("content") or "") content = str(args.get("content") or "")
mode = str(args.get("mode") or "overwrite").strip().lower() mode = str(args.get("mode") or "overwrite").strip().lower()
p = resolve_workspace_path(path) try:
normalized = _normalize_write_path(path)
except ValueError as exc:
return {"ok": False, "error": str(exc)}
p = resolve_workspace_path(normalized)
p.parent.mkdir(parents=True, exist_ok=True) p.parent.mkdir(parents=True, exist_ok=True)
if mode not in ("overwrite", "append"): if mode not in ("overwrite", "append"):
return {"ok": False, "error": "invalid_mode", "allowed": ["overwrite", "append"]} return {"ok": False, "error": "invalid_mode", "allowed": ["overwrite", "append"]}

View file

@ -61,7 +61,7 @@ def index_workspace(
max_chars_per_chunk: int = 1600, max_chars_per_chunk: int = 1600,
) -> IndexStats: ) -> IndexStats:
r = Path(root).resolve() if root else _default_workspace_root() r = Path(root).resolve() if root else _default_workspace_root()
pats = patterns or ["oclaw/**/*.py", "README*.md", "**/*.md"] pats = patterns or ["**/*.py", "README*.md", "**/*.md"]
files = _iter_files(r, pats, max_files=max(1, int(max_files))) files = _iter_files(r, pats, max_files=max(1, int(max_files)))
client = build_default_embedding_client() client = build_default_embedding_client()
chunks_upserted = 0 chunks_upserted = 0

View file

@ -6,6 +6,7 @@ import time
import importlib.util import importlib.util
import hashlib import hashlib
import re import re
import sys
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any from typing import Any
@ -15,7 +16,7 @@ from oclaw.platform.persistence.sqlite_store import OclawTask, SqliteStore
def _load_oclaw_config() -> dict[str, Any]: def _load_oclaw_config() -> dict[str, Any]:
cfg_path = (Path(PROJECT_ROOT) / "oclaw" / "oclaw.json").resolve() cfg_path = (Path(PROJECT_ROOT) / "oclaw.json").resolve()
if not cfg_path.exists(): if not cfg_path.exists():
return {} return {}
try: try:
@ -39,7 +40,7 @@ def _is_worker_enabled(entry: dict[str, Any]) -> bool:
def _resolve_wiki_root(plugin_cfg: dict[str, Any]) -> Path: def _resolve_wiki_root(plugin_cfg: dict[str, Any]) -> Path:
root_cfg = str(plugin_cfg.get("wiki_root") or "oclaw/docs/memory-system/wiki").strip() root_cfg = str(plugin_cfg.get("wiki_root") or "docs/memory-system/wiki").strip()
root = Path(root_cfg) root = Path(root_cfg)
if not root.is_absolute(): if not root.is_absolute():
root = (Path(PROJECT_ROOT) / root).resolve() root = (Path(PROJECT_ROOT) / root).resolve()
@ -76,11 +77,16 @@ def _resolve_topic_rules(plugin_cfg: dict[str, Any]) -> list[dict[str, Any]]:
def _tool_handlers(plugin_cfg: dict[str, Any]) -> dict[str, Any]: def _tool_handlers(plugin_cfg: dict[str, Any]) -> dict[str, Any]:
api_path = (Path(PROJECT_ROOT) / "oclaw" / "extensions" / "memory-wiki" / "api.py").resolve() candidates = [
(Path(PROJECT_ROOT) / "runtime" / "extensions" / "memory-wiki" / "api.py").resolve(),
(Path(PROJECT_ROOT) / "extensions" / "memory-wiki" / "api.py").resolve(),
]
api_path = next((p for p in candidates if p.exists()), candidates[0])
spec = importlib.util.spec_from_file_location("oclaw_memory_wiki_api_worker", str(api_path)) spec = importlib.util.spec_from_file_location("oclaw_memory_wiki_api_worker", str(api_path))
if spec is None or spec.loader is None: if spec is None or spec.loader is None:
return {} return {}
mod = importlib.util.module_from_spec(spec) mod = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = mod
spec.loader.exec_module(mod) # type: ignore[assignment] spec.loader.exec_module(mod) # type: ignore[assignment]
build_wiki_tool_specs = getattr(mod, "build_wiki_tool_specs", None) build_wiki_tool_specs = getattr(mod, "build_wiki_tool_specs", None)
if not callable(build_wiki_tool_specs): if not callable(build_wiki_tool_specs):

View file

@ -19,7 +19,7 @@ _SPECIALIST_IDS_CACHE: dict[tuple[Any, ...], tuple[str, ...]] = {}
def workspaces_root() -> Path: def workspaces_root() -> Path:
return (PROJECT_ROOT / "oclaw" / "runtime" / "workspaces").resolve() return (PROJECT_ROOT / "runtime" / "workspaces").resolve()
def normalize_expert_id(raw: Any) -> str: def normalize_expert_id(raw: Any) -> str:

View file

@ -90,3 +90,24 @@ def test_main_hooks_list_invocation(tmp_path, monkeypatch) -> None:
assert code == 0 assert code == 0
obj = json.loads(buf.getvalue()) obj = json.loads(buf.getvalue())
assert "hooks" in obj assert "hooks" in obj
def test_resolve_cli_workspace_uses_env_over_cwd(tmp_path, monkeypatch) -> None:
monkeypatch.chdir(tmp_path)
expected = tmp_path / "repo-root"
expected.mkdir(parents=True)
monkeypatch.setenv("OCLAW_WORKSPACE", str(expected))
from oclaw.runtime.operations.hooks_cmd import _resolve_cli_workspace
ns = argparse.Namespace(workspace="")
assert _resolve_cli_workspace(ns) == str(expected)
def test_resolve_cli_workspace_falls_back_to_workspace_root(tmp_path, monkeypatch) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("OCLAW_WORKSPACE", raising=False)
monkeypatch.setenv("OPS_WORKSPACE_ROOT", str(tmp_path))
from oclaw.runtime.operations.hooks_cmd import _resolve_cli_workspace
ns = argparse.Namespace(workspace="")
assert _resolve_cli_workspace(ns) == str(tmp_path.resolve())

View file

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from oclaw.platform.config.paths import PROJECT_ROOT
from oclaw.interfaces.http.fastapi_app import _resolve_startup_workspace_dir, _resolve_startup_workspace_dirs from oclaw.interfaces.http.fastapi_app import _resolve_startup_workspace_dir, _resolve_startup_workspace_dirs
@ -30,3 +31,9 @@ def test_resolve_startup_workspace_dirs_all_agents() -> None:
assert ("main", "D:/project/chatgpt/oclaw/workspace-main") in got_norm assert ("main", "D:/project/chatgpt/oclaw/workspace-main") in got_norm
assert ("social", "D:/project/chatgpt/oclaw/workspace-social") in got_norm assert ("social", "D:/project/chatgpt/oclaw/workspace-social") in got_norm
def test_resolve_startup_workspace_dir_fallback_to_repo_runtime_main() -> None:
got = _resolve_startup_workspace_dir({})
expect = str((PROJECT_ROOT / "runtime" / "workspaces" / "main").resolve()).replace("\\", "/")
assert got.replace("\\", "/") == expect

View file

@ -6,16 +6,16 @@ from oclaw.platform.config.paths import PROJECT_ROOT
_PROMPT_CRITICAL_FILES = ( _PROMPT_CRITICAL_FILES = (
"oclaw/runtime/chat/agent.py", "runtime/chat/agent.py",
"oclaw/runtime/agents/network_ops_agent.py", "runtime/agents/network_ops_agent.py",
"oclaw/runtime/agents/factory.py", "runtime/agents/factory.py",
"oclaw/runtime/chat/agent_messages.py", "runtime/chat/agent_messages.py",
"oclaw/runtime/system_prompt.py", "runtime/system_prompt.py",
"oclaw/runtime/memory_stage.py", "runtime/memory_stage.py",
"oclaw/runtime/project_context_prompt.py", "runtime/project_context_prompt.py",
"oclaw/runtime/chat/agent_errors.py", "runtime/chat/agent_errors.py",
"oclaw/platform/llm/image_message_client.py", "platform/llm/image_message_client.py",
"oclaw/runtime/gateway.py", "runtime/gateway.py",
) )
@ -33,8 +33,8 @@ def test_prompt_critical_paths_use_prompt_templates() -> None:
def test_prompt_markdown_frontmatter_keys_present() -> None: def test_prompt_markdown_frontmatter_keys_present() -> None:
roots = [ roots = [
PROJECT_ROOT / "oclaw" / "prompts", PROJECT_ROOT / "prompts",
PROJECT_ROOT / "oclaw" / "prompts_runtime", PROJECT_ROOT / "prompts_runtime",
] ]
for prompts_root in roots: for prompts_root in roots:
for p in prompts_root.rglob("*.md"): for p in prompts_root.rglob("*.md"):

View file

@ -12,18 +12,18 @@ def _read(rel: str) -> str:
def test_no_legacy_inline_manager_prompts() -> None: def test_no_legacy_inline_manager_prompts() -> None:
# Manager orchestrator was removed; keep this test as a smoke-guard for any new inline # Manager orchestrator was removed; keep this test as a smoke-guard for any new inline
# manager-style prompt leakage into the runtime entrypoints. # manager-style prompt leakage into the runtime entrypoints.
content = _read("oclaw/runtime/gateway.py") content = _read("runtime/gateway.py")
assert "【重要:只返回合法 JSON】" not in content assert "【重要:只返回合法 JSON】" not in content
assert "你是 AI 助手的路由(Routing)智能体" not in content assert "你是 AI 助手的路由(Routing)智能体" not in content
def test_no_legacy_inline_specialist_prompts() -> None: def test_no_legacy_inline_specialist_prompts() -> None:
content = _read("oclaw/runtime/agents/specialists.py") content = _read("runtime/agents/specialists.py")
assert "【最高优先级执行规则(必须遵守)】" not in content assert "【最高优先级执行规则(必须遵守)】" not in content
assert "【职责范围】" not in content assert "【职责范围】" not in content
def test_no_legacy_inline_runtime_prompt() -> None: def test_no_legacy_inline_runtime_prompt() -> None:
content = _read("oclaw/runtime/chat/agent.py") content = _read("runtime/chat/agent.py")
assert "你是一个通用 AI 助手。" not in content assert "你是一个通用 AI 助手。" not in content

View file

@ -12,7 +12,7 @@ from fastapi.testclient import TestClient
from oclaw.interfaces.http.fastapi_app import create_app from oclaw.interfaces.http.fastapi_app import create_app
from oclaw.platform.config.paths import db_path from oclaw.platform.config.paths import db_path
from oclaw.platform.persistence.sqlite_store import SqliteStore from oclaw.platform.persistence.sqlite_store import SqliteStore
from oclaw.runtime.tools.experts.workspace.fs_tools import list_files_tool from oclaw.runtime.tools.experts.workspace.fs_tools import list_files_tool, write_file_tool
from oclaw.runtime.tools.experts.workspace.workspace_base import ( from oclaw.runtime.tools.experts.workspace.workspace_base import (
access_from_env, access_from_env,
build_workspace_path_access, build_workspace_path_access,
@ -100,6 +100,21 @@ class WorkspacePathGuardTests(unittest.TestCase):
p = resolve_workspace_path(str(f)) p = resolve_workspace_path(str(f))
self.assertEqual(p, f.resolve()) self.assertEqual(p, f.resolve())
def test_write_file_relative_path_defaults_to_data_workspace_subdir(self) -> None:
with mock.patch.dict(
os.environ,
{"OPS_WORKSPACE_ROOT": str(self.root), "OPS_WORKSPACE_EXTRA_ROOTS": "", "OPS_WORKSPACE_ALLOW_ANY_PATH": ""},
clear=False,
):
clear_workspace_path_access_for_tests()
spec = write_file_tool()
with workspace_path_access_scope(None, None):
r = spec.handler({"path": "generated.py", "content": "print('ok')\n", "mode": "overwrite"})
self.assertTrue(r.get("ok"), r)
expected = (self.root / "data" / self.root.name / "generated.py").resolve()
self.assertEqual(str(expected), str(r.get("path")))
self.assertTrue(expected.exists())
def test_per_user_extra_roots_from_db(self) -> None: def test_per_user_extra_roots_from_db(self) -> None:
f = self.extra / "u.txt" f = self.extra / "u.txt"
f.write_text("u", encoding="utf-8") f.write_text("u", encoding="utf-8")

View file

@ -10,7 +10,7 @@ def _read(path: Path) -> str:
def test_main_dynamic_placeholder_only_in_role_system() -> None: def test_main_dynamic_placeholder_only_in_role_system() -> None:
root = (PROJECT_ROOT / "oclaw" / "runtime" / "workspaces" / "main").resolve() root = (PROJECT_ROOT / "runtime" / "workspaces" / "main").resolve()
role_system = _read(root / "ROLE_SYSTEM.md") role_system = _read(root / "ROLE_SYSTEM.md")
soul = _read(root / "SOUL.md") soul = _read(root / "SOUL.md")
assert "{{MANAGER_DYNAMIC_EXPERTS_HINT}}" in role_system assert "{{MANAGER_DYNAMIC_EXPERTS_HINT}}" in role_system
@ -22,7 +22,7 @@ def test_main_dynamic_placeholder_only_in_role_system() -> None:
def test_main_hard_routing_rule_only_in_role_system() -> None: def test_main_hard_routing_rule_only_in_role_system() -> None:
root = (PROJECT_ROOT / "oclaw" / "runtime" / "workspaces" / "main").resolve() root = (PROJECT_ROOT / "runtime" / "workspaces" / "main").resolve()
role_system = _read(root / "ROLE_SYSTEM.md") role_system = _read(root / "ROLE_SYSTEM.md")
assert "回退 `generalist`" in role_system assert "回退 `generalist`" in role_system
for name in ("AGENTS.md", "IDENTITY.md", "USER.md", "SOUL.md"): for name in ("AGENTS.md", "IDENTITY.md", "USER.md", "SOUL.md"):
@ -32,7 +32,7 @@ def test_main_hard_routing_rule_only_in_role_system() -> None:
def test_every_workspace_has_role_system() -> None: def test_every_workspace_has_role_system() -> None:
root = (PROJECT_ROOT / "oclaw" / "runtime" / "workspaces").resolve() root = (PROJECT_ROOT / "runtime" / "workspaces").resolve()
for item in root.iterdir(): for item in root.iterdir():
if not item.is_dir(): if not item.is_dir():
continue continue
@ -42,7 +42,7 @@ def test_every_workspace_has_role_system() -> None:
def test_workspace_legacy_prompt_files_removed() -> None: def test_workspace_legacy_prompt_files_removed() -> None:
root = (PROJECT_ROOT / "oclaw" / "runtime" / "workspaces").resolve() root = (PROJECT_ROOT / "runtime" / "workspaces").resolve()
legacy = ("AGENTS.md", "IDENTITY.md", "USER.md") legacy = ("AGENTS.md", "IDENTITY.md", "USER.md")
for item in root.iterdir(): for item in root.iterdir():
if not item.is_dir(): if not item.is_dir():