diff --git a/.gitignore b/.gitignore index 8c4a7761..a4466bdd 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,4 @@ data/ .idea/ workspace.xml .vscode/ +.claude/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..8821a29a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,69 @@ +# CLAUDE.md — Claude Code 项目规则 + +本文件是 Claude Code 在此仓库中工作时的行为指引。完整的项目协作规则请参见 `AGENTS.md`。 + +## 项目概览 + +NeoCode Coding Agent MVP — 一个 Go 实现的 AI 编码助手,主链路为: +`用户输入 -> Agent 推理 -> 调用工具 -> 获取结果 -> 继续推理 -> UI 展示` + +## 关键目录 + +| 目录 | 职责 | +|------|------| +| `cmd/neocode` | CLI 入口 | +| `internal/app` | 应用装配与 bootstrap | +| `internal/config` | 配置模型、YAML 加载、校验 | +| `internal/provider` | 模型厂商适配器(差异收敛在此层) | +| `internal/runtime` | ReAct 主循环、事件流、Prompt 编排 | +| `internal/session` | 会话领域模型与 JSON 持久化 | +| `internal/tools` | 工具契约、注册表与具体实现 | +| `internal/tui` | Bubble Tea TUI 状态机与渲染 | +| `internal/context` | 上下文构建、压缩决策 | +| `docs` | 架构与设计文档 | + +## 必须遵守的规则 + +### 架构边界 +- **不跨层直连**:遵循 `TUI -> Runtime -> Provider / Tool Manager` 主链路 +- **不泄漏厂商差异**:模型协议差异收敛在 `internal/provider` 内 +- **不内嵌工具逻辑**:所有可被模型调用的能力必须进入 `internal/tools` +- **不散落状态**:会话状态、消息历史、工具调用记录由 `runtime` 管理 + +### 编码规范 +- Go 惯用风格,制表符缩进,单行约 120 字符 +- `PascalCase` 导出,`camelCase` 未导出 +- 新增函数必须附带中文注释,说明职责与关键行为 +- 不硬编码路径、URL、模型名、超时等,通过配置或常量注入 +- 不硬编码业务语义字符串,收敛到共享常量或类型定义 + +### 安全 +- 不写入明文 API Key +- 配置只保存环境变量名 +- `filesystem` 工具限制在工作目录内 +- `bash` 工具限制超时与输出长度 +- 本地运行数据、会话数据不入库 + +### 测试 +- 整体测试覆盖率以 **100%** 为硬性目标 +- 改动必须同步补齐测试:正常路径 + 边界条件 + 异常分支 + 回归场景 +- 优先覆盖:配置校验、provider 转换、tool 参数校验、runtime 停止条件、事件派发 + +### 文档 +- 沿用目标文档已有语言(中文为主则续用中文) +- 实现与文档冲突时必须修正至少一个 + +### 提交前检查 +- 确认职责分工未被破坏 +- 确认新增能力接到正确层级 +- `go build ./...` && `go test ./...` && `gofmt -w ./cmd ./internal` +- 检查 `git status`,确保无密钥或临时文件混入 + +## 常用命令 + +```bash +go run ./cmd/neocode # 启动应用 +go build ./... # 编译 +go test ./... # 运行测试 +gofmt -w ./cmd ./internal # 格式化 +``` diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 8ef29eb9..7f43e051 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -13,6 +13,7 @@ import ( "neo-code/internal/memo" "neo-code/internal/provider/builtin" providercatalog "neo-code/internal/provider/catalog" + providertypes "neo-code/internal/provider/types" agentruntime "neo-code/internal/runtime" "neo-code/internal/security" agentsession "neo-code/internal/session" @@ -20,6 +21,7 @@ import ( "neo-code/internal/tools/bash" "neo-code/internal/tools/filesystem" "neo-code/internal/tools/mcp" + memotool "neo-code/internal/tools/memo" "neo-code/internal/tools/webfetch" "neo-code/internal/tui" ) @@ -102,6 +104,8 @@ func BuildRuntime(ctx context.Context, opts BootstrapOptions) (RuntimeBundle, er } contextBuilder = agentcontext.NewBuilderWithMemo(toolRegistry, memoSource) memoSvc = memo.NewService(memoStore, nil, cfg.Memo, sourceInvl) + toolRegistry.Register(memotool.NewRememberTool(memoSvc)) + toolRegistry.Register(memotool.NewRecallTool(memoSvc)) } runtimeSvc := agentruntime.NewWithFactory( @@ -112,6 +116,14 @@ func BuildRuntime(ctx context.Context, opts BootstrapOptions) (RuntimeBundle, er contextBuilder, ) + // 注入记忆提取钩子:当 AutoExtract 启用且 memoSvc 可用时,ReAct 循环完成后异步提取记忆。 + if memoSvc != nil && cfg.Memo.AutoExtract { + runtimeSvc.SetMemoExtractor(&memoExtractorAdapter{ + extractor: memo.NewRuleExtractor(), + svc: memoSvc, + }) + } + return RuntimeBundle{ Config: cfg, ConfigManager: manager, @@ -210,3 +222,14 @@ func buildToolManager(registry *tools.Registry) (tools.Manager, error) { } return tools.NewManager(registry, engine, security.NewWorkspaceSandbox()) } + +// memoExtractorAdapter 适配 memo.RuleExtractor + memo.Service 到 runtime.MemoExtractor 接口。 +type memoExtractorAdapter struct { + extractor *memo.RuleExtractor + svc *memo.Service +} + +// ExtractAndStore 实现 runtime.MemoExtractor 接口,从消息中提取记忆并保存。 +func (a *memoExtractorAdapter) ExtractAndStore(ctx context.Context, messages []providertypes.Message) { + memo.ExtractAndStore(ctx, a.extractor, a.svc, messages) +} diff --git a/internal/memo/extractor.go b/internal/memo/extractor.go new file mode 100644 index 00000000..900c2a05 --- /dev/null +++ b/internal/memo/extractor.go @@ -0,0 +1,93 @@ +package memo + +import ( + "context" + "strings" + + providertypes "neo-code/internal/provider/types" +) + +// signalPhrases 包含规则提取器识别的显式记忆信号词。 +var signalPhrases = []string{ + "记住", "记下来", "以后都这样", "以后都这样", + "我偏好", "我喜欢", "我习惯", "我希望", + "别再", "不要再", "不要使用", "避免", + "always", "never", "prefer", "avoid", + "remember", "make sure", "from now on", +} + +// RuleExtractor 基于规则的轻量记忆提取器,检测用户消息中的显式信号词。 +// 无外部依赖,适合作为默认提取器。 +type RuleExtractor struct{} + +// NewRuleExtractor 创建规则提取器实例。 +func NewRuleExtractor() *RuleExtractor { + return &RuleExtractor{} +} + +// Extract 扫描最近的消息,检测含信号词的用户输入并构造记忆条目。 +// 仅提取用户最后一条消息,避免重复。 +func (r *RuleExtractor) Extract(ctx context.Context, messages []providertypes.Message) ([]Entry, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + // 找到用户发送的最后一条消息 + var lastUserMsg string + for i := len(messages) - 1; i >= 0; i-- { + if messages[i].Role == providertypes.RoleUser { + lastUserMsg = messages[i].Content + break + } + } + if lastUserMsg == "" { + return nil, nil + } + + if !containsSignal(lastUserMsg) { + return nil, nil + } + + // 截断过长内容作为标题 + title := NormalizeTitle(lastUserMsg) + if len(title) > 150 { + title = title[:147] + "..." + } + + return []Entry{ + { + Type: TypeUser, + Title: title, + Content: lastUserMsg, + Source: SourceAutoExtract, + }, + }, nil +} + +// containsSignal 检查文本是否包含任意信号词。 +func containsSignal(text string) bool { + lower := strings.ToLower(text) + for _, phrase := range signalPhrases { + if strings.Contains(lower, strings.ToLower(phrase)) { + return true + } + } + return false +} + +// ExtractAndStore 从消息中提取记忆并保存到 Service。 +// 提取失败静默处理,不影响主循环。 +func ExtractAndStore(ctx context.Context, extractor Extractor, svc *Service, messages []providertypes.Message) { + if extractor == nil || svc == nil { + return + } + + entries, err := extractor.Extract(ctx, messages) + if err != nil || len(entries) == 0 { + return + } + + for _, entry := range entries { + _ = svc.Add(ctx, entry) // 提取失败不影响主循环,静默忽略 + } +} diff --git a/internal/memo/extractor_test.go b/internal/memo/extractor_test.go new file mode 100644 index 00000000..6f7ac809 --- /dev/null +++ b/internal/memo/extractor_test.go @@ -0,0 +1,220 @@ +package memo + +import ( + "context" + "strings" + "testing" + + "neo-code/internal/config" + providertypes "neo-code/internal/provider/types" +) + +func TestRuleExtractorExtractWithSignal(t *testing.T) { + extractor := NewRuleExtractor() + messages := []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "请帮我写个函数"}, + {Role: providertypes.RoleAssistant, Content: "好的"}, + {Role: providertypes.RoleUser, Content: "记住以后都用中文注释"}, + } + + entries, err := extractor.Extract(context.Background(), messages) + if err != nil { + t.Fatalf("Extract error: %v", err) + } + if len(entries) != 1 { + t.Fatalf("entries = %d, want 1", len(entries)) + } + if entries[0].Type != TypeUser { + t.Errorf("Type = %q, want %q", entries[0].Type, TypeUser) + } + if entries[0].Source != SourceAutoExtract { + t.Errorf("Source = %q, want %q", entries[0].Source, SourceAutoExtract) + } + if !strings.Contains(entries[0].Title, "记住以后都用中文注释") { + t.Errorf("Title = %q, should contain original text", entries[0].Title) + } +} + +func TestRuleExtractorExtractNoSignal(t *testing.T) { + extractor := NewRuleExtractor() + messages := []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "帮我写个排序函数"}, + {Role: providertypes.RoleAssistant, Content: "好的,这是一个快速排序"}, + } + + entries, err := extractor.Extract(context.Background(), messages) + if err != nil { + t.Fatalf("Extract error: %v", err) + } + if len(entries) != 0 { + t.Fatalf("entries = %d, want 0 (no signal)", len(entries)) + } +} + +func TestRuleExtractorExtractNoUserMessage(t *testing.T) { + extractor := NewRuleExtractor() + messages := []providertypes.Message{ + {Role: providertypes.RoleAssistant, Content: "好的"}, + } + + entries, err := extractor.Extract(context.Background(), messages) + if err != nil { + t.Fatalf("Extract error: %v", err) + } + if len(entries) != 0 { + t.Fatalf("entries = %d, want 0 (no user message)", len(entries)) + } +} + +func TestRuleExtractorExtractEmptyMessages(t *testing.T) { + extractor := NewRuleExtractor() + entries, err := extractor.Extract(context.Background(), nil) + if err != nil { + t.Fatalf("Extract error: %v", err) + } + if len(entries) != 0 { + t.Fatalf("entries = %d, want 0", len(entries)) + } +} + +func TestRuleExtractorExtractCancelledContext(t *testing.T) { + extractor := NewRuleExtractor() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := extractor.Extract(ctx, []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "记住这个"}, + }) + if err == nil { + t.Error("expected error for cancelled context") + } +} + +func TestRuleExtractorExtractEnglishSignals(t *testing.T) { + extractor := NewRuleExtractor() + + tests := []struct { + content string + want bool + }{ + {"always use tabs for indentation", true}, + {"never use console.log", true}, + {"I prefer dark mode", true}, + {"remember to check nil", true}, + {"avoid global variables", true}, + {"from now on use TypeScript", true}, + {"make sure to validate input", true}, + {"write a function", false}, + } + + for _, tt := range tests { + t.Run(tt.content, func(t *testing.T) { + messages := []providertypes.Message{ + {Role: providertypes.RoleUser, Content: tt.content}, + } + entries, _ := extractor.Extract(context.Background(), messages) + got := len(entries) > 0 + if got != tt.want { + t.Errorf("signal(%q) = %v, want %v", tt.content, got, tt.want) + } + }) + } +} + +func TestRuleExtractorExtractLongContent(t *testing.T) { + extractor := NewRuleExtractor() + // 超过 150 字符 + longContent := "记住" + strings.Repeat("a", 200) + messages := []providertypes.Message{ + {Role: providertypes.RoleUser, Content: longContent}, + } + + entries, err := extractor.Extract(context.Background(), messages) + if err != nil { + t.Fatalf("Extract error: %v", err) + } + if len(entries) != 1 { + t.Fatalf("entries = %d, want 1", len(entries)) + } + if len(entries[0].Title) > 150 { + t.Errorf("Title length = %d, should be <= 150", len(entries[0].Title)) + } + if !strings.HasSuffix(entries[0].Title, "...") { + t.Error("Title should be truncated with ...") + } +} + +func TestRuleExtractorExtractOnlyLastUserMessage(t *testing.T) { + extractor := NewRuleExtractor() + messages := []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "记住偏好A"}, + {Role: providertypes.RoleAssistant, Content: "好的"}, + {Role: providertypes.RoleUser, Content: "写个函数"}, + } + + entries, _ := extractor.Extract(context.Background(), messages) + // 最后一条用户消息"写个函数"没有信号词,不应提取 + if len(entries) != 0 { + t.Errorf("should only check last user message, got %d entries", len(entries)) + } +} + +func TestContainsSignal(t *testing.T) { + tests := []struct { + text string + want bool + }{ + {"记住这个偏好", true}, + {"我喜欢 tab 缩进", true}, + {"别再用空格了", true}, + {"请帮我写代码", false}, + {"REMEMBER to test", true}, + {"NEVER do this again", true}, + } + + for _, tt := range tests { + got := containsSignal(tt.text) + if got != tt.want { + t.Errorf("containsSignal(%q) = %v, want %v", tt.text, got, tt.want) + } + } +} + +func TestExtractAndStore(t *testing.T) { + t.Run("nil extractor returns silently", func(t *testing.T) { + ExtractAndStore(context.Background(), nil, nil, nil) + }) + + t.Run("nil service returns silently", func(t *testing.T) { + ExtractAndStore(context.Background(), NewRuleExtractor(), nil, nil) + }) + + t.Run("no signal does not add entries", func(t *testing.T) { + store := NewFileStore(t.TempDir(), t.TempDir()) + svc := NewService(store, nil, config.MemoConfig{MaxIndexLines: 200}, nil) + messages := []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "写个函数"}, + } + ExtractAndStore(context.Background(), NewRuleExtractor(), svc, messages) + entries, _ := svc.List(context.Background()) + if len(entries) != 0 { + t.Errorf("expected 0 entries, got %d", len(entries)) + } + }) + + t.Run("with signal adds entry", func(t *testing.T) { + store := NewFileStore(t.TempDir(), t.TempDir()) + svc := NewService(store, nil, config.MemoConfig{MaxIndexLines: 200}, nil) + messages := []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "记住以后都用中文注释"}, + } + ExtractAndStore(context.Background(), NewRuleExtractor(), svc, messages) + entries, _ := svc.List(context.Background()) + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(entries)) + } + if entries[0].Type != TypeUser { + t.Errorf("Type = %q, want %q", entries[0].Type, TypeUser) + } + }) +} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 60be921b..6bd7230d 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -126,10 +126,23 @@ type UserInput struct { Workdir string } +// ProviderFactory 根据 provider 运行时配置创建 provider 实例。 type ProviderFactory interface { Build(ctx context.Context, cfg provider.RuntimeConfig) (provider.Provider, error) } +// MemoExtractor 定义 runtime 层调用记忆提取的最小能力。 +// 与 memo.Extractor 解耦,避免 runtime 直接依赖 memo 包的具体类型。 +type MemoExtractor interface { + // ExtractAndStore 从消息中提取记忆并保存,失败静默处理。 + ExtractAndStore(ctx context.Context, messages []providertypes.Message) +} + +// SetMemoExtractor 设置可选的记忆提取钩子,完成后由 ReAct 循环调用。 +func (s *Service) SetMemoExtractor(extractor MemoExtractor) { + s.memoExtractor = extractor +} + type Service struct { configManager *config.Manager // 配置管理器,提供当前选中的 provider、model、workdir 等配置读取能力。 sessionStore agentsession.Store // 会话持久化接口,负责保存和加载聊天会话。 @@ -137,6 +150,7 @@ type Service struct { providerFactory ProviderFactory // Provider 工厂接口,根据配置动态创建具体的 provider 实例。 contextBuilder agentcontext.Builder // 上下文构建器,负责组装 system prompt 与本轮发给模型的消息上下文。 compactRunner contextcompact.Runner + memoExtractor MemoExtractor // 可选的记忆提取钩子,完成后异步提取记忆。 events chan RuntimeEvent operationMu sync.Mutex // 运行级互斥:串行化 Run 与 Compact,避免并发写同一会话。 runMu sync.Mutex // 仅保护 activeRun* 字段的并发读写。 @@ -330,6 +344,12 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { } if len(assistant.ToolCalls) == 0 { s.emit(ctx, EventAgentDone, input.RunID, session.ID, assistant) + // 异步提取记忆:不影响主循环,失败静默处理。 + if s.memoExtractor != nil { + msgs := make([]providertypes.Message, len(session.Messages)) + copy(msgs, session.Messages) + go s.memoExtractor.ExtractAndStore(context.Background(), msgs) + } return nil } diff --git a/internal/tools/memo/recall.go b/internal/tools/memo/recall.go new file mode 100644 index 00000000..52142ade --- /dev/null +++ b/internal/tools/memo/recall.go @@ -0,0 +1,105 @@ +package memo + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + + "neo-code/internal/memo" + "neo-code/internal/tools" +) + +const ( + recallToolName = tools.ToolNameMemoRecall +) + +// recallInput 定义 memo_recall 工具的 JSON 入参。 +type recallInput struct { + Keyword string `json:"keyword"` +} + +// RecallTool 让 Agent 按关键词搜索并加载记忆详情。 +type RecallTool struct { + svc *memo.Service +} + +// NewRecallTool 创建 memo_recall 工具,svc 不可为 nil。 +func NewRecallTool(svc *memo.Service) *RecallTool { + return &RecallTool{svc: svc} +} + +// Name 返回工具注册名。 +func (t *RecallTool) Name() string { return recallToolName } + +// Description 返回工具描述,供模型理解工具用途。 +func (t *RecallTool) Description() string { + return "Search and load persistent memory entries by keyword. " + + "Returns detailed content of matching memory topics. " + + "Use this to recall user preferences, project decisions, or past feedback." +} + +// Schema 返回 JSON Schema 描述的工具参数格式。 +func (t *RecallTool) Schema() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "keyword": map[string]any{ + "type": "string", + "description": "Search keyword to find matching memory entries (searches title, type, and keywords).", + }, + }, + "required": []string{"keyword"}, + } +} + +// MicroCompactPolicy 记忆读取结果应保留在上下文中,不参与 micro compact 清理。 +func (t *RecallTool) MicroCompactPolicy() tools.MicroCompactPolicy { + return tools.MicroCompactPolicyPreserveHistory +} + +// Execute 执行 memo_recall 工具调用。调用前须确保 svc 已通过构造函数注入。 +func (t *RecallTool) Execute(ctx context.Context, call tools.ToolCallInput) (tools.ToolResult, error) { + var args recallInput + if err := json.Unmarshal(call.Arguments, &args); err != nil { + err = fmt.Errorf("%s: %w", recallToolName, err) + return tools.NewErrorResult(recallToolName, "invalid arguments", err.Error(), nil), err + } + + args.Keyword = strings.TrimSpace(args.Keyword) + if args.Keyword == "" { + err := fmt.Errorf("%s: keyword is required", recallToolName) + return tools.NewErrorResult(recallToolName, tools.NormalizeErrorReason(recallToolName, err), "", nil), err + } + + results, err := t.svc.Recall(ctx, args.Keyword) + if err != nil { + return tools.NewErrorResult(recallToolName, tools.NormalizeErrorReason(recallToolName, err), "", nil), err + } + + if len(results) == 0 { + return tools.ToolResult{ + Name: recallToolName, + Content: fmt.Sprintf("No memories found matching %q.", args.Keyword), + }, nil + } + + // 按 key 排序保证输出稳定性 + keys := make([]string, 0, len(results)) + for k := range results { + keys = append(keys, k) + } + sort.Strings(keys) + + var builder strings.Builder + fmt.Fprintf(&builder, "Found %d memory topic(s) matching %q:\n\n", len(results), args.Keyword) + for _, k := range keys { + fmt.Fprintf(&builder, "--- %s ---\n%s\n\n", k, results[k]) + } + + return tools.ToolResult{ + Name: recallToolName, + Content: builder.String(), + }, nil +} diff --git a/internal/tools/memo/recall_test.go b/internal/tools/memo/recall_test.go new file mode 100644 index 00000000..22487263 --- /dev/null +++ b/internal/tools/memo/recall_test.go @@ -0,0 +1,147 @@ +package memo + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "neo-code/internal/memo" + "neo-code/internal/tools" +) + +func TestRecallToolName(t *testing.T) { + tool := NewRecallTool(nil) + if tool.Name() != tools.ToolNameMemoRecall { + t.Errorf("Name() = %q, want %q", tool.Name(), tools.ToolNameMemoRecall) + } +} + +func TestRecallToolSchema(t *testing.T) { + tool := NewRecallTool(nil) + schema := tool.Schema() + if schema["type"] != "object" { + t.Errorf("Schema type = %v, want object", schema["type"]) + } + props, ok := schema["properties"].(map[string]any) + if !ok { + t.Fatal("Schema properties is not a map") + } + if _, exists := props["keyword"]; !exists { + t.Error("Schema missing 'keyword' property") + } +} + +func TestRecallToolMicroCompactPolicy(t *testing.T) { + tool := NewRecallTool(nil) + if tool.MicroCompactPolicy() != tools.MicroCompactPolicyPreserveHistory { + t.Errorf("MicroCompactPolicy() = %v, want PreserveHistory", tool.MicroCompactPolicy()) + } +} + +func TestRecallToolExecuteSuccess(t *testing.T) { + svc := newTestService(t) + // 预先写入记忆 + svc.Add(context.Background(), memo.Entry{ + Type: memo.TypeUser, + Title: "偏好中文注释", + Content: "用户偏好使用中文注释和 tab 缩进", + Source: memo.SourceUserManual, + }) + + tool := NewRecallTool(svc) + args, _ := json.Marshal(recallInput{Keyword: "中文"}) + result, err := tool.Execute(context.Background(), tools.ToolCallInput{Arguments: args}) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + if result.IsError { + t.Errorf("unexpected error result: %s", result.Content) + } + if !strings.Contains(result.Content, "Found 1 memory") { + t.Errorf("Content should show match count: %q", result.Content) + } + if !strings.Contains(result.Content, "中文注释") { + t.Errorf("Content should contain topic content: %q", result.Content) + } +} + +func TestRecallToolExecuteNoMatch(t *testing.T) { + svc := newTestService(t) + tool := NewRecallTool(svc) + + args, _ := json.Marshal(recallInput{Keyword: "nonexistent"}) + result, err := tool.Execute(context.Background(), tools.ToolCallInput{Arguments: args}) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + if result.IsError { + t.Errorf("no match should not be an error: %s", result.Content) + } + if !strings.Contains(result.Content, "No memories found") { + t.Errorf("Content should show no match: %q", result.Content) + } +} + +func TestRecallToolExecuteInvalidJSON(t *testing.T) { + tool := NewRecallTool(nil) + _, err := tool.Execute(context.Background(), tools.ToolCallInput{Arguments: []byte("not json")}) + if err == nil { + t.Error("expected error for invalid JSON") + } +} + +func TestRecallToolExecuteEmptyKeyword(t *testing.T) { + svc := newTestService(t) + tool := NewRecallTool(svc) + + args, _ := json.Marshal(recallInput{Keyword: ""}) + result, err := tool.Execute(context.Background(), tools.ToolCallInput{Arguments: args}) + if err == nil { + t.Error("expected error for empty keyword") + } + if !result.IsError { + t.Error("expected error result") + } +} + +func TestRecallToolExecuteWhitespaceKeyword(t *testing.T) { + svc := newTestService(t) + tool := NewRecallTool(svc) + + args, _ := json.Marshal(recallInput{Keyword: " "}) + result, err := tool.Execute(context.Background(), tools.ToolCallInput{Arguments: args}) + if err == nil { + t.Error("expected error for whitespace keyword") + } + if !result.IsError { + t.Error("expected error result") + } +} + +func TestRecallToolExecuteMultipleResults(t *testing.T) { + svc := newTestService(t) + svc.Add(context.Background(), memo.Entry{Type: memo.TypeUser, Title: "偏好 tab", Content: "tab content", Source: memo.SourceUserManual}) + svc.Add(context.Background(), memo.Entry{Type: memo.TypeFeedback, Title: "反馈 tab 问题", Content: "feedback content", Source: memo.SourceUserManual}) + + tool := NewRecallTool(svc) + args, _ := json.Marshal(recallInput{Keyword: "tab"}) + result, err := tool.Execute(context.Background(), tools.ToolCallInput{Arguments: args}) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + if result.IsError { + t.Errorf("unexpected error: %s", result.Content) + } + if !strings.Contains(result.Content, "Found 2 memory") { + t.Errorf("Content should show 2 matches: %q", result.Content) + } +} + +func TestRecallToolDescription(t *testing.T) { + tool := NewRecallTool(nil) + desc := tool.Description() + if !strings.Contains(desc, "memory") { + t.Errorf("Description should mention 'memory': %q", desc) + } +} diff --git a/internal/tools/memo/remember.go b/internal/tools/memo/remember.go new file mode 100644 index 00000000..6edd0b3d --- /dev/null +++ b/internal/tools/memo/remember.go @@ -0,0 +1,117 @@ +package memo + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "neo-code/internal/memo" + "neo-code/internal/tools" +) + +const ( + rememberToolName = tools.ToolNameMemoRemember +) + +// rememberInput 定义 memo_remember 工具的 JSON 入参。 +type rememberInput struct { + Type string `json:"type"` + Title string `json:"title"` + Content string `json:"content"` + Keywords []string `json:"keywords,omitempty"` +} + +// RememberTool 让 Agent 主动保存跨会话记忆条目。 +type RememberTool struct { + svc *memo.Service +} + +// NewRememberTool 创建 memo_remember 工具,svc 不可为 nil。 +func NewRememberTool(svc *memo.Service) *RememberTool { + return &RememberTool{svc: svc} +} + +// Name 返回工具注册名。 +func (t *RememberTool) Name() string { return rememberToolName } + +// Description 返回工具描述,供模型理解工具用途。 +func (t *RememberTool) Description() string { + return "Save a persistent memory entry that will be available across sessions. " + + "Use this to remember user preferences, project decisions, feedback, or important facts." +} + +// Schema 返回 JSON Schema 描述的工具参数格式。 +func (t *RememberTool) Schema() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "type": map[string]any{ + "type": "string", + "description": "Memory type: user (preferences/profile), feedback (corrections/guidance), project (facts/decisions), reference (external resources)", + "enum": []string{"user", "feedback", "project", "reference"}, + }, + "title": map[string]any{ + "type": "string", + "description": "Short summary of the memory (~150 chars), displayed in the memory index.", + }, + "content": map[string]any{ + "type": "string", + "description": "Full memory content with relevant details. Include context like 'Why' and 'How to apply' when applicable.", + }, + "keywords": map[string]any{ + "type": "array", + "items": map[string]any{"type": "string"}, + "description": "Optional search keywords for later retrieval.", + }, + }, + "required": []string{"type", "title", "content"}, + } +} + +// MicroCompactPolicy 记忆写入结果应保留在上下文中,不参与 micro compact 清理。 +func (t *RememberTool) MicroCompactPolicy() tools.MicroCompactPolicy { + return tools.MicroCompactPolicyPreserveHistory +} + +// Execute 执行 memo_remember 工具调用。调用前须确保 svc 已通过构造函数注入。 +func (t *RememberTool) Execute(ctx context.Context, call tools.ToolCallInput) (tools.ToolResult, error) { + var args rememberInput + if err := json.Unmarshal(call.Arguments, &args); err != nil { + err = fmt.Errorf("%s: %w", rememberToolName, err) + return tools.NewErrorResult(rememberToolName, "invalid arguments", err.Error(), nil), err + } + + args.Type = strings.TrimSpace(args.Type) + args.Title = strings.TrimSpace(args.Title) + args.Content = strings.TrimSpace(args.Content) + + if args.Type == "" || args.Title == "" || args.Content == "" { + err := fmt.Errorf("%s: type, title, and content are required", rememberToolName) + return tools.NewErrorResult(rememberToolName, tools.NormalizeErrorReason(rememberToolName, err), "", nil), err + } + + memoType := memo.Type(args.Type) + if !memo.IsValidType(memoType) { + err := fmt.Errorf("%s: invalid type %q, must be one of user/feedback/project/reference", rememberToolName, args.Type) + return tools.NewErrorResult(rememberToolName, tools.NormalizeErrorReason(rememberToolName, err), "", nil), err + } + + title := memo.NormalizeTitle(args.Title) + entry := memo.Entry{ + Type: memoType, + Title: title, + Content: args.Content, + Keywords: args.Keywords, + Source: memo.SourceToolInitiated, + } + + if err := t.svc.Add(ctx, entry); err != nil { + return tools.NewErrorResult(rememberToolName, tools.NormalizeErrorReason(rememberToolName, err), "", nil), err + } + + return tools.ToolResult{ + Name: rememberToolName, + Content: fmt.Sprintf("Memory saved: [%s] %s", memoType, title), + }, nil +} diff --git a/internal/tools/memo/remember_test.go b/internal/tools/memo/remember_test.go new file mode 100644 index 00000000..f3b881f6 --- /dev/null +++ b/internal/tools/memo/remember_test.go @@ -0,0 +1,203 @@ +package memo + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "neo-code/internal/config" + "neo-code/internal/memo" + "neo-code/internal/tools" +) + +// newTestService 创建绑定临时目录的 memo.Service 实例。 +func newTestService(t *testing.T) *memo.Service { + t.Helper() + store := memo.NewFileStore(t.TempDir(), t.TempDir()) + return memo.NewService(store, nil, config.MemoConfig{MaxIndexLines: 200}, nil) +} + +func TestRememberToolName(t *testing.T) { + tool := NewRememberTool(nil) + if tool.Name() != tools.ToolNameMemoRemember { + t.Errorf("Name() = %q, want %q", tool.Name(), tools.ToolNameMemoRemember) + } +} + +func TestRememberToolSchema(t *testing.T) { + tool := NewRememberTool(nil) + schema := tool.Schema() + if schema["type"] != "object" { + t.Errorf("Schema type = %v, want object", schema["type"]) + } + props, ok := schema["properties"].(map[string]any) + if !ok { + t.Fatal("Schema properties is not a map") + } + for _, field := range []string{"type", "title", "content"} { + if _, exists := props[field]; !exists { + t.Errorf("Schema missing required property %q", field) + } + } +} + +func TestRememberToolMicroCompactPolicy(t *testing.T) { + tool := NewRememberTool(nil) + if tool.MicroCompactPolicy() != tools.MicroCompactPolicyPreserveHistory { + t.Errorf("MicroCompactPolicy() = %v, want PreserveHistory", tool.MicroCompactPolicy()) + } +} + +func TestRememberToolExecuteSuccess(t *testing.T) { + svc := newTestService(t) + tool := NewRememberTool(svc) + + args, _ := json.Marshal(rememberInput{ + Type: "user", + Title: "偏好中文注释", + Content: "用户偏好使用中文注释和 tab 缩进", + }) + result, err := tool.Execute(context.Background(), tools.ToolCallInput{Arguments: args}) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + if result.IsError { + t.Errorf("unexpected error result: %s", result.Content) + } + if !strings.Contains(result.Content, "Memory saved") { + t.Errorf("Content = %q, want saved confirmation", result.Content) + } + if !strings.Contains(result.Content, "偏好中文注释") { + t.Errorf("Content should contain title: %q", result.Content) + } + + // 验证实际保存(索引只保留 Type/Title/TopicFile,完整信息在 topic 文件中) + entries, _ := svc.List(context.Background()) + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(entries)) + } + if entries[0].Type != memo.TypeUser { + t.Errorf("Type = %q, want %q", entries[0].Type, memo.TypeUser) + } +} + +func TestRememberToolExecuteWithKeywords(t *testing.T) { + svc := newTestService(t) + tool := NewRememberTool(svc) + + args, _ := json.Marshal(rememberInput{ + Type: "feedback", + Title: "不要 mock 数据库", + Content: "集成测试必须连接真实数据库", + Keywords: []string{"testing", "database"}, + }) + result, err := tool.Execute(context.Background(), tools.ToolCallInput{Arguments: args}) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + if result.IsError { + t.Errorf("unexpected error result: %s", result.Content) + } + + entries, _ := svc.List(context.Background()) + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(entries)) + } + // Keywords 存储在 topic 文件中,不在索引中 + if entries[0].TopicFile == "" { + t.Error("TopicFile should be set") + } +} + +func TestRememberToolExecuteInvalidJSON(t *testing.T) { + tool := NewRememberTool(nil) + _, err := tool.Execute(context.Background(), tools.ToolCallInput{Arguments: []byte("not json")}) + if err == nil { + t.Error("expected error for invalid JSON") + } +} + +func TestRememberToolExecuteMissingFields(t *testing.T) { + svc := newTestService(t) + tool := NewRememberTool(svc) + + tests := []struct { + name string + args rememberInput + }{ + {"empty type", rememberInput{Type: "", Title: "t", Content: "c"}}, + {"empty title", rememberInput{Type: "user", Title: "", Content: "c"}}, + {"empty content", rememberInput{Type: "user", Title: "t", Content: ""}}, + {"whitespace type", rememberInput{Type: " ", Title: "t", Content: "c"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + args, _ := json.Marshal(tt.args) + result, err := tool.Execute(context.Background(), tools.ToolCallInput{Arguments: args}) + if err == nil { + t.Error("expected error for missing fields") + } + if !result.IsError { + t.Error("expected error result") + } + }) + } +} + +func TestRememberToolExecuteInvalidType(t *testing.T) { + svc := newTestService(t) + tool := NewRememberTool(svc) + + args, _ := json.Marshal(rememberInput{Type: "invalid", Title: "t", Content: "c"}) + result, err := tool.Execute(context.Background(), tools.ToolCallInput{Arguments: args}) + if err == nil { + t.Error("expected error for invalid type") + } + if !result.IsError { + t.Error("expected error result") + } + if !strings.Contains(result.Content, "invalid type") { + t.Errorf("Content should mention invalid type: %q", result.Content) + } +} + +func TestRememberToolExecuteAllTypes(t *testing.T) { + svc := newTestService(t) + tool := NewRememberTool(svc) + + for _, memoType := range memo.ValidTypes() { + t.Run(string(memoType), func(t *testing.T) { + args, _ := json.Marshal(rememberInput{ + Type: string(memoType), + Title: "test " + string(memoType), + Content: "content for " + string(memoType), + }) + result, err := tool.Execute(context.Background(), tools.ToolCallInput{Arguments: args}) + if err != nil { + t.Fatalf("Execute error for type %s: %v", memoType, err) + } + if result.IsError { + t.Errorf("unexpected error for type %s: %s", memoType, result.Content) + } + }) + } +} + +func TestRememberToolExecuteServiceError(t *testing.T) { + svc := newTestService(t) + tool := NewRememberTool(svc) + + args, _ := json.Marshal(rememberInput{Type: "user", Title: "test", Content: "test"}) + ctx, cancel := context.WithCancel(context.Background()) + cancel() // 取消上下文以触发错误 + + result, err := tool.Execute(ctx, tools.ToolCallInput{Arguments: args}) + if err == nil { + t.Error("expected error when context is cancelled") + } + if !result.IsError { + t.Error("expected error result") + } +} diff --git a/internal/tools/names.go b/internal/tools/names.go index 833430f7..33ff2aed 100644 --- a/internal/tools/names.go +++ b/internal/tools/names.go @@ -9,4 +9,6 @@ const ( ToolNameFilesystemGrep = "filesystem_grep" ToolNameFilesystemGlob = "filesystem_glob" ToolNameFilesystemEdit = "filesystem_edit" + ToolNameMemoRemember = "memo_remember" + ToolNameMemoRecall = "memo_recall" )