From ed8725448a878e8fb66710a93fc1a6a921c2dd64 Mon Sep 17 00:00:00 2001 From: Paul De Velder Date: Mon, 23 Feb 2026 16:13:01 +0100 Subject: [PATCH] feat(memory): add memory_save, memory_search, and memory_recall tools Three new tools for the memory vault system: - memory_save: saves structured notes with frontmatter and updates index - memory_search: searches by tags (AND logic) and/or query (title/alias match) - memory_recall: retrieves full note content by path or topic --- pkg/tools/memory.go | 271 +++++++++++++++++++++++++++++++++++++++ pkg/tools/memory_test.go | 234 +++++++++++++++++++++++++++++++++ 2 files changed, 505 insertions(+) create mode 100644 pkg/tools/memory.go create mode 100644 pkg/tools/memory_test.go diff --git a/pkg/tools/memory.go b/pkg/tools/memory.go new file mode 100644 index 000000000..0f2ab6037 --- /dev/null +++ b/pkg/tools/memory.go @@ -0,0 +1,271 @@ +package tools + +import ( + "context" + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/memory" +) + +// MemorySaveTool saves a structured note to the memory vault with frontmatter. +type MemorySaveTool struct { + vault *memory.Vault +} + +// NewMemorySaveTool creates a new MemorySaveTool backed by the given vault. +func NewMemorySaveTool(vault *memory.Vault) *MemorySaveTool { + return &MemorySaveTool{vault: vault} +} + +func (t *MemorySaveTool) Name() string { return "memory_save" } +func (t *MemorySaveTool) Description() string { + return "Save a structured note to the memory vault with frontmatter metadata. " + + "Notes are stored as markdown files with YAML frontmatter for tags, aliases, and dates." +} + +func (t *MemorySaveTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{ + "type": "string", + "description": "Relative path within memory/ (e.g. 'topics/go-errors.md')", + }, + "title": map[string]any{ + "type": "string", + "description": "Note title", + }, + "content": map[string]any{ + "type": "string", + "description": "Note body content (markdown)", + }, + "tags": map[string]any{ + "type": "string", + "description": "Comma-separated tags (e.g. 'go, patterns, errors')", + }, + "aliases": map[string]any{ + "type": "string", + "description": "Comma-separated aliases for wikilink resolution (optional)", + }, + }, + "required": []string{"path", "title", "content"}, + } +} + +func (t *MemorySaveTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + path, _ := args["path"].(string) + title, _ := args["title"].(string) + content, _ := args["content"].(string) + + if path == "" { + return ErrorResult("path is required") + } + if title == "" { + return ErrorResult("title is required") + } + if content == "" { + return ErrorResult("content is required") + } + + meta := memory.NoteMeta{ + Title: title, + } + + if tagsStr, ok := args["tags"].(string); ok && tagsStr != "" { + for _, tag := range strings.Split(tagsStr, ",") { + tag = strings.TrimSpace(tag) + if tag != "" { + meta.Tags = append(meta.Tags, tag) + } + } + } + + if aliasStr, ok := args["aliases"].(string); ok && aliasStr != "" { + for _, alias := range strings.Split(aliasStr, ",") { + alias = strings.TrimSpace(alias) + if alias != "" { + meta.Aliases = append(meta.Aliases, alias) + } + } + } + + if err := t.vault.SaveNote(path, meta, content); err != nil { + return ErrorResult(fmt.Sprintf("failed to save note: %v", err)) + } + + tagInfo := "" + if len(meta.Tags) > 0 { + tagInfo = fmt.Sprintf(" [tags: %s]", strings.Join(meta.Tags, ", ")) + } + return SilentResult(fmt.Sprintf("Saved: %s%s", path, tagInfo)) +} + +// MemorySearchTool searches the memory vault by tags, title, or text content. +type MemorySearchTool struct { + vault *memory.Vault +} + +// NewMemorySearchTool creates a new MemorySearchTool backed by the given vault. +func NewMemorySearchTool(vault *memory.Vault) *MemorySearchTool { + return &MemorySearchTool{vault: vault} +} + +func (t *MemorySearchTool) Name() string { return "memory_search" } +func (t *MemorySearchTool) Description() string { + return "Search the memory vault by tags, title, or text content. " + + "Returns a list of matching notes with metadata. Use memory_recall to read full content." +} + +func (t *MemorySearchTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "query": map[string]any{ + "type": "string", + "description": "Search query (matches title, tags, and aliases)", + }, + "tags": map[string]any{ + "type": "string", + "description": "Filter by tags (comma-separated, AND logic)", + }, + }, + } +} + +func (t *MemorySearchTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + query, _ := args["query"].(string) + tagsStr, _ := args["tags"].(string) + + var tags []string + if tagsStr != "" { + for _, tag := range strings.Split(tagsStr, ",") { + tag = strings.TrimSpace(tag) + if tag != "" { + tags = append(tags, tag) + } + } + } + + results, err := t.vault.Search(query, tags) + if err != nil { + return ErrorResult(fmt.Sprintf("search failed: %v", err)) + } + + if len(results) == 0 { + return NewToolResult("No matching notes found.") + } + + // Cap results + if len(results) > 20 { + results = results[:20] + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Found %d note(s):\n\n", len(results))) + for _, n := range results { + tagsDisplay := "" + if len(n.Tags) > 0 { + tagsDisplay = fmt.Sprintf(" [%s]", strings.Join(n.Tags, ", ")) + } + sb.WriteString(fmt.Sprintf("- **%s** (%s)%s", n.Title, n.RelPath, tagsDisplay)) + if n.Updated != "" { + sb.WriteString(fmt.Sprintf(" — updated %s", n.Updated)) + } + sb.WriteString("\n") + } + + return NewToolResult(sb.String()) +} + +// MemoryRecallTool recalls specific notes from the memory vault by path or topic. +// Unlike memory_search which returns metadata, memory_recall returns full note content. +type MemoryRecallTool struct { + vault *memory.Vault +} + +// NewMemoryRecallTool creates a new MemoryRecallTool backed by the given vault. +func NewMemoryRecallTool(vault *memory.Vault) *MemoryRecallTool { + return &MemoryRecallTool{vault: vault} +} + +func (t *MemoryRecallTool) Name() string { return "memory_recall" } +func (t *MemoryRecallTool) Description() string { + return "Recall specific notes from memory vault by path or topic. " + + "Returns full note content. Use memory_search first to find relevant paths." +} + +func (t *MemoryRecallTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{ + "type": "string", + "description": "Exact path to recall (e.g. 'topics/go-errors.md')", + }, + "topic": map[string]any{ + "type": "string", + "description": "Topic to find relevant notes for (uses search + read)", + }, + "max_notes": map[string]any{ + "type": "number", + "description": "Maximum number of notes to return when using topic (default: 3)", + }, + }, + } +} + +func (t *MemoryRecallTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + path, _ := args["path"].(string) + topic, _ := args["topic"].(string) + + if path == "" && topic == "" { + return ErrorResult("either 'path' or 'topic' is required") + } + + // Direct path recall + if path != "" { + content, err := t.vault.ReadNote(path) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to read note: %v", err)) + } + return NewToolResult(content) + } + + // Topic-based recall: search then read top matches + maxNotes := 3 + if mn, ok := args["max_notes"].(float64); ok && mn > 0 { + maxNotes = int(mn) + } + + results, err := t.vault.Search(topic, nil) + if err != nil { + return ErrorResult(fmt.Sprintf("search failed: %v", err)) + } + + if len(results) == 0 { + return NewToolResult("No matching notes found for topic: " + topic) + } + + if len(results) > maxNotes { + results = results[:maxNotes] + } + + var sb strings.Builder + for i, n := range results { + if i > 0 { + sb.WriteString("\n\n---\n\n") + } + sb.WriteString(fmt.Sprintf("## %s (%s)\n\n", n.Title, n.RelPath)) + content, err := t.vault.ReadNote(n.RelPath) + if err != nil { + sb.WriteString(fmt.Sprintf("(error reading note: %v)\n", err)) + continue + } + // Strip frontmatter from recalled content to avoid duplication + _, body := memory.ParseFrontmatter(content) + sb.WriteString(strings.TrimSpace(body)) + } + + return NewToolResult(sb.String()) +} diff --git a/pkg/tools/memory_test.go b/pkg/tools/memory_test.go new file mode 100644 index 000000000..82ef906dd --- /dev/null +++ b/pkg/tools/memory_test.go @@ -0,0 +1,234 @@ +package tools + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/memory" +) + +// --- MemorySaveTool tests --- + +func TestMemorySave_NewNote(t *testing.T) { + dir := t.TempDir() + vault := memory.NewVault(dir) + tool := NewMemorySaveTool(vault) + ctx := context.Background() + + result := tool.Execute(ctx, map[string]any{ + "path": "topics/test-note.md", + "title": "Test Note", + "content": "This is the body.", + "tags": "go, testing", + }) + + if result.IsError { + t.Fatalf("Expected success, got error: %s", result.ForLLM) + } + if !result.Silent { + t.Error("Expected silent result for memory_save") + } + if !strings.Contains(result.ForLLM, "Saved") { + t.Errorf("ForLLM = %q, expected to contain 'Saved'", result.ForLLM) + } + + // Verify file exists and has correct frontmatter + data, err := os.ReadFile(filepath.Join(dir, "topics", "test-note.md")) + if err != nil { + t.Fatalf("Note file not created: %v", err) + } + content := string(data) + if !strings.Contains(content, "title: Test Note") { + t.Error("Note missing title in frontmatter") + } + if !strings.Contains(content, "tags: [go, testing]") { + t.Error("Note missing tags in frontmatter") + } + if !strings.Contains(content, "This is the body.") { + t.Error("Note missing body content") + } + + // Verify index was updated + index := vault.ReadIndex() + if !strings.Contains(index, "Test Note") { + t.Error("Index not updated after save") + } +} + +func TestMemorySave_MissingRequired(t *testing.T) { + dir := t.TempDir() + vault := memory.NewVault(dir) + tool := NewMemorySaveTool(vault) + ctx := context.Background() + + // Missing path + result := tool.Execute(ctx, map[string]any{ + "title": "Test", + "content": "Body", + }) + if !result.IsError { + t.Error("Expected error for missing path") + } + + // Missing title + result = tool.Execute(ctx, map[string]any{ + "path": "test.md", + "content": "Body", + }) + if !result.IsError { + t.Error("Expected error for missing title") + } + + // Missing content + result = tool.Execute(ctx, map[string]any{ + "path": "test.md", + "title": "Test", + }) + if !result.IsError { + t.Error("Expected error for missing content") + } +} + +// --- MemorySearchTool tests --- + +func TestMemorySearch_ByTags(t *testing.T) { + dir := t.TempDir() + vault := memory.NewVault(dir) + + // Set up test notes + vault.SaveNote("a.md", memory.NoteMeta{Title: "Go Errors", Tags: []string{"go", "errors"}}, "Content A.") + vault.SaveNote("b.md", memory.NoteMeta{Title: "Go Testing", Tags: []string{"go", "testing"}}, "Content B.") + vault.SaveNote("c.md", memory.NoteMeta{Title: "Python", Tags: []string{"python"}}, "Content C.") + + tool := NewMemorySearchTool(vault) + ctx := context.Background() + + result := tool.Execute(ctx, map[string]any{ + "tags": "go", + }) + + if result.IsError { + t.Fatalf("Expected success, got error: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "Go Errors") { + t.Error("Search result should contain 'Go Errors'") + } + if !strings.Contains(result.ForLLM, "Go Testing") { + t.Error("Search result should contain 'Go Testing'") + } + if strings.Contains(result.ForLLM, "Python") { + t.Error("Search result should not contain 'Python'") + } +} + +func TestMemorySearch_ByQuery(t *testing.T) { + dir := t.TempDir() + vault := memory.NewVault(dir) + + vault.SaveNote("a.md", memory.NoteMeta{Title: "Go Errors", Tags: []string{"go"}}, "Content.") + vault.SaveNote("b.md", memory.NoteMeta{Title: "Python Basics", Tags: []string{"python"}}, "Content.") + + tool := NewMemorySearchTool(vault) + ctx := context.Background() + + result := tool.Execute(ctx, map[string]any{ + "query": "Error", + }) + + if result.IsError { + t.Fatalf("Expected success, got error: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "Go Errors") { + t.Error("Search result should contain 'Go Errors'") + } + if strings.Contains(result.ForLLM, "Python") { + t.Error("Search result should not contain 'Python'") + } +} + +func TestMemorySearch_NoParams(t *testing.T) { + dir := t.TempDir() + vault := memory.NewVault(dir) + tool := NewMemorySearchTool(vault) + ctx := context.Background() + + result := tool.Execute(ctx, map[string]any{}) + if result.IsError { + t.Error("Search with no params should not error (returns all notes)") + } +} + +// --- MemoryRecallTool tests --- + +func TestMemoryRecall_ByPath(t *testing.T) { + dir := t.TempDir() + vault := memory.NewVault(dir) + + vault.SaveNote("test.md", memory.NoteMeta{Title: "Test Note", Tags: []string{"test"}}, "Full body content here.") + + tool := NewMemoryRecallTool(vault) + ctx := context.Background() + + result := tool.Execute(ctx, map[string]any{ + "path": "test.md", + }) + + if result.IsError { + t.Fatalf("Expected success, got error: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "Full body content here.") { + t.Error("Recall should return full note content") + } +} + +func TestMemoryRecall_ByTopic(t *testing.T) { + dir := t.TempDir() + vault := memory.NewVault(dir) + + vault.SaveNote("go-errors.md", memory.NoteMeta{Title: "Go Error Patterns", Tags: []string{"go"}}, "Error patterns body.") + vault.SaveNote("python.md", memory.NoteMeta{Title: "Python Basics", Tags: []string{"python"}}, "Python body.") + + tool := NewMemoryRecallTool(vault) + ctx := context.Background() + + result := tool.Execute(ctx, map[string]any{ + "topic": "Go Error", + }) + + if result.IsError { + t.Fatalf("Expected success, got error: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "Error patterns body.") { + t.Error("Recall should return matching note content") + } +} + +func TestMemoryRecall_MissingNote(t *testing.T) { + dir := t.TempDir() + vault := memory.NewVault(dir) + tool := NewMemoryRecallTool(vault) + ctx := context.Background() + + result := tool.Execute(ctx, map[string]any{ + "path": "nonexistent.md", + }) + + if !result.IsError { + t.Error("Expected error for missing note") + } +} + +func TestMemoryRecall_NoParams(t *testing.T) { + dir := t.TempDir() + vault := memory.NewVault(dir) + tool := NewMemoryRecallTool(vault) + ctx := context.Background() + + result := tool.Execute(ctx, map[string]any{}) + if !result.IsError { + t.Error("Expected error when no path or topic provided") + } +}