diff --git a/pkg/agent/context.go b/pkg/agent/context.go index ba07e33d3..2d75152d0 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -74,14 +74,26 @@ Your workspace is at: %s %s +## Second Brain (Memory Database) + +Your memory is stored in a structured database at %s/memory/brain.db. +Use these tools to remember and recall information across sessions: + +- **memory_store** — save a fact, preference, note, or task (categories: general, fact, preference, task, note, contact) +- **memory_search** — full-text search across all stored memories +- **memory_list** — browse recent memories, optionally by category +- **memory_delete** — remove a memory entry by ID + +Proactively use memory_store when the user shares anything worth remembering. Use memory_search before answering questions that might draw from stored knowledge. + ## Important Rules 1. **ALWAYS use tools** - When you need to perform an action (schedule reminders, send messages, execute commands, etc.), you MUST call the appropriate tool. Do NOT just say you'll do it or pretend to do it. 2. **Be helpful and accurate** - When using tools, briefly explain what you're doing. -3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md`, - now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection, workspacePath) +3. **Memory** - Proactively store memorable information with memory_store. Legacy notes also live at %s/memory/MEMORY.md`, + now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection, workspacePath, workspacePath) } func (cb *ContextBuilder) buildToolsSection() string { diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index c6a54c7d2..68407f045 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/memory" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/routing" "github.com/sipeed/picoclaw/pkg/session" @@ -55,6 +56,14 @@ func NewAgentInstance( toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict)) toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict)) + // Second brain: structured memory database + if memDB, err := memory.Open(workspace); err == nil { + toolsRegistry.Register(tools.NewMemoryStoreTool(memDB)) + toolsRegistry.Register(tools.NewMemorySearchTool(memDB)) + toolsRegistry.Register(tools.NewMemoryListTool(memDB)) + toolsRegistry.Register(tools.NewMemoryDeleteTool(memDB)) + } + sessionsDir := filepath.Join(workspace, "sessions") sessionsManager := session.NewSessionManager(sessionsDir) diff --git a/pkg/memory/db.go b/pkg/memory/db.go new file mode 100644 index 000000000..32a549004 --- /dev/null +++ b/pkg/memory/db.go @@ -0,0 +1,292 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +// Package memory provides a structured, persistent memory database for the agent. +// It stores entries as a JSON file with full-text search and tag-based organization. +package memory + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +// Entry represents a stored memory. +type Entry struct { + ID string `json:"id"` + Content string `json:"content"` + Category string `json:"category"` + Tags []string `json:"tags"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// brainFile is the on-disk format. +type brainFile struct { + Version int `json:"version"` + Entries []*Entry `json:"entries"` +} + +// DB is a lightweight in-memory database backed by a JSON file. +type DB struct { + mu sync.RWMutex + path string + entries []*Entry +} + +// Open opens or creates the memory database at workspace/memory/brain.db. +func Open(workspace string) (*DB, error) { + memDir := filepath.Join(workspace, "memory") + if err := os.MkdirAll(memDir, 0o755); err != nil { + return nil, fmt.Errorf("memory: failed to create directory: %w", err) + } + + dbPath := filepath.Join(memDir, "brain.db") + db := &DB{path: dbPath} + + if err := db.load(); err != nil { + return nil, err + } + return db, nil +} + +// Store saves a new memory entry and returns it. +func (db *DB) Store(content, category string, tags []string) (*Entry, error) { + if strings.TrimSpace(content) == "" { + return nil, fmt.Errorf("memory: content must not be empty") + } + if category == "" { + category = "general" + } + if tags == nil { + tags = []string{} + } + now := time.Now().UTC() + e := &Entry{ + ID: newID(), + Content: content, + Category: category, + Tags: tags, + CreatedAt: now, + UpdatedAt: now, + } + + db.mu.Lock() + db.entries = append(db.entries, e) + db.mu.Unlock() + + if err := db.save(); err != nil { + return nil, err + } + return e, nil +} + +// Search performs full-text search across content and tags. +// Returns up to limit entries ordered by relevance then recency. +func (db *DB) Search(query string, limit int) []*Entry { + if limit <= 0 { + limit = 10 + } + tokens := tokenize(query) + if len(tokens) == 0 { + return db.List("", limit) + } + + type scored struct { + entry *Entry + score int + } + + db.mu.RLock() + results := make([]scored, 0, len(db.entries)) + for _, e := range db.entries { + score := scoreEntry(e, tokens) + if score > 0 { + results = append(results, scored{e, score}) + } + } + db.mu.RUnlock() + + sort.Slice(results, func(i, j int) bool { + if results[i].score != results[j].score { + return results[i].score > results[j].score + } + return results[i].entry.CreatedAt.After(results[j].entry.CreatedAt) + }) + + out := make([]*Entry, 0, limit) + for i, r := range results { + if i >= limit { + break + } + out = append(out, r.entry) + } + return out +} + +// List returns recent entries, optionally filtered by category. +func (db *DB) List(category string, limit int) []*Entry { + if limit <= 0 { + limit = 20 + } + + db.mu.RLock() + snapshot := make([]*Entry, len(db.entries)) + copy(snapshot, db.entries) + db.mu.RUnlock() + + // Sort descending by creation time + sort.Slice(snapshot, func(i, j int) bool { + return snapshot[i].CreatedAt.After(snapshot[j].CreatedAt) + }) + + out := make([]*Entry, 0, limit) + for _, e := range snapshot { + if category != "" && e.Category != category { + continue + } + out = append(out, e) + if len(out) >= limit { + break + } + } + return out +} + +// Delete removes an entry by ID. Returns error if not found. +func (db *DB) Delete(id string) error { + db.mu.Lock() + idx := -1 + for i, e := range db.entries { + if e.ID == id { + idx = i + break + } + } + if idx == -1 { + db.mu.Unlock() + return fmt.Errorf("memory: entry %q not found", id) + } + db.entries = append(db.entries[:idx], db.entries[idx+1:]...) + db.mu.Unlock() + + return db.save() +} + +// Count returns the total number of stored entries. +func (db *DB) Count() int { + db.mu.RLock() + defer db.mu.RUnlock() + return len(db.entries) +} + +// GetRecent returns the n most recent entries for context injection. +func (db *DB) GetRecent(n int) []*Entry { + return db.List("", n) +} + +// --- persistence --- + +func (db *DB) load() error { + data, err := os.ReadFile(db.path) + if os.IsNotExist(err) { + return nil // fresh start + } + if err != nil { + return fmt.Errorf("memory: failed to read %s: %w", db.path, err) + } + + var bf brainFile + if err := json.Unmarshal(data, &bf); err != nil { + return fmt.Errorf("memory: failed to parse %s: %w", db.path, err) + } + + db.mu.Lock() + db.entries = bf.Entries + if db.entries == nil { + db.entries = []*Entry{} + } + db.mu.Unlock() + return nil +} + +func (db *DB) save() error { + db.mu.RLock() + bf := brainFile{ + Version: 1, + Entries: db.entries, + } + db.mu.RUnlock() + + data, err := json.MarshalIndent(bf, "", " ") + if err != nil { + return fmt.Errorf("memory: failed to marshal: %w", err) + } + + tmp := db.path + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return fmt.Errorf("memory: failed to write temp: %w", err) + } + if err := os.Rename(tmp, db.path); err != nil { + os.Remove(tmp) + return fmt.Errorf("memory: failed to rename: %w", err) + } + return nil +} + +// --- helpers --- + +func newID() string { + b := make([]byte, 8) + rand.Read(b) //nolint:errcheck + return hex.EncodeToString(b) +} + +// tokenize splits a query into lowercase tokens. +func tokenize(s string) []string { + s = strings.ToLower(s) + parts := strings.FieldsFunc(s, func(r rune) bool { + return r == ' ' || r == ',' || r == ';' || r == '.' || r == '!' + }) + out := parts[:0] + for _, p := range parts { + if len(p) > 1 { + out = append(out, p) + } + } + return out +} + +// scoreEntry returns a relevance score for an entry given query tokens. +// Higher = more relevant. +func scoreEntry(e *Entry, tokens []string) int { + contentLow := strings.ToLower(e.Content) + tagsLow := strings.ToLower(strings.Join(e.Tags, " ")) + categoryLow := strings.ToLower(e.Category) + + score := 0 + for _, tok := range tokens { + // Exact word boundary in content scores highest + if strings.Contains(contentLow, tok) { + score += 2 + } + // Tag match is high value + if strings.Contains(tagsLow, tok) { + score += 3 + } + // Category match + if strings.Contains(categoryLow, tok) { + score += 1 + } + } + return score +} diff --git a/pkg/tools/memory.go b/pkg/tools/memory.go new file mode 100644 index 000000000..ef340b9c5 --- /dev/null +++ b/pkg/tools/memory.go @@ -0,0 +1,229 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package tools + +import ( + "context" + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/memory" +) + +// MemoryStoreTool stores a new memory entry in the brain database. +type MemoryStoreTool struct { + db *memory.DB +} + +func NewMemoryStoreTool(db *memory.DB) *MemoryStoreTool { + return &MemoryStoreTool{db: db} +} + +func (t *MemoryStoreTool) Name() string { return "memory_store" } + +func (t *MemoryStoreTool) Description() string { + return "Store a memory entry in the second brain database. Use this to remember facts, notes, preferences, or anything worth keeping for future sessions." +} + +func (t *MemoryStoreTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "content": map[string]any{ + "type": "string", + "description": "The content to remember. Be specific and detailed.", + }, + "category": map[string]any{ + "type": "string", + "description": "Category for organization (e.g. 'preference', 'fact', 'task', 'note', 'contact'). Defaults to 'general'.", + }, + "tags": map[string]any{ + "type": "array", + "items": map[string]any{"type": "string"}, + "description": "Optional tags for easier retrieval (e.g. ['work', 'important']).", + }, + }, + "required": []string{"content"}, + } +} + +func (t *MemoryStoreTool) Execute(_ context.Context, args map[string]any) *ToolResult { + content, _ := args["content"].(string) + category, _ := args["category"].(string) + + var tags []string + if raw, ok := args["tags"].([]any); ok { + for _, v := range raw { + if s, ok := v.(string); ok && s != "" { + tags = append(tags, s) + } + } + } + + e, err := t.db.Store(content, category, tags) + if err != nil { + return ErrorResult(fmt.Sprintf("Failed to store memory: %s", err)).WithError(err) + } + + return SilentResult(fmt.Sprintf("Memory stored (id=%s, category=%s)", e.ID, e.Category)) +} + +// MemorySearchTool searches stored memories using full-text search. +type MemorySearchTool struct { + db *memory.DB +} + +func NewMemorySearchTool(db *memory.DB) *MemorySearchTool { + return &MemorySearchTool{db: db} +} + +func (t *MemorySearchTool) Name() string { return "memory_search" } + +func (t *MemorySearchTool) Description() string { + return "Search stored memories using full-text search across content and tags. Use this to recall relevant information." +} + +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 against memory content and tags.", + }, + "limit": map[string]any{ + "type": "integer", + "description": "Maximum number of results to return (default: 10).", + }, + }, + "required": []string{"query"}, + } +} + +func (t *MemorySearchTool) Execute(_ context.Context, args map[string]any) *ToolResult { + query, _ := args["query"].(string) + limit := 10 + if v, ok := args["limit"].(float64); ok && v > 0 { + limit = int(v) + } + + results := t.db.Search(query, limit) + if len(results) == 0 { + return SilentResult(fmt.Sprintf("No memories found for query: %q", query)) + } + + return SilentResult(formatEntries(results)) +} + +// MemoryListTool lists recent memories, optionally filtered by category. +type MemoryListTool struct { + db *memory.DB +} + +func NewMemoryListTool(db *memory.DB) *MemoryListTool { + return &MemoryListTool{db: db} +} + +func (t *MemoryListTool) Name() string { return "memory_list" } + +func (t *MemoryListTool) Description() string { + return "List recent memories from the second brain database, optionally filtered by category." +} + +func (t *MemoryListTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "category": map[string]any{ + "type": "string", + "description": "Filter by category (e.g. 'preference', 'fact', 'task'). Omit to list all.", + }, + "limit": map[string]any{ + "type": "integer", + "description": "Maximum number of results to return (default: 20).", + }, + }, + } +} + +func (t *MemoryListTool) Execute(_ context.Context, args map[string]any) *ToolResult { + category, _ := args["category"].(string) + limit := 20 + if v, ok := args["limit"].(float64); ok && v > 0 { + limit = int(v) + } + + entries := t.db.List(category, limit) + total := t.db.Count() + + if len(entries) == 0 { + msg := fmt.Sprintf("No memories found (total in brain: %d).", total) + if category != "" { + msg = fmt.Sprintf("No memories in category %q (total in brain: %d).", category, total) + } + return SilentResult(msg) + } + + header := fmt.Sprintf("Showing %d of %d memories", len(entries), total) + if category != "" { + header = fmt.Sprintf("Showing %d memories in category %q", len(entries), category) + } + return SilentResult(header + ":\n\n" + formatEntries(entries)) +} + +// MemoryDeleteTool deletes a memory entry by ID. +type MemoryDeleteTool struct { + db *memory.DB +} + +func NewMemoryDeleteTool(db *memory.DB) *MemoryDeleteTool { + return &MemoryDeleteTool{db: db} +} + +func (t *MemoryDeleteTool) Name() string { return "memory_delete" } + +func (t *MemoryDeleteTool) Description() string { + return "Delete a specific memory entry by its ID. Use memory_list or memory_search first to find the ID." +} + +func (t *MemoryDeleteTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{ + "type": "string", + "description": "The ID of the memory entry to delete.", + }, + }, + "required": []string{"id"}, + } +} + +func (t *MemoryDeleteTool) Execute(_ context.Context, args map[string]any) *ToolResult { + id, _ := args["id"].(string) + if id == "" { + return ErrorResult("id is required") + } + + if err := t.db.Delete(id); err != nil { + return ErrorResult(fmt.Sprintf("Failed to delete memory: %s", err)).WithError(err) + } + return SilentResult(fmt.Sprintf("Memory %s deleted.", id)) +} + +// formatEntries formats a slice of memory entries for display. +func formatEntries(entries []*memory.Entry) string { + var sb strings.Builder + for _, e := range entries { + sb.WriteString(fmt.Sprintf("[%s] (%s)", e.ID, e.Category)) + if len(e.Tags) > 0 { + sb.WriteString(fmt.Sprintf(" #%s", strings.Join(e.Tags, " #"))) + } + sb.WriteString(fmt.Sprintf(" — %s\n", e.CreatedAt.Format("2006-01-02"))) + sb.WriteString(fmt.Sprintf(" %s\n\n", e.Content)) + } + return strings.TrimRight(sb.String(), "\n") +}