From 4cb3a0bda46e49ac3a2bfcf925bab24540ab6a93 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 13 Sep 2026 11:37:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(sdk):=20=E9=80=9A=E9=81=93=E6=96=B9?= =?UTF-8?q?=E5=90=91=E5=A5=91=E7=BA=A6=E8=90=BD=E5=9C=B0=20+=20=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E5=B7=A5=E7=A8=8B/=E7=A4=BA=E4=BE=8B=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E6=98=BE=E5=BC=8F=E7=99=BB=E8=AE=B0=20inputch=20+=20?= =?UTF-8?q?=E7=94=9F=E6=88=90=E5=99=A8=E4=B8=A4=E5=A4=84=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 背景:内核侧发现的真问题 在真实二进制压力测试里发现:插件只调 `RegisterOutputChannel("cli", ...)`, 却用同一个通道名 `InjectTextSync("cli", ...)` 注入输入 ⇒ 内核 inputch 登记表里 **没有**这个通道,"把 inputch 划给驻留子"直接失败(`划入 inputch cli: inputch 未注册`)。 根因是**契约没有落到插件与 SDK 面上**:inputch 是内核最基本的**输入路由单位**, "谁会往这个通道注入输入"必须显式声明,而 SDK 文档没说清它与 RegisterOutputChannel 的分工,示例与模板工程也没有示范。 ## SDK 面 - `RegisterInputChannel` / `RegisterOutputChannel` 的文档补齐**方向契约**: 入站(谁会注入)与出站(output_send__ 的回复发给谁)是分开登记的两件事; 凡是用 `InjectText*/InjectInput*/InjectInterrupt*(source, "", ...)` 注入的 通道名都要 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 `:install 原本只能从 Release 归档下载,而 SDK 开发期的新能力 (如 proc 桥要透传的 `InjectOptions.Priority`)还没发版 ⇒ 生成的工程必然编译失败 (`z_proc_gen.go: opts.Priority undefined`)。现在可用本地源码装一个版本并激活。 实测:`hmapdev sdk install --from ` → 装成 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),否则外部开发者 按文档生成的工程会撞上上面那条守卫。 --- README.md | 8 +++-- example/a2a/plugin.go | 33 ++++++++++-------- example/acp/plugin.go | 3 ++ example/browser/plugin.go | 3 ++ example/calendar/plugin.go | 49 ++++++++++++++------------ example/memo/plugin.go | 3 ++ example/rss/plugin.go | 13 +++---- sdk/plugin.go | 14 ++++++++ tools/hmapdev/cmd_build.go | 31 +++++++++++++++++ tools/hmapdev/cmd_sdk.go | 71 ++++++++++++++++++++++++++++++++++++-- tools/hmapdev/templates.go | 40 +++++++++++++++++++++ 11 files changed, 219 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index f171a66..869c58e 100644 --- a/README.md +++ b/README.md @@ -84,11 +84,15 @@ type Plugin interface { 通过 `Start(sdk *PluginSDK)` 注入的 SDK 实例提供以下方法: +> **通道的方向契约**:入站与出站是分开登记的两件事。凡是用 `InjectText*/InjectInput*/InjectInterrupt*` +> 注入的通道名都要 `RegisterInputChannel` —— inputch 是内核最基本的**输入路由单位**, +> 只有登记过的通道才能被"划给驻留子";只登记出站通道时内核会兜底登记同名 inputch 并告警(兼容老插件)。 + | 分类 | 方法 | 说明 | |------|------|------| | 阶段钩子 | `RegisterStage(stage, handler, scope...)` | 注册阶段回调,scope 可选:`StageScopeGlobal`(全局,默认)或 `StageScopeOwnTools`(仅自己工具) | -| 输入通道 | `RegisterInputChannel(name, def)` | 注册输入通道,def 为 `ChannelDef`(NoMemory/Cleaner) | -| 输出通道 | `RegisterOutputChannel(name, caps, desc, def, handler)` | 注册输出通道,def 为 `ChannelDef`,caps 为能力位掩码 | +| 输入通道 | `RegisterInputChannel(name, def)` | 注册输入通道(**入站**:谁会往这个通道注入输入),def 为 `ChannelDef`(NoMemory/Cleaner) | +| 输出通道 | `RegisterOutputChannel(name, caps, desc, def, handler)` | 注册输出通道(**出站**:`output_send__` 的回复发给谁),def 为 `ChannelDef`,caps 为能力位掩码 | | 工具注册 | `RegisterTool(name, def, handler)` | 注册工具供 LLM 调用 | | 插件 API | `RegisterPluginAPI(name)` | 注册插件 API 供其他插件访问 | | 图记忆 | `Memory()` | 访问图记忆 API(实体-关系存储) | diff --git a/example/a2a/plugin.go b/example/a2a/plugin.go index d48214b..a68eb9c 100644 --- a/example/a2a/plugin.go +++ b/example/a2a/plugin.go @@ -24,8 +24,8 @@ type Plugin struct { // 会话表:session_id → 上下文前缀。A2A 无状态协议下由插件侧维护 // 多轮上下文:同 session 的后续请求会把之前的对话拼进注入文本。 - sessMu sync.Mutex - sessions map[string]*a2aSession + sessMu sync.Mutex + sessions map[string]*a2aSession } // a2aSession 记录一个会话的轮次历史,用于延续上下文。 @@ -47,6 +47,9 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { s.SetAutoRestart(true) p.sdk = s p.sessions = make(map[string]*a2aSession) + // 入站通道:本插件用 p.name 通道注入输入(见 InjectInputSync 调用), + // 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。 + _ = s.RegisterInputChannel(p.name, sdk.ChannelDef{}) tp := p.name + "_" // 注册自身为输出通道:agent 回复 emit 到本通道时有落点, @@ -66,7 +69,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { Key: "listen", Default: "127.0.0.1:12000", Type: "string", DisplayName: "监听地址", Description: "A2A 服务端监听地址,设为空可禁用 HTTP 服务", - Category: p.name, + Category: p.name, }) // Outbound: query + discover @@ -75,10 +78,10 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ - "agent_url": map[string]interface{}{"type": "string", "description": "目标 Agent 的 A2A 端点 URL"}, - "query": map[string]interface{}{"type": "string", "description": "发送给目标 Agent 的文本查询"}, + "agent_url": map[string]interface{}{"type": "string", "description": "目标 Agent 的 A2A 端点 URL"}, + "query": map[string]interface{}{"type": "string", "description": "发送给目标 Agent 的文本查询"}, "session_id": map[string]interface{}{"type": "string", "description": "可选。上次调用返回的 session_id,传入可延续与该 agent 的多轮对话上下文"}, - "timeout": map[string]interface{}{"type": "integer", "description": "超时时间(秒),默认 60"}, + "timeout": map[string]interface{}{"type": "integer", "description": "超时时间(秒),默认 60"}, }, "required": []string{"agent_url", "query"}, }, @@ -287,7 +290,7 @@ func (p *Plugin) handleIncomingA2A(w http.ResponseWriter, r *http.Request) { Query string `json:"query,omitempty"` SessionID string `json:"session_id,omitempty"` Limit int `json:"limit,omitempty"` - Message *struct { + Message *struct { Role string `json:"role"` Parts []struct { Text string `json:"text,omitempty"` @@ -347,7 +350,7 @@ func (p *Plugin) handleIncomingA2A(w http.ResponseWriter, r *http.Request) { if sess := p.sessions[sessionID]; sess != nil { sess.History = append(sess.History, "用户: "+queryText, "助手: "+reply) if len(sess.History) > maxSessionTurns*2 { - sess.History = sess.History[len(sess.History)-maxSessionTurns*2 :] + sess.History = sess.History[len(sess.History)-maxSessionTurns*2:] } sess.LastUsed = time.Now() } @@ -357,11 +360,11 @@ func (p *Plugin) handleIncomingA2A(w http.ResponseWriter, r *http.Request) { "jsonrpc": "2.0", "id": req.ID, "result": map[string]interface{}{ - "id": fmt.Sprintf("task_%d", time.Now().UnixNano()), - "status": "completed", + "id": fmt.Sprintf("task_%d", time.Now().UnixNano()), + "status": "completed", "session_id": sessionID, "message": map[string]interface{}{ - "role": "agent", + "role": "agent", "parts": []map[string]string{{"type": "text", "text": reply}}, }, }, @@ -457,10 +460,10 @@ type A2AResponse struct { } type A2AResult struct { - TaskID string `json:"id,omitempty"` - Status string `json:"status,omitempty"` - SessionID string `json:"session_id,omitempty"` - Message *A2AMessage `json:"message,omitempty"` + TaskID string `json:"id,omitempty"` + Status string `json:"status,omitempty"` + SessionID string `json:"session_id,omitempty"` + Message *A2AMessage `json:"message,omitempty"` AgentCard *A2AAgentCard `json:"agent_card,omitempty"` } diff --git a/example/acp/plugin.go b/example/acp/plugin.go index 72dc373..ba6e8d3 100644 --- a/example/acp/plugin.go +++ b/example/acp/plugin.go @@ -47,6 +47,9 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { s.SetAutoRestart(true) p.sdk = s p.sessions = make(map[string]*sessionState) + // 入站通道:本插件用 p.name 通道注入输入(见 InjectInputSync 调用), + // 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。 + _ = s.RegisterInputChannel(p.name, sdk.ChannelDef{}) tp := p.name + "_" // 注册自身为输出通道:agent 回复 emit 到本通道时有落点。 diff --git a/example/browser/plugin.go b/example/browser/plugin.go index 81b7662..253a7a9 100644 --- a/example/browser/plugin.go +++ b/example/browser/plugin.go @@ -204,6 +204,9 @@ func newHTTPClient(timeout int, proxyURL string) *http.Client { func (p *Plugin) Start(s *sdk.PluginSDK) error { p.sdk = s s.SetAutoRestart(true) + // 入站通道:本插件用 p.name 通道注入输入(见 InjectInputSync 调用), + // 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。 + _ = s.RegisterInputChannel(p.name, sdk.ChannelDef{}) s.Settings().RegisterDef(sdk.ConfigDef{ Key: "timeout", Default: "30", Type: "int", diff --git a/example/calendar/plugin.go b/example/calendar/plugin.go index 6b475c9..4f09eb4 100644 --- a/example/calendar/plugin.go +++ b/example/calendar/plugin.go @@ -15,31 +15,31 @@ import ( ) const ( - RepeatNone = "none" - RepeatDaily = "daily" - RepeatWeekday = "weekday" - RepeatWeekly = "weekly" - RepeatBiweekly = "biweekly" - RepeatMonthly = "monthly" - RepeatYearly = "yearly" + RepeatNone = "none" + RepeatDaily = "daily" + RepeatWeekday = "weekday" + RepeatWeekly = "weekly" + RepeatBiweekly = "biweekly" + RepeatMonthly = "monthly" + RepeatYearly = "yearly" RepeatLunarYearly = "lunar_yearly" ) type CalendarEvent struct { - ID string `json:"id"` - Title string `json:"title"` - StartTime string `json:"start_time"` - EndTime string `json:"end_time,omitempty"` - AllDay bool `json:"all_day,omitempty"` - Location string `json:"location,omitempty"` - Note string `json:"note,omitempty"` - Reminds []int `json:"reminds,omitempty"` - RemindAt []int64 `json:"remind_at,omitempty"` - Repeat string `json:"repeat,omitempty"` - ParentID string `json:"parent_id,omitempty"` - Lunar bool `json:"lunar,omitempty"` - LunarMonth int `json:"lunar_month,omitempty"` - LunarDay int `json:"lunar_day,omitempty"` + ID string `json:"id"` + Title string `json:"title"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time,omitempty"` + AllDay bool `json:"all_day,omitempty"` + Location string `json:"location,omitempty"` + Note string `json:"note,omitempty"` + Reminds []int `json:"reminds,omitempty"` + RemindAt []int64 `json:"remind_at,omitempty"` + Repeat string `json:"repeat,omitempty"` + ParentID string `json:"parent_id,omitempty"` + Lunar bool `json:"lunar,omitempty"` + LunarMonth int `json:"lunar_month,omitempty"` + LunarDay int `json:"lunar_day,omitempty"` } type Plugin struct { @@ -276,6 +276,9 @@ func nextLunarYearly(targetMonth, targetDay int, after time.Time) (time.Time, bo func (p *Plugin) Start(s *sdk.PluginSDK) error { p.sdk = s + // 入站通道:本插件用 "calendar" 通道注入输入(见 Inject* 调用), + // 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。 + _ = s.RegisterInputChannel("calendar", sdk.ChannelDef{NoMemory: true}) dataDirVal, err := s.Settings().GetCore("core.daemon.data_dir") if err != nil || dataDirVal == "" { dataDirVal = "." @@ -359,7 +362,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { s.RegisterTool(tp+"today", sdk.ToolDef{ Name: tp + "today", Description: "Show today's events with countdown.", Parameters: map[string]interface{}{ - "type": "object", + "type": "object", "properties": map[string]interface{}{}, }, }, p.handleToday) @@ -367,7 +370,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { s.RegisterTool(tp+"week", sdk.ToolDef{ Name: tp + "week", Description: "Show this week's events grouped by day.", Parameters: map[string]interface{}{ - "type": "object", + "type": "object", "properties": map[string]interface{}{}, }, }, p.handleWeek) diff --git a/example/memo/plugin.go b/example/memo/plugin.go index acb06a6..53ab9c1 100644 --- a/example/memo/plugin.go +++ b/example/memo/plugin.go @@ -48,6 +48,9 @@ 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 == "" { diff --git a/example/rss/plugin.go b/example/rss/plugin.go index 5336eb2..46c4773 100644 --- a/example/rss/plugin.go +++ b/example/rss/plugin.go @@ -104,6 +104,9 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { s.SetAutoRestart(true) p.sdk = s p.client = &http.Client{Timeout: 30 * time.Second} + // 入站通道:本插件用 "rss" 通道注入输入(见 Inject* 调用), + // 输入侧必须显式登记 —— 否则"把该 inputch 划给驻留子"会报 `inputch 未注册`。 + _ = s.RegisterInputChannel("rss", sdk.ChannelDef{NoMemory: true}) p.fp = gofeed.NewParser() p.stopCh = make(chan struct{}) p.seenGUIDs = make(map[string]bool) @@ -158,7 +161,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { s.RegisterTool(tp+"list", sdk.ToolDef{ Name: tp + "list", Description: "List all subscribed feeds", Parameters: map[string]interface{}{ - "type": "object", + "type": "object", "properties": map[string]interface{}{}, }, }, p.handleList) @@ -167,7 +170,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { Name: tp + "check_now", Description: "Manually check all feeds for new articles now", NoMemory: true, Parameters: map[string]interface{}{ - "type": "object", + "type": "object", "properties": map[string]interface{}{}, }, }, p.handleCheckNow) @@ -445,7 +448,7 @@ func (p *Plugin) loadData() { return } var data struct { - Feeds []FeedSub `json:"feeds"` + Feeds []FeedSub `json:"feeds"` SeenGUIDs map[string]bool `json:"seen"` } if json.Unmarshal(b, &data) != nil { @@ -463,7 +466,7 @@ func (p *Plugin) saveData() { p.mu.RLock() defer p.mu.RUnlock() data := struct { - Feeds []FeedSub `json:"feeds"` + Feeds []FeedSub `json:"feeds"` SeenGUIDs map[string]bool `json:"seen"` }{ Feeds: p.feeds, @@ -488,8 +491,6 @@ func (p *Plugin) cleanupData() { } } - - // atomicWriteJSON 原子写 JSON:先写临时文件再 rename,避免进程崩溃截断数据文件。 func atomicWriteJSON(path string, data []byte) error { tmp := path + ".tmp" diff --git a/sdk/plugin.go b/sdk/plugin.go index f66a789..9f10d90 100644 --- a/sdk/plugin.go +++ b/sdk/plugin.go @@ -467,6 +467,11 @@ func (s *PluginSDK) RegisterPluginAPI(name string) error { } // RegisterOutputChannel registers an output channel that the output_send tool can route to. +// +// 与 RegisterInputChannel 的分工:本函数声明**出站**(output_send__ 的回复发给谁); +// 入站(谁会往 注入输入)是另一件事,用 RegisterInputChannel 声明。 +// 若该通道同时也是你的注入入口,两个都要登记。 +// // name: channel name (e.g. "qq", "webui") // caps: bitmask of supported output capabilities (CapText, CapFile, etc.) // desc: description of the channel, expected meta format, and type enum @@ -483,6 +488,15 @@ func (s *PluginSDK) RegisterOutputChannel(name string, caps int, desc string, de } // RegisterInputChannel registers an input channel with its memory behavior. +// +// 契约:**凡是用 InjectText*/InjectInput*/InjectInterrupt*(source, "", ...) +// 注入的通道名,都应当在这里登记**。inputch 是内核里最基本的**输入路由单位**: +// 只有登记过的通道才能在 inputch 登记表里出现,父 agent 才能"把某个 inputch 划给驻留子"; +// 没登记就划分会直接失败(`inputch 未注册`)。 +// +// 只登记输出通道(RegisterOutputChannel)而没登记输入通道时,内核会兜底登记同名 +// inputch 并打告警日志 —— 兜底只为兼容老插件,新插件请显式登记。 +// // def.NoMemory: 此通道输入不参与记忆计算 // def.Cleaner: 计算层对输入文本清洗后(不改原文)再向量化/提关键词 func (s *PluginSDK) RegisterInputChannel(name string, def ChannelDef) error { diff --git a/tools/hmapdev/cmd_build.go b/tools/hmapdev/cmd_build.go index 2eef47e..c5e6a3d 100644 --- a/tools/hmapdev/cmd_build.go +++ b/tools/hmapdev/cmd_build.go @@ -95,6 +95,18 @@ func cmdBuild(args []string) { plg.ResolvedSDK = normalizeSDKVersion(readMetaVersion(sdkPath)) } + // SDK 能力前置校验:proc 桥的模板(z_proc_gen.go)会透传 InjectOptions.Priority, + // 而旧版 SDK 没有这个字段。不校验的话,用户看到的是 z_proc_gen.go 里两条 + // "opts.Priority undefined" 编译错误——错误信息指向生成物,完全看不出是 SDK 版本问题。 + if sdkPath != "" && !sdkHasInjectPriority(sdkPath) { + fmt.Printf("error: 当前 SDK(%s)缺少 sdk.InjectOptions.Priority\n", plg.ResolvedSDK) + fmt.Printf(" 子进程模式(proc 桥)的模板需要它来透传注入优先级 L1-L4。\n") + fmt.Printf(" 解决办法(二选一):\n") + fmt.Printf(" 1) 升级 SDK:hmapdev sdk install <含该能力的版本> && hmapdev sdk use <版本>\n") + fmt.Printf(" 2) 用本地 SDK 源码:hmapdev sdk install --from /path/to/homeagent-sdk\n") + os.Exit(1) + } + // Ensure go.mod exists with correct SDK path sdkModule := ensureGoMod(plg, sdkPath) @@ -869,3 +881,22 @@ func linkThirdpart(plg *PlgConfig, target string) func() { os.Remove(importFile) } } + +// sdkHasInjectPriority 报告该 SDK 源码是否已具备 InjectOptions.Priority +// (proc 桥透传注入优先级所必需的能力;SDK 开发期与已发布版本可能不一致)。 +func sdkHasInjectPriority(sdkPath string) bool { + data, err := os.ReadFile(filepath.Join(sdkPath, "sdk", "plugin.go")) + if err != nil { + return true // 读不到就不拦(不在校验范围内) + } + src := string(data) + i := strings.Index(src, "type InjectOptions struct") + if i < 0 { + return true + } + seg := src[i:] + if j := strings.Index(seg, "\n}"); j > 0 { + seg = seg[:j] + } + return strings.Contains(seg, "Priority") +} diff --git a/tools/hmapdev/cmd_sdk.go b/tools/hmapdev/cmd_sdk.go index e78ed7c..eaedf5b 100644 --- a/tools/hmapdev/cmd_sdk.go +++ b/tools/hmapdev/cmd_sdk.go @@ -58,6 +58,27 @@ func cmdSDK(args []string) { sdkHelp() return } + // install --from <本地目录> [version]:用本地 SDK 源码装一个版本并激活。 + if args[0] == "install" { + from := "" + rest := []string{} + for i := 1; i < len(args); i++ { + if args[i] == "--from" && i+1 < len(args) { + from = args[i+1] + i++ + continue + } + rest = append(rest, args[i]) + } + if from != "" { + version := "" + if len(rest) > 0 && rest[0] != "latest" { + version = rest[0] + } + cmdSDKInstallFromDir(from, version) + return + } + } switch args[0] { case "list": cmdSDKList() @@ -100,7 +121,8 @@ Commands: Examples: hmapdev sdk install v0.7.1 hmapdev sdk install latest - hmapdev sdk use v0.7.1 + hmapdev sdk install v0.7.1 + hmapdev sdk install --from /path/to/homeagent-sdk # 用本地源码(SDK 开发时用)sdk use v0.7.1 `) } @@ -144,6 +166,51 @@ func cmdSDKList() { } } +// cmdSDKInstallFromDir 从**本地 SDK 源码目录**安装一个版本。 +// +// 为什么需要它:`install` 只能从 Release 归档下载,而 SDK 开发时的新能力 +// (例如 `InjectOptions.Priority` 这类 proc 桥要透传的字段)往往还没发版 —— +// 此时生成出来的插件工程会因为"引用的 SDK 还没有该字段"直接编译失败。 +// 有 --from 才能"用本地源码当这个版本的 SDK",边改 SDK 边验证模板工程。 +func cmdSDKInstallFromDir(src, version string) { + store := sdkStore() + if err := os.MkdirAll(store, 0755); err != nil { + fmt.Printf("error: create SDK store %s: %v\n", store, err) + os.Exit(1) + } + if version == "" { + version = readMetaVersion(src) + } + if version == "" { + fmt.Printf("error: cannot determine version from %s/meta/meta.go\n", src) + os.Exit(1) + } + if !strings.HasPrefix(version, "v") { + version = "v" + version + } + if _, err := os.Stat(filepath.Join(src, "go.mod")); err != nil { + fmt.Printf("error: %s 看起来不是 SDK 源码目录(缺 go.mod)\n", src) + os.Exit(1) + } + dest := sdkVersionDir(version) + _ = os.RemoveAll(dest) + if err := copyDir(src, dest); err != nil { + fmt.Printf("error: copy %s -> %s: %v\n", src, dest, err) + os.Exit(1) + } + // 源码目录里的开发产物不该带进 store。 + for _, junk := range []string{".git", "dist", "build"} { + _ = os.RemoveAll(filepath.Join(dest, junk)) + } + fmt.Printf("Installed SDK %s from %s\n", version, src) + fmt.Printf(" %s\n", dest) + if err := os.WriteFile(filepath.Join(store, "current"), []byte(version), 0644); err != nil { + fmt.Printf("error: activate %s: %v\n", version, err) + os.Exit(1) + } + fmt.Printf("Activated SDK %s\n", version) +} + // cmdSDKInstall downloads and installs an SDK version from Release archive. func cmdSDKInstall(version string) { store := sdkStore() @@ -520,5 +587,3 @@ func readMetaVersion(sdkRoot string) string { } return "0.0.0" } - - diff --git a/tools/hmapdev/templates.go b/tools/hmapdev/templates.go index 7fbbd47..036a769 100644 --- a/tools/hmapdev/templates.go +++ b/tools/hmapdev/templates.go @@ -49,6 +49,22 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { DisplayName: "示例配置", Description: "An example configuration key", Category: "{{.Plg.Name}}", }) + // ---- 通道(channel):两个方向是分开的两件事 ---- + // + // 入站 inputch ——「谁会往这个通道注入输入」。 + // 凡是用 s.InjectText*/InjectInput*/InjectInterrupt*(source, "", ...) 注入的通道名, + // 都要在这里登记:inputch 是内核最基本的**输入路由单位**,只有登记过的通道 + // 才能被「划给驻留子(resident sub-agent)」;没登记就划分会失败(inputch 未注册)。 + // 只登记出站通道时内核会兜底登记同名 inputch **并打告警**(兼容老插件)。 + chName := p.name + _ = s.RegisterInputChannel(chName, sdk.ChannelDef{NoMemory: true}) + // 出站 output ——「output_send__ 的回复发给谁」。 + // handler 收到 map:payload(string) / type(string) / meta(string|optional)。 + _ = s.RegisterOutputChannel(chName, sdk.CapText, "示例通道(回复由此返回)", + sdk.ChannelDef{NoMemory: true}, func(args map[string]interface{}) (interface{}, error) { + return map[string]interface{}{"status": "ok"}, nil + }) + tp := p.name + "_" s.RegisterTool(tp+"hello", sdk.ToolDef{ Name: tp + "hello", @@ -148,6 +164,15 @@ const tmplMainLua = `-- {{.Plg.Name}} plugin local plugin = { name = "{{.Plg.Name}}" } function plugin.start(sdk) sdk.log("info", "{{.Plg.Name}} starting...") + + -- 通道:入站与出站分开登记。 + -- 入站 inputch:凡是用 sdk.inject_text/sdk.inject_interrupt(source, "", ...) 注入的通道名 + -- 都要登记;只有登记过的通道才能被「划给驻留子」(没登记会报 inputch 未注册)。 + sdk.register_input_channel("{{.Plg.Name}}", { no_memory = true }) + -- 出站 output:output_send__ 的回复由 handler 处理 + sdk.register_output_channel("{{.Plg.Name}}", 1, "示例通道(回复由此返回)", { no_memory = true }, + function(args) return { status = "ok" } end) + sdk.register_tool("{{.Plg.Name}}_hello", { description = "A hello world tool", parameters = { type = "object", properties = {} } @@ -515,6 +540,21 @@ const tmplReadme = `# {{.Plg.Name}} hmapdev build ` + "```" + ` +## Channels + +入站与出站是分开登记的两件事: + +| 方向 | API | 用途 | +|---|---|---| +| 入站 inputch | RegisterInputChannel(name, def) | 声明「谁会往这个通道注入输入」。**凡是用 InjectText*/InjectInput*/InjectInterrupt*(source, "", ...) 注入的通道名都要登记** | +| 出站 output | RegisterOutputChannel(name, caps, desc, def, handler) | 声明 output_send__ 的回复发给谁;handler 收到 {payload,type,meta} | + +def(ChannelDef)描述该通道在记忆计算层的行为:NoMemory: true = 该通道输入不进记忆; +Cleaner = 计算层清洗后再向量化/提关键词(原文不改)。 + +> 只登记出站通道、却用同名通道注入输入时,内核会兜底登记同名 inputch 并在日志里告警。 +> 兜底只为兼容老插件 —— 请显式登记,让「这是入站通道」成为插件的明确意图。 + ## Install Upload the .hmap file through the Plugin Manager API.