Initial commit: Cyrene Plugins SDK + Plugin Manager

Extracted from Cyrene main repo (backend/pkg/plugins + backend/plugin-manager).
Contains SDK interfaces (Plugin/Tool/HostAPI), 13 built-in plugins,
ToolRegistry with call log ring buffer, and Plugin Manager REST API service.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 09:49:12 +08:00
commit 5c807d76a0
27 changed files with 3609 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
# cyrene-plugins
# Community plugin SDK and plugin-manager service for Cyrene AI
# Binaries
*.exe
*.exe~
*.dll
*.so
*.dylib
/plugin-manager
/plugin-manager.exe
# Test binary
*.test
# Output of go test
*.out
# Dependencies
vendor/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Environment
.env
.env.local
# Data
data/
+83
View File
@@ -0,0 +1,83 @@
# Cyrene Plugins
Cyrene AI 的插件系统:社区可扩展工具 SDK + Plugin Manager 服务。
## 结构
```
├── sdk/ # 插件 SDK (Plugin, Tool, HostAPI 接口 + 类型定义)
├── manager/ # ToolRegistry (调用日志环形缓冲区) + PluginManager (生命周期管理)
├── calculator/ # 内置插件 (13 个)
├── crypto/ # - 加密/哈希
├── datetime/ # - 日期时间
├── file/ # - 文件操作
├── http/ # - HTTP 请求
├── iot_control/ # - IoT 设备控制
├── iot_query/ # - IoT 设备查询
├── json/ # - JSON 处理
├── markdown/ # - Markdown 渲染
├── random/ # - 随机数生成
├── text/ # - 文本处理
├── web_fetch/ # - 网页抓取
├── web_search/ # - 网页搜索
├── cmd/
│ └── plugin-manager/ # Plugin Manager 服务 (REST API, 端口 8094)
└── go.mod
```
## 快速开始
```bash
git clone git@git.yeij.top:AskaEth/Cyrene-Plugins.git
cd Cyrene-Plugins
go build ./...
```
### 运行 Plugin Manager
```bash
go run ./cmd/plugin-manager/
# 监听 :8094,提供 REST API 管理插件
```
### API 端点
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/v1/plugins` | 列出所有插件 |
| GET | `/api/v1/plugins/:id` | 插件详情 |
| POST | `/api/v1/plugins/:id/enable` | 启用插件 |
| POST | `/api/v1/plugins/:id/disable` | 禁用插件 |
| POST | `/api/v1/plugins/:id/reload` | 热重载 |
| DELETE | `/api/v1/plugins/:id` | 卸载 |
| GET | `/api/v1/plugins/:id/tools` | 列出插件工具 |
| GET | `/api/v1/tools` | 列出所有工具 |
| POST | `/api/v1/tools/:id/execute` | 执行工具 |
| GET | `/api/v1/health` | 健康检查 |
## 开发插件
实现 `sdk.Plugin` 接口(`Metadata`, `Init`, `Start`, `Stop`, `Health`, `Tools`),然后在 `cmd/plugin-manager/main.go` 中注册。
```go
import "git.yeij.top/AskaEth/Cyrene-Plugins/sdk"
type MyPlugin struct{}
func (p *MyPlugin) Metadata() sdk.PluginMetadata {
return sdk.PluginMetadata{Name: "my-plugin", Version: "1.0.0"}
}
// ... 实现其余接口
```
## 与 Cyrene 主项目的集成
主项目 `ai-core` 通过 `go.mod` replace 指令引用本仓库进行本地开发:
```
replace git.yeij.top/AskaEth/Cyrene-Plugins => ../../../cyrene-plugins
```
## 许可证
MIT
+279
View File
@@ -0,0 +1,279 @@
package calculator
import (
"context"
"fmt"
"math"
"strconv"
"strings"
"unicode"
"git.yeij.top/AskaEth/Cyrene-Plugins/sdk"
)
type CalculatorPlugin struct {
sdk.BasePlugin
}
func (p *CalculatorPlugin) Metadata() sdk.PluginMetadata {
return sdk.PluginMetadata{
Name: "calculator", DisplayName: "Calculator", Version: "1.0.0",
Description: "Safe mathematical expression evaluation with custom parser",
Category: "utility", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
}
}
func (p *CalculatorPlugin) Tools() []sdk.Tool {
return []sdk.Tool{&CalculatorTool{}}
}
type CalculatorTool struct {
sdk.BaseTool
}
func (t *CalculatorTool) Definition() sdk.ToolDefinition {
return sdk.ToolDefinition{
ID: "calculator", Name: "calculator", DisplayName: "Calculator",
Description: "Execute mathematical calculations. Supports arithmetic, trig, logs, powers.",
Category: "utility", Complexity: sdk.ComplexitySimple,
Parameters: map[string]interface{}{
"type": "object", "properties": map[string]interface{}{"expression": map[string]interface{}{"type": "string"}},
"required": []string{"expression"},
},
}
}
func (t *CalculatorTool) Validate(args map[string]interface{}) error {
if _, ok := args["expression"]; !ok {
return fmt.Errorf("missing required parameter: expression")
}
return nil
}
func (t *CalculatorTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
expr, _ := args["expression"].(string)
result, err := evalExpression(expr)
if err != nil {
return &sdk.ToolResult{ToolName: "calculator", Success: false, Error: err.Error()}, nil
}
return &sdk.ToolResult{ToolName: "calculator", Success: true, Output: fmt.Sprintf("%v", result)}, nil
}
// Expression parser supporting +, -, *, /, %, ^, functions, constants.
type exprParser struct {
s string
pos int
}
func evalExpression(s string) (float64, error) {
p := &exprParser{s: strings.TrimSpace(s)}
result, err := p.parseAddSub()
if err != nil {
return 0, err
}
if p.pos < len(p.s) {
return 0, fmt.Errorf("unexpected character at position %d: %c", p.pos, p.s[p.pos])
}
return result, nil
}
func (p *exprParser) peek() byte {
if p.pos < len(p.s) {
return p.s[p.pos]
}
return 0
}
func (p *exprParser) skipSpaces() {
for p.pos < len(p.s) && p.s[p.pos] == ' ' {
p.pos++
}
}
func (p *exprParser) parseAddSub() (float64, error) {
left, err := p.parseMulDiv()
if err != nil {
return 0, err
}
for {
p.skipSpaces()
op := p.peek()
if op != '+' && op != '-' {
break
}
p.pos++
right, err := p.parseMulDiv()
if err != nil {
return 0, err
}
if op == '+' {
left += right
} else {
left -= right
}
}
return left, nil
}
func (p *exprParser) parseMulDiv() (float64, error) {
left, err := p.parsePower()
if err != nil {
return 0, err
}
for {
p.skipSpaces()
op := p.peek()
if op != '*' && op != '/' && op != '%' {
break
}
p.pos++
right, err := p.parsePower()
if err != nil {
return 0, err
}
switch op {
case '*':
left *= right
case '/':
if right == 0 {
return 0, fmt.Errorf("division by zero")
}
left /= right
case '%':
left = math.Mod(left, right)
}
}
return left, nil
}
func (p *exprParser) parsePower() (float64, error) {
left, err := p.parseUnary()
if err != nil {
return 0, err
}
p.skipSpaces()
if p.peek() == '^' {
p.pos++
right, err := p.parseUnary()
if err != nil {
return 0, err
}
return math.Pow(left, right), nil
}
return left, nil
}
func (p *exprParser) parseUnary() (float64, error) {
p.skipSpaces()
if p.peek() == '-' {
p.pos++
val, err := p.parseAtom()
if err != nil {
return 0, err
}
return -val, nil
}
if p.peek() == '+' {
p.pos++
}
return p.parseAtom()
}
func (p *exprParser) parseAtom() (float64, error) {
p.skipSpaces()
if p.peek() == '(' {
p.pos++
result, err := p.parseAddSub()
if err != nil {
return 0, err
}
p.skipSpaces()
if p.peek() != ')' {
return 0, fmt.Errorf("missing closing parenthesis")
}
p.pos++
return result, nil
}
if p.peek() == 0 {
return 0, fmt.Errorf("unexpected end of expression")
}
if unicode.IsDigit(rune(p.peek())) || p.peek() == '.' {
return p.parseNumber()
}
return p.parseFuncOrConst()
}
func (p *exprParser) parseNumber() (float64, error) {
start := p.pos
for p.pos < len(p.s) && (unicode.IsDigit(rune(p.s[p.pos])) || p.s[p.pos] == '.') {
p.pos++
}
return strconv.ParseFloat(p.s[start:p.pos], 64)
}
func (p *exprParser) parseFuncOrConst() (float64, error) {
start := p.pos
for p.pos < len(p.s) && (unicode.IsLetter(rune(p.s[p.pos])) || p.s[p.pos] == '_') {
p.pos++
}
name := p.s[start:p.pos]
p.skipSpaces()
switch name {
case "pi":
return math.Pi, nil
case "e":
return math.E, nil
case "sqrt", "sin", "cos", "tan", "abs", "floor", "ceil", "round", "log", "ln":
if p.peek() != '(' {
return 0, fmt.Errorf("expected '(' after function %s", name)
}
p.pos++
arg, err := p.parseAddSub()
if err != nil {
return 0, err
}
if p.peek() != ')' {
return 0, fmt.Errorf("missing ')' after function argument")
}
p.pos++
return applyFunc(name, arg)
default:
return 0, fmt.Errorf("unknown function or constant: %s", name)
}
}
func applyFunc(name string, x float64) (float64, error) {
switch name {
case "sqrt":
if x < 0 {
return 0, fmt.Errorf("square root of negative number")
}
return math.Sqrt(x), nil
case "sin":
return math.Sin(x), nil
case "cos":
return math.Cos(x), nil
case "tan":
return math.Tan(x), nil
case "abs":
return math.Abs(x), nil
case "floor":
return math.Floor(x), nil
case "ceil":
return math.Ceil(x), nil
case "round":
return math.Round(x), nil
case "log":
if x <= 0 {
return 0, fmt.Errorf("log of non-positive number")
}
return math.Log10(x), nil
case "ln":
if x <= 0 {
return 0, fmt.Errorf("ln of non-positive number")
}
return math.Log(x), nil
}
return 0, fmt.Errorf("unknown function: %s", name)
}
+47
View File
@@ -0,0 +1,47 @@
package main
import (
"context"
"fmt"
"log"
"net/http"
"git.yeij.top/AskaEth/Cyrene-Plugins/manager"
"git.yeij.top/AskaEth/Cyrene-Plugins/sdk"
)
type hostAPI struct {
registry *manager.ToolRegistry
}
func newHostAPI(registry *manager.ToolRegistry) *hostAPI {
return &hostAPI{registry: registry}
}
func (h *hostAPI) CallLLM(_ context.Context, _ []sdk.LLMMessage) (*sdk.LLMResponse, error) {
return nil, fmt.Errorf("LLM call not available in plugin host")
}
func (h *hostAPI) SearchMemory(_ context.Context, _, _ string, _ int) ([]sdk.MemoryEntry, error) {
return nil, fmt.Errorf("memory search not available in plugin host")
}
func (h *hostAPI) StoreMemory(_ context.Context, _ sdk.MemoryEntry) error {
return fmt.Errorf("memory store not available in plugin host")
}
func (h *hostAPI) Logger() sdk.Logger {
return log.Default()
}
func (h *hostAPI) GetConfig(key string) (string, error) {
return "", fmt.Errorf("config key not found: %s", key)
}
func (h *hostAPI) SetConfig(_, _ string) error { return nil }
func (h *hostAPI) PublishEvent(_ context.Context, _ map[string]interface{}) error { return nil }
func (h *hostAPI) HTTPClient() *http.Client {
return http.DefaultClient
}
@@ -0,0 +1,32 @@
package config
import "os"
type Config struct {
Port string
Env string
DataDir string
IoTSvcURL string
}
func Load() *Config {
cfg := &Config{
Port: "8094",
Env: "development",
DataDir: "./data",
IoTSvcURL: "http://localhost:8093",
}
if v := os.Getenv("PORT"); v != "" {
cfg.Port = v
}
if v := os.Getenv("ENV"); v != "" {
cfg.Env = v
}
if v := os.Getenv("DATA_DIR"); v != "" {
cfg.DataDir = v
}
if v := os.Getenv("IOT_SERVICE_URL"); v != "" {
cfg.IoTSvcURL = v
}
return cfg
}
@@ -0,0 +1,210 @@
package handler
import (
"encoding/json"
"net/http"
"strings"
"git.yeij.top/AskaEth/Cyrene-Plugins/manager"
)
// PluginHandler exposes the Plugin Manager REST API via net/http.
type PluginHandler struct {
mgr *manager.PluginManager
}
func NewPluginHandler(mgr *manager.PluginManager) *PluginHandler {
return &PluginHandler{mgr: mgr}
}
func (h *PluginHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/plugins", h.listPlugins)
mux.HandleFunc("/api/v1/plugins/", h.pluginRoute)
mux.HandleFunc("/api/v1/tools", h.listTools)
mux.HandleFunc("/api/v1/tools/", h.toolRoute)
mux.HandleFunc("/api/v1/health", h.health)
}
func (h *PluginHandler) health(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{"status": "ok", "service": "plugin-manager"})
}
func (h *PluginHandler) listPlugins(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeJSON(w, http.StatusMethodNotAllowed, errResp("method not allowed"))
return
}
plugins := h.mgr.List()
writeJSON(w, http.StatusOK, map[string]interface{}{"plugins": plugins, "total": len(plugins)})
}
func (h *PluginHandler) pluginRoute(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/v1/plugins/")
parts := strings.SplitN(path, "/", 2)
pluginID := parts[0]
if pluginID == "" {
h.listPlugins(w, r)
return
}
if len(parts) == 1 {
switch r.Method {
case "GET":
h.getPlugin(w, pluginID)
case "DELETE":
h.uninstallPlugin(w, r, pluginID)
default:
writeJSON(w, http.StatusMethodNotAllowed, errResp("method not allowed"))
}
return
}
action := parts[1]
switch action {
case "enable":
if r.Method != "POST" {
writeJSON(w, http.StatusMethodNotAllowed, errResp("method not allowed"))
return
}
h.enablePlugin(w, r, pluginID)
case "disable":
if r.Method != "POST" {
writeJSON(w, http.StatusMethodNotAllowed, errResp("method not allowed"))
return
}
h.disablePlugin(w, r, pluginID)
case "reload":
if r.Method != "POST" {
writeJSON(w, http.StatusMethodNotAllowed, errResp("method not allowed"))
return
}
h.reloadPlugin(w, r, pluginID)
case "tools":
if r.Method != "GET" {
writeJSON(w, http.StatusMethodNotAllowed, errResp("method not allowed"))
return
}
h.pluginTools(w, pluginID)
default:
writeJSON(w, http.StatusNotFound, errResp("not found"))
}
}
func (h *PluginHandler) getPlugin(w http.ResponseWriter, id string) {
info, ok := h.mgr.Get(id)
if !ok {
writeJSON(w, http.StatusNotFound, errResp("plugin not found"))
return
}
writeJSON(w, http.StatusOK, info)
}
func (h *PluginHandler) enablePlugin(w http.ResponseWriter, r *http.Request, id string) {
if err := h.mgr.Enable(r.Context(), id); err != nil {
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "enabled"})
}
func (h *PluginHandler) disablePlugin(w http.ResponseWriter, r *http.Request, id string) {
if err := h.mgr.Disable(r.Context(), id); err != nil {
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "disabled"})
}
func (h *PluginHandler) reloadPlugin(w http.ResponseWriter, r *http.Request, id string) {
if err := h.mgr.Reload(r.Context(), id); err != nil {
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "reloaded"})
}
func (h *PluginHandler) uninstallPlugin(w http.ResponseWriter, r *http.Request, id string) {
if err := h.mgr.Uninstall(r.Context(), id); err != nil {
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "uninstalled"})
}
func (h *PluginHandler) pluginTools(w http.ResponseWriter, id string) {
info, ok := h.mgr.Get(id)
if !ok {
writeJSON(w, http.StatusNotFound, errResp("plugin not found"))
return
}
registry := h.mgr.Registry()
tools := make([]interface{}, 0)
for _, toolID := range info.Tools {
if t, ok := registry.Get(toolID); ok {
tools = append(tools, t.Definition())
}
}
writeJSON(w, http.StatusOK, map[string]interface{}{"tools": tools, "total": len(tools)})
}
func (h *PluginHandler) listTools(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeJSON(w, http.StatusMethodNotAllowed, errResp("method not allowed"))
return
}
defs := h.mgr.Registry().Definitions()
writeJSON(w, http.StatusOK, map[string]interface{}{"tools": defs, "total": len(defs)})
}
func (h *PluginHandler) toolRoute(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/v1/tools/")
toolID := path
if strings.HasSuffix(path, "/execute") {
toolID = strings.TrimSuffix(path, "/execute")
if r.Method != "POST" {
writeJSON(w, http.StatusMethodNotAllowed, errResp("method not allowed"))
return
}
h.executeTool(w, r, toolID)
return
}
if r.Method != "GET" {
writeJSON(w, http.StatusMethodNotAllowed, errResp("method not allowed"))
return
}
tool, ok := h.mgr.Registry().Get(toolID)
if !ok {
writeJSON(w, http.StatusNotFound, errResp("tool not found"))
return
}
writeJSON(w, http.StatusOK, tool.Definition())
}
func (h *PluginHandler) executeTool(w http.ResponseWriter, r *http.Request, toolID string) {
var body struct {
Arguments map[string]interface{} `json:"arguments"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
result, err := h.mgr.Registry().Execute(r.Context(), toolID, body.Arguments)
if err != nil {
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
return
}
writeJSON(w, http.StatusOK, result)
}
func errResp(msg string) map[string]string {
return map[string]string{"error": msg}
}
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
+112
View File
@@ -0,0 +1,112 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene-Plugins/sdk"
iotquery "git.yeij.top/AskaEth/Cyrene-Plugins/iot_query"
)
type iotClient struct {
baseURL string
httpClient *http.Client
}
func newIoTClient(baseURL string) *iotClient {
return &iotClient{
baseURL: baseURL,
httpClient: &http.Client{Timeout: 5 * time.Second},
}
}
func (c *iotClient) GetAllDevices(ctx context.Context) ([]sdk.IoTDeviceState, error) {
url := c.baseURL + "/api/v1/devices"
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Devices []sdk.IoTDeviceState `json:"devices"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Devices, nil
}
func (c *iotClient) GetDevice(ctx context.Context, deviceID string) (*sdk.IoTDeviceState, error) {
url := fmt.Sprintf("%s/api/v1/devices/%s", c.baseURL, deviceID)
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var dev sdk.IoTDeviceState
if err := json.NewDecoder(resp.Body).Decode(&dev); err != nil {
return nil, err
}
return &dev, nil
}
// iotControllerAdapter adapts IoTClient to iotcontrol.IoTController.
type iotControllerAdapter struct {
query iotquery.IoTClient
client *http.Client
baseURL string
}
func newIoTControllerAdapter(query iotquery.IoTClient, baseURL string) *iotControllerAdapter {
return &iotControllerAdapter{
query: query,
client: &http.Client{Timeout: 5 * time.Second},
baseURL: baseURL,
}
}
func (a *iotControllerAdapter) GetDevice(ctx context.Context, deviceID string) (*sdk.IoTDeviceState, error) {
return a.query.GetDevice(ctx, deviceID)
}
func (a *iotControllerAdapter) SetDeviceProperty(ctx context.Context, deviceID, property string, value interface{}) error {
url := fmt.Sprintf("%s/api/v1/devices/%s/property", a.baseURL, deviceID)
body, _ := json.Marshal(map[string]interface{}{"property": property, "value": value})
req, _ := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json")
resp, err := a.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
msg, _ := io.ReadAll(resp.Body)
return fmt.Errorf("set property failed: HTTP %d - %s", resp.StatusCode, string(msg))
}
return nil
}
func (a *iotControllerAdapter) ToggleDevice(ctx context.Context, deviceID string) (*sdk.IoTDeviceState, error) {
url := fmt.Sprintf("%s/api/v1/devices/%s/toggle", a.baseURL, deviceID)
req, _ := http.NewRequestWithContext(ctx, "POST", url, nil)
resp, err := a.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var dev sdk.IoTDeviceState
if err := json.NewDecoder(resp.Body).Decode(&dev); err != nil {
return nil, err
}
return &dev, nil
}
+100
View File
@@ -0,0 +1,100 @@
package main
import (
"context"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"git.yeij.top/AskaEth/Cyrene-Plugins/calculator"
"git.yeij.top/AskaEth/Cyrene-Plugins/crypto"
"git.yeij.top/AskaEth/Cyrene-Plugins/datetime"
fileplugin "git.yeij.top/AskaEth/Cyrene-Plugins/file"
httpplugin "git.yeij.top/AskaEth/Cyrene-Plugins/http"
iotcontrol "git.yeij.top/AskaEth/Cyrene-Plugins/iot_control"
iotquery "git.yeij.top/AskaEth/Cyrene-Plugins/iot_query"
jsonplugin "git.yeij.top/AskaEth/Cyrene-Plugins/json"
"git.yeij.top/AskaEth/Cyrene-Plugins/manager"
"git.yeij.top/AskaEth/Cyrene-Plugins/markdown"
"git.yeij.top/AskaEth/Cyrene-Plugins/random"
"git.yeij.top/AskaEth/Cyrene-Plugins/sdk"
"git.yeij.top/AskaEth/Cyrene-Plugins/text"
webfetch "git.yeij.top/AskaEth/Cyrene-Plugins/web_fetch"
websearch "git.yeij.top/AskaEth/Cyrene-Plugins/web_search"
"git.yeij.top/AskaEth/Cyrene-Plugins/cmd/plugin-manager/internal/config"
"git.yeij.top/AskaEth/Cyrene-Plugins/cmd/plugin-manager/internal/handler"
)
func main() {
cfg := config.Load()
var iotAPI iotquery.IoTClient
if cfg.IoTSvcURL != "" {
iotAPI = newIoTClient(cfg.IoTSvcURL)
}
registry := manager.NewToolRegistry()
host := newHostAPI(registry)
mgr := manager.NewPluginManager(registry, host)
builtins := []sdk.Plugin{
&calculator.CalculatorPlugin{},
&datetime.DatetimePlugin{},
&text.TextPlugin{},
&crypto.CryptoPlugin{},
&random.RandomPlugin{},
&markdown.MarkdownPlugin{},
&jsonplugin.JSONPlugin{},
fileplugin.NewFilePlugin(cfg.DataDir),
httpplugin.NewHTTPPlugin(),
websearch.NewWebSearchPlugin(),
webfetch.NewWebFetchPlugin(),
iotquery.NewIoTQueryPlugin(iotAPI),
}
for _, p := range builtins {
if err := mgr.Install(p); err != nil {
println("WARN: install plugin failed:", err.Error())
}
}
if iotAPI != nil {
ctrlPlugin := iotcontrol.NewIoTControlPlugin(newIoTControllerAdapter(iotAPI, cfg.IoTSvcURL))
if err := mgr.Install(ctrlPlugin); err != nil {
println("WARN: install plugin failed:", err.Error())
}
}
ctx := context.Background()
errs := mgr.EnableAll(ctx)
for _, e := range errs {
println("WARN: enable plugin failed:", e.Error())
}
println("Plugin Manager: all built-in plugins enabled")
mux := http.NewServeMux()
ph := handler.NewPluginHandler(mgr)
ph.RegisterRoutes(mux)
println("Plugin Manager listening on port", cfg.Port)
srv := &http.Server{Addr: ":" + cfg.Port, Handler: mux}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
println("FATAL:", err.Error())
os.Exit(1)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
println("Shutting down Plugin Manager...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
mgr.Shutdown(shutdownCtx)
srv.Shutdown(shutdownCtx)
println("Plugin Manager stopped")
}
+116
View File
@@ -0,0 +1,116 @@
package crypto
import (
"context"
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"encoding/base64"
"fmt"
"hash"
"net/url"
"git.yeij.top/AskaEth/Cyrene-Plugins/sdk"
)
type CryptoPlugin struct{ sdk.BasePlugin }
func (p *CryptoPlugin) Metadata() sdk.PluginMetadata {
return sdk.PluginMetadata{
Name: "crypto", DisplayName: "Crypto & Encoding", Version: "1.0.0",
Description: "Hashing (MD5/SHA) and encoding (Base64, URL) utilities",
Category: "utility", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
}
}
func (p *CryptoPlugin) Tools() []sdk.Tool { return []sdk.Tool{&CryptoTool{}} }
type CryptoTool struct{ sdk.BaseTool }
func (t *CryptoTool) Definition() sdk.ToolDefinition {
return sdk.ToolDefinition{
ID: "crypto", Name: "crypto", DisplayName: "Crypto & Encoding",
Description: "Crypto hash and encoding utilities. MD5/SHA hashing, Base64 encode/decode, URL encode/decode.",
Category: "utility", Complexity: sdk.ComplexitySimple,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{"type": "string", "enum": []string{"hash", "base64_encode", "base64_decode", "url_encode", "url_decode"}},
"input": map[string]interface{}{"type": "string"},
"algorithm": map[string]interface{}{"type": "string", "enum": []string{"md5", "sha1", "sha256", "sha512"}},
},
"required": []string{"action", "input"},
},
}
}
func (t *CryptoTool) Validate(args map[string]interface{}) error {
for _, k := range []string{"action", "input"} {
if _, ok := args[k]; !ok {
return fmt.Errorf("missing required parameter: %s", k)
}
}
return nil
}
func (t *CryptoTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
action, _ := args["action"].(string)
input, _ := args["input"].(string)
switch action {
case "hash":
alg, _ := args["algorithm"].(string)
if alg == "" {
alg = "sha256"
}
var h hash.Hash
switch alg {
case "md5":
h = md5.New()
case "sha1":
h = sha1.New()
case "sha256":
h = sha256.New()
case "sha512":
h = sha512.New()
default:
return &sdk.ToolResult{ToolName: "crypto", Success: false, Error: "unsupported algorithm: " + alg}, nil
}
h.Write([]byte(input))
return &sdk.ToolResult{ToolName: "crypto", Success: true,
Output: fmt.Sprintf("%s: %x", alg, h.Sum(nil))}, nil
case "base64_encode":
return &sdk.ToolResult{ToolName: "crypto", Success: true,
Output: base64.StdEncoding.EncodeToString([]byte(input))}, nil
case "base64_decode":
for _, enc := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} {
if decoded, err := enc.DecodeString(input); err == nil {
return &sdk.ToolResult{ToolName: "crypto", Success: true, Output: truncate(string(decoded), 200)}, nil
}
}
return &sdk.ToolResult{ToolName: "crypto", Success: false, Error: "failed to decode base64"}, nil
case "url_encode":
return &sdk.ToolResult{ToolName: "crypto", Success: true,
Output: url.QueryEscape(input)}, nil
case "url_decode":
decoded, err := url.QueryUnescape(input)
if err != nil {
return &sdk.ToolResult{ToolName: "crypto", Success: false, Error: err.Error()}, nil
}
return &sdk.ToolResult{ToolName: "crypto", Success: true, Output: decoded}, nil
}
return &sdk.ToolResult{ToolName: "crypto", Success: false, Error: "unknown action: " + action}, nil
}
func truncate(s string, n int) string {
runes := []rune(s)
if len(runes) > n {
return string(runes[:n]) + "..."
}
return s
}
+170
View File
@@ -0,0 +1,170 @@
package datetime
import (
"context"
"fmt"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene-Plugins/sdk"
)
type DatetimePlugin struct{ sdk.BasePlugin }
func (p *DatetimePlugin) Metadata() sdk.PluginMetadata {
return sdk.PluginMetadata{
Name: "datetime", DisplayName: "Date & Time", Version: "1.0.0",
Description: "Date/time utilities: now, format, arithmetic, diff, timezone list",
Category: "utility", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
}
}
func (p *DatetimePlugin) Tools() []sdk.Tool { return []sdk.Tool{&DatetimeTool{}} }
type DatetimeTool struct{ sdk.BaseTool }
func (t *DatetimeTool) Definition() sdk.ToolDefinition {
return sdk.ToolDefinition{
ID: "datetime", Name: "datetime", DisplayName: "Date & Time",
Description: "Date/time utility. Get current time, format dates, date arithmetic, date diff, list timezones.",
Category: "utility", Complexity: sdk.ComplexitySimple,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{"type": "string", "enum": []string{"now", "format", "add", "diff", "timezone_list"}},
"format": map[string]interface{}{"type": "string"},
"timezone": map[string]interface{}{"type": "string"},
"date": map[string]interface{}{"type": "string"},
"duration": map[string]interface{}{"type": "string"},
"date2": map[string]interface{}{"type": "string"},
},
"required": []string{"action"},
},
}
}
func (t *DatetimeTool) Validate(args map[string]interface{}) error {
if _, ok := args["action"]; !ok {
return fmt.Errorf("missing required parameter: action")
}
return nil
}
func (t *DatetimeTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
action, _ := args["action"].(string)
tzStr, _ := args["timezone"].(string)
loc, _ := parseLocation(tzStr)
now := time.Now().In(loc)
switch action {
case "now":
return &sdk.ToolResult{ToolName: "datetime", Success: true,
Output: fmt.Sprintf("Current time: %s (unix: %d, zone: %s)", now.Format(time.RFC3339), now.Unix(), loc.String())}, nil
case "format":
dateStr, _ := args["date"].(string)
format, _ := args["format"].(string)
if format == "" {
format = time.RFC3339
}
parsed, err := parseDate(dateStr, loc)
if err != nil {
return &sdk.ToolResult{ToolName: "datetime", Success: false, Error: err.Error()}, nil
}
return &sdk.ToolResult{ToolName: "datetime", Success: true,
Output: fmt.Sprintf("Formatted: %s", parsed.Format(format))}, nil
case "add":
dateStr, _ := args["date"].(string)
durStr, _ := args["duration"].(string)
base := now
if dateStr != "" {
var err error
base, err = parseDate(dateStr, loc)
if err != nil {
return &sdk.ToolResult{ToolName: "datetime", Success: false, Error: err.Error()}, nil
}
}
result, err := addDuration(base, durStr)
if err != nil {
return &sdk.ToolResult{ToolName: "datetime", Success: false, Error: err.Error()}, nil
}
return &sdk.ToolResult{ToolName: "datetime", Success: true,
Output: fmt.Sprintf("%s + %s = %s", base.Format(time.RFC3339), durStr, result.Format(time.RFC3339))}, nil
case "diff":
d1, _ := args["date"].(string)
d2, _ := args["date2"].(string)
t1, err := parseDate(d1, loc)
if err != nil {
return &sdk.ToolResult{ToolName: "datetime", Success: false, Error: err.Error()}, nil
}
t2, err := parseDate(d2, loc)
if err != nil {
return &sdk.ToolResult{ToolName: "datetime", Success: false, Error: err.Error()}, nil
}
diff := t2.Sub(t1)
if diff < 0 {
diff = -diff
}
days := int(diff.Hours()) / 24
hours := int(diff.Hours()) % 24
minutes := int(diff.Minutes()) % 60
seconds := int(diff.Seconds()) % 60
return &sdk.ToolResult{ToolName: "datetime", Success: true,
Output: fmt.Sprintf("Difference: %d days, %d hours, %d minutes, %d seconds", days, hours, minutes, seconds)}, nil
case "timezone_list":
return &sdk.ToolResult{ToolName: "datetime", Success: true,
Output: "Common timezones: UTC, Asia/Shanghai, Asia/Tokyo, Asia/Seoul, Asia/Singapore, Asia/Kolkata, Asia/Dubai, Europe/London, Europe/Paris, Europe/Moscow, America/New_York, America/Chicago, America/Los_Angeles, America/Sao_Paulo, Australia/Sydney, Pacific/Auckland, Africa/Cairo, Africa/Lagos"}, nil
default:
return &sdk.ToolResult{ToolName: "datetime", Success: false,
Error: fmt.Sprintf("unknown action: %s", action)}, nil
}
}
func parseLocation(tz string) (*time.Location, error) {
if tz == "" {
loc, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
return time.UTC, nil
}
return loc, nil
}
return time.LoadLocation(tz)
}
func parseDate(s string, loc *time.Location) (time.Time, error) {
formats := []string{time.RFC3339, "2006-01-02T15:04:05", "2006-01-02 15:04:05", "2006-01-02", "2006/01/02"}
for _, f := range formats {
if t, err := time.ParseInLocation(f, s, loc); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("cannot parse date: %s", s)
}
func addDuration(t time.Time, durStr string) (time.Time, error) {
durStr = strings.TrimSpace(durStr)
if durStr == "" {
return t, nil
}
// Handle months and years
if strings.Contains(durStr, "M") || strings.Contains(durStr, "y") {
months := 0
years := 0
if strings.Contains(durStr, "y") {
fmt.Sscanf(durStr, "%dy", &years)
}
if strings.Contains(durStr, "M") {
fmt.Sscanf(durStr, "%dM", &months)
}
return t.AddDate(years, months, 0), nil
}
d, err := time.ParseDuration(durStr)
if err != nil {
return t, fmt.Errorf("invalid duration: %s", durStr)
}
return t.Add(d), nil
}
+158
View File
@@ -0,0 +1,158 @@
package file
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"git.yeij.top/AskaEth/Cyrene-Plugins/sdk"
)
type FilePlugin struct {
sdk.BasePlugin
dataDir string
}
func NewFilePlugin(dataDir string) *FilePlugin {
if dataDir == "" {
dataDir = "/tmp/cyrene_data"
}
return &FilePlugin{dataDir: dataDir}
}
func (p *FilePlugin) Metadata() sdk.PluginMetadata {
return sdk.PluginMetadata{
Name: "file", DisplayName: "File Operations", Version: "1.0.0",
Description: "Sandboxed file operations: read, write, list, delete within DATA_DIR",
Category: "system", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
}
}
func (p *FilePlugin) Tools() []sdk.Tool { return []sdk.Tool{&FileTool{dataDir: p.dataDir}} }
type FileTool struct {
sdk.BaseTool
dataDir string
}
func (t *FileTool) Definition() sdk.ToolDefinition {
return sdk.ToolDefinition{
ID: "file_ops", Name: "file_ops", DisplayName: "File Operations",
Description: "File operations within a sandboxed data directory. Read, write, list, check existence, delete.",
Category: "system", Complexity: sdk.ComplexitySimple,
DangerLevel: "medium",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{"type": "string", "enum": []string{"read", "write", "list", "exists", "delete"}},
"path": map[string]interface{}{"type": "string"},
"content": map[string]interface{}{"type": "string"},
},
"required": []string{"action", "path"},
},
}
}
func (t *FileTool) Validate(args map[string]interface{}) error {
for _, k := range []string{"action", "path"} {
if _, ok := args[k]; !ok {
return fmt.Errorf("missing required parameter: %s", k)
}
}
return nil
}
func (t *FileTool) safePath(p string) (string, error) {
clean := filepath.Clean(p)
abs, err := filepath.Abs(filepath.Join(t.dataDir, clean))
if err != nil {
return "", fmt.Errorf("path resolution failed: %w", err)
}
if !strings.HasPrefix(abs, filepath.Clean(t.dataDir)+string(os.PathSeparator)) && abs != filepath.Clean(t.dataDir) {
return "", fmt.Errorf("path traversal denied: %s", p)
}
return abs, nil
}
func (t *FileTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
action, _ := args["action"].(string)
pathStr, _ := args["path"].(string)
safePath, err := t.safePath(pathStr)
if err != nil {
return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: err.Error()}, nil
}
switch action {
case "read":
info, err := os.Stat(safePath)
if err != nil {
return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: err.Error()}, nil
}
if info.IsDir() {
return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: "cannot read a directory"}, nil
}
if info.Size() > 100*1024 {
return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: "file too large (>100KB)"}, nil
}
data, err := os.ReadFile(safePath)
if err != nil {
return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: err.Error()}, nil
}
return &sdk.ToolResult{ToolName: "file_ops", Success: true, Output: string(data)}, nil
case "write":
content, _ := args["content"].(string)
dir := filepath.Dir(safePath)
if err := os.MkdirAll(dir, 0755); err != nil {
return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: err.Error()}, nil
}
if err := os.WriteFile(safePath, []byte(content), 0644); err != nil {
return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: err.Error()}, nil
}
return &sdk.ToolResult{ToolName: "file_ops", Success: true, Output: fmt.Sprintf("Written %d bytes to %s", len(content), pathStr)}, nil
case "list":
entries, err := os.ReadDir(safePath)
if err != nil {
return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: err.Error()}, nil
}
var out strings.Builder
for _, e := range entries {
info, _ := e.Info()
if e.IsDir() {
out.WriteString(fmt.Sprintf("[DIR] %s/\n", e.Name()))
} else {
out.WriteString(fmt.Sprintf("[FILE] %s (%d bytes)\n", e.Name(), info.Size()))
}
}
return &sdk.ToolResult{ToolName: "file_ops", Success: true, Output: out.String()}, nil
case "exists":
info, err := os.Stat(safePath)
if err != nil {
return &sdk.ToolResult{ToolName: "file_ops", Success: true, Output: fmt.Sprintf("Path does not exist: %s", pathStr)}, nil
}
kind := "file"
if info.IsDir() {
kind = "directory"
}
return &sdk.ToolResult{ToolName: "file_ops", Success: true, Output: fmt.Sprintf("Path exists (%s): %s", kind, pathStr)}, nil
case "delete":
info, err := os.Stat(safePath)
if err != nil {
return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: err.Error()}, nil
}
if info.IsDir() {
return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: "cannot delete a directory"}, nil
}
if err := os.Remove(safePath); err != nil {
return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: err.Error()}, nil
}
return &sdk.ToolResult{ToolName: "file_ops", Success: true, Output: fmt.Sprintf("Deleted: %s", pathStr)}, nil
}
return &sdk.ToolResult{ToolName: "file_ops", Success: false, Error: "unknown action: " + action}, nil
}
+3
View File
@@ -0,0 +1,3 @@
module git.yeij.top/AskaEth/Cyrene-Plugins
go 1.21
+122
View File
@@ -0,0 +1,122 @@
package http
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene-Plugins/sdk"
)
type HTTPPlugin struct {
sdk.BasePlugin
client *http.Client
}
func NewHTTPPlugin() *HTTPPlugin {
return &HTTPPlugin{client: &http.Client{Timeout: 10 * time.Second}}
}
func (p *HTTPPlugin) Metadata() sdk.PluginMetadata {
return sdk.PluginMetadata{
Name: "http", DisplayName: "HTTP Client", Version: "1.0.0",
Description: "Send arbitrary HTTP requests with custom methods, headers, body",
Category: "network", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
}
}
func (p *HTTPPlugin) Tools() []sdk.Tool { return []sdk.Tool{&HTTPTool{client: p.client}} }
type HTTPTool struct {
sdk.BaseTool
client *http.Client
}
func (t *HTTPTool) Definition() sdk.ToolDefinition {
return sdk.ToolDefinition{
ID: "http_request", Name: "http_request", DisplayName: "HTTP Client",
Description: "Send arbitrary HTTP requests. Supports custom methods, headers, and body.",
Category: "network", Complexity: sdk.ComplexitySimple,
DangerLevel: "low",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"url": map[string]interface{}{"type": "string"},
"method": map[string]interface{}{"type": "string", "enum": []string{"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"}},
"headers": map[string]interface{}{"type": "object"},
"body": map[string]interface{}{"type": "string"},
"timeout": map[string]interface{}{"type": "number"},
},
"required": []string{"url"},
},
}
}
var allowedMethods = map[string]bool{
"GET": true, "POST": true, "PUT": true, "DELETE": true,
"PATCH": true, "HEAD": true, "OPTIONS": true,
}
func (t *HTTPTool) Validate(args map[string]interface{}) error {
if _, ok := args["url"]; !ok {
return fmt.Errorf("missing required parameter: url")
}
return nil
}
func (t *HTTPTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
urlStr, _ := args["url"].(string)
method, _ := args["method"].(string)
if method == "" {
method = "GET"
}
if !allowedMethods[method] {
return &sdk.ToolResult{ToolName: "http_request", Success: false, Error: "invalid method: " + method}, nil
}
if !strings.HasPrefix(urlStr, "http://") && !strings.HasPrefix(urlStr, "https://") {
return &sdk.ToolResult{ToolName: "http_request", Success: false, Error: "only http/https URLs allowed"}, nil
}
var bodyReader io.Reader
if body, _ := args["body"].(string); body != "" {
bodyReader = strings.NewReader(body)
}
req, err := http.NewRequest(method, urlStr, bodyReader)
if err != nil {
return &sdk.ToolResult{ToolName: "http_request", Success: false, Error: err.Error()}, nil
}
req.Header.Set("User-Agent", "CyreneBot/1.0")
if headers, ok := args["headers"].(map[string]interface{}); ok {
for k, v := range headers {
req.Header.Set(k, fmt.Sprint(v))
}
}
client := t.client
if timeout, _ := args["timeout"].(float64); timeout > 0 {
client = &http.Client{Timeout: time.Duration(timeout) * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return &sdk.ToolResult{ToolName: "http_request", Success: false, Error: err.Error()}, nil
}
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 50*1024))
return &sdk.ToolResult{ToolName: "http_request", Success: resp.StatusCode < 500, Output: fmt.Sprintf(
"HTTP %d\n%s\n\n%s", resp.StatusCode, formatHeaders(resp.Header), string(bodyBytes))}, nil
}
func formatHeaders(h http.Header) string {
var lines []string
for k, v := range h {
lines = append(lines, fmt.Sprintf("%s: %s", k, strings.Join(v, ", ")))
}
return strings.Join(lines, "\n")
}
+189
View File
@@ -0,0 +1,189 @@
package iotcontrol
import (
"context"
"fmt"
"git.yeij.top/AskaEth/Cyrene-Plugins/sdk"
)
// IoTController extends IoTClient with control operations.
type IoTController interface {
GetDevice(ctx context.Context, deviceID string) (*sdk.IoTDeviceState, error)
SetDeviceProperty(ctx context.Context, deviceID, property string, value interface{}) error
ToggleDevice(ctx context.Context, deviceID string) (*sdk.IoTDeviceState, error)
}
type IoTControlPlugin struct {
sdk.BasePlugin
iotClient IoTController
}
func NewIoTControlPlugin(client IoTController) *IoTControlPlugin {
return &IoTControlPlugin{iotClient: client}
}
func (p *IoTControlPlugin) Metadata() sdk.PluginMetadata {
return sdk.PluginMetadata{
Name: "iot_control", DisplayName: "IoT Device Control", Version: "1.0.0",
Description: "Control smart home devices: toggle, set temperature/brightness/mode/color",
Category: "iot", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
}
}
func (p *IoTControlPlugin) Tools() []sdk.Tool {
return []sdk.Tool{&IoTControlTool{iotClient: p.iotClient}}
}
type IoTControlTool struct {
sdk.BaseTool
iotClient IoTController
}
func (t *IoTControlTool) Definition() sdk.ToolDefinition {
return sdk.ToolDefinition{
ID: "iot_control", Name: "iot_control", DisplayName: "IoT Device Control",
Description: "Control smart home devices. Supports toggle, turn_on, turn_off, set_temperature, set_brightness, set_position, set_mode, set_color.",
Category: "iot", Complexity: sdk.ComplexitySimple,
DangerLevel: "medium",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"device_id": map[string]interface{}{"type": "string"},
"action": map[string]interface{}{"type": "string", "enum": []string{"toggle", "turn_on", "turn_off", "set_temperature", "set_brightness", "set_position", "set_mode", "set_color"}},
"value": map[string]interface{}{},
},
"required": []string{"device_id", "action"},
},
}
}
func (t *IoTControlTool) Validate(args map[string]interface{}) error {
for _, k := range []string{"device_id", "action"} {
if _, ok := args[k]; !ok {
return fmt.Errorf("missing required parameter: %s", k)
}
}
return nil
}
func (t *IoTControlTool) Execute(ctx context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
if t.iotClient == nil {
return &sdk.ToolResult{ToolName: "iot_control", Success: false, Error: "IoT client not configured"}, nil
}
deviceID, _ := args["device_id"].(string)
if deviceID == "" {
deviceID, _ = args["entity_id"].(string)
}
action := normalizeAction(args)
switch action {
case "turn_on", "turn_off":
status := "on"
if action == "turn_off" {
status = "off"
}
if err := t.iotClient.SetDeviceProperty(ctx, deviceID, "status", status); err != nil {
return &sdk.ToolResult{ToolName: "iot_control", Success: false, Error: err.Error()}, nil
}
return &sdk.ToolResult{ToolName: "iot_control", Success: true,
Output: fmt.Sprintf("Device %s turned %s", deviceID, status)}, nil
case "set_temperature":
value := toFloat64(args["value"])
old := ""
if dev, err := t.iotClient.GetDevice(ctx, deviceID); err == nil {
old = fmt.Sprintf(" (was %.1fC)", dev.Temperature)
}
if err := t.iotClient.SetDeviceProperty(ctx, deviceID, "temperature", value); err != nil {
return &sdk.ToolResult{ToolName: "iot_control", Success: false, Error: err.Error()}, nil
}
return &sdk.ToolResult{ToolName: "iot_control", Success: true,
Output: fmt.Sprintf("Temperature set to %.1fC%s", value, old)}, nil
case "set_brightness":
value := toFloat64(args["value"])
if err := t.iotClient.SetDeviceProperty(ctx, deviceID, "brightness", value); err != nil {
return &sdk.ToolResult{ToolName: "iot_control", Success: false, Error: err.Error()}, nil
}
return &sdk.ToolResult{ToolName: "iot_control", Success: true,
Output: fmt.Sprintf("Brightness set to %.0f%%", value)}, nil
case "set_position":
value := toFloat64(args["value"])
if err := t.iotClient.SetDeviceProperty(ctx, deviceID, "position", value); err != nil {
return &sdk.ToolResult{ToolName: "iot_control", Success: false, Error: err.Error()}, nil
}
return &sdk.ToolResult{ToolName: "iot_control", Success: true,
Output: fmt.Sprintf("Position set to %.0f%%", value)}, nil
case "set_mode":
value, _ := args["value"].(string)
if err := t.iotClient.SetDeviceProperty(ctx, deviceID, "mode", value); err != nil {
return &sdk.ToolResult{ToolName: "iot_control", Success: false, Error: err.Error()}, nil
}
return &sdk.ToolResult{ToolName: "iot_control", Success: true,
Output: fmt.Sprintf("Mode set to %s", value)}, nil
case "set_color":
value, _ := args["value"].(string)
if err := t.iotClient.SetDeviceProperty(ctx, deviceID, "color", value); err != nil {
return &sdk.ToolResult{ToolName: "iot_control", Success: false, Error: err.Error()}, nil
}
return &sdk.ToolResult{ToolName: "iot_control", Success: true,
Output: fmt.Sprintf("Color set to %s", value)}, nil
case "toggle":
dev, err := t.iotClient.ToggleDevice(ctx, deviceID)
if err != nil {
return &sdk.ToolResult{ToolName: "iot_control", Success: false, Error: err.Error()}, nil
}
return &sdk.ToolResult{ToolName: "iot_control", Success: true,
Output: fmt.Sprintf("Device %s toggled to %s", deviceID, dev.Status)}, nil
default:
return &sdk.ToolResult{ToolName: "iot_control", Success: false,
Error: fmt.Sprintf("unknown action: %s", action)}, nil
}
}
func normalizeAction(args map[string]interface{}) string {
action, _ := args["action"].(string)
// Chinese aliases
switch action {
case "打开":
return "turn_on"
case "关闭", "关掉", "关上":
return "turn_off"
case "设置温度", "调温度":
return "set_temperature"
case "设置亮度", "调亮度":
return "set_brightness"
case "设置位置":
return "set_position"
case "设置模式":
return "set_mode"
case "设置颜色":
return "set_color"
case "开关", "切换":
return "toggle"
}
return action
}
func toFloat64(v interface{}) float64 {
switch n := v.(type) {
case float64:
return n
case int:
return float64(n)
case int64:
return float64(n)
case string:
var f float64
fmt.Sscanf(n, "%f", &f)
return f
}
return 0
}
+120
View File
@@ -0,0 +1,120 @@
package iotquery
import (
"context"
"fmt"
"git.yeij.top/AskaEth/Cyrene-Plugins/sdk"
)
// IoTClient is the interface for IoT device access.
type IoTClient interface {
GetAllDevices(ctx context.Context) ([]sdk.IoTDeviceState, error)
GetDevice(ctx context.Context, deviceID string) (*sdk.IoTDeviceState, error)
}
type IoTQueryPlugin struct {
sdk.BasePlugin
iotClient IoTClient
}
func NewIoTQueryPlugin(client IoTClient) *IoTQueryPlugin {
return &IoTQueryPlugin{iotClient: client}
}
func (p *IoTQueryPlugin) Metadata() sdk.PluginMetadata {
return sdk.PluginMetadata{
Name: "iot_query", DisplayName: "IoT Device Query", Version: "1.0.0",
Description: "Query smart home device status (single device or all devices)",
Category: "iot", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
}
}
func (p *IoTQueryPlugin) Tools() []sdk.Tool { return []sdk.Tool{&IoTQueryTool{iotClient: p.iotClient}} }
type IoTQueryTool struct {
sdk.BaseTool
iotClient IoTClient
}
func (t *IoTQueryTool) Definition() sdk.ToolDefinition {
return sdk.ToolDefinition{
ID: "iot_query", Name: "iot_query", DisplayName: "IoT Device Query",
Description: "Query smart home device status. Device status is typically auto-injected; use this only when status is stale.",
Category: "iot", Complexity: sdk.ComplexitySimple,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{"device_id": map[string]interface{}{"type": "string"}},
},
}
}
func (t *IoTQueryTool) Validate(args map[string]interface{}) error { return nil }
func (t *IoTQueryTool) Execute(ctx context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
if t.iotClient == nil {
return &sdk.ToolResult{ToolName: "iot_query", Success: false, Error: "IoT client not configured"}, nil
}
deviceID, _ := args["device_id"].(string)
if deviceID != "" {
dev, err := t.iotClient.GetDevice(ctx, deviceID)
if err != nil {
return &sdk.ToolResult{ToolName: "iot_query", Success: false, Error: err.Error()}, nil
}
return &sdk.ToolResult{ToolName: "iot_query", Success: true, Output: formatDevice(dev)}, nil
}
devices, err := t.iotClient.GetAllDevices(ctx)
if err != nil {
return &sdk.ToolResult{ToolName: "iot_query", Success: false, Error: err.Error()}, nil
}
if len(devices) == 0 {
return &sdk.ToolResult{ToolName: "iot_query", Success: true, Output: "No devices found"}, nil
}
var out string
for _, d := range devices {
out += formatDeviceLine(&d) + "\n"
}
return &sdk.ToolResult{ToolName: "iot_query", Success: true, Output: out}, nil
}
func formatDevice(d *sdk.IoTDeviceState) string {
emoji := deviceEmoji(d.Type)
return fmt.Sprintf("%s %s (%s)\n Status: %s\n ID: %s", emoji, d.Name, d.Type, d.Status, d.ID)
}
func formatDeviceLine(d *sdk.IoTDeviceState) string {
emoji := deviceEmoji(d.Type)
switch d.Type {
case "light":
return fmt.Sprintf("%s %s: %s (brightness: %d, color: %s)", emoji, d.Name, d.Status, d.Brightness, d.Color)
case "ac":
return fmt.Sprintf("%s %s: %s (mode: %s, temp: %.1fC)", emoji, d.Name, d.Status, d.Mode, d.Temperature)
case "curtain":
return fmt.Sprintf("%s %s: %s (position: %d%%)", emoji, d.Name, d.Status, d.Position)
case "sensor":
return fmt.Sprintf("%s %s: %.1f%s", emoji, d.Name, d.Value, d.Unit)
case "lock":
return fmt.Sprintf("%s %s: %s (battery: %d%%)", emoji, d.Name, d.Status, d.Battery)
default:
return fmt.Sprintf("%s %s: %s", emoji, d.Name, d.Status)
}
}
func deviceEmoji(t string) string {
switch t {
case "light":
return "\U0001F4A1"
case "ac":
return "❄️"
case "curtain":
return "\U0001F3E0"
case "sensor":
return "\U0001F4CA"
case "lock":
return "\U0001F512"
default:
return "\U0001F4E6"
}
}
+132
View File
@@ -0,0 +1,132 @@
package jsonplugin
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"git.yeij.top/AskaEth/Cyrene-Plugins/sdk"
)
type JSONPlugin struct{ sdk.BasePlugin }
func (p *JSONPlugin) Metadata() sdk.PluginMetadata {
return sdk.PluginMetadata{
Name: "json", DisplayName: "JSON Processor", Version: "1.0.0",
Description: "JSON parsing, dot-path query, validation, pretty-print",
Category: "format", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
}
}
func (p *JSONPlugin) Tools() []sdk.Tool { return []sdk.Tool{&JSONTool{}} }
type JSONTool struct{ sdk.BaseTool }
func (t *JSONTool) Definition() sdk.ToolDefinition {
return sdk.ToolDefinition{
ID: "json_ops", Name: "json_ops", DisplayName: "JSON Processor",
Description: "JSON processing. Parse/pretty-print, query by dot-notation path, validate.",
Category: "format", Complexity: sdk.ComplexitySimple,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{"type": "string", "enum": []string{"parse", "query", "validate"}},
"json_string": map[string]interface{}{"type": "string"},
"path": map[string]interface{}{"type": "string"},
},
"required": []string{"action", "json_string"},
},
}
}
func (t *JSONTool) Validate(args map[string]interface{}) error {
for _, k := range []string{"action", "json_string"} {
if _, ok := args[k]; !ok {
return fmt.Errorf("missing required parameter: %s", k)
}
}
return nil
}
func (t *JSONTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
action, _ := args["action"].(string)
jsonStr, _ := args["json_string"].(string)
switch action {
case "parse":
var v interface{}
if err := json.Unmarshal([]byte(jsonStr), &v); err != nil {
return &sdk.ToolResult{ToolName: "json_ops", Success: false, Error: err.Error()}, nil
}
pretty, err := json.MarshalIndent(v, "", " ")
if err != nil {
return &sdk.ToolResult{ToolName: "json_ops", Success: false, Error: err.Error()}, nil
}
return &sdk.ToolResult{ToolName: "json_ops", Success: true, Output: string(pretty)}, nil
case "query":
var v interface{}
if err := json.Unmarshal([]byte(jsonStr), &v); err != nil {
return &sdk.ToolResult{ToolName: "json_ops", Success: false, Error: err.Error()}, nil
}
path, _ := args["path"].(string)
if path == "" {
return &sdk.ToolResult{ToolName: "json_ops", Success: false, Error: "path is required for query"}, nil
}
result, err := jsonPathQuery(v, path)
if err != nil {
return &sdk.ToolResult{ToolName: "json_ops", Success: false, Error: err.Error()}, nil
}
out, _ := json.Marshal(result)
return &sdk.ToolResult{ToolName: "json_ops", Success: true, Output: string(out)}, nil
case "validate":
var v interface{}
if err := json.Unmarshal([]byte(jsonStr), &v); err != nil {
return &sdk.ToolResult{ToolName: "json_ops", Success: true, Output: "Invalid JSON: " + err.Error()}, nil
}
typeStr := "unknown"
switch v.(type) {
case map[string]interface{}:
typeStr = "object"
case []interface{}:
typeStr = "array"
case string:
typeStr = "string"
case float64:
typeStr = "number"
case bool:
typeStr = "boolean"
}
return &sdk.ToolResult{ToolName: "json_ops", Success: true,
Output: fmt.Sprintf("Valid JSON (type: %s, size: %d bytes)", typeStr, len(jsonStr))}, nil
}
return &sdk.ToolResult{ToolName: "json_ops", Success: false, Error: "unknown action: " + action}, nil
}
func jsonPathQuery(root interface{}, path string) (interface{}, error) {
path = strings.TrimPrefix(path, "$.")
parts := strings.Split(path, ".")
current := root
for _, part := range parts {
switch v := current.(type) {
case map[string]interface{}:
var ok bool
current, ok = v[part]
if !ok {
return nil, fmt.Errorf("key %q not found", part)
}
case []interface{}:
idx, err := strconv.Atoi(part)
if err != nil || idx < 0 || idx >= len(v) {
return nil, fmt.Errorf("invalid array index: %s", part)
}
current = v[idx]
default:
return nil, fmt.Errorf("cannot traverse into %T at path segment %q", current, part)
}
}
return current, nil
}
+226
View File
@@ -0,0 +1,226 @@
package manager
import (
"context"
"fmt"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene-Plugins/sdk"
)
// PluginManager manages the lifecycle of all plugins and their tools.
type PluginManager struct {
mu sync.RWMutex
plugins map[string]*pluginEntry
registry *ToolRegistry
host sdk.HostAPI
}
type pluginEntry struct {
instance sdk.Plugin
info sdk.PluginInfo
cancel context.CancelFunc
}
func NewPluginManager(registry *ToolRegistry, host sdk.HostAPI) *PluginManager {
return &PluginManager{
plugins: make(map[string]*pluginEntry),
registry: registry,
host: host,
}
}
// Install registers a plugin instance.
func (m *PluginManager) Install(plugin sdk.Plugin) error {
meta := plugin.Metadata()
m.mu.Lock()
defer m.mu.Unlock()
if _, exists := m.plugins[meta.Name]; exists {
return fmt.Errorf("plugin %q is already installed", meta.Name)
}
m.plugins[meta.Name] = &pluginEntry{
instance: plugin,
info: sdk.PluginInfo{
Metadata: meta,
Status: sdk.StatusInstalled,
InstalledAt: time.Now(),
Enabled: false,
},
}
return nil
}
// Enable activates a plugin: Init → register tools → Start.
func (m *PluginManager) Enable(ctx context.Context, pluginName string) error {
m.mu.Lock()
entry, ok := m.plugins[pluginName]
m.mu.Unlock()
if !ok {
return fmt.Errorf("plugin %q not found", pluginName)
}
m.mu.Lock()
entry.info.Status = sdk.StatusLoaded
m.mu.Unlock()
meta := entry.instance.Metadata()
if err := entry.instance.Init(ctx, nil); err != nil {
m.mu.Lock()
entry.info.Status = sdk.StatusError
m.mu.Unlock()
return fmt.Errorf("plugin %q init failed: %w", meta.Name, err)
}
pluginCtx, cancel := context.WithCancel(context.Background())
if err := entry.instance.Start(pluginCtx, m.host); err != nil {
cancel()
m.mu.Lock()
entry.info.Status = sdk.StatusError
m.mu.Unlock()
return fmt.Errorf("plugin %q start failed: %w", meta.Name, err)
}
tools := entry.instance.Tools()
toolIDs := make([]string, 0, len(tools))
for _, t := range tools {
if err := m.registry.Register(t); err != nil {
m.registry.UnregisterAll(toolIDs)
cancel()
m.mu.Lock()
entry.info.Status = sdk.StatusError
m.mu.Unlock()
return fmt.Errorf("plugin %q tool register failed: %w", meta.Name, err)
}
toolIDs = append(toolIDs, t.Definition().ID)
}
m.mu.Lock()
entry.cancel = cancel
entry.info.Status = sdk.StatusRunning
entry.info.Enabled = true
entry.info.Tools = toolIDs
m.mu.Unlock()
return nil
}
// Disable stops a plugin and unregisters its tools.
func (m *PluginManager) Disable(ctx context.Context, pluginName string) error {
m.mu.Lock()
entry, ok := m.plugins[pluginName]
m.mu.Unlock()
if !ok {
return fmt.Errorf("plugin %q not found", pluginName)
}
if err := entry.instance.Stop(ctx); err != nil {
return fmt.Errorf("plugin %q stop failed: %w", pluginName, err)
}
if entry.cancel != nil {
entry.cancel()
}
m.registry.UnregisterAll(entry.info.Tools)
m.mu.Lock()
entry.info.Status = sdk.StatusDisabled
entry.info.Enabled = false
entry.info.Tools = nil
m.mu.Unlock()
return nil
}
// List returns info for all installed plugins.
func (m *PluginManager) List() []sdk.PluginInfo {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]sdk.PluginInfo, 0, len(m.plugins))
for _, entry := range m.plugins {
result = append(result, entry.info)
}
return result
}
// Get returns info for a single plugin.
func (m *PluginManager) Get(pluginName string) (*sdk.PluginInfo, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
entry, ok := m.plugins[pluginName]
if !ok {
return nil, false
}
info := entry.info
return &info, true
}
// EnableAll starts all installed plugins.
func (m *PluginManager) EnableAll(ctx context.Context) []error {
m.mu.RLock()
names := make([]string, 0, len(m.plugins))
for name := range m.plugins {
names = append(names, name)
}
m.mu.RUnlock()
var errs []error
for _, name := range names {
if err := m.Enable(ctx, name); err != nil {
errs = append(errs, fmt.Errorf("%s: %w", name, err))
}
}
return errs
}
// Uninstall removes a plugin completely.
func (m *PluginManager) Uninstall(ctx context.Context, pluginName string) error {
m.mu.RLock()
entry, ok := m.plugins[pluginName]
m.mu.RUnlock()
if !ok {
return fmt.Errorf("plugin %q not found", pluginName)
}
if entry.info.Status == sdk.StatusRunning {
if err := m.Disable(ctx, pluginName); err != nil {
return err
}
}
m.mu.Lock()
defer m.mu.Unlock()
delete(m.plugins, pluginName)
return nil
}
// Reload stops and re-starts a plugin.
func (m *PluginManager) Reload(ctx context.Context, pluginName string) error {
if err := m.Disable(ctx, pluginName); err != nil {
return fmt.Errorf("reload disable: %w", err)
}
return m.Enable(ctx, pluginName)
}
// Shutdown stops all running plugins gracefully.
func (m *PluginManager) Shutdown(ctx context.Context) []error {
m.mu.RLock()
names := make([]string, 0, len(m.plugins))
for name, entry := range m.plugins {
if entry.info.Status == sdk.StatusRunning {
names = append(names, name)
}
}
m.mu.RUnlock()
var errs []error
for _, name := range names {
if err := m.Disable(ctx, name); err != nil {
errs = append(errs, err)
}
}
return errs
}
// Registry returns the aggregated tool registry.
func (m *PluginManager) Registry() *ToolRegistry {
return m.registry
}
+326
View File
@@ -0,0 +1,326 @@
package manager
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene-Plugins/sdk"
)
// CtxKeyIsAdmin is the context key for the admin flag.
type ctxKey string
const CtxKeyIsAdmin ctxKey = "isAdmin"
// adminOnlyTools lists tools that require admin permission to execute.
var adminOnlyTools = map[string]bool{
"host_exec": true,
"os_exec": true,
"host_file": true,
}
// IsAdminFromCtx returns true if the context carries an admin flag.
func IsAdminFromCtx(ctx context.Context) bool {
v, _ := ctx.Value(CtxKeyIsAdmin).(bool)
return v
}
// CallLogRecord 工具调用记录
type CallLogRecord struct {
CallID string `json:"call_id"`
ToolName string `json:"tool_name"`
Arguments string `json:"arguments"`
Output string `json:"output"`
Error string `json:"error"`
Success bool `json:"success"`
DurationMs int `json:"duration_ms"`
Timestamp int64 `json:"timestamp"`
}
// callLogRing 线程安全的环形缓冲区
type callLogRing struct {
mu sync.Mutex
records []CallLogRecord
capacity int
head int
size int
}
func newCallLogRing(capacity int) *callLogRing {
return &callLogRing{capacity: capacity, records: make([]CallLogRecord, capacity)}
}
func (r *callLogRing) push(rec CallLogRecord) {
r.mu.Lock()
defer r.mu.Unlock()
rec.CallID = fmt.Sprintf("%d", time.Now().UnixNano())
rec.Timestamp = time.Now().UnixMilli()
r.records[r.head] = rec
r.head = (r.head + 1) % r.capacity
if r.size < r.capacity {
r.size++
}
}
func (r *callLogRing) getAll() []CallLogRecord {
r.mu.Lock()
defer r.mu.Unlock()
result := make([]CallLogRecord, r.size)
for i := 0; i < r.size; i++ {
idx := (r.head - 1 - i) % r.capacity
if idx < 0 {
idx += r.capacity
}
result[i] = r.records[idx]
}
return result
}
func (r *callLogRing) statsByTool() map[string]map[string]interface{} {
r.mu.Lock()
defer r.mu.Unlock()
byTool := make(map[string]map[string]interface{})
for i := 0; i < r.size; i++ {
idx := (r.head - 1 - i) % r.capacity
if idx < 0 {
idx += r.capacity
}
rec := r.records[idx]
if _, ok := byTool[rec.ToolName]; !ok {
byTool[rec.ToolName] = map[string]interface{}{
"tool_name": rec.ToolName, "count": 0, "success_count": 0,
"fail_count": 0, "total_duration_ms": 0,
}
}
s := byTool[rec.ToolName]
s["count"] = s["count"].(int) + 1
if rec.Success {
s["success_count"] = s["success_count"].(int) + 1
} else {
s["fail_count"] = s["fail_count"].(int) + 1
}
s["total_duration_ms"] = s["total_duration_ms"].(int) + rec.DurationMs
}
return byTool
}
// ToolRegistry aggregates tool definitions from all running plugins and dispatches execution.
type ToolRegistry struct {
mu sync.RWMutex
tools map[string]sdk.Tool // tool ID -> Tool
callLog *callLogRing
enabled bool
}
func NewToolRegistry() *ToolRegistry {
return &ToolRegistry{
tools: make(map[string]sdk.Tool),
callLog: newCallLogRing(500),
enabled: true,
}
}
// IsEnabled returns whether tool execution is enabled.
func (r *ToolRegistry) IsEnabled() bool {
r.mu.RLock()
defer r.mu.RUnlock()
return r.enabled
}
// SetEnabled enables or disables tool execution.
func (r *ToolRegistry) SetEnabled(enabled bool) {
r.mu.Lock()
defer r.mu.Unlock()
r.enabled = enabled
}
// DefinitionNames returns all registered tool names.
func (r *ToolRegistry) DefinitionNames() []string {
r.mu.RLock()
defer r.mu.RUnlock()
names := make([]string, 0, len(r.tools))
for id := range r.tools {
names = append(names, id)
}
return names
}
func (r *ToolRegistry) Register(tool sdk.Tool) error {
r.mu.Lock()
defer r.mu.Unlock()
id := tool.Definition().ID
if _, exists := r.tools[id]; exists {
return fmt.Errorf("tool %q already registered", id)
}
r.tools[id] = tool
return nil
}
func (r *ToolRegistry) Unregister(toolID string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.tools, toolID)
}
func (r *ToolRegistry) Get(toolID string) (sdk.Tool, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
t, ok := r.tools[toolID]
return t, ok
}
func (r *ToolRegistry) List() []sdk.Tool {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]sdk.Tool, 0, len(r.tools))
for _, t := range r.tools {
result = append(result, t)
}
return result
}
func (r *ToolRegistry) Definitions() []sdk.ToolDefinition {
r.mu.RLock()
defer r.mu.RUnlock()
defs := make([]sdk.ToolDefinition, 0, len(r.tools))
for _, t := range r.tools {
defs = append(defs, t.Definition())
}
return defs
}
func (r *ToolRegistry) Execute(ctx context.Context, toolID string, args map[string]interface{}) (*sdk.ToolResult, error) {
r.mu.RLock()
tool, ok := r.tools[toolID]
r.mu.RUnlock()
startTime := time.Now()
if !ok {
r.callLog.push(CallLogRecord{
ToolName: toolID, Error: fmt.Sprintf("tool %q not found", toolID),
Success: false, DurationMs: int(time.Since(startTime).Milliseconds()),
})
return nil, fmt.Errorf("tool %q not found", toolID)
}
if err := tool.Validate(args); err != nil {
r.callLog.push(CallLogRecord{
ToolName: toolID, Error: err.Error(), Success: false,
DurationMs: int(time.Since(startTime).Milliseconds()),
})
return &sdk.ToolResult{Success: false, Error: err.Error()}, nil
}
// Admin-only tools: deny non-admin callers.
if adminOnlyTools[toolID] && !IsAdminFromCtx(ctx) {
errMsg := fmt.Sprintf("工具 %s 仅限管理员使用", toolID)
r.callLog.push(CallLogRecord{
ToolName: toolID, Error: errMsg, Success: false,
DurationMs: int(time.Since(startTime).Milliseconds()),
})
return &sdk.ToolResult{Success: false, Error: errMsg}, nil
}
result, err := tool.Execute(ctx, args)
durationMs := int(time.Since(startTime).Milliseconds())
if err != nil {
r.callLog.push(CallLogRecord{
ToolName: toolID, Error: err.Error(), Success: false, DurationMs: durationMs,
})
return result, err
}
var argsJSON string
if args != nil {
if b, _ := json.Marshal(args); b != nil {
argsJSON = string(b)
}
}
r.callLog.push(CallLogRecord{
ToolName: toolID, Arguments: argsJSON, Output: result.Output,
Error: result.Error, Success: result.Success, DurationMs: durationMs,
})
return result, nil
}
// UnregisterAll removes all tools matching given IDs.
func (r *ToolRegistry) UnregisterAll(toolIDs []string) {
r.mu.Lock()
defer r.mu.Unlock()
for _, id := range toolIDs {
delete(r.tools, id)
}
}
// GetCallLogs 获取工具调用记录(最新在前,支持按工具名过滤、分页)
func (r *ToolRegistry) GetCallLogs(toolName string, limit, offset int) ([]CallLogRecord, int) {
all := r.callLog.getAll()
// 过滤
var filtered []CallLogRecord
if toolName == "" {
filtered = all
} else {
filtered = make([]CallLogRecord, 0)
for _, rec := range all {
if rec.ToolName == toolName {
filtered = append(filtered, rec)
}
}
}
total := len(filtered)
// 分页
if offset >= len(filtered) {
return []CallLogRecord{}, total
}
page := filtered[offset:]
if limit > 0 && limit < len(page) {
page = page[:limit]
}
return page, total
}
// GetCallStats 获取工具调用统计
func (r *ToolRegistry) GetCallStats() map[string]interface{} {
byTool := r.callLog.statsByTool()
totalCalls, successCount, failCount, totalDurationMs := 0, 0, 0, 0
toolStats := make([]map[string]interface{}, 0, len(byTool))
for _, s := range byTool {
count := s["count"].(int)
success := s["success_count"].(int)
fail := s["fail_count"].(int)
totalDur := s["total_duration_ms"].(int)
avgDur := 0.0
if count > 0 {
avgDur = float64(totalDur) / float64(count)
}
s["avg_duration_ms"] = avgDur
delete(s, "total_duration_ms")
toolStats = append(toolStats, s)
totalCalls += count
successCount += success
failCount += fail
totalDurationMs += totalDur
}
avgDuration := 0.0
if totalCalls > 0 {
avgDuration = float64(totalDurationMs) / float64(totalCalls)
}
successRate := 0.0
if totalCalls > 0 {
successRate = float64(successCount) / float64(totalCalls) * 100
}
return map[string]interface{}{
"total_calls": totalCalls, "success_count": successCount, "fail_count": failCount,
"success_rate": successRate, "avg_duration_ms": avgDuration, "by_tool": toolStats,
}
}
+184
View File
@@ -0,0 +1,184 @@
package markdown
import (
"context"
"fmt"
"regexp"
"strings"
"git.yeij.top/AskaEth/Cyrene-Plugins/sdk"
)
type MarkdownPlugin struct{ sdk.BasePlugin }
func (p *MarkdownPlugin) Metadata() sdk.PluginMetadata {
return sdk.PluginMetadata{
Name: "markdown", DisplayName: "Markdown Processor", Version: "1.0.0",
Description: "Markdown processing: to HTML, extract text/links/code, generate TOC",
Category: "format", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
}
}
func (p *MarkdownPlugin) Tools() []sdk.Tool { return []sdk.Tool{&MarkdownTool{}} }
type MarkdownTool struct{ sdk.BaseTool }
func (t *MarkdownTool) Definition() sdk.ToolDefinition {
return sdk.ToolDefinition{
ID: "markdown", Name: "markdown", DisplayName: "Markdown Processor",
Description: "Markdown processing. Convert to HTML, extract plain text, extract links/code blocks, generate TOC.",
Category: "format", Complexity: sdk.ComplexitySimple,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{"type": "string", "enum": []string{"to_html", "to_text", "extract_links", "extract_code", "table_of_contents"}},
"markdown": map[string]interface{}{"type": "string"},
},
"required": []string{"action", "markdown"},
},
}
}
func (t *MarkdownTool) Validate(args map[string]interface{}) error {
for _, k := range []string{"action", "markdown"} {
if _, ok := args[k]; !ok {
return fmt.Errorf("missing required parameter: %s", k)
}
}
return nil
}
func (t *MarkdownTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
action, _ := args["action"].(string)
md, _ := args["markdown"].(string)
switch action {
case "to_html":
return &sdk.ToolResult{ToolName: "markdown", Success: true, Output: mdToHTML(md)}, nil
case "to_text":
text := md
reCode := regexp.MustCompile("(?s)```.*?```")
text = reCode.ReplaceAllString(text, "")
text = regexp.MustCompile(`\*\*([^*]+)\*\*`).ReplaceAllString(text, "$1")
text = regexp.MustCompile(`\*([^*]+)\*`).ReplaceAllString(text, "$1")
text = regexp.MustCompile(`~~([^~]+)~~`).ReplaceAllString(text, "$1")
text = regexp.MustCompile(`^#{1,6}\s+`).ReplaceAllString(text, "")
text = regexp.MustCompile(`^[*-]\s+`).ReplaceAllString(text, "- ")
text = regexp.MustCompile(`^>\s+`).ReplaceAllString(text, "")
text = regexp.MustCompile(`\n{3,}`).ReplaceAllString(text, "\n\n")
return &sdk.ToolResult{ToolName: "markdown", Success: true, Output: strings.TrimSpace(text)}, nil
case "extract_links":
re := regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
matches := re.FindAllStringSubmatch(md, -1)
if len(matches) == 0 {
return &sdk.ToolResult{ToolName: "markdown", Success: true, Output: "No links found"}, nil
}
var out strings.Builder
for i, m := range matches {
out.WriteString(fmt.Sprintf("%d. %s -> %s\n", i+1, m[1], m[2]))
}
return &sdk.ToolResult{ToolName: "markdown", Success: true, Output: out.String()}, nil
case "extract_code":
re := regexp.MustCompile("(?s)```(\\w*)\n?(.*?)```")
matches := re.FindAllStringSubmatch(md, -1)
if len(matches) == 0 {
return &sdk.ToolResult{ToolName: "markdown", Success: true, Output: "No code blocks found"}, nil
}
var out strings.Builder
for i, m := range matches {
lang := m[1]
if lang == "" {
lang = "text"
}
code := m[2]
if len([]rune(code)) > 500 {
code = string([]rune(code)[:500]) + "..."
}
out.WriteString(fmt.Sprintf("--- Block %d (%s) ---\n%s\n\n", i+1, lang, code))
}
return &sdk.ToolResult{ToolName: "markdown", Success: true, Output: out.String()}, nil
case "table_of_contents":
re := regexp.MustCompile(`(?m)^(#{1,6})\s+(.+)$`)
matches := re.FindAllStringSubmatch(md, -1)
if len(matches) == 0 {
return &sdk.ToolResult{ToolName: "markdown", Success: true, Output: "No headings found"}, nil
}
var out strings.Builder
for _, m := range matches {
depth := len(m[1])
indent := strings.Repeat(" ", depth-1)
out.WriteString(fmt.Sprintf("%s- %s\n", indent, m[2]))
}
return &sdk.ToolResult{ToolName: "markdown", Success: true, Output: out.String()}, nil
}
return &sdk.ToolResult{ToolName: "markdown", Success: false, Error: "unknown action: " + action}, nil
}
func mdToHTML(md string) string {
// Save code blocks
type placeholder struct {
orig string
content string
language string
}
blocks := []*placeholder{}
reCode := regexp.MustCompile("(?s)```(\\w*)\n?(.*?)```")
md = reCode.ReplaceAllStringFunc(md, func(s string) string {
m := reCode.FindStringSubmatch(s)
b := &placeholder{orig: fmt.Sprintf("\x00CODE%d\x00", len(blocks)), language: m[1], content: escapeHTML(m[2])}
blocks = append(blocks, b)
return b.orig
})
// Inline elements
md = regexp.MustCompile("`([^`]+)`").ReplaceAllString(md, "<code>$1</code>")
md = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]+)\)`).ReplaceAllString(md, `<img src="$2" alt="$1">`)
md = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`).ReplaceAllString(md, `<a href="$2">$1</a>`)
md = regexp.MustCompile(`\*\*([^*]+)\*\*`).ReplaceAllString(md, `<strong>$1</strong>`)
md = regexp.MustCompile(`\*([^*]+)\*`).ReplaceAllString(md, `<em>$1</em>`)
md = regexp.MustCompile(`~~([^~]+)~~`).ReplaceAllString(md, `<del>$1</del>`)
md = regexp.MustCompile(`(?m)^#{6}\s+(.+)$`).ReplaceAllString(md, `<h6>$1</h6>`)
md = regexp.MustCompile(`(?m)^#{5}\s+(.+)$`).ReplaceAllString(md, `<h5>$1</h5>`)
md = regexp.MustCompile(`(?m)^#{4}\s+(.+)$`).ReplaceAllString(md, `<h4>$1</h4>`)
md = regexp.MustCompile(`(?m)^#{3}\s+(.+)$`).ReplaceAllString(md, `<h3>$1</h3>`)
md = regexp.MustCompile(`(?m)^#{2}\s+(.+)$`).ReplaceAllString(md, `<h2>$1</h2>`)
md = regexp.MustCompile(`(?m)^#{1}\s+(.+)$`).ReplaceAllString(md, `<h1>$1</h1>`)
md = regexp.MustCompile(`(?m)^---\s*$`).ReplaceAllString(md, `<hr>`)
md = regexp.MustCompile(`(?m)^>\s+(.+)$`).ReplaceAllString(md, `<blockquote>$1</blockquote>`)
// Restore code blocks
for _, b := range blocks {
langAttr := ""
if b.language != "" {
langAttr = " class=\"language-" + b.language + "\""
}
md = strings.Replace(md, b.orig, "<pre><code"+langAttr+">"+b.content+"</code></pre>", 1)
}
// Paragraphs
lines := strings.Split(md, "\n")
var out strings.Builder
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
if strings.HasPrefix(trimmed, "<") {
out.WriteString(trimmed + "\n")
} else {
out.WriteString("<p>" + trimmed + "</p>\n")
}
}
return out.String()
}
func escapeHTML(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
return s
}
+175
View File
@@ -0,0 +1,175 @@
package random
import (
"context"
"crypto/rand"
"fmt"
"math/big"
mathrand "math/rand"
"strings"
"git.yeij.top/AskaEth/Cyrene-Plugins/sdk"
)
type RandomPlugin struct{ sdk.BasePlugin }
func (p *RandomPlugin) Metadata() sdk.PluginMetadata {
return sdk.PluginMetadata{
Name: "random", DisplayName: "Random Generator", Version: "1.0.0",
Description: "Random generation: numbers, UUIDs, secure passwords, pick/shuffle",
Category: "utility", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
}
}
func (p *RandomPlugin) Tools() []sdk.Tool { return []sdk.Tool{&RandomTool{}} }
type RandomTool struct{ sdk.BaseTool }
func (t *RandomTool) Definition() sdk.ToolDefinition {
return sdk.ToolDefinition{
ID: "random", Name: "random", DisplayName: "Random Generator",
Description: "Random generation. Random numbers, UUID v4, secure passwords, pick from list, shuffle list.",
Category: "utility", Complexity: sdk.ComplexitySimple,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{"type": "string", "enum": []string{"number", "uuid", "password", "pick", "shuffle"}},
"min": map[string]interface{}{"type": "number"},
"max": map[string]interface{}{"type": "number"},
"length": map[string]interface{}{"type": "number"},
"items": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}},
"count": map[string]interface{}{"type": "number"},
},
"required": []string{"action"},
},
}
}
func (t *RandomTool) Validate(args map[string]interface{}) error {
if _, ok := args["action"]; !ok {
return fmt.Errorf("missing required parameter: action")
}
return nil
}
func (t *RandomTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
action, _ := args["action"].(string)
switch action {
case "number":
min := getIntArg(args, "min", 0)
max := getIntArg(args, "max", 100)
n, err := rand.Int(rand.Reader, big.NewInt(int64(max-min+1)))
if err != nil {
return &sdk.ToolResult{ToolName: "random", Success: false, Error: err.Error()}, nil
}
return &sdk.ToolResult{ToolName: "random", Success: true,
Output: fmt.Sprintf("%d", int(n.Int64())+min)}, nil
case "uuid":
uuid := make([]byte, 16)
rand.Read(uuid)
uuid[6] = (uuid[6] & 0x0f) | 0x40
uuid[8] = (uuid[8] & 0x3f) | 0x80
return &sdk.ToolResult{ToolName: "random", Success: true,
Output: fmt.Sprintf("%x-%x-%x-%x-%x", uuid[0:4], uuid[4:6], uuid[6:8], uuid[8:10], uuid[10:])}, nil
case "password":
length := getIntArg(args, "length", 16)
if length < 4 {
length = 4
}
if length > 128 {
length = 128
}
upper := "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
lower := "abcdefghijklmnopqrstuvwxyz"
digits := "0123456789"
symbols := "!@#$%^&*()_+-=[]{}|;:,.<>?"
all := upper + lower + digits + symbols
bytes := make([]byte, length)
for i := range bytes {
idx, _ := rand.Int(rand.Reader, big.NewInt(int64(len(all))))
bytes[i] = all[idx.Int64()]
}
return &sdk.ToolResult{ToolName: "random", Success: true, Output: string(bytes)}, nil
case "pick":
items := getStringSliceArg(args, "items")
if len(items) == 0 {
return &sdk.ToolResult{ToolName: "random", Success: false, Error: "items list is empty"}, nil
}
count := getIntArg(args, "count", 1)
if count > len(items) {
count = len(items)
}
indices := shuffledIndices(len(items))
picked := make([]string, count)
for i := 0; i < count; i++ {
picked[i] = items[indices[i]]
}
return &sdk.ToolResult{ToolName: "random", Success: true, Output: strings.Join(picked, ", ")}, nil
case "shuffle":
items := getStringSliceArg(args, "items")
indices := shuffledIndices(len(items))
shuffled := make([]string, len(items))
for i, idx := range indices {
shuffled[i] = items[idx]
}
return &sdk.ToolResult{ToolName: "random", Success: true, Output: strings.Join(shuffled, ", ")}, nil
}
return &sdk.ToolResult{ToolName: "random", Success: false, Error: "unknown action: " + action}, nil
}
func getIntArg(args map[string]interface{}, key string, defaultVal int) int {
v, ok := args[key]
if !ok {
return defaultVal
}
switch n := v.(type) {
case float64:
return int(n)
case int:
return n
case int64:
return int(n)
}
return defaultVal
}
func getStringSliceArg(args map[string]interface{}, key string) []string {
v, ok := args[key]
if !ok {
return nil
}
switch s := v.(type) {
case []string:
return s
case []interface{}:
result := make([]string, len(s))
for i, item := range s {
result[i] = fmt.Sprint(item)
}
return result
}
return nil
}
func shuffledIndices(n int) []int {
indices := make([]int, n)
for i := range indices {
indices[i] = i
}
for i := n - 1; i > 0; i-- {
jBig, err := rand.Int(rand.Reader, big.NewInt(int64(i+1)))
if err != nil {
j := mathrand.Intn(i + 1)
indices[i], indices[j] = indices[j], indices[i]
continue
}
j := int(jBig.Int64())
indices[i], indices[j] = indices[j], indices[i]
}
return indices
}
+40
View File
@@ -0,0 +1,40 @@
package sdk
import (
"context"
"fmt"
)
// BasePlugin provides default implementations for optional Plugin methods.
type BasePlugin struct{}
func (BasePlugin) Init(_ context.Context, _ PluginConfig) error { return nil }
func (BasePlugin) Start(_ context.Context, _ HostAPI) error { return nil }
func (BasePlugin) Stop(_ context.Context) error { return nil }
func (BasePlugin) Health(_ context.Context) error { return nil }
// BaseTool provides a Validate default that checks required parameters.
type BaseTool struct {
Def ToolDefinition
Required []string
}
func (b BaseTool) Definition() ToolDefinition { return b.Def }
func (b BaseTool) Complexity() ToolComplexity { return ComplexitySimple }
func (b BaseTool) Validate(args map[string]interface{}) error {
for _, key := range b.Required {
if _, ok := args[key]; !ok {
return fmt.Errorf("missing required parameter: %s", key)
}
}
return nil
}
func (b BaseTool) Execute(_ context.Context, _ map[string]interface{}) (*ToolResult, error) {
return nil, fmt.Errorf("not implemented")
}
+35
View File
@@ -0,0 +1,35 @@
package sdk
// PluginPermissions defines what a plugin is allowed to do.
type PluginPermissions struct {
NetworkAllowed bool `json:"networkAllowed"`
AllowedHosts []string `json:"allowedHosts,omitempty"`
IoTRead bool `json:"iotRead"`
IoTWrite bool `json:"iotWrite"`
MemoryRead bool `json:"memoryRead"`
MemoryWrite bool `json:"memoryWrite"`
FileRead bool `json:"fileRead"`
FileWrite bool `json:"fileWrite"`
AllowedPaths []string `json:"allowedPaths,omitempty"`
ExecAllowed bool `json:"execAllowed"`
MaxCPUPercent float64 `json:"maxCPUPercent"`
MaxMemoryMB int `json:"maxMemoryMB"`
}
// DefaultPermissions returns a safe default permission set.
func DefaultPermissions() PluginPermissions {
return PluginPermissions{
NetworkAllowed: false,
AllowedHosts: []string{},
IoTRead: false,
IoTWrite: false,
MemoryRead: false,
MemoryWrite: false,
FileRead: false,
FileWrite: false,
AllowedPaths: []string{},
ExecAllowed: false,
MaxCPUPercent: 10.0,
MaxMemoryMB: 128,
}
}
+49
View File
@@ -0,0 +1,49 @@
package sdk
import (
"context"
"net/http"
)
// Plugin is the main interface every plugin must implement.
type Plugin interface {
Metadata() PluginMetadata
Init(ctx context.Context, config PluginConfig) error
Start(ctx context.Context, host HostAPI) error
Stop(ctx context.Context) error
Health(ctx context.Context) error
Tools() []Tool
}
// Tool is the interface every tool must implement.
type Tool interface {
Definition() ToolDefinition
Execute(ctx context.Context, args map[string]interface{}) (*ToolResult, error)
Validate(args map[string]interface{}) error
Complexity() ToolComplexity
}
// ComplexTool extends Tool for async multi-round execution.
type ComplexTool interface {
Tool
ExecuteAsync(ctx context.Context, args map[string]interface{}) (<-chan ToolProgress, error)
Cancel(ctx context.Context, executionID string) error
}
// HostAPI gives plugins access to Cyrene core capabilities.
type HostAPI interface {
CallLLM(ctx context.Context, messages []LLMMessage) (*LLMResponse, error)
SearchMemory(ctx context.Context, userID, query string, limit int) ([]MemoryEntry, error)
StoreMemory(ctx context.Context, entry MemoryEntry) error
Logger() Logger
GetConfig(key string) (string, error)
SetConfig(key, value string) error
PublishEvent(ctx context.Context, event map[string]interface{}) error
HTTPClient() *http.Client
}
// Logger is a minimal logging interface for plugins.
type Logger interface {
Printf(format string, args ...interface{})
Println(args ...interface{})
}
+134
View File
@@ -0,0 +1,134 @@
package sdk
import "time"
// ToolComplexity grades tools into simple (single-call, <2s) and complex (multi-round, async).
type ToolComplexity string
const (
ComplexitySimple ToolComplexity = "simple"
ComplexityComplex ToolComplexity = "complex"
)
// PluginMetadata describes a plugin's identity and requirements.
type PluginMetadata struct {
Name string `json:"name"`
DisplayName string `json:"displayName"`
Version string `json:"version"`
MinCyreneVersion string `json:"minCyreneVersion"`
Author PluginAuthor `json:"author"`
Description string `json:"description"`
License string `json:"license"`
Keywords []string `json:"keywords,omitempty"`
Category string `json:"category"`
Dependencies map[string]string `json:"dependencies,omitempty"` // plugin name -> version range
Homepage string `json:"homepage,omitempty"`
Repository string `json:"repository,omitempty"`
}
type PluginAuthor struct {
Name string `json:"name"`
Email string `json:"email,omitempty"`
URL string `json:"url,omitempty"`
}
// PluginConfig holds runtime configuration for a plugin.
type PluginConfig map[string]interface{}
// ToolDefinition describes a tool's interface for LLM function calling.
type ToolDefinition struct {
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"displayName"`
Description string `json:"description"`
Category string `json:"category"`
Complexity ToolComplexity `json:"complexity"`
Parameters map[string]interface{} `json:"parameters"`
Returns map[string]interface{} `json:"returns,omitempty"`
TimeoutMs int `json:"timeout_ms,omitempty"`
MaxRetries int `json:"max_retries,omitempty"`
DangerLevel string `json:"danger_level,omitempty"` // low / medium / high
}
// ToolResult is the standard tool execution result.
type ToolResult struct {
ToolName string `json:"tool_name"`
Success bool `json:"success"`
Output string `json:"output,omitempty"`
Error string `json:"error,omitempty"`
DurationMs int64 `json:"duration_ms,omitempty"`
}
// ToolProgress reports execution progress for complex (async) tools.
type ToolProgress struct {
ExecutionID string `json:"execution_id"`
Status string `json:"status"` // started / running / completed / failed / cancelled
Progress float64 `json:"progress"` // 0.0 - 1.0
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
Result *ToolResult `json:"result,omitempty"`
}
// PluginStatus represents the current lifecycle state of a plugin.
type PluginStatus string
const (
StatusInstalled PluginStatus = "installed"
StatusLoaded PluginStatus = "loaded"
StatusRunning PluginStatus = "running"
StatusPaused PluginStatus = "paused"
StatusError PluginStatus = "error"
StatusDisabled PluginStatus = "disabled"
)
// PluginInfo is the runtime view of an installed plugin.
type PluginInfo struct {
Metadata PluginMetadata `json:"metadata"`
Status PluginStatus `json:"status"`
Tools []string `json:"tools"` // tool IDs provided by this plugin
InstalledAt time.Time `json:"installed_at"`
Enabled bool `json:"enabled"`
}
// LLMMessage is a message in an LLM conversation.
type LLMMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
// LLMResponse is the result of an LLM call.
type LLMResponse struct {
Content string `json:"content"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
}
// ToolCall represents a tool call requested by the LLM.
type ToolCall struct {
ID string `json:"id"`
Name string `json:"name"`
Arguments map[string]interface{} `json:"arguments"`
}
// IoTDeviceState is the shared device state across IoT plugins.
type IoTDeviceState struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
Brightness int `json:"brightness,omitempty"`
Color string `json:"color,omitempty"`
Mode string `json:"mode,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
Position int `json:"position,omitempty"`
Value float64 `json:"value,omitempty"`
Unit string `json:"unit,omitempty"`
Battery int `json:"battery,omitempty"`
}
// MemoryEntry is a memory record.
type MemoryEntry struct {
UserID string `json:"user_id"`
Content string `json:"content"`
Type string `json:"type"`
Meta map[string]interface{} `json:"meta,omitempty"`
}
+177
View File
@@ -0,0 +1,177 @@
package text
import (
"context"
"fmt"
"regexp"
"strings"
"unicode"
"git.yeij.top/AskaEth/Cyrene-Plugins/sdk"
)
type TextPlugin struct{ sdk.BasePlugin }
func (p *TextPlugin) Metadata() sdk.PluginMetadata {
return sdk.PluginMetadata{
Name: "text", DisplayName: "Text Processing", Version: "1.0.0",
Description: "Text processing: count stats, summarize, regex extract",
Category: "utility", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
}
}
func (p *TextPlugin) Tools() []sdk.Tool { return []sdk.Tool{&TextTool{}} }
type TextTool struct{ sdk.BaseTool }
func (t *TextTool) Definition() sdk.ToolDefinition {
return sdk.ToolDefinition{
ID: "text", Name: "text", DisplayName: "Text Processing",
Description: "Text processing. Count stats, summarize, translate, regex extract.",
Category: "utility", Complexity: sdk.ComplexitySimple,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{"type": "string", "enum": []string{"count", "summarize", "translate", "extract"}},
"text": map[string]interface{}{"type": "string"},
"target_lang": map[string]interface{}{"type": "string", "enum": []string{"en", "zh", "ja", "ko", "fr", "de"}},
"pattern": map[string]interface{}{"type": "string"},
},
"required": []string{"action", "text"},
},
}
}
func (t *TextTool) Validate(args map[string]interface{}) error {
for _, k := range []string{"action", "text"} {
if _, ok := args[k]; !ok {
return fmt.Errorf("missing required parameter: %s", k)
}
}
return nil
}
func (t *TextTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
action, _ := args["action"].(string)
txt, _ := args["text"].(string)
switch action {
case "count":
charsNoSpace := 0
chineseChars := 0
for _, r := range txt {
if !unicode.IsSpace(r) {
charsNoSpace++
}
if unicode.Is(unicode.Han, r) {
chineseChars++
}
}
words := strings.Fields(txt)
lines := strings.Split(txt, "\n")
paragraphs := regexp.MustCompile(`\n\s*\n`).Split(txt, -1)
return &sdk.ToolResult{ToolName: "text", Success: true, Output: fmt.Sprintf(
"Characters: %d (no spaces: %d, Chinese: %d)\nBytes: %d\nWords: %d\nLines: %d\nParagraphs: %d",
len([]rune(txt)), charsNoSpace, chineseChars, len(txt), len(words), len(lines), len(paragraphs))}, nil
case "summarize":
paragraphs := regexp.MustCompile(`\n\s*\n`).Split(txt, -1)
firstPara := ""
if len(paragraphs) > 0 {
runes := []rune(paragraphs[0])
if len(runes) > 300 {
runes = runes[:300]
}
firstPara = string(runes)
}
sentences := regexp.MustCompile(`[。!?.!?]+`).Split(txt, -1)
keywords := []string{"重要", "关键", "因此", "总结", "important", "key", "conclusion", "therefore"}
type scored struct {
text string
score int
}
var scoredSents []scored
for _, s := range sentences {
s = strings.TrimSpace(s)
if len([]rune(s)) < 10 {
continue
}
score := len([]rune(s))
for _, kw := range keywords {
if strings.Contains(strings.ToLower(s), strings.ToLower(kw)) {
score += 20
}
}
scoredSents = append(scoredSents, scored{s, score})
}
var out strings.Builder
out.WriteString(fmt.Sprintf("First paragraph: %s\n\nKey sentences:\n", firstPara))
count := 0
for i := 0; i < len(scoredSents) && count < 5; i++ {
out.WriteString(fmt.Sprintf("- %s\n", scoredSents[i].text))
count++
}
return &sdk.ToolResult{ToolName: "text", Success: true, Output: out.String()}, nil
case "translate":
targetLang, _ := args["target_lang"].(string)
if targetLang == "" {
targetLang = "en"
}
return &sdk.ToolResult{ToolName: "text", Success: true, Output: fmt.Sprintf(
"[Translation request] Please translate the following text to %s.\n\nOriginal text:\n%s", targetLang, txt)}, nil
case "extract":
pattern, _ := args["pattern"].(string)
var out strings.Builder
extracted := false
if pattern == "" || pattern == "email" {
re := regexp.MustCompile(`[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}`)
if matches := re.FindAllString(txt, -1); len(matches) > 0 {
out.WriteString("Emails:\n")
for _, m := range matches {
out.WriteString(fmt.Sprintf("- %s\n", m))
}
extracted = true
}
}
if pattern == "" || pattern == "phone" {
re := regexp.MustCompile(`1[3-9]\d{9}`)
if matches := re.FindAllString(txt, -1); len(matches) > 0 {
out.WriteString("Phone numbers:\n")
for _, m := range matches {
out.WriteString(fmt.Sprintf("- %s\n", m))
}
extracted = true
}
}
if pattern == "" || pattern == "url" {
re := regexp.MustCompile(`https?://[^\s<>"{}|\\^` + "`" + `\[\]]+`)
if matches := re.FindAllString(txt, -1); len(matches) > 0 {
out.WriteString("URLs:\n")
for _, m := range matches {
out.WriteString(fmt.Sprintf("- %s\n", m))
}
extracted = true
}
}
if !extracted && pattern != "" && pattern != "email" && pattern != "phone" && pattern != "url" {
re, err := regexp.Compile(pattern)
if err != nil {
return &sdk.ToolResult{ToolName: "text", Success: false, Error: "Invalid regex: " + err.Error()}, nil
}
if matches := re.FindAllString(txt, -1); len(matches) > 0 {
out.WriteString(fmt.Sprintf("Pattern matches (%s):\n", pattern))
for _, m := range matches {
out.WriteString(fmt.Sprintf("- %s\n", m))
}
extracted = true
}
}
if !extracted {
return &sdk.ToolResult{ToolName: "text", Success: true, Output: "No matches found"}, nil
}
return &sdk.ToolResult{ToolName: "text", Success: true, Output: out.String()}, nil
}
return &sdk.ToolResult{ToolName: "text", Success: false, Error: "unknown action: " + action}, nil
}
+113
View File
@@ -0,0 +1,113 @@
package webfetch
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene-Plugins/sdk"
)
type WebFetchPlugin struct {
sdk.BasePlugin
client *http.Client
}
func NewWebFetchPlugin() *WebFetchPlugin {
return &WebFetchPlugin{client: &http.Client{Timeout: 15 * time.Second}}
}
func (p *WebFetchPlugin) Metadata() sdk.PluginMetadata {
return sdk.PluginMetadata{
Name: "web_fetch", DisplayName: "Web Fetch", Version: "1.0.0",
Description: "Fetch and extract text content from URLs",
Category: "network", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
}
}
func (p *WebFetchPlugin) Tools() []sdk.Tool { return []sdk.Tool{&WebFetchTool{client: p.client}} }
type WebFetchTool struct {
sdk.BaseTool
client *http.Client
}
func (t *WebFetchTool) Definition() sdk.ToolDefinition {
return sdk.ToolDefinition{
ID: "web_fetch", Name: "web_fetch", DisplayName: "Web Fetch",
Description: "Fetch content of a specified URL. Returns plain text summary (first 2000 characters). HTTP/HTTPS only.",
Category: "network", Complexity: sdk.ComplexitySimple,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{"url": map[string]interface{}{"type": "string"}},
"required": []string{"url"},
},
}
}
func (t *WebFetchTool) Validate(args map[string]interface{}) error {
if _, ok := args["url"]; !ok {
return fmt.Errorf("missing required parameter: url")
}
return nil
}
func (t *WebFetchTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
urlStr, _ := args["url"].(string)
if !strings.HasPrefix(urlStr, "http://") && !strings.HasPrefix(urlStr, "https://") {
return &sdk.ToolResult{ToolName: "web_fetch", Success: false, Error: "only http/https URLs allowed"}, nil
}
req, _ := http.NewRequest("GET", urlStr, nil)
req.Header.Set("User-Agent", "CyreneBot/1.0")
resp, err := t.client.Do(req)
if err != nil {
return &sdk.ToolResult{ToolName: "web_fetch", Success: false, Error: err.Error()}, nil
}
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 100*1024))
text := stripHTMLFull(string(bodyBytes))
text = removeBlankLines(text)
runes := []rune(text)
if len(runes) > 2000 {
text = string(runes[:2000]) + "..."
}
return &sdk.ToolResult{ToolName: "web_fetch", Success: true, Output: fmt.Sprintf(
"URL: %s\nStatus: %d\nContent-Type: %s\n\n%s",
urlStr, resp.StatusCode, resp.Header.Get("Content-Type"), text)}, nil
}
func stripHTMLFull(s string) string {
result := make([]rune, 0, len([]rune(s)))
inTag := false
for _, r := range s {
if r == '<' {
inTag = true
continue
}
if r == '>' {
inTag = false
continue
}
if !inTag {
result = append(result, r)
}
}
return string(result)
}
func removeBlankLines(s string) string {
lines := strings.Split(s, "\n")
var result []string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed != "" {
result = append(result, trimmed)
}
}
return strings.Join(result, "\n")
}
+239
View File
@@ -0,0 +1,239 @@
package websearch
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene-Plugins/sdk"
)
type WebSearchPlugin struct {
sdk.BasePlugin
client *http.Client
searxngURL string
}
func NewWebSearchPlugin() *WebSearchPlugin {
return &WebSearchPlugin{client: &http.Client{Timeout: 10 * time.Second}}
}
func NewWebSearchPluginWithURL(searxngURL string) *WebSearchPlugin {
return &WebSearchPlugin{
client: &http.Client{Timeout: 10 * time.Second},
searxngURL: strings.TrimRight(searxngURL, "/"),
}
}
func (p *WebSearchPlugin) Metadata() sdk.PluginMetadata {
return sdk.PluginMetadata{
Name: "web_search", DisplayName: "Web Search", Version: "1.1.0",
Description: "Search the internet via SearXNG (or DuckDuckGo fallback)",
Category: "network", Author: sdk.PluginAuthor{Name: "Cyrene Team"},
}
}
func (p *WebSearchPlugin) Tools() []sdk.Tool {
return []sdk.Tool{&WebSearchTool{client: p.client, searxngURL: p.searxngURL}}
}
type WebSearchTool struct {
sdk.BaseTool
client *http.Client
searxngURL string
}
// ---- SearXNG response types ----
type searxngResponse struct {
Query string `json:"query"`
NumberOrResults int `json:"number_of_results"`
Results []searxngResult `json:"results"`
Answers []string `json:"answers"`
Suggestions []string `json:"suggestions"`
}
type searxngResult struct {
Title string `json:"title"`
URL string `json:"url"`
Content string `json:"content"`
Engine string `json:"engine"`
Score float64 `json:"score"`
}
// ---- DuckDuckGo response types (fallback) ----
type ddgResponse struct {
Abstract string `json:"Abstract"`
AbstractText string `json:"AbstractText"`
Answer string `json:"Answer"`
Heading string `json:"Heading"`
Results []ddgTopic `json:"Results"`
RelatedTopics []ddgTopic `json:"RelatedTopics"`
}
type ddgTopic struct {
FirstURL string `json:"FirstURL"`
Text string `json:"Text"`
}
func (t *WebSearchTool) Definition() sdk.ToolDefinition {
return sdk.ToolDefinition{
ID: "web_search", Name: "web_search", DisplayName: "Web Search",
Description: "Search the internet. SearXNG backend with DuckDuckGo fallback. Returns up to 5 results.",
Category: "network", Complexity: sdk.ComplexitySimple,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{"query": map[string]interface{}{"type": "string"}},
"required": []string{"query"},
},
}
}
func (t *WebSearchTool) Validate(args map[string]interface{}) error {
if _, ok := args["query"]; !ok {
return fmt.Errorf("missing required parameter: query")
}
return nil
}
func (t *WebSearchTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) {
query, _ := args["query"].(string)
if query == "" {
return &sdk.ToolResult{ToolName: "web_search", Success: false, Error: "empty query"}, nil
}
if t.searxngURL != "" {
return t.searchViaSearXNG(query)
}
return t.searchViaDuckDuckGo(query)
}
// China-accessible SearXNG engines (baidu, sogou, 360search, bing all work from China)
const searxngEngines = "bing,sogou,360search,baidu"
func (t *WebSearchTool) searchViaSearXNG(query string) (*sdk.ToolResult, error) {
apiURL := fmt.Sprintf("%s/search?format=json&engines=%s&q=%s",
t.searxngURL, searxngEngines, url.QueryEscape(query))
resp, err := t.client.Get(apiURL)
if err != nil {
return &sdk.ToolResult{ToolName: "web_search", Success: false,
Error: fmt.Sprintf("SearXNG request failed: %v", err)}, nil
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return &sdk.ToolResult{ToolName: "web_search", Success: false,
Error: fmt.Sprintf("SearXNG returned HTTP %d", resp.StatusCode)}, nil
}
var result searxngResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return &sdk.ToolResult{ToolName: "web_search", Success: false,
Error: fmt.Sprintf("SearXNG parse error: %v", err)}, nil
}
var out strings.Builder
out.WriteString(fmt.Sprintf("搜索: %s (共%d条结果)\n\n", query, result.NumberOrResults))
// 优先显示答案(如 Wikipedia infobox
for _, answer := range result.Answers {
out.WriteString(fmt.Sprintf("📌 %s\n\n", answer))
}
// 搜索结果(最多5条,按score排序)
count := 0
for _, r := range result.Results {
if count >= 5 {
break
}
if r.Title == "" || r.URL == "" {
continue
}
content := cleanSnippet(r.Content)
out.WriteString(fmt.Sprintf("%d. **%s**\n %s\n %s\n\n", count+1, r.Title, r.URL, content))
count++
}
if out.Len() == 0 {
return &sdk.ToolResult{ToolName: "web_search", Success: true,
Output: fmt.Sprintf("未找到与「%s」相关的结果。", query)}, nil
}
return &sdk.ToolResult{ToolName: "web_search", Success: true, Output: out.String()}, nil
}
func (t *WebSearchTool) searchViaDuckDuckGo(query string) (*sdk.ToolResult, error) {
apiURL := fmt.Sprintf("https://api.duckduckgo.com/?q=%s&format=json&no_html=1", url.QueryEscape(query))
resp, err := t.client.Get(apiURL)
if err != nil {
return &sdk.ToolResult{ToolName: "web_search", Success: false, Error: err.Error()}, nil
}
defer resp.Body.Close()
var result ddgResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return &sdk.ToolResult{ToolName: "web_search", Success: false, Error: err.Error()}, nil
}
var out strings.Builder
if result.Answer != "" {
out.WriteString(fmt.Sprintf("Answer: %s\n\n", result.Answer))
}
if result.AbstractText != "" {
text := result.AbstractText
if len([]rune(text)) > 500 {
text = string([]rune(text)[:500]) + "..."
}
out.WriteString(fmt.Sprintf("Abstract: %s\n\n", stripHTML(text)))
}
topics := result.Results
if len(topics) == 0 {
topics = result.RelatedTopics
}
count := 0
for _, topic := range topics {
if count >= 5 {
break
}
if topic.Text == "" {
continue
}
out.WriteString(fmt.Sprintf("%d. %s (%s)\n", count+1, stripHTML(topic.Text), topic.FirstURL))
count++
}
if out.Len() == 0 {
return &sdk.ToolResult{ToolName: "web_search", Success: true,
Output: "No results found for: " + query}, nil
}
return &sdk.ToolResult{ToolName: "web_search", Success: true, Output: out.String()}, nil
}
func cleanSnippet(s string) string {
runes := []rune(strings.TrimSpace(s))
if len(runes) > 200 {
return string(runes[:200]) + "..."
}
return string(runes)
}
func stripHTML(s string) string {
result := make([]rune, 0, len([]rune(s)))
inTag := false
for _, r := range s {
if r == '<' {
inTag = true
continue
}
if r == '>' {
inTag = false
continue
}
if !inTag {
result = append(result, r)
}
}
return strings.TrimSpace(string(result))
}