mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-13 07:57:29 +08:00
261 lines
6.9 KiB
Go
261 lines
6.9 KiB
Go
// ClawGo - Ultra-lightweight personal AI agent
|
|
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
|
|
// License: MIT
|
|
//
|
|
// Copyright (c) 2026 ClawGo contributors
|
|
|
|
package agent
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"clawgo/pkg/config"
|
|
"clawgo/pkg/logger"
|
|
)
|
|
|
|
// MemoryStore manages persistent memory for the agent.
|
|
// - Long-term memory: memory/MEMORY.md
|
|
// - Daily notes: memory/YYYYMM/YYYYMMDD.md
|
|
type MemoryStore struct {
|
|
workspace string
|
|
memoryDir string
|
|
memoryFile string
|
|
layered bool
|
|
recentDays int
|
|
includeProfile bool
|
|
includeProject bool
|
|
includeProcedure bool
|
|
}
|
|
|
|
// NewMemoryStore creates a new MemoryStore with the given workspace path.
|
|
// It ensures the memory directory exists.
|
|
func NewMemoryStore(workspace string, cfg config.MemoryConfig) *MemoryStore {
|
|
memoryDir := filepath.Join(workspace, "memory")
|
|
memoryFile := filepath.Join(memoryDir, "MEMORY.md")
|
|
|
|
// Ensure memory directory exists
|
|
if err := os.MkdirAll(memoryDir, 0755); err != nil {
|
|
logger.ErrorCF("memory", "Failed to create memory directory", map[string]interface{}{
|
|
"memory_dir": memoryDir,
|
|
logger.FieldError: err.Error(),
|
|
})
|
|
}
|
|
|
|
// Ensure MEMORY.md exists for first run (even without onboard).
|
|
if _, err := os.Stat(memoryFile); os.IsNotExist(err) {
|
|
initial := `# Long-term Memory
|
|
|
|
This file stores important information that should persist across sessions.
|
|
|
|
## User Information
|
|
|
|
(Important facts about user)
|
|
|
|
## Preferences
|
|
|
|
(User preferences learned over time)
|
|
|
|
## Important Notes
|
|
|
|
(Things to remember)
|
|
`
|
|
if writeErr := os.WriteFile(memoryFile, []byte(initial), 0644); writeErr != nil {
|
|
logger.ErrorCF("memory", "Failed to initialize MEMORY.md", map[string]interface{}{
|
|
"memory_file": memoryFile,
|
|
logger.FieldError: writeErr.Error(),
|
|
})
|
|
}
|
|
}
|
|
|
|
if cfg.Layered {
|
|
_ = os.MkdirAll(filepath.Join(memoryDir, "layers"), 0755)
|
|
ensureLayerFile(filepath.Join(memoryDir, "layers", "profile.md"), "# User Profile\n\nStable user profile, preferences, identity traits.\n")
|
|
ensureLayerFile(filepath.Join(memoryDir, "layers", "project.md"), "# Project Memory\n\nProject-specific architecture decisions and constraints.\n")
|
|
ensureLayerFile(filepath.Join(memoryDir, "layers", "procedures.md"), "# Procedures Memory\n\nReusable workflows, command recipes, and runbooks.\n")
|
|
}
|
|
|
|
recentDays := cfg.RecentDays
|
|
if recentDays <= 0 {
|
|
recentDays = 3
|
|
}
|
|
|
|
return &MemoryStore{
|
|
workspace: workspace,
|
|
memoryDir: memoryDir,
|
|
memoryFile: memoryFile,
|
|
layered: cfg.Layered,
|
|
recentDays: recentDays,
|
|
includeProfile: cfg.Layers.Profile,
|
|
includeProject: cfg.Layers.Project,
|
|
includeProcedure: cfg.Layers.Procedures,
|
|
}
|
|
}
|
|
|
|
func ensureLayerFile(path, initial string) {
|
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
_ = os.WriteFile(path, []byte(initial), 0644)
|
|
}
|
|
}
|
|
|
|
// getTodayFile returns the path to today's daily note file (memory/YYYYMM/YYYYMMDD.md).
|
|
func (ms *MemoryStore) getTodayFile() string {
|
|
today := time.Now().Format("20060102") // YYYYMMDD
|
|
monthDir := today[:6] // YYYYMM
|
|
filePath := filepath.Join(ms.memoryDir, monthDir, today+".md")
|
|
return filePath
|
|
}
|
|
|
|
// ReadLongTerm reads the long-term memory (MEMORY.md).
|
|
// Returns empty string if the file doesn't exist.
|
|
func (ms *MemoryStore) ReadLongTerm() string {
|
|
if data, err := os.ReadFile(ms.memoryFile); err == nil {
|
|
return string(data)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// WriteLongTerm writes content to the long-term memory file (MEMORY.md).
|
|
func (ms *MemoryStore) WriteLongTerm(content string) error {
|
|
if err := os.MkdirAll(ms.memoryDir, 0755); err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(ms.memoryFile, []byte(content), 0644)
|
|
}
|
|
|
|
// ReadToday reads today's daily note.
|
|
// Returns empty string if the file doesn't exist.
|
|
func (ms *MemoryStore) ReadToday() string {
|
|
todayFile := ms.getTodayFile()
|
|
if data, err := os.ReadFile(todayFile); err == nil {
|
|
return string(data)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// AppendToday appends content to today's daily note.
|
|
// If the file doesn't exist, it creates a new file with a date header.
|
|
func (ms *MemoryStore) AppendToday(content string) error {
|
|
todayFile := ms.getTodayFile()
|
|
|
|
// Ensure month directory exists
|
|
monthDir := filepath.Dir(todayFile)
|
|
if err := os.MkdirAll(monthDir, 0755); err != nil {
|
|
return err
|
|
}
|
|
|
|
var existingContent string
|
|
if data, err := os.ReadFile(todayFile); err == nil {
|
|
existingContent = string(data)
|
|
}
|
|
|
|
var newContent string
|
|
if existingContent == "" {
|
|
// Add header for new day
|
|
header := fmt.Sprintf("# %s\n\n", time.Now().Format("2006-01-02"))
|
|
newContent = header + content
|
|
} else {
|
|
// Append to existing content
|
|
newContent = existingContent + "\n" + content
|
|
}
|
|
|
|
return os.WriteFile(todayFile, []byte(newContent), 0644)
|
|
}
|
|
|
|
// GetRecentDailyNotes returns daily notes from the last N days.
|
|
// Contents are joined with "---" separator.
|
|
func (ms *MemoryStore) GetRecentDailyNotes(days int) string {
|
|
var notes []string
|
|
|
|
for i := 0; i < days; i++ {
|
|
date := time.Now().AddDate(0, 0, -i)
|
|
dateStr := date.Format("20060102") // YYYYMMDD
|
|
monthDir := dateStr[:6] // YYYYMM
|
|
filePath := filepath.Join(ms.memoryDir, monthDir, dateStr+".md")
|
|
|
|
if data, err := os.ReadFile(filePath); err == nil {
|
|
notes = append(notes, string(data))
|
|
}
|
|
}
|
|
|
|
if len(notes) == 0 {
|
|
return ""
|
|
}
|
|
|
|
// Join with separator
|
|
var result string
|
|
for i, note := range notes {
|
|
if i > 0 {
|
|
result += "\n\n---\n\n"
|
|
}
|
|
result += note
|
|
}
|
|
return result
|
|
}
|
|
|
|
// GetMemoryContext returns formatted memory context for the agent prompt.
|
|
// Includes long-term memory and recent daily notes.
|
|
func (ms *MemoryStore) GetMemoryContext() string {
|
|
var parts []string
|
|
|
|
if ms.layered {
|
|
layerParts := ms.getLayeredContext()
|
|
parts = append(parts, layerParts...)
|
|
}
|
|
|
|
// Long-term memory
|
|
longTerm := ms.ReadLongTerm()
|
|
if longTerm != "" {
|
|
parts = append(parts, "## Long-term Memory\n\n"+longTerm)
|
|
}
|
|
|
|
// Recent daily notes
|
|
recentNotes := ms.GetRecentDailyNotes(ms.recentDays)
|
|
if recentNotes != "" {
|
|
parts = append(parts, "## Recent Daily Notes\n\n"+recentNotes)
|
|
}
|
|
|
|
if len(parts) == 0 {
|
|
return ""
|
|
}
|
|
|
|
// Join parts with separator
|
|
var result string
|
|
for i, part := range parts {
|
|
if i > 0 {
|
|
result += "\n\n---\n\n"
|
|
}
|
|
result += part
|
|
}
|
|
return fmt.Sprintf("# Memory\n\n%s", result)
|
|
}
|
|
|
|
func (ms *MemoryStore) getLayeredContext() []string {
|
|
parts := []string{}
|
|
readLayer := func(filename, title string) {
|
|
data, err := os.ReadFile(filepath.Join(ms.memoryDir, "layers", filename))
|
|
if err != nil {
|
|
return
|
|
}
|
|
content := string(data)
|
|
if strings.TrimSpace(content) == "" {
|
|
return
|
|
}
|
|
parts = append(parts, fmt.Sprintf("## %s\n\n%s", title, content))
|
|
}
|
|
|
|
if ms.includeProfile {
|
|
readLayer("profile.md", "Memory Layer: Profile")
|
|
}
|
|
if ms.includeProject {
|
|
readLayer("project.md", "Memory Layer: Project")
|
|
}
|
|
if ms.includeProcedure {
|
|
readLayer("procedures.md", "Memory Layer: Procedures")
|
|
}
|
|
return parts
|
|
}
|