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:
parent
42be3dec37
commit
7b4f8cc5c6
5 changed files with 843 additions and 6 deletions
|
|
@ -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.
|
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.
|
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.
|
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.`,
|
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 {
|
func (cb *ContextBuilder) buildToolsSection() string {
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,11 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg
|
||||||
}
|
}
|
||||||
registry.Register(tools.NewWebFetchTool(50000))
|
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
|
// Hardware tools (I2C, SPI) - Linux only, returns error on other platforms
|
||||||
registry.Register(tools.NewI2CTool())
|
registry.Register(tools.NewI2CTool())
|
||||||
registry.Register(tools.NewSPITool())
|
registry.Register(tools.NewSPITool())
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,12 @@
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -129,17 +132,58 @@ func (ms *MemoryStore) GetRecentDailyNotes(days int) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMemoryContext returns formatted memory context for the agent prompt.
|
// 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 {
|
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
|
var parts []string
|
||||||
|
|
||||||
// Long-term memory
|
|
||||||
longTerm := ms.ReadLongTerm()
|
longTerm := ms.ReadLongTerm()
|
||||||
if longTerm != "" {
|
if longTerm != "" {
|
||||||
parts = append(parts, "## Long-term Memory\n\n"+longTerm)
|
parts = append(parts, "## Long-term Memory\n\n"+longTerm)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recent daily notes (last 3 days)
|
|
||||||
recentNotes := ms.GetRecentDailyNotes(3)
|
recentNotes := ms.GetRecentDailyNotes(3)
|
||||||
if recentNotes != "" {
|
if recentNotes != "" {
|
||||||
parts = append(parts, "## Recent Daily Notes\n\n"+recentNotes)
|
parts = append(parts, "## Recent Daily Notes\n\n"+recentNotes)
|
||||||
|
|
@ -149,7 +193,6 @@ func (ms *MemoryStore) GetMemoryContext() string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Join parts with separator
|
|
||||||
var result string
|
var result string
|
||||||
for i, part := range parts {
|
for i, part := range parts {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
|
|
@ -159,3 +202,85 @@ func (ms *MemoryStore) GetMemoryContext() string {
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("# Memory\n\n%s", result)
|
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
490
pkg/tools/memory.go
Normal 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
217
pkg/tools/memory_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue