mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 01:50:44 +08:00
重构仓库目录为统一的 runtime 分层并清理历史 openclaw 残留。
本次迁移将网关/通道/工具/技能/脚本与协议资源集中到新结构,统一路径常量与脚本转发机制,减少顶层噪音并保证运行与测试行为一致。 Made-with: Cursor
This commit is contained in:
parent
ba3836f00f
commit
4a23b715a2
498 changed files with 2760 additions and 2200 deletions
30
runtime/hooks/README.md
Normal file
30
runtime/hooks/README.md
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
## `oclaw/hooks`
|
||||
|
||||
Unified Python hooks runtime and hook packages.
|
||||
|
||||
### What you get
|
||||
|
||||
- **In-process hook bus**: register on `type` or `type:action`, sync/async handlers, isolated failures.
|
||||
- **Directory discovery**: finds hooks by `HOOK.md + handler.py` (or `index.py`).
|
||||
- **Config gating**: supports `hooks.internal.enabled` and `hooks.internal.entries.<hookKey>.enabled`.
|
||||
- **Source precedence**: bundled / managed / workspace collision resolution.
|
||||
|
||||
### Hook layout
|
||||
|
||||
Put hooks in any of:
|
||||
|
||||
- **Bundled**: `oclaw/hooks/bundled/<hookName>/`
|
||||
- **Managed**: `~/.oclaw/hooks/<hookName>/`
|
||||
- **Workspace**: `<workspace>/hooks/<hookName>/` (explicit opt-in by default)
|
||||
|
||||
Each hook directory must contain:
|
||||
|
||||
- `HOOK.md` with YAML frontmatter including `metadata.oclaw.events`
|
||||
- `handler.py` (or `index.py`) exporting a callable `handle(event)`
|
||||
|
||||
### Minimal self-test
|
||||
|
||||
```bash
|
||||
python "oclaw/hooks/_selftest.py"
|
||||
```
|
||||
|
||||
27
runtime/hooks/__init__.py
Normal file
27
runtime/hooks/__init__.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from .internal_hooks import (
|
||||
HookEvent,
|
||||
HookHandler,
|
||||
clear_hooks,
|
||||
create_hook_event,
|
||||
get_registered_hook_event_keys,
|
||||
register_hook,
|
||||
set_hooks_enabled,
|
||||
trigger_hook,
|
||||
unregister_hook,
|
||||
)
|
||||
|
||||
from .loader import load_internal_hooks
|
||||
|
||||
__all__ = [
|
||||
"HookEvent",
|
||||
"HookHandler",
|
||||
"register_hook",
|
||||
"unregister_hook",
|
||||
"clear_hooks",
|
||||
"get_registered_hook_event_keys",
|
||||
"set_hooks_enabled",
|
||||
"trigger_hook",
|
||||
"create_hook_event",
|
||||
"load_internal_hooks",
|
||||
]
|
||||
|
||||
95
runtime/hooks/_selftest.py
Normal file
95
runtime/hooks/_selftest.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
HOOKS_PY_DIR = Path(__file__).resolve().parent
|
||||
OCLAW_DIR = HOOKS_PY_DIR.parent
|
||||
if str(OCLAW_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(OCLAW_DIR))
|
||||
|
||||
from oclaw.runtime.hooks.internal_hooks import create_hook_event, trigger_hook # noqa: E402
|
||||
from oclaw.runtime.hooks.loader import load_internal_hooks # noqa: E402
|
||||
|
||||
|
||||
def main() -> int:
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
bundled = repo_root / "runtime" / "hooks" / "bundled"
|
||||
ws_dir = repo_root / "_hooks_selftest_workspace"
|
||||
ws_dir.mkdir(parents=True, exist_ok=True)
|
||||
(ws_dir / "memory").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
cfg = {
|
||||
"hooks": {
|
||||
"internal": {
|
||||
"enabled": True,
|
||||
"entries": {
|
||||
"bootstrap-extra-files": {
|
||||
"enabled": True,
|
||||
"paths": ["**/AGENTS.md"],
|
||||
},
|
||||
"session-memory": {
|
||||
"enabled": True,
|
||||
"messages": 15,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Use repo root as a fake "workspace dir" for this self-test.
|
||||
loaded = load_internal_hooks(cfg, workspace_dir=str(ws_dir), bundled_hooks_dir=str(bundled))
|
||||
print(f"loaded_hooks={loaded}")
|
||||
|
||||
async def run() -> None:
|
||||
# gateway:startup
|
||||
ev0 = create_hook_event(
|
||||
"gateway",
|
||||
"startup",
|
||||
"agent:main:main",
|
||||
context={"cfg": cfg, "workspaceDir": str(ws_dir)},
|
||||
)
|
||||
await trigger_hook(ev0)
|
||||
|
||||
# agent:bootstrap (inject extra files)
|
||||
# prepare a fake AGENTS.md to match **/AGENTS.md
|
||||
(ws_dir / "AGENTS.md").write_text("# AGENTS\n", encoding="utf-8")
|
||||
ctx = {"cfg": cfg, "workspaceDir": str(ws_dir), "bootstrapFiles": []}
|
||||
ev1 = create_hook_event("agent", "bootstrap", "agent:main:main", context=ctx)
|
||||
await trigger_hook(ev1)
|
||||
print(f"bootstrapFiles_after={len(ctx.get('bootstrapFiles') or [])}")
|
||||
|
||||
# command:new and command:reset (writes memory markdown; also command-logger runs on command)
|
||||
ev = create_hook_event(
|
||||
"command",
|
||||
"new",
|
||||
"agent:main:main",
|
||||
context={
|
||||
"senderId": "selftest",
|
||||
"commandSource": "local",
|
||||
"cfg": cfg,
|
||||
"workspaceDir": str(ws_dir),
|
||||
},
|
||||
)
|
||||
await trigger_hook(ev)
|
||||
ev2 = create_hook_event(
|
||||
"command",
|
||||
"reset",
|
||||
"agent:main:main",
|
||||
context={
|
||||
"senderId": "selftest",
|
||||
"commandSource": "local",
|
||||
"cfg": cfg,
|
||||
"workspaceDir": str(ws_dir),
|
||||
},
|
||||
)
|
||||
await trigger_hook(ev2)
|
||||
|
||||
asyncio.run(run())
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
13
runtime/hooks/bundled/boot-md/HOOK.md
Normal file
13
runtime/hooks/bundled/boot-md/HOOK.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
name: boot-md
|
||||
description: "Run BOOT.md on gateway startup"
|
||||
metadata:
|
||||
oclaw:
|
||||
emoji: "🚀"
|
||||
events: ["gateway:startup"]
|
||||
---
|
||||
|
||||
# Boot Checklist Hook (Python)
|
||||
|
||||
On `gateway:startup`, looks for `BOOT.md` under common workspace roots and records a run log.
|
||||
|
||||
77
runtime/hooks/bundled/boot-md/handler.py
Normal file
77
runtime/hooks/bundled/boot-md/handler.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
def _resolve_state_dir() -> Path:
|
||||
override = os.environ.get("OCLAW_STATE_DIR") or os.environ.get("OCLAW_HOME")
|
||||
if override and override.strip():
|
||||
return Path(os.path.expanduser(override.strip())).resolve()
|
||||
return Path.home() / ".oclaw"
|
||||
|
||||
|
||||
def _candidate_roots(event: Any) -> list[Path]:
|
||||
roots: list[Path] = []
|
||||
ctx = getattr(event, "context", {}) or {}
|
||||
# 1) explicit workspaceDir in hook event
|
||||
ws = ctx.get("workspaceDir") if isinstance(ctx, dict) else None
|
||||
if isinstance(ws, str) and ws.strip():
|
||||
roots.append(Path(ws).expanduser())
|
||||
# 2) OCLAW_WORKSPACE env
|
||||
env_ws = str(os.getenv("OCLAW_WORKSPACE") or "").strip()
|
||||
if env_ws:
|
||||
roots.append(Path(env_ws).expanduser())
|
||||
# 3) repo-local conventional roots
|
||||
# handler.py is under oclaw/hooks/bundled/boot-md/
|
||||
repo = Path(__file__).resolve().parents[4]
|
||||
roots.extend(
|
||||
[
|
||||
repo / "oclaw" / "runtime" / "assets" / "agent_workspaces" / "workspace-main",
|
||||
repo / "oclaw" / "workspace-main",
|
||||
repo / "oclaw" / "workspace",
|
||||
repo,
|
||||
]
|
||||
)
|
||||
# de-dupe
|
||||
out: list[Path] = []
|
||||
seen: set[str] = set()
|
||||
for p in roots:
|
||||
key = str(p.resolve()) if p.exists() else str(p)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def handle(event: Any) -> None:
|
||||
if getattr(event, "type", None) != "gateway" or getattr(event, "action", None) != "startup":
|
||||
return
|
||||
|
||||
state = _resolve_state_dir()
|
||||
log_dir = state / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_log = log_dir / "boot-md.log"
|
||||
|
||||
now = getattr(event, "timestamp", None)
|
||||
if not isinstance(now, _dt.datetime):
|
||||
now = _dt.datetime.now(tz=_dt.timezone.utc)
|
||||
|
||||
roots = _candidate_roots(event)
|
||||
checked = 0
|
||||
found = 0
|
||||
lines: list[str] = []
|
||||
for root in roots:
|
||||
checked += 1
|
||||
boot = root / "BOOT.md"
|
||||
if boot.exists() and boot.is_file():
|
||||
found += 1
|
||||
lines.append(f"[{now.isoformat()}] FOUND {boot}")
|
||||
else:
|
||||
lines.append(f"[{now.isoformat()}] MISS {boot}")
|
||||
|
||||
out_log.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
30
runtime/hooks/bundled/bootstrap-extra-files/HOOK.md
Normal file
30
runtime/hooks/bundled/bootstrap-extra-files/HOOK.md
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
name: bootstrap-extra-files
|
||||
description: "Inject additional workspace bootstrap files via glob/path patterns"
|
||||
metadata:
|
||||
oclaw:
|
||||
emoji: "📎"
|
||||
events: ["agent:bootstrap"]
|
||||
---
|
||||
|
||||
# Bootstrap Extra Files Hook (Python)
|
||||
|
||||
On `agent:bootstrap`, expands extra file glob patterns and appends them to `event.context.bootstrapFiles`.
|
||||
|
||||
Config example (hook key `bootstrap-extra-files`):
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"internal": {
|
||||
"entries": {
|
||||
"bootstrap-extra-files": {
|
||||
"enabled": true,
|
||||
"paths": ["packages/*/AGENTS.md", "packages/*/TOOLS.md"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
146
runtime/hooks/bundled/bootstrap-extra-files/handler.py
Normal file
146
runtime/hooks/bundled/bootstrap-extra-files/handler.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Sequence
|
||||
|
||||
|
||||
HOOK_KEY = "bootstrap-extra-files"
|
||||
_ALLOWED_BASENAMES = {
|
||||
"AGENTS.md",
|
||||
"SOUL.md",
|
||||
"TOOLS.md",
|
||||
"IDENTITY.md",
|
||||
"USER.md",
|
||||
"HEARTBEAT.md",
|
||||
"BOOTSTRAP.md",
|
||||
"MEMORY.md",
|
||||
"memory.md",
|
||||
}
|
||||
|
||||
|
||||
def _resolve_hook_cfg(cfg: Any) -> Dict[str, Any]:
|
||||
if not isinstance(cfg, dict):
|
||||
return {}
|
||||
hooks = cfg.get("hooks") if isinstance(cfg.get("hooks"), dict) else {}
|
||||
internal = hooks.get("internal") if isinstance(hooks.get("internal"), dict) else {}
|
||||
entries = internal.get("entries") if isinstance(internal.get("entries"), dict) else {}
|
||||
row = entries.get(HOOK_KEY)
|
||||
return row if isinstance(row, dict) else {}
|
||||
|
||||
|
||||
def _string_list(v: Any) -> List[str]:
|
||||
if isinstance(v, list):
|
||||
out = []
|
||||
for x in v:
|
||||
s = str(x or "").strip()
|
||||
if s:
|
||||
out.append(s)
|
||||
return out
|
||||
if isinstance(v, str):
|
||||
s = v.strip()
|
||||
return [s] if s else []
|
||||
return []
|
||||
|
||||
|
||||
def _patterns(hook_cfg: Dict[str, Any]) -> List[str]:
|
||||
for k in ("paths", "patterns", "files"):
|
||||
got = _string_list(hook_cfg.get(k))
|
||||
if got:
|
||||
return got
|
||||
return []
|
||||
|
||||
|
||||
def _is_within(root: Path, candidate: Path) -> bool:
|
||||
try:
|
||||
candidate.resolve().relative_to(root.resolve())
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _glob_paths(root: Path, patterns: Sequence[str]) -> List[Path]:
|
||||
"""
|
||||
We intentionally do not use Path.glob on arbitrary patterns that might escape roots via '..'.
|
||||
Instead: enumerate candidates by rglob and fnmatch on posix-style relative paths.
|
||||
"""
|
||||
if not root.exists() or not root.is_dir():
|
||||
return []
|
||||
|
||||
# Pre-normalize patterns to forward-slash for fnmatch
|
||||
raw_pats = [p.replace("\\", "/").lstrip("/") for p in patterns if str(p or "").strip()]
|
||||
pats: list[str] = []
|
||||
for pat in raw_pats:
|
||||
pats.append(pat)
|
||||
# Python's fnmatch doesn't treat "**/" as "zero-or-more directories".
|
||||
# Add a compatibility variant so "**/AGENTS.md" matches "AGENTS.md" as well.
|
||||
if pat.startswith("**/") and len(pat) > 3:
|
||||
pats.append(pat[3:])
|
||||
if not pats:
|
||||
return []
|
||||
|
||||
out: List[Path] = []
|
||||
try:
|
||||
for p in root.rglob("*"):
|
||||
if not p.is_file():
|
||||
continue
|
||||
if p.name not in _ALLOWED_BASENAMES:
|
||||
continue
|
||||
rel = p.relative_to(root).as_posix()
|
||||
if any(fnmatch.fnmatch(rel, pat) for pat in pats):
|
||||
out.append(p)
|
||||
except Exception:
|
||||
return out
|
||||
|
||||
# deterministic order
|
||||
out.sort(key=lambda x: x.as_posix())
|
||||
return out
|
||||
|
||||
|
||||
def handle(event: Any) -> None:
|
||||
if getattr(event, "type", None) != "agent" or getattr(event, "action", None) != "bootstrap":
|
||||
return
|
||||
|
||||
ctx = getattr(event, "context", None)
|
||||
if not isinstance(ctx, dict):
|
||||
return
|
||||
|
||||
hook_cfg = _resolve_hook_cfg(ctx.get("cfg"))
|
||||
if hook_cfg.get("enabled") is False:
|
||||
return
|
||||
|
||||
patterns = _patterns(hook_cfg)
|
||||
if not patterns:
|
||||
return
|
||||
|
||||
ws = ctx.get("workspaceDir")
|
||||
if not isinstance(ws, str) or not ws.strip():
|
||||
return
|
||||
ws_root = Path(ws).expanduser()
|
||||
if not ws_root.exists() or not ws_root.is_dir():
|
||||
return
|
||||
|
||||
matches = _glob_paths(ws_root, patterns)
|
||||
if not matches:
|
||||
return
|
||||
|
||||
# Mutate context.bootstrapFiles (Oclaw-style).
|
||||
boot = ctx.get("bootstrapFiles")
|
||||
if not isinstance(boot, list):
|
||||
boot = []
|
||||
ctx["bootstrapFiles"] = boot
|
||||
|
||||
existing_paths = set()
|
||||
for it in list(boot):
|
||||
if isinstance(it, dict) and isinstance(it.get("path"), str):
|
||||
existing_paths.add(it["path"])
|
||||
elif isinstance(it, str):
|
||||
existing_paths.add(it)
|
||||
|
||||
for p in matches:
|
||||
ap = str(p.resolve())
|
||||
if ap in existing_paths:
|
||||
continue
|
||||
boot.append({"path": ap, "name": p.name})
|
||||
existing_paths.add(ap)
|
||||
|
||||
13
runtime/hooks/bundled/command-logger/HOOK.md
Normal file
13
runtime/hooks/bundled/command-logger/HOOK.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
name: command-logger
|
||||
description: "Log all command events to a centralized audit file"
|
||||
metadata:
|
||||
oclaw:
|
||||
emoji: "📝"
|
||||
events: ["command"]
|
||||
---
|
||||
|
||||
# Command Logger Hook (Python)
|
||||
|
||||
Logs all `command` events to `~/.oclaw/logs/commands.log` (JSONL).
|
||||
|
||||
35
runtime/hooks/bundled/command-logger/handler.py
Normal file
35
runtime/hooks/bundled/command-logger/handler.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
def _resolve_state_dir() -> Path:
|
||||
# Keep this compatible with typical Oclaw layouts.
|
||||
override = os.environ.get("OCLAW_STATE_DIR") or os.environ.get("OCLAW_HOME")
|
||||
if override and override.strip():
|
||||
return Path(os.path.expanduser(override.strip())).resolve()
|
||||
return Path.home() / ".oclaw"
|
||||
|
||||
|
||||
def handle(event) -> None:
|
||||
if getattr(event, "type", None) != "command":
|
||||
return
|
||||
|
||||
state_dir = _resolve_state_dir()
|
||||
log_dir = state_dir / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"timestamp": getattr(getattr(event, "timestamp", None), "isoformat", lambda: None)(),
|
||||
"action": getattr(event, "action", None),
|
||||
"sessionKey": getattr(event, "sessionKey", None),
|
||||
"senderId": (getattr(event, "context", {}) or {}).get("senderId", "unknown"),
|
||||
"source": (getattr(event, "context", {}) or {}).get("commandSource", "unknown"),
|
||||
}
|
||||
|
||||
with (log_dir / "commands.log").open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
||||
|
||||
17
runtime/hooks/bundled/session-memory/HOOK.md
Normal file
17
runtime/hooks/bundled/session-memory/HOOK.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
name: session-memory
|
||||
description: "Save session context to memory when /new or /reset command is issued"
|
||||
metadata:
|
||||
oclaw:
|
||||
emoji: "💾"
|
||||
events: ["command:new", "command:reset"]
|
||||
---
|
||||
|
||||
# Session Memory Hook (Python)
|
||||
|
||||
On `command:new` / `command:reset`, exports the latest N messages of the session into
|
||||
`<workspace>/memory/YYYY-MM-DD-<slug>.md`.
|
||||
|
||||
Notes:
|
||||
- This Python port reads messages from SQLite (`SqliteStore`) instead of workspace session transcript files.
|
||||
|
||||
165
runtime/hooks/bundled/session-memory/handler.py
Normal file
165
runtime/hooks/bundled/session-memory/handler.py
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
HOOK_KEY = "session-memory"
|
||||
|
||||
|
||||
def _ensure_repo_imports() -> None:
|
||||
# Allow importing repository modules when hook is loaded by path.
|
||||
# handler.py is under oclaw/hooks/bundled/session-memory/
|
||||
repo = Path(__file__).resolve().parents[5]
|
||||
if str(repo) not in sys.path:
|
||||
sys.path.insert(0, str(repo))
|
||||
|
||||
|
||||
def _resolve_hook_cfg(cfg: Any) -> Dict[str, Any]:
|
||||
if not isinstance(cfg, dict):
|
||||
return {}
|
||||
hooks = cfg.get("hooks") if isinstance(cfg.get("hooks"), dict) else {}
|
||||
internal = hooks.get("internal") if isinstance(hooks.get("internal"), dict) else {}
|
||||
entries = internal.get("entries") if isinstance(internal.get("entries"), dict) else {}
|
||||
row = entries.get(HOOK_KEY)
|
||||
return row if isinstance(row, dict) else {}
|
||||
|
||||
|
||||
_SLUG_SAFE_RE = re.compile(r"[^a-z0-9]+")
|
||||
|
||||
|
||||
def _slugify(text: str, *, max_len: int = 40) -> str:
|
||||
s = (text or "").strip().lower()
|
||||
s = _SLUG_SAFE_RE.sub("-", s).strip("-")
|
||||
if not s:
|
||||
return ""
|
||||
s = s[:max_len].strip("-")
|
||||
return s or ""
|
||||
|
||||
|
||||
def _fallback_time_slug(ts: _dt.datetime) -> str:
|
||||
return ts.strftime("%H%M")
|
||||
|
||||
|
||||
def _workspace_dir_from_event(event: Any) -> Optional[Path]:
|
||||
ctx = getattr(event, "context", None)
|
||||
if not isinstance(ctx, dict):
|
||||
return None
|
||||
ws = ctx.get("workspaceDir")
|
||||
if isinstance(ws, str) and ws.strip():
|
||||
return Path(ws).expanduser()
|
||||
env_ws = str(os.getenv("OCLAW_WORKSPACE") or "").strip()
|
||||
if env_ws:
|
||||
return Path(env_ws).expanduser()
|
||||
return None
|
||||
|
||||
|
||||
def _recent_conversation_lines(msgs: List[Any], *, max_pairs: int) -> str:
|
||||
"""
|
||||
Render a minimal markdown "conversation" block.
|
||||
"""
|
||||
# Keep only user/assistant/tool-ish messages; show role prefixes.
|
||||
lines: list[str] = []
|
||||
for m in msgs[-max(1, max_pairs * 2) :]:
|
||||
role = str(getattr(m, "role", "") or "").strip() or "unknown"
|
||||
content = str(getattr(m, "content", "") or "").strip()
|
||||
if not content:
|
||||
continue
|
||||
if role.lower() == "assistant":
|
||||
prefix = "Assistant"
|
||||
elif role.lower() == "user":
|
||||
prefix = "User"
|
||||
else:
|
||||
prefix = role
|
||||
lines.append(f"- **{prefix}**: {content}")
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
|
||||
def handle(event: Any) -> None:
|
||||
# Only trigger on command new/reset
|
||||
if getattr(event, "type", None) != "command":
|
||||
return
|
||||
action = str(getattr(event, "action", "") or "").strip().lower()
|
||||
if action not in {"new", "reset"}:
|
||||
return
|
||||
|
||||
ctx = getattr(event, "context", None)
|
||||
if not isinstance(ctx, dict):
|
||||
ctx = {}
|
||||
|
||||
cfg = ctx.get("cfg")
|
||||
hook_cfg = _resolve_hook_cfg(cfg)
|
||||
if hook_cfg.get("enabled") is False:
|
||||
return
|
||||
|
||||
max_msgs = hook_cfg.get("messages")
|
||||
try:
|
||||
max_msgs_n = int(max_msgs) if max_msgs is not None else 15
|
||||
except Exception:
|
||||
max_msgs_n = 15
|
||||
max_msgs_n = max(5, min(max_msgs_n, 200))
|
||||
|
||||
ts = getattr(event, "timestamp", None)
|
||||
if not isinstance(ts, _dt.datetime):
|
||||
ts = _dt.datetime.now(tz=_dt.timezone.utc)
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=_dt.timezone.utc)
|
||||
|
||||
ws_dir = _workspace_dir_from_event(event)
|
||||
if ws_dir is None:
|
||||
# Nothing to do without a workspace dir target.
|
||||
return
|
||||
ws_dir.mkdir(parents=True, exist_ok=True)
|
||||
mem_dir = ws_dir / "memory"
|
||||
mem_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
session_id = str(getattr(event, "sessionKey", "") or "").strip() or "unknown"
|
||||
|
||||
# Fetch recent messages from sqlite.
|
||||
try:
|
||||
_ensure_repo_imports()
|
||||
from oclaw.platform.config.paths import db_path # type: ignore
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore # type: ignore
|
||||
|
||||
store = SqliteStore(db_path())
|
||||
msgs = store.get_messages(session_id=session_id, limit=max_msgs_n)
|
||||
except Exception:
|
||||
msgs = []
|
||||
|
||||
convo = _recent_conversation_lines(list(msgs or []), max_pairs=max_msgs_n)
|
||||
|
||||
# Build slug from first user message in window.
|
||||
base_slug = ""
|
||||
for m in list(msgs or []):
|
||||
if str(getattr(m, "role", "") or "").strip().lower() != "user":
|
||||
continue
|
||||
t = str(getattr(m, "content", "") or "").strip()
|
||||
if t:
|
||||
base_slug = _slugify(t)
|
||||
break
|
||||
if not base_slug:
|
||||
base_slug = _fallback_time_slug(ts)
|
||||
|
||||
date_str = ts.date().isoformat()
|
||||
filename = f"{date_str}-{base_slug}.md"
|
||||
target = mem_dir / filename
|
||||
|
||||
header = [
|
||||
f"# Session: {date_str} {ts.strftime('%H:%M:%S')} UTC",
|
||||
"",
|
||||
f"- **Session Key**: {session_id}",
|
||||
f"- **Action**: {action}",
|
||||
"",
|
||||
]
|
||||
body: list[str] = []
|
||||
if convo:
|
||||
body.extend(["## Conversation Summary", "", convo, ""])
|
||||
else:
|
||||
body.extend(["## Conversation Summary", "", "- (no messages found)", ""])
|
||||
|
||||
target.write_text("\n".join(header + body).strip() + "\n", encoding="utf-8")
|
||||
|
||||
13
runtime/hooks/bundled/wiki-auto-inject/HOOK.md
Normal file
13
runtime/hooks/bundled/wiki-auto-inject/HOOK.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
name: wiki-auto-inject
|
||||
description: "Inject wiki context before model prompt build"
|
||||
metadata:
|
||||
oclaw:
|
||||
emoji: "📚"
|
||||
events: ["llm:before_prompt_build"]
|
||||
---
|
||||
|
||||
# Wiki Auto Inject Hook
|
||||
|
||||
Builds a compact wiki context block and prepends it to system prompt via
|
||||
`event.context.prepend_system_context`.
|
||||
345
runtime/hooks/bundled/wiki-auto-inject/handler.py
Normal file
345
runtime/hooks/bundled/wiki-auto-inject/handler.py
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _project_root() -> Path:
|
||||
return Path(__file__).resolve().parents[4]
|
||||
|
||||
|
||||
def _load_config() -> dict[str, Any]:
|
||||
cfg_path = _project_root() / "oclaw" / "oclaw.json"
|
||||
if not cfg_path.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(cfg_path.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _resolve_wiki_entry(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
plugins = cfg.get("plugins") if isinstance(cfg, dict) else {}
|
||||
entries = plugins.get("entries") if isinstance(plugins, dict) else {}
|
||||
entry = entries.get("memory-wiki") if isinstance(entries, dict) else {}
|
||||
return entry if isinstance(entry, dict) else {}
|
||||
|
||||
|
||||
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 = Path(root_cfg)
|
||||
if not root.is_absolute():
|
||||
root = (_project_root() / root).resolve()
|
||||
auto = entry.get("auto") if isinstance(entry.get("auto"), dict) else {}
|
||||
inject = auto.get("inject") if isinstance(auto, dict) else {}
|
||||
max_chars = int(inject.get("max_chars") or 1800)
|
||||
top_k = int(inject.get("top_k") or 6)
|
||||
ultra_saver_enabled = bool(inject.get("ultra_saver_enabled", False))
|
||||
min_query_chars = int(inject.get("min_query_chars") or 20)
|
||||
require_topic_hint = bool(inject.get("require_topic_hint", True))
|
||||
return (
|
||||
root,
|
||||
max(500, min(max_chars, 8000)),
|
||||
max(1, min(top_k, 20)),
|
||||
ultra_saver_enabled,
|
||||
max(1, min(min_query_chars, 500)),
|
||||
require_topic_hint,
|
||||
)
|
||||
|
||||
|
||||
def _enabled(entry: dict[str, Any]) -> bool:
|
||||
auto = entry.get("auto") if isinstance(entry.get("auto"), dict) else {}
|
||||
return bool(auto.get("enabled", False))
|
||||
|
||||
|
||||
def _default_topic_rules() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{"topic": "network", "keywords": ["vlan", "router", "switch", "network", "dns", "gateway"]},
|
||||
{"topic": "devops", "keywords": ["deploy", "k8s", "kubernetes", "docker", "ci", "ops"]},
|
||||
{"topic": "engineering", "keywords": ["bug", "fix", "todo", "feature", "refactor", "test"]},
|
||||
]
|
||||
|
||||
|
||||
def _resolve_topic_rules(entry: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
auto = entry.get("auto") if isinstance(entry.get("auto"), dict) else {}
|
||||
routing = auto.get("topic_routing") if isinstance(auto.get("topic_routing"), dict) else {}
|
||||
rules = routing.get("rules")
|
||||
if not isinstance(rules, list):
|
||||
return _default_topic_rules()
|
||||
out: list[dict[str, Any]] = []
|
||||
for rule in rules:
|
||||
if not isinstance(rule, dict):
|
||||
continue
|
||||
topic = str(rule.get("topic") or "").strip().lower()
|
||||
kws = rule.get("keywords")
|
||||
if not topic or not isinstance(kws, list):
|
||||
continue
|
||||
keywords = [str(k).strip().lower() for k in kws if str(k).strip()]
|
||||
if not keywords:
|
||||
continue
|
||||
out.append({"topic": topic, "keywords": keywords})
|
||||
return out or _default_topic_rules()
|
||||
|
||||
|
||||
def _query_terms(query: str) -> list[str]:
|
||||
return [t for t in re.split(r"\s+", query.strip().lower()) if len(t) >= 2]
|
||||
|
||||
|
||||
def _score_line(*, query: str, terms: list[str], text: str) -> float:
|
||||
low = text.lower()
|
||||
if not low:
|
||||
return 0.0
|
||||
score = 0.0
|
||||
if query and query in low:
|
||||
score += 4.0
|
||||
hit_terms = 0
|
||||
for term in terms:
|
||||
if term in low:
|
||||
hit_terms += 1
|
||||
score += 1.2
|
||||
if hit_terms > 1:
|
||||
score += 0.8
|
||||
return score
|
||||
|
||||
|
||||
def _line_snippet(lines: list[str], idx: int) -> str:
|
||||
parts: list[str] = []
|
||||
start = max(0, idx - 1)
|
||||
end = min(len(lines), idx + 2)
|
||||
for i in range(start, end):
|
||||
line = str(lines[i] or "").strip()
|
||||
if not line:
|
||||
continue
|
||||
parts.append(line)
|
||||
return " | ".join(parts)
|
||||
|
||||
|
||||
def _load_index_file_set(wiki_root: Path) -> set[str]:
|
||||
idx = wiki_root / ".oclaw" / "index.json"
|
||||
if not idx.exists():
|
||||
return set()
|
||||
try:
|
||||
obj = json.loads(idx.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return set()
|
||||
files = obj.get("files") if isinstance(obj, dict) else None
|
||||
if not isinstance(files, list):
|
||||
return set()
|
||||
out: set[str] = set()
|
||||
for one in files:
|
||||
rel = str(one or "").replace("\\", "/").strip().lstrip("/")
|
||||
if rel:
|
||||
out.add(rel)
|
||||
return out
|
||||
|
||||
|
||||
def _load_topic_index(wiki_root: Path) -> dict[str, str]:
|
||||
p = wiki_root / ".oclaw" / "topic-index.json"
|
||||
if not p.exists():
|
||||
return {}
|
||||
try:
|
||||
obj = json.loads(p.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
topics = obj.get("topics") if isinstance(obj, dict) else None
|
||||
if not isinstance(topics, dict):
|
||||
return {}
|
||||
out: dict[str, str] = {}
|
||||
for topic, meta in topics.items():
|
||||
if not isinstance(topic, str) or not isinstance(meta, dict):
|
||||
continue
|
||||
rel = str(meta.get("path") or "").replace("\\", "/").strip().lstrip("/")
|
||||
if rel:
|
||||
out[topic.lower()] = rel
|
||||
return out
|
||||
|
||||
|
||||
def _query_topic_hints(query: str, rules: list[dict[str, Any]]) -> list[str]:
|
||||
low = str(query or "").lower()
|
||||
hints: list[str] = []
|
||||
for rule in rules:
|
||||
topic = str(rule.get("topic") or "").strip().lower()
|
||||
kws = rule.get("keywords") if isinstance(rule.get("keywords"), list) else []
|
||||
if not topic:
|
||||
continue
|
||||
if any(str(k).lower() in low for k in kws):
|
||||
hints.append(topic)
|
||||
return hints
|
||||
|
||||
|
||||
def _candidate_files(wiki_root: Path) -> list[Path]:
|
||||
preferred: list[Path] = []
|
||||
merged = wiki_root / "inbox" / "merged-turns.md"
|
||||
if merged.exists() and merged.is_file():
|
||||
preferred.append(merged)
|
||||
index_set = _load_index_file_set(wiki_root)
|
||||
files = sorted([p for p in wiki_root.rglob("*.md") if p.is_file()])
|
||||
if not files:
|
||||
return preferred
|
||||
if not index_set:
|
||||
return preferred + [p for p in files if p not in preferred]
|
||||
prioritized = []
|
||||
fallback = []
|
||||
for p in files:
|
||||
if p in preferred:
|
||||
continue
|
||||
rel = str(p.relative_to(wiki_root)).replace("\\", "/")
|
||||
if rel in index_set:
|
||||
prioritized.append(p)
|
||||
else:
|
||||
fallback.append(p)
|
||||
return preferred + prioritized + fallback
|
||||
|
||||
|
||||
def _candidate_files_for_query(wiki_root: Path, query: str, topic_rules: list[dict[str, Any]]) -> list[Path]:
|
||||
preferred: list[Path] = []
|
||||
topic_map = _load_topic_index(wiki_root)
|
||||
for hint in _query_topic_hints(query, topic_rules):
|
||||
rel = topic_map.get(hint)
|
||||
if not rel:
|
||||
continue
|
||||
p = wiki_root / rel
|
||||
if p.exists() and p.is_file() and p not in preferred:
|
||||
preferred.append(p)
|
||||
merged = wiki_root / "inbox" / "merged-turns.md"
|
||||
if merged.exists() and merged.is_file() and merged not in preferred:
|
||||
preferred.append(merged)
|
||||
rest = _candidate_files(wiki_root)
|
||||
return preferred + [p for p in rest if p not in preferred]
|
||||
|
||||
|
||||
def _collect_snippets(
|
||||
wiki_root: Path,
|
||||
query: str,
|
||||
max_chars: int,
|
||||
top_k: int,
|
||||
topic_rules: list[dict[str, Any]] | None = None,
|
||||
) -> tuple[str, list[dict[str, Any]]]:
|
||||
if not wiki_root.exists():
|
||||
return "", []
|
||||
q = query.strip().lower()
|
||||
if not q:
|
||||
return "", []
|
||||
terms = _query_terms(query)
|
||||
if not terms and len(query) < 2:
|
||||
return "", []
|
||||
rules = topic_rules or _default_topic_rules()
|
||||
candidates: list[tuple[float, int, str, int, str]] = []
|
||||
for source_rank, fp in enumerate(_candidate_files_for_query(wiki_root, query, rules)):
|
||||
rel = str(fp.relative_to(wiki_root)).replace("\\", "/")
|
||||
try:
|
||||
lines = fp.read_text(encoding="utf-8").splitlines()
|
||||
except Exception:
|
||||
continue
|
||||
for idx, line in enumerate(lines, start=1):
|
||||
txt = line.strip()
|
||||
if not txt:
|
||||
continue
|
||||
score = _score_line(query=q, terms=terms, text=txt)
|
||||
if score <= 0:
|
||||
continue
|
||||
snippet = _line_snippet(lines, idx - 1)
|
||||
one = f"- {rel}:{idx} {snippet}"
|
||||
candidates.append((score, int(source_rank), rel, idx, one))
|
||||
if not candidates:
|
||||
return "", []
|
||||
candidates.sort(key=lambda x: (-x[0], x[1], x[2], x[3]))
|
||||
blocks: list[str] = []
|
||||
meta: list[dict[str, Any]] = []
|
||||
total = 0
|
||||
truncated = False
|
||||
for score, _rank, _rel, _idx, one in candidates[: top_k * 10]:
|
||||
if any(one == exist for exist in blocks):
|
||||
continue
|
||||
if total + len(one) + 1 > max_chars:
|
||||
truncated = True
|
||||
break
|
||||
blocks.append(one)
|
||||
meta.append({"source": _rel, "line": int(_idx), "score": round(float(score), 3)})
|
||||
total += len(one) + 1
|
||||
if len(blocks) >= top_k:
|
||||
truncated = len(candidates) > len(meta)
|
||||
break
|
||||
if truncated:
|
||||
for item in meta:
|
||||
item["truncated"] = True
|
||||
return "\n".join(blocks).strip(), meta
|
||||
|
||||
|
||||
def handle(event: Any) -> None:
|
||||
if getattr(event, "type", None) != "llm" or getattr(event, "action", None) != "before_prompt_build":
|
||||
return
|
||||
ctx = getattr(event, "context", None)
|
||||
if not isinstance(ctx, dict):
|
||||
return
|
||||
memory_mode = str(ctx.get("memory_mode") or "default").strip().lower()
|
||||
if memory_mode == "store_only":
|
||||
ctx["wiki_inject_meta"] = {
|
||||
"enabled": False,
|
||||
"memory_mode": "store_only",
|
||||
"skip_reason": "memory_mode_store_only",
|
||||
}
|
||||
return
|
||||
cfg = _load_config()
|
||||
entry = _resolve_wiki_entry(cfg)
|
||||
if not _enabled(entry):
|
||||
ctx["wiki_inject_meta"] = {
|
||||
"enabled": False,
|
||||
"memory_mode": memory_mode,
|
||||
"skip_reason": "auto_disabled",
|
||||
}
|
||||
return
|
||||
wiki_root, max_chars, top_k, ultra_saver_enabled, min_query_chars, require_topic_hint = _resolve_runtime(entry)
|
||||
topic_rules = _resolve_topic_rules(entry)
|
||||
query = str(ctx.get("userText") or "").strip()
|
||||
if ultra_saver_enabled and len(query) < min_query_chars:
|
||||
ctx["wiki_inject_meta"] = {
|
||||
"enabled": False,
|
||||
"memory_mode": memory_mode,
|
||||
"ultra_saver_enabled": True,
|
||||
"skip_reason": "short_query",
|
||||
"min_query_chars": int(min_query_chars),
|
||||
"query_len": int(len(query)),
|
||||
}
|
||||
return
|
||||
if ultra_saver_enabled and require_topic_hint and not _query_topic_hints(query, topic_rules):
|
||||
ctx["wiki_inject_meta"] = {
|
||||
"enabled": False,
|
||||
"memory_mode": memory_mode,
|
||||
"ultra_saver_enabled": True,
|
||||
"skip_reason": "no_topic_hint",
|
||||
}
|
||||
return
|
||||
snippets, inject_meta = _collect_snippets(
|
||||
wiki_root,
|
||||
query,
|
||||
max_chars=max_chars,
|
||||
top_k=top_k,
|
||||
topic_rules=topic_rules,
|
||||
)
|
||||
if not snippets:
|
||||
ctx["wiki_inject_meta"] = {
|
||||
"enabled": False,
|
||||
"memory_mode": memory_mode,
|
||||
"top_k": int(top_k),
|
||||
"max_chars": int(max_chars),
|
||||
"ultra_saver_enabled": bool(ultra_saver_enabled),
|
||||
"skip_reason": "no_snippets",
|
||||
}
|
||||
return
|
||||
ctx["wiki_inject_meta"] = {
|
||||
"enabled": True,
|
||||
"memory_mode": memory_mode,
|
||||
"top_k": int(top_k),
|
||||
"max_chars": int(max_chars),
|
||||
"ultra_saver_enabled": bool(ultra_saver_enabled),
|
||||
"hits": inject_meta,
|
||||
}
|
||||
ctx["prepend_system_context"] = (
|
||||
"## Wiki Context (Auto Inject)\n"
|
||||
"Use as background context only; prefer latest user request when conflicts exist.\n\n"
|
||||
f"{snippets}"
|
||||
)
|
||||
75
runtime/hooks/frontmatter.py
Normal file
75
runtime/hooks/frontmatter.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ParsedHookFrontmatter:
|
||||
frontmatter: Dict[str, Any]
|
||||
body: str
|
||||
|
||||
|
||||
def parse_frontmatter(markdown: str) -> ParsedHookFrontmatter:
|
||||
"""
|
||||
Parse YAML frontmatter delimited by leading '---' lines.
|
||||
Returns (frontmatter_dict, remaining_body).
|
||||
"""
|
||||
text = markdown.lstrip("\ufeff")
|
||||
if not text.startswith("---"):
|
||||
return ParsedHookFrontmatter(frontmatter={}, body=markdown)
|
||||
|
||||
lines = text.splitlines(keepends=True)
|
||||
if not lines:
|
||||
return ParsedHookFrontmatter(frontmatter={}, body=markdown)
|
||||
|
||||
# first line is '---'
|
||||
if not lines[0].strip().startswith("---"):
|
||||
return ParsedHookFrontmatter(frontmatter={}, body=markdown)
|
||||
|
||||
fm_lines = []
|
||||
end_idx = None
|
||||
for i in range(1, len(lines)):
|
||||
if lines[i].strip().startswith("---"):
|
||||
end_idx = i
|
||||
break
|
||||
fm_lines.append(lines[i])
|
||||
|
||||
if end_idx is None:
|
||||
return ParsedHookFrontmatter(frontmatter={}, body=markdown)
|
||||
|
||||
fm_raw = "".join(fm_lines)
|
||||
body = "".join(lines[end_idx + 1 :])
|
||||
|
||||
try:
|
||||
parsed = yaml.safe_load(fm_raw) or {}
|
||||
if not isinstance(parsed, dict):
|
||||
parsed = {}
|
||||
except Exception:
|
||||
parsed = {}
|
||||
return ParsedHookFrontmatter(frontmatter=parsed, body=body)
|
||||
|
||||
|
||||
def resolve_oclaw_metadata(frontmatter: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Oclaw embeds metadata as a JSON-like object under key 'metadata' -> 'oclaw'
|
||||
(or sometimes as already-parsed dict). We normalize to a dict if present.
|
||||
"""
|
||||
meta = frontmatter.get("metadata")
|
||||
if not isinstance(meta, dict):
|
||||
return None
|
||||
oc = meta.get("oclaw")
|
||||
return oc if isinstance(oc, dict) else None
|
||||
|
||||
|
||||
def resolve_hook_key(name: str, entry: Dict[str, Any]) -> str:
|
||||
# Oclaw's TS resolves a config key with possible explicit hookKey; keep it simple.
|
||||
md = entry.get("metadata") if isinstance(entry, dict) else None
|
||||
if isinstance(md, dict):
|
||||
hook_key = md.get("hookKey")
|
||||
if isinstance(hook_key, str) and hook_key.strip():
|
||||
return hook_key.strip()
|
||||
return name
|
||||
|
||||
97
runtime/hooks/internal_hooks.py
Normal file
97
runtime/hooks/internal_hooks.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Awaitable, Callable, Dict, List, MutableMapping, Optional, Sequence, Set, Tuple, Union
|
||||
|
||||
log = logging.getLogger("oclaw.hooks")
|
||||
|
||||
HookEventType = str
|
||||
HookAction = str
|
||||
HookEventKey = str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class HookEvent:
|
||||
"""
|
||||
A lightweight, transport-agnostic hook event.
|
||||
|
||||
This intentionally mirrors the Oclaw internal hook model:
|
||||
- handlers may register on "type" or "type:action"
|
||||
- failures are isolated per-handler
|
||||
"""
|
||||
|
||||
type: HookEventType
|
||||
action: HookAction
|
||||
sessionKey: str
|
||||
context: Dict[str, Any] = field(default_factory=dict)
|
||||
timestamp: _dt.datetime = field(default_factory=lambda: _dt.datetime.now(tz=_dt.timezone.utc))
|
||||
messages: List[Any] = field(default_factory=list)
|
||||
|
||||
|
||||
HookHandler = Callable[[HookEvent], Union[None, Awaitable[None]]]
|
||||
|
||||
_handlers: MutableMapping[HookEventKey, List[HookHandler]] = {}
|
||||
_enabled_state: Dict[str, bool] = {"enabled": True}
|
||||
|
||||
|
||||
def register_hook(event_key: str, handler: HookHandler) -> None:
|
||||
_handlers.setdefault(event_key, []).append(handler)
|
||||
|
||||
|
||||
def unregister_hook(event_key: str, handler: HookHandler) -> None:
|
||||
existing = _handlers.get(event_key)
|
||||
if not existing:
|
||||
return
|
||||
try:
|
||||
existing.remove(handler)
|
||||
except ValueError:
|
||||
return
|
||||
if not existing:
|
||||
_handlers.pop(event_key, None)
|
||||
|
||||
|
||||
def clear_hooks() -> None:
|
||||
_handlers.clear()
|
||||
|
||||
|
||||
def set_hooks_enabled(enabled: bool) -> None:
|
||||
_enabled_state["enabled"] = bool(enabled)
|
||||
|
||||
|
||||
def get_registered_hook_event_keys() -> List[str]:
|
||||
return list(_handlers.keys())
|
||||
|
||||
|
||||
def _has_listeners(event_type: str, action: str) -> bool:
|
||||
return bool(_handlers.get(event_type)) or bool(_handlers.get(f"{event_type}:{action}"))
|
||||
|
||||
|
||||
def create_hook_event(
|
||||
event_type: HookEventType,
|
||||
action: HookAction,
|
||||
session_key: str,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
) -> HookEvent:
|
||||
return HookEvent(type=event_type, action=action, sessionKey=session_key, context=context or {})
|
||||
|
||||
|
||||
async def trigger_hook(event: HookEvent) -> None:
|
||||
if not _enabled_state["enabled"]:
|
||||
return
|
||||
if not _has_listeners(event.type, event.action):
|
||||
return
|
||||
|
||||
all_handlers: List[HookHandler] = []
|
||||
all_handlers.extend(_handlers.get(event.type, []))
|
||||
all_handlers.extend(_handlers.get(f"{event.type}:{event.action}", []))
|
||||
|
||||
for handler in all_handlers:
|
||||
try:
|
||||
res = handler(event)
|
||||
if hasattr(res, "__await__"):
|
||||
await res # type: ignore[misc]
|
||||
except Exception:
|
||||
log.exception("Hook error [%s:%s]", event.type, event.action)
|
||||
|
||||
104
runtime/hooks/loader.py
Normal file
104
runtime/hooks/loader.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .internal_hooks import HookHandler, register_hook, unregister_hook
|
||||
from .policy import resolve_hook_enable_state, resolve_hook_config
|
||||
from .workspace import HookEntry, load_workspace_hook_entries
|
||||
|
||||
|
||||
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_handler(mod: object, export_name: str) -> Optional[HookHandler]:
|
||||
handler = getattr(mod, export_name, 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,
|
||||
) -> int:
|
||||
"""
|
||||
Discover python hooks and register them into the in-process registry.
|
||||
|
||||
Hook file layout:
|
||||
<hookDir>/HOOK.md (YAML frontmatter with oclaw metadata)
|
||||
<hookDir>/handler.py (or index.py)
|
||||
|
||||
Metadata fields used:
|
||||
metadata.oclaw.events: ["type", "type:action", ...]
|
||||
metadata.oclaw.export: handler function name (default: "default" -> we map to "handle")
|
||||
"""
|
||||
# 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,
|
||||
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):
|
||||
state = resolve_hook_enable_state(entry, config)
|
||||
if not state.get("enabled"):
|
||||
continue
|
||||
|
||||
md = entry.get("metadata") or {}
|
||||
events = md.get("events") if isinstance(md, dict) else None
|
||||
if not isinstance(events, list) or not events:
|
||||
continue
|
||||
|
||||
hook = entry.get("hook") or {}
|
||||
handler_path = hook.get("handlerPath")
|
||||
if not isinstance(handler_path, str) or not handler_path:
|
||||
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 = "handle"
|
||||
|
||||
mod = _load_module_from_path(handler_path, unique_key=str(idx))
|
||||
if mod is None:
|
||||
continue
|
||||
|
||||
handler = _resolve_handler(mod, export_name)
|
||||
if handler is None:
|
||||
continue
|
||||
|
||||
for event_key in events:
|
||||
if isinstance(event_key, str) and event_key.strip():
|
||||
register_hook(event_key.strip(), handler)
|
||||
loaded += 1
|
||||
|
||||
return loaded
|
||||
|
||||
125
runtime/hooks/policy.py
Normal file
125
runtime/hooks/policy.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Dict, List, Literal, Optional, Sequence, Tuple
|
||||
|
||||
from .frontmatter import resolve_hook_key
|
||||
|
||||
HookSource = Literal["oclaw-bundled", "oclaw-plugin", "oclaw-managed", "oclaw-workspace"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class HookSourcePolicy:
|
||||
precedence: int
|
||||
trustedLocalCode: bool
|
||||
defaultEnableMode: Literal["default-on", "explicit-opt-in"]
|
||||
canOverride: Tuple[HookSource, ...]
|
||||
canBeOverriddenBy: Tuple[HookSource, ...]
|
||||
|
||||
|
||||
HOOK_SOURCE_POLICIES: Dict[HookSource, HookSourcePolicy] = {
|
||||
"oclaw-bundled": HookSourcePolicy(
|
||||
precedence=10,
|
||||
trustedLocalCode=True,
|
||||
defaultEnableMode="default-on",
|
||||
canOverride=("oclaw-bundled",),
|
||||
canBeOverriddenBy=("oclaw-managed", "oclaw-plugin"),
|
||||
),
|
||||
"oclaw-plugin": HookSourcePolicy(
|
||||
precedence=20,
|
||||
trustedLocalCode=True,
|
||||
defaultEnableMode="default-on",
|
||||
canOverride=("oclaw-bundled", "oclaw-plugin"),
|
||||
canBeOverriddenBy=("oclaw-managed",),
|
||||
),
|
||||
"oclaw-managed": HookSourcePolicy(
|
||||
precedence=30,
|
||||
trustedLocalCode=True,
|
||||
defaultEnableMode="default-on",
|
||||
canOverride=("oclaw-bundled", "oclaw-managed", "oclaw-plugin"),
|
||||
canBeOverriddenBy=("oclaw-managed",),
|
||||
),
|
||||
"oclaw-workspace": HookSourcePolicy(
|
||||
precedence=40,
|
||||
trustedLocalCode=True,
|
||||
defaultEnableMode="explicit-opt-in",
|
||||
canOverride=("oclaw-workspace",),
|
||||
canBeOverriddenBy=("oclaw-workspace",),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_hook_source_policy(source: HookSource) -> HookSourcePolicy:
|
||||
return HOOK_SOURCE_POLICIES[source]
|
||||
|
||||
|
||||
def resolve_hook_config(config: Optional[Dict[str, Any]], hook_key: str) -> Optional[Dict[str, Any]]:
|
||||
if not config or not isinstance(config, dict):
|
||||
return None
|
||||
hooks = (((config.get("hooks") or {}).get("internal") or {}).get("entries")) # type: ignore[assignment]
|
||||
if not isinstance(hooks, dict):
|
||||
return None
|
||||
entry = hooks.get(hook_key)
|
||||
return entry if isinstance(entry, dict) else None
|
||||
|
||||
|
||||
def resolve_hook_enable_state(entry: Dict[str, Any], config: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""
|
||||
Returns { enabled: bool, reason?: str }
|
||||
"""
|
||||
hook = entry.get("hook") if isinstance(entry, dict) else None
|
||||
name = (hook or {}).get("name") if isinstance(hook, dict) else None
|
||||
source = (hook or {}).get("source") if isinstance(hook, dict) else None
|
||||
if not isinstance(name, str) or not isinstance(source, str):
|
||||
return {"enabled": False, "reason": "invalid hook entry"}
|
||||
|
||||
hook_key = resolve_hook_key(name, entry)
|
||||
hook_cfg = resolve_hook_config(config, hook_key)
|
||||
|
||||
if source == "oclaw-plugin":
|
||||
return {"enabled": True}
|
||||
|
||||
if isinstance(hook_cfg, dict) and hook_cfg.get("enabled") is False:
|
||||
return {"enabled": False, "reason": "disabled in config"}
|
||||
|
||||
policy = get_hook_source_policy(source) # type: ignore[arg-type]
|
||||
if policy.defaultEnableMode == "explicit-opt-in":
|
||||
if not isinstance(hook_cfg, dict) or hook_cfg.get("enabled") is not True:
|
||||
return {"enabled": False, "reason": "workspace hook (disabled by default)"}
|
||||
return {"enabled": True}
|
||||
|
||||
|
||||
def _can_override(candidate: Dict[str, Any], existing: Dict[str, Any]) -> bool:
|
||||
c_source = ((candidate.get("hook") or {}).get("source")) if isinstance(candidate, dict) else None
|
||||
e_source = ((existing.get("hook") or {}).get("source")) if isinstance(existing, dict) else None
|
||||
if c_source not in HOOK_SOURCE_POLICIES or e_source not in HOOK_SOURCE_POLICIES:
|
||||
return False
|
||||
c_pol = get_hook_source_policy(c_source) # type: ignore[arg-type]
|
||||
e_pol = get_hook_source_policy(e_source) # type: ignore[arg-type]
|
||||
return (e_source in c_pol.canOverride) and (c_source in e_pol.canBeOverriddenBy)
|
||||
|
||||
|
||||
def resolve_hook_entries(
|
||||
entries: Sequence[Dict[str, Any]],
|
||||
on_collision_ignored: Optional[Callable[[Dict[str, Any]], None]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
ordered = sorted(
|
||||
list(enumerate(entries)),
|
||||
key=lambda x: (get_hook_source_policy(x[1]["hook"]["source"]).precedence, x[0]), # type: ignore[index]
|
||||
)
|
||||
merged: Dict[str, Dict[str, Any]] = {}
|
||||
for _, entry in ordered:
|
||||
name = entry.get("hook", {}).get("name")
|
||||
if not isinstance(name, str):
|
||||
continue
|
||||
existing = merged.get(name)
|
||||
if not existing:
|
||||
merged[name] = entry
|
||||
continue
|
||||
if _can_override(entry, existing):
|
||||
merged[name] = entry
|
||||
continue
|
||||
if on_collision_ignored:
|
||||
on_collision_ignored({"name": name, "kept": existing, "ignored": entry})
|
||||
return list(merged.values())
|
||||
|
||||
140
runtime/hooks/workspace.py
Normal file
140
runtime/hooks/workspace.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Literal, Optional, Sequence, Tuple
|
||||
|
||||
from .frontmatter import parse_frontmatter, resolve_oclaw_metadata
|
||||
from .policy import HookSource, resolve_hook_entries
|
||||
|
||||
|
||||
HookEntry = Dict[str, Any]
|
||||
|
||||
|
||||
def _read_text(path: Path) -> Optional[str]:
|
||||
try:
|
||||
return path.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _safe_is_dir(p: Path) -> bool:
|
||||
try:
|
||||
return p.exists() and p.is_dir()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _handler_candidates() -> Tuple[str, ...]:
|
||||
return ("handler.py", "index.py")
|
||||
|
||||
|
||||
def _load_hook_from_dir(hook_dir: Path, source: HookSource, plugin_id: Optional[str] = None) -> Optional[HookEntry]:
|
||||
hook_md = hook_dir / "HOOK.md"
|
||||
content = _read_text(hook_md)
|
||||
if content is None:
|
||||
return None
|
||||
|
||||
parsed = parse_frontmatter(content)
|
||||
fm = parsed.frontmatter
|
||||
name = fm.get("name") or hook_dir.name
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
name = hook_dir.name
|
||||
description = fm.get("description") or ""
|
||||
if not isinstance(description, str):
|
||||
description = ""
|
||||
|
||||
handler_path: Optional[Path] = None
|
||||
for candidate in _handler_candidates():
|
||||
cand = hook_dir / candidate
|
||||
if cand.exists() and cand.is_file():
|
||||
handler_path = cand
|
||||
break
|
||||
if handler_path is None:
|
||||
return None
|
||||
|
||||
metadata = resolve_oclaw_metadata(fm) or {}
|
||||
|
||||
return {
|
||||
"hook": {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"source": source,
|
||||
"pluginId": plugin_id,
|
||||
"filePath": str(hook_md),
|
||||
"baseDir": str(hook_dir.resolve()),
|
||||
"handlerPath": str(handler_path.resolve()),
|
||||
},
|
||||
"frontmatter": fm,
|
||||
"metadata": metadata,
|
||||
}
|
||||
|
||||
|
||||
def load_hook_entries_from_dir(dir_path: str, source: HookSource, plugin_id: Optional[str] = None) -> List[HookEntry]:
|
||||
base = Path(dir_path)
|
||||
if not _safe_is_dir(base):
|
||||
return []
|
||||
out: List[HookEntry] = []
|
||||
try:
|
||||
for child in base.iterdir():
|
||||
if not child.is_dir():
|
||||
continue
|
||||
entry = _load_hook_from_dir(child, source=source, plugin_id=plugin_id)
|
||||
if entry:
|
||||
out.append(entry)
|
||||
except Exception:
|
||||
return out
|
||||
return out
|
||||
|
||||
|
||||
def discover_workspace_hook_entries(
|
||||
workspace_dir: str,
|
||||
*,
|
||||
managed_hooks_dir: Optional[str] = None,
|
||||
bundled_hooks_dir: Optional[str] = None,
|
||||
extra_dirs: Optional[Sequence[str]] = None,
|
||||
) -> List[HookEntry]:
|
||||
"""
|
||||
Python port of Oclaw hook discovery strategy.
|
||||
|
||||
- bundled: shipped with runtime
|
||||
- managed: user config dir (~/.oclaw/hooks by default)
|
||||
- workspace: <workspace>/hooks (explicit opt-in by default)
|
||||
- extra: any extra managed dirs
|
||||
"""
|
||||
ws = Path(workspace_dir)
|
||||
managed = Path(managed_hooks_dir) if managed_hooks_dir else (Path.home() / ".oclaw" / "hooks")
|
||||
workspace_hooks = ws / "hooks"
|
||||
|
||||
entries: List[HookEntry] = []
|
||||
|
||||
if extra_dirs:
|
||||
for raw in extra_dirs:
|
||||
p = Path(os.path.expanduser(raw)).resolve()
|
||||
entries.extend(load_hook_entries_from_dir(str(p), source="oclaw-managed"))
|
||||
|
||||
if bundled_hooks_dir:
|
||||
entries.extend(load_hook_entries_from_dir(bundled_hooks_dir, source="oclaw-bundled"))
|
||||
|
||||
entries.extend(load_hook_entries_from_dir(str(managed), source="oclaw-managed"))
|
||||
entries.extend(load_hook_entries_from_dir(str(workspace_hooks), source="oclaw-workspace"))
|
||||
return entries
|
||||
|
||||
|
||||
def load_workspace_hook_entries(
|
||||
workspace_dir: str,
|
||||
*,
|
||||
managed_hooks_dir: Optional[str] = None,
|
||||
bundled_hooks_dir: Optional[str] = None,
|
||||
extra_dirs: Optional[Sequence[str]] = None,
|
||||
) -> List[HookEntry]:
|
||||
discovered = discover_workspace_hook_entries(
|
||||
workspace_dir,
|
||||
managed_hooks_dir=managed_hooks_dir,
|
||||
bundled_hooks_dir=bundled_hooks_dir,
|
||||
extra_dirs=extra_dirs,
|
||||
)
|
||||
return resolve_hook_entries(discovered)
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue