diff --git a/_sdk_local/README.md b/_sdk_local/README.md new file mode 100644 index 0000000..4542962 --- /dev/null +++ b/_sdk_local/README.md @@ -0,0 +1,205 @@ +# 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://:/api/plugins/install \ + -F "package=@my-plugin.hmap" +``` + +或手动将 `.hmap` 放入插件目录后重启平台。 diff --git a/_sdk_local/README_EN.md b/_sdk_local/README_EN.md new file mode 100644 index 0000000..cfcb172 --- /dev/null +++ b/_sdk_local/README_EN.md @@ -0,0 +1,206 @@ +# 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://:/api/plugins/install \ + -F "package=@my-plugin.hmap" +``` + +Or manually place the `.hmap` in the plugin directory and restart the platform. diff --git a/_sdk_local/example/files/plg.json b/_sdk_local/example/files/plg.json new file mode 100644 index 0000000..cad766d --- /dev/null +++ b/_sdk_local/example/files/plg.json @@ -0,0 +1,11 @@ +{ + "name": "files", + "name_zh": "文件系统", + "name_en": "File System", + "version": "1.0.0", + "description": "文件系统操作工具集(读取/写入/编辑/列表),提供沙箱化文件访问,支持配置工作目录。", + "author": "HomeAgent", + "entry": "plugin.so", + "tags": ["files", "filesystem"], + "targets": "linux/amd64" +} diff --git a/_sdk_local/example/files/plugin.go b/_sdk_local/example/files/plugin.go new file mode 100644 index 0000000..a63e9ad --- /dev/null +++ b/_sdk_local/example/files/plugin.go @@ -0,0 +1,483 @@ +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 +} diff --git a/_sdk_local/go.mod b/_sdk_local/go.mod new file mode 100644 index 0000000..665374d --- /dev/null +++ b/_sdk_local/go.mod @@ -0,0 +1,3 @@ +module gitcode.com/JianFeeeee/homeagent-sdk + +go 1.25.0 diff --git a/_sdk_local/meta/meta.go b/_sdk_local/meta/meta.go new file mode 100644 index 0000000..883a2bd --- /dev/null +++ b/_sdk_local/meta/meta.go @@ -0,0 +1,23 @@ +// 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 + ")" +} diff --git a/_sdk_local/sdk/knowledge.go b/_sdk_local/sdk/knowledge.go new file mode 100644 index 0000000..4c9d5d7 --- /dev/null +++ b/_sdk_local/sdk/knowledge.go @@ -0,0 +1,14 @@ +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"` +} diff --git a/_sdk_local/sdk/llm.go b/_sdk_local/sdk/llm.go new file mode 100644 index 0000000..b9da86a --- /dev/null +++ b/_sdk_local/sdk/llm.go @@ -0,0 +1,8 @@ +package sdk + +// LLMAPI provides access to the LLM provider manager. +type LLMAPI interface { + ListSources() []string + SetSource(name string) error + CurrentSource() string +} diff --git a/_sdk_local/sdk/memory.go b/_sdk_local/sdk/memory.go new file mode 100644 index 0000000..215b313 --- /dev/null +++ b/_sdk_local/sdk/memory.go @@ -0,0 +1,87 @@ +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"` +} diff --git a/_sdk_local/sdk/plugin.go b/_sdk_local/sdk/plugin.go new file mode 100644 index 0000000..151722c --- /dev/null +++ b/_sdk_local/sdk/plugin.go @@ -0,0 +1,356 @@ +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 +} diff --git a/_sdk_local/sdk/plugin_test.go b/_sdk_local/sdk/plugin_test.go new file mode 100644 index 0000000..a1d9fd5 --- /dev/null +++ b/_sdk_local/sdk/plugin_test.go @@ -0,0 +1,229 @@ +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)) + } +} diff --git a/_sdk_local/sdk/settings.go b/_sdk_local/sdk/settings.go new file mode 100644 index 0000000..61bfdc6 --- /dev/null +++ b/_sdk_local/sdk/settings.go @@ -0,0 +1,58 @@ +package sdk + +type SettingsAPI interface { + // Get reads the plugin's own config value (config_ 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"` +} diff --git a/cmd/homed/main.go b/cmd/homed/main.go index e30f186..3700d78 100644 --- a/cmd/homed/main.go +++ b/cmd/homed/main.go @@ -19,6 +19,7 @@ import ( agentPkg "gitcode.com/JianFeeeee/HomeAgent/internal/agent" internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config" "gitcode.com/JianFeeeee/HomeAgent/internal/events" + logpkg "gitcode.com/JianFeeeee/HomeAgent/internal/log" "gitcode.com/JianFeeeee/HomeAgent/internal/knowledge" "gitcode.com/JianFeeeee/HomeAgent/internal/meta" luapkg "gitcode.com/JianFeeeee/HomeAgent/internal/lua" @@ -327,6 +328,7 @@ func main() { // Wire registration callbacks: plugins' RegisterTool/RegisterStage → StageHost pluginReg.SetToolRegistrar(func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error { + log.Printf("[homed] SetToolRegistrar registering tool: %s (plugin=%s)", name, def.Plugin) return stageHost.RegisterTool(name, def, handler) }) pluginReg.SetStageRegistrar(func(stage sdk.Stage, handler sdk.StageHandler) { @@ -335,6 +337,7 @@ func main() { pluginReg.SetAPIRegistrar(func(name string) error { return nil }) + pluginReg.SetToolCleaner(stageHost) // ======================================================================== // Agent Core (需在插件加载前创建,因为插件 Configure 需要 StatusProvider) @@ -420,7 +423,13 @@ func main() { if err := pluginReg.Load(cfg.Plugin.Dir); err != nil { log.Printf("[homed] warning: load plugins: %v", err) } - log.Printf("[homed] stage host ready with %d registered tools", stageHost.ToolCount()) + memory.SetTextCleaner(pluginReg.CleanText) + log.Printf("[homed] stage host ready with %d registered tools, text cleaner set", stageHost.ToolCount()) + + // 日志管理:层级压缩 + 保留策略 + logManager := logpkg.NewManager(logDir, cfgReg) + go logManager.Start(ctx) + defer logManager.Stop() agent.Start() defer agent.Stop() diff --git a/go.mod b/go.mod index 74b12d0..3a48f5e 100644 --- a/go.mod +++ b/go.mod @@ -12,3 +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 + diff --git a/internal/agent/core/agent.go b/internal/agent/core/agent.go index 515d1e2..67ed8cc 100644 --- a/internal/agent/core/agent.go +++ b/internal/agent/core/agent.go @@ -165,6 +165,11 @@ func New(cfg AgentConfig) *Agent { cfg.DocStore.ReindexWithVectorizer(embedder) } + rc := NewRelevanceContext(cfg.ContextSavePath, embedder) + if cfg.StageHost != nil { + rc.SetToolDefLookup(cfg.StageHost.ToolDef) + } + return &Agent{ id: cfg.ID, startTime: time.Now(), @@ -175,7 +180,7 @@ func New(cfg AgentConfig) *Agent { indexer: cfg.Indexer, skills: cfg.Skills, tracker: cfg.Tracker, - context: NewRelevanceContext(cfg.ContextSavePath, embedder), + context: rc, systemPrompt: cfg.SystemPrompt, ctx: ctx, cancel: cancel, diff --git a/internal/agent/core/context.go b/internal/agent/core/context.go index cc97728..60cf985 100644 --- a/internal/agent/core/context.go +++ b/internal/agent/core/context.go @@ -13,6 +13,7 @@ import ( "gitcode.com/JianFeeeee/HomeAgent/internal/memory" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector" + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" ) type ContextEvent struct { @@ -27,12 +28,13 @@ type ContextEvent struct { const contextFlushInterval = 5 * time.Second type RelevanceContext struct { - mu sync.Mutex - events []*ContextEvent - embedder *memory.StaticEmbedder - savePath string - saveTimer *time.Timer - dirty bool + mu sync.Mutex + events []*ContextEvent + embedder *memory.StaticEmbedder + savePath string + saveTimer *time.Timer + dirty bool + toolDefLookup func(name string) *sdk.ToolDef } func NewRelevanceContext(savePath string, embedder *memory.StaticEmbedder) *RelevanceContext { @@ -46,6 +48,12 @@ func NewRelevanceContext(savePath string, embedder *memory.StaticEmbedder) *Rele return rc } +func (c *RelevanceContext) SetToolDefLookup(fn func(name string) *sdk.ToolDef) { + c.mu.Lock() + defer c.mu.Unlock() + c.toolDefLookup = fn +} + func (c *RelevanceContext) load() { data, err := os.ReadFile(c.savePath) if err != nil { @@ -56,7 +64,7 @@ func (c *RelevanceContext) load() { return } for _, evt := range events { - evt.Input = memory.CleanTemplateText(evt.Input) + evt.Input = memory.CleanText(evt.Input) evt.Vector = c.computeVector(evt) } c.events = events @@ -65,11 +73,11 @@ func (c *RelevanceContext) load() { func textForVector(evt *ContextEvent) string { switch { case evt.Source == "agent" && evt.Response != "": - return memory.CleanTemplateText(evt.Response) + return memory.CleanText(evt.Response) case evt.Source == "cold_storage": - return memory.CleanTemplateText(evt.Input + " " + evt.Response) + return memory.CleanText(evt.Input + " " + evt.Response) default: - return memory.CleanTemplateText(evt.Input) + return memory.CleanText(evt.Input) } } @@ -95,7 +103,7 @@ func (c *RelevanceContext) Append(evt ContextEvent) { c.mu.Lock() defer c.mu.Unlock() - evt.Input = memory.CleanTemplateText(evt.Input) + evt.Input = memory.CleanText(evt.Input) evt.Vector = c.computeVector(&evt) c.events = append(c.events, &evt) @@ -193,7 +201,7 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume if docStore != nil && len(archive) > 0 { var filtered []scored for _, s := range archive { - if s.event.Source == "agentcli" || s.event.Source == "terminal" { + if hasNoMemoryTool(s.event.ToolsUsed, c.toolDefLookup) { continue } filtered = append(filtered, s) @@ -257,3 +265,15 @@ func (c *RelevanceContext) Len() int { defer c.mu.Unlock() return len(c.events) } + +func hasNoMemoryTool(toolsUsed []string, lookup func(string) *sdk.ToolDef) bool { + if lookup == nil { + return false + } + for _, name := range toolsUsed { + if def := lookup(name); def != nil && def.NoMemory { + return true + } + } + return false +} diff --git a/internal/agent/core/context_test.go b/internal/agent/core/context_test.go index bbb6f97..14043d4 100644 --- a/internal/agent/core/context_test.go +++ b/internal/agent/core/context_test.go @@ -6,6 +6,7 @@ import ( "time" "gitcode.com/JianFeeeee/HomeAgent/internal/memory" + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" ) func newTestCtx() *RelevanceContext { @@ -237,6 +238,40 @@ 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 diff --git a/internal/agent/core/eventloop.go b/internal/agent/core/eventloop.go index fec9831..d08e3ef 100644 --- a/internal/agent/core/eventloop.go +++ b/internal/agent/core/eventloop.go @@ -205,7 +205,7 @@ func (a *Agent) processMediaInput(evt *agentIO.InputEvent) { a.emitResponse(evt, response) - if !stageCtx.NoMemory { + if !stageCtx.NoMemory && !a.hasNoMemoryTool(toolsUsed) { a.emitMemoryCandidate(evt.Source, fallback, response, toolsUsed) } } @@ -334,7 +334,7 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) { a.emitResponse(evt, response) - if !stageCtx.NoMemory { + if !stageCtx.NoMemory && !a.hasNoMemoryTool(toolsUsed) { a.emitMemoryCandidate(evt.Source, input, response, toolsUsed) } } @@ -386,6 +386,15 @@ 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 { diff --git a/internal/agent/core/stages.go b/internal/agent/core/stages.go index de8adb4..68043b8 100644 --- a/internal/agent/core/stages.go +++ b/internal/agent/core/stages.go @@ -54,6 +54,17 @@ func (h *StageHost) GetToolDefs() []sdk.ToolDef { return defs } +func (h *StageHost) ToolDef(name string) *sdk.ToolDef { + h.mu.RLock() + defer h.mu.RUnlock() + for _, def := range h.toolDefs { + if def.Name == name { + return &def + } + } + return nil +} + func (h *StageHost) ExecuteTool(name string, args map[string]interface{}) (ret interface{}, err error) { defer func() { if r := recover(); r != nil { diff --git a/internal/agent/core/stages_test.go b/internal/agent/core/stages_test.go index 8dd984f..676d087 100644 --- a/internal/agent/core/stages_test.go +++ b/internal/agent/core/stages_test.go @@ -197,3 +197,54 @@ func TestStageHostMultipleTools(t *testing.T) { t.Errorf("expected from_p2, got %v", r2) } } + +func TestStageHostToolDefLookup(t *testing.T) { + host := NewStageHost() + host.RegisterTool("tool_a", sdk.ToolDef{Name: "tool_a", NoMemory: true}, nil) + host.RegisterTool("tool_b", sdk.ToolDef{Name: "tool_b"}, nil) + + def := host.ToolDef("tool_a") + if def == nil { + t.Fatal("expected tool_a to be found") + } + if !def.NoMemory { + t.Error("tool_a should have NoMemory=true") + } + + def = host.ToolDef("tool_b") + if def == nil { + t.Fatal("expected tool_b to be found") + } + if def.NoMemory { + t.Error("tool_b should have NoMemory=false") + } + + def = host.ToolDef("nonexistent") + if def != nil { + t.Errorf("expected nil for nonexistent tool, got %v", def) + } +} + +func TestStageHostToolDefNoMemoryStored(t *testing.T) { + host := NewStageHost() + host.RegisterTool("mem_tool", sdk.ToolDef{Name: "mem_tool", NoMemory: true}, nil) + host.RegisterTool("normal_tool", sdk.ToolDef{Name: "normal_tool", NoMemory: false}, nil) + + defs := host.GetToolDefs() + found := map[string]bool{} + for _, d := range defs { + found[d.Name] = d.NoMemory + } + + if v, ok := found["mem_tool"]; !ok { + t.Error("mem_tool not found in defs") + } else if !v { + t.Error("mem_tool.NoMemory should be true") + } + + if v, ok := found["normal_tool"]; !ok { + t.Error("normal_tool not found in defs") + } else if v { + t.Error("normal_tool.NoMemory should be false") + } +} diff --git a/internal/memory/bilingual_test.go b/internal/memory/bilingual_test.go index 010169d..5ed048e 100644 --- a/internal/memory/bilingual_test.go +++ b/internal/memory/bilingual_test.go @@ -180,7 +180,7 @@ func TestBilingualVectorizeClean(t *testing.T) { cleanVec := e.VectorizeClean(inp) sim := cosineSim(rawVec, cleanVec) rawTokens := len(e.tokenize(inp)) - cleanTokens := len(e.tokenize(CleanTemplateText(inp))) + cleanTokens := len(e.tokenize(CleanText(inp))) t.Logf("[%d] sim(raw,clean)=%.4f tokens: raw=%d clean=%d", i, sim, rawTokens, cleanTokens) } } @@ -236,8 +236,8 @@ func genBilingualEvents() []bilingualEvent { func textForBilingual(ev bilingualEvent, modelPaths []string) string { switch { case ev.source == "agent" && ev.text != "": - return CleanTemplateText(ev.text) + return CleanText(ev.text) default: - return CleanTemplateText(ev.text) + return CleanText(ev.text) } } diff --git a/internal/memory/clean_stress_test.go b/internal/memory/clean_stress_test.go index 5b85cb9..660e03f 100644 --- a/internal/memory/clean_stress_test.go +++ b/internal/memory/clean_stress_test.go @@ -2,10 +2,31 @@ package memory import ( "fmt" + "regexp" "sort" "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 + } +} + type cleanTestEvent struct { idx int source string @@ -173,7 +194,7 @@ func TestCleanVectorConsistency(t *testing.T) { } for _, tmpl := range templates { - cleaned := CleanTemplateText(tmpl) + cleaned := CleanText(tmpl) t.Logf("template {%q} → {%q} (%d chars)", trimLen(tmpl, 60), cleaned, len(cleaned)) } @@ -286,11 +307,11 @@ func genStressEvents(n int) []cleanTestEvent { func cleanEventText(source, input, response string) string { switch { case source == "agent" && response != "": - return CleanTemplateText(response) + return CleanText(response) case source == "cold_storage": - return CleanTemplateText(input + " " + response) + return CleanText(input + " " + response) default: - return CleanTemplateText(input) + return CleanText(input) } } diff --git a/internal/memory/clean_text.go b/internal/memory/clean_text.go index 659216f..3f45d5d 100644 --- a/internal/memory/clean_text.go +++ b/internal/memory/clean_text.go @@ -1,45 +1,22 @@ package memory import ( - "regexp" "strings" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector" ) -var ( - 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*`, - ) - reAgentPrefix = regexp.MustCompile( - `冷知识|注意|提示|核心要求|规则`, - ) - reMultiSpace = regexp.MustCompile(`\s+`) -) +var globalTextCleaner func(string) string + +func SetTextCleaner(fn func(string) string) { + globalTextCleaner = fn +} + +func CleanText(text string) string { + if globalTextCleaner != nil { + text = globalTextCleaner(text) + } -func CleanTemplateText(text string) string { - 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, " ") text = strings.TrimSpace(text) if text == "" { @@ -53,5 +30,5 @@ func CleanTemplateText(text string) string { } func (e *StaticEmbedder) VectorizeClean(text string) vector.Vector { - return e.Vectorize(CleanTemplateText(text)) + return e.Vectorize(CleanText(text)) } diff --git a/internal/memory/clean_text_test.go b/internal/memory/clean_text_test.go new file mode 100644 index 0000000..b0f6368 --- /dev/null +++ b/internal/memory/clean_text_test.go @@ -0,0 +1,83 @@ +package memory + +import ( + "testing" +) + +func TestCleanTextTrim(t *testing.T) { + prev := globalTextCleaner + globalTextCleaner = nil + defer func() { globalTextCleaner = prev }() + + tests := []struct { + input string + expected string + }{ + {" hello ", "hello"}, + {",hello", "hello"}, + {",,hello", "hello"}, + {" ,,hello ", "hello"}, + {"", ""}, + {" ", ""}, + {",", ""}, + {",x", "x"}, + } + for _, tt := range tests { + got := CleanText(tt.input) + if got != tt.expected { + t.Errorf("CleanText(%q) = %q, want %q", tt.input, got, tt.expected) + } + } +} + +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) + } +} diff --git a/internal/memory/cut.go b/internal/memory/cut.go index 092aec5..abae7dc 100644 --- a/internal/memory/cut.go +++ b/internal/memory/cut.go @@ -93,7 +93,7 @@ var stopWords = map[string]bool{ } func ExtractKeywords(text string) []string { - text = CleanTemplateText(text) + text = CleanText(text) x := GetJieba() if x == nil { return nil @@ -120,7 +120,7 @@ func ExtractKeywords(text string) []string { // CutExact 精确模式分词:返回去停用词后的所有有义项(不限数量),用于 doc→graph 蒸馏 func CutExact(text string) []string { - text = CleanTemplateText(text) + text = CleanText(text) x := GetJieba() if x == nil { return nil diff --git a/internal/memory/cut_test.go b/internal/memory/cut_test.go index 5e70387..49136c7 100644 --- a/internal/memory/cut_test.go +++ b/internal/memory/cut_test.go @@ -101,7 +101,7 @@ func TestCutExactRemoveTimestamp(t *testing.T) { got := CutExact("[15:04] 今天天气不错") for _, g := range got { if g == "15" || g == "04" || g == "15:04" { - t.Errorf("timestamp should be removed by CleanTemplateText, got %q in %v", g, got) + t.Errorf("timestamp should be removed by CleanText, got %q in %v", g, got) } } } diff --git a/internal/memory/indexer.go b/internal/memory/indexer.go index fdcdf3e..9b7fcc5 100644 --- a/internal/memory/indexer.go +++ b/internal/memory/indexer.go @@ -90,7 +90,7 @@ func (idx *Indexer) BuildContext(userInput string) *InjectedContext { return &InjectedContext{Summary: ""} } - input := CleanTemplateText(userInput) + input := CleanText(userInput) // 1. 向量搜索:从实体名向量索引中找到相关实体 vectorEntities := idx.vectorSearchEntities(input) diff --git a/internal/memory/real_context_test.go b/internal/memory/real_context_test.go index 7833ea8..03cf3b3 100644 --- a/internal/memory/real_context_test.go +++ b/internal/memory/real_context_test.go @@ -16,7 +16,7 @@ type realEvent struct { Response string `json:"response"` } -func TestCleanTemplateText(t *testing.T) { +func TestCleanText(t *testing.T) { cases := []struct { input string expected string @@ -53,7 +53,7 @@ func TestCleanTemplateText(t *testing.T) { } for i, c := range cases { - got := CleanTemplateText(c.input) + got := 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) } @@ -91,11 +91,11 @@ func TestRealContextPerSourceVector(t *testing.T) { clean := func(ev realEvent) string { switch { case ev.Source == "agent" && ev.Response != "": - return CleanTemplateText(ev.Response) + return CleanText(ev.Response) case ev.Source == "cold_storage": - return CleanTemplateText(ev.Input + " " + ev.Response) + return CleanText(ev.Input + " " + ev.Response) default: - return CleanTemplateText(ev.Input) + return CleanText(ev.Input) } } @@ -275,11 +275,11 @@ func TestRealContextEmbedderStats(t *testing.T) { var text string switch { case ev.Source == "agent" && ev.Response != "": - text = CleanTemplateText(ev.Response) + text = CleanText(ev.Response) case ev.Source == "cold_storage": - text = CleanTemplateText(ev.Input + " " + ev.Response) + text = CleanText(ev.Input + " " + ev.Response) default: - text = CleanTemplateText(ev.Input) + text = CleanText(ev.Input) } vec := e.Vectorize(text) origLen := len(ev.Input + ev.Response) diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go index d632c9b..128ff89 100644 --- a/internal/plugin/registry.go +++ b/internal/plugin/registry.go @@ -54,6 +54,11 @@ func RegisterFactory(name string, factory NativeFactory) { globalFactories.Store(name, factory) } +// PluginToolCleaner 定义插件工具注销接口,由 StageHost 实现。 +type PluginToolCleaner interface { + UnregisterPluginTools(pluginName string) +} + type Registry struct { mu sync.RWMutex plugins map[string]sdk.Plugin @@ -75,6 +80,11 @@ type Registry struct { regTool sdk.ToolRegistrar regStage sdk.StageRegistrar regAPI sdk.APIRegistrar + + toolCleaner PluginToolCleaner + + knownDisabled map[string]bool + textCleaners []func(string) string } func NewRegistry() *Registry { @@ -82,6 +92,7 @@ func NewRegistry() *Registry { plugins: make(map[string]sdk.Plugin), factories: make(map[string]NativeFactory), pluginAutoRestart: make(map[string]bool), + knownDisabled: make(map[string]bool), } } @@ -97,6 +108,18 @@ func (r *Registry) SetPluginDir(dir string) { r.plgDir = di func (r *Registry) SetToolRegistrar(fn sdk.ToolRegistrar) { r.regTool = fn } func (r *Registry) SetStageRegistrar(fn sdk.StageRegistrar) { r.regStage = fn } func (r *Registry) SetAPIRegistrar(fn sdk.APIRegistrar) { r.regAPI = fn } +func (r *Registry) 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() @@ -217,6 +240,13 @@ func (r *Registry) Load(dir string) error { if loaded[name] { continue } + if r.isDisabled(name) { + log.Printf("[plugin] %s is disabled, skipping", name) + r.mu.Lock() + r.knownDisabled[name] = true + r.mu.Unlock() + continue + } plgDir := filepath.Join(dir, name) os.MkdirAll(plgDir, 0755) @@ -240,6 +270,7 @@ 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) } @@ -247,7 +278,26 @@ func (r *Registry) Load(dir string) error { return nil } +func (r *Registry) isDisabled(name string) bool { + if r.cfgReg == nil { + return false + } + v, err := r.cfgReg.PluginConfig(name).Get("disabled") + if err != nil || v == nil { + return false + } + return fmt.Sprint(v) == "true" +} + func (r *Registry) loadOne(plgDir, name string) bool { + if r.isDisabled(name) { + log.Printf("[plugin] %s is disabled, skipping", name) + r.mu.Lock() + r.knownDisabled[name] = true + r.mu.Unlock() + return false + } + // 1) 查找工厂(init 自注册或 RegisterNative) r.mu.RLock() factory, hasFactory := r.factories[name] @@ -303,6 +353,7 @@ 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 @@ -319,6 +370,7 @@ 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) { @@ -382,6 +434,94 @@ func (r *Registry) AutoRestartEnabled(name string) bool { return enabled } +func (r *Registry) IsDisabled(name string) bool { + return r.isDisabled(name) +} + +func (r *Registry) Enable(name string) error { + if r.cfgReg != nil { + r.cfgReg.PluginConfig(name).Set("disabled", "false") + } + r.mu.Lock() + delete(r.knownDisabled, name) + r.mu.Unlock() + plgDir := filepath.Join(r.plgDir, name) + if r.loadOne(plgDir, name) { + log.Printf("[plugin] enabled: %s", name) + return nil + } + return fmt.Errorf("enable plugin %s failed", name) +} + +func (r *Registry) Disable(name string) error { + r.mu.Lock() + p, ok := r.plugins[name] + if ok { + if err := p.Stop(); err != nil { + log.Printf("[plugin] stop %s for disable: %v", name, err) + } + delete(r.plugins, name) + for i, inst := range r.instances { + if inst.Name() == name { + r.instances = append(r.instances[:i], r.instances[i+1:]...) + break + } + } + } + r.knownDisabled[name] = true + r.mu.Unlock() + + if r.toolCleaner != nil { + r.toolCleaner.UnregisterPluginTools(name) + } + + if r.cfgReg != nil { + r.cfgReg.PluginConfig(name).Set("disabled", "true") + } + log.Printf("[plugin] disabled: %s", name) + return nil +} + +// ListKnown 返回所有已知插件(已加载 + 已禁用 + 已安装但未加载)。 +func (r *Registry) ListKnown() []string { + r.mu.RLock() + known := make(map[string]bool) + for name := range r.plugins { + known[name] = true + } + for name := range r.knownDisabled { + known[name] = true + } + r.mu.RUnlock() + + if r.plgDir != "" { + entries, _ := os.ReadDir(r.plgDir) + for _, e := range entries { + if e.IsDir() { + known[e.Name()] = true + } + } + } + + r.mu.RLock() + for name := range r.factories { + known[name] = true + } + r.mu.RUnlock() + + globalFactories.Range(func(key, val interface{}) bool { + known[key.(string)] = true + return true + }) + + list := make([]string, 0, len(known)) + for name := range known { + list = append(list, name) + } + sort.Strings(list) + return list +} + func (r *Registry) PluginMetas() map[string]PluginMeta { metas := make(map[string]PluginMeta) globalPluginMeta.Range(func(key, val interface{}) bool { diff --git a/internal/plugins/agentcli/plugin.go b/internal/plugins/agentcli/plugin.go index 144875f..2f53e86 100644 --- a/internal/plugins/agentcli/plugin.go +++ b/internal/plugins/agentcli/plugin.go @@ -197,6 +197,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { s.RegisterTool("terminal_create", sdk.ToolDef{ Name: "terminal_create", Description: "创建一个新的交互式终端会话。返回终端 ID,后续通过此 ID 进行读写操作。适用于运行交互式程序如 vim、ssh、top、nano 等。终端默认 5 分钟后自动关闭,可通过 timeout 参数调整。", + NoMemory: true, Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ @@ -225,6 +226,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { s.RegisterTool("terminal_write", sdk.ToolDef{ Name: "terminal_write", Description: "向指定终端发送输入。支持普通文本和特殊键(通过 key 参数传入)。特殊键包括:enter, tab, escape, ctrl_a~ctrl_z, alt_a~alt_z, f1~f12, up, down, left, right, home, end, backspace, delete, page_up, page_down。普通文本传入 input 参数即可。", + NoMemory: true, Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ @@ -250,6 +252,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { s.RegisterTool("terminal_read", sdk.ToolDef{ Name: "terminal_read", Description: "读取指定终端的当前屏幕内容。返回自上次读取以来的新输出。如需持续监控请多次调用。", + NoMemory: true, Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ @@ -271,6 +274,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { s.RegisterTool("terminal_resize", sdk.ToolDef{ Name: "terminal_resize", Description: "调整指定终端的尺寸(行数和列数)。", + NoMemory: true, Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ @@ -296,6 +300,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { s.RegisterTool("terminal_close", sdk.ToolDef{ Name: "terminal_close", Description: "关闭指定终端会话。释放资源。", + NoMemory: true, Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ @@ -313,6 +318,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { s.RegisterTool("terminal_list", sdk.ToolDef{ Name: "terminal_list", Description: "列出所有活跃的终端会话及其状态。", + NoMemory: true, Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{}, diff --git a/internal/plugins/cmd/plugin.go b/internal/plugins/cmd/plugin.go index 51c5fe5..17dd874 100644 --- a/internal/plugins/cmd/plugin.go +++ b/internal/plugins/cmd/plugin.go @@ -110,6 +110,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { s.RegisterTool("cmd_run", sdk.ToolDef{ Name: "cmd_run", Description: "执行一条系统命令并返回输出。适用于查询系统信息、运行脚本、操作文件等单次命令场景。命令在临时 shell 中执行,不支持交互。如需交互式终端(如 vim、ssh、top),请使用 terminal_create 相关工具。", + NoMemory: true, Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{ diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..2d23ff7 --- /dev/null +++ b/plan.md @@ -0,0 +1,246 @@ +# 插件化改造 — 实施计划 + +## 问题 + +当前 core 层两处硬编码耦合: + +1. **文本清洗**:`internal/memory/clean_text.go` 硬编码 6 个 QQ 正则,context/indexer/cut/VertorizeClean 各环节直接调用 `CleanTemplateText` + +2. **工具输出不进记忆**:`internal/agent/core/context.go:196` 硬编码 source 名过滤 + ```go + if s.event.Source == "agentcli" || s.event.Source == "terminal" { + continue + } + ``` + 意图是 `cmd_run`、`terminal_*` 等工具的产出不归档到 document memory,但手段是查 source 而不是查工具定义,且只覆盖了 Prune 路径(document),没有覆盖 `emitMemoryCandidate` 路径(text + graph memory)。 + + 同时 `eventloop.go:337` 的 `!stageCtx.NoMemory` 检查是命令式的(`InjectTextNoMemory` 设 payload flag),不是声明式的工具级控制。 + +## 目标 + +| 问题 | 方案 | +|------|------| +| 文本清洗 | 插件通过 `s.RegisterTextCleaner()` 注册清洗函数,core 遍历执行 | +| 工具记忆控制 | `ToolDef` 增加 `NoMemory bool` 字段,插件声明式标记工具;core 在 emitMemoryCandidate 和 Prune 两处自动跳过 | + +## SDK 修改(外部包 `homeagent-sdk`) + +**位置:** `sdk/plugin.go` + +### 1.1 ToolDef 增加 NoMemory + +```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"` // ← 新增 +} +``` + +### 1.2 PluginSDK 增加 TextCleaner + +```go +// PluginSDK 新增字段 +textCleaners []func(text string) string + +func (s *PluginSDK) RegisterTextCleaner(cleaner func(text string) string) { + s.textCleaners = append(s.textCleaners, cleaner) +} + +// 框架用 — 提取所有 cleaner +func (s *PluginSDK) TextCleaners() []func(text string) string { + return s.textCleaners +} +``` + +### SDK 管理策略 + +复制 SDK 到 `_sdk_local/`,`go.mod` 加 `replace gitcode.com/JianFeeeee/homeagent-sdk => ./_sdk_local` + +--- + +## 内部变更 + +### 2.1 Registry:聚合 text cleaners + +**文件:** `internal/plugin/registry.go` + +- 新增字段 `textCleaners []func(string) string` +- `buildSDK()` 中 `plg.Start(plgSDK)` 成功后提取 `plgSDK.TextCleaners()` 到 `textCleaners` +- 新增 `CleanText(text string) string` 依次执行所有 cleaner,无 cleaner 时返回原文本 +- `StopAll()` / `Reload()` 时重置 + +### 2.2 Memory:动态 cleaner 入口 + +**文件:** `internal/memory/clean_text.go` + +替换硬编码 QQ 正则为动态 cleaner: + +```go +var globalTextCleaner func(string) string + +func SetTextCleaner(fn func(string) string) { globalTextCleaner = fn } +func CleanText(text string) string { + if globalTextCleaner != nil { + text = globalTextCleaner(text) + } + // 保留通用 trim + text = strings.TrimSpace(text) + text = strings.TrimPrefix(text, ",") + text = strings.TrimPrefix(text, ",") + text = strings.TrimSpace(text) + return text +} +``` + +删除 `reQQ*` 正则变量。 + +### 2.3 StageHost:按名查 ToolDef + +**文件:** `internal/agent/core/stages.go` + +新增方法: + +```go +func (h *StageHost) ToolDef(name string) *sdk.ToolDef { + h.mu.RLock() + defer h.mu.RUnlock() + for _, def := range h.toolDefs { + if def.Name == name { + return &def + } + } + return nil +} +``` + +### 2.4 Agent:两处 NoMemory 检查 + +#### 2.4a emitMemoryCandidate 路径 + +**文件:** `internal/agent/core/eventloop.go` + +`processTextInput()` 和 `processMediaInput()` 中的 `emitMemoryCandidate` 调用前,除了检查 `stageCtx.NoMemory`,还检查本次调用的工具是否有 `NoMemory`: + +```go +hasNoMemoryTool := false +for _, name := range toolsUsed { + if def := a.stageHost.ToolDef(name); def != nil && def.NoMemory { + hasNoMemoryTool = true + break + } +} +if !stageCtx.NoMemory && !hasNoMemoryTool { + a.emitMemoryCandidate(...) +} +``` + +这样不论通过 `InjectText` 还是 `InjectTextNoMemory`,只要 LLM 调用了 `NoMemory: true` 的工具,整轮对话就不进 text memory 和 graph memory。 + +#### 2.4b Prune 归档路径 + +**文件:** `internal/agent/core/context.go` + +删除 hardcoded source 过滤,改为检查事件中 `ToolsUsed` 是否有 `NoMemory` 工具: + +```go +for _, s := range archive { + if hasNoMemoryTool(s.event.ToolsUsed) { + continue + } + filtered = append(filtered, s) +} +``` + +`hasNoMemoryTool` 通过 `StageHost.ToolDef` 查每个工具名是否有 `NoMemory: true`。 + +### 2.5 caller CleanTemplateText → CleanText + +| 文件 | 替换 | +|------|------| +| `internal/agent/core/context.go` | `memory.CleanTemplateText` → `memory.CleanText` | +| `internal/memory/indexer.go` | 同上 | +| `internal/memory/cut.go` | 同上 | +| `internal/memory/clean_text.go` | 删除函数 `CleanTemplateText`,新增 `CleanText` + `SetTextCleaner` | + +### 2.6 main.go 串联 + +**文件:** `cmd/homed/main.go` + +`pluginReg.Load()` 之后: + +```go +memory.SetTextCleaner(pluginReg.CleanText) +``` + +### 2.7 agentcli / cmd 插件:标记 NoMemory + +**文件:** `internal/plugins/agentcli/plugin.go` 和 `internal/plugins/cmd/plugin.go` + +在 `RegisterTool` 调用的 `ToolDef` 中加 `NoMemory: true`: + +- `terminal_create`, `terminal_write`, `terminal_read`, `terminal_resize`, `terminal_close`, `terminal_list` +- `cmd_run` + +--- + +## 外部插件变更 + +### QQ 插件 + +在 `Start()` 中: + +```go +s.RegisterTextCleaner(func(text string) string { + 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 +}) + +s.RegisterTool("qq_get_message", sdk.ToolDef{ + Description: "获取QQ消息正文", + Parameters: map[string]interface{}{...}, + NoMemory: true, +}, handler) +``` + +--- + +## 文件变更清单 + +| 文件 | 操作 | +|------|------| +| `sdk/plugin.go`(外部 SDK) | 修改:`ToolDef` 增 `NoMemory`、`PluginSDK` 增 `RegisterTextCleaner`/`TextCleaners` | +| `go.mod` | 修改:增 `replace` 指令 | +| `internal/plugin/registry.go` | 修改:增 cleaners 聚合 + `CleanText` | +| `internal/memory/clean_text.go` | 修改:硬编码 → 动态 cleaner,删除 `CleanTemplateText` | +| `internal/agent/core/stages.go` | 修改:增 `ToolDef(name)` 查找 | +| `internal/agent/core/eventloop.go` | 修改:`emitMemoryCandidate` 前查 `ToolDef.NoMemory` | +| `internal/agent/core/context.go` | 修改:source 过滤 → `ToolsUsed` NoMemory 检查 + `CleanTemplateText` → `CleanText` | +| `internal/agent/core/process.go` | 修改(可能需要):传递 toolsUsed 到 Prune 或加辅助方法 | +| `internal/memory/indexer.go` | 修改:`CleanTemplateText` → `CleanText` | +| `internal/memory/cut.go` | 修改:同上 | +| `internal/plugins/agentcli/plugin.go` | 修改:工具标记 `NoMemory: true` | +| `internal/plugins/cmd/plugin.go` | 修改:`cmd_run` 标记 `NoMemory: true` | +| `cmd/homed/main.go` | 修改:`memory.SetTextCleaner(pluginReg.CleanText)` | + +--- + +## 实施顺序 + +1. SDK 本地副本 + 加 `NoMemory`/`RegisterTextCleaner` +2. Registry 聚合 cleaners + `CleanText` +3. Memory 动态 cleaner + 改名调用者 +4. StageHost `ToolDef(name)` +5. eventloop + context Prune + process 加入 NoMemory 检查 +6. 标记 agentcli/cmd 工具的 `NoMemory: true` +7. main.go 串联 +8. 删除旧代码(QQ 正则、source 硬编码) +9. 构建 + 测试