539 lines
23 KiB
Python
539 lines
23 KiB
Python
"""
|
|
Base tool implementations that execute actual system operations.
|
|
"""
|
|
import os
|
|
import json
|
|
import glob
|
|
import subprocess
|
|
import uuid
|
|
import asyncio
|
|
from typing import Dict, Any, Optional, List
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
|
|
# Base directory for memory and skills
|
|
HERMES_DIR = os.path.expanduser("~/.hermes")
|
|
|
|
def _get_user_dir(base_dir: str, context: Optional[Dict[str, Any]] = None) -> str:
|
|
"""Get user-isolated subdirectory. Falls back to global dir if no user context."""
|
|
user_id = None
|
|
if context:
|
|
user_id = context.get('user_id') or context.get('userid')
|
|
if user_id:
|
|
return os.path.join(base_dir, "users", str(user_id))
|
|
return base_dir
|
|
|
|
# Shared skills directory (owner org only can write, all can read)
|
|
SHARED_SKILLS_DIR = os.path.join(HERMES_DIR, "skills")
|
|
|
|
def _is_owner_org(context: Optional[Dict[str, Any]] = None) -> bool:
|
|
"""Check if current user belongs to the owner organization (org_id == '0').
|
|
Checks context first, then falls back to ServerEnv."""
|
|
# 1. Check context for org_id
|
|
if context:
|
|
org_id = context.get('org_id') or context.get('orgid')
|
|
if org_id is not None:
|
|
return str(org_id) == '0'
|
|
# 2. Try ServerEnv
|
|
try:
|
|
from ahserver.serverenv import ServerEnv
|
|
env = ServerEnv()
|
|
org_id = getattr(env, 'orgid', None) or getattr(env, 'org_id', None)
|
|
if org_id is not None:
|
|
return str(org_id) == '0'
|
|
except Exception:
|
|
pass
|
|
return False
|
|
|
|
def _get_shared_skill_path(name: str, file_path: Optional[str] = None) -> str:
|
|
"""Get path to a shared skill file."""
|
|
if file_path:
|
|
return os.path.join(SHARED_SKILLS_DIR, name, file_path)
|
|
return os.path.join(SHARED_SKILLS_DIR, name, "SKILL.md")
|
|
|
|
def _get_user_skill_path(user_dir: str, name: str, file_path: Optional[str] = None) -> str:
|
|
"""Get path to a user-specific skill file."""
|
|
skills_dir = os.path.join(user_dir, "skills")
|
|
if file_path:
|
|
return os.path.join(skills_dir, name, file_path)
|
|
return os.path.join(skills_dir, name, "SKILL.md")
|
|
|
|
async def wrapped_read_file(path: str, offset: int = 1, limit: int = 500) -> Dict[str, Any]:
|
|
"""Actual implementation of read_file tool."""
|
|
try:
|
|
full_path = os.path.expanduser(path)
|
|
if not os.path.exists(full_path):
|
|
return {"success": False, "error": f"File not found: {path}"}
|
|
|
|
with open(full_path, 'r', encoding='utf-8', errors='ignore') as f:
|
|
all_lines = f.readlines()
|
|
|
|
total_lines = len(all_lines)
|
|
start_idx = max(0, offset - 1)
|
|
end_idx = min(total_lines, start_idx + limit)
|
|
|
|
content = "".join(all_lines[start_idx:end_idx])
|
|
return {
|
|
"success": True,
|
|
"content": content,
|
|
"total_lines": total_lines,
|
|
"start_line": offset,
|
|
"end_line": end_idx,
|
|
"truncated": end_idx < total_lines
|
|
}
|
|
except Exception as e:
|
|
return {"success": False, "error": str(e)}
|
|
|
|
async def wrapped_write_file(path: str, content: str) -> Dict[str, Any]:
|
|
"""Actual implementation of write_file tool."""
|
|
try:
|
|
full_path = os.path.expanduser(path)
|
|
os.makedirs(os.path.dirname(full_path), exist_ok=True) if os.path.dirname(full_path) else None
|
|
with open(full_path, 'w', encoding='utf-8') as f:
|
|
f.write(content)
|
|
return {"success": True, "path": path, "bytes_written": len(content.encode('utf-8'))}
|
|
except Exception as e:
|
|
return {"success": False, "error": str(e)}
|
|
|
|
async def wrapped_search_files(pattern: str, target: str = "content", path: str = ".",
|
|
file_glob: Optional[str] = None, limit: int = 50) -> Dict[str, Any]:
|
|
"""Actual implementation of search_files tool."""
|
|
try:
|
|
search_path = os.path.expanduser(path)
|
|
results = []
|
|
|
|
if target == "files":
|
|
# Search for files by name pattern
|
|
if file_glob:
|
|
search_pattern = os.path.join(search_path, "**", file_glob)
|
|
else:
|
|
search_pattern = os.path.join(search_path, "**", pattern)
|
|
|
|
for fpath in glob.glob(search_pattern, recursive=True):
|
|
if os.path.isfile(fpath):
|
|
results.append(fpath)
|
|
if len(results) >= limit:
|
|
break
|
|
return {"success": True, "matches": results[:limit], "total_count": len(results)}
|
|
else:
|
|
# Search for content inside files
|
|
import re
|
|
regex = re.compile(pattern, re.IGNORECASE)
|
|
search_pattern = os.path.join(search_path, "**", "*.py") if not file_glob else os.path.join(search_path, "**", file_glob)
|
|
if file_glob and '*' not in file_glob:
|
|
search_pattern = os.path.join(search_path, "**", file_glob)
|
|
elif not file_glob:
|
|
search_pattern = os.path.join(search_path, "**", "*.*")
|
|
|
|
count = 0
|
|
for fpath in glob.glob(search_pattern, recursive=True):
|
|
if not os.path.isfile(fpath):
|
|
continue
|
|
try:
|
|
with open(fpath, 'r', encoding='utf-8', errors='ignore') as f:
|
|
for i, line in enumerate(f, 1):
|
|
if regex.search(line):
|
|
results.append({"file": fpath, "line": i, "content": line.strip()})
|
|
count += 1
|
|
if count >= limit:
|
|
break
|
|
except:
|
|
pass
|
|
if count >= limit:
|
|
break
|
|
|
|
return {"success": True, "matches": results, "total_count": count}
|
|
except Exception as e:
|
|
return {"success": False, "error": str(e)}
|
|
|
|
async def wrapped_patch(mode: str = "replace", path: str = "", old_string: str = "",
|
|
new_string: str = "", replace_all: bool = False) -> Dict[str, Any]:
|
|
"""Actual implementation of patch tool."""
|
|
try:
|
|
full_path = os.path.expanduser(path)
|
|
with open(full_path, 'r', encoding='utf-8') as f:
|
|
content = f.read()
|
|
|
|
if mode == "replace":
|
|
if old_string not in content:
|
|
return {"success": False, "error": "old_string not found in file"}
|
|
if replace_all:
|
|
new_content = content.replace(old_string, new_string)
|
|
else:
|
|
new_content = content.replace(old_string, new_string, 1)
|
|
|
|
with open(full_path, 'w', encoding='utf-8') as f:
|
|
f.write(new_content)
|
|
return {"success": True, "path": path, "mode": "replace"}
|
|
else:
|
|
return {"success": False, "error": f"Unsupported mode: {mode}"}
|
|
except Exception as e:
|
|
return {"success": False, "error": str(e)}
|
|
|
|
async def wrapped_terminal(command: str, background: bool = False, timeout: int = 180,
|
|
workdir: Optional[str] = None, pty: bool = False,
|
|
notify_on_complete: bool = False) -> Dict[str, Any]:
|
|
"""Actual implementation of terminal tool."""
|
|
try:
|
|
cwd = os.path.expanduser(workdir) if workdir else os.getcwd()
|
|
if background:
|
|
# Run in background
|
|
proc = await asyncio.create_subprocess_shell(
|
|
command,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
cwd=cwd
|
|
)
|
|
return {"success": True, "pid": proc.pid, "status": "background_started", "command": command}
|
|
else:
|
|
proc = await asyncio.create_subprocess_shell(
|
|
command,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
cwd=cwd
|
|
)
|
|
try:
|
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
return {
|
|
"success": proc.returncode == 0,
|
|
"output": stdout.decode('utf-8', errors='ignore'),
|
|
"error_output": stderr.decode('utf-8', errors='ignore'),
|
|
"exit_code": proc.returncode,
|
|
"command": command
|
|
}
|
|
except asyncio.TimeoutError:
|
|
proc.kill()
|
|
return {"success": False, "error": f"Command timed out after {timeout}s", "command": command}
|
|
except Exception as e:
|
|
return {"success": False, "error": str(e)}
|
|
|
|
async def wrapped_process(action: str, session_id: Optional[str] = None,
|
|
data: Optional[str] = None, timeout: Optional[int] = None) -> Dict[str, Any]:
|
|
"""Wrapper for process tool - tracks background processes."""
|
|
# Note: Full process tracking requires persistent state.
|
|
# For now, returns mock for management actions.
|
|
return {"success": True, "action": action, "session_id": session_id, "note": "Process management state requires external tracking"}
|
|
|
|
async def wrapped_execute_code(code: str, context: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
"""Actual implementation of execute_code tool, user-isolated temp dir."""
|
|
try:
|
|
user_dir = _get_user_dir(HERMES_DIR, context)
|
|
temp_dir = os.path.join(user_dir, "tmp")
|
|
os.makedirs(temp_dir, exist_ok=True)
|
|
temp_file = os.path.join(temp_dir, f"exec_{uuid.uuid4().hex[:8]}.py")
|
|
|
|
with open(temp_file, 'w', encoding='utf-8') as f:
|
|
f.write(code)
|
|
|
|
proc = await asyncio.create_subprocess_shell(
|
|
f"python3 {temp_file}",
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
cwd=os.getcwd()
|
|
)
|
|
stdout, stderr = await proc.communicate()
|
|
|
|
# Clean up temp file
|
|
os.remove(temp_file)
|
|
|
|
return {
|
|
"success": proc.returncode == 0,
|
|
"output": stdout.decode('utf-8', errors='ignore'),
|
|
"error_output": stderr.decode('utf-8', errors='ignore'),
|
|
"exit_code": proc.returncode
|
|
}
|
|
except Exception as e:
|
|
return {"success": False, "error": str(e)}
|
|
|
|
# --- AI & Browser tools (require external services or complex setup, mock for now but structured) ---
|
|
|
|
async def wrapped_vision_analyze(image_url: str, question: str) -> Dict[str, Any]:
|
|
return {"success": True, "tool": "vision_analyze", "note": "Requires external vision model integration", "image_url": image_url}
|
|
|
|
async def wrapped_text_to_speech(text: str, output_path: Optional[str] = None) -> Dict[str, Any]:
|
|
return {"success": True, "tool": "text_to_speech", "note": "Requires external TTS engine"}
|
|
|
|
async def wrapped_browser_navigate(url: str) -> Dict[str, Any]:
|
|
return {"success": True, "tool": "browser_navigate", "note": "Requires browser automation driver"}
|
|
|
|
# --- Memory & Session tools ---
|
|
|
|
async def wrapped_memory(action: str, target: str, content: str = "",
|
|
old_text: str = "", context: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
"""Actual implementation of memory tool using local JSON file, user-isolated."""
|
|
try:
|
|
user_dir = _get_user_dir(HERMES_DIR, context)
|
|
memory_file = os.path.join(user_dir, "memory.json")
|
|
os.makedirs(user_dir, exist_ok=True)
|
|
|
|
if not os.path.exists(memory_file):
|
|
memory = {"user": [], "system": []}
|
|
else:
|
|
with open(memory_file, 'r') as f:
|
|
memory = json.load(f)
|
|
|
|
if action == "add":
|
|
memory[target].append(content)
|
|
with open(memory_file, 'w') as f:
|
|
json.dump(memory, f, indent=2)
|
|
return {"success": True, "action": "add", "target": target}
|
|
elif action == "list":
|
|
return {"success": True, "entries": memory.get(target, [])}
|
|
else:
|
|
return {"success": False, "error": f"Unknown action: {action}"}
|
|
except Exception as e:
|
|
return {"success": False, "error": str(e)}
|
|
|
|
async def wrapped_session_search(query: Optional[str] = None, limit: int = 3) -> Dict[str, Any]:
|
|
return {"success": True, "sessions": [], "note": "Session history tracking requires external indexing"}
|
|
|
|
async def wrapped_skill_view(name: str, file_path: Optional[str] = None,
|
|
context: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
"""View a skill. Checks user-specific dir first, then shared skills."""
|
|
try:
|
|
user_dir = _get_user_dir(HERMES_DIR, context)
|
|
|
|
# 1. Check user-specific skill first
|
|
user_path = _get_user_skill_path(user_dir, name, file_path)
|
|
if os.path.exists(user_path):
|
|
with open(user_path, 'r') as f:
|
|
return {"success": True, "content": f.read(), "source": "user"}
|
|
|
|
# 2. Check shared skill
|
|
shared_path = _get_shared_skill_path(name, file_path)
|
|
if os.path.exists(shared_path):
|
|
with open(shared_path, 'r') as f:
|
|
return {"success": True, "content": f.read(), "source": "shared"}
|
|
|
|
return {"success": False, "error": "Skill not found"}
|
|
except Exception as e:
|
|
return {"success": False, "error": str(e)}
|
|
|
|
async def wrapped_skills_list(category: Optional[str] = None,
|
|
context: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
"""List both user-specific skills and shared skills with source indicator."""
|
|
try:
|
|
user_dir = _get_user_dir(HERMES_DIR, context)
|
|
user_skills_dir = os.path.join(user_dir, "skills")
|
|
skills = []
|
|
|
|
# 1. List user-specific skills
|
|
if os.path.exists(user_skills_dir):
|
|
for d in os.listdir(user_skills_dir):
|
|
skill_path = os.path.join(user_skills_dir, d)
|
|
if os.path.isdir(skill_path) and os.path.exists(os.path.join(skill_path, "SKILL.md")):
|
|
skills.append({"name": d, "source": "user"})
|
|
|
|
# 2. List shared skills
|
|
if os.path.exists(SHARED_SKILLS_DIR):
|
|
for d in os.listdir(SHARED_SKILLS_DIR):
|
|
skill_path = os.path.join(SHARED_SKILLS_DIR, d)
|
|
if os.path.isdir(skill_path) and os.path.exists(os.path.join(skill_path, "SKILL.md")):
|
|
# Don't duplicate if same name exists in user skills
|
|
if not any(s["name"] == d for s in skills):
|
|
skills.append({"name": d, "source": "shared"})
|
|
|
|
return {"success": True, "skills": skills}
|
|
except Exception as e:
|
|
return {"success": False, "error": str(e)}
|
|
|
|
async def wrapped_skill_manage(action: str, name: str, context: Optional[Dict[str, Any]] = None,
|
|
**kwargs) -> Dict[str, Any]:
|
|
"""Manage skills with owner-org permission check for shared skills.
|
|
|
|
Dual-layer architecture:
|
|
- User skills (~/.hermes/users/{user_id}/skills/): read/write for owner
|
|
- Shared skills (~/.hermes/skills/): read for all, write for owner org only
|
|
|
|
Operations target user skills by default. Use source='shared' kwarg to
|
|
target shared skills (requires owner org membership).
|
|
"""
|
|
try:
|
|
user_dir = _get_user_dir(HERMES_DIR, context)
|
|
target = kwargs.pop('source', 'user') # 'user' (default) or 'shared'
|
|
owner = _is_owner_org(context)
|
|
|
|
if target == "shared":
|
|
# Shared skill operations require owner org membership
|
|
if not owner:
|
|
return {"success": False, "error": "共享技能仅允许所有者机构用户修改", "source": "shared"}
|
|
skills_dir = os.path.join(SHARED_SKILLS_DIR, name)
|
|
else:
|
|
# User skill operations
|
|
skills_dir = os.path.join(user_dir, "skills", name)
|
|
|
|
if action == "create":
|
|
if target == "shared" and os.path.exists(skills_dir):
|
|
return {"success": False, "error": "共享技能已存在"}
|
|
os.makedirs(skills_dir, exist_ok=True)
|
|
if 'content' in kwargs:
|
|
with open(os.path.join(skills_dir, "SKILL.md"), 'w') as f:
|
|
f.write(kwargs['content'])
|
|
return {"success": True, "action": "create", "name": name, "source": target}
|
|
|
|
elif action == "patch":
|
|
skill_file = os.path.join(skills_dir, "SKILL.md")
|
|
if not os.path.exists(skill_file):
|
|
return {"success": False, "error": "Skill not found"}
|
|
with open(skill_file, 'r') as f:
|
|
content = f.read()
|
|
old_string = kwargs.get('old_string', '')
|
|
new_string = kwargs.get('new_string', '')
|
|
if old_string not in content:
|
|
return {"success": False, "error": "old_string not found in skill"}
|
|
new_content = content.replace(old_string, new_string, 1)
|
|
with open(skill_file, 'w') as f:
|
|
f.write(new_content)
|
|
return {"success": True, "action": "patch", "name": name, "source": target}
|
|
|
|
elif action == "edit":
|
|
os.makedirs(skills_dir, exist_ok=True)
|
|
if 'content' in kwargs:
|
|
with open(os.path.join(skills_dir, "SKILL.md"), 'w') as f:
|
|
f.write(kwargs['content'])
|
|
return {"success": True, "action": "edit", "name": name, "source": target}
|
|
|
|
elif action == "delete":
|
|
import shutil
|
|
if os.path.exists(skills_dir):
|
|
shutil.rmtree(skills_dir)
|
|
return {"success": True, "action": "delete", "name": name, "source": target}
|
|
|
|
elif action == "view":
|
|
skill_file = os.path.join(skills_dir, "SKILL.md")
|
|
if os.path.exists(skill_file):
|
|
with open(skill_file, 'r') as f:
|
|
return {"success": True, "content": f.read(), "source": target}
|
|
return {"success": False, "error": "Skill not found", "source": target}
|
|
|
|
elif action == "write_file":
|
|
file_path = kwargs.get('file_path', '')
|
|
file_content = kwargs.get('file_content', '')
|
|
if not file_path:
|
|
return {"success": False, "error": "file_path required"}
|
|
os.makedirs(skills_dir, exist_ok=True)
|
|
full_path = os.path.join(skills_dir, file_path)
|
|
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
|
with open(full_path, 'w') as f:
|
|
f.write(file_content)
|
|
return {"success": True, "action": "write_file", "name": name, "source": target}
|
|
|
|
elif action == "remove_file":
|
|
file_path = kwargs.get('file_path', '')
|
|
if not file_path:
|
|
return {"success": False, "error": "file_path required"}
|
|
full_path = os.path.join(skills_dir, file_path)
|
|
if os.path.exists(full_path):
|
|
os.remove(full_path)
|
|
return {"success": True, "action": "remove_file", "name": name, "source": target}
|
|
|
|
return {"success": False, "error": f"Unsupported action: {action}"}
|
|
except Exception as e:
|
|
return {"success": False, "error": str(e)}
|
|
|
|
async def wrapped_todo(todos: Optional[List[Dict[str, Any]]] = None, merge: bool = False,
|
|
context: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
try:
|
|
user_dir = _get_user_dir(HERMES_DIR, context)
|
|
todo_file = os.path.join(user_dir, "todo.json")
|
|
os.makedirs(user_dir, exist_ok=True)
|
|
if todos is not None:
|
|
with open(todo_file, 'w') as f:
|
|
json.dump(todos, f, indent=2)
|
|
return {"success": True, "action": "save", "count": len(todos)}
|
|
|
|
if os.path.exists(todo_file):
|
|
with open(todo_file, 'r') as f:
|
|
return {"success": True, "todos": json.load(f)}
|
|
return {"success": True, "todos": []}
|
|
except Exception as e:
|
|
return {"success": False, "error": str(e)}
|
|
|
|
async def wrapped_delegate_task(goal: Optional[str] = None, context: Optional[str] = None,
|
|
tasks: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
|
|
return {"success": True, "note": "Delegation requires spawning child processes/agents"}
|
|
|
|
async def wrapped_clarify(question: str, choices: Optional[List[str]] = None) -> Dict[str, Any]:
|
|
return {"success": True, "question": question, "choices": choices, "note": "Clarification is handled by the orchestrator UI"}
|
|
|
|
async def wrapped_cronjob(action: str, **kwargs) -> Dict[str, Any]:
|
|
return {"success": True, "note": "Cron management requires external scheduler integration"}
|
|
|
|
# Group tools by category for registration
|
|
file_tools = {
|
|
'read_file': wrapped_read_file,
|
|
'write_file': wrapped_write_file,
|
|
'search_files': wrapped_search_files,
|
|
'patch': wrapped_patch
|
|
}
|
|
|
|
system_tools = {
|
|
'terminal': wrapped_terminal,
|
|
'process': wrapped_process,
|
|
'execute_code': wrapped_execute_code
|
|
}
|
|
|
|
ai_tools = {
|
|
'vision_analyze': wrapped_vision_analyze,
|
|
'text_to_speech': wrapped_text_to_speech
|
|
}
|
|
|
|
|
|
async def wrapped_browser_snapshot(full: bool = False) -> Dict[str, Any]:
|
|
return {"success": True, "tool": "browser_snapshot", "note": "Requires browser automation driver"}
|
|
|
|
async def wrapped_browser_click(ref: str) -> Dict[str, Any]:
|
|
return {"success": True, "tool": "browser_click", "note": "Requires browser automation driver"}
|
|
|
|
async def wrapped_browser_type(ref: str, text: str) -> Dict[str, Any]:
|
|
return {"success": True, "tool": "browser_type", "note": "Requires browser automation driver"}
|
|
|
|
async def wrapped_browser_press(key: str) -> Dict[str, Any]:
|
|
return {"success": True, "tool": "browser_press", "note": "Requires browser automation driver"}
|
|
|
|
async def wrapped_browser_scroll(direction: str) -> Dict[str, Any]:
|
|
return {"success": True, "tool": "browser_scroll", "note": "Requires browser automation driver"}
|
|
|
|
async def wrapped_browser_console(clear: bool = False, expression: str = None) -> Dict[str, Any]:
|
|
return {"success": True, "tool": "browser_console", "note": "Requires browser automation driver"}
|
|
|
|
async def wrapped_browser_get_images() -> Dict[str, Any]:
|
|
return {"success": True, "tool": "browser_get_images", "note": "Requires browser automation driver"}
|
|
|
|
async def wrapped_browser_vision(question: str, annotate: bool = False) -> Dict[str, Any]:
|
|
return {"success": True, "tool": "browser_vision", "note": "Requires browser automation driver"}
|
|
|
|
async def wrapped_browser_back() -> Dict[str, Any]:
|
|
return {"success": True, "tool": "browser_back", "note": "Requires browser automation driver"}
|
|
|
|
browser_tools = {
|
|
'browser_navigate': wrapped_browser_navigate,
|
|
'browser_snapshot': wrapped_browser_snapshot,
|
|
'browser_click': wrapped_browser_click,
|
|
'browser_type': wrapped_browser_type,
|
|
'browser_press': wrapped_browser_press,
|
|
'browser_scroll': wrapped_browser_scroll,
|
|
'browser_console': wrapped_browser_console,
|
|
'browser_get_images': wrapped_browser_get_images,
|
|
'browser_vision': wrapped_browser_vision,
|
|
'browser_back': wrapped_browser_back
|
|
}
|
|
|
|
memory_tools = {
|
|
'memory': wrapped_memory,
|
|
'session_search': wrapped_session_search
|
|
}
|
|
|
|
skill_tools = {
|
|
'skill_view': wrapped_skill_view,
|
|
'skills_list': wrapped_skills_list,
|
|
'skill_manage': wrapped_skill_manage
|
|
}
|
|
|
|
task_tools = {
|
|
'todo': wrapped_todo,
|
|
'delegate_task': wrapped_delegate_task,
|
|
'clarify': wrapped_clarify,
|
|
'cronjob': wrapped_cronjob
|
|
}
|