Files
homeagent-sdk/example/memo/plugin.go
JianFeeeee 4cb3a0bda4 feat(sdk): 通道方向契约落地 + 模板工程/示例插件显式登记 inputch + 生成器两处修正
## 背景:内核侧发现的真问题

在真实二进制压力测试里发现:插件只调 `RegisterOutputChannel("cli", ...)`,
却用同一个通道名 `InjectTextSync("cli", ...)` 注入输入 ⇒ 内核 inputch 登记表里
**没有**这个通道,"把 inputch 划给驻留子"直接失败(`划入 inputch cli: inputch 未注册`)。

根因是**契约没有落到插件与 SDK 面上**:inputch 是内核最基本的**输入路由单位**,
"谁会往这个通道注入输入"必须显式声明,而 SDK 文档没说清它与 RegisterOutputChannel
的分工,示例与模板工程也没有示范。

## SDK 面

- `RegisterInputChannel` / `RegisterOutputChannel` 的文档补齐**方向契约**:
  入站(谁会注入)与出站(output_send__<name> 的回复发给谁)是分开登记的两件事;
  凡是用 `InjectText*/InjectInput*/InjectInterrupt*(source, "<name>", ...)` 注入的
  通道名都要 RegisterInputChannel。README 同步补了一段契约说明。

## 示例插件(全部补齐,之前只有 qq/weather 是对的)

`a2a`、`acp`、`browser`、`memo`:注入用 `p.name` ⇒ 登记 `p.name`;
`calendar`、`rss`:注入用字面量通道名 ⇒ 登记同名通道。
(这些插件此前是"能注入、但通道不在登记表里",与 cli 同类问题。)

## 模板工程(生成器 templates.go)

- `tmplPluginGo`:示范入站+出站两个方向(含 ChannelDef/NoMemory 说明与 `inputch 未注册` 的成因)。
- `tmplMainLua`:同样两个方向(`register_input_channel` / `register_output_channel`)。
- `tmplReadme`:新增 "Channels" 一节(方向对照表 + 兜底告警说明)。
- 实测:`hmapdev init` 生成的 Go/Lua 工程都含通道代码,Go 工程可构建打包出 `.hmap`;
  `--lua` 工程同样生成通道代码。

## 生成器两处修正(都是实测踩出来的)

1. `sdk install --from <dir>`:install 原本只能从 Release 归档下载,而 SDK 开发期的新能力
   (如 proc 桥要透传的 `InjectOptions.Priority`)还没发版 ⇒ 生成的工程必然编译失败
   (`z_proc_gen.go: opts.Priority undefined`)。现在可用本地源码装一个版本并激活。
   实测:`hmapdev sdk install --from <local sdk>` → 装成 v1.3.0 并激活 → 工程构建通过。
2. 构建前置校验 `sdkHasInjectPriority`:proc 桥模板需要 `InjectOptions.Priority`,
   旧 SDK 没有时应给出**可执行**的报错(升级 SDK 或用 `--from`),
   而不是把两条 `opts.Priority undefined` 编译错误甩给用户(那些错误指向生成物,
   完全看不出是 SDK 版本问题)。实测:声明 sdk=1.2.0 的工程构建时正确命中该提示。

## 未决(发布期事项)

`InjectOptions.Priority` 属本特性线新增能力,**已发布的 SDK v1.2.0 不含它**;
发版时 SDK 版本需随之内含该能力(当前源码 meta 已是 1.3.0),否则外部开发者
按文档生成的工程会撞上上面那条守卫。
2026-09-13 11:37:36 +08:00

516 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// Todo 待办条目:会被主动提醒
type Todo struct {
ID int64 `json:"id"`
Content string `json:"content"`
CreatedAt int64 `json:"created_at"`
Done bool `json:"done"`
}
// Memo 备忘录条目:纯记事,不主动提醒
type Memo struct {
ID int64 `json:"id"`
Content string `json:"content"`
CreatedAt int64 `json:"created_at"`
}
type Plugin struct {
name string
sdk *sdk.PluginSDK
mu sync.RWMutex
todos []Todo
nextTID int64
memos []Memo
nextMID int64
todoPath string
memoPath string
stopCh chan struct{}
tp string
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.sdk = s
p.tp = p.name + "_"
// 入站通道:本插件用 p.name 通道注入输入(见 Inject* 调用),
// 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。
_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{NoMemory: true})
dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir")
if err != nil || dataDirVal == "" {
dataDirVal = "."
}
dir := filepath.Join(fmt.Sprint(dataDirVal), p.name)
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.RegisterOnRemoveHandler(p.cleanupData)
// ── 待办(会被主动提醒)──
s.RegisterTool(p.tp+"todo_add", sdk.ToolDef{
Name: p.tp + "todo_add",
Description: "添加一条待办事项。待办会被主动提醒,完成后请及时用 todo_complete 标记。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"content": map[string]interface{}{"type": "string", "description": "待办内容"},
},
"required": []string{"content"},
},
}, p.handleTodoAdd)
s.RegisterTool(p.tp+"todo_complete", sdk.ToolDef{
Name: p.tp + "todo_complete",
Description: "将指定ID的待办标记为已完成不再提醒。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"id": map[string]interface{}{"type": "integer", "description": "待办ID"},
},
"required": []string{"id"},
},
}, p.handleTodoComplete)
s.RegisterTool(p.tp+"todo_list", sdk.ToolDef{
Name: p.tp + "todo_list",
Description: "列出所有未完成的待办事项包含ID、内容和创建时间。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
}, 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)
go p.periodicCheck()
log.Printf("[%s] started, todos=%s memos=%s", p.name, p.todoPath, p.memoPath)
return nil
}
func (p *Plugin) Stop() error {
close(p.stopCh)
p.saveTodos()
p.saveMemos()
log.Printf("[%s] stopped", p.name)
return nil
}
func (p *Plugin) loadTodos() {
p.mu.Lock()
defer p.mu.Unlock()
data, err := os.ReadFile(p.todoPath)
if err != nil {
p.todos = []Todo{}
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
}
var store struct {
Memos []Memo `json:"memos"`
NextID int64 `json:"next_id"`
}
if json.Unmarshal(data, &store) != nil {
p.memos = []Memo{}
p.nextMID = 1
return
}
p.memos = store.Memos
p.nextMID = store.NextID
if p.memos == nil {
p.memos = []Memo{}
}
if p.nextMID < 1 {
p.nextMID = 1
}
}
func (p *Plugin) saveTodos() {
p.mu.RLock()
data, _ := json.MarshalIndent(map[string]interface{}{
"todos": p.todos,
"next_id": p.nextTID,
}, "", " ")
p.mu.RUnlock()
atomicWriteJSON(p.todoPath, data)
}
func (p *Plugin) saveMemos() {
p.mu.RLock()
data, _ := json.MarshalIndent(map[string]interface{}{
"memos": p.memos,
"next_id": p.nextMID,
}, "", " ")
p.mu.RUnlock()
atomicWriteJSON(p.memoPath, data)
}
// ── 待办:未完成计数与提醒 ──
func (p *Plugin) pendingTodoCount() int {
p.mu.RLock()
defer p.mu.RUnlock()
n := 0
for _, t := range p.todos {
if !t.Done {
n++
}
}
return n
}
func (p *Plugin) pendingTodos() []Todo {
p.mu.RLock()
defer p.mu.RUnlock()
var out []Todo
for _, t := range p.todos {
if !t.Done {
out = append(out, t)
}
}
return out
}
// stagePreAction 仅在待办未完成时注入上下文提示(备忘录不提示)
func (p *Plugin) stagePreAction(ctx *sdk.StageContext) error {
n := p.pendingTodoCount()
if n == 0 {
return nil
}
ctx.Lock()
ctx.ContextMsgs = append(ctx.ContextMsgs, map[string]interface{}{
"role": "system",
"content": fmt.Sprintf("目前有%d条待办未完成调用%s todo_list 工具读取具体内容", n, p.tp),
})
ctx.Unlock()
return nil
}
// periodicCheck 周期主动提醒未完成待办(备忘录不提醒)
func (p *Plugin) periodicCheck() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-p.stopCh:
return
case <-ticker.C:
n := p.pendingTodoCount()
if n == 0 {
continue
}
if p.sdk != nil {
// NoMemory这是定时提醒不是记忆内容。
p.sdk.InjectInterruptTextOpts(p.name, p.name,
fmt.Sprintf("注意,你还有%d条待办未完成请检查", n), sdk.InjectOptions{NoMemory: true})
}
}
}
}
// ── 待办工具 ──
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)
if content == "" {
return errorResult("content is required"), nil
}
p.mu.Lock()
memo := Memo{
ID: p.nextMID,
Content: content,
CreatedAt: time.Now().Unix(),
}
p.nextMID++
p.memos = append(p.memos, memo)
p.mu.Unlock()
p.saveMemos()
return map[string]interface{}{
"content": fmt.Sprintf("备忘录已创建 (ID: %d)", memo.ID),
"id": memo.ID,
}, nil
}
func (p *Plugin) handleMemoDelete(args map[string]interface{}) (interface{}, error) {
id, ok := args["id"].(float64)
if !ok {
return errorResult("id is required"), nil
}
p.mu.Lock()
found := false
for i := range p.memos {
if p.memos[i].ID == int64(id) {
p.memos = append(p.memos[:i], p.memos[i+1:]...)
found = true
break
}
}
p.mu.Unlock()
if !found {
return errorResult(fmt.Sprintf("未找到备忘录 ID: %d", int64(id))), nil
}
p.saveMemos()
return map[string]interface{}{
"content": fmt.Sprintf("备忘录 %d 已删除", int64(id)),
}, nil
}
func (p *Plugin) handleMemoList(args map[string]interface{}) (interface{}, error) {
p.mu.RLock()
memos := append([]Memo{}, p.memos...)
p.mu.RUnlock()
if len(memos) == 0 {
return map[string]interface{}{
"content": "暂无备忘录",
}, nil
}
var sb strings.Builder
for i, m := range memos {
ts := time.Unix(m.CreatedAt, 0).Format("01-02 15:04")
if i > 0 {
sb.WriteString("\n")
}
sb.WriteString(fmt.Sprintf("%d. [ID:%d] %s — %s", i+1, m.ID, m.Content, ts))
}
return map[string]interface{}{
"content": sb.String(),
"count": len(memos),
}, nil
}
func errorResult(msg string) map[string]interface{} {
return map[string]interface{}{
"isError": true,
"content": msg,
}
}
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
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)
}
}
// atomicWriteJSON 原子写 JSON先写临时文件再 rename避免进程崩溃截断数据文件。
func atomicWriteJSON(path string, data []byte) error {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0644); err != nil {
return err
}
return os.Rename(tmp, path)
}