clawgarden-agent 0.22.0

Agent runtime with persona/memory loader, judge, and pi RPC for ClawGarden
Documentation
//! SkillForge — Create skill files and update registry from natural language cron requests.
//!
//! When a cron request needs a tool that doesn't exist, SkillForge creates it.
//! Tools are written to workspace/tools/{name}.ts and registered in registry.json.

use std::path::Path;
use anyhow::{Context, Result};
use tokio::fs;

/// Tool template content for each action type.
pub const TEMPLATE_HACKERNEWS: &str = r#"#!/usr/bin/env node
/**
 * HackerNews top stories scraper
 * Usage: node hackernews-scrape.ts [--limit 10]
 */
const HN_API = 'https://hacker-news.firebaseio.com/v0/topstories.json';

async function main() {
  const args = process.argv.slice(2);
  const limit = parseInt(args.find(a => a === '--limit') ? args[args.indexOf('--limit') + 1] : '10');
  
  const ids = await fetch(HN_API).then(r => r.json());
  const top = ids.slice(0, limit);
  
  const stories = await Promise.all(top.map(async (id) => {
    const s = await fetch(`${HN_API.replace('topstories', `item/${id}`)}`).then(r => r.json()).catch(() => null);
    if (!s) return null;
    return { title: s.title, url: s.url || `https://news.ycombinator.com/item?id=${id}`, score: s.score, by: s.by, comments: s.descendants || 0 };
  }));
  
  const valid = stories.filter(Boolean);
  console.log(JSON.stringify({ stories: valid, count: valid.length }));
}

main().catch(e => { console.error(JSON.stringify({error: e.message})); process.exit(1); });
"#;

pub const TEMPLATE_TRANSLATE: &str = r#"#!/usr/bin/env node
/**
 * Translation tool — uses LLM API to translate text.
 * Input: PREV_STEP_OUTPUT env var or --text argument.
 * Output: Translated text to stdout.
 */
const API_URL = process.env.LLM_API_URL || 'http://localhost:8080/v1/chat/completions';
const API_KEY = process.env.LLM_API_KEY || '';
const MODEL = process.env.LLM_MODEL || 'glm-4-air';

async function translate(text, fromLang = 'en', toLang = 'ko') {
  if (!text || text.trim() === '') {
    console.log(JSON.stringify({ error: 'No input text' }));
    return;
  }
  
  const body = {
    model: MODEL,
    messages: [{
      role: 'user',
      content: `Translate the following from ${fromLang} to ${toLang}. Keep the format. Just translate:\n\n${text.slice(0, 4000)}`
    }],
    max_tokens: 4000,
    temperature: 0.3,
  };
  
  try {
    const res = await fetch(API_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', ...(API_KEY ? { 'Authorization': `Bearer ${API_KEY}` } : {}) },
      body: JSON.stringify(body),
    });
    const data = await res.json();
    const translated = data.choices?.[0]?.message?.content || data.error?.message || '';
    console.log(translated);
  } catch(e) {
    console.error(JSON.stringify({ error: e.message }));
    process.exit(1);
  }
}

const text = process.env.PREV_STEP_OUTPUT 
  || (process.argv.find(a => a === '--text') ? process.argv[process.argv.indexOf('--text') + 1] : '');
const from = process.argv.find(a => a === '--from') ? process.argv[process.argv.indexOf('--from') + 1] : 'en';
const to = process.argv.find(a => a === '--to') ? process.argv[process.argv.indexOf('--to') + 1] : 'ko';

translate(text, from, to);
"#;

pub const TEMPLATE_EMAIL: &str = r#"#!/usr/bin/env node
/**
 * Email sender via HTTP API (Resend, SendGrid, etc.)
 * Usage: node send-email.ts --to <email> --subject <subject>
 * Input: PREV_STEP_OUTPUT env var contains the email body.
 */
const API_KEY = process.env.EMAIL_API_KEY || process.env.RESEND_API_KEY || '';
const FROM = process.env.EMAIL_FROM || 'cron@garden.local';
const API_URL = process.env.EMAIL_API_URL || '';

async function send(to, subject, body) {
  if (!API_URL || !API_KEY) {
    console.log(JSON.stringify({ status: 'noop', reason: 'No email API configured. Set EMAIL_API_URL and EMAIL_API_KEY.' }));
    return;
  }
  
  const payload = { from: FROM, to, subject, html: `<pre>${body}</pre>` };
  
  const res = await fetch(API_URL, {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
  });
  
  const data = await res.json();
  console.log(JSON.stringify(data));
}

const to = process.argv.find(a => a === '--to') ? process.argv[process.argv.indexOf('--to') + 1] : '';
const subject = process.argv.find(a => a === '--subject') ? process.argv[process.argv.indexOf('--subject') + 1] : 'ClawGarden Cron Report';
const body = process.env.PREV_STEP_OUTPUT || '';

