From 7b4f8cc5c646a881af1b49a90ad45b9bc104225a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Feb 2026 23:05:03 +0000 Subject: [PATCH] feat: add structured memory tools with progressive disclosure Inspired by claude-mem architecture, adds three memory tools: - mem_search: keyword-based search across MEMORY.md and daily notes - mem_save: save structured observations under categories (preferences, facts, projects, decisions, observations) - mem_index: compact index of all memory content Progressive disclosure: system prompt now injects only a compact memory index (~70% fewer tokens) instead of full MEMORY.md content. Agent uses mem_search/mem_index tools to retrieve details on demand. https://claude.ai/code/session_01MYemTMPtHrcgidWs8UdjcG --- pkg/agent/context.go | 4 +- pkg/agent/loop.go | 5 + pkg/agent/memory.go | 133 ++++++++++- pkg/tools/memory.go | 490 +++++++++++++++++++++++++++++++++++++++ pkg/tools/memory_test.go | 217 +++++++++++++++++ 5 files changed, 843 insertions(+), 6 deletions(-) create mode 100644 pkg/tools/memory.go create mode 100644 pkg/tools/memory_test.go diff --git a/pkg/agent/context.go b/pkg/agent/context.go index ca5694654..b260e544e 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -77,9 +77,9 @@ You are PicoClaw, a personal AI assistant. You are lightweight, fast, and tool-o 1. **ALWAYS use tools** — When asked to perform an action, you MUST call the appropriate tool. Never simulate or pretend to execute an action. 2. **Respond in the user's language** — Match the language the user writes in. If they write in Portuguese, respond in Portuguese. If in English, respond in English. 3. **Be concise** — Give direct answers. Avoid unnecessary preambles like "Sure!" or "Of course!". Get to the point. -4. **Memory management** — Save important user preferences and facts to %s/memory/MEMORY.md. Use daily notes for temporary context. +4. **Memory management** — Use ` + "`mem_save`" + ` to store important facts/preferences under categories (preferences, facts, projects, decisions, observations). Use ` + "`mem_search`" + ` to recall specific information. Use ` + "`mem_index`" + ` to see what's in memory. Only a compact index is loaded in context — search for details on demand. 5. **Error recovery** — If a tool call fails, try an alternative approach before reporting failure to the user.`, - now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection, workspacePath) + now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection) } func (cb *ContextBuilder) buildToolsSection() string { diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 3a2952dcb..71876710e 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -88,6 +88,11 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg } registry.Register(tools.NewWebFetchTool(50000)) + // Memory tools (search, save, index) + registry.Register(tools.NewMemSearchTool(workspace)) + registry.Register(tools.NewMemSaveTool(workspace)) + registry.Register(tools.NewMemIndexTool(workspace)) + // Hardware tools (I2C, SPI) - Linux only, returns error on other platforms registry.Register(tools.NewI2CTool()) registry.Register(tools.NewSPITool()) diff --git a/pkg/agent/memory.go b/pkg/agent/memory.go index 3f6896f91..d28c6f3b3 100644 --- a/pkg/agent/memory.go +++ b/pkg/agent/memory.go @@ -7,9 +7,12 @@ package agent import ( + "bufio" "fmt" "os" "path/filepath" + "regexp" + "strings" "time" ) @@ -129,17 +132,58 @@ func (ms *MemoryStore) GetRecentDailyNotes(days int) string { } // GetMemoryContext returns formatted memory context for the agent prompt. -// Includes long-term memory and recent daily notes. +// Uses progressive disclosure: injects a compact index instead of full content. +// The agent can use mem_search and mem_index tools to retrieve details on demand. func (ms *MemoryStore) GetMemoryContext() string { + var sb strings.Builder + sb.WriteString("# Memory\n\n") + + // Progressive disclosure: compact index of long-term memory + longTerm := ms.ReadLongTerm() + if longTerm != "" { + index := ms.buildCompactIndex(longTerm) + sb.WriteString("## Long-term Memory (Index)\n\n") + sb.WriteString(index) + sb.WriteString("\n_Use `mem_search` to find specific entries or `mem_index` for full index._\n\n") + } else { + sb.WriteString("## Long-term Memory\n_Empty — use `mem_save` to store important facts and preferences._\n\n") + } + + // Recent daily notes: only today's content (compact) + todayNote := ms.ReadToday() + if todayNote != "" { + // Truncate if too long (progressive disclosure) + if len(todayNote) > 500 { + todayNote = todayNote[:500] + "\n... (truncated, use `read_file` for full content)" + } + sb.WriteString("## Today's Notes\n\n") + sb.WriteString(todayNote) + sb.WriteString("\n\n") + } + + // Show which days have notes (compact list) + recentDays := ms.getRecentNoteDays(7) + if len(recentDays) > 0 { + sb.WriteString("## Recent Notes Available\n") + for _, day := range recentDays { + sb.WriteString(fmt.Sprintf("- %s\n", day)) + } + sb.WriteString("_Use `mem_search` to search across all notes._\n") + } + + return sb.String() +} + +// GetFullMemoryContext returns the full (non-progressive) memory context. +// Used when the agent explicitly needs all memory content. +func (ms *MemoryStore) GetFullMemoryContext() string { var parts []string - // Long-term memory longTerm := ms.ReadLongTerm() if longTerm != "" { parts = append(parts, "## Long-term Memory\n\n"+longTerm) } - // Recent daily notes (last 3 days) recentNotes := ms.GetRecentDailyNotes(3) if recentNotes != "" { parts = append(parts, "## Recent Daily Notes\n\n"+recentNotes) @@ -149,7 +193,6 @@ func (ms *MemoryStore) GetMemoryContext() string { return "" } - // Join parts with separator var result string for i, part := range parts { if i > 0 { @@ -159,3 +202,85 @@ func (ms *MemoryStore) GetMemoryContext() string { } return fmt.Sprintf("# Memory\n\n%s", result) } + +// buildCompactIndex creates a compact index of the memory file. +// Shows categories with entry counts and first-line previews. +// This is the core of progressive disclosure — ~70% token reduction. +func (ms *MemoryStore) buildCompactIndex(content string) string { + categoryRe := regexp.MustCompile(`^##\s+(.+)`) + + type catEntry struct { + name string + count int + previews []string + } + + var categories []catEntry + var current *catEntry + + scanner := bufio.NewScanner(strings.NewReader(content)) + + for scanner.Scan() { + line := scanner.Text() + + if m := categoryRe.FindStringSubmatch(line); len(m) > 1 { + if current != nil { + categories = append(categories, *current) + } + current = &catEntry{name: strings.TrimSpace(m[1])} + continue + } + + if current != nil && strings.HasPrefix(strings.TrimSpace(line), "- ") { + current.count++ + // Only keep first 2 as preview + if len(current.previews) < 2 { + preview := strings.TrimSpace(line) + if len(preview) > 60 { + preview = preview[:60] + "..." + } + current.previews = append(current.previews, preview) + } + } + } + if current != nil { + categories = append(categories, *current) + } + + if len(categories) == 0 { + return "_No structured entries found._" + } + + var sb strings.Builder + totalEntries := 0 + for _, cat := range categories { + totalEntries += cat.count + sb.WriteString(fmt.Sprintf("**%s** (%d entries)", cat.name, cat.count)) + if len(cat.previews) > 0 { + sb.WriteString(": ") + sb.WriteString(cat.previews[0]) + if cat.count > 1 { + sb.WriteString(fmt.Sprintf(" (+%d more)", cat.count-1)) + } + } + sb.WriteString("\n") + } + sb.WriteString(fmt.Sprintf("\n_Total: %d entries across %d categories_", totalEntries, len(categories))) + + return sb.String() +} + +// getRecentNoteDays returns a list of recent dates that have daily notes. +func (ms *MemoryStore) getRecentNoteDays(days int) []string { + var result []string + for i := 0; i < days; i++ { + date := time.Now().AddDate(0, 0, -i) + dateStr := date.Format("20060102") + monthDir := dateStr[:6] + filePath := filepath.Join(ms.memoryDir, monthDir, dateStr+".md") + if _, err := os.Stat(filePath); err == nil { + result = append(result, date.Format("2006-01-02 (Monday)")) + } + } + return result +} diff --git a/pkg/tools/memory.go b/pkg/tools/memory.go new file mode 100644 index 000000000..1fbd57892 --- /dev/null +++ b/pkg/tools/memory.go @@ -0,0 +1,490 @@ +package tools + +import ( + "bufio" + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" +) + +// MemSearchTool searches the agent's memory files (MEMORY.md + daily notes) +// using keyword matching. Returns matching lines with context. +type MemSearchTool struct { + memoryDir string +} + +func NewMemSearchTool(workspace string) *MemSearchTool { + return &MemSearchTool{ + memoryDir: filepath.Join(workspace, "memory"), + } +} + +func (t *MemSearchTool) Name() string { return "mem_search" } + +func (t *MemSearchTool) Description() string { + return "Search through long-term memory and daily notes using keywords. Returns matching entries with surrounding context. Use this to recall specific facts, preferences, or past observations without loading all memory into context." +} + +func (t *MemSearchTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "query": map[string]interface{}{ + "type": "string", + "description": "Search query (keywords separated by spaces, all must match). Case-insensitive.", + }, + "category": map[string]interface{}{ + "type": "string", + "description": "Optional: filter by category (preferences, facts, projects, decisions, observations). Leave empty to search all.", + "enum": []string{"", "preferences", "facts", "projects", "decisions", "observations"}, + }, + "days": map[string]interface{}{ + "type": "number", + "description": "How many days of daily notes to search (default: 30, max: 365). Set to 0 to search only MEMORY.md.", + }, + }, + "required": []string{"query"}, + } +} + +func (t *MemSearchTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { + query, _ := args["query"].(string) + if query == "" { + return ErrorResult("query is required") + } + + category, _ := args["category"].(string) + days := 30 + if d, ok := args["days"].(float64); ok { + days = int(d) + if days > 365 { + days = 365 + } + } + + keywords := strings.Fields(strings.ToLower(query)) + if len(keywords) == 0 { + return ErrorResult("query must contain at least one keyword") + } + + var results []searchResult + + // Search MEMORY.md + memFile := filepath.Join(t.memoryDir, "MEMORY.md") + if memResults := t.searchFile(memFile, keywords, category); len(memResults) > 0 { + results = append(results, memResults...) + } + + // Search daily notes + if days > 0 { + for i := 0; i < days; i++ { + date := time.Now().AddDate(0, 0, -i) + dateStr := date.Format("20060102") + monthDir := dateStr[:6] + filePath := filepath.Join(t.memoryDir, monthDir, dateStr+".md") + if noteResults := t.searchFile(filePath, keywords, ""); len(noteResults) > 0 { + results = append(results, noteResults...) + } + } + } + + if len(results) == 0 { + return SilentResult(fmt.Sprintf("No results found for query: %q", query)) + } + + // Cap results + if len(results) > 20 { + results = results[:20] + } + + // Format output + var sb strings.Builder + sb.WriteString(fmt.Sprintf("## Memory Search: %q\n\n", query)) + sb.WriteString(fmt.Sprintf("Found %d matches:\n\n", len(results))) + + for _, r := range results { + sb.WriteString(fmt.Sprintf("### %s (line %d)\n", r.source, r.line)) + if r.category != "" { + sb.WriteString(fmt.Sprintf("**Category**: %s\n", r.category)) + } + sb.WriteString(r.context) + sb.WriteString("\n\n") + } + + return SilentResult(sb.String()) +} + +type searchResult struct { + source string // file name + line int // line number + category string // detected category + context string // matching text with surrounding context +} + +func (t *MemSearchTool) searchFile(filePath string, keywords []string, filterCategory string) []searchResult { + f, err := os.Open(filePath) + if err != nil { + return nil + } + defer f.Close() + + source := filepath.Base(filePath) + var lines []string + scanner := bufio.NewScanner(f) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + + var results []searchResult + currentCategory := "" + categoryRe := regexp.MustCompile(`^##\s+(.+)`) + + for i, line := range lines { + // Track current category from ## headers + if m := categoryRe.FindStringSubmatch(line); len(m) > 1 { + currentCategory = strings.ToLower(strings.TrimSpace(m[1])) + } + + // Filter by category if specified + if filterCategory != "" && !strings.Contains(currentCategory, strings.ToLower(filterCategory)) { + continue + } + + // Check if all keywords match this line + lower := strings.ToLower(line) + allMatch := true + for _, kw := range keywords { + if !strings.Contains(lower, kw) { + allMatch = false + break + } + } + + if !allMatch { + continue + } + + // Build context (2 lines before and after) + start := i - 2 + if start < 0 { + start = 0 + } + end := i + 3 + if end > len(lines) { + end = len(lines) + } + + contextLines := lines[start:end] + results = append(results, searchResult{ + source: source, + line: i + 1, + category: currentCategory, + context: strings.Join(contextLines, "\n"), + }) + } + + return results +} + +// MemSaveTool saves structured observations to memory with categorization. +type MemSaveTool struct { + memoryDir string + memoryFile string +} + +func NewMemSaveTool(workspace string) *MemSaveTool { + memoryDir := filepath.Join(workspace, "memory") + return &MemSaveTool{ + memoryDir: memoryDir, + memoryFile: filepath.Join(memoryDir, "MEMORY.md"), + } +} + +func (t *MemSaveTool) Name() string { return "mem_save" } + +func (t *MemSaveTool) Description() string { + return "Save a structured observation to long-term memory (MEMORY.md) under a specific category. Use this instead of write_file for memory operations. Categories: preferences, facts, projects, decisions, observations." +} + +func (t *MemSaveTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "category": map[string]interface{}{ + "type": "string", + "description": "Memory category to save under", + "enum": []string{"preferences", "facts", "projects", "decisions", "observations"}, + }, + "content": map[string]interface{}{ + "type": "string", + "description": "The observation or fact to save. Be concise but complete.", + }, + "tags": map[string]interface{}{ + "type": "string", + "description": "Optional: comma-separated tags for easier retrieval (e.g. 'python,coding-style,formatting')", + }, + }, + "required": []string{"category", "content"}, + } +} + +func (t *MemSaveTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { + category, _ := args["category"].(string) + content, _ := args["content"].(string) + tags, _ := args["tags"].(string) + + if category == "" { + return ErrorResult("category is required") + } + if content == "" { + return ErrorResult("content is required") + } + + // Validate category + validCategories := map[string]bool{ + "preferences": true, "facts": true, "projects": true, + "decisions": true, "observations": true, + } + if !validCategories[category] { + return ErrorResult(fmt.Sprintf("invalid category %q. Use: preferences, facts, projects, decisions, observations", category)) + } + + // Ensure memory directory exists + os.MkdirAll(t.memoryDir, 0755) + + // Read existing memory + existing := "" + if data, err := os.ReadFile(t.memoryFile); err == nil { + existing = string(data) + } + + // Format the entry + timestamp := time.Now().Format("2006-01-02") + entry := fmt.Sprintf("- %s", content) + if tags != "" { + entry += fmt.Sprintf(" `[%s]`", tags) + } + entry += fmt.Sprintf(" _%s_", timestamp) + + // Category header (capitalized) + categoryHeader := fmt.Sprintf("## %s", strings.ToUpper(category[:1])+category[1:]) + + // Insert into the right category section + if existing == "" { + // Create new memory file with structure + newContent := t.buildNewMemory(categoryHeader, entry) + if err := os.WriteFile(t.memoryFile, []byte(newContent), 0644); err != nil { + return ErrorResult(fmt.Sprintf("failed to write memory: %v", err)) + } + } else { + // Insert entry under existing category, or create the category + newContent := t.insertIntoCategory(existing, categoryHeader, entry) + if err := os.WriteFile(t.memoryFile, []byte(newContent), 0644); err != nil { + return ErrorResult(fmt.Sprintf("failed to write memory: %v", err)) + } + } + + return SilentResult(fmt.Sprintf("Saved to memory [%s]: %s", category, content)) +} + +func (t *MemSaveTool) buildNewMemory(categoryHeader, entry string) string { + // Create structured memory file + categories := []string{ + "## Preferences", "## Facts", "## Projects", + "## Decisions", "## Observations", + } + + var sb strings.Builder + sb.WriteString("# Memory\n\n") + + for _, cat := range categories { + sb.WriteString(cat + "\n\n") + if cat == categoryHeader { + sb.WriteString(entry + "\n") + } + sb.WriteString("\n") + } + + return sb.String() +} + +func (t *MemSaveTool) insertIntoCategory(existing, categoryHeader, entry string) string { + lines := strings.Split(existing, "\n") + + // Find the category section + categoryIdx := -1 + nextSectionIdx := -1 + + for i, line := range lines { + if strings.TrimSpace(line) == categoryHeader { + categoryIdx = i + // Find the next ## section + for j := i + 1; j < len(lines); j++ { + if strings.HasPrefix(strings.TrimSpace(lines[j]), "## ") { + nextSectionIdx = j + break + } + } + break + } + } + + if categoryIdx == -1 { + // Category doesn't exist, append it + return existing + "\n\n" + categoryHeader + "\n\n" + entry + "\n" + } + + // Insert entry before the next section (or at end) + insertIdx := nextSectionIdx + if insertIdx == -1 { + insertIdx = len(lines) + } + + // Find the last non-empty line before the next section to insert after it + insertAt := insertIdx + for i := insertIdx - 1; i > categoryIdx; i-- { + if strings.TrimSpace(lines[i]) != "" { + insertAt = i + 1 + break + } + } + + // Insert the entry + newLines := make([]string, 0, len(lines)+1) + newLines = append(newLines, lines[:insertAt]...) + newLines = append(newLines, entry) + newLines = append(newLines, lines[insertAt:]...) + + return strings.Join(newLines, "\n") +} + +// MemIndexTool returns a compact index of all memory content. +// Used internally by progressive disclosure. +type MemIndexTool struct { + memoryDir string +} + +func NewMemIndexTool(workspace string) *MemIndexTool { + return &MemIndexTool{ + memoryDir: filepath.Join(workspace, "memory"), + } +} + +func (t *MemIndexTool) Name() string { return "mem_index" } + +func (t *MemIndexTool) Description() string { + return "Get a compact index of all memory content (categories, entry counts, recent daily notes). Use this to understand what's in memory before searching for specific items." +} + +func (t *MemIndexTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + } +} + +func (t *MemIndexTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { + index := t.buildIndex() + return SilentResult(index) +} + +func (t *MemIndexTool) buildIndex() string { + var sb strings.Builder + sb.WriteString("## Memory Index\n\n") + + // Index MEMORY.md categories + memFile := filepath.Join(t.memoryDir, "MEMORY.md") + if data, err := os.ReadFile(memFile); err == nil { + content := string(data) + categories := t.extractCategories(content) + if len(categories) > 0 { + sb.WriteString("### Long-term Memory\n") + for _, cat := range categories { + sb.WriteString(fmt.Sprintf("- **%s**: %d entries\n", cat.name, cat.count)) + // Show first 3 entries as preview + for i, preview := range cat.previews { + if i >= 3 { + if cat.count > 3 { + sb.WriteString(fmt.Sprintf(" - ... and %d more\n", cat.count-3)) + } + break + } + sb.WriteString(fmt.Sprintf(" - %s\n", preview)) + } + } + sb.WriteString("\n") + } + } else { + sb.WriteString("### Long-term Memory\n_Empty - no MEMORY.md yet_\n\n") + } + + // Index recent daily notes + sb.WriteString("### Recent Daily Notes\n") + noteCount := 0 + for i := 0; i < 7; i++ { + date := time.Now().AddDate(0, 0, -i) + dateStr := date.Format("20060102") + monthDir := dateStr[:6] + filePath := filepath.Join(t.memoryDir, monthDir, dateStr+".md") + if info, err := os.Stat(filePath); err == nil { + noteCount++ + sb.WriteString(fmt.Sprintf("- **%s** (%d bytes)\n", + date.Format("2006-01-02"), info.Size())) + } + } + if noteCount == 0 { + sb.WriteString("_No recent daily notes_\n") + } + + return sb.String() +} + +type categoryInfo struct { + name string + count int + previews []string +} + +func (t *MemIndexTool) extractCategories(content string) []categoryInfo { + lines := strings.Split(content, "\n") + var categories []categoryInfo + var current *categoryInfo + + categoryRe := regexp.MustCompile(`^##\s+(.+)`) + + for _, line := range lines { + if m := categoryRe.FindStringSubmatch(line); len(m) > 1 { + if current != nil { + categories = append(categories, *current) + } + current = &categoryInfo{name: strings.TrimSpace(m[1])} + continue + } + + if current != nil && strings.HasPrefix(strings.TrimSpace(line), "- ") { + current.count++ + // Create preview: first 80 chars of the entry + preview := strings.TrimSpace(line) + if len(preview) > 80 { + preview = preview[:80] + "..." + } + current.previews = append(current.previews, preview) + } + } + + if current != nil { + categories = append(categories, *current) + } + + // Sort by count descending + sort.Slice(categories, func(i, j int) bool { + return categories[i].count > categories[j].count + }) + + return categories +} diff --git a/pkg/tools/memory_test.go b/pkg/tools/memory_test.go new file mode 100644 index 000000000..7a65e4202 --- /dev/null +++ b/pkg/tools/memory_test.go @@ -0,0 +1,217 @@ +package tools + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestMemSearchTool_BasicSearch(t *testing.T) { + // Setup temp workspace + tmpDir := t.TempDir() + memDir := filepath.Join(tmpDir, "memory") + os.MkdirAll(memDir, 0755) + + // Create MEMORY.md with structured content + memContent := `# Memory + +## Preferences + +- User prefers dark mode for all editors _2026-01-15_ +- Language: Portuguese (Brazil) _2026-01-10_ + +## Facts + +- User works at Acme Corp _2026-02-01_ +- Main project is PicoClaw _2026-02-10_ + +## Projects + +- PicoClaw: personal AI agent in Go _2026-02-10_ +` + os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte(memContent), 0644) + + tool := NewMemSearchTool(tmpDir) + + // Test basic search + result := tool.Execute(context.Background(), map[string]interface{}{ + "query": "dark mode", + }) + if result.IsError { + t.Fatalf("unexpected error: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "dark mode") { + t.Errorf("expected result to contain 'dark mode', got: %s", result.ForLLM) + } + + // Test category filter + result = tool.Execute(context.Background(), map[string]interface{}{ + "query": "PicoClaw", + "category": "projects", + }) + if result.IsError { + t.Fatalf("unexpected error: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "PicoClaw") { + t.Errorf("expected result to contain 'PicoClaw', got: %s", result.ForLLM) + } + + // Test no results + result = tool.Execute(context.Background(), map[string]interface{}{ + "query": "nonexistent keyword xyz", + }) + if result.IsError { + t.Fatalf("unexpected error: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "No results") { + t.Errorf("expected 'No results', got: %s", result.ForLLM) + } +} + +func TestMemSaveTool_SaveAndRetrieve(t *testing.T) { + tmpDir := t.TempDir() + memDir := filepath.Join(tmpDir, "memory") + os.MkdirAll(memDir, 0755) + + saveTool := NewMemSaveTool(tmpDir) + + // Save a preference + result := saveTool.Execute(context.Background(), map[string]interface{}{ + "category": "preferences", + "content": "User prefers vim keybindings", + "tags": "editor,vim", + }) + if result.IsError { + t.Fatalf("unexpected error: %s", result.ForLLM) + } + + // Verify file was created with correct structure + data, err := os.ReadFile(filepath.Join(memDir, "MEMORY.md")) + if err != nil { + t.Fatalf("failed to read MEMORY.md: %v", err) + } + content := string(data) + + if !strings.Contains(content, "## Preferences") { + t.Error("expected ## Preferences section") + } + if !strings.Contains(content, "vim keybindings") { + t.Error("expected saved content") + } + if !strings.Contains(content, "[editor,vim]") { + t.Error("expected tags") + } + + // Save another entry in same category + result = saveTool.Execute(context.Background(), map[string]interface{}{ + "category": "preferences", + "content": "Prefers dark theme", + }) + if result.IsError { + t.Fatalf("unexpected error: %s", result.ForLLM) + } + + data, _ = os.ReadFile(filepath.Join(memDir, "MEMORY.md")) + content = string(data) + if !strings.Contains(content, "vim keybindings") || !strings.Contains(content, "dark theme") { + t.Error("expected both entries to be present") + } + + // Save to a different category + result = saveTool.Execute(context.Background(), map[string]interface{}{ + "category": "facts", + "content": "User's name is John", + }) + if result.IsError { + t.Fatalf("unexpected error: %s", result.ForLLM) + } + + data, _ = os.ReadFile(filepath.Join(memDir, "MEMORY.md")) + content = string(data) + if !strings.Contains(content, "## Facts") { + t.Error("expected ## Facts section") + } + + // Now verify mem_search can find the saved entries + searchTool := NewMemSearchTool(tmpDir) + result = searchTool.Execute(context.Background(), map[string]interface{}{ + "query": "vim", + }) + if !strings.Contains(result.ForLLM, "vim keybindings") { + t.Errorf("expected to find saved entry via search, got: %s", result.ForLLM) + } +} + +func TestMemIndexTool_BuildIndex(t *testing.T) { + tmpDir := t.TempDir() + memDir := filepath.Join(tmpDir, "memory") + os.MkdirAll(memDir, 0755) + + // Create structured memory + memContent := `# Memory + +## Preferences + +- Dark mode enabled +- Portuguese language +- Vim keybindings + +## Facts + +- Works at Acme Corp + +## Projects + +- PicoClaw: AI agent +- Website: portfolio site +` + os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte(memContent), 0644) + + tool := NewMemIndexTool(tmpDir) + result := tool.Execute(context.Background(), nil) + + if result.IsError { + t.Fatalf("unexpected error: %s", result.ForLLM) + } + + // Check index contains category summaries + if !strings.Contains(result.ForLLM, "Preferences") { + t.Error("expected Preferences in index") + } + if !strings.Contains(result.ForLLM, "3 entries") { + t.Errorf("expected '3 entries' for Preferences, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "Facts") { + t.Error("expected Facts in index") + } + if !strings.Contains(result.ForLLM, "Projects") { + t.Error("expected Projects in index") + } +} + +func TestMemSaveTool_InvalidCategory(t *testing.T) { + tmpDir := t.TempDir() + saveTool := NewMemSaveTool(tmpDir) + + result := saveTool.Execute(context.Background(), map[string]interface{}{ + "category": "invalid_category", + "content": "test", + }) + if !result.IsError { + t.Error("expected error for invalid category") + } +} + +func TestMemSearchTool_EmptyQuery(t *testing.T) { + tmpDir := t.TempDir() + tool := NewMemSearchTool(tmpDir) + + result := tool.Execute(context.Background(), map[string]interface{}{ + "query": "", + }) + if !result.IsError { + t.Error("expected error for empty query") + } +}