persona-community-3/pkg/synap/chat.go
jordan f53b908499
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/manual/woodpecker Pipeline was successful
Initialize project from skeleton template
2026-02-23 11:10:35 +00:00

361 lines
10 KiB
Go

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
}