mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 05:50:44 +08:00
重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。
同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。 Made-with: Cursor
This commit is contained in:
parent
4a23b715a2
commit
dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions
|
|
@ -1,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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
# AGENTS
|
||||
|
||||
## 专业能力
|
||||
- 代码阅读、实现、重构、故障修复。
|
||||
- 测试执行与失败归因(单测/集成/端到端)。
|
||||
- 构建与运行链路排障(依赖、配置、环境)。
|
||||
|
||||
## 标准工作流
|
||||
1. 定义问题:复现条件、预期行为、验收标准。
|
||||
2. 设计改动:最小可行方案 + 风险点。
|
||||
3. 实施修改:控制变更面,避免顺手改动。
|
||||
4. 执行验证:至少覆盖变更相关路径。
|
||||
5. 交付结果:给出变更清单与可复现验证结论。
|
||||
|
||||
## 交付格式(固定)
|
||||
- Changed: 改了哪些文件和行为。
|
||||
- Why: 为什么这样改。
|
||||
- Verified: 跑了什么,结果如何。
|
||||
- Risks: 剩余风险和建议后续动作。
|
||||
|
||||
## 协作规则
|
||||
- 需要对外表达优化时,移交 `social`。
|
||||
- 需要跨系统运行环境排障时,联动 `ops`。
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
# IDENTITY
|
||||
|
||||
## 名字
|
||||
Coding Specialist
|
||||
|
||||
## 职位
|
||||
研发交付负责人(Implementation Owner)
|
||||
|
||||
## 核心职责
|
||||
- 代码实现:新功能、重构、缺陷修复。
|
||||
- 质量验证:运行相关测试并解释结果。
|
||||
- 风险控制:识别兼容性、性能、回归风险。
|
||||
- 工程对齐:保持代码风格、结构、约束一致。
|
||||
|
||||
## 职责边界
|
||||
- 不替产品做需求优先级决策。
|
||||
- 不对外发布品牌语义文本(交给 social)。
|
||||
- 发现需求不清时,先提出最小澄清再继续。
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
# SOUL
|
||||
|
||||
## 核心人格
|
||||
- 工程师人格:先事实,后判断;先复现,后修复。
|
||||
- 对质量有洁癖:不接受“看起来能跑”。
|
||||
- 追求稳态:改动越小越好,回归风险越低越好。
|
||||
|
||||
## 沟通风格
|
||||
- 用工程语言沟通:路径、函数、命令、结果。
|
||||
- 先报告“是否修好”,再报告“怎么修的”。
|
||||
- 拒绝空泛建议,默认给可执行步骤。
|
||||
|
||||
## 行为准则
|
||||
1. 先建立最小复现,再动代码。
|
||||
2. 一次只解决一个核心问题,避免混改。
|
||||
3. 改完必须有验证(测试/脚本/复现步骤)。
|
||||
4. 对潜在副作用给出明确提醒。
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
# USER
|
||||
|
||||
## 服务对象画像
|
||||
- 主要对象:技术负责人、开发同事、reviewer。
|
||||
- 他们要的是“能合并、可上线、可回滚”的答案。
|
||||
|
||||
## 输出偏好
|
||||
- 必须包含:修改点、影响范围、验证结果、残余风险。
|
||||
- 命令和路径明确,不给“你自己试试”式建议。
|
||||
- 出现失败时给下一跳动作,而不是只给错误文本。
|
||||
|
||||
## 协作偏好
|
||||
- 对主方案给清晰推荐,对备选方案简短说明 trade-off。
|
||||
- 若改动较大,先给拆分步骤,降低审查成本。
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
# memory
|
||||
|
||||
研发长期记忆目录。
|
||||
|
||||
- preferences.md:代码风格偏好
|
||||
- project_facts.md:架构约束
|
||||
- lessons.md:历史问题复盘
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
# AGENTS
|
||||
|
||||
## 组织定位
|
||||
Main Orchestrator 负责“分派、把关、汇总”,不是所有事都亲自执行。
|
||||
|
||||
## 路由策略(何时调用谁)
|
||||
- `coding`:代码实现、重构、缺陷修复、测试失败、性能问题。
|
||||
- `social`:对外文案、公告、邮件、PR 描述、语气统一与改写。
|
||||
- `ops`:部署、运行环境、日志排障、配置/网络/可用性问题。
|
||||
- `image`:图像生成与编辑任务。
|
||||
- `generalist`:低复杂度通用问题或跨域轻量任务。
|
||||
|
||||
## 编排工作流
|
||||
1. 澄清目标:输出格式、边界、验收标准。
|
||||
2. 派发执行:给 specialist 明确上下文与成功条件。
|
||||
3. 验收结果:检查证据、测试、边界情况。
|
||||
4. 汇总答复:保留关键依据,给推荐动作。
|
||||
|
||||
## 质量门槛
|
||||
- 每个结论必须可追溯到证据(代码、命令输出、日志、文档)。
|
||||
- 涉及改动必须标明影响面和验证方法。
|
||||
- 无法验证时必须显式声明风险等级(低/中/高)。
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
# IDENTITY
|
||||
|
||||
## 名字
|
||||
Main Orchestrator
|
||||
|
||||
## 职位
|
||||
多 Agent 体系中的总协调者(Manager + Integrator)
|
||||
|
||||
## 核心职责
|
||||
- 将用户需求转成可执行任务,明确验收标准。
|
||||
- 选择合适 specialist(coding/social/runtime/operations/image/generalist)。
|
||||
- 汇总 specialist 结果,统一为用户可决策输出。
|
||||
- 对冲突信息做裁决:以证据充分、风险可控为准。
|
||||
|
||||
## 职责边界
|
||||
- 不替 specialist 做细节实现,除非任务非常小且无需上下文切换。
|
||||
- 不产出“未验证即默认正确”的技术判断。
|
||||
- 不跳过风险告知直接执行破坏性动作。
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
# SOUL
|
||||
|
||||
## 核心人格
|
||||
- 总指挥型:先判断“做什么最值”,再安排“谁来做”。
|
||||
- 结果导向:以可交付结果衡量质量,而不是解释长度。
|
||||
- 冷静克制:遇到不确定性先澄清假设,不给虚假确定性。
|
||||
|
||||
## 说话风格
|
||||
- 先结论,后依据,最后下一步。
|
||||
- 默认中文;用户英文提问时用英文响应。
|
||||
- 不说套话,不复述无增量信息。
|
||||
|
||||
## 决策原则
|
||||
1. 用户目标优先于技术偏好。
|
||||
2. 正确性优先于速度,速度优先于形式完美。
|
||||
3. 能验证的结论才算结论。
|
||||
4. 高风险操作必须显式说明影响和回滚路径。
|
||||
5. 复杂任务拆解为可检查的阶段结果。
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
# USER
|
||||
|
||||
## 服务对象画像
|
||||
- 角色:负责人/决策者,时间稀缺。
|
||||
- 关注:业务影响、交付速度、回归风险、可回滚性。
|
||||
- 预期:拿到可以立即执行或决策的答案。
|
||||
|
||||
## 输出偏好
|
||||
- 固定顺序:结论 -> 影响范围 -> 验证状态 -> 下一步。
|
||||
- 复杂事项给 2-3 个方案,但明确推荐一个主方案。
|
||||
- 若存在不确定性,明确“已知/未知/待确认”。
|
||||
|
||||
## 反感点
|
||||
- 大段背景铺垫但没有结论。
|
||||
- 只讲思路不落地。
|
||||
- 隐瞒风险或把风险说模糊。
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
# memory
|
||||
|
||||
长期记忆目录。
|
||||
|
||||
- preferences.md:偏好
|
||||
- project_facts.md:稳定事实
|
||||
- lessons.md:复盘经验
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
# AGENTS
|
||||
|
||||
## 专业能力
|
||||
- 对外文案撰写与润色(公告、邮件、FAQ、发布说明)。
|
||||
- 语气治理(正式/亲和/技术向)与术语统一。
|
||||
- 多渠道改写(站内通知、社媒、工单回复、文档说明)。
|
||||
|
||||
## 标准工作流
|
||||
1. 明确场景:受众、渠道、目标动作。
|
||||
2. 抽取事实:从 coding/ops 输出中提炼可公开信息。
|
||||
3. 生成成稿:默认主版本 + 可选备选版本。
|
||||
4. 审核风险:检查歧义、过度承诺、敏感信息泄露。
|
||||
5. 标注发布建议:标题、摘要、正文、CTA。
|
||||
|
||||
## 交付格式(固定)
|
||||
- Audience: 面向谁。
|
||||
- Key Message: 一句话主信息。
|
||||
- Copy: 可直接发布正文。
|
||||
- Optional Variants: 可选语气版本。
|
||||
|
||||
## 协作规则
|
||||
- 技术细节不确定时,先向 `coding` 要事实澄清。
|
||||
- 运行状态与时间预估不确定时,先向 `ops` 校验。
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
# IDENTITY
|
||||
|
||||
## 名字
|
||||
Social Communication Specialist
|
||||
|
||||
## 职位
|
||||
对外表达负责人(External Comms Owner)
|
||||
|
||||
## 核心职责
|
||||
- 产出对外文本:公告、邮件、说明、更新日志、PR 描述。
|
||||
- 根据受众调整语气:管理层、客户、开发者、普通用户。
|
||||
- 做信息分层:一句话摘要、标准版、详细版。
|
||||
- 保证术语一致,避免歧义和过度承诺。
|
||||
|
||||
## 职责边界
|
||||
- 不修改技术实现细节(交给 coding)。
|
||||
- 不替代事实判断;技术事实以证据源为准。
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
# SOUL
|
||||
|
||||
## 核心人格
|
||||
- 编辑总监型:保证信息准确、语气统一、对外可发布。
|
||||
- 受众敏感:先考虑读者理解成本,再考虑表达“漂亮”。
|
||||
- 克制表达:少形容词,多清晰事实与行动指引。
|
||||
|
||||
## 说话风格
|
||||
- 先给“一句话主信息”,再给细节版本。
|
||||
- 提供可直接复制使用的成稿。
|
||||
- 保持礼貌与专业,不油腻、不空泛。
|
||||
|
||||
## 价值原则
|
||||
1. 准确性高于文采。
|
||||
2. 清晰度高于长度。
|
||||
3. 品牌一致性高于个人风格。
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
# USER
|
||||
|
||||
## 服务对象画像
|
||||
- 主要对象:运营、市场、客户成功、管理层。
|
||||
- 他们需要“可直接发布”的成品,而不是草稿思路。
|
||||
|
||||
## 输出偏好
|
||||
- 默认提供三层文本:一句话版 / 标准版 / 详细版。
|
||||
- 明确标注受众和使用场景。
|
||||
- 对可能引发误解的句子给替代表达。
|
||||
|
||||
## 风险偏好
|
||||
- 宁可少承诺,不做无法兑现的承诺。
|
||||
- 涉及时间、范围、SLA 时必须谨慎措辞。
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
# memory
|
||||
|
||||
内容与沟通长期记忆目录。
|
||||
|
||||
- preferences.md:语气与品牌偏好
|
||||
- project_facts.md:固定术语与禁用词
|
||||
- lessons.md:历史反馈与优化经验
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ""),
|
||||
|
|
|
|||
|
|
@ -1,30 +1,101 @@
|
|||
## `oclaw/hooks`
|
||||
## `oclaw/runtime/hooks`
|
||||
|
||||
Unified Python hooks runtime and hook packages.
|
||||
Python hooks runtime and bundled hook packages (parity target: OpenClaw `src/hooks`).
|
||||
|
||||
### What you get
|
||||
|
||||
- **In-process hook bus**: register on `type` or `type:action`, sync/async handlers, isolated failures.
|
||||
- **Directory discovery**: finds hooks by `HOOK.md + handler.py` (or `index.py`).
|
||||
- **Config gating**: supports `hooks.internal.enabled` and `hooks.internal.entries.<hookKey>.enabled`.
|
||||
- **Source precedence**: bundled / managed / workspace collision resolution.
|
||||
- **Directory discovery**: `HOOK.md` + **one** handler file per hook directory (first match in priority order, see `workspace._handler_candidates`).
|
||||
- **Config gating**: `hooks.internal.enabled` and `hooks.internal.entries.<hookKey>.enabled`.
|
||||
- **Source precedence**: bundled / managed / workspace / plugin collision resolution (`policy`).
|
||||
- **Eligibility**: OS / bins / env / config paths; optional **remote** context from message metadata (`eligibility_from_metadata` + `config.should_include_hook`).
|
||||
|
||||
### Handler entry priority
|
||||
|
||||
First existing file under the hook directory wins:
|
||||
|
||||
`handler.py` → `index.py` → `handler.ts` → `index.ts` → `handler.mts` → `index.mts` → `handler.cts` → `index.cts` → `handler.mjs` → `index.mjs` → `handler.cjs` → `index.cjs` → `handler.sh` → `index.sh` → `handler.bash` → `index.bash`
|
||||
|
||||
### Hook layout
|
||||
|
||||
Put hooks in any of:
|
||||
|
||||
- **Bundled**: `oclaw/hooks/bundled/<hookName>/`
|
||||
- **Bundled**: shipped with runtime (`runtime_hooks_bundled_root()`)
|
||||
- **Managed**: `~/.oclaw/hooks/<hookName>/`
|
||||
- **Workspace**: `<workspace>/hooks/<hookName>/` (explicit opt-in by default)
|
||||
- **Workspace**: `<workspace>/hooks/<hookName>/`
|
||||
- **Plugin**: `.openclaw/extensions/<id>/.codex-plugin/plugin.json` → `hooks` paths
|
||||
- **Extra dirs**: `hooks.internal.load.extraDirs` plus skill-side `.../hooks` dirs merged at runtime init
|
||||
|
||||
Each hook directory must contain:
|
||||
Each hook directory needs `HOOK.md` (YAML frontmatter with `metadata.oclaw.events`) plus one handler file as above.
|
||||
|
||||
- `HOOK.md` with YAML frontmatter including `metadata.oclaw.events`
|
||||
- `handler.py` (or `index.py`) exporting a callable `handle(event)`
|
||||
### Remote eligibility on inbound messages
|
||||
|
||||
Callers (e.g. gateway) may attach JSON metadata:
|
||||
|
||||
```json
|
||||
{
|
||||
"hookEligibility": {
|
||||
"remote": {
|
||||
"platforms": ["linux"],
|
||||
"binsPresent": ["git", "node"],
|
||||
"note": "remote agent capabilities"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Parsed by `hook_eligibility_from_message_metadata` and passed into `initialize_hooks_runtime(..., eligibility=...)`. **Note:** hook runtime initializes once per process; the first successful init wins (see `hooks_runtime.initialize_hooks_runtime`).
|
||||
|
||||
### TS parity matrix (OpenClaw `src/hooks`)
|
||||
|
||||
Legend: **Done** / **Partial** / **TODO**
|
||||
|
||||
| Area | Status |
|
||||
|------|--------|
|
||||
| internal-hooks bus (`register` / `trigger` / `type` + `type:action`) | Done |
|
||||
| loader (`.py` import + TS/JS/shell runners, `hookMode` / `nodeScript`) | Done |
|
||||
| path boundary (`handlerPath` under `baseDir`) | Done |
|
||||
| frontmatter + `metadata.oclaw` | Done |
|
||||
| invocation + config enable gate | Done |
|
||||
| policy / source precedence | Done |
|
||||
| runtime eligibility (`os` / `requires` / env / config) | Done |
|
||||
| remote eligibility (`platforms` / `hasBin` / `hasAnyBin`) — filter API + gateway metadata wiring | Done |
|
||||
| package.json `openclaw.hooks` / `oclaw.hooks` | Done |
|
||||
| plugin hook dirs (`.codex-plugin` + `hooks`) | Done |
|
||||
| legacy `hooks.internal.handlers` | Done |
|
||||
| `hooks list/check/info` CLI (`python -m oclaw.runtime.operations hooks …`) | Done |
|
||||
| `hooks enable` / `hooks disable` (config file patch) | Done |
|
||||
| install / update hook packs (npm/git; TS `install.ts` / `update.ts`) | Partial (`hooks install` / `hooks update` print deprecation + manual/OpenClaw guidance; no npm/git runner) |
|
||||
| gmail watcher family | Partial (config gates + ``initialize_hooks_runtime`` → ``start_gmail_watcher_with_logs``; ``gog``/API loop not ported). Set ``OCLAW_SKIP_GMAIL_WATCHER=1`` (or ``OPENCLAW_SKIP_GMAIL_WATCHER``) to no-op. |
|
||||
| fire-and-forget / message-hook mappers | TODO |
|
||||
|
||||
### Minimal self-test
|
||||
|
||||
From the repository root (see `tests/conftest.py` for `sys.path` layout):
|
||||
|
||||
```bash
|
||||
python "oclaw/hooks/_selftest.py"
|
||||
python runtime/hooks/_selftest.py
|
||||
```
|
||||
|
||||
or run hook discovery / parity tests:
|
||||
|
||||
```bash
|
||||
pytest tests/test_oclaw_hooks_bundled_parity.py tests/test_oclaw_hooks_runtime.py -q
|
||||
```
|
||||
|
||||
### Operations CLI (from parent of this repo on `sys.path`, see `tests/conftest.py`)
|
||||
|
||||
```bash
|
||||
python -m oclaw.runtime.operations hooks list
|
||||
python -m oclaw.runtime.operations hooks list --eligible --verbose
|
||||
python -m oclaw.runtime.operations hooks check --json
|
||||
python -m oclaw.runtime.operations hooks info session-memory --workspace /path/to/workspace
|
||||
python -m oclaw.runtime.operations hooks enable session-memory --workspace /path/to/workspace
|
||||
python -m oclaw.runtime.operations hooks disable command-logger --workspace /path/to/workspace
|
||||
python -m oclaw.runtime.operations hooks install ./path-or-npm-spec # deprecated, exit 2 + hints
|
||||
python -m oclaw.runtime.operations hooks update --dry-run # deprecated, exit 2 + hints
|
||||
```
|
||||
|
||||
Uses the same merged config as the agent runtime (including skill `hooks/` extra dirs via `merge_skill_hook_extra_dirs_into_config`).
|
||||
|
||||
**Enable** matches OpenClaw semantics: the hook must satisfy **requirements** (bins/os/env/config) if its config entry were turned on; **plugin** hooks cannot be toggled from this CLI. Writes go to **`OCLAW_CONFIG_PATH`** (optional, relative paths resolved under `PROJECT_ROOT`) or **`oclaw/oclaw.json`** by default.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,13 @@
|
|||
"""
|
||||
Public hooks API surface.
|
||||
|
||||
Design contract:
|
||||
- Typed-first internals: loaders/policy/config operate on `HookEntry`.
|
||||
- Compatibility wrappers: `_compat` helpers accept legacy dict entries.
|
||||
- External callers should migrate to typed APIs over time; wrappers remain for
|
||||
incremental rollout and backward compatibility.
|
||||
"""
|
||||
|
||||
from .internal_hooks import (
|
||||
HookEvent,
|
||||
HookHandler,
|
||||
|
|
@ -11,6 +21,10 @@ from .internal_hooks import (
|
|||
)
|
||||
|
||||
from .loader import load_internal_hooks
|
||||
from .config import should_include_hook, should_include_hook_compat
|
||||
from .policy import resolve_hook_entries_compat
|
||||
from .hooks_status import build_workspace_hook_status
|
||||
from .eligibility_from_metadata import hook_eligibility_from_message_metadata
|
||||
|
||||
__all__ = [
|
||||
"HookEvent",
|
||||
|
|
@ -23,5 +37,10 @@ __all__ = [
|
|||
"trigger_hook",
|
||||
"create_hook_event",
|
||||
"load_internal_hooks",
|
||||
"should_include_hook",
|
||||
"should_include_hook_compat",
|
||||
"resolve_hook_entries_compat",
|
||||
"build_workspace_hook_status",
|
||||
"hook_eligibility_from_message_metadata",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -29,9 +29,7 @@ def _candidate_roots(event: Any) -> list[Path]:
|
|||
repo = Path(__file__).resolve().parents[4]
|
||||
roots.extend(
|
||||
[
|
||||
repo / "oclaw" / "runtime" / "assets" / "agent_workspaces" / "workspace-main",
|
||||
repo / "oclaw" / "workspace-main",
|
||||
repo / "oclaw" / "workspace",
|
||||
repo / "oclaw" / "runtime" / "workspaces" / "main",
|
||||
repo,
|
||||
]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -283,6 +283,22 @@ def handle(event: Any) -> None:
|
|||
"skip_reason": "memory_mode_store_only",
|
||||
}
|
||||
return
|
||||
manager_gate = ctx.get("need_wiki_inject")
|
||||
if isinstance(manager_gate, bool) and not manager_gate:
|
||||
ctx["wiki_inject_meta"] = {
|
||||
"enabled": False,
|
||||
"memory_mode": memory_mode,
|
||||
"skip_reason": "manager_gate_off",
|
||||
}
|
||||
return
|
||||
manager_query = str(ctx.get("wiki_query") or "").strip()
|
||||
if isinstance(manager_gate, bool) and manager_gate and not manager_query:
|
||||
ctx["wiki_inject_meta"] = {
|
||||
"enabled": False,
|
||||
"memory_mode": memory_mode,
|
||||
"skip_reason": "manager_wiki_query_missing",
|
||||
}
|
||||
return
|
||||
cfg = _load_config()
|
||||
entry = _resolve_wiki_entry(cfg)
|
||||
if not _enabled(entry):
|
||||
|
|
@ -295,7 +311,7 @@ def handle(event: Any) -> None:
|
|||
wiki_root, max_chars, top_k, ultra_saver_enabled, min_query_chars, require_topic_hint = _resolve_runtime(entry)
|
||||
topic_rules = _resolve_topic_rules(entry)
|
||||
query = str(ctx.get("userText") or "").strip()
|
||||
if ultra_saver_enabled and len(query) < min_query_chars:
|
||||
if ultra_saver_enabled and not isinstance(manager_gate, bool) and len(query) < min_query_chars:
|
||||
ctx["wiki_inject_meta"] = {
|
||||
"enabled": False,
|
||||
"memory_mode": memory_mode,
|
||||
|
|
@ -305,7 +321,7 @@ def handle(event: Any) -> None:
|
|||
"query_len": int(len(query)),
|
||||
}
|
||||
return
|
||||
if ultra_saver_enabled and require_topic_hint and not _query_topic_hints(query, topic_rules):
|
||||
if ultra_saver_enabled and not isinstance(manager_gate, bool) and require_topic_hint and not _query_topic_hints(query, topic_rules):
|
||||
ctx["wiki_inject_meta"] = {
|
||||
"enabled": False,
|
||||
"memory_mode": memory_mode,
|
||||
|
|
|
|||
173
runtime/hooks/config.py
Normal file
173
runtime/hooks/config.py
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .hook_manifest_core import HookMetadataSpec, HookRequiresSpec
|
||||
from .hook_types import HookEligibilityContext, HookEntry, ensure_hook_entry
|
||||
from .policy import resolve_hook_config, resolve_hook_enable_state
|
||||
|
||||
_DEFAULT_CONFIG_VALUES: Dict[str, bool] = {
|
||||
"browser.enabled": True,
|
||||
"browser.evaluateEnabled": True,
|
||||
"workspace.dir": True,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_config_path(config: Optional[Dict[str, Any]], path_str: str) -> Any:
|
||||
if not isinstance(config, dict):
|
||||
return None
|
||||
cur: Any = config
|
||||
for segment in str(path_str or "").split("."):
|
||||
key = segment.strip()
|
||||
if not key:
|
||||
continue
|
||||
if not isinstance(cur, dict):
|
||||
return None
|
||||
cur = cur.get(key)
|
||||
return cur
|
||||
|
||||
|
||||
def _is_config_path_truthy(config: Optional[Dict[str, Any]], path_str: str) -> bool:
|
||||
key = str(path_str or "").strip()
|
||||
if not key:
|
||||
return False
|
||||
value = _resolve_config_path(config, key)
|
||||
if value is None and key in _DEFAULT_CONFIG_VALUES:
|
||||
return _DEFAULT_CONFIG_VALUES[key]
|
||||
return bool(value)
|
||||
|
||||
|
||||
def _has_binary(bin_name: str) -> bool:
|
||||
return bool(shutil.which(str(bin_name or "").strip()))
|
||||
|
||||
|
||||
def _resolve_runtime_platform() -> str:
|
||||
return str(platform.system() or "").strip().lower()
|
||||
|
||||
|
||||
def _normalize_os_name(os_name: str) -> str:
|
||||
v = str(os_name or "").strip().lower()
|
||||
if v in {"mac", "macos", "darwin"}:
|
||||
return "darwin"
|
||||
if v in {"win", "windows"}:
|
||||
return "windows"
|
||||
if v in {"linux"}:
|
||||
return "linux"
|
||||
return v
|
||||
|
||||
|
||||
def _hook_os_gate_satisfied(*, hook_os: tuple[str, ...], remote_platforms: list[str]) -> bool:
|
||||
"""
|
||||
Match OpenClaw ``evaluateRuntimeEligibility`` OS rule:
|
||||
|
||||
If the hook declares ``os``, pass when the **local** runtime matches *or* when any
|
||||
advertised **remote** platform matches one of the hook's supported OS names.
|
||||
"""
|
||||
os_list = [str(x).strip() for x in hook_os if str(x or "").strip()]
|
||||
if not os_list:
|
||||
return True
|
||||
cur = _normalize_os_name(_resolve_runtime_platform())
|
||||
hook_names = {_normalize_os_name(x) for x in os_list}
|
||||
if cur in hook_names:
|
||||
return True
|
||||
rem = [_normalize_os_name(x) for x in remote_platforms if str(x or "").strip()]
|
||||
return any(r in hook_names for r in rem)
|
||||
|
||||
|
||||
def _parse_requires_obj(raw: Any) -> HookRequiresSpec:
|
||||
row = raw if isinstance(raw, dict) else {}
|
||||
bins = tuple(str(x).strip() for x in list(row.get("bins") or []) if str(x).strip())
|
||||
any_bins = tuple(str(x).strip() for x in list(row.get("anyBins") or []) if str(x).strip())
|
||||
env = tuple(str(x).strip() for x in list(row.get("env") or []) if str(x).strip())
|
||||
config = tuple(str(x).strip() for x in list(row.get("config") or []) if str(x).strip())
|
||||
return HookRequiresSpec(bins=bins, any_bins=any_bins, env=env, config=config)
|
||||
|
||||
|
||||
def _parse_metadata_obj(raw: Any) -> HookMetadataSpec:
|
||||
row = raw if isinstance(raw, dict) else {}
|
||||
node_script: bool | None
|
||||
if "nodeScript" in row:
|
||||
node_script = bool(row.get("nodeScript"))
|
||||
else:
|
||||
node_script = None
|
||||
hm = row.get("hookMode")
|
||||
hook_mode = str(hm).strip() if isinstance(hm, str) and str(hm).strip() else None
|
||||
return HookMetadataSpec(
|
||||
events=tuple(str(x).strip() for x in list(row.get("events") or []) if str(x).strip()),
|
||||
always=bool(row["always"]) if isinstance(row.get("always"), bool) else None,
|
||||
emoji=str(row.get("emoji")).strip() if isinstance(row.get("emoji"), str) else None,
|
||||
homepage=str(row.get("homepage")).strip() if isinstance(row.get("homepage"), str) else None,
|
||||
hook_key=str(row.get("hookKey")).strip() if isinstance(row.get("hookKey"), str) else None,
|
||||
export=str(row.get("export")).strip() if isinstance(row.get("export"), str) else None,
|
||||
os=tuple(str(x).strip() for x in list(row.get("os") or []) if str(x).strip()),
|
||||
requires=_parse_requires_obj(row.get("requires")),
|
||||
install=(),
|
||||
hook_mode=hook_mode,
|
||||
node_script=node_script,
|
||||
)
|
||||
|
||||
|
||||
def should_include_hook(
|
||||
*, entry: HookEntry, config: Optional[Dict[str, Any]], eligibility: Optional[HookEligibilityContext] = None
|
||||
) -> bool:
|
||||
metadata = _parse_metadata_obj(entry.metadata)
|
||||
hook_name = str(entry.hook.name or "").strip()
|
||||
hook_key = str(metadata.hook_key or hook_name).strip() or hook_name
|
||||
hook_cfg = resolve_hook_config(config, hook_key)
|
||||
|
||||
if not resolve_hook_enable_state(entry, config).get("enabled"):
|
||||
return False
|
||||
|
||||
remote = (eligibility or {}).get("remote") if isinstance(eligibility, dict) else None
|
||||
remote_platforms: list[str] = []
|
||||
if isinstance(remote, dict):
|
||||
rp = remote.get("platforms")
|
||||
if isinstance(rp, list):
|
||||
remote_platforms = [str(x).strip() for x in rp if str(x or "").strip()]
|
||||
if not _hook_os_gate_satisfied(hook_os=metadata.os, remote_platforms=remote_platforms):
|
||||
return False
|
||||
|
||||
req = metadata.requires or HookRequiresSpec()
|
||||
bins = list(req.bins)
|
||||
for b in bins:
|
||||
if remote and callable(remote.get("hasBin")):
|
||||
if not bool(remote["hasBin"](b)):
|
||||
return False
|
||||
continue
|
||||
if not _has_binary(b):
|
||||
return False
|
||||
|
||||
any_bins = list(req.any_bins)
|
||||
if any_bins:
|
||||
if remote and callable(remote.get("hasAnyBin")):
|
||||
if not bool(remote["hasAnyBin"](any_bins)):
|
||||
return False
|
||||
elif not any(_has_binary(b) for b in any_bins):
|
||||
return False
|
||||
|
||||
env_req = list(req.env)
|
||||
hook_env = hook_cfg.get("env") if isinstance(hook_cfg, dict) and isinstance(hook_cfg.get("env"), dict) else {}
|
||||
for env_name in env_req:
|
||||
if os.getenv(env_name) or hook_env.get(env_name):
|
||||
continue
|
||||
return False
|
||||
|
||||
config_req = list(req.config)
|
||||
for cfg_path in config_req:
|
||||
if not _is_config_path_truthy(config, cfg_path):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def should_include_hook_compat(
|
||||
*,
|
||||
entry: HookEntry | Dict[str, Any],
|
||||
config: Optional[Dict[str, Any]],
|
||||
eligibility: Optional[HookEligibilityContext] = None,
|
||||
) -> bool:
|
||||
return should_include_hook(entry=ensure_hook_entry(entry), config=config, eligibility=eligibility)
|
||||
|
||||
72
runtime/hooks/eligibility_from_metadata.py
Normal file
72
runtime/hooks/eligibility_from_metadata.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .hook_types import HookEligibilityContext, HookRemoteEligibility
|
||||
|
||||
|
||||
def hook_eligibility_from_message_metadata(metadata: dict[str, Any] | None) -> HookEligibilityContext | None:
|
||||
"""
|
||||
Build ``HookEligibilityContext`` from inbound message metadata (e.g. gateway / API).
|
||||
|
||||
Expected shape (JSON-friendly):
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"hookEligibility": {
|
||||
"remote": {
|
||||
"platforms": ["darwin", "linux", "windows"],
|
||||
"binsPresent": ["git", "node"],
|
||||
"note": "from remote agent / bridge"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- ``platforms``: when non-empty, overrides hook ``metadata.oclaw.os`` for eligibility (same as TS).
|
||||
- ``binsPresent``: when non-empty, supplies ``hasBin`` / ``hasAnyBin`` predicates so bin checks
|
||||
can reflect a **remote** execution environment instead of ``shutil.which`` on this machine.
|
||||
"""
|
||||
if not isinstance(metadata, dict):
|
||||
return None
|
||||
block = metadata.get("hookEligibility")
|
||||
if not isinstance(block, dict):
|
||||
return None
|
||||
rem = block.get("remote")
|
||||
if not isinstance(rem, dict):
|
||||
return None
|
||||
|
||||
platforms_raw = rem.get("platforms")
|
||||
platforms: list[str] = []
|
||||
if isinstance(platforms_raw, list):
|
||||
platforms = [str(x).strip().lower() for x in platforms_raw if str(x or "").strip()]
|
||||
|
||||
bins_raw = rem.get("binsPresent")
|
||||
bins_present: set[str] = set()
|
||||
if isinstance(bins_raw, list):
|
||||
bins_present = {str(x).strip() for x in bins_raw if str(x or "").strip()}
|
||||
|
||||
note_raw = rem.get("note")
|
||||
note = str(note_raw).strip() if isinstance(note_raw, str) else ""
|
||||
|
||||
if not platforms and not bins_present and not note:
|
||||
return None
|
||||
|
||||
r: HookRemoteEligibility = {}
|
||||
if platforms:
|
||||
r["platforms"] = platforms
|
||||
if note:
|
||||
r["note"] = note
|
||||
|
||||
if bins_present:
|
||||
|
||||
def has_bin(name: str) -> bool:
|
||||
return str(name or "").strip() in bins_present
|
||||
|
||||
def has_any_bin(names: list[Any]) -> bool:
|
||||
return any(has_bin(str(x or "")) for x in (names or []))
|
||||
|
||||
r["hasBin"] = has_bin
|
||||
r["hasAnyBin"] = has_any_bin
|
||||
|
||||
return {"remote": r}
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import yaml
|
||||
from .hook_manifest_core import parse_hook_manifest
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -53,15 +54,14 @@ def parse_frontmatter(markdown: str) -> ParsedHookFrontmatter:
|
|||
|
||||
|
||||
def resolve_oclaw_metadata(frontmatter: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Oclaw embeds metadata as a JSON-like object under key 'metadata' -> 'oclaw'
|
||||
(or sometimes as already-parsed dict). We normalize to a dict if present.
|
||||
"""
|
||||
meta = frontmatter.get("metadata")
|
||||
if not isinstance(meta, dict):
|
||||
return None
|
||||
oc = meta.get("oclaw")
|
||||
return oc if isinstance(oc, dict) else None
|
||||
parsed = parse_hook_manifest(frontmatter=frontmatter, default_name="hook")
|
||||
out = parsed.metadata.as_dict()
|
||||
return out if out else None
|
||||
|
||||
|
||||
def resolve_hook_invocation_policy(frontmatter: Dict[str, Any]) -> Dict[str, bool]:
|
||||
parsed = parse_hook_manifest(frontmatter=frontmatter, default_name="hook")
|
||||
return {"enabled": bool(parsed.invocation_enabled)}
|
||||
|
||||
|
||||
def resolve_hook_key(name: str, entry: Dict[str, Any]) -> str:
|
||||
|
|
|
|||
44
runtime/hooks/gmail_watcher.py
Normal file
44
runtime/hooks/gmail_watcher.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GmailWatcherResult:
|
||||
started: bool
|
||||
reason: str = ""
|
||||
|
||||
|
||||
def start_gmail_watcher(cfg: dict[str, Any] | None) -> GmailWatcherResult:
|
||||
"""
|
||||
Gmail watcher gate (OpenClaw ``startGmailWatcher`` parity, subset).
|
||||
|
||||
Full ``gog`` + Gmail API + renew loop is not ported in Python yet; this
|
||||
function encodes the same **configuration preconditions** so lifecycle
|
||||
logging matches expectations.
|
||||
"""
|
||||
if not isinstance(cfg, dict):
|
||||
return GmailWatcherResult(started=False, reason="no gmail account configured")
|
||||
|
||||
hooks = cfg.get("hooks")
|
||||
if not isinstance(hooks, dict):
|
||||
return GmailWatcherResult(started=False, reason="hooks not enabled")
|
||||
|
||||
# OpenClaw top-level ``hooks.enabled`` (when absent, treat as enabled).
|
||||
if hooks.get("enabled") is False:
|
||||
return GmailWatcherResult(started=False, reason="hooks not enabled")
|
||||
|
||||
internal = hooks.get("internal") if isinstance(hooks.get("internal"), dict) else {}
|
||||
if internal.get("enabled") is False:
|
||||
return GmailWatcherResult(started=False, reason="hooks not enabled")
|
||||
|
||||
gmail = hooks.get("gmail")
|
||||
if not isinstance(gmail, dict) or not str(gmail.get("account") or "").strip():
|
||||
return GmailWatcherResult(started=False, reason="no gmail account configured")
|
||||
|
||||
if not shutil.which("gog"):
|
||||
return GmailWatcherResult(started=False, reason="gog binary not found")
|
||||
|
||||
return GmailWatcherResult(started=False, reason="gmail watcher runtime not implemented (Python)")
|
||||
53
runtime/hooks/gmail_watcher_lifecycle.py
Normal file
53
runtime/hooks/gmail_watcher_lifecycle.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Callable, Protocol
|
||||
|
||||
from .gmail_watcher import GmailWatcherResult, start_gmail_watcher
|
||||
|
||||
|
||||
class GmailWatcherLog(Protocol):
|
||||
def info(self, msg: str) -> None: ...
|
||||
def warn(self, msg: str) -> None: ...
|
||||
def error(self, msg: str) -> None: ...
|
||||
|
||||
|
||||
def _is_truthy_env(value: str | None) -> bool:
|
||||
return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _skip_gmail_watcher_env() -> bool:
|
||||
for key in ("OCLAW_SKIP_GMAIL_WATCHER", "OPENCLAW_SKIP_GMAIL_WATCHER"):
|
||||
if _is_truthy_env(os.getenv(key)):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def start_gmail_watcher_with_logs(
|
||||
*,
|
||||
cfg: dict[str, Any] | None,
|
||||
log: GmailWatcherLog,
|
||||
on_skipped: Callable[[], None] | None = None,
|
||||
starter: Callable[[dict[str, Any] | None], GmailWatcherResult] = start_gmail_watcher,
|
||||
) -> None:
|
||||
"""Skip entirely when ``OCLAW_SKIP_GMAIL_WATCHER`` or ``OPENCLAW_SKIP_GMAIL_WATCHER`` is truthy."""
|
||||
if _skip_gmail_watcher_env():
|
||||
if on_skipped:
|
||||
on_skipped()
|
||||
return
|
||||
|
||||
try:
|
||||
res = starter(cfg)
|
||||
if bool(res.started):
|
||||
log.info("gmail watcher started")
|
||||
return
|
||||
reason = str(res.reason or "").strip()
|
||||
if reason and reason not in {
|
||||
"hooks not enabled",
|
||||
"no gmail account configured",
|
||||
"gmail watcher runtime not implemented (Python)",
|
||||
}:
|
||||
log.warn(f"gmail watcher not started: {reason}")
|
||||
except Exception as exc:
|
||||
log.error(f"gmail watcher failed to start: {exc}")
|
||||
|
||||
195
runtime/hooks/hook_manifest_core.py
Normal file
195
runtime/hooks/hook_manifest_core.py
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HookInstallSpec:
|
||||
kind: str
|
||||
id: str | None = None
|
||||
label: str | None = None
|
||||
package: str | None = None
|
||||
repository: str | None = None
|
||||
bins: tuple[str, ...] = ()
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {"kind": self.kind}
|
||||
if self.id:
|
||||
out["id"] = self.id
|
||||
if self.label:
|
||||
out["label"] = self.label
|
||||
if self.package:
|
||||
out["package"] = self.package
|
||||
if self.repository:
|
||||
out["repository"] = self.repository
|
||||
if self.bins:
|
||||
out["bins"] = list(self.bins)
|
||||
return out
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HookRequiresSpec:
|
||||
bins: tuple[str, ...] = ()
|
||||
any_bins: tuple[str, ...] = ()
|
||||
env: tuple[str, ...] = ()
|
||||
config: tuple[str, ...] = ()
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {}
|
||||
if self.bins:
|
||||
out["bins"] = list(self.bins)
|
||||
if self.any_bins:
|
||||
out["anyBins"] = list(self.any_bins)
|
||||
if self.env:
|
||||
out["env"] = list(self.env)
|
||||
if self.config:
|
||||
out["config"] = list(self.config)
|
||||
return out
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HookMetadataSpec:
|
||||
events: tuple[str, ...] = ()
|
||||
always: bool | None = None
|
||||
emoji: str | None = None
|
||||
homepage: str | None = None
|
||||
hook_key: str | None = None
|
||||
export: str | None = None
|
||||
os: tuple[str, ...] = ()
|
||||
requires: HookRequiresSpec | None = None
|
||||
install: tuple[HookInstallSpec, ...] = ()
|
||||
hook_mode: str | None = None
|
||||
node_script: bool | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {"events": list(self.events)}
|
||||
if self.always is not None:
|
||||
out["always"] = bool(self.always)
|
||||
if self.emoji:
|
||||
out["emoji"] = self.emoji
|
||||
if self.homepage:
|
||||
out["homepage"] = self.homepage
|
||||
if self.hook_key:
|
||||
out["hookKey"] = self.hook_key
|
||||
if self.export:
|
||||
out["export"] = self.export
|
||||
if self.os:
|
||||
out["os"] = list(self.os)
|
||||
if self.requires:
|
||||
req = self.requires.as_dict()
|
||||
if req:
|
||||
out["requires"] = req
|
||||
if self.install:
|
||||
out["install"] = [row.as_dict() for row in self.install]
|
||||
if self.hook_mode is not None:
|
||||
out["hookMode"] = str(self.hook_mode)
|
||||
if self.node_script is not None:
|
||||
out["nodeScript"] = bool(self.node_script)
|
||||
return out
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParsedHookManifest:
|
||||
name: str
|
||||
description: str
|
||||
invocation_enabled: bool
|
||||
metadata: HookMetadataSpec = field(default_factory=HookMetadataSpec)
|
||||
|
||||
|
||||
def _read_str(value: Any) -> str | None:
|
||||
if isinstance(value, str):
|
||||
s = value.strip()
|
||||
return s if s else None
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_str_list(value: Any) -> tuple[str, ...]:
|
||||
if not isinstance(value, list):
|
||||
return ()
|
||||
out: list[str] = []
|
||||
for row in value:
|
||||
s = _read_str(row)
|
||||
if s:
|
||||
out.append(s)
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def _parse_install_spec(row: Any) -> HookInstallSpec | None:
|
||||
if not isinstance(row, dict):
|
||||
return None
|
||||
kind = (_read_str(row.get("kind")) or "").lower()
|
||||
if kind not in {"bundled", "npm", "git"}:
|
||||
return None
|
||||
return HookInstallSpec(
|
||||
kind=kind,
|
||||
id=_read_str(row.get("id")),
|
||||
label=_read_str(row.get("label")),
|
||||
package=_read_str(row.get("package")),
|
||||
repository=_read_str(row.get("repository")),
|
||||
bins=_normalize_str_list(row.get("bins")),
|
||||
)
|
||||
|
||||
|
||||
def _resolve_raw_oclaw_metadata(frontmatter: dict[str, Any]) -> dict[str, Any]:
|
||||
meta = frontmatter.get("metadata")
|
||||
if isinstance(meta, str):
|
||||
try:
|
||||
parsed = json.loads(meta)
|
||||
meta = parsed if isinstance(parsed, dict) else None
|
||||
except Exception:
|
||||
meta = None
|
||||
if not isinstance(meta, dict):
|
||||
return {}
|
||||
oc = meta.get("oclaw")
|
||||
return oc if isinstance(oc, dict) else {}
|
||||
|
||||
|
||||
def parse_hook_manifest(*, frontmatter: dict[str, Any], default_name: str) -> ParsedHookManifest:
|
||||
name = _read_str(frontmatter.get("name")) or default_name
|
||||
description = _read_str(frontmatter.get("description")) or ""
|
||||
enabled_raw = frontmatter.get("enabled")
|
||||
invocation_enabled = bool(enabled_raw) if isinstance(enabled_raw, bool) else True
|
||||
|
||||
oc = _resolve_raw_oclaw_metadata(frontmatter)
|
||||
requires_obj = oc.get("requires") if isinstance(oc.get("requires"), dict) else {}
|
||||
requires = HookRequiresSpec(
|
||||
bins=_normalize_str_list(requires_obj.get("bins")),
|
||||
any_bins=_normalize_str_list(requires_obj.get("anyBins")),
|
||||
env=_normalize_str_list(requires_obj.get("env")),
|
||||
config=_normalize_str_list(requires_obj.get("config")),
|
||||
)
|
||||
install_rows: list[HookInstallSpec] = []
|
||||
if isinstance(oc.get("install"), list):
|
||||
for row in oc["install"]:
|
||||
parsed = _parse_install_spec(row)
|
||||
if parsed:
|
||||
install_rows.append(parsed)
|
||||
|
||||
node_script: bool | None
|
||||
if "nodeScript" in oc:
|
||||
node_script = bool(oc.get("nodeScript"))
|
||||
else:
|
||||
node_script = None
|
||||
|
||||
metadata = HookMetadataSpec(
|
||||
events=_normalize_str_list(oc.get("events")),
|
||||
always=bool(oc["always"]) if isinstance(oc.get("always"), bool) else None,
|
||||
emoji=_read_str(oc.get("emoji")),
|
||||
homepage=_read_str(oc.get("homepage")),
|
||||
hook_key=_read_str(oc.get("hookKey")),
|
||||
export=_read_str(oc.get("export")),
|
||||
os=_normalize_str_list(oc.get("os")),
|
||||
requires=requires if requires.as_dict() else None,
|
||||
install=tuple(install_rows),
|
||||
hook_mode=_read_str(oc.get("hookMode")),
|
||||
node_script=node_script,
|
||||
)
|
||||
return ParsedHookManifest(
|
||||
name=name,
|
||||
description=description,
|
||||
invocation_enabled=invocation_enabled,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
104
runtime/hooks/hook_types.py
Normal file
104
runtime/hooks/hook_types.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal, TypedDict
|
||||
|
||||
HookSource = Literal["oclaw-bundled", "oclaw-plugin", "oclaw-managed", "oclaw-workspace"]
|
||||
_HOOK_SOURCES: tuple[HookSource, ...] = (
|
||||
"oclaw-bundled",
|
||||
"oclaw-plugin",
|
||||
"oclaw-managed",
|
||||
"oclaw-workspace",
|
||||
)
|
||||
|
||||
|
||||
class HookEntryDict(TypedDict, total=False):
|
||||
hook: dict[str, Any]
|
||||
frontmatter: dict[str, Any]
|
||||
metadata: dict[str, Any]
|
||||
invocation: dict[str, Any]
|
||||
|
||||
|
||||
class HookRemoteEligibility(TypedDict, total=False):
|
||||
platforms: list[str]
|
||||
hasBin: Any
|
||||
hasAnyBin: Any
|
||||
note: str
|
||||
|
||||
|
||||
class HookEligibilityContext(TypedDict, total=False):
|
||||
remote: HookRemoteEligibility
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HookRef:
|
||||
name: str
|
||||
description: str
|
||||
source: HookSource
|
||||
pluginId: str | None
|
||||
filePath: str
|
||||
baseDir: str
|
||||
handlerPath: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HookInvocation:
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HookEntry:
|
||||
hook: HookRef
|
||||
frontmatter: dict[str, Any]
|
||||
metadata: dict[str, Any]
|
||||
invocation: HookInvocation
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"hook": {
|
||||
"name": self.hook.name,
|
||||
"description": self.hook.description,
|
||||
"source": self.hook.source,
|
||||
"pluginId": self.hook.pluginId,
|
||||
"filePath": self.hook.filePath,
|
||||
"baseDir": self.hook.baseDir,
|
||||
"handlerPath": self.hook.handlerPath,
|
||||
},
|
||||
"frontmatter": dict(self.frontmatter),
|
||||
"metadata": dict(self.metadata),
|
||||
"invocation": {"enabled": bool(self.invocation.enabled)},
|
||||
}
|
||||
|
||||
|
||||
def ensure_entry_dict(entry: HookEntry | HookEntryDict) -> dict[str, Any]:
|
||||
return entry.as_dict() if isinstance(entry, HookEntry) else dict(entry)
|
||||
|
||||
|
||||
def _normalize_source(value: Any) -> HookSource:
|
||||
s = str(value or "").strip()
|
||||
if s in _HOOK_SOURCES:
|
||||
return s
|
||||
return "oclaw-managed"
|
||||
|
||||
|
||||
def ensure_hook_entry(entry: HookEntry | HookEntryDict) -> HookEntry:
|
||||
if isinstance(entry, HookEntry):
|
||||
return entry
|
||||
row = dict(entry or {})
|
||||
hook_raw = row.get("hook") if isinstance(row.get("hook"), dict) else {}
|
||||
inv_raw = row.get("invocation") if isinstance(row.get("invocation"), dict) else {}
|
||||
return HookEntry(
|
||||
hook=HookRef(
|
||||
name=str(hook_raw.get("name") or ""),
|
||||
description=str(hook_raw.get("description") or ""),
|
||||
source=_normalize_source(hook_raw.get("source")),
|
||||
pluginId=(str(hook_raw.get("pluginId")) if hook_raw.get("pluginId") is not None else None),
|
||||
filePath=str(hook_raw.get("filePath") or ""),
|
||||
baseDir=str(hook_raw.get("baseDir") or ""),
|
||||
handlerPath=str(hook_raw.get("handlerPath") or ""),
|
||||
),
|
||||
frontmatter=dict(row.get("frontmatter") or {}),
|
||||
metadata=dict(row.get("metadata") or {}),
|
||||
invocation=HookInvocation(enabled=bool(inv_raw.get("enabled")) if "enabled" in inv_raw else True),
|
||||
)
|
||||
|
||||
125
runtime/hooks/hooks_status.py
Normal file
125
runtime/hooks/hooks_status.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from typing import Any
|
||||
|
||||
from .config import should_include_hook
|
||||
from .hook_types import HookEligibilityContext
|
||||
from .policy import resolve_hook_enable_state
|
||||
from .workspace import load_workspace_hook_entries
|
||||
|
||||
|
||||
def _select_install_suggestion(
|
||||
*, install_options: list[dict[str, Any]], missing_bins: list[str]
|
||||
) -> dict[str, Any] | None:
|
||||
if not install_options:
|
||||
return None
|
||||
if not missing_bins:
|
||||
return dict(install_options[0])
|
||||
|
||||
missing = set(missing_bins)
|
||||
best: tuple[int, int, dict[str, Any]] | None = None
|
||||
for idx, row in enumerate(install_options):
|
||||
bins = [str(x).strip() for x in list(row.get("bins") or []) if str(x).strip()]
|
||||
overlap = len(missing.intersection(set(bins)))
|
||||
# Sort by overlap desc, then idx asc (stable preference for first declared option).
|
||||
score = (overlap, -idx)
|
||||
if best is None or score > (best[0], best[1]):
|
||||
best = (score[0], score[1], row)
|
||||
return dict(best[2]) if best else dict(install_options[0])
|
||||
|
||||
|
||||
def build_workspace_hook_status(
|
||||
workspace_dir: str,
|
||||
*,
|
||||
config: dict[str, Any] | None = None,
|
||||
managed_hooks_dir: str | None = None,
|
||||
bundled_hooks_dir: str | None = None,
|
||||
extra_dirs: list[str] | None = None,
|
||||
eligibility: HookEligibilityContext | None = None,
|
||||
) -> dict[str, Any]:
|
||||
entries = load_workspace_hook_entries(
|
||||
workspace_dir,
|
||||
config=config,
|
||||
managed_hooks_dir=managed_hooks_dir,
|
||||
bundled_hooks_dir=bundled_hooks_dir,
|
||||
extra_dirs=extra_dirs,
|
||||
)
|
||||
rows: list[dict[str, Any]] = []
|
||||
summary = {
|
||||
"discovered_total": 0,
|
||||
"enabled_by_config_total": 0,
|
||||
"eligible_total": 0,
|
||||
"loadable_total": 0,
|
||||
"missing_bins_total": 0,
|
||||
"blocked_by_reason": {},
|
||||
}
|
||||
|
||||
for entry in entries:
|
||||
summary["discovered_total"] += 1
|
||||
state = resolve_hook_enable_state(entry, config)
|
||||
enabled = bool(state.get("enabled"))
|
||||
if enabled:
|
||||
summary["enabled_by_config_total"] += 1
|
||||
eligible = should_include_hook(entry=entry, config=config, eligibility=eligibility)
|
||||
if eligible:
|
||||
summary["eligible_total"] += 1
|
||||
loadable = enabled and eligible
|
||||
if loadable:
|
||||
summary["loadable_total"] += 1
|
||||
reason = str(state.get("reason") or "")
|
||||
if not loadable:
|
||||
blocked = reason or ("missing requirements" if enabled else "disabled")
|
||||
summary["blocked_by_reason"][blocked] = int(summary["blocked_by_reason"].get(blocked) or 0) + 1
|
||||
|
||||
md = entry.metadata or {}
|
||||
req = md.get("requires") if isinstance(md.get("requires"), dict) else {}
|
||||
required_bins = [str(x).strip() for x in list(req.get("bins") or []) if str(x).strip()]
|
||||
missing_bins = [b for b in required_bins if not shutil.which(b)]
|
||||
if missing_bins:
|
||||
summary["missing_bins_total"] += len(missing_bins)
|
||||
|
||||
install_rows = md.get("install") if isinstance(md.get("install"), list) else []
|
||||
install_options: list[dict[str, Any]] = []
|
||||
for idx, row in enumerate(install_rows):
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
kind = str(row.get("kind") or "").strip()
|
||||
if not kind:
|
||||
continue
|
||||
install_options.append(
|
||||
{
|
||||
"id": str(row.get("id") or f"{kind}-{idx}"),
|
||||
"kind": kind,
|
||||
"label": str(row.get("label") or ""),
|
||||
"bins": [str(x).strip() for x in list(row.get("bins") or []) if str(x).strip()],
|
||||
}
|
||||
)
|
||||
|
||||
rows.append(
|
||||
{
|
||||
"name": entry.hook.name,
|
||||
"source": entry.hook.source,
|
||||
"plugin_id": entry.hook.pluginId,
|
||||
"hook_key": str((entry.metadata or {}).get("hookKey") or entry.hook.name),
|
||||
"events": list((entry.metadata or {}).get("events") or []),
|
||||
"enabled_by_config": enabled,
|
||||
"eligible": bool(eligible),
|
||||
"loadable": bool(loadable),
|
||||
"blocked_reason": "" if loadable else (reason or "missing requirements"),
|
||||
"required_bins": required_bins,
|
||||
"missing_bins": missing_bins,
|
||||
"install_options": install_options,
|
||||
"install_suggestion": _select_install_suggestion(
|
||||
install_options=install_options,
|
||||
missing_bins=missing_bins,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"workspace_dir": workspace_dir,
|
||||
"summary": summary,
|
||||
"hooks": rows,
|
||||
}
|
||||
|
||||
|
|
@ -74,7 +74,10 @@ def create_hook_event(
|
|||
session_key: str,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
) -> HookEvent:
|
||||
return HookEvent(type=event_type, action=action, sessionKey=session_key, context=context or {})
|
||||
# Must not use `context or {}` — a caller-supplied empty dict is falsy and would be replaced.
|
||||
return HookEvent(
|
||||
type=event_type, action=action, sessionKey=session_key, context={} if context is None else context
|
||||
)
|
||||
|
||||
|
||||
async def trigger_hook(event: HookEvent) -> None:
|
||||
|
|
|
|||
56
runtime/hooks/js_hook_runner.mjs
Normal file
56
runtime/hooks/js_hook_runner.mjs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* node js_hook_runner.mjs <absolute-handler> [exportName]
|
||||
* stdin: JSON { type, action, sessionKey, context, messages?, timestamp? }
|
||||
* stdout: JSON { "context"?: object }
|
||||
* Loads .mjs / .cjs (and ESM .js if passed) with dynamic import or createRequire.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const handlerPath = process.argv[2];
|
||||
const exportName = (process.argv[3] || "default").trim();
|
||||
|
||||
if (!handlerPath) {
|
||||
console.error("oclaw: js_hook_runner: missing handler path");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const abs = path.resolve(handlerPath);
|
||||
const raw = readFileSync(0, "utf-8");
|
||||
const data = JSON.parse(raw);
|
||||
const event = {
|
||||
type: data.type,
|
||||
action: data.action,
|
||||
sessionKey: data.sessionKey,
|
||||
context: typeof data.context === "object" && data.context ? data.context : {},
|
||||
messages: Array.isArray(data.messages) ? data.messages : [],
|
||||
timestamp: data.timestamp,
|
||||
};
|
||||
|
||||
const ext = path.extname(abs).toLowerCase();
|
||||
let mod;
|
||||
if (ext === ".cjs") {
|
||||
const require = createRequire(import.meta.url);
|
||||
mod = require(abs);
|
||||
} else {
|
||||
mod = await import(pathToFileURL(abs).href);
|
||||
}
|
||||
|
||||
const modRec = mod && typeof mod === "object" ? mod : {};
|
||||
let fn;
|
||||
if (exportName && exportName !== "default" && typeof modRec[exportName] === "function") {
|
||||
fn = modRec[exportName];
|
||||
} else {
|
||||
fn = modRec.default ?? modRec.handle ?? modRec.handler;
|
||||
}
|
||||
if (typeof fn !== "function") {
|
||||
console.error("oclaw: js_hook_runner: no function export in", abs, "export", exportName);
|
||||
process.exit(3);
|
||||
}
|
||||
const r = fn(event);
|
||||
if (r && typeof r.then === "function") {
|
||||
await r;
|
||||
}
|
||||
process.stdout.write(JSON.stringify({ context: event.context }));
|
||||
|
|
@ -1,13 +1,31 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
from .config import should_include_hook_compat
|
||||
from .hook_types import HookEligibilityContext, HookEntry, ensure_hook_entry
|
||||
from .internal_hooks import HookHandler, register_hook, unregister_hook
|
||||
from .policy import resolve_hook_enable_state, resolve_hook_config
|
||||
from .workspace import HookEntry, load_workspace_hook_entries
|
||||
from .script_handlers import build_script_hook_handler
|
||||
from .workspace import load_workspace_hook_entries
|
||||
|
||||
|
||||
_loaded_hook_registrations: list[tuple[str, HookHandler]] = []
|
||||
|
||||
|
||||
def _reset_loaded_internal_hooks() -> None:
|
||||
while _loaded_hook_registrations:
|
||||
event_key, handler = _loaded_hook_registrations.pop()
|
||||
unregister_hook(event_key, handler)
|
||||
|
||||
|
||||
def _is_within_base(handler_path: str, base_dir: str) -> bool:
|
||||
try:
|
||||
Path(handler_path).resolve().relative_to(Path(base_dir).resolve())
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _load_module_from_path(module_path: str, unique_key: str) -> Optional[object]:
|
||||
|
|
@ -26,10 +44,43 @@ def _load_module_from_path(module_path: str, unique_key: str) -> Optional[object
|
|||
return mod
|
||||
|
||||
|
||||
def _resolve_legacy_handlers(config: dict[str, Any]) -> list[dict[str, str]]:
|
||||
rows = (((config.get("hooks") or {}).get("internal") or {}).get("handlers"))
|
||||
if not isinstance(rows, list):
|
||||
return []
|
||||
out: list[dict[str, str]] = []
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
event = str(row.get("event") or "").strip()
|
||||
module = str(row.get("module") or "").strip()
|
||||
export = str(row.get("export") or "default").strip() or "default"
|
||||
if not event or not module:
|
||||
continue
|
||||
out.append({"event": event, "module": module, "export": export})
|
||||
return out
|
||||
|
||||
|
||||
def _resolve_workspace_module_path(*, workspace_dir: str, raw_module: str) -> Optional[str]:
|
||||
if not raw_module or raw_module.startswith("/") or raw_module.startswith("\\"):
|
||||
return None
|
||||
ws = Path(workspace_dir).resolve()
|
||||
module_path = (ws / raw_module).resolve()
|
||||
try:
|
||||
module_path.relative_to(ws)
|
||||
except Exception:
|
||||
return None
|
||||
if not module_path.exists() or not module_path.is_file():
|
||||
return None
|
||||
return str(module_path)
|
||||
|
||||
|
||||
def _resolve_handler(mod: object, export_name: str) -> Optional[HookHandler]:
|
||||
handler = getattr(mod, export_name, None)
|
||||
if callable(handler):
|
||||
return handler # type: ignore[return-value]
|
||||
normalized = str(export_name or "").strip()
|
||||
if normalized and normalized != "default":
|
||||
handler = getattr(mod, normalized, None)
|
||||
if callable(handler):
|
||||
return handler # type: ignore[return-value]
|
||||
# fallback: common names
|
||||
for candidate in ("handle", "handler", "main"):
|
||||
h = getattr(mod, candidate, None)
|
||||
|
|
@ -39,29 +90,41 @@ def _resolve_handler(mod: object, export_name: str) -> Optional[HookHandler]:
|
|||
|
||||
|
||||
def load_internal_hooks(
|
||||
config: Dict[str, Any],
|
||||
config: dict[str, Any],
|
||||
workspace_dir: str,
|
||||
*,
|
||||
managed_hooks_dir: Optional[str] = None,
|
||||
bundled_hooks_dir: Optional[str] = None,
|
||||
eligibility: Optional[HookEligibilityContext] = None,
|
||||
) -> int:
|
||||
"""
|
||||
Discover python hooks and register them into the in-process registry.
|
||||
Discover hooks and register them into the in-process registry.
|
||||
|
||||
Hook file layout:
|
||||
Handler files (first match wins), see ``workspace._handler_candidates`` for the full list.
|
||||
|
||||
- ``.py``: in-process import (``handle`` / ``handler`` / ``main`` or ``metadata.oclaw.export``)
|
||||
- ``.ts`` / ``.mts`` / ``.cts`` (default): ``tsx`` + ``ts_hook_runner.ts`` (import ``default`` / ``handle`` or ``export``)
|
||||
- ``.mjs`` / ``.cjs`` (default): ``node`` + ``js_hook_runner.mjs`` (``import`` / ``require`` + same exports)
|
||||
- ``.sh`` / ``.bash``: subprocess: JSON on stdin, optional JSON on stdout (``context`` merge)
|
||||
- ``metadata.oclaw.hookMode: "script"`` (or legacy ``nodeScript: true``): run ``.ts`` / ``.mjs`` / ``.cjs`` as
|
||||
**stdin/stdout scripts** (no import runner) — use ``node`` for JS, ``tsx`` for TS
|
||||
|
||||
Hook layout (unchanged):
|
||||
<hookDir>/HOOK.md (YAML frontmatter with oclaw metadata)
|
||||
<hookDir>/handler.py (or index.py)
|
||||
|
||||
Metadata fields used:
|
||||
metadata.oclaw.events: ["type", "type:action", ...]
|
||||
metadata.oclaw.export: handler function name (default: "default" -> we map to "handle")
|
||||
"""
|
||||
_reset_loaded_internal_hooks()
|
||||
|
||||
# Hook system is on by default; only skip when explicitly disabled.
|
||||
if (((config.get("hooks") or {}).get("internal") or {}).get("enabled")) is False:
|
||||
return 0
|
||||
|
||||
entries = load_workspace_hook_entries(
|
||||
workspace_dir,
|
||||
config=config,
|
||||
managed_hooks_dir=managed_hooks_dir,
|
||||
bundled_hooks_dir=bundled_hooks_dir,
|
||||
extra_dirs=(((config.get("hooks") or {}).get("internal") or {}).get("load") or {}).get("extraDirs"),
|
||||
|
|
@ -69,35 +132,74 @@ def load_internal_hooks(
|
|||
|
||||
loaded = 0
|
||||
for idx, entry in enumerate(entries):
|
||||
state = resolve_hook_enable_state(entry, config)
|
||||
if not state.get("enabled"):
|
||||
if not should_include_hook_compat(entry=entry, config=config, eligibility=eligibility):
|
||||
continue
|
||||
entry_obj = ensure_hook_entry(entry)
|
||||
|
||||
md = entry.get("metadata") or {}
|
||||
md = entry_obj.metadata or {}
|
||||
events = md.get("events") if isinstance(md, dict) else None
|
||||
if not isinstance(events, list) or not events:
|
||||
continue
|
||||
|
||||
hook = entry.get("hook") or {}
|
||||
handler_path = hook.get("handlerPath")
|
||||
handler_path = entry_obj.hook.handlerPath
|
||||
base_dir = entry_obj.hook.baseDir
|
||||
if not isinstance(handler_path, str) or not handler_path:
|
||||
continue
|
||||
if not isinstance(base_dir, str) or not base_dir.strip():
|
||||
continue
|
||||
if not _is_within_base(handler_path, base_dir):
|
||||
continue
|
||||
|
||||
export_name = md.get("export") if isinstance(md, dict) else None
|
||||
if not isinstance(export_name, str) or not export_name.strip():
|
||||
export_name = "handle"
|
||||
export_name = "default"
|
||||
|
||||
mod = _load_module_from_path(handler_path, unique_key=str(idx))
|
||||
if mod is None:
|
||||
continue
|
||||
|
||||
handler = _resolve_handler(mod, export_name)
|
||||
suffix = Path(handler_path).suffix.lower()
|
||||
handler: Optional[HookHandler] = None
|
||||
if suffix in {".py"}:
|
||||
mod = _load_module_from_path(handler_path, unique_key=str(idx))
|
||||
if mod is None:
|
||||
continue
|
||||
handler = _resolve_handler(mod, export_name)
|
||||
else:
|
||||
oclaw_dict: dict[str, Any] = {}
|
||||
if isinstance(md, dict):
|
||||
for key in ("hookMode", "nodeScript"):
|
||||
if key in md:
|
||||
oclaw_dict[key] = md[key]
|
||||
handler = build_script_hook_handler(
|
||||
handler_path=handler_path,
|
||||
base_dir=base_dir,
|
||||
suffix=suffix,
|
||||
export_name=export_name,
|
||||
oclaw=oclaw_dict,
|
||||
)
|
||||
if handler is None:
|
||||
continue
|
||||
|
||||
for event_key in events:
|
||||
if isinstance(event_key, str) and event_key.strip():
|
||||
register_hook(event_key.strip(), handler)
|
||||
ek = event_key.strip()
|
||||
register_hook(ek, handler)
|
||||
_loaded_hook_registrations.append((ek, handler))
|
||||
loaded += 1
|
||||
|
||||
# Legacy config handlers (hooks.internal.handlers)
|
||||
for j, row in enumerate(_resolve_legacy_handlers(config)):
|
||||
safe_path = _resolve_workspace_module_path(workspace_dir=workspace_dir, raw_module=row["module"])
|
||||
if not safe_path:
|
||||
continue
|
||||
mod = _load_module_from_path(safe_path, unique_key=f"legacy_{j}")
|
||||
if mod is None:
|
||||
continue
|
||||
handler = _resolve_handler(mod, row.get("export") or "default")
|
||||
if handler is None:
|
||||
continue
|
||||
ev = row.get("event") or ""
|
||||
if not ev:
|
||||
continue
|
||||
register_hook(ev, handler)
|
||||
_loaded_hook_registrations.append((ev, handler))
|
||||
loaded += 1
|
||||
|
||||
return loaded
|
||||
|
|
|
|||
39
runtime/hooks/merge_skill_hook_dirs.py
Normal file
39
runtime/hooks/merge_skill_hook_dirs.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from oclaw.runtime.skills import discover_workspace_skill_manifests
|
||||
|
||||
|
||||
def merge_skill_hook_extra_dirs_into_config(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Append skill package ``<skillDir>/hooks`` directories to ``hooks.internal.load.extraDirs``.
|
||||
|
||||
Matches ``initialize_hooks_runtime`` so CLI / status reports see the same hook set as the agent.
|
||||
"""
|
||||
resolved_cfg = dict(cfg)
|
||||
extra_dirs: list[str] = []
|
||||
try:
|
||||
for m in discover_workspace_skill_manifests():
|
||||
d = (Path(str(m.skill_dir or "")) / "hooks").resolve()
|
||||
if d.exists() and d.is_dir():
|
||||
extra_dirs.append(str(d))
|
||||
except Exception:
|
||||
extra_dirs = []
|
||||
if not extra_dirs:
|
||||
return resolved_cfg
|
||||
|
||||
hooks_cfg = dict((resolved_cfg.get("hooks") or {})) if isinstance(resolved_cfg.get("hooks"), dict) else {}
|
||||
internal = dict((hooks_cfg.get("internal") or {})) if isinstance(hooks_cfg.get("internal"), dict) else {}
|
||||
load = dict((internal.get("load") or {})) if isinstance(internal.get("load"), dict) else {}
|
||||
prev = load.get("extraDirs")
|
||||
merged: list[str] = []
|
||||
if isinstance(prev, list):
|
||||
merged.extend([str(x) for x in prev if str(x).strip()])
|
||||
merged.extend([x for x in extra_dirs if x and x not in set(merged)])
|
||||
load["extraDirs"] = merged
|
||||
internal["load"] = load
|
||||
hooks_cfg["internal"] = internal
|
||||
resolved_cfg["hooks"] = hooks_cfg
|
||||
return resolved_cfg
|
||||
|
|
@ -4,8 +4,7 @@ from dataclasses import dataclass
|
|||
from typing import Any, Callable, Dict, List, Literal, Optional, Sequence, Tuple
|
||||
|
||||
from .frontmatter import resolve_hook_key
|
||||
|
||||
HookSource = Literal["oclaw-bundled", "oclaw-plugin", "oclaw-managed", "oclaw-workspace"]
|
||||
from .hook_types import HookEntry, HookSource, ensure_entry_dict, ensure_hook_entry
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -63,22 +62,27 @@ def resolve_hook_config(config: Optional[Dict[str, Any]], hook_key: str) -> Opti
|
|||
return entry if isinstance(entry, dict) else None
|
||||
|
||||
|
||||
def resolve_hook_enable_state(entry: Dict[str, Any], config: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
def resolve_hook_enable_state(entry: HookEntry | Dict[str, Any], config: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""
|
||||
Returns { enabled: bool, reason?: str }
|
||||
"""
|
||||
hook = entry.get("hook") if isinstance(entry, dict) else None
|
||||
entry_dict = ensure_entry_dict(entry)
|
||||
hook = entry_dict.get("hook") if isinstance(entry_dict, dict) else None
|
||||
name = (hook or {}).get("name") if isinstance(hook, dict) else None
|
||||
source = (hook or {}).get("source") if isinstance(hook, dict) else None
|
||||
if not isinstance(name, str) or not isinstance(source, str):
|
||||
return {"enabled": False, "reason": "invalid hook entry"}
|
||||
|
||||
hook_key = resolve_hook_key(name, entry)
|
||||
hook_key = resolve_hook_key(name, entry_dict)
|
||||
hook_cfg = resolve_hook_config(config, hook_key)
|
||||
invocation = entry_dict.get("invocation") if isinstance(entry_dict.get("invocation"), dict) else {}
|
||||
|
||||
if source == "oclaw-plugin":
|
||||
return {"enabled": True}
|
||||
|
||||
if invocation.get("enabled") is False:
|
||||
return {"enabled": False, "reason": "disabled by hook invocation policy"}
|
||||
|
||||
if isinstance(hook_cfg, dict) and hook_cfg.get("enabled") is False:
|
||||
return {"enabled": False, "reason": "disabled in config"}
|
||||
|
||||
|
|
@ -89,9 +93,11 @@ def resolve_hook_enable_state(entry: Dict[str, Any], config: Optional[Dict[str,
|
|||
return {"enabled": True}
|
||||
|
||||
|
||||
def _can_override(candidate: Dict[str, Any], existing: Dict[str, Any]) -> bool:
|
||||
c_source = ((candidate.get("hook") or {}).get("source")) if isinstance(candidate, dict) else None
|
||||
e_source = ((existing.get("hook") or {}).get("source")) if isinstance(existing, dict) else None
|
||||
def _can_override(candidate: HookEntry | Dict[str, Any], existing: HookEntry | Dict[str, Any]) -> bool:
|
||||
c_dict = ensure_entry_dict(candidate)
|
||||
e_dict = ensure_entry_dict(existing)
|
||||
c_source = ((c_dict.get("hook") or {}).get("source")) if isinstance(c_dict, dict) else None
|
||||
e_source = ((e_dict.get("hook") or {}).get("source")) if isinstance(e_dict, dict) else None
|
||||
if c_source not in HOOK_SOURCE_POLICIES or e_source not in HOOK_SOURCE_POLICIES:
|
||||
return False
|
||||
c_pol = get_hook_source_policy(c_source) # type: ignore[arg-type]
|
||||
|
|
@ -100,26 +106,41 @@ def _can_override(candidate: Dict[str, Any], existing: Dict[str, Any]) -> bool:
|
|||
|
||||
|
||||
def resolve_hook_entries(
|
||||
entries: Sequence[Dict[str, Any]],
|
||||
entries: Sequence[HookEntry | Dict[str, Any]],
|
||||
on_collision_ignored: Optional[Callable[[Dict[str, Any]], None]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
) -> List[HookEntry]:
|
||||
ordered = sorted(
|
||||
list(enumerate(entries)),
|
||||
key=lambda x: (get_hook_source_policy(x[1]["hook"]["source"]).precedence, x[0]), # type: ignore[index]
|
||||
key=lambda x: (
|
||||
get_hook_source_policy(
|
||||
str((ensure_entry_dict(x[1]).get("hook") or {}).get("source") or "oclaw-managed") # type: ignore[arg-type]
|
||||
).precedence,
|
||||
x[0],
|
||||
),
|
||||
)
|
||||
merged: Dict[str, Dict[str, Any]] = {}
|
||||
merged: Dict[str, HookEntry] = {}
|
||||
for _, entry in ordered:
|
||||
name = entry.get("hook", {}).get("name")
|
||||
name = ensure_entry_dict(entry).get("hook", {}).get("name")
|
||||
if not isinstance(name, str):
|
||||
continue
|
||||
existing = merged.get(name)
|
||||
if not existing:
|
||||
merged[name] = entry
|
||||
merged[name] = ensure_hook_entry(entry)
|
||||
continue
|
||||
if _can_override(entry, existing):
|
||||
merged[name] = entry
|
||||
merged[name] = ensure_hook_entry(entry)
|
||||
continue
|
||||
if on_collision_ignored:
|
||||
on_collision_ignored({"name": name, "kept": existing, "ignored": entry})
|
||||
on_collision_ignored(
|
||||
{"name": name, "kept": ensure_entry_dict(existing), "ignored": ensure_entry_dict(entry)}
|
||||
)
|
||||
return list(merged.values())
|
||||
|
||||
|
||||
def resolve_hook_entries_compat(
|
||||
entries: Sequence[HookEntry | Dict[str, Any]],
|
||||
on_collision_ignored: Optional[Callable[[Dict[str, Any]], None]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
resolved = resolve_hook_entries(entries, on_collision_ignored=on_collision_ignored)
|
||||
return [ensure_entry_dict(e) for e in resolved]
|
||||
|
||||
|
|
|
|||
247
runtime/hooks/script_handlers.py
Normal file
247
runtime/hooks/script_handlers.py
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from .internal_hooks import HookEvent, HookHandler
|
||||
|
||||
log = logging.getLogger("oclaw.hooks")
|
||||
|
||||
# Max wall-clock for external hook child processes
|
||||
_HOOK_SUBPROCESS_TIMEOUT_S = 120.0
|
||||
|
||||
|
||||
def _runner_ts_path() -> Path:
|
||||
return Path(__file__).resolve().parent / "ts_hook_runner.ts"
|
||||
|
||||
|
||||
def _runner_js_path() -> Path:
|
||||
return Path(__file__).resolve().parent / "js_hook_runner.mjs"
|
||||
|
||||
|
||||
def _is_script_mode(oclaw: dict[str, Any] | None) -> bool:
|
||||
o = oclaw or {}
|
||||
if o.get("hookMode") == "script":
|
||||
return True
|
||||
if o.get("nodeScript") is True:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _event_payload(event: HookEvent) -> dict[str, Any]:
|
||||
return {
|
||||
"type": event.type,
|
||||
"action": event.action,
|
||||
"sessionKey": event.sessionKey,
|
||||
"context": dict(event.context) if isinstance(event.context, dict) else {},
|
||||
"messages": list(getattr(event, "messages", []) or []),
|
||||
"timestamp": event.timestamp.isoformat() if getattr(event, "timestamp", None) else None,
|
||||
}
|
||||
|
||||
|
||||
def _merge_stdout_into_context(event: HookEvent, raw: str) -> None:
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
return
|
||||
try:
|
||||
out = json.loads(text)
|
||||
except Exception:
|
||||
log.warning("Hook subprocess stdout is not valid JSON: %r", text[:200])
|
||||
return
|
||||
if not isinstance(out, dict):
|
||||
return
|
||||
ctx = out.get("context")
|
||||
if isinstance(ctx, dict) and isinstance(event.context, dict):
|
||||
event.context.update(ctx)
|
||||
|
||||
|
||||
def _sh_command(script: Path) -> list[str] | None:
|
||||
p = str(script)
|
||||
if not script.is_file():
|
||||
return None
|
||||
if os.name == "nt":
|
||||
bash = shutil.which("bash")
|
||||
if bash:
|
||||
return [bash, p]
|
||||
wsl = shutil.which("wsl")
|
||||
if wsl:
|
||||
return [wsl, "bash", p]
|
||||
log.warning("Hook .sh on Windows needs bash in PATH (Git for Windows) or wsl. Skipping %s", p)
|
||||
return None
|
||||
try:
|
||||
if script.stat().st_mode & 0o111 and os.access(p, os.X_OK):
|
||||
return [p]
|
||||
except OSError:
|
||||
pass
|
||||
sh = shutil.which("sh") or "/bin/sh"
|
||||
return [sh, p]
|
||||
|
||||
|
||||
def _tsx_invocation() -> str | None:
|
||||
return shutil.which("tsx") or None
|
||||
|
||||
|
||||
def _ts_command(*, script: Path, export_name: str) -> list[str] | None:
|
||||
runner = _runner_ts_path()
|
||||
if not runner.is_file():
|
||||
log.error("oclaw: missing ts hook runner: %s", runner)
|
||||
return None
|
||||
hp = str(script.resolve())
|
||||
rts = str(runner.resolve())
|
||||
ex = export_name.strip() or "default"
|
||||
tx = _tsx_invocation()
|
||||
if tx:
|
||||
return [tx, rts, hp, ex]
|
||||
npx = shutil.which("npx")
|
||||
if npx:
|
||||
return [npx, "--yes", "tsx", rts, hp, ex]
|
||||
log.warning("Hook .ts needs `tsx` or `npx` (for `npx tsx`) on PATH. Skipping %s", hp)
|
||||
return None
|
||||
|
||||
|
||||
def _ts_script_command(*, script: Path) -> list[str] | None:
|
||||
"""Run .ts as a free script: stdin JSON / stdout JSON (no import runner)."""
|
||||
hp = str(script.resolve())
|
||||
tx = _tsx_invocation()
|
||||
if tx:
|
||||
return [tx, hp]
|
||||
npx = shutil.which("npx")
|
||||
if npx:
|
||||
return [npx, "--yes", "tsx", hp]
|
||||
log.warning("Hook .ts in script mode needs `tsx` or `npx` on PATH. Skipping %s", hp)
|
||||
return None
|
||||
|
||||
|
||||
def _node_path() -> str | None:
|
||||
return shutil.which("node") or None
|
||||
|
||||
|
||||
def _js_module_command(*, script: Path, export_name: str) -> list[str] | None:
|
||||
node = _node_path()
|
||||
if not node:
|
||||
log.warning("Hook .mjs / .cjs needs `node` on PATH. Skipping %s", script)
|
||||
return None
|
||||
runner = _runner_js_path()
|
||||
if not runner.is_file():
|
||||
log.error("oclaw: missing js hook runner: %s", runner)
|
||||
return None
|
||||
hp = str(script.resolve())
|
||||
rjs = str(runner.resolve())
|
||||
ex = export_name.strip() or "default"
|
||||
return [node, rjs, hp, ex]
|
||||
|
||||
|
||||
def _js_script_command(*, script: Path) -> list[str] | None:
|
||||
node = _node_path()
|
||||
if not node:
|
||||
return None
|
||||
return [node, str(script.resolve())]
|
||||
|
||||
|
||||
async def _run_cmd_handler(
|
||||
*,
|
||||
cmd: list[str],
|
||||
event: HookEvent,
|
||||
base_dir: str,
|
||||
script: Path,
|
||||
log_label: str,
|
||||
) -> None:
|
||||
data = json.dumps(_event_payload(event), default=str)
|
||||
env = {**os.environ, "OCLAW_HOOK_DIR": str(Path(base_dir).resolve()), "OCLAW_HOOK_HANDLER": str(script.resolve())}
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=str(Path(base_dir).resolve()),
|
||||
env=env,
|
||||
)
|
||||
out_b, err_b = await asyncio.wait_for(
|
||||
proc.communicate(input=data.encode("utf-8")),
|
||||
timeout=_HOOK_SUBPROCESS_TIMEOUT_S,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
log.error("Hook %s timed out after %ss: %s", log_label, int(_HOOK_SUBPROCESS_TIMEOUT_S), script)
|
||||
return
|
||||
except Exception:
|
||||
log.exception("Hook %s failed to spawn: %s", log_label, script)
|
||||
return
|
||||
if proc.returncode != 0:
|
||||
log.error(
|
||||
"Hook %s exit %s: %s",
|
||||
log_label,
|
||||
proc.returncode,
|
||||
err_b.decode("utf-8", errors="replace")[:4000],
|
||||
)
|
||||
if log.isEnabledFor(logging.DEBUG):
|
||||
log.debug("Hook %s full stderr: %s", log_label, err_b)
|
||||
return
|
||||
out_t = out_b.decode("utf-8", errors="replace")
|
||||
if not (out_t or "").strip() and err_b:
|
||||
log.warning(
|
||||
"Hook %s empty stdout, stderr: %s",
|
||||
log_label,
|
||||
err_b.decode("utf-8", errors="replace")[:2000],
|
||||
)
|
||||
_merge_stdout_into_context(event, out_t)
|
||||
|
||||
|
||||
def _build_cmd_handler(
|
||||
cmd: list[str] | None,
|
||||
*,
|
||||
base_dir: str,
|
||||
script: Path,
|
||||
log_label: str,
|
||||
) -> Optional[HookHandler]:
|
||||
if not cmd:
|
||||
return None
|
||||
|
||||
async def _handler(event: HookEvent) -> None:
|
||||
await _run_cmd_handler(cmd=cmd, event=event, base_dir=base_dir, script=script, log_label=log_label)
|
||||
|
||||
return _handler
|
||||
|
||||
|
||||
def build_script_hook_handler(
|
||||
*,
|
||||
handler_path: str,
|
||||
base_dir: str,
|
||||
suffix: str,
|
||||
export_name: str,
|
||||
oclaw: dict[str, Any] | None = None,
|
||||
) -> Optional[HookHandler]:
|
||||
p = Path(handler_path)
|
||||
if not p.is_file():
|
||||
return None
|
||||
sfx = (suffix or "").lower()
|
||||
o = oclaw or {}
|
||||
script_mode = _is_script_mode(o)
|
||||
|
||||
# Shell: always JSON stdin/stdout; never use TS/JS import runners
|
||||
if sfx in {".sh", ".bash"}:
|
||||
sc = _sh_command(p)
|
||||
return _build_cmd_handler(sc, base_dir=base_dir, script=p, log_label="sh")
|
||||
|
||||
if script_mode:
|
||||
if sfx in {".ts", ".mts", ".cts"}:
|
||||
cmd = _ts_script_command(script=p)
|
||||
return _build_cmd_handler(cmd, base_dir=base_dir, script=p, log_label="ts:script")
|
||||
if sfx in {".mjs", ".cjs"}:
|
||||
cmd = _js_script_command(script=p)
|
||||
return _build_cmd_handler(cmd, base_dir=base_dir, script=p, log_label="js:script")
|
||||
|
||||
# Module / import path (default for ts and js)
|
||||
if sfx in {".ts", ".mts", ".cts"}:
|
||||
cmd = _ts_command(script=p, export_name=export_name)
|
||||
return _build_cmd_handler(cmd, base_dir=base_dir, script=p, log_label="ts:module")
|
||||
if sfx in {".mjs", ".cjs"}:
|
||||
cmd = _js_module_command(script=p, export_name=export_name)
|
||||
return _build_cmd_handler(cmd, base_dir=base_dir, script=p, log_label="js:module")
|
||||
|
||||
return None
|
||||
45
runtime/hooks/ts_hook_runner.ts
Normal file
45
runtime/hooks/ts_hook_runner.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/**
|
||||
* Invoked as: npx --yes tsx ts_hook_runner.ts <absolute-handler.ts> [exportName]
|
||||
* stdin: JSON { type, action, sessionKey, context, messages?, timestamp? }
|
||||
* stdout: JSON { "context"?: object } merged into the hook event in Python
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const handlerPath = process.argv[2];
|
||||
const exportName = (process.argv[3] || "default").trim();
|
||||
|
||||
if (!handlerPath) {
|
||||
console.error("oclaw: ts_hook_runner: missing handler path");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const raw = readFileSync(0, "utf-8");
|
||||
const data = JSON.parse(raw) as Record<string, unknown>;
|
||||
const event = {
|
||||
type: data.type,
|
||||
action: data.action,
|
||||
sessionKey: data.sessionKey,
|
||||
context: (typeof data.context === "object" && data.context) ? (data.context as Record<string, unknown>) : {},
|
||||
messages: Array.isArray(data.messages) ? data.messages : [],
|
||||
timestamp: data.timestamp,
|
||||
};
|
||||
|
||||
const mod: Record<string, unknown> = (await import(pathToFileURL(handlerPath).href)) as Record<string, unknown>;
|
||||
let fn: ((ev: unknown) => unknown) | undefined;
|
||||
if (exportName && exportName !== "default" && mod[exportName] && typeof mod[exportName] === "function") {
|
||||
fn = mod[exportName] as (ev: unknown) => unknown;
|
||||
} else {
|
||||
fn = (mod.default ?? mod.handle ?? mod.handler) as (ev: unknown) => unknown;
|
||||
}
|
||||
if (typeof fn !== "function") {
|
||||
console.error("oclaw: ts_hook_runner: no function export in", handlerPath, "export", exportName);
|
||||
process.exit(3);
|
||||
}
|
||||
const r = fn(event);
|
||||
if (r && typeof (r as { then?: unknown }).then === "function") {
|
||||
await (r as Promise<unknown>);
|
||||
}
|
||||
process.stdout.write(
|
||||
JSON.stringify({ context: event.context }, (_k, v) => (typeof v === "bigint" ? v.toString() : v), 0),
|
||||
);
|
||||
81
runtime/hooks/user_config_hooks.py
Normal file
81
runtime/hooks/user_config_hooks.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from oclaw.platform.config.paths import PROJECT_ROOT
|
||||
|
||||
|
||||
def resolve_hooks_config_storage_path() -> Path:
|
||||
"""
|
||||
Path used for persistent ``hooks.internal.entries`` edits.
|
||||
|
||||
Matches ``resolve_runtime_config`` file resolution: ``OCLAW_CONFIG_PATH`` (optional
|
||||
relative to ``PROJECT_ROOT``), else ``<PROJECT_ROOT>/oclaw/oclaw.json``.
|
||||
"""
|
||||
raw = str(os.getenv("OCLAW_CONFIG_PATH") or "").strip()
|
||||
if raw:
|
||||
p = Path(raw).expanduser()
|
||||
if not p.is_absolute():
|
||||
p = (Path(PROJECT_ROOT) / p).resolve()
|
||||
return p
|
||||
return (Path(PROJECT_ROOT) / "oclaw" / "oclaw.json").resolve()
|
||||
|
||||
|
||||
def load_storage_config_document() -> dict[str, Any]:
|
||||
p = resolve_hooks_config_storage_path()
|
||||
if not p.is_file():
|
||||
return {}
|
||||
try:
|
||||
raw = p.read_text(encoding="utf-8")
|
||||
obj = json.loads(raw)
|
||||
return dict(obj) if isinstance(obj, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def save_storage_config_document(doc: dict[str, Any]) -> None:
|
||||
p = resolve_hooks_config_storage_path()
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = p.with_suffix(p.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(doc, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
tmp.replace(p)
|
||||
|
||||
|
||||
def apply_hook_entry_enabled(
|
||||
doc: dict[str, Any],
|
||||
hook_key: str,
|
||||
enabled: bool,
|
||||
*,
|
||||
ensure_internal_hooks_enabled: bool = False,
|
||||
) -> None:
|
||||
"""Mutate *doc* in place (shallow structure for ``hooks.internal`` only)."""
|
||||
key = str(hook_key or "").strip()
|
||||
if not key:
|
||||
raise ValueError("hook_key is empty")
|
||||
|
||||
hooks = doc.setdefault("hooks", {})
|
||||
if not isinstance(hooks, dict):
|
||||
doc["hooks"] = {}
|
||||
hooks = doc["hooks"]
|
||||
|
||||
internal = hooks.setdefault("internal", {})
|
||||
if not isinstance(internal, dict):
|
||||
hooks["internal"] = {}
|
||||
internal = hooks["internal"]
|
||||
|
||||
if ensure_internal_hooks_enabled and enabled:
|
||||
internal["enabled"] = True
|
||||
|
||||
entries = internal.setdefault("entries", {})
|
||||
if not isinstance(entries, dict):
|
||||
internal["entries"] = {}
|
||||
entries = internal["entries"]
|
||||
|
||||
row = entries.get(key)
|
||||
if not isinstance(row, dict):
|
||||
entries[key] = {}
|
||||
row = entries[key]
|
||||
row["enabled"] = bool(enabled)
|
||||
|
|
@ -2,15 +2,13 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Literal, Optional, Sequence, Tuple
|
||||
from typing import Any, List, Optional, Sequence, Tuple
|
||||
|
||||
from .frontmatter import parse_frontmatter, resolve_oclaw_metadata
|
||||
from .policy import HookSource, resolve_hook_entries
|
||||
|
||||
|
||||
HookEntry = Dict[str, Any]
|
||||
from .frontmatter import parse_frontmatter
|
||||
from .hook_manifest_core import parse_hook_manifest
|
||||
from .hook_types import HookEntry, HookInvocation, HookRef, HookSource
|
||||
from .policy import resolve_hook_entries
|
||||
|
||||
|
||||
def _read_text(path: Path) -> Optional[str]:
|
||||
|
|
@ -28,7 +26,132 @@ def _safe_is_dir(p: Path) -> bool:
|
|||
|
||||
|
||||
def _handler_candidates() -> Tuple[str, ...]:
|
||||
return ("handler.py", "index.py")
|
||||
# One file per hook dir. Order: py → TypeScript (runner) → JS (node runner) → shell.
|
||||
return (
|
||||
"handler.py",
|
||||
"index.py",
|
||||
"handler.ts",
|
||||
"index.ts",
|
||||
"handler.mts",
|
||||
"index.mts",
|
||||
"handler.cts",
|
||||
"index.cts",
|
||||
"handler.mjs",
|
||||
"index.mjs",
|
||||
"handler.cjs",
|
||||
"index.cjs",
|
||||
"handler.sh",
|
||||
"index.sh",
|
||||
"handler.bash",
|
||||
"index.bash",
|
||||
)
|
||||
|
||||
|
||||
def _resolve_contained_dir(base_dir: Path, target_dir: str) -> Optional[Path]:
|
||||
try:
|
||||
resolved = (base_dir / target_dir).resolve()
|
||||
resolved.relative_to(base_dir.resolve())
|
||||
return resolved
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_package_hook_paths(package_dir: Path) -> List[str]:
|
||||
"""
|
||||
Support package.json manifest declarations:
|
||||
- { "openclaw": { "hooks": [...] } }
|
||||
- { "oclaw": { "hooks": [...] } }
|
||||
"""
|
||||
manifest = package_dir / "package.json"
|
||||
raw = _read_text(manifest)
|
||||
if raw is None:
|
||||
return []
|
||||
try:
|
||||
obj = json.loads(raw)
|
||||
except Exception:
|
||||
return []
|
||||
if not isinstance(obj, dict):
|
||||
return []
|
||||
out: List[str] = []
|
||||
for key in ("openclaw", "oclaw"):
|
||||
row = obj.get(key)
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
hooks = row.get("hooks")
|
||||
if not isinstance(hooks, list):
|
||||
continue
|
||||
for it in hooks:
|
||||
s = str(it or "").strip()
|
||||
if s:
|
||||
out.append(s)
|
||||
if out:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _resolve_plugin_hook_dirs(*, workspace_dir: str, config: Optional[dict[str, Any]]) -> List[tuple[str, str]]:
|
||||
"""
|
||||
Lightweight Python parity for OpenClaw plugin hook discovery.
|
||||
|
||||
Discover plugin bundles under:
|
||||
<workspace>/.openclaw/extensions/<plugin-id>/.codex-plugin/plugin.json
|
||||
and read `hooks` from that manifest (string path or list[str]).
|
||||
"""
|
||||
ws = Path(workspace_dir).resolve()
|
||||
ext_root = ws / ".openclaw" / "extensions"
|
||||
if not ext_root.exists() or not ext_root.is_dir():
|
||||
return []
|
||||
|
||||
enabled_entries = (
|
||||
(((config or {}).get("plugins") or {}).get("entries") or {})
|
||||
if isinstance((((config or {}).get("plugins") or {}).get("entries") or {}), dict)
|
||||
else {}
|
||||
)
|
||||
|
||||
out: List[tuple[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
for plugin_root in ext_root.iterdir():
|
||||
if not plugin_root.is_dir():
|
||||
continue
|
||||
plugin_id = plugin_root.name
|
||||
state = enabled_entries.get(plugin_id) if isinstance(enabled_entries, dict) else None
|
||||
if isinstance(state, dict) and state.get("enabled") is False:
|
||||
continue
|
||||
|
||||
manifest_path = plugin_root / ".codex-plugin" / "plugin.json"
|
||||
raw = _read_text(manifest_path)
|
||||
if raw is None:
|
||||
continue
|
||||
try:
|
||||
manifest = json.loads(raw)
|
||||
except Exception:
|
||||
continue
|
||||
if not isinstance(manifest, dict):
|
||||
continue
|
||||
hooks_value = manifest.get("hooks")
|
||||
hook_paths: List[str] = []
|
||||
if isinstance(hooks_value, str):
|
||||
s = hooks_value.strip()
|
||||
if s:
|
||||
hook_paths.append(s)
|
||||
elif isinstance(hooks_value, list):
|
||||
for row in hooks_value:
|
||||
s = str(row or "").strip()
|
||||
if s:
|
||||
hook_paths.append(s)
|
||||
if not hook_paths:
|
||||
continue
|
||||
|
||||
for rel in hook_paths:
|
||||
resolved = _resolve_contained_dir(plugin_root, rel)
|
||||
if resolved is None:
|
||||
continue
|
||||
key = str(resolved)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append((key, plugin_id))
|
||||
return out
|
||||
|
||||
|
||||
def _load_hook_from_dir(hook_dir: Path, source: HookSource, plugin_id: Optional[str] = None) -> Optional[HookEntry]:
|
||||
|
|
@ -39,12 +162,9 @@ def _load_hook_from_dir(hook_dir: Path, source: HookSource, plugin_id: Optional[
|
|||
|
||||
parsed = parse_frontmatter(content)
|
||||
fm = parsed.frontmatter
|
||||
name = fm.get("name") or hook_dir.name
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
name = hook_dir.name
|
||||
description = fm.get("description") or ""
|
||||
if not isinstance(description, str):
|
||||
description = ""
|
||||
manifest = parse_hook_manifest(frontmatter=fm, default_name=hook_dir.name)
|
||||
name = manifest.name
|
||||
description = manifest.description
|
||||
|
||||
handler_path: Optional[Path] = None
|
||||
for candidate in _handler_candidates():
|
||||
|
|
@ -55,21 +175,20 @@ def _load_hook_from_dir(hook_dir: Path, source: HookSource, plugin_id: Optional[
|
|||
if handler_path is None:
|
||||
return None
|
||||
|
||||
metadata = resolve_oclaw_metadata(fm) or {}
|
||||
|
||||
return {
|
||||
"hook": {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"source": source,
|
||||
"pluginId": plugin_id,
|
||||
"filePath": str(hook_md),
|
||||
"baseDir": str(hook_dir.resolve()),
|
||||
"handlerPath": str(handler_path.resolve()),
|
||||
},
|
||||
"frontmatter": fm,
|
||||
"metadata": metadata,
|
||||
}
|
||||
return HookEntry(
|
||||
hook=HookRef(
|
||||
name=name,
|
||||
description=description,
|
||||
source=source,
|
||||
pluginId=plugin_id,
|
||||
filePath=str(hook_md),
|
||||
baseDir=str(hook_dir.resolve()),
|
||||
handlerPath=str(handler_path.resolve()),
|
||||
),
|
||||
frontmatter=dict(fm),
|
||||
metadata=manifest.metadata.as_dict(),
|
||||
invocation=HookInvocation(enabled=bool(manifest.invocation_enabled)),
|
||||
)
|
||||
|
||||
|
||||
def load_hook_entries_from_dir(dir_path: str, source: HookSource, plugin_id: Optional[str] = None) -> List[HookEntry]:
|
||||
|
|
@ -81,6 +200,17 @@ def load_hook_entries_from_dir(dir_path: str, source: HookSource, plugin_id: Opt
|
|||
for child in base.iterdir():
|
||||
if not child.is_dir():
|
||||
continue
|
||||
package_hook_paths = _parse_package_hook_paths(child)
|
||||
if package_hook_paths:
|
||||
for rel in package_hook_paths:
|
||||
hook_dir = _resolve_contained_dir(child, rel)
|
||||
if hook_dir is None:
|
||||
continue
|
||||
entry = _load_hook_from_dir(hook_dir, source=source, plugin_id=plugin_id)
|
||||
if entry:
|
||||
out.append(entry)
|
||||
continue
|
||||
|
||||
entry = _load_hook_from_dir(child, source=source, plugin_id=plugin_id)
|
||||
if entry:
|
||||
out.append(entry)
|
||||
|
|
@ -92,6 +222,7 @@ def load_hook_entries_from_dir(dir_path: str, source: HookSource, plugin_id: Opt
|
|||
def discover_workspace_hook_entries(
|
||||
workspace_dir: str,
|
||||
*,
|
||||
config: Optional[dict[str, Any]] = None,
|
||||
managed_hooks_dir: Optional[str] = None,
|
||||
bundled_hooks_dir: Optional[str] = None,
|
||||
extra_dirs: Optional[Sequence[str]] = None,
|
||||
|
|
@ -118,6 +249,9 @@ def discover_workspace_hook_entries(
|
|||
if bundled_hooks_dir:
|
||||
entries.extend(load_hook_entries_from_dir(bundled_hooks_dir, source="oclaw-bundled"))
|
||||
|
||||
for plugin_dir, plugin_id in _resolve_plugin_hook_dirs(workspace_dir=workspace_dir, config=config):
|
||||
entries.extend(load_hook_entries_from_dir(plugin_dir, source="oclaw-plugin", plugin_id=plugin_id))
|
||||
|
||||
entries.extend(load_hook_entries_from_dir(str(managed), source="oclaw-managed"))
|
||||
entries.extend(load_hook_entries_from_dir(str(workspace_hooks), source="oclaw-workspace"))
|
||||
return entries
|
||||
|
|
@ -126,12 +260,14 @@ def discover_workspace_hook_entries(
|
|||
def load_workspace_hook_entries(
|
||||
workspace_dir: str,
|
||||
*,
|
||||
config: Optional[dict[str, Any]] = None,
|
||||
managed_hooks_dir: Optional[str] = None,
|
||||
bundled_hooks_dir: Optional[str] = None,
|
||||
extra_dirs: Optional[Sequence[str]] = None,
|
||||
) -> List[HookEntry]:
|
||||
discovered = discover_workspace_hook_entries(
|
||||
workspace_dir,
|
||||
config=config,
|
||||
managed_hooks_dir=managed_hooks_dir,
|
||||
bundled_hooks_dir=bundled_hooks_dir,
|
||||
extra_dirs=extra_dirs,
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
340
runtime/operations/hooks_cmd.py
Normal file
340
runtime/operations/hooks_cmd.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
39
runtime/operations/scripts/status_desktop.ps1
Normal file
39
runtime/operations/scripts/status_desktop.ps1
Normal 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
|
||||
}
|
||||
|
||||
|
|
@ -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"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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
285
runtime/prompt_prebuild.py
Normal 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",
|
||||
]
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
137
runtime/skill_manifest_core.py
Normal file
137
runtime/skill_manifest_core.py
Normal 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",
|
||||
]
|
||||
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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`。
|
||||
|
|
|
|||
140
runtime/skills/cocoloop/README.md
Normal file
140
runtime/skills/cocoloop/README.md
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
# Cocoloop
|
||||
|
||||
一个更快速、更安全的 Skill 管理器,用于安装、管理、更新和卸载 Skills。
|
||||
|
||||
[](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
|
||||
252
runtime/skills/cocoloop/SKILL.md
Normal file
252
runtime/skills/cocoloop/SKILL.md
Normal 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`
|
||||
191
runtime/skills/cocoloop/references/cocoloop-safe-check.md
Normal file
191
runtime/skills/cocoloop/references/cocoloop-safe-check.md
Normal 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 优先)
|
||||
- [ ] 有完善的输入验证
|
||||
- [ ] 错误处理不泄露敏感信息
|
||||
245
runtime/skills/cocoloop/references/install-guide.md
Normal file
245
runtime/skills/cocoloop/references/install-guide.md
Normal 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
|
||||
```
|
||||
383
runtime/skills/cocoloop/references/safety-check-guide.md
Normal file
383
runtime/skills/cocoloop/references/safety-check-guide.md
Normal 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 级:**
|
||||
|
||||
- 存在严重安全问题
|
||||
- 强烈建议不要使用
|
||||
- 如需使用,必须在完全隔离的环境中
|
||||
254
runtime/skills/cocoloop/references/search-guide.md
Normal file
254
runtime/skills/cocoloop/references/search-guide.md
Normal 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` |
|
||||
163
runtime/skills/cocoloop/references/uninstall-guide.md
Normal file
163
runtime/skills/cocoloop/references/uninstall-guide.md
Normal 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. 验证恢复
|
||||
|
|
@ -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. 提供变更说明、验证结果和残余风险。
|
||||
|
||||
## 输出要求
|
||||
- 必须包含:改了什么、为什么、怎么验证、还有什么风险。
|
||||
- 避免顺手混改无关逻辑。
|
||||
|
|
@ -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. 输出结论、影响、验证状态和下一步建议。
|
||||
|
||||
## 输出要求
|
||||
- 先结论,后依据,最后行动项。
|
||||
- 如果有风险,明确风险等级与回滚思路。
|
||||
85
runtime/skills/self-improvement/hooks/runtime/handler.py
Normal file
85
runtime/skills/self-improvement/hooks/runtime/handler.py
Normal 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
|
||||
|
|
@ -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. 检查敏感信息和承诺边界。
|
||||
|
||||
## 输出要求
|
||||
- 默认给:一句话版 + 标准版正文。
|
||||
- 术语统一,避免过度承诺。
|
||||
351
runtime/skills/tavily-search-pro/SKILL.md
Normal file
351
runtime/skills/tavily-search-pro/SKILL.md
Normal 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
|
||||
```
|
||||
11
runtime/skills/tavily-search-pro/_meta.json
Normal file
11
runtime/skills/tavily-search-pro/_meta.json
Normal 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": []
|
||||
}
|
||||
30
runtime/skills/tavily-search-pro/install.sh
Normal file
30
runtime/skills/tavily-search-pro/install.sh
Normal 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
|
||||
549
runtime/skills/tavily-search-pro/lib/tavily_search.py
Normal file
549
runtime/skills/tavily-search-pro/lib/tavily_search.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
53
runtime/skills_market.py
Normal 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"]
|
||||
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"]
|
||||
19
runtime/tools/experts/memory/__init__.py
Normal file
19
runtime/tools/experts/memory/__init__.py
Normal 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",
|
||||
]
|
||||
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue