fix: cap distiller startup scan and switch to formal SDK module dependency

- replace vendored third_party/homeagent-sdk with formal module dependency
- keep canonical SDK via go.mod pinned commit
- optimize distiller startup loading to stream recent raw records only
- parse timestamps correctly and cap startup load to 5000 records
- remove quadratic string building in raw record parsing
- restore fast startup while preserving memory pipeline
This commit is contained in:
root
2026-07-06 20:18:13 +08:00
parent 50e5dec745
commit b55f1dcf0e
32 changed files with 65 additions and 4322 deletions

View File

@ -1,2 +0,0 @@
*.so
*.hmap

View File

@ -1,145 +0,0 @@
# HomeAgent Plugin SDK
HomeAgent 外部插件开发工具包。用于开发独立于内核的 `.so` 动态插件。
## 目录结构
```
homeagent-sdk/
├── sdk/ # Go SDK 包import: gitcode.com/JianFeeeee/homeagent-sdk/sdk
│ ├── plugin.go # Plugin 接口、PluginSDK、ToolDef、ToolHandler
│ ├── settings.go # SettingsAPI插件配置读写
│ ├── memory.go # MemoryAPI / TextMemoryAPI / DocMemoryAPI
│ ├── knowledge.go # KnowledgeAPI知识库访问
│ ├── llm.go # LLMAPILLM 源管理)
│ └── API.md # 完整 API 参考文档
├── hack/plugin-dev/ # 开发工具
│ ├── scaffold.sh # 脚手架:生成新插件项目
│ ├── packager.sh # 打包插件为 .hmap 分发包
│ └── testharness/ # 插件测试框架
├── example/ # 完整插件示例
│ ├── qq/ # QQ 集成(对接 NapCat OneBot
│ ├── files/ # 文件系统操作
│ ├── memo/ # 备忘提醒
│ └── web/ # 网络搜索与抓取
└── README.md
```
## 快速开始
### 前置条件
- Go 1.21+
- 运行中的 HomeAgent 内核(用于部署插件)
### 创建插件
```bash
git clone https://gitcode.com/JianFeeeee/homeagent-sdk.git
cd homeagent-sdk
# 用脚手架生成项目骨架
hack/plugin-dev/scaffold.sh myplugin ./plugins/myplugin
# 编辑插件代码
vim plugins/myplugin/plugin.go
```
### 插件接口
每个插件必须实现三个方法:
```go
type Plugin interface {
Name() string // 插件名称
Start(sdk *PluginSDK) error // 启动:注册工具、阶段钩子等
Stop() error // 停止:清理资源
}
```
入口函数签名(插件 .so 必须导出此函数):
```go
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error)
```
### 编译
```bash
# 从插件目录
cd plugins/myplugin && make
# 或手动编译
cd <SDK_REPO_ROOT> && go build -buildmode=plugin -o <PLUGIN_DIR>/plugin.so <PLUGIN_DIR>
```
### 部署
将插件目录放入 HomeAgent 内核的插件目录(`<dataDir>/plugins/<name>/`
```
<dataDir>/plugins/myplugin/
plugin.json — {"name": "myplugin", "version": "1.0", "entry": "plugin.so"}
plugin.so — 编译产物
```
内核启动时自动发现并加载。也可通过 WebUI 插件管理页面上传 `.hmap` 包安装。
## PluginSDK API 参考
完整 API 文档见 [sdk/API.md](sdk/API.md),涵盖:
- **工具注册** — `RegisterTool``ToolDef``ToolHandler`
- **阶段钩子** — 7 个阶段的 `StageContext` 读写权限、工具归属插件字段和短路规则
- **输入投递** — `InjectText` / `InjectInterruptText` 两种投递方式
- **配置管理** — `SettingsAPI`,含自身/核心/跨插件配置
- **记忆访问** — 图记忆(`MemoryAPI`)、文档记忆(`DocMemoryAPI`)、文本记忆(`TextMemoryAPI`
- **知识库** — `KnowledgeAPI` 搜索/添加/列表
- **LLM 管理** — `LLMAPI` 源切换
- **所有 SDK 类型定义** — `ToolCall``StageContext``Entity``Triple``ConfigDef`
## 打包分发
```bash
hack/plugin-dev/packager.sh plugins/myplugin
# 输出: dist/myplugin-0.1.0.hmap
```
`.hmap` 文件是一个 zip 包,内含:
- `plugin.json` — 清单文件(名称、版本、入口)
- `plugin.so` — 编译好的 Go 插件
通过 WebUI 插件管理器上传安装。
## 测试
SDK 提供测试框架 `testharness`,可加载 .so 并模拟调用:
```go
import "gitcode.com/JianFeeeee/homeagent-sdk/hack/plugin-dev/testharness"
func TestMyPlugin(t *testing.T) {
h := testharness.New(t, "./plugin.so")
defer h.Close()
result, err := h.CallTool("myplugin_my_tool", map[string]interface{}{
"input": "hello",
})
// ...
}
```
## 示例插件
每个示例目录下都有对应的 `README.md`,包含详细的设计讲解和源码引用。
| 示例 | 说明 | 详细文档 |
|------|------|----------|
| [QQ](example/qq/) | 对接 NapCat OneBot15 个工具,涵盖消息/群/好友/文件/OCR | [讲解](example/qq/README.md) |
| [Files](example/files/) | 文件系统操作4 种写入模式,分段读取,沙箱隔离 | [讲解](example/files/README.md) |
| [Memo](example/memo/) | 备忘管理PreAction 注入 + 定时打断双提醒 | [讲解](example/memo/README.md) |
| [Web](example/web/) | DuckDuckGo 搜索 + 网页抓取SSRF 防护,代理支持 | [讲解](example/web/README.md) |
## License
MIT

View File

@ -1,84 +0,0 @@
# files 插件讲解
文件系统操作插件,提供文件的读写编辑和目录浏览能力。
## 工具清单
| 工具 | 功能 | 源码 |
|------|------|------|
| `files_read` | 读取文件内容,支持 offset/limit 分段 | `handleRead` |
| `files_write` | 写入文件,支持 4 种模式 | `handleWrite` |
| `files_edit` | 精确字符串替换编辑 | `handleEdit` |
| `files_ls` | 列出目录内容 | `handleLs` |
## 核心设计
### 沙箱路径隔离
`resolvePath()` 方法将用户传入的路径解析为沙箱内的绝对路径。关键逻辑:
```go
// 相对路径以沙箱根目录为基准拼接
if !filepath.IsAbs(userPath) {
userPath = filepath.Join(p.filesDir, userPath)
}
// 检查是否越界
base := filepath.Clean(p.filesDir)
if base != "/" && !strings.HasPrefix(abs, base+string(filepath.Separator)) && abs != base {
return "", fmt.Errorf("path outside sandbox")
}
```
当沙箱根设为 `/` 时放行所有路径;设为特定目录时拒绝访问外部。配置项 `plugin.files.dir` 控制此值。
### 分段读取
`files_read` 支持 `offset`行号1-indexed`limit`(行数上限),用于大文件分段查看:
```go
// plugin.go:handleRead
lines := strings.Split(text, "\n")
offset := 0 // 从 args["offset"] 解析1-indexed 转 0-indexed
limit := totalLines - offset
// ...
end := offset + limit
selected := lines[offset:end]
```
如果未读完会在末尾追加提示 `[Showing lines X-Y of Z. Use offset=N to continue.]`
### 四种写入模式
`files_write` 通过 `mode` 参数区分:
- **overwrite**(默认):`os.WriteFile` 覆盖写入,自动创建父目录
- **append**`os.OpenFile``O_APPEND|O_CREATE|O_WRONLY` 打开,追加内容
- **insert**:将文件按行分割,在指定行号前插入新内容,再写回
- **create**:先检查文件是否已存在,存在则报错,不存在才创建
### 精确编辑
`files_edit` 接收 `edits` 数组,每个元素有 `old``new`。要求每个 `old` 在原文中**恰好出现一次**,防止 LLM 误替换:
```go
count := strings.Count(content, oldText)
if count == 0 { /* 报错未找到 */ }
if count > 1 { /* 报错存在多处匹配 */ }
content = strings.Replace(content, oldText, newText, 1)
```
### 目录列表
`files_ls` 按字母序排序,目录加 `/` 后缀,同时显示文件大小。默认上限 500 条。
## 配置项
| Key | 默认值 | 说明 |
|-----|--------|------|
| `plugin.files.dir` | `/` | 文件操作沙箱根目录 |
## 注意事项
- 所有路径操作前都经过 `resolvePath` 沙箱检查
- 错误结果统一用 `errorResult()` 返回 `{isError: true, content: msg}` 格式LLM 可据此判断
- `files_write` 的 insert/append 模式不检查文件是否存在不存在则报错overwrite/create 模式自动创建父目录

View File

@ -1,482 +0,0 @@
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
type Plugin struct {
name string
sdk *sdk.PluginSDK
mu sync.RWMutex
filesDir string
}
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name}, nil
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.sdk = s
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "plugin.files.dir",
Default: "/",
Type: "string",
DisplayName: "文件系统根目录",
Description: "文件操作允许访问的根目录(设为 / 表示完整主机文件系统)",
Category: "files",
})
dir := getSetting[string](s.Settings(), "dir", "/")
if strings.HasPrefix(dir, "~/") {
home, _ := os.UserHomeDir()
dir = filepath.Join(home, dir[2:])
}
abs, err := filepath.Abs(dir)
if err != nil {
return fmt.Errorf("resolve files.dir: %w", err)
}
p.filesDir = abs
os.MkdirAll(p.filesDir, 0755)
tp := p.name + "_"
s.RegisterTool(tp+"read", sdk.ToolDef{
Name: tp + "read",
Description: fmt.Sprintf("Read file contents within the sandbox directory (%s). Supports offset/limit for large files.", p.filesDir),
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "File path relative to sandbox or absolute"},
"offset": map[string]interface{}{"type": "integer", "description": "Starting line number (1-indexed, optional)"},
"limit": map[string]interface{}{"type": "integer", "description": "Max lines to return (optional)"},
},
"required": []string{"path"},
},
}, p.handleRead)
s.RegisterTool(tp+"write", sdk.ToolDef{
Name: tp + "write",
Description: fmt.Sprintf("Write content to a file. Creates parent directories automatically. Sandbox: %s", p.filesDir),
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "File path"},
"content": map[string]interface{}{"type": "string", "description": "Content to write"},
"mode": map[string]interface{}{"type": "string", "description": "Write mode: overwrite (default) | append | insert | create"},
"line": map[string]interface{}{"type": "integer", "description": "Line number for insert mode (1-indexed)"},
},
"required": []string{"path", "content"},
},
}, p.handleWrite)
s.RegisterTool(tp+"edit", sdk.ToolDef{
Name: tp + "edit",
Description: fmt.Sprintf("Apply exact string replacements to a file within the sandbox (%s). All edits are matched against the original file content.", p.filesDir),
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "File path relative to sandbox or absolute"},
"edits": map[string]interface{}{
"type": "array",
"description": "One or more targeted replacements. Each old must match exactly once in the original file. Do not include overlapping edits.",
"items": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"old": map[string]interface{}{"type": "string", "description": "Exact text to find (must be unique)"},
"new": map[string]interface{}{"type": "string", "description": "Replacement text"},
},
"required": []string{"old", "new"},
},
},
},
"required": []string{"path", "edits"},
},
}, p.handleEdit)
s.RegisterTool(tp+"ls", sdk.ToolDef{
Name: tp + "ls",
Description: fmt.Sprintf("List directory contents within the sandbox (%s). Directories are marked with / suffix.", p.filesDir),
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "Directory path (optional, defaults to sandbox root)"},
"limit": map[string]interface{}{"type": "integer", "description": "Max entries (optional, default 500)"},
},
},
}, p.handleLs)
log.Printf("[%s] started, sandbox: %s", p.name, p.filesDir)
return nil
}
func (p *Plugin) Stop() error {
log.Printf("[%s] stopped", p.name)
return nil
}
// resolvePath resolves user-provided path to an absolute path within filesDir.
func (p *Plugin) resolvePath(userPath string) (string, error) {
if userPath == "" {
userPath = "."
}
if !filepath.IsAbs(userPath) {
userPath = filepath.Join(p.filesDir, userPath)
}
abs, err := filepath.Abs(userPath)
if err != nil {
return "", fmt.Errorf("resolve path: %w", err)
}
base := filepath.Clean(p.filesDir)
if base != "/" && !strings.HasPrefix(abs, base+string(filepath.Separator)) && abs != base {
return "", fmt.Errorf("path outside sandbox: %s", userPath)
}
return abs, nil
}
// handleRead implements the read tool.
func (p *Plugin) handleRead(args map[string]interface{}) (interface{}, error) {
path, _ := args["path"].(string)
if path == "" {
return errorResult("path is required"), nil
}
absPath, err := p.resolvePath(path)
if err != nil {
return errorResult(err.Error()), nil
}
info, err := os.Stat(absPath)
if err != nil {
if os.IsNotExist(err) {
return errorResult("file not found: " + path), nil
}
return errorResult("stat error: " + err.Error()), nil
}
if info.IsDir() {
return errorResult("is a directory, use ls instead: " + path), nil
}
data, err := os.ReadFile(absPath)
if err != nil {
return errorResult("read error: " + err.Error()), nil
}
text := string(data)
lines := strings.Split(text, "\n")
totalLines := len(lines)
offset := 0
if v, ok := args["offset"].(float64); ok && v > 0 {
offset = int(v) - 1
}
if offset >= totalLines {
return errorResult(fmt.Sprintf("offset %d exceeds file length (%d lines)", offset+1, totalLines)), nil
}
limit := totalLines - offset
if v, ok := args["limit"].(float64); ok && v > 0 {
if int(v) < limit {
limit = int(v)
}
}
end := offset + limit
if end > totalLines {
end = totalLines
}
selected := lines[offset:end]
output := strings.Join(selected, "\n")
truncated := false
if limit < totalLines-offset {
truncated = true
}
var sb strings.Builder
sb.WriteString(output)
if truncated {
nextOffset := end + 1
sb.WriteString(fmt.Sprintf("\n\n[Showing lines %d-%d of %d. Use offset=%d to continue.]", offset+1, end, totalLines, nextOffset))
} else if offset > 0 || end < totalLines {
sb.WriteString(fmt.Sprintf("\n\n[%d lines total]", totalLines))
}
return map[string]interface{}{
"content": sb.String(),
}, nil
}
// handleWrite implements the write tool.
func (p *Plugin) handleWrite(args map[string]interface{}) (interface{}, error) {
path, _ := args["path"].(string)
if path == "" {
return errorResult("path is required"), nil
}
content, _ := args["content"].(string)
mode, _ := args["mode"].(string)
if mode == "" {
mode = "overwrite"
}
line := 0
if v, ok := args["line"].(float64); ok && v > 0 {
line = int(v)
}
absPath, err := p.resolvePath(path)
if err != nil {
return errorResult(err.Error()), nil
}
switch mode {
case "create":
if _, err := os.Stat(absPath); err == nil {
return errorResult("file already exists: " + path), nil
}
dir := filepath.Dir(absPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return errorResult("mkdir error: " + err.Error()), nil
}
if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
return errorResult("write error: " + err.Error()), nil
}
return map[string]interface{}{
"content": fmt.Sprintf("Created %s (%d bytes)", path, len(content)),
}, nil
case "append":
dir := filepath.Dir(absPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return errorResult("mkdir error: " + err.Error()), nil
}
f, err := os.OpenFile(absPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return errorResult("open error: " + err.Error()), nil
}
defer f.Close()
if _, err := f.WriteString(content); err != nil {
return errorResult("append error: " + err.Error()), nil
}
return map[string]interface{}{
"content": fmt.Sprintf("Appended %d bytes to %s", len(content), path),
}, nil
case "insert":
if line < 1 {
return errorResult("line must be >= 1 for insert mode"), nil
}
data, err := os.ReadFile(absPath)
if err != nil {
if os.IsNotExist(err) {
return errorResult("file not found: " + path), nil
}
return errorResult("read error: " + err.Error()), nil
}
lines := strings.Split(string(data), "\n")
if line > len(lines)+1 {
return errorResult(fmt.Sprintf("line %d exceeds file length (%d lines)", line, len(lines))), nil
}
idx := line - 1
newLines := make([]string, 0, len(lines)+1)
newLines = append(newLines, lines[:idx]...)
newLines = append(newLines, content)
newLines = append(newLines, lines[idx:]...)
result := strings.Join(newLines, "\n")
if err := os.WriteFile(absPath, []byte(result), 0644); err != nil {
return errorResult("write error: " + err.Error()), nil
}
return map[string]interface{}{
"content": fmt.Sprintf("Inserted %d bytes at line %d in %s", len(content), line, path),
}, nil
default: // overwrite
dir := filepath.Dir(absPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return errorResult("mkdir error: " + err.Error()), nil
}
if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
return errorResult("write error: " + err.Error()), nil
}
return map[string]interface{}{
"content": fmt.Sprintf("Wrote %d bytes to %s", len(content), path),
}, nil
}
}
// handleEdit implements the edit tool.
func (p *Plugin) handleEdit(args map[string]interface{}) (interface{}, error) {
path, _ := args["path"].(string)
if path == "" {
return errorResult("path is required"), nil
}
absPath, err := p.resolvePath(path)
if err != nil {
return errorResult(err.Error()), nil
}
rawEdits, ok := args["edits"].([]interface{})
if !ok || len(rawEdits) == 0 {
return errorResult("edits must be a non-empty array"), nil
}
data, err := os.ReadFile(absPath)
if err != nil {
if os.IsNotExist(err) {
return errorResult("file not found: " + path), nil
}
return errorResult("read error: " + err.Error()), nil
}
original := string(data)
content := original
applied := 0
var errors []string
for i, raw := range rawEdits {
edit, ok := raw.(map[string]interface{})
if !ok {
errors = append(errors, fmt.Sprintf("edit[%d]: invalid format", i))
continue
}
oldText, _ := edit["old"].(string)
newText, _ := edit["new"].(string)
if oldText == "" {
errors = append(errors, fmt.Sprintf("edit[%d]: old is required", i))
continue
}
count := strings.Count(content, oldText)
if count == 0 {
errors = append(errors, fmt.Sprintf("edit[%d]: could not find %q in %s", i, oldText, path))
continue
}
if count > 1 {
errors = append(errors, fmt.Sprintf("edit[%d]: found %d occurrences of %q, must be unique", i, count, oldText))
continue
}
content = strings.Replace(content, oldText, newText, 1)
applied++
}
if applied == 0 {
msg := "no edits applied"
if len(errors) > 0 {
msg += ": " + strings.Join(errors, "; ")
}
return errorResult(msg), nil
}
if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
return errorResult("write error: " + err.Error()), nil
}
msg := fmt.Sprintf("Successfully applied %d/%d edits to %s", applied, len(rawEdits), path)
if len(errors) > 0 {
msg += "\nWarnings:\n" + strings.Join(errors, "\n")
}
return map[string]interface{}{
"content": msg,
}, nil
}
// handleLs implements the ls tool.
func (p *Plugin) handleLs(args map[string]interface{}) (interface{}, error) {
path, _ := args["path"].(string)
if path == "" {
path = "."
}
absPath, err := p.resolvePath(path)
if err != nil {
return errorResult(err.Error()), nil
}
info, err := os.Stat(absPath)
if err != nil {
if os.IsNotExist(err) {
return errorResult("path not found: " + path), nil
}
return errorResult("stat error: " + err.Error()), nil
}
if !info.IsDir() {
return errorResult("not a directory: " + path), nil
}
entries, err := os.ReadDir(absPath)
if err != nil {
return errorResult("readdir error: " + err.Error()), nil
}
limit := 500
if v, ok := args["limit"].(float64); ok && v > 0 {
limit = int(v)
}
sort.Slice(entries, func(i, j int) bool {
return strings.ToLower(entries[i].Name()) < strings.ToLower(entries[j].Name())
})
var lines []string
entryLimitReached := false
for i, entry := range entries {
if i >= limit {
entryLimitReached = true
break
}
name := entry.Name()
if entry.IsDir() {
name += "/"
}
lines = append(lines, name)
}
if len(lines) == 0 {
return map[string]interface{}{
"content": "(empty directory)",
}, nil
}
output := strings.Join(lines, "\n")
if entryLimitReached {
output += fmt.Sprintf("\n\n[%d entries limit reached. Use limit=N for more.]", limit)
}
return map[string]interface{}{
"content": output,
}, nil
}
// errorResult returns a standardized error result.
func errorResult(msg string) map[string]interface{} {
return map[string]interface{}{
"isError": true,
"content": msg,
}
}
// getSetting reads a setting with generic type assertion.
func getSetting[T any](s sdk.SettingsAPI, key string, def T) T {
v, err := s.Get(key)
if err != nil || v == nil {
return def
}
val, ok := v.(T)
if !ok {
return def
}
return val
}

View File

@ -1,8 +0,0 @@
{
"name": "files",
"version": "1.0.0",
"description": "文件系统操作插件,提供文件读写、编辑、目录列表等工具",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": ["files", "filesystem", "io"]
}

View File

@ -1,73 +0,0 @@
# memo 插件讲解
备忘插件,支持创建、完成、列出备忘,自动提醒未完成事项。
## 工具清单
| 工具 | 功能 | 源码 |
|------|------|------|
| `memo_create` | 创建一条备忘 | `handleCreate` |
| `memo_complete` | 标记备忘为已完成 | `handleComplete` |
| `memo_list` | 列出所有未完成备忘 | `handleList` |
## 核心设计
### 数据持久化
备忘存储在 JSON 文件中,路径由核心配置 `core.daemon.data_dir` 决定:
```go
// plugin.go:Start
dataDirVal, _ := s.Settings().GetCore("core.daemon.data_dir")
p.filePath = filepath.Join(fmt.Sprint(dataDirVal), "memos.json")
p.load()
```
`load()``save()` 实现 JSON 文件的读写,格式为 `{memos: [...], next_id: N}`。每次写操作后自动 `save()`Stop 时也执行一次。
### PreAction 注入提醒
注册 `pre_action` 阶段钩子,在每次 LLM 调用前注入未完成备忘数量:
```go
// plugin.go:stagePreAction
n := p.pendingCount()
if n == 0 { return nil }
ctx.ContextMsgs = append(ctx.ContextMsgs, map[string]interface{}{
"role": "system",
"content": fmt.Sprintf("目前有%d条备忘未完成调用%slist工具读取具体内容", n, p.tp),
})
```
这样每次 LLM 处理消息时都感知到未完成备忘,无需主动查询。
### 定时打断提醒
每 5 分钟检查未完成备忘,如果有则通过中断通道提醒:
```go
// plugin.go:periodicCheck
ticker := time.NewTicker(5 * time.Minute)
for {
select {
case <-p.stopCh: return
case <-ticker.C:
n := p.pendingCount()
if n == 0 { continue }
p.sdk.InjectInterruptText(p.name, p.name,
fmt.Sprintf("注意,你还有%d条备忘未标记完成请检查", n))
}
}
```
中断消息会打断当前 LLM 处理,在下一轮工具循环前插入 `[打断消息]`,确保 agent 不会长期忽略未完成备忘。
### 工具返回值
所有工具返回 `{content: string}``{isError: true, content: string}` 格式LLM 通过 content 字段获取结果文本。
## 注意事项
- `memo_create` 的 content 参数应包含事项的完整描述,方便后续回顾
- `memo_complete` 只标记为 done不删除数据保留历史
- 更早的暂停时自动 save防丢数据

View File

@ -1,273 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
type Memo struct {
ID int64 `json:"id"`
Content string `json:"content"`
CreatedAt int64 `json:"created_at"`
Done bool `json:"done"`
}
type Plugin struct {
name string
sdk *sdk.PluginSDK
mu sync.RWMutex
memos []Memo
nextID int64
filePath string
stopCh chan struct{}
tp string
}
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name}, nil
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.sdk = s
p.tp = p.name + "_"
p.stopCh = make(chan struct{})
dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir")
if err != nil || dataDirVal == "" {
dataDirVal = "."
}
p.filePath = filepath.Join(fmt.Sprint(dataDirVal), "memos.json")
p.load()
s.RegisterTool(p.tp+"create", sdk.ToolDef{
Name: p.tp + "create",
Description: "创建一条备忘条目。备忘内容应包含具体事项的完整描述。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"content": map[string]interface{}{"type": "string", "description": "备忘内容"},
},
"required": []string{"content"},
},
}, p.handleCreate)
s.RegisterTool(p.tp+"complete", sdk.ToolDef{
Name: p.tp + "complete",
Description: "将指定ID的备忘标记为已完成。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"id": map[string]interface{}{"type": "integer", "description": "备忘ID"},
},
"required": []string{"id"},
},
}, p.handleComplete)
s.RegisterTool(p.tp+"list", sdk.ToolDef{
Name: p.tp + "list",
Description: "列出所有未完成的备忘条目包含ID、内容和创建时间。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
}, p.handleList)
s.RegisterStage(sdk.StagePreAction, p.stagePreAction)
go p.periodicCheck()
log.Printf("[%s] started, path=%s", p.name, p.filePath)
return nil
}
func (p *Plugin) Stop() error {
close(p.stopCh)
p.save()
log.Printf("[%s] stopped", p.name)
return nil
}
func (p *Plugin) load() {
p.mu.Lock()
defer p.mu.Unlock()
data, err := os.ReadFile(p.filePath)
if err != nil {
p.memos = nil
p.nextID = 1
return
}
var store struct {
Memos []Memo `json:"memos"`
NextID int64 `json:"next_id"`
}
if json.Unmarshal(data, &store) != nil {
p.memos = nil
p.nextID = 1
return
}
p.memos = store.Memos
p.nextID = store.NextID
if p.memos == nil {
p.memos = []Memo{}
}
if p.nextID < 1 {
p.nextID = 1
}
}
func (p *Plugin) save() {
data, _ := json.MarshalIndent(map[string]interface{}{
"memos": p.memos,
"next_id": p.nextID,
}, "", " ")
os.WriteFile(p.filePath, data, 0644)
}
func (p *Plugin) pendingCount() int {
p.mu.RLock()
defer p.mu.RUnlock()
n := 0
for _, m := range p.memos {
if !m.Done {
n++
}
}
return n
}
func (p *Plugin) pendingMemos() []Memo {
p.mu.RLock()
defer p.mu.RUnlock()
var out []Memo
for _, m := range p.memos {
if !m.Done {
out = append(out, m)
}
}
return out
}
func (p *Plugin) stagePreAction(ctx *sdk.StageContext) error {
n := p.pendingCount()
if n == 0 {
return nil
}
ctx.Lock()
ctx.ContextMsgs = append(ctx.ContextMsgs, map[string]interface{}{
"role": "system",
"content": fmt.Sprintf("目前有%d条备忘未完成调用%slist工具读取具体内容", n, p.tp),
})
ctx.Unlock()
return nil
}
func (p *Plugin) periodicCheck() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-p.stopCh:
return
case <-ticker.C:
n := p.pendingCount()
if n == 0 {
continue
}
if p.sdk != nil {
p.sdk.InjectInterruptText(p.name, p.name,
fmt.Sprintf("注意,你还有%d条备忘未标记完成请检查", n))
}
}
}
}
func (p *Plugin) handleCreate(args map[string]interface{}) (interface{}, error) {
content, _ := args["content"].(string)
if content == "" {
return errorResult("content is required"), nil
}
p.mu.Lock()
memo := Memo{
ID: p.nextID,
Content: content,
CreatedAt: time.Now().Unix(),
Done: false,
}
p.nextID++
p.memos = append(p.memos, memo)
p.mu.Unlock()
p.save()
return map[string]interface{}{
"content": fmt.Sprintf("备忘已创建 (ID: %d)", memo.ID),
"id": memo.ID,
}, nil
}
func (p *Plugin) handleComplete(args map[string]interface{}) (interface{}, error) {
id, ok := args["id"].(float64)
if !ok {
return errorResult("id is required"), nil
}
p.mu.Lock()
found := false
for i := range p.memos {
if p.memos[i].ID == int64(id) && !p.memos[i].Done {
p.memos[i].Done = true
found = true
break
}
}
p.mu.Unlock()
if !found {
return errorResult(fmt.Sprintf("未找到未完成的备忘 ID: %d", int64(id))), nil
}
p.save()
return map[string]interface{}{
"content": fmt.Sprintf("备忘 %d 已标记为完成", int64(id)),
}, nil
}
func (p *Plugin) handleList(args map[string]interface{}) (interface{}, error) {
memos := p.pendingMemos()
if len(memos) == 0 {
return map[string]interface{}{
"content": "暂无未完成的备忘",
}, nil
}
var sb strings.Builder
for i, m := range memos {
t := time.Unix(m.CreatedAt, 0).Format("01-02 15:04")
if i > 0 {
sb.WriteString("\n")
}
sb.WriteString(fmt.Sprintf("%d. [ID:%d] %s — %s", i+1, m.ID, m.Content, t))
}
return map[string]interface{}{
"content": sb.String(),
"count": len(memos),
}, nil
}
func errorResult(msg string) map[string]interface{} {
return map[string]interface{}{
"isError": true,
"content": msg,
}
}

View File

@ -1,8 +0,0 @@
{
"name": "memo",
"version": "1.0.0",
"description": "备忘插件,支持创建、完成、列出备忘条目,自动提醒",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": ["memo", "reminder", "todo"]
}

View File

@ -1,16 +0,0 @@
# Build QQ plugin for HomeAgent
# Usage: make # build plugin.so
# make clean # remove build artifacts
PLUGIN_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
SDK_ROOT := $(realpath $(PLUGIN_DIR)../..)
.PHONY: all clean
all: plugin.so
plugin.so:
cd $(SDK_ROOT) && go build -buildmode=plugin -o $(PLUGIN_DIR)plugin.so $(PLUGIN_DIR)
clean:
rm -f $(PLUGIN_DIR)plugin.so

View File

@ -1,107 +0,0 @@
# qq 插件讲解
QQ 集成插件,通过 [NapCat](https://github.com/NapNeko/NapCat) OneBot 协议对接 QQ 机器人框架。
## 工具清单15 个)
| 工具 | 功能 | 源码 |
|------|------|------|
| `qq_get_message` | 获取通过中断通知的消息正文 | `handleGetMessage` |
| `qq_send_private_msg` | 发送私聊消息 | `handleSendPrivate` |
| `qq_send_group_msg` | 发送群消息 | `handleSendGroup` |
| `qq_send_file` | 发送文件/图片到私聊或群聊 | `handleSendFile` |
| `qq_get_history` | 获取历史消息 | `handleGetHistory` |
| `qq_get_groups` | 获取群列表 | `handleGetGroups` |
| `qq_get_friends` | 获取好友列表 | `handleGetFriends` |
| `qq_resolve_name` | 解析 QQ 号/群号为可读名称 | `handleResolveName` |
| `qq_get_group_member_info` | 获取群成员信息 | `handleGetGroupMemberInfo` |
| `qq_group_manage` | 群综合管理(踢人/禁言/改名等 18 个子命令) | `handleGroupManage` |
| `qq_friend_action` | 好友管理(删除/拉黑/同意请求等) | `handleFriendAction` |
| `qq_get_group_files` | 群文件操作(列表/搜索/下载) | `handleGetGroupFiles` |
| `qq_upload_group_file` | 上传文件到群 | `handleUploadGroupFile` |
| `qq_send_like` | 点赞/戳一戳 | `handleSendLike` |
| `qq_ocr_image` | 图片文字识别 | `handleOcrImage` |
## 核心设计
### 消息接收Webhook + 中断
插件启动一个 HTTP 服务器监听 NapCat 的回调 webhook收到消息后先保存到内存循环缓冲区
```go
// plugin.go:handleWebhook
p.mu.Lock()
localID := p.nextID
p.nextID++
msg := &SavedMessage{LocalID: localID, UserID: evt.UserID, ...}
p.messages = append(p.messages, msg)
// 保留最近 maxMessages(2000) 条
```
然后通过 `InjectInterruptText` 将摘要推送给 LLMLLM 再主动调用 `qq_get_message` 获取完整内容:
```go
// plugin.go:handleWebhook - interrupt text
interrupt = fmt.Sprintf("来自%s的群聊消息通过id%d使用%sget_message工具获取消息正文",
nickname, localID, tp)
p.sdk.InjectInterruptText(p.name, p.name, interrupt)
```
这种"先通知摘要,按需拉取全文"的设计避免了大量消息涌入 LLM 上下文。
### 管理员优先级标记
配置 `admin` 后,管理员消息的中断文本会加 `【重要!老大消息】` 前缀:
```go
if p.adminID > 0 && evt.UserID == p.adminID {
interrupt = "【重要!老大消息】" + interrupt
}
```
### 消息过滤
`sensitiveFilter` 在发出消息前过滤敏感信息:
```go
func (p *Plugin) sensitiveFilter(text string) string {
text = reAPIKey.ReplaceAllString(text, "$1=***")
text = reSKKey.ReplaceAllString(text, "sk-***")
text = reInternalIP.ReplaceAllString(text, "[IP]")
return text
}
```
保护 API Key、`sk-` 开头的密钥串、内网 IP 不被发到外部。
### NapCat HTTP 调用
所有 NapCat API 调用通过 `napcat()` 方法统一转发:
```go
func (p *Plugin) napcat(action string, params map[string]interface{}) (interface{}, error) {
url := fmt.Sprintf("%s/%s", p.napcatURL, action)
resp, err := http.Post(url, "application/json", bytes.NewReader(data))
// 返回原始 JSON 字符串
}
```
NapCat API 地址通过配置 `plugin.qq.napcat_url` 设置。
### 消息存储
使用循环缓冲区(`[]*SavedMessage`),最多保留 2000 条。每条消息包含本地 ID、QQ 号、昵称、群号、群名、文本内容、时间戳。`qq_get_message` 通过 `local_id` 查找。
## 配置项
| Key | 默认值 | 说明 |
|-----|--------|------|
| `plugin.qq.listen` | `127.0.0.1:<port>` | Webhook 监听地址 |
| `plugin.qq.napcat_url` | `http://127.0.0.1:<port>` | NapCat HTTP API 基地址 |
| `plugin.qq.admin` | 空 | 管理员 QQ 号 |
## 注意事项
- 依赖 NapCat 框架运行,需先启动 NapCat 并配置 webhook 指向本插件地址
- 群管理中的破坏性操作(踢人、退群等)在描述中已写明需先请示管理员
- `qq_get_message` 返回的消息对象包含完整字段LLM 可据此判断消息类型和来源

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +0,0 @@
{
"name": "qq",
"version": "1.0.0",
"description": "QQ 集成插件,对接 NapCat OneBot 框架,支持消息收发、群管理、好友管理等",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": ["qq", "napcat", "onebot", "messaging"]
}

View File

@ -1,94 +0,0 @@
# web 插件讲解
网络工具插件,提供网页搜索和内容抓取功能。
## 工具清单
| 工具 | 功能 | 源码 |
|------|------|------|
| `web_search` | 通过 DuckDuckGo 搜索网页 | `handleSearch` |
| `web_fetch` | 抓取指定 URL 的内容 | `handleFetch` |
## 核心设计
### 搜索实现
`web_search` 使用 DuckDuckGo 的 HTML 搜索页面(非 API免注册
```go
// plugin.go:handleSearch
url := fmt.Sprintf("https://html.duckduckgo.com/html/?q=%s", url.QueryEscape(query))
resp, err := p.httpClient().Get(url)
```
解析策略:扫描 HTML 查找 `<a class="result__a"` 标签提取标题和链接,查找 `<a class="result__snippet"` 提取摘要。搜索最多返回 8 条结果。
由于 DuckDuckGo 在国内被墙,支持通过代理访问(见配置项)。
### 页面抓取
`web_fetch` 直接 HTTP GET 目标 URL 并以文本形式返回内容:
```go
// plugin.go:handleFetch
resp, err := p.httpClient().Get(rawURL)
body, _ := io.ReadAll(resp.Body)
```
返回内容限制最大 100KB超过截断并提示。
### SSRF 防护
`handleFetch` 检查 URL 是否为内网 IP防止服务端请求伪造攻击
```go
// plugin.go:handleFetch — SSRF guard
if !strings.HasPrefix(parsedURL, "http") {
return errorResult("only http/https allowed"), nil
}
// 内网 IP 段检查127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
```
### HTTP 客户端
`newHTTPClient()` 创建带超时和可选代理的 HTTP 客户端:
```go
// plugin.go:newHTTPClient
func (p *Plugin) newHTTPClient() *http.Client {
transport := &http.Transport{}
if p.proxy != "" {
proxyURL, _ := url.Parse(p.proxy)
transport.Proxy = http.ProxyURL(proxyURL)
}
return &http.Client{
Timeout: time.Duration(p.timeout) * time.Second,
Transport: transport,
}
}
```
超时和代理均在配置中设置。
### 配置读取
```go
// plugin.go:Start
p.timeout = getSetting[float64](s.Settings(), "timeout", 30)
p.proxy = getSetting[string](s.Settings(), "proxy", "")
```
`getSetting` 是泛型辅助函数,支持类型安全的配置读取。
## 配置项
| Key | 默认值 | 说明 |
|-----|--------|------|
| `plugin.web.timeout` | `30` | HTTP 请求超时(秒) |
| `plugin.web.proxy` | 空 | HTTP 代理地址,如 `http://<proxy-host>:<proxy-port>` |
## 注意事项
- DuckDuckGo HTML 格式可能随网站更新变化,如果搜索结果解析失败需调整 `parseSearchResults` 中的 HTML 标记匹配
- SSRF 防护默认阻止内网请求,如需访问内网资源需修改 `isInternalIP` 逻辑
- 搜索结果依赖 DuckDuckGo 可用性,在国内使用建议配置代理

View File

@ -1,567 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
"unicode"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
type Plugin struct {
name string
sdk *sdk.PluginSDK
mu sync.RWMutex
timeout int
proxy string
client *http.Client
}
func newHTTPClient(timeout int, proxyURL string) *http.Client {
transport := &http.Transport{
DialContext: (&net.Dialer{
Timeout: time.Duration(timeout) * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: time.Duration(timeout) * time.Second,
ResponseHeaderTimeout: time.Duration(timeout) * time.Second,
}
if proxyURL != "" {
u, err := url.Parse(proxyURL)
if err == nil {
transport.Proxy = http.ProxyURL(u)
}
}
return &http.Client{
Timeout: time.Duration(timeout) * time.Second,
Transport: transport,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return fmt.Errorf("too many redirects")
}
return nil
},
}
}
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name}, nil
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.sdk = s
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "plugin.web.timeout",
Default: "30",
Type: "int",
DisplayName: "HTTP 超时(秒)",
Description: "Web fetch 和搜索的 HTTP 请求超时时间",
Category: "web",
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "plugin.web.proxy",
Default: "",
Type: "string",
DisplayName: "HTTP 代理",
Description: "HTTP 代理地址,如 http://<proxy-host>:<proxy-port>。为空则不使用代理",
Category: "web",
})
t := getSetting[float64](s.Settings(), "timeout", 30)
p.timeout = int(t)
if p.timeout < 5 {
p.timeout = 5
}
if p.timeout > 120 {
p.timeout = 120
}
p.proxy = getSetting[string](s.Settings(), "proxy", "")
p.client = newHTTPClient(p.timeout, p.proxy)
tp := p.name + "_"
s.RegisterTool(tp+"search", sdk.ToolDef{
Name: tp + "search",
Description: "Search the web for current information using DuckDuckGo. Returns formatted results with titles, URLs, and snippets.",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"query": map[string]interface{}{"type": "string", "description": "Search query"},
"count": map[string]interface{}{"type": "integer", "description": "Number of results (1-20, default 5)"},
},
"required": []string{"query"},
},
}, p.handleSearch)
s.RegisterTool(tp+"fetch", sdk.ToolDef{
Name: tp + "fetch",
Description: "Fetch a URL and extract readable content as markdown-like text. Blocked on private/internal IPs.",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"url": map[string]interface{}{"type": "string", "description": "HTTP/HTTPS URL to fetch"},
"max_chars": map[string]interface{}{"type": "integer", "description": "Max characters to return (default 20000)"},
},
"required": []string{"url"},
},
}, p.handleFetch)
proxyMsg := ""
if p.proxy != "" {
proxyMsg = fmt.Sprintf(", proxy: %s", p.proxy)
}
log.Printf("[%s] started, timeout: %ds%s", p.name, p.timeout, proxyMsg)
return nil
}
func (p *Plugin) Stop() error {
p.client.CloseIdleConnections()
log.Printf("[%s] stopped", p.name)
return nil
}
// ── SSRF 保护 ──────────────────────────────────────────────
var privateCIDRs []*net.IPNet
func init() {
cidrs := []string{
"127.0.0.0/8", // loopback
"10.0.0.0/8", // private
"172.16.0.0/12", // private
"192.168.0.0/16", // private
"100.64.0.0/10", // carrier-grade NAT
"169.254.0.0/16", // link-local
"::1/128", // IPv6 loopback
"fc00::/7", // IPv6 unique local
"fe80::/10", // IPv6 link-local
}
for _, c := range cidrs {
_, n, err := net.ParseCIDR(c)
if err == nil {
privateCIDRs = append(privateCIDRs, n)
}
}
}
func isPrivateIP(ip net.IP) bool {
for _, n := range privateCIDRs {
if n.Contains(ip) {
return true
}
}
return false
}
func (p *Plugin) ssrfCheck(rawURL string) error {
u, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("only http/https URLs are allowed, got: %s", u.Scheme)
}
host := u.Hostname()
ips, err := net.LookupHost(host)
if err != nil {
return fmt.Errorf("DNS lookup failed for %s: %w", host, err)
}
for _, ip := range ips {
parsed := net.ParseIP(ip)
if parsed == nil {
continue
}
if isPrivateIP(parsed) {
return fmt.Errorf("blocked request to private IP: %s (%s)", host, ip)
}
}
return nil
}
// ── DuckDuckGo 搜索 ────────────────────────────────────────
type ddgResult struct {
Title string
URL string
Snippet string
}
func (p *Plugin) ddgSearch(query string, count int) ([]ddgResult, error) {
form := url.Values{"q": {query}}
req, err := http.NewRequest("POST", "https://html.duckduckgo.com/html/", strings.NewReader(form.Encode()))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
resp, err := p.client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
return parseDDGResults(string(body), count), nil
}
func parseDDGResults(html string, count int) []ddgResult {
var results []ddgResult
// Find all result blocks: <div class="result__body"> ... </div>
bodyMarker := `result__body"`
for i := 0; i < len(html); i++ {
idx := strings.Index(html[i:], bodyMarker)
if idx < 0 {
break
}
i += idx
// Find closing </div>
closeIdx := findClosingTag(html, i, "</div>")
if closeIdx < 0 {
break
}
block := html[i : closeIdx+6]
r := parseSingleDDGResult(block)
if r.URL != "" {
results = append(results, r)
if len(results) >= count {
break
}
}
i = closeIdx + 6
}
return results
}
func findClosingTag(s string, start int, tag string) int {
depth := 1
pos := start
for pos < len(s) {
nextOpen := strings.Index(s[pos:], `<div`)
nextClose := strings.Index(s[pos:], tag)
if nextClose < 0 {
return -1
}
if nextOpen >= 0 && nextOpen < nextClose {
depth++
pos += nextOpen + 4
} else {
depth--
if depth == 0 {
return pos + nextClose
}
pos += nextClose + len(tag)
}
}
return -1
}
func parseSingleDDGResult(block string) ddgResult {
var r ddgResult
// Extract URL and title from: <a rel="nofollow" class="result__a" href="URL">TITLE</a>
urlMarker := `class="result__a" href="`
uIdx := strings.Index(block, urlMarker)
if uIdx >= 0 {
start := uIdx + len(urlMarker)
end := strings.Index(block[start:], `"`)
if end >= 0 {
r.URL = block[start : start+end]
}
aStart := strings.Index(block[start+end:], `>`)
if aStart >= 0 {
titleStart := start + end + aStart + 1
aEnd := strings.Index(block[titleStart:], `</a>`)
if aEnd >= 0 {
r.Title = stripTags(block[titleStart : titleStart+aEnd])
}
}
}
// Extract snippet: <a class="result__snippet" ...> ... </a>
snippetMarkers := []string{
`<a class="result__snippet`,
`<div class="result__snippet`,
}
for _, marker := range snippetMarkers {
sIdx := strings.Index(block, marker)
if sIdx >= 0 {
aStart := strings.Index(block[sIdx:], `>`)
if aStart >= 0 {
snipStart := sIdx + aStart + 1
snipEnd := strings.Index(block[snipStart:], `</a>`)
if snipEnd < 0 {
snipEnd = strings.Index(block[snipStart:], `</div>`)
}
if snipEnd >= 0 {
r.Snippet = stripTags(block[snipStart : snipStart+snipEnd])
}
}
break
}
}
return r
}
// ── Web Fetch ──────────────────────────────────────────────
func (p *Plugin) handleFetch(args map[string]interface{}) (interface{}, error) {
rawURL, _ := args["url"].(string)
if rawURL == "" {
return errorResult("url is required"), nil
}
maxChars := 20000
if v, ok := args["max_chars"].(float64); ok && v > 0 {
maxChars = int(v)
}
if maxChars > 500000 {
maxChars = 500000
}
if err := p.ssrfCheck(rawURL); err != nil {
return errorResult(err.Error()), nil
}
req, err := http.NewRequest("GET", rawURL, nil)
if err != nil {
return errorResult("invalid URL: " + err.Error()), nil
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
resp, err := p.client.Do(req)
if err != nil {
return errorResult("fetch failed: " + err.Error()), nil
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 400 {
return errorResult(fmt.Sprintf("HTTP %d: %s", resp.StatusCode, resp.Status)), nil
}
body, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxChars)+50000))
if err != nil {
return errorResult("read error: " + err.Error()), nil
}
rawText := string(body)
// Extract readable content based on content type
ct := resp.Header.Get("Content-Type")
var extracted string
if strings.Contains(ct, "text/html") {
extracted = htmlToText(rawText)
} else if strings.Contains(ct, "application/json") {
// Pretty-print JSON
var v interface{}
if json.Unmarshal(body, &v) == nil {
if pretty, err := json.MarshalIndent(v, "", " "); err == nil {
extracted = string(pretty)
} else {
extracted = rawText
}
} else {
extracted = rawText
}
} else {
extracted = rawText
}
// Clean up and truncate
extracted = strings.TrimSpace(extracted)
if len(extracted) > maxChars {
extracted = extracted[:maxChars] + "\n\n[Content truncated]"
}
if extracted == "" {
extracted = "(empty content)"
}
return map[string]interface{}{
"content": extracted,
"details": map[string]interface{}{
"url": rawURL,
"status": resp.StatusCode,
"content_type": ct,
},
}, nil
}
// ── HTML → 文本 ──────────────────────────────────────────────
func htmlToText(html string) string {
// Remove scripts
for {
start := strings.Index(strings.ToLower(html), "<script")
if start < 0 {
break
}
end := strings.Index(html[start:], "</script>")
if end < 0 {
break
}
html = html[:start] + html[start+end+9:]
}
// Remove styles
for {
start := strings.Index(strings.ToLower(html), "<style")
if start < 0 {
break
}
end := strings.Index(html[start:], "</style>")
if end < 0 {
break
}
html = html[:start] + html[start+end+8:]
}
// Replace block-level tags with newlines
for _, tag := range []string{"</p>", "</div>", "</h1>", "</h2>", "</h3>", "</h4>", "</h5>", "</h6>", "</li>", "</tr>", "</blockquote>", "<br", "</pre>"} {
html = strings.ReplaceAll(html, tag, "\n")
}
// Remove remaining tags
html = stripTags(html)
// Decode common entities
html = strings.ReplaceAll(html, "&amp;", "&")
html = strings.ReplaceAll(html, "&lt;", "<")
html = strings.ReplaceAll(html, "&gt;", ">")
html = strings.ReplaceAll(html, "&quot;", "\"")
html = strings.ReplaceAll(html, "&#39;", "'")
html = strings.ReplaceAll(html, "&nbsp;", " ")
// Collapse whitespace
lines := strings.Split(html, "\n")
var cleaned []string
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Collapse internal whitespace
in := []rune(line)
var out []rune
space := false
for _, r := range in {
if unicode.IsSpace(r) {
if !space {
out = append(out, ' ')
space = true
}
} else {
out = append(out, r)
space = false
}
}
cleaned = append(cleaned, string(out))
}
return strings.Join(cleaned, "\n")
}
func stripTags(s string) string {
var out strings.Builder
inTag := false
for _, r := range s {
if r == '<' {
inTag = true
continue
}
if r == '>' {
inTag = false
continue
}
if !inTag {
out.WriteRune(r)
}
}
return out.String()
}
// ── Search 处理 ──────────────────────────────────────────────
func (p *Plugin) handleSearch(args map[string]interface{}) (interface{}, error) {
query, _ := args["query"].(string)
if query == "" {
return errorResult("query is required"), nil
}
count := 5
if v, ok := args["count"].(float64); ok && v > 0 {
count = int(v)
}
if count < 1 {
count = 1
}
if count > 20 {
count = 20
}
results, err := p.ddgSearch(query, count)
if err != nil {
return errorResult("search failed: " + err.Error()), nil
}
if len(results) == 0 {
return map[string]interface{}{
"content": "No results found.",
}, nil
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Search results for %q:\n\n", query))
for i, r := range results {
sb.WriteString(fmt.Sprintf("%d. %s\n %s\n %s\n\n", i+1, r.Title, r.URL, r.Snippet))
}
return map[string]interface{}{
"content": strings.TrimSpace(sb.String()),
}, nil
}
// ── 工具函数 ──────────────────────────────────────────────
func errorResult(msg string) map[string]interface{} {
return map[string]interface{}{
"isError": true,
"content": msg,
}
}
func getSetting[T any](s sdk.SettingsAPI, key string, def T) T {
v, err := s.Get(key)
if err != nil || v == nil {
return def
}
val, ok := v.(T)
if !ok {
return def
}
return val
}

View File

@ -1,8 +0,0 @@
{
"name": "web",
"version": "1.0.0",
"description": "网络工具插件,提供网页搜索和内容抓取功能",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": ["web", "search", "http"]
}

View File

@ -1,3 +0,0 @@
module gitcode.com/JianFeeeee/homeagent-sdk
go 1.21

View File

@ -1,61 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# HomeAgent 插件打包工具
# 将插件目录打包为 .hmap 分发包
# 用法: ./packager.sh <plugin-dir> [输出路径]
# 示例: ./packager.sh ./plugins/myplugin ./dist/myplugin-1.0.0.hmap
PLUGIN_DIR="${1:-}"
OUTPUT="${2:-}"
if [ -z "$PLUGIN_DIR" ]; then
echo "用法: $0 <plugin-dir> [输出路径]"
echo "示例: $0 ./plugins/myplugin ./dist/myplugin-1.0.0.hmap"
exit 1
fi
PLUGIN_DIR="$(realpath "$PLUGIN_DIR")"
PLUGIN_NAME="$(basename "$PLUGIN_DIR")"
# 验证
if [ ! -f "$PLUGIN_DIR/plugin.json" ]; then
echo "错误: 不存在 plugin.json: $PLUGIN_DIR"
exit 1
fi
VERSION="$(python3 -c "import json; print(json.load(open('$PLUGIN_DIR/plugin.json'))['version'])" 2>/dev/null || echo "unknown")"
if [ -z "$OUTPUT" ]; then
mkdir -p dist
OUTPUT="$(realpath "dist/${PLUGIN_NAME}-${VERSION}.hmap")"
fi
echo "🔨 打包插件: $PLUGIN_NAME v$VERSION"
echo " 源目录: $PLUGIN_DIR"
echo " 输出: $OUTPUT"
# 检查入口文件
ENTRY="$(python3 -c "import json; print(json.load(open('$PLUGIN_DIR/plugin.json'))['entry'])" 2>/dev/null || true)"
if [ -n "$ENTRY" ] && [ ! -f "$PLUGIN_DIR/$ENTRY" ]; then
echo "⚠️ 入口文件不存在: $ENTRY"
echo " 请先编译: cd $PLUGIN_DIR && make"
exit 1
fi
# 检查已编译的 .so
if [ -f "$PLUGIN_DIR/plugin.so" ] && [ "$(stat -c %Y "$PLUGIN_DIR/plugin.so" 2>/dev/null)" -lt "$(stat -c %Y "$PLUGIN_DIR/plugin.go" 2>/dev/null)" ]; then
echo "⚠️ plugin.so 比 plugin.go 旧,建议重新编译"
echo " 请执行: cd $PLUGIN_DIR && make"
fi
cd "$PLUGIN_DIR"
zip -r "$OUTPUT" . -x "*.git*" "Makefile" ".gitignore" "*.go" "go.mod" "go.sum" "*.test" "testdata/*" "_*" 2>&1 | tail -3
echo ""
echo "✅ 打包完成: $OUTPUT"
echo " 大小: $(ls -lh "$OUTPUT" | awk '{print $5}')"
echo ""
echo "安装方式:"
echo " 1. WebUI 插件管理 → 上传安装"
echo " 2. AI 对话: 使用 plugin_install 工具并上传 URL"

View File

@ -1,45 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# HomeAgent 插件脚手架生成工具
# 用法: ./scaffold.sh <plugin-name> [输出目录]
# 示例: ./scaffold.sh myplugin ./plugins/myplugin
NAME="${1:-}"
OUTDIR="${2:-./plugins/$NAME}"
if [ -z "$NAME" ]; then
echo "用法: $0 <plugin-name> [输出目录]"
echo "示例: $0 myplugin ./plugins/myplugin"
exit 1
fi
if [ -d "$OUTDIR" ]; then
echo "错误: 目标目录已存在: $OUTDIR"
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
TEMPLATE_DIR="$SCRIPT_DIR/templates"
mkdir -p "$OUTDIR"
# 替换模板中的占位符
sed -e "s/{{.Name}}/$NAME/g" \
-e "s/{{.Version}}/0.1.0/g" \
-e "s/{{.Description}}//g" \
-e "s/{{.Author}}//g" \
"$TEMPLATE_DIR/plugin.json.tmpl" > "$OUTDIR/plugin.json"
cp "$TEMPLATE_DIR/plugin.go.tmpl" "$OUTDIR/plugin.go"
cp "$TEMPLATE_DIR/Makefile.tmpl" "$OUTDIR/Makefile"
cp "$TEMPLATE_DIR/gitignore.tmpl" "$OUTDIR/.gitignore"
echo "✅ 插件脚手架已生成: $OUTDIR"
echo ""
echo "下一步:"
echo " 1. 编辑 $OUTDIR/plugin.go 实现业务逻辑"
echo " 2. 编辑 $OUTDIR/plugin.json 完善元信息"
echo " 3. cd $OUTDIR && make # 编译 plugin.so"
echo " 4. make package # 打包为 .hmap 分发包"
echo " 5. 通过 WebUI 或 plugin_install 工具安装"

View File

@ -1,17 +0,0 @@
# Build external Go plugin for HomeAgent
# Usage: make # build plugin.so
# make clean # remove plugin.so
PLUGIN_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
SDK_ROOT := $(realpath $(PLUGIN_DIR)../..)
PLUGIN_NAME := $(notdir $(realpath $(PLUGIN_DIR)))
.PHONY: all clean
all: plugin.so
plugin.so:
cd $(SDK_ROOT) && go build -buildmode=plugin -o $(PLUGIN_DIR)plugin.so $(PLUGIN_DIR)
clean:
rm -f $(PLUGIN_DIR)plugin.so

View File

@ -1,2 +0,0 @@
plugin.so
*.hmap

View File

@ -1,54 +0,0 @@
package main
import (
"log"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
type Plugin struct {
name string
sdk *sdk.PluginSDK
}
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{
name: name,
}, nil
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.sdk = s
tp := p.name + "_"
s.RegisterTool(tp+"example", sdk.ToolDef{
Name: tp + "example",
Description: "示例工具 - 请替换为实现",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"input": map[string]interface{}{"type": "string", "description": "输入参数"},
},
"required": []string{"input"},
},
}, p.handleExample)
log.Printf("[%s] plugin started", p.name)
return nil
}
func (p *Plugin) Stop() error {
return nil
}
func (p *Plugin) handleExample(args map[string]interface{}) (interface{}, error) {
input, _ := args["input"].(string)
return map[string]interface{}{
"echo": input,
}, nil
}
func main() {}

View File

@ -1,10 +0,0 @@
{
"name": "{{.Name}}",
"version": "{{.Version}}",
"description": "{{.Description}}",
"author": "{{.Author}}",
"license": "MIT",
"entry": "plugin.so",
"min_version": "1.0.0",
"tags": ["{{.Name}}"]
}

View File

@ -1,237 +0,0 @@
// Package plugintest provides a test harness for external HomeAgent plugins.
//
// Usage:
//
// import "gitcode.com/JianFeeeee/homeagent-sdk/hack/plugin-dev/testharness"
//
// func TestMyPlugin(t *testing.T) {
// h := testharness.New(t, "./path/to/plugin.so")
// defer h.Close()
//
// result, err := h.CallTool("myplugin_my_tool", map[string]interface{}{
// "input": "hello",
// })
// if err != nil {
// t.Fatal(err)
// }
// t.Logf("result: %v", result)
// }
package plugintest
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"plugin"
"strings"
"sync"
"testing"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// Harness is a test harness for loading and testing external Go plugins.
type Harness struct {
t *testing.T
plug sdk.Plugin
sdk *sdk.PluginSDK
mu sync.Mutex
tools map[string]sdk.ToolHandler
stages map[sdk.Stage][]sdk.StageHandler
setting *mockSettings
}
// New loads a plugin .so and starts it with a mock SDK.
// soPath is the path to the compiled plugin.so file.
func New(t *testing.T, soPath string) *Harness {
t.Helper()
absPath, err := filepath.Abs(soPath)
if err != nil {
t.Fatalf("abs path: %v", err)
}
if _, err := os.Stat(absPath); err != nil {
t.Fatalf("plugin not found: %s", absPath)
}
pkg, err := plugin.Open(absPath)
if err != nil {
t.Fatalf("plugin.Open: %v", err)
}
sym, err := pkg.Lookup("NewPlugin")
if err != nil {
t.Fatalf("NewPlugin symbol not found: %v", err)
}
newPlugin, ok := sym.(func(name string, config map[string]interface{}) (sdk.Plugin, error))
if !ok {
t.Fatal("NewPlugin has wrong signature")
}
name := filepath.Base(filepath.Dir(absPath))
plug, err := newPlugin(name, nil)
if err != nil {
t.Fatalf("NewPlugin: %v", err)
}
h := &Harness{
t: t,
plug: plug,
tools: make(map[string]sdk.ToolHandler),
stages: make(map[sdk.Stage][]sdk.StageHandler),
setting: &mockSettings{
data: make(map[string]interface{}),
defs: make(map[string]sdk.ConfigDef),
},
}
h.sdk = sdk.New(name, h.setting, h.regTool, h.regStage, nil)
if err := plug.Start(h.sdk); err != nil {
t.Fatalf("plugin.Start: %v", err)
}
return h
}
func (h *Harness) regTool(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
h.mu.Lock()
defer h.mu.Unlock()
h.tools[name] = handler
return nil
}
func (h *Harness) regStage(stage sdk.Stage, handler sdk.StageHandler) {
h.mu.Lock()
defer h.mu.Unlock()
h.stages[stage] = append(h.stages[stage], handler)
}
// Plug returns the loaded plugin instance.
func (h *Harness) Plug() sdk.Plugin { return h.plug }
// SDK returns the mock PluginSDK.
func (h *Harness) SDK() *sdk.PluginSDK { return h.sdk }
// Settings returns the mock settings store for test assertions.
func (h *Harness) Settings() *mockSettings { return h.setting }
// ToolNames returns all registered tool names.
func (h *Harness) ToolNames() []string {
h.mu.Lock()
defer h.mu.Unlock()
names := make([]string, 0, len(h.tools))
for n := range h.tools {
names = append(names, n)
}
return names
}
// CallTool invokes a registered tool handler with the given arguments.
func (h *Harness) CallTool(name string, args map[string]interface{}) (interface{}, error) {
h.mu.Lock()
handler, ok := h.tools[name]
h.mu.Unlock()
if !ok {
return nil, fmt.Errorf("tool %q not registered", name)
}
return handler(args)
}
// Close stops the plugin.
func (h *Harness) Close() {
if err := h.plug.Stop(); err != nil {
h.t.Logf("plugin.Stop: %v", err)
}
}
// AssertToolRegistered fails if the tool is not registered.
func (h *Harness) AssertToolRegistered(name string) {
h.t.Helper()
h.mu.Lock()
defer h.mu.Unlock()
if _, ok := h.tools[name]; !ok {
h.t.Fatalf("expected tool %q to be registered", name)
}
}
// AssertToolResult checks that calling a tool returns the expected JSON output.
func (h *Harness) AssertToolResult(name string, args map[string]interface{}, expected map[string]interface{}) {
h.t.Helper()
got, err := h.CallTool(name, args)
if err != nil {
h.t.Fatalf("tool %q: %v", name, err)
}
gotJSON, _ := json.Marshal(got)
expJSON, _ := json.Marshal(expected)
if string(gotJSON) != string(expJSON) {
h.t.Fatalf("tool %q:\ngot: %s\nexp: %s", name, gotJSON, expJSON)
}
}
// mockSettings implements sdk.SettingsAPI for testing.
type mockSettings struct {
mu sync.Mutex
data map[string]interface{}
defs map[string]sdk.ConfigDef
}
func (m *mockSettings) Get(key string) (interface{}, error) {
m.mu.Lock()
defer m.mu.Unlock()
v, ok := m.data[key]
if !ok {
return nil, fmt.Errorf("key %q not found", key)
}
return v, nil
}
func (m *mockSettings) Set(key string, value interface{}) error {
m.mu.Lock()
defer m.mu.Unlock()
m.data[key] = value
return nil
}
func (m *mockSettings) List(prefix string) ([]string, error) {
m.mu.Lock()
defer m.mu.Unlock()
var keys []string
for k := range m.data {
if prefix == "" || strings.HasPrefix(k, prefix) {
keys = append(keys, k)
}
}
return keys, nil
}
func (m *mockSettings) RegisterDef(def sdk.ConfigDef) {
m.mu.Lock()
defer m.mu.Unlock()
m.defs[def.Key] = def
}
func (m *mockSettings) Defs(prefix string) []*sdk.ConfigDef {
m.mu.Lock()
defer m.mu.Unlock()
var defs []*sdk.ConfigDef
for _, d := range m.defs {
if prefix == "" || strings.HasPrefix(d.Key, prefix) {
defs = append(defs, &d)
}
}
return defs
}
func (m *mockSettings) Dump() map[string]interface{} {
m.mu.Lock()
defer m.mu.Unlock()
cp := make(map[string]interface{})
for k, v := range m.data {
cp[k] = v
}
return cp
}
func (m *mockSettings) Plugins() []string { return nil }

View File

@ -1,553 +0,0 @@
# PluginSDK API 参考
HomeAgent 内核通过 `*sdk.PluginSDK` 向插件暴露所有能力。插件在 `Start(sdk *PluginSDK)` 中接收此对象。
## Plugin 接口
所有插件必须实现此接口:
```go
type Plugin interface {
Name() string // 返回插件名称,与注册名一致
Start(sdk *PluginSDK) error // 初始化:注册工具、阶段钩子等
Stop() error // 清理:关连接、停 goroutine
}
```
### 入口函数
`.so` 动态插件必须导出的工厂函数:
```go
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error)
```
- `name`: 插件目录名,也是配置命名空间
- `config`: 插件依赖注入(预留,当前为空)
- 返回 `Plugin` 实例
## PluginSDK 总览
```
PluginSDK
├── 工具注册
│ └── RegisterTool(name, def, handler) error
├── 阶段钩子
│ └── RegisterStage(stage, handler)
├── 输入投递
│ ├── InjectInterruptText(source, channel, text)
│ ├── InjectText(source, channel, text)
│ └── InjectTextNoMemory(source, channel, text)
├── 配置管理 (SettingsAPI)
│ ├── Get(key) / Set(key, value)
│ ├── GetCore(key) / SetCore(key, value)
│ ├── GetPlugin(plugin, key) / SetPlugin(plugin, key, value)
│ ├── List(prefix) / ListCore(prefix)
│ ├── RegisterDef(def) / Defs(prefix)
│ ├── Dump() / Plugins()
├── 记忆访问
│ ├── Memory() -> MemoryAPI
│ ├── TextMemory() -> TextMemoryAPI
│ ├── DocMemory() -> DocMemoryAPI
├── 知识库
│ └── Knowledge() -> KnowledgeAPI
└── LLM 管理
└── LLM() -> LLMAPI
```
## 工具注册
### RegisterTool
```go
func (s *PluginSDK) RegisterTool(name string, def ToolDef, handler ToolHandler) error
```
向 LLM 注册一个可调用的工具。`name` 必须全局唯一,建议用插件名前缀避免冲突。
### ToolDef
```go
type ToolDef struct {
Name string `json:"name"` // 工具名
Plugin string `json:"plugin,omitempty"` // 工具所属插件
Description string `json:"description"` // LLM 看到的描述
Parameters map[string]interface{} `json:"parameters"` // JSON Schema
}
```
`Parameters` 使用 JSON Schema 格式描述参数。示例:
```go
sdk.ToolDef{
Name: "weather_query",
Description: "查询指定城市的天气",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"city": map[string]interface{}{
"type": "string",
"description": "城市名称",
},
},
"required": []string{"city"},
},
}
```
### ToolHandler
```go
type ToolHandler func(args map[string]interface{}) (interface{}, error)
```
- `args`: LLM 传入的参数key 为参数名value 为对应值
- 返回值: `interface{}` 会被 JSON 序列化后返回给 LLM
- 返回 `error` 时 LLM 会收到错误信息并可能重试
```go
func(args map[string]interface{}) (interface{}, error) {
city, _ := args["city"].(string)
return map[string]interface{}{
"temp": 25, "weather": "晴",
}, nil
}
```
错误结果推荐返回含 `isError` 字段的 map而非返回 error避免 LLM 重试):
```go
return map[string]interface{}{
"isError": true,
"content": "错误描述",
}, nil
```
### ToolCall / ToolResult
阶段钩子中访问的 LLM 工具调用和结果结构:
```go
type ToolCall struct {
ID string `json:"id"` // 调用 ID
Name string `json:"name"` // 工具名
Plugin string `json:"plugin,omitempty"` // 工具所属插件
Arguments map[string]interface{} `json:"arguments"` // 参数
}
type ToolResult struct {
CallID string `json:"call_id"` // 对应 ToolCall.ID
Name string `json:"name"` // 工具名
Plugin string `json:"plugin,omitempty"` // 工具所属插件
Success bool `json:"success"`
Result interface{} `json:"result"` // handler 返回值
}
```
## 阶段钩子
### RegisterStage
```go
func (s *PluginSDK) RegisterStage(stage Stage, handler StageHandler)
```
在消息处理管道的指定阶段注入逻辑。同一阶段可注册多个 handler按注册顺序执行。
### Stage
```go
type Stage string
const (
StageOnInput Stage = "on_input" // 消息到达,零处理
StagePreAction Stage = "pre_action" // LLM 调用前,上下文就绪
StagePostAction Stage = "post_action" // LLM 返回后
StageBeforeToolcall Stage = "before_toolcall" // 单个工具执行前
StageAfterToolcall Stage = "after_toolcall" // 单个工具执行后
StageBeforeOutput Stage = "before_output" // 最终输出前
StageAfterOutput Stage = "after_output" // 输出发送后
)
```
### StageHandler
```go
type StageHandler func(ctx *StageContext) error
```
### RegisterStageOwnTools
```go
func (s *PluginSDK) RegisterStageOwnTools(stage Stage, handler StageHandler)
```
仅在 `before_toolcall` / `after_toolcall` 阶段监听**当前插件自己的工具调用**。
适用场景:
- QQ 插件只审核 `qq_send_*` 自己的发送工具
- Web 插件只改写 `web_fetch` 自己的结果
- Files 插件只审计 `files_write` 自己的写操作
其他阶段会退化成普通 `RegisterStage`
### StageContext
```go
type StageContext struct {
mu sync.RWMutex
RawMessage string // 原始输入文本on_input 可改写)
UserID string // 用户标识
GroupID string // 群组标识
ContextMsgs []map[string]interface{} // 上下文消息列表pre_action 可注入)
LLMText string // LLM 返回文本post_action 可改写)
ReasoningContent string // LLM 推理过程文本
TokenUsage map[string]int // Token 用量
ToolCalls []ToolCall // LLM 请求的工具调用
ToolResults []ToolResult // 工具执行结果
FinalText string // 最终输出文本before_output 可改写)
Response *string // 设置后短路管道
Phase Stage // 当前阶段
Memory []MemItem // 召回的记忆
NoMemory bool // 是否跳过记忆
Extra map[string]interface{} // 扩展字段
}
```
**阶段权限矩阵**
| 字段 | on_input | pre_action | post_action | before_toolcall | after_toolcall | before_output | after_output |
|------|----------|------------|-------------|-----------------|----------------|---------------|--------------|
| RawMessage | 读写 | - | - | - | - | - | - |
| ContextMsgs | - | 读写 | - | - | - | - | - |
| LLMText | - | - | 读写 | - | - | - | - |
| ToolCalls | - | - | 读写 | 读写 | - | - | - |
| ToolCall.deny | - | - | - | 读写 | - | - | - |
| ToolResults | - | - | - | - | 读写 | - | - |
| FinalText | - | - | - | - | - | 读写 | 只读 |
| Response | 读写 | 读写 | 读写 | 读写 | 读写 | 读写 | - |
**短路规则**:任意阶段设置 `ctx.Response` 后,管道立即跳到 `after_output`
### 阶段示例
```go
// on_input: 拦截黑名单用户
s.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {
if ctx.UserID == "blocked_user" {
resp := "已被限制使用"
ctx.Response = &resp
}
return nil
})
// pre_action: 注入额外上下文
s.RegisterStage(sdk.StagePreAction, func(ctx *sdk.StageContext) error {
ctx.Lock()
ctx.ContextMsgs = append(ctx.ContextMsgs, map[string]interface{}{
"role": "system",
"content": "当前时间: " + time.Now().Format("15:04"),
})
ctx.Unlock()
return nil
})
```
### MemItem
```go
type MemItem struct {
Role string `json:"role"` // system / user / assistant
Content string `json:"content"` // 内容
Score float64 `json:"score"` // TF-IDF 相关性评分
}
```
## 输入投递
插件可以向 Agent 投递输入消息。
```go
// 中断投递:可打断当前 LLM 处理
// - source: 来源标识(插件名)
// - channel: 通道名
// - text: 消息文本
func (s *PluginSDK) InjectInterruptText(source, channel, text string)
// 普通投递:排队等待处理
func (s *PluginSDK) InjectText(source, channel, text string)
// 投递但不触发记忆记录
func (s *PluginSDK) InjectTextNoMemory(source, channel, text string)
```
**两种投递方式的区别**
| | InjectText | InjectInterruptText |
|---|---|---|
| 处理顺序 | 排队 | 优先 |
| 打断 LLM | 否 | 是(取消当前请求) |
| 适用场景 | 普通消息 | 定时器、重要通知 |
## 配置管理
### SettingsAPI
插件通过 `s.Settings()` 获取 `SettingsAPI`。每个插件拥有独立的 `config_<name>` SQLite 表。
```go
type SettingsAPI interface {
// 自身配置config_<name> 表)
Get(key string) (interface{}, error)
Set(key string, value interface{}) error
List(prefix string) ([]string, error)
// 核心配置config 表)
GetCore(key string) (interface{}, error)
SetCore(key string, value interface{}) error
ListCore(prefix string) ([]string, error)
// 其他插件配置config_<plugin> 表)
GetPlugin(plugin, key string) (interface{}, error)
SetPlugin(plugin, key string, value interface{}) error
ListPlugin(plugin, prefix string) ([]string, error)
// 配置定义WebUI 显示用)
RegisterDef(def ConfigDef)
Defs(prefix string) []*ConfigDef
// 全局
Dump() map[string]interface{}
Plugins() []string
}
```
### ConfigDef
```go
type ConfigDef struct {
Key string `json:"key"` // 配置键名
Default interface{} `json:"default,omitempty"` // 默认值
Type string `json:"type"` // 类型string / number / boolean
DisplayName string `json:"display_name"` // WebUI 显示名称
Description string `json:"description,omitempty"` // 说明
Category string `json:"category,omitempty"` // 分组
Options []string `json:"options,omitempty"` // 选项列表(下拉框)
Min float64 `json:"min,omitempty"`
Max float64 `json:"max,omitempty"`
Step float64 `json:"step,omitempty"`
Required bool `json:"required,omitempty"`
Secret bool `json:"secret,omitempty"` // 敏感信息(输入框掩码)
}
```
### 使用示例
```go
// 插件启动时注册配置定义
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "provider_key",
Type: "string",
DisplayName: "API Key",
Description: "第三方服务 API 密钥",
Secret: true,
Required: true,
})
// 运行时读取配置
apiKey, err := s.Settings().Get("provider_key")
// 读取核心配置
dataDir, _ := s.Settings().GetCore("core.daemon.data_dir")
// 读取其他插件配置
qqNapcat, _ := s.Settings().GetPlugin("qq", "napcat_url")
```
## 记忆访问
### MemoryAPI图记忆
存储在 SQLite 图数据库中entities + relations 表。
```go
type MemoryAPI interface {
// 召回query 为关键词列表depth 为 BFS 遍历深度
Recall(query []string, depth int) ([]Entity, []Relation, error)
// 写入三元组
Commit(triples []Triple) error
// 统计:返回实体数、关系数等
Introspect() (map[string]interface{}, error)
// 合并实体(同义消歧)
MergeEntities(source, target string) (int, error)
// 清理mode 为 "soft"(标记删除)或 "hard"(物理删除)
Purge(criteria map[string]string, mode string) (int, error)
}
```
```go
type Entity struct {
Name string `json:"name"` // 实体名称
Type string `json:"type"` // 类型: Person / Location / Concept ...
MentionCount int `json:"mention_count"` // 提及次数
}
type Relation struct {
SourceName string `json:"source_name"` // 主体
TargetName string `json:"target_name"` // 客体
RelationType string `json:"relation_type"` // 关系类型: likes / works_at / friend_of ...
}
type Triple struct {
Subject string `json:"subject"` // 主体实体名
Relation string `json:"relation"` // 关系
Object string `json:"object"` // 客体实体名
}
```
### TextMemoryAPI文本记忆
按时间顺序的原始对话日志JSONL 文件轮转存储。
```go
type TextMemoryAPI interface {
Append(evt TextEvent) error
}
type TextEvent struct {
Role string `json:"role"` // system / user / assistant
Content string `json:"content"` // 内容
Timestamp int64 `json:"timestamp"` // 时间戳
Channel string `json:"channel,omitempty"` // 来源通道
}
```
### DocMemoryAPI文档记忆
临时记忆层JSON 文件 + TF-IDF 向量索引,消费即删。
```go
type DocMemoryAPI interface {
// 搜索文档,返回 topK 条
Query(text string, topK int) []*Doc
// 插入文档
Insert(doc *Doc) error
// 删除文档
Remove(id string)
// 统计
Stats() map[string]interface{}
}
type Doc struct {
ID string `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
Score float64 `json:"score,omitempty"`
}
```
## 知识库
### KnowledgeAPI
文件系统 + TF-IDF 向量检索,独立于记忆系统的索引。
```go
type KnowledgeAPI interface {
// 搜索知识条目,返回 topK 匹配
Search(query string, topK int) ([]*Knowledge, error)
// 添加知识
Add(name, content string) error
// 列出所有知识条目名
List() ([]string, error)
}
type Knowledge struct {
Name string `json:"name"`
Content string `json:"content"`
}
```
## LLM 管理
### LLMAPI
管理 LLM 提供者源。
```go
type LLMAPI interface {
// 列出所有已注册的 LLM 源
ListSources() []string
// 切换默认 LLM 源
SetSource(name string) error
// 当前使用的 LLM 源
CurrentSource() string
}
```
## IOInjector
SDK 内部的输入投递接口,`PluginSDK.InjectInterruptText` / `InjectText` / `InjectTextNoMemory` 底层调用。
```go
type IOInjector interface {
InjectInterruptText(source, channel, text string)
InjectText(source, channel, text string)
InjectTextNoMemory(source, channel, text string)
}
```
内核在插件启动后调用 `sdk.SetIOInjector()` 注入此接口的实际实现。
## SDK 辅助类型
```go
// 工具注册回调类型
type ToolRegistrar func(name string, def ToolDef, handler ToolHandler) error
// 阶段注册回调类型
type StageRegistrar func(stage Stage, handler StageHandler)
// API 注册回调类型
type APIRegistrar func(name string) error
```
## 插件生命周期
```
内核启动
├── plugin.Registry.Load(dir)
│ ├── 扫描 plugins/ 目录
│ ├── 匹配已注册工厂或动态加载 .so
│ ├── 调用 NewPlugin(name, config)
│ └── 调用 plugin.Start(sdk) ← 插件注册工具/阶段/事件
├── 正常运行
│ ├── LLM 调用 → 路由到注册的工具
│ └── 消息处理 → 触发注册的阶段钩子
└── 内核关闭
└── plugin.Stop() ← 插件清理资源
```
### 内置插件 vs 动态插件
| | 内置插件 | 动态 .so 插件 |
|---|---|---|
| 注册方式 | `init()``RegisterFactory` | `plugin.Open` 动态加载 |
| 存放位置 | `internal/plugins/` | `<dataDir>/plugins/<name>/` |
| 编译 | 编译进内核 | 独立 `go build -buildmode=plugin` |
| SDK 导入 | `gitcode.com/JianFeeeee/HomeAgent/internal/sdk` | `gitcode.com/JianFeeeee/homeagent-sdk/sdk` |
| 热加载 | 需重新编译 | 可运行时加载/卸载 |

View File

@ -1,14 +0,0 @@
package sdk
// KnowledgeAPI provides access to the knowledge store.
type KnowledgeAPI interface {
Search(query string, topK int) ([]*Knowledge, error)
Add(name, content string) error
List() ([]string, error)
}
// Knowledge represents a knowledge entry.
type Knowledge struct {
Name string `json:"name"`
Content string `json:"content"`
}

View File

@ -1,8 +0,0 @@
package sdk
// LLMAPI provides access to the LLM provider manager.
type LLMAPI interface {
ListSources() []string
SetSource(name string) error
CurrentSource() string
}

View File

@ -1,60 +0,0 @@
package sdk
// MemoryAPI provides access to the graph memory (entity-relation store).
type MemoryAPI interface {
Recall(query []string, depth int) ([]Entity, []Relation, error)
Commit(triples []Triple) error
Introspect() (map[string]interface{}, error)
MergeEntities(source, target string) (int, error)
Purge(criteria map[string]string, mode string) (int, error)
}
// Entity represents a named entity in the knowledge graph.
type Entity struct {
Name string `json:"name"`
Type string `json:"type"`
MentionCount int `json:"mention_count"`
}
// Relation represents a relationship between two entities.
type Relation struct {
SourceName string `json:"source_name"`
TargetName string `json:"target_name"`
RelationType string `json:"relation_type"`
}
// Triple represents a subject-relation-object triple for the knowledge graph.
type Triple struct {
Subject string `json:"subject"`
Relation string `json:"relation"`
Object string `json:"object"`
}
// TextMemoryAPI provides access to chronological text event storage.
type TextMemoryAPI interface {
Append(evt TextEvent) error
}
// TextEvent represents a single text memory event.
type TextEvent struct {
Role string `json:"role"`
Content string `json:"content"`
Timestamp int64 `json:"timestamp"`
Channel string `json:"channel,omitempty"`
}
// DocMemoryAPI provides access to the document vector store.
type DocMemoryAPI interface {
Query(text string, topK int) []*Doc
Insert(doc *Doc) error
Remove(id string)
Stats() map[string]interface{}
}
// Doc represents a document in the document store.
type Doc struct {
ID string `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
Score float64 `json:"score,omitempty"`
}

View File

@ -1,237 +0,0 @@
package sdk
import "sync"
// Plugin is the interface every plugin must implement.
type Plugin interface {
Name() string
Start(sdk *PluginSDK) error
Stop() error
}
// ToolHandler is a function that handles a tool call.
type ToolHandler func(args map[string]interface{}) (interface{}, error)
// StageHandler is a function that handles a pipeline stage event.
type StageHandler func(ctx *StageContext) error
// Stage represents a point in the message processing pipeline.
type Stage string
const (
StageOnInput Stage = "on_input"
StagePreAction Stage = "pre_action"
StagePostAction Stage = "post_action"
StageBeforeToolcall Stage = "before_toolcall"
StageAfterToolcall Stage = "after_toolcall"
StageBeforeOutput Stage = "before_output"
StageAfterOutput Stage = "after_output"
)
// StageContext provides context for stage handlers.
type StageContext struct {
mu sync.RWMutex
RawMessage string
UserID string
GroupID string
ContextMsgs []map[string]interface{}
LLMText string
ReasoningContent string
TokenUsage map[string]int
ToolCalls []ToolCall
ToolResults []ToolResult
FinalText string
Response *string
Phase Stage
Memory []MemItem
NoMemory bool
Extra map[string]interface{}
}
func (c *StageContext) RLock() { c.mu.RLock() }
func (c *StageContext) RUnlock() { c.mu.RUnlock() }
func (c *StageContext) Lock() { c.mu.Lock() }
func (c *StageContext) Unlock() { c.mu.Unlock() }
func (c *StageContext) IsResponded() bool { c.mu.RLock(); defer c.mu.RUnlock(); return c.Response != nil }
// MemItem represents a memory item in stage context.
type MemItem struct {
Role string `json:"role"`
Content string `json:"content"`
Score float64 `json:"score"`
}
// ToolCall represents a model's request to call a tool.
type ToolCall struct {
ID string `json:"id"`
Name string `json:"name"`
Plugin string `json:"plugin,omitempty"`
Arguments map[string]interface{} `json:"arguments"`
}
// ToolResult represents the result of a tool call.
type ToolResult struct {
CallID string `json:"call_id"`
Name string `json:"name"`
Plugin string `json:"plugin,omitempty"`
Success bool `json:"success"`
Result interface{} `json:"result"`
}
// ToolDef describes a tool that the plugin exposes.
type ToolDef struct {
Name string `json:"name"`
Plugin string `json:"plugin,omitempty"`
Description string `json:"description"`
Parameters map[string]interface{} `json:"parameters"`
}
// IOInjector provides methods for injecting input and interrupts into the agent pipeline.
type IOInjector interface {
InjectInterruptText(source, channel, text string)
InjectText(source, channel, text string)
InjectTextNoMemory(source, channel, text string)
}
// ToolRegistrar registers a tool dynamically.
type ToolRegistrar func(name string, def ToolDef, handler ToolHandler) error
// StageRegistrar registers a stage handler.
type StageRegistrar func(stage Stage, handler StageHandler)
// APIRegistrar registers a plugin API for external access.
type APIRegistrar func(name string) error
// PluginSDK is the main API surface provided to plugins at runtime.
// It wraps tool registration, settings, memory, knowledge, LLM, and IO injection.
type PluginSDK struct {
name string
regTool ToolRegistrar
regStage StageRegistrar
regAPI APIRegistrar
io IOInjector
mem MemoryAPI
textMem TextMemoryAPI
docMem DocMemoryAPI
know KnowledgeAPI
llm LLMAPI
sett SettingsAPI
}
// New creates a PluginSDK with the given dependencies.
func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageRegistrar, regAPI APIRegistrar) *PluginSDK {
return &PluginSDK{
name: name,
sett: sett,
regTool: regTool,
regStage: regStage,
regAPI: regAPI,
}
}
// PluginName returns the name of the plugin.
func (s *PluginSDK) PluginName() string { return s.name }
// Settings returns the settings API for reading/writing plugin configuration.
func (s *PluginSDK) Settings() SettingsAPI { return s.sett }
// Memory returns the graph memory API (may be nil if not available).
func (s *PluginSDK) Memory() MemoryAPI { return s.mem }
// TextMemory returns the text memory API (may be nil if not available).
func (s *PluginSDK) TextMemory() TextMemoryAPI { return s.textMem }
// DocMemory returns the document memory API (may be nil if not available).
func (s *PluginSDK) DocMemory() DocMemoryAPI { return s.docMem }
// Knowledge returns the knowledge store API (may be nil if not available).
func (s *PluginSDK) Knowledge() KnowledgeAPI { return s.know }
// LLM returns the LLM provider API (may be nil if not available).
func (s *PluginSDK) LLM() LLMAPI { return s.llm }
// RegisterTool registers a tool that the LLM can call.
func (s *PluginSDK) RegisterTool(name string, def ToolDef, handler ToolHandler) error {
if def.Plugin == "" {
def.Plugin = s.name
}
if s.regTool != nil {
return s.regTool(name, def, handler)
}
return nil
}
// RegisterStage registers a handler for a pipeline stage.
func (s *PluginSDK) RegisterStage(stage Stage, handler StageHandler) {
if s.regStage != nil {
s.regStage(stage, handler)
}
}
// RegisterStageOwnTools only listens to this plugin's own tool calls/results in
// before_toolcall / after_toolcall stages. Other stages degrade to RegisterStage.
func (s *PluginSDK) RegisterStageOwnTools(stage Stage, handler StageHandler) {
if s.regStage == nil {
return
}
if stage != StageBeforeToolcall && stage != StageAfterToolcall {
s.regStage(stage, handler)
return
}
s.regStage(stage, func(ctx *StageContext) error {
ctx.RLock()
match := false
switch stage {
case StageBeforeToolcall:
match = len(ctx.ToolCalls) > 0 && ctx.ToolCalls[0].Plugin == s.name
case StageAfterToolcall:
match = len(ctx.ToolResults) > 0 && ctx.ToolResults[0].Plugin == s.name
}
ctx.RUnlock()
if !match {
return nil
}
return handler(ctx)
})
}
// RegisterPluginAPI registers this plugin's API for access by other plugins.
func (s *PluginSDK) RegisterPluginAPI(name string) error {
if s.regAPI != nil {
return s.regAPI(name)
}
return nil
}
// SetIOInjector sets the IO injector (called by the core at startup).
func (s *PluginSDK) SetIOInjector(io IOInjector) { s.io = io }
// SetMemoryAPI sets the memory API (called by the core at startup).
func (s *PluginSDK) SetMemoryAPI(mem MemoryAPI) { s.mem = mem }
func (s *PluginSDK) SetTextMemoryAPI(tm TextMemoryAPI) { s.textMem = tm }
func (s *PluginSDK) SetDocMemoryAPI(dm DocMemoryAPI) { s.docMem = dm }
func (s *PluginSDK) SetKnowledgeAPI(kn KnowledgeAPI) { s.know = kn }
func (s *PluginSDK) SetLLMAPI(llm LLMAPI) { s.llm = llm }
// ---- IO Convenience Methods ----
// InjectInterruptText injects a text interrupt that can preempt current LLM processing.
func (s *PluginSDK) InjectInterruptText(source, channel, text string) {
if s.io != nil {
s.io.InjectInterruptText(source, channel, text)
}
}
// InjectText injects a text message into the agent pipeline.
func (s *PluginSDK) InjectText(source, channel, text string) {
if s.io != nil {
s.io.InjectText(source, channel, text)
}
}
// InjectTextNoMemory injects a text message without generating memory.
func (s *PluginSDK) InjectTextNoMemory(source, channel, text string) {
if s.io != nil {
s.io.InjectTextNoMemory(source, channel, text)
}
}

View File

@ -1,58 +0,0 @@
package sdk
type SettingsAPI interface {
// Get reads the plugin's own config value (config_<name> table).
Get(key string) (interface{}, error)
// Set writes a config value to the plugin's own config table.
Set(key string, value interface{}) error
// List returns all keys matching the given prefix.
List(prefix string) ([]string, error)
// GetCore reads the core config table.
GetCore(key string) (interface{}, error)
// SetCore writes to the core config table.
SetCore(key string, value interface{}) error
// ListCore lists core config keys matching the prefix.
ListCore(prefix string) ([]string, error)
// GetPlugin reads another plugin's config table.
GetPlugin(plugin, key string) (interface{}, error)
// SetPlugin writes to another plugin's config table.
SetPlugin(plugin, key string, value interface{}) error
// ListPlugin lists another plugin's config keys matching the prefix.
ListPlugin(plugin, prefix string) ([]string, error)
// RegisterDef registers a config definition for UI display.
RegisterDef(def ConfigDef)
// Defs returns config definitions matching the prefix.
Defs(prefix string) []*ConfigDef
// Dump returns all config values.
Dump() map[string]interface{}
// Plugins returns a list of all plugin config namespaces.
Plugins() []string
}
// ConfigDef describes a configuration field for the WebUI.
type ConfigDef struct {
Key string `json:"key"`
Default interface{} `json:"default,omitempty"`
Type string `json:"type"`
DisplayName string `json:"display_name"`
Description string `json:"description,omitempty"`
Category string `json:"category,omitempty"`
Options []string `json:"options,omitempty"`
Min float64 `json:"min,omitempty"`
Max float64 `json:"max,omitempty"`
Step float64 `json:"step,omitempty"`
Required bool `json:"required,omitempty"`
Secret bool `json:"secret,omitempty"`
}