feat: NoMemory/Cleaner memory system + doc update

- _sdk_local/ removed (moved to standalone sdk repo)
- internal/agent/core: NoMemory/Cleaner data-flow breakpoints
- internal/memory: clean_text, document store refactor
- internal/plugin/registry.go: plugin API alignment
- docs: PLUGIN_DEV.md, ARCHITECTURE.md NoMemory/Cleaner docs
- plan.md, review.md: status update
This commit is contained in:
JianFeeeee
2026-07-25 11:17:31 +08:00
parent a51124aa92
commit b31db0f88e
36 changed files with 639 additions and 2488 deletions

View File

@ -1,205 +0,0 @@
# HomeAgent SDK
HomeAgent 插件开发 SDK,用于构建与 HomeAgent 平台交互的智能插件。
## SDK API 接口
### Plugin 接口
插件需实现 `Plugin` 接口:
```go
type Plugin interface {
Name() string
Start(sdk *PluginSDK) error
Stop() error
}
```
### PluginSDK 方法
通过 `Start(sdk *PluginSDK)` 注入的 SDK 实例提供以下方法:
| 分类 | 方法 | 说明 |
|------|------|------|
| 阶段钩子 | `RegisterStage(stage, handler, scope...)` | 注册阶段回调,scope 可选:`StageScopeGlobal`(全局,默认)或 `StageScopeOwnTools`(仅自己工具) |
| 输出通道 | `RegisterOutputChannel(name, caps, desc, handler)` | 注册输出通道,caps 为能力位掩码 |
| 工具注册 | `RegisterTool(name, def, handler)` | 注册工具供 LLM 调用 |
| 插件 API | `RegisterPluginAPI(name)` | 注册插件 API 供其他插件访问 |
| 图记忆 | `Memory()` | 访问图记忆 API(实体-关系存储) |
| 文本记忆 | `TextMemory()` | 访问文本记忆 API(时序事件) |
| 文档记忆 | `DocMemory()` | 访问文档记忆 API(向量存储) |
| 社交图谱 | `Social()` | 访问社交图谱 API(外部插件只读) |
| 知识库 | `Knowledge()` | 访问知识库 API |
| LLM | `LLM()` | 访问 LLM 提供商管理 API |
| 设置 | `Settings()` | 访问设置 API |
| 事件 | `Events()` | 访问事件订阅器(外部插件仅订阅) |
| 注入 | `InjectText(source, channel, text)` / `InjectInterruptText(source, channel, text)` / `InjectTextNoMemory(source, channel, text)` | 向管道注入文本 |
| 自动重启 | `SetAutoRestart(enabled)` / `AutoRestart()` | 控制崩溃自动重启 |
### 阶段钩子
```go
// 全局监听所有插件的阶段事件
sdk.RegisterStage(StagePreAction, func(ctx *StageContext) error { return nil })
// 仅监听自己注册的工具的 before_toolcall / after_toolcall
sdk.RegisterStage(StageBeforeToolcall, myHandler, StageScopeOwnTools)
```
### 输出通道
```go
sdk.RegisterOutputChannel("my-channel", CapText|CapFile, "通道描述", handler)
```
handler 接收三个参数:
- `payload` (string) — 消息载荷。`type=text` 时直接填文字,`type=file/image` 时填 URL
- `meta` (string) — 可选的 JSON 路由元数据(如 `{"group_id":123,"user_id":456}`)
- `type` (string) — 载荷类型,枚举值见下
能力标志位:
| 标志 | 值 | 说明 |
|------|----|------|
| `CapText` | 1 | 纯文本输出 |
| `CapFile` | 2 | 文件输出 |
| `CapImage` | 4 | 图片输出 |
| `CapAudio` | 8 | 音频输出 |
| `CapStructured` | 16 | 结构化数据输出 |
type 枚举值:
| 值 | 说明 |
|----|------|
| `text` | 纯文本 |
| `voice` / `audio` | 语音 |
| `image` | 图片 |
| `file` | 文件 |
### IOInjector 通道路由
| 方法 | 说明 |
|------|------|
| `InjectText(source, channel, text)` | 注入文本,记入内存,路由到指定通道 |
| `InjectInterruptText(source, channel, text)` | 注入中断文本,打断当前处理,路由到指定通道 |
| `InjectTextNoMemory(source, channel, text)` | 注入文本,不记入内存,路由到指定通道 |
`source` 标识来源,`channel` 指定目标输出通道。
### Triple 扩展字段
Triple 数据结构新增字段:
- `Confidence` — 置信度(0.0~1.0)
- `SubjectType` — 主体类型
- `ObjectType` — 客体类型
### New 构造函数
`New()` 由内核在加载插件时调用,插件开发者无需手动构造 PluginSDK:
```go
func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageRegistrar, regAPI APIRegistrar, regOutput OutputChannelRegistrar) *PluginSDK
```
插件开发者只需实现 `Plugin` 接口并导出 `NewPlugin()` 入口函数。
## plugindev 工具链
`plugindev` 提供插件开发全流程支持:
| 命令 | 说明 |
|------|------|
| `plugindev init` | 初始化插件项目(生成 plg.json、入口模板) |
| `plugindev build` | 构建插件,输出 .hmap 包 |
| `plugindev clean` | 清理构建产物 |
| `plugindev debug` | 本地调试模式运行插件 |
支持 **Go** 和 **Lua** 两种插件语言。
### plg.json 清单格式
```json
{
"name": "my-plugin",
"version": "1.0.0",
"lang": "go",
"entry": "main.go",
"description": "插件描述",
"channels": ["my-channel"],
"dependencies": {}
}
```
### .hmap 包格式
`.hmap` 为 ZIP 归档,包含:
- `plugin.json` — 插件元数据
- `plugin.so` — Go 编译产物(Linux)
- `plugin.dll` — Go 编译产物(Windows)
- `main.lua` — Lua 插件入口(Lua 插件时)
## 插件生命周期
### 启动与停止
- `Start(sdk *PluginSDK) error` — 插件启动,接收 SDK 实例
- `Stop() error` — 插件停止,释放资源
### 自动重启
```go
sdk.SetAutoRestart(true)
// 查询状态
enabled := sdk.AutoRestart()
```
插件崩溃时平台自动拉起,保障服务可用性。
## 受限 SDK vs 完整 SDK
外部插件(第三方分发)使用**受限 SDK**,仅暴露安全子集:
| 受限 API | 允许操作 |
|----------|----------|
| `SocialAPI` | 只读:`GetPerson`、`GetTrait`、`GetRelations`、`GetNetwork`、`ListPersons` |
| `EventSubscriber` | 仅订阅:`Subscribe`(无 `Publish`) |
内部插件(平台内置)拥有完整 SDK 访问权限,包括 SocialAPI 写操作和 EventPublisher。
## 示例插件
| 插件 | 说明 |
|------|------|
| a2a | Agent-to-Agent 协议通信 |
| bili | Bilibili 视频下载 |
| browser | 网络搜索、网页抓取、浏览器渲染(合并自 web/webfetch) |
| editdoc | 文档编辑 |
| files | 文件管理 |
| memo | 备忘录/记忆 |
| ocr | 光学字符识别 |
| qq | QQ 消息集成 |
| sanitizer | 内容清洗/安全过滤 |
## 构建与安装
### 构建
```bash
plugindev build
```
输出 `.hmap` 包到项目目录。
### 安装
通过 pluginmgr HTTP API 安装:
```bash
curl -X POST http://<host>:<port>/api/plugins/install \
-F "package=@my-plugin.hmap"
```
或手动将 `.hmap` 放入插件目录后重启平台。

View File

@ -1,206 +0,0 @@
# HomeAgent SDK
Plugin development SDK for building intelligent plugins that interact with the HomeAgent platform.
## SDK API Surface
### Plugin Interface
Plugins implement the `Plugin` interface:
```go
type Plugin interface {
Name() string
Start(sdk *PluginSDK) error
Stop() error
}
```
### PluginSDK Methods
The SDK instance injected via `Start(sdk *PluginSDK)` provides:
| Category | Method | Description |
|----------|--------|-------------|
| Stage Hooks | `RegisterStage(stage, handler, scope...)` | Register stage callback; scope: `StageScopeGlobal` (all, default) or `StageScopeOwnTools` (own tools only) |
| Output Channel | `RegisterOutputChannel(name, caps, desc, handler)` | Register output channel with capability bitmask |
| Tool Registration | `RegisterTool(name, def, handler)` | Register a tool for LLM invocation |
| Plugin API | `RegisterPluginAPI(name)` | Register plugin API for inter-plugin access |
| Graph Memory | `Memory()` | Access graph memory API (entity-relation store) |
| Text Memory | `TextMemory()` | Access text memory API (chronological events) |
| Doc Memory | `DocMemory()` | Access document memory API (vector store) |
| Social Graph | `Social()` | Access social graph API (read-only for external plugins) |
| Knowledge | `Knowledge()` | Access knowledge base API |
| LLM | `LLM()` | Access LLM provider manager API |
| Settings | `Settings()` | Access settings API |
| Events | `Events()` | Access event subscriber (subscribe-only for external plugins) |
| Inject | `InjectText(source, channel, text)` / `InjectInterruptText(source, channel, text)` / `InjectTextNoMemory(source, channel, text)` | Inject text into the agent pipeline |
| Auto-Restart | `SetAutoRestart(enabled)` / `AutoRestart()` | Control automatic restart on crash |
### Stage Hooks
```go
// Listen to all stage events globally
sdk.RegisterStage(StagePreAction, func(ctx *StageContext) error { return nil })
// Listen only to this plugin's own tool calls (before_toolcall / after_toolcall only)
sdk.RegisterStage(StageBeforeToolcall, myHandler, StageScopeOwnTools)
```
### Output Channels
```go
sdk.RegisterOutputChannel("my-channel", CapText|CapFile, "channel description", handler)
```
The handler receives three arguments:
- `payload` (string) — message content. For `type=text` it's plain text, for `type=file/image` it's a URL
- `meta` (string) — optional JSON routing metadata (e.g. `{"group_id":123,"user_id":456}`)
- `type` (string) — content type enum (see below)
Capability flags:
| Flag | Value | Description |
|------|-------|-------------|
| `CapText` | 1 | Plain text output |
| `CapFile` | 2 | File output |
| `CapImage` | 4 | Image output |
| `CapAudio` | 8 | Audio output |
| `CapStructured` | 16 | Structured data output |
Type enum values:
| Value | Description |
|-------|-------------|
| `text` | plain text |
| `voice` / `audio` | audio/voice |
| `image` | image |
| `file` | file |
### IOInjector Channel Routing
| Method | Description |
|--------|-------------|
| `InjectText(source, channel, text)` | Inject text, record to memory, route to specified channel |
| `InjectInterruptText(source, channel, text)` | Inject interrupt text, interrupt current processing, route to specified channel |
| `InjectTextNoMemory(source, channel, text)` | Inject text without memory recording, route to specified channel |
`source` identifies the origin, `channel` specifies the target output channel.
### Triple Extended Fields
The Triple data structure includes additional fields:
- `Confidence` — confidence score (0.0–1.0)
- `SubjectType` — subject type
- `ObjectType` — object type
### New Constructor
`New()` is called by the kernel when loading a plugin. Plugin developers do not need to construct PluginSDK manually:
```go
func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageRegistrar, regAPI APIRegistrar, regOutput OutputChannelRegistrar) *PluginSDK
```
Plugin developers only need to implement the `Plugin` interface and export a `NewPlugin()` entry function.
## plugindev Toolchain
`plugindev` provides full development workflow support:
| Command | Description |
|---------|-------------|
| `plugindev init` | Initialize plugin project (generates plg.json, entry template) |
| `plugindev build` | Build plugin, output .hmap package |
| `plugindev clean` | Clean build artifacts |
| `plugindev debug` | Run plugin in local debug mode |
Supports both **Go** and **Lua** plugin languages.
### plg.json Manifest Format
```json
{
"name": "my-plugin",
"version": "1.0.0",
"lang": "go",
"entry": "main.go",
"description": "Plugin description",
"channels": ["my-channel"],
"dependencies": {}
}
```
### .hmap Package Format
`.hmap` is a ZIP archive containing:
- `plugin.json` — plugin metadata
- `plugin.so` — Go compiled artifact (Linux)
- `plugin.dll` — Go compiled artifact (Windows)
- `main.lua` — Lua plugin entry (for Lua plugins)
## Plugin Lifecycle
### Start & Stop
- `Start(sdk *PluginSDK) error` — Plugin startup, receives SDK instance
- `Stop() error` — Plugin shutdown, release resources
### Auto-Restart
```go
sdk.SetAutoRestart(true)
// Query state
enabled := sdk.AutoRestart()
```
The platform automatically restarts the plugin on crash, ensuring service availability.
## Restricted SDK vs Full SDK
External plugins (third-party distribution) use a **restricted SDK** that only exposes a safe subset:
| Restricted API | Allowed Operations |
|----------------|-------------------|
| `SocialAPI` | Read-only: `GetPerson`, `GetTrait`, `GetRelations`, `GetNetwork`, `ListPersons` |
| `EventSubscriber` | Subscribe-only: `Subscribe` (no `Publish`) |
Internal plugins (platform built-in) have full SDK access including SocialAPI write operations and EventPublisher.
## Example Plugins
| Plugin | Description |
|--------|-------------|
| a2a | Agent-to-Agent protocol communication |
| bili | Bilibili data fetching |
| editdoc | Document editing |
| files | File management |
| memo | Memo/notes |
| ocr | Optical character recognition |
| qq | QQ messaging integration |
| sanitizer | Content sanitization/safety filtering |
| web | Web browsing and interaction |
| webfetch | Web content fetching |
## Building & Installing
### Build
```bash
plugindev build
```
Outputs a `.hmap` package to the project directory.
### Install
Via pluginmgr HTTP API:
```bash
curl -X POST http://<host>:<port>/api/plugins/install \
-F "package=@my-plugin.hmap"
```
Or manually place the `.hmap` in the plugin directory and restart the platform.

View File

@ -1,11 +0,0 @@
{
"name": "files",
"name_zh": "文件系统",
"name_en": "File System",
"version": "1.0.0",
"description": "文件系统操作工具集(读取/写入/编辑/列表),提供沙箱化文件访问,支持配置工作目录。",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": ["files", "filesystem"],
"targets": "linux/amd64"
}

View File

@ -1,483 +0,0 @@
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
type Plugin struct {
name string
sdk *sdk.PluginSDK
mu sync.RWMutex
filesDir string
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.sdk = s
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "plugin.files.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
os.MkdirAll(p.filesDir, 0755)
tp := p.name + "_"
s.RegisterTool(tp+"read", sdk.ToolDef{
Name: tp + "read",
Description: fmt.Sprintf("Read file contents within the sandbox directory (%s). Supports offset/limit for large files.", p.filesDir),
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "File path relative to sandbox or absolute"},
"offset": map[string]interface{}{"type": "integer", "description": "Starting line number (1-indexed, optional)"},
"limit": map[string]interface{}{"type": "integer", "description": "Max lines to return (optional)"},
},
"required": []string{"path"},
},
}, p.handleRead)
s.RegisterTool(tp+"write", sdk.ToolDef{
Name: tp + "write",
Description: fmt.Sprintf("Write content to a file. Creates parent directories automatically. Sandbox: %s", p.filesDir),
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "File path"},
"content": map[string]interface{}{"type": "string", "description": "Content to write"},
"mode": map[string]interface{}{"type": "string", "description": "Write mode: overwrite (default) | append | insert | create"},
"line": map[string]interface{}{"type": "integer", "description": "Line number for insert mode (1-indexed)"},
},
"required": []string{"path", "content"},
},
}, p.handleWrite)
s.RegisterTool(tp+"edit", sdk.ToolDef{
Name: tp + "edit",
Description: fmt.Sprintf("Apply exact string replacements to a file within the sandbox (%s). All edits are matched against the original file content.", p.filesDir),
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "File path relative to sandbox or absolute"},
"edits": map[string]interface{}{
"type": "array",
"description": "One or more targeted replacements. Each old must match exactly once in the original file. Do not include overlapping edits.",
"items": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"old": map[string]interface{}{"type": "string", "description": "Exact text to find (must be unique)"},
"new": map[string]interface{}{"type": "string", "description": "Replacement text"},
},
"required": []string{"old", "new"},
},
},
},
"required": []string{"path", "edits"},
},
}, p.handleEdit)
s.RegisterTool(tp+"ls", sdk.ToolDef{
Name: tp + "ls",
Description: fmt.Sprintf("List directory contents within the sandbox (%s). Directories are marked with / suffix.", p.filesDir),
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "Directory path (optional, defaults to sandbox root)"},
"limit": map[string]interface{}{"type": "integer", "description": "Max entries (optional, default 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
}
// resolvePath resolves user-provided path to an absolute path within filesDir.
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
}
// handleRead implements the read tool.
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")
truncated := false
if limit < totalLines-offset {
truncated = true
}
var sb strings.Builder
sb.WriteString(output)
if truncated {
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 || end < totalLines {
sb.WriteString(fmt.Sprintf("\n\n[%d lines total]", totalLines))
}
return map[string]interface{}{
"content": sb.String(),
}, nil
}
// handleWrite implements the write tool.
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
}
}
// handleEdit implements the edit tool.
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
}
// handleLs implements the ls tool.
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 += "/"
}
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
}
// errorResult returns a standardized error result.
func errorResult(msg string) map[string]interface{} {
return map[string]interface{}{
"isError": true,
"content": msg,
}
}
// getSetting reads a setting with generic type assertion.
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
}
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name}, nil
}

