feat(examples): 内置插件外置化 —— cmd/localuse/mcp 迁入 example + files 新增按行号精确编辑

## 背景

主仓按「只有必须使用内部 SDK 能力的插件才内置」的判定标准(见主仓
ARCHITECTURE「内置插件的判定标准」)把纯公开 SDK 插件移出编译期内置清单。
本仓为它们提供外部插件工程(example/ 下,hmapdev 可直接构建)。

★ 终审翻案:kbtree 与 multimodal **不**在本次外置之列 —— 逐方法对照
公开 SDK 后发现它们依赖内部专有能力(kbtree 要 KnowledgeAPI 的
Subtree/CategoryCounts/SearchIn/Categories 与树视图类型;multimodal 要
LLMAPI.Chat 与多模态消息类型),公开面没有,已加回主仓内置清单。

## 新增工程(全部对齐当前模板 + 随用随起契约)

| 工程 | 工具 | auto_start | 说明 |
|---|---|---|---|
| cmd | cmd_run | false(按需) | 沙箱执行 shell 命令 |
| localuse | screensee/screensue/camerasue/speakeruse/clipboardsee/clipboardsue/computeruse | false(按需) | 本机外设 |
| mcp | mcp_add/list/remove/restart_server | false(按需) | MCP 服务器接入(stdio/SSE) |

每工程:plg.json(含 sdk 字段声明)+ skill/<name>-tools/SKILL.md +
README。按 auto_start 契约(sdk/autostart.go 的 OnDemandNeedsSkill),
**auto_start=false 必须配 skill**,否则工具不出现在初始清单 ⇒ 模型不知道
它存在 ⇒ 永远不会被拉起,等于没装还占地方。

## 移植要点

- 注册:内置版 init()+RegisterFactory → 外部版导出 NewPluginFactory;
  RegisterPluginMeta 的显示名进 plg.json/plugin.json。
- mcp:本地 rpcRequest/rpcResponse/rpcError 与 hmapdev 生成的
  z_proc_gen.go 同名冲突(构建期生成物与本目录源码同包编译),
  本地类型改名 mcpRPC*;internal/safeclose 内联为 safeCloseChan
  (外部插件不能引 internal 包,close-of-closed-channel 纪律随之带过来)。
- module 名:cmd 的 go.mod 用 homeagent-cmd(避免与标准库 cmd 混淆);
  name_en 对齐插件名,hmapdev 以 toSnake(name_en) 命名产物
  (此前误写 Command/Local,产物成了 command_/local_)。

## files:新增 files_edit_line(按行号精确编辑)

files_edit 要求 old 文本全文件唯一,改「第三个 case 的 break」这类
非唯一文本时只能退化为整文件重写(有损)。files_edit_line 按行号
定位,op=replace/insert/delete,支持:
- 多条 edit 从后往前应用(前面行号不漂移)
- expect 安全闸:行内容与预期不符则整体拒绝(防行号过期改错位置)
- CRLF/末尾换行保真

skill 同步补 edit_line 行。测试 5 条(含 expect 拒绝路径)。

## 验证

hmapdev build 四工程全过:cmd/localuse/mcp/files 产物齐;
go test files 全绿。
This commit is contained in:
root
2026-10-19 22:01:07 +08:00
parent bea410f891
commit e849f39663
22 changed files with 2348 additions and 3 deletions

115
example/mcp/sse.go Normal file
View File

@ -0,0 +1,115 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
)
// SSETransport 通过 HTTP POST 进行 JSON-RPC 通信。
// 兼容两种服务端响应:application/json 直连响应,以及 Streamable HTTP
// 的异步响应(HTTP 202 + text/event-stream 的 SSE data 帧)。
type SSETransport struct {
url string
client *http.Client
}
func NewSSETransport(url string) *SSETransport {
// 必须带超时:远程 MCP server 网络抖动/无响应时,
// 无超时的 client 会让插件加载永久阻塞(webui 等后续插件全部起不来)
return &SSETransport{
url: url,
client: &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
},
},
}
}
// parseResponseBody 根据 Content-Type 解析 JSON-RPC 响应
func parseResponseBody(contentType string, body []byte) (*mcpRPCResponse, error) {
if strings.Contains(contentType, "text/event-stream") {
// SSE 流:逐行提取 data: 帧
var lastJSON []byte
for _, line := range strings.Split(string(body), "\n") {
line = strings.TrimRight(line, "\r")
if strings.HasPrefix(line, "data:") {
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data == "" || data == "[DONE]" {
continue
}
var frame map[string]json.RawMessage
if err := json.Unmarshal([]byte(data), &frame); err == nil {
if _, isResp := frame["id"]; isResp || frame["result"] != nil || frame["error"] != nil {
lastJSON = []byte(data)
}
}
}
}
if len(lastJSON) == 0 {
return nil, fmt.Errorf("SSE 流中未找到 JSON-RPC 响应帧: %s", truncate(string(body), 300))
}
body = lastJSON
}
var rpcResp mcpRPCResponse
if err := json.Unmarshal(body, &rpcResp); err != nil {
return nil, fmt.Errorf("unmarshal response: %w, body=%s", err, truncate(string(body), 300))
}
return &rpcResp, nil
}
func truncate(s string, n int) string {
if len(s) > n {
return s[:n] + "..."
}
return s
}
func (t *SSETransport) Send(req *mcpRPCRequest) (*mcpRPCResponse, error) {
data, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshal: %w", err)
}
httpReq, err := http.NewRequest("POST", t.url, bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("http request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "application/json, text/event-stream")
resp, err := t.client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("http post: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
ct := resp.Header.Get("Content-Type")
// 非 2xx:尝试提取错误信息
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("http status %d: %s", resp.StatusCode, truncate(string(body), 300))
}
return parseResponseBody(ct, body)
}
func (t *SSETransport) Close() error {
t.client.CloseIdleConnections()
return nil
}