package synap import ( "context" "fmt" "strings" "time" ) // ChatMessage represents a single chat message for context building. type ChatMessage struct { // ID is the message identifier ID string // Role is "user" or "agent" Role string // Content is the message text Content string // CreatedAt is when the message was sent CreatedAt time.Time // TokenCount estimates tokens in content (for 2000 token limit) TokenCount int } // ChatContext holds both recent messages and recalled memories for AI response generation. type ChatContext struct { // RecentMessages contains the last N messages (up to 2000 tokens) RecentMessages []ChatMessage // RecalledMemories contains relevant memories from Synap RecalledMemories []Memory // TotalTokens is the estimated token count for all content TotalTokens int // MemoryContext is a formatted string of recalled memories for prompt injection MemoryContext string } // BuildChatContext creates context for an AI response by combining recent messages // and recalled memories from Synap. // // This function: // 1. Includes the last 20 messages (or as many as fit in 2000 tokens) // 2. Recalls relevant memories from Synap based on the current message // 3. Formats everything into a ChatContext for prompt injection // // Example usage in peach-connection-worker: // // ctx := context.Background() // client, err := synap.NewClient("http://localhost:7432", &synap.Config{ // DefaultSpace: fmt.Sprintf("conversation_%s", conversationID), // }) // if err != nil { // log.Fatal(err) // } // // chatCtx, err := synap.BuildChatContext(ctx, client, &synap.BuildContextRequest{ // CurrentMessage: "What hobbies do you enjoy?", // RecentMessages: last20Messages, // MaxTokens: 2000, // RecallQuery: "hobbies interests activities enjoyment", // }) // // // Use chatCtx.MemoryContext in your Comm10 template variables // variables := map[string]string{ // "recalled_memories": chatCtx.MemoryContext, // "recent_context": formatMessages(chatCtx.RecentMessages), // } func BuildChatContext(ctx context.Context, client *Client, req *BuildContextRequest) (*ChatContext, error) { if req == nil { return nil, fmt.Errorf("request is required") } // 1. Select recent messages that fit within token budget recentMessages := selectRecentMessages(req.RecentMessages, req.MaxTokens) // 2. Recall relevant memories from Synap var recalledMemories []Memory var memoryContext string if req.RecallQuery != "" { recallResp, err := client.Recall(ctx, &RecallRequest{ Query: req.RecallQuery, Mode: RecallModeHybrid, MaxResults: req.MaxRecalledMemories, Threshold: req.RecallThreshold, }) if err != nil { // Log error but don't fail - proceed without recalled memories // This matches the timeout handling in the architecture docs client.logger.Warn("failed to recall memories, proceeding without context", "error", err, "query", req.RecallQuery) } else { // Combine all memory categories recalledMemories = append(recalledMemories, recallResp.Memories.Vivid...) recalledMemories = append(recalledMemories, recallResp.Memories.Associated...) // Format memories for prompt injection memoryContext = formatMemoriesForPrompt(recalledMemories) } } // 3. Calculate total tokens totalTokens := 0 for _, msg := range recentMessages { totalTokens += msg.TokenCount } // Add rough token count for memory context (4 chars ≈ 1 token) totalTokens += len(memoryContext) / 4 return &ChatContext{ RecentMessages: recentMessages, RecalledMemories: recalledMemories, TotalTokens: totalTokens, MemoryContext: memoryContext, }, nil } // BuildContextRequest specifies parameters for building chat context. type BuildContextRequest struct { // CurrentMessage is the user's current message (used for memory recall) CurrentMessage string // RecentMessages is the conversation history (newest last) // Should be the last 20-30 messages to allow token-based filtering RecentMessages []ChatMessage // MaxTokens is the maximum tokens for recent messages (default: 2000) // Memories from Synap are NOT included in this limit MaxTokens int // RecallQuery is the search query for Synap (optional) // If empty, no memory recall is performed // Typically this is the current message + keywords from recent context RecallQuery string // MaxRecalledMemories limits Synap recall results (default: 5) MaxRecalledMemories int // RecallThreshold is minimum confidence for recalled memories (default: 0.5) RecallThreshold float64 } // selectRecentMessages filters messages to fit within token budget. // // Returns messages in chronological order (oldest first), limited by token count. func selectRecentMessages(messages []ChatMessage, maxTokens int) []ChatMessage { if maxTokens == 0 { maxTokens = 2000 } // Start from newest messages and work backwards totalTokens := 0 cutoffIndex := 0 for i := len(messages) - 1; i >= 0; i-- { msgTokens := messages[i].TokenCount if msgTokens == 0 { // Estimate: ~4 characters per token msgTokens = len(messages[i].Content) / 4 } if totalTokens+msgTokens > maxTokens { cutoffIndex = i + 1 break } totalTokens += msgTokens } // Return selected messages in chronological order return messages[cutoffIndex:] } // formatMemoriesForPrompt converts Synap memories into a prompt-friendly format. // // Example output: // // "Previous relevant memories: // - (2 days ago) User mentioned they love hiking and photography // - (1 week ago) User discussed favorite travel destinations: Japan, Iceland // - (Related) User enjoys outdoor activities and nature" func formatMemoriesForPrompt(memories []Memory) string { if len(memories) == 0 { return "" } var parts []string parts = append(parts, "Previous relevant memories:") for _, mem := range memories { // Format time reference var timeRef string if !mem.ObservedAt.IsZero() { timeRef = formatTimeReference(mem.ObservedAt) } else { timeRef = "Previously" } // Get memory content content := mem.Content if mem.Episode != nil { content = mem.Episode.What } // Format with confidence indicator if medium/low confidenceIndicator := "" if mem.Confidence.Category == "Medium" || mem.Confidence.Category == "Low" { confidenceIndicator = " (uncertain)" } parts = append(parts, fmt.Sprintf("- (%s) %s%s", timeRef, content, confidenceIndicator)) } return strings.Join(parts, "\n") } // formatTimeReference converts a timestamp to human-readable relative time. // // Examples: "2 hours ago", "3 days ago", "2 weeks ago" func formatTimeReference(t time.Time) string { duration := time.Since(t) if duration < time.Hour { minutes := int(duration.Minutes()) if minutes == 1 { return "1 minute ago" } return fmt.Sprintf("%d minutes ago", minutes) } if duration < 24*time.Hour { hours := int(duration.Hours()) if hours == 1 { return "1 hour ago" } return fmt.Sprintf("%d hours ago", hours) } if duration < 7*24*time.Hour { days := int(duration.Hours() / 24) if days == 1 { return "1 day ago" } return fmt.Sprintf("%d days ago", days) } if duration < 30*24*time.Hour { weeks := int(duration.Hours() / (24 * 7)) if weeks == 1 { return "1 week ago" } return fmt.Sprintf("%d weeks ago", weeks) } months := int(duration.Hours() / (24 * 30)) if months == 1 { return "1 month ago" } return fmt.Sprintf("%d months ago", months) } // StoreConversationTurn stores both user and agent messages as episodic memories in Synap. // // This should be called by peach-connection-worker after successfully generating // and storing an agent response. // // Example usage: // // err := synap.StoreConversationTurn(ctx, client, &synap.ConversationTurn{ // UserMessage: "What are your favorite hobbies?", // AgentResponse: "I love photography and hiking in nature...", // Timestamp: time.Now(), // ConversationID: "conv_abc123", // UserID: "user_alice", // AgentID: "agent_sarah", // }) func StoreConversationTurn(ctx context.Context, client *Client, turn *ConversationTurn) error { if turn == nil { return fmt.Errorf("turn is required") } // Store user message as episode userEpisode := &Episode{ What: fmt.Sprintf("User said: %s", turn.UserMessage), When: turn.Timestamp, Where: "chat", Who: []string{turn.UserID, turn.AgentID}, Why: "conversation", Confidence: 0.9, Tags: []string{"user_message", "conversation"}, } userResp, err := client.RememberEpisode(ctx, userEpisode) if err != nil { return fmt.Errorf("store user message: %w", err) } client.logger.Debug("stored user message in synap", "memory_id", userResp.MemoryID, "conversation_id", turn.ConversationID) // Store agent response as episode agentEpisode := &Episode{ What: fmt.Sprintf("Agent responded: %s", turn.AgentResponse), When: turn.Timestamp, Where: "chat", Who: []string{turn.UserID, turn.AgentID}, Why: "conversation", Confidence: 0.9, Tags: []string{"agent_message", "conversation"}, } agentResp, err := client.RememberEpisode(ctx, agentEpisode) if err != nil { return fmt.Errorf("store agent response: %w", err) } client.logger.Debug("stored agent response in synap", "memory_id", agentResp.MemoryID, "conversation_id", turn.ConversationID) return nil } // ConversationTurn represents a single user-agent exchange for storage in Synap. type ConversationTurn struct { // UserMessage is what the user said UserMessage string // AgentResponse is what the agent replied AgentResponse string // Timestamp is when this exchange occurred Timestamp time.Time // ConversationID identifies the conversation (for logging only) ConversationID string // UserID identifies the user UserID string // AgentID identifies the agent AgentID string } // EstimateTokenCount provides a rough token count estimate for text. // // Uses the approximation: 1 token ≈ 4 characters (common for English text) // For more accuracy, integrate with tiktoken or your LLM's tokenizer. func EstimateTokenCount(text string) int { return len(text) / 4 }