package memory import ( "context" "encoding/json" "fmt" "log" "strings" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/providers" "git.gobha.me/xcaliber/chat-switchboard/roles" "git.gobha.me/xcaliber/chat-switchboard/store" ) // compactionThreshold is the minimum number of active memories before // compaction is considered worthwhile for a user. const compactionThreshold = 30 // compactionPrompt instructs the utility model to merge related memories. const compactionPrompt = `You are a memory compaction assistant. Given a set of user memories (key-value facts), merge related or redundant entries into fewer, more precise facts. Rules: - Combine facts about the same topic into a single entry - Remove duplicates, keeping the most specific/recent version - Preserve all unique information — do not drop facts - Keep keys short (2-5 words, lowercase) - Keep values concise (1-2 sentences max) - Set confidence based on how well-supported the merged fact is Respond ONLY with a JSON array of merged facts: [{"key": "...", "value": "...", "confidence": 0.0-1.0}] If no merging is possible, return the original facts unchanged.` // Compactor merges related memories to reduce count and improve quality. type Compactor struct { stores store.Stores roleResolver *roles.Resolver } // NewCompactor creates a memory compaction service. func NewCompactor(stores store.Stores, rr *roles.Resolver) *Compactor { return &Compactor{stores: stores, roleResolver: rr} } // CompactResult holds the outcome of a compaction run. type CompactResult struct { Before int `json:"before"` After int `json:"after"` Merged int `json:"merged"` Archived int `json:"archived"` } // Compact loads active memories for a user, sends them to the utility model // for merging, then upserts merged entries and archives originals. func (c *Compactor) Compact(ctx context.Context, userID string, teamID *string) (*CompactResult, error) { if c.stores.Memories == nil { return nil, fmt.Errorf("memory store not available") } // Load all active user-scope memories memories, err := c.stores.Memories.List(ctx, models.MemoryFilter{ Scope: models.MemoryScopeUser, OwnerID: userID, Status: models.MemoryStatusActive, Limit: 500, }) if err != nil { return nil, fmt.Errorf("list memories: %w", err) } if len(memories) < compactionThreshold { return &CompactResult{Before: len(memories), After: len(memories)}, nil } // Build memory text for the model var sb strings.Builder for i, m := range memories { sb.WriteString(fmt.Sprintf("%d. [%s] %s (confidence: %.2f)\n", i+1, m.Key, m.Value, m.Confidence)) } apiMessages := []providers.Message{ {Role: "system", Content: compactionPrompt}, {Role: "user", Content: fmt.Sprintf("Compact these %d memories:\n\n%s", len(memories), sb.String())}, } result, err := c.roleResolver.Complete(ctx, "utility", userID, teamID, apiMessages) if err != nil { return nil, fmt.Errorf("compaction completion: %w", err) } // Parse merged facts merged, err := parseExtractionResponse(result.Content) if err != nil { return nil, fmt.Errorf("parse compaction response: %w", err) } if len(merged) == 0 || len(merged) >= len(memories) { // Model returned nothing useful or didn't reduce — skip log.Printf("🧠 memory compaction: user %s — model returned %d facts (was %d), skipping", userID, len(merged), len(memories)) return &CompactResult{Before: len(memories), After: len(memories)}, nil } // Archive all original memories archived := 0 for _, m := range memories { if err := c.stores.Memories.Archive(ctx, m.ID); err == nil { archived++ } } // Upsert merged memories for _, fact := range merged { mem := &models.Memory{ Scope: models.MemoryScopeUser, OwnerID: userID, Key: fact.Key, Value: fact.Value, Confidence: fact.Confidence, Status: models.MemoryStatusActive, } if err := c.stores.Memories.Upsert(ctx, mem); err != nil { log.Printf("⚠ memory compaction upsert failed: key=%s: %v", fact.Key, err) } } log.Printf("🧠 memory compaction: user %s — %d → %d memories (archived %d)", userID, len(memories), len(merged), archived) return &CompactResult{ Before: len(memories), After: len(merged), Merged: len(merged), Archived: archived, }, nil } // parseCompactionResponse parses the model's JSON array response. // Reuses the same extractedFact struct from extractor.go. func parseCompactionResponse(content string) ([]extractedFact, error) { content = strings.TrimSpace(content) // Strip markdown code fences if present if strings.HasPrefix(content, "```") { lines := strings.Split(content, "\n") if len(lines) > 2 { content = strings.Join(lines[1:len(lines)-1], "\n") } } var facts []extractedFact if err := json.Unmarshal([]byte(content), &facts); err != nil { return nil, err } return facts, nil }