omnilimb 0.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- omnilimb/__init__.py +114 -0
- omnilimb/_cache.py +123 -0
- omnilimb/_errors.py +54 -0
- omnilimb/_retry.py +64 -0
- omnilimb/_scoring.py +236 -0
- omnilimb/backends/__init__.py +42 -0
- omnilimb/backends/base.py +56 -0
- omnilimb/backends/cli_backend.py +184 -0
- omnilimb/backends/native_backend.py +559 -0
- omnilimb/config.py +200 -0
- omnilimb/dashboard/__init__.py +6 -0
- omnilimb/dashboard/dist/index.js +1508 -0
- omnilimb/dashboard/dist/style.css +638 -0
- omnilimb/dashboard/manifest.json +15 -0
- omnilimb/dashboard/plugin_api.py +1357 -0
- omnilimb/plugin.yaml +25 -0
- omnilimb/registries.py +514 -0
- omnilimb/schemas.py +202 -0
- omnilimb/skills/omnilimb/SKILL.md +75 -0
- omnilimb/tools.py +821 -0
- omnilimb-0.8.0.dist-info/METADATA +244 -0
- omnilimb-0.8.0.dist-info/RECORD +26 -0
- omnilimb-0.8.0.dist-info/WHEEL +5 -0
- omnilimb-0.8.0.dist-info/entry_points.txt +2 -0
- omnilimb-0.8.0.dist-info/licenses/LICENSE +32 -0
- omnilimb-0.8.0.dist-info/top_level.txt +1 -0
omnilimb/__init__.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Omnilimb — Hermes plugin.
|
|
2
|
+
|
|
3
|
+
Turns OpenClaw / ClawHub's mature execution substrate (skill registry, local
|
|
4
|
+
sandbox, Playwright browser automation, multi-language runtimes, retry/rollback)
|
|
5
|
+
into a set of structured-JSON tools for Hermes.
|
|
6
|
+
|
|
7
|
+
Design:
|
|
8
|
+
- Hermes is the brain (LLM/conversation/memory/UI).
|
|
9
|
+
- Omnilimb is the hands/feet (deterministic execution). It NEVER calls an
|
|
10
|
+
LLM itself -> zero extra model tokens on the execution path.
|
|
11
|
+
- Two interchangeable backends, switchable via config:
|
|
12
|
+
* "cli" -> shells out to the real `openclaw` / `clawhub` CLIs.
|
|
13
|
+
* "native" -> a decoupled Python re-implementation (no Node dependency).
|
|
14
|
+
* "auto" -> cli if the `openclaw` binary is present, else native.
|
|
15
|
+
|
|
16
|
+
Every tool handler:
|
|
17
|
+
- has signature `handler(args: dict, **kwargs) -> str`
|
|
18
|
+
- ALWAYS returns a JSON string (success and error alike)
|
|
19
|
+
- NEVER raises
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
|
|
26
|
+
from . import schemas, tools
|
|
27
|
+
from .config import get_settings
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
__version__ = "0.8.0"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _cli_available() -> bool:
|
|
35
|
+
"""check_fn used to hide CLI-only behaviour gracefully (never raises)."""
|
|
36
|
+
try:
|
|
37
|
+
from .backends.cli_backend import openclaw_binary
|
|
38
|
+
|
|
39
|
+
return openclaw_binary() is not None
|
|
40
|
+
except Exception: # pragma: no cover - defensive
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _tools_available() -> bool:
|
|
45
|
+
"""Tools are available whenever a backend can be resolved.
|
|
46
|
+
|
|
47
|
+
The CLI backend needs the `openclaw` binary; the native backend always
|
|
48
|
+
resolves. So tools are available unless the user pinned `backend: cli`
|
|
49
|
+
without installing the CLI.
|
|
50
|
+
"""
|
|
51
|
+
try:
|
|
52
|
+
settings = get_settings()
|
|
53
|
+
if settings.backend == "cli":
|
|
54
|
+
return _cli_available()
|
|
55
|
+
return True
|
|
56
|
+
except Exception: # pragma: no cover - defensive
|
|
57
|
+
return True
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def register(ctx) -> None:
|
|
61
|
+
"""Entry point called once at startup. Wires schemas -> handlers.
|
|
62
|
+
|
|
63
|
+
If this function raises, Hermes disables the plugin but keeps running, so we
|
|
64
|
+
keep it defensive and side-effect-light.
|
|
65
|
+
"""
|
|
66
|
+
pairs = (
|
|
67
|
+
("claw_skill_search", schemas.CLAW_SKILL_SEARCH, tools.claw_skill_search),
|
|
68
|
+
("claw_skill_install", schemas.CLAW_SKILL_INSTALL, tools.claw_skill_install),
|
|
69
|
+
("claw_skill_list", schemas.CLAW_SKILL_LIST, tools.claw_skill_list),
|
|
70
|
+
("claw_skill_runs", schemas.CLAW_SKILL_RUNS, tools.claw_skill_runs),
|
|
71
|
+
("claw_skill_run", schemas.CLAW_SKILL_RUN, tools.claw_skill_run),
|
|
72
|
+
("claw_sandbox_exec", schemas.CLAW_SANDBOX_EXEC, tools.claw_sandbox_exec),
|
|
73
|
+
("claw_browser", schemas.CLAW_BROWSER, tools.claw_browser),
|
|
74
|
+
("claw_runtime", schemas.CLAW_RUNTIME, tools.claw_runtime),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
for name, schema, handler in pairs:
|
|
78
|
+
ctx.register_tool(
|
|
79
|
+
name=name,
|
|
80
|
+
toolset="omnilimb",
|
|
81
|
+
schema=schema,
|
|
82
|
+
handler=handler,
|
|
83
|
+
check_fn=_tools_available,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# Ship an opt-in "how to drive me" skill. Not in the system-prompt index,
|
|
87
|
+
# so it costs zero standing tokens until the agent explicitly loads it.
|
|
88
|
+
try:
|
|
89
|
+
from pathlib import Path
|
|
90
|
+
|
|
91
|
+
skill_md = Path(__file__).parent / "skills" / "omnilimb" / "SKILL.md"
|
|
92
|
+
if skill_md.exists():
|
|
93
|
+
ctx.register_skill("omnilimb", skill_md)
|
|
94
|
+
except Exception as exc: # pragma: no cover - optional
|
|
95
|
+
logger.debug("omnilimb: skill registration skipped: %s", exc)
|
|
96
|
+
|
|
97
|
+
# Diagnostics slash command: /exo status
|
|
98
|
+
try:
|
|
99
|
+
ctx.register_command(
|
|
100
|
+
"exo",
|
|
101
|
+
handler=tools.slash_claw,
|
|
102
|
+
description="Omnilimb status / backend / diagnostics",
|
|
103
|
+
args_hint="[status|backend|doctor]",
|
|
104
|
+
)
|
|
105
|
+
except Exception as exc: # pragma: no cover - optional
|
|
106
|
+
logger.debug("omnilimb: slash command skipped: %s", exc)
|
|
107
|
+
|
|
108
|
+
s = get_settings()
|
|
109
|
+
logger.info(
|
|
110
|
+
"omnilimb v%s registered (backend=%s, resolved=%s)",
|
|
111
|
+
__version__,
|
|
112
|
+
s.backend,
|
|
113
|
+
s.resolved_backend(),
|
|
114
|
+
)
|
omnilimb/_cache.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Tiny SQLite-backed fallback cache for registry results (offline-first).
|
|
2
|
+
|
|
3
|
+
Goal: when the upstream skill market (ClawHub / SkillHub) is unreachable, serve
|
|
4
|
+
the last *successful* lookup instead of an empty/error result — so search keeps
|
|
5
|
+
working during an outage. Strategy is **live-first, cache-as-fallback**: a live
|
|
6
|
+
success is always preferred (and refreshes the cache); the cache is only served
|
|
7
|
+
when the live call fails. This avoids serving stale data while online.
|
|
8
|
+
|
|
9
|
+
Stdlib only (`sqlite3`), profile-safe path under `state_dir()`, and every
|
|
10
|
+
function is defensive — a cache failure must never break a tool call.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import sqlite3
|
|
18
|
+
import time
|
|
19
|
+
from typing import Any, Callable
|
|
20
|
+
|
|
21
|
+
from .config import get_settings
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _db_path() -> str:
|
|
25
|
+
return str(get_settings().state_dir() / "cache.db")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _conn() -> sqlite3.Connection:
|
|
29
|
+
c = sqlite3.connect(_db_path(), timeout=5)
|
|
30
|
+
c.execute("CREATE TABLE IF NOT EXISTS cache (k TEXT PRIMARY KEY, ts REAL, payload TEXT)")
|
|
31
|
+
return c
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def make_key(*parts: Any) -> str:
|
|
35
|
+
raw = "|".join("" if p is None else str(p) for p in parts)
|
|
36
|
+
return hashlib.sha1(raw.encode("utf-8")).hexdigest() # noqa: S324 - cache key, not security
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cache_get(key: str) -> dict | None:
|
|
40
|
+
try:
|
|
41
|
+
c = _conn()
|
|
42
|
+
try:
|
|
43
|
+
row = c.execute("SELECT ts, payload FROM cache WHERE k=?", (key,)).fetchone()
|
|
44
|
+
finally:
|
|
45
|
+
c.close()
|
|
46
|
+
if not row:
|
|
47
|
+
return None
|
|
48
|
+
return {"ts": float(row[0]), "payload": json.loads(row[1])}
|
|
49
|
+
except Exception:
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def cache_put(key: str, payload: dict) -> None:
|
|
54
|
+
try:
|
|
55
|
+
c = _conn()
|
|
56
|
+
try:
|
|
57
|
+
c.execute(
|
|
58
|
+
"INSERT OR REPLACE INTO cache (k, ts, payload) VALUES (?, ?, ?)",
|
|
59
|
+
(key, time.time(), json.dumps(payload, ensure_ascii=False, default=str)),
|
|
60
|
+
)
|
|
61
|
+
c.commit()
|
|
62
|
+
finally:
|
|
63
|
+
c.close()
|
|
64
|
+
except Exception:
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def cache_fresh(key: str, max_age_s: int) -> dict | None:
|
|
69
|
+
"""Fast-path: return the cached payload only if it's younger than max_age_s.
|
|
70
|
+
|
|
71
|
+
Unlike :func:`cached` (which serves stale data only on live failure), this is
|
|
72
|
+
a *freshness* read used to skip a live call entirely when a recent result
|
|
73
|
+
exists — the basis for snappy repeat/paginated searches.
|
|
74
|
+
"""
|
|
75
|
+
hit = cache_get(key)
|
|
76
|
+
if not hit or not isinstance(hit.get("payload"), dict):
|
|
77
|
+
return None
|
|
78
|
+
age = time.time() - hit["ts"]
|
|
79
|
+
if age > max_age_s:
|
|
80
|
+
return None
|
|
81
|
+
out = dict(hit["payload"])
|
|
82
|
+
out["from_cache"] = True
|
|
83
|
+
out["cache_age_s"] = int(age)
|
|
84
|
+
return out
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def cached(
|
|
88
|
+
key: str,
|
|
89
|
+
fn: Callable[[], dict],
|
|
90
|
+
*,
|
|
91
|
+
enabled: bool = True,
|
|
92
|
+
max_age_s: int = 604800,
|
|
93
|
+
) -> dict:
|
|
94
|
+
"""Run *fn*; cache a success; on failure serve the last cached result.
|
|
95
|
+
|
|
96
|
+
Returns the live result when it succeeds (and refreshes the cache). On
|
|
97
|
+
failure, returns the cached payload annotated with ``from_cache``/``stale``/
|
|
98
|
+
``cache_age_s`` when a cache entry exists and is younger than *max_age_s*;
|
|
99
|
+
otherwise returns the live failure result unchanged.
|
|
100
|
+
"""
|
|
101
|
+
if not enabled:
|
|
102
|
+
try:
|
|
103
|
+
return fn()
|
|
104
|
+
except Exception as exc:
|
|
105
|
+
return {"ok": False, "error": f"{type(exc).__name__}: {exc}", "retryable": True}
|
|
106
|
+
try:
|
|
107
|
+
res = fn()
|
|
108
|
+
except Exception as exc:
|
|
109
|
+
res = {"ok": False, "error": f"{type(exc).__name__}: {exc}", "retryable": True}
|
|
110
|
+
if isinstance(res, dict) and res.get("ok"):
|
|
111
|
+
cache_put(key, res)
|
|
112
|
+
return res
|
|
113
|
+
hit = cache_get(key)
|
|
114
|
+
if hit:
|
|
115
|
+
age = int(time.time() - hit["ts"])
|
|
116
|
+
if age <= max_age_s and isinstance(hit["payload"], dict):
|
|
117
|
+
out = dict(hit["payload"])
|
|
118
|
+
out["from_cache"] = True
|
|
119
|
+
out["stale"] = True
|
|
120
|
+
out["cache_age_s"] = age
|
|
121
|
+
out["cache_note"] = "upstream unavailable — served cached result"
|
|
122
|
+
return out
|
|
123
|
+
return res
|
omnilimb/_errors.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Feature H — map raw error strings to human-friendly {reason, fix} guidance.
|
|
2
|
+
|
|
3
|
+
Pure functions, no I/O. Used by install / smoke-test / run paths so the UI can
|
|
4
|
+
show "why + how to fix" instead of a raw stack trace. First matching rule wins.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
# (regex, reason, fix). Patterns matched case-insensitively against the error.
|
|
12
|
+
_RULES: list[tuple[str, str, str]] = [
|
|
13
|
+
(r"git_terminal_prompt|could not read username|authentication failed|terminal prompts disabled",
|
|
14
|
+
"Git 需要认证(私有仓库或凭据缺失)",
|
|
15
|
+
"确认该技能是公开仓库;私有仓库需先配置 Git 凭据,或改用注册表安装。"),
|
|
16
|
+
(r"could not resolve host|name resolution|getaddrinfo|network is unreachable|connection refused|timed out|timeout|temporarily unavailable",
|
|
17
|
+
"网络不可达或超时",
|
|
18
|
+
"检查网络/代理后重试;上游市场可能临时不可用,可用缓存结果或切换市场。"),
|
|
19
|
+
(r"permission denied|access is denied|eacces|operation not permitted",
|
|
20
|
+
"权限不足",
|
|
21
|
+
"检查目标目录写权限;尽量在工作区内安装,避免写入系统目录。"),
|
|
22
|
+
(r"cannot connect to the docker daemon|docker daemon|docker.*not running|docker.*not found",
|
|
23
|
+
"需要 Docker 但未运行/未安装",
|
|
24
|
+
"安装并启动 Docker Desktop;或在设置里切到 native 后端用本地回退执行。"),
|
|
25
|
+
(r"command not found|is not recognized|missing dependency|requires?\.?bins?",
|
|
26
|
+
"缺少所需命令行依赖",
|
|
27
|
+
"按技能 SKILL.md 的 requires.bins 安装对应工具后重试(见‘环境检查’)。"),
|
|
28
|
+
(r"api[_ ]?key|unauthorized|\b401\b|invalid token|missing.*key|forbidden|\b403\b",
|
|
29
|
+
"缺少或无效的 API Key",
|
|
30
|
+
"在该技能详情页的‘凭据’区填入所需 API Key 后重试。"),
|
|
31
|
+
(r"checksum|signature|verify failed|verification failed|integrity",
|
|
32
|
+
"完整性/签名校验失败",
|
|
33
|
+
"包内容与签名不符,可能被篡改或源异常;请勿强装,换可信源或反馈维护者。"),
|
|
34
|
+
(r"unknown pack|not found|no such file|enoent|cannot find|does not exist|404",
|
|
35
|
+
"目标不存在",
|
|
36
|
+
"确认 slug/名称拼写正确,或先用搜索确认它在当前市场存在。"),
|
|
37
|
+
(r"disk|no space left|enospc",
|
|
38
|
+
"磁盘空间不足",
|
|
39
|
+
"清理磁盘空间后重试。"),
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def humanize(error) -> dict | None:
|
|
44
|
+
"""Return {reason, fix} for a raw error string, or None if there is no error."""
|
|
45
|
+
if not error:
|
|
46
|
+
return None
|
|
47
|
+
text = str(error).lower()
|
|
48
|
+
for pat, reason, fix in _RULES:
|
|
49
|
+
if re.search(pat, text):
|
|
50
|
+
return {"reason": reason, "fix": fix}
|
|
51
|
+
return {
|
|
52
|
+
"reason": "执行失败",
|
|
53
|
+
"fix": "查看下方原始错误;可重试,或检查依赖/网络/凭据/权限。",
|
|
54
|
+
}
|
omnilimb/_retry.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Retry + rollback helper.
|
|
2
|
+
|
|
3
|
+
`with_retry` runs an operation that returns a result dict. It retries on
|
|
4
|
+
*recoverable* failures (exceptions, or dicts with a truthy `retryable` flag /
|
|
5
|
+
network-ish errors) up to `retries` times with exponential backoff. The operation
|
|
6
|
+
itself is responsible for being safe to retry — pass `retries=0` for
|
|
7
|
+
non-idempotent operations.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import time
|
|
14
|
+
from typing import Any, Callable
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
_RETRYABLE_HINTS = ("timeout", "unreachable", "temporarily", "connection", "reset", "502", "503", "504")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _looks_retryable(result: Any) -> bool:
|
|
22
|
+
if not isinstance(result, dict):
|
|
23
|
+
return False
|
|
24
|
+
if result.get("ok"):
|
|
25
|
+
return False
|
|
26
|
+
if result.get("retryable") is True:
|
|
27
|
+
return True
|
|
28
|
+
err = str(result.get("error", "")).lower()
|
|
29
|
+
return any(h in err for h in _RETRYABLE_HINTS)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def with_retry(
|
|
33
|
+
op: Callable[[], dict],
|
|
34
|
+
*,
|
|
35
|
+
retries: int = 2,
|
|
36
|
+
backoff_s: float = 1.0,
|
|
37
|
+
rollback: bool = True,
|
|
38
|
+
) -> dict:
|
|
39
|
+
attempt = 0
|
|
40
|
+
last: dict = {"ok": False, "error": "not executed"}
|
|
41
|
+
while True:
|
|
42
|
+
try:
|
|
43
|
+
result = op()
|
|
44
|
+
if isinstance(result, dict) and result.get("ok"):
|
|
45
|
+
if attempt:
|
|
46
|
+
result.setdefault("attempts", attempt + 1)
|
|
47
|
+
return result
|
|
48
|
+
last = result if isinstance(result, dict) else {"ok": False, "error": str(result)}
|
|
49
|
+
if not _looks_retryable(last) or attempt >= retries:
|
|
50
|
+
if rollback and not last.get("ok"):
|
|
51
|
+
last.setdefault("rolled_back", True)
|
|
52
|
+
last.setdefault("attempts", attempt + 1)
|
|
53
|
+
return last
|
|
54
|
+
except Exception as exc: # treat as retryable transient
|
|
55
|
+
last = {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
|
|
56
|
+
if attempt >= retries:
|
|
57
|
+
if rollback:
|
|
58
|
+
last.setdefault("rolled_back", True)
|
|
59
|
+
last.setdefault("attempts", attempt + 1)
|
|
60
|
+
return last
|
|
61
|
+
sleep_for = backoff_s * (2**attempt)
|
|
62
|
+
logger.debug("with_retry: attempt %s failed, sleeping %.1fs", attempt + 1, sleep_for)
|
|
63
|
+
time.sleep(min(sleep_for, 30.0))
|
|
64
|
+
attempt += 1
|
omnilimb/_scoring.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Pro-3: deterministic skill health + Hermes-fit scoring (pure functions).
|
|
2
|
+
|
|
3
|
+
`score_skill` takes normalized metadata and returns a transparent, explainable
|
|
4
|
+
0-100 score with per-dimension breakdown, hard blockers, and an install
|
|
5
|
+
recommendation. No I/O — the dashboard endpoint gathers the inputs and calls in.
|
|
6
|
+
|
|
7
|
+
Dimensions (weights): Trust 25 · Completeness 20 · Hermes-fit 30 · Maintenance 15
|
|
8
|
+
· Safety 10. When run history is supplied, a real-world reliability adjustment
|
|
9
|
+
is folded in (a skill that actually runs green scores higher; one that keeps
|
|
10
|
+
failing is penalised and flagged).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import time
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _grade(score: int) -> str:
|
|
20
|
+
if score >= 85:
|
|
21
|
+
return "A"
|
|
22
|
+
if score >= 70:
|
|
23
|
+
return "B"
|
|
24
|
+
if score >= 50:
|
|
25
|
+
return "C"
|
|
26
|
+
return "D"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _num(value: Any) -> float:
|
|
30
|
+
"""Coerce possibly-string/None metadata numbers to a float; 0 on garbage."""
|
|
31
|
+
if value is None:
|
|
32
|
+
return 0.0
|
|
33
|
+
try:
|
|
34
|
+
return float(value)
|
|
35
|
+
except (TypeError, ValueError):
|
|
36
|
+
try:
|
|
37
|
+
return float(str(value).replace(",", "").strip())
|
|
38
|
+
except (TypeError, ValueError):
|
|
39
|
+
return 0.0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def infer_capabilities(meta: dict) -> list[str]:
|
|
43
|
+
"""Best-effort capability tags (what the skill can touch) for the Safety view (J)."""
|
|
44
|
+
caps = meta.get("capabilities")
|
|
45
|
+
if isinstance(caps, list) and caps:
|
|
46
|
+
return [str(c) for c in caps]
|
|
47
|
+
out: list[str] = []
|
|
48
|
+
if meta.get("requires_api_key") or meta.get("requires_env"):
|
|
49
|
+
out.append("network")
|
|
50
|
+
bins = [str(b).lower() for b in (meta.get("requires_bins") or [])]
|
|
51
|
+
if any(b in ("docker", "bash", "sh", "powershell", "node", "python") for b in bins):
|
|
52
|
+
out.append("exec")
|
|
53
|
+
if any(b in ("git", "curl", "wget") for b in bins):
|
|
54
|
+
out.append("network")
|
|
55
|
+
# skills almost always read/write files in their workspace
|
|
56
|
+
out.append("filesystem")
|
|
57
|
+
# de-dupe, stable order
|
|
58
|
+
seen: list[str] = []
|
|
59
|
+
for c in out:
|
|
60
|
+
if c not in seen:
|
|
61
|
+
seen.append(c)
|
|
62
|
+
return seen
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def score_skill(meta: dict, *, installed_bins: set | None = None,
|
|
66
|
+
runs_summary: dict | None = None) -> dict:
|
|
67
|
+
"""Return {score, grade, recommendation, dimensions, blockers, capabilities}."""
|
|
68
|
+
installed_bins = installed_bins or set()
|
|
69
|
+
dims: list[dict] = []
|
|
70
|
+
blockers: list[str] = []
|
|
71
|
+
|
|
72
|
+
# --- Trust (25) ---
|
|
73
|
+
tscore = 0
|
|
74
|
+
treasons = []
|
|
75
|
+
if meta.get("verified"):
|
|
76
|
+
tscore += 12
|
|
77
|
+
treasons.append("已验证发布者")
|
|
78
|
+
elif meta.get("moderation_clean"):
|
|
79
|
+
tscore += 7
|
|
80
|
+
treasons.append("已通过官方安全审核")
|
|
81
|
+
dl_raw = meta.get("downloads")
|
|
82
|
+
st_raw = meta.get("stars")
|
|
83
|
+
adoption_known = (dl_raw is not None) or (st_raw is not None)
|
|
84
|
+
dl = _num(dl_raw)
|
|
85
|
+
st = _num(st_raw)
|
|
86
|
+
if dl >= 1000 or st >= 50:
|
|
87
|
+
tscore += 9
|
|
88
|
+
treasons.append("社区采用度高")
|
|
89
|
+
elif dl >= 100 or st >= 10:
|
|
90
|
+
tscore += 5
|
|
91
|
+
treasons.append("有一定采用度")
|
|
92
|
+
elif not adoption_known:
|
|
93
|
+
# Absence of data is not evidence of low quality — credit neutrally
|
|
94
|
+
# rather than punishing skills on markets that don't expose counts.
|
|
95
|
+
tscore += 4
|
|
96
|
+
treasons.append("采用度数据未公开")
|
|
97
|
+
else:
|
|
98
|
+
treasons.append("采用度偏低")
|
|
99
|
+
if meta.get("source"):
|
|
100
|
+
tscore += 4
|
|
101
|
+
dims.append({"name": "可信度", "score": min(tscore, 25), "max": 25, "reasons": treasons})
|
|
102
|
+
|
|
103
|
+
# --- Completeness (20) ---
|
|
104
|
+
cscore = 0
|
|
105
|
+
creasons = []
|
|
106
|
+
if meta.get("has_skill_md"):
|
|
107
|
+
cscore += 10
|
|
108
|
+
creasons.append("含 SKILL.md")
|
|
109
|
+
else:
|
|
110
|
+
creasons.append("缺少 SKILL.md")
|
|
111
|
+
desc = (meta.get("description") or "").strip()
|
|
112
|
+
if len(desc) >= 40:
|
|
113
|
+
cscore += 6
|
|
114
|
+
creasons.append("描述完整")
|
|
115
|
+
elif desc:
|
|
116
|
+
cscore += 3
|
|
117
|
+
creasons.append("描述简短")
|
|
118
|
+
else:
|
|
119
|
+
creasons.append("无描述")
|
|
120
|
+
if meta.get("version"):
|
|
121
|
+
cscore += 4
|
|
122
|
+
creasons.append("有版本号")
|
|
123
|
+
dims.append({"name": "完整性", "score": min(cscore, 20), "max": 20, "reasons": creasons})
|
|
124
|
+
|
|
125
|
+
# --- Hermes-fit (30) ---
|
|
126
|
+
fscore = 18 # baseline: all skills run through deterministic structured-JSON tools
|
|
127
|
+
freasons = ["走确定性结构化 JSON 工具"]
|
|
128
|
+
req_bins = [str(b) for b in (meta.get("requires_bins") or [])]
|
|
129
|
+
missing = [b for b in req_bins if b not in installed_bins] if req_bins else []
|
|
130
|
+
if not req_bins:
|
|
131
|
+
fscore += 8
|
|
132
|
+
freasons.append("无额外命令行依赖")
|
|
133
|
+
elif not missing:
|
|
134
|
+
fscore += 8
|
|
135
|
+
freasons.append("依赖均已就绪")
|
|
136
|
+
else:
|
|
137
|
+
freasons.append("缺少依赖:" + ", ".join(missing))
|
|
138
|
+
blockers.append("缺少命令行依赖:" + ", ".join(missing))
|
|
139
|
+
lic = (meta.get("license") or "").lower()
|
|
140
|
+
if any(k in lic for k in ("mit", "apache", "bsd", "isc")):
|
|
141
|
+
fscore += 4
|
|
142
|
+
freasons.append("许可证宽松")
|
|
143
|
+
elif lic:
|
|
144
|
+
fscore += 2
|
|
145
|
+
dims.append({"name": "Hermes 兼容性", "score": min(fscore, 30), "max": 30, "reasons": freasons})
|
|
146
|
+
|
|
147
|
+
# --- Maintenance (15) ---
|
|
148
|
+
mscore = 0
|
|
149
|
+
mreasons = []
|
|
150
|
+
upd = meta.get("updated_at")
|
|
151
|
+
ts = None
|
|
152
|
+
if isinstance(upd, (int, float)):
|
|
153
|
+
ts = float(upd)
|
|
154
|
+
elif isinstance(upd, str) and upd.strip():
|
|
155
|
+
u = upd.strip()
|
|
156
|
+
try:
|
|
157
|
+
ts = float(u) # epoch seconds as string
|
|
158
|
+
except ValueError:
|
|
159
|
+
try:
|
|
160
|
+
import datetime as _dt
|
|
161
|
+
|
|
162
|
+
ts = _dt.datetime.fromisoformat(u.replace("Z", "+00:00")).timestamp()
|
|
163
|
+
except Exception:
|
|
164
|
+
ts = None
|
|
165
|
+
if ts:
|
|
166
|
+
age_days = (time.time() - ts) / 86400.0
|
|
167
|
+
if age_days <= 90:
|
|
168
|
+
mscore += 12
|
|
169
|
+
mreasons.append("近 3 个月有更新")
|
|
170
|
+
elif age_days <= 365:
|
|
171
|
+
mscore += 7
|
|
172
|
+
mreasons.append("一年内有更新")
|
|
173
|
+
else:
|
|
174
|
+
mscore += 2
|
|
175
|
+
mreasons.append("超过一年未更新")
|
|
176
|
+
else:
|
|
177
|
+
mscore += 5
|
|
178
|
+
mreasons.append("更新时间未知")
|
|
179
|
+
if meta.get("version"):
|
|
180
|
+
mscore += 3
|
|
181
|
+
mreasons.append("版本可锁定")
|
|
182
|
+
dims.append({"name": "维护度", "score": min(mscore, 15), "max": 15, "reasons": mreasons})
|
|
183
|
+
|
|
184
|
+
# --- Safety (10) ---
|
|
185
|
+
sscore = 10
|
|
186
|
+
sreasons = []
|
|
187
|
+
caps = infer_capabilities(meta)
|
|
188
|
+
if "exec" in caps and "network" in caps:
|
|
189
|
+
sscore -= 3
|
|
190
|
+
sreasons.append("可执行命令且联网(注意授权)")
|
|
191
|
+
if meta.get("requires_api_key"):
|
|
192
|
+
sreasons.append("需 API Key(如实声明)")
|
|
193
|
+
if not sreasons:
|
|
194
|
+
sreasons.append("无明显风险信号")
|
|
195
|
+
dims.append({"name": "安全", "score": max(sscore, 0), "max": 10, "reasons": sreasons})
|
|
196
|
+
|
|
197
|
+
base = sum(d["score"] for d in dims)
|
|
198
|
+
|
|
199
|
+
# --- Real-world reliability (from Pro-1 run history) ---
|
|
200
|
+
reliability = None
|
|
201
|
+
if runs_summary and runs_summary.get("total"):
|
|
202
|
+
total = int(runs_summary["total"])
|
|
203
|
+
sr = runs_summary.get("success_rate")
|
|
204
|
+
sr = float(sr) if sr is not None else None
|
|
205
|
+
if sr is not None:
|
|
206
|
+
if sr >= 0.9:
|
|
207
|
+
base = min(100, base + 5)
|
|
208
|
+
rel_note = "实测可靠(成功率 %d%%)" % round(sr * 100)
|
|
209
|
+
elif sr >= 0.6:
|
|
210
|
+
rel_note = "实测一般(成功率 %d%%)" % round(sr * 100)
|
|
211
|
+
else:
|
|
212
|
+
base = max(0, base - 8)
|
|
213
|
+
rel_note = "实测不稳定(成功率 %d%%,近 %d 次)" % (round(sr * 100), total)
|
|
214
|
+
blockers.append(rel_note)
|
|
215
|
+
reliability = {"total": total, "success_rate": sr, "note": rel_note}
|
|
216
|
+
|
|
217
|
+
score = max(0, min(100, int(round(base))))
|
|
218
|
+
grade = _grade(score)
|
|
219
|
+
if blockers:
|
|
220
|
+
recommendation = "caution"
|
|
221
|
+
elif score >= 75:
|
|
222
|
+
recommendation = "recommended"
|
|
223
|
+
elif score >= 50:
|
|
224
|
+
recommendation = "caution"
|
|
225
|
+
else:
|
|
226
|
+
recommendation = "not_recommended"
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
"score": score,
|
|
230
|
+
"grade": grade,
|
|
231
|
+
"recommendation": recommendation,
|
|
232
|
+
"dimensions": dims,
|
|
233
|
+
"blockers": blockers,
|
|
234
|
+
"capabilities": caps,
|
|
235
|
+
"reliability": reliability,
|
|
236
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Backend resolver — picks CLI vs native per config and caches the instance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
|
|
7
|
+
from ..config import get_settings
|
|
8
|
+
from .base import Backend
|
|
9
|
+
|
|
10
|
+
_lock = threading.Lock()
|
|
11
|
+
_cache: dict[str, Backend] = {}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_backend() -> Backend:
|
|
15
|
+
"""Resolve the active backend (thread-safe, cached by resolved name)."""
|
|
16
|
+
resolved = get_settings().resolved_backend()
|
|
17
|
+
cached = _cache.get(resolved)
|
|
18
|
+
if cached is not None:
|
|
19
|
+
return cached
|
|
20
|
+
with _lock:
|
|
21
|
+
cached = _cache.get(resolved)
|
|
22
|
+
if cached is not None:
|
|
23
|
+
return cached
|
|
24
|
+
if resolved == "cli":
|
|
25
|
+
from .cli_backend import CliBackend
|
|
26
|
+
|
|
27
|
+
backend: Backend = CliBackend()
|
|
28
|
+
else:
|
|
29
|
+
from .native_backend import NativeBackend
|
|
30
|
+
|
|
31
|
+
backend = NativeBackend()
|
|
32
|
+
_cache[resolved] = backend
|
|
33
|
+
return backend
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def reset_backend() -> None:
|
|
37
|
+
"""Drop cached backends (tests / config reload)."""
|
|
38
|
+
with _lock:
|
|
39
|
+
_cache.clear()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
__all__ = ["Backend", "get_backend", "reset_backend"]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Backend abstract base class.
|
|
2
|
+
|
|
3
|
+
A backend is the deterministic execution substrate. Two implementations:
|
|
4
|
+
- CliBackend : shells out to `openclaw` / `clawhub`.
|
|
5
|
+
- NativeBackend : decoupled Python re-implementation (no Node dependency).
|
|
6
|
+
|
|
7
|
+
All methods return a plain dict (NOT a JSON string); tools.py handles
|
|
8
|
+
serialization, retry, rollback and audit. Methods may raise — the caller
|
|
9
|
+
wraps them. Prefer returning {"ok": False, "error": ...} for expected
|
|
10
|
+
failures and raising only for unexpected ones.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import abc
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Backend(abc.ABC):
|
|
20
|
+
name: str = "base"
|
|
21
|
+
|
|
22
|
+
# -- skill registry ----------------------------------------------------
|
|
23
|
+
@abc.abstractmethod
|
|
24
|
+
def skill_search(self, *, query: str, limit: int, page: int = 1,
|
|
25
|
+
category: str | None = None, sort: str | None = None) -> dict[str, Any]: ...
|
|
26
|
+
|
|
27
|
+
@abc.abstractmethod
|
|
28
|
+
def skill_install(
|
|
29
|
+
self, *, slug: str, verify: bool, global_install: bool, git_fallback: bool = False
|
|
30
|
+
) -> dict[str, Any]: ...
|
|
31
|
+
|
|
32
|
+
@abc.abstractmethod
|
|
33
|
+
def skill_run(
|
|
34
|
+
self, *, slug: str, entry: str, args: dict, sandbox: bool
|
|
35
|
+
) -> dict[str, Any]: ...
|
|
36
|
+
|
|
37
|
+
@abc.abstractmethod
|
|
38
|
+
def skill_update(self, *, slug: str | None, all_: bool) -> dict[str, Any]: ...
|
|
39
|
+
|
|
40
|
+
# -- execution ---------------------------------------------------------
|
|
41
|
+
@abc.abstractmethod
|
|
42
|
+
def sandbox_exec(
|
|
43
|
+
self,
|
|
44
|
+
*,
|
|
45
|
+
command: str,
|
|
46
|
+
image: str,
|
|
47
|
+
timeout_s: int,
|
|
48
|
+
network: bool,
|
|
49
|
+
workdir: str | None,
|
|
50
|
+
) -> dict[str, Any]: ...
|
|
51
|
+
|
|
52
|
+
@abc.abstractmethod
|
|
53
|
+
def browser(self, *, actions: list[dict], headless: bool) -> dict[str, Any]: ...
|
|
54
|
+
|
|
55
|
+
@abc.abstractmethod
|
|
56
|
+
def runtime(self, *, lang: str, code: str, timeout_s: int) -> dict[str, Any]: ...
|