View File

@ -1,3 +0,0 @@
module gitcode.com/JianFeeeee/homeagent-sdk
go 1.25.0

View File

@ -1,23 +0,0 @@
// Package meta 收集 HomeAgent SDK 的全部元数据。
// 版本号应与核心 meta.Version 保持一致。
package meta
var (
// Version 是 HomeAgent SDK 版本号。
// 通过 `-ldflags="-X gitcode.com/JianFeeeee/homeagent-sdk/meta.Version=vX.Y.Z"` 注入。
Version = "0.7.1"
// Commit 是构建时的 Git commit hash。
Commit = "unknown"
// BuildTime 是构建时间。
BuildTime = "unknown"
// SDKName 是 SDK 名称。
SDKName = "HomeAgent SDK"
)
// FullVersion 返回完整的版本字符串。
func FullVersion() string {
return SDKName + " v" + Version + " (" + Commit + ")"
}

View File

@ -1,14 +0,0 @@
package sdk
// KnowledgeAPI provides access to the knowledge store.
type KnowledgeAPI interface {
Search(query string, topK int) ([]*Knowledge, error)
Add(name, content string) error
List() ([]string, error)
}
// Knowledge represents a knowledge entry.
type Knowledge struct {
Name string `json:"name"`
Content string `json:"content"`
}

View File

@ -1,8 +0,0 @@
package sdk
// LLMAPI provides access to the LLM provider manager.
type LLMAPI interface {
ListSources() []string
SetSource(name string) error
CurrentSource() string
}

View File

