Skip to main content
Glama
orneryd

M.I.M.I.R - Multi-agent Intelligent Memory & Insight Repository

by orneryd
session.go12 kB
// Package temporal - Session detection via velocity changes. // // SessionDetector identifies user context switches by monitoring: // - Time gaps between accesses // - Sudden changes in access rate (velocity) // - Pattern breaks (different time-of-day) // // Sessions are important for: // - Co-access inference (nodes accessed in same session are related) // - Context-aware search (prioritize current session nodes) // - Memory consolidation (sessions = semantic boundaries) // // # ELI12 (Explain Like I'm 12) // // Think about how you use your phone. You might: // // 📱 Morning: Check weather → Check email → Check news (WORK SESSION) // ☕ Coffee break // 📱 Later: Instagram → TikTok → YouTube (FUN SESSION) // // The SessionDetector notices when you switch between "modes": // // How it detects session changes: // // 1. TIME GAP: If you stop for 5+ minutes, that's probably a new session // "You were looking at work stuff, went to lunch, now you're on games" // // 2. VELOCITY CHANGE (the Kalman filter magic!): // The filter tracks HOW FAST you're accessing things. // If velocity suddenly changes, your "mode" changed! // // Before: accessing every 2 seconds (fast browsing) // After: accessing every 30 seconds (reading something) // → "Whoa, you slowed WAY down - new session!" // // Why does this matter for memory? // // Session 1: [Weather, Email, News] → These are probably related (work stuff) // Session 2: [Instagram, TikTok] → These are probably related (fun stuff) // // So if you search for "weather", we boost Email and News because they were // accessed in the SAME SESSION. That's co-access inference! package temporal import ( "sync" "time" "github.com/orneryd/nornicdb/pkg/filter" ) // Session represents a detected user session. type Session struct { ID string StartTime time.Time EndTime time.Time NodeIDs []string // Nodes accessed in this session Duration time.Duration IsCurrent bool } // SessionEvent represents a session boundary event. type SessionEvent struct { Type SessionEventType Timestamp time.Time NodeID string OldRate float64 NewRate float64 Reason string } // SessionEventType represents types of session events. type SessionEventType string const ( SessionStart SessionEventType = "start" SessionEnd SessionEventType = "end" SessionContinue SessionEventType = "continue" ) // SessionDetectorConfig holds configuration for session detection. type SessionDetectorConfig struct { // TimeGapThresholdSeconds - gap that triggers new session TimeGapThresholdSeconds float64 // VelocityChangeThreshold - relative change that triggers session VelocityChangeThreshold float64 // MinSessionDurationSeconds - minimum duration to be a valid session MinSessionDurationSeconds float64 // MaxSessionDurationSeconds - maximum session duration (force break) MaxSessionDurationSeconds float64 // FilterConfig for velocity tracking FilterConfig filter.VelocityConfig } // DefaultSessionDetectorConfig returns sensible defaults. func DefaultSessionDetectorConfig() SessionDetectorConfig { return SessionDetectorConfig{ TimeGapThresholdSeconds: 300, // 5 minutes VelocityChangeThreshold: 0.5, // 50% change MinSessionDurationSeconds: 10, // 10 seconds MaxSessionDurationSeconds: 7200, // 2 hours FilterConfig: filter.TemporalTrackingConfig(), } } // SessionDetector detects session boundaries from access patterns. type SessionDetector struct { mu sync.RWMutex config SessionDetectorConfig // Per-node session tracking nodeSessions map[string]*nodeSessionData // Active sessions activeSessions map[string]*Session // Session history (last N sessions per node) sessionHistory map[string][]*Session // Event listeners listeners []func(SessionEvent) // Session ID counter sessionCounter int64 } // nodeSessionData tracks session state for a single node. type nodeSessionData struct { // Velocity filter for rate tracking velocityFilter *filter.KalmanVelocity // Last access time lastAccess time.Time // Current session currentSession *Session // Last velocity (for change detection) lastVelocity float64 // Total accesses totalAccesses int64 } // NewSessionDetector creates a new session detector. func NewSessionDetector(cfg SessionDetectorConfig) *SessionDetector { return &SessionDetector{ config: cfg, nodeSessions: make(map[string]*nodeSessionData), activeSessions: make(map[string]*Session), sessionHistory: make(map[string][]*Session), listeners: make([]func(SessionEvent), 0), } } // RecordAccess records an access and checks for session boundaries. func (sd *SessionDetector) RecordAccess(nodeID string, timestamp time.Time) SessionEvent { sd.mu.Lock() defer sd.mu.Unlock() data, exists := sd.nodeSessions[nodeID] if !exists { data = sd.createNodeSession(nodeID) sd.nodeSessions[nodeID] = data } event := sd.processAccess(nodeID, data, timestamp) // Notify listeners for _, listener := range sd.listeners { listener(event) } return event } // createNodeSession creates session tracking for a new node. func (sd *SessionDetector) createNodeSession(nodeID string) *nodeSessionData { return &nodeSessionData{ velocityFilter: filter.NewKalmanVelocity(sd.config.FilterConfig), } } // processAccess handles an access event for a node. func (sd *SessionDetector) processAccess(nodeID string, data *nodeSessionData, timestamp time.Time) SessionEvent { event := SessionEvent{ Timestamp: timestamp, NodeID: nodeID, } // First access for this node if data.totalAccesses == 0 { event.Type = SessionStart event.Reason = "first_access" data.currentSession = sd.createSession(nodeID, timestamp) data.lastAccess = timestamp data.totalAccesses = 1 return event } // Calculate time gap gap := timestamp.Sub(data.lastAccess).Seconds() // Update velocity filter accessRate := 1.0 / gap if gap < 0.001 { accessRate = 1000.0 } data.velocityFilter.Process(accessRate) currentVelocity := data.velocityFilter.Velocity() // Check for session boundary isNewSession := false reason := "" // Check 1: Time gap too large if gap > sd.config.TimeGapThresholdSeconds { isNewSession = true reason = "time_gap" } // Check 2: Velocity change too large if data.lastVelocity != 0 { velChange := (currentVelocity - data.lastVelocity) / data.lastVelocity if velChange > sd.config.VelocityChangeThreshold || velChange < -sd.config.VelocityChangeThreshold { isNewSession = true reason = "velocity_change" } } // Check 3: Session too long (force break) if data.currentSession != nil { sessionDuration := timestamp.Sub(data.currentSession.StartTime).Seconds() if sessionDuration > sd.config.MaxSessionDurationSeconds { isNewSession = true reason = "max_duration" } } // Handle session transition if isNewSession { // End current session if data.currentSession != nil { sd.endSession(nodeID, data, timestamp) } // Start new session event.Type = SessionStart event.Reason = reason event.OldRate = data.lastVelocity event.NewRate = currentVelocity data.currentSession = sd.createSession(nodeID, timestamp) } else { event.Type = SessionContinue event.Reason = "same_session" // Add node to current session if not already present if data.currentSession != nil { data.currentSession.EndTime = timestamp // Check if node is already in session found := false for _, id := range data.currentSession.NodeIDs { if id == nodeID { found = true break } } if !found { data.currentSession.NodeIDs = append(data.currentSession.NodeIDs, nodeID) } } } // Update state data.lastAccess = timestamp data.lastVelocity = currentVelocity data.totalAccesses++ return event } // createSession creates a new session. func (sd *SessionDetector) createSession(nodeID string, startTime time.Time) *Session { sd.sessionCounter++ session := &Session{ ID: nodeID + "-" + startTime.Format("20060102-150405"), StartTime: startTime, EndTime: startTime, NodeIDs: []string{nodeID}, IsCurrent: true, } sd.activeSessions[nodeID] = session return session } // endSession ends the current session for a node. func (sd *SessionDetector) endSession(nodeID string, data *nodeSessionData, endTime time.Time) { if data.currentSession == nil { return } data.currentSession.EndTime = endTime data.currentSession.Duration = endTime.Sub(data.currentSession.StartTime) data.currentSession.IsCurrent = false // Add to history history := sd.sessionHistory[nodeID] history = append(history, data.currentSession) // Keep last 50 sessions if len(history) > 50 { history = history[1:] } sd.sessionHistory[nodeID] = history // Remove from active delete(sd.activeSessions, nodeID) } // GetCurrentSession returns the current session for a node. func (sd *SessionDetector) GetCurrentSession(nodeID string) *Session { sd.mu.RLock() defer sd.mu.RUnlock() data, exists := sd.nodeSessions[nodeID] if !exists || data.currentSession == nil { return nil } // Create a copy session := *data.currentSession session.NodeIDs = make([]string, len(data.currentSession.NodeIDs)) copy(session.NodeIDs, data.currentSession.NodeIDs) return &session } // GetSessionHistory returns session history for a node. func (sd *SessionDetector) GetSessionHistory(nodeID string, limit int) []*Session { sd.mu.RLock() defer sd.mu.RUnlock() history := sd.sessionHistory[nodeID] if history == nil { return nil } // Return most recent first result := make([]*Session, 0, limit) for i := len(history) - 1; i >= 0 && len(result) < limit; i-- { session := *history[i] session.NodeIDs = make([]string, len(history[i].NodeIDs)) copy(session.NodeIDs, history[i].NodeIDs) result = append(result, &session) } return result } // GetActiveSessions returns all currently active sessions. func (sd *SessionDetector) GetActiveSessions() []*Session { sd.mu.RLock() defer sd.mu.RUnlock() result := make([]*Session, 0, len(sd.activeSessions)) for _, session := range sd.activeSessions { s := *session s.NodeIDs = make([]string, len(session.NodeIDs)) copy(s.NodeIDs, session.NodeIDs) result = append(result, &s) } return result } // GetCoAccessedNodes returns nodes accessed in the same session. func (sd *SessionDetector) GetCoAccessedNodes(nodeID string) []string { session := sd.GetCurrentSession(nodeID) if session == nil { return nil } // Return all nodes except the query node result := make([]string, 0, len(session.NodeIDs)-1) for _, id := range session.NodeIDs { if id != nodeID { result = append(result, id) } } return result } // IsSessionBoundary checks if the last access was a session boundary. func (sd *SessionDetector) IsSessionBoundary(nodeID string) bool { sd.mu.RLock() defer sd.mu.RUnlock() data, exists := sd.nodeSessions[nodeID] if !exists { return false } // Check if current session just started (within last second) if data.currentSession != nil { age := time.Since(data.currentSession.StartTime) return age < time.Second } return false } // GetVelocity returns the current access rate velocity for a node. func (sd *SessionDetector) GetVelocity(nodeID string) float64 { sd.mu.RLock() defer sd.mu.RUnlock() data, exists := sd.nodeSessions[nodeID] if !exists { return 0 } return data.velocityFilter.Velocity() } // AddListener adds a listener for session events. func (sd *SessionDetector) AddListener(listener func(SessionEvent)) { sd.mu.Lock() defer sd.mu.Unlock() sd.listeners = append(sd.listeners, listener) } // Reset clears all session data. func (sd *SessionDetector) Reset() { sd.mu.Lock() defer sd.mu.Unlock() sd.nodeSessions = make(map[string]*nodeSessionData) sd.activeSessions = make(map[string]*Session) sd.sessionHistory = make(map[string][]*Session) sd.sessionCounter = 0 }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/orneryd/Mimir'

If you have feedback or need assistance with the MCP directory API, please join our Discord server