361 lines
10 KiB
Go
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
|
|
}
|