package compaction import ( "context" "encoding/json" "fmt" "log" "strings" "time" "chat-switchboard/models" "chat-switchboard/providers" "chat-switchboard/roles" "chat-switchboard/store" "chat-switchboard/treepath" ) // ── Request / Result ──────────────────────── // CompactRequest specifies what to compact. type CompactRequest struct { ChannelID string UserID string // channel owner TeamID *string // for role resolution Trigger string // "manual" | "auto" } // CompactResult describes what compaction produced. type CompactResult struct { SummaryID string SummarizedCount int Model string UsedFallback bool Content string InputTokens int OutputTokens int } // ── Errors ────────────────────────────────── var ( // ErrContextBudget is returned when the conversation to summarize // exceeds the utility model's context window. ErrContextBudget = fmt.Errorf("conversation exceeds utility model context window") ) // ── Service ───────────────────────────────── // Service provides conversation compaction (summarization). // Used by both the HTTP handler (manual) and the background scanner (auto). type Service struct { stores store.Stores resolver *roles.Resolver } // NewService creates a compaction service. func NewService(stores store.Stores, resolver *roles.Resolver) *Service { return &Service{stores: stores, resolver: resolver} } // Resolver exposes the underlying role resolver (for external checks like // IsConfigured / IsPersonalOverride). func (s *Service) Resolver() *roles.Resolver { return s.resolver } // ── Compact ───────────────────────────────── // Compact summarizes the conversation in a channel, inserting a summary // tree node. Returns the result or an error. // // The method: // 1. Loads the active path via the message tree // 2. Finds the most recent summary boundary // 3. Collects post-boundary messages // 4. Checks the content fits the utility model's context window // 5. Calls the utility model role to generate a summary // 6. Inserts the summary as a tree node with metadata // 7. Updates the user's cursor // 8. Logs usage func (s *Service) Compact(ctx context.Context, req CompactRequest) (*CompactResult, error) { // ── Load active path ── path, err := treepath.GetActivePath(req.ChannelID, req.UserID) if err != nil { return nil, fmt.Errorf("load conversation: %w", err) } if len(path) < 4 { return nil, fmt.Errorf("conversation too short to summarize") } // Find existing summary boundary (if any) and only summarize after it startIdx := 0 for i := range path { if treepath.IsSummaryMessage(&path[i]) { startIdx = i + 1 } } // Build messages to summarize (skip system messages, skip previous summaries) var toSummarize []string messagesInScope := 0 var lastMessageID string for _, m := range path[startIdx:] { if m.Role == "system" || treepath.IsSummaryMessage(&m) { continue } toSummarize = append(toSummarize, fmt.Sprintf("%s: %s", m.Role, m.Content)) messagesInScope++ lastMessageID = m.ID } if messagesInScope < 4 { return nil, fmt.Errorf("not enough new messages since last summary") } // ── Build the summarization prompt ── conversationText := strings.Join(toSummarize, "\n\n") systemPrompt := `You are a conversation summarizer. Create a concise but comprehensive summary of the following conversation that preserves: - Key decisions and conclusions reached - Important facts, names, numbers, and technical details mentioned - Action items or commitments made - The overall context and topic flow Write the summary in a way that would allow the conversation to continue naturally. Use clear, factual language. Do not include meta-commentary about the summarization process.` userContent := "Summarize this conversation:\n\n" + conversationText // ── Context budget guard rail ── // Estimate whether the prompt fits the utility model's context window. // Prevents sending truncated input to small models (e.g. 32K utility // summarizing a 128K conversation). promptTokens := EstimateTokens(systemPrompt) + 4 + EstimateTokens(userContent) + 4 utilityBudget := s.getUtilityContextBudget(ctx, req.UserID, req.TeamID) // Reserve 20% for output (the summary itself) inputCeiling := int(float64(utilityBudget) * 0.80) if promptTokens > inputCeiling { log.Printf("⚠ Compaction skipped for channel %s: prompt ~%dK tokens exceeds utility model budget %dK (ceiling %dK)", req.ChannelID, promptTokens/1000, utilityBudget/1000, inputCeiling/1000) return nil, fmt.Errorf("%w: ~%d tokens needed, utility model allows ~%d", ErrContextBudget, promptTokens, inputCeiling) } summaryPrompt := []providers.Message{ {Role: "system", Content: systemPrompt}, {Role: "user", Content: userContent}, } // ── Call utility role ── log.Printf("📝 Compacting channel %s for user %s (%d messages, ~%dK tokens, trigger=%s)", req.ChannelID, req.UserID, messagesInScope, promptTokens/1000, req.Trigger) result, err := s.resolver.Complete(ctx, roles.RoleUtility, req.UserID, req.TeamID, summaryPrompt) if err != nil { log.Printf("⚠ Compaction failed for channel %s: %v", req.ChannelID, err) return nil, fmt.Errorf("summarization failed: %w", err) } // ── Log usage ── s.logUsage(ctx, req.ChannelID, req.UserID, req.Trigger, result) // ── Insert summary message as a tree node ── metadata := models.JSONMap{ "type": "summary", "summarized_until_id": lastMessageID, "summarized_count": messagesInScope, "utility_model": result.Model, "used_fallback": result.UsedFallback, "trigger": req.Trigger, } metaJSON, _ := json.Marshal(metadata) siblingIdx := treepath.NextSiblingIndex(req.ChannelID, &lastMessageID) summaryMsg := &models.Message{ ChannelID: req.ChannelID, ParentID: &lastMessageID, Role: "assistant", Content: result.Content, Model: result.Model, Metadata: models.JSONMap{}, SiblingIndex: siblingIdx, ParticipantType: "system", } _ = json.Unmarshal(metaJSON, &summaryMsg.Metadata) if err := s.stores.Messages.Create(ctx, summaryMsg); err != nil { log.Printf("⚠ Failed to persist summary for channel %s: %v", req.ChannelID, err) return nil, fmt.Errorf("failed to save summary: %w", err) } summaryMsgID := summaryMsg.ID // Update cursor to point to the summary node if err := treepath.UpdateCursor(req.ChannelID, req.UserID, summaryMsgID); err != nil { log.Printf("⚠ Failed to update cursor after compaction: %v", err) } log.Printf("✅ Compaction done for channel %s: %s (%d messages → %d chars, trigger=%s)", req.ChannelID, summaryMsgID, messagesInScope, len(result.Content), req.Trigger) return &CompactResult{ SummaryID: summaryMsgID, SummarizedCount: messagesInScope, Model: result.Model, UsedFallback: result.UsedFallback, Content: result.Content, InputTokens: result.InputTokens, OutputTokens: result.OutputTokens, }, nil } // ── Context Budget ────────────────────────── // getUtilityContextBudget resolves the utility model's context window. // Falls back to DefaultBudget if the model isn't in the catalog. func (s *Service) getUtilityContextBudget(ctx context.Context, userID string, teamID *string) int { cfg, err := s.resolver.GetConfig(ctx, roles.RoleUtility, userID, teamID) if err != nil || cfg == nil || cfg.Primary == nil { return DefaultBudget } // Look up the primary model in catalog entry, err := s.stores.Catalog.GetByModelID(ctx, cfg.Primary.ProviderConfigID, cfg.Primary.ModelID) if err == nil && entry.Capabilities.MaxContext > 0 { return entry.Capabilities.MaxContext } // Try any-provider lookup (model may be synced under a different config) entry, err = s.stores.Catalog.GetByModelIDAny(ctx, cfg.Primary.ModelID) if err == nil && entry.Capabilities.MaxContext > 0 { return entry.Capabilities.MaxContext } return DefaultBudget } // ── Rate Limit Check ──────────────────────── // CheckRateLimit checks the utility rate limit for org-funded calls. // Returns nil if within limits, an error with a descriptive message otherwise. func (s *Service) CheckRateLimit(ctx context.Context, userID string) error { // Load rate limit from global settings (default: 20/hour, 0 = unlimited) limit := 20 settings, err := s.stores.GlobalConfig.Get(ctx, "utility_rate_limit") if err == nil { if v, ok := settings["value"]; ok { switch n := v.(type) { case float64: limit = int(n) case int: limit = n } } } if limit <= 0 { return nil // unlimited } since := time.Now().Add(-1 * time.Hour) count, err := s.stores.Usage.CountRecentByRole(ctx, userID, roles.RoleUtility, since) if err != nil { log.Printf("⚠ Rate limit check failed: %v", err) return nil // fail open — don't block on DB errors } if count >= limit { return fmt.Errorf("rate limit exceeded: %d utility calls in the last hour (limit: %d)", count, limit) } return nil } // ── Helpers ───────────────────────────────── // GetUserTeamID returns the user's first team ID (for role resolution). func (s *Service) GetUserTeamID(ctx context.Context, userID string) *string { teamID, _ := s.stores.Teams.GetFirstTeamIDForUser(ctx, userID) if teamID == "" { return nil } return &teamID } func (s *Service) logUsage(ctx context.Context, channelID, userID, trigger string, result *roles.CompletionResult) { role := roles.RoleUtility entry := &models.UsageEntry{ ChannelID: &channelID, UserID: userID, ProviderConfigID: &result.ConfigID, ProviderScope: result.ProviderScope, ModelID: result.Model, Role: &role, PromptTokens: result.InputTokens, CompletionTokens: result.OutputTokens, CacheCreationTokens: result.CacheCreationTokens, CacheReadTokens: result.CacheReadTokens, } // Calculate cost from pricing pricing, err := s.stores.Pricing.GetForModel(ctx, result.ConfigID, result.Model) if err == nil && pricing != nil { if pricing.InputPerM != nil { costIn := float64(result.InputTokens) / 1_000_000 * *pricing.InputPerM entry.CostInput = &costIn } if pricing.OutputPerM != nil { costOut := float64(result.OutputTokens) / 1_000_000 * *pricing.OutputPerM entry.CostOutput = &costOut } } if err := s.stores.Usage.Log(ctx, entry); err != nil { log.Printf("⚠ Failed to log compaction usage: %v", err) } }