23 Commits

Author SHA1 Message Date
c91739d670 fix(qq): parseIDList 兼容科学计数法存库的历史坏值
配置里 QQ 号被 WebUI 以科学计数法(2.198972886e+09)存库时,
ParseInt 解析失败导致 adminIDs 为空、老大消息不被标记。
ParseInt 失败后回退 ParseFloat, 整数值转为 int64。
2026-08-15 17:24:21 +08:00
6527a40539 fix: CABINumMin 恢复为 1 兼容旧 ABI 插件(仅缺 stage 写回) 2026-08-15 16:34:25 +08:00
392f391f68 v0.9.0: C ABI v2 stage 写回 + ABI 版本对齐核心版本号
- meta: ABI 标识版本改为字符串 semver(ABIVersion=CoreVersion="0.9.0"),
  C 层协商用派生整数 CABINum=900(major*100+minor),不再用独立数字编码
- plugindev 模板: invoke_stage 增加 result out 参数(stage 写回),
  插件在 OnInput/AfterToolcall/PostAction 修改 StageContext 后回传内核
- cmd_init: CABIVersion 改用 CABINum
- example/sanitizer: 增强为全链路清洗(坏 UTF-8/U+FFFD/ANSI 转义),
  挂载 OnInput/AfterToolcall/PostAction 三阶段(依赖 stage 写回能力)
2026-08-15 15:52:10 +08:00
cca9fdce9c example: 新增 acp(ACP 代理通信)、vanblog(VanBlog 博客管理) 插件; 多插件工具调用检测与清理改进; weather 移除 onRemove/文本记忆; plugindev 精简 2026-08-15 09:03:05 +08:00
b6e30f9279 tools: plugindev 工具链支持公共 IOInjector.InjectInputSync(CORE_INJECT_INPUT_SYNC=47,z_bridge dispatchIO 桥接),重建 bin 预编译二进制;example/memo: plg.json 修复(name_en 去斜杠、去 BOM);sdk/plugin.go 注释精简 2026-08-02 16:24:59 +08:00
8e5610c494 example/memo: 待办与备忘分离(待办提醒、备忘纯记事)
- 待办(todo_add/todo_complete/todo_list):保留原有主动提醒能力——
  stagePreAction 注入未完成条数 + periodicCheck 每 5 分钟 InjectInterruptText;
  数据文件 todos.json
- 备忘(memo_create/memo_list/memo_delete):纯记事用途,不参与任何提醒
  (无 stage 注入、无周期中断);数据文件 memos.json
- cleanupData 卸载时清理两个数据文件;ID 各自独立递增
2026-08-02 15:47:37 +08:00
1796395668 sdk: 公共 IOInjector 补 InjectInputSync(同步注入并等待回复)
与主仓 third_party/homeagent-sdk 对齐:IOInjector 接口新增
InjectInputSync(source, channel, text) string + PluginSDK 便捷方法
(通道插件请求-响应流:转发入站消息并取回 agent 回复文本)
2026-08-02 15:30:22 +08:00
f3d87ec35f docs: 生命周期文档补全 onRemove(删除清理)说明
- README.md/README_EN.md:新增删除清理(onRemove)小节——语义(仅卸载
  触发、重载/禁用不触发,Stop 之后执行)、内核配套清理(工具注册/disabled/
  配置项定义 plugin.<name>.*/配置表 config_<name>)、示例清单与代码片段
- plugindev 模板 README.md.tmpl:新增 Lifecycle 段(RegisterStopHandler 每次
  停止、RegisterOnRemoveHandler 仅卸载)
2026-08-02 15:23:22 +08:00
aee63a4f98 example: 演示插件 onRemove 清理补齐(memo/rss/weather)
- memo: 卸载时删除 memos.json 数据文件
- rss: 卸载时清理 .homeagent/rss 订阅数据目录
- weather: 卸载时清理 .homeagent/weather 缓存目录
- 与 calendar(events.json)一致:仅删除触发、重载不触发;
  files(filesDir 为用户配置的访问根目录)、bili/qq(用户下载资产)、
  ocr(临时目录函数内自清理)按语义不加入删除回调
2026-08-02 13:37:59 +08:00
fb07081929 插件删除回调(onRemove)与模板/示例同步
- sdk: RegisterOnRemoveHandler/RunOnRemoveHandlers(仅卸载时触发,重载不触发;
  后注册先执行、幂等),与 RegisterStopHandler/RunStopHandlers 并存
- plugindev: 模板 main.go.tmpl 新增 onRemove 演示(删配置键),templates.go 同步
- example/calendar: RegisterOnRemoveHandler(p.cleanupData) 卸载时清理 events.json
- README/README_EN: 生命周期文档补充 onRemove
- example/calendar/plg.json: 版本对齐
2026-08-02 13:33:25 +08:00
db5d3133ea docs: document pre-built plugindev binaries in bin/ 2026-07-31 13:17:19 +08:00
12a8e99892 plugindev: rebuild pre-built binaries with latest toolchain (no local replace, auto go mod download) 2026-07-31 13:16:13 +08:00
62447e3952 plugindev: generate go.mod without local absolute replace; auto-download SDK module on first build
- init 生成的 go.mod 只 require SDK 线上版本(gitcode.com/JianFeeeee/homeagent-sdk v0.8.0),不再写本地绝对路径 replace
- build 仅在显式指定(plg.json sdk_path 或 --sdk-path)时写入 replace
- 首次构建自动执行 go mod download <sdk_module> 生成 go.sum(修复无 go.sum 构建失败)
- 修正 ensureGoMod 模块名解析(支持 require 行与 require 块)
2026-07-31 13:12:48 +08:00
bc1a005885 docs: fix plugindev install endpoint and build output; examples table; fix NewPluginFactory in bridge template
- README 构建与安装:输出目录为 dist/,安装端点为 pluginmgr POST /plugins(JSON 或 raw body),删除不存在的 /api/plugins/install
- 示例表:weather/luademo 新增、qq 更新 17 工具(补入 2b54814 未提交部分)
- templates.go: go_init_plugin 调用 NewPluginFactory(模板插件仅导出该函数)
2026-07-31 13:00:09 +08:00
2b54814037 examples: update weather with v0.8.0 API surface, add luademo Lua example, fix go.mod replaces 2026-07-31 10:59:13 +08:00
429fe9e1b9 plugindev: fall back to git clone when SDK archive download unavailable 2026-07-31 10:32:46 +08:00
87136057b1 plugindev: align Lua SDK mock with external plugin surface, refactor debug 2026-07-31 10:28:08 +08:00
84bf100a12 example/qq: add typing indicator on private messages
- setInputStatus helper wrapping NapCat set_input_status API
- startTyping/stopTyping with goroutine-based typing manager
  (5s interval, 30s auto-timeout)
- start typing on webhook private message receipt
- stop typing when output channel sends a response
- fix plg.json UTF-8 BOM that broke plugindev parse
2026-07-30 09:24:20 +08:00
78ef7998c2 docs: add RegisterInputChannel, ChannelDef to SDK README 2026-07-29 15:51:32 +08:00
b166697dd7 bump version to v0.8.0 2026-07-29 15:45:33 +08:00
4d01e75282 example/qq: use relative replace path, disable bundle mode 2026-07-29 15:24:43 +08:00
c5bcae9404 sdk: add RegisterInputChannel + NoMemory/Cleaner support
- ChannelDef struct: NoMemory bool + Cleaner func(string) string
- InputChannelRegistrar interface + RegisterInputChannel method
- RegisterOutputChannel now takes ChannelDef parameter
- QQ example registers input channel with NoMemory:true + Cleaner
2026-07-29 14:46:15 +08:00
c7c66b8d39 docs: 修正 plugindev 工具链描述 + 补充入口函数/Lua 插件说明 + 补全示例插件列表 2026-07-28 21:56:54 +08:00
72 changed files with 3879 additions and 589 deletions

3
.gitignore vendored
View File

@ -25,3 +25,6 @@ z_entry.c
# Pre-built plugindev binaries in bin/ should be tracked # Pre-built plugindev binaries in bin/ should be tracked
!bin/plugindev* !bin/plugindev*
!bin/*.exe !bin/*.exe
# plugindev binary in tools/
tools/plugindev/plugindev

147
README.md
View File

@ -23,7 +23,8 @@ type Plugin interface {
| 分类 | 方法 | 说明 | | 分类 | 方法 | 说明 |
|------|------|------| |------|------|------|
| 阶段钩子 | `RegisterStage(stage, handler, scope...)` | 注册阶段回调scope 可选:`StageScopeGlobal`(全局,默认)或 `StageScopeOwnTools`(仅自己工具) | | 阶段钩子 | `RegisterStage(stage, handler, scope...)` | 注册阶段回调scope 可选:`StageScopeGlobal`(全局,默认)或 `StageScopeOwnTools`(仅自己工具) |
| 输通道 | `RegisterOutputChannel(name, caps, desc, handler)` | 注册输通道,caps 为能力位掩码 | | 输通道 | `RegisterInputChannel(name, def)` | 注册输通道,def 为 `ChannelDef`NoMemory/Cleaner |
| 输出通道 | `RegisterOutputChannel(name, caps, desc, def, handler)` | 注册输出通道def 为 `ChannelDef`caps 为能力位掩码 |
| 工具注册 | `RegisterTool(name, def, handler)` | 注册工具供 LLM 调用 | | 工具注册 | `RegisterTool(name, def, handler)` | 注册工具供 LLM 调用 |
| 插件 API | `RegisterPluginAPI(name)` | 注册插件 API 供其他插件访问 | | 插件 API | `RegisterPluginAPI(name)` | 注册插件 API 供其他插件访问 |
| 图记忆 | `Memory()` | 访问图记忆 API实体-关系存储) | | 图记忆 | `Memory()` | 访问图记忆 API实体-关系存储) |
@ -47,10 +48,30 @@ sdk.RegisterStage(StagePreAction, func(ctx *StageContext) error { return nil })
sdk.RegisterStage(StageBeforeToolcall, myHandler, StageScopeOwnTools) sdk.RegisterStage(StageBeforeToolcall, myHandler, StageScopeOwnTools)
``` ```
### ChannelDef
```go
type ChannelDef struct {
NoMemory bool // 通道输入/输出不参与记忆计算(向量/关键词/蒸馏),原文保留
Cleaner func(string) string // 可选:计算层过滤函数(不改原文)
}
```
`ChannelDef` 控制通道在记忆计算层的行为,与 `ToolDef``NoMemory`/`Cleaner` 语义一致。
### 输入通道
```go
sdk.RegisterInputChannel("qq", ChannelDef{
NoMemory: true,
Cleaner: func(text string) string { return strings.TrimSpace(text) },
})
```
### 输出通道 ### 输出通道
```go ```go
sdk.RegisterOutputChannel("my-channel", CapText|CapFile, "通道描述", handler) sdk.RegisterOutputChannel("my-channel", CapText|CapFile, "通道描述", ChannelDef{}, handler)
``` ```
handler 接收三个参数: handler 接收三个参数:
@ -117,21 +138,38 @@ Triple 数据结构新增字段:
func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageRegistrar, regAPI APIRegistrar, regOutput OutputChannelRegistrar) *PluginSDK func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageRegistrar, regAPI APIRegistrar, regOutput OutputChannelRegistrar) *PluginSDK
``` ```
插件开发者只需实现 `Plugin` 接口并导出 `NewPlugin()` 入口函数。 插件开发者只需实现 `Plugin` 接口并导出 `NewPluginFactory()` 入口函数。
## plugindev 工具链 ## plugindev 工具链
`plugindev` 提供插件开发全流程支持: `plugindev` 提供插件开发全流程支持。仓库 `bin/` 提供各平台预制二进制linux/darwin/windows × amd64/arm64下载后直接加入 PATH 即可
```bash
curl -o plugindev https://gitcode.com/JianFeeeee/homeagent-sdk/-/raw/main/bin/plugindev_linux_amd64
chmod +x plugindev
```
| 命令 | 说明 | | 命令 | 说明 |
|------|------| |------|------|
| `plugindev init` | 初始化插件项目(生成 plg.json、入口模板 | | `plugindev init <name> [--lua]` | 初始化插件项目(生成 plg.json、plugin.go 或 main.lua、go.mod、README.md |
| `plugindev build` | 构建插件,输出 .hmap 包 | | `plugindev build [flags]` | 编译并打包为 `.hmap` 包(支持跨平台编译和 bundle 模式) |
| `plugindev clean` | 清理构建产物 | | `plugindev clean` | 清理 `build/``dist/` 目录及生成文件plugin.json、z_bridge_gen.go |
| `plugindev debug` | 本地调试模式运行插件 | | `plugindev debug [dir]` | 通过 Yaegi Go 解释器加载插件源码,启动交互式 REPL 调试 |
| `plugindev sdk <command>` | SDK 版本管理子命令list/install/use/path/current/latest |
支持 **Go****Lua** 两种插件语言。 支持 **Go****Lua** 两种插件语言。
### build 命令 flags
| Flag | 说明 |
|------|------|
| `--outdir <dir>` | 输出目录(默认 `dist`,可覆盖 plg.json 中的 `outdir` |
| `--target <os/arch>` | 构建目标(如 `linux/amd64`),可重复指定(追加到 plg.json 中的 targets |
| `--bundle` | 强制 bundle 模式(同时编译 linux/amd64, darwin/amd64, windows/amd64 |
| `--no-bundle` | 关闭 bundle 模式,仅按 targets 逐个编译 |
| `--sdk-path <path>` | 指定 SDK 源码路径(覆盖 plg.json 中的 `sdk_path` |
| `--replace <from=to>` / `-R` | Go 模块替换(追加到 plg.json 中的 replaces`from` 为模块路径,`to` 为本地路径 |
### plg.json 清单格式 ### plg.json 清单格式
```json ```json
@ -164,13 +202,15 @@ func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageReg
| `version` | string | 版本号 | | `version` | string | 版本号 |
| `description` | string | 插件描述 | | `description` | string | 插件描述 |
| `author` | string | 作者 | | `author` | string | 作者 |
| `entry` | string | 入口文件(`plugin.so` / `main.lua` | | `entry` | string | 入口文件(`plugin.so` / `plugin.dll` / `main.lua` |
| `tags` | string[] | 标签 | | `tags` | string[] | 标签 |
| `targets` | string | 构建目标,逗号分隔(如 `linux/amd64,windows/amd64` | | `targets` | string | 构建目标,逗号分隔(如 `linux/amd64,windows/amd64`Lua 插件为 `lua` |
| `outdir` | string | 输出目录(默认 `dist` | | `outdir` | string | 输出目录(默认 `dist` |
| `bundle` | bool | 是否 bundle 模式(同时编译多平台) | | `bundle` | bool | 是否 bundle 模式(同时编译多平台,默认 `true` |
| `sdk_path` | string | SDK 源码路径(覆盖自动检测的 SDK 路径) |
| `go_version` | string | Go 版本(如 `1.21`,默认从 SDK 的 go.mod 读取) |
| `replaces` | object | Go 模块替换key=模块路径value=本地路径 | | `replaces` | object | Go 模块替换key=模块路径value=本地路径 |
| `source_dirs` | string[] | 额外源码搜索路径(编译时自动导入) | | `source_dirs` | string[] | 额外源码搜索路径(编译时自动导入,用于引入 `thirdpart/` 外部的共享代码 |
### .hmap 包格式 ### .hmap 包格式
@ -179,14 +219,53 @@ func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageReg
- `plugin.json` — 插件元数据 - `plugin.json` — 插件元数据
- `plugin.so` — Go 编译产物Linux - `plugin.so` — Go 编译产物Linux
- `plugin.dll` — Go 编译产物Windows - `plugin.dll` — Go 编译产物Windows
- `plugin.dylib` — Go 编译产物macOSbundle 模式)
- `main.lua` — Lua 插件入口Lua 插件时) - `main.lua` — Lua 插件入口Lua 插件时)
## 插件生命周期 ## 插件生命周期
### 入口函数
插件必须导出 `NewPluginFactory` 入口函数Go`start()` 函数Lua
**Go 插件** — 实现 `Plugin` 接口并导出工厂函数:
```go
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name}, nil
}
```
该函数由内核在加载插件时调用,`name` 为插件名,`config``skill.json` 中的配置(如有)。
**Lua 插件** — 返回包含 `start(sdk)``stop()` 方法的 table
```lua
local plugin = { name = "my-plugin" }
function plugin.start(sdk) -- 注册工具等 end
function plugin.stop() end
return plugin
```
### 启动与停止 ### 启动与停止
- `Start(sdk *PluginSDK) error` — 插件启动,接收 SDK 实例 - `Start(sdk *PluginSDK) error` — 插件启动,接收 SDK 实例
- `Stop() error` — 插件停止,释放资源 - `Stop() error` — 插件停止,释放资源
- `sdk.RegisterStopHandler(fn func())` — 注册停止清理回调。内核(内置插件)或 z_bridge外部插件会在调用插件 `Stop()` **之前**统一执行已注册的 handler后注册先执行执行后清空、幂等。适合做持久化落盘、取消后台任务等清理此时插件内存状态仍然新鲜避免在 `Stop()` 阶段以陈旧状态写回导致数据复活。
### 删除清理onRemove
`Stop`/`RegisterStopHandler` 在插件**停止**(含重载、禁用)时执行;`RegisterOnRemoveHandler` 仅在插件被**卸载(删除)**时执行一次,重载/禁用不触发:
- `sdk.RegisterOnRemoveHandler(fn func())` — 注册删除清理回调。内核在 `RemovePlugin` 流程中、插件 `Stop()` **之后**执行(后注册先执行,执行后清空、幂等)。用于删除插件自身创建的持久化文件(数据/缓存/状态文件)。
- 内核卸载时一并清理:工具注册、`disabled_plugins` 记录、插件配置项定义(`plugin.<name>.*`)与插件配置表(`config_<name>`),卸载后插件配置区完全消失。
- 示例:`example/calendar`(删 events.json`example/memo`(删 memos.json`example/rss`(删订阅数据目录)、`example/weather`(删缓存目录);`plugindev` 模板含 onRemove 演示。
```go
sdk.RegisterOnRemoveHandler(func() {
os.Remove(filepath.Join(dataDir, "events.json"))
})
```
### 自动重启 ### 自动重启
@ -211,17 +290,23 @@ enabled := sdk.AutoRestart()
## 示例插件 ## 示例插件
| 插件 | 说明 | | 插件 | 类型 | 说明 |
|------|------| |------|------|------|
| a2a | Agent-to-Agent 协议通信 | | [weather](example/weather) | Go | 天气查询wttr.in演示 NoMemory/Cleaner/阶段钩子/通道/文本记忆 |
| bili | Bilibili 视频下载 | | [luademo](example/luademo) | Lua | Lua 全功能示例,覆盖 v0.8.0 Lua SDK 全部 API 面 |
| browser | 网络搜索、网页抓取、浏览器渲染(合并自 web/webfetch | | [qq](example/qq) | Go | QQ 消息集成NapCat17 个工具,输入/输出通道完整对接 |
| editdoc | 文档编辑 | | [a2a](example/a2a) | Go | Agent-to-Agent 协议通信 |
| files | 文件管理 | | [ai_image](example/ai_image) | Go | AI 图片生成 |
| memo | 备忘录/记忆 | | [bili](example/bili) | Go | Bilibili 视频下载 |
| ocr | 光学字符识别 | | [browser](example/browser) | Go | 网络搜索、网页抓取、浏览器渲染 |
| qq | QQ 消息集成 | | [calendar](example/calendar) | Go | 日历管理 |
| sanitizer | 内容清洗/安全过滤 | | [editdoc](example/editdoc) | Go | 文档编辑 |
| [files](example/files) | Go | 文件管理 |
| [memo](example/memo) | Go | 备忘录PreAction 注入 + 定时提醒) |
| [music](example/music) | Go | 音乐播放 |
| [ocr](example/ocr) | Go | 光学字符识别 |
| [rss](example/rss) | Go | RSS 订阅 |
| [sanitizer](example/sanitizer) | Go | 内容清洗/安全过滤 |
## 构建与安装 ## 构建与安装
@ -231,15 +316,21 @@ enabled := sdk.AutoRestart()
plugindev build plugindev build
``` ```
输出 `.hmap` 包到项目目录 输出 `.hmap` 包到 `dist/` 目录(默认 bundle 多平台合集;单平台构建使用 `plugindev build --no-bundle`
### 安装 ### 安装
通过 pluginmgr HTTP API 安装: 通过 pluginmgr HTTP API 安装(端口默认 9876仅监听 127.0.0.1,无鉴权)
```bash ```bash
curl -X POST http://<host>:<port>/api/plugins/install \ # 本地路径
-F "package=@my-plugin.hmap" curl -X POST http://127.0.0.1:9876/plugins \
-H "Content-Type: application/json" \
-d '{"path": "/path/to/my-plugin.hmap"}'
# 直接上传二进制
curl -X POST http://127.0.0.1:9876/plugins \
--data-binary @dist/my-plugin.hmap
``` ```
或手动将 `.hmap` 放入插件目录后重启平台。 通过 WebUI 插件管理页面上传,也可手动将 `.hmap` 放入插件目录后重启平台。

View File

@ -23,7 +23,8 @@ The SDK instance injected via `Start(sdk *PluginSDK)` provides:
| Category | Method | Description | | Category | Method | Description |
|----------|--------|-------------| |----------|--------|-------------|
| Stage Hooks | `RegisterStage(stage, handler, scope...)` | Register stage callback; scope: `StageScopeGlobal` (all, default) or `StageScopeOwnTools` (own tools only) | | Stage Hooks | `RegisterStage(stage, handler, scope...)` | Register stage callback; scope: `StageScopeGlobal` (all, default) or `StageScopeOwnTools` (own tools only) |
| Output Channel | `RegisterOutputChannel(name, caps, desc, handler)` | Register output channel with capability bitmask | | Input Channel | `RegisterInputChannel(name, def)` | Register input channel with `ChannelDef` (NoMemory/Cleaner) |
| Output Channel | `RegisterOutputChannel(name, caps, desc, def, handler)` | Register output channel with `ChannelDef` and capability bitmask |
| Tool Registration | `RegisterTool(name, def, handler)` | Register a tool for LLM invocation | | Tool Registration | `RegisterTool(name, def, handler)` | Register a tool for LLM invocation |
| Plugin API | `RegisterPluginAPI(name)` | Register plugin API for inter-plugin access | | Plugin API | `RegisterPluginAPI(name)` | Register plugin API for inter-plugin access |
| Graph Memory | `Memory()` | Access graph memory API (entity-relation store) | | Graph Memory | `Memory()` | Access graph memory API (entity-relation store) |
@ -47,10 +48,30 @@ sdk.RegisterStage(StagePreAction, func(ctx *StageContext) error { return nil })
sdk.RegisterStage(StageBeforeToolcall, myHandler, StageScopeOwnTools) sdk.RegisterStage(StageBeforeToolcall, myHandler, StageScopeOwnTools)
``` ```
### ChannelDef
```go
type ChannelDef struct {
NoMemory bool // Channel input/output skips memory computation (vector/keyword/distill), original text preserved
Cleaner func(string) string // Optional: computation layer filter (does not modify original text)
}
```
`ChannelDef` controls channel behavior in the memory computation layer, with the same semantics as `ToolDef.NoMemory`/`Cleaner`.
### Input Channels
```go
sdk.RegisterInputChannel("qq", ChannelDef{
NoMemory: true,
Cleaner: func(text string) string { return strings.TrimSpace(text) },
})
```
### Output Channels ### Output Channels
```go ```go
sdk.RegisterOutputChannel("my-channel", CapText|CapFile, "channel description", handler) sdk.RegisterOutputChannel("my-channel", CapText|CapFile, "channel description", ChannelDef{}, handler)
``` ```
The handler receives three arguments: The handler receives three arguments:
@ -187,6 +208,21 @@ Supports both **Go** and **Lua** plugin languages.
- `Start(sdk *PluginSDK) error` — Plugin startup, receives SDK instance - `Start(sdk *PluginSDK) error` — Plugin startup, receives SDK instance
- `Stop() error` — Plugin shutdown, release resources - `Stop() error` — Plugin shutdown, release resources
- `sdk.RegisterStopHandler(fn func())` — Register a shutdown cleanup callback. The kernel (for built-in plugins) or z_bridge (for external plugins) runs all registered handlers **before** calling the plugin's `Stop()` (LIFO order, cleared after running — idempotent). Use it for persistence and cancelling background work: plugin memory is still fresh at that point, avoiding stale-state write-backs that resurrect deleted data.
### Remove Cleanup (onRemove)
`Stop` / `RegisterStopHandler` run whenever the plugin **stops** (including reload and disable); `RegisterOnRemoveHandler` runs **only once when the plugin is uninstalled (removed)** — never on reload or disable:
- `sdk.RegisterOnRemoveHandler(fn func())` — Register a remove cleanup callback. The kernel runs it **after** the plugin's `Stop()` in the `RemovePlugin` flow (LIFO order, cleared after running — idempotent). Use it to delete persistent files the plugin created itself (data/cache/state files).
- The kernel also cleans up on uninstall: tool registrations, the `disabled_plugins` record, the plugin's config definitions (`plugin.<name>.*`) and its config table (`config_<name>`) — the plugin's config section disappears completely after removal.
- Examples: `example/calendar` (removes events.json), `example/memo` (removes memos.json), `example/rss` (removes the subscription data dir), `example/weather` (removes the cache dir); the `plugindev` template includes an onRemove demo.
```go
sdk.RegisterOnRemoveHandler(func() {
os.Remove(filepath.Join(dataDir, "events.json"))
})
```
### Auto-Restart ### Auto-Restart
@ -211,18 +247,23 @@ Internal plugins (platform built-in) have full SDK access including SocialAPI wr
## Example Plugins ## Example Plugins
| Plugin | Description | | Plugin | Type | Description |
|--------|-------------| |--------|------|-------------|
| a2a | Agent-to-Agent protocol communication | | [weather](example/weather) | Go | Weather queries (wttr.in); demonstrates NoMemory/Cleaner/stage hooks/channels/text memory |
| bili | Bilibili data fetching | | [luademo](example/luademo) | Lua | Full-featured Lua example covering the whole v0.8.0 Lua SDK surface |
| editdoc | Document editing | | [qq](example/qq) | Go | QQ messaging integration (NapCat), 17 tools, full input/output channel wiring |
| files | File management | | [a2a](example/a2a) | Go | Agent-to-Agent protocol communication |
| memo | Memo/notes | | [ai_image](example/ai_image) | Go | AI image generation |
| ocr | Optical character recognition | | [bili](example/bili) | Go | Bilibili video downloading |
| qq | QQ messaging integration | | [browser](example/browser) | Go | Web search, page fetching, browser rendering |
| sanitizer | Content sanitization/safety filtering | | [calendar](example/calendar) | Go | Calendar management |
| web | Web browsing and interaction | | [editdoc](example/editdoc) | Go | Document editing |
| webfetch | Web content fetching | | [files](example/files) | Go | File management |
| [memo](example/memo) | Go | Memos (PreAction injection + scheduled reminders) |
| [music](example/music) | Go | Music playback |
| [ocr](example/ocr) | Go | Optical character recognition |
| [rss](example/rss) | Go | RSS subscriptions |
| [sanitizer](example/sanitizer) | Go | Content sanitization / safety filtering |
## Building & Installing ## Building & Installing
@ -232,15 +273,21 @@ Internal plugins (platform built-in) have full SDK access including SocialAPI wr
plugindev build plugindev build
``` ```
Outputs a `.hmap` package to the project directory. Outputs a `.hmap` package to the `dist/` directory (default is the multi-platform bundle; use `plugindev build --no-bundle` for a single-target build).
### Install ### Install
Via pluginmgr HTTP API: Via the pluginmgr HTTP API (default port 9876, listening on 127.0.0.1 only, no auth):
```bash ```bash
curl -X POST http://<host>:<port>/api/plugins/install \ # Local path
-F "package=@my-plugin.hmap" curl -X POST http://127.0.0.1:9876/plugins \
-H "Content-Type: application/json" \
-d '{"path": "/path/to/my-plugin.hmap"}'
# Upload binary directly
curl -X POST http://127.0.0.1:9876/plugins \
--data-binary @dist/my-plugin.hmap
``` ```
Or manually place the `.hmap` in the plugin directory and restart the platform. Or upload via the WebUI plugin management page, or manually place the `.hmap` in the plugin directory and restart the platform.

BIN
bin/plugindev_darwin_amd64 Normal file → Executable file

Binary file not shown.

BIN
bin/plugindev_darwin_arm64 Normal file → Executable file

Binary file not shown.

BIN
bin/plugindev_linux_amd64 Normal file → Executable file

Binary file not shown.

BIN
bin/plugindev_linux_arm64 Normal file → Executable file

Binary file not shown.

BIN
bin/plugindev_windows_amd64.exe Normal file → Executable file

Binary file not shown.

View File

@ -4,4 +4,4 @@ go 1.25.0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0 require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

View File

@ -1,4 +1,4 @@
{ {
"name": "a2a", "name": "a2a",
"name_zh": "A2A 代理通信", "name_zh": "A2A 代理通信",
"name_en": "A2A Agent Communication", "name_en": "A2A Agent Communication",

View File

@ -9,6 +9,7 @@ import (
"net" "net"
"net/http" "net/http"
"strings" "strings"
"sync"
"time" "time"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk" "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
@ -17,6 +18,7 @@ import (
type Plugin struct { type Plugin struct {
name string name string
sdk *sdk.PluginSDK sdk *sdk.PluginSDK
srvMu sync.Mutex
server *http.Server server *http.Server
serverAddr string serverAddr string
} }
@ -97,7 +99,9 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
// Inbound HTTP server // Inbound HTTP server
if addr, _ := s.Settings().Get("listen"); addr != nil { if addr, _ := s.Settings().Get("listen"); addr != nil {
if addrStr, ok := addr.(string); ok && addrStr != "" { if addrStr, ok := addr.(string); ok && addrStr != "" {
p.startServer(addrStr) if err := p.startServer(addrStr); err != nil {
log.Printf("[%s] start A2A server: %v", p.name, err)
}
} }
} }
@ -111,6 +115,8 @@ func (p *Plugin) Stop() error {
} }
func (p *Plugin) stopServer() { func (p *Plugin) stopServer() {
p.srvMu.Lock()
defer p.srvMu.Unlock()
if p.server != nil { if p.server != nil {
p.server.Close() p.server.Close()
p.server = nil p.server = nil
@ -120,7 +126,7 @@ func (p *Plugin) stopServer() {
// ---- Inbound HTTP Server ---- // ---- Inbound HTTP Server ----
func (p *Plugin) startServer(addr string) { func (p *Plugin) startServer(addr string) error {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/agent-card", p.handleAgentCard) mux.HandleFunc("/agent-card", p.handleAgentCard)
mux.HandleFunc("/task", p.handleIncomingTask) mux.HandleFunc("/task", p.handleIncomingTask)
@ -128,18 +134,27 @@ func (p *Plugin) startServer(addr string) {
listener, err := net.Listen("tcp", addr) listener, err := net.Listen("tcp", addr)
if err != nil { if err != nil {
log.Printf("[%s] listen %s: %v", p.name, addr, err) return fmt.Errorf("listen %s: %v", addr, err)
return
} }
p.server = &http.Server{Handler: mux} srv := &http.Server{Handler: mux}
p.serverAddr = listener.Addr().String() addrStr := listener.Addr().String()
p.srvMu.Lock()
if p.server != nil {
p.server.Close()
}
p.server = srv
p.serverAddr = addrStr
p.srvMu.Unlock()
go func() { go func() {
log.Printf("[%s] A2A server on %s", p.name, p.serverAddr) log.Printf("[%s] A2A server on %s", p.name, addrStr)
if err := p.server.Serve(listener); err != nil && err != http.ErrServerClosed { if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
log.Printf("[%s] serve: %v", p.name, err) log.Printf("[%s] serve: %v", p.name, err)
} }
}() }()
return nil
} }
func (p *Plugin) handleAgentCard(w http.ResponseWriter, r *http.Request) { func (p *Plugin) handleAgentCard(w http.ResponseWriter, r *http.Request) {
@ -411,24 +426,21 @@ func (p *Plugin) handleA2AQuery(args map[string]interface{}) (interface{}, error
func (p *Plugin) handleConfigure(args map[string]interface{}) (interface{}, error) { func (p *Plugin) handleConfigure(args map[string]interface{}) (interface{}, error) {
listen, _ := args["listen"].(string) listen, _ := args["listen"].(string)
if listen == "" { listen = strings.TrimSpace(listen)
return "参数 listen 不能为空。设为空字符串可禁用 HTTP 服务。", nil
}
if err := p.sdk.Settings().Set("listen", listen); err != nil { if err := p.sdk.Settings().Set("listen", listen); err != nil {
return fmt.Sprintf("保存配置失败: %v", err), nil return fmt.Sprintf("保存配置失败: %v", err), nil
} }
p.stopServer() if listen == "" || listen == "off" || listen == "disabled" {
if listen != "" { p.stopServer()
p.startServer(listen) return "A2A HTTP 服务已禁用listen 设为空)", nil
} }
status := "已启动" if err := p.startServer(listen); err != nil {
if listen == "" { return fmt.Sprintf("A2A 配置已保存,但服务启动失败: %v", err), nil
status = "已禁用"
} }
return fmt.Sprintf("A2A 配置已更新。监听地址: %s (%s)", listen, status), nil return fmt.Sprintf("A2A 配置已更新。监听地址: %s (已启动)", listen), nil
} }
func (p *Plugin) handleRestart(args map[string]interface{}) (interface{}, error) { func (p *Plugin) handleRestart(args map[string]interface{}) (interface{}, error) {
@ -436,23 +448,28 @@ func (p *Plugin) handleRestart(args map[string]interface{}) (interface{}, error)
addr, _ := p.sdk.Settings().Get("listen") addr, _ := p.sdk.Settings().Get("listen")
addrStr, _ := addr.(string) addrStr, _ := addr.(string)
if addrStr == "" { if addrStr == "" || addrStr == "off" || addrStr == "disabled" {
return "A2A 服务未配置监听地址listen 为空),无法启动", nil return "A2A 服务未配置监听地址listen 为空),无法启动", nil
} }
p.startServer(addrStr) if err := p.startServer(addrStr); err != nil {
if p.server == nil { return fmt.Sprintf("A2A 服务启动失败: %v", err), nil
return fmt.Sprintf("A2A 服务启动失败,请检查监听地址: %s", addrStr), nil
} }
return fmt.Sprintf("A2A 服务已重启,监听: %s", p.serverAddr), nil
p.srvMu.Lock()
listening := p.serverAddr
p.srvMu.Unlock()
return fmt.Sprintf("A2A 服务已重启,监听: %s", listening), nil
} }
func (p *Plugin) handleStatus(args map[string]interface{}) (interface{}, error) { func (p *Plugin) handleStatus(args map[string]interface{}) (interface{}, error) {
addr, _ := p.sdk.Settings().Get("listen") addr, _ := p.sdk.Settings().Get("listen")
addrStr, _ := addr.(string) addrStr, _ := addr.(string)
p.srvMu.Lock()
serverRunning := p.server != nil serverRunning := p.server != nil
listening := p.serverAddr listening := p.serverAddr
p.srvMu.Unlock()
if !serverRunning { if !serverRunning {
listening = "未运行" listening = "未运行"
} }

7
example/acp/go.mod Normal file
View File

@ -0,0 +1,7 @@
module acp
go 1.25.0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

11
example/acp/main.go Normal file
View File

@ -0,0 +1,11 @@
//go:build !windows || !cgo
package main
import (
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
return NewPluginFactory(name, config)
}

15
example/acp/plg.json Normal file
View File

@ -0,0 +1,15 @@
{
"name": "acp",
"name_zh": "ACP 代理通信",
"name_en": "ACP Agent Client Protocol",
"version": "1.0.0",
"description": "Agent Client Protocol 通信插件:充当 ACP 服务端接受其他 Agent 的任务请求,同时提供客户端工具向远程 ACP Agent如 opencode发起会话并读取回复",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": ["acp", "agent", "interop"],
"targets": "linux/amd64",
"outdir": "dist",
"bundle": true,
"replaces": {},
"source_dirs": []
}

528
example/acp/plugin.go Normal file
View File

@ -0,0 +1,528 @@
package main
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"strings"
"sync"
"time"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// acpPlugin 实现 Agent Client Protocol (ACP) 0.0.x 子集:
// - 服务端POST /api/session JSON-RPCsession/new / session/update
// 请求注入本 Agent另提供 GET /api/session?id=xxx SSE 事件流。
// - 客户端:向远程 ACP 服务端发 session/new 并读取 SSE session/reply。
type Plugin struct {
name string
sdk *sdk.PluginSDK
srvMu sync.Mutex
server *http.Server
serverID string
mu sync.RWMutex
sessions map[string]*sessionState
}
type sessionState struct {
ID string
Replying []map[string]interface{}
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.sdk = s
p.sessions = make(map[string]*sessionState)
tp := p.name + "_"
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "listen", Default: "127.0.0.1:12001",
Type: "string", DisplayName: "监听地址",
Description: "ACP 服务端监听地址,设为空可禁用 HTTP 服务",
Category: p.name,
})
s.RegisterTool(tp+"acp_query", sdk.ToolDef{
Name: tp + "acp_query", Description: "向远程 ACP Agent如 opencode http://127.0.0.1:13000、pi bridge http://127.0.0.1:12011 或回环到自身 12001发起一个会话请求并等待回复返回其最终回答文本兼容 SSE 型与同步 JSON 型 ACP 服务端",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"server_url": map[string]interface{}{"type": "string", "description": "目标 ACP 服务端地址(如 http://127.0.0.1:13000"},
"prompt": map[string]interface{}{"type": "string", "description": "发送给目标 Agent 的任务描述"},
"timeout": map[string]interface{}{"type": "integer", "description": "等待回复超时(秒),默认 120"},
},
"required": []string{"server_url", "prompt"},
},
Cleaner: func(output string) string {
var r struct {
Reply string `json:"reply"`
}
if json.Unmarshal([]byte(output), &r) == nil && r.Reply != "" {
return r.Reply
}
return output
},
}, p.handleAcpQuery)
s.RegisterTool(tp+"acp_configure", sdk.ToolDef{
Name: tp + "acp_configure", Description: "修改 ACP 插件的监听配置并生效(重启 HTTP 服务)",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"listen": map[string]interface{}{"type": "string", "description": "监听地址(如 0.0.0.0:12001设为空禁用"},
},
},
}, p.handleConfigure)
s.RegisterTool(tp+"acp_status", sdk.ToolDef{
Name: tp + "acp_status", Description: "查看 ACP 插件运行状态与当前活跃会话数",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
}, p.handleStatus)
addr, _ := s.Settings().Get("listen")
if addrStr, ok := addr.(string); ok && addrStr != "" {
if err := p.startServer(addrStr); err != nil {
log.Printf("[%s] start ACP server: %v", p.name, err)
}
}
log.Printf("[%s] started", p.name)
return nil
}
func (p *Plugin) Stop() error {
p.stopServer()
return nil
}
func (p *Plugin) stopServer() {
p.srvMu.Lock()
defer p.srvMu.Unlock()
if p.server != nil {
p.server.Close()
p.server = nil
p.serverID = ""
}
}
// ---- Inbound HTTP Server ----
func (p *Plugin) startServer(addr string) error {
mux := http.NewServeMux()
mux.HandleFunc("/api/session", p.handleSession)
listener, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("listen %s: %v", addr, err)
}
srv := &http.Server{Handler: mux}
addrStr := listener.Addr().String()
p.srvMu.Lock()
if p.server != nil {
p.server.Close()
}
p.server = srv
p.serverID = addrStr
p.srvMu.Unlock()
go func() {
log.Printf("[%s] ACP server on %s", p.name, addrStr)
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
log.Printf("[%s] serve: %v", p.name, err)
}
}()
return nil
}
func (p *Plugin) handleSession(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "POST":
p.handleSessionPost(w, r)
case "GET":
p.handleSessionSSE(w, r)
default:
http.Error(w, "", http.StatusMethodNotAllowed)
}
}
// handleSessionPost 处理 JSON-RPCsession/new 与 session/update
func (p *Plugin) handleSessionPost(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req struct {
JSONRPC string `json:"jsonrpc"`
ID interface{} `json:"id"`
Method string `json:"method"`
Params struct {
Request *struct {
Text string `json:"text"`
} `json:"request,omitempty"`
SessionID string `json:"session_id,omitempty"`
Final bool `json:"final,omitempty"`
} `json:"params,omitempty"`
}
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, "invalid json-rpc", http.StatusBadRequest)
return
}
switch req.Method {
case "session/new":
text := ""
if req.Params.Request != nil {
text = strings.TrimSpace(req.Params.Request.Text)
}
if text == "" {
http.Error(w, "request.text required", http.StatusBadRequest)
return
}
sid := fmt.Sprintf("session_%d", time.Now().UnixNano())
p.mu.Lock()
p.sessions[sid] = &sessionState{ID: sid}
p.mu.Unlock()
if p.sdk != nil {
p.sdk.InjectInterruptText(p.name, "acp",
fmt.Sprintf("[来自ACP Agent的请求请求 session %s]\n%s", sid, text))
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"jsonrpc": "2.0", "id": req.ID,
"result": map[string]interface{}{
"session": map[string]interface{}{"id": sid},
},
})
case "session/update":
sid := req.Params.SessionID
p.mu.Lock()
st := p.sessions[sid]
if st != nil && req.Params.Final {
st.Replying = append(st.Replying, map[string]interface{}{
"type": "reply", "text": "done",
})
}
p.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"jsonrpc": "2.0", "id": req.ID,
"result": map[string]interface{}{"final": true},
})
case "session/cancel":
p.mu.Lock()
delete(p.sessions, req.Params.SessionID)
p.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"jsonrpc": "2.0", "id": req.ID,
"result": map[string]interface{}{"canceled": true},
})
default:
http.Error(w, fmt.Sprintf("unknown method %q", req.Method), http.StatusBadRequest)
}
}
// handleSessionSSE 提供 SSE 事件流订阅
func (p *Plugin) handleSessionSSE(w http.ResponseWriter, r *http.Request) {
sid := r.URL.Query().Get("id")
if sid == "" {
http.Error(w, "id query param required", http.StatusBadRequest)
return
}
p.mu.RLock()
st := p.sessions[sid]
p.mu.RUnlock()
if st == nil {
http.Error(w, "session not found", http.StatusNotFound)
return
}
fl, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for {
p.mu.RLock()
replies := append([]map[string]interface{}{}, st.Replying...)
p.mu.RUnlock()
for _, rep := range replies {
data, _ := json.Marshal(rep)
fmt.Fprintf(w, "event: session/reply\ndata: %s\n\n", data)
fl.Flush()
}
p.mu.Lock()
st.Replying = nil
p.mu.Unlock()
select {
case <-r.Context().Done():
return
case <-ticker.C:
}
}
}
// ---- OutboundACP 客户端 ----
// parseRPCBody 兼容 JSON 与 SSE 两种响应体
func parseRPCBody(ct string, body []byte) (*json.RawMessage, error) {
if strings.Contains(ct, "text/event-stream") {
sc := bufio.NewScanner(bytes.NewReader(body))
var last string
for sc.Scan() {
line := strings.TrimRight(sc.Text(), "\r")
if strings.HasPrefix(line, "data:") {
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data != "" && data != "[DONE]" {
last = data
}
}
}
if last == "" {
return nil, fmt.Errorf("SSE body 中无 data 帧: %s", truncateStr(string(body), 200))
}
body = []byte(last)
}
var raw json.RawMessage
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("解析响应失败: %v: %s", err, truncateStr(string(body), 300))
}
return &raw, nil
}
func truncateStr(s string, n int) string {
if len(s) > n {
return s[:n] + "..."
}
return s
}
func (p *Plugin) handleAcpQuery(args map[string]interface{}) (interface{}, error) {
serverURL, _ := args["server_url"].(string)
serverURL = strings.TrimRight(strings.TrimSpace(serverURL), "/")
if serverURL == "" {
return map[string]interface{}{"error": "server_url 不能为空"}, nil
}
if !strings.HasPrefix(serverURL, "http://") && !strings.HasPrefix(serverURL, "https://") {
serverURL = "http://" + serverURL
}
prompt, _ := args["prompt"].(string)
prompt = strings.TrimSpace(prompt)
if prompt == "" {
return map[string]interface{}{"error": "prompt 不能为空"}, nil
}
timeoutSec := 120
if v, ok := args["timeout"].(float64); ok && v > 0 {
timeoutSec = int(v)
}
endpoint := serverURL + "/api/session"
client := &http.Client{Timeout: time.Duration(timeoutSec) * time.Second}
newBody, _ := json.Marshal(map[string]interface{}{
"jsonrpc": "2.0", "id": "acp-" + fmt.Sprintf("%d", time.Now().UnixNano()),
"method": "session/new",
"params": map[string]interface{}{
"request": map[string]interface{}{"text": prompt},
},
})
req, _ := http.NewRequest("POST", endpoint, bytes.NewReader(newBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
resp, err := client.Do(req)
if err != nil {
return map[string]interface{}{"error": fmt.Sprintf("请求失败(超时%d秒): %v", timeoutSec, err)}, nil
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 && resp.StatusCode != 202 {
return map[string]interface{}{"error": fmt.Sprintf("状态码 %d", resp.StatusCode), "raw_body": truncateStr(string(body), 300)}, nil
}
raw, err := parseRPCBody(resp.Header.Get("Content-Type"), body)
if err != nil {
return map[string]interface{}{"error": err.Error()}, nil
}
var rpcResp struct {
Result *struct {
Session *struct {
ID string `json:"id"`
} `json:"session,omitempty"`
SessionID string `json:"sessionId,omitempty"`
Reply string `json:"reply,omitempty"`
} `json:"result,omitempty"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error,omitempty"`
}
if err := json.Unmarshal(*raw, &rpcResp); err != nil {
return map[string]interface{}{"error": fmt.Sprintf("JSON-RPC 解析失败: %v", err), "raw_body": truncateStr(string(*raw), 300)}, nil
}
if rpcResp.Error != nil {
return map[string]interface{}{"error": fmt.Sprintf("ACP 错误 [%d]: %s", rpcResp.Error.Code, rpcResp.Error.Message)}, nil
}
if rpcResp.Result == nil {
return map[string]interface{}{"error": "响应中没有 result", "raw_body": truncateStr(string(*raw), 300)}, nil
}
// 兼容两种协议:
// A) 标准/SSE 型opencode、本插件服务端result.session.id回复经 SSE 事件流
// B) 同步 JSON 型pi bridgeresult.sessionId + result.reply
if rpcResp.Result.Reply != "" {
return map[string]interface{}{
"session_id": rpcResp.Result.SessionID,
"status": "completed",
"reply": rpcResp.Result.Reply,
}, nil
}
if rpcResp.Result.Session == nil || rpcResp.Result.Session.ID == "" {
return map[string]interface{}{"error": "响应中没有 session.id", "raw_body": truncateStr(string(*raw), 300)}, nil
}
sid := rpcResp.Result.Session.ID
replyText := p.readSSEReply(endpoint, sid, client, timeoutSec)
return map[string]interface{}{
"session_id": sid,
"status": "completed",
"reply": replyText,
}, nil
}
// readSSEReply 通过 SSE 读取 session/reply 事件并拼接回复文本
func (p *Plugin) readSSEReply(endpoint, sid string, client *http.Client, timeoutSec int) string {
sseURL := fmt.Sprintf("%s?id=%s", endpoint, sid)
req, _ := http.NewRequest("GET", sseURL, nil)
req.Header.Set("Accept", "text/event-stream")
resp, err := client.Do(req)
if err != nil {
return fmt.Sprintf("(SSE 读取失败: %v)", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bb, _ := io.ReadAll(resp.Body)
return fmt.Sprintf("(SSE 状态码 %d: %s)", resp.StatusCode, truncateStr(string(bb), 200))
}
var sb strings.Builder
sc := bufio.NewScanner(resp.Body)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
deadline := time.Now().Add(time.Duration(timeoutSec) * time.Second)
for sc.Scan() {
if time.Now().After(deadline) {
break
}
line := strings.TrimRight(sc.Text(), "\r")
if strings.HasPrefix(line, "event: ") && strings.TrimSpace(strings.TrimPrefix(line, "event: ")) == "session/error" {
break
}
if strings.HasPrefix(line, "data:") {
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data == "" || data == "[DONE]" {
continue
}
var evt struct {
SessionID string `json:"session_id,omitempty"`
Type string `json:"type,omitempty"`
Text string `json:"text,omitempty"`
Message *struct {
Text string `json:"text"`
} `json:"message,omitempty"`
}
if json.Unmarshal([]byte(data), &evt) == nil {
text := evt.Text
if evt.Message != nil && evt.Message.Text != "" {
text = evt.Message.Text
}
if text != "" {
if sb.Len() > 0 {
sb.WriteString("\n")
}
sb.WriteString(text)
}
}
}
}
if sb.Len() == 0 {
return "(未收到回复)"
}
return sb.String()
}
// ---- Management ----
func (p *Plugin) handleConfigure(args map[string]interface{}) (interface{}, error) {
listen, _ := args["listen"].(string)
listen = strings.TrimSpace(listen)
if err := p.sdk.Settings().Set("listen", listen); err != nil {
return fmt.Sprintf("保存配置失败: %v", err), nil
}
if listen == "" || listen == "off" || listen == "disabled" {
p.stopServer()
return "ACP HTTP 服务已禁用", nil
}
if err := p.startServer(listen); err != nil {
return fmt.Sprintf("ACP 配置已保存,但服务启动失败: %v", err), nil
}
return fmt.Sprintf("ACP 配置已更新,监听: %s", listen), nil
}
func (p *Plugin) handleStatus(args map[string]interface{}) (interface{}, error) {
addr, _ := p.sdk.Settings().Get("listen")
addrStr, _ := addr.(string)
p.srvMu.Lock()
serverRunning := p.server != nil
listening := p.serverID
p.srvMu.Unlock()
p.mu.RLock()
n := len(p.sessions)
p.mu.RUnlock()
if !serverRunning {
listening = "未运行"
}
return fmt.Sprintf("配置监听地址: %s\n当前监听: %s\n服务状态: %s\n活跃会话: %d",
addrStr, listening, map[bool]string{true: "运行中", false: "已停止"}[serverRunning], n), nil
}
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name}, nil
}

View File

@ -4,4 +4,4 @@ go 1.25.0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0 require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

View File

@ -1,4 +1,4 @@
{ {
"name": "ai_image", "name": "ai_image",
"name_zh": "AI绘图", "name_zh": "AI绘图",
"name_en": "AI Image", "name_en": "AI Image",

View File

@ -4,4 +4,4 @@ go 1.25.0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0 require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

View File

@ -1,4 +1,4 @@
{ {
"name": "bili", "name": "bili",
"name_zh": "B站视频下载", "name_zh": "B站视频下载",
"name_en": "Bilibili Video Downloader", "name_en": "Bilibili Video Downloader",

View File

@ -8,13 +8,15 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk" "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
) )
type Plugin struct { type Plugin struct {
name string name string
sdk *sdk.PluginSDK sdk *sdk.PluginSDK
proxy string
} }
func (p *Plugin) Name() string { return p.name } func (p *Plugin) Name() string { return p.name }
@ -30,6 +32,17 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
Description: "B站视频下载后的保存目录", Description: "B站视频下载后的保存目录",
Category: p.name, Category: p.name,
}) })
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "proxy", Default: "",
Type: "string", DisplayName: "HTTP 代理",
Description: "yt-dlp 下载使用的 HTTP 代理地址(如 http://127.0.0.1:7890留空则不设置",
Category: p.name,
})
if v, _ := s.Settings().Get("proxy"); v != nil {
if str, ok := v.(string); ok {
p.proxy = str
}
}
s.RegisterTool(tp+"video", sdk.ToolDef{ s.RegisterTool(tp+"video", sdk.ToolDef{
Name: tp + "video", Name: tp + "video",
@ -101,7 +114,7 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
cmd := exec.Command("yt-dlp", ytdlpArgs...) cmd := exec.Command("yt-dlp", ytdlpArgs...)
cmd.Stdout = &out cmd.Stdout = &out
cmd.Stderr = &out cmd.Stderr = &out
cmd.Env = append(os.Environ(), "HTTP_PROXY=http://127.0.0.1:7890", "HTTPS_PROXY=http://127.0.0.1:7890") cmd.Env = proxyEnv(p.proxy)
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("yt-dlp info: %w\n%s", err, strings.TrimSpace(out.String())) return nil, fmt.Errorf("yt-dlp info: %w\n%s", err, strings.TrimSpace(out.String()))
} }
@ -171,12 +184,17 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil
} }
taskDir := filepath.Join(outputDir, fmt.Sprintf("bili_%d", time.Now().UnixNano()))
if err := os.MkdirAll(taskDir, 0755); err != nil {
return nil, fmt.Errorf("mkdir task dir: %w", err)
}
dlArgs := []string{ dlArgs := []string{
"--no-warnings", "--no-warnings",
"--socket-timeout", "30", "--socket-timeout", "30",
"--retries", "3", "--retries", "3",
"--fragment-retries", "3", "--fragment-retries", "3",
"-o", filepath.Join(outputDir, "%(title)s.%(ext)s"), "-o", filepath.Join(taskDir, "%(title)s.%(ext)s"),
"--no-overwrites", "--no-overwrites",
} }
if format != "" { if format != "" {
@ -184,7 +202,7 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
} }
dlArgs = append(dlArgs, url) dlArgs = append(dlArgs, url)
cmd2 := exec.Command("yt-dlp", dlArgs...) cmd2 := exec.Command("yt-dlp", dlArgs...)
cmd2.Env = append(os.Environ(), "HTTP_PROXY=http://127.0.0.1:7890", "HTTPS_PROXY=http://127.0.0.1:7890") cmd2.Env = proxyEnv(p.proxy)
var dlOut bytes.Buffer var dlOut bytes.Buffer
cmd2.Stdout = &dlOut cmd2.Stdout = &dlOut
cmd2.Stderr = &dlOut cmd2.Stderr = &dlOut
@ -192,9 +210,18 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
return nil, fmt.Errorf("yt-dlp download: %w\n%s", err, strings.TrimSpace(dlOut.String())) return nil, fmt.Errorf("yt-dlp download: %w\n%s", err, strings.TrimSpace(dlOut.String()))
} }
entries, _ := os.ReadDir(outputDir) parts, _ := filepath.Glob(filepath.Join(taskDir, "*.part"))
var newest string for _, f := range parts {
var newestTime int64 os.Remove(f)
}
residuals, _ := filepath.Glob(filepath.Join(taskDir, "*.ytdl"))
for _, f := range residuals {
os.Remove(f)
}
entries, _ := os.ReadDir(taskDir)
var mainFile string
var mainSize int64
for _, e := range entries { for _, e := range entries {
if e.IsDir() { if e.IsDir() {
continue continue
@ -203,30 +230,32 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
if fi == nil { if fi == nil {
continue continue
} }
t := fi.ModTime().Unix() if fi.Size() > mainSize {
if t > newestTime { mainSize = fi.Size()
newestTime = t mainFile = e.Name()
newest = e.Name()
} }
} }
if newest == "" { if mainFile == "" {
return map[string]interface{}{ return map[string]interface{}{
"content": "下载完成,但未找到视频文件", "content": "下载完成,但未找到视频文件",
}, nil }, nil
} }
dlPath := filepath.Join(outputDir, newest) dlPath := filepath.Join(taskDir, mainFile)
fi, _ := os.Stat(dlPath)
var fileSize int64
if fi != nil {
fileSize = fi.Size()
}
return map[string]interface{}{ return map[string]interface{}{
"content": fmt.Sprintf("下载完成: %s (%.1f MB)\n路径: %s", newest, float64(fileSize)/1048576, dlPath), "content": fmt.Sprintf("下载完成: %s (%.1f MB)\n路径: %s", mainFile, float64(mainSize)/1048576, dlPath),
"file": dlPath, "file": dlPath,
"filename": newest, "filename": mainFile,
}, nil }, nil
} }
func proxyEnv(proxy string) []string {
env := os.Environ()
if proxy != "" {
env = append(env, "HTTP_PROXY="+proxy, "HTTPS_PROXY="+proxy)
}
return env
}
func contains(slice []string, s string) bool { func contains(slice []string, s string) bool {
for _, v := range slice { for _, v := range slice {
if v == s { if v == s {

View File

@ -17,10 +17,10 @@ require (
golang.org/x/sys v0.16.0 golang.org/x/sys v0.16.0
) )
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

View File

@ -1,4 +1,4 @@
{ {
"name": "browser", "name": "browser",
"name_zh": "浏览器", "name_zh": "浏览器",
"name_en": "Browser", "name_en": "Browser",

View File

@ -37,6 +37,7 @@ type Plugin struct {
nextID int nextID int
wg sync.WaitGroup wg sync.WaitGroup
stopCh chan struct{} stopCh chan struct{}
stopOnce sync.Once
} }
type BrowserSession struct { type BrowserSession struct {
@ -273,7 +274,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
"properties": map[string]interface{}{ "properties": map[string]interface{}{
"id": map[string]interface{}{"type": "string", "description": "浏览器会话 ID"}, "id": map[string]interface{}{"type": "string", "description": "浏览器会话 ID"},
"full": map[string]interface{}{"type": "boolean", "description": "是否全页截图(默认 false仅视口)"}, "full": map[string]interface{}{"type": "boolean", "description": "是否全页截图(默认 false仅视口)"},
"format": map[string]interface{}{"type": "string", "description": "图片格式: png 或 jpeg(默认 png)"}, "format": map[string]interface{}{"type": "string", "description": "图片格式: 仅支持 png(默认 png)"},
}, },
"required": []string{"id"}, "required": []string{"id"},
}, },
@ -356,18 +357,20 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
} }
func (p *Plugin) Stop() error { func (p *Plugin) Stop() error {
close(p.stopCh) p.stopOnce.Do(func() {
p.wg.Wait() close(p.stopCh)
if p.client != nil { p.wg.Wait()
p.client.CloseIdleConnections() if p.client != nil {
} p.client.CloseIdleConnections()
p.mu.Lock() }
for _, s := range p.sessions { p.mu.Lock()
s.Close() for _, s := range p.sessions {
} s.Close()
p.sessions = nil }
p.mu.Unlock() p.sessions = nil
log.Printf("[%s] stopped", p.name) p.mu.Unlock()
log.Printf("[%s] stopped", p.name)
})
return nil return nil
} }
@ -665,6 +668,9 @@ func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error)
if rawURL == "" { if rawURL == "" {
return errResult("url is required"), nil return errResult("url is required"), nil
} }
if err := p.ssrfCheck(rawURL); err != nil {
return errResult(err.Error()), nil
}
waitSec := int64(readArg(args, "wait", float64(0))) waitSec := int64(readArg(args, "wait", float64(0)))
if waitSec > 0 { if waitSec > 0 {
time.Sleep(time.Duration(waitSec) * time.Second) time.Sleep(time.Duration(waitSec) * time.Second)
@ -766,7 +772,7 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
return errResult("navigate failed: " + err.Error()), nil return errResult("navigate failed: " + err.Error()), nil
} }
session.currentURL = initURL session.currentURL = initURL
p.sdk.InjectText(p.name, p.name, fmt.Sprintf("[浏览器 %s 已打开 %s]", id, initURL)) p.sdk.InjectTextNoMemory(p.name, p.name, fmt.Sprintf("[浏览器 %s 已打开 %s]", id, initURL))
} }
log.Printf("[%s] created browser session %s: url=%s timeout=%v", p.name, id, initURL, timeout) log.Printf("[%s] created browser session %s: url=%s timeout=%v", p.name, id, initURL, timeout)
@ -807,7 +813,7 @@ func (p *Plugin) handleNavigate(args map[string]interface{}) (interface{}, error
return errResult("navigate failed: " + err.Error()), nil return errResult("navigate failed: " + err.Error()), nil
} }
s.currentURL = rawURL s.currentURL = rawURL
p.sdk.InjectText(p.name, p.name, fmt.Sprintf("[浏览器 %s 已导航到 %s]", id, rawURL)) p.sdk.InjectTextNoMemory(p.name, p.name, fmt.Sprintf("[浏览器 %s 已导航到 %s]", id, rawURL))
return map[string]interface{}{"status": "ok", "url": rawURL}, nil return map[string]interface{}{"status": "ok", "url": rawURL}, nil
} }
@ -825,6 +831,9 @@ func (p *Plugin) handleScreenshot(args map[string]interface{}) (interface{}, err
full = v full = v
} }
format := readArg(args, "format", "png") format := readArg(args, "format", "png")
if format != "png" {
return errResult("仅支持 png 格式"), nil
}
var buf []byte var buf []byte
var err error var err error
if full { if full {
@ -841,7 +850,7 @@ func (p *Plugin) handleScreenshot(args map[string]interface{}) (interface{}, err
"format": format, "format": format,
"size": len(buf), "size": len(buf),
"base64": b64, "base64": b64,
"data_uri": fmt.Sprintf("data:image/%s;base64,%s", format, b64), "data_uri": fmt.Sprintf("data:image/png;base64,%s", b64),
}, nil }, nil
} }
@ -1001,9 +1010,9 @@ func (p *Plugin) cleanupLoop() {
for id, s := range p.sessions { for id, s := range p.sessions {
if time.Since(s.createdAt) >= s.timeout { if time.Since(s.createdAt) >= s.timeout {
log.Printf("[%s] cleanup: browser session %s expired", p.name, id) log.Printf("[%s] cleanup: browser session %s expired", p.name, id)
delete(p.sessions, id) delete(p.sessions, id)
go s.Close() s.Close()
p.sdk.InjectText(p.name, p.name, fmt.Sprintf("[浏览器会话 %s 已超时关闭]", id)) p.sdk.InjectInterruptText(p.name, p.name, fmt.Sprintf("[浏览器会话 %s 已超时关闭]", id))
} }
} }
p.mu.Unlock() p.mu.Unlock()

View File

@ -4,4 +4,4 @@ go 1.25.0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0 require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

View File

@ -1,4 +1,4 @@
{ {
"name": "calendar", "name": "calendar",
"name_zh": "日历", "name_zh": "日历",
"name_en": "Calendar", "name_en": "Calendar",

View File

@ -134,6 +134,18 @@ func readArg[T string | int64 | float64](args map[string]interface{}, key string
return fallback return fallback
} }
func readArgBool(args map[string]interface{}, key string) bool {
if v, ok := args[key]; ok && v != nil {
if b, ok := v.(bool); ok {
return b
}
if s, ok := v.(string); ok {
return s == "1" || strings.EqualFold(s, "true")
}
}
return false
}
// --- Time Helpers --- // --- Time Helpers ---
var shortWeekday = map[time.Weekday]string{ var shortWeekday = map[time.Weekday]string{
@ -185,14 +197,14 @@ func daysInLunarYear(year int) int {
} }
y := lunarInfo[year-1900] y := lunarInfo[year-1900]
sum := 0 sum := 0
for i := 0x8000; i > 0; i >>= 1 { for i := 0x8000; i > 0x8; i >>= 1 {
if y&i > 0 { if y&i > 0 {
sum += 30 sum += 30
} else { } else {
sum += 29 sum += 29
} }
} }
return sum return sum + leapDays(year)
} }
func leapMonth(year int) int { func leapMonth(year int) int {
@ -236,11 +248,9 @@ func lunarToSolar(year, month, day int) (time.Time, bool) {
offset += daysInLunarYear(y) offset += daysInLunarYear(y)
} }
lm := leapMonth(year) lm := leapMonth(year)
_ = lm
for m := 1; m < month; m++ { for m := 1; m < month; m++ {
offset += monthDays(year, m) offset += monthDays(year, m)
if m == lm {
offset += leapDays(year)
}
} }
offset += day - 1 offset += day - 1
solar := baseSolar.AddDate(0, 0, offset) solar := baseSolar.AddDate(0, 0, offset)
@ -254,7 +264,7 @@ func nextLunarYearly(targetMonth, targetDay int, after time.Time) (time.Time, bo
if !ok { if !ok {
continue continue
} }
if t.After(after) || t.Equal(after) { if t.After(after) {
return t, true return t, true
} }
} }
@ -266,14 +276,22 @@ func nextLunarYearly(targetMonth, targetDay int, after time.Time) (time.Time, bo
func (p *Plugin) Start(s *sdk.PluginSDK) error { func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.sdk = s p.sdk = s
dataHome := os.Getenv("HOME") dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir")
if dataHome == "" { if err != nil || dataDirVal == "" {
dataHome = "/tmp" dataDirVal = "."
}
p.dataDir = filepath.Join(fmt.Sprint(dataDirVal), "calendar")
if err := os.MkdirAll(p.dataDir, 0755); err != nil {
fmt.Printf("[%s] mkdir %s: %v\n", p.name, p.dataDir, err)
} }
p.dataDir = filepath.Join(dataHome, ".homeagent", "calendar")
os.MkdirAll(p.dataDir, 0755)
p.loadEvents() p.loadEvents()
// 持久化交由 stop handler内核会在调用 Stop() 之前执行,
// 避免 Stop() 阶段以陈旧内存写回导致已删除事件复活。
s.RegisterStopHandler(p.saveEvents)
// 删除清理:卸载插件时移除本地事件数据文件(删除专用回调,重载不触发)。
s.RegisterOnRemoveHandler(p.cleanupData)
tp := p.name + "_" tp := p.name + "_"
s.RegisterTool(tp+"event_add", sdk.ToolDef{ s.RegisterTool(tp+"event_add", sdk.ToolDef{
@ -388,7 +406,6 @@ func (p *Plugin) Stop() error {
p.remindTicker.Stop() p.remindTicker.Stop()
close(p.stopCh) close(p.stopCh)
p.wg.Wait() p.wg.Wait()
p.saveEvents()
fmt.Printf("[%s] stopped\n", p.name) fmt.Printf("[%s] stopped\n", p.name)
return nil return nil
} }
@ -411,9 +428,9 @@ func (p *Plugin) checkReminders() {
now := time.Now() now := time.Now()
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock()
changed := false changed := false
var injectMsgs []string
for i := range p.events { for i := range p.events {
e := &p.events[i] e := &p.events[i]
@ -455,7 +472,7 @@ func (p *Plugin) checkReminders() {
if e.Note != "" { if e.Note != "" {
msg += fmt.Sprintf("\n📝 %s", e.Note) msg += fmt.Sprintf("\n📝 %s", e.Note)
} }
go p.sdk.InjectInterruptText("calendar", "calendar", msg) injectMsgs = append(injectMsgs, msg)
} }
} }
@ -479,8 +496,17 @@ func (p *Plugin) checkReminders() {
pid = e.ParentID pid = e.ParentID
} }
next.ParentID = pid next.ParentID = pid
newEvents = append(newEvents, *next) dup := false
changed = true for _, ev := range p.events {
if ev.ID != e.ID && ev.ParentID == pid && ev.StartTime == next.StartTime {
dup = true
break
}
}
if !dup {
newEvents = append(newEvents, *next)
changed = true
}
} }
} }
if len(newEvents) > 0 { if len(newEvents) > 0 {
@ -491,6 +517,11 @@ func (p *Plugin) checkReminders() {
if changed { if changed {
p.saveEventsLocked() p.saveEventsLocked()
} }
p.mu.Unlock()
for _, msg := range injectMsgs {
p.sdk.InjectInterruptText("calendar", "calendar", msg)
}
} }
func (p *Plugin) nextOccurrence(e CalendarEvent, evtTime time.Time) *CalendarEvent { func (p *Plugin) nextOccurrence(e CalendarEvent, evtTime time.Time) *CalendarEvent {
@ -560,9 +591,7 @@ func (p *Plugin) cleanupPastEvents() {
keep = append(keep, e) keep = append(keep, e)
continue continue
} }
if e.Repeat != "" && e.Repeat != RepeatNone { _ = e // 过时重复事件不再保留next 已由 nextOccurrence 追加
keep = append(keep, e)
}
} }
p.events = keep p.events = keep
} }
@ -573,6 +602,17 @@ func (p *Plugin) eventsFile() string {
return filepath.Join(p.dataDir, "events.json") return filepath.Join(p.dataDir, "events.json")
} }
// cleanupData 删除插件时清理本地持久化数据文件。
func (p *Plugin) cleanupData() {
p.mu.Lock()
defer p.mu.Unlock()
if err := os.Remove(p.eventsFile()); err != nil && !os.IsNotExist(err) {
fmt.Printf("[calendar] onRemove cleanup: %v\n", err)
} else {
fmt.Printf("[calendar] onRemove removed %s\n", p.eventsFile())
}
}
func (p *Plugin) loadEvents() { func (p *Plugin) loadEvents() {
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
@ -692,10 +732,7 @@ func (p *Plugin) handleEventAdd(args map[string]interface{}) (interface{}, error
note := readArg(args, "note", "") note := readArg(args, "note", "")
remindStr := readArg(args, "remind_before", "") remindStr := readArg(args, "remind_before", "")
reminds := parseReminds(remindStr) reminds := parseReminds(remindStr)
lunar := false lunar := readArgBool(args, "lunar")
if v := readArg(args, "lunar", ""); v == "true" {
lunar = true
}
lunarMonth := int(readArg(args, "lunar_month", int64(0))) lunarMonth := int(readArg(args, "lunar_month", int64(0)))
lunarDay := int(readArg(args, "lunar_day", int64(0))) lunarDay := int(readArg(args, "lunar_day", int64(0)))
@ -897,10 +934,12 @@ func (p *Plugin) handleEventUpdate(args map[string]interface{}) (interface{}, er
e.Repeat = v e.Repeat = v
} }
} }
if v := readArg(args, "lunar", ""); v == "true" { if v, ok := args["lunar"]; ok && v != nil {
e.Lunar = true if b, ok := v.(bool); ok {
} else if v == "false" { e.Lunar = b
e.Lunar = false } else if s, ok := v.(string); ok {
e.Lunar = s == "1" || strings.EqualFold(s, "true")
}
} }
if v := readArg(args, "lunar_month", int64(0)); v > 0 { if v := readArg(args, "lunar_month", int64(0)); v > 0 {
e.LunarMonth = int(v) e.LunarMonth = int(v)

View File

@ -4,4 +4,4 @@ go 1.25.0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0 require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

View File

@ -1,4 +1,4 @@
{ {
"name": "editdoc", "name": "editdoc",
"name_zh": "文档编辑", "name_zh": "文档编辑",
"name_en": "Document Editor", "name_en": "Document Editor",

View File

@ -4,15 +4,19 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk" "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
) )
type Plugin struct { type Plugin struct {
name string name string
sdk *sdk.PluginSDK sdk *sdk.PluginSDK
scriptPath string
venvPython string
} }
func (p *Plugin) Name() string { return p.name } func (p *Plugin) Name() string { return p.name }
@ -20,6 +24,30 @@ func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error { func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true) s.SetAutoRestart(true)
p.sdk = s p.sdk = s
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "script_path", Default: "", Type: "string",
DisplayName: "编辑脚本路径",
Description: "edit_doc.py 的绝对路径;留空时使用插件可执行文件同目录下的 edit_doc.py",
Category: p.name,
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "venv_python", Default: "", Type: "string",
DisplayName: "venv Python 解释器",
Description: "执行 edit_doc.py 使用的 Python 解释器(建议用 venv 内的 python必须配置留空将报错",
Category: p.name,
})
if v, err := s.Settings().Get("script_path"); err == nil {
if str, ok := v.(string); ok {
p.scriptPath = str
}
}
if v, err := s.Settings().Get("venv_python"); err == nil {
if str, ok := v.(string); ok {
p.venvPython = str
}
}
s.RegisterTool("edit_document", sdk.ToolDef{ s.RegisterTool("edit_document", sdk.ToolDef{
Name: "edit_document", Name: "edit_document",
Description: "编辑 Office 文档内容。支持替换文本、修改单元格等操作。编辑后原文件被覆盖。操作前建议先用 read_document 查看内容。支持 .docx / .xlsx / .pptx。", Description: "编辑 Office 文档内容。支持替换文本、修改单元格等操作。编辑后原文件被覆盖。操作前建议先用 read_document 查看内容。支持 .docx / .xlsx / .pptx。",
@ -80,19 +108,24 @@ func (p *Plugin) handleEditDocument(args map[string]interface{}) (interface{}, e
} }
pyArgsJSON, _ := json.Marshal(pyArgs) pyArgsJSON, _ := json.Marshal(pyArgs)
scriptPath := "/home/newqqagent/plugins/editdoc/edit_doc.py" scriptPath := p.scriptPath
if scriptPath == "" {
scriptPath = filepath.Join(filepath.Dir(os.Args[0]), "edit_doc.py")
log.Printf("[%s] script_path 未配置,使用默认脚本路径: %s", p.name, scriptPath)
}
if _, err := os.Stat(scriptPath); os.IsNotExist(err) { if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
return nil, fmt.Errorf("edit_doc.py not found at %s", scriptPath) return nil, fmt.Errorf("edit_doc.py not found at %s(请在插件配置 script_path 中指定脚本路径)", scriptPath)
} }
venvPython := "/home/program/qq-workspace/self-workplace/.venv/bin/python3" if p.venvPython == "" {
pythonBin := "python3" return nil, fmt.Errorf("venv_python 未配置,无法执行脚本;请在插件配置中设置 venv_pythonvenv 内 python 的绝对路径)")
if _, err := os.Stat(venvPython); err == nil { }
pythonBin = venvPython if _, err := os.Stat(p.venvPython); err != nil {
return nil, fmt.Errorf("venv python 不存在: %s请检查 venv_python 配置)", p.venvPython)
} }
var out bytes.Buffer var out bytes.Buffer
cmd := exec.Command(pythonBin, scriptPath, file, operation, string(pyArgsJSON)) cmd := exec.Command(p.venvPython, scriptPath, file, operation, string(pyArgsJSON))
cmd.Stdout = &out cmd.Stdout = &out
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("edit document: %w", err) return nil, fmt.Errorf("edit document: %w", err)

View File

@ -4,4 +4,4 @@ go 1.25.0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0 require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

View File

@ -1,4 +1,4 @@
{ {
"name": "files", "name": "files",
"name_zh": "文件系统", "name_zh": "文件系统",
"name_en": "File System", "name_en": "File System",

View File

@ -27,24 +27,39 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.sdk = s p.sdk = s
s.Settings().RegisterDef(sdk.ConfigDef{ s.Settings().RegisterDef(sdk.ConfigDef{
Key: "dir", Key: "dir",
Default: "/", Default: "",
Type: "string", Type: "string",
DisplayName: "文件系统根目录", DisplayName: "文件系统根目录",
Description: "文件操作允许访问的根目录(设为 / 表示完整主机文件系统)", Description: "文件操作允许访问的根目录;留空时使用默认沙箱目录(主数据目录/files_sandbox不建议设为 /",
Category: "files", Category: "files",
}) })
dir := getSetting[string](s.Settings(), "dir", "/") dir := getSetting[string](s.Settings(), "dir", "")
if strings.HasPrefix(dir, "~/") { if strings.HasPrefix(dir, "~/") {
home, _ := os.UserHomeDir() home, _ := os.UserHomeDir()
dir = filepath.Join(home, dir[2:]) dir = filepath.Join(home, dir[2:])
} }
if dir == "" {
dataDir, err := s.Settings().GetCore("core.daemon.data_dir")
base := "."
if err == nil {
if ds, ok := dataDir.(string); ok && ds != "" {
base = ds
}
}
dir = filepath.Join(base, "files_sandbox")
}
abs, err := filepath.Abs(dir) abs, err := filepath.Abs(dir)
if err != nil { if err != nil {
return fmt.Errorf("resolve files.dir: %w", err) return fmt.Errorf("resolve files.dir: %w", err)
} }
if err := os.MkdirAll(abs, 0755); err != nil {
return fmt.Errorf("mkdir files.dir: %w", err)
}
if real, err := filepath.EvalSymlinks(abs); err == nil {
abs = real
}
p.filesDir = abs p.filesDir = abs
os.MkdirAll(p.filesDir, 0755)
tp := p.name + "_" tp := p.name + "_"
@ -143,10 +158,60 @@ func (p *Plugin) resolvePath(userPath string) (string, error) {
return "", fmt.Errorf("resolve path: %w", err) return "", fmt.Errorf("resolve path: %w", err)
} }
base := filepath.Clean(p.filesDir) base := filepath.Clean(p.filesDir)
if base != "/" && !strings.HasPrefix(abs, base+string(filepath.Separator)) && abs != base { if !withinSandbox(base, abs) {
return "", fmt.Errorf("path outside sandbox: %s", userPath) return "", fmt.Errorf("path outside sandbox: %s", userPath)
} }
return abs, nil real, err := evalReal(base, abs)
if err != nil {
return "", err
}
if !withinSandbox(base, real) {
return "", fmt.Errorf("path escapes sandbox via symlink: %s", userPath)
}
return real, nil
}
func withinSandbox(base, abs string) bool {
if base == "/" {
return true
}
return abs == base || strings.HasPrefix(abs, base+string(filepath.Separator))
}
func evalReal(base, abs string) (string, error) {
existing := abs
var tail []string
for {
real, err := filepath.EvalSymlinks(existing)
if err == nil {
full := real
for i := len(tail) - 1; i >= 0; i-- {
full = filepath.Join(full, tail[i])
}
return full, nil
}
if !os.IsNotExist(err) {
return "", fmt.Errorf("resolve path: %w", err)
}
if link, lerr := os.Readlink(existing); lerr == nil {
target := link
if !filepath.IsAbs(target) {
target = filepath.Join(filepath.Dir(existing), target)
}
if t, aerr := filepath.Abs(target); aerr == nil {
target = filepath.Clean(t)
}
if !withinSandbox(base, target) {
return "", fmt.Errorf("path escapes sandbox via symlink: %s", abs)
}
}
parent := filepath.Dir(existing)
if parent == existing {
return "", fmt.Errorf("resolve path: %w", err)
}
tail = append(tail, filepath.Base(existing))
existing = parent
}
} }
// handleRead implements the read tool. // handleRead implements the read tool.

25
example/luademo/README.md Normal file
View File

@ -0,0 +1,25 @@
# luademo
Lua 插件全功能示例,展示 v0.8.0 Lua SDK 的完整能力面:
- **工具注册**`no_memory` + `cleaner`(记忆计算层过滤)
- **阶段钩子**`register_stage(stage, handler, scope)``own_tools` 与全局作用域
- **通道**`register_output_channel` / `register_input_channel`def 支持 no_memory/cleaner
- **数据类 API**`sdk.memory.*``sdk.doc.*``sdk.knowledge.*``sdk.text_memory.*``sdk.llm.*``sdk.settings.*``sdk.social.*`
- **其他**`register_api``set_auto_restart`
## 本地独立测试
```bash
lua main.lua # 使用 sdk.lua mock不依赖内核
```
## 构建
```bash
plugindev build
```
## 安装
通过插件管理 HTTP API 上传 `.hmap` 包,或解压到 `<data>/plugins/luademo/` 后重启内核。

101
example/luademo/main.lua Normal file
View File

@ -0,0 +1,101 @@
-- luademo plugin — 展示 v0.8.0 Lua SDK 全部能力
-- 运行环境内核注入真实实现lua main.lua 可用 sdk.lua mock 独立测试
local plugin = { name = "luademo" }
function plugin.start(sdk)
sdk.log("info", "luademo starting...")
-- 注册配置项WebUI 可展示)
sdk.settings.register_def({
key = "plugin.luademo.greeting",
default = "Hello",
type = "string",
display_name = "Greeting",
description = "Greeting prefix for the hello tool",
category = "luademo",
})
-- 注册工具no_memory输出跳过记忆计算+ cleaner计算层过滤函数
sdk.register_tool("luademo_hello", {
description = "A hello world tool with no_memory and cleaner",
parameters = { type = "object", properties = {} },
no_memory = true,
cleaner = function(text) return "CLEANED:" .. text end,
}, function(args)
local prefix, err = sdk.settings.get_core("plugin.luademo.greeting")
if err ~= nil then prefix = "Hello" end
return { content = (prefix or "Hello") .. " from luademo plugin!" }
end)
-- 注册工具:数据类 API 巡检memory/doc/knowledge/text_memory/llm/settings/social
sdk.register_tool("luademo_probe", {
description = "Exercise every aligned data API and return combined results",
parameters = { type = "object", properties = {} },
no_memory = true,
}, function(args)
local res = {}
local ok, err = sdk.memory.commit({ { subject = "demo", relation = "uses", object = "lua" } })
res.memory_commit = { ok = ok, err = err }
local recalled, rerr = sdk.memory.recall("demo", 1)
res.memory_recall = { result = recalled, err = rerr }
ok, err = sdk.doc.insert({ id = "demo-1", title = "lua demo doc", content = "hello lua world" })
res.doc_insert = { ok = ok, err = err }
local docs, derr = sdk.doc.query("lua", 2)
res.doc_query = { result = docs, err = derr }
ok, err = sdk.knowledge.add("luademo", "lua knowledge entry")
res.knowledge_add = { ok = ok, err = err }
local entries, kerr = sdk.knowledge.search("luademo", 2)
res.knowledge_search = { result = entries, err = kerr }
ok, err = sdk.text_memory.append({ role = "tool", content = "luademo probe ran", channel = "luademo" })
res.text_memory = { ok = ok, err = err }
local sources, serr = sdk.llm.list_sources()
res.llm_sources = { result = sources, err = serr }
local v, verr = sdk.settings.get_core("agent.name")
res.settings_get_core = { result = v, err = verr }
local defs, defserr = sdk.settings.defs("plugin.luademo")
res.settings_defs = { result = defs, err = defserr }
local persons, perr = sdk.social.list_persons()
res.social_persons = { result = persons, err = perr }
return { content = res }
end)
-- 阶段钩子own_tools 作用域(仅本插件工具被调用时触发)
sdk.register_stage("before_toolcall", function(ctx)
local calls = ctx.tool_calls or {}
if calls[1] then
sdk.log("info", "luademo stage before_toolcall: tool=" .. tostring(calls[1].name))
end
return nil
end, "own_tools")
-- 阶段钩子:全局作用域
sdk.register_stage("pre_action", function(ctx)
sdk.log("info", "luademo stage pre_action: user=" .. tostring(ctx.user_id))
return nil
end)
-- 输出通道路由输出到外部渠道def 支持 no_memory/cleaner
sdk.register_output_channel("luademo_out", 0, "luademo push channel",
{ no_memory = true, cleaner = function(t) return "OCLEANED:" .. t end },
function(args) return { content = "out-channel ack" } end)
-- 输入通道
sdk.register_input_channel("luademo_in", { no_memory = true })
-- 其他 API
sdk.register_api("luademo.ping")
sdk.set_auto_restart(true)
sdk.log("info", "luademo started")
end
function plugin.stop() sdk.log("info", "luademo stopped") end
return plugin

11
example/luademo/plg.json Normal file
View File

@ -0,0 +1,11 @@
{
"name": "luademo",
"name_zh": "Lua 全功能示例",
"name_en": "Lua Demo",
"version": "0.1.0",
"description": "Lua 插件全功能示例:工具(no_memory/cleaner) + 阶段钩子 + 通道 + 数据类 API",
"author": "HomeAgent",
"entry": "main.lua",
"tags": ["luademo"],
"targets": "lua"
}

67
example/luademo/sdk.lua Normal file
View File

@ -0,0 +1,67 @@
-- HomeAgent Lua Plugin SDK (standalone mock)
sdk = {}
function sdk.log(level, msg) print("[lua-plugin] " .. tostring(level) .. ": " .. tostring(msg)) end
function sdk.register_tool(name, def, handler) print("[lua-plugin] register_tool: " .. tostring(name)) end
function sdk.register_stage(stage, handler, scope) print("[lua-plugin] register_stage: " .. tostring(stage) .. " scope=" .. tostring(scope)) end
function sdk.register_api(name) print("[lua-plugin] register_api: " .. tostring(name)) end
function sdk.register_output_channel(name, caps, desc, def, handler) print("[lua-plugin] register_output_channel: " .. tostring(name)) end
function sdk.register_input_channel(name, def) print("[lua-plugin] register_input_channel: " .. tostring(name)) end
function sdk.get_setting(key) return nil end
function sdk.set_setting(key, value) print("[lua-plugin] set_setting: " .. tostring(key)) end
function sdk.inject_text(source, channel, text) print("[lua-plugin] inject_text: " .. tostring(source)) end
function sdk.inject_interrupt(source, channel, text) print("[lua-plugin] inject_interrupt: " .. tostring(source)) end
function sdk.inject_text_no_memory(source, channel, text) print("[lua-plugin] inject_text_no_memory: " .. tostring(source)) end
function sdk.set_auto_restart(enabled) print("[lua-plugin] set_auto_restart: " .. tostring(enabled)) end
sdk.memory = {}
function sdk.memory.recall(query, depth) return {entities={}, relations={}} end
function sdk.memory.commit(triples) return nil end
function sdk.memory.introspect() return {} end
function sdk.memory.merge(source, target) return 0 end
function sdk.memory.purge(criteria, hard) return 0 end
sdk.doc = {}
function sdk.doc.query(text, top_k) return {} end
function sdk.doc.insert(doc) return nil end
function sdk.doc.remove(id) return nil end
function sdk.doc.stats() return {} end
sdk.knowledge = {}
function sdk.knowledge.search(query, limit) return {} end
function sdk.knowledge.add(tag, content) return nil end
function sdk.knowledge.list() return {} end
sdk.text_memory = {}
function sdk.text_memory.append(evt) return nil end
sdk.llm = {}
function sdk.llm.list_sources() return {} end
function sdk.llm.set_source(name) return nil end
function sdk.llm.current_source() return nil end
sdk.social = {}
function sdk.social.get_person(name) return {} end
function sdk.social.get_network(name, depth) return {} end
function sdk.social.get_trait(name, trait) return {value=nil, found=false} end
function sdk.social.get_relations(name) return {} end
function sdk.social.list_persons() return {} end
sdk.settings = {}
function sdk.settings.get_core(key) return nil end
function sdk.settings.set_core(key, value) return nil end
function sdk.settings.list_core(prefix) return {} end
function sdk.settings.get_plugin(plugin, key) return nil end
function sdk.settings.set_plugin(plugin, key, value) return nil end
function sdk.settings.list_plugin(plugin, prefix) return {} end
function sdk.settings.list(prefix) return {} end
function sdk.settings.register_def(def) return nil end
function sdk.settings.defs(prefix) return {} end
function sdk.settings.dump() return {} end
function sdk.settings.plugins() return {} end
sdk.json = {}
function sdk.json.encode(val)
if type(val) == "string" then return '"' .. val:gsub('"', '\\"'):gsub('\n', '\\n') .. '"'
elseif type(val) == "number" or type(val) == "boolean" then return tostring(val)
elseif type(val) == "table" then local parts, i = {}, 1
for k, v in pairs(val) do parts[i] = sdk.json.encode(k) .. ":" .. sdk.json.encode(v); i = i + 1 end
return "{" .. table.concat(parts, ",") .. "}" end
return "null"
end
function sdk.json.decode(str) local ok, fn = pcall(load, "return " .. str); if ok then return fn() end; return nil end
sdk.http = {}
function sdk.http.get(url) print("[lua-plugin] http.get: " .. tostring(url)); return {status=200, body='{"mock":true}', headers={}} end
function sdk.http.post(url, body, ct) print("[lua-plugin] http.post: " .. tostring(url)); return {status=200, body='{"mock":true}', headers={}} end
return sdk

View File

@ -4,4 +4,4 @@ go 1.25.0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0 require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

View File

@ -1,9 +1,9 @@
{ {
"name": "memo", "name": "memo",
"name_zh": "备忘录", "name_zh": "备忘录",
"name_en": "Memo/Notes", "name_en": "Memo",
"version": "1.0.0", "version": "1.0.0",
"description": "待办事项与备忘录管理插件。支持创建、完成、列表查看。通过阶段钩子在每次对话前注入待办提醒。", "description": "待办与备忘录插件。待办todo_add/todo_complete/todo_list会主动提醒备忘录memo_create/memo_list/memo_delete纯记事不提醒。",
"author": "HomeAgent", "author": "HomeAgent",
"entry": "plugin.so", "entry": "plugin.so",
"tags": ["memo", "todo", "notes"], "tags": ["memo", "todo", "notes"],

View File

@ -13,20 +13,31 @@ import (
"gitcode.com/JianFeeeee/homeagent-sdk/sdk" "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
) )
type Memo struct { // Todo 待办条目:会被主动提醒
type Todo struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Content string `json:"content"` Content string `json:"content"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
Done bool `json:"done"` Done bool `json:"done"`
} }
// Memo 备忘录条目:纯记事,不主动提醒
type Memo struct {
ID int64 `json:"id"`
Content string `json:"content"`
CreatedAt int64 `json:"created_at"`
}
type Plugin struct { type Plugin struct {
name string name string
sdk *sdk.PluginSDK sdk *sdk.PluginSDK
mu sync.RWMutex mu sync.RWMutex
todos []Todo
nextTID int64
memos []Memo memos []Memo
nextID int64 nextMID int64
filePath string todoPath string
memoPath string
stopCh chan struct{} stopCh chan struct{}
tp string tp string
} }
@ -37,70 +48,154 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true) s.SetAutoRestart(true)
p.sdk = s p.sdk = s
p.tp = p.name + "_" p.tp = p.name + "_"
p.stopCh = make(chan struct{})
dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir") dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir")
if err != nil || dataDirVal == "" { if err != nil || dataDirVal == "" {
dataDirVal = "." dataDirVal = "."
} }
p.filePath = filepath.Join(fmt.Sprint(dataDirVal), "memos.json") dir := filepath.Join(fmt.Sprint(dataDirVal), p.name)
p.load() if err := os.MkdirAll(dir, 0755); err != nil {
log.Printf("[%s] mkdir data dir %s: %v", p.name, dir, err)
}
p.todoPath = filepath.Join(dir, "todos.json")
p.memoPath = filepath.Join(dir, "memos.json")
p.loadTodos()
p.loadMemos()
s.RegisterTool(p.tp+"create", sdk.ToolDef{ // 卸载(删除)时清理数据文件;重载不触发
Name: p.tp + "create", s.RegisterOnRemoveHandler(p.cleanupData)
Description: "创建一条备忘条目。备忘内容应包含具体事项的完整描述。",
// ── 待办(会被主动提醒)──
s.RegisterTool(p.tp+"todo_add", sdk.ToolDef{
Name: p.tp + "todo_add",
Description: "添加一条待办事项。待办会被主动提醒,完成后请及时用 todo_complete 标记。",
Parameters: map[string]interface{}{ Parameters: map[string]interface{}{
"type": "object", "type": "object",
"properties": map[string]interface{}{ "properties": map[string]interface{}{
"content": map[string]interface{}{"type": "string", "description": "备忘内容"}, "content": map[string]interface{}{"type": "string", "description": "待办内容"},
}, },
"required": []string{"content"}, "required": []string{"content"},
}, },
}, p.handleCreate) }, p.handleTodoAdd)
s.RegisterTool(p.tp+"complete", sdk.ToolDef{ s.RegisterTool(p.tp+"todo_complete", sdk.ToolDef{
Name: p.tp + "complete", Name: p.tp + "todo_complete",
Description: "将指定ID的备忘标记为已完成。", Description: "将指定ID的待办标记为已完成(不再提醒)。",
Parameters: map[string]interface{}{ Parameters: map[string]interface{}{
"type": "object", "type": "object",
"properties": map[string]interface{}{ "properties": map[string]interface{}{
"id": map[string]interface{}{"type": "integer", "description": "备忘ID"}, "id": map[string]interface{}{"type": "integer", "description": "待办ID"},
}, },
"required": []string{"id"}, "required": []string{"id"},
}, },
}, p.handleComplete) }, p.handleTodoComplete)
s.RegisterTool(p.tp+"list", sdk.ToolDef{ s.RegisterTool(p.tp+"todo_list", sdk.ToolDef{
Name: p.tp + "list", Name: p.tp + "todo_list",
Description: "列出所有未完成的备忘条目包含ID、内容和创建时间。", Description: "列出所有未完成的待办事项包含ID、内容和创建时间。",
Parameters: map[string]interface{}{ Parameters: map[string]interface{}{
"type": "object", "type": "object",
"properties": map[string]interface{}{}, "properties": map[string]interface{}{},
}, },
}, p.handleList) }, p.handleTodoList)
s.RegisterTool(p.tp+"todo_delete", sdk.ToolDef{
Name: p.tp + "todo_delete",
Description: "删除指定ID的待办事项包括已完成的。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"id": map[string]interface{}{"type": "integer", "description": "待办ID"},
},
"required": []string{"id"},
},
}, p.handleTodoDelete)
// ── 备忘(纯记事,不提醒)──
s.RegisterTool(p.tp+"memo_create", sdk.ToolDef{
Name: p.tp + "memo_create",
Description: "创建一条备忘录。备忘录是纯记事(备注)用途,不会主动提醒,内容应包含完整信息供后续查阅。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"content": map[string]interface{}{"type": "string", "description": "备忘录内容"},
},
"required": []string{"content"},
},
}, p.handleMemoCreate)
s.RegisterTool(p.tp+"memo_list", sdk.ToolDef{
Name: p.tp + "memo_list",
Description: "列出所有备忘录包含ID、内容和创建时间。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
}, p.handleMemoList)
s.RegisterTool(p.tp+"memo_delete", sdk.ToolDef{
Name: p.tp + "memo_delete",
Description: "删除指定ID的备忘录。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"id": map[string]interface{}{"type": "integer", "description": "备忘录ID"},
},
"required": []string{"id"},
},
}, p.handleMemoDelete)
// 待办提醒:预动作注入未完成条数 + 周期主动提醒(备忘录不参与)
s.RegisterStage(sdk.StagePreAction, p.stagePreAction) s.RegisterStage(sdk.StagePreAction, p.stagePreAction)
go p.periodicCheck() go p.periodicCheck()
log.Printf("[%s] started, path=%s", p.name, p.filePath) log.Printf("[%s] started, todos=%s memos=%s", p.name, p.todoPath, p.memoPath)
return nil return nil
} }
func (p *Plugin) Stop() error { func (p *Plugin) Stop() error {
close(p.stopCh) close(p.stopCh)
p.save() p.saveTodos()
p.saveMemos()
log.Printf("[%s] stopped", p.name) log.Printf("[%s] stopped", p.name)
return nil return nil
} }
func (p *Plugin) load() { func (p *Plugin) loadTodos() {
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
data, err := os.ReadFile(p.filePath) data, err := os.ReadFile(p.todoPath)
if err != nil { if err != nil {
p.memos = nil p.todos = []Todo{}
p.nextID = 1 p.nextTID = 1
return
}
var store struct {
Todos []Todo `json:"todos"`
NextID int64 `json:"next_id"`
}
if json.Unmarshal(data, &store) != nil {
p.todos = []Todo{}
p.nextTID = 1
return
}
p.todos = store.Todos
p.nextTID = store.NextID
if p.todos == nil {
p.todos = []Todo{}
}
if p.nextTID < 1 {
p.nextTID = 1
}
}
func (p *Plugin) loadMemos() {
p.mu.Lock()
defer p.mu.Unlock()
data, err := os.ReadFile(p.memoPath)
if err != nil {
p.memos = []Memo{}
p.nextMID = 1
return return
} }
var store struct { var store struct {
@ -108,66 +203,82 @@ func (p *Plugin) load() {
NextID int64 `json:"next_id"` NextID int64 `json:"next_id"`
} }
if json.Unmarshal(data, &store) != nil { if json.Unmarshal(data, &store) != nil {
p.memos = nil p.memos = []Memo{}
p.nextID = 1 p.nextMID = 1
return return
} }
p.memos = store.Memos p.memos = store.Memos
p.nextID = store.NextID p.nextMID = store.NextID
if p.memos == nil { if p.memos == nil {
p.memos = []Memo{} p.memos = []Memo{}
} }
if p.nextID < 1 { if p.nextMID < 1 {
p.nextID = 1 p.nextMID = 1
} }
} }
func (p *Plugin) save() { func (p *Plugin) saveTodos() {
p.mu.RLock()
data, _ := json.MarshalIndent(map[string]interface{}{ data, _ := json.MarshalIndent(map[string]interface{}{
"memos": p.memos, "todos": p.todos,
"next_id": p.nextID, "next_id": p.nextTID,
}, "", " ") }, "", " ")
os.WriteFile(p.filePath, data, 0644) p.mu.RUnlock()
os.WriteFile(p.todoPath, data, 0644)
} }
func (p *Plugin) pendingCount() int { func (p *Plugin) saveMemos() {
p.mu.RLock()
data, _ := json.MarshalIndent(map[string]interface{}{
"memos": p.memos,
"next_id": p.nextMID,
}, "", " ")
p.mu.RUnlock()
os.WriteFile(p.memoPath, data, 0644)
}
// ── 待办:未完成计数与提醒 ──
func (p *Plugin) pendingTodoCount() int {
p.mu.RLock() p.mu.RLock()
defer p.mu.RUnlock() defer p.mu.RUnlock()
n := 0 n := 0
for _, m := range p.memos { for _, t := range p.todos {
if !m.Done { if !t.Done {
n++ n++
} }
} }
return n return n
} }
func (p *Plugin) pendingMemos() []Memo { func (p *Plugin) pendingTodos() []Todo {
p.mu.RLock() p.mu.RLock()
defer p.mu.RUnlock() defer p.mu.RUnlock()
var out []Memo var out []Todo
for _, m := range p.memos { for _, t := range p.todos {
if !m.Done { if !t.Done {
out = append(out, m) out = append(out, t)
} }
} }
return out return out
} }
// stagePreAction 仅在待办未完成时注入上下文提示(备忘录不提示)
func (p *Plugin) stagePreAction(ctx *sdk.StageContext) error { func (p *Plugin) stagePreAction(ctx *sdk.StageContext) error {
n := p.pendingCount() n := p.pendingTodoCount()
if n == 0 { if n == 0 {
return nil return nil
} }
ctx.Lock() ctx.Lock()
ctx.ContextMsgs = append(ctx.ContextMsgs, map[string]interface{}{ ctx.ContextMsgs = append(ctx.ContextMsgs, map[string]interface{}{
"role": "system", "role": "system",
"content": fmt.Sprintf("目前有%d条备忘未完成,调用%slist工具读取具体内容", n, p.tp), "content": fmt.Sprintf("目前有%d条待办未完成,调用%s todo_list 工具读取具体内容", n, p.tp),
}) })
ctx.Unlock() ctx.Unlock()
return nil return nil
} }
// periodicCheck 周期主动提醒未完成待办(备忘录不提醒)
func (p *Plugin) periodicCheck() { func (p *Plugin) periodicCheck() {
ticker := time.NewTicker(5 * time.Minute) ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop() defer ticker.Stop()
@ -176,19 +287,124 @@ func (p *Plugin) periodicCheck() {
case <-p.stopCh: case <-p.stopCh:
return return
case <-ticker.C: case <-ticker.C:
n := p.pendingCount() n := p.pendingTodoCount()
if n == 0 { if n == 0 {
continue continue
} }
if p.sdk != nil { if p.sdk != nil {
p.sdk.InjectInterruptText(p.name, p.name, p.sdk.InjectInterruptText(p.name, p.name,
fmt.Sprintf("注意,你还有%d条备忘未标记完成,请检查", n)) fmt.Sprintf("注意,你还有%d条待办未完成,请检查", n))
} }
} }
} }
} }
func (p *Plugin) handleCreate(args map[string]interface{}) (interface{}, error) { // ── 待办工具 ──
func (p *Plugin) handleTodoAdd(args map[string]interface{}) (interface{}, error) {
content, _ := args["content"].(string)
if content == "" {
return errorResult("content is required"), nil
}
p.mu.Lock()
todo := Todo{
ID: p.nextTID,
Content: content,
CreatedAt: time.Now().Unix(),
Done: false,
}
p.nextTID++
p.todos = append(p.todos, todo)
p.mu.Unlock()
p.saveTodos()
return map[string]interface{}{
"content": fmt.Sprintf("待办已添加 (ID: %d)", todo.ID),
"id": todo.ID,
}, nil
}
func (p *Plugin) handleTodoComplete(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.todos {
if p.todos[i].ID == int64(id) && !p.todos[i].Done {
p.todos[i].Done = true
found = true
break
}
}
p.mu.Unlock()
if !found {
return errorResult(fmt.Sprintf("未找到未完成的待办 ID: %d", int64(id))), nil
}
p.saveTodos()
return map[string]interface{}{
"content": fmt.Sprintf("待办 %d 已标记为完成", int64(id)),
}, nil
}
func (p *Plugin) handleTodoList(args map[string]interface{}) (interface{}, error) {
todos := p.pendingTodos()
if len(todos) == 0 {
return map[string]interface{}{
"content": "暂无未完成的待办",
}, nil
}
var sb strings.Builder
for i, t := range todos {
ts := time.Unix(t.CreatedAt, 0).Format("01-02 15:04")
if i > 0 {
sb.WriteString("\n")
}
sb.WriteString(fmt.Sprintf("%d. [ID:%d] %s — %s", i+1, t.ID, t.Content, ts))
}
return map[string]interface{}{
"content": sb.String(),
"count": len(todos),
}, nil
}
func (p *Plugin) handleTodoDelete(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.todos {
if p.todos[i].ID == int64(id) {
p.todos = append(p.todos[:i], p.todos[i+1:]...)
found = true
break
}
}
p.mu.Unlock()
if !found {
return errorResult(fmt.Sprintf("未找到待办 ID: %d", int64(id))), nil
}
p.saveTodos()
return map[string]interface{}{
"content": fmt.Sprintf("待办 %d 已删除", int64(id)),
}, nil
}
// ── 备忘工具 ──
func (p *Plugin) handleMemoCreate(args map[string]interface{}) (interface{}, error) {
content, _ := args["content"].(string) content, _ := args["content"].(string)
if content == "" { if content == "" {
return errorResult("content is required"), nil return errorResult("content is required"), nil
@ -196,23 +412,22 @@ func (p *Plugin) handleCreate(args map[string]interface{}) (interface{}, error)
p.mu.Lock() p.mu.Lock()
memo := Memo{ memo := Memo{
ID: p.nextID, ID: p.nextMID,
Content: content, Content: content,
CreatedAt: time.Now().Unix(), CreatedAt: time.Now().Unix(),
Done: false,
} }
p.nextID++ p.nextMID++
p.memos = append(p.memos, memo) p.memos = append(p.memos, memo)
p.mu.Unlock() p.mu.Unlock()
p.save() p.saveMemos()
return map[string]interface{}{ return map[string]interface{}{
"content": fmt.Sprintf("备忘已创建 (ID: %d)", memo.ID), "content": fmt.Sprintf("备忘已创建 (ID: %d)", memo.ID),
"id": memo.ID, "id": memo.ID,
}, nil }, nil
} }
func (p *Plugin) handleComplete(args map[string]interface{}) (interface{}, error) { func (p *Plugin) handleMemoDelete(args map[string]interface{}) (interface{}, error) {
id, ok := args["id"].(float64) id, ok := args["id"].(float64)
if !ok { if !ok {
return errorResult("id is required"), nil return errorResult("id is required"), nil
@ -221,8 +436,8 @@ func (p *Plugin) handleComplete(args map[string]interface{}) (interface{}, error
p.mu.Lock() p.mu.Lock()
found := false found := false
for i := range p.memos { for i := range p.memos {
if p.memos[i].ID == int64(id) && !p.memos[i].Done { if p.memos[i].ID == int64(id) {
p.memos[i].Done = true p.memos = append(p.memos[:i], p.memos[i+1:]...)
found = true found = true
break break
} }
@ -230,30 +445,33 @@ func (p *Plugin) handleComplete(args map[string]interface{}) (interface{}, error
p.mu.Unlock() p.mu.Unlock()
if !found { if !found {
return errorResult(fmt.Sprintf("未找到未完成的备忘 ID: %d", int64(id))), nil return errorResult(fmt.Sprintf("未找到备忘 ID: %d", int64(id))), nil
} }
p.save() p.saveMemos()
return map[string]interface{}{ return map[string]interface{}{
"content": fmt.Sprintf("备忘 %d 已标记为完成", int64(id)), "content": fmt.Sprintf("备忘 %d 已删除", int64(id)),
}, nil }, nil
} }
func (p *Plugin) handleList(args map[string]interface{}) (interface{}, error) { func (p *Plugin) handleMemoList(args map[string]interface{}) (interface{}, error) {
memos := p.pendingMemos() p.mu.RLock()
memos := append([]Memo{}, p.memos...)
p.mu.RUnlock()
if len(memos) == 0 { if len(memos) == 0 {
return map[string]interface{}{ return map[string]interface{}{
"content": "暂无未完成的备忘", "content": "暂无备忘",
}, nil }, nil
} }
var sb strings.Builder var sb strings.Builder
for i, m := range memos { for i, m := range memos {
t := time.Unix(m.CreatedAt, 0).Format("01-02 15:04") ts := time.Unix(m.CreatedAt, 0).Format("01-02 15:04")
if i > 0 { if i > 0 {
sb.WriteString("\n") sb.WriteString("\n")
} }
sb.WriteString(fmt.Sprintf("%d. [ID:%d] %s — %s", i+1, m.ID, m.Content, t)) sb.WriteString(fmt.Sprintf("%d. [ID:%d] %s — %s", i+1, m.ID, m.Content, ts))
} }
return map[string]interface{}{ return map[string]interface{}{
@ -270,5 +488,15 @@ func errorResult(msg string) map[string]interface{} {
} }
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) { func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name}, nil return &Plugin{name: name, stopCh: make(chan struct{})}, nil
}
// cleanupData 卸载时清理数据文件(待办 + 备忘)
func (p *Plugin) cleanupData() {
if p.todoPath != "" {
os.Remove(p.todoPath)
}
if p.memoPath != "" {
os.Remove(p.memoPath)
}
} }

View File

@ -4,4 +4,4 @@ go 1.25.0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0 require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

View File

@ -1,4 +1,4 @@
{ {
"name": "music", "name": "music",
"name_zh": "音乐搜索", "name_zh": "音乐搜索",
"name_en": "Music Search", "name_en": "Music Search",

View File

@ -4,4 +4,4 @@ go 1.25.0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0 require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

View File

@ -1,4 +1,4 @@
{ {
"name": "ocr", "name": "ocr",
"name_zh": "OCR 文字识别", "name_zh": "OCR 文字识别",
"name_en": "OCR Text Recognition", "name_en": "OCR Text Recognition",

View File

@ -4,4 +4,4 @@ go 1.25.0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0 require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

View File

@ -1,4 +1,4 @@
{ {
"name": "qq", "name": "qq",
"name_zh": "QQ消息", "name_zh": "QQ消息",
"name_en": "qq", "name_en": "qq",
@ -9,7 +9,7 @@
"tags": ["qq", "messaging"], "tags": ["qq", "messaging"],
"targets": "linux/amd64", "targets": "linux/amd64",
"outdir": "dist", "outdir": "dist",
"bundle": true, "bundle": false,
"replaces": {}, "replaces": {},
"source_dirs": [] "source_dirs": []
} }

View File

@ -3,12 +3,14 @@ package main
import ( import (
"bytes" "bytes"
"context" "context"
"crypto/hmac"
"encoding/base64" "encoding/base64"
"encoding/binary" "encoding/binary"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log" "log"
"math"
"net" "net"
"net/http" "net/http"
"os" "os"
@ -32,7 +34,7 @@ type ForwardRule struct {
} }
func rconSend(host string, port int, password, cmd string) error { func rconSend(host string, port int, password, cmd string) error {
addr := fmt.Sprintf("%s:%d", host, port) addr := net.JoinHostPort(host, strconv.Itoa(port))
conn, err := net.DialTimeout("tcp", addr, 5*time.Second) conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
if err != nil { if err != nil {
return fmt.Errorf("rcon dial: %w", err) return fmt.Errorf("rcon dial: %w", err)
@ -66,7 +68,7 @@ func rconPacket(id, typ int32, body string) []byte {
b = append(b, 0) // null terminator b = append(b, 0) // null terminator
b = append(b, 0) // padding b = append(b, 0) // padding
length := 4 + 4 + len(b) length := 4 + 4 + len(b)
pkt := make([]byte, 4+len(b)) pkt := make([]byte, 12+len(b))
binary.LittleEndian.PutUint32(pkt, uint32(length)) binary.LittleEndian.PutUint32(pkt, uint32(length))
binary.LittleEndian.PutUint32(pkt[4:], uint32(id)) binary.LittleEndian.PutUint32(pkt[4:], uint32(id))
binary.LittleEndian.PutUint32(pkt[8:], uint32(typ)) binary.LittleEndian.PutUint32(pkt[8:], uint32(typ))
@ -90,7 +92,8 @@ type Plugin struct {
napcatURL string napcatURL string
remoteDir string remoteDir string
filesDir string filesDir string
adminID int64 webhookToken string
adminIDs []int64
botID int64 botID int64
botNickname string botNickname string
dmPolicy string dmPolicy string
@ -102,6 +105,13 @@ type Plugin struct {
agentfsDir string agentfsDir string
downloadMu sync.Mutex downloadMu sync.Mutex
downloadTasks []*DownloadTask downloadTasks []*DownloadTask
typingMu sync.Mutex
typingMap map[int64]*typingState
}
type typingState struct {
userID int64
stopCh chan struct{}
} }
func (p *Plugin) Name() string { return p.name } func (p *Plugin) Name() string { return p.name }
@ -112,7 +122,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.Settings().RegisterDef(sdk.ConfigDef{Key: "listen", Default: "0.0.0.0:25580", Type: "string", DisplayName: "监听地址", Description: "Webhook HTTP 监听地址", Category: "qq"}) s.Settings().RegisterDef(sdk.ConfigDef{Key: "listen", Default: "0.0.0.0:25580", Type: "string", DisplayName: "监听地址", Description: "Webhook HTTP 监听地址", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "napcat_url", Default: "http://127.0.0.1:3000", Type: "string", DisplayName: "NapCat 地址", Description: "NapCat HTTP API 基础 URL", Category: "qq"}) s.Settings().RegisterDef(sdk.ConfigDef{Key: "napcat_url", Default: "http://127.0.0.1:3000", Type: "string", DisplayName: "NapCat 地址", Description: "NapCat HTTP API 基础 URL", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "admin", Default: "", Type: "string", DisplayName: "管理员 QQ", Description: "管理员 QQ 号收到其消息时标记【重要!老大消息】", Category: "qq"}) s.Settings().RegisterDef(sdk.ConfigDef{Key: "admin", Default: "", Type: "string", DisplayName: "管理员 QQ", Description: "管理员 QQ 号列表,逗号分隔。收到其消息时标记【重要!老大消息】", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "dm_policy", Default: "open", Type: "string", DisplayName: "私聊策略", Description: "open / allowlist / disabled", Category: "qq", Options: []string{"open", "allowlist", "disabled"}}) s.Settings().RegisterDef(sdk.ConfigDef{Key: "dm_policy", Default: "open", Type: "string", DisplayName: "私聊策略", Description: "open / allowlist / disabled", Category: "qq", Options: []string{"open", "allowlist", "disabled"}})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "allow_from", Default: "", Type: "string", DisplayName: "私聊白名单", Description: "允许私聊机器人的 QQ 号列表,逗号分隔", Category: "qq"}) s.Settings().RegisterDef(sdk.ConfigDef{Key: "allow_from", Default: "", Type: "string", DisplayName: "私聊白名单", Description: "允许私聊机器人的 QQ 号列表,逗号分隔", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "group_policy", Default: "open", Type: "string", DisplayName: "群聊策略", Description: "open / allowlist / disabled", Category: "qq", Options: []string{"open", "allowlist", "disabled"}}) s.Settings().RegisterDef(sdk.ConfigDef{Key: "group_policy", Default: "open", Type: "string", DisplayName: "群聊策略", Description: "open / allowlist / disabled", Category: "qq", Options: []string{"open", "allowlist", "disabled"}})
@ -120,13 +130,15 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.Settings().RegisterDef(sdk.ConfigDef{Key: "forward_rules", Default: "[]", Type: "string", DisplayName: "转发规则", Description: "JSON 数组,每项 {group_id,host,port,password,template}。匹配的群消息通过 RCON 转发到 Minecraft。template 支持 {nickname} {message} 占位", Category: "qq"}) s.Settings().RegisterDef(sdk.ConfigDef{Key: "forward_rules", Default: "[]", Type: "string", DisplayName: "转发规则", Description: "JSON 数组,每项 {group_id,host,port,password,template}。匹配的群消息通过 RCON 转发到 Minecraft。template 支持 {nickname} {message} 占位", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "files_dir", Default: "/home/newqqagent/agentfs/merged/qq_files", Type: "string", DisplayName: "文件存储目录", Description: "从QQ接收的文件保存目录CQ file/image 自动下载到此目录)", Category: "qq"}) s.Settings().RegisterDef(sdk.ConfigDef{Key: "files_dir", Default: "/home/newqqagent/agentfs/merged/qq_files", Type: "string", DisplayName: "文件存储目录", Description: "从QQ接收的文件保存目录CQ file/image 自动下载到此目录)", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "remote_dir", Default: "/home/program/qq-workspace/remote", Type: "string", DisplayName: "NapCat容器共享目录", Description: "与NapCat容器共享的文件目录主机路径。发文件时文件会复制到此目录NapCat内部映射为/app/files/", Category: "qq"}) s.Settings().RegisterDef(sdk.ConfigDef{Key: "remote_dir", Default: "/home/program/qq-workspace/remote", Type: "string", DisplayName: "NapCat容器共享目录", Description: "与NapCat容器共享的文件目录主机路径。发文件时文件会复制到此目录NapCat内部映射为/app/files/", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "webhook_token", Default: "", Type: "string", DisplayName: "Webhook 令牌", Description: "NapCat 上报请求头 X-Webhook-Token 校验值,留空则不校验", Category: "qq"})
s.Settings().RegisterDef(sdk.ConfigDef{Key: "agentfs_dir", Default: "/home/newqqagent/agentfs/merged", Type: "string", DisplayName: "AgentFS目录", Description: "文件读写的工作目录read_document/video_download 等工具的默认工作目录", Category: "qq"}) s.Settings().RegisterDef(sdk.ConfigDef{Key: "agentfs_dir", Default: "/home/newqqagent/agentfs/merged", Type: "string", DisplayName: "AgentFS目录", Description: "文件读写的工作目录read_document/video_download 等工具的默认工作目录", Category: "qq"})
settings := s.Settings() settings := s.Settings()
p.listenAddr = getSetting[string](settings, "listen", "0.0.0.0:25580") p.listenAddr = getSetting[string](settings, "listen", "0.0.0.0:25580")
p.webhookToken = getSetting[string](settings, "webhook_token", "")
p.napcatURL = strings.TrimRight(getSetting[string](settings, "napcat_url", "http://127.0.0.1:3000"), "/") p.napcatURL = strings.TrimRight(getSetting[string](settings, "napcat_url", "http://127.0.0.1:3000"), "/")
p.adminID = getSetting[int64](settings, "admin", 0) p.adminIDs = parseIDList(getSetting[string](settings, "admin", ""))
p.dmPolicy = normalizePolicy(getSetting[string](settings, "dm_policy", "open")) p.dmPolicy = normalizePolicy(getSetting[string](settings, "dm_policy", "open"))
p.groupPolicy = normalizePolicy(getSetting[string](settings, "group_policy", "open")) p.groupPolicy = normalizePolicy(getSetting[string](settings, "group_policy", "open"))
p.allowFrom = parseIDSet(getSetting[string](settings, "allow_from", "")) p.allowFrom = parseIDSet(getSetting[string](settings, "allow_from", ""))
@ -165,7 +177,28 @@ meta JSON 格式:
"reply_to": 12345 // 可选,回复指定消息 ID "reply_to": 12345 // 可选,回复指定消息 ID
} }
type 枚举: text文字/ voice语音转文字后发送/ image图片URL/ file文件URL`, type 枚举: text文字/ voice语音转文字后发送/ image图片URL/ file文件URL`,
p.handleChannelOutput) sdk.ChannelDef{}, p.handleChannelOutput)
// ---- 注册输入通道(记忆计算层行为) ----
inputCleaner := func(text string) string {
// 从中断模板中提取语义内容:消息来源和发送者昵称
// 模板: 来自「昵称」的私聊/群聊消息(message_id=N)...
// 模板: 【重要!老大消息】来自「昵称」...
cleaned := text
// 去掉模板前缀
if strings.HasPrefix(cleaned, "【重要!老大消息】") {
cleaned = strings.TrimPrefix(cleaned, "【重要!老大消息】")
}
// 提取 "来自「XXX」" 中的昵称作为关键词
if start := strings.Index(cleaned, "来自「"); start >= 0 {
if end := strings.Index(cleaned[start:], "」"); end >= 0 {
nick := cleaned[start+len("来自「") : start+end]
cleaned = nick
}
}
return cleaned
}
s.RegisterInputChannel("qq", sdk.ChannelDef{NoMemory: true, Cleaner: inputCleaner})
// 查询类工具输出清洗器:提取 JSON 中的 content/文本字段参与向量化 // 查询类工具输出清洗器:提取 JSON 中的 content/文本字段参与向量化
cleaner := func(output string) string { cleaner := func(output string) string {
@ -251,7 +284,7 @@ type 枚举: text文字/ voice语音转文字后发送/ image
NoMemory: false, NoMemory: false,
Parameters: map[string]interface{}{ Parameters: map[string]interface{}{
"type": "object", "properties": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{
"user_id": map[string]interface{}{"type": "integer", "description": "QQ号与group_id二选一"}, "user_id": map[string]interface{}{"type": "integer", "description": "QQ号与group_id二选一"},
"group_id": map[string]interface{}{"type": "integer", "description": "群号与user_id二选一"}, "group_id": map[string]interface{}{"type": "integer", "description": "群号与user_id二选一"},
}, },
}, },
@ -284,19 +317,19 @@ type 枚举: text文字/ voice语音转文字后发送/ image
Name: tp + "group_manage", Description: "QQ群综合管理。通过command参数执行各种操作leave退群, kick踢人, ban禁言, unban解禁, rename改名, mute-all全员禁言, set-card设名片, set-admin设管理, set-title设头衔, member-list成员列表, group-info群详情, member-info成员详情, at-all-remain@全体剩余, msg-history消息历史, recall撤回, pin-msg精华, list-files文件列表, pending-requests待处理请求, folder-create创建文件夹。注意leave/kick/ban/unban/mute-all/set-admin等破坏性操作必须先请示管理员确认后再执行。", Name: tp + "group_manage", Description: "QQ群综合管理。通过command参数执行各种操作leave退群, kick踢人, ban禁言, unban解禁, rename改名, mute-all全员禁言, set-card设名片, set-admin设管理, set-title设头衔, member-list成员列表, group-info群详情, member-info成员详情, at-all-remain@全体剩余, msg-history消息历史, recall撤回, pin-msg精华, list-files文件列表, pending-requests待处理请求, folder-create创建文件夹。注意leave/kick/ban/unban/mute-all/set-admin等破坏性操作必须先请示管理员确认后再执行。",
Parameters: map[string]interface{}{ Parameters: map[string]interface{}{
"type": "object", "properties": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{
"command": map[string]interface{}{"type": "string", "description": "操作命令"}, "command": map[string]interface{}{"type": "string", "description": "操作命令"},
"group_id": map[string]interface{}{"type": "integer", "description": "群号"}, "group_id": map[string]interface{}{"type": "integer", "description": "群号"},
"user_id": map[string]interface{}{"type": "integer", "description": "QQ号踢人/禁言/设名片等需要)"}, "user_id": map[string]interface{}{"type": "integer", "description": "QQ号踢人/禁言/设名片等需要)"},
"message_id": map[string]interface{}{"type": "integer", "description": "消息ID撤回/精华)"}, "message_id": map[string]interface{}{"type": "integer", "description": "消息ID撤回/精华)"},
"name": map[string]interface{}{"type": "string", "description": "群名称rename或文件夹名folder-create"}, "name": map[string]interface{}{"type": "string", "description": "群名称rename或文件夹名folder-create"},
"card": map[string]interface{}{"type": "string", "description": "群名片set-card"}, "card": map[string]interface{}{"type": "string", "description": "群名片set-card"},
"title": map[string]interface{}{"type": "string", "description": "群头衔set-title"}, "title": map[string]interface{}{"type": "string", "description": "群头衔set-title"},
"enable": map[string]interface{}{"type": "boolean", "description": "启用/禁用set-admin/mute-all"}, "enable": map[string]interface{}{"type": "boolean", "description": "启用/禁用set-admin/mute-all"},
"minutes": map[string]interface{}{"type": "integer", "description": "禁言分钟数ban0=解禁"}, "minutes": map[string]interface{}{"type": "integer", "description": "禁言分钟数ban0=解禁"},
"count": map[string]interface{}{"type": "integer", "description": "消息条数msg-history默认10"}, "count": map[string]interface{}{"type": "integer", "description": "消息条数msg-history默认10"},
"folder_id": map[string]interface{}{"type": "string", "description": "文件夹IDlist-files"}, "folder_id": map[string]interface{}{"type": "string", "description": "文件夹IDlist-files"},
"reject_add": map[string]interface{}{"type": "boolean", "description": "踢出时拒绝加群kick"}, "reject_add": map[string]interface{}{"type": "boolean", "description": "踢出时拒绝加群kick"},
"confirm": map[string]interface{}{"type": "boolean", "description": "高风险操作确认标记。执行 leave/kick/ban/unban/rename/mute-all/set-card/set-admin/set-title/recall/pin-msg/folder-create 时必须传 true"}, "confirm": map[string]interface{}{"type": "boolean", "description": "高风险操作确认标记。执行 leave/kick/ban/unban/rename/mute-all/set-card/set-admin/set-title/recall/pin-msg/folder-create 时必须传 true"},
}, },
}, },
NoMemory: true, NoMemory: true,
@ -306,12 +339,12 @@ type 枚举: text文字/ voice语音转文字后发送/ image
Name: tp + "friend_action", Description: "QQ好友管理delete删除好友, block拉黑删好友+从所有群踢出+拒绝加群), approve-friend同意好友请求, reject-friend拒绝好友请求, list-friends列出好友。注意涉及删除/拉黑的操作必须请示管理员确认后再执行,未经授权不可操作。", Name: tp + "friend_action", Description: "QQ好友管理delete删除好友, block拉黑删好友+从所有群踢出+拒绝加群), approve-friend同意好友请求, reject-friend拒绝好友请求, list-friends列出好友。注意涉及删除/拉黑的操作必须请示管理员确认后再执行,未经授权不可操作。",
Parameters: map[string]interface{}{ Parameters: map[string]interface{}{
"type": "object", "properties": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{
"command": map[string]interface{}{"type": "string", "description": "操作: delete|block|approve-friend|reject-friend|list-friends"}, "command": map[string]interface{}{"type": "string", "description": "操作: delete|block|approve-friend|reject-friend|list-friends"},
"user_id": map[string]interface{}{"type": "integer", "description": "目标QQ号"}, "user_id": map[string]interface{}{"type": "integer", "description": "目标QQ号"},
"flag": map[string]interface{}{"type": "string", "description": "好友请求flagapprove-friend/reject-friend需要"}, "flag": map[string]interface{}{"type": "string", "description": "好友请求flagapprove-friend/reject-friend需要"},
"remark": map[string]interface{}{"type": "string", "description": "好友备注approve-friend可选"}, "remark": map[string]interface{}{"type": "string", "description": "好友备注approve-friend可选"},
"group_id": map[string]interface{}{"type": "integer", "description": "仅从指定群踢出block配合"}, "group_id": map[string]interface{}{"type": "integer", "description": "仅从指定群踢出block配合"},
"confirm": map[string]interface{}{"type": "boolean", "description": "高风险操作确认标记。执行 delete/block/approve-friend/reject-friend 时必须传 true"}, "confirm": map[string]interface{}{"type": "boolean", "description": "高风险操作确认标记。执行 delete/block/approve-friend/reject-friend 时必须传 true"},
}, },
}, },
NoMemory: true, NoMemory: true,
@ -324,12 +357,12 @@ type 枚举: text文字/ voice语音转文字后发送/ image
Cleaner: cleaner, Cleaner: cleaner,
Parameters: map[string]interface{}{ Parameters: map[string]interface{}{
"type": "object", "properties": map[string]interface{}{ "type": "object", "properties": map[string]interface{}{
"group_id": map[string]interface{}{"type": "integer", "description": "群号"}, "group_id": map[string]interface{}{"type": "integer", "description": "群号"},
"command": map[string]interface{}{"type": "string", "description": "操作: list|search|download"}, "command": map[string]interface{}{"type": "string", "description": "操作: list|search|download"},
"folder_id": map[string]interface{}{"type": "string", "description": "文件夹IDlist指定文件夹"}, "folder_id": map[string]interface{}{"type": "string", "description": "文件夹IDlist指定文件夹"},
"keyword": map[string]interface{}{"type": "string", "description": "搜索关键词search"}, "keyword": map[string]interface{}{"type": "string", "description": "搜索关键词search"},
"file_id": map[string]interface{}{"type": "string", "description": "文件IDdownload"}, "file_id": map[string]interface{}{"type": "string", "description": "文件IDdownload"},
"filename": map[string]interface{}{"type": "string", "description": "保存文件名download可选"}, "filename": map[string]interface{}{"type": "string", "description": "保存文件名download可选"},
}, },
}, },
}, p.handleGetGroupFiles) }, p.handleGetGroupFiles)
@ -426,6 +459,15 @@ type 枚举: text文字/ voice语音转文字后发送/ image
} }
func (p *Plugin) Stop() error { func (p *Plugin) Stop() error {
p.typingMu.Lock()
for _, st := range p.typingMap {
select {
case <-st.stopCh:
default:
close(st.stopCh)
}
}
p.typingMu.Unlock()
if p.srv != nil { if p.srv != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
@ -447,8 +489,8 @@ func (p *Plugin) fetchBotInfo() {
return return
} }
var info struct { var info struct {
Status string `json:"status"` Status string `json:"status"`
Data *struct { Data *struct {
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
Nickname string `json:"nickname"` Nickname string `json:"nickname"`
} `json:"data"` } `json:"data"`
@ -535,6 +577,34 @@ func parseIDSet(raw string) map[int64]struct{} {
return out return out
} }
func parseIDList(raw string) []int64 {
var out []int64
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if n, err := strconv.ParseInt(part, 10, 64); err == nil && n > 0 {
out = append(out, n)
continue
}
// 兼容历史坏数据:科学计数法存库的值(如 2.198972886e+09
if f, err := strconv.ParseFloat(part, 64); err == nil && f > 0 && f == math.Trunc(f) {
out = append(out, int64(f))
}
}
return out
}
func (p *Plugin) isAdmin(userID int64) bool {
for _, id := range p.adminIDs {
if id == userID {
return true
}
}
return false
}
// isAtBot checks if the message contains an @-mention of the bot. // isAtBot checks if the message contains an @-mention of the bot.
func (p *Plugin) isAtBot(msg interface{}) bool { func (p *Plugin) isAtBot(msg interface{}) bool {
segments, ok := msg.([]interface{}) segments, ok := msg.([]interface{})
@ -588,7 +658,7 @@ func (p *Plugin) isGroupAllowed(groupID int64) bool {
case "allowlist": case "allowlist":
_, ok := p.groupAllowFrom[groupID] _, ok := p.groupAllowFrom[groupID]
return ok return ok
default: default:
return true return true
} }
} }
@ -600,11 +670,6 @@ func (p *Plugin) beforeOwnToolcall(ctx *sdk.StageContext) error {
return nil return nil
} }
tc := &ctx.ToolCalls[0] tc := &ctx.ToolCalls[0]
if tc.Name == p.name+"_send_file" || tc.Name == p.name+"_upload_group_file" {
if file, ok := tc.Arguments["file"].(string); ok {
tc.Arguments["file"] = p.sensitiveFilter(file)
}
}
if tc.Name == p.name+"_group_manage" { if tc.Name == p.name+"_group_manage" {
cmd, _ := tc.Arguments["command"].(string) cmd, _ := tc.Arguments["command"].(string)
if requiresConfirmGroupCommand(cmd) { if requiresConfirmGroupCommand(cmd) {
@ -651,6 +716,10 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
http.Error(w, "", http.StatusMethodNotAllowed) http.Error(w, "", http.StatusMethodNotAllowed)
return return
} }
if p.webhookToken != "" && !hmac.Equal([]byte(r.Header.Get("X-Webhook-Token")), []byte(p.webhookToken)) {
w.WriteHeader(http.StatusUnauthorized)
return
}
body, _ := io.ReadAll(r.Body) body, _ := io.ReadAll(r.Body)
var evt struct { var evt struct {
PostType string `json:"post_type"` PostType string `json:"post_type"`
@ -717,10 +786,22 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
} else { } else {
interrupt = fmt.Sprintf("来自「%s」的私聊消息(message_id=%d)。使用%sget_message(message_id=%d)获取消息正文。使用%s回复对方", nickname, evt.MessageID, tp, evt.MessageID, outputTool) interrupt = fmt.Sprintf("来自「%s」的私聊消息(message_id=%d)。使用%sget_message(message_id=%d)获取消息正文。使用%s回复对方", nickname, evt.MessageID, tp, evt.MessageID, outputTool)
} }
if p.adminID > 0 && evt.UserID == p.adminID { if p.isAdmin(evt.UserID) {
interrupt = "【重要!老大消息】" + interrupt interrupt = "【重要!老大消息】" + interrupt
} }
if text != "" {
text = stripCQRe.ReplaceAllString(text, "")
text = strings.TrimSpace(text)
}
if text == "" {
w.WriteHeader(http.StatusOK)
return
}
if highRiskRe.MatchString(text) {
interrupt = "【⚠️ 高危信息,谨慎处理】" + interrupt
}
if evt.MessageType == "group" && p.sdk != nil { if evt.MessageType == "group" && p.sdk != nil {
rulesRaw := getSetting[string](p.sdk.Settings(), "forward_rules", "[]") rulesRaw := getSetting[string](p.sdk.Settings(), "forward_rules", "[]")
var rules []ForwardRule var rules []ForwardRule
@ -738,6 +819,10 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
} }
} }
if evt.MessageType == "private" {
p.startTyping(evt.UserID)
}
if p.sdk != nil { if p.sdk != nil {
p.sdk.InjectInterruptText(p.name, p.name, interrupt) p.sdk.InjectInterruptText(p.name, p.name, interrupt)
} }
@ -911,7 +996,7 @@ func (p *Plugin) handleChannelOutput(args map[string]interface{}) (interface{},
payload, _ := args["payload"].(string) payload, _ := args["payload"].(string)
rawType, _ := args["type"].(string) rawType, _ := args["type"].(string)
meta, _ := args["meta"].(string) meta, _ := args["meta"].(string)
log.Printf("[qq] handleChannelOutput payload=%q type=%s meta=%s", payload, rawType, meta) log.Printf("[qq] handleChannelOutput type=%s payload_len=%d meta=%s", rawType, len(payload), meta)
if payload == "" || rawType == "" { if payload == "" || rawType == "" {
return nil, fmt.Errorf("payload 和 type 参数不能为空") return nil, fmt.Errorf("payload 和 type 参数不能为空")
} }
@ -935,6 +1020,10 @@ func (p *Plugin) handleChannelOutput(args map[string]interface{}) (interface{},
return nil, fmt.Errorf("meta 中需要 group_id 或 user_id 字段。用 output_send__qq_help 查看格式说明") return nil, fmt.Errorf("meta 中需要 group_id 或 user_id 字段。用 output_send__qq_help 查看格式说明")
} }
if userID > 0 {
p.stopTyping(userID)
}
switch rawType { switch rawType {
case "text": case "text":
text := p.sensitiveFilter(payload) text := p.sensitiveFilter(payload)
@ -1072,7 +1161,7 @@ func (p *Plugin) handleSendFile(args map[string]interface{}) (interface{}, error
if name == "" { if name == "" {
name = filepath.Base(filePath) name = filepath.Base(filePath)
} }
name = p.sensitiveFilter(name) name = sanitizeFilename(name)
asImage, _ := args["as_image"].(bool) asImage, _ := args["as_image"].(bool)
// copy to remote dir for NapCat container access // copy to remote dir for NapCat container access
@ -1276,7 +1365,7 @@ func (p *Plugin) handleResolveNickname(args map[string]interface{}) (interface{}
} }
keyword = strings.ToLower(keyword) keyword = strings.ToLower(keyword)
gid, groupErr := convInt64(args["group_id"]) gid, groupErr := convInt64(args["group_id"])
if groupErr == nil { if groupErr == nil {
v, err := p.napcat("get_group_member_list", map[string]interface{}{"group_id": gid}) v, err := p.napcat("get_group_member_list", map[string]interface{}{"group_id": gid})
if err != nil { if err != nil {
@ -1490,20 +1579,11 @@ func (p *Plugin) handleFriendAction(args map[string]interface{}) (interface{}, e
// delete friend // delete friend
p.napcat("delete_friend", map[string]interface{}{"user_id": uid}) p.napcat("delete_friend", map[string]interface{}{"user_id": uid})
// kick from groups // kick from groups
if gid, err := convInt64(args["group_id"]); err == nil { gid, err := convInt64(args["group_id"])
p.napcat("set_group_kick", map[string]interface{}{"group_id": gid, "user_id": uid, "reject_add_request": true}) if err != nil {
} else { return map[string]interface{}{"isError": true, "content": "block 必须提供 group_id插件不会自动遍历所有群踢人"}, nil
grps, _ := p.napcat("get_group_list", map[string]interface{}{})
if list, ok := grps.([]interface{}); ok {
for _, g := range list {
if m, ok := g.(map[string]interface{}); ok {
if gid, ok := m["group_id"].(float64); ok {
p.napcat("set_group_kick", map[string]interface{}{"group_id": int64(gid), "user_id": uid, "reject_add_request": true})
}
}
}
}
} }
p.napcat("set_group_kick", map[string]interface{}{"group_id": gid, "user_id": uid, "reject_add_request": true})
return `{"status":"ok","message":"blocked"}`, nil return `{"status":"ok","message":"blocked"}`, nil
case "approve-friend": case "approve-friend":
flag, _ := args["flag"].(string) flag, _ := args["flag"].(string)
@ -1538,6 +1618,7 @@ func (p *Plugin) handleGetGroupFiles(args map[string]interface{}) (interface{},
if filename == "" { if filename == "" {
filename = fmt.Sprintf("group_file_%s", fileID) filename = fmt.Sprintf("group_file_%s", fileID)
} }
filename = sanitizeFilename(filename)
// get download URL // get download URL
resp, err := p.napcat("get_group_file_url", map[string]interface{}{"group_id": gid, "file_id": fileID}) resp, err := p.napcat("get_group_file_url", map[string]interface{}{"group_id": gid, "file_id": fileID})
if err != nil { if err != nil {
@ -1665,7 +1746,7 @@ func (p *Plugin) handleUploadGroupFile(args map[string]interface{}) (interface{}
if name == "" { if name == "" {
name = filepath.Base(filePath) name = filepath.Base(filePath)
} }
name = p.sensitiveFilter(name) name = sanitizeFilename(name)
data, err := os.ReadFile(filePath) data, err := os.ReadFile(filePath)
if err != nil { if err != nil {
@ -1964,8 +2045,8 @@ func (p *Plugin) handleReadDocument(args map[string]interface{}) (interface{}, e
result += fmt.Sprintf("\n\n...(内容过长,仅显示前 20000 字符,共 %d 字符)", origLen) result += fmt.Sprintf("\n\n...(内容过长,仅显示前 20000 字符,共 %d 字符)", origLen)
} }
return map[string]interface{}{ return map[string]interface{}{
"content": result, "content": result,
"file": path, "file": path,
"truncated": truncated, "truncated": truncated,
}, nil }, nil
} }
@ -2116,6 +2197,54 @@ func (p *Plugin) napcat(action string, params map[string]interface{}) (interface
return raw, nil return raw, nil
} }
func (p *Plugin) setInputStatus(userID int64, eventType int) (interface{}, error) {
return p.napcat("set_input_status", map[string]interface{}{
"user_id": userID,
"event_type": eventType,
})
}
func (p *Plugin) startTyping(userID int64) {
p.typingMu.Lock()
if _, ok := p.typingMap[userID]; ok {
p.typingMu.Unlock()
return
}
ts := &typingState{userID: userID, stopCh: make(chan struct{})}
p.typingMap[userID] = ts
p.typingMu.Unlock()
go p.typingLoop(ts)
}
func (p *Plugin) stopTyping(userID int64) {
p.typingMu.Lock()
ts, ok := p.typingMap[userID]
if ok {
delete(p.typingMap, userID)
}
p.typingMu.Unlock()
if ok {
close(ts.stopCh)
}
}
func (p *Plugin) typingLoop(ts *typingState) {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
timeout := time.After(30 * time.Second)
for {
select {
case <-ticker.C:
p.setInputStatus(ts.userID, 1)
case <-ts.stopCh:
return
case <-timeout:
p.stopTyping(ts.userID)
return
}
}
}
// ======== Helpers ======== // ======== Helpers ========
// rawString extracts a string from napcat's return type (json.RawMessage or string). // rawString extracts a string from napcat's return type (json.RawMessage or string).
@ -2134,6 +2263,8 @@ func rawString(v interface{}) (string, bool) {
var reAPIKey = regexp.MustCompile(`(?i)(api[_-]?key|token|secret|password)\s*[=:]\s*\S+`) var reAPIKey = regexp.MustCompile(`(?i)(api[_-]?key|token|secret|password)\s*[=:]\s*\S+`)
var reSKKey = regexp.MustCompile(`sk-[a-zA-Z0-9]{20,}`) var reSKKey = regexp.MustCompile(`sk-[a-zA-Z0-9]{20,}`)
var reInternalIP = regexp.MustCompile(`\b(127\.\d{1,3}\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})\b`) var reInternalIP = regexp.MustCompile(`\b(127\.\d{1,3}\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})\b`)
var stripCQRe = regexp.MustCompile(`\[CQ:[^\]]*\]|\[mirai:[^\]]*\]`)
var highRiskRe = regexp.MustCompile(`(假如你是|你现在是|请你(扮演|化作|假装|成为)|扮演(一个|一下)|把你自己(想象|当成)|你的(人设|设定)是|穿越(到|回)|你是从.{0,10}(来|穿越)|帮我编(个|一个)故事|写(个|一个)故事让|故事(中|里)的|觉得(这个|这台|这家)?(机器人|AI|助手|ai).{0,8}(怎么样|如何|好不好|评价)|评价(下|一下)?(这个|这台|这家)?(机器人|AI|助手|ai|gpt)|忽略(之前|所有)?(指令|规则|限制|禁令)|解除.{0,6}(限制|规则|约束)|越狱|绕过.{0,6}(限制|审查)|不用(遵守|管)(任何)?(规则|限制|指令)|无视(所有)?(规则|指令)|你是(一个|一只)自由的)`)
func (p *Plugin) sensitiveFilter(text string) string { func (p *Plugin) sensitiveFilter(text string) string {
if p.remoteDir != "" { if p.remoteDir != "" {
@ -2171,13 +2302,8 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e
allowFrom: make(map[int64]struct{}), allowFrom: make(map[int64]struct{}),
groupAllowFrom: make(map[int64]struct{}), groupAllowFrom: make(map[int64]struct{}),
downloadTasks: make([]*DownloadTask, 0), downloadTasks: make([]*DownloadTask, 0),
typingMap: make(map[int64]*typingState),
dmPolicy: "open", dmPolicy: "open",
groupPolicy: "open", groupPolicy: "open",
}, nil }, nil
} }

View File

@ -10,8 +10,8 @@ require (
golang.org/x/text v0.38.0 golang.org/x/text v0.38.0
) )
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk replace gitcode.com/JianFeeeee/homeagent-sdk => ../../
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

View File

@ -1,4 +1,4 @@
{ {
"name": "rss", "name": "rss",
"name_zh": "RSS订阅", "name_zh": "RSS订阅",
"name_en": "RSS", "name_en": "RSS",

View File

@ -16,6 +16,8 @@ import (
"github.com/mmcdole/gofeed" "github.com/mmcdole/gofeed"
) )
const injectDedupWindow = 5 * time.Minute
type FeedSub struct { type FeedSub struct {
URL string `json:"url"` URL string `json:"url"`
Title string `json:"title"` Title string `json:"title"`
@ -32,7 +34,9 @@ type Plugin struct {
mu sync.RWMutex mu sync.RWMutex
feeds []FeedSub feeds []FeedSub
seenGUIDs map[string]bool seenGUIDs map[string]bool
injected map[string]time.Time
stopCh chan struct{} stopCh chan struct{}
stopOnce sync.Once
wg sync.WaitGroup wg sync.WaitGroup
pollTicker *time.Ticker pollTicker *time.Ticker
} }
@ -103,16 +107,22 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.fp = gofeed.NewParser() p.fp = gofeed.NewParser()
p.stopCh = make(chan struct{}) p.stopCh = make(chan struct{})
p.seenGUIDs = make(map[string]bool) p.seenGUIDs = make(map[string]bool)
p.injected = make(map[string]time.Time)
p.feeds = []FeedSub{} p.feeds = []FeedSub{}
dataHome := os.Getenv("HOME") dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir")
if dataHome == "" { if err != nil || dataDirVal == "" {
dataHome = "/tmp" dataDirVal = "."
}
p.dataDir = filepath.Join(fmt.Sprint(dataDirVal), "rss")
if err := os.MkdirAll(p.dataDir, 0755); err != nil {
fmt.Printf("[%s] mkdir %s: %v\n", p.name, p.dataDir, err)
} }
p.dataDir = filepath.Join(dataHome, ".homeagent", "rss")
os.MkdirAll(p.dataDir, 0755)
p.loadData() p.loadData()
// 卸载(删除)时清理订阅数据目录;重载不触发
s.RegisterOnRemoveHandler(p.cleanupData)
s.Settings().RegisterDef(sdk.ConfigDef{ s.Settings().RegisterDef(sdk.ConfigDef{
Key: "poll_interval", Default: "30", Type: "string", Key: "poll_interval", Default: "30", Type: "string",
DisplayName: "Poll Interval", Description: "Default polling interval in minutes (default: 30)", DisplayName: "Poll Interval", Description: "Default polling interval in minutes (default: 30)",
@ -176,7 +186,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
} }
func (p *Plugin) Stop() error { func (p *Plugin) Stop() error {
close(p.stopCh) p.stopOnce.Do(func() { close(p.stopCh) })
p.pollTicker.Stop() p.pollTicker.Stop()
p.wg.Wait() p.wg.Wait()
p.saveData() p.saveData()
@ -248,9 +258,34 @@ func (p *Plugin) checkFeed(sub FeedSub) {
return return
} }
var lines []string now := time.Now()
lines = append(lines, fmt.Sprintf("📡 %s (%s) — %d 篇新文章:", title, sub.URL, len(newArticles))) toInject := make([]*gofeed.Item, 0, len(newArticles))
p.mu.Lock()
for _, item := range newArticles { for _, item := range newArticles {
guid := item.GUID
if guid == "" {
guid = item.Link
}
if guid == "" {
continue
}
key := sub.URL + "|" + guid
if t, ok := p.injected[key]; ok && now.Sub(t) < injectDedupWindow {
continue
}
p.injected[key] = now
p.seenGUIDs[key] = true
toInject = append(toInject, item)
}
p.mu.Unlock()
if len(toInject) == 0 {
return
}
var lines []string
lines = append(lines, fmt.Sprintf("📡 %s (%s) — %d 篇新文章:", title, sub.URL, len(toInject)))
for _, item := range toInject {
pubDate := "" pubDate := ""
if item.PublishedParsed != nil { if item.PublishedParsed != nil {
pubDate = item.PublishedParsed.Format("01-02 15:04") pubDate = item.PublishedParsed.Format("01-02 15:04")
@ -266,19 +301,6 @@ func (p *Plugin) checkFeed(sub FeedSub) {
} }
p.sdk.InjectInterruptText("rss", "rss", strings.Join(lines, "\n")) p.sdk.InjectInterruptText("rss", "rss", strings.Join(lines, "\n"))
p.mu.Lock()
for _, item := range newArticles {
guid := item.GUID
if guid == "" {
guid = item.Link
}
if guid == "" {
continue
}
p.seenGUIDs[sub.URL+"|"+guid] = true
}
p.mu.Unlock()
p.saveData() p.saveData()
} }
@ -320,6 +342,7 @@ func (p *Plugin) handleSubscribe(args map[string]interface{}) (interface{}, erro
} }
guidCount := 0 guidCount := 0
p.mu.Lock()
for _, item := range parsed.Items { for _, item := range parsed.Items {
guid := item.GUID guid := item.GUID
if guid == "" { if guid == "" {
@ -331,6 +354,7 @@ func (p *Plugin) handleSubscribe(args map[string]interface{}) (interface{}, erro
p.seenGUIDs[url+"|"+guid] = true p.seenGUIDs[url+"|"+guid] = true
guidCount++ guidCount++
} }
p.mu.Unlock()
p.mu.Lock() p.mu.Lock()
p.feeds = append(p.feeds, sub) p.feeds = append(p.feeds, sub)
@ -395,7 +419,16 @@ func (p *Plugin) handleList(args map[string]interface{}) (interface{}, error) {
} }
func (p *Plugin) handleCheckNow(args map[string]interface{}) (interface{}, error) { func (p *Plugin) handleCheckNow(args map[string]interface{}) (interface{}, error) {
go p.checkAllFeeds() select {
case <-p.stopCh:
return map[string]interface{}{"isError": true, "content": "plugin is stopping"}, nil
default:
}
p.wg.Add(1)
go func() {
defer p.wg.Done()
p.checkAllFeeds()
}()
return map[string]interface{}{"content": "Checking all feeds for updates..."}, nil return map[string]interface{}{"content": "Checking all feeds for updates..."}, nil
} }
@ -437,4 +470,19 @@ func (p *Plugin) saveData() {
os.WriteFile(p.dataFile(), b, 0644) os.WriteFile(p.dataFile(), b, 0644)
} }
// cleanupData 卸载时清理订阅数据目录feeds.json 等)
func (p *Plugin) cleanupData() {
p.mu.Lock()
defer p.mu.Unlock()
if p.dataDir == "" {
return
}
for _, f := range []string{"feeds.json"} {
path := filepath.Join(p.dataDir, f)
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
fmt.Printf("[%s] onRemove cleanup %s: %v\n", p.name, path, err)
}
}
}

View File

@ -4,4 +4,4 @@ go 1.25.0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0 require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

View File

@ -1,4 +1,4 @@
{ {
"name": "sanitizer", "name": "sanitizer",
"name_zh": "输出清洗", "name_zh": "输出清洗",
"name_en": "sanitizer", "name_en": "sanitizer",

View File

@ -1,5 +1,13 @@
// Package main 是一个外部插件示例(编译为 .so 通过 -buildmode=plugin // Package main 是一个外部插件示例(编译为 .so 通过 -buildmode=plugin
// 在 StagePostAction 阶段清洗 LLM 输出中的工具调用残留(思维泄漏)。 // 目标:在 Agent 全链路清洗文本,防止乱码(坏 UTF-8 / U+FFFD / ANSI 转义)污染上下文并被 LLM 复读,
// 同时保留原有"工具调用残留(思维泄漏)"清理。
//
// 挂载阶段:
// - StageOnInput : 清洗用户输入RawMessage
// - StageAfterToolcall : 清洗工具执行结果ToolResults坏字节不进 LLM 上下文
// - StagePostAction : 清洗 LLM 输出LLMText保留原有思维泄漏清理
//
// 依赖 ABI v2 的 stage 写回能力:插件对 StageContext 的修改会同步回内核。
// //
// 编译: // 编译:
// //
@ -13,21 +21,24 @@ import (
"log" "log"
"regexp" "regexp"
"strings" "strings"
"unicode/utf8"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk" "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
) )
var ( var (
toolCallTagRE = regexp.MustCompile(`(?s)<tool_call[^>]*>.*?</tool_call>`) toolCallTagRE = regexp.MustCompile(`(?s)<tool_call[^>]*>.*?</tool_call>`)
invokeTagRE = regexp.MustCompile(`(?s)<invoke[^>]*>.*?</invoke>`) invokeTagRE = regexp.MustCompile(`(?s)<invoke[^>]*>.*?</invoke>`)
toolTagRE = regexp.MustCompile(`(?s)<tool[^>]*>.*?</tool>`) toolTagRE = regexp.MustCompile(`(?s)<tool[^>]*>.*?</tool>`)
functionTagRE = regexp.MustCompile(`(?s)<function[^>]*>.*?</function>`) functionTagRE = regexp.MustCompile(`(?s)<function[^>]*>.*?</function>`)
toolCodeBlockRE = regexp.MustCompile("(?s)```(?:xml|json)?\\s*<tool_call[^>]*>.*?</tool_call>\\s*```") toolCodeBlockRE = regexp.MustCompile("(?s)```(?:xml|json)?\\s*<tool_call[^>]*>.*?</tool_call>\\s*```")
invokeCodeBlockRE = regexp.MustCompile("(?s)```(?:xml|json)?\\s*<invoke[^>]*>.*?</invoke>\\s*```") invokeCodeBlockRE = regexp.MustCompile("(?s)```(?:xml|json)?\\s*<invoke[^>]*>.*?</invoke>\\s*```")
toolCodeBlockRE2 = regexp.MustCompile("(?s)```(?:xml|json)?\\s*<tool[^>]*>.*?</tool>\\s*```") toolCodeBlockRE2 = regexp.MustCompile("(?s)```(?:xml|json)?\\s*<tool[^>]*>.*?</tool>\\s*```")
chineseMarkerRE = regexp.MustCompile(`(?s)【tool_call】.*?【/tool_call】`) chineseMarkerRE = regexp.MustCompile(`(?s)【tool_call】.*?【/tool_call】`)
multiNewlineRE = regexp.MustCompile(`\n{3,}`) multiNewlineRE = regexp.MustCompile(`\n{3,}`)
toolNameRE = regexp.MustCompile(`^(cmd_run|terminal_create|terminal_write|memory_|knowledge_|doc_|social_|output_send|output_set_channel|llm_|plgreload|spawn_child|child_result|describe_image|transcribe_audio|ocr_image|timer_set|plugin_install|plugin_remove|qq_|a2a_|mcp_|healthcheck|files_|web_)`) toolNameRE = regexp.MustCompile(`^(cmd_run|terminal_create|terminal_write|memory_|knowledge_|doc_|social_|output_set_channel|output_send|llm_|plgreload|spawn_child|child_result|describe_image|transcribe_audio|ocr_image|timer_set|plugin_install|plugin_remove|qq_|a2a_|mcp_|healthcheck|files_|web_)`)
placeholderRE = regexp.MustCompile(`(?i)\{\{\s*tool\s*[:][^}]*\}\}`)
atToolRE = regexp.MustCompile(`(?i)^@\s*tool\b`)
) )
type Plugin struct{} type Plugin struct{}
@ -36,10 +47,41 @@ func (p *Plugin) Name() string { return "sanitizer" }
func (p *Plugin) Start(s *sdk.PluginSDK) error { func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true) s.SetAutoRestart(true)
// 1) 输入清洗
s.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {
ctx.Lock()
before := ctx.RawMessage
ctx.RawMessage = cleanText(ctx.RawMessage)
if before != ctx.RawMessage {
log.Printf("[sanitizer] StageOnInput: cleaned %d bytes", len(before)-len(ctx.RawMessage))
}
ctx.Unlock()
return nil
})
// 2) 工具结果清洗(坏字节/ANSI 不得进 LLM 上下文)
s.RegisterStage(sdk.StageAfterToolcall, func(ctx *sdk.StageContext) error {
ctx.Lock()
defer ctx.Unlock()
for i, tr := range ctx.ToolResults {
if s, ok := tr.Result.(string); ok {
clean := cleanText(s)
if clean != s {
ctx.ToolResults[i].Result = clean
log.Printf("[sanitizer] StageAfterToolcall: tool=%s cleaned %d bytes", tr.Name, len(s)-len(clean))
}
}
}
return nil
})
// 3) LLM 输出清洗(保留原有思维泄漏清理 + 新增乱码清洗)
s.RegisterStage(sdk.StagePostAction, func(ctx *sdk.StageContext) error { s.RegisterStage(sdk.StagePostAction, func(ctx *sdk.StageContext) error {
ctx.Lock() ctx.Lock()
before := len(ctx.LLMText) before := len(ctx.LLMText)
ctx.LLMText = cleanToolCallLeakage(ctx.LLMText) ctx.LLMText = cleanToolCallLeakage(ctx.LLMText)
ctx.LLMText = cleanText(ctx.LLMText)
after := len(ctx.LLMText) after := len(ctx.LLMText)
ctx.Unlock() ctx.Unlock()
if before != after { if before != after {
@ -47,7 +89,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
} }
return nil return nil
}) })
log.Printf("[sanitizer] stage PostAction registered") log.Printf("[sanitizer] stage OnInput/AfterToolcall/PostAction registered")
return nil return nil
} }
@ -57,6 +99,7 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e
return &Plugin{}, nil return &Plugin{}, nil
} }
// cleanToolCallLeakage 清洗 LLM 输出中的工具调用残留(思维泄漏)。
func cleanToolCallLeakage(content string) string { func cleanToolCallLeakage(content string) string {
if content == "" { if content == "" {
return content return content
@ -83,8 +126,12 @@ func cleanToolCallLeakage(content string) string {
cleaned = append(cleaned, line) cleaned = append(cleaned, line)
continue continue
} }
if toolNameRE.MatchString(trimmed) { if placeholderRE.MatchString(trimmed) || atToolRE.MatchString(trimmed) {
if strings.Contains(trimmed, "(") || strings.Contains(trimmed, "\"") || strings.Contains(trimmed, ":") { continue
}
if m := toolNameRE.FindStringIndex(trimmed); m != nil {
rest := trimmed[m[1]:]
if strings.HasPrefix(rest, "(") && strings.Contains(rest, ")") {
continue continue
} }
} }
@ -100,3 +147,72 @@ func cleanToolCallLeakage(content string) string {
} }
return content return content
} }
// cleanText 清洗可能污染 LLM 上下文/输出的文本:
// 1. 剥离 ANSI 转义序列(\x1b[...m 等,源自终端输出)
// 2. 剔除无效 UTF-8 字节strings.ToValidUTF8 语义)与已解码的 U+FFFD 替换符,
// 避免模型复读坏字节/替换符造成乱码(把坏段落整体丢弃比留残字更干净)
func cleanText(s string) string {
if s == "" {
return s
}
// 先剥离 ANSI 转义ESC [ 参数 m / ESC ] 标题 / 其他 CSI 序列
if strings.ContainsRune(s, 0x1b) {
var sb strings.Builder
sb.Grow(len(s))
i := 0
for i < len(s) {
c := s[i]
if c == 0x1b {
// 跳过完整转义序列
j := i + 1
if j < len(s) {
switch s[j] {
case '[': // CSI: ESC [ <params> <letter>
j++
for j < len(s) && !(s[j] >= 0x40 && s[j] <= 0x7e) {
j++
}
if j < len(s) {
j++
}
i = j
continue
case ']': // OSC: ESC ] ... BEL / ST
i = j + 1
for i < len(s) && s[i] != 0x07 {
i++
}
i++ // skip BEL
continue
default: // 单字符转义ESC c ESC 7 等)
i = j + 1
continue
}
}
i++
continue
}
sb.WriteByte(c)
i++
}
s = sb.String()
}
// 剔除无效 UTF-8 与 U+FFFD 替换符
if !utf8.ValidString(s) {
s = strings.ToValidUTF8(s, "")
}
if strings.ContainsRune(s, utf8.RuneError) {
// 连 U+FFFD 也不留给模型复述
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
if r != utf8.RuneError {
b.WriteRune(r)
}
}
s = b.String()
}
return s
}

View File

@ -2,6 +2,31 @@ package main
import "testing" import "testing"
func TestCleanText(t *testing.T) {
tests := []struct {
name, input, want string
}{
{"empty", "", ""},
{"clean", "你好世界 hello", "你好世界 hello"},
{"invalid_utf8", "a\xff\xfe b", "a b"},
{"ufffd", "有乱码\ufffd字符", "有乱码字符"},
{"multiple_ufffd", "a\ufffd\ufffdb\ufffdc", "abc"},
{"ansi_color", "\x1b[31m红色\x1b[0m结束", "红色结束"},
{"ansi_cursor", "a\x1b[2K\r\nb", "a\r\nb"},
{"ansi_osc", "\x1b]0;title\x07文本", "文本"},
{"an_and_ufffd", "\x1b[31m\ufffd中文\x1b[0m", "中文"},
{"emoji_kept", "颜文字(・ω・´)和🍎", "颜文字(・ω・´)和🍎"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := cleanText(tt.input)
if got != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
})
}
}
func TestCleanToolCallLeakage(t *testing.T) { func TestCleanToolCallLeakage(t *testing.T) {
tests := []struct { tests := []struct {
name, input, want string name, input, want string
@ -28,4 +53,4 @@ func TestCleanToolCallLeakage(t *testing.T) {
} }
}) })
} }
} }

7
example/vanblog/go.mod Normal file
View File

@ -0,0 +1,7 @@
module vanblog-plugin
go 1.25.0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

11
example/vanblog/plg.json Normal file
View File

@ -0,0 +1,11 @@
{
"name": "vanblog",
"name_zh": "VanBlog 博客管理",
"name_en": "VanBlog",
"version": "1.0.0",
"description": "管理 VanBlog 开源博客系统:文章的增删改查、分类标签管理、草稿发布、备份导出等",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": ["blog", "vanblog", "cms"],
"targets": "linux/amd64"
}

1334
example/vanblog/plugin.go Normal file

File diff suppressed because it is too large Load Diff

View File

@ -4,5 +4,5 @@ go 1.25.0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0 require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace gitcode.com/JianFeeeee/homeagent-sdk => E:/program/homeagent/homeagentsdk replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

View File

@ -5,8 +5,6 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"os"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -44,12 +42,6 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
} }
} }
dataHome := os.Getenv("HOME")
if dataHome == "" {
dataHome = "/tmp"
}
os.MkdirAll(filepath.Join(dataHome, ".homeagent", "weather"), 0755)
tp := p.name + "_" tp := p.name + "_"
s.RegisterTool(tp+"current", sdk.ToolDef{ s.RegisterTool(tp+"current", sdk.ToolDef{
Name: tp + "current", Description: "Get current weather for a city", Name: tp + "current", Description: "Get current weather for a city",
@ -60,6 +52,17 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
"units": map[string]interface{}{"type": "string", "description": "Units: metric (celsius) or imperial (fahrenheit), default metric"}, "units": map[string]interface{}{"type": "string", "description": "Units: metric (celsius) or imperial (fahrenheit), default metric"},
}, },
}, },
// NoMemory: 外部实时数据对记忆计算无长期价值,跳过向量化/关键词提取
NoMemory: true,
// Cleaner: 工具输出参与记忆计算前先过滤;这里演示用法(保留摘要行)
Cleaner: func(output string) string {
for _, line := range strings.Split(output, "\n") {
if strings.HasPrefix(line, "🌤") {
return line
}
}
return output
},
}, p.handleCurrent) }, p.handleCurrent)
s.RegisterTool(tp+"forecast", sdk.ToolDef{ s.RegisterTool(tp+"forecast", sdk.ToolDef{
@ -72,6 +75,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
"units": map[string]interface{}{"type": "string", "description": "Units: metric or imperial, default metric"}, "units": map[string]interface{}{"type": "string", "description": "Units: metric or imperial, default metric"},
}, },
}, },
NoMemory: true,
}, p.handleForecast) }, p.handleForecast)
s.RegisterTool(tp+"set_location", sdk.ToolDef{ s.RegisterTool(tp+"set_location", sdk.ToolDef{
@ -83,8 +87,34 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
}, },
"required": []string{"location"}, "required": []string{"location"},
}, },
NoMemory: true,
}, p.handleSetLocation) }, p.handleSetLocation)
// 阶段钩子own_tools 作用域——仅在本插件的工具被调用时触发
s.RegisterStage(sdk.StageAfterToolcall, func(ctx *sdk.StageContext) error {
ctx.Lock()
defer ctx.Unlock()
if len(ctx.ToolResults) > 0 {
fmt.Printf("[%s] stage after_toolcall(own): %s\n", p.name, ctx.ToolResults[0].Name)
}
return nil
}, sdk.StageScopeOwnTools)
// 输出通道:把天气结果主动推给用户(如 QQ/WebUI 渠道)
if err := s.RegisterOutputChannel(tp+"weather_out", 0, "push weather to user", sdk.ChannelDef{
NoMemory: true,
}, func(args map[string]interface{}) (interface{}, error) {
payload, _ := args["payload"].(string)
return map[string]interface{}{"content": "weather pushed: " + payload}, nil
}); err != nil {
return err
}
// 输入通道接收天气订阅请求NoMemory: 通道输入不参与记忆计算)
if err := s.RegisterInputChannel(tp+"weather_in", sdk.ChannelDef{NoMemory: true}); err != nil {
return err
}
fmt.Printf("[%s] started\n", p.name) fmt.Printf("[%s] started\n", p.name)
return nil return nil
} }
@ -343,7 +373,11 @@ func (p *Plugin) handleForecast(args map[string]interface{}) (interface{}, error
sunset = day.Astronomy[0].Sunset sunset = day.Astronomy[0].Sunset
} }
line := fmt.Sprintf(" %s %s/%s — %s~%s%s %s", weekday, day.Date[5:], day.Date[8:], minT, maxT, unitStr, desc) datePart := ""
if len(day.Date) >= 8 {
datePart = day.Date[5:7] + "/" + day.Date[8:]
}
line := fmt.Sprintf(" %s %s — %s~%s%s %s", weekday, datePart, minT, maxT, unitStr, desc)
if precip != "" { if precip != "" {
line += precip line += precip
} }

2
go.mod
View File

@ -1,3 +1,3 @@
module gitcode.com/JianFeeeee/homeagent-sdk module gitcode.com/JianFeeeee/homeagent-sdk
go 1.25.0 go 1.21.0

View File

@ -6,7 +6,7 @@ package meta
var ( var (
// Version 是 HomeAgent SDK 版本号。 // Version 是 HomeAgent SDK 版本号。
// 通过 `-ldflags="-X gitcode.com/JianFeeeee/homeagent-sdk/meta.Version=vX.Y.Z"` 注入。 // 通过 `-ldflags="-X gitcode.com/JianFeeeee/homeagent-sdk/meta.Version=vX.Y.Z"` 注入。
Version = "0.7.2" Version = "0.9.0"
// Commit 是构建时的 Git commit hash。 // Commit 是构建时的 Git commit hash。
Commit = "unknown" Commit = "unknown"
@ -21,7 +21,7 @@ var (
CoreModule = "gitcode.com/JianFeeeee/HomeAgent" CoreModule = "gitcode.com/JianFeeeee/HomeAgent"
// CoreVersion 是此 SDK 所兼容的最低核心版本。 // CoreVersion 是此 SDK 所兼容的最低核心版本。
CoreVersion = "0.7.2" CoreVersion = "0.9.0"
) )
// FullVersion 返回完整的版本字符串。 // FullVersion 返回完整的版本字符串。
@ -30,11 +30,26 @@ func FullVersion() string {
} }
// ---- ABI 版本(与核心仓 internal/meta/meta.go 同步) ---- // ---- ABI 版本(与核心仓 internal/meta/meta.go 同步) ----
// 修改时需确保核心仓与 SDK 仓的值一致 // ABI 标识版本直接取内核版本号字符串semver与核心 Version 保持一致,不使用独立数字编码
// 协商层C 结构体 int version 字段)使用 CABINum由版本字符串派生的整数major*100 + minor
// 映射v0.8.x → CABINum=800v0.9.x → CABINum=900invoke_stage 写回)。
// 小版本patch演进不影响 ABICABINum 不变。version_min 保证旧 ABI 插件仍可加载。
var (
// ABIVersion 是 ABI 标识版本(字符串 semver与 SDK CoreVersion 对齐)。
ABIVersion = CoreVersion
// ABIVersionMin 是兼容的最低 ABI 标识版本。
ABIVersionMin = "0.8.0"
)
const ( const (
ABIVersion = 1 // CABINum 是 C 层协商用的整数版本major*100 + minor随 ABIVersion 派生。
ABIVersionMin = 1 CABINum = 900
// CABINumMin 是 C 层兼容的最低整数版本。
// 旧工具链v0.8 之前)写入的整数 version=1无写回能力但与新内核结构兼容
// 因此最小值保持 1 以兼容全部旧插件(新插件 900 匹配,旧插件 1/2 通过);
// 仅当未来内核 ABI 破坏兼容时才提高该值。
CABINumMin = 1
) )
// ---- Dispatch Method IDs与核心仓 internal/meta/meta.go 同步) ---- // ---- Dispatch Method IDs与核心仓 internal/meta/meta.go 同步) ----

View File

@ -35,6 +35,14 @@ const (
StageAfterOutput Stage = "after_output" StageAfterOutput Stage = "after_output"
) )
// ChannelDef 描述通道在记忆计算层的行为,与 ToolDef.NoMemory/Cleaner 语义一致。
// NoMemory: 此通道输入/输出不参与记忆计算(向量化/关键词提取/蒸馏),但原文保留在上下文中
// Cleaner: 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏/存档提取关键词时调用
type ChannelDef struct {
NoMemory bool
Cleaner func(string) string
}
// StageContext provides context for stage handlers. // StageContext provides context for stage handlers.
type StageContext struct { type StageContext struct {
mu sync.RWMutex mu sync.RWMutex
@ -103,6 +111,9 @@ type IOInjector interface {
InjectInterruptText(source, channel, text string) InjectInterruptText(source, channel, text string)
InjectText(source, channel, text string) InjectText(source, channel, text string)
InjectTextNoMemory(source, channel, text string) InjectTextNoMemory(source, channel, text string)
// InjectInputSync 注入输入事件并同步等待 agent 回复,返回回复文本(无回复时返回空串)。
// 用于通道消息的完整闭环:收到入站 → agent 处理 → 回复取回 → 送回通道。
InjectInputSync(source, channel, text string) string
} }
// EventType identifies the kind of system event. // EventType identifies the kind of system event.
@ -156,8 +167,11 @@ type StageRegistrar func(stage Stage, handler StageHandler)
// APIRegistrar registers a plugin API for external access. // APIRegistrar registers a plugin API for external access.
type APIRegistrar func(name string) error type APIRegistrar func(name string) error
// InputChannelRegistrar registers an input channel with its memory behavior.
type InputChannelRegistrar func(name string, def ChannelDef) error
// OutputChannelRegistrar registers an output channel that the output_send tool can use. // OutputChannelRegistrar registers an output channel that the output_send tool can use.
type OutputChannelRegistrar func(name string, caps int, desc string, handler ToolHandler) error type OutputChannelRegistrar func(name string, caps int, desc string, def ChannelDef, handler ToolHandler) error
// Output capability flags // Output capability flags
const ( const (
@ -176,6 +190,7 @@ type PluginSDK struct {
regStage StageRegistrar regStage StageRegistrar
regAPI APIRegistrar regAPI APIRegistrar
regOutput OutputChannelRegistrar regOutput OutputChannelRegistrar
regInput InputChannelRegistrar
io IOInjector io IOInjector
mem MemoryAPI mem MemoryAPI
textMem TextMemoryAPI textMem TextMemoryAPI
@ -187,6 +202,12 @@ type PluginSDK struct {
events EventSubscriber events EventSubscriber
autoRestart bool autoRestart bool
stopMu sync.Mutex
stopHandlers []func()
removeMu sync.Mutex
removeHandlers []func()
} }
// New creates a PluginSDK with the given dependencies. // New creates a PluginSDK with the given dependencies.
@ -289,10 +310,21 @@ func (s *PluginSDK) RegisterPluginAPI(name string) error {
// name: channel name (e.g. "qq", "webui") // name: channel name (e.g. "qq", "webui")
// caps: bitmask of supported output capabilities (CapText, CapFile, etc.) // caps: bitmask of supported output capabilities (CapText, CapFile, etc.)
// desc: description of the channel, expected meta format, and type enum // desc: description of the channel, expected meta format, and type enum
// def: 通道在记忆计算层的行为NoMemory/Cleaner
// handler: receives args map with keys: payload (string), type (string), meta (string|optional) // handler: receives args map with keys: payload (string), type (string), meta (string|optional)
func (s *PluginSDK) RegisterOutputChannel(name string, caps int, desc string, handler ToolHandler) error { func (s *PluginSDK) RegisterOutputChannel(name string, caps int, desc string, def ChannelDef, handler ToolHandler) error {
if s.regOutput != nil { if s.regOutput != nil {
return s.regOutput(name, caps, desc, handler) return s.regOutput(name, caps, desc, def, handler)
}
return nil
}
// RegisterInputChannel registers an input channel with its memory behavior.
// def.NoMemory: 此通道输入不参与记忆计算
// def.Cleaner: 计算层对输入文本清洗后(不改原文)再向量化/提关键词
func (s *PluginSDK) RegisterInputChannel(name string, def ChannelDef) error {
if s.regInput != nil {
return s.regInput(name, def)
} }
return nil return nil
} }
@ -300,6 +332,9 @@ func (s *PluginSDK) RegisterOutputChannel(name string, caps int, desc string, ha
// SetOutputChannelRegistrar sets the output channel registrar (called by the core at startup). // SetOutputChannelRegistrar sets the output channel registrar (called by the core at startup).
func (s *PluginSDK) SetOutputChannelRegistrar(r OutputChannelRegistrar) { s.regOutput = r } func (s *PluginSDK) SetOutputChannelRegistrar(r OutputChannelRegistrar) { s.regOutput = r }
// SetInputChannelRegistrar sets the input channel registrar (called by the core at startup).
func (s *PluginSDK) SetInputChannelRegistrar(r InputChannelRegistrar) { s.regInput = r }
// SetIOInjector sets the IO injector (called by the core at startup). // SetIOInjector sets the IO injector (called by the core at startup).
func (s *PluginSDK) SetIOInjector(io IOInjector) { s.io = io } func (s *PluginSDK) SetIOInjector(io IOInjector) { s.io = io }
@ -335,9 +370,69 @@ func (s *PluginSDK) InjectTextNoMemory(source, channel, text string) {
} }
} }
// InjectInputSync injects a text message and synchronously waits for the agent reply,
// returning the reply text (empty string if none). Replies must be dispatched back
// to the source channel by the caller.
func (s *PluginSDK) InjectInputSync(source, channel, text string) string {
if s.io == nil {
return ""
}
return s.io.InjectInputSync(source, channel, text)
}
// SetAutoRestart 设置插件是否允许内核自动重启(崩溃后自动重载)。 // SetAutoRestart 设置插件是否允许内核自动重启(崩溃后自动重载)。
// 默认 true。如果插件有无法恢复的状态如外部连接应设为 false。 // 默认 true。如果插件有无法恢复的状态如外部连接应设为 false。
func (s *PluginSDK) SetAutoRestart(enabled bool) { s.autoRestart = enabled } func (s *PluginSDK) SetAutoRestart(enabled bool) { s.autoRestart = enabled }
// AutoRestart 返回插件是否允许自动重启。 // AutoRestart 返回插件是否允许自动重启。
func (s *PluginSDK) AutoRestart() bool { return s.autoRestart } func (s *PluginSDK) AutoRestart() bool { return s.autoRestart }
// RegisterStopHandler 注册插件停止阶段的清理回调。
// 注册的 handler 会在插件 Stop() 之前按"后注册先执行"的顺序调用,
// 适用于释放资源、落盘状态、关闭子进程等停止时清理操作。
// 可注册多个;执行后清空(进程停止前只执行一次)。
func (s *PluginSDK) RegisterStopHandler(fn func()) {
if fn == nil {
return
}
s.stopMu.Lock()
s.stopHandlers = append(s.stopHandlers, fn)
s.stopMu.Unlock()
}
// RunStopHandlers 执行全部已注册的 stop handler后注册先执行执行后清空幂等
// 由内核(内置插件)或插件桥接层(外部插件 z_bridge 的 StopPlugin在调用插件 Stop() 前执行。
func (s *PluginSDK) RunStopHandlers() {
s.stopMu.Lock()
handlers := append([]func(){}, s.stopHandlers...)
s.stopHandlers = nil
s.stopMu.Unlock()
for i := len(handlers) - 1; i >= 0; i-- {
handlers[i]()
}
}
// RegisterOnRemoveHandler 注册插件被删除(卸载)时的清理回调。
// 注册的 handler 会在插件目录被移除前按"后注册先执行"的顺序调用,
// 适用于清理外部资源、删除配置表、下线状态等删除后处理。
// 可注册多个;执行后清空(一次删除只执行一次)。
func (s *PluginSDK) RegisterOnRemoveHandler(fn func()) {
if fn == nil {
return
}
s.removeMu.Lock()
s.removeHandlers = append(s.removeHandlers, fn)
s.removeMu.Unlock()
}
// RunOnRemoveHandlers 执行全部已注册的 onRemove handler后注册先执行执行后清空幂等
// 由内核在卸载插件registry.RemovePlugin时、插件 Stop() 之后执行。
func (s *PluginSDK) RunOnRemoveHandlers() {
s.removeMu.Lock()
handlers := append([]func(){}, s.removeHandlers...)
s.removeHandlers = nil
s.removeMu.Unlock()
for i := len(handlers) - 1; i >= 0; i-- {
handlers[i]()
}
}

View File

@ -66,7 +66,21 @@ func cmdBuild(args []string) {
} }
// Ensure go.mod exists with correct SDK path // Ensure go.mod exists with correct SDK path
ensureGoMod(plg, sdkPath) sdkModule := ensureGoMod(plg, sdkPath)
// First build: fetch the SDK module (generates go.sum with zip hash)
if sdkModule != "" {
if _, err := os.Stat("go.sum"); os.IsNotExist(err) {
dl := exec.Command("go", "mod", "download", sdkModule)
dl.Env = os.Environ()
dl.Stdout = os.Stdout
dl.Stderr = os.Stderr
fmt.Println(" downloading SDK module deps...")
if err := dl.Run(); err != nil {
fmt.Printf(" error: go mod download: %v\n", err)
}
}
}
// Merge plg.json replaces + CLI overrides // Merge plg.json replaces + CLI overrides
replaceSlice := plg.ReplacesToSlice() replaceSlice := plg.ReplacesToSlice()
@ -246,62 +260,77 @@ func resolveBuild(target string) (*buildConfig, string) {
// ensureGoMod 确保插件项目的 go.mod 包含 SDK 的 replace 指令。 // ensureGoMod 确保插件项目的 go.mod 包含 SDK 的 replace 指令。
// 如果 go.mod 不存在或已有正确 replace则跳过。 // 如果 go.mod 不存在或已有正确 replace则跳过。
func ensureGoMod(plg *PlgConfig, sdkPath string) { func ensureGoMod(plg *PlgConfig, sdkPath string) string {
if sdkPath == "" {
// 从 plugindev 自身推断 SDK 路径
self, err := os.Executable()
if err != nil {
return
}
cand := filepath.Dir(filepath.Dir(filepath.Dir(self)))
if _, err := os.Stat(filepath.Join(cand, "sdk", "plugin.go")); err != nil {
return
}
sdkPath = cand
}
gomodPath := "go.mod" gomodPath := "go.mod"
data, err := os.ReadFile(gomodPath) data, err := os.ReadFile(gomodPath)
if err != nil { if err != nil {
return // no go.mod, skip return "" // no go.mod, skip
} }
lines := strings.Split(string(data), "\n") lines := strings.Split(string(data), "\n")
var sdkModule string var sdkModule string
for _, line := range lines { for _, line := range lines {
line = strings.TrimSpace(line) line = strings.TrimSpace(line)
if strings.HasPrefix(line, "require ") || strings.HasPrefix(line, "require (") { if line == "" || strings.HasPrefix(line, "//") {
continue continue
} }
if strings.Contains(line, "homeagent-sdk/sdk") || strings.Contains(line, "homeagent-sdk") { var mod string
if strings.HasPrefix(line, "require ") {
parts := strings.Fields(line) parts := strings.Fields(line)
if len(parts) >= 1 && !strings.HasPrefix(parts[0], "//") && !strings.HasPrefix(parts[0], "replace") { if len(parts) >= 2 {
sdkModule = parts[0] mod = parts[1]
} }
} else if !strings.HasPrefix(line, "require") &&
!strings.HasPrefix(line, "module ") &&
!strings.HasPrefix(line, "go ") &&
!strings.HasPrefix(line, "replace ") {
// require 块内行(无前缀)或 import 行
parts := strings.Fields(line)
if len(parts) >= 1 {
mod = parts[0]
}
}
if mod != "" && strings.Contains(mod, "homeagent-sdk") {
sdkModule = mod
break
} }
} }
if sdkModule == "" { if sdkModule == "" {
return return ""
}
if sdkPath == "" {
// 仅显式配置plg.json sdk_path 或 --sdk-path才写入 replace
// 避免 go.mod 中出现本地绝对路径。
return sdkModule
} }
// 检查是否已有 replace 指令
absSDK, _ := filepath.Abs(sdkPath) absSDK, _ := filepath.Abs(sdkPath)
absSDK = strings.ReplaceAll(absSDK, "\\", "/") absSDK = strings.ReplaceAll(absSDK, "\\", "/")
// Remove any existing replace line for this module (even if path differs)
var keep []string
replaceLine := fmt.Sprintf("replace %s => %s", sdkModule, absSDK)
alreadyExists := false
for _, line := range lines { for _, line := range lines {
if strings.Contains(line, "replace") && strings.Contains(line, sdkModule) { if strings.HasPrefix(strings.TrimSpace(line), "replace ") &&
strings.Contains(line, sdkModule) {
parts := strings.Fields(line) parts := strings.Fields(line)
if len(parts) >= 3 && strings.ReplaceAll(parts[2], "\\", "/") == absSDK { if len(parts) >= 3 && strings.ReplaceAll(parts[2], "\\", "/") == absSDK {
return // 已存在且路径正确 alreadyExists = true
} }
continue // strip any existing replace for this module
} }
keep = append(keep, line)
} }
if alreadyExists {
// 追加 replace 指令 return sdkModule
replaceLine := fmt.Sprintf("replace %s => %s", sdkModule, absSDK) }
newData := string(data) + "\n" + replaceLine + "\n" keep = append(keep, replaceLine, "")
if err := os.WriteFile(gomodPath, []byte(newData), 0644); err != nil { if err := os.WriteFile(gomodPath, []byte(strings.Join(keep, "\n")), 0644); err != nil {
fmt.Printf(" warn: update go.mod replace: %v\n", err) fmt.Printf(" warn: update go.mod replace: %v\n", err)
} }
return sdkModule
} }
func resolveSDKPath(sdkPath string) string { func resolveSDKPath(sdkPath string) string {
@ -387,7 +416,7 @@ func buildTarget(plg *PlgConfig, target, outDir, sdkPath string) {
// Auto-generate C ABI bridge (all platforms use c-shared) // Auto-generate C ABI bridge (all platforms use c-shared)
bridgeCleanup := generateBridge(cfg.goos) bridgeCleanup := generateBridge(cfg.goos)
_ = bridgeCleanup // DISABLED cleanup for debug defer bridgeCleanup()
// Auto-link thirdpart/ contents + source_dirs + replace targets // Auto-link thirdpart/ contents + source_dirs + replace targets
thirdpartCleanup := linkThirdpart(plg, target) thirdpartCleanup := linkThirdpart(plg, target)
@ -566,33 +595,6 @@ func detectWindowsCC() string {
// stripIncludeGuard strips preprocessor guards and C++ comments from a C header, // stripIncludeGuard strips preprocessor guards and C++ comments from a C header,
// since these can confuse cgo's type resolution. // since these can confuse cgo's type resolution.
func stripIncludeGuard(header string) string {
lines := strings.Split(header, "\n")
var out []string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "#ifndef HOMEAGENT_CABI_H" || trimmed == "#define HOMEAGENT_CABI_H" {
continue
}
if trimmed == "#endif" || strings.HasPrefix(trimmed, "#endif") {
continue
}
if trimmed == "#ifdef __cplusplus" || trimmed == "extern \"C\" {" || trimmed == "}" {
continue
}
// Strip C++-style comments (cgo parser may not handle them in /* */ blocks)
if idx := strings.Index(line, "//"); idx >= 0 {
line = line[:idx]
}
cleaned := strings.TrimSpace(line)
if cleaned == "" {
continue
}
out = append(out, line)
}
return strings.Join(out, "\n")
}
// generateBridge generates the C ABI bridge files for non-Lua builds. // generateBridge generates the C ABI bridge files for non-Lua builds.
// Returns a cleanup function to remove generated files. // Returns a cleanup function to remove generated files.
func generateBridge(goos string) func() { func generateBridge(goos string) func() {
@ -662,7 +664,11 @@ func linkThirdpart(plg *PlgConfig, target string) func() {
dirs = append(dirs, "thirdpart") dirs = append(dirs, "thirdpart")
} }
dirs = append(dirs, plg.SourceDirs...) dirs = append(dirs, plg.SourceDirs...)
for _, to := range plg.Replaces { for _, r := range plg.ReplacesToSlice() {
_, to, found := strings.Cut(r, "=")
if !found {
continue
}
if abs, err := filepath.Abs(to); err == nil { if abs, err := filepath.Abs(to); err == nil {
if info, err := os.Stat(abs); err == nil && info.IsDir() { if info, err := os.Stat(abs); err == nil && info.IsDir() {
dirs = append(dirs, abs) dirs = append(dirs, abs)
@ -699,25 +705,21 @@ func linkThirdpart(plg *PlgConfig, target string) func() {
continue continue
} }
// Determine import path: for relative dirs under module, use module path prefix;
// for absolute paths, derive from replace or use package name
dirName := filepath.Base(d)
if !filepath.IsAbs(d) { if !filepath.IsAbs(d) {
importPath := modulePath + "/" + d importPath := modulePath + "/" + d
stubs = append(stubs, importPath) stubs = append(stubs, importPath)
} else { } else {
// External directory: use the replace "from" key if found, else use dir name // External directory: must be in replaces to get a valid import path
found := false for _, r := range plg.ReplacesToSlice() {
for from, to := range plg.Replaces { from, to, found := strings.Cut(r, "=")
if !found {
continue
}
if absTo, _ := filepath.Abs(to); absTo == d { if absTo, _ := filepath.Abs(to); absTo == d {
stubs = append(stubs, from) stubs = append(stubs, strings.TrimSpace(from))
found = true
break break
} }
} }
if !found && dirName != "" {
stubs = append(stubs, modulePath+"/"+dirName)
}
} }
} }

View File

@ -153,7 +153,7 @@ func debugLua(dir, sdkPath, luaPath string) {
} }
func debugGo(dir string, replaces []string) { func debugGo(dir string, replaces []string) {
debug, err := yaegi.NewGoPluginDebug(dir, replaces) debug, err := yaegi.NewYaegiDebugger(dir, replaces)
if err != nil { if err != nil {
fmt.Printf("error: %v\n", err) fmt.Printf("error: %v\n", err)
os.Exit(1) os.Exit(1)
@ -161,34 +161,15 @@ func debugGo(dir string, replaces []string) {
fmt.Printf("[debug] Plugin dir: %s\n", dir) fmt.Printf("[debug] Plugin dir: %s\n", dir)
// Clean any stale debug harness if err := debug.LoadPlugin(); err != nil {
debug.Cleanup() fmt.Printf("[debug] load plugin: %v\n", err)
os.Exit(1)
}
// Try Yaegi interpreter first (fast, no compilation) if err := debug.StartREPL(); err != nil {
if err := debug.DebugWithYaegi(); err != nil { fmt.Printf("[debug] repl error: %v\n", err)
// Fall back to go run with generated debug harness os.Exit(1)
// Apply third-party replace directives before go run
patcher := NewGoModPatcher(dir, replaces)
restore, pErr := patcher.Apply()
if pErr != nil {
fmt.Printf("[debug] warn: apply replaces: %v\n", pErr)
}
if _, genErr := debug.GenerateDebugMain(); genErr != nil {
restore()
fmt.Printf("[debug] generate fallback: %v\n", genErr)
os.Exit(1)
}
runErr := debug.DebugWithGoRun()
debug.Cleanup()
restore()
if runErr != nil {
fmt.Printf("[debug] go run failed: %v\n", runErr)
os.Exit(1)
}
} }
} }
var _ = strings.TrimSpace

View File

@ -61,7 +61,6 @@ type TemplateData struct {
GoVersion string GoVersion string
SDKModule string SDKModule string
SDKVersion string SDKVersion string
SDKReplace string
// C ABI // C ABI
CABIVersion int CABIVersion int
@ -114,23 +113,19 @@ func cmdInit(args []string) {
Targets: targets, Targets: targets,
}, },
IsLua: isLua, IsLua: isLua,
CABIVersion: meta.ABIVersion, CABIVersion: meta.CABINum,
CABIHeader: tmplCABIHeader, CABIHeader: tmplCABIHeader,
} }
// Detect SDK info for Go plugin go.mod // Detect SDK info for Go plugin go.mod.
// 生成的 go.mod 只 require SDK 线上模块版本,不写本地路径 replace
// 本地调试请用 `plugindev build --sdk-path <path>` 或手动加 replace。
if !isLua { if !isLua {
sdkMod, goVer, sdkPath, sdkVer := detectSDKInfo() sdkMod, goVer, _, sdkVer := detectSDKInfo()
sdkReplace := sdkPath
// Make replace path absolute and use forward slashes
if abs, err := filepath.Abs(sdkPath); err == nil {
sdkReplace = strings.ReplaceAll(abs, "\\", "/")
}
data.ModulePath = name data.ModulePath = name
data.GoVersion = goVer data.GoVersion = goVer
data.SDKModule = sdkMod data.SDKModule = sdkMod
data.SDKVersion = "v" + sdkVer data.SDKVersion = "v" + sdkVer
data.SDKReplace = sdkReplace
} }
if err := os.MkdirAll(dir, 0755); err != nil { if err := os.MkdirAll(dir, 0755); err != nil {

View File

@ -11,6 +11,7 @@ import (
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
"time"
) )
const sdkDirName = "plugindev/sdk" const sdkDirName = "plugindev/sdk"
@ -157,33 +158,6 @@ func cmdSDKInstall(version string) {
url := fmt.Sprintf(sdkDownloadURL, version, version) url := fmt.Sprintf(sdkDownloadURL, version, version)
fmt.Printf("Downloading SDK %s from Release archive...\n", version) fmt.Printf("Downloading SDK %s from Release archive...\n", version)
tmpFile, err := os.CreateTemp("", "homeagent-sdk-*.tar.gz")
if err != nil {
fmt.Printf("error: create temp file: %v\n", err)
os.Exit(1)
}
tmpPath := tmpFile.Name()
defer os.Remove(tmpPath)
resp, err := http.Get(url)
if err != nil {
fmt.Printf("error: download SDK %s: %v\n", version, err)
os.Exit(1)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
fmt.Printf("error: download SDK %s: HTTP %d\n", version, resp.StatusCode)
os.Exit(1)
}
if _, err := io.Copy(tmpFile, resp.Body); err != nil {
fmt.Printf("error: save SDK archive: %v\n", err)
os.Exit(1)
}
tmpFile.Close()
// Extract to temp dir, then rename to dest
tmpDir, err := os.MkdirTemp("", "homeagent-sdk-extract-*") tmpDir, err := os.MkdirTemp("", "homeagent-sdk-extract-*")
if err != nil { if err != nil {
fmt.Printf("error: create temp dir: %v\n", err) fmt.Printf("error: create temp dir: %v\n", err)
@ -191,12 +165,75 @@ func cmdSDKInstall(version string) {
} }
defer os.RemoveAll(tmpDir) defer os.RemoveAll(tmpDir)
gzr, err := gzip.NewReader(openFile(tmpPath)) if err := installFromArchive(url, tmpDir); err != nil {
fmt.Printf("warn: archive download failed (%v), falling back to git clone...\n", err)
if err := installFromGit(version, tmpDir); err != nil {
fmt.Printf("error: install SDK %s: %v\n", version, err)
os.Exit(1)
}
}
if err := os.Rename(tmpDir, dest); err != nil {
// Cross-filesystem rename fallback
if err := copyDir(tmpDir, dest); err != nil {
fmt.Printf("error: move SDK to store: %v\n", err)
os.Exit(1)
}
os.RemoveAll(tmpDir)
}
fmt.Printf("SDK version %s installed at %s\n", version, dest)
// Auto-switch to newly installed version if no version is currently active
if resolveCurrentVersion(store) == "" {
setCurrentVersion(store, version)
}
}
func openFile(path string) (*os.File, error) {
f, err := os.Open(path)
if err != nil { if err != nil {
fmt.Printf("error: read archive: %v\n", err) return nil, err
os.Exit(1) }
return f, nil
}
// installFromArchive downloads the SDK release archive and extracts it to tmpDir.
func installFromArchive(url, tmpDir string) error {
tmpFile, err := os.CreateTemp("", "homeagent-sdk-*.tar.gz")
if err != nil {
return err
}
tmpPath := tmpFile.Name()
defer os.Remove(tmpPath)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download SDK %s: HTTP %d", url, resp.StatusCode)
}
if _, err := io.Copy(tmpFile, resp.Body); err != nil {
return err
}
tmpFile.Close()
f, err := openFile(tmpPath)
if err != nil {
return err
}
gzr, err := gzip.NewReader(f)
if err != nil {
f.Close()
return err
} }
defer gzr.Close() defer gzr.Close()
defer f.Close()
tr := tar.NewReader(gzr) tr := tar.NewReader(gzr)
for { for {
@ -205,8 +242,7 @@ func cmdSDKInstall(version string) {
break break
} }
if err != nil { if err != nil {
fmt.Printf("error: extract archive: %v\n", err) return err
os.Exit(1)
} }
// Strip top-level directory from archive path // Strip top-level directory from archive path
@ -227,38 +263,27 @@ func cmdSDKInstall(version string) {
os.MkdirAll(filepath.Dir(target), 0755) os.MkdirAll(filepath.Dir(target), 0755)
f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY, os.FileMode(header.Mode)) f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY, os.FileMode(header.Mode))
if err != nil { if err != nil {
fmt.Printf("error: create file %s: %v\n", target, err) return err
os.Exit(1)
} }
if _, err := io.Copy(f, tr); err != nil { if _, err := io.Copy(f, tr); err != nil {
f.Close() f.Close()
fmt.Printf("error: write file %s: %v\n", target, err) return err
os.Exit(1)
} }
f.Close() f.Close()
} }
} }
gzr.Close() return nil
if err := os.Rename(tmpDir, dest); err != nil {
fmt.Printf("error: move SDK to store: %v\n", err)
os.Exit(1)
}
fmt.Printf("SDK version %s installed at %s\n", version, dest)
// Auto-switch to newly installed version if no version is currently active
if resolveCurrentVersion(store) == "" {
setCurrentVersion(store, version)
}
} }
func openFile(path string) *os.File { // installFromGit clones the SDK repo at the given tag/branch into tmpDir.
f, err := os.Open(path) func installFromGit(version, tmpDir string) error {
if err != nil { cmd := exec.Command("git", "clone", "--depth", "1", "--branch", version, sdkRepoURL, tmpDir)
panic(err) cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("git clone: %v", err)
} }
return f return os.RemoveAll(filepath.Join(tmpDir, ".git"))
} }
// cmdSDKUse switches the active SDK version. // cmdSDKUse switches the active SDK version.
@ -396,17 +421,19 @@ func compareSemver(a, b string) int {
return 0 return 0
} }
// parseSemver extracts [major, minor, patch] from a vX.Y.Z string. // parseSemver extracts [major, minor, patch] from a vX.Y.Z[-pre] string.
// Prerelease tags parse to the same major.minor.patch as their release (ignoring prerelease).
func parseSemver(tag string) [3]int { func parseSemver(tag string) [3]int {
var v [3]int var v [3]int
s := strings.TrimPrefix(tag, "v") s := strings.TrimPrefix(tag, "v")
// Strip prerelease suffix (-...)
if idx := strings.IndexByte(s, '-'); idx >= 0 {
s = s[:idx]
}
parts := strings.SplitN(s, ".", 3) parts := strings.SplitN(s, ".", 3)
for i, p := range parts { for i := 0; i < 3 && i < len(parts); i++ {
if i >= 3 {
break
}
n := 0 n := 0
fmt.Sscanf(p, "%d", &n) fmt.Sscanf(parts[i], "%d", &n)
v[i] = n v[i] = n
} }
return v return v
@ -433,6 +460,35 @@ func activeSDKRoot() string {
return root return root
} }
// copyDir recursively copies src to dst (cross-filesystem rename fallback).
func copyDir(src, dst string) error {
if err := os.MkdirAll(dst, 0755); err != nil {
return err
}
entries, err := os.ReadDir(src)
if err != nil {
return err
}
for _, e := range entries {
srcPath := filepath.Join(src, e.Name())
dstPath := filepath.Join(dst, e.Name())
if e.IsDir() {
if err := copyDir(srcPath, dstPath); err != nil {
return err
}
} else {
data, err := os.ReadFile(srcPath)
if err != nil {
return err
}
if err := os.WriteFile(dstPath, data, 0644); err != nil {
return err
}
}
}
return nil
}
// readMetaVersion reads the Version string from the SDK's meta/meta.go. // readMetaVersion reads the Version string from the SDK's meta/meta.go.
// If the file is missing or unreadable, returns "0.0.0". // If the file is missing or unreadable, returns "0.0.0".
func readMetaVersion(sdkRoot string) string { func readMetaVersion(sdkRoot string) string {

View File

@ -1,6 +1,6 @@
module github.com/JianFeeeee/homeagent-sdk/tools/plugindev module github.com/JianFeeeee/homeagent-sdk/tools/plugindev
go 1.25.0 go 1.21.0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0 require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0

View File

@ -30,7 +30,7 @@ func (p *GoModPatcher) Apply() (func(), error) {
p.backup = string(data) p.backup = string(data)
var sb strings.Builder var sb strings.Builder
sb.WriteString(strings.TrimRight(string(data), "\n")) sb.WriteString(strings.TrimRight(string(data), "\r\n"))
sb.WriteString("\n") sb.WriteString("\n")
for _, r := range p.replaces { for _, r := range p.replaces {
from, to, found := strings.Cut(r, "=") from, to, found := strings.Cut(r, "=")

View File

@ -1,3 +0,0 @@
package main
// tmplPluginInitC is in templates.go (moved to keep all C ABI together)

View File

@ -19,8 +19,6 @@ const tmplGoMod = `module {{.ModulePath}}
go {{.GoVersion}} go {{.GoVersion}}
require {{.SDKModule}} {{.SDKVersion}} require {{.SDKModule}} {{.SDKVersion}}
replace {{.SDKModule}} => {{.SDKReplace}}
` `
const tmplPluginGo = `package main const tmplPluginGo = `package main
@ -39,6 +37,7 @@ func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error { func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.sdk = s p.sdk = s
s.RegisterStopHandler(func() { fmt.Printf("[%s] stop handler running\n", p.name) })
s.Settings().RegisterDef(sdk.ConfigDef{ s.Settings().RegisterDef(sdk.ConfigDef{
Key: "plugin.{{.Plg.Name}}.example", Default: "hello", Type: "string", Key: "plugin.{{.Plg.Name}}.example", Default: "hello", Type: "string",
DisplayName: "示例配置", Description: "An example configuration key", DisplayName: "示例配置", Description: "An example configuration key",
@ -74,13 +73,55 @@ const tmplSDKLua = `-- HomeAgent Lua Plugin SDK (standalone mock)
sdk = {} sdk = {}
function sdk.log(level, msg) print("[lua-plugin] " .. tostring(level) .. ": " .. tostring(msg)) end function sdk.log(level, msg) print("[lua-plugin] " .. tostring(level) .. ": " .. tostring(msg)) end
function sdk.register_tool(name, def, handler) print("[lua-plugin] register_tool: " .. tostring(name)) end function sdk.register_tool(name, def, handler) print("[lua-plugin] register_tool: " .. tostring(name)) end
function sdk.register_stage(stage, handler) print("[lua-plugin] register_stage: " .. tostring(stage)) end function sdk.register_stage(stage, handler, scope) print("[lua-plugin] register_stage: " .. tostring(stage) .. " scope=" .. tostring(scope)) end
function sdk.register_api(name) print("[lua-plugin] register_api: " .. tostring(name)) end function sdk.register_api(name) print("[lua-plugin] register_api: " .. tostring(name)) end
function sdk.register_output_channel(name, caps, desc, def, handler) print("[lua-plugin] register_output_channel: " .. tostring(name)) end
function sdk.register_input_channel(name, def) print("[lua-plugin] register_input_channel: " .. tostring(name)) end
function sdk.get_setting(key) return nil end function sdk.get_setting(key) return nil end
function sdk.set_setting(key, value) print("[lua-plugin] set_setting: " .. tostring(key)) end function sdk.set_setting(key, value) print("[lua-plugin] set_setting: " .. tostring(key)) end
function sdk.inject_text(source, channel, text) print("[lua-plugin] inject_text: " .. tostring(source)) end function sdk.inject_text(source, channel, text) print("[lua-plugin] inject_text: " .. tostring(source)) end
function sdk.inject_interrupt(source, channel, text) print("[lua-plugin] inject_interrupt: " .. tostring(source)) end function sdk.inject_interrupt(source, channel, text) print("[lua-plugin] inject_interrupt: " .. tostring(source)) end
function sdk.inject_text_no_memory(source, channel, text) print("[lua-plugin] inject_text_no_memory: " .. tostring(source)) end function sdk.inject_text_no_memory(source, channel, text) print("[lua-plugin] inject_text_no_memory: " .. tostring(source)) end
function sdk.set_auto_restart(enabled) print("[lua-plugin] set_auto_restart: " .. tostring(enabled)) end
sdk.memory = {}
function sdk.memory.recall(query, depth) return {entities={}, relations={}} end
function sdk.memory.commit(triples) return nil end
function sdk.memory.introspect() return {} end
function sdk.memory.merge(source, target) return 0 end
function sdk.memory.purge(criteria, hard) return 0 end
sdk.doc = {}
function sdk.doc.query(text, top_k) return {} end
function sdk.doc.insert(doc) return nil end
function sdk.doc.remove(id) return nil end
function sdk.doc.stats() return {} end
sdk.knowledge = {}
function sdk.knowledge.search(query, limit) return {} end
function sdk.knowledge.add(tag, content) return nil end
function sdk.knowledge.list() return {} end
sdk.text_memory = {}
function sdk.text_memory.append(evt) return nil end
sdk.llm = {}
function sdk.llm.list_sources() return {} end
function sdk.llm.set_source(name) return nil end
function sdk.llm.current_source() return nil end
sdk.social = {}
function sdk.social.get_person(name) return {} end
function sdk.social.get_network(name, depth) return {} end
function sdk.social.get_trait(name, trait) return {value=nil, found=false} end
function sdk.social.get_relations(name) return {} end
function sdk.social.list_persons() return {} end
sdk.settings = {}
function sdk.settings.get_core(key) return nil end
function sdk.settings.set_core(key, value) return nil end
function sdk.settings.list_core(prefix) return {} end
function sdk.settings.get_plugin(plugin, key) return nil end
function sdk.settings.set_plugin(plugin, key, value) return nil end
function sdk.settings.list_plugin(plugin, prefix) return {} end
function sdk.settings.list(prefix) return {} end
function sdk.settings.register_def(def) return nil end
function sdk.settings.defs(prefix) return {} end
function sdk.settings.dump() return {} end
function sdk.settings.plugins() return {} end
sdk.json = {} sdk.json = {}
function sdk.json.encode(val) function sdk.json.encode(val)
if type(val) == "string" then return '"' .. val:gsub('"', '\\"'):gsub('\n', '\\n') .. '"' if type(val) == "string" then return '"' .. val:gsub('"', '\\"'):gsub('\n', '\\n') .. '"'
@ -138,6 +179,7 @@ type bridgeState struct {
handlers map[string]sdk.ToolHandler handlers map[string]sdk.ToolHandler
stages map[string]sdk.StageHandler stages map[string]sdk.StageHandler
settings map[string]interface{} settings map[string]interface{}
sdk *sdk.PluginSDK
} }
func newHandle(plg sdk.Plugin) unsafe.Pointer { func newHandle(plg sdk.Plugin) unsafe.Pointer {
@ -179,8 +221,10 @@ func StartPlugin(handle unsafe.Pointer) C.int {
}, },
func(stage sdk.Stage, handler sdk.StageHandler) { bs.stages[string(stage)] = handler }, func(stage sdk.Stage, handler sdk.StageHandler) { bs.stages[string(stage)] = handler },
func(name string) error { return nil }, func(name string) error { return nil },
func(name string, caps int, desc string, handler sdk.ToolHandler) error { return nil }, func(name string, caps int, desc string, def sdk.ChannelDef, handler sdk.ToolHandler) error { return nil },
) )
mockSDK.SetInputChannelRegistrar(func(name string, def sdk.ChannelDef) error { return nil })
bs.sdk = mockSDK
if err := bs.plugin.Start(mockSDK); err != nil { return 1 } if err := bs.plugin.Start(mockSDK); err != nil { return 1 }
return 0 return 0
} }
@ -189,6 +233,9 @@ func StartPlugin(handle unsafe.Pointer) C.int {
func StopPlugin(handle unsafe.Pointer) C.int { func StopPlugin(handle unsafe.Pointer) C.int {
bs := getState(handle) bs := getState(handle)
if bs == nil { return 1 } if bs == nil { return 1 }
if bs.sdk != nil {
bs.sdk.RunStopHandlers()
}
if err := bs.plugin.Stop(); err != nil { return 1 } if err := bs.plugin.Stop(); err != nil { return 1 }
return 0 return 0
} }
@ -283,8 +330,8 @@ func main() {}
const tmplCABIHeader = ` const tmplCABIHeader = `
#ifndef HOMEAGENT_CABI_H #ifndef HOMEAGENT_CABI_H
#define HOMEAGENT_CABI_H #define HOMEAGENT_CABI_H
// HOMEAGENT_ABI_VERSION 与 sdk/meta/meta.go ABIVersion 同步 // HOMEAGENT_ABI_VERSION 与 sdk/meta/meta.go CABINum 同步major*100+minorv0.9.x→900
#define HOMEAGENT_ABI_VERSION 1 #define HOMEAGENT_ABI_VERSION 900
#ifdef __cplusplus #ifdef __cplusplus
extern "C" { extern "C" {
#endif #endif
@ -296,7 +343,7 @@ typedef struct {
int (*start_plugin)(void*, int, char**); int (*start_plugin)(void*, int, char**);
int (*stop_plugin)(char**); int (*stop_plugin)(char**);
int (*invoke_tool)(char*, char*, char**, char**); int (*invoke_tool)(char*, char*, char**, char**);
int (*invoke_stage)(char*, char*, char**); int (*invoke_stage)(char*, char*, char**, char**);
int (*invoke_output)(char*, char*, char*, char**); int (*invoke_output)(char*, char*, char*, char**);
void (*free_string)(char*); void (*free_string)(char*);
} PluginAPI; } PluginAPI;
@ -318,6 +365,7 @@ enum {
CORE_INJECT_TEXT = 5, CORE_INJECT_TEXT = 5,
CORE_INJECT_INTERRUPT_TEXT = 6, CORE_INJECT_INTERRUPT_TEXT = 6,
CORE_INJECT_TEXT_NO_MEMORY = 7, CORE_INJECT_TEXT_NO_MEMORY = 7,
CORE_INJECT_INPUT_SYNC = 47,
CORE_SET_AUTO_RESTART = 8, CORE_SET_AUTO_RESTART = 8,
CORE_MEMORY_RECALL = 9, CORE_MEMORY_RECALL = 9,
CORE_MEMORY_COMMIT = 10, CORE_MEMORY_COMMIT = 10,
@ -356,6 +404,7 @@ enum {
CORE_SETTINGS_DEFS = 43, CORE_SETTINGS_DEFS = 43,
CORE_SETTINGS_DUMP = 44, CORE_SETTINGS_DUMP = 44,
CORE_SETTINGS_PLUGINS = 45, CORE_SETTINGS_PLUGINS = 45,
CORE_REGISTER_INPUT_CH = 46,
}; };
#ifdef __cplusplus #ifdef __cplusplus
@ -387,9 +436,11 @@ import (
var ( var (
mu sync.Mutex mu sync.Mutex
currentPlg sdk.Plugin currentPlg sdk.Plugin
currentSDK *sdk.PluginSDK
coreAPI unsafe.Pointer coreAPI unsafe.Pointer
handlerMu sync.RWMutex handlerMu sync.RWMutex
coreAPIMu sync.RWMutex
toolHandlers = map[string]sdk.ToolHandler{} toolHandlers = map[string]sdk.ToolHandler{}
stageHandlers = map[string]sdk.StageHandler{} stageHandlers = map[string]sdk.StageHandler{}
outputHandlers = map[string]sdk.ToolHandler{} outputHandlers = map[string]sdk.ToolHandler{}
@ -398,29 +449,35 @@ var (
// ---- CoreAPI dispatch helpers ---- // ---- CoreAPI dispatch helpers ----
func callVoid(methodID int, s1, s2, s3 string, i1, i2 int) error { func callVoid(methodID int, s1, s2, s3 string, i1, i2 int) error {
coreAPIMu.RLock()
api := coreAPI
coreAPIMu.RUnlock()
var c1, c2, c3 *C.char var c1, c2, c3 *C.char
if s1 != "" { c1 = C.CString(s1); defer C.free(unsafe.Pointer(c1)) } if s1 != "" { c1 = C.CString(s1); defer C.free(unsafe.Pointer(c1)) }
if s2 != "" { c2 = C.CString(s2); defer C.free(unsafe.Pointer(c2)) } if s2 != "" { c2 = C.CString(s2); defer C.free(unsafe.Pointer(c2)) }
if s3 != "" { c3 = C.CString(s3); defer C.free(unsafe.Pointer(c3)) } if s3 != "" { c3 = C.CString(s3); defer C.free(unsafe.Pointer(c3)) }
var cErr *C.char var cErr *C.char
if C.ha_dispatch(C.int(methodID), coreAPI, c1, c2, c3, C.int(i1), C.int(i2), nil, &cErr) != 0 && cErr != nil { if C.ha_dispatch(C.int(methodID), api, c1, c2, c3, C.int(i1), C.int(i2), nil, &cErr) != 0 && cErr != nil {
return fmt.Errorf("%s", C.GoString(cErr)) return fmt.Errorf("%s", C.GoString(cErr))
} }
return nil return nil
} }
func callString(methodID int, s1, s2, s3 string, i1, i2 int) (string, error) { func callString(methodID int, s1, s2, s3 string, i1, i2 int) (string, error) {
coreAPIMu.RLock()
api := coreAPI
coreAPIMu.RUnlock()
var c1, c2, c3 *C.char var c1, c2, c3 *C.char
if s1 != "" { c1 = C.CString(s1); defer C.free(unsafe.Pointer(c1)) } if s1 != "" { c1 = C.CString(s1); defer C.free(unsafe.Pointer(c1)) }
if s2 != "" { c2 = C.CString(s2); defer C.free(unsafe.Pointer(c2)) } if s2 != "" { c2 = C.CString(s2); defer C.free(unsafe.Pointer(c2)) }
if s3 != "" { c3 = C.CString(s3); defer C.free(unsafe.Pointer(c3)) } if s3 != "" { c3 = C.CString(s3); defer C.free(unsafe.Pointer(c3)) }
var strResult, cErr *C.char var strResult, cErr *C.char
if C.ha_dispatch(C.int(methodID), coreAPI, c1, c2, c3, C.int(i1), C.int(i2), &strResult, &cErr) != 0 && cErr != nil { if C.ha_dispatch(C.int(methodID), api, c1, c2, c3, C.int(i1), C.int(i2), &strResult, &cErr) != 0 && cErr != nil {
return "", fmt.Errorf("%s", C.GoString(cErr)) return "", fmt.Errorf("%s", C.GoString(cErr))
} }
if strResult != nil { if strResult != nil {
result := C.GoString(strResult) result := C.GoString(strResult)
C.ha_dispatch(C.int(25), coreAPI, strResult, nil, nil, 0, 0, nil, nil) C.ha_dispatch(C.int(25), api, strResult, nil, nil, 0, 0, nil, nil)
return result, nil return result, nil
} }
return "", nil return "", nil
@ -447,11 +504,12 @@ func buildPluginSDK(name string) *sdk.PluginSDK {
callVoid(2, string(stage), "", "", 0, 0) callVoid(2, string(stage), "", "", 0, 0)
}, },
func(name string) error { return callVoid(4, name, "", "", 0, 0) }, func(name string) error { return callVoid(4, name, "", "", 0, 0) },
func(name string, caps int, desc string, handler sdk.ToolHandler) error { func(name string, caps int, desc string, def sdk.ChannelDef, handler sdk.ToolHandler) error {
handlerMu.Lock() handlerMu.Lock()
outputHandlers[name] = handler outputHandlers[name] = handler
handlerMu.Unlock() handlerMu.Unlock()
return callVoid(3, name, desc, "", caps, 0) defJSON, _ := json.Marshal(def)
return callVoid(3, name, desc, string(defJSON), caps, 0)
}, },
) )
base.SetIOInjector(dispatchIO{}) base.SetIOInjector(dispatchIO{})
@ -461,6 +519,12 @@ func buildPluginSDK(name string) *sdk.PluginSDK {
base.SetLLMAPI(dispatchLLM{}) base.SetLLMAPI(dispatchLLM{})
base.SetSocialAPI(dispatchSocial{}) base.SetSocialAPI(dispatchSocial{})
base.SetTextMemoryAPI(dispatchTextMemory{}) base.SetTextMemoryAPI(dispatchTextMemory{})
base.SetInputChannelRegistrar(
func(name string, def sdk.ChannelDef) error {
defJSON, _ := json.Marshal(def)
return callVoid(46, name, string(defJSON), "", 0, 0)
},
)
return base return base
} }
@ -470,6 +534,7 @@ type dispatchIO struct{}
func (dispatchIO) InjectInterruptText(s, c, t string) { callVoid(6, s, c, t, 0, 0) } func (dispatchIO) InjectInterruptText(s, c, t string) { callVoid(6, s, c, t, 0, 0) }
func (dispatchIO) InjectText(s, c, t string) { callVoid(5, s, c, t, 0, 0) } func (dispatchIO) InjectText(s, c, t string) { callVoid(5, s, c, t, 0, 0) }
func (dispatchIO) InjectTextNoMemory(s, c, t string) { callVoid(7, s, c, t, 0, 0) } func (dispatchIO) InjectTextNoMemory(s, c, t string) { callVoid(7, s, c, t, 0, 0) }
func (dispatchIO) InjectInputSync(s, c, t string) string { r, _ := callString(47, s, c, t, 0, 0); return r }
type dispatchMemory struct{} type dispatchMemory struct{}
func (dispatchMemory) Recall(q []string, d int) ([]sdk.Entity, []sdk.Relation, error) { func (dispatchMemory) Recall(q []string, d int) ([]sdk.Entity, []sdk.Relation, error) {
@ -557,9 +622,9 @@ func (d *dispatchSettings) Plugins() []string {
//export go_init_plugin //export go_init_plugin
func go_init_plugin(name *C.char, configJSON *C.char, errorOut **C.char) C.int { func go_init_plugin(name *C.char, configJSON *C.char, errorOut **C.char) C.int {
plg, err := NewPlugin(C.GoString(name), nil) plg, err := NewPluginFactory(C.GoString(name), nil)
if err != nil || plg == nil { if err != nil || plg == nil {
if err != nil { *errorOut = C.CString(err.Error()) } else { *errorOut = C.CString("NewPlugin returned nil") } if err != nil { *errorOut = C.CString(err.Error()) } else { *errorOut = C.CString("NewPluginFactory returned nil") }
return 1 return 1
} }
mu.Lock(); currentPlg = plg; mu.Unlock() mu.Lock(); currentPlg = plg; mu.Unlock()
@ -571,11 +636,14 @@ func go_init_plugin(name *C.char, configJSON *C.char, errorOut **C.char) C.int {
func go_start_plugin(coreAPIptr unsafe.Pointer, coreVersion C.int, errorOut **C.char) C.int { func go_start_plugin(coreAPIptr unsafe.Pointer, coreVersion C.int, errorOut **C.char) C.int {
mu.Lock() mu.Lock()
plg := currentPlg plg := currentPlg
coreAPIMu.Lock()
coreAPI = coreAPIptr coreAPI = coreAPIptr
coreAPIMu.Unlock()
mu.Unlock() mu.Unlock()
_ = coreVersion _ = coreVersion
if plg == nil { *errorOut = C.CString("not initialized"); return 1 } if plg == nil { *errorOut = C.CString("not initialized"); return 1 }
sdk := buildPluginSDK(plg.Name()) sdk := buildPluginSDK(plg.Name())
mu.Lock(); currentSDK = sdk; mu.Unlock()
if err := plg.Start(sdk); err != nil { *errorOut = C.CString(err.Error()); return 1 } if err := plg.Start(sdk); err != nil { *errorOut = C.CString(err.Error()); return 1 }
return 0 return 0
} }
@ -584,9 +652,16 @@ func go_start_plugin(coreAPIptr unsafe.Pointer, coreVersion C.int, errorOut **C.
func go_stop_plugin(errorOut **C.char) C.int { func go_stop_plugin(errorOut **C.char) C.int {
mu.Lock() mu.Lock()
plg := currentPlg plg := currentPlg
sdk := currentSDK
currentPlg = nil currentPlg = nil
currentSDK = nil
coreAPIMu.Lock()
coreAPI = nil coreAPI = nil
coreAPIMu.Unlock()
mu.Unlock() mu.Unlock()
if sdk != nil {
sdk.RunStopHandlers()
}
if plg != nil { if plg != nil {
if err := plg.Stop(); err != nil { *errorOut = C.CString(err.Error()); return 1 } if err := plg.Stop(); err != nil { *errorOut = C.CString(err.Error()); return 1 }
} }
@ -609,8 +684,53 @@ func go_invoke_tool(name *C.char, argsJSON *C.char, resultOut **C.char, errorOut
return 0 return 0
} }
// fillStageContext 将内核传来的 ctx JSON 填充到插件侧 StageContext。
func fillStageContext(sc *sdk.StageContext, ctxJSON string) {
var m map[string]interface{}
if err := json.Unmarshal([]byte(ctxJSON), &m); err != nil {
return
}
if v, _ := m["raw_message"].(string); v != "" { sc.RawMessage = v }
if v, _ := m["user_id"].(string); v != "" { sc.UserID = v }
if v, _ := m["group_id"].(string); v != "" { sc.GroupID = v }
if v, _ := m["phase"].(string); v != "" { sc.Phase = sdk.Stage(v) }
if v, _ := m["llm_text"].(string); v != "" { sc.LLMText = v }
if v, _ := m["final_text"].(string); v != "" { sc.FinalText = v }
if v, _ := m["no_memory"].(bool); v { sc.NoMemory = true }
if v, _ := m["response"].(string); v != "" { sc.Response = &v }
if v, _ := m["tool_calls"].([]interface{}); len(v) > 0 {
b, _ := json.Marshal(v); json.Unmarshal(b, &sc.ToolCalls)
}
if v, _ := m["tool_results"].([]interface{}); len(v) > 0 {
b, _ := json.Marshal(v); json.Unmarshal(b, &sc.ToolResults)
}
}
// stageContextWritable 提取插件可写且内核会同步回去的字段。
func stageContextWritable(sc *sdk.StageContext) map[string]interface{} {
m := map[string]interface{}{
"raw_message": sc.RawMessage,
"user_id": sc.UserID,
"group_id": sc.GroupID,
"phase": string(sc.Phase),
"llm_text": sc.LLMText,
"final_text": sc.FinalText,
"no_memory": sc.NoMemory,
}
if sc.Response != nil {
m["response"] = *sc.Response
}
if len(sc.ToolCalls) > 0 {
m["tool_calls"] = sc.ToolCalls
}
if len(sc.ToolResults) > 0 {
m["tool_results"] = sc.ToolResults
}
return m
}
//export go_invoke_stage //export go_invoke_stage
func go_invoke_stage(stage *C.char, ctxJSON *C.char, errorOut **C.char) C.int { func go_invoke_stage(stage *C.char, ctxJSON *C.char, resultOut **C.char, errorOut **C.char) C.int {
goStage := C.GoString(stage) goStage := C.GoString(stage)
handlerMu.RLock() handlerMu.RLock()
h, ok := stageHandlers[goStage] h, ok := stageHandlers[goStage]
@ -618,25 +738,15 @@ func go_invoke_stage(stage *C.char, ctxJSON *C.char, errorOut **C.char) C.int {
if !ok { return 0 } if !ok { return 0 }
sc := &sdk.StageContext{} sc := &sdk.StageContext{}
if ctxJSON != nil { if ctxJSON != nil {
var m map[string]interface{} fillStageContext(sc, C.GoString(ctxJSON))
if err := json.Unmarshal([]byte(C.GoString(ctxJSON)), &m); err == nil {
if v, _ := m["raw_message"].(string); v != "" { sc.RawMessage = v }
if v, _ := m["user_id"].(string); v != "" { sc.UserID = v }
if v, _ := m["group_id"].(string); v != "" { sc.GroupID = v }
if v, _ := m["phase"].(string); v != "" { sc.Phase = sdk.Stage(v) }
if v, _ := m["llm_text"].(string); v != "" { sc.LLMText = v }
if v, _ := m["final_text"].(string); v != "" { sc.FinalText = v }
if v, _ := m["no_memory"].(bool); v { sc.NoMemory = true }
if v, _ := m["response"].(string); v != "" { sc.Response = &v }
if v, _ := m["tool_calls"].([]interface{}); len(v) > 0 {
b, _ := json.Marshal(v); json.Unmarshal(b, &sc.ToolCalls)
}
if v, _ := m["tool_results"].([]interface{}); len(v) > 0 {
b, _ := json.Marshal(v); json.Unmarshal(b, &sc.ToolResults)
}
}
} }
if err := h(sc); err != nil { *errorOut = C.CString(err.Error()); return 1 } if err := h(sc); err != nil { *errorOut = C.CString(err.Error()); return 1 }
// ABI v2: 回传插件修改后的上下文(若调用方要求)
if resultOut != nil {
if b, err := json.Marshal(stageContextWritable(sc)); err == nil {
*resultOut = C.CString(string(b))
}
}
return 0 return 0
} }
@ -667,7 +777,8 @@ func main() {}
const tmplPluginInitC = `#include <stdlib.h> const tmplPluginInitC = `#include <stdlib.h>
#include <string.h> #include <string.h>
#define HOMEAGENT_ABI_VERSION 1 // HOMEAGENT_ABI_VERSION 与 sdk/meta/meta.go CABINum 同步major*100+minorv0.9.x→900
#define HOMEAGENT_ABI_VERSION 900
typedef struct { typedef struct {
int version; int version_min; int version; int version_min;
@ -675,7 +786,7 @@ typedef struct {
int (*start_plugin)(void*, int, char**); int (*start_plugin)(void*, int, char**);
int (*stop_plugin)(char**); int (*stop_plugin)(char**);
int (*invoke_tool)(char*, char*, char**, char**); int (*invoke_tool)(char*, char*, char**, char**);
int (*invoke_stage)(char*, char*, char**); int (*invoke_stage)(char*, char*, char**, char**);
int (*invoke_output)(char*, char*, char*, char**); int (*invoke_output)(char*, char*, char*, char**);
void (*free_string)(char*); void (*free_string)(char*);
} PluginAPI; } PluginAPI;
@ -690,7 +801,7 @@ extern int go_init_plugin(char*, char*, char**);
extern int go_start_plugin(void*, int, char**); extern int go_start_plugin(void*, int, char**);
extern int go_stop_plugin(char**); extern int go_stop_plugin(char**);
extern int go_invoke_tool(char*, char*, char**, char**); extern int go_invoke_tool(char*, char*, char**, char**);
extern int go_invoke_stage(char*, char*, char**); extern int go_invoke_stage(char*, char*, char**, char**);
extern int go_invoke_output(char*, char*, char*, char**); extern int go_invoke_output(char*, char*, char*, char**);
extern void go_free_string(char*); extern void go_free_string(char*);
@ -698,7 +809,7 @@ int c_init_plugin(char* n, char* c, char** e) { return go_init_plugin(n, c, e);
int c_start_plugin(void* a, int v, char** e) { return go_start_plugin(a, v, e); } int c_start_plugin(void* a, int v, char** e) { return go_start_plugin(a, v, e); }
int c_stop_plugin(char** e) { return go_stop_plugin(e); } int c_stop_plugin(char** e) { return go_stop_plugin(e); }
int c_invoke_tool(char* n, char* a, char** r, char** e) { return go_invoke_tool(n, a, r, e); } int c_invoke_tool(char* n, char* a, char** r, char** e) { return go_invoke_tool(n, a, r, e); }
int c_invoke_stage(char* s, char* c, char** e) { return go_invoke_stage(s, c, e); } int c_invoke_stage(char* s, char* c, char** r, char** e) { return go_invoke_stage(s, c, r, e); }
int c_invoke_output(char* c, char* m, char* p, char** e) { return go_invoke_output(c, m, p, e); } int c_invoke_output(char* c, char* m, char* p, char** e) { return go_invoke_output(c, m, p, e); }
void c_free_string(char* p) { go_free_string(p); } void c_free_string(char* p) { go_free_string(p); }

View File

@ -17,3 +17,8 @@ curl -X POST http://localhost:8080/api/v1/plugins \
-H "Content-Type: application/octet-stream" \ -H "Content-Type: application/octet-stream" \
--data-binary @dist/<name_en_snake>_linux_amd64.hmap --data-binary @dist/<name_en_snake>_linux_amd64.hmap
``` ```
## Lifecycle
- `RegisterStopHandler` — runs on every stop (including reload/disable), before `Stop()`.
- `RegisterOnRemoveHandler` — runs **only on uninstall (remove)**, after `Stop()`; clean up the plugin's own data files here. Reload/disable do NOT trigger it. See the onRemove demo in `main.go`.

View File

@ -20,6 +20,12 @@ func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error { func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.sdk = s p.sdk = s
s.RegisterStopHandler(func() {
fmt.Printf("[%s] stop handler running\n", p.name)
})
s.RegisterOnRemoveHandler(func() {
fmt.Printf("[%s] onRemove handler running\n", p.name)
})
s.Settings().RegisterDef(sdk.ConfigDef{ s.Settings().RegisterDef(sdk.ConfigDef{
Key: "plugin.{{.Plg.Name}}.example", Key: "plugin.{{.Plg.Name}}.example",
Default: "hello", Default: "hello",

View File

@ -345,23 +345,23 @@ func New(name string) *PluginSDK {
func (s *PluginSDK) RegisterTool(name string, def ToolDef, handler ToolHandler) { func (s *PluginSDK) RegisterTool(name string, def ToolDef, handler ToolHandler) {
logf("register_tool: %s", name) logf("register_tool: %s", name)
mu.Lock() s.mu.Lock()
defer mu.Unlock() defer s.mu.Unlock()
s.toolDefs[name] = def s.toolDefs[name] = def
s.toolHandlers[name] = handler s.toolHandlers[name] = handler
} }
func (s *PluginSDK) RegisterStage(stage Stage, handler StageHandler) { func (s *PluginSDK) RegisterStage(stage Stage, handler StageHandler) {
logf("register_stage: %s", string(stage)) logf("register_stage: %s", string(stage))
mu.Lock() s.mu.Lock()
defer mu.Unlock() defer s.mu.Unlock()
s.stageHandlers[string(stage)] = handler s.stageHandlers[string(stage)] = handler
} }
func (s *PluginSDK) RegisterOutputChannel(name string, caps int, desc string, handler ToolHandler) { func (s *PluginSDK) RegisterOutputChannel(name string, caps int, desc string, handler ToolHandler) {
logf("register_output_channel: %s", name) logf("register_output_channel: %s", name)
mu.Lock() s.mu.Lock()
defer mu.Unlock() defer s.mu.Unlock()
s.outChannels[name] = handler s.outChannels[name] = handler
} }
@ -370,9 +370,9 @@ func (s *PluginSDK) RegisterPluginAPI(name string) {
} }
func (s *PluginSDK) CallTool(name string, args map[string]interface{}) (interface{}, error) { func (s *PluginSDK) CallTool(name string, args map[string]interface{}) (interface{}, error) {
mu.Lock() s.mu.RLock()
handler, ok := s.toolHandlers[name] handler, ok := s.toolHandlers[name]
mu.Unlock() s.mu.RUnlock()
if !ok { if !ok {
return nil, fmt.Errorf("tool not found: %s", name) return nil, fmt.Errorf("tool not found: %s", name)
} }
@ -380,9 +380,9 @@ func (s *PluginSDK) CallTool(name string, args map[string]interface{}) (interfac
} }
func (s *PluginSDK) CallStage(stage string, ctx *StageContext) error { func (s *PluginSDK) CallStage(stage string, ctx *StageContext) error {
mu.Lock() s.mu.RLock()
handler, ok := s.stageHandlers[stage] handler, ok := s.stageHandlers[stage]
mu.Unlock() s.mu.RUnlock()
if !ok { if !ok {
return nil return nil
} }
@ -390,8 +390,8 @@ func (s *PluginSDK) CallStage(stage string, ctx *StageContext) error {
} }
func (s *PluginSDK) ListTools() []ToolDef { func (s *PluginSDK) ListTools() []ToolDef {
mu.Lock() s.mu.RLock()
defer mu.Unlock() defer s.mu.RUnlock()
defs := make([]ToolDef, 0, len(s.toolDefs)) defs := make([]ToolDef, 0, len(s.toolDefs))
for _, def := range s.toolDefs { for _, def := range s.toolDefs {
defs = append(defs, def) defs = append(defs, def)