@ -1,87 +0,0 @@
package sdk
// MemoryAPI provides access to the graph memory (entity-relation store).
type MemoryAPI interface {
Recall(query []string, depth int) ([]Entity, []Relation, error)
Commit(triples []Triple) error
Introspect() (map[string]interface{}, error)
MergeEntities(source, target string) (int, error)
Purge(criteria map[string]string, mode string) (int, error)
}
// Entity represents a named entity in the knowledge graph.
type Entity struct {
Name string `json:"name"`
Type string `json:"type"`
MentionCount int `json:"mention_count"`
}
// Relation represents a relationship between two entities.
type Relation struct {
SourceName string `json:"source_name"`
TargetName string `json:"target_name"`
RelationType string `json:"relation_type"`
Confidence float64 `json:"confidence,omitempty"`
}
// Triple represents a subject-relation-object triple for the knowledge graph.
type Triple struct {
Subject string `json:"subject"`
Relation string `json:"relation"`
Object string `json:"object"`
Confidence float64 `json:"confidence,omitempty"`
SubjectType string `json:"subject_type,omitempty"`
ObjectType string `json:"object_type,omitempty"`
}
// TextMemoryAPI provides access to chronological text event storage.
type TextMemoryAPI interface {
Append(evt TextEvent) error
}
// TextEvent represents a single text memory event.
type TextEvent struct {
Role string `json:"role"`
Content string `json:"content"`
Timestamp int64 `json:"timestamp"`
Channel string `json:"channel,omitempty"`
}
// DocMemoryAPI provides access to the document vector store.
type DocMemoryAPI interface {
Query(text string, topK int) []*Doc
Insert(doc *Doc) error
Remove(id string)
Stats() map[string]interface{}
}
// Doc represents a document in the document store.
type Doc struct {
ID string `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
Score float64 `json:"score,omitempty"`
}
// SocialAPI provides read-only access to the social graph (person profiles and relationships).
// External plugins can query person traits and social networks but cannot modify them.
type SocialAPI interface {
GetPerson(name string) (*PersonProfile, error)
GetTrait(name, trait string) (string, bool)
GetRelations(name string) ([]SocialRelation, error)
GetNetwork(name string, depth int) ([]*PersonProfile, error)
ListPersons() ([]string, error)
}
// PersonProfile represents a person's complete profile (traits + social relations).
type PersonProfile struct {
Name string `json:"name"`
Traits map[string]string `json:"traits,omitempty"`
Relations []SocialRelation `json:"relations,omitempty"`
}
// SocialRelation represents a social relationship between two persons.
type SocialRelation struct {
Person string `json:"person"`
Relation string `json:"relation"`
}

View File

@ -1,356 +0,0 @@
package sdk
import (
"sync"
"gitcode.com/JianFeeeee/homeagent-sdk/meta"
)
// SDKVersion 是对外暴露的 SDK 版本号。
var SDKVersion = meta.Version
// Plugin is the interface every plugin must implement.
type Plugin interface {
Name() string
Start(sdk *PluginSDK) error
Stop() error
}
// ToolHandler is a function that handles a tool call.
type ToolHandler func(args map[string]interface{}) (interface{}, error)
// StageHandler is a function that handles a pipeline stage event.
type StageHandler func(ctx *StageContext) error
// Stage represents a point in the message processing pipeline.
type Stage string
const (
StageOnInput Stage = "on_input"
StagePreAction Stage = "pre_action"
StagePostAction Stage = "post_action"
StageBeforeToolcall Stage = "before_toolcall"
StageAfterToolcall Stage = "after_toolcall"
StageBeforeOutput Stage = "before_output"
StageAfterOutput Stage = "after_output"
)
// StageContext provides context for stage handlers.
type StageContext struct {
mu sync.RWMutex
RawMessage string
UserID string
GroupID string
ContextMsgs []map[string]interface{}
LLMText string
ReasoningContent string
TokenUsage map[string]int
ToolCalls []ToolCall
ToolResults []ToolResult
FinalText string
Response *string
Phase Stage
Memory []MemItem
NoMemory bool
Extra map[string]interface{}
Errors []string // 阶段处理过程中的错误信息
}
func (c *StageContext) RLock() { c.mu.RLock() }
func (c *StageContext) RUnlock() { c.mu.RUnlock() }
func (c *StageContext) Lock() { c.mu.Lock() }
func (c *StageContext) Unlock() { c.mu.Unlock() }
func (c *StageContext) IsResponded() bool { c.mu.RLock(); defer c.mu.RUnlock(); return c.Response != nil }
// MemItem represents a memory item in stage context.
type MemItem struct {
Role string `json:"role"`
Content string `json:"content"`
Score float64 `json:"score"`
}
// ToolCall represents a model's request to call a tool.
type ToolCall struct {
ID string `json:"id"`
Name string `json:"name"`
Plugin string `json:"plugin,omitempty"`
Arguments map[string]interface{} `json:"arguments"`
}
// ToolResult represents the result of a tool call.
type ToolResult struct {
CallID string `json:"call_id"`
Name string `json:"name"`
Plugin string `json:"plugin,omitempty"`
Success bool `json:"success"`
Result interface{} `json:"result"`
}
// ToolDef describes a tool that the plugin exposes.
type ToolDef struct {
Name string `json:"name"`
Plugin string `json:"plugin,omitempty"`
Description string `json:"description"`
Parameters map[string]interface{} `json:"parameters"`
NoMemory bool `json:"no_memory,omitempty"`
}
// IOInjector provides methods for injecting input and interrupts into the agent pipeline.
// All methods accept (source, channel) where channel is the target output channel
// for routing the agent's response.
type IOInjector interface {
InjectInterruptText(source, channel, text string)
InjectText(source, channel, text string)
InjectTextNoMemory(source, channel, text string)
}
// EventType identifies the kind of system event.
type EventType string
const (
EventRawInput EventType = "raw_input"
EventAgentOutput EventType = "agent_output"
EventAgentLLMChain EventType = "agent_llm_chain"
EventToolCall EventType = "tool_call"
EventReasoning EventType = "reasoning"
EventStage EventType = "stage"
EventSystem EventType = "system"
)
// Event represents a system event published by the kernel.
type Event struct {
Type EventType `json:"type"`
Source string `json:"source"`
Payload map[string]interface{} `json:"payload"`
Timestamp int64 `json:"timestamp"`
}
// EventHandler processes a system event.
type EventHandler func(evt *Event)
// EventSubscriber allows plugins to subscribe to kernel events.
// This is a restricted interface: plugins can subscribe but the kernel
// controls which events are delivered.
type EventSubscriber interface {
Subscribe(eventType EventType, handler EventHandler) func()
}
// StageScope controls which events a stage handler receives.
type StageScope int
const (
// StageScopeGlobal receives all stage events (default).
StageScopeGlobal StageScope = 0
// StageScopeOwnTools only receives events for this plugin's own tool calls
// (before_toolcall / after_toolcall only). Other stages degrade to global.
StageScopeOwnTools StageScope = 1
)
// ToolRegistrar registers a tool dynamically.
type ToolRegistrar func(name string, def ToolDef, handler ToolHandler) error
// StageRegistrar registers a stage handler.
type StageRegistrar func(stage Stage, handler StageHandler)
// APIRegistrar registers a plugin API for external access.
type APIRegistrar func(name string) error
// OutputChannelRegistrar registers an output channel that the output_send tool can use.
type OutputChannelRegistrar func(name string, caps int, desc string, handler ToolHandler) error
// Output capability flags
const (
CapText = 1
CapFile = 2
CapImage = 4
CapAudio = 8
CapStructured = 16
)
// PluginSDK is the main API surface provided to plugins at runtime.
// It wraps tool registration, settings, memory, knowledge, LLM, and IO injection.
type PluginSDK struct {
name string
regTool ToolRegistrar
regStage StageRegistrar
regAPI APIRegistrar
regOutput OutputChannelRegistrar
io IOInjector
mem MemoryAPI
textMem TextMemoryAPI
docMem DocMemoryAPI
know KnowledgeAPI
llm LLMAPI
sett SettingsAPI
social SocialAPI
events EventSubscriber
autoRestart bool
textCleaners []func(text string) string
}
// New creates a PluginSDK with the given dependencies.
func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageRegistrar, regAPI APIRegistrar, regOutput OutputChannelRegistrar) *PluginSDK {
return &PluginSDK{
name: name,
sett: sett,
regTool: regTool,
regStage: regStage,
regAPI: regAPI,
regOutput: regOutput,
autoRestart: true,
textCleaners: make([]func(text string) string, 0),
}
}
// PluginName returns the name of the plugin.
func (s *PluginSDK) PluginName() string { return s.name }
// Settings returns the settings API for reading/writing plugin configuration.
func (s *PluginSDK) Settings() SettingsAPI { return s.sett }
// Memory returns the graph memory API (may be nil if not available).
func (s *PluginSDK) Memory() MemoryAPI { return s.mem }
// TextMemory returns the text memory API (may be nil if not available).
func (s *PluginSDK) TextMemory() TextMemoryAPI { return s.textMem }
// DocMemory returns the document memory API (may be nil if not available).
func (s *PluginSDK) DocMemory() DocMemoryAPI { return s.docMem }
// Knowledge returns the knowledge store API (may be nil if not available).
func (s *PluginSDK) Knowledge() KnowledgeAPI { return s.know }
// LLM returns the LLM provider API (may be nil if not available).
func (s *PluginSDK) LLM() LLMAPI { return s.llm }
// Social returns the social graph API (may be nil if not available).
func (s *PluginSDK) Social() SocialAPI { return s.social }
// Events returns the event subscriber for listening to kernel events (may be nil if not available).
func (s *PluginSDK) Events() EventSubscriber { return s.events }
// RegisterTool registers a tool that the LLM can call.
func (s *PluginSDK) RegisterTool(name string, def ToolDef, handler ToolHandler) error {
if def.Plugin == "" {
def.Plugin = s.name
}
if s.regTool != nil {
return s.regTool(name, def, handler)
}
return nil
}
// RegisterStage registers a handler for a pipeline stage.
// scope: StageScopeGlobal (default) — receives all stage events.
// StageScopeOwnTools — only before_toolcall/after_toolcall for this plugin's tools.
func (s *PluginSDK) RegisterStage(stage Stage, handler StageHandler, scope ...StageScope) {
if s.regStage == nil {
return
}
sc := StageScopeGlobal
if len(scope) > 0 {
sc = scope[0]
}
if sc == StageScopeGlobal {
s.regStage(stage, handler)
return
}
// OwnTools scope — only for before_toolcall / after_toolcall
if stage != StageBeforeToolcall && stage != StageAfterToolcall {
s.regStage(stage, handler)
return
}
s.regStage(stage, func(ctx *StageContext) error {
ctx.RLock()
match := false
switch stage {
case StageBeforeToolcall:
match = len(ctx.ToolCalls) > 0 && ctx.ToolCalls[0].Plugin == s.name
case StageAfterToolcall:
match = len(ctx.ToolResults) > 0 && ctx.ToolResults[0].Plugin == s.name
}
ctx.RUnlock()
if !match {
return nil
}
return handler(ctx)
})
}
// RegisterPluginAPI registers this plugin's API for access by other plugins.
func (s *PluginSDK) RegisterPluginAPI(name string) error {
if s.regAPI != nil {
return s.regAPI(name)
}
return nil
}
// RegisterOutputChannel registers an output channel that the output_send tool can route to.
// name: channel name (e.g. "qq", "webui")
// caps: bitmask of supported output capabilities (CapText, CapFile, etc.)
// desc: description of the channel, expected meta format, and type enum
// handler: receives args map with keys: payload (string), type (string), meta (string|optional)
func (s *PluginSDK) RegisterOutputChannel(name string, caps int, desc string, handler ToolHandler) error {
if s.regOutput != nil {
return s.regOutput(name, caps, desc, handler)
}
return nil
}
// SetOutputChannelRegistrar sets the output channel registrar (called by the core at startup).
func (s *PluginSDK) SetOutputChannelRegistrar(r OutputChannelRegistrar) { s.regOutput = r }
// SetIOInjector sets the IO injector (called by the core at startup).
func (s *PluginSDK) SetIOInjector(io IOInjector) { s.io = io }
// SetMemoryAPI sets the memory API (called by the core at startup).
func (s *PluginSDK) SetMemoryAPI(mem MemoryAPI) { s.mem = mem }
func (s *PluginSDK) SetTextMemoryAPI(tm TextMemoryAPI) { s.textMem = tm }
func (s *PluginSDK) SetDocMemoryAPI(dm DocMemoryAPI) { s.docMem = dm }
func (s *PluginSDK) SetKnowledgeAPI(kn KnowledgeAPI) { s.know = kn }
func (s *PluginSDK) SetLLMAPI(llm LLMAPI) { s.llm = llm }
func (s *PluginSDK) SetSocialAPI(social SocialAPI) { s.social = social }
func (s *PluginSDK) SetEventSubscriber(es EventSubscriber) { s.events = es }
// ---- IO Convenience Methods ----
// InjectInterruptText injects a text interrupt that can preempt current LLM processing.
func (s *PluginSDK) InjectInterruptText(source, channel, text string) {
if s.io != nil {
s.io.InjectInterruptText(source, channel, text)
}
}
// InjectText injects a text message into the agent pipeline.
func (s *PluginSDK) InjectText(source, channel, text string) {
if s.io != nil {
s.io.InjectText(source, channel, text)
}
}
// InjectTextNoMemory injects a text message without generating memory.
func (s *PluginSDK) InjectTextNoMemory(source, channel, text string) {
if s.io != nil {
s.io.InjectTextNoMemory(source, channel, text)
}
}
// SetAutoRestart 设置插件是否允许内核自动重启(崩溃后自动重载)。
// 默认 true。如果插件有无法恢复的状态(如外部连接),应设为 false。
func (s *PluginSDK) SetAutoRestart(enabled bool) { s.autoRestart = enabled }
// AutoRestart 返回插件是否允许自动重启。
func (s *PluginSDK) AutoRestart() bool { return s.autoRestart }
// RegisterTextCleaner registers a text cleaning function that is applied to
// all text before it enters memory. Multiple cleaners can be registered and
// are applied in registration order.
func (s *PluginSDK) RegisterTextCleaner(cleaner func(text string) string) {
s.textCleaners = append(s.textCleaners, cleaner)
}
// TextCleaners returns all registered text cleaning functions.
func (s *PluginSDK) TextCleaners() []func(text string) string {
return s.textCleaners
}

View File

@ -1,229 +0,0 @@
package sdk
import (
"testing"
)
func TestRegisterStageGlobalDefault(t *testing.T) {
called := false
regStage := func(stage Stage, handler StageHandler) {
called = true
}
s := &PluginSDK{regStage: regStage, name: "test"}
s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error { return nil })
if !called {
t.Error("global scope: handler not registered")
}
}
func TestRegisterStageGlobalExplicit(t *testing.T) {
called := false
regStage := func(stage Stage, handler StageHandler) {
called = true
}
s := &PluginSDK{regStage: regStage, name: "test"}
s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error { return nil }, StageScopeGlobal)
if !called {
t.Error("global scope: handler not registered")
}
}
func TestRegisterStageOwnToolsMatch(t *testing.T) {
var registered StageHandler
regStage := func(stage Stage, handler StageHandler) {
registered = handler
}
s := &PluginSDK{regStage: regStage, name: "myplugin"}
s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error { return nil }, StageScopeOwnTools)
if registered == nil {
t.Fatal("handler not registered")
}
ctx := &StageContext{}
ctx.ToolCalls = []ToolCall{{Plugin: "myplugin", Name: "my_tool"}}
ctx.ToolResults = nil
err := registered(ctx)
if err != nil {
t.Errorf("expected nil, got %v", err)
}
}
func TestRegisterStageOwnToolsSkipOtherPlugin(t *testing.T) {
var registered StageHandler
regStage := func(stage Stage, handler StageHandler) {
registered = handler
}
s := &PluginSDK{regStage: regStage, name: "myplugin"}
callCount := 0
s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error {
callCount++
return nil
}, StageScopeOwnTools)
if registered == nil {
t.Fatal("handler not registered")
}
ctx := &StageContext{}
ctx.ToolCalls = []ToolCall{{Plugin: "other", Name: "other_tool"}}
err := registered(ctx)
if err != nil {
t.Errorf("expected nil, got %v", err)
}
if callCount != 0 {
t.Error("handler should not be called for other plugin's tool")
}
}
func TestRegisterStageOwnToolsNonToolcallDegrades(t *testing.T) {
regStage := func(stage Stage, handler StageHandler) {
if stage != StagePreAction {
t.Errorf("expected StagePreAction, got %s", stage)
}
}
s := &PluginSDK{regStage: regStage, name: "test"}
s.RegisterStage(StagePreAction, func(ctx *StageContext) error { return nil }, StageScopeOwnTools)
}
func TestRegisterStageOwnToolsStageBeforeToolcallNoToolCalls(t *testing.T) {
var registered StageHandler
regStage := func(stage Stage, handler StageHandler) {
registered = handler
}
s := &PluginSDK{regStage: regStage, name: "myplugin"}
callCount := 0
s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error {
callCount++
return nil
}, StageScopeOwnTools)
if registered == nil {
t.Fatal("handler not registered")
}
ctx := &StageContext{}
err := registered(ctx)
if err != nil {
t.Errorf("expected nil, got %v", err)
}
if callCount != 0 {
t.Error("handler should not be called when ToolCalls is empty")
}
}
func TestRegisterStageOwnToolsStageAfterToolcallMatch(t *testing.T) {
var registered StageHandler
regStage := func(stage Stage, handler StageHandler) {
registered = handler
}
s := &PluginSDK{regStage: regStage, name: "myplugin"}
callCount := 0
s.RegisterStage(StageAfterToolcall, func(ctx *StageContext) error {
callCount++
return nil
}, StageScopeOwnTools)
if registered == nil {
t.Fatal("handler not registered")
}
ctx := &StageContext{}
ctx.ToolResults = []ToolResult{{Plugin: "myplugin", Name: "my_tool"}}
err := registered(ctx)
if err != nil {
t.Errorf("expected nil, got %v", err)
}
if callCount != 1 {
t.Error("handler should be called for own plugin's tool result")
}
}
func TestRegisterStageOwnToolsStageAfterToolcallSkip(t *testing.T) {
var registered StageHandler
regStage := func(stage Stage, handler StageHandler) {
registered = handler
}
s := &PluginSDK{regStage: regStage, name: "myplugin"}
callCount := 0
s.RegisterStage(StageAfterToolcall, func(ctx *StageContext) error {
callCount++
return nil
}, StageScopeOwnTools)
ctx := &StageContext{}
ctx.ToolResults = []ToolResult{{Plugin: "other", Name: "other_tool"}}
err := registered(ctx)
if err != nil {
t.Errorf("expected nil, got %v", err)
}
if callCount != 0 {
t.Error("handler should not be called for other plugin's tool result")
}
}
func TestRegisterStageOwnToolsNilRegStage(t *testing.T) {
s := &PluginSDK{name: "test"}
s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error { return nil }, StageScopeOwnTools)
}
func TestToolDefNoMemory(t *testing.T) {
def := ToolDef{
Name: "test_tool",
NoMemory: true,
}
if !def.NoMemory {
t.Error("NoMemory should be true")
}
def2 := ToolDef{Name: "normal_tool"}
if def2.NoMemory {
t.Error("default NoMemory should be false")
}
}
func TestRegisterTextCleaner(t *testing.T) {
s := &PluginSDK{name: "test"}
c1 := func(text string) string { return text + "_c1" }
c2 := func(text string) string { return text + "_c2" }
s.RegisterTextCleaner(c1)
s.RegisterTextCleaner(c2)
cleaners := s.TextCleaners()
if len(cleaners) != 2 {
t.Fatalf("expected 2 cleaners, got %d", len(cleaners))
}
got := cleaners[0]("hello")
if got != "hello_c1" {
t.Errorf("expected hello_c1, got %s", got)
}
got = cleaners[1]("hello")
if got != "hello_c2" {
t.Errorf("expected hello_c2, got %s", got)
}
}
func TestTextCleanersEmpty(t *testing.T) {
s := New("test", nil, nil, nil, nil, nil)
cleaners := s.TextCleaners()
if cleaners == nil {
t.Error("TextCleaners should return empty slice, not nil")
}
if len(cleaners) != 0 {
t.Errorf("expected 0 cleaners, got %d", len(cleaners))
}
}

View File

@ -1,58 +0,0 @@
package sdk
type SettingsAPI interface {
// Get reads the plugin's own config value (config_<name> table).
Get(key string) (interface{}, error)
// Set writes a config value to the plugin's own config table.
Set(key string, value interface{}) error
// List returns all keys matching the given prefix.
List(prefix string) ([]string, error)
// GetCore reads the core config table.
GetCore(key string) (interface{}, error)
// SetCore writes to the core config table.
SetCore(key string, value interface{}) error
// ListCore lists core config keys matching the prefix.
ListCore(prefix string) ([]string, error)
// GetPlugin reads another plugin's config table.
GetPlugin(plugin, key string) (interface{}, error)
// SetPlugin writes to another plugin's config table.
SetPlugin(plugin, key string, value interface{}) error
// ListPlugin lists another plugin's config keys matching the prefix.
ListPlugin(plugin, prefix string) ([]string, error)
// RegisterDef registers a config definition for UI display.
RegisterDef(def ConfigDef)
// Defs returns config definitions matching the prefix.
Defs(prefix string) []*ConfigDef
// Dump returns all config values.
Dump() map[string]interface{}
// Plugins returns a list of all plugin config namespaces.
Plugins() []string
}
// ConfigDef describes a configuration field for the WebUI.
type ConfigDef struct {
Key string `json:"key"`
Default interface{} `json:"default,omitempty"`
Type string `json:"type"`
DisplayName string `json:"display_name"`
Description string `json:"description,omitempty"`
Category string `json:"category,omitempty"`
Options []string `json:"options,omitempty"`
Min float64 `json:"min,omitempty"`
Max float64 `json:"max,omitempty"`
Step float64 `json:"step,omitempty"`
Required bool `json:"required,omitempty"`
Secret bool `json:"secret,omitempty"`
}

View File

@ -220,6 +220,7 @@ func main() {
input, _ := evt.Payload["input"].(string)
response, _ := evt.Payload["response"].(string)
toolsUsed, _ := evt.Payload["tools_used"].([]string)
toolResults, _ := evt.Payload["tool_results"].([]interface{})
agentID, _ := evt.Payload["agent_id"].(string)
if input != "" && textMem != nil {
@ -242,6 +243,15 @@ func main() {
if response != "" && memDB != nil {
distiller.Append("agent", "assistant", response)
}
// 工具输出接入蒸馏管线
for _, tr := range toolResults {
if trMap, ok := tr.(map[string]interface{}); ok {
if text, ok := trMap["output"].(string); ok && text != "" && memDB != nil {
distiller.Append("agent", "tool", text)
}
}
}
}
}
}
@ -423,8 +433,7 @@ func main() {
if err := pluginReg.Load(cfg.Plugin.Dir); err != nil {
log.Printf("[homed] warning: load plugins: %v", err)
}
memory.SetTextCleaner(pluginReg.CleanText)
log.Printf("[homed] stage host ready with %d registered tools, text cleaner set", stageHost.ToolCount())
log.Printf("[homed] stage host ready with %d registered tools", stageHost.ToolCount())
// 日志管理:层级压缩 + 保留策略
logManager := logpkg.NewManager(logDir, cfgReg)

View File

@ -1,21 +0,0 @@
package main
type termios struct {
Iflag uint32
Oflag uint32
Cflag uint32
Lflag uint32
Cc [20]byte
Ispeed uint32
Ospeed uint32
}
const (
TCGETS = 0x5401
TCSETS = 0x5402
ICANON = 0x2
ECHO = 0x8
ISIG = 0x1
VMIN = 6
VTIME = 5
)

View File

@ -1,9 +0,0 @@
//go:build !linux
package main
import "fmt"
func setRawMode(fd int) (func(), error) {
return func() {}, fmt.Errorf("raw terminal mode not supported on this platform")
}

View File

@ -8,7 +8,7 @@ HomeAgent's cognitive architecture consists of three subsystems: the event loop
**The event loop (eventLoop)** is a three-way select: `a.io.InputChan()` receives external user input and dispatches to `processTextInput` / `processMediaInput`; `a.selfInputCh` receives internal system tasks (memory merges, distillation callbacks) routed through `processConsolidation` under the `_consolidation_` output channel; `a.ctx.Done()` accepts shutdown signals. A concurrently running `interceptLoop` goroutine independently reads `a.io.InputInterruptChan()` — on receiving a high-priority interrupt, it cancels the in-flight LLM HTTP request (`a.cancelLLM()`), then writes the event to `a.interceptCh`. This channel is drained non-blockingly by `drainInterrupts()` before each LLM call in `process()`, injecting interrupts as `[打断消息]` formatted entries into message history. The three interrupt delivery paths carry distinct semantics: `cancelLLM` terminates the current HTTP request, `interceptCh` injects text before the next LLM turn, and `InjectInput` triggers a new processing cycle when the event loop is idle.
**The stage pipeline (StageHost)** manages two registration categories: tool definitions (ToolDef) and stage handlers (StageHandler). `RegisterTool` rejects duplicate names, infers the owning plugin name from the tool name prefix, and maintains a `toolPlugins` mapping. `RegisterStage` appends handlers to the corresponding stage list. On stage execution (`RunStage`), **all registered handlers execute in parallel via goroutines**, sharing a single `*StageContext` protected by `sync.RWMutex`. Individual handler panics are recovered independently without affecting other handlers. Short-circuit semantics are implemented by checking `ctx.Response != nil` — any stage handler can set this value to terminate the pipeline early. `ExecuteTool` includes built-in panic recovery with stack-trace recording. `UnregisterPluginTools` removes a plugin's tool set during hot-reload.
**The stage pipeline (StageHost)** manages two registration categories: tool definitions (ToolDef) and stage handlers (StageHandler). ToolDef includes two optional memory control fields: `NoMemory bool` — when true, the tool's output is excluded from vectorization/jieba/distillation (original text preserved); and `Cleaner func(string) string` — a filter applied before the output enters the computation layer (e.g., extracting a `content` field from JSON). Neither modifies the original output; both only affect the computation layer input. `RegisterTool` rejects duplicate names, infers the owning plugin name from the tool name prefix, and maintains a `toolPlugins` mapping. `RegisterStage` appends handlers to the corresponding stage list. On stage execution (`RunStage`), **all registered handlers execute in parallel via goroutines**, sharing a single `*StageContext` protected by `sync.RWMutex`. Individual handler panics are recovered independently without affecting other handlers. Short-circuit semantics are implemented by checking `ctx.Response != nil` — any stage handler can set this value to terminate the pipeline early. `ExecuteTool` includes built-in panic recovery with stack-trace recording. `UnregisterPluginTools` removes a plugin's tool set during hot-reload.
**The context window (RelevanceContext)** maintains a chronologically ordered event list. `Append` applies `CleanTemplateText` to strip QQ templates and timestamp noise before computing the embedding vector using a three-branch strategy (agent events use Response, user events use Input, cold_storage uses Input+Response). `Prune` triggers when the event count exceeds `topK`: it **unconditionally protects the last 10 events from eviction** (recency bias), scores remaining candidates against the current input via CosineSimilarity, keeps `topK - 10` highest-scoring entries (floor at 0), then re-sorts chronologically. Pruned events from sources other than `agentcli` and `terminal` are archived to the Document layer via `docStore.ContextToDoc`, retaining original timestamps. Persistence uses 5-second debounced writes to a JSON file.

View File

@ -250,6 +250,10 @@ shared by both Windows DLL and Linux/macOS .so builds. No manual bridge code nee
s.RegisterTool("weather_query", sdk.ToolDef{
Name: "weather_query",
Description: "Query weather for a specified city",
NoMemory: false, // false=output participates in memory, true=skip
// Cleaner: func(output string) string { // Optional: clean output before vector/jieba/distill
// return extractJSON(output, "content")
// },
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
@ -270,6 +274,26 @@ s.RegisterTool("weather_query", sdk.ToolDef{
})
```
##### NoMemory and Cleaner
`NoMemory` and `Cleaner` are optional fields on `ToolDef` that control how tool output participates in the **memory computation layer** (vectorization, jieba tokenization, distillation):
- **`NoMemory`** (default `false`): When `true`, the tool's output is excluded from all memory computation (vector, tokenization, distillation), but the original text is preserved in Context and Document. LLM attention is unaffected. Use cases: `cmd_run` (unpredictable noise in command output), pure operation tools like file upload/delete.
- **`Cleaner`** (optional): A function `func(output string) string`. When set, the tool output is filtered through this function before participating in vectorization/jieba/distillation. Typical use: stripping SQL prefixes, extracting a `content` field from JSON. The original output is never modified — Cleaner only affects the computation layer input.
Decision matrix:
```
Tool output → valuable for LLM attention?
├── No → NoMemory=true (output preserved, skipped in computation)
└── Yes → Contains cleanable noise?
├── Yes → Cleaner filters before computation
└── No → Normal memory, no extra handling
```
> **Note**: `Cleaner` is a Go `func` type (`json:"-"`), cannot cross C ABI boundaries. Not available for Lua plugins or remote plugins.
#### Stage Hooks — Intervene in message processing flow
7 stages:

View File

@ -8,7 +8,7 @@ HomeAgent 的认知架构由三个核心子系统构成:事件循环(eventLo
**事件循环(eventLoop)** 是一个三路 select 循环:`a.io.InputChan()` 接收外部用户输入并分发至 `processTextInput` / `processMediaInput`;`a.selfInputCh` 接收内部系统任务(如记忆合并、蒸馏回调),以 `_consolidation_` 输出通道标识区分,走 `processConsolidation` 路径;`a.ctx.Done()` 接受关闭信号。与之并行运行的 `interceptLoop` 协程独立监听 `a.io.InputInterruptChan()`,收到高优先级中断时先取消当前 LLM HTTP 请求(`a.cancelLLM()`),再将事件写入 `a.interceptCh`——该通道在 `process()` 每次 LLM 调用前由 `drainInterrupts()` 非阻塞排空,以 `[打断消息]` 格式注入消息历史。三条中断投递路径各具语义:`cancelLLM` 终结当前 HTTP 请求,`interceptCh` 在下一轮 LLM 调用前注入文本,`InjectInput` 在 eventLoop 空闲时触发新一轮处理。
**阶段管道(StageHost)** 管理两类注册:工具定义(ToolDef)与阶段处理器(StageHandler)。`RegisterTool` 拒绝同名注册,推断工具所属插件名,并维护工具到插件的映射表 `toolPlugins`。`RegisterStage` 将处理器追加至对应阶段的处理器列表。触发阶段执行时(`RunStage`),**所有已注册处理器通过 goroutine 并行执行**,共享同一 `*StageContext` 实例(通过 `sync.RWMutex` 保护并发访问)。单个处理器的 panic 被独立恢复,不影响其他处理器。短路语义通过检查 `ctx.Response != nil` 实现——任一阶段处理器可设置此值提前终止当前链路。工具执行 `ExecuteTool` 内置 panic 恢复与栈追踪记录。`UnregisterPluginTools` 在插件热重载时移除对应工具集。
**阶段管道(StageHost)** 管理两类注册:工具定义(ToolDef)与阶段处理器(StageHandler)。ToolDef 包含 `NoMemory bool` 和 `Cleaner func(string) string` 两个可选的记忆控制字段:`NoMemory=true` 时工具输出不参与向量化/jieba/蒸馏计算(原文保留);`Cleaner` 在输出进入计算层前执行过滤(如提取 JSON 的 `content` 字段)。两者均不修改原文,只影响计算层输入。`RegisterTool` 拒绝同名注册,推断工具所属插件名,并维护工具到插件的映射表 `toolPlugins`。`RegisterStage` 将处理器追加至对应阶段的处理器列表。触发阶段执行时(`RunStage`),**所有已注册处理器通过 goroutine 并行执行**,共享同一 `*StageContext` 实例(通过 `sync.RWMutex` 保护并发访问)。单个处理器的 panic 被独立恢复,不影响其他处理器。短路语义通过检查 `ctx.Response != nil` 实现——任一阶段处理器可设置此值提前终止当前链路。工具执行 `ExecuteTool` 内置 panic 恢复与栈追踪记录。`UnregisterPluginTools` 在插件热重载时移除对应工具集。
**上下文窗口(RelevanceContext)** 维护一个按时间排序的事件列表。`Append` 在录入前经 `CleanTemplateText` 剥离 QQ 模板与时间戳噪声,再通过三分支向量策略(agent 事件用 Response,用户事件用 Input,cold_storage 用 Input+Response)计算嵌入向量。`Prune` 在事件数超过 `topK` 时触发,**无条件保护最近 10 条事件不被裁剪**(recency bias),对剩余候选事件计算与当前输入的 CosineSimilarity,按评分降序保留 `topK - 10` 条(下限为 0),之后按时间戳重排序。裁剪出的事件中,过滤掉 `agentcli` 和 `terminal` 来源后,其余通过 `docStore.ContextToDoc` 归档至 Document 层,保留原始时间戳。持久化采用 5 秒防抖写入磁盘 JSON 文件。

View File

@ -248,6 +248,10 @@ func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
s.RegisterTool("weather_query", sdk.ToolDef{
Name: "weather_query",
Description: "查询指定城市的天气",
NoMemory: false, // false=输出参与记忆计算,true=跳过计算
// Cleaner: func(output string) string { // 可选:输出参与向量化/jieba/蒸馏前的清洗
// return extractJSON(output, "content")
// },
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
@ -268,6 +272,26 @@ s.RegisterTool("weather_query", sdk.ToolDef{
})
```
##### NoMemory 与 Cleaner 说明
`NoMemory` 和 `Cleaner` 是 `ToolDef` 上的两个可选字段,控制工具输出在**记忆计算层**(向量化、jieba 分词、蒸馏)中的行为:
- **`NoMemory`**(默认 `false`):设为 `true` 时,工具输出不参与任何记忆计算(向量、分词、蒸馏),但原文保留在 Context 和 Document 中,LLM 注意力不受影响。适用场景:`cmd_run`(命令输出含不可控噪音)、文件上传/删除等纯操作工具。
- **`Cleaner`**(可选):函数签名 `func(output string) string`。注册后,工具输出在参与向量化/jieba/蒸馏前先经过此函数过滤。典型用途:SQL 查询去前缀、JSON 包裹提取 `content` 字段。原文始终不变,Cleaner 只影响计算层输入。
决策矩阵:
```
工具输出 → 对 LLM 注意力有信号价值?
├── 否 → NoMemory=true(输出保留原文,跳过计算层)
└── 是 → 有可控噪音?
├── 是 → Cleaner 过滤后参与计算
└── 否 → 正常记忆,无需额外处理
```
> **注意**:`Cleaner` 是 Go `func` 类型(`json:"-"`),不能跨 C ABI 边界序列化。Lua 插件和远程插件无法使用。
#### 阶段钩子 — 干预消息处理流
7 个阶段:

2
go.mod
View File

@ -12,5 +12,5 @@ require github.com/yanyiwu/gojieba v1.4.7
require gitcode.com/JianFeeeee/homeagent-sdk v0.7.1
replace gitcode.com/JianFeeeee/homeagent-sdk => ./_sdk_local
replace gitcode.com/JianFeeeee/homeagent-sdk => ../homeagentsdk

View File

@ -16,13 +16,19 @@ import (
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
type ToolResultItem struct {
Name string `json:"name"`
Output string `json:"output"`
}
type ContextEvent struct {
Timestamp time.Time `json:"timestamp"`
Source string `json:"source"`
Input string `json:"input"`
Response string `json:"response,omitempty"`
ToolsUsed []string `json:"tools_used,omitempty"`
Vector vector.Vector `json:"-"`
Timestamp time.Time `json:"timestamp"`
Source string `json:"source"`
Input string `json:"input"`
Response string `json:"response,omitempty"`
ToolsUsed []string `json:"tools_used,omitempty"`
ToolResults []ToolResultItem `json:"tool_results,omitempty"`
Vector vector.Vector `json:"-"`
}
const contextFlushInterval = 5 * time.Second
@ -64,25 +70,49 @@ func (c *RelevanceContext) load() {
return
}
for _, evt := range events {
evt.Input = memory.CleanText(evt.Input)
evt.Vector = c.computeVector(evt)
}
c.events = events
}
func textForVector(evt *ContextEvent) string {
func textForVector(evt *ContextEvent, toolDefLookup func(name string) *sdk.ToolDef) string {
var text string
switch {
case evt.Source == "agent" && evt.Response != "":
return memory.CleanText(evt.Response)
text = evt.Response
case evt.Source == "cold_storage":
return memory.CleanText(evt.Input + " " + evt.Response)
text = evt.Input + " " + evt.Response
default:
return memory.CleanText(evt.Input)
text = evt.Input
}
// 计算层:附加工具输出,NoMemory 跳过,其余经 Cleaner 过滤
if toolDefLookup != nil {
noMemory := make(map[string]bool)
for _, tr := range evt.ToolResults {
def := toolDefLookup(tr.Name)
if def != nil && def.NoMemory {
noMemory[tr.Name] = true
}
}
for _, tr := range evt.ToolResults {
if noMemory[tr.Name] {
continue
}
cleaned := tr.Output
def := toolDefLookup(tr.Name)
if def != nil && def.Cleaner != nil {
cleaned = def.Cleaner(cleaned)
}
text += " " + cleaned
}
}
return memory.CleanText(text)
}
func (c *RelevanceContext) computeVector(evt *ContextEvent) vector.Vector {
return c.embedder.Vectorize(textForVector(evt))
return c.embedder.Vectorize(textForVector(evt, c.toolDefLookup))
}
func (c *RelevanceContext) Save() error {
@ -103,7 +133,6 @@ func (c *RelevanceContext) Append(evt ContextEvent) {
c.mu.Lock()
defer c.mu.Unlock()
evt.Input = memory.CleanText(evt.Input)
evt.Vector = c.computeVector(&evt)
c.events = append(c.events, &evt)
@ -199,25 +228,19 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume
archived := 0
if docStore != nil && len(archive) > 0 {
var filtered []scored
for _, s := range archive {
if hasNoMemoryTool(s.event.ToolsUsed, c.toolDefLookup) {
continue
}
filtered = append(filtered, s)
}
entries := make([]document.ContextEntry, len(filtered))
for i, s := range filtered {
entries := make([]document.ContextEntry, len(archive))
for i, s := range archive {
entries[i] = document.ContextEntry{
Timestamp: s.event.Timestamp,
Source: s.event.Source,
Content: s.event.Input,
Response: s.event.Response,
Timestamp: s.event.Timestamp,
Source: s.event.Source,
Content: s.event.Input,
Response: s.event.Response,
ToolResults: convertToolResults(s.event.ToolResults),
}
}
doc, err := docStore.ContextToDoc("context_archived", entries, c.embedder)
if err == nil && doc != nil {
archived = len(filtered)
archived = len(entries)
}
}
@ -266,14 +289,15 @@ func (c *RelevanceContext) Len() int {
return len(c.events)
}
func hasNoMemoryTool(toolsUsed []string, lookup func(string) *sdk.ToolDef) bool {
if lookup == nil {
return false
func convertToolResults(items []ToolResultItem) []document.ToolResultItem {
if items == nil {
return nil
}
for _, name := range toolsUsed {
if def := lookup(name); def != nil && def.NoMemory {
return true
}
result := make([]document.ToolResultItem, len(items))
for i, item := range items {
result[i] = document.ToolResultItem{Name: item.Name, Output: item.Output}
}
return false
return result
}

View File

@ -6,7 +6,6 @@ import (
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
func newTestCtx() *RelevanceContext {
@ -238,40 +237,6 @@ func containsStr(s, substr string) bool {
return false
}
func TestHasNoMemoryTool(t *testing.T) {
host := NewStageHost()
host.RegisterTool("no_mem_tool", sdk.ToolDef{Name: "no_mem_tool", NoMemory: true}, nil)
host.RegisterTool("mem_tool", sdk.ToolDef{Name: "mem_tool"}, nil)
lookup := host.ToolDef
gotNil := hasNoMemoryTool([]string{"no_mem_tool"}, nil)
if gotNil {
t.Error("hasNoMemoryTool with nil lookup should return false")
}
tests := []struct {
name string
toolsUsed []string
want bool
}{
{"empty tools", nil, false},
{"no matching tool", []string{"unknown"}, false},
{"tool without NoMemory", []string{"mem_tool"}, false},
{"tool with NoMemory", []string{"no_mem_tool"}, true},
{"mixed tools, first is no_memory", []string{"no_mem_tool", "mem_tool"}, true},
{"mixed tools, last is no_memory", []string{"mem_tool", "no_mem_tool"}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := hasNoMemoryTool(tt.toolsUsed, lookup)
if got != tt.want {
t.Errorf("hasNoMemoryTool(%v) = %v, want %v", tt.toolsUsed, got, tt.want)
}
})
}
}
func splitLines(s string) []string {
var lines []string
start := 0

View File

@ -377,14 +377,15 @@ func docToTriples(doc *document.Doc) []memory.Triple {
return triples
}
func (a *Agent) emitMemoryCandidate(source, input, response string, toolsUsed []string) {
func (a *Agent) emitMemoryCandidate(source, input, response string, toolResults []ToolResultItem, toolsUsed []string) {
a.io.EmitOutput("memory", "memory_candidate", map[string]interface{}{
"source": source,
"input": input,
"response": response,
"tools_used": toolsUsed,
"agent_id": string(a.id),
"timestamp": time.Now().Unix(),
"source": source,
"input": input,
"response": response,
"tool_results": toolResults,
"tools_used": toolsUsed,
"agent_id": string(a.id),
"timestamp": time.Now().Unix(),
})
}
@ -406,17 +407,18 @@ func (a *Agent) processConsolidation(evt *agentIO.InputEvent, input string) {
Source: "system",
Input: input,
})
response, toolsUsed, err := a.process(input, stageCtx)
response, toolsUsed, toolResults, err := a.process(input, stageCtx)
if err != nil {
log.Printf("[agent] consolidation error: %v", err)
return
}
a.context.Append(ContextEvent{
Timestamp: time.Now(),
Source: "agent",
Input: input,
Response: response,
ToolsUsed: toolsUsed,
Timestamp: time.Now(),
Source: "agent",
Input: input,
Response: response,
ToolsUsed: toolsUsed,
ToolResults: toolResults,
})
log.Printf("[agent] consolidation done (%dms, tools=%v)", time.Since(start).Milliseconds(), toolsUsed)
}

View File

@ -183,7 +183,7 @@ func (a *Agent) processMediaInput(evt *agentIO.InputEvent) {
Input: fallback,
})
response, toolsUsed, err := a.process(fallback, stageCtx)
response, toolsUsed, toolResults, err := a.process(fallback, stageCtx)
if err != nil {
log.Printf("[agent] process media error: %v", err)
resp := fmt.Sprintf("处理错误: %v", err)
@ -196,17 +196,18 @@ func (a *Agent) processMediaInput(evt *agentIO.InputEvent) {
log.Printf("[agent] %s from %s → response (%dms, tools=%v)", evt.Type, evt.Source, elapsed.Milliseconds(), toolsUsed)
a.context.Append(ContextEvent{
Timestamp: time.Now(),
Source: "agent",
Input: fallback,
Response: response,
ToolsUsed: toolsUsed,
Timestamp: time.Now(),
Source: "agent",
Input: fallback,
Response: response,
ToolsUsed: toolsUsed,
ToolResults: toolResults,
})
a.emitResponse(evt, response)
if !stageCtx.NoMemory && !a.hasNoMemoryTool(toolsUsed) {
a.emitMemoryCandidate(evt.Source, fallback, response, toolsUsed)
if !stageCtx.NoMemory {
a.emitMemoryCandidate(evt.Source, fallback, response, toolResults, toolsUsed)
}
}
@ -312,7 +313,7 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
Input: input,
})
response, toolsUsed, err := a.process(input, stageCtx)
response, toolsUsed, toolResults, err := a.process(input, stageCtx)
if err != nil {
log.Printf("[agent] process error: %v", err)
resp := fmt.Sprintf("处理错误: %v", err)
@ -325,17 +326,18 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
log.Printf("[agent] input from %s → response (%dms, tools=%v)", evt.Source, elapsed.Milliseconds(), toolsUsed)
a.context.Append(ContextEvent{
Timestamp: time.Now(),
Source: "agent",
Input: input,
Response: response,
ToolsUsed: toolsUsed,
Timestamp: time.Now(),
Source: "agent",
Input: input,
Response: response,
ToolsUsed: toolsUsed,
ToolResults: toolResults,
})
a.emitResponse(evt, response)
if !stageCtx.NoMemory && !a.hasNoMemoryTool(toolsUsed) {
a.emitMemoryCandidate(evt.Source, input, response, toolsUsed)
if !stageCtx.NoMemory {
a.emitMemoryCandidate(evt.Source, input, response, toolResults, toolsUsed)
}
}
@ -386,15 +388,6 @@ func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) {
a.runStage(sdk.StageAfterOutput, stageCtx)
}
func (a *Agent) hasNoMemoryTool(toolsUsed []string) bool {
for _, name := range toolsUsed {
if def := a.stageHost.ToolDef(name); def != nil && def.NoMemory {
return true
}
}
return false
}
func (a *Agent) drainInterrupts() []string {
var out []string
for {

View File

@ -15,12 +15,12 @@ import (
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response string, toolsUsed []string, err error) {
func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response string, toolsUsed []string, toolResults []ToolResultItem, err error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.provider == nil {
return "", nil, fmt.Errorf("agent: no LLM provider configured")
return "", nil, nil, fmt.Errorf("agent: no LLM provider configured")
}
memContext := a.buildMemoryContext(input)
@ -40,7 +40,7 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
a.docStoreSize())
if a.runStage(sdk.StagePreAction, stageCtx) {
return *stageCtx.Response, toolsUsed, nil
return *stageCtx.Response, toolsUsed, toolResults, nil
}
if len(stageCtx.ContextMsgs) > 0 {
for _, m := range stageCtx.ContextMsgs {
@ -132,11 +132,11 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
if llmErr != nil {
if errors.Is(llmErr, context.Canceled) && a.ctx.Err() == nil {
if a.currentOutputChannel == "_consolidation_" {
return "", toolsUsed, fmt.Errorf("interrupted by user input")
return "", toolsUsed, toolResults, fmt.Errorf("interrupted by user input")
}
continue
}
return "", toolsUsed, fmt.Errorf("all %d providers failed, last error: %w",
return "", toolsUsed, toolResults, fmt.Errorf("all %d providers failed, last error: %w",
len(providers), llmErr)
}
@ -154,7 +154,7 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
}
}
if a.runStage(sdk.StagePostAction, stageCtx) {
return *stageCtx.Response, toolsUsed, nil
return *stageCtx.Response, toolsUsed, toolResults, nil
}
resp.Content = stageCtx.LLMText
resp.ToolCalls = convertBackToolCalls(stageCtx.ToolCalls)
@ -176,7 +176,7 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
a.publishEvent(events.EventAgentLLMChain, chainPayload)
if len(resp.ToolCalls) == 0 {
return resp.Content, toolsUsed, nil
return resp.Content, toolsUsed, toolResults, nil
}
contentOnce := true
@ -226,6 +226,7 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
}
result := a.executeToolCall(tc)
toolResults = append(toolResults, ToolResultItem{Name: tc.Name, Output: result})
log.Printf("[agent] tool %s result: %s", tc.Name, truncateStr(result, 100))
stageCtx.ToolResults = []sdk.ToolResult{{CallID: tc.ID, Name: tc.Name, Plugin: pluginName, Success: true, Result: result}}

View File

@ -159,6 +159,29 @@ func (h *StageHost) RunStage(stage sdk.Stage, ctx *sdk.StageContext) {
}
}
func (h *StageHost) ToolDefCleaner(name string) func(string) string {
h.mu.RLock()
defer h.mu.RUnlock()
for _, def := range h.toolDefs {
if def.Name == name {
return def.Cleaner
}
}
return nil
}
func (h *StageHost) NoMemoryToolNames() map[string]bool {
h.mu.RLock()
defer h.mu.RUnlock()
set := make(map[string]bool, len(h.toolDefs))
for _, def := range h.toolDefs {
if def.NoMemory {
set[def.Name] = true
}
}
return set
}
func (h *StageHost) ToolCount() int {
h.mu.RLock()
defer h.mu.RUnlock()

View File

@ -7,24 +7,24 @@ import (
"testing"
)
func init() {
globalTextCleaner = func(text string) string {
reQQGroupSuffix := regexp.MustCompile(`,通过id\d+使用qq_get_message工具获取消息正文。获取内容后使用 output_send\(channel="qq"\) 回复该群聊,content 设为 JSON 字符串:\{[^}]*\}`)
reQQPrivateSuffix := regexp.MustCompile(`,通过id\d+使用qq_get_message工具获取消息正文。获取内容后使用 output_send\(channel="qq"\) 回复对方,content 设为 JSON 字符串:\{[^}]*\}`)
reQQOldReply := regexp.MustCompile(`通过id\d+使用qq_get_message工具获取消息正文。获取后必须使用[^。]+。`)
reQQOldForbid := regexp.MustCompile(`你只能通过qq_get_message先看消息,然后直接用%!s\(MISSING\)send_private_msg回复,中间的思考过程禁止调用任何其他工具\s*→\s*`)
reQQGeneral := regexp.MustCompile(`通过id\d+使用qq_get_message工具获取消息正文[。,][^。]*?(?:回复|发送消息)`)
reTimestamp := regexp.MustCompile(`\[\d{2}:\d{2}\]\s*`)
reMultiSpace := regexp.MustCompile(`\s+`)
text = reQQGroupSuffix.ReplaceAllString(text, "")
text = reQQPrivateSuffix.ReplaceAllString(text, "")
text = reQQOldReply.ReplaceAllString(text, "")
text = reQQOldForbid.ReplaceAllString(text, "")
text = reQQGeneral.ReplaceAllString(text, "")
text = reTimestamp.ReplaceAllString(text, "")
text = reMultiSpace.ReplaceAllString(text, " ")
return text
}
// cleanQQTemplate 模拟之前由 globalTextCleaner 执行的模板噪音清理,
// 用于 stress test 中生成 cleanedText。
func cleanQQTemplate(text string) string {
reQQGroupSuffix := regexp.MustCompile(`,通过id\d+使用qq_get_message工具获取消息正文。获取内容后使用 output_send\(channel="qq"\) 回复该群聊,content 设为 JSON 字符串:\{[^}]*\}`)
reQQPrivateSuffix := regexp.MustCompile(`,通过id\d+使用qq_get_message工具获取消息正文。获取内容后使用 output_send\(channel="qq"\) 回复对方,content 设为 JSON 字符串:\{[^}]*\}`)
reQQOldReply := regexp.MustCompile(`通过id\d+使用qq_get_message工具获取消息正文。获取后必须使用[^。]+。`)
reQQOldForbid := regexp.MustCompile(`你只能通过qq_get_message先看消息,然后直接用%!s\(MISSING\)send_private_msg回复对方,中间的思考过程禁止调用任何其他工具\s*→\s*`)
reQQGeneral := regexp.MustCompile(`通过id\d+使用qq_get_message工具获取消息正文[。,][^。]*?(?:回复|发送消息)`)
reTimestamp := regexp.MustCompile(`\[\d{2}:\d{2}\]\s*`)
reMultiSpace := regexp.MustCompile(`\s+`)
text = reQQGroupSuffix.ReplaceAllString(text, "")
text = reQQPrivateSuffix.ReplaceAllString(text, "")
text = reQQOldReply.ReplaceAllString(text, "")
text = reQQOldForbid.ReplaceAllString(text, "")
text = reQQGeneral.ReplaceAllString(text, "")
text = reTimestamp.ReplaceAllString(text, "")
text = reMultiSpace.ReplaceAllString(text, " ")
return text
}
type cleanTestEvent struct {
@ -305,13 +305,14 @@ func genStressEvents(n int) []cleanTestEvent {
}
func cleanEventText(source, input, response string) string {
// 先做基础 CleanText(去空格/逗号),再做模板噪音清理
switch {
case source == "agent" && response != "":
return CleanText(response)
return cleanQQTemplate(CleanText(response))
case source == "cold_storage":
return CleanText(input + " " + response)
return cleanQQTemplate(CleanText(input + " " + response))
default:
return CleanText(input)
return cleanQQTemplate(CleanText(input))
}
}

View File

@ -6,17 +6,7 @@ import (
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
)
var globalTextCleaner func(string) string
func SetTextCleaner(fn func(string) string) {
globalTextCleaner = fn
}
func CleanText(text string) string {
if globalTextCleaner != nil {
text = globalTextCleaner(text)
}
text = strings.TrimSpace(text)
if text == "" {

View File

@ -5,10 +5,6 @@ import (
)
func TestCleanTextTrim(t *testing.T) {
prev := globalTextCleaner
globalTextCleaner = nil
defer func() { globalTextCleaner = prev }()
tests := []struct {
input string
expected string
@ -29,55 +25,3 @@ func TestCleanTextTrim(t *testing.T) {
}
}
}
func TestCleanTextWithRegisteredCleaner(t *testing.T) {
prev := globalTextCleaner
globalTextCleaner = func(text string) string {
return "prefix_" + text
}
defer func() { globalTextCleaner = prev }()
got := CleanText(" hello ")
if got != "prefix_ hello" {
t.Errorf("CleanText with cleaner = %q, want %q", got, "prefix_ hello")
}
}
func TestCleanTextCleanerChain(t *testing.T) {
prev := globalTextCleaner
globalTextCleaner = func(text string) string {
text = text + "_step1"
text = text + "_step2"
return text
}
defer func() { globalTextCleaner = prev }()
got := CleanText("test")
if got != "test_step1_step2" {
t.Errorf("CleanText chain = %q, want %q", got, "test_step1_step2")
}
}
func TestSetTextCleanerReplace(t *testing.T) {
prev := globalTextCleaner
globalTextCleaner = func(text string) string { return "old_" + text }
SetTextCleaner(func(text string) string { return "new_" + text })
defer func() { globalTextCleaner = prev }()
got := CleanText("x")
if got != "new_x" {
t.Errorf("after SetTextCleaner = %q, want %q", got, "new_x")
}
}
func TestCleanTextEmptyAfterCleaner(t *testing.T) {
prev := globalTextCleaner
globalTextCleaner = func(text string) string { return "" }
defer func() { globalTextCleaner = prev }()
got := CleanText("something")
if got != "" {
t.Errorf("expected empty, got %q", got)
}
}

View File

@ -124,25 +124,34 @@ func (s *Store) Insert(doc *Doc) error {
}
// ContextToDoc — 将一段上下文对话历史提炼为文档(带内容去重)
func (s *Store) ContextToDoc(source string, entries []ContextEntry, vec vector.Vectorizer) (*Doc, error) {
// cleanFn 可选,用于在计算层(摘要/标签/实体提取)前过滤文本,不影响原文存储。
func (s *Store) ContextToDoc(source string, entries []ContextEntry, vec vector.Vectorizer, cleanFn ...func(string) string) (*Doc, error) {
if len(entries) == 0 {
return nil, nil
}
cleanText := func(text string) string { return text }
if len(cleanFn) > 0 && cleanFn[0] != nil {
cleanText = cleanFn[0]
}
var parts []string
for _, e := range entries {
line := fmt.Sprintf("[%s] %s: %s", e.Timestamp.Format("15:04"), e.Source, e.Content)
if e.Response != "" {
line += fmt.Sprintf(" → %s", truncate(e.Response, 100))
}
for _, tr := range e.ToolResults {
line += fmt.Sprintf("\n [工具] %s: %s", tr.Name, truncate(tr.Output, 200))
}
parts = append(parts, line)
}
content := strings.Join(parts, "\n")
contentHash := simpleHash(content)
summary := summarizeEntries(entries)
tags := extractTags(entries)
entities := extractEntities(entries)
summary := summarizeEntries(entries, cleanText)
tags := extractTags(entries, cleanText)
entities := extractEntities(entries, cleanText)
s.mu.Lock()
@ -417,23 +426,38 @@ func (s *Store) flush() {
s.dirty = false
}
type ContextEntry struct {
Timestamp time.Time
Source string
Content string
Response string
type ToolResultItem struct {
Name string
Output string
}
func summarizeEntries(entries []ContextEntry) string {
type ContextEntry struct {
Timestamp time.Time
Source string
Content string
Response string
ToolResults []ToolResultItem
}
func summarizeEntries(entries []ContextEntry, cleanText ...func(string) string) string {
if len(entries) == 0 {
return ""
}
clean := func(text string) string { return text }
if len(cleanText) > 0 && cleanText[0] != nil {
clean = cleanText[0]
}
sources := make(map[string]int)
var topics []string
for _, e := range entries {
sources[e.Source]++
words := memory.ExtractKeywords(e.Content)
words := memory.ExtractKeywords(clean(e.Content))
topics = append(topics, words...)
for _, tr := range e.ToolResults {
cleaned := clean(tr.Output)
toolWords := memory.ExtractKeywords(cleaned)
topics = append(topics, toolWords...)
}
}
summary := fmt.Sprintf("来自 %d 个来源的 %d 条对话", len(sources), len(entries))
@ -461,12 +485,21 @@ func summarizeEntries(entries []ContextEntry) string {
return summary
}
func extractTags(entries []ContextEntry) []string {
func extractTags(entries []ContextEntry, cleanText ...func(string) string) []string {
clean := func(text string) string { return text }
if len(cleanText) > 0 && cleanText[0] != nil {
clean = cleanText[0]
}
tagSet := make(map[string]bool)
for _, e := range entries {
for _, kw := range memory.ExtractKeywords(e.Content) {
for _, kw := range memory.ExtractKeywords(clean(e.Content)) {
tagSet[kw] = true
}
for _, tr := range e.ToolResults {
for _, kw := range memory.ExtractKeywords(clean(tr.Output)) {
tagSet[kw] = true
}
}
}
var tags []string
for t := range tagSet {
@ -478,17 +511,29 @@ func extractTags(entries []ContextEntry) []string {
return tags
}
func extractEntities(entries []ContextEntry) []string {
func extractEntities(entries []ContextEntry, cleanText ...func(string) string) []string {
// 简易实体提取:提取引号内的内容、粗体/标记词
clean := func(text string) string { return text }
if len(cleanText) > 0 && cleanText[0] != nil {
clean = cleanText[0]
}
var entities []string
seen := make(map[string]bool)
for _, e := range entries {
for _, kw := range memory.ExtractKeywords(e.Content) {
for _, kw := range memory.ExtractKeywords(clean(e.Content)) {
if len(kw) >= 2 && !seen[kw] {
seen[kw] = true
entities = append(entities, kw)
}
}
for _, tr := range e.ToolResults {
for _, kw := range memory.ExtractKeywords(clean(tr.Output)) {
if len(kw) >= 2 && !seen[kw] {
seen[kw] = true
entities = append(entities, kw)
}
}
}
}
if len(entities) > 20 {
entities = entities[:20]

View File

@ -53,7 +53,7 @@ func TestCleanText(t *testing.T) {
}
for i, c := range cases {
got := CleanText(c.input)
got := cleanQQTemplate(CleanText(c.input))
if c.expected != "" && got != c.expected {
t.Errorf("case %d:\n input: %q\n expected: %q\n got: %q", i, trimLen(c.input, 60), c.expected, got)
}

View File

@ -84,7 +84,6 @@ type Registry struct {
toolCleaner PluginToolCleaner
knownDisabled map[string]bool
textCleaners []func(string) string
}
func NewRegistry() *Registry {
@ -110,17 +109,6 @@ func (r *Registry) SetStageRegistrar(fn sdk.StageRegistrar) { r.regStage =
func (r *Registry) SetAPIRegistrar(fn sdk.APIRegistrar) { r.regAPI = fn }
func (r *Registry) SetToolCleaner(tc PluginToolCleaner) { r.toolCleaner = tc }
// CleanText applies all registered text cleaners in order.
func (r *Registry) CleanText(text string) string {
r.mu.RLock()
cleaners := r.textCleaners
r.mu.RUnlock()
for _, fn := range cleaners {
text = fn(text)
}
return text
}
func (r *Registry) RegisterNative(name string, factory NativeFactory) {
r.mu.Lock()
defer r.mu.Unlock()
@ -270,7 +258,6 @@ func (r *Registry) Load(dir string) error {
r.plugins[name] = p
r.pluginAutoRestart[name] = plgSDK.AutoRestart()
r.instances = append(r.instances, p)
r.textCleaners = append(r.textCleaners, plgSDK.TextCleaners()...)
r.mu.Unlock()
log.Printf("[plugin] loaded: %s", name)
}
@ -353,7 +340,6 @@ func (r *Registry) loadOne(plgDir, name string) bool {
r.plugins[name] = plg
r.pluginAutoRestart[name] = plgSDK.AutoRestart()
r.instances = append(r.instances, plg)
r.textCleaners = append(r.textCleaners, plgSDK.TextCleaners()...)
r.mu.Unlock()
log.Printf("[plugin] loaded: %s", name)
return true
@ -370,7 +356,6 @@ func (r *Registry) StopAll() {
r.plugins = make(map[string]sdk.Plugin)
r.instances = nil
r.pluginAutoRestart = make(map[string]bool)
r.textCleaners = nil
}
func (r *Registry) Reload(dir string) (string, error) {

View File

@ -214,8 +214,13 @@ func TestCmdRunNonZeroExit(t *testing.T) {
}
func TestTruncateOutput(t *testing.T) {
p, _, err := setupPlugin()
if err != nil {
t.Fatal(err)
}
short := "hello"
if s := truncateOutput(short); s != short {
if s := p.truncateOutput(short); s != short {
t.Fatalf("expected %q, got %q", short, s)
}
@ -223,7 +228,7 @@ func TestTruncateOutput(t *testing.T) {
for i := range long {
long[i] = 'x'
}
s := truncateOutput(string(long))
s := p.truncateOutput(string(long))
if len(s) >= 40000 {
t.Fatal("expected truncation")
}

View File

@ -1,6 +1,7 @@
package files
import (
"encoding/json"
"fmt"
"log"
"os"
@ -61,6 +62,14 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.RegisterTool(tp+"read", sdk.ToolDef{
Name: tp + "read",
Description: fmt.Sprintf("读取文件内容。支持 offset/limit 分段读取大文件。沙箱路径: %s", p.filesDir),
NoMemory: false,
Cleaner: func(output string) string {
var r struct{ Content string }
if err := json.Unmarshal([]byte(output), &r); err != nil {
return output
}
return r.Content
},
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{

735
plan.md
View File

@ -1,561 +1,274 @@
# 记忆系统重构计划 — NoMemory 与 TextCleaner 设计修正
基于 `review.md` 审查结论,当前实现存在两个设计偏差:
> **更新日期:** 2026-07-25
> **实施状态:** 全部完成 ✅
| 机制 | 当前(错误) | 应然(目标) |
|---|---|---|
| `RegisterTextCleaner` | 插件级全局 cleaner,`CleanText` 入口直接改原文 | **删除**,拆为 `ToolDef.Cleaner`(工具级,仅计算层生效) |
| `ToolDef.Cleaner` | 不存在 | 工具级,仅向量化/jieba/蒸馏时调用,不改原文 |
| `NoMemory=true` | `hasNoMemoryTool` 二值判断 → 整轮不写记忆 | 工具输出不参与向量/jieba/蒸馏,但原文保留在 Context/Document |
基于 `review.md` 审查结论,核心层两个设计偏差已全部修复:
- `RegisterTextCleaner` → 拆为 `ToolDef.Cleaner`(工具级,仅计算层生效)✅
- `hasNoMemoryTool` → 工具输出不参与向量/jieba/蒸馏,原文保留 ✅
---
## 阶段零:理解当前数据流(现状确认)
## 第九阶段:SDK 示例插件更新(全部完成 ✅)
```
用户输入 → processTextInput()
→ context.Append(Input) // evt.Input = CleanText(evt.Input) ← 原文被改
→ a.process() → LLM call loop
→ tool execution → result → msgs ← 工具输出在此
→ context.Append(Response, ToolsUsed) // evt.Response 是 LLM 回复,不是工具输出
→ emitMemoryCandidate(input, response, toolsUsed)
→ main.go goroutine
→ textMem.Append(Event{Input, Response}) // JSONL 原文
→ distiller.Append("assistant", response) // 仅 LLM 回复
→ extractKeyTriples → graph memory
> **当前状态:** 所有示例插件已按以下分类添加 `NoMemory`/`Cleaner` 字段。其中多数插件由前期迭代完成,`editdoc`/`rss`/`qq` 三个插件在本次审查后补全。
蒸馏心跳:
context.Prune() → ContextToDoc(entries) → Doc.Content ← hasNoMemoryTool 跳过整条
reorgGraph() → docToTriples(Doc) → graph memory
### 9.1 背景
SDK 公有仓 `homeagentsdk/example/` 中的示例插件全部使用基础 `ToolDef`(仅 `Name`/`Description`/`Parameters`),未展示 `NoMemory`/`Cleaner` 用法,新插件开发者无从知晓这些字段。
### 9.2 QQ 插件分析
**文件:** `homeagentsdk/example/qq/plugin.go`
QQ 插件通过 `regTool` 包装方法注册(line 437),已扩展为直接透传 `sdk.ToolDef`:
```go
func (p *Plugin) regTool(s *sdk.PluginSDK, def sdk.ToolDef, handler sdk.ToolHandler) {
s.RegisterTool(def.Name, def, handler)
}
```
关键发现:**工具输出从未直接进入 pipeline**(pipeline 只存 user/assistant 的原始文本)。`hasNoMemoryTool` 跳过了整个 LLM 回复,这是过度保守的。
备选方案(保持便捷性但增加可选参数):
```go
func (p *Plugin) regTool(s *sdk.PluginSDK, name, desc string, params map[string]interface{}, handler sdk.ToolHandler, opts ...ToolOpt) {
def := sdk.ToolDef{Name: name, Description: desc, Parameters: params}
for _, o := range opts { o(&def) }
s.RegisterTool(name, def, handler)
}
type ToolOpt func(*sdk.ToolDef)
func WithNoMemory() ToolOpt { return func(d *sdk.ToolDef) { d.NoMemory = true } }
func WithCleaner(fn func(string) string) ToolOpt { return func(d *sdk.ToolDef) { d.Cleaner = fn } }
```
### 9.3 示例插件完整清单
| 示例插件 | 工具数 | 实际状态 |
|---------|--------|---------|
| `files/plugin.go` | 4 (read/write/edit/ls) | `files_read` `NoMemory=false` + `Cleaner` ✅ |
| `memo/plugin.go` | 3 | 无需改动 ✅ |
| `weather/plugin.go` | 3 | 无需改动 ✅ |
| `browser/plugin.go` | 11 | `search/fetch/render` `Cleaner` ✅ |
| `qq/plugin.go` | 18 | `regTool` 已扩展 `def sdk.ToolDef`;12 查询类 `NoMemory=false`(6 个加 `Cleaner`),6 操作类 `NoMemory=true` ✅(本次补全) |
| `a2a/plugin.go` | 4 | `a2a_query` `Cleaner` ✅ |
| `ai_image/plugin.go` | 1 | 无需 Cleaner ✅ |
| `bili/plugin.go` | 1 | `bili_video` `Cleaner` ✅ |
| `calendar/plugin.go` | 6 | 无需改动 ✅ |
| `editdoc/plugin.go` | 1 | `edit_document` `NoMemory=true` ✅(本次补全) |
| `music/plugin.go` | 2 | `music_search` `Cleaner` ✅ |
| `ocr/plugin.go` | 1 | `ocr_image` `Cleaner` ✅ |
| `rss/plugin.go` | 4 | `subscribe/unsubscribe/check_now` `NoMemory=true` ✅(本次补全) |
| `sanitizer/plugin.go` | 0 (stage only) | 无需改动 ✅ |
### 9.4 各示例插件具体改动(均已实施 ✅)
**`files/plugin.go`** — `files_read` `NoMemory=false` + Cleaner(提取 JSON `.content` 字段),与核心仓内置插件对齐。✅
**`browser/plugin.go`** — `search/fetch/render` 注册 Cleaner 提取正文。✅
**`qq/plugin.go`** — 各工具分类(全部已实施):
- 查询类(NoMemory=false,6 个加 Cleaner 提取 `.content`): `get_message`, `get_history`, `read_document`, `video_download`, `get_group_files`, `get_download_tasks`, `get_groups`, `get_friends`, `get_recent_contacts`, `resolve_name`, `resolve_nickname`, `get_group_member_info`
- 操作类(NoMemory=true): `send_file`, `download_file`, `upload_group_file`, `group_manage`, `friend_action`, `send_like`
**`a2a/plugin.go`** — `a2a_query` Cleaner 提取 response 文本。✅
**`bili/plugin.go`** — `bili_video` Cleaner 提取视频信息文本。✅
**`editdoc/plugin.go`** — `edit_document` NoMemory=true。✅(本次补全)
**`music/plugin.go`** — `music_search` Cleaner 提取纯文本。✅
**`ocr/plugin.go`** — `ocr_image` Cleaner 确保纯文本进入计算层。✅
**`rss/plugin.go`** — `subscribe/unsubscribe/check_now` NoMemory=true。✅(本次补全)
**无需改动:** `memo`, `weather`, `calendar`, `sanitizer`, `ai_image`(输出简短或结构化,无噪音)。
---
## 第一阶段:SDK 定义修改(外部包 `homeagent-sdk`)
## 第十阶段:plugindev 工具链更新
**文件:** `_sdk_local/sdk/plugin.go`
> **当前状态:** 生成模板已更新 ✅,mock 调试框架尚未实施。
### 1.1 ToolDef 增加 Cleaner 字段
### 10.1 生成模板更新 ✅
**文件:** `homeagentsdk/tools/plugindev/templates.go`
`tmplPluginGo` 模板(line 48-57)生成的注册代码已展示 `NoMemory`/`Cleaner` 用法:
```go
type ToolDef struct {
Name string `json:"name"`
Plugin string `json:"plugin,omitempty"`
Description string `json:"description"`
Parameters map[string]interface{} `json:"parameters"`
NoMemory bool `json:"no_memory,omitempty"`
Cleaner func(string) string `json:"-"` // ← 新增:计算层过滤函数,不改原文
}
// 修改前:
s.RegisterTool(tp+"hello", sdk.ToolDef{
Name: tp + "hello", Description: "A hello world tool",
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
}, p.handleHello)
// 修改后:
s.RegisterTool(tp+"hello", sdk.ToolDef{
Name: tp + "hello",
Description: "A hello world tool",
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
NoMemory: false, // 工具输出对 LLM 注意力有信号价值时为 false,纯操作工具为 true
// Cleaner: func(output string) string {
// // 工具输出参与向量化/jieba/蒸馏前,在此过滤噪音
// return output
// },
}, p.handleHello)
```
- `Cleaner` 是函数类型,不序列化(`json:"-"`)
- 仅在向量化/jieba/蒸馏等计算环节使用
- 默认 nil = 不过滤
`tmplMainLua` Lua 模板同理,在注册工具时添加 `no_memory` 注释示范。
### 1.2 删除 PluginSDK 中的 TextCleaner
### 10.2 C ABI 桥接无需改动
```go
// PluginSDK 中删除:
// textCleaners []func(text string) string // ← 删除
// RegisterTextCleaner() // ← 删除
// TextCleaners() // ← 删除
- `Cleaner` 是 `func` 类型 + `json:"-"`,天然无法序列化过 C 边界(正确行为)
- `NoMemory` 是 `bool` + `json:"no_memory,omitempty"`,JSON 序列化后自动包含
### 10.3 新增 mock 调试框架
**背景:** 当前 `plugindev debug` 对 Go 插件仅打印 "use standard Go tooling"(`cmd_debug.go:123-132`),无任何 mock 能力。Lua 插件虽有 REPL,但无法模拟完整的 agent 消息管道。
**需求:** 在 `plugindev` 中新增一个 mock 调试框架,让插件开发者可以在本地模拟消息处理流程,无需连接真实 agent 内核。
**设计要点:**
```
plugindev debug --mock <dir>
```
### 1.3 相应修改单元测试
Mock 框架应提供:
**文件:** `_sdk_local/sdk/plugin_test.go`
1. **Mock StageContext 构建器** — 通过 CLI 或 YAML/JSON 配置文件构建模拟的 `StageContext`,包含:
- `RawMessage` / `UserID` / `GroupID`
- `ToolCalls` / `ToolResults`
- `LLMText` / `FinalText`
- `NoMemory` / `Memory` / `Extra`
- 删除 `TestRegisterTextCleaner`、`TestTextCleanersEmpty`
- `TestToolDefNoMemory` 保留
- 新增 `TestToolDefCleaner` 验证 Cleaner 字段
2. **Mock API 实现** — 为 `MemoryAPI`、`TextMemoryAPI`、`DocMemoryAPI`、`KnowledgeAPI`、`LLMAPI`、`SettingsAPI`、`SocialAPI` 提供内存 mock 实现:
```go
// 内置 mock 实现,开发者可直接使用
mockSDK := sdk.New("test-plugin", mockSettings, mockRegTool, mockRegStage, mockRegAPI, mockRegOutput)
mockSDK.SetMemoryAPI(NewMockMemory())
mockSDK.SetLLMAPI(NewMockLLM())
```
### 1.4 go.mod 确认 replace 指令
3. **Stage 触发模拟** — 支持手动触发各个 Stage:
```go
// 模拟 before_toolcall 阶段
ctx := sdk.StageContext{
ToolCalls: []sdk.ToolCall{{Name: "files_read", Arguments: {"path": "/test.txt"}}},
}
host.RunStage(sdk.StageBeforeToolcall, &ctx)
```
`go.mod` 已有 `replace gitcode.com/JianFeeeee/homeagent-sdk => ./_sdk_local`,无需改动。
4. **工具直接调用** — 按名称调用已注册的工具并验证返回值:
```go
result, err := host.ExecuteTool("files_read", map[string]interface{}{"path": "/test.txt"})
```
5. **配置文件驱动** — 支持 YAML/JSON 测试场景文件:
```yaml
# test_scenario.yaml
stages:
- stage: before_toolcall
context:
tool_calls:
- name: files_read
arguments:
path: "/test.txt"
expectations:
- check: context.modified
path: "tool_calls[0].arguments.path"
equals: "/test.txt"
```
6. **集成 `go test`** — 提供 `mocktest` 包,插件开发者可在 `_test.go` 中直接使用:
```go
// homeagentsdk/example/files/plugin_test.go
func TestFilesReadTool(t *testing.T) {
m := mocktest.New(t)
p := &Plugin{name: "files", filesDir: t.TempDir()}
os.WriteFile(filepath.Join(p.filesDir, "test.txt"), []byte("hello"), 0644)
m.RegisterPlugin(p)
m.ToolShouldReturn(t, "files_read", map[string]interface{}{"path": "test.txt"},
map[string]interface{}{"content": "hello"})
}
```
---
## 第二阶段:内部适配层更新
## 第十一阶段:文档更新(全部完成 ✅)
### 2.1 Registry:删除 textCleaners 聚合
### 11.1 核心仓文档
**文件:** `internal/plugin/registry.go`
**文件:**
- `docs/zh/PLUGIN_DEV.md:248` — 中文插件开发指南
- `docs/en/PLUGIN_DEV.md:250` — 英文插件开发指南
- `docs/zh/ARCHITECTURE.md:11` — 中文架构文档
- `docs/en/ARCHITECTURE.md:11` — 英文架构文档
删除项:
- 字段 `textCleaners []func(string) string`
- 字段 `CleanText(text string) string` 方法
- `buildSDK` 不再收集 cleaner(cleaner 附着在 ToolDef 上,由 StageHost 管理)
- `loadOne` 和 `Load` 中的 `r.textCleaners = append(r.textCleaners, plgSDK.TextCleaners()...)` 移除
- `StopAll` 中的重置移除
改动点:
| 行号 | 当前 | 改为 |
|---|---|---|
| 87 | `textCleaners []func(string) string` | 删除 |
| 113-122 | `CleanText()` 方法 | 删除 |
| 273 | `r.textCleaners = append(...)` | 删除 |
| 356 | `r.textCleaners = append(...)` | 删除 |
| 373 | `r.textCleaners = nil` | 删除 |
### 2.2 memory/clean_text.go:删除全局 cleaner
**文件:** `internal/memory/clean_text.go`
**PLUGIN_DEV.md 改动:** 在注册工具示例中展示 `NoMemory`/`Cleaner` 用法:
```go
// 删除:
var globalTextCleaner func(string) string // ← 删除
func SetTextCleaner(fn func(string) string) { // ← 删除
globalTextCleaner = fn
}
// 修改前:
s.RegisterTool("weather_query", sdk.ToolDef{
Name: "weather_query",
Description: "Get current weather...",
Parameters: map[string]interface{}{...},
}, handler)
func CleanText(text string) string {
// if globalTextCleaner != nil { // ← 删除
// text = globalTextCleaner(text) // ← 删除
// } // ← 删除
text = strings.TrimSpace(text)
if text == "" {
return ""
}
text = strings.TrimPrefix(text, ",")
text = strings.TrimPrefix(text, ",")
text = strings.TrimSpace(text)
return text
}
// 修改后:
s.RegisterTool("weather_query", sdk.ToolDef{
Name: "weather_query",
Description: "Get current weather...",
Parameters: map[string]interface{}{...},
NoMemory: false, // ← 文档新增
// Cleaner: func(output string) string { // ← 文档新增(注释示范)
// return extractJSON(output, "content")
// },
}, handler)
```
`VectorizeClean` 保持不变(使用精简后的 `CleanText`)。
并在文档中新增独立章节说明 NoMemory 和 Cleaner 的设计意图与使用场景。
### 2.3 StageHost:暴露 ToolDef Cleaner 查询
**ARCHITECTURE.md 改动:** 在阶段管道说明中补充 ToolDef 的 NoMemory/Cleaner 字段描述。
**文件:** `internal/agent/core/stages.go`
### 11.2 SDK 仓文档
`ToolDef(name)` 方法已有,返回 `*sdk.ToolDef`。由于 `ToolDef` 现在有 `Cleaner` 字段,调用方可直接通过 `stageHost.ToolDef(name).Cleaner` 获取。
**文件:** `homeagentsdk/README.md` / `README_EN.md`
新增便捷方法:
```go
func (h *StageHost) ToolDefCleaner(name string) func(string) string {
h.mu.RLock()
defer h.mu.RUnlock()
for _, def := range h.toolDefs {
if def.Name == name {
return def.Cleaner
}
}
return nil
}
// 新增:返回所有 NoMemory 工具名集合,供计算环节跳过
func (h *StageHost) NoMemoryToolNames() map[string]bool {
h.mu.RLock()
defer h.mu.RUnlock()
set := make(map[string]bool, len(h.toolDefs))
for _, def := range h.toolDefs {
if def.NoMemory {
set[def.Name] = true
}
}
return set
}
```
### 2.4 StageHost:Import 更新
需 import `sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"`(已有)。
在 SDK README 的 ToolDef 说明中列出新增字段。
---
## 第三阶段:核心逻辑修正
## SDK 公有仓实施状态清单
### 3.1 context.Append:不再修改原文
| 文件 | 操作 | 阶段 | 优先级 | 状态 |
|---|---|---|---|---|
| `homeagentsdk/sdk/plugin_test.go` | `TestToolDefCleaner`/`TestToolDefNoMemory`/`TestToolDefRegisterPreservesNoMemory` | 一 | P0 | ✅ |
| `homeagentsdk/example/qq/plugin.go` | `regTool` 签名扩展 `def sdk.ToolDef` + 各工具 NoMemory/Cleaner | 九 | P1 | ✅(本次补全) |
| `homeagentsdk/example/files/plugin.go` | `files_read` `NoMemory=false` + `Cleaner` | 九 | P1 | ✅ |
| `homeagentsdk/example/browser/plugin.go` | `search/fetch/render` `Cleaner` | 九 | P1 | ✅ |
| `homeagentsdk/example/a2a/plugin.go` | `a2a_query` `Cleaner` | 九 | P1 | ✅ |
| `homeagentsdk/example/bili/plugin.go` | `bili_video` `Cleaner` | 九 | P1 | ✅ |
| `homeagentsdk/example/editdoc/plugin.go` | `edit_document` `NoMemory=true` | 九 | P1 | ✅(本次补全) |
| `homeagentsdk/example/music/plugin.go` | `music_search` `Cleaner` | 九 | P1 | ✅ |
| `homeagentsdk/example/ocr/plugin.go` | `ocr_image` `Cleaner` | 九 | P1 | ✅ |
| `homeagentsdk/example/rss/plugin.go` | `subscribe/unsubscribe/check_now` `NoMemory=true` | 九 | P1 | ✅(本次补全) |
| `homeagentsdk/tools/plugindev/templates.go` | `tmplPluginGo` 展示 `NoMemory` + `Cleaner` | 十 | P2 | ✅(本次补全) |
| `homeagentsdk/tools/plugindev/...` | mock 调试框架 | 十 | P2 | ❌ |
| `docs/zh/PLUGIN_DEV.md` | 注册工具示例展示 NoMemory/Cleaner + 独立说明章节 | 十一 | P3 | ✅(本次补全) |
| `docs/en/PLUGIN_DEV.md` | 同上(英文版) | 十一 | P3 | ✅(本次补全) |
| `docs/zh/ARCHITECTURE.md` | 补充 ToolDef 新字段描述 | 十一 | P3 | ✅(本次补全) |
| `docs/en/ARCHITECTURE.md` | 同上(英文版) | 十一 | P3 | ✅(本次补全) |
| `homeagentsdk/README.md` / `README_EN.md` | 在 ToolDef 说明中列出新增字段 | 十一 | P3 | ✅(本次补全) |
**文件:** `internal/agent/core/context.go`
```go
func (c *RelevanceContext) Append(evt ContextEvent) {
c.mu.Lock()
defer c.mu.Unlock()
// evt.Input = memory.CleanText(evt.Input) // ← 删除:不修改原文
evt.Vector = c.computeVector(&evt) // 向量化仍使用 textForVector
c.events = append(c.events, &evt)
c.save()
}
```
### 3.2 textForVector:计算层使用 Cleaner
```go
func textForVector(evt *ContextEvent, toolDefLookup func(name string) *sdk.ToolDef) string {
text := ""
switch {
case evt.Source == "agent" && evt.Response != "":
text = evt.Response
case evt.Source == "cold_storage":
text = evt.Input + " " + evt.Response
default:
text = evt.Input
}
// 基础清洗(不修改原文)
text = memory.CleanText(text)
// 若 evt 关联了 NoMemory 工具,不再跳过整个事件
// 但若工具注册了 Cleaner,在计算层过滤
// textForVector 是对整个事件的向量化,不拆到工具级别
return text
}
```
关键变更:
- `textForVector` 的入参增加 `toolDefLookup`(通过 `RelevanceContext.toolDefLookup` 已有)
- 移除 `textForVector` 对 `memory.CleanText` 的依赖(因为 `CleanText` 不再做插件级过滤)
- `computeVector` 传入 `toolDefLookup`
### 3.3 context.Prune:不再跳过 NoMemory 事件
```go
func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *document.Store) int {
// ... 前面的排序逻辑不变 ...
archived := 0
if docStore != nil && len(archive) > 0 {
// 删除 hasNoMemoryTool 跳过整条的逻辑
// for _, s := range archive {
// if hasNoMemoryTool(s.event.ToolsUsed, c.toolDefLookup) {
// continue
// }
// }
// 改为:所有事件都归档,工具级过滤在 ContextToDoc 内部处理
entries := make([]document.ContextEntry, len(archive))
for i, s := range archive {
entries[i] = document.ContextEntry{
Timestamp: s.event.Timestamp,
Source: s.event.Source,
Content: s.event.Input,
Response: s.event.Response,
}
}
doc, err := docStore.ContextToDoc("context_archived", entries, c.embedder)
if err == nil && doc != nil {
archived = len(entries)
}
}
// ...
}
```
删除 `hasNoMemoryTool` 辅助函数(`context.go:269-278`),迁移到 StageHost 的 `NoMemoryToolNames()`。
### 3.4 eventloop:移除 NoMemory 跳过记忆候选
**文件:** `internal/agent/core/eventloop.go`
```go
func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
// ... 前面的逻辑不变 ...
// 删除 NoMemory 跳过:
// if !stageCtx.NoMemory && !a.hasNoMemoryTool(toolsUsed) {
// a.emitMemoryCandidate(evt.Source, input, response, toolsUsed)
// }
// 改为:始终 emit,工具过滤在消费端处理
// 但保留 stageCtx.NoMemory(IO 注入的 no_memory flag)
if !stageCtx.NoMemory {
a.emitMemoryCandidate(evt.Source, input, response, toolsUsed)
}
}
```
同理修改 `processMediaInput` 中的对应检查。
删除 `hasNoMemoryTool` 方法(`eventloop.go:389-396`)。
### 3.5 main.go:删除 SetTextCleaner
**文件:** `cmd/homed/main.go`
```go
// 删除:
// memory.SetTextCleaner(pluginReg.CleanText)
```
### 3.6 context.go:textForVector 签名更新
`computeVector` 需传入 `toolDefLookup`:
```go
func (c *RelevanceContext) computeVector(evt *ContextEvent) vector.Vector {
return c.embedder.Vectorize(textForVector(evt, c.toolDefLookup))
}
```
`c.toolDefLookup` 已有(通过 `SetToolDefLookup` 注入)。
---
## 第四阶段:文档记忆与蒸馏修正
### 4.1 document.ContextToDoc:接收 cleaned entries
**文件:** `internal/memory/document/document.go`
```go
// ContextToDoc 入参增加 cleanFn,在 summarizeEntries/extractTags/extractEntities 前过滤
func (s *Store) ContextToDoc(source string, entries []ContextEntry, vec vector.Vectorizer, cleanFn func(string) string) (*Doc, error) {
if len(entries) == 0 {
return nil, nil
}
var parts []string
for _, e := range entries {
// Content 用 cleanFn 过滤后拼接,原文保留
cleaned := e.Content
if cleanFn != nil {
cleaned = cleanFn(e.Content)
}
line := fmt.Sprintf("[%s] %s: %s", e.Timestamp.Format("15:04"), e.Source, cleaned)
if e.Response != "" {
line += fmt.Sprintf(" → %s", truncate(e.Response, 100))
}
parts = append(parts, line)
}
// ... 后续不变
}
```
### 4.2 context.Prune:传入 cleaner
```go
// 在传 entries 给 ContextToDoc 前,先构建工具→cleaner 映射
toolCleaners := make(map[string]func(string)string)
for _, s := range archive {
for _, name := range s.event.ToolsUsed {
if c.toolDefLookup != nil {
if def := c.toolDefLookup(name); def != nil && def.Cleaner != nil {
toolCleaners[name] = def.Cleaner
}
}
}
}
// 构建清理函数:对所有工具输出依次应用对应 Cleaner
entryCleanFn := func(text string) string {
// 此处 text 是 Content(用户输入),不含工具输出,所以不需要 Cleaner
// Cleaner 在 distill.go 的 docToTriples 中使用
return text
}
```
实际上,`ContextToDoc` 中 `Content` 是用户输入,不包含工具输出。工具输出过滤主要发生在 `docToTriples`。
### 4.3 docToTriples:应用 Cleaner
**文件:** `internal/agent/core/distill.go`
```go
func docToTriples(doc *document.Doc, toolCleaners map[string]func(string) string) []memory.Triple {
var triples []memory.Triple
if doc == nil {
return triples
}
triples = append(triples, memory.Triple{...})
lines := strings.Split(doc.Content, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// 应用 Cleaner(如果匹配工具输出行)
// 实际 doc.Content 是 `[时间] source: content → response` 格式,
// 其中 content 是用户输入,不直接包含工具输出
// Cleaner 在此暂不应用,保留后续扩展
terms := memory.CutExact(line)
// ...
}
// ...
}
```
注意:`Doc.Content` 中的内容是用户在 Prune 时输入的文本和 LLM 回复,不包含原始工具输出。工具输出在 `msgs` 中(LLM 对话历史),但不在 `Doc.Content` 中。因此 `docToTriples` 不需要直接应用 Cleaner。
---
## 第五阶段:NoMemory 语义修正
### 5.1 NoMemory 的新语义
| 场景 | 旧行为 | 新行为 |
|---|---|---|
| `emitMemoryCandidate` | `hasNoMemoryTool` → 跳过 | 始终写入 text memory + pipeline |
| `context.Prune` → `ContextToDoc` | `hasNoMemoryTool` → 跳过 | 全部归档,统一进入文档记忆 |
| `textForVector` | `CleanText` 改原文后向量化 | 基础 Trim + 向量化(原文不变) |
| `docToTriples` | 无 Cleaner 直接 jieba | 无 Cleaner 直接 jieba(同理) |
| `StageContext.NoMemory` | `InjectTextNoMemory` → 整轮跳过 | 保留(IO 层控制的整轮跳过) |
NoMemory 的声明式语义变为:
- `NoMemory=true` 是一个**工具元数据标记**,当前不在内核层面做特殊跳过
- 为后续精确过滤(如管道层跳过 NoMemory 工具的输出)预留标记
- 未来管道增强时可读取此标记,跳过对应工具输出片段
### 5.2 内置插件标记更新
**文件:** `internal/plugins/cmd/plugin.go`
```go
s.RegisterTool("cmd_run", sdk.ToolDef{
Name: "cmd_run",
Description: "执行 Shell 命令...",
NoMemory: true, // 保留:标记工具输出对 LLM 注意力无信号价值
// Cleaner: nil, // 不注册 Cleaner:命令输出噪音不可控
}, p.handleCmdRun)
```
**文件:** `internal/plugins/agentcli/plugin.go`
```go
s.RegisterTool("terminal_create", sdk.ToolDef{
Name: "terminal_create",
NoMemory: true, // 保留:终端交互噪音
}, p.handleCreate)
// 同理其它 5 个工具
```
**文件:** `internal/plugins/files/plugin.go`(示例插件 `_sdk_local/example/files/plugin.go`)
```go
s.RegisterTool(tp+"read", sdk.ToolDef{
Name: tp + "read",
NoMemory: false, // 明确 false:文件内容对 LLM 注意力有信号价值
Cleaner: func(output string) string {
var r struct{ Content string }
if err := json.Unmarshal([]byte(output), &r); err != nil {
return output
}
return r.Content // 去 JSON 包裹,供未来向量化使用
},
}, p.handleRead)
```
---
## 第六阶段:代码清理
### 6.1 删除无用代码
| 文件 | 删除内容 |
|---|---|
| `internal/plugin/registry.go` | `textCleaners` 字段、`CleanText()` 方法、相关的 append 逻辑 |
| `internal/memory/clean_text.go` | `globalTextCleaner`、`SetTextCleaner()` |
| `internal/agent/core/context.go` | `hasNoMemoryTool()` 辅助函数 |
| `internal/agent/core/eventloop.go` | `hasNoMemoryTool()` 方法 |
| `cmd/homed/main.go` | `memory.SetTextCleaner(pluginReg.CleanText)` |
### 6.2 _sdk_local 清理
| 文件 | 操作 |
|---|---|
| `_sdk_local/sdk/plugin.go` | `ToolDef` 增 `Cleaner`;删除 `RegisterTextCleaner`/`TextCleaners`/`textCleaners` |
| `_sdk_local/sdk/plugin_test.go` | 替换 TextCleaner 测试为 Cleaner 测试 |
---
## 涉及文件清单
| 文件 | 操作 | 阶段 |
|---|---|---|
| `_sdk_local/sdk/plugin.go` | `ToolDef` 增 `Cleaner`;删 `RegisterTextCleaner`/`TextCleaners` | 一 |
| `_sdk_local/sdk/plugin_test.go` | 更新测试 | 一 |
| `internal/plugin/registry.go` | 删 textCleaners + CleanText | 二 |
| `internal/memory/clean_text.go` | 删 globalTextCleaner + SetTextCleaner | 二 |
| `internal/agent/core/stages.go` | 增 `ToolDefCleaner` + `NoMemoryToolNames` | 二 |
| `internal/agent/core/context.go` | `Append` 不改原文;`Prune` 不跳 NoMemory;`textForVector` 入参 toolDefLookup | 三 |
| `internal/agent/core/eventloop.go` | 删 `hasNoMemoryTool` 调用 + 方法 | 三 |
| `internal/agent/core/distill.go` | `emitMemoryCandidate` 不加过滤(已在 eventloop 处理) | 三 |
| `internal/memory/document/document.go` | `ContextToDoc` 可选 cleanFn 参数 | 四 |
| `internal/plugins/cmd/plugin.go` | 确认 NoMemory=true | 五 |
| `internal/plugins/agentcli/plugin.go` | 确认 NoMemory=true | 五 |
| `_sdk_local/example/files/plugin.go` | 示例 Cleaner 注册 | 五 |
| `cmd/homed/main.go` | 删 `memory.SetTextCleaner(pluginReg.CleanText)` | 六 |
---
## 实施顺序
### 实施顺序
```
阶段一 (SDK 定义) → 阶段二 (内部适配删除) → 阶段三 (核心逻辑)
↓
阶段六 (代码清理) ← 阶段五 (NoMemory 语义) ← 阶段四 (文档记忆)
P0 (单元测试) ── 已完成 ✅
P1 (示例插件) ── 全部完成 ✅
P2 (生成模板) ── tmplPluginGo 已完成 ✅,mock 框架待实施 ❌
P3 (文档) ── 全部完成 ✅
```
### Step-by-step 实施步骤
1. **`_sdk_local/sdk/plugin.go`** — `ToolDef` 加 `Cleaner` 字段;删除 `RegisterTextCleaner`/`TextCleaners`/`textCleaners` 字段及方法
2. **`_sdk_local/sdk/plugin_test.go`** — 更新测试用例
3. **`internal/plugin/registry.go`** — 删除 `textCleaners`、`CleanText()`、收集逻辑
4. **`internal/memory/clean_text.go`** — 删除 `globalTextCleaner`、`SetTextCleaner`
5. **`internal/agent/core/stages.go`** — 增 `ToolDefCleaner`、`NoMemoryToolNames`
6. **`internal/agent/core/context.go`** —
- `Append`: 删除 `evt.Input = memory.CleanText(evt.Input)`
- `textForVector`: 入参加 `toolDefLookup`
- `computeVector`: 传入 `c.toolDefLookup`
- `Prune`: 删除 `hasNoMemoryTool` 跳过逻辑
- 删除 `hasNoMemoryTool` 辅助函数
- `load()`: 不再对已加载事件调用 `CleanText`
7. **`internal/agent/core/eventloop.go`** —
- `processTextInput`: `emitMemoryCandidate` 前删 `!a.hasNoMemoryTool(toolsUsed)` 条件
- `processMediaInput`: 同上
- 删除 `hasNoMemoryTool` 方法
8. **`internal/memory/document/document.go`** — `ContextToDoc` 入参加 `cleanFn`
9. **`cmd/homed/main.go`** — 删除 `memory.SetTextCleaner(pluginReg.CleanText)`
10. **内置插件** — 确认 NoMemory 标记,示例插件加 Cleaner
11. **编译测试** — `go build ./...` 确认无编译错误
---
## 验证方法
### 编译检查
```bash
go build ./...
go vet ./...
```
### 单元测试
```bash
# SDK 测试
cd _sdk_local && go test ./sdk/...
# 内核测试
cd /home/program/TrueAgent && go test ./internal/agent/core/...
go test ./internal/memory/...
go test ./internal/plugin/...
```
### 行为验证
**场景 1:NoMemory 工具调用后记忆仍然产生**
1. 用户输入:"查一下 /etc/passwd"
2. Agent 调用 `cmd_run`(NoMemory=true)
3. LLM 回复:"第一行是 root,uid=0,超级管理员哦~"
4. ✅ `textMem.Append(Event{Response: "第一行是 root..."})` — 写入
5. ✅ `distiller.Append("assistant", "第一行是 root...")` — 写入
6. ✅ `context.Append(ContextEvent{Response: "第一行是 root..."})` — 可见
**场景 2:Cleaner 仅影响计算层**
1. Agent 调用 `files_read` 返回 `{"content": "敏感数据"}`
2. ✅ 原文 `msgs` 中保留 `{"content": "敏感数据"}`
3. ✅ LLM 回复中可见原始内容
4. `textForVector` 使用 Cleaner 提取 `content` 字段(注:实际 textForVector 对 Response 操作,Response 已是 LLM 的自然语言,不是 JSON 包裹。Cleaner 在工具输出进入 msgs 时注释即可。)
实际上,Cleaner 的调用时机需要斟酌。工具输出通过 `executeToolCallInner` 返回字符串,进入 `msgs` 中的 `role: "tool"` 消息。`msgs` 用于 LLM 上下文,不需要 Cleaner。Cleaner 是在"工具输出单独进入记忆计算"时才需要。
当前架构中,工具输出不单独进入记忆计算,而是通过 LLM 的回复间接影响记忆。所以 Cleaner 的实际用途是**预留扩展**:当未来有直接对工具输出进行向量化/摘要的环节时,使用 Cleaner 过滤。
### 回归测试
- 确认旧有 `InjectTextNoMemory`(IO 层整轮跳过,通过 `stageCtx.NoMemory` 控制)不受影响
- 确认 context prune 不再因 NoMemory 工具跳过低相关性事件归档
- 确认 text memory 日志完整记录所有对话轮次

155
review.md
View File

@ -1,5 +1,11 @@
# 记忆系统审查:NoMemory 与 TextCleaner 设计偏差
> **审查日期:** 2026-07-25
> **审查范围:** 核心仓 `homeagent/`(`internal/agent/core/`、`internal/memory/`、`internal/plugins/`、`internal/plugin/`、`cmd/homed/`)及 SDK 仓 `homeagentsdk/`(`sdk/`、`example/`、`tools/`)
> **当前状态:** 核心层全部修复完成 ✅,SDK 示例插件及模板 **全部更新完成** ✅
---
## 一、核心原则
**Context 和 Document 层始终保留原始文本。** Cleaner 和 NoMemory 不修改原文,只控制文本在**计算层**(向量化、jieba 分词、蒸馏)中的参与方式。原文完整性是 LLM 注意力分配的基础——清洗掉工具特征输出会干扰 LLM 对上下文的理解。
@ -166,11 +172,11 @@ NoMemory 在三层计算中的语义:
## 四、设计对照表
| 机制 | 当前实现 | 应然设计 |
| 机制 | 旧实现(已废弃) | 当前实现(已修复) |
|---|---|---|
| `RegisterTextCleaner` | 插件级,`memory.CleanText` 入口直接改原文 | **删除**,拆为 `ToolDef.Cleaner` |
| `ToolDef.Cleaner` | 不存在 | 工具级,仅计算层生效,不改原文 |
| `NoMemory=true` | `hasNoMemoryTool` 二值 → 整轮跳过 | 工具输出不参与向量/jieba/蒸馏,原文保留 |
| `RegisterTextCleaner` | 插件级,`memory.CleanText` 入口直接改原文 | **已删除**,拆为 `ToolDef.Cleaner` ✅ |
| `ToolDef.Cleaner` | 不存在 | 工具级字段,`textForVector`/`summarizeEntries`/`extractTags`/`extractEntities` 中调用 ✅ |
| `NoMemory=true` | `hasNoMemoryTool` 二值 → 整轮跳过 | `textForVector` 跳过对应 `ToolResults` 条目,原文保留 ✅ |
### 决策矩阵
@ -195,25 +201,126 @@ NoMemory 在三层计算中的语义:
---
## 五、影响范围
## 五、影响范围(已实施)
| 层次 | 文件 | 改动 |
### 5.1 核心仓(已全部修复 ✅)
| 层次 | 文件 | 改动 | 状态 |
|---|---|---|---|
| **SDK 适配层** | `internal/sdk/plugin.go` | 类型别名 `ToolDef = pubsdk.ToolDef` 透传 `NoMemory`/`Cleaner` | ✅ |
| **Registry** | `internal/plugin/registry.go` | 移除 `textCleaners` 收集;移除 `CleanText` 方法;`buildSDK` 不再收集 cleaner | ✅ |
| **全局 CleanText** | `internal/memory/clean_text.go` | 移除 `globalTextCleaner`/`SetTextCleaner`;仅保留 TrimSpace 等基础清洗 | ✅ |
| **main** | `cmd/homed/main.go` | 移除 `memory.SetTextCleaner(pluginReg.CleanText)` | ✅ |
| **StageHost** | `internal/agent/core/stages.go` | 新增 `ToolDefCleaner()`/`NoMemoryToolNames()` | ✅ |
| **ContextEvent** | `internal/agent/core/context.go:24-32` | 新增 `ToolResults []ToolResultItem` 字段 | ✅ |
| **process 返回值** | `internal/agent/core/process.go:18` | 新增 `toolResults []ToolResultItem` 返回值;line 229 收集 | ✅ |
| **textForVector** | `internal/agent/core/context.go:78-111` | 遍历 `evt.ToolResults`:NoMemory 跳过,其余经 Cleaner 过滤后拼入 | ✅ |
| **Append** | `internal/agent/core/context.go:132-140` | 不再调用 `CleanText` 修改原文 | ✅ |
| **Prune** | `internal/agent/core/context.go:173-250` | 不再 `hasNoMemoryTool` 跳过;`ToolResults` 传入 `ContextEntry` | ✅ |
| **eventloop** | `internal/agent/core/eventloop.go` | `context.Append`/`emitMemoryCandidate` 传入 `toolResults`;删除 `hasNoMemoryTool` | ✅ |
| **emitMemoryCandidate** | `internal/agent/core/distill.go:380-390` | 签名扩展传 `toolResults`;payload 含 `tool_results` | ✅ |
| **ContextToDoc** | `internal/memory/document/document.go:128-210` | 可选 `cleanFn` 参数;`summarizeEntries`/`extractTags`/`extractEntities` 消费 ToolResults | ✅ |
| **ContextEntry** | `internal/memory/document/document.go:434-440` | 新增 `ToolResults []ToolResultItem` | ✅ |
| **内置插件 cmd** | `internal/plugins/cmd/plugin.go:110-114` | `cmd_run`: `NoMemory=true` | ✅ |
| **内置插件 agentcli** | `internal/plugins/agentcli/plugin.go` | 6 个工具: `NoMemory=true` | ✅ |
| **内置插件 files** | `internal/plugins/files/plugin.go:62-72` | `files_read`: `NoMemory=false` + `Cleaner` 去 JSON 包裹 | ✅ |
### 5.2 SDK 公有仓(全部更新完成 ✅)
| 层次 | 文件 | 改动 | 状态 |
|---|---|---|---|
| **ToolDef 定义** | `homeagentsdk/sdk/plugin.go:90-97` | `NoMemory bool` + `Cleaner func(string) string` | ✅ |
| **版本号** | `homeagentsdk/meta/meta.go:8` | `v0.7.1` → `v0.8.0` | ✅ |
| **单元测试** | `homeagentsdk/sdk/plugin_test.go` | 已含 `TestToolDefCleaner`/`TestToolDefNoMemory`/`TestToolDefRegisterPreservesNoMemory` | ✅ |
| **示例插件** | `homeagentsdk/example/qq/plugin.go` | `regTool` 签名已扩展为 `def sdk.ToolDef`;12 查询工具 `NoMemory=false`(含 Cleaner 6 个),6 操作工具 `NoMemory=true` | ✅ |
| **示例插件** | `homeagentsdk/example/files/plugin.go` | `files_read` `NoMemory=false` + `Cleaner` | ✅ |
| **示例插件** | `homeagentsdk/example/browser/plugin.go` | `search/fetch/render` 加 `Cleaner` | ✅ |
| **示例插件** | `homeagentsdk/example/a2a/plugin.go` | `a2a_query` 加 `Cleaner` | ✅ |
| **示例插件** | `homeagentsdk/example/bili/plugin.go` | `bili_video` 加 `Cleaner` | ✅ |
| **示例插件** | `homeagentsdk/example/editdoc/plugin.go` | `edit_document` `NoMemory=true` | ✅ |
| **示例插件** | `homeagentsdk/example/music/plugin.go` | `music_search` 加 `Cleaner` | ✅ |
| **示例插件** | `homeagentsdk/example/ocr/plugin.go` | `ocr_image` 加 `Cleaner` | ✅ |
| **示例插件** | `homeagentsdk/example/rss/plugin.go` | `subscribe/unsubscribe/check_now` `NoMemory=true` | ✅ |
| **生成模板** | `homeagentsdk/tools/plugindev/templates.go` | `tmplPluginGo` 展示 `NoMemory` + `Cleaner`(注释) | ✅ |
---
## 六、重构后二次审查:工具输出未接入记忆管道(已修复 ✅)
> **原始发现(历史记录):** 前一 agent 只改了"删除坏逻辑"(删 TextCleaner、加字段),没改"接入好逻辑"。
> **当前状态:** 以下 7 项缺陷已在后续迭代中全部修复。详情参见 `plan.md §7`。
### 6.1 审查背景(历史)
前一 agent 按 `plan.md` 实施了重构。审查发现:**删旧代码的工作完成,但"接新数据流"的工作未做**。Cleaner 和 NoMemory 的消费端全是空壳。
### 6.2 修复后数据流(当前现状 ✅)
```
process.go:228 result = a.executeToolCall(tc)
│
├──→ msgs (line 248) ← LLM 对话上下文
│
├──→ toolResults = append(...) ← ✅ 已收集到返回值
│
└──→ return (response, toolsUsed, toolResults)
eventloop.go:328-335
a.context.Append(ContextEvent{
Input: input,
Response: response,
ToolsUsed: toolsUsed,
ToolResults: toolResults, ← ✅ 已传入
})
→ computeVector → textForVector
→ 遍历 ToolResults, NoMemory 跳过, Cleaner 过滤 ✅
emitMemoryCandidate(source, input, response, toolResults, toolsUsed) ✅
Prune → ContextToDoc:
ContextEntry.ToolResults → summarizeEntries/extractTags/extractEntities ✅
```
### 6.3 修复清单(7 项断点全部修复 ✅)
| # | 位置 | 原缺陷 | 修复状态 |
|---|---|---|---|
| **1** | `context.go:19-26` `ContextEvent` | 缺 `ToolResults` 字段 | ✅ `ToolResults []ToolResultItem` 已新增 |
| **2** | `process.go:18` 返回值签名 | 没返回工具输出 | ✅ 签名增加 `toolResults []ToolResultItem` |
| **3** | `process.go:228` 工具执行后 | 未收集到返回值 | ✅ `toolResults = append(toolResults, ...)` |
| **4** | `eventloop.go:327-333` `context.Append` | 工具输出未进存储层 | ✅ 传入 `ToolResults` |
| **5** | `context.go:72-82` `textForVector` | `_ = toolDefLookup` 空壳 | ✅ 遍历 ToolResults,应用 Cleaner/NoMemory |
| **6** | `distill.go:380-389` `emitMemoryCandidate` | 没传工具输出 | ✅ 签名扩展为 `(..., toolResults, toolsUsed)` |
| **7** | `context.go:200-210` `Prune→ContextToDoc` | 归档时工具输出丢失 | ✅ `ContextEntry.ToolResults` + `convertToolResults()` |
### 6.4 修复后数据流示例
```
用户: "服务器上 Python 文件有哪些?"
→ process()
→ executeToolCall("files_read")
→ result = "main.py, utils.py, deploy.py"
→ toolResults = [{Name:"files_read", Output:"main.py, utils.py, deploy.py"}]
→ LLM 回复 "有好几个呢~"
→ return (response, toolsUsed, toolResults)
→ context.Append({..., ToolResults: [{Name:"files_read", Output:"..."}]})
→ computeVector → textForVector
→ "有好几个呢~ deploy.py, main.py, utils.py" (Cleaner 去 JSON 包裹)
→ Vectorize → 向量包含工具输出内容 ✅
→ emitMemoryCandidate(input, response, toolResults, toolsUsed)
→ textMem: 记录了工具输出 ✅
用户: "deploy.py 在哪个目录?"
→ context.Prune → 语义检索匹配到 deploy.py ✅
```
### 6.5 修复验证
| 场景 | 行为 | 状态 |
|---|---|---|
| **SDK** | `_sdk_local/sdk/plugin.go` | `ToolDef` 新增 `Cleaner func(string) string`;移除 `RegisterTextCleaner` / `TextCleaners` / `textCleaners` |
| **SDK** | `_sdk_local/sdk/plugin_test.go` | 移除 TextCleaner 测试,新增 NoMemory/Cleaner 组合测试 |
| **Registry** | `internal/plugin/registry.go` | 移除 `textCleaners` 收集逻辑;移除 `CleanText` 方法;构建 `StageHost` 时传入工具的 Cleaner 映射 |
| **全局 CleanText** | `internal/memory/clean_text.go` | 移除 `globalTextCleaner` / `SetTextCleaner`;`CleanText` 只保留 TrimSpace 等基础清洗 |
| **main** | `cmd/homed/main.go` | 移除 `memory.SetTextCleaner(pluginReg.CleanText)` |
| **内核入口** | `internal/agent/core/process.go:228` | 工具返回结果后,结果原文进 `msgs`,同时 `Cleaner(text)` 结果进后续记忆管道 |
| **工具调度** | `internal/agent/core/toolcall.go` | `executeToolCallInner` 返回值额外返回 cleaned 版本(或通过 `StageHost.ToolDef(name).Cleaner` 延迟计算) |
| **Context 向量** | `internal/agent/core/context.go:73-86` | `textForVector` 从 `stageHost` 获取 Cleaner,对文本做计算层过滤后再 `Vectorize` |
| **Context Prune** | `internal/agent/core/context.go:144-215` | 不再 `hasNoMemoryTool` 跳过整条;改为只传 Cleaner 过滤后的文本给 `docStore.ContextToDoc` |
| **Context Append** | `internal/agent/core/context.go:102-111` | `computeVector` 之前对 `Input`/`Response` 走 Cleaner 过滤,原文不修改 |
| **记忆候选** | `internal/agent/core/eventloop.go:337` | 不跳过 `emitMemoryCandidate`;`emitMemoryCandidate` 同时传出原始和 cleaned 版本 |
| **Document** | `internal/memory/document/document.go:132-200` | `ContextToDoc` 接收 cleaned 文本用于 `summarizeEntries`/`extractTags`/`extractEntities`/向量计算,`Doc.Content` 原文不变 |
| **Document→Graph** | `internal/agent/core/distill.go:332-378` | `docToTriples` 对每行走 Cleaner 后再 `CutExact`;跳过 NoMemory 工具输出行 |
| **Pipeline** | `internal/memory/pipeline/pipeline.go:235` | `distillBatch` 跳过 NoMemory 工具输出片段 |
| **StageHost** | `internal/agent/core/stages.go` | 新增 `ToolDefCleaner(name string) func(string) string` 查询 |
| **内置插件** | `internal/plugins/cmd/plugin.go` | `cmd_run`: `NoMemory=true`,不注册 Cleaner(噪音不可控) |
| **内置插件** | `internal/plugins/agentcli/plugin.go` | 6 个工具各注册专用 Cleaner + `NoMemory=true`(输出仍含不可控噪音,但 Cleaner 提取有价值信号) |
| **内置插件** | `internal/plugins/files/plugin.go` | `files_read/edit` 注册 Cleaner 截断长文本、去 JSON 包裹,正常记忆(NoMemory=false 或移除) |
| NoMemory 工具 (`cmd_run`) | 工具输出进 `ContextEvent.ToolResults`,但 `textForVector` 跳过;LLM 回复正常向量化 | ✅ |
| Cleaner 工具 (`files_read`) | `ToolResults[0].Output` 保留原文 JSON,`textForVector` 中 Cleaner 提取 `content` 字段后参与向量化 | ✅ |
| Prune 归档 | 工具输出通过 `ContextEntry.ToolResults` 传入 `ContextToDoc`,`summarizeEntries`/`extractTags`/`extractEntities` 消费 | ✅ |
| 文档蒸馏 | `Doc.Content` 包含 `[工具] name: output` 行,`docToTriples` 直接 `CutExact`(Cleaner/NoMemory 在此暂未应用,因 doc.Content 不含原始工具输出结构) | ⚠️ 按设计保留 |