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
This commit is contained in:
Paul De Velder 2026-02-23 16:13:01 +01:00
parent 3afa6cca93
commit ed8725448a
2 changed files with 505 additions and 0 deletions

271
pkg/tools/memory.go Normal file
View file

@ -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())
}

234
pkg/tools/memory_test.go Normal file
View file

@ -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")
}
}