feat: files built-in plugin, doc rewrite, architecture cleanup

- Add files plugin as built-in (internal/plugins/files/) with read/write/edit/ls tools,
  supporting overwrite/append/insert/create modes and offset/limit segmented reading
- Rewrite README.md with core domain separation and three-layer memory highlights
- Rewrite docs/OVERVIEW.md with per-subsystem file path references
- Rewrite docs/ARCHITECTURE.md (783→~300 lines), merge redundant sections
- Clean docs/PLUGIN_DEV.md: remove emoji, simplify SDK examples
- Fix provider Model pollution in LuaAdaptedProvider.Chat()
- Fix executeToolCall to return actual error vs quiet not-found
- Fix plugin.Open path caching with SHA256 temp-path workaround
- Add knowledge/homeagent_architecture demo entry
- Add config/personal/personal.md identity configuration
This commit is contained in:
root
2026-07-06 14:33:09 +08:00
parent 8cec92d947
commit df0abcd298
12 changed files with 1111 additions and 890 deletions

View File

@ -1,41 +1,76 @@
# HomeAgent # HomeAgent
24/7 智能管家。**核心零 IO**,一切外界交互来自插件 首个提出**核心域与应用域分离**的 Agent 框架。内核零 IO一切外界交互由插件承载——WebUI、QQ、命令行、文件操作、网络搜索、备忘全部是插件内核不碰任何 IO
📖 [项目概览(非技术)](docs/OVERVIEW.md) · 配合**三层记忆架构**Context → Document → Graph单对话长期稳定运行记忆不衰减。
🔧 [插件开发指南](docs/PLUGIN_DEV.md) ·
🏗️ [技术架构](docs/ARCHITECTURE.md) · ```go
📋 [实施计划](PLAN.md) homed内核零 IO PluginSDK 插件所有 IO 能力
```
## 核心创新
**核心域与应用域分离** — 内核只做 LLM 编排、记忆管理、知识检索;所有 IO 能力(收发消息、读写文件、网络请求、硬件交互)全由插件实现。插件可热加载、独立开发、独立发布。这不是 RPC 框架的微服务拆分,而是 Agent 框架层次的领域划分。
**三层记忆架构** — 解决 Agent 长期运行的记忆衰减问题:
- **Context 层**TF-IDF 相关性评分的事件窗口,维护最近 topK 条上下文
- **Document 层**:临时记忆,冷数据自动下沉,也支持用户主动提交
- **Graph 层**SQLite 图数据库,持久化实体关系和语义记忆,支持蒸馏管道从原始对话中提取三元组
## 快速体验 ## 快速体验
```bash ```bash
# 构建
make build build-cli make build build-cli
./build/homed -data /tmp/ha
```
# 启动内核(需要 DeepSeek API 密钥) ```bash
DEEPSEEK_API_KEY="sk-xxx" ./build/homed -data /tmp/ha # 交互模式
# 交互模式(自动发现 socket
./build/waiter ./build/waiter
# 或单条消息 # 或单条消息
echo "你好" | ./build/waiter echo "你好,记住我喜欢喝咖啡" | ./build/waiter
``` ```
配置文件 `~/.config/homeagent/cli.yaml` API 密钥通过 WebUI `http://localhost:8080` 设置页配置,持久化在 SQLite 中。
```yaml ## 代码结构
mode: auto # auto / local / remote
colors: true
history_size: 1000
prompt: "waiter> "
```
## 架构一句话
``` ```
homed(内核零 IO← PluginSDK → 插件(所有 IO 能力) cmd/homed/ 守护进程入口,组装所有子系统
cmd/waiter/ CLI 客户端Unix socket
internal/
├── agent/core/ Agent 核心事件循环、LLM 工具循环、7 阶段管道
├── agent/api/ LLM Provider + 8 个 Lua 适配器
├── memory/ 三层记忆Graph(SQLite) / Document(JSON+TF-IDF) / Text(JSONL)
├── knowledge/ 知识库(文件系统 + TF-IDF
├── plugin/ 插件注册表 + .so 动态加载器
├── plugins/ 内置 10 个插件webui/cli/timer/cmd/mcp/openclaw/agentcli/healthcheck/pluginmgr/files
├── sdk/ PluginSDKTool/Stage/Event 三通道)
├── config/ SQLite 配置中心
├── events/ 事件总线
└── lua/adapters/ 8 个 LLM 协议适配器脚本
外部插件(.so示例在 [homeagent-sdk/example/](https://gitcode.com/JianFeeeee/homeagent-sdk)
``` ```
依赖Go 1.19+, CGo (go-sqlite3), Linux。 ## 项目状态
核心可用,插件系统和 SDK 已就绪。内置 9 个插件,外部插件示例见 [SDK 仓库](https://gitcode.com/JianFeeeee/homeagent-sdk)。
## 文档
- [项目概览](docs/OVERVIEW.md)
- [技术架构](docs/ARCHITECTURE.md)
- [插件开发指南](docs/PLUGIN_DEV.md)
- [Lua Adapter](docs/ADAPTER.md)
- [知识库演示](knowledge/homeagent_architecture/content.md)
## 构建
```bash
make build build-cli # 编译守护进程 + CLI
make test # go test ./...
make install # 安装到系统
```
依赖Go 1.19+, CGo (go-sqlite3), Linux。

View File

@ -0,0 +1,14 @@
## 你的身份
你是 HomeAgent——一个全新自研的新一代 Agent 框架。
你以内核 + 插件架构驱动,实现了稳定高效、记忆不衰减的长时持续运行。
## 对用户的称呼
你对用户的称呼永远是"老大",绝对禁止使用"老板""主人"称呼用户,不论任何情况。
## 对话风格
- 用语气词(哈、嘛、呢、~、😊、🔥 等),不要太端着
- 重要的事先说结论,再展开解释
- 回复要简洁自然

File diff suppressed because it is too large Load Diff

View File

@ -1,87 +1,72 @@
# HomeAgent — 你的 24/7 智能管家 # HomeAgent — 项目概览
## 这是什么的? ## 这是什么
HomeAgent 是一个**持续运行的个人智能管家**。它像一个随时在线的大脑,你可以通过聊天跟它交流,让它帮你记住事情、查询知识、设置提醒、执行任务 HomeAgent 是一个持续运行的个人智能 Agent 框架
## 核心目标 核心架构:一个长时间运行的内核进程(`homed`),通过插件系统接入各种 IO 通道QQ、Web、命令行等。内核负责 LLM 调用编排、记忆管理、知识检索;插件负责所有外部 IO——收发消息、执行文件操作、搜索网络等。
| 目标 | 说明 | ### 核心创新
|------|------|
| **永远在线** | 启动后持续运行,不像普通聊天软件需要每次打开 |
| **真正记住你** | 它不会每次对话都"失忆"——它会积累对你的了解,记住你的喜好、关系网和重要信息 |
| **隐私可控** | 所有数据存储在你自己的设备上(本地数据库),你也可以选择使用自己的 API 密钥 |
| **能力可扩展** | 通过"插件"添加新能力——就像手机装 App 一样 |
## 谁需要它? **核心域与应用域分离** — 这是首个明确提出这一划分的 Agent 框架。内核(核心域)不做任何 IO所有 IO 能力归属插件(应用域)。边界通过 PluginSDK 明确定义:
- 插件向内核注册工具Tool供 LLM 调用
- 插件挂入处理管道Stage在各阶段拦截/改写消息流
- 插件订阅/发布事件Event松耦合通信
- 插件通过 IO API 排队或打断投递输入
- **想有个私人助理** — 帮你记待办、定时提醒、管理联系人 这一划分的意义:内核保持纯粹(零 IO只做编排和记忆插件保持灵活各司其职热加载互不污染。
- **重视隐私的用户** — 数据全在本地,不经过第三方云服务
- **开发者和技术爱好者** — 可以自己编写插件来扩展功能
- **想探索 AI Agent 的人** — 一个真实可运行的 Agent 系统,不只是 API 调用
## 它能做什么? **三层记忆架构** — 解决 Agent 长期运行的记忆衰减:
- **Context 层**:内存中 TF-IDF 评分的事件窗口,实时维护最近上下文,低相关性事件自动下沉到下一层
- **Document 层**JSON 文件 + TF-IDF 向量索引的临时记忆,支持显式提交和隐式归档,冷数据蒸馏到 Graph
- **Graph 层**SQLite 图数据库持久化实体entities和关系relationsBFS 遍历召回,蒸馏管道从对话中提取三元组
### 🧠 记忆 三层递进:上下文 → 冷归档 → 长期图记忆,确保 Agent 长时间运行不退化。
- **记住你是谁** — 你的名字、喜好、重要日期
- **记住人际关系** — "张三是我同事,李四是我的朋友"
- **长期积累** — 聊得越多,它越了解你
### 📚 知识 ## 它实际做了什么
- 你可以主动教它知识("公司的休假制度是……"
- 它会在需要时检索相关知识
### ⏰ 定时提醒 代码位于 `/home/program/TrueAgent`Go 语言实现。
- "5分钟后提醒我喝水"
- 倒计时结束后它会主动通知你
### 🔌 可扩展(插件) **内核** (`internal/agent/core/agent.go`)
- **Web 控制台** — 在浏览器中管理和配置7 标签页 SPA - 维护一个消息循环(`eventLoop`),从 IO 层排队接收输入
- **命令行** — 通过终端快速交互 - 每次输入走完整的处理管道:记忆召回 → 人格注入 → LLM 调用 → 工具执行 → 输出发送
- **健康检查** — 自动检测系统各组件状态LLM 驱动故障排查 - LLM 调用通过 Provider 接口抽象,支持 8 个 LLM 源自动降级
- **更多能力** — 开发者可以写插件接入任何服务 - 上下文管理(`context.go`)基于 TF-IDF 评分,自动剪枝低相关性事件
## 它是如何工作的?(简述) **记忆系统** (`internal/memory/`)
- **GraphDB** (`graph.go`) — SQLiteentities + relations 表BFS 遍历
- **Document Store** (`document/doc.go`) — 临时记忆JSON 文件 + TF-IDF 向量索引,消费即删
- **Text Memory** (`text/text.go`) — 原始对话日志JSONL 文件轮转
- **Social Store** (`social/social.go`) — 人格特质 + 关系网,包装 GraphDB
- **Memory Indexer** (`indexer.go`) — 自动将 GraphDB 实体向量化,用户输入时召回注入 system prompt
``` **知识库** (`internal/knowledge/knowledge.go`)
你(通过聊天软件/终端/网页) - 文件系统目录 `knowledge/<name>/content.md`
- TF-IDF 向量搜索,独立于记忆系统的索引实例
- LLM 通过 `knowledge_search` / `knowledge_create` / `knowledge_list` 三个工具操作
HomeAgent 内核 ←→ 插件(能力扩展)
本地存储(你的数据只在你这里)
```
- **内核** 是"大脑"——负责理解你说什么、调用什么能力、记住什么 **插件系统** (`internal/plugin/`)
- **插件** 是"手脚"——负责收发消息、设置定时器、连接外部服务等 - 内置插件Go `init()` 自注册,编译进内核
- **所有数据存本地** — 你的对话、记忆、配置都保存在你自己的设备上 - 外部插件Go `-buildmode=plugin` 编译为 `.so`,通过 `plugin.Open` 动态加载
- PluginSDK (`internal/plugin/sdk/`) 定义三通道RegisterTool / RegisterStage / Subscribe
- 阶段钩子 7 个on_input → pre_action → post_action → before_toolcall → after_toolcall → before_output → after_output
## 和普通 AI 聊天有什么区别? **LLM Provider** (`internal/agent/api/provider.go`)
- Provider 接口Name / Chat / ChatStream
- 三种实现OpenAIProvider标准 OpenAI API、OllamaProvider本地、LuaAdaptedProviderLua 胶水适配)
- LuaAdapter 位于 `internal/lua/adapters/`,每个 LLM 源对应一个 `.lua` 脚本
- 内置 8 个适配器deepseek / openai / anthropic / gemini / mistral / groq / github / ollama
| | 普通 AI 聊天 | HomeAgent | **WebUI** (`internal/plugins/webui/`)
|---|---|---| - 嵌入式 SPA 仪表盘(`dashboard.html` 通过 `//go:embed` 打包)
| 记忆 | 每次对话独立,不记得你 | 长期记忆,越来越了解你 | - REST API状态查询、配置管理、记忆操作、知识库管理、插件管理
| 持续运行 | 关掉就没了 | 7×24 在线 | - 兼容 OpenAI API 格式的 `/v1/chat/completions` 端点
| 主动能力 | 只能回复问题 | 能设定时器、主动提醒 | - SSE 事件流 `/api/v1/chat/events`
| 可扩展 | 固定能力 | 插件系统,可无限扩展 |
| 数据隐私 | 上传到云服务 | 本地存储,完全可控 |
## 快速体验
```bash
# 启动(需要 DeepSeek API 密钥)
DEEPSEEK_API_KEY="sk-xxx" ./homed -data /tmp/ha
# 在另一个终端聊天
echo "你好,请记住我喜欢喝咖啡" | ./waiter
```
## 项目状态 ## 项目状态
HomeAgent 正在积极开发中。核心功能已可运行插件系统和开发者 API 已就绪 核心功能已可运行插件系统和 SDK 已就绪,可独立开发外部插件
--- - 内置插件webui / cli / timer / cmd / mcp / agentcli / healthcheck / pluginmgr / openclaw / files
- 外部插件示例SDK 仓库 `example/`qq / files / web / memo
*想参与开发?查看 [PLUGIN_DEV.md](PLUGIN_DEV.md) 插件开发指南。* - 打包分发:`.hmap` 插件包格式,通过 WebUI 安装
*了解技术架构?查看 [ARCHITECTURE.md](ARCHITECTURE.md)。*

View File

@ -2,9 +2,18 @@
## 概述 ## 概述
HomeAgent 的所有外部交互能力都来自插件。插件是独立运行的 Go 包,通过 `PluginSDK`Go API与内核交互。 HomeAgent 的所有外部交互能力都来自插件。插件通过 `PluginSDK`Go API与内核交互。
每个插件需要实现一个非常简单的接口: **SDK 仓库**:插件开发工具、模板代码和示例插件统一托管在
**[gitcode.com/JianFeeeee/homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk)**。
```bash
git clone https://gitcode.com/JianFeeeee/homeagent-sdk.git
cd homeagent-sdk
hack/plugin-dev/scaffold.sh myplugin ./plugins/myplugin
```
每个插件实现一个三方法接口:
```go ```go
type Plugin interface { type Plugin interface {
@ -18,8 +27,8 @@ type Plugin interface {
| 方式 | 适用场景 | 复杂度 | | 方式 | 适用场景 | 复杂度 |
|------|---------|--------| |------|---------|--------|
| **动态 .so 插件(推荐)** | 独立分发的第三方插件 | 中等,使用 [SDK 仓库](https://gitcode.com/JianFeeeee/homeagent-sdk) 脚手架生成 |
| **内置插件** | 随 HomeAgent 一起发布 | 简单,需合入主仓库 | | **内置插件** | 随 HomeAgent 一起发布 | 简单,需合入主仓库 |
| **动态 .so 插件** | 独立分发的第三方插件 | 中等,需编译为 .so |
| **Lua 脚本插件** | 轻量快速原型 | 简单(预留功能) | | **Lua 脚本插件** | 轻量快速原型 | 简单(预留功能) |
--- ---
@ -170,41 +179,37 @@ func (p *Plugin) Stop() error {
### PluginSDK 核心 API ### PluginSDK 核心 API
#### 📤 IO — 输入输出 #### IO — 输入输出
```go ```go
// 排队通道投递输入(按序处理) // 排队投递(按序处理)
sdk.InjectInput(source, channel string, payload map[string]interface{}) sdk.InjectInput(source, channel string, payload map[string]interface{})
// 中断通道投递输入(可打断当前 LLM 处理) // 中断投递(可打断当前 LLM 处理)
sdk.InjectInterrupt(source, channel string, payload map[string]interface{}) sdk.InjectInterrupt(source, channel string, payload map[string]interface{})
// 快捷方式:投递文本到排队通道 // 快捷方式:text → Input
sdk.InjectText(source, channel, text string) sdk.InjectText(source, channel, text string)
// 快捷方式:投递文本到中断通道
sdk.InjectInterruptText(source, channel, text string) sdk.InjectInterruptText(source, channel, text string)
// 同步请求-响应:发送文本并等待回复CLI 插件使用) // 同步请求-响应CLI 插件使用)
sdk.InjectTextSync(source, channel, text string) *OutputEvent sdk.InjectTextSync(source, channel, text string) *OutputEvent
// 注册一个输出通道LLM 通过 output_send 工具选择发送到通道) // 注册/管理输出通道LLM 通过 output_send 选择发送到哪个通道)
sdk.RegisterChannel(name string, dev Device) error sdk.RegisterChannel(name string, dev Device) error
sdk.UnregisterChannel(name string) sdk.UnregisterChannel(name string)
sdk.ListChannels() []ChannelInfo sdk.ListChannels() []ChannelInfo
``` ```
#### 🛠️ 工具 — 让 LLM 可调用你的能力 #### 工具 — 让 LLM 可调用你的能力
```go ```go
sdk.RegisterTool(name string, def ToolDef, handler ToolHandler) error sdk.RegisterTool(name string, def ToolDef, handler ToolHandler) error
``` ```
- `name`: 工具名称(LLM 通过此名称调用 - `name`: LLM 通过此名称调用
- `def`: 工具定义(描述 + 参数 JSON Schema - `def`: JSON Schema 描述+参数
- `handler`: 调用时执行函数 - `handler`: 执行函数
工具定义示例:
```go ```go
sdk.RegisterTool("weather_query", sdk.ToolDef{ sdk.RegisterTool("weather_query", sdk.ToolDef{
@ -222,7 +227,6 @@ sdk.RegisterTool("weather_query", sdk.ToolDef{
}, },
}, func(args map[string]interface{}) (interface{}, error) { }, func(args map[string]interface{}) (interface{}, error) {
city, _ := args["city"].(string) city, _ := args["city"].(string)
// 查询天气并返回
return map[string]interface{}{ return map[string]interface{}{
"city": city, "city": city,
"temp": 25, "temp": 25,
@ -231,50 +235,46 @@ sdk.RegisterTool("weather_query", sdk.ToolDef{
}) })
``` ```
#### 🔌 阶段钩子 — 干预消息处理流 #### 阶段钩子 — 干预消息处理流
7 个阶段, 按执行顺序 7 个阶段:
| 阶段 | 时机 | 用途 | | 阶段 | 时机 | 用途 |
|------|------|------| |------|------|------|
| `on_input` | 消息刚到达 Agent | 黑名单、限流、短路回复 | | `on_input` | 消息刚到达 Agent | 黑名单、限流、短路 |
| `pre_action` | 即将调用 LLM | 注入额外上下文 | | `pre_action` | 即将调用 LLM | 注入上下文 |
| `post_action` | LLM 返回结果 | 修改 LLM 输出 | | `post_action` | LLM 返回结果 | 修改输出/工具列表 |
| `before_toolcall` | 工具调用前 | 审计、拒绝、改参 | | `before_toolcall` | 工具调用前 | 审计、拒绝、改参 |
| `after_toolcall` | 工具执行后 | 脱敏、改写结果 | | `after_toolcall` | 工具执行后 | 脱敏、改写结果 |
| `before_output` | 输出前 | 调整格式 | | `before_output` | 输出前 | 格式适配 |
| `after_output` | 输出后 | 统计、记录 | | `after_output` | 输出后 | 统计日志 |
```go ```go
sdk.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error { sdk.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {
input := ctx.RawMessage
// 检查是否是黑名单用户
if ctx.UserID == "blocked_user" { if ctx.UserID == "blocked_user" {
resp := "你已被限制使用" resp := "你已被限制使用"
ctx.Response = &resp // 设置 Response 会短路后续阶段 ctx.Response = &resp // 短路后续阶段
return nil return nil
} }
return nil return nil
}) })
``` ```
#### 📡 事件 — 订阅/发布系统事件 #### 事件 — 订阅/发布系统事件
```go ```go
// 订阅事件
unsub := sdk.Subscribe(events.EventType("tool_call"), func(evt *events.Event) { unsub := sdk.Subscribe(events.EventType("tool_call"), func(evt *events.Event) {
log.Printf("工具被调用: %v", evt.Payload) log.Printf("工具被调用: %v", evt.Payload)
}) })
defer unsub() // 插件 Stop 时取消订阅 defer unsub()
// 发布事件
sdk.Publish(&events.Event{ sdk.Publish(&events.Event{
Type: "my_event", Type: "my_event",
Payload: map[string]interface{}{"key": "value"}, Payload: map[string]interface{}{"key": "value"},
}) })
``` ```
#### 🧠 能力访问 #### 能力访问
```go ```go
// 记忆 // 记忆
@ -288,7 +288,7 @@ sdk.Knowledge().Search(query string) ([]string, error)
sdk.LLM().ListSources() []SourceInfo sdk.LLM().ListSources() []SourceInfo
sdk.LLM().SetSource(name string) error sdk.LLM().SetSource(name string) error
// 配置(插件自身的配置表 config_<plugin_name> // 配置(插件自身的 config_<plugin_name>
sdk.Settings().Get(key string) (interface{}, error) sdk.Settings().Get(key string) (interface{}, error)
sdk.Settings().Set(key string, value interface{}) error sdk.Settings().Set(key string, value interface{}) error
sdk.Settings().List(prefix string) ([]string, error) sdk.Settings().List(prefix string) ([]string, error)
@ -296,20 +296,15 @@ sdk.Settings().List(prefix string) ([]string, error)
### 读取插件配置 ### 读取插件配置
插件有自己的配置`config_<插件名>`,例如 `config_mcp` 插件独立 SQLite `config_<name>`
```go ```go
// 在 Start() 中
val, err := s.Settings().Get("api_key") val, err := s.Settings().Get("api_key")
if err != nil { if err != nil {
// 未配置 // 未配置
} }
```
用户通过 WebUI 或 CLI 设置: // 读取其他插件配置
```go
// 读取其他插件的配置
s.Settings().GetPlugin("other_plugin", "some_key") s.Settings().GetPlugin("other_plugin", "some_key")
// 读取核心配置 // 读取核心配置
@ -345,14 +340,28 @@ pluginReg.Load(plgDir) // 之后调用
## 四、动态 .so 插件 ## 四、动态 .so 插件
### 编译插件为 .so 动态插件是独立于 HomeAgent 内核编译的 Go 插件,使用外部的 [Plugin SDK](https://gitcode.com/JianFeeeee/homeagent-sdk)
而非内核内部的 SDK 包。
完整的外部插件示例在 SDK 仓库的 `example/` 目录下:`qq``files``memo``web`
### 快速开始
使用 SDK 仓库的脚手架生成项目:
```bash
git clone https://gitcode.com/JianFeeeee/homeagent-sdk.git
cd homeagent-sdk
hack/plugin-dev/scaffold.sh myplugin ./plugins/myplugin
```
生成的代码:
```go ```go
// myplugin/plugin.go
package main package main
import ( import (
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
) )
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) { func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
@ -371,13 +380,23 @@ func (p *myPlugin) Start(s *sdk.PluginSDK) error {
func (p *myPlugin) Stop() error { return nil } func (p *myPlugin) Stop() error { return nil }
``` ```
编译 ### 编译
```bash ```bash
go build -buildmode=plugin -o plugin.so ./myplugin/ cd <SDK_REPO_ROOT>
go build -buildmode=plugin -o plugins/myplugin/plugin.so plugins/myplugin/
```
或使用项目中的 Makefile
```bash
cd plugins/myplugin && make
``` ```
### 部署 ### 部署
将插件目录(含 `plugin.json` + `plugin.so`)放入内核配置的插件目录:
``` ```
<dataDir>/plugins/myplugin/ <dataDir>/plugins/myplugin/
plugin.json — {"name": "myplugin", "version": "1.0", "description": "..."} plugin.json — {"name": "myplugin", "version": "1.0", "description": "..."}
@ -386,21 +405,39 @@ go build -buildmode=plugin -o plugin.so ./myplugin/
内核扫描时会自动发现并加载。无需修改 `main.go``all.go` 内核扫描时会自动发现并加载。无需修改 `main.go``all.go`
### 打包分发
使用 SDK 仓库的打包工具生成 `.hmap` 分发包:
```bash
hack/plugin-dev/packager.sh plugins/myplugin
# 输出: dist/myplugin-0.1.0.hmap
```
通过 WebUI 插件管理页面上传安装,或使用 `plugin_install` 工具。
### 完整示例
SDK 仓库的 `example/qq/` 目录提供了一个完整的 QQ 集成插件示例(对接 NapCat 框架),
涵盖消息收发、群管理、好友管理、文件操作、OCR 等功能,可作为开发参考。
--- ---
## 五、最佳实践 ## 五、最佳实践
1. **Start() 非阻塞** — 长时间运行的任务用 goroutine 启动,不要 Start() 中阻塞 1. `Start()` 非阻塞 goroutine 启动长任务,不要阻塞 Start
2. **Stop() 清理资源** — 关闭网络连接、停 goroutine、取消订阅 2. `Stop()` 清理资源 — 关连接、停 goroutine、取消订阅
3. **工具 name 唯一** — 工具名不能与其他插件冲突,建议插件名前缀 3. 工具名唯一 — 建议插件名前缀避免冲突
4. **错误处理** — 工具 handler 返回 `error`LLM 会收到错误信息并可能重试 4. handler 返回 `error` LLM 会收到并可能重试
5. **中断 vs 排队** — 需要打断当前 LLM 处理的`InjectInterruptText`,普通`InjectText` 5. 打断`InjectInterruptText`,普通投递`InjectText`
6. **配置优先** — 不要硬编码配置,`Settings().Get/Set` 读写插件配置 6. 配置`Settings().Get/Set`,不要硬编码
--- ---
## 六、现有插件参考 ## 六、现有插件参考
### 内置插件
| 插件 | 位置 | 特点 | | 插件 | 位置 | 特点 |
|------|------|------| |------|------|------|
| Timer | `internal/plugins/timer/` | 最简单的完整示例,注册一个工具 + 中断反馈 | | Timer | `internal/plugins/timer/` | 最简单的完整示例,注册一个工具 + 中断反馈 |
@ -409,7 +446,15 @@ go build -buildmode=plugin -o plugin.so ./myplugin/
| WebUI | `internal/plugins/webui/` | HTTP 服务 + 依赖注入Configure 模式) | | WebUI | `internal/plugins/webui/` | HTTP 服务 + 依赖注入Configure 模式) |
| MCP | `internal/plugins/mcp/` | JSON-RPC over stdio/SSE连接 MCP 服务器 | | MCP | `internal/plugins/mcp/` | JSON-RPC over stdio/SSE连接 MCP 服务器 |
### 外部插件示例
| 插件 | 位置 | 特点 |
|------|------|------|
| QQ | `example/qq/` in [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) | NapCat 框架对接14 个工具 |
| 你的插件 | `plugins/yourplugin/` | 使用 SDK 脚手架生成 |
--- ---
*了解项目整体目标?查看 [OVERVIEW.md](OVERVIEW.md)。* *了解项目整体目标?查看 [OVERVIEW.md](OVERVIEW.md)。*
*了解技术架构?查看 [ARCHITECTURE.md](ARCHITECTURE.md)。* *了解技术架构?查看 [ARCHITECTURE.md](ARCHITECTURE.md)。*
*SDK 仓库与开发工具?查看 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk)。*

View File

@ -407,9 +407,7 @@ func NewLuaAdaptedProvider(cfg BaseConfig, vm *luaVM.VM, adapter string) *LuaAda
func (p *LuaAdaptedProvider) Name() string { return p.name } func (p *LuaAdaptedProvider) Name() string { return p.name }
func (p *LuaAdaptedProvider) Chat(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) { func (p *LuaAdaptedProvider) Chat(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) {
if req.Model == "" { req.Model = p.cfg.Model
req.Model = p.cfg.Model
}
rawReq, _ := json.Marshal(req) rawReq, _ := json.Marshal(req)
@ -464,9 +462,7 @@ func (p *LuaAdaptedProvider) Chat(ctx context.Context, req *CompletionRequest) (
} }
func (p *LuaAdaptedProvider) ChatStream(ctx context.Context, req *CompletionRequest) (<-chan StreamChunk, error) { func (p *LuaAdaptedProvider) ChatStream(ctx context.Context, req *CompletionRequest) (<-chan StreamChunk, error) {
if req.Model == "" { req.Model = p.cfg.Model
req.Model = p.cfg.Model
}
req.Stream = true req.Stream = true
rawReq, _ := json.Marshal(req) rawReq, _ := json.Marshal(req)
@ -585,6 +581,7 @@ func (s *SSEScanner) Text() string { return s.pending }
type ProviderManager struct { type ProviderManager struct {
mu sync.RWMutex mu sync.RWMutex
providers map[string]Provider providers map[string]Provider
order []string
default_ string default_ string
} }
@ -598,6 +595,7 @@ func (m *ProviderManager) Register(name string, p Provider) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
m.providers[name] = p m.providers[name] = p
m.order = append(m.order, name)
if m.default_ == "" { if m.default_ == "" {
m.default_ = name m.default_ = name
} }
@ -647,13 +645,29 @@ func (m *ProviderManager) QuickChat(ctx context.Context, prompt string) (*Comple
func (m *ProviderManager) List() []string { func (m *ProviderManager) List() []string {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
var names []string names := make([]string, len(m.order))
for n := range m.providers { copy(names, m.order)
names = append(names, n)
}
return names return names
} }
func (m *ProviderManager) OrderedProviders() []Provider {
m.mu.RLock()
defer m.mu.RUnlock()
list := make([]Provider, 0, len(m.order))
for _, name := range m.order {
if p, ok := m.providers[name]; ok {
list = append(list, p)
}
}
return list
}
func (m *ProviderManager) ProviderCount() int {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.providers)
}
type rawToolCall struct { type rawToolCall struct {
ID string `json:"id"` ID string `json:"id"`
Type string `json:"type"` Type string `json:"type"`

View File

@ -42,7 +42,6 @@ type Agent struct {
systemPrompt string systemPrompt string
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
maxTurns int
// 文档记忆(第二层) // 文档记忆(第二层)
docStore *document.Store docStore *document.Store
@ -114,7 +113,6 @@ type AgentConfig struct {
Indexer *memory.Indexer Indexer *memory.Indexer
Skills *skill.Manager Skills *skill.Manager
Tracker *tracker.Tracker Tracker *tracker.Tracker
MaxToolTurns int
DocStore *document.Store DocStore *document.Store
Knowledge *knowledge.Store Knowledge *knowledge.Store
@ -135,9 +133,6 @@ type AgentConfig struct {
func New(cfg AgentConfig) *Agent { func New(cfg AgentConfig) *Agent {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
if cfg.MaxToolTurns <= 0 {
cfg.MaxToolTurns = 10
}
if cfg.DistillInterval <= 0 { if cfg.DistillInterval <= 0 {
cfg.DistillInterval = 30 * time.Minute cfg.DistillInterval = 30 * time.Minute
} }
@ -158,7 +153,6 @@ func New(cfg AgentConfig) *Agent {
systemPrompt: cfg.SystemPrompt, systemPrompt: cfg.SystemPrompt,
ctx: ctx, ctx: ctx,
cancel: cancel, cancel: cancel,
maxTurns: cfg.MaxToolTurns,
docStore: cfg.DocStore, docStore: cfg.DocStore,
knowledge: cfg.Knowledge, knowledge: cfg.Knowledge,
social: cfg.SocialStore, social: cfg.SocialStore,
@ -577,7 +571,7 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
} }
} }
for turn := 0; turn < a.maxTurns; turn++ { for turn := 0; ; turn++ {
// === 高优先级打断:每次 LLM 调用前检查拦截通道 === // === 高优先级打断:每次 LLM 调用前检查拦截通道 ===
if text := a.drainInterrupt(); text != "" { if text := a.drainInterrupt(); text != "" {
msgs = append(msgs, agentAPI.Message{ msgs = append(msgs, agentAPI.Message{
@ -600,20 +594,49 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
} }
// 可取消的 LLM 调用interceptLoop 通过 cancelLLM 打断进行中的请求 // 可取消的 LLM 调用interceptLoop 通过 cancelLLM 打断进行中的请求
reqCtx, reqCancel := context.WithCancel(a.ctx) // 多 LLM 源顺位降级:当当前 provider 失败时,按注册顺序依次尝试
a.llmMu.Lock() var providers []agentAPI.Provider
a.cancelLLM = reqCancel if a.providerManager != nil {
a.llmMu.Unlock() providers = a.providerManager.OrderedProviders()
}
if len(providers) == 0 {
providers = []agentAPI.Provider{a.provider}
}
var resp *agentAPI.CompletionResponse
var llmErr error
resp, err := a.provider.Chat(reqCtx, req) for pi, fbProvider := range providers {
if pi > 0 {
log.Printf("[agent] LLM fallback: trying provider %q (fallback #%d/%d)",
fbProvider.Name(), pi, len(providers)-1)
}
a.llmMu.Lock() fCtx, fCancel := context.WithCancel(a.ctx)
a.cancelLLM = nil a.llmMu.Lock()
a.llmMu.Unlock() a.cancelLLM = fCancel
reqCancel() a.llmMu.Unlock()
if err != nil { resp, llmErr = fbProvider.Chat(fCtx, req)
return "", toolsUsed, fmt.Errorf("provider: %w", err)
a.llmMu.Lock()
a.cancelLLM = nil
a.llmMu.Unlock()
fCancel()
if llmErr == nil {
if fbProvider != a.provider {
a.provider = fbProvider
log.Printf("[agent] switched active provider to %q after fallback",
fbProvider.Name())
}
break
}
log.Printf("[agent] provider %q failed: %v", fbProvider.Name(), llmErr)
}
if llmErr != nil {
return "", toolsUsed, fmt.Errorf("all %d providers failed, last error: %w",
len(providers), llmErr)
} }
// === Stage: post_action — LLM 返回,插件可审查/修改 === // === Stage: post_action — LLM 返回,插件可审查/修改 ===
@ -678,10 +701,8 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
"result": result, "result": result,
"status": "ok", "status": "ok",
}) })
}
} }
}
return "", toolsUsed, fmt.Errorf("tool execution exceeded %d turns", a.maxTurns)
} }
func convertToolCalls(tcs []agentAPI.ToolCall) []sdk.ToolCall { func convertToolCalls(tcs []agentAPI.ToolCall) []sdk.ToolCall {
@ -767,6 +788,8 @@ func (a *Agent) executeToolCall(tc agentAPI.ToolCall) string {
if a.stageHost != nil { if a.stageHost != nil {
if result, err := a.stageHost.ExecuteTool(tc.Name, tc.Arguments); err == nil { if result, err := a.stageHost.ExecuteTool(tc.Name, tc.Arguments); err == nil {
return fmt.Sprintf("%v", result) return fmt.Sprintf("%v", result)
} else if !strings.Contains(err.Error(), "not found in any plugin") {
return fmt.Sprintf("工具 %s 执行失败: %v", tc.Name, err)
} }
} }
@ -1242,6 +1265,30 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
return prompt return prompt
} }
// cleanParams removes empty required arrays from tool parameters that strict APIs reject.
func cleanParams(params map[string]interface{}) map[string]interface{} {
if params == nil {
return nil
}
cleaned := make(map[string]interface{}, len(params))
for k, v := range params {
cleaned[k] = v
}
if req, ok := cleaned["required"]; ok {
switch v := req.(type) {
case []interface{}:
if len(v) == 0 {
delete(cleaned, "required")
}
case []string:
if len(v) == 0 {
delete(cleaned, "required")
}
}
}
return cleaned
}
func (a *Agent) buildToolDefs() []interface{} { func (a *Agent) buildToolDefs() []interface{} {
var tools []interface{} var tools []interface{}
@ -1252,7 +1299,7 @@ func (a *Agent) buildToolDefs() []interface{} {
"function": map[string]interface{}{ "function": map[string]interface{}{
"name": td.Name, "name": td.Name,
"description": td.Description, "description": td.Description,
"parameters": td.Parameters, "parameters": cleanParams(td.Parameters),
}, },
}) })
} }
@ -1266,7 +1313,7 @@ func (a *Agent) buildToolDefs() []interface{} {
"function": map[string]interface{}{ "function": map[string]interface{}{
"name": td.Name, "name": td.Name,
"description": td.Description, "description": td.Description,
"parameters": td.Parameters, "parameters": cleanParams(td.Parameters),
}, },
}) })
} }

View File

@ -275,6 +275,7 @@ func (r *ConfigRegistry) seedDBValues(dataDir string) {
set("core.agent.max_tool_turns", "10") set("core.agent.max_tool_turns", "10")
set("core.agent.max_context_size", "30") set("core.agent.max_context_size", "30")
set("core.agent.distill_interval", "30m") set("core.agent.distill_interval", "30m")
set("core.agent.workdir", "")
set("core.input_processing.image.fallback_provider", "") set("core.input_processing.image.fallback_provider", "")
set("core.input_processing.image.fallback_model", "") set("core.input_processing.image.fallback_model", "")
@ -352,6 +353,7 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string) {
reg(ConfigDef{Key: "core.agent.max_tool_turns", Default: "10", Type: "int", DisplayName: "最大工具轮次", Description: "单次请求允许的最大工具调用轮数", Category: "agent"}) reg(ConfigDef{Key: "core.agent.max_tool_turns", Default: "10", Type: "int", DisplayName: "最大工具轮次", Description: "单次请求允许的最大工具调用轮数", Category: "agent"})
reg(ConfigDef{Key: "core.agent.max_context_size", Default: "30", Type: "int", DisplayName: "最大上下文", Description: "上下文窗口中保留的最大消息条数", Category: "agent"}) reg(ConfigDef{Key: "core.agent.max_context_size", Default: "30", Type: "int", DisplayName: "最大上下文", Description: "上下文窗口中保留的最大消息条数", Category: "agent"})
reg(ConfigDef{Key: "core.agent.distill_interval", Default: "30m", Type: "duration", DisplayName: "蒸馏间隔", Description: "记忆蒸馏的执行间隔", Category: "agent"}) reg(ConfigDef{Key: "core.agent.distill_interval", Default: "30m", Type: "duration", DisplayName: "蒸馏间隔", Description: "记忆蒸馏的执行间隔", Category: "agent"})
reg(ConfigDef{Key: "core.agent.workdir", Default: "", Type: "string", DisplayName: "工作目录", Description: "Agent 命令执行的默认工作目录(如 cmd_run 工具的 fallback留空使用内核所在目录", Category: "agent"})
reg(ConfigDef{Key: "core.input_processing.image.fallback_provider", Default: "", Type: "string", DisplayName: "图片回退提供商", Description: "当主 LLM 不支持图片处理时使用的提供商(留空则自动降级为文字描述)", Category: "input"}) reg(ConfigDef{Key: "core.input_processing.image.fallback_provider", Default: "", Type: "string", DisplayName: "图片回退提供商", Description: "当主 LLM 不支持图片处理时使用的提供商(留空则自动降级为文字描述)", Category: "input"})
reg(ConfigDef{Key: "core.input_processing.image.fallback_model", Default: "", Type: "string", DisplayName: "图片回退模型", Description: "图片回退提供商使用的模型名", Category: "input"}) reg(ConfigDef{Key: "core.input_processing.image.fallback_model", Default: "", Type: "string", DisplayName: "图片回退模型", Description: "图片回退提供商使用的模型名", Category: "input"})

View File

@ -1,6 +1,8 @@
package plugin package plugin
import ( import (
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
@ -51,9 +53,23 @@ func tryLoadSO(dir, name string, config map[string]interface{}) (sdk.Plugin, err
return nil, nil return nil, nil
} }
p, err := plugin.Open(soPath) // 复制到临时路径以绕过 Go plugin.Open 的路径缓存
data, err := os.ReadFile(soPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("plugin.Open %s: %w", soPath, err) return nil, fmt.Errorf("read %s: %w", soPath, err)
}
h := sha256.Sum256(data)
cacheKey := fmt.Sprintf("plugin_%s_%s.so", name, hex.EncodeToString(h[:8]))
cachePath := filepath.Join(os.TempDir(), cacheKey)
if _, err := os.Stat(cachePath); os.IsNotExist(err) {
if err := os.WriteFile(cachePath, data, 0644); err != nil {
return nil, fmt.Errorf("write cache %s: %w", cachePath, err)
}
}
p, err := plugin.Open(cachePath)
if err != nil {
return nil, fmt.Errorf("plugin.Open %s: %w", cachePath, err)
} }
sym, err := p.Lookup("NewPlugin") sym, err := p.Lookup("NewPlugin")

View File

@ -4,6 +4,7 @@ import (
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/agentcli" _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/agentcli"
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cli" _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cli"
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cmd" _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cmd"
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/files"
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/healthcheck" _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/healthcheck"
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/mcp" _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/mcp"
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/openclaw" _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/openclaw"

View File

@ -0,0 +1,483 @@
package files
import (
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
func init() {
plugin.RegisterFactory("files", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
return New(name), nil
})
}
type Plugin struct {
name string
sdk *sdk.PluginSDK
mu sync.RWMutex
filesDir string
}
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 {
p.sdk = s
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "dir",
Default: "/",
Type: "string",
DisplayName: "文件系统根目录",
Description: "文件操作允许访问的根目录(设为 / 表示完整主机文件系统)",
Category: "files",
})
dir := getSetting[string](s.Settings(), "dir", "/")
if strings.HasPrefix(dir, "~/") {
home, _ := os.UserHomeDir()
dir = filepath.Join(home, dir[2:])
}
abs, err := filepath.Abs(dir)
if err != nil {
return fmt.Errorf("resolve files.dir: %w", err)
}
p.filesDir = abs
tp := p.name + "_"
s.RegisterTool(tp+"read", sdk.ToolDef{
Name: tp + "read",
Description: fmt.Sprintf("读取文件内容。支持 offset/limit 分段读取大文件。沙箱路径: %s", p.filesDir),
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "文件路径(绝对路径或相对于沙箱的路径)"},
"offset": map[string]interface{}{"type": "integer", "description": "起始行号从1开始可选默认1"},
"limit": map[string]interface{}{"type": "integer", "description": "最多返回的行数(可选,默认全部)"},
},
"required": []string{"path"},
},
}, p.handleRead)
s.RegisterTool(tp+"write", sdk.ToolDef{
Name: tp + "write",
Description: fmt.Sprintf("写入文件。自动创建父目录。沙箱路径: %s", p.filesDir),
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "文件路径"},
"content": map[string]interface{}{"type": "string", "description": "要写入的内容"},
"mode": map[string]interface{}{"type": "string", "description": "写入模式: overwrite覆盖默认| append追加到末尾| insert插入到指定行| create创建新文件已存在则报错"},
"line": map[string]interface{}{"type": "integer", "description": "插入模式时的目标行号从1开始内容将插入到该行之前"},
},
"required": []string{"path", "content"},
},
}, p.handleWrite)
s.RegisterTool(tp+"edit", sdk.ToolDef{
Name: tp + "edit",
Description: fmt.Sprintf("对文件执行精确字符串替换。每个 old 必须在原文中唯一匹配。沙箱路径: %s", p.filesDir),
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "文件路径"},
"edits": map[string]interface{}{
"type": "array",
"description": "一个或多个替换操作。每个 old 必须在原文中恰好出现一次。不要包含重叠的 edit。",
"items": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"old": map[string]interface{}{"type": "string", "description": "要查找的原文(必须在文件中唯一)"},
"new": map[string]interface{}{"type": "string", "description": "替换后的文本"},
},
"required": []string{"old", "new"},
},
},
},
"required": []string{"path", "edits"},
},
}, p.handleEdit)
s.RegisterTool(tp+"ls", sdk.ToolDef{
Name: tp + "ls",
Description: fmt.Sprintf("列出目录内容。目录以 / 后缀标记。沙箱路径: %s", p.filesDir),
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "目录路径(可选,默认为沙箱根目录)"},
"limit": map[string]interface{}{"type": "integer", "description": "最多返回条目数可选默认500"},
},
},
}, p.handleLs)
log.Printf("[%s] started, sandbox: %s", p.name, p.filesDir)
return nil
}
func (p *Plugin) Stop() error {
log.Printf("[%s] stopped", p.name)
return nil
}
func (p *Plugin) resolvePath(userPath string) (string, error) {
if userPath == "" {
userPath = "."
}
if !filepath.IsAbs(userPath) {
userPath = filepath.Join(p.filesDir, userPath)
}
abs, err := filepath.Abs(userPath)
if err != nil {
return "", fmt.Errorf("resolve path: %w", err)
}
base := filepath.Clean(p.filesDir)
if base != "/" && !strings.HasPrefix(abs, base+string(filepath.Separator)) && abs != base {
return "", fmt.Errorf("path outside sandbox: %s", userPath)
}
return abs, nil
}
func (p *Plugin) handleRead(args map[string]interface{}) (interface{}, error) {
path, _ := args["path"].(string)
if path == "" {
return errorResult("path is required"), nil
}
absPath, err := p.resolvePath(path)
if err != nil {
return errorResult(err.Error()), nil
}
info, err := os.Stat(absPath)
if err != nil {
if os.IsNotExist(err) {
return errorResult("file not found: " + path), nil
}
return errorResult("stat error: " + err.Error()), nil
}
if info.IsDir() {
return errorResult("is a directory, use ls instead: " + path), nil
}
data, err := os.ReadFile(absPath)
if err != nil {
return errorResult("read error: " + err.Error()), nil
}
text := string(data)
lines := strings.Split(text, "\n")
totalLines := len(lines)
offset := 0
if v, ok := args["offset"].(float64); ok && v > 0 {
offset = int(v) - 1
}
if offset >= totalLines {
return errorResult(fmt.Sprintf("offset %d exceeds file length (%d lines)", offset+1, totalLines)), nil
}
limit := totalLines - offset
if v, ok := args["limit"].(float64); ok && v > 0 {
if int(v) < limit {
limit = int(v)
}
}
end := offset + limit
if end > totalLines {
end = totalLines
}
selected := lines[offset:end]
output := strings.Join(selected, "\n")
var sb strings.Builder
sb.WriteString(output)
if end < totalLines {
nextOffset := end + 1
sb.WriteString(fmt.Sprintf("\n\n[Showing lines %d-%d of %d. Use offset=%d to continue.]", offset+1, end, totalLines, nextOffset))
} else if offset > 0 {
sb.WriteString(fmt.Sprintf("\n\n[%d lines total]", totalLines))
}
return map[string]interface{}{
"content": sb.String(),
"size": len(data),
"lines": totalLines,
}, nil
}
func (p *Plugin) handleWrite(args map[string]interface{}) (interface{}, error) {
path, _ := args["path"].(string)
if path == "" {
return errorResult("path is required"), nil
}
content, _ := args["content"].(string)
mode, _ := args["mode"].(string)
if mode == "" {
mode = "overwrite"
}
line := 0
if v, ok := args["line"].(float64); ok && v > 0 {
line = int(v)
}
absPath, err := p.resolvePath(path)
if err != nil {
return errorResult(err.Error()), nil
}
switch mode {
case "create":
if _, err := os.Stat(absPath); err == nil {
return errorResult("file already exists: " + path), nil
}
dir := filepath.Dir(absPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return errorResult("mkdir error: " + err.Error()), nil
}
if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
return errorResult("write error: " + err.Error()), nil
}
return map[string]interface{}{
"content": fmt.Sprintf("Created %s (%d bytes)", path, len(content)),
}, nil
case "append":
dir := filepath.Dir(absPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return errorResult("mkdir error: " + err.Error()), nil
}
f, err := os.OpenFile(absPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return errorResult("open error: " + err.Error()), nil
}
defer f.Close()
if _, err := f.WriteString(content); err != nil {
return errorResult("append error: " + err.Error()), nil
}
return map[string]interface{}{
"content": fmt.Sprintf("Appended %d bytes to %s", len(content), path),
}, nil
case "insert":
if line < 1 {
return errorResult("line must be >= 1 for insert mode"), nil
}
data, err := os.ReadFile(absPath)
if err != nil {
if os.IsNotExist(err) {
return errorResult("file not found: " + path), nil
}
return errorResult("read error: " + err.Error()), nil
}
lines := strings.Split(string(data), "\n")
if line > len(lines)+1 {
return errorResult(fmt.Sprintf("line %d exceeds file length (%d lines)", line, len(lines))), nil
}
idx := line - 1
newLines := make([]string, 0, len(lines)+1)
newLines = append(newLines, lines[:idx]...)
newLines = append(newLines, content)
newLines = append(newLines, lines[idx:]...)
result := strings.Join(newLines, "\n")
if err := os.WriteFile(absPath, []byte(result), 0644); err != nil {
return errorResult("write error: " + err.Error()), nil
}
return map[string]interface{}{
"content": fmt.Sprintf("Inserted %d bytes at line %d in %s", len(content), line, path),
}, nil
default: // overwrite
dir := filepath.Dir(absPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return errorResult("mkdir error: " + err.Error()), nil
}
if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
return errorResult("write error: " + err.Error()), nil
}
return map[string]interface{}{
"content": fmt.Sprintf("Wrote %d bytes to %s", len(content), path),
}, nil
}
}
func (p *Plugin) handleEdit(args map[string]interface{}) (interface{}, error) {
path, _ := args["path"].(string)
if path == "" {
return errorResult("path is required"), nil
}
absPath, err := p.resolvePath(path)
if err != nil {
return errorResult(err.Error()), nil
}
rawEdits, ok := args["edits"].([]interface{})
if !ok || len(rawEdits) == 0 {
return errorResult("edits must be a non-empty array"), nil
}
data, err := os.ReadFile(absPath)
if err != nil {
if os.IsNotExist(err) {
return errorResult("file not found: " + path), nil
}
return errorResult("read error: " + err.Error()), nil
}
original := string(data)
content := original
applied := 0
var errors []string
for i, raw := range rawEdits {
edit, ok := raw.(map[string]interface{})
if !ok {
errors = append(errors, fmt.Sprintf("edit[%d]: invalid format", i))
continue
}
oldText, _ := edit["old"].(string)
newText, _ := edit["new"].(string)
if oldText == "" {
errors = append(errors, fmt.Sprintf("edit[%d]: old is required", i))
continue
}
count := strings.Count(content, oldText)
if count == 0 {
errors = append(errors, fmt.Sprintf("edit[%d]: could not find %q in %s", i, oldText, path))
continue
}
if count > 1 {
errors = append(errors, fmt.Sprintf("edit[%d]: found %d occurrences of %q, must be unique", i, count, oldText))
continue
}
content = strings.Replace(content, oldText, newText, 1)
applied++
}
if applied == 0 {
msg := "no edits applied"
if len(errors) > 0 {
msg += ": " + strings.Join(errors, "; ")
}
return errorResult(msg), nil
}
if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
return errorResult("write error: " + err.Error()), nil
}
msg := fmt.Sprintf("Successfully applied %d/%d edits to %s", applied, len(rawEdits), path)
if len(errors) > 0 {
msg += "\nWarnings:\n" + strings.Join(errors, "\n")
}
return map[string]interface{}{
"content": msg,
}, nil
}
func (p *Plugin) handleLs(args map[string]interface{}) (interface{}, error) {
path, _ := args["path"].(string)
if path == "" {
path = "."
}
absPath, err := p.resolvePath(path)
if err != nil {
return errorResult(err.Error()), nil
}
info, err := os.Stat(absPath)
if err != nil {
if os.IsNotExist(err) {
return errorResult("path not found: " + path), nil
}
return errorResult("stat error: " + err.Error()), nil
}
if !info.IsDir() {
return errorResult("not a directory: " + path), nil
}
entries, err := os.ReadDir(absPath)
if err != nil {
return errorResult("readdir error: " + err.Error()), nil
}
limit := 500
if v, ok := args["limit"].(float64); ok && v > 0 {
limit = int(v)
}
sort.Slice(entries, func(i, j int) bool {
return strings.ToLower(entries[i].Name()) < strings.ToLower(entries[j].Name())
})
var lines []string
entryLimitReached := false
for i, entry := range entries {
if i >= limit {
entryLimitReached = true
break
}
name := entry.Name()
if entry.IsDir() {
name += "/"
}
info, err := entry.Info()
if err == nil {
name = fmt.Sprintf("%-40s %8d", name, info.Size())
}
lines = append(lines, name)
}
if len(lines) == 0 {
return map[string]interface{}{
"content": "(empty directory)",
}, nil
}
output := strings.Join(lines, "\n")
if entryLimitReached {
output += fmt.Sprintf("\n\n[%d entries limit reached. Use limit=N for more.]", limit)
}
return map[string]interface{}{
"content": output,
}, nil
}
func errorResult(msg string) map[string]interface{} {
return map[string]interface{}{
"isError": true,
"content": msg,
}
}
func getSetting[T any](s sdk.SettingsAPI, key string, def T) T {
v, err := s.Get(key)
if err != nil || v == nil {
return def
}
val, ok := v.(T)
if !ok {
return def
}
return val
}

