diff --git a/DESIGN.md b/DESIGN.md index c177033..2533085 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,19 +1,13 @@ -# HomeAgent 架构设计 v4 +# HomeAgent 架构设计 完整架构文档参见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)。 +实施计划参见 [PLAN.md](PLAN.md)。 ## 核心原则 -- **核心零 IO** — 无任何硬编码 IO 能力,所有 IO 来自插件 -- **输出是工具调用** — Agent 必须显式 `output_send` 才能通信 +- **核心零 IO** — 无任何硬编码 IO 能力 +- **输出是工具调用** — Agent 必须显式调用 output_send 才能通信 - **三通道插件** — 工具 (RegisterTool)、阶段 (RegisterStage)、事件 (Subscribe/Publish) -- **阶段管道** — 7 个 hook 点让插件干预消息处理流:`on_input` → `pre_action` → `post_action` ↔ `before_toolcall`/`after_toolcall` → `before_output` → `after_output` +- **阶段管道** — 7 个 hook 点让插件干预消息处理流 - **三层记忆** — Context (内存) → Document (JSON+向量) → Graph (SQLite) - **知识独立** — 独立 TF-IDF 向量索引,不与记忆耦合 - -## 快速启动 - -```bash -make build # 编译 -make run # 编译并启动(数据 /tmp/homeagent) -``` diff --git a/Makefile b/Makefile index 45b2b65..245ca81 100644 --- a/Makefile +++ b/Makefile @@ -1,18 +1,24 @@ -.PHONY: all build clean install test run +.PHONY: all build clean install test run build-cli BINARY=homed +CLI_BINARY=homecli GO=go GOCACHE=/tmp/gocache GOPATH=$(shell go env GOPATH) BUILD_DIR=build -all: build +all: build build-cli build: @mkdir -p $(BUILD_DIR) CGO_ENABLED=1 $(GO) build -o $(BUILD_DIR)/$(BINARY) ./cmd/homed/ @echo "Built: $(BUILD_DIR)/$(BINARY)" +build-cli: + @mkdir -p $(BUILD_DIR) + CGO_ENABLED=0 $(GO) build -o $(BUILD_DIR)/$(CLI_BINARY) ./cmd/cli/ + @echo "Built: $(BUILD_DIR)/$(CLI_BINARY)" + build-static: @mkdir -p $(BUILD_DIR) CGO_ENABLED=1 $(GO) build -tags netgo -ldflags '-extldflags "-static"' -o $(BUILD_DIR)/$(BINARY)-static ./cmd/homed/ diff --git a/PLAN.md b/PLAN.md index 7ea3eec..67e8e46 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,132 +1,246 @@ -# HomeAgent 实施计划 +# HomeAgent 架构与实施 -## 已完成 +## 架构概览 -### Phase 0 — 核心基础设施 ✅ +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ homed (内核) │ +│ ┌──────────┐ ┌──────────┐ ┌────────────┐ ┌───────────────────┐ │ +│ │LLM源管理 │ │Agent编排 │ │记忆管理 │ │知识管理 (TF-IDF) │ │ +│ │Lua适配器 │ │主agent │ │图/文本/ │ │ │ │ +│ │(协议转换) │ │子agent │ │文档三层 │ │ │ │ +│ └──────────┘ └──────────┘ └────────────┘ └───────────────────┘ │ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ IO通道管理器 (IOManager) │ │ +│ │ 排队通道 (Queue) + 中断通道 (Interrupt) │ │ +│ │ 核心回环 _consolidation_ (记忆消歧/系统维护) │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ 阶段管道 (StageHost 7 阶段, 并行执行) + 事件总线 (EventBus) │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ PluginSDK — 内核对插件的完整 Go API │ │ +│ │ IO/工具/阶段/事件/记忆/知识/LLM/配置 │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +├─────────────────────────────────────────────────────────────────────┤ +│ 内置插件 (编译入内核, init() 自注册) │ +│ internal/plugins/all.go (空白导入触发 init) │ +│ ┌──────────┐ ┌──────────┐ ┌────────────┐ ┌──────────────┐ │ +│ │ WebUI │ │ CLI │ │ OpenClaw │ │ Timer │ │ +│ │ HTTP 服务│ │ Unix socket│ │SKILL.md→SDK│ │ timer_set 工具│ │ +│ └──────────┘ └──────────┘ └────────────┘ └──────────────┘ │ +├─────────────────────────────────────────────────────────────────────┤ +│ 动态插件 (/plugins// 按需加载) │ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ plugin.json 元数据 + plugin.so (Go -buildmode=plugin) │ │ +│ │ 或 main.lua (Lua 脚本, 预留) │ │ +│ │ Registry.Load() 自动扫描, 无 factory → tryLoadSO → tryLoadLua │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +├─────────────────────────────────────────────────────────────────────┤ +│ waiter (通用客户端) │ +│ waiter -say "你好" → Unix socket → CLI 插件 │ +│ waiter (交互模式) → 同上 │ +└─────────────────────────────────────────────────────────────────────┘ +``` -| 任务 | 文件 | 状态 | +## 核心原则 + +1. **核心零 IO** — homed 不监听端口, 不启动 HTTP 服务, 不读 stdin +2. **一切外界交互都是插件** — 通过 PluginSDK 与核心交互 +3. **插件自注册** — 每个插件的 `init()` 调用 `plugin.RegisterFactory()`, 内核无需硬编码 +4. **动态 .so 加载** — 第三方插件编译为 `.so`, 放入 `plugins//plugin.so` +5. **PluginSDK 是内核"系统调用"** — 插件只能通过 SDK 访问核心能力 +6. **Stage 并行执行** — 同阶段所有 handler goroutine 并发, StageContext 内嵌 RWMutex +7. **中断最高优先级** — 独立 `interceptLoop` 可随时打断进行中的 LLM 请求 +8. **子 agent 是主 agent 的工具** — `spawn_child`/`child_result`, 不是 SDK 部分 + +## 插件注册体系 + +### 两种注册路径 + +| 路径 | 场景 | 实现 | |------|------|------| -| SDK 接口定义 | `internal/plugin/sdk/api.go` | ✅ | -| PluginAPI(RegisterTool/RegisterStage/Subscribe/Publish) | `internal/plugin/sdk/api.go` | ✅ | -| 插件内部 EventBus | `internal/plugin/sdk/bus.go` | ✅ | -| 系统 EventBus | `internal/events/bus.go` | ✅ | -| StageHost 编排器 | `internal/agent/core/stages.go` | ✅ | -| Agent 阶段注入(7 个 hook 点) | `internal/agent/core/agent.go` | ✅ | -| 插件注册表 SDK 支持 | `internal/plugin/plugin.go` | ✅ | -| main.go 接入 EventBus + StageHost | `cmd/homed/main.go` | ✅ | -| 架构文档 v4 | `docs/ARCHITECTURE.md` | ✅ | +| **自注册 (init)** | 内置插件 (timer/cli/openclaw/webui) | 包 `init()` 调 `plugin.RegisterFactory(name, factory)` | +| **动态加载** | 第三方插件 | 扫描 `/plugins//`, 读 manifest + .so | ---- - -## 待实施 - -### Phase 1 — 插件 SDK 迁移(当前) - -| # | 任务 | 说明 | 优先级 | -|---|------|------|--------| -| 1.1 | SDK 添加 `ToolDef` 参数描述支持 | `RegisterTool` 接受 `ToolDef` 结构体(含 parameters)而非纯 handler | high | -| 1.2 | StageHost 收集完整 ToolDef | 目前只传 name,需传完整 description + parameters 给 LLM | high | -| 1.3 | Registry.AddPluginAPI 自动构建 StageHost | 替代手动 `syncFromRegistry` | high | -| 1.4 | 添加 `before_toolcall` deny 机制的测试 | 确保 `StageContext.Response` 在工具级别生效 | medium | -| 1.5 | 添加 `on_input` 改写消息的测试 | `stageCtx.RawMessage` 在阶段后被正确使用 | medium | - -### Phase 2 — 迁移 WebUI 到 SDK 模式 - -| # | 任务 | 说明 | 优先级 | -|---|------|------|--------| -| 2.1 | WebUI 改为通过 `PluginAPI` 注册 | 不再依赖 `Device` 接口 | high | -| 2.2 | WebUI 通过 `Subscribe(EventAll)` 获取所有事件 | 取代 OutputChan 监听 | high | -| 2.3 | WebUI 注册 `output_send` 工具 | 通过 `RegisterTool` 暴露给 LLM | high | -| 2.4 | 删除 `internal/api/plugin.go` 的 Device 包装 | 不再需要 `Device` 适配器 | medium | -| 2.5 | Handler 改为通过 EventBus 获取 IOManager 引用 | 减少直接依赖 | low | - -### Phase 3 — 迁移 QQ/OneBot 到 SDK 模式 - -| # | 任务 | 说明 | 优先级 | -|---|------|------|--------| -| 3.1 | OneBot 插件改为 `PluginAPI.RegisterTool` | 注册 `qq_send_private_msg` 等工具 | high | -| 3.2 | OneBot 接管后通过 `Publish(raw_input)` 发布事件 | 取代 IOManager.InjectInput | high | -| 3.3 | OneBot 注册阶段钩子 | 可接入群聊特定的 `pre_action` 逻辑 | medium | -| 3.4 | 删除 `internal/onebot/device.go` 的 Device 包装 | SDK 模式原生支持 | medium | - -### Phase 4 — 迁移 OutputBus 到 SDK - -| # | 任务 | 说明 | 优先级 | -|---|------|------|--------| -| 4.1 | 创建 `internal/outputbus/` 插件 | 管理 `output_send`/`output_list_channels` | high | -| 4.2 | 通过 `RegisterTool` 注册输出工具 | LLM 可直接调用 | high | -| 4.3 | 通过 `RegisterStage(before_output)` 拦截最终文本 | 渠道适配 | medium | -| 4.4 | Agent 内置的 output_* 工具改为委托给 outputbus | 解耦核心 | medium | - -### Phase 5 — 清理旧组件 - -| # | 任务 | 说明 | 优先级 | -|---|------|------|--------| -| 5.1 | 删除 `Device` 接口定义 | 全部迁移后移除 | high | -| 5.2 | 删除 `IOManager.ExecuteTool` | 工具路由走 StageHost | high | -| 5.3 | 删除 `IOManager.EmitOutput`/`EmitOutputTo` | 走 EventBus | medium | -| 5.4 | 删除 `IOManager.AtomicSwapDevices` | 不再需要设备热替换 | medium | -| 5.5 | 删除 `PluginDevice` 包装器 | SDK 模式替代 | medium | -| 5.6 | 删除 `internal/onebot/device.go` | 已迁移到 SDK | high | -| 5.7 | 删除 `internal/api/plugin.go` | 已迁移到 SDK | medium | -| 5.8 | 精简 `cmd/homed/main.go` | 移除设备相关初始化 | medium | - -### Phase 6 — 进程隔离 - -| # | 任务 | 说明 | 优先级 | -|---|------|------|--------| -| 6.1 | 实现 Unix Socket JSON-RPC 传输层 | 进程隔离模式 | low | -| 6.2 | `sdk.Run()` 自动检测 in-process/external | 开发 vs 生产 | low | -| 6.3 | 插件进程管理(启动/停止/健康检查) | Supervisor 扩展 | low | - -### Phase 7 — 增强功能 - -| # | 任务 | 说明 | 优先级 | -|---|------|------|--------| -| 7.1 | WebUI D3.js 力导向图记忆星图 | 已有 API `GET /api/v1/memory/star` | low | -| 7.2 | Model Context Protocol (MCP) 支持 | 标准工具协议 | low | -| 7.3 | 多 Agent 支持 | 每个 Agent 独立上下文 | low | -| 7.4 | Python 插件 SDK | 扩展生态 | low | - ---- - -## 文件最终结构(Phase 5 完成后) +### 自注册流程 ``` -HomeAgent/ -├── cmd/homed/main.go — 入口 -├── internal/ -│ ├── agent/ -│ │ ├── core/ -│ │ │ ├── agent.go — Agent 核心 -│ │ │ ├── context.go — 相关性上下文 -│ │ │ └── stages.go — StageHost -│ │ └── api/ -│ │ └── provider.go — LLM Provider -│ ├── events/ -│ │ └── bus.go — 系统事件总线 -│ ├── plugin/ -│ │ └── sdk/ -│ │ ├── api.go — PluginAPI -│ │ └── bus.go — 插件 EventBus -│ ├── memory/ — 三层记忆 -│ ├── knowledge/ — 知识库 -│ ├── tracker/ — 变更追踪 -│ ├── supervisor/ — 守护进程 -│ └── plugins/ — 插件实现 -│ ├── webui/ — HTTP API + 仪表盘 -│ ├── onebot/ — QQ 通道 -│ └── outputbus/ — 输出通道管理 -├── docs/ -│ └── ARCHITECTURE.md — 架构文档 -├── DESIGN.md -├── PLAN.md -└── README.md +internal/plugins/timer/plugin.go + func init() { + plugin.RegisterFactory("timer", func(name string, cfg map[string]interface{}) (sdk.Plugin, error) { + return New(name), nil + }) + } + +internal/plugins/all.go + package plugins + import ( _ "timer" _ "cli" _ "openclaw" _ "webui" ) + // 空白导入触发所有 init() → RegisterFactory + +cmd/homed/main.go + cli.DefaultSocket = *cliSocket // 注入运行时变量 + openclaw.SkillsDir = filepath.Join(...) + webui.Configure(httpAddr, sup, mem, sk, ...) + pluginReg.Load(plgDir) // 自动创建目录 + 加载 ``` -## 设计原则 +### 动态 .so 加载 -1. **核心零 IO** — Core 不依赖任何插件、设备、通道实现 -2. **三通道标准** — 所有插件通过 Tool/Stage/Event 与核心交互 -3. **增量迁移** — 每阶段保持向后兼容,旧组件与新 SDK 并行运行 -4. **测试覆盖** — 每阶段提交前确保全部测试通过 +插件目录结构: +``` +/plugins/myplugin/ + plugin.json — 元数据 {name, version, description, author, entry} + plugin.so — Go -buildmode=plugin 编译, 导出 NewPlugin(name, config) +``` + +动态加载器 `internal/plugin/dynamic.go` 扫描 `.so`: +```go +func tryLoadSO(dir, name string, config map[string]interface{}) (sdk.Plugin, error) { + p, _ := plugin.Open(filepath.Join(dir, "plugin.so")) + sym, _ := p.Lookup("NewPlugin") + fn := sym.(func(string, map[string]interface{}) (sdk.Plugin, error)) + return fn(name, config), nil +} +``` + +内置插件也可剥离为 .so, 当前保持 init 自注册。 + +## 输入双通道 + 中断打断 + +### 通道结构 + +``` +IOManager + ├── inputCh (chan *InputEvent, 256) — 排队通道, 按序处理 + ├── interruptCh (chan *InputEvent, 64) — 中断通道, 可打断 LLM + └── outputCh (chan *OutputEvent, 256) — 输出通道 +``` + +### 中断打断机制 + +``` +timer 插件 interceptLoop (独立 goroutine) + │ │ + ├─ s.InjectInterruptText(...) ─────────┤ + │ │ + │ ┌──────────┴──────────┐ + │ │ (a) cancelLLM() │ → 取消进行中的 HTTP 请求 + │ │ (b) interceptCh <- │ → process() 非阻塞读取 + │ │ (c) InjectInput(...) │ → 空闲时 eventLoop 消费 + │ └─────────────────────┘ + │ │ + ▼ ▼ +interruptCh process() 工具循环 + │ + 每个 turn 开始前: + drainInterrupt() → 注入 [打断消息] system msg +``` + +**三种投递路径 (interceptLoop)**: +- **(a)** `cancelLLM()` — 直接取消当前 Provider HTTP 请求, 捕获 `context.Canceled` +- **(b)** `interceptCh <- text` — `process()` 每轮 LLM 调用前 `drainInterrupt()`, 注入 `[打断消息]` 到上下文 +- **(c)** `InjectInput("interrupt", "text", ...)` — `eventLoop` 在空闲时收到新输入, 启动新处理循环 + +## 阶段管道 (Stage Pipeline) + +7 个阶段, **并行执行**: + +``` +on_input → pre_action → post_action ↔ before_toolcall/after_toolcall → before_output → after_output +``` + +| 阶段 | 时机 | 插件能力 | +|------|------|---------| +| `on_input` | 消息到 Agent | 可短路回复 | +| `pre_action` | LLM 调用前 | 注入 system 消息 | +| `post_action` | LLM 返回后 | 审查/修改文本和工具调用 | +| `before_toolcall` | 工具执行前 | 拒绝/改参 | +| `after_toolcall` | 工具执行后 | 修改结果 | +| `before_output` | 输出前 | 改写最终文本 | +| `after_output` | 输出后 | 只读统计 | + +并行规则: 所有 handler 用 goroutine 并发, StageContext 内嵌 `sync.RWMutex`, handler 通过 `Lock()/RLock()/IsResponded()` 协防。 + +## 配置体系 (ConfigRegistry) + +全部配置持久化在 SQLite: + +| 表 | 用途 | 访问 | +|----|------|------| +| `config` | 核心配置 (LLM/daemon/agent) | SettingsAPI.GetCore/SetCore | +| `config_` | 插件独立配置 | SettingsAPI.Get/Set/List | +| | 跨插件读写 | GetPlugin/SetPlugin/ListPlugin/Dump | + +## 内核入口 (cmd/homed/main.go) + +初始化顺序: + +``` +1. 基础设施 → 记忆/技能/Lua/监督/追踪/IO/事件 +2. 配置中心 (SQLite) + LLM Provider +3. 阶段管道 StageHost + 插件注册表 Registry +4. 注入内置插件依赖 (cli.DefaultSocket / openclaw.SkillsDir / webui.Configure) +5. Registry.Load(plgDir) → 自注册 + 动态加载 +6. Agent 启动 (eventLoop + interceptLoop + distillLoop) +7. 等待信号 → 关机 +``` + +## 目录结构 + +``` +cmd/ + homed/main.go — 内核入口 (零 IO) + waiter/main.go — CLI 客户端 (Unix socket) +internal/ + sdk/ ★ PluginSDK (Go API) + plugin.go — Plugin 接口 + PluginSDK 结构体 + memory.go / knowledge.go — 记忆/知识包装 + settings.go / llm.go — 配置/LLM 源 + agent/ + core/ + agent.go — Agent: eventLoop/interceptLoop/process + context.go — RelevanceContext (TF-IDF) + stages.go — StageHost (并行阶段管道) + api/provider.go — Provider 接口 + LuaAdaptedProvider + io/channel.go — IOManager (Queue/Interrupt/Output) + plugin/ + registry.go — 注册表: 生命周期, Load, RegisterFactory + manifest.go — PluginManifest (plugin.json) + dynamic.go — .so 动态加载器 + plugin.go — SKILL 插件解析 + plugins/ + all.go — 空白导入触发所有内置插件 init() + timer/ cli/ openclaw/ webui/ — 内置插件 + events/bus.go — 系统事件总线 + memory/ — 三层记忆 (Context→Document→Graph) + knowledge/ — 知识库 + config/registry.go — 配置中心 + tracker/ — overlayfs 变更追踪 + supervisor/ — 守护管理 + skill/ — 技能管理 + lua/vm.go — Lua VM (LLM 协议适配) +pkg/types/ — 类型定义 +docs/ARCHITECTURE.md — 完整架构文档 +``` + +## 与旧架构关键区别 + +| 维度 | 之前 | 现在 | +|------|------|------| +| 插件注册 | main.go 硬编码 RegisterNative | init() 自注册 + .so 动态加载 | +| 内核入口 | 逐个 import 插件包 | 仅 import all.go (空白导入) | +| 中断处理 | 无消费者, 消息丢失 | interceptLoop + cancelLLM + drainInterrupt | +| 插件目录 | 手动硬编码创建 | Load() 自动为每个注册工厂创建 | +| 依赖注入 | 闭包绑定在 RegisterNative | 包级变量 (cli.DefaultSocket 等) | +| 阶段执行 | 顺序 | 并行 (goroutine + WaitGroup) | + +## 构建与验证 + +```bash +make build # 编译 homed + waiter +./build/homed -data /tmp/ha # 启动 +./build/waiter -say "你好" # 发送消息 +``` + +要求: Go 1.19+, CGo (go-sqlite3), Linux (Unix socket + overlayfs). diff --git a/README.md b/README.md index c964f41..dbfaa65 100644 --- a/README.md +++ b/README.md @@ -1,83 +1,37 @@ # HomeAgent -单二进制 24/7 智能管家。**核心零 IO**,所有输入输出通过插件,插件通过三通道与核心交互:工具、阶段钩子、事件订阅。 +24/7 智能管家。**核心零 IO**,一切外界交互来自插件。 -## 架构 +## 架构概览 ``` - 外部(QQ / HTTP / 硬件...) - │ 通过插件注册 - ▼ - IOManager.InjectInput() - │ - ▼ - ┌─────────────────────────────────────────────────┐ - │ Agent Core │ - │ │ - │ on_input → Context → Memory Recall │ - │ → pre_action → [LLM ↔ 工具循环] → before_output │ - │ → output_send → after_output │ - │ │ - │ 内置:三层记忆 + 知识库 + Child Agent │ - └─────────────────────────────────────────────────┘ - │ - ├── Stage Pipeline(7 阶段,插件可拦截/改写) - ├── Tool System(插件注册工具给 LLM) - └── Event Bus(插件订阅系统事件) +homed (内核) — 零 IO,纯管理 + ├── LLM 源管理 (Lua 适配器协议转换) + ├── Agent 编排 (主 agent + interceptLoop + 子 agent) + ├── 三层记忆 (Context → Document → Graph) + ├── 知识库 (独立 TF-IDF) + ├── IO 通道管理 (Queue / Interrupt / Output) + ├── 阶段管道 (StageHost: 7 阶段并行) + └── 事件总线 (EventBus) + │ + ▼ PluginSDK (Go API: 工具/阶段/事件/记忆/知识/LLM/配置) + │ + plugins (init() 自注册 + .so 动态加载) + ├── WebUI (HTTP 服务) + ├── CLI (Unix socket) + ├── OpenClaw 兼容 (SKILL.md → SDK 工具) + ├── Timer (timer_set 工具 + 中断反馈) + └── 第三方 .so 插件 (plugins//plugin.so) ``` -## 快速开始 +核心文档: [PLAN.md](PLAN.md) · [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) + +## 构建 ```bash make build ./build/homed -data /tmp/homeagent +./build/waiter -say "你好" ``` -依赖:Go 1.19+、CGo(go-sqlite3)。 - -## 阶段管道 - -``` -on_input → pre_action → post_action ↔ before_toolcall/after_toolcall → before_output → after_output - ↑_______________| - 循环 -``` - -| 阶段 | 插件可做 | -|---|---| -| `on_input` | 鉴权、拉黑、改写、短路 | -| `pre_action` | 注入 context 消息 | -| `post_action` | 审查/改写 LLM 输出、增删工具 | -| `before_toolcall` | 拒绝、改参、审计 | -| `after_toolcall` | 脱敏、改写结果 | -| `before_output` | 改写最终文本、加格式 | -| `after_output` | 记录/统计 | - -## 三层记忆 - -| 层 | 存储 | 容量 | 裁剪 | -|---|---|---|---| -| Context | 内存 TF-IDF | 30 条 | 余弦相似度排序→文档 | -| Document | JSON + 向量索引 | ∞ | 72h 冷→图 | -| Graph | SQLite 三元组 | ∞ | 定期重整+同义合并 | - -## 插件三通道 - -| 通道 | 方向 | 用途 | -|---|---|---| -| `RegisterTool` | 插件→LLM | Agent 调用插件功能 | -| `RegisterStage` | 核心→插件 | 干预消息处理流 | -| `Subscribe/Publish` | 双向 | 审计/日志/通知 | - -## 核心命令 - -```bash -make build # 编译 -make run # 编译+启动 -make test # 测试 -make install # 系统安装 -``` - -## 完整文档 - -详见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)。 +依赖: Go 1.19+, CGo (go-sqlite3), Linux (Unix socket + overlayfs) diff --git a/cmd/homed/main.go b/cmd/homed/main.go index bb8018a..64b34d8 100644 --- a/cmd/homed/main.go +++ b/cmd/homed/main.go @@ -13,12 +13,8 @@ import ( agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core" agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" agentPkg "gitcode.com/JianFeeeee/HomeAgent/internal/agent" - "gitcode.com/JianFeeeee/HomeAgent/internal/api" - "gitcode.com/JianFeeeee/HomeAgent/config" internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config" "gitcode.com/JianFeeeee/HomeAgent/internal/events" - "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/test_deepseek" - sdk "gitcode.com/JianFeeeee/HomeAgent/internal/plugin/sdk" "gitcode.com/JianFeeeee/HomeAgent/internal/knowledge" luapkg "gitcode.com/JianFeeeee/HomeAgent/internal/lua" "gitcode.com/JianFeeeee/HomeAgent/internal/memory" @@ -26,38 +22,40 @@ import ( "gitcode.com/JianFeeeee/HomeAgent/internal/memory/pipeline" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/social" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/text" - "gitcode.com/JianFeeeee/HomeAgent/internal/onebot" "gitcode.com/JianFeeeee/HomeAgent/internal/plugin" + cli "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cli" + openclaw "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/openclaw" + webui "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/webui" + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" "gitcode.com/JianFeeeee/HomeAgent/internal/skill" "gitcode.com/JianFeeeee/HomeAgent/internal/supervisor" "gitcode.com/JianFeeeee/HomeAgent/internal/tracker" + _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins" ) func main() { - configPath := flag.String("config", config.DefaultConfigPath, "path to config file") dataDir := flag.String("data", "/var/lib/homeagent", "data directory") + httpAddr := flag.String("webui", ":8080", "webui listen address") + cliSocket := flag.String("socket", "", "cli unix socket path (default: /cli.sock)") flag.Parse() - log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile) - log.Printf("[homed] starting HomeAgent v0.1.0") - - cfg, err := config.Load(*configPath) - if err != nil { - log.Fatalf("load config: %v", err) + if *cliSocket == "" { + *cliSocket = filepath.Join(*dataDir, "cli.sock") } - cfg.Daemon.DataDir = *dataDir + log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile) + log.Printf("[homed] starting HomeAgent v0.1.0 (pure kernel)") - agentWorkDir := filepath.Join(cfg.Daemon.DataDir, "agentfs") + agentWorkDir := filepath.Join(*dataDir, "agentfs") dirs := []string{ - cfg.Daemon.DataDir, - filepath.Join(cfg.Daemon.DataDir, "snapshots"), - filepath.Join(cfg.Daemon.DataDir, "skills"), - filepath.Join(cfg.Daemon.DataDir, "plugins"), - filepath.Join(cfg.Daemon.DataDir, "changesets"), - filepath.Join(cfg.Daemon.DataDir, "memory"), - filepath.Join(cfg.Daemon.DataDir, "memory", "raw"), - filepath.Join(cfg.Daemon.DataDir, "adapters"), + *dataDir, + filepath.Join(*dataDir, "snapshots"), + filepath.Join(*dataDir, "skills"), + filepath.Join(*dataDir, "plugins"), + filepath.Join(*dataDir, "changesets"), + filepath.Join(*dataDir, "memory"), + filepath.Join(*dataDir, "memory", "raw"), + filepath.Join(*dataDir, "adapters"), agentWorkDir, } for _, d := range dirs { @@ -66,8 +64,11 @@ func main() { } } - // === Graph Memory === - memDB, err := memory.NewGraphDB(filepath.Join(cfg.Daemon.DataDir, "memory", "graph.db")) + // ======================================================================== + // 基础设施层:记忆、技能 + // ======================================================================== + + memDB, err := memory.NewGraphDB(filepath.Join(*dataDir, "memory", "graph.db")) if err != nil { log.Printf("[homed] warning: memory init failed: %v", err) memDB = nil @@ -79,15 +80,9 @@ func main() { } memIdx := memory.NewIndexer(memDB) - - // === Social Store(人物特质与关系网)=== socialStore := social.New(memDB) - if memDB != nil { - log.Printf("[homed] social store initialized") - } - // === Memory Pipeline === - distiller := pipeline.NewDistiller(memDB, cfg.Daemon.DataDir, pipeline.DistillerConfig{ + distiller := pipeline.NewDistiller(memDB, *dataDir, pipeline.DistillerConfig{ Interval: 10 * time.Minute, RetentionDays: 7, BatchSize: 50, @@ -97,41 +92,24 @@ func main() { defer distiller.Stop() } - // === Skills === - skMgr := skill.NewManager(filepath.Join(cfg.Daemon.DataDir, "skills")) + skMgr := skill.NewManager(filepath.Join(*dataDir, "skills")) if err := skMgr.Init(); err != nil { log.Printf("[homed] warning: skill init failed: %v", err) } - // === Plugin Registry (OpenClaw SKILL.md compatible) === - pluginReg := plugin.NewRegistry() - // 注册内置原生插件工厂 - pluginReg.RegisterNative("qq", func(name string, config map[string]interface{}, iom *agentIO.IOManager) (agentIO.Device, error) { - wsURL, _ := config["entry"].(string) - if wsURL == "" { - wsURL = "ws://127.0.0.1:6700" - } - accessToken, _ := config["access_token"].(string) - return onebot.NewDevice(name, wsURL, accessToken, iom), nil - }) + // ======================================================================== + // 配置中心(SQLite 持久化,唯一配置源) + // ======================================================================== - // === Config Registry (统一配置中心,SQLite 持久化) === - // 所有配置收敛到 SQLite,YAML 仅作首次 seed - cfgReg := internalConfig.NewConfigRegistry(filepath.Join(cfg.Daemon.DataDir, "config.db")) + cfgReg := internalConfig.NewConfigRegistry(filepath.Join(*dataDir, "config.db")) defer cfgReg.Close() - cfgReg.SeedFrom(cfg) - cfg = cfgReg.ToConfig() // 此后全从 DB 读取 + cfgReg.SeedDefaults(*dataDir) + cfg := cfgReg.ToConfig() - pluginReg.SetConfigRegistry(cfgReg) - log.Printf("[homed] config registry seeded from YAML, %d keys in SQLite", len(cfgReg.List(""))) + // ======================================================================== + // Lua VM(LLM 协议适配) + // ======================================================================== - // === Supervisor === - sup := supervisor.New(cfg) - if err := sup.Start(); err != nil { - log.Fatalf("start supervisor: %v", err) - } - - // === Lua VM === luaVM := luapkg.NewVM(filepath.Join(cfg.Daemon.DataDir, "adapters")) if err := luaVM.Start(); err != nil { log.Printf("[homed] warning: lua vm init failed: %v", err) @@ -139,66 +117,19 @@ func main() { defer luaVM.Stop() } - // === IO Abstraction Layer (唯一输入路径) === - iom := agentIO.NewIOManager() + // ======================================================================== + // 守护管理(代理生命周期管理) + // ======================================================================== - // 插件绑定 IO 管理器 → 插件自动注册为 IO 设备 - pluginReg.SetIOManager(iom) - // 首次加载插件 - if result, err := pluginReg.Reload(filepath.Join(cfg.Daemon.DataDir, "plugins")); err != nil { - log.Printf("[homed] warning: load plugins: %v", err) - } else { - log.Printf("[homed] %s", result) + sup := supervisor.New(cfg) + if err := sup.Start(); err != nil { + log.Fatalf("start supervisor: %v", err) } - // === Text Memory (三层记忆: Context → Text → Graph) === - textMem := text.New(filepath.Join(cfg.Daemon.DataDir, "memory", "text")) - if err := textMem.Start(); err != nil { - log.Printf("[homed] warning: text memory start: %v", err) - } else { - defer textMem.Stop() - log.Printf("[homed] text memory active at %s", filepath.Join(cfg.Daemon.DataDir, "memory", "text")) - } + // ======================================================================== + // 变更追踪(overlayfs) + // ======================================================================== - // Wire IO output events → TextMemory + distiller → GraphMemory - if distiller != nil { - go func() { - for evt := range iom.OutputChan() { - if evt.Target == "memory" && evt.Type == "memory_candidate" { - source, _ := evt.Payload["source"].(string) - input, _ := evt.Payload["input"].(string) - response, _ := evt.Payload["response"].(string) - toolsUsed, _ := evt.Payload["tools_used"].([]string) - agentID, _ := evt.Payload["agent_id"].(string) - - // 1. 写文本记忆(持久化原始日志) - if input != "" && textMem != nil { - te := text.Event{ - Timestamp: time.Now().Unix(), - Source: source, - Input: input, - Response: response, - ToolsUsed: toolsUsed, - AgentID: agentID, - } - if err := textMem.Append(te); err != nil { - log.Printf("[homed] text memory append: %v", err) - } - } - - // 2. 喂蒸馏器(生成三元组 → 图记忆) - if input != "" { - distiller.Append("agent", "user", input) - } - if response != "" { - distiller.Append("agent", "assistant", response) - } - } - } - }() - } - - // === Change Tracker (overlayfs-based, 追踪所有修改) === trk := tracker.NewTracker(cfg.Daemon.DataDir, agentWorkDir) if err := trk.Init(); err != nil { log.Printf("[homed] warning: tracker init: %v", err) @@ -210,7 +141,64 @@ func main() { } } - // === API Provider Manager(多 LLM 源,通过 Lua 适配器兼容不同 API)=== + // ======================================================================== + // 内核 API:IOManager(IO 抽象层) + EventBus(事件总线) + // 所有插件通过这两个通道与核心交互 + // ======================================================================== + + iom := agentIO.NewIOManager() + evBus := events.NewBus() + log.Printf("[homed] kernel API ready: IOManager + EventBus") + + // ======================================================================== + // 文本记忆 + 记忆蒸馏管线 + // ======================================================================== + + textMem := text.New(filepath.Join(cfg.Daemon.DataDir, "memory", "text")) + if err := textMem.Start(); err != nil { + log.Printf("[homed] warning: text memory start: %v", err) + } else { + defer textMem.Stop() + log.Printf("[homed] text memory active at %s", filepath.Join(cfg.Daemon.DataDir, "memory", "text")) + } + + go func() { + for evt := range iom.OutputChan() { + if evt.Target == "memory" && evt.Type == "memory_candidate" { + source, _ := evt.Payload["source"].(string) + input, _ := evt.Payload["input"].(string) + response, _ := evt.Payload["response"].(string) + toolsUsed, _ := evt.Payload["tools_used"].([]string) + agentID, _ := evt.Payload["agent_id"].(string) + + if input != "" && textMem != nil { + te := text.Event{ + Timestamp: time.Now().Unix(), + Source: source, + Input: input, + Response: response, + ToolsUsed: toolsUsed, + AgentID: agentID, + } + if err := textMem.Append(te); err != nil { + log.Printf("[homed] text memory append: %v", err) + } + } + + if input != "" { + distiller.Append("agent", "user", input) + } + if response != "" { + distiller.Append("agent", "assistant", response) + } + } + } + }() + + // ======================================================================== + // LLM Provider 管理(多源,通过 Lua 适配器协议转换) + // ======================================================================== + apiKey := cfg.LLM.APIKey if apiKey == "" { apiKey = os.Getenv("DEEPSEEK_API_KEY") @@ -232,13 +220,31 @@ func main() { }, luaVM, src.Adapter) providerMgr.Register(src.Name, luaProvider) } - // 默认源由 config 指定 if cfg.LLM.Provider != "" { providerMgr.SetDefault(cfg.LLM.Provider) } provider := providerMgr.Default() - // === Personality (固定人格内核) === + // ======================================================================== + // 文档记忆 + 知识库 + // ======================================================================== + + docStore := document.NewStore(filepath.Join(cfg.Daemon.DataDir, "memory", "documents")) + if err := docStore.Start(); err != nil { + log.Printf("[homed] warning: document store: %v", err) + } + + ks := knowledge.NewStore(filepath.Join(cfg.Daemon.DataDir, "knowledge")) + if err := ks.Start(); err != nil { + log.Printf("[homed] warning: knowledge store: %v", err) + } else { + log.Printf("[homed] knowledge store active with %d items", len(ks.List())) + } + + // ======================================================================== + // 人格设定 + // ======================================================================== + personalPath := filepath.Join(cfg.Daemon.DataDir, "personal", "personal.md") personality, err := agentPkg.LoadPersonality(personalPath) if err != nil { @@ -248,41 +254,55 @@ func main() { log.Printf("[homed] personality loaded (%d bytes)", len(personality.Content)) } - // === Document Memory (第二层记忆:上下文→文档) === - docStore := document.NewStore(filepath.Join(cfg.Daemon.DataDir, "memory", "documents")) - if err := docStore.Start(); err != nil { - log.Printf("[homed] warning: document store: %v", err) - } + // ======================================================================== + // 阶段管道(StageHost)+ 插件系统(Registry) + // ======================================================================== - // === Knowledge Store (知识库) === - ks := knowledge.NewStore(filepath.Join(cfg.Daemon.DataDir, "knowledge")) - if err := ks.Start(); err != nil { - log.Printf("[homed] warning: knowledge store: %v", err) - } else { - log.Printf("[homed] knowledge store active with %d items", len(ks.List())) - } - - // === Event Bus (系统事件总线) === - evBus := events.NewBus() - log.Printf("[homed] event bus initialized") - - // === Stage Host (阶段管道编排) === stageHost := agentCore.NewStageHost() - stageHost.SyncFromRegistry(pluginReg) - log.Printf("[homed] stage host initialized with %d plugin sdks", pluginReg.SDKPluginCount()) - // === Test DeepSeek Plugin === - tdBus := sdk.NewInProcessBus() - tdPlugin := test_deepseek.New(tdBus) - // 插件作用域的配置表(RegisterPluginAPI 自动设置 config_test_deepseek 表) - if err := pluginReg.RegisterPluginAPI(tdPlugin); err != nil { - log.Printf("[homed] warning: register test_deepseek plugin: %v", err) - } else { - stageHost.RegisterPlugin(tdPlugin) - log.Printf("[homed] test_deepseek plugin registered (config table: config_test_deepseek)") + pluginReg := plugin.NewRegistry() + pluginReg.SetIOManager(iom) + pluginReg.SetEventBus(evBus) + pluginReg.SetMemory(memDB) + pluginReg.SetTextMemory(textMem) + pluginReg.SetDocStore(docStore) + pluginReg.SetKnowledge(ks) + pluginReg.SetProviderManager(providerMgr) + pluginReg.SetConfigRegistry(cfgReg) + pluginReg.SetPluginDir(filepath.Join(cfg.Daemon.DataDir, "plugins")) + + // Wire registration callbacks: plugins' RegisterTool/RegisterStage → StageHost + pluginReg.SetToolRegistrar(func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error { + return stageHost.RegisterTool(name, def, handler) + }) + pluginReg.SetStageRegistrar(func(stage sdk.Stage, handler sdk.StageHandler) { + stageHost.RegisterStage(stage, handler) + }) + pluginReg.SetAPIRegistrar(func(name string) error { + return nil + }) + + // 为内置插件注入内核依赖(各插件通过 init() 自注册工厂) + cli.DefaultSocket = *cliSocket + openclaw.SkillsDir = filepath.Join(cfg.Daemon.DataDir, "skills") + webui.Configure(*httpAddr, + sup, memDB, skMgr, luaVM, cfg, iom, textMem, ks, trk, cfgReg, pluginReg, evBus, + ) + + // Auto-create plugins directory (without hardcoding plugin names) + plgDir := filepath.Join(cfg.Daemon.DataDir, "plugins") + os.MkdirAll(plgDir, 0755) + + // Load all plugins — each scans its own dir and is loaded via factory or .so + if err := pluginReg.Load(plgDir); err != nil { + log.Printf("[homed] warning: load plugins: %v", err) } + log.Printf("[homed] stage host ready with %d registered tools", stageHost.ToolCount()) + + // ======================================================================== + // Agent Core + // ======================================================================== - // === Single Agent Core === agent := agentCore.New(agentCore.AgentConfig{ ID: "main", SystemPrompt: `你是 HomeAgent,一个持续运行的个人管家。 @@ -306,25 +326,20 @@ func main() { 12. llm_list_sources — 列出所有可用的 LLM 源 13. llm_set_source — 切换到指定 LLM 源 -当用户问及个人信息或历史时,调用 memory_recall。 -当用户告诉了你新的个人信息时,调用 memory_commit。 -当用户提到某个人的性格、喜好或人际关系时,使用 person_set_trait 和 person_relate 记录。 -需要查询知识时使用 knowledge_search。 -如需切换 LLM 供应商(如从 DeepSeek 切到 OpenAI),使用 llm_list_sources 查看可用源,再用 llm_set_source 切换。 回复你的真实想法,用自然语言与用户交流。`, Provider: provider, ProviderManager: providerMgr, - IO: iom, - Memory: memDB, - Indexer: memIdx, - Skills: skMgr, - Tracker: trk, - MaxToolTurns: 10, - DocStore: docStore, - Knowledge: ks, - SocialStore: socialStore, - TextMemory: textMem, - Personality: personality, + IO: iom, + Memory: memDB, + Indexer: memIdx, + Skills: skMgr, + Tracker: trk, + MaxToolTurns: 10, + DocStore: docStore, + Knowledge: ks, + SocialStore: socialStore, + TextMemory: textMem, + Personality: personality, PluginReg: pluginReg, PluginDir: filepath.Join(cfg.Daemon.DataDir, "plugins"), ContextSavePath: filepath.Join(cfg.Daemon.DataDir, "memory", "context.json"), @@ -334,27 +349,23 @@ func main() { agent.Start() defer agent.Stop() - // Wire supervisor with tracker + agent registration (after both exist) sup.SetTracker(trk) sup.RegisterAgent("main") log.Printf("[homed] main agent started, model=%s base=%s sources=%d adapters=%d", cfg.LLM.Model, cfg.LLM.BaseURL, len(cfg.LLM.Sources), len(luaVM.ListAdapters())) + log.Printf("[homed] kernel ready, waiting for plugin IO...") - // === Built-in HTTP API & WebUI Plugin === - webui := api.NewWebUIPlugin( - "webui", cfg.Daemon.ListenAddr, - sup, memDB, skMgr, luaVM, cfg, iom, textMem, ks, trk, cfgReg, pluginReg, - ) - iom.RegisterDevice(webui) - webui.Start() - defer webui.Stop() + // ======================================================================== + // 等待退出信号 + // ======================================================================== sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) <-sigCh log.Printf("[homed] shutting down...") + pluginReg.StopAll() if trk != nil { trk.Stop() } @@ -364,5 +375,3 @@ func main() { sup.Shutdown() log.Printf("[homed] stopped") } - - diff --git a/cmd/waiter/main.go b/cmd/waiter/main.go new file mode 100644 index 0000000..58028ea --- /dev/null +++ b/cmd/waiter/main.go @@ -0,0 +1,180 @@ +package main + +import ( + "bufio" + "encoding/json" + "flag" + "fmt" + "log" + "net" + "net/http" + "os" + "strings" + "sync" + "time" +) + +func main() { + socket := flag.String("socket", "/var/lib/homeagent/cli.sock", "unix socket path") + remote := flag.String("remote", "", "remote webui URL (e.g. http://127.0.0.1:8080)") + say := flag.String("say", "", "send a message and print response (one-shot, no TUI)") + flag.Parse() + + if *say != "" { + if *remote != "" { + sayRemote(*remote, *say) + } else { + sayLocal(*socket, *say) + } + return + } + + if *remote != "" { + runRemote(*remote) + } else { + runLocal(*socket) + } +} + +// sayLocal sends one message via Unix socket and prints the response +func sayLocal(socketPath, message string) { + conn, err := net.Dial("unix", socketPath) + if err != nil { + log.Fatalf("connect to %s: %v", socketPath, err) + } + defer conn.Close() + + fmt.Fprintf(conn, "%s\n", message) + + scanner := bufio.NewScanner(conn) + scanner.Scan() + if err := scanner.Err(); err != nil { + log.Fatalf("read: %v", err) + } + + var resp struct { + Type string `json:"type"` + Content string `json:"content"` + Error string `json:"error"` + } + if err := json.Unmarshal(scanner.Bytes(), &resp); err != nil { + fmt.Println(scanner.Text()) + return + } + switch resp.Type { + case "response": + fmt.Println(resp.Content) + case "error": + log.Fatalf("error: %s", resp.Error) + default: + fmt.Println(scanner.Text()) + } +} + +// sayRemote sends one message via HTTP and prints the response +func sayRemote(baseURL, message string) { + baseURL = strings.TrimRight(baseURL, "/") + body := fmt.Sprintf(`{"message":%q}`, message) + resp, err := http.Post(baseURL+"/api/v1/chat", "application/json", strings.NewReader(body)) + if err != nil { + log.Fatalf("http post: %v", err) + } + defer resp.Body.Close() + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + if content, ok := result["response"].(string); ok { + fmt.Println(content) + } +} + +// runLocal starts an interactive TUI via Unix socket +func runLocal(socketPath string) { + conn, err := net.Dial("unix", socketPath) + if err != nil { + log.Fatalf("connect to %s: %v", socketPath, err) + } + defer conn.Close() + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + scanner := bufio.NewScanner(conn) + for scanner.Scan() { + var resp struct { + Type string `json:"type"` + Content string `json:"content"` + Error string `json:"error"` + } + if err := json.Unmarshal(scanner.Bytes(), &resp); err != nil { + fmt.Println(scanner.Text()) + continue + } + switch resp.Type { + case "response": + fmt.Println(resp.Content) + case "error": + fmt.Fprintf(os.Stderr, "error: %s\n", resp.Error) + default: + fmt.Println(scanner.Text()) + } + } + }() + + scanner := bufio.NewScanner(os.Stdin) + fmt.Print("> ") + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + fmt.Print("> ") + continue + } + if line == "/exit" || line == "/quit" { + break + } + fmt.Fprintf(conn, "%s\n", line) + } + + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + select { + case <-done: + case <-time.After(60 * time.Second): + } +} + +// runRemote starts an interactive TUI via HTTP +func runRemote(baseURL string) { + baseURL = strings.TrimRight(baseURL, "/") + scanner := bufio.NewScanner(os.Stdin) + fmt.Print("> ") + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + fmt.Print("> ") + continue + } + if line == "/exit" || line == "/quit" { + break + } + body := fmt.Sprintf(`{"message":%q}`, line) + resp, err := http.Post(baseURL+"/api/v1/chat", "application/json", strings.NewReader(body)) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + fmt.Print("> ") + continue + } + var result map[string]interface{} + if resp.StatusCode == http.StatusOK { + json.NewDecoder(resp.Body).Decode(&result) + } + resp.Body.Close() + if content, ok := result["response"].(string); ok { + fmt.Println(content) + } + fmt.Print("> ") + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 47c8109..864347a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -583,70 +583,71 @@ Agent.eventLoop() → handleInput → processTextInput ## 十五、代码结构 ``` -cmd/homed/main.go — 入口:组装所有子系统 +cmd/homed/main.go — 入口:组装所有子系统, 零 IO +cmd/waiter/main.go — CLI 客户端 (Unix socket) internal/ ├── agent/ │ ├── core/ -│ │ ├── agent.go — Agent 核心:事件循环、工具循环、心跳 +│ │ ├── agent.go — Agent: eventLoop/interceptLoop/process/distillLoop │ │ ├── context.go — RelevanceContext:TF-IDF 上下文管理 -│ │ └── stages.go — StageHost:阶段管道编排 +│ │ └── stages.go — StageHost:阶段管道编排 (并行执行) │ ├── api/ -│ │ └── provider.go — Provider 接口 + DeepSeek/Ollama 实现 +│ │ └── provider.go — Provider 接口 + DeepSeek/Ollama/LuaAdaptedProvider │ ├── io/ -│ │ └── channel.go — IOManager + Device 接口(过渡期保留) +│ │ └── channel.go — IOManager (排队/中断/输出三通道) │ └── personal.go — 人格加载 -├── agent/ -│ ├── core/ -│ │ ├── agent.go — Agent 核心:事件循环、工具循环、心跳、selfInputCh -│ │ ├── context.go — RelevanceContext:TF-IDF 上下文管理 -│ │ └── stages.go — StageHost:阶段管道编排 -│ ├── api/ -│ │ └── provider.go — Provider 接口 + OpenAI/Ollama/LuaAdaptedProvider -│ ├── io/ -│ │ └── channel.go — IOManager + Device 接口(过渡期保留) -│ └── personal.go — 人格加载 -├── api/ -│ ├── handler.go — HTTP API 端点 + WebUI (内联 HTML/JS/CSS) -│ └── plugin.go — WebUI Device 包装 +├── sdk/ ★ PluginSDK: 核心 Go API +│ ├── plugin.go — Plugin 接口 + PluginSDK 结构体 +│ ├── memory.go — MemoryAPI (图/文本/文档) +│ ├── knowledge.go — KnowledgeAPI +│ ├── settings.go — SettingsAPI (配置) +│ └── llm.go — LLMAPI (源管理) ├── config/ -│ └── registry.go — ConfigRegistry:统一配置中心 +│ └── registry.go — ConfigRegistry:统一配置中心 (SQLite) ├── events/ │ └── bus.go — 系统事件总线 (Publish/Subscribe) +├── plugin/ +│ ├── registry.go — 注册表:生命周期 Load/StopAll/Reload, RegisterFactory +│ ├── manifest.go — PluginManifest (plugin.json 元数据) +│ ├── dynamic.go — .so 动态加载器 (Go plugin.Open) +│ └── plugin.go — SKILL 插件解析 (OpenClaw 兼容) +├── plugins/ +│ ├── all.go — 空白导入触发所有内置插件 init() +│ ├── timer/plugin.go — 定时器 (timer_set 工具 + 中断反馈) +│ ├── cli/plugin.go — CLI 插件 (Unix socket, InjectTextSync) +│ ├── openclaw/plugin.go — OpenClaw 兼容 (SKILL.md → SDK 工具注册) +│ └── webui/ — WebUI 插件 (HTTP 服务器 + 仪表盘) +│ ├── plugin.go +│ └── handler.go ├── memory/ │ ├── graph.go — SQLite 图数据库 │ ├── indexer.go — 图索引器 │ ├── vector/store.go — TF-IDF 向量存储 │ ├── document/doc.go — 文档记忆 -│ ├── text/text.go — 文本记忆(JSONL) +│ ├── text/text.go — 文本记忆 (JSONL) │ └── pipeline/ — 蒸馏器 ├── knowledge/ │ └── knowledge.go — 知识系统 -├── plugin/ -│ ├── plugin.go — 插件注册表 + ConfigRegistry + SettingsAPI 注入 -│ └── sdk/ -│ ├── api.go — PluginAPI (Tool/Stage/Event/Settings/Memory/Knowledge) -│ └── bus.go — 插件内部 EventBus 接口 ├── onebot/ — OneBot V11 QQ 协议实现 ├── tracker/ — 变更追踪 (overlayfs) ├── supervisor/ — 守护进程 ├── skill/ — 技能管理器 ├── lua/ -│ ├── vm.go — Lua VM (json.encode/decode, CallTransformRequest/Response) -│ └── adapters/ -│ ├── openai.lua — OpenAI 协议适配 -│ ├── deepseek.lua — DeepSeek 协议适配 (temperature=0, reasoning) -│ └── ollama.lua — Ollama 协议适配 +│ ├── vm.go — Lua VM (json.encode/decode, transform) +│ └── adapters/ — LLM 协议适配器脚本 ├── network/ — 网络监控 ├── container/ — 容器管理 ├── snapshot/ — 快照 ├── embed/ — 嵌入 └── tokenizer/ — 分词器 config/ — 顶层配置加载 -├── config.go — Config 结构 + 加载/保存 +├── config.go — Config 结构 └── config.yaml pkg/types/ — 类型定义 docs/ -└── ARCHITECTURE.md — 本架构文档 +├── ARCHITECTURE.md — 本架构文档 +├── ADAPTER.md — Lua 适配器文档 +└── PLAN.md — 实施计划/概览 ``` --- @@ -664,3 +665,109 @@ docs/ | 核心 IO | IOManager `EmitOutput` 直出 | 全部走 `output_send` 工具 | | 插件工具路由 | IOManager `ExecuteTool` 链 | StageHost + Registry 双层路由 | | 内部任务 | 无 | selfInputCh 自循环通道(不经过 IO) | + +--- + +## 十七、插件自注册与动态加载 + +### 自注册机制 + +内置插件通过 `init()` 自注册,无需 `main.go` 硬编码: + +```go +// internal/plugins/timer/plugin.go +func init() { + plugin.RegisterFactory("timer", func(name string, config map[string]interface{}) (sdk.Plugin, error) { + return New(name), nil + }) +} +``` + +空白导入文件 `internal/plugins/all.go` 触发所有内置插件的 `init()`: + +```go +package plugins +import ( + _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cli" + _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/openclaw" + _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/timer" + _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/webui" +) +``` + +`main.go` 不再需要 `RegisterNative` 调用,只需设置包级变量注入运行时依赖: + +```go +cli.DefaultSocket = *cliSocket +openclaw.SkillsDir = filepath.Join(*dataDir, "skills") +webui.Configure(httpAddr, sup, memDB, ...) +pluginReg.Load(plgDir) // 自动扫描目录 + 使用已注册的工厂 +``` + +`Load()` 分两步执行: +1. 扫描 `plugins/` 下已有子目录,匹配已注册工厂加载 +2. 对已注册工厂但尚无目录的,自动创建目录并加载 + +### 动态 .so 加载 + +第三方插件编译为 `.so` 文件,放入 `/plugins//`: + +``` +/plugins/myplugin/ + plugin.json { "name": "myplugin", "version": "1.0", "entry": "plugin.so" } + plugin.so (Go -buildmode=plugin, 导出 NewPlugin 函数) +``` + +加载器 (`internal/plugin/dynamic.go`) 流程: + +```go +tryLoadSO(dir, name, config): + 1. plugin.Open("plugin.so") + 2. Lookup("NewPlugin") — 签名 func(name string, config map[string]interface{}) (sdk.Plugin, error) + 3. 调用 factory, 包装为 dynamicPlugin +``` + +内置插件保持 init() 自注册编译进内核,第三方插件以 .so 形式热加载。 + +--- + +## 十八、中断打断机制 + +### 架构 + +``` +interceptLoop (独立 goroutine) + ├── InputInterruptChan() ← 定时器/消息通知等 + │ + ├── (a) cancelLLM() → Provider HTTP 请求取消 + ├── (b) interceptCh < text → process() turn 前 drainInterrupt() + └── (c) InjectInput("interrupt", "text", ...) → 空闲时触发新处理 +``` + +### 三种投递路径 + +| 路径 | 目标 | 触发时机 | +|------|------|---------| +| **(a) cancelLLM** | 取消进行中的 Provider HTTP 请求 | 拦截到 `context.Canceled` | +| **(b) interceptCh** | process() 工具循环中注入 `[打断消息]` | 每个 LLM call 前 `drainInterrupt()` | +| **(c) InjectInput** | eventLoop 空闲时启动新处理循环 | 无进行中请求时 | + +### process() 内中断注入 + +```go +for turn := 0; turn < maxTurns; turn++ { + if text := a.drainInterrupt(); text != "" { + msgs = append(msgs, agentAPI.Message{ + Role: "system", + Content: fmt.Sprintf("[打断消息] %s", text), + }) + } + // LLM call with cancellable context + reqCtx, cancel := context.WithCancel(a.ctx) + a.cancelLLM = cancel // interceptLoop 可调用 + resp, err := provider.Chat(reqCtx, req) + a.cancelLLM = nil + cancel() + // ... tool call loop ... +} +``` diff --git a/homed b/homed deleted file mode 100755 index de8c58c..0000000 Binary files a/homed and /dev/null differ diff --git a/internal/agent/core/agent.go b/internal/agent/core/agent.go index 6e60a43..5eb546a 100644 --- a/internal/agent/core/agent.go +++ b/internal/agent/core/agent.go @@ -18,7 +18,7 @@ import ( "gitcode.com/JianFeeeee/HomeAgent/internal/memory/social" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/text" "gitcode.com/JianFeeeee/HomeAgent/internal/plugin" - sdk "gitcode.com/JianFeeeee/HomeAgent/internal/plugin/sdk" + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" "gitcode.com/JianFeeeee/HomeAgent/internal/skill" "gitcode.com/JianFeeeee/HomeAgent/internal/tracker" "gitcode.com/JianFeeeee/HomeAgent/pkg/types" @@ -82,6 +82,13 @@ type Agent struct { childMu sync.Mutex childNextID int64 childResults map[string]string + + // 高优先级打断通道:interceptLoop 注入,process() 在工具循环轮次间非阻塞读取 + interceptCh chan string + + // 进行中的 LLM 请求取消函数,interceptLoop 可调用以在请求中打断 + cancelLLM context.CancelFunc + llmMu sync.Mutex } type AgentConfig struct { @@ -148,11 +155,13 @@ func New(cfg AgentConfig) *Agent { eventBus: cfg.EventBus, selfInputCh: make(chan string, 64), childResults: make(map[string]string), + interceptCh: make(chan string, 64), } } func (a *Agent) Start() { go a.eventLoop() + go a.interceptLoop() go a.distillLoop() log.Printf("[agent] %s started, waiting for IO interrupts", a.id) } @@ -191,6 +200,45 @@ func (a *Agent) eventLoop() { } } +// interceptLoop 独立 goroutine 监控中断通道。 +// 两种路径投递: +// a) 通过 cancelLLM + interceptCh 直接打断进行中的 LLM 请求 +// b) 通过 a.io.InjectInput() → InputChan → eventLoop(代理空闲时触发新处理循环) +func (a *Agent) interceptLoop() { + for { + select { + case evt := <-a.io.InputInterruptChan(): + text, _ := evt.Payload["content"].(string) + if text == "" { + continue + } + log.Printf("[agent] interrupt from %s: %s", evt.Source, truncateStr(text, 80)) + + // (a) 直接取消进行中的 LLM 请求 + a.llmMu.Lock() + if a.cancelLLM != nil { + a.cancelLLM() + log.Printf("[agent] LLM request cancelled by interrupt") + } + a.llmMu.Unlock() + + // 注入拦截通道 — process() 在工具循环中非阻塞读取 + select { + case a.interceptCh <- text: + default: + } + + // (b) 投递为新输入 — 代理空闲时 eventLoop 会消费 + a.io.InjectInput("interrupt", "text", map[string]interface{}{ + "content": fmt.Sprintf("[interrupt] %s: %s", evt.Source, text), + }) + + case <-a.ctx.Done(): + return + } + } +} + // handleSelfInput 处理自循环输入(内部任务,不经过 IO 层) func (a *Agent) handleSelfInput(task string) { a.processTextInput(&agentIO.InputEvent{ @@ -361,6 +409,15 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri } for turn := 0; turn < a.maxTurns; turn++ { + // === 高优先级打断:每次 LLM 调用前检查拦截通道 === + if text := a.drainInterrupt(); text != "" { + msgs = append(msgs, agentAPI.Message{ + Role: "system", + Content: fmt.Sprintf("[打断消息] 用户发来一条紧急消息,请优先处理:\n%s", text), + }) + log.Printf("[agent] interrupt injected before LLM call (turn %d)", turn) + } + req := &agentAPI.CompletionRequest{ Messages: msgs, MaxTokens: 4096, @@ -371,7 +428,19 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri }, } - resp, err := a.provider.Chat(a.ctx, req) + // 可取消的 LLM 调用:interceptLoop 通过 cancelLLM 打断进行中的请求 + reqCtx, reqCancel := context.WithCancel(a.ctx) + a.llmMu.Lock() + a.cancelLLM = reqCancel + a.llmMu.Unlock() + + resp, err := a.provider.Chat(reqCtx, req) + + a.llmMu.Lock() + a.cancelLLM = nil + a.llmMu.Unlock() + reqCancel() + if err != nil { return "", toolsUsed, fmt.Errorf("provider: %w", err) } @@ -1969,9 +2038,19 @@ func getFloat(m map[string]interface{}, key string) float64 { } func truncateStr(s string, max int) string { - runes := []rune(s) - if len(runes) > max { - return string(runes[:max]) + "..." + if len(s) <= max { + return s + } + return s[:max] + "..." +} + +// drainInterrupt 非阻塞读取 interceptCh 中的一条打断消息。 +// 若有多条,只取最先到达的一条(丢弃后续)。 +func (a *Agent) drainInterrupt() string { + select { + case text := <-a.interceptCh: + return text + default: + return "" } - return s } diff --git a/internal/agent/core/stages.go b/internal/agent/core/stages.go index 6b5d391..8dd31cf 100644 --- a/internal/agent/core/stages.go +++ b/internal/agent/core/stages.go @@ -2,73 +2,89 @@ package core import ( "fmt" + "sync" - "gitcode.com/JianFeeeee/HomeAgent/internal/plugin" - sdk "gitcode.com/JianFeeeee/HomeAgent/internal/plugin/sdk" + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" ) type StageHost struct { - plugins []*sdk.PluginAPI + mu sync.RWMutex toolDefs []sdk.ToolDef tools map[string]sdk.ToolHandler + stages map[sdk.Stage][]sdk.StageHandler } func NewStageHost() *StageHost { return &StageHost{ - tools: make(map[string]sdk.ToolHandler), + tools: make(map[string]sdk.ToolHandler), + stages: make(map[sdk.Stage][]sdk.StageHandler), } } -func (h *StageHost) RegisterPlugin(api *sdk.PluginAPI) { - h.plugins = append(h.plugins, api) - for name, handler := range api.Tools() { - h.tools[name] = handler - h.toolDefs = append(h.toolDefs, sdk.ToolDef{Name: name}) +func (h *StageHost) RegisterTool(name string, def sdk.ToolDef, handler sdk.ToolHandler) error { + h.mu.Lock() + defer h.mu.Unlock() + if _, exists := h.tools[name]; exists { + return fmt.Errorf("tool %s already registered", name) } + h.tools[name] = handler + h.toolDefs = append(h.toolDefs, def) + return nil } -// SyncFromRegistry 从插件注册表同步 SDK 插件 -func (h *StageHost) SyncFromRegistry(reg *plugin.Registry) { - if reg == nil { - return - } - for _, td := range reg.GetAllSDKToolDefs() { - h.toolDefs = append(h.toolDefs, td) - } +func (h *StageHost) RegisterStage(stage sdk.Stage, handler sdk.StageHandler) { + h.mu.Lock() + defer h.mu.Unlock() + h.stages[stage] = append(h.stages[stage], handler) } func (h *StageHost) GetToolDefs() []sdk.ToolDef { - return h.toolDefs + h.mu.RLock() + defer h.mu.RUnlock() + defs := make([]sdk.ToolDef, len(h.toolDefs)) + copy(defs, h.toolDefs) + return defs } func (h *StageHost) ExecuteTool(name string, args map[string]interface{}) (interface{}, error) { - if handler, ok := h.tools[name]; ok { - return handler(args) + h.mu.RLock() + handler, ok := h.tools[name] + h.mu.RUnlock() + if !ok { + return nil, fmt.Errorf("tool %s not found in any plugin", name) } - return nil, fmt.Errorf("tool %s not found in any plugin", name) + return handler(args) } +// RunStage 并行调用同阶段所有注册的处理函数。 +// 各 handler 共享 *StageContext,通过其内置 RWMutex 安全读写: +// - 只读操作先调用 ctx.RLock() / defer ctx.RUnlock() +// - 写操作(如设置 ctx.Response)先调用 ctx.Lock() / defer ctx.Unlock() +// 如果任意 handler 设置了 Response,后续 handler 可通过 ctx.IsResponded() 判断后提前返回。 func (h *StageHost) RunStage(stage sdk.Stage, ctx *sdk.StageContext) { - for _, p := range h.plugins { - for _, handler := range p.StageHandlers(stage) { - if err := handler(ctx); err != nil { - return - } - if ctx.Response != nil { - return - } - } + h.mu.RLock() + handlers := h.stages[stage] + h.mu.RUnlock() + if len(handlers) == 0 { + return } + var wg sync.WaitGroup + for _, handler := range handlers { + wg.Add(1) + go func(fn sdk.StageHandler) { + defer wg.Done() + fn(ctx) + }(handler) + } + wg.Wait() } func (h *StageHost) RunStageAll(stage sdk.Stage, ctx *sdk.StageContext) { - for _, p := range h.plugins { - for _, handler := range p.StageHandlers(stage) { - handler(ctx) - } - } + h.RunStage(stage, ctx) } -func (h *StageHost) PluginCount() int { - return len(h.plugins) +func (h *StageHost) ToolCount() int { + h.mu.RLock() + defer h.mu.RUnlock() + return len(h.tools) } diff --git a/internal/agent/core/stages_test.go b/internal/agent/core/stages_test.go index 7eb6227..c98debc 100644 --- a/internal/agent/core/stages_test.go +++ b/internal/agent/core/stages_test.go @@ -1,23 +1,21 @@ package core import ( + "sync" + "sync/atomic" "testing" - sdk "gitcode.com/JianFeeeee/HomeAgent/internal/plugin/sdk" + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" ) -func TestStageHostRegisterPlugin(t *testing.T) { +func TestStageHostRegisterTool(t *testing.T) { host := NewStageHost() - api := sdk.NewPluginAPI("test", "1.0.0", nil, nil, nil) - api.RegisterTool("test_tool", func(args map[string]interface{}) (interface{}, error) { + err := host.RegisterTool("test_tool", sdk.ToolDef{Name: "test_tool"}, func(args map[string]interface{}) (interface{}, error) { return "ok", nil }) - - host.RegisterPlugin(api) - - if host.PluginCount() != 1 { - t.Errorf("expected 1 plugin, got %d", host.PluginCount()) + if err != nil { + t.Fatalf("register: %v", err) } defs := host.GetToolDefs() @@ -29,16 +27,22 @@ func TestStageHostRegisterPlugin(t *testing.T) { } } +func TestStageHostRegisterToolDuplicate(t *testing.T) { + host := NewStageHost() + host.RegisterTool("dup", sdk.ToolDef{Name: "dup"}, nil) + err := host.RegisterTool("dup", sdk.ToolDef{Name: "dup"}, nil) + if err == nil { + t.Error("expected error on duplicate tool") + } +} + func TestStageHostExecuteTool(t *testing.T) { host := NewStageHost() - api := sdk.NewPluginAPI("test", "1.0.0", nil, nil, nil) - api.RegisterTool("hello", func(args map[string]interface{}) (interface{}, error) { + host.RegisterTool("hello", sdk.ToolDef{Name: "hello"}, func(args map[string]interface{}) (interface{}, error) { return "world", nil }) - host.RegisterPlugin(api) - result, err := host.ExecuteTool("hello", nil) if err != nil { t.Fatalf("execute: %v", err) @@ -55,16 +59,13 @@ func TestStageHostExecuteTool(t *testing.T) { func TestStageHostRunStage(t *testing.T) { host := NewStageHost() - api := sdk.NewPluginAPI("test", "1.0.0", nil, nil, nil) var called bool - api.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error { + host.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error { called = true return nil }) - host.RegisterPlugin(api) - ctx := &sdk.StageContext{RawMessage: "hello"} host.RunStage(sdk.StageOnInput, ctx) @@ -73,56 +74,78 @@ func TestStageHostRunStage(t *testing.T) { } } -func TestStageHostRunStageShortCircuit(t *testing.T) { +func TestStageHostRunStageParallel(t *testing.T) { host := NewStageHost() - api1 := sdk.NewPluginAPI("p1", "1.0.0", nil, nil, nil) - api1.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error { - resp := "short-circuited" - ctx.Response = &resp + // Two handlers that both try to set Response under Lock. + // Only the first to acquire Lock actually wins; the second sees IsResponded() and skips. + host.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error { + ctx.Lock() + if ctx.Response == nil { + resp := "from-first" + ctx.Response = &resp + } + ctx.Unlock() return nil }) - - var api2called bool - api2 := sdk.NewPluginAPI("p2", "1.0.0", nil, nil, nil) - api2.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error { - api2called = true + host.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error { + ctx.Lock() + if ctx.Response == nil { + resp := "from-second" + ctx.Response = &resp + } + ctx.Unlock() return nil }) - host.RegisterPlugin(api1) - host.RegisterPlugin(api2) - ctx := &sdk.StageContext{RawMessage: "hello"} host.RunStage(sdk.StageOnInput, ctx) - if ctx.Response == nil || *ctx.Response != "short-circuited" { - t.Errorf("expected short-circuited, got %v", ctx.Response) + if ctx.Response == nil { + t.Fatal("expected a response to be set") } - if api2called { - t.Error("api2 should not have been called after short circuit") + if *ctx.Response != "from-first" && *ctx.Response != "from-second" { + t.Errorf("expected either from-first or from-second, got %s", *ctx.Response) + } +} + +func TestStageHostRunStageConcurrency(t *testing.T) { + host := NewStageHost() + + var counter int32 + n := 10 + for i := 0; i < n; i++ { + host.RegisterStage(sdk.StageAfterOutput, func(ctx *sdk.StageContext) error { + atomic.AddInt32(&counter, 1) + return nil + }) + } + + host.RunStage(sdk.StageAfterOutput, &sdk.StageContext{}) + + if int(counter) != n { + t.Errorf("expected %d handlers called, got %d", n, counter) } } func TestStageHostRunStageAll(t *testing.T) { host := NewStageHost() + var mu sync.Mutex count := 0 - api1 := sdk.NewPluginAPI("p1", "1.0.0", nil, nil, nil) - api1.RegisterStage(sdk.StageAfterOutput, func(ctx *sdk.StageContext) error { + host.RegisterStage(sdk.StageAfterOutput, func(ctx *sdk.StageContext) error { + mu.Lock() count++ + mu.Unlock() return nil }) - - api2 := sdk.NewPluginAPI("p2", "1.0.0", nil, nil, nil) - api2.RegisterStage(sdk.StageAfterOutput, func(ctx *sdk.StageContext) error { + host.RegisterStage(sdk.StageAfterOutput, func(ctx *sdk.StageContext) error { + mu.Lock() count++ + mu.Unlock() return nil }) - host.RegisterPlugin(api1) - host.RegisterPlugin(api2) - host.RunStageAll(sdk.StageAfterOutput, &sdk.StageContext{}) if count != 2 { @@ -130,42 +153,11 @@ func TestStageHostRunStageAll(t *testing.T) { } } -func TestStageHostMultiplePlugins(t *testing.T) { - host := NewStageHost() - - p1 := sdk.NewPluginAPI("p1", "1.0.0", nil, nil, nil) - p1.RegisterTool("tool1", func(args map[string]interface{}) (interface{}, error) { - return "from_p1", nil - }) - - p2 := sdk.NewPluginAPI("p2", "1.0.0", nil, nil, nil) - p2.RegisterTool("tool2", func(args map[string]interface{}) (interface{}, error) { - return "from_p2", nil - }) - - host.RegisterPlugin(p1) - host.RegisterPlugin(p2) - - if host.PluginCount() != 2 { - t.Errorf("expected 2 plugins, got %d", host.PluginCount()) - } - - r1, _ := host.ExecuteTool("tool1", nil) - if r1.(string) != "from_p1" { - t.Errorf("expected from_p1, got %v", r1) - } - - r2, _ := host.ExecuteTool("tool2", nil) - if r2.(string) != "from_p2" { - t.Errorf("expected from_p2, got %v", r2) - } -} - func TestStageHostEmpty(t *testing.T) { host := NewStageHost() - if host.PluginCount() != 0 { - t.Errorf("expected 0 plugins, got %d", host.PluginCount()) + if host.ToolCount() != 0 { + t.Errorf("expected 0 tools, got %d", host.ToolCount()) } defs := host.GetToolDefs() @@ -178,6 +170,30 @@ func TestStageHostEmpty(t *testing.T) { t.Error("expected error on empty host") } - // RunStage on empty host should not panic host.RunStage(sdk.StageOnInput, &sdk.StageContext{}) } + +func TestStageHostMultipleTools(t *testing.T) { + host := NewStageHost() + + host.RegisterTool("tool1", sdk.ToolDef{Name: "tool1"}, func(args map[string]interface{}) (interface{}, error) { + return "from_p1", nil + }) + host.RegisterTool("tool2", sdk.ToolDef{Name: "tool2"}, func(args map[string]interface{}) (interface{}, error) { + return "from_p2", nil + }) + + if host.ToolCount() != 2 { + t.Errorf("expected 2 tools, got %d", host.ToolCount()) + } + + r1, _ := host.ExecuteTool("tool1", nil) + if r1.(string) != "from_p1" { + t.Errorf("expected from_p1, got %v", r1) + } + + r2, _ := host.ExecuteTool("tool2", nil) + if r2.(string) != "from_p2" { + t.Errorf("expected from_p2, got %v", r2) + } +} diff --git a/internal/agent/io/channel.go b/internal/agent/io/channel.go index efc322f..b31ee0d 100644 --- a/internal/agent/io/channel.go +++ b/internal/agent/io/channel.go @@ -89,18 +89,20 @@ type OutputEvent struct { } type IOManager struct { - mu sync.RWMutex - devices map[string]Device - inputCh chan *InputEvent - outputCh chan *OutputEvent - nextReqID int64 + mu sync.RWMutex + devices map[string]Device + inputCh chan *InputEvent + interruptCh chan *InputEvent + outputCh chan *OutputEvent + nextReqID int64 } func NewIOManager() *IOManager { return &IOManager{ - devices: make(map[string]Device), - inputCh: make(chan *InputEvent, 256), - outputCh: make(chan *OutputEvent, 256), + devices: make(map[string]Device), + inputCh: make(chan *InputEvent, 256), + interruptCh: make(chan *InputEvent, 64), + outputCh: make(chan *OutputEvent, 256), } } @@ -239,6 +241,29 @@ func (m *IOManager) InjectTextTo(source, outputChannel, text string) { }) } +// InjectInterrupt 向中断通道发送输入 +func (m *IOManager) InjectInterrupt(source, channel string, payload map[string]interface{}) { + if payload == nil { + payload = map[string]interface{}{} + } + m.interruptCh <- &InputEvent{ + RequestID: m.nextRequestID(), + Source: source, + Type: payload["type"].(string), + Payload: payload, + OutputChannel: channel, + } +} + +func (m *IOManager) InjectInterruptText(source, channel, text string) { + m.InjectInterrupt(source, channel, map[string]interface{}{ + "type": "text", + "content": text, + }) +} + +func (m *IOManager) InputInterruptChan() <-chan *InputEvent { return m.interruptCh } + // InjectTextSyncTo 注入文本输入(同步等待)并指定输出通道 func (m *IOManager) InjectTextSyncTo(source, outputChannel, text string) *OutputEvent { return m.InjectInputSyncTo(source, outputChannel, "text", map[string]interface{}{ diff --git a/internal/api/plugin.go b/internal/api/plugin.go deleted file mode 100644 index e81e664..0000000 --- a/internal/api/plugin.go +++ /dev/null @@ -1,71 +0,0 @@ -package api - -import ( - "log" - "net/http" - - agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" - internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config" - "gitcode.com/JianFeeeee/HomeAgent/internal/knowledge" - luaVM "gitcode.com/JianFeeeee/HomeAgent/internal/lua" - "gitcode.com/JianFeeeee/HomeAgent/internal/memory" - "gitcode.com/JianFeeeee/HomeAgent/internal/memory/text" - "gitcode.com/JianFeeeee/HomeAgent/internal/plugin" - "gitcode.com/JianFeeeee/HomeAgent/internal/skill" - "gitcode.com/JianFeeeee/HomeAgent/internal/supervisor" - "gitcode.com/JianFeeeee/HomeAgent/internal/tracker" - "gitcode.com/JianFeeeee/HomeAgent/pkg/types" -) - -// Plugin 将 HTTP API + WebUI 包装为 IO Device -// 作为 HomeAgent 自带的默认 IO 通道插件 -type Plugin struct { - name string - handler *Handler - server *http.Server - addr string - mux *http.ServeMux -} - -func NewWebUIPlugin(name, addr string, sup *supervisor.Daemon, mem *memory.GraphDB, sk *skill.Manager, - lua *luaVM.VM, cfg *types.Config, iom *agentIO.IOManager, tm *text.Memory, ks *knowledge.Store, tr *tracker.Tracker, - cr *internalConfig.ConfigRegistry, pr *plugin.Registry) *Plugin { - - h := NewHandler(sup, mem, sk, lua, cfg, iom, tm, ks, tr, cr, pr) - mux := http.NewServeMux() - h.RegisterRoutes(mux) - - return &Plugin{ - name: name, - handler: h, - addr: addr, - mux: mux, - } -} - -func (p *Plugin) Name() string { return p.name } -func (p *Plugin) Type() agentIO.DeviceType { return agentIO.DeviceIO } -func (p *Plugin) Description() string { return "HTTP API & Web Dashboard" } -func (p *Plugin) OutputCapabilities() agentIO.OutputCapability { return agentIO.CapText | agentIO.CapStructured } -func (p *Plugin) Tools() []agentIO.ToolDef { return nil } -func (p *Plugin) Execute(tool string, args map[string]interface{}) (interface{}, error) { - return nil, nil -} - -func (p *Plugin) Start() error { - p.server = &http.Server{Addr: p.addr, Handler: p.mux} - go func() { - log.Printf("[webui] HTTP server listening on %s", p.addr) - if err := p.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Printf("[webui] server error: %v", err) - } - }() - return nil -} - -func (p *Plugin) Stop() error { - if p.server != nil { - return p.server.Close() - } - return nil -} diff --git a/internal/config/registry.go b/internal/config/registry.go index 051ff70..178db4b 100644 --- a/internal/config/registry.go +++ b/internal/config/registry.go @@ -150,12 +150,11 @@ func (r *ConfigRegistry) Close() error { return r.db.Close() } -// SeedFrom 从 *types.Config 批量导入默认值到 config 表(仅空表时写入) -func (r *ConfigRegistry) SeedFrom(cfg *types.Config) { +// SeedDefaults 用硬编码默认值填充 config 表(仅空表时写入),不再依赖 YAML +func (r *ConfigRegistry) SeedDefaults(dataDir string) { r.mu.Lock() defer r.mu.Unlock() - // 检查是否已有数据 var count int r.db.QueryRow(`SELECT COUNT(*) FROM config`).Scan(&count) if count > 0 { @@ -177,44 +176,54 @@ func (r *ConfigRegistry) SeedFrom(cfg *types.Config) { set := func(k, v string) { stmt.Exec(k, v) } // daemon - set("core.daemon.listen_addr", cfg.Daemon.ListenAddr) - set("core.daemon.data_dir", cfg.Daemon.DataDir) - set("core.daemon.heartbeat_interval", cfg.Daemon.HeartbeatInterval.String()) - set("core.daemon.check_interval", cfg.Daemon.CheckInterval.String()) - set("core.daemon.log_level", cfg.Daemon.LogLevel) + set("core.daemon.listen_addr", ":8080") + set("core.daemon.data_dir", dataDir) + set("core.daemon.heartbeat_interval", "15s") + set("core.daemon.check_interval", "30s") + set("core.daemon.log_level", "info") // llm - set("core.llm.provider", cfg.LLM.Provider) - set("core.llm.model", cfg.LLM.Model) - set("core.llm.base_url", cfg.LLM.BaseURL) - set("core.llm.adapter", cfg.LLM.Adapter) - set("core.llm.temperature", strconv.FormatFloat(cfg.LLM.Temperature, 'f', 2, 64)) - set("core.llm.max_tokens", strconv.Itoa(cfg.LLM.MaxTokens)) + set("core.llm.provider", "deepseek") + set("core.llm.model", "deepseek-v4-flash") + set("core.llm.base_url", "https://api.deepseek.com") + set("core.llm.adapter", "deepseek") + set("core.llm.temperature", "0.7") + set("core.llm.max_tokens", "4096") // llm sources - for _, src := range cfg.LLM.Sources { - p := "core.llm.sources." + src.Name - set(p+".base_url", src.BaseURL) - set(p+".model", src.Model) - set(p+".adapter", src.Adapter) - set(p+".adapter_path", src.AdapterPath) + sources := map[string]map[string]string{ + "deepseek": {"base_url": "https://api.deepseek.com", "model": "deepseek-v4-flash", "adapter": "deepseek", "adapter_path": "adapters/deepseek.lua"}, + "openai": {"base_url": "https://api.openai.com/v1", "model": "gpt-4o", "adapter": "openai", "adapter_path": "adapters/openai.lua"}, + "anthropic": {"base_url": "https://api.anthropic.com", "model": "claude-sonnet-4-20250514", "adapter": "anthropic", "adapter_path": "adapters/anthropic.lua"}, + "gemini": {"base_url": "https://generativelanguage.googleapis.com", "model": "gemini-2.0-flash", "adapter": "gemini", "adapter_path": "adapters/gemini.lua"}, + "mistral": {"base_url": "https://api.mistral.ai", "model": "mistral-large-latest", "adapter": "mistral", "adapter_path": "adapters/mistral.lua"}, + "groq": {"base_url": "https://api.groq.com", "model": "llama3-70b-8192", "adapter": "groq", "adapter_path": "adapters/groq.lua"}, + "github": {"base_url": "https://models.inference.ai.azure.com", "model": "gpt-4o", "adapter": "github", "adapter_path": "adapters/github.lua"}, + "ollama": {"base_url": "http://localhost:11434", "model": "llama3", "adapter": "ollama", "adapter_path": "adapters/ollama.lua"}, + } + for name, props := range sources { + p := "core.llm.sources." + name + set(p+".base_url", props["base_url"]) + set(p+".model", props["model"]) + set(p+".adapter", props["adapter"]) + set(p+".adapter_path", props["adapter_path"]) } // defaults - set("core.defaults.image", cfg.Defaults.Image) - set("core.defaults.openclaw_enabled", strconv.FormatBool(cfg.Defaults.OpenClawEnabled)) - set("core.defaults.snapshot.interval", cfg.Defaults.SnapshotPolicy.Interval.String()) - set("core.defaults.snapshot.max_snapshots", strconv.Itoa(cfg.Defaults.SnapshotPolicy.MaxSnapshots)) - set("core.defaults.snapshot.pre_action", strconv.FormatBool(cfg.Defaults.SnapshotPolicy.PreAction)) - set("core.defaults.snapshot.post_action", strconv.FormatBool(cfg.Defaults.SnapshotPolicy.PostAction)) - set("core.defaults.rollback.max_retries", strconv.Itoa(cfg.Defaults.RollbackPolicy.MaxRetries)) - set("core.defaults.rollback.health_threshold", strconv.Itoa(int(cfg.Defaults.RollbackPolicy.HealthThreshold))) - set("core.defaults.rollback.cooldown_period", cfg.Defaults.RollbackPolicy.CooldownPeriod.String()) - set("core.defaults.rollback.auto_rollback", strconv.FormatBool(cfg.Defaults.RollbackPolicy.AutoRollback)) - set("core.defaults.resource.cpu", cfg.Defaults.ResourceLimit.CPU) - set("core.defaults.resource.memory", cfg.Defaults.ResourceLimit.Memory) - set("core.defaults.resource.disk", cfg.Defaults.ResourceLimit.Disk) - set("core.defaults.resource.network", strconv.FormatBool(cfg.Defaults.ResourceLimit.Network)) + set("core.defaults.image", "homeagent/agent-base:latest") + set("core.defaults.openclaw_enabled", "true") + set("core.defaults.snapshot.interval", "10m") + set("core.defaults.snapshot.max_snapshots", "20") + set("core.defaults.snapshot.pre_action", "true") + set("core.defaults.snapshot.post_action", "false") + set("core.defaults.rollback.max_retries", "3") + set("core.defaults.rollback.health_threshold", "3") + set("core.defaults.rollback.cooldown_period", "30s") + set("core.defaults.rollback.auto_rollback", "true") + set("core.defaults.resource.cpu", "2") + set("core.defaults.resource.memory", "2g") + set("core.defaults.resource.disk", "10g") + set("core.defaults.resource.network", "true") set("core.agent.max_tool_turns", "10") set("core.agent.max_context_size", "30") set("core.agent.distill_interval", "30m") diff --git a/internal/config/registry_test.go b/internal/config/registry_test.go index baa9bc4..52ab542 100644 --- a/internal/config/registry_test.go +++ b/internal/config/registry_test.go @@ -4,8 +4,6 @@ import ( "path/filepath" "testing" "time" - - "gitcode.com/JianFeeeee/HomeAgent/pkg/types" ) func TestRegistryBasic(t *testing.T) { @@ -154,70 +152,36 @@ func TestPluginConfig(t *testing.T) { r.Close() } -func TestSeedFromToConfig(t *testing.T) { +func TestSeedDefaultsToConfig(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.db") - cfg := &types.Config{ - Daemon: types.DaemonConfig{ - ListenAddr: ":9090", - DataDir: "/tmp/test", - HeartbeatInterval: 10 * time.Second, - CheckInterval: 20 * time.Second, - LogLevel: "debug", - }, - LLM: types.LLMConfig{ - Provider: "deepseek", - Model: "deepseek-v4-flash", - BaseURL: "https://api.deepseek.com", - Adapter: "deepseek", - Temperature: 0.5, - MaxTokens: 2048, - Sources: []types.LLMSource{ - {Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", Adapter: "deepseek", AdapterPath: "adapters/deepseek.lua"}, - {Name: "openai", BaseURL: "https://api.openai.com/v1", Model: "gpt-4o", Adapter: "openai", AdapterPath: "adapters/openai.lua"}, - }, - }, - Defaults: types.AgentConfig{ - Image: "test-image", - OpenClawEnabled: true, - }, - } - r := NewConfigRegistry(path) - r.SeedFrom(cfg) + r.SeedDefaults(dir) - // Verify DB was seeded - if len(r.List("")) == 0 { - t.Fatal("SeedFrom produced empty DB") + // Verify DB was seeded with expected number of keys + keys := r.List("") + if len(keys) == 0 { + t.Fatal("SeedDefaults produced empty DB") } // Reconstruct config from DB cfg2 := r.ToConfig() - if cfg2.Daemon.ListenAddr != ":9090" { - t.Fatalf("expected :9090, got %s", cfg2.Daemon.ListenAddr) - } - if cfg2.Daemon.LogLevel != "debug" { - t.Fatalf("expected debug, got %s", cfg2.Daemon.LogLevel) + if cfg2.Daemon.ListenAddr != ":8080" { + t.Fatalf("expected :8080, got %s", cfg2.Daemon.ListenAddr) } if cfg2.LLM.Provider != "deepseek" { t.Fatalf("expected deepseek, got %s", cfg2.LLM.Provider) } - if cfg2.LLM.MaxTokens != 2048 { - t.Fatalf("expected 2048, got %d", cfg2.LLM.MaxTokens) - } - if len(cfg2.LLM.Sources) != 2 { - t.Fatalf("expected 2 sources, got %d", len(cfg2.LLM.Sources)) - } - if cfg2.LLM.Sources[0].AdapterPath != "adapters/deepseek.lua" { - t.Fatalf("expected adapters/deepseek.lua, got %s", cfg2.LLM.Sources[0].AdapterPath) + if len(cfg2.LLM.Sources) == 0 { + t.Fatal("expected at least 1 LLM source") } - // Second SeedFrom should be no-op (DB already has data) - r.SeedFrom(cfg) - if len(r.List("")) != len(r.List("")) { - t.Fatal("second SeedFrom changed DB count") + // Second SeedDefaults should be no-op (DB already has data) + r.SeedDefaults(dir) + if len(r.List("")) != len(keys) { + t.Fatal("second SeedDefaults changed DB count") } r.Close() diff --git a/internal/memory/document/document.go b/internal/memory/document/document.go index 03a0ba7..83a9ca1 100644 --- a/internal/memory/document/document.go +++ b/internal/memory/document/document.go @@ -131,6 +131,30 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry) (*Doc, error return doc, nil } +// Consume — 向量相似度查询并移除文档(召回后即从冷存储删除,避免重复记忆) +func (s *Store) Consume(text string, topK int) []*Doc { + s.mu.Lock() + defer s.mu.Unlock() + + if topK <= 0 { + topK = 5 + } + + vec := s.veczer.Vectorize(text) + results := s.vec.Search(vec, topK) + + var docs []*Doc + for _, r := range results { + if d, ok := s.docs[r.ID]; ok { + delete(s.docs, r.ID) + s.vec.Remove(r.ID) + s.dirty = true + docs = append(docs, d) + } + } + return docs +} + // Query — 向量相似度查询文档 func (s *Store) Query(text string, topK int) []*Doc { s.mu.RLock() diff --git a/internal/memory/indexer.go b/internal/memory/indexer.go index 2ce0734..b1cf895 100644 --- a/internal/memory/indexer.go +++ b/internal/memory/indexer.go @@ -14,14 +14,25 @@ type Indexer struct { vec *vector.Store veczer *vector.TFIDFVectorizer mu sync.RWMutex - trained bool + trained bool + recalled map[string]bool // 已通过工具调用显式召回的实体名,自动注入时跳过 } func NewIndexer(db *GraphDB) *Indexer { return &Indexer{ - db: db, - vec: vector.NewStore(), - veczer: vector.NewTFIDFVectorizer(2), + db: db, + vec: vector.NewStore(), + veczer: vector.NewTFIDFVectorizer(2), + recalled: make(map[string]bool), + } +} + +// MarkRecalled 标记实体名已被工具调用显式召回,后续自动注入时跳过 +func (idx *Indexer) MarkRecalled(names ...string) { + idx.mu.Lock() + defer idx.mu.Unlock() + for _, name := range names { + idx.recalled[name] = true } } @@ -100,15 +111,25 @@ func (idx *Indexer) BuildContext(userInput string) *InjectedContext { return &InjectedContext{Summary: ""} } + // 过滤已被工具调用显式召回的实体,避免重复注入 + idx.mu.RLock() + filtered := result.Entities[:0] + for _, e := range result.Entities { + if !idx.recalled[e.Name] { + filtered = append(filtered, e) + } + } + idx.mu.RUnlock() + ctx := &InjectedContext{ - Entities: result.Entities, + Entities: filtered, Relations: nil, } - if len(result.Entities) > 0 { - summary := buildIndexSummary(result.Entities) + if len(filtered) > 0 { + summary := buildIndexSummary(filtered) ctx.Summary = summary - ctx.TokenEstimate = estimateTokens(summary) + len(result.Entities)*8 + ctx.TokenEstimate = estimateTokens(summary) + len(filtered)*8 } else { ctx.Summary = "" } diff --git a/internal/memory/social/social.go b/internal/memory/social/social.go new file mode 100644 index 0000000..3157e89 --- /dev/null +++ b/internal/memory/social/social.go @@ -0,0 +1,298 @@ +package social + +import ( + "fmt" + "strings" + "sync" + + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" +) + +const ( + entityTypePerson = "person" + entityTypeTrait = "trait_value" + traitPrefix = "trait:" +) + +type PersonProfile struct { + Name string `json:"name"` + Traits map[string]string `json:"traits,omitempty"` + Relations []SocialRelation `json:"relations,omitempty"` +} + +type SocialRelation struct { + Person string `json:"person"` + Relation string `json:"relation"` // 关系类型:朋友/家人/同事/邻居/... +} + +type SocialStore struct { + db *memory.GraphDB + mu sync.RWMutex +} + +func New(db *memory.GraphDB) *SocialStore { + return &SocialStore{db: db} +} + +// GetPerson 获取人物完整档案(特质 + 社交关系) +func (s *SocialStore) GetPerson(name string) (*PersonProfile, error) { + if s.db == nil { + return nil, fmt.Errorf("social store not available") + } + + result, err := s.db.Recall([]string{name}, nil, 2, "") + if err != nil { + return nil, err + } + + profile := &PersonProfile{ + Name: name, + Traits: make(map[string]string), + } + + // 查找指定 person 的 ID + var personID int64 + for _, e := range result.Entities { + if e.Name == name { + personID = e.ID + break + } + } + if personID == 0 { + return nil, fmt.Errorf("person '%s' not found", name) + } + + // 区分 trait 关系和社交关系 + for _, r := range result.Relations { + if strings.HasPrefix(r.RelationType, traitPrefix) { + // 特质:trait:<特质名> + traitName := strings.TrimPrefix(r.RelationType, traitPrefix) + if r.SourceID == personID { + profile.Traits[traitName] = r.TargetName + } else { + profile.Traits[traitName] = r.SourceName + } + } else if r.SourceID == personID { + profile.Relations = append(profile.Relations, SocialRelation{ + Person: r.TargetName, + Relation: r.RelationType, + }) + } else if r.TargetID == personID { + profile.Relations = append(profile.Relations, SocialRelation{ + Person: r.SourceName, + Relation: r.RelationType, + }) + } + } + + return profile, nil +} + +// SetTrait 设置/更新人物特质。如果同名特质已存在则覆盖 +func (s *SocialStore) SetTrait(name, trait, value string) error { + if s.db == nil { + return fmt.Errorf("social store not available") + } + + // 先清除旧特质值 + oldVal, found := s.GetTrait(name, trait) + if found && oldVal != "" { + s.db.Purge(map[string]string{ + "subject_contains": name, + "relation_type": traitPrefix + trait, + }, "soft") + } + + triples := []memory.Triple{ + { + Subject: name, + SubjectType: entityTypePerson, + Relation: traitPrefix + trait, + Object: value, + ObjectType: entityTypeTrait, + Confidence: 1.0, + }, + } + _, _, err := s.db.Commit(triples, "social_trait", 0) + return err +} + +// GetTrait 获取指定人物的指定特质值 +func (s *SocialStore) GetTrait(name, trait string) (string, bool) { + if s.db == nil { + return "", false + } + + result, err := s.db.Recall([]string{name}, nil, 1, "") + if err != nil || result == nil { + return "", false + } + + var personID int64 + for _, e := range result.Entities { + if e.Name == name { + personID = e.ID + break + } + } + if personID == 0 { + return "", false + } + + for _, r := range result.Relations { + if r.RelationType == traitPrefix+trait { + if r.SourceID == personID { + return r.TargetName, true + } + return r.SourceName, true + } + } + return "", false +} + +// AddRelation 建立两人之间的社交关系 +func (s *SocialStore) AddRelation(personA, relation, personB string) error { + if s.db == nil { + return fmt.Errorf("social store not available") + } + + triples := []memory.Triple{ + { + Subject: personA, + SubjectType: entityTypePerson, + Relation: relation, + Object: personB, + ObjectType: entityTypePerson, + Confidence: 1.0, + }, + } + _, _, err := s.db.Commit(triples, "social_relation", 0) + return err +} + +// RemoveRelation 删除两人之间的社交关系 +func (s *SocialStore) RemoveRelation(personA, relation, personB string) error { + if s.db == nil { + return fmt.Errorf("social store not available") + } + + _, err := s.db.Purge(map[string]string{ + "subject_contains": personA, + "target_contains": personB, + "relation_type": relation, + }, "soft") + return err +} + +// GetRelations 获取指定人物的所有社交关系 +func (s *SocialStore) GetRelations(name string) ([]SocialRelation, error) { + if s.db == nil { + return nil, fmt.Errorf("social store not available") + } + + result, err := s.db.Recall([]string{name}, nil, 1, "") + if err != nil { + return nil, err + } + + var personID int64 + for _, e := range result.Entities { + if e.Name == name { + personID = e.ID + break + } + } + if personID == 0 { + return nil, nil + } + + var relations []SocialRelation + for _, r := range result.Relations { + if strings.HasPrefix(r.RelationType, traitPrefix) { + continue + } + if r.SourceID == personID { + relations = append(relations, SocialRelation{Person: r.TargetName, Relation: r.RelationType}) + } else if r.TargetID == personID { + relations = append(relations, SocialRelation{Person: r.SourceName, Relation: r.RelationType}) + } + } + return relations, nil +} + +// GetNetwork 获取指定人物周围 depth 度的社交网络 +func (s *SocialStore) GetNetwork(name string, depth int) ([]*PersonProfile, error) { + if s.db == nil { + return nil, fmt.Errorf("social store not available") + } + + // 用 Recall 的 BFS 遍历获取多度关联 + result, err := s.db.Recall([]string{name}, nil, depth, "") + if err != nil { + return nil, err + } + + personMap := make(map[int64]*PersonProfile) + for _, e := range result.Entities { + p := &PersonProfile{ + Name: e.Name, + Traits: make(map[string]string), + } + personMap[e.ID] = p + } + + for _, r := range result.Relations { + if strings.HasPrefix(r.RelationType, traitPrefix) { + traitName := strings.TrimPrefix(r.RelationType, traitPrefix) + if p, ok := personMap[r.SourceID]; ok { + p.Traits[traitName] = r.TargetName + } + if p, ok := personMap[r.TargetID]; ok { + p.Traits[traitName] = r.SourceName + } + } + } + + // 收集关系 + for _, r := range result.Relations { + if strings.HasPrefix(r.RelationType, traitPrefix) { + continue + } + sr := SocialRelation{Relation: r.RelationType} + if p, ok := personMap[r.SourceID]; ok { + sr.Person = r.TargetName + p.Relations = append(p.Relations, sr) + } + sr = SocialRelation{Relation: r.RelationType} + if p, ok := personMap[r.TargetID]; ok { + sr.Person = r.SourceName + p.Relations = append(p.Relations, sr) + } + } + + var profiles []*PersonProfile + for _, p := range personMap { + profiles = append(profiles, p) + } + return profiles, nil +} + +// ListPersons 列出所有已知人物(entity.type = person) +func (s *SocialStore) ListPersons() ([]string, error) { + if s.db == nil { + return nil, fmt.Errorf("social store not available") + } + + result, err := s.db.Recall(nil, nil, 1, "") + if err != nil { + return nil, err + } + + var names []string + for _, e := range result.Entities { + if e.Type == entityTypePerson || e.Type == "Person" { + names = append(names, e.Name) + } + } + return names, nil +} diff --git a/internal/memory/text/text.go b/internal/memory/text/text.go index 9111075..fbd5a1e 100644 --- a/internal/memory/text/text.go +++ b/internal/memory/text/text.go @@ -227,6 +227,148 @@ func (m *Memory) FileCount() int { return len(files) } +// PurgeByFilter 删除所有满足 filter 函数的事件(重写所有 JSONL 文件) +func (m *Memory) PurgeByFilter(filter func(Event) bool) (int, error) { + m.mu.Lock() + defer m.mu.Unlock() + + // 关闭当前文件,准备重建 + if m.current != nil { + m.current.Close() + m.current = nil + } + + files, err := m.listFiles() + if err != nil { + return 0, err + } + + totalRemoved := 0 + for _, fpath := range files { + kept, removed, err := m.purgeFile(fpath, filter) + if err != nil { + log.Printf("[text memory] purge file %s: %v", fpath, err) + continue + } + totalRemoved += removed + + if len(kept) == 0 { + os.Remove(fpath) + } else if removed > 0 { + m.rewriteFile(fpath, kept) + } + } + + // 重新打开当前文件 + m.openCurrent() + return totalRemoved, nil +} + +// ReplaceByFilter 替换所有满足 filter 的事件(通过 replace 函数修改),重写文件 +func (m *Memory) ReplaceByFilter(filter func(Event) bool, replace func(Event) Event) (int, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if m.current != nil { + m.current.Close() + m.current = nil + } + + files, err := m.listFiles() + if err != nil { + return 0, err + } + + totalReplaced := 0 + for _, fpath := range files { + events, replaced, err := m.replaceFile(fpath, filter, replace) + if err != nil { + log.Printf("[text memory] replace file %s: %v", fpath, err) + continue + } + totalReplaced += replaced + + if len(events) == 0 { + os.Remove(fpath) + } else if replaced > 0 { + m.rewriteFile(fpath, events) + } + } + + m.openCurrent() + return totalReplaced, nil +} + +// ——— internal helpers ——— + +func (m *Memory) purgeFile(path string, filter func(Event) bool) (kept []Event, removed int, err error) { + f, err := os.Open(path) + if err != nil { + return nil, 0, err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 1024*1024), 1024*1024) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var evt Event + if err := json.Unmarshal([]byte(line), &evt); err != nil { + continue + } + if filter(evt) { + removed++ + } else { + kept = append(kept, evt) + } + } + return kept, removed, scanner.Err() +} + +func (m *Memory) replaceFile(path string, filter func(Event) bool, replace func(Event) Event) (events []Event, replaced int, err error) { + f, err := os.Open(path) + if err != nil { + return nil, 0, err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 1024*1024), 1024*1024) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var evt Event + if err := json.Unmarshal([]byte(line), &evt); err != nil { + continue + } + if filter(evt) { + evt = replace(evt) + replaced++ + } + events = append(events, evt) + } + return events, replaced, scanner.Err() +} + +func (m *Memory) rewriteFile(path string, events []Event) { + f, err := os.Create(path) + if err != nil { + log.Printf("[text memory] rewrite %s: %v", path, err) + return + } + defer f.Close() + + enc := json.NewEncoder(f) + for _, evt := range events { + enc.Encode(evt) + } +} + func (m *Memory) Stats() map[string]interface{} { m.mu.Lock() defer m.mu.Unlock() diff --git a/internal/plugin/dynamic.go b/internal/plugin/dynamic.go new file mode 100644 index 0000000..919702c --- /dev/null +++ b/internal/plugin/dynamic.go @@ -0,0 +1,87 @@ +package plugin + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "plugin" + + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" +) + +// .so 插件必须导出函数 NewPlugin,签名与 NativeFactory 一致: +// +// func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { +// return &myPlugin{name: name}, nil +// } +const ( + soEntry = "plugin.so" + luaEntry = "main.lua" + metaEntry = "plugin.json" +) + +type dynamicPlugin struct { + name string + impl sdk.Plugin +} + +func (p *dynamicPlugin) Name() string { return p.name } +func (p *dynamicPlugin) Start(s *sdk.PluginSDK) error { return p.impl.Start(s) } +func (p *dynamicPlugin) Stop() error { return p.impl.Stop() } + +// readManifest 读取插件目录下的 plugin.json。文件不存在时不报错。 +func readManifest(dir string) *PluginManifest { + data, err := os.ReadFile(filepath.Join(dir, metaEntry)) + if err != nil { + return nil + } + var m PluginManifest + if err := json.Unmarshal(data, &m); err != nil { + return nil + } + return &m +} + +// tryLoadSO 尝试从插件目录加载 plugin.so(Go plugin -buildmode=plugin)。 +// 返回 nil,nil 表示目录中没有 plugin.so。 +func tryLoadSO(dir, name string, config map[string]interface{}) (sdk.Plugin, error) { + soPath := filepath.Join(dir, soEntry) + if _, err := os.Stat(soPath); os.IsNotExist(err) { + return nil, nil + } + + p, err := plugin.Open(soPath) + if err != nil { + return nil, fmt.Errorf("plugin.Open %s: %w", soPath, err) + } + + sym, err := p.Lookup("NewPlugin") + if err != nil { + return nil, fmt.Errorf(".so %s must export NewPlugin: %w", soPath, err) + } + + fn, ok := sym.(func(string, map[string]interface{}) (sdk.Plugin, error)) + if !ok { + return nil, fmt.Errorf("NewPlugin in %s has wrong signature", soPath) + } + + plg, err := fn(name, config) + if err != nil { + return nil, fmt.Errorf("NewPlugin %s: %w", name, err) + } + + return &dynamicPlugin{name: name, impl: plg}, nil +} + +// tryLoadLua 尝试从插件目录加载 main.lua(Lua 插件)。 +// 返回 nil,nil 表示目录中没有 main.lua。 +func tryLoadLua(dir, name string, config map[string]interface{}) (sdk.Plugin, error) { + luaPath := filepath.Join(dir, luaEntry) + if _, err := os.Stat(luaPath); os.IsNotExist(err) { + return nil, nil + } + + // 预留:Lua 插件需在 LuaVM 中注册一个 LuaPlugin 包装器 + return nil, fmt.Errorf("lua plugin loading not yet implemented: %s", name) +} diff --git a/internal/plugin/manifest.go b/internal/plugin/manifest.go new file mode 100644 index 0000000..797c1a1 --- /dev/null +++ b/internal/plugin/manifest.go @@ -0,0 +1,11 @@ +package plugin + +// PluginManifest 每个插件目录中的 plugin.json 元数据。 +type PluginManifest struct { + Name string `json:"name"` + Version string `json:"version,omitempty"` + Description string `json:"description,omitempty"` + Author string `json:"author,omitempty"` + Entry string `json:"entry,omitempty"` // "plugin.so" | "main.lua" | "" + Deprecated bool `json:"deprecated,omitempty"` +} diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index 870b84a..9ffdc27 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -6,30 +6,23 @@ import ( "log" "os" "path/filepath" - "sort" "strings" "sync" - "time" agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" - internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config" - sdk "gitcode.com/JianFeeeee/HomeAgent/internal/plugin/sdk" ) type PluginType string const ( PluginTypeSKILL PluginType = "skill" - PluginTypeNative PluginType = "native" ) -// IOConfig 定义插件作为 IO 通道时的配置 -// 每个插件通过此配置声明自己的 I/O 端口 type IOConfig struct { - Type string `json:"type"` // "input" / "output" / "io" - InputRoute string `json:"input_route"` // 输入源标识,如 "qq", "email" - OutputRoute string `json:"output_route"` // 输出通道标识,默认等于 InputRoute - OutputCaps []string `json:"output_caps"` // 支持的输出能力: "text","file","image","audio","structured" + Type string `json:"type"` + InputRoute string `json:"input_route"` + OutputRoute string `json:"output_route"` + OutputCaps []string `json:"output_caps"` } type Plugin interface { @@ -41,10 +34,9 @@ type Plugin interface { Enabled() bool SetEnabled(bool) IOConfig() *IOConfig - Device() agentIO.Device // 内嵌的 IO 设备,nil 表示纯技能插件 + RawContent() string } -// ToolDef 复用 IO 抽象层的定义,确保 Plugin 和 Device 使用同一类型 type ToolDef = agentIO.ToolDef type SKILLPlugin struct { @@ -93,21 +85,11 @@ func LoadSKILL(path string) (*SKILLPlugin, error) { IO *IOConfig `json:"io,omitempty"` } if err := json.Unmarshal(data, &meta); err == nil { - if meta.Name != "" { - p.name = meta.Name - } - if meta.Description != "" { - p.description = meta.Description - } - if meta.Version != "" { - p.version = meta.Version - } - if meta.Author != "" { - p.author = meta.Author - } - if meta.IO != nil { - p.ioConfig = meta.IO - } + if meta.Name != "" { p.name = meta.Name } + if meta.Description != "" { p.description = meta.Description } + if meta.Version != "" { p.version = meta.Version } + if meta.Author != "" { p.author = meta.Author } + if meta.IO != nil { p.ioConfig = meta.IO } } } } else if filepath.Ext(path) == ".md" { @@ -129,565 +111,139 @@ func LoadSKILL(path string) (*SKILLPlugin, error) { return p, nil } -func (p *SKILLPlugin) Name() string { return p.name } +func (p *SKILLPlugin) Name() string { return p.name } func (p *SKILLPlugin) PluginType() PluginType { return PluginTypeSKILL } -func (p *SKILLPlugin) Description() string { return p.description } -func (p *SKILLPlugin) Version() string { return p.version } -func (p *SKILLPlugin) Enabled() bool { p.mu.RLock(); defer p.mu.RUnlock(); return p.enabled } -func (p *SKILLPlugin) SetEnabled(v bool) { p.mu.Lock(); defer p.mu.Unlock(); p.enabled = v } -func (p *SKILLPlugin) Tools() []ToolDef { return p.toolDefs } -func (p *SKILLPlugin) IOConfig() *IOConfig { return p.ioConfig } -func (p *SKILLPlugin) Device() agentIO.Device { return nil } // SKILL 插件无原生 IO 设备 -func (p *SKILLPlugin) RawContent() string { return p.rawContent } +func (p *SKILLPlugin) Description() string { return p.description } +func (p *SKILLPlugin) Version() string { return p.version } +func (p *SKILLPlugin) Enabled() bool { p.mu.RLock(); defer p.mu.RUnlock(); return p.enabled } +func (p *SKILLPlugin) SetEnabled(v bool) { p.mu.Lock(); defer p.mu.Unlock(); p.enabled = v } +func (p *SKILLPlugin) Tools() []ToolDef { return p.toolDefs } +func (p *SKILLPlugin) IOConfig() *IOConfig { return p.ioConfig } +func (p *SKILLPlugin) RawContent() string { return p.rawContent } -// NativeFactory 是内置插件构造器,用于需要原生 Go 实现的插件(如 OneBot QQ) -// 返回 agentIO.Device 以直接注册到 IO 管理层 -type NativeFactory func(name string, config map[string]interface{}, iom *agentIO.IOManager) (agentIO.Device, error) - -type Registry struct { - mu sync.RWMutex - plugins map[string]Plugin - ioMgr *agentIO.IOManager - factories map[string]NativeFactory // 名称匹配的插件使用原生实现 - sdkAPIs map[string]*sdkAPI // SDK 插件 API 实例 - cfgReg *internalConfig.ConfigRegistry -} - -// SetConfigRegistry 注入全局配置注册表,使 SDK 插件可访问配置 -func (r *Registry) SetConfigRegistry(cr *internalConfig.ConfigRegistry) { - r.mu.Lock() - defer r.mu.Unlock() - r.cfgReg = cr -} - -type sdkAPI struct { - api *sdk.PluginAPI - tools map[string]sdk.ToolHandler - stages map[sdk.Stage][]sdk.StageHandler -} - -func NewRegistry() *Registry { - return &Registry{ - plugins: make(map[string]Plugin), - factories: make(map[string]NativeFactory), - sdkAPIs: make(map[string]*sdkAPI), - } -} - -// RegisterPluginAPI 注册一个 SDK 插件 API 实例 -func (r *Registry) RegisterPluginAPI(api *sdk.PluginAPI) error { - r.mu.Lock() - defer r.mu.Unlock() - if _, ok := r.sdkAPIs[api.Name]; ok { - return fmt.Errorf("sdk api %s already registered", api.Name) - } - - // 注入 SettingsAPI(插件作用域配置表 config_) - if r.cfgReg != nil { - ps := r.cfgReg.PluginConfig(api.Name) - api.SetSettings(ps) - } - - r.sdkAPIs[api.Name] = &sdkAPI{ - api: api, - tools: api.Tools(), - stages: make(map[sdk.Stage][]sdk.StageHandler), - } - for stage := range sdk.AllStages() { - if handlers := api.StageHandlers(stage); len(handlers) > 0 { - r.sdkAPIs[api.Name].stages[stage] = handlers +func extractDescription(content string) string { + for _, line := range splitLines(content) { + line = trimSpace(line) + if line != "" && !strings.HasPrefix(line, "#") { + return line } } - log.Printf("[plugin] registered SDK plugin: %s (tools=%d, stages=%d)", - api.Name, len(api.Tools()), len(r.sdkAPIs[api.Name].stages)) - return nil + return "" } -// GetAllSDKToolDefs 收集所有 SDK 插件的工具定义 -func (r *Registry) GetAllSDKToolDefs() []sdk.ToolDef { - r.mu.RLock() - defer r.mu.RUnlock() - var defs []sdk.ToolDef - for _, sa := range r.sdkAPIs { - for name := range sa.tools { - defs = append(defs, sdk.ToolDef{Name: name}) - } - } - return defs -} - -// ExecuteSDKTool 执行 SDK 插件工具 -func (r *Registry) ExecuteSDKTool(name string, args map[string]interface{}) (interface{}, error) { - r.mu.RLock() - defer r.mu.RUnlock() - for _, sa := range r.sdkAPIs { - if handler, ok := sa.tools[name]; ok { - return handler(args) - } - } - return nil, fmt.Errorf("sdk tool %s not found", name) -} - -// GetAllSDKPlugins 获取所有已注册的 SDK 插件 API 实例 -func (r *Registry) GetAllSDKPlugins() []*sdk.PluginAPI { - r.mu.RLock() - defer r.mu.RUnlock() - apis := make([]*sdk.PluginAPI, 0, len(r.sdkAPIs)) - for _, sa := range r.sdkAPIs { - apis = append(apis, sa.api) - } - return apis -} - -// GetStageHandlers 获取所有 SDK 插件在指定阶段的处理器 -func (r *Registry) GetStageHandlers(stage sdk.Stage) []sdk.StageHandler { - r.mu.RLock() - defer r.mu.RUnlock() - var handlers []sdk.StageHandler - for _, sa := range r.sdkAPIs { - if h, ok := sa.stages[stage]; ok { - handlers = append(handlers, h...) - } - } - return handlers -} - -// SDKPluginCount 返回已注册的 SDK 插件数量 -func (r *Registry) SDKPluginCount() int { - r.mu.RLock() - defer r.mu.RUnlock() - return len(r.sdkAPIs) -} - -// RegisterNative 注册内置原生插件工厂。当从 plugins/ 加载插件时, -// 如果插件名称匹配已注册的工厂,优先使用原生设备注册。 -// 例如: r.RegisterNative("qq", onebot.NewDeviceFactory) -func (r *Registry) RegisterNative(name string, factory NativeFactory) { - r.mu.Lock() - defer r.mu.Unlock() - r.factories[name] = factory -} - -// SetIOManager 绑定 IO 管理器,启用 IO 设备自动注册 -func (r *Registry) SetIOManager(mgr *agentIO.IOManager) { - r.mu.Lock() - defer r.mu.Unlock() - r.ioMgr = mgr -} - -func (r *Registry) Register(p Plugin) { - r.mu.Lock() - defer r.mu.Unlock() - r.plugins[p.Name()] = p - log.Printf("[plugin] registered: %s (%s)", p.Name(), p.PluginType()) - - // 注册内嵌 IO 设备到 IOManager - if r.ioMgr != nil { - dev := p.Device() - if dev != nil { - // 原生设备直接注册 - if err := r.ioMgr.RegisterDevice(dev); err != nil { - log.Printf("[plugin] register native device %s: %v", p.Name(), err) - return +func extractField(content string, field string) string { + prefix := field + ":" + lowerPrefix := strings.ToLower(prefix) + for _, line := range splitLines(content) { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(strings.ToLower(trimmed), lowerPrefix) { + for i := 0; i < len(trimmed); i++ { + if trimmed[i] == ':' { + return strings.TrimSpace(trimmed[i+1:]) + } } - } else if p.IOConfig() != nil { - // 有 IO 配置但无原生设备 → 用 PluginDevice 包装 - dev = NewPluginDevice(p) - if err := r.ioMgr.RegisterDevice(dev); err != nil { - log.Printf("[plugin] register plugin device %s: %v", p.Name(), err) - return - } - } else { - return // 纯技能插件,无 IO 通道 - } - - if cfg := p.IOConfig(); cfg != nil { - log.Printf("[plugin] io device %s active (type=%s, caps=%v)", - p.Name(), cfg.Type, cfg.OutputCaps) } } + return "" } -// nativePlugin 包装原生 IO 设备为 Plugin 接口 -// Plugin 是容器,Device 是组件 -type nativePlugin struct { - name string - dev agentIO.Device - cfg *IOConfig -} - -func (p *nativePlugin) Name() string { return p.name } -func (p *nativePlugin) PluginType() PluginType { return PluginTypeNative } -func (p *nativePlugin) Description() string { return p.dev.Description() } -func (p *nativePlugin) Version() string { return "1.0.0" } -func (p *nativePlugin) Tools() []ToolDef { return p.dev.Tools() } -func (p *nativePlugin) Enabled() bool { return true } -func (p *nativePlugin) SetEnabled(v bool) {} -func (p *nativePlugin) IOConfig() *IOConfig { return p.cfg } -func (p *nativePlugin) Device() agentIO.Device { return p.dev } - -// Reload 原子化重载 plugins/ 目录: -// 1. 扫描磁盘加载新插件,构造并启动新 IO 设备 -// 2. 原子替换 IOManager 的设备表与路由表 -// 3. 停止并清理旧设备 -// 全程无通信中断:旧设备持续服务直到路由切换完成 -func (r *Registry) Reload(dir string) (string, error) { - entries, err := os.ReadDir(dir) - if err != nil { - if os.IsNotExist(err) { - return "插件目录不存在", nil - } - return "", fmt.Errorf("扫描插件目录: %w", err) - } - - // 1. 扫描磁盘,并行加载新插件 - type loadedPlugin struct { - name string - p Plugin - err error - } - var loaded []loadedPlugin - - for _, entry := range entries { - name := entry.Name() - if !entry.IsDir() && !strings.HasSuffix(name, ".md") { - continue - } - if entry.IsDir() { - // 略过非插件目录(不含 SKILL.md) - if _, err := os.Stat(filepath.Join(dir, name, "SKILL.md")); os.IsNotExist(err) { - continue - } - } else { - name = name[:len(name)-3] - } - - r.mu.RLock() - _, exists := r.plugins[name] - r.mu.RUnlock() - if exists { - continue // 已存在,跳过(后续可用 diff 检测变更) - } - - r.mu.RLock() - factory, hasFactory := r.factories[name] - r.mu.RUnlock() - - if hasFactory { - dev, err := factory(name, r.configFor(name, dir), r.ioMgr) - if err != nil { - loaded = append(loaded, loadedPlugin{name: name, err: err}) - continue - } - np := &nativePlugin{name: name, dev: dev} - np.cfg = extractIOConfig(r.readFile(filepath.Join(dir, name))) - loaded = append(loaded, loadedPlugin{name: name, p: np}) - } else { - p, err := LoadSKILL(filepath.Join(dir, entry.Name())) - if err != nil { - loaded = append(loaded, loadedPlugin{name: name, err: err}) - continue - } - loaded = append(loaded, loadedPlugin{name: name, p: p}) - } - } - - // 2. 启动新设备的 IO 通道 - newDevices := make(map[string]agentIO.Device) - for _, lp := range loaded { - if lp.err != nil { - log.Printf("[plugin] skip %s: %v", lp.name, lp.err) - continue - } - dev := lp.p.Device() - if dev == nil && lp.p.IOConfig() != nil { - dev = NewPluginDevice(lp.p) - } - if dev != nil { - dev.Start() // 新设备预先启动 - newDevices[lp.name] = dev - } - } - - // 3. 原子切换 - r.mu.Lock() - var oldPlugins map[string]Plugin - if r.ioMgr != nil { - // 获取旧设备并原子替换 - oldDevices := r.ioMgr.AtomicSwapDevices(newDevices) - // 停止旧设备 - for _, dev := range oldDevices { - go dev.Stop() - } - } else { - for _, dev := range newDevices { - dev.Stop() - } - } - // 替换插件表 - oldPlugins = r.plugins - r.plugins = make(map[string]Plugin) - for _, lp := range loaded { - if lp.p != nil { - r.plugins[lp.name] = lp.p - } - } - pluginCount := len(r.plugins) - ioCount := len(newDevices) - r.mu.Unlock() - - // 4. 清理旧插件资源 - for _, p := range oldPlugins { - _ = p - } - - log.Printf("[plugin] atomic reload: %d plugins, %d io channels", pluginCount, ioCount) - return fmt.Sprintf("插件重载完成: %d 个插件, %d 个 IO 通道", pluginCount, ioCount), nil -} - -func (r *Registry) Unregister(name string) { - r.mu.Lock() - defer r.mu.Unlock() - - if _, ok := r.plugins[name]; ok && r.ioMgr != nil { - r.ioMgr.UnregisterDevice(name) - log.Printf("[plugin] unregistered io device: %s", name) - } - delete(r.plugins, name) -} - -func (r *Registry) Get(name string) Plugin { - r.mu.RLock() - defer r.mu.RUnlock() - return r.plugins[name] -} - -func (r *Registry) List() []Plugin { - r.mu.RLock() - defer r.mu.RUnlock() - list := make([]Plugin, 0, len(r.plugins)) - for _, p := range r.plugins { - list = append(list, p) - } - sort.Slice(list, func(i, j int) bool { - return list[i].Name() < list[j].Name() - }) - return list -} - -func (r *Registry) ListEnabled() []Plugin { - r.mu.RLock() - defer r.mu.RUnlock() - var list []Plugin - for _, p := range r.plugins { - if p.Enabled() { - list = append(list, p) - } - } - return list -} - -func (r *Registry) GetAllToolDefs() []ToolDef { - r.mu.RLock() - defer r.mu.RUnlock() +func extractToolDefs(content string) []ToolDef { + lines := splitLines(content) var defs []ToolDef - for _, p := range r.plugins { - if !p.Enabled() { + var currentTool *ToolDef + inCodeBlock := false + + for i := 0; i < len(lines); i++ { + line := lines[i] + trimmed := strings.TrimSpace(line) + + if strings.HasPrefix(trimmed, "```") { + inCodeBlock = !inCodeBlock continue } - defs = append(defs, p.Tools()...) + if inCodeBlock { continue } + + if strings.HasPrefix(trimmed, "## ") && !strings.HasPrefix(trimmed, "### ") { + if currentTool != nil && currentTool.Name != "" { + defs = append(defs, *currentTool) + } + currentTool = &ToolDef{} + namePart := strings.TrimPrefix(trimmed, "## ") + if isNonToolSection(namePart) { + currentTool = nil + continue + } + currentTool.Name = namePart + currentTool.Parameters = map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + } + continue + } + + if strings.HasPrefix(trimmed, "### Tool: ") { + if currentTool != nil && currentTool.Name != "" { + defs = append(defs, *currentTool) + } + currentTool = &ToolDef{} + namePart := strings.TrimPrefix(trimmed, "### Tool: ") + currentTool.Name = namePart + currentTool.Parameters = map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + } + continue + } + + if currentTool == nil || currentTool.Name == "" { continue } + + if currentTool.Description == "" && trimmed != "" && + !strings.HasPrefix(trimmed, "- ") && !strings.HasPrefix(trimmed, "#") { + currentTool.Description = trimmed + continue + } + + if strings.HasPrefix(trimmed, "- ") { + paramLine := strings.TrimPrefix(trimmed, "- ") + colonIdx := strings.Index(paramLine, ":") + if colonIdx > 0 { + paramName := strings.TrimSpace(paramLine[:colonIdx]) + paramDesc := strings.TrimSpace(paramLine[colonIdx+1:]) + if paramName != "" { + props := currentTool.Parameters["properties"].(map[string]interface{}) + props[paramName] = map[string]interface{}{ + "type": "string", + "description": paramDesc, + } + } + } + } + } + + if currentTool != nil && currentTool.Name != "" { + defs = append(defs, *currentTool) } return defs } -// HotReload 定期扫描插件目录,检测新增/变更/删除的插件并动态注册/注销 -// interval=0 表示只执行一次扫描 -func (r *Registry) HotReload(dir string, interval time.Duration, done <-chan struct{}) { - if interval <= 0 { - r.scanAndSync(dir) - return +func isNonToolSection(name string) bool { + lower := strings.ToLower(name) + skip := []string{ + "tools", "usage", "examples", "installation", "setup", + "configuration", "overview", "description", "notes", + "parameters", "return", "returns", "options", "syntax", } - ticker := time.NewTicker(interval) - defer ticker.Stop() - for { - select { - case <-ticker.C: - r.scanAndSync(dir) - case <-done: - return + for _, s := range skip { + if lower == s || strings.HasPrefix(lower, s+" ") || strings.HasPrefix(lower, s+":") { + return true } } + return false } -// scanAndSync 扫描插件目录并与当前注册表同步 -func (r *Registry) scanAndSync(dir string) { - entries, err := os.ReadDir(dir) - if err != nil { - if !os.IsNotExist(err) { - log.Printf("[plugin] scan error: %v", err) - } - return - } - - // 收集当前磁盘上的插件名 - diskSet := make(map[string]bool) - for _, entry := range entries { - name := entry.Name() - if entry.IsDir() { - diskSet[name] = true - } else if strings.HasSuffix(name, ".md") { - diskSet[name[:len(name)-3]] = true - } - } - - r.mu.Lock() - // 移除已不存在的插件 - for name := range r.plugins { - if !diskSet[name] { - if r.ioMgr != nil { - r.ioMgr.UnregisterDevice(name) - } - delete(r.plugins, name) - log.Printf("[plugin] hot-unload: %s", name) - } - } - r.mu.Unlock() - - // 加载新增的插件 - for _, entry := range entries { - path := filepath.Join(dir, entry.Name()) - name := entry.Name() - if entry.IsDir() { - name = entry.Name() - } else if strings.HasSuffix(name, ".md") { - name = name[:len(name)-3] - } else { - continue - } - - r.mu.RLock() - exists := r.plugins[name] - r.mu.RUnlock() - if exists != nil { - continue - } - - // 检查是否有原生工厂 - r.mu.RLock() - factory, hasFactory := r.factories[name] - r.mu.RUnlock() - - if hasFactory { - // 使用原生设备,构造 Plugin 容器 - dev, err := factory(name, r.configFor(name, dir), r.ioMgr) - if err != nil { - log.Printf("[plugin] native factory %s: %v", name, err) - continue - } - np := &nativePlugin{name: name, dev: dev} - np.cfg = extractIOConfig(r.readFile(path)) - r.Register(np) - log.Printf("[plugin] hot-load native: %s", name) - } else { - var p Plugin - p, err = LoadSKILL(path) - if err != nil { - log.Printf("[plugin] hot-load skip %s: %v", entry.Name(), err) - continue - } - r.Register(p) - log.Printf("[plugin] hot-load: %s", p.Name()) - } - } -} - -// configFor 读取插件目录的 skill.json 作为原生工厂的配置 -func (r *Registry) configFor(name, dir string) map[string]interface{} { - cfg := map[string]interface{}{} - path := filepath.Join(dir, name, "skill.json") - data, err := os.ReadFile(path) - if err != nil { - return cfg - } - var meta map[string]interface{} - if json.Unmarshal(data, &meta) == nil { - for k, v := range meta { - cfg[k] = v - } - } - return cfg -} - -// readFile 读取插件目录的 SKILL.md -func (r *Registry) readFile(path string) string { - if info, err := os.Stat(path); err == nil && info.IsDir() { - data, _ := os.ReadFile(filepath.Join(path, "SKILL.md")) - return string(data) - } - data, _ := os.ReadFile(path) - return string(data) -} - -func (r *Registry) LoadDir(dir string) error { - entries, err := os.ReadDir(dir) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return err - } - for _, entry := range entries { - path := filepath.Join(dir, entry.Name()) - name := entry.Name() - if entry.IsDir() { - // 原生插件优先 - r.mu.RLock() - factory, hasFactory := r.factories[name] - r.mu.RUnlock() - if hasFactory { - dev, err := factory(name, r.configFor(name, dir), r.ioMgr) - if err != nil { - log.Printf("[plugin] native factory %s: %v", name, err) - continue - } - r.mu.Lock() - r.plugins[name] = &nativePlugin{name: name, dev: dev} - r.mu.Unlock() - if r.ioMgr != nil { - r.ioMgr.RegisterDevice(dev) - log.Printf("[plugin] native: %s", name) - } - continue - } - p, err := LoadSKILL(path) - if err != nil { - log.Printf("[plugin] skip dir %s: %v", entry.Name(), err) - continue - } - r.Register(p) - } else if filepath.Ext(entry.Name()) == ".md" { - p, err := LoadSKILL(path) - if err != nil { - log.Printf("[plugin] skip file %s: %v", entry.Name(), err) - continue - } - r.Register(p) - } - } - return nil -} - -// extractIOConfig parses IO port metadata from SKILL.md content -// Supported fields: -// io_type: input|output|io -// io_input_route: qq -// io_output_route: qq (optional, defaults to input_route) -// io_output_caps: text,file,image func extractIOConfig(content string) *IOConfig { ioType := extractField(content, "io_type") - if ioType == "" { - return nil - } + if ioType == "" { return nil } cfg := &IOConfig{ Type: ioType, InputRoute: extractField(content, "io_input_route"), @@ -708,234 +264,6 @@ func extractIOConfig(content string) *IOConfig { return cfg } -// PluginDevice 将 Plugin 包装为 IO Device,实现热插拔 -type PluginDevice struct { - plugin Plugin - caps agentIO.OutputCapability -} - -func NewPluginDevice(p Plugin) *PluginDevice { - var caps agentIO.OutputCapability - if cfg := p.IOConfig(); cfg != nil { - // 有 IO 配置时使用声明的能力 - for _, c := range cfg.OutputCaps { - switch strings.ToLower(c) { - case "text": - caps |= agentIO.CapText - case "file": - caps |= agentIO.CapFile - case "image": - caps |= agentIO.CapImage - case "audio": - caps |= agentIO.CapAudio - case "structured": - caps |= agentIO.CapStructured - } - } - } - // 无 IO 配置时 caps == 0 → 纯工具插件,不暴露为输出通道 - return &PluginDevice{plugin: p, caps: caps} -} - -func (d *PluginDevice) Name() string { return d.plugin.Name() } -func (d *PluginDevice) Type() agentIO.DeviceType { - if cfg := d.plugin.IOConfig(); cfg != nil { - switch cfg.Type { - case "input": - return agentIO.DeviceInput - case "output": - return agentIO.DeviceOutput - case "io": - return agentIO.DeviceIO - } - } - // 无 IO 配置 → 纯工具插件,归为 DeviceInput(无输出能力) - return agentIO.DeviceInput -} -func (d *PluginDevice) Description() string { return d.plugin.Description() } - -func (d *PluginDevice) Tools() []agentIO.ToolDef { - pts := d.plugin.Tools() - defs := make([]agentIO.ToolDef, len(pts)) - copy(defs, pts) - return defs -} - -func (d *PluginDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) { - for _, t := range d.plugin.Tools() { - if t.Name == tool { - if t.Handler != nil { - return t.Handler(args) - } - return nil, fmt.Errorf("plugin %s: tool %s has no handler", d.plugin.Name(), tool) - } - } - return nil, fmt.Errorf("plugin %s: unknown tool %s", d.plugin.Name(), tool) -} - -func (d *PluginDevice) Start() error { return nil } -func (d *PluginDevice) Stop() error { return nil } -func (d *PluginDevice) OutputCapabilities() agentIO.OutputCapability { return d.caps } - -// extractDescription returns the first non-empty, non-header line -func extractDescription(content string) string { - for _, line := range splitLines(content) { - line = trimSpace(line) - if line != "" && !hasPrefix(line, "#") { - return line - } - } - return "" -} - -// extractField finds `field: value` pattern in content -func extractField(content string, field string) string { - prefix := field + ":" - lowerPrefix := toLower(prefix) - for _, line := range splitLines(content) { - trimmed := trimSpace(line) - if hasPrefix(toLower(trimmed), lowerPrefix) { - // 找到冒号位置,提取冒号后的内容 - colonIdx := -1 - for i := 0; i < len(trimmed); i++ { - if trimmed[i] == ':' { - colonIdx = i - break - } - } - if colonIdx >= 0 { - return trimSpace(trimmed[colonIdx+1:]) - } - } - } - return "" -} - -// extractToolDefs 从 SKILL.md 中解析工具定义(OpenClaw 格式) -// -// 格式: -// ## tool_name -// 工具描述 -// - param1: 参数描述 -// - param2: 参数描述 -// -// 也支持: -// ### Tool: tool_name -// 格式(三级标题) -func extractToolDefs(content string) []ToolDef { - lines := splitLines(content) - var defs []ToolDef - var currentTool *ToolDef - inCodeBlock := false - - for i := 0; i < len(lines); i++ { - line := lines[i] - trimmed := trimSpace(line) - - // 跳过代码块 - if strings.HasPrefix(trimmed, "```") { - inCodeBlock = !inCodeBlock - continue - } - if inCodeBlock { - continue - } - - // 检测工具定义开始: ## tool_name 或 ### Tool: tool_name - if strings.HasPrefix(trimmed, "## ") && !strings.HasPrefix(trimmed, "### ") { - // 结束上一个工具 - if currentTool != nil && currentTool.Name != "" { - defs = append(defs, *currentTool) - } - currentTool = &ToolDef{} - namePart := strings.TrimPrefix(trimmed, "## ") - // 跳过已知的非工具章节 - if isNonToolSection(namePart) { - currentTool = nil - continue - } - currentTool.Name = namePart - currentTool.Parameters = map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{}, - } - continue - } - - // 也支持 ### Tool: name 格式 - if strings.HasPrefix(trimmed, "### Tool: ") { - if currentTool != nil && currentTool.Name != "" { - defs = append(defs, *currentTool) - } - currentTool = &ToolDef{} - namePart := strings.TrimPrefix(trimmed, "### Tool: ") - currentTool.Name = namePart - currentTool.Parameters = map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{}, - } - continue - } - - if currentTool == nil { - continue - } - - // 如果 name 为空且已进入工具段,跳过 - if currentTool.Name == "" { - continue - } - - // 描述行:第一个非空、非标题、非列表行 - if currentTool.Description == "" && trimmed != "" && - !strings.HasPrefix(trimmed, "- ") && - !strings.HasPrefix(trimmed, "#") { - currentTool.Description = trimmed - continue - } - - // 参数行:- param: description - if strings.HasPrefix(trimmed, "- ") { - paramLine := strings.TrimPrefix(trimmed, "- ") - colonIdx := strings.Index(paramLine, ":") - if colonIdx > 0 { - paramName := strings.TrimSpace(paramLine[:colonIdx]) - paramDesc := strings.TrimSpace(paramLine[colonIdx+1:]) - if paramName != "" { - props := currentTool.Parameters["properties"].(map[string]interface{}) - props[paramName] = map[string]interface{}{ - "type": "string", - "description": paramDesc, - } - } - } - } - } - - // 收尾最后一个工具 - if currentTool != nil && currentTool.Name != "" { - defs = append(defs, *currentTool) - } - - return defs -} - -// isNonToolSection 判断是否为非工具章节(如 Usage、Examples、Installation 等) -func isNonToolSection(name string) bool { - lower := strings.ToLower(name) - skip := []string{ - "tools", "usage", "examples", "installation", "setup", - "configuration", "overview", "description", "notes", - "parameters", "return", "returns", "options", "syntax", - } - for _, s := range skip { - if lower == s || strings.HasPrefix(lower, s+" ") || strings.HasPrefix(lower, s+":") { - return true - } - } - return false -} - func splitLines(s string) []string { var lines []string start := 0 @@ -961,28 +289,7 @@ func trimSpace(s string) string { return s[start:end] } -func hasPrefix(s, prefix string) bool { - if len(s) < len(prefix) { - return false - } - return s[:len(prefix)] == prefix -} - -func toLower(s string) string { - b := make([]byte, len(s)) - for i := 0; i < len(s); i++ { - if s[i] >= 'A' && s[i] <= 'Z' { - b[i] = s[i] + 32 - } else { - b[i] = s[i] - } - } - return string(b) -} - -func trimPrefix(s, prefix string) string { - if hasPrefix(s, prefix) { - return s[len(prefix):] - } - return s +// KeepTypes 通过引用确保编译器不丢弃类型 +var _ = []interface{}{ + PluginTypeSKILL, } diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go new file mode 100644 index 0000000..bf8be52 --- /dev/null +++ b/internal/plugin/registry.go @@ -0,0 +1,303 @@ +package plugin + +import ( + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "sort" + "sync" + + agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" + "gitcode.com/JianFeeeee/HomeAgent/internal/events" + "gitcode.com/JianFeeeee/HomeAgent/internal/knowledge" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" + doc "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory/text" + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" + internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config" +) + +type NativeFactory func(name string, config map[string]interface{}) (sdk.Plugin, error) + +// globalFactories 是插件通过 init() 自注册的全局工厂表。 +// Registry.RegisterNative() 写入此表;Registry.Load() 从中查找。 +var globalFactories sync.Map + +// RegisterFactory 供插件包在 init() 中调用,实现自注册。 +// plugin.RegisterFactory("timer", func(name string, cfg map[string]interface{}) (sdk.Plugin, error) { ... }) +func RegisterFactory(name string, factory NativeFactory) { + globalFactories.Store(name, factory) +} + +type Registry struct { + mu sync.RWMutex + plugins map[string]sdk.Plugin + instances []sdk.Plugin + factories map[string]NativeFactory + + iom *agentIO.IOManager + evBus *events.Bus + memDB *memory.GraphDB + textMem *text.Memory + docStore *doc.Store + ks *knowledge.Store + mgr *agentAPI.ProviderManager + cfgReg *internalConfig.ConfigRegistry + plgDir string + + regTool sdk.ToolRegistrar + regStage sdk.StageRegistrar + regAPI sdk.APIRegistrar +} + +func NewRegistry() *Registry { + return &Registry{ + plugins: make(map[string]sdk.Plugin), + factories: make(map[string]NativeFactory), + } +} + +func (r *Registry) SetIOManager(iom *agentIO.IOManager) { r.iom = iom } +func (r *Registry) SetEventBus(evBus *events.Bus) { r.evBus = evBus } +func (r *Registry) SetMemory(memDB *memory.GraphDB) { r.memDB = memDB } +func (r *Registry) SetTextMemory(tm *text.Memory) { r.textMem = tm } +func (r *Registry) SetDocStore(ds *doc.Store) { r.docStore = ds } +func (r *Registry) SetKnowledge(ks *knowledge.Store) { r.ks = ks } +func (r *Registry) SetProviderManager(mgr *agentAPI.ProviderManager) { r.mgr = mgr } +func (r *Registry) SetConfigRegistry(cfgReg *internalConfig.ConfigRegistry) { r.cfgReg = cfgReg } +func (r *Registry) SetPluginDir(dir string) { r.plgDir = dir } +func (r *Registry) SetToolRegistrar(fn sdk.ToolRegistrar) { r.regTool = fn } +func (r *Registry) SetStageRegistrar(fn sdk.StageRegistrar) { r.regStage = fn } +func (r *Registry) SetAPIRegistrar(fn sdk.APIRegistrar) { r.regAPI = fn } + +func (r *Registry) RegisterNative(name string, factory NativeFactory) { + r.mu.Lock() + defer r.mu.Unlock() + r.factories[name] = factory + globalFactories.Store(name, factory) +} + +func (r *Registry) buildSDK(name string) *sdk.PluginSDK { + sett := sdk.NewSettings(name, r.cfgReg) + + regTool := r.regTool + if regTool == nil { + regTool = func(toolName string, def sdk.ToolDef, handler sdk.ToolHandler) error { + return nil + } + } + regStage := r.regStage + if regStage == nil { + regStage = func(stage sdk.Stage, handler sdk.StageHandler) {} + } + regAPI := r.regAPI + if regAPI == nil { + regAPI = func(name string) error { return nil } + } + + return sdk.New(name, + r.iom, r.evBus, + sdk.NewGraphMemory(r.memDB), + sdk.NewTextMemory(r.textMem), + sdk.NewDocMemory(r.docStore), + sdk.NewKnowledge(r.ks), + sdk.NewLLM(r.mgr), + sett, + regTool, regStage, regAPI, + ) +} + +func (r *Registry) Load(dir string) error { + if dir == "" { + dir = r.plgDir + } + os.MkdirAll(dir, 0755) + + // 1) 扫描已有目录 + loaded := map[string]bool{} + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + os.MkdirAll(dir, 0755) + return nil + } + return err + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + name := entry.Name() + plgDir := filepath.Join(dir, name) + if r.loadOne(plgDir, name) { + loaded[name] = true + } + } + + // 2) 对已注册但尚无目录的工厂,创建目录并加载 + r.mu.RLock() + allFactories := make(map[string]NativeFactory) + for name, f := range r.factories { + allFactories[name] = f + } + r.mu.RUnlock() + + globalFactories.Range(func(key, val interface{}) bool { + name := key.(string) + if _, ok := allFactories[name]; !ok { + allFactories[name] = val.(NativeFactory) + } + return true + }) + + for name, factory := range allFactories { + if loaded[name] { + continue + } + plgDir := filepath.Join(dir, name) + os.MkdirAll(plgDir, 0755) + + cfg := r.readConfig(plgDir) + p, err := factory(name, cfg) + if err != nil { + log.Printf("[plugin] factory %s: %v", name, err) + continue + } + if p == nil { + continue + } + + plgSDK := r.buildSDK(name) + if err := p.Start(plgSDK); err != nil { + log.Printf("[plugin] start %s: %v", name, err) + continue + } + + r.mu.Lock() + r.plugins[name] = p + r.instances = append(r.instances, p) + r.mu.Unlock() + log.Printf("[plugin] loaded: %s", name) + } + + return nil +} + +func (r *Registry) loadOne(plgDir, name string) bool { + // 1) 查找工厂(init 自注册或 RegisterNative) + r.mu.RLock() + factory, hasFactory := r.factories[name] + r.mu.RUnlock() + if !hasFactory { + v, ok := globalFactories.Load(name) + if ok { + factory = v.(NativeFactory) + hasFactory = true + } + } + + var plg sdk.Plugin + + if hasFactory { + cfg := r.readConfig(plgDir) + p, err := factory(name, cfg) + if err != nil { + log.Printf("[plugin] factory %s: %v", name, err) + return false + } + if p == nil { + return false + } + plg = p + } else { + // 2) 无工厂,尝试动态加载 .so / .lua + dynCfg := r.readConfig(plgDir) + p, err := r.tryDynamic(plgDir, name, dynCfg) + if err != nil { + log.Printf("[plugin] dynamic %s: %v", name, err) + } + if p == nil { + return false + } + plg = p + } + + plgSDK := r.buildSDK(name) + if err := plg.Start(plgSDK); err != nil { + log.Printf("[plugin] start %s: %v", name, err) + return false + } + + r.mu.Lock() + r.plugins[name] = plg + r.instances = append(r.instances, plg) + r.mu.Unlock() + log.Printf("[plugin] loaded: %s", name) + return true +} + +func (r *Registry) StopAll() { + r.mu.Lock() + defer r.mu.Unlock() + for _, p := range r.instances { + if err := p.Stop(); err != nil { + log.Printf("[plugin] stop %s: %v", p.Name(), err) + } + } + r.plugins = make(map[string]sdk.Plugin) + r.instances = nil +} + +func (r *Registry) Reload(dir string) (string, error) { + r.StopAll() + if err := r.Load(dir); err != nil { + return "", err + } + return fmt.Sprintf("loaded %d plugins", len(r.instances)), nil +} + +func (r *Registry) List() []string { + r.mu.RLock() + defer r.mu.RUnlock() + list := make([]string, 0, len(r.plugins)) + for name := range r.plugins { + list = append(list, name) + } + sort.Strings(list) + return list +} + +func (r *Registry) Get(name string) sdk.Plugin { + r.mu.RLock() + defer r.mu.RUnlock() + return r.plugins[name] +} + +func (r *Registry) tryDynamic(plgDir, name string, config map[string]interface{}) (sdk.Plugin, error) { + // 优先尝试 .so(Go plugin),其次 .lua(Lua 脚本) + plg, err := tryLoadSO(plgDir, name, config) + if err != nil { + return nil, err + } + if plg != nil { + return plg, nil + } + return tryLoadLua(plgDir, name, config) +} + +func (r *Registry) readConfig(plgDir string) map[string]interface{} { + cfg := map[string]interface{}{} + data, err := os.ReadFile(filepath.Join(plgDir, "skill.json")) + if err != nil { + return cfg + } + var meta map[string]interface{} + if err := json.Unmarshal(data, &meta); err == nil { + return meta + } + return cfg +} diff --git a/internal/plugins/all.go b/internal/plugins/all.go new file mode 100644 index 0000000..6f3dabd --- /dev/null +++ b/internal/plugins/all.go @@ -0,0 +1,8 @@ +package plugins + +import ( + _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cli" + _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/openclaw" + _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/timer" + _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/webui" +) diff --git a/internal/plugins/cli/plugin.go b/internal/plugins/cli/plugin.go new file mode 100644 index 0000000..3fcad85 --- /dev/null +++ b/internal/plugins/cli/plugin.go @@ -0,0 +1,128 @@ +package cli + +import ( + "bufio" + "encoding/json" + "fmt" + "log" + "net" + "os" + "path/filepath" + "sync" + + "gitcode.com/JianFeeeee/HomeAgent/internal/plugin" + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" +) + +// DefaultSocket 由 main.go 在 Load() 前设置,覆盖默认 socket 路径。 +// 若为空,factory 使用 "/cli.sock"。 +var DefaultSocket string + +func init() { + plugin.RegisterFactory("cli", func(name string, config map[string]interface{}) (sdk.Plugin, error) { + sock := DefaultSocket + if sock == "" { + sock = filepath.Join(config["data_dir"].(string), "cli.sock") + } + return New(name, sock), nil + }) +} + +type Plugin struct { + name string + socket string + ln net.Listener + mu sync.Mutex + wg sync.WaitGroup +} + +func New(name, socketPath string) *Plugin { + return &Plugin{ + name: name, + socket: socketPath, + } +} + +func (p *Plugin) Name() string { return p.name } + +func (p *Plugin) Start(s *sdk.PluginSDK) error { + dir := filepath.Dir(p.socket) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("create socket dir: %w", err) + } + + os.Remove(p.socket) + + ln, err := net.Listen("unix", p.socket) + if err != nil { + return fmt.Errorf("listen unix socket %s: %w", p.socket, err) + } + p.ln = ln + + os.Chmod(p.socket, 0666) + + p.wg.Add(1) + go p.acceptLoop(s) + + log.Printf("[cli] unix socket listening on %s", p.socket) + return nil +} + +func (p *Plugin) acceptLoop(s *sdk.PluginSDK) { + defer p.wg.Done() + for { + conn, err := p.ln.Accept() + if err != nil { + break + } + p.wg.Add(1) + go p.handleConn(conn, s) + } +} + +func (p *Plugin) handleConn(conn net.Conn, s *sdk.PluginSDK) { + defer conn.Close() + defer p.wg.Done() + + scanner := bufio.NewScanner(conn) + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + + resp := s.InjectTextSync("cli", "cli", line) + if resp != nil { + content, _ := resp.Payload["content"].(string) + writeLine(conn, map[string]interface{}{ + "type": "response", + "content": content, + }) + } else { + writeLine(conn, map[string]interface{}{ + "type": "error", + "error": "agent is not available", + }) + } + } +} + +func writeLine(conn net.Conn, v interface{}) { + data, err := json.Marshal(v) + if err != nil { + return + } + data = append(data, '\n') + conn.Write(data) +} + +func (p *Plugin) Stop() error { + p.mu.Lock() + defer p.mu.Unlock() + if p.ln != nil { + p.ln.Close() + } + p.wg.Wait() + os.Remove(p.socket) + return nil +} diff --git a/internal/plugins/openclaw/plugin.go b/internal/plugins/openclaw/plugin.go new file mode 100644 index 0000000..a33d27a --- /dev/null +++ b/internal/plugins/openclaw/plugin.go @@ -0,0 +1,88 @@ +package openclaw + +import ( + "fmt" + "log" + "os" + "path/filepath" + + "gitcode.com/JianFeeeee/HomeAgent/internal/plugin" + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" +) + +// SkillsDir 由 main.go 在 Load() 前设置,指向 SKILL.md 存放目录。 +var SkillsDir string + +func init() { + plugin.RegisterFactory("openclaw", func(name string, config map[string]interface{}) (sdk.Plugin, error) { + dir := SkillsDir + if dir == "" { + dir = filepath.Join(config["data_dir"].(string), "skills") + } + return New(name, dir), nil + }) +} + +type Plugin struct { + name string + skillsDir string + skills []*plugin.SKILLPlugin +} + +func New(name, skillsDir string) *Plugin { + return &Plugin{ + name: name, + skillsDir: skillsDir, + } +} + +func (p *Plugin) Name() string { return p.name } + +func (p *Plugin) Start(s *sdk.PluginSDK) error { + entries, err := os.ReadDir(p.skillsDir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("read skills dir %s: %w", p.skillsDir, err) + } + + for _, entry := range entries { + skillPath := filepath.Join(p.skillsDir, entry.Name()) + sk, err := plugin.LoadSKILL(skillPath) + if err != nil { + log.Printf("[openclaw] load skill %s: %v", entry.Name(), err) + continue + } + p.skills = append(p.skills, sk) + + // Register each tool defined in the SKILL + for _, td := range sk.Tools() { + name := td.Name + def := sdk.ToolDef{ + Name: name, + Description: td.Description, + Parameters: td.Parameters, + } + // SKILL tools are informational (advisory) — no handler + if err := s.RegisterTool(name, def, nil); err != nil { + log.Printf("[openclaw] register tool %s: %v", name, err) + } + } + + // Register IO config as a channel if defined + if iocfg := sk.IOConfig(); iocfg != nil { + log.Printf("[openclaw] skill %s io: type=%s in=%s out=%s caps=%v", + sk.Name(), iocfg.Type, iocfg.InputRoute, iocfg.OutputRoute, iocfg.OutputCaps) + } + + log.Printf("[openclaw] loaded skill: %s v%s", sk.Name(), sk.Version()) + } + + return nil +} + +func (p *Plugin) Stop() error { + p.skills = nil + return nil +} diff --git a/internal/plugins/timer/plugin.go b/internal/plugins/timer/plugin.go new file mode 100644 index 0000000..92598b9 --- /dev/null +++ b/internal/plugins/timer/plugin.go @@ -0,0 +1,98 @@ +package timer + +import ( + "fmt" + "log" + "sync" + "time" + + "gitcode.com/JianFeeeee/HomeAgent/internal/plugin" + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" +) + +func init() { + plugin.RegisterFactory("timer", func(name string, config map[string]interface{}) (sdk.Plugin, error) { + return New(name), nil + }) +} + +type Plugin struct { + name string + mu sync.Mutex + wg sync.WaitGroup +} + +type timerTask struct { + id int + dur time.Duration + message string + doneAt time.Time + s *sdk.PluginSDK +} + +func New(name string) *Plugin { + return &Plugin{name: name} +} + +func (p *Plugin) Name() string { return p.name } + +func (p *Plugin) Start(s *sdk.PluginSDK) error { + s.RegisterTool("timer_set", sdk.ToolDef{ + Name: "timer_set", + Description: "设置一个定时提醒。倒计时结束后通过中断通道通知 agent。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "duration": map[string]interface{}{ + "type": "string", + "description": "持续时间,例如 5s, 2m, 1h", + }, + "message": map[string]interface{}{ + "type": "string", + "description": "提醒内容", + }, + }, + "required": []string{"duration", "message"}, + }, + }, func(args map[string]interface{}) (interface{}, error) { + durStr, _ := args["duration"].(string) + message, _ := args["message"].(string) + if durStr == "" { + return map[string]interface{}{"error": "duration is required"}, nil + } + if message == "" { + return map[string]interface{}{"error": "message is required"}, nil + } + + dur, err := time.ParseDuration(durStr) + if err != nil { + return map[string]interface{}{"error": fmt.Sprintf("invalid duration %q: %v", durStr, err)}, nil + } + + p.mu.Lock() + p.wg.Add(1) + p.mu.Unlock() + + go func() { + defer p.wg.Done() + time.Sleep(dur) + log.Printf("[timer] firing: %s (%s later)", message, dur) + s.InjectInterruptText("timer", "timer", fmt.Sprintf("timer: %s", message)) + }() + + doneAt := time.Now().Add(dur) + return map[string]interface{}{ + "status": "timer_set", + "duration": durStr, + "message": message, + "done_at": doneAt.Format(time.RFC3339), + }, nil + }) + + return nil +} + +func (p *Plugin) Stop() error { + p.wg.Wait() + return nil +} diff --git a/internal/api/handler.go b/internal/plugins/webui/handler.go similarity index 92% rename from internal/api/handler.go rename to internal/plugins/webui/handler.go index ffa4dd6..6822b3d 100644 --- a/internal/api/handler.go +++ b/internal/plugins/webui/handler.go @@ -1,4 +1,4 @@ -package api +package webui import ( "encoding/json" @@ -12,6 +12,7 @@ import ( agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config" + "gitcode.com/JianFeeeee/HomeAgent/internal/events" "gitcode.com/JianFeeeee/HomeAgent/internal/knowledge" luaVM "gitcode.com/JianFeeeee/HomeAgent/internal/lua" "gitcode.com/JianFeeeee/HomeAgent/internal/memory" @@ -37,9 +38,10 @@ type Handler struct { tracker *tracker.Tracker cfgReg *internalConfig.ConfigRegistry pluginReg *plugin.Registry + eventBus *events.Bus } -func NewHandler(sup *supervisor.Daemon, mem *memory.GraphDB, sk *skill.Manager, lua *luaVM.VM, cfg *types.Config, iom *agentIO.IOManager, tm *text.Memory, ks *knowledge.Store, tr *tracker.Tracker, cr *internalConfig.ConfigRegistry, pr *plugin.Registry) *Handler { +func NewHandler(sup *supervisor.Daemon, mem *memory.GraphDB, sk *skill.Manager, lua *luaVM.VM, cfg *types.Config, iom *agentIO.IOManager, tm *text.Memory, ks *knowledge.Store, tr *tracker.Tracker, cr *internalConfig.ConfigRegistry, pr *plugin.Registry, evBus *events.Bus) *Handler { var idx *memory.Indexer if mem != nil { idx = memory.NewIndexer(mem) @@ -58,6 +60,7 @@ func NewHandler(sup *supervisor.Daemon, mem *memory.GraphDB, sk *skill.Manager, tracker: tr, cfgReg: cr, pluginReg: pr, + eventBus: evBus, } } @@ -81,6 +84,8 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/v1/adapters/", h.handleAdapterByID) mux.HandleFunc("/api/v1/tracker", h.handleTracker) mux.HandleFunc("/api/v1/tracker/", h.handleTracker) + mux.HandleFunc("/api/v1/chat", h.handleChat) + mux.HandleFunc("/api/v1/chat/events", h.handleChatEvents) mux.HandleFunc("/v1/chat/completions", h.handleOpenAICompletions) mux.HandleFunc("/", h.handleStatic) } @@ -416,7 +421,6 @@ func (h *Handler) handleTextMemory(w http.ResponseWriter, r *http.Request) { "recent": recent, }) case http.MethodDelete: - // future: purge writeJSON(w, http.StatusAccepted, map[string]string{"status": "not_implemented"}) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -498,6 +502,80 @@ func (h *Handler) handleNetwork(w http.ResponseWriter, r *http.Request) { }) } +func (h *Handler) handleChat(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var body struct { + Message string `json:"message"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + if body.Message == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "message is required"}) + return + } + + resp := h.iom.InjectTextSync("cli", body.Message) + if resp == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "agent unavailable"}) + return + } + content, _ := resp.Payload["content"].(string) + writeJSON(w, http.StatusOK, map[string]interface{}{ + "response": content, + }) +} + +func (h *Handler) handleChatEvents(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.WriteHeader(http.StatusOK) + flusher.Flush() + + done := r.Context().Done() + if h.eventBus == nil { + fmt.Fprintf(w, "event: error\ndata: {\"msg\":\"event bus unavailable\"}\n\n") + flusher.Flush() + return + } + + ticker := time.NewTicker(15 * time.Second) + defer ticker.Stop() + + unsub := h.eventBus.Subscribe(events.EventAll, func(evt *events.Event) { + data, _ := json.Marshal(evt) + fmt.Fprintf(w, "event: %s\ndata: %s\n\n", evt.Type, data) + flusher.Flush() + }) + defer unsub() + + for { + select { + case <-done: + return + case <-ticker.C: + fmt.Fprintf(w, ": heartbeat\n\n") + flusher.Flush() + } + } +} + func (h *Handler) handleConfig(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: @@ -529,11 +607,10 @@ func (h *Handler) handleSettings(w http.ResponseWriter, r *http.Request) { v, _ := h.cfgReg.Get(k) values[k] = v } - // 返回插件列表供侧边栏分组 plugins := []string{"core"} if h.pluginReg != nil { for _, p := range h.pluginReg.List() { - plugins = append(plugins, "plugin."+p.Name()) + plugins = append(plugins, "plugin."+p) } } sort.Strings(plugins) @@ -560,13 +637,17 @@ func (h *Handler) handleSettings(w http.ResponseWriter, r *http.Request) { } } -// OpenAI 兼容 API — 所有输入走 IO 抽象层(中断) func (h *Handler) handleOpenAICompletions(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } + if h.iom == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "IO manager not available"}) + return + } + var req struct { Model string `json:"model"` Messages []openAIMessage `json:"messages"` @@ -583,15 +664,17 @@ func (h *Handler) handleOpenAICompletions(w http.ResponseWriter, r *http.Request return } - // 取最后一条 user 消息作为输入 lastMsg := req.Messages[len(req.Messages)-1] if lastMsg.Role != "user" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "last message must be from user"}) return } - // 通过 IO 抽象层同步注入(中断式) response := h.iom.InjectTextSync("http", lastMsg.Content) + if response == nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "no response from agent"}) + return + } resp := map[string]interface{}{ "id": fmt.Sprintf("chatcmpl-%d", time.Now().UnixNano()), @@ -731,7 +814,7 @@ pre{background:#0f172a;border-radius:6px;padding:12px;font-size:12px;overflow-x: