oclaw/runtime/hooks/loader.py
oliver dbbe3add6a 重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。
同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。

Made-with: Cursor
2026-04-26 08:34:33 +08:00

206 lines
7.5 KiB
Python

from __future__ import annotations
import importlib.util
from pathlib import Path
from typing import Any, Optional
from .config import should_include_hook_compat
from .hook_types import HookEligibilityContext, HookEntry, ensure_hook_entry
from .internal_hooks import HookHandler, register_hook, unregister_hook
from .script_handlers import build_script_hook_handler
from .workspace import load_workspace_hook_entries
_loaded_hook_registrations: list[tuple[str, HookHandler]] = []
def _reset_loaded_internal_hooks() -> None:
while _loaded_hook_registrations:
event_key, handler = _loaded_hook_registrations.pop()
unregister_hook(event_key, handler)
def _is_within_base(handler_path: str, base_dir: str) -> bool:
try:
Path(handler_path).resolve().relative_to(Path(base_dir).resolve())
return True
except Exception:
return False
def _load_module_from_path(module_path: str, unique_key: str) -> Optional[object]:
"""
Dynamically import a Python module from an absolute path.
"""
p = Path(module_path)
if not p.exists() or not p.is_file():
return None
name = f"oclaw_hook_{unique_key}_{p.stem}".replace("-", "_")
spec = importlib.util.spec_from_file_location(name, str(p))
if not spec or not spec.loader:
return None
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod) # type: ignore[assignment]
return mod
def _resolve_legacy_handlers(config: dict[str, Any]) -> list[dict[str, str]]:
rows = (((config.get("hooks") or {}).get("internal") or {}).get("handlers"))
if not isinstance(rows, list):
return []
out: list[dict[str, str]] = []
for row in rows:
if not isinstance(row, dict):
continue
event = str(row.get("event") or "").strip()
module = str(row.get("module") or "").strip()
export = str(row.get("export") or "default").strip() or "default"
if not event or not module:
continue
out.append({"event": event, "module": module, "export": export})
return out
def _resolve_workspace_module_path(*, workspace_dir: str, raw_module: str) -> Optional[str]:
if not raw_module or raw_module.startswith("/") or raw_module.startswith("\\"):
return None
ws = Path(workspace_dir).resolve()
module_path = (ws / raw_module).resolve()
try:
module_path.relative_to(ws)
except Exception:
return None
if not module_path.exists() or not module_path.is_file():
return None
return str(module_path)
def _resolve_handler(mod: object, export_name: str) -> Optional[HookHandler]:
normalized = str(export_name or "").strip()
if normalized and normalized != "default":
handler = getattr(mod, normalized, None)
if callable(handler):
return handler # type: ignore[return-value]
# fallback: common names
for candidate in ("handle", "handler", "main"):
h = getattr(mod, candidate, None)
if callable(h):
return h # type: ignore[return-value]
return None
def load_internal_hooks(
config: dict[str, Any],
workspace_dir: str,
*,
managed_hooks_dir: Optional[str] = None,
bundled_hooks_dir: Optional[str] = None,
eligibility: Optional[HookEligibilityContext] = None,
) -> int:
"""
Discover hooks and register them into the in-process registry.
Handler files (first match wins), see ``workspace._handler_candidates`` for the full list.
- ``.py``: in-process import (``handle`` / ``handler`` / ``main`` or ``metadata.oclaw.export``)
- ``.ts`` / ``.mts`` / ``.cts`` (default): ``tsx`` + ``ts_hook_runner.ts`` (import ``default`` / ``handle`` or ``export``)
- ``.mjs`` / ``.cjs`` (default): ``node`` + ``js_hook_runner.mjs`` (``import`` / ``require`` + same exports)
- ``.sh`` / ``.bash``: subprocess: JSON on stdin, optional JSON on stdout (``context`` merge)
- ``metadata.oclaw.hookMode: "script"`` (or legacy ``nodeScript: true``): run ``.ts`` / ``.mjs`` / ``.cjs`` as
**stdin/stdout scripts** (no import runner) — use ``node`` for JS, ``tsx`` for TS
Hook layout (unchanged):
<hookDir>/HOOK.md (YAML frontmatter with oclaw metadata)
Metadata fields used:
metadata.oclaw.events: ["type", "type:action", ...]
metadata.oclaw.export: handler function name (default: "default" -> we map to "handle")
"""
_reset_loaded_internal_hooks()
# Hook system is on by default; only skip when explicitly disabled.
if (((config.get("hooks") or {}).get("internal") or {}).get("enabled")) is False:
return 0
entries = load_workspace_hook_entries(
workspace_dir,
config=config,
managed_hooks_dir=managed_hooks_dir,
bundled_hooks_dir=bundled_hooks_dir,
extra_dirs=(((config.get("hooks") or {}).get("internal") or {}).get("load") or {}).get("extraDirs"),
)
loaded = 0
for idx, entry in enumerate(entries):
if not should_include_hook_compat(entry=entry, config=config, eligibility=eligibility):
continue
entry_obj = ensure_hook_entry(entry)
md = entry_obj.metadata or {}
events = md.get("events") if isinstance(md, dict) else None
if not isinstance(events, list) or not events:
continue
handler_path = entry_obj.hook.handlerPath
base_dir = entry_obj.hook.baseDir
if not isinstance(handler_path, str) or not handler_path:
continue
if not isinstance(base_dir, str) or not base_dir.strip():
continue
if not _is_within_base(handler_path, base_dir):
continue
export_name = md.get("export") if isinstance(md, dict) else None
if not isinstance(export_name, str) or not export_name.strip():
export_name = "default"
suffix = Path(handler_path).suffix.lower()
handler: Optional[HookHandler] = None
if suffix in {".py"}:
mod = _load_module_from_path(handler_path, unique_key=str(idx))
if mod is None:
continue
handler = _resolve_handler(mod, export_name)
else:
oclaw_dict: dict[str, Any] = {}
if isinstance(md, dict):
for key in ("hookMode", "nodeScript"):
if key in md:
oclaw_dict[key] = md[key]
handler = build_script_hook_handler(
handler_path=handler_path,
base_dir=base_dir,
suffix=suffix,
export_name=export_name,
oclaw=oclaw_dict,
)
if handler is None:
continue
for event_key in events:
if isinstance(event_key, str) and event_key.strip():
ek = event_key.strip()
register_hook(ek, handler)
_loaded_hook_registrations.append((ek, handler))
loaded += 1
# Legacy config handlers (hooks.internal.handlers)
for j, row in enumerate(_resolve_legacy_handlers(config)):
safe_path = _resolve_workspace_module_path(workspace_dir=workspace_dir, raw_module=row["module"])
if not safe_path:
continue
mod = _load_module_from_path(safe_path, unique_key=f"legacy_{j}")
if mod is None:
continue
handler = _resolve_handler(mod, row.get("export") or "default")
if handler is None:
continue
ev = row.get("event") or ""
if not ev:
continue
register_hook(ev, handler)
_loaded_hook_registrations.append((ev, handler))
loaded += 1
return loaded