// Package memory provides persistent context and memory capabilities for AI patrol. // This file implements cross-run memory that helps AI learn and remember insights. package memory import ( "encoding/json" "fmt" "os" "path/filepath" "sort" "sync" "sync/atomic" "time" "github.com/rs/zerolog/log" ) // MemoryType categorizes different types of memories type MemoryType string const ( // MemoryTypeResource stores per-resource notes MemoryTypeResource MemoryType = "resource" // MemoryTypeIncident stores past incident learnings MemoryTypeIncident MemoryType = "incident" // MemoryTypePattern stores learned patterns MemoryTypePattern MemoryType = "pattern" // MemoryTypePreference stores user preferences MemoryTypePreference MemoryType = "preference" ) // Memory represents a single memory entry type Memory struct { ID string `json:"id"` Type MemoryType `json:"type"` ResourceID string `json:"resource_id,omitempty"` Content string `json:"content"` Source string `json:"source,omitempty"` // "ai", "user", "system" CreatedAt time.Time `json:"created_at"` LastUsed time.Time `json:"last_used"` UseCount int `json:"use_count"` Relevance float64 `json:"relevance"` // 0-1, decays over time Tags []string `json:"tags,omitempty"` RelatedIDs []string `json:"related_ids,omitempty"` // Related memory IDs } // ResourceMemory stores notes about a specific resource type ResourceMemory struct { ResourceID string `json:"resource_id"` ResourceName string `json:"resource_name,omitempty"` ResourceType string `json:"resource_type,omitempty"` Notes []string `json:"notes"` Patterns []string `json:"patterns,omitempty"` // Observed patterns LastUpdated time.Time `json:"last_updated"` } // IncidentMemory stores learnings from past incidents type IncidentMemory struct { ID string `json:"id"` ResourceID string `json:"resource_id"` Timestamp time.Time `json:"timestamp"` Summary string `json:"summary"` RootCause string `json:"root_cause,omitempty"` Resolution string `json:"resolution,omitempty"` LessonsLearned []string `json:"lessons_learned,omitempty"` } // PatternMemory stores learned operational patterns type PatternMemory struct { ID string `json:"id"` Pattern string `json:"pattern"` Description string `json:"description"` Occurrences int `json:"occurrences"` LastSeen time.Time `json:"last_seen"` Confidence float64 `json:"confidence"` Example string `json:"example,omitempty"` } // ContextStoreConfig configures the context store type ContextStoreConfig struct { DataDir string MaxMemoriesPerType int MaxResourceNotes int RetentionDays int RelevanceDecayDays int // Days after which relevance starts decaying } // DefaultContextStoreConfig returns sensible defaults func DefaultContextStoreConfig() ContextStoreConfig { return ContextStoreConfig{ MaxMemoriesPerType: 1000, MaxResourceNotes: 20, RetentionDays: 90, RelevanceDecayDays: 7, } } // ContextStore stores and manages persistent AI context type ContextStore struct { mu sync.RWMutex saveMu sync.Mutex // serializes disk writes to prevent .tmp file races config ContextStoreConfig // Memories by type memories map[MemoryType]map[string]*Memory // Resource-specific memories resourceMemories map[string]*ResourceMemory // Incident memories incidentMemories map[string]*IncidentMemory // Pattern memories patternMemories map[string]*PatternMemory // Persistence dataDir string dirty bool } // NewContextStore creates a new context store func NewContextStore(cfg ContextStoreConfig) *ContextStore { if cfg.MaxMemoriesPerType <= 0 { cfg.MaxMemoriesPerType = 1000 } if cfg.MaxResourceNotes <= 0 { cfg.MaxResourceNotes = 20 } if cfg.RetentionDays <= 0 { cfg.RetentionDays = 90 } if cfg.RelevanceDecayDays <= 0 { cfg.RelevanceDecayDays = 7 } store := &ContextStore{ config: cfg, memories: make(map[MemoryType]map[string]*Memory), resourceMemories: make(map[string]*ResourceMemory), incidentMemories: make(map[string]*IncidentMemory), patternMemories: make(map[string]*PatternMemory), dataDir: cfg.DataDir, } // Initialize memory type maps store.memories[MemoryTypeResource] = make(map[string]*Memory) store.memories[MemoryTypeIncident] = make(map[string]*Memory) store.memories[MemoryTypePattern] = make(map[string]*Memory) store.memories[MemoryTypePreference] = make(map[string]*Memory) // Load from disk if cfg.DataDir != "" { if err := store.loadFromDisk(); err != nil { log.Warn().Err(err).Msg("Failed to load context store from disk") } else { total := len(store.resourceMemories) + len(store.incidentMemories) + len(store.patternMemories) if total > 0 { log.Info().Int("total_memories", total).Msg("Loaded context store from disk") } } } return store } // Remember stores a new memory func (s *ContextStore) Remember(resourceID, content, source string, memType MemoryType, tags ...string) string { s.mu.Lock() memory := &Memory{ ID: generateMemoryID(), Type: memType, ResourceID: resourceID, Content: content, Source: source, CreatedAt: time.Now(), LastUsed: time.Now(), UseCount: 1, Relevance: 1.0, Tags: tags, } if _, ok := s.memories[memType]; !ok { s.memories[memType] = make(map[string]*Memory) } s.memories[memType][memory.ID] = memory // Also add to resource-specific memory if resourceID != "" && (memType == MemoryTypeResource || memType == MemoryTypePattern) { s.addResourceNoteLocked(resourceID, content) } s.dirty = true memoryID := memory.ID s.mu.Unlock() go s.saveIfDirty() log.Debug(). Str("memory_id", memoryID). Str("type", string(memType)). Str("resource", resourceID). Msg("Stored new memory") return memoryID } // addResourceNoteLocked adds a note to a resource's memory (must hold lock) func (s *ContextStore) addResourceNoteLocked(resourceID, note string) { if resourceID == "" || note == "" { return } mem, ok := s.resourceMemories[resourceID] if !ok { mem = &ResourceMemory{ ResourceID: resourceID, Notes: make([]string, 0), } s.resourceMemories[resourceID] = mem } // Check for duplicate for _, existing := range mem.Notes { if existing == note { return } } mem.Notes = append(mem.Notes, note) mem.LastUpdated = time.Now() // Trim to max notes if len(mem.Notes) > s.config.MaxResourceNotes { mem.Notes = mem.Notes[len(mem.Notes)-s.config.MaxResourceNotes:] } } // AddResourceNote adds a note about a specific resource func (s *ContextStore) AddResourceNote(resourceID, resourceName, resourceType, note string) { s.mu.Lock() s.addResourceNoteLocked(resourceID, note) // Update name and type if provided if mem, ok := s.resourceMemories[resourceID]; ok { if resourceName != "" { mem.ResourceName = resourceName } if resourceType != "" { mem.ResourceType = resourceType } } s.dirty = true s.mu.Unlock() go s.saveIfDirty() } // AddIncidentMemory stores a learning from an incident func (s *ContextStore) AddIncidentMemory(incident *IncidentMemory) { s.mu.Lock() if incident.ID == "" { incident.ID = generateIncidentMemoryID() } if incident.Timestamp.IsZero() { incident.Timestamp = time.Now() } s.incidentMemories[incident.ID] = incident // Also create a general memory content := incident.Summary if incident.RootCause != "" { content += " Root cause: " + incident.RootCause } if incident.Resolution != "" { content += " Resolution: " + incident.Resolution } memory := &Memory{ ID: generateMemoryID(), Type: MemoryTypeIncident, ResourceID: incident.ResourceID, Content: content, Source: "system", CreatedAt: incident.Timestamp, LastUsed: time.Now(), UseCount: 1, Relevance: 1.0, } s.memories[MemoryTypeIncident][memory.ID] = memory s.dirty = true s.mu.Unlock() go s.saveIfDirty() } // AddPatternMemory stores a learned pattern func (s *ContextStore) AddPatternMemory(pattern *PatternMemory) { s.mu.Lock() if pattern.ID == "" { pattern.ID = generatePatternMemoryID() } // Check if similar pattern exists for _, existing := range s.patternMemories { if existing.Pattern == pattern.Pattern { existing.Occurrences++ existing.LastSeen = time.Now() existing.Confidence = calculatePatternConfidence(existing.Occurrences) s.dirty = true s.mu.Unlock() go s.saveIfDirty() return } } pattern.LastSeen = time.Now() pattern.Confidence = calculatePatternConfidence(pattern.Occurrences) s.patternMemories[pattern.ID] = pattern s.dirty = true s.mu.Unlock() go s.saveIfDirty() } // Recall retrieves memories relevant to a resource func (s *ContextStore) Recall(resourceID string) []Memory { s.mu.Lock() defer s.mu.Unlock() var result []Memory // Get resource-specific memories for _, mem := range s.memories[MemoryTypeResource] { if mem.ResourceID == resourceID { s.markUsedLocked(mem) result = append(result, *mem) } } // Get incident memories for _, mem := range s.memories[MemoryTypeIncident] { if mem.ResourceID == resourceID { s.markUsedLocked(mem) result = append(result, *mem) } } // Get pattern memories related to resource for _, mem := range s.memories[MemoryTypePattern] { if mem.ResourceID == resourceID { s.markUsedLocked(mem) result = append(result, *mem) } } // Sort by relevance sort.Slice(result, func(i, j int) bool { return result[i].Relevance > result[j].Relevance }) return result } // RecallByType retrieves memories of a specific type func (s *ContextStore) RecallByType(memType MemoryType, limit int) []Memory { s.mu.Lock() defer s.mu.Unlock() var result []Memory if memories, ok := s.memories[memType]; ok { for _, mem := range memories { s.markUsedLocked(mem) result = append(result, *mem) } } // Sort by relevance sort.Slice(result, func(i, j int) bool { return result[i].Relevance > result[j].Relevance }) if limit > 0 && len(result) > limit { result = result[:limit] } return result } // GetResourceMemory returns the memory for a specific resource func (s *ContextStore) GetResourceMemory(resourceID string) *ResourceMemory { s.mu.RLock() defer s.mu.RUnlock() if mem, ok := s.resourceMemories[resourceID]; ok { copy := *mem return © } return nil } // GetRecentIncidents returns recent incident memories func (s *ContextStore) GetRecentIncidents(limit int) []*IncidentMemory { s.mu.RLock() defer s.mu.RUnlock() return s.getRecentIncidentsLocked(limit) } // getRecentIncidentsLocked retrieves recent incidents without acquiring the lock (caller must hold it). func (s *ContextStore) getRecentIncidentsLocked(limit int) []*IncidentMemory { var result []*IncidentMemory for _, incident := range s.incidentMemories { copy := *incident result = append(result, ©) } // Sort by timestamp (most recent first) sort.Slice(result, func(i, j int) bool { return result[i].Timestamp.After(result[j].Timestamp) }) if limit > 0 && len(result) > limit { result = result[:limit] } return result } // GetPatterns returns learned patterns above confidence threshold func (s *ContextStore) GetPatterns(minConfidence float64) []*PatternMemory { s.mu.RLock() defer s.mu.RUnlock() return s.getPatternsLocked(minConfidence) } // getPatternsLocked retrieves patterns without acquiring the lock (caller must hold it). func (s *ContextStore) getPatternsLocked(minConfidence float64) []*PatternMemory { var result []*PatternMemory for _, pattern := range s.patternMemories { if pattern.Confidence >= minConfidence { copy := *pattern result = append(result, ©) } } // Sort by confidence sort.Slice(result, func(i, j int) bool { return result[i].Confidence > result[j].Confidence }) return result } // markUsedLocked updates usage stats for a memory (must hold lock) func (s *ContextStore) markUsedLocked(mem *Memory) { mem.LastUsed = time.Now() mem.UseCount++ // Boost relevance slightly on use mem.Relevance = minF(1.0, mem.Relevance+0.1) s.dirty = true } // DecayRelevance applies time-based decay to memory relevance func (s *ContextStore) DecayRelevance() { s.mu.Lock() defer s.mu.Unlock() now := time.Now() decayStart := time.Duration(s.config.RelevanceDecayDays) * 24 * time.Hour for _, memories := range s.memories { for _, mem := range memories { age := now.Sub(mem.LastUsed) if age > decayStart { // Decay by 10% per week after decay starts weeks := (age - decayStart).Hours() / (24 * 7) decay := 0.1 * weeks mem.Relevance = maxF(0.1, mem.Relevance-decay) } } } s.dirty = true } // Cleanup removes old and low-relevance memories func (s *ContextStore) Cleanup() int { s.mu.Lock() removed := 0 cutoff := time.Now().AddDate(0, 0, -s.config.RetentionDays) minRelevance := 0.1 for memType, memories := range s.memories { for id, mem := range memories { if mem.CreatedAt.Before(cutoff) || mem.Relevance < minRelevance { delete(s.memories[memType], id) removed++ } } // Trim to max per type if len(memories) > s.config.MaxMemoriesPerType { // Convert to slice and sort by relevance var memList []*Memory for _, m := range memories { memList = append(memList, m) } sort.Slice(memList, func(i, j int) bool { return memList[i].Relevance > memList[j].Relevance }) // Keep only top memories s.memories[memType] = make(map[string]*Memory) for i := 0; i < s.config.MaxMemoriesPerType && i < len(memList); i++ { s.memories[memType][memList[i].ID] = memList[i] } removed += len(memList) - s.config.MaxMemoriesPerType } } needsSave := removed > 0 if needsSave { s.dirty = true } s.mu.Unlock() if needsSave { go s.saveIfDirty() } return removed } // FormatForPatrol formats context for patrol prompt injection func (s *ContextStore) FormatForPatrol() string { s.mu.RLock() defer s.mu.RUnlock() var result string // Add resource notes if len(s.resourceMemories) > 0 { result += "\n## Resource Notes\n" result += "Notes about specific resources from previous observations:\n\n" count := 0 for _, mem := range s.resourceMemories { if count >= 10 { // Limit for context size break } if len(mem.Notes) > 0 { name := mem.ResourceName if name == "" { name = mem.ResourceID } result += fmt.Sprintf("### %s\n", name) for _, note := range mem.Notes { result += "- " + note + "\n" } count++ } } } // Add recent incidents incidents := s.getRecentIncidentsLocked(5) if len(incidents) > 0 { result += "\n## Recent Incidents\n" result += "Past incidents that may be relevant:\n\n" for _, incident := range incidents { result += fmt.Sprintf("- %s: %s", incident.Timestamp.Format("2006-01-02"), incident.Summary) if incident.RootCause != "" { result += fmt.Sprintf(" (Root cause: %s)", incident.RootCause) } result += "\n" } } // Add learned patterns patterns := s.getPatternsLocked(0.5) if len(patterns) > 0 { result += "\n## Learned Patterns\n" result += "Operational patterns observed over time:\n\n" for _, pattern := range patterns { if len(patterns) > 5 { break } result += fmt.Sprintf("- %s (%.0f%% confidence)\n", pattern.Description, pattern.Confidence*100) } } return result } // FormatForResource formats context for a specific resource func (s *ContextStore) FormatForResource(resourceID string) string { s.mu.RLock() defer s.mu.RUnlock() mem, ok := s.resourceMemories[resourceID] if !ok || len(mem.Notes) == 0 { return "" } result := "\n## Resource Memory\n" result += fmt.Sprintf("Notes about %s:\n", mem.ResourceName) for _, note := range mem.Notes { result += "- " + note + "\n" } // Add patterns if any if len(mem.Patterns) > 0 { result += "\nObserved patterns:\n" for _, pattern := range mem.Patterns { result += "- " + pattern + "\n" } } return result } // saveIfDirty saves to disk if there are changes func (s *ContextStore) saveIfDirty() { s.mu.Lock() if !s.dirty || s.dataDir == "" { s.mu.Unlock() return } s.dirty = false s.mu.Unlock() if err := s.saveToDisk(); err != nil { log.Warn().Err(err).Msg("Failed to save context store") s.mu.Lock() s.dirty = true s.mu.Unlock() } } // saveToDisk persists data func (s *ContextStore) saveToDisk() error { if s.dataDir == "" { return nil } s.saveMu.Lock() defer s.saveMu.Unlock() s.mu.RLock() data := struct { Memories map[MemoryType]map[string]*Memory `json:"memories"` ResourceMemories map[string]*ResourceMemory `json:"resource_memories"` IncidentMemories map[string]*IncidentMemory `json:"incident_memories"` PatternMemories map[string]*PatternMemory `json:"pattern_memories"` }{ Memories: s.memories, ResourceMemories: s.resourceMemories, IncidentMemories: s.incidentMemories, PatternMemories: s.patternMemories, } jsonData, err := json.MarshalIndent(data, "", " ") s.mu.RUnlock() if err != nil { return err } if err := os.MkdirAll(s.dataDir, 0755); err != nil { return err } path := filepath.Join(s.dataDir, "ai_context.json") tmpPath := path + ".tmp" if err := os.WriteFile(tmpPath, jsonData, 0600); err != nil { return err } return os.Rename(tmpPath, path) } // loadFromDisk loads data func (s *ContextStore) loadFromDisk() error { if s.dataDir == "" { return nil } path := filepath.Join(s.dataDir, "ai_context.json") jsonData, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return nil } return err } var data struct { Memories map[MemoryType]map[string]*Memory `json:"memories"` ResourceMemories map[string]*ResourceMemory `json:"resource_memories"` IncidentMemories map[string]*IncidentMemory `json:"incident_memories"` PatternMemories map[string]*PatternMemory `json:"pattern_memories"` } if err := json.Unmarshal(jsonData, &data); err != nil { return err } if data.Memories != nil { s.memories = data.Memories } if data.ResourceMemories != nil { s.resourceMemories = data.ResourceMemories } if data.IncidentMemories != nil { s.incidentMemories = data.IncidentMemories } if data.PatternMemories != nil { s.patternMemories = data.PatternMemories } return nil } // ForceSave immediately saves to disk func (s *ContextStore) ForceSave() error { s.mu.Lock() s.dirty = false s.mu.Unlock() return s.saveToDisk() } // Helper functions var memoryCounter, incidentMemCounter, patternMemCounter atomic.Int64 func generateMemoryID() string { n := memoryCounter.Add(1) return fmt.Sprintf("mem-%s-%d", time.Now().Format("20060102150405"), n%1000) } func generateIncidentMemoryID() string { n := incidentMemCounter.Add(1) return fmt.Sprintf("inc-mem-%s-%d", time.Now().Format("20060102150405"), n%1000) } func generatePatternMemoryID() string { n := patternMemCounter.Add(1) return fmt.Sprintf("pat-mem-%s-%d", time.Now().Format("20060102150405"), n%1000) } func calculatePatternConfidence(occurrences int) float64 { // Logarithmic confidence growth if occurrences < 3 { return float64(occurrences) * 0.15 } confidence := 0.45 + 0.1*float64(occurrences-3) if confidence > 0.95 { confidence = 0.95 } return confidence } func minF(a, b float64) float64 { if a < b { return a } return b } func maxF(a, b float64) float64 { if a > b { return a } return b }