1 Commits

Author SHA1 Message Date
AskaEth dd256da0a6 Initial commit 2026-06-06 09:46:42 +08:00
28 changed files with 75 additions and 3608 deletions
-38
View File
@@ -1,38 +0,0 @@
# 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/
+73
View File
@@ -0,0 +1,73 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
Copyright 2026 AskaEth
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+2 -82
View File
@@ -1,83 +1,3 @@
# Cyrene Plugins # 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
@@ -1,279 +0,0 @@
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
@@ -1,47 +0,0 @@
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
}
@@ -1,32 +0,0 @@
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
}
@@ -1,210 +0,0 @@
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
@@ -1,112 +0,0 @@
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
@@ -1,100 +0,0 @@
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
@@ -1,116 +0,0 @@
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
@@ -1,170 +0,0 @@
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
@@ -1,158 +0,0 @@
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
@@ -1,3 +0,0 @@
module git.yeij.top/AskaEth/Cyrene-Plugins
go 1.21
-122
View File
@@ -1,122 +0,0 @@
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
@@ -1,189 +0,0 @@
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
@@ -1,120 +0,0 @@
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
@@ -1,132 +0,0 @@
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
@@ -1,226 +0,0 @@
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
@@ -1,326 +0,0 @@
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
@@ -1,184 +0,0 @@
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
@@ -1,175 +0,0 @@
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
@@ -1,40 +0,0 @@
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
@@ -1,35 +0,0 @@
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
@@ -1,49 +0,0 @@
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
@@ -1,134 +0,0 @@
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
@@ -1,177 +0,0 @@
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
@@ -1,113 +0,0 @@
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
@@ -1,239 +0,0 @@
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))
}