重构仓库目录为统一的 runtime 分层并清理历史 openclaw 残留。

本次迁移将网关/通道/工具/技能/脚本与协议资源集中到新结构,统一路径常量与脚本转发机制,减少顶层噪音并保证运行与测试行为一致。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-25 01:24:23 +08:00
parent ba3836f00f
commit 4a23b715a2
498 changed files with 2760 additions and 2200 deletions

View file

@ -0,0 +1,2 @@
"""按专家维度组织的工具目录。"""

View file

@ -0,0 +1,29 @@
"""通识专家工具清单。"""
from oclaw.runtime.tools.base import ToolSpec
def system_info_tool() -> ToolSpec:
from .system_info import system_info_tool as factory
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"]

View file

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

View file

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

View file

@ -0,0 +1,129 @@
from __future__ import annotations
import base64
import io
import os
from typing import Any
from PIL import Image
from oclaw.platform.files.attachment_assets import AttachmentAssetStore
from oclaw.runtime.tools.base import ToolSpec
def image_edit_tool() -> ToolSpec:
"""Edit an uploaded image using OpenAI Images API.
Input image is referenced by attachment_id (disk-backed asset store).
Output is saved back to the asset store and returned as attachment_id.
"""
def handler(args: dict[str, Any]) -> dict[str, Any]:
attachment_id = str(args.get("attachment_id") or "").strip()
instruction = str(args.get("instruction") or "").strip()
model = str(args.get("model") or os.getenv("OPENAI_IMAGE_MODEL") or "gpt-image-1").strip()
if not attachment_id:
return {"ok": False, "error": "attachment_id is required"}
if not instruction:
return {"ok": False, "error": "instruction is required"}
store = AttachmentAssetStore()
blob, meta = store.load_bytes(attachment_id)
if not blob:
return {"ok": False, "error": f"attachment not found: {attachment_id}"}
try:
from openai import OpenAI
except Exception as e:
return {"ok": False, "error": f"openai package is not available: {type(e).__name__}: {e}"}
api_key = (os.getenv("OPENAI_API_KEY") or "").strip()
base_url = (os.getenv("OPENAI_BASE_URL") or "").strip()
if not api_key:
return {"ok": False, "error": "OPENAI_API_KEY is not set"}
client_kwargs: dict[str, Any] = {"api_key": api_key}
if base_url:
client_kwargs["base_url"] = base_url
client = OpenAI(**client_kwargs)
# OpenAI SDK expects a file-like object for edits.
img_file = io.BytesIO(blob)
img_file.name = "input.png" # type: ignore[attr-defined]
b64_out: str | None = None
try:
# Preferred: image edit endpoint (if supported by the gateway/model).
resp = client.images.edit( # type: ignore[attr-defined]
model=model,
image=img_file,
prompt=instruction,
response_format="b64_json",
)
data0 = resp.data[0] if getattr(resp, "data", None) else None
b64_out = getattr(data0, "b64_json", None) if data0 is not None else None
except Exception:
# Fallback: generate a new image from prompt (still returns an image, but not true edit).
try:
resp = client.images.generate( # type: ignore[attr-defined]
model=model,
prompt=instruction,
response_format="b64_json",
)
data0 = resp.data[0] if getattr(resp, "data", None) else None
b64_out = getattr(data0, "b64_json", None) if data0 is not None else None
except Exception as e2:
return {"ok": False, "error": f"image api failed: {type(e2).__name__}: {e2}"}
if not b64_out:
return {"ok": False, "error": "image api returned no b64_json payload"}
try:
out_bytes = base64.b64decode(b64_out.encode("ascii"))
except Exception as e:
return {"ok": False, "error": f"failed to decode image b64: {type(e).__name__}: {e}"}
width = None
height = None
try:
with Image.open(io.BytesIO(out_bytes)) as im:
width, height = im.size
except Exception:
pass
out_meta = store.save_bytes(
out_bytes,
filename=f"edited-{meta.name if meta else 'image'}.png",
mime="image/png",
width=width,
height=height,
)
return {
"ok": True,
"attachment_id": out_meta.attachment_id,
"name": out_meta.name,
"mime": out_meta.mime,
"bytes": out_meta.bytes,
"width": out_meta.width,
"height": out_meta.height,
}
return ToolSpec(
name="image_edit",
description="Edit an uploaded image referenced by attachment_id, returning a new attachment_id.",
parameters={
"type": "object",
"properties": {
"attachment_id": {"type": "string", "description": "Input image attachment id (image_ref)."},
"instruction": {"type": "string", "description": "Edit instruction for the image."},
"model": {"type": "string", "description": "OpenAI image model name (default: gpt-image-1)."},
},
"required": ["attachment_id", "instruction"],
},
handler=handler,
tags=frozenset({"image", "edit"}),
)
__all__ = ["image_edit_tool"]

View file

@ -0,0 +1,33 @@
from __future__ import annotations
import datetime
import time
from typing import Any
from oclaw.runtime.tools.base import ToolSpec
def system_info_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
now = datetime.datetime.now()
utc_now = datetime.datetime.now(datetime.timezone.utc)
timezone_name = time.tzname[0] if time.daylight == 0 else time.tzname[1]
timezone_offset = (now - utc_now.replace(tzinfo=None)).total_seconds() / 3600
return {
"ok": True,
"current_time": now.strftime("%Y-%m-%d %H:%M:%S"),
"timezone": timezone_name,
"timezone_offset": f"UTC{'+' if timezone_offset >= 0 else ''}{timezone_offset:g}",
"timestamp": int(time.time()),
}
return ToolSpec(
name="get_system_time",
description="Return the current local time, timezone name, and UTC offset.",
parameters={"type": "object", "properties": {}, "additionalProperties": False},
handler=handler,
read_only=True,
)
__all__ = ["system_info_tool"]

View file

@ -0,0 +1,150 @@
from __future__ import annotations
from typing import Any
from oclaw.platform.files.tabular_attachment_store import (
aggregate_table,
analyze_table_full_scan,
query_table,
run_table_sql,
)
from oclaw.runtime.tools.base import ToolSpec
def query_tabular_attachment_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
table_id = str(args.get("table_id") or "").strip()
if not table_id:
return {"ok": False, "error": "table_id_required"}
raw_cols = args.get("columns")
cols = [str(x) for x in raw_cols] if isinstance(raw_cols, list) else None
sheet = str(args.get("sheet") or "").strip() or None
where_contains = args.get("where_contains") if isinstance(args.get("where_contains"), dict) else None
aggregate = args.get("aggregate") if isinstance(args.get("aggregate"), dict) else None
if aggregate:
return aggregate_table(
table_id=table_id,
metric=str(aggregate.get("metric") or ""),
target_column=str(aggregate.get("target_column") or "").strip() or None,
group_by=str(aggregate.get("group_by") or "").strip() or None,
where_contains=where_contains,
top_n=int(aggregate.get("top_n") or 20),
sheet=sheet,
)
return query_table(
table_id=table_id,
columns=cols,
limit=int(args.get("limit") or 50),
offset=int(args.get("offset") or 0),
where_contains=where_contains, # {"column":"...", "keyword":"..."}
sheet=sheet,
)
return ToolSpec(
name="query_tabular_attachment",
description="Query rows from a large uploaded table by table_id with optional column selection and keyword filter.",
parameters={
"type": "object",
"properties": {
"table_id": {"type": "string"},
"sheet": {"type": "string"},
"columns": {"type": "array", "items": {"type": "string"}},
"limit": {"type": "integer", "minimum": 1, "maximum": 200},
"offset": {"type": "integer", "minimum": 0},
"where_contains": {
"type": "object",
"properties": {
"column": {"type": "string"},
"keyword": {"type": "string"},
},
"additionalProperties": False,
},
"aggregate": {
"type": "object",
"properties": {
"metric": {"type": "string", "enum": ["count", "sum", "avg"]},
"target_column": {"type": "string"},
"group_by": {"type": "string"},
"top_n": {"type": "integer", "minimum": 1, "maximum": 200},
},
"required": ["metric"],
"additionalProperties": False,
},
},
"required": ["table_id"],
"additionalProperties": False,
},
handler=handler,
read_only=True,
)
def run_tabular_sql_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
table_id = str(args.get("table_id") or "").strip()
sql = str(args.get("sql") or "").strip()
sheet = str(args.get("sheet") or "").strip() or None
if not table_id:
return {"ok": False, "error": "table_id_required"}
return run_table_sql(
table_id=table_id,
sql=sql,
limit=int(args.get("limit") or 200),
sheet=sheet,
)
return ToolSpec(
name="run_tabular_sql",
description="Run a READ-ONLY SQL query against uploaded table by table_id. Only SELECT/WITH allowed.",
parameters={
"type": "object",
"properties": {
"table_id": {"type": "string"},
"sheet": {"type": "string"},
"sql": {"type": "string"},
"limit": {"type": "integer", "minimum": 1, "maximum": 500},
},
"required": ["table_id", "sql"],
"additionalProperties": False,
},
handler=handler,
read_only=True,
)
def analyze_tabular_attachment_full_scan_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
table_id = str(args.get("table_id") or "").strip()
if not table_id:
return {"ok": False, "error": "table_id_required"}
raw_cols = args.get("columns")
cols = [str(x) for x in raw_cols] if isinstance(raw_cols, list) else None
sheet = str(args.get("sheet") or "").strip() or None
return analyze_table_full_scan(
table_id=table_id,
columns=cols,
sheet=sheet,
top_values_limit=int(args.get("top_values_limit") or 3),
)
return ToolSpec(
name="analyze_tabular_attachment_full_scan",
description="Run a full-table scan for selected columns and return concise profiling stats with audit evidence.",
parameters={
"type": "object",
"properties": {
"table_id": {"type": "string"},
"sheet": {"type": "string"},
"columns": {"type": "array", "items": {"type": "string"}},
"top_values_limit": {"type": "integer", "minimum": 0, "maximum": 10},
},
"required": ["table_id"],
"additionalProperties": False,
},
handler=handler,
read_only=True,
)
__all__ = ["query_tabular_attachment_tool", "run_tabular_sql_tool", "analyze_tabular_attachment_full_scan_tool"]

