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:
parent
3afa6cca93
commit
ed8725448a
2 changed files with 505 additions and 0 deletions
271
pkg/tools/memory.go
Normal file
271
pkg/tools/memory.go
Normal 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
234
pkg/tools/memory_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue