Files
leon-ai--leon/tools/coding_development/opencode/src/python/opencode_tool.py
T

1847 lines
86 KiB
Python

import json
import os
import tempfile
import re
from datetime import datetime
from pathlib import Path
from typing import Dict, Any, Optional, List
from bridges.python.src.sdk.base_tool import BaseTool, ExecuteCommandOptions
from bridges.python.src.sdk.toolkit_config import ToolkitConfig
# Hardcoded default settings for OpenCode tool
OPENCODE_OPENROUTER_API_KEY = None
OPENCODE_OPENROUTER_MODEL = "openrouter/openai/gpt-5.2-codex"
DEFAULT_SETTINGS = {
"OPENCODE_OPENROUTER_API_KEY": OPENCODE_OPENROUTER_API_KEY,
"OPENCODE_OPENROUTER_MODEL": OPENCODE_OPENROUTER_MODEL,
}
REQUIRED_SETTINGS = ["OPENCODE_OPENROUTER_API_KEY"]
OPENCODE_CONFIG_CONTENT = {
"$schema": "https://opencode.ai/config.json",
"permission": "allow",
"provider": {"openrouter": {"options": {"apiKey": ""}}},
}
class OpenCodeTool(BaseTool):
"""OpenCode tool for AI-powered code generation using OpenCode CLI"""
TOOLKIT = "coding_development"
def __init__(self):
super().__init__()
self.config = ToolkitConfig.load(self.TOOLKIT, self.tool_name)
self.providers: Dict[str, Dict[str, Any]] = {}
tool_settings = ToolkitConfig.load_tool_settings(
self.TOOLKIT, self.tool_name, DEFAULT_SETTINGS
)
self.settings = tool_settings
self.required_settings = REQUIRED_SETTINGS
self._check_required_settings(self.tool_name)
openrouter_api_key = tool_settings.get("OPENCODE_OPENROUTER_API_KEY")
if openrouter_api_key and str(openrouter_api_key).strip():
OPENCODE_CONFIG_CONTENT["provider"]["openrouter"]["options"]["apiKey"] = (
openrouter_api_key
)
os.environ["OPENCODE_CONFIG_CONTENT"] = json.dumps(OPENCODE_CONFIG_CONTENT)
# Auto-configure providers from toolkit settings
self._load_providers_from_settings(self.settings)
# Provider configurations based on OpenCode documentation
self.provider_configs = {
"openrouter": {
"name": "OpenRouter",
"default_model": "openrouter/openai/gpt-5.2-codex",
}
}
def _load_providers_from_settings(self, tool_settings: Dict[str, Any]) -> None:
"""Load provider configurations from toolkit settings"""
provider_settings_map = {
"openrouter": {
"api_key_key": "OPENCODE_OPENROUTER_API_KEY",
"model_key": "OPENCODE_OPENROUTER_MODEL",
"api_key_default": OPENCODE_OPENROUTER_API_KEY,
"model_default": OPENCODE_OPENROUTER_MODEL,
}
}
for provider, settings_config in provider_settings_map.items():
api_key = tool_settings.get(
settings_config["api_key_key"], settings_config["api_key_default"]
)
model = tool_settings.get(
settings_config["model_key"], settings_config["model_default"]
)
if api_key and api_key.strip():
self.configure_provider(provider, api_key, model)
@property
def tool_name(self) -> str:
return "opencode"
@property
def toolkit(self) -> str:
return self.TOOLKIT
@property
def description(self) -> str:
return self.config["description"]
def configure_provider(
self, provider: str, api_key: str, model: Optional[str] = None
) -> None:
"""Configure a provider with API key"""
if provider not in self.provider_configs:
raise ValueError(f"Unknown provider: {provider}")
provider_config = self.provider_configs[provider]
self.providers[provider] = {
"name": provider_config["name"],
"api_key": api_key,
"model": model or provider_config["default_model"],
}
def get_configured_providers(self) -> List[str]:
"""Get list of configured providers"""
return list(self.providers.keys())
def get_available_providers(self) -> List[str]:
"""Get list of available providers"""
return list(self.provider_configs.keys())
def get_default_model(self, provider: str) -> str:
"""Get default model for a provider"""
if provider not in self.provider_configs:
raise ValueError(f"Unknown provider: {provider}")
return self.provider_configs[provider]["default_model"]
def _setup_provider_auth(self, provider: str, api_key: str) -> None:
"""Setup OpenCode auth for a provider"""
auth_file = Path.home() / ".local" / "share" / "opencode" / "auth.json"
# Ensure directory exists
auth_file.parent.mkdir(parents=True, exist_ok=True)
auth_data: Dict[str, Dict[str, str]] = {}
# Read existing auth if it exists
if auth_file.exists():
with open(auth_file, "r") as f:
auth_data = json.load(f)
# Add/update provider auth
auth_data[provider] = {"apiKey": api_key}
# Write auth file
with open(auth_file, "w") as f:
json.dump(auth_data, f, indent=2)
def _analyze_relevant_toolkits(self, description: str) -> set:
"""Analyze skill description to determine relevant toolkits"""
description_lower = description.lower()
relevant_toolkits = set()
toolkits_dir = Path("tools")
if not toolkits_dir.exists():
# Default to coding_development if toolkits directory doesn't exist
relevant_toolkits.add("coding_development")
return relevant_toolkits
try:
for toolkit_dir in toolkits_dir.iterdir():
if not toolkit_dir.is_dir():
continue
toolkit_json = toolkit_dir / "toolkit.json"
if not toolkit_json.exists():
continue
try:
with open(toolkit_json) as f:
toolkit_data = json.load(f)
if not toolkit_data.get("description"):
continue
# Extract meaningful words from toolkit description
toolkit_desc_lower = toolkit_data["description"].lower()
toolkit_words = [
word
for word in toolkit_desc_lower.split()
if len(word) > 3 # Filter out short words
]
# Also extract words from toolkit name
toolkit_name_words = [
word
for word in (toolkit_data.get("name", "")).lower().split()
if len(word) > 3
]
# Check if any meaningful words from toolkit match the skill description
all_words = toolkit_words + toolkit_name_words
for word in all_words:
if word in description_lower:
relevant_toolkits.add(toolkit_dir.name)
break
except (json.JSONDecodeError, KeyError):
continue
# If no specific toolkits matched, include coding_development as a default
if not relevant_toolkits:
relevant_toolkits.add("coding_development")
except Exception:
# If we can't scan toolkits, default to coding_development
relevant_toolkits.add("coding_development")
return relevant_toolkits
def _scan_available_toolkits(self, relevant_toolkits: Optional[set] = None) -> str:
"""Scan available toolkits and their tools (optionally filtered)"""
toolkits_dir = Path("tools")
toolkit_info = "# Available Leon Tools & Toolkits\n\n"
toolkit_info += "**IMPORTANT**: You must USE existing tools instead of creating duplicate functionality.\n"
toolkit_info += "You can EXTEND existing tools with new methods OR create NEW tools when necessary.\n\n"
if not toolkits_dir.exists():
toolkit_info += "Could not scan available toolkits. Use existing tools when possible.\n\n"
return toolkit_info
try:
for toolkit_dir in toolkits_dir.iterdir():
if not toolkit_dir.is_dir():
continue
# Skip if filtering is enabled and this toolkit is not relevant
if (
relevant_toolkits is not None
and toolkit_dir.name not in relevant_toolkits
):
continue
toolkit_json = toolkit_dir / "toolkit.json"
if not toolkit_json.exists():
continue
try:
with open(toolkit_json) as f:
toolkit_data = json.load(f)
tools = toolkit_data.get("tools", [])
if not tools:
continue
toolkit_info += f"## {toolkit_data.get('name', toolkit_dir.name)}\n"
toolkit_info += (
f"{toolkit_data.get('description', 'No description')}\n\n"
)
for tool_name in tools:
tool_manifest = toolkit_dir / tool_name / "tool.json"
tool_description = "No description"
if tool_manifest.exists():
try:
with open(tool_manifest, "r", encoding="utf-8") as f:
manifest_data = json.load(f)
tool_description = manifest_data.get(
"description", tool_description
)
except json.JSONDecodeError:
pass
toolkit_info += f"### {tool_name}\n"
toolkit_info += f"- **Description**: {tool_description}\n"
# Convert to PascalCase for import
pascal_name = "".join(
word.capitalize()
for word in tool_name.replace("-", "_").split("_")
)
import_path = f"@tools/{toolkit_dir.name}/{tool_name}"
toolkit_info += f"- **Import**: `import {pascal_name}Tool from '{import_path}'`\n"
toolkit_info += "\n"
toolkit_info += "\n"
except (json.JSONDecodeError, KeyError):
continue
except Exception:
toolkit_info += "Could not scan available toolkits. Use existing tools when possible.\n\n"
return toolkit_info
def _get_tool_methods(self, tool_name: str) -> List[Dict[str, str]]:
"""Get method signatures from a tool file"""
tools_root = Path("tools")
tool_path = next(
tools_root.glob(f"*/{tool_name}/src/nodejs/{tool_name}-tool.ts"),
None,
)
if not tool_path or not tool_path.exists():
return []
try:
with open(tool_path, "r") as f:
content = f.read()
methods = []
# Simple regex to extract public method signatures and JSDoc comments
method_pattern = r"/\*\*[\s\S]*?\*/\s*(?:async\s+)?(\w+)\s*\([^)]*\):[^{]*"
matches = re.findall(method_pattern, content)
for match in matches:
method_name = match
# Skip private methods and getters
if method_name.startswith("_") or method_name == "constructor":
continue
# Extract JSDoc for this method (simplified)
description = "No description"
# Look for JSDoc before the method
jsdoc_match = re.search(
r"/\*\*([\s\S]*?)\*/\s*(?:async\s+)?" + re.escape(method_name),
content,
)
if jsdoc_match:
jsdoc_content = jsdoc_match.group(1)
desc_match = re.search(r"\*\s*([^@\n]+)", jsdoc_content)
if desc_match:
description = desc_match.group(1).strip()
methods.append({"name": method_name, "description": description})
return methods
except Exception:
return []
def _scan_aurora_components(self) -> str:
"""Scan Aurora SDK components and document their usage"""
aurora_dir = Path("bridges/nodejs/src/sdk/aurora")
aurora_doc = ""
aurora_doc += "# Aurora UI Components\n\n"
aurora_doc += "# Aurora UI Components\n\n"
aurora_doc += "Focus on **non-interactive components** (Text, Image, Lists, Loaders, Progress).\n\n"
try:
if not aurora_dir.exists():
aurora_doc += "Could not scan Aurora components. Use Card, Text, Flexbox, List, ListItem, CircularProgress, Progress, and Loader.\n\n"
return aurora_doc
component_files = list(aurora_dir.iterdir())
non_interactive_components = [
"card",
"circular-progress",
"flexbox",
"icon",
"image",
"link",
"list",
"list-header",
"list-item",
"loader",
"progress",
"scroll-container",
"status",
"text",
"widget-wrapper",
]
aurora_doc += "## Available Components\n\n"
aurora_doc += "**Layout**: Card, Flexbox, ScrollContainer\n"
aurora_doc += "**Display**: Text, Image, Icon, Link, Status\n"
aurora_doc += "**Lists**: List, ListItem, ListHeader\n"
aurora_doc += "**Feedback**: Loader, Progress, CircularProgress\n\n"
aurora_doc += "**Import**: `from bridges.python.src.sdk.aurora.component_name import ComponentName`\n\n"
aurora_doc += "## Widget Pattern (Python)\n\n"
aurora_doc += "```python\n"
aurora_doc += (
"from bridges.python.src.sdk.widget import Widget, WidgetOptions\n"
)
aurora_doc += "from bridges.python.src.sdk.aurora.flexbox import Flexbox\n"
aurora_doc += "from bridges.python.src.sdk.aurora.text import Text\n\n"
aurora_doc += "class MyWidget(Widget[Params]):\n"
aurora_doc += " def render(self):\n"
aurora_doc += " # Use Flexbox or List as root (NOT Card!)\n"
aurora_doc += (
" return Flexbox({'children': [Text({'children': 'Hello'})]})\n"
)
aurora_doc += "```\n\n"
aurora_doc += "## Common Component Props\n\n"
aurora_doc += "### Flexbox Props\n"
aurora_doc += "- `flexDirection`: 'row' | 'column'\n"
aurora_doc += "- `gap`: 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'\n"
aurora_doc += "- `alignItems`: 'start' | 'center' | 'end' | 'stretch'\n"
aurora_doc += "- `justifyContent`: 'start' | 'center' | 'end' | 'between' | 'around'\n"
aurora_doc += "- `children`: Array of components\n\n"
aurora_doc += "### Text Props\n"
aurora_doc += "- `children`: string (the text content)\n"
aurora_doc += "- `fontSize`: 'xs' | 'sm' | 'md' | 'lg' | 'xl'\n"
aurora_doc += "- `fontWeight`: 'normal' | 'medium' | 'semi-bold' | 'bold'\n"
aurora_doc += "- `secondary`: boolean (muted color)\n\n"
aurora_doc += "### Card Props\n"
aurora_doc += "- `children`: Array of components\n"
aurora_doc += "- `padding`: 'none' | 'sm' | 'md' | 'lg'\n\n"
aurora_doc += "### CircularProgress Props\n"
aurora_doc += "- `value`: number (0-100)\n"
aurora_doc += "- `size`: 'sm' | 'md' | 'lg'\n"
aurora_doc += "- `children`: string (center text, optional)\n\n"
aurora_doc += "### Progress Props\n"
aurora_doc += "- `value`: number (0-100)\n"
aurora_doc += "- `size`: 'sm' | 'md' | 'lg'\n\n"
aurora_doc += "### Loader Props\n"
aurora_doc += "- `size`: 'sm' | 'md' | 'lg'\n\n"
aurora_doc += "### List Props\n"
aurora_doc += "- `children`: Array of ListItem components\n\n"
aurora_doc += "### ListItem Props\n"
aurora_doc += "- `children`: string | component\n\n"
aurora_doc += "### ListHeader Props\n"
aurora_doc += "- `children`: string\n\n"
aurora_doc += "## Critical Rules (Python)\n\n"
aurora_doc += "- Root: Flexbox or List (NOT Card!)\n"
aurora_doc += "- Image props: use 'backgroundSize', 'shape', 'radiusTop'/'radiusBottom' (booleans)\n"
aurora_doc += "- File: src/widgets/widget_name.py\n\n"
except Exception:
aurora_doc += "Could not scan Aurora components. Use Card, Text, Flexbox, List, ListItem, CircularProgress, Progress, and Loader.\n\n"
return aurora_doc
def _get_tool_creation_guidelines(self, bridge: str) -> str:
"""Get tool creation and extension guidelines"""
guidelines = ""
guidelines += "# Creating New Tools or Extending Existing Tools\n\n"
guidelines += "You have the ability to create NEW tools or EXTEND existing tools with new methods.\n\n"
guidelines += "## Decision: When to Create vs Extend\n\n"
guidelines += "- **Use existing tools**: If a tool already provides the functionality needed\n"
guidelines += "- **Extend existing tools**: If a tool exists in the right domain but lacks a specific method\n"
guidelines += "- **Create new tools**: When no existing toolkit/tool covers the domain\n\n"
guidelines += "## Creating a New Tool\n\n"
if bridge == "nodejs":
guidelines += "### TypeScript Tool Structure\n\n"
guidelines += "Create a new file at `tools/{toolkit_name}/{tool_name}/src/nodejs/{tool_name}-tool.ts`:\n\n"
guidelines += "```typescript\n"
guidelines += "import { Tool } from '@sdk/base-tool'\n"
guidelines += "import { ToolkitConfig } from '@sdk/toolkit-config'\n\n"
guidelines += "export default class MyNewTool extends Tool {\n"
guidelines += " private static readonly TOOLKIT = 'toolkit_name' // e.g., 'music_audio'\n"
guidelines += (
" private readonly config: ReturnType<typeof ToolkitConfig.load>\n\n"
)
guidelines += " constructor() {\n"
guidelines += " super()\n"
guidelines += " this.config = ToolkitConfig.load(MyNewTool.TOOLKIT, this.toolName)\n"
guidelines += " }\n\n"
guidelines += " get toolName(): string {\n"
guidelines += " return 'mynew' # Hardcode tool name\n"
guidelines += " }\n\n"
guidelines += " get toolkit(): string {\n"
guidelines += " return MyNewTool.TOOLKIT\n"
guidelines += " }\n\n"
guidelines += " get description(): string {\n"
guidelines += " return this.config['description']\n"
guidelines += " }\n\n"
guidelines += " /**\n"
guidelines += " * Your tool method\n"
guidelines += " */\n"
guidelines += " async myMethod(param: string): Promise<string> {\n"
guidelines += " // Implementation\n"
guidelines += (
" // If the tool needs a binary, use this.executeCommand()\n"
)
guidelines += " return 'result'\n"
guidelines += " }\n"
guidelines += "}\n"
guidelines += "```\n\n"
else:
guidelines += "### Python Tool Structure\n\n"
guidelines += "Create a new file at `tools/{toolkit_name}/{tool_name}/src/python/{tool_name}_tool.py`:\n\n"
guidelines += "```python\n"
guidelines += "from bridges.python.src.sdk.base_tool import BaseTool\n"
guidelines += "from bridges.python.src.sdk.toolkit_config import ToolkitConfig\n\n"
guidelines += "class MyNewTool(BaseTool):\n"
guidelines += " TOOLKIT = 'toolkit_name' # e.g., 'music_audio'\n\n"
guidelines += " def __init__(self):\n"
guidelines += " super().__init__()\n"
guidelines += " self.config = ToolkitConfig.load(self.TOOLKIT, self.tool_name)\n\n"
guidelines += " @property\n"
guidelines += " def tool_name(self) -> str:\n"
guidelines += " return 'mynew' # Hardcode tool name\n\n"
guidelines += " @property\n"
guidelines += " def toolkit(self) -> str:\n"
guidelines += " return self.TOOLKIT\n\n"
guidelines += " @property\n"
guidelines += " def description(self) -> str:\n"
guidelines += " return self.config['description']\n\n"
guidelines += " def my_method(self, param: str) -> str:\n"
guidelines += " # Implementation\n"
guidelines += " return 'result'\n"
guidelines += "```\n\n"
guidelines += "### Register New Tool\n\n"
guidelines += (
"1) Add tool id to `tools/{toolkit_name}/toolkit.json`:\n\n"
)
guidelines += "```json\n"
guidelines += "{\n"
guidelines += ' "name": "Toolkit Name",\n'
guidelines += ' "description": "Description",\n'
guidelines += ' "tools": ["mynew"]\n'
guidelines += "}\n"
guidelines += "```\n\n"
guidelines += "2) Create tool manifest `tools/{toolkit_name}/mynew/tool.json`:\n\n"
guidelines += "```json\n"
guidelines += "{\n"
guidelines += ' "$schema": "../../../schemas/tool-schemas/tool.json",\n'
guidelines += ' "tool_id": "mynew",\n'
guidelines += ' "toolkit_id": "{toolkit_name}",\n'
guidelines += ' "name": "My New Tool",\n'
guidelines += ' "description": "My new tool description",\n'
guidelines += ' "author": { "name": "Your Name" },\n'
guidelines += ' "binaries": {\n'
guidelines += ' "linux-x86_64": "https://url-to-binary.tar.gz"\n'
guidelines += " },\n"
guidelines += ' "functions": {\n'
guidelines += ' "my_method": {\n'
guidelines += ' "description": "My method description",\n'
guidelines += ' "input_schema": { "param": "string" }\n'
guidelines += " }\n"
guidelines += " }\n"
guidelines += "}\n"
guidelines += "```\n\n"
guidelines += "## Extending an Existing Tool\n\n"
guidelines += "To add a new method to an existing tool:\n\n"
if bridge == "nodejs":
guidelines += "1. Open the existing tool file (e.g., `tools/video_streaming/ytdlp/src/nodejs/ytdlp-tool.ts`)\n"
guidelines += "2. Add your new method to the class:\n\n"
guidelines += "```typescript\n"
guidelines += " /**\n"
guidelines += " * My new method description\n"
guidelines += " */\n"
guidelines += " async myNewMethod(param: string): Promise<string> {\n"
guidelines += " // Use this.executeCommand() for binary tools\n"
guidelines += " const result = await this.executeCommand({\n"
guidelines += " binaryName: 'yt-dlp',\n"
guidelines += " args: ['--param', param],\n"
guidelines += " options: { sync: true }\n"
guidelines += " })\n"
guidelines += " return result\n"
guidelines += " }\n"
guidelines += "```\n\n"
else:
guidelines += "1. Open the existing tool file (e.g., `tools/video_streaming/ytdlp/src/python/ytdlp_tool.py`)\n"
guidelines += "2. Add your new method to the class:\n\n"
guidelines += "```python\n"
guidelines += " def my_new_method(self, param: str) -> str:\n"
guidelines += ' """My new method description"""\n'
guidelines += " # Use self.execute_command() for binary tools\n"
guidelines += " result = self.execute_command(\n"
guidelines += " binary_name='yt-dlp',\n"
guidelines += " args=['--param', param]\n"
guidelines += " )\n"
guidelines += " return result\n"
guidelines += "```\n\n"
guidelines += "## Important Notes\n\n"
guidelines += "- **Never duplicate**: Check existing tools first before creating new ones\n"
guidelines += "- **Toolkit placement**: Choose the right toolkit (e.g., audio tools go in music_audio)\n"
guidelines += "- **Binary tools**: If your tool wraps a CLI binary, use `executeCommand()`\n"
guidelines += "- **Pure code tools**: If no binary is needed, implement the logic directly\n"
guidelines += "- **Method naming**: Use clear, descriptive names (e.g., `downloadVideo`, `extractAudio`)\n\n"
return guidelines
def _build_leon_context(
self,
description: str,
system_prompt: Optional[str] = None,
context_files: Optional[List[str]] = None,
bridge: str = "nodejs",
) -> str:
"""Build Leon-specific context for OpenCode from template"""
try:
system_prompt_section = (
f"# System Instructions\n\n{system_prompt}\n\n" if system_prompt else ""
)
relevant_toolkits = self._analyze_relevant_toolkits(description)
toolkit_info = self._scan_available_toolkits(relevant_toolkits)
repo_snapshot = self._build_repo_snapshot(context_files or [])
reference_files_section = self._build_reference_files_section(
context_files or []
)
language = "TypeScript" if bridge == "nodejs" else "Python"
file_extension = ".ts" if bridge == "nodejs" else ".py"
bridge_name = "Node.js" if bridge == "nodejs" else "Python"
bridge_path = "nodejs" if bridge == "nodejs" else "python"
tool_creation_guidelines = self._get_tool_creation_guidelines(bridge)
aurora_components = self._scan_aurora_components()
template = self._load_prompt_template()
return self._apply_template(
template,
{
"SYSTEM_PROMPT_SECTION": system_prompt_section,
"REPO_SNAPSHOT": repo_snapshot,
"TOOLKIT_INFO": toolkit_info,
"LANGUAGE": language,
"FILE_EXTENSION": file_extension,
"BRIDGE": bridge,
"BRIDGE_NAME": bridge_name,
"BRIDGE_PATH": bridge_path,
"BRIDGE_SPECIFIC_GUIDELINES": self._build_bridge_specific_guidelines(
bridge, file_extension
),
"SETTINGS_USAGE_EXAMPLE": self._build_settings_usage_example(
bridge
),
"BATCH_PROCESSING_EXAMPLE": self._build_batch_processing_example(
bridge
),
"TOOL_CREATION_GUIDELINES": tool_creation_guidelines,
"AURORA_COMPONENTS": aurora_components,
"LEON_ANSWER_BASIC_EXAMPLE": self._build_leon_answer_basic_example(
bridge
),
"CONTEXT_DATA_EXAMPLE": self._build_context_data_example(bridge),
"ACTION_PARAMS_EXAMPLE": self._build_action_params_example(bridge),
"REFERENCE_FILES_SECTION": reference_files_section,
},
)
except Exception:
return self._build_leon_context_legacy(
description, system_prompt, context_files, bridge
)
def _load_prompt_template(self) -> str:
template_path = Path(__file__).parent / "lib" / "prompt.md"
return template_path.read_text(encoding="utf-8")
def _apply_template(self, template: str, values: Dict[str, str]) -> str:
output = template
for key, value in values.items():
output = re.sub(r"{{\s*" + re.escape(key) + r"\s*}}", value, output)
return output
def _build_bridge_specific_guidelines(
self, bridge: str, file_extension: str
) -> str:
if bridge == "nodejs":
return (
"- **Tool usage**: Import tools like `import YtdlpTool from '@tools/video_streaming/ytdlp'`\n"
"- **SDK imports**: @sdk/types, @sdk/leon, @sdk/params-helper\n"
"- **Action structure**: Export a `run` function as the action entry point\n"
"- **Responses**: Use leon.answer() to respond to users\n"
f"- **File extensions**: ALL files MUST use {file_extension} (actions, widgets, utilities)\n"
"- **Extra files**: Put shared helpers in src/lib; only action entry points go in src/actions\n"
f"- **File structure**: skill.json + locales/en.json + src/actions/*{file_extension} + src/widgets/*{file_extension} + src/lib/*{file_extension}\n"
)
return (
"- **Tool usage**: Import tools like `from tools.video_streaming.ytdlp import YtdlpTool`\n"
"- **SDK imports**: from bridges.python.src.sdk.leon import leon; from bridges.python.src.sdk.types import ActionParams; from bridges.python.src.sdk.params_helper import ParamsHelper\n"
"- **Action structure**: Define a `run` function as the action entry point\n"
"- **Responses**: Use leon.answer() to respond to users\n"
f"- **File extensions**: ALL files MUST use {file_extension} (actions, widgets, utilities)\n"
"- **Extra files**: Put shared helpers in src/lib; only action entry points go in src/actions\n"
f"- **File structure**: skill.json + locales/en.json + src/actions/*{file_extension} + src/widgets/*{file_extension} + src/lib/*{file_extension}\n"
)
def _build_settings_usage_example(self, bridge: str) -> str:
if bridge == "nodejs":
return (
"```typescript\n"
"import { Settings } from '@sdk/settings'\n"
"import ToolManager, { isMissingToolSettingsError } from '@sdk/tool-manager'\n"
"import OpenRouterTool from '@tools/communication/openrouter'\n\n"
"interface MySkillSettings extends Record<string, unknown> {\n"
" provider_model?: string\n"
" max_tokens?: number\n"
"}\n\n"
"export const run: ActionFunction = async function (params, paramsHelper) {\n"
" const settings = new Settings<MySkillSettings>()\n"
" const model = (await settings.get('provider_model')) || 'default-model'\n"
" const maxTokens = (await settings.get('max_tokens')) || 1000\n\n"
" let tool: OpenRouterTool\n"
" try {\n"
" tool = await ToolManager.initTool(OpenRouterTool)\n"
" } catch (error) {\n"
" if (isMissingToolSettingsError(error)) return\n"
" throw error\n"
" }\n\n"
" // Use tool + settings...\n"
" // On errors, include: core: { should_stop_skill: true }\n"
"}\n"
"```\n\n"
)
return (
"```python\n"
"from bridges.python.src.sdk.tool_manager import ToolManager, is_missing_tool_settings_error\n"
"from bridges.python.src.sdk.types import ActionParams\n"
"from bridges.python.src.sdk.settings import Settings\n"
"from tools.communication.openrouter import OpenRouterTool\n\n"
"def run(params: ActionParams, params_helper: ParamsHelper) -> None:\n"
" settings = Settings()\n"
" model = settings.get('provider_model') or 'default-model'\n"
" max_tokens = settings.get('max_tokens') or 1000\n\n"
" try:\n"
" tool = ToolManager.init_tool(OpenRouterTool)\n"
" except Exception as error:\n"
" if is_missing_tool_settings_error(error):\n"
" return\n"
" raise\n\n"
" # Use tool + settings...\n"
"```\n\n"
)
def _build_batch_processing_example(self, bridge: str) -> str:
if bridge == "nodejs":
return (
"```typescript\n"
"// DON'T DO THIS - Inefficient!\n"
"for (const segment of segments) {\n"
" await chatterbox.synthesizeSpeechToFiles({\n"
" text: segment.text,\n"
" audio_path: segment.path\n"
" })\n"
"}\n"
"```\n\n"
"[CORRECT] - Single batch call (FAST):\n"
"```typescript\n"
"// DO THIS - Read the tool to discover it accepts an array!\n"
"const tasks = segments.map(segment => ({\n"
" text: segment.text,\n"
" audio_path: segment.path,\n"
" voice_name: segment.voice\n"
"}))\n\n"
"// Single call processes all segments efficiently\n"
"await chatterbox.synthesizeSpeechToFiles(tasks)\n"
"```\n\n"
)
return (
"```python\n"
"# DON'T DO THIS - Inefficient!\n"
"for segment in segments:\n"
" chatterbox.synthesize_speech_to_files({\n"
" 'text': segment['text'],\n"
" 'audio_path': segment['path']\n"
" })\n"
"```\n\n"
"[CORRECT] - Single batch call (FAST):\n"
"```python\n"
"# DO THIS - Read the tool to discover it accepts a list!\n"
"tasks = [{\n"
" 'text': segment['text'],\n"
" 'audio_path': segment['path'],\n"
" 'voice_name': segment['voice']\n"
"} for segment in segments]\n\n"
"# Single call processes all segments efficiently\n"
"chatterbox.synthesize_speech_to_files(tasks)\n"
"```\n\n"
)
def _build_leon_answer_basic_example(self, bridge: str) -> str:
if bridge == "nodejs":
return (
"```typescript\n"
"// Simple text response with localized message key\n"
"leon.answer({\n"
" key: 'success_message',\n"
" data: {\n"
" file_name: 'example.mp4',\n"
" file_size: '25 MB'\n"
" }\n"
"})\n"
"```\n\n"
)
return (
"```python\n"
"# Simple text response with localized message key\n"
"leon.answer({\n"
" 'key': 'success_message',\n"
" 'data': {\n"
" 'file_name': 'example.mp4',\n"
" 'file_size': '25 MB'\n"
" }\n"
"})\n"
"```\n\n"
)
def _build_context_data_example(self, bridge: str) -> str:
if bridge == "nodejs":
return (
"```typescript\n"
"// Action 1: Download video and pass path to next action\n"
"leon.answer({\n"
" key: 'download_completed',\n"
" data: {\n"
" file_path: formatFilePath(videoPath)\n"
" },\n"
" core: {\n"
" context_data: {\n"
" video_path: videoPath, // Pass full path\n"
" target_language: targetLanguage, // Pass other needed data\n"
" quality: quality\n"
" }\n"
" }\n"
"})\n\n"
"// Action 2: Retrieve data from previous action\n"
"const videoPath = paramsHelper.getContextData<string>('video_path')\n"
"const targetLanguage = paramsHelper.getContextData<string>('target_language')\n"
"```\n\n"
)
return (
"```python\n"
"# Action 1: Download video and pass path to next action\n"
"leon.answer({\n"
" 'key': 'download_completed',\n"
" 'data': {\n"
" 'file_path': format_file_path(video_path)\n"
" },\n"
" 'core': {\n"
" 'context_data': {\n"
" 'video_path': video_path, # Pass full path\n"
" 'target_language': target_language, # Pass other needed data\n"
" 'quality': quality\n"
" }\n"
" }\n"
"})\n\n"
"# Action 2: Retrieve data from previous action\n"
"video_path = params_helper.get_context_data('video_path')\n"
"target_language = params_helper.get_context_data('target_language')\n"
"```\n\n"
)
def _build_action_params_example(self, bridge: str) -> str:
if bridge == "nodejs":
return (
"```typescript\n"
"import type { ActionFunction } from '@sdk/types'\n"
"import { leon } from '@sdk/leon'\n"
"import { ParamsHelper } from '@sdk/params-helper'\n\n"
"export const run: ActionFunction = async function (\n"
" params,\n"
" paramsHelper: ParamsHelper\n"
") {\n"
" // Get action arguments defined in skill.json parameters\n"
" const location = paramsHelper.getActionArgument('location') as string\n"
" const units = paramsHelper.getActionArgument('units') as string | undefined\n\n"
" // Access raw params if needed\n"
" const utterance = params.utterance\n"
" const lang = params.lang\n"
"}\n"
"```\n\n"
)
return (
"```python\n"
"from bridges.python.src.sdk.leon import leon\n"
"from bridges.python.src.sdk.types import ActionParams\n"
"from bridges.python.src.sdk.params_helper import ParamsHelper\n\n"
"def run(params: ActionParams, params_helper: ParamsHelper) -> None:\n"
" # Get action arguments defined in skill.json parameters\n"
" location = params_helper.get_action_argument('location')\n"
" units = params_helper.get_action_argument('units')\n\n"
" # Access raw params if needed\n"
" utterance = params.get('utterance')\n"
" lang = params.get('lang')\n"
"```\n\n"
)
def _build_reference_files_section(self, context_files: List[str]) -> str:
if not context_files:
return ""
lines = ["# Reference Files\n", "Please study these example files:"]
lines.extend([f"- {file}" for file in context_files])
return "\n".join(lines) + "\n\n"
def _build_repo_snapshot(self, context_files: List[str]) -> str:
root_dir = Path.cwd()
try:
root_entries = sorted(
[entry.name for entry in root_dir.iterdir() if entry.is_dir()]
)
except Exception:
root_entries = []
skills_updated_at = self._get_latest_mtime(root_dir / "skills")
toolkits_updated_at = self._get_latest_mtime(root_dir / "tools")
context_file_lines = self._get_context_file_snapshot(context_files)
lines = [
"# Repository Snapshot (Quick)\n",
f"Generated: {datetime.utcnow().isoformat()}Z",
f"Root: {root_dir}",
f"Top-level directories: {', '.join(root_entries) if root_entries else 'n/a'}",
f"skills/ updated: {self._format_snapshot_date(skills_updated_at)}",
f"tools/ updated: {self._format_snapshot_date(toolkits_updated_at)}",
]
if context_file_lines:
lines.append("Context files:")
lines.extend([f"- {line}" for line in context_file_lines])
return "\n".join(lines) + "\n\n"
def _format_snapshot_date(self, value: Optional[float]) -> str:
return (
datetime.utcfromtimestamp(value).isoformat() + "Z" if value else "unknown"
)
def _get_latest_mtime(self, dir_path: Path) -> Optional[float]:
try:
entries = list(dir_path.iterdir())
if not entries:
return None
latest = max(entry.stat().st_mtime for entry in entries)
return latest
except Exception:
return None
def _get_context_file_snapshot(self, context_files: List[str]) -> List[str]:
snapshots = []
for file in context_files:
full_path = Path.cwd() / file
try:
mtime = full_path.stat().st_mtime
snapshots.append(
f"{file} (modified {datetime.utcfromtimestamp(mtime).isoformat()}Z)"
)
except Exception:
snapshots.append(f"{file} (missing)")
return snapshots
def _build_leon_context_legacy(
self,
description: str,
system_prompt: Optional[str] = None,
context_files: Optional[List[str]] = None,
bridge: str = "nodejs",
) -> str:
"""Build Leon-specific context for OpenCode"""
context = ""
if system_prompt:
context += f"# System Instructions\n\n{system_prompt}\n\n"
# Analyze and determine relevant toolkits based on skill description
relevant_toolkits = self._analyze_relevant_toolkits(description)
# Add available toolkits and tools information (filtered by relevance)
context += self._scan_available_toolkits(relevant_toolkits)
language = "TypeScript" if bridge == "nodejs" else "Python"
file_extension = ".ts" if bridge == "nodejs" else ".py"
context += "# Leon Skill Development Guidelines\n\n"
context += f"You are generating code for Leon AI assistant using **{language}**. Follow these guidelines:\n\n"
context += f"- **Language**: CRITICAL - Write ALL skill source code in {language} (actions, widgets, utilities, everything)\n"
context += f"- **Bridge**: Use the {'Node.js' if bridge == 'nodejs' else 'Python'} bridge\n"
context += f"- **Consistency**: The bridge setting ({bridge}) applies to the ENTIRE skill - all actions, widgets, and utilities must use {language}\n"
context += "- **Skill Location**: CRITICAL - Create skills directly in the `skills/` folder, NOT in subfolders\n"
context += "- **Use existing tools**: Check the tools listed above first! Don't recreate functionality.\n"
context += "- **DON'T modify tools**: Never edit existing tool files. Only use them in your actions.\n"
if bridge == "nodejs":
context += "- **Tool usage**: Import tools like `import YtdlpTool from '@tools/video_streaming/ytdlp'`\n"
context += "- **SDK imports**: @sdk/types, @sdk/leon, @sdk/params-helper\n"
context += "- **Action structure**: Export a `run` function as the action entry point\n"
context += "- **Responses**: Use leon.answer() to respond to users\n"
context += f"- **File extensions**: ALL files MUST use {file_extension} (actions, widgets, utilities)\n"
context += "- **Extra files**: Put shared helpers in src/lib; only action entry points go in src/actions\n"
context += f"- **File structure**: skill.json + locales/en.json + src/actions/*{file_extension} + src/widgets/*{file_extension} + src/lib/*{file_extension}\n"
else:
context += "- **Tool usage**: Import tools like `from tools.video_streaming.ytdlp import YtdlpTool`\n"
context += "- **SDK imports**: from bridges.python.src.sdk.leon import leon; from bridges.python.src.sdk.types import ActionParams; from bridges.python.src.sdk.params_helper import ParamsHelper\n"
context += "- **Action structure**: Define a `run` function as the action entry point\n"
context += "- **Responses**: Use leon.answer() to respond to users\n"
context += f"- **File extensions**: ALL files MUST use {file_extension} (actions, widgets, utilities)\n"
context += "- **Extra files**: Put shared helpers in src/lib; only action entry points go in src/actions\n"
context += f"- **File structure**: skill.json + locales/en.json + src/actions/*{file_extension} + src/widgets/*{file_extension} + src/lib/*{file_extension}\n"
context += "- **Validation**: Validate against schemas in ../../schemas/skill-schemas/\n\n"
context += "# Skill Directory Structure - CRITICAL\n\n"
context += "**IMPORTANT**: Skills must be created directly in the `skills/` root folder.\n\n"
context += "## Correct Structure\n\n"
context += "```\n"
context += "skills/\n"
context += "├── my_skill_name/ # ✅ Directly in skills/ folder\n"
context += "│ ├── skill.json\n"
context += "│ ├── locales/\n"
context += "│ │ └── en.json\n"
context += "│ └── src/\n"
context += "│ ├── settings.sample.json\n"
context += "│ ├── settings.json\n"
context += "│ ├── actions/\n"
context += f"│ │ └── action_name{file_extension}\n"
context += "│ ├── lib/ # Helpers/utilities\n"
context += f"│ │ └── helpers{file_extension}\n"
context += "│ └── widgets/ # Optional\n"
context += f"│ └── widget_name{file_extension}\n"
context += "```\n\n"
context += "## WRONG - Do NOT Create Skills in Subfolders\n\n"
context += "```\n"
context += "skills/\n"
context += (
"├── utilities/ # ❌ WRONG - Don't use category subfolders\n"
)
context += "│ └── my_skill/\n"
context += "├── entertainment/ # ❌ WRONG\n"
context += "│ └── my_skill/\n"
context += "```\n\n"
context += "**Key Rules:**\n"
context += (
"1. Skills go directly in `skills/skill_name/` (no intermediate folders)\n"
)
context += "2. Skill folder name should be lowercase with underscores (e.g., `video_translator_skill`)\n"
context += "3. Always end skill folder name with `_skill` suffix\n"
context += f"4. CRITICAL: ALL source files use {file_extension} - actions, widgets, utilities (bridge={bridge})\n\n"
context += f"## Bridge Consistency - ABSOLUTELY CRITICAL\n\n"
context += f'**VERY IMPORTANT**: When bridge is set to "{bridge}", ALL skill source code MUST be in {language}.\n\n'
context += "**This means:**\n"
context += f"- Actions: {file_extension} ({language})\n"
context += f"- Widgets: {file_extension} ({language})\n"
context += f"- Utilities: {file_extension} ({language})\n"
context += f"- Helper functions: {file_extension} ({language})\n"
context += "- NEVER mix TypeScript and Python in the same skill!\n\n"
context += "**Wrong Example (DO NOT DO THIS):**\n"
context += "```\n"
context += "src/\n"
context += "├── actions/\n"
context += "│ └── my_action.py # ❌ Python\n"
context += "└── widgets/\n"
context += " └── my_widget.ts # ❌ TypeScript - INCONSISTENT!\n"
context += "```\n\n"
context += "**Correct Example:**\n"
context += "```\n"
context += "src/\n"
context += "├── actions/\n"
context += f"│ └── my_action{file_extension} # ✅ {language}\n"
context += "└── widgets/\n"
context += (
f" └── my_widget{file_extension} # ✅ {language} - CONSISTENT!\n"
)
context += "```\n\n"
# Add JSON file schema requirements
context += "# JSON File Schema References - CRITICAL\n\n"
context += "**IMPORTANT**: All JSON configuration files MUST include schema references at the beginning.\n\n"
context += "## Required Schema References\n\n"
context += "### skill.json - COMPLETE STRUCTURE (Based on schemas/skill-schemas/skill.json)\n\n"
context += "**CRITICAL**: Understanding skill.json structure is essential for creating skills correctly.\n\n"
context += "## When to Use Flow vs Direct Actions\n\n"
context += "### Use Direct Actions (No Flow) When:\n"
context += '- **Single-step tasks**: Skill has only one action (e.g., "generate podcast")\n'
context += "- **Independent actions**: Each action is standalone, not part of a sequence\n"
context += "- **Simple skills**: No multi-step workflows needed\n\n"
context += "### Use Flow When:\n"
context += "- **Multi-step workflows**: Actions must be executed in a specific sequence\n"
context += (
"- **Data passing**: One action's output is needed by the next action\n"
)
context += "- **Complex processes**: Like video translation (download → transcribe → translate → synthesize → merge)\n\n"
context += "## skill.json Structure Examples\n\n"
context += "### Example 1: Simple Skill (No Flow) - Single Action\n"
context += (
"Use this when the skill has only one action or independent actions:\n\n"
)
context += "```json\n{\n"
context += ' "$schema": "../../schemas/skill-schemas/skill.json",\n'
context += ' "name": "Podcast Generator",\n'
context += ' "bridge": "nodejs",\n'
context += ' "version": "1.0.0",\n'
context += ' "description": "Generate podcast conversations on any topic.",\n'
context += ' "author": {\n'
context += ' "name": "Your Name",\n'
context += ' "email": "your.email@example.com"\n'
context += " },\n"
context += ' "actions": {\n'
context += ' "generate": {\n'
context += ' "type": "logic",\n'
context += ' "description": "Generate a podcast conversation on any topic with customizable duration.",\n'
context += ' "parameters": {\n'
context += ' "topic": {\n'
context += ' "type": "string",\n'
context += ' "description": "The topic to discuss in the podcast."\n'
context += " },\n"
context += ' "duration": {\n'
context += ' "type": "number",\n'
context += ' "description": "Duration in minutes (1-5)."\n'
context += " }\n"
context += " },\n"
context += ' "optional_parameters": ["duration"]\n'
context += " }\n"
context += " }\n"
context += "}\n```\n\n"
context += "### Example 2: Complex Skill with Flow - Multi-Step Workflow\n"
context += "Use this when actions must execute in sequence and share data:\n\n"
context += "```json\n{\n"
context += ' "$schema": "../../schemas/skill-schemas/skill.json",\n'
context += ' "name": "Video Translator",\n'
context += ' "bridge": "nodejs",\n'
context += ' "version": "1.0.0",\n'
context += (
' "description": "Translate and dub videos into different languages.",\n'
)
context += ' "author": {\n'
context += ' "name": "Your Name",\n'
context += ' "email": "your.email@example.com"\n'
context += " },\n"
context += ' "flow": [\n'
context += ' "download_video",\n'
context += ' "extract_audio",\n'
context += ' "transcribe",\n'
context += ' "translate_transcription",\n'
context += ' "create_new_audio",\n'
context += ' "merge_audio"\n'
context += " ],\n"
context += ' "actions": {\n'
context += ' "download_video": {\n'
context += ' "type": "logic",\n'
context += ' "description": "Download a video from a URL for translation processing.",\n'
context += ' "parameters": {\n'
context += ' "video_url": {\n'
context += ' "type": "string",\n'
context += ' "description": "The URL of the video to download (YouTube, Twitch, etc.)."\n'
context += " },\n"
context += ' "target_language": {\n'
context += ' "type": "string",\n'
context += ' "description": "The target language for translation (e.g., Chinese, Spanish, French)."\n'
context += " },\n"
context += ' "quality": {\n'
context += ' "type": "string",\n'
context += ' "enum": ["worst", "best", "720p", "1080p", "480p"],\n'
context += ' "description": "The video quality to download."\n'
context += " }\n"
context += " },\n"
context += ' "optional_parameters": ["quality"]\n'
context += " },\n"
context += ' "extract_audio": {\n'
context += ' "type": "logic",\n'
context += ' "description": "Extract audio from a downloaded video file for translation processing."\n'
context += " },\n"
context += ' "transcribe": {\n'
context += ' "type": "logic",\n'
context += ' "description": "Transcribe the extracted audio to text with speaker diarization."\n'
context += " },\n"
context += ' "translate_transcription": {\n'
context += ' "type": "logic",\n'
context += ' "description": "Translate transcription from source to target language using LLM."\n'
context += " },\n"
context += ' "create_new_audio": {\n'
context += ' "type": "logic",\n'
context += ' "description": "Generate dubbed audio using voice cloning and translated text."\n'
context += " },\n"
context += ' "merge_audio": {\n'
context += ' "type": "logic",\n'
context += ' "description": "Replace original video audio with the dubbed audio."\n'
context += " }\n"
context += " },\n"
context += ' "action_notes": [\n'
context += ' "The flow automatically passes data between actions using context_data.",\n'
context += ' "Only the first action (download_video) receives direct user parameters."\n'
context += " ]\n"
context += "}\n```\n\n"
context += "## Key Differences\n\n"
context += "### Simple Skill (No Flow):\n"
context += '- Has only `"actions"` object\n'
context += "- Each action can be called independently by the LLM\n"
context += "- LLM matches user intent to action descriptions\n"
context += "- Actions don't depend on each other\n\n"
context += "### Complex Skill (With Flow):\n"
context += '- Has `"flow"` array defining action execution order\n'
context += "- Only the FIRST action in the flow is exposed to the LLM\n"
context += "- Subsequent actions are triggered automatically in sequence\n"
context += "- Data passes between actions via `leon.answer({ 'core': { 'context_data': {...} } })`\n"
context += '- Can reference actions from other skills (e.g., `"music_audio_toolkit_skill:transcribe_audio"`)\n\n'
context += "## Required Fields (Per Schema)\n\n"
context += "**Skill Level (Required):**\n"
context += '- `$schema`: "../../schemas/skill-schemas/skill.json"\n'
context += "- `name`: Skill name (string, min 1 char)\n"
context += '- `bridge`: "nodejs" or "python"\n'
context += '- `version`: Semver string (e.g., "1.0.0")\n'
context += "- `description`: What the skill does (string, min 1 char)\n"
context += (
"- `author`: Object with `name` (required), optional `email` and `url`\n"
)
context += "- `actions`: Object containing action definitions\n\n"
context += "**Optional Skill Fields:**\n"
context += "- `flow`: Array of action names to execute in sequence\n"
context += "- `action_notes`: Array of strings for additional LLM context\n\n"
context += "**Action Fields:**\n"
context += (
'- `type` (required): "logic" (runs code) or "dialog" (just responds)\n'
)
context += "- `description` (required): 16-128 chars, used by LLM to match user intent\n"
context += "- `parameters` (optional): Object defining expected inputs\n"
context += "- `optional_parameters` (optional): Array of parameter names that are optional\n"
context += "- `is_loop` (optional): Boolean for action loops\n\n"
context += "## Parameter Definition Format\n\n"
context += "Parameters support various types:\n\n"
context += "```json\n"
context += '"parameters": {\n'
context += ' "param_name": {\n'
context += ' "type": "string", // or "number"\n'
context += (
' "description": "What this parameter represents (8-128 chars).",\n'
)
context += ' "enum": ["option1", "option2"] // Optional: restrict to specific values\n'
context += " },\n"
context += ' "complex_param": {\n'
context += ' "type": "object",\n'
context += ' "properties": {\n'
context += ' "nested_field": { "type": "string" }\n'
context += " },\n"
context += ' "description": "Object with nested properties."\n'
context += " }\n"
context += "}\n```\n\n"
context += "## Decision Guide: Flow or No Flow?\n\n"
context += "Ask yourself:\n"
context += (
"1. **Does my skill have multiple actions that must run in sequence?**\n"
)
context += " - YES → Use a `flow` array\n"
context += " - NO → Use direct actions only\n\n"
context += "2. **Do my actions need to pass data to each other?**\n"
context += " - YES → Use a `flow` with `context_data`\n"
context += " - NO → Use direct actions\n\n"
context += "3. **Is there a clear step-by-step pipeline?**\n"
context += " - YES → Use a `flow`\n"
context += " - NO → Use direct actions\n\n"
context += "## CRITICAL: Toolkit Skills - Reusable Actions Across Skills\n\n"
context += "**IMPORTANT**: Some skills are designed as **toolkit skills** - their actions can be reused by other skills!\n\n"
context += "### What Are Toolkit Skills?\n\n"
context += "Toolkit skills are special skills whose primary purpose is to provide **reusable actions** that other skills can call.\n"
context += "They typically end with `_toolkit_skill` in their name.\n\n"
context += "**Existing Toolkit Skills:**\n"
context += "- `music_audio_toolkit_skill`: Provides actions like `transcribe_audio`, `detect_language`, etc.\n"
context += "- `search_web_toolkit_skill`: Provides `search` action for web/X research\n"
context += "- More toolkit skills may exist in the skills directory\n\n"
context += "### How to Use Toolkit Skills in Flows\n\n"
context += '**Format**: `"skill_name:action_name"`\n\n'
context += "**Example: Using music_audio_toolkit_skill in a flow**\n"
context += "```json\n"
context += "{\n"
context += ' "flow": [\n'
context += ' "download_video",\n'
context += ' "extract_audio",\n'
context += ' "music_audio_toolkit_skill:transcribe_audio",\n'
context += ' "translate_transcription"\n'
context += " ],\n"
context += ' "actions": {\n'
context += ' "download_video": { "type": "logic", "description": "..." },\n'
context += ' "extract_audio": { "type": "logic", "description": "..." },\n'
context += (
" // No need to define transcribe_audio - it comes from the toolkit!\n"
)
context += (
' "translate_transcription": { "type": "logic", "description": "..." }\n'
)
context += " }\n"
context += "}\n"
context += "```\n\n"
context += "### When to Use Toolkit Skills\n\n"
context += "**USE toolkit skill actions when:**\n"
context += (
"- ✅ The functionality already exists (transcription, search, etc.)\n"
)
context += "- ✅ You want consistent behavior across multiple skills\n"
context += "- ✅ You want to avoid code duplication\n\n"
context += "**CREATE your own action when:**\n"
context += "- ✅ You need custom logic specific to your skill\n"
context += "- ✅ No toolkit skill provides the needed functionality\n\n"
context += "### Finding Available Toolkit Actions\n\n"
context += (
"**IMPORTANT**: Before creating a skill, check existing toolkit skills:\n"
)
context += "1. Read `skills/*_toolkit_skill/skill.json` files\n"
context += "2. Check their README.md for usage examples\n"
context += "3. Look at their `actions` object for available actions\n\n"
context += "## Best Practices\n\n"
context += (
"1. **Start simple**: If you only need one action, don't use a flow\n"
)
context += "2. **Check toolkit skills FIRST**: Don't reinvent the wheel - use existing toolkit actions\n"
context += "3. **Use flows for pipelines**: Video processing, translation, multi-step tasks\n"
context += "4. **Descriptive action descriptions**: LLM uses them to match user intent (16-128 chars)\n"
context += "5. **Descriptive action names**: Use verbs (download_video, transcribe, translate)\n"
context += "6. **First action gets parameters**: Only the first action in a flow receives user parameters\n"
context += "7. **Use context_data**: Pass data between flow actions via `leon.answer({ 'core': { 'context_data': {...} } })`\n"
context += (
"8. **Schema validation**: Always include `$schema` reference at the top\n"
)
context += '9. **Cross-skill format**: Use `"skill_name:action_name"` for toolkit actions in flows\n'
context += "10. **Read toolkit READMEs**: They contain usage examples and parameter requirements\n\n"
context += "### locales/en.json - CRITICAL STRUCTURE\n"
context += "**VERY IMPORTANT**: The locale file has a specific structure with top-level properties.\n"
context += "DO NOT put action names directly at the root level!\n\n"
context += "```json\n"
context += "{\n"
context += (
' "$schema": "../../../schemas/skill-schemas/skill-locale-config.json",\n'
)
context += ' "actions": {\n'
context += ' "action_name_1": {\n'
context += ' "missing_param_follow_ups": {\n'
context += (
' "param_name": ["Follow up question 1", "Follow up question 2"]\n'
)
context += " },\n"
context += ' "answers": {\n'
context += (
' "answer_key": ["Answer variation 1", "Answer variation 2"]\n'
)
context += " }\n"
context += " },\n"
context += ' "action_name_2": {\n'
context += " // Same structure\n"
context += " }\n"
context += " },\n"
context += ' "common_answers": {\n'
context += ' "common_key": ["Shared answer 1", "Shared answer 2"]\n'
context += " },\n"
context += ' "variables": {\n'
context += ' "var_name": "value"\n'
context += " },\n"
context += ' "widget_contents": {\n'
context += ' "widget_key": "Widget content"\n'
context += " }\n"
context += "}\n"
context += "```\n\n"
context += "**Locale File Structure Rules:**\n"
context += "1. Must have `$schema` reference at the top\n"
context += (
"2. Must have `actions` object containing all action configurations\n"
)
context += "3. Can have optional `common_answers` for shared responses\n"
context += "4. Can have optional `variables` for reusable values\n"
context += "5. Can have optional `widget_contents` for widget text\n"
context += "6. Each action inside `actions` has `missing_param_follow_ups` and `answers`\n\n"
# Add settings files documentation
context += "# Skill Settings Files - REQUIRED\n\n"
context += "**CRITICAL**: Every skill MUST have both settings files, even if empty.\n\n"
context += "## Required Files\n\n"
context += "1. **src/settings.sample.json** - Sample configuration template\n"
context += "2. **src/settings.json** - Actual configuration (initially identical to sample)\n\n"
context += "Both files must be **identical** when created. Users will modify settings.json with their values.\n\n"
context += "## Settings File Patterns\n\n"
context += "### Pattern 1: No Configuration Needed\n\n"
context += "If the skill doesn't need any API keys or configuration:\n\n"
context += "```json\n"
context += "{}\n"
context += "```\n\n"
context += "### Pattern 2: API Keys and Configuration\n\n"
context += (
"If the skill needs API keys, provider selection, or other settings:\n\n"
)
context += "```json\n"
context += "{\n"
context += ' "provider_api_key": "sk-...",\n'
context += ' "provider_model": "model-name",\n'
context += ' "max_tokens": 2000,\n'
context += ' "temperature": 0.7\n'
context += "}\n"
context += "```\n\n"
context += "## Real Examples\n\n"
context += "### Example 1: Simple Skill (No Settings)\n"
context += "```json\n"
context += "// src/settings.sample.json and src/settings.json\n"
context += "{}\n"
context += "```\n\n"
context += "### Example 2: Skill with API Configuration\n"
context += "```json\n"
context += "// src/settings.sample.json and src/settings.json\n"
context += "{\n"
context += ' "translation_openrouter_api_key": "",\n'
context += (
' "translation_openrouter_model": "google/gemini-3-flash-preview",\n'
)
context += ' "translation_max_tokens_per_request": 2000,\n'
context += ' "translation_segments_per_batch": 10,\n'
context += ' "speech_synthesis_provider": "chatterbox_onnx"\n'
context += "}\n"
context += "```\n\n"
context += "## How to Use Settings in Actions\n\n"
if bridge == "nodejs":
context += "```typescript\n"
context += "import { Settings } from '@sdk/settings'\n"
context += "import ToolManager, { isMissingToolSettingsError } from '@sdk/tool-manager'\n"
context += "import OpenRouterTool from '@tools/communication/openrouter'\n\n"
context += "interface MySkillSettings extends Record<string, unknown> {\n"
context += " provider_model?: string\n"
context += " max_tokens?: number\n"
context += "}\n\n"
context += "export const run: ActionFunction = async function (params, paramsHelper) {\n"
context += " const settings = new Settings<MySkillSettings>()\n"
context += " const model = (await settings.get('provider_model')) || 'default-model'\n"
context += (
" const maxTokens = (await settings.get('max_tokens')) || 1000\n\n"
)
context += " let tool: OpenRouterTool\n"
context += " try {\n"
context += " tool = await ToolManager.initTool(OpenRouterTool)\n"
context += " } catch (error) {\n"
context += " if (isMissingToolSettingsError(error)) return\n"
context += " throw error\n"
context += " }\n\n"
context += " // Use tool + settings...\n"
context += "}\n"
context += "```\n\n"
else:
context += "```python\n"
context += "from bridges.python.src.sdk.tool_manager import ToolManager, is_missing_tool_settings_error\n"
context += "from bridges.python.src.sdk.types import ActionParams\n"
context += "from bridges.python.src.sdk.settings import Settings\n"
context += "from tools.communication.openrouter import OpenRouterTool\n\n"
context += (
"def run(params: ActionParams, params_helper: ParamsHelper) -> None:\n"
)
context += " settings = Settings()\n"
context += " model = settings.get('provider_model') or 'default-model'\n"
context += " max_tokens = settings.get('max_tokens') or 1000\n\n"
context += " try:\n"
context += " tool = ToolManager.init_tool(OpenRouterTool)\n"
context += " except Exception as error:\n"
context += " if is_missing_tool_settings_error(error):\n"
context += " return\n"
context += " raise\n\n"
context += " # Use tool + settings...\n"
context += " # On errors, include: core: { 'should_stop_skill': True }\n"
context += "```\n\n"
context += "## Settings Best Practices\n\n"
context += "1. **Always create both files**: settings.sample.json AND settings.json (identical initially)\n"
context += "2. **Use descriptive keys**: `translation_api_key` not `key1`\n"
context += "3. **Provide placeholder values**: Show the format. But set null for API keys or credentials\n"
context += "4. **Include defaults**: For non-sensitive settings (model names, timeouts, etc.)\n"
context += "5. **Document in README**: Explain what each setting does\n"
context += "6. **Validate in action**: Check if required settings exist before using them\n"
context += "7. **Use empty object if no settings**: Don't skip the files, create `{}`\n\n"
# Add CRITICAL planning section
context += (
"# CRITICAL: Planning and Understanding Tools BEFORE Writing Code\n\n"
)
context += "**EXTREMELY IMPORTANT**: You MUST follow this workflow before writing ANY code:\n\n"
context += "## Step 1: Identify Required Tools\n\n"
context += "Before writing code, analyze what tools you'll need:\n"
context += "1. **Review the available tools list above** - Check if tools already exist\n"
context += "2. **Match your needs to existing tools** - Don't duplicate functionality\n"
context += "3. **List the tools you plan to use** - Be specific (e.g., FfmpegTool, ChatterboxOnnxTool)\n\n"
context += "## Step 2: Read and Understand Tool Implementations\n\n"
context += "**CRITICAL**: You MUST read the actual source code of tools before using them!\n\n"
file_ext = ".ts" if bridge == "nodejs" else ".py"
bridge_path = "nodejs" if bridge == "nodejs" else "python"
context += f"For EACH tool you plan to use:\n"
context += f"1. **Read the tool file** at `bridges/{bridge_path}/src/sdk/tools/{{tool-name}}-tool{file_ext}`\n"
context += (
"2. **Understand ALL available methods** - Don't assume, READ the code\n"
)
context += "3. **Check for batch/efficient operations** - Many tools support batch processing!\n"
context += "4. **Note the method signatures** - Parameter names, types, return values\n"
context += "5. **Look for special features** - Async operations, streaming, callbacks, etc.\n\n"
context += "## Step 3: Plan for Efficiency\n\n"
context += "**CRITICAL EXAMPLES OF EFFICIENT PATTERNS:**\n\n"
context += "### Example: ChatterboxOnnxTool - Batch Processing\n\n"
context += "❌ **WRONG** - Multiple separate calls (SLOW):\n"
if bridge == "nodejs":
context += "```typescript\n"
context += "// DON'T DO THIS - Inefficient!\n"
context += "for (const segment of segments) {\n"
context += " await chatterbox.synthesizeSpeechToFiles({\n"
context += " text: segment.text,\n"
context += " audio_path: segment.path\n"
context += " })\n"
context += "}\n"
context += "```\n\n"
context += "✅ **CORRECT** - Single batch call (FAST):\n"
context += "```typescript\n"
context += "// DO THIS - Read the tool to discover it accepts an array!\n"
context += "const tasks = segments.map(segment => ({\n"
context += " text: segment.text,\n"
context += " audio_path: segment.path,\n"
context += " voice_name: segment.voice\n"
context += "}))\n\n"
context += "// Single call processes all segments efficiently\n"
context += "await chatterbox.synthesizeSpeechToFiles(tasks)\n"
context += "```\n\n"
else:
context += "```python\n"
context += "# DON'T DO THIS - Inefficient!\n"
context += "for segment in segments:\n"
context += " chatterbox.synthesize_speech_to_files({\n"
context += " 'text': segment['text'],\n"
context += " 'audio_path': segment['path']\n"
context += " })\n"
context += "```\n\n"
context += "✅ **CORRECT** - Single batch call (FAST):\n"
context += "```python\n"
context += "# DO THIS - Read the tool to discover it accepts a list!\n"
context += "tasks = [{\n"
context += " 'text': segment['text'],\n"
context += " 'audio_path': segment['path'],\n"
context += " 'voice_name': segment['voice']\n"
context += "} for segment in segments]\n\n"
context += "# Single call processes all segments efficiently\n"
context += "chatterbox.synthesize_speech_to_files(tasks)\n"
context += "```\n\n"
context += "### Why This Matters:\n\n"
context += "- **Performance**: Batch processing can be 10-100x faster\n"
context += "- **Resource efficiency**: Less overhead, better parallelization\n"
context += "- **Better UX**: User gets results much faster\n\n"
context += "## Step 4: Plan Your Architecture\n\n"
context += "Now that you understand the tools, plan your code:\n"
context += "1. **Outline the workflow** - Step-by-step what needs to happen\n"
context += (
"2. **Identify batch opportunities** - Where can you group operations?\n"
)
context += "3. **Plan data structures** - What format does each tool expect?\n"
context += "4. **Consider error handling** - What if a tool call fails?\n"
context += "5. **Think about progress reporting** - Keep user informed\n\n"
context += "## Step 5: Only THEN Write Code\n\n"
context += (
"After completing steps 1-4, you can write efficient, correct code.\n\n"
)
context += "## If Tools or Methods Are Missing\n\n"
context += "If you've read the tools and found:\n"
context += (
"- **Tool doesn't exist**: Create a new tool (see guidelines below)\n"
)
context += "- **Method is missing**: Add the method to the existing tool (in BOTH TS + Python)\n"
context += "- **Functionality is incomplete**: Extend the tool with new capabilities\n\n"
context += "**REMEMBER**: Always implement in BOTH TypeScript AND Python when creating/extending tools!\n\n"
# Add new tool creation and extension documentation
context += self._get_tool_creation_guidelines(bridge)
# Add Aurora UI components documentation
context += self._scan_aurora_components()
context += "# Understanding leon.answer() - Critical Information\n\n"
context += "The `leon.answer()` method is your primary way to communicate with users and pass data between actions.\n\n"
context += "## Basic Usage\n\n"
if bridge == "nodejs":
context += "```typescript\n"
context += "// Simple text response with localized message key\n"
context += "leon.answer({\n"
context += " key: 'success_message',\n"
context += " data: {\n"
context += " file_name: 'example.mp4',\n"
context += " file_size: '25 MB'\n"
context += " }\n"
context += "})\n"
context += "```\n\n"
else:
context += "```python\n"
context += "# Simple text response with localized message key\n"
context += "leon.answer({\n"
context += " 'key': 'success_message',\n"
context += " 'data': {\n"
context += " 'file_name': 'example.mp4',\n"
context += " 'file_size': '25 MB'\n"
context += " }\n"
context += "})\n"
context += "```\n\n"
context += "## Passing Data to Next Action (context_data)\n\n"
context += "Use `core.context_data` to pass data between actions in a multi-step workflow:\n\n"
if bridge == "nodejs":
context += "```typescript\n"
context += "// Action 1: Download video and pass path to next action\n"
context += "leon.answer({\n"
context += " key: 'download_completed',\n"
context += " data: {\n"
context += " file_path: formatFilePath(videoPath)\n"
context += " },\n"
context += " core: {\n"
context += " context_data: {\n"
context += " video_path: videoPath, // Pass full path\n"
context += (
" target_language: targetLanguage, // Pass other needed data\n"
)
context += " quality: quality\n"
context += " }\n"
context += " }\n"
context += "})\n\n"
context += "// Action 2: Retrieve data from previous action\n"
context += (
"const videoPath = paramsHelper.getContextData<string>('video_path')\n"
)
context += "const targetLanguage = paramsHelper.getContextData<string>('target_language')\n"
context += "```\n\n"
else:
context += "```python\n"
context += "# Action 1: Download video and pass path to next action\n"
context += "leon.answer({\n"
context += " 'key': 'download_completed',\n"
context += " 'data': {\n"
context += " 'file_path': format_file_path(video_path)\n"
context += " },\n"
context += " 'core': {\n"
context += " 'context_data': {\n"
context += " 'video_path': video_path, # Pass full path\n"
context += (
" 'target_language': target_language, # Pass other needed data\n"
)
context += " 'quality': quality\n"
context += " }\n"
context += " }\n"
context += "})\n\n"
context += "# Action 2: Retrieve data from previous action\n"
context += "video_path = params_helper.get_context_data('video_path')\n"
context += (
"target_language = params_helper.get_context_data('target_language')\n"
)
context += "```\n\n"
context += "## Widget Usage\n\n"
context += "**Show**: `leon.answer({ widget: myWidget })` (no key/data!)\n"
context += "**Update**: Use `replaceMessageId` and keep same widget ID\n\n"
context += "## leon.answer() Options\n\n"
context += "- **key**: Localized message key\n"
context += "- **data**: Variables for message (user-visible)\n"
context += "- **widget**: UI component (MUST be alone, no key/data!)\n"
context += "- **core.context_data**: Data for next action\n"
context += "- **core.next_action**: Chain to 'skill:action'\n"
context += "- **replaceMessageId**: Update existing message\n\n"
if context_files:
context += "# Reference Files\n\n"
context += "Please study these example files:\n"
for file in context_files:
context += f"- {file}\n"
context += "\n"
return context
def generate_skill(
self,
description: str,
provider: str,
target_path: str,
model: Optional[str] = None,
api_key: Optional[str] = None,
context_files: Optional[List[str]] = None,
system_prompt: Optional[str] = None,
bridge: str = "nodejs",
) -> Dict[str, Any]:
"""
Generate skill using OpenCode CLI with agentic loop
Args:
description: Description of the skill to generate
provider: LLM provider to use
target_path: Target directory for generated skill
model: Model name (uses default if not specified)
api_key: API key for the provider
context_files: List of files for OpenCode to learn from
system_prompt: System prompt for the LLM
Returns:
Dict with result or error
"""
# Get provider configuration
provider_data = self.providers.get(provider)
# If not configured, configure with provided API key
if not provider_data and api_key:
provider_config = self.provider_configs[provider]
model_to_use = model or provider_config["default_model"]
self.configure_provider(provider, api_key, model_to_use)
provider_data = self.providers.get(provider)
# Setup OpenCode auth
self._setup_provider_auth(provider, api_key)
if not provider_data or not provider_data.get("api_key"):
return {
"success": False,
"error": f"Provider '{provider}' is not configured. Please provide an API key.",
}
model_to_use = provider_data.get("model")
# Build the OpenCode prompt with Leon-specific context
leon_context = self._build_leon_context(
description, system_prompt, context_files or [], bridge
)
full_prompt = f"{leon_context}\n\n{description}"
# Create temporary prompt file
with tempfile.NamedTemporaryFile(
mode="w", suffix=".txt", delete=False, prefix="opencode-leon-"
) as tmp:
tmp.write(full_prompt)
prompt_file = tmp.name
os.makedirs(target_path, exist_ok=True)
try:
skills_dir = Path(target_path) / "skills"
existing_skills = self._get_existing_skills(skills_dir)
args = ["run", description]
if model_to_use:
args.extend(["--model", model_to_use])
args.extend(["--file", prompt_file])
result = self.execute_command(
ExecuteCommandOptions(
binary_name="opencode",
args=args,
options={
"sync": True,
"cwd": target_path,
"timeout": 600_000,
"open_in_terminal": True,
},
)
)
files_created = self._get_created_files(skills_dir, existing_skills)
return {
"success": True,
"output": result
or f"OpenCode launched in a new terminal. Prompt: {prompt_file}",
"provider_used": provider,
"model_used": model_to_use,
"files_created": files_created,
}
except Exception as e:
return {"success": False, "error": f"OpenCode generation error: {str(e)}"}
def _get_existing_skills(self, skills_dir: Path) -> set:
"""Get set of existing skill folder names"""
existing = set()
try:
if skills_dir.exists():
for entry in skills_dir.iterdir():
if entry.is_dir() and entry.name.endswith("_skill"):
existing.add(entry.name)
except Exception:
pass
return existing
def _get_created_files(self, skills_dir: Path, existing_skills: set) -> List[str]:
"""Get list of newly created files in new skill folders"""
created_files = []
try:
if skills_dir.exists():
for entry in skills_dir.iterdir():
if (
entry.is_dir()
and entry.name.endswith("_skill")
and entry.name not in existing_skills
):
all_files = self._get_all_files_recursive(entry)
created_files.extend(
[str(f.relative_to(Path.cwd())) for f in all_files]
)
except Exception:
pass
return created_files
def _get_all_files_recursive(self, dir_path: Path) -> List[Path]:
"""Recursively get all files in a directory"""
files = []
try:
for entry in dir_path.iterdir():
if entry.is_dir():
files.extend(self._get_all_files_recursive(entry))
else:
files.append(entry)
except Exception:
pass
return files