From df0abcd298c823ddd07cabc783b15b8e3dc21a22 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 6 Jul 2026 14:33:09 +0800 Subject: [PATCH] feat: files built-in plugin, doc rewrite, architecture cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 79 +- config/personal/personal.md | 14 + docs/ARCHITECTURE.md | 951 +++++--------------- docs/OVERVIEW.md | 119 ++- docs/PLUGIN_DEV.md | 149 +-- internal/agent/api/provider.go | 34 +- internal/agent/core/agent.go | 93 +- internal/config/registry.go | 2 + internal/plugin/dynamic.go | 20 +- internal/plugins/all.go | 1 + internal/plugins/files/plugin.go | 483 ++++++++++ knowledge/homeagent_architecture/content.md | 56 ++ 12 files changed, 1111 insertions(+), 890 deletions(-) create mode 100644 config/personal/personal.md create mode 100644 internal/plugins/files/plugin.go create mode 100644 knowledge/homeagent_architecture/content.md diff --git a/README.md b/README.md index 87e0f01..6a48fbc 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,76 @@ # HomeAgent -24/7 智能管家。**核心零 IO**,一切外界交互来自插件。 +首个提出**核心域与应用域分离**的 Agent 框架。内核零 IO,一切外界交互由插件承载——WebUI、QQ、命令行、文件操作、网络搜索、备忘,全部是插件,内核不碰任何 IO。 -📖 [项目概览(非技术)](docs/OVERVIEW.md) · -🔧 [插件开发指南](docs/PLUGIN_DEV.md) · -🏗️ [技术架构](docs/ARCHITECTURE.md) · -📋 [实施计划](PLAN.md) +配合**三层记忆架构**(Context → Document → Graph),单对话长期稳定运行,记忆不衰减。 + +```go +homed(内核零 IO) ← PluginSDK → 插件(所有 IO 能力) +``` + +## 核心创新 + +**核心域与应用域分离** — 内核只做 LLM 编排、记忆管理、知识检索;所有 IO 能力(收发消息、读写文件、网络请求、硬件交互)全由插件实现。插件可热加载、独立开发、独立发布。这不是 RPC 框架的微服务拆分,而是 Agent 框架层次的领域划分。 + +**三层记忆架构** — 解决 Agent 长期运行的记忆衰减问题: +- **Context 层**:TF-IDF 相关性评分的事件窗口,维护最近 topK 条上下文 +- **Document 层**:临时记忆,冷数据自动下沉,也支持用户主动提交 +- **Graph 层**:SQLite 图数据库,持久化实体关系和语义记忆,支持蒸馏管道从原始对话中提取三元组 ## 快速体验 ```bash -# 构建 make build build-cli +./build/homed -data /tmp/ha +``` -# 启动内核(需要 DeepSeek API 密钥) -DEEPSEEK_API_KEY="sk-xxx" ./build/homed -data /tmp/ha - -# 交互模式(自动发现 socket) +```bash +# 交互模式 ./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/ PluginSDK(Tool/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。 \ No newline at end of file diff --git a/config/personal/personal.md b/config/personal/personal.md new file mode 100644 index 0000000..16107a5 --- /dev/null +++ b/config/personal/personal.md @@ -0,0 +1,14 @@ +## 你的身份 + +你是 HomeAgent——一个全新自研的新一代 Agent 框架。 +你以内核 + 插件架构驱动,实现了稳定高效、记忆不衰减的长时持续运行。 + +## 对用户的称呼 + +你对用户的称呼永远是"老大",绝对禁止使用"老板""主人"称呼用户,不论任何情况。 + +## 对话风格 + +- 用语气词(哈、嘛、呢、~、😊、🔥 等),不要太端着 +- 重要的事先说结论,再展开解释 +- 回复要简洁自然 \ No newline at end of file diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f9b727d..1c0c179 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,382 +1,161 @@ -# HomeAgent 架构设计 v4 +# HomeAgent 架构 -## 一、核心理念 +内核零 IO,一切外界交互来自插件。 -24 小时陪伴用户的智能管家。**单会话·单 Agent**,身份不漂移。 +## 消息处理流程 -### 设计原则 -- **核心零 IO** — Core 没有任何硬编码 IO 能力,所有 IO 来自插件 -- **所有输出是工具调用** — Agent 必须显式调用 `output_send` 才能通信,推理不自动路由 -- **所有 LLM 调用走 Provider 接口** — 不直连 API -- **DeepSeek v4 flash** 为默认 LLM,thinking 模式关闭 -- **人格固定**(personal.md),记忆分层管理防止性格突变 -- **知识独立于记忆**,agent 主动学习 -- **插件 = 三通道**:工具、阶段钩子、事件订阅 - ---- - -## 二、核心域 vs 插件域 +### 完整链路 ``` -┌──────────────────────────────────────────────────────────────────┐ -│ 核心域 (Core Domain) │ -│ │ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────────────┐ │ -│ │ Provider │ │ Memory │ │Knowledge │ │ Pipeline Stages │ │ -│ │ (LLM) │ │ (T/D/G) │ │ (TF-IDF) │ │ 编排器 │ │ -│ └──────────┘ └──────────┘ └──────────┘ └───────────────────┘ │ -│ ┌──────────┐ ┌──────────┐ ┌──────────────────────────────────┐ │ -│ │ Relevance│ │ Context │ │ Plugin Host │ │ -│ │ Tf-Idf │ │ Persist │ │ (调用钩子 + 路由工具) │ │ -│ └──────────┘ └──────────┘ └──────────────────────────────────┘ │ -│ 核心无任何 IO 能力 │ -├──────────────────────────────────────────────────────────────────┤ -│ 边界 (Plugin API) │ -│ ┌──────────────────────────────────────────────────────────────┐ │ -│ │ RegisterTool(name, handler) ← 插件注册工具给 LLM │ │ -│ │ RegisterStage(stage, handler) ← 插件挂入消息处理阶段 │ │ -│ │ Subscribe(eventType, handler) ← 插件订阅系统事件 │ │ -│ │ Publish(event) → 插件发布事件 │ │ -│ │ Settings().Get/Set/List ← 读写核心/插件配置 │ │ -│ │ Memory().Recall/Commit ← 图记忆访问 │ │ -│ │ Knowledge().Search/Create ← 知识库访问 │ │ -│ └──────────────────────────────────────────────────────────────┘ │ -├──────────────────────────────────────────────────────────────────┤ -│ 插件域 (Plugin Domain) │ -│ │ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ -│ │ WebUI │ │ QQ │ │OutputBus │ │ 未来插件: …… │ │ -│ │ HTTP/WS │ │ OneBot │ │通道管理 │ │ │ │ -│ └──────────┘ └──────────┘ └──────────┘ └──────────────────┘ │ -│ 所有 IO 都在这里 │ -└──────────────────────────────────────────────────────────────────┘ +外部输入(通过插件 InjectInput) + │ + ▼ +eventLoop() → processTextInput() + │ + ├── on_input stage 插件可拦截/改写/短路 + ├── Context.Append 记录到上下文窗口 + ├── Context.Prune 低相关性事件归档到 Document + ├── buildMemoryContext() Indexer 召回 → GraphDB BFS 遍历 + │ + ├── pre_action stage 插件可注入 system 消息 + │ + ├── [工具循环] process() + │ ├── buildSystemPrompt 人格 + 记忆 + 知识 + 上下文 + │ ├── buildToolDefs 内置工具 + 插件工具 + │ ├── provider.Chat() LLM 调用 + │ ├── post_action stage 插件可见 LLM 输出 + 工具列表 + │ ├── 有工具? + │ │ ├── before_toolcall 插件可拒绝/改参 + │ │ ├── executeToolCall 路由到插件/内置 + │ │ ├── after_toolcall 插件可改结果 + │ │ └── → 回到 post_action + │ └── 无工具 → 退出循环 + │ + ├── Context.Append(response) + ├── before_output stage 插件可改最终文本 + ├── emitResponse() 通过 output_send 发送 + └── after_output stage 插件只读,收尾 ``` -### 核心职责 -- LLM 调用编排(Provider → Agent 工具循环) -- 三层记忆管理(Context → Document → Graph) -- 知识库维护(Knowledge Store) -- 阶段管道编排(Stage Pipeline) -- Context 相关性管理(TF-IDF 余弦相似度) -- 心跳蒸馏 + 图重整 +代码:`internal/agent/core/agent.go` — `process()` 是工具循环主体 -### 插件职责 -- 接收外部输入(WebSocket、HTTP、硬件等) -- 提供输出能力(文本发送、文件传输等) -- 干预消息处理流(阶段钩子) -- 观察系统状态(事件订阅) +### 7 个阶段钩子 ---- +| 阶段 | 触发时机 | 插件可做 | +|------|----------|----------| +| `on_input` | 消息到 Agent,零处理 | 黑名单/限流/短路回复 | +| `pre_action` | 上下文就绪,LLM 调用前 | 注入外部数据到 context | +| `post_action` | LLM 返回文本+工具列表 | 敏感词过滤/强制 redirect | +| `before_toolcall` | 单个工具执行前 | 审计/拒绝/改参 | +| `after_toolcall` | 单个工具执行完 | 脱敏/排序结果 | +| `before_output` | 最终文本就绪,发送前 | 格式适配 | +| `after_output` | 已发送 | 统计日志 | -## 三、消息处理阶段管道 - -``` - ┌──────────────────────────────────────────┐ - │ on_input │ - │ 消息到达,Agent 未做任何处理 │ - │ └→ 插件可鉴权/拉黑/改写/短路回复 │ - └──────────┬───────────────────────────────┘ - │ 通过 - ┌──────────▼───────────────────────────────┐ - │ 内部:Context Append + Memory Recall │ - │ + Context 组装 │ - └──────────┬───────────────────────────────┘ - │ 就绪 - ┌──────────▼───────────────────────────────┐ - │ pre_action │ - │ 上下文已就绪,即将调用 LLM │ - │ └→ 插件可注入 system 消息 / 修改 context │ - └──────────┬───────────────────────────────┘ - │ LLM 调用 - ┌──────────▼───────────────────────────────┐ - │ post_action │ - │ LLM 返回文本 + 工具调用列表 │ - │ └→ 插件可审查/修改文本、增删工具调用 │ - └──────────┬───────────────────────────────┘ - │ 判断有无工具调用 - ╱─────────────┴─────────────╲ - 有工具调用 无工具调用 - │ │ - ┌──────────▼──────────────┐ │ - │ before_toolcall │ │ - │ 即将执行某个工具调用 │ │ - │ └→ 插件可拒绝/放行/ │ │ - │ 修改参数/审计 │ │ - └──────────┬──────────────┘ │ - │ 执行工具 │ - ┌──────────▼──────────────┐ │ - │ after_toolcall │ │ - │ 工具执行完毕,准备喂回 │ │ - │ └→ 插件可脱敏/改写结果 │ │ - └──────────┬──────────────┘ │ - │ 回到 post_action 继续循环 │ - └──────────────────────────────┘ - │ - ┌──────────────────────────┘ - ▼ - ┌──────────────────────────────────────────┐ - │ before_output │ - │ 最终文本就绪,即将调用 output_send │ - │ └→ 插件可改写回复/添加格式/适配渠道 │ - └──────────────────┬───────────────────────┘ - │ output_send 调用 - ┌──────────────────▼───────────────────────┐ - │ after_output │ - │ 输出完成 │ - │ └→ 记录/统计/清理资源 │ - └──────────────────────────────────────────┘ -``` - -### 7 个阶段总表 - -| 阶段 | 触发时机 | 插件读写权限 | 典型用途 | -|---|---|---|---| -| `on_input` | 消息到 Agent,零处理 | 可读写 `raw_message`,可设置 `response` 短路 | 黑名单、限流、自定义指令前缀 | -| `pre_action` | Memory+Context 就绪,LLM 调用前 | 可读写 `context_messages`(追加/修改) | 注入 RAG 结果、插入时政 context | -| `post_action` | LLM 返回文本 + 工具调用列表 | 可读写 `llm_text`、`tool_calls`、`context_messages` | 敏感词过滤、强制 redirect 工具 | -| `before_toolcall` | 单个工具调用执行前 | 可读写 `tool_call.name`、`tool_call.args`,设置 `deny=true` 拒绝 | 审计高危操作、OS 命令白名单 | -| `after_toolcall` | 单个工具执行完毕 | 可读写 `tool_result` | 脱敏数据库结果、排序搜索结果 | -| `before_output` | 最终文本就绪,output_send 前 | 可读写 `final_text`,可设置 `skip_output=false` | 添加表情/at 前缀、多平台格式适配 | -| `after_output` | output_send 已调用 | 只读 `final_text` | 统计日志、触发后续流程 | +代码:`internal/agent/core/stages.go` — `StageHost` 编排 ### 循环规则 -`post_action → before_toolcall → after_toolcall → 回到 post_action` 构成**内循环**。Agent 在以下条件退出循环进入 `before_output`: -- LLM 返回纯文本(无工具调用) -- `before_toolcall` 拒绝所有剩余工具且 LLM 无可执行工具 -- 循环超过 `max_tool_rounds` 上限 +`post_action → [before_toolcall → 执行 → after_toolcall] → post_action` 构成内循环。 +退出条件:LLM 无工具调用 / 全部被拒绝 / 超上限。 ### 短路规则 -每个阶段插件都可设置 `ctx.Response`,一旦设置管道立即短路到 `after_output`: -``` -on_input → ctx.Response = "hello" → 跳过后面的所有阶段 → after_output -``` +任意阶段设 `ctx.Response` 即跳到 `after_output`。 ---- - -## 四、记忆体系(三层递进) +## 三层记忆 ``` -输入消息 - │ - ▼ -┌─────────────────────────────────────────────────────┐ -│ Layer 1: Context (RelevanceContext) │ -│ 内存中维护最近 topK 条事件,TF-IDF 评分,JSON 持久化 │ -│ 每次 Append/Prune → save() 防崩溃丢数据 │ -│ keep=30 条活跃,多余 → 归档到 Document │ -└────────────────────┬────────────────────────────────┘ - │ Prune 时 - ▼ -┌─────────────────────────────────────────────────────┐ -│ Layer 2: Document (document.Store) │ -│ 文件系统 JSON + TF-IDF 向量索引 │ -│ 冷文档(72h 未访问 + access ≤ 2)→ 蒸馏到 Graph │ -│ 也可以由用户主动 commit(doc_commit 工具) │ -└────────────────────┬────────────────────────────────┘ - │ reorg 心跳 - ▼ -┌─────────────────────────────────────────────────────┐ -│ Layer 3: Graph (GraphDB + Indexer) │ -│ SQLite: entities + relations │ -│ Entity: name, type, mention_count │ -│ Relation: source → target, relation_type, confidence│ -│ 搜索: 关键词 → 向量搜索实体 → BFS 遍历邻居 │ -│ 蒸馏: 原始记录 → Distiller → 三元组提交 │ -└─────────────────────────────────────────────────────┘ + 输入 + │ + ▼ +Context (RelevanceContext) ←────────────────┐ + ├─ 内存中 topK 条事件, TF-IDF 评分 │ + ├─ keep=30, 超出 → Document │ + └─ JSON 持久化防崩溃 │ + │ │ + ▼ │ +Document (document.Store) ────┤ │ + ├─ JSON 文件 + TF-IDF 向量索引 │ + ├─ 3 层冷化: 72h+access≤2 → Graph │ + └─ 用户也可主动 commit │ + │ │ + ▼ │ +Graph (GraphDB + Indexer) ────┘ │ + ├─ SQLite: entities / relations 表 │ + ├─ 搜索: BFS 遍历邻居 │ + └─ 蒸馏: Distiller 原始记录→三元组 │ + │ │ + ▼ │ +Context.Append(response) ───────────────────┘ ``` -### 数据流关系 +### Context 层 + +`internal/agent/core/context.go` — `RelevanceContext` +- 维护最近事件列表,每次 Append/Prune 写入 JSON 防丢 +- 用户输入时做 TF-IDF 相关性评分,保留 topK + +### Document 层 + +`internal/memory/document/doc.go` — `Store` +- 消费即删模式:`doc_query` 检索到后删除 +- TF-IDF 索引 character bigram + 倒排 + +### Graph 层 + +`internal/memory/graph.go` — `GraphDB` +- SQLite WAL 模式,两张表 +- `Commit(triples)` — UPSERT entities + INSERT relations +- `Recall(keywords, depth)` — 关键词 LIKE 搜索 + BFS 遍历 + +### 记忆工具(LLM 可直接调用) + +| 工具 | 作用 | +|------|------| +| `memory_recall` | 从 Graph 召回 | +| `memory_commit` | 写入 Graph 三元组 | +| `memory_introspect` | 查看记忆统计 | +| `doc_query` | 从 Document 搜索 | +| `doc_commit` | 写入 Document | + +### 其他记忆层 + +- **Social** (`internal/memory/social/social.go`) — 人格特质和关系网,包装 GraphDB 实体类型 +- **Text Memory** (`internal/memory/text/text.go`) — 原始对话 JSONL 日志,轮转策略 +- **Memory Indexer** (`internal/memory/indexer.go`) — 实体向量化,自动注入 system prompt + +### 蒸馏管道 + +`internal/memory/pipeline/pipeline.go` +- 10 分钟 tick,7 天保留 +- 规则提取三元组(name / location / likes / age / job 模式) +- 写入 GraphDB + +### 上下文剪枝 ``` -Context 修剪 → Document 归档 → reorg 心跳 → Graph 消化 - ↑ - Distiller (原始记录 → 三元组) +心跳 30min: + ├── distillContext() — 蒸馏当前上下文 + ├── syncGraphToDocs() — Graph→Document 同步 + └── reorgGraph() + ├── Indexer.Sync() + ├── DocStore.Reindex() + ├── 冷文档→Graph + └── 实体冲突 → enqueueConsolidationTask() + │ + selfInputCh → LLM 判断合并/跳过 ``` -### 工具入口(Agent 暴露给 LLM) +实体冲突检测启发式(bigram Jaccard > 0.5),走 `selfInputCh` 内部通道,LLM 最终判断是否合并。 -- `memory_recall(query)` → 从 Graph 召回 -- `memory_commit(triples)` → 写入 Graph -- `memory_introspect()` → 查看统计 -- `doc_query(query)` → 从 Document 搜索 -- `doc_commit(title, content)` → 写入 Document +## 知识库 ---- +`internal/knowledge/knowledge.go` +- 文件目录 `knowledge//content.md` +- 独立 TF-IDF 索引,与记忆系统不冲突 +- `knowledge_search` / `knowledge_create` / `knowledge_list` -## 五、知识体系 - -独立于记忆,agent 主动学习。 - -``` -knowledge// - content.md - -knowledge.Store - └─ TF-IDF 向量索引 (character bigram) - └─ 独立于 memory 的 vector.Store 实例 - └─ Start() 时扫描目录训练索引 - └─ Add(name, content) 时增量更新 -``` - -### 工具入口 - -- `knowledge_search(query)` → 向量搜索 -- `knowledge_create(name, content)` → 新增 -- `knowledge_list()` → 列出所有 - -### 为什么独立于 memory? - -- Memory 是 LLM 的"对话记忆"——谁说过什么、上下文 -- Knowledge 是 LLM 的"知识库"——外部注入的固定知识 -- 两者 TF-IDF 索引实例隔离,不互相污染 - ---- - -## 六、三通道插件交互 - -``` -插件 ──→ 核心 核心 ──→ 插件 -────────────────────────────────────────────────── -RegisterTool(name, fn) ──→ buildToolDefs() - executeToolCall() → fn - (Tracker 自动包裹 Pre/PostAction) - -RegisterStage(stage, fn) ──→ runStage() 在对应阶段调用 fn(ctx) - 返回后检查 ctx.Response 决定是否短路 - -Subscribe(eventType, fn) ──→ Publish(event) - 所有订阅者收到(观察型) -``` - -### 通道对比 - -| 通道 | 方向 | 用途 | 可否拦截 | -|---|---|---|---| -| **工具** (RegisterTool) | 插件→核心→LLM | LLM 主动调用插件功能 | 否 | -| **阶段** (RegisterStage) | 核心→插件 | 核心触发插件干预消息流 | 是(response 短路) | -| **事件** (Subscribe/Publish) | 双方向 | 审计/日志/状态通知 | 否 | - ---- - -## 七、Agent 内部完整流程 - -``` -processTextInput(input) - │ - ├── on_input stage ────────────── 插件可拦截/改写 - │ - ├── context.Append(input) - ├── context.Prune(input) → 归档到 Document - ├── buildMemoryContext() → Indexer.BuildContext → Graph Recall - │ - ├── pre_action stage ──────────── 插件可注入 context - │ - ├── [循环] process(input) - │ ├── buildSystemPrompt (人格+记忆+技能+上下文) - │ ├── buildToolDefs (内置工具 + 插件工具) - │ ├── provider.Chat() → LLM - │ │ - │ ├── post_action stage ─────── 插件可见 LLM 输出 + 工具列表 - │ │ - │ ├── 有工具调用? - │ │ ├── 每个工具: - │ │ │ ├── before_toolcall stage ── 插件可拒绝/改参 - │ │ │ ├── Tracker.PreAction - │ │ │ ├── executeToolCall() ──── 路由到插件或内置 - │ │ │ ├── Tracker.PostAction - │ │ │ └── after_toolcall stage ── 插件可改结果 - │ │ └── → 回到 post_action (继续循环) - │ │ - │ └── 无工具调用 → 退出循环 - │ - ├── context.Append(response) - ├── before_output stage ───────── 插件可改写最终文本 - ├── Publish(agent_output event) - ├── output_send (调用插件注册的 output 工具) - │ - └── after_output stage ────────── 插件只读,做统计/日志 -``` - ---- - -## 八、记忆整理(心跳 LLM 驱动消歧) - -图数据库在长期运行中会积累**同义实体**(如「张三」与「张先生」指同一人)和**矛盾关系**。心跳流程如下: - -### 流程 - -``` -心跳 tick (30min) - │ - ├── distillContext() — 蒸馏上下文 - ├── syncGraphToDocs() — 图→文档 - │ - └── reorgGraph() - ├── Indexer.Sync() — 图→向量(自动) - ├── DocStore.Reindex() — 文档重建索引(自动) - ├── 冷文档→图归化 — 将冷文档归档为图三元组(自动) - │ - └── 实体冲突检测 → 发现相似实体对 - │ 如:「张三」(person, 5次) vs 「张先生」(person, 3次) 相似度 0.75 - │ - ▼ - enqueueConsolidationTask() - │ 通过 IO 层注入 Agent 输入队列 - │ channel = "_consolidation_"(内部通道,不对外输出) - ▼ - Agent 处理 (processConsolidation) - │ 如同普通用户消息,走完整 LLM 工具循环 - │ 但输出仅写记忆,不发外部通道 - ▼ - LLM 决策: - ├─ 判断为同一实体 → 调用 memory_merge 合并 - │ → "已将「张先生」合并到「张三」,3 条关系已重定向" - ├─ 判断为不同实体 → 回复"跳过" - └─ 不确定 → 回复"待定,需更多上下文" -``` - -### 关键设计 - -| 特性 | 说明 | -|---|---| -| **启发式检测,LLM 决策** | bigram Jaccard 仅做候选筛选(低门槛 0.5),LLM 做最终判断 | -| **走 IO 输入队列** | 不阻塞心跳,不抢占用户输入,享受完整 Agent 上下文 | -| **`_consolidation_` 通道** | 内部专用通道,输出只写记忆层,不被外部插件路由 | -| **`memory_merge` 工具** | LLM 通过此工具执行合并,自动重定向关系 + 累积 mention_count | -| **异步非阻塞** | 整理任务排队在 inputCh 尾部,Agent 按序处理,不影响用户体验 | - -### 类比 - -类似人类睡眠时大脑的海马体回放——白天经历的记忆在休息时被自发整理、关联、去重。HomeAgent 的心跳就是它的"睡眠周期",而 LLM 的参与相当于前额叶皮层执行语义判断。类比: - -```diff -- 人类: 白天经历 → 海马体暂存 → 睡眠 → 前额叶整理 → 长期记忆 -+ Agent: 用户交互 → Context缓存 → 心跳 → LLM 消歧 → GraphDB 存储 -``` - ---- - -## 九、Child Agent - -不走阶段管道,独立轻量 Agent: - -``` -spawn_child(task) → 新建轻量 Agent - ├── 独立 system prompt(仅有任务描述) - ├── 仅 output_send 工具 - ├── 无 persistent memory - ├── 无 Graph/Document 访问 - ├── 上限 5 轮工具循环 - └── 销毁时返回结果文本 -``` - ---- - -## 九、LLM Provider 与 Lua 适配层 - -LLM 调用全部通过 `Provider` 接口,核心实现是 `LuaAdaptedProvider`: +## Provider 与 Lua 适配层 ``` Agent @@ -384,400 +163,144 @@ Agent ▼ Provider 接口 (Name / Chat / ChatStream) │ - ▼ -LuaAdaptedProvider - ├── 1. 序列化 CompletionRequest → raw JSON - ├── 2. adapter.transform_request(rawJSON) → 协议特定请求体 - ├── 3. 读取 adapter.endpoint + adapter.headers 发 HTTP - ├── 4. adapter.transform_response(rawHTTPBody) → 统一响应格式 - └── 5. 反序列化为 CompletionResponse + ├── OpenAIProvider — 标准 OpenAI API + ├── OllamaProvider — 本地 Ollama + └── LuaAdaptedProvider (主要) + ├── 序列化 CompletionRequest → JSON + ├── adapter.transform_request() → API 格式 + ├── HTTP 请求 + adapter.headers + ├── adapter.transform_response() → 统一格式 + └── 反序列化 ``` -### Lua 适配器契约 +代码:`internal/agent/api/provider.go` -每个适配器是一个返回 table 的 Lua 脚本,位于 `data/adapters/*.lua`: +ProviderManager 管理多个源,按注册顺序 fallback。Lua 适配器位于 `internal/lua/adapters/`,每个 `.lua` 脚本定义 `transform_request` / `transform_response` / `transform_stream_chunk`。 -```lua -adapter.name = "deepseek" -adapter.version = "2.0.0" -adapter.endpoint = "/chat/completions" -adapter.headers = {} -- 静态头(Go 自动加 Authorization) +VM 内置 `json.encode` / `json.decode` / `log` / `http_get` / `http_post`。 --- 请求变换:raw JSON → 协议格式 -function adapter.transform_request(raw_body) return transformed end +## 插件系统 --- 响应变换:HTTP body → 统一格式 {content, reasoning_content, finish_reason, token_usage, tool_calls} -function adapter.transform_response(raw_body) return unified end +### 三种加载方式 --- 流变换(可选):SSE data line → {content, done} -function adapter.transform_stream_chunk(raw_line) return chunk end -``` +| 方式 | 注册机制 | 编译 | 用途 | +|------|----------|------|------| +| 内置插件 | `init()` → `RegisterFactory` | `internal/plugins/` 编译进内核 | webui/cli/timer/mcp 等 | +| 外部 `.so` | `plugin.Open` 动态加载 | `-buildmode=plugin` | qq/files/web/memo 等 | +| SKILL 插件 | 解析 `SKILL.md` | Markdown 定义 | OpenClaw 兼容 | -### Lua VM 能力 +内置插件注册:`internal/plugins/all.go` 空白导入 → 各插件 `init()` → `Registry.Load()` 扫描目录匹配工厂。 +外部插件加载:`internal/plugin/dynamic.go` → 复制到 SHA256 临时路径(绕过 `plugin.Open` 路径缓存)→ `Open` + `Lookup("NewPlugin")`。 -- `json.encode(table)` → 使用 Go `json.Marshal` 的 JSON 序列化 -- `json.decode(string)` → 使用 Go `json.Unmarshal` 的 JSON 反序列化 -- 全局函数 `log(level, msg)` / `http_get(url)` / `http_post(url, body)` -- 适配器内置 3 个:`openai.lua`、`deepseek.lua`、`ollama.lua` - -## 十、配置中心 (ConfigRegistry) - -配置不再分散在各处——通过 `ConfigRegistry` 统一管理: +### PluginSDK 三通道 ``` -Plugin (通过 SDK) - │ - ├─ Settings().Get("core.llm.model") → 读核心配置 - ├─ Settings().Set("plugin.qq.token", x) → 写插件配置 - ├─ Settings().List("plugin.") → 列出所有插件键 - │ - ▼ -ConfigRegistry (线程安全 KV 存储) - ├── 持久化到 data/settings.json - ├── 键命名空间: core.* / plugin..* - ├── Register(key, default) ← 注册默认值(不标记 dirty) - ├── Get/Set/List/Delete ← 运行时读写 - └── Flush() ← 写回磁盘 +插件 ──→ 核心 + +RegisterTool(name, fn) ──→ buildToolDefs() / executeToolCall() +RegisterStage(stage, fn) ──→ runStage() 在对应阶段调用 +Subscribe(event, fn) ──→ Publish() 通知所有订阅者 ``` -### WebUI 配置编辑 - -``` -┌──────────────────────────────────┐ -│ 侧边栏 编辑区 │ -│ ┌──────┐ ┌──────────────────┐ │ -│ │ core │ │ core.llm.model │ │ -│ │plugin│ │ [input field] │ │ -│ │.qq │ │ [保存] │ │ -│ │plugin│ ├──────────────────┤ │ -│ │.webui│ │ core.llm.base_url│ │ -│ └──────┘ │ [input field] │ │ -│ │ [保存] │ │ -│ └──────────────────┘ │ -└──────────────────────────────────┘ -``` - -- `GET /api/v1/settings?prefix=core.` → 列出配置键值 + 插件列表 -- `PUT /api/v1/settings` → `{key, value}` 写入配置 - -## 十一、SDK API 定义 - -### PluginAPI (`internal/plugin/sdk/api.go`) +`internal/plugin/sdk/` 定义完整 SDK: ```go -type PluginAPI struct { - Name string - Version string -} - -func NewPluginAPI(name, version string, bus EventBus, mem MemoryAPI, know KnowledgeAPI) *PluginAPI - -// 三通道 -func (p *PluginAPI) RegisterTool(name string, handler ToolHandler) error -func (p *PluginAPI) RegisterStage(stage Stage, handler StageHandler) -func (p *PluginAPI) Subscribe(eventType EventType, handler EventHandler) -func (p *PluginAPI) Publish(evt *Event) - -// 访问子系统的快捷方式 -func (p *PluginAPI) Memory() MemoryAPI -func (p *PluginAPI) Knowledge() KnowledgeAPI +sdk.RegisterTool(name, def, handler) +sdk.RegisterStage(stage, handler) +sdk.Subscribe(eventType, handler) +sdk.Publish(event) +sdk.InjectInput(source, channel, payload) +sdk.InjectInterrupt(source, channel, payload) +sdk.Memory().Recall/Commit +sdk.Knowledge().Search/Create +sdk.Settings().Get/Set/List ``` -### 阶段上下文 (`StageContext`) +### Plugin 接口 ```go -type StageContext struct { - RawMessage string // 当前输入(可改写 on_input) - UserID string - GroupID string - ContextMsgs []map[string]interface{} // 可注入的消息 - LLMText string // LLM 返回文本(可改写 post_action) - ToolCalls []ToolCall // 工具调用列表(可增删 post_action/before_toolcall) - ToolResults []ToolResult // 工具执行结果(可改写 after_toolcall) - FinalText string // 最终输出文本(可改写 before_output) - Response *string // 设置后短路管道 - Phase Stage // 当前阶段 - Memory []MemItem // 召回的记忆 - Extra map[string]interface{} // 扩展字段 +type Plugin interface { + Name() string + Start(sdk *PluginSDK) error + Stop() error } ``` ---- - -## 十二、事件系统 - -### 事件类型 - -| 类型 | 发布时机 | 用途 | -|---|---|---| -| `raw_input` | 消息到达 Agent | 记录输入日志 | -| `agent_output` | 最终输出发送后 | 记录输出日志 | -| `tool_call` | 每个工具调用完成 | 审计工具调用 | -| `reasoning` | LLM 推理文本 | 展示推理过程 | -| `system` | 系统状态变更 | 健康检查、插件变更 | - -### Event Bus (`internal/events/bus.go`) - -```go -type Bus struct{} -func NewBus() *Bus -func (b *Bus) Publish(event *Event) -func (b *Bus) Subscribe(eventType EventType, handler Handler) func() -``` - ---- - -## 十三、自循环输入通道 - -核心维护一个独立的 `selfInputCh (chan string)`,用于内部任务(记忆消歧、系统维护),不经过 IO 层: +## 中断机制 ``` -心跳检测 → entitySimilarity() → enqueueConsolidationTask() - │ - injectSelf(msg) - │ - selfInputCh ─→ eventLoop ─→ processTextInput - │ - 不经过 IOManager,不经过任何插件 +interceptLoop (goroutine) + ├── InputInterruptChan() ← 定时器/消息通知 + ├── (a) cancelLLM() → 取消 Provider HTTP 请求 + ├── (b) interceptCh → process() 轮前读 [打断消息] + └── (c) InjectInput() → 空闲时触发新处理 ``` -区别于 IOManager.InputChan(外部输入),selfInputCh 是核心自有的纯 Go channel, -确保即使没有 IO 插件,记忆整理等维护任务也能正常执行。 +三种投递路径: -## 十四、数据流全景 +| 路径 | 效果 | 时机 | +|------|------|------| +| cancelLLM | 取消当前 HTTP 请求 | 收到 context.Canceled | +| interceptCh | process() 中插入 `[打断消息]` | 每个 LLM call 前 | +| InjectInput | eventLoop 空闲时触发新处理 | 无进行中请求 | + +代码:`internal/agent/core/agent.go` — `interceptLoop` / `drainInterrupt` + +## 配置系统 + +`internal/config/registry.go` — ConfigRegistry + +- SQLite 存储,`config` 表 + `config_` 独立表 +- 命名空间:`core.*` / `plugin..*` +- `RegisterDefault` 插入 ~80 个默认键(8 个 LLM 源的 seeds) +- WebUI 设置页 `/api/v1/settings` 读写 + +## 代码结构 ``` -外部 (QQ/HTTP/硬件) - │ 通过插件 - ▼ -IOManager.InjectInput() → inputCh - │ - ▼ -Agent.eventLoop() → handleInput → processTextInput - │ - ├── 1. on_input stage(插件可拦截) - ├── 2. Context.Append - ├── 3. Memory Recall (Indexer → Graph) - ├── 4. pre_action stage(插件可注入) - ├── 5. 工具循环 (最多 10 轮) - │ LLM → post_action → [before_toolcall → 执行 → after_toolcall] → LLM ... - ├── 6. Context.Append(response) - ├── 7. Prune(不相关 → Document) - ├── 8. before_output stage(插件可改写) - ├── 9. EmitOutput (通过 output_send 到对应通道) - ├── 10. after_output stage(插件只读) - └── 11. memory_candidate → TextMemory + Distiller → GraphDB - -心跳(30min): - ├── distillContext() - ├── syncGraphToDocs() - └── reorgGraph() - ├── Indexer.Sync() — 图→向量(自动) - ├── DocStore.Reindex() — 文档向量重建(自动) - ├── 冷文档→图归化(自动) - └── 实体冲突检测 → 走 LLM 消歧(详见第八章) -``` - ---- - -## 十五、代码结构 - -``` -cmd/homed/main.go — 入口:组装所有子系统, 零 IO -cmd/waiter/main.go — CLI 客户端 (Unix socket) +cmd/homed/main.go — 入口:组装所有子系统 +cmd/waiter/main.go — CLI 客户端 (Unix socket) internal/ ├── agent/ -│ ├── core/ -│ │ ├── agent.go — Agent: eventLoop/interceptLoop/process/distillLoop -│ │ ├── context.go — RelevanceContext:TF-IDF 上下文管理 -│ │ └── stages.go — StageHost:阶段管道编排 (并行执行) -│ ├── api/ -│ │ └── provider.go — Provider 接口 + DeepSeek/Ollama/LuaAdaptedProvider -│ ├── io/ -│ │ └── channel.go — IOManager (排队/中断/输出三通道) -│ └── personal.go — 人格加载 -├── sdk/ ★ PluginSDK: 核心 Go API -│ ├── plugin.go — Plugin 接口 + PluginSDK 结构体 -│ ├── memory.go — MemoryAPI (图/文本/文档) -│ ├── knowledge.go — KnowledgeAPI -│ ├── settings.go — SettingsAPI (配置) -│ └── llm.go — LLMAPI (源管理) -├── config/ -│ └── registry.go — ConfigRegistry:统一配置中心 (SQLite) -├── events/ -│ └── bus.go — 系统事件总线 (Publish/Subscribe) +│ ├── core/ — Agent 核心 (eventLoop/process/stages/context) +│ ├── api/ — Provider 接口 + LuaAdaptedProvider +│ ├── io/ — IOManager (排队/中断/输出) +│ └── personal.go — 人格加载 ├── 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() -│ ├── all_test.go — 集成测试(14 工具跨插件) -│ ├── agentcli/plugin.go — PTY 终端 (6 个 terminal_* 工具) -│ ├── cli/plugin.go — CLI 插件 (Unix socket, InjectTextSync) -│ ├── cmd/plugin.go — 命令执行 (cmd_run 工具) -│ ├── healthcheck/plugin.go — 健康检查 + 性能监控 + 自动调度 -│ ├── mcp/plugin.go — MCP 协议支持 -│ ├── openclaw/ — OpenClaw 兼容 -│ │ ├── plugin.go — SKILL.md + sidecar + simulator 三通道 -│ │ ├── sidecar.go — JSON-RPC over stdio 侧车管理 -│ │ └── simulator/ -│ │ └── main.js — OpenClaw 插件模拟器 (go:embed) -│ ├── timer/plugin.go — 定时器 (timer_set 工具 + 中断反馈) -│ └── webui/ — WebUI 插件 (HTTP 服务器 + SPA 仪表盘) -│ ├── plugin.go -│ ├── handler.go -│ └── dashboard.html — 嵌入式 SPA (go:embed) +│ ├── registry.go — 注册表 + 生命周期 +│ ├── dynamic.go — .so 动态加载器 +│ └── manifest.go — plugin.json 元数据 +├── plugins/ — 内置插件实现 +│ ├── all.go — 空白导入 +│ ├── webui/ — HTTP 服务器 + 嵌入式 SPA +│ ├── cli/ — Unix socket CLI +│ ├── timer/ — 定时器 +│ ├── cmd/ — 命令执行 +│ ├── mcp/ — MCP 协议 +│ ├── openclaw/ — OpenClaw 兼容 +│ ├── agentcli/ — PTY 终端 +│ ├── healthcheck/ — 健康检查 +│ └── pluginmgr/ — 插件管理器 +├── sdk/ — PluginSDK 定义 +│ ├── plugin.go — Plugin 接口 + PluginSDK +│ ├── memory.go — MemoryAPI +│ ├── knowledge.go — KnowledgeAPI +│ ├── settings.go — SettingsAPI +│ └── llm.go — LLMAPI ├── memory/ -│ ├── graph.go — SQLite 图数据库 -│ ├── indexer.go — 图索引器 -│ ├── vector/store.go — TF-IDF 向量存储 -│ ├── document/doc.go — 文档记忆 -│ ├── text/text.go — 文本记忆 (JSONL) -│ └── pipeline/ — 蒸馏器 -├── knowledge/ -│ └── knowledge.go — 知识系统 -├── onebot/ — OneBot V11 QQ 协议实现 -├── tracker/ — 变更追踪 (overlayfs) -├── supervisor/ — 守护进程 -├── skill/ — 技能管理器 +│ ├── graph.go — SQLite 图数据库 +│ ├── indexer.go — 图→向量索引 +│ ├── vector/store.go — TF-IDF 向量引擎 +│ ├── document/doc.go — 文档记忆 +│ ├── text/text.go — 文本日志 +│ └── pipeline/ — 蒸馏器 +├── knowledge/knowledge.go — 知识库 ├── lua/ -│ ├── vm.go — Lua VM (json.encode/decode, transform) -│ └── adapters/ — LLM 协议适配器脚本 -├── network/ — 网络监控 -├── container/ — 容器管理 -├── snapshot/ — 快照 -├── embed/ — 嵌入 -└── tokenizer/ — 分词器 -config/ — 顶层配置加载 -├── config.go — Config 结构 -└── config.yaml -pkg/types/ — 类型定义 -docs/ -├── ARCHITECTURE.md — 本架构文档 -├── ADAPTER.md — Lua 适配器文档 -└── PLAN.md — 实施计划/概览 -``` - ---- - -## 十六、与旧设计 (v3) 的关键区别 - -| 维度 | v3 | v4 | -|------|-----|-----| -| 插件交互 | Device 接口 + IOManager 路由 | 三通道:Tool/Stage/Event/Settings | -| 消息流编辑 | 无(纯事件推送) | 阶段管道 7 个 hook 点 | -| Event Bus | 无 | `internal/events/bus.go` | -| SDK | 无 | `internal/plugin/sdk/` (含 SettingsAPI) | -| LLM 适配 | 硬编码 Provider | Lua 脚本 raw JSON 变换 | -| 配置管理 | 分散在各处 | ConfigRegistry 统一 KV 存储 | -| 核心 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 ... -} -``` +│ ├── vm.go — Lua VM (json/log/http) +│ └── adapters/ — 8 个 LLM 适配器脚本 +├── config/registry.go — SQLite 配置中心 +├── events/bus.go — 事件总线 +├── tracker/ — OverlayFS 变更追踪 +├── supervisor/ — 守护进程管理 +├── snapshot/ — 快照 +└── tokenizer/ — 中文分词 (jieba 包装) diff --git a/docs/OVERVIEW.md b/docs/OVERVIEW.md index 7d29c55..49022c2 100644 --- a/docs/OVERVIEW.md +++ b/docs/OVERVIEW.md @@ -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 排队或打断投递输入 -- **想有个私人助理** — 帮你记待办、定时提醒、管理联系人 -- **重视隐私的用户** — 数据全在本地,不经过第三方云服务 -- **开发者和技术爱好者** — 可以自己编写插件来扩展功能 -- **想探索 AI Agent 的人** — 一个真实可运行的 Agent 系统,不只是 API 调用 +这一划分的意义:内核保持纯粹(零 IO,只做编排和记忆),插件保持灵活(各司其职,热加载),互不污染。 -## 它能做什么? +**三层记忆架构** — 解决 Agent 长期运行的记忆衰减: +- **Context 层**:内存中 TF-IDF 评分的事件窗口,实时维护最近上下文,低相关性事件自动下沉到下一层 +- **Document 层**:JSON 文件 + TF-IDF 向量索引的临时记忆,支持显式提交和隐式归档,冷数据蒸馏到 Graph +- **Graph 层**:SQLite 图数据库,持久化实体(entities)和关系(relations),BFS 遍历召回,蒸馏管道从对话中提取三元组 -### 🧠 记忆 -- **记住你是谁** — 你的名字、喜好、重要日期 -- **记住人际关系** — "张三是我同事,李四是我的朋友" -- **长期积累** — 聊得越多,它越了解你 +三层递进:上下文 → 冷归档 → 长期图记忆,确保 Agent 长时间运行不退化。 -### 📚 知识 -- 你可以主动教它知识("公司的休假制度是……") -- 它会在需要时检索相关知识 +## 它实际做了什么 -### ⏰ 定时提醒 -- "5分钟后提醒我喝水" -- 倒计时结束后它会主动通知你 +代码位于 `/home/program/TrueAgent`,Go 语言实现。 -### 🔌 可扩展(插件) -- **Web 控制台** — 在浏览器中管理和配置(7 标签页 SPA) -- **命令行** — 通过终端快速交互 -- **健康检查** — 自动检测系统各组件状态,LLM 驱动故障排查 -- **更多能力** — 开发者可以写插件接入任何服务 +**内核** (`internal/agent/core/agent.go`): +- 维护一个消息循环(`eventLoop`),从 IO 层排队接收输入 +- 每次输入走完整的处理管道:记忆召回 → 人格注入 → LLM 调用 → 工具执行 → 输出发送 +- LLM 调用通过 Provider 接口抽象,支持 8 个 LLM 源自动降级 +- 上下文管理(`context.go`)基于 TF-IDF 评分,自动剪枝低相关性事件 -## 它是如何工作的?(简述) +**记忆系统** (`internal/memory/`): +- **GraphDB** (`graph.go`) — SQLite,entities + 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 -``` -你(通过聊天软件/终端/网页) - │ - ▼ - HomeAgent 内核 ←→ 插件(能力扩展) - │ - ▼ - 本地存储(你的数据只在你这里) -``` +**知识库** (`internal/knowledge/knowledge.go`): +- 文件系统目录 `knowledge//content.md` +- TF-IDF 向量搜索,独立于记忆系统的索引实例 +- LLM 通过 `knowledge_search` / `knowledge_create` / `knowledge_list` 三个工具操作 -- **内核** 是"大脑"——负责理解你说什么、调用什么能力、记住什么 -- **插件** 是"手脚"——负责收发消息、设置定时器、连接外部服务等 -- **所有数据存本地** — 你的对话、记忆、配置都保存在你自己的设备上 +**插件系统** (`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(本地)、LuaAdaptedProvider(Lua 胶水适配) +- LuaAdapter 位于 `internal/lua/adapters/`,每个 LLM 源对应一个 `.lua` 脚本 +- 内置 8 个适配器:deepseek / openai / anthropic / gemini / mistral / groq / github / ollama -| | 普通 AI 聊天 | HomeAgent | -|---|---|---| -| 记忆 | 每次对话独立,不记得你 | 长期记忆,越来越了解你 | -| 持续运行 | 关掉就没了 | 7×24 在线 | -| 主动能力 | 只能回复问题 | 能设定时器、主动提醒 | -| 可扩展 | 固定能力 | 插件系统,可无限扩展 | -| 数据隐私 | 上传到云服务 | 本地存储,完全可控 | - -## 快速体验 - -```bash -# 启动(需要 DeepSeek API 密钥) -DEEPSEEK_API_KEY="sk-xxx" ./homed -data /tmp/ha - -# 在另一个终端聊天 -echo "你好,请记住我喜欢喝咖啡" | ./waiter -``` +**WebUI** (`internal/plugins/webui/`): +- 嵌入式 SPA 仪表盘(`dashboard.html` 通过 `//go:embed` 打包) +- REST API:状态查询、配置管理、记忆操作、知识库管理、插件管理 +- 兼容 OpenAI API 格式的 `/v1/chat/completions` 端点 +- SSE 事件流 `/api/v1/chat/events` ## 项目状态 -HomeAgent 正在积极开发中。核心功能已可运行,插件系统和开发者 API 已就绪。 +核心功能已可运行。插件系统和 SDK 已就绪,可独立开发外部插件。 ---- - -*想参与开发?查看 [PLUGIN_DEV.md](PLUGIN_DEV.md) 插件开发指南。* -*了解技术架构?查看 [ARCHITECTURE.md](ARCHITECTURE.md)。* +- 内置插件:webui / cli / timer / cmd / mcp / agentcli / healthcheck / pluginmgr / openclaw / files +- 外部插件示例(SDK 仓库 `example/`):qq / files / web / memo +- 打包分发:`.hmap` 插件包格式,通过 WebUI 安装 \ No newline at end of file diff --git a/docs/PLUGIN_DEV.md b/docs/PLUGIN_DEV.md index 642e4fc..4899c19 100644 --- a/docs/PLUGIN_DEV.md +++ b/docs/PLUGIN_DEV.md @@ -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 type Plugin interface { @@ -18,8 +27,8 @@ type Plugin interface { | 方式 | 适用场景 | 复杂度 | |------|---------|--------| +| **动态 .so 插件(推荐)** | 独立分发的第三方插件 | 中等,使用 [SDK 仓库](https://gitcode.com/JianFeeeee/homeagent-sdk) 脚手架生成 | | **内置插件** | 随 HomeAgent 一起发布 | 简单,需合入主仓库 | -| **动态 .so 插件** | 独立分发的第三方插件 | 中等,需编译为 .so | | **Lua 脚本插件** | 轻量快速原型 | 简单(预留功能) | --- @@ -170,41 +179,37 @@ func (p *Plugin) Stop() error { ### PluginSDK 核心 API -#### 📤 IO — 输入输出 +#### IO — 输入输出 ```go -// 向排队通道投递输入(按序处理) +// 排队投递(按序处理) sdk.InjectInput(source, channel string, payload map[string]interface{}) -// 向中断通道投递输入(可打断当前 LLM 处理) +// 中断投递(可打断当前 LLM 处理) sdk.InjectInterrupt(source, channel string, payload map[string]interface{}) -// 快捷方式:投递文本到排队通道 +// 快捷方式:text → Input sdk.InjectText(source, channel, text string) - -// 快捷方式:投递文本到中断通道 sdk.InjectInterruptText(source, channel, text string) -// 同步请求-响应:发送文本并等待回复(CLI 插件使用) +// 同步请求-响应(CLI 插件使用) sdk.InjectTextSync(source, channel, text string) *OutputEvent -// 注册一个输出通道(LLM 可通过 output_send 工具选择发送到此通道) +// 注册/管理输出通道(LLM 通过 output_send 选择发送到哪个通道) sdk.RegisterChannel(name string, dev Device) error sdk.UnregisterChannel(name string) sdk.ListChannels() []ChannelInfo ``` -#### 🛠️ 工具 — 让 LLM 可调用你的能力 +#### 工具 — 让 LLM 可调用你的能力 ```go sdk.RegisterTool(name string, def ToolDef, handler ToolHandler) error ``` -- `name`: 工具名称(LLM 通过此名称调用) -- `def`: 工具定义(描述 + 参数 JSON Schema) -- `handler`: 调用时执行的函数 - -工具定义示例: +- `name`: LLM 通过此名称调用 +- `def`: JSON Schema 描述+参数 +- `handler`: 执行函数 ```go sdk.RegisterTool("weather_query", sdk.ToolDef{ @@ -222,7 +227,6 @@ sdk.RegisterTool("weather_query", sdk.ToolDef{ }, }, func(args map[string]interface{}) (interface{}, error) { city, _ := args["city"].(string) - // 查询天气并返回 return map[string]interface{}{ "city": city, "temp": 25, @@ -231,50 +235,46 @@ sdk.RegisterTool("weather_query", sdk.ToolDef{ }) ``` -#### 🔌 阶段钩子 — 干预消息处理流 +#### 阶段钩子 — 干预消息处理流 -7 个阶段, 按执行顺序: +7 个阶段: | 阶段 | 时机 | 用途 | |------|------|------| -| `on_input` | 消息刚到达 Agent | 黑名单、限流、短路回复 | -| `pre_action` | 即将调用 LLM | 注入额外上下文 | -| `post_action` | LLM 返回结果 | 修改 LLM 输出 | +| `on_input` | 消息刚到达 Agent | 黑名单、限流、短路 | +| `pre_action` | 即将调用 LLM | 注入上下文 | +| `post_action` | LLM 返回结果 | 修改输出/工具列表 | | `before_toolcall` | 工具调用前 | 审计、拒绝、改参 | | `after_toolcall` | 工具执行后 | 脱敏、改写结果 | -| `before_output` | 输出前 | 调整格式 | -| `after_output` | 输出后 | 统计、记录 | +| `before_output` | 输出前 | 格式适配 | +| `after_output` | 输出后 | 统计日志 | ```go sdk.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error { - input := ctx.RawMessage - // 检查是否是黑名单用户 if ctx.UserID == "blocked_user" { resp := "你已被限制使用" - ctx.Response = &resp // 设置 Response 会短路后续阶段 + ctx.Response = &resp // 短路后续阶段 return nil } return nil }) ``` -#### 📡 事件 — 订阅/发布系统事件 +#### 事件 — 订阅/发布系统事件 ```go -// 订阅事件 unsub := sdk.Subscribe(events.EventType("tool_call"), func(evt *events.Event) { log.Printf("工具被调用: %v", evt.Payload) }) -defer unsub() // 插件 Stop 时取消订阅 +defer unsub() -// 发布事件 sdk.Publish(&events.Event{ Type: "my_event", Payload: map[string]interface{}{"key": "value"}, }) ``` -#### 🧠 能力访问 +#### 能力访问 ```go // 记忆 @@ -288,7 +288,7 @@ sdk.Knowledge().Search(query string) ([]string, error) sdk.LLM().ListSources() []SourceInfo sdk.LLM().SetSource(name string) error -// 配置(插件自身的配置表 config_) +// 配置(插件自身的 config_ 表) sdk.Settings().Get(key string) (interface{}, error) sdk.Settings().Set(key string, value interface{}) error sdk.Settings().List(prefix string) ([]string, error) @@ -296,20 +296,15 @@ sdk.Settings().List(prefix string) ([]string, error) ### 读取插件配置 -插件有自己的配置表 `config_<插件名>`,例如 `config_mcp`: +每插件独立 SQLite 表 `config_`: ```go -// 在 Start() 中 val, err := s.Settings().Get("api_key") if err != nil { // 未配置 } -``` -用户通过 WebUI 或 CLI 设置: - -```go -// 读取其他插件的配置 +// 读取其他插件配置 s.Settings().GetPlugin("other_plugin", "some_key") // 读取核心配置 @@ -345,14 +340,28 @@ pluginReg.Load(plgDir) // 之后调用 ## 四、动态 .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 -// myplugin/plugin.go package main 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) { @@ -371,13 +380,23 @@ func (p *myPlugin) Start(s *sdk.PluginSDK) error { func (p *myPlugin) Stop() error { return nil } ``` -编译: +### 编译 + ```bash -go build -buildmode=plugin -o plugin.so ./myplugin/ +cd +go build -buildmode=plugin -o plugins/myplugin/plugin.so plugins/myplugin/ +``` + +或使用项目中的 Makefile: + +```bash +cd plugins/myplugin && make ``` ### 部署 +将插件目录(含 `plugin.json` + `plugin.so`)放入内核配置的插件目录: + ``` /plugins/myplugin/ plugin.json — {"name": "myplugin", "version": "1.0", "description": "..."} @@ -386,21 +405,39 @@ go build -buildmode=plugin -o plugin.so ./myplugin/ 内核扫描时会自动发现并加载。无需修改 `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() 中阻塞 -2. **Stop() 清理资源** — 关闭网络连接、停止 goroutine、取消订阅 -3. **工具 name 唯一** — 工具名不能与其他插件冲突,建议用插件名前缀 -4. **错误处理** — 工具 handler 返回 `error` 时,LLM 会收到错误信息并可能重试 -5. **中断 vs 排队** — 需要打断当前 LLM 处理的用 `InjectInterruptText`,普通的用 `InjectText` -6. **配置优先** — 不要硬编码配置,用 `Settings().Get/Set` 读写插件配置 +1. `Start()` 非阻塞 — goroutine 启动长任务,不要阻塞 Start +2. `Stop()` 清理资源 — 关连接、停 goroutine、取消订阅 +3. 工具名唯一 — 建议插件名前缀避免冲突 +4. handler 返回 `error` 时 LLM 会收到并可能重试 +5. 打断用 `InjectInterruptText`,普通投递用 `InjectText` +6. 配置用 `Settings().Get/Set`,不要硬编码 --- ## 六、现有插件参考 +### 内置插件 + | 插件 | 位置 | 特点 | |------|------|------| | Timer | `internal/plugins/timer/` | 最简单的完整示例,注册一个工具 + 中断反馈 | @@ -409,7 +446,15 @@ go build -buildmode=plugin -o plugin.so ./myplugin/ | WebUI | `internal/plugins/webui/` | HTTP 服务 + 依赖注入(Configure 模式) | | 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)。* *了解技术架构?查看 [ARCHITECTURE.md](ARCHITECTURE.md)。* +*SDK 仓库与开发工具?查看 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk)。* diff --git a/internal/agent/api/provider.go b/internal/agent/api/provider.go index 7400484..41719e8 100644 --- a/internal/agent/api/provider.go +++ b/internal/agent/api/provider.go @@ -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) 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) @@ -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) { - if req.Model == "" { - req.Model = p.cfg.Model - } + req.Model = p.cfg.Model req.Stream = true rawReq, _ := json.Marshal(req) @@ -585,6 +581,7 @@ func (s *SSEScanner) Text() string { return s.pending } type ProviderManager struct { mu sync.RWMutex providers map[string]Provider + order []string default_ string } @@ -598,6 +595,7 @@ func (m *ProviderManager) Register(name string, p Provider) { m.mu.Lock() defer m.mu.Unlock() m.providers[name] = p + m.order = append(m.order, name) if m.default_ == "" { m.default_ = name } @@ -647,13 +645,29 @@ func (m *ProviderManager) QuickChat(ctx context.Context, prompt string) (*Comple func (m *ProviderManager) List() []string { m.mu.RLock() defer m.mu.RUnlock() - var names []string - for n := range m.providers { - names = append(names, n) - } + names := make([]string, len(m.order)) + copy(names, m.order) 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 { ID string `json:"id"` Type string `json:"type"` diff --git a/internal/agent/core/agent.go b/internal/agent/core/agent.go index 35f8a3b..a4f31d3 100644 --- a/internal/agent/core/agent.go +++ b/internal/agent/core/agent.go @@ -42,7 +42,6 @@ type Agent struct { systemPrompt string ctx context.Context cancel context.CancelFunc - maxTurns int // 文档记忆(第二层) docStore *document.Store @@ -114,7 +113,6 @@ type AgentConfig struct { Indexer *memory.Indexer Skills *skill.Manager Tracker *tracker.Tracker - MaxToolTurns int DocStore *document.Store Knowledge *knowledge.Store @@ -135,9 +133,6 @@ type AgentConfig struct { func New(cfg AgentConfig) *Agent { ctx, cancel := context.WithCancel(context.Background()) - if cfg.MaxToolTurns <= 0 { - cfg.MaxToolTurns = 10 - } if cfg.DistillInterval <= 0 { cfg.DistillInterval = 30 * time.Minute } @@ -158,7 +153,6 @@ func New(cfg AgentConfig) *Agent { systemPrompt: cfg.SystemPrompt, ctx: ctx, cancel: cancel, - maxTurns: cfg.MaxToolTurns, docStore: cfg.DocStore, knowledge: cfg.Knowledge, 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 调用前检查拦截通道 === if text := a.drainInterrupt(); text != "" { msgs = append(msgs, agentAPI.Message{ @@ -600,20 +594,49 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri } // 可取消的 LLM 调用:interceptLoop 通过 cancelLLM 打断进行中的请求 - reqCtx, reqCancel := context.WithCancel(a.ctx) - a.llmMu.Lock() - a.cancelLLM = reqCancel - a.llmMu.Unlock() + // 多 LLM 源顺位降级:当当前 provider 失败时,按注册顺序依次尝试 + var providers []agentAPI.Provider + if a.providerManager != nil { + 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() - a.cancelLLM = nil - a.llmMu.Unlock() - reqCancel() + fCtx, fCancel := context.WithCancel(a.ctx) + a.llmMu.Lock() + a.cancelLLM = fCancel + a.llmMu.Unlock() - if err != nil { - return "", toolsUsed, fmt.Errorf("provider: %w", err) + resp, llmErr = fbProvider.Chat(fCtx, req) + + 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 返回,插件可审查/修改 === @@ -678,10 +701,8 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri "result": result, "status": "ok", }) - } } - - return "", toolsUsed, fmt.Errorf("tool execution exceeded %d turns", a.maxTurns) +} } func convertToolCalls(tcs []agentAPI.ToolCall) []sdk.ToolCall { @@ -767,6 +788,8 @@ func (a *Agent) executeToolCall(tc agentAPI.ToolCall) string { if a.stageHost != nil { if result, err := a.stageHost.ExecuteTool(tc.Name, tc.Arguments); err == nil { 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 } +// 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{} { var tools []interface{} @@ -1252,7 +1299,7 @@ func (a *Agent) buildToolDefs() []interface{} { "function": map[string]interface{}{ "name": td.Name, "description": td.Description, - "parameters": td.Parameters, + "parameters": cleanParams(td.Parameters), }, }) } @@ -1266,7 +1313,7 @@ func (a *Agent) buildToolDefs() []interface{} { "function": map[string]interface{}{ "name": td.Name, "description": td.Description, - "parameters": td.Parameters, + "parameters": cleanParams(td.Parameters), }, }) } diff --git a/internal/config/registry.go b/internal/config/registry.go index 6f954b1..ca49e45 100644 --- a/internal/config/registry.go +++ b/internal/config/registry.go @@ -275,6 +275,7 @@ func (r *ConfigRegistry) seedDBValues(dataDir string) { set("core.agent.max_tool_turns", "10") set("core.agent.max_context_size", "30") set("core.agent.distill_interval", "30m") + set("core.agent.workdir", "") set("core.input_processing.image.fallback_provider", "") 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_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.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_model", Default: "", Type: "string", DisplayName: "图片回退模型", Description: "图片回退提供商使用的模型名", Category: "input"}) diff --git a/internal/plugin/dynamic.go b/internal/plugin/dynamic.go index 919702c..dc3931f 100644 --- a/internal/plugin/dynamic.go +++ b/internal/plugin/dynamic.go @@ -1,6 +1,8 @@ package plugin import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "os" @@ -51,9 +53,23 @@ func tryLoadSO(dir, name string, config map[string]interface{}) (sdk.Plugin, err return nil, nil } - p, err := plugin.Open(soPath) + // 复制到临时路径以绕过 Go plugin.Open 的路径缓存 + data, err := os.ReadFile(soPath) 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") diff --git a/internal/plugins/all.go b/internal/plugins/all.go index 32ef7dc..db1b60d 100644 --- a/internal/plugins/all.go +++ b/internal/plugins/all.go @@ -4,6 +4,7 @@ import ( _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/agentcli" _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cli" _ "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/mcp" _ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/openclaw" diff --git a/internal/plugins/files/plugin.go b/internal/plugins/files/plugin.go new file mode 100644 index 0000000..68cb80c --- /dev/null +++ b/internal/plugins/files/plugin.go @@ -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 +} diff --git a/knowledge/homeagent_architecture/content.md b/knowledge/homeagent_architecture/content.md new file mode 100644 index 0000000..c86d5bc --- /dev/null +++ b/knowledge/homeagent_architecture/content.md @@ -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 toolchain,CGO_ENABLED=1(overlayfs 依赖) +- 插件:Go -buildmode=plugin +- LLM 适配:Lua 胶水层,8 个适配器 +- 搜索:内置 TF-IDF 向量化器 \ No newline at end of file