send(to, subject, body).catch(e => { console.error(e.message); process.exit(1); });
"#;

pub const TEMPLATE_WRITE_FILE: &str = r#"#!/usr/bin/env node
/**
 * Write content to a file (Obsidian vault, logs, etc.)
 * Usage: node write-file.ts --path <path>
 * Input: PREV_STEP_OUTPUT env var contains the file content.
 */
const fs = require('fs');
const path = require('path');

const filePath = process.argv.find(a => a === '--path') 
  ? process.argv[process.argv.indexOf('--path') + 1] 
  : process.env.FILE_PATH || '';
const content = process.env.PREV_STEP_OUTPUT 
  || (process.argv.find(a => a === '--content') ? process.argv[process.argv.indexOf('--content') + 1] : '');

if (!filePath) {
  console.error(JSON.stringify({ error: 'No path specified. Use --path or FILE_PATH env var.' }));
  process.exit(1);
}

const dir = path.dirname(filePath);
if (dir && dir !== '.') {
  fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(filePath, content, 'utf8');
console.log(JSON.stringify({ written: filePath, bytes: content.length }));
"#;

pub const TEMPLATE_GITHUB: &str = r#"#!/usr/bin/env node
/**
 * GitHub API: fetch trending repositories or user's starred repos.
 * Usage: node github-api.ts --endpoint trending [--language javascript]
 */
const API_KEY = process.env.GITHUB_TOKEN || '';
const BASE = 'https://api.github.com';

async function gh(endpoint, params = {}) {
  const qs = Object.entries(params).map(([k,v]) => `${k}=${encodeURIComponent(v)}`).join('&');
  const url = `${BASE}${endpoint}${qs ? '?' + qs : ''}`;
  
  const headers = { 'Accept': 'application/vnd.github.v3+json' };
  if (API_KEY) headers['Authorization'] = `token ${API_KEY}`;
  
  const res = await fetch(url, { headers });
  if (!res.ok) throw new Error(`GitHub API ${res.status}: ${await res.text()}`);
  return res.json();
}

async function main() {
  const args = process.argv.slice(2);
  const endpoint = args.find(a => a === '--endpoint') ? args[args.indexOf('--endpoint') + 1] : 'trending';
  const lang = args.find(a => a === '--language') ? args[args.indexOf('--language') + 1] : '';
  
  let data;
  if (endpoint === 'trending') {
    // GitHub doesn't have a native trending API — use search instead
    const since = 'daily';
    const q = lang ? `language:${lang} created:>${since}` : `created:>${since}`;
    data = await gh('/search/repositories', { q, sort: 'stars', order: 'desc', per_page: '10' });
    data = { repos: (data.items || []).map(r => ({ name: r.full_name, desc: r.description, stars: r.stargazers_count, url: r.html_url, lang: r.language })) };
  } else if (endpoint === 'user') {
    const user = args.find(a => a === '--user') ? args[args.indexOf('--user') + 1] : '';
    data = await gh(`/users/${user}/starred`);
    data = { repos: data.map(r => ({ name: r.full_name, desc: r.description, stars: r.stargazers_count })) };
  } else {
    data = { error: `Unknown endpoint: ${endpoint}` };
  }
  
  console.log(JSON.stringify(data));
}

main().catch(e => { console.error(JSON.stringify({ error: e.message })); process.exit(1); });
"#;

pub const TEMPLATE_SCRAPE: &str = r#"#!/usr/bin/env node
/**
 * Generic HTTP fetch + HTML scraper.
 * Usage: node scrape.ts --url <url> [--selector .title] [--json]
 */
async function main() {
  const args = process.argv.slice(2);
  const url = args.find(a => a === '--url') ? args[args.indexOf('--url') + 1] : '';
  const selector = args.find(a => a === '--selector') ? args[args.indexOf('--selector') + 1] : '';
  
  if (!url) {
    console.error(JSON.stringify({ error: 'No URL specified. Use --url <url>' }));
    process.exit(1);
  }
  
  const res = await fetch(url);
  const html = await res.text();
  
  if (args.includes('--json')) {
    // Try to parse as JSON
    try {
      const json = JSON.parse(html);
      console.log(JSON.stringify(json));
    } catch {
      console.log(JSON.stringify({ error: 'Not JSON', status: res.status, length: html.length }));
    }
  } else {
    // Return raw text or selected portion
    if (selector) {
      // Simple extraction: look for text between tags
      const match = html.match(new RegExp(`<[^>]*${selector}[^>]*>([^<]+)</`, 'i'));
      const text = match ? match[1].trim() : html.slice(0, 2000);
      console.log(JSON.stringify({ url, extracted: text }));
    } else {
      console.log(JSON.stringify({ url, status: res.status, preview: html.slice(0, 500) }));
    }
  }
}

main().catch(e => { console.error(JSON.stringify({ error: e.message })); process.exit(1); });
"#;

/// Map action keywords to tool templates.
pub fn template_for_action(action: &str) -> (&'static str, &'static str, &'static str) {
    match action.to_lowercase().as_str() {
        "hackernews" | "hn" | "news" | "scrape" => (
            "hackernews-scrape",
            TEMPLATE_HACKERNEWS,
            "Fetch top stories from Hacker News API",
        ),
        "translate" | "번역" => (
            "translate-ko",
            TEMPLATE_TRANSLATE,
            "Translate text using LLM API",
        ),
        "email" | "mail" | "이메일" | "메일" => (
            "send-email",
            TEMPLATE_EMAIL,
            "Send email via HTTP API (Resend, etc.)",
        ),
        "write_file" | "obsidian" | "save" | "노트" | "기록" => (
            "write-file",
            TEMPLATE_WRITE_FILE,
            "Write content to a file path",
        ),
        "github" | "trending" | "repo" | "레포" | "깃허브" => (
            "github-api",
            TEMPLATE_GITHUB,
            "GitHub API: trending repos, starred repos",
        ),
        "fetch" | "http" | "web" => (
            "scrape",
            TEMPLATE_SCRAPE,
            "Generic HTTP fetch and HTML scraper",
        ),
        _ => (
            "custom-tool",
            r#"#!/usr/bin/env node
console.log(process.env.PREV_STEP_OUTPUT || '');
"#,
            "Custom tool",
        ),
    }
}

/// Create a tool file in workspace/tools/ and update registry.json.
/// Returns the tool name if created or already exists.
pub async fn ensure_tool(
    workspace_dir: &Path,
    action: &str,
    _params: &str,
) -> Result<String> {
    let (tool_name, content, description) = template_for_action(action);
    let tool_path = workspace_dir.join("tools").join(format!("{}.ts", tool_name));

    // Create tools dir
    fs::create_dir_all(workspace_dir.join("tools")).await.context("create tools dir")?;

    // Write tool if not exists
    if !tool_path.exists() {
        fs::write(&tool_path, content).await.context("write tool file")?;

        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let perms = fs::metadata(&tool_path).await?.permissions();
            let mut p = perms;
            p.set_mode(0o755);
            fs::set_permissions(&tool_path, p).await?;
        }
    }

    // Update registry.json
    update_registry(workspace_dir, tool_name, description).await?;

    Ok(tool_name.to_string())
}

