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
This commit is contained in:
Claude 2026-02-18 23:05:03 +00:00
parent 42be3dec37
commit 7b4f8cc5c6
No known key found for this signature in database
5 changed files with 843 additions and 6 deletions

View file

@ -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 {

View file

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

View file

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

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

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

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

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