mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 22:40:45 +08:00
重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。
同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。 Made-with: Cursor
This commit is contained in:
parent
4a23b715a2
commit
dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions
|
|
@ -1,30 +1,101 @@
|
|||
## `oclaw/hooks`
|
||||
## `oclaw/runtime/hooks`
|
||||
|
||||
Unified Python hooks runtime and hook packages.
|
||||
Python hooks runtime and bundled hook packages (parity target: OpenClaw `src/hooks`).
|
||||
|
||||
### 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.
|
||||
- **Directory discovery**: `HOOK.md` + **one** handler file per hook directory (first match in priority order, see `workspace._handler_candidates`).
|
||||
- **Config gating**: `hooks.internal.enabled` and `hooks.internal.entries.<hookKey>.enabled`.
|
||||
- **Source precedence**: bundled / managed / workspace / plugin collision resolution (`policy`).
|
||||
- **Eligibility**: OS / bins / env / config paths; optional **remote** context from message metadata (`eligibility_from_metadata` + `config.should_include_hook`).
|
||||
|
||||
### Handler entry priority
|
||||
|
||||
First existing file under the hook directory wins:
|
||||
|
||||
`handler.py` → `index.py` → `handler.ts` → `index.ts` → `handler.mts` → `index.mts` → `handler.cts` → `index.cts` → `handler.mjs` → `index.mjs` → `handler.cjs` → `index.cjs` → `handler.sh` → `index.sh` → `handler.bash` → `index.bash`
|
||||
|
||||
### Hook layout
|
||||
|
||||
Put hooks in any of:
|
||||
|
||||
- **Bundled**: `oclaw/hooks/bundled/<hookName>/`
|
||||
- **Bundled**: shipped with runtime (`runtime_hooks_bundled_root()`)
|
||||
- **Managed**: `~/.oclaw/hooks/<hookName>/`
|
||||
- **Workspace**: `<workspace>/hooks/<hookName>/` (explicit opt-in by default)
|
||||
- **Workspace**: `<workspace>/hooks/<hookName>/`
|
||||
- **Plugin**: `.openclaw/extensions/<id>/.codex-plugin/plugin.json` → `hooks` paths
|
||||
- **Extra dirs**: `hooks.internal.load.extraDirs` plus skill-side `.../hooks` dirs merged at runtime init
|
||||
|
||||
Each hook directory must contain:
|
||||
Each hook directory needs `HOOK.md` (YAML frontmatter with `metadata.oclaw.events`) plus one handler file as above.
|
||||
|
||||
- `HOOK.md` with YAML frontmatter including `metadata.oclaw.events`
|
||||
- `handler.py` (or `index.py`) exporting a callable `handle(event)`
|
||||
### Remote eligibility on inbound messages
|
||||
|
||||
Callers (e.g. gateway) may attach JSON metadata:
|
||||
|
||||
```json
|
||||
{
|
||||
"hookEligibility": {
|
||||
"remote": {
|
||||
"platforms": ["linux"],
|
||||
"binsPresent": ["git", "node"],
|
||||
"note": "remote agent capabilities"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Parsed by `hook_eligibility_from_message_metadata` and passed into `initialize_hooks_runtime(..., eligibility=...)`. **Note:** hook runtime initializes once per process; the first successful init wins (see `hooks_runtime.initialize_hooks_runtime`).
|
||||
|
||||
### TS parity matrix (OpenClaw `src/hooks`)
|
||||
|
||||
Legend: **Done** / **Partial** / **TODO**
|
||||
|
||||
| Area | Status |
|
||||
|------|--------|
|
||||
| internal-hooks bus (`register` / `trigger` / `type` + `type:action`) | Done |
|
||||
| loader (`.py` import + TS/JS/shell runners, `hookMode` / `nodeScript`) | Done |
|
||||
| path boundary (`handlerPath` under `baseDir`) | Done |
|
||||
| frontmatter + `metadata.oclaw` | Done |
|
||||
| invocation + config enable gate | Done |
|
||||
| policy / source precedence | Done |
|
||||
| runtime eligibility (`os` / `requires` / env / config) | Done |
|
||||
| remote eligibility (`platforms` / `hasBin` / `hasAnyBin`) — filter API + gateway metadata wiring | Done |
|
||||
| package.json `openclaw.hooks` / `oclaw.hooks` | Done |
|
||||
| plugin hook dirs (`.codex-plugin` + `hooks`) | Done |
|
||||
| legacy `hooks.internal.handlers` | Done |
|
||||
| `hooks list/check/info` CLI (`python -m oclaw.runtime.operations hooks …`) | Done |
|
||||
| `hooks enable` / `hooks disable` (config file patch) | Done |
|
||||
| install / update hook packs (npm/git; TS `install.ts` / `update.ts`) | Partial (`hooks install` / `hooks update` print deprecation + manual/OpenClaw guidance; no npm/git runner) |
|
||||
| gmail watcher family | Partial (config gates + ``initialize_hooks_runtime`` → ``start_gmail_watcher_with_logs``; ``gog``/API loop not ported). Set ``OCLAW_SKIP_GMAIL_WATCHER=1`` (or ``OPENCLAW_SKIP_GMAIL_WATCHER``) to no-op. |
|
||||
| fire-and-forget / message-hook mappers | TODO |
|
||||
|
||||
### Minimal self-test
|
||||
|
||||
From the repository root (see `tests/conftest.py` for `sys.path` layout):
|
||||
|
||||
```bash
|
||||
python "oclaw/hooks/_selftest.py"
|
||||
python runtime/hooks/_selftest.py
|
||||
```
|
||||
|
||||
or run hook discovery / parity tests:
|
||||
|
||||
```bash
|
||||
pytest tests/test_oclaw_hooks_bundled_parity.py tests/test_oclaw_hooks_runtime.py -q
|
||||
```
|
||||
|
||||
### Operations CLI (from parent of this repo on `sys.path`, see `tests/conftest.py`)
|
||||
|
||||
```bash
|
||||
python -m oclaw.runtime.operations hooks list
|
||||
python -m oclaw.runtime.operations hooks list --eligible --verbose
|
||||
python -m oclaw.runtime.operations hooks check --json
|
||||
python -m oclaw.runtime.operations hooks info session-memory --workspace /path/to/workspace
|
||||
python -m oclaw.runtime.operations hooks enable session-memory --workspace /path/to/workspace
|
||||
python -m oclaw.runtime.operations hooks disable command-logger --workspace /path/to/workspace
|
||||
python -m oclaw.runtime.operations hooks install ./path-or-npm-spec # deprecated, exit 2 + hints
|
||||
python -m oclaw.runtime.operations hooks update --dry-run # deprecated, exit 2 + hints
|
||||
```
|
||||
|
||||
Uses the same merged config as the agent runtime (including skill `hooks/` extra dirs via `merge_skill_hook_extra_dirs_into_config`).
|
||||
|
||||
**Enable** matches OpenClaw semantics: the hook must satisfy **requirements** (bins/os/env/config) if its config entry were turned on; **plugin** hooks cannot be toggled from this CLI. Writes go to **`OCLAW_CONFIG_PATH`** (optional, relative paths resolved under `PROJECT_ROOT`) or **`oclaw/oclaw.json`** by default.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,13 @@
|
|||
"""
|
||||
Public hooks API surface.
|
||||
|
||||
Design contract:
|
||||
- Typed-first internals: loaders/policy/config operate on `HookEntry`.
|
||||
- Compatibility wrappers: `_compat` helpers accept legacy dict entries.
|
||||
- External callers should migrate to typed APIs over time; wrappers remain for
|
||||
incremental rollout and backward compatibility.
|
||||
"""
|
||||
|
||||
from .internal_hooks import (
|
||||
HookEvent,
|
||||
HookHandler,
|
||||
|
|
@ -11,6 +21,10 @@ from .internal_hooks import (
|
|||
)
|
||||
|
||||
from .loader import load_internal_hooks
|
||||
from .config import should_include_hook, should_include_hook_compat
|
||||
from .policy import resolve_hook_entries_compat
|
||||
from .hooks_status import build_workspace_hook_status
|
||||
from .eligibility_from_metadata import hook_eligibility_from_message_metadata
|
||||
|
||||
__all__ = [
|
||||
"HookEvent",
|
||||
|
|
@ -23,5 +37,10 @@ __all__ = [
|
|||
"trigger_hook",
|
||||
"create_hook_event",
|
||||
"load_internal_hooks",
|
||||
"should_include_hook",
|
||||
"should_include_hook_compat",
|
||||
"resolve_hook_entries_compat",
|
||||
"build_workspace_hook_status",
|
||||
"hook_eligibility_from_message_metadata",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -29,9 +29,7 @@ def _candidate_roots(event: Any) -> list[Path]:
|
|||
repo = Path(__file__).resolve().parents[4]
|
||||
roots.extend(
|
||||
[
|
||||
repo / "oclaw" / "runtime" / "assets" / "agent_workspaces" / "workspace-main",
|
||||
repo / "oclaw" / "workspace-main",
|
||||
repo / "oclaw" / "workspace",
|
||||
repo / "oclaw" / "runtime" / "workspaces" / "main",
|
||||
repo,
|
||||
]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -283,6 +283,22 @@ def handle(event: Any) -> None:
|
|||
"skip_reason": "memory_mode_store_only",
|
||||
}
|
||||
return
|
||||
manager_gate = ctx.get("need_wiki_inject")
|
||||
if isinstance(manager_gate, bool) and not manager_gate:
|
||||
ctx["wiki_inject_meta"] = {
|
||||
"enabled": False,
|
||||
"memory_mode": memory_mode,
|
||||
"skip_reason": "manager_gate_off",
|
||||
}
|
||||
return
|
||||
manager_query = str(ctx.get("wiki_query") or "").strip()
|
||||
if isinstance(manager_gate, bool) and manager_gate and not manager_query:
|
||||
ctx["wiki_inject_meta"] = {
|
||||
"enabled": False,
|
||||
"memory_mode": memory_mode,
|
||||
"skip_reason": "manager_wiki_query_missing",
|
||||
}
|
||||
return
|
||||
cfg = _load_config()
|
||||
entry = _resolve_wiki_entry(cfg)
|
||||
if not _enabled(entry):
|
||||
|
|
@ -295,7 +311,7 @@ def handle(event: Any) -> None:
|
|||
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:
|
||||
if ultra_saver_enabled and not isinstance(manager_gate, bool) and len(query) < min_query_chars:
|
||||
ctx["wiki_inject_meta"] = {
|
||||
"enabled": False,
|
||||
"memory_mode": memory_mode,
|
||||
|
|
@ -305,7 +321,7 @@ def handle(event: Any) -> None:
|
|||
"query_len": int(len(query)),
|
||||
}
|
||||
return
|
||||
if ultra_saver_enabled and require_topic_hint and not _query_topic_hints(query, topic_rules):
|
||||
if ultra_saver_enabled and not isinstance(manager_gate, bool) and require_topic_hint and not _query_topic_hints(query, topic_rules):
|
||||
ctx["wiki_inject_meta"] = {
|
||||
"enabled": False,
|
||||
"memory_mode": memory_mode,
|
||||
|
|
|
|||
173
runtime/hooks/config.py
Normal file
173
runtime/hooks/config.py
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .hook_manifest_core import HookMetadataSpec, HookRequiresSpec
|
||||
from .hook_types import HookEligibilityContext, HookEntry, ensure_hook_entry
|
||||
from .policy import resolve_hook_config, resolve_hook_enable_state
|
||||
|
||||
_DEFAULT_CONFIG_VALUES: Dict[str, bool] = {
|
||||
"browser.enabled": True,
|
||||
"browser.evaluateEnabled": True,
|
||||
"workspace.dir": True,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_config_path(config: Optional[Dict[str, Any]], path_str: str) -> Any:
|
||||
if not isinstance(config, dict):
|
||||
return None
|
||||
cur: Any = config
|
||||
for segment in str(path_str or "").split("."):
|
||||
key = segment.strip()
|
||||
if not key:
|
||||
continue
|
||||
if not isinstance(cur, dict):
|
||||
return None
|
||||
cur = cur.get(key)
|
||||
return cur
|
||||
|
||||
|
||||
def _is_config_path_truthy(config: Optional[Dict[str, Any]], path_str: str) -> bool:
|
||||
key = str(path_str or "").strip()
|
||||
if not key:
|
||||
return False
|
||||
value = _resolve_config_path(config, key)
|
||||
if value is None and key in _DEFAULT_CONFIG_VALUES:
|
||||
return _DEFAULT_CONFIG_VALUES[key]
|
||||
return bool(value)
|
||||
|
||||
|
||||
def _has_binary(bin_name: str) -> bool:
|
||||
return bool(shutil.which(str(bin_name or "").strip()))
|
||||
|
||||
|
||||
def _resolve_runtime_platform() -> str:
|
||||
return str(platform.system() or "").strip().lower()
|
||||
|
||||
|
||||
def _normalize_os_name(os_name: str) -> str:
|
||||
v = str(os_name or "").strip().lower()
|
||||
if v in {"mac", "macos", "darwin"}:
|
||||
return "darwin"
|
||||
if v in {"win", "windows"}:
|
||||
return "windows"
|
||||
if v in {"linux"}:
|
||||
return "linux"
|
||||
return v
|
||||
|
||||
|
||||
def _hook_os_gate_satisfied(*, hook_os: tuple[str, ...], remote_platforms: list[str]) -> bool:
|
||||
"""
|
||||
Match OpenClaw ``evaluateRuntimeEligibility`` OS rule:
|
||||
|
||||
If the hook declares ``os``, pass when the **local** runtime matches *or* when any
|
||||
advertised **remote** platform matches one of the hook's supported OS names.
|
||||
"""
|
||||
os_list = [str(x).strip() for x in hook_os if str(x or "").strip()]
|
||||
if not os_list:
|
||||
return True
|
||||
cur = _normalize_os_name(_resolve_runtime_platform())
|
||||
hook_names = {_normalize_os_name(x) for x in os_list}
|
||||
if cur in hook_names:
|
||||
return True
|
||||
rem = [_normalize_os_name(x) for x in remote_platforms if str(x or "").strip()]
|
||||
return any(r in hook_names for r in rem)
|
||||
|
||||
|
||||
def _parse_requires_obj(raw: Any) -> HookRequiresSpec:
|
||||
row = raw if isinstance(raw, dict) else {}
|
||||
bins = tuple(str(x).strip() for x in list(row.get("bins") or []) if str(x).strip())
|
||||
any_bins = tuple(str(x).strip() for x in list(row.get("anyBins") or []) if str(x).strip())
|
||||
env = tuple(str(x).strip() for x in list(row.get("env") or []) if str(x).strip())
|
||||
config = tuple(str(x).strip() for x in list(row.get("config") or []) if str(x).strip())
|
||||
return HookRequiresSpec(bins=bins, any_bins=any_bins, env=env, config=config)
|
||||
|
||||
|
||||
def _parse_metadata_obj(raw: Any) -> HookMetadataSpec:
|
||||
row = raw if isinstance(raw, dict) else {}
|
||||
node_script: bool | None
|
||||
if "nodeScript" in row:
|
||||
node_script = bool(row.get("nodeScript"))
|
||||
else:
|
||||
node_script = None
|
||||
hm = row.get("hookMode")
|
||||
hook_mode = str(hm).strip() if isinstance(hm, str) and str(hm).strip() else None
|
||||
return HookMetadataSpec(
|
||||
events=tuple(str(x).strip() for x in list(row.get("events") or []) if str(x).strip()),
|
||||
always=bool(row["always"]) if isinstance(row.get("always"), bool) else None,
|
||||
emoji=str(row.get("emoji")).strip() if isinstance(row.get("emoji"), str) else None,
|
||||
homepage=str(row.get("homepage")).strip() if isinstance(row.get("homepage"), str) else None,
|
||||
hook_key=str(row.get("hookKey")).strip() if isinstance(row.get("hookKey"), str) else None,
|
||||
export=str(row.get("export")).strip() if isinstance(row.get("export"), str) else None,
|
||||
os=tuple(str(x).strip() for x in list(row.get("os") or []) if str(x).strip()),
|
||||
requires=_parse_requires_obj(row.get("requires")),
|
||||
install=(),
|
||||
hook_mode=hook_mode,
|
||||
node_script=node_script,
|
||||
)
|
||||
|
||||
|
||||
def should_include_hook(
|
||||
*, entry: HookEntry, config: Optional[Dict[str, Any]], eligibility: Optional[HookEligibilityContext] = None
|
||||
) -> bool:
|
||||
metadata = _parse_metadata_obj(entry.metadata)
|
||||
hook_name = str(entry.hook.name or "").strip()
|
||||
hook_key = str(metadata.hook_key or hook_name).strip() or hook_name
|
||||
hook_cfg = resolve_hook_config(config, hook_key)
|
||||
|
||||
if not resolve_hook_enable_state(entry, config).get("enabled"):
|
||||
return False
|
||||
|
||||
remote = (eligibility or {}).get("remote") if isinstance(eligibility, dict) else None
|
||||
remote_platforms: list[str] = []
|
||||
if isinstance(remote, dict):
|
||||
rp = remote.get("platforms")
|
||||
if isinstance(rp, list):
|
||||
remote_platforms = [str(x).strip() for x in rp if str(x or "").strip()]
|
||||
if not _hook_os_gate_satisfied(hook_os=metadata.os, remote_platforms=remote_platforms):
|
||||
return False
|
||||
|
||||
req = metadata.requires or HookRequiresSpec()
|
||||
bins = list(req.bins)
|
||||
for b in bins:
|
||||
if remote and callable(remote.get("hasBin")):
|
||||
if not bool(remote["hasBin"](b)):
|
||||
return False
|
||||
continue
|
||||
if not _has_binary(b):
|
||||
return False
|
||||
|
||||
any_bins = list(req.any_bins)
|
||||
if any_bins:
|
||||
if remote and callable(remote.get("hasAnyBin")):
|
||||
if not bool(remote["hasAnyBin"](any_bins)):
|
||||
return False
|
||||
elif not any(_has_binary(b) for b in any_bins):
|
||||
return False
|
||||
|
||||
env_req = list(req.env)
|
||||
hook_env = hook_cfg.get("env") if isinstance(hook_cfg, dict) and isinstance(hook_cfg.get("env"), dict) else {}
|
||||
for env_name in env_req:
|
||||
if os.getenv(env_name) or hook_env.get(env_name):
|
||||
continue
|
||||
return False
|
||||
|
||||
config_req = list(req.config)
|
||||
for cfg_path in config_req:
|
||||
if not _is_config_path_truthy(config, cfg_path):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def should_include_hook_compat(
|
||||
*,
|
||||
entry: HookEntry | Dict[str, Any],
|
||||
config: Optional[Dict[str, Any]],
|
||||
eligibility: Optional[HookEligibilityContext] = None,
|
||||
) -> bool:
|
||||
return should_include_hook(entry=ensure_hook_entry(entry), config=config, eligibility=eligibility)
|
||||
|
||||
72
runtime/hooks/eligibility_from_metadata.py
Normal file
72
runtime/hooks/eligibility_from_metadata.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .hook_types import HookEligibilityContext, HookRemoteEligibility
|
||||
|
||||
|
||||
def hook_eligibility_from_message_metadata(metadata: dict[str, Any] | None) -> HookEligibilityContext | None:
|
||||
"""
|
||||
Build ``HookEligibilityContext`` from inbound message metadata (e.g. gateway / API).
|
||||
|
||||
Expected shape (JSON-friendly):
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"hookEligibility": {
|
||||
"remote": {
|
||||
"platforms": ["darwin", "linux", "windows"],
|
||||
"binsPresent": ["git", "node"],
|
||||
"note": "from remote agent / bridge"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- ``platforms``: when non-empty, overrides hook ``metadata.oclaw.os`` for eligibility (same as TS).
|
||||
- ``binsPresent``: when non-empty, supplies ``hasBin`` / ``hasAnyBin`` predicates so bin checks
|
||||
can reflect a **remote** execution environment instead of ``shutil.which`` on this machine.
|
||||
"""
|
||||
if not isinstance(metadata, dict):
|
||||
return None
|
||||
block = metadata.get("hookEligibility")
|
||||
if not isinstance(block, dict):
|
||||
return None
|
||||
rem = block.get("remote")
|
||||
if not isinstance(rem, dict):
|
||||
return None
|
||||
|
||||
platforms_raw = rem.get("platforms")
|
||||
platforms: list[str] = []
|
||||
if isinstance(platforms_raw, list):
|
||||
platforms = [str(x).strip().lower() for x in platforms_raw if str(x or "").strip()]
|
||||
|
||||
bins_raw = rem.get("binsPresent")
|
||||
bins_present: set[str] = set()
|
||||
if isinstance(bins_raw, list):
|
||||
bins_present = {str(x).strip() for x in bins_raw if str(x or "").strip()}
|
||||
|
||||
note_raw = rem.get("note")
|
||||
note = str(note_raw).strip() if isinstance(note_raw, str) else ""
|
||||
|
||||
if not platforms and not bins_present and not note:
|
||||
return None
|
||||
|
||||
r: HookRemoteEligibility = {}
|
||||
if platforms:
|
||||
r["platforms"] = platforms
|
||||
if note:
|
||||
r["note"] = note
|
||||
|
||||
if bins_present:
|
||||
|
||||
def has_bin(name: str) -> bool:
|
||||
return str(name or "").strip() in bins_present
|
||||
|
||||
def has_any_bin(names: list[Any]) -> bool:
|
||||
return any(has_bin(str(x or "")) for x in (names or []))
|
||||
|
||||
r["hasBin"] = has_bin
|
||||
r["hasAnyBin"] = has_any_bin
|
||||
|
||||
return {"remote": r}
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import yaml
|
||||
from .hook_manifest_core import parse_hook_manifest
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -53,15 +54,14 @@ def parse_frontmatter(markdown: str) -> ParsedHookFrontmatter:
|
|||
|
||||
|
||||
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
|
||||
parsed = parse_hook_manifest(frontmatter=frontmatter, default_name="hook")
|
||||
out = parsed.metadata.as_dict()
|
||||
return out if out else None
|
||||
|
||||
|
||||
def resolve_hook_invocation_policy(frontmatter: Dict[str, Any]) -> Dict[str, bool]:
|
||||
parsed = parse_hook_manifest(frontmatter=frontmatter, default_name="hook")
|
||||
return {"enabled": bool(parsed.invocation_enabled)}
|
||||
|
||||
|
||||
def resolve_hook_key(name: str, entry: Dict[str, Any]) -> str:
|
||||
|
|
|
|||
44
runtime/hooks/gmail_watcher.py
Normal file
44
runtime/hooks/gmail_watcher.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GmailWatcherResult:
|
||||
started: bool
|
||||
reason: str = ""
|
||||
|
||||
|
||||
def start_gmail_watcher(cfg: dict[str, Any] | None) -> GmailWatcherResult:
|
||||
"""
|
||||
Gmail watcher gate (OpenClaw ``startGmailWatcher`` parity, subset).
|
||||
|
||||
Full ``gog`` + Gmail API + renew loop is not ported in Python yet; this
|
||||
function encodes the same **configuration preconditions** so lifecycle
|
||||
logging matches expectations.
|
||||
"""
|
||||
if not isinstance(cfg, dict):
|
||||
return GmailWatcherResult(started=False, reason="no gmail account configured")
|
||||
|
||||
hooks = cfg.get("hooks")
|
||||
if not isinstance(hooks, dict):
|
||||
return GmailWatcherResult(started=False, reason="hooks not enabled")
|
||||
|
||||
# OpenClaw top-level ``hooks.enabled`` (when absent, treat as enabled).
|
||||
if hooks.get("enabled") is False:
|
||||
return GmailWatcherResult(started=False, reason="hooks not enabled")
|
||||
|
||||
internal = hooks.get("internal") if isinstance(hooks.get("internal"), dict) else {}
|
||||
if internal.get("enabled") is False:
|
||||
return GmailWatcherResult(started=False, reason="hooks not enabled")
|
||||
|
||||
gmail = hooks.get("gmail")
|
||||
if not isinstance(gmail, dict) or not str(gmail.get("account") or "").strip():
|
||||
return GmailWatcherResult(started=False, reason="no gmail account configured")
|
||||
|
||||
if not shutil.which("gog"):
|
||||
return GmailWatcherResult(started=False, reason="gog binary not found")
|
||||
|
||||
return GmailWatcherResult(started=False, reason="gmail watcher runtime not implemented (Python)")
|
||||
53
runtime/hooks/gmail_watcher_lifecycle.py
Normal file
53
runtime/hooks/gmail_watcher_lifecycle.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Callable, Protocol
|
||||
|
||||
from .gmail_watcher import GmailWatcherResult, start_gmail_watcher
|
||||
|
||||
|
||||
class GmailWatcherLog(Protocol):
|
||||
def info(self, msg: str) -> None: ...
|
||||
def warn(self, msg: str) -> None: ...
|
||||
def error(self, msg: str) -> None: ...
|
||||
|
||||
|
||||
def _is_truthy_env(value: str | None) -> bool:
|
||||
return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _skip_gmail_watcher_env() -> bool:
|
||||
for key in ("OCLAW_SKIP_GMAIL_WATCHER", "OPENCLAW_SKIP_GMAIL_WATCHER"):
|
||||
if _is_truthy_env(os.getenv(key)):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def start_gmail_watcher_with_logs(
|
||||
*,
|
||||
cfg: dict[str, Any] | None,
|
||||
log: GmailWatcherLog,
|
||||
on_skipped: Callable[[], None] | None = None,
|
||||
starter: Callable[[dict[str, Any] | None], GmailWatcherResult] = start_gmail_watcher,
|
||||
) -> None:
|
||||
"""Skip entirely when ``OCLAW_SKIP_GMAIL_WATCHER`` or ``OPENCLAW_SKIP_GMAIL_WATCHER`` is truthy."""
|
||||
if _skip_gmail_watcher_env():
|
||||
if on_skipped:
|
||||
on_skipped()
|
||||
return
|
||||
|
||||
try:
|
||||
res = starter(cfg)
|
||||
if bool(res.started):
|
||||
log.info("gmail watcher started")
|
||||
return
|
||||
reason = str(res.reason or "").strip()
|
||||
if reason and reason not in {
|
||||
"hooks not enabled",
|
||||
"no gmail account configured",
|
||||
"gmail watcher runtime not implemented (Python)",
|
||||
}:
|
||||
log.warn(f"gmail watcher not started: {reason}")
|
||||
except Exception as exc:
|
||||
log.error(f"gmail watcher failed to start: {exc}")
|
||||
|
||||
195
runtime/hooks/hook_manifest_core.py
Normal file
195
runtime/hooks/hook_manifest_core.py
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HookInstallSpec:
|
||||
kind: str
|
||||
id: str | None = None
|
||||
label: str | None = None
|
||||
package: str | None = None
|
||||
repository: str | None = None
|
||||
bins: tuple[str, ...] = ()
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {"kind": self.kind}
|
||||
if self.id:
|
||||
out["id"] = self.id
|
||||
if self.label:
|
||||
out["label"] = self.label
|
||||
if self.package:
|
||||
out["package"] = self.package
|
||||
if self.repository:
|
||||
out["repository"] = self.repository
|
||||
if self.bins:
|
||||
out["bins"] = list(self.bins)
|
||||
return out
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HookRequiresSpec:
|
||||
bins: tuple[str, ...] = ()
|
||||
any_bins: tuple[str, ...] = ()
|
||||
env: tuple[str, ...] = ()
|
||||
config: tuple[str, ...] = ()
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {}
|
||||
if self.bins:
|
||||
out["bins"] = list(self.bins)
|
||||
if self.any_bins:
|
||||
out["anyBins"] = list(self.any_bins)
|
||||
if self.env:
|
||||
out["env"] = list(self.env)
|
||||
if self.config:
|
||||
out["config"] = list(self.config)
|
||||
return out
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HookMetadataSpec:
|
||||
events: tuple[str, ...] = ()
|
||||
always: bool | None = None
|
||||
emoji: str | None = None
|
||||
homepage: str | None = None
|
||||
hook_key: str | None = None
|
||||
export: str | None = None
|
||||
os: tuple[str, ...] = ()
|
||||
requires: HookRequiresSpec | None = None
|
||||
install: tuple[HookInstallSpec, ...] = ()
|
||||
hook_mode: str | None = None
|
||||
node_script: bool | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {"events": list(self.events)}
|
||||
if self.always is not None:
|
||||
out["always"] = bool(self.always)
|
||||
if self.emoji:
|
||||
out["emoji"] = self.emoji
|
||||
if self.homepage:
|
||||
out["homepage"] = self.homepage
|
||||
if self.hook_key:
|
||||
out["hookKey"] = self.hook_key
|
||||
if self.export:
|
||||
out["export"] = self.export
|
||||
if self.os:
|
||||
out["os"] = list(self.os)
|
||||
if self.requires:
|
||||
req = self.requires.as_dict()
|
||||
if req:
|
||||
out["requires"] = req
|
||||
if self.install:
|
||||
out["install"] = [row.as_dict() for row in self.install]
|
||||
if self.hook_mode is not None:
|
||||
out["hookMode"] = str(self.hook_mode)
|
||||
if self.node_script is not None:
|
||||
out["nodeScript"] = bool(self.node_script)
|
||||
return out
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParsedHookManifest:
|
||||
name: str
|
||||
description: str
|
||||
invocation_enabled: bool
|
||||
metadata: HookMetadataSpec = field(default_factory=HookMetadataSpec)
|
||||
|
||||
|
||||
def _read_str(value: Any) -> str | None:
|
||||
if isinstance(value, str):
|
||||
s = value.strip()
|
||||
return s if s else None
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_str_list(value: Any) -> tuple[str, ...]:
|
||||
if not isinstance(value, list):
|
||||
return ()
|
||||
out: list[str] = []
|
||||
for row in value:
|
||||
s = _read_str(row)
|
||||
if s:
|
||||
out.append(s)
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def _parse_install_spec(row: Any) -> HookInstallSpec | None:
|
||||
if not isinstance(row, dict):
|
||||
return None
|
||||
kind = (_read_str(row.get("kind")) or "").lower()
|
||||
if kind not in {"bundled", "npm", "git"}:
|
||||
return None
|
||||
return HookInstallSpec(
|
||||
kind=kind,
|
||||
id=_read_str(row.get("id")),
|
||||
label=_read_str(row.get("label")),
|
||||
package=_read_str(row.get("package")),
|
||||
repository=_read_str(row.get("repository")),
|
||||
bins=_normalize_str_list(row.get("bins")),
|
||||
)
|
||||
|
||||
|
||||
def _resolve_raw_oclaw_metadata(frontmatter: dict[str, Any]) -> dict[str, Any]:
|
||||
meta = frontmatter.get("metadata")
|
||||
if isinstance(meta, str):
|
||||
try:
|
||||
parsed = json.loads(meta)
|
||||
meta = parsed if isinstance(parsed, dict) else None
|
||||
except Exception:
|
||||
meta = None
|
||||
if not isinstance(meta, dict):
|
||||
return {}
|
||||
oc = meta.get("oclaw")
|
||||
return oc if isinstance(oc, dict) else {}
|
||||
|
||||
|
||||
def parse_hook_manifest(*, frontmatter: dict[str, Any], default_name: str) -> ParsedHookManifest:
|
||||
name = _read_str(frontmatter.get("name")) or default_name
|
||||
description = _read_str(frontmatter.get("description")) or ""
|
||||
enabled_raw = frontmatter.get("enabled")
|
||||
invocation_enabled = bool(enabled_raw) if isinstance(enabled_raw, bool) else True
|
||||
|
||||
oc = _resolve_raw_oclaw_metadata(frontmatter)
|
||||
requires_obj = oc.get("requires") if isinstance(oc.get("requires"), dict) else {}
|
||||
requires = HookRequiresSpec(
|
||||
bins=_normalize_str_list(requires_obj.get("bins")),
|
||||
any_bins=_normalize_str_list(requires_obj.get("anyBins")),
|
||||
env=_normalize_str_list(requires_obj.get("env")),
|
||||
config=_normalize_str_list(requires_obj.get("config")),
|
||||
)
|
||||
install_rows: list[HookInstallSpec] = []
|
||||
if isinstance(oc.get("install"), list):
|
||||
for row in oc["install"]:
|
||||
parsed = _parse_install_spec(row)
|
||||
if parsed:
|
||||
install_rows.append(parsed)
|
||||
|
||||
node_script: bool | None
|
||||
if "nodeScript" in oc:
|
||||
node_script = bool(oc.get("nodeScript"))
|
||||
else:
|
||||
node_script = None
|
||||
|
||||
metadata = HookMetadataSpec(
|
||||
events=_normalize_str_list(oc.get("events")),
|
||||
always=bool(oc["always"]) if isinstance(oc.get("always"), bool) else None,
|
||||
emoji=_read_str(oc.get("emoji")),
|
||||
homepage=_read_str(oc.get("homepage")),
|
||||
hook_key=_read_str(oc.get("hookKey")),
|
||||
export=_read_str(oc.get("export")),
|
||||
os=_normalize_str_list(oc.get("os")),
|
||||
requires=requires if requires.as_dict() else None,
|
||||
install=tuple(install_rows),
|
||||
hook_mode=_read_str(oc.get("hookMode")),
|
||||
node_script=node_script,
|
||||
)
|
||||
return ParsedHookManifest(
|
||||
name=name,
|
||||
description=description,
|
||||
invocation_enabled=invocation_enabled,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
104
runtime/hooks/hook_types.py
Normal file
104
runtime/hooks/hook_types.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal, TypedDict
|
||||
|
||||
HookSource = Literal["oclaw-bundled", "oclaw-plugin", "oclaw-managed", "oclaw-workspace"]
|
||||
_HOOK_SOURCES: tuple[HookSource, ...] = (
|
||||
"oclaw-bundled",
|
||||
"oclaw-plugin",
|
||||
"oclaw-managed",
|
||||
"oclaw-workspace",
|
||||
)
|
||||
|
||||
|
||||
class HookEntryDict(TypedDict, total=False):
|
||||
hook: dict[str, Any]
|
||||
frontmatter: dict[str, Any]
|
||||
metadata: dict[str, Any]
|
||||
invocation: dict[str, Any]
|
||||
|
||||
|
||||
class HookRemoteEligibility(TypedDict, total=False):
|
||||
platforms: list[str]
|
||||
hasBin: Any
|
||||
hasAnyBin: Any
|
||||
note: str
|
||||
|
||||
|
||||
class HookEligibilityContext(TypedDict, total=False):
|
||||
remote: HookRemoteEligibility
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HookRef:
|
||||
name: str
|
||||
description: str
|
||||
source: HookSource
|
||||
pluginId: str | None
|
||||
filePath: str
|
||||
baseDir: str
|
||||
handlerPath: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HookInvocation:
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HookEntry:
|
||||
hook: HookRef
|
||||
frontmatter: dict[str, Any]
|
||||
metadata: dict[str, Any]
|
||||
invocation: HookInvocation
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"hook": {
|
||||
"name": self.hook.name,
|
||||
"description": self.hook.description,
|
||||
"source": self.hook.source,
|
||||
"pluginId": self.hook.pluginId,
|
||||
"filePath": self.hook.filePath,
|
||||
"baseDir": self.hook.baseDir,
|
||||
"handlerPath": self.hook.handlerPath,
|
||||
},
|
||||
"frontmatter": dict(self.frontmatter),
|
||||
"metadata": dict(self.metadata),
|
||||
"invocation": {"enabled": bool(self.invocation.enabled)},
|
||||
}
|
||||
|
||||
|
||||
def ensure_entry_dict(entry: HookEntry | HookEntryDict) -> dict[str, Any]:
|
||||
return entry.as_dict() if isinstance(entry, HookEntry) else dict(entry)
|
||||
|
||||
|
||||
def _normalize_source(value: Any) -> HookSource:
|
||||
s = str(value or "").strip()
|
||||
if s in _HOOK_SOURCES:
|
||||
return s
|
||||
return "oclaw-managed"
|
||||
|
||||
|
||||
def ensure_hook_entry(entry: HookEntry | HookEntryDict) -> HookEntry:
|
||||
if isinstance(entry, HookEntry):
|
||||
return entry
|
||||
row = dict(entry or {})
|
||||
hook_raw = row.get("hook") if isinstance(row.get("hook"), dict) else {}
|
||||
inv_raw = row.get("invocation") if isinstance(row.get("invocation"), dict) else {}
|
||||
return HookEntry(
|
||||
hook=HookRef(
|
||||
name=str(hook_raw.get("name") or ""),
|
||||
description=str(hook_raw.get("description") or ""),
|
||||
source=_normalize_source(hook_raw.get("source")),
|
||||
pluginId=(str(hook_raw.get("pluginId")) if hook_raw.get("pluginId") is not None else None),
|
||||
filePath=str(hook_raw.get("filePath") or ""),
|
||||
baseDir=str(hook_raw.get("baseDir") or ""),
|
||||
handlerPath=str(hook_raw.get("handlerPath") or ""),
|
||||
),
|
||||
frontmatter=dict(row.get("frontmatter") or {}),
|
||||
metadata=dict(row.get("metadata") or {}),
|
||||
invocation=HookInvocation(enabled=bool(inv_raw.get("enabled")) if "enabled" in inv_raw else True),
|
||||
)
|
||||
|
||||
125
runtime/hooks/hooks_status.py
Normal file
125
runtime/hooks/hooks_status.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from typing import Any
|
||||
|
||||
from .config import should_include_hook
|
||||
from .hook_types import HookEligibilityContext
|
||||
from .policy import resolve_hook_enable_state
|
||||
from .workspace import load_workspace_hook_entries
|
||||
|
||||
|
||||
def _select_install_suggestion(
|
||||
*, install_options: list[dict[str, Any]], missing_bins: list[str]
|
||||
) -> dict[str, Any] | None:
|
||||
if not install_options:
|
||||
return None
|
||||
if not missing_bins:
|
||||
return dict(install_options[0])
|
||||
|
||||
missing = set(missing_bins)
|
||||
best: tuple[int, int, dict[str, Any]] | None = None
|
||||
for idx, row in enumerate(install_options):
|
||||
bins = [str(x).strip() for x in list(row.get("bins") or []) if str(x).strip()]
|
||||
overlap = len(missing.intersection(set(bins)))
|
||||
# Sort by overlap desc, then idx asc (stable preference for first declared option).
|
||||
score = (overlap, -idx)
|
||||
if best is None or score > (best[0], best[1]):
|
||||
best = (score[0], score[1], row)
|
||||
return dict(best[2]) if best else dict(install_options[0])
|
||||
|
||||
|
||||
def build_workspace_hook_status(
|
||||
workspace_dir: str,
|
||||
*,
|
||||
config: dict[str, Any] | None = None,
|
||||
managed_hooks_dir: str | None = None,
|
||||
bundled_hooks_dir: str | None = None,
|
||||
extra_dirs: list[str] | None = None,
|
||||
eligibility: HookEligibilityContext | None = None,
|
||||
) -> dict[str, Any]:
|
||||
entries = load_workspace_hook_entries(
|
||||
workspace_dir,
|
||||
config=config,
|
||||
managed_hooks_dir=managed_hooks_dir,
|
||||
bundled_hooks_dir=bundled_hooks_dir,
|
||||
extra_dirs=extra_dirs,
|
||||
)
|
||||
rows: list[dict[str, Any]] = []
|
||||
summary = {
|
||||
"discovered_total": 0,
|
||||
"enabled_by_config_total": 0,
|
||||
"eligible_total": 0,
|
||||
"loadable_total": 0,
|
||||
"missing_bins_total": 0,
|
||||
"blocked_by_reason": {},
|
||||
}
|
||||
|
||||
for entry in entries:
|
||||
summary["discovered_total"] += 1
|
||||
state = resolve_hook_enable_state(entry, config)
|
||||
enabled = bool(state.get("enabled"))
|
||||
if enabled:
|
||||
summary["enabled_by_config_total"] += 1
|
||||
eligible = should_include_hook(entry=entry, config=config, eligibility=eligibility)
|
||||
if eligible:
|
||||
summary["eligible_total"] += 1
|
||||
loadable = enabled and eligible
|
||||
if loadable:
|
||||
summary["loadable_total"] += 1
|
||||
reason = str(state.get("reason") or "")
|
||||
if not loadable:
|
||||
blocked = reason or ("missing requirements" if enabled else "disabled")
|
||||
summary["blocked_by_reason"][blocked] = int(summary["blocked_by_reason"].get(blocked) or 0) + 1
|
||||
|
||||
md = entry.metadata or {}
|
||||
req = md.get("requires") if isinstance(md.get("requires"), dict) else {}
|
||||
required_bins = [str(x).strip() for x in list(req.get("bins") or []) if str(x).strip()]
|
||||
missing_bins = [b for b in required_bins if not shutil.which(b)]
|
||||
if missing_bins:
|
||||
summary["missing_bins_total"] += len(missing_bins)
|
||||
|
||||
install_rows = md.get("install") if isinstance(md.get("install"), list) else []
|
||||
install_options: list[dict[str, Any]] = []
|
||||
for idx, row in enumerate(install_rows):
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
kind = str(row.get("kind") or "").strip()
|
||||
if not kind:
|
||||
continue
|
||||
install_options.append(
|
||||
{
|
||||
"id": str(row.get("id") or f"{kind}-{idx}"),
|
||||
"kind": kind,
|
||||
"label": str(row.get("label") or ""),
|
||||
"bins": [str(x).strip() for x in list(row.get("bins") or []) if str(x).strip()],
|
||||
}
|
||||
)
|
||||
|
||||
rows.append(
|
||||
{
|
||||
"name": entry.hook.name,
|
||||
"source": entry.hook.source,
|
||||
"plugin_id": entry.hook.pluginId,
|
||||
"hook_key": str((entry.metadata or {}).get("hookKey") or entry.hook.name),
|
||||
"events": list((entry.metadata or {}).get("events") or []),
|
||||
"enabled_by_config": enabled,
|
||||
"eligible": bool(eligible),
|
||||
"loadable": bool(loadable),
|
||||
"blocked_reason": "" if loadable else (reason or "missing requirements"),
|
||||
"required_bins": required_bins,
|
||||
"missing_bins": missing_bins,
|
||||
"install_options": install_options,
|
||||
"install_suggestion": _select_install_suggestion(
|
||||
install_options=install_options,
|
||||
missing_bins=missing_bins,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"workspace_dir": workspace_dir,
|
||||
"summary": summary,
|
||||
"hooks": rows,
|
||||
}
|
||||
|
||||
|
|
@ -74,7 +74,10 @@ def create_hook_event(
|
|||
session_key: str,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
) -> HookEvent:
|
||||
return HookEvent(type=event_type, action=action, sessionKey=session_key, context=context or {})
|
||||
# Must not use `context or {}` — a caller-supplied empty dict is falsy and would be replaced.
|
||||
return HookEvent(
|
||||
type=event_type, action=action, sessionKey=session_key, context={} if context is None else context
|
||||
)
|
||||
|
||||
|
||||
async def trigger_hook(event: HookEvent) -> None:
|
||||
|
|
|
|||
56
runtime/hooks/js_hook_runner.mjs
Normal file
56
runtime/hooks/js_hook_runner.mjs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* node js_hook_runner.mjs <absolute-handler> [exportName]
|
||||
* stdin: JSON { type, action, sessionKey, context, messages?, timestamp? }
|
||||
* stdout: JSON { "context"?: object }
|
||||
* Loads .mjs / .cjs (and ESM .js if passed) with dynamic import or createRequire.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const handlerPath = process.argv[2];
|
||||
const exportName = (process.argv[3] || "default").trim();
|
||||
|
||||
if (!handlerPath) {
|
||||
console.error("oclaw: js_hook_runner: missing handler path");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const abs = path.resolve(handlerPath);
|
||||
const raw = readFileSync(0, "utf-8");
|
||||
const data = JSON.parse(raw);
|
||||
const event = {
|
||||
type: data.type,
|
||||
action: data.action,
|
||||
sessionKey: data.sessionKey,
|
||||
context: typeof data.context === "object" && data.context ? data.context : {},
|
||||
messages: Array.isArray(data.messages) ? data.messages : [],
|
||||
timestamp: data.timestamp,
|
||||
};
|
||||
|
||||
const ext = path.extname(abs).toLowerCase();
|
||||
let mod;
|
||||
if (ext === ".cjs") {
|
||||
const require = createRequire(import.meta.url);
|
||||
mod = require(abs);
|
||||
} else {
|
||||
mod = await import(pathToFileURL(abs).href);
|
||||
}
|
||||
|
||||
const modRec = mod && typeof mod === "object" ? mod : {};
|
||||
let fn;
|
||||
if (exportName && exportName !== "default" && typeof modRec[exportName] === "function") {
|
||||
fn = modRec[exportName];
|
||||
} else {
|
||||
fn = modRec.default ?? modRec.handle ?? modRec.handler;
|
||||
}
|
||||
if (typeof fn !== "function") {
|
||||
console.error("oclaw: js_hook_runner: no function export in", abs, "export", exportName);
|
||||
process.exit(3);
|
||||
}
|
||||
const r = fn(event);
|
||||
if (r && typeof r.then === "function") {
|
||||
await r;
|
||||
}
|
||||
process.stdout.write(JSON.stringify({ context: event.context }));
|
||||
|
|
@ -1,13 +1,31 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
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 .policy import resolve_hook_enable_state, resolve_hook_config
|
||||
from .workspace import HookEntry, load_workspace_hook_entries
|
||||
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]:
|
||||
|
|
@ -26,10 +44,43 @@ def _load_module_from_path(module_path: str, unique_key: str) -> Optional[object
|
|||
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]:
|
||||
handler = getattr(mod, export_name, None)
|
||||
if callable(handler):
|
||||
return handler # type: ignore[return-value]
|
||||
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)
|
||||
|
|
@ -39,29 +90,41 @@ def _resolve_handler(mod: object, export_name: str) -> Optional[HookHandler]:
|
|||
|
||||
|
||||
def load_internal_hooks(
|
||||
config: Dict[str, Any],
|
||||
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 python hooks and register them into the in-process registry.
|
||||
Discover hooks and register them into the in-process registry.
|
||||
|
||||
Hook file layout:
|
||||
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)
|
||||
<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")
|
||||
"""
|
||||
_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"),
|
||||
|
|
@ -69,35 +132,74 @@ def load_internal_hooks(
|
|||
|
||||
loaded = 0
|
||||
for idx, entry in enumerate(entries):
|
||||
state = resolve_hook_enable_state(entry, config)
|
||||
if not state.get("enabled"):
|
||||
if not should_include_hook_compat(entry=entry, config=config, eligibility=eligibility):
|
||||
continue
|
||||
entry_obj = ensure_hook_entry(entry)
|
||||
|
||||
md = entry.get("metadata") or {}
|
||||
md = entry_obj.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")
|
||||
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 = "handle"
|
||||
export_name = "default"
|
||||
|
||||
mod = _load_module_from_path(handler_path, unique_key=str(idx))
|
||||
if mod is None:
|
||||
continue
|
||||
|
||||
handler = _resolve_handler(mod, export_name)
|
||||
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():
|
||||
register_hook(event_key.strip(), handler)
|
||||
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
|
||||
|
|
|
|||
39
runtime/hooks/merge_skill_hook_dirs.py
Normal file
39
runtime/hooks/merge_skill_hook_dirs.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from oclaw.runtime.skills import discover_workspace_skill_manifests
|
||||
|
||||
|
||||
def merge_skill_hook_extra_dirs_into_config(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Append skill package ``<skillDir>/hooks`` directories to ``hooks.internal.load.extraDirs``.
|
||||
|
||||
Matches ``initialize_hooks_runtime`` so CLI / status reports see the same hook set as the agent.
|
||||
"""
|
||||
resolved_cfg = dict(cfg)
|
||||
extra_dirs: list[str] = []
|
||||
try:
|
||||
for m in discover_workspace_skill_manifests():
|
||||
d = (Path(str(m.skill_dir or "")) / "hooks").resolve()
|
||||
if d.exists() and d.is_dir():
|
||||
extra_dirs.append(str(d))
|
||||
except Exception:
|
||||
extra_dirs = []
|
||||
if not extra_dirs:
|
||||
return resolved_cfg
|
||||
|
||||
hooks_cfg = dict((resolved_cfg.get("hooks") or {})) if isinstance(resolved_cfg.get("hooks"), dict) else {}
|
||||
internal = dict((hooks_cfg.get("internal") or {})) if isinstance(hooks_cfg.get("internal"), dict) else {}
|
||||
load = dict((internal.get("load") or {})) if isinstance(internal.get("load"), dict) else {}
|
||||
prev = load.get("extraDirs")
|
||||
merged: list[str] = []
|
||||
if isinstance(prev, list):
|
||||
merged.extend([str(x) for x in prev if str(x).strip()])
|
||||
merged.extend([x for x in extra_dirs if x and x not in set(merged)])
|
||||
load["extraDirs"] = merged
|
||||
internal["load"] = load
|
||||
hooks_cfg["internal"] = internal
|
||||
resolved_cfg["hooks"] = hooks_cfg
|
||||
return resolved_cfg
|
||||
|
|
@ -4,8 +4,7 @@ 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"]
|
||||
from .hook_types import HookEntry, HookSource, ensure_entry_dict, ensure_hook_entry
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -63,22 +62,27 @@ def resolve_hook_config(config: Optional[Dict[str, Any]], hook_key: str) -> Opti
|
|||
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]:
|
||||
def resolve_hook_enable_state(entry: HookEntry | 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
|
||||
entry_dict = ensure_entry_dict(entry)
|
||||
hook = entry_dict.get("hook") if isinstance(entry_dict, 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_key = resolve_hook_key(name, entry_dict)
|
||||
hook_cfg = resolve_hook_config(config, hook_key)
|
||||
invocation = entry_dict.get("invocation") if isinstance(entry_dict.get("invocation"), dict) else {}
|
||||
|
||||
if source == "oclaw-plugin":
|
||||
return {"enabled": True}
|
||||
|
||||
if invocation.get("enabled") is False:
|
||||
return {"enabled": False, "reason": "disabled by hook invocation policy"}
|
||||
|
||||
if isinstance(hook_cfg, dict) and hook_cfg.get("enabled") is False:
|
||||
return {"enabled": False, "reason": "disabled in config"}
|
||||
|
||||
|
|
@ -89,9 +93,11 @@ def resolve_hook_enable_state(entry: Dict[str, Any], config: Optional[Dict[str,
|
|||
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
|
||||
def _can_override(candidate: HookEntry | Dict[str, Any], existing: HookEntry | Dict[str, Any]) -> bool:
|
||||
c_dict = ensure_entry_dict(candidate)
|
||||
e_dict = ensure_entry_dict(existing)
|
||||
c_source = ((c_dict.get("hook") or {}).get("source")) if isinstance(c_dict, dict) else None
|
||||
e_source = ((e_dict.get("hook") or {}).get("source")) if isinstance(e_dict, 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]
|
||||
|
|
@ -100,26 +106,41 @@ def _can_override(candidate: Dict[str, Any], existing: Dict[str, Any]) -> bool:
|
|||
|
||||
|
||||
def resolve_hook_entries(
|
||||
entries: Sequence[Dict[str, Any]],
|
||||
entries: Sequence[HookEntry | Dict[str, Any]],
|
||||
on_collision_ignored: Optional[Callable[[Dict[str, Any]], None]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
) -> List[HookEntry]:
|
||||
ordered = sorted(
|
||||
list(enumerate(entries)),
|
||||
key=lambda x: (get_hook_source_policy(x[1]["hook"]["source"]).precedence, x[0]), # type: ignore[index]
|
||||
key=lambda x: (
|
||||
get_hook_source_policy(
|
||||
str((ensure_entry_dict(x[1]).get("hook") or {}).get("source") or "oclaw-managed") # type: ignore[arg-type]
|
||||
).precedence,
|
||||
x[0],
|
||||
),
|
||||
)
|
||||
merged: Dict[str, Dict[str, Any]] = {}
|
||||
merged: Dict[str, HookEntry] = {}
|
||||
for _, entry in ordered:
|
||||
name = entry.get("hook", {}).get("name")
|
||||
name = ensure_entry_dict(entry).get("hook", {}).get("name")
|
||||
if not isinstance(name, str):
|
||||
continue
|
||||
existing = merged.get(name)
|
||||
if not existing:
|
||||
merged[name] = entry
|
||||
merged[name] = ensure_hook_entry(entry)
|
||||
continue
|
||||
if _can_override(entry, existing):
|
||||
merged[name] = entry
|
||||
merged[name] = ensure_hook_entry(entry)
|
||||
continue
|
||||
if on_collision_ignored:
|
||||
on_collision_ignored({"name": name, "kept": existing, "ignored": entry})
|
||||
on_collision_ignored(
|
||||
{"name": name, "kept": ensure_entry_dict(existing), "ignored": ensure_entry_dict(entry)}
|
||||
)
|
||||
return list(merged.values())
|
||||
|
||||
|
||||
def resolve_hook_entries_compat(
|
||||
entries: Sequence[HookEntry | Dict[str, Any]],
|
||||
on_collision_ignored: Optional[Callable[[Dict[str, Any]], None]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
resolved = resolve_hook_entries(entries, on_collision_ignored=on_collision_ignored)
|
||||
return [ensure_entry_dict(e) for e in resolved]
|
||||
|
||||
|
|
|
|||
247
runtime/hooks/script_handlers.py
Normal file
247
runtime/hooks/script_handlers.py
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from .internal_hooks import HookEvent, HookHandler
|
||||
|
||||
log = logging.getLogger("oclaw.hooks")
|
||||
|
||||
# Max wall-clock for external hook child processes
|
||||
_HOOK_SUBPROCESS_TIMEOUT_S = 120.0
|
||||
|
||||
|
||||
def _runner_ts_path() -> Path:
|
||||
return Path(__file__).resolve().parent / "ts_hook_runner.ts"
|
||||
|
||||
|
||||
def _runner_js_path() -> Path:
|
||||
return Path(__file__).resolve().parent / "js_hook_runner.mjs"
|
||||
|
||||
|
||||
def _is_script_mode(oclaw: dict[str, Any] | None) -> bool:
|
||||
o = oclaw or {}
|
||||
if o.get("hookMode") == "script":
|
||||
return True
|
||||
if o.get("nodeScript") is True:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _event_payload(event: HookEvent) -> dict[str, Any]:
|
||||
return {
|
||||
"type": event.type,
|
||||
"action": event.action,
|
||||
"sessionKey": event.sessionKey,
|
||||
"context": dict(event.context) if isinstance(event.context, dict) else {},
|
||||
"messages": list(getattr(event, "messages", []) or []),
|
||||
"timestamp": event.timestamp.isoformat() if getattr(event, "timestamp", None) else None,
|
||||
}
|
||||
|
||||
|
||||
def _merge_stdout_into_context(event: HookEvent, raw: str) -> None:
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return
|
||||
try:
|
||||
out = json.loads(text)
|
||||
except Exception:
|
||||
log.warning("Hook subprocess stdout is not valid JSON: %r", text[:200])
|
||||
return
|
||||
if not isinstance(out, dict):
|
||||
return
|
||||
ctx = out.get("context")
|
||||
if isinstance(ctx, dict) and isinstance(event.context, dict):
|
||||
event.context.update(ctx)
|
||||
|
||||
|
||||
def _sh_command(script: Path) -> list[str] | None:
|
||||
p = str(script)
|
||||
if not script.is_file():
|
||||
return None
|
||||
if os.name == "nt":
|
||||
bash = shutil.which("bash")
|
||||
if bash:
|
||||
return [bash, p]
|
||||
wsl = shutil.which("wsl")
|
||||
if wsl:
|
||||
return [wsl, "bash", p]
|
||||
log.warning("Hook .sh on Windows needs bash in PATH (Git for Windows) or wsl. Skipping %s", p)
|
||||
return None
|
||||
try:
|
||||
if script.stat().st_mode & 0o111 and os.access(p, os.X_OK):
|
||||
return [p]
|
||||
except OSError:
|
||||
pass
|
||||
sh = shutil.which("sh") or "/bin/sh"
|
||||
return [sh, p]
|
||||
|
||||
|
||||
def _tsx_invocation() -> str | None:
|
||||
return shutil.which("tsx") or None
|
||||
|
||||
|
||||
def _ts_command(*, script: Path, export_name: str) -> list[str] | None:
|
||||
runner = _runner_ts_path()
|
||||
if not runner.is_file():
|
||||
log.error("oclaw: missing ts hook runner: %s", runner)
|
||||
return None
|
||||
hp = str(script.resolve())
|
||||
rts = str(runner.resolve())
|
||||
ex = export_name.strip() or "default"
|
||||
tx = _tsx_invocation()
|
||||
if tx:
|
||||
return [tx, rts, hp, ex]
|
||||
npx = shutil.which("npx")
|
||||
if npx:
|
||||
return [npx, "--yes", "tsx", rts, hp, ex]
|
||||
log.warning("Hook .ts needs `tsx` or `npx` (for `npx tsx`) on PATH. Skipping %s", hp)
|
||||
return None
|
||||
|
||||
|
||||
def _ts_script_command(*, script: Path) -> list[str] | None:
|
||||
"""Run .ts as a free script: stdin JSON / stdout JSON (no import runner)."""
|
||||
hp = str(script.resolve())
|
||||
tx = _tsx_invocation()
|
||||
if tx:
|
||||
return [tx, hp]
|
||||
npx = shutil.which("npx")
|
||||
if npx:
|
||||
return [npx, "--yes", "tsx", hp]
|
||||
log.warning("Hook .ts in script mode needs `tsx` or `npx` on PATH. Skipping %s", hp)
|
||||
return None
|
||||
|
||||
|
||||
def _node_path() -> str | None:
|
||||
return shutil.which("node") or None
|
||||
|
||||
|
||||
def _js_module_command(*, script: Path, export_name: str) -> list[str] | None:
|
||||
node = _node_path()
|
||||
if not node:
|
||||
log.warning("Hook .mjs / .cjs needs `node` on PATH. Skipping %s", script)
|
||||
return None
|
||||
runner = _runner_js_path()
|
||||
if not runner.is_file():
|
||||
log.error("oclaw: missing js hook runner: %s", runner)
|
||||
return None
|
||||
hp = str(script.resolve())
|
||||
rjs = str(runner.resolve())
|
||||
ex = export_name.strip() or "default"
|
||||
return [node, rjs, hp, ex]
|
||||
|
||||
|
||||
def _js_script_command(*, script: Path) -> list[str] | None:
|
||||
node = _node_path()
|
||||
if not node:
|
||||
return None
|
||||
return [node, str(script.resolve())]
|
||||
|
||||
|
||||
async def _run_cmd_handler(
|
||||
*,
|
||||
cmd: list[str],
|
||||
event: HookEvent,
|
||||
base_dir: str,
|
||||
script: Path,
|
||||
log_label: str,
|
||||
) -> None:
|
||||
data = json.dumps(_event_payload(event), default=str)
|
||||
env = {**os.environ, "OCLAW_HOOK_DIR": str(Path(base_dir).resolve()), "OCLAW_HOOK_HANDLER": str(script.resolve())}
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=str(Path(base_dir).resolve()),
|
||||
env=env,
|
||||
)
|
||||
out_b, err_b = await asyncio.wait_for(
|
||||
proc.communicate(input=data.encode("utf-8")),
|
||||
timeout=_HOOK_SUBPROCESS_TIMEOUT_S,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
log.error("Hook %s timed out after %ss: %s", log_label, int(_HOOK_SUBPROCESS_TIMEOUT_S), script)
|
||||
return
|
||||
except Exception:
|
||||
log.exception("Hook %s failed to spawn: %s", log_label, script)
|
||||
return
|
||||
if proc.returncode != 0:
|
||||
log.error(
|
||||
"Hook %s exit %s: %s",
|
||||
log_label,
|
||||
proc.returncode,
|
||||
err_b.decode("utf-8", errors="replace")[:4000],
|
||||
)
|
||||
if log.isEnabledFor(logging.DEBUG):
|
||||
log.debug("Hook %s full stderr: %s", log_label, err_b)
|
||||
return
|
||||
out_t = out_b.decode("utf-8", errors="replace")
|
||||
if not (out_t or "").strip() and err_b:
|
||||
log.warning(
|
||||
"Hook %s empty stdout, stderr: %s",
|
||||
log_label,
|
||||
err_b.decode("utf-8", errors="replace")[:2000],
|
||||
)
|
||||
_merge_stdout_into_context(event, out_t)
|
||||
|
||||
|
||||
def _build_cmd_handler(
|
||||
cmd: list[str] | None,
|
||||
*,
|
||||
base_dir: str,
|
||||
script: Path,
|
||||
log_label: str,
|
||||
) -> Optional[HookHandler]:
|
||||
if not cmd:
|
||||
return None
|
||||
|
||||
async def _handler(event: HookEvent) -> None:
|
||||
await _run_cmd_handler(cmd=cmd, event=event, base_dir=base_dir, script=script, log_label=log_label)
|
||||
|
||||
return _handler
|
||||
|
||||
|
||||
def build_script_hook_handler(
|
||||
*,
|
||||
handler_path: str,
|
||||
base_dir: str,
|
||||
suffix: str,
|
||||
export_name: str,
|
||||
oclaw: dict[str, Any] | None = None,
|
||||
) -> Optional[HookHandler]:
|
||||
p = Path(handler_path)
|
||||
if not p.is_file():
|
||||
return None
|
||||
sfx = (suffix or "").lower()
|
||||
o = oclaw or {}
|
||||
script_mode = _is_script_mode(o)
|
||||
|
||||
# Shell: always JSON stdin/stdout; never use TS/JS import runners
|
||||
if sfx in {".sh", ".bash"}:
|
||||
sc = _sh_command(p)
|
||||
return _build_cmd_handler(sc, base_dir=base_dir, script=p, log_label="sh")
|
||||
|
||||
if script_mode:
|
||||
if sfx in {".ts", ".mts", ".cts"}:
|
||||
cmd = _ts_script_command(script=p)
|
||||
return _build_cmd_handler(cmd, base_dir=base_dir, script=p, log_label="ts:script")
|
||||
if sfx in {".mjs", ".cjs"}:
|
||||
cmd = _js_script_command(script=p)
|
||||
return _build_cmd_handler(cmd, base_dir=base_dir, script=p, log_label="js:script")
|
||||
|
||||
# Module / import path (default for ts and js)
|
||||
if sfx in {".ts", ".mts", ".cts"}:
|
||||
cmd = _ts_command(script=p, export_name=export_name)
|
||||
return _build_cmd_handler(cmd, base_dir=base_dir, script=p, log_label="ts:module")
|
||||
if sfx in {".mjs", ".cjs"}:
|
||||
cmd = _js_module_command(script=p, export_name=export_name)
|
||||
return _build_cmd_handler(cmd, base_dir=base_dir, script=p, log_label="js:module")
|
||||
|
||||
return None
|
||||
45
runtime/hooks/ts_hook_runner.ts
Normal file
45
runtime/hooks/ts_hook_runner.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/**
|
||||
* Invoked as: npx --yes tsx ts_hook_runner.ts <absolute-handler.ts> [exportName]
|
||||
* stdin: JSON { type, action, sessionKey, context, messages?, timestamp? }
|
||||
* stdout: JSON { "context"?: object } merged into the hook event in Python
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const handlerPath = process.argv[2];
|
||||
const exportName = (process.argv[3] || "default").trim();
|
||||
|
||||
if (!handlerPath) {
|
||||
console.error("oclaw: ts_hook_runner: missing handler path");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const raw = readFileSync(0, "utf-8");
|
||||
const data = JSON.parse(raw) as Record<string, unknown>;
|
||||
const event = {
|
||||
type: data.type,
|
||||
action: data.action,
|
||||
sessionKey: data.sessionKey,
|
||||
context: (typeof data.context === "object" && data.context) ? (data.context as Record<string, unknown>) : {},
|
||||
messages: Array.isArray(data.messages) ? data.messages : [],
|
||||
timestamp: data.timestamp,
|
||||
};
|
||||
|
||||
const mod: Record<string, unknown> = (await import(pathToFileURL(handlerPath).href)) as Record<string, unknown>;
|
||||
let fn: ((ev: unknown) => unknown) | undefined;
|
||||
if (exportName && exportName !== "default" && mod[exportName] && typeof mod[exportName] === "function") {
|
||||
fn = mod[exportName] as (ev: unknown) => unknown;
|
||||
} else {
|
||||
fn = (mod.default ?? mod.handle ?? mod.handler) as (ev: unknown) => unknown;
|
||||
}
|
||||
if (typeof fn !== "function") {
|
||||
console.error("oclaw: ts_hook_runner: no function export in", handlerPath, "export", exportName);
|
||||
process.exit(3);
|
||||
}
|
||||
const r = fn(event);
|
||||
if (r && typeof (r as { then?: unknown }).then === "function") {
|
||||
await (r as Promise<unknown>);
|
||||
}
|
||||
process.stdout.write(
|
||||
JSON.stringify({ context: event.context }, (_k, v) => (typeof v === "bigint" ? v.toString() : v), 0),
|
||||
);
|
||||
81
runtime/hooks/user_config_hooks.py
Normal file
81
runtime/hooks/user_config_hooks.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from oclaw.platform.config.paths import PROJECT_ROOT
|
||||
|
||||
|
||||
def resolve_hooks_config_storage_path() -> Path:
|
||||
"""
|
||||
Path used for persistent ``hooks.internal.entries`` edits.
|
||||
|
||||
Matches ``resolve_runtime_config`` file resolution: ``OCLAW_CONFIG_PATH`` (optional
|
||||
relative to ``PROJECT_ROOT``), else ``<PROJECT_ROOT>/oclaw/oclaw.json``.
|
||||
"""
|
||||
raw = str(os.getenv("OCLAW_CONFIG_PATH") or "").strip()
|
||||
if raw:
|
||||
p = Path(raw).expanduser()
|
||||
if not p.is_absolute():
|
||||
p = (Path(PROJECT_ROOT) / p).resolve()
|
||||
return p
|
||||
return (Path(PROJECT_ROOT) / "oclaw" / "oclaw.json").resolve()
|
||||
|
||||
|
||||
def load_storage_config_document() -> dict[str, Any]:
|
||||
p = resolve_hooks_config_storage_path()
|
||||
if not p.is_file():
|
||||
return {}
|
||||
try:
|
||||
raw = p.read_text(encoding="utf-8")
|
||||
obj = json.loads(raw)
|
||||
return dict(obj) if isinstance(obj, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def save_storage_config_document(doc: dict[str, Any]) -> None:
|
||||
p = resolve_hooks_config_storage_path()
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = p.with_suffix(p.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(doc, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
tmp.replace(p)
|
||||
|
||||
|
||||
def apply_hook_entry_enabled(
|
||||
doc: dict[str, Any],
|
||||
hook_key: str,
|
||||
enabled: bool,
|
||||
*,
|
||||
ensure_internal_hooks_enabled: bool = False,
|
||||
) -> None:
|
||||
"""Mutate *doc* in place (shallow structure for ``hooks.internal`` only)."""
|
||||
key = str(hook_key or "").strip()
|
||||
if not key:
|
||||
raise ValueError("hook_key is empty")
|
||||
|
||||
hooks = doc.setdefault("hooks", {})
|
||||
if not isinstance(hooks, dict):
|
||||
doc["hooks"] = {}
|
||||
hooks = doc["hooks"]
|
||||
|
||||
internal = hooks.setdefault("internal", {})
|
||||
if not isinstance(internal, dict):
|
||||
hooks["internal"] = {}
|
||||
internal = hooks["internal"]
|
||||
|
||||
if ensure_internal_hooks_enabled and enabled:
|
||||
internal["enabled"] = True
|
||||
|
||||
entries = internal.setdefault("entries", {})
|
||||
if not isinstance(entries, dict):
|
||||
internal["entries"] = {}
|
||||
entries = internal["entries"]
|
||||
|
||||
row = entries.get(key)
|
||||
if not isinstance(row, dict):
|
||||
entries[key] = {}
|
||||
row = entries[key]
|
||||
row["enabled"] = bool(enabled)
|
||||
|
|
@ -2,15 +2,13 @@ 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 typing import Any, List, Optional, Sequence, Tuple
|
||||
|
||||
from .frontmatter import parse_frontmatter, resolve_oclaw_metadata
|
||||
from .policy import HookSource, resolve_hook_entries
|
||||
|
||||
|
||||
HookEntry = Dict[str, Any]
|
||||
from .frontmatter import parse_frontmatter
|
||||
from .hook_manifest_core import parse_hook_manifest
|
||||
from .hook_types import HookEntry, HookInvocation, HookRef, HookSource
|
||||
from .policy import resolve_hook_entries
|
||||
|
||||
|
||||
def _read_text(path: Path) -> Optional[str]:
|
||||
|
|
@ -28,7 +26,132 @@ def _safe_is_dir(p: Path) -> bool:
|
|||
|
||||
|
||||
def _handler_candidates() -> Tuple[str, ...]:
|
||||
return ("handler.py", "index.py")
|
||||
# One file per hook dir. Order: py → TypeScript (runner) → JS (node runner) → shell.
|
||||
return (
|
||||
"handler.py",
|
||||
"index.py",
|
||||
"handler.ts",
|
||||
"index.ts",
|
||||
"handler.mts",
|
||||
"index.mts",
|
||||
"handler.cts",
|
||||
"index.cts",
|
||||
"handler.mjs",
|
||||
"index.mjs",
|
||||
"handler.cjs",
|
||||
"index.cjs",
|
||||
"handler.sh",
|
||||
"index.sh",
|
||||
"handler.bash",
|
||||
"index.bash",
|
||||
)
|
||||
|
||||
|
||||
def _resolve_contained_dir(base_dir: Path, target_dir: str) -> Optional[Path]:
|
||||
try:
|
||||
resolved = (base_dir / target_dir).resolve()
|
||||
resolved.relative_to(base_dir.resolve())
|
||||
return resolved
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_package_hook_paths(package_dir: Path) -> List[str]:
|
||||
"""
|
||||
Support package.json manifest declarations:
|
||||
- { "openclaw": { "hooks": [...] } }
|
||||
- { "oclaw": { "hooks": [...] } }
|
||||
"""
|
||||
manifest = package_dir / "package.json"
|
||||
raw = _read_text(manifest)
|
||||
if raw is None:
|
||||
return []
|
||||
try:
|
||||
obj = json.loads(raw)
|
||||
except Exception:
|
||||
return []
|
||||
if not isinstance(obj, dict):
|
||||
return []
|
||||
out: List[str] = []
|
||||
for key in ("openclaw", "oclaw"):
|
||||
row = obj.get(key)
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
hooks = row.get("hooks")
|
||||
if not isinstance(hooks, list):
|
||||
continue
|
||||
for it in hooks:
|
||||
s = str(it or "").strip()
|
||||
if s:
|
||||
out.append(s)
|
||||
if out:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _resolve_plugin_hook_dirs(*, workspace_dir: str, config: Optional[dict[str, Any]]) -> List[tuple[str, str]]:
|
||||
"""
|
||||
Lightweight Python parity for OpenClaw plugin hook discovery.
|
||||
|
||||
Discover plugin bundles under:
|
||||
<workspace>/.openclaw/extensions/<plugin-id>/.codex-plugin/plugin.json
|
||||
and read `hooks` from that manifest (string path or list[str]).
|
||||
"""
|
||||
ws = Path(workspace_dir).resolve()
|
||||
ext_root = ws / ".openclaw" / "extensions"
|
||||
if not ext_root.exists() or not ext_root.is_dir():
|
||||
return []
|
||||
|
||||
enabled_entries = (
|
||||
(((config or {}).get("plugins") or {}).get("entries") or {})
|
||||
if isinstance((((config or {}).get("plugins") or {}).get("entries") or {}), dict)
|
||||
else {}
|
||||
)
|
||||
|
||||
out: List[tuple[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
for plugin_root in ext_root.iterdir():
|
||||
if not plugin_root.is_dir():
|
||||
continue
|
||||
plugin_id = plugin_root.name
|
||||
state = enabled_entries.get(plugin_id) if isinstance(enabled_entries, dict) else None
|
||||
if isinstance(state, dict) and state.get("enabled") is False:
|
||||
continue
|
||||
|
||||
manifest_path = plugin_root / ".codex-plugin" / "plugin.json"
|
||||
raw = _read_text(manifest_path)
|
||||
if raw is None:
|
||||
continue
|
||||
try:
|
||||
manifest = json.loads(raw)
|
||||
except Exception:
|
||||
continue
|
||||
if not isinstance(manifest, dict):
|
||||
continue
|
||||
hooks_value = manifest.get("hooks")
|
||||
hook_paths: List[str] = []
|
||||
if isinstance(hooks_value, str):
|
||||
s = hooks_value.strip()
|
||||
if s:
|
||||
hook_paths.append(s)
|
||||
elif isinstance(hooks_value, list):
|
||||
for row in hooks_value:
|
||||
s = str(row or "").strip()
|
||||
if s:
|
||||
hook_paths.append(s)
|
||||
if not hook_paths:
|
||||
continue
|
||||
|
||||
for rel in hook_paths:
|
||||
resolved = _resolve_contained_dir(plugin_root, rel)
|
||||
if resolved is None:
|
||||
continue
|
||||
key = str(resolved)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append((key, plugin_id))
|
||||
return out
|
||||
|
||||
|
||||
def _load_hook_from_dir(hook_dir: Path, source: HookSource, plugin_id: Optional[str] = None) -> Optional[HookEntry]:
|
||||
|
|
@ -39,12 +162,9 @@ def _load_hook_from_dir(hook_dir: Path, source: HookSource, plugin_id: Optional[
|
|||
|
||||
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 = ""
|
||||
manifest = parse_hook_manifest(frontmatter=fm, default_name=hook_dir.name)
|
||||
name = manifest.name
|
||||
description = manifest.description
|
||||
|
||||
handler_path: Optional[Path] = None
|
||||
for candidate in _handler_candidates():
|
||||
|
|
@ -55,21 +175,20 @@ def _load_hook_from_dir(hook_dir: Path, source: HookSource, plugin_id: Optional[
|
|||
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,
|
||||
}
|
||||
return HookEntry(
|
||||
hook=HookRef(
|
||||
name=name,
|
||||
description=description,
|
||||
source=source,
|
||||
pluginId=plugin_id,
|
||||
filePath=str(hook_md),
|
||||
baseDir=str(hook_dir.resolve()),
|
||||
handlerPath=str(handler_path.resolve()),
|
||||
),
|
||||
frontmatter=dict(fm),
|
||||
metadata=manifest.metadata.as_dict(),
|
||||
invocation=HookInvocation(enabled=bool(manifest.invocation_enabled)),
|
||||
)
|
||||
|
||||
|
||||
def load_hook_entries_from_dir(dir_path: str, source: HookSource, plugin_id: Optional[str] = None) -> List[HookEntry]:
|
||||
|
|
@ -81,6 +200,17 @@ def load_hook_entries_from_dir(dir_path: str, source: HookSource, plugin_id: Opt
|
|||
for child in base.iterdir():
|
||||
if not child.is_dir():
|
||||
continue
|
||||
package_hook_paths = _parse_package_hook_paths(child)
|
||||
if package_hook_paths:
|
||||
for rel in package_hook_paths:
|
||||
hook_dir = _resolve_contained_dir(child, rel)
|
||||
if hook_dir is None:
|
||||
continue
|
||||
entry = _load_hook_from_dir(hook_dir, source=source, plugin_id=plugin_id)
|
||||
if entry:
|
||||
out.append(entry)
|
||||
continue
|
||||
|
||||
entry = _load_hook_from_dir(child, source=source, plugin_id=plugin_id)
|
||||
if entry:
|
||||
out.append(entry)
|
||||
|
|
@ -92,6 +222,7 @@ def load_hook_entries_from_dir(dir_path: str, source: HookSource, plugin_id: Opt
|
|||
def discover_workspace_hook_entries(
|
||||
workspace_dir: str,
|
||||
*,
|
||||
config: Optional[dict[str, Any]] = None,
|
||||
managed_hooks_dir: Optional[str] = None,
|
||||
bundled_hooks_dir: Optional[str] = None,
|
||||
extra_dirs: Optional[Sequence[str]] = None,
|
||||
|
|
@ -118,6 +249,9 @@ def discover_workspace_hook_entries(
|
|||
if bundled_hooks_dir:
|
||||
entries.extend(load_hook_entries_from_dir(bundled_hooks_dir, source="oclaw-bundled"))
|
||||
|
||||
for plugin_dir, plugin_id in _resolve_plugin_hook_dirs(workspace_dir=workspace_dir, config=config):
|
||||
entries.extend(load_hook_entries_from_dir(plugin_dir, source="oclaw-plugin", plugin_id=plugin_id))
|
||||
|
||||
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
|
||||
|
|
@ -126,12 +260,14 @@ def discover_workspace_hook_entries(
|
|||
def load_workspace_hook_entries(
|
||||
workspace_dir: str,
|
||||
*,
|
||||
config: Optional[dict[str, Any]] = None,
|
||||
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,
|
||||
config=config,
|
||||
managed_hooks_dir=managed_hooks_dir,
|
||||
bundled_hooks_dir=bundled_hooks_dir,
|
||||
extra_dirs=extra_dirs,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue