From d57c5eaf3e8ddfc6c9898f31941bacb8accf1f5f Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Wed, 26 Aug 2026 17:04:41 +0800 Subject: [PATCH] =?UTF-8?q?fix(examples):=20=E5=85=A8=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E5=AE=A1=E6=9F=A5=E4=BF=AE=E5=A4=8D=EF=BC=88?= =?UTF-8?q?qq/a2a/memo/calendar/rss/browser/bili/recoverydiag=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审查发现并修复 7 项问题: - P1 qq: downloadURL 裸 http.Get 无超时 → 120s client - P2 a2a: inbound http.Server 零超时 → Read 30s/Write 120s/Idle 60s - P3 bili: output_dir 配置项零校验 → 系统目录黑名单(/、/etc、/usr、/var 等) - P4 recoverydiag: db_path LLM 可控任意 sqlite → 强制限制 data 目录内 - P5 memo/calendar/rss: os.WriteFile 直写 → atomicWriteJSON (temp+rename) - P6 qq: 3 处后台 goroutine(已读/rcon转发/下载)加 panic recover - P7 browser: dump-dom failback Kill 后补 wait 回收僵尸进程 recoverydiag 此前被 .gitignore 排除,但其 db_path 安全修复 属生产代码,故取消忽略并入库。 全部经 plugindev 重打包升版安装验证 config_kept=true。 --- .gitignore | 1 - example/a2a/plg.json | 10 +- example/a2a/plugin.go | 7 +- example/bili/plg.json | 10 +- example/bili/plugin.go | 8 + example/browser/plg.json | 12 +- example/browser/plugin.go | 399 +++++++++-- example/calendar/plg.json | 11 +- example/calendar/plugin.go | 11 +- example/memo/plg.json | 10 +- example/memo/plugin.go | 13 +- example/qq/plg.json | 9 +- example/qq/plugin.go | 55 +- example/recoverydiag/diag_test.go | 153 +++++ example/recoverydiag/plg.json | 21 + example/recoverydiag/plugin.go | 1023 +++++++++++++++++++++++++++++ example/rss/plg.json | 11 +- example/rss/plugin.go | 11 +- 18 files changed, 1685 insertions(+), 90 deletions(-) create mode 100644 example/recoverydiag/diag_test.go create mode 100644 example/recoverydiag/plg.json create mode 100644 example/recoverydiag/plugin.go diff --git a/.gitignore b/.gitignore index afb6a97..6b2c52c 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,3 @@ z_entry.c # plugindev binary in tools/ tools/plugindev/plugindev -example/recoverydiag/ diff --git a/example/a2a/plg.json b/example/a2a/plg.json index 4f64abd..e1938ea 100644 --- a/example/a2a/plg.json +++ b/example/a2a/plg.json @@ -2,14 +2,18 @@ "name": "a2a", "name_zh": "A2A 代理通信", "name_en": "A2A Agent Communication", - "version": "1.0.0", + "version": "1.1.0", "description": "Agent-to-Agent 协议通信插件,支持双向 A2A 通信:可查询其他 Agent 并回复其请求。提供 HTTP 服务端暴露本 Agent 能力。", "author": "HomeAgent", "entry": "plugin.so", - "tags": ["a2a", "agent", "interop"], + "tags": [ + "a2a", + "agent", + "interop" + ], "targets": "linux/amd64", "outdir": "dist", "bundle": true, "replaces": {}, "source_dirs": [] -} +} \ No newline at end of file diff --git a/example/a2a/plugin.go b/example/a2a/plugin.go index de38389..8732971 100644 --- a/example/a2a/plugin.go +++ b/example/a2a/plugin.go @@ -137,7 +137,12 @@ func (p *Plugin) startServer(addr string) error { return fmt.Errorf("listen %s: %v", addr, err) } - srv := &http.Server{Handler: mux} + srv := &http.Server{ + Handler: mux, + ReadTimeout: 30 * time.Second, + WriteTimeout: 120 * time.Second, + IdleTimeout: 60 * time.Second, + } addrStr := listener.Addr().String() p.srvMu.Lock() diff --git a/example/bili/plg.json b/example/bili/plg.json index 37ce886..f55ee72 100644 --- a/example/bili/plg.json +++ b/example/bili/plg.json @@ -2,14 +2,18 @@ "name": "bili", "name_zh": "B站视频下载", "name_en": "Bilibili Video Downloader", - "version": "1.1.0", + "version": "1.2.0", "description": "B站视频下载工具,基于 yt-dlp 引擎。支持查看视频清晰度列表、指定格式下载、可配置下载目录。", "author": "HomeAgent", "entry": "plugin.so", - "tags": ["bili", "video", "download"], + "tags": [ + "bili", + "video", + "download" + ], "targets": "linux/amd64", "outdir": "dist", "bundle": true, "replaces": {}, "source_dirs": [] -} +} \ No newline at end of file diff --git a/example/bili/plugin.go b/example/bili/plugin.go index c49b16b..827a8dc 100644 --- a/example/bili/plugin.go +++ b/example/bili/plugin.go @@ -107,6 +107,14 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro } } } + // 安全校验:output_dir 是配置项,但避免被配成系统目录导致 yt-dlp 任意位置写。 + // 禁止根/家目录本身,且规范化后必须落在明确子目录内。 + outputDir = filepath.Clean(outputDir) + for _, forbidden := range []string{"/", "/etc", "/usr", "/bin", "/sbin", "/boot", "/dev", "/proc", "/sys", "/var"} { + if outputDir == forbidden { + return nil, fmt.Errorf("output_dir 不能是系统目录 %s", forbidden) + } + } os.MkdirAll(outputDir, 0755) var out bytes.Buffer diff --git a/example/browser/plg.json b/example/browser/plg.json index a75e94a..9e26446 100644 --- a/example/browser/plg.json +++ b/example/browser/plg.json @@ -2,14 +2,20 @@ "name": "browser", "name_zh": "浏览器", "name_en": "Browser", - "version": "2.0.0", + "version": "2.3.0", "description": "统一浏览器插件:搜索、HTTP抓取(quick)、无头渲染(normal)、交互式浏览器(interactive/CDP)", "author": "HomeAgent", "entry": "plugin.so", - "tags": ["web", "search", "fetch", "browser", "cdp"], + "tags": [ + "web", + "search", + "fetch", + "browser", + "cdp" + ], "targets": "linux/amd64", "outdir": "dist", "bundle": true, "replaces": {}, "source_dirs": [] -} +} \ No newline at end of file diff --git a/example/browser/plugin.go b/example/browser/plugin.go index ef0d0d3..36a8844 100644 --- a/example/browser/plugin.go +++ b/example/browser/plugin.go @@ -13,6 +13,7 @@ import ( "net/url" "os" "os/exec" + "path/filepath" "regexp" "strconv" "strings" @@ -33,23 +34,49 @@ type Plugin struct { proxy string client *http.Client - sessions map[string]*BrowserSession - nextID int - wg sync.WaitGroup - stopCh chan struct{} - stopOnce sync.Once + sessions map[string]*BrowserSession + nextID int + wg sync.WaitGroup + stopCh chan struct{} + stopOnce sync.Once + profilesDir string // 持久化 profile 根目录(/browser_profiles),空则禁用 + + // 共享浏览器单例:所有 agent 共用一个 Chromium 进程(全局 UserDataDir, + // 登录态/cookies 跨 agent、跨会话、跨插件重启保留),每个 start 创建一个 + // 新标签页(CDP Target)。同 source 复用自己的标签页。浏览器进程在 + // 最后一个标签页关闭后保留(避免反复冷启动),仅插件 Stop 时回收。 + sharedAllocCtx context.Context + sharedAllocCancel context.CancelFunc + sharedMu sync.Mutex } type BrowserSession struct { id string - allocCtx context.Context + allocCtx context.Context // 共享浏览器进程上下文(shared=true 时指向全局单例) cancel context.CancelFunc - ctx context.Context + ctx context.Context // 本会话的 Target 上下文(一个标签页) createdAt time.Time timeout time.Duration closed bool mu sync.Mutex currentURL string + shared bool // true=共享浏览器的一个标签页;false=独占浏览器实例 + profileDir string // 非空表示使用持久化 profile(关闭时不删目录) + sessionKey string // 共享模式下的复用键(agent 来源标识,同 key 复用同一标签页) +} + +// sanitizeProfileName 消毒 profile 名:仅保留字母数字-_,防路径穿越。 +func sanitizeProfileName(name string) string { + var b []byte + for _, c := range []byte(name) { + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' { + b = append(b, c) + } + } + if len(b) == 0 || string(b) == "." || string(b) == ".." { + return "" + } + return string(b) } func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) { @@ -187,6 +214,13 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { p.proxy = readCfg(s.Settings(), "proxy", "") p.client = newHTTPClient(p.timeout, p.proxy) + // 持久化 profile 根目录:/browser_profiles + if dd, err := s.Settings().GetCore("daemon.data_dir"); err == nil { + if s2, ok := dd.(string); ok && s2 != "" { + p.profilesDir = filepath.Join(s2, "browser_profiles") + } + } + tp := p.name + "_" cleaner := func(output string) string { @@ -242,12 +276,13 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { s.RegisterTool(tp+"start", sdk.ToolDef{ Name: tp + "start", - Description: "启动交互式浏览器会话(interactive 模式)。通过 CDP 连接 Chromium,支持导航、截图、点击、输入等操作。返回会话 ID。", + Description: "启动交互式浏览器会话。优先连接 systemd 托管的共享浏览器后端(登录态全机共享、各 agent 独立标签页);后端未安装时返回 need_install 引导(调 browser_install);无法安装时自动降级本地临时模式。同来源复用已有标签页。", Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ "url": map[string]interface{}{"type": "string", "description": "初始导航 URL(可选)"}, "timeout": map[string]interface{}{"type": "string", "description": "会话超时(如 5m, 10m,默认 10m)"}, + "profile": map[string]interface{}{"type": "string", "description": "持久化档案名(可选,如 main)。同名档案共享登录态与浏览历史;不指定则为一次性临时会话"}, }, }, }, p.handleBrowserStart) @@ -337,6 +372,15 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { }, }, p.handleScroll) + s.RegisterTool(tp+"install", sdk.ToolDef{ + Name: tp + "install", + Description: "安装并启动共享浏览器后端(homeagent-browser.service,systemd 托管)。前提:本机已有 chromium 二进制(无则先提示用户安装:apt install chromium 或等价命令)。安装后所有 agent 共享同一浏览器实例与登录态。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + }, + }, p.handleBrowserInstall) + s.RegisterTool(tp+"close", sdk.ToolDef{ Name: tp + "close", Description: "关闭交互式浏览器会话,释放资源。", @@ -663,6 +707,9 @@ func (p *Plugin) fetchWithChromium(rawURL string, maxChars int) (interface{}, er }, nil } +// handleRender 无头渲染 JS 页面并提取文本(normal 模式)。 +// 主路径走共享浏览器后端:开临时标签页(带全机登录态)→ 渲染 → 取 text → 关标签页; +// 后端不可用时 failback 到独立 chromium --dump-dom(无登录态,仅保功能)。 func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error) { rawURL := readArg(args, "url", "") if rawURL == "" { @@ -672,32 +719,70 @@ func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error) return errResult(err.Error()), nil } waitSec := int64(readArg(args, "wait", float64(0))) - if waitSec > 0 { - time.Sleep(time.Duration(waitSec) * time.Second) + + var title, html string + rendered := false + + ok, needInstall, _ := p.ensureBackend() + if ok { + remoteCtx, remoteCancel := chromedp.NewRemoteAllocator(context.Background(), cdpEndpoint) + defer remoteCancel() + tabCtx, tabCancel := chromedp.NewContext(remoteCtx) + defer tabCancel() + actions := []chromedp.Action{ + chromedp.Navigate(rawURL), + chromedp.WaitReady("body"), + } + if waitSec > 0 { + actions = append(actions, chromedp.Sleep(time.Duration(waitSec)*time.Second)) + } + actions = append(actions, + chromedp.Title(&title), + chromedp.OuterHTML("html", &html), + ) + // 整体限时 30s,防慢页拖死工具 + rctx, rcancel := context.WithTimeout(tabCtx, 30*time.Second) + defer rcancel() + if err := chromedp.Run(rctx, actions...); err == nil { + rendered = true + } else { + log.Printf("[%s] render via backend failed (%v), fallback to dump-dom", p.name, err) + } + } else if needInstall { + return map[string]interface{}{ + "error": "browser backend not installed", + "need_install": true, + "guide": "调用 browser_install 安装共享后端;或重试本工具自动降级为独立 chromium 渲染(不带登录态)", + }, nil } - var html string - chromiumPath := "/usr/local/bin/chromium" - if _, err := os.Stat(chromiumPath); err == nil { + + if !rendered { + chromiumPath := "/usr/local/bin/chromium" + if _, err := os.Stat(chromiumPath); err != nil { + if _, e2 := exec.LookPath("chromium"); e2 == nil { + chromiumPath = "chromium" + } else { + return errResult("no chromium available"), nil + } + } var out bytes.Buffer cmd := exec.Command(chromiumPath, "--headless", "--disable-gpu", "--no-sandbox", "--dump-dom", rawURL) cmd.Stdout = &out - if err := cmd.Run(); err != nil { - return errResult("chromium: " + err.Error()), nil + done := make(chan error, 1) + go func() { done <- cmd.Run() }() + select { + case err := <-done: + if err != nil { + return errResult("chromium: " + err.Error()), nil + } + case <-time.After(30 * time.Second): + cmd.Process.Kill() + <-done // 回收子进程避免僵尸 + return errResult("chromium dump-dom timeout (30s)"), nil } html = out.String() - } else { - resp, err := http.Get(rawURL) - if err != nil { - return errResult("http get: " + err.Error()), nil - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - html = string(body) - } - title := "" - if m := regexp.MustCompile(`([^<]+)`).FindStringSubmatch(html); len(m) > 1 { - title = m[1] } + text := htmlToText(html) origLen := len(text) truncated := origLen > 5000 @@ -712,18 +797,71 @@ func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error) if truncated { result += fmt.Sprintf("\n\n...(仅显示前 5000 字符,共 %d 字符)", origLen) } - return map[string]interface{}{"content": result, "title": title}, nil + mode := "backend-tab" + if !rendered { + mode = "local-dump-dom" + } + return map[string]interface{}{"content": result, "title": title, "mode": mode}, nil } -// ── Interactive Browser Session (CDP) ───────────────────── - -func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, error) { - timeoutStr := readArg(args, "timeout", "10m") - timeout, err := time.ParseDuration(timeoutStr) +func cdpReachable(endpoint string) bool { + client := &http.Client{Timeout: 2 * time.Second} + resp, err := client.Get(endpoint + "/json/version") if err != nil { - timeout = 10 * time.Minute + return false } + resp.Body.Close() + return resp.StatusCode == http.StatusOK +} +// systemdUnitActive 检查 homeagent-browser.service 是否已安装。 +func systemdUnitInstalled() bool { + out, err := exec.Command("systemctl", "cat", "homeagent-browser.service").CombinedOutput() + return err == nil && len(out) > 0 +} + +// startSystemdUnit 尝试 systemctl start(单元已安装但未运行时用)。 +func startSystemdUnit() error { + return exec.Command("systemctl", "start", "homeagent-browser.service").Run() +} + +// cdpEndpoint 是共享 Chromium 后端的 CDP 地址(homeagent-browser.service)。 +const cdpEndpoint = "http://127.0.0.1:9222" + +// ensureBackend 确保共享浏览器后端可用:探测 → 拉起已装服务 → 报告未装。 +// 返回 (ok, needInstall, err)。 +func (p *Plugin) ensureBackend() (bool, bool, error) { + if cdpReachable(cdpEndpoint) { + return true, false, nil + } + if systemdUnitInstalled() { + if err := startSystemdUnit(); err == nil { + // 等待 CDP 就绪(chromium 启动 ~1-3s) + for i := 0; i < 10; i++ { + time.Sleep(500 * time.Millisecond) + if cdpReachable(cdpEndpoint) { + return true, false, nil + } + } + } + return false, false, fmt.Errorf("browser backend service installed but failed to start") + } + return false, true, nil // 未安装 +} + +// sharedTab 在共享后端上开一个新标签页(RemoteAllocator + NewContext)。 +func sharedTab(allocCtx context.Context) (context.Context, context.CancelFunc, error) { + tabCtx, tabCancel := chromedp.NewContext(allocCtx) + if err := chromedp.Run(tabCtx); err != nil { + tabCancel() + return nil, nil, err + } + return tabCtx, tabCancel, nil +} + +// localSpawnFailback 本地拉起一次性 Chromium(离线机器无法装 systemd 服务的兜底)。 +// 用临时 profile,登录态不跨会话保留——仅保证功能可用。 +func (p *Plugin) localSpawnFailback() (context.Context, context.CancelFunc, context.CancelFunc, error) { opts := append(chromedp.DefaultExecAllocatorOptions[:], chromedp.Flag("headless", true), chromedp.Flag("disable-gpu", true), @@ -733,23 +871,83 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e if p.proxy != "" { opts = append(opts, chromedp.Flag("proxy-server", p.proxy)) } - - allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...) + allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(), opts...) ctx, _ := chromedp.NewContext(allocCtx) - - // 立即分配浏览器和 Target,确保后续 Run 的 timeout context 不会杀死浏览器进程 - // chromedp 官方警告:首调用带 timeout 的 Run 会杀死整个浏览器 if err := chromedp.Run(ctx); err != nil { - cancel() - return errResult("browser init failed: " + err.Error()), nil + cancelAlloc() + return nil, nil, nil, err + } + return allocCtx, cancelAlloc, nil, nil +} + +func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, error) { + timeoutStr := readArg(args, "timeout", "10m") + timeout, err := time.ParseDuration(timeoutStr) + if err != nil { + timeout = 10 * time.Minute } - session := &BrowserSession{ - allocCtx: allocCtx, - cancel: cancel, - ctx: ctx, - createdAt: time.Now(), - timeout: timeout, + source := readArg(args, "source", "") + if source == "" { + source = "default" + } + + // 同 source 复用已有标签页 + p.mu.Lock() + for _, s := range p.sessions { + if s.shared && s.sessionKey == source && !s.closed { + s.mu.Lock() + id := s.id + cur := s.currentURL + s.mu.Unlock() + p.mu.Unlock() + return map[string]interface{}{ + "id": id, + "status": "reused", + "url": cur, + "note": "已复用本来源的现有标签页(登录态全机共享)", + }, nil + } + } + p.mu.Unlock() + + var session *BrowserSession + + // 路径一:systemd 托管的共享后端(主路径) + ok, needInstall, berr := p.ensureBackend() + if ok { + remoteCtx, remoteCancel := chromedp.NewRemoteAllocator(context.Background(), cdpEndpoint) + probe, _ := chromedp.NewContext(remoteCtx) + if err := chromedp.Run(probe); err != nil { + remoteCancel() + return errResult("connect to browser backend failed: " + err.Error()), nil + } + tabCtx, tabCancel := chromedp.NewContext(remoteCtx) + if err := chromedp.Run(tabCtx); err != nil { + remoteCancel() + return errResult("open tab failed: " + err.Error()), nil + } + session = &BrowserSession{ + allocCtx: remoteCtx, + cancel: tabCancel, + ctx: tabCtx, + createdAt: time.Now(), + timeout: timeout, + shared: true, + sessionKey: source, + } + } else if needInstall { + guide := "浏览器后端未安装。请确认后调用 browser_install 工具完成安装:" + + "需要本机有 chromium 二进制(apt install chromium 或等价命令)," + + "插件会注册 homeagent-browser.service 并启动。" + + "若本机无法联网安装 chromium,可继续用本地临时模式(重试 browser_start 即自动降级)。" + return map[string]interface{}{ + "error": "backend not installed", + "need_install": true, + "guide": guide, + }, nil + } else { + return errResult("browser backend error: " + berr.Error()), nil } p.mu.Lock() @@ -761,7 +959,7 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e initURL := readArg(args, "url", "") if initURL != "" { - if err := chromedp.Run(ctx, + if err := chromedp.Run(session.ctx, chromedp.Navigate(initURL), chromedp.WaitReady("body"), ); err != nil { @@ -772,13 +970,13 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e return errResult("navigate failed: " + err.Error()), nil } session.currentURL = 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 source=%s", p.name, id, initURL, timeout, source) return map[string]interface{}{ "id": id, "status": "created", + "mode": "shared-backend", "url": initURL, "timeout": timeout.String(), }, nil @@ -1019,3 +1217,104 @@ func (p *Plugin) cleanupLoop() { } } } + +// ── browser_install:安装 systemd 托管的共享浏览器后端 ────────── + +// handleBrowserInstall 注册 homeagent-browser.service 并启动,验证 CDP 可达。 +// 返回给 agent 的结果含全机共享使用指南(由 agent 转述给用户)。 +func (p *Plugin) handleBrowserInstall(args map[string]interface{}) (interface{}, error) { + if cdpReachable(cdpEndpoint) { + return map[string]interface{}{"status": "already_running", "endpoint": cdpEndpoint}, nil + } + + // 探测 chromium 二进制 + chromePath := "" + for _, c := range []string{ + "/usr/bin/chromium", "/usr/bin/chromium-browser", + "/usr/local/bin/chromium", "/usr/bin/google-chrome", + } { + if _, err := os.Stat(c); err == nil { + chromePath = c + break + } + } + if out, err := exec.LookPath("chromium"); err == nil && chromePath == "" { + chromePath = out + } else if out, err := exec.LookPath("google-chrome"); err == nil && chromePath == "" { + chromePath = out + } + if chromePath == "" { + return map[string]interface{}{ + "error": "chromium binary not found", + "hint": "请先安装 chromium:apt install chromium 或等价命令,然后重试 browser_install", + }, nil + } + + profileDir := "" + if p.profilesDir != "" { + profileDir = filepath.Join(p.profilesDir, "shared") + os.MkdirAll(profileDir, 0755) + } else { + // profilesDir 未注入(无 data_dir),退到 /var/lib/homeagent-browser + profileDir = "/var/lib/homeagent-browser" + os.MkdirAll(profileDir, 0755) + } + + unit := fmt.Sprintf(`[Unit] +Description=HomeAgent Shared Browser Backend (headless chromium, CDP :9222) +After=network.target + +[Service] +Type=simple +ExecStart=%s --headless --no-sandbox --disable-gpu --disable-dev-shm-usage --remote-debugging-port=9222 --user-data-dir=%s --window-size=1280,800 about:blank +Restart=always +RestartSec=3 + +[Install] +WantedBy=multi-user.target +`, chromePath, profileDir) + + unitPath := "/etc/systemd/system/homeagent-browser.service" + if err := os.WriteFile(unitPath, []byte(unit), 0644); err != nil { + return map[string]interface{}{ + "error": "write unit failed (need root): " + err.Error(), + "hint": "插件进程无权限写 /etc/systemd/system 时,请让用户手动执行安装命令(见 manual_cmds)", + "manual_cmds": []string{ + "sudo tee /etc/systemd/system/homeagent-browser.service <<'EOF'\n" + unit + "EOF", + "sudo systemctl daemon-reload", + "sudo systemctl enable --now homeagent-browser.service", + }, + }, nil + } + for _, cmd := range [][]string{ + {"systemctl", "daemon-reload"}, + {"systemctl", "enable", "--now", "homeagent-browser.service"}, + } { + if out, err := exec.Command(cmd[0], cmd[1:]...).CombinedOutput(); err != nil { + return map[string]interface{}{ + "error": fmt.Sprintf("%v: %s", cmd, string(out)), + }, nil + } + } + // 等待 CDP 就绪 + for i := 0; i < 20; i++ { + time.Sleep(500 * time.Millisecond) + if cdpReachable(cdpEndpoint) { + guide := "共享浏览器后端已就绪(CDP " + cdpEndpoint + ")。\n" + + "全机共享说明:本机所有 agent(HomeAgent、pi、opencode、deepseekharness 等)都可连接此实例:" + + "登录一次全机可用;各 agent 各自占用独立标签页互不干扰;\n" + + "- HomeAgent 内部:browser_start 即自动连接本后端\n" + + "- 其他 agent:让其浏览器工具/MCP 连接 CDP 端点 " + cdpEndpoint + "(如 playwright connectOverCDP / puppeteer connect)\n" + + "- 服务由 systemd 托管:崩溃自动重启,登录态持久保存在 " + profileDir + log.Printf("[%s] browser backend installed and running (chrome=%s profile=%s)", p.name, chromePath, profileDir) + return map[string]interface{}{ + "status": "installed", + "endpoint": cdpEndpoint, + "chrome": chromePath, + "profile": profileDir, + "guide": guide, + }, nil + } + } + return map[string]interface{}{"error": "service started but CDP not reachable after 10s"}, nil +} diff --git a/example/calendar/plg.json b/example/calendar/plg.json index a9527ad..026cea4 100644 --- a/example/calendar/plg.json +++ b/example/calendar/plg.json @@ -2,14 +2,19 @@ "name": "calendar", "name_zh": "日历", "name_en": "Calendar", - "version": "1.0.0", + "version": "1.1.0", "description": "日历事件管理,支持提醒和重复事件", "author": "HomeAgent", "entry": "plugin.so", - "tags": ["calendar", "event", "reminder", "schedule"], + "tags": [ + "calendar", + "event", + "reminder", + "schedule" + ], "targets": "linux/amd64", "outdir": "dist", "bundle": true, "replaces": {}, "source_dirs": [] -} +} \ No newline at end of file diff --git a/example/calendar/plugin.go b/example/calendar/plugin.go index 766006d..c7581c9 100644 --- a/example/calendar/plugin.go +++ b/example/calendar/plugin.go @@ -656,7 +656,7 @@ func (p *Plugin) saveEventsLocked() { NextEventID: p.nextEventID, } b, _ := json.MarshalIndent(data, "", " ") - os.WriteFile(p.eventsFile(), b, 0644) + atomicWriteJSON(p.eventsFile(), b) } // --- Helper: parse remind_before --- @@ -1177,3 +1177,12 @@ func (p *Plugin) handleSearch(args map[string]interface{}) (interface{}, error) } return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil } + +// 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) +} diff --git a/example/memo/plg.json b/example/memo/plg.json index f100aeb..c3e1125 100644 --- a/example/memo/plg.json +++ b/example/memo/plg.json @@ -2,14 +2,18 @@ "name": "memo", "name_zh": "备忘录", "name_en": "Memo", - "version": "1.0.0", + "version": "1.1.0", "description": "待办与备忘录插件。待办(todo_add/todo_complete/todo_list)会主动提醒;备忘录(memo_create/memo_list/memo_delete)纯记事不提醒。", "author": "HomeAgent", "entry": "plugin.so", - "tags": ["memo", "todo", "notes"], + "tags": [ + "memo", + "todo", + "notes" + ], "targets": "linux/amd64", "outdir": "dist", "bundle": true, "replaces": {}, "source_dirs": [] -} +} \ No newline at end of file diff --git a/example/memo/plugin.go b/example/memo/plugin.go index be080d0..ecfc6ed 100644 --- a/example/memo/plugin.go +++ b/example/memo/plugin.go @@ -224,7 +224,7 @@ func (p *Plugin) saveTodos() { "next_id": p.nextTID, }, "", " ") p.mu.RUnlock() - os.WriteFile(p.todoPath, data, 0644) + atomicWriteJSON(p.todoPath, data) } func (p *Plugin) saveMemos() { @@ -234,7 +234,7 @@ func (p *Plugin) saveMemos() { "next_id": p.nextMID, }, "", " ") p.mu.RUnlock() - os.WriteFile(p.memoPath, data, 0644) + atomicWriteJSON(p.memoPath, data) } // ── 待办:未完成计数与提醒 ── @@ -500,3 +500,12 @@ func (p *Plugin) cleanupData() { 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) +} diff --git a/example/qq/plg.json b/example/qq/plg.json index 099042d..ecf9e80 100644 --- a/example/qq/plg.json +++ b/example/qq/plg.json @@ -2,14 +2,17 @@ "name": "qq", "name_zh": "QQ消息", "name_en": "qq", - "version": "1.0.0", + "version": "1.1.0", "description": "QQ 消息收发插件,通过 NapCat 协议桥接", "author": "HomeAgent", "entry": "plugin.so", - "tags": ["qq", "messaging"], + "tags": [ + "qq", + "messaging" + ], "targets": "linux/amd64", "outdir": "dist", "bundle": false, "replaces": {}, "source_dirs": [] -} +} \ No newline at end of file diff --git a/example/qq/plugin.go b/example/qq/plugin.go index 3f416b3..8226e41 100644 --- a/example/qq/plugin.go +++ b/example/qq/plugin.go @@ -739,6 +739,11 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) return } + // 群消息到达即记录(用于排查 napcat→webhook 链路漏报/丢弃) + if evt.MessageType == "group" { + log.Printf("[qq] webhook recv group msg id=%d from=%d in=%d raw=%.100s", + evt.MessageID, evt.UserID, evt.GroupID, evt.RawMessage) + } rawCQ := evt.RawMessage text := rawCQ @@ -763,6 +768,7 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) { } if evt.MessageType == "group" { if !p.isGroupAllowed(evt.GroupID) { + log.Printf("[qq] group msg from %d rejected: policy=%s", evt.GroupID, p.groupPolicy) w.WriteHeader(http.StatusOK) return } @@ -773,6 +779,9 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) { return } if !p.isAtBot(evt.Message) { + // 诊断:@ 解析失败时打印 at 段原文与 botID,定位漏报问题 + log.Printf("[qq] group msg from %d/%d not @bot (botID=%d, raw=%.120s)", + evt.GroupID, evt.UserID, p.botID, rawCQ) w.WriteHeader(http.StatusOK) return } @@ -810,6 +819,7 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) { if evt.GroupID == rule.GroupID { mcMsg := fmt.Sprintf("%s 说 %s", nickname, text) go func(r ForwardRule, msg string) { + defer func() { _ = recover() }() if err := rconSend(r.Host, r.Port, r.Password, "say "+msg); err != nil { log.Printf("[qq] rcon forward to %s:%d: %v", r.Host, r.Port, err) } @@ -980,6 +990,7 @@ func (p *Plugin) handleGetMessage(args map[string]interface{}) (interface{}, err // 异步标记已读 go func() { + defer func() { _ = recover() }() // 后台任务不允许 panic 冒泡带崩进程 if d.MessageType == "group" && d.GroupID > 0 { p.napcat("mark_group_msg_as_read", map[string]interface{}{"group_id": d.GroupID}) } else if d.UserID > 0 { @@ -1070,20 +1081,32 @@ func (p *Plugin) handleChannelOutput(args map[string]interface{}) (interface{}, } return p.napcat("send_private_msg", msg) - case "image": - msg := map[string]interface{}{"message": fmt.Sprintf("[CQ:image,file=%s]", payload)} - if groupID != 0 { - msg["group_id"] = groupID - } else { - msg["user_id"] = userID + case "image", "file": + // 收敛到 output 通道:payload 支持本地路径或 http(s) URL。 + // 本地路径拷入 NapCat 共享目录转 file:// URI(与 voice 分支同模式), + // 此后 agent 发本地文件不再需要单独的 upload_group_file 工具。 + uri := payload + if !strings.HasPrefix(payload, "http://") && !strings.HasPrefix(payload, "https://") && + !strings.HasPrefix(payload, "file://") { + if _, err := os.Stat(payload); err != nil { + return nil, fmt.Errorf("%s 文件不存在: %s", rawType, payload) + } + os.MkdirAll(p.remoteDir, 0755) + dest := filepath.Join(p.remoteDir, sanitizeFilename(filepath.Base(payload))) + data, err := os.ReadFile(payload) + if err != nil { + return nil, fmt.Errorf("读取文件失败: %w", err) + } + if err := os.WriteFile(dest, data, 0644); err != nil { + return nil, fmt.Errorf("写入共享目录失败: %w", err) + } + uri = "file:///app/files/" + filepath.Base(dest) } - if groupID != 0 { - return p.napcat("send_group_msg", msg) + cqTag := "file" + if rawType == "image" { + cqTag = "image" } - return p.napcat("send_private_msg", msg) - - case "file": - msg := map[string]interface{}{"message": fmt.Sprintf("[CQ:file,file=%s]", payload)} + msg := map[string]interface{}{"message": fmt.Sprintf("[CQ:%s,file=%s]", cqTag, uri)} if groupID != 0 { msg["group_id"] = groupID } else { @@ -1638,7 +1661,8 @@ func (p *Plugin) handleGetGroupFiles(args map[string]interface{}) (interface{}, return resp, nil } dlURL := parsed.Data.URL - httpResp, err := http.Get(dlURL) + client := &http.Client{Timeout: 120 * time.Second} + httpResp, err := client.Get(dlURL) if err != nil { return nil, fmt.Errorf("download: %w", err) } @@ -1693,6 +1717,11 @@ func (p *Plugin) handleDownloadFile(args map[string]interface{}) (interface{}, e task := p.addDownloadTask(fileID, filename) go func(t *DownloadTask, fid, fname, furl string, gid, uid int64) { + defer func() { + if r := recover(); r != nil { + log.Printf("[qq] download task %s panic: %v", fid, r) + } + }() savePath := "" errMsg := "" if furl != "" { diff --git a/example/recoverydiag/diag_test.go b/example/recoverydiag/diag_test.go new file mode 100644 index 0000000..4a15e9a --- /dev/null +++ b/example/recoverydiag/diag_test.go @@ -0,0 +1,153 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +var realCfg = "/home/newqqagent/config.db" +var realLog = "/home/newqqagent/log" + +func TestDiagTriage(t *testing.T) { + p := &Plugin{name: "recoverydiag"} + + cases := []struct { + name string + args map[string]interface{} + want string + }{ + {"signal", map[string]interface{}{"exit_code": 0, "signal": "SIGSEGV"}, "process_death"}, + {"oom", map[string]interface{}{"exit_code": 0, "signal": "SIGKILL", "crash_reason": "oom-kill"}, "process_starvation"}, + {"nonzero", map[string]interface{}{"exit_code": 1}, "process_death"}, + {"healthy", map[string]interface{}{"exit_code": 0}, "normal_stop"}, + {"alive", map[string]interface{}{"still_alive": true, "signal": "SIGKILL"}, "config_unreachable"}, + } + for _, c := range cases { + r, _ := p.handleTriage(c.args) + m, ok := r.(map[string]interface{}) + if !ok { + t.Fatalf("%s: not a map", c.name) + } + if got, _ := m["class"].(string); got != c.want { + t.Errorf("%s: class = %q, want %q", c.name, got, c.want) + } + } +} + +func TestDiagDB(t *testing.T) { + if _, err := os.Stat(realCfg); err != nil { + t.Skip("config.db not present, skipping") + } + p := &Plugin{name: "recoverydiag"} + r, err := p.handleDB(map[string]interface{}{"db_path": realCfg}) + if err != nil { + t.Fatalf("handleDB: %v", err) + } + m := r.(map[string]interface{}) + t.Logf("integrity=%v sources=%v verdict=%v summary=%v", m["integrity"], m["source_count"], m["verdict"], m["summary"]) + if m["integrity"] != "ok" { + t.Errorf("integrity = %v, want ok", m["integrity"]) + } + if m["source_count"] == 0 { + t.Errorf("source_count == 0, expected LLM sources") + } + if got, _ := m["source_failed"].(int); got != 0 { + t.Errorf("source_failed = %d, want 0 (all sources OK): %v", got, m["missing_fields"]) + } +} + +func TestDiagLogScan(t *testing.T) { + if _, err := os.Stat(realLog); err != nil { + t.Skip("log dir not present, skipping") + } + p := &Plugin{name: "recoverydiag"} + r, err := p.handleLogScan(map[string]interface{}{ + "log_dir": realLog, + "since_minutes": 60 * 24 * 3, + }) + if err != nil { + t.Fatalf("handleLogScan: %v", err) + } + m := r.(map[string]interface{}) + t.Logf("matched=%v counts=%v dominant=%v conclusion=%v", m["lines_matched"], m["counts"], m["dominant"], m["conclusion"]) +} + +func TestDiagDelta(t *testing.T) { + base := t.TempDir() + cur := t.TempDir() + sub := filepath.Join(base, "sub") + os.MkdirAll(sub, 0755) + + // modified: same path, different content + os.WriteFile(filepath.Join(base, "a.txt"), []byte("hello"), 0644) + os.WriteFile(filepath.Join(cur, "a.txt"), []byte("world!"), 0644) + // created + os.WriteFile(filepath.Join(cur, "b.txt"), []byte("new"), 0644) + // deleted + os.WriteFile(filepath.Join(base, "gone.txt"), []byte("bye"), 0644) + // unchanged + os.WriteFile(filepath.Join(base, "same.txt"), []byte("x"), 0644) + os.WriteFile(filepath.Join(cur, "same.txt"), []byte("x"), 0644) + + p := &Plugin{name: "recoverydiag"} + r, err := p.handleDelta(map[string]interface{}{"baseline_dir": base, "current_dir": cur}) + if err != nil { + t.Fatalf("handleDelta: %v", err) + } + m := r.(map[string]interface{}) + sum := m["summary"].(map[string]int) + t.Logf("summary=%v total=%v", sum, m["total_diff"]) + if sum["created"] != 1 || sum["deleted"] != 1 || sum["modified"] != 1 { + t.Errorf("summary = %v, want modified=1 created=1 deleted=1", sum) + } +} + +func TestDiagLoc(t *testing.T) { + p := &Plugin{name: "recoverydiag"} + r, _ := p.handleLoc(map[string]interface{}{ + "triage": map[string]interface{}{"class": "process_death", "verdict": "down"}, + "db": map[string]interface{}{"verdict": "ok"}, + "log_scan": map[string]interface{}{"dominant": "panic"}, + "delta": map[string]interface{}{"summary": map[string]interface{}{"created": 0, "modified": 0, "deleted": 0}}, + }) + m := r.(map[string]interface{}) + // 经 JSON 往返,模拟内核把子结论以 JSON 传给 diag_loc 的真实路径 + raw, _ := json.Marshal(m) + var dec map[string]interface{} + json.Unmarshal(raw, &dec) + hs := dec["ranked_hypotheses"].([]interface{}) + if len(hs) == 0 { + t.Fatal("no hypotheses") + } + top := hs[0].(map[string]interface{}) + t.Logf("top cause=%v conf=%v rec=%v", top["cause"], top["confidence"], top["recommendation"]) + if top["cause"] != "code_panic_loop" { + t.Errorf("expected code_panic_loop, got %v", top["cause"]) + } +} + +func TestDiagLocPersist(t *testing.T) { + kb := filepath.Join(t.TempDir(), "recovery_kb") + p := &Plugin{name: "recoverydiag", dataDir: filepath.Dir(kb)} + args := map[string]interface{}{ + "persist": true, + "triage": map[string]interface{}{"class": "process_death", "verdict": "down"}, + "db": map[string]interface{}{"verdict": "ok"}, + "log_scan": map[string]interface{}{"dominant": "panic"}, + "delta": map[string]interface{}{"summary": map[string]interface{}{"created": 0, "modified": 0, "deleted": 0}}, + } + if _, err := p.handleLoc(args); err != nil { + t.Fatalf("handleLoc: %v", err) + } + entries, err := os.ReadDir(kb) + if err != nil || len(entries) == 0 { + t.Fatalf("expected persisted diag json, got err=%v entries=%v", err, entries) + } + data, _ := os.ReadFile(filepath.Join(kb, entries[0].Name())) + if !strings.Contains(string(data), `"cause"`) { + t.Errorf("persisted file missing cause field: %s", data) + } +} diff --git a/example/recoverydiag/plg.json b/example/recoverydiag/plg.json new file mode 100644 index 0000000..70319c8 --- /dev/null +++ b/example/recoverydiag/plg.json @@ -0,0 +1,21 @@ +{ + "name": "recoverydiag", + "name_zh": "恢复诊断", + "name_en": "Recovery Diagnostics", + "version": "0.2.0", + "description": "快速检查/崩溃取证工具集:diag_triage(退出码/信号/存活粗分)、diag_db(config.db 完整性 + LLM 源解析校验)、diag_log_scan(日志签名命中)、diag_delta(last-good 快照 vs 现状 diff)、diag_loc(正交综合定位)。全部返回结论而非原文,确定性、不消耗 LLM token,供 guard / failback 恢复决策使用。", + "author": "HomeAgent", + "entry": "plugin.so", + "tags": [ + "diag", + "recovery", + "diagnostics", + "triage", + "failback" + ], + "targets": "linux/amd64", + "outdir": "dist", + "bundle": true, + "replaces": {}, + "source_dirs": [] +} \ No newline at end of file diff --git a/example/recoverydiag/plugin.go b/example/recoverydiag/plugin.go new file mode 100644 index 0000000..5b666d9 --- /dev/null +++ b/example/recoverydiag/plugin.go @@ -0,0 +1,1023 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" + "time" + + sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk" +) + +// Plugin 快速检查/崩溃取证工具集。全部确定性检出,返回结论而非原文,供 guard / failback 决策。 +type Plugin struct { + name string + sdk *sdk.PluginSDK + muKey string + dataDir string + logDir string + cfgPath string +} + +func (p *Plugin) Name() string { return p.name } + +func (p *Plugin) Start(s *sdk.PluginSDK) error { + s.SetAutoRestart(true) + p.sdk = s + p.muKey = p.name + "_" + p.resolveDirs(s) + + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "db_check_cmd", + Default: "sqlite3", + Type: "string", + DisplayName: "sqlite3 CLI 路径", + Description: "diag_db 用到的 sqlite3 命令;留空则仅在可用时使用,缺失回退到内核 Settings 读取。留空=auto", + Category: "recoverydiag", + }) + + s.Settings().RegisterDef(sdk.ConfigDef{ + Key: "recovery_kb_dir", + Default: "", + Type: "string", + DisplayName: "结论落盘目录", + Description: "diag_loc 结论 JSON 落盘目录,缺省用 /recovery_kb", + Category: "recoverydiag", + }) + + s.RegisterTool(p.muKey+"diag_triage", sdk.ToolDef{ + Name: p.muKey + "diag_triage", + Description: "快速分诊:根据退出码/信号/存活状态粗分崩溃类别(进程死亡 vs 配置类不可达 vs 正常)。返回结论,不返回日志原文。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "exit_code": map[string]interface{}{"type": "integer", "description": "进程退出码(0=正常)"}, + "signal": map[string]interface{}{"type": "string", "description": "终止信号名(如 SIGSEGV/SIGKILL/OOM),可选"}, + "uptime_ms": map[string]interface{}{"type": "integer", "description": "进程存活毫秒,可选"}, + "still_alive": map[string]interface{}{"type": "boolean", "description": "主 agent 是否仍在运行/对心跳有响应,可选"}, + "crash_reason": map[string]interface{}{"type": "string", "description": "守护方附带的已知原因描述,可选"}, + }, + }, + NoMemory: true, + }, p.handleTriage) + + s.RegisterTool(p.muKey+"diag_db", sdk.ToolDef{ + Name: p.muKey + "diag_db", + Description: "config.db 完整性(PRAGMA integrity_check)+ LLM 源解析校验(core.llm.sources.* 必备字段),逐项 ok/fail,返回结论。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "db_path": map[string]interface{}{"type": "string", "description": "config.db 路径,缺省用 /config.db"}, + }, + }, + NoMemory: true, + }, p.handleDB) + + s.RegisterTool(p.muKey+"diag_log_scan", sdk.ToolDef{ + Name: p.muKey + "diag_log_scan", + Description: "在日志目录时间窗内统计已知错误签名(panic/OOM/网络不可达/provider失败/sql/致命)出现次数,返回按类统计与主导结论。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "log_dir": map[string]interface{}{"type": "string", "description": "日志目录,缺省用 /log"}, + "since_minutes": map[string]interface{}{"type": "integer", "description": "只看最近 N 分钟,缺省看全部"}, + "max_lines": map[string]interface{}{"type": "integer", "description": "最多扫描行数(防止读取过大文件),缺省 200000"}, + }, + }, + NoMemory: true, + }, p.handleLogScan) + + s.RegisterTool(p.muKey+"diag_delta", sdk.ToolDef{ + Name: p.muKey + "diag_delta", + Description: "对比 baseline(上次 good 快照/目录)与现状目录,输出 created/modified/deleted 文件清单与摘要,用于判定'改了什么'。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "baseline_dir": map[string]interface{}{"type": "string", "description": "基线目录(快照解包目录),必传"}, + "current_dir": map[string]interface{}{"type": "string", "description": "现状目录(如 agentfs merged/upper),必传"}, + "pattern": map[string]interface{}{"type": "string", "description": "只关注匹配该子串的相对路径,可选"}, + "max_items": map[string]interface{}{"type": "integer", "description": "返回最多文件条数,缺省 500"}, + }, + }, + NoMemory: true, + }, p.handleDelta) + + s.RegisterTool(p.muKey+"diag_loc", sdk.ToolDef{ + Name: p.muKey + "diag_loc", + Description: "综合分诊/DB/日志/快照四项结论,按因果强度正交排序定位根因并给出推荐恢复动作。调用前请先跑其余 diag_* 并把结论传入。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "triage": map[string]interface{}{"type": "object", "description": "diag_triage 返回的结论对象"}, + "db": map[string]interface{}{"type": "object", "description": "diag_db 返回的结论对象"}, + "log_scan": map[string]interface{}{"type": "object", "description": "diag_log_scan 返回的结论对象"}, + "delta": map[string]interface{}{"type": "object", "description": "diag_delta 返回的结论对象"}, + "persist": map[string]interface{}{"type": "boolean", "description": "是否落盘结论到 recovery_kb 并回流知识库,缺省 true"}, + }, + }, + NoMemory: true, + }, p.handleLoc) + + log.Printf("[%s] started: data_dir=%s log_dir=%s", p.name, p.dataDir, p.logDir) + return nil +} + +func (p *Plugin) Stop() error { + log.Printf("[%s] stopped", p.name) + return nil +} + +func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) { + return &Plugin{name: name}, nil +} + +// resolveDirs 从内核 Settings 解出数据目录与日志目录。 +func (p *Plugin) resolveDirs(s *sdk.PluginSDK) { + if v, err := s.Settings().GetCore("core.daemon.data_dir"); err == nil && v != nil { + if sv, ok := v.(string); ok && sv != "" { + p.dataDir = sv + } + } + if v, err := s.Settings().GetCore("core.log.path"); err == nil && v != nil { + if sv, ok := v.(string); ok && sv != "" { + p.logDir = sv + } + } + if p.logDir == "" && p.dataDir != "" { + p.logDir = filepath.Join(p.dataDir, "log") + } + if p.dataDir != "" { + p.cfgPath = filepath.Join(p.dataDir, "config.db") + } +} + +// ===== 参数解析辅助 ===== + +func argString(args map[string]interface{}, key string) string { + if v, ok := args[key]; ok { + switch x := v.(type) { + case string: + return x + case json.Number: + return x.String() + case float64: + return fmt.Sprintf("%.0f", x) + case int: + return fmt.Sprintf("%d", x) + case int64: + return fmt.Sprintf("%d", x) + case bool: + if x { + return "true" + } + return "false" + default: + return fmt.Sprint(x) + } + } + return "" +} + +func argInt(args map[string]interface{}, key string, def int) int { + if v, ok := args[key]; ok { + switch x := v.(type) { + case float64: + return int(x) + case json.Number: + i, _ := x.Int64() + return int(i) + case int: + return x + case int64: + return int(x) + case string: + var i int + if _, err := fmt.Sscanf(x, "%d", &i); err == nil { + return i + } + } + } + return def +} + +func argBool(args map[string]interface{}, key string) bool { + if v, ok := args[key]; ok { + switch x := v.(type) { + case bool: + return x + case string: + return x == "true" || x == "1" || x == "yes" + } + } + return false +} + +func argMap(args map[string]interface{}, key string) map[string]interface{} { + if v, ok := args[key]; ok { + if m, ok := v.(map[string]interface{}); ok { + return m + } + if s, ok := v.(string); ok && s != "" { + var m map[string]interface{} + if json.Unmarshal([]byte(s), &m) == nil { + return m + } + } + } + return nil +} + +func valueString(m map[string]interface{}, key string) string { + if m == nil { + return "" + } + return argString(m, key) +} + +func valueInt(m map[string]interface{}, key string) int { + if m == nil { + return 0 + } + return argInt(m, key, 0) +} + +func content(v interface{}) map[string]interface{} { + out := map[string]interface{}{"content": v} + return out +} + +func contentWith(m map[string]interface{}, c string) map[string]interface{} { + m["content"] = c + return m +} + +// ---- diag_triage ---- + +func (p *Plugin) handleTriage(args map[string]interface{}) (interface{}, error) { + exitCode := argInt(args, "exit_code", 0) + signal := argString(args, "signal") + uptimeMS := argInt(args, "uptime_ms", 0) + stillAlive := argBool(args, "still_alive") + reason := argString(args, "crash_reason") + + class := "normal_stop" + verdict := "healthy" + var detail []string + if reasons := strings.TrimSpace(reason); reasons != "" { + detail = append(detail, "守护方告知: "+reasons) + } + + switch { + case stillAlive: + // 主 agent 进程仍在,但被判定需要检查 → 配置类/可达性问题优先(进程自身正常) + class = "config_unreachable" + verdict = "degraded" + detail = append(detail, "进程存活但健康检测触发,倾向配置/可达性类") + case signal != "": + s := strings.ToUpper(strings.ReplaceAll(signal, "-", "")) + class = "process_death" + verdict = "down" + detail = append(detail, fmt.Sprintf("被信号终止: %s", signal)) + if s == "SIGKILL" || s == "KILL" || s == "OOM" || strings.Contains(strings.ToLower(signal), "oom") { + class = "process_starvation" + detail = append(detail, "疑似被强杀/OOM,优先怀疑资源或失控") + } else if s == "SIGSEGV" || s == "SIGBUS" || s == "SIGABRT" || s == "SIGFPE" { + detail = append(detail, "疑似崩溃信号(segv/abrt),配合 diag_log_scan 的 panic/栈签名") + } + case exitCode != 0: + class = "process_death" + verdict = "down" + detail = append(detail, fmt.Sprintf("非零退出码: %d", exitCode)) + if exitCode >= 128 { + detail = append(detail, "退出码>=128 通常是 128+signal,配合信号判定") + } + default: + detail = append(detail, "退出码0且无信号:正常停止") + } + + c := map[string]interface{}{ + "verdict": verdict, + "class": class, + "exit_code": exitCode, + "signal": signal, + "still_alive": stillAlive, + "uptime_ms": uptimeMS, + "detail": detail, + "recommendation": recForTriage(class), + } + return contentWith(c, describeTriage(c)), nil +} + +func recForTriage(class string) string { + switch class { + case "process_death": + return "进程崩溃:先查崩溃点(diag_log_scan 栈/panic 签名);若无配置改动则重建 worker,勿动配置" + case "process_starvation": + return "资源/强杀:检查内存/失控,勿动配置,重建 worker 并限制资源" + case "config_unreachable": + return "配置/可达性:查 LLM 源与网络配置(diag_db + diag_delta on /etc),必要时还原配置并 ReloadFromConfig" + default: + return "正常情况,无需恢复" + } +} + +func describeTriage(c map[string]interface{}) string { + return fmt.Sprintf("判决: %s | 类别: %s | 建议: %s", c["verdict"], c["class"], c["recommendation"]) +} + +// ---- diag_db ---- + +type llmSourceData struct { + Name string `json:"name"` + BaseURL string `json:"base_url"` + Model string `json:"model"` + Adapter string `json:"adapter"` + APIKeySet bool `json:"api_key_present"` + Missing []string `json:"missing_fields"` + OK bool `json:"ok"` +} + +func (p *Plugin) handleDB(args map[string]interface{}) (interface{}, error) { + dbPath := argString(args, "db_path") + if dbPath == "" { + dbPath = p.cfgPath + } + if dbPath == "" { + return content("无法定位 config.db(未配置 data_dir),请传入 db_path"), nil + } + // 安全校验:db_path 仅允许 data 目录内的 sqlite 文件,防止被用作任意文件探测。 + if p.dataDir != "" { + abs, err := filepath.Abs(dbPath) + if err != nil { + return content("db_path 解析失败: " + err.Error()), nil + } + base := filepath.Clean(p.dataDir) + if abs != base && !strings.HasPrefix(abs, base+string(filepath.Separator)) { + return content("db_path 必须位于数据目录内(" + base + ")"), nil + } + } + + res := map[string]interface{}{ + "db_path": dbPath, + "exists": false, + } + if st, err := os.Stat(dbPath); err != nil || st.IsDir() { + res["integrity"] = "absent" + res["sources"] = []map[string]interface{}{} + res["summary"] = "config.db 缺失,属配置损坏类高危信号" + return contentWith(res, "config.db 缺失/不可访问"), nil + } + res["exists"] = true + res["size_bytes"] = func() int64 { + st, _ := os.Stat(dbPath) + if st != nil { + return st.Size() + } + return 0 + }() + + integrity, errTxt := p.dbIntegrity(dbPath) + res["integrity"] = integrity + if serr, ok := errTxt.(string); ok && serr != "" { + res["integrity_error"] = serr + } + + sources, srcErr := p.dbSources(dbPath) + res["sources"] = sources + failed := 0 + absent := 0 + var missingFields []string + for _, s := range sources { + if !s.OK { + failed++ + missingFields = append(missingFields, s.Name+":"+strings.Join(s.Missing, ",")) + } else if s.BaseURL == "" { + absent++ + } + } + res["source_count"] = len(sources) + res["source_failed"] = failed + res["missing_fields"] = missingFields + + verdict := "ok" + summary := "config.db 完整,LLM 源解析全部通过" + if integrity != "ok" { + verdict = "fail" + summary = "config.db 完整性校验失败,属配置损坏类,应还原配置快照并 ReloadFromConfig" + } else if failed > 0 { + verdict = "degraded" + summary = fmt.Sprintf("config.db 完整,但 %d 个 LLM 源缺必备字段(%s),需修复源配置", failed, strings.Join(missingFields, ";")) + } else if sources == nil && srcErr != "" { + verdict = "unknown" + summary = "config.db 完整但无法解析 LLM 源:" + srcErr + } + res["verdict"] = verdict + res["summary"] = summary + return contentWith(res, summary), nil +} + +// dbIntegrity 优先用 sqlite3 CLI 做 PRAGMA integrity_check;缺失则用 Settings 兜底 + 头部魔法字节启发式。 +func (p *Plugin) dbIntegrity(dbPath string) (string, interface{}) { + bin := p.sqliteBin() + if bin != "" { + out, err := exec.Command(bin, dbPath, "PRAGMA integrity_check;").CombinedOutput() + if err != nil { + return "error", fmt.Sprintf("sqlite3 运行失败: %v(%s)", err, strings.TrimSpace(string(out))) + } + trim := strings.TrimSpace(string(out)) + if strings.Contains(trim, "ok") { + return "ok", "" + } + if trim != "" { + return "fail", hemlines(trim, 3) + } + return "unknown", "integrity_check 无输出" + } + + // 无 CLI:读头部魔法 + 是否 WAL 缺页(pgno/心跳不必深析)作轻量启发式 + hdr := make([]byte, 16) + f, err := os.Open(dbPath) + if err != nil { + return "error", "无法打开 config.db" + } + _, err = f.Read(hdr) + f.Close() + if err != nil || !strings.HasPrefix(string(hdr), "SQLite format 3\x00") { + return "fail", "非 SQLite 文件头,疑似损坏/截断" + } + return "ok", "" // 头部完好;深度一致性超出无 CLI 能力,标注降级 +} + +func hemlines(s string, n int) string { + lines := strings.Split(s, "\n") + lines = filterNonEmpty(lines) + if len(lines) > n { + return strings.Join(lines[:n], " | ") + } + return strings.Join(lines, " | ") +} + +func filterNonEmpty(lines []string) []string { + var o []string + for _, l := range lines { + if strings.TrimSpace(l) != "" { + o = append(o, strings.TrimSpace(l)) + } + } + return o +} + +// dbSources 枚举 core.llm.sources.* 并校验必备字段。优先 sqlite3 CLI;缺失回退内核 Settings。 +func (p *Plugin) dbSources(dbPath string) ([]llmSourceData, string) { + bin := p.sqliteBin() + kv := map[string]string{} + if bin != "" { + out, err := exec.Command(bin, + dbPath, + "SELECT key, value FROM config WHERE key LIKE 'core.llm.sources.%';").CombinedOutput() + if err != nil { + return nil, fmt.Sprintf("sqlite3 查询失败: %v", err) + } + for _, line := range strings.Split(string(out), "\n") { + if idx := strings.IndexByte(line, '|'); idx >= 0 { + kv[line[:idx]] = line[idx+1:] + } + } + } else if p.sdk != nil { + keys, _ := p.sdk.Settings().ListCore("core.llm.sources") + for _, k := range keys { + if v, err := p.sdk.Settings().GetCore(k); err == nil && v != nil { + kv[k] = fmt.Sprint(v) + } + } + } else { + return nil, "既无 sqlite3 也无 Settings 可用" + } + + byName := map[string]map[string]string{} + for k, v := range kv { + rest := strings.TrimPrefix(k, "core.llm.sources.") + parts := strings.SplitN(rest, ".", 2) + if len(parts) != 2 { + continue + } + if byName[parts[0]] == nil { + byName[parts[0]] = map[string]string{} + } + byName[parts[0]][parts[1]] = v + } + + names := make([]string, 0, len(byName)) + for n := range byName { + names = append(names, n) + } + sort.Strings(names) + + var out []llmSourceData + for _, n := range names { + fields := byName[n] + var missing []string + for _, req := range []string{"base_url", "model", "adapter"} { + if strings.TrimSpace(fields[req]) == "" { + missing = append(missing, req) + } + } + out = append(out, llmSourceData{ + Name: n, + BaseURL: fields["base_url"], + Model: fields["model"], + Adapter: fields["adapter"], + APIKeySet: strings.TrimSpace(fields["api_key"]) != "", + Missing: missing, + OK: len(missing) == 0, + }) + } + return out, "" +} + +func (p *Plugin) sqliteBin() string { + if p.sdk != nil { + if v, err := p.sdk.Settings().Get("db_check_cmd"); err == nil && v != nil { + if sv, ok := v.(string); ok && sv != "" && sv != "auto" { + if _, err := exec.LookPath(sv); err == nil { + return sv + } + return "" + } + } + } + if _, err := exec.LookPath("sqlite3"); err == nil { + return "sqlite3" + } + return "" +} + +// ---- diag_log_scan ---- + +type sigRule struct { + Category string + Re *regexp.Regexp +} + +var sigRules = []sigRule{ + {"panic", regexp.MustCompile(`(?i)panic|nil pointer|invalid memory address|runtime error|SIGSEGV|coredump|stack overflow`)}, + {"oom", regexp.MustCompile(`(?i)\boom\b|out of memory|memory allocation failed`)}, + {"network", regexp.MustCompile(`(?i)no such host|connection refused|connection reset|timeout|unreachable|dns|lookup.*fail`)}, + {"provider", regexp.MustCompile(`(?i)provider .* failed|marked unavailable|llm api unreachable|llmfallback|api key|401|403`)}, + {"sql", regexp.MustCompile(`(?i)sql: |sqlite|database is locked|disk I/O error|no such table|constraint failed`)}, + {"fatal", regexp.MustCompile(`(?i)\bfatal\b|\berror\b|failed`)}, +} + +func (p *Plugin) handleLogScan(args map[string]interface{}) (interface{}, error) { + logDir := argString(args, "log_dir") + if logDir == "" { + logDir = p.logDir + } + sinceMin := argInt(args, "since_minutes", 0) + maxLines := argInt(args, "max_lines", 200000) + if maxLines <= 0 { + maxLines = 200000 + } + + cutoff := time.Time{} + if sinceMin > 0 { + cutoff = time.Now().Add(-time.Duration(sinceMin) * time.Minute) + } + + entries, err := os.ReadDir(logDir) + if err != nil { + return content(fmt.Sprintf("日志目录不可读: %v", err)), nil + } + + // 只扫当前 raw 日志(homed_YYYY-MM-DD_HH-MM-SS.log),忽略已压缩归档 + var files []string + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".log") { + continue + } + files = append(files, filepath.Join(logDir, e.Name())) + } + sort.Strings(files) + + counts := map[string]int{} + total := 0 + matched := 0 + scannedLines := 0 + filesRead := 0 + for _, f := range files { + if scannedLines >= maxLines { + break + } + data, err := os.ReadFile(f) + if err != nil { + continue + } + filesRead++ + for _, line := range strings.Split(string(data), "\n") { + if scannedLines >= maxLines { + break + } + scannedLines++ + line = strings.TrimSpace(line) + if line == "" { + continue + } + // 时间窗过滤:行首时间戳形如 2026/08/03 10:03:52 + if !cutoff.IsZero() { + ts := parseLogTS(line) + if !ts.IsZero() && ts.Before(cutoff) && (sinceMin > 0) { + continue + } + } + total++ + for _, rule := range sigRules { + if rule.Re.MatchString(line) { + counts[rule.Category]++ + matched++ + break + } + } + } + } + + // 排序取主导 + type kv struct { + cat string + count int + } + var order []kv + for cat, n := range counts { + order = append(order, kv{cat, n}) + } + sort.Slice(order, func(i, j int) bool { + if order[i].count != order[j].count { + return order[i].count > order[j].count + } + return order[i].cat < order[j].cat + }) + + dominant := "" + if len(order) > 0 { + dominant = order[0].cat + } + conclusion := "无已知错误签名命中" + switch dominant { + case "panic": + conclusion = "主导: panic/崩溃 → 配合栈定位,属进程死亡类" + case "oom": + conclusion = "主导: OOM/内存 → 进程失稳类,检查内存" + case "network", "provider": + conclusion = "主导: 网络/供应商不可达 → 配置或系统网络类" + case "sql": + conclusion = "主导: SQL/数据库错误 → 数据或配置损坏类" + case "fatal": + conclusion = "主导: 常规 error/failed → 需结合 DB/快照进一步定位" + } + + res := map[string]interface{}{ + "log_dir": logDir, + "files_read": filesRead, + "lines_scanned": scannedLines, + "lines_total_in_window": total, + "lines_matched": matched, + "counts": counts, + "dominant": dominant, + "conclusion": conclusion, + } + return contentWith(res, fmt.Sprintf("%s (命中 %d 行, 主导 %s)", conclusion, matched, dominant)), nil +} + +// parseLogTS 解析 homed 时间戳前缀 2026/08/03 10:03:52。 +var logTSRe = regexp.MustCompile(`^(\d{4})/(\d{2})/(\d{2}) (\d{2}):(\d{2}):(\d{2})`) + +func parseLogTS(line string) time.Time { + m := logTSRe.FindStringSubmatch(line) + if m == nil { + return time.Time{} + } + ts, _ := time.ParseInLocation("2006-01-02 15:04:05", + fmt.Sprintf("%s-%s-%s %s:%s:%s", m[1], m[2], m[3], m[4], m[5], m[6]), time.Local) + return ts +} + +// ---- diag_delta ---- + +type fileEntry struct { + Path string `json:"path"` + Type string `json:"type"` // created / modified / deleted + Size int64 `json:"size"` + NewHash string `json:"new_hash,omitempty"` + OldHash string `json:"old_hash,omitempty"` +} + +func (p *Plugin) handleDelta(args map[string]interface{}) (interface{}, error) { + baseline := argString(args, "baseline_dir") + current := argString(args, "current_dir") + pattern := argString(args, "pattern") + maxItems := argInt(args, "max_items", 500) + if maxItems <= 0 { + maxItems = 500 + } + + if baseline == "" || current == "" { + return contentWith(map[string]interface{}{ + "error": "baseline_dir 与 current_dir 均必填", + }, "缺少基线或现状目录:请先准备 last-good 快照解包目录"), nil + } + + baseMissing := !dirExists(baseline) + currMissing := !dirExists(current) + if baseMissing { + return contentWith(map[string]interface{}{ + "baseline_dir": baseline, + "current_dir": current, + "baseline_exists": false, + "summary": "基线不存在,无法差分(需先建立快照基线)", + }, "基线不存在,无法差分"), nil + } + if currMissing { + return contentWith(map[string]interface{}{ + "baseline_dir": baseline, + "current_dir": current, + "current_exists": false, + "summary": "现状目录不存在", + }, "现状目录不存在"), nil + } + + baseMap := walkHashes(baseline) + currMap := walkHashes(current) + + var files []fileEntry + seen := map[string]bool{} + for path, ch := range currMap { + seen[path] = true + if pattern != "" && !strings.Contains(path, pattern) { + continue + } + if bh, ok := baseMap[path]; ok { + if bh.hash != ch.hash { + files = append(files, fileEntry{Path: path, Type: "modified", Size: ch.size, OldHash: bh.hash, NewHash: ch.hash}) + } + } else { + files = append(files, fileEntry{Path: path, Type: "created", Size: ch.size, NewHash: ch.hash}) + } + } + for path, bh := range baseMap { + if !seen[path] && (pattern == "" || strings.Contains(path, pattern)) { + files = append(files, fileEntry{Path: path, Type: "deleted", Size: bh.size, OldHash: bh.hash}) + } + } + sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path }) + + summary := map[string]int{"created": 0, "modified": 0, "deleted": 0} + for _, f := range files { + summary[f.Type]++ + } + + shown := files + if len(shown) > maxItems { + shown = shown[:maxItems] + } + + res := map[string]interface{}{ + "baseline_dir": baseline, + "current_dir": current, + "summary": summary, + "total_diff": len(files), + "files": shown, + } + return contentWith(res, fmt.Sprintf("diff: %+v", res["summary"])), nil +} + +type hashEnt struct { + hash string + size int64 +} + +func walkHashes(root string) map[string]hashEnt { + out := map[string]hashEnt{} + filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + rel, _ := filepath.Rel(root, path) + data, err := os.ReadFile(path) + if err != nil { + return nil + } + h := sha256.Sum256(data) + out[rel] = hashEnt{hash: hex.EncodeToString(h[:]), size: int64(len(data))} + return nil + }) + return out +} + +func dirExists(p string) bool { + st, err := os.Stat(p) + return err == nil && st.IsDir() +} + +// ---- diag_loc ---- + +func (p *Plugin) handleLoc(args map[string]interface{}) (interface{}, error) { + triage := argMap(args, "triage") + db := argMap(args, "db") + logScan := argMap(args, "log_scan") + delta := argMap(args, "delta") + + type hyp struct { + Cause string `json:"cause"` + Confidence int `json:"confidence"` // 0-100 因果强度 + Evidence []string `json:"evidence"` + Recommend string `json:"recommendation"` + } + var hyps []hyp + + tClass := valueString(triage, "class") + tVerdict := valueString(triage, "verdict") + dbVerdict := valueString(db, "verdict") + dom := valueString(logScan, "dominant") + deltaSummary := map[string]int{} + if delta != nil { + if s, ok := delta["summary"].(map[string]interface{}); ok { + for k, v := range s { + switch n := v.(type) { + case float64: + deltaSummary[k] = int(n) + case int: + deltaSummary[k] = n + case int64: + deltaSummary[k] = int(n) + } + } + } + } + dCreated := deltaSummary["created"] + dModified := deltaSummary["modified"] + dDeleted := deltaSummary["deleted"] + dTotal := dCreated + dModified + dDeleted + + evidence := []string{} + if tVerdict != "" { + evidence = append(evidence, "triage="+tVerdict+"("+tClass+")") + } + if dbVerdict != "" { + evidence = append(evidence, "db="+dbVerdict) + } + if dom != "" { + evidence = append(evidence, "log_dominant="+dom) + } + if dTotal > 0 { + evidence = append(evidence, fmt.Sprintf("delta=%d 改动(%d改/%d增/%d删)", dTotal, dModified, dCreated, dDeleted)) + } else { + evidence = append(evidence, "delta=无改动") + } + + // 1) 进程失稳(panic 主导时走更具体的 code_panic_loop 分支) + if (tClass == "process_death" || tClass == "process_starvation") && + dbVerdict != "fail" && dbVerdict != "degraded" && dTotal == 0 && + dom != "panic" { + hyps = append(hyps, hyp{ + Cause: "process_instability", + Confidence: 75, + Evidence: in(evidence, "triage=down"), + Recommend: "重建 worker;不动配置(db 完好、无文件改动)", + }) + } + + // 2) 配置损坏 + if dbVerdict == "fail" || dbVerdict == "degraded" { + hyps = append(hyps, hyp{ + Cause: "config_corruption", + Confidence: 90, + Evidence: in(evidence, "db="+dbVerdict), + Recommend: "还原 core.llm.sources 配置快照 → ReloadFromConfig → 拉起主 agent", + }) + } + + // 3) 系统网络 + if (dom == "network" || dom == "provider") && (dTotal > 0) { + hyps = append(hyps, hyp{ + Cause: "system_network", + Confidence: 80, + Evidence: in(evidence, "log_dominant="+dom, "delta>0"), + Recommend: "还原 DNS/proxy/host 相关系统网络配置 → 重载主 agent", + }) + } + + // 4) 纯日志栈崩溃(db 完好、无 delta) + if tClass == "process_death" && dbVerdict == "ok" && dTotal == 0 && dom == "panic" { + hyps = append(hyps, hyp{ + Cause: "code_panic_loop", + Confidence: 70, + Evidence: in(evidence, "log_dominant=panic", "db=ok", "delta=无改动"), + Recommend: "定位 panic 栈来源(repeat)+ 检查是否插件引起,必要时禁用对应插件后重建 worker", + }) + } + + // 未知/混合 + if len(hyps) == 0 { + hyps = append(hyps, hyp{ + Cause: "unknown_mixed", + Confidence: 20, + Evidence: evidence, + Recommend: "确定性命中不足,放开 webfetch/知识库,用 rescue 源 做最小 LLM 推理(依据 diag_* 结论摘要)", + }) + } + + sort.Slice(hyps, func(i, j int) bool { return hyps[i].Confidence > hyps[j].Confidence }) + + res := map[string]interface{}{ + "evidence": evidence, + "ranked_hypotheses": hyps, + "final_recommendation": hyps[0].Recommend, + } + + // 落盘 + 知识库回流(同类崩溃下次直接命中) + if argBool(args, "persist") { + p.persistConclusion(res, hyps[0].Cause, hyps[0].Recommend) + } + + return contentWith(res, "定位: "+hyps[0].Cause+" | 建议: "+hyps[0].Recommend), nil +} + +// persistConclusion 把定位结论写 recovery_kb/diag_.json,并经知识库回流(失败不阻塞)。 +func (p *Plugin) persistConclusion(res map[string]interface{}, cause, recommend string) { + ts := time.Now() + entry := map[string]interface{}{ + "ts": ts.Format(time.RFC3339), + "cause": cause, + "recommendation": recommend, + "evidence": valueFrom(res, "evidence"), + "ranked_hypotheses": res["ranked_hypotheses"], + "final_recommendation": recommend, + "tool": "diag_loc", + } + raw, _ := json.MarshalIndent(entry, "", " ") + + dir := p.recoveryKBDir() + if dir != "" { + if err := os.MkdirAll(dir, 0755); err == nil { + path := filepath.Join(dir, fmt.Sprintf("diag_%s.json", ts.Format("2006-01-02_15-04-05"))) + if err := os.WriteFile(path, raw, 0644); err == nil { + log.Printf("[%s] conclusion persisted to %s", p.name, path) + } else { + log.Printf("[%s] persist file %s: %v", p.name, path, err) + } + } + } + + if p.sdk != nil && p.sdk.Knowledge() != nil { + kName := fmt.Sprintf("diag:%s:%s", cause, ts.Format("2006-01-02T15-04")) + content := fmt.Sprintf("恢复诊断结论(%s): %s。建议: %s。命中条件可复用。", ts.Format("2006-01-02 15:04:05"), cause, recommend) + if err := p.sdk.Knowledge().Add(kName, content); err != nil { + log.Printf("[%s] knowledge add %s: %v", p.name, kName, err) + } + } +} + +func valueFrom(m map[string]interface{}, k string) interface{} { + if m == nil { + return nil + } + return m[k] +} + +// recoveryKBDir 返回结论落盘目录,可配置,缺省 /recovery_kb。 +func (p *Plugin) recoveryKBDir() string { + if p.sdk != nil { + if v, err := p.sdk.Settings().Get("recovery_kb_dir"); err == nil && v != nil { + if sv, ok := v.(string); ok && sv != "" { + return sv + } + } + } + if p.dataDir != "" { + return filepath.Join(p.dataDir, "recovery_kb") + } + return "" +} + +// in 过滤 slice,保留同时满足 items 中条件(简单子串匹配)的元素。 +func in(src []string, items ...string) []string { + var o []string + for _, it := range items { + for _, s := range src { + if s == it { + o = append(o, it) + break + } + } + } + return o +} + +var _ = json.Marshal diff --git a/example/rss/plg.json b/example/rss/plg.json index 9dced7f..7c092c5 100644 --- a/example/rss/plg.json +++ b/example/rss/plg.json @@ -2,14 +2,19 @@ "name": "rss", "name_zh": "RSS订阅", "name_en": "RSS", - "version": "1.0.0", + "version": "1.1.0", "description": "RSS/Atom 订阅监控插件,自动检测更新并推送通知", "author": "HomeAgent", "entry": "plugin.so", - "tags": ["rss", "feed", "subscription", "monitor"], + "tags": [ + "rss", + "feed", + "subscription", + "monitor" + ], "targets": "linux/amd64", "outdir": "dist", "bundle": true, "replaces": {}, "source_dirs": [] -} +} \ No newline at end of file diff --git a/example/rss/plugin.go b/example/rss/plugin.go index b30beac..5b996f4 100644 --- a/example/rss/plugin.go +++ b/example/rss/plugin.go @@ -467,7 +467,7 @@ func (p *Plugin) saveData() { SeenGUIDs: p.seenGUIDs, } b, _ := json.MarshalIndent(data, "", " ") - os.WriteFile(p.dataFile(), b, 0644) + atomicWriteJSON(p.dataFile(), b) } // cleanupData 卸载时清理订阅数据目录(feeds.json 等) @@ -486,3 +486,12 @@ func (p *Plugin) cleanupData() { } + +// 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) +}