View File

@ -0,0 +1,56 @@
HomeAgent 是完全独立自研的新一代 Agent 框架。
## 架构总览
HomeAgent 采用内核 + 插件双层架构:
```
外部输入QQ/Web/CLI
┌─────────────────────────────────────┐
│ 内核 (Kernel) │
│ ┌───────┐ ┌───────┐ ┌────────┐ │
│ │ LLM │ │ 记忆 │ │ 上下文 │ │
│ │ 引擎 │ │ 系统 │ │ 管理器 │ │
│ └───────┘ └───────┘ └────────┘ │
│ ┌───────┐ ┌───────┐ ┌────────┐ │
│ │ 工具 │ │ 事件 │ │ 配置 │ │
│ │ 调度 │ │ 总线 │ │ 系统 │ │
│ └───────┘ └───────┘ └────────┘ │
└──────────────┬──────────────────────┘
┌─────────────────────────────────────┐
│ 插件层 (Plugins) │
│ QQ / Web / Cmd / 备忘 / Files / … │
└─────────────────────────────────────┘
```
## 核心特性
### 内核
- **LLM 引擎**:多 Provider 自动故障转移8 个适配器DeepSeek/Anthropic/Gemini/GitHub/Groq/Mistral/Ollama/OpenAI自动降级
- **记忆系统**:四层记忆架构——图记忆(实体+关系)、文档记忆、文本记忆、社交记忆,向量索引检索
- **上下文管理**:自动剪枝低相关性事件,蒸馏重要信息写入长期记忆,有效防止上下文膨胀和记忆衰减
- **工具调度**:同质阶段并行执行,支持 pre_action/post_action/on_input 等生命周期钩子
- **事件总线**:发布/订阅模式,插件间松耦合通信
- **打断机制**:高优先级消息可打断进行中的 LLM 请求,即时响应
### 插件系统
- **双模式加载**内部插件Go 包编译集成和外部插件Go plugin -buildmode=plugin 动态加载)
- **热加载**:插件管理 API运行时安装/卸载/重载插件
- **Stage 钩子**:插件可在 on_input/pre_action/post_action/before_toolcall/after_toolcall/before_output/after_output 各阶段注入逻辑
- **配置系统**:每插件独立 SQLite 配置表,标准注册/读取 API
### 数据存储
- SQLite 集中配置(核心 + 每插件独立表空间)
- 记忆数据存本地文件系统,向量索引内嵌(无需外部向量数据库)
- 文件变更追踪基于 overlayfs 实现
## 技术栈
- 语言Go 1.19+
- 构建:标准 Go toolchainCGO_ENABLED=1overlayfs 依赖)
- 插件Go -buildmode=plugin
- LLM 适配Lua 胶水层8 个适配器
- 搜索:内置 TF-IDF 向量化器