View file

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

View file

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

View file

@ -0,0 +1,15 @@
from .wiki_curator_tools import (
memory_curator_wiki_apply_tool,
memory_curator_wiki_get_tool,
memory_curator_wiki_lint_tool,
memory_curator_wiki_search_tool,
memory_curator_wiki_status_tool,
)
__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",
]

View file

@ -0,0 +1,156 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Callable
from oclaw.platform.config.paths import PROJECT_ROOT
from oclaw.runtime.tools.base import ToolSpec
def _plugin_cfg() -> dict[str, Any]:
cfg_path = (PROJECT_ROOT / "oclaw" / "oclaw.json").resolve()
if not cfg_path.exists():
return {}
try:
import json
obj = json.loads(cfg_path.read_text(encoding="utf-8"))
except Exception:
return {}
plugins = obj.get("plugins") if isinstance(obj, dict) else {}
entries = plugins.get("entries") if isinstance(plugins, dict) else {}
entry = entries.get("memory-wiki") if isinstance(entries, dict) else {}
return entry if isinstance(entry, dict) else {}
def _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))
if spec is None or spec.loader is None:
return {}
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod) # type: ignore[assignment]
fn = getattr(mod, "build_wiki_tool_specs", None)
if not callable(fn):
return {}
specs = fn(SimpleNamespace(plugin_config=_plugin_cfg()))
out: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {}
for spec_item in specs:
if not isinstance(spec_item, dict):
continue
name = str(spec_item.get("name") or "").strip()
handler = spec_item.get("handler")
if name and callable(handler):
out[name] = handler
return out
def _delegate(tool_name: str, args: dict[str, Any]) -> dict[str, Any]:
handlers = _wiki_handlers()
h = handlers.get(tool_name)
if not callable(h):
return {"ok": False, "error": f"wiki handler unavailable: {tool_name}"}
try:
return h(dict(args or {}))
except Exception as exc:
return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
def memory_curator_wiki_status_tool() -> ToolSpec:
return ToolSpec(
name="memory_curator_wiki_status",
description="Read wiki runtime status for memory curation.",
parameters={"type": "object", "properties": {}, "required": [], "additionalProperties": False},
handler=lambda args: _delegate("wiki_status", args),
tags=frozenset({"memory", "wiki", "curator"}),
read_only=True,
)
def memory_curator_wiki_get_tool() -> ToolSpec:
return ToolSpec(
name="memory_curator_wiki_get",
description="Read a markdown file from wiki for curation.",
parameters={
"type": "object",
"properties": {
"path": {"type": "string"},
"start_line": {"type": "integer"},
"end_line": {"type": "integer"},
},
"required": ["path"],
"additionalProperties": False,
},
handler=lambda args: _delegate("wiki_get", args),
tags=frozenset({"memory", "wiki", "curator"}),
read_only=True,
)
def memory_curator_wiki_search_tool() -> ToolSpec:
return ToolSpec(
name="memory_curator_wiki_search",
description="Search wiki markdown for memory curation.",
parameters={
"type": "object",
"properties": {
"query": {"type": "string"},
"limit": {"type": "integer"},
"is_regex": {"type": "boolean"},
"case_sensitive": {"type": "boolean"},
},
"required": ["query"],
"additionalProperties": False,
},
handler=lambda args: _delegate("wiki_search", args),
tags=frozenset({"memory", "wiki", "curator"}),
read_only=True,
)
def memory_curator_wiki_lint_tool() -> ToolSpec:
return ToolSpec(
name="memory_curator_wiki_lint",
description="Lint wiki markdown structure for curation quality.",
parameters={
"type": "object",
"properties": {"path": {"type": "string"}},
"required": [],
"additionalProperties": False,
},
handler=lambda args: _delegate("wiki_lint", args),
tags=frozenset({"memory", "wiki", "curator"}),
read_only=True,
)
def memory_curator_wiki_apply_tool() -> ToolSpec:
return ToolSpec(
name="memory_curator_wiki_apply",
description="Apply curated write/append/delete changes to wiki markdown.",
parameters={
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["write", "append", "delete"]},
"path": {"type": "string"},
"content": {"type": "string"},
},
"required": ["action", "path"],
"additionalProperties": False,
},
handler=lambda args: _delegate("wiki_apply", args),
tags=frozenset({"memory", "wiki", "curator", "write"}),
risk_level="high",
read_only=False,
)
__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",
]

View file

@ -0,0 +1,40 @@
from __future__ import annotations
import difflib
from typing import Any
from oclaw.runtime.tools.base import ToolSpec
def config_diff_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
left_name = str(args.get("left_name") or "left")
right_name = str(args.get("right_name") or "right")
left = str(args.get("left") or "")
right = str(args.get("right") or "")
left_lines = left.splitlines(keepends=False)
right_lines = right.splitlines(keepends=False)
diff_lines = list(
difflib.unified_diff(left_lines, right_lines, fromfile=left_name, tofile=right_name, lineterm="")
)
return {"ok": True, "diff": "\n".join(diff_lines), "changed": left_lines != right_lines}
return ToolSpec(
name="config_diff",
description="Compare two configuration texts and return a unified diff.",
parameters={
"type": "object",
"properties": {
"left_name": {"type": "string", "description": "Optional label for the left side."},
"right_name": {"type": "string", "description": "Optional label for the right side."},
"left": {"type": "string", "description": "Left configuration text."},
"right": {"type": "string", "description": "Right configuration text."},
},
"required": ["left", "right"],
"additionalProperties": False,
},
handler=handler,
)
__all__ = ["config_diff_tool"]

View file

@ -0,0 +1,78 @@
from __future__ import annotations
import re
import subprocess
import sys
from typing import Any
from oclaw.runtime.tools.base import ToolSpec
_TTL_RE = re.compile(r"\bttl[= ]\d+\b", re.IGNORECASE)
_AVG_WIN_RE = re.compile(r"Average\s*=\s*(\d+)\s*ms", re.IGNORECASE)
_AVG_NIX_RE = re.compile(r"=\s*[\d.]+/([\d.]+)/[\d.]+/[\d.]+\s*ms")
def _ping(host: str, count: int, timeout_ms: int) -> dict[str, Any]:
try:
if sys.platform == "win32":
cmd = ["ping", "-n", str(count), "-w", str(timeout_ms), host]
timeout_s = max(1, (timeout_ms * count) / 1000 + 2)
else:
timeout_s_each = max(1, int(round(timeout_ms / 1000)))
cmd = ["ping", "-c", str(count), "-W", str(timeout_s_each), host]
timeout_s = max(1, timeout_s_each * count + 2)
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_s)
except FileNotFoundError:
return {"ok": False, "error": "ping command not found on this system"}
except subprocess.TimeoutExpired:
return {"ok": True, "reachable": False, "output": "ping timed out"}
output = (proc.stdout or "") + ("\n" + proc.stderr if proc.stderr else "")
reachable = proc.returncode == 0 and bool(_TTL_RE.search(output))
avg_ms = None
if sys.platform == "win32":
m = _AVG_WIN_RE.search(output)
if m:
try:
avg_ms = int(m.group(1))
except ValueError:
avg_ms = None
else:
m2 = _AVG_NIX_RE.search(output)
if m2:
try:
avg_ms = int(float(m2.group(1)))
except ValueError:
avg_ms = None
return {"ok": True, "reachable": reachable, "avg_ms": avg_ms, "returncode": proc.returncode, "output": output}
def device_status_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
host = str(args.get("host"))
count = int(args.get("count") or 2)
timeout_ms = int(args.get("timeout_ms") or 1000)
if count < 1 or count > 10:
return {"ok": False, "error": "count must be between 1 and 10"}
if timeout_ms < 200 or timeout_ms > 10000:
return {"ok": False, "error": "timeout_ms must be between 200 and 10000"}
return _ping(host=host, count=count, timeout_ms=timeout_ms)
return ToolSpec(
name="device_status",
description="Check host reachability using ICMP ping (system ping binary).",
parameters={
"type": "object",
"properties": {
"host": {"type": "string", "description": "Hostname or IP address."},
"count": {"type": "integer", "description": "Number of ping probes. Default 2."},
"timeout_ms": {"type": "integer", "description": "Per-packet timeout in milliseconds. Default 1000."},
},
"required": ["host"],
"additionalProperties": False,
},
handler=handler,
)
__all__ = ["device_status_tool"]

View file

@ -0,0 +1,99 @@
from __future__ import annotations
from collections import deque
from typing import Any
from oclaw.runtime.tools.base import ToolSpec
def _default_edges() -> list[tuple[str, str]]:
return [
("R1", "R2"),
("R2", "R3"),
("R3", "R4"),
("R2", "R5"),
("R5", "R4"),
("R1", "SW1"),
("SW1", "FW1"),
("FW1", "R3"),
]
def _build_adj(edges: list[tuple[str, str]]) -> dict[str, set[str]]:
adj: dict[str, set[str]] = {}
for a, b in edges:
adj.setdefault(a, set()).add(b)
adj.setdefault(b, set()).add(a)
return adj
def _bfs_path(adj: dict[str, set[str]], src: str, dst: str) -> list[str] | None:
if src == dst:
return [src]
q: deque[str] = deque([src])
prev: dict[str, str | None] = {src: None}
while q:
cur = q.popleft()
for nxt in sorted(adj.get(cur, set())):
if nxt in prev:
continue
prev[nxt] = cur
if nxt == dst:
q.clear()
break
q.append(nxt)
if dst not in prev:
return None
path: list[str] = []
cur2: str | None = dst
while cur2 is not None:
path.append(cur2)
cur2 = prev[cur2]
path.reverse()
return path
def get_path_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
src = str(args.get("src"))
dst = str(args.get("dst"))
raw_edges = args.get("topology_edges")
edges: list[tuple[str, str]]
if raw_edges is None:
edges = _default_edges()
else:
edges = []
for item in raw_edges:
if not isinstance(item, (list, tuple)) or len(item) != 2:
return {"ok": False, "error": "topology_edges must be an array of pairs; each item must contain two node names."}
edges.append((str(item[0]), str(item[1])))
adj = _build_adj(edges)
path = _bfs_path(adj, src, dst)
if not path:
return {"ok": False, "src": src, "dst": dst, "error": "No reachable path in the given topology."}
return {"ok": True, "src": src, "dst": dst, "hops": path, "hop_count": len(path) - 1}
return ToolSpec(
name="get_path",
description="Compute the shortest path from src to dst (BFS) over an undirected topology. Optional topology_edges overrides the built-in demo graph.",
parameters={
"type": "object",
"properties": {
"src": {"type": "string", "description": "Source node name."},
"dst": {"type": "string", "description": "Destination node name."},
"topology_edges": {
"type": "array",
"description": 'Optional edge list, e.g. [["R1","R2"],["R2","R3"]].',
"items": {"type": "array", "items": {"type": "string"}, "minItems": 2, "maxItems": 2},
},
},
"required": ["src", "dst"],
"additionalProperties": False,
},
handler=handler,
)
__all__ = ["get_path_tool"]

View file

@ -0,0 +1,53 @@
from __future__ import annotations
import re
from collections import Counter
from typing import Any
from oclaw.runtime.tools.base import ToolSpec
_LEVEL_RE = re.compile(r"\b(ERROR|WARN|WARNING|INFO|DEBUG)\b", re.IGNORECASE)
def log_analysis_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
text = str(args.get("log") or "")
max_lines = int(args.get("max_lines") or 2000)
lines = text.splitlines()
if len(lines) > max_lines:
lines = lines[-max_lines:]
levels: Counter[str] = Counter()
samples: dict[str, list[str]] = {"ERROR": [], "WARN": []}
for line in lines:
m = _LEVEL_RE.search(line)
if not m:
continue
level = m.group(1).upper()
if level == "WARNING":
level = "WARN"
if level in ("ERROR", "WARN", "INFO", "DEBUG"):
levels[level] += 1
if level in samples and len(samples[level]) < 5:
samples[level].append(line[:500])
top_lines = [l[:500] for l in lines[-20:]]
return {"ok": True, "line_count": len(lines), "level_count": dict(levels), "samples": samples, "tail": top_lines}
return ToolSpec(
name="log_analysis",
description="Summarize log text: counts of ERROR/WARN/INFO/DEBUG lines, sample lines, and the last lines (tail).",
parameters={
"type": "object",
"properties": {
"log": {"type": "string", "description": "Log text to analyze."},
"max_lines": {"type": "integer", "description": "Maximum number of lines to analyze (uses the tail if exceeded). Default 2000."},
},
"required": ["log"],
"additionalProperties": False,
},
handler=handler,
)
__all__ = ["log_analysis_tool"]

View file

@ -0,0 +1,259 @@
from __future__ import annotations
import concurrent.futures
import datetime
import socket
import ssl
import subprocess
import sys
import uuid
from typing import Any
import httpx
from oclaw.runtime.tools.base import ToolSpec
def dns_lookup_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
domain = args.get("domain")
if not domain:
return {"ok": False, "error": "domain is required"}
try:
ips = socket.gethostbyname_ex(domain)[2]
return {"ok": True, "domain": domain, "ips": ips, "count": len(ips)}
except Exception as e:
return {"ok": False, "error": f"DNS resolution failed: {e}"}
return ToolSpec(
name="dns_lookup",
description="Resolve a domain name to IPv4 addresses (A records via system resolver).",
parameters={
"type": "object",
"properties": {
"domain": {"type": "string", "description": "Domain name (e.g. example.com)."},
},
"required": ["domain"],
"additionalProperties": False,
},
handler=handler,
)
def ssl_check_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
domain = args.get("domain")
port = int(args.get("port") or 443)
if not domain:
return {"ok": False, "error": "domain is required"}
try:
context = ssl.create_default_context()
with socket.create_connection((domain, port), timeout=10) as sock:
with context.wrap_socket(sock, server_hostname=domain) as ssock:
cert = ssock.getpeercert()
not_before = datetime.datetime.strptime(cert["notBefore"], "%b %d %H:%M:%S %Y %Z")
not_after = datetime.datetime.strptime(cert["notAfter"], "%b %d %H:%M:%S %Y %Z")
remaining_days = (not_after - datetime.datetime.utcnow()).days
subject = dict(x[0] for x in cert["subject"])
issuer = dict(x[0] for x in cert["issuer"])
return {
"ok": True,
"domain": domain,
"issuer": issuer.get("commonName"),
"issued_to": subject.get("commonName"),
"valid_from": not_before.strftime("%Y-%m-%d"),
"valid_until": not_after.strftime("%Y-%m-%d"),
"remaining_days": remaining_days,
"is_expired": remaining_days < 0,
}
except Exception as e:
return {"ok": False, "error": f"SSL check failed: {e}"}
return ToolSpec(
name="ssl_cert_check",
description="Inspect the TLS certificate presented by host:port (default 443).",
parameters={
"type": "object",
"properties": {
"domain": {"type": "string", "description": "Server hostname."},
"port": {"type": "integer", "description": "TCP port. Default 443."},
},
"required": ["domain"],
"additionalProperties": False,
},
handler=handler,
)
def port_check_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
host = args.get("host")
port = int(args.get("port"))
protocol = str(args.get("protocol") or "tcp").lower()
timeout = float(args.get("timeout") or 2.0)
if not host or not port:
return {"ok": False, "error": "host and port are required"}
if protocol == "tcp":
try:
with socket.create_connection((host, port), timeout=timeout):
return {"ok": True, "host": host, "port": port, "protocol": "TCP", "status": "open"}
except socket.timeout:
return {"ok": True, "host": host, "port": port, "protocol": "TCP", "status": "timeout"}
except Exception as e:
return {"ok": True, "host": host, "port": port, "protocol": "TCP", "status": "closed", "error": str(e)}
if protocol == "udp":
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(timeout)
sock.sendto(b"", (host, port))
try:
sock.recvfrom(1024)
return {"ok": True, "host": host, "port": port, "protocol": "UDP", "status": "open", "received": True}
except socket.timeout:
return {"ok": True, "host": host, "port": port, "protocol": "UDP", "status": "open|filtered"}
except Exception as e:
return {"ok": True, "host": host, "port": port, "protocol": "UDP", "status": "closed", "error": str(e)}
finally:
sock.close()
except Exception as e:
return {"ok": False, "error": f"UDP check failed: {e}"}
return {"ok": False, "error": f"Unsupported protocol: {protocol}"}
return ToolSpec(
name="port_check",
description="Test whether a TCP or UDP port appears open on a host.",
parameters={
"type": "object",
"properties": {
"host": {"type": "string", "description": "Hostname or IP address."},
"port": {"type": "integer", "description": "Port number."},
"protocol": {"type": "string", "enum": ["tcp", "udp"], "description": "tcp or udp. Default tcp."},
"timeout": {"type": "number", "description": "Timeout in seconds. Default 2.0."},
},
"required": ["host", "port"],
"additionalProperties": False,
},
handler=handler,
)
def port_scan_tool() -> ToolSpec:
COMMON_PORTS = [21, 22, 23, 25, 53, 80, 110, 143, 443, 445, 1433, 1521, 3306, 3389, 5432, 6379, 8080, 27017]
def scan_port(host: str, port: int, timeout: float) -> int | None:
try:
with socket.create_connection((host, port), timeout=timeout):
return port
except Exception:
return None
def handler(args: dict[str, Any]) -> dict[str, Any]:
host = args.get("host")
start_port = args.get("start_port")
end_port = args.get("end_port")
ports_to_scan = args.get("ports")
timeout = float(args.get("timeout") or 0.5)
max_threads = int(args.get("max_threads") or 20)
if not host:
return {"ok": False, "error": "host is required"}
if ports_to_scan:
ports = [int(p) for p in ports_to_scan]
elif start_port is not None and end_port is not None:
s, e = int(start_port), int(end_port)
if e - s > 1000:
return {"ok": False, "error": "Cannot scan more than 1000 ports in one call"}
ports = list(range(s, e + 1))
else:
ports = COMMON_PORTS
open_ports: list[int] = []
with concurrent.futures.ThreadPoolExecutor(max_workers=max_threads) as executor:
future_to_port = {executor.submit(scan_port, host, port, timeout): port for port in ports}
for future in concurrent.futures.as_completed(future_to_port):
result = future.result()
if result is not None:
open_ports.append(result)
open_ports.sort()
return {
"ok": True,
"host": host,
"open_ports": open_ports,
"scanned_count": len(ports),
"open_count": len(open_ports),
"status": "completed",
}
return ToolSpec(
name="port_scan",
description="Scan TCP ports on a host (common ports, a numeric range, or an explicit list).",
parameters={
"type": "object",
"properties": {
"host": {"type": "string", "description": "Hostname or IP address."},
"start_port": {"type": "integer", "description": "Start of port range (inclusive)."},
"end_port": {"type": "integer", "description": "End of port range (inclusive)."},
"ports": {"type": "array", "items": {"type": "integer"}, "description": "Explicit list of ports to scan."},
"timeout": {"type": "number", "description": "Per-port timeout in seconds. Default 0.5."},
"max_threads": {"type": "integer", "description": "Maximum concurrent probes. Default 20."},
},
"required": ["host"],
"additionalProperties": False,
},
handler=handler,
)
def local_net_info_tool() -> ToolSpec:
def get_mac_address() -> str:
return ":".join(["{:02x}".format((uuid.getnode() >> i) & 0xFF) for i in range(0, 8 * 6, 8)][::-1])
def get_public_ip() -> str:
try:
with httpx.Client(timeout=5.0) as client:
resp = client.get("https://api64.ipify.org?format=json")
return str(resp.json().get("ip") or "Unknown")
except Exception:
return "Unknown"
def get_gateway() -> str:
try:
if sys.platform == "win32":
output = subprocess.check_output("route print 0.0.0.0", shell=True).decode("gbk", errors="replace")
for line in output.splitlines():
if "0.0.0.0" in line and "On-link" not in line:
parts = line.split()
if len(parts) >= 3:
return parts[2]
else:
output = subprocess.check_output("ip route show default", shell=True).decode(errors="replace")
return output.split()[2]
except Exception:
return "Unknown"
def handler(args: dict[str, Any]) -> dict[str, Any]:
try:
hostname = socket.gethostname()
local_ip = socket.gethostbyname(hostname)
mac = get_mac_address()
gateway = get_gateway()
public_ip = get_public_ip()
return {
"ok": True,
"hostname": hostname,
"local_ip": local_ip,
"public_ip": public_ip,
"mac_address": mac,
"gateway": gateway,
"platform": sys.platform,
}
except Exception as e:
return {"ok": False, "error": f"Failed to read local network info: {e}"}
return ToolSpec(
name="get_local_net_info",
description="Summarize local hostname, IPs, MAC, default gateway, and OS platform (best-effort).",
parameters={"type": "object", "properties": {}, "additionalProperties": False},
handler=handler,
)
__all__ = ["dns_lookup_tool", "ssl_check_tool", "port_check_tool", "port_scan_tool", "local_net_info_tool"]

View file

@ -0,0 +1,77 @@
"""网络运维专家工具清单。"""
from oclaw.runtime.tools.base import ToolSpec
def query_route_tool() -> ToolSpec:
from .query_route import query_route_tool as factory
return factory()
def get_path_tool() -> ToolSpec:
from .get_path import get_path_tool as factory
return factory()
def config_diff_tool() -> ToolSpec:
from .config_diff import config_diff_tool as factory
return factory()
def device_status_tool() -> ToolSpec:
from .device_status import device_status_tool as factory
return factory()
def log_analysis_tool() -> ToolSpec:
from .log_analysis import log_analysis_tool as factory
return factory()
def dns_lookup_tool() -> ToolSpec:
from .network_probe_tools import dns_lookup_tool as factory
return factory()
def ssl_check_tool() -> ToolSpec:
from .network_probe_tools import ssl_check_tool as factory
return factory()
def port_check_tool() -> ToolSpec:
from .network_probe_tools import port_check_tool as factory
return factory()
def port_scan_tool() -> ToolSpec:
from .network_probe_tools import port_scan_tool as factory
return factory()
def local_net_info_tool() -> ToolSpec:
from .network_probe_tools import local_net_info_tool as factory
return factory()
__all__ = [
"query_route_tool",
"get_path_tool",
"config_diff_tool",
"device_status_tool",
"log_analysis_tool",
"dns_lookup_tool",
"ssl_check_tool",
"port_check_tool",
"port_scan_tool",
"local_net_info_tool",
]

View file

@ -0,0 +1,51 @@
from __future__ import annotations
import ipaddress
from typing import Any
from oclaw.runtime.tools.base import ToolSpec
def _pick_route(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> dict[str, Any]:
if isinstance(ip, ipaddress.IPv4Address):
if ip in ipaddress.ip_network("10.0.0.0/8"):
return {"prefix": "10.0.0.0/8", "next_hop": "192.168.1.1", "out_if": "GigabitEthernet0/0"}
if ip in ipaddress.ip_network("172.16.0.0/12"):
return {"prefix": "172.16.0.0/12", "next_hop": "192.168.2.1", "out_if": "GigabitEthernet0/1"}
if ip in ipaddress.ip_network("192.168.0.0/16"):
return {"prefix": "192.168.0.0/16", "next_hop": "direct", "out_if": "Vlan10"}
return {"prefix": "0.0.0.0/0", "next_hop": "203.0.113.1", "out_if": "GigabitEthernet1/0"}
if ip in ipaddress.ip_network("fc00::/7"):
return {"prefix": "fc00::/7", "next_hop": "fe80::1", "out_if": "Vlan20"}
return {"prefix": "::/0", "next_hop": "2001:db8::1", "out_if": "GigabitEthernet1/0"}
def query_route_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
destination = str(args.get("destination"))
vrf = args.get("vrf")
try:
ip = ipaddress.ip_address(destination)
except ValueError:
return {"ok": False, "error": f"Invalid IP address: {destination}"}
route = _pick_route(ip)
return {"ok": True, "destination": destination, "vrf": vrf, "route": route}
return ToolSpec(
name="query_route",
description="Look up route egress and next hop for a destination IP (demo data; replace with a real device or controller API).",
parameters={
"type": "object",
"properties": {
"destination": {"type": "string", "description": "Destination IP address (IPv4 or IPv6)."},
"vrf": {"type": "string", "description": "Optional VRF name."},
},
"required": ["destination"],
"additionalProperties": False,
},
handler=handler,
)
__all__ = ["query_route_tool"]

View file

@ -0,0 +1,10 @@
from __future__ import annotations
from oclaw.runtime.tools.experts.generalist.tabular_query import (
analyze_tabular_attachment_full_scan_tool,
query_tabular_attachment_tool,
run_tabular_sql_tool,
)
__all__ = ["query_tabular_attachment_tool", "run_tabular_sql_tool", "analyze_tabular_attachment_full_scan_tool"]

View file

@ -0,0 +1,4 @@
from __future__ import annotations
__all__ = []

View file

@ -0,0 +1,96 @@
from __future__ import annotations
import hashlib
from typing import Any
from oclaw.platform.config.paths import db_path
from oclaw.platform.embeddings.embedding_client import build_default_embedding_client
from oclaw.platform.persistence.sqlite_store import SqliteStore
from oclaw.runtime.tools.base import ToolSpec
def _chunk_id(source: str, text: str) -> str:
raw = f"{source}\n{text}".encode("utf-8", errors="ignore")
return hashlib.sha1(raw).hexdigest()
def kb_add_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
try:
tenant_id = str(args.get("tenant_id") or "").strip()
user_id = str(args.get("user_id") or "").strip()
text = str(args.get("text") or "").strip()
title = str(args.get("title") or "").strip()
if not tenant_id or not user_id or not text:
return {"ok": False, "error": "tenant_id, user_id, text are required"}
source = f"builtin:tenant:{tenant_id}:kb"
if title:
source = f"{source}:{title[:48]}"
cid = _chunk_id(source, text)
store = SqliteStore(db_path())
store.upsert_knowledge_chunk(
chunk_id=cid,
source=source,
content=text,
metadata={"tenant_id": tenant_id, "user_id": user_id, "title": title, "source": source},
)
client = build_default_embedding_client()
emb = client.embed(text[:8000])
store.upsert_knowledge_embedding(chunk_id=cid, model=emb.model, vector=emb.vector)
return {"ok": True, "chunk_id": cid, "source": source, "embedding_model": emb.model}
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
return ToolSpec(
name="kb_add",
description="Add a knowledge snippet for a tenant into the vector knowledge base.",
parameters={
"type": "object",
"properties": {"tenant_id": {"type": "string"}, "user_id": {"type": "string"}, "title": {"type": "string", "description": "Optional title/label."}, "text": {"type": "string", "description": "Knowledge content to store."}},
"required": ["tenant_id", "user_id", "text"],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"productivity", "rag", "write"}),
)
def kb_search_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
try:
tenant_id = str(args.get("tenant_id") or "").strip()
query = str(args.get("query") or "").strip()
limit = int(args.get("limit") or 3)
if not tenant_id or not query:
return {"ok": False, "error": "tenant_id and query are required"}
store = SqliteStore(db_path())
from oclaw.runtime.orchestration.memory import retrieve_context
rows = retrieve_context(store, query, limit=max(1, min(limit, 6)))
filtered = [r for r in rows if str(r.get("source") or "").startswith(f"builtin:tenant:{tenant_id}:")]
hits = filtered[: max(1, min(limit, 6))]
if not hits:
like_rows = store.search_knowledge(query=query, limit=max(1, min(limit, 6)))
hits = [r for r in like_rows if str(r.get("source") or "").startswith(f"builtin:tenant:{tenant_id}:")][: max(1, min(limit, 6))]
refs = []
for h in hits:
refs.append({"source": str(h.get("source") or ""), "snippet": str(h.get("content") or "")[:240]})
return {"ok": True, "hits": refs}
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
return ToolSpec(
name="kb_search",
description="Search tenant knowledge base and return citations/snippets.",
parameters={
"type": "object",
"properties": {"tenant_id": {"type": "string"}, "query": {"type": "string"}, "limit": {"type": "integer", "default": 3}},
"required": ["tenant_id", "query"],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"productivity", "rag"}),
)
__all__ = ["kb_add_tool", "kb_search_tool"]

View file

@ -0,0 +1,146 @@
from __future__ import annotations
from typing import Any
from oclaw.platform.config.paths import db_path
from oclaw.platform.persistence.sqlite_store import SqliteStore
from oclaw.runtime.tools.base import ToolSpec
def _require(s: str, name: str) -> str:
v = (s or "").strip()
if not v:
raise ValueError(f"{name} is required")
return v
def todo_create_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
try:
tenant_id = _require(str(args.get("tenant_id") or ""), "tenant_id")
owner_user_id = _require(str(args.get("owner_user_id") or ""), "owner_user_id")
title = _require(str(args.get("title") or ""), "title")
due_at = str(args.get("due_at") or "").strip() or None
assignee_user_id = str(args.get("assignee_user_id") or "").strip() or None
store = SqliteStore(db_path())
row = store.todo_create(
tenant_id=tenant_id,
owner_user_id=owner_user_id,
title=title,
due_at=due_at,
assignee_user_id=assignee_user_id,
)
return {"ok": True, "todo": row}
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
return ToolSpec(
name="todo_create",
description="Create a todo item for a tenant/user.",
parameters={
"type": "object",
"properties": {
"tenant_id": {"type": "string"},
"owner_user_id": {"type": "string"},
"title": {"type": "string"},
"due_at": {"type": "string", "description": "Optional ISO timestamp or natural text."},
"assignee_user_id": {"type": "string", "description": "Optional user id to assign."},
},
"required": ["tenant_id", "owner_user_id", "title"],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"productivity", "write"}),
)
def todo_list_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
try:
tenant_id = _require(str(args.get("tenant_id") or ""), "tenant_id")
assignee_user_id = str(args.get("assignee_user_id") or "").strip() or None
status = str(args.get("status") or "open").strip() or None
limit = int(args.get("limit") or 50)
store = SqliteStore(db_path())
rows = store.todo_list(
tenant_id=tenant_id,
assignee_user_id=assignee_user_id,
status=status,
limit=limit,
)
return {"ok": True, "items": rows}
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
return ToolSpec(
name="todo_list",
description="List todo items by tenant (optionally by assignee and status).",
parameters={
"type": "object",
"properties": {
"tenant_id": {"type": "string"},
"assignee_user_id": {"type": "string"},
"status": {"type": "string", "default": "open"},
"limit": {"type": "integer", "default": 50},
},
"required": ["tenant_id"],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"productivity"}),
)
def todo_done_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
try:
tenant_id = _require(str(args.get("tenant_id") or ""), "tenant_id")
todo_id = _require(str(args.get("todo_id") or ""), "todo_id")
store = SqliteStore(db_path())
ok = store.todo_set_status(tenant_id=tenant_id, todo_id=todo_id, status="done")
return {"ok": bool(ok), "todo_id": todo_id}
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
return ToolSpec(
name="todo_done",
description="Mark a todo item as done.",
parameters={
"type": "object",
"properties": {"tenant_id": {"type": "string"}, "todo_id": {"type": "string"}},
"required": ["tenant_id", "todo_id"],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"productivity", "write"}),
)
def todo_assign_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
try:
tenant_id = _require(str(args.get("tenant_id") or ""), "tenant_id")
todo_id = _require(str(args.get("todo_id") or ""), "todo_id")
assignee_user_id = _require(str(args.get("assignee_user_id") or ""), "assignee_user_id")
store = SqliteStore(db_path())
ok = store.todo_assign(tenant_id=tenant_id, todo_id=todo_id, assignee_user_id=assignee_user_id)
return {"ok": bool(ok), "todo_id": todo_id, "assignee_user_id": assignee_user_id}
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
return ToolSpec(
name="todo_assign",
description="Assign a todo item to a user.",
parameters={
"type": "object",
"properties": {"tenant_id": {"type": "string"}, "todo_id": {"type": "string"}, "assignee_user_id": {"type": "string"}},
"required": ["tenant_id", "todo_id", "assignee_user_id"],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"productivity", "write"}),
)
__all__ = ["todo_create_tool", "todo_list_tool", "todo_done_tool", "todo_assign_tool"]

View file

@ -0,0 +1,4 @@
from __future__ import annotations
__all__ = []

View file

@ -0,0 +1,148 @@
from __future__ import annotations
import hashlib
from typing import Any
from oclaw.runtime.tools.base import ToolSpec
from oclaw.runtime.tools.experts.workspace.workspace_base import resolve_workspace_path
def read_file_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
path = str(args.get("path") or "").strip()
offset = int(args.get("offset") or 1)
limit = int(args.get("limit") or 400)
if offset == 0:
offset = 1
if limit <= 0:
limit = 1
p = resolve_workspace_path(path)
if not p.exists() or not p.is_file():
return {"ok": False, "error": "file_not_found", "path": str(p)}
text = p.read_text(encoding="utf-8", errors="replace").splitlines()
# 1-indexed offsets; negative counts from end
if offset < 0:
start = max(0, len(text) + offset)
else:
start = max(0, offset - 1)
end = min(len(text), start + min(limit, 2000))
out_lines = [f"{i+1}|{text[i]}" for i in range(start, end)]
blob = p.read_bytes()
sha = hashlib.sha256(blob).hexdigest()
return {
"ok": True,
"path": str(p),
"start_line": start + 1,
"end_line": end,
"total_lines": len(text),
"sha256": sha,
"content": "\n".join(out_lines),
}
return ToolSpec(
name="read_file",
description="Read a text file from the workspace with line numbers.",
parameters={
"type": "object",
"properties": {
"path": {"type": "string", "description": "File path, relative to workspace root."},
"offset": {"type": "integer", "description": "1-indexed start line; negative counts from end.", "default": 1},
"limit": {"type": "integer", "description": "Max lines to return (capped).", "default": 400},
},
"required": ["path"],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"workspace"}),
read_only=True,
)
def write_file_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
path = str(args.get("path") or "").strip()
content = str(args.get("content") or "")
mode = str(args.get("mode") or "overwrite").strip().lower()
p = resolve_workspace_path(path)
p.parent.mkdir(parents=True, exist_ok=True)
if mode not in ("overwrite", "append"):
return {"ok": False, "error": "invalid_mode", "allowed": ["overwrite", "append"]}
if mode == "append":
p.write_text(p.read_text(encoding="utf-8", errors="replace") + content, encoding="utf-8")
else:
p.write_text(content, encoding="utf-8")
return {"ok": True, "path": str(p), "bytes": p.stat().st_size}
return ToolSpec(
name="write_file",
description="Write text content to a workspace file (overwrite or append).",
parameters={
"type": "object",
"properties": {
"path": {"type": "string", "description": "File path, relative to workspace root."},
"content": {"type": "string", "description": "Full text content to write."},
"mode": {"type": "string", "enum": ["overwrite", "append"], "default": "overwrite"},
},
"required": ["path", "content"],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"workspace", "write"}),
)
def list_files_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
pattern = str(args.get("pattern") or "**/*").strip() or "**/*"
max_results = int(args.get("max_results") or 200)
root_arg = str(args.get("root") or "").strip()
if not root_arg:
base = resolve_workspace_path(".")
else:
base = resolve_workspace_path(root_arg)
if not base.is_dir():
return {"ok": False, "error": "not_a_directory", "path": str(base)}
out: list[str] = []
for p in base.glob(pattern):
if p.is_dir():
continue
rel = str(p.relative_to(base))
out.append(rel)
if len(out) >= max(1, min(max_results, 2000)):
break
return {
"ok": True,
"root": str(base),
"pattern": pattern,
"count": len(out),
"files": out,
}
return ToolSpec(
name="glob",
description=(
"List files under a directory matching a glob pattern. "
"Default root is the workspace root; set `root` to an absolute path (e.g. D:\\\\download) when the user names a folder outside the repo — "
"this respects gateway workspace path policy. Prefer this over MCP filesystem list_directory when the user path may be outside MCP's configured roots."
),
parameters={
"type": "object",
"properties": {
"pattern": {"type": "string", "description": "Glob pattern relative to root, e.g. '**/*' or '*.pdf'.", "default": "**/*"},
"root": {
"type": "string",
"description": "Optional directory to search under (absolute or workspace-relative). If omitted, uses workspace root.",
},
"max_results": {"type": "integer", "default": 200, "description": "Max number of files to return."},
},
"required": [],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"workspace"}),
read_only=True,
)
__all__ = ["read_file_tool", "write_file_tool", "list_files_tool"]

View file

@ -0,0 +1,154 @@
from __future__ import annotations
import subprocess
from typing import Any
from oclaw.runtime.tools.base import ToolSpec
from oclaw.runtime.tools.experts.workspace.workspace_base import resolve_workspace_path, truncate_text, sanitize_git_ref
def _git(command: str, *, cwd: str) -> dict[str, Any]:
workdir = resolve_workspace_path(cwd or ".")
cp = subprocess.run(
f"git {command}",
cwd=str(workdir),
shell=True,
capture_output=True,
text=True,
timeout=60.0,
)
out = (cp.stdout or "") + (("\n" + cp.stderr) if cp.stderr else "")
return {"exit_code": int(cp.returncode), "output": truncate_text(out, limit=20000), "cwd": str(workdir)}
def git_status_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
cwd = str(args.get("cwd") or ".").strip()
res = _git("status --porcelain=v1 -b", cwd=cwd)
ok = res["exit_code"] == 0
return {"ok": ok, **res}
return ToolSpec(
name="git_status",
description="Show git status (porcelain).",
parameters={
"type": "object",
"properties": {"cwd": {"type": "string", "default": ".", "description": "Repo directory."}},
"required": [],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"workspace", "git"}),
)
def git_diff_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
cwd = str(args.get("cwd") or ".").strip()
ref = sanitize_git_ref(str(args.get("ref") or "").strip()) if args.get("ref") else ""
cmd = "diff" if not ref else f"diff {ref}...HEAD"
res = _git(cmd, cwd=cwd)
ok = res["exit_code"] == 0
return {"ok": ok, **res}
return ToolSpec(
name="git_diff",
description="Show git diff (default: working tree; optional ref...HEAD).",
parameters={
"type": "object",
"properties": {
"cwd": {"type": "string", "default": ".", "description": "Repo directory."},
"ref": {"type": "string", "description": "Optional ref for ref...HEAD diff."},
},
"required": [],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"workspace", "git"}),
)
def git_log_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
cwd = str(args.get("cwd") or ".").strip()
n = int(args.get("n") or 10)
n = max(1, min(n, 50))
res = _git(f"log -{n} --oneline --decorate", cwd=cwd)
ok = res["exit_code"] == 0
return {"ok": ok, **res}
return ToolSpec(
name="git_log",
description="Show recent git commits (oneline).",
parameters={
"type": "object",
"properties": {"cwd": {"type": "string", "default": ".", "description": "Repo directory."}, "n": {"type": "integer", "default": 10}},
"required": [],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"workspace", "git"}),
)
def git_commit_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
cwd = str(args.get("cwd") or ".").strip()
message = str(args.get("message") or "").strip()
if not message:
return {"ok": False, "error": "message_required"}
# stage all changes (simple default)
s1 = _git("add -A", cwd=cwd)
if s1["exit_code"] != 0:
return {"ok": False, "error": "git_add_failed", **s1}
msg_esc = message.replace('"', '\\"')
s2 = _git(f'commit -m "{msg_esc}"', cwd=cwd)
ok = s2["exit_code"] == 0
return {"ok": ok, **s2}
return ToolSpec(
name="git_commit",
description="Stage all and create a git commit (requires confirmation by policy).",
parameters={
"type": "object",
"properties": {
"cwd": {"type": "string", "default": ".", "description": "Repo directory."},
"message": {"type": "string", "description": "Commit message."},
},
"required": ["message"],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"workspace", "git", "write"}),
)
def git_push_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
cwd = str(args.get("cwd") or ".").strip()
remote = str(args.get("remote") or "origin").strip() or "origin"
refspec = str(args.get("refspec") or "HEAD").strip() or "HEAD"
res = _git(f"push {remote} {refspec}", cwd=cwd)
ok = res["exit_code"] == 0
return {"ok": ok, **res}
return ToolSpec(
name="git_push",
description="Push current branch (requires confirmation by policy).",
parameters={
"type": "object",
"properties": {
"cwd": {"type": "string", "default": ".", "description": "Repo directory."},
"remote": {"type": "string", "default": "origin"},
"refspec": {"type": "string", "default": "HEAD"},
},
"required": [],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"workspace", "git", "write"}),
)
__all__ = ["git_status_tool", "git_diff_tool", "git_log_tool", "git_commit_tool", "git_push_tool"]

View file

@ -0,0 +1,53 @@
from __future__ import annotations
import hashlib
from typing import Any
from oclaw.runtime.tools.base import ToolSpec
from oclaw.runtime.tools.experts.workspace.workspace_base import resolve_workspace_path
def apply_patch_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
path = str(args.get("path") or "").strip()
new_content = str(args.get("new_content") or "")
expected_sha256 = str(args.get("expected_sha256") or "").strip()
p = resolve_workspace_path(path)
if p.exists() and p.is_file() and expected_sha256:
cur = hashlib.sha256(p.read_bytes()).hexdigest()
if cur != expected_sha256:
return {
"ok": False,
"error": "sha_mismatch",
"path": str(p),
"expected_sha256": expected_sha256,
"current_sha256": cur,
}
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(new_content, encoding="utf-8")
sha = hashlib.sha256(p.read_bytes()).hexdigest()
return {"ok": True, "path": str(p), "sha256": sha, "bytes": p.stat().st_size}
return ToolSpec(
name="apply_patch",
description="Apply a full-file patch by overwriting a file with new content (optional sha256 precondition).",
parameters={
"type": "object",
"properties": {
"path": {"type": "string", "description": "File path, relative to workspace root."},
"new_content": {"type": "string", "description": "New full file content."},
"expected_sha256": {
"type": "string",
"description": "If provided, the current file sha256 must match (precondition).",
},
},
"required": ["path", "new_content"],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"workspace", "write"}),
)
__all__ = ["apply_patch_tool"]

View file

@ -0,0 +1,92 @@
from __future__ import annotations
import re
from typing import Any
from oclaw.runtime.tools.base import ToolSpec
from oclaw.runtime.tools.experts.workspace.workspace_base import resolve_workspace_path
def grep_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
pattern = str(args.get("pattern") or "").strip()
file_glob = str(args.get("file_glob") or "**/*").strip() or "**/*"
max_matches = int(args.get("max_matches") or 200)
if not pattern:
return {"ok": False, "error": "pattern_required"}
root = resolve_workspace_path(".")
try:
rx = re.compile(pattern)
except re.error as e:
return {"ok": False, "error": "invalid_regex", "detail": str(e)}
matches: list[dict[str, Any]] = []
for p in root.glob(file_glob):
if p.is_dir():
continue
try:
text = p.read_text(encoding="utf-8", errors="replace").splitlines()
except Exception:
continue
for i, line in enumerate(text, start=1):
if rx.search(line):
matches.append({"file": str(p.relative_to(root)), "line": i, "text": line[:400]})
if len(matches) >= max(1, min(max_matches, 5000)):
return {"ok": True, "pattern": pattern, "count": len(matches), "matches": matches}
return {"ok": True, "pattern": pattern, "count": len(matches), "matches": matches}
return ToolSpec(
name="grep",
description="Search files in the workspace for a regex pattern.",
parameters={
"type": "object",
"properties": {
"pattern": {"type": "string", "description": "Regex pattern."},
"file_glob": {"type": "string", "default": "**/*", "description": "Glob of files to search."},
"max_matches": {"type": "integer", "default": 200, "description": "Max number of matches."},
},
"required": ["pattern"],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"workspace"}),
read_only=True,
)
def index_workspace_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
max_files = int(args.get("max_files") or 120)
try:
# Lazy import to avoid heavy deps during tool discovery.
from oclaw.platform.config.paths import db_path
except Exception:
pass
# Indexer uses store passed via closure? ToolSpec doesn't carry store.
# We index using the global SqliteStore path (same as app runtime).
try:
from oclaw.platform.persistence.sqlite_store import SqliteStore
from oclaw.platform.config.paths import db_path
from oclaw.runtime.tools.workspace_indexer import index_workspace
store = SqliteStore(db_path())
st = index_workspace(store, max_files=max(1, min(max_files, 800)))
return {"ok": True, "files_seen": st.files_seen, "chunks_upserted": st.chunks_upserted, "embeddings_upserted": st.embeddings_upserted}
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
return ToolSpec(
name="index_workspace",
description="Index workspace files into the vector knowledge base for RAG (may be slow).",
parameters={
"type": "object",
"properties": {"max_files": {"type": "integer", "default": 120, "description": "Max files to index."}},
"required": [],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"workspace", "rag"}),
)
__all__ = ["grep_tool", "index_workspace_tool"]

View file

@ -0,0 +1,91 @@
from __future__ import annotations
import subprocess
from typing import Any
from oclaw.runtime.tools.base import ToolSpec
from oclaw.runtime.tools.experts.workspace.workspace_base import resolve_workspace_path, truncate_text
def run_command_tool() -> ToolSpec:
def handler(args: dict[str, Any]) -> dict[str, Any]:
import os
if str(os.getenv("AIA_ENABLE_RUN_COMMAND") or "").strip().lower() not in ("1", "true", "yes", "on"):
return {
"ok": False,
"error": "disabled",
"hint": "Set AIA_ENABLE_RUN_COMMAND=1 to enable this high-risk tool.",
}
command = str(args.get("command") or "").strip()
cwd = str(args.get("cwd") or "").strip()
timeout_s = float(args.get("timeout_s") or 30.0)
max_output_chars = int(args.get("max_output_chars") or 20000)
if not command:
return {"ok": False, "error": "command_required"}
workdir = resolve_workspace_path(cwd or ".")
try:
run_kwargs: dict[str, Any] = {
"cwd": str(workdir),
"shell": True,
"capture_output": True,
"text": True,
"timeout": max(1.0, min(timeout_s, 600.0)),
}
if os.name == "nt":
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
startupinfo.wShowWindow = 0 # SW_HIDE
run_kwargs["startupinfo"] = startupinfo
run_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
cp = subprocess.run(
command,
**run_kwargs,
)
out = (cp.stdout or "") + (("\n" + cp.stderr) if cp.stderr else "")
out = truncate_text(out, limit=max(1000, min(max_output_chars, 200000)))
return {
"ok": True,
"command": command,
"cwd": str(workdir),
"exit_code": int(cp.returncode),
"output": out,
}
except subprocess.TimeoutExpired as e:
partial = ""
try:
partial = ((e.stdout or "") + ("\n" + (e.stderr or "") if e.stderr else "")).strip()
except Exception:
partial = ""
return {
"ok": False,
"error": "timeout",
"command": command,
"cwd": str(workdir),
"timeout_s": timeout_s,
"output": truncate_text(partial, limit=max_output_chars),
}
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}", "command": command, "cwd": str(workdir)}
return ToolSpec(
name="run_command",
description="Run a shell command inside the workspace (captured output, timeout).",
parameters={
"type": "object",
"properties": {
"command": {"type": "string", "description": "Shell command to run."},
"cwd": {"type": "string", "description": "Working directory relative to workspace.", "default": "."},
"timeout_s": {"type": "number", "default": 30.0, "description": "Command timeout in seconds."},
"max_output_chars": {"type": "integer", "default": 20000, "description": "Max characters to return."},
},
"required": ["command"],
"additionalProperties": False,
},
handler=handler,
tags=frozenset({"workspace", "exec"}),
)
__all__ = ["run_command_tool"]

View file

@ -0,0 +1,261 @@
from __future__ import annotations
import os
import re
import threading
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterator
from oclaw.platform.config.paths import PROJECT_ROOT
_TLS = threading.local()
def _env_truthy(name: str) -> bool:
return str(os.getenv(name) or "").strip().lower() in ("1", "true", "yes", "on")
def workspace_root() -> Path:
# Allow explicit override (recommended when running as a packaged app)
override = (os.getenv("AIA_WORKSPACE_ROOT") or os.getenv("OPS_WORKSPACE_ROOT") or "").strip()
if override:
p = Path(override).expanduser()
return p.resolve()
return Path(PROJECT_ROOT).resolve()
def _parse_pipe_separated_roots(raw: str) -> list[Path]:
out: list[Path] = []
for part in (raw or "").split("|"):
p = part.strip().strip('"').strip("'")
if not p:
continue
try:
rp = Path(p).expanduser().resolve()
if rp.is_absolute():
out.append(rp)
except Exception:
continue
return out
@dataclass(frozen=True)
class WorkspacePathAccess:
"""Effective path guard for the current tool invocation (env + optional per-user DB)."""
extra_roots: tuple[Path, ...]
allow_any_path: bool
def access_from_env() -> WorkspacePathAccess:
raw_extra = os.getenv("AIA_WORKSPACE_EXTRA_ROOTS") or os.getenv("OPS_WORKSPACE_EXTRA_ROOTS") or ""
extra = _parse_pipe_separated_roots(raw_extra)
allow = _env_truthy("AIA_WORKSPACE_ALLOW_ANY_PATH") or _env_truthy("OPS_WORKSPACE_ALLOW_ANY_PATH")
return WorkspacePathAccess(extra_roots=tuple(extra), allow_any_path=allow)
def _merge_access(a: WorkspacePathAccess, b: WorkspacePathAccess) -> WorkspacePathAccess:
merged: dict[str, Path] = {}
for p in (*a.extra_roots, *b.extra_roots):
try:
k = str(p.resolve())
except Exception:
k = str(p)
merged.setdefault(k, p)
return WorkspacePathAccess(
extra_roots=tuple(merged.values()),
allow_any_path=bool(a.allow_any_path or b.allow_any_path),
)
def build_workspace_path_access(
store: Any,
session_id: str | None,
*,
owner_fallback_session_id: str | None = None,
allowlist_tenant_id: str | None = None,
allowlist_user_id: str | None = None,
) -> WorkspacePathAccess:
"""Resolve per-user ``extra_roots`` / ``allow_any_path`` from ``user_workspace_path_allowlist``.
``session_id`` is usually the chat row messages are written to (may be a specialist temp session
without ``ui_session_owner``). In that case pass ``owner_fallback_session_id`` = the user's
UI-owned session id so DB allowlist still applies.
If ``get_ui_session_owner`` yields nothing, ``allowlist_tenant_id`` + ``allowlist_user_id``
(from the authenticated user / request metadata) can be used to load the same allowlist, so
a missing ``ui_session_owner`` row does not drop per-user extra roots.
"""
base = access_from_env()
if store is None:
return base
picked_owner: dict[str, Any] | None = None
for cand in (str(session_id or "").strip(), str(owner_fallback_session_id or "").strip()):
if not cand:
continue
try:
own = store.get_ui_session_owner(session_id=cand)
except Exception:
own = None
if not own:
continue
tid = str(own.get("tenant_id") or "").strip()
uid = str(own.get("user_id") or "").strip()
if tid and uid:
picked_owner = own
break
if picked_owner:
tid = str(picked_owner.get("tenant_id") or "").strip()
uid = str(picked_owner.get("user_id") or "").strip()
try:
row = store.get_user_workspace_path_allowlist(tenant_id=tid, user_id=uid)
except Exception:
row = None
if not row:
return base
db_extras = _parse_pipe_separated_roots(str(row.get("extra_roots") or ""))
db_access = WorkspacePathAccess(
extra_roots=tuple(db_extras),
allow_any_path=bool(row.get("allow_any_path")),
)
return _merge_access(base, db_access)
# Fallback: use explicit tenant / user (e.g. wecom or admin ``metadata``) when session is not
# linked in ``ui_session_owner`` (legacy session or data repair in progress).
t2 = str(allowlist_tenant_id or "").strip()
u2 = str(allowlist_user_id or "").strip()
if not t2 or not u2:
return base
try:
row = store.get_user_workspace_path_allowlist(tenant_id=t2, user_id=u2)
except Exception:
row = None
if not row:
return base
db_extras = _parse_pipe_separated_roots(str(row.get("extra_roots") or ""))
db_access = WorkspacePathAccess(
extra_roots=tuple(db_extras),
allow_any_path=bool(row.get("allow_any_path")),
)
return _merge_access(base, db_access)
@contextmanager
def workspace_path_access_scope(
store: Any,
session_id: str | None,
*,
owner_fallback_session_id: str | None = None,
allowlist_tenant_id: str | None = None,
allowlist_user_id: str | None = None,
) -> Iterator[WorkspacePathAccess]:
acc = build_workspace_path_access(
store,
session_id,
owner_fallback_session_id=owner_fallback_session_id,
allowlist_tenant_id=allowlist_tenant_id,
allowlist_user_id=allowlist_user_id,
)
prev = getattr(_TLS, "access", None)
_TLS.access = acc
try:
yield acc
finally:
if prev is None:
if hasattr(_TLS, "access"):
delattr(_TLS, "access")
else:
_TLS.access = prev
def current_workspace_path_access() -> WorkspacePathAccess:
a = getattr(_TLS, "access", None)
if isinstance(a, WorkspacePathAccess):
return a
return access_from_env()
def clear_workspace_path_access_for_tests() -> None:
if hasattr(_TLS, "access"):
delattr(_TLS, "access")
def _is_subpath(path: Path, root: Path) -> bool:
"""``path`` is under ``root`` (treated as a directory), including the root itself.
On Windows, comparison is case- and path-separator-insensitive; ``resolve`` may
not normalize casing consistently across all drives, so we use normcase.
"""
try:
pr = path.resolve()
rr = root.resolve()
except (OSError, ValueError, RuntimeError):
return False
if os.name == "nt":
np = os.path.normcase(str(pr))
nroot = os.path.normcase(str(rr))
if np == nroot:
return True
sep = os.sep
if not nroot.endswith(sep):
nroot = nroot + sep
return np.startswith(nroot) or (np + sep).startswith(nroot)
try:
pr.relative_to(rr)
return True
except (ValueError, OSError, RuntimeError):
return False
def resolve_workspace_path(user_path: str) -> Path:
p = Path(str(user_path or "").strip().strip('"').strip("'") or "")
if not p:
raise ValueError("path is required")
root = workspace_root()
abs_path = p if p.is_absolute() else (root / p)
abs_path = abs_path.resolve()
access = current_workspace_path_access()
if access.allow_any_path:
return abs_path
roots = (root,) + access.extra_roots
if any(_is_subpath(abs_path, r) for r in roots):
return abs_path
raise ValueError("path escapes workspace root")
def truncate_text(s: str, *, limit: int = 20000) -> str:
s = s or ""
if len(s) <= limit:
return s
return s[: max(0, limit - 12)] + "\n...<truncated>"
# NOTE: put '-' at end or escape it to avoid "bad character range" on Windows Python regex.
_SAFE_GIT_REF_RE = re.compile(r"^[A-Za-z0-9._/\\-]{1,80}$")
def sanitize_git_ref(ref: str) -> str:
r = (ref or "").strip()
if not r:
return ""
if not _SAFE_GIT_REF_RE.match(r):
raise ValueError("invalid git ref")
return r
__all__ = [
"WorkspacePathAccess",
"access_from_env",
"build_workspace_path_access",
"clear_workspace_path_access_for_tests",
"current_workspace_path_access",
"resolve_workspace_path",
"sanitize_git_ref",
"truncate_text",
"workspace_path_access_scope",
"workspace_root",
]