mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-08 00:25:05 +00:00
feat: complete HomeAgent architecture v2
- IO abstraction layer with OutputChannel routing and capability validation - Three-layer memory (Context-Document-Graph) with TF-IDF relevance pruning - OneBot V11 QQ protocol plugin with Reverse WebSocket client - Plugin system with hot-reload (SKILL.md + native factories) - Knowledge system with TF-IDF vector indexing - Personality system (personal.md) - Text memory (JSONL with rotation) - Change tracker (overlayfs) with rollback - Lua adapter VM - Design document (DESIGN.md) Module: gitcode.com/JianFeeeee/HomeAgent
This commit is contained in:
899
internal/plugin/plugin.go
Normal file
899
internal/plugin/plugin.go
Normal file
@ -0,0 +1,899 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
type PluginType string
|
||||
|
||||
const (
|
||||
PluginTypeSKILL PluginType = "skill"
|
||||
PluginTypeNative PluginType = "native"
|
||||
)
|
||||
|
||||
// IOConfig 定义插件作为 IO 通道时的配置
|
||||
// 每个插件通过此配置声明自己的 I/O 端口
|
||||
type IOConfig struct {
|
||||
Type string `json:"type"` // "input" / "output" / "io"
|
||||
InputRoute string `json:"input_route"` // 输入源标识,如 "qq", "email"
|
||||
OutputRoute string `json:"output_route"` // 输出通道标识,默认等于 InputRoute
|
||||
OutputCaps []string `json:"output_caps"` // 支持的输出能力: "text","file","image","audio","structured"
|
||||
}
|
||||
|
||||
type Plugin interface {
|
||||
Name() string
|
||||
PluginType() PluginType
|
||||
Description() string
|
||||
Version() string
|
||||
Tools() []ToolDef
|
||||
Enabled() bool
|
||||
SetEnabled(bool)
|
||||
IOConfig() *IOConfig
|
||||
Device() agentIO.Device // 内嵌的 IO 设备,nil 表示纯技能插件
|
||||
}
|
||||
|
||||
// ToolDef 复用 IO 抽象层的定义,确保 Plugin 和 Device 使用同一类型
|
||||
type ToolDef = agentIO.ToolDef
|
||||
|
||||
type SKILLPlugin struct {
|
||||
mu sync.RWMutex
|
||||
name string
|
||||
description string
|
||||
version string
|
||||
author string
|
||||
enabled bool
|
||||
rawContent string
|
||||
sourceDir string
|
||||
toolDefs []ToolDef
|
||||
ioConfig *IOConfig
|
||||
}
|
||||
|
||||
func LoadSKILL(path string) (*SKILLPlugin, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat %s: %w", path, err)
|
||||
}
|
||||
|
||||
name := filepath.Base(path)
|
||||
p := &SKILLPlugin{
|
||||
name: name,
|
||||
sourceDir: path,
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
skillFile := filepath.Join(path, "SKILL.md")
|
||||
if data, err := os.ReadFile(skillFile); err == nil {
|
||||
p.rawContent = string(data)
|
||||
p.description = extractDescription(p.rawContent)
|
||||
p.version = extractField(p.rawContent, "version")
|
||||
p.author = extractField(p.rawContent, "author")
|
||||
p.ioConfig = extractIOConfig(p.rawContent)
|
||||
}
|
||||
|
||||
metaFile := filepath.Join(path, "skill.json")
|
||||
if data, err := os.ReadFile(metaFile); err == nil {
|
||||
var meta struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
IO *IOConfig `json:"io,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &meta); err == nil {
|
||||
if meta.Name != "" {
|
||||
p.name = meta.Name
|
||||
}
|
||||
if meta.Description != "" {
|
||||
p.description = meta.Description
|
||||
}
|
||||
if meta.Version != "" {
|
||||
p.version = meta.Version
|
||||
}
|
||||
if meta.Author != "" {
|
||||
p.author = meta.Author
|
||||
}
|
||||
if meta.IO != nil {
|
||||
p.ioConfig = meta.IO
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if filepath.Ext(path) == ".md" {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.rawContent = string(data)
|
||||
p.name = name[:len(name)-3]
|
||||
p.description = extractDescription(p.rawContent)
|
||||
p.ioConfig = extractIOConfig(p.rawContent)
|
||||
}
|
||||
|
||||
if p.rawContent != "" {
|
||||
p.toolDefs = extractToolDefs(p.rawContent)
|
||||
}
|
||||
|
||||
log.Printf("[plugin] loaded SKILL: %s v%s", p.name, p.version)
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (p *SKILLPlugin) Name() string { return p.name }
|
||||
func (p *SKILLPlugin) PluginType() PluginType { return PluginTypeSKILL }
|
||||
func (p *SKILLPlugin) Description() string { return p.description }
|
||||
func (p *SKILLPlugin) Version() string { return p.version }
|
||||
func (p *SKILLPlugin) Enabled() bool { p.mu.RLock(); defer p.mu.RUnlock(); return p.enabled }
|
||||
func (p *SKILLPlugin) SetEnabled(v bool) { p.mu.Lock(); defer p.mu.Unlock(); p.enabled = v }
|
||||
func (p *SKILLPlugin) Tools() []ToolDef { return p.toolDefs }
|
||||
func (p *SKILLPlugin) IOConfig() *IOConfig { return p.ioConfig }
|
||||
func (p *SKILLPlugin) Device() agentIO.Device { return nil } // SKILL 插件无原生 IO 设备
|
||||
func (p *SKILLPlugin) RawContent() string { return p.rawContent }
|
||||
|
||||
// NativeFactory 是内置插件构造器,用于需要原生 Go 实现的插件(如 OneBot QQ)
|
||||
// 返回 agentIO.Device 以直接注册到 IO 管理层
|
||||
type NativeFactory func(name string, config map[string]interface{}, iom *agentIO.IOManager) (agentIO.Device, error)
|
||||
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
plugins map[string]Plugin
|
||||
ioMgr *agentIO.IOManager
|
||||
factories map[string]NativeFactory // 名称匹配的插件使用原生实现
|
||||
}
|
||||
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{
|
||||
plugins: make(map[string]Plugin),
|
||||
factories: make(map[string]NativeFactory),
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterNative 注册内置原生插件工厂。当从 plugins/ 加载插件时,
|
||||
// 如果插件名称匹配已注册的工厂,优先使用原生设备注册。
|
||||
// 例如: r.RegisterNative("qq", onebot.NewDeviceFactory)
|
||||
func (r *Registry) RegisterNative(name string, factory NativeFactory) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.factories[name] = factory
|
||||
}
|
||||
|
||||
// SetIOManager 绑定 IO 管理器,启用 IO 设备自动注册
|
||||
func (r *Registry) SetIOManager(mgr *agentIO.IOManager) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.ioMgr = mgr
|
||||
}
|
||||
|
||||
func (r *Registry) Register(p Plugin) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.plugins[p.Name()] = p
|
||||
log.Printf("[plugin] registered: %s (%s)", p.Name(), p.PluginType())
|
||||
|
||||
// 注册内嵌 IO 设备到 IOManager
|
||||
if r.ioMgr != nil {
|
||||
dev := p.Device()
|
||||
if dev != nil {
|
||||
// 原生设备直接注册
|
||||
if err := r.ioMgr.RegisterDevice(dev); err != nil {
|
||||
log.Printf("[plugin] register native device %s: %v", p.Name(), err)
|
||||
return
|
||||
}
|
||||
} else if p.IOConfig() != nil {
|
||||
// 有 IO 配置但无原生设备 → 用 PluginDevice 包装
|
||||
dev = NewPluginDevice(p)
|
||||
if err := r.ioMgr.RegisterDevice(dev); err != nil {
|
||||
log.Printf("[plugin] register plugin device %s: %v", p.Name(), err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
return // 纯技能插件,无 IO 通道
|
||||
}
|
||||
|
||||
// 自动注册输出路由
|
||||
if cfg := p.IOConfig(); cfg != nil {
|
||||
log.Printf("[plugin] io device %s active (type=%s, caps=%v)",
|
||||
p.Name(), cfg.Type, cfg.OutputCaps)
|
||||
if cfg.InputRoute != "" {
|
||||
outputRoute := cfg.OutputRoute
|
||||
if outputRoute == "" {
|
||||
outputRoute = cfg.InputRoute
|
||||
}
|
||||
r.ioMgr.RegisterOutputRoute(cfg.InputRoute, outputRoute)
|
||||
log.Printf("[plugin] route: %s → %s", cfg.InputRoute, outputRoute)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// nativePlugin 包装原生 IO 设备为 Plugin 接口
|
||||
// Plugin 是容器,Device 是组件
|
||||
type nativePlugin struct {
|
||||
name string
|
||||
dev agentIO.Device
|
||||
cfg *IOConfig
|
||||
}
|
||||
|
||||
func (p *nativePlugin) Name() string { return p.name }
|
||||
func (p *nativePlugin) PluginType() PluginType { return PluginTypeNative }
|
||||
func (p *nativePlugin) Description() string { return p.dev.Description() }
|
||||
func (p *nativePlugin) Version() string { return "1.0.0" }
|
||||
func (p *nativePlugin) Tools() []ToolDef { return p.dev.Tools() }
|
||||
func (p *nativePlugin) Enabled() bool { return true }
|
||||
func (p *nativePlugin) SetEnabled(v bool) {}
|
||||
func (p *nativePlugin) IOConfig() *IOConfig { return p.cfg }
|
||||
func (p *nativePlugin) Device() agentIO.Device { return p.dev }
|
||||
|
||||
// Reload 原子化重载 plugins/ 目录:
|
||||
// 1. 扫描磁盘加载新插件,构造并启动新 IO 设备
|
||||
// 2. 原子替换 IOManager 的设备表与路由表
|
||||
// 3. 停止并清理旧设备
|
||||
// 全程无通信中断:旧设备持续服务直到路由切换完成
|
||||
func (r *Registry) Reload(dir string) (string, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "插件目录不存在", nil
|
||||
}
|
||||
return "", fmt.Errorf("扫描插件目录: %w", err)
|
||||
}
|
||||
|
||||
// 1. 扫描磁盘,并行加载新插件
|
||||
type loadedPlugin struct {
|
||||
name string
|
||||
p Plugin
|
||||
err error
|
||||
}
|
||||
var loaded []loadedPlugin
|
||||
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if !entry.IsDir() && !strings.HasSuffix(name, ".md") {
|
||||
continue
|
||||
}
|
||||
if entry.IsDir() {
|
||||
// 略过非插件目录(不含 SKILL.md)
|
||||
if _, err := os.Stat(filepath.Join(dir, name, "SKILL.md")); os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
name = name[:len(name)-3]
|
||||
}
|
||||
|
||||
r.mu.RLock()
|
||||
_, exists := r.plugins[name]
|
||||
r.mu.RUnlock()
|
||||
if exists {
|
||||
continue // 已存在,跳过(后续可用 diff 检测变更)
|
||||
}
|
||||
|
||||
r.mu.RLock()
|
||||
factory, hasFactory := r.factories[name]
|
||||
r.mu.RUnlock()
|
||||
|
||||
if hasFactory {
|
||||
dev, err := factory(name, r.configFor(name, dir), r.ioMgr)
|
||||
if err != nil {
|
||||
loaded = append(loaded, loadedPlugin{name: name, err: err})
|
||||
continue
|
||||
}
|
||||
np := &nativePlugin{name: name, dev: dev}
|
||||
np.cfg = extractIOConfig(r.readFile(filepath.Join(dir, name)))
|
||||
loaded = append(loaded, loadedPlugin{name: name, p: np})
|
||||
} else {
|
||||
p, err := LoadSKILL(filepath.Join(dir, entry.Name()))
|
||||
if err != nil {
|
||||
loaded = append(loaded, loadedPlugin{name: name, err: err})
|
||||
continue
|
||||
}
|
||||
loaded = append(loaded, loadedPlugin{name: name, p: p})
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 启动新设备的 IO 通道
|
||||
newDevices := make(map[string]agentIO.Device)
|
||||
newRoutes := make(map[string]string)
|
||||
for _, lp := range loaded {
|
||||
if lp.err != nil {
|
||||
log.Printf("[plugin] skip %s: %v", lp.name, lp.err)
|
||||
continue
|
||||
}
|
||||
dev := lp.p.Device()
|
||||
if dev == nil && lp.p.IOConfig() != nil {
|
||||
dev = NewPluginDevice(lp.p)
|
||||
}
|
||||
if dev != nil {
|
||||
dev.Start() // 新设备预先启动
|
||||
newDevices[lp.name] = dev
|
||||
if cfg := lp.p.IOConfig(); cfg != nil {
|
||||
if cfg.InputRoute != "" {
|
||||
out := cfg.OutputRoute
|
||||
if out == "" {
|
||||
out = cfg.InputRoute
|
||||
}
|
||||
newRoutes[cfg.InputRoute] = out
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 原子切换
|
||||
r.mu.Lock()
|
||||
var oldPlugins map[string]Plugin
|
||||
if r.ioMgr != nil {
|
||||
// 获取旧设备并原子替换
|
||||
oldDevices := r.ioMgr.AtomicSwapDevices(newDevices, newRoutes)
|
||||
// 停止旧设备
|
||||
for _, dev := range oldDevices {
|
||||
go dev.Stop()
|
||||
}
|
||||
} else {
|
||||
for _, dev := range newDevices {
|
||||
dev.Stop()
|
||||
}
|
||||
}
|
||||
// 替换插件表
|
||||
oldPlugins = r.plugins
|
||||
r.plugins = make(map[string]Plugin)
|
||||
for _, lp := range loaded {
|
||||
if lp.p != nil {
|
||||
r.plugins[lp.name] = lp.p
|
||||
}
|
||||
}
|
||||
pluginCount := len(r.plugins)
|
||||
ioCount := len(newDevices)
|
||||
r.mu.Unlock()
|
||||
|
||||
// 4. 清理旧插件资源
|
||||
for _, p := range oldPlugins {
|
||||
_ = p
|
||||
}
|
||||
|
||||
log.Printf("[plugin] atomic reload: %d plugins, %d io channels", pluginCount, ioCount)
|
||||
return fmt.Sprintf("插件重载完成: %d 个插件, %d 个 IO 通道", pluginCount, ioCount), nil
|
||||
}
|
||||
|
||||
func (r *Registry) Unregister(name string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, ok := r.plugins[name]; ok && r.ioMgr != nil {
|
||||
r.ioMgr.UnregisterDevice(name)
|
||||
log.Printf("[plugin] unregistered io device: %s", name)
|
||||
}
|
||||
delete(r.plugins, name)
|
||||
}
|
||||
|
||||
func (r *Registry) Get(name string) Plugin {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.plugins[name]
|
||||
}
|
||||
|
||||
func (r *Registry) List() []Plugin {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
list := make([]Plugin, 0, len(r.plugins))
|
||||
for _, p := range r.plugins {
|
||||
list = append(list, p)
|
||||
}
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
return list[i].Name() < list[j].Name()
|
||||
})
|
||||
return list
|
||||
}
|
||||
|
||||
func (r *Registry) ListEnabled() []Plugin {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
var list []Plugin
|
||||
for _, p := range r.plugins {
|
||||
if p.Enabled() {
|
||||
list = append(list, p)
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func (r *Registry) GetAllToolDefs() []ToolDef {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
var defs []ToolDef
|
||||
for _, p := range r.plugins {
|
||||
if !p.Enabled() {
|
||||
continue
|
||||
}
|
||||
defs = append(defs, p.Tools()...)
|
||||
}
|
||||
return defs
|
||||
}
|
||||
|
||||
// HotReload 定期扫描插件目录,检测新增/变更/删除的插件并动态注册/注销
|
||||
// interval=0 表示只执行一次扫描
|
||||
func (r *Registry) HotReload(dir string, interval time.Duration, done <-chan struct{}) {
|
||||
if interval <= 0 {
|
||||
r.scanAndSync(dir)
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
r.scanAndSync(dir)
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// scanAndSync 扫描插件目录并与当前注册表同步
|
||||
func (r *Registry) scanAndSync(dir string) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
log.Printf("[plugin] scan error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 收集当前磁盘上的插件名
|
||||
diskSet := make(map[string]bool)
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if entry.IsDir() {
|
||||
diskSet[name] = true
|
||||
} else if strings.HasSuffix(name, ".md") {
|
||||
diskSet[name[:len(name)-3]] = true
|
||||
}
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
// 移除已不存在的插件
|
||||
for name := range r.plugins {
|
||||
if !diskSet[name] {
|
||||
if r.ioMgr != nil {
|
||||
r.ioMgr.UnregisterDevice(name)
|
||||
}
|
||||
delete(r.plugins, name)
|
||||
log.Printf("[plugin] hot-unload: %s", name)
|
||||
}
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
// 加载新增的插件
|
||||
for _, entry := range entries {
|
||||
path := filepath.Join(dir, entry.Name())
|
||||
name := entry.Name()
|
||||
if entry.IsDir() {
|
||||
name = entry.Name()
|
||||
} else if strings.HasSuffix(name, ".md") {
|
||||
name = name[:len(name)-3]
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
r.mu.RLock()
|
||||
exists := r.plugins[name]
|
||||
r.mu.RUnlock()
|
||||
if exists != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否有原生工厂
|
||||
r.mu.RLock()
|
||||
factory, hasFactory := r.factories[name]
|
||||
r.mu.RUnlock()
|
||||
|
||||
if hasFactory {
|
||||
// 使用原生设备,构造 Plugin 容器
|
||||
dev, err := factory(name, r.configFor(name, dir), r.ioMgr)
|
||||
if err != nil {
|
||||
log.Printf("[plugin] native factory %s: %v", name, err)
|
||||
continue
|
||||
}
|
||||
np := &nativePlugin{name: name, dev: dev}
|
||||
np.cfg = extractIOConfig(r.readFile(path))
|
||||
r.Register(np)
|
||||
log.Printf("[plugin] hot-load native: %s", name)
|
||||
} else {
|
||||
var p Plugin
|
||||
p, err = LoadSKILL(path)
|
||||
if err != nil {
|
||||
log.Printf("[plugin] hot-load skip %s: %v", entry.Name(), err)
|
||||
continue
|
||||
}
|
||||
r.Register(p)
|
||||
log.Printf("[plugin] hot-load: %s", p.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// configFor 读取插件目录的 skill.json 作为原生工厂的配置
|
||||
func (r *Registry) configFor(name, dir string) map[string]interface{} {
|
||||
cfg := map[string]interface{}{}
|
||||
path := filepath.Join(dir, name, "skill.json")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return cfg
|
||||
}
|
||||
var meta map[string]interface{}
|
||||
if json.Unmarshal(data, &meta) == nil {
|
||||
for k, v := range meta {
|
||||
cfg[k] = v
|
||||
}
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// readFile 读取插件目录的 SKILL.md
|
||||
func (r *Registry) readFile(path string) string {
|
||||
if info, err := os.Stat(path); err == nil && info.IsDir() {
|
||||
data, _ := os.ReadFile(filepath.Join(path, "SKILL.md"))
|
||||
return string(data)
|
||||
}
|
||||
data, _ := os.ReadFile(path)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func (r *Registry) LoadDir(dir string) error {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
path := filepath.Join(dir, entry.Name())
|
||||
name := entry.Name()
|
||||
if entry.IsDir() {
|
||||
// 原生插件优先
|
||||
r.mu.RLock()
|
||||
factory, hasFactory := r.factories[name]
|
||||
r.mu.RUnlock()
|
||||
if hasFactory {
|
||||
dev, err := factory(name, r.configFor(name, dir), r.ioMgr)
|
||||
if err != nil {
|
||||
log.Printf("[plugin] native factory %s: %v", name, err)
|
||||
continue
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.plugins[name] = &nativePlugin{name: name, dev: dev}
|
||||
r.mu.Unlock()
|
||||
if r.ioMgr != nil {
|
||||
r.ioMgr.RegisterDevice(dev)
|
||||
log.Printf("[plugin] native: %s", name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
p, err := LoadSKILL(path)
|
||||
if err != nil {
|
||||
log.Printf("[plugin] skip dir %s: %v", entry.Name(), err)
|
||||
continue
|
||||
}
|
||||
r.Register(p)
|
||||
} else if filepath.Ext(entry.Name()) == ".md" {
|
||||
p, err := LoadSKILL(path)
|
||||
if err != nil {
|
||||
log.Printf("[plugin] skip file %s: %v", entry.Name(), err)
|
||||
continue
|
||||
}
|
||||
r.Register(p)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractIOConfig parses IO port metadata from SKILL.md content
|
||||
// Supported fields:
|
||||
// io_type: input|output|io
|
||||
// io_input_route: qq
|
||||
// io_output_route: qq (optional, defaults to input_route)
|
||||
// io_output_caps: text,file,image
|
||||
func extractIOConfig(content string) *IOConfig {
|
||||
ioType := extractField(content, "io_type")
|
||||
if ioType == "" {
|
||||
return nil
|
||||
}
|
||||
cfg := &IOConfig{
|
||||
Type: ioType,
|
||||
InputRoute: extractField(content, "io_input_route"),
|
||||
}
|
||||
if cfg.InputRoute == "" {
|
||||
cfg.InputRoute = extractField(content, "io_route")
|
||||
}
|
||||
cfg.OutputRoute = extractField(content, "io_output_route")
|
||||
if cfg.OutputRoute == "" {
|
||||
cfg.OutputRoute = cfg.InputRoute
|
||||
}
|
||||
capsStr := extractField(content, "io_output_caps")
|
||||
if capsStr != "" {
|
||||
for _, c := range strings.Split(capsStr, ",") {
|
||||
cfg.OutputCaps = append(cfg.OutputCaps, strings.TrimSpace(c))
|
||||
}
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// PluginDevice 将 Plugin 包装为 IO Device,实现热插拔
|
||||
type PluginDevice struct {
|
||||
plugin Plugin
|
||||
caps agentIO.OutputCapability
|
||||
}
|
||||
|
||||
func NewPluginDevice(p Plugin) *PluginDevice {
|
||||
var caps agentIO.OutputCapability
|
||||
if cfg := p.IOConfig(); cfg != nil {
|
||||
// 有 IO 配置时使用声明的能力
|
||||
for _, c := range cfg.OutputCaps {
|
||||
switch strings.ToLower(c) {
|
||||
case "text":
|
||||
caps |= agentIO.CapText
|
||||
case "file":
|
||||
caps |= agentIO.CapFile
|
||||
case "image":
|
||||
caps |= agentIO.CapImage
|
||||
case "audio":
|
||||
caps |= agentIO.CapAudio
|
||||
case "structured":
|
||||
caps |= agentIO.CapStructured
|
||||
}
|
||||
}
|
||||
}
|
||||
// 无 IO 配置时 caps == 0 → 纯工具插件,不暴露为输出通道
|
||||
return &PluginDevice{plugin: p, caps: caps}
|
||||
}
|
||||
|
||||
func (d *PluginDevice) Name() string { return d.plugin.Name() }
|
||||
func (d *PluginDevice) Type() agentIO.DeviceType {
|
||||
if cfg := d.plugin.IOConfig(); cfg != nil {
|
||||
switch cfg.Type {
|
||||
case "input":
|
||||
return agentIO.DeviceInput
|
||||
case "output":
|
||||
return agentIO.DeviceOutput
|
||||
case "io":
|
||||
return agentIO.DeviceIO
|
||||
}
|
||||
}
|
||||
// 无 IO 配置 → 纯工具插件,归为 DeviceInput(无输出能力)
|
||||
return agentIO.DeviceInput
|
||||
}
|
||||
func (d *PluginDevice) Description() string { return d.plugin.Description() }
|
||||
|
||||
func (d *PluginDevice) Tools() []agentIO.ToolDef {
|
||||
pts := d.plugin.Tools()
|
||||
defs := make([]agentIO.ToolDef, 0, len(pts))
|
||||
for _, t := range pts {
|
||||
defs = append(defs, agentIO.ToolDef{
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
Parameters: t.Parameters,
|
||||
})
|
||||
}
|
||||
return defs
|
||||
}
|
||||
|
||||
func (d *PluginDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
|
||||
for _, t := range d.plugin.Tools() {
|
||||
if t.Name == tool {
|
||||
if t.Handler != nil {
|
||||
return t.Handler(args)
|
||||
}
|
||||
return nil, fmt.Errorf("plugin %s: tool %s has no handler", d.plugin.Name(), tool)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("plugin %s: unknown tool %s", d.plugin.Name(), tool)
|
||||
}
|
||||
|
||||
func (d *PluginDevice) Start() error { return nil }
|
||||
func (d *PluginDevice) Stop() error { return nil }
|
||||
func (d *PluginDevice) OutputCapabilities() agentIO.OutputCapability { return d.caps }
|
||||
|
||||
// extractDescription returns the first non-empty, non-header line
|
||||
func extractDescription(content string) string {
|
||||
for _, line := range splitLines(content) {
|
||||
line = trimSpace(line)
|
||||
if line != "" && !hasPrefix(line, "#") {
|
||||
return line
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractField finds `field: value` pattern in content
|
||||
func extractField(content string, field string) string {
|
||||
prefix := field + ":"
|
||||
for _, line := range splitLines(content) {
|
||||
trimmed := trimSpace(line)
|
||||
if hasPrefix(toLower(trimmed), prefix) {
|
||||
return trimSpace(trimPrefix(trimmed, prefix))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractToolDefs 从 SKILL.md 中解析工具定义(OpenClaw 格式)
|
||||
//
|
||||
// 格式:
|
||||
// ## tool_name
|
||||
// 工具描述
|
||||
// - param1: 参数描述
|
||||
// - param2: 参数描述
|
||||
//
|
||||
// 也支持:
|
||||
// ### Tool: tool_name
|
||||
// 格式(三级标题)
|
||||
func extractToolDefs(content string) []ToolDef {
|
||||
lines := splitLines(content)
|
||||
var defs []ToolDef
|
||||
var currentTool *ToolDef
|
||||
inCodeBlock := false
|
||||
|
||||
for i := 0; i < len(lines); i++ {
|
||||
line := lines[i]
|
||||
trimmed := trimSpace(line)
|
||||
|
||||
// 跳过代码块
|
||||
if strings.HasPrefix(trimmed, "```") {
|
||||
inCodeBlock = !inCodeBlock
|
||||
continue
|
||||
}
|
||||
if inCodeBlock {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检测工具定义开始: ## tool_name 或 ### Tool: tool_name
|
||||
if strings.HasPrefix(trimmed, "## ") && !strings.HasPrefix(trimmed, "### ") {
|
||||
// 结束上一个工具
|
||||
if currentTool != nil && currentTool.Name != "" {
|
||||
defs = append(defs, *currentTool)
|
||||
}
|
||||
currentTool = &ToolDef{}
|
||||
namePart := strings.TrimPrefix(trimmed, "## ")
|
||||
// 跳过已知的非工具章节
|
||||
if isNonToolSection(namePart) {
|
||||
currentTool = nil
|
||||
continue
|
||||
}
|
||||
currentTool.Name = namePart
|
||||
currentTool.Parameters = map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 也支持 ### Tool: name 格式
|
||||
if strings.HasPrefix(trimmed, "### Tool: ") {
|
||||
if currentTool != nil && currentTool.Name != "" {
|
||||
defs = append(defs, *currentTool)
|
||||
}
|
||||
currentTool = &ToolDef{}
|
||||
namePart := strings.TrimPrefix(trimmed, "### Tool: ")
|
||||
currentTool.Name = namePart
|
||||
currentTool.Parameters = map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if currentTool == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// 如果 name 为空且已进入工具段,跳过
|
||||
if currentTool.Name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 描述行:第一个非空、非标题、非列表行
|
||||
if currentTool.Description == "" && trimmed != "" &&
|
||||
!strings.HasPrefix(trimmed, "- ") &&
|
||||
!strings.HasPrefix(trimmed, "#") {
|
||||
currentTool.Description = trimmed
|
||||
continue
|
||||
}
|
||||
|
||||
// 参数行:- param: description
|
||||
if strings.HasPrefix(trimmed, "- ") {
|
||||
paramLine := strings.TrimPrefix(trimmed, "- ")
|
||||
colonIdx := strings.Index(paramLine, ":")
|
||||
if colonIdx > 0 {
|
||||
paramName := strings.TrimSpace(paramLine[:colonIdx])
|
||||
paramDesc := strings.TrimSpace(paramLine[colonIdx+1:])
|
||||
if paramName != "" {
|
||||
props := currentTool.Parameters["properties"].(map[string]interface{})
|
||||
props[paramName] = map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": paramDesc,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 收尾最后一个工具
|
||||
if currentTool != nil && currentTool.Name != "" {
|
||||
defs = append(defs, *currentTool)
|
||||
}
|
||||
|
||||
return defs
|
||||
}
|
||||
|
||||
// isNonToolSection 判断是否为非工具章节(如 Usage、Examples、Installation 等)
|
||||
func isNonToolSection(name string) bool {
|
||||
lower := strings.ToLower(name)
|
||||
skip := []string{
|
||||
"tools", "usage", "examples", "installation", "setup",
|
||||
"configuration", "overview", "description", "notes",
|
||||
"parameters", "return", "returns", "options", "syntax",
|
||||
}
|
||||
for _, s := range skip {
|
||||
if lower == s || strings.HasPrefix(lower, s+" ") || strings.HasPrefix(lower, s+":") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func splitLines(s string) []string {
|
||||
var lines []string
|
||||
start := 0
|
||||
for i := 0; i <= len(s); i++ {
|
||||
if i == len(s) || s[i] == '\n' {
|
||||
if i > start {
|
||||
lines = append(lines, s[start:i])
|
||||
}
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func trimSpace(s string) string {
|
||||
start, end := 0, len(s)
|
||||
for start < end && (s[start] == ' ' || s[start] == '\t' || s[start] == '\r') {
|
||||
start++
|
||||
}
|
||||
for end > start && (s[end-1] == ' ' || s[end-1] == '\t' || s[end-1] == '\r') {
|
||||
end--
|
||||
}
|
||||
return s[start:end]
|
||||
}
|
||||
|
||||
func hasPrefix(s, prefix string) bool {
|
||||
if len(s) < len(prefix) {
|
||||
return false
|
||||
}
|
||||
return s[:len(prefix)] == prefix
|
||||
}
|
||||
|
||||
func toLower(s string) string {
|
||||
b := make([]byte, len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] >= 'A' && s[i] <= 'Z' {
|
||||
b[i] = s[i] + 32
|
||||
} else {
|
||||
b[i] = s[i]
|
||||
}
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func trimPrefix(s, prefix string) string {
|
||||
if hasPrefix(s, prefix) {
|
||||
return s[len(prefix):]
|
||||
}
|
||||
return s
|
||||
}
|
||||
Reference in New Issue
Block a user