feat: add second brain memory database with structured storage and tools

Replaces flat MEMORY.md with a JSON-backed database (workspace/memory/brain.db)
that supports full-text search, tags, categories, and persistent recall across sessions.

New package pkg/memory/db.go:
- Entry struct with id, content, category, tags, timestamps
- Store, Search (scored full-text), List (by category), Delete, GetRecent
- Atomic saves via temp-file + rename, zero new dependencies (stdlib only)

New tools in pkg/tools/memory.go:
- memory_store  — persist a fact/preference/note/task/contact
- memory_search — scored keyword search across content and tags
- memory_list   — recent entries, optionally filtered by category
- memory_delete — remove an entry by ID

Wired into pkg/agent/instance.go (tool registration) and pkg/agent/context.go
(system prompt teaches the agent to proactively use memory tools).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Non 2026-03-06 18:08:32 +07:00
parent ec6da7a530
commit 598eaea5e4
4 changed files with 544 additions and 2 deletions

View file

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

View file

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

292
pkg/memory/db.go Normal file
View file

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

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

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