/// Ensure all tools needed by a set of step specs.
pub async fn ensure_tools(
    workspace_dir: &Path,
    steps: &[crate::cron_nlp::parser::StepSpec],
) -> Result<Vec<String>> {
    let mut tool_names = Vec::new();
    for step in steps {
        let name = ensure_tool(workspace_dir, &step.action, &step.params).await?;
        tool_names.push(name);
    }
    Ok(tool_names)
}

/// Update workspace/skills/registry.json with a new tool entry.
async fn update_registry(workspace_dir: &Path, tool_name: &str, description: &str) -> Result<()> {
    let registry_path = workspace_dir.join("skills").join("registry.json");

    // Read existing registry
    let content = match fs::read_to_string(&registry_path).await {
        Ok(c) => c,
        Err(_) => r#"{"version":1,"skills":[]}"#.to_string(),
    };

    let mut registry: serde_json::Value = serde_json::from_str(&content)
        .unwrap_or_else(|_| serde_json::json!({"version": 1, "skills": [] }));

    // Check for duplicates
    if let Some(skills) = registry.get_mut("skills").and_then(|s| s.as_array_mut()) {
        if skills.iter().any(|s| {
            s.get("name").and_then(|n| n.as_str()) == Some(tool_name)
        }) {
            return Ok(()); // already registered
        }

        skills.push(serde_json::json!({
            "name": tool_name,
            "version": "1",
            "description": description,
            "runtime": "shell",
            "script_path": format!("tools/{}.ts", tool_name),
            "side_effect_level": "read_only",
            "timeout_ms": 30000,
            "requires_auth": false,
            "visibility": ["global"],
            "tags": ["cron", "workflow", "auto-generated"]
        }));
    }

    let new_content = serde_json::to_string_pretty(&registry)
        .context("serialize registry")?;
    fs::write(&registry_path, new_content)
        .await
        .context("write registry")?;

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_template_for_action() {
        let (name, _, desc) = template_for_action("hackernews");
        assert_eq!(name, "hackernews-scrape");
        assert!(desc.contains("Hacker News"));

        let (name2, _, _) = template_for_action("email");
        assert_eq!(name2, "send-email");

        let (name3, _, _) = template_for_action("translate");
        assert_eq!(name3, "translate-ko");
    }
}