mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-03 15:53:56 +00:00
refactor: rename openclaw -> clawhubadapter, add ClawHub search/install, fix agent interrupt
- Rename internal/plugins/openclaw/ -> internal/plugins/clawhubadapter/ - Add RegistryDispatcher with 5 sub-registries (Tool, Provider, Channel, Stage, Cap) - Add clawhubadapter_search tool for ClawHub marketplace search - Add clawhub: prefix support for installing from ClawHub (ZIP/tgz auto-detect) - Add CallProvider RPC and provider/call routing - Fix QQ interrupt: check interceptCh before/after each tool execution
This commit is contained in:
361
internal/plugins/clawhubadapter/manager/main.js
Normal file
361
internal/plugins/clawhubadapter/manager/main.js
Normal file
@ -0,0 +1,361 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
// ---- Utility ----
|
||||
function writeJSON(obj) {
|
||||
process.stdout.write(JSON.stringify(obj) + '\n');
|
||||
}
|
||||
function sendError(id, code, message) {
|
||||
writeJSON({ jsonrpc: '2.0', id, error: { code, message } });
|
||||
}
|
||||
function notify(method, params) {
|
||||
writeJSON({ jsonrpc: '2.0', method, params });
|
||||
}
|
||||
function readJSON(file) {
|
||||
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) { return null; }
|
||||
}
|
||||
|
||||
// ---- Plugin registry ----
|
||||
const loadedPlugins = {}; // name -> { entry, tools: [{name, execute, ...}] }
|
||||
const allTools = []; // flat list of all tools across all plugins
|
||||
const allProviders = {}; // type -> { name, instance } across all plugins
|
||||
|
||||
function registerPluginTools(name, tools, api) {
|
||||
for (const t of tools) {
|
||||
if (t && t.name) {
|
||||
t._plugin = name;
|
||||
allTools.push(t);
|
||||
notify('register', { type: 'tool', data: { name: t.name, description: t.description, parameters: t.parameters } });
|
||||
}
|
||||
}
|
||||
loadedPlugins[name] = { tools, api };
|
||||
}
|
||||
|
||||
function loadPlugin(pluginDir, name) {
|
||||
// Resolve entry
|
||||
const pkg = readJSON(path.join(pluginDir, 'package.json'));
|
||||
let entryPath = null;
|
||||
|
||||
if (pkg && pkg.openclaw) {
|
||||
let raw = pkg.openclaw.runtimeExtensions || pkg.openclaw.extensions;
|
||||
if (typeof raw === 'string') raw = [raw];
|
||||
if (Array.isArray(raw)) {
|
||||
for (const ext of raw) {
|
||||
let ep = path.resolve(pluginDir, ext);
|
||||
if (ep.endsWith('.ts')) { const js = ep.replace(/\.ts$/, '.js'); if (fs.existsSync(js)) { entryPath = js; break; } }
|
||||
if (fs.existsSync(ep)) { entryPath = ep; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!entryPath) {
|
||||
const manifest = readJSON(path.join(pluginDir, 'openclaw.plugin.json'));
|
||||
if (manifest) {
|
||||
const ep = manifest.entry || manifest.main || 'index.js';
|
||||
entryPath = path.join(pluginDir, ep);
|
||||
}
|
||||
}
|
||||
|
||||
if (!entryPath) entryPath = path.join(pluginDir, 'index.js');
|
||||
if (!fs.existsSync(entryPath)) {
|
||||
process.stderr.write(`[manager] entry not found for ${name}: ${entryPath}\n`);
|
||||
return false;
|
||||
}
|
||||
|
||||
let pluginEntry;
|
||||
try { pluginEntry = require(entryPath); } catch (e) {
|
||||
process.stderr.write(`[manager] load ${name}: ${e.message}\n`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const entry = pluginEntry.default || pluginEntry;
|
||||
if (typeof entry !== 'object' || typeof entry.register !== 'function') {
|
||||
process.stderr.write(`[manager] ${name}: entry must export {register(api)}\n`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const registeredTools = [];
|
||||
const api = {
|
||||
id: name,
|
||||
name,
|
||||
version: (pkg && pkg.version) || '1.0.0',
|
||||
description: (pkg && pkg.description) || '',
|
||||
source: pluginDir,
|
||||
rootDir: pluginDir,
|
||||
config: {},
|
||||
pluginConfig: {},
|
||||
registrationMode: 'full',
|
||||
logger: { debug: () => {}, info: () => {}, warn: () => {}, error: (...args) => process.stderr.write(`[${name}] ${args.join(' ')}\n`) },
|
||||
resolvePath: (p) => path.resolve(pluginDir, p),
|
||||
registerTool: (def, opts) => {
|
||||
if (typeof def === 'function') {
|
||||
const toolCtx = { id: name, cwd: pluginDir, env: process.env, allow: ['*'] };
|
||||
const result = def(toolCtx);
|
||||
const tools = Array.isArray(result) ? result : [result];
|
||||
for (const t of tools) { if (t && typeof t.execute === 'function') registeredTools.push(t); }
|
||||
return;
|
||||
}
|
||||
if (!def || !def.name) return;
|
||||
registeredTools.push({ name: def.name, label: def.label || def.name, description: def.description || '', parameters: def.parameters || { type: 'object', properties: {} }, execute: typeof def.execute === 'function' ? def.execute : undefined });
|
||||
},
|
||||
registerProvider: (p) => {
|
||||
if (p && p.id) allProviders['llm'] = { name: p.id, instance: p };
|
||||
notify('register', { type: 'provider', data: { name: p?.id || p?.name } });
|
||||
},
|
||||
registerChannel: (ch) => notify('register', { type: 'channel', data: { name: ch.name, type: ch.type } }),
|
||||
registerHook: (hook) => notify('register', { type: 'hook', data: { name: hook.name, event: hook.event } }),
|
||||
registerHttpRoute: (route) => notify('register', { type: 'http_route', data: { path: route.path, method: route.method } }),
|
||||
registerCommand: (cmd) => notify('register', { type: 'command', data: { name: cmd.name, description: cmd.description } }),
|
||||
registerService: (svc) => notify('register', { type: 'service', data: { name: svc.name } }),
|
||||
registerImageGenerationProvider: (p) => {
|
||||
if (p) allProviders['image_generation'] = { name: p.name, instance: p };
|
||||
notify('register', { type: 'image_generation_provider', data: { name: p?.name } });
|
||||
},
|
||||
registerWebFetchProvider: (p) => {
|
||||
if (p) allProviders['web_fetch'] = { name: p.name, instance: p };
|
||||
notify('register', { type: 'web_fetch_provider', data: { name: p?.name } });
|
||||
},
|
||||
registerWebSearchProvider: (p) => {
|
||||
if (p) allProviders['web_search'] = { name: p.name, instance: p };
|
||||
notify('register', { type: 'web_search_provider', data: { name: p?.name } });
|
||||
},
|
||||
start: (cb) => {},
|
||||
stop: (cb) => {},
|
||||
};
|
||||
|
||||
entry.register(api);
|
||||
registerPluginTools(name, registeredTools, api);
|
||||
process.stderr.write(`[manager] loaded plugin: ${name} (${registeredTools.length} tools)\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- Install npm package ----
|
||||
function installNPMPackage(spec, skillsDir) {
|
||||
process.stderr.write(`[manager] installing: ${spec}\n`);
|
||||
const installDir = path.join(skillsDir, '.npm_install_' + Date.now());
|
||||
fs.mkdirSync(installDir, { recursive: true });
|
||||
|
||||
try {
|
||||
execSync(`npm install ${spec} --no-save --prefix "${installDir}"`, {
|
||||
cwd: installDir, stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 120000, env: { ...process.env, NODE_PATH: path.join(installDir, 'node_modules') }
|
||||
});
|
||||
} catch (e) {
|
||||
fs.rmSync(installDir, { recursive: true, force: true });
|
||||
return { error: e.stderr ? e.stderr.toString() : e.message };
|
||||
}
|
||||
|
||||
const nm = path.join(installDir, 'node_modules');
|
||||
if (!fs.existsSync(nm)) {
|
||||
fs.rmSync(installDir, { recursive: true, force: true });
|
||||
return { error: 'node_modules not created' };
|
||||
}
|
||||
|
||||
let foundPluginDir = null;
|
||||
let foundName = null;
|
||||
|
||||
const entries = fs.readdirSync(nm);
|
||||
for (const entry of entries) {
|
||||
const dir = path.join(nm, entry);
|
||||
if (!fs.statSync(dir).isDirectory()) continue;
|
||||
|
||||
if (entry.startsWith('@')) {
|
||||
const subs = fs.readdirSync(dir);
|
||||
for (const sub of subs) {
|
||||
const subDir = path.join(dir, sub);
|
||||
if (fs.existsSync(path.join(subDir, 'openclaw.plugin.json')) ||
|
||||
(fs.existsSync(path.join(subDir, 'package.json')) && readJSON(path.join(subDir, 'package.json'))?.openclaw)) {
|
||||
foundPluginDir = subDir;
|
||||
foundName = entry + '/' + sub;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (fs.existsSync(path.join(dir, 'openclaw.plugin.json')) ||
|
||||
(fs.existsSync(path.join(dir, 'package.json')) && readJSON(path.join(dir, 'package.json'))?.openclaw)) {
|
||||
foundPluginDir = dir;
|
||||
foundName = entry;
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundPluginDir) {
|
||||
fs.rmSync(installDir, { recursive: true, force: true });
|
||||
return { error: `no OC plugin found in installed package "${spec}"` };
|
||||
}
|
||||
|
||||
// Copy to skills dir
|
||||
const targetDir = path.join(skillsDir, foundName);
|
||||
if (fs.existsSync(targetDir)) fs.rmSync(targetDir, { recursive: true, force: true });
|
||||
cpSync(foundPluginDir, targetDir);
|
||||
fs.rmSync(installDir, { recursive: true, force: true });
|
||||
|
||||
return { name: foundName, dir: targetDir };
|
||||
}
|
||||
|
||||
function cpSync(src, dst) {
|
||||
fs.mkdirSync(dst, { recursive: true });
|
||||
for (const entry of fs.readdirSync(src)) {
|
||||
const s = path.join(src, entry);
|
||||
const d = path.join(dst, entry);
|
||||
if (fs.statSync(s).isDirectory()) {
|
||||
cpSync(s, d);
|
||||
} else {
|
||||
fs.copyFileSync(s, d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Main ----
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length < 1) {
|
||||
process.stderr.write('[manager] usage: node main.js <skills-dir>\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const skillsDir = path.resolve(args[0]);
|
||||
|
||||
// Load existing plugins on startup
|
||||
process.stderr.write(`[manager] scanning: ${skillsDir}\n`);
|
||||
if (fs.existsSync(skillsDir)) {
|
||||
for (const entry of fs.readdirSync(skillsDir)) {
|
||||
if (entry.startsWith('.')) continue; // skip hidden
|
||||
const pluginDir = path.join(skillsDir, entry);
|
||||
if (!fs.statSync(pluginDir).isDirectory()) continue;
|
||||
if (fs.existsSync(path.join(pluginDir, 'main.js')) || fs.existsSync(path.join(pluginDir, 'main.py'))) {
|
||||
process.stderr.write(`[manager] skip non-OC plugin: ${entry} (main.js/main.py)\n`);
|
||||
continue;
|
||||
}
|
||||
if (fs.existsSync(path.join(pluginDir, 'openclaw.plugin.json')) ||
|
||||
(fs.existsSync(path.join(pluginDir, 'package.json')) && readJSON(path.join(pluginDir, 'package.json'))?.openclaw)) {
|
||||
loadPlugin(pluginDir, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- JSON-RPC ----
|
||||
const readline = require('readline');
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false });
|
||||
|
||||
rl.on('line', async (line) => {
|
||||
let req;
|
||||
try { req = JSON.parse(line); } catch { sendError(null, -32700, 'Parse error'); return; }
|
||||
|
||||
const id = req.id;
|
||||
const method = req.method;
|
||||
|
||||
if (method === 'ping') {
|
||||
writeJSON({ jsonrpc: '2.0', id, result: { status: 'ok', plugins: Object.keys(loadedPlugins).length } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'plugins/list') {
|
||||
const list = Object.entries(loadedPlugins).map(([name, p]) => ({
|
||||
name,
|
||||
tools: p.tools.map(t => ({ name: t.name, description: t.description })),
|
||||
}));
|
||||
writeJSON({ jsonrpc: '2.0', id, result: { plugins: list } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'plugins/install') {
|
||||
const pkg = req.params?.package;
|
||||
if (!pkg) { sendError(id, -32602, 'package required'); return; }
|
||||
|
||||
const result = installNPMPackage(pkg, skillsDir);
|
||||
if (result.error) {
|
||||
sendError(id, -32603, result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Load the newly installed plugin
|
||||
const ok = loadPlugin(result.dir, result.name);
|
||||
if (!ok) {
|
||||
sendError(id, -32603, `failed to load installed plugin: ${result.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
writeJSON({ jsonrpc: '2.0', id, result: { name: result.name, tools: loadedPlugins[result.name].tools.map(t => t.name) } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'plugins/uninstall') {
|
||||
const name = req.params?.name;
|
||||
if (!name) { sendError(id, -32602, 'name required'); return; }
|
||||
|
||||
if (!loadedPlugins[name]) { sendError(id, -32601, `plugin not found: ${name}`); return; }
|
||||
|
||||
// Remove tools
|
||||
const idxs = [];
|
||||
for (let i = allTools.length - 1; i >= 0; i--) {
|
||||
if (allTools[i]._plugin === name) allTools.splice(i, 1);
|
||||
}
|
||||
delete loadedPlugins[name];
|
||||
|
||||
// Remove directory
|
||||
const pluginDir = path.join(skillsDir, name);
|
||||
if (fs.existsSync(pluginDir)) fs.rmSync(pluginDir, { recursive: true, force: true });
|
||||
|
||||
writeJSON({ jsonrpc: '2.0', id, result: { status: 'uninstalled', name } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'tools/list') {
|
||||
const tools = allTools.map(t => ({
|
||||
name: t.name,
|
||||
description: t.description || '',
|
||||
inputSchema: t.parameters || { type: 'object', properties: {} },
|
||||
}));
|
||||
writeJSON({ jsonrpc: '2.0', id, result: { tools } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'tools/call') {
|
||||
const params = req.params || {};
|
||||
const toolName = params.name;
|
||||
const args = params.arguments || {};
|
||||
|
||||
const tool = allTools.find(t => t.name === toolName);
|
||||
if (!tool) { sendError(id, -32601, `Tool not found: ${toolName}`); return; }
|
||||
if (typeof tool.execute !== 'function') { sendError(id, -32603, `Tool ${toolName} has no execute`); return; }
|
||||
|
||||
try {
|
||||
const result = await tool.execute('mgr-call-1', args, undefined, undefined);
|
||||
if (result && typeof result === 'object' && Array.isArray(result.content)) {
|
||||
writeJSON({ jsonrpc: '2.0', id, result });
|
||||
} else {
|
||||
const text = typeof result === 'string' ? result : JSON.stringify(result);
|
||||
writeJSON({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text }] } });
|
||||
}
|
||||
} catch (e) { sendError(id, -32603, e.message); }
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'provider/call') {
|
||||
const { type, action, args } = req.params || {};
|
||||
if (!type) { sendError(id, -32602, 'type required'); return; }
|
||||
|
||||
const provider = allProviders[type];
|
||||
if (!provider) { sendError(id, -32601, `Provider not found: ${type}`); return; }
|
||||
|
||||
const methodName = action || 'execute';
|
||||
if (typeof provider.instance[methodName] !== 'function') {
|
||||
sendError(id, -32603, `Provider ${type} has no method ${methodName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await provider.instance[methodName](args);
|
||||
if (result && typeof result === 'object' && Array.isArray(result.content)) {
|
||||
writeJSON({ jsonrpc: '2.0', id, result });
|
||||
} else {
|
||||
const text = typeof result === 'string' ? result : JSON.stringify(result);
|
||||
writeJSON({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text }] } });
|
||||
}
|
||||
} catch (e) { sendError(id, -32603, e.message); }
|
||||
return;
|
||||
}
|
||||
|
||||
sendError(id, -32601, `Method not found: ${method}`);
|
||||
});
|
||||
868
internal/plugins/clawhubadapter/plugin.go
Normal file
868
internal/plugins/clawhubadapter/plugin.go
Normal file
@ -0,0 +1,868 @@
|
||||
package clawhubadapter
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
//go:embed simulator/main.js
|
||||
var simulatorSrc string
|
||||
|
||||
//go:embed manager/main.js
|
||||
var managerSrc string
|
||||
|
||||
//go:embed pysimulator/main.py
|
||||
var pySimulatorSrc string
|
||||
|
||||
var SkillsDir string
|
||||
var SimulatorDir string
|
||||
|
||||
func init() {
|
||||
plugin.RegisterPluginMeta("clawhubadapter", "ClawHub 适配器", "ClawHub Adapter")
|
||||
plugin.RegisterFactory("clawhubadapter", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
dir := SkillsDir
|
||||
if dir == "" {
|
||||
dataDir, ok := config["data_dir"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("clawhubadapter plugin: config missing 'data_dir' or not a string")
|
||||
}
|
||||
dir = filepath.Join(dataDir, "skills")
|
||||
}
|
||||
return New(name, dir), nil
|
||||
})
|
||||
}
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
skillsDir string
|
||||
simulatorDir string
|
||||
skills []*plugin.SKILLPlugin
|
||||
sidecars []*sidecarProcess
|
||||
manager *sidecarProcess
|
||||
mu sync.Mutex
|
||||
sdk *sdk.PluginSDK
|
||||
dispatcher *RegistryDispatcher
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func New(name, skillsDir string) *Plugin {
|
||||
sd := SimulatorDir
|
||||
if sd == "" {
|
||||
sd = filepath.Join(skillsDir, ".simulator")
|
||||
}
|
||||
return &Plugin{
|
||||
name: name,
|
||||
skillsDir: skillsDir,
|
||||
simulatorDir: sd,
|
||||
dispatcher: NewDispatcher(),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
p.sdk = s
|
||||
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "skills_dir", Type: "string", DisplayName: "Skill 加载目录",
|
||||
Description: "OpenClaw 技能加载目录路径(留空则使用默认路径)",
|
||||
})
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "simulator_dir", Type: "string", DisplayName: "模拟器工作目录",
|
||||
Description: "OpenClaw 模拟器工作目录路径(留空则使用默认路径)",
|
||||
})
|
||||
p.httpClient = &http.Client{}
|
||||
if v, _ := s.Settings().Get("skills_dir"); v != nil {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
p.skillsDir = s
|
||||
}
|
||||
}
|
||||
if v, _ := s.Settings().Get("simulator_dir"); v != nil {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
p.simulatorDir = s
|
||||
}
|
||||
}
|
||||
|
||||
// Launch OC plugin manager first (handles OC-format plugin installation and lifecycle)
|
||||
os.MkdirAll(p.skillsDir, 0755)
|
||||
if err := p.launchManager(s); err != nil {
|
||||
log.Printf("[clawhubadapter] launch manager: %v", err)
|
||||
}
|
||||
|
||||
// Load existing plugins from skills dir
|
||||
if entries, err := os.ReadDir(p.skillsDir); err == nil {
|
||||
for _, entry := range entries {
|
||||
skillPath := filepath.Join(p.skillsDir, entry.Name())
|
||||
subs, err := os.ReadDir(skillPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
hasMainJS := false
|
||||
hasMainPy := false
|
||||
hasOCManifest := false
|
||||
hasOCPackage := false
|
||||
for _, f := range subs {
|
||||
switch f.Name() {
|
||||
case "main.js":
|
||||
hasMainJS = true
|
||||
case "main.py":
|
||||
hasMainPy = true
|
||||
case "openclaw.plugin.json":
|
||||
hasOCManifest = true
|
||||
case "package.json":
|
||||
hasOCPackage = hasOCExtensions(filepath.Join(skillPath, "package.json"))
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case hasMainJS:
|
||||
if err := p.loadSidecar(s, skillPath, entry.Name()); err != nil {
|
||||
log.Printf("[clawhubadapter] sidecar %s: %v", entry.Name(), err)
|
||||
}
|
||||
case hasMainPy:
|
||||
if err := p.loadPySidecar(s, skillPath, entry.Name()); err != nil {
|
||||
log.Printf("[clawhubadapter] pysidecar %s: %v", entry.Name(), err)
|
||||
}
|
||||
case hasOCManifest || hasOCPackage:
|
||||
log.Printf("[clawhubadapter] ocplugin %s handled by manager", entry.Name())
|
||||
default:
|
||||
sk, err := plugin.LoadSKILL(skillPath)
|
||||
if err != nil {
|
||||
log.Printf("[clawhubadapter] load skill %s: %v", entry.Name(), err)
|
||||
continue
|
||||
}
|
||||
p.skills = append(p.skills, sk)
|
||||
log.Printf("[clawhubadapter] loaded skill: %s v%s", sk.Name(), sk.Version())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register plugin management tools that talk to the manager (always, even if skills dir is empty)
|
||||
tp := p.name + "_"
|
||||
s.RegisterTool(tp+"npm_install", sdk.ToolDef{
|
||||
Name: tp + "npm_install",
|
||||
Description: "安装 ClawHub 适配器插件管理器中的插件。支持 npm: 前缀(npm 包)和 clawhub: 前缀(ClawHub 市场)。安装后立即可用。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"package": map[string]interface{}{"type": "string", "description": "插件包标识。npm:<pkg> 从 npm 安装,clawhub:<pkg> 从 ClawHub 市场安装(如 clawhub:openclaw-codex-app-server)"},
|
||||
},
|
||||
"required": []string{"package"},
|
||||
},
|
||||
}, p.handlePluginInstall)
|
||||
|
||||
s.RegisterTool(tp+"npm_uninstall", sdk.ToolDef{
|
||||
Name: tp + "npm_uninstall",
|
||||
Description: "从插件管理器中移除已安装的插件。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"name": map[string]interface{}{"type": "string", "description": "要移除的插件名称"},
|
||||
},
|
||||
"required": []string{"name"},
|
||||
},
|
||||
}, p.handlePluginUninstall)
|
||||
|
||||
s.RegisterTool(tp+"search", sdk.ToolDef{
|
||||
Name: tp + "search",
|
||||
Description: "搜索 ClawHub 插件市场,查找可安装的插件。返回插件名称、描述和安装命令提示。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"query": map[string]interface{}{"type": "string", "description": "搜索关键词"},
|
||||
},
|
||||
"required": []string{"query"},
|
||||
},
|
||||
}, p.handleClawHubSearch)
|
||||
|
||||
s.RegisterTool(tp+"list", sdk.ToolDef{
|
||||
Name: tp + "list",
|
||||
Description: "列出所有已安装的 ClawHub 插件及其工具。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
}, p.handlePluginList)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── Manager ────────────────────────────────────────────────
|
||||
|
||||
// launchManager 启动 OC 插件管理器(Node.js 进程),用于安装/卸载/加载 OC 格式插件
|
||||
func (p *Plugin) launchManager(s *sdk.PluginSDK) error {
|
||||
managerPath := filepath.Join(p.simulatorDir, "manager.js")
|
||||
if err := os.MkdirAll(p.simulatorDir, 0755); err != nil {
|
||||
return fmt.Errorf("create simulator dir: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(managerPath, []byte(managerSrc), 0644); err != nil {
|
||||
return fmt.Errorf("write manager: %w", err)
|
||||
}
|
||||
|
||||
// Ensure skills dir exists for the manager to scan
|
||||
os.MkdirAll(p.skillsDir, 0755)
|
||||
|
||||
sp, err := launchProcess("node", managerPath, p.skillsDir, "manager")
|
||||
if err != nil {
|
||||
return fmt.Errorf("launch manager: %w", err)
|
||||
}
|
||||
if sp == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Drain initial registration notifications from already-loaded plugins
|
||||
for done := false; !done; {
|
||||
select {
|
||||
case n := <-sp.NotifyChan():
|
||||
p.translateAndRegister(n, sp, s, "manager")
|
||||
default:
|
||||
done = true
|
||||
}
|
||||
}
|
||||
go p.notifyLoop(sp, s, "manager")
|
||||
|
||||
p.mu.Lock()
|
||||
p.manager = sp
|
||||
p.sidecars = append(p.sidecars, sp)
|
||||
p.mu.Unlock()
|
||||
|
||||
log.Printf("[clawhubadapter] OC plugin manager started")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── 管理工具处理 ────────────────────────────────────────────
|
||||
|
||||
func (p *Plugin) handlePluginInstall(args map[string]interface{}) (interface{}, error) {
|
||||
pkg, _ := args["package"].(string)
|
||||
if pkg == "" {
|
||||
return errorResult("package is required"), nil
|
||||
}
|
||||
|
||||
if strings.HasPrefix(pkg, "clawhub:") {
|
||||
return p.installFromClawHub(pkg)
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
mgr := p.manager
|
||||
p.mu.Unlock()
|
||||
|
||||
if mgr == nil {
|
||||
return errorResult("plugin manager not available"), nil
|
||||
}
|
||||
|
||||
data, err := mgr.call("plugins/install", map[string]interface{}{
|
||||
"package": pkg,
|
||||
})
|
||||
if err != nil {
|
||||
return errorResult(fmt.Sprintf("install failed: %v", err)), nil
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Name string `json:"name"`
|
||||
Tools []string `json:"tools"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("Plugin installed. Raw response: %s", string(data)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("已安装插件: %s\n 工具: %s", result.Name, strings.Join(result.Tools, ", ")),
|
||||
}, nil
|
||||
}
|
||||
|
||||
const clawhubAPI = "https://clawhub.ai/api/v1"
|
||||
|
||||
func (p *Plugin) installFromClawHub(spec string) (interface{}, error) {
|
||||
name := strings.TrimPrefix(spec, "clawhub:")
|
||||
if name == "" {
|
||||
return errorResult("clawhub package name is required"), nil
|
||||
}
|
||||
|
||||
pkgURL := fmt.Sprintf("%s/packages/%s/download", clawhubAPI, url.PathEscape(name))
|
||||
resp, err := p.httpClient.Get(pkgURL)
|
||||
if err != nil {
|
||||
return errorResult(fmt.Sprintf("download failed: %v", err)), nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return errorResult(fmt.Sprintf("ClawHub API error (status %d): %s", resp.StatusCode, string(body))), nil
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return errorResult(fmt.Sprintf("read download: %v", err)), nil
|
||||
}
|
||||
|
||||
extractDir := filepath.Join(p.skillsDir, name)
|
||||
os.MkdirAll(extractDir, 0755)
|
||||
|
||||
if err := extractArchive(data, extractDir); err != nil {
|
||||
return errorResult(fmt.Sprintf("extract failed: %v", err)), nil
|
||||
}
|
||||
|
||||
if err := p.reloadPlugin(name); err != nil {
|
||||
return errorResult(fmt.Sprintf("loaded but with warning: %v", err)), nil
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("已从 ClawHub 安装插件: %s", name),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handleClawHubSearch(args map[string]interface{}) (interface{}, error) {
|
||||
query, _ := args["query"].(string)
|
||||
if query == "" {
|
||||
return errorResult("query is required"), nil
|
||||
}
|
||||
|
||||
searchURL := fmt.Sprintf("%s/search?q=%s", clawhubAPI, url.QueryEscape(query))
|
||||
resp, err := p.httpClient.Get(searchURL)
|
||||
if err != nil {
|
||||
return errorResult(fmt.Sprintf("search failed: %v", err)), nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var result struct {
|
||||
Results []struct {
|
||||
Slug string `json:"slug"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Summary string `json:"summary"`
|
||||
Version string `json:"version"`
|
||||
Downloads int `json:"downloads"`
|
||||
} `json:"results"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("Search results (raw):\n%s", string(body)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
if len(result.Results) == 0 {
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("未找到匹配 \"%s\" 的 ClawHub 插件", query),
|
||||
}, nil
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for _, pkg := range result.Results {
|
||||
ver := pkg.Version
|
||||
if ver == "" {
|
||||
ver = "latest"
|
||||
}
|
||||
name := pkg.DisplayName
|
||||
if name == "" {
|
||||
name = pkg.Slug
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("- %s (%s) v%s | ⬇ %d\n %s\n 安装: clawhubadapter_npm_install package=clawhub:%s",
|
||||
name, pkg.Slug, ver, pkg.Downloads, pkg.Summary, pkg.Slug))
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("在 ClawHub 找到 %d 个插件:\n%s", len(result.Results), strings.Join(lines, "\n")),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handlePluginUninstall(args map[string]interface{}) (interface{}, error) {
|
||||
name, _ := args["name"].(string)
|
||||
if name == "" {
|
||||
return errorResult("name is required"), nil
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
mgr := p.manager
|
||||
p.mu.Unlock()
|
||||
|
||||
if mgr == nil {
|
||||
return errorResult("plugin manager not available"), nil
|
||||
}
|
||||
|
||||
data, err := mgr.call("plugins/uninstall", map[string]interface{}{
|
||||
"name": name,
|
||||
})
|
||||
if err != nil {
|
||||
return errorResult(fmt.Sprintf("uninstall failed: %v", err)), nil
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("已卸载插件: %s\n %s", name, string(data)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) handlePluginList(args map[string]interface{}) (interface{}, error) {
|
||||
p.mu.Lock()
|
||||
mgr := p.manager
|
||||
p.mu.Unlock()
|
||||
|
||||
if mgr != nil {
|
||||
data, err := mgr.call("plugins/list", nil)
|
||||
if err == nil && data != nil {
|
||||
var result struct {
|
||||
Plugins []struct {
|
||||
Name string `json:"name"`
|
||||
Tools []struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
} `json:"tools"`
|
||||
} `json:"plugins"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &result); err == nil {
|
||||
var parts []string
|
||||
parts = append(parts, fmt.Sprintf("Skills dir: %s\n", p.skillsDir))
|
||||
|
||||
if len(result.Plugins) > 0 {
|
||||
parts = append(parts, fmt.Sprintf("\nOC 插件 (%d):", len(result.Plugins)))
|
||||
for _, pl := range result.Plugins {
|
||||
var names []string
|
||||
for _, t := range pl.Tools {
|
||||
names = append(names, t.Name)
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf(" %s: %s", pl.Name, strings.Join(names, ", ")))
|
||||
}
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
if len(p.sidecars) > 0 {
|
||||
sidecarCount := 0
|
||||
for _, sp := range p.sidecars {
|
||||
if sp != p.manager {
|
||||
sidecarCount++
|
||||
}
|
||||
}
|
||||
if sidecarCount > 0 {
|
||||
parts = append(parts, fmt.Sprintf("\nSidecar 插件 (%d):", sidecarCount))
|
||||
for _, sp := range p.sidecars {
|
||||
if sp == p.manager {
|
||||
continue
|
||||
}
|
||||
tools, _ := sp.ListTools()
|
||||
var names []string
|
||||
for _, t := range tools {
|
||||
names = append(names, t.Name)
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf(" %s: %s", sp.name, strings.Join(names, ", ")))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(p.skills) > 0 {
|
||||
parts = append(parts, fmt.Sprintf("\nSKILL 插件 (%d):", len(p.skills)))
|
||||
for _, sk := range p.skills {
|
||||
parts = append(parts, fmt.Sprintf(" %s v%s", sk.Name(), sk.Version()))
|
||||
}
|
||||
}
|
||||
caps := p.dispatcher.Capabilities()
|
||||
if len(caps) > 0 {
|
||||
parts = append(parts, fmt.Sprintf("\nCapabilities (%d):", len(caps)))
|
||||
for _, c := range caps {
|
||||
parts = append(parts, fmt.Sprintf(" %s", c))
|
||||
}
|
||||
}
|
||||
if len(result.Plugins) == 0 && len(p.sidecars) <= 1 && len(p.skills) == 0 {
|
||||
parts = append(parts, "没有已安装的插件。")
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": strings.Join(parts, "\n"),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: list known plugins from Go side
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
var parts []string
|
||||
parts = append(parts, fmt.Sprintf("Skills dir: %s\n", p.skillsDir))
|
||||
|
||||
if len(p.sidecars) > 0 {
|
||||
parts = append(parts, fmt.Sprintf("\nSidecar/OC 插件 (%d):", len(p.sidecars)))
|
||||
for _, sp := range p.sidecars {
|
||||
tools, err := sp.ListTools()
|
||||
toolList := ""
|
||||
if err == nil {
|
||||
var names []string
|
||||
for _, t := range tools {
|
||||
names = append(names, t.Name)
|
||||
}
|
||||
toolList = strings.Join(names, ", ")
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf(" %s: %s", sp.name, toolList))
|
||||
}
|
||||
}
|
||||
|
||||
if len(p.skills) > 0 {
|
||||
parts = append(parts, fmt.Sprintf("\nSKILL 插件 (%d):", len(p.skills)))
|
||||
for _, sk := range p.skills {
|
||||
parts = append(parts, fmt.Sprintf(" %s v%s", sk.Name(), sk.Version()))
|
||||
}
|
||||
}
|
||||
|
||||
caps := p.dispatcher.Capabilities()
|
||||
if len(caps) > 0 {
|
||||
parts = append(parts, fmt.Sprintf("\nCapabilities (%d):", len(caps)))
|
||||
for _, c := range caps {
|
||||
parts = append(parts, fmt.Sprintf(" %s", c))
|
||||
}
|
||||
}
|
||||
|
||||
if len(p.sidecars) == 0 && len(p.skills) == 0 {
|
||||
parts = append(parts, "没有已安装的插件。")
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": strings.Join(parts, "\n"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) loadOCPlugin(s *sdk.PluginSDK, dir, name string) error {
|
||||
simPath := filepath.Join(p.simulatorDir, "main.js")
|
||||
if err := os.MkdirAll(p.simulatorDir, 0755); err != nil {
|
||||
return fmt.Errorf("create simulator dir: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(simPath, []byte(simulatorSrc), 0644); err != nil {
|
||||
return fmt.Errorf("write simulator: %w", err)
|
||||
}
|
||||
|
||||
sp, err := launchProcess("node", simPath, dir, name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("launch simulator: %w", err)
|
||||
}
|
||||
if sp == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 通知是唯一注册路径。
|
||||
// 插件 register(api) 期间模拟器将所有 register* 调用以通知推送给 Go。
|
||||
// waitReady 之后所有初始化通知已缓冲在 notifyCh 中,同步排空处理。
|
||||
for done := false; !done; {
|
||||
select {
|
||||
case n := <-sp.NotifyChan():
|
||||
p.translateAndRegister(n, sp, s, name)
|
||||
default:
|
||||
done = true
|
||||
}
|
||||
}
|
||||
|
||||
// 启动持久通知协程:后续模拟器推送的注册通知持续转译注册到核心
|
||||
go p.notifyLoop(sp, s, name)
|
||||
|
||||
// ListTools 仅验证日志,不参与注册
|
||||
tools, err := sp.ListTools()
|
||||
if err != nil {
|
||||
sp.Close()
|
||||
return fmt.Errorf("list tools: %w", err)
|
||||
}
|
||||
log.Printf("[clawhubadapter] ocplugin %s verified %d tools via ListTools", name, len(tools))
|
||||
|
||||
p.mu.Lock()
|
||||
p.sidecars = append(p.sidecars, sp)
|
||||
p.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// notifyLoop 持续监听模拟器的注册通知,即时转译注册到核心。
|
||||
// 生命周期绑定 sidecarProcess.NotifyChan,sp.Close() 时会关闭通道使循环退出。
|
||||
func (p *Plugin) notifyLoop(sp *sidecarProcess, s *sdk.PluginSDK, pluginName string) {
|
||||
for n := range sp.NotifyChan() {
|
||||
p.translateAndRegister(n, sp, s, pluginName)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) translateAndRegister(n OCNotification, sp *sidecarProcess, s *sdk.PluginSDK, pluginName string) {
|
||||
if n.Method != "register" {
|
||||
return
|
||||
}
|
||||
var params struct {
|
||||
Type string `json:"type"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(n.Params, ¶ms); err != nil {
|
||||
return
|
||||
}
|
||||
p.dispatcher.Dispatch(params.Type, params.Data, pluginName, sp, s)
|
||||
}
|
||||
|
||||
func (p *Plugin) loadPySidecar(s *sdk.PluginSDK, dir, name string) error {
|
||||
simPath := filepath.Join(p.simulatorDir, "pysim.py")
|
||||
if err := os.MkdirAll(p.simulatorDir, 0755); err != nil {
|
||||
return fmt.Errorf("create simulator dir: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(simPath, []byte(pySimulatorSrc), 0644); err != nil {
|
||||
return fmt.Errorf("write pysimulator: %w", err)
|
||||
}
|
||||
|
||||
// 查找可用的 Python 解释器
|
||||
pythonBin := "python3"
|
||||
for _, candidate := range []string{"/usr/bin/python3", "/usr/local/bin/python3"} {
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
pythonBin = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
sp, err := launchProcess(pythonBin, simPath, dir, name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("launch pysimulator: %w", err)
|
||||
}
|
||||
if sp == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 处理注册通知 (同 OC 插件流程)
|
||||
for done := false; !done; {
|
||||
select {
|
||||
case n := <-sp.NotifyChan():
|
||||
p.translateAndRegister(n, sp, s, name)
|
||||
default:
|
||||
done = true
|
||||
}
|
||||
}
|
||||
go p.notifyLoop(sp, s, name)
|
||||
|
||||
tools, err := sp.ListTools()
|
||||
if err != nil {
|
||||
sp.Close()
|
||||
return fmt.Errorf("list tools: %w", err)
|
||||
}
|
||||
log.Printf("[clawhubadapter] pysidecar %s registered %d tools", name, len(tools))
|
||||
|
||||
p.mu.Lock()
|
||||
p.sidecars = append(p.sidecars, sp)
|
||||
p.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) loadSidecar(s *sdk.PluginSDK, dir, name string) error {
|
||||
sp, err := launchSidecar(dir, name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("launch: %w", err)
|
||||
}
|
||||
if sp == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
tools, err := sp.ListTools()
|
||||
if err != nil {
|
||||
sp.Close()
|
||||
return fmt.Errorf("list tools: %w", err)
|
||||
}
|
||||
|
||||
for _, tool := range tools {
|
||||
toolName := fmt.Sprintf("%s_%s", name, tool.Name)
|
||||
tDef := sdk.ToolDef{
|
||||
Name: toolName,
|
||||
Description: fmt.Sprintf("[%s] %s", name, tool.Description),
|
||||
Parameters: tool.InputSchema,
|
||||
}
|
||||
handler := func(sp *sidecarProcess, toolName string) sdk.ToolHandler {
|
||||
return func(args map[string]interface{}) (interface{}, error) {
|
||||
return sp.CallTool(toolName, args)
|
||||
}
|
||||
}(sp, tool.Name)
|
||||
if err := s.RegisterTool(toolName, tDef, handler); err != nil {
|
||||
log.Printf("[clawhubadapter] register sidecar tool %s: %v", toolName, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("[clawhubadapter] registered sidecar tool: %s (from %s)", toolName, name)
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
p.sidecars = append(p.sidecars, sp)
|
||||
p.mu.Unlock()
|
||||
log.Printf("[clawhubadapter] sidecar %s started with %d tools", name, len(tools))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for _, sp := range p.sidecars {
|
||||
sp.Close()
|
||||
}
|
||||
p.sidecars = nil
|
||||
p.skills = nil
|
||||
p.manager = nil
|
||||
p.sdk = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func errorResult(msg string) interface{} {
|
||||
return map[string]interface{}{
|
||||
"isError": true,
|
||||
"content": msg,
|
||||
}
|
||||
}
|
||||
|
||||
// hasOCExtensions 检测 package.json 中是否有 openclaw.extensions 或 openclaw.runtimeExtensions
|
||||
func hasOCExtensions(pkgPath string) bool {
|
||||
data, err := os.ReadFile(pkgPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
var pkg struct {
|
||||
OpenClaw *struct {
|
||||
Extensions interface{} `json:"extensions"`
|
||||
RuntimeExtensions interface{} `json:"runtimeExtensions"`
|
||||
} `json:"openclaw"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &pkg); err != nil {
|
||||
return false
|
||||
}
|
||||
return pkg.OpenClaw != nil && (pkg.OpenClaw.Extensions != nil || pkg.OpenClaw.RuntimeExtensions != nil)
|
||||
}
|
||||
|
||||
func (p *Plugin) reloadPlugin(name string) error {
|
||||
pluginDir := filepath.Join(p.skillsDir, name)
|
||||
info, err := os.Stat(pluginDir)
|
||||
if err != nil || !info.IsDir() {
|
||||
return fmt.Errorf("plugin dir not found: %s", pluginDir)
|
||||
}
|
||||
|
||||
if p.sdk == nil {
|
||||
return fmt.Errorf("sdk not initialized")
|
||||
}
|
||||
|
||||
subs, err := os.ReadDir(pluginDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hasMainJS := false
|
||||
hasMainPy := false
|
||||
hasOCManifest := false
|
||||
for _, f := range subs {
|
||||
switch f.Name() {
|
||||
case "main.js":
|
||||
hasMainJS = true
|
||||
case "main.py":
|
||||
hasMainPy = true
|
||||
case "openclaw.plugin.json":
|
||||
hasOCManifest = true
|
||||
}
|
||||
}
|
||||
|
||||
os.MkdirAll(p.simulatorDir, 0755)
|
||||
|
||||
switch {
|
||||
case hasMainJS:
|
||||
return p.loadSidecar(p.sdk, pluginDir, name)
|
||||
case hasMainPy:
|
||||
return p.loadPySidecar(p.sdk, pluginDir, name)
|
||||
case hasOCManifest:
|
||||
return p.loadOCPlugin(p.sdk, pluginDir, name)
|
||||
default:
|
||||
sk, err := plugin.LoadSKILL(pluginDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load skill: %w", err)
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.skills = append(p.skills, sk)
|
||||
p.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func extractArchive(data []byte, dest string) error {
|
||||
if len(data) < 4 {
|
||||
return fmt.Errorf("archive too small (%d bytes)", len(data))
|
||||
}
|
||||
|
||||
if data[0] == 0x50 && data[1] == 0x4B && data[2] == 0x03 && data[3] == 0x04 {
|
||||
return extractZIP(data, dest)
|
||||
}
|
||||
|
||||
return extractTGZ(data, dest)
|
||||
}
|
||||
|
||||
func extractZIP(data []byte, dest string) error {
|
||||
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("zip: %w", err)
|
||||
}
|
||||
for _, f := range zr.File {
|
||||
target := filepath.Join(dest, f.Name)
|
||||
if f.FileInfo().IsDir() {
|
||||
os.MkdirAll(target, 0755)
|
||||
continue
|
||||
}
|
||||
os.MkdirAll(filepath.Dir(target), 0755)
|
||||
r, err := f.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.Create(target)
|
||||
if err != nil {
|
||||
r.Close()
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(out, r)
|
||||
r.Close()
|
||||
out.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractTGZ(data []byte, dest string) error {
|
||||
gzr, err := gzip.NewReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return fmt.Errorf("gzip: %w", err)
|
||||
}
|
||||
defer gzr.Close()
|
||||
|
||||
tr := tar.NewReader(gzr)
|
||||
for {
|
||||
header, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
target := filepath.Join(dest, header.Name)
|
||||
switch header.Typeflag {
|
||||
case tar.TypeDir:
|
||||
os.MkdirAll(target, 0755)
|
||||
case tar.TypeReg:
|
||||
os.MkdirAll(filepath.Dir(target), 0755)
|
||||
f, err := os.Create(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(f, tr); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
164
internal/plugins/clawhubadapter/pysimulator/main.py
Normal file
164
internal/plugins/clawhubadapter/pysimulator/main.py
Normal file
@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
OpenClaw Python Sidecar Simulator
|
||||
|
||||
Loads a Python plugin (main.py) from the plugin directory and
|
||||
provides JSON-RPC over stdin/stdout for tool listing and execution.
|
||||
|
||||
Protocol is identical to the Node.js simulator:
|
||||
- ping: returns {"status": "ok"}
|
||||
- tools/list: lists registered tools
|
||||
- tools/call: executes a tool
|
||||
|
||||
Plugin main.py should define:
|
||||
def register(api):
|
||||
api.register_tool(name, description, parameters, execute_fn)
|
||||
"""
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
def resolve_entry(plugin_dir):
|
||||
"""Resolve the plugin entry point."""
|
||||
main_py = os.path.join(plugin_dir, 'main.py')
|
||||
if os.path.isfile(main_py):
|
||||
return main_py
|
||||
return None
|
||||
|
||||
|
||||
class PluginAPI:
|
||||
"""API passed to the plugin's register() function."""
|
||||
|
||||
def __init__(self, plugin_dir):
|
||||
self.id = os.path.basename(plugin_dir)
|
||||
self.name = os.path.basename(plugin_dir)
|
||||
self.version = '1.0.0'
|
||||
self.description = ''
|
||||
self.source = plugin_dir
|
||||
self.root_dir = plugin_dir
|
||||
self.config = {}
|
||||
self.plugin_config = {}
|
||||
self.registration_mode = 'full'
|
||||
self._tools = {}
|
||||
|
||||
def register_tool(self, name, description='', parameters=None, execute=None):
|
||||
if parameters is None:
|
||||
parameters = {'type': 'object', 'properties': {}}
|
||||
# Notify Go side
|
||||
self._notify('register', {
|
||||
'type': 'tool',
|
||||
'data': {
|
||||
'name': name,
|
||||
'description': description,
|
||||
'parameters': parameters,
|
||||
}
|
||||
})
|
||||
self._tools[name] = {
|
||||
'name': name,
|
||||
'description': description,
|
||||
'parameters': parameters,
|
||||
'execute': execute,
|
||||
}
|
||||
|
||||
def register_provider(self, provider_type, provider):
|
||||
self._notify('register', {
|
||||
'type': 'provider',
|
||||
'data': {'name': provider.name if hasattr(provider, 'name') else str(provider)},
|
||||
})
|
||||
|
||||
def register_channel(self, channel):
|
||||
self._notify('register', {
|
||||
'type': 'channel',
|
||||
'data': {'name': channel.name if hasattr(channel, 'name') else str(channel)},
|
||||
})
|
||||
|
||||
def _notify(self, method, params):
|
||||
line = json.dumps({'jsonrpc': '2.0', 'method': method, 'params': params}, ensure_ascii=False)
|
||||
sys.stdout.write(line + '\n')
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def load_plugin(plugin_dir):
|
||||
"""Load the plugin from main.py and call its register()."""
|
||||
entry_path = resolve_entry(plugin_dir)
|
||||
if entry_path is None:
|
||||
sys.stderr.write(f'[pysimulator] entry not found in {plugin_dir}\n')
|
||||
sys.exit(1)
|
||||
|
||||
sys.path.insert(0, plugin_dir)
|
||||
spec = importlib.util.spec_from_file_location('plugin_main', entry_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
if not hasattr(module, 'register'):
|
||||
sys.stderr.write(f'[pysimulator] {entry_path} must define a register(api) function\n')
|
||||
sys.exit(1)
|
||||
|
||||
api = PluginAPI(plugin_dir)
|
||||
module.register(api)
|
||||
return api
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
sys.stderr.write('[pysimulator] usage: python3 main.py <plugin-dir>\n')
|
||||
sys.exit(1)
|
||||
|
||||
plugin_dir = os.path.abspath(sys.argv[1])
|
||||
api = load_plugin(plugin_dir)
|
||||
|
||||
# JSON-RPC loop over stdin
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
req = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
resp = {'jsonrpc': '2.0', 'id': None, 'error': {'code': -32700, 'message': 'Parse error'}}
|
||||
sys.stdout.write(json.dumps(resp, ensure_ascii=False) + '\n')
|
||||
sys.stdout.flush()
|
||||
continue
|
||||
|
||||
req_id = req.get('id')
|
||||
method = req.get('method', '')
|
||||
|
||||
if method == 'ping':
|
||||
resp = {'jsonrpc': '2.0', 'id': req_id, 'result': {'status': 'ok'}}
|
||||
elif method == 'tools/list':
|
||||
tools = [
|
||||
{'name': t['name'], 'description': t['description'], 'inputSchema': t['parameters']}
|
||||
for t in api._tools.values()
|
||||
]
|
||||
resp = {'jsonrpc': '2.0', 'id': req_id, 'result': {'tools': tools}}
|
||||
elif method == 'tools/call':
|
||||
params = req.get('params', {})
|
||||
tool_name = params.get('name', '')
|
||||
arguments = params.get('arguments', {})
|
||||
|
||||
tool = api._tools.get(tool_name)
|
||||
if tool is None:
|
||||
resp = {'jsonrpc': '2.0', 'id': req_id, 'error': {'code': -32601, 'message': f'Tool not found: {tool_name}'}}
|
||||
elif tool.get('execute') is None:
|
||||
resp = {'jsonrpc': '2.0', 'id': req_id, 'error': {'code': -32603, 'message': f'Tool {tool_name} has no execute function'}}
|
||||
else:
|
||||
try:
|
||||
result = tool['execute'](arguments)
|
||||
if isinstance(result, dict) and 'content' in result:
|
||||
resp = {'jsonrpc': '2.0', 'id': req_id, 'result': result}
|
||||
else:
|
||||
text = str(result) if not isinstance(result, str) else result
|
||||
resp = {'jsonrpc': '2.0', 'id': req_id, 'result': {'content': [{'type': 'text', 'text': text}]}}
|
||||
except Exception as e:
|
||||
resp = {'jsonrpc': '2.0', 'id': req_id, 'error': {'code': -32603, 'message': str(e)}}
|
||||
else:
|
||||
resp = {'jsonrpc': '2.0', 'id': req_id, 'error': {'code': -32601, 'message': f'Method not found: {method}'}}
|
||||
|
||||
sys.stdout.write(json.dumps(resp, ensure_ascii=False) + '\n')
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
251
internal/plugins/clawhubadapter/registry.go
Normal file
251
internal/plugins/clawhubadapter/registry.go
Normal file
@ -0,0 +1,251 @@
|
||||
package clawhubadapter
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
type ToolRegistry struct{}
|
||||
|
||||
func (r *ToolRegistry) Dispatch(data json.RawMessage, pluginName string, sp *sidecarProcess, s *sdk.PluginSDK) {
|
||||
var d struct {
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
Parameters map[string]interface{} `json:"parameters"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &d); err != nil || d.Name == "" {
|
||||
return
|
||||
}
|
||||
toolName := fmt.Sprintf("%s_%s", pluginName, d.Name)
|
||||
tDef := sdk.ToolDef{
|
||||
Name: toolName,
|
||||
Description: d.Description,
|
||||
Parameters: d.Parameters,
|
||||
}
|
||||
handler := func(sp *sidecarProcess, ocToolName string) sdk.ToolHandler {
|
||||
return func(args map[string]interface{}) (interface{}, error) {
|
||||
return sp.CallTool(ocToolName, args)
|
||||
}
|
||||
}(sp, d.Name)
|
||||
if err := s.RegisterTool(toolName, tDef, handler); err != nil {
|
||||
log.Printf("[clawhubadapter] register tool %s: %v", toolName, err)
|
||||
}
|
||||
}
|
||||
|
||||
type providerDef struct {
|
||||
ocType string
|
||||
toolSuffix string
|
||||
desc string
|
||||
}
|
||||
|
||||
var providerMap = []providerDef{
|
||||
{"image_generation", "generate_image", "根据文本描述生成图片,返回图片 URL"},
|
||||
{"music_generation", "generate_music", "根据描述生成音乐"},
|
||||
{"video_generation", "generate_video", "根据描述生成视频"},
|
||||
{"speech", "synthesize_speech", "将文本合成为语音"},
|
||||
{"web_search", "web_search", "搜索互联网信息"},
|
||||
{"web_fetch", "web_fetch", "获取指定网页的内容"},
|
||||
{"media_understanding", "analyze_media", "分析图片、音频或视频内容"},
|
||||
{"realtime_transcription", "transcribe_audio", "将音频转写为文字"},
|
||||
{"realtime_voice", "voice_io", "实时语音输入输出"},
|
||||
}
|
||||
|
||||
type ProviderRegistry struct{}
|
||||
|
||||
func (r *ProviderRegistry) Dispatch(typeStr string, data json.RawMessage, pluginName string, sp *sidecarProcess, s *sdk.PluginSDK) {
|
||||
var d struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
json.Unmarshal(data, &d)
|
||||
|
||||
for _, p := range providerMap {
|
||||
if p.ocType == typeStr {
|
||||
toolName := fmt.Sprintf("%s_%s", pluginName, p.toolSuffix)
|
||||
desc := p.desc
|
||||
if d.Name != "" {
|
||||
desc = fmt.Sprintf("[%s] %s", d.Name, desc)
|
||||
}
|
||||
tDef := sdk.ToolDef{
|
||||
Name: toolName,
|
||||
Description: desc,
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
handler := func(sp *sidecarProcess, ocType string) sdk.ToolHandler {
|
||||
return func(args map[string]interface{}) (interface{}, error) {
|
||||
return sp.CallProvider(ocType, args)
|
||||
}
|
||||
}(sp, typeStr)
|
||||
if err := s.RegisterTool(toolName, tDef, handler); err != nil {
|
||||
log.Printf("[clawhubadapter] register provider tool %s: %v", toolName, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[clawhubadapter] unknown provider type: %s (plugin: %s)", typeStr, pluginName)
|
||||
}
|
||||
|
||||
type channelDevice struct {
|
||||
name string
|
||||
pluginName string
|
||||
sp *sidecarProcess
|
||||
ocType string
|
||||
}
|
||||
|
||||
func (d *channelDevice) Name() string { return d.name }
|
||||
func (d *channelDevice) Type() agentIO.DeviceType { return agentIO.DeviceIO }
|
||||
func (d *channelDevice) Description() string { return fmt.Sprintf("OC channel %s (from %s)", d.name, d.pluginName) }
|
||||
func (d *channelDevice) OutputCapabilities() agentIO.OutputCapability { return ocTypeToCap(d.ocType) }
|
||||
func (d *channelDevice) Start() error { return nil }
|
||||
func (d *channelDevice) Stop() error { return nil }
|
||||
func (d *channelDevice) Tools() []agentIO.ToolDef { return nil }
|
||||
func (d *channelDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
|
||||
return d.sp.CallTool(tool, args)
|
||||
}
|
||||
|
||||
func ocTypeToCap(t string) agentIO.OutputCapability {
|
||||
switch t {
|
||||
case "text":
|
||||
return agentIO.CapText
|
||||
case "file":
|
||||
return agentIO.CapFile
|
||||
case "image":
|
||||
return agentIO.CapImage
|
||||
case "audio":
|
||||
return agentIO.CapAudio
|
||||
default:
|
||||
return agentIO.CapText
|
||||
}
|
||||
}
|
||||
|
||||
type ChannelRegistry struct{}
|
||||
|
||||
func (r *ChannelRegistry) Dispatch(data json.RawMessage, pluginName string, sp *sidecarProcess, s *sdk.PluginSDK) {
|
||||
var d struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &d); err != nil || d.Name == "" {
|
||||
return
|
||||
}
|
||||
dev := &channelDevice{
|
||||
name: d.Name,
|
||||
pluginName: pluginName,
|
||||
sp: sp,
|
||||
ocType: d.Type,
|
||||
}
|
||||
if err := s.RegisterChannel(d.Name, dev); err != nil {
|
||||
log.Printf("[clawhubadapter] register channel %s: %v", d.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
type StageRegistry struct{}
|
||||
|
||||
func (r *StageRegistry) Dispatch(data json.RawMessage, pluginName string, sp *sidecarProcess, s *sdk.PluginSDK) {
|
||||
var d struct {
|
||||
Name string `json:"name"`
|
||||
Event string `json:"event"`
|
||||
}
|
||||
json.Unmarshal(data, &d)
|
||||
log.Printf("[clawhubadapter] hook %s/%s (plugin: %s) — stub: OC hooks need bidirectional bridge",
|
||||
d.Name, d.Event, pluginName)
|
||||
}
|
||||
|
||||
type CapRecorder struct {
|
||||
mu sync.Mutex
|
||||
caps []string
|
||||
}
|
||||
|
||||
func (r *CapRecorder) Record(typeStr string, data json.RawMessage, pluginName string) {
|
||||
var d struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
json.Unmarshal(data, &d)
|
||||
capStr := fmt.Sprintf("[%s] capability: %s", pluginName, typeStr)
|
||||
if d.Name != "" {
|
||||
capStr += " (" + d.Name + ")"
|
||||
}
|
||||
if d.Description != "" {
|
||||
capStr += ": " + d.Description
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.caps = append(r.caps, capStr)
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *CapRecorder) Snapshot() []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]string, len(r.caps))
|
||||
copy(out, r.caps)
|
||||
return out
|
||||
}
|
||||
|
||||
type RegistryDispatcher struct {
|
||||
toolReg *ToolRegistry
|
||||
providerReg *ProviderRegistry
|
||||
channelReg *ChannelRegistry
|
||||
stageReg *StageRegistry
|
||||
capRecorder *CapRecorder
|
||||
}
|
||||
|
||||
func NewDispatcher() *RegistryDispatcher {
|
||||
return &RegistryDispatcher{
|
||||
toolReg: &ToolRegistry{},
|
||||
providerReg: &ProviderRegistry{},
|
||||
channelReg: &ChannelRegistry{},
|
||||
stageReg: &StageRegistry{},
|
||||
capRecorder: &CapRecorder{},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *RegistryDispatcher) Dispatch(typeStr string, data json.RawMessage, pluginName string, sp *sidecarProcess, s *sdk.PluginSDK) {
|
||||
switch typeStr {
|
||||
case "tool":
|
||||
d.toolReg.Dispatch(data, pluginName, sp, s)
|
||||
case "channel":
|
||||
d.channelReg.Dispatch(data, pluginName, sp, s)
|
||||
case "provider", "image_generation_provider", "music_generation_provider",
|
||||
"video_generation_provider", "speech_provider",
|
||||
"realtime_transcription_provider", "realtime_voice_provider",
|
||||
"media_understanding_provider",
|
||||
"web_fetch_provider", "web_search_provider",
|
||||
"embedding_provider", "memory_embedding_provider":
|
||||
d.providerReg.Dispatch(typeStr, data, pluginName, sp, s)
|
||||
d.capRecorder.Record(typeStr, data, pluginName)
|
||||
case "hook", "runtime_lifecycle", "lifecycle",
|
||||
"agent_event_subscription", "agent_harness",
|
||||
"session_event", "conversation_binding_resolved",
|
||||
"interactive_handler",
|
||||
"cli", "cli_backend", "node_cli_feature",
|
||||
"command", "http_route", "service",
|
||||
"gateway_method", "gateway_discovery_service",
|
||||
"trusted_tool_policy", "tool_metadata",
|
||||
"context_engine",
|
||||
"memory_capability", "memory_prompt_section",
|
||||
"memory_flush_plan", "memory_runtime",
|
||||
"memory_prompt_supplement", "memory_corpus_supplement",
|
||||
"session_extension", "session_scheduler_job",
|
||||
"session_action", "control_ui_descriptor",
|
||||
"agent_tool_result_middleware":
|
||||
d.stageReg.Dispatch(data, pluginName, sp, s)
|
||||
d.capRecorder.Record(typeStr, data, pluginName)
|
||||
default:
|
||||
d.capRecorder.Record(typeStr, data, pluginName)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *RegistryDispatcher) Capabilities() []string {
|
||||
return d.capRecorder.Snapshot()
|
||||
}
|
||||
359
internal/plugins/clawhubadapter/sidecar.go
Normal file
359
internal/plugins/clawhubadapter/sidecar.go
Normal file
@ -0,0 +1,359 @@
|
||||
package clawhubadapter
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type sidecarRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID int `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params interface{} `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
type sidecarResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID int `json:"id"`
|
||||
Result *json.RawMessage `json:"result,omitempty"`
|
||||
Error *struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type OCPTool struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema map[string]interface{} `json:"inputSchema"`
|
||||
}
|
||||
|
||||
type OCCallResult struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
} `json:"content"`
|
||||
}
|
||||
|
||||
// OCNotification 是模拟器主动推送的通知
|
||||
type OCNotification struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
}
|
||||
|
||||
type sidecarProcess struct {
|
||||
name string
|
||||
dir string
|
||||
cmd *exec.Cmd
|
||||
stdin *bufio.Writer
|
||||
mu sync.Mutex
|
||||
nextID int
|
||||
closed bool
|
||||
stopped bool
|
||||
|
||||
// 异步 reader
|
||||
pending map[int]chan<- []byte
|
||||
notifyCh chan OCNotification
|
||||
readerStop chan struct{}
|
||||
readerWg sync.WaitGroup
|
||||
readerReady chan struct{}
|
||||
}
|
||||
|
||||
func newSidecarProcess(name, dir string, cmd *exec.Cmd, stdin *bufio.Writer, stdout io.Reader) *sidecarProcess {
|
||||
sp := &sidecarProcess{
|
||||
name: name,
|
||||
dir: dir,
|
||||
cmd: cmd,
|
||||
stdin: stdin,
|
||||
pending: make(map[int]chan<- []byte),
|
||||
notifyCh: make(chan OCNotification, 1024),
|
||||
readerStop: make(chan struct{}),
|
||||
readerReady: make(chan struct{}),
|
||||
}
|
||||
sp.readerWg.Add(1)
|
||||
go sp.readLoop(stdout)
|
||||
<-sp.readerReady
|
||||
return sp
|
||||
}
|
||||
|
||||
func (s *sidecarProcess) readLoop(r io.Reader) {
|
||||
defer s.readerWg.Done()
|
||||
scanner := bufio.NewScanner(bufio.NewReader(r))
|
||||
// 加大 scanner buffer 防止长行截断
|
||||
scanner.Buffer(make([]byte, 0, 1024*64), 1024*64)
|
||||
close(s.readerReady)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.readerStop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
if !scanner.Scan() {
|
||||
if scanner.Err() != nil {
|
||||
log.Printf("[clawhubadapter] sidecar %s read error: %v", s.name, scanner.Err())
|
||||
}
|
||||
return
|
||||
}
|
||||
line := scanner.Text()
|
||||
|
||||
var base struct {
|
||||
ID *int `json:"id"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Error *json.RawMessage `json:"error,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &base); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if base.ID != nil {
|
||||
s.mu.Lock()
|
||||
ch, ok := s.pending[*base.ID]
|
||||
delete(s.pending, *base.ID)
|
||||
s.mu.Unlock()
|
||||
if ok {
|
||||
ch <- []byte(line)
|
||||
close(ch)
|
||||
}
|
||||
} else if base.Method != "" {
|
||||
var notif OCNotification
|
||||
if err := json.Unmarshal([]byte(line), ¬if); err == nil {
|
||||
select {
|
||||
case s.notifyCh <- notif:
|
||||
default:
|
||||
log.Printf("[clawhubadapter] sidecar %s notify channel full, dropping: %s", s.name, notif.Method)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sidecarProcess) NotifyChan() <-chan OCNotification {
|
||||
return s.notifyCh
|
||||
}
|
||||
|
||||
func launchSidecar(dir, name string) (*sidecarProcess, error) {
|
||||
mainJS := filepath.Join(dir, "main.js")
|
||||
if _, err := os.Stat(mainJS); os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return launchProcess("node", mainJS, dir, name)
|
||||
}
|
||||
|
||||
func launchProcess(bin, arg, dir, name string) (*sidecarProcess, error) {
|
||||
nodePath := bin
|
||||
if bin == "node" {
|
||||
if p := os.Getenv("NODE_PATH"); p != "" {
|
||||
nodePath = filepath.Join(p, "node")
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.Command(nodePath, arg, dir)
|
||||
cmd.Dir = dir
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stdin pipe: %w", err)
|
||||
}
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("start %s: %w", name, err)
|
||||
}
|
||||
|
||||
sp := newSidecarProcess(name, dir, cmd, bufio.NewWriter(stdin), stdout)
|
||||
|
||||
if err := sp.waitReady(); err != nil {
|
||||
sp.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return sp, nil
|
||||
}
|
||||
|
||||
func (s *sidecarProcess) waitReady() error {
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := s.call("ping", nil)
|
||||
done <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
case <-time.After(5 * time.Second):
|
||||
return fmt.Errorf("sidecar %s ping timeout", s.name)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sidecarProcess) call(method string, params interface{}) ([]byte, error) {
|
||||
ch := make(chan []byte, 1)
|
||||
|
||||
s.mu.Lock()
|
||||
if s.closed || s.stopped {
|
||||
s.mu.Unlock()
|
||||
return nil, fmt.Errorf("sidecar %s closed", s.name)
|
||||
}
|
||||
s.nextID++
|
||||
id := s.nextID
|
||||
s.pending[id] = ch
|
||||
|
||||
req := sidecarRequest{
|
||||
JSONRPC: "2.0",
|
||||
ID: id,
|
||||
Method: method,
|
||||
Params: params,
|
||||
}
|
||||
data, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
delete(s.pending, id)
|
||||
s.mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
if _, err := s.stdin.Write(data); err != nil {
|
||||
delete(s.pending, id)
|
||||
s.mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
if _, err := s.stdin.Write([]byte("\n")); err != nil {
|
||||
delete(s.pending, id)
|
||||
s.mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
if err := s.stdin.Flush(); err != nil {
|
||||
delete(s.pending, id)
|
||||
s.mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
select {
|
||||
case raw := <-ch:
|
||||
if raw == nil {
|
||||
return nil, fmt.Errorf("sidecar %s error: nil response", s.name)
|
||||
}
|
||||
var resp sidecarResponse
|
||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||
return nil, fmt.Errorf("sidecar %s unmarshal: %w", s.name, err)
|
||||
}
|
||||
if resp.Error != nil {
|
||||
return nil, fmt.Errorf("sidecar %s error: %s", s.name, resp.Error.Message)
|
||||
}
|
||||
if resp.Result == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return []byte(*resp.Result), nil
|
||||
case <-time.After(30 * time.Second):
|
||||
s.mu.Lock()
|
||||
delete(s.pending, id)
|
||||
s.mu.Unlock()
|
||||
return nil, fmt.Errorf("sidecar %s call %s timeout", s.name, method)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sidecarProcess) ListTools() ([]OCPTool, error) {
|
||||
data, err := s.call("tools/list", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if data == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var result struct {
|
||||
Tools []OCPTool `json:"tools"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Tools, nil
|
||||
}
|
||||
|
||||
func (s *sidecarProcess) CallTool(name string, args map[string]interface{}) (string, error) {
|
||||
data, err := s.call("tools/call", map[string]interface{}{
|
||||
"name": name,
|
||||
"arguments": args,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if data == nil {
|
||||
return "", nil
|
||||
}
|
||||
var result OCCallResult
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var sb string
|
||||
for _, c := range result.Content {
|
||||
if c.Type == "text" {
|
||||
sb += c.Text
|
||||
}
|
||||
}
|
||||
return sb, nil
|
||||
}
|
||||
|
||||
func (s *sidecarProcess) CallProvider(providerType string, args map[string]interface{}) (string, error) {
|
||||
data, err := s.call("provider/call", map[string]interface{}{
|
||||
"type": providerType,
|
||||
"action": "execute",
|
||||
"args": args,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if data == nil {
|
||||
return "", nil
|
||||
}
|
||||
var result OCCallResult
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var sb string
|
||||
for _, c := range result.Content {
|
||||
if c.Type == "text" {
|
||||
sb += c.Text
|
||||
}
|
||||
}
|
||||
return sb, nil
|
||||
}
|
||||
|
||||
func (s *sidecarProcess) Close() {
|
||||
s.mu.Lock()
|
||||
if s.closed || s.stopped {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.stopped = true
|
||||
s.mu.Unlock()
|
||||
|
||||
// 先杀进程(关闭 stdout pipe),然后 reader 的 Scan() 会退出
|
||||
if s.cmd != nil && s.cmd.Process != nil {
|
||||
s.cmd.Process.Kill()
|
||||
s.cmd.Wait()
|
||||
}
|
||||
|
||||
// 等 reader 循环结束
|
||||
s.readerWg.Wait()
|
||||
|
||||
s.mu.Lock()
|
||||
for id, ch := range s.pending {
|
||||
close(ch)
|
||||
delete(s.pending, id)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
log.Printf("[clawhubadapter] sidecar %s stopped", s.name)
|
||||
}
|
||||
379
internal/plugins/clawhubadapter/sidecar_test.go
Normal file
379
internal/plugins/clawhubadapter/sidecar_test.go
Normal file
@ -0,0 +1,379 @@
|
||||
package clawhubadapter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
func TestLaunchSidecarNoMainJS(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
sp, err := launchSidecar(tmpDir, "nonexistent")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if sp != nil {
|
||||
t.Fatal("expected nil for dir without main.js")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchSidecarAndListTools(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
src := filepath.Join("testdata", "echoplugin", "main.js")
|
||||
data, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
t.Fatalf("read test plugin: %v", err)
|
||||
}
|
||||
dst := filepath.Join(tmpDir, "main.js")
|
||||
if err := os.WriteFile(dst, data, 0755); err != nil {
|
||||
t.Fatalf("write test plugin: %v", err)
|
||||
}
|
||||
|
||||
sp, err := launchSidecar(tmpDir, "echoplugin")
|
||||
if err != nil {
|
||||
t.Fatalf("launch sidecar: %v", err)
|
||||
}
|
||||
defer sp.Close()
|
||||
|
||||
tools, err := sp.ListTools()
|
||||
if err != nil {
|
||||
t.Fatalf("list tools: %v", err)
|
||||
}
|
||||
|
||||
if len(tools) == 0 {
|
||||
t.Fatal("expected at least one tool")
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, tool := range tools {
|
||||
if tool.Name == "echo" {
|
||||
found = true
|
||||
if tool.Description == "" {
|
||||
t.Error("expected non-empty description for echo tool")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected 'echo' tool in list")
|
||||
}
|
||||
t.Logf("tools: %+v", tools)
|
||||
}
|
||||
|
||||
func TestCallEchoTool(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
src := filepath.Join("testdata", "echoplugin", "main.js")
|
||||
data, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
t.Fatalf("read test plugin: %v", err)
|
||||
}
|
||||
dst := filepath.Join(tmpDir, "main.js")
|
||||
if err := os.WriteFile(dst, data, 0755); err != nil {
|
||||
t.Fatalf("write test plugin: %v", err)
|
||||
}
|
||||
|
||||
sp, err := launchSidecar(tmpDir, "echoplugin")
|
||||
if err != nil {
|
||||
t.Fatalf("launch sidecar: %v", err)
|
||||
}
|
||||
defer sp.Close()
|
||||
|
||||
result, err := sp.CallTool("echo", map[string]interface{}{
|
||||
"text": "hello world",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("call echo tool: %v", err)
|
||||
}
|
||||
|
||||
expected := "Echo: hello world"
|
||||
if result != expected {
|
||||
t.Fatalf("expected %q, got %q", expected, result)
|
||||
}
|
||||
t.Logf("echo result: %s", result)
|
||||
}
|
||||
|
||||
func TestCallAddTool(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
src := filepath.Join("testdata", "echoplugin", "main.js")
|
||||
data, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
t.Fatalf("read test plugin: %v", err)
|
||||
}
|
||||
dst := filepath.Join(tmpDir, "main.js")
|
||||
if err := os.WriteFile(dst, data, 0755); err != nil {
|
||||
t.Fatalf("write test plugin: %v", err)
|
||||
}
|
||||
|
||||
sp, err := launchSidecar(tmpDir, "echoplugin")
|
||||
if err != nil {
|
||||
t.Fatalf("launch sidecar: %v", err)
|
||||
}
|
||||
defer sp.Close()
|
||||
|
||||
result, err := sp.CallTool("add", map[string]interface{}{
|
||||
"a": 3.0,
|
||||
"b": 4.0,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("call add tool: %v", err)
|
||||
}
|
||||
|
||||
expected := "7"
|
||||
if result != expected {
|
||||
t.Fatalf("expected %q, got %q", expected, result)
|
||||
}
|
||||
t.Logf("add result: %s", result)
|
||||
}
|
||||
|
||||
func TestCallNonexistentTool(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
src := filepath.Join("testdata", "echoplugin", "main.js")
|
||||
data, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
t.Fatalf("read test plugin: %v", err)
|
||||
}
|
||||
dst := filepath.Join(tmpDir, "main.js")
|
||||
if err := os.WriteFile(dst, data, 0755); err != nil {
|
||||
t.Fatalf("write test plugin: %v", err)
|
||||
}
|
||||
|
||||
sp, err := launchSidecar(tmpDir, "echoplugin")
|
||||
if err != nil {
|
||||
t.Fatalf("launch sidecar: %v", err)
|
||||
}
|
||||
defer sp.Close()
|
||||
|
||||
_, err = sp.CallTool("nonexistent", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent tool")
|
||||
}
|
||||
t.Logf("expected error: %v", err)
|
||||
}
|
||||
|
||||
func TestConcurrentCalls(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
src := filepath.Join("testdata", "echoplugin", "main.js")
|
||||
data, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
t.Fatalf("read test plugin: %v", err)
|
||||
}
|
||||
dst := filepath.Join(tmpDir, "main.js")
|
||||
if err := os.WriteFile(dst, data, 0755); err != nil {
|
||||
t.Fatalf("write test plugin: %v", err)
|
||||
}
|
||||
|
||||
sp, err := launchSidecar(tmpDir, "echoplugin")
|
||||
if err != nil {
|
||||
t.Fatalf("launch sidecar: %v", err)
|
||||
}
|
||||
defer sp.Close()
|
||||
|
||||
done := make(chan bool, 5)
|
||||
for i := 0; i < 5; i++ {
|
||||
go func(n int) {
|
||||
result, err := sp.CallTool("add", map[string]interface{}{
|
||||
"a": float64(n),
|
||||
"b": float64(n * 2),
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("concurrent call %d: %v", n, err)
|
||||
}
|
||||
if result != fmt.Sprintf("%d", n + n*2) {
|
||||
t.Errorf("call %d: expected %d, got %s", n, n + n*2, result)
|
||||
}
|
||||
done <- true
|
||||
}(i)
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Simulator + OpenClaw plugin tests ----
|
||||
|
||||
func launchSimulator(t *testing.T, pluginDir, name string) *sidecarProcess {
|
||||
t.Helper()
|
||||
simPath, err := filepath.Abs(filepath.Join("simulator", "main.js"))
|
||||
if err != nil {
|
||||
t.Fatalf("abs simulator path: %v", err)
|
||||
}
|
||||
sp, err := launchProcess("node", simPath, pluginDir, name)
|
||||
if err != nil {
|
||||
t.Fatalf("launch simulator for %s: %v", name, err)
|
||||
}
|
||||
return sp
|
||||
}
|
||||
|
||||
func TestSimulatorWithOCSimple(t *testing.T) {
|
||||
pluginDir, err := filepath.Abs(filepath.Join("testdata", "oc-simple"))
|
||||
if err != nil {
|
||||
t.Fatalf("abs testdata: %v", err)
|
||||
}
|
||||
sp := launchSimulator(t, pluginDir, "oc-simple")
|
||||
defer sp.Close()
|
||||
|
||||
tools, err := sp.ListTools()
|
||||
if err != nil {
|
||||
t.Fatalf("list tools: %v", err)
|
||||
}
|
||||
if len(tools) != 2 {
|
||||
t.Fatalf("expected 2 tools, got %d: %+v", len(tools), tools)
|
||||
}
|
||||
|
||||
found := map[string]bool{"greet": false, "ping": false}
|
||||
for _, tool := range tools {
|
||||
found[tool.Name] = true
|
||||
}
|
||||
if !found["greet"] || !found["ping"] {
|
||||
t.Fatalf("expected greet and ping tools, got %+v", tools)
|
||||
}
|
||||
|
||||
result, err := sp.CallTool("greet", map[string]interface{}{"name": "Test"})
|
||||
if err != nil {
|
||||
t.Fatalf("call greet: %v", err)
|
||||
}
|
||||
if result != "Hello, Test!" {
|
||||
t.Fatalf("expected 'Hello, Test!', got %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulatorWithOCPackage(t *testing.T) {
|
||||
pluginDir, err := filepath.Abs(filepath.Join("testdata", "oc-pkg"))
|
||||
if err != nil {
|
||||
t.Fatalf("abs testdata: %v", err)
|
||||
}
|
||||
sp := launchSimulator(t, pluginDir, "oc-pkg")
|
||||
defer sp.Close()
|
||||
|
||||
tools, err := sp.ListTools()
|
||||
if err != nil {
|
||||
t.Fatalf("list tools: %v", err)
|
||||
}
|
||||
if len(tools) != 2 {
|
||||
t.Fatalf("expected 2 tools, got %d: %+v", len(tools), tools)
|
||||
}
|
||||
|
||||
found := map[string]bool{"add": false, "info": false}
|
||||
for _, tool := range tools {
|
||||
found[tool.Name] = true
|
||||
if tool.Name == "add" {
|
||||
if tool.InputSchema == nil {
|
||||
t.Error("add tool should have inputSchema")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found["add"] || !found["info"] {
|
||||
t.Fatalf("expected add and info tools, got %+v", tools)
|
||||
}
|
||||
|
||||
result, err := sp.CallTool("add", map[string]interface{}{"a": 10.0, "b": 20.0})
|
||||
if err != nil {
|
||||
t.Fatalf("call add: %v", err)
|
||||
}
|
||||
if result != "30" {
|
||||
t.Fatalf("expected '30', got %q", result)
|
||||
}
|
||||
|
||||
infoResult, err := sp.CallTool("info", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("call info: %v", err)
|
||||
}
|
||||
if infoResult == "" {
|
||||
t.Fatal("expected non-empty info result")
|
||||
}
|
||||
t.Logf("info result: %s", infoResult)
|
||||
}
|
||||
|
||||
func TestLoadOCPluginViaPluginStart(t *testing.T) {
|
||||
skillsDir := t.TempDir()
|
||||
|
||||
ocSimpleDir := filepath.Join(skillsDir, "oc-simple")
|
||||
if err := os.MkdirAll(ocSimpleDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
for _, name := range []string{"openclaw.plugin.json", "index.js"} {
|
||||
src := filepath.Join("testdata", "oc-simple", name)
|
||||
data, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(ocSimpleDir, name), data, 0644); err != nil {
|
||||
t.Fatalf("write %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
ocPkgDir := filepath.Join(skillsDir, "oc-pkg")
|
||||
if err := os.MkdirAll(filepath.Join(ocPkgDir, "lib"), 0755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
for _, name := range []string{"package.json", "lib/entry.js"} {
|
||||
src := filepath.Join("testdata", "oc-pkg", name)
|
||||
data, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
dst := filepath.Join(ocPkgDir, name)
|
||||
if err := os.WriteFile(dst, data, 0644); err != nil {
|
||||
t.Fatalf("write %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
SimulatorDir = filepath.Join(t.TempDir(), ".simulator")
|
||||
|
||||
p := New("openclaw", skillsDir)
|
||||
|
||||
var registeredTools []string
|
||||
registeredHandlers := make(map[string]sdk.ToolHandler)
|
||||
sdk := sdk.New("openclaw", sdk.SDKConfig{
|
||||
RegTool: func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
|
||||
registeredTools = append(registeredTools, name)
|
||||
registeredHandlers[name] = handler
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
if err := p.Start(sdk); err != nil {
|
||||
t.Fatalf("start plugin: %v", err)
|
||||
}
|
||||
defer p.Stop()
|
||||
|
||||
expected := []string{"oc-simple_greet", "oc-simple_ping", "oc-pkg_add", "oc-pkg_info"}
|
||||
for _, exp := range expected {
|
||||
found := false
|
||||
for _, name := range registeredTools {
|
||||
if name == exp {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected tool %q not registered. got: %v", exp, registeredTools)
|
||||
}
|
||||
}
|
||||
|
||||
handler, ok := registeredHandlers["oc-simple_greet"]
|
||||
if !ok {
|
||||
t.Fatal("greet handler not registered")
|
||||
}
|
||||
result, err := handler(map[string]interface{}{"name": "OpenClaw"})
|
||||
if err != nil {
|
||||
t.Fatalf("exec greet: %v", err)
|
||||
}
|
||||
if result != "Hello, OpenClaw!" {
|
||||
t.Fatalf("expected 'Hello, OpenClaw!', got %v", result)
|
||||
}
|
||||
|
||||
handler, ok = registeredHandlers["oc-pkg_add"]
|
||||
if !ok {
|
||||
t.Fatal("add handler not registered")
|
||||
}
|
||||
result, err = handler(map[string]interface{}{"a": 7.0, "b": 8.0})
|
||||
if err != nil {
|
||||
t.Fatalf("exec add: %v", err)
|
||||
}
|
||||
if result != "15" {
|
||||
t.Fatalf("expected '15', got %v", result)
|
||||
}
|
||||
}
|
||||
371
internal/plugins/clawhubadapter/simulator/main.js
Normal file
371
internal/plugins/clawhubadapter/simulator/main.js
Normal file
@ -0,0 +1,371 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// ---- 工具函数 ----
|
||||
function writeJSON(obj) {
|
||||
process.stdout.write(JSON.stringify(obj) + '\n');
|
||||
}
|
||||
|
||||
function sendError(id, code, message) {
|
||||
writeJSON({ jsonrpc: '2.0', id, error: { code, message } });
|
||||
}
|
||||
|
||||
function readJSON(file) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function notify(method, params) {
|
||||
writeJSON({ jsonrpc: '2.0', method, params });
|
||||
}
|
||||
|
||||
// ---- 解析插件入口 ----
|
||||
const pluginDir = path.resolve(process.argv[2]);
|
||||
if (!pluginDir) {
|
||||
process.stderr.write('[simulator] usage: node main.js <plugin-dir>\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pkgPath = path.join(pluginDir, 'package.json');
|
||||
const pkg = readJSON(pkgPath);
|
||||
let entryPath = null;
|
||||
|
||||
if (pkg && pkg.openclaw) {
|
||||
let raw = pkg.openclaw.runtimeExtensions || pkg.openclaw.extensions;
|
||||
if (typeof raw === 'string') raw = [raw];
|
||||
if (Array.isArray(raw) && raw.length > 0) {
|
||||
for (const ext of raw) {
|
||||
let ep = path.resolve(pluginDir, ext);
|
||||
if (ep.endsWith('.ts')) {
|
||||
const jsEp = ep.replace(/\.ts$/, '.js');
|
||||
if (fs.existsSync(jsEp)) { entryPath = jsEp; break; }
|
||||
}
|
||||
if (fs.existsSync(ep)) { entryPath = ep; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!entryPath) {
|
||||
const manifest = readJSON(path.join(pluginDir, 'openclaw.plugin.json'));
|
||||
if (manifest) {
|
||||
const ep = manifest.entry || manifest.main || 'index.js';
|
||||
entryPath = path.join(pluginDir, ep);
|
||||
}
|
||||
}
|
||||
|
||||
if (!entryPath) {
|
||||
entryPath = path.join(pluginDir, 'index.js');
|
||||
}
|
||||
|
||||
if (!fs.existsSync(entryPath)) {
|
||||
process.stderr.write(`[simulator] entry not found: ${entryPath}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ---- 加载插件 ----
|
||||
let pluginEntry;
|
||||
try {
|
||||
pluginEntry = require(entryPath);
|
||||
} catch (e) {
|
||||
process.stderr.write(`[simulator] load plugin: ${e.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const entry = pluginEntry.default || pluginEntry;
|
||||
|
||||
if (typeof entry !== 'object' || typeof entry.register !== 'function') {
|
||||
process.stderr.write(`[simulator] plugin entry must export {default: {register(api)}}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ---- 注册工具(本地存储,供 tools/list 和 tools/call 用) ----
|
||||
const registeredTools = [];
|
||||
|
||||
// ---- Provider 存储(供 provider/call 用) ----
|
||||
const registeredProviders = {}; // type -> { name, instance }
|
||||
|
||||
function registerTool(defOrFactory, opts) {
|
||||
if (typeof defOrFactory === 'function') {
|
||||
const toolCtx = {
|
||||
id: 'simulator',
|
||||
cwd: pluginDir,
|
||||
env: process.env,
|
||||
allow: ['*'],
|
||||
};
|
||||
const result = defOrFactory(toolCtx);
|
||||
const tools = Array.isArray(result) ? result : [result];
|
||||
for (const t of tools) {
|
||||
if (t && typeof t.execute === 'function') {
|
||||
registeredTools.push(t);
|
||||
notify('register', { type: 'tool', data: { name: t.name, description: t.description, parameters: t.parameters } });
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const def = defOrFactory;
|
||||
if (!def || !def.name) return;
|
||||
|
||||
registeredTools.push({
|
||||
name: def.name,
|
||||
label: def.label || def.name,
|
||||
description: def.description || '',
|
||||
parameters: def.parameters || { type: 'object', properties: {} },
|
||||
execute: typeof def.execute === 'function' ? def.execute : undefined,
|
||||
});
|
||||
notify('register', { type: 'tool', data: { name: def.name, label: def.label, description: def.description, parameters: def.parameters } });
|
||||
}
|
||||
|
||||
// ---- 构造完整的 OpenClawPluginApi ----
|
||||
const api = {
|
||||
id: entry.id || 'unknown',
|
||||
name: entry.name || 'Unknown',
|
||||
version: entry.version,
|
||||
description: entry.description,
|
||||
source: pluginDir,
|
||||
rootDir: pluginDir,
|
||||
config: {},
|
||||
pluginConfig: {},
|
||||
registrationMode: 'full',
|
||||
logger: {
|
||||
debug: (...args) => {},
|
||||
info: (...args) => {},
|
||||
warn: (...args) => {},
|
||||
error: (...args) => process.stderr.write(`[plugin] ${args.join(' ')}\n`),
|
||||
},
|
||||
resolvePath: (p) => path.resolve(pluginDir, p),
|
||||
|
||||
// ---- 工具注册 ----
|
||||
registerTool,
|
||||
|
||||
// ---- Provider 注册(同时存储实例,支持 provider/call) ----
|
||||
registerProvider: (provider) => {
|
||||
if (provider && provider.id) registeredProviders['llm'] = { name: provider.id, instance: provider };
|
||||
notify('register', { type: 'provider', data: { name: provider?.id || provider?.name, description: provider?.description } });
|
||||
},
|
||||
registerEmbeddingProvider: (p) => {
|
||||
if (p) registeredProviders['embedding'] = { name: p.name, instance: p };
|
||||
notify('register', { type: 'embedding_provider', data: { name: p?.name } });
|
||||
},
|
||||
registerSpeechProvider: (p) => {
|
||||
if (p) registeredProviders['speech'] = { name: p.name, instance: p };
|
||||
notify('register', { type: 'speech_provider', data: { name: p?.name } });
|
||||
},
|
||||
registerRealtimeTranscriptionProvider: (p) => {
|
||||
if (p) registeredProviders['realtime_transcription'] = { name: p.name, instance: p };
|
||||
notify('register', { type: 'realtime_transcription_provider', data: { name: p?.name } });
|
||||
},
|
||||
registerRealtimeVoiceProvider: (p) => {
|
||||
if (p) registeredProviders['realtime_voice'] = { name: p.name, instance: p };
|
||||
notify('register', { type: 'realtime_voice_provider', data: { name: p?.name } });
|
||||
},
|
||||
registerMediaUnderstandingProvider: (p) => {
|
||||
if (p) registeredProviders['media_understanding'] = { name: p.name, instance: p };
|
||||
notify('register', { type: 'media_understanding_provider', data: { name: p?.name } });
|
||||
},
|
||||
registerImageGenerationProvider: (p) => {
|
||||
if (p) registeredProviders['image_generation'] = { name: p.name, instance: p };
|
||||
notify('register', { type: 'image_generation_provider', data: { name: p?.name } });
|
||||
},
|
||||
registerMusicGenerationProvider: (p) => {
|
||||
if (p) registeredProviders['music_generation'] = { name: p.name, instance: p };
|
||||
notify('register', { type: 'music_generation_provider', data: { name: p?.name } });
|
||||
},
|
||||
registerVideoGenerationProvider: (p) => {
|
||||
if (p) registeredProviders['video_generation'] = { name: p.name, instance: p };
|
||||
notify('register', { type: 'video_generation_provider', data: { name: p?.name } });
|
||||
},
|
||||
registerWebFetchProvider: (p) => {
|
||||
if (p) registeredProviders['web_fetch'] = { name: p.name, instance: p };
|
||||
notify('register', { type: 'web_fetch_provider', data: { name: p?.name } });
|
||||
},
|
||||
registerWebSearchProvider: (p) => {
|
||||
if (p) registeredProviders['web_search'] = { name: p.name, instance: p };
|
||||
notify('register', { type: 'web_search_provider', data: { name: p?.name } });
|
||||
},
|
||||
registerMemoryEmbeddingProvider: (p) => {
|
||||
if (p) registeredProviders['memory_embedding'] = { name: p.name, instance: p };
|
||||
notify('register', { type: 'memory_embedding_provider', data: { name: p?.name } });
|
||||
},
|
||||
|
||||
// ---- Channel 注册 ----
|
||||
registerChannel: (ch) => notify('register', { type: 'channel', data: { name: ch.name, type: ch.type } }),
|
||||
|
||||
// ---- Hook / 生命周期 ----
|
||||
registerHook: (hook) => notify('register', { type: 'hook', data: { name: hook.name, event: hook.event } }),
|
||||
registerRuntimeLifecycle: (lc) => notify('register', { type: 'runtime_lifecycle', data: { name: lc.name } }),
|
||||
|
||||
// ---- HTTP 路由 ----
|
||||
registerHttpRoute: (route) => notify('register', { type: 'http_route', data: { path: route.path, method: route.method } }),
|
||||
|
||||
// ---- CLI 命令 ----
|
||||
registerCommand: (cmd) => notify('register', { type: 'command', data: { name: cmd.name, description: cmd.description } }),
|
||||
registerCli: (cli) => notify('register', { type: 'cli', data: { name: cli.name } }),
|
||||
registerCliBackend: (cb) => notify('register', { type: 'cli_backend', data: { name: cb.name } }),
|
||||
registerNodeCliFeature: (f) => notify('register', { type: 'node_cli_feature', data: { name: f.name } }),
|
||||
|
||||
// ---- Service ----
|
||||
registerService: (svc) => notify('register', { type: 'service', data: { name: svc.name } }),
|
||||
|
||||
// ---- Agent 相关 ----
|
||||
registerAgentHarness: (h) => notify('register', { type: 'agent_harness', data: { name: h.name } }),
|
||||
registerAgentToolResultMiddleware: (m) => notify('register', { type: 'agent_tool_result_middleware', data: {} }),
|
||||
registerInteractiveHandler: (h) => notify('register', { type: 'interactive_handler', data: { name: h.name } }),
|
||||
|
||||
// ---- Gateway ----
|
||||
registerGatewayMethod: (gm) => notify('register', { type: 'gateway_method', data: { name: gm.name } }),
|
||||
registerGatewayDiscoveryService: (gs) => notify('register', { type: 'gateway_discovery_service', data: { name: gs.name } }),
|
||||
|
||||
// ---- Trust & Metadata ----
|
||||
registerTrustedToolPolicy: (p) => notify('register', { type: 'trusted_tool_policy', data: { name: p.name } }),
|
||||
registerToolMetadata: (m) => notify('register', { type: 'tool_metadata', data: { name: m.name } }),
|
||||
|
||||
// ---- Context Engine ----
|
||||
registerContextEngine: (ce) => notify('register', { type: 'context_engine', data: { name: ce.name } }),
|
||||
|
||||
// ---- Memory 子系统 ----
|
||||
registerMemoryCapability: (mc) => notify('register', { type: 'memory_capability', data: { name: mc.name } }),
|
||||
registerMemoryPromptSection: (ps) => notify('register', { type: 'memory_prompt_section', data: { name: ps.name } }),
|
||||
registerMemoryFlushPlan: (fp) => notify('register', { type: 'memory_flush_plan', data: { name: fp.name } }),
|
||||
registerMemoryRuntime: (mr) => notify('register', { type: 'memory_runtime', data: { name: mr.name } }),
|
||||
registerMemoryPromptSupplement: (ps) => notify('register', { type: 'memory_prompt_supplement', data: { name: ps.name } }),
|
||||
registerMemoryCorpusSupplement: (cs) => notify('register', { type: 'memory_corpus_supplement', data: { name: cs.name } }),
|
||||
|
||||
// ---- 会话相关 ----
|
||||
on: (event, handler) => notify('register', { type: 'session_event', data: { event } }),
|
||||
onConversationBindingResolved: (handler) => notify('register', { type: 'conversation_binding_resolved', data: {} }),
|
||||
|
||||
session: {
|
||||
state: { registerSessionExtension: (se) => notify('register', { type: 'session_extension', data: { name: se.name } }) },
|
||||
workflow: {
|
||||
enqueueNextTurnInjection: () => {},
|
||||
registerSessionSchedulerJob: (job) => notify('register', { type: 'session_scheduler_job', data: { name: job.name } }),
|
||||
sendSessionAttachment: () => {},
|
||||
scheduleSessionTurn: () => {},
|
||||
unscheduleSessionTurnsByTag: () => {},
|
||||
},
|
||||
controls: {
|
||||
registerControlUiDescriptor: (d) => notify('register', { type: 'control_ui_descriptor', data: { name: d.name } }),
|
||||
registerSessionAction: (a) => notify('register', { type: 'session_action', data: { name: a.name } }),
|
||||
},
|
||||
},
|
||||
|
||||
agent: {
|
||||
events: {
|
||||
registerAgentEventSubscription: (sub) => notify('register', { type: 'agent_event_subscription', data: { event: sub.event } }),
|
||||
emitAgentEvent: (event, data) => notify('agent_event', { event, data }),
|
||||
},
|
||||
},
|
||||
|
||||
lifecycle: { registerRuntimeLifecycle: (lc) => notify('register', { type: 'lifecycle', data: { name: lc.name } }) },
|
||||
|
||||
runContext: {
|
||||
setRunContext: () => {},
|
||||
getRunContext: () => ({}),
|
||||
clearRunContext: () => {},
|
||||
},
|
||||
|
||||
runtime: {},
|
||||
};
|
||||
|
||||
// ---- 注册插件 ----
|
||||
entry.register(api);
|
||||
|
||||
// ---- JSON-RPC 协议处理 ----
|
||||
const readline = require('readline');
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
terminal: false,
|
||||
});
|
||||
|
||||
rl.on('line', async (line) => {
|
||||
let req;
|
||||
try {
|
||||
req = JSON.parse(line);
|
||||
} catch {
|
||||
sendError(null, -32700, 'Parse error');
|
||||
return;
|
||||
}
|
||||
|
||||
const id = req.id;
|
||||
const method = req.method;
|
||||
|
||||
if (method === 'ping') {
|
||||
writeJSON({ jsonrpc: '2.0', id, result: { status: 'ok' } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'tools/list') {
|
||||
const tools = registeredTools.map(t => ({
|
||||
name: t.name,
|
||||
description: t.description || '',
|
||||
inputSchema: t.parameters || { type: 'object', properties: {} },
|
||||
}));
|
||||
writeJSON({ jsonrpc: '2.0', id, result: { tools } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'tools/call') {
|
||||
const params = req.params || {};
|
||||
const toolName = params.name;
|
||||
const args = params.arguments || {};
|
||||
|
||||
const tool = registeredTools.find(t => t.name === toolName);
|
||||
if (!tool) {
|
||||
sendError(id, -32601, `Tool not found: ${toolName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof tool.execute !== 'function') {
|
||||
sendError(id, -32603, `Tool ${toolName} has no execute function`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await tool.execute('sim-call-1', args, undefined, undefined);
|
||||
if (result && typeof result === 'object' && Array.isArray(result.content)) {
|
||||
writeJSON({ jsonrpc: '2.0', id, result });
|
||||
} else {
|
||||
const text = typeof result === 'string' ? result : JSON.stringify(result);
|
||||
writeJSON({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text }] } });
|
||||
}
|
||||
} catch (e) {
|
||||
sendError(id, -32603, e.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- Provider 调用 ----
|
||||
if (method === 'provider/call') {
|
||||
const { type, action, args } = req.params || {};
|
||||
if (!type) { sendError(id, -32602, 'type required'); return; }
|
||||
|
||||
const provider = registeredProviders[type];
|
||||
if (!provider) { sendError(id, -32601, `Provider not found: ${type}`); return; }
|
||||
|
||||
const methodName = action || 'execute';
|
||||
if (typeof provider.instance[methodName] !== 'function') {
|
||||
sendError(id, -32603, `Provider ${type} has no method ${methodName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await provider.instance[methodName](args);
|
||||
if (result && typeof result === 'object' && Array.isArray(result.content)) {
|
||||
writeJSON({ jsonrpc: '2.0', id, result });
|
||||
} else {
|
||||
const text = typeof result === 'string' ? result : JSON.stringify(result);
|
||||
writeJSON({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text }] } });
|
||||
}
|
||||
} catch (e) {
|
||||
sendError(id, -32603, e.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
sendError(id, -32601, `Method not found: ${method}`);
|
||||
});
|
||||
106
internal/plugins/clawhubadapter/testdata/echoplugin/main.js
vendored
Normal file
106
internal/plugins/clawhubadapter/testdata/echoplugin/main.js
vendored
Normal file
@ -0,0 +1,106 @@
|
||||
const readline = require('readline');
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
terminal: false
|
||||
});
|
||||
|
||||
const tools = [
|
||||
{
|
||||
name: 'echo',
|
||||
description: 'Echo back the input text',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
text: { type: 'string', description: 'Text to echo' }
|
||||
},
|
||||
required: ['text']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'add',
|
||||
description: 'Add two numbers',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
a: { type: 'number', description: 'First number' },
|
||||
b: { type: 'number', description: 'Second number' }
|
||||
},
|
||||
required: ['a', 'b']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'ping',
|
||||
description: 'Health check',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
rl.on('line', (line) => {
|
||||
let req;
|
||||
try {
|
||||
req = JSON.parse(line);
|
||||
} catch (e) {
|
||||
sendError(null, -32700, 'Parse error');
|
||||
return;
|
||||
}
|
||||
|
||||
const id = req.id;
|
||||
const method = req.method;
|
||||
|
||||
if (method === 'ping') {
|
||||
sendResult(id, { status: 'ok' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'tools/list') {
|
||||
sendResult(id, { tools });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'tools/call') {
|
||||
const params = req.params || {};
|
||||
const toolName = params.name;
|
||||
const args = params.arguments || {};
|
||||
|
||||
const tool = tools.find(t => t.name === toolName);
|
||||
if (!tool) {
|
||||
sendError(id, -32601, `Tool not found: ${toolName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let result;
|
||||
switch (toolName) {
|
||||
case 'echo':
|
||||
result = { content: [{ type: 'text', text: `Echo: ${args.text || ''}` }] };
|
||||
break;
|
||||
case 'add':
|
||||
const sum = (Number(args.a) || 0) + (Number(args.b) || 0);
|
||||
result = { content: [{ type: 'text', text: `${sum}` }] };
|
||||
break;
|
||||
default:
|
||||
sendError(id, -32601, `Not implemented: ${toolName}`);
|
||||
return;
|
||||
}
|
||||
sendResult(id, result);
|
||||
return;
|
||||
}
|
||||
|
||||
sendError(id, -32601, `Method not found: ${method}`);
|
||||
});
|
||||
|
||||
function sendResult(id, result) {
|
||||
const resp = { jsonrpc: '2.0', id, result };
|
||||
process.stdout.write(JSON.stringify(resp) + '\n');
|
||||
}
|
||||
|
||||
function sendError(id, code, message) {
|
||||
const resp = { jsonrpc: '2.0', id, error: { code, message } };
|
||||
process.stdout.write(JSON.stringify(resp) + '\n');
|
||||
}
|
||||
|
||||
// Sidecar is ready - no startup message needed
|
||||
33
internal/plugins/clawhubadapter/testdata/oc-pkg/lib/entry.js
vendored
Normal file
33
internal/plugins/clawhubadapter/testdata/oc-pkg/lib/entry.js
vendored
Normal file
@ -0,0 +1,33 @@
|
||||
module.exports = {
|
||||
default: {
|
||||
id: 'oc-pkg',
|
||||
name: 'OC Package',
|
||||
version: '2.0.0',
|
||||
description: 'Test plugin discovered via package.json extensions',
|
||||
register(api) {
|
||||
api.registerTool({
|
||||
name: 'add',
|
||||
description: 'Add two numbers',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
a: { type: 'number', description: 'First number' },
|
||||
b: { type: 'number', description: 'Second number' },
|
||||
},
|
||||
required: ['a', 'b'],
|
||||
},
|
||||
execute(id, params) {
|
||||
return String(params.a + params.b);
|
||||
},
|
||||
});
|
||||
|
||||
api.registerTool({
|
||||
name: 'info',
|
||||
description: 'Return plugin info',
|
||||
execute(id, params) {
|
||||
return JSON.stringify({ id: this.id, name: this.name, version: this.version });
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
8
internal/plugins/clawhubadapter/testdata/oc-pkg/package.json
vendored
Normal file
8
internal/plugins/clawhubadapter/testdata/oc-pkg/package.json
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "oc-pkg",
|
||||
"version": "2.0.0",
|
||||
"description": "OC plugin with package.json entry discovery",
|
||||
"openclaw": {
|
||||
"extensions": ["lib/entry.js"]
|
||||
}
|
||||
}
|
||||
31
internal/plugins/clawhubadapter/testdata/oc-simple/index.js
vendored
Normal file
31
internal/plugins/clawhubadapter/testdata/oc-simple/index.js
vendored
Normal file
@ -0,0 +1,31 @@
|
||||
module.exports = {
|
||||
default: {
|
||||
id: 'oc-simple',
|
||||
name: 'OC Simple',
|
||||
version: '1.0.0',
|
||||
description: 'Simple test plugin for OpenClaw simulator',
|
||||
register(api) {
|
||||
api.registerTool({
|
||||
name: 'greet',
|
||||
description: 'Greet someone',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Name to greet' },
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
execute(id, params) {
|
||||
return `Hello, ${params.name}!`;
|
||||
},
|
||||
});
|
||||
api.registerTool({
|
||||
name: 'ping',
|
||||
description: 'Health check ping',
|
||||
execute(id, params) {
|
||||
return 'pong';
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
6
internal/plugins/clawhubadapter/testdata/oc-simple/openclaw.plugin.json
vendored
Normal file
6
internal/plugins/clawhubadapter/testdata/oc-simple/openclaw.plugin.json
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "oc-simple",
|
||||
"version": "1.0.0",
|
||||
"entry": "index.js",
|
||||
"description": "Simple OpenClaw test plugin"
|
||||
}
|
||||
Reference in New Issue
Block a user